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.
Files changed (103) hide show
  1. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/PKG-INFO +2 -1
  2. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/README.md +1 -0
  3. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/pyproject.toml +1 -1
  4. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_memory_guard.py +79 -7
  5. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/config.py +3 -0
  6. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/memory_guard.py +72 -12
  7. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/memory_guard.py +3 -0
  8. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper.egg-info/PKG-INFO +2 -1
  9. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/LICENSE +0 -0
  10. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/setup.cfg +0 -0
  11. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_adapters.py +0 -0
  12. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_brief_sections.py +0 -0
  13. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_candidate_reviewer.py +0 -0
  14. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_core_memory.py +0 -0
  15. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_curator.py +0 -0
  16. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_delegated_search.py +0 -0
  17. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_dialectic.py +0 -0
  18. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_dialectic_tier.py +0 -0
  19. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_error_paths.py +0 -0
  20. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_extract_daemon.py +0 -0
  21. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_i18n_multilang.py +0 -0
  22. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_identity.py +0 -0
  23. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_lessons.py +0 -0
  24. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_missed_spawns.py +0 -0
  25. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_nudges.py +0 -0
  26. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_process_health.py +0 -0
  27. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_shadow_review.py +0 -0
  28. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_skill_hint.py +0 -0
  29. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_skill_tier.py +0 -0
  30. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_skill_use_parser.py +0 -0
  31. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_skill_watcher.py +0 -0
  32. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_skills.py +0 -0
  33. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_spawn_budget.py +0 -0
  34. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_spawn_config.py +0 -0
  35. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_spawn_hint.py +0 -0
  36. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_spawn_slim.py +0 -0
  37. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_threads.py +0 -0
  38. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_tools_smoke.py +0 -0
  39. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_validate_threads.py +0 -0
  40. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/tests/test_vec_search.py +0 -0
  41. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/__init__.py +0 -0
  42. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/_mcp.py +0 -0
  43. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/_setup.py +0 -0
  44. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/adapters/__init__.py +0 -0
  45. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/adapters/_hook_helpers.py +0 -0
  46. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/adapters/base.py +0 -0
  47. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/adapters/claude_code.py +0 -0
  48. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/adapters/claude_desktop.py +0 -0
  49. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/adapters/codex.py +0 -0
  50. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/adapters/copilot.py +0 -0
  51. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/adapters/gemini.py +0 -0
  52. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/adapters/vscode.py +0 -0
  53. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/brief.py +0 -0
  54. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/candidate_reviewer.py +0 -0
  55. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/curator.py +0 -0
  56. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/db.py +0 -0
  57. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/embeddings.py +0 -0
  58. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/extract_daemon.py +0 -0
  59. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/helpers.py +0 -0
  60. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/i18n.py +0 -0
  61. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/identity.py +0 -0
  62. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/ingest.py +0 -0
  63. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/lessons.py +0 -0
  64. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/nudges.py +0 -0
  65. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/process_health.py +0 -0
  66. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/review_prompts.py +0 -0
  67. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/search_proxy.py +0 -0
  68. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/server.py +0 -0
  69. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/shadow_review.py +0 -0
  70. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/skill_watcher.py +0 -0
  71. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/spawn_budget.py +0 -0
  72. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/spawn_config.py +0 -0
  73. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/__init__.py +0 -0
  74. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/candidate_reviewer.py +0 -0
  75. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/concepts.py +0 -0
  76. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/consolidate.py +0 -0
  77. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/core_memory.py +0 -0
  78. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/correlation.py +0 -0
  79. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/curator.py +0 -0
  80. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/dialectic.py +0 -0
  81. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/dialog.py +0 -0
  82. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/distill.py +0 -0
  83. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/extract.py +0 -0
  84. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/graph.py +0 -0
  85. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/invariants.py +0 -0
  86. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/lessons.py +0 -0
  87. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/missed_spawns.py +0 -0
  88. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/peers.py +0 -0
  89. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/pickup.py +0 -0
  90. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/probes.py +0 -0
  91. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/process_health.py +0 -0
  92. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/session.py +0 -0
  93. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/shadow_review.py +0 -0
  94. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/skills.py +0 -0
  95. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/spawn.py +0 -0
  96. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/style.py +0 -0
  97. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/threads.py +0 -0
  98. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper/tools/validate.py +0 -0
  99. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper.egg-info/SOURCES.txt +0 -0
  100. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper.egg-info/dependency_links.txt +0 -0
  101. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper.egg-info/entry_points.txt +0 -0
  102. {threadkeeper-0.6.0 → threadkeeper-0.6.2}/threadkeeper.egg-info/requires.txt +0 -0
  103. {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.0
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.0"
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
- assert sorted(out["reclaim_requests"]["requested"]) == sorted([os.getpid(), 1002])
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(), 1002])
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=os.getpid()) | {"heartbeat_age_s": None},
207
- _proc(1002, 800, ppid=os.getpid()) | {"heartbeat_age_s": 5},
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
- assert out["retired"] == [1001]
219
- assert calls == [(1001, _sig.SIGTERM)]
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, pid: int, summary: str) -> None:
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
- (identity._session_id or "", kind, str(pid), summary[:200], int(time.time())),
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
- _emit_event("memory_guard_aggregate_warn", os.getpid(), msg)
403
- reclaim_requests = request_reclaim(
404
- result["procs"], reason="aggregate_warn"
405
- )
406
- if any(p["pid"] == os.getpid() for p in result["procs"]):
407
- local_reclaim = reclaim_memory(reason="aggregate_warn")
408
- if notify and not dry_run:
409
- _maybe_notify(os.getpid(), "aggregate_warn", msg)
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.0
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