diff --git a/CHANGELOG.md b/CHANGELOG.md index f7014b50..1fa6d02c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,10 @@ breaking changes may land in a minor release. writes became atomic. Their writers now refuse with the `PermissionError` a plain `write_text` used to raise. Machine-minted state (run archives, stop requests, the config-digest stamp) is unaffected. +- **Document the qualified-id obligation once for adapter authors (#311).** The authoring guide + states the rule a native-id backend must follow rather than leaving it to be inferred from + psmux's per-seam specifics, and `TerminalMultiplexer.new_parked_window` now says its id is + opaque and MAY be qualified, matching `new_window`. Documentation only; no behavior change. ### Fixed diff --git a/docs/adapter-authoring-guide.md b/docs/adapter-authoring-guide.md index 1b35a5b3..86637843 100644 --- a/docs/adapter-authoring-guide.md +++ b/docs/adapter-authoring-guide.md @@ -155,20 +155,17 @@ module-level `parse_target()` — or the backend's own **native id** (whatever y `target()`, precisely so the seam grammar never has to carry a pane or window id: the parked-window return target — `current_return_target`, above — and the native window id, which psmux qualifies to `session:@N` because its ids are -per-server (falling back to the bare id where that grammar would not survive — -the backend owns those conditions, and applies them uniformly, so the -`new_window`/`list_window_ids` symmetry rule holds under the fallback too). +per-server (falling back to the bare id where that grammar would not survive). Both are replayed opaquely; neither is parsed by core. psmux applies the same qualification to `new_parked_window`, the `window_id` columns of `list_windows` -and `current_window_id`; the latter two must agree, since the ctl-window prune -compares them to skip its own window. To preserve unambiguous lookup, -`new_parked_window` must agree with the `list_windows` column too; a backend that -qualifies one side only remains usable but falls back to resolving parked -windows by name, which is ambiguous whenever several kinds share a run id -(#482). tmux consumes the token natively (it coincides with tmux exact-match -syntax), so `BaseTmuxBackend` passes it straight through. A native-id backend -calls `parse_target()` first — `None` means "already a native id, use as-is", -otherwise resolve `(session, window)` yourself; the herdr adapter's +and `current_window_id`. To preserve unambiguous lookup, `new_parked_window` must +agree with the `list_windows` column; a backend that qualifies one side only +remains usable but falls back to resolving parked windows by name, which is +ambiguous whenever several kinds share a run id (#482). tmux consumes the token +natively (it coincides with tmux exact-match syntax), so `BaseTmuxBackend` passes +it straight through. A native-id backend calls `parse_target()` first — `None` +means "already a native id, use as-is", otherwise resolve `(session, window)` +yourself; the herdr adapter's `_parse_target` ([backend.py](https://github.com/pbean/bmad-loop-adapter-herdr/blob/main/src/bmad_loop_adapter_herdr/backend.py)) is the worked example (workspace-by-label → tab-by-name → root pane, resolved @@ -177,6 +174,35 @@ stay a stable _by-name_ reference: core formats targets ahead of use (a parked window's return target, for one), so eager resolution to a live id goes stale — inheriting the default and resolving lazily is almost always right. +**The qualified-id obligation.** The psmux qualifications above are instances of +one rule, and it is the rule — not the instances — a native-id backend needs: +**if an id-minting seam returns anything other than a bare native id, every seam +whose output core compares that id against must emit the identical form, and +every verb the id is replayed through must accept it.** Where a verb cannot take +that form — or cannot take a window scope at all — the backend translates inside +that verb rather than exempting the id from qualification: psmux has no +per-window _user_ options to write to (#310), so its option trio runs the +qualified target through `_option_scope` and writes a session-scoped key carrying +the window's id, refusing a bare id rather than guessing a server (the built-ins +pass straight through to the base). It carried a second such translation on +`select_window` until its 3.3.8 floor made the server resolve a scoped id itself +(psmux/psmux#497). Where the composed grammar cannot carry the value at all, the +backend degrades uniformly across every seam that mints or lists the id, so the +pairings still line up under the fallback — but the fallback is lossy, not a free +escape hatch: a bare psmux id routes by the _caller's_ server, so the degrade +condition must stay the narrow one the grammar forces (#221), never a +convenience. Those pairings are not a closed set: a new one appears whenever a +caller compares two id-producing seams to each other, so treat the rule as the +contract and each documented pairing as an instance of it. Every way of getting +the _form_ wrong is quiet — these are id-shape faults, never transport faults, +which `list_window_ids` must still raise: a mint/list split reads every live +window as instantly dead; a `list_windows`/`current_window_id` split makes the +ctl-window prune kill the window it is running in (#291); a +`list_windows`/`list_window_ids` split reports every kill candidate as verifiably +gone, survivors included (#435). And a verb handed a form it rejects fails or +silently no-ops — the seam is best-effort or its caller swallows the raise, so no +error reaches core either way. + Operations that can race a window dying (`pipe_pane`) or a session already being gone (`kill_session`) must tolerate it rather than raise; everything else raises a `MultiplexerError` subclass on failure, which call sites catch at the seam (e.g. diff --git a/src/bmad_loop/adapters/multiplexer.py b/src/bmad_loop/adapters/multiplexer.py index f158eb8b..b57dbc75 100644 --- a/src/bmad_loop/adapters/multiplexer.py +++ b/src/bmad_loop/adapters/multiplexer.py @@ -171,8 +171,13 @@ def new_parked_window( """Create a window that runs ``argv`` then *parks* — waiting on a key so the exit status stays inspectable instead of the window closing the moment the process exits — and finally returns an attached client to its origin - (keyed by the per-window ``return_opt``). Returns the native window id; - for its required form see :meth:`list_window_ids`'s note on #482.""" + (keyed by the per-window ``return_opt``). Returns the native window id. + + That id is **opaque to core** exactly as :meth:`new_window`'s is, so a + backend MAY return an already-qualified target rather than a bare id + (psmux returns ``session:@N``) — and an obligation follows from that + choice here too, a different pairing than :meth:`new_window`'s: for the + form it binds the id to, see :meth:`list_window_ids`'s note on #482.""" @abstractmethod def list_window_ids(self, session: str) -> list[str]: