threadkeeper 0.6.1__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.1 → threadkeeper-0.6.2}/PKG-INFO +1 -1
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/pyproject.toml +1 -1
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/tests/test_memory_guard.py +44 -8
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/memory_guard.py +68 -12
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/tools/memory_guard.py +2 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper.egg-info/PKG-INFO +1 -1
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/LICENSE +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/README.md +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/setup.cfg +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/tests/test_adapters.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/tests/test_brief_sections.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/tests/test_candidate_reviewer.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/tests/test_core_memory.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/tests/test_curator.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/tests/test_delegated_search.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/tests/test_dialectic.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/tests/test_dialectic_tier.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/tests/test_error_paths.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/tests/test_extract_daemon.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/tests/test_i18n_multilang.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/tests/test_identity.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/tests/test_lessons.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/tests/test_missed_spawns.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/tests/test_nudges.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/tests/test_process_health.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/tests/test_shadow_review.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/tests/test_skill_hint.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/tests/test_skill_tier.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/tests/test_skill_use_parser.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/tests/test_skill_watcher.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/tests/test_skills.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/tests/test_spawn_budget.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/tests/test_spawn_config.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/tests/test_spawn_hint.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/tests/test_spawn_slim.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/tests/test_threads.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/tests/test_tools_smoke.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/tests/test_validate_threads.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/tests/test_vec_search.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/__init__.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/_mcp.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/_setup.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/adapters/__init__.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/adapters/_hook_helpers.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/adapters/base.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/adapters/claude_code.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/adapters/claude_desktop.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/adapters/codex.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/adapters/copilot.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/adapters/gemini.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/adapters/vscode.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/brief.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/candidate_reviewer.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/config.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/curator.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/db.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/embeddings.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/extract_daemon.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/helpers.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/i18n.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/identity.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/ingest.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/lessons.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/nudges.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/process_health.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/review_prompts.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/search_proxy.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/server.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/shadow_review.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/skill_watcher.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/spawn_budget.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/spawn_config.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/tools/__init__.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/tools/candidate_reviewer.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/tools/concepts.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/tools/consolidate.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/tools/core_memory.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/tools/correlation.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/tools/curator.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/tools/dialectic.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/tools/dialog.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/tools/distill.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/tools/extract.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/tools/graph.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/tools/invariants.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/tools/lessons.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/tools/missed_spawns.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/tools/peers.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/tools/pickup.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/tools/probes.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/tools/process_health.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/tools/session.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/tools/shadow_review.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/tools/skills.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/tools/spawn.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/tools/style.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/tools/threads.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper/tools/validate.py +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper.egg-info/SOURCES.txt +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper.egg-info/dependency_links.txt +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper.egg-info/entry_points.txt +0 -0
- {threadkeeper-0.6.1 → threadkeeper-0.6.2}/threadkeeper.egg-info/requires.txt +0 -0
- {threadkeeper-0.6.1 → 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
|
|
@@ -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):
|
|
@@ -204,13 +239,13 @@ def test_check_apply_retires_idle_candidate_on_aggregate_pressure(mp_with_cid, m
|
|
|
204
239
|
def scan():
|
|
205
240
|
return [
|
|
206
241
|
_proc(os.getpid(), 900),
|
|
207
|
-
_proc(1001, 1200, ppid=1) | {
|
|
242
|
+
_proc(os.getpid() + 1001, 1200, ppid=1) | {
|
|
208
243
|
"heartbeat_age_s": None,
|
|
209
244
|
"parent_alive": False,
|
|
210
245
|
"is_orphaned": True,
|
|
211
246
|
"orphan_reason": "parent_gone + no_heartbeat",
|
|
212
247
|
},
|
|
213
|
-
_proc(1002, 800, ppid=os.getpid()) | {"heartbeat_age_s": 5},
|
|
248
|
+
_proc(os.getpid() + 1002, 800, ppid=os.getpid()) | {"heartbeat_age_s": 5},
|
|
214
249
|
]
|
|
215
250
|
|
|
216
251
|
monkeypatch.setattr(process_health, "scan", scan)
|
|
@@ -221,8 +256,9 @@ def test_check_apply_retires_idle_candidate_on_aggregate_pressure(mp_with_cid, m
|
|
|
221
256
|
)
|
|
222
257
|
|
|
223
258
|
out = memory_guard.check_once(dry_run=False, notify=False)
|
|
224
|
-
|
|
225
|
-
assert
|
|
259
|
+
stale_pid = os.getpid() + 1001
|
|
260
|
+
assert out["retired"] == [stale_pid]
|
|
261
|
+
assert calls == [(stale_pid, _sig.SIGTERM)]
|
|
226
262
|
|
|
227
263
|
|
|
228
264
|
def test_aggregate_retire_skips_live_parent_without_opt_in(mp_with_cid, monkeypatch):
|
|
@@ -242,7 +278,7 @@ def test_aggregate_retire_skips_live_parent_without_opt_in(mp_with_cid, monkeypa
|
|
|
242
278
|
})
|
|
243
279
|
monkeypatch.setattr(process_health, "scan", lambda: [
|
|
244
280
|
_proc(os.getpid(), 900),
|
|
245
|
-
_proc(1001, 1200, ppid=os.getpid()) | {"heartbeat_age_s": None},
|
|
281
|
+
_proc(os.getpid() + 1001, 1200, ppid=os.getpid()) | {"heartbeat_age_s": None},
|
|
246
282
|
])
|
|
247
283
|
calls: list[tuple[int, int]] = []
|
|
248
284
|
monkeypatch.setattr(
|
|
@@ -73,13 +73,19 @@ def _log_line(line: str) -> None:
|
|
|
73
73
|
logger.debug("memory_guard: failed to append log", exc_info=True)
|
|
74
74
|
|
|
75
75
|
|
|
76
|
-
def _emit_event(kind: str,
|
|
76
|
+
def _emit_event(kind: str, target: int | str, summary: str) -> None:
|
|
77
77
|
try:
|
|
78
78
|
conn = get_db()
|
|
79
79
|
conn.execute(
|
|
80
80
|
"INSERT INTO events (session_id, kind, target, summary, created_at) "
|
|
81
81
|
"VALUES (?,?,?,?,?)",
|
|
82
|
-
(
|
|
82
|
+
(
|
|
83
|
+
identity._session_id or "",
|
|
84
|
+
kind,
|
|
85
|
+
str(target),
|
|
86
|
+
summary[:200],
|
|
87
|
+
int(time.time()),
|
|
88
|
+
),
|
|
83
89
|
)
|
|
84
90
|
conn.commit()
|
|
85
91
|
except Exception:
|
|
@@ -228,6 +234,18 @@ def _pending_recent_control(conn, action: str, pid: int, now: int) -> bool:
|
|
|
228
234
|
return row is not None
|
|
229
235
|
|
|
230
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
|
+
|
|
231
249
|
def request_reclaim(procs: list[dict] | None = None, reason: str = "manual") -> dict:
|
|
232
250
|
"""Queue trim requests for the given process rows, deduped by cooldown."""
|
|
233
251
|
if procs is None:
|
|
@@ -293,6 +311,33 @@ def _aggregate_state(procs: list[dict]) -> dict:
|
|
|
293
311
|
}
|
|
294
312
|
|
|
295
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
|
+
|
|
296
341
|
def _idle_retire_candidates(procs: list[dict]) -> list[dict]:
|
|
297
342
|
candidates: list[dict] = []
|
|
298
343
|
for p in procs:
|
|
@@ -351,12 +396,15 @@ def scan_over_limit() -> dict:
|
|
|
351
396
|
warn.append(p)
|
|
352
397
|
aggregate = _aggregate_state(procs)
|
|
353
398
|
retire = _retire_plan(procs, aggregate)
|
|
399
|
+
coordinator = is_global_guard_coordinator(procs)
|
|
354
400
|
return {
|
|
355
401
|
"procs": procs,
|
|
356
402
|
"warn": warn,
|
|
357
403
|
"kill": kill,
|
|
358
404
|
"aggregate": aggregate,
|
|
359
405
|
"retire": retire,
|
|
406
|
+
"coordinator": coordinator,
|
|
407
|
+
"coordinator_pid": _global_guard_coordinator_pid(procs),
|
|
360
408
|
"warn_mb": MEMORY_GUARD_WARN_MB,
|
|
361
409
|
"kill_mb": MEMORY_GUARD_KILL_MB,
|
|
362
410
|
"reclaim_mb": MEMORY_GUARD_RECLAIM_MB,
|
|
@@ -377,6 +425,7 @@ def check_once(*, dry_run: bool = True, notify: bool = True) -> dict:
|
|
|
377
425
|
handled_controls = handle_resource_controls()
|
|
378
426
|
|
|
379
427
|
result = scan_over_limit()
|
|
428
|
+
is_coordinator = bool(result.get("coordinator"))
|
|
380
429
|
killed: list[int] = []
|
|
381
430
|
failed: list[dict] = []
|
|
382
431
|
retired: list[int] = []
|
|
@@ -384,6 +433,8 @@ def check_once(*, dry_run: bool = True, notify: bool = True) -> dict:
|
|
|
384
433
|
local_reclaim: dict | None = None
|
|
385
434
|
|
|
386
435
|
for p in result["warn"]:
|
|
436
|
+
if p["pid"] != os.getpid():
|
|
437
|
+
continue
|
|
387
438
|
msg = (
|
|
388
439
|
f"pid {p['pid']} RSS {p['rss_mb']}MB crossed warn "
|
|
389
440
|
f"threshold {MEMORY_GUARD_WARN_MB}MB"
|
|
@@ -396,21 +447,24 @@ def check_once(*, dry_run: bool = True, notify: bool = True) -> dict:
|
|
|
396
447
|
_maybe_notify(p["pid"], "warn", msg)
|
|
397
448
|
|
|
398
449
|
aggregate = result["aggregate"]
|
|
399
|
-
if aggregate["warn"]:
|
|
450
|
+
if aggregate["warn"] and is_coordinator:
|
|
400
451
|
msg = (
|
|
401
452
|
f"aggregate RSS {aggregate['rss_mb']}MB crossed warn "
|
|
402
453
|
f"threshold {aggregate['warn_mb']}MB across "
|
|
403
454
|
f"{len(result['procs'])} server process(es)"
|
|
404
455
|
)
|
|
405
456
|
if not dry_run:
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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)
|
|
414
468
|
|
|
415
469
|
kill_rows = sorted(
|
|
416
470
|
result["kill"], key=lambda p: p["pid"] == os.getpid()
|
|
@@ -422,6 +476,8 @@ def check_once(*, dry_run: bool = True, notify: bool = True) -> dict:
|
|
|
422
476
|
)
|
|
423
477
|
if dry_run:
|
|
424
478
|
continue
|
|
479
|
+
if p["pid"] != os.getpid() and not is_coordinator:
|
|
480
|
+
continue
|
|
425
481
|
_emit_event("memory_guard_kill", p["pid"], msg)
|
|
426
482
|
if notify:
|
|
427
483
|
_maybe_notify(p["pid"], "kill", msg, force=True)
|
|
@@ -431,7 +487,7 @@ def check_once(*, dry_run: bool = True, notify: bool = True) -> dict:
|
|
|
431
487
|
except (ProcessLookupError, PermissionError, OSError) as e:
|
|
432
488
|
failed.append({"pid": p["pid"], "err": str(e)})
|
|
433
489
|
|
|
434
|
-
if aggregate["warn"] and result["retire"]:
|
|
490
|
+
if aggregate["warn"] and is_coordinator and result["retire"]:
|
|
435
491
|
for p in result["retire"]:
|
|
436
492
|
msg = (
|
|
437
493
|
f"aggregate RSS {aggregate['rss_mb']}MB; retiring idle "
|
|
@@ -33,6 +33,8 @@ def memory_guard_status() -> str:
|
|
|
33
33
|
f"agg_warn_mb={agg['warn_mb']} agg_kill_mb={agg['kill_mb']} "
|
|
34
34
|
f"target_servers={agg['target_servers']} "
|
|
35
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 '-'} "
|
|
36
38
|
f"notify={'on' if result['notify'] else 'off'}",
|
|
37
39
|
f"processes={len(procs)} rss_total={total_mb}MB aggregate={agg_marker}",
|
|
38
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
|
|
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
|