claude-nb 0.3.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/LICENSE +38 -0
- package/Makefile +60 -0
- package/README.md +63 -0
- package/VERSION +1 -0
- package/bin/_pip_entry.py +25 -0
- package/bin/board +287 -0
- package/bin/cnb +150 -0
- package/bin/cnb.js +33 -0
- package/bin/dispatcher +151 -0
- package/bin/dispatcher-watchdog +57 -0
- package/bin/doctor +328 -0
- package/bin/init +316 -0
- package/bin/registry +347 -0
- package/bin/swarm +896 -0
- package/lib/__init__.py +1 -0
- package/lib/board_admin.py +128 -0
- package/lib/board_bbs.py +99 -0
- package/lib/board_bug.py +161 -0
- package/lib/board_db.py +262 -0
- package/lib/board_lock.py +113 -0
- package/lib/board_mailbox.py +145 -0
- package/lib/board_maintenance.py +237 -0
- package/lib/board_msg.py +230 -0
- package/lib/board_task.py +200 -0
- package/lib/board_view.py +366 -0
- package/lib/board_vote.py +164 -0
- package/lib/build_lock.py +221 -0
- package/lib/cli.py +34 -0
- package/lib/common.py +285 -0
- package/lib/concerns/__init__.py +42 -0
- package/lib/concerns/adaptive_throttle.py +26 -0
- package/lib/concerns/base.py +25 -0
- package/lib/concerns/bug_sla_checker.py +32 -0
- package/lib/concerns/config.py +22 -0
- package/lib/concerns/coral_manager.py +61 -0
- package/lib/concerns/coral_poker.py +57 -0
- package/lib/concerns/file_watcher.py +127 -0
- package/lib/concerns/health_checker.py +72 -0
- package/lib/concerns/helpers.py +152 -0
- package/lib/concerns/idle_detector.py +56 -0
- package/lib/concerns/idle_killer.py +41 -0
- package/lib/concerns/idle_nudger.py +38 -0
- package/lib/concerns/inbox_nudger.py +34 -0
- package/lib/concerns/resource_monitor.py +47 -0
- package/lib/concerns/session_keepalive.py +23 -0
- package/lib/concerns/time_announcer.py +34 -0
- package/lib/crypto.py +92 -0
- package/lib/health.py +187 -0
- package/lib/inject.py +164 -0
- package/lib/migrate.py +109 -0
- package/lib/monitor.py +373 -0
- package/lib/panel.py +137 -0
- package/lib/resources.py +341 -0
- package/migrations/001_foreign_keys.sql +77 -0
- package/migrations/002_session_persona.sql +1 -0
- package/migrations/003_mailbox.sql +9 -0
- package/package.json +28 -0
- package/pyproject.toml +71 -0
- package/registry/0001-meridian.json +12 -0
- package/registry/0002-forge.json +12 -0
- package/registry/0003-lead.json +12 -0
- package/registry/0004-ms-encrypted-mailbox-live.json +12 -0
- package/registry/GENESIS.json +9 -0
- package/registry/pubkeys.json +5 -0
- package/schema.sql +138 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""build_lock.py -- Build queue with mkdir-based atomic locking.
|
|
3
|
+
|
|
4
|
+
Serialize builds to prevent CPU saturation.
|
|
5
|
+
Only one session can build at a time; others wait or skip.
|
|
6
|
+
|
|
7
|
+
Uses mkdir-based locking for atomicity: mkdir is atomic on all POSIX
|
|
8
|
+
systems, so two concurrent callers cannot both succeed.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
./lib/build_lock.py acquire <session> <target>
|
|
12
|
+
./lib/build_lock.py release <session>
|
|
13
|
+
./lib/build_lock.py status
|
|
14
|
+
./lib/build_lock.py wrap <session> <command...>
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
# Try to import common; fall back gracefully
|
|
24
|
+
try:
|
|
25
|
+
from lib.common import ClaudesEnv
|
|
26
|
+
except ImportError:
|
|
27
|
+
_here = Path(__file__).resolve().parent.parent
|
|
28
|
+
sys.path.insert(0, str(_here))
|
|
29
|
+
from lib.common import ClaudesEnv
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
MAX_WAIT = 300 # seconds to wait before giving up
|
|
33
|
+
STALE_THRESHOLD = 600 # 10 minutes
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BuildLock:
|
|
37
|
+
"""Mkdir-based atomic build lock."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, lock_dir: Path) -> None:
|
|
40
|
+
self.lock_dir = lock_dir
|
|
41
|
+
self.info_file = lock_dir / "info"
|
|
42
|
+
|
|
43
|
+
def read_info(self) -> tuple[str, int, str]:
|
|
44
|
+
"""Parse the info file. Returns (holder, locked_at, target)."""
|
|
45
|
+
if self.info_file.exists():
|
|
46
|
+
try:
|
|
47
|
+
parts = self.info_file.read_text().strip().split("|", 2)
|
|
48
|
+
holder = parts[0] if len(parts) > 0 else "unknown"
|
|
49
|
+
locked_at = int(parts[1]) if len(parts) > 1 else 0
|
|
50
|
+
target = parts[2] if len(parts) > 2 else "unknown"
|
|
51
|
+
return holder, locked_at, target
|
|
52
|
+
except (OSError, ValueError) as e:
|
|
53
|
+
print(f"WARNING: corrupted lock info file ({e}), resetting", file=sys.stderr)
|
|
54
|
+
self._remove()
|
|
55
|
+
return "unknown", 0, "unknown"
|
|
56
|
+
|
|
57
|
+
def _try_mkdir(self) -> bool:
|
|
58
|
+
"""Attempt atomic mkdir. Returns True if we got the lock."""
|
|
59
|
+
try:
|
|
60
|
+
os.mkdir(self.lock_dir)
|
|
61
|
+
return True
|
|
62
|
+
except FileExistsError:
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
def _force_release_stale(self) -> bool:
|
|
66
|
+
"""Remove a stale lock (>10 min). Returns True if removed."""
|
|
67
|
+
holder, locked_at, target = self.read_info()
|
|
68
|
+
now = int(time.time())
|
|
69
|
+
if locked_at > 0 and (now - locked_at) > STALE_THRESHOLD:
|
|
70
|
+
print(f"Stale lock from {holder} (>10min), forcing release")
|
|
71
|
+
self._remove()
|
|
72
|
+
return True
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
def _remove(self) -> None:
|
|
76
|
+
"""Remove the lock directory tree."""
|
|
77
|
+
import shutil
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
shutil.rmtree(self.lock_dir)
|
|
81
|
+
except FileNotFoundError:
|
|
82
|
+
pass
|
|
83
|
+
except OSError as e:
|
|
84
|
+
print(f"WARNING: failed to remove lock dir: {e}", file=sys.stderr)
|
|
85
|
+
|
|
86
|
+
def _write_info(self, session: str, target: str) -> None:
|
|
87
|
+
"""Write metadata into the lock directory."""
|
|
88
|
+
self.info_file.write_text(f"{session}|{int(time.time())}|{target}")
|
|
89
|
+
|
|
90
|
+
def acquire(self, session: str, target: str = "unknown") -> tuple[bool, str]:
|
|
91
|
+
"""Try to get the build lock.
|
|
92
|
+
|
|
93
|
+
Returns (success, message) where message is e.g. "OK|sess|target"
|
|
94
|
+
or "BUSY|holder|target".
|
|
95
|
+
"""
|
|
96
|
+
if self._try_mkdir():
|
|
97
|
+
self._write_info(session, target)
|
|
98
|
+
return True, f"OK|{session}|{target}"
|
|
99
|
+
|
|
100
|
+
# Lock exists -- check for staleness
|
|
101
|
+
if self._force_release_stale() and self._try_mkdir():
|
|
102
|
+
self._write_info(session, target)
|
|
103
|
+
return True, f"OK|{session}|{target}"
|
|
104
|
+
|
|
105
|
+
holder, _, lock_target = self.read_info()
|
|
106
|
+
return False, f"BUSY|{holder}|{lock_target}"
|
|
107
|
+
|
|
108
|
+
def release(self, session: str) -> tuple[bool, str]:
|
|
109
|
+
"""Release the build lock held by *session*."""
|
|
110
|
+
if not self.lock_dir.is_dir():
|
|
111
|
+
return True, "OK (no lock)"
|
|
112
|
+
holder, _, _ = self.read_info()
|
|
113
|
+
if holder == session:
|
|
114
|
+
self._remove()
|
|
115
|
+
return True, "OK released"
|
|
116
|
+
return False, f"ERR: lock held by {holder}, not {session}"
|
|
117
|
+
|
|
118
|
+
def status(self) -> str:
|
|
119
|
+
"""Return a human-readable status string."""
|
|
120
|
+
if not self.lock_dir.is_dir():
|
|
121
|
+
return "FREE"
|
|
122
|
+
holder, locked_at, target = self.read_info()
|
|
123
|
+
elapsed = int(time.time()) - locked_at
|
|
124
|
+
return f"LOCKED by {holder} ({target}) for {elapsed}s"
|
|
125
|
+
|
|
126
|
+
def wrap(self, session: str, command: list) -> int:
|
|
127
|
+
"""Acquire lock, run command, release on exit.
|
|
128
|
+
|
|
129
|
+
Returns the command's exit code.
|
|
130
|
+
"""
|
|
131
|
+
target = command[0] if command else "unknown"
|
|
132
|
+
|
|
133
|
+
waited = 0
|
|
134
|
+
while not self._try_mkdir():
|
|
135
|
+
if self.lock_dir.is_dir():
|
|
136
|
+
holder, locked_at, lock_target = self.read_info()
|
|
137
|
+
now = int(time.time())
|
|
138
|
+
|
|
139
|
+
# Stale check
|
|
140
|
+
if locked_at > 0 and (now - locked_at) > STALE_THRESHOLD:
|
|
141
|
+
print(f"[build-queue] Stale lock from {holder}, forcing release")
|
|
142
|
+
self._remove()
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
if waited >= MAX_WAIT:
|
|
146
|
+
print(f"[build-queue] Timeout waiting for lock (held by {holder})")
|
|
147
|
+
return 1
|
|
148
|
+
|
|
149
|
+
if waited % 30 == 0:
|
|
150
|
+
print(f"[build-queue] Waiting for {holder} to finish building... ({waited}s)")
|
|
151
|
+
|
|
152
|
+
time.sleep(5)
|
|
153
|
+
waited += 5
|
|
154
|
+
|
|
155
|
+
# We hold the lock
|
|
156
|
+
self._write_info(session, target)
|
|
157
|
+
print(f"[build-queue] {session} acquired lock, running: {' '.join(command)}")
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
result = subprocess.run(command)
|
|
161
|
+
exit_code = result.returncode
|
|
162
|
+
except Exception as e:
|
|
163
|
+
print(f"[build-queue] Command failed: {e}", file=sys.stderr)
|
|
164
|
+
exit_code = 1
|
|
165
|
+
finally:
|
|
166
|
+
self._remove()
|
|
167
|
+
|
|
168
|
+
print(f"[build-queue] Build finished (exit {exit_code})")
|
|
169
|
+
return exit_code
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
# CLI
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def main() -> None:
|
|
178
|
+
env = ClaudesEnv.load()
|
|
179
|
+
lock = BuildLock(env.claudes_dir / "build.lock.d")
|
|
180
|
+
|
|
181
|
+
args = sys.argv[1:]
|
|
182
|
+
cmd = args[0] if args else "status"
|
|
183
|
+
|
|
184
|
+
if cmd == "acquire":
|
|
185
|
+
if len(args) < 2:
|
|
186
|
+
print("Usage: build_lock.py acquire <session> <target>", file=sys.stderr)
|
|
187
|
+
sys.exit(1)
|
|
188
|
+
session = args[1]
|
|
189
|
+
target = args[2] if len(args) > 2 else "unknown"
|
|
190
|
+
ok, msg = lock.acquire(session, target)
|
|
191
|
+
print(msg)
|
|
192
|
+
if not ok:
|
|
193
|
+
sys.exit(1)
|
|
194
|
+
|
|
195
|
+
elif cmd == "release":
|
|
196
|
+
if len(args) < 2:
|
|
197
|
+
print("Usage: build_lock.py release <session>", file=sys.stderr)
|
|
198
|
+
sys.exit(1)
|
|
199
|
+
ok, msg = lock.release(args[1])
|
|
200
|
+
print(msg)
|
|
201
|
+
if not ok:
|
|
202
|
+
sys.exit(1)
|
|
203
|
+
|
|
204
|
+
elif cmd == "status":
|
|
205
|
+
print(lock.status())
|
|
206
|
+
|
|
207
|
+
elif cmd == "wrap":
|
|
208
|
+
if len(args) < 3:
|
|
209
|
+
print("Usage: build_lock.py wrap <session> <command...>", file=sys.stderr)
|
|
210
|
+
sys.exit(1)
|
|
211
|
+
session = args[1]
|
|
212
|
+
command = args[2:]
|
|
213
|
+
sys.exit(lock.wrap(session, command))
|
|
214
|
+
|
|
215
|
+
else:
|
|
216
|
+
print("Usage: build_lock.py {acquire|release|status|wrap} [args...]", file=sys.stderr)
|
|
217
|
+
sys.exit(1)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
if __name__ == "__main__":
|
|
221
|
+
main()
|
package/lib/cli.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Entry point for `cnb` when installed via pip/uv.
|
|
2
|
+
|
|
3
|
+
Resolves the bash entry script relative to this file, so it works from
|
|
4
|
+
any working directory after a normal (non-editable) pip install. For
|
|
5
|
+
editable installs that ship only the lib/ directory, fall back to
|
|
6
|
+
running the bash script through the npm-linked Node.js wrapper.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main() -> None:
|
|
15
|
+
claudes_home = Path(__file__).resolve().parent.parent
|
|
16
|
+
entrypoint = claudes_home / "bin" / "cnb"
|
|
17
|
+
|
|
18
|
+
if entrypoint.exists():
|
|
19
|
+
os.execvp("bash", ["bash", str(entrypoint)] + sys.argv[1:])
|
|
20
|
+
|
|
21
|
+
# Fallback for editable installs: try the npm global bin wrapper
|
|
22
|
+
npm_bin = Path("/opt/homebrew/bin/cnb")
|
|
23
|
+
if npm_bin.exists():
|
|
24
|
+
os.execvp(str(npm_bin), [str(npm_bin)] + sys.argv[1:])
|
|
25
|
+
|
|
26
|
+
# Last resort: search PATH
|
|
27
|
+
import shutil
|
|
28
|
+
|
|
29
|
+
found = shutil.which("cnb")
|
|
30
|
+
if found and Path(found).resolve() != Path(__file__).resolve():
|
|
31
|
+
os.execvp(found, [found] + sys.argv[1:])
|
|
32
|
+
|
|
33
|
+
print(f"FATAL: entrypoint not found at {entrypoint}", file=sys.stderr)
|
|
34
|
+
raise SystemExit(1)
|
package/lib/common.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared utilities for cnb Python modules."""
|
|
3
|
+
|
|
4
|
+
import sqlite3
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import Callable, Generator
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Generic, TypeVar
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def find_claudes_dir() -> Path:
|
|
15
|
+
"""Walk up from cwd to find the .claudes/ directory."""
|
|
16
|
+
d = Path.cwd()
|
|
17
|
+
while d != d.parent:
|
|
18
|
+
if (d / ".claudes").is_dir():
|
|
19
|
+
return d / ".claudes"
|
|
20
|
+
d = d.parent
|
|
21
|
+
raise FileNotFoundError(".claudes/ not found")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _parse_toml(path: Path) -> dict:
|
|
25
|
+
"""Parse a simple TOML config file (handles our flat key=value format)."""
|
|
26
|
+
import tomllib
|
|
27
|
+
|
|
28
|
+
return tomllib.loads(path.read_text())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class ClaudesEnv:
|
|
33
|
+
claudes_dir: Path
|
|
34
|
+
project_root: Path
|
|
35
|
+
install_home: Path
|
|
36
|
+
board_db: Path
|
|
37
|
+
sessions_dir: Path
|
|
38
|
+
cv_dir: Path
|
|
39
|
+
log_dir: Path
|
|
40
|
+
prefix: str
|
|
41
|
+
sessions: list[str]
|
|
42
|
+
suspended_file: Path
|
|
43
|
+
attendance_log: Path
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def load(cls) -> "ClaudesEnv":
|
|
47
|
+
cd = find_claudes_dir()
|
|
48
|
+
pr = cd.parent
|
|
49
|
+
config: dict = {}
|
|
50
|
+
|
|
51
|
+
toml_file = cd / "config.toml"
|
|
52
|
+
if toml_file.exists():
|
|
53
|
+
config = _parse_toml(toml_file)
|
|
54
|
+
else:
|
|
55
|
+
print("ERROR: config.toml not found in .claudes/. Run: cnb init <session-names>", flush=True)
|
|
56
|
+
raise SystemExit(1)
|
|
57
|
+
|
|
58
|
+
log_dir = cd / "logs"
|
|
59
|
+
install_home_raw = config.get("claudes_home", str(cd.parent))
|
|
60
|
+
install_home = Path(install_home_raw) if install_home_raw else cd.parent
|
|
61
|
+
return cls(
|
|
62
|
+
claudes_dir=cd,
|
|
63
|
+
project_root=pr,
|
|
64
|
+
install_home=install_home,
|
|
65
|
+
board_db=cd / "board.db",
|
|
66
|
+
sessions_dir=cd / "sessions",
|
|
67
|
+
cv_dir=cd / "cv",
|
|
68
|
+
log_dir=log_dir,
|
|
69
|
+
prefix=config.get("prefix", "cc"),
|
|
70
|
+
sessions=config.get("sessions", []),
|
|
71
|
+
suspended_file=cd / "suspended.list",
|
|
72
|
+
attendance_log=log_dir / "attendance.log",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
PRIVILEGED_ROLES: frozenset[str] = frozenset({"lead", "dispatcher"})
|
|
77
|
+
|
|
78
|
+
TERMINAL_TASK_STATUSES: frozenset[str] = frozenset({"done"})
|
|
79
|
+
TERMINAL_BUG_STATUSES: frozenset[str] = frozenset({"FIXED"})
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def is_privileged(name: str) -> bool:
|
|
83
|
+
"""Return True if *name* is a privileged role (lead/dispatcher)."""
|
|
84
|
+
return name in PRIVILEGED_ROLES
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def is_terminal_task_status(status: str) -> bool:
|
|
88
|
+
"""Return True if *status* is a terminal state (task will not transition further).
|
|
89
|
+
|
|
90
|
+
Guards against operating on dead tasks: inject, assign, nudge, etc.
|
|
91
|
+
"""
|
|
92
|
+
return status in TERMINAL_TASK_STATUSES
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def is_terminal_bug_status(status: str) -> bool:
|
|
96
|
+
"""Return True if *status* is a terminal state (bug is resolved).
|
|
97
|
+
|
|
98
|
+
Guards against reassigning or updating resolved bugs.
|
|
99
|
+
"""
|
|
100
|
+
return status in TERMINAL_BUG_STATUSES
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def sanitize_session_name(name: str) -> str:
|
|
104
|
+
"""Strip path traversal and other filesystem-dangerous characters.
|
|
105
|
+
|
|
106
|
+
Session names become .md filenames in sessions/. This prevents names like
|
|
107
|
+
'../evil' from escaping the sessions directory.
|
|
108
|
+
"""
|
|
109
|
+
return name.replace("/", "_").replace("\\", "_").replace("\0", "")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def ts() -> str:
|
|
113
|
+
"""Current timestamp as 'YYYY-MM-DD HH:MM:SS'."""
|
|
114
|
+
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def is_suspended(name: str, sf: Path) -> bool:
|
|
118
|
+
"""Check if a session name appears in the suspended.list file."""
|
|
119
|
+
return sf.exists() and name in sf.read_text().splitlines()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def date_to_epoch(s: str) -> int:
|
|
123
|
+
"""Parse a date string into a Unix epoch, trying multiple formats."""
|
|
124
|
+
for f in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"):
|
|
125
|
+
try:
|
|
126
|
+
return int(datetime.strptime(s, f).timestamp())
|
|
127
|
+
except ValueError:
|
|
128
|
+
pass
|
|
129
|
+
return 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
# Database wrappers (shared abstract base)
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class BaseDB(ABC):
|
|
138
|
+
"""Abstract base for SQLite wrappers for the cnb board database.
|
|
139
|
+
|
|
140
|
+
Guarantees: new connection per call, WAL mode, parameterized queries.
|
|
141
|
+
Both DB (lightweight) and BoardDB (full-featured) implement this interface.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
@abstractmethod
|
|
145
|
+
def query(self, sql: str, params: tuple[Any, ...] = ()) -> list[sqlite3.Row]:
|
|
146
|
+
"""Execute a SELECT and return all rows."""
|
|
147
|
+
|
|
148
|
+
@abstractmethod
|
|
149
|
+
def scalar(self, sql: str, params: tuple[Any, ...] = ()) -> Any:
|
|
150
|
+
"""Execute a SELECT and return the first column of the first row, or None."""
|
|
151
|
+
|
|
152
|
+
@abstractmethod
|
|
153
|
+
def execute(self, sql: str, params: tuple[Any, ...] = ()) -> int:
|
|
154
|
+
"""Execute a non-SELECT statement and return lastrowid."""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class DB(BaseDB):
|
|
158
|
+
"""Lightweight SQLite wrapper — new connection per call, no pooling.
|
|
159
|
+
|
|
160
|
+
Used by: bin/dispatcher, lib/monitor.py, standalone scripts.
|
|
161
|
+
For board_* modules, use BoardDB (which adds .md file sync helpers).
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def __init__(self, path: Path) -> None:
|
|
165
|
+
self.db_path = path
|
|
166
|
+
|
|
167
|
+
@contextmanager
|
|
168
|
+
def conn(self) -> Generator[sqlite3.Connection, None, None]:
|
|
169
|
+
"""New connection per call. Commits on success, rolls back on exception."""
|
|
170
|
+
c = sqlite3.connect(str(self.db_path))
|
|
171
|
+
c.execute("PRAGMA journal_mode=WAL")
|
|
172
|
+
c.execute("PRAGMA foreign_keys=ON")
|
|
173
|
+
c.row_factory = sqlite3.Row
|
|
174
|
+
try:
|
|
175
|
+
yield c
|
|
176
|
+
c.commit()
|
|
177
|
+
except Exception:
|
|
178
|
+
c.rollback()
|
|
179
|
+
raise
|
|
180
|
+
finally:
|
|
181
|
+
c.close()
|
|
182
|
+
|
|
183
|
+
def query(self, sql: str, params: tuple[Any, ...] = ()) -> list[sqlite3.Row]:
|
|
184
|
+
with self.conn() as c:
|
|
185
|
+
return c.execute(sql, params).fetchall()
|
|
186
|
+
|
|
187
|
+
def scalar(self, sql: str, params: tuple[Any, ...] = ()) -> Any:
|
|
188
|
+
rows = self.query(sql, params)
|
|
189
|
+
return rows[0][0] if rows else None
|
|
190
|
+
|
|
191
|
+
def execute(self, sql: str, params: tuple[Any, ...] = ()) -> int:
|
|
192
|
+
with self.conn() as c:
|
|
193
|
+
return c.execute(sql, params).lastrowid
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
# Signal — inspired by Claude Code's createSignal() (utils/signal.ts)
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
T = TypeVar("T")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class Signal(Generic[T]):
|
|
204
|
+
"""Lightweight pub/sub for pure event notification (no stored state).
|
|
205
|
+
|
|
206
|
+
Distinct from a state store — there is no snapshot, no get_state().
|
|
207
|
+
Subscribers are notified that "something happened", optionally with args.
|
|
208
|
+
|
|
209
|
+
Usage:
|
|
210
|
+
changed = Signal[str]()
|
|
211
|
+
unsub = changed.subscribe(lambda src: print(f"changed: {src}"))
|
|
212
|
+
changed.emit("file_watcher") # prints "changed: file_watcher"
|
|
213
|
+
unsub() # unsubscribed
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
def __init__(self) -> None:
|
|
217
|
+
self._listeners: set[Callable[[T], None]] = set()
|
|
218
|
+
|
|
219
|
+
def subscribe(self, listener: Callable[[T], None]) -> Callable[[], None]:
|
|
220
|
+
"""Register a listener. Returns an unsubscribe function."""
|
|
221
|
+
self._listeners.add(listener)
|
|
222
|
+
|
|
223
|
+
def unsubscribe() -> None:
|
|
224
|
+
self._listeners.discard(listener)
|
|
225
|
+
|
|
226
|
+
return unsubscribe
|
|
227
|
+
|
|
228
|
+
def emit(self, arg: T) -> None:
|
|
229
|
+
"""Notify all listeners with *arg*."""
|
|
230
|
+
for listener in list(self._listeners):
|
|
231
|
+
try:
|
|
232
|
+
listener(arg)
|
|
233
|
+
except Exception:
|
|
234
|
+
pass # keep firing remaining listeners
|
|
235
|
+
|
|
236
|
+
def clear(self) -> None:
|
|
237
|
+
"""Remove all listeners. Use in dispose/reset paths."""
|
|
238
|
+
self._listeners.clear()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
# CLI flag parser
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def parse_flags(
|
|
247
|
+
args: list[str],
|
|
248
|
+
value_flags: dict[str, list[str]] | None = None,
|
|
249
|
+
bool_flags: dict[str, list[str]] | None = None,
|
|
250
|
+
) -> tuple[dict[str, str | bool], list[str]]:
|
|
251
|
+
"""Parse CLI flags from an argument list, returning (flags_dict, positional_args).
|
|
252
|
+
|
|
253
|
+
value_flags: canonical_name -> [aliases...] — flags that consume the next arg as value.
|
|
254
|
+
bool_flags: canonical_name -> [aliases...] — flags that set True when present.
|
|
255
|
+
"""
|
|
256
|
+
value_flags = value_flags or {}
|
|
257
|
+
bool_flags = bool_flags or {}
|
|
258
|
+
|
|
259
|
+
val_lookup: dict[str, str] = {}
|
|
260
|
+
for canonical, aliases in value_flags.items():
|
|
261
|
+
for a in aliases:
|
|
262
|
+
val_lookup[a] = canonical
|
|
263
|
+
|
|
264
|
+
bool_lookup: dict[str, str] = {}
|
|
265
|
+
for canonical, aliases in bool_flags.items():
|
|
266
|
+
for a in aliases:
|
|
267
|
+
bool_lookup[a] = canonical
|
|
268
|
+
|
|
269
|
+
result: dict[str, str | bool] = {}
|
|
270
|
+
positional: list[str] = []
|
|
271
|
+
i = 0
|
|
272
|
+
while i < len(args):
|
|
273
|
+
if args[i] in val_lookup:
|
|
274
|
+
canonical = val_lookup[args[i]]
|
|
275
|
+
if i + 1 >= len(args):
|
|
276
|
+
return result, positional
|
|
277
|
+
result[canonical] = args[i + 1]
|
|
278
|
+
i += 2
|
|
279
|
+
elif args[i] in bool_lookup:
|
|
280
|
+
result[bool_lookup[args[i]]] = True
|
|
281
|
+
i += 1
|
|
282
|
+
else:
|
|
283
|
+
positional.append(args[i])
|
|
284
|
+
i += 1
|
|
285
|
+
return result, positional
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Dispatcher concerns package.
|
|
2
|
+
|
|
3
|
+
Each concern is a self-contained module with its own check interval.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .adaptive_throttle import AdaptiveThrottle
|
|
7
|
+
from .base import Concern
|
|
8
|
+
from .bug_sla_checker import BugSLAChecker
|
|
9
|
+
from .config import DispatcherConfig
|
|
10
|
+
from .coral_manager import CoralManager
|
|
11
|
+
from .coral_poker import CoralPoker
|
|
12
|
+
from .file_watcher import FileWatcher
|
|
13
|
+
from .health_checker import HealthChecker
|
|
14
|
+
from .helpers import log, tmux_ok, warn
|
|
15
|
+
from .idle_detector import IdleDetector
|
|
16
|
+
from .idle_killer import IdleKiller
|
|
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
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"AdaptiveThrottle",
|
|
25
|
+
"BugSLAChecker",
|
|
26
|
+
"Concern",
|
|
27
|
+
"CoralManager",
|
|
28
|
+
"CoralPoker",
|
|
29
|
+
"DispatcherConfig",
|
|
30
|
+
"FileWatcher",
|
|
31
|
+
"HealthChecker",
|
|
32
|
+
"IdleDetector",
|
|
33
|
+
"IdleKiller",
|
|
34
|
+
"IdleNudger",
|
|
35
|
+
"InboxNudger",
|
|
36
|
+
"ResourceMonitor",
|
|
37
|
+
"SessionKeepAlive",
|
|
38
|
+
"TimeAnnouncer",
|
|
39
|
+
"log",
|
|
40
|
+
"tmux_ok",
|
|
41
|
+
"warn",
|
|
42
|
+
]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""AdaptiveThrottle — slow down main loop when CPU is high."""
|
|
2
|
+
|
|
3
|
+
from lib.resources import check_cpu
|
|
4
|
+
|
|
5
|
+
from .base import Concern
|
|
6
|
+
from .helpers import log
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AdaptiveThrottle(Concern):
|
|
10
|
+
interval = 10
|
|
11
|
+
HIGH = 80
|
|
12
|
+
LOW = 60
|
|
13
|
+
MAX_MULT = 4
|
|
14
|
+
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
super().__init__()
|
|
17
|
+
self.multiplier: int = 1
|
|
18
|
+
|
|
19
|
+
def tick(self, now: int) -> None:
|
|
20
|
+
cpu = check_cpu()
|
|
21
|
+
if cpu.usage > self.HIGH and self.multiplier < self.MAX_MULT:
|
|
22
|
+
self.multiplier = min(self.multiplier * 2, self.MAX_MULT)
|
|
23
|
+
log(f"THROTTLE: CPU={cpu.usage}% > {self.HIGH}%, interval x{self.multiplier}")
|
|
24
|
+
elif cpu.usage < self.LOW and self.multiplier > 1:
|
|
25
|
+
self.multiplier = 1
|
|
26
|
+
log(f"THROTTLE: CPU={cpu.usage}% < {self.LOW}%, restored normal interval")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Base Concern class for dispatcher concerns."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Concern:
|
|
5
|
+
"""Base class for all dispatcher concerns.
|
|
6
|
+
|
|
7
|
+
Each concern has an independent check interval and a tick() method
|
|
8
|
+
that performs the actual work.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
interval: int = 5
|
|
12
|
+
|
|
13
|
+
def __init__(self) -> None:
|
|
14
|
+
self.last_tick: int = 0
|
|
15
|
+
|
|
16
|
+
def should_tick(self, now: int) -> bool:
|
|
17
|
+
return (now - self.last_tick) >= self.interval
|
|
18
|
+
|
|
19
|
+
def tick(self, now: int) -> None:
|
|
20
|
+
raise NotImplementedError
|
|
21
|
+
|
|
22
|
+
def maybe_tick(self, now: int) -> None:
|
|
23
|
+
if self.should_tick(now):
|
|
24
|
+
self.tick(now)
|
|
25
|
+
self.last_tick = now
|
|
@@ -0,0 +1,32 @@
|
|
|
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}")
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Configuration object passed to all concerns as dependency injection."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class DispatcherConfig:
|
|
9
|
+
"""Holds all paths and settings that concerns need."""
|
|
10
|
+
|
|
11
|
+
prefix: str
|
|
12
|
+
project_root: Path
|
|
13
|
+
claudes_dir: Path
|
|
14
|
+
sessions_dir: Path
|
|
15
|
+
board_db: Path
|
|
16
|
+
suspended_file: Path
|
|
17
|
+
board_sh: str
|
|
18
|
+
coral_sess: str
|
|
19
|
+
dispatcher_session: str
|
|
20
|
+
log_dir: Path
|
|
21
|
+
okr_dir: Path
|
|
22
|
+
dev_sessions: list[str] = field(default_factory=list)
|