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
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""swarm_backend — terminal multiplexer backends (tmux, screen)."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SessionBackend(ABC):
|
|
13
|
+
"""Abstract base for terminal multiplexer backends."""
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def is_running(self, prefix: str, name: str) -> bool: ...
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def start_session(
|
|
20
|
+
self,
|
|
21
|
+
prefix: str,
|
|
22
|
+
name: str,
|
|
23
|
+
project_root: Path,
|
|
24
|
+
agent_cmd: str,
|
|
25
|
+
) -> str: ...
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def stop_session(self, prefix: str, name: str, save_cmd: str) -> None: ...
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def status_line(self, prefix: str, name: str, agent: str) -> str: ...
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def attach(self, prefix: str, name: str) -> None: ...
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def inject(self, prefix: str, name: str, message: str) -> None: ...
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def capture_pane(self, prefix: str, name: str) -> str: ...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TmuxBackend(SessionBackend):
|
|
44
|
+
def _sess(self, prefix: str, name: str) -> str:
|
|
45
|
+
return f"{prefix}-{name}"
|
|
46
|
+
|
|
47
|
+
def is_running(self, prefix: str, name: str) -> bool:
|
|
48
|
+
r = subprocess.run(
|
|
49
|
+
["tmux", "has-session", "-t", self._sess(prefix, name)],
|
|
50
|
+
capture_output=True,
|
|
51
|
+
timeout=5,
|
|
52
|
+
)
|
|
53
|
+
return r.returncode == 0
|
|
54
|
+
|
|
55
|
+
def capture_pane(self, prefix: str, name: str) -> str:
|
|
56
|
+
r = subprocess.run(
|
|
57
|
+
["tmux", "capture-pane", "-t", self._sess(prefix, name), "-p"],
|
|
58
|
+
capture_output=True,
|
|
59
|
+
text=True,
|
|
60
|
+
timeout=5,
|
|
61
|
+
)
|
|
62
|
+
return r.stdout if r.returncode == 0 else ""
|
|
63
|
+
|
|
64
|
+
def wait_for_shell(self, prefix: str, name: str, timeout: int = 15) -> bool:
|
|
65
|
+
waited = 0
|
|
66
|
+
while waited < timeout:
|
|
67
|
+
text = self.capture_pane(prefix, name)
|
|
68
|
+
if "$" in text or "%" in text or "❯" in text:
|
|
69
|
+
return True
|
|
70
|
+
time.sleep(1)
|
|
71
|
+
waited += 1
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
def wait_for_prompt(self, prefix: str, name: str, timeout: int = 60) -> bool:
|
|
75
|
+
waited = 0
|
|
76
|
+
while waited < timeout:
|
|
77
|
+
text = self.capture_pane(prefix, name)
|
|
78
|
+
if "❯" in text:
|
|
79
|
+
return True
|
|
80
|
+
time.sleep(2)
|
|
81
|
+
waited += 2
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
def auto_accept_trust(self, prefix: str, name: str) -> None:
|
|
85
|
+
sess = self._sess(prefix, name)
|
|
86
|
+
waited = 0
|
|
87
|
+
while waited < 60:
|
|
88
|
+
r = subprocess.run(
|
|
89
|
+
["tmux", "capture-pane", "-t", sess, "-p"],
|
|
90
|
+
capture_output=True,
|
|
91
|
+
text=True,
|
|
92
|
+
timeout=5,
|
|
93
|
+
)
|
|
94
|
+
if "I trust" in r.stdout or "trust this folder" in r.stdout.lower():
|
|
95
|
+
time.sleep(0.5)
|
|
96
|
+
subprocess.run(["tmux", "send-keys", "-t", sess, "Enter"])
|
|
97
|
+
return
|
|
98
|
+
time.sleep(2)
|
|
99
|
+
waited += 2
|
|
100
|
+
|
|
101
|
+
def start_session(
|
|
102
|
+
self,
|
|
103
|
+
prefix: str,
|
|
104
|
+
name: str,
|
|
105
|
+
project_root: Path,
|
|
106
|
+
agent_cmd: str,
|
|
107
|
+
) -> str:
|
|
108
|
+
sess = self._sess(prefix, name)
|
|
109
|
+
subprocess.run(["tmux", "new-session", "-d", "-s", sess, "-x", "200", "-y", "50"])
|
|
110
|
+
self.wait_for_shell(prefix, name, timeout=10)
|
|
111
|
+
subprocess.run(
|
|
112
|
+
[
|
|
113
|
+
"tmux",
|
|
114
|
+
"send-keys",
|
|
115
|
+
"-t",
|
|
116
|
+
sess,
|
|
117
|
+
f"source ~/.zprofile 2>/dev/null; source ~/.zshrc 2>/dev/null; cd '{project_root}'",
|
|
118
|
+
"Enter",
|
|
119
|
+
]
|
|
120
|
+
)
|
|
121
|
+
self.wait_for_shell(prefix, name, timeout=10)
|
|
122
|
+
subprocess.run(["tmux", "send-keys", "-t", sess, agent_cmd, "Enter"])
|
|
123
|
+
return sess
|
|
124
|
+
|
|
125
|
+
def stop_session(self, prefix: str, name: str, save_cmd: str) -> None:
|
|
126
|
+
sess = self._sess(prefix, name)
|
|
127
|
+
subprocess.run(["tmux", "send-keys", "-t", sess, "C-c"])
|
|
128
|
+
time.sleep(1)
|
|
129
|
+
subprocess.run(["tmux", "send-keys", "-t", sess, f"! {save_cmd}", "Enter"])
|
|
130
|
+
time.sleep(3)
|
|
131
|
+
subprocess.run(["tmux", "send-keys", "-t", sess, "/exit", "Enter"])
|
|
132
|
+
|
|
133
|
+
waited = 0
|
|
134
|
+
while self.is_running(prefix, name) and waited < 15:
|
|
135
|
+
time.sleep(1)
|
|
136
|
+
waited += 1
|
|
137
|
+
if self.is_running(prefix, name):
|
|
138
|
+
subprocess.run(["tmux", "kill-session", "-t", sess])
|
|
139
|
+
print(f" {name}: force killed (after {waited}s)")
|
|
140
|
+
else:
|
|
141
|
+
print(f" {name}: exited gracefully")
|
|
142
|
+
|
|
143
|
+
def status_line(self, prefix: str, name: str, agent: str) -> str:
|
|
144
|
+
return f"running (tmux, agent: {agent})"
|
|
145
|
+
|
|
146
|
+
def attach(self, prefix: str, name: str) -> None:
|
|
147
|
+
os.execvp("tmux", ["tmux", "attach-session", "-t", self._sess(prefix, name)])
|
|
148
|
+
|
|
149
|
+
def inject(self, prefix: str, name: str, message: str) -> None:
|
|
150
|
+
sess = self._sess(prefix, name)
|
|
151
|
+
if not self.is_running(prefix, name):
|
|
152
|
+
print(f" {name}: not running")
|
|
153
|
+
raise SystemExit(1)
|
|
154
|
+
oneline = message.replace("\n", " ")
|
|
155
|
+
subprocess.run(["tmux", "send-keys", "-t", sess, "-l", oneline])
|
|
156
|
+
subprocess.run(["tmux", "send-keys", "-t", sess, "Enter"])
|
|
157
|
+
print(f" {name}: injected")
|
|
158
|
+
|
|
159
|
+
def inject_initial_prompt(self, prefix: str, name: str, prompt: str, log_dir: Path) -> None:
|
|
160
|
+
if self.wait_for_prompt(prefix, name, timeout=60):
|
|
161
|
+
sess = self._sess(prefix, name)
|
|
162
|
+
time.sleep(1)
|
|
163
|
+
subprocess.run(["tmux", "send-keys", "-t", sess, "-l", prompt])
|
|
164
|
+
subprocess.run(["tmux", "send-keys", "-t", sess, "Enter"])
|
|
165
|
+
else:
|
|
166
|
+
with open(log_dir / f"{name}.log", "a") as f:
|
|
167
|
+
f.write(f"[WARN] {name}: prompt not detected after 60s, skipping injection\n")
|
|
168
|
+
|
|
169
|
+
def enable_mouse(self) -> None:
|
|
170
|
+
subprocess.run(["tmux", "set", "-g", "mouse", "on"], capture_output=True)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class ScreenBackend(SessionBackend):
|
|
174
|
+
def _sess(self, prefix: str, name: str) -> str:
|
|
175
|
+
return f"{prefix}-{name}"
|
|
176
|
+
|
|
177
|
+
def is_running(self, prefix: str, name: str) -> bool:
|
|
178
|
+
sess_tag = f".{self._sess(prefix, name)}"
|
|
179
|
+
r = subprocess.run(["screen", "-list"], capture_output=True, text=True, timeout=5)
|
|
180
|
+
output = r.stdout + r.stderr
|
|
181
|
+
return bool(re.search(rf"{re.escape(sess_tag)}\s", output))
|
|
182
|
+
|
|
183
|
+
def capture_pane(self, prefix: str, name: str) -> str:
|
|
184
|
+
return ""
|
|
185
|
+
|
|
186
|
+
def start_session(
|
|
187
|
+
self,
|
|
188
|
+
prefix: str,
|
|
189
|
+
name: str,
|
|
190
|
+
project_root: Path,
|
|
191
|
+
agent_cmd: str,
|
|
192
|
+
) -> str:
|
|
193
|
+
sess = self._sess(prefix, name)
|
|
194
|
+
subprocess.run(["screen", "-dmS", sess])
|
|
195
|
+
time.sleep(1)
|
|
196
|
+
subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", f"cd '{project_root}'"])
|
|
197
|
+
subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", "\r"])
|
|
198
|
+
time.sleep(0.5)
|
|
199
|
+
subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", agent_cmd])
|
|
200
|
+
subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", "\r"])
|
|
201
|
+
return sess
|
|
202
|
+
|
|
203
|
+
def stop_session(self, prefix: str, name: str, save_cmd: str) -> None:
|
|
204
|
+
sess = self._sess(prefix, name)
|
|
205
|
+
subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", "\x03"])
|
|
206
|
+
time.sleep(1)
|
|
207
|
+
subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", f"! {save_cmd}\r"])
|
|
208
|
+
time.sleep(3)
|
|
209
|
+
subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", "/exit\r"])
|
|
210
|
+
|
|
211
|
+
waited = 0
|
|
212
|
+
while self.is_running(prefix, name) and waited < 15:
|
|
213
|
+
time.sleep(1)
|
|
214
|
+
waited += 1
|
|
215
|
+
if self.is_running(prefix, name):
|
|
216
|
+
subprocess.run(["screen", "-S", sess, "-X", "quit"])
|
|
217
|
+
print(f" {name}: force killed (after {waited}s)")
|
|
218
|
+
else:
|
|
219
|
+
print(f" {name}: exited gracefully")
|
|
220
|
+
|
|
221
|
+
def status_line(self, prefix: str, name: str, agent: str) -> str:
|
|
222
|
+
r = subprocess.run(["screen", "-list"], capture_output=True, text=True, timeout=5)
|
|
223
|
+
output = r.stdout + r.stderr
|
|
224
|
+
state = ""
|
|
225
|
+
for line in output.splitlines():
|
|
226
|
+
if f".{self._sess(prefix, name)}" in line:
|
|
227
|
+
parts = line.strip().split()
|
|
228
|
+
if parts:
|
|
229
|
+
state = parts[-1]
|
|
230
|
+
break
|
|
231
|
+
return f"running (screen, agent: {agent}) {state}"
|
|
232
|
+
|
|
233
|
+
def attach(self, prefix: str, name: str) -> None:
|
|
234
|
+
os.execvp("screen", ["screen", "-r", self._sess(prefix, name)])
|
|
235
|
+
|
|
236
|
+
def inject(self, prefix: str, name: str, message: str) -> None:
|
|
237
|
+
sess = self._sess(prefix, name)
|
|
238
|
+
if not self.is_running(prefix, name):
|
|
239
|
+
print(f" {name}: not running")
|
|
240
|
+
raise SystemExit(1)
|
|
241
|
+
oneline = message.replace("\n", " ")
|
|
242
|
+
subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", oneline])
|
|
243
|
+
time.sleep(0.3)
|
|
244
|
+
subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", "\r"])
|
|
245
|
+
print(f" {name}: injected")
|
|
246
|
+
|
|
247
|
+
def inject_initial_prompt(self, prefix: str, name: str, prompt: str, log_dir: Path) -> None:
|
|
248
|
+
time.sleep(3)
|
|
249
|
+
sess = self._sess(prefix, name)
|
|
250
|
+
subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", prompt])
|
|
251
|
+
time.sleep(0.3)
|
|
252
|
+
subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", "\r"])
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def detect_backend() -> SessionBackend:
|
|
256
|
+
override = os.environ.get("SWARM_MODE", "")
|
|
257
|
+
if override == "tmux":
|
|
258
|
+
return TmuxBackend()
|
|
259
|
+
if override == "screen":
|
|
260
|
+
return ScreenBackend()
|
|
261
|
+
if shutil.which("tmux"):
|
|
262
|
+
return TmuxBackend()
|
|
263
|
+
if shutil.which("screen"):
|
|
264
|
+
return ScreenBackend()
|
|
265
|
+
print("ERROR: neither tmux nor screen found. Install one: brew install tmux")
|
|
266
|
+
raise SystemExit(1)
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
package/registry/pubkeys.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meridian": "3f9baebbfe2ddd36e0ccd6f98301fd1bdc57aa82f395791a769864078181c43c",
|
|
3
3
|
"musk": "16e74dfbbd797fe17a8e44d99b71d207c8e3aa1b36ffbf2dfe88420dc54bf37c",
|
|
4
|
-
"lead": "51b6ebbbe720745bcb9386e1d997481bd265beac45d183fc2ac11fae9824c43a"
|
|
4
|
+
"lead": "51b6ebbbe720745bcb9386e1d997481bd265beac45d183fc2ac11fae9824c43a",
|
|
5
|
+
"sutskever": "e00b9ede0b97993105ec9f6f7ff40aebe545081534050d2d719623cf1f8fd946"
|
|
5
6
|
}
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
"""BugSLAChecker — check overdue bugs."""
|
|
2
|
-
|
|
3
|
-
import subprocess
|
|
4
|
-
|
|
5
|
-
from .base import Concern
|
|
6
|
-
from .config import DispatcherConfig
|
|
7
|
-
from .coral_poker import CoralPoker
|
|
8
|
-
from .helpers import log
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class BugSLAChecker(Concern):
|
|
12
|
-
interval = 600
|
|
13
|
-
|
|
14
|
-
def __init__(self, cfg: DispatcherConfig, poker: CoralPoker) -> None:
|
|
15
|
-
super().__init__()
|
|
16
|
-
self.cfg = cfg
|
|
17
|
-
self.poker = poker
|
|
18
|
-
|
|
19
|
-
def tick(self, now: int) -> None:
|
|
20
|
-
try:
|
|
21
|
-
r = subprocess.run(
|
|
22
|
-
[self.cfg.board_sh, "bug", "overdue"],
|
|
23
|
-
capture_output=True,
|
|
24
|
-
text=True,
|
|
25
|
-
timeout=10,
|
|
26
|
-
)
|
|
27
|
-
overdue = r.stdout.strip()
|
|
28
|
-
except Exception:
|
|
29
|
-
return
|
|
30
|
-
if overdue and "No overdue" not in overdue:
|
|
31
|
-
log(f"Bug SLA alert: {overdue}")
|
|
32
|
-
self.poker.poke(f"[Dispatcher] Bug SLA 超时: {overdue}")
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
"""CoralPoker — periodic heartbeat to dispatcher session."""
|
|
2
|
-
|
|
3
|
-
import re
|
|
4
|
-
import time
|
|
5
|
-
|
|
6
|
-
from .base import Concern
|
|
7
|
-
from .config import DispatcherConfig
|
|
8
|
-
from .helpers import db, is_claude_running, log, pane_md5, tmux, tmux_ok, tmux_send
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class CoralPoker(Concern):
|
|
12
|
-
interval = 120
|
|
13
|
-
|
|
14
|
-
def __init__(self, cfg: DispatcherConfig) -> None:
|
|
15
|
-
super().__init__()
|
|
16
|
-
self.cfg = cfg
|
|
17
|
-
self.last_poke: int = int(time.time())
|
|
18
|
-
|
|
19
|
-
def poke(self, msg: str) -> bool:
|
|
20
|
-
if not tmux_ok("has-session", "-t", self.cfg.coral_sess) or not is_claude_running(self.cfg.coral_sess):
|
|
21
|
-
return False
|
|
22
|
-
|
|
23
|
-
content = tmux("capture-pane", "-t", self.cfg.coral_sess, "-p") or ""
|
|
24
|
-
prompts = [l for l in content.splitlines() if l.startswith("❯")]
|
|
25
|
-
if prompts and re.match(r"^❯ .{3,}", prompts[-1]):
|
|
26
|
-
log("Coral: skip (typing)")
|
|
27
|
-
return False
|
|
28
|
-
|
|
29
|
-
h1 = pane_md5(self.cfg.coral_sess)
|
|
30
|
-
time.sleep(1)
|
|
31
|
-
if h1 != pane_md5(self.cfg.coral_sess):
|
|
32
|
-
log("Coral: skip (busy)")
|
|
33
|
-
return False
|
|
34
|
-
|
|
35
|
-
log("Coral: poking")
|
|
36
|
-
tmux_send(self.cfg.coral_sess, msg)
|
|
37
|
-
self.last_poke = int(time.time())
|
|
38
|
-
return True
|
|
39
|
-
|
|
40
|
-
def tick(self, now: int) -> None:
|
|
41
|
-
unread = 0
|
|
42
|
-
if self.cfg.board_db.exists():
|
|
43
|
-
try:
|
|
44
|
-
unread = (
|
|
45
|
-
db(self.cfg).scalar(
|
|
46
|
-
"SELECT COUNT(*) FROM inbox WHERE session=? AND read=0",
|
|
47
|
-
(self.cfg.dispatcher_session,),
|
|
48
|
-
)
|
|
49
|
-
or 0
|
|
50
|
-
)
|
|
51
|
-
except Exception:
|
|
52
|
-
pass
|
|
53
|
-
|
|
54
|
-
if unread > 0:
|
|
55
|
-
self.poke(f"[Dispatcher] 你有 {unread} 条未读消息")
|
|
56
|
-
elif (now - self.last_poke) >= self.interval:
|
|
57
|
-
self.poke(f"[Dispatcher] heartbeat {time.strftime('%H:%M:%S')}")
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
"""HealthChecker — periodic status report + team idle detection."""
|
|
2
|
-
|
|
3
|
-
import time
|
|
4
|
-
|
|
5
|
-
from lib.common import date_to_epoch, is_suspended
|
|
6
|
-
|
|
7
|
-
from .base import Concern
|
|
8
|
-
from .config import DispatcherConfig
|
|
9
|
-
from .coral_manager import CoralManager
|
|
10
|
-
from .coral_poker import CoralPoker
|
|
11
|
-
from .helpers import board_send, db, get_dev_sessions, is_claude_running, log, tmux_ok
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
class HealthChecker(Concern):
|
|
15
|
-
INITIAL = 600
|
|
16
|
-
MAX = 3600
|
|
17
|
-
IDLE_THRESHOLD = 1800
|
|
18
|
-
|
|
19
|
-
def __init__(self, cfg: DispatcherConfig, poker: CoralPoker, coral: CoralManager) -> None:
|
|
20
|
-
super().__init__()
|
|
21
|
-
self.cfg = cfg
|
|
22
|
-
self.interval = self.INITIAL
|
|
23
|
-
self.poker = poker
|
|
24
|
-
self.coral = coral
|
|
25
|
-
self.last_idle_alert: int = 0
|
|
26
|
-
|
|
27
|
-
def tick(self, now: int) -> None:
|
|
28
|
-
parts = []
|
|
29
|
-
for name in get_dev_sessions(self.cfg):
|
|
30
|
-
on = "on" if tmux_ok("has-session", "-t", f"{self.cfg.prefix}-{name}") else "off"
|
|
31
|
-
parts.append(f"{name}:{on}")
|
|
32
|
-
status = " ".join(parts)
|
|
33
|
-
|
|
34
|
-
log(f"Health check (interval:{self.interval}s): {status}")
|
|
35
|
-
self.poker.poke(f"[Dispatcher] 健康巡检 {time.strftime('%H:%M:%S')}: {status}")
|
|
36
|
-
self.interval = min(self.interval * 2, self.MAX)
|
|
37
|
-
self._check_team_idle(now)
|
|
38
|
-
|
|
39
|
-
def _check_team_idle(self, now: int) -> None:
|
|
40
|
-
if not self.cfg.board_db.exists():
|
|
41
|
-
return
|
|
42
|
-
idle_list: list[str] = []
|
|
43
|
-
total = 0
|
|
44
|
-
d = db(self.cfg)
|
|
45
|
-
|
|
46
|
-
for name in get_dev_sessions(self.cfg):
|
|
47
|
-
if is_suspended(name, self.cfg.suspended_file):
|
|
48
|
-
continue
|
|
49
|
-
sess = f"{self.cfg.prefix}-{name}"
|
|
50
|
-
if not tmux_ok("has-session", "-t", sess) or not is_claude_running(sess):
|
|
51
|
-
continue
|
|
52
|
-
if self.coral.in_grace_period(name, now):
|
|
53
|
-
continue
|
|
54
|
-
|
|
55
|
-
total += 1
|
|
56
|
-
try:
|
|
57
|
-
updated = d.scalar("SELECT updated_at FROM sessions WHERE name=?", (name,)) or ""
|
|
58
|
-
except Exception:
|
|
59
|
-
continue
|
|
60
|
-
if updated:
|
|
61
|
-
age = now - date_to_epoch(updated)
|
|
62
|
-
if age > self.IDLE_THRESHOLD:
|
|
63
|
-
idle_list.append(f"{name}({age}s)")
|
|
64
|
-
|
|
65
|
-
if idle_list and len(idle_list) == total and (now - self.last_idle_alert) > 3600:
|
|
66
|
-
log(f"All sessions idle: {' '.join(idle_list)}")
|
|
67
|
-
board_send(
|
|
68
|
-
self.cfg,
|
|
69
|
-
"lead",
|
|
70
|
-
f"[Dispatcher] 全员空闲超过 {self.IDLE_THRESHOLD // 60} 分钟:{' '.join(idle_list)}。可能需要分配工作。",
|
|
71
|
-
)
|
|
72
|
-
self.last_idle_alert = now
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
"""IdleDetector — batch screen snapshot comparison (non-blocking).
|
|
2
|
-
|
|
3
|
-
Compares snapshots across consecutive ticks instead of sleeping mid-tick.
|
|
4
|
-
"""
|
|
5
|
-
|
|
6
|
-
import re
|
|
7
|
-
|
|
8
|
-
from .base import Concern
|
|
9
|
-
from .config import DispatcherConfig
|
|
10
|
-
from .helpers import has_tool_process, pane_md5, tmux
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class IdleDetector(Concern):
|
|
14
|
-
interval = 5
|
|
15
|
-
|
|
16
|
-
def __init__(self, cfg: DispatcherConfig) -> None:
|
|
17
|
-
super().__init__()
|
|
18
|
-
self.cfg = cfg
|
|
19
|
-
self.cache: dict[str, str] = {} # sess -> "idle" | "busy"
|
|
20
|
-
self._prev_snap: dict[str, str] = {} # sess -> md5 from previous tick
|
|
21
|
-
|
|
22
|
-
def is_idle(self, sess: str) -> bool:
|
|
23
|
-
return self.cache.get(sess) == "idle"
|
|
24
|
-
|
|
25
|
-
def tick(self, now: int) -> None:
|
|
26
|
-
self.cache.clear()
|
|
27
|
-
raw = tmux("list-sessions", "-F", "#{session_name}")
|
|
28
|
-
if not raw:
|
|
29
|
-
self._prev_snap.clear()
|
|
30
|
-
return
|
|
31
|
-
|
|
32
|
-
all_sessions = [s for s in raw.splitlines() if s.startswith(f"{self.cfg.prefix}-")]
|
|
33
|
-
need_snapshot: list[str] = []
|
|
34
|
-
|
|
35
|
-
for sess in all_sessions:
|
|
36
|
-
pane = tmux("capture-pane", "-t", sess, "-p") or ""
|
|
37
|
-
prompts = [l for l in pane.splitlines() if l.startswith("❯")]
|
|
38
|
-
if prompts and re.match(r"^❯ .{3,}", prompts[-1]):
|
|
39
|
-
self.cache[sess] = "busy"
|
|
40
|
-
continue
|
|
41
|
-
if has_tool_process(sess):
|
|
42
|
-
self.cache[sess] = "busy"
|
|
43
|
-
continue
|
|
44
|
-
need_snapshot.append(sess)
|
|
45
|
-
|
|
46
|
-
current_snap: dict[str, str] = {}
|
|
47
|
-
for sess in need_snapshot:
|
|
48
|
-
md5 = pane_md5(sess)
|
|
49
|
-
current_snap[sess] = md5
|
|
50
|
-
if sess in self._prev_snap and self._prev_snap[sess] == md5:
|
|
51
|
-
self.cache[sess] = "idle"
|
|
52
|
-
else:
|
|
53
|
-
self.cache[sess] = "busy"
|
|
54
|
-
|
|
55
|
-
self._prev_snap = {s: pane_md5(s) for s in all_sessions if s not in self.cache or self.cache[s] != "busy"}
|
|
56
|
-
self._prev_snap.update(current_snap)
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
"""IdleKiller — kill sessions idle >30min."""
|
|
2
|
-
|
|
3
|
-
from .base import Concern
|
|
4
|
-
from .config import DispatcherConfig
|
|
5
|
-
from .coral_manager import CoralManager
|
|
6
|
-
from .helpers import board_send, get_dev_sessions, is_claude_running, log, tmux, tmux_ok
|
|
7
|
-
from .idle_detector import IdleDetector
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class IdleKiller(Concern):
|
|
11
|
-
interval = 5
|
|
12
|
-
THRESHOLD = 1800
|
|
13
|
-
|
|
14
|
-
def __init__(self, cfg: DispatcherConfig, idle: IdleDetector, coral: CoralManager) -> None:
|
|
15
|
-
super().__init__()
|
|
16
|
-
self.cfg = cfg
|
|
17
|
-
self.idle = idle
|
|
18
|
-
self.coral = coral
|
|
19
|
-
self.idle_since: dict[str, int] = {}
|
|
20
|
-
|
|
21
|
-
def tick(self, now: int) -> None:
|
|
22
|
-
for name in get_dev_sessions(self.cfg):
|
|
23
|
-
sess = f"{self.cfg.prefix}-{name}"
|
|
24
|
-
if not tmux_ok("has-session", "-t", sess) or not is_claude_running(sess):
|
|
25
|
-
self.idle_since.pop(name, None)
|
|
26
|
-
continue
|
|
27
|
-
|
|
28
|
-
if name not in self.coral.boot_times:
|
|
29
|
-
self.coral.record_boot(name)
|
|
30
|
-
if self.coral.in_grace_period(name, now):
|
|
31
|
-
continue
|
|
32
|
-
|
|
33
|
-
if self.idle.is_idle(sess):
|
|
34
|
-
since = self.idle_since.setdefault(name, now)
|
|
35
|
-
if (now - since) >= self.THRESHOLD:
|
|
36
|
-
log(f"{name}: idle {now - since}s (>30min), killing session")
|
|
37
|
-
tmux("kill-session", "-t", sess)
|
|
38
|
-
self.idle_since.pop(name, None)
|
|
39
|
-
board_send(self.cfg, "All", f"[Dispatcher] {name} 闲置超过 30 分钟,已终止。")
|
|
40
|
-
else:
|
|
41
|
-
self.idle_since.pop(name, None)
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
"""IdleNudger — nudge idle sessions to continue working."""
|
|
2
|
-
|
|
3
|
-
from lib.common import is_suspended
|
|
4
|
-
|
|
5
|
-
from .base import Concern
|
|
6
|
-
from .config import DispatcherConfig
|
|
7
|
-
from .helpers import get_dev_sessions, is_claude_running, log, tmux_ok, tmux_send
|
|
8
|
-
from .idle_detector import IdleDetector
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class IdleNudger(Concern):
|
|
12
|
-
interval = 5
|
|
13
|
-
COOLDOWN = 300
|
|
14
|
-
|
|
15
|
-
def __init__(self, cfg: DispatcherConfig, idle: IdleDetector) -> None:
|
|
16
|
-
super().__init__()
|
|
17
|
-
self.cfg = cfg
|
|
18
|
-
self.idle = idle
|
|
19
|
-
self.last_nudge: dict[str, int] = {}
|
|
20
|
-
|
|
21
|
-
def tick(self, now: int) -> None:
|
|
22
|
-
for name in get_dev_sessions(self.cfg):
|
|
23
|
-
if is_suspended(name, self.cfg.suspended_file):
|
|
24
|
-
continue
|
|
25
|
-
sess = f"{self.cfg.prefix}-{name}"
|
|
26
|
-
if not tmux_ok("has-session", "-t", sess) or not is_claude_running(sess):
|
|
27
|
-
continue
|
|
28
|
-
if not self.idle.is_idle(sess):
|
|
29
|
-
continue
|
|
30
|
-
if (now - self.last_nudge.get(name, 0)) < self.COOLDOWN:
|
|
31
|
-
continue
|
|
32
|
-
|
|
33
|
-
log(f"{name}: idle, nudging autonomous loop")
|
|
34
|
-
tmux_send(
|
|
35
|
-
sess,
|
|
36
|
-
f"继续工作。检查你的 OKR ({self.cfg.okr_dir}/{name}.md),推进你的活跃 KR。自己决定优先级。",
|
|
37
|
-
)
|
|
38
|
-
self.last_nudge[name] = now
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
"""InboxNudger — detect unread inboxes, nudge sessions."""
|
|
2
|
-
|
|
3
|
-
from .base import Concern
|
|
4
|
-
from .config import DispatcherConfig
|
|
5
|
-
from .helpers import db, get_dev_sessions, is_claude_running, log, tmux_ok, tmux_send
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class InboxNudger(Concern):
|
|
9
|
-
interval = 5
|
|
10
|
-
|
|
11
|
-
def __init__(self, cfg: DispatcherConfig) -> None:
|
|
12
|
-
super().__init__()
|
|
13
|
-
self.cfg = cfg
|
|
14
|
-
|
|
15
|
-
def nudge_if_unread(self, name: str) -> None:
|
|
16
|
-
if not self.cfg.board_db.exists():
|
|
17
|
-
return
|
|
18
|
-
try:
|
|
19
|
-
unread = db(self.cfg).scalar("SELECT COUNT(*) FROM inbox WHERE session=? AND read=0", (name,)) or 0
|
|
20
|
-
except Exception:
|
|
21
|
-
return
|
|
22
|
-
if unread <= 0:
|
|
23
|
-
return
|
|
24
|
-
|
|
25
|
-
sess = f"{self.cfg.prefix}-{name}"
|
|
26
|
-
if not tmux_ok("has-session", "-t", sess) or not is_claude_running(sess):
|
|
27
|
-
return
|
|
28
|
-
|
|
29
|
-
log(f"INBOX: {name} has {unread} unread -> nudging")
|
|
30
|
-
tmux_send(sess, f"./board --as {name} inbox")
|
|
31
|
-
|
|
32
|
-
def tick(self, now: int) -> None:
|
|
33
|
-
for name in get_dev_sessions(self.cfg):
|
|
34
|
-
self.nudge_if_unread(name)
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
"""ResourceMonitor — battery/memory/CPU."""
|
|
2
|
-
|
|
3
|
-
from lib.resources import check_battery, check_cpu, check_memory
|
|
4
|
-
|
|
5
|
-
from .base import Concern
|
|
6
|
-
from .config import DispatcherConfig
|
|
7
|
-
from .helpers import board_send, get_dev_sessions, is_claude_running, log, tmux_send
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class ResourceMonitor(Concern):
|
|
11
|
-
interval = 60
|
|
12
|
-
|
|
13
|
-
def __init__(self, cfg: DispatcherConfig) -> None:
|
|
14
|
-
super().__init__()
|
|
15
|
-
self.cfg = cfg
|
|
16
|
-
self.last_state = ""
|
|
17
|
-
|
|
18
|
-
def tick(self, now: int) -> None:
|
|
19
|
-
batt = check_battery()
|
|
20
|
-
mem = check_memory()
|
|
21
|
-
cpu = check_cpu()
|
|
22
|
-
|
|
23
|
-
state = f"{batt.status}|{batt.pct}|{mem.status}|{mem.pressure}|{cpu.status}|{cpu.usage}"
|
|
24
|
-
if state == self.last_state:
|
|
25
|
-
return
|
|
26
|
-
self.last_state = state
|
|
27
|
-
|
|
28
|
-
if batt.status == "CRITICAL":
|
|
29
|
-
log(f"RESOURCE: Battery CRITICAL ({batt.pct}%)")
|
|
30
|
-
board_send(self.cfg, "All", f"[Resource] 电池严重不足 ({batt.pct}%),暂停非关键 session。")
|
|
31
|
-
for name in get_dev_sessions(self.cfg):
|
|
32
|
-
sess = f"{self.cfg.prefix}-{name}"
|
|
33
|
-
if is_claude_running(sess):
|
|
34
|
-
tmux_send(sess, "[系统] 电池严重不足,请立即保存状态。")
|
|
35
|
-
elif batt.status == "LOW":
|
|
36
|
-
log(f"RESOURCE: Battery LOW ({batt.pct}%)")
|
|
37
|
-
board_send(self.cfg, "All", f"[Resource] 电池低 ({batt.pct}%),建议减少活跃 session 到 2-3 个。")
|
|
38
|
-
|
|
39
|
-
if mem.status == "CRITICAL":
|
|
40
|
-
log("RESOURCE: Memory pressure CRITICAL")
|
|
41
|
-
board_send(self.cfg, "All", "[Resource] 内存压力严重!建议重启最大的 session 释放内存。")
|
|
42
|
-
elif mem.status == "WARNING":
|
|
43
|
-
log("RESOURCE: Memory pressure WARNING")
|
|
44
|
-
board_send(self.cfg, "All", "[Resource] 内存压力升高,关注中。")
|
|
45
|
-
|
|
46
|
-
if cpu.status == "SATURATED":
|
|
47
|
-
log(f"RESOURCE: CPU saturated ({cpu.usage}%)")
|