claude-nb 0.3.0 → 0.5.1
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.
- package/Makefile +8 -2
- package/README.md +57 -36
- package/VERSION +1 -1
- package/bin/board +112 -34
- package/bin/cnb +152 -65
- package/bin/dispatcher +25 -11
- package/bin/doctor +3 -5
- package/bin/init +13 -47
- package/bin/notify +224 -0
- package/bin/registry +8 -23
- package/bin/swarm +41 -860
- package/bin/sync-version +131 -0
- package/lib/board_admin.py +19 -9
- package/lib/board_bbs.py +23 -8
- package/lib/board_bug.py +2 -1
- package/lib/board_db.py +31 -141
- package/lib/board_lock.py +5 -1
- package/lib/board_mailbox.py +18 -8
- package/lib/board_maintenance.py +26 -27
- package/lib/board_msg.py +76 -39
- package/lib/board_pending.py +233 -0
- package/lib/board_pulse.py +14 -0
- package/lib/board_task.py +41 -32
- package/lib/board_tui.py +120 -0
- package/lib/board_view.py +70 -50
- package/lib/board_vote.py +9 -3
- package/lib/build_lock.py +7 -7
- package/lib/common.py +45 -3
- package/lib/concerns/__init__.py +7 -11
- package/lib/concerns/{coral_manager.py → coral.py} +54 -4
- package/lib/concerns/digest_scheduler.py +109 -0
- package/lib/concerns/file_watcher.py +73 -68
- package/lib/concerns/health.py +136 -0
- package/lib/concerns/helpers.py +1 -5
- package/lib/concerns/idle.py +130 -0
- package/lib/concerns/notification_push.py +171 -0
- package/lib/concerns/notifications.py +145 -0
- package/lib/concerns/nudge_coordinator.py +148 -0
- package/lib/digest.py +62 -0
- package/lib/health.py +2 -2
- package/lib/inject.py +2 -2
- package/lib/migrate.py +1 -0
- package/lib/monitor.py +9 -22
- package/lib/notification_config.py +101 -0
- package/lib/swarm.py +464 -0
- package/lib/swarm_backend.py +300 -0
- package/lib/theme_profiles.py +89 -0
- package/migrations/004_heartbeat.sql +1 -0
- package/migrations/005_notification_log.sql +12 -0
- package/migrations/006_pending_actions.sql +15 -0
- package/package.json +4 -3
- package/pyproject.toml +3 -2
- package/registry/README.md +9 -0
- package/registry/pubkeys.json +2 -1
- package/schema.sql +29 -1
- package/lib/concerns/bug_sla_checker.py +0 -32
- package/lib/concerns/coral_poker.py +0 -57
- package/lib/concerns/health_checker.py +0 -72
- package/lib/concerns/idle_detector.py +0 -56
- package/lib/concerns/idle_killer.py +0 -41
- package/lib/concerns/idle_nudger.py +0 -38
- package/lib/concerns/inbox_nudger.py +0 -34
- package/lib/concerns/resource_monitor.py +0 -47
- package/lib/concerns/session_keepalive.py +0 -23
- package/lib/concerns/time_announcer.py +0 -34
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""DigestScheduler — sends daily/weekly digests at scheduled times."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from lib.digest import generate_daily_digest
|
|
9
|
+
from lib.notification_config import load as load_config
|
|
10
|
+
|
|
11
|
+
from .base import Concern
|
|
12
|
+
from .config import DispatcherConfig
|
|
13
|
+
from .helpers import board_send, db, get_dev_sessions, log, warn
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DigestScheduler(Concern):
|
|
17
|
+
interval = 30
|
|
18
|
+
|
|
19
|
+
def __init__(self, cfg: DispatcherConfig) -> None:
|
|
20
|
+
super().__init__()
|
|
21
|
+
self.cfg = cfg
|
|
22
|
+
self._last_daily_date: str = ""
|
|
23
|
+
self._last_weekly_date: str = ""
|
|
24
|
+
|
|
25
|
+
def _config_path(self) -> Path:
|
|
26
|
+
return self.cfg.claudes_dir / "notifications.toml"
|
|
27
|
+
|
|
28
|
+
def _already_sent_today(self, notif_type: str, date_str: str) -> bool:
|
|
29
|
+
try:
|
|
30
|
+
count = db(self.cfg).scalar(
|
|
31
|
+
"SELECT COUNT(*) FROM notification_log WHERE notif_type=? AND ref_id=?",
|
|
32
|
+
(notif_type, f"digest-{date_str}"),
|
|
33
|
+
)
|
|
34
|
+
return bool(count)
|
|
35
|
+
except Exception:
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
def _record_digest(self, notif_type: str, recipient: str, date_str: str, channel: str) -> None:
|
|
39
|
+
try:
|
|
40
|
+
with db(self.cfg).conn() as c:
|
|
41
|
+
c.execute(
|
|
42
|
+
"INSERT INTO notification_log(notif_type, recipient, ref_type, ref_id, channel) VALUES(?,?,?,?,?)",
|
|
43
|
+
(notif_type, recipient, "digest", f"digest-{date_str}", channel),
|
|
44
|
+
)
|
|
45
|
+
except Exception as e:
|
|
46
|
+
warn(f"digest record: {e}")
|
|
47
|
+
|
|
48
|
+
def tick(self, now: int) -> None:
|
|
49
|
+
d = datetime.now()
|
|
50
|
+
if d.hour != 9 or d.minute > 5:
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
date_str = d.strftime("%Y-%m-%d")
|
|
54
|
+
|
|
55
|
+
if date_str != self._last_daily_date:
|
|
56
|
+
self._send_daily(date_str)
|
|
57
|
+
self._last_daily_date = date_str
|
|
58
|
+
|
|
59
|
+
if d.weekday() == 0 and date_str != self._last_weekly_date:
|
|
60
|
+
self._send_weekly(date_str)
|
|
61
|
+
self._last_weekly_date = date_str
|
|
62
|
+
|
|
63
|
+
def _send_daily(self, date_str: str) -> None:
|
|
64
|
+
if self._already_sent_today("daily-digest", date_str):
|
|
65
|
+
log(f"Daily digest already sent for {date_str}, skipping")
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
config = load_config(self._config_path())
|
|
69
|
+
members = get_dev_sessions(self.cfg)
|
|
70
|
+
subscribers = config.subscribers_for("daily-digest", members)
|
|
71
|
+
|
|
72
|
+
if not subscribers:
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
board = db(self.cfg)
|
|
77
|
+
digest_text = generate_daily_digest(board)
|
|
78
|
+
except Exception as e:
|
|
79
|
+
warn(f"digest generation failed: {e}")
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
for member in subscribers:
|
|
83
|
+
channel = config.channel_for(member)
|
|
84
|
+
if channel == "board-inbox":
|
|
85
|
+
board_send(self.cfg, member, digest_text)
|
|
86
|
+
else:
|
|
87
|
+
log(f"[digest] {channel} delivery not implemented for {member}")
|
|
88
|
+
self._record_digest("daily-digest", member, date_str, channel)
|
|
89
|
+
|
|
90
|
+
log(f"Daily digest sent to {len(subscribers)} subscribers")
|
|
91
|
+
|
|
92
|
+
def _send_weekly(self, date_str: str) -> None:
|
|
93
|
+
if self._already_sent_today("weekly-report", date_str):
|
|
94
|
+
log(f"Weekly report already sent for {date_str}, skipping")
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
config = load_config(self._config_path())
|
|
98
|
+
members = get_dev_sessions(self.cfg)
|
|
99
|
+
subscribers = config.subscribers_for("weekly-report", members)
|
|
100
|
+
|
|
101
|
+
if not subscribers:
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
board_send(self.cfg, "all", f"[Weekly Report] {date_str} — 本周报告待实现")
|
|
105
|
+
for member in subscribers:
|
|
106
|
+
channel = config.channel_for(member)
|
|
107
|
+
self._record_digest("weekly-report", member, date_str, channel)
|
|
108
|
+
|
|
109
|
+
log(f"Weekly report sent to {len(subscribers)} subscribers")
|
|
@@ -8,16 +8,16 @@ from lib.common import is_suspended
|
|
|
8
8
|
from .base import Concern
|
|
9
9
|
from .config import DispatcherConfig
|
|
10
10
|
from .helpers import log
|
|
11
|
-
from .
|
|
11
|
+
from .nudge_coordinator import NudgeCoordinator
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
class FileWatcher(Concern):
|
|
15
15
|
interval = 1
|
|
16
16
|
|
|
17
|
-
def __init__(self, cfg: DispatcherConfig,
|
|
17
|
+
def __init__(self, cfg: DispatcherConfig, nudge: NudgeCoordinator) -> None:
|
|
18
18
|
super().__init__()
|
|
19
19
|
self.cfg = cfg
|
|
20
|
-
self.
|
|
20
|
+
self.nudge = nudge
|
|
21
21
|
self._queue: list[str] = []
|
|
22
22
|
self._lock = threading.Lock()
|
|
23
23
|
self._stop = threading.Event()
|
|
@@ -41,77 +41,82 @@ class FileWatcher(Concern):
|
|
|
41
41
|
import select as sel
|
|
42
42
|
|
|
43
43
|
kq = sel.kqueue()
|
|
44
|
-
|
|
45
|
-
dir_fd = os.open(watch_dir, os.O_RDONLY)
|
|
46
|
-
kq.control(
|
|
47
|
-
[
|
|
48
|
-
sel.kevent(
|
|
49
|
-
dir_fd,
|
|
50
|
-
filter=sel.KQ_FILTER_VNODE,
|
|
51
|
-
flags=sel.KQ_EV_ADD | sel.KQ_EV_CLEAR,
|
|
52
|
-
fflags=sel.KQ_NOTE_WRITE,
|
|
53
|
-
)
|
|
54
|
-
],
|
|
55
|
-
0,
|
|
56
|
-
)
|
|
44
|
+
dir_fd = -1
|
|
57
45
|
file_fds: dict[str, int] = {}
|
|
46
|
+
try:
|
|
47
|
+
watch_dir = str(self.cfg.sessions_dir)
|
|
48
|
+
dir_fd = os.open(watch_dir, os.O_RDONLY)
|
|
49
|
+
kq.control(
|
|
50
|
+
[
|
|
51
|
+
sel.kevent(
|
|
52
|
+
dir_fd,
|
|
53
|
+
filter=sel.KQ_FILTER_VNODE,
|
|
54
|
+
flags=sel.KQ_EV_ADD | sel.KQ_EV_CLEAR,
|
|
55
|
+
fflags=sel.KQ_NOTE_WRITE,
|
|
56
|
+
)
|
|
57
|
+
],
|
|
58
|
+
0,
|
|
59
|
+
)
|
|
58
60
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
def refresh() -> None:
|
|
62
|
+
for f in os.listdir(watch_dir):
|
|
63
|
+
if not f.endswith(".md"):
|
|
64
|
+
continue
|
|
65
|
+
path = os.path.join(watch_dir, f)
|
|
66
|
+
if path in file_fds:
|
|
67
|
+
continue
|
|
68
|
+
fd = -1
|
|
69
|
+
try:
|
|
70
|
+
fd = os.open(path, os.O_RDONLY)
|
|
71
|
+
kq.control(
|
|
72
|
+
[
|
|
73
|
+
sel.kevent(
|
|
74
|
+
fd,
|
|
75
|
+
filter=sel.KQ_FILTER_VNODE,
|
|
76
|
+
flags=sel.KQ_EV_ADD | sel.KQ_EV_CLEAR,
|
|
77
|
+
fflags=sel.KQ_NOTE_WRITE | sel.KQ_NOTE_EXTEND,
|
|
78
|
+
)
|
|
79
|
+
],
|
|
80
|
+
0,
|
|
81
|
+
)
|
|
82
|
+
file_fds[path] = fd
|
|
83
|
+
except OSError:
|
|
84
|
+
if fd >= 0:
|
|
85
|
+
os.close(fd)
|
|
86
|
+
|
|
87
|
+
refresh()
|
|
88
|
+
while not self._stop.is_set():
|
|
89
|
+
fd_to_path = {fd: p for p, fd in file_fds.items()}
|
|
90
|
+
try:
|
|
91
|
+
events = kq.control(None, 8, 2.0)
|
|
92
|
+
except (InterruptedError, OSError):
|
|
93
|
+
if self._stop.is_set():
|
|
94
|
+
break
|
|
62
95
|
continue
|
|
63
|
-
|
|
64
|
-
|
|
96
|
+
if not events:
|
|
97
|
+
refresh()
|
|
65
98
|
continue
|
|
99
|
+
changed: set[str] = set()
|
|
100
|
+
for ev in events:
|
|
101
|
+
if ev.ident == dir_fd:
|
|
102
|
+
refresh()
|
|
103
|
+
elif ev.ident in fd_to_path:
|
|
104
|
+
changed.add(os.path.basename(fd_to_path[ev.ident]).replace(".md", ""))
|
|
105
|
+
if changed:
|
|
106
|
+
with self._lock:
|
|
107
|
+
self._queue.extend(changed)
|
|
108
|
+
finally:
|
|
109
|
+
for fd in file_fds.values():
|
|
66
110
|
try:
|
|
67
|
-
|
|
68
|
-
kq.control(
|
|
69
|
-
[
|
|
70
|
-
sel.kevent(
|
|
71
|
-
fd,
|
|
72
|
-
filter=sel.KQ_FILTER_VNODE,
|
|
73
|
-
flags=sel.KQ_EV_ADD | sel.KQ_EV_CLEAR,
|
|
74
|
-
fflags=sel.KQ_NOTE_WRITE | sel.KQ_NOTE_EXTEND,
|
|
75
|
-
)
|
|
76
|
-
],
|
|
77
|
-
0,
|
|
78
|
-
)
|
|
79
|
-
file_fds[path] = fd
|
|
111
|
+
os.close(fd)
|
|
80
112
|
except OSError:
|
|
81
113
|
pass
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
except (InterruptedError, OSError):
|
|
89
|
-
if self._stop.is_set():
|
|
90
|
-
break
|
|
91
|
-
continue
|
|
92
|
-
if not events:
|
|
93
|
-
refresh()
|
|
94
|
-
continue
|
|
95
|
-
changed: set[str] = set()
|
|
96
|
-
for ev in events:
|
|
97
|
-
if ev.ident == dir_fd:
|
|
98
|
-
refresh()
|
|
99
|
-
elif ev.ident in fd_to_path:
|
|
100
|
-
changed.add(os.path.basename(fd_to_path[ev.ident]).replace(".md", ""))
|
|
101
|
-
if changed:
|
|
102
|
-
with self._lock:
|
|
103
|
-
self._queue.extend(changed)
|
|
104
|
-
|
|
105
|
-
for fd in file_fds.values():
|
|
106
|
-
try:
|
|
107
|
-
os.close(fd)
|
|
108
|
-
except OSError:
|
|
109
|
-
pass
|
|
110
|
-
try:
|
|
111
|
-
os.close(dir_fd)
|
|
112
|
-
except OSError:
|
|
113
|
-
pass
|
|
114
|
-
kq.close()
|
|
114
|
+
if dir_fd >= 0:
|
|
115
|
+
try:
|
|
116
|
+
os.close(dir_fd)
|
|
117
|
+
except OSError:
|
|
118
|
+
pass
|
|
119
|
+
kq.close()
|
|
115
120
|
|
|
116
121
|
def stop(self) -> None:
|
|
117
122
|
self._stop.set()
|
|
@@ -124,4 +129,4 @@ class FileWatcher(Concern):
|
|
|
124
129
|
self._queue.clear()
|
|
125
130
|
for name in names:
|
|
126
131
|
if not is_suspended(name, self.cfg.suspended_file):
|
|
127
|
-
self.
|
|
132
|
+
self.nudge.check_session(name, now)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Health monitoring: session keepalive, periodic health checks, resource monitoring."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from lib.common import date_to_epoch, is_suspended
|
|
6
|
+
from lib.resources import check_battery, check_cpu, check_memory
|
|
7
|
+
|
|
8
|
+
from .base import Concern
|
|
9
|
+
from .config import DispatcherConfig
|
|
10
|
+
from .coral import CoralManager, CoralPoker
|
|
11
|
+
from .helpers import (
|
|
12
|
+
board_send,
|
|
13
|
+
db,
|
|
14
|
+
get_dev_sessions,
|
|
15
|
+
is_claude_running,
|
|
16
|
+
log,
|
|
17
|
+
tmux_ok,
|
|
18
|
+
tmux_send,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SessionKeepAlive(Concern):
|
|
23
|
+
interval = 5
|
|
24
|
+
|
|
25
|
+
def __init__(self, cfg: DispatcherConfig) -> None:
|
|
26
|
+
super().__init__()
|
|
27
|
+
self.cfg = cfg
|
|
28
|
+
|
|
29
|
+
def tick(self, now: int) -> None:
|
|
30
|
+
for name in get_dev_sessions(self.cfg):
|
|
31
|
+
if is_suspended(name, self.cfg.suspended_file):
|
|
32
|
+
continue
|
|
33
|
+
sess = f"{self.cfg.prefix}-{name}"
|
|
34
|
+
if tmux_ok("has-session", "-t", sess) and not is_claude_running(sess):
|
|
35
|
+
log(f"{name}: 同学已退出, NOT restarting (idle policy)")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class HealthChecker(Concern):
|
|
39
|
+
INITIAL = 600
|
|
40
|
+
MAX = 3600
|
|
41
|
+
IDLE_THRESHOLD = 1800
|
|
42
|
+
|
|
43
|
+
def __init__(self, cfg: DispatcherConfig, poker: CoralPoker, coral: CoralManager) -> None:
|
|
44
|
+
super().__init__()
|
|
45
|
+
self.cfg = cfg
|
|
46
|
+
self.interval = self.INITIAL
|
|
47
|
+
self.poker = poker
|
|
48
|
+
self.coral = coral
|
|
49
|
+
self.last_idle_alert: int = 0
|
|
50
|
+
|
|
51
|
+
def tick(self, now: int) -> None:
|
|
52
|
+
parts = []
|
|
53
|
+
for name in get_dev_sessions(self.cfg):
|
|
54
|
+
on = "on" if tmux_ok("has-session", "-t", f"{self.cfg.prefix}-{name}") else "off"
|
|
55
|
+
parts.append(f"{name}:{on}")
|
|
56
|
+
status = " ".join(parts)
|
|
57
|
+
|
|
58
|
+
log(f"Health check (interval:{self.interval}s): {status}")
|
|
59
|
+
self.poker.poke(f"[Dispatcher] 健康巡检 {time.strftime('%H:%M:%S')}: {status}")
|
|
60
|
+
self.interval = min(self.interval * 2, self.MAX)
|
|
61
|
+
self._check_team_idle(now)
|
|
62
|
+
|
|
63
|
+
def _check_team_idle(self, now: int) -> None:
|
|
64
|
+
if not self.cfg.board_db.exists():
|
|
65
|
+
return
|
|
66
|
+
idle_list: list[str] = []
|
|
67
|
+
total = 0
|
|
68
|
+
d = db(self.cfg)
|
|
69
|
+
|
|
70
|
+
for name in get_dev_sessions(self.cfg):
|
|
71
|
+
if is_suspended(name, self.cfg.suspended_file):
|
|
72
|
+
continue
|
|
73
|
+
sess = f"{self.cfg.prefix}-{name}"
|
|
74
|
+
if not tmux_ok("has-session", "-t", sess) or not is_claude_running(sess):
|
|
75
|
+
continue
|
|
76
|
+
if self.coral.in_grace_period(name, now):
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
total += 1
|
|
80
|
+
try:
|
|
81
|
+
updated = d.scalar("SELECT updated_at FROM sessions WHERE name=?", (name,)) or ""
|
|
82
|
+
except Exception:
|
|
83
|
+
continue
|
|
84
|
+
if updated:
|
|
85
|
+
age = now - date_to_epoch(updated)
|
|
86
|
+
if age > self.IDLE_THRESHOLD:
|
|
87
|
+
idle_list.append(f"{name}({age}s)")
|
|
88
|
+
|
|
89
|
+
if idle_list and len(idle_list) == total and (now - self.last_idle_alert) > 3600:
|
|
90
|
+
log(f"All sessions idle: {' '.join(idle_list)}")
|
|
91
|
+
board_send(
|
|
92
|
+
self.cfg,
|
|
93
|
+
"lead",
|
|
94
|
+
f"[Dispatcher] 全员空闲超过 {self.IDLE_THRESHOLD // 60} 分钟:{' '.join(idle_list)}。可能需要分配工作。",
|
|
95
|
+
)
|
|
96
|
+
self.last_idle_alert = now
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ResourceMonitor(Concern):
|
|
100
|
+
interval = 60
|
|
101
|
+
|
|
102
|
+
def __init__(self, cfg: DispatcherConfig) -> None:
|
|
103
|
+
super().__init__()
|
|
104
|
+
self.cfg = cfg
|
|
105
|
+
self.last_state = ""
|
|
106
|
+
|
|
107
|
+
def tick(self, now: int) -> None:
|
|
108
|
+
batt = check_battery()
|
|
109
|
+
mem = check_memory()
|
|
110
|
+
cpu = check_cpu()
|
|
111
|
+
|
|
112
|
+
state = f"{batt.status}|{batt.pct}|{mem.status}|{mem.pressure}|{cpu.status}|{cpu.usage}"
|
|
113
|
+
if state == self.last_state:
|
|
114
|
+
return
|
|
115
|
+
self.last_state = state
|
|
116
|
+
|
|
117
|
+
if batt.status == "CRITICAL":
|
|
118
|
+
log(f"RESOURCE: Battery CRITICAL ({batt.pct}%)")
|
|
119
|
+
board_send(self.cfg, "All", f"[Resource] 电池严重不足 ({batt.pct}%),暂停非关键 session。")
|
|
120
|
+
for name in get_dev_sessions(self.cfg):
|
|
121
|
+
sess = f"{self.cfg.prefix}-{name}"
|
|
122
|
+
if is_claude_running(sess):
|
|
123
|
+
tmux_send(sess, "[系统] 电池严重不足,请立即保存状态。")
|
|
124
|
+
elif batt.status == "LOW":
|
|
125
|
+
log(f"RESOURCE: Battery LOW ({batt.pct}%)")
|
|
126
|
+
board_send(self.cfg, "All", f"[Resource] 电池低 ({batt.pct}%),建议减少活跃 session 到 2-3 个。")
|
|
127
|
+
|
|
128
|
+
if mem.status == "CRITICAL":
|
|
129
|
+
log("RESOURCE: Memory pressure CRITICAL")
|
|
130
|
+
board_send(self.cfg, "All", "[Resource] 内存压力严重!建议重启最大的 session 释放内存。")
|
|
131
|
+
elif mem.status == "WARNING":
|
|
132
|
+
log("RESOURCE: Memory pressure WARNING")
|
|
133
|
+
board_send(self.cfg, "All", "[Resource] 内存压力升高,关注中。")
|
|
134
|
+
|
|
135
|
+
if cpu.status == "SATURATED":
|
|
136
|
+
log(f"RESOURCE: CPU saturated ({cpu.usage}%)")
|
package/lib/concerns/helpers.py
CHANGED
|
@@ -70,11 +70,7 @@ def get_dev_sessions(cfg: DispatcherConfig) -> list[str]:
|
|
|
70
70
|
return []
|
|
71
71
|
pfx = f"{cfg.prefix}-"
|
|
72
72
|
protected = {"dispatcher", "lead"}
|
|
73
|
-
return [
|
|
74
|
-
line[len(pfx) :]
|
|
75
|
-
for line in raw.splitlines()
|
|
76
|
-
if line.startswith(pfx) and line[len(pfx) :] not in protected
|
|
77
|
-
]
|
|
73
|
+
return [line[len(pfx) :] for line in raw.splitlines() if line.startswith(pfx) and line[len(pfx) :] not in protected]
|
|
78
74
|
|
|
79
75
|
|
|
80
76
|
def pane_md5(sess: str) -> str:
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Idle detection, nudging, and killing for dispatcher sessions."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from lib.common import is_suspended
|
|
6
|
+
|
|
7
|
+
from .base import Concern
|
|
8
|
+
from .config import DispatcherConfig
|
|
9
|
+
from .coral import CoralManager
|
|
10
|
+
from .helpers import (
|
|
11
|
+
board_send,
|
|
12
|
+
get_dev_sessions,
|
|
13
|
+
has_tool_process,
|
|
14
|
+
is_claude_running,
|
|
15
|
+
log,
|
|
16
|
+
pane_md5,
|
|
17
|
+
tmux,
|
|
18
|
+
tmux_ok,
|
|
19
|
+
tmux_send,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class IdleDetector(Concern):
|
|
24
|
+
interval = 5
|
|
25
|
+
|
|
26
|
+
def __init__(self, cfg: DispatcherConfig) -> None:
|
|
27
|
+
super().__init__()
|
|
28
|
+
self.cfg = cfg
|
|
29
|
+
self.cache: dict[str, str] = {} # sess -> "idle" | "busy"
|
|
30
|
+
self._prev_snap: dict[str, str] = {} # sess -> md5 from previous tick
|
|
31
|
+
|
|
32
|
+
def is_idle(self, sess: str) -> bool:
|
|
33
|
+
return self.cache.get(sess) == "idle"
|
|
34
|
+
|
|
35
|
+
def tick(self, now: int) -> None:
|
|
36
|
+
self.cache.clear()
|
|
37
|
+
raw = tmux("list-sessions", "-F", "#{session_name}")
|
|
38
|
+
if not raw:
|
|
39
|
+
self._prev_snap.clear()
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
all_sessions = [s for s in raw.splitlines() if s.startswith(f"{self.cfg.prefix}-")]
|
|
43
|
+
need_snapshot: list[str] = []
|
|
44
|
+
|
|
45
|
+
for sess in all_sessions:
|
|
46
|
+
pane = tmux("capture-pane", "-t", sess, "-p") or ""
|
|
47
|
+
prompts = [l for l in pane.splitlines() if l.startswith("❯")]
|
|
48
|
+
if prompts and re.match(r"^❯ .{3,}", prompts[-1]):
|
|
49
|
+
self.cache[sess] = "busy"
|
|
50
|
+
continue
|
|
51
|
+
if has_tool_process(sess):
|
|
52
|
+
self.cache[sess] = "busy"
|
|
53
|
+
continue
|
|
54
|
+
need_snapshot.append(sess)
|
|
55
|
+
|
|
56
|
+
current_snap: dict[str, str] = {}
|
|
57
|
+
for sess in need_snapshot:
|
|
58
|
+
md5 = pane_md5(sess)
|
|
59
|
+
current_snap[sess] = md5
|
|
60
|
+
if sess in self._prev_snap and self._prev_snap[sess] == md5:
|
|
61
|
+
self.cache[sess] = "idle"
|
|
62
|
+
else:
|
|
63
|
+
self.cache[sess] = "busy"
|
|
64
|
+
|
|
65
|
+
self._prev_snap = {s: pane_md5(s) for s in all_sessions if s not in self.cache or self.cache[s] != "busy"}
|
|
66
|
+
self._prev_snap.update(current_snap)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class IdleKiller(Concern):
|
|
70
|
+
interval = 5
|
|
71
|
+
THRESHOLD = 1800
|
|
72
|
+
|
|
73
|
+
def __init__(self, cfg: DispatcherConfig, idle: IdleDetector, coral: CoralManager) -> None:
|
|
74
|
+
super().__init__()
|
|
75
|
+
self.cfg = cfg
|
|
76
|
+
self.idle = idle
|
|
77
|
+
self.coral = coral
|
|
78
|
+
self.idle_since: dict[str, int] = {}
|
|
79
|
+
|
|
80
|
+
def tick(self, now: int) -> None:
|
|
81
|
+
for name in get_dev_sessions(self.cfg):
|
|
82
|
+
sess = f"{self.cfg.prefix}-{name}"
|
|
83
|
+
if not tmux_ok("has-session", "-t", sess) or not is_claude_running(sess):
|
|
84
|
+
self.idle_since.pop(name, None)
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
if name not in self.coral.boot_times:
|
|
88
|
+
self.coral.record_boot(name)
|
|
89
|
+
if self.coral.in_grace_period(name, now):
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
if self.idle.is_idle(sess):
|
|
93
|
+
since = self.idle_since.setdefault(name, now)
|
|
94
|
+
if (now - since) >= self.THRESHOLD:
|
|
95
|
+
log(f"{name}: idle {now - since}s (>30min), killing session")
|
|
96
|
+
tmux("kill-session", "-t", sess)
|
|
97
|
+
self.idle_since.pop(name, None)
|
|
98
|
+
board_send(self.cfg, "All", f"[Dispatcher] {name} 闲置超过 30 分钟,已终止。")
|
|
99
|
+
else:
|
|
100
|
+
self.idle_since.pop(name, None)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class IdleNudger(Concern):
|
|
104
|
+
interval = 5
|
|
105
|
+
COOLDOWN = 300
|
|
106
|
+
|
|
107
|
+
def __init__(self, cfg: DispatcherConfig, idle: IdleDetector) -> None:
|
|
108
|
+
super().__init__()
|
|
109
|
+
self.cfg = cfg
|
|
110
|
+
self.idle = idle
|
|
111
|
+
self.last_nudge: dict[str, int] = {}
|
|
112
|
+
|
|
113
|
+
def tick(self, now: int) -> None:
|
|
114
|
+
for name in get_dev_sessions(self.cfg):
|
|
115
|
+
if is_suspended(name, self.cfg.suspended_file):
|
|
116
|
+
continue
|
|
117
|
+
sess = f"{self.cfg.prefix}-{name}"
|
|
118
|
+
if not tmux_ok("has-session", "-t", sess) or not is_claude_running(sess):
|
|
119
|
+
continue
|
|
120
|
+
if not self.idle.is_idle(sess):
|
|
121
|
+
continue
|
|
122
|
+
if (now - self.last_nudge.get(name, 0)) < self.COOLDOWN:
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
log(f"{name}: idle, nudging autonomous loop")
|
|
126
|
+
tmux_send(
|
|
127
|
+
sess,
|
|
128
|
+
f"继续工作。检查你的 OKR ({self.cfg.okr_dir}/{name}.md),推进你的活跃 KR。自己决定优先级。",
|
|
129
|
+
)
|
|
130
|
+
self.last_nudge[name] = now
|