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.
Files changed (65) hide show
  1. package/LICENSE +38 -0
  2. package/Makefile +60 -0
  3. package/README.md +63 -0
  4. package/VERSION +1 -0
  5. package/bin/_pip_entry.py +25 -0
  6. package/bin/board +287 -0
  7. package/bin/cnb +150 -0
  8. package/bin/cnb.js +33 -0
  9. package/bin/dispatcher +151 -0
  10. package/bin/dispatcher-watchdog +57 -0
  11. package/bin/doctor +328 -0
  12. package/bin/init +316 -0
  13. package/bin/registry +347 -0
  14. package/bin/swarm +896 -0
  15. package/lib/__init__.py +1 -0
  16. package/lib/board_admin.py +128 -0
  17. package/lib/board_bbs.py +99 -0
  18. package/lib/board_bug.py +161 -0
  19. package/lib/board_db.py +262 -0
  20. package/lib/board_lock.py +113 -0
  21. package/lib/board_mailbox.py +145 -0
  22. package/lib/board_maintenance.py +237 -0
  23. package/lib/board_msg.py +230 -0
  24. package/lib/board_task.py +200 -0
  25. package/lib/board_view.py +366 -0
  26. package/lib/board_vote.py +164 -0
  27. package/lib/build_lock.py +221 -0
  28. package/lib/cli.py +34 -0
  29. package/lib/common.py +285 -0
  30. package/lib/concerns/__init__.py +42 -0
  31. package/lib/concerns/adaptive_throttle.py +26 -0
  32. package/lib/concerns/base.py +25 -0
  33. package/lib/concerns/bug_sla_checker.py +32 -0
  34. package/lib/concerns/config.py +22 -0
  35. package/lib/concerns/coral_manager.py +61 -0
  36. package/lib/concerns/coral_poker.py +57 -0
  37. package/lib/concerns/file_watcher.py +127 -0
  38. package/lib/concerns/health_checker.py +72 -0
  39. package/lib/concerns/helpers.py +152 -0
  40. package/lib/concerns/idle_detector.py +56 -0
  41. package/lib/concerns/idle_killer.py +41 -0
  42. package/lib/concerns/idle_nudger.py +38 -0
  43. package/lib/concerns/inbox_nudger.py +34 -0
  44. package/lib/concerns/resource_monitor.py +47 -0
  45. package/lib/concerns/session_keepalive.py +23 -0
  46. package/lib/concerns/time_announcer.py +34 -0
  47. package/lib/crypto.py +92 -0
  48. package/lib/health.py +187 -0
  49. package/lib/inject.py +164 -0
  50. package/lib/migrate.py +109 -0
  51. package/lib/monitor.py +373 -0
  52. package/lib/panel.py +137 -0
  53. package/lib/resources.py +341 -0
  54. package/migrations/001_foreign_keys.sql +77 -0
  55. package/migrations/002_session_persona.sql +1 -0
  56. package/migrations/003_mailbox.sql +9 -0
  57. package/package.json +28 -0
  58. package/pyproject.toml +71 -0
  59. package/registry/0001-meridian.json +12 -0
  60. package/registry/0002-forge.json +12 -0
  61. package/registry/0003-lead.json +12 -0
  62. package/registry/0004-ms-encrypted-mailbox-live.json +12 -0
  63. package/registry/GENESIS.json +9 -0
  64. package/registry/pubkeys.json +5 -0
  65. 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)