Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.5.76-dev
0.5.83-dev
20 changes: 19 additions & 1 deletion lib/concerns/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 []
Expand All @@ -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()
Expand Down
47 changes: 46 additions & 1 deletion lib/concerns/nudge_coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -159,10 +179,35 @@ 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)

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)
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
19 changes: 19 additions & 0 deletions tests/test_concern_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
# ===========================================================================
Expand Down
117 changes: 117 additions & 0 deletions tests/test_nudge_coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading