claude-nb 0.3.0 → 0.4.0
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/README.md +70 -33
- package/VERSION +1 -1
- package/bin/board +10 -0
- package/bin/cnb +109 -48
- package/bin/init +5 -39
- package/bin/swarm +41 -860
- package/lib/board_db.py +15 -137
- package/lib/board_lock.py +0 -1
- package/lib/board_mailbox.py +15 -7
- package/lib/board_maintenance.py +26 -27
- package/lib/board_msg.py +18 -69
- package/lib/board_task.py +35 -38
- package/lib/board_tui.py +112 -0
- package/lib/board_view.py +12 -24
- package/lib/concerns/__init__.py +4 -11
- package/lib/concerns/{coral_manager.py → coral.py} +53 -3
- package/lib/concerns/file_watcher.py +1 -1
- package/lib/concerns/health.py +136 -0
- package/lib/concerns/helpers.py +1 -5
- package/lib/concerns/idle.py +130 -0
- package/lib/concerns/notifications.py +90 -0
- package/lib/migrate.py +1 -0
- package/lib/monitor.py +1 -18
- package/lib/swarm.py +456 -0
- package/lib/swarm_backend.py +266 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/registry/pubkeys.json +2 -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
package/lib/board_tui.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""board_tui — tmux-native team UI with mouse support.
|
|
2
|
+
|
|
3
|
+
Opens a single terminal window with tmux. Each worker is a window (tab).
|
|
4
|
+
Mouse click on the tab bar to switch. That's it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from lib.board_db import BoardDB
|
|
12
|
+
|
|
13
|
+
UI_SESSION = "cnb-ui"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _tmux(*args: str) -> int:
|
|
17
|
+
return subprocess.run(["tmux", *args], capture_output=True, text=True, timeout=5).returncode
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _tmux_out(*args: str) -> str:
|
|
21
|
+
r = subprocess.run(["tmux", *args], capture_output=True, text=True, timeout=5)
|
|
22
|
+
return r.stdout.strip() if r.returncode == 0 else ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _session_exists(name: str) -> bool:
|
|
26
|
+
return _tmux("has-session", "-t", name) == 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def cmd_tui(db: BoardDB) -> None:
|
|
30
|
+
"""Open team UI: one tmux window per worker, mouse-clickable tabs."""
|
|
31
|
+
if not db.env:
|
|
32
|
+
print("ERROR: 需要完整环境才能启动 TUI")
|
|
33
|
+
raise SystemExit(1)
|
|
34
|
+
|
|
35
|
+
prefix = db.env.prefix
|
|
36
|
+
workers = [r[0] for r in db.query("SELECT name FROM sessions WHERE name != 'all' ORDER BY name")]
|
|
37
|
+
|
|
38
|
+
if not workers:
|
|
39
|
+
print("ERROR: 没有注册的 session")
|
|
40
|
+
raise SystemExit(1)
|
|
41
|
+
|
|
42
|
+
online = [w for w in workers if _session_exists(f"{prefix}-{w}")]
|
|
43
|
+
if not online:
|
|
44
|
+
print("ERROR: 没有在线的 worker,先运行 cnb swarm start")
|
|
45
|
+
raise SystemExit(1)
|
|
46
|
+
|
|
47
|
+
# Kill old UI session if exists
|
|
48
|
+
if _session_exists(UI_SESSION):
|
|
49
|
+
_tmux("kill-session", "-t", UI_SESSION)
|
|
50
|
+
|
|
51
|
+
# Create session grouped with first worker
|
|
52
|
+
_tmux("new-session", "-d", "-s", UI_SESSION, "-t", f"{prefix}-{online[0]}")
|
|
53
|
+
|
|
54
|
+
# Link remaining workers as windows
|
|
55
|
+
for name in online[1:]:
|
|
56
|
+
_tmux("link-window", "-s", f"{prefix}-{name}", "-t", UI_SESSION, "-a")
|
|
57
|
+
|
|
58
|
+
# Rename windows
|
|
59
|
+
win_indices = _tmux_out("list-windows", "-t", UI_SESSION, "-F", "#{window_index}").split("\n")
|
|
60
|
+
for i, name in enumerate(online):
|
|
61
|
+
if i < len(win_indices):
|
|
62
|
+
_tmux("rename-window", "-t", f"{UI_SESSION}:{win_indices[i]}", name)
|
|
63
|
+
|
|
64
|
+
# Mouse + clean visual config
|
|
65
|
+
opts = {
|
|
66
|
+
"mouse": "on",
|
|
67
|
+
"status": "on",
|
|
68
|
+
"status-position": "top",
|
|
69
|
+
"status-style": "bg=default,fg=white",
|
|
70
|
+
"status-left": " cnb ",
|
|
71
|
+
"status-left-style": "bold",
|
|
72
|
+
"status-left-length": "6",
|
|
73
|
+
"status-right": f" {len(online)} online ",
|
|
74
|
+
"status-right-style": "dim",
|
|
75
|
+
"window-status-format": " #W ",
|
|
76
|
+
"window-status-current-format": " #W ",
|
|
77
|
+
"window-status-style": "dim",
|
|
78
|
+
"window-status-current-style": "bold,underscore",
|
|
79
|
+
"window-status-separator": "",
|
|
80
|
+
}
|
|
81
|
+
for k, v in opts.items():
|
|
82
|
+
_tmux("set-option", "-t", UI_SESSION, k, v)
|
|
83
|
+
|
|
84
|
+
# Open in new terminal window
|
|
85
|
+
_open_terminal()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _open_terminal() -> None:
|
|
89
|
+
"""Open a new terminal window attached to cnb-ui."""
|
|
90
|
+
attach_cmd = f"tmux attach -t {UI_SESSION}"
|
|
91
|
+
|
|
92
|
+
if sys.platform == "darwin":
|
|
93
|
+
if os.path.isdir("/Applications/iTerm.app"):
|
|
94
|
+
script = (
|
|
95
|
+
'tell application "iTerm"\n'
|
|
96
|
+
" activate\n"
|
|
97
|
+
f' create window with default profile command "{attach_cmd}"\n'
|
|
98
|
+
"end tell"
|
|
99
|
+
)
|
|
100
|
+
else:
|
|
101
|
+
script = f'tell application "Terminal"\n do script "{attach_cmd}"\n activate\nend tell'
|
|
102
|
+
subprocess.run(["osascript", "-e", script], capture_output=True)
|
|
103
|
+
print("OK 已打开 — 点击顶部 tab 切换同学")
|
|
104
|
+
else:
|
|
105
|
+
import shutil
|
|
106
|
+
|
|
107
|
+
for term in ("gnome-terminal", "xterm", "konsole", "alacritty"):
|
|
108
|
+
if shutil.which(term):
|
|
109
|
+
subprocess.Popen([term, "--", "bash", "-c", attach_cmd])
|
|
110
|
+
print("OK 已打开 — 点击顶部 tab 切换同学")
|
|
111
|
+
return
|
|
112
|
+
print(f"运行: {attach_cmd}")
|
package/lib/board_view.py
CHANGED
|
@@ -15,6 +15,7 @@ def _git(project_root: Path, *args: str) -> str:
|
|
|
15
15
|
["git", "-C", str(project_root), *args],
|
|
16
16
|
capture_output=True,
|
|
17
17
|
text=True,
|
|
18
|
+
timeout=5,
|
|
18
19
|
)
|
|
19
20
|
return r.stdout
|
|
20
21
|
|
|
@@ -40,16 +41,6 @@ def _tmux_pane_command(name: str) -> str:
|
|
|
40
41
|
return ""
|
|
41
42
|
|
|
42
43
|
|
|
43
|
-
def _pgrep(pattern: str) -> str | None:
|
|
44
|
-
try:
|
|
45
|
-
r = subprocess.run(["pgrep", "-f", pattern], capture_output=True, text=True, timeout=3)
|
|
46
|
-
if r.returncode == 0:
|
|
47
|
-
return r.stdout.strip().split("\n")[0]
|
|
48
|
-
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
49
|
-
pass
|
|
50
|
-
return None
|
|
51
|
-
|
|
52
|
-
|
|
53
44
|
def cmd_overview(db: BoardDB) -> None:
|
|
54
45
|
"""Default view when running cnb with no args."""
|
|
55
46
|
prefix = db.env.prefix
|
|
@@ -80,10 +71,7 @@ def cmd_overview(db: BoardDB) -> None:
|
|
|
80
71
|
print(line)
|
|
81
72
|
|
|
82
73
|
# ── recent messages ──
|
|
83
|
-
rows = db.query(
|
|
84
|
-
"SELECT ts, sender, recipient, substr(body, 1, 80) "
|
|
85
|
-
"FROM messages ORDER BY id DESC LIMIT 5"
|
|
86
|
-
)
|
|
74
|
+
rows = db.query("SELECT ts, sender, recipient, substr(body, 1, 80) FROM messages ORDER BY id DESC LIMIT 5")
|
|
87
75
|
if rows:
|
|
88
76
|
print()
|
|
89
77
|
print("Recent:")
|
|
@@ -91,20 +79,20 @@ def cmd_overview(db: BoardDB) -> None:
|
|
|
91
79
|
print(f" [{ts_val}] {sender} → {recipient}: {body}")
|
|
92
80
|
|
|
93
81
|
# ── open proposals ──
|
|
94
|
-
proposals = db.query(
|
|
95
|
-
"SELECT number || '-' || slug FROM proposals WHERE status='OPEN'"
|
|
96
|
-
)
|
|
82
|
+
proposals = db.query("SELECT number || '-' || slug FROM proposals WHERE status='OPEN'")
|
|
97
83
|
if proposals:
|
|
98
84
|
print()
|
|
99
85
|
print(f"Open proposals: {len(proposals)}")
|
|
100
86
|
|
|
101
87
|
# ── dispatcher ──
|
|
102
|
-
|
|
88
|
+
dispatcher_sess = f"{prefix}-dispatcher"
|
|
103
89
|
print()
|
|
104
|
-
if
|
|
105
|
-
print(f" dispatcher: running (
|
|
90
|
+
if _tmux_has_session(dispatcher_sess):
|
|
91
|
+
print(f" dispatcher: running ({dispatcher_sess})")
|
|
106
92
|
else:
|
|
107
|
-
running = any(
|
|
93
|
+
running = any(
|
|
94
|
+
_tmux_has_session(f"{prefix}-{n}") for (n,) in db.query("SELECT name FROM sessions WHERE name != 'all'")
|
|
95
|
+
)
|
|
108
96
|
if running:
|
|
109
97
|
print(" dispatcher: NOT RUNNING — run: cnb dispatcher")
|
|
110
98
|
else:
|
|
@@ -258,9 +246,9 @@ def cmd_dashboard(db: BoardDB) -> None:
|
|
|
258
246
|
print(f" {name:<7s} {status:<8s}{inbox_str}")
|
|
259
247
|
print(f" {task}")
|
|
260
248
|
print()
|
|
261
|
-
|
|
262
|
-
if
|
|
263
|
-
print(f" dispatcher: running (
|
|
249
|
+
dispatcher_sess = f"{prefix}-dispatcher"
|
|
250
|
+
if _tmux_has_session(dispatcher_sess):
|
|
251
|
+
print(f" dispatcher: running ({dispatcher_sess})")
|
|
264
252
|
else:
|
|
265
253
|
print(" dispatcher: NOT RUNNING")
|
|
266
254
|
|
package/lib/concerns/__init__.py
CHANGED
|
@@ -5,20 +5,13 @@ Each concern is a self-contained module with its own check interval.
|
|
|
5
5
|
|
|
6
6
|
from .adaptive_throttle import AdaptiveThrottle
|
|
7
7
|
from .base import Concern
|
|
8
|
-
from .bug_sla_checker import BugSLAChecker
|
|
9
8
|
from .config import DispatcherConfig
|
|
10
|
-
from .
|
|
11
|
-
from .coral_poker import CoralPoker
|
|
9
|
+
from .coral import CoralManager, CoralPoker
|
|
12
10
|
from .file_watcher import FileWatcher
|
|
13
|
-
from .
|
|
11
|
+
from .health import HealthChecker, ResourceMonitor, SessionKeepAlive
|
|
14
12
|
from .helpers import log, tmux_ok, warn
|
|
15
|
-
from .
|
|
16
|
-
from .
|
|
17
|
-
from .idle_nudger import IdleNudger
|
|
18
|
-
from .inbox_nudger import InboxNudger
|
|
19
|
-
from .resource_monitor import ResourceMonitor
|
|
20
|
-
from .session_keepalive import SessionKeepAlive
|
|
21
|
-
from .time_announcer import TimeAnnouncer
|
|
13
|
+
from .idle import IdleDetector, IdleKiller, IdleNudger
|
|
14
|
+
from .notifications import BugSLAChecker, InboxNudger, TimeAnnouncer
|
|
22
15
|
|
|
23
16
|
__all__ = [
|
|
24
17
|
"AdaptiveThrottle",
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Coral: dispatcher session lifecycle management and heartbeat."""
|
|
2
2
|
|
|
3
|
+
import re
|
|
3
4
|
import time
|
|
4
5
|
|
|
5
6
|
from lib.common import is_suspended
|
|
6
7
|
|
|
7
8
|
from .base import Concern
|
|
8
9
|
from .config import DispatcherConfig
|
|
9
|
-
from .helpers import is_claude_running, log, tmux, tmux_send
|
|
10
|
+
from .helpers import db, is_claude_running, log, pane_md5, tmux, tmux_ok, tmux_send
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
class CoralManager(Concern):
|
|
@@ -49,8 +50,8 @@ class CoralManager(Concern):
|
|
|
49
50
|
self._wait_until_ready()
|
|
50
51
|
|
|
51
52
|
def _wait_until_ready(self) -> None:
|
|
52
|
-
"""Poll until the Claude process is running, or timeout after BOOT_WAIT."""
|
|
53
53
|
import time as _time
|
|
54
|
+
|
|
54
55
|
deadline = _time.monotonic() + self.BOOT_WAIT
|
|
55
56
|
while _time.monotonic() < deadline:
|
|
56
57
|
if is_claude_running(self.cfg.coral_sess):
|
|
@@ -59,3 +60,52 @@ class CoralManager(Concern):
|
|
|
59
60
|
return
|
|
60
61
|
_time.sleep(1)
|
|
61
62
|
log(f"WARNING: Coral not ready after {self.BOOT_WAIT}s, will retry next tick")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CoralPoker(Concern):
|
|
66
|
+
interval = 120
|
|
67
|
+
|
|
68
|
+
def __init__(self, cfg: DispatcherConfig) -> None:
|
|
69
|
+
super().__init__()
|
|
70
|
+
self.cfg = cfg
|
|
71
|
+
self.last_poke: int = int(time.time())
|
|
72
|
+
|
|
73
|
+
def poke(self, msg: str) -> bool:
|
|
74
|
+
if not tmux_ok("has-session", "-t", self.cfg.coral_sess) or not is_claude_running(self.cfg.coral_sess):
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
content = tmux("capture-pane", "-t", self.cfg.coral_sess, "-p") or ""
|
|
78
|
+
prompts = [l for l in content.splitlines() if l.startswith("❯")]
|
|
79
|
+
if prompts and re.match(r"^❯ .{3,}", prompts[-1]):
|
|
80
|
+
log("Coral: skip (typing)")
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
h1 = pane_md5(self.cfg.coral_sess)
|
|
84
|
+
time.sleep(1)
|
|
85
|
+
if h1 != pane_md5(self.cfg.coral_sess):
|
|
86
|
+
log("Coral: skip (busy)")
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
log("Coral: poking")
|
|
90
|
+
tmux_send(self.cfg.coral_sess, msg)
|
|
91
|
+
self.last_poke = int(time.time())
|
|
92
|
+
return True
|
|
93
|
+
|
|
94
|
+
def tick(self, now: int) -> None:
|
|
95
|
+
unread = 0
|
|
96
|
+
if self.cfg.board_db.exists():
|
|
97
|
+
try:
|
|
98
|
+
unread = (
|
|
99
|
+
db(self.cfg).scalar(
|
|
100
|
+
"SELECT COUNT(*) FROM inbox WHERE session=? AND read=0",
|
|
101
|
+
(self.cfg.dispatcher_session,),
|
|
102
|
+
)
|
|
103
|
+
or 0
|
|
104
|
+
)
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
if unread > 0:
|
|
109
|
+
self.poke(f"[Dispatcher] 你有 {unread} 条未读消息")
|
|
110
|
+
elif (now - self.last_poke) >= self.interval:
|
|
111
|
+
self.poke(f"[Dispatcher] heartbeat {time.strftime('%H:%M:%S')}")
|
|
@@ -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}: agent exited, 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
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Notifications: inbox nudging, time announcements, bug SLA checks."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
from .base import Concern
|
|
6
|
+
from .config import DispatcherConfig
|
|
7
|
+
from .coral import CoralPoker
|
|
8
|
+
from .helpers import board_send, db, get_dev_sessions, is_claude_running, log, tmux_ok, tmux_send
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InboxNudger(Concern):
|
|
12
|
+
interval = 5
|
|
13
|
+
|
|
14
|
+
def __init__(self, cfg: DispatcherConfig) -> None:
|
|
15
|
+
super().__init__()
|
|
16
|
+
self.cfg = cfg
|
|
17
|
+
|
|
18
|
+
def nudge_if_unread(self, name: str) -> None:
|
|
19
|
+
if not self.cfg.board_db.exists():
|
|
20
|
+
return
|
|
21
|
+
try:
|
|
22
|
+
unread = db(self.cfg).scalar("SELECT COUNT(*) FROM inbox WHERE session=? AND read=0", (name,)) or 0
|
|
23
|
+
except Exception:
|
|
24
|
+
return
|
|
25
|
+
if unread <= 0:
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
sess = f"{self.cfg.prefix}-{name}"
|
|
29
|
+
if not tmux_ok("has-session", "-t", sess) or not is_claude_running(sess):
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
log(f"INBOX: {name} has {unread} unread -> nudging")
|
|
33
|
+
tmux_send(sess, f"./board --as {name} inbox")
|
|
34
|
+
|
|
35
|
+
def tick(self, now: int) -> None:
|
|
36
|
+
for name in get_dev_sessions(self.cfg):
|
|
37
|
+
self.nudge_if_unread(name)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TimeAnnouncer(Concern):
|
|
41
|
+
interval = 30
|
|
42
|
+
|
|
43
|
+
def __init__(self, cfg: DispatcherConfig) -> None:
|
|
44
|
+
super().__init__()
|
|
45
|
+
self.cfg = cfg
|
|
46
|
+
self.last_hour = -1
|
|
47
|
+
|
|
48
|
+
def tick(self, now: int) -> None:
|
|
49
|
+
from datetime import datetime as dt
|
|
50
|
+
|
|
51
|
+
d = dt.now()
|
|
52
|
+
if d.minute != 0 or d.hour == self.last_hour:
|
|
53
|
+
return
|
|
54
|
+
self.last_hour = d.hour
|
|
55
|
+
ts = d.strftime("%Y-%m-%d %H:%M")
|
|
56
|
+
|
|
57
|
+
if d.hour == 9:
|
|
58
|
+
board_send(
|
|
59
|
+
self.cfg,
|
|
60
|
+
"All",
|
|
61
|
+
f"[Clock] {ts} ({d.strftime('%A')}) — 新一天。检查 KR 列表,确认优先级。",
|
|
62
|
+
)
|
|
63
|
+
log("Daily announcement sent")
|
|
64
|
+
else:
|
|
65
|
+
board_send(self.cfg, "All", f"[Clock] 现在是 {ts}。")
|
|
66
|
+
log(f"Hourly announcement: {d.hour}:00")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class BugSLAChecker(Concern):
|
|
70
|
+
interval = 600
|
|
71
|
+
|
|
72
|
+
def __init__(self, cfg: DispatcherConfig, poker: CoralPoker) -> None:
|
|
73
|
+
super().__init__()
|
|
74
|
+
self.cfg = cfg
|
|
75
|
+
self.poker = poker
|
|
76
|
+
|
|
77
|
+
def tick(self, now: int) -> None:
|
|
78
|
+
try:
|
|
79
|
+
r = subprocess.run(
|
|
80
|
+
[self.cfg.board_sh, "bug", "overdue"],
|
|
81
|
+
capture_output=True,
|
|
82
|
+
text=True,
|
|
83
|
+
timeout=10,
|
|
84
|
+
)
|
|
85
|
+
overdue = r.stdout.strip()
|
|
86
|
+
except Exception:
|
|
87
|
+
return
|
|
88
|
+
if overdue and "No overdue" not in overdue:
|
|
89
|
+
log(f"Bug SLA alert: {overdue}")
|
|
90
|
+
self.poker.poke(f"[Dispatcher] Bug SLA 超时: {overdue}")
|
package/lib/migrate.py
CHANGED
|
@@ -83,6 +83,7 @@ def run_migrations(db_path: Path, claudes_home: Path) -> int:
|
|
|
83
83
|
# CLI
|
|
84
84
|
# ---------------------------------------------------------------------------
|
|
85
85
|
|
|
86
|
+
|
|
86
87
|
def main() -> None:
|
|
87
88
|
"""Run pending migrations. Called from init and doctor."""
|
|
88
89
|
# Resolve CLAUDES_HOME relative to this file
|