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