From ed1fce3329e87e7838331e1854ec776a99963899 Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 19 Sep 2026 10:24:28 +0200 Subject: [PATCH] nix: gate the avatar-sync path unit on the same condition as its service `systemd.paths.forge-avatar-sync` was gated on `agent.icon != null` alone, while the `systemd.services.forge-avatar-sync` it triggers is gated on `agent.icon != null && agent.forge.url != null`. An agent with an icon and no forge URL therefore rendered a `.path` unit, pulled into multi-user.target, watching for a forge-token whose arrival would activate a unit that does not exist. The module already documents the fixed behaviour: `forge.url`'s own option description says the tea-login and avatar-sync units are "not generated at all" when it is null --- an absent integration, never a misdirected one. That sentence was true of the oneshot and false of its watcher. Latent, not live: hive-c0re renders `forge.url` into every agent's config from the host's `HIVE_FORGE_URL`, so on a real hive it is always set and the asymmetric arm is unreachable. It is reachable wherever the agent modules are evaluated outside a hive. A module-eval case pins both halves absent for an agent with an icon and no forge; it fails on the parent commit, where the path unit renders. --- nix/agent-modules/forge.nix | 23 ++++++++++++++++------- nix/module-eval.nix | 23 +++++++++++++++++++++++ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/nix/agent-modules/forge.nix b/nix/agent-modules/forge.nix index 8e0a9da2..694b479e 100644 --- a/nix/agent-modules/forge.nix +++ b/nix/agent-modules/forge.nix @@ -12,8 +12,8 @@ let homeDir = "/home/${userName}"; # Same 512×512 rasterization of the agent icon the matrix avatar # sync uses (./matrix.nix — identical derivation, same store path). - # Only forced when an icon is configured (the avatar-sync unit below - # is gated on `services.hyperhive.agent.icon != null`). + # Only forced when an icon is configured AND a forge is (the avatar-sync + # unit below, the one thing that references this, is gated on both). iconPng = pkgs.runCommand "hive-agent-icon.png" { nativeBuildInputs = [ pkgs.librsvg ]; } '' rsvg-convert -f png -w 512 -h 512 ${config.services.hyperhive.agent.icon} -o $out ''; @@ -266,11 +266,20 @@ in # watches paths this unit has no business reacting to. # The service reads `$HYPERHIVE_STATE_DIR/forge-token`; this is the same # file, spelled the way `tea-login` above already spells it. - systemd.paths.forge-avatar-sync = lib.mkIf (config.services.hyperhive.agent.icon != null) { - description = "trigger forge-avatar-sync when forge-token appears"; - wantedBy = [ "multi-user.target" ]; - pathConfig.PathChanged = "/agents/${userName}/state/forge-token"; - }; + # + # ⚠️ Gated on the SAME condition as the service it triggers, not just on + # the icon: a `.path` unit whose `Unit=` does not exist is a unit pulled + # into multi-user.target that can only ever fail to activate. The two + # halves are one feature and appear together or not at all --- which is + # what `forge.url`'s own option doc promises. + systemd.paths.forge-avatar-sync = + lib.mkIf + (config.services.hyperhive.agent.icon != null && config.services.hyperhive.agent.forge.url != null) + { + description = "trigger forge-avatar-sync when forge-token appears"; + wantedBy = [ "multi-user.target" ]; + pathConfig.PathChanged = "/agents/${userName}/state/forge-token"; + }; # One-shot: services.hyperhive.agent.icon → Forgejo profile avatar. Shape contract: # docs/process/conventions.md::Best-effort oneshot services. diff --git a/nix/module-eval.nix b/nix/module-eval.nix index 5b3da706..11cbdf0a 100644 --- a/nix/module-eval.nix +++ b/nix/module-eval.nix @@ -723,6 +723,15 @@ let # get it installed twice. agentPluginsDuplicate = agent { claudePlugins = [ "base@hyperhive" ]; }; agentPlugins = machine: machine.services.hyperhive.agent.claudePlugins; + + # An agent with an icon and no forge. Both halves of the avatar sync — the + # `.path` watcher and the oneshot it triggers — hang off the icon, but only + # the service can do anything with a forge URL, so the icon alone is the one + # input that can render half the feature. `pkgs.emptyFile` rather than a real + # SVG: the icon is only ever a gate here, and nothing this case reads + # rasterizes it. + agentIconNoForge = agentWith { services.hyperhive.agent.icon = pkgs.emptyFile; }; + agentHarness = machine: machine.systemd.services.hive-agent; agentSubagentDaemon = machine: machine.systemd.services.hive-subagent-daemon; agentBaoIdentity = machine: machine.systemd.services.hive-agent-bao-identity; @@ -2050,6 +2059,20 @@ let name = "one subagent losing the OOM draw does not stop the daemon"; ok = (agentSubagentDaemon agentUncapped).serviceConfig.OOMPolicy == "continue"; } + { + # The absence class, and a gate asymmetry nothing else can see. A `.path` + # unit is `wantedBy = multi-user.target` and names a `Unit=` by + # convention rather than by reference, so one gated more loosely than the + # service it triggers evaluates clean, deploys clean, and then fails to + # activate the first time the watched file is written. `forge.url`'s own + # option doc already promises the avatar-sync units are "not generated at + # all" without a forge — this is the case that makes that sentence true + # of the watcher and not only of the oneshot. + name = "an agent with an icon and no forge renders neither avatar-sync unit"; + ok = + !(agentIconNoForge.systemd.paths ? forge-avatar-sync) + && !(agentIconNoForge.systemd.services ? forge-avatar-sync); + } { # A homeserver URL is the whole input: from it the module derives the # hive-internal `main` account, and from a non-empty account set the three