threadkeeper 0.9.0__tar.gz → 0.9.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/PKG-INFO +5 -2
  2. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/README.md +4 -1
  3. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/pyproject.toml +1 -1
  4. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_candidate_reviewer.py +36 -0
  5. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/candidate_reviewer.py +117 -43
  6. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper.egg-info/PKG-INFO +5 -2
  7. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/LICENSE +0 -0
  8. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/setup.cfg +0 -0
  9. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_adapters.py +0 -0
  10. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_agent_status.py +0 -0
  11. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_brief_footprint.py +0 -0
  12. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_brief_sections.py +0 -0
  13. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_config_settings.py +0 -0
  14. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_core_memory.py +0 -0
  15. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_curator.py +0 -0
  16. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_dashboard.py +0 -0
  17. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_delegated_search.py +0 -0
  18. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_dialectic.py +0 -0
  19. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_dialectic_feed_tools.py +0 -0
  20. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_dialectic_miner.py +0 -0
  21. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_dialectic_observation_resolve.py +0 -0
  22. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_dialectic_recompute.py +0 -0
  23. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_dialectic_tier.py +0 -0
  24. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_dialectic_validator.py +0 -0
  25. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_error_paths.py +0 -0
  26. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_evolve_applier.py +0 -0
  27. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_evolve_apply_2.py +0 -0
  28. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_evolve_apply_3.py +0 -0
  29. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_evolve_daemon.py +0 -0
  30. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_extract_daemon.py +0 -0
  31. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_extract_dedup.py +0 -0
  32. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_i18n_multilang.py +0 -0
  33. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_identity.py +0 -0
  34. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_ingest_status.py +0 -0
  35. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_lessons.py +0 -0
  36. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_memory_guard.py +0 -0
  37. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_missed_spawns.py +0 -0
  38. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_nudges.py +0 -0
  39. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_onnx_embeddings.py +0 -0
  40. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_panel.py +0 -0
  41. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_probe_daemon.py +0 -0
  42. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_process_health.py +0 -0
  43. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_search_fts_punctuation.py +0 -0
  44. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_shadow_review.py +0 -0
  45. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_skill_hint.py +0 -0
  46. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_skill_passive_tier.py +0 -0
  47. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_skill_tier.py +0 -0
  48. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_skill_use_parser.py +0 -0
  49. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_skill_watcher.py +0 -0
  50. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_skills.py +0 -0
  51. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_spawn_budget.py +0 -0
  52. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_spawn_codex_stdin.py +0 -0
  53. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_spawn_config.py +0 -0
  54. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_spawn_hint.py +0 -0
  55. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_spawn_reap.py +0 -0
  56. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_spawn_slim.py +0 -0
  57. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_spawn_wrap.py +0 -0
  58. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_thread_janitor.py +0 -0
  59. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_threads.py +0 -0
  60. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_tools_smoke.py +0 -0
  61. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_validate_threads.py +0 -0
  62. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_vec_search.py +0 -0
  63. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/__init__.py +0 -0
  64. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/_mcp.py +0 -0
  65. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/_setup.py +0 -0
  66. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/_spawn_wrap.py +0 -0
  67. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/adapters/__init__.py +0 -0
  68. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/adapters/_hook_helpers.py +0 -0
  69. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/adapters/base.py +0 -0
  70. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/adapters/claude_code.py +0 -0
  71. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/adapters/claude_desktop.py +0 -0
  72. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/adapters/codex.py +0 -0
  73. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/adapters/copilot.py +0 -0
  74. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/adapters/gemini.py +0 -0
  75. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/adapters/vscode.py +0 -0
  76. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/agent_status.py +0 -0
  77. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/brief.py +0 -0
  78. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/config.py +0 -0
  79. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/curator.py +0 -0
  80. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/db.py +0 -0
  81. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/dialectic_miner.py +0 -0
  82. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/dialectic_validator.py +0 -0
  83. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/embeddings.py +0 -0
  84. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/evolve_applier.py +0 -0
  85. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/evolve_daemon.py +0 -0
  86. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/extract_daemon.py +0 -0
  87. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/helpers.py +0 -0
  88. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/i18n.py +0 -0
  89. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/identity.py +0 -0
  90. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/ingest.py +0 -0
  91. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/lessons.py +0 -0
  92. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/memory_guard.py +0 -0
  93. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/menubar_app.py +0 -0
  94. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/migrate_embeddings.py +0 -0
  95. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/nudges.py +0 -0
  96. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/probe_daemon.py +0 -0
  97. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/process_health.py +0 -0
  98. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/review_prompts.py +0 -0
  99. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/search_proxy.py +0 -0
  100. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/server.py +0 -0
  101. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/shadow_review.py +0 -0
  102. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/skill_watcher.py +0 -0
  103. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/spawn_budget.py +0 -0
  104. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/spawn_config.py +0 -0
  105. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/thread_janitor.py +0 -0
  106. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/__init__.py +0 -0
  107. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/agent_status.py +0 -0
  108. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/candidate_reviewer.py +0 -0
  109. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/concepts.py +0 -0
  110. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/consolidate.py +0 -0
  111. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/core_memory.py +0 -0
  112. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/correlation.py +0 -0
  113. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/curator.py +0 -0
  114. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/dashboard.py +0 -0
  115. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/dialectic.py +0 -0
  116. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/dialectic_feed.py +0 -0
  117. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/dialog.py +0 -0
  118. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/distill.py +0 -0
  119. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/evolve_applier.py +0 -0
  120. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/extract.py +0 -0
  121. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/graph.py +0 -0
  122. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/invariants.py +0 -0
  123. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/lessons.py +0 -0
  124. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/memory_guard.py +0 -0
  125. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/missed_spawns.py +0 -0
  126. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/panel.py +0 -0
  127. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/peers.py +0 -0
  128. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/pickup.py +0 -0
  129. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/probes.py +0 -0
  130. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/process_health.py +0 -0
  131. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/session.py +0 -0
  132. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/shadow_review.py +0 -0
  133. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/skills.py +0 -0
  134. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/spawn.py +0 -0
  135. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/style.py +0 -0
  136. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/threads.py +0 -0
  137. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/validate.py +0 -0
  138. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper.egg-info/SOURCES.txt +0 -0
  139. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper.egg-info/dependency_links.txt +0 -0
  140. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper.egg-info/entry_points.txt +0 -0
  141. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper.egg-info/requires.txt +0 -0
  142. {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: threadkeeper
3
- Version: 0.9.0
3
+ Version: 0.9.1
4
4
  Summary: Multi-agent shared brain across Claude Code/Desktop, Codex, Gemini, Copilot, VS Code. Cross-session memory, self-improving skill loops, inter-agent signaling — one local MCP server.
5
5
  Author: thread-keeper contributors
6
6
  License: MIT
@@ -365,7 +365,10 @@ Hard limits: max 2 new skills per pass, `[PROTECTED]` (pinned +
365
365
  foreground-authored) skills off-limits. Closes the gap between
366
366
  heuristic harvest and SKILL.md materialization — previously pending
367
367
  candidates accumulated indefinitely waiting for an agent to call
368
- `accept_candidate()` manually.
368
+ `accept_candidate()` manually. The loop is machine-wide single-flight:
369
+ while one reviewer child is running, other foreground servers/ticks report
370
+ `candidate_review_running` instead of spawning another child for the same
371
+ queue.
369
372
 
370
373
  #### 5. Autonomous Curator
371
374
 
@@ -324,7 +324,10 @@ Hard limits: max 2 new skills per pass, `[PROTECTED]` (pinned +
324
324
  foreground-authored) skills off-limits. Closes the gap between
325
325
  heuristic harvest and SKILL.md materialization — previously pending
326
326
  candidates accumulated indefinitely waiting for an agent to call
327
- `accept_candidate()` manually.
327
+ `accept_candidate()` manually. The loop is machine-wide single-flight:
328
+ while one reviewer child is running, other foreground servers/ticks report
329
+ `candidate_review_running` instead of spawning another child for the same
330
+ queue.
328
331
 
329
332
  #### 5. Autonomous Curator
330
333
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "threadkeeper"
7
- version = "0.9.0"
7
+ version = "0.9.1"
8
8
  description = "Multi-agent shared brain across Claude Code/Desktop, Codex, Gemini, Copilot, VS Code. Cross-session memory, self-improving skill loops, inter-agent signaling — one local MCP server."
9
9
  requires-python = ">=3.11"
10
10
  authors = [{ name = "thread-keeper contributors" }]
@@ -7,6 +7,7 @@ fork.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import os
10
11
  import sys
11
12
  import time
12
13
  from pathlib import Path
@@ -205,6 +206,41 @@ def test_run_spawns_when_threshold_met(tmp_path, monkeypatch):
205
206
  assert "Bash" not in allowed
206
207
 
207
208
 
209
+ def test_single_flight_when_reviewer_child_running(tmp_path, monkeypatch):
210
+ """Candidate review consumes one global queue; don't spawn duplicates."""
211
+ pkg = _bootstrap(tmp_path, monkeypatch, min_n="3")
212
+ conn = pkg["db"].get_db()
213
+ for i in range(4):
214
+ _seed_pending(conn, "verbatim", f"candidate {i}", age_s=60 + i)
215
+ conn.execute(
216
+ "INSERT INTO tasks "
217
+ "(id, pid, parent_cid, spawned_cid, cwd, prompt, started_at) "
218
+ "VALUES ('tk_running_review', ?, 'p', 'c', '/x', ?, ?)",
219
+ (
220
+ os.getpid(),
221
+ "You are a CANDIDATE REVIEWER for thread-keeper's extract queue.",
222
+ int(time.time()) - 30,
223
+ ),
224
+ )
225
+ conn.commit()
226
+
227
+ import threadkeeper.tools.spawn as spawn_mod
228
+
229
+ def fail_spawn(**kwargs): # pragma: no cover - should not be called
230
+ raise AssertionError("spawn should not run while reviewer is active")
231
+
232
+ monkeypatch.setattr(spawn_mod, "spawn", fail_spawn)
233
+
234
+ out = pkg["candidate_reviewer"].run_review_pass(force=True)
235
+
236
+ assert out == "candidate_review_running n=1 (single-flight)"
237
+ row = conn.execute(
238
+ "SELECT summary FROM events WHERE kind='candidate_review_pass' "
239
+ "ORDER BY id DESC LIMIT 1"
240
+ ).fetchone()
241
+ assert "candidate_review_running n=1" in row["summary"]
242
+
243
+
208
244
  # ──────────────────────────────────────────────────────────────────────
209
245
  # Daemon lifecycle
210
246
  # ──────────────────────────────────────────────────────────────────────
@@ -35,6 +35,7 @@ Hardstops:
35
35
 
36
36
  from __future__ import annotations
37
37
 
38
+ from contextlib import contextmanager
38
39
  import logging
39
40
  import sqlite3
40
41
  import threading
@@ -43,6 +44,7 @@ import time
43
44
  from .config import (
44
45
  CANDIDATE_REVIEW_INTERVAL_S,
45
46
  CANDIDATE_REVIEW_MIN,
47
+ DB_PATH,
46
48
  )
47
49
  from .db import get_db
48
50
  from . import identity
@@ -51,6 +53,7 @@ logger = logging.getLogger(__name__)
51
53
 
52
54
  _started = False
53
55
 
56
+ CANDIDATE_REVIEW_PROMPT_PREFIX = "You are a CANDIDATE REVIEWER"
54
57
 
55
58
  CANDIDATE_REVIEW_PROMPT = """\
56
59
  You are a CANDIDATE REVIEWER for thread-keeper's extract queue.
@@ -247,6 +250,67 @@ def _collect_pending(conn: sqlite3.Connection) -> tuple[str, int]:
247
250
  return ("\n".join(parts), len(rows))
248
251
 
249
252
 
253
+ def _running_reviewer_children(conn: sqlite3.Connection) -> list[str]:
254
+ """Running candidate-review task ids, reaping dead rows.
255
+
256
+ Candidate review consumes one global queue. Two reviewers launched from
257
+ different foreground MCP servers will read the same candidates and burn
258
+ memory doing duplicate work, so this loop is machine-wide single-flight.
259
+ """
260
+ from .helpers import alive
261
+ try:
262
+ rows = conn.execute(
263
+ "SELECT id, pid FROM tasks WHERE ended_at IS NULL "
264
+ "AND prompt LIKE ?",
265
+ (CANDIDATE_REVIEW_PROMPT_PREFIX + "%",),
266
+ ).fetchall()
267
+ except sqlite3.OperationalError:
268
+ return []
269
+ now = int(time.time())
270
+ running: list[str] = []
271
+ touched = False
272
+ for r in rows:
273
+ pid = int(r["pid"] or 0)
274
+ if pid > 0 and not alive(pid):
275
+ conn.execute(
276
+ "UPDATE tasks SET ended_at=? WHERE id=? AND ended_at IS NULL",
277
+ (now, r["id"]),
278
+ )
279
+ touched = True
280
+ continue
281
+ running.append(r["id"])
282
+ if touched:
283
+ conn.commit()
284
+ return running
285
+
286
+
287
+ @contextmanager
288
+ def _review_spawn_lock():
289
+ """Cross-process guard for check-running-then-spawn.
290
+
291
+ The tasks-table running check is not atomic across foreground MCP
292
+ processes. A short file lock prevents two daemon ticks from both observing
293
+ no reviewer and spawning duplicate children for the same pending queue.
294
+ """
295
+ try:
296
+ import fcntl
297
+ except ImportError: # pragma: no cover - thread-keeper runs on Unix CLIs.
298
+ yield True
299
+ return
300
+ lock_path = DB_PATH.parent / "candidate-reviewer.lock"
301
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
302
+ with lock_path.open("w") as lock:
303
+ try:
304
+ fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
305
+ except BlockingIOError:
306
+ yield False
307
+ return
308
+ try:
309
+ yield True
310
+ finally:
311
+ fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
312
+
313
+
250
314
  # ──────────────────────────────────────────────────────────────────────
251
315
  # Synchronous pass + daemon loop
252
316
  # ──────────────────────────────────────────────────────────────────────
@@ -263,53 +327,63 @@ def run_review_pass(force: bool = False) -> str:
263
327
  """
264
328
  if CANDIDATE_REVIEW_INTERVAL_S <= 0 and not force:
265
329
  return "disabled"
266
- conn = get_db()
267
- inventory, n_pending = _collect_pending(conn)
268
- now = int(time.time())
269
- if n_pending < CANDIDATE_REVIEW_MIN:
270
- _record_review_pass(
271
- conn, now,
272
- f"below_threshold pending={n_pending} "
273
- f"min={CANDIDATE_REVIEW_MIN}",
330
+ with _review_spawn_lock() as locked:
331
+ if not locked:
332
+ return "candidate_review_running n=1 (single-flight lock)"
333
+
334
+ conn = get_db()
335
+ now = int(time.time())
336
+ running = _running_reviewer_children(conn)
337
+ if running:
338
+ out = f"candidate_review_running n={len(running)} (single-flight)"
339
+ _record_review_pass(conn, now, out)
340
+ return out
341
+
342
+ inventory, n_pending = _collect_pending(conn)
343
+ if n_pending < CANDIDATE_REVIEW_MIN:
344
+ _record_review_pass(
345
+ conn, now,
346
+ f"below_threshold pending={n_pending} "
347
+ f"min={CANDIDATE_REVIEW_MIN}",
348
+ )
349
+ return f"below_threshold n={n_pending}"
350
+
351
+ active_skills = _active_skills_dump(conn)
352
+ full_prompt = (
353
+ CANDIDATE_REVIEW_PROMPT
354
+ + active_skills
355
+ + inventory
274
356
  )
275
- return f"below_threshold n={n_pending}"
276
357
 
277
- active_skills = _active_skills_dump(conn)
278
- full_prompt = (
279
- CANDIDATE_REVIEW_PROMPT
280
- + active_skills
281
- + inventory
282
- )
358
+ from .tools.spawn import spawn # type: ignore
359
+ try:
360
+ result = spawn(
361
+ prompt=full_prompt,
362
+ visible=False,
363
+ capture_output=True,
364
+ permission_mode="auto",
365
+ role="candidate_reviewer",
366
+ write_origin="candidate_review",
367
+ slim=True,
368
+ extra_allowed_tools=(
369
+ "mcp__thread-keeper__skill_manage,"
370
+ "mcp__thread-keeper__skill_list,"
371
+ "mcp__thread-keeper__accept_candidate,"
372
+ "mcp__thread-keeper__reject_candidate,"
373
+ "mcp__thread-keeper__lesson_append,"
374
+ "mcp__thread-keeper__mark_skill_materialized,"
375
+ "Read,Write"
376
+ ),
377
+ )
378
+ except Exception as e:
379
+ _record_review_pass(conn, now, f"spawn_error: {e}")
380
+ return f"spawn_error: {e}"
283
381
 
284
- from .tools.spawn import spawn # type: ignore
285
- try:
286
- result = spawn(
287
- prompt=full_prompt,
288
- visible=False,
289
- capture_output=True,
290
- permission_mode="auto",
291
- role="candidate_reviewer",
292
- write_origin="candidate_review",
293
- slim=True,
294
- extra_allowed_tools=(
295
- "mcp__thread-keeper__skill_manage,"
296
- "mcp__thread-keeper__skill_list,"
297
- "mcp__thread-keeper__accept_candidate,"
298
- "mcp__thread-keeper__reject_candidate,"
299
- "mcp__thread-keeper__lesson_append,"
300
- "mcp__thread-keeper__mark_skill_materialized,"
301
- "Read,Write"
302
- ),
382
+ _record_review_pass(
383
+ conn, now,
384
+ f"spawned pending={n_pending} :: {str(result)[:140]}",
303
385
  )
304
- except Exception as e:
305
- _record_review_pass(conn, now, f"spawn_error: {e}")
306
- return f"spawn_error: {e}"
307
-
308
- _record_review_pass(
309
- conn, now,
310
- f"spawned pending={n_pending} :: {str(result)[:140]}",
311
- )
312
- return str(result)
386
+ return str(result)
313
387
 
314
388
 
315
389
  def _serve_loop() -> None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: threadkeeper
3
- Version: 0.9.0
3
+ Version: 0.9.1
4
4
  Summary: Multi-agent shared brain across Claude Code/Desktop, Codex, Gemini, Copilot, VS Code. Cross-session memory, self-improving skill loops, inter-agent signaling — one local MCP server.
5
5
  Author: thread-keeper contributors
6
6
  License: MIT
@@ -365,7 +365,10 @@ Hard limits: max 2 new skills per pass, `[PROTECTED]` (pinned +
365
365
  foreground-authored) skills off-limits. Closes the gap between
366
366
  heuristic harvest and SKILL.md materialization — previously pending
367
367
  candidates accumulated indefinitely waiting for an agent to call
368
- `accept_candidate()` manually.
368
+ `accept_candidate()` manually. The loop is machine-wide single-flight:
369
+ while one reviewer child is running, other foreground servers/ticks report
370
+ `candidate_review_running` instead of spawning another child for the same
371
+ queue.
369
372
 
370
373
  #### 5. Autonomous Curator
371
374
 
File without changes
File without changes