Skip to content

Commit 61b6ae8

Browse files
randclaude
andcommitted
feat: wire StatePersistence, ReasoningTraces, and StrategyCache into orchestrator (#4, #15)
Connect three existing but disconnected components into the RLM orchestrator's post-execution path. StatePersistence now tracks session state and trajectory counts, ReasoningTraces receives trajectory events for SPEC-04 decision graphs, and StrategyCache learns from successful runs per Spec §8.1. Also adds Python fallback hook scripts for session-init and trajectory-save, and hardens RLMSessionState.from_dict() to tolerate unknown fields from schema drift. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 54d88c0 commit 61b6ae8

7 files changed

Lines changed: 460 additions & 3 deletions

File tree

scripts/session-init.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#!/usr/bin/env python3
2+
"""Session initialization hook (Python fallback).
3+
4+
Called by hook-dispatch.sh when Go binary is unavailable.
5+
Initializes RLM session state for cross-hook persistence.
6+
"""
7+
8+
import json
9+
import os
10+
import sys
11+
12+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
13+
14+
try:
15+
from state_persistence import get_persistence
16+
17+
session_id = os.environ.get("CLAUDE_SESSION_ID", "default")
18+
persistence = get_persistence()
19+
persistence.init_session(session_id)
20+
persistence.save_state()
21+
print(json.dumps({"status": "initialized", "session_id": session_id}))
22+
except Exception as e:
23+
# Fail-open: don't block Claude Code
24+
print(json.dumps({"status": "skipped", "reason": str(e)}))

scripts/trajectory-save.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
#!/usr/bin/env python3
2+
"""Trajectory save hook (Python fallback).
3+
4+
Called by hook-dispatch.sh when Go binary is unavailable.
5+
Reads persisted session state and outputs a trajectory summary.
6+
"""
7+
8+
import json
9+
import os
10+
import sys
11+
12+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
13+
14+
try:
15+
from state_persistence import get_persistence
16+
17+
session_id = os.environ.get("CLAUDE_SESSION_ID", "default")
18+
persistence = get_persistence()
19+
persistence.restore_state(session_id)
20+
state = persistence.current_state
21+
summary = {
22+
"session_id": session_id,
23+
"trajectory_events": state.trajectory_events_count if state else 0,
24+
"trajectory_path": state.trajectory_path if state else None,
25+
"tokens_used": state.total_tokens_used if state else 0,
26+
}
27+
print(json.dumps({"status": "saved", "summary": summary}))
28+
except FileNotFoundError:
29+
print(json.dumps({"status": "skipped", "reason": "no_session_state"}))
30+
except Exception as e:
31+
print(json.dumps({"status": "skipped", "reason": str(e)}))

src/orchestrator/core.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
from ..api_client import ClaudeClient, Provider, init_client
2626
from ..memory_store import MemoryStore
27+
from ..state_persistence import StatePersistence
2728

2829
# ============================================================================
2930
# Error Recovery (SPEC-12.10)
@@ -125,6 +126,7 @@ def __init__(
125126
client: ClaudeClient | None = None,
126127
smart_routing: bool = True,
127128
memory_store: MemoryStore | None = None,
129+
persistence: StatePersistence | None = None,
128130
auto_memory: bool = True,
129131
error_recovery: ErrorRecoveryConfig | None = None,
130132
verification_config: VerificationConfig | None = None,
@@ -137,6 +139,7 @@ def __init__(
137139
client: Claude API client (creates one if None)
138140
smart_routing: Enable intelligent model routing based on query type
139141
memory_store: Optional memory store for auto-memory integration
142+
persistence: Optional state persistence for cross-session state (#4)
140143
auto_memory: If True and memory_store provided, auto-store findings
141144
error_recovery: Configuration for error recovery strategies
142145
verification_config: Epistemic verification config (uses default if None)
@@ -148,6 +151,7 @@ def __init__(
148151
self.smart_routing = smart_routing
149152
self._router: SmartRouter | None = None
150153
self._memory_store = memory_store
154+
self._persistence = persistence
151155
self._auto_memory = auto_memory
152156
self._error_recovery = error_recovery or ErrorRecoveryConfig()
153157
# SPEC-16.22: Epistemic verification config (always-on by default)
@@ -238,6 +242,14 @@ async def run(
238242
await trajectory.emit(start_event)
239243
yield start_event
240244

245+
# Initialize session persistence (#4 fix)
246+
if self._persistence is not None:
247+
import os
248+
249+
session_id = os.environ.get("CLAUDE_SESSION_ID", "rlm_default")
250+
self._persistence.init_session(session_id)
251+
self._persistence.update_rlm_active(True)
252+
241253
# Initialize RecursiveREPL for depth management and cost tracking
242254
recursive_handler = RecursiveREPL(
243255
context=context,
@@ -627,6 +639,7 @@ async def run(
627639
yield final_event
628640

629641
# Export trajectory if enabled
642+
trajectory_export_path: str | None = None
630643
if self.config.trajectory.export_enabled:
631644
import os
632645
import time
@@ -635,7 +648,42 @@ async def run(
635648
export_dir = Path(os.path.expanduser(self.config.trajectory.export_path))
636649
export_dir.mkdir(parents=True, exist_ok=True)
637650
filename = f"trajectory_{int(time.time())}.json"
638-
trajectory.export_json(str(export_dir / filename))
651+
trajectory_export_path = str(export_dir / filename)
652+
trajectory.export_json(trajectory_export_path)
653+
654+
# --- Post-execution persistence bridges (#4, #15 fix) ---
655+
656+
# 1. Update StatePersistence with trajectory data
657+
if self._persistence is not None and self._persistence.current_state is not None:
658+
event_count = len(trajectory.get_full_trajectory())
659+
self._persistence.increment_trajectory_events(event_count)
660+
if trajectory_export_path:
661+
self._persistence.set_trajectory_path(trajectory_export_path)
662+
self._persistence.save_state()
663+
664+
# 2. Bridge to ReasoningTraces (SPEC-04)
665+
if self._memory_store is not None:
666+
try:
667+
from ..reasoning_traces import ReasoningTraces
668+
669+
traces = ReasoningTraces(store=self._memory_store)
670+
for event in trajectory.get_full_trajectory():
671+
traces.from_trajectory_event(event)
672+
except Exception:
673+
pass # Non-blocking: traces are observability
674+
675+
# 3. Bridge to StrategyCache (Spec §8.1)
676+
if state.final_answer:
677+
try:
678+
from ..strategy_cache import get_strategy_cache
679+
from ..trajectory_analysis import TrajectoryAnalyzer
680+
681+
analysis = TrajectoryAnalyzer().analyze(trajectory.get_full_trajectory())
682+
cache = get_strategy_cache()
683+
cache.add(query, analysis)
684+
cache.save()
685+
except Exception:
686+
pass # Non-blocking
639687

640688
async def _process_deferred_operations(
641689
self,

src/state_persistence.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,11 @@ def to_dict(self) -> dict[str, Any]:
5858

5959
@classmethod
6060
def from_dict(cls, data: dict[str, Any]) -> RLMSessionState:
61-
"""Create from dictionary."""
62-
return cls(**data)
61+
"""Create from dictionary, tolerating unknown fields."""
62+
from dataclasses import fields as dc_fields
63+
64+
known = {f.name for f in dc_fields(cls)}
65+
return cls(**{k: v for k, v in data.items() if k in known})
6366

6467

6568
class StatePersistence:

tests/unit/test_hook_scripts.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""
2+
Unit tests for Python fallback hook scripts.
3+
4+
Tests that session-init.py and trajectory-save.py produce valid JSON
5+
and handle errors gracefully (fail-open).
6+
"""
7+
8+
import json
9+
import subprocess
10+
import sys
11+
from pathlib import Path
12+
13+
import pytest
14+
15+
SCRIPTS_DIR = Path(__file__).parent.parent.parent / "scripts"
16+
17+
18+
class TestSessionInitHook:
19+
"""Tests for scripts/session-init.py."""
20+
21+
def test_produces_valid_json(self):
22+
"""session-init.py produces valid JSON output."""
23+
result = subprocess.run(
24+
[sys.executable, str(SCRIPTS_DIR / "session-init.py")],
25+
capture_output=True,
26+
text=True,
27+
timeout=10,
28+
)
29+
30+
output = json.loads(result.stdout.strip())
31+
assert "status" in output
32+
33+
def test_initializes_with_session_id(self):
34+
"""session-init.py uses CLAUDE_SESSION_ID from env."""
35+
env = {"CLAUDE_SESSION_ID": "test-hook-session", "PATH": ""}
36+
result = subprocess.run(
37+
[sys.executable, str(SCRIPTS_DIR / "session-init.py")],
38+
capture_output=True,
39+
text=True,
40+
timeout=10,
41+
env={**dict(__import__("os").environ), **env},
42+
)
43+
44+
output = json.loads(result.stdout.strip())
45+
# Either initialized successfully or skipped gracefully
46+
assert output["status"] in ("initialized", "skipped")
47+
48+
def test_fail_open_on_error(self):
49+
"""session-init.py outputs skipped status on import failure."""
50+
script = SCRIPTS_DIR / "session-init.py"
51+
# Poison state_persistence module to force ImportError,
52+
# while keeping stdlib intact and __file__ defined for the script
53+
code = (
54+
f"__file__ = {str(script)!r}; "
55+
"import sys; sys.modules['state_persistence'] = None; "
56+
f"exec(compile(open({str(script)!r}).read(), {str(script)!r}, 'exec'))"
57+
)
58+
result = subprocess.run(
59+
[sys.executable, "-c", code],
60+
capture_output=True,
61+
text=True,
62+
timeout=10,
63+
)
64+
65+
# Should not crash — must produce valid JSON with skipped status
66+
assert result.returncode == 0, f"Hook crashed: {result.stderr}"
67+
assert result.stdout.strip(), "Hook produced no output"
68+
output = json.loads(result.stdout.strip())
69+
assert output["status"] == "skipped"
70+
71+
72+
class TestTrajectorySaveHook:
73+
"""Tests for scripts/trajectory-save.py."""
74+
75+
def test_produces_valid_json(self):
76+
"""trajectory-save.py produces valid JSON output."""
77+
result = subprocess.run(
78+
[sys.executable, str(SCRIPTS_DIR / "trajectory-save.py")],
79+
capture_output=True,
80+
text=True,
81+
timeout=10,
82+
)
83+
84+
output = json.loads(result.stdout.strip())
85+
assert "status" in output
86+
87+
def test_handles_no_state_gracefully(self):
88+
"""trajectory-save.py handles missing state file gracefully."""
89+
env = {"CLAUDE_SESSION_ID": "nonexistent-session-12345"}
90+
result = subprocess.run(
91+
[sys.executable, str(SCRIPTS_DIR / "trajectory-save.py")],
92+
capture_output=True,
93+
text=True,
94+
timeout=10,
95+
env={**dict(__import__("os").environ), **env},
96+
)
97+
98+
output = json.loads(result.stdout.strip())
99+
assert output["status"] == "skipped"

0 commit comments

Comments
 (0)