syncmill 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. syncmill/__init__.py +11 -0
  2. syncmill/agents.py +2064 -0
  3. syncmill/artifact_io.py +201 -0
  4. syncmill/artifacts.py +1946 -0
  5. syncmill/board.py +2060 -0
  6. syncmill/breaker.py +327 -0
  7. syncmill/cli.py +4986 -0
  8. syncmill/command_errors.py +32 -0
  9. syncmill/config.py +1118 -0
  10. syncmill/dashboard.py +935 -0
  11. syncmill/gates.py +179 -0
  12. syncmill/gateway.py +234 -0
  13. syncmill/git_env.py +99 -0
  14. syncmill/isolated_session.py +431 -0
  15. syncmill/matcher.py +127 -0
  16. syncmill/memory.py +1067 -0
  17. syncmill/memory_backend.py +1907 -0
  18. syncmill/memory_migration.py +962 -0
  19. syncmill/model_routing.py +482 -0
  20. syncmill/models.py +244 -0
  21. syncmill/monitor.py +775 -0
  22. syncmill/phoenix_exporter.py +339 -0
  23. syncmill/planning.py +512 -0
  24. syncmill/platform_support.py +14 -0
  25. syncmill/preflight.py +1129 -0
  26. syncmill/project.py +1067 -0
  27. syncmill/report.py +502 -0
  28. syncmill/review_candidates.py +107 -0
  29. syncmill/room_compare.py +2380 -0
  30. syncmill/room_raw.py +484 -0
  31. syncmill/room_viewer.py +604 -0
  32. syncmill/rooms.py +4044 -0
  33. syncmill/session.py +444 -0
  34. syncmill/strategies/__init__.py +82 -0
  35. syncmill/strategies/base.py +910 -0
  36. syncmill/strategies/compete.py +233 -0
  37. syncmill/strategies/council.py +429 -0
  38. syncmill/strategies/decompose.py +570 -0
  39. syncmill/strategies/pipeline.py +221 -0
  40. syncmill/strategies/route.py +130 -0
  41. syncmill/supervisor.py +1423 -0
  42. syncmill/teamfile.py +571 -0
  43. syncmill/telemetry_hook.py +231 -0
  44. syncmill/terminal.py +245 -0
  45. syncmill/tracer.py +548 -0
  46. syncmill/tui.py +3537 -0
  47. syncmill/tui_collaboration.py +499 -0
  48. syncmill/tui_jobs.py +1407 -0
  49. syncmill/tui_render.py +156 -0
  50. syncmill/workflow.py +3746 -0
  51. syncmill/worktree.py +1045 -0
  52. syncmill-0.2.0.dist-info/METADATA +853 -0
  53. syncmill-0.2.0.dist-info/RECORD +56 -0
  54. syncmill-0.2.0.dist-info/WHEEL +4 -0
  55. syncmill-0.2.0.dist-info/entry_points.txt +2 -0
  56. syncmill-0.2.0.dist-info/licenses/LICENSE +191 -0
@@ -0,0 +1,604 @@
1
+ """Read-only tmux viewer for a comparison-room round (#0.1.2 Phase 2).
2
+
3
+ A room round already tees each member's output to a private bounded log
4
+ (:class:`syncmill.rooms.RoundLogSink`). This module surfaces those logs live:
5
+ ``room attach`` (and the TUI Rooms ``t`` key) open a **read-only** tmux session
6
+ with one ``tail -F`` pane per member. It is observation-only — the panes run
7
+ ``tail``, never a provider — so there is no steering here (interactive steering
8
+ stays in ``session``).
9
+
10
+ It is deliberately a flat top-level module (the gateway/monitor/session/board
11
+ precedent) that **reuses monitor's tmux primitives** (``_tmux``/``_tmux_attach``/
12
+ ``tmux_available``) rather than reimplementing them, and follows session.py's
13
+ ownership discipline: a deterministic name is the reattach key, and lifecycle
14
+ operations target EXACT names (``=name``) so they can never touch a foreign
15
+ session. Two invariants carry over from session.py: it is **not** an agent and
16
+ **not** a strategy (it touches neither agents nor memory — only tmux and the
17
+ room store's already-written logs), and — unlike the round itself, whose
18
+ execution never touches tmux — an explicit attach with no tmux available is a
19
+ hard error (this IS the tmux feature, so there is no fallback).
20
+
21
+ Ownership is **project-scoped**. One tmux server is shared across every project
22
+ on a host, so the session name carries the project id: reclaiming viewers for
23
+ project B must never touch project A's live sessions. A sweep only ever
24
+ considers names under *this* project's prefix, and a per-room kill scopes to
25
+ *this* room's prefix — the project id + uuid4 room id together are the
26
+ independent ownership record, not a bare pattern match.
27
+
28
+ Round execution is untouched: the viewer only ever runs on an explicit user
29
+ attach, reading logs the round already wrote, so a tmux failure here can never
30
+ affect a running round.
31
+
32
+ Two attach state machines, deliberately separate because their lifecycles
33
+ differ:
34
+
35
+ * **External** (not inside tmux) — a FOREGROUND ``attach-session`` that blocks
36
+ until the user detaches (``Ctrl-b d``); the caller (the CLI, or the TUI under
37
+ ``App.suspend``) regains control on return.
38
+ * **Inside tmux** (``$TMUX`` set) — a non-blocking ``switch-client -E -t
39
+ =<session>``; nesting an attach is refused by tmux, and switch does not block,
40
+ so there is no suspend/return dance — the user returns with ``Prefix + L``.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import os
46
+ import re
47
+ from hashlib import sha256
48
+ from typing import TYPE_CHECKING
49
+ from uuid import uuid4
50
+
51
+ from syncmill.command_errors import command_error
52
+ from syncmill.monitor import _tmux, _tmux_attach, tmux_available
53
+
54
+ if TYPE_CHECKING: # a type hint only — importing at runtime would make rooms.py,
55
+ # which drives teardown from the room lifecycle, import a module that imports
56
+ # it back. Keeping it type-only makes the runtime graph rooms → room_viewer.
57
+ from syncmill.rooms import RoomStore
58
+
59
+ _VIEWER_PREFIX = "syncmill-room-"
60
+
61
+ #: A tmux user option stamped on every viewer we create, carrying the project
62
+ #: key. Verified before ANY kill so a coincidental same-named user session (or a
63
+ #: session from a project whose key collides) is never reclaimed — the name is
64
+ #: the scope, this marker is the ownership PROOF.
65
+ _OWNER_OPTION = "@syncmill_room_viewer"
66
+
67
+ #: Terminal room states whose viewers a sweep may reclaim / a create must refuse.
68
+ _TERMINAL_ROOM_STATES = {"applied", "closed", "stale"}
69
+
70
+ #: A tmux session id is ``$`` followed by digits. We validate the id parsed from
71
+ #: ``new-session -F`` against this in full — a bare ``startswith("$")`` would let a
72
+ #: degenerate value like ``$`` or ``$x`` through into set-option/kill targets.
73
+ _SESSION_ID_RE = re.compile(r"\$\d+\Z")
74
+
75
+
76
+ class RoomViewerError(RuntimeError):
77
+ """A room viewer could not be opened. Like ``SessionLaunchError``, this has no
78
+ fallback — the viewer IS the tmux feature — so the CLI surfaces it directly."""
79
+
80
+
81
+ def _project_digest(project_id: str) -> str:
82
+ """The full SHA-256 of the raw project id, stored in the ownership marker so
83
+ the ownership check does not lean on the truncated name key."""
84
+ return sha256(project_id.encode("utf-8", errors="surrogateescape")).hexdigest()
85
+
86
+
87
+ def _project_key(project_id: str) -> str:
88
+ """A tmux-name-safe, collision-resistant key for a project. A lossy character
89
+ substitution collides (``team/a`` and ``team:a`` map to one name, so a sweep
90
+ of one would kill the other's viewers), so this is a digest of the RAW id.
91
+ The name uses a short prefix of the digest; the full digest lives in the
92
+ ownership marker (:func:`_project_digest`) as the authoritative check."""
93
+ return _project_digest(project_id)[:16]
94
+
95
+
96
+ def _exact(name: str) -> str:
97
+ """A tmux ``target-session`` spec forcing EXACT matching (session.py's rule):
98
+ tmux resolves a raw target exact-THEN-prefix, so ``=name`` is what keeps a
99
+ lookup from matching a longer foreign session when the exact one is absent.
100
+ Used for SESSION targets (``has-session``/``kill-session``/``attach``/``switch``)."""
101
+ return f"={name}"
102
+
103
+
104
+ def _project_prefix(project_id: str) -> str:
105
+ """The prefix every viewer session of one project shares — the project-scope
106
+ ownership boundary a sweep must never cross."""
107
+ return f"{_VIEWER_PREFIX}{_project_key(project_id)}-"
108
+
109
+
110
+ def viewer_session_name(project_id: str, room_id: str, round_number: int) -> str:
111
+ """The deterministic tmux session name for a room round — the reattach key.
112
+
113
+ Carries a collision-resistant project key so one host's shared tmux server
114
+ can host viewers for many projects without a sweep in one reclaiming
115
+ another's live sessions. Room ids are uuid4 (already tmux-safe).
116
+ """
117
+ return f"{_project_prefix(project_id)}{room_id}-r{round_number}"
118
+
119
+
120
+ def _room_prefix(project_id: str, room_id: str) -> str:
121
+ """The prefix scoping a per-room kill to exactly this room's viewers. The
122
+ trailing ``-r`` (round marker) prevents matching a room whose id is a prefix
123
+ of another — room ids are uuid4, so this is exact to the room."""
124
+ return f"{_project_prefix(project_id)}{room_id}-r"
125
+
126
+
127
+ def _session_exists(name: str) -> bool:
128
+ return _tmux("has-session", "-t", _exact(name)).returncode == 0
129
+
130
+
131
+ def _session_id(name: str) -> str | None:
132
+ """The tmux ``session_id`` (``$N``) of the session currently named ``name``,
133
+ or ``None`` if absent. This binds an inspect-then-kill to ONE session
134
+ instance: a stale rebuild that kills by id can never hit a fresh replacement
135
+ that took the name in between (killing by name would). ``list-sessions``
136
+ (unlike ``display-message``) needs no attached client."""
137
+ res = _tmux("list-sessions", "-F", "#{session_name} #{session_id}")
138
+ if res.returncode != 0:
139
+ return None
140
+ for line in res.stdout.splitlines():
141
+ parts = line.split()
142
+ if len(parts) == 2 and parts[0] == name:
143
+ return parts[1]
144
+ return None
145
+
146
+
147
+ def _inspect_instance(session_id: str, project_id: str) -> tuple[bool, int] | None:
148
+ """``(owned, pane_count)`` for a specific session INSTANCE (by id), or ``None``
149
+ if that instance has DISAPPEARED between resolution and inspection.
150
+
151
+ Distinguishing "gone" from "exists but unowned" is load-bearing: a session
152
+ that vanishes mid-inspection must be retried/recreated, not reported as a
153
+ foreign-name collision. The ``-q`` flag is essential — WITHOUT it tmux also
154
+ returns non-zero for an UNSET option, so a stable unowned session would look
155
+ "gone" and (mis)trip the churn path. With ``-q`` an unset option is rc 0 +
156
+ empty, and only a missing session is non-zero — the true gone signal."""
157
+ owner = _tmux("show-options", "-t", f"{session_id}:", "-q", "-v", _OWNER_OPTION)
158
+ if owner.returncode != 0:
159
+ return None # instance disappeared
160
+ owned = owner.stdout.strip() == _project_digest(project_id)
161
+ panes = _tmux("list-panes", "-t", session_id, "-F", "#{pane_id}")
162
+ return owned, (len(panes.stdout.split()) if panes.returncode == 0 else 0)
163
+
164
+
165
+ def _kill_owned_instance(name: str, project_id: str) -> bool:
166
+ """Resolve ``name`` to its session id, verify OUR ownership of THAT instance,
167
+ and kill the SAME id. Binding the check and the kill to one id closes the
168
+ TOCTOU where a checked-owned session is replaced by a foreign session under
169
+ the same name before a name-based kill lands. Returns whether it killed."""
170
+ sid = _session_id(name)
171
+ if sid is None:
172
+ return False
173
+ # -q so an unset marker is rc 0 + empty (not-ours), not a false "gone".
174
+ owner = _tmux("show-options", "-t", f"{sid}:", "-q", "-v", _OWNER_OPTION)
175
+ if owner.returncode != 0 or owner.stdout.strip() != _project_digest(project_id):
176
+ return False # gone, or not ours
177
+ return _tmux("kill-session", "-t", sid).returncode == 0
178
+
179
+
180
+ def _resolve_round(store: RoomStore, room_id: str, round_number: int | None) -> dict:
181
+ """The requested round (or the latest), validated to exist."""
182
+ rounds = store.list_rounds(room_id)
183
+ if not rounds:
184
+ raise RoomViewerError(f"room {room_id} has no rounds yet")
185
+ if round_number is None:
186
+ return rounds[-1]
187
+ for row in rounds:
188
+ if int(row["number"]) == round_number:
189
+ return row
190
+ raise RoomViewerError(f"room {room_id} has no round {round_number}")
191
+
192
+
193
+ def ensure_viewer(
194
+ store: RoomStore,
195
+ project_id: str,
196
+ room_id: str,
197
+ round_number: int | None = None,
198
+ ) -> str:
199
+ """Ensure a read-only viewer session exists for a round and return its name.
200
+
201
+ Idempotent: an existing session is returned as-is (the reattach path). A
202
+ round with no per-member monitor rows (one that predates the progress table)
203
+ is "terminal unavailable" — there is nothing live to follow — and raises
204
+ rather than opening an empty session.
205
+ """
206
+ if not tmux_available():
207
+ raise RoomViewerError("tmux is not available; the room viewer requires tmux")
208
+ room = store.get_room(room_id) # raises RoomError for an unknown room
209
+ if room["state"] in _TERMINAL_ROOM_STATES:
210
+ # A terminal room's viewer has nothing live to follow; refuse to recreate
211
+ # one (else a sweep-then-attach would resurrect it every time).
212
+ raise RoomViewerError(f"room is {room['state']}; nothing to view")
213
+ round_row = _resolve_round(store, room_id, round_number)
214
+ members = store.list_round_members(round_row["id"])
215
+ if not members:
216
+ raise RoomViewerError(
217
+ f"round {round_row['number']} has no monitor data "
218
+ "(it predates per-member progress); terminal unavailable"
219
+ )
220
+ name = viewer_session_name(project_id, room_id, int(round_row["number"]))
221
+ # Resolve every member log up front — for BOTH paths. The reattach
222
+ # early-return below must not bypass _create_viewer's fail-closed log
223
+ # validation: a log that no longer resolves (DB drift, a symlink swapped
224
+ # in) invalidates any existing viewer. Valid logs are also hardened here
225
+ # (0700/0600) so a log seeded permissive by an older syncmill is
226
+ # tightened on reattach, not only on rebuild.
227
+ resolved_logs = [
228
+ _resolve_member_log(store, room_id, round_row["id"], member)
229
+ for member in members
230
+ ]
231
+ logs_valid = all(path is not None for path in resolved_logs)
232
+ _harden_member_logs(resolved_logs)
233
+ # Bind the ownership/completeness checks AND the rebuild-kill to ONE instance
234
+ # (by session id): a concurrent caller that already replaced the name has a
235
+ # different id, so our stale kill targets the gone instance, never its valid
236
+ # replacement (killing by name would clobber it). A session that vanishes
237
+ # mid-inspection is re-resolved under a bounded retry rather than mistaken
238
+ # for a foreign-name collision.
239
+ for _ in range(4):
240
+ sid = _session_id(name)
241
+ if sid is None:
242
+ break # no session under the name → create
243
+ inspected = _inspect_instance(sid, project_id)
244
+ if inspected is None:
245
+ continue # the instance vanished mid-inspection → re-resolve
246
+ owned, panes = inspected
247
+ if owned and panes == len(members) and logs_valid:
248
+ # Ours, complete, and every log still resolves → reattach.
249
+ return name
250
+ if owned:
251
+ # Ours but incomplete (a pane exited, a prior build was killed
252
+ # mid-flight) OR its logs no longer resolve (a stale viewer
253
+ # tailing invalid paths): kill THIS instance — never by name —
254
+ # and fall through to _create_viewer, whose fail-closed log
255
+ # validation rejects the invalid case.
256
+ _tmux("kill-session", "-t", sid)
257
+ break
258
+ # Not ours. Only raise if the SAME unowned instance still holds the name;
259
+ # if the name was taken over by a different instance in between (e.g. a
260
+ # valid owned creator just published), re-inspect the replacement rather
261
+ # than report a stale collision.
262
+ if _session_id(name) == sid:
263
+ raise RoomViewerError(
264
+ f"a session named {name!r} exists but is not a syncmill viewer; refusing"
265
+ )
266
+ continue
267
+ else:
268
+ # A persistently-churning unowned name (never the same instance twice):
269
+ # do not attach or clobber it — refuse.
270
+ raise RoomViewerError(
271
+ f"a session named {name!r} keeps being replaced by non-viewer sessions; refusing"
272
+ )
273
+ _create_viewer(store, project_id, name, room_id, round_row, members)
274
+ return name
275
+
276
+
277
+ def _harden_member_logs(paths: list[str | None]) -> None:
278
+ """Best-effort 0700/0600 tightening of existing resolved member logs.
279
+
280
+ ``RoundLogSink._open``'s ``O_CREAT`` mode is ignored for a pre-existing
281
+ file, so a log/dir created permissive by an older version stays that way
282
+ until explicitly chmodded. ``None`` entries (invalid paths) are skipped —
283
+ the caller separately fail-closes on them; a missing file is fine — the
284
+ sink or the seeding loop creates it privately later.
285
+ """
286
+ for path in paths:
287
+ if path is None:
288
+ continue
289
+ try:
290
+ os.chmod(os.path.dirname(path), 0o700)
291
+ except OSError:
292
+ pass
293
+ try:
294
+ os.chmod(path, 0o600)
295
+ except OSError:
296
+ pass
297
+
298
+
299
+ def _resolve_member_log(
300
+ store: RoomStore, room_id: str, round_id: str, member: dict
301
+ ) -> str | None:
302
+ """Fail-closed absolute log path for a member, or ``None`` if invalid.
303
+
304
+ The path is RECOMPUTED from authoritative room/round/member ids (never the
305
+ stored string is trusted), required to equal the stored ``log_path``, and
306
+ bound to the exact round directory with NO symlink indirection — so an
307
+ absolute/``..``/symlinked ``log_path`` in the DB, or an in-root symlink that
308
+ would rebind the member to another file, can never make the viewer tail
309
+ outside the round's own tree.
310
+ """
311
+ stored = member.get("log_path")
312
+ canonical = store.round_log_relpath(room_id, round_id, member["member_id"])
313
+ if stored != canonical:
314
+ return None # drift from the authoritative path → do not follow it
315
+ root = store.candidates.root.resolve()
316
+ expected = root / canonical
317
+ # Reject the leaf being a symlink, and any symlink in the chain (realpath
318
+ # resolves every component, so a divergence means a symlink was traversed).
319
+ if expected.is_symlink() or os.path.realpath(expected) != str(expected):
320
+ return None
321
+ try:
322
+ expected.relative_to(root)
323
+ except ValueError:
324
+ return None
325
+ if expected.exists() and not expected.is_file():
326
+ return None
327
+ return str(expected)
328
+
329
+
330
+ def _terminal_room_state(store: RoomStore, room_id: str) -> str | None:
331
+ """The room's state if it is terminal, else ``None``; never raises.
332
+
333
+ Used to re-read the authoritative state when a build step fails: a terminal
334
+ transition (close/apply/prune) sweeps our temp session mid-build, and the
335
+ resulting raw tmux error ("name in use", a split failure) describes the
336
+ symptom rather than the cause. A store failure here yields ``None`` so the
337
+ original tmux error still surfaces.
338
+ """
339
+ try:
340
+ state = store.get_room(room_id)["state"]
341
+ except Exception:
342
+ return None
343
+ return state if state in _TERMINAL_ROOM_STATES else None
344
+
345
+
346
+ def _create_viewer(
347
+ store: RoomStore,
348
+ project_id: str,
349
+ name: str,
350
+ room_id: str,
351
+ round_row: dict,
352
+ members: list[dict],
353
+ ) -> None:
354
+ """Build the read-only session ATOMICALLY under a temp name, then rename it to
355
+ the final name only once complete.
356
+
357
+ Two properties this guarantees: (a) the final name never names a partial
358
+ session — a concurrent reattach that sees it can safely attach; (b) if ANY
359
+ member's log is invalid or ANY pane fails, the whole build is torn down and
360
+ the create raises — a viewer never silently drops a member.
361
+ """
362
+ logs: list[tuple[str, str]] = []
363
+ for member in members:
364
+ path = _resolve_member_log(store, room_id, round_row["id"], member)
365
+ if path is None:
366
+ # Fail the whole viewer, not just this member — a partial viewer that
367
+ # silently omits a member reads as complete and misleads the reviewer.
368
+ raise RoomViewerError(
369
+ f"member {member['member_id']} has no valid log; refusing a partial viewer"
370
+ )
371
+ logs.append((member["member_id"], path))
372
+ # Seed each log so `tail -F` opens cleanly even before the first write.
373
+ # Use RoundLogSink._open's modes (0700 dir, 0600 file): O_CREAT's mode is
374
+ # ignored for a pre-existing file, so a umask-default seed would leave the
375
+ # member's private provider-output log group/world-readable for its whole
376
+ # life once the sink later opens it.
377
+ for _, path in logs:
378
+ try:
379
+ log_dir = os.path.dirname(path)
380
+ os.makedirs(log_dir, exist_ok=True, mode=0o700)
381
+ os.close(os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600))
382
+ # mkdir/open modes only apply to newly created paths — tighten
383
+ # pre-existing ones (e.g. seeded by an older syncmill at 0755/0644)
384
+ # so the private log never stays group/world-readable.
385
+ os.chmod(log_dir, 0o700)
386
+ os.chmod(path, 0o600)
387
+ except OSError:
388
+ pass # best-effort; tail -F tolerates a not-yet-existent file
389
+ temp = f"{name}-mk{uuid4().hex[:8]}"
390
+ first_member, first_log = logs[0]
391
+ # Capture OUR session id up front: every teardown of the session we build
392
+ # (build-failure, terminal-publication) targets THIS id, never the name, so a
393
+ # foreign session that later claims the deterministic name is never killed by
394
+ # us. The temp name is random, but the rename preserves the id, so the id is
395
+ # the one stable handle across the whole build.
396
+ created = _tmux(
397
+ "new-session", "-d", "-s", temp, "-P", "-F", "#{session_id} #{pane_id}",
398
+ "tail", "-n", "+1", "-F", first_log,
399
+ )
400
+ if created.returncode != 0:
401
+ raise command_error(
402
+ RoomViewerError, "tmux new-session failed for the room viewer", created.stderr
403
+ )
404
+ parts = created.stdout.split()
405
+ our_sid = parts[0] if parts else _session_id(temp)
406
+ if not our_sid or not _SESSION_ID_RE.match(our_sid):
407
+ # -F should always print the session id ($N); if it somehow did not and
408
+ # the temp is already gone, we have no stable handle — tear down by the
409
+ # (unique) temp name and fail cleanly rather than pass None into tmux.
410
+ _tmux("kill-session", "-t", _exact(temp))
411
+ raise RoomViewerError("could not resolve the new viewer session id")
412
+ first_pane = parts[1] if len(parts) > 1 else ""
413
+ try:
414
+ # Stamp ownership FIRST and require it to succeed: an unmarked session
415
+ # would be unreclaimable by close/sweep and unrecognizable as ours. The
416
+ # marker (the full project digest) is addressed via the session id since
417
+ # set-option rejects a bare =session; it survives the rename.
418
+ stamped = _tmux("set-option", "-t", f"{our_sid}:", _OWNER_OPTION, _project_digest(project_id))
419
+ if stamped.returncode != 0:
420
+ raise command_error(
421
+ RoomViewerError, "failed to stamp viewer ownership", stamped.stderr
422
+ )
423
+ _tmux("set-option", "-t", f"{our_sid}:", "pane-border-status", "top")
424
+ _tmux("set-option", "-t", f"{our_sid}:", "pane-border-format", " #{pane_title} ")
425
+ if first_pane:
426
+ _tmux("select-pane", "-t", first_pane, "-T", f"{first_member} (round {round_row['number']})")
427
+ for member_id, path in logs[1:]:
428
+ res = _tmux(
429
+ "split-window", "-t", f"{our_sid}:", "-d", "-P", "-F", "#{pane_id}",
430
+ "tail", "-n", "+1", "-F", path,
431
+ )
432
+ if res.returncode != 0:
433
+ raise command_error(
434
+ RoomViewerError, f"failed to add a viewer pane for {member_id}", res.stderr
435
+ )
436
+ pane_id = res.stdout.strip()
437
+ if pane_id:
438
+ _tmux("select-pane", "-t", pane_id, "-T", member_id)
439
+ _tmux("select-layout", "-t", f"{our_sid}:", "tiled")
440
+ except Exception:
441
+ _tmux("kill-session", "-t", our_sid) # our instance, by id
442
+ # A terminal transition that swept our temp mid-build is the CAUSE of the
443
+ # tmux failure we are holding; report it as such (same wording as the
444
+ # pre-check) rather than the raw split/pane error it manifested as.
445
+ terminal = _terminal_room_state(store, room_id)
446
+ if terminal is not None:
447
+ raise RoomViewerError(f"room is {terminal}; nothing to view") from None
448
+ raise
449
+ except BaseException:
450
+ _tmux("kill-session", "-t", our_sid) # our instance, by id
451
+ raise
452
+ # Publish atomically: the final name appears only now, fully built.
453
+ renamed = _tmux("rename-session", "-t", our_sid, name)
454
+ if renamed.returncode != 0:
455
+ # A concurrent creator won the race and already published the final name;
456
+ # drop our complete-but-duplicate instance (by id) and accept the winner
457
+ # only after validating IT — resolved once to a sid — is a complete, owned
458
+ # viewer, never a squatting foreign name (which two name-based lookups
459
+ # could straddle a replacement).
460
+ _tmux("kill-session", "-t", our_sid)
461
+ winner_sid = _session_id(name)
462
+ inspected = _inspect_instance(winner_sid, project_id) if winner_sid else None
463
+ if inspected is None or not (inspected[0] and inspected[1] == len(members)):
464
+ terminal = _terminal_room_state(store, room_id)
465
+ if terminal is not None:
466
+ # Not a lost race — a terminal transition swept our temp session
467
+ # before the rename. Say so, matching ensure_viewer's pre-check.
468
+ raise RoomViewerError(f"room is {terminal}; nothing to view")
469
+ raise command_error(
470
+ RoomViewerError, "failed to publish the viewer (name in use)", renamed.stderr
471
+ )
472
+ return
473
+ # A terminal transition (close/apply/prune) can race publication: it may have
474
+ # swept the temp just before the rename, then found nothing. Re-read the
475
+ # authoritative state and reclaim OUR just-published instance (by id, so a
476
+ # foreign session that took the name is never killed) if the room is terminal.
477
+ try:
478
+ if store.get_room(room_id)["state"] in _TERMINAL_ROOM_STATES:
479
+ _tmux("kill-session", "-t", our_sid)
480
+ except Exception:
481
+ pass
482
+
483
+
484
+ def attach_viewer(name: str) -> int:
485
+ """Attach to a viewer session, choosing the state machine by context.
486
+
487
+ Returns a tmux exit code (0 on a clean external detach or a successful
488
+ in-tmux switch). Not wrapped in any Textual suspend — the TUI caller wraps
489
+ THIS in ``App.suspend`` for the external path; the CLI needs no wrapper.
490
+ """
491
+ if in_tmux():
492
+ # Inside tmux: nesting an attach is refused, and switch does not block,
493
+ # so there is no suspend/return dance. -E resets to the pane env.
494
+ return _tmux("switch-client", "-E", "-t", _exact(name)).returncode
495
+ # External: foreground attach that blocks until the user detaches.
496
+ return _tmux_attach(_exact(name))
497
+
498
+
499
+ def in_tmux() -> bool:
500
+ """Whether this process is running inside a tmux client (selects the state
501
+ machine and the correct return hint to print)."""
502
+ return bool(os.environ.get("TMUX"))
503
+
504
+
505
+ def close_room_viewers(project_id: str, room_id: str) -> int:
506
+ """Kill every viewer session for a room's rounds. Best-effort, returns the
507
+ count killed. Called from the room lifecycle (close/apply/prune); a missing
508
+ tmux server is a no-op.
509
+
510
+ Scoped to ``{project}-{room}-r``: project id + uuid4 room id are the
511
+ ownership record, so this can only ever match this project's viewers of this
512
+ exact room, never a foreign or cross-project session. No store is needed —
513
+ unlike :func:`sweep_orphan_viewers`, the caller has already decided the room
514
+ is terminal; the session names alone carry the scope.
515
+ """
516
+ if not tmux_available():
517
+ return 0
518
+ prefix = _room_prefix(project_id, room_id)
519
+ killed = 0
520
+ for session in _list_project_viewers(project_id):
521
+ # Resolve name → sid, check OUR ownership of that instance, kill the same
522
+ # id — so a checked-owned session replaced by a foreign one under the same
523
+ # name before the kill is never clobbered (the TOCTOU fixed everywhere).
524
+ if session.startswith(prefix) and _kill_owned_instance(session, project_id):
525
+ killed += 1
526
+ return killed
527
+
528
+
529
+ def sweep_orphan_viewers(store: RoomStore, project_id: str) -> int:
530
+ """Kill THIS project's viewer sessions whose room no longer exists or is
531
+ terminal. Best-effort; returns the count reclaimed.
532
+
533
+ Only sessions under this project's prefix are ever considered — the
534
+ cross-project safety boundary — so a sweep in project B can never reclaim
535
+ project A's live viewers, and never touches a foreign/user/monitor session.
536
+ """
537
+ if not tmux_available():
538
+ return 0
539
+ live = {room["id"]: room["state"] for room in store.list_rooms()}
540
+ sessions = _list_project_viewers(project_id)
541
+ present = set(sessions)
542
+ killed = 0
543
+ for session in sessions:
544
+ reclaim = False
545
+ final = _final_of_temp(session)
546
+ if final is not None and final in present:
547
+ # An abandoned build temp whose final session already published — a
548
+ # SIGKILL/crash mid-build leaves persistent tail processes.
549
+ reclaim = True
550
+ else:
551
+ room_id = _room_id_from_name(session, project_id)
552
+ if room_id is None:
553
+ continue # unparseable under our own prefix → leave it alone
554
+ state = live.get(room_id)
555
+ reclaim = state is None or state in _TERMINAL_ROOM_STATES
556
+ # Same sid-bound check-and-kill as close: never kill by name.
557
+ if reclaim and _kill_owned_instance(session, project_id):
558
+ killed += 1
559
+ return killed
560
+
561
+
562
+ def _final_of_temp(name: str) -> str | None:
563
+ """If ``name`` is a build temp (``…-mk{hex}``), the final name it was building
564
+ toward; else ``None``. Used to reclaim a temp abandoned by a crashed build
565
+ once its final session exists."""
566
+ mk = name.rfind("-mk")
567
+ if mk <= 0:
568
+ return None
569
+ suffix = name[mk + 3:]
570
+ if suffix and all(c in "0123456789abcdef" for c in suffix):
571
+ return name[:mk]
572
+ return None
573
+
574
+
575
+ def _list_project_viewers(project_id: str) -> list[str]:
576
+ """Live tmux session names under THIS project's viewer prefix (``[]`` with no
577
+ server). The project prefix is the scope boundary every reclaim respects."""
578
+ res = _tmux("list-sessions", "-F", "#{session_name}")
579
+ if res.returncode != 0:
580
+ return []
581
+ prefix = _project_prefix(project_id)
582
+ return [line.strip() for line in res.stdout.splitlines() if line.strip().startswith(prefix)]
583
+
584
+
585
+ def _room_id_from_name(name: str, project_id: str) -> str | None:
586
+ """Recover the room_id from a viewer session name of THIS project, or ``None``.
587
+
588
+ The name is ``{project_prefix}{room_id}-r{n}``, or an in-flight temp
589
+ ``…-r{n}-mk{hex}``. The project prefix is KNOWN (passed in) and stripped
590
+ exactly, so hyphens in a project id are never ambiguous. A ``-mk{hex}`` build
591
+ suffix is stripped first so an abandoned temp (a SIGKILL mid-build) is still
592
+ attributed to its room and reclaimable — then the trailing ``-r{digits}``
593
+ round marker is stripped (uuid4s carry no such tail)."""
594
+ prefix = _project_prefix(project_id)
595
+ if not name.startswith(prefix):
596
+ return None
597
+ body = name[len(prefix):]
598
+ mk = body.rfind("-mk")
599
+ if mk > 0 and body[mk + 3:] and all(c in "0123456789abcdef" for c in body[mk + 3:]):
600
+ body = body[:mk] # an abandoned build temp → attribute to its room
601
+ marker = body.rfind("-r")
602
+ if marker <= 0 or not body[marker + 2:].isdigit():
603
+ return None
604
+ return body[:marker]