diff --git a/CHANGELOG.md b/CHANGELOG.md index 813ee87..4fe3d28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.5.83-dev (unreleased) + +### Features + +- **Dispatcher keeps the lead session alive (#223)** — Added `get_lead_session()` helper and routed the `lead` session through `NudgeCoordinator._process_lead_session()`. Lead gets its own idle copy (`检查团队状态 / 处理 inbox / 看 PR / 主动分派下一批活`) rather than the employee OKR prompt — employees idling is normal (they wait for orders), but lead idling stalls the whole team. Inbox/queued-flush nudges still take priority over the lead-idle nudge. + ## 0.5.76-dev (unreleased) ### Bug Fixes diff --git a/VERSION b/VERSION index 4cac581..6502228 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.76-dev +0.5.83-dev diff --git a/lib/concerns/helpers.py b/lib/concerns/helpers.py index 4839b67..15b02d5 100644 --- a/lib/concerns/helpers.py +++ b/lib/concerns/helpers.py @@ -9,7 +9,7 @@ from lib.board_db import BoardDB from lib.tmux_utils import is_agent_running as is_claude_running # noqa: F401 — re-export from lib.tmux_utils import ( - tmux_ok, # noqa: F401 — re-export for concerns + tmux_ok, tmux_run, ) from lib.tmux_utils import tmux_send as _tmux_send_raw @@ -45,6 +45,13 @@ def tmux_send(sess: str, text: str) -> bool: def get_dev_sessions(cfg: DispatcherConfig) -> list[str]: + """List non-lead, non-infra tmux sessions ("员工" tongxue). + + `dispatcher` is the dispatcher's own session and is always excluded. + `lead` is excluded from this list because dispatcher must nudge it on a + different cadence and with different copy than employees — see + `get_lead_session` and `NudgeCoordinator._process_lead_session` (#223). + """ raw = tmux_run("list-sessions", "-F", "#{session_name}") if not raw: return [] @@ -53,6 +60,17 @@ def get_dev_sessions(cfg: DispatcherConfig) -> list[str]: return [line[len(pfx) :] for line in raw.splitlines() if line.startswith(pfx) and line[len(pfx) :] not in protected] +def get_lead_session(cfg: DispatcherConfig) -> str | None: + """Return the lead session name if a `{prefix}-lead` tmux session exists. + + Returned name is bare (`"lead"`), not the full tmux session. Callers + construct the tmux session with `f"{cfg.prefix}-{name}"` as elsewhere. + """ + if not tmux_ok("has-session", "-t", f"{cfg.prefix}-lead"): + return None + return "lead" + + def pane_md5(sess: str) -> str: content = tmux_run("capture-pane", "-t", sess, "-p") or "" return hashlib.md5(content.encode()).hexdigest() diff --git a/lib/concerns/nudge_coordinator.py b/lib/concerns/nudge_coordinator.py index b1a5294..320972e 100644 --- a/lib/concerns/nudge_coordinator.py +++ b/lib/concerns/nudge_coordinator.py @@ -13,7 +13,7 @@ from .base import Concern from .config import DispatcherConfig -from .helpers import db, get_dev_sessions, is_claude_running, log, tmux, tmux_ok, tmux_send +from .helpers import db, get_dev_sessions, get_lead_session, is_claude_running, log, tmux, tmux_ok, tmux_send @dataclass @@ -128,6 +128,26 @@ def _try_idle(self, name: str) -> bool: ) return True + def _try_lead_idle(self, name: str) -> bool: + """Lead-specific idle nudge (#223). + + Employees idling waiting for orders is normal; lead idling is not — + nobody is dispatching work. Use copy that tells lead to look at the + team and assign, not to "continue their own work". + """ + sess = f"{self.cfg.prefix}-{name}" + if not self.idle.is_idle(sess): + return False + if _already_queued(sess, "分派下一批活"): + return False + tmux_send( + sess, + "检查团队状态:跑 `cnb board --as lead view` 看谁空、谁卡住;" + "处理你的 inbox;扫一遍 open PR 看需要谁 review 或 rebase;" + "主动分派下一批活给空闲同学。不要等。", + ) + return True + def get_nudge_stats(self, name: str) -> dict: rec = self._records.get(name) if not rec: @@ -159,6 +179,29 @@ def _process_session(self, name: str, now: int) -> None: self._record(name, nudge_type, now) break + def _process_lead_session(self, now: int) -> None: + """Process the lead session with lead-specific idle copy (#223).""" + name = "lead" + if is_suspended(name, self.cfg.suspended_file): + return + if not self._session_ready(name, now): + return + + if name in self._records: + self._check_effectiveness(name) + + if not self._can_nudge(name, now): + return + + for nudge_type, try_fn in [ + ("inbox", self._try_inbox), + ("flush", self._try_queued_flush), + ("lead_idle", self._try_lead_idle), + ]: + if try_fn(name): + self._record(name, nudge_type, now) + break + def check_session(self, name: str, now: int) -> None: """Check and nudge a specific session immediately.""" self._process_session(name, now) @@ -166,3 +209,5 @@ def check_session(self, name: str, now: int) -> None: def tick(self, now: int) -> None: for name in get_dev_sessions(self.cfg): self._process_session(name, now) + if get_lead_session(self.cfg): + self._process_lead_session(now) diff --git a/package.json b/package.json index e7a71d4..cb4ed8c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-nb", - "version": "0.5.76-dev", + "version": "0.5.83-dev", "description": "Multi-agent coordination framework for Claude Code sessions", "engines": { "node": ">=18" diff --git a/pyproject.toml b/pyproject.toml index b8d9589..4601ea2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "claude-nb" -version = "0.5.76.dev0" +version = "0.5.83.dev0" description = "Multi-agent coordination framework for Claude Code sessions" requires-python = ">=3.11" license = "MIT" diff --git a/tests/test_concern_helpers.py b/tests/test_concern_helpers.py index ec7efeb..a76e94c 100644 --- a/tests/test_concern_helpers.py +++ b/tests/test_concern_helpers.py @@ -9,6 +9,7 @@ from lib.concerns.helpers import ( board_send, get_dev_sessions, + get_lead_session, has_tool_process, is_claude_running, is_pane_typing, @@ -203,6 +204,24 @@ def test_returns_empty_when_tmux_fails(self, mock_tmux, tmp_path): assert get_dev_sessions(cfg) == [] +# =========================================================================== +# get_lead_session() +# =========================================================================== + + +class TestGetLeadSession: + @patch("lib.concerns.helpers.tmux_ok", return_value=True) + def test_returns_lead_when_session_exists(self, mock_ok, tmp_path): + cfg = make_cfg(tmp_path) + assert get_lead_session(cfg) == "lead" + mock_ok.assert_called_once_with("has-session", "-t", "cc-test-lead") + + @patch("lib.concerns.helpers.tmux_ok", return_value=False) + def test_returns_none_when_session_missing(self, mock_ok, tmp_path): + cfg = make_cfg(tmp_path) + assert get_lead_session(cfg) is None + + # =========================================================================== # pane_md5() # =========================================================================== diff --git a/tests/test_nudge_coordinator.py b/tests/test_nudge_coordinator.py index 205afb9..2792422 100644 --- a/tests/test_nudge_coordinator.py +++ b/tests/test_nudge_coordinator.py @@ -453,6 +453,123 @@ def scalar(self, *_args): assert mock_send.call_count >= 1, "fresh prompt — nudge must still fire" +class TestLeadKeepAlive: + """#223: dispatcher must nudge the lead session when it's idle, using + lead-specific copy ("dispatch work") rather than the employee OKR copy.""" + + @patch("lib.concerns.nudge_coordinator.tmux_ok", return_value=True) + @patch("lib.concerns.nudge_coordinator.is_claude_running", return_value=True) + @patch("lib.concerns.nudge_coordinator.tmux_send", return_value=True) + @patch("lib.concerns.nudge_coordinator.get_dev_sessions", return_value=[]) + @patch("lib.concerns.nudge_coordinator.get_lead_session", return_value="lead") + @patch("lib.concerns.nudge_coordinator.tmux", return_value="some output\n❯\n") + def test_idle_lead_gets_lead_specific_nudge( + self, _tmux, _lead, _devs, mock_send, _running, _ok, NudgeCoordinator, tmp_path + ): + cfg = make_cfg(tmp_path, []) + idle = make_idle({"cc-test-lead"}) + coord = NudgeCoordinator(cfg, idle) + + with patch("lib.concerns.nudge_coordinator.db") as mock_db: + mock_db.return_value.scalar.return_value = 0 # no unread → falls to idle + coord.tick(1000) + + assert mock_send.call_count == 1, "lead idle should nudge exactly once" + sess, text = mock_send.call_args[0] + assert sess == "cc-test-lead" + # Lead copy must talk about dispatching to the team, not about the + # lead's "own OKR". + assert "团队" in text + assert "分派" in text + assert "OKR" not in text, "lead nudge must not reuse employee OKR copy" + + @patch("lib.concerns.nudge_coordinator.tmux_ok", return_value=True) + @patch("lib.concerns.nudge_coordinator.is_claude_running", return_value=True) + @patch("lib.concerns.nudge_coordinator.tmux_send", return_value=True) + @patch("lib.concerns.nudge_coordinator.get_dev_sessions", return_value=[]) + @patch("lib.concerns.nudge_coordinator.get_lead_session", return_value="lead") + @patch("lib.concerns.nudge_coordinator.tmux", return_value="some output\n❯ working\n") + def test_active_lead_is_not_nudged(self, _tmux, _lead, _devs, mock_send, _running, _ok, NudgeCoordinator, tmp_path): + cfg = make_cfg(tmp_path, []) + idle = make_idle(set()) # lead is NOT idle + coord = NudgeCoordinator(cfg, idle) + + with patch("lib.concerns.nudge_coordinator.db") as mock_db: + mock_db.return_value.scalar.return_value = 0 + coord.tick(1000) + + assert mock_send.call_count == 0, "active lead must not be nudged" + + @patch("lib.concerns.nudge_coordinator.tmux_ok", return_value=True) + @patch("lib.concerns.nudge_coordinator.is_claude_running", return_value=True) + @patch("lib.concerns.nudge_coordinator.tmux_send", return_value=True) + @patch("lib.concerns.nudge_coordinator.get_dev_sessions", return_value=[]) + @patch("lib.concerns.nudge_coordinator.get_lead_session", return_value=None) + def test_no_lead_session_no_nudge(self, _lead, _devs, mock_send, _running, _ok, NudgeCoordinator, tmp_path): + cfg = make_cfg(tmp_path, []) + idle = make_idle({"cc-test-lead"}) # would be idle if it existed + coord = NudgeCoordinator(cfg, idle) + + coord.tick(1000) + + assert mock_send.call_count == 0, "without a lead session there is nothing to nudge" + + @patch("lib.concerns.nudge_coordinator.tmux_ok", return_value=True) + @patch("lib.concerns.nudge_coordinator.is_claude_running", return_value=True) + @patch("lib.concerns.nudge_coordinator.tmux_send", return_value=True) + @patch("lib.concerns.nudge_coordinator.get_dev_sessions", return_value=[]) + @patch("lib.concerns.nudge_coordinator.get_lead_session", return_value="lead") + def test_lead_unread_inbox_takes_priority_over_idle( + self, _lead, _devs, mock_send, _running, _ok, NudgeCoordinator, tmp_path, monkeypatch + ): + cfg = make_cfg(tmp_path, []) + from lib.concerns import nudge_coordinator as nc + + class _FakeDB: + def scalar(self, *_args): + return 5 # lead has 5 unread + + monkeypatch.setattr(nc, "db", lambda _cfg: _FakeDB()) + monkeypatch.setattr(nc, "tmux", lambda *args: "❯\n" if args[0] == "capture-pane" else "") + idle = make_idle({"cc-test-lead"}) + coord = NudgeCoordinator(cfg, idle) + + coord.tick(1000) + + assert mock_send.call_count == 1 + sent = mock_send.call_args[0][1] + # Inbox nudge wins over lead-idle nudge. + assert "inbox" in sent + assert "分派" not in sent + + @patch("lib.concerns.nudge_coordinator.tmux_ok", return_value=True) + @patch("lib.concerns.nudge_coordinator.is_claude_running", return_value=True) + @patch("lib.concerns.nudge_coordinator.tmux_send", return_value=True) + @patch("lib.concerns.nudge_coordinator.get_dev_sessions", return_value=[]) + @patch("lib.concerns.nudge_coordinator.get_lead_session", return_value="lead") + def test_lead_idle_skipped_when_already_queued( + self, _lead, _devs, mock_send, _running, _ok, NudgeCoordinator, tmp_path, monkeypatch + ): + cfg = make_cfg(tmp_path, []) + from lib.concerns import nudge_coordinator as nc + + monkeypatch.setattr( + nc, + "tmux", + lambda *args: ( + "❯ 检查团队状态:... 主动分派下一批活给空闲同学。不要等。\n" if args[0] == "capture-pane" else "" + ), + ) + idle = make_idle({"cc-test-lead"}) + coord = NudgeCoordinator(cfg, idle) + + with patch("lib.concerns.nudge_coordinator.db") as mock_db: + mock_db.return_value.scalar.return_value = 0 + coord.tick(1000) + + assert mock_send.call_count == 0, "must not re-stuff lead idle prompt already at prompt" + + class TestStructure: def test_is_concern_subclass(self, NudgeCoordinator): assert issubclass(NudgeCoordinator, Concern)