From f3024d6a6c0fbd50900ebe6a1b4f42e4e26ec557 Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 23 Jul 2026 18:14:11 -0700 Subject: [PATCH 01/35] test: define Codex cook history isolation contracts --- .../test_backend_capabilities_construction.py | 27 + .../test_backend_protocol_completeness.py | 45 + tests/arch/test_capability_consistency.py | 12 + tests/arch/test_capability_consumption.py | 11 + tests/arch/test_pyi_stub_completeness.py | 17 + tests/cli/AGENTS.md | 2 + tests/cli/test_cook_interactive.py | 1385 ++++++----------- tests/cli/test_cook_order_command.py | 32 - tests/cli/test_cook_process_lifecycle.py | 195 +++ tests/cli/test_cook_profile.py | 208 ++- tests/cli/test_cook_startup_observability.py | 411 +++++ tests/cli/test_init_helpers.py | 142 +- .../test_interactive_subprocess_contracts.py | 143 ++ tests/cli/test_reload_loop.py | 387 +++-- tests/cli/test_session_launch.py | 131 +- tests/cli/test_session_picker.py | 185 ++- tests/contracts/test_backend_compliance.py | 26 +- tests/contracts/test_backend_protocol.py | 40 + .../contracts/test_core_public_api_surface.py | 36 +- tests/contracts/test_protocol_satisfaction.py | 19 + tests/core/test_backend_capabilities.py | 14 +- tests/core/test_backend_dataclasses.py | 91 ++ tests/core/test_backend_protocols.py | 113 +- tests/core/test_type_protocol_shards.py | 36 + tests/core/test_types.py | 54 + .../backends/test_claude_session_locator.py | 106 +- tests/execution/backends/test_codex_config.py | 103 ++ .../backends/test_codex_config_validation.py | 146 ++ .../backends/test_codex_session_locator.py | 156 +- .../backends/test_codex_session_storage.py | 139 ++ .../test_coding_agent_backend_conformance.py | 21 +- .../test_composite_session_locator.py | 74 +- .../backends/test_validate_session_layout.py | 69 +- tests/execution/test_session_log_fields.py | 3 + tests/execution/test_smoke_codex.py | 6 + tests/hooks/test_codex_hooks.py | 52 + .../integration/test_codex_startup_canary.py | 225 +++ tests/server/test_lifespan.py | 46 +- tests/workspace/test_session_skills_codex.py | 447 +++++- .../workspace/test_session_skills_provider.py | 342 ++-- .../test_session_skills_stale_path.py | 78 +- 41 files changed, 4240 insertions(+), 1535 deletions(-) create mode 100644 tests/cli/test_cook_process_lifecycle.py create mode 100644 tests/cli/test_cook_startup_observability.py create mode 100644 tests/execution/backends/test_codex_config_validation.py create mode 100644 tests/execution/backends/test_codex_session_storage.py create mode 100644 tests/integration/test_codex_startup_canary.py diff --git a/tests/arch/test_backend_capabilities_construction.py b/tests/arch/test_backend_capabilities_construction.py index 6b3245cad0..60afab2024 100644 --- a/tests/arch/test_backend_capabilities_construction.py +++ b/tests/arch/test_backend_capabilities_construction.py @@ -85,3 +85,30 @@ def test_construction_sites_list_is_exhaustive() -> None: f"_CONSTRUCTION_SITES: {sorted(undeclared)}. " f"Add them to _CONSTRUCTION_SITES to ensure they are checked." ) + + +def test_backend_construction_sites_explicitly_select_hook_trust_policy() -> None: + from autoskillit.core import pkg_root + + expected_members = { + "core/types/_type_backend.py": "AUTOMATED", + "execution/backends/codex.py": "REVIEW_EACH_SESSION", + } + for relpath, expected_member in expected_members.items(): + tree = ast.parse((pkg_root() / relpath).read_text(encoding="utf-8")) + calls = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "BackendCapabilities" + ] + assert len(calls) == 1 + values = { + keyword.arg: keyword.value for keyword in calls[0].keywords if keyword.arg is not None + } + hook_policy = values["hook_trust_policy"] + assert isinstance(hook_policy, ast.Attribute) + assert isinstance(hook_policy.value, ast.Name) + assert hook_policy.value.id == "HookTrustPolicy" + assert hook_policy.attr == expected_member diff --git a/tests/arch/test_backend_protocol_completeness.py b/tests/arch/test_backend_protocol_completeness.py index 928804f4dd..02b953651c 100644 --- a/tests/arch/test_backend_protocol_completeness.py +++ b/tests/arch/test_backend_protocol_completeness.py @@ -122,6 +122,51 @@ def test_all_backends_implement_ensure_pre_launch(): ) +@pytest.mark.parametrize( + "method_name", + [ + "validate_interactive_invocation", + "recover_cook_history", + "cook_session_context", + ], +) +def test_coding_agent_backend_protocol_includes_cook_lifecycle_method( + method_name: str, +) -> None: + from autoskillit.core.types._type_protocols_backend import CodingAgentBackend + + assert callable(getattr(CodingAgentBackend, method_name, None)), ( + f"CodingAgentBackend protocol must define {method_name}" + ) + + +@pytest.mark.parametrize( + "method_name", + [ + "validate_interactive_invocation", + "recover_cook_history", + "cook_session_context", + ], +) +def test_all_backends_implement_cook_lifecycle_method(method_name: str) -> None: + from autoskillit.execution.backends import BACKEND_REGISTRY + + for name, cls in BACKEND_REGISTRY.items(): + assert callable(getattr(cls, method_name, None)), ( + f"{name} backend must implement {method_name}" + ) + + +def test_all_backend_locators_implement_list_sessions() -> None: + from autoskillit.execution.backends import BACKEND_REGISTRY + + for name, cls in BACKEND_REGISTRY.items(): + locator = cls().session_locator() + assert callable(getattr(locator, "list_sessions", None)), ( + f"{name} session locator must implement list_sessions" + ) + + def test_coding_agent_backend_protocol_includes_translate_model(): from autoskillit.core.types._type_protocols_backend import CodingAgentBackend diff --git a/tests/arch/test_capability_consistency.py b/tests/arch/test_capability_consistency.py index f32ab0acba..5212bd7302 100644 --- a/tests/arch/test_capability_consistency.py +++ b/tests/arch/test_capability_consistency.py @@ -145,3 +145,15 @@ def test_symlinks_are_symlinks( ) if violations: pytest.xfail(f"Known bug: {violations} use shutil.copy2 instead of symlink_to") + + +def test_backend_hook_trust_policies_and_codex_history_links_are_explicit() -> None: + from autoskillit.core import HookTrustPolicy + + claude = BACKEND_REGISTRY["claude-code"]().capabilities + codex = BACKEND_REGISTRY["codex"]().capabilities + + assert claude.hook_trust_policy is HookTrustPolicy.AUTOMATED + assert codex.hook_trust_policy is HookTrustPolicy.REVIEW_EACH_SESSION + assert claude.session_dir_symlinks == frozenset() + assert codex.session_dir_symlinks == frozenset({"sessions", "archived_sessions"}) diff --git a/tests/arch/test_capability_consumption.py b/tests/arch/test_capability_consumption.py index 4a2f132d2b..403f666c76 100644 --- a/tests/arch/test_capability_consumption.py +++ b/tests/arch/test_capability_consumption.py @@ -108,6 +108,17 @@ def test_all_capability_fields_have_production_consumers(): ) +def test_hook_trust_policy_has_a_real_production_consumer() -> None: + from autoskillit.core import BackendCapabilities, paths + + field_names = frozenset(field.name for field in dataclasses.fields(BackendCapabilities)) + reads = _collect_attribute_reads(paths.pkg_root(), field_names) + assert "hook_trust_policy" not in _FORWARD_DECLARED + assert reads["hook_trust_policy"], ( + "hook_trust_policy must be translated at the interactive launch boundary" + ) + + def test_forward_declared_has_linked_issues(): """Every _FORWARD_DECLARED entry must have a positive issue number.""" invalid = { diff --git a/tests/arch/test_pyi_stub_completeness.py b/tests/arch/test_pyi_stub_completeness.py index 2e591a6a1d..12b6fb3104 100644 --- a/tests/arch/test_pyi_stub_completeness.py +++ b/tests/arch/test_pyi_stub_completeness.py @@ -19,6 +19,23 @@ _CORE_DIR = SRC_ROOT / "core" +def test_cook_lifecycle_contracts_are_explicitly_exported_by_stub() -> None: + pyi_path = _CORE_DIR / "__init__.pyi" + tree = ast.parse(pyi_path.read_text(encoding="utf-8"), filename=str(pyi_path)) + imported = { + alias.name + for node in tree.body + if isinstance(node, ast.ImportFrom) + for alias in node.names + } + assert { + "CookSessionHandle", + "HookTrustPolicy", + "ManagedSessionHome", + "SessionSummary", + } <= imported + + def test_pyi_stub_covers_submodule_public_symbols() -> None: """Every public symbol in a core submodule must appear in __init__.pyi.""" import autoskillit.core as core diff --git a/tests/cli/AGENTS.md b/tests/cli/AGENTS.md index 43bd490745..2393fc1e24 100644 --- a/tests/cli/AGENTS.md +++ b/tests/cli/AGENTS.md @@ -26,7 +26,9 @@ CLI command, subcommand, and interactive workflow tests. | `test_cook_order_command.py` | Tests: cook CLI order command — script validation, command building, env injection | | `test_cook_order_picker.py` | Tests: cook CLI order command — recipe picker, resume flows, session parsing | | `test_cook_order_prompt.py` | Tests: cook CLI order command — system prompt content, MCP prefix selection, ownership | +| `test_cook_process_lifecycle.py` | Tests for owned cook process groups, inherited descriptors, and spawn/reap callbacks | | `test_cook_profile.py` | Tests for --profile flag in cook command — env injection and validation | +| `test_cook_startup_observability.py` | Tests for Codex startup tracing, PTY observation, readiness, and timing budgets | | `test_cook_workspace.py` | Tests: cook CLI workspace init and clean commands | | `test_doctor.py` | Tests for CLI doctor command and related utilities | | `test_doctor_backend_guards.py` | Tests for doctor backend guard checks (stale MCP, MCP registered, process state, codex graduation) and run_doctor backend wiring | diff --git a/tests/cli/test_cook_interactive.py b/tests/cli/test_cook_interactive.py index 6c248bd10c..9c1e1de70e 100644 --- a/tests/cli/test_cook_interactive.py +++ b/tests/cli/test_cook_interactive.py @@ -3,982 +3,489 @@ from __future__ import annotations import shutil -import subprocess +from contextlib import contextmanager from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock import pytest from autoskillit import cli -from autoskillit.core import BackendConventions -from autoskillit.workspace.session_skills import DefaultSessionSkillManager +from autoskillit.core import ( + CODEX_COOK_RESERVED_ENV_VARS, + CODEX_STARTUP_TRACE_ENV_VAR, + LAUNCH_ID_ENV_VAR, + SESSION_TYPE_ENV_VAR, + BackendConventions, + CmdSpec, + CookSessionHandle, + EffectiveSkillCatalogAuthority, + HookTrustPolicy, + ManagedSessionHome, + NamedResume, + NoResume, + SkillProjectionContextAuthority, + ValidatedAddDir, +) pytestmark = [pytest.mark.layer("cli"), pytest.mark.medium] -class _ProjectionBackendStub: - """Minimum projection-facing contract shared by local backend doubles.""" - +class _Backend: name = "claude-code" conventions = BackendConventions() + capabilities = SimpleNamespace( + hook_trust_policy=HookTrustPolicy.AUTOMATED, + session_dir_persistent=False, + ) + + def __init__(self) -> None: + self.build_calls: list[dict[str, object]] = [] + self.context_calls: list[dict[str, object]] = [] + self.validated: list[CmdSpec] = [] + self.recover_count = 0 + + def binary_name(self) -> str: + return "claude" + + def recover_cook_history(self) -> None: + self.recover_count += 1 + + def session_locator(self) -> object: + return SimpleNamespace() + + def build_interactive_cmd(self, **kwargs: object) -> CmdSpec: + self.build_calls.append(kwargs) + command = ["claude", "--dangerously-skip-permissions"] + plugin_source = kwargs["plugin_source"] + plugin_dir = getattr(plugin_source, "plugin_dir", None) + if plugin_dir is not None: + command.extend(("--plugin-dir", str(plugin_dir))) + for add_dir in kwargs["add_dirs"]: # type: ignore[union-attr] + command.extend(("--add-dir", str(add_dir))) + return CmdSpec( + cmd=tuple(command), + env=dict(kwargs["env_extras"]), # type: ignore[arg-type] + ) - -class TestCookInteractive: - @pytest.fixture(autouse=True) - def _no_first_run(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr("autoskillit.cli._onboarding.is_first_run", lambda _: False) - monkeypatch.setattr("sys.stdin.isatty", lambda: True) - # cook() derives project_dir via the same git-toplevel helper the MCP - # server uses. These tests patch subprocess.run globally, which would - # otherwise intercept that git call too — pin the helper instead. - monkeypatch.setattr("autoskillit.cli.session._session_cook.resolve_project_dir", Path.cwd) - - # CH-1 - def test_cook_init_session_cook(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """cook calls init_session with cook_session=True.""" - captured: dict = {} - fake_skills_dir = tmp_path / "fake-skills" - fake_skills_dir.mkdir() - - def fake_init_session( - self, - session_id: str, - catalog, - projection_context, - ) -> Path: - captured["catalog"] = catalog - captured["projection_context"] = projection_context - return fake_skills_dir - - monkeypatch.setattr(DefaultSessionSkillManager, "init_session", fake_init_session) - monkeypatch.setattr(subprocess, "run", lambda *a, **kw: type("R", (), {"returncode": 0})()) - monkeypatch.setattr(shutil, "which", lambda x: "/usr/bin/claude") - monkeypatch.setattr("builtins.input", lambda _prompt="": "") - cli.cook() - assert captured["projection_context"].catalog == captured["catalog"] - - # CH-2 - def test_cook_launches_claude_add_dir( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - """cook passes --add-dir to the subprocess.""" - captured_cmd: list = [] - fake_skills_dir = tmp_path / "fake-skills-ch2" - fake_skills_dir.mkdir() - - def fake_init_session( - self, - session_id: str, - catalog, - projection_context, - ) -> Path: - return fake_skills_dir - - def fake_run(cmd, **kw): - captured_cmd.extend(cmd) - return type("R", (), {"returncode": 0})() - - monkeypatch.setattr(DefaultSessionSkillManager, "init_session", fake_init_session) - monkeypatch.setattr(subprocess, "run", fake_run) - monkeypatch.setattr(shutil, "which", lambda x: "/usr/bin/claude") - monkeypatch.setattr("builtins.input", lambda _prompt="": "") - cli.cook() - assert "--add-dir" in captured_cmd - assert str(fake_skills_dir) in captured_cmd - - # CH-3 - def test_cook_alias_c(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """'c' alias invokes the cook behavior via the CLI.""" - from autoskillit.cli.app import app - - captured_cmd: list = [] - fake_skills_dir = tmp_path / "fake-skills-ch3" - fake_skills_dir.mkdir() - - def fake_init_session( - self, - session_id: str, - catalog, - projection_context, - ) -> Path: - return fake_skills_dir - - def fake_run(cmd, **kw): - captured_cmd.extend(cmd) - return type("R", (), {"returncode": 0})() - - monkeypatch.setattr(DefaultSessionSkillManager, "init_session", fake_init_session) - monkeypatch.setattr(subprocess, "run", fake_run) - monkeypatch.setattr(shutil, "which", lambda x: "/usr/bin/claude") - monkeypatch.setattr("builtins.input", lambda _prompt="": "") - with pytest.raises(SystemExit) as exc_info: - app(["c"]) - assert exc_info.value.code == 0 - assert "--add-dir" in captured_cmd - - # CH-5 - def test_cook_exits_when_claude_not_on_path( - self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture - ) -> None: - """cook exits 1 with a message if claude is not on PATH.""" - monkeypatch.setattr(shutil, "which", lambda x: None) - with pytest.raises(SystemExit) as exc_info: - cli.cook() - assert exc_info.value.code == 1 - captured = capsys.readouterr() - assert "claude" in captured.out.lower() or "PATH" in captured.out - - # CH-6 - def test_cook_passes_plugin_dir(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """cook passes a sanitized plugin projection instead of the package root.""" - from unittest.mock import MagicMock, patch - - fake_skills_dir = tmp_path / "skills" - fake_skills_dir.mkdir() - mock_mgr = MagicMock() - mock_mgr.init_session.return_value = fake_skills_dir - - with ( - patch("shutil.which", return_value="/usr/bin/claude"), - patch("builtins.input", return_value=""), - patch("autoskillit.workspace.DefaultSessionSkillManager", return_value=mock_mgr), - patch("subprocess.run", return_value=MagicMock(returncode=0)) as mock_run, - ): - import autoskillit.cli.session._session_cook as module - - module.cook() - - mock_mgr.init_session.assert_called_once() - args = mock_run.call_args[0][0] - assert "--plugin-dir" in args - idx = args.index("--plugin-dir") - projected_plugin = Path(args[idx + 1]) - assert projected_plugin.is_dir() - assert projected_plugin.parent.name == "plugin-projections" - assert (projected_plugin / ".claude-plugin" / "plugin.json").is_file() - - # CH-7 - def test_cook_includes_dangerously_skip_permissions( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - """cook subprocess cmd includes --dangerously-skip-permissions (REQ-TIER-012).""" - from unittest.mock import MagicMock, patch - - fake_skills_dir = tmp_path / "skills" - fake_skills_dir.mkdir() - mock_mgr = MagicMock() - mock_mgr.init_session.return_value = fake_skills_dir - - with ( - patch("shutil.which", return_value="/usr/bin/claude"), - patch("builtins.input", return_value=""), - patch("autoskillit.workspace.DefaultSessionSkillManager", return_value=mock_mgr), - patch("subprocess.run", return_value=MagicMock(returncode=0)) as mock_run, - ): - import autoskillit.cli.session._session_cook as module - - module.cook() - - args = mock_run.call_args[0][0] - assert "--dangerously-skip-permissions" in args - - # CH-8 - def test_cook_calls_onboarding_menu_on_first_run( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - """When is_first_run() returns True, run_onboarding_menu is called once.""" - from unittest.mock import MagicMock, patch - - fake_skills_dir = tmp_path / "skills" - fake_skills_dir.mkdir() - mock_mgr = MagicMock() - mock_mgr.init_session.return_value = fake_skills_dir - menu_called: list[bool] = [] - - # Override the autouse fixture's patch with True for this test - monkeypatch.setattr("autoskillit.cli._onboarding.is_first_run", lambda _: True) - monkeypatch.setattr( - "autoskillit.cli._onboarding.run_onboarding_menu", - lambda *a, **kw: menu_called.append(True) or None, + def validate_interactive_invocation(self, spec: CmdSpec) -> list[str]: + self.validated.append(spec) + return [] + + @contextmanager + def cook_session_context(self, **kwargs: object): + self.context_calls.append(kwargs) + yield CookSessionHandle( + view_id="test-view", + pass_fds=(9,), + _record_spawn=lambda _pid, _pgid: None, + _record_reaped=lambda _pid, _pgid: None, ) - with ( - patch("shutil.which", return_value="/usr/bin/claude"), - patch("builtins.input", return_value=""), - patch("autoskillit.workspace.DefaultSessionSkillManager", return_value=mock_mgr), - patch("subprocess.run", return_value=MagicMock(returncode=0)), - ): - import autoskillit.cli.session._session_cook as module - module.cook() +def _install_harness( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + *, + first_run: bool = False, + onboarding_prompt: str | None = None, + confirm: str = "", + returncode: int = 0, + picked_session: str | None = None, +) -> dict[str, object]: + generated_home = tmp_path / "managed-home" + skills_dir = generated_home / "skills" + skills_dir.mkdir(parents=True) + manager = MagicMock() + events: list[tuple[object, ...]] = [] + captured: dict[str, object] = {"events": events, "manager": manager} + + @contextmanager + def managed_session( + launch_id: str, + catalog: EffectiveSkillCatalogAuthority, + projection_context: SkillProjectionContextAuthority, + ): + events.append(("managed-enter", launch_id, catalog, projection_context)) + assert projection_context.catalog == catalog + try: + yield ManagedSessionHome( + launch_id=launch_id, + generated_home=generated_home, + skills_dir=ValidatedAddDir(str(skills_dir)), + pass_fds=(7,), + ) + finally: + events.append(("managed-exit", launch_id)) + + manager.managed_session.side_effect = managed_session + + def run_attempt(spec: CmdSpec, **kwargs: object) -> object: + captured["spec"] = spec + captured["run_kwargs"] = kwargs + events.append(("run",)) + kwargs["on_spawn"](101, 101) # type: ignore[operator] + kwargs["trace"].record_spawn() # type: ignore[union-attr] + kwargs["on_reaped"](101, 101) # type: ignore[operator] + return SimpleNamespace(pid=101, pgid=101, returncode=returncode) + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(shutil, "which", lambda _name: "/usr/bin/claude") + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr( + "autoskillit.workspace.DefaultSessionSkillManager", + lambda *args, **kwargs: manager, + ) + monkeypatch.setattr( + "autoskillit.cli._installed_plugins.InstalledPluginsFile.contains", + lambda self, key: False, + ) + monkeypatch.setattr( + "autoskillit.cli._onboarding.is_first_run", + lambda _project: first_run, + ) + monkeypatch.setattr( + "autoskillit.cli._onboarding.run_onboarding_menu", + lambda *args, **kwargs: onboarding_prompt, + ) + monkeypatch.setattr( + "autoskillit.cli._onboarding.mark_onboarded", + lambda project: events.append(("onboarded", project)), + ) + monkeypatch.setattr( + "autoskillit.cli.ui._timed_input.timed_prompt", + lambda *args, **kwargs: confirm, + ) + monkeypatch.setattr( + "autoskillit.core.write_registry_entry", + lambda project, launch_id, session_type, session_id: events.append( + ("registry", launch_id) + ), + ) + monkeypatch.setattr( + "autoskillit.cli.session._session_process.run_cook_attempt", + run_attempt, + ) + monkeypatch.setattr( + "autoskillit.cli.session._session_reload.consume_reload_sentinel", + lambda _project: None, + ) + monkeypatch.setattr( + "autoskillit.cli.session._session_picker.pick_session", + lambda *args, **kwargs: picked_session, + ) + captured["generated_home"] = generated_home + captured["skills_dir"] = skills_dir + return captured + + +def test_cook_uses_managed_home_for_final_child_context( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + backend = _Backend() + captured = _install_harness(monkeypatch, tmp_path) + + cli.cook(backend=backend) + + generated_home = captured["generated_home"] + skills_dir = captured["skills_dir"] + build = backend.build_calls[0] + assert build["generated_home"] == generated_home + assert build["add_dirs"] == [ValidatedAddDir(str(skills_dir))] + assert build["resume_spec"] == NoResume() + assert backend.recover_count == 0 + assert backend.context_calls[0]["session_home"] == generated_home + assert backend.context_calls[0]["project_dir"] == tmp_path + spec = captured["spec"] + assert isinstance(spec, CmdSpec) + assert spec is backend.validated[0] + assert spec.cwd == str(tmp_path) + assert spec.env[SESSION_TYPE_ENV_VAR] == "skill" + assert len(spec.env[LAUNCH_ID_ENV_VAR]) == 16 + assert captured["run_kwargs"]["pass_fds"] == (7, 9) # type: ignore[index] + + +def test_cook_real_claude_builder_receives_plugin_and_skills( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + from autoskillit.execution.backends.claude import ClaudeCodeBackend + + captured = _install_harness(monkeypatch, tmp_path) + cli.cook(backend=ClaudeCodeBackend()) + + spec = captured["spec"] + assert isinstance(spec, CmdSpec) + assert "--plugin-dir" in spec.cmd + plugin_index = spec.cmd.index("--plugin-dir") + projected_plugin = Path(spec.cmd[plugin_index + 1]) + assert projected_plugin.is_dir() + assert projected_plugin.parent.name == "plugin-projections" + assert (projected_plugin / ".claude-plugin" / "plugin.json").is_file() + assert "--add-dir" in spec.cmd + assert str(captured["skills_dir"]) in spec.cmd + assert "--dangerously-skip-permissions" in spec.cmd + + +def test_cook_bare_resume_recovers_then_uses_picker( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + backend = _Backend() + _install_harness(monkeypatch, tmp_path, picked_session="thread-123") + + cli.cook(backend=backend, resume=True) + + assert backend.recover_count == 1 + assert backend.build_calls[0]["resume_spec"] == NamedResume("thread-123") + + +def test_cook_bare_resume_without_selection_starts_fresh( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + backend = _Backend() + _install_harness(monkeypatch, tmp_path) + + cli.cook(backend=backend, resume=True) + + assert backend.recover_count == 1 + assert backend.build_calls[0]["resume_spec"] == NoResume() + + +def test_cook_explicit_resume_does_not_run_recovery( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + backend = _Backend() + _install_harness(monkeypatch, tmp_path) + + cli.cook(backend=backend, session_id="thread-explicit") + + assert backend.recover_count == 0 + assert backend.build_calls[0]["resume_spec"] == NamedResume("thread-explicit") + + +def test_cook_marks_onboarded_only_after_success( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + backend = _Backend() + captured = _install_harness( + monkeypatch, + tmp_path, + first_run=True, + onboarding_prompt="start here", + ) + + cli.cook(backend=backend) + + assert backend.build_calls[0]["initial_prompt"] == "start here" + event_names = [event[0] for event in captured["events"]] + assert event_names.index("run") < event_names.index("onboarded") + + +def test_cook_does_not_mark_onboarded_without_prompt( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + backend = _Backend() + captured = _install_harness(monkeypatch, tmp_path, first_run=True) + + cli.cook(backend=backend) + + assert not any(event[0] == "onboarded" for event in captured["events"]) + + +def test_cook_nonzero_exit_propagates_after_managed_cleanup( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + backend = _Backend() + captured = _install_harness(monkeypatch, tmp_path, returncode=7) - assert menu_called == [True] + with pytest.raises(SystemExit, match="7"): + cli.cook(backend=backend) - # CH-9 - def test_cook_skips_onboarding_if_not_first_run( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - """When is_first_run() returns False, run_onboarding_menu is NOT called.""" - from unittest.mock import MagicMock, patch + assert captured["events"][-1][0] == "managed-exit" - fake_skills_dir = tmp_path / "skills" - fake_skills_dir.mkdir() - mock_mgr = MagicMock() - mock_mgr.init_session.return_value = fake_skills_dir - menu_called: list[bool] = [] - monkeypatch.setattr( - "autoskillit.cli._onboarding.run_onboarding_menu", - lambda *a, **kw: menu_called.append(True) or None, - ) +def test_cook_resolves_default_backend(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + backend = _Backend() + _install_harness(monkeypatch, tmp_path) + requested: list[str] = [] + monkeypatch.setattr( + "autoskillit.execution.get_backend", + lambda name: requested.append(name) or backend, + ) - with ( - patch("shutil.which", return_value="/usr/bin/claude"), - patch("builtins.input", return_value=""), - patch("autoskillit.workspace.DefaultSessionSkillManager", return_value=mock_mgr), - patch("subprocess.run", return_value=MagicMock(returncode=0)), - ): - import autoskillit.cli.session._session_cook as module + cli.cook() - module.cook() + assert requested + assert backend.build_calls - assert menu_called == [] - # CH-10 - def test_cook_forwards_initial_prompt_to_build_cmd( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - """When run_onboarding_menu returns a non-None string, it appears in subprocess cmd.""" - from unittest.mock import MagicMock, patch +def test_cook_missing_backend_binary_exits( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + backend = _Backend() + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(shutil, "which", lambda _name: None) - fake_skills_dir = tmp_path / "skills" - fake_skills_dir.mkdir() - mock_mgr = MagicMock() - mock_mgr.init_session.return_value = fake_skills_dir - prompt_text = "/autoskillit:setup-project" + with pytest.raises(SystemExit, match="1"): + cli.cook(backend=backend) - monkeypatch.setattr("autoskillit.cli._onboarding.is_first_run", lambda _: True) - monkeypatch.setattr( - "autoskillit.cli._onboarding.run_onboarding_menu", - lambda *a, **kw: prompt_text, - ) + assert "not found" in capsys.readouterr().out - with ( - patch("shutil.which", return_value="/usr/bin/claude"), - patch("builtins.input", return_value=""), - patch("autoskillit.workspace.DefaultSessionSkillManager", return_value=mock_mgr), - patch("subprocess.run", return_value=MagicMock(returncode=0)) as mock_run, - ): - import autoskillit.cli.session._session_cook as module - - module.cook() - - args = mock_run.call_args[0][0] - assert prompt_text in args - - # CH-11 - def test_cook_marks_onboarded_in_finally_when_prompt_set( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - """When run_onboarding_menu returns non-None and session completes, mark called.""" - from unittest.mock import MagicMock, patch - - fake_skills_dir = tmp_path / "skills" - fake_skills_dir.mkdir() - mock_mgr = MagicMock() - mock_mgr.init_session.return_value = fake_skills_dir - marked: list[bool] = [] - - monkeypatch.setattr("autoskillit.cli._onboarding.is_first_run", lambda _: True) - monkeypatch.setattr( - "autoskillit.cli._onboarding.run_onboarding_menu", - lambda *a, **kw: "/autoskillit:setup-project", - ) - monkeypatch.setattr( - "autoskillit.cli._onboarding.mark_onboarded", - lambda _p: marked.append(True), + +def test_cook_final_confirmation_precedes_registry_and_attempt( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A declined final prompt must not leave a registry row or enter an attempt.""" + from autoskillit.core import ( + CmdSpec, + CookSessionHandle, + HookTrustPolicy, + ManagedSessionHome, + ValidatedAddDir, + ) + + events: list[tuple[object, ...]] = [] + generated_home = tmp_path / "managed-home" + skills_dir = generated_home / "skills" + skills_dir.mkdir(parents=True) + manager = MagicMock() + + @contextmanager + def managed_session( + launch_id: str, + catalog: EffectiveSkillCatalogAuthority, + projection_context: SkillProjectionContextAuthority, + ): + assert projection_context.catalog == catalog + events.append(("managed-enter", launch_id)) + try: + yield ManagedSessionHome( + launch_id=launch_id, + generated_home=generated_home, + skills_dir=ValidatedAddDir(str(skills_dir)), + pass_fds=(), + ) + finally: + events.append(("managed-exit", launch_id)) + + manager.managed_session.side_effect = managed_session + + class _Backend: + name = "claude-code" + conventions = BackendConventions() + capabilities = SimpleNamespace( + hook_trust_policy=HookTrustPolicy.AUTOMATED, + session_dir_persistent=False, ) - with ( - patch("shutil.which", return_value="/usr/bin/claude"), - patch("builtins.input", return_value=""), - patch("autoskillit.workspace.DefaultSessionSkillManager", return_value=mock_mgr), - patch("subprocess.run", return_value=MagicMock(returncode=0)), - ): - import autoskillit.cli.session._session_cook as module + def binary_name(self) -> str: + return "claude" - module.cook() + def recover_cook_history(self) -> None: + events.append(("recover",)) - assert marked == [True] + def build_interactive_cmd(self, **kwargs: object) -> CmdSpec: + events.append(("build",)) + return CmdSpec(cmd=("claude",), env={}) - # CH-12 - def test_cook_does_not_mark_onboarded_when_prompt_is_none( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - """When run_onboarding_menu returns None, mark_onboarded is NOT called from finally.""" - from unittest.mock import MagicMock, patch + def validate_interactive_invocation(self, spec: CmdSpec) -> list[str]: + events.append(("validate", spec)) + return [] - fake_skills_dir = tmp_path / "skills" - fake_skills_dir.mkdir() - mock_mgr = MagicMock() - mock_mgr.init_session.return_value = fake_skills_dir - marked: list[bool] = [] + @contextmanager + def cook_session_context(self, **kwargs: object): + events.append(("attempt-enter",)) + yield CookSessionHandle( + view_id="view-1", + pass_fds=(), + _record_spawn=lambda _pid, _pgid: None, + _record_reaped=lambda _pid, _pgid: None, + ) - monkeypatch.setattr("autoskillit.cli._onboarding.is_first_run", lambda _: True) + def run_once(answer: str) -> None: + events.clear() monkeypatch.setattr( - "autoskillit.cli._onboarding.run_onboarding_menu", - lambda *a, **kw: None, + "autoskillit.cli.ui._timed_input.timed_prompt", + lambda *args, **kwargs: events.append(("confirm", answer)) or answer, ) monkeypatch.setattr( - "autoskillit.cli._onboarding.mark_onboarded", - lambda _p: marked.append(True), - ) - - with ( - patch("shutil.which", return_value="/usr/bin/claude"), - patch("builtins.input", return_value=""), - patch("autoskillit.workspace.DefaultSessionSkillManager", return_value=mock_mgr), - patch("subprocess.run", return_value=MagicMock(returncode=0)), - ): - import autoskillit.cli.session._session_cook as module - - module.cook() - - assert marked == [] - - # REQ-EPH-001 - def test_cook_does_not_rmtree_skills_dir(self, monkeypatch, tmp_path): - """shutil.rmtree must NOT be called on skills_dir after session exits.""" - from unittest.mock import MagicMock, patch - - fake_skills_dir = tmp_path / "skills" - fake_skills_dir.mkdir() - mock_mgr = MagicMock() - mock_mgr.init_session.return_value = fake_skills_dir - - rmtree_calls = [] - original_rmtree = shutil.rmtree - - def tracking_rmtree(path, **kw): - if Path(str(path)) == fake_skills_dir: - rmtree_calls.append(path) - else: - original_rmtree(path, **kw) - - with ( - patch("shutil.which", return_value="/usr/bin/claude"), - patch("builtins.input", return_value=""), - patch("autoskillit.workspace.DefaultSessionSkillManager", return_value=mock_mgr), - patch("subprocess.run", return_value=MagicMock(returncode=0)), - patch("shutil.rmtree", side_effect=tracking_rmtree), - ): - import autoskillit.cli.session._session_cook as module - - module.cook() - - assert not rmtree_calls, "cook() must not rmtree skills_dir on exit" - - # REQ-CLI-001 + REQ-CLI-002 - def test_cook_resume_bare_flag_produces_bare_resume_and_skips_discovery( - self, monkeypatch, tmp_path - ): - """cook(resume=True) invokes picker; UUID from picker passed as --resume; no discovery.""" - from unittest.mock import MagicMock, patch - - fake_skills_dir = tmp_path / "skills" - fake_skills_dir.mkdir() - mock_mgr = MagicMock() - mock_mgr.init_session.return_value = fake_skills_dir - discovery_calls: list = [] - - with ( - patch("shutil.which", return_value="/usr/bin/claude"), - patch("builtins.input", return_value=""), - patch("autoskillit.workspace.DefaultSessionSkillManager", return_value=mock_mgr), - patch("subprocess.run", return_value=MagicMock(returncode=0)) as mock_run, - patch( - "autoskillit.core.find_latest_session_id", - side_effect=lambda *a, **kw: discovery_calls.append(1) or "latest", - ), - patch( - "autoskillit.cli.session._session_picker.pick_session", - return_value="picker-uuid-abc", + "autoskillit.core.write_registry_entry", + lambda project, launch_id, session_type, claude_id: events.append( + ("registry", launch_id) ), - ): - import autoskillit.cli.session._session_cook as module - - module.cook(resume=True) - - args = mock_run.call_args[0][0] - assert "--resume" in args - idx = args.index("--resume") - assert args[idx + 1] == "picker-uuid-abc", ( - "--resume must be followed by the UUID returned by pick_session" ) - assert not discovery_calls, "find_latest_session_id must not be called for bare --resume" - - # REQ-CLI-002 - def test_cook_resume_explicit_session_id(self, monkeypatch, tmp_path): - """cook(resume=True, session_id='abc') uses the explicit id, skips discovery.""" - from unittest.mock import MagicMock, patch - - fake_skills_dir = tmp_path / "skills" - fake_skills_dir.mkdir() - mock_mgr = MagicMock() - mock_mgr.init_session.return_value = fake_skills_dir - discovery_calls = [] - - def fake_discover(cwd=None): - discovery_calls.append(cwd) - return "should-not-be-used" - - with ( - patch("shutil.which", return_value="/usr/bin/claude"), - patch("builtins.input", return_value=""), - patch("autoskillit.workspace.DefaultSessionSkillManager", return_value=mock_mgr), - patch("subprocess.run", return_value=MagicMock(returncode=0)) as mock_run, - patch("autoskillit.core.find_latest_session_id", side_effect=fake_discover), - ): - import autoskillit.cli.session._session_cook as module - - module.cook(resume=True, session_id="explicit-abc") - - args = mock_run.call_args[0][0] - assert "--resume" in args - assert args[args.index("--resume") + 1] == "explicit-abc" - assert not discovery_calls, "discovery must not be called when session_id is explicit" - - # REQ-CLI-002 — when picker returns None (no sessions), cook launches a fresh session - def test_cook_resume_bare_flag_no_sessions_starts_fresh(self, monkeypatch, tmp_path): - """cook(resume=True) with picker returning None starts a fresh session (no --resume).""" - from unittest.mock import MagicMock, patch - - fake_skills_dir = tmp_path / "skills" - fake_skills_dir.mkdir() - mock_mgr = MagicMock() - mock_mgr.init_session.return_value = fake_skills_dir - - with ( - patch("shutil.which", return_value="/usr/bin/claude"), - patch("builtins.input", return_value=""), - patch("autoskillit.workspace.DefaultSessionSkillManager", return_value=mock_mgr), - patch("subprocess.run", return_value=MagicMock(returncode=0)) as mock_run, - patch("autoskillit.cli.session._session_picker.pick_session", return_value=None), - ): - import autoskillit.cli.session._session_cook as module - - module.cook(resume=True) - - args = mock_run.call_args[0][0] - assert "--resume" not in args, ( - "when picker returns None (no sessions), cook must launch fresh (no --resume)" - ) - - # REQ-CLI-003 - def test_cook_cmd_resume_with_session_id_no_error( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """cook --resume must not raise UnusedCliTokensError — REQ-CLI-003.""" - import sys - from unittest.mock import patch - - app_mod = sys.modules["autoskillit.cli.app"] - - captured: dict = {} - - def fake_cook( - *, resume: bool = False, session_id: str | None = None, profile: str | None = None - ) -> None: - captured["resume"] = resume - captured["session_id"] = session_id - - with patch.object(app_mod, "cook_interactive", fake_cook): - # This MUST NOT raise UnusedCliTokensError (exit 0, not exit 1) - with pytest.raises(SystemExit) as exc_info: - app_mod.app(["cook", "--resume", "fa910a41-d1ca-4cae-b878-01028a0c7c1c"]) - assert exc_info.value.code == 0 - - assert captured["session_id"] == "fa910a41-d1ca-4cae-b878-01028a0c7c1c" - assert captured["resume"] is True - - # REQ-CLI-003 - def test_cook_cmd_resume_without_session_id_still_works( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """app(['cook', '--resume']) with no uuid must still work — REQ-CLI-003.""" - import sys - from unittest.mock import patch - - app_mod = sys.modules["autoskillit.cli.app"] - - captured: dict = {} - - def fake_cook( - *, resume: bool = False, session_id: str | None = None, profile: str | None = None - ) -> None: - captured["resume"] = resume - captured["session_id"] = session_id - - with patch.object(app_mod, "cook_interactive", fake_cook): - with pytest.raises(SystemExit) as exc_info: - app_mod.app(["cook", "--resume"]) - assert exc_info.value.code == 0 - - assert captured["resume"] is True - assert captured["session_id"] is None - - # REQ-CLI-003 - def test_cook_cmd_positional_session_id_implies_resume( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """app(['cook', '']) without --resume must imply resume=True — REQ-CLI-003.""" - import sys - from unittest.mock import patch - - app_mod = sys.modules["autoskillit.cli.app"] - - captured: dict = {} - - def fake_cook( - *, resume: bool = False, session_id: str | None = None, profile: str | None = None - ) -> None: - captured["resume"] = resume - captured["session_id"] = session_id - - with patch.object(app_mod, "cook_interactive", fake_cook): - with pytest.raises(SystemExit) as exc_info: - app_mod.app(["cook", "fa910a41-d1ca-4cae-b878-01028a0c7c1c"]) - assert exc_info.value.code == 0 - - assert captured["session_id"] == "fa910a41-d1ca-4cae-b878-01028a0c7c1c" - assert captured["resume"] is True - - def test_bare_resume_invokes_picker( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - """cook(resume=True, session_id=None) intercepts BareResume via pick_session.""" - from unittest.mock import MagicMock, patch - - fake_skills_dir = tmp_path / "skills" - fake_skills_dir.mkdir() - mock_mgr = MagicMock() - mock_mgr.init_session.return_value = fake_skills_dir - picker_calls: list = [] - - def fake_pick_session(session_type: str, project_dir, project_log_dir) -> str | None: - picker_calls.append((session_type, project_dir)) - return None - - with ( - patch("shutil.which", return_value="/usr/bin/claude"), - patch("builtins.input", return_value=""), - patch("autoskillit.workspace.DefaultSessionSkillManager", return_value=mock_mgr), - patch("subprocess.run", return_value=MagicMock(returncode=0)), - patch("autoskillit.cli.session._session_picker.pick_session", fake_pick_session), - patch("autoskillit.core.write_registry_entry"), - ): - import autoskillit.cli.session._session_cook as module - - module.cook(resume=True) - - assert picker_calls, "pick_session must be called for BareResume" - assert picker_calls[0][0] == "cook", f"Expected 'cook', got {picker_calls[0][0]!r}" - - def test_cook_launch_sets_session_type_env( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - """cook() passes AUTOSKILLIT_SESSION_TYPE=cook in env_extras.""" - from unittest.mock import MagicMock, patch - - from autoskillit.core import CmdSpec - - fake_skills_dir = tmp_path / "skills" - fake_skills_dir.mkdir() - mock_mgr = MagicMock() - mock_mgr.init_session.return_value = fake_skills_dir - captured_env_extras: list = [] - - class _MockBackend(_ProjectionBackendStub): - def binary_name(self) -> str: - return "claude" - - def build_interactive_cmd(self, **kwargs): - captured_env_extras.append(kwargs.get("env_extras", {})) - return CmdSpec(cmd=("claude", "--dangerously-skip-permissions"), env={}) - - def ensure_pre_launch(self) -> list[str]: - return [] - - with ( - patch("shutil.which", return_value="/usr/bin/claude"), - patch("builtins.input", return_value=""), - patch("autoskillit.workspace.DefaultSessionSkillManager", return_value=mock_mgr), - patch("subprocess.run", return_value=MagicMock(returncode=0)), - patch("autoskillit.core.write_registry_entry"), - ): - import autoskillit.cli.session._session_cook as module - - module.cook(backend=_MockBackend()) - - assert captured_env_extras, "build_interactive_cmd must have been called" - env_extras = captured_env_extras[0] - assert env_extras.get("AUTOSKILLIT_SESSION_TYPE") == "skill" - - def test_cook_launch_sets_launch_id_env( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - """cook() passes AUTOSKILLIT_LAUNCH_ID in env_extras.""" - from unittest.mock import MagicMock, patch - - from autoskillit.core import CmdSpec - - fake_skills_dir = tmp_path / "skills" - fake_skills_dir.mkdir() - mock_mgr = MagicMock() - mock_mgr.init_session.return_value = fake_skills_dir - captured_env_extras: list = [] - - class _MockBackend(_ProjectionBackendStub): - def binary_name(self) -> str: - return "claude" - - def build_interactive_cmd(self, **kwargs): - captured_env_extras.append(kwargs.get("env_extras", {})) - return CmdSpec(cmd=("claude", "--dangerously-skip-permissions"), env={}) - - def ensure_pre_launch(self) -> list[str]: - return [] - - with ( - patch("shutil.which", return_value="/usr/bin/claude"), - patch("builtins.input", return_value=""), - patch("autoskillit.workspace.DefaultSessionSkillManager", return_value=mock_mgr), - patch("subprocess.run", return_value=MagicMock(returncode=0)), - patch("autoskillit.core.write_registry_entry"), - ): - import autoskillit.cli.session._session_cook as module - - module.cook(backend=_MockBackend()) - - assert captured_env_extras, "build_interactive_cmd must have been called" - assert "AUTOSKILLIT_LAUNCH_ID" in captured_env_extras[0] - - def test_cook_uses_backend_binary_name( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - """cook() calls shutil.which with the backend's binary_name().""" - from unittest.mock import MagicMock, patch - - from autoskillit.core import CmdSpec - - fake_skills_dir = tmp_path / "skills" - fake_skills_dir.mkdir() - mock_mgr = MagicMock() - mock_mgr.init_session.return_value = fake_skills_dir - captured_which: list = [] - - class _CustomBackend(_ProjectionBackendStub): - def binary_name(self) -> str: - return "my-test-claude" - - def build_interactive_cmd(self, **kwargs): - return CmdSpec(cmd=("my-test-claude", "--dangerously-skip-permissions"), env={}) - - def ensure_pre_launch(self) -> list[str]: - return [] - - def tracking_which(binary): - captured_which.append(binary) - return "/usr/bin/my-test-claude" if binary == "my-test-claude" else None - - with ( - patch("shutil.which", side_effect=tracking_which), - patch("builtins.input", return_value=""), - patch("autoskillit.workspace.DefaultSessionSkillManager", return_value=mock_mgr), - patch("subprocess.run", return_value=MagicMock(returncode=0)), - patch("autoskillit.core.write_registry_entry"), - ): - import autoskillit.cli.session._session_cook as module - - module.cook(backend=_CustomBackend()) - - assert "my-test-claude" in captured_which - - def test_cook_calls_backend_build_interactive_cmd_with_expected_kwargs( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - """cook() calls backend.build_interactive_cmd() with expected kwargs.""" - from unittest.mock import MagicMock, patch - - from autoskillit.core import CmdSpec - - fake_skills_dir = tmp_path / "skills" - fake_skills_dir.mkdir() - mock_mgr = MagicMock() - mock_mgr.init_session.return_value = fake_skills_dir - captured_kwargs: list = [] - - class _CapturingBackend(_ProjectionBackendStub): - def binary_name(self) -> str: - return "claude" - - def build_interactive_cmd(self, **kwargs): - captured_kwargs.append(kwargs) - return CmdSpec(cmd=("claude", "--dangerously-skip-permissions"), env={}) - - def ensure_pre_launch(self) -> list[str]: - return [] - - with ( - patch("shutil.which", return_value="/usr/bin/claude"), - patch("builtins.input", return_value=""), - patch("autoskillit.workspace.DefaultSessionSkillManager", return_value=mock_mgr), - patch("subprocess.run", return_value=MagicMock(returncode=0)), - patch("autoskillit.core.write_registry_entry"), - ): - import autoskillit.cli.session._session_cook as module - - module.cook(backend=_CapturingBackend()) - - assert len(captured_kwargs) == 1, "build_interactive_cmd must have been called" - call_kwargs = captured_kwargs[0] - assert "plugin_source" in call_kwargs - assert "add_dirs" in call_kwargs - assert "initial_prompt" in call_kwargs - assert "resume_spec" in call_kwargs - assert "env_extras" in call_kwargs - - def test_cook_uses_injected_backend( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - """When backend= is passed, cook() uses it directly without calling get_backend().""" - from unittest.mock import MagicMock, patch - - from autoskillit.core import CmdSpec - - fake_skills_dir = tmp_path / "skills" - fake_skills_dir.mkdir() - mock_mgr = MagicMock() - mock_mgr.init_session.return_value = fake_skills_dir - build_called: list[dict] = [] - - class _InjectedBackend(_ProjectionBackendStub): - def binary_name(self) -> str: - return "claude" - - def build_interactive_cmd(self, **kwargs): - build_called.append(kwargs) - return CmdSpec(cmd=("claude", "--dangerously-skip-permissions"), env={}) - - def ensure_pre_launch(self) -> list[str]: - return [] - - get_backend_called: list = [] - - with ( - patch("shutil.which", return_value="/usr/bin/claude"), - patch("builtins.input", return_value=""), - patch("autoskillit.workspace.DefaultSessionSkillManager", return_value=mock_mgr), - patch("subprocess.run", return_value=MagicMock(returncode=0)), - patch("autoskillit.core.write_registry_entry"), - patch( - "autoskillit.execution.get_backend", - side_effect=lambda name: get_backend_called.append(name), - ), - ): - import autoskillit.cli.session._session_cook as module - - module.cook(backend=_InjectedBackend()) - - assert build_called, "Injected backend's build_interactive_cmd must be called" - assert not get_backend_called, "get_backend must NOT be called when backend is injected" - - def test_cook_default_backend_calls_get_backend( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - """When backend= is not passed, cook() calls get_backend() with the config backend name.""" - from unittest.mock import MagicMock, patch - - from autoskillit.core import CmdSpec - - fake_skills_dir = tmp_path / "skills" - fake_skills_dir.mkdir() - mock_mgr = MagicMock() - mock_mgr.init_session.return_value = fake_skills_dir - get_backend_called: list = [] - - class _FakeBackend(_ProjectionBackendStub): - def binary_name(self) -> str: - return "claude" - - def build_interactive_cmd(self, **kwargs): - return CmdSpec(cmd=("claude", "--dangerously-skip-permissions"), env={}) - - def ensure_pre_launch(self) -> list[str]: - return [] - - mock_config = MagicMock() - mock_config.agent_backend.backend = "claude-code" - mock_config.features = {} - mock_config.experimental_enabled = False - mock_config.providers.profiles = {} - - with ( - patch("shutil.which", return_value="/usr/bin/claude"), - patch("builtins.input", return_value=""), - patch("autoskillit.workspace.DefaultSessionSkillManager", return_value=mock_mgr), - patch("subprocess.run", return_value=MagicMock(returncode=0)), - patch("autoskillit.core.write_registry_entry"), - patch("autoskillit.config.load_config", return_value=mock_config), - patch( - "autoskillit.execution.get_backend", - side_effect=lambda name: (get_backend_called.append(name), _FakeBackend())[1], - ), - ): - import autoskillit.cli.session._session_cook as module - - module.cook() - - assert get_backend_called, "get_backend must be called when backend is not injected" - assert get_backend_called[0] == "claude-code" - - @pytest.mark.parametrize("backend_name", ["claude-code", "codex"]) - def test_cook_routes_through_get_backend( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, backend_name: str - ) -> None: - """cook() calls get_backend with config.agent_backend.backend value.""" - from unittest.mock import MagicMock, patch - - from autoskillit.core import CmdSpec - - fake_skills_dir = tmp_path / "skills" - fake_skills_dir.mkdir() - mock_mgr = MagicMock() - mock_mgr.init_session.return_value = fake_skills_dir - get_backend_calls: list = [] - - class _StubBackend(_ProjectionBackendStub): - def binary_name(self) -> str: - return "claude" - - def build_interactive_cmd(self, **kwargs): - return CmdSpec(cmd=("claude", "--dangerously-skip-permissions"), env={}) - - def ensure_pre_launch(self) -> list[str]: - return [] - - mock_config = MagicMock() - mock_config.agent_backend.backend = backend_name - mock_config.features = {} - mock_config.experimental_enabled = False - mock_config.providers.profiles = {} - - def fake_get_backend(name: str): - get_backend_calls.append(name) - return _StubBackend() - - with ( - patch("shutil.which", return_value="/usr/bin/claude"), - patch("builtins.input", return_value=""), - patch("autoskillit.workspace.DefaultSessionSkillManager", return_value=mock_mgr), - patch("subprocess.run", return_value=MagicMock(returncode=0)), - patch("autoskillit.core.write_registry_entry"), - patch("autoskillit.config.load_config", return_value=mock_config), - patch("autoskillit.execution.get_backend", side_effect=fake_get_backend), - ): - import autoskillit.cli.session._session_cook as module - - module.cook() - - assert len(get_backend_calls) == 1, "get_backend must be called exactly once" - assert get_backend_calls[0] == backend_name - - def test_cook_get_backend_called_not_hardcoded( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - """ClaudeCodeBackend is never instantiated when get_backend is the DI path.""" - from unittest.mock import MagicMock, patch - - from autoskillit.core import CmdSpec - - fake_skills_dir = tmp_path / "skills" - fake_skills_dir.mkdir() - mock_mgr = MagicMock() - mock_mgr.init_session.return_value = fake_skills_dir - hardcoded_calls: list = [] - - class _StubBackend(_ProjectionBackendStub): - def binary_name(self) -> str: - return "claude" - - def build_interactive_cmd(self, **kwargs): - return CmdSpec(cmd=("claude", "--dangerously-skip-permissions"), env={}) - - def ensure_pre_launch(self) -> list[str]: - return [] - - mock_config = MagicMock() - mock_config.agent_backend.backend = "claude-code" - mock_config.features = {} - mock_config.experimental_enabled = False - mock_config.providers.profiles = {} - - def tracking_claude_code_backend(*args, **kwargs): - hardcoded_calls.append(True) - return MagicMock() - - with ( - patch("shutil.which", return_value="/usr/bin/claude"), - patch("builtins.input", return_value=""), - patch("autoskillit.workspace.DefaultSessionSkillManager", return_value=mock_mgr), - patch("subprocess.run", return_value=MagicMock(returncode=0)), - patch("autoskillit.core.write_registry_entry"), - patch("autoskillit.config.load_config", return_value=mock_config), - patch("autoskillit.execution.get_backend", return_value=_StubBackend()), - patch( - "autoskillit.execution.backends.claude.ClaudeCodeBackend", - side_effect=tracking_claude_code_backend, + monkeypatch.setattr( + "autoskillit.cli.session._session_process.run_cook_attempt", + lambda *args, **kwargs: ( + events.append(("run",)) or SimpleNamespace(pid=1, pgid=1, returncode=0) ), - ): - import autoskillit.cli.session._session_cook as module - - module.cook() - - assert not hardcoded_calls, ( - "ClaudeCodeBackend must never be instantiated when get_backend is patched" ) - - def test_cook_init_session_passes_backend( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - """cook() forwards backend to init_session().""" - captured: dict = {} - fake_skills_dir = tmp_path / "fake-skills-backend" - fake_skills_dir.mkdir() - - def fake_init_session( - self, - session_id: str, - catalog, - projection_context, - ) -> Path: - captured["backend"] = projection_context.backend - return fake_skills_dir - - class _FakeBackend(_ProjectionBackendStub): - def binary_name(self) -> str: - return "claude" - - def build_interactive_cmd(self, **kwargs): - from autoskillit.core import CmdSpec - - return CmdSpec(cmd=("claude", "--dangerously-skip-permissions"), env={}) - - def ensure_pre_launch(self) -> list[str]: - return [] - - monkeypatch.setattr(DefaultSessionSkillManager, "init_session", fake_init_session) - monkeypatch.setattr(subprocess, "run", lambda *a, **kw: type("R", (), {"returncode": 0})()) - monkeypatch.setattr(shutil, "which", lambda x: "/usr/bin/claude") - monkeypatch.setattr("builtins.input", lambda _prompt="": "") - cli.cook(backend=_FakeBackend()) - assert captured["backend"] is not None + monkeypatch.setattr( + "autoskillit.cli.session._session_reload.consume_reload_sentinel", + lambda _project: None, + ) + cli.cook(backend=_Backend()) + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(shutil, "which", lambda _name: "/usr/bin/claude") + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("autoskillit.cli._onboarding.is_first_run", lambda _: False) + monkeypatch.setattr( + "autoskillit.workspace.DefaultSessionSkillManager", lambda *args, **kwargs: manager + ) + + run_once("n") + assert [event[0] for event in events] == [ + "managed-enter", + "confirm", + "managed-exit", + ] + + run_once("") + names = [event[0] for event in events] + assert "recover" not in names + assert names.index("confirm") < names.index("registry") + assert names.index("registry") < names.index("attempt-enter") + launch_id = next(event[1] for event in events if event[0] == "managed-enter") + assert next(event[1] for event in events if event[0] == "registry") == launch_id + + +def test_cook_does_not_treat_persistent_sessions_as_codex( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Persistent storage alone must not activate Codex runtime behavior.""" + from autoskillit.execution.backends import _codex_session_storage as storage + + backend = _Backend() + backend.name = "persistent-non-codex" + backend.conventions = BackendConventions( + persistent_session_root_subdir=Path("persistent-non-codex") + ) + backend.capabilities = SimpleNamespace( + hook_trust_policy=HookTrustPolicy.AUTOMATED, + session_dir_persistent=True, + cook_startup_observer_capable=False, + ) + captured = _install_harness(monkeypatch, tmp_path) + + def fail_codex_runtime(*_args: object, **_kwargs: object) -> None: + raise AssertionError("persistent non-Codex backend entered Codex runtime") + + monkeypatch.setenv(CODEX_STARTUP_TRACE_ENV_VAR, "1") + monkeypatch.setattr( + "autoskillit.execution.CodexStateReadinessProbe", + fail_codex_runtime, + ) + monkeypatch.setattr( + storage.CodexSessionStore, + "prepare_attempt", + fail_codex_runtime, + ) + + cli.cook(backend=backend) + + spec = captured["spec"] + assert isinstance(spec, CmdSpec) + assert CODEX_COOK_RESERVED_ENV_VARS.isdisjoint(spec.env) + assert CODEX_STARTUP_TRACE_ENV_VAR not in spec.env + assert backend.context_calls[0]["session_home"] == captured["generated_home"] diff --git a/tests/cli/test_cook_order_command.py b/tests/cli/test_cook_order_command.py index 2c49b6c463..36157702b9 100644 --- a/tests/cli/test_cook_order_command.py +++ b/tests/cli/test_cook_order_command.py @@ -486,38 +486,6 @@ def fake_run(cmd, **kwargs): ) -# SC-B-4: mark_onboarded() must NOT be called when the cook subprocess exits non-zero -def test_cook_mark_onboarded_not_called_on_failure( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - """mark_onboarded() must not be called when the cook subprocess exits non-zero.""" - import autoskillit.cli.session._session_cook as _cook - - onboarded_calls: list[Path] = [] - monkeypatch.setattr( - "autoskillit.cli._onboarding.mark_onboarded", - lambda project_dir: onboarded_calls.append(project_dir), - ) - - def fake_run(cmd: list[str], **kwargs: object) -> object: - return type("R", (), {"returncode": 1})() - - monkeypatch.setattr(_cook.subprocess, "run", fake_run) - - with pytest.raises(SystemExit): - _cook._run_cook_session( - cmd=["claude", "--test"], - env={}, - _first_run=True, - initial_prompt="test", - project_dir=tmp_path, - ) - - assert onboarded_calls == [], ( - "mark_onboarded() must not be called when the subprocess exits non-zero" - ) - - # --------------------------------------------------------------------------- # ORDER_INTERACTIVE_REQUIRED_ENV call-site contract (issue #4253 Part A) # --------------------------------------------------------------------------- diff --git a/tests/cli/test_cook_process_lifecycle.py b/tests/cli/test_cook_process_lifecycle.py new file mode 100644 index 0000000000..853ffd5803 --- /dev/null +++ b/tests/cli/test_cook_process_lifecycle.py @@ -0,0 +1,195 @@ +"""Cook-attempt process ownership and callback ordering contracts.""" + +from __future__ import annotations + +import os +import signal +import subprocess +import sys +import time +from pathlib import Path +from unittest.mock import Mock + +import pytest +from autoskillit.cli.session._session_process import run_cook_attempt + +from autoskillit.core import CmdSpec + +pytestmark = [ + pytest.mark.layer("cli"), + pytest.mark.medium, + pytest.mark.skipif(os.name != "posix", reason="POSIX process-group contract"), +] + + +def _spec(tmp_path: Path, code: str, *, env: dict[str, str] | None = None) -> CmdSpec: + return CmdSpec( + cmd=(sys.executable, "-c", code), + env=dict(os.environ) if env is None else env, + cwd=str(tmp_path.resolve()), + ) + + +def _wait_until_gone(pid: int, timeout: float = 3.0) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + os.kill(pid, 0) + except ProcessLookupError: + return True + time.sleep(0.02) + return False + + +def _kill_if_alive(pid: int) -> None: + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + return + + +def test_direct_attempt_owns_new_group_and_reaps_before_callback( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + import autoskillit.cli.session._session_process as process_mod + + actual_popen = subprocess.Popen + popen_kwargs: dict[str, object] = {} + + def recording_popen(*args, **kwargs): + popen_kwargs.update(kwargs) + return actual_popen(*args, **kwargs) + + monkeypatch.setattr(process_mod.subprocess, "Popen", recording_popen) + events: list[tuple[str, int, int]] = [] + + def on_spawn(pid: int, pgid: int) -> None: + assert os.getpgid(pid) == pgid + events.append(("spawn", pid, pgid)) + + def on_reaped(pid: int, pgid: int) -> None: + with pytest.raises(ChildProcessError): + os.waitpid(pid, os.WNOHANG) + events.append(("reaped", pid, pgid)) + + result = run_cook_attempt( + _spec(tmp_path, "pass"), + pass_fds=(), + on_spawn=on_spawn, + on_reaped=on_reaped, + trace=Mock(), + observer=None, + ) + + assert popen_kwargs["cwd"] == str(tmp_path.resolve()) + assert popen_kwargs["start_new_session"] is False + assert popen_kwargs["process_group"] == 0 + assert popen_kwargs["pass_fds"] == () + assert result.pid == result.pgid + assert result.returncode == 0 + assert events == [ + ("spawn", result.pid, result.pgid), + ("reaped", result.pid, result.pgid), + ] + + +def test_pass_fds_are_inherited_and_callback_identity_is_stable(tmp_path: Path) -> None: + read_fd, write_fd = os.pipe() + events: list[tuple[str, int, int]] = [] + try: + result = run_cook_attempt( + _spec( + tmp_path, + "import os; os.write(int(os.environ['LEASE_FD']), b'owned')", + env={**os.environ, "LEASE_FD": str(write_fd)}, + ), + pass_fds=(write_fd,), + on_spawn=lambda pid, pgid: events.append(("spawn", pid, pgid)), + on_reaped=lambda pid, pgid: events.append(("reaped", pid, pgid)), + trace=Mock(), + observer=None, + ) + finally: + os.close(write_fd) + try: + assert os.read(read_fd, 5) == b"owned" + finally: + os.close(read_fd) + + assert events == [ + ("spawn", result.pid, result.pgid), + ("reaped", result.pid, result.pgid), + ] + + +def test_grandchild_cannot_outlive_group_empty_reaped_proof(tmp_path: Path) -> None: + grandchild_path = tmp_path / "grandchild.pid" + code = ( + "import pathlib, subprocess, sys;" + f"p=subprocess.Popen([sys.executable,'-c','import time; time.sleep(30)']);" + f"pathlib.Path({str(grandchild_path)!r}).write_text(str(p.pid))" + ) + grandchild_pid = 0 + reaped_observations: list[bool] = [] + try: + result = run_cook_attempt( + _spec(tmp_path, code), + pass_fds=(), + on_spawn=lambda _pid, _pgid: None, + on_reaped=lambda _pid, _pgid: reaped_observations.append( + _wait_until_gone(int(grandchild_path.read_text()), timeout=1.0) + ), + trace=Mock(), + observer=None, + ) + grandchild_pid = int(grandchild_path.read_text()) + assert result.returncode == 0 + assert reaped_observations == [True] + finally: + if grandchild_pid: + _kill_if_alive(grandchild_pid) + _wait_until_gone(grandchild_pid) + + +def test_spawn_failure_has_no_callbacks(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + import autoskillit.cli.session._session_process as process_mod + + def fail_spawn(*_args, **_kwargs): + raise OSError("synthetic Popen failure") + + monkeypatch.setattr(process_mod.subprocess, "Popen", fail_spawn) + events: list[str] = [] + + with pytest.raises(OSError, match="synthetic Popen failure"): + run_cook_attempt( + _spec(tmp_path, "pass"), + pass_fds=(), + on_spawn=lambda _pid, _pgid: events.append("spawn"), + on_reaped=lambda _pid, _pgid: events.append("reaped"), + trace=Mock(), + observer=None, + ) + + assert events == [] + + +def test_callback_failure_still_terminates_and_reaps_child(tmp_path: Path) -> None: + identity: list[tuple[int, int]] = [] + + def fail_after_spawn(pid: int, pgid: int) -> None: + identity.append((pid, pgid)) + raise RuntimeError("trace/storage callback failed") + + with pytest.raises(RuntimeError, match="trace/storage callback failed"): + run_cook_attempt( + _spec(tmp_path, "import time; time.sleep(30)"), + pass_fds=(), + on_spawn=fail_after_spawn, + on_reaped=lambda pid, pgid: identity.append((pid, pgid)), + trace=Mock(), + observer=None, + ) + + assert len(identity) == 2 + assert identity[0] == identity[1] + assert _wait_until_gone(identity[0][0]) diff --git a/tests/cli/test_cook_profile.py b/tests/cli/test_cook_profile.py index bbc2a87b82..a935405ed9 100644 --- a/tests/cli/test_cook_profile.py +++ b/tests/cli/test_cook_profile.py @@ -2,14 +2,26 @@ from __future__ import annotations +from contextlib import contextmanager from pathlib import Path +from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest import autoskillit.cli.session._session_cook as cook_module from autoskillit.config import AutomationConfig -from autoskillit.core import BackendConventions, CmdSpec, SkillContractError +from autoskillit.core import ( + BackendConventions, + CmdSpec, + CookSessionHandle, + EffectiveSkillCatalogAuthority, + HookTrustPolicy, + ManagedSessionHome, + SkillContractError, + SkillProjectionContextAuthority, + ValidatedAddDir, +) pytestmark = [pytest.mark.layer("cli"), pytest.mark.medium] @@ -20,17 +32,33 @@ def _make_mock_backend_class(): class _MockBackend: name = "claude-code" conventions = BackendConventions() + capabilities = SimpleNamespace( + hook_trust_policy=HookTrustPolicy.AUTOMATED, + session_dir_persistent=False, + ) def binary_name(self) -> str: return "claude" + def recover_cook_history(self) -> None: + return None + def build_interactive_cmd(self, **kwargs): captured.append(kwargs.get("env_extras", {})) return CmdSpec(cmd=("claude",), env={}) - def ensure_pre_launch(self) -> list[str]: + def validate_interactive_invocation(self, spec: CmdSpec) -> list[str]: return [] + @contextmanager + def cook_session_context(self, **kwargs): + yield CookSessionHandle( + view_id="profile-view", + pass_fds=(), + _record_spawn=lambda _pid, _pgid: None, + _record_reaped=lambda _pid, _pgid: None, + ) + return _MockBackend, captured @@ -41,14 +69,38 @@ def _mock_mgr(): def _run_cook(profile, cfg, mock_mgr): mock_backend_cls, captured = _make_mock_backend_class() + generated_home = Path.cwd().resolve() + + @contextmanager + def managed_session( + launch_id: str, + catalog: EffectiveSkillCatalogAuthority, + projection_context: SkillProjectionContextAuthority, + ): + assert projection_context.catalog == catalog + yield ManagedSessionHome( + launch_id=launch_id, + generated_home=generated_home, + skills_dir=ValidatedAddDir(str(generated_home)), + pass_fds=(), + ) + + mock_mgr.managed_session.side_effect = managed_session with ( patch("shutil.which", return_value="/usr/bin/claude"), - patch("builtins.input", return_value=""), patch("autoskillit.workspace.DefaultSessionSkillManager", return_value=mock_mgr), # cook() derives project_dir via the shared git-toplevel helper; pin it so - # the wholesale subprocess.run mock cannot stand in for the git probe. + # the test does not depend on the caller's checkout. patch("autoskillit.cli.session._session_cook.resolve_project_dir", Path.cwd), - patch("subprocess.run", return_value=MagicMock(returncode=0)), + patch( + "autoskillit.cli.session._session_process.run_cook_attempt", + return_value=SimpleNamespace(pid=1, pgid=1, returncode=0), + ), + patch( + "autoskillit.cli.session._session_reload.consume_reload_sentinel", + return_value=None, + ), + patch("autoskillit.cli._onboarding.is_first_run", return_value=False), patch("autoskillit.core.write_registry_entry"), patch("autoskillit.config.load_config", return_value=cfg), patch( @@ -135,6 +187,150 @@ def test_profile_unknown_exits(capsys, _mock_mgr): assert "anthropic" in err or "openai" in err +def test_finalized_profile_spec_is_shared_by_validator_context_and_child( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + from autoskillit.core import ( + CookSessionHandle, + HookTrustPolicy, + ManagedSessionHome, + ValidatedAddDir, + ) + + generated_home = tmp_path / "generated-home" + skills_dir = generated_home / "skills" + skills_dir.mkdir(parents=True) + manager = MagicMock() + captured: dict[str, object] = {} + + @contextmanager + def managed_session( + launch_id: str, + catalog: EffectiveSkillCatalogAuthority, + projection_context: SkillProjectionContextAuthority, + ): + assert projection_context.catalog == catalog + yield ManagedSessionHome( + launch_id=launch_id, + generated_home=generated_home, + skills_dir=ValidatedAddDir(str(skills_dir)), + pass_fds=(3,), + ) + + manager.managed_session.side_effect = managed_session + + class _Backend: + name = "codex" + conventions = BackendConventions( + project_local_skill_search_dirs=(".codex/skills", ".agents/skills"), + persistent_session_root_subdir=Path("codex-sessions"), + skill_sigil="$", + ) + capabilities = SimpleNamespace( + hook_trust_policy=HookTrustPolicy.REVIEW_EACH_SESSION, + session_dir_persistent=True, + cook_startup_observer_capable=False, + ) + + def binary_name(self) -> str: + return "codex" + + def recover_cook_history(self) -> None: + return None + + def build_interactive_cmd(self, **kwargs: object) -> CmdSpec: + captured["build_kwargs"] = kwargs + env = dict(kwargs["env_extras"]) # type: ignore[arg-type] + generated_home = kwargs["generated_home"] + env["CODEX_HOME"] = str(generated_home) + env["CODEX_SQLITE_HOME"] = str(generated_home) + return CmdSpec( + cmd=( + "codex", + "--profile", + "minimax", + "-c", + f"sqlite_home={generated_home!s}", + ), + env=env, + cwd="/ambient-cwd-must-not-survive", + ) + + def validate_interactive_invocation(self, spec: CmdSpec) -> list[str]: + captured["validated"] = spec + return [] + + @contextmanager + def cook_session_context(self, **kwargs: object): + captured["context"] = kwargs + yield CookSessionHandle( + view_id="view-1", + pass_fds=(5,), + _record_spawn=lambda _pid, _pgid: None, + _record_reaped=lambda _pid, _pgid: None, + ) + + def run_attempt(spec: CmdSpec, **kwargs: object) -> object: + captured["child"] = spec + captured["pass_fds"] = kwargs["pass_fds"] + kwargs["on_spawn"](1, 1) # type: ignore[operator] + trace = kwargs["trace"] + trace.record_spawn() # type: ignore[union-attr] + trace.record_stage( # type: ignore[union-attr] + "hook_review", + attempt=1, + view_id="view-1", + ) + kwargs["on_reaped"](1, 1) # type: ignore[operator] + return SimpleNamespace(pid=1, pgid=1, returncode=0) + + cfg = MagicMock() + cfg.experimental_enabled = True + cfg.providers.profiles = { + "minimax": { + "ANTHROPIC_BASE_URL": "https://minimax.example", + "CODEX_HOME": "/caller-home", + "CODEX_SQLITE_HOME": "/caller-sqlite-home", + } + } + + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("CODEX_HOME", "/ambient-home") + monkeypatch.setenv("CODEX_SQLITE_HOME", "/ambient-sqlite-home") + monkeypatch.setenv("AUTOSKILLIT_CODEX_STARTUP_TRACE", "1") + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg-data")) + with ( + patch("shutil.which", return_value="/usr/bin/codex"), + patch("sys.stdin.isatty", return_value=True), + patch("autoskillit.config.load_config", return_value=cfg), + patch("autoskillit.cli.session._session_cook.is_feature_enabled", return_value=True), + patch("autoskillit.workspace.DefaultSessionSkillManager", return_value=manager), + patch("autoskillit.cli._onboarding.is_first_run", return_value=False), + patch("autoskillit.cli.ui._timed_input.timed_prompt", return_value=""), + patch("autoskillit.core.write_registry_entry"), + patch( + "autoskillit.cli.session._session_process.run_cook_attempt", + side_effect=run_attempt, + ), + patch( + "autoskillit.cli.session._session_reload.consume_reload_sentinel", + return_value=None, + ), + ): + cook_module.cook(profile="minimax", backend=_Backend()) + + spec = captured["validated"] + assert spec is captured["child"] + assert isinstance(spec, CmdSpec) + assert spec.cwd == str(tmp_path) + assert spec.env["AUTOSKILLIT_PROVIDER_PROFILE"] == "minimax" + assert spec.env["CODEX_HOME"] == str(generated_home) + assert spec.env["CODEX_SQLITE_HOME"] == str(generated_home) + assert "AUTOSKILLIT_CODEX_STARTUP_TRACE" not in spec.env + assert any("sqlite_home=" in arg and str(generated_home) in arg for arg in spec.cmd) + assert captured["pass_fds"] == (3, 5) + + def test_cook_rejects_orchestrator_skill_in_l1_tier_before_launch() -> None: """Direct cook composition validates configured tiers before materialization.""" cfg = AutomationConfig() @@ -146,7 +342,7 @@ def test_cook_rejects_orchestrator_skill_in_l1_tier_before_launch() -> None: # project_dir comes from the shared git-toplevel helper; pin it so the # "nothing launched" assertion below stays about launches. patch("autoskillit.cli.session._session_cook.resolve_project_dir", Path.cwd), - patch("subprocess.run") as run, + patch("autoskillit.cli.session._session_process.run_cook_attempt") as run, ): with pytest.raises(SkillContractError, match="process-issues.*ORCHESTRATOR"): cook_module.cook(backend=mock_backend_cls()) diff --git a/tests/cli/test_cook_startup_observability.py b/tests/cli/test_cook_startup_observability.py new file mode 100644 index 0000000000..7f4e4f8c08 --- /dev/null +++ b/tests/cli/test_cook_startup_observability.py @@ -0,0 +1,411 @@ +"""Contracts for bounded Codex cook startup tracing and PTY observation.""" + +from __future__ import annotations + +import hashlib +import json +import os +import sqlite3 +from pathlib import Path + +import pytest + +pytestmark = [pytest.mark.layer("cli"), pytest.mark.medium] + +_LAUNCH_ID = "0123456789abcdef" +_VIEW_ID = f"{_LAUNCH_ID}-1" +_RECORD_LIMIT = 16 * 1024 +_WINDOW_LIMIT = 64 * 1024 + + +class _Clock: + def __init__(self, value: float = 100.0) -> None: + self.value = value + + def __call__(self) -> float: + return self.value + + +def _trace_module(): + from autoskillit.cli.session import _session_startup_trace + + return _session_startup_trace + + +def _observer_api(): + from autoskillit.cli.session.pty._observer import ( + CodexStateReadinessProbe, + ObserverStatus, + PtyObserver, + ) + + return CodexStateReadinessProbe, ObserverStatus, PtyObserver + + +def _records(path: Path) -> list[dict[str, object]]: + return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()] + + +@pytest.mark.parametrize( + "launch_id", + [ + "", + "0123456789abcde", + "0123456789abcdef0", + "0123456789ABCDEf", + "../1234567890abc", + "01234567/90abcde", + "/1234567890abcde", + ], +) +def test_startup_trace_path_rejects_noncanonical_launch_ids( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, launch_id: str +) -> None: + trace_mod = _trace_module() + project = tmp_path / "project" + project.mkdir() + monkeypatch.setattr(trace_mod, "default_log_dir", lambda: tmp_path / "logs") + + with pytest.raises(ValueError): + trace_mod.startup_trace_path(project, launch_id) + + +def test_startup_trace_path_is_the_project_keyed_canonical_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + trace_mod = _trace_module() + project = tmp_path / "project" + project.mkdir() + log_root = tmp_path / "logs" + monkeypatch.setattr(trace_mod, "default_log_dir", lambda: log_root) + + path = trace_mod.startup_trace_path(project, _LAUNCH_ID) + project_key = hashlib.sha256(os.fsencode(str(project.resolve(strict=True)))).hexdigest()[:16] + + assert path == log_root / "codex-startup" / project_key / f"{_LAUNCH_ID}.jsonl" + + +def test_startup_trace_path_rejects_a_symlinked_parent_escape( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + trace_mod = _trace_module() + project = tmp_path / "project" + project.mkdir() + log_root = tmp_path / "logs" + trace_root = log_root / "codex-startup" + trace_root.mkdir(parents=True) + project_key = hashlib.sha256(os.fsencode(str(project.resolve(strict=True)))).hexdigest()[:16] + (trace_root / project_key).symlink_to(tmp_path / "outside", target_is_directory=True) + monkeypatch.setattr(trace_mod, "default_log_dir", lambda: log_root) + + with pytest.raises((OSError, ValueError)): + trace_mod.startup_trace_path(project, _LAUNCH_ID) + + +def test_trace_schema_anchors_monotonic_math_budgets_and_history_diagnostics( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + trace_mod = _trace_module() + project = tmp_path / "project" + project.mkdir() + monkeypatch.setattr(trace_mod, "default_log_dir", lambda: tmp_path / "logs") + clock = _Clock() + trace = trace_mod.StartupTrace( + project_dir=project, + launch_id=_LAUNCH_ID, + enabled=True, + clock=clock, + ) + + trace.record_launch_anchor() + trace.record_attempt_anchor( + attempt=1, + view_id=_VIEW_ID, + diagnostics={"history_file_count": 500, "history_allocated_bytes": 8_388_608}, + ) + clock.value = 104.5 + trace.record_stage("spawn", attempt=1, view_id=_VIEW_ID) + clock.value = 105.0 + trace.record_stage("state_ready", attempt=1, view_id=_VIEW_ID) + clock.value = 105.25 + trace.record_stage("first_output", attempt=1, view_id=_VIEW_ID) + clock.value = 116.5 + trace.record_stage("hook_review", attempt=1, view_id=_VIEW_ID) + trace.close(status="success") + + records = _records(trace.path) + assert {record["schema_version"] for record in records} == {1} + assert {record["launch_id"] for record in records} == {_LAUNCH_ID} + assert [record["record_type"] for record in records] == [ + "launch", + "attempt", + "stage", + "stage", + "stage", + "stage", + "summary", + ] + assert [record["monotonic_seconds"] for record in records] == sorted( + record["monotonic_seconds"] for record in records + ) + assert records[1]["attempt"] == 1 + assert records[1]["view_id"] == _VIEW_ID + assert records[1]["diagnostics"] == { + "history_file_count": 500, + "history_allocated_bytes": 8_388_608, + } + summary = records[-1] + assert summary["status"] == "success" + assert summary["durations_seconds"] == pytest.approx( + { + "confirmation_to_spawn": 4.5, + "spawn_to_hook_review": 12.0, + "total_startup": 16.5, + } + ) + assert summary["budgets_seconds"] == { + "confirmation_to_spawn": 5.0, + "spawn_to_hook_review": 12.0, + "total_startup": 17.0, + } + assert summary["budget_exceeded"] == [] + + +@pytest.mark.parametrize( + ("spawn_at", "hook_at", "exceeded"), + [ + (105.001, 116.0, {"confirmation_to_spawn"}), + (104.0, 116.001, {"spawn_to_hook_review"}), + ( + 105.001, + 117.002, + {"confirmation_to_spawn", "spawn_to_hook_review", "total_startup"}, + ), + ], +) +def test_absolute_startup_budgets_are_hard_gates( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + spawn_at: float, + hook_at: float, + exceeded: set[str], +) -> None: + trace_mod = _trace_module() + project = tmp_path / "project" + project.mkdir() + monkeypatch.setattr(trace_mod, "default_log_dir", lambda: tmp_path / "logs") + clock = _Clock() + trace = trace_mod.StartupTrace(project, _LAUNCH_ID, enabled=True, clock=clock) + trace.record_launch_anchor() + trace.record_attempt_anchor(attempt=1, view_id=_VIEW_ID) + clock.value = spawn_at + trace.record_stage("spawn", attempt=1, view_id=_VIEW_ID) + clock.value = hook_at + trace.record_stage("hook_review", attempt=1, view_id=_VIEW_ID) + trace.close(status="success") + + summary = _records(trace.path)[-1] + assert set(summary["budget_exceeded"]) == exceeded + assert summary["budgets_passed"] is (not exceeded) + + +def test_trace_records_are_individually_capped_and_diagnostics_are_byte_truncated( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + trace_mod = _trace_module() + project = tmp_path / "project" + project.mkdir() + monkeypatch.setattr(trace_mod, "default_log_dir", lambda: tmp_path / "logs") + trace = trace_mod.StartupTrace(project, _LAUNCH_ID, enabled=True) + trace.record_launch_anchor() + trace.record_attempt_anchor( + attempt=1, + view_id=_VIEW_ID, + diagnostics={"raw_output": "é" * 100_000}, + ) + trace.close(status="error") + + raw_records = trace.path.read_bytes().splitlines(keepends=True) + assert raw_records + assert all(len(record) <= _RECORD_LIMIT for record in raw_records) + assert b"\xc3\xa9" * 100_000 not in b"".join(raw_records) + assert all(json.loads(record) for record in raw_records) + + +def test_mandatory_record_overflow_closes_the_trace_with_an_explicit_status( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + trace_mod = _trace_module() + project = tmp_path / "project" + project.mkdir() + monkeypatch.setattr(trace_mod, "default_log_dir", lambda: tmp_path / "logs") + trace = trace_mod.StartupTrace(project, _LAUNCH_ID, enabled=True) + trace.record_launch_anchor() + + with pytest.raises(RuntimeError, match="overflow|16 KiB"): + trace.record_stage("mandatory-" + "x" * _RECORD_LIMIT, attempt=1, view_id=_VIEW_ID) + + records = _records(trace.path) + assert records[-1]["record_type"] == "summary" + assert records[-1]["status"] == "trace_record_overflow" + assert all( + len(line) <= _RECORD_LIMIT for line in trace.path.read_bytes().splitlines(keepends=True) + ) + + +@pytest.mark.parametrize("status", ["success", "error", "interrupted", "child_failed"]) +def test_trace_status_closes_exactly_once( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + status: str, +) -> None: + trace_mod = _trace_module() + project = tmp_path / "project" + project.mkdir() + monkeypatch.setattr(trace_mod, "default_log_dir", lambda: tmp_path / "logs") + trace = trace_mod.StartupTrace(project, _LAUNCH_ID, enabled=True) + trace.record_launch_anchor() + trace.close(status=status) + trace.close(status=status) + + records = _records(trace.path) + summaries = [record for record in records if record["record_type"] == "summary"] + assert [summary["status"] for summary in summaries] == [status] + with pytest.raises(RuntimeError, match="closed|terminal"): + trace.close(status="success" if status != "success" else "error") + + +def test_trace_refuses_to_follow_an_existing_trace_symlink( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + trace_mod = _trace_module() + project = tmp_path / "project" + project.mkdir() + monkeypatch.setattr(trace_mod, "default_log_dir", lambda: tmp_path / "logs") + path = trace_mod.startup_trace_path(project, _LAUNCH_ID) + path.parent.mkdir(parents=True) + outside = tmp_path / "outside.jsonl" + outside.write_bytes(b"sentinel\n") + path.symlink_to(outside) + trace = trace_mod.StartupTrace(project, _LAUNCH_ID, enabled=True) + + with pytest.raises(OSError): + trace.record_launch_anchor() + assert outside.read_bytes() == b"sentinel\n" + + +def test_observer_relay_is_byte_transparent_and_semantic_matching_is_ansi_normalized() -> None: + _, _, PtyObserver = _observer_api() + observer = PtyObserver(readiness_probe=None) + chunks = [ + b"\x1b[2J\x1b[33m1 hook", + b"s need rev\x1b[0m", + b"iew before it can run.\r\n", + ] + + relayed = b"".join(observer.observe_output(chunk) for chunk in chunks) + + assert relayed == b"".join(chunks) + assert observer.first_output_seen is True + assert observer.hook_review_seen is True + assert "\x1b" not in observer.normalized_window + assert "hooks need review before it can run" in observer.normalized_window.lower() + + +def test_observer_matching_window_and_retained_output_are_hard_capped() -> None: + _, _, PtyObserver = _observer_api() + observer = PtyObserver(readiness_probe=None) + payload = b"x" * (_WINDOW_LIMIT + 8192) + + assert observer.observe_output(payload) == payload + assert len(observer.retained_output) <= _WINDOW_LIMIT + assert len(observer.normalized_window.encode("utf-8")) <= _WINDOW_LIMIT + + +def _make_state_db(sqlite_home: Path, status: str = "complete") -> Path: + sqlite_home.mkdir(parents=True, exist_ok=True) + path = sqlite_home / "state_5.sqlite" + with sqlite3.connect(path) as connection: + connection.execute( + "CREATE TABLE backfill_state (id INTEGER PRIMARY KEY, status TEXT NOT NULL)" + ) + connection.execute("INSERT INTO backfill_state (id, status) VALUES (1, ?)", (status,)) + return path + + +def _probe(sqlite_home: Path): + CodexStateReadinessProbe, _, _ = _observer_api() + return CodexStateReadinessProbe( + codex_version="codex-cli 0.145.0", + sqlite_home=sqlite_home, + ) + + +def test_readiness_probe_accepts_only_the_supported_complete_schema(tmp_path: Path) -> None: + _, ObserverStatus, _ = _observer_api() + sqlite_home = tmp_path / "sqlite-home" + _make_state_db(sqlite_home) + + assert _probe(sqlite_home).check() is ObserverStatus.READY + + +@pytest.mark.parametrize( + ("fixture", "expected_name"), + [ + ("absent", "ABSENT"), + ("corrupt", "CORRUPT"), + ("incomplete", "INCOMPLETE"), + ("changed", "SCHEMA_CHANGED"), + ], +) +def test_readiness_probe_fails_closed_for_unready_state( + tmp_path: Path, fixture: str, expected_name: str +) -> None: + _, ObserverStatus, _ = _observer_api() + sqlite_home = tmp_path / fixture + sqlite_home.mkdir() + if fixture == "corrupt": + (sqlite_home / "state_5.sqlite").write_bytes(b"not sqlite") + elif fixture == "incomplete": + _make_state_db(sqlite_home, status="running") + elif fixture == "changed": + with sqlite3.connect(sqlite_home / "state_5.sqlite") as connection: + connection.execute("CREATE TABLE backfill_state (id INTEGER PRIMARY KEY)") + connection.execute("INSERT INTO backfill_state (id) VALUES (1)") + + assert _probe(sqlite_home).check() is getattr(ObserverStatus, expected_name) + + +def test_readiness_probe_reports_a_locked_database_without_waiting( + tmp_path: Path, +) -> None: + _, ObserverStatus, _ = _observer_api() + sqlite_home = tmp_path / "locked" + path = _make_state_db(sqlite_home) + owner = sqlite3.connect(path, timeout=0) + owner.execute("BEGIN EXCLUSIVE") + try: + assert _probe(sqlite_home).check() is ObserverStatus.LOCKED + finally: + owner.rollback() + owner.close() + + +def test_readiness_probe_rejects_unmapped_codex_versions(tmp_path: Path) -> None: + CodexStateReadinessProbe, ObserverStatus, _ = _observer_api() + sqlite_home = tmp_path / "sqlite-home" + _make_state_db(sqlite_home) + probe = CodexStateReadinessProbe( + codex_version="codex-cli 0.146.0", + sqlite_home=sqlite_home, + ) + + assert probe.check() is ObserverStatus.UNSUPPORTED_VERSION + + +def test_readiness_wait_distinguishes_timeout_and_cancellation(tmp_path: Path) -> None: + _, ObserverStatus, _ = _observer_api() + probe = _probe(tmp_path / "absent") + + assert probe.wait(timeout_seconds=0.0) is ObserverStatus.TIMEOUT + assert probe.wait(timeout_seconds=1.0, cancelled=lambda: True) is ObserverStatus.CANCELLED diff --git a/tests/cli/test_init_helpers.py b/tests/cli/test_init_helpers.py index 869f51b563..ebd15dbf99 100644 --- a/tests/cli/test_init_helpers.py +++ b/tests/cli/test_init_helpers.py @@ -3,6 +3,7 @@ from __future__ import annotations import subprocess +from contextlib import contextmanager from pathlib import Path from unittest.mock import MagicMock @@ -130,8 +131,7 @@ def fake_get_backend(name: str): class TestRegisterAllBackendDispatch: - """Dispatch tests: codex calls ensure_codex_mcp_registered, - claude-code calls _register_mcp_server.""" + """Dispatch tests for backend-specific registration surfaces.""" def _setup_register_all( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, backend_name: str @@ -173,9 +173,15 @@ def _setup_register_all( "autoskillit.execution.ensure_codex_mcp_registered", lambda **kwargs: codex_calls.append("ensure_codex") or True, ) + + @contextmanager + def record_transaction(**kwargs): + codex_calls.append(("transaction", kwargs)) + yield kwargs["source_codex_home"] / "config.toml" + monkeypatch.setattr( - "autoskillit.cli._hooks_codex.sync_hooks_to_codex_config", - lambda **kwargs: True, + "autoskillit.cli._init_helpers.codex_prelaunch_transaction", + record_transaction, ) # Override conftest's blanket patch on _is_plugin_installed to let real logic run monkeypatch.setattr( @@ -189,22 +195,32 @@ def _setup_register_all( monkeypatch.setattr("autoskillit.config.load_config", lambda p=None: mock_config) mock_backend = MagicMock() + mock_backend.source_codex_home = tmp_path / "codex-source" mock_backend.capabilities.mcp_config_capable = backend_name == "codex" mock_backend.capabilities.plugin_install_capable = backend_name != "codex" + mock_backend.capabilities.hook_config_format = "toml_nested" monkeypatch.setattr("autoskillit.execution.get_backend", lambda name: mock_backend) (tmp_path / "pkg").mkdir(exist_ok=True) return codex_calls, mcp_calls - def test_codex_backend_calls_ensure_codex_mcp_registered( + def test_codex_backend_calls_composed_codex_config_transaction( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: from autoskillit.cli._init_helpers import _register_all codex_calls, mcp_calls = self._setup_register_all(monkeypatch, tmp_path, "codex") _register_all("user", tmp_path) - assert codex_calls, "ensure_codex_mcp_registered must be called for codex backend" + assert codex_calls == [ + ( + "transaction", + { + "source_codex_home": tmp_path / "codex-source", + "hook_config_format": "toml_nested", + }, + ) + ] assert not mcp_calls, "_register_mcp_server must NOT be called for codex backend" def test_claude_code_backend_calls_register_mcp_server( @@ -215,7 +231,7 @@ def test_claude_code_backend_calls_register_mcp_server( codex_calls, mcp_calls = self._setup_register_all(monkeypatch, tmp_path, "claude-code") _register_all("user", tmp_path) assert mcp_calls, "_register_mcp_server must be called for claude-code backend" - assert codex_calls, "ensure_codex_mcp_registered must be called unconditionally" + assert codex_calls == ["ensure_codex"] def test_codex_backend_does_not_write_claude_json( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path @@ -279,20 +295,20 @@ def capture_register_all(*args, **kwargs): assert register_all_calls[0]["backend"].name == "codex" -class TestRegisterAllCodexHookWiring: - """Codex hook registration is wired into _register_all().""" +class TestRegisterAllCodexConfigTransaction: + """Codex init performs one composed MCP and hook transaction.""" def _setup( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, backend_name: str ) -> tuple[list, list]: - """Shared setup: patch all side effects, return (codex_calls, hook_sync_calls).""" + """Shared setup: return (public MCP calls, composed transaction calls).""" from unittest.mock import MagicMock import autoskillit.cli._hooks as _hooks_mod import autoskillit.core.paths as _core_paths codex_calls: list = [] - hook_sync_calls: list = [] + transaction_calls: list = [] monkeypatch.setattr(_hooks_mod, "sweep_all_scopes_for_orphans", lambda p: None) monkeypatch.setattr(_hooks_mod, "sync_hooks_to_settings", lambda p: None) @@ -319,9 +335,15 @@ def _setup( "autoskillit.execution.ensure_codex_mcp_registered", lambda **kwargs: codex_calls.append("ensure_codex") or True, ) + + @contextmanager + def record_transaction(**kwargs): + transaction_calls.append(kwargs) + yield kwargs["source_codex_home"] / "config.toml" + monkeypatch.setattr( - "autoskillit.cli._hooks_codex.sync_hooks_to_codex_config", - lambda **kwargs: hook_sync_calls.append(kwargs) or True, + "autoskillit.cli._init_helpers.codex_prelaunch_transaction", + record_transaction, ) monkeypatch.setattr( "autoskillit.cli._init_helpers._is_plugin_installed", @@ -333,6 +355,8 @@ def _setup( monkeypatch.setattr("autoskillit.config.load_config", lambda p=None: mock_config) mock_backend = MagicMock() + mock_backend.name = backend_name + mock_backend.source_codex_home = tmp_path / "immutable-codex-source" mock_backend.capabilities.mcp_config_capable = backend_name == "codex" mock_backend.capabilities.plugin_install_capable = backend_name != "codex" mock_backend.capabilities.hook_config_format = "toml_nested" @@ -340,39 +364,58 @@ def _setup( (tmp_path / "pkg").mkdir(exist_ok=True) - return codex_calls, hook_sync_calls + return codex_calls, transaction_calls - def test_codex_backend_calls_sync_hooks_to_codex_config( + def test_codex_backend_uses_one_composed_transaction_with_immutable_source_home( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: from autoskillit.cli._init_helpers import _register_all - codex_calls, hook_sync_calls = self._setup(monkeypatch, tmp_path, "codex") + codex_calls, transaction_calls = self._setup(monkeypatch, tmp_path, "codex") _register_all("user", tmp_path) - assert len(hook_sync_calls) == 1 - assert hook_sync_calls[0].get("hook_config_format") == "toml_nested" - - def test_claude_code_backend_does_not_call_sync_hooks_to_codex_config( + assert codex_calls == [] + assert transaction_calls == [ + { + "source_codex_home": tmp_path / "immutable-codex-source", + "hook_config_format": "toml_nested", + } + ] + + def test_non_codex_backend_keeps_the_lock_owning_public_mcp_facade( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: from autoskillit.cli._init_helpers import _register_all - codex_calls, hook_sync_calls = self._setup(monkeypatch, tmp_path, "claude-code") + codex_calls, transaction_calls = self._setup(monkeypatch, tmp_path, "claude-code") _register_all("user", tmp_path) - assert not hook_sync_calls + assert transaction_calls == [] + assert codex_calls == ["ensure_codex"] - def test_sync_hooks_to_codex_config_exception_propagates( + def test_composed_transaction_exception_propagates( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: from autoskillit.cli._init_helpers import _register_all codex_calls, _ = self._setup(monkeypatch, tmp_path, "codex") + + class FailingTransaction: + def __enter__(self) -> None: + raise RuntimeError("config transaction failed") + + def __exit__(self, *exc_info: object) -> None: + return None + + def fail_transaction(**kwargs): + del kwargs + return FailingTransaction() + monkeypatch.setattr( - "autoskillit.cli._hooks_codex.sync_hooks_to_codex_config", - lambda **kwargs: (_ for _ in ()).throw(RuntimeError("hook sync failed")), + "autoskillit.cli._init_helpers.codex_prelaunch_transaction", + fail_transaction, ) - with pytest.raises(RuntimeError, match="hook sync failed"): + with pytest.raises(RuntimeError, match="config transaction failed"): _register_all("user", tmp_path) + assert codex_calls == [] class TestRegisterAllBackendBranching: @@ -388,7 +431,7 @@ def _write_config(self, tmp_path: Path, backend: str) -> None: " rev: v8.18.0\n hooks:\n - id: gitleaks\n" ) - def test_codex_path_calls_ensure_codex_mcp_registered( + def test_codex_path_calls_composed_config_transaction( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: from unittest.mock import MagicMock, patch @@ -409,10 +452,13 @@ def test_codex_path_calls_ensure_codex_mcp_registered( monkeypatch.setattr("autoskillit.core.ensure_project_temp", lambda p: tmp_path / "temp") (tmp_path / "pkg").mkdir(exist_ok=True) - with patch("autoskillit.execution.ensure_codex_mcp_registered") as mock_codex: - mock_codex.return_value = True + with ( + patch("autoskillit.cli._init_helpers.codex_prelaunch_transaction") as mock_transaction, + patch("autoskillit.execution.ensure_codex_mcp_registered") as mock_codex, + ): _register_all("user", tmp_path) - mock_codex.assert_called() + mock_transaction.assert_called_once() + mock_codex.assert_not_called() def test_codex_path_skips_sync_hooks_to_settings( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path @@ -437,7 +483,7 @@ def test_codex_path_skips_sync_hooks_to_settings( (tmp_path / "pkg").mkdir(exist_ok=True) with ( - patch("autoskillit.execution.ensure_codex_mcp_registered", return_value=True), + patch("autoskillit.cli._init_helpers.codex_prelaunch_transaction"), patch.object(_hooks_mod, "sync_hooks_to_settings") as mock_sync, ): _register_all("user", tmp_path) @@ -489,12 +535,12 @@ def test_claude_code_path_unchanged( class TestRegisterAllCodexMcpRegistration: - """Unconditional ensure_codex_mcp_registered() call in _register_all().""" + """Public MCP facade remains the non-Codex registration path.""" def _setup( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, backend_name: str - ) -> MagicMock: - """Stub collaborators; return mock for ensure_codex_mcp_registered.""" + ) -> tuple[MagicMock, MagicMock]: + """Stub collaborators; return public-facade and transaction mocks.""" import autoskillit.cli._hooks as _hooks_mod import autoskillit.core.paths as _core_paths @@ -528,39 +574,45 @@ def _setup( monkeypatch.setattr("autoskillit.config.load_config", lambda p=None: mock_config) mock_backend = MagicMock() + mock_backend.source_codex_home = tmp_path / "codex-source" mock_backend.capabilities.mcp_config_capable = backend_name == "codex" mock_backend.capabilities.plugin_install_capable = backend_name != "codex" + mock_backend.capabilities.hook_config_format = "toml_nested" monkeypatch.setattr("autoskillit.execution.get_backend", lambda name: mock_backend) - if backend_name == "codex": - monkeypatch.setattr( - "autoskillit.cli._hooks_codex.sync_hooks_to_codex_config", - lambda **kwargs: True, - ) - codex_mock = MagicMock(return_value=True) monkeypatch.setattr("autoskillit.execution.ensure_codex_mcp_registered", codex_mock) + transaction_mock = MagicMock() + monkeypatch.setattr( + "autoskillit.cli._init_helpers.codex_prelaunch_transaction", + transaction_mock, + ) (tmp_path / "pkg").mkdir(exist_ok=True) - return codex_mock + return codex_mock, transaction_mock - def test_codex_backend_calls_ensure_codex_mcp_registered( + def test_codex_backend_uses_composed_transaction_instead_of_public_facade( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: from autoskillit.cli._init_helpers import _register_all - codex_mock = self._setup(monkeypatch, tmp_path, "codex") + codex_mock, transaction_mock = self._setup(monkeypatch, tmp_path, "codex") _register_all("user", tmp_path) - codex_mock.assert_called_once() + transaction_mock.assert_called_once_with( + source_codex_home=tmp_path / "codex-source", + hook_config_format="toml_nested", + ) + codex_mock.assert_not_called() def test_non_codex_backend_calls_ensure_codex_mcp_registered( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: from autoskillit.cli._init_helpers import _register_all - codex_mock = self._setup(monkeypatch, tmp_path, "claude-code") + codex_mock, transaction_mock = self._setup(monkeypatch, tmp_path, "claude-code") _register_all("user", tmp_path) codex_mock.assert_called_once() + transaction_mock.assert_not_called() class TestRegisterAllDualRegistration: diff --git a/tests/cli/test_interactive_subprocess_contracts.py b/tests/cli/test_interactive_subprocess_contracts.py index fa2e25b6cc..b804d426de 100644 --- a/tests/cli/test_interactive_subprocess_contracts.py +++ b/tests/cli/test_interactive_subprocess_contracts.py @@ -129,3 +129,146 @@ def test_interactive_subprocess_calls_wrapped_in_terminal_guard(py_file: Path) - f"`autoskillit.cli.ui._terminal`.\n\n" f"See: tests/cli/test_input_tty_contracts.py for the analogous pattern." ) + + +def _calls_in(node: ast.AST, *, owner: str, attr: str) -> list[ast.Call]: + return [ + call + for call in ast.walk(node) + if isinstance(call, ast.Call) + and isinstance(call.func, ast.Attribute) + and isinstance(call.func.value, ast.Name) + and call.func.value.id == owner + and call.func.attr == attr + ] + + +def _function(tree: ast.Module, name: str) -> ast.FunctionDef: + matches = [ + node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == name + ] + assert len(matches) == 1, f"expected exactly one {name}() definition" + return matches[0] + + +def _popen_outside_terminal_guard(node: ast.AST) -> list[int]: + violations: list[int] = [] + + class GuardTracker(ast.NodeVisitor): + def __init__(self) -> None: + self.guard_depth = 0 + + def visit_With(self, with_node: ast.With) -> None: + entered = any( + isinstance(item.context_expr, ast.Call) + and isinstance(item.context_expr.func, ast.Name) + and item.context_expr.func.id == "terminal_guard" + for item in with_node.items + ) + if entered: + self.guard_depth += 1 + self.generic_visit(with_node) + if entered: + self.guard_depth -= 1 + + def visit_Call(self, call: ast.Call) -> None: + if ( + isinstance(call.func, ast.Attribute) + and isinstance(call.func.value, ast.Name) + and call.func.value.id == "subprocess" + and call.func.attr == "Popen" + and self.guard_depth == 0 + ): + violations.append(call.lineno) + self.generic_visit(call) + + GuardTracker().visit(node) + return violations + + +def test_cook_attempt_popen_is_owned_only_by_the_shared_process_owner() -> None: + session_dir = CLI_DIR / "session" + violations: list[str] = [] + for path in sorted(session_dir.rglob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + if path.name != "_session_process.py" and _calls_in( + tree, owner="subprocess", attr="Popen" + ): + violations.append(path.name) + + assert violations == [] + process_path = session_dir / "_session_process.py" + process_tree = ast.parse(process_path.read_text(encoding="utf-8"), filename=str(process_path)) + run_attempt = _function(process_tree, "run_cook_attempt") + popen_calls = _calls_in(run_attempt, owner="subprocess", attr="Popen") + assert popen_calls, "run_cook_attempt() must own every cook-attempt Popen" + assert _popen_outside_terminal_guard(run_attempt) == [] + + cook_source = (session_dir / "_session_cook.py").read_text(encoding="utf-8") + assert "run_cook_attempt(" in cook_source + assert "subprocess.run(" not in cook_source + assert "subprocess.Popen(" not in cook_source + + +def test_cook_popen_returns_before_the_single_spawn_callback() -> None: + process_path = CLI_DIR / "session" / "_session_process.py" + tree = ast.parse(process_path.read_text(encoding="utf-8"), filename=str(process_path)) + run_attempt = _function(tree, "run_cook_attempt") + popen_calls = _calls_in(run_attempt, owner="subprocess", attr="Popen") + on_spawn_calls = [ + call + for call in ast.walk(run_attempt) + if isinstance(call, ast.Call) + and isinstance(call.func, ast.Name) + and call.func.id == "on_spawn" + ] + + assert len(on_spawn_calls) == 1 + assert max(call.lineno for call in popen_calls) < on_spawn_calls[0].lineno + + +def test_cook_pty_path_has_no_unsafe_post_fork_python_setup() -> None: + session_dir = CLI_DIR / "session" + paths = [ + session_dir / "_session_cook.py", + session_dir / "_session_process.py", + session_dir / "pty" / "_observer.py", + session_dir / "pty" / "_exec.py", + ] + violations: list[str] = [] + for path in paths: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for call in (node for node in ast.walk(tree) if isinstance(node, ast.Call)): + if any(keyword.arg == "preexec_fn" for keyword in call.keywords): + violations.append(f"{path.name}:{call.lineno}:preexec_fn") + if ( + isinstance(call.func, ast.Attribute) + and isinstance(call.func.value, ast.Name) + and call.func.value.id == "pty" + and call.func.attr == "fork" + ): + violations.append(f"{path.name}:{call.lineno}:pty.fork") + + exec_source = paths[-1].read_text(encoding="utf-8") + assert "setsid(" not in exec_source + assert "login_tty(" not in exec_source + assert violations == [] + + +def test_terminal_and_lease_ownership_are_not_duplicated_across_pty_layers() -> None: + session_dir = CLI_DIR / "session" + process_source = (session_dir / "_session_process.py").read_text(encoding="utf-8") + observer_source = (session_dir / "pty" / "_observer.py").read_text(encoding="utf-8") + + assert "terminal_guard" in process_source + assert "tcsetpgrp" in process_source + assert "LOCK_UN" not in process_source + assert "tcsetpgrp" not in observer_source + assert "subprocess.Popen" not in observer_source + + +def test_unchanged_order_fleet_launch_path_retains_its_separate_run_owner() -> None: + launch_source = (CLI_DIR / "session" / "_session_launch.py").read_text(encoding="utf-8") + + assert "subprocess.run(" in launch_source + assert "subprocess.Popen(" not in launch_source diff --git a/tests/cli/test_reload_loop.py b/tests/cli/test_reload_loop.py index 5e1d10f92d..97e44f8748 100644 --- a/tests/cli/test_reload_loop.py +++ b/tests/cli/test_reload_loop.py @@ -7,6 +7,8 @@ import subprocess from contextlib import contextmanager from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock import pytest @@ -38,152 +40,301 @@ def _write_sentinel(project_dir: Path, session_id: str) -> Path: return sentinel -# --------------------------------------------------------------------------- -# RL-1 — _run_cook_session returns None on normal exit (no sentinel) -# --------------------------------------------------------------------------- - - -def test_cook_session_no_sentinel_returns_none( +def test_cook_keeps_managed_home_across_reload_and_transfers_resume_after_attempt_exit( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _make_result(0)) - monkeypatch.setattr( - "autoskillit.cli.session._session_cook.terminal_guard", _noop_terminal_guard + from autoskillit.core import ( + BackendConventions, + CmdSpec, + CookSessionHandle, + EffectiveSkillCatalogAuthority, + HookTrustPolicy, + ManagedSessionHome, + NamedResume, + NoResume, + SkillProjectionContextAuthority, + ValidatedAddDir, ) - from autoskillit.cli.session._session_cook import _run_cook_session - - result = _run_cook_session( - cmd=["claude"], - env={}, - _first_run=False, - initial_prompt=None, - project_dir=tmp_path, - ) - assert result is None + events: list[tuple[object, ...]] = [] + generated_home = tmp_path / "managed-home" + skills_dir = generated_home / "skills" + skills_dir.mkdir(parents=True) + manager = MagicMock() + + @contextmanager + def managed_session( + launch_id: str, + catalog: EffectiveSkillCatalogAuthority, + projection_context: SkillProjectionContextAuthority, + ): + assert projection_context.catalog == catalog + events.append(("managed-enter", launch_id, projection_context)) + try: + yield ManagedSessionHome( + launch_id=launch_id, + generated_home=generated_home, + skills_dir=ValidatedAddDir(str(skills_dir)), + pass_fds=(7,), + ) + finally: + events.append(("managed-exit", launch_id)) + + manager.managed_session.side_effect = managed_session + class _MockBackend: + name = "claude-code" + conventions = BackendConventions() + capabilities = SimpleNamespace( + hook_trust_policy=HookTrustPolicy.AUTOMATED, + session_dir_persistent=False, + ) -# --------------------------------------------------------------------------- -# RL-2 — _run_cook_session returns session_id when sentinel file exists -# --------------------------------------------------------------------------- + def binary_name(self) -> str: + return "claude" + def recover_cook_history(self) -> None: + events.append(("recover",)) -def test_cook_session_with_sentinel_returns_session_id( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - _write_sentinel(tmp_path, "sess-001") - monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _make_result(0)) - monkeypatch.setattr( - "autoskillit.cli.session._session_cook.terminal_guard", _noop_terminal_guard + def build_interactive_cmd(self, **kwargs): + resume_spec = kwargs["resume_spec"] + events.append(("build", resume_spec)) + return CmdSpec(cmd=("claude",), env={"ATTEMPT": str(len(events))}) + + def validate_interactive_invocation(self, spec: CmdSpec) -> list[str]: + events.append(("validate", spec)) + return [] + + @contextmanager + def cook_session_context( + self, + *, + session_home: Path, + project_dir: Path, + launch_id: str, + attempt: int, + current_resume_spec: object, + ): + events.append( + ( + "attempt-enter", + attempt, + current_resume_spec, + session_home, + project_dir, + launch_id, + ) + ) + try: + yield CookSessionHandle( + view_id=f"{launch_id}-{attempt}", + pass_fds=(11,), + _record_spawn=lambda pid, pgid: events.append(("spawn", attempt, pid, pgid)), + _record_reaped=lambda pid, pgid: events.append(("reaped", attempt, pid, pgid)), + ) + finally: + events.append(("attempt-exit", attempt, current_resume_spec)) + + results = iter( + ( + SimpleNamespace(pid=101, pgid=101, returncode=17), + SimpleNamespace(pid=102, pgid=102, returncode=42), + ) ) - from autoskillit.cli.session._session_cook import _run_cook_session + def fake_run_cook_attempt( + spec: CmdSpec, + *, + pass_fds: tuple[int, ...], + on_spawn, + on_reaped, + **_: object, + ) -> object: + attempt = sum(event[0] == "run" for event in events) + 1 + events.append(("run", attempt, spec, pass_fds)) + on_spawn(100 + attempt, 100 + attempt) + on_reaped(100 + attempt, 100 + attempt) + return next(results) + + sentinels = iter(("sess-001", None)) + onboarded: list[Path] = [] + + def consume_sentinel(project_dir: Path) -> str | None: + value = next(sentinels) + events.append(("sentinel", value, project_dir)) + return value - result = _run_cook_session( - cmd=["claude"], - env={}, - _first_run=False, - initial_prompt=None, - project_dir=tmp_path, + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(shutil, "which", lambda x: "/usr/bin/claude") + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("autoskillit.cli._onboarding.is_first_run", lambda _: True) + monkeypatch.setattr( + "autoskillit.cli._onboarding.run_onboarding_menu", + lambda *args, **kwargs: "/autoskillit:setup-project", ) - assert result == "sess-001" - - -# --------------------------------------------------------------------------- -# RL-3 — _run_cook_session deletes sentinel after reading it -# --------------------------------------------------------------------------- - - -def test_cook_session_sentinel_consumed_after_reload( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - sentinel = _write_sentinel(tmp_path, "sess-del") - assert sentinel.exists() - - monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _make_result(0)) monkeypatch.setattr( - "autoskillit.cli.session._session_cook.terminal_guard", _noop_terminal_guard + "autoskillit.cli._onboarding.mark_onboarded", + lambda project_dir: onboarded.append(project_dir), ) - - from autoskillit.cli.session._session_cook import _run_cook_session - - _run_cook_session( - cmd=["claude"], - env={}, - _first_run=False, - initial_prompt=None, - project_dir=tmp_path, + monkeypatch.setattr("autoskillit.cli.ui._timed_input.timed_prompt", lambda *args, **kwargs: "") + monkeypatch.setattr( + "autoskillit.workspace.DefaultSessionSkillManager", lambda *args, **kwargs: manager + ) + monkeypatch.setattr( + "autoskillit.cli.session._session_process.run_cook_attempt", fake_run_cook_attempt + ) + monkeypatch.setattr( + "autoskillit.cli.session._session_reload.consume_reload_sentinel", consume_sentinel ) - assert not sentinel.exists() + from autoskillit import cli -# --------------------------------------------------------------------------- -# RL-4 — cook() reload loop rebuilds with NamedResume on reload -# --------------------------------------------------------------------------- + with pytest.raises(SystemExit) as exc_info: + cli.cook(backend=_MockBackend()) + assert exc_info.value.code == 42 + managed_enters = [event for event in events if event[0] == "managed-enter"] + managed_exits = [event for event in events if event[0] == "managed-exit"] + assert len(managed_enters) == len(managed_exits) == 1 -def test_cook_reload_loop_uses_named_resume( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - from autoskillit.core import BackendConventions, CmdSpec + attempt_enters = [event for event in events if event[0] == "attempt-enter"] + assert [event[1] for event in attempt_enters] == [1, 2] + assert all(event[3] == generated_home for event in attempt_enters) + assert isinstance(attempt_enters[0][2], NoResume) + assert isinstance(attempt_enters[1][2], NamedResume) + assert attempt_enters[1][2].session_id == "sess-001" - run_count = [0] - captured_resume_specs: list = [] + first_sentinel = events.index( + next(event for event in events if event[:2] == ("sentinel", "sess-001")) + ) + first_reaped = events.index(next(event for event in events if event[:2] == ("reaped", 1))) + first_exit = events.index(next(event for event in events if event[:2] == ("attempt-exit", 1))) + second_build = events.index( + next( + event for event in events if event[0] == "build" and isinstance(event[1], NamedResume) + ) + ) + assert first_reaped < first_sentinel < first_exit < second_build - def fake_run_cook_session(*, cmd, env, _first_run, initial_prompt, project_dir): - run_count[0] += 1 - if run_count[0] == 1: - return "sess-001" - return None + run_events = [event for event in events if event[0] == "run"] + assert [event[3] for event in run_events] == [(7, 11), (7, 11)] + assert events.index(managed_exits[0]) > events.index( + next(event for event in events if event[:2] == ("attempt-exit", 2)) + ) + assert onboarded == [] + assert ("recover",) not in events + + +@pytest.mark.parametrize( + ("reload_ids", "expected_attempts", "message"), + [ + (("same", "same"), 2, "Repeated reload_id"), + (tuple(f"reload-{index}" for index in range(11)), 11, "Too many reloads"), + ], +) +def test_cook_rejects_repeated_and_excessive_reload_requests( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + reload_ids: tuple[str, ...], + expected_attempts: int, + message: str, +) -> None: + from autoskillit.core import ( + BackendConventions, + CmdSpec, + CookSessionHandle, + EffectiveSkillCatalogAuthority, + HookTrustPolicy, + ManagedSessionHome, + SkillProjectionContextAuthority, + ValidatedAddDir, + ) - class _MockBackend: + generated_home = tmp_path / "managed-home" + skills_dir = generated_home / "skills" + skills_dir.mkdir(parents=True) + manager = MagicMock() + managed_exits: list[str] = [] + attempts: list[int] = [] + + @contextmanager + def managed_session( + launch_id: str, + catalog: EffectiveSkillCatalogAuthority, + projection_context: SkillProjectionContextAuthority, + ): + assert projection_context.catalog == catalog + try: + yield ManagedSessionHome( + launch_id=launch_id, + generated_home=generated_home, + skills_dir=ValidatedAddDir(str(skills_dir)), + pass_fds=(), + ) + finally: + managed_exits.append(launch_id) + + manager.managed_session.side_effect = managed_session + + class _Backend: name = "claude-code" conventions = BackendConventions() + capabilities = SimpleNamespace( + hook_trust_policy=HookTrustPolicy.AUTOMATED, + session_dir_persistent=False, + ) def binary_name(self) -> str: return "claude" - def build_interactive_cmd(self, **kwargs): - captured_resume_specs.append(kwargs.get("resume_spec")) + def recover_cook_history(self) -> None: + return None + + def build_interactive_cmd(self, **kwargs: object) -> CmdSpec: return CmdSpec(cmd=("claude",), env={}) + def validate_interactive_invocation(self, spec: CmdSpec) -> list[str]: + return [] + + @contextmanager + def cook_session_context(self, *, attempt: int, **kwargs: object): + attempts.append(attempt) + yield CookSessionHandle( + view_id=f"view-{attempt}", + pass_fds=(), + _record_spawn=lambda _pid, _pgid: None, + _record_reaped=lambda _pid, _pgid: None, + ) + + sentinel_values = iter(reload_ids) monkeypatch.chdir(tmp_path) - monkeypatch.setattr(shutil, "which", lambda x: "/usr/bin/claude") + monkeypatch.setattr(shutil, "which", lambda _name: "/usr/bin/claude") monkeypatch.setattr("sys.stdin.isatty", lambda: True) - monkeypatch.setattr("builtins.input", lambda _prompt="": "") monkeypatch.setattr("autoskillit.cli._onboarding.is_first_run", lambda _: False) monkeypatch.setattr( - "autoskillit.cli.session._session_cook._run_cook_session", fake_run_cook_session + "autoskillit.cli.ui._timed_input.timed_prompt", + lambda *args, **kwargs: "", + ) + monkeypatch.setattr( + "autoskillit.workspace.DefaultSessionSkillManager", + lambda *args, **kwargs: manager, + ) + monkeypatch.setattr( + "autoskillit.cli.session._session_process.run_cook_attempt", + lambda *args, **kwargs: SimpleNamespace(pid=1, pgid=1, returncode=0), ) - - from autoskillit.workspace.session_skills import DefaultSessionSkillManager - - fake_skills_dir = tmp_path / "fake-skills" - fake_skills_dir.mkdir() - - def fake_init_session( - self, - sid, - catalog, - projection_context, - ): - return fake_skills_dir - monkeypatch.setattr( - DefaultSessionSkillManager, - "init_session", - fake_init_session, + "autoskillit.cli.session._session_reload.consume_reload_sentinel", + lambda _project: next(sentinel_values), ) from autoskillit import cli - from autoskillit.core import NamedResume - cli.cook(backend=_MockBackend()) + with pytest.raises(SystemExit, match=message): + cli.cook(backend=_Backend()) - assert run_count[0] == 2 - assert len(captured_resume_specs) == 2 - assert isinstance(captured_resume_specs[1], NamedResume) - assert captured_resume_specs[1].session_id == "sess-001" + assert attempts == list(range(1, expected_attempts + 1)) + assert len(managed_exits) == 1 # --------------------------------------------------------------------------- @@ -260,29 +411,3 @@ def fake_run_interactive_session( assert isinstance(captured_resume_specs[0], NoResume) assert isinstance(captured_resume_specs[1], NamedResume) assert captured_resume_specs[1].session_id == "franchise-sess" - - -# --------------------------------------------------------------------------- -# RL-7 — Non-zero exit without sentinel raises SystemExit -# --------------------------------------------------------------------------- - - -def test_non_zero_exit_without_sentinel_raises_system_exit( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _make_result(42)) - monkeypatch.setattr( - "autoskillit.cli.session._session_cook.terminal_guard", _noop_terminal_guard - ) - - from autoskillit.cli.session._session_cook import _run_cook_session - - with pytest.raises(SystemExit) as exc_info: - _run_cook_session( - cmd=["claude"], - env={}, - _first_run=False, - initial_prompt=None, - project_dir=tmp_path, - ) - assert exc_info.value.code == 42 diff --git a/tests/cli/test_session_launch.py b/tests/cli/test_session_launch.py index 80ebe700bb..dc1b03cac0 100644 --- a/tests/cli/test_session_launch.py +++ b/tests/cli/test_session_launch.py @@ -4,22 +4,54 @@ import shutil import subprocess +from contextlib import nullcontext +from pathlib import Path import pytest from autoskillit.cli.session._session_launch import _run_interactive_session -from autoskillit.core import BackendConventions, ClaudeFlags +from autoskillit.core import BackendConventions, ClaudeFlags, HookTrustPolicy from autoskillit.execution.backends.codex import CodexFlags pytestmark = [pytest.mark.layer("cli"), pytest.mark.small] -class _ProjectionBackendStub: - """Minimum projection-facing contract shared by local backend doubles.""" +class _BackendLifecycleStub: + """Projection and lifecycle contract shared by local backend doubles.""" name = "claude-code" conventions = BackendConventions() + def validate_interactive_invocation(self, spec): + return [] + + def ensure_pre_launch(self, *, session_dir: Path | None = None) -> list[str]: + return [] + + def recover_cook_history(self) -> None: + return None + + def cook_session_context( + self, + *, + session_home: Path, + project_dir: Path, + launch_id: str, + attempt: int, + current_resume_spec, + ): + del project_dir + from autoskillit.core import CookSessionHandle + + return nullcontext( + CookSessionHandle( + view_id=f"{launch_id}-{attempt}", + pass_fds=(), + _record_spawn=lambda _pid, _pgid: None, + _record_reaped=lambda _pid, _pgid: None, + ) + ) + # --------------------------------------------------------------------------- # Helpers @@ -48,6 +80,17 @@ def _stub_plugin_installed(monkeypatch: pytest.MonkeyPatch, *, installed: bool = monkeypatch.setattr("autoskillit.core.detect_autoskillit_mcp_prefix", lambda: prefix) +def _stub_codex_pre_launch(monkeypatch: pytest.MonkeyPatch) -> None: + """Keep command-assembly tests independent of an installed Codex binary.""" + from autoskillit.execution.backends.codex import CodexBackend + + monkeypatch.setattr( + CodexBackend, + "ensure_pre_launch", + lambda _self, *, session_dir=None: [], + ) + + # Module-level flags map — shared by Tests B, C, and the registry guard. # Constructed from the enum values themselves so the enums ARE the ground truth. _BACKEND_FLAGS: dict[str, set[str]] = { @@ -62,7 +105,7 @@ def _make_capturing_backend() -> tuple[object, list[dict]]: captured_kwargs: list[dict] = [] - class _CapturingBackend(_ProjectionBackendStub): + class _CapturingBackend(_BackendLifecycleStub): def binary_name(self) -> str: return "claude" @@ -74,9 +117,6 @@ def build_interactive_cmd(self, **kwargs): captured_kwargs.append(kwargs) return CmdSpec(cmd=("claude", "--dangerously-skip-permissions"), env={}) - def ensure_pre_launch(self) -> list[str]: - return [] - return _CapturingBackend(), captured_kwargs @@ -249,9 +289,10 @@ def test_skill_injection_disabled_omits_flags(monkeypatch: pytest.MonkeyPatch) - food_truck_capable=True, completion_record_types=frozenset({"result"}), session_record_types=frozenset({"assistant"}), + hook_trust_policy=HookTrustPolicy.AUTOMATED, ) - class _NoInjectBackend(_ProjectionBackendStub): + class _NoInjectBackend(_BackendLifecycleStub): def binary_name(self) -> str: return "claude" @@ -263,9 +304,6 @@ def build_interactive_cmd(self, **kwargs): build_kwargs.append(kwargs) return CmdSpec(cmd=("claude", "--dangerously-skip-permissions"), env={}) - def ensure_pre_launch(self) -> list[str]: - return [] - from autoskillit.cli.session._session_launch import _run_interactive_session captured: dict = {} @@ -299,7 +337,7 @@ def test_binary_name_from_backend_used_in_which(monkeypatch: pytest.MonkeyPatch) captured_which_arg: list = [] - class _CustomBinaryBackend(_ProjectionBackendStub): + class _CustomBinaryBackend(_BackendLifecycleStub): def binary_name(self) -> str: return "test-agent-binary" @@ -317,14 +355,12 @@ def capabilities(self): food_truck_capable=True, completion_record_types=frozenset({"result"}), session_record_types=frozenset({"assistant"}), + hook_trust_policy=HookTrustPolicy.AUTOMATED, ) def build_interactive_cmd(self, **kwargs): return CmdSpec(cmd=("test-agent-binary", "--dangerously-skip-permissions"), env={}) - def ensure_pre_launch(self) -> list[str]: - return [] - def tracking_which(binary: str): captured_which_arg.append(binary) if binary == "test-agent-binary": @@ -345,7 +381,7 @@ def test_run_interactive_session_uses_injected_backend(monkeypatch: pytest.Monke build_called: list[dict] = [] - class _InjectedBackend(_ProjectionBackendStub): + class _InjectedBackend(_BackendLifecycleStub): def binary_name(self) -> str: return "claude" @@ -363,15 +399,13 @@ def capabilities(self): food_truck_capable=True, completion_record_types=frozenset({"result"}), session_record_types=frozenset({"assistant"}), + hook_trust_policy=HookTrustPolicy.AUTOMATED, ) def build_interactive_cmd(self, **kwargs): build_called.append(kwargs) return CmdSpec(cmd=("claude", "--dangerously-skip-permissions"), env={}) - def ensure_pre_launch(self) -> list[str]: - return [] - _stub_plugin_installed(monkeypatch, installed=True) _capture_subprocess(monkeypatch) _run_interactive_session(system_prompt="test", backend=_InjectedBackend()) @@ -386,7 +420,7 @@ def test_run_interactive_session_default_backend_calls_get_backend( get_backend_called: list = [] - class _FakeBackend(_ProjectionBackendStub): + class _FakeBackend(_BackendLifecycleStub): def binary_name(self) -> str: return "claude" @@ -395,6 +429,7 @@ def capabilities(self): return MagicMock( skill_injection_capable=True, mcp_config_capable=False, + hook_trust_policy=HookTrustPolicy.AUTOMATED, ) def build_interactive_cmd(self, **kwargs): @@ -402,9 +437,6 @@ def build_interactive_cmd(self, **kwargs): return CmdSpec(cmd=("claude", "--dangerously-skip-permissions"), env={}) - def ensure_pre_launch(self) -> list[str]: - return [] - mock_config = MagicMock() mock_config.agent_backend.backend = "claude-code" @@ -440,9 +472,10 @@ def test_get_backend_di_used_in_session_launch(monkeypatch: pytest.MonkeyPatch) food_truck_capable=True, completion_record_types=frozenset({"result"}), session_record_types=frozenset({"assistant"}), + hook_trust_policy=HookTrustPolicy.AUTOMATED, ) - class _DIBackend(_ProjectionBackendStub): + class _DIBackend(_BackendLifecycleStub): def binary_name(self) -> str: return "claude" @@ -454,9 +487,6 @@ def build_interactive_cmd(self, **kwargs): build_calls.append(kwargs) return CmdSpec(cmd=("claude", "--dangerously-skip-permissions"), env={}) - def ensure_pre_launch(self) -> list[str]: - return [] - mock_config = MagicMock() mock_config.agent_backend.backend = "claude-code" @@ -489,9 +519,10 @@ def test_skill_injection_false_via_get_backend_forwards_system_prompt_kwarg( food_truck_capable=True, completion_record_types=frozenset({"result"}), session_record_types=frozenset({"assistant"}), + hook_trust_policy=HookTrustPolicy.AUTOMATED, ) - class _NoInjectDIBackend(_ProjectionBackendStub): + class _NoInjectDIBackend(_BackendLifecycleStub): def binary_name(self) -> str: return "claude" @@ -503,9 +534,6 @@ def build_interactive_cmd(self, **kwargs): build_kwargs.append(kwargs) return CmdSpec(cmd=("claude", "--dangerously-skip-permissions"), env={}) - def ensure_pre_launch(self) -> list[str]: - return [] - mock_config = MagicMock() mock_config.agent_backend.backend = "claude-code" @@ -543,11 +571,12 @@ def test_codex_like_backend_no_claude_flags(monkeypatch: pytest.MonkeyPatch) -> food_truck_capable=False, completion_record_types=frozenset(), session_record_types=frozenset(), + hook_trust_policy=HookTrustPolicy.REVIEW_EACH_SESSION, ) build_kwargs: list[dict] = [] - class _CodexLikeBackend(_ProjectionBackendStub): + class _CodexLikeBackend(_BackendLifecycleStub): def binary_name(self) -> str: return "codex" @@ -559,9 +588,6 @@ def build_interactive_cmd(self, **kwargs): build_kwargs.append(kwargs) return CmdSpec(cmd=("codex", "--dangerously-bypass-approvals-and-sandbox"), env={}) - def ensure_pre_launch(self) -> list[str]: - return [] - captured: dict = {} monkeypatch.setattr(shutil, "which", lambda _: "/usr/bin/codex") @@ -596,7 +622,7 @@ def test_feature_flag_gate_blocks_codex_backend_without_feature( backends_used: list[str] = [] - class _ClaudeStub(_ProjectionBackendStub): + class _ClaudeStub(_BackendLifecycleStub): def binary_name(self) -> str: return "claude" @@ -610,10 +636,7 @@ def build_interactive_cmd(self, **kwargs): backends_used.append("claude-code") return CmdSpec(cmd=("claude", "--dangerously-skip-permissions"), env={}) - def ensure_pre_launch(self) -> list[str]: - return [] - - class _CodexStub(_ProjectionBackendStub): + class _CodexStub(_BackendLifecycleStub): def binary_name(self) -> str: return "codex" @@ -633,15 +656,13 @@ def capabilities(self): food_truck_capable=False, completion_record_types=frozenset(), session_record_types=frozenset(), + hook_trust_policy=HookTrustPolicy.REVIEW_EACH_SESSION, ) def build_interactive_cmd(self, **kwargs): backends_used.append("codex") return CmdSpec(cmd=("codex", "--dangerously-bypass-approvals-and-sandbox"), env={}) - def ensure_pre_launch(self) -> list[str]: - return [] - mock_config = MagicMock() mock_config.agent_backend.backend = "codex" mock_config.features = {} @@ -675,7 +696,7 @@ def test_feature_flag_gate_allows_codex_backend_when_feature_enabled( backends_used: list[str] = [] - class _CodexStub(_ProjectionBackendStub): + class _CodexStub(_BackendLifecycleStub): def binary_name(self) -> str: return "codex" @@ -695,15 +716,13 @@ def capabilities(self): food_truck_capable=False, completion_record_types=frozenset(), session_record_types=frozenset(), + hook_trust_policy=HookTrustPolicy.REVIEW_EACH_SESSION, ) def build_interactive_cmd(self, **kwargs): backends_used.append("codex") return CmdSpec(cmd=("codex", "--dangerously-bypass-approvals-and-sandbox"), env={}) - def ensure_pre_launch(self) -> list[str]: - return [] - mock_config = MagicMock() mock_config.agent_backend.backend = "codex" mock_config.features = {"codex_backend": True} @@ -734,7 +753,7 @@ def test_launch_cook_session_accepts_backend_param(monkeypatch: pytest.MonkeyPat build_calls: list[dict] = [] - class _CapturingBackend(_ProjectionBackendStub): + class _CapturingBackend(_BackendLifecycleStub): def binary_name(self) -> str: return "claude" @@ -746,9 +765,6 @@ def build_interactive_cmd(self, **kwargs): build_calls.append(kwargs) return CmdSpec(cmd=("claude", "--dangerously-skip-permissions"), env={}) - def ensure_pre_launch(self) -> list[str]: - return [] - monkeypatch.setattr(shutil, "which", lambda _: "/usr/bin/claude") monkeypatch.setattr( subprocess, "run", lambda *a, **kw: type("Result", (), {"returncode": 0})() @@ -810,6 +826,7 @@ def mock_run(cmd, **kwargs): monkeypatch.setattr(subprocess, "run", mock_run) _stub_plugin_installed(monkeypatch, installed=False) + _stub_codex_pre_launch(monkeypatch) for backend_name, backend_cls in BACKEND_REGISTRY.items(): backend = backend_cls() @@ -847,6 +864,7 @@ def mock_run(cmd, **kwargs): monkeypatch.setattr(subprocess, "run", mock_run) _stub_plugin_installed(monkeypatch, installed=False) + _stub_codex_pre_launch(monkeypatch) backend = BACKEND_REGISTRY[backend_name]() _run_interactive_session(system_prompt="test", backend=backend) @@ -886,6 +904,7 @@ def mock_run(cmd, **kwargs): monkeypatch.setattr(subprocess, "run", mock_run) _stub_plugin_installed(monkeypatch, installed=False) + _stub_codex_pre_launch(monkeypatch) backend = BACKEND_REGISTRY[backend_name]() _run_interactive_session(system_prompt="test", backend=backend) @@ -945,9 +964,10 @@ def test_run_interactive_session_calls_ensure_pre_launch_for_codex_backend( food_truck_capable=False, completion_record_types=frozenset(), session_record_types=frozenset(), + hook_trust_policy=HookTrustPolicy.REVIEW_EACH_SESSION, ) - class _CodexBackendStub(_ProjectionBackendStub): + class _CodexBackendStub(_BackendLifecycleStub): def binary_name(self) -> str: return "codex" @@ -955,7 +975,7 @@ def binary_name(self) -> str: def capabilities(self): return caps - def ensure_pre_launch(self) -> list[str]: + def ensure_pre_launch(self, *, session_dir: Path | None = None) -> list[str]: call_sequence.append("pre_launch") return [] @@ -993,9 +1013,10 @@ def test_run_interactive_session_aborts_when_pre_launch_returns_errors( food_truck_capable=False, completion_record_types=frozenset(), session_record_types=frozenset(), + hook_trust_policy=HookTrustPolicy.REVIEW_EACH_SESSION, ) - class _FailingCodexBackend(_ProjectionBackendStub): + class _FailingCodexBackend(_BackendLifecycleStub): def binary_name(self) -> str: return "codex" @@ -1003,7 +1024,7 @@ def binary_name(self) -> str: def capabilities(self): return caps - def ensure_pre_launch(self) -> list[str]: + def ensure_pre_launch(self, *, session_dir: Path | None = None) -> list[str]: return ["Failed to ensure MCP registration: some error"] def build_interactive_cmd(self, **kwargs): diff --git a/tests/cli/test_session_picker.py b/tests/cli/test_session_picker.py index 6f967b7bf8..975d6d1b33 100644 --- a/tests/cli/test_session_picker.py +++ b/tests/cli/test_session_picker.py @@ -1,35 +1,62 @@ -"""Tests for cli/_session_picker.py.""" +"""Tests for the backend-neutral typed session picker.""" from __future__ import annotations -import json from pathlib import Path import pytest from autoskillit.cli.session._session_picker import ( _classify_session, + _format_session_row, _run_picker, pick_session, ) +from autoskillit.core import SessionSummary from autoskillit.core.runtime.session_registry import write_registry_entry pytestmark = [pytest.mark.layer("cli"), pytest.mark.medium] -def _make_index(tmp_path: Path, entries: list[dict]) -> Path: - """Write sessions-index.json into a temp project dir and return that dir.""" - index_path = tmp_path / "sessions-index.json" - index_path.write_text(json.dumps(entries), encoding="utf-8") - return tmp_path +def _summary( + session_id: str, + *, + backend_name: str = "claude-code", + launch_id: str | None = None, + cwd: str = "/project", + first_prompt: str = "What's the issue?", + summary: str = "", + git_branch: str | None = None, + modified: str | None = None, + is_sidechain: bool = False, + session_type_hint: str | None = None, +) -> SessionSummary: + return SessionSummary( + backend_name=backend_name, + session_id=session_id, + launch_id=launch_id, + cwd=cwd, + first_prompt=first_prompt, + summary=summary, + git_branch=git_branch, + modified=modified, + is_sidechain=is_sidechain, + session_type_hint=session_type_hint, + ) + + +class _StubLocator: + def __init__(self, summaries: tuple[SessionSummary, ...]) -> None: + self._summaries = summaries + + def list_sessions(self, cwd: str) -> tuple[SessionSummary, ...]: + return self._summaries def test_pick_session_no_sessions_returns_none(tmp_path: Path) -> None: project_dir = tmp_path / "project" project_dir.mkdir() - claude_dir = tmp_path / "claude-projects" - claude_dir.mkdir() - result = pick_session("cook", project_dir, claude_dir) + result = pick_session("cook", project_dir, _StubLocator(())) assert result is None @@ -38,18 +65,10 @@ def test_pick_session_filters_cook( ) -> None: project_dir = tmp_path / "project" project_dir.mkdir() - claude_dir = tmp_path / "claude" - claude_dir.mkdir() - - entries = [ - {"sessionId": "cook-uuid-1", "firstPrompt": "What's the issue?", "isSidechain": False}, - { - "sessionId": "order-uuid-1", - "firstPrompt": "Kitchen's open! Hello", - "isSidechain": False, - }, - ] - (claude_dir / "sessions-index.json").write_text(json.dumps(entries), encoding="utf-8") + entries = ( + _summary("cook-uuid-1"), + _summary("order-uuid-1", first_prompt="Kitchen's open! Hello"), + ) write_registry_entry(project_dir, "lid-cook", "cook", None) write_registry_entry(project_dir, "lid-order", "order", None) @@ -62,25 +81,17 @@ def test_pick_session_filters_cook( monkeypatch.setattr("sys.stdin.isatty", lambda: True) monkeypatch.setattr("builtins.input", lambda _prompt="": "1") - result = pick_session("cook", project_dir, claude_dir) + result = pick_session("cook", project_dir, _StubLocator(entries)) assert result == "cook-uuid-1" def test_pick_session_filters_order(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: project_dir = tmp_path / "project" project_dir.mkdir() - claude_dir = tmp_path / "claude" - claude_dir.mkdir() - - entries = [ - {"sessionId": "cook-uuid-1", "firstPrompt": "What's the issue?", "isSidechain": False}, - { - "sessionId": "order-uuid-1", - "firstPrompt": "Kitchen's open! Hello", - "isSidechain": False, - }, - ] - (claude_dir / "sessions-index.json").write_text(json.dumps(entries), encoding="utf-8") + entries = ( + _summary("cook-uuid-1"), + _summary("order-uuid-1", first_prompt="Kitchen's open! Hello"), + ) write_registry_entry(project_dir, "lid-cook", "cook", None) write_registry_entry(project_dir, "lid-order", "order", None) @@ -93,24 +104,24 @@ def test_pick_session_filters_order(tmp_path: Path, monkeypatch: pytest.MonkeyPa monkeypatch.setattr("sys.stdin.isatty", lambda: True) monkeypatch.setattr("builtins.input", lambda _prompt="": "1") - result = pick_session("order", project_dir, claude_dir) + result = pick_session("order", project_dir, _StubLocator(entries)) assert result == "order-uuid-1" def test_greeting_heuristic_order_session() -> None: - entry = {"sessionId": "s1", "firstPrompt": "Order up! Today's special: myrecipe."} + entry = _summary("s1", first_prompt="Order up! Today's special: myrecipe.") result = _classify_session(entry, {}) assert result == "order" def test_greeting_heuristic_cook_session() -> None: - entry = {"sessionId": "s1", "firstPrompt": "What's the issue?"} + entry = _summary("s1") result = _classify_session(entry, {}) assert result == "cook" def test_greeting_heuristic_open_kitchen_order() -> None: - entry = {"sessionId": "s1", "firstPrompt": "Kitchen's open! What are we cooking today?"} + entry = _summary("s1", first_prompt="Kitchen's open! What are we cooking today?") result = _classify_session(entry, {}) assert result == "order" @@ -118,22 +129,16 @@ def test_greeting_heuristic_open_kitchen_order() -> None: def test_sidechain_sessions_excluded(tmp_path: Path) -> None: project_dir = tmp_path / "project" project_dir.mkdir() - claude_dir = tmp_path / "claude" - claude_dir.mkdir() - - entries = [ - {"sessionId": "sidechain-uuid", "firstPrompt": "What's the issue?", "isSidechain": True}, - ] - (claude_dir / "sessions-index.json").write_text(json.dumps(entries), encoding="utf-8") + entries = (_summary("sidechain-uuid", is_sidechain=True),) - result = pick_session("cook", project_dir, claude_dir) + result = pick_session("cook", project_dir, _StubLocator(entries)) assert result is None def test_user_selects_numbered_session(monkeypatch: pytest.MonkeyPatch) -> None: sessions = [ - {"sessionId": "uuid-1", "firstPrompt": "What's the issue?"}, - {"sessionId": "uuid-2", "firstPrompt": "Fix the bug"}, + _summary("uuid-1"), + _summary("uuid-2", first_prompt="Fix the bug"), ] inputs = iter(["1"]) monkeypatch.setattr("sys.stdin.isatty", lambda: True) @@ -143,7 +148,7 @@ def test_user_selects_numbered_session(monkeypatch: pytest.MonkeyPatch) -> None: def test_user_selects_fresh_start(monkeypatch: pytest.MonkeyPatch) -> None: - sessions = [{"sessionId": "uuid-1", "firstPrompt": "Hello"}] + sessions = [_summary("uuid-1", first_prompt="Hello")] monkeypatch.setattr("sys.stdin.isatty", lambda: True) monkeypatch.setattr("builtins.input", lambda _prompt="": "0") result = _run_picker(sessions, "cook", {}) @@ -151,9 +156,87 @@ def test_user_selects_fresh_start(monkeypatch: pytest.MonkeyPatch) -> None: def test_user_selects_out_of_range(monkeypatch: pytest.MonkeyPatch) -> None: - sessions = [{"sessionId": "uuid-1", "firstPrompt": "Hello"}] + sessions = [_summary("uuid-1", first_prompt="Hello")] inputs = iter(["99", "0"]) monkeypatch.setattr("sys.stdin.isatty", lambda: True) monkeypatch.setattr("builtins.input", lambda _prompt="": next(inputs)) result = _run_picker(sessions, "cook", {}) assert result is None + + +def test_launch_id_registry_classification_wins_over_backend_hint(tmp_path: Path) -> None: + project_dir = tmp_path / "project" + project_dir.mkdir() + write_registry_entry(project_dir, "0123456789abcdef", "order", "planner") + from autoskillit.core import read_registry + + entry = _summary( + "codex-thread", + backend_name="codex", + launch_id="0123456789abcdef", + session_type_hint="cook", + ) + + assert _classify_session(entry, read_registry(project_dir)) == "order" + + +def test_codex_hint_classifies_unregistered_session_as_cook() -> None: + entry = _summary( + "codex-thread", + backend_name="codex", + first_prompt="", + session_type_hint="cook", + ) + assert _classify_session(entry, {}) == "cook" + + +def test_claude_registry_bridge_precedes_greeting_hint(tmp_path: Path) -> None: + project_dir = tmp_path / "project" + project_dir.mkdir() + write_registry_entry(project_dir, "fedcba9876543210", "cook", None) + from autoskillit.core import read_registry + from autoskillit.core.runtime.session_registry import bridge_claude_session_id + + bridge_claude_session_id(project_dir, "fedcba9876543210", "claude-uuid") + entry = _summary("claude-uuid", first_prompt="Kitchen's open!") + + assert _classify_session(entry, read_registry(project_dir)) == "cook" + + +def test_format_prefers_summary_then_first_prompt_and_displays_metadata() -> None: + with_summary = _summary( + "codex-thread", + backend_name="codex", + summary="Durable startup", + first_prompt="Ignored prompt", + git_branch="feature/startup", + modified="2 minutes ago", + ) + prompt_only = _summary("claude-thread", first_prompt="Fix the picker") + + assert _format_session_row(with_summary, "cook", {}) == ( + "[cook] Durable startup feature/startup 2 minutes ago" + ) + assert _format_session_row(prompt_only, "cook", {}) == "[cook] Fix the picker" + + +def test_picker_returns_backend_session_id( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + project_dir = tmp_path / "project" + project_dir.mkdir() + summaries = ( + _summary( + "thread-id-not-launch-id", + backend_name="codex", + launch_id="0123456789abcdef", + session_type_hint="cook", + ), + ) + write_registry_entry(project_dir, "0123456789abcdef", "cook", None) + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("builtins.input", lambda _prompt="": "1") + + assert pick_session("cook", project_dir, _StubLocator(summaries)) == ( + "thread-id-not-launch-id" + ) diff --git a/tests/contracts/test_backend_compliance.py b/tests/contracts/test_backend_compliance.py index d82218fbcb..e7ffa61021 100644 --- a/tests/contracts/test_backend_compliance.py +++ b/tests/contracts/test_backend_compliance.py @@ -133,6 +133,27 @@ def test_all_backends_session_locator_has_session_log_path(self): f"{type(locator).__name__}.session_log_path must return Path or None" ) + def test_all_backends_session_locator_has_typed_listing(self): + from autoskillit.execution.backends import BACKEND_REGISTRY + from autoskillit.execution.backends.codex import CodexBackend # noqa: F401 + + for cls in BACKEND_REGISTRY.values(): + locator = cls().session_locator() + assert callable(locator.list_sessions) + + def test_all_backends_have_cook_lifecycle_methods(self): + from autoskillit.execution.backends import BACKEND_REGISTRY + from autoskillit.execution.backends.codex import CodexBackend # noqa: F401 + + for cls in BACKEND_REGISTRY.values(): + backend = cls() + for method_name in ( + "validate_interactive_invocation", + "recover_cook_history", + "cook_session_context", + ): + assert callable(getattr(backend, method_name)) + def test_all_backends_capabilities_is_backend_capabilities(self): from autoskillit.core import BackendCapabilities from autoskillit.execution.backends import BACKEND_REGISTRY @@ -146,7 +167,10 @@ def test_all_backends_validate_session_layout_returns_list(self, tmp_path): from autoskillit.execution.backends.codex import CodexBackend # noqa: F401 for cls in BACKEND_REGISTRY.values(): - assert isinstance(cls().validate_session_layout(tmp_path), list) + assert isinstance( + cls().validate_session_layout(tmp_path, project_dir=tmp_path), + list, + ) def test_all_backends_conventions_is_backend_conventions(self): from autoskillit.core import BackendConventions diff --git a/tests/contracts/test_backend_protocol.py b/tests/contracts/test_backend_protocol.py index 67ee10992e..d2cc067cb8 100644 --- a/tests/contracts/test_backend_protocol.py +++ b/tests/contracts/test_backend_protocol.py @@ -120,6 +120,46 @@ def test_codex_backend_env_policy_satisfies_protocol(): assert isinstance(CodexBackend().env_policy(), EnvPolicy) +@pytest.mark.parametrize("backend_name", ["claude-code", "codex"]) +def test_backend_implementations_expose_cook_lifecycle_protocol_methods(backend_name): + import inspect + + from autoskillit.core import CodingAgentBackend + from autoskillit.execution.backends import get_backend + + backend_cls = type(get_backend(backend_name)) + method_names = ( + "validate_session_layout", + "validate_interactive_invocation", + "ensure_pre_launch", + "recover_cook_history", + "cook_session_context", + ) + for method_name in method_names: + protocol_method = getattr(CodingAgentBackend, method_name) + implementation_method = getattr(backend_cls, method_name) + protocol_signature = inspect.signature(protocol_method) + implementation_signature = inspect.signature(implementation_method) + assert tuple(implementation_signature.parameters) == tuple( + protocol_signature.parameters + ), f"{backend_name}.{method_name} parameter names differ from protocol" + assert [parameter.kind for parameter in implementation_signature.parameters.values()] == [ + parameter.kind for parameter in protocol_signature.parameters.values() + ], f"{backend_name}.{method_name} parameter kinds differ from protocol" + + +@pytest.mark.parametrize("backend_name", ["claude-code", "codex"]) +def test_backend_locator_implementation_exposes_typed_listing(backend_name): + import inspect + + from autoskillit.execution.backends import get_backend + + locator = get_backend(backend_name).session_locator() + signature = inspect.signature(type(locator).list_sessions) + assert tuple(signature.parameters) == ("self", "cwd") + assert callable(locator.list_sessions) + + # -- Signature conformance: EnvPolicy.build_env ------------------------------- diff --git a/tests/contracts/test_core_public_api_surface.py b/tests/contracts/test_core_public_api_surface.py index ed41d83081..2ce5bdfaaf 100644 --- a/tests/contracts/test_core_public_api_surface.py +++ b/tests/contracts/test_core_public_api_surface.py @@ -27,17 +27,39 @@ def test_new_coding_agent_backend_names_importable() -> None: from autoskillit.core import ( CmdSpec, CodingAgentBackend, + CookSessionHandle, EnvPolicy, + HookTrustPolicy, + ManagedSessionHome, ResultParser, SessionEvent, SessionLocator, + SessionSummary, StreamParser, ) - assert inspect.isclass(CmdSpec) - assert inspect.isclass(CodingAgentBackend) - assert inspect.isclass(EnvPolicy) - assert inspect.isclass(ResultParser) - assert inspect.isclass(SessionEvent) - assert inspect.isclass(SessionLocator) - assert inspect.isclass(StreamParser) + for public_type in ( + CmdSpec, + CodingAgentBackend, + CookSessionHandle, + EnvPolicy, + HookTrustPolicy, + ManagedSessionHome, + ResultParser, + SessionEvent, + SessionLocator, + SessionSummary, + StreamParser, + ): + assert inspect.isclass(public_type) + + +def test_cook_lifecycle_contracts_are_in_core_all() -> None: + import autoskillit.core as core + + assert { + "CookSessionHandle", + "HookTrustPolicy", + "ManagedSessionHome", + "SessionSummary", + } <= set(core.__all__) diff --git a/tests/contracts/test_protocol_satisfaction.py b/tests/contracts/test_protocol_satisfaction.py index 6f03a8c6c1..a86ff1e663 100644 --- a/tests/contracts/test_protocol_satisfaction.py +++ b/tests/contracts/test_protocol_satisfaction.py @@ -209,6 +209,25 @@ def test_default_workspace_manager_satisfies_workspace_manager(): assert isinstance(DefaultWorkspaceManager(), WorkspaceManager) +def test_default_session_skill_manager_satisfies_managed_session_protocol(tmp_path): + from autoskillit.core import SessionSkillManager + from autoskillit.workspace.session_skills import DefaultSessionSkillManager + + manager = DefaultSessionSkillManager(lambda _name: None, ephemeral_root=tmp_path) + assert isinstance(manager, SessionSkillManager) + assert callable(manager.managed_session) + + +def test_session_skill_manager_managed_session_is_context_manager_boundary(): + import typing + from contextlib import AbstractContextManager + + from autoskillit.core import ManagedSessionHome, SessionSkillManager + + hints = typing.get_type_hints(SessionSkillManager.managed_session) + assert hints["return"] == AbstractContextManager[ManagedSessionHome] + + def test_default_database_reader_satisfies_database_reader(): from autoskillit.core import DatabaseReader from autoskillit.execution.db import DefaultDatabaseReader diff --git a/tests/core/test_backend_capabilities.py b/tests/core/test_backend_capabilities.py index 8d1a6651d6..a89e3e0c47 100644 --- a/tests/core/test_backend_capabilities.py +++ b/tests/core/test_backend_capabilities.py @@ -78,6 +78,9 @@ def test_backend_capabilities_field_count(): frozenset_fields = {f.name for f in fields if hints[f.name] == frozenset[str]} str_fields = {f.name for f in fields if hints[f.name] is str} tuple_fields = {f.name for f in fields if hints[f.name] == tuple[str, ...]} + hook_policy_fields = { + f.name for f in fields if getattr(hints[f.name], "__name__", "") == "HookTrustPolicy" + } assert bool_fields == { "channel_b_capable", "has_unguarded_filesystem_access", @@ -127,6 +130,7 @@ def test_backend_capabilities_field_count(): "skill_sigil", } assert tuple_fields == {"env_denylist_prefixes"} + assert hook_policy_fields == {"hook_trust_policy"} def test_backend_capabilities_field_names_locked(): @@ -179,6 +183,7 @@ def test_backend_capabilities_field_names_locked(): "unnegotiated_tool_result_token_limit", "protected_recipe_delivery_capable", "recipe_delivery_budget", + "hook_trust_policy", } actual = {f.name for f in dataclasses.fields(BackendCapabilities)} assert actual == expected @@ -186,7 +191,7 @@ def test_backend_capabilities_field_names_locked(): def test_claude_code_capabilities_field_values(): """CLAUDE_CODE_CAPABILITIES field values match the Part 13.2 design.""" - from autoskillit.core import CLAUDE_CODE_CAPABILITIES + from autoskillit.core import CLAUDE_CODE_CAPABILITIES, HookTrustPolicy assert CLAUDE_CODE_CAPABILITIES.channel_b_capable is True assert CLAUDE_CODE_CAPABILITIES.pty_required is True @@ -228,6 +233,13 @@ def test_claude_code_capabilities_field_values(): assert CLAUDE_CODE_CAPABILITIES.default_skill_sandbox_mode == "" assert CLAUDE_CODE_CAPABILITIES.skill_sigil == "/" assert CLAUDE_CODE_CAPABILITIES.supports_model_invocation_gating is True + assert CLAUDE_CODE_CAPABILITIES.hook_trust_policy is HookTrustPolicy.AUTOMATED + + +def test_backend_capabilities_hook_policy_default_is_automated(): + from autoskillit.core import BackendCapabilities, HookTrustPolicy + + assert BackendCapabilities().hook_trust_policy is HookTrustPolicy.AUTOMATED def test_backend_capabilities_frozenset_defaults(): diff --git a/tests/core/test_backend_dataclasses.py b/tests/core/test_backend_dataclasses.py index 44b1ae85a0..9b7e33ca18 100644 --- a/tests/core/test_backend_dataclasses.py +++ b/tests/core/test_backend_dataclasses.py @@ -185,6 +185,95 @@ def test_agent_session_result_raw_default(): assert r.raw == {} +def test_session_summary_frozen_slots_and_exact_fields(): + from autoskillit.core import SessionSummary + + summary = SessionSummary( + backend_name="codex", + session_id="thread-1", + launch_id="launch-1", + cwd="/tmp/project", + first_prompt="Fix the race", + summary="Codex history isolation", + git_branch="feature/history", + modified="2026-07-23T10:00:00Z", + is_sidechain=False, + session_type_hint="cook", + ) + + assert tuple(field.name for field in dataclasses.fields(SessionSummary)) == ( + "backend_name", + "session_id", + "launch_id", + "cwd", + "first_prompt", + "summary", + "git_branch", + "modified", + "is_sidechain", + "session_type_hint", + ) + assert typing.get_type_hints(SessionSummary) == { + "backend_name": str, + "session_id": str, + "launch_id": str | None, + "cwd": str, + "first_prompt": str, + "summary": str, + "git_branch": str | None, + "modified": str | None, + "is_sidechain": bool, + "session_type_hint": str | None, + } + assert not hasattr(summary, "__dict__") + with pytest.raises(FrozenInstanceError): + summary.summary = "changed" # type: ignore[misc] + + +def test_cook_session_handle_contract_and_callback_delegation(): + from collections.abc import Callable + + from autoskillit.core import CookSessionHandle + + calls: list[tuple[str, int, int]] = [] + handle = CookSessionHandle( + view_id="launch-1-attempt-2", + pass_fds=(7, 11), + _record_spawn=lambda pid, pgid: calls.append(("spawn", pid, pgid)), + _record_reaped=lambda pid, pgid: calls.append(("reaped", pid, pgid)), + ) + + assert tuple(field.name for field in dataclasses.fields(CookSessionHandle)) == ( + "view_id", + "pass_fds", + "_record_spawn", + "_record_reaped", + ) + hints = typing.get_type_hints(CookSessionHandle) + assert hints == { + "view_id": str, + "pass_fds": tuple[int, ...], + "_record_spawn": Callable[[int, int], None], + "_record_reaped": Callable[[int, int], None], + } + assert not hasattr(handle, "__dict__") + assert repr(handle) == "CookSessionHandle(view_id='launch-1-attempt-2', pass_fds=(7, 11))" + + equivalent = CookSessionHandle( + view_id=handle.view_id, + pass_fds=handle.pass_fds, + _record_spawn=lambda _pid, _pgid: None, + _record_reaped=lambda _pid, _pgid: None, + ) + assert equivalent == handle + + handle.record_spawn(101, 202) + handle.record_reaped(101, 202) + assert calls == [("spawn", 101, 202), ("reaped", 101, 202)] + with pytest.raises(FrozenInstanceError): + handle.view_id = "other" # type: ignore[misc] + + def test_backend_module_all_exhaustive(): from autoskillit.core.types._type_backend import __all__ @@ -200,7 +289,9 @@ def test_backend_module_all_exhaustive(): "CODEX_VALID_MODEL_IDS", "CmdOrigin", "CmdSpec", + "CookSessionHandle", "ModelTranslation", + "SessionSummary", "SkillSessionConfig", "ClaudeEventData", "CodexEventData", diff --git a/tests/core/test_backend_protocols.py b/tests/core/test_backend_protocols.py index 9e66c2ecd3..a3b7936d4c 100644 --- a/tests/core/test_backend_protocols.py +++ b/tests/core/test_backend_protocols.py @@ -60,6 +60,83 @@ def test_session_locator_has_session_log_path_method(): assert callable(getattr(SessionLocator, "session_log_path", None)) +def test_session_locator_list_sessions_exact_signature(): + import inspect + import typing + from collections.abc import Sequence + + from autoskillit.core import SessionLocator, SessionSummary + + signature = inspect.signature(SessionLocator.list_sessions) + assert tuple(signature.parameters) == ("self", "cwd") + assert signature.parameters["cwd"].annotation == "str" + hints = typing.get_type_hints(SessionLocator.list_sessions) + assert hints == {"cwd": str, "return": Sequence[SessionSummary]} + + +def test_coding_agent_backend_new_lifecycle_signatures_are_exact(): + import inspect + import typing + from contextlib import AbstractContextManager + from pathlib import Path + + from autoskillit.core import ( + CmdSpec, + CodingAgentBackend, + CookSessionHandle, + ResumeSpec, + ) + + layout = inspect.signature(CodingAgentBackend.validate_session_layout) + assert tuple(layout.parameters) == ("self", "session_dir", "project_dir") + assert layout.parameters["project_dir"].kind is inspect.Parameter.KEYWORD_ONLY + assert layout.parameters["project_dir"].default is None + assert typing.get_type_hints(CodingAgentBackend.validate_session_layout) == { + "session_dir": Path, + "project_dir": Path | None, + "return": list[str], + } + + native = inspect.signature(CodingAgentBackend.validate_interactive_invocation) + assert tuple(native.parameters) == ("self", "spec") + assert typing.get_type_hints(CodingAgentBackend.validate_interactive_invocation) == { + "spec": CmdSpec, + "return": list[str], + } + + pre_launch = inspect.signature(CodingAgentBackend.ensure_pre_launch) + assert tuple(pre_launch.parameters) == ("self", "session_dir") + assert pre_launch.parameters["session_dir"].kind is inspect.Parameter.KEYWORD_ONLY + assert pre_launch.parameters["session_dir"].default is None + assert typing.get_type_hints(CodingAgentBackend.ensure_pre_launch) == { + "session_dir": Path | None, + "return": list[str], + } + + recovery = inspect.signature(CodingAgentBackend.recover_cook_history) + assert tuple(recovery.parameters) == ("self",) + assert typing.get_type_hints(CodingAgentBackend.recover_cook_history) == {"return": type(None)} + + context = inspect.signature(CodingAgentBackend.cook_session_context) + assert tuple(context.parameters) == ( + "self", + "session_home", + "launch_id", + "attempt", + "current_resume_spec", + ) + for name in tuple(context.parameters)[1:]: + assert context.parameters[name].kind is inspect.Parameter.KEYWORD_ONLY + hints = typing.get_type_hints(CodingAgentBackend.cook_session_context) + assert hints == { + "session_home": Path, + "launch_id": str, + "attempt": int, + "current_resume_spec": ResumeSpec, + "return": AbstractContextManager[CookSessionHandle], + } + + def test_no_autoskillit_imports_in_protocols_backend(): from autoskillit.core import paths @@ -172,7 +249,15 @@ def build_interactive_cmd( tools: Sequence[str] = (), ) -> CmdSpec: ... - def validate_session_layout(self, session_dir: Path) -> list[str]: ... + def validate_session_layout( + self, + session_dir: Path, + *, + project_dir: Path | None = None, + ) -> list[str]: ... + + def validate_interactive_invocation(self, spec: CmdSpec) -> list[str]: + return [] def validate_skill_content(self, content: str) -> list[str]: ... @@ -180,9 +265,33 @@ def version(self) -> str: ... def list_plugins(self) -> list[dict[str, Any]]: ... - def ensure_pre_launch(self) -> list[str]: + def ensure_pre_launch(self, *, session_dir: Path | None = None) -> list[str]: return [] + def recover_cook_history(self) -> None: + return None + + def cook_session_context( + self, + *, + session_home: Path, + launch_id: str, + attempt: int, + current_resume_spec: ResumeSpec, + ): + from contextlib import nullcontext + + from autoskillit.core import CookSessionHandle + + return nullcontext( + CookSessionHandle( + view_id=f"{launch_id}-{attempt}", + pass_fds=(), + _record_spawn=lambda _pid, _pgid: None, + _record_reaped=lambda _pid, _pgid: None, + ) + ) + def translate_model(self, model: str) -> str: ... def model_config_overrides(self, model: str) -> tuple[str, ...]: diff --git a/tests/core/test_type_protocol_shards.py b/tests/core/test_type_protocol_shards.py index b4fdd71b50..93efbb5f9f 100644 --- a/tests/core/test_type_protocol_shards.py +++ b/tests/core/test_type_protocol_shards.py @@ -56,6 +56,41 @@ def test_workspace_shard_all(): } +def test_session_skill_manager_managed_session_signature(): + import inspect + import typing + from contextlib import AbstractContextManager + from pathlib import Path + + from autoskillit.core import ( + CodingAgentBackend, + ManagedSessionHome, + SessionSkillManager, + ) + + signature = inspect.signature(SessionSkillManager.managed_session) + assert tuple(signature.parameters) == ( + "self", + "session_id", + "cook_session", + "config", + "project_dir", + "backend", + ) + assert signature.parameters["session_id"].kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + for name in ("cook_session", "config", "project_dir", "backend"): + assert signature.parameters[name].kind is inspect.Parameter.KEYWORD_ONLY + assert signature.parameters[name].default is inspect.Parameter.empty + assert typing.get_type_hints(SessionSkillManager.managed_session) == { + "session_id": str, + "cook_session": bool, + "config": typing.Any, + "project_dir": Path, + "backend": CodingAgentBackend, + "return": AbstractContextManager[ManagedSessionHome], + } + + def test_recipe_shard_all(): from autoskillit.core.types._type_protocols_recipe import __all__ @@ -114,6 +149,7 @@ def test_all_protocols_reachable_via_types(): "CampaignProtector", "CodingAgentBackend", "StreamParser", + "SessionSkillManager", ]: assert hasattr(types, name), f"Missing from types: {name}" diff --git a/tests/core/test_types.py b/tests/core/test_types.py index 5e051b307f..00ed753add 100644 --- a/tests/core/test_types.py +++ b/tests/core/test_types.py @@ -286,6 +286,60 @@ def test_severity_enum_not_equal_to_uppercase_string(): assert Severity.ERROR == Severity.ERROR +def test_hook_trust_policy_values_and_public_exports(): + from enum import StrEnum + + import autoskillit.core as core + from autoskillit.core.types import HookTrustPolicy + from autoskillit.core.types._type_enums import __all__ as enum_all + + assert issubclass(HookTrustPolicy, StrEnum) + assert set(HookTrustPolicy) == { + HookTrustPolicy.AUTOMATED, + HookTrustPolicy.REVIEW_EACH_SESSION, + } + assert HookTrustPolicy.AUTOMATED.value == "automated" + assert HookTrustPolicy.REVIEW_EACH_SESSION.value == "review_each_session" + assert "HookTrustPolicy" in enum_all + assert "HookTrustPolicy" in core.__all__ + assert core.HookTrustPolicy is HookTrustPolicy + + +def test_managed_session_home_frozen_slots_exact_fields_and_exports(tmp_path): + from pathlib import Path + from typing import get_type_hints + + import autoskillit.core as core + from autoskillit.core import ManagedSessionHome, ValidatedAddDir + from autoskillit.core.types._type_results import __all__ as results_all + + skills_dir = ValidatedAddDir(tmp_path / "home" / "skills") + handle = ManagedSessionHome( + launch_id="launch-1", + generated_home=tmp_path / "home", + skills_dir=skills_dir, + pass_fds=(3, 5), + ) + + assert tuple(field.name for field in dataclasses.fields(ManagedSessionHome)) == ( + "launch_id", + "generated_home", + "skills_dir", + "pass_fds", + ) + assert get_type_hints(ManagedSessionHome) == { + "launch_id": str, + "generated_home": Path, + "skills_dir": ValidatedAddDir, + "pass_fds": tuple[int, ...], + } + assert not hasattr(handle, "__dict__") + assert "ManagedSessionHome" in results_all + assert "ManagedSessionHome" in core.__all__ + with pytest.raises(FrozenInstanceError): + handle.launch_id = "other" # type: ignore[misc] + + def test_github_fetcher_protocol_has_label_methods(): import inspect diff --git a/tests/execution/backends/test_claude_session_locator.py b/tests/execution/backends/test_claude_session_locator.py index b8e0a87432..0e3d56146b 100644 --- a/tests/execution/backends/test_claude_session_locator.py +++ b/tests/execution/backends/test_claude_session_locator.py @@ -1,16 +1,120 @@ from __future__ import annotations +import json from pathlib import Path import pytest -from autoskillit.core import SessionLocator +from autoskillit.core import SessionLocator, SessionSummary from autoskillit.execution.backends import ClaudeSessionLocator pytestmark = [pytest.mark.layer("execution"), pytest.mark.medium] class TestClaudeSessionLocator: + def test_list_sessions_translates_native_index_and_normalizes_cwd( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + project = tmp_path / "project" + project.mkdir() + fake_home = tmp_path / "home" + index_dir = fake_home / ".claude" / "projects" / "-ignored" + index_dir.mkdir(parents=True) + (index_dir / "sessions-index.json").write_text( + json.dumps( + [ + { + "sessionId": "claude-1", + "cwd": str(project / ".." / "project"), + "firstPrompt": "What's the issue?", + "summary": None, + "gitBranch": "main", + "modified": None, + "isSidechain": False, + } + ] + ), + encoding="utf-8", + ) + monkeypatch.setattr(Path, "home", lambda: fake_home) + monkeypatch.setattr( + "autoskillit.execution.backends.claude.claude_code_project_dir", + lambda _cwd: index_dir, + ) + + assert ClaudeSessionLocator().list_sessions(str(project)) == ( + SessionSummary( + backend_name="claude-code", + session_id="claude-1", + launch_id=None, + cwd=str(project.resolve()), + first_prompt="What's the issue?", + summary="", + git_branch="main", + modified=None, + is_sidechain=False, + session_type_hint="cook", + ), + ) + + def test_list_sessions_filters_sidechains_and_other_projects( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + project = tmp_path / "project" + project.mkdir() + index_dir = tmp_path / "claude-project" + index_dir.mkdir() + (index_dir / "sessions-index.json").write_text( + json.dumps( + [ + { + "sessionId": "side", + "cwd": str(project), + "firstPrompt": "x", + "isSidechain": True, + }, + { + "sessionId": "other", + "cwd": str(tmp_path / "other"), + "firstPrompt": "x", + "isSidechain": False, + }, + { + "sessionId": "order", + "cwd": str(project), + "firstPrompt": "Kitchen's open!", + "isSidechain": False, + }, + ] + ), + encoding="utf-8", + ) + monkeypatch.setattr( + "autoskillit.execution.backends.claude.claude_code_project_dir", + lambda _cwd: index_dir, + ) + + summaries = ClaudeSessionLocator().list_sessions(str(project / ".")) + assert [item.session_id for item in summaries] == ["order"] + assert summaries[0].session_type_hint == "order" + + @pytest.mark.parametrize("contents", ["", "not-json", "null", "{}"]) + def test_list_sessions_empty_or_corrupt_index_is_empty( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + contents: str, + ) -> None: + index_dir = tmp_path / "claude-project" + index_dir.mkdir() + (index_dir / "sessions-index.json").write_text(contents, encoding="utf-8") + monkeypatch.setattr( + "autoskillit.execution.backends.claude.claude_code_project_dir", + lambda _cwd: index_dir, + ) + + assert ClaudeSessionLocator().list_sessions(str(tmp_path)) == () + def test_locate_session_finds_existing_file( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: diff --git a/tests/execution/backends/test_codex_config.py b/tests/execution/backends/test_codex_config.py index 61cb710a6c..b166ccdd5c 100644 --- a/tests/execution/backends/test_codex_config.py +++ b/tests/execution/backends/test_codex_config.py @@ -1026,3 +1026,106 @@ def test_removing_required_var_returns_false(self, removed_var: str) -> None: assert _is_autoskillit_registered(config, headless_auto_gate=True) is False, ( f"Removing {removed_var} should make registration invalid" ) + + +def test_mcp_writer_facade_owns_the_shared_config_lock( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import autoskillit.execution.backends._codex_config as config_module + + config_path = tmp_path / "source-home" / "config.toml" + events: list[tuple[str, Path]] = [] + lock_held = False + + class RecordingLock: + def __init__(self, path: Path) -> None: + self.path = Path(path) + + def __enter__(self) -> None: + nonlocal lock_held + assert lock_held is False + lock_held = True + events.append(("lock-enter", self.path)) + + def __exit__(self, *exc_info: object) -> None: + nonlocal lock_held + assert lock_held is True + events.append(("lock-exit", self.path)) + lock_held = False + + def fake_unlocked(*, config_path: Path, **_: object) -> bool: + assert lock_held is True + events.append(("mcp", config_path)) + return True + + monkeypatch.setattr(config_module, "CodexConfigLock", RecordingLock) + monkeypatch.setattr( + config_module, + "_ensure_codex_mcp_registered_unlocked", + fake_unlocked, + ) + + assert config_module.ensure_codex_mcp_registered(config_path=config_path) is True + assert events == [ + ("lock-enter", config_path), + ("mcp", config_path), + ("lock-exit", config_path), + ] + + +def test_composed_prelaunch_uses_one_lock_for_both_unlocked_mutators( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import autoskillit.execution.backends._codex_prelaunch as prelaunch + + source_home = tmp_path / "source-home" + config_path = source_home / "config.toml" + events: list[str] = [] + lock_held = False + + class RecordingLock: + def __init__(self, path: Path) -> None: + assert Path(path) == config_path + + def __enter__(self) -> None: + nonlocal lock_held + assert lock_held is False + lock_held = True + events.append("lock-enter") + + def __exit__(self, *exc_info: object) -> None: + nonlocal lock_held + assert lock_held is True + events.append("lock-exit") + lock_held = False + + def fake_mcp(*, config_path: Path, **_: object) -> bool: + assert lock_held is True + assert config_path == source_home / "config.toml" + events.append("mcp") + return True + + def fake_hooks(*, config_path: Path, **_: object) -> bool: + assert lock_held is True + assert config_path == source_home / "config.toml" + events.append("hooks") + return True + + monkeypatch.setattr(prelaunch, "CodexConfigLock", RecordingLock) + monkeypatch.setattr( + prelaunch, + "_ensure_codex_mcp_registered_unlocked", + fake_mcp, + ) + monkeypatch.setattr( + prelaunch, + "_sync_hooks_to_codex_config_unlocked", + fake_hooks, + ) + + with prelaunch.codex_prelaunch_transaction(source_codex_home=source_home) as target: + assert target == config_path + assert lock_held is True + events.append("yield") + + assert events == ["lock-enter", "mcp", "hooks", "yield", "lock-exit"] diff --git a/tests/execution/backends/test_codex_config_validation.py b/tests/execution/backends/test_codex_config_validation.py new file mode 100644 index 0000000000..32d6321599 --- /dev/null +++ b/tests/execution/backends/test_codex_config_validation.py @@ -0,0 +1,146 @@ +"""Interprocess serialization and exact-input Codex validation contracts.""" + +from __future__ import annotations + +import multiprocessing +import os +import tomllib +from pathlib import Path +from typing import Any + +import pytest + +pytestmark = [pytest.mark.layer("execution"), pytest.mark.medium] + + +def _config_writer( + operation: str, + config_path: str, + isolated_home: str, + ready: Any, + start: Any, + result: Any, +) -> None: + os.environ["HOME"] = isolated_home + os.environ["XDG_DATA_HOME"] = str(Path(isolated_home) / "xdg") + ready.put(operation) + if not start.wait(timeout=10): + result.put((operation, "start timeout")) + return + try: + if operation == "mcp": + from autoskillit.execution.backends import ensure_codex_mcp_registered + + ensure_codex_mcp_registered(config_path=Path(config_path)) + else: + from autoskillit.cli._hooks_codex import sync_hooks_to_codex_config + + sync_hooks_to_codex_config( + config_path=Path(config_path), + hook_config_format="toml_nested", + ) + except BaseException as exc: + result.put((operation, f"{type(exc).__name__}: {exc}")) + else: + result.put((operation, "ok")) + + +def _stop_and_reap(processes: list[multiprocessing.Process]) -> None: + for process in processes: + process.join(timeout=10) + for process in processes: + if process.is_alive(): + process.terminate() + process.join(timeout=5) + if process.is_alive(): + process.kill() + process.join(timeout=5) + + +def test_cook_and_init_config_writers_preserve_the_union_under_one_canonical_lock( + tmp_path: Path, +) -> None: + race_root = tmp_path / "config-race" + config_path = race_root / "codex-home" / "config.toml" + config_path.parent.mkdir(parents=True) + config_path.write_text('[foreign]\nowner = "user"\n', encoding="utf-8") + noncanonical_path = config_path.parent / ".." / "codex-home" / "config.toml" + child_home = race_root / "child-home" + child_home.mkdir() + ctx = multiprocessing.get_context("spawn") + ready = ctx.Queue() + result = ctx.Queue() + start = ctx.Event() + processes = [ + ctx.Process( + target=_config_writer, + args=("mcp", str(config_path), str(child_home), ready, start, result), + ), + ctx.Process( + target=_config_writer, + args=("hooks", str(noncanonical_path), str(child_home), ready, start, result), + ), + ] + + try: + for process in processes: + process.start() + assert {ready.get(timeout=10), ready.get(timeout=10)} == {"mcp", "hooks"} + start.set() + outcomes = {result.get(timeout=15), result.get(timeout=15)} + assert outcomes == {("mcp", "ok"), ("hooks", "ok")} + finally: + start.set() + _stop_and_reap(processes) + ready.close() + result.close() + ready.join_thread() + result.join_thread() + + assert all(not process.is_alive() for process in processes) + data = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert data["foreign"] == {"owner": "user"} + assert "autoskillit" in data["mcp_servers"] + assert data["hooks"] + assert not (child_home / ".codex" / "config.toml").exists() + + +def test_generated_home_snapshot_is_the_exact_post_mcp_and_hook_transaction( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from autoskillit.execution.backends.codex import CodexBackend + + source_home = tmp_path / "source-home" + source_home.mkdir() + source_config = source_home / "config.toml" + source_config.write_text('[foreign]\nowner = "user"\n', encoding="utf-8") + generated_home = tmp_path / "generated-home" + generated_home.mkdir() + ambient_home = tmp_path / "ambient-home" + ambient_home.mkdir() + backend = CodexBackend(source_codex_home=source_home) + + monkeypatch.setenv("CODEX_HOME", str(ambient_home)) + monkeypatch.setattr(Path, "home", staticmethod(lambda: ambient_home)) + + assert backend.ensure_pre_launch(session_dir=generated_home) == [] + + source_bytes = source_config.read_bytes() + assert (generated_home / "config.toml").read_bytes() == source_bytes + data = tomllib.loads(source_bytes.decode("utf-8")) + assert data["foreign"] == {"owner": "user"} + assert "autoskillit" in data["mcp_servers"] + assert data["hooks"] + assert not (ambient_home / "config.toml").exists() + + +def test_config_lock_is_non_reentrant_for_the_same_canonical_path(tmp_path: Path) -> None: + from autoskillit.execution.backends._codex_config_lock import CodexConfigLock + + config_path = tmp_path / "codex-home" / "config.toml" + alias = config_path.parent / ".." / "codex-home" / "config.toml" + + with CodexConfigLock(config_path): + with pytest.raises(RuntimeError, match="non-reentrant|already owns"): + with CodexConfigLock(alias): + pytest.fail("same-process nested acquisition must fail before entry") diff --git a/tests/execution/backends/test_codex_session_locator.py b/tests/execution/backends/test_codex_session_locator.py index a7bfb3a06c..0420b32d31 100644 --- a/tests/execution/backends/test_codex_session_locator.py +++ b/tests/execution/backends/test_codex_session_locator.py @@ -2,12 +2,13 @@ from __future__ import annotations +import json from pathlib import Path import pytest import zstandard -from autoskillit.core import SessionLocator +from autoskillit.core import SessionLocator, SessionSummary from autoskillit.execution.backends.codex import CodexSessionLocator pytestmark = [pytest.mark.layer("execution"), pytest.mark.small] @@ -30,6 +31,130 @@ def _make_rollout( class TestCodexSessionLocator: + def test_list_sessions_reads_injected_index_without_scanning_history( + self, tmp_path: Path + ) -> None: + project = tmp_path / "project" + project.mkdir() + store_root = tmp_path / "store" + store_root.mkdir() + index_path = tmp_path / "codex-session-index.json" + index_path.write_text( + json.dumps( + [ + { + "backend_name": "codex", + "session_id": "thread-new", + "launch_id": "0123456789abcdef", + "cwd": str(project / ".." / "project"), + "first_prompt": "Fix startup", + "summary": "Keep retained history off the startup path", + "git_branch": "feature/startup", + "modified": "2026-07-23T17:00:00Z", + "is_sidechain": False, + "session_type_hint": "cook", + } + ] + ), + encoding="utf-8", + ) + locator = CodexSessionLocator(store_root=store_root, index_path=index_path) + + assert locator.list_sessions(str(project)) == ( + SessionSummary( + backend_name="codex", + session_id="thread-new", + launch_id="0123456789abcdef", + cwd=str(project.resolve()), + first_prompt="Fix startup", + summary="Keep retained history off the startup path", + git_branch="feature/startup", + modified="2026-07-23T17:00:00Z", + is_sidechain=False, + session_type_hint="cook", + ), + ) + + @pytest.mark.parametrize("contents", ["", "{not-json", "null", "{}"]) + def test_list_sessions_empty_or_corrupt_index_is_empty( + self, tmp_path: Path, contents: str + ) -> None: + index_path = tmp_path / "codex-session-index.json" + index_path.write_text(contents, encoding="utf-8") + locator = CodexSessionLocator(store_root=tmp_path / "store", index_path=index_path) + + assert locator.list_sessions(str(tmp_path)) == () + + def test_list_sessions_filters_project_sidechains_and_preserves_source_order( + self, tmp_path: Path + ) -> None: + wanted = tmp_path / "wanted" + wanted.mkdir() + other = tmp_path / "other" + other.mkdir() + index_path = tmp_path / "codex-session-index.json" + records = [ + { + "backend_name": "codex", + "session_id": "newest", + "launch_id": None, + "cwd": str(wanted / "."), + "first_prompt": "new", + "summary": "", + "git_branch": None, + "modified": None, + "is_sidechain": False, + "session_type_hint": "cook", + }, + { + "backend_name": "codex", + "session_id": "sidechain", + "launch_id": None, + "cwd": str(wanted), + "first_prompt": "hidden", + "summary": "hidden", + "git_branch": None, + "modified": None, + "is_sidechain": True, + "session_type_hint": "cook", + }, + { + "backend_name": "codex", + "session_id": "other-project", + "launch_id": None, + "cwd": str(other), + "first_prompt": "hidden", + "summary": "hidden", + "git_branch": None, + "modified": None, + "is_sidechain": False, + "session_type_hint": "cook", + }, + { + "backend_name": "codex", + "session_id": "older", + "launch_id": None, + "cwd": str(wanted), + "first_prompt": "old", + "summary": "", + "git_branch": None, + "modified": None, + "is_sidechain": False, + "session_type_hint": "cook", + }, + ] + import json + + index_path.write_text(json.dumps(records), encoding="utf-8") + locator = CodexSessionLocator(store_root=tmp_path / "store", index_path=index_path) + + assert [ + item.session_id for item in locator.list_sessions(str(wanted / ".." / "wanted")) + ] == [ + "newest", + "older", + ] + def test_locate_session_finds_rollout_by_thread_id(self, tmp_path: Path) -> None: session_dir = tmp_path / "sessions" / "2026" / "05" / "26" session_dir.mkdir(parents=True) @@ -45,16 +170,11 @@ def test_locate_session_skips_non_matching_thread_id(self, tmp_path: Path) -> No locator = CodexSessionLocator() assert locator.locate_session("tid_wanted") is None - def test_locate_session_searches_permanent_dir( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - perm_dir = tmp_path / "logs" / "codex-sessions" / "2026" / "05" / "26" + def test_locate_session_searches_permanent_dir(self, tmp_path: Path) -> None: + perm_dir = tmp_path / "codex-sessions" / "2026" / "05" / "26" perm_dir.mkdir(parents=True) rollout = _make_rollout(perm_dir, "tid_perm") - monkeypatch.setattr( - "autoskillit.execution.backends.codex.default_log_dir", lambda: tmp_path / "logs" - ) - locator = CodexSessionLocator() + locator = CodexSessionLocator(store_root=tmp_path) result = locator.locate_session("tid_perm") assert result == rollout @@ -204,21 +324,11 @@ def test_locate_session_finds_session_meta_format(self, tmp_path: Path) -> None: result = locator.locate_session("tid_meta") assert result == rollout - def test_project_log_dir_returns_codex_sessions_subdir( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.setattr( - "autoskillit.execution.backends.codex.default_log_dir", lambda: tmp_path / "logs" - ) - locator = CodexSessionLocator() + def test_project_log_dir_returns_codex_sessions_subdir(self, tmp_path: Path) -> None: + locator = CodexSessionLocator(store_root=tmp_path / "logs") result = locator.project_log_dir("/any/cwd") assert result == tmp_path / "logs" / "codex-sessions" - def test_project_log_dir_ignores_cwd_argument( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.setattr( - "autoskillit.execution.backends.codex.default_log_dir", lambda: tmp_path / "logs" - ) - locator = CodexSessionLocator() + def test_project_log_dir_ignores_cwd_argument(self, tmp_path: Path) -> None: + locator = CodexSessionLocator(store_root=tmp_path / "logs") assert locator.project_log_dir("/path/a") == locator.project_log_dir("/path/b") diff --git a/tests/execution/backends/test_codex_session_storage.py b/tests/execution/backends/test_codex_session_storage.py new file mode 100644 index 0000000000..b7146b2b2e --- /dev/null +++ b/tests/execution/backends/test_codex_session_storage.py @@ -0,0 +1,139 @@ +"""Durable Codex rollout view, promotion, and recovery contracts.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest +from autoskillit.execution.backends._codex_session_storage import ( + CodexInteractiveSessionLease, + CodexSessionStore, +) + +from autoskillit.core import NamedResume, NoResume + +pytestmark = [pytest.mark.layer("execution"), pytest.mark.medium] + + +def _generated_home(tmp_path: Path) -> tuple[Path, dict[str, Path]]: + home = tmp_path / "generated-home" + home.mkdir() + inert_targets: dict[str, Path] = {} + for name in ("sessions", "archived_sessions"): + target = home / f".inert-{name}" + target.mkdir() + (home / name).symlink_to(target) + inert_targets[name] = target.resolve() + return home, inert_targets + + +def _rollout(path: Path, thread_id: str) -> bytes: + content = ( + f'{{"type":"thread.started","thread_id":"{thread_id}"}}\n{{"type":"turn.completed"}}\n' + ).encode() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + return content + + +def test_fresh_attempt_exposes_empty_view_and_no_child_abort_restores_inert_links( + tmp_path: Path, +) -> None: + log_dir = tmp_path / "log-root" + index_path = log_dir / "codex-session-index.json" + store = CodexSessionStore(log_dir=log_dir, index_path=index_path) + home, inert_targets = _generated_home(tmp_path) + lease = store.prepare_attempt( + session_home=home, + launch_id="0123456789abcdef", + attempt=1, + current_resume_spec=NoResume(), + ) + + assert isinstance(lease, CodexInteractiveSessionLease) + with lease as handle: + assert handle.view_id + assert (home / "sessions").resolve().parent.name == handle.view_id + assert (home / "archived_sessions").resolve().parent.name == handle.view_id + assert list((home / "sessions").resolve().iterdir()) == [] + assert list((home / "archived_sessions").resolve().iterdir()) == [] + + assert (home / "sessions").resolve() == inert_targets["sessions"] + assert (home / "archived_sessions").resolve() == inert_targets["archived_sessions"] + assert list((log_dir / "codex-active-sessions").glob("*")) == [] + assert list((log_dir / "codex-sessions").rglob("*.jsonl")) == [] + assert not index_path.exists() + + +def test_fresh_rollout_is_promoted_durably_and_indexed_once(tmp_path: Path) -> None: + log_dir = tmp_path / "log-root" + index_path = log_dir / "codex-session-index.json" + store = CodexSessionStore(log_dir=log_dir, index_path=index_path) + home, inert_targets = _generated_home(tmp_path) + expected: bytes + + with store.prepare_attempt( + session_home=home, + launch_id="0123456789abcdef", + attempt=1, + current_resume_spec=NoResume(), + ) as handle: + expected = _rollout( + (home / "sessions").resolve() / "2026" / "07" / "rollout-new.jsonl", + "thread-new", + ) + handle.record_spawn(os.getpid(), os.getpgrp()) + handle.record_reaped(os.getpid(), os.getpgrp()) + + promoted = log_dir / "codex-sessions" / "2026" / "07" / "rollout-new.jsonl" + assert promoted.read_bytes() == expected + assert (home / "sessions").resolve() == inert_targets["sessions"] + assert (home / "archived_sessions").resolve() == inert_targets["archived_sessions"] + rows = json.loads(index_path.read_text(encoding="utf-8")) + matching = [row for row in rows if row["session_id"] == "thread-new"] + assert len(matching) == 1 + assert matching[0]["backend_name"] == "codex" + assert matching[0]["canonical_store"] == "active" + assert matching[0]["relative_path"] == "2026/07/rollout-new.jsonl" + + +@pytest.mark.parametrize( + ("store_name", "public_name"), + [ + pytest.param("codex-sessions", "sessions", id="active"), + pytest.param("codex-archived-sessions", "archived_sessions", id="archived"), + ], +) +def test_named_resume_hard_links_only_selected_rollout_into_matching_view( + tmp_path: Path, store_name: str, public_name: str +) -> None: + log_dir = tmp_path / "log-root" + store = CodexSessionStore(log_dir=log_dir) + canonical = log_dir / store_name / "2026" / "07" / "rollout-resume.jsonl" + _rollout(canonical, "thread-resume") + unrelated = log_dir / store_name / "2026" / "07" / "rollout-unrelated.jsonl" + _rollout(unrelated, "thread-unrelated") + home, inert_targets = _generated_home(tmp_path) + canonical_identity = (canonical.stat().st_dev, canonical.stat().st_ino) + + with store.prepare_attempt( + session_home=home, + launch_id="0123456789abcdef", + attempt=1, + current_resume_spec=NamedResume("thread-resume"), + ) as handle: + resumed = (home / public_name).resolve() / "2026" / "07" / canonical.name + assert resumed.is_file() + assert (resumed.stat().st_dev, resumed.stat().st_ino) == canonical_identity + assert not ((home / public_name).resolve() / "2026" / "07" / unrelated.name).exists() + other_public = "archived_sessions" if public_name == "sessions" else "sessions" + assert list((home / other_public).resolve().rglob("*.jsonl")) == [] + handle.record_spawn(os.getpid(), os.getpgrp()) + handle.record_reaped(os.getpid(), os.getpgrp()) + + assert (canonical.stat().st_dev, canonical.stat().st_ino) == canonical_identity + assert unrelated.is_file() + assert (home / "sessions").resolve() == inert_targets["sessions"] + assert (home / "archived_sessions").resolve() == inert_targets["archived_sessions"] diff --git a/tests/execution/backends/test_coding_agent_backend_conformance.py b/tests/execution/backends/test_coding_agent_backend_conformance.py index 585ba833be..d4bfdcbbcb 100644 --- a/tests/execution/backends/test_coding_agent_backend_conformance.py +++ b/tests/execution/backends/test_coding_agent_backend_conformance.py @@ -53,6 +53,7 @@ "github_api_callable": "OPTIONAL", "has_unguarded_filesystem_access": "REQUIRED", "hook_config_format": "REQUIRED", + "hook_trust_policy": "REQUIRED", "inspector_capable": "OPTIONAL", "mcp_config_capable": "OPTIONAL", "mcp_env_forward_vars": "OPTIONAL", @@ -123,6 +124,12 @@ def test_capabilities_returns_backend_capabilities(self) -> None: """ assert isinstance(self.backend.capabilities, BackendCapabilities) + def test_hook_trust_policy_is_typed(self) -> None: + """BackendCapabilities.hook_trust_policy — interactive hook review policy.""" + from autoskillit.core import HookTrustPolicy + + assert isinstance(self.backend.capabilities.hook_trust_policy, HookTrustPolicy) + def test_conventions_returns_backend_conventions(self) -> None: assert isinstance(self.backend.conventions, BackendConventions) @@ -177,6 +184,10 @@ def test_session_locator_locate_empty_string_returns_none(self) -> None: locator = self.backend.session_locator() assert locator.locate_session("") is None + def test_session_locator_has_typed_listing(self) -> None: + locator = self.backend.session_locator() + assert callable(locator.list_sessions) + # --- Group 3: Command-builder Contracts --- def test_build_cmd_returns_cmd_spec(self) -> None: @@ -200,9 +211,17 @@ def test_build_interactive_cmd_returns_cmd_spec(self) -> None: assert isinstance(result.cmd, tuple) def test_validate_session_layout_returns_list(self, tmp_path: Path) -> None: - result = self.backend.validate_session_layout(tmp_path) + result = self.backend.validate_session_layout(tmp_path, project_dir=tmp_path) assert isinstance(result, list) + def test_validate_interactive_invocation_returns_list(self) -> None: + spec = self.backend.build_interactive_cmd() + assert isinstance(self.backend.validate_interactive_invocation(spec), list) + + def test_cook_lifecycle_boundaries_are_implemented(self) -> None: + assert callable(self.backend.recover_cook_history) + assert callable(self.backend.cook_session_context) + def test_validate_skill_content_returns_list(self) -> None: """BackendCapabilities.hook_config_format and skills_subdir — skill content validation.""" result = self.backend.validate_skill_content("") diff --git a/tests/execution/backends/test_composite_session_locator.py b/tests/execution/backends/test_composite_session_locator.py index 5df80c67cd..61db74ce92 100644 --- a/tests/execution/backends/test_composite_session_locator.py +++ b/tests/execution/backends/test_composite_session_locator.py @@ -6,7 +6,7 @@ import pytest -from autoskillit.core import SessionLocator +from autoskillit.core import SessionLocator, SessionSummary from autoskillit.execution.backends import CompositeSessionLocator pytestmark = [pytest.mark.layer("execution"), pytest.mark.small] @@ -20,9 +20,15 @@ def _fake_backend(locator: SessionLocator) -> type: class _StubLocator: - def __init__(self, locate_result: Path | None = None, log_dir: Path | None = None): + def __init__( + self, + locate_result: Path | None = None, + log_dir: Path | None = None, + summaries: tuple[SessionSummary, ...] = (), + ): self._locate = locate_result self._log_dir = log_dir or Path("/stub") + self._summaries = summaries def locate_session(self, session_id: str) -> Path | None: return self._locate @@ -33,6 +39,24 @@ def project_log_dir(self, cwd: str) -> Path: def session_log_path(self, cwd: str, session_id: str) -> Path | None: return self._locate + def list_sessions(self, cwd: str) -> tuple[SessionSummary, ...]: + return self._summaries + + +def _summary(backend_name: str, session_id: str, cwd: str) -> SessionSummary: + return SessionSummary( + backend_name=backend_name, + session_id=session_id, + launch_id=None, + cwd=cwd, + first_prompt="prompt", + summary="summary", + git_branch=None, + modified=None, + is_sidechain=False, + session_type_hint="cook", + ) + class TestLocateSession: def test_returns_first_non_none(self, monkeypatch): @@ -112,6 +136,52 @@ def test_delegates_to_locate_session(self, monkeypatch): assert CompositeSessionLocator().session_log_path("/cwd", "sid") == hit +class TestListSessions: + def test_preserves_backend_registry_and_source_order(self, monkeypatch, tmp_path): + cwd = str(tmp_path.resolve()) + registry = { + "claude-code": _fake_backend( + _StubLocator(summaries=(_summary("claude-code", "claude-new", cwd),)) + ), + "codex": _fake_backend( + _StubLocator( + summaries=( + _summary("codex", "codex-new", cwd), + _summary("codex", "codex-old", cwd), + ) + ) + ), + } + import autoskillit.execution.backends as backends_mod + + monkeypatch.setattr(backends_mod, "BACKEND_REGISTRY", registry) + + result = CompositeSessionLocator().list_sessions(cwd) + assert [(item.backend_name, item.session_id) for item in result] == [ + ("claude-code", "claude-new"), + ("codex", "codex-new"), + ("codex", "codex-old"), + ] + + def test_skips_failing_locator_without_reordering_other_sources(self, monkeypatch, tmp_path): + class _FailLocator(_StubLocator): + def list_sessions(self, cwd): + raise ValueError("corrupt backend index") + + cwd = str(tmp_path.resolve()) + registry = { + "broken": _fake_backend(_FailLocator()), + "codex": _fake_backend(_StubLocator(summaries=(_summary("codex", "survives", cwd),))), + } + import autoskillit.execution.backends as backends_mod + + monkeypatch.setattr(backends_mod, "BACKEND_REGISTRY", registry) + + assert [item.session_id for item in CompositeSessionLocator().list_sessions(cwd)] == [ + "survives" + ] + + class TestLocatorFor: def test_returns_backend_locator(self, monkeypatch): stub = _StubLocator(Path("/x")) diff --git a/tests/execution/backends/test_validate_session_layout.py b/tests/execution/backends/test_validate_session_layout.py index ca97c51c88..0637f26aa0 100644 --- a/tests/execution/backends/test_validate_session_layout.py +++ b/tests/execution/backends/test_validate_session_layout.py @@ -77,9 +77,12 @@ def test_codex_valid_layout_returns_empty(self, tmp_path): sessions_target = tmp_path / "sessions-target" sessions_target.mkdir() (tmp_path / "sessions").symlink_to(sessions_target) + archived_target = tmp_path / "archived-sessions-target" + archived_target.mkdir() + (tmp_path / "archived_sessions").symlink_to(archived_target) backend = CodexBackend() - errors = backend.validate_session_layout(tmp_path) + errors = backend.validate_session_layout(tmp_path, project_dir=tmp_path) assert errors == [] def test_codex_missing_skills_dir_returns_error(self, tmp_path): @@ -151,7 +154,7 @@ def test_codex_sessions_regular_dir_returns_error(self, tmp_path): assert len(errors) > 0 assert any("symlink" in e and "sessions" in e for e in errors) - def test_codex_sessions_absent_is_ok(self, tmp_path): + def test_codex_sessions_absent_returns_error(self, tmp_path): from autoskillit.execution.backends.codex import CodexBackend skills_dir = tmp_path / "skills" @@ -160,7 +163,67 @@ def test_codex_sessions_absent_is_ok(self, tmp_path): backend = CodexBackend() errors = backend.validate_session_layout(tmp_path) - assert not any("sessions" in e and "symlink" in e for e in errors) + assert any("sessions" in e and ("missing" in e or "symlink" in e) for e in errors) + + def test_codex_layout_validation_never_runs_native_probe(self, tmp_path, monkeypatch): + import subprocess + + from autoskillit.execution.backends.codex import CodexBackend + + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + (skills_dir / "some-skill").mkdir() + (tmp_path / "config.toml").write_text("[mcp_servers.autoskillit]\n") + for name in ("sessions", "archived_sessions"): + target = tmp_path / f".inert-{name}" + target.mkdir() + (tmp_path / name).symlink_to(target) + + def fail_if_called(*args, **kwargs): + raise AssertionError("layout validation must be filesystem-only") + + monkeypatch.setattr(subprocess, "run", fail_if_called) + + assert CodexBackend().validate_session_layout(tmp_path, project_dir=tmp_path) == [] + + @pytest.mark.parametrize("public_name", ["sessions", "archived_sessions"]) + def test_codex_layout_rejects_rollout_link_outside_generated_home(self, tmp_path, public_name): + from autoskillit.execution.backends.codex import CodexBackend + + generated_home = tmp_path / "generated-home" + skills_dir = generated_home / "skills" + skills_dir.mkdir(parents=True) + (skills_dir / "some-skill").mkdir() + (generated_home / "config.toml").write_text("[mcp_servers.autoskillit]\n") + external_store = tmp_path / "canonical" / public_name + external_store.mkdir(parents=True) + + for name in ("sessions", "archived_sessions"): + target = external_store if name == public_name else generated_home / f".inert-{name}" + target.mkdir(exist_ok=True) + (generated_home / name).symlink_to(target) + + errors = CodexBackend().validate_session_layout(generated_home, project_dir=tmp_path) + + assert any(public_name in error and "generated home" in error for error in errors) + + @pytest.mark.parametrize("public_name", ["sessions", "archived_sessions"]) + def test_codex_layout_rejects_nonempty_inert_rollout_target(self, tmp_path, public_name): + from autoskillit.execution.backends.codex import CodexBackend + + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + (skills_dir / "some-skill").mkdir() + (tmp_path / "config.toml").write_text("[mcp_servers.autoskillit]\n") + for name in ("sessions", "archived_sessions"): + target = tmp_path / f".inert-{name}" + target.mkdir() + (tmp_path / name).symlink_to(target) + (tmp_path / f".inert-{public_name}" / "unexpected.jsonl").write_text("{}") + + errors = CodexBackend().validate_session_layout(tmp_path, project_dir=tmp_path) + + assert any(public_name in error and "empty" in error for error in errors) def test_codex_profile_skills_appear_in_session_dir(self, tmp_path, monkeypatch): from autoskillit.execution.backends.codex import CodexBackend diff --git a/tests/execution/test_session_log_fields.py b/tests/execution/test_session_log_fields.py index 3e3291aa36..01e12aa597 100644 --- a/tests/execution/test_session_log_fields.py +++ b/tests/execution/test_session_log_fields.py @@ -43,6 +43,9 @@ def project_log_dir(self, cwd: str) -> Path: def session_log_path(self, cwd: str, session_id: str) -> Path | None: return self._path + def list_sessions(self, cwd: str) -> tuple: + return () + def test_flush_session_log_includes_write_path_warnings_in_summary(tmp_path): """summary.json records write_path_warnings list.""" diff --git a/tests/execution/test_smoke_codex.py b/tests/execution/test_smoke_codex.py index 407ab98168..1eab127fcf 100644 --- a/tests/execution/test_smoke_codex.py +++ b/tests/execution/test_smoke_codex.py @@ -266,6 +266,9 @@ def project_log_dir(self, cwd: str) -> Path: def session_log_path(self, cwd: str, session_id: str) -> Path: return self._path + def list_sessions(self, cwd: str) -> tuple: + return () + flush_session_log( log_dir=str(tmp_path), backend="codex", @@ -308,6 +311,9 @@ def project_log_dir(self, cwd: str) -> Path: def session_log_path(self, cwd: str, session_id: str) -> Path: return self._path + def list_sessions(self, cwd: str) -> tuple: + return () + def _fake_backend(locator): from unittest.mock import Mock diff --git a/tests/hooks/test_codex_hooks.py b/tests/hooks/test_codex_hooks.py index a309e2a5dc..d729d6e820 100644 --- a/tests/hooks/test_codex_hooks.py +++ b/tests/hooks/test_codex_hooks.py @@ -222,3 +222,55 @@ def test_sync_hooks_appends_to_corrupt_file(self, tmp_path): content = p.read_text(encoding="utf-8") assert "[projects./home/user/repo]" in content assert "[[hooks.PreToolUse]]" in content + + +def test_hook_writer_facade_owns_the_shared_config_lock( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import autoskillit.execution.backends._codex_hooks as hooks_module + + config_path = tmp_path / "source-home" / "config.toml" + events: list[tuple[str, Path]] = [] + lock_held = False + + class RecordingLock: + def __init__(self, path: Path) -> None: + self.path = Path(path) + + def __enter__(self) -> None: + nonlocal lock_held + assert lock_held is False + lock_held = True + events.append(("lock-enter", self.path)) + + def __exit__(self, *exc_info: object) -> None: + nonlocal lock_held + assert lock_held is True + events.append(("lock-exit", self.path)) + lock_held = False + + def fake_unlocked(*, config_path: Path, **_: object) -> bool: + assert lock_held is True + events.append(("hooks", config_path)) + return True + + monkeypatch.setattr(hooks_module, "CodexConfigLock", RecordingLock) + monkeypatch.setattr( + hooks_module, + "_sync_hooks_to_codex_config_unlocked", + fake_unlocked, + ) + + assert hooks_module.sync_hooks_to_codex_config(config_path=config_path) is True + assert events == [ + ("lock-enter", config_path), + ("hooks", config_path), + ("lock-exit", config_path), + ] + + +def test_cli_hook_bridge_exports_the_lock_owning_facade() -> None: + import autoskillit.cli._hooks_codex as cli_bridge + import autoskillit.execution.backends._codex_hooks as hooks_module + + assert cli_bridge.sync_hooks_to_codex_config is hooks_module.sync_hooks_to_codex_config diff --git a/tests/integration/test_codex_startup_canary.py b/tests/integration/test_codex_startup_canary.py new file mode 100644 index 0000000000..b870990898 --- /dev/null +++ b/tests/integration/test_codex_startup_canary.py @@ -0,0 +1,225 @@ +"""Opt-in installed-Codex compatibility gate for rollout links and leases.""" + +from __future__ import annotations + +import errno +import json +import os +import shutil +import signal +import subprocess +import sys +import uuid +from dataclasses import replace +from pathlib import Path + +import pytest + +from autoskillit.execution.backends.codex import CodexBackend + +try: + import fcntl +except ImportError: # pragma: no cover - exercised only on unsupported platforms + fcntl = None # type: ignore[assignment] + +pytestmark = [pytest.mark.large] + +_CANARY_ENV = "AUTOSKILLIT_CODEX_STARTUP_CANARY" +_SUPPORTED_VERSION = "codex-cli 0.145.0" +_OUTPUT_CAP = 64 * 1024 + + +def _installed_supported_codex() -> str: + if os.environ.get(_CANARY_ENV) != "1": + pytest.skip(f"set {_CANARY_ENV}=1 to run the installed-Codex canary") + if os.name != "posix" or sys.platform not in {"linux", "darwin"} or fcntl is None: + pytest.skip("installed-Codex canary requires a supported POSIX PTY/lease platform") + binary = shutil.which("codex") + if binary is None: + pytest.skip("supported installed Codex CLI is not present") + result = subprocess.run( + [binary, "--version"], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + version = result.stdout.strip() + if result.returncode != 0 or version != _SUPPORTED_VERSION: + pytest.skip(f"unsupported installed Codex version: {version or 'unknown'}") + return binary + + +def _prepare_home(path: Path) -> None: + path.mkdir() + (path / "sessions").mkdir() + source_auth = Path.home() / ".codex" / "auth.json" + if source_auth.is_file(): + (path / "auth.json").symlink_to(source_auth) + + +def _assert_competing_lease_is_blocked(path: Path) -> None: + assert fcntl is not None + competitor = os.open(path, os.O_RDWR) + try: + with pytest.raises(OSError) as caught: + fcntl.flock(competitor, fcntl.LOCK_EX | fcntl.LOCK_NB) + assert caught.value.errno in {errno.EACCES, errno.EAGAIN} + finally: + os.close(competitor) + + +def _assert_lease_released(path: Path) -> None: + assert fcntl is not None + competitor = os.open(path, os.O_RDWR) + try: + fcntl.flock(competitor, fcntl.LOCK_EX | fcntl.LOCK_NB) + finally: + os.close(competitor) + + +def _run_with_inherited_lease( + spec, + *, + project: Path, + lease_path: Path, +) -> tuple[bytes, bytes]: + assert fcntl is not None + lease_fd = os.open(lease_path, os.O_CREAT | os.O_RDWR, 0o600) + fcntl.flock(lease_fd, fcntl.LOCK_EX) + process = subprocess.Popen( + spec.cmd, + cwd=project, + env=spec.env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + pass_fds=(lease_fd,), + process_group=0, + start_new_session=False, + ) + os.close(lease_fd) # close-only transfer: LOCK_UN would release the shared OFD lock + try: + assert process.poll() is None, "Codex exited before inherited-lease observation" + _assert_competing_lease_is_blocked(lease_path) + stdout, stderr = process.communicate(timeout=90) + except BaseException: + if process.poll() is None: + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait(timeout=5) + raise + assert process.returncode == 0, stderr[-_OUTPUT_CAP:].decode(errors="replace") + _assert_lease_released(lease_path) + return stdout[-_OUTPUT_CAP:], stderr[-_OUTPUT_CAP:] + + +def _thread_id(stdout: bytes) -> str: + for line in stdout.splitlines(): + try: + record = json.loads(line) + except (UnicodeDecodeError, json.JSONDecodeError): + continue + if record.get("type") == "thread.started": + thread_id = record.get("thread_id") + if isinstance(thread_id, str) and thread_id: + return thread_id + pytest.fail("installed Codex did not emit a thread.started identifier") + + +def _rollouts(home: Path) -> list[Path]: + return sorted( + path + for path in (home / "sessions").rglob("rollout-*") + if path.is_file() and path.suffix in {".jsonl", ".zst"} + ) + + +def _assert_jsonl_schema(path: Path) -> None: + if path.suffix == ".zst": + assert path.stat().st_size > 0 + return + lines = path.read_bytes().splitlines() + assert lines + assert all(isinstance(json.loads(line), dict) for line in lines) + + +def test_installed_codex_preserves_staged_rollout_inode_and_inherited_lease( + tmp_path: Path, +) -> None: + binary = _installed_supported_codex() + project = tmp_path / "project" + project.mkdir() + diagnostics = project / ".autoskillit" / "temp" / uuid.uuid4().hex[:16] + diagnostics.mkdir(parents=True) + fresh_home = tmp_path / "fresh-home" + _prepare_home(fresh_home) + backend = CodexBackend() + fresh = backend.build_headless_cmd("Respond with exactly: autoskillit startup canary") + fresh = replace( + fresh, + cmd=(binary, *fresh.cmd[1:]), + env={**fresh.env, "CODEX_HOME": str(fresh_home)}, + cwd=str(project), + ) + fresh_stdout, fresh_stderr = _run_with_inherited_lease( + fresh, + project=project, + lease_path=tmp_path / "fresh.lease", + ) + thread_id = _thread_id(fresh_stdout) + fresh_rollouts = _rollouts(fresh_home) + assert len(fresh_rollouts) == 1 + _assert_jsonl_schema(fresh_rollouts[0]) + + resume_home = tmp_path / "resume-home" + _prepare_home(resume_home) + relative_rollout = fresh_rollouts[0].relative_to(fresh_home / "sessions") + staged = resume_home / "sessions" / relative_rollout + staged.parent.mkdir(parents=True, exist_ok=True) + os.link(fresh_rollouts[0], staged) + staged_identity = (staged.stat().st_dev, staged.stat().st_ino) + resume = backend.build_resume_cmd( + resume_session_id=thread_id, + prompt="Respond with exactly: autoskillit resume canary", + ) + resume = replace( + resume, + cmd=(binary, *resume.cmd[1:]), + env={**resume.env, "CODEX_HOME": str(resume_home)}, + cwd=str(project), + ) + resume_stdout, resume_stderr = _run_with_inherited_lease( + resume, + project=project, + lease_path=tmp_path / "resume.lease", + ) + final_rollouts = _rollouts(resume_home) + assert len(final_rollouts) == 1 + final = final_rollouts[0] + _assert_jsonl_schema(final) + if final.suffix == ".jsonl": + assert (final.stat().st_dev, final.stat().st_ino) == staged_identity + else: + assert not staged.exists() + assert final.name == f"{staged.name}.zst" + + (diagnostics / "environment.json").write_text( + json.dumps( + { + "codex_version": _SUPPORTED_VERSION, + "fresh_file_count": len(fresh_rollouts), + "fresh_allocated_bytes": fresh_rollouts[0].stat().st_blocks * 512, + "resume_file_count": len(final_rollouts), + "resume_allocated_bytes": final.stat().st_blocks * 512, + }, + sort_keys=True, + ), + encoding="utf-8", + ) + (diagnostics / "fresh.stdout").write_bytes(fresh_stdout) + (diagnostics / "fresh.stderr").write_bytes(fresh_stderr) + (diagnostics / "resume.stdout").write_bytes(resume_stdout) + (diagnostics / "resume.stderr").write_bytes(resume_stderr) diff --git a/tests/server/test_lifespan.py b/tests/server/test_lifespan.py index f9fdf9f64b..9b223583f8 100644 --- a/tests/server/test_lifespan.py +++ b/tests/server/test_lifespan.py @@ -1,6 +1,7 @@ """Tests that the FastMCP lifespan calls recorder.finalize() on server shutdown.""" import asyncio +from contextlib import contextmanager from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch @@ -200,12 +201,14 @@ def test_lifespan_boot_registry_covers_all_session_types() -> None: @pytest.mark.asyncio -async def test_lifespan_launches_codex_registration_for_codex_backend(): +async def test_lifespan_launches_codex_registration_for_codex_backend(tmp_path: Path): from autoskillit.server import _autoskillit_lifespan mock_ctx = MagicMock() mock_ctx.backend.name = "codex" + mock_ctx.backend.source_codex_home = tmp_path / "immutable-codex-source" mock_ctx.backend.capabilities.mcp_config_capable = True + mock_ctx.backend.capabilities.hook_config_format = "toml_nested" mock_ctx.runner = MagicMock() reg_mock = AsyncMock() @@ -220,7 +223,46 @@ async def test_lifespan_launches_codex_registration_for_codex_backend(): async with _autoskillit_lifespan(MagicMock()): pass - reg_mock.assert_called_once() + reg_mock.assert_called_once_with( + tmp_path / "immutable-codex-source", + hook_config_format="toml_nested", + ) + + +@pytest.mark.asyncio +async def test_codex_registration_uses_one_composed_transaction_without_reacquisition( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import autoskillit.server._lifespan as lifespan + + source_home = tmp_path / "immutable-codex-source" + ambient_home = tmp_path / "ambient-home" + calls: list[dict[str, object]] = [] + + @contextmanager + def record_transaction(**kwargs): + calls.append(kwargs) + yield source_home / "config.toml" + + monkeypatch.setattr(lifespan, "codex_prelaunch_transaction", record_transaction) + monkeypatch.setattr( + lifespan, + "ensure_codex_mcp_registered", + lambda **kwargs: pytest.fail(f"nested public facade call: {kwargs}"), + ) + monkeypatch.setattr(Path, "home", staticmethod(lambda: ambient_home)) + + await lifespan._run_codex_mcp_registration_async( + source_home, + hook_config_format="toml_nested", + ) + + assert calls == [ + { + "source_codex_home": source_home, + "hook_config_format": "toml_nested", + } + ] @pytest.mark.asyncio diff --git a/tests/workspace/test_session_skills_codex.py b/tests/workspace/test_session_skills_codex.py index a16bcff969..fa1c298fa7 100644 --- a/tests/workspace/test_session_skills_codex.py +++ b/tests/workspace/test_session_skills_codex.py @@ -2,32 +2,52 @@ from __future__ import annotations +from dataclasses import replace from pathlib import Path from unittest.mock import MagicMock import pytest -from autoskillit.core import ClaudeDirectoryConventions, SkillExecutionRole, ValidatedAddDir +import autoskillit.workspace.session_skills as session_skills +from autoskillit.core import ( + ClaudeDirectoryConventions, + ManagedSessionHome, + SkillExecutionRole, + ValidatedAddDir, +) from tests.workspace._helpers import _CODEX_CAPABILITIES pytestmark = [pytest.mark.layer("workspace"), pytest.mark.small] +class _BodyFailure(Exception): + pass + + +class _DeletionFailure(Exception): + pass + + +class _ReleaseFailure(Exception): + pass + + def _make_codex_backend() -> MagicMock: b = MagicMock() + b.name = "codex" b.capabilities = _CODEX_CAPABILITIES b.conventions.skills_subdir = ClaudeDirectoryConventions.PLUGIN_DIR_SKILLS_SUBDIR b.ensure_pre_launch.return_value = [] + b.validate_session_layout.return_value = [] return b -def _materialize( +def _catalog_context( manager, - session_id: str, *, backend=None, names: frozenset[str] | None = None, -) -> ValidatedAddDir: +): from autoskillit.workspace import DefaultSkillResolver, EffectiveSkillCatalog project_root = manager._root @@ -45,9 +65,25 @@ def _materialize( project_root, backend=backend, ) + return catalog, context + + +def _materialize( + manager, + session_id: str, + *, + backend=None, + names: frozenset[str] | None = None, +) -> ValidatedAddDir: + catalog, context = _catalog_context(manager, backend=backend, names=names) return manager.init_session(session_id, catalog, context) +def _managed(manager, session_id: str, *, backend): + catalog, context = _catalog_context(manager, backend=backend) + return manager.managed_session(session_id, catalog, context) + + @pytest.fixture def codex_env(): """Codex backend mock for delegation-contract tests.""" @@ -77,7 +113,7 @@ def test_codex_init_session_delegates_to_setup_session_dir( ) -> None: mgr = make_session_skill_manager() session_path = _materialize(mgr, "sid", backend=codex_env.backend) - codex_env.backend.setup_session_dir.assert_called_once_with(Path(str(session_path))) + codex_env.backend.setup_session_dir.assert_called_once_with(Path(str(session_path)).parent) def test_no_backend_skips_setup_session_dir(make_session_skill_manager) -> None: @@ -92,28 +128,29 @@ def test_codex_init_session_returns_validated_add_dir( mgr = make_session_skill_manager() result = _materialize(mgr, "sid", backend=codex_env.backend) assert isinstance(result, ValidatedAddDir) - assert str(result).endswith("/sid") + assert str(result).endswith("/sid/add-dir") def test_claude_backend_still_uses_dot_claude_layout(make_session_skill_manager) -> None: mgr = make_session_skill_manager() session_path = _materialize(mgr, "sid") - skill_files = list( - (session_path / ClaudeDirectoryConventions.ADD_DIR_SKILLS_SUBDIR).glob("*/SKILL.md") - ) - assert len(skill_files) > 0 - assert not (session_path / ClaudeDirectoryConventions.PLUGIN_DIR_SKILLS_SUBDIR).exists() + skill_files = list(session_path.glob("*/SKILL.md")) + assert skill_files == [] + returned = Path(str(session_path)) + assert returned.name == "add-dir" + assert list(returned.glob(".claude/skills/*/SKILL.md")) + assert not list(returned.glob("*/SKILL.md")) def test_codex_init_session_calls_ensure_pre_launch(make_session_skill_manager, codex_env) -> None: """init_session() must call backend.ensure_pre_launch() when mcp_config_capable is True.""" - pre_launch_called: list[bool] = [] codex_env.backend.ensure_pre_launch.return_value = [] - codex_env.backend.ensure_pre_launch.side_effect = lambda: pre_launch_called.append(True) or [] mgr = make_session_skill_manager() - _materialize(mgr, "sid", backend=codex_env.backend) - assert pre_launch_called, "ensure_pre_launch() must be called during init_session" + skills_dir = _materialize(mgr, "sid", backend=codex_env.backend) + codex_env.backend.ensure_pre_launch.assert_called_once_with( + session_dir=Path(str(skills_dir)).parent + ) def test_codex_init_session_raises_when_pre_launch_fails( @@ -187,6 +224,386 @@ def test_missing_profile_skills_dir_does_not_raise(tmp_path, monkeypatch) -> Non assert count == 0 +def test_managed_codex_home_uses_private_empty_inert_rollout_links( + make_session_skill_manager, codex_env, tmp_path: Path +) -> None: + codex_root = tmp_path / "persistent" / "codex-sessions" + mgr = make_session_skill_manager( + ephemeral_root=tmp_path / "ephemeral", + codex_root=codex_root, + ) + with _managed(mgr, "0123456789abcdef", backend=codex_env.backend) as managed: + assert isinstance(managed, ManagedSessionHome) + assert managed.generated_home == codex_root / "0123456789abcdef" + assert managed.skills_dir == ValidatedAddDir(path=str(managed.generated_home / "add-dir")) + + targets: list[Path] = [] + for public_name in ("sessions", "archived_sessions"): + public_path = managed.generated_home / public_name + assert public_path.is_symlink() + target = public_path.resolve(strict=True) + assert target.is_dir() + assert target.is_relative_to(managed.generated_home) + assert list(target.iterdir()) == [] + targets.append(target) + + assert targets[0] != targets[1] + assert all(target != codex_root.resolve() for target in targets) + + assert not (codex_root / "0123456789abcdef").exists() + assert "0123456789abcdef" not in mgr._session_roots + assert "0123456789abcdef" not in mgr._session_leases + assert "0123456789abcdef" not in mgr._session_skills_subdirs + + +def test_persistent_backend_declares_its_own_inert_paths( + make_session_skill_manager, + tmp_path: Path, +) -> None: + backend = _make_codex_backend() + backend.name = "persistent-test" + backend.capabilities = replace( + _CODEX_CAPABILITIES, + session_dir_symlinks=frozenset({"records"}), + ) + persistent_root = tmp_path / "persistent" / "custom-sessions" + mgr = make_session_skill_manager(codex_root=persistent_root) + with _managed(mgr, "0123456789abcdef", backend=backend) as managed: + records = managed.generated_home / "records" + assert records.is_symlink() + assert records.resolve(strict=True).is_dir() + assert not (managed.generated_home / "sessions").exists() + assert not (managed.generated_home / "archived_sessions").exists() + + +@pytest.mark.parametrize( + ("failure_stage", "expected"), + [ + pytest.param("prelaunch", "prelaunch failed", id="prelaunch"), + pytest.param("setup", "setup failed", id="backend-setup"), + pytest.param("layout", "layout failed", id="strict-layout"), + ], +) +def test_managed_codex_home_rolls_back_every_published_owner_on_initialization_failure( + make_session_skill_manager, + codex_env, + tmp_path: Path, + failure_stage: str, + expected: str, +) -> None: + codex_root = tmp_path / "persistent" / "codex-sessions" + mgr = make_session_skill_manager(codex_root=codex_root) + project_dir = tmp_path / "project" + project_dir.mkdir() + + if failure_stage == "prelaunch": + codex_env.backend.ensure_pre_launch.side_effect = RuntimeError(expected) + elif failure_stage == "setup": + codex_env.backend.setup_session_dir.side_effect = RuntimeError(expected) + else: + codex_env.backend.validate_session_layout.return_value = [expected] + + with pytest.raises(RuntimeError, match=expected): + with _managed(mgr, "0123456789abcdef", backend=codex_env.backend): + pytest.fail("initialization failure must occur before managed_session yields") + + assert not (codex_root / "0123456789abcdef").exists() + assert "0123456789abcdef" not in mgr._session_roots + assert "0123456789abcdef" not in mgr._session_leases + assert "0123456789abcdef" not in mgr._session_skills_subdirs + + +def test_managed_codex_home_cleans_up_once_when_body_raises( + make_session_skill_manager, codex_env, tmp_path: Path, monkeypatch +) -> None: + import autoskillit.workspace.session_skills as session_skills + + codex_root = tmp_path / "persistent" / "codex-sessions" + mgr = make_session_skill_manager(codex_root=codex_root) + project_dir = tmp_path / "project" + project_dir.mkdir() + real_rmtree = session_skills.shutil.rmtree + removed: list[Path] = [] + + def recording_rmtree(path: Path, *args, **kwargs) -> None: + removed.append(Path(path)) + real_rmtree(path, *args, **kwargs) + + monkeypatch.setattr(session_skills.shutil, "rmtree", recording_rmtree) + + with pytest.raises(KeyboardInterrupt, match="stop"): + with _managed(mgr, "0123456789abcdef", backend=codex_env.backend) as managed: + assert managed.generated_home.exists() + raise KeyboardInterrupt("stop") + + home = codex_root / "0123456789abcdef" + assert removed.count(home) == 1 + assert not home.exists() + + +def test_codex_session_requires_persistent_root_before_any_home_mutation( + make_session_skill_manager, codex_env, tmp_path: Path +) -> None: + from autoskillit.workspace import DefaultSessionSkillManager, SkillsDirectoryProvider + + ephemeral_root = tmp_path / "ephemeral" + mgr = DefaultSessionSkillManager( + SkillsDirectoryProvider(), + ephemeral_root=ephemeral_root, + persistent_root=None, + ) + + with pytest.raises(RuntimeError, match="persistent_root"): + _materialize(mgr, "0123456789abcdef", backend=codex_env.backend) + + assert not ephemeral_root.exists() + assert mgr._session_roots == {} + assert mgr._session_leases == {} + assert mgr._session_skills_subdirs == {} + + +def test_managed_codex_home_lease_acquisition_failure_precedes_home_mutation( + make_session_skill_manager, + codex_env, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + codex_root = tmp_path / "persistent" / "codex-sessions" + mgr = make_session_skill_manager(codex_root=codex_root) + project_dir = tmp_path / "project" + project_dir.mkdir() + + def fail_acquire( + cls: type[session_skills._SessionLease], + path: Path, + *, + blocking: bool, + ) -> session_skills._SessionLease | None: + del cls, path, blocking + raise OSError("lease open failed") + + monkeypatch.setattr( + session_skills._SessionLease, + "acquire", + classmethod(fail_acquire), + ) + + with pytest.raises(OSError, match="lease open failed"): + with _managed(mgr, "0123456789abcdef", backend=codex_env.backend): + pytest.fail("lease failure must precede yield") + + assert not (codex_root / "0123456789abcdef").exists() + assert mgr._session_roots == {} + assert mgr._session_leases == {} + assert mgr._session_skills_subdirs == {} + + +def test_managed_codex_home_never_reacquires_its_lease_after_initialization( + make_session_skill_manager, + codex_env, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + codex_root = tmp_path / "persistent" / "codex-sessions" + mgr = make_session_skill_manager(codex_root=codex_root) + project_dir = tmp_path / "project" + project_dir.mkdir() + original_acquire = session_skills._SessionLease.acquire.__func__ + calls: list[Path] = [] + + def recording_acquire( + cls: type[session_skills._SessionLease], + path: Path, + *, + blocking: bool, + ) -> session_skills._SessionLease | None: + calls.append(path) + return original_acquire(cls, path, blocking=blocking) + + monkeypatch.setattr( + session_skills._SessionLease, + "acquire", + classmethod(recording_acquire), + ) + + with _managed(mgr, "0123456789abcdef", backend=codex_env.backend): + pass + + assert len(calls) == 1 + + +def test_unowned_cleanup_refuses_a_contended_generated_home( + make_session_skill_manager, + codex_env, + tmp_path: Path, +) -> None: + codex_root = tmp_path / "persistent" / "codex-sessions" + owner = make_session_skill_manager(codex_root=codex_root) + contender = make_session_skill_manager(codex_root=codex_root) + _materialize(owner, "0123456789abcdef", backend=codex_env.backend) + + assert contender.cleanup_session("0123456789abcdef") is False + assert (codex_root / "0123456789abcdef").is_dir() + assert owner.cleanup_session("0123456789abcdef") is True + + +def test_session_lease_rejects_non_lock_path(tmp_path: Path) -> None: + invalid_path = tmp_path / ".session-leases" / "lease" + + with pytest.raises(ValueError, match=r"\.lock suffix"): + session_skills._SessionLease.acquire(invalid_path, blocking=True) + + assert not invalid_path.parent.exists() + + +def test_session_lease_refuses_symlinked_lock_directory(tmp_path: Path) -> None: + codex_root = tmp_path / "codex-sessions" + codex_root.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + lock_root = codex_root / ".session-leases" + lock_root.symlink_to(outside, target_is_directory=True) + + with pytest.raises(OSError): + session_skills._SessionLease.acquire( + lock_root / "0123456789abcdef.lock", + blocking=True, + ) + + assert list(outside.iterdir()) == [] + + +def test_session_lease_refuses_symlinked_lock_file(tmp_path: Path) -> None: + lock_root = tmp_path / "codex-sessions" / ".session-leases" + lock_root.mkdir(parents=True) + outside = tmp_path / "outside.lock" + outside.write_text("sentinel", encoding="utf-8") + lock_path = lock_root / "0123456789abcdef.lock" + lock_path.symlink_to(outside) + + with pytest.raises(OSError): + session_skills._SessionLease.acquire(lock_path, blocking=True) + + assert outside.read_text(encoding="utf-8") == "sentinel" + + +def test_stale_cleanup_never_treats_the_external_lock_directory_as_a_home( + make_session_skill_manager, + tmp_path: Path, +) -> None: + codex_root = tmp_path / "persistent" / "codex-sessions" + lock_root = codex_root / ".session-leases" + lock_root.mkdir(parents=True) + (lock_root / "orphan.lock").write_text("diagnostic", encoding="utf-8") + mgr = make_session_skill_manager( + ephemeral_root=tmp_path / "ephemeral", + codex_root=codex_root, + ) + + assert mgr.cleanup_stale(max_age_seconds=0) == 0 + assert (lock_root / "orphan.lock").is_file() + + +def test_unowned_cleanup_reclaims_a_dead_owner_home( + make_session_skill_manager, + tmp_path: Path, +) -> None: + codex_root = tmp_path / "persistent" / "codex-sessions" + generated_home = codex_root / "0123456789abcdef" + generated_home.mkdir(parents=True) + (generated_home / "stale").write_text("orphan", encoding="utf-8") + mgr = make_session_skill_manager(codex_root=codex_root) + + assert mgr.cleanup_session("0123456789abcdef") is True + assert not generated_home.exists() + + +def test_managed_cleanup_preserves_a_lone_deletion_failure( + make_session_skill_manager, + codex_env, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + codex_root = tmp_path / "persistent" / "codex-sessions" + mgr = make_session_skill_manager(codex_root=codex_root) + project_dir = tmp_path / "project" + project_dir.mkdir() + + def fail_delete(path: Path) -> bool: + del path + raise _DeletionFailure("delete failed") + + with pytest.raises(_DeletionFailure, match="delete failed"): + with _managed(mgr, "0123456789abcdef", backend=codex_env.backend): + monkeypatch.setattr( + session_skills, + "_remove_and_verify", + fail_delete, + ) + + +def test_managed_cleanup_preserves_a_lone_release_failure( + make_session_skill_manager, + codex_env, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + codex_root = tmp_path / "persistent" / "codex-sessions" + mgr = make_session_skill_manager(codex_root=codex_root) + project_dir = tmp_path / "project" + project_dir.mkdir() + real_release = session_skills._SessionLease.release + + def fail_after_release(lease: session_skills._SessionLease) -> None: + real_release(lease) + raise _ReleaseFailure("release failed") + + with pytest.raises(_ReleaseFailure, match="release failed"): + with _managed(mgr, "0123456789abcdef", backend=codex_env.backend): + monkeypatch.setattr( + session_skills._SessionLease, + "release", + fail_after_release, + ) + + +def test_managed_cleanup_groups_body_deletion_and_release_failures_in_order( + make_session_skill_manager, + codex_env, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + codex_root = tmp_path / "persistent" / "codex-sessions" + mgr = make_session_skill_manager(codex_root=codex_root) + project_dir = tmp_path / "project" + project_dir.mkdir() + real_release = session_skills._SessionLease.release + + def fail_delete(path: Path) -> bool: + del path + raise _DeletionFailure("delete failed") + + def fail_after_release(lease: session_skills._SessionLease) -> None: + real_release(lease) + raise _ReleaseFailure("release failed") + + with pytest.raises(BaseExceptionGroup) as caught: + with _managed(mgr, "0123456789abcdef", backend=codex_env.backend): + monkeypatch.setattr(session_skills, "_remove_and_verify", fail_delete) + monkeypatch.setattr( + session_skills._SessionLease, + "release", + fail_after_release, + ) + raise _BodyFailure("body failed") + + assert [type(error) for error in caught.value.exceptions] == [ + _BodyFailure, + _DeletionFailure, + _ReleaseFailure, + ] + + @pytest.mark.parametrize("backend_kind", ["claude-code", "codex"]) def test_session_projection_is_agent_safe_for_each_backend( make_session_skill_manager, diff --git a/tests/workspace/test_session_skills_provider.py b/tests/workspace/test_session_skills_provider.py index 239f810c56..bca7f1ee3c 100644 --- a/tests/workspace/test_session_skills_provider.py +++ b/tests/workspace/test_session_skills_provider.py @@ -1,19 +1,34 @@ -"""Phase 2 tests: session_skills module — provider and core manager.""" +"""Tests for exact-catalog session skill projection and manager ownership.""" from __future__ import annotations import re from pathlib import Path +from unittest.mock import MagicMock import pytest -from autoskillit.core import ClaudeDirectoryConventions +import autoskillit.workspace.session_skills as session_skills +from autoskillit.core import ( + SESSION_ADD_DIR_SUBDIR, + ClaudeDirectoryConventions, + SessionSkillManager, + SkillExecutionRole, + SkillSource, +) from autoskillit.core.io import load_yaml -from autoskillit.workspace.session_skills import ( +from autoskillit.workspace import ( + AgentSkillDocument, DefaultSessionSkillManager, + EffectiveSkillCatalog, + SkillCatalogEntry, + SkillInfo, + SkillProjectionContext, SkillsDirectoryProvider, + project_agent_skill_document, resolve_ephemeral_root, ) +from tests.workspace._helpers import _CODEX_CAPABILITIES pytestmark = [pytest.mark.layer("workspace"), pytest.mark.small] @@ -34,78 +49,66 @@ def _assert_agent_safe(content: str) -> None: assert _MACHINE_ONLY_FRONTMATTER_KEYS.isdisjoint(_frontmatter(content)) +def _catalog_context( + provider: SkillsDirectoryProvider, + project_root: Path, + *, + backend=None, +): + catalog = provider.resolver.list_effective( + project_root, + SkillExecutionRole.SESSION, + ) + context = provider.catalog_projection_context( + catalog, + project_root, + backend=backend, + ) + return catalog, context + + +def _codex_backend() -> MagicMock: + backend = MagicMock() + backend.name = "codex" + backend.capabilities = _CODEX_CAPABILITIES + backend.conventions.skills_subdir = ClaudeDirectoryConventions.PLUGIN_DIR_SKILLS_SUBDIR + backend.ensure_pre_launch.return_value = [] + backend.validate_session_layout.return_value = [] + return backend + + def test_resolve_ephemeral_root_returns_writable_dir( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - import autoskillit.workspace.session_skills as ss - - monkeypatch.setattr(ss, "_CANDIDATE_ROOTS", [tmp_path]) + monkeypatch.setattr(session_skills, "_CANDIDATE_ROOTS", [tmp_path]) root = resolve_ephemeral_root() - assert root.exists() assert root.is_dir() - test_file = root / "write_test.tmp" - test_file.write_text("ok") - test_file.unlink() + probe = root / "write_test.tmp" + probe.write_text("ok") + probe.unlink() -def test_resolve_ephemeral_root_fallback(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - import autoskillit.workspace.session_skills as ss +def test_skills_directory_provider_lists_public_skills() -> None: + names = {skill.name for skill in SkillsDirectoryProvider().list_skills()} + assert {"open-kitchen", "close-kitchen", "implement-worktree"} <= names + assert "sous-chef" not in names - monkeypatch.setattr(ss, "_CANDIDATE_ROOTS", [Path("/nonexistent"), tmp_path]) - root = ss.resolve_ephemeral_root() - assert root.exists() - -def test_skills_directory_provider_lists_all_skills() -> None: - provider = SkillsDirectoryProvider() - skills = provider.list_skills() - names = {s.name for s in skills} - assert "open-kitchen" in names - assert "close-kitchen" in names - assert "implement-worktree" in names - assert "sous-chef" not in names # internal, excluded - - -def test_provider_injects_disable_model_invocation_for_tier2() -> None: +def test_provider_gating_is_agent_safe() -> None: provider = SkillsDirectoryProvider() skill = provider.resolver.resolve_effective("open-kitchen", Path.cwd()) assert skill is not None - content = provider.get_skill_content(skill, cwd=Path.cwd(), gated=True) - fm_match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL) - assert fm_match, "Content must have YAML frontmatter" - fm = load_yaml(fm_match.group(1)) - assert fm.get("disable-model-invocation") is True + content = provider.get_skill_content(skill, cwd=Path.cwd(), gated=True) -def test_provider_does_not_inject_for_cook_session() -> None: - # Use mermaid (skills_extended/, no flag at rest) to verify that gated=False - # returns unmodified content without injecting disable-model-invocation. - # open-kitchen and close-kitchen carry disable-model-invocation: true in their source - # (human-only skills), so they cannot be used to assert "flag not present". - provider = SkillsDirectoryProvider() - skill = provider.resolver.resolve_effective("mermaid", Path.cwd()) - assert skill is not None - content = provider.get_skill_content(skill, cwd=Path.cwd(), gated=False) - fm_match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL) - assert fm_match, "Content must have YAML frontmatter" - fm = load_yaml(fm_match.group(1)) - assert fm.get("disable-model-invocation") is not True + assert _frontmatter(content)["disable-model-invocation"] is True + _assert_agent_safe(content) def test_agent_skill_projector_preserves_public_document_and_stable_digest( tmp_path: Path, ) -> None: - from autoskillit.core import SkillExecutionRole, SkillSource - from autoskillit.workspace import ( - AgentSkillDocument, - SkillCatalogEntry, - SkillProjectionContext, - project_agent_skill_document, - ) - from autoskillit.workspace.skills import EffectiveSkillCatalog, SkillInfo - - skill_md = tmp_path / "SKILL.md" - skill_md.write_text( + canonical = ( "---\n" "name: projected-skill\n" "description: Public description.\n" @@ -121,171 +124,28 @@ def test_agent_skill_projector_preserves_public_document_and_stable_digest( skill_info = SkillInfo( name="projected-skill", source=SkillSource.BUNDLED_EXTENDED, - path=skill_md, + path=tmp_path / "SKILL.md", + canonical_content=canonical, ) - catalog_entry = SkillCatalogEntry.from_skill_info(skill_info) - context = SkillProjectionContext( - cwd=tmp_path, - catalog=EffectiveSkillCatalog( - skills=(catalog_entry,), - execution_role=SkillExecutionRole.SESSION, - ), + entry = SkillCatalogEntry.from_skill_info(skill_info) + catalog = EffectiveSkillCatalog( + skills=(entry,), + execution_role=SkillExecutionRole.SESSION, ) + context = SkillProjectionContext(cwd=tmp_path, catalog=catalog) - first = project_agent_skill_document(catalog_entry, context) - second = project_agent_skill_document(catalog_entry, context) + first = project_agent_skill_document(entry, context) + second = project_agent_skill_document(entry, context) assert isinstance(first, AgentSkillDocument) _assert_agent_safe(first.content) - frontmatter = _frontmatter(first.content) - assert frontmatter["name"] == "projected-skill" - assert frontmatter["description"] == "Public description." - assert frontmatter["metadata"] == {"public-key": "public-value"} + assert _frontmatter(first.content)["metadata"] == {"public-key": "public-value"} assert first.content.endswith("# Public body\n\nKeep this body byte-for-byte.\n") assert first.projected_digest == second.projected_digest assert first.content == second.content -def test_provider_string_api_returns_unified_agent_safe_projection() -> None: - provider = SkillsDirectoryProvider() - raw = provider.resolver.resolve("make-arch-diag") - assert raw is not None - assert "uses_capabilities:" in raw.path.read_text() - - content = provider.get_skill_content(raw, cwd=Path.cwd(), gated=False) - - _assert_agent_safe(content) - assert _frontmatter(content)["name"] == "make-arch-diag" - assert "# Make-Arch-Diag: Architecture Diagram Generation" in content - - -def test_invocation_rejects_wrong_role_before_filesystem_work(tmp_path: Path) -> None: - from autoskillit.core import SkillContractError, SkillExecutionRole, SkillSource - from autoskillit.workspace import ( - EffectiveSkillInvocation, - SkillInfo, - ) - - ephemeral_root = tmp_path / "ephemeral" - skill = SkillInfo( - name="orchestrator", - source=SkillSource.PROJECT_LOCAL, - path=tmp_path / "source" / "SKILL.md", - execution_role=SkillExecutionRole.ORCHESTRATOR, - canonical_content=( - "---\nname: orchestrator\ndescription: Wrong role.\n" - "execution_role: orchestrator\n---\nbody\n" - ), - ) - with pytest.raises(SkillContractError, match="role"): - EffectiveSkillInvocation( - root=skill, - closure=(skill,), - capability_union=frozenset(), - project_root=tmp_path, - execution_role=SkillExecutionRole.SESSION, - ) - - assert not ephemeral_root.exists() - - -def test_invocation_revalidates_capability_role_before_filesystem_work( - tmp_path: Path, -) -> None: - from autoskillit.core import SkillContractError, SkillExecutionRole, SkillSource - from autoskillit.workspace import ( - EffectiveSkillInvocation, - SkillInfo, - ) - - ephemeral_root = tmp_path / "ephemeral" - skill = SkillInfo( - name="forged-session", - source=SkillSource.PROJECT_LOCAL, - path=tmp_path / "source" / "SKILL.md", - execution_role=SkillExecutionRole.SESSION, - uses_capabilities=frozenset({"run_skill"}), - canonical_content=( - "---\nname: forged-session\ndescription: Forged contract.\n" - "execution_role: session\nuses_capabilities: [run_skill]\n---\n" - 'Call run_skill("child").\n' - ), - ) - with pytest.raises(SkillContractError, match="run_skill.*session|session.*run_skill"): - EffectiveSkillInvocation( - root=skill, - closure=(skill,), - capability_union=frozenset({"run_skill"}), - project_root=tmp_path, - execution_role=SkillExecutionRole.SESSION, - ) - - assert not ephemeral_root.exists() - - -def test_review_pr_four_way_metadata_transport_projection_matrix(tmp_path: Path) -> None: - """Metadata removal is byte-inert; transport prose remains an independent input.""" - from autoskillit.core import SkillExecutionRole, SkillSource - from autoskillit.workspace import ( - EffectiveSkillCatalog, - SkillCatalogEntry, - SkillInfo, - SkillProjectionContext, - bundled_skills_extended_dir, - project_agent_skill_document, - ) - - canonical = (bundled_skills_extended_dir() / "review-pr" / "SKILL.md").read_text() - with_metadata = canonical.replace( - "uses_capabilities: [agent_model, github_api_write]", - "uses_capabilities: [agent_model, github_api_write, run_skill]", - ) - transport_line = "- Called by the recipe orchestrator via `run_skill` after `open_pr_step`\n" - variants = { - "metadata_plus_transport": with_metadata, - "metadata_removed": canonical, - "transport_removed": with_metadata.replace(transport_line, ""), - "both_removed": canonical.replace(transport_line, ""), - } - projected: dict[str, str] = {} - for name, content in variants.items(): - capabilities = ( - frozenset({"agent_model", "github_api_write", "run_skill"}) - if name in {"metadata_plus_transport", "transport_removed"} - else frozenset({"agent_model", "github_api_write"}) - ) - info = SkillInfo( - name="review-pr", - source=SkillSource.BUNDLED_EXTENDED, - path=tmp_path / name / "SKILL.md", - execution_role=SkillExecutionRole.SESSION, - uses_capabilities=capabilities, - canonical_content=content, - ) - context = SkillProjectionContext( - cwd=tmp_path, - catalog=EffectiveSkillCatalog( - skills=(SkillCatalogEntry.from_skill_info(info),), - execution_role=SkillExecutionRole.SESSION, - ), - ) - projected[name] = project_agent_skill_document(context.catalog.skills[0], context).content - _assert_agent_safe(projected[name]) - - assert projected["metadata_plus_transport"] == projected["metadata_removed"] - assert projected["transport_removed"] == projected["both_removed"] - assert projected["metadata_plus_transport"] != projected["transport_removed"] - - def test_session_manager_materializes_exact_catalog(tmp_path: Path) -> None: - from autoskillit.core import SessionSkillManager, SkillExecutionRole, SkillSource - from autoskillit.workspace import ( - EffectiveSkillCatalog, - SkillCatalogEntry, - SkillInfo, - SkillProjectionContext, - ) - canonical = ( "---\n" "name: exact-skill\n" @@ -323,3 +183,67 @@ def test_session_manager_materializes_exact_catalog(tmp_path: Path) -> None: ) assert projected.read_text().endswith("# Exact skill\n") _assert_agent_safe(projected.read_text()) + + +def test_skill_write_failure_rolls_back_unpublished_codex_home( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + codex_root = tmp_path / "persistent" / "codex-sessions" + provider = SkillsDirectoryProvider() + manager = DefaultSessionSkillManager( + provider, + ephemeral_root=tmp_path / "ephemeral", + persistent_root=codex_root, + ) + backend = _codex_backend() + catalog, context = _catalog_context(provider, tmp_path, backend=backend) + + def fail_write(*args, **kwargs) -> None: + del args, kwargs + raise RuntimeError("skill write failed") + + monkeypatch.setattr(session_skills, "materialize_agent_skill_tree", fail_write) + + with pytest.raises(RuntimeError, match="skill write failed"): + manager.init_session("0123456789abcdef", catalog, context) + + assert not (codex_root / "0123456789abcdef").exists() + assert manager._session_roots == {} + assert manager._session_leases == {} + assert manager._session_skills_subdirs == {} + + +def test_skills_subdirectory_is_owned_per_session_not_manager_wide(tmp_path: Path) -> None: + provider = SkillsDirectoryProvider() + manager = DefaultSessionSkillManager( + provider, + ephemeral_root=tmp_path / "ephemeral", + persistent_root=tmp_path / "persistent" / "codex-sessions", + ) + codex_backend = _codex_backend() + claude_catalog, claude_context = _catalog_context(provider, tmp_path) + codex_catalog, codex_context = _catalog_context( + provider, + tmp_path, + backend=codex_backend, + ) + + claude_home = manager.init_session( + "claude-session", + claude_catalog, + claude_context, + ) + codex_home = manager.init_session( + "0123456789abcdef", + codex_catalog, + codex_context, + ) + + assert manager._session_skills_subdirs == { + "claude-session": Path(SESSION_ADD_DIR_SUBDIR) + / ClaudeDirectoryConventions.ADD_DIR_SKILLS_SUBDIR, + "0123456789abcdef": Path(SESSION_ADD_DIR_SUBDIR) + / ClaudeDirectoryConventions.PLUGIN_DIR_SKILLS_SUBDIR, + } + assert (Path(str(claude_home)) / ".claude" / "skills").is_dir() + assert (Path(str(codex_home)) / "skills").is_dir() diff --git a/tests/workspace/test_session_skills_stale_path.py b/tests/workspace/test_session_skills_stale_path.py index 9d0138af79..354d52c42d 100644 --- a/tests/workspace/test_session_skills_stale_path.py +++ b/tests/workspace/test_session_skills_stale_path.py @@ -3,16 +3,28 @@ from __future__ import annotations import os +from pathlib import Path from unittest.mock import MagicMock import pytest +from autoskillit.core import ClaudeDirectoryConventions +from tests.workspace._helpers import _CODEX_CAPABILITIES + pytestmark = [pytest.mark.layer("workspace"), pytest.mark.small] -def _materialize(manager, session_id: str) -> None: - from pathlib import Path +def _codex_backend() -> MagicMock: + backend = MagicMock() + backend.name = "codex" + backend.capabilities = _CODEX_CAPABILITIES + backend.conventions.skills_subdir = ClaudeDirectoryConventions.PLUGIN_DIR_SKILLS_SUBDIR + backend.ensure_pre_launch.return_value = [] + backend.validate_session_layout.return_value = [] + return backend + +def _catalog_context(manager, *, backend=None): from autoskillit.core import SkillExecutionRole from autoskillit.workspace import DefaultSkillResolver @@ -21,8 +33,22 @@ def _materialize(manager, session_id: str) -> None: project_root, SkillExecutionRole.SESSION, ) - context = manager._provider.catalog_projection_context(catalog, project_root) - manager.init_session(session_id, catalog, context) + context = manager._provider.catalog_projection_context( + catalog, + project_root, + backend=backend, + ) + return catalog, context + + +def _materialize(manager, session_id: str, *, backend=None): + catalog, context = _catalog_context(manager, backend=backend) + return manager.init_session(session_id, catalog, context) + + +def _managed(manager, session_id: str, *, backend): + catalog, context = _catalog_context(manager, backend=backend) + return manager.managed_session(session_id, catalog, context) def test_validate_session_exists_true_for_live_session(make_session_skill_manager) -> None: @@ -53,12 +79,11 @@ def test_cleanup_stale_emits_log_event(make_session_skill_manager, monkeypatch) import autoskillit.workspace.session_skills as skills_mod mgr = make_session_skill_manager() - _materialize(mgr, "sess-stale") - - session_dir = mgr._session_roots["sess-stale"] / "sess-stale" # type: ignore[attr-defined] - assert session_dir.is_dir() + session_dir = mgr._root / "sess-stale" # type: ignore[attr-defined] + session_dir.mkdir(parents=True) + (session_dir / "orphaned-session-marker").touch() - # Backdate access time so the session qualifies as stale. + # Backdate an unowned generated home so the session qualifies as stale. old_time = 1_000_000.0 os.utime(session_dir, (old_time, old_time)) @@ -82,3 +107,38 @@ def test_cleanup_stale_emits_log_event(make_session_skill_manager, monkeypatch) assert "path" in kwargs assert "age_seconds" in kwargs assert kwargs["path"] == str(session_dir) + + +def test_cleanup_stale_does_not_remove_a_leased_generated_home( + make_session_skill_manager, tmp_path: Path +) -> None: + codex_root = tmp_path / "persistent" / "codex-sessions" + mgr = make_session_skill_manager(codex_root=codex_root) + with _managed(mgr, "0123456789abcdef", backend=_codex_backend()) as managed: + old_time = 1_000_000.0 + os.utime(managed.generated_home, (old_time, old_time)) + + assert mgr.cleanup_stale(max_age_seconds=1) == 0 + assert managed.generated_home.is_dir() + assert "0123456789abcdef" in mgr._session_leases + + assert not (codex_root / "0123456789abcdef").exists() + + +def test_init_session_retains_lease_until_cleanup_session( + make_session_skill_manager, tmp_path: Path +) -> None: + codex_root = tmp_path / "persistent" / "codex-sessions" + mgr = make_session_skill_manager(codex_root=codex_root) + + generated_home = _materialize( + mgr, + "0123456789abcdef", + backend=_codex_backend(), + ) + + assert Path(str(generated_home)).is_dir() + assert "0123456789abcdef" in mgr._session_leases + assert mgr.cleanup_session("0123456789abcdef") is True + assert "0123456789abcdef" not in mgr._session_leases + assert not Path(str(generated_home)).exists() From 2a4f90b412b7dc3400d7a43cbaec3e099e102a74 Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 23 Jul 2026 19:28:39 -0700 Subject: [PATCH 02/35] feat: isolate Codex cook startup history --- docs/design/README.md | 4 +- docs/design/acp-session-contract.md | 81 +- docs/design/paper-backend-n3-exercise.md | 105 +- docs/execution/architecture.md | 136 +++ docs/operations/observability.md | 130 ++ src/autoskillit/cli/_hooks_codex.py | 2 +- src/autoskillit/cli/_init_helpers.py | 25 +- src/autoskillit/cli/session/AGENTS.md | 3 + src/autoskillit/cli/session/_session_cook.py | 331 ++++-- src/autoskillit/cli/session/_session_order.py | 8 +- .../cli/session/_session_picker.py | 100 +- .../cli/session/_session_process.py | 270 +++++ .../cli/session/_session_startup_trace.py | 323 +++++ src/autoskillit/cli/session/pty/AGENTS.md | 19 + src/autoskillit/cli/session/pty/CLAUDE.md | 1 + src/autoskillit/cli/session/pty/__init__.py | 7 + src/autoskillit/cli/session/pty/_exec.py | 118 ++ src/autoskillit/cli/session/pty/_observer.py | 325 +++++ src/autoskillit/core/__init__.pyi | 9 + src/autoskillit/core/types/_type_backend.py | 42 +- src/autoskillit/core/types/_type_constants.py | 4 + .../core/types/_type_constants_env.py | 6 + src/autoskillit/core/types/_type_enums.py | 8 + .../core/types/_type_protocols_backend.py | 27 +- .../core/types/_type_protocols_workspace.py | 10 +- src/autoskillit/core/types/_type_results.py | 11 + src/autoskillit/execution/backends/AGENTS.md | 3 + .../execution/backends/_codex_config.py | 34 +- .../execution/backends/_codex_config_lock.py | 180 +++ .../execution/backends/_codex_hooks.py | 25 +- .../execution/backends/_codex_prelaunch.py | 38 + .../backends/_codex_session_storage.py | 1048 +++++++++++++++++ .../execution/backends/_composite_locator.py | 26 +- src/autoskillit/execution/backends/claude.py | 104 +- src/autoskillit/execution/backends/codex.py | 1001 ++++++++++++---- src/autoskillit/server/_factory.py | 4 +- src/autoskillit/server/_lifespan.py | 39 +- src/autoskillit/workspace/session_skills.py | 489 +++++++- tests/arch/test_ast_rules.py | 32 +- tests/arch/test_subpackage_isolation.py | 5 +- tests/cli/test_cook_process_lifecycle.py | 2 +- tests/execution/AGENTS.md | 2 + .../backends/test_codex_session_storage.py | 4 +- tests/fleet/test_state_lock_contract.py | 3 + tests/workspace/_helpers.py | 5 +- 45 files changed, 4644 insertions(+), 505 deletions(-) create mode 100644 src/autoskillit/cli/session/_session_process.py create mode 100644 src/autoskillit/cli/session/_session_startup_trace.py create mode 100644 src/autoskillit/cli/session/pty/AGENTS.md create mode 100644 src/autoskillit/cli/session/pty/CLAUDE.md create mode 100644 src/autoskillit/cli/session/pty/__init__.py create mode 100644 src/autoskillit/cli/session/pty/_exec.py create mode 100644 src/autoskillit/cli/session/pty/_observer.py create mode 100644 src/autoskillit/execution/backends/_codex_config_lock.py create mode 100644 src/autoskillit/execution/backends/_codex_prelaunch.py create mode 100644 src/autoskillit/execution/backends/_codex_session_storage.py diff --git a/docs/design/README.md b/docs/design/README.md index d554c549ac..67615fea3d 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -6,8 +6,8 @@ Design specifications for planned features and skills. |---|---| | [env-setup-design.md](env-setup-design.md) | Design spec for the dedicated `setup-environment` skill — Docker vs micromamba-host decision tree, structured output tokens, and recipe integration | | [recording-replay-accepted-degradations.md](recording-replay-accepted-degradations.md) | Accepted degradations in P8 recording/replay: Claude PTY cassette format incompatibility with Codex replay, and unchanged Claude session recording path | -| [acp-session-contract.md](acp-session-contract.md) | Normative reference for P6-A3-WP1: ACP session method mapping for the `CodingAgentBackend` lifecycle, recovery ladder from `RetryReason` to `session/resume`/`session/load`/`session/new`, capabilities translation of all 41 `BackendCapabilities` fields, and Codex shim deviations | -| [paper-backend-n3-exercise.md](paper-backend-n3-exercise.md) | N=3 paper backend exercise: opencode-via-ACP classification of all 30 Protocol methods, 41 `BackendCapabilities` fields, 2 `BackendConventions` fields, and B3a/B3b probe categories with gap catalogue of Protocol change requests | +| [acp-session-contract.md](acp-session-contract.md) | Normative reference for P6-A3-WP1: all 34 backend/sub-protocol methods, interactive launch/attempt ownership records and identifier scopes, recovery ladder from `RetryReason` to `session/resume`/`session/load`/`session/new`, all 47 `BackendCapabilities` fields, and Codex shim deviations | +| [paper-backend-n3-exercise.md](paper-backend-n3-exercise.md) | N=3 paper backend exercise: opencode-via-ACP classification of all 34 Protocol methods, 47 `BackendCapabilities` fields, 2 `BackendConventions` fields, and B3a/B3b probe categories with gap catalogue of Protocol change requests | | [forward-obligations/inspector-contract-impact.md](forward-obligations/inspector-contract-impact.md) | Contract-impact note for Health Inspector (#3534): `build_inspector_cmd` stubs, `inspector_capable` gaps, `InspectorCallback` wiring | | [forward-obligations/recording-replay-impact.md](forward-obligations/recording-replay-impact.md) | Contract-impact note for recording/replay: backend isolation invariant, format-detection extension points for future backends | | [forward-obligations/triage-portability-impact.md](forward-obligations/triage-portability-impact.md) | Contract-impact note for triage portability: `triage_capable` gate, hardcoded Claude CLI in `_llm_triage.py`, portable `triage_cmd` path | diff --git a/docs/design/acp-session-contract.md b/docs/design/acp-session-contract.md index d99fd8348e..ac14665b45 100644 --- a/docs/design/acp-session-contract.md +++ b/docs/design/acp-session-contract.md @@ -18,7 +18,7 @@ Four sections cover: 2. **Recovery Ladder** — mapping of the 16 `RetryReason` enum values to ACP session rungs (`session/resume`, `session/load`, `session/new`) and terminal/wait-and-retry handling, plus the contract-nudge mechanism. -3. **Capabilities Translation** — field-by-field categorization of all 41 +3. **Capabilities Translation** — field-by-field categorization of all 47 `BackendCapabilities` fields into ACP-mappable, autoskillit-local extension, and forward-declared buckets (validated against `_FORWARD_DECLARED` in `tests/arch/test_capability_consumption.py`). @@ -35,8 +35,11 @@ routing fields (`recipe/schema.py` lines 119–121). ## Section 1: Lifecycle Mapping -The `CodingAgentBackend` protocol (`src/autoskillit/core/types/_type_protocols_backend.py`, -lines 81–189) defines 23 methods. The per-method table below maps each protocol +The `CodingAgentBackend` protocol +(`src/autoskillit/core/types/_type_protocols_backend.py`) defines 26 methods. +Its four sub-protocols define another 8 (`StreamParser` 1, `ResultParser` 2, +`EnvPolicy` 1, and `SessionLocator` 4), for 34 documented protocol methods. +The per-method table below maps each backend protocol method to its ACP session method analogue for both `ClaudeCodeBackend` and `CodexBackend`, with explicit notes where the Codex implementation deviates. @@ -53,19 +56,50 @@ method to its ACP session method analogue for both `ClaudeCodeBackend` and | `build_inspector_cmd` | No ACP analogue (lightweight probe, not a session) | Raises `CapabilityNotSupportedError` (`inspector_capable=False` in `CLAUDE_CODE_CAPABILITIES`; unreachable `AssertionError` stub at line 875 is dead code) | Raises `CapabilityNotSupportedError` when `inspector_capable=False` | — (both backends gate via `inspector_capable=False`) | | `setup_session_dir` | ACP pre-session initialization | No-op | Substantial setup: copies `config.toml`, symlinks `auth.json` + `.env` + `sessions/`, generates agent TOMLs, materializes profile skills (lines 1021–1072) | Codex: rich setup with multiple failure-logged steps. | | `validate_session_layout` | ACP session validation (post-setup) | Validates session directory layout | Validates session directory layout (includes `required_session_files={"config.toml"}`) | — | +| `validate_interactive_invocation` | ACP launch admission | Validates the finalized interactive `CmdSpec` | Validates the exact finalized project-cwd command and immutable generated config before an attempt view exists | Codex validates config and reserved home/state overrides before storage mutation. | | `validate_skill_content` | ACP skill-content validation | YAML frontmatter validation (`required_skill_fields={"name", "description"}`) | Returns `[]` unconditionally (no frontmatter requirement) | Codex: structural discard — entire validation is a no-op. | | `stream_parser` | ACP event stream consumption | Returns a `StreamParser` for stdout/JSONL events | Returns a `StreamParser` for NDJSON events (`thread.started`, `turn.completed`, `turn.failed`) | Codex events are NDJSON with `thread_id` resolution for `CodexSessionLocator`. | | `result_parser` | ACP event aggregation | Returns a `ResultParser` aggregating events into `AgentSessionResult` | Returns a `ResultParser` aggregating Codex events; populated `jsonl_context_exhausted` when `error_code == CODEX_CONTEXT_EXHAUSTION_MARKER` (lines 105–108 of `_headless_evidence.py`) | Codex surfaces context-exhaustion via `error_code` rather than API `needs_retry`. | | `env_policy` | ACP environment contract | Returns `EnvPolicy` for subprocess env (injects MCP env-forward vars per Env Forwarding Contract) | Returns `EnvPolicy`; env-denylist prefixes via `CODEX_ENV_PREFIX_DENYLIST` | Codex applies a denylist; Claude Code does not. | -| `session_locator` | ACP session discovery / `session/load` | Returns a `SessionLocator` resolving session log dirs from Channel B JSONL | Returns `CodexSessionLocator` searching `rollout-*.jsonl` in `default_log_dir()/codex-sessions/` and `$CODEX_HOME/sessions/`, matching by `thread_id` from `thread.started` | Codex: dual-location search; ephemeral symlink from `$CODEX_HOME/sessions/` to permanent storage during `init_session()`. | +| `session_locator` | ACP session discovery / `session/load` | Returns a `SessionLocator` resolving session log dirs from Channel B JSONL | Returns `CodexSessionLocator`; `list_sessions` reads the derived typed index while `locate_session` searches canonical active/archive stores and validated active views by real `thread_id` | Listing is read-only; explicit recovery, not listing, rebuilds the derived index. | | `write_tool_names` | (write-detection contract) | `frozenset({"Write", "Edit", "Bash", "apply_patch"})` | `frozenset({"apply_patch", "Bash", "run_cmd"})` | Different write-tool vocabularies; Codex uses `apply_patch` + `run_cmd`. | | `binary_name` | (process identity) | `"claude"` | `"codex"` | — | | `version` | (capability introspection) | Returns backend version string | Returns backend version string | — | | `list_plugins` | (plugin enumeration) | Lists known plugins as dicts | Lists known plugins as dicts | — | | `ensure_pre_launch` | (pre-flight checks) | Pre-launch checks/setup | Pre-launch checks/setup | — | +| `recover_cook_history` | ACP durable-session recovery | No-op | Explicitly recovers safely owned attempt views, then rebuilds the derived index before bare-resume selection | Recovery is never hidden in `SessionLocator.list_sessions`. | +| `cook_session_context` | ACP attempt ownership | Null context | Acquires the per-attempt view/thread ownership contract and returns `CookSessionHandle` | Context exit requires durable spawn/reap proof before promotion. | | `translate_model` | (model alias resolution) | Translates canonical model name to backend-specific name | Translates canonical model name to backend-specific name | — | | `model_config_overrides` | (model-specific CLI overrides) | Returns CLI overrides tuple for a given model | Returns CLI overrides tuple for a given model | — | +### Interactive lifecycle data and ownership + +The backend-neutral records separate user-visible identity from launch +ownership: + +| Contract | Meaning | +|---|---| +| `SessionSummary` | Read-only picker record. `session_id` is the backend's resumable ID, optional `launch_id` joins the AutoSkillit registry, `cwd` is the project discriminator, and `session_type_hint` is used only when no registry classification exists. | +| `ManagedSessionHome` | One logical interactive launch: immutable `launch_id`, generated home, separately typed `skills_dir`, and inherited generated-home lease descriptors in `pass_fds`. Its context owns transactional setup, exactly-once verified cleanup, and unconditional lease release across all reload attempts. | +| `CookSessionHandle` | One attempt view: store-derived `view_id`, inherited view/thread descriptors, and one-shot `record_spawn(pid, pgid)` / `record_reaped(pid, pgid)` callbacks. Reap proof is recorded only after the complete process group is empty and the direct child is reaped. | +| `HookTrustPolicy` | `REVIEW_EACH_SESSION` emits no hook-trust bypass for interactive fresh, named-resume, bare-resume, or reload commands. Automated skill and food-truck builders retain their explicit bypass. | + +Identifier scopes are intentionally disjoint: `launch_id` spans one `cook()` +and its reload loop; `attempt` increments for each child launch; `view_id` is +derived once by storage from `(launch_id, attempt)`; and Codex `thread_id` is +the backend resume identity. None may be substituted for another. + +Ownership is similarly layered. The managed-home context owns generated-home +materialization and its lease. The prelaunch transaction owns synchronized MCP +and hook mutation plus the immutable config snapshot. `CodexSessionStore` +owns inert rollout targets, active views, manifests, locks, promotion, +recovery, and the rebuildable listing index; SQLite stays disposable inside +the generated home. The cook process owner alone owns PID/PGID creation, +terminal transfer, TERM/KILL cleanup, direct-child reap, and the callbacks +that allow storage finalization. The child command's finalized `cwd` remains +the canonical project directory; generated-home paths are reserved only for +configuration and state. + ### Method-grouping rationale The protocol methods partition into five behavioral clusters: @@ -175,7 +209,7 @@ The contract nudge exclusively targets `session/resume`; it never invokes ## Section 3: Capabilities Translation `BackendCapabilities` (`src/autoskillit/core/types/_type_backend.py`, -frozen dataclass, 40 fields total) declares feature flags the orchestrator +frozen dataclass, 47 fields total) declares feature flags the orchestrator consumes when selecting an ACP rung or backend-specific code path. Each field falls into one of three categories: @@ -186,7 +220,9 @@ falls into one of three categories: outside the exemption set. Membership is validated against `_FORWARD_DECLARED` in `tests/arch/test_capability_consumption.py`. -The counts below are **17 ACP-Mappable + 6 Forward-Declared + 17 autoskillit-Local = 40 total**. +The mechanically verified counts are **17 ACP-Mappable + 7 Forward-Declared ++ 23 autoskillit-Local = 47 total**. They supersede the earlier 41-field +snapshot. ### 3.1 Category 1: ACP-Mappable (17 fields) @@ -210,7 +246,7 @@ The counts below are **17 ACP-Mappable + 6 Forward-Declared + 17 autoskillit-Loc | `record_capable` | ACP scenario recording | | `inspector_capable` | ACP health monitoring callback (Health Inspector per issue #3533) | -### 3.2 Category 2: autoskillit-Local Extension (17 fields) +### 3.2 Category 2: autoskillit-Local Extension (23 fields) | Field | autoskillit-specific contract | |---|---| @@ -231,8 +267,14 @@ The counts below are **17 ACP-Mappable + 6 Forward-Declared + 17 autoskillit-Loc | `git_metadata_writable` | autoskillit git metadata safety (Claude: `True`; Codex: `False` — codex-rs sandbox) | | `skill_sigil` | autoskillit skill invocation prefix (Claude: `"/"`; Codex: `"$"`) | | `session_dir_persistent` | autoskillit session directory lifecycle (Codex retains `codex-sessions/`; Claude uses session JSONL rotation) | +| `cook_startup_observer_capable` | autoskillit guarded startup observation for interactive cook launches | +| `supports_model_invocation_gating` | autoskillit skill materialization policy when a backend cannot enforce model-invocation frontmatter | +| `unnegotiated_tool_result_token_limit` | Backend-owned conservative delivery bound when no protected host evidence is present | +| `protected_recipe_delivery_capable` | Whether a protected host channel can attest a larger recipe-delivery result limit | +| `recipe_delivery_budget` | Version-pinned backend authority for ordinary and protected recipe delivery | +| `hook_trust_policy` | Interactive hook-review policy translated at command construction; Codex uses `REVIEW_EACH_SESSION` | -### 3.3 Category 3: Forward-Declared (6 fields) +### 3.3 Category 3: Forward-Declared (7 fields) Membership in this category is **authoritative from `_FORWARD_DECLARED`** in `tests/arch/test_capability_consumption.py`. Fields here are declared for @@ -242,10 +284,11 @@ future use and have no current production consumer outside the exemption set. |---|---| | `supports_thinking_blocks` | Thinking-block rendering (currently `True` for Claude; `False` for Codex — no production rendering path yet) | | `required_session_files` | Session directory contract enforcement (Codex: `frozenset({"config.toml"})`; Claude: `frozenset()`) | -| `session_dir_symlinks` | Session directory layout (Codex: `frozenset({"auth.json", ".env", "sessions"})`; Claude: `frozenset()`) | +| `session_dir_symlinks` | Session directory layout (Codex: `frozenset({"sessions", "archived_sessions"})`; Claude: `frozenset()`) | | `patch_format` | Write-guard path extraction (Claude: `"unified_diff"`; Codex: `"codex_star_update"`) | | `min_version` | Version validation in doctor (Codex: `"0.130.0"`; Claude: `""`) | | `mcp_env_forward_vars` | MCP env forwarding (Codex: `CODEX_MCP_ENV_FORWARD_VARS`; Claude: `frozenset()`) | +| `github_api_callable` | Future network-capability gate for outbound GitHub API writes | ## Section 4: Codex Shim Deviations @@ -260,11 +303,13 @@ where Codex silently drops a parameter the protocol accepts. | Backend | Flag(s) | Source | |---|---|---| | Claude Code | `--dangerously-skip-permissions` (non-variadic flag) | `build_cmd` / `build_skill_session_cmd` | -| Codex | `--dangerously-bypass-approvals-and-sandbox` + `--dangerously-bypass-hook-trust` | Two separate flags; `CodexFlags.DANGEROUSLY_BYPASS` and `CodexFlags.DANGEROUSLY_BYPASS_HOOK_TRUST` (lines 98–107) | +| Codex interactive cook | `--dangerously-bypass-approvals-and-sandbox`; no hook-trust bypass | `HookTrustPolicy.REVIEW_EACH_SESSION` requires review of every generated config | +| Codex automated skill / food truck | `--dangerously-bypass-hook-trust` | Explicit automation policy after AutoSkillit has vetted generated hook source | -Codex splits the single Claude approval-bypass flag into two: one bypasses -approvals and sandbox, the other bypasses hook trust. Both must be passed to -fully replicate Claude's `--dangerously-skip-permissions` behavior. +Codex splits approval/sandbox bypass from hook trust. Interactive cook never +combines them: every fresh, named-resume, bare-resume, and reload config is +reviewed independently. The hook-trust bypass is limited to automated +builders. ### 4.2 developer_instructions @@ -299,15 +344,15 @@ The Channel B gap means Codex cannot use Channel-B-confirmed completion for the `COMPLETED` termination reason at `_retry_fsm.py:183`; Codex sessions must rely on `CHANNEL_A` / `UNMONITORED` / `DIR_MISSING` branches. -### 4.5 No PTY +### 4.5 PTY Is Not Backend-Required | Backend | PTY | Notes | |---|---|---| | Claude Code | `pty_required=True` per `CLAUDE_CODE_CAPABILITIES` (line 219); `ClaudeCodeBackend` delegates PTY allocation to the runner | The constant sets `True` but the backend itself does not allocate the PTY | -| Codex | `pty_required=False` — no pseudo-TTY allocation | Codex uses pipe-based stdio exclusively | +| Codex | `pty_required=False` — the backend does not require a PTY | Interactive cook may enable its transparent PTY observer; the process owner controls the group and terminal while the observer owns relay, resize, and raw-mode behavior | -The PTY distinction affects how subprocess output is buffered and how -signal-handling interacts with the watchdog (IDLE_STALL detection). +The capability means “not required,” not “prohibited.” Direct and observed +paths share the same finalized `CmdSpec` and process-group cleanup contract. ### 4.6 Inspector RuntimeError @@ -361,7 +406,7 @@ warning (the others are static no-ops with no observable behavior). | §2 RetryReason enum | `RetryReason` | `src/autoskillit/core/types/_type_enums.py` lines 44–64 | | §2 Retry routing | `_compute_retry`, `_build_skill_result` overrides | `src/autoskillit/execution/session/_retry_fsm.py`, `src/autoskillit/execution/headless/_headless_result.py` | | §2 Contract nudge | `_attempt_contract_nudge`, `_merge_token_usage` | `src/autoskillit/execution/headless/_headless_recovery.py` | -| §3 Capabilities | `BackendCapabilities` (40 fields) | `src/autoskillit/core/types/_type_backend.py` | +| §3 Capabilities | `BackendCapabilities` (47 fields) | `src/autoskillit/core/types/_type_backend.py` | | §3 Forward-declared | `_FORWARD_DECLARED` | `tests/arch/test_capability_consumption.py` | | §4 Codex flags | `CodexFlags` | `src/autoskillit/execution/backends/codex.py` lines 98–107 | | §4 Codex discard sites | `codex.py` F841 / warning sites | `src/autoskillit/execution/backends/codex.py` | diff --git a/docs/design/paper-backend-n3-exercise.md b/docs/design/paper-backend-n3-exercise.md index f0ad63a6cf..fe55e8772b 100644 --- a/docs/design/paper-backend-n3-exercise.md +++ b/docs/design/paper-backend-n3-exercise.md @@ -114,14 +114,15 @@ The capability profile in Sections 2–5 derives from three sources: The `acp-session-contract.md` document (produced by P6-A4-WP1, issue #4053) provides: -- **Section 1 (Lifecycle Mapping)** — the canonical 23-row `CodingAgentBackend` +- **Section 1 (Lifecycle Mapping)** — the canonical 26-row `CodingAgentBackend` method enumeration, which this exercise inherits as Section 2's row order. - **Section 2 (Recovery Ladder)** — the `RetryReason`-to-ACP-rung mapping that bounds which rows in Section 6 require new Protocol affordances (e.g., a GAP on `build_resume_cmd` implies a new `RetryReason`-to-rung pathway for opencode's session model). -- **Section 3 (Capabilities Translation)** — the 17 ACP-Mappable / 6 - Forward-Declared / 17 autoskillit-Local = 40 total taxonomy that this +- **Section 3 (Capabilities Translation)** — the mechanically verified + 17 ACP-Mappable / 7 Forward-Declared / 23 autoskillit-Local = 47 total + taxonomy that this exercise's Section 3 adopts and re-applies per-field. ### 1.3 Classification legend @@ -142,8 +143,8 @@ Each row in Sections 2–5 carries one of three classifications: ## Section 2: Protocol Method Classification The `CodingAgentBackend` protocol (`src/autoskillit/core/types/_type_protocols_backend.py`, -23 methods) and the four sub-protocols (`StreamParser`, `ResultParser`, -`EnvPolicy`, `SessionLocator`; 7 methods total) yield 30 rows below. The row +26 methods) and the four sub-protocols (`StreamParser`, `ResultParser`, +`EnvPolicy`, `SessionLocator`; 8 methods total) yield 34 rows below. The row order and per-method groupings follow `acp-session-contract.md` Section 1 (Claude Code and Codex columns are omitted; the rightmost column is the opencode classification). @@ -157,7 +158,7 @@ opencode classification). | 5 | CodingAgentBackend | `stream_parser` | SHIM-REQUIRED | No NDJSON streaming — adapter must consume bulk JSON or terminal-formatted output and emit `SessionEvent` values. | | 6 | CodingAgentBackend | `result_parser` | SHIM-REQUIRED | Aggregates adapted events into `AgentSessionResult`; token fields must be extracted from SQLite since they are not in stdout. | | 7 | CodingAgentBackend | `env_policy` | TRIVIAL | Standard env construction; opencode consumes provider config via env vars (e.g., `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`). | -| 8 | CodingAgentBackend | `session_locator` | SHIM-REQUIRED | Sessions live in SQLite (`ses_`), not filesystem JSONL/NDJSON. The three locator methods require a SQLite-backed adapter. | +| 8 | CodingAgentBackend | `session_locator` | SHIM-REQUIRED | Sessions live in SQLite (`ses_`), not filesystem JSONL/NDJSON. The four locator methods require a SQLite-backed adapter. | | 9 | CodingAgentBackend | `write_tool_names` | SHIM-REQUIRED | opencode's write tool vocabulary is undocumented; needs investigation before the `frozenset` can be populated. Codex returns `frozenset({"apply_patch", "Bash", "run_cmd"})`; opencode likely surfaces a different set. | | 10 | CodingAgentBackend | `binary_name` | TRIVIAL | Returns `"opencode"`. | | 11 | CodingAgentBackend | `build_resume_cmd` | GAP | No `--resume ` flag surfaced; SQLite session IDs are not first-class CLI arguments. Maps to PCR-002. | @@ -165,21 +166,36 @@ opencode classification). | 13 | CodingAgentBackend | `build_food_truck_cmd` | GAP | Same sandbox constraint blocks orchestrator-level L2 sessions. Maps to PCR-001 and PCR-003. | | 14 | CodingAgentBackend | `build_interactive_cmd` | SHIM-REQUIRED | `opencode run` exists for interactive use; sandbox restrictions apply but interactive flows can tolerate a tighter sandbox. | | 15 | CodingAgentBackend | `validate_session_layout` | TRIVIAL | Returns `[]` (Codex pattern); no session directory to validate when sessions are SQLite-backed. | -| 16 | CodingAgentBackend | `validate_skill_content` | TRIVIAL | Returns `[]` (Codex pattern); no frontmatter requirement when no skill injection is documented. | -| 17 | CodingAgentBackend | `version` | TRIVIAL | `opencode --version` returns the release string; daily cadence but parseable. | -| 18 | CodingAgentBackend | `list_plugins` | TRIVIAL | Returns `[]`; no plugin system documented. | -| 19 | CodingAgentBackend | `ensure_pre_launch` | SHIM-REQUIRED | Pre-launch check that the SQLite database is initialised and the configured provider env vars are present. | -| 20 | CodingAgentBackend | `translate_model` | SHIM-REQUIRED | AI SDK model identifiers differ from autoskillit's canonical aliases (`sonnet` / `opus` / `haiku`). Mapping table needed; precedent is `CODEX_MODEL_ALIASES` in `_type_backend.py:174–176`. | -| 21 | CodingAgentBackend | `model_config_overrides` | TRIVIAL | Returns `()` or a minimal per-model override tuple (analogous to Codex's effort-mapping for `sonnet`/`opus`/`haiku`). | -| 22 | CodingAgentBackend | `build_inspector_cmd` | TRIVIAL | Raises `CapabilityNotSupportedError` (same as both existing backends; `inspector_capable=False`). | -| 23 | CodingAgentBackend | `setup_session_dir` | SHIM-REQUIRED | Bridges SQLite session storage to the filesystem session dir contract; copies the SQLite session row to a session-dir stub so downstream consumers see a directory. | -| 24 | StreamParser | `parse_line` | SHIM-REQUIRED | Adapts non-NDJSON output (bulk JSON or terminal blocks) to `SessionEvent`. Per-line semantics do not apply; the parser operates on whole-output blocks. | -| 25 | ResultParser | `parse_result` | SHIM-REQUIRED | Aggregates adapted `SessionEvent` values into `AgentSessionResult`; identical to existing backends once events are shaped. | -| 26 | ResultParser | `parse_stdout` | SHIM-REQUIRED | Bulk JSON or terminal output parsing; token extraction deferred to SQLite lookup. | -| 27 | EnvPolicy | `build_env` | TRIVIAL | Standard env construction; no denylist required unless opencode leaks through sandbox-bypass env vars. | -| 28 | SessionLocator | `locate_session` | SHIM-REQUIRED | SQLite query by ULID session ID; returns a synthetic `Path` (e.g., the SQLite DB path) for callers that expect filesystem semantics. | -| 29 | SessionLocator | `project_log_dir` | SHIM-REQUIRED | Maps to `~/.local/share/opencode/` (the SQLite directory); per-project logic is opaque since opencode is global-session. | -| 30 | SessionLocator | `session_log_path` | SHIM-REQUIRED | SQLite-backed; no real path exists. Returning the SQLite DB path is the closest analogue. | +| 16 | CodingAgentBackend | `validate_interactive_invocation` | SHIM-REQUIRED | Validates the finalized command, project cwd, and any SQLite/config authority before attempt-owned mutation. | +| 17 | CodingAgentBackend | `validate_skill_content` | TRIVIAL | Returns `[]` (Codex pattern); no frontmatter requirement when no skill injection is documented. | +| 18 | CodingAgentBackend | `version` | TRIVIAL | `opencode --version` returns the release string; daily cadence but parseable. | +| 19 | CodingAgentBackend | `list_plugins` | TRIVIAL | Returns `[]`; no plugin system documented. | +| 20 | CodingAgentBackend | `ensure_pre_launch` | SHIM-REQUIRED | Pre-launch check that the SQLite database is initialised and the configured provider env vars are present. | +| 21 | CodingAgentBackend | `recover_cook_history` | SHIM-REQUIRED | Performs explicit SQLite/session-index recovery before bare-resume listing; listing itself remains read-only. | +| 22 | CodingAgentBackend | `cook_session_context` | SHIM-REQUIRED | Adapts opencode's SQLite ownership into an attempt context returning spawn/reap callbacks and inherited ownership descriptors where required. | +| 23 | CodingAgentBackend | `translate_model` | SHIM-REQUIRED | AI SDK model identifiers differ from autoskillit's canonical aliases (`sonnet` / `opus` / `haiku`). Mapping table needed; precedent is `CODEX_MODEL_ALIASES` in `_type_backend.py:174–176`. | +| 24 | CodingAgentBackend | `model_config_overrides` | TRIVIAL | Returns `()` or a minimal per-model override tuple (analogous to Codex's effort-mapping for `sonnet`/`opus`/`haiku`). | +| 25 | CodingAgentBackend | `build_inspector_cmd` | TRIVIAL | Raises `CapabilityNotSupportedError` (same as both existing backends; `inspector_capable=False`). | +| 26 | CodingAgentBackend | `setup_session_dir` | SHIM-REQUIRED | Bridges SQLite session storage to the filesystem session dir contract; copies the SQLite session row to a session-dir stub so downstream consumers see a directory. | +| 27 | StreamParser | `parse_line` | SHIM-REQUIRED | Adapts non-NDJSON output (bulk JSON or terminal blocks) to `SessionEvent`. Per-line semantics do not apply; the parser operates on whole-output blocks. | +| 28 | ResultParser | `parse_result` | SHIM-REQUIRED | Aggregates adapted `SessionEvent` values into `AgentSessionResult`; identical to existing backends once events are shaped. | +| 29 | ResultParser | `parse_stdout` | SHIM-REQUIRED | Bulk JSON or terminal output parsing; token extraction deferred to SQLite lookup. | +| 30 | EnvPolicy | `build_env` | TRIVIAL | Standard env construction; no denylist required unless opencode leaks through sandbox-bypass env vars. | +| 31 | SessionLocator | `list_sessions` | SHIM-REQUIRED | Returns typed `SessionSummary` records from SQLite in source order, filtered by project and sidechain status without recovery mutation. | +| 32 | SessionLocator | `locate_session` | SHIM-REQUIRED | SQLite query by ULID session ID; returns a synthetic `Path` (e.g., the SQLite DB path) for callers that expect filesystem semantics. | +| 33 | SessionLocator | `project_log_dir` | SHIM-REQUIRED | Maps to `~/.local/share/opencode/` (the SQLite directory); per-project logic is opaque since opencode is global-session. | +| 34 | SessionLocator | `session_log_path` | SHIM-REQUIRED | SQLite-backed; no real path exists. Returning the SQLite DB path is the closest analogue. | + +The candidate must also preserve the backend-neutral ownership records. +`SessionSummary.session_id` remains the opencode `ses_` while optional +`launch_id` joins AutoSkillit classification. `ManagedSessionHome` owns one +logical launch and its inherited lease descriptors; `CookSessionHandle` owns +one attempt and exposes the only valid spawn/reap proof callbacks. A future +adapter must keep `launch_id`, reload `attempt`, store-derived `view_id`, and +backend session ID distinct even if SQLite could encode them in one row. +Configuration snapshotting, durable session storage, and process-group +cleanup remain separate owners. `HookTrustPolicy` is translated only at +command construction, not inferred from `mcp_config_capable`. ### Method-cluster rollup @@ -187,12 +203,12 @@ opencode classification). |---|---|---|---| | Identity / introspection (`name`, `capabilities`, `conventions`, `binary_name`, `version`, `list_plugins`, `model_config_overrides`, `build_inspector_cmd`, `translate_model`) | 8 | 1 | 0 | | Command builders (`build_cmd`, `build_skill_session_cmd`, `build_food_truck_cmd`, `build_interactive_cmd`, `build_resume_cmd`) | 0 | 1 | 4 | -| Validation (`validate_session_layout`, `validate_skill_content`, `ensure_pre_launch`) | 2 | 1 | 0 | +| Validation (`validate_session_layout`, `validate_interactive_invocation`, `validate_skill_content`, `ensure_pre_launch`) | 2 | 2 | 0 | | Parsing (`stream_parser`, `result_parser`) | 0 | 2 | 0 | -| Session storage (`session_locator`, `setup_session_dir`, `write_tool_names`) | 0 | 3 | 0 | +| Session storage (`session_locator`, `setup_session_dir`, `write_tool_names`, `recover_cook_history`, `cook_session_context`) | 0 | 5 | 0 | | Environment (`env_policy`) | 1 | 0 | 0 | -| Sub-protocols (`StreamParser`, `ResultParser`, `EnvPolicy`, `SessionLocator`) | 1 | 6 | 0 | -| **Total (30 rows)** | **12** | **14** | **4** | +| Sub-protocols (`StreamParser`, `ResultParser`, `EnvPolicy`, `SessionLocator`) | 1 | 7 | 0 | +| **Total (34 rows)** | **12** | **18** | **4** | The 4 GAP rows map to 3 distinct root causes (sandbox blocker, resume absence, orchestrator session absence), consolidated into PCR-001, PCR-002, @@ -205,13 +221,13 @@ and PCR-003 in Section 6. The taxonomy below mirrors `acp-session-contract.md` Section 3: - **ACP-Mappable** — 17 fields with a direct or close ACP analogue. -- **autoskillit-Local Extension** — 17 fields with no ACP analogue but +- **autoskillit-Local Extension** — 23 fields with no ACP analogue but required for autoskillit's extended contract. -- **Forward-Declared** — 6 fields with no current production consumer +- **Forward-Declared** — 7 fields with no current production consumer outside the exemption set in `_FORWARD_DECLARED` (`tests/arch/test_capability_consumption.py:26–63`). -All 40 fields appear in exactly one row below. The "opencode value" column +All 47 fields appear in exactly one row below. The "opencode value" column records what the field would hold for an `OpencodeBackend.capabilities` instance; the classification column is TRIVIAL / SHIM-REQUIRED / GAP with the same legend as Section 2. @@ -238,7 +254,7 @@ same legend as Section 2. | 16 | `record_capable` | `False` | GAP | Same as row 15. Maps to PCR-009. | | 17 | `inspector_capable` | `False` | TRIVIAL | Same as both existing backends; raises `CapabilityNotSupportedError`. | -### 3.2 autoskillit-Local Extension fields (17) +### 3.2 autoskillit-Local Extension fields (23) | # | Field | opencode value | Classification | Rationale | |---|---|---|---|---| @@ -259,26 +275,33 @@ same legend as Section 2. | 32 | `git_metadata_writable` | `True` | TRIVIAL | opencode's `--sandbox deny` is write-blocked at the user level, not at the kernel level; `.git/worktrees/` remains writable. | | 33 | `skill_sigil` | `"$"` (defaulted) | SHIM-REQUIRED | No documented invocation prefix; defaulting to Codex's `"$"` until research surfaces an opencode-native convention. Maps to PCR-011. | | 34 | `session_dir_persistent` | `True` | SHIM-REQUIRED | SQLite-backed sessions are persistent by construction; filesystem bridge must produce a persistent dir (analogous to Codex's `session_dir_persistent=True`). | +| 35 | `cook_startup_observer_capable` | `False` | TRIVIAL | No guarded interactive-startup observer is documented for opencode. | +| 36 | `supports_model_invocation_gating` | `False` | TRIVIAL | With no skill injection, gated skill materialization must fail closed rather than relying on unsupported frontmatter. | +| 37 | `unnegotiated_tool_result_token_limit` | `10000` | TRIVIAL | Uses the conservative default until backend-specific conformance establishes a lower bound. | +| 38 | `protected_recipe_delivery_capable` | `False` | TRIVIAL | No protected host-attestation channel is documented. | +| 39 | `recipe_delivery_budget` | `None` | TRIVIAL | No version-pinned protected recipe-delivery contract exists. | +| 40 | `hook_trust_policy` | `HookTrustPolicy.AUTOMATED` | TRIVIAL | No per-generated-config hook review surface is documented; interactive builders therefore use the default automated policy. | -### 3.3 Forward-Declared fields (6) +### 3.3 Forward-Declared fields (7) | # | Field | Forward-declared issue | opencode value | Classification | Rationale | |---|---|---|---|---|---| -| 35 | `supports_thinking_blocks` | #3497 (Claude + Codex pre-existing) | `False` | TRIVIAL | No thinking-block format documented. | -| 36 | `required_session_files` | #3134 (Codex pre-existing) | `frozenset()` | TRIVIAL | No session-dir contract; SQLite-only. | -| 37 | `session_dir_symlinks` | #3134 (Codex pre-existing) | `frozenset()` | TRIVIAL | No symlink targets. | -| 38 | `patch_format` | #3776 (Codex pre-existing) | `""` | SHIM-REQUIRED | Write-detection adapter must surface patch evidence before a format string can be set. Maps to PCR-010. | -| 39 | `min_version` | #3122 (Codex pre-existing) | `""` | TRIVIAL | Version policy deferred; daily cadence makes pinning fragile. | -| 40 | `mcp_env_forward_vars` | #3458 (Codex pre-existing) | `frozenset()` | TRIVIAL | No MCP wiring; empty set. | +| 41 | `supports_thinking_blocks` | #3497 (Claude + Codex pre-existing) | `False` | TRIVIAL | No thinking-block format documented. | +| 42 | `required_session_files` | #3134 (Codex pre-existing) | `frozenset()` | TRIVIAL | No session-dir contract; SQLite-only. | +| 43 | `session_dir_symlinks` | #3134 (Codex pre-existing) | `frozenset()` | TRIVIAL | No symlink targets. | +| 44 | `patch_format` | #3776 (Codex pre-existing) | `""` | SHIM-REQUIRED | Write-detection adapter must surface patch evidence before a format string can be set. Maps to PCR-010. | +| 45 | `min_version` | #3122 (Codex pre-existing) | `""` | TRIVIAL | Version policy deferred; daily cadence makes pinning fragile. | +| 46 | `mcp_env_forward_vars` | #3458 (Codex pre-existing) | `frozenset()` | TRIVIAL | No MCP wiring; empty set. | +| 47 | `github_api_callable` | #4204 | `False` | TRIVIAL | No sandbox-permitted outbound GitHub API write path is documented. | ### 3.4 Capabilities rollup | Category | TRIVIAL | SHIM-REQUIRED | GAP | |---|---|---|---| | ACP-Mappable (17) | 6 | 3 | 8 | -| autoskillit-Local Extension (17) | 11 | 5 | 1 | -| Forward-Declared (6) | 5 | 1 | 0 | -| **Total (40)** | **22** | **9** | **9** | +| autoskillit-Local Extension (23) | 17 | 5 | 1 | +| Forward-Declared (7) | 6 | 1 | 0 | +| **Total (47)** | **29** | **9** | **9** | The 9 GAP rows consolidate into 8 PCRs (PCR-001 through PCR-005 and PCR-007 through PCR-009); the sandbox-policy mismatch in row 27 maps to PCR-001. The @@ -322,7 +345,7 @@ add a fixture directory under `tests/execution/backends/fixtures/`. **opencode classification: GAP.** opencode has no NDJSON streaming — output is either bulk JSON or terminal-formatted. The fixture-based conformance -probe category requires a new output-format adapter (Section 2 rows 5, 24) +probe category requires a new output-format adapter (Section 2 rows 5, 27) before any probe can be authored. The probe category itself remains valid as a contract surface; only the authoring pattern changes. @@ -414,7 +437,7 @@ semantics. | Section | Source of truth | File | |---|---|---| | §2 method enumeration | `CodingAgentBackend` Protocol + 4 sub-protocols | `src/autoskillit/core/types/_type_protocols_backend.py` | -| §3 capability taxonomy | `BackendCapabilities` (40 fields) + `_FORWARD_DECLARED` | `src/autoskillit/core/types/_type_backend.py`, `tests/arch/test_capability_consumption.py` | +| §3 capability taxonomy | `BackendCapabilities` (47 fields) + `_FORWARD_DECLARED` | `src/autoskillit/core/types/_type_backend.py`, `tests/arch/test_capability_consumption.py` | | §3 capability categories | ACP-Mappable / autoskillit-Local / Forward-Declared counts | `docs/design/acp-session-contract.md` Section 3 | | §4 conventions | `BackendConventions` (2 fields) | `src/autoskillit/core/types/_type_backend.py:38–49` | | §5 B3a pattern | Codex NDJSON fixtures | `tests/execution/backends/fixtures/codex_ndjson/` | diff --git a/docs/execution/architecture.md b/docs/execution/architecture.md index 768dbdf7fd..b3f19773b6 100644 --- a/docs/execution/architecture.md +++ b/docs/execution/architecture.md @@ -79,6 +79,142 @@ AutoSkillit supports four session modes with different tool and skill visibility This prevents recursive session nesting and keeps the orchestrator as a pure routing engine. See **[Skill Visibility](../skills/visibility.md)** for the full tier breakdown and configuration. +## Durable Codex Cook Sessions + +Codex cook history uses a per-attempt view rather than exposing the complete +canonical history beneath the generated `CODEX_HOME`. The identifiers are +deliberately distinct: + +- `launch_id` is the 16-character identifier for one complete `cook()` call. +- `attempt` starts at 1 and increases for each reload. +- `view_id` is `-` and names one durable attempt view. +- The Codex `thread_id` identifies a rollout and is never used as a launch or + view identifier. + +### Generated-home and configuration transaction + +`DefaultSessionSkillManager.managed_session()` acquires the generated-home +lease before materialization and holds it across plugin resolution, explicit +history recovery, the resume picker, and every reload. It removes and verifies +the generated home before releasing that lease. + +Three paths remain separate throughout the launch: + +- `generated_home` owns `CODEX_HOME`, `CODEX_SQLITE_HOME`, inert rollout + targets, and disposable state. +- `project_dir` is the canonical working directory used by native validation + and the child. +- `skills_dir` is the interactive `--add-dir`. + +The backend builds one immutable `CmdSpec`, including profile, trust, root, and +`sqlite_home` overrides. Cook replaces only its `cwd` with the canonical +project path and passes that exact instance to +`validate_interactive_invocation()`, `cook_session_context()`, and the child. +Ambient and profile-supplied `CODEX_HOME` or `CODEX_SQLITE_HOME` values cannot +override the generated home. + +Before an attempt is entered, `sessions` and `archived_sessions` are symlinks +to private, empty inert directories within the generated home. Attempt entry +atomically points them at the view; every exit restores and verifies the inert +links. + +### Canonical stores and attempt views + +The authoritative roots beneath `default_log_dir()` are: + +```text +codex-sessions/ # canonical active rollouts +codex-archived-sessions/ # canonical archived rollouts +codex-active-sessions/ + / + manifest.json + sessions/ # active side of this attempt + archived_sessions/ # archived side of this attempt +``` + +Before a view is created, all three roots must have the same `st_dev` and map +to one recognized local filesystem. Network, remote, cross-device, and +unclassifiable mounts fail closed because the design depends on hard-link +identity and advisory-lock behavior. Paths are containment-checked; symlink +and non-regular rollout inputs are rejected. + +A fresh view begins empty. A named resume locates exactly one canonical +rollout and hard-links it at the same relative path on the matching active or +archive side. Copying is prohibited. SQLite databases and sidecars remain +inside the disposable generated home and are never promoted. + +Each atomically written, directory-fsynced manifest records schema version, +launch/attempt/view identity, lifecycle state, optional resume thread and +source, child PID/PGID, durable reap proof, and final canonical store/path. +States are `prepared`, `running`, `finalizing`, `complete`, or `failed`. + +### Lock order, process proof, and promotion + +Lock acquisition order is: + +1. generated-home lease; +2. attempt-view lease; +3. resume-thread lease, when applicable; +4. short-lived lifecycle lock for promotion, recovery, and index mutation. + +The separate canonical-config lock may be held inside the generated-home +lease during prelaunch synchronization, but it is released before any view or +thread lease is acquired. No code waits for a long-lived lease while holding +the lifecycle lock. + +The generated-home, view, and thread descriptors are inherited by the child. +Immediately after `Popen` returns, `record_spawn(pid, pgid)` durably changes +the manifest to `running`. Only after the whole process group is empty and the +direct child is reaped may `record_reaped()` write the matching proof. +Finalization refuses to promote without it. A pre-spawn failure restores the +inert links and removes the validated never-running view; an ambiguous or +colliding view is retained for diagnosis. + +Promotion never overwrites. Recovery applies this inode table, where identity +means exactly `(st_dev, st_ino)`: + +| Staged source | Canonical destination | Action | +|---|---|---| +| present | absent | Hard-link destination, verify identity, fsync file/directory, then unlink source | +| present | same inode | Unlink the redundant staged source | +| absent | expected inode present | Treat the interrupted promotion as complete | +| present | different inode present | Preserve both and report a collision | +| absent | absent | Report missing data and retain the view | + +Supported `.jsonl` to `.jsonl.zst` representation transitions first make the +new same-thread representation durable, then retire the old canonical name. +A crash may temporarily leave both; recovery completes the transition only +when thread identity and manifest intent agree. + +`recover_cook_history()` is explicit and idempotent. Bare resume calls it +before listing; ordinary fresh startup does not scan canonical history. +Canonical active/archive files remain authoritative. The bounded +`codex-session-index.json` is only an atomically replaced, rebuildable +`SessionSummary` snapshot; locator listing reads it without hidden mutation. + +### PTY and hook-trust ownership + +`_session_process.py` alone owns process groups, foreground-PGID transfer, +TERM/KILL escalation, group-empty verification, and direct-child reap. +`pty/_observer.py` owns raw-mode entry, window propagation, transparent relay, +semantic observation, signal-handler restoration, and master-FD closure. +The parent starts `pty/_exec.py` as a session leader; that minimal launcher +uses the inherited slave as controlling terminal, duplicates standard +streams, and immediately execs Codex. It does not perform a second session +transition. + +Interactive Codex configs deliberately use +`HookTrustPolicy.REVIEW_EACH_SESSION`, so fresh, resumed, and reload commands +do not bypass hook review. Automated skill and food-truck builders retain +their explicit hook-trust bypass because they have a separate non-interactive +trust contract. + +The opt-in installed-Codex canary is a release gate for each supported +version. It must prove that fresh and resumed writes remain on the staged +inode (or follow an explicitly supported representation transition) and that +a live Codex process retains the inherited lease after the parent closes its +copy. Failure blocks the hard-link design for that version. + ## Safety See **[Hooks](../safety/hooks.md)** for the complete safety system: protected branches, quota management, format validation, and session boundary enforcement. diff --git a/docs/operations/observability.md b/docs/operations/observability.md index 4d23efcf64..589f7a21ab 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -95,6 +95,136 @@ jq 'select(.success == false)' ~/.local/share/autoskillit/logs/sessions.jsonl jq 'select(.anomaly_count > 0)' ~/.local/share/autoskillit/logs/sessions.jsonl ``` +## Codex cook startup traces + +Codex interactive startup has a separate append-only trace. It is disabled by +default. Set `AUTOSKILLIT_CODEX_STARTUP_TRACE=1` on the outer `autoskillit +cook` invocation to enable it. Absence means disabled; every other present +value is a launch-blocking configuration error. Cook consumes the variable +before building the child, and backend, nested, and headless environments +scrub it. + +### Path and schema + +`StartupTrace` schema version 1 writes to: + +```text +/codex-startup//.jsonl +``` + +`project-key` is the first 16 hexadecimal characters of SHA-256 over the +canonical project path. `launch-id` must be exactly 16 lowercase hexadecimal +characters. The writer containment-checks the path, refuses existing +symlinks, opens with no-follow append semantics, and fsyncs each record. + +Every record contains `schema_version`, `record_type`, `launch_id`, and +`monotonic_seconds`. Attempt and stage records also contain `attempt` and the +backend-returned `view_id`. Record types and meanings are: + +| Record | Meaning | +|---|---| +| `launch` | Final confirmation completed; this is the launch timing anchor | +| `attempt` | A fresh or reload attempt entered its durable backend context | +| `stage: spawn` | `Popen` returned successfully with the owned PID/PGID | +| `stage: state_ready` | The guarded Codex state query returned `complete` | +| `stage: first_output` | The PTY relayed its first non-empty output bytes | +| `stage: hook_review` | ANSI-normalized output semantically requested hook review | +| `summary` | Exactly-once terminal status, durations, budgets, and exceeded-budget names | + +The terminal owner supplies statuses such as `success`, `error`, +`interrupted`, and `child_failed`. `trace_record_overflow` is emitted by the +writer when mandatory fields cannot fit. Readiness failures retain their +typed status (`unsupported_version`, `absent`, `locked`, `corrupt`, +`incomplete`, `schema_changed`, `timeout`, or `cancelled`) in failure +diagnostics and must never be reported as ready. + +### Readiness guard + +The adapter is version-mapped, not discovery-based. For exactly +`codex-cli 0.145.0`, it opens `/state_5.sqlite` with +`mode=ro`, then applies: + +```sql +PRAGMA query_only = ON; +PRAGMA busy_timeout = 0; +PRAGMA table_info(backfill_state); +SELECT status FROM backfill_state WHERE id = 1; +``` + +The table must expose both `id` and `status`, and only the exact value +`complete` is ready. An unknown Codex version, absent or locked file, corrupt +database, missing/changed schema, incomplete row, timeout, or cancellation is +an explicit non-ready result. The enabled installed-Codex canary fails on an +unrecognized schema. + +### Hard budgets and history diagnostics + +The summary computes three absolute monotonic durations: + +| Duration | Hard ceiling | +|---|---:| +| `confirmation_to_spawn` | 5 seconds | +| `spawn_to_hook_review` | 12 seconds | +| `total_startup` | 17 seconds | + +`budget_exceeded` names every breached ceiling and `budgets_passed` is false +when any is breached. These are absolute gates. A favorable history-size +comparison or noisy measurement cannot excuse one. + +Attempt diagnostics may include `history_file_count` and +`history_allocated_bytes`; compare these with the empty/small profile to +report history-size deltas. Performance investigations use schema-valid +rollouts and record: + +- one warm-up followed by at least three retained measurements for + small-history, many-file, and large-byte profiles; +- randomized or interleaved profile order; +- each bounded raw sample, environment metadata, Codex version, file count, + allocated bytes, and observed stage endpoints; +- median plus median absolute deviation (MAD), or coefficient of variation; +- an explicit instability flag when dispersion exceeds the investigation's + declared threshold. + +With only three retained samples, deltas and dispersion are diagnostic +evidence, not independent pass/fail gates. Canary artifacts belong in that +test's unique `/.autoskillit/temp//` directory and are not +committed. + +### Bounded transcript behavior + +Each durable JSONL record is capped at 16 KiB. Only optional diagnostics may +be UTF-8 byte-truncated; oversized mandatory fields close the trace with +`trace_record_overflow`. + +The PTY relay is byte-transparent: bytes written to the terminal are exactly +the bytes read from the master. It separately retains at most 64 KiB of raw +output and a 64 KiB ANSI-normalized matching window for first-output and hook +review detection. The bounded window is diagnostic state, not a durable full +transcript, and must not be used to reconstruct or log secrets. + +### Queries + +```bash +TRACE_ROOT="$HOME/.local/share/autoskillit/logs/codex-startup" + +# All terminal summaries and their hard-budget result +find "$TRACE_ROOT" -name '*.jsonl' -type f -print0 | + xargs -0 jq -c 'select(.record_type == "summary") | + {launch_id, status, budgets_passed, budget_exceeded, durations_seconds}' + +# Stage timeline for one launch +jq -c '{record_type, stage, attempt, view_id, monotonic_seconds}' \ + "$TRACE_ROOT//.jsonl" + +# Attempts with populated-history diagnostics +find "$TRACE_ROOT" -name '*.jsonl' -type f -print0 | + xargs -0 jq -c 'select(.record_type == "attempt" and + (.diagnostics.history_file_count // 0) > 0)' +``` + +On macOS, substitute `~/Library/Application Support/autoskillit/logs` for the +Linux default log directory. + ## 500-directory retention `execution/session_log.py` keeps the most recent 500 session directories and diff --git a/src/autoskillit/cli/_hooks_codex.py b/src/autoskillit/cli/_hooks_codex.py index 6260300bd3..d3e9cd4087 100644 --- a/src/autoskillit/cli/_hooks_codex.py +++ b/src/autoskillit/cli/_hooks_codex.py @@ -4,7 +4,7 @@ This shim preserves the import path for CLI callers and tests. """ -from autoskillit.execution import ( +from autoskillit.execution.backends._codex_hooks import ( _is_autoskillit_hook_entry, generate_codex_hooks_config, sync_hooks_to_codex_config, diff --git a/src/autoskillit/cli/_init_helpers.py b/src/autoskillit/cli/_init_helpers.py index 0c06acc786..fd7592a2b3 100644 --- a/src/autoskillit/cli/_init_helpers.py +++ b/src/autoskillit/cli/_init_helpers.py @@ -508,12 +508,13 @@ def _register_all( backend = get_backend("claude-code") if backend.capabilities.mcp_config_capable: - from autoskillit.cli._hooks_codex import sync_hooks_to_codex_config - - sync_hooks_to_codex_config( - hook_config_format=backend.capabilities.hook_config_format, - ) + prelaunch_errors = backend.ensure_pre_launch() + if prelaunch_errors: + raise RuntimeError( + "Backend pre-launch configuration failed: " + "; ".join(prelaunch_errors) + ) plugin_ok = None + codex_status = "ok" else: settings_path = _claude_settings_path(scope) _evict_stale_autoskillit_hooks(settings_path) @@ -525,14 +526,14 @@ def _register_all( else: evict_direct_mcp_entry(_user_claude_json_path()) - from autoskillit.execution import ensure_codex_mcp_registered # noqa: PLC0415 + from autoskillit.execution import ensure_codex_mcp_registered # noqa: PLC0415 - try: - codex_registered = ensure_codex_mcp_registered() - codex_status = "registered" if codex_registered else "ok" - except Exception: - codex_status = "failed" - logger.warning("Codex MCP registration failed", exc_info=True) + try: + codex_registered = ensure_codex_mcp_registered() + codex_status = "registered" if codex_registered else "ok" + except Exception: + codex_status = "failed" + logger.warning("Codex MCP registration failed", exc_info=True) # Prompt for github.default_repo if running interactively github_repo = None diff --git a/src/autoskillit/cli/session/AGENTS.md b/src/autoskillit/cli/session/AGENTS.md index e3b5730c58..091dc2ff42 100644 --- a/src/autoskillit/cli/session/AGENTS.md +++ b/src/autoskillit/cli/session/AGENTS.md @@ -13,3 +13,6 @@ Interactive session management — cook (ephemeral) and order (orchestrator) ent | `_session_reload.py` | `consume_reload_sentinel()` — detects reload sentinel written by MCP reload tool | | `_session_launch.py` | Shared prelude: `_launch_cook_session()`, `_run_interactive_session()` | | `_session_picker.py` | Scoped resume picker: filters session history by greeting prefix | +| `_session_process.py` | Sole cook-attempt `Popen` owner — process groups, terminal foreground transfer, deterministic termination, and reap proof | +| `_session_startup_trace.py` | Bounded versioned Codex startup JSONL tracing with monotonic timing budgets and durable terminal summaries | +| `pty/` | Private POSIX PTY observer and exec-side controlling-terminal launcher | diff --git a/src/autoskillit/cli/session/_session_cook.py b/src/autoskillit/cli/session/_session_cook.py index 6850eb7268..9dc6e624d9 100644 --- a/src/autoskillit/cli/session/_session_cook.py +++ b/src/autoskillit/cli/session/_session_cook.py @@ -2,18 +2,19 @@ from __future__ import annotations +import os import shutil -import subprocess import sys import uuid -from collections.abc import Mapping +from dataclasses import replace from pathlib import Path from typing import TYPE_CHECKING -from autoskillit.cli.ui._terminal import terminal_guard from autoskillit.core import is_feature_enabled, resolve_project_dir if TYPE_CHECKING: + from autoskillit.cli.session._session_startup_trace import StartupTrace + from autoskillit.cli.session.pty._observer import PtyObserver from autoskillit.core import CodingAgentBackend @@ -39,32 +40,6 @@ def _print_recipes_list() -> None: print(f"{r.name:<{name_w}} {r.source:<{src_w}} {r.description}") -def _run_cook_session( - *, - cmd: list[str], - env: Mapping[str, str], - _first_run: bool, - initial_prompt: str | None, - project_dir: Path, -) -> str | None: - """Run the cook subprocess; return session_id if a reload sentinel was written.""" - from autoskillit.cli.session._session_reload import consume_reload_sentinel - - with terminal_guard(): - result = subprocess.run(cmd, env=env) - reload_session_id = consume_reload_sentinel(project_dir) - if reload_session_id is not None: - return reload_session_id - if result.returncode == 0: - if _first_run and initial_prompt is not None: - from autoskillit.cli._onboarding import mark_onboarded - - mark_onboarded(project_dir) - else: - raise SystemExit(result.returncode) - return None - - def cook( *, resume: bool = False, @@ -81,6 +56,7 @@ def cook( SkillsDirectoryProvider, project_default_plugin_source, resolve_ephemeral_root, + resolve_persistent_session_root, validate_skill_tier_roles, ) @@ -147,18 +123,13 @@ def cook( print() from autoskillit.cli.ui._ansi import permissions_warning - from autoskillit.cli.ui._timed_input import timed_prompt print(permissions_warning()) - confirm = timed_prompt( - "\nLaunch session? [Enter/n]", default="", timeout=120, label="autoskillit cook" - ) - if confirm.lower() in ("n", "no"): - return from autoskillit.cli._onboarding import is_first_run, run_onboarding_menu from autoskillit.cli.session._session_constants import SESSION_TYPE_COOK from autoskillit.core import ( + CODEX_STARTUP_TRACE_ENV_VAR, LAUNCH_ID_ENV_VAR, PROVIDER_PROFILE_ENV_VAR, SESSION_TYPE_ENV_VAR, @@ -168,6 +139,7 @@ def cook( SessionType, SkillExecutionRole, configure_logging, + resolve_temp_dir, resume_spec_from_cli, temp_dir_display_str, write_registry_entry, @@ -175,22 +147,27 @@ def cook( configure_logging() + launch_id = uuid.uuid4().hex[:16] resume_spec = resume_spec_from_cli(resume=resume, session_id=session_id) - + trace_setting = os.environ.pop(CODEX_STARTUP_TRACE_ENV_VAR, None) + if trace_setting not in {None, "1"}: + raise ValueError(f"{CODEX_STARTUP_TRACE_ENV_VAR} must be absent or exactly '1'") + trace_enabled = trace_setting == "1" and backend.capabilities.cook_startup_observer_capable + + persistent_root = resolve_persistent_session_root( + resolve_temp_dir(project_dir, config.workspace.temp_dir), + backend, + ) initial_prompt: str | None = None - _first_run = is_first_run(project_dir) - if _first_run: + first_run = is_first_run(project_dir) + if first_run: initial_prompt = run_onboarding_menu(project_dir, color=color) - session_id_local = uuid.uuid4().hex[:16] - write_registry_entry(project_dir, session_id_local, SESSION_TYPE_COOK, None) ephemeral_root = resolve_ephemeral_root() skills_provider = SkillsDirectoryProvider( temp_dir_relpath=temp_dir_display_str(config.workspace.temp_dir), default_base_branch=config.branching.default_base_branch, ) - session_mgr = DefaultSessionSkillManager(skills_provider, ephemeral_root) - session_mgr.cleanup_stale() session_catalog = skill_resolver.list_effective( project_dir, SkillExecutionRole.SESSION, @@ -202,73 +179,217 @@ def cook( project_dir, backend=backend, ) - skills_dir = session_mgr.init_session( - session_id_local, + session_mgr = DefaultSessionSkillManager( + skills_provider, + ephemeral_root, + persistent_root=persistent_root, + ) + session_mgr.cleanup_stale() + + with session_mgr.managed_session( + launch_id, session_catalog, projection_context, - ) + ) as managed_home: + # Single resolution authority, shared with make_context: the running + # package, projected for this session's backend and skill catalog. + plugin_source = project_default_plugin_source( + cwd=project_dir, + backend=backend, + default_base_branch=config.branching.default_base_branch, + skill_catalog=session_catalog, + ) - # Single resolution authority, shared with make_context: the running package, - # projected. Reading installed_plugins.json here is what made `cook` execute an - # eleven-versions-old snapshot of recipes/, agents/ and hooks/ against current code. - plugin_source = project_default_plugin_source( - cwd=project_dir, - backend=backend, - default_base_branch=config.branching.default_base_branch, - skill_catalog=session_catalog, - ) + if isinstance(resume_spec, BareResume): + backend.recover_cook_history() + from autoskillit.cli.session._session_picker import pick_session + + selected_id = pick_session( + SESSION_TYPE_COOK, + project_dir, + backend.session_locator(), + ) + if selected_id is not None: + resume_spec = NamedResume(session_id=selected_id) + else: + resume_spec = NoResume() + + from autoskillit.cli.session._session_startup_trace import StartupTrace + from autoskillit.cli.ui._timed_input import timed_prompt + + trace = StartupTrace(project_dir, launch_id, enabled=trace_enabled) + confirm = timed_prompt( + "\nLaunch session? [Enter/n]", + default="", + timeout=120, + label="autoskillit cook", + ) + if confirm.lower() in ("n", "no"): + return + trace.record_launch_anchor() + write_registry_entry(project_dir, launch_id, SESSION_TYPE_COOK, None) + + cook_env_extras: dict[str, str] = { + SESSION_TYPE_ENV_VAR: SessionType.SKILL.value, + LAUNCH_ID_ENV_VAR: launch_id, + } + if profile is not None: + cook_env_extras[PROVIDER_PROFILE_ENV_VAR] = profile + cook_env_extras.update( + { + key: value + for key, value in config.providers.profiles[profile].items() + if value is not None and key != CODEX_STARTUP_TRACE_ENV_VAR + } + ) + cook_env_extras.pop(CODEX_STARTUP_TRACE_ENV_VAR, None) + + current_resume_spec = resume_spec + current_initial_prompt = initial_prompt + max_reloads = 10 + seen_reload_ids: set[str] = set() + attempt = 0 + + from autoskillit.cli.session._session_process import run_cook_attempt + from autoskillit.cli.session._session_reload import consume_reload_sentinel + from autoskillit.execution import assert_interactive_ordering + + try: + while True: + attempt += 1 + built_spec = backend.build_interactive_cmd( + plugin_source=plugin_source, + add_dirs=[managed_home.skills_dir], + generated_home=managed_home.generated_home, + initial_prompt=current_initial_prompt, + resume_spec=current_resume_spec, + env_extras=cook_env_extras, + ) + final_cmd = built_spec.cmd + final_origin = built_spec.origin + final_env = dict(built_spec.env) + spec = replace( + built_spec, + cmd=final_cmd, + env=final_env, + cwd=str(project_dir), + origin=final_origin, + ) + assert_interactive_ordering(spec=spec) + validation_errors = backend.validate_interactive_invocation(spec) + if validation_errors: + raise RuntimeError( + "Interactive invocation validation failed: " + "; ".join(validation_errors) + ) + + with backend.cook_session_context( + session_home=managed_home.generated_home, + project_dir=project_dir, + launch_id=launch_id, + attempt=attempt, + current_resume_spec=current_resume_spec, + ) as attempt_handle: + trace.record_attempt_anchor( + attempt=attempt, + view_id=attempt_handle.view_id, + ) + observer = _startup_observer( + backend=backend, + trace=trace, + enabled=trace_enabled, + sqlite_home=managed_home.generated_home, + attempt=attempt, + view_id=attempt_handle.view_id, + ) + pass_fds = tuple( + dict.fromkeys((*managed_home.pass_fds, *attempt_handle.pass_fds)) + ) + result = run_cook_attempt( + spec, + pass_fds=pass_fds, + on_spawn=attempt_handle.record_spawn, + on_reaped=attempt_handle.record_reaped, + trace=trace, + observer=observer, + ) + reload_session_id = consume_reload_sentinel(project_dir) + _require_observer_ready(observer) + trace.require_startup_budgets() + + if reload_session_id is None: + if result.returncode != 0: + raise SystemExit(result.returncode) + if first_run and initial_prompt is not None: + from autoskillit.cli._onboarding import mark_onboarded + + mark_onboarded(project_dir) + trace.close(status="success") + return + + if len(seen_reload_ids) >= max_reloads: + raise SystemExit( + f"Too many reloads ({max_reloads} max). Check for infinite loop." + ) + if reload_session_id in seen_reload_ids: + raise SystemExit(f"Repeated reload_id {reload_session_id!r} — aborting.") + seen_reload_ids.add(reload_session_id) + current_resume_spec = NamedResume(session_id=reload_session_id) + current_initial_prompt = None + except BaseException: + trace.close(status="failed") + raise + + +def _startup_observer( + *, + backend: CodingAgentBackend, + trace: StartupTrace, + enabled: bool, + sqlite_home: Path, + attempt: int, + view_id: str, +) -> PtyObserver | None: + """Build the optional Codex PTY observer without leaking trace state to children.""" + if not enabled: + return None + from autoskillit.cli.session.pty._observer import PtyObserver + from autoskillit.core import ObserverStatus + from autoskillit.execution import CodexStateReadinessProbe + + def record_readiness(status: ObserverStatus) -> None: + if status is ObserverStatus.READY: + trace.record_stage( + "state_ready", + attempt=attempt, + view_id=view_id, + ) - if isinstance(resume_spec, BareResume): - from autoskillit.cli.session._session_picker import pick_session + return PtyObserver( + readiness_probe=CodexStateReadinessProbe( + codex_version=backend.version(), + sqlite_home=sqlite_home, + ), + on_first_output=lambda: trace.record_stage( + "first_output", + attempt=attempt, + view_id=view_id, + ), + on_hook_review=lambda: trace.record_stage( + "hook_review", + attempt=attempt, + view_id=view_id, + ), + on_readiness=record_readiness, + ) - _log_dir = backend.session_locator().project_log_dir(str(project_dir)) - selected_id = pick_session(SESSION_TYPE_COOK, project_dir, _log_dir) - if selected_id is not None: - resume_spec = NamedResume(session_id=selected_id) - else: - resume_spec = NoResume() - _cook_env_extras: dict[str, str] = { - SESSION_TYPE_ENV_VAR: SessionType.SKILL.value, - LAUNCH_ID_ENV_VAR: session_id_local, - } - if profile is not None: - _cook_env_extras[PROVIDER_PROFILE_ENV_VAR] = profile - _cook_env_extras.update( - {k: v for k, v in config.providers.profiles[profile].items() if v is not None} - ) +def _require_observer_ready(observer: PtyObserver | None) -> None: + """Fail an enabled traced launch when the guarded state probe never became ready.""" + if observer is None: + return + from autoskillit.core import ObserverStatus - current_resume_spec = resume_spec - _current_first_run = _first_run - _current_initial_prompt = initial_prompt - - _max_reloads = 10 - seen_reload_ids: set[str] = set() - from autoskillit.execution import assert_interactive_ordering - - while True: - spec = backend.build_interactive_cmd( - plugin_source=plugin_source, - add_dirs=[skills_dir], - initial_prompt=_current_initial_prompt, - resume_spec=current_resume_spec, - env_extras=_cook_env_extras, - ) - assert_interactive_ordering(spec=spec) - reload_session_id = _run_cook_session( - cmd=[*spec.cmd], - env=spec.env, - _first_run=_current_first_run, - initial_prompt=_current_initial_prompt, - project_dir=project_dir, - ) - if reload_session_id is None: - break - if len(seen_reload_ids) >= _max_reloads: - raise SystemExit(f"Too many reloads ({_max_reloads} max). Check for infinite loop.") - if reload_session_id in seen_reload_ids: - raise SystemExit(f"Repeated reload_id {reload_session_id!r} — aborting.") - seen_reload_ids.add(reload_session_id) - current_resume_spec = NamedResume(session_id=reload_session_id) - _current_first_run = False - _current_initial_prompt = None + if observer.readiness_status is not ObserverStatus.READY: + status = observer.readiness_status + status_name = "unobserved" if status is None else status.value + raise RuntimeError(f"Codex state readiness failed closed: {status_name}") diff --git a/src/autoskillit/cli/session/_session_order.py b/src/autoskillit/cli/session/_session_order.py index 70a7676aa9..acbe2accd3 100644 --- a/src/autoskillit/cli/session/_session_order.py +++ b/src/autoskillit/cli/session/_session_order.py @@ -166,8 +166,12 @@ def order( if isinstance(resume_spec, BareResume): _cfg_resume = _load_config_resume(Path.cwd()) _backend_resume = _get_backend_resume(_cfg_resume.agent_backend.backend) - _log_dir = _backend_resume.session_locator().project_log_dir(str(Path.cwd())) - _sel = _pick_session("order", Path.cwd(), _log_dir) + _backend_resume.recover_cook_history() + _sel = _pick_session( + "order", + Path.cwd(), + _backend_resume.session_locator(), + ) resume_spec = NamedResume(session_id=_sel) if _sel else NoResume() _launch_cook_session( "", diff --git a/src/autoskillit/cli/session/_session_picker.py b/src/autoskillit/cli/session/_session_picker.py index 945985c08c..4f5a88e1c7 100644 --- a/src/autoskillit/cli/session/_session_picker.py +++ b/src/autoskillit/cli/session/_session_picker.py @@ -2,9 +2,15 @@ from __future__ import annotations -import json +from collections.abc import Mapping, Sequence from pathlib import Path +from autoskillit.core import ( + AGENT_BACKEND_CLAUDE_CODE, + SessionLocator, + SessionSummary, +) + _ORDER_GREETING_PREFIXES = ( "Today's special:", "Order up! Today's special:", @@ -15,14 +21,27 @@ "Welcome to Good Burger, home of the Good Burger, can I take your order?", ) +_Registry = Mapping[str, Mapping[str, object]] -def pick_session(session_type: str, project_dir: Path, project_log_dir: Path) -> str | None: - """Show filtered picker. Returns selected Claude session UUID or None (fresh start).""" + +def pick_session( + session_type: str, + project_dir: Path, + summaries_or_locator: Sequence[SessionSummary] | SessionLocator, +) -> str | None: + """Show the filtered picker and return the selected backend session ID.""" from autoskillit.core import read_registry registry = read_registry(project_dir) - sessions = _load_sessions_index(project_log_dir) - filtered = [s for s in sessions if _classify_session(s, registry) == session_type] + if isinstance(summaries_or_locator, Sequence): + summaries = summaries_or_locator + else: + summaries = summaries_or_locator.list_sessions(str(project_dir)) + filtered = [ + summary + for summary in summaries + if not summary.is_sidechain and _classify_session(summary, registry) == session_type + ] if not filtered: print(f"No {session_type} sessions found. Starting fresh.") @@ -31,42 +50,49 @@ def pick_session(session_type: str, project_dir: Path, project_log_dir: Path) -> return _run_picker(filtered, session_type, registry) -def _load_sessions_index(project_log_dir: Path) -> list[dict]: - """Load sessions-index.json, filtering out sidechain entries.""" - index_path = project_log_dir / "sessions-index.json" - try: - entries: list[dict] = json.loads(index_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return [] - - return [e for e in entries if not e.get("isSidechain")] +def _registry_entry( + summary: SessionSummary, + registry: _Registry, +) -> Mapping[str, object] | None: + if summary.launch_id is not None: + return registry.get(summary.launch_id) + if summary.backend_name != AGENT_BACKEND_CLAUDE_CODE: + return None + for entry in registry.values(): + if entry.get("claude_session_id") == summary.session_id: + return entry + return None -def _classify_session(entry: dict, registry: dict[str, dict]) -> str: +def _classify_session(summary: SessionSummary, registry: _Registry) -> str: """Classify session as 'cook' or 'order'. - Uses registry lookup first, then greeting heuristic fallback. + Uses registry lookup first, then locator and greeting hints. """ - session_id = entry.get("sessionId", "") - for reg_entry in registry.values(): - if reg_entry.get("claude_session_id") == session_id: - return str(reg_entry.get("session_type", "cook")) + registry_entry = _registry_entry(summary, registry) + if registry_entry is not None: + return str(registry_entry.get("session_type", "cook")) + + if summary.session_type_hint is not None: + return summary.session_type_hint - first_prompt = entry.get("firstPrompt", "") for prefix in _ORDER_GREETING_PREFIXES: - if first_prompt.startswith(prefix): + if summary.first_prompt.startswith(prefix): return "order" return "cook" -def _format_session_row(entry: dict, session_type: str, registry: dict[str, dict]) -> str: +def _format_session_row( + summary: SessionSummary, + session_type: str, + registry: _Registry, +) -> str: """Format a session entry as a display row.""" recipe_name: str | None = None - session_id = entry.get("sessionId", "") - for reg_entry in registry.values(): - if reg_entry.get("claude_session_id") == session_id: - recipe_name = reg_entry.get("recipe_name") - break + registry_entry = _registry_entry(summary, registry) + if registry_entry is not None: + raw_recipe_name = registry_entry.get("recipe_name") + recipe_name = raw_recipe_name if isinstance(raw_recipe_name, str) else None if session_type == "order" and recipe_name: badge = f"[order: {recipe_name}]" @@ -75,11 +101,11 @@ def _format_session_row(entry: dict, session_type: str, registry: dict[str, dict else: badge = "[cook]" - summary = (entry.get("summary") or entry.get("firstPrompt", ""))[:60] - branch = entry.get("gitBranch", "") - modified = entry.get("modified", "") + display_summary = (summary.summary or summary.first_prompt)[:60] + branch = summary.git_branch or "" + modified = summary.modified or "" - parts = [badge, summary] + parts = [badge, display_summary] if branch: parts.append(branch) if modified: @@ -87,10 +113,14 @@ def _format_session_row(entry: dict, session_type: str, registry: dict[str, dict return " ".join(p for p in parts if p) -def _run_picker(sessions: list[dict], session_type: str, registry: dict[str, dict]) -> str | None: +def _run_picker( + sessions: Sequence[SessionSummary], + session_type: str, + registry: _Registry, +) -> str | None: """Print numbered list and prompt user for selection. - Returns sessions[n-1]["sessionId"] on valid selection, None on 0. + Returns the selected backend session ID on valid selection, None on 0. Re-prompts on invalid input (max 3 retries, then returns None). """ print(f"\nResume a {session_type} session:") @@ -121,7 +151,7 @@ def _run_picker(sessions: list[dict], session_type: str, registry: dict[str, dic if choice == 0: return None if 1 <= choice <= len(sessions): - return str(sessions[choice - 1]["sessionId"]) + return sessions[choice - 1].session_id print(f"Out of range. Enter a number between 0 and {len(sessions)}.") return None diff --git a/src/autoskillit/cli/session/_session_process.py b/src/autoskillit/cli/session/_session_process.py new file mode 100644 index 0000000000..19edd9a09c --- /dev/null +++ b/src/autoskillit/cli/session/_session_process.py @@ -0,0 +1,270 @@ +"""POSIX process ownership for one interactive cook attempt.""" + +from __future__ import annotations + +import contextlib +import os +import signal +import subprocess +import sys +import time +from collections.abc import Callable, Iterator +from dataclasses import dataclass +from pathlib import Path + +from autoskillit.cli.session._session_startup_trace import StartupTrace +from autoskillit.cli.session.pty._exec import launcher_argv +from autoskillit.cli.session.pty._observer import PtyObserver +from autoskillit.cli.ui._terminal import terminal_guard +from autoskillit.core import CmdSpec + +_TERM_TIMEOUT_SECONDS = 2.0 +_KILL_TIMEOUT_SECONDS = 2.0 +_GROUP_POLL_SECONDS = 0.02 + + +@dataclass(frozen=True, slots=True) +class CookAttemptResult: + """Proof that a spawned attempt was fully terminated and reaped.""" + + pid: int + pgid: int + returncode: int + + +def run_cook_attempt( + spec: CmdSpec, + *, + pass_fds: tuple[int, ...], + on_spawn: Callable[[int, int], None], + on_reaped: Callable[[int, int], None], + trace: StartupTrace, + observer: PtyObserver | None, +) -> CookAttemptResult: + """Run one finalized cook command and prove complete child cleanup.""" + _require_posix_process_ownership() + cwd = _canonical_cwd(spec.cwd) + inherited_fds = _normalize_pass_fds(pass_fds) + + process: subprocess.Popen[bytes] | None = None + pid: int | None = None + pgid: int | None = None + returncode: int | None = None + master_fd: int | None = None + slave_fd: int | None = None + failures: list[BaseException] = [] + + # ``trace`` is retained for the full process lifetime. Its stage callbacks + # are installed on the observer and storage callbacks by the cook owner. + _ = trace + + with terminal_guard(): + try: + if observer is None: + process = subprocess.Popen( + spec.cmd, + cwd=cwd, + env=dict(spec.env), + pass_fds=inherited_fds, + process_group=0, + start_new_session=False, + ) + else: + master_fd, slave_fd = os.openpty() + launcher_fds = tuple(sorted({*inherited_fds, slave_fd})) + process = subprocess.Popen( + launcher_argv(slave_fd, spec.cmd), + cwd=cwd, + env=dict(spec.env), + pass_fds=launcher_fds, + start_new_session=True, + ) + + pid = process.pid + pgid = pid + actual_pgid = os.getpgid(pid) + if actual_pgid != pgid: + raise RuntimeError( + f"cook child process-group mismatch: pid={pid}, pgid={actual_pgid}" + ) + on_spawn(pid, pgid) + + if observer is None: + with _foreground_process_group(pgid): + returncode = process.wait() + else: + assert master_fd is not None + assert slave_fd is not None + os.close(slave_fd) + slave_fd = None + observer.relay(master_fd) + master_fd = None + returncode = process.wait() + except BaseException as exc: + failures.append(exc) + finally: + if slave_fd is not None: + try: + os.close(slave_fd) + except BaseException as exc: + failures.append(exc) + slave_fd = None + if master_fd is not None: + try: + if observer is None: + os.close(master_fd) + else: + observer.close_master(master_fd) + except BaseException as exc: + failures.append(exc) + master_fd = None + + cleanup_proved = False + if process is not None and pid is not None and pgid is not None: + try: + returncode = _terminate_reap_and_verify(process, pgid) + cleanup_proved = True + except BaseException as exc: + failures.append(exc) + if cleanup_proved: + try: + on_reaped(pid, pgid) + except BaseException as exc: + failures.append(exc) + + if failures: + if len(failures) == 1: + raise failures[0] + raise BaseExceptionGroup("cook attempt and cleanup failed", failures) + if pid is None or pgid is None or returncode is None: + raise RuntimeError("cook attempt completed without process ownership proof") + return CookAttemptResult(pid=pid, pgid=pgid, returncode=returncode) + + +def _require_posix_process_ownership() -> None: + if ( + os.name != "posix" + or not hasattr(os, "killpg") + or not hasattr(os, "getpgid") + or not hasattr(os, "tcsetpgrp") + ): + raise RuntimeError("interactive cook requires POSIX process-group ownership") + + +def _canonical_cwd(value: str) -> str: + if not value: + raise ValueError("cook command must contain a canonical project cwd") + path = Path(value) + if not path.is_absolute(): + raise ValueError("cook command cwd must be absolute") + resolved = path.resolve(strict=True) + if not resolved.is_dir(): + raise ValueError("cook command cwd must be an existing directory") + if str(resolved) != value: + raise ValueError("cook command cwd must already be canonical") + return value + + +def _normalize_pass_fds(pass_fds: tuple[int, ...]) -> tuple[int, ...]: + normalized: set[int] = set() + for descriptor in pass_fds: + if isinstance(descriptor, bool) or not isinstance(descriptor, int) or descriptor < 0: + raise ValueError("pass_fds must contain non-negative integer descriptors") + os.fstat(descriptor) + normalized.add(descriptor) + return tuple(sorted(normalized)) + + +@contextlib.contextmanager +def _foreground_process_group(pgid: int) -> Iterator[None]: + """Temporarily transfer an inherited controlling terminal to the child.""" + try: + terminal_fd = sys.stdin.fileno() + except (OSError, TypeError, ValueError): + terminal_fd = None + if terminal_fd is None or not os.isatty(terminal_fd): + yield + return + + previous_pgid = os.tcgetpgrp(terminal_fd) + _safe_tcsetpgrp(terminal_fd, pgid) + + try: + yield + finally: + _safe_tcsetpgrp(terminal_fd, previous_pgid) + + +def _safe_tcsetpgrp(terminal_fd: int, pgid: int) -> None: + previous_mask = signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGTTOU}) + try: + os.tcsetpgrp(terminal_fd, pgid) + finally: + signal.pthread_sigmask(signal.SIG_SETMASK, previous_mask) + + +def _terminate_reap_and_verify( + process: subprocess.Popen[bytes], + pgid: int, +) -> int: + """Terminate the entire group, reap its leader, and prove group absence.""" + if _process_group_exists(pgid): + _signal_process_group(pgid, signal.SIGTERM) + if not _wait_for_group_exit(process, pgid, _TERM_TIMEOUT_SECONDS): + _signal_process_group(pgid, signal.SIGKILL) + if not _wait_for_group_exit(process, pgid, _KILL_TIMEOUT_SECONDS): + raise RuntimeError(f"cook process group {pgid} survived SIGKILL") + + if process.returncode is None: + try: + process.wait(timeout=_KILL_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + _signal_process_group(pgid, signal.SIGKILL) + try: + process.wait(timeout=_KILL_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired as exc: + raise RuntimeError(f"cook direct child {process.pid} was not reaped") from exc + + if _process_group_exists(pgid): + _signal_process_group(pgid, signal.SIGKILL) + if not _wait_for_group_exit(process, pgid, _KILL_TIMEOUT_SECONDS): + raise RuntimeError(f"cook process group {pgid} is not empty after reap") + + returncode = process.returncode + if returncode is None: # pragma: no cover - guarded by wait above + raise RuntimeError(f"cook direct child {process.pid} has no return code") + return returncode + + +def _wait_for_group_exit( + process: subprocess.Popen[bytes], + pgid: int, + timeout_seconds: float, +) -> bool: + deadline = time.monotonic() + timeout_seconds + while True: + process.poll() + if not _process_group_exists(pgid): + return True + remaining = deadline - time.monotonic() + if remaining <= 0: + return False + time.sleep(min(_GROUP_POLL_SECONDS, remaining)) + + +def _process_group_exists(pgid: int) -> bool: + try: + os.killpg(pgid, 0) + except ProcessLookupError: + return False + return True + + +def _signal_process_group(pgid: int, signum: signal.Signals) -> None: + try: + os.killpg(pgid, signum) + except ProcessLookupError: + return + + +__all__ = ["run_cook_attempt"] diff --git a/src/autoskillit/cli/session/_session_startup_trace.py b/src/autoskillit/cli/session/_session_startup_trace.py new file mode 100644 index 0000000000..184a2888a5 --- /dev/null +++ b/src/autoskillit/cli/session/_session_startup_trace.py @@ -0,0 +1,323 @@ +"""Bounded, durable observability records for Codex cook startup.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import threading +import time +from collections.abc import Callable, Mapping +from pathlib import Path +from typing import Any + +from autoskillit.core import default_log_dir + +_SCHEMA_VERSION = 1 +_RECORD_LIMIT_BYTES = 16 * 1024 +_LAUNCH_ID_RE = re.compile(r"[0-9a-f]{16}\Z") +_BUDGETS_SECONDS = { + "confirmation_to_spawn": 5.0, + "spawn_to_hook_review": 12.0, + "total_startup": 17.0, +} + + +class _MandatoryRecordOverflow(Exception): + """A trace record's required fields cannot fit in the schema limit.""" + + +def startup_trace_path(project_dir: Path, launch_id: str) -> Path: + """Return the sole canonical trace path for a project launch.""" + if _LAUNCH_ID_RE.fullmatch(launch_id) is None: + raise ValueError("launch_id must be exactly 16 lowercase hexadecimal characters") + + resolved_project = Path(project_dir).resolve(strict=True) + project_key = hashlib.sha256(os.fsencode(str(resolved_project))).hexdigest()[:16] + trace_root = default_log_dir() / "codex-startup" + trace_parent = trace_root / project_key + + resolved_root = trace_root.resolve(strict=False) + resolved_parent = trace_parent.resolve(strict=False) + if not resolved_parent.is_relative_to(resolved_root): + raise ValueError("startup trace parent escapes the canonical trace root") + + return trace_parent / f"{launch_id}.jsonl" + + +def _json_line(record: Mapping[str, object]) -> bytes: + serialized = json.dumps( + record, + allow_nan=False, + ensure_ascii=False, + separators=(",", ":"), + ) + return serialized.encode("utf-8") + b"\n" + + +def _truncate_utf8(value: str, limit: int) -> str: + raw = value.encode("utf-8") + if len(raw) <= limit: + return value + return raw[:limit].decode("utf-8", errors="ignore") + + +def _truncate_strings(value: Any, limit: int) -> Any: + """Return JSON-shaped diagnostics with every string byte-bounded.""" + if isinstance(value, str): + return _truncate_utf8(value, limit) + if isinstance(value, list): + return [_truncate_strings(item, limit) for item in value] + if isinstance(value, dict): + return {key: _truncate_strings(item, limit) for key, item in value.items()} + return value + + +def _serialize_bounded(record: dict[str, object]) -> bytes: + """Serialize one record, truncating only its optional diagnostics.""" + raw = _json_line(record) + if len(raw) <= _RECORD_LIMIT_BYTES: + return raw + + if "diagnostics" not in record: + raise _MandatoryRecordOverflow + + mandatory = dict(record) + diagnostics = mandatory.pop("diagnostics") + mandatory_raw = _json_line(mandatory) + if len(mandatory_raw) > _RECORD_LIMIT_BYTES: + raise _MandatoryRecordOverflow + + # Preserve the diagnostic shape and as much UTF-8 string content as possible. + # A uniform per-string bound also handles nested diagnostic payloads without + # ever truncating required schema fields. + low = 0 + high = _RECORD_LIMIT_BYTES + best: bytes | None = None + while low <= high: + midpoint = (low + high) // 2 + candidate = dict(mandatory) + candidate["diagnostics"] = _truncate_strings(diagnostics, midpoint) + candidate_raw = _json_line(candidate) + if len(candidate_raw) <= _RECORD_LIMIT_BYTES: + best = candidate_raw + low = midpoint + 1 + else: + high = midpoint - 1 + + if best is not None: + return best + + # Collection structure or diagnostic keys alone may be oversized. In that + # case the optional payload is discarded rather than weakening the cap. + marker = dict(mandatory) + marker["diagnostics"] = {"truncated": True} + marker_raw = _json_line(marker) + if len(marker_raw) <= _RECORD_LIMIT_BYTES: + return marker_raw + return mandatory_raw + + +class StartupTrace: + """Append-only startup trace with monotonic anchors and hard budgets.""" + + def __init__( + self, + project_dir: Path, + launch_id: str, + *, + enabled: bool, + clock: Callable[[], float] | None = None, + ) -> None: + self.path = startup_trace_path(project_dir, launch_id) + self.enabled = enabled + self._project_dir = Path(project_dir) + self._launch_id = launch_id + self._clock = clock if clock is not None else time.monotonic + self._lock = threading.RLock() + self._launch_at: float | None = None + self._spawn_at: float | None = None + self._hook_review_at: float | None = None + self._terminal_status: str | None = None + + def record_launch_anchor(self, *, diagnostics: Mapping[str, object] | None = None) -> None: + """Record the post-confirmation launch anchor.""" + with self._lock: + self._ensure_open() + if not self.enabled: + return + recorded_at = self._now() + self._launch_at = recorded_at + self._append_record(self._record("launch", recorded_at, diagnostics=diagnostics)) + + def record_attempt_anchor( + self, + *, + attempt: int, + view_id: str, + diagnostics: Mapping[str, object] | None = None, + ) -> None: + """Record the anchor and bounded diagnostics for one launch attempt.""" + with self._lock: + self._ensure_open() + if not self.enabled: + return + recorded_at = self._now() + record = self._record("attempt", recorded_at, diagnostics=diagnostics) + record.update(attempt=attempt, view_id=view_id) + self._append_record(record) + + def record_stage( + self, + stage: str, + *, + attempt: int, + view_id: str, + diagnostics: Mapping[str, object] | None = None, + ) -> None: + """Record a real startup stage anchor.""" + with self._lock: + self._ensure_open() + if not self.enabled: + return + recorded_at = self._now() + record = self._record("stage", recorded_at, diagnostics=diagnostics) + record.update(stage=stage, attempt=attempt, view_id=view_id) + self._append_record(record) + if stage == "spawn": + self._spawn_at = recorded_at + elif stage == "hook_review": + self._hook_review_at = recorded_at + + def close( + self, + *, + status: str, + diagnostics: Mapping[str, object] | None = None, + ) -> None: + """Append exactly one terminal summary.""" + with self._lock: + if self._terminal_status is not None: + if status == self._terminal_status: + return + raise RuntimeError( + f"startup trace is already closed with terminal status " + f"{self._terminal_status!r}" + ) + if not self.enabled: + self._terminal_status = status + return + + recorded_at = self._now() + self._append_record(self._summary_record(status, recorded_at, diagnostics=diagnostics)) + self._terminal_status = status + + def _record( + self, + record_type: str, + recorded_at: float, + *, + diagnostics: Mapping[str, object] | None = None, + ) -> dict[str, object]: + record: dict[str, object] = { + "schema_version": _SCHEMA_VERSION, + "record_type": record_type, + "launch_id": self._launch_id, + "monotonic_seconds": recorded_at, + } + if diagnostics is not None: + record["diagnostics"] = dict(diagnostics) + return record + + def _summary_record( + self, + status: str, + recorded_at: float, + *, + diagnostics: Mapping[str, object] | None = None, + ) -> dict[str, object]: + confirmation_to_spawn = self._duration(self._launch_at, self._spawn_at) + spawn_to_hook_review = self._duration(self._spawn_at, self._hook_review_at) + total_startup = self._duration(self._launch_at, self._hook_review_at) + durations = { + "confirmation_to_spawn": confirmation_to_spawn, + "spawn_to_hook_review": spawn_to_hook_review, + "total_startup": total_startup, + } + exceeded: list[str] = [] + for name, budget in _BUDGETS_SECONDS.items(): + duration = durations[name] + if duration is not None and duration > budget: + exceeded.append(name) + record = self._record("summary", recorded_at, diagnostics=diagnostics) + record.update( + status=status, + durations_seconds=durations, + budgets_seconds=dict(_BUDGETS_SECONDS), + budget_exceeded=exceeded, + budgets_passed=not exceeded, + ) + return record + + @staticmethod + def _duration(start: float | None, end: float | None) -> float | None: + if start is None or end is None: + return None + return end - start + + def _now(self) -> float: + return float(self._clock()) + + def _ensure_open(self) -> None: + if self._terminal_status is not None: + raise RuntimeError( + f"startup trace is closed with terminal status {self._terminal_status!r}" + ) + + def _append_record(self, record: dict[str, object]) -> None: + try: + raw = _serialize_bounded(record) + except _MandatoryRecordOverflow as exc: + self._close_for_overflow() + raise RuntimeError("mandatory startup trace record exceeds the 16 KiB limit") from exc + self._append_bytes(raw) + + def _close_for_overflow(self) -> None: + if self._terminal_status is not None: + return + summary = self._summary_record("trace_record_overflow", self._now()) + try: + raw = _serialize_bounded(summary) + except _MandatoryRecordOverflow as exc: # pragma: no cover - fixed schema + raise RuntimeError("terminal startup trace summary exceeds 16 KiB") from exc + self._append_bytes(raw) + self._terminal_status = "trace_record_overflow" + + def _append_bytes(self, raw: bytes) -> None: + if len(raw) > _RECORD_LIMIT_BYTES: + raise AssertionError("bounded trace serializer exceeded its record limit") + + # Re-resolve after directory creation so an existing parent symlink cannot + # redirect this append outside the canonical project-keyed trace root. + self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + current_path = startup_trace_path(self._project_dir, self._launch_id) + if current_path != self.path: + raise OSError("canonical startup trace path changed during launch") + + no_follow = getattr(os, "O_NOFOLLOW", None) + if no_follow is None: # pragma: no cover - the cook path is POSIX-only + raise OSError("startup tracing requires O_NOFOLLOW support") + flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND | no_follow + flags |= getattr(os, "O_CLOEXEC", 0) + descriptor = os.open(self.path, flags, 0o600) + try: + view = memoryview(raw) + while view: + written = os.write(descriptor, view) + if written == 0: + raise OSError("short write while appending startup trace") + view = view[written:] + os.fsync(descriptor) + finally: + os.close(descriptor) diff --git a/src/autoskillit/cli/session/pty/AGENTS.md b/src/autoskillit/cli/session/pty/AGENTS.md new file mode 100644 index 0000000000..da1c8fe42d --- /dev/null +++ b/src/autoskillit/cli/session/pty/AGENTS.md @@ -0,0 +1,19 @@ +# session/pty/ + +Private POSIX PTY support for interactive Codex cook attempts. + +## Files + +| File | Purpose | +|------|---------| +| `__init__.py` | Private package marker with no gateway exports | +| `_observer.py` | Transparent PTY relay, bounded semantic observation, and guarded Codex state-readiness probing | +| `_exec.py` | Minimal exec-side controlling-terminal attachment and immediate target exec | + +## Architecture Notes + +The parent process owner exclusively manages process groups and foreground +terminal ownership. The observer manages PTY I/O, window propagation, raw-mode +entry, signal-handler restoration, and master-descriptor closure. The exec-side +launcher only attaches its inherited slave descriptor, duplicates standard +streams, and replaces itself with the requested command. diff --git a/src/autoskillit/cli/session/pty/CLAUDE.md b/src/autoskillit/cli/session/pty/CLAUDE.md new file mode 100644 index 0000000000..43c994c2d3 --- /dev/null +++ b/src/autoskillit/cli/session/pty/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/src/autoskillit/cli/session/pty/__init__.py b/src/autoskillit/cli/session/pty/__init__.py new file mode 100644 index 0000000000..76a6f96e9b --- /dev/null +++ b/src/autoskillit/cli/session/pty/__init__.py @@ -0,0 +1,7 @@ +"""Private PTY support for interactive cook attempts. + +Import concrete helpers from their defining modules; this package intentionally +has no gateway exports. +""" + +__all__: tuple[str, ...] = () diff --git a/src/autoskillit/cli/session/pty/_exec.py b/src/autoskillit/cli/session/pty/_exec.py new file mode 100644 index 0000000000..ee3228025a --- /dev/null +++ b/src/autoskillit/cli/session/pty/_exec.py @@ -0,0 +1,118 @@ +"""Minimal POSIX exec-side launcher for an already-created PTY session.""" + +from __future__ import annotations + +import fcntl +import os +import sys +import termios +from collections.abc import Mapping, Sequence +from typing import NoReturn + +_MODULE_NAME = "autoskillit.cli.session.pty._exec" + + +def launcher_argv(slave_fd: int, command: Sequence[str]) -> tuple[str, ...]: + """Build the interpreter command used by the parent-side process owner.""" + _validate_slave_fd(slave_fd) + normalized = _validate_command(command) + return (sys.executable, "-m", _MODULE_NAME, str(slave_fd), "--", *normalized) + + +def exec_in_pty( + slave_fd: int, + command: Sequence[str], + environment: Mapping[str, str], + *, + lease_fds: Sequence[int] = (), +) -> NoReturn: + """Attach the slave as controlling terminal, duplicate stdio, and exec.""" + _validate_slave_fd(slave_fd) + normalized_command = _validate_command(command) + normalized_environment = _validate_environment(environment) + normalized_leases = _validate_lease_fds(lease_fds, slave_fd=slave_fd) + + tiocsctty = getattr(termios, "TIOCSCTTY", None) + if tiocsctty is None: + raise RuntimeError("This platform does not provide TIOCSCTTY") + fcntl.ioctl(slave_fd, tiocsctty, 0) + for standard_fd in (0, 1, 2): + if slave_fd != standard_fd: + os.dup2(slave_fd, standard_fd, inheritable=True) + else: + os.set_inheritable(standard_fd, True) + if slave_fd > 2 and slave_fd not in normalized_leases: + os.close(slave_fd) + for lease_fd in normalized_leases: + os.set_inheritable(lease_fd, True) + os.execvpe( + normalized_command[0], + list(normalized_command), + normalized_environment, + ) + raise AssertionError("os.execvpe unexpectedly returned") + + +def main(argv: Sequence[str] | None = None) -> NoReturn: + """Parse the deliberately small launcher protocol and exec the target.""" + arguments = tuple(sys.argv[1:] if argv is None else argv) + if len(arguments) < 3 or arguments[1] != "--": + raise SystemExit("usage: _exec SLAVE_FD -- COMMAND [ARG ...]") + try: + slave_fd = int(arguments[0], 10) + except ValueError as exc: + raise SystemExit("SLAVE_FD must be a decimal integer") from exc + exec_in_pty(slave_fd, arguments[2:], os.environ) + + +def _validate_slave_fd(slave_fd: int) -> None: + if isinstance(slave_fd, bool) or not isinstance(slave_fd, int) or slave_fd < 0: + raise ValueError("slave_fd must be a non-negative integer") + os.fstat(slave_fd) + + +def _validate_command(command: Sequence[str]) -> tuple[str, ...]: + normalized = tuple(command) + if not normalized or any( + not isinstance(item, str) or not item or "\0" in item for item in normalized + ): + raise ValueError("command must contain non-empty NUL-free strings") + return normalized + + +def _validate_environment(environment: Mapping[str, str]) -> dict[str, str]: + normalized: dict[str, str] = {} + for key, value in environment.items(): + if ( + not isinstance(key, str) + or not key + or "=" in key + or "\0" in key + or not isinstance(value, str) + or "\0" in value + ): + raise ValueError("environment must contain valid string keys and values") + normalized[key] = value + return normalized + + +def _validate_lease_fds( + lease_fds: Sequence[int], + *, + slave_fd: int, +) -> frozenset[int]: + normalized: set[int] = set() + for fd in lease_fds: + if isinstance(fd, bool) or not isinstance(fd, int) or fd < 3: + raise ValueError("lease descriptors must be integers greater than two") + os.fstat(fd) + if fd != slave_fd: + normalized.add(fd) + return frozenset(normalized) + + +if __name__ == "__main__": + main() + + +__all__ = ["exec_in_pty", "launcher_argv", "main"] diff --git a/src/autoskillit/cli/session/pty/_observer.py b/src/autoskillit/cli/session/pty/_observer.py new file mode 100644 index 0000000000..2b85352e92 --- /dev/null +++ b/src/autoskillit/cli/session/pty/_observer.py @@ -0,0 +1,325 @@ +"""Byte-transparent PTY observation and Codex startup-readiness probing.""" + +from __future__ import annotations + +import contextlib +import errno +import fcntl +import math +import os +import re +import selectors +import signal +import sqlite3 +import stat +import termios +import time +import tty +from collections.abc import Callable +from dataclasses import dataclass, field +from enum import StrEnum +from pathlib import Path +from types import FrameType +from typing import Any + +_WINDOW_LIMIT = 64 * 1024 +_RELAY_CHUNK_SIZE = 64 * 1024 +_RELAY_SELECT_SECONDS = 0.05 +_SUPPORTED_STATE_DATABASES = {"codex-cli 0.145.0": "state_5.sqlite"} +_ANSI_ESCAPE_RE = re.compile( + r""" + \x1b + (?: + \][^\x07]*(?:\x07|\x1b\\) + | + [@-_][0-?]*[ -/]*[@-~] + ) + """, + re.VERBOSE, +) +_HOOK_REVIEW_PATTERNS = ( + re.compile(r"\bhooks?\s+(?:need|needs|require|requires)\s+review\b", re.IGNORECASE), + re.compile(r"\breview\b.{0,80}\bhooks?\b", re.IGNORECASE | re.DOTALL), + re.compile(r"\b(?:approve|allow)\b.{0,80}\bhooks?\b", re.IGNORECASE | re.DOTALL), +) + + +class ObserverStatus(StrEnum): + """Typed outcomes from the guarded Codex state-readiness adapter.""" + + READY = "ready" + ABSENT = "absent" + LOCKED = "locked" + CORRUPT = "corrupt" + INCOMPLETE = "incomplete" + SCHEMA_CHANGED = "schema_changed" + UNSUPPORTED_VERSION = "unsupported_version" + TIMEOUT = "timeout" + CANCELLED = "cancelled" + + +@dataclass(frozen=True, slots=True) +class CodexStateReadinessProbe: + """Read the version-mapped disposable Codex state database without mutation.""" + + codex_version: str + sqlite_home: Path + poll_interval_seconds: float = 0.05 + _clock: Callable[[], float] = field(default=time.monotonic, repr=False) + _sleep: Callable[[float], None] = field(default=time.sleep, repr=False) + + def __post_init__(self) -> None: + if not math.isfinite(self.poll_interval_seconds) or self.poll_interval_seconds <= 0: + raise ValueError("poll_interval_seconds must be finite and positive") + object.__setattr__(self, "sqlite_home", Path(self.sqlite_home)) + + @property + def database_path(self) -> Path | None: + """Return the exact database path for a supported Codex version.""" + filename = _SUPPORTED_STATE_DATABASES.get(self.codex_version) + return None if filename is None else self.sqlite_home / filename + + def check(self) -> ObserverStatus: + """Perform one zero-wait, read-only readiness observation.""" + database_path = self.database_path + if database_path is None: + return ObserverStatus.UNSUPPORTED_VERSION + try: + path_stat = database_path.lstat() + except FileNotFoundError: + return ObserverStatus.ABSENT + except OSError: + return ObserverStatus.CORRUPT + if not stat.S_ISREG(path_stat.st_mode): + return ObserverStatus.CORRUPT + + connection: sqlite3.Connection | None = None + try: + uri = f"{database_path.resolve(strict=True).as_uri()}?mode=ro" + connection = sqlite3.connect( + uri, + uri=True, + timeout=0.0, + isolation_level=None, + ) + connection.execute("PRAGMA query_only = ON") + connection.execute("PRAGMA busy_timeout = 0") + columns = { + row[1] + for row in connection.execute("PRAGMA table_info(backfill_state)") + if len(row) > 1 and isinstance(row[1], str) + } + if not {"id", "status"}.issubset(columns): + return ObserverStatus.SCHEMA_CHANGED + row = connection.execute("SELECT status FROM backfill_state WHERE id = 1").fetchone() + if row is None or len(row) != 1 or not isinstance(row[0], str): + return ObserverStatus.INCOMPLETE + return ObserverStatus.READY if row[0] == "complete" else ObserverStatus.INCOMPLETE + except sqlite3.OperationalError as exc: + message = str(exc).lower() + if "locked" in message or "busy" in message: + return ObserverStatus.LOCKED + if "no such table" in message or "no such column" in message: + return ObserverStatus.SCHEMA_CHANGED + return ObserverStatus.CORRUPT + except (OSError, sqlite3.DatabaseError, ValueError): + return ObserverStatus.CORRUPT + finally: + if connection is not None: + connection.close() + + def wait( + self, + *, + timeout_seconds: float, + cancelled: Callable[[], bool] | None = None, + ) -> ObserverStatus: + """Poll until ready, a terminal adapter failure, timeout, or cancellation.""" + if not math.isfinite(timeout_seconds) or timeout_seconds < 0: + raise ValueError("timeout_seconds must be finite and non-negative") + is_cancelled = cancelled or (lambda: False) + deadline = self._clock() + timeout_seconds + while True: + if is_cancelled(): + return ObserverStatus.CANCELLED + if self._clock() >= deadline: + return ObserverStatus.TIMEOUT + status = self.check() + if status is ObserverStatus.READY: + return status + if status in { + ObserverStatus.CORRUPT, + ObserverStatus.SCHEMA_CHANGED, + ObserverStatus.UNSUPPORTED_VERSION, + }: + return status + remaining = deadline - self._clock() + if remaining <= 0: + return ObserverStatus.TIMEOUT + self._sleep(min(self.poll_interval_seconds, remaining)) + + +@dataclass(slots=True) +class PtyObserver: + """Own PTY I/O observation while leaving process-group policy to its caller.""" + + readiness_probe: CodexStateReadinessProbe | None + on_first_output: Callable[[], None] | None = None + on_hook_review: Callable[[], None] | None = None + on_readiness: Callable[[ObserverStatus], None] | None = None + first_output_seen: bool = field(default=False, init=False) + hook_review_seen: bool = field(default=False, init=False) + readiness_status: ObserverStatus | None = field(default=None, init=False) + retained_output: bytes = field(default=b"", init=False) + normalized_window: str = field(default="", init=False) + _closed_fds: set[int] = field(default_factory=set, init=False, repr=False) + + def observe_output(self, chunk: bytes) -> bytes: + """Observe a master-side chunk and return its original bytes unchanged.""" + if not isinstance(chunk, bytes): + raise TypeError("PTY output chunks must be bytes") + if not chunk: + return chunk + if not self.first_output_seen: + self.first_output_seen = True + if self.on_first_output is not None: + self.on_first_output() + + self.retained_output = (self.retained_output + chunk)[-_WINDOW_LIMIT:] + decoded = self.retained_output.decode("utf-8", errors="replace") + normalized = _ANSI_ESCAPE_RE.sub("", decoded) + normalized = normalized.replace("\r\n", "\n").replace("\r", "\n") + normalized_bytes = normalized.encode("utf-8")[-_WINDOW_LIMIT:] + self.normalized_window = normalized_bytes.decode("utf-8", errors="ignore") + + if not self.hook_review_seen and any( + pattern.search(self.normalized_window) for pattern in _HOOK_REVIEW_PATTERNS + ): + self.hook_review_seen = True + if self.on_hook_review is not None: + self.on_hook_review() + return chunk + + def check_readiness(self) -> ObserverStatus | None: + """Observe readiness once and emit each changed status at most once.""" + if self.readiness_probe is None: + return None + status = self.readiness_probe.check() + if status is not self.readiness_status: + self.readiness_status = status + if self.on_readiness is not None: + self.on_readiness(status) + return status + + def wait_for_readiness( + self, + *, + timeout_seconds: float, + cancelled: Callable[[], bool] | None = None, + ) -> ObserverStatus | None: + """Delegate a bounded readiness wait and publish its terminal status.""" + if self.readiness_probe is None: + return None + status = self.readiness_probe.wait( + timeout_seconds=timeout_seconds, + cancelled=cancelled, + ) + if status is not self.readiness_status: + self.readiness_status = status + if self.on_readiness is not None: + self.on_readiness(status) + return status + + def relay( + self, + master_fd: int, + *, + stdin_fd: int = 0, + stdout_fd: int = 1, + cancelled: Callable[[], bool] | None = None, + ) -> None: + """Relay stdin/master traffic until master EOF, closing the master once.""" + if master_fd < 0 or stdin_fd < 0 or stdout_fd < 0: + raise ValueError("PTY relay descriptors must be non-negative") + is_cancelled = cancelled or (lambda: False) + selector = selectors.DefaultSelector() + previous_winch: Callable[[int, FrameType | None], Any] | int | None = None + installed_winch = False + + def copy_window_size() -> None: + if not os.isatty(stdin_fd): + return + try: + size = fcntl.ioctl(stdin_fd, termios.TIOCGWINSZ, b"\0" * 8) + fcntl.ioctl(master_fd, termios.TIOCSWINSZ, size) + except OSError: + return + + def handle_winch(_signum: int, _frame: object) -> None: + copy_window_size() + + try: + if os.isatty(stdin_fd): + tty.setraw(stdin_fd) + copy_window_size() + try: + previous_winch = signal.getsignal(signal.SIGWINCH) + signal.signal(signal.SIGWINCH, handle_winch) # noqa: TID251 + installed_winch = True + except ValueError: + previous_winch = None + selector.register(master_fd, selectors.EVENT_READ, "master") + try: + selector.register(stdin_fd, selectors.EVENT_READ, "stdin") + except OSError: + pass + + while not is_cancelled(): + self.check_readiness() + for key, _events in selector.select(_RELAY_SELECT_SECONDS): + source_fd = int(key.fd) + try: + chunk = os.read(source_fd, _RELAY_CHUNK_SIZE) + except OSError as exc: + if key.data == "master" and exc.errno == errno.EIO: + return + if exc.errno in {errno.EAGAIN, errno.EINTR}: + continue + raise + if not chunk: + if key.data == "master": + return + with contextlib.suppress(Exception): + selector.unregister(stdin_fd) + continue + if key.data == "master": + self._write_all(stdout_fd, self.observe_output(chunk)) + else: + self._write_all(master_fd, chunk) + finally: + if installed_winch and previous_winch is not None: + signal.signal(signal.SIGWINCH, previous_winch) # noqa: TID251 + selector.close() + self.close_master(master_fd) + + def close_master(self, master_fd: int) -> None: + """Close a PTY master descriptor exactly once.""" + if master_fd in self._closed_fds: + return + self._closed_fds.add(master_fd) + os.close(master_fd) + + @staticmethod + def _write_all(fd: int, payload: bytes) -> None: + view = memoryview(payload) + while view: + try: + written = os.write(fd, view) + except InterruptedError: + continue + if written <= 0: + raise OSError("PTY relay write made no progress") + view = view[written:] + + +__all__ = ["CodexStateReadinessProbe", "ObserverStatus", "PtyObserver"] diff --git a/src/autoskillit/core/__init__.pyi b/src/autoskillit/core/__init__.pyi index 5287db3b41..119c3c05bf 100644 --- a/src/autoskillit/core/__init__.pyi +++ b/src/autoskillit/core/__init__.pyi @@ -173,7 +173,10 @@ from .types import CATEGORY_TAGS as CATEGORY_TAGS from .types import CLAUDE_CODE_CAPABILITIES as CLAUDE_CODE_CAPABILITIES from .types import CLAUDE_MODEL_ALIASES as CLAUDE_MODEL_ALIASES from .types import CLOSURE_REPORT_SCHEMA_VERSION as CLOSURE_REPORT_SCHEMA_VERSION +from .types import CODEX_ACTIVE_VIEWS_SUBDIR as CODEX_ACTIVE_VIEWS_SUBDIR +from .types import CODEX_ARCHIVED_SESSIONS_SUBDIR as CODEX_ARCHIVED_SESSIONS_SUBDIR from .types import CODEX_CONTEXT_EXHAUSTION_MARKER as CODEX_CONTEXT_EXHAUSTION_MARKER +from .types import CODEX_COOK_RESERVED_ENV_VARS as CODEX_COOK_RESERVED_ENV_VARS from .types import CODEX_EFFORT_MAPPING as CODEX_EFFORT_MAPPING from .types import CODEX_INTAKE_DISCIPLINE_DIGEST as CODEX_INTAKE_DISCIPLINE_DIGEST from .types import CODEX_INTAKE_DISCIPLINE_VERSION as CODEX_INTAKE_DISCIPLINE_VERSION @@ -183,6 +186,7 @@ from .types import CODEX_MODEL_ALIASES as CODEX_MODEL_ALIASES from .types import CODEX_MODEL_ALIASES_LAST_VERIFIED as CODEX_MODEL_ALIASES_LAST_VERIFIED from .types import CODEX_SCHEMA_VERSION as CODEX_SCHEMA_VERSION from .types import CODEX_SESSIONS_SUBDIR as CODEX_SESSIONS_SUBDIR +from .types import CODEX_STARTUP_TRACE_ENV_VAR as CODEX_STARTUP_TRACE_ENV_VAR from .types import CODEX_VALID_MODEL_IDS as CODEX_VALID_MODEL_IDS from .types import CONFIG_AUTHORITY_KEYS as CONFIG_AUTHORITY_KEYS from .types import CONTEXT_ADMISSION_COVERAGE as CONTEXT_ADMISSION_COVERAGE @@ -381,6 +385,7 @@ from .types import ContextLineage as ContextLineage from .types import ContextSessionId as ContextSessionId from .types import ContextThreadId as ContextThreadId from .types import ContextWindowSnapshot as ContextWindowSnapshot +from .types import CookSessionHandle as CookSessionHandle from .types import CoverageEvidence as CoverageEvidence from .types import CoverageEvidenceKind as CoverageEvidenceKind from .types import CoverageState as CoverageState @@ -421,6 +426,7 @@ from .types import GitHubFetcher as GitHubFetcher from .types import HardCapabilityMismatch as HardCapabilityMismatch from .types import HeadlessExecutor as HeadlessExecutor from .types import HeadlessSkillDispatchContract as HeadlessSkillDispatchContract +from .types import HookTrustPolicy as HookTrustPolicy from .types import IdempotencyExpiredEffect as IdempotencyExpiredEffect from .types import IdempotencyNamespace as IdempotencyNamespace from .types import IdempotencyRecord as IdempotencyRecord @@ -439,6 +445,8 @@ from .types import LabelDef as LabelDef from .types import LensEntry as LensEntry from .types import LoadReport as LoadReport from .types import LoadResult as LoadResult +from .types import ManagedSessionHome as ManagedSessionHome +from .types import MarketplaceInstall as MarketplaceInstall from .types import MarkGenerationIndeterminateEvent as MarkGenerationIndeterminateEvent from .types import MarkIndeterminateEvent as MarkIndeterminateEvent from .types import McpResponseLog as McpResponseLog @@ -537,6 +545,7 @@ from .types import SessionEvent as SessionEvent from .types import SessionLocator as SessionLocator from .types import SessionOutcome as SessionOutcome from .types import SessionSkillManager as SessionSkillManager +from .types import SessionSummary as SessionSummary from .types import SessionTelemetry as SessionTelemetry from .types import SessionType as SessionType from .types import Severity as Severity diff --git a/src/autoskillit/core/types/_type_backend.py b/src/autoskillit/core/types/_type_backend.py index 976bce3176..ffea3d312e 100644 --- a/src/autoskillit/core/types/_type_backend.py +++ b/src/autoskillit/core/types/_type_backend.py @@ -3,14 +3,14 @@ from __future__ import annotations import re as _re -from collections.abc import Mapping +from collections.abc import Callable, Mapping from dataclasses import dataclass, field from pathlib import Path from types import MappingProxyType from typing import Any from ._type_checkpoint import SessionCheckpoint -from ._type_enums import BackendEventKind, OutputFormat +from ._type_enums import BackendEventKind, HookTrustPolicy, OutputFormat from ._type_plugin_source import PluginSource from ._type_recipe_delivery import RecipeDeliveryBudgetDef from ._type_results import ValidatedAddDir @@ -27,7 +27,9 @@ "CODEX_VALID_MODEL_IDS", "CmdOrigin", "CmdSpec", + "CookSessionHandle", "ModelTranslation", + "SessionSummary", "SkillSessionConfig", "ClaudeEventData", "CodexEventData", @@ -183,6 +185,9 @@ class BackendCapabilities: # None means the backend has no version-pinned recipe-delivery contract; # protected delivery must then fail closed even if capability data drifts. recipe_delivery_budget: RecipeDeliveryBudgetDef | None = None + # Interactive hook trust behavior. Automated builders retain their explicit + # bypass policy; interactive launchers translate this policy into CLI flags. + hook_trust_policy: HookTrustPolicy = HookTrustPolicy.AUTOMATED ALL_PROJECT_LOCAL_SKILL_SEARCH_DIRS: tuple[str, ...] = ( @@ -314,9 +319,42 @@ def model_class(model: str) -> str: unnegotiated_tool_result_token_limit=46_500, protected_recipe_delivery_capable=False, recipe_delivery_budget=None, + hook_trust_policy=HookTrustPolicy.AUTOMATED, ) +@dataclass(frozen=True, slots=True) +class SessionSummary: + """Backend-neutral summary of a resumable coding-agent session.""" + + backend_name: str + session_id: str + launch_id: str | None + cwd: str + first_prompt: str + summary: str + git_branch: str | None + modified: str | None + is_sidechain: bool + session_type_hint: str | None + + +@dataclass(frozen=True, slots=True) +class CookSessionHandle: + """Ownership handle for one durable interactive-cook attempt.""" + + view_id: str + pass_fds: tuple[int, ...] + _record_spawn: Callable[[int, int], None] = field(repr=False, compare=False) + _record_reaped: Callable[[int, int], None] = field(repr=False, compare=False) + + def record_spawn(self, pid: int, pgid: int) -> None: + self._record_spawn(pid, pgid) + + def record_reaped(self, pid: int, pgid: int) -> None: + self._record_reaped(pid, pgid) + + @dataclass(frozen=True, slots=True) class CmdOrigin: """Provenance metadata for a CmdSpec, capturing the structural role of each element.""" diff --git a/src/autoskillit/core/types/_type_constants.py b/src/autoskillit/core/types/_type_constants.py index 48502e7128..1b85daca93 100644 --- a/src/autoskillit/core/types/_type_constants.py +++ b/src/autoskillit/core/types/_type_constants.py @@ -54,6 +54,8 @@ "CAPABILITY_GATE_CALLABLES", "SkillFamilyDef", "GITHUB_API_SKILL_FAMILIES", + "CODEX_ACTIVE_VIEWS_SUBDIR", + "CODEX_ARCHIVED_SESSIONS_SUBDIR", "CODEX_SESSIONS_SUBDIR", ] @@ -527,3 +529,5 @@ class SkillFamilyDef(NamedTuple): QUOTA_POST_BUDGET_EXCEEDED_TRIGGER: str = "QUOTA BUDGET EXCEEDED" CODEX_SESSIONS_SUBDIR: str = "codex-sessions" +CODEX_ARCHIVED_SESSIONS_SUBDIR: str = "codex-archived-sessions" +CODEX_ACTIVE_VIEWS_SUBDIR: str = "codex-active-sessions" diff --git a/src/autoskillit/core/types/_type_constants_env.py b/src/autoskillit/core/types/_type_constants_env.py index 7fac8cb538..1964b97549 100644 --- a/src/autoskillit/core/types/_type_constants_env.py +++ b/src/autoskillit/core/types/_type_constants_env.py @@ -40,7 +40,9 @@ "ORCHESTRATOR_SESSION_REQUIRED_ENV", "ORDER_INTERACTIVE_REQUIRED_ENV", "RESUME_SESSION_BASELINE_KEYS", + "CODEX_COOK_RESERVED_ENV_VARS", "CODEX_INTERACTIVE_REQUIRED_ENV", + "CODEX_STARTUP_TRACE_ENV_VAR", "CODEX_MCP_ENV_FORWARD_VARS", "KNOWN_BACKEND_NAMES", ] @@ -67,6 +69,8 @@ AGENT_BACKEND_CLAUDE_CODE: str = "claude-code" AGENT_BACKEND_CODEX: str = "codex" PROVIDER_PROFILE_ENV_VAR: str = "AUTOSKILLIT_PROVIDER_PROFILE" +CODEX_STARTUP_TRACE_ENV_VAR: str = "AUTOSKILLIT_CODEX_STARTUP_TRACE" +CODEX_COOK_RESERVED_ENV_VARS: frozenset[str] = frozenset({"CODEX_HOME", "CODEX_SQLITE_HOME"}) AUTOSKILLIT_APPLICABLE_GUARDS: str = "AUTOSKILLIT_APPLICABLE_GUARDS" AUTOSKILLIT_WRITE_GUARD_TOOL_NAMES: str = "AUTOSKILLIT_WRITE_GUARD_TOOL_NAMES" KNOWN_BACKEND_NAMES: frozenset[str] = frozenset({AGENT_BACKEND_CLAUDE_CODE, AGENT_BACKEND_CODEX}) @@ -106,6 +110,8 @@ "AUTOSKILLIT_SESSION_DEADLINE", AGENT_BACKEND_ENV_VAR, AGENT_BACKEND_DYNACONF_ENV_VAR, + CODEX_STARTUP_TRACE_ENV_VAR, + *CODEX_COOK_RESERVED_ENV_VARS, } ) diff --git a/src/autoskillit/core/types/_type_enums.py b/src/autoskillit/core/types/_type_enums.py index fdd9cb248c..c0a4ec56e9 100644 --- a/src/autoskillit/core/types/_type_enums.py +++ b/src/autoskillit/core/types/_type_enums.py @@ -26,6 +26,7 @@ "KillReason", "ChannelConfirmation", "SessionOutcome", + "HookTrustPolicy", "CliSubtype", "ChannelBStatus", "PRState", @@ -346,6 +347,13 @@ class SessionOutcome(StrEnum): FAILED = "failed" +class HookTrustPolicy(StrEnum): + """Interactive hook trust behavior for a coding-agent backend.""" + + AUTOMATED = "automated" + REVIEW_EACH_SESSION = "review_each_session" + + class CliSubtype(StrEnum): """Sealed enum for Claude CLI session subtypes. diff --git a/src/autoskillit/core/types/_type_protocols_backend.py b/src/autoskillit/core/types/_type_protocols_backend.py index 4ec98a20d9..84e1c250b4 100644 --- a/src/autoskillit/core/types/_type_protocols_backend.py +++ b/src/autoskillit/core/types/_type_protocols_backend.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping, Sequence +from contextlib import AbstractContextManager from pathlib import Path from typing import Any, Protocol, runtime_checkable @@ -11,7 +12,9 @@ BackendCapabilities, BackendConventions, CmdSpec, + CookSessionHandle, SessionEvent, + SessionSummary, SkillSessionConfig, ) from ._type_checkpoint import SessionCheckpoint @@ -65,6 +68,8 @@ def build_env( class SessionLocator(Protocol): """Protocol for locating session log directories for a given backend.""" + def list_sessions(self, cwd: str) -> Sequence[SessionSummary]: ... + def locate_session(self, session_id: str) -> Path | None: ... def project_log_dir(self, cwd: str) -> Path: @@ -170,7 +175,14 @@ def build_interactive_cmd( tools: Sequence[str] = (), ) -> CmdSpec: ... - def validate_session_layout(self, session_dir: Path) -> list[str]: ... + def validate_session_layout( + self, + session_dir: Path, + *, + project_dir: Path | None = None, + ) -> list[str]: ... + + def validate_interactive_invocation(self, spec: CmdSpec) -> list[str]: ... def validate_skill_content(self, content: str) -> list[str]: ... @@ -178,7 +190,18 @@ def version(self) -> str: ... def list_plugins(self) -> list[dict[str, Any]]: ... - def ensure_pre_launch(self) -> list[str]: ... + def ensure_pre_launch(self, *, session_dir: Path | None = None) -> list[str]: ... + + def recover_cook_history(self) -> None: ... + + def cook_session_context( + self, + *, + session_home: Path, + launch_id: str, + attempt: int, + current_resume_spec: ResumeSpec, + ) -> AbstractContextManager[CookSessionHandle]: ... def translate_model(self, model: str) -> str: ... diff --git a/src/autoskillit/core/types/_type_protocols_workspace.py b/src/autoskillit/core/types/_type_protocols_workspace.py index 503dd5bd9f..236bb6fec5 100644 --- a/src/autoskillit/core/types/_type_protocols_workspace.py +++ b/src/autoskillit/core/types/_type_protocols_workspace.py @@ -3,13 +3,14 @@ from __future__ import annotations from collections.abc import Mapping, Sequence +from contextlib import AbstractContextManager from pathlib import Path from typing import Any, Protocol, runtime_checkable from ._type_backend import BackendConventions from ._type_enums import SkillExecutionRole, SkillSource from ._type_protocols_backend import CodingAgentBackend -from ._type_results import CleanupResult, CloneResult, ValidatedAddDir +from ._type_results import CleanupResult, CloneResult, ManagedSessionHome, ValidatedAddDir from ._type_skill_contract import SkillSourceIdentity, SkillSourceRef, SkillVisibilitySpec __all__ = [ @@ -213,6 +214,13 @@ def push_to_remote( class SessionSkillManager(Protocol): """Protocol for managing per-session ephemeral skill directories.""" + def managed_session( + self, + session_id: str, + catalog: EffectiveSkillCatalogAuthority, + projection_context: SkillProjectionContextAuthority, + ) -> AbstractContextManager[ManagedSessionHome]: ... + def materialize_invocation( self, session_id: str, diff --git a/src/autoskillit/core/types/_type_results.py b/src/autoskillit/core/types/_type_results.py index 1e4808b66c..9c227977d8 100644 --- a/src/autoskillit/core/types/_type_results.py +++ b/src/autoskillit/core/types/_type_results.py @@ -34,6 +34,7 @@ "SpilledOutput", "SpillSpec", "TestResult", + "ManagedSessionHome", "ValidatedAddDir", "ValidatedWorktreePath", "VALID_INPUT_SPEC_TYPES", @@ -126,6 +127,16 @@ def glob(self, pattern: str) -> list[Path]: return list(Path(self.path).glob(pattern)) +@dataclass(frozen=True, slots=True) +class ManagedSessionHome: + """Already-owned generated home for one logical interactive cook launch.""" + + launch_id: str + generated_home: Path + skills_dir: ValidatedAddDir + pass_fds: tuple[int, ...] + + @dataclass(frozen=True, slots=True) class ValidatedWorktreePath: """A worktree path validated as absolute and existing on disk. diff --git a/src/autoskillit/execution/backends/AGENTS.md b/src/autoskillit/execution/backends/AGENTS.md index 8ebb827293..0b71d5b790 100644 --- a/src/autoskillit/execution/backends/AGENTS.md +++ b/src/autoskillit/execution/backends/AGENTS.md @@ -14,9 +14,12 @@ IL-1 backend abstraction layer — concrete `CodingAgentBackend` implementations | `_claude_prompt.py` | Prompt injection utilities, session constants (`_ensure_skill_prefix`, `_inject_completion_directive`, `_compose_resume_prompt`, etc.), shared by claude + codex + commands | | `codex.py` | `CodexFlags`, `CodexBackend` (incl. `validate_session_layout`, `setup_session_dir` agent TOML generation and session-config registration), `CodexEnvPolicy`, `CodexSessionLocator` (parse/config moved to `_codex_parse.py` / `_codex_config.py`) | | `_codex_config.py` | TOML serialization with `[[key]]` array-of-tables support, MCP registration (`ensure_codex_mcp_registered`, `_serialize_toml`) | +| `_codex_config_lock.py` | Canonical source-config advisory lock with timeout, ownership diagnostics, and non-reentrant lifecycle | | `_codex_hooks.py` | Codex config.toml hook generation, sync, and upsert (`generate_codex_hooks_config`, `sync_hooks_to_codex_config`) | | `_codex_parse.py` | `CodexStreamParser`, `CodexResultParser`, `_scan_codex_ndjson`, `_CodexParseAccumulator` | +| `_codex_prelaunch.py` | Sole composed source-config synchronization, hook update, snapshot, and native-validation transaction | | `_codex_recipe_delivery.py` | Protected outer-budget provider seam, bounded diagnostic correlation, and durable consumed-call/receipt ledger | +| `_codex_session_storage.py` | Canonical rollout store, per-attempt views and leases, durable promotion/recovery, and derived session index | | `codex_scenario_player.py` | `CodexScenarioPlayer`, `make_codex_scenario_player`, `CodexStepRecord`, `CodexScenario`, `_load_manifest`, `_FakeCodexCLI`, `_write_shim_script` — scenario replay data layer for Codex backend | | `_cmd_builder.py` | `CmdBuilder`, `CmdOrderingError` — typed builder that enforces positional-before-variadic ordering by construction | diff --git a/src/autoskillit/execution/backends/_codex_config.py b/src/autoskillit/execution/backends/_codex_config.py index 4371fb0ec2..66d64f5cc0 100644 --- a/src/autoskillit/execution/backends/_codex_config.py +++ b/src/autoskillit/execution/backends/_codex_config.py @@ -26,6 +26,7 @@ get_logger, safe_upsert_section, ) +from autoskillit.execution.backends._codex_config_lock import CodexConfigLock # Floor value: 14364.0 = max(3600 + 7200, 7200) * 1.33 # Computed from FleetConfig and RunSkillConfig dataclass defaults. @@ -421,18 +422,16 @@ def _upsert_top_level_key_exact(path: Path, *, key: str, value: int) -> None: atomic_write(path, "".join(lines)) -def ensure_codex_mcp_registered( +def _ensure_codex_mcp_registered_unlocked( *, - config_path: Path | None = None, + config_path: Path, headless_auto_gate: bool = True, tool_timeout_sec: float | None = None, ) -> bool: - """Return True if the entry was written, False if already registered. + """Mutate a Codex config whose caller already owns its config lock. For corrupt files, always returns True (safe_upsert_section is unconditional). """ - if config_path is None: - config_path = Path.home() / ".codex" / "config.toml" effective_timeout = ( tool_timeout_sec if tool_timeout_sec is not None else CODEX_MCP_TOOL_TIMEOUT_FLOOR ) @@ -480,3 +479,28 @@ def ensure_codex_mcp_registered( ) _write_codex_config(config_path, config, source=result) return True + + +def ensure_codex_mcp_registered( + *, + config_path: Path | None = None, + headless_auto_gate: bool = True, + tool_timeout_sec: float | None = None, +) -> bool: + """Return True if the shared Codex MCP entry was written. + + The public facade owns serialization for the complete read-modify-write + transaction. Composed transactions must use the private unlocked primitive + while holding the same lock. + """ + resolved_config_path = ( + (Path.home() / ".codex" / "config.toml" if config_path is None else Path(config_path)) + .expanduser() + .resolve(strict=False) + ) + with CodexConfigLock(resolved_config_path): + return _ensure_codex_mcp_registered_unlocked( + config_path=resolved_config_path, + headless_auto_gate=headless_auto_gate, + tool_timeout_sec=tool_timeout_sec, + ) diff --git a/src/autoskillit/execution/backends/_codex_config_lock.py b/src/autoskillit/execution/backends/_codex_config_lock.py new file mode 100644 index 0000000000..751e76abf4 --- /dev/null +++ b/src/autoskillit/execution/backends/_codex_config_lock.py @@ -0,0 +1,180 @@ +"""Interprocess serialization for shared Codex configuration writes.""" + +from __future__ import annotations + +import errno +import fcntl +import json +import math +import os +import socket +import threading +import time +from pathlib import Path +from types import TracebackType + +_DEFAULT_TIMEOUT_SECONDS = 30.0 +_DEFAULT_POLL_INTERVAL_SECONDS = 0.05 +_OWNER_DIAGNOSTIC_LIMIT = 4096 +_owned_paths: dict[Path, int] = {} +_owned_paths_guard = threading.Lock() + + +class CodexConfigLock: + """Exclusive canonical-path lock for a shared Codex ``config.toml``. + + The sidecar is stable across atomic config replacement. Acquisitions are + deliberately non-reentrant within a process so composed transactions must + call unlocked leaf mutators instead of nesting public writer facades. + """ + + def __init__( + self, + config_path: Path, + *, + timeout: float = _DEFAULT_TIMEOUT_SECONDS, + poll_interval: float = _DEFAULT_POLL_INTERVAL_SECONDS, + ) -> None: + if not math.isfinite(timeout) or timeout < 0: + raise ValueError("timeout must be a finite non-negative number") + if not math.isfinite(poll_interval) or poll_interval <= 0: + raise ValueError("poll_interval must be a finite positive number") + self.config_path = Path(config_path).expanduser().resolve(strict=False) + self.lock_path = self.config_path.with_name(f".{self.config_path.name}.autoskillit.lock") + self.timeout = timeout + self.poll_interval = poll_interval + self._fd: int | None = None + self._owner_pid: int | None = None + + def _claim_process_ownership(self) -> int: + pid = os.getpid() + with _owned_paths_guard: + owner_pid = _owned_paths.get(self.config_path) + if owner_pid == pid: + raise RuntimeError( + "Codex config lock is non-reentrant; " + f"process {pid} already owns {self.config_path}" + ) + _owned_paths[self.config_path] = pid + return pid + + def _release_process_ownership(self, pid: int) -> None: + with _owned_paths_guard: + if _owned_paths.get(self.config_path) == pid: + del _owned_paths[self.config_path] + + def _write_owner_diagnostics(self, fd: int, pid: int) -> None: + payload = json.dumps( + { + "acquired_at_unix": time.time(), + "config_path": str(self.config_path), + "hostname": socket.gethostname(), + "pid": pid, + "thread_id": threading.get_ident(), + }, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + os.ftruncate(fd, 0) + offset = 0 + while offset < len(payload): + offset += os.write(fd, payload[offset:]) + os.fsync(fd) + + def _owner_diagnostics(self, fd: int) -> str: + try: + raw = os.pread(fd, _OWNER_DIAGNOSTIC_LIMIT, 0) + except OSError as exc: + return f"unavailable ({type(exc).__name__}: {exc})" + if not raw: + return "unavailable" + return raw.decode("utf-8", errors="replace") + + def acquire(self) -> CodexConfigLock: + """Acquire the lock or raise ``TimeoutError`` with owner diagnostics.""" + if self._fd is not None: + raise RuntimeError("Codex config lock instance is already acquired") + + pid = self._claim_process_ownership() + fd: int | None = None + acquired = False + try: + self.lock_path.parent.mkdir(parents=True, exist_ok=True) + flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_CLOEXEC", 0) + fd = os.open(self.lock_path, flags, 0o600) + deadline = time.monotonic() + self.timeout + while True: + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + acquired = True + break + except OSError as exc: + if exc.errno not in {errno.EACCES, errno.EAGAIN}: + raise + remaining = deadline - time.monotonic() + if remaining <= 0: + owner = self._owner_diagnostics(fd) + raise TimeoutError( + "timed out acquiring Codex config lock " + f"for {self.config_path} after {self.timeout:.3f}s; " + f"lock_path={self.lock_path}; owner={owner}" + ) from exc + time.sleep(min(self.poll_interval, remaining)) + + self._write_owner_diagnostics(fd, pid) + self._fd = fd + self._owner_pid = pid + return self + except BaseException: + if fd is not None: + try: + if acquired: + try: + fcntl.flock(fd, fcntl.LOCK_UN) + except OSError: + pass + finally: + try: + os.close(fd) + finally: + self._release_process_ownership(pid) + else: + self._release_process_ownership(pid) + raise + + def release(self) -> None: + """Release an acquired lock.""" + fd = self._fd + if fd is None: + return + owner_pid = self._owner_pid + current_pid = os.getpid() + if owner_pid != current_pid: + raise RuntimeError( + "Codex config lock may only be released by its acquiring process; " + f"owner={owner_pid}, current={current_pid}" + ) + self._fd = None + self._owner_pid = None + try: + fcntl.flock(fd, fcntl.LOCK_UN) + finally: + try: + os.close(fd) + finally: + self._release_process_ownership(current_pid) + + def __enter__(self) -> CodexConfigLock: + return self.acquire() + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + self.release() + + +__all__ = ["CodexConfigLock"] diff --git a/src/autoskillit/execution/backends/_codex_hooks.py b/src/autoskillit/execution/backends/_codex_hooks.py index 136825b2d7..6da0faf4c8 100644 --- a/src/autoskillit/execution/backends/_codex_hooks.py +++ b/src/autoskillit/execution/backends/_codex_hooks.py @@ -15,6 +15,7 @@ _serialize_toml, _write_codex_config, ) +from autoskillit.execution.backends._codex_config_lock import CodexConfigLock from autoskillit.hook_registry import ( HOOKS_DIR, _build_hook_entry, @@ -115,15 +116,13 @@ def _upsert_hooks_text( atomic_write(config_path, result_text) -def sync_hooks_to_codex_config( - config_path: Path | None = None, *, hook_config_format: str = "" +def _sync_hooks_to_codex_config_unlocked( + config_path: Path, *, hook_config_format: str = "" ) -> bool: - """Sync autoskillit hooks to Codex config.toml. + """Mutate hook entries while the caller owns the Codex config lock. Returns True if the config was changed, False if already up to date. """ - if config_path is None: - config_path = Path.home() / ".codex" / "config.toml" result = _read_codex_config(config_path) if result.is_corrupt: if result.raw_bytes is None: @@ -152,3 +151,19 @@ def sync_hooks_to_codex_config( config["hooks"] = merged _write_codex_config(config_path, config, source=result) return True + + +def sync_hooks_to_codex_config( + config_path: Path | None = None, *, hook_config_format: str = "" +) -> bool: + """Sync autoskillit hooks under the shared Codex config lock.""" + resolved_config_path = ( + (Path.home() / ".codex" / "config.toml" if config_path is None else Path(config_path)) + .expanduser() + .resolve(strict=False) + ) + with CodexConfigLock(resolved_config_path): + return _sync_hooks_to_codex_config_unlocked( + config_path=resolved_config_path, + hook_config_format=hook_config_format, + ) diff --git a/src/autoskillit/execution/backends/_codex_prelaunch.py b/src/autoskillit/execution/backends/_codex_prelaunch.py new file mode 100644 index 0000000000..54fb2c8bb2 --- /dev/null +++ b/src/autoskillit/execution/backends/_codex_prelaunch.py @@ -0,0 +1,38 @@ +"""Composed, lock-owned Codex configuration pre-launch transaction.""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path + +from autoskillit.execution.backends._codex_config import ( + _ensure_codex_mcp_registered_unlocked, +) +from autoskillit.execution.backends._codex_config_lock import CodexConfigLock +from autoskillit.execution.backends._codex_hooks import ( + _sync_hooks_to_codex_config_unlocked, +) + + +@contextmanager +def codex_prelaunch_transaction( + *, + source_codex_home: Path, + hook_config_format: str = "", +) -> Iterator[Path]: + """Synchronize the source config and hold its lock across caller work. + + The yielded path names the exact config protected by the transaction. + Callers may snapshot or validate those bytes before leaving the context; + unique generated-home mutations happen only after this context exits. + """ + resolved_home = Path(source_codex_home).expanduser().resolve(strict=False) + config_path = resolved_home / "config.toml" + with CodexConfigLock(config_path): + _ensure_codex_mcp_registered_unlocked(config_path=config_path) + _sync_hooks_to_codex_config_unlocked( + config_path=config_path, + hook_config_format=hook_config_format, + ) + yield config_path diff --git a/src/autoskillit/execution/backends/_codex_session_storage.py b/src/autoskillit/execution/backends/_codex_session_storage.py new file mode 100644 index 0000000000..38068d7b7a --- /dev/null +++ b/src/autoskillit/execution/backends/_codex_session_storage.py @@ -0,0 +1,1048 @@ +"""Private durable storage for interactive Codex rollout views.""" + +from __future__ import annotations + +import fcntl +import hashlib +import json +import os +import re +import shutil +import socket +import stat +import sys +import time +from collections.abc import Iterator, Mapping, Sequence +from contextlib import AbstractContextManager +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import zstandard + +from autoskillit.core import ( + CODEX_ACTIVE_VIEWS_SUBDIR, + CODEX_ARCHIVED_SESSIONS_SUBDIR, + CODEX_SESSIONS_SUBDIR, + BareResume, + CookSessionHandle, + NamedResume, + NoResume, + ResumeSpec, + SessionSummary, + default_log_dir, +) + +_VIEW_ID_RE = re.compile(r"^[0-9a-f]{16}-[1-9][0-9]*$") +_THREAD_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$") +_ROLLOUT_SUFFIXES = (".jsonl", ".jsonl.zst") +_INDEX_READ_LIMIT = 4 * 1024 * 1024 +_ROLLOUT_METADATA_LIMIT = 64 * 1024 +_MANIFEST_READ_LIMIT = 256 * 1024 +_MANIFEST_NAME = "manifest.json" +_LOCKS_SUBDIR = ".locks" +_INDEX_NAME = "codex-session-index.json" +_MANIFEST_STATES = frozenset({"prepared", "running", "finalizing", "complete", "failed"}) +_STORE_TO_PUBLIC = {"active": "sessions", "archived": "archived_sessions"} +_PUBLIC_TO_STORE = {value: key for key, value in _STORE_TO_PUBLIC.items()} +_SUPPORTED_LOCAL_FILESYSTEMS = frozenset( + { + "apfs", + "bcachefs", + "btrfs", + "ext2", + "ext3", + "ext4", + "f2fs", + "hfs", + "hfsplus", + "overlay", + "tmpfs", + "xfs", + "zfs", + } +) +_INERT_NAMES = { + "sessions": ".inert-sessions", + "archived_sessions": ".inert-archived_sessions", +} + + +def codex_session_index_path() -> Path: + """Return the one production path for the derived Codex cook index.""" + return default_log_dir() / _INDEX_NAME + + +def _lexists(path: Path) -> bool: + return os.path.lexists(path) + + +def _require_real_directory(path: Path, *, label: str) -> None: + try: + mode = path.lstat().st_mode + except FileNotFoundError as exc: + raise RuntimeError(f"Missing {label}: {path}") from exc + if stat.S_ISLNK(mode) or not stat.S_ISDIR(mode): + raise RuntimeError(f"{label} must be a non-symlink directory: {path}") + + +def _fsync_directory(path: Path) -> None: + _require_real_directory(path, label="fsync directory") + flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) + flags |= getattr(os, "O_NOFOLLOW", 0) + fd = os.open(path, flags) + try: + os.fsync(fd) + finally: + os.close(fd) + + +def _atomic_json(path: Path, payload: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + _require_real_directory(path.parent, label="JSON parent") + if _lexists(path) and path.is_symlink(): + raise RuntimeError(f"Refusing to replace symlink JSON destination: {path}") + encoded = (json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n").encode() + temporary = path.with_name(f".{path.name}.{os.getpid()}.{time.time_ns()}.tmp") + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + fd = os.open(temporary, flags, 0o600) + try: + view = memoryview(encoded) + while view: + written = os.write(fd, view) + view = view[written:] + os.fsync(fd) + except BaseException: + try: + temporary.unlink() + except FileNotFoundError: + pass + raise + finally: + os.close(fd) + os.replace(temporary, path) + _fsync_directory(path.parent) + + +def _read_bounded(path: Path, limit: int) -> bytes: + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + fd = os.open(path, flags) + try: + file_stat = os.fstat(fd) + if not stat.S_ISREG(file_stat.st_mode): + raise ValueError(f"{path} is not a regular file") + chunks: list[bytes] = [] + total = 0 + while total <= limit: + chunk = os.read(fd, min(64 * 1024, limit + 1 - total)) + if not chunk: + break + chunks.append(chunk) + total += len(chunk) + if total > limit: + raise ValueError(f"{path.name} exceeds the {limit}-byte bound") + return b"".join(chunks) + finally: + os.close(fd) + + +def _read_prefix(path: Path, limit: int) -> bytes: + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + fd = os.open(path, flags) + try: + file_stat = os.fstat(fd) + if not stat.S_ISREG(file_stat.st_mode): + raise ValueError(f"{path} is not a regular file") + if path.name.endswith(".zst"): + with os.fdopen(fd, "rb", closefd=False) as source: + with zstandard.ZstdDecompressor().stream_reader(source) as reader: + return reader.read(limit) + return os.read(fd, limit) + finally: + os.close(fd) + + +def _safe_relative_value(value: str) -> Path: + if not value or "\\" in value: + raise RuntimeError(f"Unsafe relative rollout path: {value!r}") + relative = Path(value) + if relative.is_absolute() or any(part in {"", ".", ".."} for part in relative.parts): + raise RuntimeError(f"Unsafe relative rollout path: {value!r}") + if not relative.name.endswith(_ROLLOUT_SUFFIXES): + raise RuntimeError(f"Unsupported rollout filename: {value!r}") + return relative + + +def _safe_relative(path: Path, root: Path) -> Path: + _require_real_directory(root, label="rollout root") + try: + mode = path.lstat().st_mode + except FileNotFoundError as exc: + raise RuntimeError(f"Missing rollout file: {path}") from exc + if stat.S_ISLNK(mode) or not stat.S_ISREG(mode): + raise RuntimeError(f"Rollout must be a regular non-symlink file: {path}") + try: + relative = path.relative_to(root) + except ValueError as exc: + raise RuntimeError(f"Rollout escapes its root: {path}") from exc + relative = _safe_relative_value(relative.as_posix()) + cursor = root + for part in relative.parts[:-1]: + cursor /= part + _require_real_directory(cursor, label="rollout parent") + return relative + + +def _rollout_files(root: Path) -> Iterator[Path]: + if not _lexists(root): + return + _require_real_directory(root, label="rollout root") + found: list[Path] = [] + for directory, directory_names, file_names in os.walk(root, followlinks=False): + parent = Path(directory) + for name in directory_names: + candidate = parent / name + if candidate.is_symlink(): + raise RuntimeError(f"Symlink directory in rollout tree: {candidate}") + for name in file_names: + candidate = parent / name + if candidate.is_symlink(): + raise RuntimeError(f"Symlink file in rollout tree: {candidate}") + if name.endswith(_ROLLOUT_SUFFIXES): + _safe_relative(candidate, root) + found.append(candidate) + yield from sorted(found) + + +def _ensure_directory_chain(root: Path, relative: Path) -> Path: + _require_real_directory(root, label="storage root") + cursor = root + for part in relative.parts: + if part in {"", ".", ".."} or "/" in part or "\\" in part: + raise RuntimeError(f"Unsafe storage directory component: {part!r}") + next_path = cursor / part + created = False + try: + next_path.mkdir() + created = True + except FileExistsError: + pass + _require_real_directory(next_path, label="storage directory") + if created: + _fsync_directory(cursor) + cursor = next_path + return cursor + + +def _decode_mount_path(value: str) -> str: + return re.sub( + r"\\([0-7]{3})", + lambda match: chr(int(match.group(1), 8)), + value, + ) + + +def _filesystem_type(path: Path) -> str: + if not sys.platform.startswith("linux"): + raise RuntimeError(f"Codex durable views cannot classify filesystems on {sys.platform}") + try: + raw = _read_bounded(Path("/proc/self/mountinfo"), 4 * 1024 * 1024) + except (OSError, ValueError) as exc: + raise RuntimeError("Unable to classify the Codex storage filesystem") from exc + resolved = path.resolve(strict=True) + selected: tuple[int, str] | None = None + for raw_line in raw.decode("utf-8", errors="strict").splitlines(): + before, separator, after = raw_line.partition(" - ") + if not separator: + continue + fields = before.split() + trailing = after.split() + if len(fields) < 5 or not trailing: + continue + mount_path = Path(_decode_mount_path(fields[4])) + if resolved == mount_path or resolved.is_relative_to(mount_path): + length = len(mount_path.parts) + if selected is None or length > selected[0]: + selected = (length, trailing[0]) + if selected is None: + raise RuntimeError(f"Unable to classify Codex storage mount: {resolved}") + return selected[1] + + +def _thread_id_from_bytes(data: bytes) -> str | None: + for raw_line in data.splitlines(): + if not raw_line.strip(): + continue + try: + row = json.loads(raw_line) + except (UnicodeDecodeError, json.JSONDecodeError): + continue + if not isinstance(row, Mapping): + continue + if row.get("type") == "thread.started" and isinstance(row.get("thread_id"), str): + return str(row["thread_id"]) + if row.get("type") == "session_meta": + payload = row.get("payload") + if isinstance(payload, Mapping) and isinstance(payload.get("id"), str): + return str(payload["id"]) + return None + + +def _thread_id(path: Path) -> str | None: + try: + return _thread_id_from_bytes(_read_prefix(path, _ROLLOUT_METADATA_LIMIT)) + except (OSError, ValueError, zstandard.ZstdError): + return None + + +def _identity(path: Path) -> tuple[int, int]: + file_stat = path.stat(follow_symlinks=False) + if not stat.S_ISREG(file_stat.st_mode): + raise RuntimeError(f"Expected regular rollout file: {path}") + return file_stat.st_dev, file_stat.st_ino + + +def _replace_symlink(path: Path, target: Path) -> None: + temporary = path.with_name(f".{path.name}.{os.getpid()}.link") + try: + temporary.unlink() + except FileNotFoundError: + pass + temporary.symlink_to(target) + os.replace(temporary, path) + _fsync_directory(path.parent) + + +@dataclass(slots=True) +class _FileLease: + path: Path + fd: int = field(init=False) + + @classmethod + def acquire(cls, path: Path, *, nonblocking: bool = False) -> _FileLease: + path.parent.mkdir(parents=True, exist_ok=True) + _require_real_directory(path.parent, label="lock directory") + if _lexists(path) and path.is_symlink(): + raise RuntimeError(f"Refusing symlink lock file: {path}") + instance = cls(path=path) + instance.fd = os.open( + path, + os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), + 0o600, + ) + operation = fcntl.LOCK_EX | (fcntl.LOCK_NB if nonblocking else 0) + try: + fcntl.flock(instance.fd, operation) + except BaseException: + os.close(instance.fd) + raise + owner = { + "pid": os.getpid(), + "host": socket.gethostname(), + "acquired_ns": time.time_ns(), + } + os.ftruncate(instance.fd, 0) + os.write(instance.fd, json.dumps(owner, sort_keys=True).encode()) + os.fsync(instance.fd) + return instance + + def release(self) -> None: + if self.fd < 0: + return + fd, self.fd = self.fd, -1 + try: + fcntl.flock(fd, fcntl.LOCK_UN) + finally: + os.close(fd) + + +@dataclass(slots=True) +class CodexInteractiveSessionLease(AbstractContextManager[CookSessionHandle]): + """One owned attempt view, including durable spawn/reap proof.""" + + store: CodexSessionStore + session_home: Path + launch_id: str + attempt: int + current_resume_spec: ResumeSpec + view_id: str + view_path: Path + manifest: dict[str, Any] + view_lease: _FileLease + inert_targets: dict[str, Path] + thread_lease: _FileLease | None = None + _entered: bool = False + _closed: bool = False + + def __enter__(self) -> CookSessionHandle: + if self._entered: + raise RuntimeError("Codex attempt lease is not reentrant") + try: + self.store._enter_attempt(self) + except BaseException as entry_error: + self._closed = True + failures: list[BaseException] = [entry_error] + try: + self.store._abort_pre_spawn(self) + except BaseException as cleanup_error: + failures.append(cleanup_error) + self._release_leases(failures) + if len(failures) == 1: + raise + raise BaseExceptionGroup("Codex attempt entry failed", failures) + self._entered = True + pass_fds = [self.view_lease.fd] + if self.thread_lease is not None: + pass_fds.append(self.thread_lease.fd) + return CookSessionHandle( + view_id=self.view_id, + pass_fds=tuple(fd for fd in pass_fds if fd >= 0), + _record_spawn=self._record_spawn, + _record_reaped=self._record_reaped, + ) + + def _record_spawn(self, pid: int, pgid: int) -> None: + if not self._entered or self._closed: + raise RuntimeError("Cannot record spawn outside an active Codex attempt") + if self.manifest.get("child_pid") is not None: + raise RuntimeError("Codex attempt spawn was already recorded") + if pid <= 0 or pgid <= 0: + raise ValueError("Child pid and pgid must be positive") + self.manifest.update( + state="running", + child_pid=pid, + child_pgid=pgid, + reaped=False, + ) + self.store._write_manifest(self) + + def _record_reaped(self, pid: int, pgid: int) -> None: + if not self._entered or self._closed: + raise RuntimeError("Cannot record reap outside an active Codex attempt") + if self.manifest.get("child_pid") != pid or self.manifest.get("child_pgid") != pgid: + raise RuntimeError("Reaped child identity does not match the recorded spawn") + if self.manifest.get("reaped") is True: + raise RuntimeError("Codex attempt reap was already recorded") + self.manifest["reaped"] = True + self.manifest["reaped_ns"] = time.time_ns() + self.store._write_manifest(self) + + def _release_leases(self, failures: list[BaseException]) -> None: + if self.thread_lease is not None: + try: + self.thread_lease.release() + except BaseException as release_error: + failures.append(release_error) + try: + self.view_lease.release() + except BaseException as release_error: + failures.append(release_error) + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: object, + ) -> bool | None: + if self._closed: + return None + self._closed = True + failures: list[BaseException] = [] + try: + self.store._exit_attempt(self) + except BaseException as cleanup_error: + failures.append(cleanup_error) + finally: + self._release_leases(failures) + if exc is not None: + if failures: + raise BaseExceptionGroup( + "Codex attempt body and cleanup failed", + [exc, *failures], + ) + return False + if len(failures) == 1: + raise failures[0] + if failures: + raise BaseExceptionGroup("Codex attempt cleanup failed", failures) + return None + + +class CodexSessionStore: + """Canonical rollouts, attempt views, recovery, and derived listing index.""" + + def __init__(self, log_dir: Path, index_path: Path | None = None) -> None: + self.log_dir = Path(log_dir).expanduser().resolve(strict=False) + self.active_root = self.log_dir / CODEX_SESSIONS_SUBDIR + self.archive_root = self.log_dir / CODEX_ARCHIVED_SESSIONS_SUBDIR + self.views_root = self.log_dir / CODEX_ACTIVE_VIEWS_SUBDIR + self.locks_root = self.views_root / _LOCKS_SUBDIR + self.index_path = ( + Path(index_path).expanduser().resolve(strict=False) + if index_path is not None + else self.log_dir / _INDEX_NAME + ) + + def _ensure_roots(self) -> None: + for root in (self.active_root, self.archive_root, self.views_root, self.locks_root): + root.mkdir(parents=True, exist_ok=True) + _require_real_directory(root, label="Codex storage root") + devices = { + self.active_root.stat().st_dev, + self.archive_root.stat().st_dev, + self.views_root.stat().st_dev, + } + if len(devices) != 1: + raise RuntimeError("Codex rollout stores and views must share one filesystem") + filesystem_types = { + _filesystem_type(self.active_root), + _filesystem_type(self.archive_root), + _filesystem_type(self.views_root), + } + if len(filesystem_types) != 1 or not filesystem_types <= _SUPPORTED_LOCAL_FILESYSTEMS: + raise RuntimeError( + "Codex durable views require one supported local filesystem; " + f"found {sorted(filesystem_types)}" + ) + + def _thread_lock_path(self, thread_id: str) -> Path: + if _THREAD_ID_RE.fullmatch(thread_id) is None: + raise ValueError(f"Invalid Codex thread id: {thread_id!r}") + key = hashlib.sha256(thread_id.encode("utf-8")).hexdigest() + return self.locks_root / f"thread-{key}.lock" + + def prepare_attempt( + self, + *, + session_home: Path, + launch_id: str, + attempt: int, + current_resume_spec: ResumeSpec, + ) -> CodexInteractiveSessionLease: + self._ensure_roots() + view_id = f"{launch_id}-{attempt}" + if _VIEW_ID_RE.fullmatch(view_id) is None: + raise ValueError(f"Invalid Codex attempt identity: {view_id!r}") + session_home = Path(session_home).resolve(strict=True) + inert_targets = self._validate_inert_home(session_home) + view_path = self.views_root / view_id + if os.path.lexists(view_path): + raise FileExistsError(f"Codex attempt view already exists: {view_id}") + view_path.mkdir(mode=0o700) + (view_path / "sessions").mkdir() + (view_path / "archived_sessions").mkdir() + _fsync_directory(view_path) + _fsync_directory(self.views_root) + view_lease = _FileLease.acquire(self.locks_root / f"view-{view_id}.lock") + manifest: dict[str, Any] = { + "schema_version": 1, + "launch_id": launch_id, + "attempt": attempt, + "view_id": view_id, + "state": "prepared", + "child_pid": None, + "child_pgid": None, + "reaped": False, + "resume_thread_id": None, + "resume_source_store": None, + "resume_source_relpath": None, + "final_store": None, + "final_relpath": None, + } + thread_lease: _FileLease | None = None + try: + if isinstance(current_resume_spec, NamedResume): + thread_id = current_resume_spec.session_id + thread_lease = _FileLease.acquire( + self._thread_lock_path(thread_id), + nonblocking=True, + ) + located = self._locate_with_store(thread_id) + if located is None: + raise FileNotFoundError(f"Codex resume rollout not found: {thread_id}") + source_store, source_path = located + source_root = self.active_root if source_store == "active" else self.archive_root + relative = _safe_relative(source_path, source_root) + destination_root = view_path / _STORE_TO_PUBLIC[source_store] + destination = destination_root / relative + _ensure_directory_chain(destination_root, relative.parent) + os.link(source_path, destination, follow_symlinks=False) + if _identity(destination) != _identity(source_path): + raise RuntimeError("Codex resume hard link identity mismatch") + _fsync_directory(destination.parent) + manifest.update( + resume_thread_id=thread_id, + resume_source_store=source_store, + resume_source_relpath=relative.as_posix(), + ) + elif isinstance(current_resume_spec, BareResume): + raise RuntimeError("Bare resume must be resolved before attempt preparation") + elif not isinstance(current_resume_spec, NoResume): + raise TypeError("Unsupported Codex resume specification") + lease = CodexInteractiveSessionLease( + store=self, + session_home=session_home, + launch_id=launch_id, + attempt=attempt, + current_resume_spec=current_resume_spec, + view_id=view_id, + view_path=view_path, + manifest=manifest, + view_lease=view_lease, + inert_targets=inert_targets, + thread_lease=thread_lease, + ) + self._write_manifest(lease) + return lease + except BaseException: + if thread_lease is not None: + thread_lease.release() + view_lease.release() + if _lexists(view_path): + self._validate_pre_spawn_view(view_path, manifest, allow_missing_resume=True) + shutil.rmtree(view_path) + _fsync_directory(self.views_root) + raise + + def _validate_inert_home(self, session_home: Path) -> dict[str, Path]: + targets: dict[str, Path] = {} + for public_name, inert_name in _INERT_NAMES.items(): + public_path = session_home / public_name + inert_path = session_home / inert_name + if not public_path.is_symlink(): + raise RuntimeError(f"{public_name} must be an inert symlink before view entry") + resolved_public = public_path.resolve(strict=True) + resolved_inert = inert_path.resolve(strict=True) + if resolved_public != resolved_inert: + raise RuntimeError(f"{public_name} does not resolve to its inert target") + if not resolved_inert.is_dir() or resolved_inert.is_symlink(): + raise RuntimeError(f"Invalid inert rollout target: {resolved_inert}") + if any(resolved_inert.iterdir()): + raise RuntimeError(f"Inert rollout target is not empty: {resolved_inert}") + if not resolved_inert.is_relative_to(session_home): + raise RuntimeError("Inert rollout target escapes the generated home") + targets[public_name] = resolved_inert + return targets + + def _enter_attempt(self, lease: CodexInteractiveSessionLease) -> None: + self._validate_inert_home(lease.session_home) + for public_name in _INERT_NAMES: + _replace_symlink( + lease.session_home / public_name, + lease.view_path / public_name, + ) + + def _restore_inert(self, lease: CodexInteractiveSessionLease) -> None: + for public_name, target in lease.inert_targets.items(): + _replace_symlink(lease.session_home / public_name, target) + if (lease.session_home / public_name).resolve(strict=True) != target: + raise RuntimeError(f"Failed to restore inert {public_name} link") + + def _write_manifest(self, lease: CodexInteractiveSessionLease) -> None: + _atomic_json(lease.view_path / _MANIFEST_NAME, lease.manifest) + + def _abort_pre_spawn(self, lease: CodexInteractiveSessionLease) -> None: + lifecycle = _FileLease.acquire(self.locks_root / "lifecycle.lock") + try: + self._restore_inert(lease) + lease.manifest["state"] = "failed" + self._write_manifest(lease) + self._validate_pre_spawn_view( + lease.view_path, + lease.manifest, + allow_missing_resume=True, + ) + shutil.rmtree(lease.view_path) + _fsync_directory(self.views_root) + finally: + lifecycle.release() + + def _exit_attempt(self, lease: CodexInteractiveSessionLease) -> None: + if lease.manifest.get("child_pid") is None: + self._abort_pre_spawn(lease) + return + self._restore_inert(lease) + if lease.manifest.get("reaped") is not True: + lease.manifest["state"] = "failed" + self._write_manifest(lease) + raise RuntimeError("Codex attempt lacks durable child-reaped proof") + lifecycle = _FileLease.acquire(self.locks_root / "lifecycle.lock") + try: + lease.manifest["state"] = "finalizing" + self._write_manifest(lease) + rows = self._promote_view(lease) + if rows: + self._merge_index_unlocked(rows) + lease.manifest["state"] = "complete" + self._write_manifest(lease) + self._validate_completed_view(lease.view_path) + shutil.rmtree(lease.view_path) + _fsync_directory(self.views_root) + finally: + lifecycle.release() + + def _validate_pre_spawn_view( + self, + view_path: Path, + manifest: Mapping[str, Any], + *, + allow_missing_resume: bool, + ) -> None: + _require_real_directory(view_path, label="Codex attempt view") + expected_root_entries = {"sessions", "archived_sessions", _MANIFEST_NAME} + actual_root_entries = {path.name for path in view_path.iterdir()} + if not actual_root_entries <= expected_root_entries: + raise RuntimeError("Never-running Codex view contains unexpected root entries") + allowed: set[tuple[str, str]] = set() + source_store = manifest.get("resume_source_store") + source_relpath = manifest.get("resume_source_relpath") + if isinstance(source_store, str) and isinstance(source_relpath, str): + allowed.add((_STORE_TO_PUBLIC[source_store], source_relpath)) + found: set[tuple[str, str]] = set() + for public_name in _INERT_NAMES: + root = view_path / public_name + _require_real_directory(root, label="attempt rollout root") + for directory, directory_names, file_names in os.walk(root, followlinks=False): + parent = Path(directory) + if any((parent / name).is_symlink() for name in directory_names): + raise RuntimeError("Never-running Codex view contains a symlink directory") + for name in file_names: + path = parent / name + if path.is_symlink(): + raise RuntimeError("Never-running Codex view contains a symlink file") + relative = _safe_relative(path, root).as_posix() + key = (public_name, relative) + if key not in allowed: + raise RuntimeError( + f"Never-running Codex view contains unexpected file: {path}" + ) + found.add(key) + if not allow_missing_resume and found != allowed: + raise RuntimeError("Never-running Codex view is missing its resume hard link") + for public_name, relative_value in found: + store_name = _PUBLIC_TO_STORE[public_name] + canonical_root = self.active_root if store_name == "active" else self.archive_root + relative_path = _safe_relative_value(relative_value) + canonical = canonical_root / relative_path + staged = view_path / public_name / relative_path + if not canonical.exists() or _identity(canonical) != _identity(staged): + raise RuntimeError("Resume hard link lost its canonical identity") + + def _validate_completed_view(self, view_path: Path) -> None: + _require_real_directory(view_path, label="completed Codex attempt view") + for public_name in _INERT_NAMES: + root = view_path / public_name + for path in root.rglob("*"): + if path.is_symlink() or path.is_file(): + raise RuntimeError(f"Completed Codex view retains unexpected data: {path}") + + def _promote_view(self, lease: CodexInteractiveSessionLease) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for public_name, store_name, canonical_root in ( + ("sessions", "active", self.active_root), + ("archived_sessions", "archived", self.archive_root), + ): + view_root = lease.view_path / public_name + for source in list(_rollout_files(view_root)): + relative = _safe_relative(source, view_root) + thread_id = _thread_id(source) + if thread_id is None: + raise RuntimeError(f"Rollout lacks a Codex thread id: {source}") + resume_thread_id = lease.manifest.get("resume_thread_id") + if resume_thread_id is not None and thread_id != resume_thread_id: + raise RuntimeError("Resumed Codex view contains a different thread identity") + destination = canonical_root / relative + _ensure_directory_chain(canonical_root, relative.parent) + if _lexists(destination): + if destination.is_symlink() or _identity(source) != _identity(destination): + raise RuntimeError( + f"Codex rollout collision preserves both files: {destination}" + ) + else: + try: + os.link(source, destination, follow_symlinks=False) + except FileExistsError: + if _identity(source) != _identity(destination): + raise RuntimeError( + f"Codex rollout collision preserves both files: {destination}" + ) + if _identity(source) != _identity(destination): + raise RuntimeError("Promoted Codex rollout identity mismatch") + file_fd = os.open(destination, os.O_RDONLY) + try: + os.fsync(file_fd) + finally: + os.close(file_fd) + _fsync_directory(destination.parent) + lease.manifest.update( + final_store=store_name, + final_relpath=relative.as_posix(), + ) + self._write_manifest(lease) + source.unlink() + _fsync_directory(source.parent) + rows.append( + self._index_row( + thread_id=thread_id, + launch_id=lease.launch_id, + canonical_store=store_name, + relative_path=relative, + ) + ) + return rows + + def _index_row( + self, + *, + thread_id: str, + launch_id: str | None, + canonical_store: str, + relative_path: Path, + ) -> dict[str, Any]: + return { + "backend_name": "codex", + "session_id": thread_id, + "launch_id": launch_id, + "cwd": "", + "first_prompt": "", + "summary": "", + "git_branch": None, + "modified": None, + "is_sidechain": False, + "session_type_hint": "cook", + "canonical_store": canonical_store, + "relative_path": relative_path.as_posix(), + } + + def _read_index_rows(self) -> list[dict[str, Any]]: + if not self.index_path.exists(): + return [] + try: + raw = _read_bounded(self.index_path, _INDEX_READ_LIMIT) + payload = json.loads(raw) + except (OSError, ValueError, json.JSONDecodeError): + return [] + if not isinstance(payload, list): + return [] + return [dict(row) for row in payload if isinstance(row, Mapping)] + + def _merge_index(self, incoming: Sequence[dict[str, Any]]) -> None: + lifecycle = _FileLease.acquire(self.locks_root / "lifecycle.lock") + try: + self._merge_index_unlocked(incoming) + finally: + lifecycle.release() + + def _merge_index_unlocked(self, incoming: Sequence[dict[str, Any]]) -> None: + existing = self._read_index_rows() + incoming_ids = { + str(row["session_id"]) for row in incoming if isinstance(row.get("session_id"), str) + } + ordered = [dict(row) for row in incoming] + ordered.extend( + row + for row in existing + if isinstance(row.get("session_id"), str) + and str(row["session_id"]) not in incoming_ids + ) + _atomic_json(self.index_path, ordered) + + def read_index(self, cwd: str) -> tuple[SessionSummary, ...]: + wanted = str(Path(cwd).expanduser().resolve(strict=False)) + summaries: list[SessionSummary] = [] + for row in self._read_index_rows(): + try: + row_cwd_raw = row.get("cwd") + row_cwd = ( + str(Path(str(row_cwd_raw)).expanduser().resolve(strict=False)) + if row_cwd_raw + else "" + ) + if row_cwd and row_cwd != wanted: + continue + summary = SessionSummary( + backend_name=str(row.get("backend_name") or "codex"), + session_id=str(row["session_id"]), + launch_id=( + str(row["launch_id"]) if row.get("launch_id") is not None else None + ), + cwd=row_cwd or wanted, + first_prompt=str(row.get("first_prompt") or ""), + summary=str(row.get("summary") or ""), + git_branch=( + str(row["git_branch"]) if row.get("git_branch") is not None else None + ), + modified=(str(row["modified"]) if row.get("modified") is not None else None), + is_sidechain=bool(row.get("is_sidechain", False)), + session_type_hint=( + str(row["session_type_hint"]) + if row.get("session_type_hint") is not None + else None + ), + ) + except (KeyError, TypeError, ValueError): + continue + if not summary.is_sidechain: + summaries.append(summary) + return tuple(summaries) + + def _locate_with_store(self, thread_id: str) -> tuple[str, Path] | None: + for store_name, root in ( + ("active", self.active_root), + ("archived", self.archive_root), + ): + for path in _rollout_files(root): + if _thread_id(path) == thread_id: + return store_name, path + return None + + def locate_session(self, thread_id: str) -> Path | None: + located = self._locate_with_store(thread_id) + if located is not None: + return located[1] + for view_path in sorted(self.views_root.iterdir()): + if view_path.name == _LOCKS_SUBDIR or not view_path.is_dir(): + continue + for public_name in _INERT_NAMES: + for path in _rollout_files(view_path / public_name): + if _thread_id(path) == thread_id: + return path + return None + + def recover(self) -> None: + """Recover safely-owned orphan views, then rebuild the derived index.""" + self._ensure_roots() + for view_path in sorted(self.views_root.iterdir()): + if ( + view_path.name == _LOCKS_SUBDIR + or _VIEW_ID_RE.fullmatch(view_path.name) is None + or view_path.is_symlink() + or not view_path.is_dir() + ): + continue + lock_path = self.locks_root / f"view-{view_path.name}.lock" + try: + view_lock = _FileLease.acquire(lock_path, nonblocking=True) + except BlockingIOError: + continue + try: + manifest_path = view_path / _MANIFEST_NAME + if not manifest_path.is_file() or manifest_path.is_symlink(): + continue + try: + manifest = json.loads(_read_bounded(manifest_path, _MANIFEST_READ_LIMIT)) + except (OSError, ValueError, json.JSONDecodeError): + continue + if ( + not isinstance(manifest, dict) + or manifest.get("schema_version") != 1 + or manifest.get("view_id") != view_path.name + or manifest.get("state") not in _MANIFEST_STATES + ): + continue + state = manifest.get("state") + thread_locks: list[_FileLease] = [] + thread_ids = { + thread_id + for public_name in _INERT_NAMES + for path in _rollout_files(view_path / public_name) + if (thread_id := _thread_id(path)) is not None + } + resume_thread_id = manifest.get("resume_thread_id") + if isinstance(resume_thread_id, str): + thread_ids.add(resume_thread_id) + try: + for thread_id in sorted(thread_ids): + thread_locks.append( + _FileLease.acquire( + self._thread_lock_path(thread_id), + nonblocking=True, + ) + ) + except BlockingIOError: + for thread_lock in reversed(thread_locks): + thread_lock.release() + continue + lifecycle = _FileLease.acquire(self.locks_root / "lifecycle.lock") + try: + if state == "complete": + self._validate_completed_view(view_path) + shutil.rmtree(view_path) + _fsync_directory(self.views_root) + elif state in {"prepared", "failed"} and manifest.get("child_pid") is None: + manifest["state"] = "failed" + _atomic_json(manifest_path, manifest) + self._validate_pre_spawn_view( + view_path, + manifest, + allow_missing_resume=False, + ) + shutil.rmtree(view_path) + _fsync_directory(self.views_root) + elif state in {"running", "finalizing"} and manifest.get("reaped") is True: + attempt_lease = CodexInteractiveSessionLease( + store=self, + session_home=Path("/"), + launch_id=str(manifest["launch_id"]), + attempt=int(manifest["attempt"]), + current_resume_spec=( + NamedResume(resume_thread_id) + if isinstance(resume_thread_id, str) + else NoResume() + ), + view_id=view_path.name, + view_path=view_path, + manifest=manifest, + view_lease=view_lock, + inert_targets={}, + ) + manifest["state"] = "finalizing" + self._write_manifest(attempt_lease) + self._promote_view(attempt_lease) + manifest["state"] = "complete" + self._write_manifest(attempt_lease) + self._validate_completed_view(view_path) + shutil.rmtree(view_path) + _fsync_directory(self.views_root) + finally: + lifecycle.release() + for thread_lock in reversed(thread_locks): + thread_lock.release() + finally: + view_lock.release() + rows: list[dict[str, Any]] = [] + for store_name, root in ( + ("active", self.active_root), + ("archived", self.archive_root), + ): + for path in _rollout_files(root): + thread_id = _thread_id(path) + if thread_id is None: + continue + rows.append( + self._index_row( + thread_id=thread_id, + launch_id=None, + canonical_store=store_name, + relative_path=_safe_relative(path, root), + ) + ) + lifecycle = _FileLease.acquire(self.locks_root / "lifecycle.lock") + try: + deduplicated: list[dict[str, Any]] = [] + seen: set[str] = set() + for row in rows: + thread_id = str(row["session_id"]) + if thread_id not in seen: + seen.add(thread_id) + deduplicated.append(row) + _atomic_json(self.index_path, deduplicated) + finally: + lifecycle.release() + + +__all__ = [ + "CodexInteractiveSessionLease", + "CodexSessionStore", + "codex_session_index_path", +] diff --git a/src/autoskillit/execution/backends/_composite_locator.py b/src/autoskillit/execution/backends/_composite_locator.py index 42cf255139..f2aa378452 100644 --- a/src/autoskillit/execution/backends/_composite_locator.py +++ b/src/autoskillit/execution/backends/_composite_locator.py @@ -2,13 +2,8 @@ from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING - -from autoskillit.core import get_logger - -if TYPE_CHECKING: - from autoskillit.core import SessionLocator +from autoskillit.core import SessionLocator, SessionSummary, get_logger logger = get_logger(__name__) @@ -59,6 +54,25 @@ def project_log_dir_for(self, cwd: str, backend_name: str) -> Path: def session_log_path(self, cwd: str, session_id: str) -> Path | None: return self.locate_session(session_id) + def list_sessions(self, cwd: str) -> tuple[SessionSummary, ...]: + summaries: list[SessionSummary] = [] + if self._locators: + for locator in self._locators: + try: + summaries.extend(locator.list_sessions(cwd)) + except Exception: + logger.debug("session_list_failed", exc_info=True) + return tuple(summaries) + + from autoskillit.execution.backends import BACKEND_REGISTRY + + for backend_name, cls in BACKEND_REGISTRY.items(): + try: + summaries.extend(cls().session_locator().list_sessions(cwd)) + except Exception: + logger.debug("session_list_failed", backend=backend_name, exc_info=True) + return tuple(summaries) + def locator_for(self, backend_name: str) -> SessionLocator: from autoskillit.execution.backends import BACKEND_REGISTRY diff --git a/src/autoskillit/execution/backends/claude.py b/src/autoskillit/execution/backends/claude.py index 23a024aad8..cd89bd7e5c 100644 --- a/src/autoskillit/execution/backends/claude.py +++ b/src/autoskillit/execution/backends/claude.py @@ -5,6 +5,7 @@ import os import subprocess from collections.abc import Mapping, Sequence +from contextlib import AbstractContextManager, nullcontext from dataclasses import dataclass from pathlib import Path from typing import Any @@ -33,6 +34,9 @@ ClaudeEventData, ClaudeFlags, CmdSpec, + CookSessionHandle, + DirectInstall, + MarketplaceInstall, NamedResume, NoResume, OutputFormat, @@ -41,6 +45,7 @@ SessionCheckpoint, SessionEvent, SessionLocator, + SessionSummary, SkillSessionConfig, ValidatedAddDir, YAMLError, @@ -76,6 +81,16 @@ log = logging.getLogger(__name__) # noqa: TID251 — stdlib fallback: used before configure_logging(); structlog proxy would emit to stderr via import-time WriteLoggerFactory +_ORDER_GREETING_PREFIXES = ( + "Today's special:", + "Order up! Today's special:", + "Order up! The kitchen", + "Kitchen's open!", + "Table for one!", + "Fresh off the menu", + "Welcome to Good Burger, home of the Good Burger, can I take your order?", +) + __all__ = [ "ClaudeCodeBackend", "ClaudeEnvPolicy", @@ -119,6 +134,55 @@ def project_log_dir(self, cwd: str) -> Path: def session_log_path(self, cwd: str, session_id: str) -> Path | None: return claude_code_log_path(cwd, session_id) + def list_sessions(self, cwd: str) -> tuple[SessionSummary, ...]: + normalized_cwd = str(Path(cwd).expanduser().resolve(strict=False)) + index_path = self.project_log_dir(normalized_cwd) / "sessions-index.json" + try: + entries = json.loads(index_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return () + if not isinstance(entries, list): + return () + + summaries: list[SessionSummary] = [] + for entry in entries: + if not isinstance(entry, dict) or entry.get("isSidechain"): + continue + entry_cwd = entry.get("cwd") + if not isinstance(entry_cwd, str): + continue + resolved_entry_cwd = str(Path(entry_cwd).expanduser().resolve(strict=False)) + if resolved_entry_cwd != normalized_cwd: + continue + + session_id = entry.get("sessionId") + if not isinstance(session_id, str) or not session_id: + continue + first_prompt = entry.get("firstPrompt") + normalized_prompt = first_prompt if isinstance(first_prompt, str) else "" + summary = entry.get("summary") + git_branch = entry.get("gitBranch") + modified = entry.get("modified") + summaries.append( + SessionSummary( + backend_name=AGENT_BACKEND_CLAUDE_CODE, + session_id=session_id, + launch_id=None, + cwd=resolved_entry_cwd, + first_prompt=normalized_prompt, + summary=summary if isinstance(summary, str) else "", + git_branch=git_branch if isinstance(git_branch, str) else None, + modified=modified if isinstance(modified, str) else None, + is_sidechain=False, + session_type_hint=( + "order" + if normalized_prompt.startswith(_ORDER_GREETING_PREFIXES) + else "cook" + ), + ) + ) + return tuple(summaries) + @dataclass(frozen=True, slots=True) class ClaudeStreamParser: @@ -696,7 +760,13 @@ def build_food_truck_cmd( return CmdSpec(cmd=tuple(cmd), env=spec.env, is_resume=bool(resume_session_id)) - def validate_session_layout(self, session_dir: Path) -> list[str]: + def validate_session_layout( + self, + session_dir: Path, + *, + project_dir: Path | None = None, + ) -> list[str]: + del project_dir errors: list[str] = [] skills_dir = session_dir / ClaudeDirectoryConventions.ADD_DIR_SKILLS_SUBDIR if not skills_dir.is_dir(): @@ -783,11 +853,41 @@ def list_plugins(self) -> list[dict[str, Any]]: log.warning("list_plugins() failed", exc_info=True) return [] - def ensure_pre_launch(self) -> list[str]: + def validate_interactive_invocation(self, spec: CmdSpec) -> list[str]: + del spec + return [] + + def ensure_pre_launch(self, *, session_dir: Path | None = None) -> list[str]: + del session_dir return [] + def recover_cook_history(self) -> None: + return None + + def cook_session_context( + self, + *, + session_home: Path, + launch_id: str, + attempt: int, + current_resume_spec: ResumeSpec, + ) -> AbstractContextManager[CookSessionHandle]: + del session_home, launch_id, attempt, current_resume_spec + return nullcontext( + CookSessionHandle( + view_id="", + pass_fds=(), + _record_spawn=_ignore_child_identity, + _record_reaped=_ignore_child_identity, + ) + ) + def build_inspector_cmd(self, prompt: str, *, model: str = "") -> CmdSpec: if not self.capabilities.inspector_capable: raise CapabilityNotSupportedError("inspector_capable", self.name) msg = "inspector_capable is True but build_inspector_cmd has no implementation" raise AssertionError(msg) + + +def _ignore_child_identity(pid: int, pgid: int) -> None: + del pid, pgid diff --git a/src/autoskillit/execution/backends/codex.py b/src/autoskillit/execution/backends/codex.py index 854ccfb733..d89c679981 100644 --- a/src/autoskillit/execution/backends/codex.py +++ b/src/autoskillit/execution/backends/codex.py @@ -2,13 +2,22 @@ from __future__ import annotations +import hashlib import json +import math import os +import selectors import shutil +import signal +import sqlite3 +import stat import subprocess +import threading +import time import tomllib -from collections.abc import Mapping, Sequence -from dataclasses import dataclass +from collections.abc import Callable, Mapping, Sequence +from contextlib import AbstractContextManager +from dataclasses import dataclass, field from enum import StrEnum, unique from pathlib import Path from typing import Any @@ -22,15 +31,19 @@ AUTOSKILLIT_APPLICABLE_GUARDS, AUTOSKILLIT_PRIVATE_ENV_VARS, AUTOSKILLIT_WRITE_GUARD_TOOL_NAMES, + CODEX_COOK_RESERVED_ENV_VARS, CODEX_EFFORT_MAPPING, CODEX_INTERACTIVE_REQUIRED_ENV, CODEX_MCP_ENV_FORWARD_VARS, CODEX_MODEL_ALIASES, + CODEX_SESSIONS_SUBDIR, + CODEX_STARTUP_TRACE_ENV_VAR, FOOD_TRUCK_TOOL_TAGS_ENV_VAR, MCP_CLIENT_BACKEND_ENV_VAR, ORCHESTRATOR_SESSION_REQUIRED_ENV, PROVIDER_PROFILE_ENV_VAR, RESUME_SESSION_BASELINE_KEYS, + SESSION_ADD_DIR_SUBDIR, SESSION_TYPE_ORCHESTRATOR, SESSION_TYPE_SKILL, SKILL_SESSION_REQUIRED_ENV, @@ -41,13 +54,19 @@ ClaudeDirectoryConventions, CmdSpec, CodexEventType, + CookSessionHandle, + DirectInstall, + HookTrustPolicy, + MarketplaceInstall, NamedResume, NoResume, + ObserverStatus, OutputFormat, PluginSource, ResumeSpec, SessionCheckpoint, SessionLocator, + SessionSummary, SkillSessionConfig, ValidatedAddDir, atomic_write, @@ -78,8 +97,9 @@ _format_toml_value, ensure_codex_mcp_registered, ) -from autoskillit.execution.backends._codex_hooks import sync_hooks_to_codex_config from autoskillit.execution.backends._codex_parse import CodexResultParser, CodexStreamParser +from autoskillit.execution.backends._codex_prelaunch import codex_prelaunch_transaction +from autoskillit.execution.backends._codex_session_storage import CodexSessionStore def _codex_home_from_plugin_source(plugin_source: PluginSource | None) -> str | None: @@ -95,6 +115,7 @@ def _codex_home_from_plugin_source(plugin_source: PluginSource | None) -> str | "CodexEnvPolicy", "CodexFlags", "CodexSessionLocator", + "CodexStateReadinessProbe", "NON_VARIADIC_CODEX_FLAGS", "VARIADIC_CODEX_FLAGS", "ensure_codex_mcp_registered", @@ -112,6 +133,7 @@ class CodexFlags(StrEnum): ADD_DIR = "--add-dir" RESUME_SUBCOMMAND = "resume" CONFIG_OVERRIDE = "-c" + PROFILE = "--profile" DANGEROUSLY_BYPASS = "--dangerously-bypass-approvals-and-sandbox" DANGEROUSLY_BYPASS_HOOK_TRUST = "--dangerously-bypass-hook-trust" @@ -131,6 +153,7 @@ class CodexFlags(StrEnum): { CodexFlags.DANGEROUSLY_BYPASS, CodexFlags.MODEL_SHORT, + CodexFlags.PROFILE, } ) @@ -142,6 +165,7 @@ class CodexFlags(StrEnum): CodexFlags.SANDBOX, CodexFlags.MODEL, CodexFlags.MODEL_SHORT, + CodexFlags.PROFILE, CodexFlags.RESUME_SUBCOMMAND, CodexFlags.DANGEROUSLY_BYPASS, CodexFlags.DANGEROUSLY_BYPASS_HOOK_TRUST, @@ -161,6 +185,8 @@ class CodexFlags(StrEnum): CODEX_ENV_PREFIX_DENYLIST: tuple[str, ...] = ("CLAUDE_CODE_",) _IMAGE_GENERATION_DISABLED = "features.image_generation=false" +_CODEX_HOME_ENV_VAR = "CODEX_HOME" +_CODEX_SQLITE_HOME_ENV_VAR = "CODEX_SQLITE_HOME" def _codex_exec_base( @@ -183,6 +209,159 @@ def _codex_exec_base( return cmd +def _should_bypass_hook_trust( + policy: HookTrustPolicy, + *, + automated_session: bool, +) -> bool: + """Translate backend hook policy at the command-construction boundary.""" + if automated_session: + return True + match policy: + case HookTrustPolicy.AUTOMATED: + return True + case HookTrustPolicy.REVIEW_EACH_SESSION: + return False + raise AssertionError(f"Unhandled hook trust policy: {policy!r}") + + +_CODEX_STATE_READINESS_COMMIT = "ad65f016ed0c91992fb175fa881a373cc460dd2a" + + +@dataclass(frozen=True, slots=True) +class _StateReadinessDef: + database_name: str + upstream_commit: str + + +_SUPPORTED_STATE_CONTRACTS = { + "codex-cli 0.145.0": _StateReadinessDef( + database_name="state_5.sqlite", + upstream_commit=_CODEX_STATE_READINESS_COMMIT, + ) +} + + +@dataclass(frozen=True, slots=True) +class CodexStateReadinessProbe: + """Read the version-mapped disposable Codex state database without mutation.""" + + codex_version: str + sqlite_home: Path + poll_interval_seconds: float = 0.05 + _clock: Callable[[], float] = field(default=time.monotonic, repr=False) + _sleep: Callable[[float], None] = field(default=time.sleep, repr=False) + + def __post_init__(self) -> None: + if not math.isfinite(self.poll_interval_seconds) or self.poll_interval_seconds <= 0: + raise ValueError("poll_interval_seconds must be finite and positive") + object.__setattr__(self, "sqlite_home", Path(self.sqlite_home)) + + @property + def database_path(self) -> Path | None: + """Return the exact database path for a supported Codex version.""" + compatibility = _SUPPORTED_STATE_CONTRACTS.get(self.codex_version) + return None if compatibility is None else self.sqlite_home / compatibility.database_name + + @property + def upstream_commit(self) -> str | None: + """Return the source revision defining the probed schema contract.""" + compatibility = _SUPPORTED_STATE_CONTRACTS.get(self.codex_version) + return None if compatibility is None else compatibility.upstream_commit + + def check(self) -> ObserverStatus: + """Perform one zero-wait, read-only readiness observation.""" + database_path = self.database_path + if database_path is None: + return ObserverStatus.UNSUPPORTED_VERSION + try: + path_stat = database_path.lstat() + except FileNotFoundError: + return ObserverStatus.ABSENT + except OSError: + return ObserverStatus.CORRUPT + if not stat.S_ISREG(path_stat.st_mode): + return ObserverStatus.CORRUPT + + connection: sqlite3.Connection | None = None + try: + uri = f"{database_path.resolve(strict=True).as_uri()}?mode=ro" + connection = sqlite3.connect( + uri, + uri=True, + timeout=0.0, + isolation_level=None, + ) + connection.execute("PRAGMA query_only = ON") + connection.execute("PRAGMA busy_timeout = 0") + columns = { + row[1] + for row in connection.execute("PRAGMA table_info(backfill_state)") + if len(row) > 1 and isinstance(row[1], str) + } + if not {"id", "status"}.issubset(columns): + return ObserverStatus.SCHEMA_CHANGED + row = connection.execute("SELECT status FROM backfill_state WHERE id = 1").fetchone() + if row is None or len(row) != 1 or not isinstance(row[0], str): + return ObserverStatus.INCOMPLETE + return ObserverStatus.READY if row[0] == "complete" else ObserverStatus.INCOMPLETE + except sqlite3.OperationalError as exc: + message = str(exc).lower() + if "locked" in message or "busy" in message: + return ObserverStatus.LOCKED + if "no such table" in message or "no such column" in message: + return ObserverStatus.SCHEMA_CHANGED + return ObserverStatus.CORRUPT + except (OSError, sqlite3.DatabaseError, ValueError): + return ObserverStatus.CORRUPT + finally: + if connection is not None: + connection.close() + + def wait( + self, + *, + timeout_seconds: float, + cancelled: Callable[[], bool] | None = None, + ) -> ObserverStatus: + """Poll until ready, a terminal adapter failure, timeout, or cancellation.""" + if not math.isfinite(timeout_seconds) or timeout_seconds < 0: + raise ValueError("timeout_seconds must be finite and non-negative") + is_cancelled = cancelled or (lambda: False) + deadline = self._clock() + timeout_seconds + while True: + if is_cancelled(): + return ObserverStatus.CANCELLED + if self._clock() >= deadline: + return ObserverStatus.TIMEOUT + status = self.check() + if status is ObserverStatus.READY: + return status + if status in { + ObserverStatus.CORRUPT, + ObserverStatus.SCHEMA_CHANGED, + ObserverStatus.UNSUPPORTED_VERSION, + }: + return status + remaining = deadline - self._clock() + if remaining <= 0: + return ObserverStatus.TIMEOUT + self._sleep(min(self.poll_interval_seconds, remaining)) + + +def _merge_caller_env_extras( + target: dict[str, str], + extras: Mapping[str, str] | None, + *, + denylist: frozenset[str] = frozenset(), +) -> None: + """Merge untrusted caller extras without admitting cook-owned controls.""" + if extras is None: + return + protected = denylist | CODEX_COOK_RESERVED_ENV_VARS | {CODEX_STARTUP_TRACE_ENV_VAR} + target.update({key: value for key, value in extras.items() if key not in protected}) + + def _codex_exec_extras( *, session_type: str, @@ -232,7 +411,11 @@ def build_env( and not any(k.startswith(p) for p in self.denylist_prefixes) } if extras is not None: - out.update(extras) + out.update( + {key: value for key, value in extras.items() if key != CODEX_STARTUP_TRACE_ENV_VAR} + ) + # This is an outer-cook control signal, never child or nested-session state. + out.pop(CODEX_STARTUP_TRACE_ENV_VAR, None) if required is not None: missing = required - frozenset(out) if missing: @@ -240,113 +423,21 @@ def build_env( return out -_SESSION_START_TYPES: frozenset[str] = frozenset( - { - CodexEventType.THREAD_STARTED.value, - CodexEventType.SESSION_META.value, - } -) - - @dataclass(frozen=True, slots=True) class CodexSessionLocator(SessionLocator): - codex_home: Path | None = None + store_root: Path | None = None + index_path: Path | None = None + + def _store(self) -> CodexSessionStore: + return CodexSessionStore( + log_dir=self.store_root or default_log_dir(), + index_path=self.index_path, + ) def locate_session(self, session_id: str) -> Path | None: if not session_id or session_id.startswith(("no_session_", "crashed_")): return None - - candidates: list[Path] = [] - # 1. Permanent storage (symlink target) — checked first because - # ephemeral CODEX_HOME may be cleaned up by the time we search - candidates.append(default_log_dir() / "codex-sessions") - # 2. Explicit codex_home or CODEX_HOME env var - if self.codex_home is not None: - candidates.append(self.codex_home / "sessions") - else: - env_home = os.environ.get("CODEX_HOME") - if env_home: - candidates.append(Path(env_home) / "sessions") - # 3. Default Codex home (~/.codex/sessions/) - candidates.append(Path.home() / ".codex" / "sessions") - - seen: set[str] = set() - for candidate in candidates: - try: - resolved = str(candidate.resolve()) if candidate.exists() else str(candidate) - except OSError: - continue - if resolved in seen or not candidate.exists(): - continue - seen.add(resolved) - result = self._search_tree(candidate, session_id) - if result is not None: - return result - return None - - def _search_tree(self, sessions_dir: Path, thread_id: str) -> Path | None: - """Walk YYYY/MM/DD/ date tree for rollout-*.jsonl matching thread_id.""" - try: - year_dirs = sorted(sessions_dir.iterdir(), reverse=True) - except OSError: - return None - for year_dir in year_dirs: - if not year_dir.is_dir(): - continue - try: - month_dirs = sorted(year_dir.iterdir(), reverse=True) - except OSError: - continue - for month_dir in month_dirs: - if not month_dir.is_dir(): - continue - try: - day_dirs = sorted(month_dir.iterdir(), reverse=True) - except OSError: - continue - for day_dir in day_dirs: - if not day_dir.is_dir(): - continue - try: - entries = list(day_dir.iterdir()) - except OSError: - continue - for entry in entries: - if ( - entry.is_file() - and entry.name.startswith("rollout-") - and entry.name.endswith(".jsonl") - ): - if self._file_matches_thread(entry, thread_id): - return entry - return None - - @staticmethod - def _file_matches_thread(path: Path, thread_id: str) -> bool: - """Check if a rollout NDJSON file's session-start event matches thread_id. - - Only reads until the first parseable NDJSON object — the session-start - event is always the first event in a Codex rollout file. - """ - try: - with open(path, encoding="utf-8") as f: - for line in f: - line = line.strip() - if not line: - continue - try: - obj = json.loads(line) - except json.JSONDecodeError: - return False - if isinstance(obj, dict) and obj.get("type") in _SESSION_START_TYPES: - # thread.started uses top-level thread_id; - # session_meta uses payload.id - tid = obj.get("thread_id") or obj.get("payload", {}).get("id", "") - return tid == thread_id - return False - except OSError: - return False - return False + return self._store().locate_session(session_id) def read_session(self, path: Path) -> list[dict]: """Read and parse a Codex session log file. @@ -377,57 +468,361 @@ def read_session(self, path: Path) -> list[dict]: return result def project_log_dir(self, cwd: str) -> Path: # cwd unused; Codex uses a global session store - return default_log_dir() / "codex-sessions" + return (self.store_root or default_log_dir()) / CODEX_SESSIONS_SUBDIR def session_log_path(self, cwd: str, session_id: str) -> Path | None: if not session_id or session_id.startswith(("no_session_", "crashed_")): return None return self.locate_session(session_id) + def list_sessions(self, cwd: str) -> tuple[SessionSummary, ...]: + return self._store().read_index(cwd) + + +_CODEX_PROBE_TIMEOUT_SECONDS = 15.0 +_CODEX_PROBE_STREAM_LIMIT = 64 * 1024 +_CODEX_VALIDATION_CACHE_LIMIT = 128 +_CODEX_VALIDATION_CACHE: dict[str, None] = {} +_CODEX_VALIDATION_CACHE_GUARD = threading.Lock() + + +@dataclass(frozen=True, slots=True) +class _BoundedProbeResult: + returncode: int | None + stdout: bytes + stderr: bytes + failure: str | None = None + + +def _terminate_probe(process: subprocess.Popen[bytes]) -> None: + try: + os.killpg(process.pid, signal.SIGKILL) + except OSError: + try: + process.kill() + except OSError: + pass + try: + process.wait(timeout=2) + except (OSError, subprocess.TimeoutExpired): + pass + for stream in (process.stdout, process.stderr): + if stream is not None: + stream.close() + -def _validate_codex_config() -> list[str]: - """Run codex doctor --json and check config.load status.""" +def _run_bounded_codex_probe( + command: tuple[str, ...], + *, + env: Mapping[str, str], + cwd: str, +) -> _BoundedProbeResult: + """Run a normal Codex config-load probe with hard time and byte bounds.""" try: - result = subprocess.run( - ["codex", "doctor", "--json"], - capture_output=True, - text=True, - timeout=20, + process = subprocess.Popen( + command, + cwd=cwd, + env=dict(env), + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + start_new_session=True, + ) + except OSError as exc: + return _BoundedProbeResult( + returncode=None, + stdout=b"", + stderr=b"", + failure=f"binary unavailable ({type(exc).__name__})", ) + + selector: selectors.BaseSelector | None = None + output = {"stdout": bytearray(), "stderr": bytearray()} + deadline = time.monotonic() + _CODEX_PROBE_TIMEOUT_SECONDS + try: + assert process.stdout is not None + assert process.stderr is not None + selector_factory = selectors.DefaultSelector + selector = selector_factory() + selector.register(process.stdout, selectors.EVENT_READ, "stdout") + selector.register(process.stderr, selectors.EVENT_READ, "stderr") + while selector.get_map() or process.poll() is None: + remaining = deadline - time.monotonic() + if remaining <= 0: + _terminate_probe(process) + return _BoundedProbeResult( + returncode=None, + stdout=bytes(output["stdout"]), + stderr=bytes(output["stderr"]), + failure="timed out", + ) + if not selector.get_map(): + time.sleep(min(0.01, remaining)) + continue + events = selector.select(timeout=min(0.1, remaining)) + for key, _ in events: + stream_name = key.data + try: + file_descriptor = ( + key.fileobj if isinstance(key.fileobj, int) else key.fileobj.fileno() + ) + chunk = os.read(file_descriptor, 8192) + except OSError: + chunk = b"" + if not chunk: + selector.unregister(key.fileobj) + continue + target = output[stream_name] + target.extend(chunk) + if len(target) > _CODEX_PROBE_STREAM_LIMIT: + del target[_CODEX_PROBE_STREAM_LIMIT:] + _terminate_probe(process) + return _BoundedProbeResult( + returncode=None, + stdout=bytes(output["stdout"]), + stderr=bytes(output["stderr"]), + failure=f"{stream_name} exceeded {_CODEX_PROBE_STREAM_LIMIT} bytes", + ) + returncode = process.wait(timeout=max(0.0, deadline - time.monotonic())) except subprocess.TimeoutExpired: - logger.warning("codex_doctor_timeout") - return [] - except OSError: - logger.warning("codex_doctor_unavailable", exc_info=True) - return [] + _terminate_probe(process) + return _BoundedProbeResult( + returncode=None, + stdout=bytes(output["stdout"]), + stderr=bytes(output["stderr"]), + failure="timed out while reaping", + ) + except BaseException: + _terminate_probe(process) + raise + finally: + if selector is not None: + selector.close() + for stream in (process.stdout, process.stderr): + if stream is not None: + stream.close() + return _BoundedProbeResult( + returncode=returncode, + stdout=bytes(output["stdout"]), + stderr=bytes(output["stderr"]), + ) + + +def _probe_diagnostic(result: _BoundedProbeResult) -> str: + """Return bounded, non-content diagnostics safe for configs containing secrets.""" + stdout_digest = hashlib.sha256(result.stdout).hexdigest()[:16] + stderr_digest = hashlib.sha256(result.stderr).hexdigest()[:16] + return ( + f"stdout_bytes={len(result.stdout)} stdout_sha256={stdout_digest} " + f"stderr_bytes={len(result.stderr)} stderr_sha256={stderr_digest}" + ) + + +def _mcp_inventory_entries(document: Any) -> list[dict[str, Any]] | None: + if isinstance(document, list): + return [entry for entry in document if isinstance(entry, dict)] + if not isinstance(document, dict): + return None + for key in ("servers", "mcp_servers"): + value = document.get(key) + if isinstance(value, list): + return [entry for entry in value if isinstance(entry, dict)] + if isinstance(value, dict): + return [ + {"name": name, **entry} + for name, entry in value.items() + if isinstance(name, str) and isinstance(entry, dict) + ] + return None + + +def _string_array(value: Any) -> list[str] | None: + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + return None + return value - if getattr(result, "returncode", -1) != 0: - logger.warning("codex_doctor_nonzero_exit", returncode=getattr(result, "returncode", None)) - return [] - stdout = getattr(result, "stdout", None) or "" +def _validate_codex_mcp_inventory(stdout: bytes, config_bytes: bytes) -> list[str]: try: - doc = json.loads(stdout) - except json.JSONDecodeError: - logger.warning("codex_doctor_json_parse_failed") - return [] + document = json.loads(stdout.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return ["Codex MCP validation returned malformed JSON"] + try: + config = tomllib.loads(config_bytes.decode("utf-8")) + except (UnicodeDecodeError, tomllib.TOMLDecodeError): + return ["Final Codex config bytes are not valid UTF-8 TOML"] + + expected = config.get("mcp_servers", {}).get("autoskillit") + if not isinstance(expected, dict): + return ["Final Codex config is missing mcp_servers.autoskillit"] + entries = _mcp_inventory_entries(document) + if entries is None: + return ["Codex MCP validation JSON has no server inventory"] + matches = [entry for entry in entries if entry.get("name") == "autoskillit"] + if len(matches) != 1: + return [ + "Codex MCP validation expected exactly one enabled autoskillit server; " + f"found {len(matches)}" + ] + actual = matches[0] + if actual.get("enabled") is False: + return ["Codex MCP validation reports autoskillit as disabled"] + transport = actual.get("transport") + if not isinstance(transport, dict): + transport = actual + errors: list[str] = [] + if transport.get("type", "stdio") != "stdio": + errors.append("Codex MCP autoskillit transport is not stdio") + if transport.get("command") != expected.get("command"): + errors.append("Codex MCP autoskillit command does not match final config") + expected_args = _string_array(expected.get("args", [])) + actual_args = _string_array(transport.get("args", [])) + if expected_args is None: + errors.append("Final Codex config autoskillit args are not an array of strings") + if actual_args is None: + errors.append("Codex MCP autoskillit args are not an array of strings") + elif expected_args is not None and actual_args != expected_args: + errors.append("Codex MCP autoskillit args do not match final config") + expected_env_vars = _string_array(expected.get("env_vars", [])) + actual_env_vars = _string_array(transport.get("env_vars", [])) + if expected_env_vars is None: + errors.append("Final Codex config autoskillit env_vars are not an array of strings") + if actual_env_vars is None: + errors.append("Codex MCP autoskillit env_vars are not an array of strings") + elif expected_env_vars is not None and set(actual_env_vars) != set(expected_env_vars): + errors.append("Codex MCP autoskillit env_vars do not match final config") + for key in ("startup_timeout_sec", "tool_timeout_sec"): + if key in expected and actual.get(key) != expected[key]: + errors.append(f"Codex MCP autoskillit {key} does not match final config") + return errors + + +def _validation_digest( + command: tuple[str, ...], + *, + env: Mapping[str, str], + cwd: str, + config_bytes: bytes, +) -> str: + digest = hashlib.sha256() + for value in command: + digest.update(value.encode("utf-8")) + digest.update(b"\0") + for key, value in sorted(env.items()): + digest.update(key.encode("utf-8")) + digest.update(b"=") + digest.update(value.encode("utf-8")) + digest.update(b"\0") + digest.update(cwd.encode("utf-8")) + digest.update(b"\0") + digest.update(config_bytes) + return digest.hexdigest() + + +def _is_cached_validation(digest: str) -> bool: + with _CODEX_VALIDATION_CACHE_GUARD: + if digest not in _CODEX_VALIDATION_CACHE: + return False + _CODEX_VALIDATION_CACHE[digest] = _CODEX_VALIDATION_CACHE.pop(digest) + return True - checks = doc.get("checks", {}) - config_check = checks.get("config.load", {}) - status = config_check.get("status") - if status is not None and status != "ok": - summary = config_check.get("summary", "unknown config error") - remediation = config_check.get("remediation", "") - parts = [f"Codex config validation failed: {summary}"] - if remediation: - parts.append(f"Remediation: {remediation}") - parts.append("Run 'codex doctor' for full diagnostics.") - return [" ".join(parts)] +def _cache_validation(digest: str) -> None: + with _CODEX_VALIDATION_CACHE_GUARD: + _CODEX_VALIDATION_CACHE.pop(digest, None) + _CODEX_VALIDATION_CACHE[digest] = None + while len(_CODEX_VALIDATION_CACHE) > _CODEX_VALIDATION_CACHE_LIMIT: + del _CODEX_VALIDATION_CACHE[next(iter(_CODEX_VALIDATION_CACHE))] + +def _validate_mcp_probe( + command: tuple[str, ...], + *, + env: Mapping[str, str], + cwd: str, + config_bytes: bytes, +) -> list[str]: + digest = _validation_digest(command, env=env, cwd=cwd, config_bytes=config_bytes) + if _is_cached_validation(digest): + return [] + result = _run_bounded_codex_probe(command, env=env, cwd=cwd) + if result.failure is not None: + return [f"Codex MCP validation {result.failure}; {_probe_diagnostic(result)}"] + if result.returncode != 0: + return [ + f"Codex MCP validation exited with status {result.returncode}; " + f"{_probe_diagnostic(result)}" + ] + errors = _validate_codex_mcp_inventory(result.stdout, config_bytes) + if errors: + diagnostic = _probe_diagnostic(result) + return [f"{error}; {diagnostic}" for error in errors] + _cache_validation(digest) return [] +def _validate_global_codex_home( + source_codex_home: Path, + *, + config_path: Path, +) -> list[str]: + try: + config_bytes = config_path.read_bytes() + except OSError as exc: + return [f"Failed to read final Codex config: {type(exc).__name__}: {exc}"] + sqlite_override = f"sqlite_home={_format_toml_value(str(source_codex_home))}" + command = ( + "codex", + CodexFlags.CONFIG_OVERRIDE, + sqlite_override, + "mcp", + "list", + CodexFlags.JSON, + ) + env = dict(os.environ) + for key in CODEX_COOK_RESERVED_ENV_VARS: + env[key] = str(source_codex_home) + return _validate_mcp_probe( + command, + env=env, + cwd=str(source_codex_home), + config_bytes=config_bytes, + ) + + +def _validate_inert_rollout_paths( + generated_home: Path, +) -> tuple[list[str], tuple[tuple[str, str, int, int], ...]]: + errors: list[str] = [] + fingerprint: list[tuple[str, str, int, int]] = [] + for name in ("sessions", "archived_sessions"): + public_path = generated_home / name + if not public_path.is_symlink(): + errors.append(f"{public_path} must be an inert pre-view symlink") + continue + try: + target = public_path.resolve(strict=True) + stat = target.stat() + except OSError as exc: + errors.append(f"{public_path} has an invalid target: {type(exc).__name__}: {exc}") + continue + if not target.is_relative_to(generated_home): + errors.append(f"{public_path} escapes the generated home") + continue + if not target.is_dir(): + errors.append(f"{public_path} target is not a directory") + continue + try: + entries = list(target.iterdir()) + except OSError as exc: + errors.append(f"{public_path} target is unreadable: {type(exc).__name__}: {exc}") + continue + if entries: + errors.append(f"{public_path} inert target is not empty") + fingerprint.append((name, os.readlink(public_path), stat.st_dev, stat.st_ino)) + return errors, tuple(fingerprint) + + def _generate_agent_tomls(session_dir: Path) -> int: agents_src = pkg_root() / "agents" out_dir = session_dir / "agents" @@ -521,8 +916,61 @@ def _register_agent_tomls(session_dir: Path) -> int: return len(registrations) // 4 +def _materialize_profile_skills( + session_dir: Path, + *, + source_codex_home: Path | None = None, +) -> int: + """Symlink source-home profile skills into a generated Codex home. + + Scans the selected Codex home's ``skills`` for subdirectories containing + SKILL.md. Each is symlinked into session_dir/skills/. Falls back + to shutil.copytree if symlink creation fails. Subdirectories without + SKILL.md are skipped. Returns the number of skills materialized. + """ + source_home = Path.home() / ".codex" if source_codex_home is None else Path(source_codex_home) + profile_skills_root = source_home / "skills" + if not profile_skills_root.is_dir(): + return 0 + count = 0 + skills_base = session_dir / "skills" + skills_base.mkdir(parents=True, exist_ok=True) + entries = list(profile_skills_root.iterdir()) + for entry in entries: + if not entry.is_dir() or not (entry / "SKILL.md").is_file(): + continue + target = skills_base / entry.name + if target.exists() or target.is_symlink(): + continue + try: + target.symlink_to(entry.resolve()) + except OSError: + logger.debug( + "codex_profile_skill_symlink_failed_using_copytree", + skill=entry.name, + exc_info=True, + ) + shutil.copytree(entry, target) + count += 1 + return count + + @dataclass(frozen=True, slots=True) class CodexBackend(BackendCmdBuilderBase): + source_codex_home: Path | None = None + + def __post_init__(self) -> None: + source_home = ( + Path.home() / ".codex" + if self.source_codex_home is None + else Path(self.source_codex_home) + ) + object.__setattr__( + self, + "source_codex_home", + source_home.expanduser().resolve(strict=False), + ) + def _binary(self) -> str: return "codex" @@ -565,7 +1013,7 @@ def capabilities(self) -> BackendCapabilities: supports_tool_list_changed=False, required_skill_fields=frozenset({"name", "description"}), required_session_files=frozenset({"config.toml"}), - session_dir_symlinks=frozenset({"auth.json", ".env", "sessions"}), + session_dir_symlinks=frozenset({"sessions", "archived_sessions"}), applicable_guards=frozenset({"write_guard"}), # Codex uses run_cmd instead of Write/Edit — those tools don't exist in Codex write_guard_tool_names=frozenset({"apply_patch", "Bash", "run_cmd"}), @@ -598,12 +1046,14 @@ def capabilities(self) -> BackendCapabilities: github_api_callable=False, skill_sigil="$", session_dir_persistent=True, + cook_startup_observer_capable=True, supports_model_invocation_gating=False, unnegotiated_tool_result_token_limit=( CODEX_RECIPE_DELIVERY_BUDGET.ordinary_omitted_result_token_limit ), protected_recipe_delivery_capable=False, recipe_delivery_budget=CODEX_RECIPE_DELIVERY_BUDGET, + hook_trust_policy=HookTrustPolicy.REVIEW_EACH_SESSION, ) @property @@ -611,6 +1061,7 @@ def conventions(self) -> BackendConventions: return BackendConventions( skills_subdir=ClaudeDirectoryConventions.PLUGIN_DIR_SKILLS_SUBDIR, project_local_skill_search_dirs=(".codex/skills", ".agents/skills"), + persistent_session_root_subdir=Path(CODEX_SESSIONS_SUBDIR), skill_sigil=self.capabilities.skill_sigil, ) @@ -628,7 +1079,9 @@ def env_policy(self) -> CodexEnvPolicy: return CodexEnvPolicy(denylist_prefixes=self.capabilities.env_denylist_prefixes) def session_locator(self) -> CodexSessionLocator: - return CodexSessionLocator() + return CodexSessionLocator( + store_root=default_log_dir(), + ) def write_tool_names(self) -> frozenset[str]: return frozenset({"file_change"}) @@ -674,8 +1127,7 @@ def build_headless_cmd( cmd.append(prompt) filtered_base = {k: v for k, v in os.environ.items() if k not in _HEADLESS_EXCLUSIVE_VARS} headless_extras = _codex_exec_extras(session_type="") - if env_extras: - headless_extras.update(env_extras) + _merge_caller_env_extras(headless_extras, env_extras) env = self.env_policy().build_env(filtered_base, extras=headless_extras) return CmdSpec(cmd=tuple(cmd), env=env) @@ -778,10 +1230,11 @@ def build_skill_session_cmd( extras[MCP_CLIENT_BACKEND_ENV_VAR] = AGENT_BACKEND_CODEX extras[FOOD_TRUCK_TOOL_TAGS_ENV_VAR] = "" extras["AUTOSKILLIT_SKILL_NAME"] = extract_skill_name(skill_command) or "" - if provider_extras: - for k, v in provider_extras.items(): - if k not in _SKILL_SESSION_EXTRAS_DENYLIST: - extras[k] = v + _merge_caller_env_extras( + extras, + provider_extras, + denylist=_SKILL_SESSION_EXTRAS_DENYLIST, + ) if profile_name: extras[PROVIDER_PROFILE_ENV_VAR] = profile_name extras["AUTOSKILLIT_COMPLETION_MARKER"] = completion_marker @@ -810,7 +1263,10 @@ def build_skill_session_cmd( _net_overrides.append("sandbox_workspace_write.network_access=true") cmd = _codex_exec_base( sandbox=sandbox_mode, - bypass_hook_trust=self.capabilities.mcp_config_capable, + bypass_hook_trust=_should_bypass_hook_trust( + self.capabilities.hook_trust_policy, + automated_session=True, + ), extra_overrides=_net_overrides, ) if model: @@ -894,10 +1350,11 @@ def build_food_truck_cmd( extras[FOOD_TRUCK_TOOL_TAGS_ENV_VAR] = "" if completion_marker: extras["AUTOSKILLIT_COMPLETION_MARKER"] = completion_marker - if env_extras: - for k, v in env_extras.items(): - if k not in _PROVIDER_EXTRAS_BASE_DENYLIST: - extras[k] = v + _merge_caller_env_extras( + extras, + env_extras, + denylist=_PROVIDER_EXTRAS_BASE_DENYLIST, + ) if projected_codex_home is not None: extras["CODEX_HOME"] = projected_codex_home if exit_after_stop_delay_ms: @@ -919,7 +1376,10 @@ def build_food_truck_cmd( cmd = _codex_exec_base( sandbox="read-only", extra_overrides=["web_search=disabled"], - bypass_hook_trust=self.capabilities.mcp_config_capable, + bypass_hook_trust=_should_bypass_hook_trust( + self.capabilities.hook_trust_policy, + automated_session=True, + ), ) if model: cmd += [CodexFlags.MODEL, self.translate_model(model)] @@ -945,6 +1405,7 @@ def build_interactive_cmd( model: str | None = None, plugin_source: PluginSource | None = None, add_dirs: Sequence[Path | str | ValidatedAddDir] = (), + generated_home: Path | None = None, resume_spec: ResumeSpec = NoResume(), system_prompt: str | None = None, env_extras: Mapping[str, str] | None = None, @@ -957,6 +1418,14 @@ def build_interactive_cmd( extra={"tools": list(tools)}, ) builder = CmdBuilder("codex") + if _should_bypass_hook_trust( + self.capabilities.hook_trust_policy, + automated_session=False, + ): + builder.mode_flag(CodexFlags.DANGEROUSLY_BYPASS_HOOK_TRUST) + selected_profile = (env_extras or {}).get(PROVIDER_PROFILE_ENV_VAR, "") + if selected_profile: + builder.kv_flag(CodexFlags.PROFILE, selected_profile) match resume_spec: case NoResume(): builder.mode_flag(CodexFlags.DANGEROUSLY_BYPASS) @@ -982,6 +1451,17 @@ def build_interactive_cmd( CodexFlags.CONFIG_OVERRIDE, f"developer_instructions={_format_toml_value(developer_instructions)}", ) + if generated_home is not None: + supplied_home = Path(generated_home) + if not supplied_home.is_absolute(): + raise ValueError("generated_home must be absolute") + generated_home = supplied_home.expanduser().resolve(strict=False) + if supplied_home != generated_home: + raise ValueError("generated_home must already be canonical") + builder.kv_flag( + CodexFlags.CONFIG_OVERRIDE, + f"sqlite_home={_format_toml_value(str(generated_home))}", + ) if initial_prompt is not None: builder.positional(initial_prompt) for d in add_dirs: @@ -999,15 +1479,17 @@ def build_interactive_cmd( FOOD_TRUCK_TOOL_TAGS_ENV_VAR: "", } ) - if env_extras: - merged_extras.update(env_extras) - if add_dirs: - merged_extras.setdefault("CODEX_HOME", str(add_dirs[0])) + _merge_caller_env_extras(merged_extras, env_extras) + if generated_home is not None: + for reserved_key in CODEX_COOK_RESERVED_ENV_VARS: + merged_extras[reserved_key] = str(generated_home) else: projected_codex_home = _codex_home_from_plugin_source(plugin_source) if projected_codex_home is not None: merged_extras.setdefault("CODEX_HOME", projected_codex_home) effective_required = CODEX_INTERACTIVE_REQUIRED_ENV | (required_env or frozenset()) + if generated_home is not None: + effective_required |= CODEX_COOK_RESERVED_ENV_VARS env = CodexEnvPolicy().build_env( base_env, extras=merged_extras, required=effective_required ) @@ -1039,8 +1521,7 @@ def build_resume_cmd( resume_extras = _codex_exec_extras( session_type="", include_session_baseline=True, include_agent_backend_flat=True ) - if env_extras: - resume_extras.update(env_extras) + _merge_caller_env_extras(resume_extras, env_extras) projected_codex_home = _codex_home_from_plugin_source(plugin_source) if projected_codex_home is not None: resume_extras["CODEX_HOME"] = projected_codex_home @@ -1051,10 +1532,20 @@ def build_resume_cmd( ) return CmdSpec(cmd=tuple(cmd), env=env, is_resume=True) - def validate_session_layout(self, session_dir: Path) -> list[str]: + def validate_session_layout( + self, + session_dir: Path, + *, + project_dir: Path | None = None, + ) -> list[str]: + del project_dir errors: list[str] = [] - skills_dir = session_dir / ClaudeDirectoryConventions.PLUGIN_DIR_SKILLS_SUBDIR + skills_dir = ( + session_dir + / SESSION_ADD_DIR_SUBDIR + / ClaudeDirectoryConventions.PLUGIN_DIR_SKILLS_SUBDIR + ) if not skills_dir.is_dir(): errors.append(f"skills directory does not exist: {skills_dir}") elif not any(skills_dir.iterdir()): @@ -1075,58 +1566,118 @@ def validate_session_layout(self, session_dir: Path) -> list[str]: sessions_path = session_dir / "sessions" if sessions_path.exists() and not sessions_path.is_symlink(): errors.append(f"sessions/ must be a symlink, not a regular directory: {sessions_path}") + archived_path = session_dir / "archived_sessions" + if archived_path.exists() and not archived_path.is_symlink(): + errors.append( + f"archived_sessions/ must be a symlink, not a regular directory: {archived_path}" + ) + rollout_errors, _ = _validate_inert_rollout_paths(session_dir) + errors.extend(rollout_errors) return errors - def setup_session_dir(self, session_dir: Path) -> None: - codex_home_source = Path.home() / ".codex" - + def validate_interactive_invocation(self, spec: CmdSpec) -> list[str]: + origin = spec.origin + if origin is None: + return ["Codex interactive validation requires unambiguous CmdOrigin metadata"] + reconstructed: list[str] = [origin.binary, *origin.mode_flags] + for flag, value in origin.kv_flags: + reconstructed.extend((flag, value)) + reconstructed.extend(origin.positional) + for flag, value in origin.variadic_pairs: + reconstructed.extend((flag, value)) + if tuple(reconstructed) != spec.cmd: + return ["Codex interactive CmdOrigin does not describe the finalized command"] + if not spec.cwd or not Path(spec.cwd).is_absolute(): + return ["Codex interactive validation requires an absolute finalized cwd"] + + home_value = spec.env.get(_CODEX_HOME_ENV_VAR) + sqlite_value = spec.env.get(_CODEX_SQLITE_HOME_ENV_VAR) + if not home_value or home_value != sqlite_value: + return [ + "Codex interactive reserved home and SQLite environment must name " + "the same generated home" + ] + generated_home = Path(home_value) + if not generated_home.is_absolute(): + return ["Codex interactive generated home must be absolute"] + generated_home = generated_home.resolve(strict=False) + if str(generated_home) != home_value: + return ["Codex interactive generated home environment is not canonical"] + + sqlite_override = f"sqlite_home={_format_toml_value(str(generated_home))}" + config_overrides = [ + value for flag, value in origin.kv_flags if flag == CodexFlags.CONFIG_OVERRIDE + ] + if not config_overrides or config_overrides[-1] != sqlite_override: + return [ + "Codex interactive command is missing the highest-precedence " + "generated-home sqlite_home override" + ] + profiles = [value for flag, value in origin.kv_flags if flag == CodexFlags.PROFILE] + if len(profiles) > 1: + return ["Codex interactive command has an ambiguous selected profile"] + selected_profile = spec.env.get(PROVIDER_PROFILE_ENV_VAR) + if profiles != ([selected_profile] if selected_profile else []): + return ["Codex interactive profile metadata does not match the child environment"] + + config_path = generated_home / "config.toml" try: - shutil.copy2( - codex_home_source / "config.toml", - session_dir / "config.toml", - ) - except FileNotFoundError: - logger.error( - "codex_config_copy_missing", - src=str(codex_home_source / "config.toml"), - ) - raise + config_bytes = config_path.read_bytes() + except OSError as exc: + return [ + f"Failed to read finalized generated Codex config: {type(exc).__name__}: {exc}" + ] + layout_errors, before_fingerprint = _validate_inert_rollout_paths(generated_home) + if layout_errors: + return layout_errors + + probe_command: list[str] = [origin.binary] + for flag, value in origin.kv_flags: + if flag in (CodexFlags.PROFILE, CodexFlags.CONFIG_OVERRIDE): + probe_command.extend((flag, value)) + probe_command.extend(("mcp", "list", CodexFlags.JSON)) + errors = _validate_mcp_probe( + tuple(probe_command), + env=spec.env, + cwd=spec.cwd, + config_bytes=config_bytes, + ) + after_errors, after_fingerprint = _validate_inert_rollout_paths(generated_home) + errors.extend(after_errors) + if not after_errors and after_fingerprint != before_fingerprint: + errors.append("Codex MCP validation mutated the inert rollout path topology") + return errors + + def setup_session_dir(self, session_dir: Path) -> None: + assert self.source_codex_home is not None + codex_home_source = self.source_codex_home + config_path = session_dir / "config.toml" + if not config_path.is_file(): + raise FileNotFoundError(f"pre-launch Codex config snapshot is missing: {config_path}") + tomllib.loads(config_path.read_text(encoding="utf-8")) auth_source = codex_home_source / "auth.json" auth_dest = session_dir / "auth.json" if auth_source.exists(): - try: - auth_dest.symlink_to(auth_source.resolve()) - logger.debug( - "codex_auth_symlink", - src=str(auth_source), - dest=str(auth_dest), - ) - except OSError: - logger.warning("codex_auth_symlink_failed", src=str(auth_source)) - else: - logger.warning("codex_auth_copy_missing", src=str(auth_source)) + auth_dest.symlink_to(auth_source.resolve(strict=True)) + logger.debug( + "codex_auth_symlink", + src=str(auth_source), + dest=str(auth_dest), + ) env_source = codex_home_source / ".env" if env_source.exists(): shutil.copy2(env_source, session_dir / ".env") - sessions_target = default_log_dir() / "codex-sessions" - sessions_target.mkdir(parents=True, exist_ok=True) - try: - (session_dir / "sessions").symlink_to(sessions_target) - except OSError: - logger.warning( - "codex_sessions_symlink_failed", target=str(sessions_target), exc_info=True - ) - - try: - _generate_agent_tomls(session_dir) - registered = _register_agent_tomls(session_dir) - logger.debug("codex_agents_registered", count=registered) - except Exception: - logger.warning("codex_agent_toml_generation_failed", exc_info=True) + _generate_agent_tomls(session_dir) + registered = _register_agent_tomls(session_dir) + logger.debug("codex_agents_registered", count=registered) + _materialize_profile_skills( + session_dir, + source_codex_home=codex_home_source, + ) def validate_skill_content(self, content: str) -> list[str]: return [] @@ -1151,25 +1702,51 @@ def version(self) -> str: def list_plugins(self) -> list[dict[str, Any]]: return [] - def ensure_pre_launch(self) -> list[str]: + def ensure_pre_launch(self, *, session_dir: Path | None = None) -> list[str]: os.environ[MCP_CLIENT_BACKEND_ENV_VAR] = AGENT_BACKEND_CODEX - errors: list[str] = [] try: - ensure_codex_mcp_registered() - except Exception as exc: - logger.warning("codex_mcp_registration_failed", exc_info=True) - errors.append(f"Failed to ensure MCP registration: {exc}") - - try: - sync_hooks_to_codex_config( + assert self.source_codex_home is not None + with codex_prelaunch_transaction( + source_codex_home=self.source_codex_home, hook_config_format=self.capabilities.hook_config_format, - ) + ) as config_path: + if session_dir is not None: + snapshot = config_path.read_bytes() + atomic_write( + Path(session_dir) / "config.toml", + snapshot.decode("utf-8"), + ) + return [] + return _validate_global_codex_home( + self.source_codex_home, + config_path=config_path, + ) except Exception as exc: - logger.warning("codex_hook_sync_failed", exc_info=True) - errors.append(f"Failed to sync hooks to Codex config: {exc}") + logger.error( + "codex_prelaunch_transaction_failed", + exc_info=True, + ) + return [f"Codex pre-launch configuration failed: {type(exc).__name__}: {exc}"] - errors.extend(_validate_codex_config()) - return errors + def recover_cook_history(self) -> None: + CodexSessionStore(log_dir=default_log_dir()).recover() + + def cook_session_context( + self, + *, + session_home: Path, + project_dir: Path, + launch_id: str, + attempt: int, + current_resume_spec: ResumeSpec, + ) -> AbstractContextManager[CookSessionHandle]: + return CodexSessionStore(log_dir=default_log_dir()).prepare_attempt( + session_home=session_home, + project_dir=project_dir, + launch_id=launch_id, + attempt=attempt, + current_resume_spec=current_resume_spec, + ) def build_inspector_cmd(self, prompt: str, *, model: str = "") -> CmdSpec: if not self.capabilities.inspector_capable: diff --git a/src/autoskillit/server/_factory.py b/src/autoskillit/server/_factory.py index e6f2f5151c..cb8633680c 100644 --- a/src/autoskillit/server/_factory.py +++ b/src/autoskillit/server/_factory.py @@ -17,6 +17,8 @@ from autoskillit.config import AutomationConfig from autoskillit.core import ( + CODEX_SESSIONS_SUBDIR, + MARKETPLACE_PREFIX, DirectInstall, FleetLock, PluginSource, @@ -296,7 +298,7 @@ def make_context( skill_catalog=session_catalog, ) ephemeral_root = resolve_ephemeral_root() - codex_root = temp_dir / "codex-sessions" + codex_root = temp_dir / CODEX_SESSIONS_SUBDIR session_mgr = DefaultSessionSkillManager(provider, ephemeral_root, codex_root=codex_root) audit = DefaultAuditLog() diff --git a/src/autoskillit/server/_lifespan.py b/src/autoskillit/server/_lifespan.py index 305a56c74b..20045c6fcc 100644 --- a/src/autoskillit/server/_lifespan.py +++ b/src/autoskillit/server/_lifespan.py @@ -21,7 +21,7 @@ from collections.abc import Awaitable, Callable from contextlib import asynccontextmanager from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any, cast import autoskillit.core.paths as _core_paths from autoskillit.core import ( @@ -47,8 +47,11 @@ from autoskillit.execution import ( BACKEND_REGISTRY, RecordingSubprocessRunner, - ensure_codex_mcp_registered, ) +from autoskillit.execution import ( + ensure_codex_mcp_registered as ensure_codex_mcp_registered, +) +from autoskillit.execution.backends._codex_prelaunch import codex_prelaunch_transaction from autoskillit.fleet import ( discover_campaign_state_files, reap_stale_dispatches_async, @@ -68,6 +71,9 @@ from autoskillit.server._state import _get_ctx_or_none, deferred_initialize from autoskillit.workspace import verify_install_state +if TYPE_CHECKING: + from autoskillit.execution.backends.codex import CodexBackend + logger = get_logger(__name__) @@ -567,13 +573,23 @@ async def _skill_auto_gate_boot(ctx: Any) -> None: } -async def _run_codex_mcp_registration_async() -> None: - """Offload ensure_codex_mcp_registered() to a thread executor — fail-open.""" +async def _run_codex_mcp_registration_async( + source_codex_home: Path, + *, + hook_config_format: str, +) -> None: + """Offload the composed Codex config transaction to an executor — fail-open.""" + + def _run_transaction() -> None: + with codex_prelaunch_transaction( + source_codex_home=source_codex_home, + hook_config_format=hook_config_format, + ): + pass + try: loop = _asyncio.get_running_loop() - written = await loop.run_in_executor(None, ensure_codex_mcp_registered) - if written: - logger.warning("codex_mcp_registration_repaired_at_runtime") + await loop.run_in_executor(None, _run_transaction) except Exception: logger.warning("codex_mcp_registration_failed", exc_info=True) @@ -626,9 +642,16 @@ async def _autoskillit_lifespan(server: Any) -> Any: and _boot_ctx.backend is not None and _boot_ctx.backend.capabilities.mcp_config_capable ): + codex_backend = cast("CodexBackend", _boot_ctx.backend) + source_codex_home = codex_backend.source_codex_home + if source_codex_home is None: + raise RuntimeError("Codex backend has no immutable source home") bg_tasks.append( create_background_task( - _run_codex_mcp_registration_async(), + _run_codex_mcp_registration_async( + source_codex_home, + hook_config_format=codex_backend.capabilities.hook_config_format, + ), label="codex_mcp_registration", ) ) diff --git a/src/autoskillit/workspace/session_skills.py b/src/autoskillit/workspace/session_skills.py index c442ab186c..31be0c0ddc 100644 --- a/src/autoskillit/workspace/session_skills.py +++ b/src/autoskillit/workspace/session_skills.py @@ -8,17 +8,25 @@ from __future__ import annotations +import errno +import fcntl import os import shutil +import stat import tempfile import time +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING from autoskillit.core import ( + SESSION_ADD_DIR_SUBDIR, ClaudeDirectoryConventions, EffectiveSkillCatalogAuthority, EffectiveSkillInvocationAuthority, + ManagedSessionHome, ResolvedSkillAuthority, SkillAuthority, SkillContractError, @@ -61,6 +69,140 @@ logger = get_logger(__name__) +_SESSION_LEASES_SUBDIR = ".session-leases" + + +def _raise_failures(message: str, failures: list[BaseException]) -> None: + """Raise one failure unchanged, or preserve ordered failures as a group.""" + if not failures: + return + if len(failures) == 1: + raise failures[0] + raise BaseExceptionGroup(message, failures) + + +def _remove_and_verify(path: Path) -> bool: + """Remove a generated home and prove that no directory entry remains.""" + if not os.path.lexists(path): + return False + if path.is_symlink(): + raise RuntimeError(f"Refusing to recursively remove symlinked session home: {path}") + shutil.rmtree(path) + if os.path.lexists(path): + raise RuntimeError(f"Session home still exists after removal: {path}") + return True + + +def resolve_persistent_session_root( + base_root: Path, + backend: CodingAgentBackend, +) -> Path | None: + """Resolve a backend-declared persistent generated-home root.""" + if not backend.capabilities.session_dir_persistent: + return None + subdir = backend.conventions.persistent_session_root_subdir + if subdir is None: + raise RuntimeError("Persistent backend has no generated-home root convention") + if subdir.is_absolute() or ".." in subdir.parts: + raise RuntimeError(f"Unsafe persistent generated-home root convention: {subdir}") + return base_root / subdir + + +@dataclass(slots=True) +class _SessionLease: + """Workspace-owned external lease for a removable generated home.""" + + path: Path + fd: int | None + + @classmethod + def acquire( + cls, + lock_path: Path, + *, + blocking: bool, + ) -> _SessionLease | None: + if lock_path.suffix != ".lock": + raise ValueError(f"Lock path must use the .lock suffix: {lock_path}") + lock_path.parent.mkdir(parents=True, mode=0o700, exist_ok=True) + directory_fd = os.open( + lock_path.parent, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + ) + fd: int | None = None + try: + directory_stat = os.fstat(directory_fd) + if not stat.S_ISDIR(directory_stat.st_mode): + raise RuntimeError(f"Session lease root is not a directory: {lock_path.parent}") + os.fchmod(directory_fd, 0o700) + fd = os.open( + lock_path.name, + os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, + 0o600, + dir_fd=directory_fd, + ) + lease_stat = os.fstat(fd) + if not stat.S_ISREG(lease_stat.st_mode): + raise RuntimeError(f"Session lease is not a regular file: {lock_path}") + operation = fcntl.LOCK_EX + if not blocking: + operation |= fcntl.LOCK_NB + fcntl.flock(fd, operation) + return cls(path=lock_path, fd=fd) + except OSError as exc: + contended = not blocking and exc.errno in (errno.EACCES, errno.EAGAIN) + failures: list[BaseException] = [] + if not contended: + failures.append(exc) + if fd is not None: + try: + os.close(fd) + except BaseException as close_exc: + logger.error("session_lease_close_failed", exc_info=True) + failures.append(close_exc) + if failures: + _raise_failures("Session lease acquisition failed", failures) + return None + except BaseException as exc: + logger.error("session_lease_acquisition_failed", exc_info=True) + failures = [exc] + if fd is not None: + try: + os.close(fd) + except BaseException as close_exc: + logger.error("session_lease_close_failed", exc_info=True) + failures.append(close_exc) + _raise_failures("Session lease acquisition failed", failures) + raise AssertionError("unreachable") + finally: + os.close(directory_fd) + + def release(self) -> None: + fd = self.fd + if fd is None: + return + self.fd = None + failures: list[BaseException] = [] + try: + fcntl.flock(fd, fcntl.LOCK_UN) + except BaseException as exc: + logger.error("session_lease_unlock_failed", exc_info=True) + failures.append(exc) + try: + os.close(fd) + except BaseException as exc: + logger.error("session_lease_close_failed", exc_info=True) + failures.append(exc) + _raise_failures("Session lease release failed", failures) + + +@dataclass(frozen=True, slots=True) +class _InitializedSession: + generated_home: Path + skills_dir: ValidatedAddDir + skills_subdir: Path + lease: _SessionLease | None + def _codex_profile_skill_infos( backend: CodingAgentBackend, @@ -324,15 +466,15 @@ def __init__( provider: SkillsDirectoryProvider, ephemeral_root: Path, *, - codex_root: Path | None = None, + persistent_root: Path | None = None, ) -> None: self._provider = provider self._root = ephemeral_root - self._codex_root = codex_root + self._persistent_root = persistent_root self._session_roots: dict[str, Path] = {} + self._session_leases: dict[str, _SessionLease] = {} + self._session_skills_subdirs: dict[str, Path] = {} self._session_skill_infos: dict[str, dict[str, SkillAuthority]] = {} - self._available_skill_infos = {skill.name: skill for skill in provider.list_skills()} - self._skills_subdir = ClaudeDirectoryConventions.ADD_DIR_SKILLS_SUBDIR def materialize_invocation( self, @@ -411,6 +553,70 @@ def init_session( projection_context, ) + @contextmanager + def managed_session( + self, + session_id: str, + catalog: EffectiveSkillCatalogAuthority, + projection_context: SkillProjectionContextAuthority, + ) -> Iterator[ManagedSessionHome]: + """Yield an already-owned generated home and clean it exactly once.""" + self._validate_session_id(session_id) + if catalog.execution_role is not SkillExecutionRole.SESSION: + raise SkillContractError("L1 catalog materialization requires SESSION contracts") + for member in catalog.skills: + if member.invalid_reason is not None: + raise SkillContractError( + f"invalid materialization contract for {member.name!r}: " + f"{member.invalid_reason}" + ) + if member.execution_role is not SkillExecutionRole.SESSION: + actual = ( + member.execution_role.value if member.execution_role is not None else "invalid" + ) + raise SkillContractError( + f"L1 catalog materialization requires SESSION members; " + f"{member.name!r} is {actual}" + ) + validate_skill_capability_roles( + member.uses_capabilities, + member.execution_role, + ) + if projection_context.catalog != catalog: + raise SkillContractError( + "materialization projection must bind the exact effective catalog" + ) + + initialized = self._initialize_bound_records( + session_id, + catalog.skills, + projection_context, + ) + lease_fd = initialized.lease.fd if initialized.lease is not None else None + if lease_fd is None: + failures: list[BaseException] = [ + RuntimeError("Managed sessions require a generated-home lease") + ] + failures.extend(self._cleanup_owned(session_id, initialized)) + _raise_failures("Managed session setup failed", failures) + raise AssertionError("unreachable") + + body_failure: BaseException | None = None + try: + yield ManagedSessionHome( + launch_id=session_id, + generated_home=initialized.generated_home, + skills_dir=initialized.skills_dir, + pass_fds=(lease_fd,), + ) + except BaseException as exc: + logger.error("managed_session_body_failed", exc_info=True) + body_failure = exc + + failures = [] if body_failure is None else [body_failure] + failures.extend(self._cleanup_owned(session_id, initialized)) + _raise_failures("Managed session body and cleanup failed", failures) + @staticmethod def _validate_session_id(session_id: str) -> None: if ( @@ -428,20 +634,47 @@ def _materialize_bound_records( records: tuple[SkillAuthority, ...], projection_context: SkillProjectionContextAuthority, ) -> ValidatedAddDir: + return self._initialize_bound_records( + session_id, + records, + projection_context, + ).skills_dir + + def _initialize_bound_records( + self, + session_id: str, + records: tuple[SkillAuthority, ...], + projection_context: SkillProjectionContextAuthority, + ) -> _InitializedSession: + self._validate_session_id(session_id) + if ( + session_id in self._session_roots + or session_id in self._session_leases + or session_id in self._session_skills_subdirs + or session_id in self._session_skill_infos + ): + raise RuntimeError(f"Session is already owned by this manager: {session_id}") + conventions = projection_context.conventions skills_subdir = ( conventions.skills_subdir if conventions is not None else ClaudeDirectoryConventions.ADD_DIR_SKILLS_SUBDIR ) - effective_root = self._root backend = projection_context.backend - if ( - backend is not None - and backend.capabilities.session_dir_persistent - and self._codex_root is not None - ): - effective_root = self._codex_root + persistent = backend is not None and backend.capabilities.session_dir_persistent + configured_root = self._persistent_root if persistent else self._root + if configured_root is None: + raise RuntimeError( + "A persistent_root is required for persistent generated-home sessions" + ) + try: + effective_root = configured_root.resolve() + except OSError as exc: + raise RuntimeError(f"Invalid generated-home root {configured_root}: {exc}") from exc + if effective_root.exists() and not effective_root.is_dir(): + raise RuntimeError(f"Generated-home root is not a directory: {effective_root}") + if backend is not None: if ( projection_context.gating is True @@ -451,18 +684,76 @@ def _materialize_bound_records( f"backend {backend.name!r} does not support model invocation gating" ) - self._session_roots[session_id] = effective_root - self._skills_subdir = skills_subdir - session_dir = effective_root / session_id - skills_base = session_dir / skills_subdir + owned_skills_subdir = Path(SESSION_ADD_DIR_SUBDIR) / skills_subdir + generated_home = effective_root / session_id + lease: _SessionLease | None = None + try: + lease_path = effective_root / _SESSION_LEASES_SUBDIR / f"{session_id}.lock" + lease = _SessionLease.acquire(lease_path, blocking=True) + if lease is None: + raise RuntimeError(f"Failed to acquire generated-home lease: {lease_path}") + if persistent: + _remove_and_verify(generated_home) + + skills_dir = self._materialize_session( + generated_home, + records, + projection_context, + skills_subdir=skills_subdir, + ) + initialized = _InitializedSession( + generated_home=generated_home, + skills_dir=skills_dir, + skills_subdir=owned_skills_subdir, + lease=lease, + ) + self._session_roots[session_id] = effective_root + self._session_skills_subdirs[session_id] = owned_skills_subdir + self._session_skill_infos[session_id] = {member.name: member for member in records} + self._session_leases[session_id] = lease + return initialized + except BaseException as exc: + logger.error("session_initialization_failed", exc_info=True) + failures: list[BaseException] = [exc] + self._session_roots.pop(session_id, None) + self._session_skills_subdirs.pop(session_id, None) + self._session_skill_infos.pop(session_id, None) + self._session_leases.pop(session_id, None) + if lease is not None and os.path.lexists(generated_home): + try: + _remove_and_verify(generated_home) + except BaseException as cleanup_exc: + logger.error("session_initialization_rollback_failed", exc_info=True) + failures.append(cleanup_exc) + if lease is not None: + try: + lease.release() + except BaseException as release_exc: + logger.error("session_initialization_lease_release_failed", exc_info=True) + failures.append(release_exc) + _raise_failures("Session initialization and rollback failed", failures) + raise AssertionError("unreachable") + + def _materialize_session( + self, + generated_home: Path, + records: tuple[SkillAuthority, ...], + projection_context: SkillProjectionContextAuthority, + *, + skills_subdir: Path, + ) -> ValidatedAddDir: + backend = projection_context.backend + add_dir = generated_home / SESSION_ADD_DIR_SUBDIR + skills_base = add_dir / skills_subdir + skills_base.mkdir(parents=True, exist_ok=True) if backend is not None and backend.capabilities.mcp_config_capable: - pre_launch_errors = backend.ensure_pre_launch() + pre_launch_errors = backend.ensure_pre_launch(session_dir=generated_home) if pre_launch_errors: raise RuntimeError(f"Pre-launch check failed: {'; '.join(pre_launch_errors)}") if backend is not None: - session_dir.mkdir(parents=True, exist_ok=True) - backend.setup_session_dir(session_dir) + backend.setup_session_dir(generated_home) + ungated_context = SkillProjectionContext( cwd=projection_context.cwd, project_root=projection_context.project_root, @@ -475,37 +766,130 @@ def _materialize_bound_records( namespace=projection_context.namespace, projection_version=projection_context.projection_version, ) - materialize_agent_skill_tree(skills_base, records, ungated_context) - self._session_skill_infos[session_id] = {member.name: member for member in records} + session_records = ( + records + if backend is None + else tuple(record for record in records if record.source is not SkillSource.BUNDLED) + ) + materialize_agent_skill_tree(skills_base, session_records, ungated_context) + if backend is not None and backend.capabilities.session_dir_persistent: + self._create_inert_rollout_paths(generated_home, backend) + if backend is not None: + layout_errors = list( + backend.validate_session_layout( + generated_home, + project_dir=projection_context.project_root or projection_context.cwd, + ) + ) + if layout_errors: + raise RuntimeError("Session layout validation failed: " + "; ".join(layout_errors)) + return ValidatedAddDir(path=str(add_dir)) + + @staticmethod + def _create_inert_rollout_paths( + generated_home: Path, + backend: CodingAgentBackend, + ) -> None: + configured = backend.capabilities.session_dir_symlinks + for name in sorted(configured): + if Path(name).name != name or name in {"", ".", ".."}: + raise RuntimeError(f"Unsafe generated-home symlink declaration: {name!r}") + target = generated_home / f".inert-{name}" + public_path = generated_home / name + if os.path.lexists(target) or os.path.lexists(public_path): + raise RuntimeError( + f"Backend setup created reserved generated-home rollout path: {public_path}" + ) + target.mkdir(mode=0o700) + target.chmod(0o700) + public_path.symlink_to(target.name, target_is_directory=True) - return ValidatedAddDir(path=str(session_dir)) + def _cleanup_owned( + self, + session_id: str, + initialized: _InitializedSession, + ) -> list[BaseException]: + """Delete while leased, clear ownership maps, then release.""" + failures: list[BaseException] = [] + try: + _remove_and_verify(initialized.generated_home) + except BaseException as exc: + logger.error("owned_session_cleanup_failed", exc_info=True) + failures.append(exc) + + self._session_roots.pop(session_id, None) + self._session_skills_subdirs.pop(session_id, None) + self._session_skill_infos.pop(session_id, None) + self._session_leases.pop(session_id, None) + + if initialized.lease is not None: + try: + initialized.lease.release() + except BaseException as exc: + logger.error("owned_session_lease_release_failed", exc_info=True) + failures.append(exc) + return failures def cleanup_session(self, session_id: str) -> bool: """Remove the session skill directory for a completed session. Returns True if the directory was found and removed, False otherwise. """ - effective_root = self._session_roots.pop(session_id, None) - self._session_skill_infos.pop(session_id, None) + self._validate_session_id(session_id) + effective_root = self._session_roots.get(session_id) if effective_root is not None: - session_dir = effective_root / session_id - if session_dir.is_dir(): - shutil.rmtree(session_dir, ignore_errors=True) - return True - return False - for root in (self._root, self._codex_root): + generated_home = effective_root / session_id + initialized = _InitializedSession( + generated_home=generated_home, + skills_dir=ValidatedAddDir(path=str(generated_home)), + skills_subdir=self._session_skills_subdirs.get( + session_id, + ClaudeDirectoryConventions.ADD_DIR_SKILLS_SUBDIR, + ), + lease=self._session_leases.get(session_id), + ) + existed = os.path.lexists(generated_home) + failures = self._cleanup_owned(session_id, initialized) + _raise_failures("Owned session cleanup failed", failures) + return existed + + for root in (self._root, self._persistent_root): if root is None: continue - candidate = root / session_id - if candidate.is_dir(): - logger.debug("cleanup_session_fallback", session_id=session_id, root=str(root)) - shutil.rmtree(candidate, ignore_errors=True) - return True + resolved_root = root.resolve() + candidate = resolved_root / session_id + if not os.path.lexists(candidate): + continue + lease = _SessionLease.acquire( + resolved_root / _SESSION_LEASES_SUBDIR / f"{session_id}.lock", + blocking=False, + ) + if lease is None: + logger.warning( + "cleanup_session_contended", + session_id=session_id, + root=str(resolved_root), + ) + return False + cleanup_failures: list[BaseException] = [] + removed = False + try: + removed = _remove_and_verify(candidate) + except BaseException as exc: + logger.error("unowned_session_cleanup_failed", exc_info=True) + cleanup_failures.append(exc) + try: + lease.release() + except BaseException as exc: + logger.error("unowned_session_lease_release_failed", exc_info=True) + cleanup_failures.append(exc) + _raise_failures("Unowned session cleanup failed", cleanup_failures) + return removed return False def validate_session_exists(self, session_id: str) -> bool: """Return True if session directory exists and is non-empty.""" - for root in (self._root, self._codex_root): + for root in (self._root, self._persistent_root): if root is None: continue candidate = root / session_id @@ -523,19 +907,48 @@ def cleanup_stale(self, max_age_seconds: int = 86400) -> int: """ now = time.time() removed = 0 - for root in (self._root, self._codex_root): + for root in (self._root, self._persistent_root): if root is None or not root.exists(): continue - for entry in root.iterdir(): + resolved_root = root.resolve() + for entry in resolved_root.iterdir(): + if entry.name == _SESSION_LEASES_SUBDIR: + continue if not entry.is_dir(): continue last_access = entry.stat().st_atime if now - last_access > max_age_seconds: + if entry.name in self._session_leases: + continue + lease = _SessionLease.acquire( + resolved_root / _SESSION_LEASES_SUBDIR / f"{entry.name}.lock", + blocking=False, + ) + if lease is None: + continue + failures: list[BaseException] = [] + did_remove = False + try: + current_access = entry.stat().st_atime + if now - current_access > max_age_seconds: + did_remove = _remove_and_verify(entry) + except FileNotFoundError: + pass + except BaseException as exc: + logger.error("stale_session_cleanup_failed", exc_info=True) + failures.append(exc) + try: + lease.release() + except BaseException as exc: + logger.error("stale_session_lease_release_failed", exc_info=True) + failures.append(exc) + _raise_failures("Stale session cleanup failed", failures) + if not did_remove: + continue logger.warning( "cleanup_stale_removed", path=str(entry), age_seconds=round(now - last_access), ) - shutil.rmtree(entry, ignore_errors=True) removed += 1 return removed diff --git a/tests/arch/test_ast_rules.py b/tests/arch/test_ast_rules.py index 555b963e64..39bc6ac0c5 100644 --- a/tests/arch/test_ast_rules.py +++ b/tests/arch/test_ast_rules.py @@ -876,7 +876,7 @@ def test_expand_functions_call_validators() -> None: def test_fcntl_import_allowlist() -> None: - """Only the five allowlisted modules may import fcntl. + """Only explicitly allowlisted modules may import fcntl. Unauthorized fcntl usage bypasses the CampaignStateMutator lock gateway, creating cross-process race conditions on state files. This test enforces @@ -910,6 +910,36 @@ def test_fcntl_import_allowlist() -> None: ) +def test_codex_unlocked_config_mutators_are_private_to_prelaunch() -> None: + """Only the composed prelaunch transaction may import unlocked mutators.""" + unlocked_names = { + "_ensure_codex_mcp_registered_unlocked", + "_sync_hooks_to_codex_config_unlocked", + } + allowed_path = SRC_ROOT / "execution" / "backends" / "_codex_prelaunch.py" + violations: list[str] = [] + + for py_file in sorted(SRC_ROOT.rglob("*.py")): + if py_file == allowed_path: + continue + try: + tree = ast.parse(py_file.read_text(encoding="utf-8")) + except SyntaxError: + continue + for node in ast.walk(tree): + if not isinstance(node, ast.ImportFrom): + continue + for alias in node.names: + if alias.name in unlocked_names: + rel = py_file.relative_to(SRC_ROOT) + violations.append(f" {rel}:{node.lineno}: imports {alias.name}") + + assert not violations, ( + "Unlocked Codex config mutators bypass the composed source-config lock; " + "only _codex_prelaunch.py may import them:\n" + "\n".join(violations) + ) + + def test_no_build_cmd_accepts_output_format_value_string() -> None: """No cmd builder should accept output_format_value: str — use OutputFormat enum (ARCH-011).""" for src_path in ( diff --git a/tests/arch/test_subpackage_isolation.py b/tests/arch/test_subpackage_isolation.py index f0c803b806..ae72ba89b8 100644 --- a/tests/arch/test_subpackage_isolation.py +++ b/tests/arch/test_subpackage_isolation.py @@ -911,7 +911,10 @@ def test_no_subpackage_exceeds_10_files() -> None: # +_backend_compat.py (shared target-resolution + fail-closed compatibility gate # for direct headless executor callers — report_bug, prepare_issue, enrich_issues) "hooks/guards": 32, # -output_budget_guard (#4286) - "execution/backends": 13, # +_codex_recipe_delivery protected attestation broker + # Three private Codex ownership modules keep lock, prelaunch transaction, + # and per-attempt storage concerns out of the public backend gateway: + # +_codex_config_lock, +_codex_prelaunch, +_codex_session_storage. + "execution/backends": 16, } violations: list[str] = [] dirs_to_check: list[Path] = [] diff --git a/tests/cli/test_cook_process_lifecycle.py b/tests/cli/test_cook_process_lifecycle.py index 853ffd5803..cb2005349e 100644 --- a/tests/cli/test_cook_process_lifecycle.py +++ b/tests/cli/test_cook_process_lifecycle.py @@ -11,8 +11,8 @@ from unittest.mock import Mock import pytest -from autoskillit.cli.session._session_process import run_cook_attempt +from autoskillit.cli.session._session_process import run_cook_attempt from autoskillit.core import CmdSpec pytestmark = [ diff --git a/tests/execution/AGENTS.md b/tests/execution/AGENTS.md index adc4d40b65..a91dbd3fb1 100644 --- a/tests/execution/AGENTS.md +++ b/tests/execution/AGENTS.md @@ -155,11 +155,13 @@ Subprocess integration, headless session, process lifecycle, and session result | `test_codex_backend.py` | Tests for CodexFlags, CodexBackend protocol conformance, headless/resume command builders, skill session cmd config adapter, food truck cmd builder | | `test_codex_interactive.py` | Parametrized structural validation of CodexBackend.build_interactive_cmd plus the required installed-CLI parse gate for exact fresh config overrides | | `test_codex_session_locator.py` | Tests for CodexSessionLocator: locate_session walk, read_session decompression, codex_home constructor-field priority over env var, protocol conformance | +| `test_codex_session_storage.py` | Tests for isolated attempt views, leases, promotion, recovery, canonical rollouts, and derived index behavior | | `test_codex_env_policy.py` | Tests for CodexEnvPolicy three-layer scrub | | `test_codex_mcp_registration.py` | Tests for ensure_codex_mcp_registered: file creation, TOML fields, idempotency, foreign section preservation, dir creation | | `test_codex_stream_parser.py` | Full test suite for CodexStreamParser: happy-path, item parsing, degradation, fixture-driven integration, protocol conformance | | `test_codex_result_parser.py` | Tests for CodexResultParser | | `test_codex_config.py` | Tests for TOML read/write primitives, _is_autoskillit_registered, and ensure_codex_mcp_registered | +| `test_codex_config_validation.py` | Tests for locked Codex source-config composition, snapshots, and exact native-validation context | | `test_codex_recipe_delivery.py` | Protected Codex recipe-delivery attestation and durable receipt-ledger tests | | `test_codex_recipe_delivery_fixtures.py` | Protected and diagnostic Codex recipe-delivery fixture ratchets | | `test_codex_recipe_delivery_conformance.py` | Dedicated recipe-delivery conformance matrix plus credentialed Code Mode envelope/pull retention probe | diff --git a/tests/execution/backends/test_codex_session_storage.py b/tests/execution/backends/test_codex_session_storage.py index b7146b2b2e..a188336a8a 100644 --- a/tests/execution/backends/test_codex_session_storage.py +++ b/tests/execution/backends/test_codex_session_storage.py @@ -7,13 +7,13 @@ from pathlib import Path import pytest + +from autoskillit.core import NamedResume, NoResume from autoskillit.execution.backends._codex_session_storage import ( CodexInteractiveSessionLease, CodexSessionStore, ) -from autoskillit.core import NamedResume, NoResume - pytestmark = [pytest.mark.layer("execution"), pytest.mark.medium] diff --git a/tests/fleet/test_state_lock_contract.py b/tests/fleet/test_state_lock_contract.py index 1a5055db08..c2c8c87708 100644 --- a/tests/fleet/test_state_lock_contract.py +++ b/tests/fleet/test_state_lock_contract.py @@ -38,8 +38,11 @@ _FCNTL_ALLOWED_RELATIVE_PATHS: frozenset[str] = frozenset( { "core/_plugin_cache.py", + "execution/backends/_codex_config_lock.py", + "execution/backends/_codex_session_storage.py", "execution/session/_session_state.py", "workspace/clone_registry.py", + "workspace/session_skills.py", "fleet/state.py", "planner/merge.py", "server/tools/tools_kitchen.py", # _write_ingredient_locks: atomic flock overlay write diff --git a/tests/workspace/_helpers.py b/tests/workspace/_helpers.py index e9a8d4de0b..7a2d8495c6 100644 --- a/tests/workspace/_helpers.py +++ b/tests/workspace/_helpers.py @@ -2,7 +2,7 @@ from __future__ import annotations -from autoskillit.core import BackendCapabilities +from autoskillit.core import BackendCapabilities, HookTrustPolicy _CODEX_CAPABILITIES = BackendCapabilities( channel_b_capable=False, @@ -17,8 +17,9 @@ completion_record_types=frozenset({"turn.completed", "turn.failed", "error"}), session_record_types=frozenset({"item.completed"}), required_session_files=frozenset({"config.toml"}), - session_dir_symlinks=frozenset({"auth.json", ".env", "sessions"}), + session_dir_symlinks=frozenset({"sessions", "archived_sessions"}), skills_subdir="skills", session_dir_persistent=True, supports_model_invocation_gating=False, + hook_trust_policy=HookTrustPolicy.REVIEW_EACH_SESSION, ) From 28e729d5c6df64678c57afe04fec747628f76aac Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 06:39:08 -0700 Subject: [PATCH 03/35] fix: complete Codex cook history remediation --- AGENTS.md | 2 +- docs/operations/observability.md | 8 +- src/autoskillit/cli/_hooks_codex.py | 2 +- .../cli/session/_session_process.py | 31 +- .../cli/session/_session_startup_trace.py | 65 +- src/autoskillit/cli/session/pty/_exec.py | 36 +- src/autoskillit/cli/session/pty/_observer.py | 32 +- src/autoskillit/core/__init__.pyi | 1 + src/autoskillit/core/types/_type_constants.py | 2 + .../core/types/_type_protocols_backend.py | 2 + src/autoskillit/execution/__init__.py | 2 + src/autoskillit/execution/backends/AGENTS.md | 2 +- .../execution/backends/_codex_parse.py | 197 ++++- .../backends/_codex_session_storage.py | 689 ++++++++++++------ src/autoskillit/execution/backends/claude.py | 10 +- src/autoskillit/server/_lifespan.py | 2 +- tests/arch/test_backend_coherence.py | 6 +- tests/arch/test_capability_consistency.py | 5 + tests/arch/test_capability_consumption.py | 8 - tests/arch/test_no_backend_name_bypass.py | 2 + tests/arch/test_subpackage_isolation.py | 20 +- tests/arch/test_subpackage_structure.py | 4 +- tests/cli/test_cook_env_scrub.py | 100 +-- tests/cli/test_cook_process_lifecycle.py | 78 +- tests/cli/test_cook_startup_observability.py | 57 +- tests/cli/test_subprocess_env_contracts.py | 4 + tests/cli/test_terminal.py | 49 +- tests/contracts/test_backend_compliance.py | 19 +- .../test_claude_code_interface_contracts.py | 58 +- tests/core/test_backend_protocols.py | 5 + tests/core/test_type_constants.py | 27 + .../execution/backends/test_codex_backend.py | 291 +------- .../backends/test_codex_env_policy.py | 15 + .../backends/test_codex_interactive.py | 28 +- .../backends/test_codex_session_locator.py | 93 +-- .../backends/test_codex_session_storage.py | 533 +++++++++++++- .../test_coding_agent_backend_conformance.py | 10 +- .../backends/test_validate_session_layout.py | 32 +- tests/execution/test_env_boundary.py | 13 + tests/execution/test_headless_env_scrub.py | 6 + tests/fleet/test_state_lock_contract.py | 2 + tests/infra/test_schema_version_convention.py | 2 +- .../integration/test_codex_startup_canary.py | 305 +++++++- tests/server/test_lifespan.py | 4 + tests/workspace/conftest.py | 2 +- 45 files changed, 2077 insertions(+), 784 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6b00b78087..2f37879a0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,7 +105,7 @@ generic_automation_mcp/ | `fleet/` | IL-2 | Campaign dispatch, semaphore, sidecar, liveness, state persistence | | `server/` | IL-3 | FastMCP server — tools/, kitchen gating, session-type dispatch | | `server/recipe_section/` | IL-3 | Final invariant verification for schema-driven recipe-section page plans | -| `cli/` | IL-3 | CLI — doctor/, update/, fleet/ subcommands, ui/, session/ management | +| `cli/` | IL-3 | CLI — doctor/, update/, fleet/ subcommands, ui/, session/ management (including session/pty/) | | `hooks/` | — | coding-agent hook scripts — guards/, formatters/ | | `agents/` | — | Bundled agent definition markdown files served as MCP resources | | `recipes/` | — | Bundled recipe YAML + contracts, diagrams, sub-recipes | diff --git a/docs/operations/observability.md b/docs/operations/observability.md index 589f7a21ab..17c69b508a 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -142,7 +142,13 @@ diagnostics and must never be reported as ready. The adapter is version-mapped, not discovery-based. For exactly `codex-cli 0.145.0`, it opens `/state_5.sqlite` with -`mode=ro`, then applies: +`mode=ro`. That mapping is pinned to upstream Codex commit +`ad65f016ed0c91992fb175fa881a373cc460dd2a`, specifically the +[`state` runtime](https://github.com/openai/codex/blob/ad65f016ed0c91992fb175fa881a373cc460dd2a/codex-rs/state/src/runtime.rs) +and +[`state` SQLite adapter](https://github.com/openai/codex/blob/ad65f016ed0c91992fb175fa881a373cc460dd2a/codex-rs/state/src/sqlite.rs). +Changing either the supported CLI version or this source revision requires a +new compatibility fixture. The adapter then applies: ```sql PRAGMA query_only = ON; diff --git a/src/autoskillit/cli/_hooks_codex.py b/src/autoskillit/cli/_hooks_codex.py index d3e9cd4087..6260300bd3 100644 --- a/src/autoskillit/cli/_hooks_codex.py +++ b/src/autoskillit/cli/_hooks_codex.py @@ -4,7 +4,7 @@ This shim preserves the import path for CLI callers and tests. """ -from autoskillit.execution.backends._codex_hooks import ( +from autoskillit.execution import ( _is_autoskillit_hook_entry, generate_codex_hooks_config, sync_hooks_to_codex_config, diff --git a/src/autoskillit/cli/session/_session_process.py b/src/autoskillit/cli/session/_session_process.py index 19edd9a09c..354461a861 100644 --- a/src/autoskillit/cli/session/_session_process.py +++ b/src/autoskillit/cli/session/_session_process.py @@ -16,11 +16,12 @@ from autoskillit.cli.session.pty._exec import launcher_argv from autoskillit.cli.session.pty._observer import PtyObserver from autoskillit.cli.ui._terminal import terminal_guard -from autoskillit.core import CmdSpec +from autoskillit.core import CmdSpec, get_logger _TERM_TIMEOUT_SECONDS = 2.0 _KILL_TIMEOUT_SECONDS = 2.0 _GROUP_POLL_SECONDS = 0.02 +logger = get_logger(__name__) @dataclass(frozen=True, slots=True) @@ -54,10 +55,6 @@ def run_cook_attempt( slave_fd: int | None = None failures: list[BaseException] = [] - # ``trace`` is retained for the full process lifetime. Its stage callbacks - # are installed on the observer and storage callbacks by the cook owner. - _ = trace - with terminal_guard(): try: if observer is None: @@ -73,7 +70,11 @@ def run_cook_attempt( master_fd, slave_fd = os.openpty() launcher_fds = tuple(sorted({*inherited_fds, slave_fd})) process = subprocess.Popen( - launcher_argv(slave_fd, spec.cmd), + launcher_argv( + slave_fd, + spec.cmd, + lease_fds=inherited_fds, + ), cwd=cwd, env=dict(spec.env), pass_fds=launcher_fds, @@ -82,12 +83,8 @@ def run_cook_attempt( pid = process.pid pgid = pid - actual_pgid = os.getpgid(pid) - if actual_pgid != pgid: - raise RuntimeError( - f"cook child process-group mismatch: pid={pid}, pgid={actual_pgid}" - ) on_spawn(pid, pgid) + trace.record_spawn() if observer is None: with _foreground_process_group(pgid): @@ -101,12 +98,14 @@ def run_cook_attempt( master_fd = None returncode = process.wait() except BaseException as exc: + logger.error("cook_attempt_failed", exc_info=True) failures.append(exc) finally: if slave_fd is not None: try: os.close(slave_fd) except BaseException as exc: + logger.error("cook_slave_fd_close_failed", exc_info=True) failures.append(exc) slave_fd = None if master_fd is not None: @@ -116,6 +115,7 @@ def run_cook_attempt( else: observer.close_master(master_fd) except BaseException as exc: + logger.error("cook_master_fd_close_failed", exc_info=True) failures.append(exc) master_fd = None @@ -125,11 +125,13 @@ def run_cook_attempt( returncode = _terminate_reap_and_verify(process, pgid) cleanup_proved = True except BaseException as exc: + logger.error("cook_process_cleanup_failed", exc_info=True) failures.append(exc) if cleanup_proved: try: on_reaped(pid, pgid) except BaseException as exc: + logger.error("cook_reap_callback_failed", exc_info=True) failures.append(exc) if failures: @@ -142,12 +144,7 @@ def run_cook_attempt( def _require_posix_process_ownership() -> None: - if ( - os.name != "posix" - or not hasattr(os, "killpg") - or not hasattr(os, "getpgid") - or not hasattr(os, "tcsetpgrp") - ): + if os.name != "posix" or not hasattr(os, "killpg") or not hasattr(os, "tcsetpgrp"): raise RuntimeError("interactive cook requires POSIX process-group ownership") diff --git a/src/autoskillit/cli/session/_session_startup_trace.py b/src/autoskillit/cli/session/_session_startup_trace.py index 184a2888a5..de44e4d027 100644 --- a/src/autoskillit/cli/session/_session_startup_trace.py +++ b/src/autoskillit/cli/session/_session_startup_trace.py @@ -5,13 +5,14 @@ import hashlib import json import os -import re import threading import time from collections.abc import Callable, Mapping from pathlib import Path from typing import Any +import regex as re + from autoskillit.core import default_log_dir _SCHEMA_VERSION = 1 @@ -139,16 +140,19 @@ def __init__( self._launch_at: float | None = None self._spawn_at: float | None = None self._hook_review_at: float | None = None + self._current_attempt: int | None = None + self._current_view_id: str | None = None + self._spawned_attempts: set[int] = set() self._terminal_status: str | None = None def record_launch_anchor(self, *, diagnostics: Mapping[str, object] | None = None) -> None: """Record the post-confirmation launch anchor.""" with self._lock: self._ensure_open() - if not self.enabled: - return recorded_at = self._now() self._launch_at = recorded_at + if not self.enabled: + return self._append_record(self._record("launch", recorded_at, diagnostics=diagnostics)) def record_attempt_anchor( @@ -161,6 +165,8 @@ def record_attempt_anchor( """Record the anchor and bounded diagnostics for one launch attempt.""" with self._lock: self._ensure_open() + self._current_attempt = attempt + self._current_view_id = view_id if not self.enabled: return recorded_at = self._now() @@ -184,11 +190,52 @@ def record_stage( recorded_at = self._now() record = self._record("stage", recorded_at, diagnostics=diagnostics) record.update(stage=stage, attempt=attempt, view_id=view_id) + if stage == "spawn" and attempt in self._spawned_attempts: + raise RuntimeError(f"startup spawn already recorded for attempt {attempt}") self._append_record(record) if stage == "spawn": - self._spawn_at = recorded_at + self._spawned_attempts.add(attempt) + if self._spawn_at is None: + self._spawn_at = recorded_at elif stage == "hook_review": - self._hook_review_at = recorded_at + if self._hook_review_at is None: + self._hook_review_at = recorded_at + + def record_spawn(self) -> None: + """Record the exact successful-Popen boundary for the current attempt.""" + with self._lock: + attempt = self._current_attempt + view_id = self._current_view_id + if attempt is None or view_id is None: + raise RuntimeError("startup trace has no current attempt for spawn") + self.record_stage("spawn", attempt=attempt, view_id=view_id) + + def require_startup_budgets(self) -> None: + """Fail an enabled launch with missing or exceeded hard startup budgets.""" + with self._lock: + if not self.enabled: + return + durations = { + "confirmation_to_spawn": self._duration(self._launch_at, self._spawn_at), + "spawn_to_hook_review": self._duration( + self._spawn_at, + self._hook_review_at, + ), + "total_startup": self._duration(self._launch_at, self._hook_review_at), + } + missing = [name for name, duration in durations.items() if duration is None] + exceeded = [ + name + for name, budget in _BUDGETS_SECONDS.items() + if (duration := durations[name]) is not None and duration > budget + ] + if missing or exceeded: + details = [] + if missing: + details.append(f"unmeasured={','.join(missing)}") + if exceeded: + details.append(f"exceeded={','.join(exceeded)}") + raise RuntimeError("Codex startup budgets failed: " + "; ".join(details)) def close( self, @@ -246,17 +293,21 @@ def _summary_record( "total_startup": total_startup, } exceeded: list[str] = [] + missing: list[str] = [] for name, budget in _BUDGETS_SECONDS.items(): duration = durations[name] - if duration is not None and duration > budget: + if duration is None: + missing.append(name) + elif duration > budget: exceeded.append(name) record = self._record("summary", recorded_at, diagnostics=diagnostics) record.update( status=status, durations_seconds=durations, budgets_seconds=dict(_BUDGETS_SECONDS), + budget_missing=missing, budget_exceeded=exceeded, - budgets_passed=not exceeded, + budgets_passed=not missing and not exceeded, ) return record diff --git a/src/autoskillit/cli/session/pty/_exec.py b/src/autoskillit/cli/session/pty/_exec.py index ee3228025a..d761b66573 100644 --- a/src/autoskillit/cli/session/pty/_exec.py +++ b/src/autoskillit/cli/session/pty/_exec.py @@ -12,11 +12,25 @@ _MODULE_NAME = "autoskillit.cli.session.pty._exec" -def launcher_argv(slave_fd: int, command: Sequence[str]) -> tuple[str, ...]: +def launcher_argv( + slave_fd: int, + command: Sequence[str], + *, + lease_fds: Sequence[int] = (), +) -> tuple[str, ...]: """Build the interpreter command used by the parent-side process owner.""" _validate_slave_fd(slave_fd) normalized = _validate_command(command) - return (sys.executable, "-m", _MODULE_NAME, str(slave_fd), "--", *normalized) + normalized_leases = _validate_lease_fds(lease_fds, slave_fd=slave_fd) + return ( + sys.executable, + "-m", + _MODULE_NAME, + str(slave_fd), + *(str(fd) for fd in sorted(normalized_leases)), + "--", + *normalized, + ) def exec_in_pty( @@ -56,13 +70,23 @@ def exec_in_pty( def main(argv: Sequence[str] | None = None) -> NoReturn: """Parse the deliberately small launcher protocol and exec the target.""" arguments = tuple(sys.argv[1:] if argv is None else argv) - if len(arguments) < 3 or arguments[1] != "--": - raise SystemExit("usage: _exec SLAVE_FD -- COMMAND [ARG ...]") + try: + separator = arguments.index("--") + except ValueError as exc: + raise SystemExit("usage: _exec SLAVE_FD [LEASE_FD ...] -- COMMAND [ARG ...]") from exc + if separator < 1 or separator + 1 >= len(arguments): + raise SystemExit("usage: _exec SLAVE_FD [LEASE_FD ...] -- COMMAND [ARG ...]") try: slave_fd = int(arguments[0], 10) + lease_fds = tuple(int(value, 10) for value in arguments[1:separator]) except ValueError as exc: - raise SystemExit("SLAVE_FD must be a decimal integer") from exc - exec_in_pty(slave_fd, arguments[2:], os.environ) + raise SystemExit("SLAVE_FD and LEASE_FD values must be decimal integers") from exc + exec_in_pty( + slave_fd, + arguments[separator + 1 :], + os.environ, + lease_fds=lease_fds, + ) def _validate_slave_fd(slave_fd: int) -> None: diff --git a/src/autoskillit/cli/session/pty/_observer.py b/src/autoskillit/cli/session/pty/_observer.py index 2b85352e92..c06b5449dc 100644 --- a/src/autoskillit/cli/session/pty/_observer.py +++ b/src/autoskillit/cli/session/pty/_observer.py @@ -7,7 +7,6 @@ import fcntl import math import os -import re import selectors import signal import sqlite3 @@ -22,10 +21,12 @@ from types import FrameType from typing import Any +import regex as re + _WINDOW_LIMIT = 64 * 1024 _RELAY_CHUNK_SIZE = 64 * 1024 _RELAY_SELECT_SECONDS = 0.05 -_SUPPORTED_STATE_DATABASES = {"codex-cli 0.145.0": "state_5.sqlite"} +_CODEX_STATE_READINESS_COMMIT = "ad65f016ed0c91992fb175fa881a373cc460dd2a" _ANSI_ESCAPE_RE = re.compile( r""" \x1b @@ -58,6 +59,20 @@ class ObserverStatus(StrEnum): CANCELLED = "cancelled" +@dataclass(frozen=True, slots=True) +class _StateReadinessDef: + database_name: str + upstream_commit: str + + +_SUPPORTED_STATE_CONTRACTS = { + "codex-cli 0.145.0": _StateReadinessDef( + database_name="state_5.sqlite", + upstream_commit=_CODEX_STATE_READINESS_COMMIT, + ) +} + + @dataclass(frozen=True, slots=True) class CodexStateReadinessProbe: """Read the version-mapped disposable Codex state database without mutation.""" @@ -76,8 +91,14 @@ def __post_init__(self) -> None: @property def database_path(self) -> Path | None: """Return the exact database path for a supported Codex version.""" - filename = _SUPPORTED_STATE_DATABASES.get(self.codex_version) - return None if filename is None else self.sqlite_home / filename + compatibility = _SUPPORTED_STATE_CONTRACTS.get(self.codex_version) + return None if compatibility is None else self.sqlite_home / compatibility.database_name + + @property + def upstream_commit(self) -> str | None: + """Return the source revision defining the probed schema contract.""" + compatibility = _SUPPORTED_STATE_CONTRACTS.get(self.codex_version) + return None if compatibility is None else compatibility.upstream_commit def check(self) -> ObserverStatus: """Perform one zero-wait, read-only readiness observation.""" @@ -242,7 +263,8 @@ def relay( if master_fd < 0 or stdin_fd < 0 or stdout_fd < 0: raise ValueError("PTY relay descriptors must be non-negative") is_cancelled = cancelled or (lambda: False) - selector = selectors.DefaultSelector() + selector_factory = selectors.DefaultSelector + selector = selector_factory() previous_winch: Callable[[int, FrameType | None], Any] | int | None = None installed_winch = False diff --git a/src/autoskillit/core/__init__.pyi b/src/autoskillit/core/__init__.pyi index 119c3c05bf..2bbd8c9194 100644 --- a/src/autoskillit/core/__init__.pyi +++ b/src/autoskillit/core/__init__.pyi @@ -283,6 +283,7 @@ from .types import REVIEW_APPROACH_MARKER as REVIEW_APPROACH_MARKER from .types import ROUTING_AUTHORITY_CLAUSE as ROUTING_AUTHORITY_CLAUSE from .types import RUN_PYTHON_SENTINEL_KEYS as RUN_PYTHON_SENTINEL_KEYS from .types import SCOPE_DIRECTION_SOURCE_TYPES as SCOPE_DIRECTION_SOURCE_TYPES +from .types import SESSION_ADD_DIR_SUBDIR as SESSION_ADD_DIR_SUBDIR from .types import SESSION_TYPE_ENV_VAR as SESSION_TYPE_ENV_VAR from .types import SESSION_TYPE_FLEET as SESSION_TYPE_FLEET from .types import SESSION_TYPE_ORCHESTRATOR as SESSION_TYPE_ORCHESTRATOR diff --git a/src/autoskillit/core/types/_type_constants.py b/src/autoskillit/core/types/_type_constants.py index 1b85daca93..536b1dd296 100644 --- a/src/autoskillit/core/types/_type_constants.py +++ b/src/autoskillit/core/types/_type_constants.py @@ -57,6 +57,7 @@ "CODEX_ACTIVE_VIEWS_SUBDIR", "CODEX_ARCHIVED_SESSIONS_SUBDIR", "CODEX_SESSIONS_SUBDIR", + "SESSION_ADD_DIR_SUBDIR", ] OUTPUT_DISCIPLINE_POLICY_VERSION = 1 @@ -531,3 +532,4 @@ class SkillFamilyDef(NamedTuple): CODEX_SESSIONS_SUBDIR: str = "codex-sessions" CODEX_ARCHIVED_SESSIONS_SUBDIR: str = "codex-archived-sessions" CODEX_ACTIVE_VIEWS_SUBDIR: str = "codex-active-sessions" +SESSION_ADD_DIR_SUBDIR: str = "add-dir" diff --git a/src/autoskillit/core/types/_type_protocols_backend.py b/src/autoskillit/core/types/_type_protocols_backend.py index 84e1c250b4..7f87b60c59 100644 --- a/src/autoskillit/core/types/_type_protocols_backend.py +++ b/src/autoskillit/core/types/_type_protocols_backend.py @@ -168,6 +168,7 @@ def build_interactive_cmd( model: str | None = None, plugin_source: PluginSource | None = None, add_dirs: Sequence[Path | str | ValidatedAddDir] = (), + generated_home: Path | None = None, resume_spec: ResumeSpec = NoResume(), system_prompt: str | None = None, env_extras: Mapping[str, str] | None = None, @@ -198,6 +199,7 @@ def cook_session_context( self, *, session_home: Path, + project_dir: Path, launch_id: str, attempt: int, current_resume_spec: ResumeSpec, diff --git a/src/autoskillit/execution/__init__.py b/src/autoskillit/execution/__init__.py index 21c35ba23c..a662bb8f5b 100644 --- a/src/autoskillit/execution/__init__.py +++ b/src/autoskillit/execution/__init__.py @@ -54,6 +54,7 @@ resolve_unique_codex_host_correlation, sync_hooks_to_codex_config, ) +from autoskillit.execution.backends._codex_prelaunch import codex_prelaunch_transaction from autoskillit.execution.ci import DefaultCIWatcher from autoskillit.execution.commands import ClaudeHeadlessCmd from autoskillit.execution.db import ( @@ -279,6 +280,7 @@ "_serialize_toml", "_write_codex_config", "codex_recipe_delivery_calling_contract", + "codex_prelaunch_transaction", "ensure_codex_mcp_registered", "enumerate_fresh_codex_marker_ids", "generate_codex_hooks_config", diff --git a/src/autoskillit/execution/backends/AGENTS.md b/src/autoskillit/execution/backends/AGENTS.md index 0b71d5b790..01163044c0 100644 --- a/src/autoskillit/execution/backends/AGENTS.md +++ b/src/autoskillit/execution/backends/AGENTS.md @@ -16,7 +16,7 @@ IL-1 backend abstraction layer — concrete `CodingAgentBackend` implementations | `_codex_config.py` | TOML serialization with `[[key]]` array-of-tables support, MCP registration (`ensure_codex_mcp_registered`, `_serialize_toml`) | | `_codex_config_lock.py` | Canonical source-config advisory lock with timeout, ownership diagnostics, and non-reentrant lifecycle | | `_codex_hooks.py` | Codex config.toml hook generation, sync, and upsert (`generate_codex_hooks_config`, `sync_hooks_to_codex_config`) | -| `_codex_parse.py` | `CodexStreamParser`, `CodexResultParser`, `_scan_codex_ndjson`, `_CodexParseAccumulator` | +| `_codex_parse.py` | `CodexStreamParser`, `CodexResultParser`, NDJSON scanning, and bounded persisted-rollout parsing | | `_codex_prelaunch.py` | Sole composed source-config synchronization, hook update, snapshot, and native-validation transaction | | `_codex_recipe_delivery.py` | Protected outer-budget provider seam, bounded diagnostic correlation, and durable consumed-call/receipt ledger | | `_codex_session_storage.py` | Canonical rollout store, per-attempt views and leases, durable promotion/recovery, and derived session index | diff --git a/src/autoskillit/execution/backends/_codex_parse.py b/src/autoskillit/execution/backends/_codex_parse.py index bfd1640bcb..66dfea6f2b 100644 --- a/src/autoskillit/execution/backends/_codex_parse.py +++ b/src/autoskillit/execution/backends/_codex_parse.py @@ -1,11 +1,17 @@ -"""NDJSON stream/result parsing for the Codex backend.""" +"""NDJSON and persisted-rollout parsing for the Codex backend.""" from __future__ import annotations import json -from collections.abc import Sequence +import os +import stat +from collections.abc import Iterator, Mapping, Sequence +from contextlib import contextmanager from dataclasses import dataclass, field -from typing import Any +from pathlib import Path +from typing import Any, BinaryIO + +import zstandard from autoskillit.core import ( AGENT_BACKEND_CODEX, @@ -23,6 +29,191 @@ from autoskillit.execution.process import _marker_is_standalone logger = get_logger(__name__) +_ROLLOUT_METADATA_LIMIT = 64 * 1024 +_ROLLOUT_SUFFIXES = (".jsonl", ".jsonl.zst") + + +def _safe_relative_value(value: str) -> Path: + if not value or "\\" in value: + raise RuntimeError(f"Unsafe relative rollout path: {value!r}") + relative = Path(value) + if relative.is_absolute() or any(part in {"", ".", ".."} for part in relative.parts): + raise RuntimeError(f"Unsafe relative rollout path: {value!r}") + if not relative.name.endswith(_ROLLOUT_SUFFIXES): + raise RuntimeError(f"Unsupported rollout filename: {value!r}") + return relative + + +def _require_real_directory(path: Path, *, label: str) -> None: + try: + mode = path.lstat().st_mode + except FileNotFoundError as exc: + raise RuntimeError(f"Missing {label}: {path}") from exc + if stat.S_ISLNK(mode) or not stat.S_ISDIR(mode): + raise RuntimeError(f"{label} must be a non-symlink directory: {path}") + + +def _safe_relative(path: Path, root: Path) -> Path: + _require_real_directory(root, label="rollout root") + try: + mode = path.lstat().st_mode + except FileNotFoundError as exc: + raise RuntimeError(f"Missing rollout file: {path}") from exc + if stat.S_ISLNK(mode) or not stat.S_ISREG(mode): + raise RuntimeError(f"Rollout must be a regular non-symlink file: {path}") + try: + relative = path.relative_to(root) + except ValueError as exc: + raise RuntimeError(f"Rollout escapes its root: {path}") from exc + relative = _safe_relative_value(relative.as_posix()) + cursor = root + for part in relative.parts[:-1]: + cursor /= part + _require_real_directory(cursor, label="rollout parent") + return relative + + +def _rollout_files(root: Path) -> Iterator[Path]: + if not os.path.lexists(root): + return + _require_real_directory(root, label="rollout root") + found: list[Path] = [] + for directory, directory_names, file_names in os.walk(root, followlinks=False): + parent = Path(directory) + for name in directory_names: + candidate = parent / name + if candidate.is_symlink(): + raise RuntimeError(f"Symlink directory in rollout tree: {candidate}") + for name in file_names: + candidate = parent / name + if candidate.is_symlink(): + raise RuntimeError(f"Symlink file in rollout tree: {candidate}") + if name.endswith(_ROLLOUT_SUFFIXES): + _safe_relative(candidate, root) + found.append(candidate) + yield from sorted(found) + + +def _read_prefix(path: Path, limit: int) -> bytes: + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + fd = os.open(path, flags) + try: + file_stat = os.fstat(fd) + if not stat.S_ISREG(file_stat.st_mode): + raise ValueError(f"{path} is not a regular file") + if path.name.endswith(".zst"): + with os.fdopen(fd, "rb", closefd=False) as source: + with zstandard.ZstdDecompressor().stream_reader(source) as reader: + return reader.read(limit) + return os.read(fd, limit) + finally: + os.close(fd) + + +def _thread_id_from_bytes(data: bytes) -> str | None: + for raw_line in data.splitlines(): + if not raw_line.strip(): + continue + try: + row = json.loads(raw_line) + except (UnicodeDecodeError, json.JSONDecodeError): + continue + if not isinstance(row, Mapping): + continue + if row.get("type") == "thread.started" and isinstance(row.get("thread_id"), str): + return str(row["thread_id"]) + if row.get("type") == "session_meta": + payload = row.get("payload") + if isinstance(payload, Mapping) and isinstance(payload.get("id"), str): + return str(payload["id"]) + return None + + +def _cwd_from_bytes(data: bytes) -> str | None: + for raw_line in data.splitlines(): + if not raw_line.strip(): + continue + try: + row = json.loads(raw_line) + except (UnicodeDecodeError, json.JSONDecodeError): + continue + if not isinstance(row, Mapping) or row.get("type") != "session_meta": + continue + payload = row.get("payload") + if isinstance(payload, Mapping) and isinstance(payload.get("cwd"), str): + raw_cwd = str(payload["cwd"]) + path = Path(raw_cwd) + if path.is_absolute(): + return str(path.expanduser().resolve(strict=False)) + return None + + +def _thread_id(path: Path) -> str | None: + try: + return _thread_id_from_bytes(_read_prefix(path, _ROLLOUT_METADATA_LIMIT)) + except (OSError, ValueError, zstandard.ZstdError): + return None + + +def _rollout_cwd(path: Path) -> str | None: + try: + return _cwd_from_bytes(_read_prefix(path, _ROLLOUT_METADATA_LIMIT)) + except (OSError, ValueError, zstandard.ZstdError): + return None + + +def _identity(path: Path) -> tuple[int, int]: + file_stat = path.stat(follow_symlinks=False) + if not stat.S_ISREG(file_stat.st_mode): + raise RuntimeError(f"Expected regular rollout file: {path}") + return file_stat.st_dev, file_stat.st_ino + + +@contextmanager +def _logical_rollout_reader(path: Path) -> Iterator[BinaryIO]: + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + fd = os.open(path, flags) + try: + file_stat = os.fstat(fd) + if not stat.S_ISREG(file_stat.st_mode): + raise RuntimeError(f"Rollout must be a regular file: {path}") + with os.fdopen(fd, "rb", closefd=False) as source: + if path.name.endswith(".zst"): + with zstandard.ZstdDecompressor().stream_reader(source) as reader: + yield reader + else: + yield source + finally: + os.close(fd) + + +def _read_exact(reader: BinaryIO, size: int) -> bytes: + chunks: list[bytes] = [] + remaining = size + while remaining: + chunk = reader.read(remaining) + if not chunk: + break + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) + + +def _preserves_rollout_prefix(prior: Path, candidate: Path) -> bool: + """Return whether candidate preserves every logical byte already in prior.""" + if _identity(prior) == _identity(candidate): + return True + try: + with ( + _logical_rollout_reader(prior) as prior_reader, + _logical_rollout_reader(candidate) as candidate_reader, + ): + while chunk := prior_reader.read(64 * 1024): + if _read_exact(candidate_reader, len(chunk)) != chunk: + return False + except (OSError, ValueError, zstandard.ZstdError): + return False + return True @dataclass diff --git a/src/autoskillit/execution/backends/_codex_session_storage.py b/src/autoskillit/execution/backends/_codex_session_storage.py index 38068d7b7a..93a226108a 100644 --- a/src/autoskillit/execution/backends/_codex_session_storage.py +++ b/src/autoskillit/execution/backends/_codex_session_storage.py @@ -6,19 +6,18 @@ import hashlib import json import os -import re import shutil import socket import stat import sys import time -from collections.abc import Iterator, Mapping, Sequence +from collections.abc import Mapping, Sequence from contextlib import AbstractContextManager from dataclasses import dataclass, field from pathlib import Path from typing import Any -import zstandard +import regex as re from autoskillit.core import ( CODEX_ACTIVE_VIEWS_SUBDIR, @@ -31,17 +30,27 @@ ResumeSpec, SessionSummary, default_log_dir, + get_logger, +) +from autoskillit.execution.backends._codex_parse import ( + _identity, + _preserves_rollout_prefix, + _rollout_cwd, + _rollout_files, + _safe_relative, + _safe_relative_value, + _thread_id, ) _VIEW_ID_RE = re.compile(r"^[0-9a-f]{16}-[1-9][0-9]*$") +_LAUNCH_ID_RE = re.compile(r"^[0-9a-f]{16}$") _THREAD_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$") -_ROLLOUT_SUFFIXES = (".jsonl", ".jsonl.zst") _INDEX_READ_LIMIT = 4 * 1024 * 1024 -_ROLLOUT_METADATA_LIMIT = 64 * 1024 _MANIFEST_READ_LIMIT = 256 * 1024 _MANIFEST_NAME = "manifest.json" _LOCKS_SUBDIR = ".locks" _INDEX_NAME = "codex-session-index.json" +logger = get_logger(__name__) _MANIFEST_STATES = frozenset({"prepared", "running", "finalizing", "complete", "failed"}) _STORE_TO_PUBLIC = {"active": "sessions", "archived": "archived_sessions"} _PUBLIC_TO_STORE = {value: key for key, value in _STORE_TO_PUBLIC.items()} @@ -68,9 +77,10 @@ } -def codex_session_index_path() -> Path: +def codex_session_index_path(log_dir: Path | None = None) -> Path: """Return the one production path for the derived Codex cook index.""" - return default_log_dir() / _INDEX_NAME + root = default_log_dir() if log_dir is None else Path(log_dir) + return root.expanduser().resolve(strict=False) / _INDEX_NAME def _lexists(path: Path) -> bool: @@ -146,74 +156,6 @@ def _read_bounded(path: Path, limit: int) -> bytes: os.close(fd) -def _read_prefix(path: Path, limit: int) -> bytes: - flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) - fd = os.open(path, flags) - try: - file_stat = os.fstat(fd) - if not stat.S_ISREG(file_stat.st_mode): - raise ValueError(f"{path} is not a regular file") - if path.name.endswith(".zst"): - with os.fdopen(fd, "rb", closefd=False) as source: - with zstandard.ZstdDecompressor().stream_reader(source) as reader: - return reader.read(limit) - return os.read(fd, limit) - finally: - os.close(fd) - - -def _safe_relative_value(value: str) -> Path: - if not value or "\\" in value: - raise RuntimeError(f"Unsafe relative rollout path: {value!r}") - relative = Path(value) - if relative.is_absolute() or any(part in {"", ".", ".."} for part in relative.parts): - raise RuntimeError(f"Unsafe relative rollout path: {value!r}") - if not relative.name.endswith(_ROLLOUT_SUFFIXES): - raise RuntimeError(f"Unsupported rollout filename: {value!r}") - return relative - - -def _safe_relative(path: Path, root: Path) -> Path: - _require_real_directory(root, label="rollout root") - try: - mode = path.lstat().st_mode - except FileNotFoundError as exc: - raise RuntimeError(f"Missing rollout file: {path}") from exc - if stat.S_ISLNK(mode) or not stat.S_ISREG(mode): - raise RuntimeError(f"Rollout must be a regular non-symlink file: {path}") - try: - relative = path.relative_to(root) - except ValueError as exc: - raise RuntimeError(f"Rollout escapes its root: {path}") from exc - relative = _safe_relative_value(relative.as_posix()) - cursor = root - for part in relative.parts[:-1]: - cursor /= part - _require_real_directory(cursor, label="rollout parent") - return relative - - -def _rollout_files(root: Path) -> Iterator[Path]: - if not _lexists(root): - return - _require_real_directory(root, label="rollout root") - found: list[Path] = [] - for directory, directory_names, file_names in os.walk(root, followlinks=False): - parent = Path(directory) - for name in directory_names: - candidate = parent / name - if candidate.is_symlink(): - raise RuntimeError(f"Symlink directory in rollout tree: {candidate}") - for name in file_names: - candidate = parent / name - if candidate.is_symlink(): - raise RuntimeError(f"Symlink file in rollout tree: {candidate}") - if name.endswith(_ROLLOUT_SUFFIXES): - _safe_relative(candidate, root) - found.append(candidate) - yield from sorted(found) - - def _ensure_directory_chain(root: Path, relative: Path) -> Path: _require_real_directory(root, label="storage root") cursor = root @@ -269,39 +211,6 @@ def _filesystem_type(path: Path) -> str: return selected[1] -def _thread_id_from_bytes(data: bytes) -> str | None: - for raw_line in data.splitlines(): - if not raw_line.strip(): - continue - try: - row = json.loads(raw_line) - except (UnicodeDecodeError, json.JSONDecodeError): - continue - if not isinstance(row, Mapping): - continue - if row.get("type") == "thread.started" and isinstance(row.get("thread_id"), str): - return str(row["thread_id"]) - if row.get("type") == "session_meta": - payload = row.get("payload") - if isinstance(payload, Mapping) and isinstance(payload.get("id"), str): - return str(payload["id"]) - return None - - -def _thread_id(path: Path) -> str | None: - try: - return _thread_id_from_bytes(_read_prefix(path, _ROLLOUT_METADATA_LIMIT)) - except (OSError, ValueError, zstandard.ZstdError): - return None - - -def _identity(path: Path) -> tuple[int, int]: - file_stat = path.stat(follow_symlinks=False) - if not stat.S_ISREG(file_stat.st_mode): - raise RuntimeError(f"Expected regular rollout file: {path}") - return file_stat.st_dev, file_stat.st_ino - - def _replace_symlink(path: Path, target: Path) -> None: temporary = path.with_name(f".{path.name}.{os.getpid()}.link") try: @@ -380,11 +289,13 @@ def __enter__(self) -> CookSessionHandle: try: self.store._enter_attempt(self) except BaseException as entry_error: + logger.error("codex_attempt_entry_failed", exc_info=True) self._closed = True failures: list[BaseException] = [entry_error] try: self.store._abort_pre_spawn(self) except BaseException as cleanup_error: + logger.error("codex_attempt_entry_rollback_failed", exc_info=True) failures.append(cleanup_error) self._release_leases(failures) if len(failures) == 1: @@ -432,10 +343,12 @@ def _release_leases(self, failures: list[BaseException]) -> None: try: self.thread_lease.release() except BaseException as release_error: + logger.error("codex_thread_lease_release_failed", exc_info=True) failures.append(release_error) try: self.view_lease.release() except BaseException as release_error: + logger.error("codex_view_lease_release_failed", exc_info=True) failures.append(release_error) def __exit__( @@ -451,6 +364,7 @@ def __exit__( try: self.store._exit_attempt(self) except BaseException as cleanup_error: + logger.error("codex_attempt_exit_failed", exc_info=True) failures.append(cleanup_error) finally: self._release_leases(failures) @@ -480,7 +394,7 @@ def __init__(self, log_dir: Path, index_path: Path | None = None) -> None: self.index_path = ( Path(index_path).expanduser().resolve(strict=False) if index_path is not None - else self.log_dir / _INDEX_NAME + else codex_session_index_path(self.log_dir) ) def _ensure_roots(self) -> None: @@ -515,6 +429,7 @@ def prepare_attempt( self, *, session_home: Path, + project_dir: Path, launch_id: str, attempt: int, current_resume_spec: ResumeSpec, @@ -524,21 +439,21 @@ def prepare_attempt( if _VIEW_ID_RE.fullmatch(view_id) is None: raise ValueError(f"Invalid Codex attempt identity: {view_id!r}") session_home = Path(session_home).resolve(strict=True) + project_path = Path(project_dir) + if not project_path.is_absolute(): + raise ValueError("Codex project directory must be absolute") + resolved_project = project_path.resolve(strict=True) + if resolved_project != project_path or not resolved_project.is_dir(): + raise ValueError("Codex project directory must be canonical") inert_targets = self._validate_inert_home(session_home) view_path = self.views_root / view_id - if os.path.lexists(view_path): - raise FileExistsError(f"Codex attempt view already exists: {view_id}") - view_path.mkdir(mode=0o700) - (view_path / "sessions").mkdir() - (view_path / "archived_sessions").mkdir() - _fsync_directory(view_path) - _fsync_directory(self.views_root) view_lease = _FileLease.acquire(self.locks_root / f"view-{view_id}.lock") manifest: dict[str, Any] = { "schema_version": 1, "launch_id": launch_id, "attempt": attempt, "view_id": view_id, + "project_cwd": str(resolved_project), "state": "prepared", "child_pid": None, "child_pgid": None, @@ -550,7 +465,16 @@ def prepare_attempt( "final_relpath": None, } thread_lease: _FileLease | None = None + view_created = False try: + if os.path.lexists(view_path): + raise FileExistsError(f"Codex attempt view already exists: {view_id}") + view_path.mkdir(mode=0o700) + view_created = True + (view_path / "sessions").mkdir() + (view_path / "archived_sessions").mkdir() + _fsync_directory(view_path) + _fsync_directory(self.views_root) if isinstance(current_resume_spec, NamedResume): thread_id = current_resume_spec.session_id thread_lease = _FileLease.acquire( @@ -598,7 +522,7 @@ def prepare_attempt( if thread_lease is not None: thread_lease.release() view_lease.release() - if _lexists(view_path): + if view_created and _lexists(view_path): self._validate_pre_spawn_view(view_path, manifest, allow_missing_resume=True) shutil.rmtree(view_path) _fsync_directory(self.views_root) @@ -737,65 +661,159 @@ def _validate_completed_view(self, view_path: Path) -> None: raise RuntimeError(f"Completed Codex view retains unexpected data: {path}") def _promote_view(self, lease: CodexInteractiveSessionLease) -> list[dict[str, Any]]: - rows: list[dict[str, Any]] = [] - for public_name, store_name, canonical_root in ( - ("sessions", "active", self.active_root), - ("archived_sessions", "archived", self.archive_root), - ): + candidates: list[tuple[str, Path, Path, str]] = [] + for public_name, store_name in _PUBLIC_TO_STORE.items(): view_root = lease.view_path / public_name - for source in list(_rollout_files(view_root)): + for source in _rollout_files(view_root): relative = _safe_relative(source, view_root) thread_id = _thread_id(source) if thread_id is None: raise RuntimeError(f"Rollout lacks a Codex thread id: {source}") - resume_thread_id = lease.manifest.get("resume_thread_id") - if resume_thread_id is not None and thread_id != resume_thread_id: - raise RuntimeError("Resumed Codex view contains a different thread identity") - destination = canonical_root / relative - _ensure_directory_chain(canonical_root, relative.parent) - if _lexists(destination): - if destination.is_symlink() or _identity(source) != _identity(destination): + candidates.append((store_name, relative, source, thread_id)) + + resume_thread_id = lease.manifest.get("resume_thread_id") + if isinstance(resume_thread_id, str) and any( + thread_id != resume_thread_id for _, _, _, thread_id in candidates + ): + raise RuntimeError("Resumed Codex view contains a different thread identity") + + final_store = lease.manifest.get("final_store") + final_relpath_value = lease.manifest.get("final_relpath") + if (final_store is None) != (final_relpath_value is None): + raise RuntimeError("Codex final rollout metadata is incomplete") + + selected_source: Path | None = None + if isinstance(final_store, str) and isinstance(final_relpath_value, str): + if final_store not in _STORE_TO_PUBLIC: + raise RuntimeError(f"Invalid final Codex store: {final_store!r}") + final_relative = _safe_relative_value(final_relpath_value) + for store_name, relative, source, _ in candidates: + if store_name == final_store and relative == final_relative: + selected_source = source + break + else: + if not candidates: + raise RuntimeError("Codex attempt has no rollout data to promote") + selectable = candidates + resume_store = lease.manifest.get("resume_source_store") + resume_relpath = lease.manifest.get("resume_source_relpath") + if isinstance(resume_store, str) and isinstance(resume_relpath, str): + transitioned = [ + candidate + for candidate in candidates + if (candidate[0], candidate[1].as_posix()) != (resume_store, resume_relpath) + ] + if transitioned: + selectable = transitioned + unique_locations = { + (store_name, relative.as_posix()) for store_name, relative, _, _ in selectable + } + if len(unique_locations) != 1: + raise RuntimeError("Codex rollout transition is ambiguous; preserving staged data") + final_store, final_relative, selected_source, _ = selectable[0] + + canonical_root = self.active_root if final_store == "active" else self.archive_root + destination = canonical_root / final_relative + _ensure_directory_chain(canonical_root, final_relative.parent) + if selected_source is None and not _lexists(destination): + raise RuntimeError("Final Codex rollout is missing from staging and canonical storage") + + comparison_source = selected_source if selected_source is not None else destination + destination_thread_id = _thread_id(comparison_source) + if destination_thread_id is None: + raise RuntimeError("Final Codex rollout lacks a thread identity") + if isinstance(resume_thread_id, str) and destination_thread_id != resume_thread_id: + raise RuntimeError("Final Codex rollout changed thread identity") + + for _, _, source, thread_id in candidates: + if thread_id != destination_thread_id: + raise RuntimeError("Codex view contains multiple thread identities") + if not _preserves_rollout_prefix(source, comparison_source): + raise RuntimeError("Codex rollout transition would discard staged rollout content") + + canonical_matches = self._canonical_matches(destination_thread_id) + obsolete_canonical: list[Path] = [] + for _, canonical in canonical_matches: + if canonical == destination: + continue + if not _preserves_rollout_prefix(canonical, comparison_source): + raise RuntimeError( + "Codex rollout transition would discard canonical rollout content" + ) + obsolete_canonical.append(canonical) + + if selected_source is not None: + if _lexists(destination): + if destination.is_symlink() or _identity(selected_source) != _identity( + destination + ): + raise RuntimeError( + f"Codex rollout collision preserves both files: {destination}" + ) + else: + try: + os.link(selected_source, destination, follow_symlinks=False) + except FileExistsError: + if _identity(selected_source) != _identity(destination): raise RuntimeError( f"Codex rollout collision preserves both files: {destination}" ) - else: - try: - os.link(source, destination, follow_symlinks=False) - except FileExistsError: - if _identity(source) != _identity(destination): - raise RuntimeError( - f"Codex rollout collision preserves both files: {destination}" - ) - if _identity(source) != _identity(destination): - raise RuntimeError("Promoted Codex rollout identity mismatch") - file_fd = os.open(destination, os.O_RDONLY) - try: - os.fsync(file_fd) - finally: - os.close(file_fd) - _fsync_directory(destination.parent) - lease.manifest.update( - final_store=store_name, - final_relpath=relative.as_posix(), - ) - self._write_manifest(lease) + if _identity(selected_source) != _identity(destination): + raise RuntimeError("Promoted Codex rollout identity mismatch") + file_fd = os.open(destination, os.O_RDONLY) + try: + os.fsync(file_fd) + finally: + os.close(file_fd) + _fsync_directory(destination.parent) + + destination_thread_id = _thread_id(destination) + if destination_thread_id is None: + raise RuntimeError("Final Codex rollout lacks a thread identity") + if isinstance(resume_thread_id, str) and destination_thread_id != resume_thread_id: + raise RuntimeError("Final Codex rollout changed thread identity") + + if lease.manifest.get("final_store") is None: + lease.manifest.update( + final_store=final_store, + final_relpath=final_relative.as_posix(), + ) + self._write_manifest(lease) + + for canonical in obsolete_canonical: + if _lexists(canonical): + canonical.unlink() + _fsync_directory(canonical.parent) + + for _, _, source, _ in candidates: + if _lexists(source): source.unlink() _fsync_directory(source.parent) - rows.append( - self._index_row( - thread_id=thread_id, - launch_id=lease.launch_id, - canonical_store=store_name, - relative_path=relative, - ) - ) - return rows + + remaining = [ + path + for public_name in _INERT_NAMES + for path in _rollout_files(lease.view_path / public_name) + ] + if remaining: + raise RuntimeError("Codex view retains rollout data after promotion") + + return [ + self._index_row( + thread_id=destination_thread_id, + launch_id=lease.launch_id, + cwd=str(lease.manifest["project_cwd"]), + canonical_store=final_store, + relative_path=final_relative, + ) + ] def _index_row( self, *, thread_id: str, launch_id: str | None, + cwd: str, canonical_store: str, relative_path: Path, ) -> dict[str, Any]: @@ -803,7 +821,7 @@ def _index_row( "backend_name": "codex", "session_id": thread_id, "launch_id": launch_id, - "cwd": "", + "cwd": cwd, "first_prompt": "", "summary": "", "git_branch": None, @@ -835,10 +853,28 @@ def _merge_index(self, incoming: Sequence[dict[str, Any]]) -> None: def _merge_index_unlocked(self, incoming: Sequence[dict[str, Any]]) -> None: existing = self._read_index_rows() + existing_by_id = { + str(row["session_id"]): row + for row in existing + if isinstance(row.get("session_id"), str) + } incoming_ids = { str(row["session_id"]) for row in incoming if isinstance(row.get("session_id"), str) } - ordered = [dict(row) for row in incoming] + if len(incoming_ids) != len(incoming): + raise RuntimeError("Codex index update contains duplicate or invalid session ids") + ordered: list[dict[str, Any]] = [] + for row in incoming: + merged = dict(row) + session_id = str(merged["session_id"]) + previous = existing_by_id.get(session_id) + if ( + merged.get("launch_id") is None + and previous is not None + and isinstance(previous.get("launch_id"), str) + ): + merged["launch_id"] = previous["launch_id"] + ordered.append(merged) ordered.extend( row for row in existing @@ -847,18 +883,57 @@ def _merge_index_unlocked(self, incoming: Sequence[dict[str, Any]]) -> None: ) _atomic_json(self.index_path, ordered) + def _rebuild_index_unlocked(self) -> None: + existing_by_id = { + str(row["session_id"]): row + for row in self._read_index_rows() + if isinstance(row.get("session_id"), str) + } + rebuilt: list[dict[str, Any]] = [] + seen: dict[str, Path] = {} + for store_name, root in ( + ("active", self.active_root), + ("archived", self.archive_root), + ): + for path in _rollout_files(root): + thread_id = _thread_id(path) + cwd = _rollout_cwd(path) + if thread_id is None or cwd is None: + continue + previous_path = seen.get(thread_id) + if previous_path is not None: + raise RuntimeError( + "Cannot rebuild Codex index from ambiguous canonical " + f"representations: {previous_path}, {path}" + ) + seen[thread_id] = path + existing = existing_by_id.get(thread_id) + launch_id = ( + str(existing["launch_id"]) + if existing is not None and isinstance(existing.get("launch_id"), str) + else None + ) + rebuilt.append( + self._index_row( + thread_id=thread_id, + launch_id=launch_id, + cwd=cwd, + canonical_store=store_name, + relative_path=_safe_relative(path, root), + ) + ) + _atomic_json(self.index_path, rebuilt) + def read_index(self, cwd: str) -> tuple[SessionSummary, ...]: wanted = str(Path(cwd).expanduser().resolve(strict=False)) summaries: list[SessionSummary] = [] for row in self._read_index_rows(): try: row_cwd_raw = row.get("cwd") - row_cwd = ( - str(Path(str(row_cwd_raw)).expanduser().resolve(strict=False)) - if row_cwd_raw - else "" - ) - if row_cwd and row_cwd != wanted: + if not isinstance(row_cwd_raw, str) or not row_cwd_raw: + continue + row_cwd = str(Path(row_cwd_raw).expanduser().resolve(strict=False)) + if row_cwd != wanted: continue summary = SessionSummary( backend_name=str(row.get("backend_name") or "codex"), @@ -866,7 +941,7 @@ def read_index(self, cwd: str) -> tuple[SessionSummary, ...]: launch_id=( str(row["launch_id"]) if row.get("launch_id") is not None else None ), - cwd=row_cwd or wanted, + cwd=row_cwd, first_prompt=str(row.get("first_prompt") or ""), summary=str(row.get("summary") or ""), git_branch=( @@ -886,39 +961,232 @@ def read_index(self, cwd: str) -> tuple[SessionSummary, ...]: summaries.append(summary) return tuple(summaries) - def _locate_with_store(self, thread_id: str) -> tuple[str, Path] | None: + def _canonical_matches(self, thread_id: str) -> list[tuple[str, Path]]: + matches: list[tuple[str, Path]] = [] for store_name, root in ( ("active", self.active_root), ("archived", self.archive_root), ): for path in _rollout_files(root): if _thread_id(path) == thread_id: - return store_name, path - return None + matches.append((store_name, path)) + return matches + + def _locate_with_store(self, thread_id: str) -> tuple[str, Path] | None: + matches = self._canonical_matches(thread_id) + if len(matches) > 1: + locations = ", ".join(str(path) for _, path in matches) + raise RuntimeError( + f"Ambiguous canonical Codex rollout representations for {thread_id}: {locations}" + ) + return matches[0] if matches else None def locate_session(self, thread_id: str) -> Path | None: located = self._locate_with_store(thread_id) if located is not None: return located[1] + if not self.views_root.is_dir(): + return None for view_path in sorted(self.views_root.iterdir()): if view_path.name == _LOCKS_SUBDIR or not view_path.is_dir(): continue + try: + manifest = json.loads( + _read_bounded(view_path / _MANIFEST_NAME, _MANIFEST_READ_LIMIT) + ) + if not isinstance(manifest, dict): + continue + self._validate_manifest(view_path, manifest) + if manifest["state"] not in {"running", "finalizing"}: + continue + except (OSError, RuntimeError, ValueError, json.JSONDecodeError): + continue for public_name in _INERT_NAMES: for path in _rollout_files(view_path / public_name): if _thread_id(path) == thread_id: return path return None + def _validate_manifest( + self, + view_path: Path, + manifest: Mapping[str, Any], + ) -> None: + _require_real_directory(view_path, label="Codex recovery view") + manifest_path = view_path / _MANIFEST_NAME + try: + manifest_mode = manifest_path.lstat().st_mode + except FileNotFoundError as exc: + raise RuntimeError("Codex recovery view has no manifest") from exc + if stat.S_ISLNK(manifest_mode) or not stat.S_ISREG(manifest_mode): + raise RuntimeError("Codex recovery manifest must be a regular non-symlink file") + expected_entries = {"sessions", "archived_sessions", _MANIFEST_NAME} + if {entry.name for entry in view_path.iterdir()} != expected_entries: + raise RuntimeError("Codex recovery view has an invalid root layout") + for public_name in _INERT_NAMES: + _require_real_directory( + view_path / public_name, + label=f"Codex recovery {public_name} root", + ) + + if manifest.get("schema_version") != 1: + raise RuntimeError("Unsupported Codex recovery manifest schema") + launch_id = manifest.get("launch_id") + attempt = manifest.get("attempt") + view_id = manifest.get("view_id") + if not isinstance(launch_id, str) or _LAUNCH_ID_RE.fullmatch(launch_id) is None: + raise RuntimeError("Codex recovery manifest has an invalid launch id") + if isinstance(attempt, bool) or not isinstance(attempt, int) or attempt <= 0: + raise RuntimeError("Codex recovery manifest has an invalid attempt") + expected_view_id = f"{launch_id}-{attempt}" + if view_id != expected_view_id or view_path.name != expected_view_id: + raise RuntimeError("Codex recovery manifest identity is inconsistent") + + project_cwd = manifest.get("project_cwd") + if not isinstance(project_cwd, str): + raise RuntimeError("Codex recovery manifest has no project discriminator") + project_path = Path(project_cwd) + if ( + not project_path.is_absolute() + or str(project_path.expanduser().resolve(strict=False)) != project_cwd + ): + raise RuntimeError("Codex recovery project discriminator is not canonical") + + state = manifest.get("state") + if state not in _MANIFEST_STATES: + raise RuntimeError("Codex recovery manifest has an invalid lifecycle state") + child_pid = manifest.get("child_pid") + child_pgid = manifest.get("child_pgid") + child_values = (child_pid, child_pgid) + child_absent = child_values == (None, None) + child_valid = all( + not isinstance(value, bool) and isinstance(value, int) and value > 0 + for value in child_values + ) + if not child_absent and not child_valid: + raise RuntimeError("Codex recovery child identity is incomplete") + + reaped = manifest.get("reaped") + if not isinstance(reaped, bool): + raise RuntimeError("Codex recovery reap proof is not boolean") + reaped_ns = manifest.get("reaped_ns") + if reaped: + if not child_valid: + raise RuntimeError("Codex recovery reap proof has no matching child") + if isinstance(reaped_ns, bool) or not isinstance(reaped_ns, int) or reaped_ns <= 0: + raise RuntimeError("Codex recovery reap timestamp is invalid") + elif reaped_ns is not None: + raise RuntimeError("Codex recovery has a reap timestamp without proof") + + resume_thread_id = manifest.get("resume_thread_id") + resume_store = manifest.get("resume_source_store") + resume_relpath = manifest.get("resume_source_relpath") + resume_values = (resume_thread_id, resume_store, resume_relpath) + if resume_values != (None, None, None): + if ( + not isinstance(resume_thread_id, str) + or _THREAD_ID_RE.fullmatch(resume_thread_id) is None + or resume_store not in _STORE_TO_PUBLIC + or not isinstance(resume_relpath, str) + ): + raise RuntimeError("Codex recovery resume metadata is incomplete") + _safe_relative_value(resume_relpath) + + final_store = manifest.get("final_store") + final_relpath = manifest.get("final_relpath") + if (final_store is None) != (final_relpath is None): + raise RuntimeError("Codex recovery final metadata is incomplete") + if final_store is not None: + if final_store not in _STORE_TO_PUBLIC or not isinstance(final_relpath, str): + raise RuntimeError("Codex recovery final metadata is invalid") + final_relative = _safe_relative_value(final_relpath) + if state not in {"finalizing", "complete"}: + raise RuntimeError("Codex recovery final metadata precedes finalization") + if state == "complete": + canonical_root = self.active_root if final_store == "active" else self.archive_root + canonical = canonical_root / final_relative + _safe_relative(canonical, canonical_root) + final_thread_id = _thread_id(canonical) + if final_thread_id is None or ( + isinstance(resume_thread_id, str) and final_thread_id != resume_thread_id + ): + raise RuntimeError("Codex recovery final rollout identity is invalid") + elif state == "complete": + raise RuntimeError("Complete Codex recovery view has no final rollout metadata") + + staged_rollouts: list[tuple[str, Path, Path, str]] = [] + for public_name, store_name in _PUBLIC_TO_STORE.items(): + staged_root = view_path / public_name + for staged in _rollout_files(staged_root): + relative = _safe_relative(staged, staged_root) + thread_id = _thread_id(staged) + if thread_id is None or _THREAD_ID_RE.fullmatch(thread_id) is None: + raise RuntimeError("Codex recovery staged rollout has no valid thread id") + staged_rollouts.append((store_name, relative, staged, thread_id)) + + staged_thread_ids = {item[3] for item in staged_rollouts} + if len(staged_thread_ids) > 1: + raise RuntimeError("Codex recovery view contains multiple thread identities") + if isinstance(resume_thread_id, str) and any( + thread_id != resume_thread_id for *_, thread_id in staged_rollouts + ): + raise RuntimeError("Codex recovery resume view changed thread identity") + + if isinstance(resume_store, str) and isinstance(resume_relpath, str): + resume_root = self.active_root if resume_store == "active" else self.archive_root + resume_relative = _safe_relative_value(resume_relpath) + canonical_resume = resume_root / resume_relative + if state in {"prepared", "running"}: + _safe_relative(canonical_resume, resume_root) + if not staged_rollouts or not any( + _preserves_rollout_prefix(canonical_resume, staged) + for _, _, staged, _ in staged_rollouts + ): + raise RuntimeError( + "Codex recovery resume view does not preserve its canonical source" + ) + + if isinstance(final_store, str) and isinstance(final_relpath, str): + final_root = self.active_root if final_store == "active" else self.archive_root + final_relative = _safe_relative_value(final_relpath) + canonical_final = final_root / final_relative + staged_final = view_path / _STORE_TO_PUBLIC[final_store] / final_relative + final_candidates = [path for path in (staged_final, canonical_final) if _lexists(path)] + if not final_candidates: + raise RuntimeError("Codex recovery final rollout data is missing") + for final_candidate in final_candidates: + expected_root = ( + view_path / _STORE_TO_PUBLIC[final_store] + if final_candidate == staged_final + else final_root + ) + _safe_relative(final_candidate, expected_root) + final_thread_id = _thread_id(final_candidate) + if final_thread_id is None or ( + isinstance(resume_thread_id, str) and final_thread_id != resume_thread_id + ): + raise RuntimeError("Codex recovery final rollout identity is invalid") + + if state == "prepared" and (not child_absent or reaped): + raise RuntimeError("Prepared Codex recovery view has child lifecycle data") + if state in {"running", "finalizing", "complete"} and not child_valid: + raise RuntimeError("Spawned Codex recovery view has no child identity") + if state in {"finalizing", "complete"} and not reaped: + raise RuntimeError("Final Codex recovery view has no reap proof") + def recover(self) -> None: """Recover safely-owned orphan views, then rebuild the derived index.""" self._ensure_roots() + failures: list[BaseException] = [] for view_path in sorted(self.views_root.iterdir()): + if view_path.name == _LOCKS_SUBDIR: + continue if ( - view_path.name == _LOCKS_SUBDIR - or _VIEW_ID_RE.fullmatch(view_path.name) is None + _VIEW_ID_RE.fullmatch(view_path.name) is None or view_path.is_symlink() or not view_path.is_dir() ): + failures.append(RuntimeError(f"Invalid Codex recovery view retained: {view_path}")) continue lock_path = self.locks_root / f"view-{view_path.name}.lock" try: @@ -926,19 +1194,19 @@ def recover(self) -> None: except BlockingIOError: continue try: - manifest_path = view_path / _MANIFEST_NAME - if not manifest_path.is_file() or manifest_path.is_symlink(): - continue try: + manifest_path = view_path / _MANIFEST_NAME manifest = json.loads(_read_bounded(manifest_path, _MANIFEST_READ_LIMIT)) - except (OSError, ValueError, json.JSONDecodeError): - continue - if ( - not isinstance(manifest, dict) - or manifest.get("schema_version") != 1 - or manifest.get("view_id") != view_path.name - or manifest.get("state") not in _MANIFEST_STATES - ): + if not isinstance(manifest, dict): + raise RuntimeError("Codex recovery manifest is not an object") + self._validate_manifest(view_path, manifest) + except BaseException as exc: + logger.error("codex_recovery_manifest_invalid", exc_info=True) + failures.append( + RuntimeError( + f"Invalid Codex recovery manifest retained for {view_path.name}: {exc}" + ) + ) continue state = manifest.get("state") thread_locks: list[_FileLease] = [] @@ -970,8 +1238,6 @@ def recover(self) -> None: shutil.rmtree(view_path) _fsync_directory(self.views_root) elif state in {"prepared", "failed"} and manifest.get("child_pid") is None: - manifest["state"] = "failed" - _atomic_json(manifest_path, manifest) self._validate_pre_spawn_view( view_path, manifest, @@ -979,7 +1245,11 @@ def recover(self) -> None: ) shutil.rmtree(view_path) _fsync_directory(self.views_root) - elif state in {"running", "finalizing"} and manifest.get("reaped") is True: + elif state in {"running", "finalizing", "failed"}: + if manifest.get("reaped") is not True: + manifest["reaped"] = True + manifest["reaped_ns"] = time.time_ns() + _atomic_json(manifest_path, manifest) attempt_lease = CodexInteractiveSessionLease( store=self, session_home=Path("/"), @@ -998,47 +1268,38 @@ def recover(self) -> None: ) manifest["state"] = "finalizing" self._write_manifest(attempt_lease) - self._promote_view(attempt_lease) + recovered_rows = self._promote_view(attempt_lease) + self._merge_index_unlocked(recovered_rows) manifest["state"] = "complete" self._write_manifest(attempt_lease) self._validate_completed_view(view_path) shutil.rmtree(view_path) _fsync_directory(self.views_root) + else: + raise RuntimeError(f"Unsupported Codex recovery state retained: {state!r}") + except BaseException as exc: + logger.error("codex_recovery_view_failed", exc_info=True) + failures.append( + RuntimeError(f"Codex recovery failed closed for {view_path.name}: {exc}") + ) finally: lifecycle.release() for thread_lock in reversed(thread_locks): thread_lock.release() finally: view_lock.release() - rows: list[dict[str, Any]] = [] - for store_name, root in ( - ("active", self.active_root), - ("archived", self.archive_root), - ): - for path in _rollout_files(root): - thread_id = _thread_id(path) - if thread_id is None: - continue - rows.append( - self._index_row( - thread_id=thread_id, - launch_id=None, - canonical_store=store_name, - relative_path=_safe_relative(path, root), - ) - ) lifecycle = _FileLease.acquire(self.locks_root / "lifecycle.lock") try: - deduplicated: list[dict[str, Any]] = [] - seen: set[str] = set() - for row in rows: - thread_id = str(row["session_id"]) - if thread_id not in seen: - seen.add(thread_id) - deduplicated.append(row) - _atomic_json(self.index_path, deduplicated) + self._rebuild_index_unlocked() + except BaseException as exc: + logger.error("codex_recovery_index_rebuild_failed", exc_info=True) + failures.append(RuntimeError(f"Codex recovery index rebuild failed closed: {exc}")) finally: lifecycle.release() + if failures: + if len(failures) == 1: + raise failures[0] + raise BaseExceptionGroup("Codex history recovery failed closed", failures) __all__ = [ diff --git a/src/autoskillit/execution/backends/claude.py b/src/autoskillit/execution/backends/claude.py index cd89bd7e5c..95981c36ff 100644 --- a/src/autoskillit/execution/backends/claude.py +++ b/src/autoskillit/execution/backends/claude.py @@ -20,6 +20,7 @@ NON_VARIADIC_CLAUDE_FLAGS, ORCHESTRATOR_SESSION_REQUIRED_ENV, PROVIDER_PROFILE_ENV_VAR, + SESSION_ADD_DIR_SUBDIR, SESSION_TYPE_ORCHESTRATOR, SESSION_TYPE_SKILL, SKILL_SESSION_REQUIRED_ENV, @@ -443,6 +444,7 @@ def build_interactive_cmd( model: str | None = None, plugin_source: PluginSource | None = None, add_dirs: Sequence[Path | str | ValidatedAddDir] = (), + generated_home: Path | None = None, resume_spec: ResumeSpec = NoResume(), system_prompt: str | None = None, env_extras: Mapping[str, str] | None = None, @@ -489,6 +491,7 @@ def build_interactive_cmd( an interactive session is determined at runtime by kitchen state, not by a ``SESSION_TYPE`` env variable. """ + del generated_home builder = CmdBuilder("claude") builder.mode_flag(ClaudeFlags.DANGEROUSLY_SKIP_PERMISSIONS) match resume_spec: @@ -768,7 +771,9 @@ def validate_session_layout( ) -> list[str]: del project_dir errors: list[str] = [] - skills_dir = session_dir / ClaudeDirectoryConventions.ADD_DIR_SKILLS_SUBDIR + skills_dir = ( + session_dir / SESSION_ADD_DIR_SUBDIR / ClaudeDirectoryConventions.ADD_DIR_SKILLS_SUBDIR + ) if not skills_dir.is_dir(): errors.append(f"skills directory does not exist: {skills_dir}") else: @@ -868,11 +873,12 @@ def cook_session_context( self, *, session_home: Path, + project_dir: Path, launch_id: str, attempt: int, current_resume_spec: ResumeSpec, ) -> AbstractContextManager[CookSessionHandle]: - del session_home, launch_id, attempt, current_resume_spec + del session_home, project_dir, launch_id, attempt, current_resume_spec return nullcontext( CookSessionHandle( view_id="", diff --git a/src/autoskillit/server/_lifespan.py b/src/autoskillit/server/_lifespan.py index 20045c6fcc..afa8bde3fb 100644 --- a/src/autoskillit/server/_lifespan.py +++ b/src/autoskillit/server/_lifespan.py @@ -47,11 +47,11 @@ from autoskillit.execution import ( BACKEND_REGISTRY, RecordingSubprocessRunner, + codex_prelaunch_transaction, ) from autoskillit.execution import ( ensure_codex_mcp_registered as ensure_codex_mcp_registered, ) -from autoskillit.execution.backends._codex_prelaunch import codex_prelaunch_transaction from autoskillit.fleet import ( discover_campaign_state_files, reap_stale_dispatches_async, diff --git a/tests/arch/test_backend_coherence.py b/tests/arch/test_backend_coherence.py index e32e06a20c..6888b3b843 100644 --- a/tests/arch/test_backend_coherence.py +++ b/tests/arch/test_backend_coherence.py @@ -132,15 +132,13 @@ def test_triage_uses_capability_field(): def test_skills_subdir_uses_conventions_field(): - """session_skills.py must read backend.conventions.skills_subdir, not module constants.""" + """Session initialization must read backend conventions, not infer a backend layout.""" import inspect import textwrap from autoskillit.workspace import session_skills - source = inspect.getsource( - session_skills.DefaultSessionSkillManager._materialize_bound_records - ) + source = inspect.getsource(session_skills.DefaultSessionSkillManager._initialize_bound_records) tree = ast.parse(textwrap.dedent(source)) has_skills_subdir = any( isinstance(node, ast.Attribute) and node.attr == "skills_subdir" for node in ast.walk(tree) diff --git a/tests/arch/test_capability_consistency.py b/tests/arch/test_capability_consistency.py index 5212bd7302..3d08ccff6e 100644 --- a/tests/arch/test_capability_consistency.py +++ b/tests/arch/test_capability_consistency.py @@ -8,6 +8,7 @@ from autoskillit.execution.backends import BACKEND_REGISTRY from autoskillit.hook_registry import HOOKS_DIR +from autoskillit.workspace.session_skills import DefaultSessionSkillManager pytestmark = [pytest.mark.layer("arch"), pytest.mark.medium] @@ -89,6 +90,7 @@ def test_required_files_are_created( backend = backend_cls() session_dir = tmp_path / "session" session_dir.mkdir() + assert backend.ensure_pre_launch(session_dir=session_dir) == [] backend.setup_session_dir(session_dir) for filename in sorted(backend.capabilities.required_session_files): assert (session_dir / filename).is_file(), ( @@ -120,7 +122,10 @@ def test_symlinks_are_symlinks( backend = backend_cls() session_dir = tmp_path / "session" session_dir.mkdir() + assert backend.ensure_pre_launch(session_dir=session_dir) == [] backend.setup_session_dir(session_dir) + if backend.capabilities.session_dir_persistent: + DefaultSessionSkillManager._create_inert_rollout_paths(session_dir, backend) missing: list[str] = [] violations: list[str] = [] diff --git a/tests/arch/test_capability_consumption.py b/tests/arch/test_capability_consumption.py index 403f666c76..24571b01a9 100644 --- a/tests/arch/test_capability_consumption.py +++ b/tests/arch/test_capability_consumption.py @@ -47,14 +47,6 @@ class ForwardDeclaredField: ), added_date=date(2026, 6, 2), ), - "session_dir_symlinks": ForwardDeclaredField( - issue=3134, - rationale=( - "production consumer moved to CodexBackend.setup_session_dir — " - "field retained for validate_session_layout" - ), - added_date=date(2026, 6, 2), - ), "patch_format": ForwardDeclaredField( issue=3776, rationale="patch path extraction routing — P2-A3-WP1 (#3787) co-lands consumer", diff --git a/tests/arch/test_no_backend_name_bypass.py b/tests/arch/test_no_backend_name_bypass.py index 814c345450..ad4493769a 100644 --- a/tests/arch/test_no_backend_name_bypass.py +++ b/tests/arch/test_no_backend_name_bypass.py @@ -23,6 +23,8 @@ "execution/headless/_headless_helpers.py", # FeatureDef.requires_backend_alignment is config-layer; no capabilities at scan time "cli/session/_session_launch.py", + # Persisted SessionSummary metadata has no live backend capability object. + "cli/session/_session_picker.py", } ) diff --git a/tests/arch/test_subpackage_isolation.py b/tests/arch/test_subpackage_isolation.py index ae72ba89b8..d6d127dc1b 100644 --- a/tests/arch/test_subpackage_isolation.py +++ b/tests/arch/test_subpackage_isolation.py @@ -1087,7 +1087,7 @@ def test_data_directories_are_not_python_packages() -> None: "(#4293 pipeline tracker split-brain, +65 net lines)", ), "execution/backends/codex.py": ( - 1212, + 1800, "REQ-CNST-010-E9: Codex backend — skill_sigil capability threading adds multi-line " "keyword args to _ensure_skill_prefix call sites and _has_prefix guard; " "write_guard_tool_names env injection adds 7 lines to _codex_exec_extras; " @@ -1117,7 +1117,23 @@ def test_data_directories_are_not_python_packages() -> None: "; output-discipline delivery for fresh interactive sessions and generated agent " "TOMLs (+12 net lines)" "; _register_agent_tomls session-config registration for generated roles " - "(+39 net lines)", + "(+39 net lines)" + "; interactive Codex startup validation, explicit generated-home construction, " + "profile probing, and durable cook-storage adapter integration remain co-located " + "with the backend whose command grammar they validate", + ), + "execution/backends/_codex_session_storage.py": ( + 1400, + "REQ-CNST-010-E13: Codex interactive rollout storage is one transaction boundary " + "covering inode-preserving staging, process/thread/view leases, promotion, index " + "publication, manifest validation, and crash recovery; splitting those state " + "transitions would duplicate invariants across independently mutable modules", + ), + "workspace/session_skills.py": ( + 1400, + "REQ-CNST-010-E14: session skill materialization owns the generated-home lease and " + "cleanup transaction as well as backend-specific layout validation; keeping those " + "operations together preserves the create/validate/yield/delete ownership proof", ), "rules_skill_content.py": ( 1200, diff --git a/tests/arch/test_subpackage_structure.py b/tests/arch/test_subpackage_structure.py index 2aba426dda..0012160c4c 100644 --- a/tests/arch/test_subpackage_structure.py +++ b/tests/arch/test_subpackage_structure.py @@ -65,8 +65,8 @@ def test_type_constants_split_completeness(self): assert len(combined) == len(remaining) + len(env) + len(features) + len(registries), ( "Duplicate symbols across split modules" ) - assert len(combined) == 127, ( - f"Expected 127 symbols total, got {len(combined)} " + assert len(combined) == 130, ( + f"Expected 130 symbols total, got {len(combined)} " f"(remaining={len(remaining)}, env={len(env)}, " f"features={len(features)}, registries={len(registries)})" ) diff --git a/tests/cli/test_cook_env_scrub.py b/tests/cli/test_cook_env_scrub.py index 9300d4110b..45d0965a60 100644 --- a/tests/cli/test_cook_env_scrub.py +++ b/tests/cli/test_cook_env_scrub.py @@ -8,11 +8,14 @@ from __future__ import annotations +from contextlib import nullcontext from pathlib import Path +from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest +from autoskillit.core import CmdSpec, ManagedSessionHome, ValidatedAddDir from autoskillit.execution.backends._backend_cmd_builder_base import SHARED_BASELINE_ENV pytestmark = [pytest.mark.layer("cli"), pytest.mark.small] @@ -113,6 +116,56 @@ def test_launch_cook_session_env_has_mcp_connection_nonblocking( assert env["MCP_CONNECTION_NONBLOCKING"] == "0" +def _capture_cook_spec( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> CmdSpec: + from autoskillit.cli.session._session_cook import cook + from autoskillit.execution.backends.claude import ClaudeCodeBackend + + generated_home = tmp_path / "generated-home" + skills_dir = generated_home / ".claude" / "skills" + skills_dir.mkdir(parents=True) + managed_home = ManagedSessionHome( + launch_id="0123456789abcdef", + generated_home=generated_home, + skills_dir=ValidatedAddDir(path=str(skills_dir)), + pass_fds=(), + ) + mock_mgr = MagicMock() + mock_mgr.managed_session.return_value = nullcontext(managed_home) + captured: dict[str, object] = {} + + def fake_run(spec, **_kwargs): + captured["spec"] = spec + return SimpleNamespace(pid=101, pgid=101, returncode=0) + + monkeypatch.setattr("shutil.which", lambda _cmd: "/usr/bin/claude") + monkeypatch.setattr("autoskillit.cli._onboarding.is_first_run", lambda _path: False) + monkeypatch.setattr("autoskillit.cli.ui._timed_input.timed_prompt", lambda *_a, **_k: "") + monkeypatch.setattr( + "autoskillit.cli._installed_plugins.InstalledPluginsFile.contains", + lambda *_a, **_k: False, + ) + monkeypatch.setattr( + "autoskillit.workspace.DefaultSessionSkillManager", lambda *_a, **_k: mock_mgr + ) + monkeypatch.setattr( + "autoskillit.cli.session._session_process.run_cook_attempt", + fake_run, + ) + monkeypatch.setattr( + "autoskillit.cli.session._session_reload.consume_reload_sentinel", + lambda _path: None, + ) + monkeypatch.setattr("autoskillit.core.write_registry_entry", lambda *_a, **_k: None) + + cook(backend=ClaudeCodeBackend()) + result = captured["spec"] + assert isinstance(result, CmdSpec) + return result + + def test_cook_command_env_excludes_ide_vars( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: @@ -123,28 +176,8 @@ def test_cook_command_env_excludes_ide_vars( # uses; subprocess.run is patched wholesale below, so pin the helper instead. monkeypatch.setattr("autoskillit.cli.session._session_cook.resolve_project_dir", Path.cwd) - fake_skills_dir = tmp_path / "skills" - fake_skills_dir.mkdir() - mock_mgr = MagicMock() - mock_mgr.init_session.return_value = fake_skills_dir - - with ( - patch("shutil.which", return_value="/usr/bin/claude"), - patch("builtins.input", return_value=""), - patch("sys.stdin.isatty", return_value=True), - patch("autoskillit.workspace.DefaultSessionSkillManager", return_value=mock_mgr), - patch( - "autoskillit.cli.session._session_cook.subprocess.run", - return_value=MagicMock(returncode=0), - ) as mock_run, - patch("autoskillit.cli.session._session_cook.terminal_guard"), - ): - from autoskillit.cli.session._session_cook import cook - - cook() - - mock_run.assert_called_once() - env = mock_run.call_args.kwargs["env"] + spec = _capture_cook_spec(monkeypatch, tmp_path) + env = spec.env assert "CLAUDE_CODE_SSE_PORT" not in env assert "ENABLE_IDE_INTEGRATION" not in env assert env["CLAUDE_CODE_AUTO_CONNECT_IDE"] == "0" @@ -156,25 +189,6 @@ def test_cook_command_env_has_max_mcp_output_tokens( """cook() must inject MAX_MCP_OUTPUT_TOKENS=50000 into the subprocess env.""" monkeypatch.chdir(tmp_path) - fake_skills_dir = tmp_path / "skills" - fake_skills_dir.mkdir() - mock_mgr = MagicMock() - mock_mgr.init_session.return_value = fake_skills_dir - - with ( - patch("shutil.which", return_value="/usr/bin/claude"), - patch("builtins.input", return_value=""), - patch("sys.stdin.isatty", return_value=True), - patch("autoskillit.workspace.DefaultSessionSkillManager", return_value=mock_mgr), - patch( - "autoskillit.cli.session._session_cook.subprocess.run", - return_value=MagicMock(returncode=0), - ) as mock_run, - patch("autoskillit.cli.session._session_cook.terminal_guard"), - ): - from autoskillit.cli.session._session_cook import cook - - cook() - - env = mock_run.call_args.kwargs["env"] + spec = _capture_cook_spec(monkeypatch, tmp_path) + env = spec.env assert env["MAX_MCP_OUTPUT_TOKENS"] == SHARED_BASELINE_ENV["MAX_MCP_OUTPUT_TOKENS"] diff --git a/tests/cli/test_cook_process_lifecycle.py b/tests/cli/test_cook_process_lifecycle.py index cb2005349e..e77f472965 100644 --- a/tests/cli/test_cook_process_lifecycle.py +++ b/tests/cli/test_cook_process_lifecycle.py @@ -13,13 +13,10 @@ import pytest from autoskillit.cli.session._session_process import run_cook_attempt +from autoskillit.cli.session.pty._observer import PtyObserver from autoskillit.core import CmdSpec -pytestmark = [ - pytest.mark.layer("cli"), - pytest.mark.medium, - pytest.mark.skipif(os.name != "posix", reason="POSIX process-group contract"), -] +pytestmark = [pytest.mark.layer("cli"), pytest.mark.medium] def _spec(tmp_path: Path, code: str, *, env: dict[str, str] | None = None) -> CmdSpec: @@ -48,9 +45,26 @@ def _kill_if_alive(pid: int) -> None: return +def _assert_unsupported_platform(tmp_path: Path) -> bool: + if os.name == "posix": + return False + with pytest.raises(RuntimeError, match="POSIX process-group ownership"): + run_cook_attempt( + _spec(tmp_path, "pass"), + pass_fds=(), + on_spawn=lambda _pid, _pgid: None, + on_reaped=lambda _pid, _pgid: None, + trace=Mock(), + observer=None, + ) + return True + + def test_direct_attempt_owns_new_group_and_reaps_before_callback( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: + if _assert_unsupported_platform(tmp_path): + return import autoskillit.cli.session._session_process as process_mod actual_popen = subprocess.Popen @@ -94,6 +108,8 @@ def on_reaped(pid: int, pgid: int) -> None: def test_pass_fds_are_inherited_and_callback_identity_is_stable(tmp_path: Path) -> None: + if _assert_unsupported_platform(tmp_path): + return read_fd, write_fd = os.pipe() events: list[tuple[str, int, int]] = [] try: @@ -123,6 +139,8 @@ def test_pass_fds_are_inherited_and_callback_identity_is_stable(tmp_path: Path) def test_grandchild_cannot_outlive_group_empty_reaped_proof(tmp_path: Path) -> None: + if _assert_unsupported_platform(tmp_path): + return grandchild_path = tmp_path / "grandchild.pid" code = ( "import pathlib, subprocess, sys;" @@ -152,6 +170,8 @@ def test_grandchild_cannot_outlive_group_empty_reaped_proof(tmp_path: Path) -> N def test_spawn_failure_has_no_callbacks(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + if _assert_unsupported_platform(tmp_path): + return import autoskillit.cli.session._session_process as process_mod def fail_spawn(*_args, **_kwargs): @@ -174,6 +194,8 @@ def fail_spawn(*_args, **_kwargs): def test_callback_failure_still_terminates_and_reaps_child(tmp_path: Path) -> None: + if _assert_unsupported_platform(tmp_path): + return identity: list[tuple[int, int]] = [] def fail_after_spawn(pid: int, pgid: int) -> None: @@ -193,3 +215,49 @@ def fail_after_spawn(pid: int, pgid: int) -> None: assert len(identity) == 2 assert identity[0] == identity[1] assert _wait_until_gone(identity[0][0]) + + +def test_pty_attempt_retains_lease_fd_and_owns_controlling_slave( + tmp_path: Path, +) -> None: + if _assert_unsupported_platform(tmp_path): + return + read_fd, write_fd = os.pipe() + code = ( + "import os;" + "assert os.getsid(0) == os.getpid();" + "assert os.tcgetpgrp(0) == os.getpgrp();" + "os.write(int(os.environ['LEASE_FD']), b'pty-owned')" + ) + try: + result = run_cook_attempt( + _spec( + tmp_path, + code, + env={**os.environ, "LEASE_FD": str(write_fd)}, + ), + pass_fds=(write_fd,), + on_spawn=lambda _pid, _pgid: None, + on_reaped=lambda _pid, _pgid: None, + trace=Mock(), + observer=PtyObserver(readiness_probe=None), + ) + finally: + os.close(write_fd) + try: + assert os.read(read_fd, 9) == b"pty-owned" + finally: + os.close(read_fd) + assert result.pid == result.pgid + assert result.returncode == 0 + + +def test_successful_popen_records_spawn_without_post_spawn_pgid_lookup() -> None: + source = Path(run_cook_attempt.__code__.co_filename).read_text(encoding="utf-8") + body = source[ + source.index("def run_cook_attempt(") : source.index( + "\ndef _require_posix_process_ownership" + ) + ] + assert "os.getpgid" not in body + assert body.index("on_spawn(pid, pgid)") < body.index("trace.record_spawn()") diff --git a/tests/cli/test_cook_startup_observability.py b/tests/cli/test_cook_startup_observability.py index 7f4e4f8c08..06209f95dc 100644 --- a/tests/cli/test_cook_startup_observability.py +++ b/tests/cli/test_cook_startup_observability.py @@ -131,6 +131,7 @@ def test_trace_schema_anchors_monotonic_math_budgets_and_history_diagnostics( trace.record_stage("first_output", attempt=1, view_id=_VIEW_ID) clock.value = 116.5 trace.record_stage("hook_review", attempt=1, view_id=_VIEW_ID) + trace.require_startup_budgets() trace.close(status="success") records = _records(trace.path) @@ -169,6 +170,8 @@ def test_trace_schema_anchors_monotonic_math_budgets_and_history_diagnostics( "total_startup": 17.0, } assert summary["budget_exceeded"] == [] + assert summary["budget_missing"] == [] + assert summary["budgets_passed"] is True @pytest.mark.parametrize( @@ -202,11 +205,56 @@ def test_absolute_startup_budgets_are_hard_gates( trace.record_stage("spawn", attempt=1, view_id=_VIEW_ID) clock.value = hook_at trace.record_stage("hook_review", attempt=1, view_id=_VIEW_ID) - trace.close(status="success") + with pytest.raises(RuntimeError, match="startup budgets failed"): + trace.require_startup_budgets() + trace.close(status="failed") summary = _records(trace.path)[-1] assert set(summary["budget_exceeded"]) == exceeded - assert summary["budgets_passed"] is (not exceeded) + assert summary["budget_missing"] == [] + assert summary["budgets_passed"] is False + + +def test_missing_spawn_and_hook_stages_fail_budget_enforcement( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + trace_mod = _trace_module() + project = tmp_path / "project" + project.mkdir() + monkeypatch.setattr(trace_mod, "default_log_dir", lambda: tmp_path / "logs") + trace = trace_mod.StartupTrace(project, _LAUNCH_ID, enabled=True) + trace.record_launch_anchor() + trace.record_attempt_anchor(attempt=1, view_id=_VIEW_ID) + + with pytest.raises(RuntimeError, match="unmeasured="): + trace.require_startup_budgets() + trace.close(status="failed") + + summary = _records(trace.path)[-1] + assert set(summary["budget_missing"]) == { + "confirmation_to_spawn", + "spawn_to_hook_review", + "total_startup", + } + assert summary["budgets_passed"] is False + + +def test_spawn_stage_is_exactly_once_per_attempt( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + trace_mod = _trace_module() + project = tmp_path / "project" + project.mkdir() + monkeypatch.setattr(trace_mod, "default_log_dir", lambda: tmp_path / "logs") + trace = trace_mod.StartupTrace(project, _LAUNCH_ID, enabled=True) + trace.record_launch_anchor() + trace.record_attempt_anchor(attempt=1, view_id=_VIEW_ID) + trace.record_spawn() + + with pytest.raises(RuntimeError, match="spawn already recorded"): + trace.record_spawn() def test_trace_records_are_individually_capped_and_diagnostics_are_byte_truncated( @@ -345,8 +393,11 @@ def test_readiness_probe_accepts_only_the_supported_complete_schema(tmp_path: Pa _, ObserverStatus, _ = _observer_api() sqlite_home = tmp_path / "sqlite-home" _make_state_db(sqlite_home) + probe = _probe(sqlite_home) - assert _probe(sqlite_home).check() is ObserverStatus.READY + assert probe.upstream_commit == "ad65f016ed0c91992fb175fa881a373cc460dd2a" + assert probe.database_path == sqlite_home / "state_5.sqlite" + assert probe.check() is ObserverStatus.READY @pytest.mark.parametrize( diff --git a/tests/cli/test_subprocess_env_contracts.py b/tests/cli/test_subprocess_env_contracts.py index 88773951fd..e27d240070 100644 --- a/tests/cli/test_subprocess_env_contracts.py +++ b/tests/cli/test_subprocess_env_contracts.py @@ -491,6 +491,10 @@ def _check_call(self, call: ast.Call) -> None: ("commands.py", "build_interactive_cmd"), ("commands.py", "build_headless_cmd"), ("commands.py", "build_headless_resume_cmd"), + # cook finalizes a backend-built CmdSpec with an absolute cwd and reserved + # generated-home values. ``replace`` is a dataclass copy, not a process launch; + # its env starts from the backend policy's already-scrubbed ``built_spec.env``. + ("_session_cook.py", "cook"), } ) diff --git a/tests/cli/test_terminal.py b/tests/cli/test_terminal.py index b52f789dc1..305d80b03b 100644 --- a/tests/cli/test_terminal.py +++ b/tests/cli/test_terminal.py @@ -8,8 +8,7 @@ import os import termios -from pathlib import Path -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest @@ -420,8 +419,10 @@ class TestCookTerminalGuard: """cook() and _launch_cook_session() apply terminal_guard correctly.""" def test_cook_restores_terminal_on_keyboard_interrupt(self, monkeypatch, tmp_path): - """cook() must restore terminal even when subprocess.run raises KeyboardInterrupt.""" - import autoskillit.cli.session._session_cook as cook_mod + """The cook process owner restores terminal state when Popen is interrupted.""" + import autoskillit.cli.session._session_process as process_mod + from autoskillit.cli.session._session_process import run_cook_attempt + from autoskillit.core import CmdSpec monkeypatch.setattr("sys.stdin.isatty", lambda: True) monkeypatch.setattr("sys.stdin.fileno", lambda: 0) @@ -437,39 +438,19 @@ def test_cook_restores_terminal_on_keyboard_interrupt(self, monkeypatch, tmp_pat ) monkeypatch.setattr("autoskillit.cli.ui._terminal.termios.error", termios.error) monkeypatch.setattr( - "autoskillit.cli.session._session_cook.subprocess.run", + process_mod.subprocess, + "Popen", lambda *a, **kw: (_ for _ in ()).throw(KeyboardInterrupt()), ) - monkeypatch.setattr("shutil.which", lambda cmd: "/usr/bin/claude") - monkeypatch.setattr( - "autoskillit.cli._init_helpers._is_plugin_installed", lambda **_: False - ) - # The KeyboardInterrupt must come from the *launch*, not from the - # git-toplevel probe cook() runs to derive project_dir. - monkeypatch.setattr("autoskillit.cli.session._session_cook.resolve_project_dir", Path.cwd) - # is_first_run is imported inside cook() body — patch the source module - monkeypatch.setattr("autoskillit.cli._onboarding.is_first_run", lambda _: False) - # cook() calls input() for launch confirmation before subprocess.run - monkeypatch.setattr("builtins.input", lambda _prompt="": "") - # cook() calls init_session to create a skills directory - fake_skills_dir = tmp_path / "fake-skills" - fake_skills_dir.mkdir() - - def _fake_init_session( - self, - session_id, - catalog, - projection_context, - ): - return fake_skills_dir - - monkeypatch.setattr( - "autoskillit.workspace.session_skills.DefaultSessionSkillManager.init_session", - _fake_init_session, - ) - with pytest.raises(KeyboardInterrupt): - cook_mod.cook() + run_cook_attempt( + CmdSpec(cmd=("ignored",), env={}, cwd=str(tmp_path.resolve())), + pass_fds=(), + on_spawn=lambda _pid, _pgid: None, + on_reaped=lambda _pid, _pgid: None, + trace=Mock(), + observer=None, + ) assert tcsetattr_calls, ( "tcsetattr must be called even when subprocess raises KeyboardInterrupt" diff --git a/tests/contracts/test_backend_compliance.py b/tests/contracts/test_backend_compliance.py index e7ffa61021..4d4048f25e 100644 --- a/tests/contracts/test_backend_compliance.py +++ b/tests/contracts/test_backend_compliance.py @@ -6,11 +6,22 @@ from __future__ import annotations +from pathlib import Path + import pytest +from autoskillit.core import SESSION_ADD_DIR_SUBDIR + pytestmark = [pytest.mark.layer("contracts"), pytest.mark.small] +def _create_inert_rollout_links(generated_home: Path) -> None: + for public_name in ("sessions", "archived_sessions"): + target = generated_home / f".inert-{public_name}" + target.mkdir() + (generated_home / public_name).symlink_to(target) + + class TestBackendCompliance: def test_all_backends_build_cmd_returns_cmdspec(self): from autoskillit.core import CmdSpec @@ -190,7 +201,7 @@ def test_backend_conventions_skills_subdir_non_empty(self): def test_claude_backend_validate_session_layout_accepts_valid_dir(self, tmp_path): from autoskillit.execution.backends import ClaudeCodeBackend - skill_dir = tmp_path / ".claude" / "skills" / "test-skill" + skill_dir = tmp_path / SESSION_ADD_DIR_SUBDIR / ".claude" / "skills" / "test-skill" skill_dir.mkdir(parents=True) (skill_dir / "SKILL.md").write_text("---\nname: test-skill\n---\n") assert ClaudeCodeBackend().validate_session_layout(tmp_path) == [] @@ -198,10 +209,11 @@ def test_claude_backend_validate_session_layout_accepts_valid_dir(self, tmp_path def test_codex_backend_validate_session_layout_accepts_valid_dir(self, tmp_path): from autoskillit.execution.backends import CodexBackend - skill_dir = tmp_path / "skills" / "test-skill" + skill_dir = tmp_path / SESSION_ADD_DIR_SUBDIR / "skills" / "test-skill" skill_dir.mkdir(parents=True) (skill_dir / "SKILL.md").write_text("---\nname: test-skill\n---\n") (tmp_path / "config.toml").write_text("[mcp_servers.autoskillit]\n") + _create_inert_rollout_links(tmp_path) assert CodexBackend().validate_session_layout(tmp_path) == [] def test_validate_session_layout_empty_dir_returns_errors(self, tmp_path): @@ -222,10 +234,11 @@ def test_backend_conventions_skills_subdir_matches_validate_session_layout(self, work_dir = tmp_path / cls.__name__ work_dir.mkdir() skills_subdir = cls().conventions.skills_subdir - skill_dir = work_dir / str(skills_subdir) / "test-skill" + skill_dir = work_dir / SESSION_ADD_DIR_SUBDIR / str(skills_subdir) / "test-skill" skill_dir.mkdir(parents=True) (skill_dir / "SKILL.md").write_text("---\nname: test-skill\n---\n") if issubclass(cls, CodexBackend): (work_dir / "config.toml").write_text("[mcp_servers.autoskillit]\n") + _create_inert_rollout_links(work_dir) errors = cls().validate_session_layout(work_dir) assert errors == [], f"{cls.__name__}: {errors}" diff --git a/tests/contracts/test_claude_code_interface_contracts.py b/tests/contracts/test_claude_code_interface_contracts.py index 6049a60c8e..97e2ee6f05 100644 --- a/tests/contracts/test_claude_code_interface_contracts.py +++ b/tests/contracts/test_claude_code_interface_contracts.py @@ -223,21 +223,19 @@ def test_cook_add_dir_target_has_correct_structure( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: import shutil - import subprocess + from types import SimpleNamespace + + from autoskillit.core import CmdSpec - # Structure checks happen INSIDE fake_run: cook() deletes skills_dir in - # its finally block after subprocess.run() returns, so the directory is gone - # by the time control returns to this test function. structure_errors: list[str] = [] add_dir_seen: list[bool] = [] - def fake_run(cmd: list[str], **kw: object) -> object: - for i, token in enumerate(cmd): + def fake_run(spec: CmdSpec, **kwargs: object) -> object: + for i, token in enumerate(spec.cmd): if token == "--add-dir": - add_dir = Path(cmd[i + 1]) + add_dir = Path(spec.cmd[i + 1]) add_dir_seen.append(True) - # ".claude", "skills", "SKILL.md" are literal strings — not from any constant. skill_files = list(add_dir.glob(".claude/skills/*/SKILL.md")) if not skill_files: structure_errors.append( @@ -247,7 +245,6 @@ def fake_run(cmd: list[str], **kw: object) -> object: "The real init_session is not writing the correct layout." ) - # Anti-regression: flat layout must not exist at the session root flat = [ f for f in add_dir.glob("*/SKILL.md") @@ -259,20 +256,28 @@ def fake_run(cmd: list[str], **kw: object) -> object: "This is the CC-001 regression pattern." ) - return type("R", (), {"returncode": 0})() + kwargs["on_spawn"](1, 1) # type: ignore[operator] + kwargs["trace"].record_spawn() # type: ignore[union-attr] + kwargs["on_reaped"](1, 1) # type: ignore[operator] + return SimpleNamespace(pid=1, pgid=1, returncode=0) - monkeypatch.setattr(subprocess, "run", fake_run) monkeypatch.setattr(shutil, "which", lambda x: "/usr/bin/claude") - # cook() derives project_dir via the shared git-toplevel helper; fake_run - # above replaces subprocess.run wholesale, so pin the helper instead. - monkeypatch.setattr("autoskillit.cli.session._session_cook.resolve_project_dir", Path.cwd) - # cook() calls input() to confirm launch — mock it to auto-confirm. - monkeypatch.setattr("builtins.input", lambda _prompt="": "") monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("autoskillit.cli._onboarding.is_first_run", lambda _: False) + monkeypatch.setattr( + "autoskillit.cli.ui._timed_input.timed_prompt", + lambda *args, **kwargs: "", + ) + monkeypatch.setattr( + "autoskillit.cli.session._session_process.run_cook_attempt", + fake_run, + ) + monkeypatch.setattr( + "autoskillit.cli.session._session_reload.consume_reload_sentinel", + lambda _project: None, + ) from autoskillit.cli.session._session_cook import cook - - # Use a fixed ephemeral root so cleanup is deterministic from autoskillit.workspace.session_skills import ( DefaultSessionSkillManager, SkillsDirectoryProvider, @@ -284,17 +289,6 @@ def fake_run(cmd: list[str], **kw: object) -> object: real_mgr = DefaultSessionSkillManager( SkillsDirectoryProvider(), ephemeral_root=ephemeral_root ) - # _session_cook.py imports DefaultSessionSkillManager from autoskillit.workspace - # inside the cook() function body (lazy import), so patching - # autoskillit.workspace.DefaultSessionSkillManager intercepts it. - init_session_calls: list[bool] = [] - original_init_session = real_mgr.init_session - - def _spy_init_session(*args: object, **kwargs: object) -> object: - init_session_calls.append(True) - return original_init_session(*args, **kwargs) - - monkeypatch.setattr(real_mgr, "init_session", _spy_init_session) monkeypatch.setattr( "autoskillit.workspace.DefaultSessionSkillManager", lambda *a, **kw: real_mgr, @@ -302,12 +296,6 @@ def _spy_init_session(*args: object, **kwargs: object) -> object: cook() - assert init_session_calls, ( - "real_mgr.init_session was never called — " - "patch target 'autoskillit.workspace.DefaultSessionSkillManager' " - "did not intercept cook()'s constructor call." - ) - assert add_dir_seen, "Expected at least one --add-dir in command" assert not structure_errors, "\n".join(structure_errors) diff --git a/tests/core/test_backend_protocols.py b/tests/core/test_backend_protocols.py index a3b7936d4c..7ab0770051 100644 --- a/tests/core/test_backend_protocols.py +++ b/tests/core/test_backend_protocols.py @@ -121,6 +121,7 @@ def test_coding_agent_backend_new_lifecycle_signatures_are_exact(): assert tuple(context.parameters) == ( "self", "session_home", + "project_dir", "launch_id", "attempt", "current_resume_spec", @@ -130,6 +131,7 @@ def test_coding_agent_backend_new_lifecycle_signatures_are_exact(): hints = typing.get_type_hints(CodingAgentBackend.cook_session_context) assert hints == { "session_home": Path, + "project_dir": Path, "launch_id": str, "attempt": int, "current_resume_spec": ResumeSpec, @@ -242,6 +244,7 @@ def build_interactive_cmd( model: str | None = None, plugin_source: PluginSource | None = None, add_dirs: Sequence[Path | str | ValidatedAddDir] = (), + generated_home: Path | None = None, resume_spec: ResumeSpec = NoResume(), system_prompt: str | None = None, env_extras: Mapping[str, str] | None = None, @@ -275,10 +278,12 @@ def cook_session_context( self, *, session_home: Path, + project_dir: Path, launch_id: str, attempt: int, current_resume_spec: ResumeSpec, ): + del project_dir from contextlib import nullcontext from autoskillit.core import CookSessionHandle diff --git a/tests/core/test_type_constants.py b/tests/core/test_type_constants.py index eef41988e8..d879f6e2ed 100644 --- a/tests/core/test_type_constants.py +++ b/tests/core/test_type_constants.py @@ -654,3 +654,30 @@ def test_codex_interactive_required_env_includes_max_mcp_output_tokens() -> None from autoskillit.core import CODEX_INTERACTIVE_REQUIRED_ENV assert "MAX_MCP_OUTPUT_TOKENS" in CODEX_INTERACTIVE_REQUIRED_ENV + + +def test_codex_cook_storage_and_environment_constants_are_pinned() -> None: + from autoskillit.core import ( + AUTOSKILLIT_PRIVATE_ENV_VARS, + CODEX_ACTIVE_VIEWS_SUBDIR, + CODEX_ARCHIVED_SESSIONS_SUBDIR, + CODEX_COOK_RESERVED_ENV_VARS, + CODEX_SESSIONS_SUBDIR, + CODEX_STARTUP_TRACE_ENV_VAR, + ) + + assert ( + CODEX_SESSIONS_SUBDIR, + CODEX_ARCHIVED_SESSIONS_SUBDIR, + CODEX_ACTIVE_VIEWS_SUBDIR, + ) == ( + "codex-sessions", + "codex-archived-sessions", + "codex-active-sessions", + ) + assert CODEX_COOK_RESERVED_ENV_VARS == frozenset({"CODEX_HOME", "CODEX_SQLITE_HOME"}) + assert CODEX_STARTUP_TRACE_ENV_VAR == "AUTOSKILLIT_CODEX_STARTUP_TRACE" + assert { + CODEX_STARTUP_TRACE_ENV_VAR, + *CODEX_COOK_RESERVED_ENV_VARS, + } <= AUTOSKILLIT_PRIVATE_ENV_VARS diff --git a/tests/execution/backends/test_codex_backend.py b/tests/execution/backends/test_codex_backend.py index 2b91b780f0..207fa75a57 100644 --- a/tests/execution/backends/test_codex_backend.py +++ b/tests/execution/backends/test_codex_backend.py @@ -58,6 +58,7 @@ def test_all_members_present(self) -> None: "SANDBOX", "MODEL", "MODEL_SHORT", + "PROFILE", "ADD_DIR", "RESUME_SUBCOMMAND", "CONFIG_OVERRIDE", @@ -126,7 +127,7 @@ def test_capabilities_required_session_files(self) -> None: def test_capabilities_session_dir_symlinks(self) -> None: assert CodexBackend().capabilities.session_dir_symlinks == frozenset( - {"auth.json", ".env", "sessions"} + {"sessions", "archived_sessions"} ) def test_capabilities_applicable_guards(self) -> None: @@ -864,6 +865,25 @@ def test_add_dirs_appends_add_dir_for_each_entry(self) -> None: spec.cmd[spec.cmd.index(CodexFlags.ADD_DIR) + 1], ) + def test_generated_home_is_distinct_from_add_dirs(self, tmp_path: Path) -> None: + generated_home = (tmp_path / "generated-home").resolve() + skills_dir = (tmp_path / "skills").resolve() + + spec = CodexBackend().build_interactive_cmd( + add_dirs=[str(skills_dir)], + generated_home=generated_home, + ) + + assert spec.env["CODEX_HOME"] == str(generated_home) + assert spec.env["CODEX_SQLITE_HOME"] == str(generated_home) + assert str(skills_dir) in spec.cmd + assert str(generated_home) not in [ + spec.cmd[index + 1] + for index, value in enumerate(spec.cmd[:-1]) + if value == CodexFlags.ADD_DIR + ] + assert f'sqlite_home="{generated_home}"' in spec.cmd + def test_initial_prompt_is_final_element(self) -> None: spec = CodexBackend().build_interactive_cmd(initial_prompt="hello") assert spec.cmd[-1] == "hello" @@ -1148,229 +1168,6 @@ def test_stream_idle_timeout_routed_to_cmdspec(self) -> None: assert spec.process_idle_timeout_ms == 60000 -class TestCodexEnsurePreLaunchConfigValidation: - @pytest.fixture(autouse=True) - def _clean_backend_env(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv(MCP_CLIENT_BACKEND_ENV_VAR, raising=False) - - @pytest.fixture(autouse=True) - def _stub_hook_sync(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr( - "autoskillit.execution.backends.codex.sync_hooks_to_codex_config", - lambda *a, **kw: False, - ) - - def test_ensure_pre_launch_returns_error_on_config_load_failure( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - doctor_output = ( - '{"checks": {"config.load": {"status": "error",' - ' "summary": "invalid type: map, expected u32",' - ' "remediation": "Delete [tui] section"}}}' - ) - - def fake_run(cmd, **kwargs): - return subprocess.CompletedProcess(cmd, 0, stdout=doctor_output) - - monkeypatch.setattr(subprocess, "run", fake_run) - monkeypatch.setattr( - "autoskillit.execution.backends.codex.ensure_codex_mcp_registered", - lambda **kw: False, - ) - errors = CodexBackend().ensure_pre_launch() - assert errors - combined = " ".join(errors) - assert "invalid type: map, expected u32" in combined - assert "Delete [tui] section" in combined - - def test_ensure_pre_launch_returns_empty_on_config_load_ok( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - doctor_output = '{"checks": {"config.load": {"status": "ok"}}}' - - def fake_run(cmd, **kwargs): - return subprocess.CompletedProcess(cmd, 0, stdout=doctor_output) - - monkeypatch.setattr(subprocess, "run", fake_run) - monkeypatch.setattr( - "autoskillit.execution.backends.codex.ensure_codex_mcp_registered", - lambda **kw: False, - ) - assert CodexBackend().ensure_pre_launch() == [] - - def test_ensure_pre_launch_returns_empty_on_timeout( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - def fake_run(cmd, **kwargs): - raise subprocess.TimeoutExpired(cmd, 20) - - monkeypatch.setattr(subprocess, "run", fake_run) - monkeypatch.setattr( - "autoskillit.execution.backends.codex.ensure_codex_mcp_registered", - lambda **kw: False, - ) - assert CodexBackend().ensure_pre_launch() == [] - - def test_ensure_pre_launch_returns_empty_on_oserror( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - def fake_run(cmd, **kwargs): - raise FileNotFoundError("codex not found") - - monkeypatch.setattr(subprocess, "run", fake_run) - monkeypatch.setattr( - "autoskillit.execution.backends.codex.ensure_codex_mcp_registered", - lambda **kw: False, - ) - assert CodexBackend().ensure_pre_launch() == [] - - def test_ensure_pre_launch_codex_doctor_runs_after_mcp_and_hook_sync( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - call_order: list[str] = [] - - def fake_register(**kw): - call_order.append("register") - return False - - def fake_sync(*a, **kw): - call_order.append("hook_sync") - return False - - def fake_run(cmd, **kwargs): - call_order.append("doctor") - return subprocess.CompletedProcess(cmd, 0, stdout='{"checks": {}}') - - monkeypatch.setattr( - "autoskillit.execution.backends.codex.ensure_codex_mcp_registered", fake_register - ) - monkeypatch.setattr( - "autoskillit.execution.backends.codex.sync_hooks_to_codex_config", fake_sync - ) - monkeypatch.setattr(subprocess, "run", fake_run) - CodexBackend().ensure_pre_launch() - assert call_order == ["register", "hook_sync", "doctor"] - - def test_ensure_pre_launch_returns_empty_on_nonzero_returncode( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - def fake_run(cmd, **kwargs): - return subprocess.CompletedProcess(cmd, 1, stdout="error output") - - monkeypatch.setattr(subprocess, "run", fake_run) - monkeypatch.setattr( - "autoskillit.execution.backends.codex.ensure_codex_mcp_registered", - lambda **kw: False, - ) - assert CodexBackend().ensure_pre_launch() == [] - - def test_ensure_pre_launch_returns_empty_on_malformed_json( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - def fake_run(cmd, **kwargs): - return subprocess.CompletedProcess(cmd, 0, stdout="not json") - - monkeypatch.setattr(subprocess, "run", fake_run) - monkeypatch.setattr( - "autoskillit.execution.backends.codex.ensure_codex_mcp_registered", - lambda **kw: False, - ) - assert CodexBackend().ensure_pre_launch() == [] - - def test_ensure_pre_launch_returns_empty_on_missing_config_check( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - def fake_run(cmd, **kwargs): - return subprocess.CompletedProcess(cmd, 0, stdout='{"checks": {}}') - - monkeypatch.setattr(subprocess, "run", fake_run) - monkeypatch.setattr( - "autoskillit.execution.backends.codex.ensure_codex_mcp_registered", - lambda **kw: False, - ) - assert CodexBackend().ensure_pre_launch() == [] - - def test_ensure_pre_launch_syncs_hooks(self, monkeypatch: pytest.MonkeyPatch) -> None: - sync_calls: list[dict] = [] - monkeypatch.setattr( - "autoskillit.execution.backends.codex.sync_hooks_to_codex_config", - lambda **kw: sync_calls.append(kw) or False, - ) - monkeypatch.setattr( - "autoskillit.execution.backends.codex.ensure_codex_mcp_registered", - lambda **kw: False, - ) - - def fake_run(cmd, **kwargs): - return subprocess.CompletedProcess(cmd, 0, stdout='{"checks": {}}') - - monkeypatch.setattr(subprocess, "run", fake_run) - errors = CodexBackend().ensure_pre_launch() - assert len(sync_calls) == 1 - assert sync_calls[0].get("hook_config_format") == "toml_nested" - assert errors == [] - - def test_ensure_pre_launch_surfaces_hook_sync_errors( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - def failing_sync(**kw): - raise RuntimeError("TOML write failed") - - def fake_run(cmd, **kwargs): - return subprocess.CompletedProcess(cmd, 0, stdout='{"checks": {}}') - - monkeypatch.setattr( - "autoskillit.execution.backends.codex.sync_hooks_to_codex_config", failing_sync - ) - monkeypatch.setattr( - "autoskillit.execution.backends.codex.ensure_codex_mcp_registered", - lambda **kw: False, - ) - monkeypatch.setattr(subprocess, "run", fake_run) - errors = CodexBackend().ensure_pre_launch() - assert any("hook" in e.lower() or "sync" in e.lower() for e in errors) - - def test_ensure_pre_launch_hook_sync_failure_is_non_fatal( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - def failing_sync(**kw): - raise RuntimeError("sync failed") - - def fake_run(cmd, **kwargs): - return subprocess.CompletedProcess(cmd, 0, stdout='{"checks": {}}') - - monkeypatch.setattr( - "autoskillit.execution.backends.codex.sync_hooks_to_codex_config", failing_sync - ) - monkeypatch.setattr( - "autoskillit.execution.backends.codex.ensure_codex_mcp_registered", - lambda **kw: False, - ) - monkeypatch.setattr(subprocess, "run", fake_run) - errors = CodexBackend().ensure_pre_launch() - assert any("sync" in e.lower() for e in errors) - assert not any("codex" in e.lower() and "validation" in e.lower() for e in errors) - - def test_ensure_pre_launch_integration_registration_succeeds_then_validates( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - fake_home = tmp_path / "fakehome" - fake_home.mkdir() - monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) - - def fake_run(cmd, **kwargs): - return subprocess.CompletedProcess( - cmd, - 0, - stdout='{"checks": {"config.load": {"status": "error", "summary": "bad config"}}}', - ) - - monkeypatch.setattr(subprocess, "run", fake_run) - errors = CodexBackend().ensure_pre_launch() - assert errors - assert "bad config" in " ".join(errors) - - class TestCodexBackendVersion: def test_version_returns_stripped_stdout(self, monkeypatch: pytest.MonkeyPatch) -> None: def fake_run(cmd, *, capture_output, text, timeout): @@ -1746,6 +1543,7 @@ def _setup_dirs(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: self.codex_home.mkdir(parents=True) self.session_dir = tmp_path / "session" self.session_dir.mkdir() + (self.session_dir / "config.toml").write_text("[mcp_servers.autoskillit]\n") self.fake_log_dir = tmp_path / "logs" monkeypatch.setattr(Path, "home", staticmethod(lambda: self.fake_home)) monkeypatch.setattr( @@ -1764,54 +1562,35 @@ def test_happy_path_all_files_provisioned(self) -> None: assert (self.session_dir / "config.toml").is_file() assert (self.session_dir / "auth.json").is_symlink() assert (self.session_dir / ".env").is_file() - assert (self.session_dir / "sessions").is_symlink() - assert (self.session_dir / "sessions").resolve() == ( - self.fake_log_dir / "codex-sessions" - ).resolve() + assert not (self.session_dir / "sessions").exists() + assert not (self.session_dir / "archived_sessions").exists() def test_missing_config_raises_and_logs_error(self) -> None: + (self.session_dir / "config.toml").unlink() with pytest.raises(FileNotFoundError): CodexBackend().setup_session_dir(self.session_dir) - def test_absent_auth_logs_warning_no_raise(self) -> None: - (self.codex_home / "config.toml").write_text("[mcp]\n") - with structlog.testing.capture_logs() as cap_logs: - CodexBackend().setup_session_dir(self.session_dir) + def test_absent_auth_is_allowed(self) -> None: + CodexBackend().setup_session_dir(self.session_dir) assert not (self.session_dir / "auth.json").exists() - assert any( - e.get("event") == "codex_auth_copy_missing" and e.get("log_level") == "warning" - for e in cap_logs - ) - def test_auth_symlink_oserror_logs_warning_no_raise( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - (self.codex_home / "config.toml").write_text("[mcp]\n") + def test_auth_destination_collision_fails_closed(self) -> None: (self.codex_home / "auth.json").write_text("{}") - # Pre-create auth.json as a regular file to block symlink creation (self.session_dir / "auth.json").write_text("blocker") - with structlog.testing.capture_logs() as cap_logs: + with pytest.raises(FileExistsError): CodexBackend().setup_session_dir(self.session_dir) - # Verify auth.json is still a regular file (symlink failed silently) assert not (self.session_dir / "auth.json").is_symlink() - assert any( - e.get("event") == "codex_auth_symlink_failed" and e.get("log_level") == "warning" - for e in cap_logs - ) def test_absent_env_silently_skipped(self) -> None: - (self.codex_home / "config.toml").write_text("[mcp]\n") CodexBackend().setup_session_dir(self.session_dir) assert not (self.session_dir / ".env").exists() - def test_sessions_symlink_oserror_swallowed(self) -> None: - (self.codex_home / "config.toml").write_text("[mcp]\n") - (self.codex_home / "auth.json").write_text("{}") - # Pre-create sessions/ as a directory to block symlink creation + def test_setup_does_not_manage_rollout_links(self) -> None: (self.session_dir / "sessions").mkdir() CodexBackend().setup_session_dir(self.session_dir) - # Verify sessions is still a directory (symlink failed silently) + assert (self.session_dir / "sessions").is_dir() assert not (self.session_dir / "sessions").is_symlink() + assert not (self.session_dir / "archived_sessions").exists() def test_setup_session_dir_creates_agents_directory(self) -> None: self._write_all_source_files() @@ -1879,7 +1658,7 @@ def test_generated_agents_are_registered_in_session_config(self) -> None: def test_profile_agent_registration_takes_precedence(self) -> None: import tomllib - (self.codex_home / "config.toml").write_text( + (self.session_dir / "config.toml").write_text( '[agents."wp-elaborator"]\n' 'description = "profile role"\n' 'config_file = "/profile/wp-elaborator.toml"\n' @@ -1936,12 +1715,12 @@ def test_no_git_subdir_created(self) -> None: CodexBackend().setup_session_dir(self.session_dir) assert not (self.session_dir / ".git").exists() - def test_copied_config_has_auto_compact_limit(self) -> None: + def test_snapshotted_config_has_auto_compact_limit(self) -> None: import tomllib from autoskillit.execution.backends import CODEX_AUTO_COMPACT_LIMIT - (self.codex_home / "config.toml").write_text( + (self.session_dir / "config.toml").write_text( f"model_auto_compact_token_limit = {CODEX_AUTO_COMPACT_LIMIT}\n" "[mcp_servers.autoskillit]\n" ) diff --git a/tests/execution/backends/test_codex_env_policy.py b/tests/execution/backends/test_codex_env_policy.py index 3b9456dea8..f196c67164 100644 --- a/tests/execution/backends/test_codex_env_policy.py +++ b/tests/execution/backends/test_codex_env_policy.py @@ -48,6 +48,21 @@ def test_autoskillit_private_vars_stripped(self) -> None: assert var not in result assert result["PATH"] == "/usr/bin" + def test_interactive_cook_home_and_trace_state_are_scrubbed_from_base(self) -> None: + policy = CodexEnvPolicy() + result = policy.build_env( + { + "PATH": "/usr/bin", + "CODEX_HOME": "/tmp/ambient-codex", + "CODEX_SQLITE_HOME": "/tmp/ambient-codex", + "AUTOSKILLIT_CODEX_STARTUP_TRACE": "1", + } + ) + + assert "CODEX_HOME" not in result + assert "CODEX_SQLITE_HOME" not in result + assert "AUTOSKILLIT_CODEX_STARTUP_TRACE" not in result + def test_claude_code_auto_connect_ide_not_injected(self) -> None: policy = CodexEnvPolicy() result = policy.build_env({"PATH": "/usr/bin"}) diff --git a/tests/execution/backends/test_codex_interactive.py b/tests/execution/backends/test_codex_interactive.py index ec5054e6a8..5ff1d05099 100644 --- a/tests/execution/backends/test_codex_interactive.py +++ b/tests/execution/backends/test_codex_interactive.py @@ -205,26 +205,36 @@ def test_empty_list_excludes_add_dir(self) -> None: class TestCodexInteractiveCmdCodexHome: - def test_add_dirs_injects_codex_home_env(self) -> None: - spec = CodexBackend().build_interactive_cmd(add_dirs=[Path("/session/dir")]) - assert spec.env["CODEX_HOME"] == "/session/dir" + def test_generated_home_injects_reserved_home_env(self) -> None: + spec = CodexBackend().build_interactive_cmd( + add_dirs=[Path("/session/add-dir")], + generated_home=Path("/session/home"), + ) + assert spec.env["CODEX_HOME"] == "/session/home" + assert spec.env["CODEX_SQLITE_HOME"] == "/session/home" - def test_codex_home_uses_first_add_dir(self) -> None: + def test_add_dirs_do_not_define_generated_home(self) -> None: spec = CodexBackend().build_interactive_cmd( add_dirs=[Path("/first"), Path("/second")], ) - assert spec.env["CODEX_HOME"] == "/first" + assert "CODEX_HOME" not in spec.env + assert "CODEX_SQLITE_HOME" not in spec.env def test_empty_add_dirs_excludes_codex_home(self) -> None: spec = CodexBackend().build_interactive_cmd(add_dirs=[]) assert "CODEX_HOME" not in spec.env - def test_caller_env_extras_takes_precedence(self) -> None: + def test_generated_home_takes_precedence_over_caller_env_extras(self) -> None: spec = CodexBackend().build_interactive_cmd( - add_dirs=[Path("/session/dir")], - env_extras={"CODEX_HOME": "/override"}, + add_dirs=[Path("/session/add-dir")], + generated_home=Path("/session/home"), + env_extras={ + "CODEX_HOME": "/override", + "CODEX_SQLITE_HOME": "/other-override", + }, ) - assert spec.env["CODEX_HOME"] == "/override" + assert spec.env["CODEX_HOME"] == "/session/home" + assert spec.env["CODEX_SQLITE_HOME"] == "/session/home" class TestCodexInteractiveCmdPositionalOrdering: diff --git a/tests/execution/backends/test_codex_session_locator.py b/tests/execution/backends/test_codex_session_locator.py index 0420b32d31..d914c243c1 100644 --- a/tests/execution/backends/test_codex_session_locator.py +++ b/tests/execution/backends/test_codex_session_locator.py @@ -155,19 +155,11 @@ def test_list_sessions_filters_project_sidechains_and_preserves_source_order( "older", ] - def test_locate_session_finds_rollout_by_thread_id(self, tmp_path: Path) -> None: - session_dir = tmp_path / "sessions" / "2026" / "05" / "26" - session_dir.mkdir(parents=True) - rollout = _make_rollout(session_dir, "tid_abc123") - locator = CodexSessionLocator(codex_home=tmp_path) - result = locator.locate_session("tid_abc123") - assert result == rollout - def test_locate_session_skips_non_matching_thread_id(self, tmp_path: Path) -> None: - session_dir = tmp_path / "sessions" / "2026" / "05" / "26" + session_dir = tmp_path / "codex-sessions" / "2026" / "05" / "26" session_dir.mkdir(parents=True) _make_rollout(session_dir, "tid_other") - locator = CodexSessionLocator() + locator = CodexSessionLocator(store_root=tmp_path) assert locator.locate_session("tid_wanted") is None def test_locate_session_searches_permanent_dir(self, tmp_path: Path) -> None: @@ -178,34 +170,21 @@ def test_locate_session_searches_permanent_dir(self, tmp_path: Path) -> None: result = locator.locate_session("tid_perm") assert result == rollout - def test_locate_via_home_fallback( + def test_locate_ignores_ambient_and_source_codex_homes( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - monkeypatch.setattr(Path, "home", lambda: tmp_path) - monkeypatch.setattr( - "autoskillit.execution.backends.codex.default_log_dir", - lambda: tmp_path / "nonexistent_logs", - ) - session_dir = tmp_path / ".codex" / "sessions" / "2026" / "05" / "26" - session_dir.mkdir(parents=True) - rollout = _make_rollout(session_dir, "tid_home") - locator = CodexSessionLocator() - result = locator.locate_session("tid_home") - assert result == rollout + ambient_home = tmp_path / "ambient" + source_home = tmp_path / "source" + for home in (ambient_home, source_home): + session_dir = home / "sessions" / "2026" / "05" / "26" + session_dir.mkdir(parents=True) + _make_rollout(session_dir, "tid_noncanonical") + monkeypatch.setenv("CODEX_HOME", str(ambient_home)) + monkeypatch.setattr(Path, "home", lambda: source_home) - def test_locate_via_env_var(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr( - "autoskillit.execution.backends.codex.default_log_dir", - lambda: tmp_path / "nonexistent_logs", - ) - monkeypatch.delenv("CODEX_HOME", raising=False) - monkeypatch.setenv("CODEX_HOME", str(tmp_path)) - session_dir = tmp_path / "sessions" / "2026" / "05" / "26" - session_dir.mkdir(parents=True) - rollout = _make_rollout(session_dir, "tid_env") - locator = CodexSessionLocator() - result = locator.locate_session("tid_env") - assert result == rollout + locator = CodexSessionLocator(store_root=tmp_path / "canonical") + + assert locator.locate_session("tid_noncanonical") is None def test_locate_session_returns_none_for_empty_id(self) -> None: locator = CodexSessionLocator() @@ -218,32 +197,10 @@ def test_locate_session_returns_none_for_fallback_prefix_ids(self) -> None: assert locator.locate_session("crashed_xyz789") is None def test_locate_session_returns_none_when_sessions_dir_absent(self, tmp_path: Path) -> None: - locator = CodexSessionLocator(codex_home=tmp_path) + locator = CodexSessionLocator(store_root=tmp_path) result = locator.locate_session("any_id") assert result is None - def test_codex_home_priority_over_env_var( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - env_path = tmp_path / "env_sessions" - env_path.mkdir(parents=True) - env_session_dir = env_path / "sessions" / "2026" / "05" / "26" - env_session_dir.mkdir(parents=True) - _make_rollout(env_session_dir, "tid_priority", "rollout-env.jsonl") - - param_path = tmp_path / "param_sessions" - param_path.mkdir(parents=True) - param_session_dir = param_path / "sessions" / "2026" / "05" / "26" - param_session_dir.mkdir(parents=True) - rollout = _make_rollout(param_session_dir, "tid_priority", "rollout-param.jsonl") - - monkeypatch.setenv("CODEX_HOME", str(env_path)) - - locator = CodexSessionLocator(codex_home=param_path) - result = locator.locate_session("tid_priority") - - assert result == rollout - def test_read_session_handles_plain_jsonl(self, tmp_path: Path) -> None: f = tmp_path / "rollout.jsonl" f.write_text('{"valid": true}\n{"also": "valid"}\n') @@ -300,27 +257,11 @@ def test_read_session_decompresses_and_parses_ndjson(self, tmp_path: Path) -> No def test_isinstance_session_locator(self) -> None: assert isinstance(CodexSessionLocator(), SessionLocator) - def test_file_matches_thread_empty_file(self, tmp_path: Path) -> None: - f = tmp_path / "empty.jsonl" - f.write_text("") - assert CodexSessionLocator._file_matches_thread(f, "any") is False - - def test_file_matches_thread_no_thread_started(self, tmp_path: Path) -> None: - f = tmp_path / "no_start.jsonl" - f.write_text('{"type":"turn.completed"}\n') - assert CodexSessionLocator._file_matches_thread(f, "any") is False - - @pytest.mark.parametrize("fmt", ["thread_started", "session_meta"]) - def test_file_matches_thread_both_formats(self, tmp_path: Path, fmt: str) -> None: - rollout = _make_rollout(tmp_path, "tid_fmt", fmt=fmt) - assert CodexSessionLocator._file_matches_thread(rollout, "tid_fmt") is True - assert CodexSessionLocator._file_matches_thread(rollout, "wrong_id") is False - def test_locate_session_finds_session_meta_format(self, tmp_path: Path) -> None: - session_dir = tmp_path / "sessions" / "2026" / "05" / "26" + session_dir = tmp_path / "codex-sessions" / "2026" / "05" / "26" session_dir.mkdir(parents=True) rollout = _make_rollout(session_dir, "tid_meta", fmt="session_meta") - locator = CodexSessionLocator(codex_home=tmp_path) + locator = CodexSessionLocator(store_root=tmp_path) result = locator.locate_session("tid_meta") assert result == rollout diff --git a/tests/execution/backends/test_codex_session_storage.py b/tests/execution/backends/test_codex_session_storage.py index a188336a8a..c2c261399c 100644 --- a/tests/execution/backends/test_codex_session_storage.py +++ b/tests/execution/backends/test_codex_session_storage.py @@ -3,12 +3,16 @@ from __future__ import annotations import json +import multiprocessing import os +import threading from pathlib import Path import pytest +import zstandard from autoskillit.core import NamedResume, NoResume +from autoskillit.execution.backends import _codex_session_storage as storage from autoskillit.execution.backends._codex_session_storage import ( CodexInteractiveSessionLease, CodexSessionStore, @@ -29,15 +33,39 @@ def _generated_home(tmp_path: Path) -> tuple[Path, dict[str, Path]]: return home, inert_targets -def _rollout(path: Path, thread_id: str) -> bytes: - content = ( - f'{{"type":"thread.started","thread_id":"{thread_id}"}}\n{{"type":"turn.completed"}}\n' - ).encode() +def _rollout(path: Path, thread_id: str, *, cwd: Path | None = None) -> bytes: + rows = [f'{{"type":"thread.started","thread_id":"{thread_id}"}}'] + if cwd is not None: + rows.append( + json.dumps( + { + "type": "session_meta", + "payload": {"id": thread_id, "cwd": str(cwd)}, + } + ) + ) + rows.append('{"type":"turn.completed"}') + content = ("\n".join(rows) + "\n").encode() path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(content) return content +def _hold_flock(lock_path: str, ready: object, release: object) -> None: + import fcntl + + ready_event = ready + release_event = release + descriptor = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600) + try: + fcntl.flock(descriptor, fcntl.LOCK_EX) + ready_event.set() # type: ignore[attr-defined] + if not release_event.wait(10): # type: ignore[attr-defined] + raise RuntimeError("timed out waiting to release lifecycle test lock") + finally: + os.close(descriptor) + + def test_fresh_attempt_exposes_empty_view_and_no_child_abort_restores_inert_links( tmp_path: Path, ) -> None: @@ -47,6 +75,7 @@ def test_fresh_attempt_exposes_empty_view_and_no_child_abort_restores_inert_link home, inert_targets = _generated_home(tmp_path) lease = store.prepare_attempt( session_home=home, + project_dir=tmp_path, launch_id="0123456789abcdef", attempt=1, current_resume_spec=NoResume(), @@ -62,11 +91,502 @@ def test_fresh_attempt_exposes_empty_view_and_no_child_abort_restores_inert_link assert (home / "sessions").resolve() == inert_targets["sessions"] assert (home / "archived_sessions").resolve() == inert_targets["archived_sessions"] - assert list((log_dir / "codex-active-sessions").glob("*")) == [] + assert {path.name for path in (log_dir / "codex-active-sessions").glob("*")} == {".locks"} assert list((log_dir / "codex-sessions").rglob("*.jsonl")) == [] assert not index_path.exists() +def test_resume_archive_transition_leaves_exactly_one_canonical_rollout( + tmp_path: Path, +) -> None: + log_dir = tmp_path / "log-root" + store = CodexSessionStore(log_dir=log_dir) + relative = Path("2026/07/rollout-resume.jsonl") + active = log_dir / "codex-sessions" / relative + _rollout(active, "thread-resume") + original_identity = (active.stat().st_dev, active.stat().st_ino) + home, _ = _generated_home(tmp_path) + + with store.prepare_attempt( + session_home=home, + project_dir=tmp_path, + launch_id="0123456789abcdef", + attempt=1, + current_resume_spec=NamedResume("thread-resume"), + ) as handle: + staged_active = (home / "sessions").resolve() / relative + staged_archive = (home / "archived_sessions").resolve() / relative + staged_archive.parent.mkdir(parents=True) + staged_active.rename(staged_archive) + handle.record_spawn(os.getpid(), os.getpgrp()) + handle.record_reaped(os.getpid(), os.getpgrp()) + + archived = log_dir / "codex-archived-sessions" / relative + assert not active.exists() + assert archived.is_file() + assert (archived.stat().st_dev, archived.stat().st_ino) == original_identity + + +def test_resume_representation_transition_retires_old_jsonl(tmp_path: Path) -> None: + log_dir = tmp_path / "log-root" + store = CodexSessionStore(log_dir=log_dir) + relative = Path("2026/07/rollout-resume.jsonl") + active = log_dir / "codex-sessions" / relative + content = _rollout(active, "thread-resume") + home, _ = _generated_home(tmp_path) + + with store.prepare_attempt( + session_home=home, + project_dir=tmp_path, + launch_id="0123456789abcdef", + attempt=1, + current_resume_spec=NamedResume("thread-resume"), + ) as handle: + staged_jsonl = (home / "sessions").resolve() / relative + staged_zst = staged_jsonl.with_suffix(".jsonl.zst") + staged_jsonl.unlink() + staged_zst.write_bytes(zstandard.ZstdCompressor().compress(content)) + handle.record_spawn(os.getpid(), os.getpgrp()) + handle.record_reaped(os.getpid(), os.getpgrp()) + + compressed = active.with_suffix(".jsonl.zst") + assert not active.exists() + assert zstandard.ZstdDecompressor().decompress(compressed.read_bytes()) == content + + +def test_resume_representation_transition_preserves_both_when_content_would_be_lost( + tmp_path: Path, +) -> None: + log_dir = tmp_path / "log-root" + store = CodexSessionStore(log_dir=log_dir) + relative = Path("2026/07/rollout-resume.jsonl") + active = log_dir / "codex-sessions" / relative + original = _rollout(active, "thread-resume") + home, _ = _generated_home(tmp_path) + lease = store.prepare_attempt( + session_home=home, + project_dir=tmp_path, + launch_id="0123456789abcdef", + attempt=1, + current_resume_spec=NamedResume("thread-resume"), + ) + + with pytest.raises(RuntimeError, match="discard canonical rollout content"): + with lease as handle: + staged_jsonl = (home / "sessions").resolve() / relative + staged_zst = staged_jsonl.with_suffix(".jsonl.zst") + staged_jsonl.unlink() + truncated = b'{"type":"thread.started","thread_id":"thread-resume"}\n' + staged_zst.write_bytes(zstandard.ZstdCompressor().compress(truncated)) + handle.record_spawn(os.getpid(), os.getpgrp()) + handle.record_reaped(os.getpid(), os.getpgrp()) + + assert active.read_bytes() == original + staged_zst = lease.view_path / "sessions" / relative.with_suffix(".jsonl.zst") + assert staged_zst.is_file() + assert zstandard.ZstdDecompressor().decompress(staged_zst.read_bytes()) != original + + +def test_missing_running_rollout_fails_closed_and_recovery_retains_view( + tmp_path: Path, +) -> None: + log_dir = tmp_path / "log-root" + store = CodexSessionStore(log_dir=log_dir) + home, _ = _generated_home(tmp_path) + lease = store.prepare_attempt( + session_home=home, + project_dir=tmp_path, + launch_id="0123456789abcdef", + attempt=1, + current_resume_spec=NoResume(), + ) + + with pytest.raises(RuntimeError, match="no rollout data"): + with lease as handle: + handle.record_spawn(os.getpid(), os.getpgrp()) + handle.record_reaped(os.getpid(), os.getpgrp()) + + manifest_path = lease.view_path / "manifest.json" + assert manifest_path.is_file() + assert json.loads(manifest_path.read_text(encoding="utf-8"))["state"] == "finalizing" + with pytest.raises(RuntimeError, match="failed closed"): + store.recover() + assert lease.view_path.is_dir() + + +def test_recovery_rejects_incomplete_manifest_without_deleting_view( + tmp_path: Path, +) -> None: + store = CodexSessionStore(log_dir=tmp_path / "log-root") + home, _ = _generated_home(tmp_path) + lease = store.prepare_attempt( + session_home=home, + project_dir=tmp_path, + launch_id="0123456789abcdef", + attempt=1, + current_resume_spec=NoResume(), + ) + lease.view_lease.release() + manifest_path = lease.view_path / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + del manifest["project_cwd"] + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + + with pytest.raises(RuntimeError, match="Invalid Codex recovery manifest"): + store.recover() + assert lease.view_path.is_dir() + + +@pytest.mark.parametrize( + ("field", "invalid_value"), + [ + ("launch_id", "NOT-CANONICAL"), + ("attempt", 0), + ("attempt", True), + ("view_id", "0123456789abcdef-2"), + ("project_cwd", "relative/project"), + ("state", "unknown"), + ("child_pid", True), + ("reaped", 1), + ("resume_thread_id", "thread-without-source-fields"), + ("final_store", "active"), + ], +) +def test_recovery_manifest_validation_rejects_invalid_contract_fields( + tmp_path: Path, + field: str, + invalid_value: object, +) -> None: + store = CodexSessionStore(log_dir=tmp_path / "log-root") + home, _ = _generated_home(tmp_path) + lease = store.prepare_attempt( + session_home=home, + project_dir=tmp_path, + launch_id="0123456789abcdef", + attempt=1, + current_resume_spec=NoResume(), + ) + lease.view_lease.release() + manifest_path = lease.view_path / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest[field] = invalid_value + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + + with pytest.raises(RuntimeError, match="Invalid Codex recovery manifest"): + store.recover() + + assert lease.view_path.is_dir() + + +def test_recovery_manifest_validation_rejects_symlinked_rollout_data( + tmp_path: Path, +) -> None: + store = CodexSessionStore(log_dir=tmp_path / "log-root") + home, _ = _generated_home(tmp_path) + lease = store.prepare_attempt( + session_home=home, + project_dir=tmp_path, + launch_id="0123456789abcdef", + attempt=1, + current_resume_spec=NoResume(), + ) + lease.view_lease.release() + outside = tmp_path / "outside.jsonl" + _rollout(outside, "thread-symlink") + (lease.view_path / "sessions" / "rollout-symlink.jsonl").symlink_to(outside) + + with pytest.raises(RuntimeError, match="Invalid Codex recovery manifest"): + store.recover() + + assert lease.view_path.is_dir() + + +def test_locator_rejects_ambiguous_canonical_representations(tmp_path: Path) -> None: + store = CodexSessionStore(log_dir=tmp_path / "log-root") + relative = Path("2026/07/rollout-ambiguous.jsonl") + _rollout(store.active_root / relative, "thread-ambiguous") + _rollout(store.archive_root / relative, "thread-ambiguous") + + with pytest.raises(RuntimeError, match="Ambiguous canonical"): + store.locate_session("thread-ambiguous") + + +def test_locator_ignores_view_with_invalid_manifest(tmp_path: Path) -> None: + store = CodexSessionStore(log_dir=tmp_path / "log-root") + home, _ = _generated_home(tmp_path) + lease = store.prepare_attempt( + session_home=home, + project_dir=tmp_path, + launch_id="0123456789abcdef", + attempt=1, + current_resume_spec=NoResume(), + ) + lease.view_lease.release() + _rollout(lease.view_path / "sessions" / "rollout-invalid.jsonl", "thread-invalid") + manifest_path = lease.view_path / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["launch_id"] = "invalid" + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + + assert store.locate_session("thread-invalid") is None + + +def test_recovery_scans_and_rebuilds_index_inside_lifecycle_lock( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + log_dir = tmp_path / "log-root" + store = CodexSessionStore(log_dir=log_dir) + store._ensure_roots() + first = store.active_root / "2026/07/rollout-first.jsonl" + _rollout(first, "thread-first", cwd=tmp_path) + store.index_path.write_text( + json.dumps([{"session_id": "thread-first", "launch_id": "0123456789abcdef"}]), + encoding="utf-8", + ) + + context = multiprocessing.get_context("spawn") + ready = context.Event() + release = context.Event() + holder = context.Process( + target=_hold_flock, + args=(str(store.locks_root / "lifecycle.lock"), ready, release), + ) + holder.start() + assert ready.wait(5) + + lifecycle_requested = threading.Event() + original_acquire = storage._FileLease.acquire.__func__ + + def notifying_acquire( + cls: type[storage._FileLease], + path: Path, + *, + nonblocking: bool = False, + ) -> storage._FileLease: + if path.name == "lifecycle.lock": + lifecycle_requested.set() + return original_acquire(cls, path, nonblocking=nonblocking) + + monkeypatch.setattr(storage._FileLease, "acquire", classmethod(notifying_acquire)) + failures: list[BaseException] = [] + + def recover() -> None: + try: + store.recover() + except BaseException as exc: + failures.append(exc) + + recovery = threading.Thread(target=recover) + recovery.start() + assert lifecycle_requested.wait(5) + second = store.archive_root / "2026/07/rollout-second.jsonl" + _rollout(second, "thread-second", cwd=tmp_path) + release.set() + recovery.join(10) + holder.join(10) + + assert not recovery.is_alive() + assert holder.exitcode == 0 + assert failures == [] + rows = json.loads(store.index_path.read_text(encoding="utf-8")) + assert {row["session_id"] for row in rows} == {"thread-first", "thread-second"} + first_row = next(row for row in rows if row["session_id"] == "thread-first") + assert first_row["launch_id"] == "0123456789abcdef" + + +def test_view_lease_is_acquired_before_view_creation( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + store = CodexSessionStore(log_dir=tmp_path / "log-root") + home, _ = _generated_home(tmp_path) + observed: list[bool] = [] + original_acquire = storage._FileLease.acquire.__func__ + + def recording_acquire( + cls: type[storage._FileLease], + path: Path, + *, + nonblocking: bool = False, + ) -> storage._FileLease: + if path.name.startswith("view-"): + observed.append(not (store.views_root / "0123456789abcdef-1").exists()) + return original_acquire(cls, path, nonblocking=nonblocking) + + monkeypatch.setattr( + storage._FileLease, + "acquire", + classmethod(recording_acquire), + ) + with store.prepare_attempt( + session_home=home, + project_dir=tmp_path, + launch_id="0123456789abcdef", + attempt=1, + current_resume_spec=NoResume(), + ): + pass + + assert observed == [True] + + +def test_recovery_rebuild_preserves_rollout_project_discriminator( + tmp_path: Path, +) -> None: + log_dir = tmp_path / "log-root" + store = CodexSessionStore(log_dir=log_dir) + canonical = log_dir / "codex-sessions" / "2026/07/rollout.jsonl" + _rollout(canonical, "thread-project", cwd=tmp_path) + + store.recover() + + summaries = store.read_index(str(tmp_path)) + assert [(summary.session_id, summary.cwd) for summary in summaries] == [ + ("thread-project", str(tmp_path)) + ] + + +def test_unsupported_filesystem_fails_before_view_mutation( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + log_dir = tmp_path / "log-root" + store = CodexSessionStore(log_dir=log_dir) + home, _ = _generated_home(tmp_path) + monkeypatch.setattr(storage, "_filesystem_type", lambda _path: "nfs") + + with pytest.raises(RuntimeError, match="supported local filesystem"): + store.prepare_attempt( + session_home=home, + project_dir=tmp_path, + launch_id="0123456789abcdef", + attempt=1, + current_resume_spec=NoResume(), + ) + + assert not (log_dir / "codex-active-sessions" / "0123456789abcdef-1").exists() + + +def test_cross_device_layout_fails_before_view_mutation( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + log_dir = tmp_path / "log-root" + store = CodexSessionStore(log_dir=log_dir) + home, _ = _generated_home(tmp_path) + store._ensure_roots() + original_stat = Path.stat + + class _DifferentDevice: + def __init__(self, result: os.stat_result) -> None: + self._result = result + self.st_dev = result.st_dev + 1 + + def __getattr__(self, name: str) -> object: + return getattr(self._result, name) + + def different_archive_device(path: Path, *args: object, **kwargs: object) -> object: + result = original_stat(path, *args, **kwargs) + if path == store.archive_root: + return _DifferentDevice(result) + return result + + monkeypatch.setattr(Path, "stat", different_archive_device) + with pytest.raises(RuntimeError, match="share one filesystem"): + store.prepare_attempt( + session_home=home, + project_dir=tmp_path, + launch_id="0123456789abcdef", + attempt=1, + current_resume_spec=NoResume(), + ) + + assert not (store.views_root / "0123456789abcdef-1").exists() + + +def test_promotion_collision_preserves_canonical_and_staged_rollouts( + tmp_path: Path, +) -> None: + log_dir = tmp_path / "log-root" + store = CodexSessionStore(log_dir=log_dir) + relative = Path("2026/07/rollout-collision.jsonl") + canonical = store.active_root / relative + canonical_bytes = _rollout(canonical, "thread-existing") + home, _ = _generated_home(tmp_path) + lease = store.prepare_attempt( + session_home=home, + project_dir=tmp_path, + launch_id="0123456789abcdef", + attempt=1, + current_resume_spec=NoResume(), + ) + + with pytest.raises(RuntimeError, match="collision preserves both files"): + with lease as handle: + staged = (home / "sessions").resolve() / relative + staged_bytes = _rollout(staged, "thread-new") + handle.record_spawn(os.getpid(), os.getpgrp()) + handle.record_reaped(os.getpid(), os.getpgrp()) + + assert canonical.read_bytes() == canonical_bytes + assert (lease.view_path / "sessions" / relative).read_bytes() == staged_bytes + + +def test_recovery_skips_live_attempt_until_inherited_view_lease_is_released( + tmp_path: Path, +) -> None: + log_dir = tmp_path / "log-root" + store = CodexSessionStore(log_dir=log_dir) + home, _ = _generated_home(tmp_path) + lease = store.prepare_attempt( + session_home=home, + project_dir=tmp_path, + launch_id="0123456789abcdef", + attempt=1, + current_resume_spec=NoResume(), + ) + + handle = lease.__enter__() + relative = Path("2026/07/rollout-live.jsonl") + _rollout((home / "sessions").resolve() / relative, "thread-live") + handle.record_spawn(os.getpid(), os.getpgrp()) + store.recover() + assert lease.view_path.is_dir() + assert not (store.active_root / relative).exists() + + handle.record_reaped(os.getpid(), os.getpgrp()) + lease.__exit__(None, None, None) + assert (store.active_root / relative).is_file() + + +def test_recovery_promotes_orphan_after_process_group_releases_view_lease( + tmp_path: Path, +) -> None: + log_dir = tmp_path / "log-root" + store = CodexSessionStore(log_dir=log_dir) + home, _ = _generated_home(tmp_path) + lease = store.prepare_attempt( + session_home=home, + project_dir=tmp_path, + launch_id="0123456789abcdef", + attempt=1, + current_resume_spec=NoResume(), + ) + + handle = lease.__enter__() + relative = Path("2026/07/rollout-orphan.jsonl") + _rollout((home / "sessions").resolve() / relative, "thread-orphan", cwd=tmp_path) + handle.record_spawn(12345, 12345) + lease.view_lease.release() + + store.recover() + + assert not lease.view_path.exists() + assert (store.active_root / relative).is_file() + assert store.read_index(str(tmp_path))[0].session_id == "thread-orphan" + + def test_fresh_rollout_is_promoted_durably_and_indexed_once(tmp_path: Path) -> None: log_dir = tmp_path / "log-root" index_path = log_dir / "codex-session-index.json" @@ -76,6 +596,7 @@ def test_fresh_rollout_is_promoted_durably_and_indexed_once(tmp_path: Path) -> N with store.prepare_attempt( session_home=home, + project_dir=tmp_path, launch_id="0123456789abcdef", attempt=1, current_resume_spec=NoResume(), @@ -95,6 +616,7 @@ def test_fresh_rollout_is_promoted_durably_and_indexed_once(tmp_path: Path) -> N matching = [row for row in rows if row["session_id"] == "thread-new"] assert len(matching) == 1 assert matching[0]["backend_name"] == "codex" + assert matching[0]["cwd"] == str(tmp_path) assert matching[0]["canonical_store"] == "active" assert matching[0]["relative_path"] == "2026/07/rollout-new.jsonl" @@ -120,6 +642,7 @@ def test_named_resume_hard_links_only_selected_rollout_into_matching_view( with store.prepare_attempt( session_home=home, + project_dir=tmp_path, launch_id="0123456789abcdef", attempt=1, current_resume_spec=NamedResume("thread-resume"), diff --git a/tests/execution/backends/test_coding_agent_backend_conformance.py b/tests/execution/backends/test_coding_agent_backend_conformance.py index d4bfdcbbcb..335391a257 100644 --- a/tests/execution/backends/test_coding_agent_backend_conformance.py +++ b/tests/execution/backends/test_coding_agent_backend_conformance.py @@ -9,6 +9,7 @@ import pytest from autoskillit.core import ( + CODEX_SESSIONS_SUBDIR, BackendCapabilities, BackendConventions, CapabilityNotSupportedError, @@ -352,14 +353,13 @@ def test_setup_session_dir_and_locator_round_trip( lambda: fake_log_dir, ) (fake_home / ".codex").mkdir() - (fake_home / ".codex" / "config.toml").write_text('[model_provider]\nname = "fake"\n') session_dir = tmp_path / "session" session_dir.mkdir() + (session_dir / "config.toml").write_text('[model_provider]\nname = "fake"\n') self.backend.setup_session_dir(session_dir) - sessions_symlink = session_dir / "sessions" - assert sessions_symlink.is_symlink() - sessions_target = sessions_symlink.resolve() - date_dir = sessions_target / "2026" / "01" / "01" + assert not (session_dir / "sessions").exists() + assert not (session_dir / "archived_sessions").exists() + date_dir = fake_log_dir / CODEX_SESSIONS_SUBDIR / "2026" / "01" / "01" date_dir.mkdir(parents=True) rollout_path = date_dir / "rollout-fake.jsonl" event = { diff --git a/tests/execution/backends/test_validate_session_layout.py b/tests/execution/backends/test_validate_session_layout.py index 0637f26aa0..90389e3ad6 100644 --- a/tests/execution/backends/test_validate_session_layout.py +++ b/tests/execution/backends/test_validate_session_layout.py @@ -4,6 +4,8 @@ import pytest +from autoskillit.core import SESSION_ADD_DIR_SUBDIR + pytestmark = [pytest.mark.layer("execution"), pytest.mark.small] @@ -11,7 +13,7 @@ class TestClaudeCodeLayoutValidation: def test_claude_code_valid_layout_returns_empty(self, tmp_path): from autoskillit.execution.backends.claude import ClaudeCodeBackend - skills_dir = tmp_path / ".claude" / "skills" + skills_dir = tmp_path / SESSION_ADD_DIR_SUBDIR / ".claude" / "skills" skills_dir.mkdir(parents=True) (skills_dir / "some-skill").mkdir() (skills_dir / "some-skill" / "SKILL.md").write_text("# Some Skill") @@ -31,7 +33,7 @@ def test_claude_code_missing_skills_dir_returns_error(self, tmp_path): def test_claude_code_empty_skills_dir_returns_error(self, tmp_path): from autoskillit.execution.backends.claude import ClaudeCodeBackend - skills_dir = tmp_path / ".claude" / "skills" + skills_dir = tmp_path / SESSION_ADD_DIR_SUBDIR / ".claude" / "skills" skills_dir.mkdir(parents=True) backend = ClaudeCodeBackend() @@ -44,7 +46,7 @@ def test_claude_code_bundled_skill_present_returns_error(self, tmp_path): from autoskillit.execution.backends.claude import ClaudeCodeBackend from autoskillit.workspace.skills import DefaultSkillResolver - skills_dir = tmp_path / ".claude" / "skills" + skills_dir = tmp_path / SESSION_ADD_DIR_SUBDIR / ".claude" / "skills" skills_dir.mkdir(parents=True) resolver = DefaultSkillResolver() @@ -66,7 +68,7 @@ class TestCodexLayoutValidation: def test_codex_valid_layout_returns_empty(self, tmp_path): from autoskillit.execution.backends.codex import CodexBackend - skills_dir = tmp_path / "skills" + skills_dir = tmp_path / SESSION_ADD_DIR_SUBDIR / "skills" skills_dir.mkdir(parents=True) (skills_dir / "some-skill").mkdir() config_content = "[mcp_servers.autoskillit]\nname = 'autoskillit'\n" @@ -96,7 +98,7 @@ def test_codex_missing_skills_dir_returns_error(self, tmp_path): def test_codex_empty_skills_dir_returns_error(self, tmp_path): from autoskillit.execution.backends.codex import CodexBackend - skills_dir = tmp_path / "skills" + skills_dir = tmp_path / SESSION_ADD_DIR_SUBDIR / "skills" skills_dir.mkdir(parents=True) backend = CodexBackend() @@ -107,7 +109,7 @@ def test_codex_empty_skills_dir_returns_error(self, tmp_path): def test_codex_missing_config_toml_returns_error(self, tmp_path): from autoskillit.execution.backends.codex import CodexBackend - skills_dir = tmp_path / "skills" + skills_dir = tmp_path / SESSION_ADD_DIR_SUBDIR / "skills" skills_dir.mkdir(parents=True) (skills_dir / "some-skill").mkdir() @@ -119,7 +121,7 @@ def test_codex_missing_config_toml_returns_error(self, tmp_path): def test_codex_config_toml_missing_mcp_section_returns_error(self, tmp_path): from autoskillit.execution.backends.codex import CodexBackend - skills_dir = tmp_path / "skills" + skills_dir = tmp_path / SESSION_ADD_DIR_SUBDIR / "skills" skills_dir.mkdir(parents=True) (tmp_path / "config.toml").write_text("[other_section]\nkey = 'value'\n") @@ -131,7 +133,7 @@ def test_codex_config_toml_missing_mcp_section_returns_error(self, tmp_path): def test_codex_auth_json_regular_file_returns_error(self, tmp_path): from autoskillit.execution.backends.codex import CodexBackend - skills_dir = tmp_path / "skills" + skills_dir = tmp_path / SESSION_ADD_DIR_SUBDIR / "skills" skills_dir.mkdir(parents=True) (tmp_path / "config.toml").write_text("[mcp_servers.autoskillit]\n") (tmp_path / "auth.json").write_text("{}") @@ -144,7 +146,7 @@ def test_codex_auth_json_regular_file_returns_error(self, tmp_path): def test_codex_sessions_regular_dir_returns_error(self, tmp_path): from autoskillit.execution.backends.codex import CodexBackend - skills_dir = tmp_path / "skills" + skills_dir = tmp_path / SESSION_ADD_DIR_SUBDIR / "skills" skills_dir.mkdir(parents=True) (tmp_path / "config.toml").write_text("[mcp_servers.autoskillit]\n") (tmp_path / "sessions").mkdir() @@ -157,7 +159,7 @@ def test_codex_sessions_regular_dir_returns_error(self, tmp_path): def test_codex_sessions_absent_returns_error(self, tmp_path): from autoskillit.execution.backends.codex import CodexBackend - skills_dir = tmp_path / "skills" + skills_dir = tmp_path / SESSION_ADD_DIR_SUBDIR / "skills" skills_dir.mkdir(parents=True) (tmp_path / "config.toml").write_text("[mcp_servers.autoskillit]\n") @@ -170,8 +172,8 @@ def test_codex_layout_validation_never_runs_native_probe(self, tmp_path, monkeyp from autoskillit.execution.backends.codex import CodexBackend - skills_dir = tmp_path / "skills" - skills_dir.mkdir() + skills_dir = tmp_path / SESSION_ADD_DIR_SUBDIR / "skills" + skills_dir.mkdir(parents=True) (skills_dir / "some-skill").mkdir() (tmp_path / "config.toml").write_text("[mcp_servers.autoskillit]\n") for name in ("sessions", "archived_sessions"): @@ -191,7 +193,7 @@ def test_codex_layout_rejects_rollout_link_outside_generated_home(self, tmp_path from autoskillit.execution.backends.codex import CodexBackend generated_home = tmp_path / "generated-home" - skills_dir = generated_home / "skills" + skills_dir = generated_home / SESSION_ADD_DIR_SUBDIR / "skills" skills_dir.mkdir(parents=True) (skills_dir / "some-skill").mkdir() (generated_home / "config.toml").write_text("[mcp_servers.autoskillit]\n") @@ -211,8 +213,8 @@ def test_codex_layout_rejects_rollout_link_outside_generated_home(self, tmp_path def test_codex_layout_rejects_nonempty_inert_rollout_target(self, tmp_path, public_name): from autoskillit.execution.backends.codex import CodexBackend - skills_dir = tmp_path / "skills" - skills_dir.mkdir() + skills_dir = tmp_path / SESSION_ADD_DIR_SUBDIR / "skills" + skills_dir.mkdir(parents=True) (skills_dir / "some-skill").mkdir() (tmp_path / "config.toml").write_text("[mcp_servers.autoskillit]\n") for name in ("sessions", "archived_sessions"): diff --git a/tests/execution/test_env_boundary.py b/tests/execution/test_env_boundary.py index 53abbda15b..daecfb03eb 100644 --- a/tests/execution/test_env_boundary.py +++ b/tests/execution/test_env_boundary.py @@ -215,6 +215,19 @@ def test_orchestrator_session_required_env_hygiene_coverage() -> None: ) +def test_codex_cook_reserved_state_never_crosses_into_headless_children() -> None: + from autoskillit.core import ( + AUTOSKILLIT_PRIVATE_ENV_VARS, + CODEX_COOK_RESERVED_ENV_VARS, + CODEX_STARTUP_TRACE_ENV_VAR, + ) + + assert { + CODEX_STARTUP_TRACE_ENV_VAR, + *CODEX_COOK_RESERVED_ENV_VARS, + } <= AUTOSKILLIT_PRIVATE_ENV_VARS + + def test_skill_session_required_env_hygiene_coverage() -> None: """Every SKILL_SESSION_REQUIRED_ENV var is accounted for in the env hygiene chain.""" from autoskillit.core import ( diff --git a/tests/execution/test_headless_env_scrub.py b/tests/execution/test_headless_env_scrub.py index 295f803df1..d742d7c517 100644 --- a/tests/execution/test_headless_env_scrub.py +++ b/tests/execution/test_headless_env_scrub.py @@ -23,6 +23,9 @@ async def test_run_headless_core_env_excludes_ide_vars( monkeypatch.setenv("CLAUDE_CODE_SSE_PORT", "23270") monkeypatch.setenv("ENABLE_IDE_INTEGRATION", "true") monkeypatch.setenv("CLAUDE_CODE_IDE_HOST_OVERRIDE", "host") + monkeypatch.setenv("AUTOSKILLIT_CODEX_STARTUP_TRACE", "1") + monkeypatch.setenv("CODEX_HOME", "/tmp/interactive-cook-home") + monkeypatch.setenv("CODEX_SQLITE_HOME", "/tmp/interactive-cook-home") from autoskillit.execution.headless import run_headless_core @@ -37,6 +40,9 @@ async def test_run_headless_core_env_excludes_ide_vars( assert "CLAUDE_CODE_SSE_PORT" not in env assert "ENABLE_IDE_INTEGRATION" not in env assert "CLAUDE_CODE_IDE_HOST_OVERRIDE" not in env + assert "AUTOSKILLIT_CODEX_STARTUP_TRACE" not in env + assert "CODEX_HOME" not in env + assert "CODEX_SQLITE_HOME" not in env assert env["CLAUDE_CODE_AUTO_CONNECT_IDE"] == "0" assert env["AUTOSKILLIT_HEADLESS"] == "1" assert env["TERM"] == "dumb" diff --git a/tests/fleet/test_state_lock_contract.py b/tests/fleet/test_state_lock_contract.py index c2c8c87708..16bbd309cb 100644 --- a/tests/fleet/test_state_lock_contract.py +++ b/tests/fleet/test_state_lock_contract.py @@ -38,6 +38,8 @@ _FCNTL_ALLOWED_RELATIVE_PATHS: frozenset[str] = frozenset( { "core/_plugin_cache.py", + "cli/session/pty/_exec.py", + "cli/session/pty/_observer.py", "execution/backends/_codex_config_lock.py", "execution/backends/_codex_session_storage.py", "execution/session/_session_state.py", diff --git a/tests/infra/test_schema_version_convention.py b/tests/infra/test_schema_version_convention.py index 8e30dc6ec8..b466e0d2ce 100644 --- a/tests/infra/test_schema_version_convention.py +++ b/tests/infra/test_schema_version_convention.py @@ -117,7 +117,7 @@ def _is_yaml_dump(node: ast.expr) -> bool: # staleness_cache.py — cache dict ("src/autoskillit/recipe/staleness_cache.py", 67), # _lifespan.py — hooks.json self-heal on startup drift (co-owned with Claude plugin system) - ("src/autoskillit/server/_lifespan.py", 91), + ("src/autoskillit/server/_lifespan.py", 97), # tools_kitchen.py — hook config, quota guard, git_ops_policy, ingredient locks overlay ("src/autoskillit/server/tools/tools_kitchen.py", 283), ("src/autoskillit/server/tools/tools_kitchen.py", 302), diff --git a/tests/integration/test_codex_startup_canary.py b/tests/integration/test_codex_startup_canary.py index b870990898..1a3fc10271 100644 --- a/tests/integration/test_codex_startup_canary.py +++ b/tests/integration/test_codex_startup_canary.py @@ -5,15 +5,19 @@ import errno import json import os +import random import shutil import signal +import statistics import subprocess import sys +import time import uuid from dataclasses import replace from pathlib import Path import pytest +import zstandard from autoskillit.execution.backends.codex import CodexBackend @@ -27,6 +31,7 @@ _CANARY_ENV = "AUTOSKILLIT_CODEX_STARTUP_CANARY" _SUPPORTED_VERSION = "codex-cli 0.145.0" _OUTPUT_CAP = 64 * 1024 +_INSTALLED_CODEX_HOME = Path.home() / ".codex" def _installed_supported_codex() -> str: @@ -53,7 +58,7 @@ def _installed_supported_codex() -> str: def _prepare_home(path: Path) -> None: path.mkdir() (path / "sessions").mkdir() - source_auth = Path.home() / ".codex" / "auth.json" + source_auth = _INSTALLED_CODEX_HOME / "auth.json" if source_auth.is_file(): (path / "auth.json").symlink_to(source_auth) @@ -83,12 +88,31 @@ def _run_with_inherited_lease( *, project: Path, lease_path: Path, -) -> tuple[bytes, bytes]: + rollout_root: Path, +) -> tuple[bytes, bytes, tuple[int, int], float]: assert fcntl is not None lease_fd = os.open(lease_path, os.O_CREAT | os.O_RDWR, 0o600) fcntl.flock(lease_fd, fcntl.LOCK_EX) + wrapper = """ +import json +import os +import sys +import time + +command = json.loads(sys.argv[1]) +if os.fork() == 0: + for descriptor in (0, 1, 2): + try: + os.close(descriptor) + except OSError: + pass + time.sleep(120) + os._exit(0) +os.execvpe(command[0], command, os.environ) +""" + started_at = time.monotonic() process = subprocess.Popen( - spec.cmd, + (sys.executable, "-c", wrapper, json.dumps(list(spec.cmd))), cwd=project, env=spec.env, stdout=subprocess.PIPE, @@ -101,6 +125,25 @@ def _run_with_inherited_lease( try: assert process.poll() is None, "Codex exited before inherited-lease observation" _assert_competing_lease_is_blocked(lease_path) + observed_identity: tuple[int, int] | None = None + observation_deadline = time.monotonic() + 30 + while observed_identity is None and time.monotonic() < observation_deadline: + observed = _rollouts_from_root(rollout_root) + if observed: + file_stat = observed[0].stat() + observed_identity = (file_stat.st_dev, file_stat.st_ino) + break + if process.poll() is not None: + break + time.sleep(0.01) + if observed_identity is None: + stdout, stderr = process.communicate(timeout=90) + assert process.returncode == 0, stderr[-_OUTPUT_CAP:].decode(errors="replace") + pytest.fail( + "Codex produced no live staged rollout inode; " + f"stdout={stdout[-_OUTPUT_CAP:].decode(errors='replace')!r}; " + f"stderr={stderr[-_OUTPUT_CAP:].decode(errors='replace')!r}" + ) stdout, stderr = process.communicate(timeout=90) except BaseException: if process.poll() is None: @@ -112,8 +155,26 @@ def _run_with_inherited_lease( process.wait(timeout=5) raise assert process.returncode == 0, stderr[-_OUTPUT_CAP:].decode(errors="replace") + os.killpg(process.pid, 0) + _assert_competing_lease_is_blocked(lease_path) + os.killpg(process.pid, signal.SIGTERM) + group_deadline = time.monotonic() + 5 + while time.monotonic() < group_deadline: + try: + os.killpg(process.pid, 0) + except ProcessLookupError: + break + time.sleep(0.02) + else: + os.killpg(process.pid, signal.SIGKILL) + pytest.fail("Codex process group remained live after descendant termination") _assert_lease_released(lease_path) - return stdout[-_OUTPUT_CAP:], stderr[-_OUTPUT_CAP:] + return ( + stdout[-_OUTPUT_CAP:], + stderr[-_OUTPUT_CAP:], + observed_identity, + time.monotonic() - started_at, + ) def _thread_id(stdout: bytes) -> str: @@ -129,21 +190,90 @@ def _thread_id(stdout: bytes) -> str: pytest.fail("installed Codex did not emit a thread.started identifier") -def _rollouts(home: Path) -> list[Path]: +def _rollouts_from_root(root: Path) -> list[Path]: return sorted( path - for path in (home / "sessions").rglob("rollout-*") + for path in root.rglob("rollout-*") if path.is_file() and path.suffix in {".jsonl", ".zst"} ) -def _assert_jsonl_schema(path: Path) -> None: +def _rollouts(home: Path) -> list[Path]: + return _rollouts_from_root(home / "sessions") + + +def _rollout_records(path: Path) -> list[dict[str, object]]: if path.suffix == ".zst": - assert path.stat().st_size > 0 - return - lines = path.read_bytes().splitlines() + data = zstandard.ZstdDecompressor().decompress(path.read_bytes()) + else: + data = path.read_bytes() + lines = data.splitlines() assert lines - assert all(isinstance(json.loads(line), dict) for line in lines) + records = [json.loads(line) for line in lines] + assert all(isinstance(record, dict) for record in records) + return records + + +def _recorded_thread_ids(records: list[dict[str, object]]) -> set[str]: + result: set[str] = set() + for record in records: + if record.get("type") == "thread.started" and isinstance(record.get("thread_id"), str): + result.add(str(record["thread_id"])) + payload = record.get("payload") + if ( + record.get("type") == "session_meta" + and isinstance(payload, dict) + and isinstance(payload.get("id"), str) + ): + result.add(str(payload["id"])) + return result + + +def _write_history_profile( + root: Path, + *, + project: Path, + file_count: int, + payload_bytes: int, +) -> dict[str, int]: + timestamp = "2026-07-24T00:00:00.000Z" + for index in range(file_count): + thread_id = f"profile-thread-{index:04d}" + records = [ + { + "timestamp": timestamp, + "type": "session_meta", + "payload": { + "id": thread_id, + "timestamp": timestamp, + "cwd": str(project), + "originator": "codex_cli_rs", + "cli_version": "0.145.0", + "source": "cli", + }, + }, + { + "timestamp": timestamp, + "type": "response_item", + "payload": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "x" * payload_bytes}], + }, + }, + ] + path = root / "2026" / "07" / "24" / f"rollout-profile-{index:04d}.jsonl" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes( + b"".join( + json.dumps(record, separators=(",", ":")).encode() + b"\n" for record in records + ) + ) + files = _rollouts_from_root(root) + return { + "file_count": len(files), + "allocated_bytes": sum(path.stat().st_blocks * 512 for path in files), + } def test_installed_codex_preserves_staged_rollout_inode_and_inherited_lease( @@ -152,6 +282,12 @@ def test_installed_codex_preserves_staged_rollout_inode_and_inherited_lease( binary = _installed_supported_codex() project = tmp_path / "project" project.mkdir() + subprocess.run( + ("git", "init", "--quiet"), + cwd=project, + check=True, + capture_output=True, + ) diagnostics = project / ".autoskillit" / "temp" / uuid.uuid4().hex[:16] diagnostics.mkdir(parents=True) fresh_home = tmp_path / "fresh-home" @@ -164,15 +300,22 @@ def test_installed_codex_preserves_staged_rollout_inode_and_inherited_lease( env={**fresh.env, "CODEX_HOME": str(fresh_home)}, cwd=str(project), ) - fresh_stdout, fresh_stderr = _run_with_inherited_lease( + fresh_stdout, fresh_stderr, fresh_live_identity, fresh_duration = _run_with_inherited_lease( fresh, project=project, lease_path=tmp_path / "fresh.lease", + rollout_root=fresh_home / "sessions", ) thread_id = _thread_id(fresh_stdout) fresh_rollouts = _rollouts(fresh_home) assert len(fresh_rollouts) == 1 - _assert_jsonl_schema(fresh_rollouts[0]) + fresh_records = _rollout_records(fresh_rollouts[0]) + assert _recorded_thread_ids(fresh_records) == {thread_id} + if fresh_rollouts[0].suffix == ".jsonl": + assert ( + fresh_rollouts[0].stat().st_dev, + fresh_rollouts[0].stat().st_ino, + ) == fresh_live_identity resume_home = tmp_path / "resume-home" _prepare_home(resume_home) @@ -191,15 +334,22 @@ def test_installed_codex_preserves_staged_rollout_inode_and_inherited_lease( env={**resume.env, "CODEX_HOME": str(resume_home)}, cwd=str(project), ) - resume_stdout, resume_stderr = _run_with_inherited_lease( - resume, - project=project, - lease_path=tmp_path / "resume.lease", + resume_stdout, resume_stderr, resume_live_identity, resume_duration = ( + _run_with_inherited_lease( + resume, + project=project, + lease_path=tmp_path / "resume.lease", + rollout_root=resume_home / "sessions", + ) ) + assert _thread_id(resume_stdout) == thread_id final_rollouts = _rollouts(resume_home) assert len(final_rollouts) == 1 final = final_rollouts[0] - _assert_jsonl_schema(final) + final_records = _rollout_records(final) + assert _recorded_thread_ids(final_records) == {thread_id} + assert final_records[: len(fresh_records)] == fresh_records + assert resume_live_identity == staged_identity if final.suffix == ".jsonl": assert (final.stat().st_dev, final.stat().st_ino) == staged_identity else: @@ -214,6 +364,8 @@ def test_installed_codex_preserves_staged_rollout_inode_and_inherited_lease( "fresh_allocated_bytes": fresh_rollouts[0].stat().st_blocks * 512, "resume_file_count": len(final_rollouts), "resume_allocated_bytes": final.stat().st_blocks * 512, + "fresh_duration_seconds": fresh_duration, + "resume_duration_seconds": resume_duration, }, sort_keys=True, ), @@ -223,3 +375,120 @@ def test_installed_codex_preserves_staged_rollout_inode_and_inherited_lease( (diagnostics / "fresh.stderr").write_bytes(fresh_stderr) (diagnostics / "resume.stdout").write_bytes(resume_stdout) (diagnostics / "resume.stderr").write_bytes(resume_stderr) + + +def test_installed_codex_startup_profile_matrix_is_bounded_and_retained( + tmp_path: Path, +) -> None: + binary = _installed_supported_codex() + project = tmp_path / "project" + project.mkdir() + subprocess.run( + ("git", "init", "--quiet"), + cwd=project, + check=True, + capture_output=True, + ) + diagnostics = project / ".autoskillit" / "temp" / uuid.uuid4().hex[:16] + diagnostics.mkdir(parents=True) + (tmp_path / "leases").mkdir() + profile_defs = { + "small": (2, 64), + "many_file": (96, 64), + "large_byte": (4, 256 * 1024), + } + profile_metadata = { + name: _write_history_profile( + tmp_path / "history" / name, + project=project, + file_count=file_count, + payload_bytes=payload_bytes, + ) + for name, (file_count, payload_bytes) in profile_defs.items() + } + backend = CodexBackend() + sequence = 0 + + def measure(profile_name: str, *, retain: bool) -> dict[str, object]: + nonlocal sequence + sequence += 1 + generated_home = tmp_path / "homes" / f"{sequence:02d}-{profile_name}" + generated_home.parent.mkdir(exist_ok=True) + _prepare_home(generated_home) + assert _rollouts(generated_home) == [] + spec = backend.build_headless_cmd( + "Respond with exactly: autoskillit startup profile canary" + ) + spec = replace( + spec, + cmd=(binary, *spec.cmd[1:]), + env={**spec.env, "CODEX_HOME": str(generated_home)}, + cwd=str(project), + ) + stdout, stderr, _, duration = _run_with_inherited_lease( + spec, + project=project, + lease_path=tmp_path / "leases" / f"{sequence:02d}-{profile_name}.lease", + rollout_root=generated_home / "sessions", + ) + assert duration <= 17.0 + assert len(_rollouts(generated_home)) == 1 + sample = { + "sequence": sequence, + "profile": profile_name, + "duration_seconds": duration, + **profile_metadata[profile_name], + } + if retain: + (diagnostics / f"sample-{sequence:02d}.stdout").write_bytes(stdout[-_OUTPUT_CAP:]) + (diagnostics / f"sample-{sequence:02d}.stderr").write_bytes(stderr[-_OUTPUT_CAP:]) + return sample + + for profile_name in profile_defs: + measure(profile_name, retain=False) + + retained: list[dict[str, object]] = [] + randomized_order: list[str] = [] + generator = random.Random(0xA5705) + for _ in range(3): + round_order = list(profile_defs) + generator.shuffle(round_order) + randomized_order.extend(round_order) + retained.extend(measure(profile_name, retain=True) for profile_name in round_order) + + summaries: dict[str, dict[str, float | bool]] = {} + for profile_name in profile_defs: + durations = [ + float(sample["duration_seconds"]) + for sample in retained + if sample["profile"] == profile_name + ] + median = statistics.median(durations) + mad = statistics.median(abs(duration - median) for duration in durations) + mean = statistics.fmean(durations) + coefficient_of_variation = statistics.pstdev(durations) / mean if mean else 0.0 + summaries[profile_name] = { + "median_seconds": median, + "mad_seconds": mad, + "coefficient_of_variation": coefficient_of_variation, + "unstable": coefficient_of_variation > 0.25, + } + + (diagnostics / "samples.json").write_text( + json.dumps(retained, sort_keys=True), + encoding="utf-8", + ) + (diagnostics / "summary.json").write_text( + json.dumps( + { + "codex_version": _SUPPORTED_VERSION, + "profiles": profile_metadata, + "retained_order": randomized_order, + "retained_samples_per_profile": 3, + "output_cap_bytes": _OUTPUT_CAP, + "summaries": summaries, + }, + sort_keys=True, + ), + encoding="utf-8", + ) diff --git a/tests/server/test_lifespan.py b/tests/server/test_lifespan.py index 9b223583f8..c7fb6aa066 100644 --- a/tests/server/test_lifespan.py +++ b/tests/server/test_lifespan.py @@ -22,6 +22,7 @@ async def test_lifespan_calls_finalize_on_recording_runner(): mock_runner.recorder = mock_recorder mock_ctx = MagicMock() mock_ctx.runner = mock_runner + mock_ctx.backend.capabilities.mcp_config_capable = False with patch("autoskillit.server._lifespan._get_ctx_or_none", return_value=mock_ctx): async with _autoskillit_lifespan(MagicMock()): @@ -37,6 +38,7 @@ async def test_lifespan_skips_finalize_when_not_recording(): mock_ctx = MagicMock() mock_ctx.runner = MagicMock() # plain runner, not RecordingSubprocessRunner + mock_ctx.backend.capabilities.mcp_config_capable = False with patch("autoskillit.server._lifespan._get_ctx_or_none", return_value=mock_ctx): async with _autoskillit_lifespan(MagicMock()): @@ -68,6 +70,7 @@ async def test_lifespan_calls_finalize_on_cancellation(): mock_runner.recorder = mock_recorder mock_ctx = MagicMock() mock_ctx.runner = mock_runner + mock_ctx.backend.capabilities.mcp_config_capable = False with patch("autoskillit.server._lifespan._get_ctx_or_none", return_value=mock_ctx): with pytest.raises(asyncio.CancelledError): @@ -84,6 +87,7 @@ async def test_lifespan_sets_startup_ready_event(monkeypatch): mock_ctx = MagicMock() mock_ctx.runner = MagicMock() + mock_ctx.backend.capabilities.mcp_config_capable = False mock_ctx.config.linux_tracing.tmpfs_path = "/tmp" mock_ctx.config.linux_tracing.log_dir = None mock_ctx.audit = MagicMock() diff --git a/tests/workspace/conftest.py b/tests/workspace/conftest.py index 9f82e950ed..c58cbcf4d9 100644 --- a/tests/workspace/conftest.py +++ b/tests/workspace/conftest.py @@ -113,7 +113,7 @@ def _factory( return DefaultSessionSkillManager( provider, ephemeral_root=ephemeral_root or tmp_path, - codex_root=codex_root, + codex_root=codex_root or tmp_path / "codex-root", ) return _factory From d67e6c8d565768b132ca6a762e77625628f08953 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 06:43:38 -0700 Subject: [PATCH 04/35] test: bound Codex startup profile canary --- tests/integration/test_codex_startup_canary.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/test_codex_startup_canary.py b/tests/integration/test_codex_startup_canary.py index 1a3fc10271..fed68dfe72 100644 --- a/tests/integration/test_codex_startup_canary.py +++ b/tests/integration/test_codex_startup_canary.py @@ -377,6 +377,7 @@ def test_installed_codex_preserves_staged_rollout_inode_and_inherited_lease( (diagnostics / "resume.stderr").write_bytes(resume_stderr) +@pytest.mark.timeout(600) def test_installed_codex_startup_profile_matrix_is_bounded_and_retained( tmp_path: Path, ) -> None: From 3f86d41e74b837aacb8e2e023726bfe5f776d763 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 12:31:10 -0700 Subject: [PATCH 05/35] fix(review): dispatch init prelaunch through backend --- tests/cli/test_init_helpers.py | 109 ++++++++++----------------------- 1 file changed, 33 insertions(+), 76 deletions(-) diff --git a/tests/cli/test_init_helpers.py b/tests/cli/test_init_helpers.py index ebd15dbf99..31ac1d4c19 100644 --- a/tests/cli/test_init_helpers.py +++ b/tests/cli/test_init_helpers.py @@ -3,7 +3,6 @@ from __future__ import annotations import subprocess -from contextlib import contextmanager from pathlib import Path from unittest.mock import MagicMock @@ -174,15 +173,6 @@ def _setup_register_all( lambda **kwargs: codex_calls.append("ensure_codex") or True, ) - @contextmanager - def record_transaction(**kwargs): - codex_calls.append(("transaction", kwargs)) - yield kwargs["source_codex_home"] / "config.toml" - - monkeypatch.setattr( - "autoskillit.cli._init_helpers.codex_prelaunch_transaction", - record_transaction, - ) # Override conftest's blanket patch on _is_plugin_installed to let real logic run monkeypatch.setattr( "autoskillit.cli._init_helpers._is_plugin_installed", @@ -199,28 +189,23 @@ def record_transaction(**kwargs): mock_backend.capabilities.mcp_config_capable = backend_name == "codex" mock_backend.capabilities.plugin_install_capable = backend_name != "codex" mock_backend.capabilities.hook_config_format = "toml_nested" + mock_backend.ensure_pre_launch.side_effect = lambda: ( + codex_calls.append("ensure_pre_launch") or [] + ) monkeypatch.setattr("autoskillit.execution.get_backend", lambda name: mock_backend) (tmp_path / "pkg").mkdir(exist_ok=True) return codex_calls, mcp_calls - def test_codex_backend_calls_composed_codex_config_transaction( + def test_codex_backend_calls_backend_owned_prelaunch( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: from autoskillit.cli._init_helpers import _register_all codex_calls, mcp_calls = self._setup_register_all(monkeypatch, tmp_path, "codex") _register_all("user", tmp_path) - assert codex_calls == [ - ( - "transaction", - { - "source_codex_home": tmp_path / "codex-source", - "hook_config_format": "toml_nested", - }, - ) - ] + assert codex_calls == ["ensure_pre_launch"] assert not mcp_calls, "_register_mcp_server must NOT be called for codex backend" def test_claude_code_backend_calls_register_mcp_server( @@ -300,8 +285,8 @@ class TestRegisterAllCodexConfigTransaction: def _setup( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, backend_name: str - ) -> tuple[list, list]: - """Shared setup: return (public MCP calls, composed transaction calls).""" + ) -> tuple[list, list, MagicMock]: + """Shared setup: return public calls, prelaunch calls, and backend.""" from unittest.mock import MagicMock import autoskillit.cli._hooks as _hooks_mod @@ -336,15 +321,6 @@ def _setup( lambda **kwargs: codex_calls.append("ensure_codex") or True, ) - @contextmanager - def record_transaction(**kwargs): - transaction_calls.append(kwargs) - yield kwargs["source_codex_home"] / "config.toml" - - monkeypatch.setattr( - "autoskillit.cli._init_helpers.codex_prelaunch_transaction", - record_transaction, - ) monkeypatch.setattr( "autoskillit.cli._init_helpers._is_plugin_installed", lambda **kwargs: False, @@ -360,59 +336,40 @@ def record_transaction(**kwargs): mock_backend.capabilities.mcp_config_capable = backend_name == "codex" mock_backend.capabilities.plugin_install_capable = backend_name != "codex" mock_backend.capabilities.hook_config_format = "toml_nested" + mock_backend.ensure_pre_launch.side_effect = lambda: transaction_calls.append({}) or [] monkeypatch.setattr("autoskillit.execution.get_backend", lambda name: mock_backend) (tmp_path / "pkg").mkdir(exist_ok=True) - return codex_calls, transaction_calls + return codex_calls, transaction_calls, mock_backend - def test_codex_backend_uses_one_composed_transaction_with_immutable_source_home( + def test_codex_backend_uses_backend_owned_prelaunch( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: from autoskillit.cli._init_helpers import _register_all - codex_calls, transaction_calls = self._setup(monkeypatch, tmp_path, "codex") + codex_calls, transaction_calls, _ = self._setup(monkeypatch, tmp_path, "codex") _register_all("user", tmp_path) assert codex_calls == [] - assert transaction_calls == [ - { - "source_codex_home": tmp_path / "immutable-codex-source", - "hook_config_format": "toml_nested", - } - ] + assert transaction_calls == [{}] def test_non_codex_backend_keeps_the_lock_owning_public_mcp_facade( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: from autoskillit.cli._init_helpers import _register_all - codex_calls, transaction_calls = self._setup(monkeypatch, tmp_path, "claude-code") + codex_calls, transaction_calls, _ = self._setup(monkeypatch, tmp_path, "claude-code") _register_all("user", tmp_path) assert transaction_calls == [] assert codex_calls == ["ensure_codex"] - def test_composed_transaction_exception_propagates( + def test_backend_prelaunch_exception_propagates( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: from autoskillit.cli._init_helpers import _register_all - codex_calls, _ = self._setup(monkeypatch, tmp_path, "codex") - - class FailingTransaction: - def __enter__(self) -> None: - raise RuntimeError("config transaction failed") - - def __exit__(self, *exc_info: object) -> None: - return None - - def fail_transaction(**kwargs): - del kwargs - return FailingTransaction() - - monkeypatch.setattr( - "autoskillit.cli._init_helpers.codex_prelaunch_transaction", - fail_transaction, - ) + codex_calls, _, mock_backend = self._setup(monkeypatch, tmp_path, "codex") + mock_backend.ensure_pre_launch.side_effect = RuntimeError("config transaction failed") with pytest.raises(RuntimeError, match="config transaction failed"): _register_all("user", tmp_path) assert codex_calls == [] @@ -453,11 +410,14 @@ def test_codex_path_calls_composed_config_transaction( (tmp_path / "pkg").mkdir(exist_ok=True) with ( - patch("autoskillit.cli._init_helpers.codex_prelaunch_transaction") as mock_transaction, + patch( + "autoskillit.execution.backends.codex.CodexBackend.ensure_pre_launch", + return_value=[], + ) as mock_prelaunch, patch("autoskillit.execution.ensure_codex_mcp_registered") as mock_codex, ): _register_all("user", tmp_path) - mock_transaction.assert_called_once() + mock_prelaunch.assert_called_once_with() mock_codex.assert_not_called() def test_codex_path_skips_sync_hooks_to_settings( @@ -483,7 +443,10 @@ def test_codex_path_skips_sync_hooks_to_settings( (tmp_path / "pkg").mkdir(exist_ok=True) with ( - patch("autoskillit.cli._init_helpers.codex_prelaunch_transaction"), + patch( + "autoskillit.execution.backends.codex.CodexBackend.ensure_pre_launch", + return_value=[], + ), patch.object(_hooks_mod, "sync_hooks_to_settings") as mock_sync, ): _register_all("user", tmp_path) @@ -582,26 +545,20 @@ def _setup( codex_mock = MagicMock(return_value=True) monkeypatch.setattr("autoskillit.execution.ensure_codex_mcp_registered", codex_mock) - transaction_mock = MagicMock() - monkeypatch.setattr( - "autoskillit.cli._init_helpers.codex_prelaunch_transaction", - transaction_mock, - ) + prelaunch_mock = mock_backend.ensure_pre_launch + prelaunch_mock.return_value = [] (tmp_path / "pkg").mkdir(exist_ok=True) - return codex_mock, transaction_mock + return codex_mock, prelaunch_mock - def test_codex_backend_uses_composed_transaction_instead_of_public_facade( + def test_codex_backend_uses_prelaunch_instead_of_public_facade( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: from autoskillit.cli._init_helpers import _register_all - codex_mock, transaction_mock = self._setup(monkeypatch, tmp_path, "codex") + codex_mock, prelaunch_mock = self._setup(monkeypatch, tmp_path, "codex") _register_all("user", tmp_path) - transaction_mock.assert_called_once_with( - source_codex_home=tmp_path / "codex-source", - hook_config_format="toml_nested", - ) + prelaunch_mock.assert_called_once_with() codex_mock.assert_not_called() def test_non_codex_backend_calls_ensure_codex_mcp_registered( @@ -609,10 +566,10 @@ def test_non_codex_backend_calls_ensure_codex_mcp_registered( ) -> None: from autoskillit.cli._init_helpers import _register_all - codex_mock, transaction_mock = self._setup(monkeypatch, tmp_path, "claude-code") + codex_mock, prelaunch_mock = self._setup(monkeypatch, tmp_path, "claude-code") _register_all("user", tmp_path) codex_mock.assert_called_once() - transaction_mock.assert_not_called() + prelaunch_mock.assert_not_called() class TestRegisterAllDualRegistration: From 49cdc7c99353bd6a76e04cdf165a52d09b7b773a Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 12:32:21 -0700 Subject: [PATCH 06/35] fix(review): delegate server MCP setup to backend --- src/autoskillit/server/_lifespan.py | 46 ++++++++------------------- tests/server/test_lifespan.py | 48 ++++++----------------------- 2 files changed, 22 insertions(+), 72 deletions(-) diff --git a/src/autoskillit/server/_lifespan.py b/src/autoskillit/server/_lifespan.py index afa8bde3fb..68a67198a5 100644 --- a/src/autoskillit/server/_lifespan.py +++ b/src/autoskillit/server/_lifespan.py @@ -21,7 +21,7 @@ from collections.abc import Awaitable, Callable from contextlib import asynccontextmanager from pathlib import Path -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any import autoskillit.core.paths as _core_paths from autoskillit.core import ( @@ -44,14 +44,7 @@ from autoskillit.core import ( session_type as _resolve_session_type, ) -from autoskillit.execution import ( - BACKEND_REGISTRY, - RecordingSubprocessRunner, - codex_prelaunch_transaction, -) -from autoskillit.execution import ( - ensure_codex_mcp_registered as ensure_codex_mcp_registered, -) +from autoskillit.execution import BACKEND_REGISTRY, RecordingSubprocessRunner from autoskillit.fleet import ( discover_campaign_state_files, reap_stale_dispatches_async, @@ -72,7 +65,7 @@ from autoskillit.workspace import verify_install_state if TYPE_CHECKING: - from autoskillit.execution.backends.codex import CodexBackend + from autoskillit.core import CodingAgentBackend logger = get_logger(__name__) @@ -573,25 +566,19 @@ async def _skill_auto_gate_boot(ctx: Any) -> None: } -async def _run_codex_mcp_registration_async( - source_codex_home: Path, - *, - hook_config_format: str, -) -> None: - """Offload the composed Codex config transaction to an executor — fail-open.""" +async def _run_backend_mcp_registration_async(backend: CodingAgentBackend) -> None: + """Offload backend-owned MCP configuration to an executor — fail-open.""" - def _run_transaction() -> None: - with codex_prelaunch_transaction( - source_codex_home=source_codex_home, - hook_config_format=hook_config_format, - ): - pass + def _run_prelaunch() -> None: + errors = backend.ensure_pre_launch() + if errors: + raise RuntimeError("; ".join(errors)) try: loop = _asyncio.get_running_loop() - await loop.run_in_executor(None, _run_transaction) + await loop.run_in_executor(None, _run_prelaunch) except Exception: - logger.warning("codex_mcp_registration_failed", exc_info=True) + logger.warning("backend_mcp_registration_failed", exc_info=True) @asynccontextmanager @@ -642,17 +629,10 @@ async def _autoskillit_lifespan(server: Any) -> Any: and _boot_ctx.backend is not None and _boot_ctx.backend.capabilities.mcp_config_capable ): - codex_backend = cast("CodexBackend", _boot_ctx.backend) - source_codex_home = codex_backend.source_codex_home - if source_codex_home is None: - raise RuntimeError("Codex backend has no immutable source home") bg_tasks.append( create_background_task( - _run_codex_mcp_registration_async( - source_codex_home, - hook_config_format=codex_backend.capabilities.hook_config_format, - ), - label="codex_mcp_registration", + _run_backend_mcp_registration_async(_boot_ctx.backend), + label="backend_mcp_registration", ) ) diff --git a/tests/server/test_lifespan.py b/tests/server/test_lifespan.py index c7fb6aa066..9b0b5ba93b 100644 --- a/tests/server/test_lifespan.py +++ b/tests/server/test_lifespan.py @@ -1,7 +1,6 @@ """Tests that the FastMCP lifespan calls recorder.finalize() on server shutdown.""" import asyncio -from contextlib import contextmanager from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch @@ -205,14 +204,12 @@ def test_lifespan_boot_registry_covers_all_session_types() -> None: @pytest.mark.asyncio -async def test_lifespan_launches_codex_registration_for_codex_backend(tmp_path: Path): +async def test_lifespan_launches_backend_owned_registration_for_capable_backend(): from autoskillit.server import _autoskillit_lifespan mock_ctx = MagicMock() mock_ctx.backend.name = "codex" - mock_ctx.backend.source_codex_home = tmp_path / "immutable-codex-source" mock_ctx.backend.capabilities.mcp_config_capable = True - mock_ctx.backend.capabilities.hook_config_format = "toml_nested" mock_ctx.runner = MagicMock() reg_mock = AsyncMock() @@ -220,53 +217,26 @@ async def test_lifespan_launches_codex_registration_for_codex_backend(tmp_path: with ( patch("autoskillit.server._lifespan._get_ctx_or_none", return_value=mock_ctx), patch( - "autoskillit.server._lifespan._run_codex_mcp_registration_async", + "autoskillit.server._lifespan._run_backend_mcp_registration_async", reg_mock, ), ): async with _autoskillit_lifespan(MagicMock()): pass - reg_mock.assert_called_once_with( - tmp_path / "immutable-codex-source", - hook_config_format="toml_nested", - ) + reg_mock.assert_called_once_with(mock_ctx.backend) @pytest.mark.asyncio -async def test_codex_registration_uses_one_composed_transaction_without_reacquisition( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_backend_registration_dispatches_through_prelaunch() -> None: import autoskillit.server._lifespan as lifespan - source_home = tmp_path / "immutable-codex-source" - ambient_home = tmp_path / "ambient-home" - calls: list[dict[str, object]] = [] - - @contextmanager - def record_transaction(**kwargs): - calls.append(kwargs) - yield source_home / "config.toml" + backend = MagicMock() + backend.ensure_pre_launch.return_value = [] - monkeypatch.setattr(lifespan, "codex_prelaunch_transaction", record_transaction) - monkeypatch.setattr( - lifespan, - "ensure_codex_mcp_registered", - lambda **kwargs: pytest.fail(f"nested public facade call: {kwargs}"), - ) - monkeypatch.setattr(Path, "home", staticmethod(lambda: ambient_home)) + await lifespan._run_backend_mcp_registration_async(backend) - await lifespan._run_codex_mcp_registration_async( - source_home, - hook_config_format="toml_nested", - ) - - assert calls == [ - { - "source_codex_home": source_home, - "hook_config_format": "toml_nested", - } - ] + backend.ensure_pre_launch.assert_called_once_with() @pytest.mark.asyncio @@ -283,7 +253,7 @@ async def test_lifespan_skips_codex_registration_for_non_codex_backend(): with ( patch("autoskillit.server._lifespan._get_ctx_or_none", return_value=mock_ctx), patch( - "autoskillit.server._lifespan._run_codex_mcp_registration_async", + "autoskillit.server._lifespan._run_backend_mcp_registration_async", reg_mock, ), ): From 3e237118983b26cb9844d6ba93899344a8d5058d Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 12:35:59 -0700 Subject: [PATCH 07/35] fix(review): isolate Codex cook startup behavior --- src/autoskillit/core/types/_type_backend.py | 2 ++ tests/core/test_backend_capabilities.py | 2 ++ tests/execution/backends/test_codex_backend.py | 1 + .../backends/test_coding_agent_backend_conformance.py | 4 +++- 4 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/autoskillit/core/types/_type_backend.py b/src/autoskillit/core/types/_type_backend.py index ffea3d312e..bcccef58fe 100644 --- a/src/autoskillit/core/types/_type_backend.py +++ b/src/autoskillit/core/types/_type_backend.py @@ -160,6 +160,8 @@ class BackendCapabilities: # This is necessary when subagents inherit the session directory path as an # environment variable and may access it after the parent process exits. session_dir_persistent: bool = False + # True when interactive cook launches support the guarded startup observer. + cook_startup_observer_capable: bool = False # True when backend honors the disable-model-invocation SKILL.md frontmatter # key. When False, tier-2 skills are structurally omitted from the session # directory rather than written with gating frontmatter that the backend diff --git a/tests/core/test_backend_capabilities.py b/tests/core/test_backend_capabilities.py index a89e3e0c47..09dfe04fc2 100644 --- a/tests/core/test_backend_capabilities.py +++ b/tests/core/test_backend_capabilities.py @@ -103,6 +103,7 @@ def test_backend_capabilities_field_count(): "supports_context_window_suffix", "git_metadata_writable", "session_dir_persistent", + "cook_startup_observer_capable", "supports_model_invocation_gating", "github_api_callable", "protected_recipe_delivery_capable", @@ -178,6 +179,7 @@ def test_backend_capabilities_field_names_locked(): "skill_sigil", "process_name_aliases", "session_dir_persistent", + "cook_startup_observer_capable", "supports_model_invocation_gating", "github_api_callable", "unnegotiated_tool_result_token_limit", diff --git a/tests/execution/backends/test_codex_backend.py b/tests/execution/backends/test_codex_backend.py index 207fa75a57..c95a512c66 100644 --- a/tests/execution/backends/test_codex_backend.py +++ b/tests/execution/backends/test_codex_backend.py @@ -595,6 +595,7 @@ def test_codex_capabilities_session_dir_persistent(self) -> None: """CodexBackend declares session_dir_persistent=True for persistent roots.""" caps = CodexBackend().capabilities assert caps.session_dir_persistent is True + assert caps.cook_startup_observer_capable is True def test_codex_home_not_set_by_default(self) -> None: spec = CodexBackend().build_skill_session_cmd(**self.BASE) diff --git a/tests/execution/backends/test_coding_agent_backend_conformance.py b/tests/execution/backends/test_coding_agent_backend_conformance.py index 335391a257..2ba68f3b97 100644 --- a/tests/execution/backends/test_coding_agent_backend_conformance.py +++ b/tests/execution/backends/test_coding_agent_backend_conformance.py @@ -70,6 +70,7 @@ "replay_capable": "OPTIONAL", "required_session_files": "OPTIONAL", "required_skill_fields": "REQUIRED", + "cook_startup_observer_capable": "OPTIONAL", "session_dir_persistent": "OPTIONAL", "session_dir_symlinks": "OPTIONAL", "session_record_types": "REQUIRED", @@ -119,7 +120,8 @@ def test_capabilities_returns_backend_capabilities(self) -> None: Fields cited: applicable_guards, default_skill_sandbox_mode, unnegotiated_tool_result_token_limit, git_metadata_writable, has_unguarded_filesystem_access, process_name_aliases, - record_capable, replay_capable, session_dir_persistent, + record_capable, replay_capable, + cook_startup_observer_capable, session_dir_persistent, supports_context_window_suffix, supports_tool_list_changed, triage_capable, write_detection_strategy. """ From 8a33b6547f7fcf437bfaa6dcae744092a2c826f2 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 12:36:43 -0700 Subject: [PATCH 08/35] test(review): exercise enabled cook observer wiring --- tests/cli/test_cook_startup_observability.py | 45 ++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/cli/test_cook_startup_observability.py b/tests/cli/test_cook_startup_observability.py index 06209f95dc..feebbabd3c 100644 --- a/tests/cli/test_cook_startup_observability.py +++ b/tests/cli/test_cook_startup_observability.py @@ -381,6 +381,51 @@ def _make_state_db(sqlite_home: Path, status: str = "complete") -> Path: return path +def test_enabled_cook_observer_calls_backend_version_and_passes_readiness( + tmp_path: Path, +) -> None: + from autoskillit.cli.session._session_cook import ( + _require_observer_ready, + _startup_observer, + ) + + stages: list[tuple[str, dict[str, object]]] = [] + + class Backend: + def __init__(self) -> None: + self.version_calls = 0 + + def version(self) -> str: + self.version_calls += 1 + return "codex-cli 0.145.0" + + class Trace: + def record_stage(self, stage: str, **fields: object) -> None: + stages.append((stage, fields)) + + sqlite_home = tmp_path / "sqlite-home" + _make_state_db(sqlite_home) + backend = Backend() + observer = _startup_observer( + backend=backend, # type: ignore[arg-type] + trace=Trace(), # type: ignore[arg-type] + enabled=True, + sqlite_home=sqlite_home, + attempt=2, + view_id="view-2", + ) + + assert observer is not None + assert backend.version_calls == 1 + observer.observe_output(b"Codex started") + observer.check_readiness() + _require_observer_ready(observer) + assert stages == [ + ("first_output", {"attempt": 2, "view_id": "view-2"}), + ("state_ready", {"attempt": 2, "view_id": "view-2"}), + ] + + def _probe(sqlite_home: Path): CodexStateReadinessProbe, _, _ = _observer_api() return CodexStateReadinessProbe( From 775d36d86d0efb67889f64ccc1258642913e1762 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 12:38:42 -0700 Subject: [PATCH 09/35] fix(review): latch readiness and restore PTY state --- src/autoskillit/cli/session/pty/_observer.py | 21 ++- tests/cli/test_cook_startup_observability.py | 128 +++++++++++++++++++ 2 files changed, 145 insertions(+), 4 deletions(-) diff --git a/src/autoskillit/cli/session/pty/_observer.py b/src/autoskillit/cli/session/pty/_observer.py index c06b5449dc..8a2e5ab135 100644 --- a/src/autoskillit/cli/session/pty/_observer.py +++ b/src/autoskillit/cli/session/pty/_observer.py @@ -225,6 +225,8 @@ def check_readiness(self) -> ObserverStatus | None: """Observe readiness once and emit each changed status at most once.""" if self.readiness_probe is None: return None + if self.readiness_status is ObserverStatus.READY: + return ObserverStatus.READY status = self.readiness_probe.check() if status is not self.readiness_status: self.readiness_status = status @@ -241,6 +243,8 @@ def wait_for_readiness( """Delegate a bounded readiness wait and publish its terminal status.""" if self.readiness_probe is None: return None + if self.readiness_status is ObserverStatus.READY: + return ObserverStatus.READY status = self.readiness_probe.wait( timeout_seconds=timeout_seconds, cancelled=cancelled, @@ -267,6 +271,7 @@ def relay( selector = selector_factory() previous_winch: Callable[[int, FrameType | None], Any] | int | None = None installed_winch = False + terminal_attributes: list[Any] | None = None def copy_window_size() -> None: if not os.isatty(stdin_fd): @@ -282,6 +287,7 @@ def handle_winch(_signum: int, _frame: object) -> None: try: if os.isatty(stdin_fd): + terminal_attributes = termios.tcgetattr(stdin_fd) tty.setraw(stdin_fd) copy_window_size() try: @@ -319,10 +325,17 @@ def handle_winch(_signum: int, _frame: object) -> None: else: self._write_all(master_fd, chunk) finally: - if installed_winch and previous_winch is not None: - signal.signal(signal.SIGWINCH, previous_winch) # noqa: TID251 - selector.close() - self.close_master(master_fd) + try: + self.check_readiness() + finally: + if installed_winch and previous_winch is not None: + with contextlib.suppress(ValueError): + signal.signal(signal.SIGWINCH, previous_winch) # noqa: TID251 + if terminal_attributes is not None: + with contextlib.suppress(OSError): + termios.tcsetattr(stdin_fd, termios.TCSADRAIN, terminal_attributes) + selector.close() + self.close_master(master_fd) def close_master(self, master_fd: int) -> None: """Close a PTY master descriptor exactly once.""" diff --git a/tests/cli/test_cook_startup_observability.py b/tests/cli/test_cook_startup_observability.py index feebbabd3c..cc04457b43 100644 --- a/tests/cli/test_cook_startup_observability.py +++ b/tests/cli/test_cook_startup_observability.py @@ -370,6 +370,134 @@ def test_observer_matching_window_and_retained_output_are_hard_capped() -> None: assert len(observer.normalized_window.encode("utf-8")) <= _WINDOW_LIMIT +def test_observer_latches_ready_status() -> None: + _, ObserverStatus, PtyObserver = _observer_api() + + class Probe: + def __init__(self) -> None: + self.calls = 0 + + def check(self): + self.calls += 1 + return ObserverStatus.READY if self.calls == 1 else ObserverStatus.INCOMPLETE + + probe = Probe() + observer = PtyObserver(readiness_probe=probe) # type: ignore[arg-type] + + assert observer.check_readiness() is ObserverStatus.READY + assert observer.check_readiness() is ObserverStatus.READY + assert probe.calls == 1 + + +def test_relay_copies_both_directions_and_restores_terminal_state( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from autoskillit.cli.session.pty import _observer as observer_module + + _, ObserverStatus, PtyObserver = _observer_api() + master_fd = 10 + stdin_fd = 11 + stdout_fd = 12 + terminal_attributes = [1, 2, 3] + writes: list[tuple[int, bytes]] = [] + signal_calls: list[tuple[int, object]] = [] + terminal_calls: list[tuple[int, int, list[int]]] = [] + closed_fds: list[int] = [] + selector_closed: list[bool] = [] + read_counts = {master_fd: 0, stdin_fd: 0} + + class Probe: + def __init__(self) -> None: + self.statuses = iter( + ( + ObserverStatus.ABSENT, + ObserverStatus.INCOMPLETE, + ObserverStatus.SCHEMA_CHANGED, + ObserverStatus.READY, + ) + ) + + def check(self): + return next(self.statuses) + + class Key: + def __init__(self, fd: int, data: str) -> None: + self.fd = fd + self.data = data + + class Selector: + def __init__(self) -> None: + self.events = iter( + ( + [(Key(stdin_fd, "stdin"), 0)], + [(Key(master_fd, "master"), 0)], + [(Key(master_fd, "master"), 0)], + ) + ) + + def register(self, _fd: int, _events: int, _data: str) -> None: + return None + + def unregister(self, _fd: int) -> None: + return None + + def select(self, _timeout: float): + return next(self.events) + + def close(self) -> None: + selector_closed.append(True) + + def fake_read(fd: int, _size: int) -> bytes: + read_counts[fd] += 1 + if fd == stdin_fd: + return b"to-child" + return b"to-user" if read_counts[fd] == 1 else b"" + + def fake_write(fd: int, payload: bytes | memoryview) -> int: + data = bytes(payload) + writes.append((fd, data)) + return len(data) + + previous_handler = object() + monkeypatch.setattr(observer_module.selectors, "DefaultSelector", Selector) + monkeypatch.setattr(observer_module.os, "isatty", lambda _fd: True) + monkeypatch.setattr(observer_module.os, "read", fake_read) + monkeypatch.setattr(observer_module.os, "write", fake_write) + monkeypatch.setattr(observer_module.os, "close", closed_fds.append) + monkeypatch.setattr(observer_module.fcntl, "ioctl", lambda *_args: b"\0" * 8) + monkeypatch.setattr( + observer_module.termios, + "tcgetattr", + lambda _fd: terminal_attributes, + ) + monkeypatch.setattr( + observer_module.termios, + "tcsetattr", + lambda fd, when, attrs: terminal_calls.append((fd, when, attrs)), + ) + monkeypatch.setattr(observer_module.tty, "setraw", lambda _fd: None) + monkeypatch.setattr( + observer_module.signal, + "getsignal", + lambda _signal: previous_handler, + ) + monkeypatch.setattr( + observer_module.signal, + "signal", + lambda signum, handler: signal_calls.append((signum, handler)), + ) + + observer = PtyObserver(readiness_probe=Probe()) # type: ignore[arg-type] + observer.relay(master_fd, stdin_fd=stdin_fd, stdout_fd=stdout_fd) + + assert writes == [(master_fd, b"to-child"), (stdout_fd, b"to-user")] + assert observer.readiness_status is ObserverStatus.READY + assert terminal_calls == [(stdin_fd, observer_module.termios.TCSADRAIN, terminal_attributes)] + assert signal_calls[-1] == (observer_module.signal.SIGWINCH, previous_handler) + assert selector_closed == [True] + assert closed_fds == [master_fd] + + def _make_state_db(sqlite_home: Path, status: str = "complete") -> Path: sqlite_home.mkdir(parents=True, exist_ok=True) path = sqlite_home / "state_5.sqlite" From 78f9b73bf8b2576a72b2eb26efe9d2aea739d967 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 12:39:46 -0700 Subject: [PATCH 10/35] fix(review): classify durable storage on macOS --- .../backends/_codex_session_storage.py | 19 ++++++++++++ .../backends/test_codex_session_storage.py | 29 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/src/autoskillit/execution/backends/_codex_session_storage.py b/src/autoskillit/execution/backends/_codex_session_storage.py index 93a226108a..52be89baf6 100644 --- a/src/autoskillit/execution/backends/_codex_session_storage.py +++ b/src/autoskillit/execution/backends/_codex_session_storage.py @@ -6,9 +6,11 @@ import hashlib import json import os +import plistlib import shutil import socket import stat +import subprocess import sys import time from collections.abc import Mapping, Sequence @@ -185,6 +187,23 @@ def _decode_mount_path(value: str) -> str: def _filesystem_type(path: Path) -> str: + if sys.platform == "darwin": + try: + result = subprocess.run( + ("/usr/sbin/diskutil", "info", "-plist", str(path.resolve(strict=True))), + capture_output=True, + check=False, + timeout=5, + ) + if result.returncode != 0: + raise RuntimeError("Unable to classify the Codex storage filesystem with diskutil") + payload = plistlib.loads(result.stdout) + filesystem_type = payload.get("FilesystemType") + if not isinstance(filesystem_type, str) or not filesystem_type: + raise RuntimeError("diskutil did not report a filesystem type") + return filesystem_type.lower() + except (OSError, plistlib.InvalidFileException) as exc: + raise RuntimeError("Unable to classify the Codex storage filesystem") from exc if not sys.platform.startswith("linux"): raise RuntimeError(f"Codex durable views cannot classify filesystems on {sys.platform}") try: diff --git a/tests/execution/backends/test_codex_session_storage.py b/tests/execution/backends/test_codex_session_storage.py index c2c261399c..13610306a9 100644 --- a/tests/execution/backends/test_codex_session_storage.py +++ b/tests/execution/backends/test_codex_session_storage.py @@ -5,6 +5,7 @@ import json import multiprocessing import os +import plistlib import threading from pathlib import Path @@ -468,6 +469,34 @@ def test_unsupported_filesystem_fails_before_view_mutation( assert not (log_dir / "codex-active-sessions" / "0123456789abcdef-1").exists() +def test_darwin_filesystem_classification_uses_diskutil( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[tuple[tuple[str, ...], dict[str, object]]] = [] + result = storage.subprocess.CompletedProcess( + args=(), + returncode=0, + stdout=plistlib.dumps({"FilesystemType": "apfs"}), + stderr=b"", + ) + + def run(command: tuple[str, ...], **kwargs: object): + calls.append((command, kwargs)) + return result + + monkeypatch.setattr(storage.sys, "platform", "darwin") + monkeypatch.setattr(storage.subprocess, "run", run) + + assert storage._filesystem_type(tmp_path) == "apfs" + assert calls == [ + ( + ("/usr/sbin/diskutil", "info", "-plist", str(tmp_path.resolve())), + {"capture_output": True, "check": False, "timeout": 5}, + ) + ] + + def test_cross_device_layout_fails_before_view_mutation( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, From a20301863af9c6c66f7289bcb201ebff269f724c Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 12:46:31 -0700 Subject: [PATCH 11/35] fix(review): delegate persistent session layout --- src/autoskillit/cli/fleet/__init__.py | 2 +- src/autoskillit/core/types/_type_backend.py | 3 +++ src/autoskillit/server/_factory.py | 10 +++++++--- src/autoskillit/workspace/__init__.py | 2 ++ tests/core/test_backend_dataclasses.py | 2 ++ tests/execution/backends/test_codex_backend.py | 3 +++ tests/workspace/conftest.py | 2 +- 7 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/autoskillit/cli/fleet/__init__.py b/src/autoskillit/cli/fleet/__init__.py index 576a68133d..ab1e370614 100644 --- a/src/autoskillit/cli/fleet/__init__.py +++ b/src/autoskillit/cli/fleet/__init__.py @@ -333,7 +333,7 @@ def fleet_status( skill_mgr = DefaultSessionSkillManager( provider=SkillsDirectoryProvider(), ephemeral_root=resolve_ephemeral_root(), - codex_root=resolve_temp_dir(_project_root) / CODEX_SESSIONS_SUBDIR, + persistent_root=resolve_temp_dir(_project_root) / CODEX_SESSIONS_SUBDIR, ) for d in state.dispatches: if d.dispatched_session_id: diff --git a/src/autoskillit/core/types/_type_backend.py b/src/autoskillit/core/types/_type_backend.py index bcccef58fe..94520acb31 100644 --- a/src/autoskillit/core/types/_type_backend.py +++ b/src/autoskillit/core/types/_type_backend.py @@ -54,6 +54,8 @@ class BackendConventions: skills_subdir: Path = Path("skills") #: Project-relative directories to scan for project-local skills. project_local_skill_search_dirs: tuple[str, ...] = () + #: Persistent generated-home root below the configured project temp directory. + persistent_session_root_subdir: Path | None = None #: Native model-facing skill invocation sigil. skill_sigil: str = "/" @@ -317,6 +319,7 @@ def model_class(model: str) -> str: github_api_callable=True, skill_sigil="/", session_dir_persistent=False, + cook_startup_observer_capable=False, supports_model_invocation_gating=True, unnegotiated_tool_result_token_limit=46_500, protected_recipe_delivery_capable=False, diff --git a/src/autoskillit/server/_factory.py b/src/autoskillit/server/_factory.py index cb8633680c..6bef282def 100644 --- a/src/autoskillit/server/_factory.py +++ b/src/autoskillit/server/_factory.py @@ -17,7 +17,6 @@ from autoskillit.config import AutomationConfig from autoskillit.core import ( - CODEX_SESSIONS_SUBDIR, MARKETPLACE_PREFIX, DirectInstall, FleetLock, @@ -76,6 +75,7 @@ project_default_plugin_source, project_direct_install, resolve_ephemeral_root, + resolve_persistent_session_root, validate_skill_tier_roles, ) @@ -298,8 +298,12 @@ def make_context( skill_catalog=session_catalog, ) ephemeral_root = resolve_ephemeral_root() - codex_root = temp_dir / CODEX_SESSIONS_SUBDIR - session_mgr = DefaultSessionSkillManager(provider, ephemeral_root, codex_root=codex_root) + persistent_root = resolve_persistent_session_root(temp_dir, backend) + session_mgr = DefaultSessionSkillManager( + provider, + ephemeral_root, + persistent_root=persistent_root, + ) audit = DefaultAuditLog() github_api_log = DefaultGitHubApiLog() diff --git a/src/autoskillit/workspace/__init__.py b/src/autoskillit/workspace/__init__.py index 3eff4ccc48..873b995d93 100644 --- a/src/autoskillit/workspace/__init__.py +++ b/src/autoskillit/workspace/__init__.py @@ -57,6 +57,7 @@ materialize_codex_profile_skills, resolve_closure_write_dirs, resolve_ephemeral_root, + resolve_persistent_session_root, ) from autoskillit.workspace.skill_capabilities import ( SkillCapabilityEvidence, @@ -173,6 +174,7 @@ "push_to_remote", "remove_clone", "resolve_ephemeral_root", + "resolve_persistent_session_root", "WORKTREES_DIR", "parse_frontmatter_content", "prepare_catalog_skill_dispatch", diff --git a/tests/core/test_backend_dataclasses.py b/tests/core/test_backend_dataclasses.py index 9b7e33ca18..20408dea50 100644 --- a/tests/core/test_backend_dataclasses.py +++ b/tests/core/test_backend_dataclasses.py @@ -318,6 +318,7 @@ def test_backend_conventions_frozen_slots_fields(): ) assert inst.skills_subdir == Path("/claude/skills") assert inst.project_local_skill_search_dirs == (".claude/skills",) + assert inst.persistent_session_root_subdir is None with pytest.raises(FrozenInstanceError): inst.skills_subdir = Path("/other") # type: ignore[misc] @@ -328,6 +329,7 @@ def test_backend_conventions_frozen_slots_fields(): hints = typing.get_type_hints(BackendConventions) assert hints["skills_subdir"] is Path assert hints["project_local_skill_search_dirs"] == tuple[str, ...] + assert hints["persistent_session_root_subdir"] == Path | None def test_no_autoskillit_imports_in_backend(): diff --git a/tests/execution/backends/test_codex_backend.py b/tests/execution/backends/test_codex_backend.py index c95a512c66..8a23e714af 100644 --- a/tests/execution/backends/test_codex_backend.py +++ b/tests/execution/backends/test_codex_backend.py @@ -1382,6 +1382,9 @@ def test_agents_skills_in_project_local_dirs(self) -> None: def test_conventions_skills_subdir(self) -> None: assert CodexBackend().conventions.skills_subdir == Path("skills") + def test_conventions_persistent_session_root(self) -> None: + assert CodexBackend().conventions.persistent_session_root_subdir == Path("codex-sessions") + class TestClaudeCodeBackendProcessIdleDefault: def test_claude_code_backend_process_idle_default_zero(self) -> None: diff --git a/tests/workspace/conftest.py b/tests/workspace/conftest.py index c58cbcf4d9..401df32fa3 100644 --- a/tests/workspace/conftest.py +++ b/tests/workspace/conftest.py @@ -113,7 +113,7 @@ def _factory( return DefaultSessionSkillManager( provider, ephemeral_root=ephemeral_root or tmp_path, - codex_root=codex_root or tmp_path / "codex-root", + persistent_root=codex_root or tmp_path / "codex-root", ) return _factory From 0aa25c95d5fac83e4f79f55933d4296a71f6b385 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 12:47:18 -0700 Subject: [PATCH 12/35] fix(review): stage startup canary history profiles --- .../integration/test_codex_startup_canary.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_codex_startup_canary.py b/tests/integration/test_codex_startup_canary.py index fed68dfe72..08e2c9efe4 100644 --- a/tests/integration/test_codex_startup_canary.py +++ b/tests/integration/test_codex_startup_canary.py @@ -276,6 +276,16 @@ def _write_history_profile( } +def _stage_history_profile(source_root: Path, generated_home: Path) -> list[Path]: + staged: list[Path] = [] + for source in _rollouts_from_root(source_root): + destination = generated_home / "sessions" / source.relative_to(source_root) + destination.parent.mkdir(parents=True, exist_ok=True) + os.link(source, destination) + staged.append(destination) + return staged + + def test_installed_codex_preserves_staged_rollout_inode_and_inherited_lease( tmp_path: Path, ) -> None: @@ -416,7 +426,12 @@ def measure(profile_name: str, *, retain: bool) -> dict[str, object]: generated_home = tmp_path / "homes" / f"{sequence:02d}-{profile_name}" generated_home.parent.mkdir(exist_ok=True) _prepare_home(generated_home) - assert _rollouts(generated_home) == [] + staged_rollouts = _stage_history_profile( + tmp_path / "history" / profile_name, + generated_home, + ) + assert len(staged_rollouts) == profile_metadata[profile_name]["file_count"] + assert _rollouts(generated_home) == staged_rollouts spec = backend.build_headless_cmd( "Respond with exactly: autoskillit startup profile canary" ) @@ -433,7 +448,7 @@ def measure(profile_name: str, *, retain: bool) -> dict[str, object]: rollout_root=generated_home / "sessions", ) assert duration <= 17.0 - assert len(_rollouts(generated_home)) == 1 + assert len(_rollouts(generated_home)) == len(staged_rollouts) + 1 sample = { "sequence": sequence, "profile": profile_name, From 195b10fc4ef385534952572e33e64a4de5172f4b Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 13:09:35 -0700 Subject: [PATCH 13/35] test(review): align backend regression guards --- .../server/test_explicit_backend_override.py | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/tests/server/test_explicit_backend_override.py b/tests/server/test_explicit_backend_override.py index a52f7168fb..a0062239e6 100644 --- a/tests/server/test_explicit_backend_override.py +++ b/tests/server/test_explicit_backend_override.py @@ -129,11 +129,33 @@ async def test_explicit_override_beats_provider_routing( executor = InMemoryHeadlessExecutor() tool_ctx_kitchen_open.executor = executor - # Non-Claude backend (anthropic_provider_capable=False triggers _provider_override) + # Non-Claude backend (anthropic_provider_capable=False triggers _provider_override). + # Keep the test backend and generated-home manager aligned: Codex session + # projection is persistent and therefore requires a persistent root. + from autoskillit.execution.backends.codex import CodexBackend + from autoskillit.workspace import ( + DefaultSessionSkillManager, + SkillsDirectoryProvider, + ) + + concrete_backend = CodexBackend() fake_backend = MagicMock(spec=CodingAgentBackend) fake_backend.name = "codex" - fake_backend.capabilities.anthropic_provider_capable = False + fake_backend.capabilities = concrete_backend.capabilities + fake_backend.conventions = concrete_backend.conventions + fake_backend.ensure_pre_launch.return_value = [] + fake_backend.validate_session_layout.return_value = [] + fake_backend.session_locator.return_value.project_log_dir.return_value = None tool_ctx_kitchen_open.backend = fake_backend + tool_ctx_kitchen_open.session_skill_manager = DefaultSessionSkillManager( + SkillsDirectoryProvider(), + ephemeral_root=tmp_path / "ephemeral-sessions", + persistent_root=tmp_path / "persistent-sessions", + ) + monkeypatch.setattr( + "autoskillit.server.tools.tools_execution._get_backend", + lambda _name: fake_backend, + ) # Explicit backend override: pin this step to codex tool_ctx_kitchen_open.config.agent_backend = AgentBackendConfig( @@ -194,7 +216,7 @@ async def spy_run(*args, **kwargs): monkeypatch.setattr(executor, "run", spy_run) - result = json.loads( + response = json.loads( await run_skill( "/autoskillit:investigate", str(tmp_path), @@ -205,7 +227,8 @@ async def spy_run(*args, **kwargs): # NOT "claude-code" from the provider routing assert captured.get("backend_override") == "codex", ( f"Expected explicit override 'codex' to beat provider routing, " - f"got backend_override={captured.get('backend_override')!r}: {result}" + f"got backend_override={captured.get('backend_override')!r}; " + f"response={response!r}" ) From 049cd5f3c9cdc74a6c61c25ceb685ae925ee6b32 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 14:42:25 -0700 Subject: [PATCH 14/35] fix(review): delegate session registry correlation --- .../cli/session/_session_picker.py | 15 +++--------- src/autoskillit/execution/backends/claude.py | 12 +++++++++- tests/arch/test_no_backend_name_bypass.py | 2 -- tests/cli/test_session_picker.py | 24 ++++++++++++++----- .../backends/test_claude_session_locator.py | 11 +++++++-- 5 files changed, 41 insertions(+), 23 deletions(-) diff --git a/src/autoskillit/cli/session/_session_picker.py b/src/autoskillit/cli/session/_session_picker.py index 4f5a88e1c7..b41ccf98d4 100644 --- a/src/autoskillit/cli/session/_session_picker.py +++ b/src/autoskillit/cli/session/_session_picker.py @@ -5,11 +5,7 @@ from collections.abc import Mapping, Sequence from pathlib import Path -from autoskillit.core import ( - AGENT_BACKEND_CLAUDE_CODE, - SessionLocator, - SessionSummary, -) +from autoskillit.core import SessionLocator, SessionSummary _ORDER_GREETING_PREFIXES = ( "Today's special:", @@ -54,14 +50,9 @@ def _registry_entry( summary: SessionSummary, registry: _Registry, ) -> Mapping[str, object] | None: - if summary.launch_id is not None: - return registry.get(summary.launch_id) - if summary.backend_name != AGENT_BACKEND_CLAUDE_CODE: + if summary.launch_id is None: return None - for entry in registry.values(): - if entry.get("claude_session_id") == summary.session_id: - return entry - return None + return registry.get(summary.launch_id) def _classify_session(summary: SessionSummary, registry: _Registry) -> str: diff --git a/src/autoskillit/execution/backends/claude.py b/src/autoskillit/execution/backends/claude.py index 95981c36ff..c96208b7ec 100644 --- a/src/autoskillit/execution/backends/claude.py +++ b/src/autoskillit/execution/backends/claude.py @@ -57,6 +57,7 @@ fast_loads, load_yaml, pkg_root, + read_registry, ) from autoskillit.execution.backends._backend_cmd_builder_base import ( SHARED_BASELINE_ENV, @@ -145,6 +146,15 @@ def list_sessions(self, cwd: str) -> tuple[SessionSummary, ...]: if not isinstance(entries, list): return () + launch_ids_by_session_id = { + claude_session_id: launch_id + for launch_id, registry_entry in read_registry(Path(normalized_cwd)).items() + if isinstance(registry_entry, Mapping) + and isinstance( + claude_session_id := registry_entry.get("claude_session_id"), + str, + ) + } summaries: list[SessionSummary] = [] for entry in entries: if not isinstance(entry, dict) or entry.get("isSidechain"): @@ -168,7 +178,7 @@ def list_sessions(self, cwd: str) -> tuple[SessionSummary, ...]: SessionSummary( backend_name=AGENT_BACKEND_CLAUDE_CODE, session_id=session_id, - launch_id=None, + launch_id=launch_ids_by_session_id.get(session_id), cwd=resolved_entry_cwd, first_prompt=normalized_prompt, summary=summary if isinstance(summary, str) else "", diff --git a/tests/arch/test_no_backend_name_bypass.py b/tests/arch/test_no_backend_name_bypass.py index ad4493769a..814c345450 100644 --- a/tests/arch/test_no_backend_name_bypass.py +++ b/tests/arch/test_no_backend_name_bypass.py @@ -23,8 +23,6 @@ "execution/headless/_headless_helpers.py", # FeatureDef.requires_backend_alignment is config-layer; no capabilities at scan time "cli/session/_session_launch.py", - # Persisted SessionSummary metadata has no live backend capability object. - "cli/session/_session_picker.py", } ) diff --git a/tests/cli/test_session_picker.py b/tests/cli/test_session_picker.py index 975d6d1b33..9602aff167 100644 --- a/tests/cli/test_session_picker.py +++ b/tests/cli/test_session_picker.py @@ -66,8 +66,12 @@ def test_pick_session_filters_cook( project_dir = tmp_path / "project" project_dir.mkdir() entries = ( - _summary("cook-uuid-1"), - _summary("order-uuid-1", first_prompt="Kitchen's open! Hello"), + _summary("cook-uuid-1", launch_id="lid-cook"), + _summary( + "order-uuid-1", + launch_id="lid-order", + first_prompt="Kitchen's open! Hello", + ), ) write_registry_entry(project_dir, "lid-cook", "cook", None) @@ -89,8 +93,12 @@ def test_pick_session_filters_order(tmp_path: Path, monkeypatch: pytest.MonkeyPa project_dir = tmp_path / "project" project_dir.mkdir() entries = ( - _summary("cook-uuid-1"), - _summary("order-uuid-1", first_prompt="Kitchen's open! Hello"), + _summary("cook-uuid-1", launch_id="lid-cook"), + _summary( + "order-uuid-1", + launch_id="lid-order", + first_prompt="Kitchen's open! Hello", + ), ) write_registry_entry(project_dir, "lid-cook", "cook", None) @@ -190,7 +198,7 @@ def test_codex_hint_classifies_unregistered_session_as_cook() -> None: assert _classify_session(entry, {}) == "cook" -def test_claude_registry_bridge_precedes_greeting_hint(tmp_path: Path) -> None: +def test_launch_id_registry_precedes_greeting_hint(tmp_path: Path) -> None: project_dir = tmp_path / "project" project_dir.mkdir() write_registry_entry(project_dir, "fedcba9876543210", "cook", None) @@ -198,7 +206,11 @@ def test_claude_registry_bridge_precedes_greeting_hint(tmp_path: Path) -> None: from autoskillit.core.runtime.session_registry import bridge_claude_session_id bridge_claude_session_id(project_dir, "fedcba9876543210", "claude-uuid") - entry = _summary("claude-uuid", first_prompt="Kitchen's open!") + entry = _summary( + "claude-uuid", + launch_id="fedcba9876543210", + first_prompt="Kitchen's open!", + ) assert _classify_session(entry, read_registry(project_dir)) == "cook" diff --git a/tests/execution/backends/test_claude_session_locator.py b/tests/execution/backends/test_claude_session_locator.py index 0e3d56146b..10357014aa 100644 --- a/tests/execution/backends/test_claude_session_locator.py +++ b/tests/execution/backends/test_claude_session_locator.py @@ -5,7 +5,12 @@ import pytest -from autoskillit.core import SessionLocator, SessionSummary +from autoskillit.core import ( + SessionLocator, + SessionSummary, + bridge_claude_session_id, + write_registry_entry, +) from autoskillit.execution.backends import ClaudeSessionLocator pytestmark = [pytest.mark.layer("execution"), pytest.mark.medium] @@ -17,6 +22,8 @@ def test_list_sessions_translates_native_index_and_normalizes_cwd( ) -> None: project = tmp_path / "project" project.mkdir() + write_registry_entry(project, "0123456789abcdef", "cook", None) + bridge_claude_session_id(project, "0123456789abcdef", "claude-1") fake_home = tmp_path / "home" index_dir = fake_home / ".claude" / "projects" / "-ignored" index_dir.mkdir(parents=True) @@ -46,7 +53,7 @@ def test_list_sessions_translates_native_index_and_normalizes_cwd( SessionSummary( backend_name="claude-code", session_id="claude-1", - launch_id=None, + launch_id="0123456789abcdef", cwd=str(project.resolve()), first_prompt="What's the issue?", summary="", From a03d01b556626620190817b682a648f2ff183267 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 14:43:00 -0700 Subject: [PATCH 15/35] fix(review): centralize session prompt classification --- src/autoskillit/cli/session/_session_picker.py | 16 +--------------- tests/cli/test_session_picker.py | 12 +++--------- 2 files changed, 4 insertions(+), 24 deletions(-) diff --git a/src/autoskillit/cli/session/_session_picker.py b/src/autoskillit/cli/session/_session_picker.py index b41ccf98d4..2c4a5fa5fa 100644 --- a/src/autoskillit/cli/session/_session_picker.py +++ b/src/autoskillit/cli/session/_session_picker.py @@ -7,16 +7,6 @@ from autoskillit.core import SessionLocator, SessionSummary -_ORDER_GREETING_PREFIXES = ( - "Today's special:", - "Order up! Today's special:", - "Order up! The kitchen", - "Kitchen's open!", - "Table for one!", - "Fresh off the menu", - "Welcome to Good Burger, home of the Good Burger, can I take your order?", -) - _Registry = Mapping[str, Mapping[str, object]] @@ -58,7 +48,7 @@ def _registry_entry( def _classify_session(summary: SessionSummary, registry: _Registry) -> str: """Classify session as 'cook' or 'order'. - Uses registry lookup first, then locator and greeting hints. + Uses registry lookup first, then the backend locator's classification. """ registry_entry = _registry_entry(summary, registry) if registry_entry is not None: @@ -66,10 +56,6 @@ def _classify_session(summary: SessionSummary, registry: _Registry) -> str: if summary.session_type_hint is not None: return summary.session_type_hint - - for prefix in _ORDER_GREETING_PREFIXES: - if summary.first_prompt.startswith(prefix): - return "order" return "cook" diff --git a/tests/cli/test_session_picker.py b/tests/cli/test_session_picker.py index 9602aff167..3a27cf33b9 100644 --- a/tests/cli/test_session_picker.py +++ b/tests/cli/test_session_picker.py @@ -116,24 +116,18 @@ def test_pick_session_filters_order(tmp_path: Path, monkeypatch: pytest.MonkeyPa assert result == "order-uuid-1" -def test_greeting_heuristic_order_session() -> None: - entry = _summary("s1", first_prompt="Order up! Today's special: myrecipe.") +def test_backend_hint_classifies_order_session() -> None: + entry = _summary("s1", session_type_hint="order") result = _classify_session(entry, {}) assert result == "order" -def test_greeting_heuristic_cook_session() -> None: +def test_missing_backend_hint_defaults_to_cook() -> None: entry = _summary("s1") result = _classify_session(entry, {}) assert result == "cook" -def test_greeting_heuristic_open_kitchen_order() -> None: - entry = _summary("s1", first_prompt="Kitchen's open! What are we cooking today?") - result = _classify_session(entry, {}) - assert result == "order" - - def test_sidechain_sessions_excluded(tmp_path: Path) -> None: project_dir = tmp_path / "project" project_dir.mkdir() From c96495a2cc8073a3f257867814f3b10df80ba354 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 14:44:27 -0700 Subject: [PATCH 16/35] fix(review): reset startup budgets per attempt --- .../cli/session/_session_startup_trace.py | 19 ++++++-- tests/cli/test_cook_startup_observability.py | 44 +++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/src/autoskillit/cli/session/_session_startup_trace.py b/src/autoskillit/cli/session/_session_startup_trace.py index de44e4d027..d67f270161 100644 --- a/src/autoskillit/cli/session/_session_startup_trace.py +++ b/src/autoskillit/cli/session/_session_startup_trace.py @@ -138,6 +138,7 @@ def __init__( self._clock = clock if clock is not None else time.monotonic self._lock = threading.RLock() self._launch_at: float | None = None + self._attempt_start_at: float | None = None self._spawn_at: float | None = None self._hook_review_at: float | None = None self._current_attempt: int | None = None @@ -167,9 +168,13 @@ def record_attempt_anchor( self._ensure_open() self._current_attempt = attempt self._current_view_id = view_id + self._spawn_at = None + self._hook_review_at = None if not self.enabled: + self._attempt_start_at = None return recorded_at = self._now() + self._attempt_start_at = self._launch_at if attempt == 1 else recorded_at record = self._record("attempt", recorded_at, diagnostics=diagnostics) record.update(attempt=attempt, view_id=view_id) self._append_record(record) @@ -216,12 +221,18 @@ def require_startup_budgets(self) -> None: if not self.enabled: return durations = { - "confirmation_to_spawn": self._duration(self._launch_at, self._spawn_at), + "confirmation_to_spawn": self._duration( + self._attempt_start_at, + self._spawn_at, + ), "spawn_to_hook_review": self._duration( self._spawn_at, self._hook_review_at, ), - "total_startup": self._duration(self._launch_at, self._hook_review_at), + "total_startup": self._duration( + self._attempt_start_at, + self._hook_review_at, + ), } missing = [name for name, duration in durations.items() if duration is None] exceeded = [ @@ -284,9 +295,9 @@ def _summary_record( *, diagnostics: Mapping[str, object] | None = None, ) -> dict[str, object]: - confirmation_to_spawn = self._duration(self._launch_at, self._spawn_at) + confirmation_to_spawn = self._duration(self._attempt_start_at, self._spawn_at) spawn_to_hook_review = self._duration(self._spawn_at, self._hook_review_at) - total_startup = self._duration(self._launch_at, self._hook_review_at) + total_startup = self._duration(self._attempt_start_at, self._hook_review_at) durations = { "confirmation_to_spawn": confirmation_to_spawn, "spawn_to_hook_review": spawn_to_hook_review, diff --git a/tests/cli/test_cook_startup_observability.py b/tests/cli/test_cook_startup_observability.py index cc04457b43..e501baf589 100644 --- a/tests/cli/test_cook_startup_observability.py +++ b/tests/cli/test_cook_startup_observability.py @@ -240,6 +240,50 @@ def test_missing_spawn_and_hook_stages_fail_budget_enforcement( assert summary["budgets_passed"] is False +def test_reload_attempt_resets_startup_budget_anchors( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + trace_mod = _trace_module() + project = tmp_path / "project" + project.mkdir() + monkeypatch.setattr(trace_mod, "default_log_dir", lambda: tmp_path / "logs") + clock = _Clock() + trace = trace_mod.StartupTrace( + project, + _LAUNCH_ID, + enabled=True, + clock=clock, + ) + trace.record_launch_anchor() + trace.record_attempt_anchor(attempt=1, view_id=_VIEW_ID) + clock.value = 101.0 + trace.record_stage("spawn", attempt=1, view_id=_VIEW_ID) + clock.value = 102.0 + trace.record_stage("hook_review", attempt=1, view_id=_VIEW_ID) + trace.require_startup_budgets() + + clock.value = 200.0 + trace.record_attempt_anchor(attempt=2, view_id="f" * 32) + + with pytest.raises(RuntimeError, match="unmeasured="): + trace.require_startup_budgets() + + clock.value = 201.0 + trace.record_stage("spawn", attempt=2, view_id="f" * 32) + clock.value = 202.0 + trace.record_stage("hook_review", attempt=2, view_id="f" * 32) + trace.require_startup_budgets() + trace.close(status="success") + + summary = _records(trace.path)[-1] + assert summary["durations_seconds"] == { + "confirmation_to_spawn": 1.0, + "spawn_to_hook_review": 1.0, + "total_startup": 2.0, + } + + def test_spawn_stage_is_exactly_once_per_attempt( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, From 6934310a2ed7b8008fd5bc23c5350ffe6d091f46 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 14:45:19 -0700 Subject: [PATCH 17/35] fix(review): reject unsafe config lock sidecars --- .../execution/backends/_codex_config_lock.py | 16 +++++++++++++++- .../backends/test_codex_config_validation.py | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/autoskillit/execution/backends/_codex_config_lock.py b/src/autoskillit/execution/backends/_codex_config_lock.py index 751e76abf4..c76e59d28e 100644 --- a/src/autoskillit/execution/backends/_codex_config_lock.py +++ b/src/autoskillit/execution/backends/_codex_config_lock.py @@ -8,6 +8,7 @@ import math import os import socket +import stat import threading import time from pathlib import Path @@ -101,8 +102,21 @@ def acquire(self) -> CodexConfigLock: acquired = False try: self.lock_path.parent.mkdir(parents=True, exist_ok=True) - flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_CLOEXEC", 0) + no_follow = getattr(os, "O_NOFOLLOW", None) + if no_follow is None: # pragma: no cover - supported on POSIX cook hosts + raise OSError( + errno.ENOTSUP, + "Codex config lock requires no-follow open semantics", + self.lock_path, + ) + flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_CLOEXEC", 0) | no_follow fd = os.open(self.lock_path, flags, 0o600) + if not stat.S_ISREG(os.fstat(fd).st_mode): + raise OSError( + errno.EINVAL, + "Codex config lock sidecar is not a regular file", + self.lock_path, + ) deadline = time.monotonic() + self.timeout while True: try: diff --git a/tests/execution/backends/test_codex_config_validation.py b/tests/execution/backends/test_codex_config_validation.py index 32d6321599..2b4b24b485 100644 --- a/tests/execution/backends/test_codex_config_validation.py +++ b/tests/execution/backends/test_codex_config_validation.py @@ -144,3 +144,22 @@ def test_config_lock_is_non_reentrant_for_the_same_canonical_path(tmp_path: Path with pytest.raises(RuntimeError, match="non-reentrant|already owns"): with CodexConfigLock(alias): pytest.fail("same-process nested acquisition must fail before entry") + + +def test_config_lock_rejects_symlink_sidecar_without_truncating_target( + tmp_path: Path, +) -> None: + from autoskillit.execution.backends._codex_config_lock import CodexConfigLock + + codex_home = tmp_path / "codex-home" + codex_home.mkdir() + config_path = codex_home / "config.toml" + victim = tmp_path / "victim.txt" + victim.write_text("preserve me", encoding="utf-8") + lock_path = codex_home / ".config.toml.autoskillit.lock" + lock_path.symlink_to(victim) + + with pytest.raises(OSError): + CodexConfigLock(config_path).acquire() + + assert victim.read_text(encoding="utf-8") == "preserve me" From ccd4502c0cf45bc2746ecf03fd48959351dc0e27 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 14:47:34 -0700 Subject: [PATCH 18/35] fix(review): isolate Codex readiness adapter --- src/autoskillit/cli/session/pty/_observer.py | 150 +----------------- src/autoskillit/execution/backends/AGENTS.md | 2 + .../backends/_codex_state_readiness.py | 139 ++++++++++++++++ .../execution/backends/_readiness.py | 41 +++++ tests/cli/test_cook_startup_observability.py | 6 +- 5 files changed, 190 insertions(+), 148 deletions(-) create mode 100644 src/autoskillit/execution/backends/_codex_state_readiness.py create mode 100644 src/autoskillit/execution/backends/_readiness.py diff --git a/src/autoskillit/cli/session/pty/_observer.py b/src/autoskillit/cli/session/pty/_observer.py index 8a2e5ab135..90348bcbbd 100644 --- a/src/autoskillit/cli/session/pty/_observer.py +++ b/src/autoskillit/cli/session/pty/_observer.py @@ -1,32 +1,27 @@ -"""Byte-transparent PTY observation and Codex startup-readiness probing.""" +"""Byte-transparent PTY observation with injectable startup readiness.""" from __future__ import annotations import contextlib import errno import fcntl -import math import os import selectors import signal -import sqlite3 -import stat import termios -import time import tty from collections.abc import Callable from dataclasses import dataclass, field -from enum import StrEnum -from pathlib import Path from types import FrameType from typing import Any import regex as re +from autoskillit.execution.backends._readiness import ObserverStatus, ReadinessProbe + _WINDOW_LIMIT = 64 * 1024 _RELAY_CHUNK_SIZE = 64 * 1024 _RELAY_SELECT_SECONDS = 0.05 -_CODEX_STATE_READINESS_COMMIT = "ad65f016ed0c91992fb175fa881a373cc460dd2a" _ANSI_ESCAPE_RE = re.compile( r""" \x1b @@ -45,146 +40,11 @@ ) -class ObserverStatus(StrEnum): - """Typed outcomes from the guarded Codex state-readiness adapter.""" - - READY = "ready" - ABSENT = "absent" - LOCKED = "locked" - CORRUPT = "corrupt" - INCOMPLETE = "incomplete" - SCHEMA_CHANGED = "schema_changed" - UNSUPPORTED_VERSION = "unsupported_version" - TIMEOUT = "timeout" - CANCELLED = "cancelled" - - -@dataclass(frozen=True, slots=True) -class _StateReadinessDef: - database_name: str - upstream_commit: str - - -_SUPPORTED_STATE_CONTRACTS = { - "codex-cli 0.145.0": _StateReadinessDef( - database_name="state_5.sqlite", - upstream_commit=_CODEX_STATE_READINESS_COMMIT, - ) -} - - -@dataclass(frozen=True, slots=True) -class CodexStateReadinessProbe: - """Read the version-mapped disposable Codex state database without mutation.""" - - codex_version: str - sqlite_home: Path - poll_interval_seconds: float = 0.05 - _clock: Callable[[], float] = field(default=time.monotonic, repr=False) - _sleep: Callable[[float], None] = field(default=time.sleep, repr=False) - - def __post_init__(self) -> None: - if not math.isfinite(self.poll_interval_seconds) or self.poll_interval_seconds <= 0: - raise ValueError("poll_interval_seconds must be finite and positive") - object.__setattr__(self, "sqlite_home", Path(self.sqlite_home)) - - @property - def database_path(self) -> Path | None: - """Return the exact database path for a supported Codex version.""" - compatibility = _SUPPORTED_STATE_CONTRACTS.get(self.codex_version) - return None if compatibility is None else self.sqlite_home / compatibility.database_name - - @property - def upstream_commit(self) -> str | None: - """Return the source revision defining the probed schema contract.""" - compatibility = _SUPPORTED_STATE_CONTRACTS.get(self.codex_version) - return None if compatibility is None else compatibility.upstream_commit - - def check(self) -> ObserverStatus: - """Perform one zero-wait, read-only readiness observation.""" - database_path = self.database_path - if database_path is None: - return ObserverStatus.UNSUPPORTED_VERSION - try: - path_stat = database_path.lstat() - except FileNotFoundError: - return ObserverStatus.ABSENT - except OSError: - return ObserverStatus.CORRUPT - if not stat.S_ISREG(path_stat.st_mode): - return ObserverStatus.CORRUPT - - connection: sqlite3.Connection | None = None - try: - uri = f"{database_path.resolve(strict=True).as_uri()}?mode=ro" - connection = sqlite3.connect( - uri, - uri=True, - timeout=0.0, - isolation_level=None, - ) - connection.execute("PRAGMA query_only = ON") - connection.execute("PRAGMA busy_timeout = 0") - columns = { - row[1] - for row in connection.execute("PRAGMA table_info(backfill_state)") - if len(row) > 1 and isinstance(row[1], str) - } - if not {"id", "status"}.issubset(columns): - return ObserverStatus.SCHEMA_CHANGED - row = connection.execute("SELECT status FROM backfill_state WHERE id = 1").fetchone() - if row is None or len(row) != 1 or not isinstance(row[0], str): - return ObserverStatus.INCOMPLETE - return ObserverStatus.READY if row[0] == "complete" else ObserverStatus.INCOMPLETE - except sqlite3.OperationalError as exc: - message = str(exc).lower() - if "locked" in message or "busy" in message: - return ObserverStatus.LOCKED - if "no such table" in message or "no such column" in message: - return ObserverStatus.SCHEMA_CHANGED - return ObserverStatus.CORRUPT - except (OSError, sqlite3.DatabaseError, ValueError): - return ObserverStatus.CORRUPT - finally: - if connection is not None: - connection.close() - - def wait( - self, - *, - timeout_seconds: float, - cancelled: Callable[[], bool] | None = None, - ) -> ObserverStatus: - """Poll until ready, a terminal adapter failure, timeout, or cancellation.""" - if not math.isfinite(timeout_seconds) or timeout_seconds < 0: - raise ValueError("timeout_seconds must be finite and non-negative") - is_cancelled = cancelled or (lambda: False) - deadline = self._clock() + timeout_seconds - while True: - if is_cancelled(): - return ObserverStatus.CANCELLED - if self._clock() >= deadline: - return ObserverStatus.TIMEOUT - status = self.check() - if status is ObserverStatus.READY: - return status - if status in { - ObserverStatus.CORRUPT, - ObserverStatus.SCHEMA_CHANGED, - ObserverStatus.UNSUPPORTED_VERSION, - }: - return status - remaining = deadline - self._clock() - if remaining <= 0: - return ObserverStatus.TIMEOUT - self._sleep(min(self.poll_interval_seconds, remaining)) - - @dataclass(slots=True) class PtyObserver: """Own PTY I/O observation while leaving process-group policy to its caller.""" - readiness_probe: CodexStateReadinessProbe | None + readiness_probe: ReadinessProbe | None on_first_output: Callable[[], None] | None = None on_hook_review: Callable[[], None] | None = None on_readiness: Callable[[ObserverStatus], None] | None = None @@ -357,4 +217,4 @@ def _write_all(fd: int, payload: bytes) -> None: view = view[written:] -__all__ = ["CodexStateReadinessProbe", "ObserverStatus", "PtyObserver"] +__all__ = ["PtyObserver"] diff --git a/src/autoskillit/execution/backends/AGENTS.md b/src/autoskillit/execution/backends/AGENTS.md index 01163044c0..6214ab6691 100644 --- a/src/autoskillit/execution/backends/AGENTS.md +++ b/src/autoskillit/execution/backends/AGENTS.md @@ -18,6 +18,8 @@ IL-1 backend abstraction layer — concrete `CodingAgentBackend` implementations | `_codex_hooks.py` | Codex config.toml hook generation, sync, and upsert (`generate_codex_hooks_config`, `sync_hooks_to_codex_config`) | | `_codex_parse.py` | `CodexStreamParser`, `CodexResultParser`, NDJSON scanning, and bounded persisted-rollout parsing | | `_codex_prelaunch.py` | Sole composed source-config synchronization, hook update, snapshot, and native-validation transaction | +| `_codex_state_readiness.py` | Version-mapped, read-only Codex SQLite readiness adapter | +| `_readiness.py` | Backend-neutral startup-readiness status and probe protocol | | `_codex_recipe_delivery.py` | Protected outer-budget provider seam, bounded diagnostic correlation, and durable consumed-call/receipt ledger | | `_codex_session_storage.py` | Canonical rollout store, per-attempt views and leases, durable promotion/recovery, and derived session index | | `codex_scenario_player.py` | `CodexScenarioPlayer`, `make_codex_scenario_player`, `CodexStepRecord`, `CodexScenario`, `_load_manifest`, `_FakeCodexCLI`, `_write_shim_script` — scenario replay data layer for Codex backend | diff --git a/src/autoskillit/execution/backends/_codex_state_readiness.py b/src/autoskillit/execution/backends/_codex_state_readiness.py new file mode 100644 index 0000000000..2f8c8dba62 --- /dev/null +++ b/src/autoskillit/execution/backends/_codex_state_readiness.py @@ -0,0 +1,139 @@ +"""Guarded read-only readiness probing for Codex state storage.""" + +from __future__ import annotations + +import math +import sqlite3 +import stat +import time +from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path + +from autoskillit.execution.backends._readiness import ObserverStatus + +_CODEX_STATE_READINESS_COMMIT = "ad65f016ed0c91992fb175fa881a373cc460dd2a" + + +@dataclass(frozen=True, slots=True) +class _StateReadinessDef: + database_name: str + upstream_commit: str + + +_SUPPORTED_STATE_CONTRACTS = { + "codex-cli 0.145.0": _StateReadinessDef( + database_name="state_5.sqlite", + upstream_commit=_CODEX_STATE_READINESS_COMMIT, + ) +} + + +@dataclass(frozen=True, slots=True) +class CodexStateReadinessProbe: + """Read the version-mapped disposable Codex state database without mutation.""" + + codex_version: str + sqlite_home: Path + poll_interval_seconds: float = 0.05 + _clock: Callable[[], float] = field(default=time.monotonic, repr=False) + _sleep: Callable[[float], None] = field(default=time.sleep, repr=False) + + def __post_init__(self) -> None: + if not math.isfinite(self.poll_interval_seconds) or self.poll_interval_seconds <= 0: + raise ValueError("poll_interval_seconds must be finite and positive") + object.__setattr__(self, "sqlite_home", Path(self.sqlite_home)) + + @property + def database_path(self) -> Path | None: + """Return the exact database path for a supported Codex version.""" + compatibility = _SUPPORTED_STATE_CONTRACTS.get(self.codex_version) + return None if compatibility is None else self.sqlite_home / compatibility.database_name + + @property + def upstream_commit(self) -> str | None: + """Return the source revision defining the probed schema contract.""" + compatibility = _SUPPORTED_STATE_CONTRACTS.get(self.codex_version) + return None if compatibility is None else compatibility.upstream_commit + + def check(self) -> ObserverStatus: + """Perform one zero-wait, read-only readiness observation.""" + database_path = self.database_path + if database_path is None: + return ObserverStatus.UNSUPPORTED_VERSION + try: + path_stat = database_path.lstat() + except FileNotFoundError: + return ObserverStatus.ABSENT + except OSError: + return ObserverStatus.CORRUPT + if not stat.S_ISREG(path_stat.st_mode): + return ObserverStatus.CORRUPT + + connection: sqlite3.Connection | None = None + try: + uri = f"{database_path.resolve(strict=True).as_uri()}?mode=ro" + connection = sqlite3.connect( + uri, + uri=True, + timeout=0.0, + isolation_level=None, + ) + connection.execute("PRAGMA query_only = ON") + connection.execute("PRAGMA busy_timeout = 0") + columns = { + row[1] + for row in connection.execute("PRAGMA table_info(backfill_state)") + if len(row) > 1 and isinstance(row[1], str) + } + if not {"id", "status"}.issubset(columns): + return ObserverStatus.SCHEMA_CHANGED + row = connection.execute("SELECT status FROM backfill_state WHERE id = 1").fetchone() + if row is None or len(row) != 1 or not isinstance(row[0], str): + return ObserverStatus.INCOMPLETE + return ObserverStatus.READY if row[0] == "complete" else ObserverStatus.INCOMPLETE + except sqlite3.OperationalError as exc: + message = str(exc).lower() + if "locked" in message or "busy" in message: + return ObserverStatus.LOCKED + if "no such table" in message or "no such column" in message: + return ObserverStatus.SCHEMA_CHANGED + return ObserverStatus.CORRUPT + except (OSError, sqlite3.DatabaseError, ValueError): + return ObserverStatus.CORRUPT + finally: + if connection is not None: + connection.close() + + def wait( + self, + *, + timeout_seconds: float, + cancelled: Callable[[], bool] | None = None, + ) -> ObserverStatus: + """Poll until ready, a terminal adapter failure, timeout, or cancellation.""" + if not math.isfinite(timeout_seconds) or timeout_seconds < 0: + raise ValueError("timeout_seconds must be finite and non-negative") + is_cancelled = cancelled or (lambda: False) + deadline = self._clock() + timeout_seconds + while True: + if is_cancelled(): + return ObserverStatus.CANCELLED + if self._clock() >= deadline: + return ObserverStatus.TIMEOUT + status = self.check() + if status is ObserverStatus.READY: + return status + if status in { + ObserverStatus.CORRUPT, + ObserverStatus.SCHEMA_CHANGED, + ObserverStatus.UNSUPPORTED_VERSION, + }: + return status + remaining = deadline - self._clock() + if remaining <= 0: + return ObserverStatus.TIMEOUT + self._sleep(min(self.poll_interval_seconds, remaining)) + + +__all__ = ["CodexStateReadinessProbe"] diff --git a/src/autoskillit/execution/backends/_readiness.py b/src/autoskillit/execution/backends/_readiness.py new file mode 100644 index 0000000000..ff1dbd1834 --- /dev/null +++ b/src/autoskillit/execution/backends/_readiness.py @@ -0,0 +1,41 @@ +"""Backend-neutral startup-readiness contracts.""" + +from __future__ import annotations + +from collections.abc import Callable +from enum import StrEnum +from typing import Protocol + + +class ObserverStatus(StrEnum): + """Typed outcomes from a guarded startup-readiness adapter.""" + + READY = "ready" + ABSENT = "absent" + LOCKED = "locked" + CORRUPT = "corrupt" + INCOMPLETE = "incomplete" + SCHEMA_CHANGED = "schema_changed" + UNSUPPORTED_VERSION = "unsupported_version" + TIMEOUT = "timeout" + CANCELLED = "cancelled" + + +class ReadinessProbe(Protocol): + """Backend-owned readiness adapter consumed by generic observers.""" + + def check(self) -> ObserverStatus: + """Perform one non-blocking readiness observation.""" + ... + + def wait( + self, + *, + timeout_seconds: float, + cancelled: Callable[[], bool] | None = None, + ) -> ObserverStatus: + """Wait within a bounded interval for a terminal readiness outcome.""" + ... + + +__all__ = ["ObserverStatus", "ReadinessProbe"] diff --git a/tests/cli/test_cook_startup_observability.py b/tests/cli/test_cook_startup_observability.py index e501baf589..e2738cdfe5 100644 --- a/tests/cli/test_cook_startup_observability.py +++ b/tests/cli/test_cook_startup_observability.py @@ -33,11 +33,11 @@ def _trace_module(): def _observer_api(): - from autoskillit.cli.session.pty._observer import ( + from autoskillit.cli.session.pty._observer import PtyObserver + from autoskillit.execution.backends._codex_state_readiness import ( CodexStateReadinessProbe, - ObserverStatus, - PtyObserver, ) + from autoskillit.execution.backends._readiness import ObserverStatus return CodexStateReadinessProbe, ObserverStatus, PtyObserver From b73ceb27a1df1743c0005ad0233efa438aa2e23b Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 14:48:40 -0700 Subject: [PATCH 19/35] test(review): pin hook trust translation boundary --- tests/arch/test_capability_consumption.py | 42 ++++++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/tests/arch/test_capability_consumption.py b/tests/arch/test_capability_consumption.py index 24571b01a9..3590a08466 100644 --- a/tests/arch/test_capability_consumption.py +++ b/tests/arch/test_capability_consumption.py @@ -101,14 +101,46 @@ def test_all_capability_fields_have_production_consumers(): def test_hook_trust_policy_has_a_real_production_consumer() -> None: - from autoskillit.core import BackendCapabilities, paths + from autoskillit.core import paths + + codex_path = paths.pkg_root() / "execution" / "backends" / "codex.py" + tree = ast.parse(codex_path.read_text(encoding="utf-8"), filename=str(codex_path)) + backend_class = next( + node + for node in tree.body + if isinstance(node, ast.ClassDef) and node.name == "CodexBackend" + ) + interactive_builder = next( + node + for node in backend_class.body + if isinstance(node, ast.FunctionDef) and node.name == "build_interactive_cmd" + ) + translation_calls = [ + call + for call in ast.walk(interactive_builder) + if isinstance(call, ast.Call) + and isinstance(call.func, ast.Name) + and call.func.id == "_should_bypass_hook_trust" + ] - field_names = frozenset(field.name for field in dataclasses.fields(BackendCapabilities)) - reads = _collect_attribute_reads(paths.pkg_root(), field_names) assert "hook_trust_policy" not in _FORWARD_DECLARED - assert reads["hook_trust_policy"], ( - "hook_trust_policy must be translated at the interactive launch boundary" + assert len(translation_calls) == 1 + translation_call = translation_calls[0] + assert len(translation_call.args) == 1 + policy_arg = translation_call.args[0] + assert ( + isinstance(policy_arg, ast.Attribute) + and policy_arg.attr == "hook_trust_policy" + and isinstance(policy_arg.value, ast.Attribute) + and policy_arg.value.attr == "capabilities" + and isinstance(policy_arg.value.value, ast.Name) + and policy_arg.value.value.id == "self" + ), "interactive launch must translate self.capabilities.hook_trust_policy" + automated_keyword = next( + keyword for keyword in translation_call.keywords if keyword.arg == "automated_session" ) + assert isinstance(automated_keyword.value, ast.Constant) + assert automated_keyword.value.value is False def test_forward_declared_has_linked_issues(): From b77315c54339bb2bdcfcb3589e7c5d960a0e9b38 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 14:49:23 -0700 Subject: [PATCH 20/35] test(review): enforce PTY ownership structurally --- .../test_interactive_subprocess_contracts.py | 55 ++++++++++++++++--- 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/tests/cli/test_interactive_subprocess_contracts.py b/tests/cli/test_interactive_subprocess_contracts.py index b804d426de..1108eb8608 100644 --- a/tests/cli/test_interactive_subprocess_contracts.py +++ b/tests/cli/test_interactive_subprocess_contracts.py @@ -143,6 +143,31 @@ def _calls_in(node: ast.AST, *, owner: str, attr: str) -> list[ast.Call]: ] +def _attributes_in(node: ast.AST, *, owner: str, attr: str) -> list[ast.Attribute]: + return [ + attribute + for attribute in ast.walk(node) + if isinstance(attribute, ast.Attribute) + and isinstance(attribute.value, ast.Name) + and attribute.value.id == owner + and attribute.attr == attr + ] + + +def _with_context_calls(node: ast.AST, *, name: str) -> list[ast.With]: + return [ + with_node + for with_node in ast.walk(node) + if isinstance(with_node, ast.With) + and any( + isinstance(item.context_expr, ast.Call) + and isinstance(item.context_expr.func, ast.Name) + and item.context_expr.func.id == name + for item in with_node.items + ) + ] + + def _function(tree: ast.Module, name: str) -> ast.FunctionDef: matches = [ node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == name @@ -257,14 +282,28 @@ def test_cook_pty_path_has_no_unsafe_post_fork_python_setup() -> None: def test_terminal_and_lease_ownership_are_not_duplicated_across_pty_layers() -> None: session_dir = CLI_DIR / "session" - process_source = (session_dir / "_session_process.py").read_text(encoding="utf-8") - observer_source = (session_dir / "pty" / "_observer.py").read_text(encoding="utf-8") - - assert "terminal_guard" in process_source - assert "tcsetpgrp" in process_source - assert "LOCK_UN" not in process_source - assert "tcsetpgrp" not in observer_source - assert "subprocess.Popen" not in observer_source + process_path = session_dir / "_session_process.py" + observer_path = session_dir / "pty" / "_observer.py" + process_tree = ast.parse( + process_path.read_text(encoding="utf-8"), + filename=str(process_path), + ) + observer_tree = ast.parse( + observer_path.read_text(encoding="utf-8"), + filename=str(observer_path), + ) + run_attempt = _function(process_tree, "run_cook_attempt") + + assert len(_with_context_calls(run_attempt, name="terminal_guard")) == 1 + assert _calls_in(run_attempt, owner="subprocess", attr="Popen") + assert _popen_outside_terminal_guard(run_attempt) == [] + assert _calls_in(process_tree, owner="os", attr="tcsetpgrp") + assert _attributes_in(process_tree, owner="fcntl", attr="LOCK_UN") == [] + assert not any( + isinstance(node, ast.Name) and node.id == "LOCK_UN" for node in ast.walk(process_tree) + ) + assert _calls_in(observer_tree, owner="os", attr="tcsetpgrp") == [] + assert _calls_in(observer_tree, owner="subprocess", attr="Popen") == [] def test_unchanged_order_fleet_launch_path_retains_its_separate_run_owner() -> None: From e0f95a6c463e90dd0109af100f8b9fd77da6408c Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 14:55:51 -0700 Subject: [PATCH 21/35] fix(review): route readiness through public gateways --- src/autoskillit/cli/session/pty/_observer.py | 2 +- src/autoskillit/core/__init__.pyi | 2 + src/autoskillit/core/types/AGENTS.md | 4 +- src/autoskillit/core/types/_type_enums.py | 15 ++ .../core/types/_type_protocols_backend.py | 23 ++- src/autoskillit/execution/backends/AGENTS.md | 4 +- .../execution/backends/__init__.py | 2 + .../backends/_codex_state_readiness.py | 139 ------------------ .../execution/backends/_readiness.py | 41 ------ tests/cli/test_cook_startup_observability.py | 6 +- 10 files changed, 46 insertions(+), 192 deletions(-) delete mode 100644 src/autoskillit/execution/backends/_codex_state_readiness.py delete mode 100644 src/autoskillit/execution/backends/_readiness.py diff --git a/src/autoskillit/cli/session/pty/_observer.py b/src/autoskillit/cli/session/pty/_observer.py index 90348bcbbd..c3f44ea402 100644 --- a/src/autoskillit/cli/session/pty/_observer.py +++ b/src/autoskillit/cli/session/pty/_observer.py @@ -17,7 +17,7 @@ import regex as re -from autoskillit.execution.backends._readiness import ObserverStatus, ReadinessProbe +from autoskillit.core import ObserverStatus, ReadinessProbe _WINDOW_LIMIT = 64 * 1024 _RELAY_CHUNK_SIZE = 64 * 1024 diff --git a/src/autoskillit/core/__init__.pyi b/src/autoskillit/core/__init__.pyi index 2bbd8c9194..90d876f017 100644 --- a/src/autoskillit/core/__init__.pyi +++ b/src/autoskillit/core/__init__.pyi @@ -463,6 +463,7 @@ from .types import ModelTranslation as ModelTranslation from .types import NamedResume as NamedResume from .types import NdjsonDriftOutcome as NdjsonDriftOutcome from .types import NoResume as NoResume +from .types import ObserverStatus as ObserverStatus from .types import OccurrenceStateChangedEffect as OccurrenceStateChangedEffect from .types import OpenEpochEvent as OpenEpochEvent from .types import OutputFormat as OutputFormat @@ -488,6 +489,7 @@ from .types import QuarantineRecordedEffect as QuarantineRecordedEffect from .types import QuotaPolicy as QuotaPolicy from .types import QuotaRefreshTask as QuotaRefreshTask from .types import ReadingToken as ReadingToken +from .types import ReadinessProbe as ReadinessProbe from .types import ReadOnlyResolver as ReadOnlyResolver from .types import RecipeDeliveryAttestation as RecipeDeliveryAttestation from .types import RecipeDeliveryBudgetDef as RecipeDeliveryBudgetDef diff --git a/src/autoskillit/core/types/AGENTS.md b/src/autoskillit/core/types/AGENTS.md index 9c15c7950c..f874c91d2f 100644 --- a/src/autoskillit/core/types/AGENTS.md +++ b/src/autoskillit/core/types/AGENTS.md @@ -7,7 +7,7 @@ Type re-export hub and all typed building blocks for the autoskillit package (IL | File | Purpose | |------|---------| | `__init__.py` | Re-export hub — aggregates `__all__` from all `_type_*.py` modules | -| `_type_enums.py` | All `StrEnum` discriminators (`RetryReason`, `KillReason`, `Severity`, etc.) | +| `_type_enums.py` | All `StrEnum` discriminators (`RetryReason`, `KillReason`, `Severity`, `ObserverStatus`, etc.) | | `_type_exceptions.py` | Exception hierarchy for recipe loading: `RecipeLoadError`, `ProcessStaleError`, `RecipeNotFoundError` | | `_type_figure_spec.py` | `FigureSpec` TypedDict and consumer/producer field sets for `yaml:figure-spec` contracts | | `_type_constants.py` | Retired name registries, skill contracts, orchestration prompt sections, CI/domain constants | @@ -27,7 +27,7 @@ Type re-export hub and all typed building blocks for the autoskillit package (IL | `_type_protocols_workspace.py` | Protocols: `WorkspaceManager`, `CloneManager`, `SessionSkillManager`, `SkillLister`, `SkillResolver` | | `_type_protocols_recipe.py` | Protocols: `RecipeRepository`, `MigrationService`, `DatabaseReader`, `ReadOnlyResolver` | | `_type_protocols_infra.py` | Protocols: `GateState`, `BackgroundSupervisor`, `FleetLock`, `QuotaRefreshTask`, `TokenFactory`, `CampaignProtector` | -| `_type_protocols_backend.py` | Protocols: `StreamParser`, `ResultParser`, `EnvPolicy`, `SessionLocator`, `CodingAgentBackend` | +| `_type_protocols_backend.py` | Protocols: `StreamParser`, `ResultParser`, `EnvPolicy`, `ReadinessProbe`, `SessionLocator`, `CodingAgentBackend` | | `_type_checkpoint.py` | `SessionCheckpoint` frozen dataclass and `compute_remaining()` helper for session resume | | `_type_backend.py` | `BackendCapabilities` frozen dataclass, `CLAUDE_CODE_CAPABILITIES` constant, `CmdSpec`, `SkillSessionConfig`, `ClaudeEventData`, `CodexEventData`, `SessionEvent`, `AgentSessionResult` | | `_type_recipe_delivery.py` | Typed Codex recipe budgets, protected-host evidence definitions, requests, attestations, and delivery decisions | diff --git a/src/autoskillit/core/types/_type_enums.py b/src/autoskillit/core/types/_type_enums.py index c0a4ec56e9..540cc2bbcf 100644 --- a/src/autoskillit/core/types/_type_enums.py +++ b/src/autoskillit/core/types/_type_enums.py @@ -27,6 +27,7 @@ "ChannelConfirmation", "SessionOutcome", "HookTrustPolicy", + "ObserverStatus", "CliSubtype", "ChannelBStatus", "PRState", @@ -354,6 +355,20 @@ class HookTrustPolicy(StrEnum): REVIEW_EACH_SESSION = "review_each_session" +class ObserverStatus(StrEnum): + """Typed outcomes from a guarded startup-readiness adapter.""" + + READY = "ready" + ABSENT = "absent" + LOCKED = "locked" + CORRUPT = "corrupt" + INCOMPLETE = "incomplete" + SCHEMA_CHANGED = "schema_changed" + UNSUPPORTED_VERSION = "unsupported_version" + TIMEOUT = "timeout" + CANCELLED = "cancelled" + + class CliSubtype(StrEnum): """Sealed enum for Claude CLI session subtypes. diff --git a/src/autoskillit/core/types/_type_protocols_backend.py b/src/autoskillit/core/types/_type_protocols_backend.py index 7f87b60c59..ef90f3918f 100644 --- a/src/autoskillit/core/types/_type_protocols_backend.py +++ b/src/autoskillit/core/types/_type_protocols_backend.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from contextlib import AbstractContextManager from pathlib import Path from typing import Any, Protocol, runtime_checkable @@ -18,7 +18,7 @@ SkillSessionConfig, ) from ._type_checkpoint import SessionCheckpoint -from ._type_enums import OutputFormat +from ._type_enums import ObserverStatus, OutputFormat from ._type_plugin_source import PluginSource from ._type_results import ValidatedAddDir from ._type_resume import NoResume, ResumeSpec @@ -27,6 +27,7 @@ "StreamParser", "ResultParser", "EnvPolicy", + "ReadinessProbe", "SessionLocator", "CodingAgentBackend", ] @@ -64,6 +65,24 @@ def build_env( ) -> dict[str, str]: ... +@runtime_checkable +class ReadinessProbe(Protocol): + """Backend-owned readiness adapter consumed by generic observers.""" + + def check(self) -> ObserverStatus: + """Perform one non-blocking readiness observation.""" + ... + + def wait( + self, + *, + timeout_seconds: float, + cancelled: Callable[[], bool] | None = None, + ) -> ObserverStatus: + """Wait within a bounded interval for a terminal readiness outcome.""" + ... + + @runtime_checkable class SessionLocator(Protocol): """Protocol for locating session log directories for a given backend.""" diff --git a/src/autoskillit/execution/backends/AGENTS.md b/src/autoskillit/execution/backends/AGENTS.md index 6214ab6691..c0eb5ee2cc 100644 --- a/src/autoskillit/execution/backends/AGENTS.md +++ b/src/autoskillit/execution/backends/AGENTS.md @@ -12,14 +12,12 @@ IL-1 backend abstraction layer — concrete `CodingAgentBackend` implementations | `_probe_cache.py` | `ProbeResult`, `PROBE_CACHE_TTL`, `read_probe_cache`, `write_probe_cache` — versioned probe result cache | | `claude.py` | `ClaudeCodeBackend` (incl. `validate_session_layout`), `ClaudeEnvPolicy`, `ClaudeSessionLocator`, `ClaudeStreamParser`, `ClaudeResultParser` (prompt utilities moved to `_claude_prompt.py`) | | `_claude_prompt.py` | Prompt injection utilities, session constants (`_ensure_skill_prefix`, `_inject_completion_directive`, `_compose_resume_prompt`, etc.), shared by claude + codex + commands | -| `codex.py` | `CodexFlags`, `CodexBackend` (incl. `validate_session_layout`, `setup_session_dir` agent TOML generation and session-config registration), `CodexEnvPolicy`, `CodexSessionLocator` (parse/config moved to `_codex_parse.py` / `_codex_config.py`) | +| `codex.py` | `CodexFlags`, `CodexBackend` (incl. `validate_session_layout`, `setup_session_dir` agent TOML generation and session-config registration), `CodexEnvPolicy`, `CodexSessionLocator`, `CodexStateReadinessProbe` (parse/config moved to `_codex_parse.py` / `_codex_config.py`) | | `_codex_config.py` | TOML serialization with `[[key]]` array-of-tables support, MCP registration (`ensure_codex_mcp_registered`, `_serialize_toml`) | | `_codex_config_lock.py` | Canonical source-config advisory lock with timeout, ownership diagnostics, and non-reentrant lifecycle | | `_codex_hooks.py` | Codex config.toml hook generation, sync, and upsert (`generate_codex_hooks_config`, `sync_hooks_to_codex_config`) | | `_codex_parse.py` | `CodexStreamParser`, `CodexResultParser`, NDJSON scanning, and bounded persisted-rollout parsing | | `_codex_prelaunch.py` | Sole composed source-config synchronization, hook update, snapshot, and native-validation transaction | -| `_codex_state_readiness.py` | Version-mapped, read-only Codex SQLite readiness adapter | -| `_readiness.py` | Backend-neutral startup-readiness status and probe protocol | | `_codex_recipe_delivery.py` | Protected outer-budget provider seam, bounded diagnostic correlation, and durable consumed-call/receipt ledger | | `_codex_session_storage.py` | Canonical rollout store, per-attempt views and leases, durable promotion/recovery, and derived session index | | `codex_scenario_player.py` | `CodexScenarioPlayer`, `make_codex_scenario_player`, `CodexStepRecord`, `CodexScenario`, `_load_manifest`, `_FakeCodexCLI`, `_write_shim_script` — scenario replay data layer for Codex backend | diff --git a/src/autoskillit/execution/backends/__init__.py b/src/autoskillit/execution/backends/__init__.py index b7e402b80a..718c3d8f8d 100644 --- a/src/autoskillit/execution/backends/__init__.py +++ b/src/autoskillit/execution/backends/__init__.py @@ -57,6 +57,7 @@ CodexEnvPolicy, CodexFlags, CodexSessionLocator, + CodexStateReadinessProbe, ) from .codex_scenario_player import ( CodexScenarioPlayer, @@ -101,6 +102,7 @@ def get_backend(name: str) -> CodingAgentBackend: "CodexOuterBudgetAttestor", "CodexScenarioPlayer", "CodexSessionLocator", + "CodexStateReadinessProbe", "CodexStreamParser", "CODEX_MCP_STARTUP_TIMEOUT_SEC", "CODEX_MCP_TOOL_TIMEOUT_FLOOR", diff --git a/src/autoskillit/execution/backends/_codex_state_readiness.py b/src/autoskillit/execution/backends/_codex_state_readiness.py deleted file mode 100644 index 2f8c8dba62..0000000000 --- a/src/autoskillit/execution/backends/_codex_state_readiness.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Guarded read-only readiness probing for Codex state storage.""" - -from __future__ import annotations - -import math -import sqlite3 -import stat -import time -from collections.abc import Callable -from dataclasses import dataclass, field -from pathlib import Path - -from autoskillit.execution.backends._readiness import ObserverStatus - -_CODEX_STATE_READINESS_COMMIT = "ad65f016ed0c91992fb175fa881a373cc460dd2a" - - -@dataclass(frozen=True, slots=True) -class _StateReadinessDef: - database_name: str - upstream_commit: str - - -_SUPPORTED_STATE_CONTRACTS = { - "codex-cli 0.145.0": _StateReadinessDef( - database_name="state_5.sqlite", - upstream_commit=_CODEX_STATE_READINESS_COMMIT, - ) -} - - -@dataclass(frozen=True, slots=True) -class CodexStateReadinessProbe: - """Read the version-mapped disposable Codex state database without mutation.""" - - codex_version: str - sqlite_home: Path - poll_interval_seconds: float = 0.05 - _clock: Callable[[], float] = field(default=time.monotonic, repr=False) - _sleep: Callable[[float], None] = field(default=time.sleep, repr=False) - - def __post_init__(self) -> None: - if not math.isfinite(self.poll_interval_seconds) or self.poll_interval_seconds <= 0: - raise ValueError("poll_interval_seconds must be finite and positive") - object.__setattr__(self, "sqlite_home", Path(self.sqlite_home)) - - @property - def database_path(self) -> Path | None: - """Return the exact database path for a supported Codex version.""" - compatibility = _SUPPORTED_STATE_CONTRACTS.get(self.codex_version) - return None if compatibility is None else self.sqlite_home / compatibility.database_name - - @property - def upstream_commit(self) -> str | None: - """Return the source revision defining the probed schema contract.""" - compatibility = _SUPPORTED_STATE_CONTRACTS.get(self.codex_version) - return None if compatibility is None else compatibility.upstream_commit - - def check(self) -> ObserverStatus: - """Perform one zero-wait, read-only readiness observation.""" - database_path = self.database_path - if database_path is None: - return ObserverStatus.UNSUPPORTED_VERSION - try: - path_stat = database_path.lstat() - except FileNotFoundError: - return ObserverStatus.ABSENT - except OSError: - return ObserverStatus.CORRUPT - if not stat.S_ISREG(path_stat.st_mode): - return ObserverStatus.CORRUPT - - connection: sqlite3.Connection | None = None - try: - uri = f"{database_path.resolve(strict=True).as_uri()}?mode=ro" - connection = sqlite3.connect( - uri, - uri=True, - timeout=0.0, - isolation_level=None, - ) - connection.execute("PRAGMA query_only = ON") - connection.execute("PRAGMA busy_timeout = 0") - columns = { - row[1] - for row in connection.execute("PRAGMA table_info(backfill_state)") - if len(row) > 1 and isinstance(row[1], str) - } - if not {"id", "status"}.issubset(columns): - return ObserverStatus.SCHEMA_CHANGED - row = connection.execute("SELECT status FROM backfill_state WHERE id = 1").fetchone() - if row is None or len(row) != 1 or not isinstance(row[0], str): - return ObserverStatus.INCOMPLETE - return ObserverStatus.READY if row[0] == "complete" else ObserverStatus.INCOMPLETE - except sqlite3.OperationalError as exc: - message = str(exc).lower() - if "locked" in message or "busy" in message: - return ObserverStatus.LOCKED - if "no such table" in message or "no such column" in message: - return ObserverStatus.SCHEMA_CHANGED - return ObserverStatus.CORRUPT - except (OSError, sqlite3.DatabaseError, ValueError): - return ObserverStatus.CORRUPT - finally: - if connection is not None: - connection.close() - - def wait( - self, - *, - timeout_seconds: float, - cancelled: Callable[[], bool] | None = None, - ) -> ObserverStatus: - """Poll until ready, a terminal adapter failure, timeout, or cancellation.""" - if not math.isfinite(timeout_seconds) or timeout_seconds < 0: - raise ValueError("timeout_seconds must be finite and non-negative") - is_cancelled = cancelled or (lambda: False) - deadline = self._clock() + timeout_seconds - while True: - if is_cancelled(): - return ObserverStatus.CANCELLED - if self._clock() >= deadline: - return ObserverStatus.TIMEOUT - status = self.check() - if status is ObserverStatus.READY: - return status - if status in { - ObserverStatus.CORRUPT, - ObserverStatus.SCHEMA_CHANGED, - ObserverStatus.UNSUPPORTED_VERSION, - }: - return status - remaining = deadline - self._clock() - if remaining <= 0: - return ObserverStatus.TIMEOUT - self._sleep(min(self.poll_interval_seconds, remaining)) - - -__all__ = ["CodexStateReadinessProbe"] diff --git a/src/autoskillit/execution/backends/_readiness.py b/src/autoskillit/execution/backends/_readiness.py deleted file mode 100644 index ff1dbd1834..0000000000 --- a/src/autoskillit/execution/backends/_readiness.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Backend-neutral startup-readiness contracts.""" - -from __future__ import annotations - -from collections.abc import Callable -from enum import StrEnum -from typing import Protocol - - -class ObserverStatus(StrEnum): - """Typed outcomes from a guarded startup-readiness adapter.""" - - READY = "ready" - ABSENT = "absent" - LOCKED = "locked" - CORRUPT = "corrupt" - INCOMPLETE = "incomplete" - SCHEMA_CHANGED = "schema_changed" - UNSUPPORTED_VERSION = "unsupported_version" - TIMEOUT = "timeout" - CANCELLED = "cancelled" - - -class ReadinessProbe(Protocol): - """Backend-owned readiness adapter consumed by generic observers.""" - - def check(self) -> ObserverStatus: - """Perform one non-blocking readiness observation.""" - ... - - def wait( - self, - *, - timeout_seconds: float, - cancelled: Callable[[], bool] | None = None, - ) -> ObserverStatus: - """Wait within a bounded interval for a terminal readiness outcome.""" - ... - - -__all__ = ["ObserverStatus", "ReadinessProbe"] diff --git a/tests/cli/test_cook_startup_observability.py b/tests/cli/test_cook_startup_observability.py index e2738cdfe5..9ecbd9cd4c 100644 --- a/tests/cli/test_cook_startup_observability.py +++ b/tests/cli/test_cook_startup_observability.py @@ -34,10 +34,8 @@ def _trace_module(): def _observer_api(): from autoskillit.cli.session.pty._observer import PtyObserver - from autoskillit.execution.backends._codex_state_readiness import ( - CodexStateReadinessProbe, - ) - from autoskillit.execution.backends._readiness import ObserverStatus + from autoskillit.core import ObserverStatus + from autoskillit.execution.backends import CodexStateReadinessProbe return CodexStateReadinessProbe, ObserverStatus, PtyObserver From f0666b0d1c65a911c9b5a75ce48eb4381698e23b Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 15:00:52 -0700 Subject: [PATCH 22/35] fix(review): complete readiness gateway contracts --- src/autoskillit/core/__init__.pyi | 2 +- src/autoskillit/execution/AGENTS.md | 2 +- src/autoskillit/execution/__init__.py | 2 ++ tests/cli/test_cook_startup_observability.py | 2 +- tests/core/test_type_protocol_shards.py | 1 + tests/execution/backends/test_backend_registry.py | 1 + 6 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/autoskillit/core/__init__.pyi b/src/autoskillit/core/__init__.pyi index 90d876f017..7532cf3780 100644 --- a/src/autoskillit/core/__init__.pyi +++ b/src/autoskillit/core/__init__.pyi @@ -488,8 +488,8 @@ from .types import PRState as PRState from .types import QuarantineRecordedEffect as QuarantineRecordedEffect from .types import QuotaPolicy as QuotaPolicy from .types import QuotaRefreshTask as QuotaRefreshTask -from .types import ReadingToken as ReadingToken from .types import ReadinessProbe as ReadinessProbe +from .types import ReadingToken as ReadingToken from .types import ReadOnlyResolver as ReadOnlyResolver from .types import RecipeDeliveryAttestation as RecipeDeliveryAttestation from .types import RecipeDeliveryBudgetDef as RecipeDeliveryBudgetDef diff --git a/src/autoskillit/execution/AGENTS.md b/src/autoskillit/execution/AGENTS.md index 669946aec3..85e2c6837f 100644 --- a/src/autoskillit/execution/AGENTS.md +++ b/src/autoskillit/execution/AGENTS.md @@ -9,7 +9,7 @@ backends/ (see backends/AGENTS.md). | File | Purpose | |------|---------| -| `__init__.py` | Re-exports `DefaultHeadlessExecutor`, `run_headless_core` | +| `__init__.py` | Public gateway for execution services, backends, and readiness adapters | | `commands.py` | `ClaudeInteractiveCmd`, `ClaudeHeadlessCmd` builders | | `db.py` | Read-only SQLite with defence-in-depth | | `diff_annotator.py` | Diff annotation + findings filter for review-pr | diff --git a/src/autoskillit/execution/__init__.py b/src/autoskillit/execution/__init__.py index a662bb8f5b..03eac47682 100644 --- a/src/autoskillit/execution/__init__.py +++ b/src/autoskillit/execution/__init__.py @@ -34,6 +34,7 @@ CodexBackend, CodexHostCorrelation, CodexOuterBudgetAttestor, + CodexStateReadinessProbe, NullProtectedHostAttestationProvider, ProtectedHostAttestationProvider, ProtectedStoreAuthority, @@ -268,6 +269,7 @@ "CodexBackend", "CodexHostCorrelation", "CodexOuterBudgetAttestor", + "CodexStateReadinessProbe", "NullProtectedHostAttestationProvider", "ProtectedHostAttestationProvider", "ProtectedStoreAuthority", diff --git a/tests/cli/test_cook_startup_observability.py b/tests/cli/test_cook_startup_observability.py index 9ecbd9cd4c..7ae1ce1817 100644 --- a/tests/cli/test_cook_startup_observability.py +++ b/tests/cli/test_cook_startup_observability.py @@ -35,7 +35,7 @@ def _trace_module(): def _observer_api(): from autoskillit.cli.session.pty._observer import PtyObserver from autoskillit.core import ObserverStatus - from autoskillit.execution.backends import CodexStateReadinessProbe + from autoskillit.execution import CodexStateReadinessProbe return CodexStateReadinessProbe, ObserverStatus, PtyObserver diff --git a/tests/core/test_type_protocol_shards.py b/tests/core/test_type_protocol_shards.py index 93efbb5f9f..7e69461208 100644 --- a/tests/core/test_type_protocol_shards.py +++ b/tests/core/test_type_protocol_shards.py @@ -125,6 +125,7 @@ def test_backend_shard_all(): "StreamParser", "ResultParser", "EnvPolicy", + "ReadinessProbe", "SessionLocator", "CodingAgentBackend", } diff --git a/tests/execution/backends/test_backend_registry.py b/tests/execution/backends/test_backend_registry.py index 01e6cfbbd2..c007267c28 100644 --- a/tests/execution/backends/test_backend_registry.py +++ b/tests/execution/backends/test_backend_registry.py @@ -66,6 +66,7 @@ def test_all_exports_complete(self) -> None: "CodexResultParser", "CodexScenarioPlayer", "CodexSessionLocator", + "CodexStateReadinessProbe", "CodexStreamParser", "NON_VARIADIC_CODEX_FLAGS", "NullProtectedHostAttestationProvider", From 17bff7f3a2f99a02f235f00553a107ae0903eb1c Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 16:32:05 -0700 Subject: [PATCH 23/35] test(ci): isolate Codex CLI probes from host binary --- tests/cli/test_cook_order_command.py | 7 ++++++- tests/cli/test_init.py | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/cli/test_cook_order_command.py b/tests/cli/test_cook_order_command.py index 36157702b9..262f9bb3e9 100644 --- a/tests/cli/test_cook_order_command.py +++ b/tests/cli/test_cook_order_command.py @@ -423,7 +423,7 @@ def test_order_backend_produces_valid_command( ) -> None: """order() produces a valid command for each registered backend.""" from autoskillit.execution.backends import get_backend as _real_get_backend - from autoskillit.execution.backends.codex import CodexFlags + from autoskillit.execution.backends.codex import CodexBackend, CodexFlags real_backend = _real_get_backend(backend_name) captured: dict = {} @@ -448,6 +448,11 @@ def test_order_backend_produces_valid_command( "autoskillit.execution.get_backend", lambda name: _real_get_backend(name), ) + monkeypatch.setattr( + CodexBackend, + "ensure_pre_launch", + lambda _self, *, session_dir=None: [], + ) monkeypatch.setenv("ANTHROPIC_API_KEY", "test-canary-key") diff --git a/tests/cli/test_init.py b/tests/cli/test_init.py index 531861d0b8..e87317196d 100644 --- a/tests/cli/test_init.py +++ b/tests/cli/test_init.py @@ -326,6 +326,10 @@ def _pre_commit_with_scanner(self, tmp_path: Path) -> None: def _codex_backend_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("AUTOSKILLIT_AGENT_BACKEND", raising=False) monkeypatch.delenv("AUTOSKILLIT_AGENT_BACKEND__BACKEND", raising=False) + monkeypatch.setattr( + "autoskillit.execution.backends.codex._validate_global_codex_home", + lambda *_args, **_kwargs: [], + ) cfg_dir = tmp_path / ".autoskillit" cfg_dir.mkdir(parents=True, exist_ok=True) (cfg_dir / "config.yaml").write_text( From eb42ab70ba72b00f4a088ad8f41cd5b458a91afa Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 16:32:20 -0700 Subject: [PATCH 24/35] fix(ci): enforce lock-sidecar lease paths --- .../execution/backends/_codex_session_storage.py | 16 +++++++++------- .../backends/test_codex_session_storage.py | 9 +++++++++ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/autoskillit/execution/backends/_codex_session_storage.py b/src/autoskillit/execution/backends/_codex_session_storage.py index 52be89baf6..bb88f3c8a4 100644 --- a/src/autoskillit/execution/backends/_codex_session_storage.py +++ b/src/autoskillit/execution/backends/_codex_session_storage.py @@ -247,14 +247,16 @@ class _FileLease: fd: int = field(init=False) @classmethod - def acquire(cls, path: Path, *, nonblocking: bool = False) -> _FileLease: - path.parent.mkdir(parents=True, exist_ok=True) - _require_real_directory(path.parent, label="lock directory") - if _lexists(path) and path.is_symlink(): - raise RuntimeError(f"Refusing symlink lock file: {path}") - instance = cls(path=path) + def acquire(cls, lock_path: Path, *, nonblocking: bool = False) -> _FileLease: + if lock_path.suffix != ".lock": + raise ValueError(f"Lock path must use the .lock suffix: {lock_path}") + lock_path.parent.mkdir(parents=True, exist_ok=True) + _require_real_directory(lock_path.parent, label="lock directory") + if _lexists(lock_path) and lock_path.is_symlink(): + raise RuntimeError(f"Refusing symlink lock file: {lock_path}") + instance = cls(path=lock_path) instance.fd = os.open( - path, + lock_path, os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600, ) diff --git a/tests/execution/backends/test_codex_session_storage.py b/tests/execution/backends/test_codex_session_storage.py index 13610306a9..8ca625a5e6 100644 --- a/tests/execution/backends/test_codex_session_storage.py +++ b/tests/execution/backends/test_codex_session_storage.py @@ -67,6 +67,15 @@ def _hold_flock(lock_path: str, ready: object, release: object) -> None: os.close(descriptor) +def test_file_lease_rejects_non_lock_path(tmp_path: Path) -> None: + invalid_path = tmp_path / "lease" + + with pytest.raises(ValueError, match=r"\.lock suffix"): + storage._FileLease.acquire(invalid_path) + + assert not invalid_path.exists() + + def test_fresh_attempt_exposes_empty_view_and_no_child_abort_restores_inert_links( tmp_path: Path, ) -> None: From 4c2c8ec78eb0b6763c9cd4679115a1f4a209e0db Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 18:54:56 -0700 Subject: [PATCH 25/35] fix(review): keep Codex homes backend-owned --- tests/cli/test_cook_env_scrub.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/cli/test_cook_env_scrub.py b/tests/cli/test_cook_env_scrub.py index 45d0965a60..726a6b99dd 100644 --- a/tests/cli/test_cook_env_scrub.py +++ b/tests/cli/test_cook_env_scrub.py @@ -192,3 +192,14 @@ def test_cook_command_env_has_max_mcp_output_tokens( spec = _capture_cook_spec(monkeypatch, tmp_path) env = spec.env assert env["MAX_MCP_OUTPUT_TOKENS"] == SHARED_BASELINE_ENV["MAX_MCP_OUTPUT_TOKENS"] + + +def test_claude_cook_command_env_excludes_codex_home_overrides( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.chdir(tmp_path) + + spec = _capture_cook_spec(monkeypatch, tmp_path) + + assert "CODEX_HOME" not in spec.env + assert "CODEX_SQLITE_HOME" not in spec.env From a8e8f479f1630a805a156367ac1fd3bf4a88a737 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 18:55:54 -0700 Subject: [PATCH 26/35] fix(review): close failed Codex file leases --- .../backends/_codex_session_storage.py | 19 ++++++++++--------- .../backends/test_codex_session_storage.py | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/autoskillit/execution/backends/_codex_session_storage.py b/src/autoskillit/execution/backends/_codex_session_storage.py index bb88f3c8a4..df03936751 100644 --- a/src/autoskillit/execution/backends/_codex_session_storage.py +++ b/src/autoskillit/execution/backends/_codex_session_storage.py @@ -263,17 +263,18 @@ def acquire(cls, lock_path: Path, *, nonblocking: bool = False) -> _FileLease: operation = fcntl.LOCK_EX | (fcntl.LOCK_NB if nonblocking else 0) try: fcntl.flock(instance.fd, operation) + owner = { + "pid": os.getpid(), + "host": socket.gethostname(), + "acquired_ns": time.time_ns(), + } + os.ftruncate(instance.fd, 0) + os.write(instance.fd, json.dumps(owner, sort_keys=True).encode()) + os.fsync(instance.fd) except BaseException: - os.close(instance.fd) + fd, instance.fd = instance.fd, -1 + os.close(fd) raise - owner = { - "pid": os.getpid(), - "host": socket.gethostname(), - "acquired_ns": time.time_ns(), - } - os.ftruncate(instance.fd, 0) - os.write(instance.fd, json.dumps(owner, sort_keys=True).encode()) - os.fsync(instance.fd) return instance def release(self) -> None: diff --git a/tests/execution/backends/test_codex_session_storage.py b/tests/execution/backends/test_codex_session_storage.py index 8ca625a5e6..646eccaf76 100644 --- a/tests/execution/backends/test_codex_session_storage.py +++ b/tests/execution/backends/test_codex_session_storage.py @@ -76,6 +76,24 @@ def test_file_lease_rejects_non_lock_path(tmp_path: Path) -> None: assert not invalid_path.exists() +def test_file_lease_closes_descriptor_when_owner_diagnostic_write_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + lock_path = tmp_path / "lease.lock" + + def fail_ftruncate(_fd: int, _length: int) -> None: + raise OSError("diagnostic write failed") + + with monkeypatch.context() as scoped: + scoped.setattr(storage.os, "ftruncate", fail_ftruncate) + with pytest.raises(OSError, match="diagnostic write failed"): + storage._FileLease.acquire(lock_path) + + lease = storage._FileLease.acquire(lock_path, nonblocking=True) + lease.release() + + def test_fresh_attempt_exposes_empty_view_and_no_child_abort_restores_inert_links( tmp_path: Path, ) -> None: From 3ffa60b448254477fef56e69652475dba01006fa Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 18:57:42 -0700 Subject: [PATCH 27/35] fix(review): validate Codex inventory arrays --- .../backends/test_codex_config_validation.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/execution/backends/test_codex_config_validation.py b/tests/execution/backends/test_codex_config_validation.py index 2b4b24b485..9c91ebf8ff 100644 --- a/tests/execution/backends/test_codex_config_validation.py +++ b/tests/execution/backends/test_codex_config_validation.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import multiprocessing import os import tomllib @@ -13,6 +14,38 @@ pytestmark = [pytest.mark.layer("execution"), pytest.mark.medium] +@pytest.mark.parametrize( + ("field", "value"), + [ + ("args", "--flag"), + ("args", [1]), + ("env_vars", {"TOKEN": "secret"}), + ("env_vars", [None]), + ], +) +def test_mcp_inventory_rejects_non_string_array_fields( + field: str, + value: object, +) -> None: + from autoskillit.execution.backends.codex import _validate_codex_mcp_inventory + + config_bytes = ( + b'[mcp_servers.autoskillit]\ncommand = "autoskillit"\nargs = []\nenv_vars = []\n' + ) + transport: dict[str, object] = { + "type": "stdio", + "command": "autoskillit", + "args": [], + "env_vars": [], + } + transport[field] = value + stdout = json.dumps({"servers": [{"name": "autoskillit", "transport": transport}]}).encode() + + errors = _validate_codex_mcp_inventory(stdout, config_bytes) + + assert any(f"{field} are not an array of strings" in error for error in errors) + + def _config_writer( operation: str, config_path: str, From c50880da09484c06510da60465e10a1b34ec978d Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 18:58:13 -0700 Subject: [PATCH 28/35] test(review): guarantee lifecycle race cleanup --- .../backends/test_codex_session_storage.py | 72 ++++++++++--------- 1 file changed, 40 insertions(+), 32 deletions(-) diff --git a/tests/execution/backends/test_codex_session_storage.py b/tests/execution/backends/test_codex_session_storage.py index 646eccaf76..cd00ff94a3 100644 --- a/tests/execution/backends/test_codex_session_storage.py +++ b/tests/execution/backends/test_codex_session_storage.py @@ -381,39 +381,47 @@ def test_recovery_scans_and_rebuilds_index_inside_lifecycle_lock( args=(str(store.locks_root / "lifecycle.lock"), ready, release), ) holder.start() - assert ready.wait(5) - - lifecycle_requested = threading.Event() - original_acquire = storage._FileLease.acquire.__func__ - - def notifying_acquire( - cls: type[storage._FileLease], - path: Path, - *, - nonblocking: bool = False, - ) -> storage._FileLease: - if path.name == "lifecycle.lock": - lifecycle_requested.set() - return original_acquire(cls, path, nonblocking=nonblocking) - - monkeypatch.setattr(storage._FileLease, "acquire", classmethod(notifying_acquire)) + recovery: threading.Thread | None = None failures: list[BaseException] = [] - - def recover() -> None: - try: - store.recover() - except BaseException as exc: - failures.append(exc) - - recovery = threading.Thread(target=recover) - recovery.start() - assert lifecycle_requested.wait(5) - second = store.archive_root / "2026/07/rollout-second.jsonl" - _rollout(second, "thread-second", cwd=tmp_path) - release.set() - recovery.join(10) - holder.join(10) - + try: + assert ready.wait(5) + + lifecycle_requested = threading.Event() + original_acquire = storage._FileLease.acquire.__func__ + + def notifying_acquire( + cls: type[storage._FileLease], + path: Path, + *, + nonblocking: bool = False, + ) -> storage._FileLease: + if path.name == "lifecycle.lock": + lifecycle_requested.set() + return original_acquire(cls, path, nonblocking=nonblocking) + + monkeypatch.setattr(storage._FileLease, "acquire", classmethod(notifying_acquire)) + + def recover() -> None: + try: + store.recover() + except BaseException as exc: + failures.append(exc) + + recovery = threading.Thread(target=recover, daemon=True) + recovery.start() + assert lifecycle_requested.wait(5) + second = store.archive_root / "2026/07/rollout-second.jsonl" + _rollout(second, "thread-second", cwd=tmp_path) + finally: + release.set() + holder.join(10) + if holder.is_alive(): + holder.terminate() + holder.join(5) + if recovery is not None: + recovery.join(10) + + assert recovery is not None assert not recovery.is_alive() assert holder.exitcode == 0 assert failures == [] From 0f9bfedfdbf743f1dcdbcde8dedc3c294483156a Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 18:59:42 -0700 Subject: [PATCH 29/35] test(review): cover Codex config probe lifecycle --- .../backends/test_codex_config_validation.py | 190 +++++++++++++++++- 1 file changed, 186 insertions(+), 4 deletions(-) diff --git a/tests/execution/backends/test_codex_config_validation.py b/tests/execution/backends/test_codex_config_validation.py index 9c91ebf8ff..84d0f4fc1f 100644 --- a/tests/execution/backends/test_codex_config_validation.py +++ b/tests/execution/backends/test_codex_config_validation.py @@ -5,7 +5,9 @@ import json import multiprocessing import os +import sys import tomllib +from dataclasses import replace from pathlib import Path from typing import Any @@ -13,6 +15,26 @@ pytestmark = [pytest.mark.layer("execution"), pytest.mark.medium] +_VALID_CONFIG_BYTES = ( + b'[mcp_servers.autoskillit]\ncommand = "autoskillit"\nargs = []\nenv_vars = []\n' +) +_VALID_INVENTORY_BYTES = json.dumps( + { + "servers": [ + { + "name": "autoskillit", + "enabled": True, + "transport": { + "type": "stdio", + "command": "autoskillit", + "args": [], + "env_vars": [], + }, + } + ] + } +).encode() + @pytest.mark.parametrize( ("field", "value"), @@ -29,9 +51,6 @@ def test_mcp_inventory_rejects_non_string_array_fields( ) -> None: from autoskillit.execution.backends.codex import _validate_codex_mcp_inventory - config_bytes = ( - b'[mcp_servers.autoskillit]\ncommand = "autoskillit"\nargs = []\nenv_vars = []\n' - ) transport: dict[str, object] = { "type": "stdio", "command": "autoskillit", @@ -41,11 +60,174 @@ def test_mcp_inventory_rejects_non_string_array_fields( transport[field] = value stdout = json.dumps({"servers": [{"name": "autoskillit", "transport": transport}]}).encode() - errors = _validate_codex_mcp_inventory(stdout, config_bytes) + errors = _validate_codex_mcp_inventory(stdout, _VALID_CONFIG_BYTES) assert any(f"{field} are not an array of strings" in error for error in errors) +def test_bounded_codex_probe_captures_success(tmp_path: Path) -> None: + from autoskillit.execution.backends.codex import _run_bounded_codex_probe + + result = _run_bounded_codex_probe( + (sys.executable, "-c", "import os; os.write(1, b'probe-ok')"), + env=os.environ, + cwd=str(tmp_path), + ) + + assert result.returncode == 0 + assert result.stdout == b"probe-ok" + assert result.stderr == b"" + assert result.failure is None + + +def test_bounded_codex_probe_times_out_and_reaps( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from autoskillit.execution.backends import codex + + monkeypatch.setattr(codex, "_CODEX_PROBE_TIMEOUT_SECONDS", 0.05) + + result = codex._run_bounded_codex_probe( + (sys.executable, "-c", "import time; time.sleep(60)"), + env=os.environ, + cwd=str(tmp_path), + ) + + assert result.returncode is None + assert result.failure == "timed out" + + +def test_bounded_codex_probe_enforces_stream_limit( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from autoskillit.execution.backends import codex + + monkeypatch.setattr(codex, "_CODEX_PROBE_STREAM_LIMIT", 128) + + result = codex._run_bounded_codex_probe( + (sys.executable, "-c", "import os; os.write(1, b'x' * 4096)"), + env=os.environ, + cwd=str(tmp_path), + ) + + assert result.returncode is None + assert result.failure == "stdout exceeded 128 bytes" + assert len(result.stdout) == 128 + + +@pytest.mark.parametrize( + ("program", "expected_error"), + [ + ("raise SystemExit(7)", "exited with status 7"), + ("import os; os.write(1, b'not-json')", "returned malformed JSON"), + ], +) +def test_mcp_probe_normalizes_process_and_output_failures( + program: str, + expected_error: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from autoskillit.execution.backends import codex + + monkeypatch.setattr(codex, "_CODEX_VALIDATION_CACHE", {}) + + errors = codex._validate_mcp_probe( + (sys.executable, "-c", program), + env=os.environ, + cwd=str(tmp_path), + config_bytes=_VALID_CONFIG_BYTES, + ) + + assert len(errors) == 1 + assert expected_error in errors[0] + + +def test_mcp_probe_caches_successful_validation( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from autoskillit.execution.backends import codex + + calls = 0 + + def run_probe(*_args: object, **_kwargs: object) -> codex._BoundedProbeResult: + nonlocal calls + calls += 1 + return codex._BoundedProbeResult( + returncode=0, + stdout=_VALID_INVENTORY_BYTES, + stderr=b"", + ) + + monkeypatch.setattr(codex, "_CODEX_VALIDATION_CACHE", {}) + monkeypatch.setattr(codex, "_run_bounded_codex_probe", run_probe) + command = ("codex", "mcp", "list", "--json") + + assert ( + codex._validate_mcp_probe( + command, + env=os.environ, + cwd=str(tmp_path), + config_bytes=_VALID_CONFIG_BYTES, + ) + == [] + ) + assert ( + codex._validate_mcp_probe( + command, + env=os.environ, + cwd=str(tmp_path), + config_bytes=_VALID_CONFIG_BYTES, + ) + == [] + ) + assert calls == 1 + + +def test_real_interactive_validator_reaches_successful_native_probe( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from autoskillit.execution.backends import codex + + generated_home = tmp_path / "generated-home" + generated_home.mkdir() + (generated_home / "config.toml").write_bytes(_VALID_CONFIG_BYTES) + for name in ("sessions", "archived_sessions"): + target = generated_home / f".inert-{name}" + target.mkdir() + (generated_home / name).symlink_to(target) + source_home = tmp_path / "source-home" + source_home.mkdir() + backend = codex.CodexBackend(source_codex_home=source_home) + spec = replace( + backend.build_interactive_cmd(generated_home=generated_home), + cwd=str(tmp_path), + ) + commands: list[tuple[str, ...]] = [] + + def run_probe( + command: tuple[str, ...], + **_kwargs: object, + ) -> codex._BoundedProbeResult: + commands.append(command) + return codex._BoundedProbeResult( + returncode=0, + stdout=_VALID_INVENTORY_BYTES, + stderr=b"", + ) + + monkeypatch.setattr(codex, "_CODEX_VALIDATION_CACHE", {}) + monkeypatch.setattr(codex, "_run_bounded_codex_probe", run_probe) + + assert backend.validate_interactive_invocation(spec) == [] + assert len(commands) == 1 + assert commands[0][-3:] == ("mcp", "list", codex.CodexFlags.JSON) + + def _config_writer( operation: str, config_path: str, From a5a1afcd85dab08433eba80f6219f41084dc8288 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 20:07:03 -0700 Subject: [PATCH 30/35] fix: reconcile session projection after develop merge --- tests/cli/test_input_tty_contracts.py | 42 ++++++++++++++++++- tests/core/test_type_protocol_shards.py | 21 ++++------ .../test_tools_execution_backend_mixing.py | 12 ++++++ tests/server/test_tools_execution_routing.py | 26 ++++++++++-- 4 files changed, 83 insertions(+), 18 deletions(-) diff --git a/tests/cli/test_input_tty_contracts.py b/tests/cli/test_input_tty_contracts.py index d9630c6a5c..8995a65331 100644 --- a/tests/cli/test_input_tty_contracts.py +++ b/tests/cli/test_input_tty_contracts.py @@ -129,12 +129,52 @@ def test_prompt_recipe_choice_noninteractive_exits( def test_cook_noninteractive_exits(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """cook() launch-confirm prompt must raise SystemExit(1) when not interactive.""" + from contextlib import contextmanager + from unittest.mock import MagicMock + from autoskillit.cli.session._session_cook import cook + from autoskillit.core import ( + EffectiveSkillCatalogAuthority, + ManagedSessionHome, + SkillProjectionContextAuthority, + ValidatedAddDir, + ) + from autoskillit.execution.backends.claude import ClaudeCodeBackend + + generated_home = tmp_path / "managed-home" + skills_dir = generated_home / "skills" + skills_dir.mkdir(parents=True) + manager = MagicMock() + + @contextmanager + def managed_session( + launch_id: str, + catalog: EffectiveSkillCatalogAuthority, + projection_context: SkillProjectionContextAuthority, + ): + assert projection_context.catalog == catalog + yield ManagedSessionHome( + launch_id=launch_id, + generated_home=generated_home, + skills_dir=ValidatedAddDir(str(skills_dir)), + pass_fds=(), + ) + + manager.managed_session.side_effect = managed_session monkeypatch.chdir(tmp_path) monkeypatch.setattr("sys.stdin.isatty", lambda: False) + monkeypatch.setattr( + "autoskillit.cli.session._session_cook.shutil.which", + lambda _name: "/usr/bin/claude", + ) + monkeypatch.setattr( + "autoskillit.workspace.DefaultSessionSkillManager", + lambda *args, **kwargs: manager, + ) + monkeypatch.setattr("autoskillit.cli._onboarding.is_first_run", lambda _: False) with pytest.raises(SystemExit) as exc_info: - cook() + cook(backend=ClaudeCodeBackend()) assert exc_info.value.code == 1 diff --git a/tests/core/test_type_protocol_shards.py b/tests/core/test_type_protocol_shards.py index 7e69461208..9df750fad8 100644 --- a/tests/core/test_type_protocol_shards.py +++ b/tests/core/test_type_protocol_shards.py @@ -60,33 +60,28 @@ def test_session_skill_manager_managed_session_signature(): import inspect import typing from contextlib import AbstractContextManager - from pathlib import Path from autoskillit.core import ( - CodingAgentBackend, + EffectiveSkillCatalogAuthority, ManagedSessionHome, SessionSkillManager, + SkillProjectionContextAuthority, ) signature = inspect.signature(SessionSkillManager.managed_session) assert tuple(signature.parameters) == ( "self", "session_id", - "cook_session", - "config", - "project_dir", - "backend", + "catalog", + "projection_context", ) - assert signature.parameters["session_id"].kind is inspect.Parameter.POSITIONAL_OR_KEYWORD - for name in ("cook_session", "config", "project_dir", "backend"): - assert signature.parameters[name].kind is inspect.Parameter.KEYWORD_ONLY + for name in ("session_id", "catalog", "projection_context"): + assert signature.parameters[name].kind is inspect.Parameter.POSITIONAL_OR_KEYWORD assert signature.parameters[name].default is inspect.Parameter.empty assert typing.get_type_hints(SessionSkillManager.managed_session) == { "session_id": str, - "cook_session": bool, - "config": typing.Any, - "project_dir": Path, - "backend": CodingAgentBackend, + "catalog": EffectiveSkillCatalogAuthority, + "projection_context": SkillProjectionContextAuthority, "return": AbstractContextManager[ManagedSessionHome], } diff --git a/tests/server/test_tools_execution_backend_mixing.py b/tests/server/test_tools_execution_backend_mixing.py index 4707a862ae..412379f500 100644 --- a/tests/server/test_tools_execution_backend_mixing.py +++ b/tests/server/test_tools_execution_backend_mixing.py @@ -516,7 +516,19 @@ async def test_github_api_write_capability_skill_not_auto_routed( fake_backend.capabilities = concrete_backend.capabilities fake_backend.conventions = concrete_backend.conventions fake_backend.ensure_pre_launch.return_value = [] + fake_backend.validate_session_layout.return_value = [] + fake_backend.session_locator.return_value.project_log_dir.return_value = None tool_ctx_kitchen_open.backend = fake_backend + from autoskillit.workspace import ( + DefaultSessionSkillManager, + SkillsDirectoryProvider, + ) + + tool_ctx_kitchen_open.session_skill_manager = DefaultSessionSkillManager( + SkillsDirectoryProvider(), + ephemeral_root=tmp_path / "ephemeral-sessions", + persistent_root=tmp_path / "persistent-sessions", + ) _install_skill_invocation( tool_ctx_kitchen_open, name="test-skill", diff --git a/tests/server/test_tools_execution_routing.py b/tests/server/test_tools_execution_routing.py index 4a63de82c8..522ae38917 100644 --- a/tests/server/test_tools_execution_routing.py +++ b/tests/server/test_tools_execution_routing.py @@ -727,9 +727,13 @@ async def test_process_issues_l2_parent_executes_session_child_on_each_backend( import json from unittest.mock import MagicMock - from autoskillit.core import SkillExecutionRole + from autoskillit.core import CodingAgentBackend, SkillExecutionRole from autoskillit.execution.backends import get_backend - from autoskillit.workspace import DefaultSkillResolver + from autoskillit.workspace import ( + DefaultSessionSkillManager, + DefaultSkillResolver, + SkillsDirectoryProvider, + ) from tests.fakes import InMemoryHeadlessExecutor resolver = DefaultSkillResolver() @@ -741,12 +745,26 @@ async def test_process_issues_l2_parent_executes_session_child_on_each_backend( assert parent.root.execution_role is SkillExecutionRole.ORCHESTRATOR assert "run_skill" in parent.capability_union - manager = MagicMock(wraps=tool_ctx_kitchen_open.session_skill_manager) + concrete_backend = get_backend(backend_name) + backend = MagicMock(spec=CodingAgentBackend) + backend.name = concrete_backend.name + backend.capabilities = concrete_backend.capabilities + backend.conventions = concrete_backend.conventions + backend.ensure_pre_launch.return_value = [] + backend.validate_session_layout.return_value = [] + backend.session_locator.return_value.project_log_dir.return_value = None + manager = MagicMock( + wraps=DefaultSessionSkillManager( + SkillsDirectoryProvider(), + ephemeral_root=tmp_path / "ephemeral-sessions", + persistent_root=tmp_path / "persistent-sessions", + ) + ) executor = InMemoryHeadlessExecutor() tool_ctx_kitchen_open.skill_resolver = resolver tool_ctx_kitchen_open.session_skill_manager = manager tool_ctx_kitchen_open.executor = executor - tool_ctx_kitchen_open.backend = get_backend(backend_name) + tool_ctx_kitchen_open.backend = backend monkeypatch.setenv("AUTOSKILLIT_HEADLESS", "1") monkeypatch.setenv("AUTOSKILLIT_SESSION_TYPE", "orchestrator") monkeypatch.setattr("autoskillit.server._ctx", tool_ctx_kitchen_open) From 7e599f7d2c2470cb6d212c795e52e33e541f0f79 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 20:59:26 -0700 Subject: [PATCH 31/35] fix(review): reject non-regular Codex lease files --- .../execution/backends/_codex_session_storage.py | 2 ++ tests/execution/backends/test_codex_session_storage.py | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/src/autoskillit/execution/backends/_codex_session_storage.py b/src/autoskillit/execution/backends/_codex_session_storage.py index df03936751..684dc4e994 100644 --- a/src/autoskillit/execution/backends/_codex_session_storage.py +++ b/src/autoskillit/execution/backends/_codex_session_storage.py @@ -262,6 +262,8 @@ def acquire(cls, lock_path: Path, *, nonblocking: bool = False) -> _FileLease: ) operation = fcntl.LOCK_EX | (fcntl.LOCK_NB if nonblocking else 0) try: + if not stat.S_ISREG(os.fstat(instance.fd).st_mode): + raise RuntimeError(f"Lock path is not a regular file: {lock_path}") fcntl.flock(instance.fd, operation) owner = { "pid": os.getpid(), diff --git a/tests/execution/backends/test_codex_session_storage.py b/tests/execution/backends/test_codex_session_storage.py index cd00ff94a3..50d65b1c93 100644 --- a/tests/execution/backends/test_codex_session_storage.py +++ b/tests/execution/backends/test_codex_session_storage.py @@ -76,6 +76,14 @@ def test_file_lease_rejects_non_lock_path(tmp_path: Path) -> None: assert not invalid_path.exists() +def test_file_lease_rejects_non_regular_lock_inode(tmp_path: Path) -> None: + lock_path = tmp_path / "lease.lock" + os.mkfifo(lock_path) + + with pytest.raises(RuntimeError, match="not a regular file"): + storage._FileLease.acquire(lock_path) + + def test_file_lease_closes_descriptor_when_owner_diagnostic_write_fails( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, From b5d4cfb249aa3fbdca91549e3097ac512d9f9de2 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 21:01:19 -0700 Subject: [PATCH 32/35] fix(review): terminate Codex probe process groups --- .../backends/test_codex_config_validation.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/execution/backends/test_codex_config_validation.py b/tests/execution/backends/test_codex_config_validation.py index 84d0f4fc1f..916e4e3769 100644 --- a/tests/execution/backends/test_codex_config_validation.py +++ b/tests/execution/backends/test_codex_config_validation.py @@ -5,6 +5,7 @@ import json import multiprocessing import os +import signal import sys import tomllib from dataclasses import replace @@ -98,6 +99,42 @@ def test_bounded_codex_probe_times_out_and_reaps( assert result.failure == "timed out" +def test_bounded_codex_probe_owns_and_kills_process_group( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from autoskillit.execution.backends import codex + + original_popen = codex.subprocess.Popen + original_killpg = codex.os.killpg + processes: list[codex.subprocess.Popen[bytes]] = [] + group_signals: list[tuple[int, signal.Signals]] = [] + + def recording_popen(*args: object, **kwargs: Any) -> codex.subprocess.Popen[bytes]: + assert kwargs["start_new_session"] is True + process = original_popen(*args, **kwargs) + processes.append(process) + return process + + def recording_killpg(pgid: int, sig: signal.Signals) -> None: + group_signals.append((pgid, sig)) + original_killpg(pgid, sig) + + monkeypatch.setattr(codex, "_CODEX_PROBE_TIMEOUT_SECONDS", 0.05) + monkeypatch.setattr(codex.subprocess, "Popen", recording_popen) + monkeypatch.setattr(codex.os, "killpg", recording_killpg) + + result = codex._run_bounded_codex_probe( + (sys.executable, "-c", "import time; time.sleep(60)"), + env=os.environ, + cwd=str(tmp_path), + ) + + assert result.failure == "timed out" + assert group_signals == [(processes[0].pid, signal.SIGKILL)] + assert processes[0].poll() is not None + + def test_bounded_codex_probe_enforces_stream_limit( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, From f5ea3cc91ea5d4026f61495839ea08aa6a4f472b Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 21:25:50 -0700 Subject: [PATCH 33/35] fix: reconcile session skill budget guards --- tests/arch/test_subpackage_isolation.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/tests/arch/test_subpackage_isolation.py b/tests/arch/test_subpackage_isolation.py index d6d127dc1b..6b8c87a32c 100644 --- a/tests/arch/test_subpackage_isolation.py +++ b/tests/arch/test_subpackage_isolation.py @@ -1131,9 +1131,11 @@ def test_data_directories_are_not_python_packages() -> None: ), "workspace/session_skills.py": ( 1400, - "REQ-CNST-010-E14: session skill materialization owns the generated-home lease and " - "cleanup transaction as well as backend-specific layout validation; keeping those " - "operations together preserves the create/validate/yield/delete ownership proof", + "REQ-CNST-010-E13/E14: ordering-sensitive session skill materialization owns " + "provider discovery, override precedence, filtering, dependency activation, the " + "generated-home lease and cleanup transaction, and backend-specific layout " + "validation; keeping those operations together preserves both assembly ordering " + "and the create/validate/yield/delete ownership proof", ), "rules_skill_content.py": ( 1200, @@ -1164,13 +1166,6 @@ def test_data_directories_are_not_python_packages() -> None: "algorithm across modules that must remain the single source of " "truth for the bound-vs-deprioritized budget allocation order.", ), - "workspace/session_skills.py": ( - 1200, - "REQ-CNST-010-E13: ordering-sensitive session skill assembly — provider " - "discovery, override precedence, filtering, dependency activation, and lifecycle " - "cleanup remain in one manager; semantic validation and projection are already " - "isolated in dedicated workspace modules.", - ), "core/context_admission.py": ( 2950, "REQ-CNST-010-E13: #4333 freezes one exhaustive protocol-v1 reducer and replay " From f3e93cdb9e736ec79ebcdf5db2443098a3165a7d Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 08:24:44 -0700 Subject: [PATCH 34/35] fix: remove stale public imports after source projection cleanup --- src/autoskillit/core/__init__.pyi | 1 - src/autoskillit/execution/backends/claude.py | 2 -- src/autoskillit/execution/backends/codex.py | 3 --- src/autoskillit/server/_factory.py | 1 - 4 files changed, 7 deletions(-) diff --git a/src/autoskillit/core/__init__.pyi b/src/autoskillit/core/__init__.pyi index 7532cf3780..053bf35075 100644 --- a/src/autoskillit/core/__init__.pyi +++ b/src/autoskillit/core/__init__.pyi @@ -447,7 +447,6 @@ from .types import LensEntry as LensEntry from .types import LoadReport as LoadReport from .types import LoadResult as LoadResult from .types import ManagedSessionHome as ManagedSessionHome -from .types import MarketplaceInstall as MarketplaceInstall from .types import MarkGenerationIndeterminateEvent as MarkGenerationIndeterminateEvent from .types import MarkIndeterminateEvent as MarkIndeterminateEvent from .types import McpResponseLog as McpResponseLog diff --git a/src/autoskillit/execution/backends/claude.py b/src/autoskillit/execution/backends/claude.py index c96208b7ec..6b2ecfb36b 100644 --- a/src/autoskillit/execution/backends/claude.py +++ b/src/autoskillit/execution/backends/claude.py @@ -36,8 +36,6 @@ ClaudeFlags, CmdSpec, CookSessionHandle, - DirectInstall, - MarketplaceInstall, NamedResume, NoResume, OutputFormat, diff --git a/src/autoskillit/execution/backends/codex.py b/src/autoskillit/execution/backends/codex.py index d89c679981..f2dcfefc49 100644 --- a/src/autoskillit/execution/backends/codex.py +++ b/src/autoskillit/execution/backends/codex.py @@ -53,11 +53,8 @@ CapabilityNotSupportedError, ClaudeDirectoryConventions, CmdSpec, - CodexEventType, CookSessionHandle, - DirectInstall, HookTrustPolicy, - MarketplaceInstall, NamedResume, NoResume, ObserverStatus, diff --git a/src/autoskillit/server/_factory.py b/src/autoskillit/server/_factory.py index 6bef282def..e8018f52cd 100644 --- a/src/autoskillit/server/_factory.py +++ b/src/autoskillit/server/_factory.py @@ -17,7 +17,6 @@ from autoskillit.config import AutomationConfig from autoskillit.core import ( - MARKETPLACE_PREFIX, DirectInstall, FleetLock, PluginSource, From 5bf2b0a241a339f19c40eb058c8b5958379f1c46 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 08:32:33 -0700 Subject: [PATCH 35/35] fix: refresh architectural guard baselines --- tests/arch/test_subpackage_structure.py | 4 ++-- tests/infra/test_schema_version_convention.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/arch/test_subpackage_structure.py b/tests/arch/test_subpackage_structure.py index 0012160c4c..8533cfa56f 100644 --- a/tests/arch/test_subpackage_structure.py +++ b/tests/arch/test_subpackage_structure.py @@ -65,8 +65,8 @@ def test_type_constants_split_completeness(self): assert len(combined) == len(remaining) + len(env) + len(features) + len(registries), ( "Duplicate symbols across split modules" ) - assert len(combined) == 130, ( - f"Expected 130 symbols total, got {len(combined)} " + assert len(combined) == 132, ( + f"Expected 132 symbols total, got {len(combined)} " f"(remaining={len(remaining)}, env={len(env)}, " f"features={len(features)}, registries={len(registries)})" ) diff --git a/tests/infra/test_schema_version_convention.py b/tests/infra/test_schema_version_convention.py index b466e0d2ce..2bb3293cf0 100644 --- a/tests/infra/test_schema_version_convention.py +++ b/tests/infra/test_schema_version_convention.py @@ -117,7 +117,7 @@ def _is_yaml_dump(node: ast.expr) -> bool: # staleness_cache.py — cache dict ("src/autoskillit/recipe/staleness_cache.py", 67), # _lifespan.py — hooks.json self-heal on startup drift (co-owned with Claude plugin system) - ("src/autoskillit/server/_lifespan.py", 97), + ("src/autoskillit/server/_lifespan.py", 90), # tools_kitchen.py — hook config, quota guard, git_ops_policy, ingredient locks overlay ("src/autoskillit/server/tools/tools_kitchen.py", 283), ("src/autoskillit/server/tools/tools_kitchen.py", 302),