Skip to content

Commit c03c163

Browse files
committed
3.1.0 — Fix lock ordering, meta index counts, reindex MCP tool, semantic search optimization
1 parent 76457f0 commit c03c163

14 files changed

Lines changed: 420 additions & 68 deletions

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"source": "url",
1111
"url": "https://github.com/thrialectics/threadlinking.git"
1212
},
13-
"version": "3.0.1",
13+
"version": "3.1.0",
1414
"description": "Preserve decision-making context across sessions by linking files to the decisions behind them"
1515
}
1616
]

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "threadlinking",
33
"description": "Preserve decision-making context across sessions by linking files to the decisions behind them",
4-
"version": "3.0.1",
4+
"version": "3.1.0",
55
"author": {
66
"name": "Marianne"
77
},

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Threadlinking ships as a **Claude Code plugin** — when you run `threadlinking
1616

1717
### MCP Tools
1818

19-
Threadlinking exposes 12 tools to Claude Code via MCP:
19+
Threadlinking exposes 13 tools to Claude Code via MCP:
2020

2121
| Tool | Description |
2222
|------|-------------|
@@ -31,6 +31,7 @@ Threadlinking exposes 12 tools to Claude Code via MCP:
3131
| `threadlinking_semantic_search` | Search threads by semantic similarity |
3232
| `threadlinking_analytics` | Get usage analytics and insights |
3333
| `threadlinking_export` | Export thread(s) in markdown, JSON, or timeline format |
34+
| `threadlinking_reindex` | Rebuild the semantic search index |
3435
| `threadlinking_status` | Check available features and index status |
3536

3637
### Slash Commands
Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
---
2+
title: "refactor: Storage correctness, performance, and MCP completeness"
3+
type: refactor
4+
status: active
5+
date: 2026-03-09
6+
---
7+
8+
# Storage Correctness, Performance, and MCP Completeness
9+
10+
## Overview
11+
12+
Address five issues identified in code review: a lock ordering violation that risks deadlock, a performance bottleneck where `list` loads all thread files, a missing `reindex` MCP tool, unnecessary full-index loads in semantic search, and silent error swallowing in the `track` hook.
13+
14+
## Problem Statement
15+
16+
1. **Lock ordering deadlock**`updateThread()` acquires a per-thread lock, then calls `updateMetaIndex()` which acquires the meta lock. If another process holds the meta lock and needs a thread lock, classic deadlock. The 10s stale timeout makes this non-fatal but causes operation failures.
17+
18+
2. **`list` loads all thread files**`listThreads()` calls `loadAllThreads()` just to count snippets and files per thread. At 50+ threads this becomes a noticeable disk I/O bottleneck. The meta index only stores summary and dates — not counts.
19+
20+
3. **No `reindex` MCP tool** — Users working exclusively through Claude Code's MCP interface can't trigger a semantic index rebuild. The semantic search tells them to run `threadlinking reindex` but they have no way to do it from MCP.
21+
22+
4. **`semanticSearch` loads full index** — Every semantic search reads all thread files via `loadIndex()` just to resolve thread metadata for results. Could use the lightweight meta index instead.
23+
24+
5. **`track` swallows all errors** — The hook's `catch {}` means a corrupted `pending.json` silently stops tracking files until someone notices.
25+
26+
## Technical Approach
27+
28+
### Architecture
29+
30+
The fix centers on `src/core/storage.ts`. The key insight: **move the meta index update outside the thread lock** in `updateThread()`. This eliminates the nested lock acquisition that causes deadlock risk, and naturally leads to enriching the meta index with counts (since we need the meta update to be self-contained).
31+
32+
### Implementation Phases
33+
34+
#### Phase 1: Fix Lock Ordering (Correctness)
35+
36+
**Goal:** Eliminate deadlock risk by ensuring no function holds two locks simultaneously.
37+
38+
**Approach:** In `updateThread()`, release the thread lock *before* calling `updateMetaIndex()`. The thread file is already written atomically at that point — the meta update is a best-effort sync of metadata.
39+
40+
**Tasks:**
41+
42+
- [ ] `src/core/storage.ts` — Restructure `updateThread()`:
43+
1. Acquire thread lock
44+
2. Load, mutate, atomicWrite thread file
45+
3. Capture the updated thread data
46+
4. Release thread lock
47+
5. Call `updateMetaIndex()` (now outside thread lock)
48+
- The try/finally block needs restructuring — release must happen before the meta call
49+
- [ ] `src/core/storage.ts` — Audit `saveThread()`:
50+
- Currently: atomicWrite thread (no lock) → updateMetaIndex
51+
- This is fine (only one lock), but document the ordering
52+
- [ ] `src/core/operations/relate.ts` — Fix sequential `updateThread` calls:
53+
- Sort thread IDs alphabetically before acquiring locks to prevent A→B vs B→A deadlock
54+
- After Phase 1's `updateThread` fix, each call only holds one lock at a time, so this becomes less critical — but deterministic ordering is still good practice
55+
- [ ] `src/commands/rename.ts` — Audit sequential `saveThread` + `deleteThreadFile`:
56+
- Both only acquire meta lock — no deadlock risk, but document
57+
- [ ] `src/commands/clear.ts` — Audit loop of `deleteThreadFile`:
58+
- Sequential meta locks — no deadlock, but contention. Consider batching into single meta update
59+
- [ ] Add doc comment to `updateThread()` explaining lock ordering contract
60+
- [ ] Add doc comment to `updateMetaIndex()` explaining it must never be called while holding a thread lock (enforced by design after this change)
61+
62+
**Tests:**
63+
64+
- [ ] `tests/operations.test.ts` — Add concurrency test: two `addSnippet` calls to different threads in parallel should both succeed
65+
- [ ] `tests/operations.test.ts` — Add concurrency test: `addSnippet` + `listThreads` in parallel should not deadlock
66+
- [ ] `tests/operations.test.ts` — Verify `relateThreads(a, b)` and `relateThreads(b, a)` produce same result (deterministic ordering)
67+
68+
**Risk:** Brief window between thread write and meta update where they're inconsistent. This is acceptable because:
69+
- Meta index is advisory (listing/display only)
70+
- Thread file is the source of truth
71+
- `loadAllThreads()` doesn't use meta index
72+
- Worst case: a `list` shows stale count for <1 second
73+
74+
**Files:** `src/core/storage.ts`, `src/core/operations/relate.ts`, `tests/operations.test.ts`
75+
76+
---
77+
78+
#### Phase 2: Meta Index Carries Counts (Performance)
79+
80+
**Goal:** Eliminate `loadAllThreads()` from `list` by storing counts in the meta index.
81+
82+
**Approach:** Enrich `ThreadMeta` with `snippetCount` and `fileCount`. Update `extractMeta()` to include them. The meta index stays small (two extra numbers per thread) but removes the need to load full thread files for listing.
83+
84+
**Tasks:**
85+
86+
- [ ] `src/core/storage.ts` — Add to `ThreadMeta` interface:
87+
```typescript
88+
export interface ThreadMeta {
89+
summary: string;
90+
date_created: string;
91+
date_modified?: string;
92+
snippetCount?: number; // Optional for backward compat with existing index.json
93+
fileCount?: number;
94+
}
95+
```
96+
- [ ] `src/core/storage.ts` — Update `extractMeta()`:
97+
```typescript
98+
function extractMeta(thread: Thread): ThreadMeta {
99+
return {
100+
summary: thread.summary,
101+
date_created: thread.date_created,
102+
date_modified: thread.date_modified,
103+
snippetCount: (thread.snippets || []).length,
104+
fileCount: (thread.linked_files || []).length,
105+
};
106+
}
107+
```
108+
- [ ] `src/core/operations/list.ts` — Remove `loadAllThreads()` call:
109+
- Use `meta.threads[id].snippetCount ?? 0` instead of loading full thread
110+
- For the `allLinkedFiles` set (used to filter pending), fall back to `loadAllThreads()` only if any meta entry lacks `fileCount` (migration path)
111+
- Alternatively: track linked files in a separate lightweight structure (future optimization)
112+
- [ ] `src/core/operations/list.ts` — Handle migration gracefully:
113+
- If `snippetCount` is undefined in meta, fall back to `loadAllThreads()` (one-time until all threads are touched)
114+
- Consider adding a `rebuildMetaCounts()` function that iterates all threads once and updates meta
115+
- [ ] Migration: Add `rebuildMetaCounts()` to `src/core/storage.ts`:
116+
- Loads all threads once, updates meta with accurate counts
117+
- Called during `ensureMigrated()` if any thread lacks counts
118+
- Or exposed as CLI command for manual trigger
119+
120+
**Complication — `allLinkedFiles` set:**
121+
The `list` operation builds a set of all linked files across all threads to filter the pending list. This still requires reading all threads OR maintaining a separate index of linked files. Options:
122+
- **Option A:** Accept that `list` with pending files still loads all threads. Only optimize the thread listing part.
123+
- **Option B:** Store a flat `allLinkedFiles: string[]` in the meta index. Updated whenever attach/detach runs.
124+
- **Option C:** Skip the linked-file filter for pending — show all pending files regardless.
125+
126+
**Recommendation:** Option A for now. The snippet/file counts are the big win (avoids parsing all snippet content). The linked-files filter is a secondary optimization.
127+
128+
**Tests:**
129+
130+
- [ ] `tests/operations.test.ts` — Verify `listThreads()` returns correct counts from meta index
131+
- [ ] `tests/operations.test.ts` — Verify counts update after `addSnippet`
132+
- [ ] `tests/operations.test.ts` — Verify counts update after `attachFile` / `detachFile`
133+
- [ ] `tests/operations.test.ts` — Verify backward compat: old meta without counts falls back correctly
134+
135+
**Files:** `src/core/storage.ts`, `src/core/operations/list.ts`, `tests/operations.test.ts`
136+
137+
---
138+
139+
#### Phase 3: Add `reindex` MCP Tool (Workflow Gap)
140+
141+
**Goal:** Let MCP-only users trigger semantic index rebuild.
142+
143+
**Tasks:**
144+
145+
- [ ] `src/mcp/server.ts` — Add `threadlinking_reindex` tool:
146+
```typescript
147+
server.tool(
148+
'threadlinking_reindex',
149+
'Rebuild the semantic search index. Run after adding many snippets, or if semantic search returns stale results.',
150+
{},
151+
async () => {
152+
const result = await rebuildSemanticIndex();
153+
return {
154+
content: [{ type: 'text', text: result.message }],
155+
isError: !result.success,
156+
};
157+
}
158+
);
159+
```
160+
- [ ] Verify `rebuildSemanticIndex` is already exported from `src/core/index.js`
161+
- [ ] Update README MCP tools table — add `threadlinking_reindex` (13 tools now)
162+
- [ ] Update `threadlinking_status` tool to mention reindex availability
163+
164+
**Tests:**
165+
166+
- [ ] Manual test via MCP: call `threadlinking_reindex`, verify index rebuilds
167+
168+
**Files:** `src/mcp/server.ts`, `README.md`
169+
170+
---
171+
172+
#### Phase 4: Optimize Semantic Search (Performance)
173+
174+
**Goal:** Stop loading all thread files on every semantic search.
175+
176+
**Approach:** Use `loadMetaIndex()` instead of `loadIndex()` in `semanticSearch()`. The search results only need thread ID, summary, and dates — all available in meta. Only load full thread data if the caller specifically needs snippet content.
177+
178+
**Tasks:**
179+
180+
- [ ] `src/core/operations/semantic.ts` — In `semanticSearch()`:
181+
- Replace `loadIndex()` with `loadMetaIndex()`
182+
- Build results using `meta.threads[threadId]` instead of `threadIndex[threadId]`
183+
- The `SemanticSearchResult` type already uses a subset that maps to `ThreadMeta`
184+
- [ ] `src/core/types.ts` — Verify `SemanticSearchResult` shape:
185+
- Check if it references full `Thread` or just metadata fields
186+
- If it needs full thread data (e.g., matched snippet content), load only the matched thread files (top N)
187+
- [ ] `src/mcp/server.ts` — Verify MCP semantic search handler doesn't depend on full thread data
188+
189+
**Risk:** If `SemanticSearchResult` includes matched snippet content (not just IDs), we'd need to load the specific matched thread files. This is still better than loading all threads — typically 3-5 results vs 50+ threads.
190+
191+
**Tests:**
192+
193+
- [ ] `tests/operations.test.ts` — Semantic search returns correct results (existing tests should cover)
194+
- [ ] Performance: manual test with 50+ seeded threads to verify improvement
195+
196+
**Files:** `src/core/operations/semantic.ts`, possibly `src/core/types.ts`
197+
198+
---
199+
200+
#### Phase 5: Track Error Logging (Observability)
201+
202+
**Goal:** Make `track` command failures observable without blocking the editing workflow.
203+
204+
**Approach:** Write errors to a debug log file instead of swallowing silently. The hook stays async and non-blocking — it just leaves a trail.
205+
206+
**Tasks:**
207+
208+
- [ ] `src/commands/track.ts` — In the catch block:
209+
```typescript
210+
catch (error) {
211+
try {
212+
const logPath = join(homedir(), '.threadlinking', 'debug.log');
213+
const timestamp = new Date().toISOString();
214+
const message = error instanceof Error ? error.message : String(error);
215+
appendFileSync(logPath, `[${timestamp}] track error: ${message}\n`);
216+
} catch {
217+
// If we can't even log, truly swallow
218+
}
219+
}
220+
```
221+
- [ ] Consider log rotation — if `debug.log` exceeds 100KB, truncate to last 50KB on write
222+
- [ ] Add `debug.log` to `.threadlinkingignore` default content so it doesn't show as pending
223+
224+
**Tests:**
225+
226+
- [ ] `tests/operations.test.ts` — Verify track with corrupted pending.json writes to debug.log
227+
228+
**Files:** `src/commands/track.ts`, possibly `src/core/ignore.ts` (default ignore content)
229+
230+
---
231+
232+
## Acceptance Criteria
233+
234+
### Functional Requirements
235+
236+
- [ ] No function holds two locks simultaneously
237+
- [ ] `threadlinking list` with 50+ threads does not load all thread files (when meta has counts)
238+
- [ ] `threadlinking_reindex` MCP tool exists and rebuilds the semantic index
239+
- [ ] `semanticSearch` does not call `loadAllThreads()` or `loadIndex()`
240+
- [ ] `track` errors are logged to `~/.threadlinking/debug.log`
241+
242+
### Non-Functional Requirements
243+
244+
- [ ] All 195 existing tests pass after each phase
245+
- [ ] Build succeeds with no TypeScript errors after each phase
246+
- [ ] Backward compatible: existing `index.json` without counts works (graceful fallback)
247+
- [ ] Lock ordering documented in code comments
248+
249+
### Quality Gates
250+
251+
- [ ] Concurrency tests added for Phase 1
252+
- [ ] Count accuracy tests added for Phase 2
253+
- [ ] Each phase is a separate commit for clean revert if needed
254+
255+
## Dependencies & Prerequisites
256+
257+
- Phase 2 depends on Phase 1 (meta update logic changes in Phase 1 affect how counts are written)
258+
- Phase 3 is independent (can be done anytime)
259+
- Phase 4 is independent (can be done anytime)
260+
- Phase 5 is independent (can be done anytime)
261+
262+
```
263+
Phase 1 (lock ordering) ──→ Phase 2 (meta counts)
264+
Phase 3 (reindex MCP) [independent]
265+
Phase 4 (semantic perf) [independent]
266+
Phase 5 (track logging) [independent]
267+
```
268+
269+
## Risk Analysis & Mitigation
270+
271+
| Risk | Likelihood | Impact | Mitigation |
272+
|------|-----------|--------|------------|
273+
| Phase 1 introduces brief meta inconsistency | Certain | Low | Meta is advisory; thread file is source of truth |
274+
| Phase 2 migration misses threads | Low | Medium | Fallback to `loadAllThreads()` when counts missing |
275+
| Phase 2 counts drift from reality | Low | Low | `extractMeta()` recalculates on every thread update |
276+
| Phase 4 breaks semantic search result format | Low | Medium | Verify result type compatibility before changing |
277+
278+
## References
279+
280+
### Internal References
281+
282+
- Storage layer: `src/core/storage.ts` — all locking, atomic writes, migration
283+
- Meta index structure: `src/core/storage.ts:42-51` (`ThreadMeta`, `MetaIndex`)
284+
- Lock options: `src/core/storage.ts:66-69` (stale: 10s, update: 5s)
285+
- `updateThread`: `src/core/storage.ts:278-313`
286+
- `updateMetaIndex`: `src/core/storage.ts:220-236`
287+
- `extractMeta`: `src/core/storage.ts:117-123`
288+
- `loadAllThreads`: `src/core/storage.ts:339-356`
289+
- List operation: `src/core/operations/list.ts:14-17` (hardcoded `needCounts = true`)
290+
- Semantic search: `src/core/operations/semantic.ts`
291+
- Track command: `src/commands/track.ts`
292+
- MCP server: `src/mcp/server.ts`
293+
- Test patterns: `tests/operations.test.ts`
294+
- Relate operation (new): `src/core/operations/relate.ts` — sequential `updateThread` calls
295+
296+
### Lock Acquisition Map
297+
298+
| Function | Lock 1 | Lock 2 (nested) | Risk |
299+
|----------|--------|-----------------|------|
300+
| `updateThread(id, fn)` | Per-thread file | Meta index (via `updateMetaIndex`) | **Deadlock** |
301+
| `updateMetaIndex(fn)` | Meta index || Safe |
302+
| `saveThread(id, thread)` || Meta index (via `updateMetaIndex`) | Safe |
303+
| `deleteThreadFile(id)` || Meta index (via `updateMetaIndex`) | Safe |
304+
| `updatePending(fn)` | Pending file || Safe |
305+
| `updateIndex(fn)` | Meta index (global) || Safe |

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "threadlinking",
3-
"version": "3.0.1",
3+
"version": "3.1.0",
44
"description": "Preserve the decisions behind your code",
55
"type": "module",
66
"files": [

src/commands/track.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { Command } from 'commander';
2-
import { existsSync } from 'fs';
2+
import { existsSync, appendFileSync } from 'fs';
3+
import { join } from 'path';
4+
import { homedir } from 'os';
35
import { loadIndex, updatePending, resolvePath } from '../core/index.js';
46
import { isIgnored } from '../core/ignore.js';
57

@@ -59,7 +61,14 @@ export const trackCommand = new Command('track')
5961
if (!options.quiet && wasTracked) {
6062
console.log(`Tracked: ${resolvedPath}`);
6163
}
62-
} catch {
63-
// Fail silently - this runs on every file write
64+
} catch (error) {
65+
try {
66+
const logPath = join(homedir(), '.threadlinking', 'debug.log');
67+
const timestamp = new Date().toISOString();
68+
const message = error instanceof Error ? error.message : String(error);
69+
appendFileSync(logPath, `[${timestamp}] track error: ${message}\n`);
70+
} catch {
71+
// If we can't even log, truly swallow
72+
}
6473
}
6574
});

src/core/ignore.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ const DEFAULT_IGNORE_CONTENT = `# Threadlinking ignore patterns (gitignore synta
5555
**/.venv/**
5656
**/venv/**
5757
**/__pycache__/**
58+
59+
# Threadlinking debug log
60+
debug.log
5861
`;
5962

6063
interface CacheEntry {

0 commit comments

Comments
 (0)