Skip to content

Commit 1c285dd

Browse files
committed
docs: document reliability fixes
1 parent a4d1384 commit 1c285dd

3 files changed

Lines changed: 760 additions & 0 deletions

File tree

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
# Validated Architectural Review Report
2+
3+
**Review scope:** `src/pythinker_code/background/`, `src/pythinker_code/memory/`, and `src/pythinker_code/auth/opencode_go.py`
4+
**Validation date:** 2026-05-30
5+
**Validated against:** current working tree at `d3bf815627fdcfcf87f1f8abbabbb2d6749419a0`
6+
**Status:** findings re-validated against the live tree (tests re-run, usages re-grepped). Each finding now carries a concrete, behavior-preserving fix.
7+
8+
---
9+
10+
## Summary
11+
12+
The original draft mixed valid cleanup opportunities with stale or incorrect findings. This version keeps only findings that still match the current codebase, and pairs each with the most robust fix that preserves existing behavior.
13+
14+
**Current actionable findings:**
15+
16+
1. `memory/retriever.py` still has an unused abstraction seam around `Retriever` / `LexicalRetriever` / `SqliteFts5Retriever`.
17+
2. `background/manager.py` still duplicates locked task-runtime status mutation logic across six `_mark_task_*` methods.
18+
3. `background/manager.py` still reaches into private store locking/write helpers where the public `update_runtime()` API can cover most cases.
19+
4. `memory/recall.py` still passes a `store_path` argument that is immediately discarded.
20+
5. `memory/consolidation.py` still calls the private `ProjectMemoryStore._ensure_dir()` method.
21+
22+
**Optional cleanups:**
23+
24+
- `Counter(doc)` can replace the manual term-frequency loop in `memory/retriever.py`.
25+
- OpenCode Go response parsing can be clarified, but any Pydantic refactor must preserve current tolerant parsing behavior.
26+
27+
**Corroborating evidence gathered during re-validation:**
28+
29+
- The retriever seam (Finding 1) has **zero production references**`SqliteFts5Retriever` and `sqlite_fts5_available` appear only in `retriever_sqlite.py` itself and one test; the only retriever used in the app path is `LexicalRetriever`.
30+
- The migration target in Finding 3, `BackgroundTaskStore.update_runtime()`, is **already the established pattern** in this module (`manager.py:277`, `manager.py:659`, `worker.py:276`). The six `_mark_task_*` methods and the stale-recovery block are the remaining holdouts.
31+
- `update_runtime_under_lock()` does **not** exist anywhere in the tree — no new lock-exposing API is needed.
32+
33+
The stale and incorrect draft entries have been removed; the sections below list only current findings, robust fixes, and optional cleanups that still match the codebase.
34+
35+
---
36+
37+
## Recommended fix order
38+
39+
The findings interact. Applying them in this order avoids rework and keeps each diff behavior-preserving:
40+
41+
1. **Finding 4** — drop the dead `store_path` parameter. This removes the `_ensure_dir()` call at `recall.py:252`.
42+
2. **Finding 5** — add the public `ensure_root()` alias. After step 1, `consolidation.py:36` is the only external `_ensure_dir()` caller, so this isolates the change.
43+
3. **Finding 2** — extract the status-transition helper. Route it through `update_runtime()`.
44+
4. **Finding 3** — step 3 already eliminates the private `_runtime_lock` / `_write_runtime_unlocked` usage in the six mark-methods (`manager.py:821-912`). Only the stale-recovery block (`manager.py:598-626`) remains; decide its treatment per Finding 3 below.
45+
5. **Finding 1** — delete the unused retriever seam.
46+
6. **Optional A / B** — apply if desired; B is "leave as-is" by default.
47+
48+
---
49+
50+
## Validated Findings
51+
52+
### 1. Unused retriever abstraction and SQLite seam
53+
54+
**Files:**
55+
56+
- `src/pythinker_code/memory/retriever.py:7,49-54`
57+
- `src/pythinker_code/memory/retriever_sqlite.py` (entire file)
58+
59+
**Severity:** Low
60+
**Category:** Overengineering / YAGNI
61+
62+
`Retriever` is an abstract base class with no production polymorphic dispatch. `LexicalRetriever` is the real implementation used by recall (`recall.py:13,94`), while `SqliteFts5Retriever` is a capability seam that delegates directly to `LexicalRetriever`.
63+
64+
Re-validation tightened this: `SqliteFts5Retriever` and `sqlite_fts5_available` have **no production callers at all** — they are referenced only inside `retriever_sqlite.py` and a single test (`tests/core/test_memory_phase_bcd.py:18,158`, `test_sqlite_retriever_falls_back_to_lexical`). There is no `memory/__init__.py` re-exporting these symbols, so nothing outside the package depends on them.
65+
66+
**Robust fix:**
67+
68+
Delete the dead seam outright — it is safe because there is no public export and no production dispatch:
69+
70+
- Delete `src/pythinker_code/memory/retriever_sqlite.py`.
71+
- In `retriever.py`, remove the `Retriever` ABC (lines 49-51) and make `LexicalRetriever` a plain class (`class LexicalRetriever:`). Remove the now-orphaned `from abc import ABC, abstractmethod` import (line 7).
72+
- Keep the name `LexicalRetriever` — it is imported at `recall.py:13` and used at `recall.py:94`; renaming is pure churn for no behavioral gain.
73+
- Delete the test `test_sqlite_retriever_falls_back_to_lexical` and its import at `tests/core/test_memory_phase_bcd.py:18`. (The fallback it asserts no longer exists once the wrapper is gone.)
74+
75+
Do **not** reintroduce an FTS5 abstraction until there is a measured indexed implementation and a real dispatch path. If a future plugin contract genuinely needs polymorphism, add the protocol back at that point with a concrete second implementation — not before.
76+
77+
---
78+
79+
### 2. Duplicated background task status mutation methods
80+
81+
**File:** `src/pythinker_code/background/manager.py:821-922`
82+
**Severity:** Medium
83+
**Category:** Duplication / maintainability
84+
85+
There are six near-identical status mutation methods, each performing the same locked read-check-mutate-write sequence:
86+
87+
- `_mark_task_running`
88+
- `_mark_task_awaiting_approval`
89+
- `_mark_task_completed`
90+
- `_mark_task_failed`
91+
- `_mark_task_timed_out`
92+
- `_mark_task_killed`
93+
94+
**Semantics that must be preserved exactly:**
95+
96+
- terminal states must not be overwritten (early no-op);
97+
- `updated_at` is set to now on every applied transition;
98+
- `_mark_task_running()` sets `heartbeat_at = updated_at` and clears `failure_reason`;
99+
- `completed` clears `failure_reason`; `completed`/`failed`/`timed_out`/`killed` set `finished_at = updated_at`; `running`/`awaiting_approval` do **not** set `finished_at`;
100+
- `timed_out` sets both `interrupted` and `timed_out`; `killed` sets `interrupted`;
101+
- telemetry fires **only** when the transition was actually applied (not on the terminal no-op), guarded by `started_at and finished_at`, with reason labels unchanged: `completed``success=True` (no reason); `failed``reason="error"`; `timed_out``reason="timeout"`; `killed``reason="killed"`; `running`/`awaiting_approval` → no telemetry.
102+
103+
**Robust fix:**
104+
105+
Extract one private helper that owns the locked, terminal-guarded write via the **public** `update_runtime()` API, and signals whether the transition was applied so telemetry stays in the callers:
106+
107+
```python
108+
def _transition_status(
109+
self,
110+
task_id: str,
111+
*,
112+
mutate: Callable[[TaskRuntime], None],
113+
) -> TaskRuntime | None:
114+
"""Locked, terminal-guarded status write via the public store API.
115+
116+
Stamps ``updated_at`` then applies ``mutate``. Returns the resulting
117+
runtime when the transition was applied, or ``None`` when the task was
118+
already terminal (no write performed, so callers skip telemetry).
119+
"""
120+
applied = False
121+
122+
def _apply(runtime: TaskRuntime) -> bool:
123+
nonlocal applied
124+
if is_terminal_status(runtime.status):
125+
return False
126+
runtime.updated_at = time.time()
127+
mutate(runtime)
128+
applied = True
129+
return True
130+
131+
runtime = self._store.update_runtime(task_id, _apply)
132+
return runtime if applied else None
133+
```
134+
135+
Each mark-method becomes a thin mutator plus its own telemetry. Two representative cases:
136+
137+
```python
138+
def _mark_task_running(self, task_id: str) -> None:
139+
def mutate(r: TaskRuntime) -> None:
140+
r.status = "running"
141+
r.heartbeat_at = r.updated_at
142+
r.failure_reason = None
143+
144+
self._transition_status(task_id, mutate=mutate)
145+
146+
def _mark_task_completed(self, task_id: str) -> None:
147+
def mutate(r: TaskRuntime) -> None:
148+
r.status = "completed"
149+
r.finished_at = r.updated_at
150+
r.failure_reason = None
151+
152+
runtime = self._transition_status(task_id, mutate=mutate)
153+
if runtime and runtime.started_at and runtime.finished_at:
154+
from pythinker_code.telemetry import track
155+
156+
track(
157+
"background_task_completed",
158+
success=True,
159+
duration_s=runtime.finished_at - runtime.started_at,
160+
)
161+
```
162+
163+
`_mark_task_failed` / `_mark_task_timed_out` / `_mark_task_killed` follow the same shape, each setting its status, `finished_at`, flags, and `failure_reason` in `mutate`, then emitting telemetry with its own `reason` label guarded by `if runtime and runtime.started_at and runtime.finished_at`. `_mark_task_awaiting_approval` uses only `mutate` with no telemetry.
164+
165+
This collapses six bodies to one shared critical section while keeping each method's distinct mutation and telemetry explicit. Ensure `Callable` is imported (`from collections.abc import Callable`) if it is not already.
166+
167+
---
168+
169+
### 3. Manager still uses private store lock/write internals
170+
171+
**Files:**
172+
173+
- `src/pythinker_code/background/manager.py:598-626` (stale-task recovery)
174+
- `src/pythinker_code/background/manager.py:821-912` (the six mark-methods)
175+
- `src/pythinker_code/background/store.py:143-153` (`update_runtime`)
176+
177+
**Severity:** Medium
178+
**Category:** Architectural coupling
179+
180+
`BackgroundTaskManager` calls private store internals directly — `self._store._runtime_lock(...)` and `self._store._write_runtime_unlocked(...)` — in both the mark-methods and the stale-recovery block. The store already exposes `update_runtime(task_id, update_fn)`, which performs the locked read-modify-write and returns the resulting runtime, and which the manager **already uses** at `manager.py:277` and `manager.py:659` (and the worker at `worker.py:276`). No `update_runtime_under_lock()` API is needed and none exists.
181+
182+
**Robust fix:**
183+
184+
- **Six mark-methods (`821-912`):** resolved for free by Finding 2 — routing `_transition_status` through `update_runtime()` removes every private `_runtime_lock` / `_write_runtime_unlocked` call in these methods.
185+
- **Stale-recovery block (`598-626`):** this block reads *both* `read_runtime` and `read_control` under the same lock and branches on `fresh_control.kill_requested_at` (`manager.py:600,615`). `update_runtime`'s callback is only *passed* the runtime, but it is a closure over `self._store`, so it can call `self._store.read_control(view.spec.id)` itself — that read executes inside the same `_runtime_lock` and does not deadlock, because `read_runtime`/`read_control` are lock-free (`store.py` takes `_runtime_lock` only in `write_runtime`/`update_runtime`; the current block already calls `read_control` while holding the lock). So this path *can* migrate with **no new store API**. Two reasonable options:
186+
1. **Leave it as-is.** The block is a single, well-commented critical section (`manager.py:595-597` explains *why* the lock spans the read and the write). Holding `_runtime_lock` here is correct and the private access is localized. Most surgical, lowest-risk — the default recommendation.
187+
2. **Migrate via a closure that reads control in-callback.** Move the body into a `recover_stale(runtime) -> bool` closure passed to `update_runtime()`; the early-outs (terminal / not-yet-stale) become `return False`, replacing today's `continue`. The surrounding per-view loop is unchanged — each iteration calls `update_runtime(view.spec.id, recover_stale)`:
188+
189+
```python
190+
def recover_stale(runtime: TaskRuntime) -> bool:
191+
if is_terminal_status(runtime.status):
192+
return False
193+
progress = (
194+
runtime.heartbeat_at or runtime.started_at
195+
or runtime.updated_at or view.spec.created_at
196+
)
197+
if now - progress <= stale_after:
198+
return False
199+
control = self._store.read_control(view.spec.id) # inside the lock via the closure
200+
heartbeat_missing = runtime.heartbeat_at is None
201+
runtime.finished_at = now
202+
runtime.updated_at = now
203+
if control.kill_requested_at is not None:
204+
runtime.status = "killed"
205+
runtime.interrupted = True
206+
runtime.failure_reason = control.kill_reason or "Killed during recovery"
207+
else:
208+
runtime.status = "lost"
209+
runtime.failure_reason = (
210+
"Background worker never heartbeat after startup"
211+
if heartbeat_missing
212+
else "Background worker heartbeat expired"
213+
)
214+
return True
215+
216+
self._store.update_runtime(view.spec.id, recover_stale)
217+
```
218+
219+
Neither option needs a new lock-exposing or recovery-specific store method. Prefer (1) for minimal churn, or (2) if you want zero private-internal access from the manager.
220+
221+
---
222+
223+
### 4. `build_recall_block()` accepts an unused `store_path`
224+
225+
**File:** `src/pythinker_code/memory/recall.py:86-97,252-258`
226+
**Severity:** Low
227+
**Category:** Dead parameter / unnecessary private access
228+
229+
`build_recall_block()` accepts `store_path`, then immediately discards it with `_ = store_path` (`recall.py:97`). The call site computes that value through `self._store._ensure_dir()` (`recall.py:252`) purely to feed the dead parameter. `build_recall_block` is called only at `recall.py:253` and three tests, so the signature is safe to change.
230+
231+
**Robust fix:**
232+
233+
- Remove the `store_path` parameter from `build_recall_block()` (`recall.py:92`) and delete the `_ = store_path` discard line (`recall.py:97`).
234+
- At the call site, delete `store_root = await self._store._ensure_dir()` (`recall.py:252`) and the `store_path=str(store_root / "memory")` argument (`recall.py:258`). This also removes one private `_ensure_dir()` usage.
235+
- Update the three call sites that pass the kwarg, dropping `store_path=...`:
236+
- `tests/core/test_recall_provider.py:39` (`test_build_recall_block_includes_open_todos_and_facts`)
237+
- `tests/core/test_recall_provider.py:52` (`test_build_recall_block_empty_when_nothing`)
238+
- `tests/core/test_recall_provider.py:63` (`test_build_recall_block_open_todos_only_does_not_suggest_missing_files`)
239+
240+
The recall output is unchanged — the parameter and its value were never used in the block. The only incidental difference is that recall no longer eagerly creates the store directory via `_ensure_dir()`; the recall read path (candidates are passed in already loaded) does not depend on that side effect.
241+
242+
---
243+
244+
### 5. Memory consolidation calls a private store method
245+
246+
**File:** `src/pythinker_code/memory/consolidation.py:35-37`
247+
**Severity:** Low
248+
**Category:** Architectural coupling
249+
250+
`inbox_dir()` calls `ProjectMemoryStore._ensure_dir()` from outside `project_memory.py` and suppresses the private-usage warning (`consolidation.py:36`). After Finding 4 removes the `recall.py:252` usage, `consolidation.py:36` is the **only** external `_ensure_dir()` caller.
251+
252+
**Robust fix:**
253+
254+
- Add a public async alias on `ProjectMemoryStore` that delegates to the existing implementation:
255+
256+
```python
257+
async def ensure_root(self) -> Path:
258+
"""Public entry point for ``_ensure_dir`` used by collaborators."""
259+
return await self._ensure_dir()
260+
```
261+
262+
- Update `consolidation.py:36` to `root = await store.ensure_root()` and drop the `# pyright: ignore[reportPrivateUsage]` suppression.
263+
264+
Keep `_ensure_dir()` as the single source of truth; `ensure_root()` is only a public surface so collaborators don't reach into a private method. (Naming note: the background and notifications stores use a private `_ensure_root`; the public `ensure_root` here is intentional and reads cleanly as the published method.)
265+
266+
---
267+
268+
## Optional Cleanups
269+
270+
### A. Use `collections.Counter` for term frequency
271+
272+
**File:** `src/pythinker_code/memory/retriever.py:76-78`
273+
**Severity:** Low
274+
275+
Replace the manual term-frequency loop:
276+
277+
```python
278+
tf: dict[str, int] = {}
279+
for term in doc:
280+
tf[term] = tf.get(term, 0) + 1
281+
```
282+
283+
with `tf = Counter(doc)` (`from collections import Counter`). This is a drop-in: the scoring loop guards every access with `if term not in tf: continue` before reading `tf[term]`, so `Counter`'s default-zero behavior changes nothing. Readability cleanup only — not a correctness issue. The adjacent document-frequency loop (`retriever.py:67-70`) could likewise use `df.update(set(doc))`, but leave it unless you are already touching that block.
284+
285+
---
286+
287+
### B. OpenCode Go response parsing can be made clearer, but not stricter by accident
288+
289+
**File:** `src/pythinker_code/auth/opencode_go.py:179-228`
290+
**Severity:** Low/Medium
291+
292+
The current implementation manually validates `/models` and `models.dev` payloads with `isinstance()` and casts. The behavior is intentionally tolerant:
293+
294+
- malformed top-level payloads return an empty result (`_extract_model_ids``[]`, `_parse_models_dev_metadata``{}`);
295+
- malformed list/dict entries are skipped (`continue`) while valid entries are kept;
296+
- models.dev enrichment is best-effort and must not break login.
297+
298+
**Robust fix: prefer leaving this as-is.** The current `isinstance`/`cast` code already encodes exactly the tolerance required, and a Pydantic rewrite risks silently regressing it for no functional gain. If clarity is the goal, add a short comment documenting the tolerance contract rather than rewriting the parser.
299+
300+
If a Pydantic refactor is nonetheless mandated, it must preserve those semantics: validate entries **item-by-item** with `model_config = ConfigDict(extra="ignore")`, skipping individual `ValidationError`s, and never reject the whole response because one entry is malformed. Top-level shape mismatch must still yield empty results, and enrichment failure must never propagate into the login path.
301+
302+
---
303+
304+
## Verification Performed
305+
306+
Targeted validation command (re-run during this pass):
307+
308+
```bash
309+
uv run pytest \
310+
tests/background/test_manager.py::test_recover_agent_view_does_not_clobber_terminal_runtime_from_stale_view \
311+
tests/background/test_manager.py::test_mark_task_completed_is_lock_protected \
312+
tests/background/test_worker.py::test_worker_completes_successfully \
313+
tests/core/test_memory_phase_bcd.py::test_sqlite_retriever_falls_back_to_lexical \
314+
tests/core/test_recall_provider.py::test_build_recall_block_includes_open_todos_and_facts
315+
```
316+
317+
Result: **5 passed** (re-confirmed 2026-05-30, `0.08s`).
318+
319+
Note: after applying Finding 1 (delete the retriever seam) and Finding 4 (drop `store_path`), two of these tests change by design — `test_sqlite_retriever_falls_back_to_lexical` is deleted with the seam, and `test_build_recall_block_includes_open_todos_and_facts` drops its `store_path` kwarg. Re-run the full `tests/background` and `tests/core` suites after each fix to confirm no behavioral regressions.

0 commit comments

Comments
 (0)