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.
This commit is contained in:
atlas 2026-09-19 10:24:28 +02:00
commit ed1fce3329
2 changed files with 39 additions and 7 deletions

View file

@ -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.

View file

@ -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