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.
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/PKG-INFO +5 -2
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/README.md +4 -1
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/pyproject.toml +1 -1
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_candidate_reviewer.py +36 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/candidate_reviewer.py +117 -43
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper.egg-info/PKG-INFO +5 -2
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/LICENSE +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/setup.cfg +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_adapters.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_agent_status.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_brief_footprint.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_brief_sections.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_config_settings.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_core_memory.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_curator.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_dashboard.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_delegated_search.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_dialectic.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_dialectic_feed_tools.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_dialectic_miner.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_dialectic_observation_resolve.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_dialectic_recompute.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_dialectic_tier.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_dialectic_validator.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_error_paths.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_evolve_applier.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_evolve_apply_2.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_evolve_apply_3.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_evolve_daemon.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_extract_daemon.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_extract_dedup.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_i18n_multilang.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_identity.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_ingest_status.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_lessons.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_memory_guard.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_missed_spawns.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_nudges.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_onnx_embeddings.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_panel.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_probe_daemon.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_process_health.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_search_fts_punctuation.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_shadow_review.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_skill_hint.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_skill_passive_tier.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_skill_tier.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_skill_use_parser.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_skill_watcher.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_skills.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_spawn_budget.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_spawn_codex_stdin.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_spawn_config.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_spawn_hint.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_spawn_reap.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_spawn_slim.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_spawn_wrap.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_thread_janitor.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_threads.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_tools_smoke.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_validate_threads.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/tests/test_vec_search.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/__init__.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/_mcp.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/_setup.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/_spawn_wrap.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/adapters/__init__.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/adapters/_hook_helpers.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/adapters/base.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/adapters/claude_code.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/adapters/claude_desktop.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/adapters/codex.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/adapters/copilot.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/adapters/gemini.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/adapters/vscode.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/agent_status.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/brief.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/config.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/curator.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/db.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/dialectic_miner.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/dialectic_validator.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/embeddings.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/evolve_applier.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/evolve_daemon.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/extract_daemon.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/helpers.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/i18n.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/identity.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/ingest.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/lessons.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/memory_guard.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/menubar_app.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/migrate_embeddings.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/nudges.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/probe_daemon.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/process_health.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/review_prompts.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/search_proxy.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/server.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/shadow_review.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/skill_watcher.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/spawn_budget.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/spawn_config.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/thread_janitor.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/__init__.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/agent_status.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/candidate_reviewer.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/concepts.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/consolidate.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/core_memory.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/correlation.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/curator.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/dashboard.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/dialectic.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/dialectic_feed.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/dialog.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/distill.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/evolve_applier.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/extract.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/graph.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/invariants.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/lessons.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/memory_guard.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/missed_spawns.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/panel.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/peers.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/pickup.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/probes.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/process_health.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/session.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/shadow_review.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/skills.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/spawn.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/style.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/threads.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper/tools/validate.py +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper.egg-info/SOURCES.txt +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper.egg-info/dependency_links.txt +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper.egg-info/entry_points.txt +0 -0
- {threadkeeper-0.9.0 → threadkeeper-0.9.1}/threadkeeper.egg-info/requires.txt +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|