Skip to content

Commit 6f93702

Browse files
Trecekclaude
andauthored
feat(backends): introduce CompositeSessionLocator dispatch point (#4101)
## Summary Introduce a `CompositeSessionLocator` in `execution/backends/_composite_locator.py` that provides a single dispatch point for session log resolution across backends, replacing the dual `codex_log_path`/`ClaudeSessionLocator` branching in `flush_session_log`. The composite iterates `BACKEND_REGISTRY` to locate sessions, supports per-backend dispatch via `locator_for(backend)`, and (remediation) wraps each backend's call in `try/except Exception` with structured debug logging so a failing backend cannot abort the scan. <details> <summary>Individual Group Plans</summary> ### Group 1: T5-P4-A6-WP2 Provide a single SessionLocator dispatch point Create a `CompositeSessionLocator` in `execution/backends/_composite_locator.py` that iterates `BACKEND_REGISTRY` to locate session logs, then replace the dual `codex_log_path`/`ClaudeSessionLocator` branching in `flush_session_log` with backend-parameter-driven dispatch through this composite. Remove the `codex_log_path` parameter from `flush_session_log` and the pre-computation block from `_headless_execute.py`. Update tests to use explicit `backend=` dispatch instead of `codex_log_path=`. ### Group 2: T5-P4-A6-WP2 Remediation — Per-backend exception handling in CompositeSessionLocator.locate_session Add per-backend `try/except Exception` with structured debug logging inside `CompositeSessionLocator.locate_session` so that if one backend's locator raises, the scan continues to the next backend rather than aborting. Add a test verifying this resilient-iteration behavior. </details> Closes #4024 ## Implementation Plan Plan files: - `/home/talon/projects/autoskillit-runs/impl-20260612-201007-221351/.autoskillit/temp/make-plan/t5_p4_a6_wp2_composite_session_locator_plan_2026-06-12_201500.md` - `/home/talon/projects/autoskillit-runs/impl-20260612-201007-221351/.autoskillit/temp/make-plan/t5_p4_a6_wp2_remediation_composite_locator_plan_2026-06-12_202600.md` 🤖 Generated with [Claude Code](https://claude.com/claude-code) via AutoSkillit <!-- autoskillit:pipeline-signature steps=prepare_pr,run_arch_lenses,compose_pr,annotate_pr_diff,review_pr --> ## Token Usage Summary | Step | Model | count | uncached | output | cache_read | peak_ctx | turns | cache_write | time | |------|-------|-------|----------|--------|------------|----------|-------|-------------|------| | plan* | opus[1m] | 2 | 6.5k | 58.5k | 3.1M | 136.9k | 101 | 177.1k | 24m 29s | | verify* | sonnet | 2 | 934 | 26.5k | 701.1k | 80.8k | 47 | 88.2k | 9m 46s | | implement* | MiniMax-M3 | 2 | 4.5M | 19.1k | 0 | 0 | 136 | 0 | 12m 53s | | fix* | sonnet | 1 | 382 | 38.2k | 4.5M | 126.2k | 127 | 105.2k | 18m 0s | | audit_impl* | sonnet | 2 | 1.4k | 30.4k | 722.7k | 61.9k | 46 | 90.8k | 15m 16s | | retry_worktree* | sonnet | 1 | 68 | 1.8k | 278.1k | 42.8k | 16 | 21.9k | 2m 23s | | prepare_pr* | MiniMax-M3 | 1 | 258.4k | 3.4k | 0 | 0 | 17 | 0 | 1m 12s | | compose_pr* | MiniMax-M3 | 1 | 287.9k | 2.2k | 0 | 0 | 16 | 0 | 1m 0s | | **Total** | | | 5.1M | 180.1k | 9.3M | 136.9k | | 483.3k | 1h 25m | \* *Step used a non-Anthropic provider; caching behavior may differ.* ## Token Efficiency | Step | LoC Changed | cache_read/LoC | cache_write/LoC | output/LoC | |------|-------------|----------------|-----------------|------------| | plan | 0 | — | — | — | | verify | 0 | — | — | — | | implement | 288 | 0.0 | 0.0 | 66.4 | | fix | 18 | 247544.2 | 5846.3 | 2124.1 | | audit_impl | 0 | — | — | — | | retry_worktree | 0 | — | — | — | | prepare_pr | 0 | — | — | — | | compose_pr | 0 | — | — | — | | **Total** | **306** | 30255.8 | 1579.4 | 588.4 | ## Model Usage Breakdown | Model | steps | uncached | output | cache_read | cache_write | time | |-------|-------|----------|--------|------------|-------------|------| | opus[1m] | 1 | 6.5k | 58.5k | 3.1M | 177.1k | 24m 29s | | sonnet | 4 | 2.8k | 96.9k | 6.2M | 306.2k | 45m 26s | | MiniMax-M3 | 3 | 5.1M | 24.7k | 0 | 0 | 15m 7s | --------- Co-authored-by: claude <claude@anthropic.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 946c3c4 commit 6f93702

10 files changed

Lines changed: 277 additions & 28 deletions

File tree

src/autoskillit/execution/backends/AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ IL-1 backend abstraction layer — concrete `CodingAgentBackend` implementations
88
|------|---------|
99
| `__init__.py` | `BACKEND_REGISTRY`, `get_backend()` factory, re-exports |
1010
| `_backend_cmd_builder_base.py` | `BackendCmdBuilderBase` ABC (frozen dataclass), `FlagVocabulary` NamedTuple, `SHARED_BASELINE_ENV` — canonical location for shared env-assembly keys |
11+
| `_composite_locator.py` | `CompositeSessionLocator` — single dispatch point iterating BACKEND_REGISTRY for session location |
1112
| `claude.py` | `ClaudeCodeBackend` (incl. `validate_session_layout`), `ClaudeEnvPolicy`, `ClaudeSessionLocator`, `ClaudeStreamParser`, `ClaudeResultParser` (prompt utilities moved to `_claude_prompt.py`) |
1213
| `_claude_prompt.py` | Prompt injection utilities, session constants (`_ensure_skill_prefix`, `_inject_completion_directive`, `_compose_resume_prompt`, etc.), shared by claude + codex + commands |
1314
| `codex.py` | `CodexFlags`, `CodexBackend` (incl. `validate_session_layout`, `setup_session_dir` agent TOML generation via `_generate_agent_tomls`), `CodexEnvPolicy`, `CodexSessionLocator` (parse/config moved to `_codex_parse.py` / `_codex_config.py`) |

src/autoskillit/execution/backends/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
sync_hooks_to_codex_config,
2121
)
2222
from ._codex_parse import CodexResultParser, CodexStreamParser
23+
from ._composite_locator import CompositeSessionLocator
2324
from .claude import (
2425
ClaudeCodeBackend,
2526
ClaudeEnvPolicy,
@@ -62,6 +63,7 @@ def get_backend(name: str) -> CodingAgentBackend:
6263
"BACKEND_REGISTRY",
6364
"CODEX_EXEC_FLAGS",
6465
"CODEX_TOP_LEVEL_ONLY_FLAGS",
66+
"CompositeSessionLocator",
6567
"_is_autoskillit_hook_entry",
6668
"generate_codex_hooks_config",
6769
"sync_hooks_to_codex_config",
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass, field
4+
from pathlib import Path
5+
from typing import TYPE_CHECKING
6+
7+
from autoskillit.core import get_logger
8+
9+
if TYPE_CHECKING:
10+
from autoskillit.core import SessionLocator
11+
12+
13+
logger = get_logger(__name__)
14+
15+
16+
@dataclass(frozen=True, slots=True)
17+
class CompositeSessionLocator:
18+
_locators: tuple[SessionLocator, ...] = field(default=(), repr=False)
19+
20+
def locate_session(self, session_id: str) -> Path | None:
21+
if not session_id or session_id.startswith(("no_session_", "crashed_")):
22+
return None
23+
if self._locators:
24+
locators: tuple[SessionLocator, ...] = self._locators
25+
for locator in locators:
26+
try:
27+
result = locator.locate_session(session_id)
28+
except Exception:
29+
logger.debug("session_locate_failed", exc_info=True)
30+
continue
31+
if result is not None:
32+
return result
33+
return None
34+
from autoskillit.execution.backends import BACKEND_REGISTRY
35+
36+
for backend_name, cls in BACKEND_REGISTRY.items():
37+
try:
38+
result = cls().session_locator().locate_session(session_id)
39+
except Exception:
40+
logger.debug("session_locate_failed", backend=backend_name, exc_info=True)
41+
continue
42+
if result is not None:
43+
return result
44+
return None
45+
46+
def project_log_dir(self, cwd: str) -> Path:
47+
return self.project_log_dir_for(cwd, "claude-code")
48+
49+
def project_log_dir_for(self, cwd: str, backend_name: str) -> Path:
50+
from autoskillit.execution.backends import BACKEND_REGISTRY
51+
52+
cls = BACKEND_REGISTRY.get(backend_name)
53+
if cls is None:
54+
valid = ", ".join(sorted(BACKEND_REGISTRY))
55+
msg = f"Unknown backend {backend_name!r}. Valid names: {valid}"
56+
raise ValueError(msg)
57+
return cls().session_locator().project_log_dir(cwd)
58+
59+
def session_log_path(self, cwd: str, session_id: str) -> Path | None:
60+
return self.locate_session(session_id)
61+
62+
def locator_for(self, backend_name: str) -> SessionLocator:
63+
from autoskillit.execution.backends import BACKEND_REGISTRY
64+
65+
cls = BACKEND_REGISTRY.get(backend_name)
66+
if cls is None:
67+
valid = ", ".join(sorted(BACKEND_REGISTRY))
68+
msg = f"Unknown backend {backend_name!r}. Valid names: {valid}"
69+
raise ValueError(msg)
70+
return cls().session_locator()

src/autoskillit/execution/headless/_headless_execute.py

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,7 @@ async def _execute_claude_headless(
311311
max_sessions=ctx.config.linux_tracing.max_sessions,
312312
model_identity=model_identity,
313313
backend=cast(Literal["claude-code", "codex"], _step_backend.name),
314+
channel_b_capable=_step_backend.capabilities.channel_b_capable,
314315
comm_aliases=_step_backend.capabilities.process_name_aliases,
315316
telemetry=_build_error_path_telemetry(
316317
ctx.github_api_log,
@@ -374,6 +375,7 @@ async def _execute_claude_headless(
374375
max_sessions=ctx.config.linux_tracing.max_sessions,
375376
model_identity=model_identity,
376377
backend=cast(Literal["claude-code", "codex"], _step_backend.name),
378+
channel_b_capable=_step_backend.capabilities.channel_b_capable,
377379
comm_aliases=_step_backend.capabilities.process_name_aliases,
378380
telemetry=_build_error_path_telemetry(
379381
ctx.github_api_log,
@@ -496,15 +498,6 @@ async def _execute_claude_headless(
496498
):
497499
from autoskillit.execution.session_log import flush_session_log
498500

499-
_codex_log: Path | None = None
500-
if not _step_backend.capabilities.channel_b_capable and skill_result.session_id:
501-
try:
502-
_codex_log = _step_backend.session_locator().locate_session(
503-
skill_result.session_id
504-
)
505-
except Exception:
506-
logger.debug("codex_session_locate_failed", exc_info=True)
507-
508501
try:
509502
flush_session_log(
510503
log_dir=ctx.config.linux_tracing.log_dir,
@@ -574,8 +567,8 @@ async def _execute_claude_headless(
574567
max_sessions=ctx.config.linux_tracing.max_sessions,
575568
model_identity=model_identity,
576569
is_resume=spec.is_resume,
577-
codex_log_path=_codex_log,
578570
backend=cast(Literal["claude-code", "codex"], _step_backend.name),
571+
channel_b_capable=_step_backend.capabilities.channel_b_capable,
579572
)
580573
except Exception:
581574
logger.debug("session_log_flush_failed", exc_info=True)

src/autoskillit/execution/session_log.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -151,9 +151,9 @@ def flush_session_log(
151151
model_identity: ModelIdentity = ModelIdentity.unknown(),
152152
max_sessions: int | None = None,
153153
is_resume: bool = False,
154-
codex_log_path: Path | None = None,
155154
session_locator: SessionLocator | None = None,
156155
backend: Literal["claude-code", "codex"] = "claude-code",
156+
channel_b_capable: bool = True,
157157
telemetry: SessionTelemetry,
158158
) -> None:
159159
"""Flush session diagnostics to disk.
@@ -183,17 +183,28 @@ def flush_session_log(
183183
else:
184184
dir_name = f"no_session_{start_ts.replace(':', '-')}"
185185

186-
if codex_log_path is not None:
187-
cc_log = None
188-
cc_log_str = None
186+
from autoskillit.execution.backends import CompositeSessionLocator
187+
188+
_codex_log_str: str | None = None
189+
190+
if session_locator is not None:
191+
_locator = session_locator
189192
else:
190-
from autoskillit.execution.backends.claude import (
191-
ClaudeSessionLocator,
192-
) # deferred: avoids circular import via backends/claude.py
193+
_composite = CompositeSessionLocator()
194+
_locator = _composite.locator_for(backend)
193195

194-
_locator = session_locator or ClaudeSessionLocator()
196+
if channel_b_capable:
195197
cc_log = _locator.session_log_path(cwd, session_id)
196198
cc_log_str = str(cc_log) if cc_log else None
199+
else:
200+
cc_log = None
201+
cc_log_str = None
202+
if session_id and not session_id.startswith(("no_session_", "crashed_")):
203+
try:
204+
_codex_found = _locator.locate_session(session_id)
205+
_codex_log_str = str(_codex_found) if _codex_found else None
206+
except Exception:
207+
logger.debug("session_locate_failed", backend=backend, exc_info=True)
197208

198209
if cc_log and not cc_log.exists():
199210
logger.warning("claude_code_log_not_found", path=cc_log_str, session_id=session_id)
@@ -506,7 +517,7 @@ def flush_session_log(
506517
"campaign_id": campaign_id,
507518
"dispatch_id": dispatch_id,
508519
"claude_code_log": cc_log_str,
509-
"codex_log": str(codex_log_path) if codex_log_path else None,
520+
"codex_log": _codex_log_str,
510521
"skill_command": skill_command,
511522
"success": success,
512523
"subtype": subtype,

tests/arch/test_subpackage_isolation.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -878,6 +878,7 @@ def test_no_subpackage_exceeds_10_files() -> None:
878878
"recipe/rules": 51, # +commit_guard_regression_route +rules_model +rules_gitignored_deliverable # noqa: E501
879879
"server/tools": 27, # +_preflight.py (dispatch-feasibility preflight)
880880
"hooks/guards": 32, # +fleet_claim_guard, +reset_resume_gate, +recipe_read_guard
881+
"execution/backends": 11, # +_composite_locator.py (CompositeSessionLocator)
881882
}
882883
violations: list[str] = []
883884
dirs_to_check: list[Path] = []

tests/execution/backends/test_backend_registry.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ def test_all_exports_complete(self) -> None:
4747
"CODEX_MCP_TOOL_TIMEOUT_FLOOR",
4848
"CODEX_TOOL_OUTPUT_TOKEN_LIMIT",
4949
"CODEX_TOP_LEVEL_ONLY_FLAGS",
50+
"CompositeSessionLocator",
5051
"ClaudeCodeBackend",
5152
"ClaudeEnvPolicy",
5253
"ClaudeResultParser",
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import FrozenInstanceError
4+
from pathlib import Path
5+
from unittest.mock import Mock
6+
7+
import pytest
8+
9+
from autoskillit.core import SessionLocator
10+
from autoskillit.execution.backends import CompositeSessionLocator
11+
12+
pytestmark = [pytest.mark.layer("execution"), pytest.mark.small]
13+
14+
15+
def _fake_backend(locator: SessionLocator) -> type:
16+
backend = Mock()
17+
backend.session_locator.return_value = locator
18+
cls = Mock(return_value=backend)
19+
return cls
20+
21+
22+
class _StubLocator:
23+
def __init__(self, locate_result: Path | None = None, log_dir: Path | None = None):
24+
self._locate = locate_result
25+
self._log_dir = log_dir or Path("/stub")
26+
27+
def locate_session(self, session_id: str) -> Path | None:
28+
return self._locate
29+
30+
def project_log_dir(self, cwd: str) -> Path:
31+
return self._log_dir
32+
33+
def session_log_path(self, cwd: str, session_id: str) -> Path | None:
34+
return self._locate
35+
36+
37+
class TestLocateSession:
38+
def test_returns_first_non_none(self, monkeypatch):
39+
hit = Path("/found/session.jsonl")
40+
registry = {
41+
"miss": _fake_backend(_StubLocator(None)),
42+
"hit": _fake_backend(_StubLocator(hit)),
43+
}
44+
import autoskillit.execution.backends as backends_mod
45+
46+
monkeypatch.setattr(backends_mod, "BACKEND_REGISTRY", registry)
47+
48+
assert CompositeSessionLocator().locate_session("sid-1") == hit
49+
50+
def test_returns_none_when_all_miss(self, monkeypatch):
51+
registry = {"a": _fake_backend(_StubLocator(None))}
52+
import autoskillit.execution.backends as backends_mod
53+
54+
monkeypatch.setattr(backends_mod, "BACKEND_REGISTRY", registry)
55+
56+
assert CompositeSessionLocator().locate_session("sid-1") is None
57+
58+
@pytest.mark.parametrize("sid", ["", "no_session_123", "crashed_456"])
59+
def test_guards_invalid_ids(self, sid):
60+
assert CompositeSessionLocator().locate_session(sid) is None
61+
62+
def test_skips_failing_backend(self, monkeypatch):
63+
class _FailLocator:
64+
def locate_session(self, session_id):
65+
raise RuntimeError("backend unavailable")
66+
67+
def project_log_dir(self, cwd):
68+
return Path("/stub")
69+
70+
def session_log_path(self, cwd, session_id):
71+
return None
72+
73+
hit = Path("/found/session.jsonl")
74+
registry = {
75+
"broken": _fake_backend(_FailLocator()),
76+
"good": _fake_backend(_StubLocator(hit)),
77+
}
78+
import autoskillit.execution.backends as backends_mod
79+
80+
monkeypatch.setattr(backends_mod, "BACKEND_REGISTRY", registry)
81+
82+
assert CompositeSessionLocator().locate_session("sid-1") == hit
83+
84+
85+
class TestProjectLogDirFor:
86+
def test_dispatches_by_name(self, monkeypatch):
87+
expected = Path("/logs/claude")
88+
registry = {"claude-code": _fake_backend(_StubLocator(log_dir=expected))}
89+
import autoskillit.execution.backends as backends_mod
90+
91+
monkeypatch.setattr(backends_mod, "BACKEND_REGISTRY", registry)
92+
93+
assert CompositeSessionLocator().project_log_dir_for("/cwd", "claude-code") == expected
94+
95+
def test_raises_for_unknown(self, monkeypatch):
96+
import autoskillit.execution.backends as backends_mod
97+
98+
monkeypatch.setattr(backends_mod, "BACKEND_REGISTRY", {})
99+
100+
with pytest.raises(ValueError, match="Unknown backend"):
101+
CompositeSessionLocator().project_log_dir_for("/cwd", "unknown")
102+
103+
104+
class TestSessionLogPath:
105+
def test_delegates_to_locate_session(self, monkeypatch):
106+
hit = Path("/found/session.jsonl")
107+
registry = {"a": _fake_backend(_StubLocator(hit))}
108+
import autoskillit.execution.backends as backends_mod
109+
110+
monkeypatch.setattr(backends_mod, "BACKEND_REGISTRY", registry)
111+
112+
assert CompositeSessionLocator().session_log_path("/cwd", "sid") == hit
113+
114+
115+
class TestLocatorFor:
116+
def test_returns_backend_locator(self, monkeypatch):
117+
stub = _StubLocator(Path("/x"))
118+
registry = {"claude-code": _fake_backend(stub)}
119+
import autoskillit.execution.backends as backends_mod
120+
121+
monkeypatch.setattr(backends_mod, "BACKEND_REGISTRY", registry)
122+
123+
result = CompositeSessionLocator().locator_for("claude-code")
124+
assert result is stub
125+
126+
def test_raises_for_unknown(self, monkeypatch):
127+
import autoskillit.execution.backends as backends_mod
128+
129+
monkeypatch.setattr(backends_mod, "BACKEND_REGISTRY", {})
130+
131+
with pytest.raises(ValueError, match="Unknown backend"):
132+
CompositeSessionLocator().locator_for("unknown")
133+
134+
135+
class TestInvariants:
136+
def test_frozen(self):
137+
loc = CompositeSessionLocator()
138+
with pytest.raises((FrozenInstanceError, TypeError)):
139+
loc.x = 1 # type: ignore[attr-defined]
140+
141+
def test_protocol_conformance(self):
142+
assert isinstance(CompositeSessionLocator(), SessionLocator)

tests/execution/test_backend_dispatch.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from __future__ import annotations
44

5-
from pathlib import Path
65
from unittest.mock import AsyncMock, patch
76

87
import pytest
@@ -339,8 +338,6 @@ async def test_channel_b_false_dispatches_codex_log_via_locate_session(
339338
)
340339
backend = _mock_backend(channel_b_capable=False, process_name="codex")
341340
backend.name = "codex"
342-
sentinel = Path("/sentinel/codex.jsonl")
343-
backend.session_locator().locate_session.return_value = sentinel
344341

345342
fake_runner, flush_calls = _patch_for_flush(monkeypatch, tmp_path, result)
346343
minimal_ctx.runner = fake_runner # type: ignore[assignment]
@@ -355,8 +352,8 @@ async def test_channel_b_false_dispatches_codex_log_via_locate_session(
355352
step_backend=backend,
356353
)
357354

358-
backend.session_locator().locate_session.assert_called_once_with("sid-1")
359-
assert flush_calls[0]["codex_log_path"] == sentinel
355+
assert "codex_log_path" not in flush_calls[0]
356+
assert flush_calls[0]["backend"] == "codex"
360357

361358
@pytest.mark.anyio
362359
async def test_channel_b_true_skips_session_locator(self, minimal_ctx, tmp_path, monkeypatch):
@@ -388,5 +385,5 @@ async def test_channel_b_true_skips_session_locator(self, minimal_ctx, tmp_path,
388385
step_backend=backend,
389386
)
390387

391-
backend.session_locator.return_value.locate_session.assert_not_called()
392-
assert flush_calls[0]["codex_log_path"] is None
388+
assert "codex_log_path" not in flush_calls[0]
389+
assert flush_calls[0]["backend"] == "claude-code"

0 commit comments

Comments
 (0)