Skip to content
Merged
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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,28 @@ story <id>`, the same annotation a sweep bundle writes. Both sprint and stories

### Fixed

- **`return_attached_client` no longer claims a return that never happened (#227).** It discarded
`switch_client`'s result and answered `True` unconditionally, so a failed switch plus a failed
`-l` fallback still journaled the return and sent the sweep unattended while the human sat in
the sweep window with their terminal never handed back. `RETURN_OPTION` is now cleared only on a
real return — a failed one is left for the parked window's trailer, which is a second chance if
someone dismisses the park prompt, not a rescue for an unattended window.

The two failures are reported apart, because they point opposite ways: a failed switch leaves
the client in this window with a human in front of it (`ATTENDED` — an attended sweep keeps
prompting, which is the fix), while a failed detach means there was no client to detach at all
(`UNREACHABLE` — the sweep goes unattended, or a `--repeat` cycle blocks forever on a prompt no
one can see). Announcing it stays tied to a real return; `UNREACHABLE` journals
`sweep-return-no-client` and prints nothing.

`TerminalMultiplexer.detach_client` widens from `None` to `bool` to carry that, and both client
verbs now owe **effect, not dispatch**. tmux reads it off the exit code. psmux cannot — every arm
of its `detach-client` / `switch-client` exits 0 whether or not a client moved — so it measures
the session's attached-client count across the call and answers on the drop; unobservable
degrades to `False`, never a vacuous `True` (#317). That path went live with the option channel
in #310, so on Windows the return was reachable and dishonest at the same time. Out-of-tree
backends still returning `None` read as "nothing detached" — degraded, not broken.

- **Give psmux a working per-window option channel (#310).** psmux keeps one user-option scope
per server and returns `''` for every `-w` read of an `@`-prefixed name, so both mechanisms
riding per-window options were broken. The ctl-window project tag read the same bled value for
Expand Down
43 changes: 39 additions & 4 deletions docs/adapter-authoring-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,41 @@ seams of a full OS port are in
parked-window return hop, replayed opaquely by `switch_client` and the parked
trailer; concrete default = the native pane id, so most backends inherit it —
override only when your ids do not resolve from another session's context,
as psmux does to emit `=session:%N`), `detach_client`, `switch_client` (with
an optional last-client fallback), `available` (is this backend usable on
the current host).
as psmux does to emit `=session:%N`), `detach_client` / `switch_client` (with
an optional last-client fallback — both answer a bool the parked-window
return path trusts: report **effect**, not that the command was dispatched,
so a backend with no real detach returns `False`. If your CLI's exit code
already means "a client moved" you are done (tmux: `detach-client` fails with
_no current client_). If it does not, **measure** — psmux is the worked
example: every arm of its `detach-client` / `switch-client` exits 0 whether or
not a client moved, so the backend counts the session's attached clients
across the call and answers on the drop. Match the measure to what the verb is
meant to change, and record what it cannot see: that count reaches a detach,
and reaches the switch the return path normally performs — the recorded target
is a pane in the session the client came _from_, so a real switch leaves this
session — but a switch _within_ this session moves the client between windows
without moving the count, and so reads as no effect. Failing that way is
conformant: the caller keeps prompting, which is the safe direction. psmux
carries exactly that blind spot, in its `Residue:` note. Where even a measure
is unavailable, answer `False` and record the gap in your degradation ledger;
a vacuous `True` is the one answer that strands a human),
`available` (is this backend usable on the current host).

The caller's two failures are not symmetric, which is what makes the rule
worth the round-trip. A failed `switch_client` is positive evidence: the
client is still in the window it was already in, so someone may well be
watching. A failed `detach_client` is not evidence of anything — `False` there
means only "no verified hand-back", and covers all three of nothing was
attached (tmux says so outright), the effect could not be observed, and the
backend has no detach verb at all (herdr, whose client only a manual chord
releases). `tui.launch.return_attached_client` reports the two as `ATTENDED`
and `UNREACHABLE`, and an attended sweep keeps prompting on the first but goes
unattended on the second. `UNREACHABLE` is therefore the caller's _policy_ for
an unverified detach rather than a claim the window is empty: assuming a human
is still there costs a `--repeat` cycle blocked forever on `input()` in a
window nobody may be viewing, which is the worse way to be wrong. What no
backend may do is answer `True` when nothing happened — that collapses both
failures into "the human has their terminal back", which is #227.

**Window targets.** The target-taking methods (`kill_window`, `select_window`,
the window-option trio, `attach_target_argv`, `switch_client`) receive one of two
Expand Down Expand Up @@ -156,7 +188,10 @@ tee — runs a per-window **poller** thread that snapshots `pane read` into the
whenever the content changes, which is exactly enough to drive the two log consumers
a tmux tee would (`generic._log_activity_key`'s stall re-arm and `probe`'s marker
discovery). Its module docstring is a **degradation ledger** of every such
divergence (sidecar options, poller `pipe_pane`, no-op `detach_client`, the attach
divergence (sidecar options, poller `pipe_pane`, the no-op `detach_client` —
which the widened seam now requires to answer `False`, not `None`, so the
parked-return path reads it as `UNREACHABLE` and an attended sweep stops
prompting into a window whose client only a manual chord can release — the attach
argv, the advisory geometry, the protocol-version policy) — the reference for what
"implement fresh" costs when the host has no tmux-shaped CLI. The operator-facing
view — what a herdr _user_ notices and does — is
Expand Down
4 changes: 3 additions & 1 deletion docs/tui-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,9 @@ toast. Then:
3. Detach with `ctrl-b d`. (On tmux this step is belt-and-suspenders — answering
already hands your terminal back. On backends without a detach verb — e.g.
the herdr adapter — the hand-back cannot be automatic: press its detach
chord, `ctrl+b q` on herdr.)
chord, `ctrl+b q` on herdr. Either way the sweep knows it can no longer reach
you and goes unattended for the rest of the run, so detaching first never
leaves it waiting on a prompt you can't see.)

The banner clears on the next poll after the sweep journals anything further
(the answer is recorded as a `decision:` line in `deferred-work.md`). Sweeps
Expand Down
23 changes: 17 additions & 6 deletions src/bmad_loop/adapters/multiplexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,16 +277,27 @@ def current_return_target(self) -> str | None:
return self.current_pane_id() or None

@abstractmethod
def detach_client(self) -> None:
"""Detach the client viewing the current session (best-effort: a no-op on
a transport failure)."""
def detach_client(self) -> bool:
"""Detach the client viewing the current session. Returns True iff a
client was actually detached — **effect, not dispatch**: a transport
failure answers False, and so does a backend with no real detach.
tmux gets this from the exit code (`detach-client` fails with "no
current client"); a backend whose CLI exits 0 either way measures it
instead (psmux counts the session's attached clients across the call).
Callers that only want the terminal handed back may ignore the answer;
the parked-window return path cannot — it clears its return option on a
True, and a vacuous one strands the human, while a False is positive
evidence that nobody is watching this window any more (see
tui.launch.return_attached_client)."""

@abstractmethod
def switch_client(self, target: str, last_fallback: bool = False) -> bool:
"""Switch the current client to ``target`` (optionally falling back to
the last client on failure). Returns True iff a switch happened — so a
transport failure returns False. ``target`` is a :meth:`target` token
or a backend-native id."""
the last client on failure). Returns True iff a switch happened — the
same effect-not-dispatch rule as :meth:`detach_client`, so a transport
failure returns False and a backend whose CLI cannot report the move
measures it or answers False. ``target`` is a :meth:`target` token or a
backend-native id."""

@abstractmethod
def available(self) -> bool:
Expand Down
85 changes: 84 additions & 1 deletion src/bmad_loop/adapters/psmux_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@
the window-option verbs, the ``@``-prefixed columns of ``list_windows``
and the parked trailer route through a substitute channel — see the
``per-window option channel (#310)`` block below for the model and its
rules. ``available()`` additionally gates on
rules. ``detach-client`` and ``switch-client`` report dispatch rather than
effect — every arm exits 0 whether or not a client moved — so both seam
booleans are measured against the session's attached-client count instead of
read off the exit code; see the ``client verbs: observed effect (#317)``
block. ``available()`` additionally gates on
the reported version: psmux releases up to 3.3.6 kill recycled PIDs during
pane/session teardown without a process-identity check, which can take down
an unrelated long-lived process mid-run. The psmux behaviors cited in this
Expand Down Expand Up @@ -721,6 +725,85 @@ def current_return_target(self) -> str | None:
return pane
return self.target(session, pane)

# ------------------------------- client verbs: observed effect (#317)
#
# psmux's client verbs report *dispatch*, not effect. At ``v3.3.7`` both CLI
# arms end in ``send_control(cmd)?; return Ok(())``, so the only nonzero exit
# is an unreachable session server: ``detach-client`` succeeds with zero
# clients attached (and a flag-less detach is promoted server-side to
# detach-all), and *no* form of ``switch-client`` — ``-t`` included —
# carries a server reply. Later builds narrow this but do not close it: a
# response path landed after the tag and only for ``-t``, leaving ``-l``
# exit-0 regardless. Taking those exit codes as the seam's booleans is the
# rc-0 no-op (#228) reached through Python instead of a shell fallback, and
# it is what ``tui.launch.return_attached_client`` would consume to decide a
# human has been handed their terminal back.
#
# So the exit code is discarded and the effect is measured: count the
# clients attached to this session before and after the verb, and answer on
# the DELTA. Never on an absolute count — a ``-t`` read against a session
# whose server is gone answers from whichever server the fallback picks, so
# "zero attached" on its own proves nothing (#315). A drop cannot be
# manufactured that way: it needs two successful reads of the same session,
# the first of them nonzero.
#
# Unobservable answers False, never a vacuous True. For the detach that is
# safe in both directions — the caller's UNREACHABLE and RETURNED agree that
# nobody is left at this terminal, and the only cost is the parked trailer
# re-issuing a detach that no-ops. For the switch it is also the truth
# today: the switch leg is inert at 3.3.7 (psmux/psmux#483), so no client
# ever moves. When upstream lands that fix the probe reports it without a
# code change here — which is why this measures rather than hardcoding.
#
# Residue: a switch whose target pane lives in THIS session moves the client
# between windows without changing the session's attached count, so it reads
# as no effect. Reachable only when the return target was recorded from
# inside a control-session window; the caller then keeps prompting, which is
# the safe direction.

def _attached_clients(self, session: str) -> int | None:
"""Clients attached to ``session``, or None when psmux cannot say.

Self-detecting on purpose: an unsupported format field cannot answer a
plausible integer, so a psmux build that does not carry
``#{session_attached}`` degrades to None instead of a wrong count.
"""
try:
proc = self._run(
["display-message", "-p", "-t", session, "#{session_attached}"], check=False
)
except (subprocess.SubprocessError, OSError):
return None
if proc.returncode != 0:
return None
text = proc.stdout.strip()
return int(text) if text.isdigit() else None

def _client_left(self, verb: list[str]) -> bool:
"""Run a client verb and answer whether a client left this session."""
session = self.current_session()
if not session:
# Not inside a pane (or the probe failed): there is no "this
# session" to measure against, and no client of ours to move.
return False
before = self._attached_clients(session)
try:
self._run(verb, check=False)
except (subprocess.SubprocessError, OSError):
return False
after = self._attached_clients(session)
if before is None or after is None:
return False
return before > 0 and after < before

def detach_client(self) -> bool:
return self._client_left(["detach-client"])

def switch_client(self, target: str, last_fallback: bool = False) -> bool:
if self._client_left(["switch-client", "-t", target]):
return True
return last_fallback and self._client_left(["switch-client", "-l"])

def pipe_pane(self, window_id: str, log_file: Path) -> None:
# The base's POSIX `cat >>` sink assumes a POSIX host shell, and psmux
# strips every dash-flag token from the piped command before spawning it
Expand Down
9 changes: 6 additions & 3 deletions src/bmad_loop/adapters/tmux_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,11 +429,14 @@ def _display_message(self, fmt: str) -> str | None:
return None
return proc.stdout.strip() if proc.returncode == 0 else None

def detach_client(self) -> None:
def detach_client(self) -> bool:
# Returns True iff a client was detached; a transport failure didn't
# detach anything, so False is the honest answer.
try:
self._run(["detach-client"], check=False)
proc = self._run(["detach-client"], check=False)
except (subprocess.SubprocessError, OSError):
pass
return False
return proc.returncode == 0

def switch_client(self, target: str, last_fallback: bool = False) -> bool:
# Returns True iff a switch happened; a transport failure didn't switch, so
Expand Down
29 changes: 23 additions & 6 deletions src/bmad_loop/sweep.py
Original file line number Diff line number Diff line change
Expand Up @@ -1006,16 +1006,33 @@ def _return_after_decisions(self) -> None:
detach a plain-shell client, switch a tmux client back to its origin. A
plain foreground sweep (nobody attached, no return target) is untouched.

After a successful return we go unattended for the rest of the run: a
later --repeat cycle's input() would otherwise block forever in a window
no one is viewing. New decisions then defer via the unattended path,
recorded for `bmad-loop decisions` or the next attended sweep."""
We then go unattended for the rest of the run: a later --repeat cycle's
input() would otherwise block forever in a window no one is viewing. New
decisions defer via the unattended path instead, recorded for
`bmad-loop decisions` or the next attended sweep.

The trigger for that is "nobody can be relied on to answer here any
more", NOT "the hand-back succeeded" — the two come apart on a failed
return, in opposite directions. A failed switch is evidence the client
is still in this window with a human in front of it (ATTENDED: keep
prompting, which is the whole point of #227). A failed detach reports
only that no hand-back was verified — nothing attached, an effect the
backend cannot observe, or no detach verb at all — and under that
uncertainty going unattended is the outcome that does not strand a
--repeat cycle on input(); the decisions it defers stay reachable via
`bmad-loop decisions`. Only a real return is announced: UNREACHABLE
prints nothing, since there may be no one to read it."""
from .tui import launch # import-light: launch.py has no textual imports

if launch.return_attached_client():
outcome = launch.return_attached_client()
if outcome is launch.ReturnOutcome.ATTENDED:
return
self.prompting = False
if outcome is launch.ReturnOutcome.RETURNED:
self.journal.append("sweep-returned-after-decisions")
self.prompter.print_fn("✓ decisions recorded — sweep continues in the background")
self.prompting = False
else:
self.journal.append("sweep-return-no-client")

def _apply_decision_effect(self, decision: Decision, option: DecisionOption) -> None:
ledger = self.workspace.paths.deferred_work
Expand Down
Loading