feat(@projects/@clare): ✨ add remote control session registration
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
da2b27e7b8
commit
d698cd6430
3 changed files with 164 additions and 25 deletions
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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-<user>-<slug>-<epoch>`),
|
||||
# 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:
|
||||
|
|
|
|||
95
tests/test_pull_liveness.py
Normal file
95
tests/test_pull_liveness.py
Normal file
|
|
@ -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"
|
||||
Loading…
Add table
Reference in a new issue