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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,15 @@ breaking changes may land in a minor release.
- Refuse psmux option values shaped `-X=value`, `-t…` or `--`: psmux 3.4.0 rewrites the
first silently and rejects the others, so the value is now warned about and its key freed
(#905).
- Stop `cleanup` and the TUI from calling the sessions in a displaced registry `not migrated`
when it is another project's derived `PSMUX_DATA_DIR` (or this project's from before a
rename): name it as such and say its control session and untagged sessions most likely
belong to that project, while a session tagged as this project's is yours; the stderr
note names it too (#906).
- Report a pre-upgrade `bmad-loop-ctl-<16 hex>` control session left in the project's own
psmux registry: `cleanup` names it on stderr and in `sessions.legacy_leftovers` with the
command that ends it (`sessions.legacy_kill_commands`), and `attach` names it when it finds
nothing to attach (#733).
- Fire the TUI's stale state-root note on psmux too: the psmux backend now answers which
value a new pane inherits through psmux 3.4.0's named `show-environment` (#660).
- Fix the OpenCode V2 SSE-reconnect end-to-end test failing on slow runners (Windows): the
Expand Down
22 changes: 20 additions & 2 deletions docs/multiplexer-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,12 @@ prunes it. Nothing launches into it, though, so the TUI's attach and stop keys a
parked-window sweep do not reach its windows. To end one, let its runs finish
(`bmad-loop stop` reaches a live engine wherever it runs) and check that every window shows
the exit banner (see `capture-pane` below). Then run
`psmux kill-session -t bmad-loop-ctl-<16 hex>`.
`psmux kill-session -t bmad-loop-ctl-<16 hex>`. `cleanup` names the one an earlier release
started for this project in its registry (matched by that exact name, since a run id
`ctl-<16 hex>` gives an agent session the same shape) on stderr and in
`sessions.legacy_leftovers`, with that command ready to paste
(`sessions.legacy_kill_commands` in `--json`). `attach` names it when it finds nothing to
attach, with `switch-client` instead of `attach` when run inside a psmux pane.

**If the state root cannot be derived** (for example, `BMAD_LOOP_STATE_DIR` is a relative
path), bmad-loop says so and leaves whatever `PSMUX_DATA_DIR` was in force. `cleanup` then
Expand Down Expand Up @@ -202,9 +207,11 @@ repeats the warning. A server already running with it on is not detected. Unset
before the per-project root: psmux's default registry, and any `PSMUX_DATA_DIR` root
bmad-loop displaced. It kills only sessions that carry this project's ownership tag and
whose run is not still going. Whatever it leaves is named on stderr as
`left in <registry> (not migrated): …` and in `cleanup --json` at
`left in <registry> (<label>): …` and in `cleanup --json` at
`sessions.legacy_leftovers`; a registry that could not be asked at all is named as
`legacy registry not checked …` (`sessions.legacy_unverified`, a toast in the TUI).
The label is `not migrated`, except for a registry derived from another project path,
which gets its own label (below).

Three kinds stay behind by design:

Expand All @@ -213,6 +220,17 @@ Three kinds stay behind by design:
- **A live session of your own.** Cleanup never kills a run whose engine is still going.
- The shared **`bmad-loop-ctl` control session** in that registry.

**Sessions in another registry.** A displaced root shaped like one bmad-loop derived
(`<project tag>/_mux`) under a tag that is not this project's is reported in full like any
other, but its line reads `left in <registry> (another project's derived registry, or this
project's from before a rename): …` instead of `(not migrated)`. That is the root
`bmad-loop mux` printed for another project, or for this one before it was renamed or moved,
still exported in the shell. Its control session and untagged sessions most likely belong to
that project: do not remove them from here. A session tagged as this project's is yours. A
derived-shaped root whose tag cannot be compared gets the same wording. The
`note: using bmad-loop's own psmux registry …` line that names the displaced root says it is
such a registry, and `cleanup --json` is unchanged.

To inspect one, put the shell on the registry the message named:

```powershell
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -546,7 +546,7 @@ The `--dry-run` plan and the outcome share this shape.
| `sessions` | object | agent sessions (below) |
| `ctl_windows` | object | control windows (below) |

`sessions`: `removed` (run ids whose session kill was attempted), `live` (left alone), `unverifiable_pid` (subset of `removed` whose engine liveness could not be proven), `legacy_leftovers`, `legacy_unverified`, and `scan_error` (present only when the scan failed).
`sessions`: `removed` (run ids whose session kill was attempted), `live` (left alone), `unverifiable_pid` (subset of `removed` whose engine liveness could not be proven), `legacy_leftovers`, `legacy_kill_commands` (a pre-upgrade control session in this project's registry mapped to the command that ends it, `null` when the backend is not psmux), `legacy_unverified`, and `scan_error` (present only when the scan failed).

`ctl_windows`: `removed` (verified gone, or would close under `--dry-run`), `survived`, `unverifiable` (kill outcome could not be checked), `undetermined` (kept because the window's state could not be read), `scan_error` (str or null; when set, the other lists mean "no answer").

Expand Down
154 changes: 149 additions & 5 deletions src/bmad_loop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,9 +249,15 @@ def _configure_mux(project: Path) -> None:
# An honoured value is the operator's own stated preference, so it gets
# no note; `bmad-loop mux` still says which source won.
if ambient is not None and ambient != root:
owner = (
f"is {runs.FOREIGN_REGISTRY}, left alone; its sessions most likely belong to "
"that project"
if runs.foreign_registry_root(ambient, project)
else "is left for your own sessions"
)
print(
f"note: using bmad-loop's own psmux registry {root} — your "
f"{runs.PSMUX_DATA_DIR}={ambient} is left for your own sessions "
f"{runs.PSMUX_DATA_DIR}={ambient} {owner} "
f"(`bmad-loop mux` prints the export that reaches these)",
file=sys.stderr,
)
Expand Down Expand Up @@ -6428,6 +6434,53 @@ def cmd_list(args: argparse.Namespace) -> int:
return 0


def _note_superseded_ctl_for_attach(project: Path) -> None:
"""After "nothing to attach": name each pre-upgrade control session still
standing in this project's registry (#733), with the command that attaches
to it, because a run window parked there is one attach cannot see.

Named, not adopted. attach, stop and the parked-window sweep address
``runs.ctl_session_for`` only (see ``runs.is_ctl_session_name``); finding
a window here would land the operator in a session `stop` cannot then
close the window of, and a decision answered there would hand its client
back to nowhere. So the operator is pointed at it and the session ends when
they end it (`cleanup` prints how).

The verb is the one attach itself would run here: inside a multiplexer
client a nested attach is refused, so the backend switches the client
instead (``attach_target_argv``). A listing that could not be taken is said,
not passed off as "no such session" after "nothing to attach".

A paste-ready command only on psmux (``_is_psmux``); another backend's
session is named without one."""
from .adapters.multiplexer import MultiplexerError, get_multiplexer

def _fault(msg: str) -> None:
print(
f"note: could not check this project's registry for a pre-upgrade control "
f"session: {msg}",
file=sys.stderr,
)

try:
mux = get_multiplexer()
stale = runs.superseded_ctl_sessions(project, mux, on_fault=_fault)
except MultiplexerError as exc:
_fault(str(exc))
return
psmux = _is_psmux(mux)
for name in stale:
where = "this run's window may be there"
if psmux:
verb = mux.attach_target_argv(f"={name}")[1]
where += ": " + _psmux_command(mux.registry_root(), f"{verb} -t '={name}'")
print(
f"note: pre-upgrade control session {name} is still running in this "
f"project's registry, and attach does not look inside it; {where}",
file=sys.stderr,
)


def cmd_attach(args: argparse.Namespace) -> int:
from .tui import launch # import-safe: launch.py has no textual imports

Expand Down Expand Up @@ -6464,6 +6517,7 @@ def ctl_fault(msg: str) -> None:
# Not after a ctl fault: "nothing to attach" would claim the
# unchecked ctl window absent (#750). The warning above stands.
print(f"nothing to attach for run {run_dir.name}", file=sys.stderr)
_note_superseded_ctl_for_attach(project)
return 1
argv, return_window = plan
# Record where to send the client once the sweep finishes this cycle's
Expand Down Expand Up @@ -6652,8 +6706,80 @@ def cmd_archive(args: argparse.Namespace) -> int:
return 0


def _is_psmux(mux: object) -> bool:
"""Whether ``mux`` is the bundled psmux backend, the one transport whose
commands :func:`_psmux_command` can spell. ponytail: no generic renderer;
an out-of-tree registry-namespaced backend gets its sessions named, not a
command, until one needs more."""
from .adapters.psmux_backend import PsmuxMultiplexer

return isinstance(mux, PsmuxMultiplexer)


def _psmux_command(registry: str | None, verb: str) -> str:
"""A paste-ready PowerShell line running ``psmux <verb>`` against
``registry`` (psmux's own default when ``None``). Quoted the way
``_mux_info`` quotes its export line. psmux only (:func:`_is_psmux`)."""
run = f"psmux {verb}"
Comment thread
dracic marked this conversation as resolved.
if registry is None:
return run
quoted = registry.replace("'", "''")
return f"$env:{runs.PSMUX_DATA_DIR} = '{quoted}'; {run}"


def _superseded_ctl_kill_commands(
project: Path, leftovers: dict[str, list[str]]
) -> dict[str, str | None]:
"""``{name: command that ends it}`` for each control session in the
registry in force under the name an earlier release minted for this
project there (#733, ``runs.superseded_ctl_name``), picked out of
``leftovers`` rather than listed a second time.

A command here and none for the other leftovers, because this session is
ours beyond doubt — its name is a digest of this project's derived root —
and nothing else will ever end it: no bmad-loop command addresses it. The
caveat travels with the command: ending it kills whatever its windows
still run. ``None`` for the command when the backend is not psmux
(:func:`_is_psmux`): the session is named, and no command is advertised
that could not run there."""
if not leftovers:
return {}
from .adapters.multiplexer import MultiplexerError, get_multiplexer

try:
mux = get_multiplexer()
root = mux.registry_root()
old = mux.session_name_key(runs.superseded_ctl_name(project, root))
except (MultiplexerError, runs.StateRootError, OSError, RuntimeError):
# Already reported: the same derivation in `legacy_registry_leftovers`
# put this fault in `legacy_unverified`, and named no session for it.
return {}
# The exact name `runs.superseded_ctl_sessions` matched, never the shape: a
# run's agent session `bmad-loop-ctl-<16 hex>` is spelled the same way.
psmux = _is_psmux(mux)
return {
name: _psmux_command(root, f"kill-session -t '={name}'") if psmux else None
for name in leftovers.get(root or runs.DEFAULT_REGISTRY_LABEL, [])
if mux.session_name_key(name) == old
}


def _warn_superseded_ctl(kill_commands: dict[str, str | None]) -> None:
for name, command in kill_commands.items():
how = f"end it with: {command}" if command else "end it with your multiplexer"
print(
f"pre-upgrade control session {name} is still running in this project's "
"registry, and no bmad-loop command addresses it; once its runs have "
"finished and every window in it shows the exit banner (ending it kills "
f"what they still run), {how}",
file=sys.stderr,
)


def _warn_legacy_leftovers(
leftovers: dict[str, list[str]], unverified: list[str] | tuple[str, ...] = ()
project: Path,
leftovers: dict[str, list[str]],
unverified: list[str] | tuple[str, ...] = (),
) -> None:
"""Name what each legacy multiplexer registry still holds after the sweep.

Expand Down Expand Up @@ -6687,13 +6813,27 @@ def _warn_legacy_leftovers(

``unverified`` names each legacy registry that could not be asked at all
(DW-469): without a line for it, a registry that raised printed exactly what
a registry holding nothing prints — nothing."""
a registry holding nothing prints — nothing.

A registry shaped like a root bmad-loop derived from another project path
(`runs.foreign_registry_root`, #906) gets its own line: its control session
and untagged sessions are not a migration remainder but most likely that
project's, which is the one thing to say before anyone removes them. A
session tagged as ours there is still named, and is still ours."""
for line in unverified:
print(
f"legacy registry not checked for sessions left behind (not migrated): {line}",
file=sys.stderr,
)
for registry, names in leftovers.items():
if runs.foreign_registry_root(registry, project):
print(
f"left in {registry} ({runs.FOREIGN_REGISTRY}): "
+ ", ".join(names)
+ f" — {runs.FOREIGN_REGISTRY_HINT}",
file=sys.stderr,
)
continue
print(
f"left in {registry} (not migrated): "
+ ", ".join(names)
Expand All @@ -6720,6 +6860,7 @@ def cmd_cleanup(args: argparse.Namespace) -> int:
leftovers, unverified = runs.legacy_registry_leftovers(
project, announced=killed if args.dry_run else ()
)
kill_commands = _superseded_ctl_kill_commands(project, leftovers)
# The listing behind prune_sessions reads an unavailable backend as no
# sessions; this names the cases where that is not a clean answer (#864).
# A misconfigured process host fails the liveness read behind it: a scan
Expand Down Expand Up @@ -6795,6 +6936,7 @@ def cmd_cleanup(args: argparse.Namespace) -> int:
# grouping serves the text mode, which has room to say where.
legacy_leftovers=sorted({n for names in leftovers.values() for n in names}),
legacy_unverified=unverified,
legacy_kill_commands=kill_commands,
sessions_scan_error=sessions_scan_error,
)
)
Expand All @@ -6809,7 +6951,8 @@ def cmd_cleanup(args: argparse.Namespace) -> int:
print(f"would close ctl window {name}")
if live:
print(f"leaving {len(live)} live session(s) untouched")
_warn_legacy_leftovers(leftovers, unverified)
_warn_legacy_leftovers(project, leftovers, unverified)
_warn_superseded_ctl(kill_commands)
return 0
# The count now excludes non-removals, so on stdout alone a smaller number is
# indistinguishable from a quieter sweep — and `cleanup > log` keeps only
Expand All @@ -6827,7 +6970,8 @@ def cmd_cleanup(args: argparse.Namespace) -> int:
# Same wording as the TUI toast: one claim, one phrase, so an operator
# moving between the two surfaces is reading the same thing.
print(f"ctl window(s) still open after the kill: {', '.join(survived)}", file=sys.stderr)
_warn_legacy_leftovers(leftovers, unverified)
_warn_legacy_leftovers(project, leftovers, unverified)
_warn_superseded_ctl(kill_commands)
if unverifiable:
# Not "killed but unverifiable": kill_window is a silent no-op on a
# transport failure, so whether the kill even reached the server is part
Expand Down
10 changes: 10 additions & 0 deletions src/bmad_loop/documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,7 @@ def cleanup_document(
windows_undetermined: list[str] | None = None,
legacy_leftovers: list[str] | None = None,
legacy_unverified: list[str] | None = None,
legacy_kill_commands: dict[str, str | None] | None = None,
sessions_scan_error: str | None = None,
) -> dict[str, object]:
"""The `cleanup --json` document: the multiplexer artifacts this invocation
Expand Down Expand Up @@ -458,6 +459,14 @@ def cleanup_document(
`legacy_unverified` means "not looked at", not "nothing left". Additive, no
schema bump; the text mode prints each line on stderr.

`sessions.legacy_kill_commands` maps each pre-upgrade control session in
this project's own registry (#733) — also named in `legacy_leftovers` — to
the paste-ready command that ends it, or to ``null`` when the backend is
not psmux and no command can be spelled for it. No bmad-loop command
addresses such a session, so this is the only way out; run it once every
window in it has parked. Additive, no schema bump; the text mode prints each
on stderr.

`sessions.scan_error` is present only when the session listing could not
see this project's sessions (#864): the backend is unavailable while a run
of this project is or may be alive (`runs.session_scan_error`). `removed` and
Expand All @@ -478,6 +487,7 @@ def cleanup_document(
"unverifiable_pid": sorted(unknown),
"legacy_leftovers": list(legacy_leftovers or []),
"legacy_unverified": list(legacy_unverified or []),
"legacy_kill_commands": dict(legacy_kill_commands or {}),
}
if sessions_scan_error is not None:
sessions["scan_error"] = sessions_scan_error
Expand Down
Loading
Loading