threadkeeper 0.5.3__tar.gz → 0.6.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.5.3 → threadkeeper-0.6.1}/PKG-INFO +19 -2
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/README.md +18 -1
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/pyproject.toml +1 -1
- threadkeeper-0.6.1/tests/test_memory_guard.py +255 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/tests/test_nudges.py +66 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/tests/test_shadow_review.py +55 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/tests/test_spawn_slim.py +17 -2
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/_setup.py +37 -5
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/brief.py +28 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/candidate_reviewer.py +5 -4
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/config.py +52 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/curator.py +5 -3
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/db.py +16 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/embeddings.py +52 -18
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/extract_daemon.py +5 -3
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/identity.py +10 -0
- threadkeeper-0.6.1/threadkeeper/memory_guard.py +491 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/nudges.py +62 -1
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/server.py +1 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/shadow_review.py +53 -12
- threadkeeper-0.6.1/threadkeeper/tools/memory_guard.py +128 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/tools/spawn.py +36 -10
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper.egg-info/PKG-INFO +19 -2
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper.egg-info/SOURCES.txt +3 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/LICENSE +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/setup.cfg +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/tests/test_adapters.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/tests/test_brief_sections.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/tests/test_candidate_reviewer.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/tests/test_core_memory.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/tests/test_curator.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/tests/test_delegated_search.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/tests/test_dialectic.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/tests/test_dialectic_tier.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/tests/test_error_paths.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/tests/test_extract_daemon.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/tests/test_i18n_multilang.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/tests/test_identity.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/tests/test_lessons.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/tests/test_missed_spawns.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/tests/test_process_health.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/tests/test_skill_hint.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/tests/test_skill_tier.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/tests/test_skill_use_parser.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/tests/test_skill_watcher.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/tests/test_skills.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/tests/test_spawn_budget.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/tests/test_spawn_config.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/tests/test_spawn_hint.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/tests/test_threads.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/tests/test_tools_smoke.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/tests/test_validate_threads.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/tests/test_vec_search.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/__init__.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/_mcp.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/adapters/__init__.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/adapters/_hook_helpers.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/adapters/base.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/adapters/claude_code.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/adapters/claude_desktop.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/adapters/codex.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/adapters/copilot.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/adapters/gemini.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/adapters/vscode.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/helpers.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/i18n.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/ingest.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/lessons.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/process_health.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/review_prompts.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/search_proxy.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/skill_watcher.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/spawn_budget.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/spawn_config.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/tools/__init__.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/tools/candidate_reviewer.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/tools/concepts.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/tools/consolidate.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/tools/core_memory.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/tools/correlation.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/tools/curator.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/tools/dialectic.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/tools/dialog.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/tools/distill.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/tools/extract.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/tools/graph.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/tools/invariants.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/tools/lessons.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/tools/missed_spawns.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/tools/peers.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/tools/pickup.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/tools/probes.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/tools/process_health.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/tools/session.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/tools/shadow_review.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/tools/skills.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/tools/style.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/tools/threads.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper/tools/validate.py +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper.egg-info/dependency_links.txt +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper.egg-info/entry_points.txt +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.1}/threadkeeper.egg-info/requires.txt +0 -0
- {threadkeeper-0.5.3 → threadkeeper-0.6.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.
|
|
3
|
+
Version: 0.6.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
|
|
@@ -183,6 +183,8 @@ Claude session via a `claude -p` subprocess. By default `slim=True`: the
|
|
|
183
183
|
child loads only the thread-keeper MCP, no embeddings, no third-party
|
|
184
184
|
servers. ~500 MB RSS versus ~1.3 GB for a full child. Heuristic for the
|
|
185
185
|
parent: N≥2 modular independent units of ≥5 min each = spawn signal.
|
|
186
|
+
Spawn also marks children with `THREADKEEPER_SPAWNED_CHILD=1`, so
|
|
187
|
+
autonomous learning daemons cannot recursively start inside review forks.
|
|
186
188
|
|
|
187
189
|
A daemon measures combined child RSS every 10 s; admission control
|
|
188
190
|
refuses a new spawn that would exceed `THREADKEEPER_SPAWN_BUDGET_MB`
|
|
@@ -275,7 +277,11 @@ the last cursor **across all CLIs at once**. The window filters
|
|
|
275
277
|
internal review-child sessions (no self-pollution) and strips adapter
|
|
276
278
|
`[tool_result]` / `[tool_call]` noise (the "clean context" rule). If
|
|
277
279
|
≥500 chars of meaningful signal remain, spawns a slim observer child
|
|
278
|
-
that decides on class-level learning.
|
|
280
|
+
that decides on class-level learning. It is single-flight across the shared
|
|
281
|
+
DB: if any shadow observer task is already running, the daemon does not spawn
|
|
282
|
+
another one and does not advance the cursor. Shadow observer children are
|
|
283
|
+
marked as spawned/background processes, so they cannot start their own shadow
|
|
284
|
+
daemon even if a CLI drops the no-embeddings env. Idempotent through
|
|
279
285
|
`events.kind='shadow_review_pass'`.
|
|
280
286
|
|
|
281
287
|
#### 3. Extract daemon
|
|
@@ -418,8 +424,19 @@ The most-used env knobs (full list in `threadkeeper/config.py`):
|
|
|
418
424
|
| `THREADKEEPER_CURATOR_MIN_LESSONS` | 3 | min lessons before curator engages |
|
|
419
425
|
| `THREADKEEPER_CURATOR_DESTRUCTIVE` | "" (advisory) | when "1": curator child applies its own PATCH/PRUNE/CONSOLIDATE directly instead of writing advisory REPORT only |
|
|
420
426
|
| `THREADKEEPER_SPAWN_BUDGET_MB` | 3072 | combined child RSS cap (MB); 0 disables |
|
|
427
|
+
| `THREADKEEPER_MEMORY_GUARD_POLL_S` | 30 | server RSS guard tick (s); 0 disables |
|
|
428
|
+
| `THREADKEEPER_MEMORY_GUARD_WARN_MB` | 1536 | notify/log when a server crosses this RSS |
|
|
429
|
+
| `THREADKEEPER_MEMORY_GUARD_KILL_MB` | 3072 | SIGTERM server above this RSS; 0 disables killing |
|
|
430
|
+
| `THREADKEEPER_MEMORY_GUARD_AGG_WARN_MB` | 2048 | notify/request trim when all server RSS crosses this |
|
|
431
|
+
| `THREADKEEPER_MEMORY_GUARD_AGG_KILL_MB` | 3072 | under aggregate pressure, retire stale idle servers |
|
|
432
|
+
| `THREADKEEPER_MEMORY_GUARD_RECLAIM_MB` | 1024 | local RSS floor before warn-triggered self trim |
|
|
433
|
+
| `THREADKEEPER_MEMORY_GUARD_TARGET_SERVERS` | 1 | aggregate-pressure target after retiring stale idle servers |
|
|
434
|
+
| `THREADKEEPER_MEMORY_GUARD_RETIRE_IDLE_S` | 900 | heartbeat age before a non-self server is retireable |
|
|
435
|
+
| `THREADKEEPER_MEMORY_GUARD_RETIRE_LIVE` | "" (off) | allow retiring parent-alive MCP servers; off protects live clients |
|
|
436
|
+
| `THREADKEEPER_MEMORY_GUARD_NOTIFY` | "1" | send macOS desktop notification when possible |
|
|
421
437
|
| `THREADKEEPER_INGEST_INTERVAL_S` | 3 | transcript ingest tick (s) |
|
|
422
438
|
| `THREADKEEPER_NO_EMBEDDINGS` | "" | force-disable sentence-transformers |
|
|
439
|
+
| `THREADKEEPER_SPAWNED_CHILD` | "" | spawn-internal marker; disables autonomous daemons in children |
|
|
423
440
|
| `THREADKEEPER_SKILL_NUDGE_INTERVAL` | 10 | events between `skill_hint` nudges |
|
|
424
441
|
|
|
425
442
|
Persist them via `~/.claude/settings.json`'s `env` block (Claude Code) or
|
|
@@ -149,6 +149,8 @@ Claude session via a `claude -p` subprocess. By default `slim=True`: the
|
|
|
149
149
|
child loads only the thread-keeper MCP, no embeddings, no third-party
|
|
150
150
|
servers. ~500 MB RSS versus ~1.3 GB for a full child. Heuristic for the
|
|
151
151
|
parent: N≥2 modular independent units of ≥5 min each = spawn signal.
|
|
152
|
+
Spawn also marks children with `THREADKEEPER_SPAWNED_CHILD=1`, so
|
|
153
|
+
autonomous learning daemons cannot recursively start inside review forks.
|
|
152
154
|
|
|
153
155
|
A daemon measures combined child RSS every 10 s; admission control
|
|
154
156
|
refuses a new spawn that would exceed `THREADKEEPER_SPAWN_BUDGET_MB`
|
|
@@ -241,7 +243,11 @@ the last cursor **across all CLIs at once**. The window filters
|
|
|
241
243
|
internal review-child sessions (no self-pollution) and strips adapter
|
|
242
244
|
`[tool_result]` / `[tool_call]` noise (the "clean context" rule). If
|
|
243
245
|
≥500 chars of meaningful signal remain, spawns a slim observer child
|
|
244
|
-
that decides on class-level learning.
|
|
246
|
+
that decides on class-level learning. It is single-flight across the shared
|
|
247
|
+
DB: if any shadow observer task is already running, the daemon does not spawn
|
|
248
|
+
another one and does not advance the cursor. Shadow observer children are
|
|
249
|
+
marked as spawned/background processes, so they cannot start their own shadow
|
|
250
|
+
daemon even if a CLI drops the no-embeddings env. Idempotent through
|
|
245
251
|
`events.kind='shadow_review_pass'`.
|
|
246
252
|
|
|
247
253
|
#### 3. Extract daemon
|
|
@@ -384,8 +390,19 @@ The most-used env knobs (full list in `threadkeeper/config.py`):
|
|
|
384
390
|
| `THREADKEEPER_CURATOR_MIN_LESSONS` | 3 | min lessons before curator engages |
|
|
385
391
|
| `THREADKEEPER_CURATOR_DESTRUCTIVE` | "" (advisory) | when "1": curator child applies its own PATCH/PRUNE/CONSOLIDATE directly instead of writing advisory REPORT only |
|
|
386
392
|
| `THREADKEEPER_SPAWN_BUDGET_MB` | 3072 | combined child RSS cap (MB); 0 disables |
|
|
393
|
+
| `THREADKEEPER_MEMORY_GUARD_POLL_S` | 30 | server RSS guard tick (s); 0 disables |
|
|
394
|
+
| `THREADKEEPER_MEMORY_GUARD_WARN_MB` | 1536 | notify/log when a server crosses this RSS |
|
|
395
|
+
| `THREADKEEPER_MEMORY_GUARD_KILL_MB` | 3072 | SIGTERM server above this RSS; 0 disables killing |
|
|
396
|
+
| `THREADKEEPER_MEMORY_GUARD_AGG_WARN_MB` | 2048 | notify/request trim when all server RSS crosses this |
|
|
397
|
+
| `THREADKEEPER_MEMORY_GUARD_AGG_KILL_MB` | 3072 | under aggregate pressure, retire stale idle servers |
|
|
398
|
+
| `THREADKEEPER_MEMORY_GUARD_RECLAIM_MB` | 1024 | local RSS floor before warn-triggered self trim |
|
|
399
|
+
| `THREADKEEPER_MEMORY_GUARD_TARGET_SERVERS` | 1 | aggregate-pressure target after retiring stale idle servers |
|
|
400
|
+
| `THREADKEEPER_MEMORY_GUARD_RETIRE_IDLE_S` | 900 | heartbeat age before a non-self server is retireable |
|
|
401
|
+
| `THREADKEEPER_MEMORY_GUARD_RETIRE_LIVE` | "" (off) | allow retiring parent-alive MCP servers; off protects live clients |
|
|
402
|
+
| `THREADKEEPER_MEMORY_GUARD_NOTIFY` | "1" | send macOS desktop notification when possible |
|
|
387
403
|
| `THREADKEEPER_INGEST_INTERVAL_S` | 3 | transcript ingest tick (s) |
|
|
388
404
|
| `THREADKEEPER_NO_EMBEDDINGS` | "" | force-disable sentence-transformers |
|
|
405
|
+
| `THREADKEEPER_SPAWNED_CHILD` | "" | spawn-internal marker; disables autonomous daemons in children |
|
|
389
406
|
| `THREADKEEPER_SKILL_NUDGE_INTERVAL` | 10 | events between `skill_hint` nudges |
|
|
390
407
|
|
|
391
408
|
Persist them via `~/.claude/settings.json`'s `env` block (Claude Code) or
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "threadkeeper"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.6.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" }]
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Memory guard for thread-keeper server RSS thresholds."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import signal as _sig
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
_FAKE_CID = "55556666-7777-8888-9999-000011112222"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _tool(pkg, name):
|
|
12
|
+
return pkg["mcp"]._tool_manager._tools[name].fn
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _proc(pid, rss_mb, *, ppid=None):
|
|
16
|
+
return {
|
|
17
|
+
"pid": pid,
|
|
18
|
+
"ppid": os.getpid() if ppid is None else ppid,
|
|
19
|
+
"rss_kb": rss_mb * 1024,
|
|
20
|
+
"rss_mb": rss_mb,
|
|
21
|
+
"etime": "1:00",
|
|
22
|
+
"command": "python -m threadkeeper.server",
|
|
23
|
+
"parent_alive": True,
|
|
24
|
+
"heartbeat_age_s": 5,
|
|
25
|
+
"is_self": pid == os.getpid(),
|
|
26
|
+
"is_orphaned": False,
|
|
27
|
+
"orphan_reason": "parent_alive",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_scan_over_limit_splits_warn_and_kill(mp_with_cid, monkeypatch):
|
|
32
|
+
mp_with_cid(_FAKE_CID)
|
|
33
|
+
from threadkeeper import memory_guard, process_health
|
|
34
|
+
|
|
35
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_WARN_MB", 1000)
|
|
36
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_KILL_MB", 2000)
|
|
37
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_WARN_MB", 0)
|
|
38
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_KILL_MB", 0)
|
|
39
|
+
monkeypatch.setattr(process_health, "scan", lambda: [
|
|
40
|
+
_proc(1001, 800),
|
|
41
|
+
_proc(1002, 1200),
|
|
42
|
+
_proc(1003, 2500),
|
|
43
|
+
])
|
|
44
|
+
|
|
45
|
+
out = memory_guard.scan_over_limit()
|
|
46
|
+
assert [p["pid"] for p in out["warn"]] == [1002]
|
|
47
|
+
assert [p["pid"] for p in out["kill"]] == [1003]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_check_dry_run_does_not_kill(mp_with_cid, monkeypatch):
|
|
51
|
+
mp_with_cid(_FAKE_CID)
|
|
52
|
+
from threadkeeper import memory_guard, process_health
|
|
53
|
+
|
|
54
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_WARN_MB", 1000)
|
|
55
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_KILL_MB", 2000)
|
|
56
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_WARN_MB", 0)
|
|
57
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_KILL_MB", 0)
|
|
58
|
+
monkeypatch.setattr(process_health, "scan", lambda: [_proc(1003, 2500)])
|
|
59
|
+
killed: list[tuple[int, int]] = []
|
|
60
|
+
monkeypatch.setattr(
|
|
61
|
+
"os.kill",
|
|
62
|
+
lambda pid, sig: killed.append((pid, sig)) if sig != 0 else None,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
out = memory_guard.check_once(dry_run=True, notify=False)
|
|
66
|
+
assert [p["pid"] for p in out["kill"]] == [1003]
|
|
67
|
+
assert out["killed"] == []
|
|
68
|
+
assert killed == []
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_check_apply_sends_sigterm(mp_with_cid, monkeypatch):
|
|
72
|
+
mp_with_cid(_FAKE_CID)
|
|
73
|
+
from threadkeeper import memory_guard, process_health
|
|
74
|
+
|
|
75
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_WARN_MB", 1000)
|
|
76
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_KILL_MB", 2000)
|
|
77
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_WARN_MB", 0)
|
|
78
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_KILL_MB", 0)
|
|
79
|
+
monkeypatch.setattr(process_health, "scan", lambda: [_proc(1004, 2500)])
|
|
80
|
+
calls: list[tuple[int, int]] = []
|
|
81
|
+
monkeypatch.setattr("os.kill", lambda pid, sig: calls.append((pid, sig)))
|
|
82
|
+
monkeypatch.setattr(memory_guard, "reclaim_memory", lambda reason="": {
|
|
83
|
+
"before_mb": 2500, "after_mb": 2400, "freed_mb": 100,
|
|
84
|
+
"pid": os.getpid(), "actions": [],
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
out = memory_guard.check_once(dry_run=False, notify=False)
|
|
88
|
+
assert out["killed"] == [1004]
|
|
89
|
+
assert calls == [(1004, _sig.SIGTERM)]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_memory_guard_status_tool_reports_thresholds(mp_with_cid, monkeypatch):
|
|
93
|
+
pkg = mp_with_cid(_FAKE_CID)
|
|
94
|
+
from threadkeeper import memory_guard, process_health
|
|
95
|
+
|
|
96
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_POLL_S", 30)
|
|
97
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_WARN_MB", 1000)
|
|
98
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_KILL_MB", 2000)
|
|
99
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_WARN_MB", 0)
|
|
100
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_KILL_MB", 0)
|
|
101
|
+
monkeypatch.setattr(process_health, "scan", lambda: [
|
|
102
|
+
_proc(1001, 800),
|
|
103
|
+
_proc(1002, 1200),
|
|
104
|
+
_proc(1003, 2500),
|
|
105
|
+
])
|
|
106
|
+
|
|
107
|
+
txt = _tool(pkg, "memory_guard_status")()
|
|
108
|
+
assert "state=active" in txt
|
|
109
|
+
assert "warn_mb=1000" in txt
|
|
110
|
+
assert "kill_mb=2000" in txt
|
|
111
|
+
assert "ok pid=1001" in txt
|
|
112
|
+
assert "WARN pid=1002" in txt
|
|
113
|
+
assert "KILL pid=1003" in txt
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_memory_guard_check_tool_defaults_to_dry_run(mp_with_cid, monkeypatch):
|
|
117
|
+
pkg = mp_with_cid(_FAKE_CID)
|
|
118
|
+
from threadkeeper import memory_guard, process_health
|
|
119
|
+
|
|
120
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_WARN_MB", 1000)
|
|
121
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_KILL_MB", 2000)
|
|
122
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_WARN_MB", 0)
|
|
123
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_KILL_MB", 0)
|
|
124
|
+
monkeypatch.setattr(process_health, "scan", lambda: [_proc(1005, 2500)])
|
|
125
|
+
killed: list[tuple[int, int]] = []
|
|
126
|
+
monkeypatch.setattr(
|
|
127
|
+
"os.kill",
|
|
128
|
+
lambda pid, sig: killed.append((pid, sig)) if sig != 0 else None,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
txt = _tool(pkg, "memory_guard_check")()
|
|
132
|
+
assert txt.startswith("dry_run")
|
|
133
|
+
assert "would SIGTERM pid=1005" in txt
|
|
134
|
+
assert killed == []
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_scan_reports_aggregate_pressure(mp_with_cid, monkeypatch):
|
|
138
|
+
mp_with_cid(_FAKE_CID)
|
|
139
|
+
from threadkeeper import memory_guard, process_health
|
|
140
|
+
|
|
141
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_WARN_MB", 5000)
|
|
142
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_KILL_MB", 6000)
|
|
143
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_WARN_MB", 2000)
|
|
144
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_KILL_MB", 3000)
|
|
145
|
+
monkeypatch.setattr(process_health, "scan", lambda: [
|
|
146
|
+
_proc(1001, 800),
|
|
147
|
+
_proc(1002, 900),
|
|
148
|
+
_proc(1003, 1200),
|
|
149
|
+
])
|
|
150
|
+
|
|
151
|
+
out = memory_guard.scan_over_limit()
|
|
152
|
+
assert out["aggregate"]["rss_mb"] == 2900
|
|
153
|
+
assert out["aggregate"]["warn"] is True
|
|
154
|
+
assert out["aggregate"]["kill"] is False
|
|
155
|
+
assert out["warn"] == []
|
|
156
|
+
assert out["kill"] == []
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_check_apply_requests_peer_trim_on_aggregate_warn(mp_with_cid, monkeypatch):
|
|
160
|
+
pkg = mp_with_cid(_FAKE_CID)
|
|
161
|
+
from threadkeeper import memory_guard, process_health
|
|
162
|
+
|
|
163
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_WARN_MB", 5000)
|
|
164
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_KILL_MB", 6000)
|
|
165
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_WARN_MB", 2000)
|
|
166
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_KILL_MB", 0)
|
|
167
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_TARGET_SERVERS", 1)
|
|
168
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_RETIRE_IDLE_S", 900)
|
|
169
|
+
monkeypatch.setattr(memory_guard, "reclaim_memory", lambda reason="": {
|
|
170
|
+
"before_mb": 900, "after_mb": 800, "freed_mb": 100,
|
|
171
|
+
"pid": os.getpid(), "actions": ["fake"],
|
|
172
|
+
})
|
|
173
|
+
monkeypatch.setattr(process_health, "scan", lambda: [
|
|
174
|
+
_proc(os.getpid(), 900),
|
|
175
|
+
_proc(1002, 1200, ppid=os.getpid()),
|
|
176
|
+
])
|
|
177
|
+
|
|
178
|
+
out = memory_guard.check_once(dry_run=False, notify=False)
|
|
179
|
+
assert sorted(out["reclaim_requests"]["requested"]) == sorted([os.getpid(), 1002])
|
|
180
|
+
|
|
181
|
+
conn = pkg["db"].get_db()
|
|
182
|
+
rows = conn.execute(
|
|
183
|
+
"SELECT target_pid FROM resource_controls WHERE action='trim'"
|
|
184
|
+
).fetchall()
|
|
185
|
+
assert sorted(r["target_pid"] for r in rows) == sorted([os.getpid(), 1002])
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_check_apply_retires_idle_candidate_on_aggregate_pressure(mp_with_cid, monkeypatch):
|
|
189
|
+
mp_with_cid(_FAKE_CID)
|
|
190
|
+
from threadkeeper import memory_guard, process_health
|
|
191
|
+
|
|
192
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_WARN_MB", 5000)
|
|
193
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_KILL_MB", 6000)
|
|
194
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_WARN_MB", 2000)
|
|
195
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_KILL_MB", 3000)
|
|
196
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_TARGET_SERVERS", 1)
|
|
197
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_RETIRE_IDLE_S", 900)
|
|
198
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_RETIRE_LIVE", False)
|
|
199
|
+
monkeypatch.setattr(memory_guard, "reclaim_memory", lambda reason="": {
|
|
200
|
+
"before_mb": 900, "after_mb": 800, "freed_mb": 100,
|
|
201
|
+
"pid": os.getpid(), "actions": ["fake"],
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
def scan():
|
|
205
|
+
return [
|
|
206
|
+
_proc(os.getpid(), 900),
|
|
207
|
+
_proc(1001, 1200, ppid=1) | {
|
|
208
|
+
"heartbeat_age_s": None,
|
|
209
|
+
"parent_alive": False,
|
|
210
|
+
"is_orphaned": True,
|
|
211
|
+
"orphan_reason": "parent_gone + no_heartbeat",
|
|
212
|
+
},
|
|
213
|
+
_proc(1002, 800, ppid=os.getpid()) | {"heartbeat_age_s": 5},
|
|
214
|
+
]
|
|
215
|
+
|
|
216
|
+
monkeypatch.setattr(process_health, "scan", scan)
|
|
217
|
+
calls: list[tuple[int, int]] = []
|
|
218
|
+
monkeypatch.setattr(
|
|
219
|
+
"os.kill",
|
|
220
|
+
lambda pid, sig: calls.append((pid, sig)) if sig != 0 else None,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
out = memory_guard.check_once(dry_run=False, notify=False)
|
|
224
|
+
assert out["retired"] == [1001]
|
|
225
|
+
assert calls == [(1001, _sig.SIGTERM)]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_aggregate_retire_skips_live_parent_without_opt_in(mp_with_cid, monkeypatch):
|
|
229
|
+
mp_with_cid(_FAKE_CID)
|
|
230
|
+
from threadkeeper import memory_guard, process_health
|
|
231
|
+
|
|
232
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_WARN_MB", 5000)
|
|
233
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_KILL_MB", 6000)
|
|
234
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_WARN_MB", 2000)
|
|
235
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_KILL_MB", 3000)
|
|
236
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_TARGET_SERVERS", 1)
|
|
237
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_RETIRE_IDLE_S", 900)
|
|
238
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_RETIRE_LIVE", False)
|
|
239
|
+
monkeypatch.setattr(memory_guard, "reclaim_memory", lambda reason="": {
|
|
240
|
+
"before_mb": 900, "after_mb": 800, "freed_mb": 100,
|
|
241
|
+
"pid": os.getpid(), "actions": ["fake"],
|
|
242
|
+
})
|
|
243
|
+
monkeypatch.setattr(process_health, "scan", lambda: [
|
|
244
|
+
_proc(os.getpid(), 900),
|
|
245
|
+
_proc(1001, 1200, ppid=os.getpid()) | {"heartbeat_age_s": None},
|
|
246
|
+
])
|
|
247
|
+
calls: list[tuple[int, int]] = []
|
|
248
|
+
monkeypatch.setattr(
|
|
249
|
+
"os.kill",
|
|
250
|
+
lambda pid, sig: calls.append((pid, sig)) if sig != 0 else None,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
out = memory_guard.check_once(dry_run=False, notify=False)
|
|
254
|
+
assert out["retired"] == []
|
|
255
|
+
assert calls == []
|
|
@@ -438,3 +438,69 @@ def test_close_thread_with_auto_review_enabled_spawns(tmp_path, monkeypatch):
|
|
|
438
438
|
assert len(calls) == 1
|
|
439
439
|
assert calls[0]["thread_id"] == tid
|
|
440
440
|
assert calls[0]["mode"] == "auto"
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
444
|
+
# compute_thread_nudge (open-thread nudge for hook-less clients)
|
|
445
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
446
|
+
|
|
447
|
+
def _insert_event(pkg, session_id: str, kind: str) -> None:
|
|
448
|
+
conn = pkg["db"].get_db()
|
|
449
|
+
conn.execute(
|
|
450
|
+
"INSERT INTO events (session_id, kind, target, summary, created_at) "
|
|
451
|
+
"VALUES (?,?,?,?,?)",
|
|
452
|
+
(session_id, kind, None, "", int(time.time())),
|
|
453
|
+
)
|
|
454
|
+
conn.commit()
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def test_thread_nudge_fires_on_empty_session(tmp_path, monkeypatch):
|
|
458
|
+
"""No activity threshold — fires as soon as a session exists with no
|
|
459
|
+
open_thread (mirrors the hook firing on the first prompt)."""
|
|
460
|
+
pkg = _bootstrap_with_env(tmp_path, monkeypatch)
|
|
461
|
+
conn = pkg["db"].get_db()
|
|
462
|
+
out = pkg["nudges"].compute_thread_nudge(conn, "sess-empty")
|
|
463
|
+
assert out is not None
|
|
464
|
+
assert "thread_hint" in out
|
|
465
|
+
assert "open_thread(question)" in out
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def test_thread_nudge_silent_after_open_thread(tmp_path, monkeypatch):
|
|
469
|
+
pkg = _bootstrap_with_env(tmp_path, monkeypatch)
|
|
470
|
+
_insert_event(pkg, "sess-B", "open_thread")
|
|
471
|
+
conn = pkg["db"].get_db()
|
|
472
|
+
assert pkg["nudges"].compute_thread_nudge(conn, "sess-B") is None
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def test_thread_nudge_silent_after_shown(tmp_path, monkeypatch):
|
|
476
|
+
pkg = _bootstrap_with_env(tmp_path, monkeypatch)
|
|
477
|
+
_insert_event(pkg, "sess-C", "thread_hint_shown")
|
|
478
|
+
conn = pkg["db"].get_db()
|
|
479
|
+
assert pkg["nudges"].compute_thread_nudge(conn, "sess-C") is None
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def test_thread_nudge_silent_without_session_id(tmp_path, monkeypatch):
|
|
483
|
+
pkg = _bootstrap_with_env(tmp_path, monkeypatch)
|
|
484
|
+
conn = pkg["db"].get_db()
|
|
485
|
+
assert pkg["nudges"].compute_thread_nudge(conn, "") is None
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def test_brief_shows_thread_hint_once_then_suppresses(tmp_path, monkeypatch):
|
|
489
|
+
"""brief() surfaces the nudge on the first call (no env set = hook-less
|
|
490
|
+
client), logs thread_hint_shown, and stays quiet thereafter."""
|
|
491
|
+
pkg = _bootstrap_with_env(tmp_path, monkeypatch)
|
|
492
|
+
monkeypatch.delenv("THREADKEEPER_BRIEF_NO_THREAD_NUDGE", raising=False)
|
|
493
|
+
brief = _tool(pkg, "brief")
|
|
494
|
+
out1 = brief()
|
|
495
|
+
assert "thread_hint" in out1
|
|
496
|
+
out2 = brief()
|
|
497
|
+
assert "thread_hint" not in out2
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def test_brief_suppresses_thread_hint_when_env_set(tmp_path, monkeypatch):
|
|
501
|
+
"""SessionStart-hook path (env set) never surfaces the in-brief nudge —
|
|
502
|
+
the UserPromptSubmit hook owns it there."""
|
|
503
|
+
pkg = _bootstrap_with_env(tmp_path, monkeypatch)
|
|
504
|
+
monkeypatch.setenv("THREADKEEPER_BRIEF_NO_THREAD_NUDGE", "1")
|
|
505
|
+
out = _tool(pkg, "brief")()
|
|
506
|
+
assert "thread_hint" not in out
|
|
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
import time
|
|
13
13
|
import sys
|
|
14
|
+
import os
|
|
14
15
|
from pathlib import Path
|
|
15
16
|
|
|
16
17
|
import pytest
|
|
@@ -258,6 +259,42 @@ def test_run_shadow_pass_spawns_when_threshold_met(tmp_path, monkeypatch):
|
|
|
258
259
|
assert long_msg.strip()[:40] in kw["prompt"]
|
|
259
260
|
|
|
260
261
|
|
|
262
|
+
def test_run_shadow_pass_single_flight_when_child_running(tmp_path, monkeypatch):
|
|
263
|
+
pkg = _bootstrap(tmp_path, monkeypatch, min_chars="100")
|
|
264
|
+
conn = pkg["db"].get_db()
|
|
265
|
+
now = int(time.time())
|
|
266
|
+
long_msg = "Pattern: in this type of task always X. " * 10
|
|
267
|
+
_seed_dialog(conn, "user", long_msg, now - 5)
|
|
268
|
+
conn.execute(
|
|
269
|
+
"INSERT INTO tasks (id, pid, parent_cid, spawned_cid, cwd, prompt, "
|
|
270
|
+
"started_at, rss_kb, rss_updated_at) "
|
|
271
|
+
"VALUES (?,?,?,?,?,?,?,?,?)",
|
|
272
|
+
(
|
|
273
|
+
"tk_shadow_running",
|
|
274
|
+
os.getpid(),
|
|
275
|
+
"parent",
|
|
276
|
+
"child",
|
|
277
|
+
str(tmp_path),
|
|
278
|
+
pkg["shadow_review"].SHADOW_REVIEW_PROMPT,
|
|
279
|
+
now - 1,
|
|
280
|
+
123,
|
|
281
|
+
now,
|
|
282
|
+
),
|
|
283
|
+
)
|
|
284
|
+
conn.commit()
|
|
285
|
+
|
|
286
|
+
import threadkeeper.tools.spawn as spawn_mod
|
|
287
|
+
|
|
288
|
+
def should_not_spawn(**kwargs):
|
|
289
|
+
raise AssertionError("shadow pass should be single-flight")
|
|
290
|
+
|
|
291
|
+
monkeypatch.setattr(spawn_mod, "spawn", should_not_spawn)
|
|
292
|
+
out = pkg["shadow_review"].run_shadow_pass(force=True)
|
|
293
|
+
assert out == "shadow_child_running n=1"
|
|
294
|
+
# Cursor does not advance; retry the same window when the child exits.
|
|
295
|
+
assert pkg["shadow_review"]._last_shadow_ts(conn) == 0
|
|
296
|
+
|
|
297
|
+
|
|
261
298
|
def test_run_shadow_pass_idempotent_after_cursor_advance(tmp_path, monkeypatch):
|
|
262
299
|
"""Second pass over the same data must produce no_window once cursor
|
|
263
300
|
catches up."""
|
|
@@ -314,6 +351,24 @@ def test_daemon_does_not_start_in_slim_child(tmp_path, monkeypatch):
|
|
|
314
351
|
)
|
|
315
352
|
|
|
316
353
|
|
|
354
|
+
def test_daemon_does_not_start_in_marked_spawned_child(tmp_path, monkeypatch):
|
|
355
|
+
"""The cascade guard must not depend only on NO_EMBEDDINGS.
|
|
356
|
+
|
|
357
|
+
Some CLIs launch MCP servers from a config env block, so the child
|
|
358
|
+
process may not reliably inherit THREADKEEPER_NO_EMBEDDINGS. The explicit
|
|
359
|
+
THREADKEEPER_SPAWNED_CHILD marker still has to stop shadow_review.
|
|
360
|
+
"""
|
|
361
|
+
monkeypatch.setenv("THREADKEEPER_SPAWNED_CHILD", "1")
|
|
362
|
+
pkg = _bootstrap(tmp_path, monkeypatch, interval="60")
|
|
363
|
+
import threadkeeper.config as cfg
|
|
364
|
+
monkeypatch.setattr(cfg, "SEMANTIC_AVAILABLE", True)
|
|
365
|
+
pkg["shadow_review"]._started = False
|
|
366
|
+
pkg["shadow_review"].start_shadow_daemon()
|
|
367
|
+
assert pkg["shadow_review"]._started is False, (
|
|
368
|
+
"marked spawned child should refuse to start shadow daemon"
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
317
372
|
def test_mcp_shadow_review_status_reports_passes(tmp_path, monkeypatch):
|
|
318
373
|
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
319
374
|
pkg["shadow_review"]._record_shadow_pass(
|
|
@@ -44,7 +44,12 @@ def test_build_slim_mcp_config_from_claude_json(tmp_path, monkeypatch):
|
|
|
44
44
|
del sys.modules[name]
|
|
45
45
|
from threadkeeper.tools.spawn import _build_slim_mcp_config
|
|
46
46
|
|
|
47
|
-
slim_path = _build_slim_mcp_config("tk_test01"
|
|
47
|
+
slim_path = _build_slim_mcp_config("tk_test01", {
|
|
48
|
+
"THREADKEEPER_FORCE_CID": _FAKE_CID,
|
|
49
|
+
"THREADKEEPER_SPAWNED_CHILD": "1",
|
|
50
|
+
"THREADKEEPER_NO_EMBEDDINGS": "1",
|
|
51
|
+
"THREADKEEPER_WRITE_ORIGIN": "shadow_review",
|
|
52
|
+
})
|
|
48
53
|
assert slim_path is not None
|
|
49
54
|
assert slim_path.exists()
|
|
50
55
|
data = json.loads(slim_path.read_text())
|
|
@@ -53,6 +58,11 @@ def test_build_slim_mcp_config_from_claude_json(tmp_path, monkeypatch):
|
|
|
53
58
|
mp = data["mcpServers"]["thread-keeper"]
|
|
54
59
|
assert mp["command"] == "/path/to/python"
|
|
55
60
|
assert mp["args"] == ["-m", "threadkeeper.server"]
|
|
61
|
+
assert mp["env"]["PYTHONPATH"] == "/path/to/repo"
|
|
62
|
+
assert mp["env"]["THREADKEEPER_FORCE_CID"] == _FAKE_CID
|
|
63
|
+
assert mp["env"]["THREADKEEPER_SPAWNED_CHILD"] == "1"
|
|
64
|
+
assert mp["env"]["THREADKEEPER_NO_EMBEDDINGS"] == "1"
|
|
65
|
+
assert mp["env"]["THREADKEEPER_WRITE_ORIGIN"] == "shadow_review"
|
|
56
66
|
|
|
57
67
|
|
|
58
68
|
def test_build_slim_mcp_config_synthesizes_when_no_claude_json(tmp_path, monkeypatch):
|
|
@@ -70,7 +80,10 @@ def test_build_slim_mcp_config_synthesizes_when_no_claude_json(tmp_path, monkeyp
|
|
|
70
80
|
del sys.modules[name]
|
|
71
81
|
from threadkeeper.tools.spawn import _build_slim_mcp_config
|
|
72
82
|
|
|
73
|
-
slim_path = _build_slim_mcp_config("tk_synth"
|
|
83
|
+
slim_path = _build_slim_mcp_config("tk_synth", {
|
|
84
|
+
"THREADKEEPER_SPAWNED_CHILD": "1",
|
|
85
|
+
"THREADKEEPER_NO_EMBEDDINGS": "1",
|
|
86
|
+
})
|
|
74
87
|
assert slim_path is not None
|
|
75
88
|
data = json.loads(slim_path.read_text())
|
|
76
89
|
assert "thread-keeper" in data["mcpServers"]
|
|
@@ -79,6 +92,8 @@ def test_build_slim_mcp_config_synthesizes_when_no_claude_json(tmp_path, monkeyp
|
|
|
79
92
|
assert mp["command"] == sys.executable
|
|
80
93
|
assert "threadkeeper.server" in mp["args"]
|
|
81
94
|
assert "PYTHONPATH" in mp["env"]
|
|
95
|
+
assert mp["env"]["THREADKEEPER_SPAWNED_CHILD"] == "1"
|
|
96
|
+
assert mp["env"]["THREADKEEPER_NO_EMBEDDINGS"] == "1"
|
|
82
97
|
|
|
83
98
|
|
|
84
99
|
def test_spawn_slim_falls_back_to_full_config_when_unable(tmp_path, monkeypatch):
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Idempotently wires thread-keeper into a Claude Code installation:
|
|
4
4
|
1. Registers `thread-keeper` MCP server in ~/.claude.json
|
|
5
|
-
2. Installs hooks (SessionStart, PostToolUse, UserPromptSubmit
|
|
6
|
-
~/.claude/settings.json
|
|
5
|
+
2. Installs hooks (SessionStart, PostToolUse, UserPromptSubmit, Stop,
|
|
6
|
+
PreToolUse) in ~/.claude/settings.json
|
|
7
7
|
3. Copies hook scripts to ~/.threadkeeper/hooks/
|
|
8
8
|
4. Updates the managed block in ~/.claude/CLAUDE.md between sentinel
|
|
9
9
|
markers — content outside the markers is preserved.
|
|
@@ -69,8 +69,11 @@ At session start:
|
|
|
69
69
|
If the user's opening message is substantive, pass it as `query`
|
|
70
70
|
to `brief()` to inline relevant past notes.
|
|
71
71
|
|
|
72
|
-
During the conversation
|
|
73
|
-
|
|
72
|
+
During the conversation (a UserPromptSubmit hook nudges you once per
|
|
73
|
+
session if you haven't opened a thread, and a Stop hook reminds you to
|
|
74
|
+
close at the end — but the discipline is yours; act before the nudge):
|
|
75
|
+
- The FIRST substantive topic of a session (debugging, a feature, a
|
|
76
|
+
multi-step task) → `open_thread()` BEFORE diving into tool calls.
|
|
74
77
|
- Topic resolved with an outcome → `close_thread(thread_id, outcome)`.
|
|
75
78
|
- After every turn that produced a decision or insight →
|
|
76
79
|
`note(thread_id, ..., kind in ['move','failed','insight','open_q'])`.
|
|
@@ -149,7 +152,14 @@ def install_hooks(dry_run: bool) -> list[str]:
|
|
|
149
152
|
# and is referenced by every supporting CLI.
|
|
150
153
|
if not dry_run:
|
|
151
154
|
TK_HOOKS_DIR.mkdir(parents=True, exist_ok=True)
|
|
152
|
-
for fname in (
|
|
155
|
+
for fname in (
|
|
156
|
+
"tk-brief.sh",
|
|
157
|
+
"tk-status.sh",
|
|
158
|
+
"inbox-check.sh",
|
|
159
|
+
"tk-task-gate.sh",
|
|
160
|
+
"tk-thread-nudge.sh",
|
|
161
|
+
"tk-session-end.sh",
|
|
162
|
+
):
|
|
153
163
|
src = HOOKS_SRC / fname
|
|
154
164
|
dst = TK_HOOKS_DIR / fname
|
|
155
165
|
if not src.exists():
|
|
@@ -182,6 +192,28 @@ def install_hooks(dry_run: bool) -> list[str]:
|
|
|
182
192
|
"matcher": "",
|
|
183
193
|
"command": str(TK_HOOKS_DIR / "inbox-check.sh"),
|
|
184
194
|
},
|
|
195
|
+
# Open-thread safety net: once per session, nudge open_thread() if
|
|
196
|
+
# none was opened yet (additionalContext, non-blocking).
|
|
197
|
+
{
|
|
198
|
+
"event": "UserPromptSubmit",
|
|
199
|
+
"matcher": "",
|
|
200
|
+
"command": str(TK_HOOKS_DIR / "tk-thread-nudge.sh"),
|
|
201
|
+
},
|
|
202
|
+
# close_thread / session_end safety net at end of turn (throttled to
|
|
203
|
+
# once per session; advisory systemMessage, never blocks stopping).
|
|
204
|
+
{
|
|
205
|
+
"event": "Stop",
|
|
206
|
+
"matcher": "",
|
|
207
|
+
"command": str(TK_HOOKS_DIR / "tk-session-end.sh"),
|
|
208
|
+
},
|
|
209
|
+
# spawn-vs-Task gate: block the built-in Task tool for work that
|
|
210
|
+
# should go through mcp__thread-keeper__spawn() (Claude Code only;
|
|
211
|
+
# other CLIs ignore an unknown PreToolUse event).
|
|
212
|
+
{
|
|
213
|
+
"event": "PreToolUse",
|
|
214
|
+
"matcher": "^Task$",
|
|
215
|
+
"command": str(TK_HOOKS_DIR / "tk-task-gate.sh"),
|
|
216
|
+
},
|
|
185
217
|
]
|
|
186
218
|
|
|
187
219
|
# 3) Ask each installed adapter to wire them up in its native
|
|
@@ -10,6 +10,7 @@ pickup_top, evolve_pending, and the trailing user-facing reminder.
|
|
|
10
10
|
shared dialog log tailed by open_dialog_window().
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
|
+
import os
|
|
13
14
|
import re
|
|
14
15
|
import sqlite3
|
|
15
16
|
import time
|
|
@@ -339,6 +340,33 @@ def render_brief(conn: sqlite3.Connection, query: str = "", k: int = 6) -> str:
|
|
|
339
340
|
for t in closed_t:
|
|
340
341
|
out.append(f" {t['id']} out={q((t['outcome'] or '-')[:120])}")
|
|
341
342
|
|
|
343
|
+
# ── thread_hint (open-thread nudge for hook-less clients) ─────────────
|
|
344
|
+
# Claude Code / Gemini / Copilot get the open-thread reminder from the
|
|
345
|
+
# UserPromptSubmit hook (tk-thread-nudge.sh); their SessionStart hook
|
|
346
|
+
# sets THREADKEEPER_BRIEF_NO_THREAD_NUDGE so this path stays quiet and
|
|
347
|
+
# doesn't double-fire. Clients with NO hook mechanism (Claude Desktop,
|
|
348
|
+
# Codex, VS Code) call brief() directly — they have no env set, so the
|
|
349
|
+
# nudge surfaces here instead. See nudges.compute_thread_nudge.
|
|
350
|
+
if not os.environ.get("THREADKEEPER_BRIEF_NO_THREAD_NUDGE"):
|
|
351
|
+
try:
|
|
352
|
+
from .nudges import compute_thread_nudge
|
|
353
|
+
th_nudge = compute_thread_nudge(conn, identity._session_id or "")
|
|
354
|
+
except (sqlite3.OperationalError, ImportError):
|
|
355
|
+
th_nudge = None
|
|
356
|
+
if th_nudge:
|
|
357
|
+
out.append("")
|
|
358
|
+
out.append(th_nudge)
|
|
359
|
+
try:
|
|
360
|
+
conn.execute(
|
|
361
|
+
"INSERT INTO events (session_id, kind, target, summary, "
|
|
362
|
+
"created_at) VALUES (?,?,?,?,?)",
|
|
363
|
+
(identity._session_id or "", "thread_hint_shown",
|
|
364
|
+
self_cid or "", "", now),
|
|
365
|
+
)
|
|
366
|
+
conn.commit()
|
|
367
|
+
except sqlite3.OperationalError:
|
|
368
|
+
pass
|
|
369
|
+
|
|
342
370
|
# ── memory_nudge ──────────────────────────────────────────────────────
|
|
343
371
|
# Counter-driven (active push, not just passive surface): when N mutating
|
|
344
372
|
# events have passed in this session without a memory save, escalate from
|