From d698cd64304b9f51a0241cfe58f9ef6f8087c4fa Mon Sep 17 00:00:00 2001 From: Natalie Date: Wed, 20 May 2026 18:46:20 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@clare):=20=E2=9C=A8=20add=20rem?= =?UTF-8?q?ote=20control=20session=20registration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/clare/orchestrator/bootstrap.py | 33 ++++++++-- src/clare/pull.py | 61 +++++++++++------- tests/test_pull_liveness.py | 95 +++++++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 25 deletions(-) create mode 100644 tests/test_pull_liveness.py diff --git a/src/clare/orchestrator/bootstrap.py b/src/clare/orchestrator/bootstrap.py index dbeedaa..1949683 100644 --- a/src/clare/orchestrator/bootstrap.py +++ b/src/clare/orchestrator/bootstrap.py @@ -295,10 +295,10 @@ def _send_kick(*, rcl: Rclaude, cwd: str) -> None: """ rcl.send( text=( - "[bootstrap] You are Clare's orchestrator. Read CLAUDE.md in your " - "cwd, confirm your MCP tools (clare:*) are available via `/mcp`, " - "then run `/remote-control clare` so you appear in claude.ai/code " - "as the central agent. Reply with a single word: ready." + "[bootstrap] You are Clare's orchestrator. Read CLAUDE.md in " + "your cwd — it defines your role and per-turn workflow. Your " + "clare:* MCP tools are already wired. Reply with a single " + "word: ready." ), match=_cwd_slug(cwd), yes=True, @@ -306,6 +306,24 @@ def _send_kick(*, rcl: Rclaude, cwd: str) -> None: ) +def _register_remote_control(*, rcl: Rclaude, cwd: str) -> None: + """Register the orchestrator session with claude.ai/code. + + `/remote-control` is a Claude *slash command* — it only takes effect + delivered as literal input, never from prose. The bootstrap kick is a + prose message (Claude cannot self-invoke a slash command from message + text), so the command itself is delivered as a separate send. This is + what makes the orchestrator appear as a remote session the user can + drive from the browser / claude.ai/code. + """ + rcl.send( + text="/remote-control clare", + match=_cwd_slug(cwd), + yes=True, + dry_run=False, + ) + + def _stage_remote_workspace(local_staging: Path, host: str, remote_cwd: str) -> None: """rsync the local staging dir to `host:remote_cwd`. Idempotent.""" import subprocess @@ -473,6 +491,13 @@ def ensure_running( write_session_uuid( new_uuid, host=cfg.orchestrator.host, config_path=config_path, ) + # Register with claude.ai/code so the user can drive the orchestrator + # from the browser. Non-fatal — it still works via Clare's chat + # regardless of whether remote-control registration succeeds. + try: + _register_remote_control(rcl=rcl, cwd=effective_cwd) + except RclaudeError: + pass return new_uuid diff --git a/src/clare/pull.py b/src/clare/pull.py index 03b6711..3d4100e 100644 --- a/src/clare/pull.py +++ b/src/clare/pull.py @@ -17,6 +17,7 @@ from __future__ import annotations import re as _re import sqlite3 +from collections import Counter from dataclasses import dataclass from datetime import datetime, timezone from uuid import UUID @@ -128,11 +129,18 @@ def pull( ) sessions_observed += 1 - # --- Liveness: mark sessions whose host+cwd has no live tmux pane ---- - # Without this, agent_status / list_fleet show stale rows for sessions - # whose tmux died long ago. The on-disk JSONL persists forever so - # session UUIDs alone don't tell us what's actually running. Compare - # the current rclaude tmux roster against the projection. + # --- Liveness: mark sessions backed by a live tmux pane -------------- + # The on-disk JSONL persists forever, so a session UUID says nothing + # about what's running. We match against the live tmux roster. + # + # tmux names carry only the cwd slug (`claude---`), + # never the session UUID — so a pane can't be mapped to an exact + # session. The old code marked EVERY session at a live cwd as alive, + # which over-counted badly: a busy workspace with one live pane and + # 100 historical JSONLs reported 100 "alive". Instead, per + # (host, cwd-slug) with live pane(s), mark only the N most-recently + # touched sessions alive, where N = the number of live panes there. + # `sessions_alive` then tracks the real count of running panes. try: tmux_rows = rclaude.list_tmux() except RclaudeError as exc: @@ -151,29 +159,40 @@ def pull( m = _TMUX_NAME.match(name) return m.group(1) if m else "" - alive_keys: set[tuple[str, str]] = { + # How many live panes exist per (host, cwd-slug). + live_pane_counts: Counter[tuple[str, str]] = Counter( (r.host, _tmux_cwd_slug(r.session_name)) for r in tmux_rows if _tmux_cwd_slug(r.session_name) - } + ) db_sessions = conn.execute( - "SELECT uuid, host, cwd FROM sessions" + "SELECT uuid, host, cwd, last_seen_mtime FROM sessions" ).fetchall() + # Group sessions by the workspace key their tmux pane would carry. + by_workspace: dict[tuple[str, str], list[sqlite3.Row]] = {} + for row in db_sessions: + by_workspace.setdefault( + (row["host"], _slug(row["cwd"] or "")), [] + ).append(row) + sessions_closed = 0 sessions_alive = 0 - for row in db_sessions: - slug = _slug(row["cwd"] or "") - # Exact match against an extracted tmux cwd slug on the same host. - is_alive = slug != "" and (row["host"], slug) in alive_keys - new_liveness = "alive" if is_alive else "closed" - conn.execute( - "UPDATE sessions SET liveness = ? WHERE uuid = ? AND liveness != ?", - (new_liveness, row["uuid"], new_liveness), - ) - if is_alive: - sessions_alive += 1 - else: - sessions_closed += 1 + for (host, slug), rows in by_workspace.items(): + n_live = live_pane_counts.get((host, slug), 0) if slug else 0 + # Freshest-first: the N newest sessions at a live workspace are the + # ones plausibly attached to its N live panes. + rows.sort(key=lambda r: r["last_seen_mtime"] or "", reverse=True) + for idx, row in enumerate(rows): + is_alive = idx < n_live + new_liveness = "alive" if is_alive else "closed" + conn.execute( + "UPDATE sessions SET liveness = ? WHERE uuid = ? AND liveness != ?", + (new_liveness, row["uuid"], new_liveness), + ) + if is_alive: + sessions_alive += 1 + else: + sessions_closed += 1 # --- Triage ----------------------------------------------------------- try: diff --git a/tests/test_pull_liveness.py b/tests/test_pull_liveness.py new file mode 100644 index 0000000..835cf2b --- /dev/null +++ b/tests/test_pull_liveness.py @@ -0,0 +1,95 @@ +"""Liveness pass: `sessions_alive` tracks live tmux panes, not disk JSONLs. + +Regression for the over-count bug — the old pass marked *every* session at +a cwd with any live pane as `alive`, so a busy workspace with one live pane +and N historical session JSONLs reported N "alive". The fix ranks sessions +by recency per (host, cwd-slug) and marks only the freshest N, where N is +the number of live panes at that workspace. +""" + +from __future__ import annotations + +from uuid import UUID + +from clare.pull import pull +from clare.rclaude import SessionRow, TmuxRow + +_CWD = "/var/home/lilith/Code/@projects/@clare" +_SLUG = "var-home-lilith-Code--projects--clare" + + +class _FakeRclaude: + def __init__(self, sessions: list[SessionRow], tmux: list[TmuxRow]): + self._sessions = sessions + self._tmux = tmux + + def list_sessions(self) -> list[SessionRow]: + return list(self._sessions) + + def list_tmux(self) -> list[TmuxRow]: + return list(self._tmux) + + def triage(self) -> list: + return [] + + +def _session(uuid_hex: str, mtime: int) -> SessionRow: + return SessionRow( + host="apricot", uuid=UUID(uuid_hex), snippet="x", cwd=_CWD, mtime_epoch=mtime, + ) + + +def _liveness(conn, uuid_hex: str) -> str: + return conn.execute( + "SELECT liveness FROM sessions WHERE uuid = ?", (uuid_hex,) + ).fetchone()["liveness"] + + +def test_one_live_pane_marks_only_freshest_session_alive(conn, gen) -> None: + """3 disk sessions at one workspace, 1 live pane → only the newest alive.""" + old = "11111111-1111-1111-1111-111111111111" + mid = "22222222-2222-2222-2222-222222222222" + new = "33333333-3333-3333-3333-333333333333" + fake = _FakeRclaude( + sessions=[ + _session(old, 1_700_000_000), + _session(mid, 1_700_000_500), + _session(new, 1_700_001_000), + ], + tmux=[TmuxRow(host="apricot", session_name=f"claude-natalie-{_SLUG}-1779326883", detail="")], + ) + stats = pull(conn, gen, rclaude=fake) + assert stats.sessions_alive == 1 + assert stats.sessions_closed == 2 + assert _liveness(conn, new) == "alive" + assert _liveness(conn, mid) == "closed" + assert _liveness(conn, old) == "closed" + + +def test_alive_count_equals_live_pane_count(conn, gen) -> None: + """4 disk sessions, 2 live panes → the 2 freshest are alive.""" + uuids = [f"{i}{i}{i}{i}{i}{i}{i}{i}-0000-0000-0000-000000000000"[:36] for i in range(4)] + fake = _FakeRclaude( + sessions=[_session(u, 1_700_000_000 + i * 100) for i, u in enumerate(uuids)], + tmux=[ + TmuxRow(host="apricot", session_name=f"claude-natalie-{_SLUG}-1779320000", detail=""), + TmuxRow(host="apricot", session_name=f"claude-natalie-{_SLUG}-1779320001", detail=""), + ], + ) + stats = pull(conn, gen, rclaude=fake) + assert stats.sessions_alive == 2 + assert stats.sessions_closed == 2 + # Freshest two (indices 3, 2) alive; oldest two (1, 0) closed. + assert _liveness(conn, uuids[3]) == "alive" + assert _liveness(conn, uuids[2]) == "alive" + assert _liveness(conn, uuids[1]) == "closed" + assert _liveness(conn, uuids[0]) == "closed" + + +def test_no_live_pane_marks_all_closed(conn, gen) -> None: + """A workspace with disk sessions but no live pane → all closed.""" + u = "44444444-4444-4444-4444-444444444444" + fake = _FakeRclaude(sessions=[_session(u, 1_700_000_000)], tmux=[]) + stats = pull(conn, gen, rclaude=fake) + assert stats.sessions_alive == 0 + assert _liveness(conn, u) == "closed"