Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ breaking changes may land in a minor release.

### Fixed

- Tell a failed window listing apart from an empty session: liveness raises when the failure is unproven, metadata keeps its sentinel and warns (#525).
- Anchor the TUI's paused-spec read and its `Request replan` write on the tree the run
owns. Under isolation both resolved against the main checkout, so the review modals
showed that copy of the spec and the replan reset it — reporting success while the run's
Expand Down
36 changes: 26 additions & 10 deletions src/bmad_loop/adapters/multiplexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,17 +216,30 @@ def list_window_ids(self, session: str) -> list[str]:
that diverges remains usable, but falls back to the ambiguous by-name
lookup whenever several kinds share a run id.

Raises :class:`MultiplexerError` if the transport itself fails (timeout /
missing binary): an empty list means "no windows" and must not be
conflated with "couldn't ask" — this op backs the engine's liveness
probe (:meth:`window_alive`)."""
Raises :class:`MultiplexerError` whenever the listing could not be TAKEN
— a transport failure (timeout / missing binary) or a query that failed
without proving the session gone: an empty list means "no windows" and
must not be conflated with "couldn't ask", because this op backs the
engine's liveness probe (:meth:`window_alive`).

So ``[]`` carries a positive claim, not a shrug: the backend either
listed the session's windows and found none, or established that the
session no longer exists. A backend answering over a server therefore
owes callers a discrimination — a server that errors while its windows
are alive must not answer ``[]`` (#525). Which conditions PROVE absence
is the backend's own question: the exit code alone does not decide it,
and neither does a confirming :meth:`has_session`, whose False is
weaker than it looks (see its note)."""

@abstractmethod
def list_windows(self, session: str, fields: list[str]) -> list[tuple[str, ...]]:
"""One tuple per window in ``session``, each holding the requested
backend fields in order. Best-effort: returns ``[]`` on a transport
failure (unlike :meth:`list_window_ids`, this is metadata, not a liveness
probe, so a sentinel is safe).
failure OR a failed query (unlike :meth:`list_window_ids`, this is
metadata, not a liveness probe, so a sentinel is safe — the answer
degrades toward doing nothing, never toward claiming a death or a
kill). A backend SHOULD still say on stderr when the failure did not
prove the session gone, so an every-call failure is not silent (#525).

A ``window_id`` column carries the same id form :meth:`current_window_id`
AND :meth:`list_window_ids` return; core compares all three directly. The
Expand All @@ -241,10 +254,13 @@ def list_windows(self, session: str, fields: list[str]) -> list[tuple[str, ...]]
def window_alive(self, session: str, window_id: str) -> bool:
"""True iff ``window_id`` is still a window of ``session``.

May raise :class:`MultiplexerError` when liveness is unknowable (a
transport timeout / missing binary) — callers must treat that as "don't
know", not "dead", and must not tear down a possibly-working session on
it."""
May raise :class:`MultiplexerError` when liveness is unknowable — a
transport timeout / missing binary, or any other failure to take the
listing this membership test reads (see :meth:`list_window_ids`).
Callers must treat that as "don't know", not "dead", and must not tear
down a possibly-working session on it. Reachable in ordinary operation,
not only under a hung binary: a live server that refuses or drops the
query answers here (#525)."""

@abstractmethod
def kill_window(self, target: str) -> None:
Expand Down
47 changes: 38 additions & 9 deletions src/bmad_loop/adapters/psmux_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,17 @@ def list_window_ids(self, session: str) -> list[str]:
# psmux's list-windows emits bare `@N` lines; qualify them identically
# to new_window or window_alive's membership check (native_id in
# list_window_ids) would read every window as dead.
#
# No `_SESSION_GONE_STDERR` override, and that is a measurement rather
# than an omission (#525). psmux 3.3.8 words a vanished session as
# `psmux: no server running on session '<name>'` — the base's fragment
# matches it — and its client-side variant carries `can't find session`
# too. The failures that must NOT read as gone are worded well clear of
# both: a live session whose key was rejected answers `psmux: Invalid
# session key`, and one whose server is unreachable answers `psmux:
# connection timed out`. Both were rc 1 with the windows demonstrably
# alive — this backend is where the bug was actually reachable, because
# a per-session TCP server has failure modes tmux's socket does not.
return [
self._qualified_window_id(session, window_id)
for window_id in super().list_window_ids(session)
Expand Down Expand Up @@ -594,6 +605,16 @@ def list_windows(self, session: str, fields: list[str]) -> list[tuple[str, ...]]
id_columns = {i for i, field in enumerate(fields) if field == "window_id"}
if not opt_columns and not id_columns:
return rows
if not rows:
# No rows to fill, so the option listing below has nothing to fill
# them WITH — a pure short-circuit, byte-identical output. It is
# load-bearing anyway (#525): the base answers [] both for a session
# it proved gone and for a spawn that never landed, silently in each
# case, and pressing on would spend a second probe and then warn that
# the option listing failed — about a session that is legitimately
# gone, or on a box with no multiplexer at all. The base's honest
# silences must not be re-broken by the wrapper that reads them.
return rows
# The #221 degrade: an empty or `:`-bearing session cannot be routed
# with `-t`, and an unrouted read would answer from whichever server
# the fallback picks — fill "" without issuing reads at all.
Expand Down Expand Up @@ -901,7 +922,12 @@ def _scoped_options(self, session: str) -> dict[str, str] | None:
surprising {} is possible and is not proof that no keys are set."""
try:
proc = self._run(["show-options", "-q", "-t", session], check=False)
except (subprocess.SubprocessError, OSError):
except (subprocess.SubprocessError, OSError, UnicodeError):
# UnicodeError as in the base's listings (#525): this is the SECOND
# probe of a two-probe read, so a strict-codec leaf that decoded the
# window listing cleanly can still fault here — and the caller above
# is a best-effort metadata op that must degrade to "unset", never
# raise a decode error out of it.
return None
if proc.returncode != 0:
return None
Expand Down Expand Up @@ -951,10 +977,12 @@ def _sweep_orphan_keys(self, session: str) -> None:
# A session being swept just minted a window, so an empty live
# list is a failed probe, not an empty session — treating it as
# truth would sweep every key, live windows included. Warned for
# the same reason the listing failure above is, and this is the
# branch that actually fires: list_window_ids RAISES on a
# transport fault (caught below) and answers [] only on rc != 0,
# so silence here is a server failing every launch with no signal.
# the same reason the listing failure above is. Since #525 the
# only way to reach here is a listing that PROVED the session
# gone — every other failure raises and lands in the arm below —
# which under a just-minted window means the server died between
# the mint and the sweep. Its keys died with it, so the warning
# is the whole remaining duty.
print(
f"warning: orphan-key sweep on {session} could not list live "
"windows; orphaned keys unswept until the next launch",
Expand All @@ -978,10 +1006,11 @@ def kill_window(self, target: str) -> None:
# (the project tag scopes the prune retry; the return key keeps both
# return legs armed, see _parked_trailer). Scope resolves before the kill because a name
# token cannot be resolved once the window is dead. An empty liveness
# listing is ambiguous — a failed probe, or a session that died with
# its last window — so it degrades toward retaining the keys; the
# launch-time orphan sweep reclaims them once the window is provably
# gone. Discovery is generic by the seam's marker — the backend must
# listing means the session is gone (#525 narrowed it to that; anything
# unproven raises into the arm below), and its keys went with the
# server — but this path cannot tell that from the pre-#525 reading, so
# it still degrades toward retaining them; the launch-time orphan sweep
# reclaims whatever survived once the window is provably gone. Discovery is generic by the seam's marker — the backend must
# not know which option names callers use. Best-effort throughout:
# cleanup failure warns (the sweep precedent) but never blocks or
# fails the kill.
Expand Down
Loading