diff --git a/agent/BACKLOG.md b/agent/BACKLOG.md index 3300188..1f71e6f 100644 --- a/agent/BACKLOG.md +++ b/agent/BACKLOG.md @@ -28,72 +28,22 @@ in [`docs/ROADMAP.md`](../docs/ROADMAP.md); map in ## NEXT -### 76. Hibernation default `[big]` -**Product intent (Bernardo 2026-07-10):** hibernation on by default; -zram on alongside for live memory pressure. Disk swap ≥ RAM for the -hibernate image; zram high-priority for day-to-day swap (not sufficient -alone for resume). +### 76. Hibernation default — all agent work shipped `[blocked:hw]` +Hibernation + zram on by default (product intent, Bernardo 2026-07-10). +**All V0/V1/V2 slices done** (2026-07-10); only the V3 laptop check remains +(HARDWARE-QUEUE → "AMD dev box"). Design record in **ROADMAP § Hibernation + +zram by default**. Shipped: +- zram default (`modules/nixos/oom.nix`, zstd/50%/prio 100) — `checks.zram-swap`. +- Installer/template already default new installs to a RAM-sized encrypted + `@swap` swapfile + `resume_offset` wiring; `swapSize=0` = no-swap opt-out. +- `docs/MIGRATION.md` enable-hibernation runbook (existing machines). +- `rofi.nix` Power → Hibernate: kept unconditional, **notifies on failure**. +- `checks.hibernate` VM test (V2 PASS) — hibernate→resume→crash round-trip; + zram (prio 100) doesn't swallow the image, disk swap carries it. -**✓ zram slice shipped (2026-07-10):** `zramSwap` on by default in -`modules/nixos/oom.nix` (zstd, 50% RAM, priority 100 so day-to-day paging -stays off the disk swap reserved for the hibernate image); `checks.zram-swap` -VM test asserts device/algorithm/priority (V2 pass). **Remainder below is the -hibernation half** — disk swapfile default + resume + installer. - -**Reference layout (this machine — already hibernation-capable, no zram -yet):** LUKS (`crypto_LUKS` → mapper `crypted`) holds one BTRFS with -`@swap` mounted at `/swap`; swapfile `/swap/swapfile` (here 16G); -`swapDevices = [{ device = "/swap/swapfile"; }]`; -`boot.resumeDevice = "/dev/disk/by-uuid/"`; -`boot.kernelParams = [ "resume_offset=…" ]` from -`btrfs inspect-internal map-swapfile -r`. The swapfile lives **inside -the encrypted volume** (subvolume, not a separate cleartext partition). -Installer path already sketches this (`@swap` + resume_offset patch in -`nomarchy-install` / `patch-template.py`) when `swapSize` ≥ RAM-ish. - -**✓ Design settled (Bernardo 2026-07-10):** -- **Swap sizing** — keep **exactly RAM** (rounded up to whole GiB), as the - installer already does. Hibernate image ≤ RAM; zram (priority 100) takes - day-to-day paging so the file stays reserved for the image. No change. -- **`swapSize=0`** — stays a no-swap / no-resume opt-out (already handled by - installer + patch-template: no `@swap`, no `swapDevices`/resume wiring). -- **Migration (existing machines)** — **docs runbook in `docs/MIGRATION.md`**, - not a tool: create `@swap` subvol + swapfile, `map-swapfile -r` offset, - add `swapDevices`/`boot.resumeDevice`/`resume_offset` to `system.nix`. -- **No-swap Hibernate UX** — **keep the Power-menu Hibernate row**; when - hibernate fails (no swap), show a desktop **notification** explaining no - swap is configured (rather than hiding the row or a silent no-op). - -**Already shipped for NEW installs** (verify, don't rebuild): installer -defaults `NOMARCHY_SWAP_GB=RAM` → hibernation-ready `@swap` swapfile encrypted -with root; `patch-template.py` writes `swapDevices` + `boot.resumeDevice` + -`resume_offset`; Power menu has a Hibernate row; hibernate+LUKS+hyprlock -interplay handled in `modules/nixos/default.nix` / `modules/home/idle.nix`. - -**Remaining slices:** - -1. ~~**Docs** — `docs/MIGRATION.md` hibernation-enable runbook (V0).~~ ✓ - shipped 2026-07-10 (§ "Enabling hibernation on an existing machine"; - commands verified live against the dev machine's LUKS+@swap layout). -2. ~~**Menu** — Hibernate row: notify-on-failure when no swap configured.~~ - ✓ shipped 2026-07-10 (`rofi.nix` power arm: `systemctl hibernate || - notify-send …`; success blocks until resume so the notify only fires on - real failure). V1 (HM generation builds; `writeShellScriptBin` bash -n) + - control-flow proof (fail→notify, success→no notify). Row kept unconditional. - The session-level "no-swap pick surfaces a mako notification" is folded - into the V3 laptop check below. -3. **Verify (required)** - - **V0:** eval / option / disko contracts. - - **V2 (mandatory, agent):** exercise hibernation in the **VM harness** - (`runNixOSTest` or install-test style) — encrypted root + BTRFS - `@swap` swapfile + resume_offset; prove hibernate→resume path at - least as far as QEMU allows (fail the item if only “config - evaluates”). - - **V3:** real laptop (this LUKS+@swap layout): Hibernate → power off - → resume session; queue HARDWARE-QUEUE. - -**Out of scope:** TLP; formatter. Prefer boring NixOS knobs; extend the -existing installer swapfile story rather than inventing a second layout. +**V3 remaining:** real hibernate→resume on the LUKS+@swap swapfile+offset +layout (QEMU can't feed a boot-time offset / drive LUKS-initrd) + the no-swap +notify in a real session. Steps queued in HARDWARE-QUEUE. Close #76 on PASS. ## LATER diff --git a/agent/HARDWARE-QUEUE.md b/agent/HARDWARE-QUEUE.md index 0d3b21a..32341bb 100644 --- a/agent/HARDWARE-QUEUE.md +++ b/agent/HARDWARE-QUEUE.md @@ -290,6 +290,27 @@ that run; leftover open items still need a pass.* screenshot loaded. Check that the UI draws on the current theme palette (tools colored properly) and hitting save places the screenshot in `~/Pictures/Screenshots/` while hitting copy places it in the clipboard. +- [ ] **#76 hibernate→resume on the real LUKS+@swap layout** — the V2 VM test + proves the resume mechanism + zram coexistence, but QEMU can't feed a + boot-time `resume_offset` for a btrfs *swapfile* nor drive the LUKS + initrd unlock, so this triple needs hardware (dev box already runs it: + LUKS mapper `cryptroot`, btrfs `@`, `/swap/swapfile`). + 1. Confirm the layout: `swapon --show` lists `/swap/swapfile` (Type file) + **and** `/dev/zram0` at priority 100; `cat /proc/cmdline` shows + `resume_offset=…`; `nixos-option boot.resumeDevice` is the btrfs UUID. + 2. Open apps, note an unsaved in-RAM state (e.g. a terminal `echo` scroll, + an editor buffer). Run `nomarchy-menu → Power → Hibernate` (or + `systemctl hibernate`). Machine writes the image and **powers off**. + 3. Power on: you unlock LUKS **once** in the themed prompt and land back + in the **same session** — windows/state restored, not a fresh login. + 4. **Pass** = session restored after a full power-off + single unlock. + Fail (fresh boot / stuck initrd) → NOW bug in BACKLOG (capture + `journalctl -b -1 | grep -i -E 'hibernat|resume|swap'`). +- [ ] **#76 no-swap Hibernate notify** — on a machine installed with `swap = 0` + (or temporarily `sudo swapoff -a`): `nomarchy-menu → Power → Hibernate` + must surface a desktop notification ("Couldn't hibernate — likely no + swap is configured. See docs/MIGRATION.md → Enabling hibernation."), + **not** a silent no-op. (`swapon` after, if you swapoff'd for the test.) ## AMD dev box only - [ ] **AMD runtime bits** — VA-API (`vainfo` → radeonsi), amd-pstate EPP diff --git a/agent/JOURNAL.md b/agent/JOURNAL.md index 9024746..c5dd123 100644 --- a/agent/JOURNAL.md +++ b/agent/JOURNAL.md @@ -17,6 +17,24 @@ Template: --- +## 2026-07-10 — #76 slice 3: hibernate VM test (V2 PASS) → #76 agent-complete +- **Task:** BACKLOG #76 slice 3 — the mandatory V2 hibernate test + V3 queue. +- **Did:** Added `checks.hibernate` (flake.nix), modeled on nixpkgs' + hibernate.nix but wired to Nomarchy's #76 risk: imports oom.nix (zram + default-on), disk swap at lower priority + explicit `boot.resumeDevice`, + systemd-initrd. Test drives hibernate→power-off→resume→crash: ramfs marker + survives resume, a crash-boot loses it (real resume, not a survivor), and + asserts zram@100 + disk-swap<100 so the image lands on disk not volatile + zram. Queued the V3 laptop steps (real LUKS+@swap+offset resume + no-swap + notify) in HARDWARE-QUEUE. Collapsed #76 to a `[blocked:hw]` stub; design + record → ROADMAP § "Hibernation + zram by default". +- **Verified:** **V2 PASS** — `nix build .#checks.x86_64-linux.hibernate` + built the driver fresh and exited 0 (all assertions held). `nix flake + check --no-build` green. +- **Pending:** only V3 (dev box / laptop) — #76's last check; close on PASS. +- **Next suggestion:** #76 is now blocked:hw; NEXT head falls to LATER/ + PROPOSED triage or a QA sweep — pick per LOOP.md orientation next session. + ## 2026-07-10 — #76 slice 2: Hibernate notify-on-failure - **Task:** BACKLOG #76 remaining slice 2 — per the settled "keep row, notify on failure" call. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b18525d..893f615 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -305,6 +305,24 @@ Design/decision records and a running log of shipped work (items marked logo/lock/bullet → text, entry/progress-box → surface, progress-bar → accent. Reads on light and dark; follows the theme as of the last system rebuild (like the background tint). +- ✓ **Hibernation + zram by default (BACKLOG #76):** two memory layers ship + on by default. `modules/nixos/oom.nix` enables `zramSwap` (zstd, 50% RAM, + **priority 100**) as the compressed-RAM layer *before* earlyoom; the + installer defaults the disk swap to a **RAM-sized** hibernation-ready + swapfile on an `@swap` btrfs subvolume *inside* the encrypted volume, with + `patch-template.py` wiring `swapDevices` + `boot.resumeDevice` + + `resume_offset` (from `btrfs inspect-internal map-swapfile -r`). The + priority split is the point: day-to-day paging stays in zram, so the disk + swapfile is reserved for the hibernate image. `swapSize=0` stays a clean + no-swap opt-out; the Power-menu Hibernate row is kept unconditional and + **notifies on failure** (no-swap machines) rather than silently no-op'ing. + Existing machines get an *enable-hibernation* runbook in `docs/MIGRATION.md` + (no tool). Guarded by two VM checks — `checks.zram-swap` (device/algo/ + priority) and `checks.hibernate` (a full hibernate→resume→crash cycle that + proves the image round-trips through disk swap and that zram at priority + 100 doesn't swallow it). The LUKS-initrd unlock + btrfs-swapfile + `resume_offset` triple is a V3 laptop check (QEMU can't feed a boot-time + offset for a file that only exists after first boot). - ✓ **Hibernate double-unlock:** on resume from hibernate the LUKS passphrase already gates the machine, but locking hyprlock before sleep meant a second password. Fixed by *not locking* before an encrypted diff --git a/flake.nix b/flake.nix index 34f1396..32e56d0 100644 --- a/flake.nix +++ b/flake.nix @@ -684,6 +684,74 @@ ''; }; + # Hibernate → resume (BACKLOG #76): the load-bearing risk the + # hibernation default introduces is coexistence with the zram + # swap that oom.nix now enables by default. Prove, in the VM + # harness, that (a) a machine hibernates and resumes for real — + # a volatile ramfs marker survives resume but NOT a fresh boot — + # and (b) zram (priority 100) does not swallow the hibernate + # image: the disk swap (lower priority, explicit resumeDevice) + # carries it. Modeled on nixpkgs' hibernate.nix (partition swap + + # systemd-initrd resume). What QEMU genuinely can't do is feed a + # boot-time `resume_offset` for a btrfs *swapfile* (the offset + # only exists after the file is allocated on first boot) or drive + # a LUKS-initrd unlock end-to-end — so the encrypted @swap + # swapfile + offset path stays a V3 laptop check (HARDWARE-QUEUE), + # which already runs that exact layout. This test imports oom.nix + # so it tracks the real zram default, not a hand-copy. + hibernate = pkgs.testers.runNixOSTest { + name = "nomarchy-hibernate"; + nodes.machine = { config, lib, pkgs, ... }: { + imports = [ ./modules/nixos/oom.nix ]; # zram default-on (#76) + virtualisation.memorySize = 2048; + # Store + EFI must persist across the hibernate/resume/crash + # boots; a disk swap ≥ 2×RAM holds the image. + virtualisation.useNixStoreImage = true; + virtualisation.useEFIBoot = true; + virtualisation.emptyDiskImages = [ (2 * config.virtualisation.memorySize) ]; + boot.initrd.systemd.enable = true; # systemd-initrd, like Nomarchy + # Disk swap at a LOWER priority than zram (100); explicit + # resumeDevice — exactly how the installer wires hibernation — + # so the image goes to disk regardless of zram's priority. + swapDevices = lib.mkOverride 0 [ + { device = "/dev/vdc"; options = [ "x-systemd.makefs" ]; priority = 1; } + ]; + boot.resumeDevice = "/dev/vdc"; + # Let the driver's shell reconnect after resume. + systemd.services.backdoor.conflicts = [ "sleep.target" ]; + powerManagement.resumeCommands = "systemctl --no-block restart backdoor.service"; + }; + testScript = '' + machine.wait_for_unit("default.target") + + # zram (oom.nix) up at priority 100; disk swap present and lower + # — so the hibernate image lands on disk, not volatile zram. + machine.wait_until_succeeds("grep -q /dev/zram0 /proc/swaps", timeout=60) + machine.succeed( + "awk '$1==\"/dev/zram0\"&&$5==100{z=1} " + "$1==\"/dev/vdc\"&&$5<100{d=1} END{exit !(z&&d)}' /proc/swaps" + ) + + # Marker in volatile RAM: survives a resume, never a fresh boot. + machine.succeed( + "mkdir /run/test", + "mount -t ramfs -o size=1m ramfs /run/test", + "echo not persisted to disk > /run/test/suspended", + ) + + # Hibernate, wait for real power-off, then resume. + machine.execute("systemctl hibernate >&2 &", check_return=False) + machine.wait_for_shutdown() + machine.start() + machine.succeed("grep 'not persisted to disk' /run/test/suspended") + + # Prove it was a genuine resume: a crash-boot must lose the marker. + machine.crash() + machine.wait_for_unit("default.target") + machine.fail("grep 'not persisted to disk' /run/test/suspended") + ''; + }; + # Snapshot GUI canary: btrfs-assistant crashes in libbtrfsutil's # UNPRIVILEGED subvolume iteration (btrfs-progs 6.17.1, fixed # upstream after 6.17.1) but runs fine as root — which is how the