Skip to content

Commit a180b5c

Browse files
DeepfreezechillBrian KrafftCopilot
authored
feat(P3): Epic 3.3 — LineageTracker extraction + /8eyes fixes (#17)
* feat(3.3): extract LineageTracker from monolithic SkillStore Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(3.3): address /8eyes R1 findings — cycle bug, diamond DAG, shared-conn, layering - Fix 1: get_ancestors() cycle bug — seed visited with starting skill_id to prevent cycles - Fix 2: get_lineage_tree() diamond DAG — pass copy of visited to each sibling subtree to preserve all paths - Fix 3: shared-conn _reader() dirty reads — acquire lock when using shared connection - Fix 4: SkillRepository._to_record layering violation — make public as to_record() - Fix 5: recent_analyses hydration regression — re-hydrate in SkillStore delegation methods - Fix 6: missing test coverage — add cycle, diamond, shared-conn isolation, and facade tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Brian Krafft <bkrafft@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d0a3394 commit a180b5c

7 files changed

Lines changed: 3496 additions & 109 deletions

File tree

openspace/skill_engine/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"ValidationError",
1919
"SkillStore",
2020
"SkillRepository",
21+
"LineageTracker",
2122
"ExecutionAnalyzer",
2223
"SkillEvolver",
2324
"EvolutionTrigger",
@@ -42,6 +43,7 @@
4243
"ValidationError": (".types", "ValidationError"),
4344
"SkillStore": (".store", "SkillStore"),
4445
"SkillRepository": (".skill_repository", "SkillRepository"),
46+
"LineageTracker": (".lineage_tracker", "LineageTracker"),
4547
"ExecutionAnalyzer": (".analyzer", "ExecutionAnalyzer"),
4648
"SkillEvolver": (".evolver", "SkillEvolver"),
4749
"EvolutionTrigger": (".evolver", "EvolutionTrigger"),
Lines changed: 379 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,379 @@
1+
"""LineageTracker — extracted lineage/parent-child tracking from SkillStore.
2+
3+
Epic 3.3: Separates lineage traversal and evolution recording from the
4+
monolithic SkillStore, following the same extraction pattern as
5+
SkillRepository (Epic 3.2).
6+
7+
Architecture:
8+
- Accepts ``db_path`` for standalone use, or ``conn`` + ``lock`` for
9+
embedding inside SkillStore (shared write connection, shared mutex).
10+
- All reads use a short-lived read-only connection (WAL parallel reads)
11+
when owning the connection, or the shared connection when injected.
12+
- All writes go through the persistent write connection with a mutex.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import sqlite3
18+
import threading
19+
import time
20+
from contextlib import contextmanager
21+
from datetime import datetime
22+
from functools import wraps
23+
from pathlib import Path
24+
from typing import Any, Dict, Generator, List, Optional
25+
26+
from openspace.utils.logging import Logger
27+
28+
from .skill_repository import SkillRepository
29+
from .types import (
30+
SkillLineage,
31+
SkillOrigin,
32+
SkillRecord,
33+
)
34+
35+
logger = Logger.get_logger(__name__)
36+
37+
38+
def _db_retry(
39+
max_retries: int = 5,
40+
initial_delay: float = 0.1,
41+
backoff: float = 2.0,
42+
):
43+
"""Retry on transient SQLite errors with exponential backoff."""
44+
45+
def decorator(func):
46+
@wraps(func)
47+
def wrapper(*args, **kwargs):
48+
delay = initial_delay
49+
for attempt in range(max_retries):
50+
try:
51+
return func(*args, **kwargs)
52+
except (sqlite3.OperationalError, sqlite3.DatabaseError) as exc:
53+
if attempt == max_retries - 1:
54+
logger.error(
55+
f"DB {func.__name__} failed after {max_retries} retries: {exc}"
56+
)
57+
raise
58+
logger.warning(
59+
f"DB {func.__name__} retry {attempt + 1}/{max_retries}: {exc}"
60+
)
61+
time.sleep(delay)
62+
delay *= backoff
63+
64+
return wrapper
65+
66+
return decorator
67+
68+
69+
class LineageTracker:
70+
"""Tracks parent-child lineage relationships between skill versions.
71+
72+
Extracted from ``SkillStore`` (Epic 3.3) to isolate lineage traversal
73+
and evolution recording logic.
74+
75+
Usage (standalone)::
76+
77+
tracker = LineageTracker(db_path=Path("skills.db"))
78+
tracker.record_derivation(child_record, parent_skill_ids=["parent_id"])
79+
ancestors = tracker.get_ancestors("child_id")
80+
tracker.close()
81+
82+
Usage (embedded in SkillStore)::
83+
84+
tracker = LineageTracker(conn=self._conn, lock=self._mu)
85+
# shares write connection and mutex with SkillStore
86+
87+
Args:
88+
db_path: Path to the SQLite database file.
89+
conn: Optional existing SQLite connection (for embedding in SkillStore).
90+
If provided, the tracker will NOT own or close this connection.
91+
lock: Optional :class:`threading.Lock` to use instead of creating a
92+
private one. When sharing a connection with another component,
93+
pass the same lock to avoid dual-mutex.
94+
"""
95+
96+
def __init__(
97+
self,
98+
db_path: Optional[Path] = None,
99+
conn: Optional[sqlite3.Connection] = None,
100+
lock: Optional[threading.Lock] = None,
101+
) -> None:
102+
self._owns_conn = conn is None
103+
self._closed = False
104+
self._mu = lock if lock is not None else threading.Lock()
105+
106+
if conn is not None:
107+
self._conn = conn
108+
self._db_path = Path(":shared:")
109+
# When sharing a connection, the caller owns DDL and the repo
110+
self._repo = SkillRepository(conn=conn, lock=self._mu)
111+
else:
112+
if db_path is None:
113+
raise ValueError("Either db_path or conn must be provided")
114+
self._db_path = Path(db_path)
115+
self._repo = SkillRepository(db_path=db_path)
116+
self._conn = self._repo._conn
117+
self._mu = self._repo._mu
118+
119+
logger.debug(f"LineageTracker ready at {self._db_path}")
120+
121+
# ── Connection helpers ─────────────────────────────────────────────
122+
123+
def _make_connection(self, *, read_only: bool) -> sqlite3.Connection:
124+
"""Create a tuned SQLite connection."""
125+
conn = sqlite3.connect(
126+
str(self._db_path),
127+
timeout=30.0,
128+
check_same_thread=False,
129+
)
130+
conn.execute("PRAGMA journal_mode=WAL")
131+
conn.execute("PRAGMA busy_timeout=30000")
132+
conn.execute("PRAGMA synchronous=NORMAL")
133+
conn.execute("PRAGMA cache_size=-16000")
134+
conn.execute("PRAGMA temp_store=MEMORY")
135+
conn.execute("PRAGMA foreign_keys=ON")
136+
if read_only:
137+
conn.execute("PRAGMA query_only=ON")
138+
conn.row_factory = sqlite3.Row
139+
return conn
140+
141+
@contextmanager
142+
def _reader(self) -> Generator[sqlite3.Connection, None, None]:
143+
"""Open a temporary read-only connection (WAL parallel reads)."""
144+
self._ensure_open()
145+
if not self._owns_conn:
146+
# Fix 3: Acquire lock when using shared connection to prevent dirty reads
147+
with self._mu:
148+
yield self._conn
149+
return
150+
conn = self._make_connection(read_only=True)
151+
try:
152+
yield conn
153+
finally:
154+
conn.close()
155+
156+
def _ensure_open(self) -> None:
157+
if self._closed:
158+
raise RuntimeError("LineageTracker is closed")
159+
160+
def close(self) -> None:
161+
"""Close the tracker. Only closes owned resources."""
162+
if self._closed:
163+
return
164+
self._closed = True
165+
if self._owns_conn:
166+
try:
167+
self._repo.close()
168+
except Exception:
169+
pass
170+
logger.debug("LineageTracker closed")
171+
172+
@property
173+
def db_path(self) -> Path:
174+
return self._db_path
175+
176+
# ── Lineage recording ─────────────────────────────────────────────
177+
178+
@_db_retry()
179+
def record_derivation(
180+
self,
181+
new_record: SkillRecord,
182+
parent_skill_ids: List[str],
183+
) -> None:
184+
"""Record a derivation: persist new skill + parent-child links.
185+
186+
For ``FIXED`` origin, parent skills are deactivated (superseded).
187+
For ``DERIVED`` origin, parents remain active.
188+
189+
Args:
190+
new_record: The new skill record to persist.
191+
parent_skill_ids: List of parent skill_ids.
192+
"""
193+
self._ensure_open()
194+
with self._mu:
195+
self._conn.execute("BEGIN")
196+
try:
197+
# For FIXED: deactivate same-name parents (superseded)
198+
if new_record.lineage.origin == SkillOrigin.FIXED:
199+
for pid in parent_skill_ids:
200+
self._conn.execute(
201+
"UPDATE skill_records SET is_active=0, last_updated=? "
202+
"WHERE skill_id=?",
203+
(datetime.now().isoformat(), pid),
204+
)
205+
206+
new_record.lineage.parent_skill_ids = list(parent_skill_ids)
207+
new_record.is_active = True
208+
209+
self._repo._upsert(new_record)
210+
self._conn.commit()
211+
212+
origin = new_record.lineage.origin.value
213+
logger.info(
214+
f"record_derivation ({origin}): "
215+
f"{new_record.name}@gen{new_record.lineage.generation} "
216+
f"[{new_record.skill_id}] ← parents={parent_skill_ids}"
217+
)
218+
except Exception:
219+
self._conn.rollback()
220+
raise
221+
222+
# ── Lineage queries ───────────────────────────────────────────────
223+
224+
@_db_retry()
225+
def get_lineage(self, skill_id: str) -> Optional[SkillLineage]:
226+
"""Return the :class:`SkillLineage` for the given skill, or ``None``.
227+
228+
Args:
229+
skill_id: The skill to look up.
230+
231+
Returns:
232+
The lineage metadata, or ``None`` if the skill doesn't exist.
233+
"""
234+
record = self._repo.get(skill_id)
235+
return record.lineage if record else None
236+
237+
@_db_retry()
238+
def get_children(self, parent_skill_id: str) -> List[str]:
239+
"""Find skill_ids directly derived from the given parent.
240+
241+
Args:
242+
parent_skill_id: The parent skill to query.
243+
244+
Returns:
245+
List of child skill_ids (may be empty).
246+
"""
247+
with self._reader() as conn:
248+
rows = conn.execute(
249+
"SELECT skill_id FROM skill_lineage_parents WHERE parent_skill_id=?",
250+
(parent_skill_id,),
251+
).fetchall()
252+
return [r["skill_id"] for r in rows]
253+
254+
@_db_retry()
255+
def get_ancestors(
256+
self, skill_id: str, max_depth: int = 10
257+
) -> List[SkillRecord]:
258+
"""Walk up the lineage tree; returns ancestors oldest-first.
259+
260+
Uses BFS to traverse parent links, respecting ``max_depth`` to
261+
prevent runaway traversal on deep or cyclic graphs.
262+
263+
Args:
264+
skill_id: The skill to find ancestors for.
265+
max_depth: Maximum number of generations to traverse.
266+
267+
Returns:
268+
List of ancestor :class:`SkillRecord` objects, sorted by
269+
generation (oldest first).
270+
"""
271+
with self._reader() as conn:
272+
visited: set[str] = {skill_id} # Fix 1: Seed with starting skill_id to prevent cycles
273+
ancestors: List[SkillRecord] = []
274+
frontier = [skill_id]
275+
276+
for _ in range(max_depth):
277+
next_frontier: List[str] = []
278+
for sid in frontier:
279+
for pr in conn.execute(
280+
"SELECT parent_skill_id FROM skill_lineage_parents "
281+
"WHERE skill_id=?",
282+
(sid,),
283+
).fetchall():
284+
pid = pr["parent_skill_id"]
285+
if pid in visited:
286+
continue
287+
visited.add(pid)
288+
row = conn.execute(
289+
"SELECT * FROM skill_records WHERE skill_id=?",
290+
(pid,),
291+
).fetchone()
292+
if row:
293+
ancestors.append(
294+
SkillRepository.to_record(conn, row)
295+
)
296+
next_frontier.append(pid)
297+
frontier = next_frontier
298+
if not frontier:
299+
break
300+
301+
ancestors.sort(key=lambda r: r.lineage.generation)
302+
return ancestors
303+
304+
@_db_retry()
305+
def get_evolution_chain(self, name: str) -> List[SkillRecord]:
306+
"""Load all versions of a named skill, sorted by generation.
307+
308+
Returns both active and inactive versions to show the full
309+
evolution history.
310+
311+
Args:
312+
name: The skill name to search for.
313+
314+
Returns:
315+
List of :class:`SkillRecord` sorted by ``lineage.generation`` ASC.
316+
"""
317+
with self._reader() as conn:
318+
rows = conn.execute(
319+
"SELECT * FROM skill_records WHERE name=? "
320+
"ORDER BY lineage_generation ASC",
321+
(name,),
322+
).fetchall()
323+
return [SkillRepository.to_record(conn, r) for r in rows]
324+
325+
@_db_retry()
326+
def get_lineage_tree(
327+
self, skill_id: str, max_depth: int = 5
328+
) -> Dict[str, Any]:
329+
"""Build a JSON-friendly tree rooted at *skill_id* (downward).
330+
331+
Each node contains: ``skill_id``, ``name``, ``generation``,
332+
``origin``, ``is_active``, ``children``.
333+
334+
Args:
335+
skill_id: Root of the tree.
336+
max_depth: Maximum depth to traverse.
337+
338+
Returns:
339+
Nested dict representing the lineage tree.
340+
"""
341+
with self._reader() as conn:
342+
return self._subtree(conn, skill_id, max_depth, set())
343+
344+
def _subtree(
345+
self,
346+
conn: sqlite3.Connection,
347+
sid: str,
348+
depth: int,
349+
visited: set,
350+
) -> Dict[str, Any]:
351+
"""Recursive helper for :meth:`get_lineage_tree`."""
352+
visited.add(sid)
353+
row = conn.execute(
354+
"SELECT skill_id, name, lineage_generation, lineage_origin, "
355+
"is_active FROM skill_records WHERE skill_id=?",
356+
(sid,),
357+
).fetchone()
358+
node: Dict[str, Any] = {
359+
"skill_id": sid,
360+
"name": row["name"] if row else "?",
361+
"generation": row["lineage_generation"] if row else -1,
362+
"origin": row["lineage_origin"] if row else "unknown",
363+
"is_active": bool(row["is_active"]) if row else False,
364+
"children": [],
365+
}
366+
if depth <= 0:
367+
return node
368+
for cr in conn.execute(
369+
"SELECT skill_id FROM skill_lineage_parents "
370+
"WHERE parent_skill_id=?",
371+
(sid,),
372+
).fetchall():
373+
cid = cr["skill_id"]
374+
if cid not in visited:
375+
# Fix 2: Pass copy of visited to prevent diamond DAG edge loss
376+
node["children"].append(
377+
self._subtree(conn, cid, depth - 1, visited.copy())
378+
)
379+
return node

0 commit comments

Comments
 (0)