diff --git a/agent/HARDWARE-QUEUE.md b/agent/HARDWARE-QUEUE.md index 01f57f0..78760c2 100644 --- a/agent/HARDWARE-QUEUE.md +++ b/agent/HARDWARE-QUEUE.md @@ -32,7 +32,40 @@ the **T14s** (webcam case). nomarchy-battery-charge-limit.service` stays false, `Result=success`, BAT0's `charge_control_end_threshold` still matches the limit, and the journal has no new `start-limit-hit`/`Failed with result` lines. -- [ ] **Docking recovery round 5 (dock/keyboard/audio hotplug, 2026-07-14)** +- [ ] **Docking recovery round 7 (undock panel restore, 2026-07-14)** — + round 6 (AMD dev box, Bernardo) **passed on the plug side**: the dock + docked by itself, audio went straight to the monitor, and the wallpaper + painted on the external. One count failed: **unplugging the cable left + the laptop panel black**, recoverable only by re-docking. The journal + named the cause exactly — across three undocks, two logged + `transition=undock internal=eDP-1 result=enable-timeout` and one + `result=ok`, i.e. an intermittent race, not a logic error. Both halves + are fixed in the commit carrying this entry: `hyprctl keyword monitor + eDP-1,preferred,auto,1` is *accepted* (exit 0) while Hyprland is still + tearing down the departing external and then silently dropped, so the + transition now re-issues it on every poll for 5s rather than trusting + one shot; and the watcher no longer treats a failed undock as final — + the departure is queued and retried on the 1s tick (up to 6 attempts) + instead of being marked handled with `|| true`, which is what turned one + lost keyword into a panel dark until re-dock. **The VM harness cannot + reach this** (`tools/monitor-fallback.nix`: QEMU's headless backend + aborts Hyprland if the last active output is deleted while the DRM + output is disabled, so the test must undock *before* removing DP-1 and + never collides the two) — hardware is the only place it can be proven. + **Re-check:** relogin (mandatory — see below), dock, then unplug the + cable **at least five times**, since the old failure only hit ~2 of 3. + **Pass:** the panel comes back every single time with workspaces intact, + and `journalctl --user -t nomarchy-display-transition -t + nomarchy-display-watch --since '-10 min'` shows a + `transition=undock result=ok attempts=N` for each undock and **never** + `result=gave-up`. `attempts=2` or more is the retry doing its job and is + a pass; `gave-up` means the panel could not be enabled at all and is a + different fault — capture the artifacts listed in round 4 before + changing state. +- [ ] **Docking recovery round 5/6 (keyboard + headphone-jack counts, + 2026-07-14)** — round 6 confirmed the dock/audio/wallpaper counts + below; the **keyboard-prompts-exactly-once** and **headphone + plug/unplug** counts were not reported either way and still need a run. — round 4 was run on the AMD dev box and **failed on four counts** (Bernardo): no wallpaper on the external monitor, no automatic dock mode (System › Display had to be used by hand), audio staying on the diff --git a/agent/JOURNAL.md b/agent/JOURNAL.md index 733bcb2..8e1b632 100644 --- a/agent/JOURNAL.md +++ b/agent/JOURNAL.md @@ -19,6 +19,34 @@ Template: --- +## 2026-07-14 — docking round 6: undock left the laptop panel black (this commit) +- **Task:** HARDWARE-QUEUE docking round 6 result (Bernardo, AMD dev box). + Plug side all passed (auto-dock, audio to the monitor, wallpaper on the + external); unplugging left the panel black until re-docked. +- **Did:** Bernardo's journal was decisive — 3 undocks, 2× + `result=enable-timeout` + 1× `result=ok`, so an intermittent race. Two + fixes in modules/home/hyprland.nix: (1) the undock `hyprctl keyword monitor + eDP-1,preferred,auto,1` exits 0 but is dropped when it lands mid-teardown + of the departing external — now re-issued every poll for 5s, with + `monitors` listing the panel as the only proof; (2) the watcher marked the + departure handled via `"$TRANSITION" undock … || true` + `awaiting_lid_open=1` + and `known_outputs` had already moved, so nothing ever retried — one lost + keyword = a dark panel. The undock is now a queued invariant driven on the + 1s tick (6 attempts, then `result=gave-up`). +- **Verified:** V2 — `nix flake check --no-build` all checks passed; + `nix build --impure -f tools/monitor-fallback.nix` exit 0 in 57s (reaches + the final `lid-open transition` checkpoint); shellcheck clean on both + generated scripts (the lone SC2125 is pre-existing + intentional). + **Honest limit:** the harness CANNOT reach this race — QEMU's headless + backend aborts Hyprland if the last active output is deleted while the DRM + output is disabled, so it must undock before removing DP-1 and never + collides the two. The fix itself is **V3 pending**; comment added at + tools/monitor-fallback.nix so a reader doesn't mistake a pass for coverage. +- **Pending:** V3 → HARDWARE-QUEUE round 7 (unplug ≥5×; old failure was only + ~2 in 3). Round 5/6's keyboard-prompt + headphone-jack counts were never + reported either way — split into their own entry, still unverified. +- **Next suggestion:** wait on round 7 before touching the dock path again. + ## 2026-07-13 — #99 live-seed git-init; theme-switch failure filed as #113 (this commit) - **Task:** BACKLOG #99 — false "theme-state.json NOT git-tracked" doctor/ Waybar warning in the live session; resolve the held question from Fable. diff --git a/modules/home/hyprland.nix b/modules/home/hyprland.nix index eae6c7e..4730939 100644 --- a/modules/home/hyprland.nix +++ b/modules/home/hyprland.nix @@ -181,16 +181,19 @@ let valid_output "$internal" || { echo "invalid monitor name" >&2; exit 64; } # Safety-critical ordering: never remove/move away from an external # before the internal panel is active and can receive workspaces. - hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1 \ - || { log "transition=undock internal=$internal result=enable-failed"; exit 1; } + # The enable races the departing external's teardown: a keyword that + # lands mid-modeset is accepted (hyprctl exits 0) and then quietly + # dropped, so re-issue it every poll instead of trusting one shot. + # `monitors` listing the panel — not hyprctl's exit code — is proof. ready= tries=0 - while [ "$tries" -lt 20 ]; do + while [ "$tries" -lt 25 ]; do + hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1 if hyprctl monitors -j 2>/dev/null \ | jq -e --arg m "$internal" 'any(.[]; .name == $m)' >/dev/null; then ready=1; break fi - tries=$((tries+1)); sleep 0.1 + tries=$((tries+1)); sleep 0.2 done [ -n "$ready" ] \ || { log "transition=undock internal=$internal result=enable-timeout"; exit 1; } @@ -324,6 +327,8 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList inhibitor_pid= inhibitor_file="$rt/nomarchy-dock-lid-inhibitor.pid" awaiting_lid_open= + undock_pending= + undock_tries=0 log() { "$LOGGER" -t nomarchy-display-watch -- "$*"; } internal_output() { if [ -n "''${NOMARCHY_INTERNAL_OUTPUT:-}" ]; then @@ -513,6 +518,9 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList known_outputs=$cur if [ -n "$added_ext" ]; then + # A new external supersedes any undock still queued for an older + # departure: the panel is about to be disabled again on purpose. + undock_pending= [ -z "$internal" ] || acquire_inhibitor # An external output that was not there a moment ago is an # unambiguous physical plug whether or not we saw the IPC event, @@ -522,13 +530,16 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList paint_outputs & log "outputs-changed added=$added_ext audio-reprobe=scheduled" fi - # awaiting_lid_open marks an undock already performed for this + # awaiting_lid_open marks an undock already completed for this # departure — the guard is what keeps event + tick from doubling up. + # The attempt itself is deliberately NOT made here: it is queued and + # driven by the invariant below, because the first try races the + # external's teardown and losing it must not cost the panel. if [ -n "$gone" ] && [ -z "$ext" ] && [ -n "$internal" ] \ && [ -z "$awaiting_lid_open" ]; then - "$TRANSITION" undock "$internal" || true - awaiting_lid_open=1 - log "outputs-changed removed=$gone transition=undock" + undock_pending=1 + undock_tries=0 + log "outputs-changed removed=$gone transition=undock-queued" fi check_monitors if [ -n "$added_ext" ] \ @@ -538,6 +549,28 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList fi # Safety invariants, re-checked regardless of whether the set moved. + # + # A dark panel is the worst outcome this watcher can produce, so the + # undock is an invariant driven to completion on the tick — not a + # one-shot fired off the removal event. On hardware the first attempt + # loses the race with the external's teardown often enough that + # treating it as final left the laptop black until it was re-docked. + if [ -n "$undock_pending" ] && [ -z "$ext" ] && [ -n "$internal" ]; then + if "$TRANSITION" undock "$internal"; then + undock_pending= + awaiting_lid_open=1 + log "transition=undock result=ok attempts=$((undock_tries+1))" + else + undock_tries=$((undock_tries+1)) + # Bounded: a panel that cannot be enabled at all is a different + # fault, and retrying forever would pin the lid inhibitor with it. + if [ "$undock_tries" -ge 6 ]; then + undock_pending= + awaiting_lid_open=1 + log "transition=undock result=gave-up attempts=$undock_tries" + fi + fi + fi if [ -n "$internal" ] && [ -n "$ext" ]; then if [ -z "$inhibitor_pid" ] || ! kill -0 "$inhibitor_pid" 2>/dev/null; then acquire_inhibitor || true diff --git a/tools/monitor-fallback.nix b/tools/monitor-fallback.nix index b660da4..293a66b 100644 --- a/tools/monitor-fallback.nix +++ b/tools/monitor-fallback.nix @@ -130,6 +130,13 @@ pkgs.testers.runNixOSTest { # monitorremoved event still proves that the watcher retains the inhibitor # until lid-open, without mistaking a backend limitation for product # behavior. Physical cable-yank timing remains the bounded V3 check. + # + # Undocking a quiescent output is therefore all this harness can assert — + # and that gap is exactly what shipped a black panel in round 6: on real + # hardware the enable collides with the departing external's teardown and + # is silently dropped ~2 times in 3. The keyword re-issue in the + # transition and the tick-driven retry in the watcher are what cover it; + # neither is exercised below, so do not read a pass here as proof of them. user(f"{transition} undock Virtual-1") machine.wait_until_succeeds( hy + "-j monitors' | jq -e 'any(.[]; .name == \"Virtual-1\")'", timeout=20