Skip to content

Commit 3cb55fd

Browse files
Brian KrafftCopilot
andcommitted
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>
1 parent 48982ad commit 3cb55fd

6 files changed

Lines changed: 2677 additions & 11 deletions

File tree

openspace/skill_engine/lineage_tracker.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,9 @@ def _reader(self) -> Generator[sqlite3.Connection, None, None]:
143143
"""Open a temporary read-only connection (WAL parallel reads)."""
144144
self._ensure_open()
145145
if not self._owns_conn:
146-
yield self._conn
146+
# Fix 3: Acquire lock when using shared connection to prevent dirty reads
147+
with self._mu:
148+
yield self._conn
147149
return
148150
conn = self._make_connection(read_only=True)
149151
try:
@@ -267,7 +269,7 @@ def get_ancestors(
267269
generation (oldest first).
268270
"""
269271
with self._reader() as conn:
270-
visited: set[str] = set()
272+
visited: set[str] = {skill_id} # Fix 1: Seed with starting skill_id to prevent cycles
271273
ancestors: List[SkillRecord] = []
272274
frontier = [skill_id]
273275

@@ -289,7 +291,7 @@ def get_ancestors(
289291
).fetchone()
290292
if row:
291293
ancestors.append(
292-
SkillRepository._to_record(conn, row)
294+
SkillRepository.to_record(conn, row)
293295
)
294296
next_frontier.append(pid)
295297
frontier = next_frontier
@@ -318,7 +320,7 @@ def get_evolution_chain(self, name: str) -> List[SkillRecord]:
318320
"ORDER BY lineage_generation ASC",
319321
(name,),
320322
).fetchall()
321-
return [SkillRepository._to_record(conn, r) for r in rows]
323+
return [SkillRepository.to_record(conn, r) for r in rows]
322324

323325
@_db_retry()
324326
def get_lineage_tree(
@@ -370,7 +372,8 @@ def _subtree(
370372
).fetchall():
371373
cid = cr["skill_id"]
372374
if cid not in visited:
375+
# Fix 2: Pass copy of visited to prevent diamond DAG edge loss
373376
node["children"].append(
374-
self._subtree(conn, cid, depth - 1, visited)
377+
self._subtree(conn, cid, depth - 1, visited.copy())
375378
)
376379
return node

openspace/skill_engine/skill_repository.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ def get(self, skill_id: str) -> Optional[SkillRecord]:
276276
"SELECT * FROM skill_records WHERE skill_id=?",
277277
(skill_id,),
278278
).fetchone()
279-
return self._to_record(conn, row) if row else None
279+
return self.to_record(conn, row) if row else None
280280

281281
# ── CRUD: Delete ──────────────────────────────────────────────────
282282

@@ -316,7 +316,7 @@ def list_all(self, *, active_only: bool = False) -> Dict[str, SkillRecord]:
316316
).fetchall()
317317
result: Dict[str, SkillRecord] = {}
318318
for row in rows:
319-
rec = self._to_record(conn, row)
319+
rec = self.to_record(conn, row)
320320
result[rec.skill_id] = rec
321321
return result
322322

@@ -375,7 +375,7 @@ def search(
375375
params,
376376
).fetchall()
377377

378-
return [self._to_record(conn, row) for row in rows]
378+
return [self.to_record(conn, row) for row in rows]
379379

380380
# ── CRUD: Count ───────────────────────────────────────────────────
381381

@@ -525,7 +525,7 @@ def _upsert(self, record: SkillRecord) -> None:
525525
# ── Internal: Deserialization ─────────────────────────────────────
526526

527527
@staticmethod
528-
def _to_record(conn: sqlite3.Connection, row: sqlite3.Row) -> SkillRecord:
528+
def to_record(conn: sqlite3.Connection, row: sqlite3.Row) -> SkillRecord:
529529
"""Deserialize a ``skill_records`` row + related rows → SkillRecord."""
530530
sid = row["skill_id"]
531531

openspace/skill_engine/store.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -699,7 +699,9 @@ def get_versions(self, name: str) -> List[SkillRecord]:
699699
700700
Delegates to :class:`LineageTracker` (Epic 3.3).
701701
"""
702-
return self._lineage.get_evolution_chain(name)
702+
# Fix 5: Hydrate recent_analyses after delegation
703+
records = self._lineage.get_evolution_chain(name)
704+
return [self._hydrate_recent_analyses(record) for record in records]
703705

704706
@_db_retry()
705707
def load_by_category(self, category: SkillCategory, *, active_only: bool = True) -> List[SkillRecord]:
@@ -998,7 +1000,9 @@ def get_ancestry(self, skill_id: str, max_depth: int = 10) -> List[SkillRecord]:
9981000
9991001
Delegates to :class:`LineageTracker` (Epic 3.3).
10001002
"""
1001-
return self._lineage.get_ancestors(skill_id, max_depth=max_depth)
1003+
# Fix 5: Hydrate recent_analyses after delegation
1004+
records = self._lineage.get_ancestors(skill_id, max_depth=max_depth)
1005+
return [self._hydrate_recent_analyses(record) for record in records]
10021006

10031007
@_db_retry()
10041008
def get_lineage_tree(self, skill_id: str, max_depth: int = 5) -> Dict[str, Any]:
@@ -1198,6 +1202,41 @@ def _insert_analysis(self, a: ExecutionAnalysis) -> int:
11981202

11991203
return analysis_id
12001204

1205+
# Fix 5: Helper method to hydrate recent_analyses for records from LineageTracker
1206+
def _hydrate_recent_analyses(self, record: SkillRecord) -> SkillRecord:
1207+
"""Hydrate recent_analyses for a SkillRecord from LineageTracker delegation."""
1208+
with self._reader() as conn:
1209+
analysis_rows = conn.execute(
1210+
"SELECT ea.* FROM execution_analyses ea "
1211+
"JOIN skill_judgments sj ON ea.id = sj.analysis_id "
1212+
"WHERE sj.skill_id = ? "
1213+
"ORDER BY ea.timestamp DESC LIMIT ?",
1214+
(record.skill_id, SkillRecord.MAX_RECENT),
1215+
).fetchall()
1216+
1217+
# Create a new record with hydrated recent_analyses
1218+
return SkillRecord(
1219+
skill_id=record.skill_id,
1220+
name=record.name,
1221+
description=record.description,
1222+
path=record.path,
1223+
is_active=record.is_active,
1224+
category=record.category,
1225+
tags=record.tags,
1226+
visibility=record.visibility,
1227+
creator_id=record.creator_id,
1228+
lineage=record.lineage,
1229+
tool_dependencies=record.tool_dependencies,
1230+
critical_tools=record.critical_tools,
1231+
total_selections=record.total_selections,
1232+
total_applied=record.total_applied,
1233+
total_completions=record.total_completions,
1234+
total_fallbacks=record.total_fallbacks,
1235+
recent_analyses=[self._to_analysis(conn, r) for r in reversed(analysis_rows)],
1236+
first_seen=record.first_seen,
1237+
last_updated=record.last_updated,
1238+
)
1239+
12011240
# Deserialization
12021241
def _to_record(self, conn: sqlite3.Connection, row: sqlite3.Row) -> SkillRecord:
12031242
"""Deserialize a skill_records row + related rows → SkillRecord."""

0 commit comments

Comments
 (0)