diff --git a/src/claire/orchestrator/tools.py b/src/claire/orchestrator/tools.py index bc775b7..8d3d7a6 100644 --- a/src/claire/orchestrator/tools.py +++ b/src/claire/orchestrator/tools.py @@ -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, + } # --------------------------------------------------------------------------- diff --git a/src/claire/pull.py b/src/claire/pull.py index 85548be..e47856e 100644 --- a/src/claire/pull.py +++ b/src/claire/pull.py @@ -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 ` 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: diff --git a/src/claire/rclaude.py b/src/claire/rclaude.py index a26fd44..7a7e1ab 100644 --- a/src/claire/rclaude.py +++ b/src/claire/rclaude.py @@ -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 `. `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 diff --git a/src/claire/web/service.py b/src/claire/web/service.py index 6760682..6151ad9 100644 --- a/src/claire/web/service.py +++ b/src/claire/web/service.py @@ -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 ` 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), ) diff --git a/tests/test_pull_idempotency.py b/tests/test_pull_idempotency.py index 611d9f8..a544f50 100644 --- a/tests/test_pull_idempotency.py +++ b/tests/test_pull_idempotency.py @@ -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) diff --git a/tests/test_rclaude_wrapper.py b/tests/test_rclaude_wrapper.py index f47eb46..8a2ff3f 100644 --- a/tests/test_rclaude_wrapper.py +++ b/tests/test_rclaude_wrapper.py @@ -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;