threadkeeper 0.6.0__tar.gz → 0.6.2__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.6.0 → threadkeeper-0.6.2}/PKG-INFO +2 -1
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/README.md +1 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/pyproject.toml +1 -1
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_memory_guard.py +79 -7
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/config.py +3 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/memory_guard.py +72 -12
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/memory_guard.py +3 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper.egg-info/PKG-INFO +2 -1
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/LICENSE +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/setup.cfg +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_adapters.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_brief_sections.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_candidate_reviewer.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_core_memory.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_curator.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_delegated_search.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_dialectic.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_dialectic_tier.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_error_paths.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_extract_daemon.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_i18n_multilang.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_identity.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_lessons.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_missed_spawns.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_nudges.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_process_health.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_shadow_review.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_skill_hint.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_skill_tier.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_skill_use_parser.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_skill_watcher.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_skills.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_spawn_budget.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_spawn_config.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_spawn_hint.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_spawn_slim.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_threads.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_tools_smoke.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_validate_threads.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_vec_search.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/__init__.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/_mcp.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/_setup.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/adapters/__init__.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/adapters/_hook_helpers.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/adapters/base.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/adapters/claude_code.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/adapters/claude_desktop.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/adapters/codex.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/adapters/copilot.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/adapters/gemini.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/adapters/vscode.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/brief.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/candidate_reviewer.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/curator.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/db.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/embeddings.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/extract_daemon.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/helpers.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/i18n.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/identity.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/ingest.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/lessons.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/nudges.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/process_health.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/review_prompts.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/search_proxy.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/server.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/shadow_review.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/skill_watcher.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/spawn_budget.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/spawn_config.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/__init__.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/candidate_reviewer.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/concepts.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/consolidate.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/core_memory.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/correlation.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/curator.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/dialectic.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/dialog.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/distill.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/extract.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/graph.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/invariants.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/lessons.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/missed_spawns.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/peers.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/pickup.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/probes.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/process_health.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/session.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/shadow_review.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/skills.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/spawn.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/style.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/threads.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/validate.py +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper.egg-info/SOURCES.txt +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper.egg-info/dependency_links.txt +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper.egg-info/entry_points.txt +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper.egg-info/requires.txt +0 -0
- {threadkeeper-0.6.0 → threadkeeper-0.6.2}/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.6.
|
|
3
|
+
Version: 0.6.2
|
|
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
|
|
@@ -432,6 +432,7 @@ The most-used env knobs (full list in `threadkeeper/config.py`):
|
|
|
432
432
|
| `THREADKEEPER_MEMORY_GUARD_RECLAIM_MB` | 1024 | local RSS floor before warn-triggered self trim |
|
|
433
433
|
| `THREADKEEPER_MEMORY_GUARD_TARGET_SERVERS` | 1 | aggregate-pressure target after retiring stale idle servers |
|
|
434
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 |
|
|
435
436
|
| `THREADKEEPER_MEMORY_GUARD_NOTIFY` | "1" | send macOS desktop notification when possible |
|
|
436
437
|
| `THREADKEEPER_INGEST_INTERVAL_S` | 3 | transcript ingest tick (s) |
|
|
437
438
|
| `THREADKEEPER_NO_EMBEDDINGS` | "" | force-disable sentence-transformers |
|
|
@@ -398,6 +398,7 @@ The most-used env knobs (full list in `threadkeeper/config.py`):
|
|
|
398
398
|
| `THREADKEEPER_MEMORY_GUARD_RECLAIM_MB` | 1024 | local RSS floor before warn-triggered self trim |
|
|
399
399
|
| `THREADKEEPER_MEMORY_GUARD_TARGET_SERVERS` | 1 | aggregate-pressure target after retiring stale idle servers |
|
|
400
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 |
|
|
401
402
|
| `THREADKEEPER_MEMORY_GUARD_NOTIFY` | "1" | send macOS desktop notification when possible |
|
|
402
403
|
| `THREADKEEPER_INGEST_INTERVAL_S` | 3 | transcript ingest tick (s) |
|
|
403
404
|
| `THREADKEEPER_NO_EMBEDDINGS` | "" | force-disable sentence-transformers |
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "threadkeeper"
|
|
7
|
-
version = "0.6.
|
|
7
|
+
version = "0.6.2"
|
|
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" }]
|
|
@@ -108,6 +108,7 @@ def test_memory_guard_status_tool_reports_thresholds(mp_with_cid, monkeypatch):
|
|
|
108
108
|
assert "state=active" in txt
|
|
109
109
|
assert "warn_mb=1000" in txt
|
|
110
110
|
assert "kill_mb=2000" in txt
|
|
111
|
+
assert "coordinator=on" in txt
|
|
111
112
|
assert "ok pid=1001" in txt
|
|
112
113
|
assert "WARN pid=1002" in txt
|
|
113
114
|
assert "KILL pid=1003" in txt
|
|
@@ -172,17 +173,51 @@ def test_check_apply_requests_peer_trim_on_aggregate_warn(mp_with_cid, monkeypat
|
|
|
172
173
|
})
|
|
173
174
|
monkeypatch.setattr(process_health, "scan", lambda: [
|
|
174
175
|
_proc(os.getpid(), 900),
|
|
175
|
-
_proc(1002, 1200, ppid=os.getpid()),
|
|
176
|
+
_proc(os.getpid() + 1002, 1200, ppid=os.getpid()),
|
|
176
177
|
])
|
|
177
178
|
|
|
178
179
|
out = memory_guard.check_once(dry_run=False, notify=False)
|
|
179
|
-
|
|
180
|
+
peer_pid = os.getpid() + 1002
|
|
181
|
+
assert sorted(out["reclaim_requests"]["requested"]) == sorted(
|
|
182
|
+
[os.getpid(), peer_pid]
|
|
183
|
+
)
|
|
180
184
|
|
|
181
185
|
conn = pkg["db"].get_db()
|
|
182
186
|
rows = conn.execute(
|
|
183
187
|
"SELECT target_pid FROM resource_controls WHERE action='trim'"
|
|
184
188
|
).fetchall()
|
|
185
|
-
assert sorted(r["target_pid"] for r in rows) == sorted([os.getpid(),
|
|
189
|
+
assert sorted(r["target_pid"] for r in rows) == sorted([os.getpid(), peer_pid])
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_aggregate_side_effects_only_run_on_coordinator(mp_with_cid, monkeypatch):
|
|
193
|
+
mp_with_cid(_FAKE_CID)
|
|
194
|
+
from threadkeeper import memory_guard, process_health
|
|
195
|
+
|
|
196
|
+
self_pid = os.getpid()
|
|
197
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_WARN_MB", 5000)
|
|
198
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_KILL_MB", 6000)
|
|
199
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_WARN_MB", 2000)
|
|
200
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_KILL_MB", 0)
|
|
201
|
+
monkeypatch.setattr(process_health, "scan", lambda: [
|
|
202
|
+
_proc(self_pid - 1, 1200),
|
|
203
|
+
_proc(self_pid, 1200),
|
|
204
|
+
])
|
|
205
|
+
reclaim_calls: list[str] = []
|
|
206
|
+
monkeypatch.setattr(
|
|
207
|
+
memory_guard,
|
|
208
|
+
"reclaim_memory",
|
|
209
|
+
lambda reason="": reclaim_calls.append(reason) or {
|
|
210
|
+
"before_mb": 1200, "after_mb": 1100, "freed_mb": 100,
|
|
211
|
+
"pid": self_pid, "actions": [],
|
|
212
|
+
},
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
out = memory_guard.check_once(dry_run=False, notify=False)
|
|
216
|
+
assert out["aggregate"]["warn"] is True
|
|
217
|
+
assert out["coordinator"] is False
|
|
218
|
+
assert out["reclaim_requests"]["count"] == 0
|
|
219
|
+
assert out["local_reclaim"] is None
|
|
220
|
+
assert reclaim_calls == []
|
|
186
221
|
|
|
187
222
|
|
|
188
223
|
def test_check_apply_retires_idle_candidate_on_aggregate_pressure(mp_with_cid, monkeypatch):
|
|
@@ -195,6 +230,7 @@ def test_check_apply_retires_idle_candidate_on_aggregate_pressure(mp_with_cid, m
|
|
|
195
230
|
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_KILL_MB", 3000)
|
|
196
231
|
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_TARGET_SERVERS", 1)
|
|
197
232
|
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_RETIRE_IDLE_S", 900)
|
|
233
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_RETIRE_LIVE", False)
|
|
198
234
|
monkeypatch.setattr(memory_guard, "reclaim_memory", lambda reason="": {
|
|
199
235
|
"before_mb": 900, "after_mb": 800, "freed_mb": 100,
|
|
200
236
|
"pid": os.getpid(), "actions": ["fake"],
|
|
@@ -203,8 +239,13 @@ def test_check_apply_retires_idle_candidate_on_aggregate_pressure(mp_with_cid, m
|
|
|
203
239
|
def scan():
|
|
204
240
|
return [
|
|
205
241
|
_proc(os.getpid(), 900),
|
|
206
|
-
_proc(1001, 1200, ppid=
|
|
207
|
-
|
|
242
|
+
_proc(os.getpid() + 1001, 1200, ppid=1) | {
|
|
243
|
+
"heartbeat_age_s": None,
|
|
244
|
+
"parent_alive": False,
|
|
245
|
+
"is_orphaned": True,
|
|
246
|
+
"orphan_reason": "parent_gone + no_heartbeat",
|
|
247
|
+
},
|
|
248
|
+
_proc(os.getpid() + 1002, 800, ppid=os.getpid()) | {"heartbeat_age_s": 5},
|
|
208
249
|
]
|
|
209
250
|
|
|
210
251
|
monkeypatch.setattr(process_health, "scan", scan)
|
|
@@ -215,5 +256,36 @@ def test_check_apply_retires_idle_candidate_on_aggregate_pressure(mp_with_cid, m
|
|
|
215
256
|
)
|
|
216
257
|
|
|
217
258
|
out = memory_guard.check_once(dry_run=False, notify=False)
|
|
218
|
-
|
|
219
|
-
assert
|
|
259
|
+
stale_pid = os.getpid() + 1001
|
|
260
|
+
assert out["retired"] == [stale_pid]
|
|
261
|
+
assert calls == [(stale_pid, _sig.SIGTERM)]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_aggregate_retire_skips_live_parent_without_opt_in(mp_with_cid, monkeypatch):
|
|
265
|
+
mp_with_cid(_FAKE_CID)
|
|
266
|
+
from threadkeeper import memory_guard, process_health
|
|
267
|
+
|
|
268
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_WARN_MB", 5000)
|
|
269
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_KILL_MB", 6000)
|
|
270
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_WARN_MB", 2000)
|
|
271
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_KILL_MB", 3000)
|
|
272
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_TARGET_SERVERS", 1)
|
|
273
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_RETIRE_IDLE_S", 900)
|
|
274
|
+
monkeypatch.setattr(memory_guard, "MEMORY_GUARD_RETIRE_LIVE", False)
|
|
275
|
+
monkeypatch.setattr(memory_guard, "reclaim_memory", lambda reason="": {
|
|
276
|
+
"before_mb": 900, "after_mb": 800, "freed_mb": 100,
|
|
277
|
+
"pid": os.getpid(), "actions": ["fake"],
|
|
278
|
+
})
|
|
279
|
+
monkeypatch.setattr(process_health, "scan", lambda: [
|
|
280
|
+
_proc(os.getpid(), 900),
|
|
281
|
+
_proc(os.getpid() + 1001, 1200, ppid=os.getpid()) | {"heartbeat_age_s": None},
|
|
282
|
+
])
|
|
283
|
+
calls: list[tuple[int, int]] = []
|
|
284
|
+
monkeypatch.setattr(
|
|
285
|
+
"os.kill",
|
|
286
|
+
lambda pid, sig: calls.append((pid, sig)) if sig != 0 else None,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
out = memory_guard.check_once(dry_run=False, notify=False)
|
|
290
|
+
assert out["retired"] == []
|
|
291
|
+
assert calls == []
|
|
@@ -183,6 +183,9 @@ MEMORY_GUARD_TARGET_SERVERS: int = int(
|
|
|
183
183
|
MEMORY_GUARD_RETIRE_IDLE_S: int = int(
|
|
184
184
|
os.environ.get("THREADKEEPER_MEMORY_GUARD_RETIRE_IDLE_S", "900")
|
|
185
185
|
)
|
|
186
|
+
MEMORY_GUARD_RETIRE_LIVE: bool = os.environ.get(
|
|
187
|
+
"THREADKEEPER_MEMORY_GUARD_RETIRE_LIVE", ""
|
|
188
|
+
).lower() in {"1", "true", "yes", "on"}
|
|
186
189
|
MEMORY_GUARD_NOTIFY: bool = os.environ.get(
|
|
187
190
|
"THREADKEEPER_MEMORY_GUARD_NOTIFY", "1"
|
|
188
191
|
).lower() in {"1", "true", "yes", "on"}
|
|
@@ -28,6 +28,7 @@ from .config import (
|
|
|
28
28
|
MEMORY_GUARD_POLL_S,
|
|
29
29
|
MEMORY_GUARD_RECLAIM_MB,
|
|
30
30
|
MEMORY_GUARD_RETIRE_IDLE_S,
|
|
31
|
+
MEMORY_GUARD_RETIRE_LIVE,
|
|
31
32
|
MEMORY_GUARD_TARGET_SERVERS,
|
|
32
33
|
MEMORY_GUARD_WARN_MB,
|
|
33
34
|
TASK_LOG_DIR,
|
|
@@ -72,13 +73,19 @@ def _log_line(line: str) -> None:
|
|
|
72
73
|
logger.debug("memory_guard: failed to append log", exc_info=True)
|
|
73
74
|
|
|
74
75
|
|
|
75
|
-
def _emit_event(kind: str,
|
|
76
|
+
def _emit_event(kind: str, target: int | str, summary: str) -> None:
|
|
76
77
|
try:
|
|
77
78
|
conn = get_db()
|
|
78
79
|
conn.execute(
|
|
79
80
|
"INSERT INTO events (session_id, kind, target, summary, created_at) "
|
|
80
81
|
"VALUES (?,?,?,?,?)",
|
|
81
|
-
(
|
|
82
|
+
(
|
|
83
|
+
identity._session_id or "",
|
|
84
|
+
kind,
|
|
85
|
+
str(target),
|
|
86
|
+
summary[:200],
|
|
87
|
+
int(time.time()),
|
|
88
|
+
),
|
|
82
89
|
)
|
|
83
90
|
conn.commit()
|
|
84
91
|
except Exception:
|
|
@@ -227,6 +234,18 @@ def _pending_recent_control(conn, action: str, pid: int, now: int) -> bool:
|
|
|
227
234
|
return row is not None
|
|
228
235
|
|
|
229
236
|
|
|
237
|
+
def _recent_event(conn, kind: str, target: str, now: int) -> bool:
|
|
238
|
+
if MEMORY_GUARD_COOLDOWN_S <= 0:
|
|
239
|
+
return False
|
|
240
|
+
row = conn.execute(
|
|
241
|
+
"SELECT 1 FROM events "
|
|
242
|
+
"WHERE kind=? AND target=? AND created_at>=? "
|
|
243
|
+
"ORDER BY id DESC LIMIT 1",
|
|
244
|
+
(kind, target, now - max(1, MEMORY_GUARD_COOLDOWN_S)),
|
|
245
|
+
).fetchone()
|
|
246
|
+
return row is not None
|
|
247
|
+
|
|
248
|
+
|
|
230
249
|
def request_reclaim(procs: list[dict] | None = None, reason: str = "manual") -> dict:
|
|
231
250
|
"""Queue trim requests for the given process rows, deduped by cooldown."""
|
|
232
251
|
if procs is None:
|
|
@@ -288,14 +307,44 @@ def _aggregate_state(procs: list[dict]) -> dict:
|
|
|
288
307
|
"kill_mb": MEMORY_GUARD_AGG_KILL_MB,
|
|
289
308
|
"target_servers": MEMORY_GUARD_TARGET_SERVERS,
|
|
290
309
|
"retire_idle_s": MEMORY_GUARD_RETIRE_IDLE_S,
|
|
310
|
+
"retire_live": MEMORY_GUARD_RETIRE_LIVE,
|
|
291
311
|
}
|
|
292
312
|
|
|
293
313
|
|
|
314
|
+
def _global_guard_coordinator_pid(procs: list[dict]) -> int | None:
|
|
315
|
+
candidates = [
|
|
316
|
+
int(p["pid"])
|
|
317
|
+
for p in procs
|
|
318
|
+
if int(p.get("pid") or 0) > 0 and not p.get("is_orphaned")
|
|
319
|
+
]
|
|
320
|
+
if not candidates:
|
|
321
|
+
candidates = [int(p["pid"]) for p in procs if int(p.get("pid") or 0) > 0]
|
|
322
|
+
return min(candidates) if candidates else None
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def is_global_guard_coordinator(procs: list[dict]) -> bool:
|
|
326
|
+
"""True when this server owns process-wide aggregate side effects.
|
|
327
|
+
|
|
328
|
+
Every MCP server runs its own local daemon. Without coordination, N open
|
|
329
|
+
conversations produce N aggregate warnings, N trim sweeps, and racing retire
|
|
330
|
+
attempts. The oldest non-orphaned server is the single coordinator; if the
|
|
331
|
+
scanner somehow misses this process, treat the current manual caller as the
|
|
332
|
+
coordinator so diagnostics/tests still apply side effects.
|
|
333
|
+
"""
|
|
334
|
+
self_pid = os.getpid()
|
|
335
|
+
pids = {int(p["pid"]) for p in procs if int(p.get("pid") or 0) > 0}
|
|
336
|
+
if self_pid not in pids:
|
|
337
|
+
return True
|
|
338
|
+
return self_pid == _global_guard_coordinator_pid(procs)
|
|
339
|
+
|
|
340
|
+
|
|
294
341
|
def _idle_retire_candidates(procs: list[dict]) -> list[dict]:
|
|
295
342
|
candidates: list[dict] = []
|
|
296
343
|
for p in procs:
|
|
297
344
|
if p.get("is_self"):
|
|
298
345
|
continue
|
|
346
|
+
if p.get("parent_alive") and not MEMORY_GUARD_RETIRE_LIVE:
|
|
347
|
+
continue
|
|
299
348
|
hb = p.get("heartbeat_age_s")
|
|
300
349
|
if hb is None or hb >= MEMORY_GUARD_RETIRE_IDLE_S:
|
|
301
350
|
candidates.append(dict(p, rss_mb=_rss_mb(p)))
|
|
@@ -347,12 +396,15 @@ def scan_over_limit() -> dict:
|
|
|
347
396
|
warn.append(p)
|
|
348
397
|
aggregate = _aggregate_state(procs)
|
|
349
398
|
retire = _retire_plan(procs, aggregate)
|
|
399
|
+
coordinator = is_global_guard_coordinator(procs)
|
|
350
400
|
return {
|
|
351
401
|
"procs": procs,
|
|
352
402
|
"warn": warn,
|
|
353
403
|
"kill": kill,
|
|
354
404
|
"aggregate": aggregate,
|
|
355
405
|
"retire": retire,
|
|
406
|
+
"coordinator": coordinator,
|
|
407
|
+
"coordinator_pid": _global_guard_coordinator_pid(procs),
|
|
356
408
|
"warn_mb": MEMORY_GUARD_WARN_MB,
|
|
357
409
|
"kill_mb": MEMORY_GUARD_KILL_MB,
|
|
358
410
|
"reclaim_mb": MEMORY_GUARD_RECLAIM_MB,
|
|
@@ -373,6 +425,7 @@ def check_once(*, dry_run: bool = True, notify: bool = True) -> dict:
|
|
|
373
425
|
handled_controls = handle_resource_controls()
|
|
374
426
|
|
|
375
427
|
result = scan_over_limit()
|
|
428
|
+
is_coordinator = bool(result.get("coordinator"))
|
|
376
429
|
killed: list[int] = []
|
|
377
430
|
failed: list[dict] = []
|
|
378
431
|
retired: list[int] = []
|
|
@@ -380,6 +433,8 @@ def check_once(*, dry_run: bool = True, notify: bool = True) -> dict:
|
|
|
380
433
|
local_reclaim: dict | None = None
|
|
381
434
|
|
|
382
435
|
for p in result["warn"]:
|
|
436
|
+
if p["pid"] != os.getpid():
|
|
437
|
+
continue
|
|
383
438
|
msg = (
|
|
384
439
|
f"pid {p['pid']} RSS {p['rss_mb']}MB crossed warn "
|
|
385
440
|
f"threshold {MEMORY_GUARD_WARN_MB}MB"
|
|
@@ -392,21 +447,24 @@ def check_once(*, dry_run: bool = True, notify: bool = True) -> dict:
|
|
|
392
447
|
_maybe_notify(p["pid"], "warn", msg)
|
|
393
448
|
|
|
394
449
|
aggregate = result["aggregate"]
|
|
395
|
-
if aggregate["warn"]:
|
|
450
|
+
if aggregate["warn"] and is_coordinator:
|
|
396
451
|
msg = (
|
|
397
452
|
f"aggregate RSS {aggregate['rss_mb']}MB crossed warn "
|
|
398
453
|
f"threshold {aggregate['warn_mb']}MB across "
|
|
399
454
|
f"{len(result['procs'])} server process(es)"
|
|
400
455
|
)
|
|
401
456
|
if not dry_run:
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
457
|
+
conn = get_db()
|
|
458
|
+
now = int(time.time())
|
|
459
|
+
if not _recent_event(conn, "memory_guard_aggregate_warn", "aggregate", now):
|
|
460
|
+
_emit_event("memory_guard_aggregate_warn", "aggregate", msg)
|
|
461
|
+
reclaim_requests = request_reclaim(
|
|
462
|
+
result["procs"], reason="aggregate_warn"
|
|
463
|
+
)
|
|
464
|
+
if any(p["pid"] == os.getpid() for p in result["procs"]):
|
|
465
|
+
local_reclaim = reclaim_memory(reason="aggregate_warn")
|
|
466
|
+
if notify:
|
|
467
|
+
_maybe_notify(os.getpid(), "aggregate_warn", msg)
|
|
410
468
|
|
|
411
469
|
kill_rows = sorted(
|
|
412
470
|
result["kill"], key=lambda p: p["pid"] == os.getpid()
|
|
@@ -418,6 +476,8 @@ def check_once(*, dry_run: bool = True, notify: bool = True) -> dict:
|
|
|
418
476
|
)
|
|
419
477
|
if dry_run:
|
|
420
478
|
continue
|
|
479
|
+
if p["pid"] != os.getpid() and not is_coordinator:
|
|
480
|
+
continue
|
|
421
481
|
_emit_event("memory_guard_kill", p["pid"], msg)
|
|
422
482
|
if notify:
|
|
423
483
|
_maybe_notify(p["pid"], "kill", msg, force=True)
|
|
@@ -427,7 +487,7 @@ def check_once(*, dry_run: bool = True, notify: bool = True) -> dict:
|
|
|
427
487
|
except (ProcessLookupError, PermissionError, OSError) as e:
|
|
428
488
|
failed.append({"pid": p["pid"], "err": str(e)})
|
|
429
489
|
|
|
430
|
-
if aggregate["warn"] and result["retire"]:
|
|
490
|
+
if aggregate["warn"] and is_coordinator and result["retire"]:
|
|
431
491
|
for p in result["retire"]:
|
|
432
492
|
msg = (
|
|
433
493
|
f"aggregate RSS {aggregate['rss_mb']}MB; retiring idle "
|
|
@@ -32,6 +32,9 @@ def memory_guard_status() -> str:
|
|
|
32
32
|
f"warn_mb={result['warn_mb']} kill_mb={result['kill_mb']} "
|
|
33
33
|
f"agg_warn_mb={agg['warn_mb']} agg_kill_mb={agg['kill_mb']} "
|
|
34
34
|
f"target_servers={agg['target_servers']} "
|
|
35
|
+
f"retire_live={'on' if agg['retire_live'] else 'off'} "
|
|
36
|
+
f"coordinator={'on' if result['coordinator'] else 'off'} "
|
|
37
|
+
f"coordinator_pid={result['coordinator_pid'] or '-'} "
|
|
35
38
|
f"notify={'on' if result['notify'] else 'off'}",
|
|
36
39
|
f"processes={len(procs)} rss_total={total_mb}MB aggregate={agg_marker}",
|
|
37
40
|
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: threadkeeper
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.2
|
|
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
|
|
@@ -432,6 +432,7 @@ The most-used env knobs (full list in `threadkeeper/config.py`):
|
|
|
432
432
|
| `THREADKEEPER_MEMORY_GUARD_RECLAIM_MB` | 1024 | local RSS floor before warn-triggered self trim |
|
|
433
433
|
| `THREADKEEPER_MEMORY_GUARD_TARGET_SERVERS` | 1 | aggregate-pressure target after retiring stale idle servers |
|
|
434
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 |
|
|
435
436
|
| `THREADKEEPER_MEMORY_GUARD_NOTIFY` | "1" | send macOS desktop notification when possible |
|
|
436
437
|
| `THREADKEEPER_INGEST_INTERVAL_S` | 3 | transcript ingest tick (s) |
|
|
437
438
|
| `THREADKEEPER_NO_EMBEDDINGS` | "" | force-disable sentence-transformers |
|
|
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
|