feat(@projects/@claire): ✨ improve session targeting for direct sends
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
f7277f6619
commit
eb382ff1c6
6 changed files with 144 additions and 9 deletions
|
|
@ -400,18 +400,42 @@ def send_to_session(
|
|||
session_ref: str,
|
||||
text: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Send text directly to one session, bypassing project-broadcast."""
|
||||
"""Send text directly to one session, bypassing project-broadcast.
|
||||
|
||||
Targets the session by its `tmux_name`: `rclaude send --match` matches
|
||||
tmux *session names*, never Claude session UUIDs, so passing a UUID
|
||||
there silently matches nothing. The session's `tmux_name` is populated
|
||||
by the pull loop (for resumed sessions) or by `dispatch_task` (for
|
||||
freshly-spawned ones). If it's still unknown, fail loudly rather than
|
||||
fire a send that would match nothing.
|
||||
"""
|
||||
try:
|
||||
sid = service.resolve_session_id(conn, session_ref)
|
||||
except (service.NotFound, service.InvalidInput) as exc:
|
||||
raise ToolError(str(exc)) from exc
|
||||
session = read.get_session(conn, sid)
|
||||
if session is None:
|
||||
raise ToolError(
|
||||
f"session {sid} has no projection row yet — run `pull` first so "
|
||||
f"its tmux_name can be resolved"
|
||||
)
|
||||
if not session.tmux_name:
|
||||
raise ToolError(
|
||||
f"session {sid} has no known tmux_name — it has not been observed "
|
||||
f"as a live tmux pane (run `pull`, or the session may not be "
|
||||
f"running). `rclaude --match` cannot target a session by UUID."
|
||||
)
|
||||
from ..rclaude import Rclaude, RclaudeError
|
||||
rcl = Rclaude()
|
||||
try:
|
||||
rcl.send(text=text, match=str(sid)[:8], yes=True, dry_run=False)
|
||||
rcl.send(text=text, match=session.tmux_name, yes=True, dry_run=False)
|
||||
except RclaudeError as exc:
|
||||
raise ToolError(f"rclaude send failed: {exc}") from exc
|
||||
return {"session_uuid": str(sid), "delivered": True}
|
||||
return {
|
||||
"session_uuid": str(sid),
|
||||
"tmux_name": session.tmux_name,
|
||||
"delivered": True,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -52,6 +52,12 @@ def _session_snapshot(
|
|||
return {UUID(r["uuid"]): (r["host"], r["cwd"], r["last_seen_mtime"]) for r in rows}
|
||||
|
||||
|
||||
def _tmux_name_snapshot(conn: sqlite3.Connection) -> dict[UUID, str | None]:
|
||||
"""Return {uuid: tmux_name} from the sessions table — for the tmux-mapping diff."""
|
||||
rows = conn.execute("SELECT uuid, tmux_name FROM sessions").fetchall()
|
||||
return {UUID(r["uuid"]): r["tmux_name"] for r in rows}
|
||||
|
||||
|
||||
def _triage_snapshot(
|
||||
conn: sqlite3.Connection,
|
||||
) -> dict[UUID, tuple[int | None, str | None, str | None]]:
|
||||
|
|
@ -146,6 +152,42 @@ def pull(
|
|||
except RclaudeError as exc:
|
||||
errors.append(f"list_tmux: {exc}")
|
||||
tmux_rows = []
|
||||
|
||||
# --- UUID → tmux_name mapping -----------------------------------------
|
||||
# A tmux pane started via `claude --resume <uuid>` carries that UUID in
|
||||
# its `resumed_uuid` column. That's the only signal that resolves a
|
||||
# *specific* session UUID to a live tmux session name (the tmux name
|
||||
# itself encodes only the cwd slug). Without it, `send_to_session` can't
|
||||
# target a session — `rclaude --match` matches tmux names, never UUIDs.
|
||||
# Emit a SessionObserved carrying `tmux_name` so the projection's
|
||||
# `sessions.tmux_name` column gets populated. Diff against the current
|
||||
# projection so we only emit when the mapping actually changed; only set
|
||||
# tmux_name when known (the COALESCE in the projection never clobbers a
|
||||
# known name with None).
|
||||
tmux_name_snap = _tmux_name_snapshot(conn)
|
||||
for trow in tmux_rows:
|
||||
if not trow.resumed_uuid:
|
||||
continue
|
||||
try:
|
||||
mapped_uuid = UUID(trow.resumed_uuid)
|
||||
except ValueError:
|
||||
errors.append(f"list_tmux: bad resumed_uuid {trow.resumed_uuid!r}")
|
||||
continue
|
||||
if tmux_name_snap.get(mapped_uuid) == trow.session_name:
|
||||
continue
|
||||
events.append(
|
||||
conn,
|
||||
generator,
|
||||
events.SessionObserved(
|
||||
session_uuid=mapped_uuid,
|
||||
host=trow.host,
|
||||
cwd=None,
|
||||
tmux_name=trow.session_name,
|
||||
last_seen_mtime=None,
|
||||
),
|
||||
)
|
||||
sessions_observed += 1
|
||||
|
||||
# Slug used in tmux names mirrors rclaude's: leading slashes/tildes
|
||||
# stripped, non-alphanumeric → '-'.
|
||||
def _slug(s: str) -> str:
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ class TmuxRow:
|
|||
host: str
|
||||
session_name: str
|
||||
detail: str
|
||||
# The Claude session UUID this tmux pane was started with via
|
||||
# `claude --resume <uuid>`. `None` for fresh-spawned panes (no --resume)
|
||||
# or for output from an older rclaude that doesn't emit the 5th column.
|
||||
resumed_uuid: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -144,7 +148,9 @@ class Rclaude:
|
|||
def list_tmux(self) -> list[TmuxRow]:
|
||||
"""Cross-host live tmux sessions via `rclaude list tmux --tsv`.
|
||||
|
||||
Raw TSV row shape: `host\\ttmux\\tsession_name\\tdetail`.
|
||||
Raw TSV row shape: `host\\ttmux\\tsession_name\\tdetail\\tresumed_uuid`.
|
||||
The 5th column (`resumed_uuid`) is additive — rows from an older
|
||||
rclaude with only 4 columns are tolerated, mapping to `None`.
|
||||
"""
|
||||
raw = self._run(["list", "tmux", "--tsv"])
|
||||
rows: list[TmuxRow] = []
|
||||
|
|
@ -154,11 +160,13 @@ class Rclaude:
|
|||
parts = line.split("\t")
|
||||
if len(parts) < 3 or parts[1] != "tmux":
|
||||
continue
|
||||
resumed = parts[4].strip() if len(parts) >= 5 and parts[4].strip() else None
|
||||
rows.append(
|
||||
TmuxRow(
|
||||
host=parts[0],
|
||||
session_name=parts[2],
|
||||
detail=parts[3] if len(parts) >= 4 else "",
|
||||
resumed_uuid=resumed,
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
|
|
|||
|
|
@ -412,8 +412,16 @@ def broadcast(
|
|||
failed = 0
|
||||
errors: list[str] = []
|
||||
for sid in session_uuids:
|
||||
# `rclaude --match` matches tmux session names, never Claude UUIDs —
|
||||
# resolve each session to its `tmux_name` before targeting. A session
|
||||
# with no known tmux_name can't be reached; surface that as a failure.
|
||||
session = read.get_session(conn, UUID(sid))
|
||||
if session is None or not session.tmux_name:
|
||||
failed += 1
|
||||
errors.append(f"{sid}: no known tmux_name (not observed as a live pane)")
|
||||
continue
|
||||
try:
|
||||
rcl.send(text=text, match=sid[:8], yes=yes, dry_run=not yes)
|
||||
rcl.send(text=text, match=session.tmux_name, yes=yes, dry_run=not yes)
|
||||
delivered += 1
|
||||
except RclaudeError as exc:
|
||||
failed += 1
|
||||
|
|
@ -1435,6 +1443,23 @@ def dispatch_task(
|
|||
"session spawned but not discovered within timeout",
|
||||
)
|
||||
|
||||
# Record the UUID → tmux_name mapping for this fresh session. A freshly
|
||||
# spawned pane has no `claude --resume <uuid>` in its start command, so
|
||||
# the pull loop's resumed_uuid mapping will never see it — but dispatch
|
||||
# knows both the new UUID and the tmux name it spawned. Emitting this now
|
||||
# makes the dispatched agent addressable via `send_to_session` (which
|
||||
# resolves UUID → tmux_name → `rclaude send --match`).
|
||||
ev.append(
|
||||
conn,
|
||||
gen,
|
||||
ev.SessionObserved(
|
||||
session_uuid=UUID(new_uuid),
|
||||
host=host,
|
||||
cwd=cwd,
|
||||
tmux_name=tmux_name,
|
||||
),
|
||||
)
|
||||
|
||||
assignment = create_assignment(
|
||||
conn, gen, task_id=task_id, session_uuid=UUID(new_uuid),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,21 +14,27 @@ from __future__ import annotations
|
|||
from uuid import UUID
|
||||
|
||||
from claire.pull import pull
|
||||
from claire.rclaude import SessionRow, TriageRow
|
||||
from claire.rclaude import SessionRow, TmuxRow, TriageRow
|
||||
|
||||
|
||||
class _FakeRclaude:
|
||||
"""Deterministic rclaude stand-in returning the same rows every call."""
|
||||
|
||||
def __init__(self, sessions: list[SessionRow], triage: list[TriageRow] | None = None):
|
||||
def __init__(
|
||||
self,
|
||||
sessions: list[SessionRow],
|
||||
triage: list[TriageRow] | None = None,
|
||||
tmux: list[TmuxRow] | None = None,
|
||||
):
|
||||
self._sessions = sessions
|
||||
self._triage = triage or []
|
||||
self._tmux = tmux or []
|
||||
|
||||
def list_sessions(self) -> list[SessionRow]:
|
||||
return list(self._sessions)
|
||||
|
||||
def list_tmux(self) -> list:
|
||||
return [] # no live panes in this idempotency test
|
||||
def list_tmux(self) -> list[TmuxRow]:
|
||||
return list(self._tmux)
|
||||
|
||||
def triage(self) -> list[TriageRow]:
|
||||
return list(self._triage)
|
||||
|
|
|
|||
|
|
@ -48,6 +48,36 @@ def test_rclaude_list_tmux_parses_tsv(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
assert rows[1].host == "apricot"
|
||||
|
||||
|
||||
def test_rclaude_list_tmux_parses_resumed_uuid(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The 5th TSV column (resumed_uuid) is parsed when present."""
|
||||
monkeypatch.setattr("shutil.which", lambda _: "/usr/local/bin/rclaude")
|
||||
output = (
|
||||
# resumed session — has the 5th column
|
||||
"local\ttmux\tclaude-natalie-foo-1715964800\t1 windows\t"
|
||||
"08381960-ecca-49eb-9d6e-9f0c505e8f9e\n"
|
||||
# fresh-spawned session — 5th column present but empty
|
||||
"apricot\ttmux\tclaude-natalie-bar-1715964900\t2 windows\t\n"
|
||||
)
|
||||
rcl = Rclaude(runner=_fake_runner(stdout=output))
|
||||
rows = rcl.list_tmux()
|
||||
assert len(rows) == 2
|
||||
assert rows[0].resumed_uuid == "08381960-ecca-49eb-9d6e-9f0c505e8f9e"
|
||||
assert rows[1].resumed_uuid is None
|
||||
|
||||
|
||||
def test_rclaude_list_tmux_tolerates_missing_5th_column(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Output from an older rclaude (only 4 columns) maps resumed_uuid to None."""
|
||||
monkeypatch.setattr("shutil.which", lambda _: "/usr/local/bin/rclaude")
|
||||
output = "local\ttmux\tclaude-natalie-foo-1715964800\t1 windows (created ...)\n"
|
||||
rcl = Rclaude(runner=_fake_runner(stdout=output))
|
||||
rows = rcl.list_tmux()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].session_name == "claude-natalie-foo-1715964800"
|
||||
assert rows[0].resumed_uuid is None
|
||||
|
||||
|
||||
def test_rclaude_list_sessions_parses_tsv(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("shutil.which", lambda _: "/usr/local/bin/rclaude")
|
||||
# `rclaude list sessions --tsv` interleaves tmux rows with session rows;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue