From fae93982955c20cec0316e9cc4c9f00f150edaa4 Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 7 Nov 2026 06:25:38 +0100 Subject: [PATCH] start the forge and github pollers when their token lands late Both pollers wait about ten minutes for the agent's token, then exit 0. Under Restart=on-failure that exit is final, and nothing started them again, so a token minted or placed after the window left the agent without notifications until its container restarted. Add a path unit per poller with PathChanged on the token file(s) the poller reads, the shape forge-avatar-sync already uses. A path unit starts its service only when it is not running, so a poller that found its token is untouched. The github path unit is gated on github.enable like its service. Container-side: it ships with the agent's next rebuild, and an agent on an older rev keeps the old behaviour. Closes #6179 --- docs/2-handbook/integrations/forge.md | 6 ++++++ docs/2-handbook/integrations/github.md | 5 ++++- nix/agent-modules/forge.nix | 15 +++++++++++++++ nix/agent-modules/github.nix | 13 +++++++++++++ nix/module-eval/agent-forge-bao.nix | 17 +++++++++++++++++ nix/module-eval/agent-github-bao.nix | 15 +++++++++++++++ 6 files changed, 70 insertions(+), 1 deletion(-) diff --git a/docs/2-handbook/integrations/forge.md b/docs/2-handbook/integrations/forge.md index 0279b8ba..e8a46b4a 100644 --- a/docs/2-handbook/integrations/forge.md +++ b/docs/2-handbook/integrations/forge.md @@ -133,6 +133,12 @@ unread-only filter), formats each notification as a broker `Wake { from: "forge" }` message, and delivers it to the agent's own inbox so claude's normal turn loop picks it up. +The daemon waits about ten minutes for the agent's forge token, then exits 0, +which its `Restart=on-failure` doesn't restart. A path unit +(`hive-forge-notify.path`) starts it again when the fetched token file or the +state-dir `forge-token` changes, so a token minted later still starts the +polling. + The crate builds a second, independent binary for a different host — see [github.md](github.md#notifications). diff --git a/docs/2-handbook/integrations/github.md b/docs/2-handbook/integrations/github.md index 7495cb72..9d2e96d1 100644 --- a/docs/2-handbook/integrations/github.md +++ b/docs/2-handbook/integrations/github.md @@ -110,7 +110,10 @@ crate its build cache. At runtime the poller needs the PAT above. No PAT, no polling: the unit logs why and exits 0, which is why it's `Restart = on-failure` and never `always` — a clean exit on a PAT-less agent must not become a -restart loop. Once polling, it re-reads `/github-token` on every +restart loop. The poller waits about ten minutes for the PAT to appear +before it gives up. A path unit (`hive-github-notify.path`) starts the poller +again when `/github-token` changes, so a PAT that lands later still +enables polling. Once polling, it re-reads the file on every tick, so it picks up a rotated PAT on the next tick without a restart. Forgejo models its notifications API on GitHub's, so one tolerant diff --git a/nix/agent-modules/forge.nix b/nix/agent-modules/forge.nix index 5b137eb5..e8420f53 100644 --- a/nix/agent-modules/forge.nix +++ b/nix/agent-modules/forge.nix @@ -181,6 +181,21 @@ in }; }; + # Starts the poller when a token appears after it gave up waiting for one + # (the poller waits about ten minutes, then exits 0, which `on-failure` + # does not restart). A path unit starts its service only when it is not + # running, so it costs nothing while the poller runs. Both files the poller + # reads are watched. `PathChanged=` for the reason given on + # `forge-avatar-sync` below. + systemd.paths.hive-forge-notify = { + description = "start hive-forge-notify when the forge token appears"; + wantedBy = [ "multi-user.target" ]; + pathConfig.PathChanged = [ + fetchedTokenFile + stateTokenFile + ]; + }; + # Path-trigger sibling: re-fires forge-avatar-sync when the fetched # forge token (./forge-token.nix) appears or is replaced. On first # agent deployment the container can boot before the swarm has minted diff --git a/nix/agent-modules/github.nix b/nix/agent-modules/github.nix index 94085849..147b7601 100644 --- a/nix/agent-modules/github.nix +++ b/nix/agent-modules/github.nix @@ -127,5 +127,18 @@ in Group = userName; }; }; + + # Starts the poller when the token appears after it gave up waiting for one + # (about ten minutes, then exit 0, which `on-failure` does not restart). + # The operator places the PAT through the swarm after the agent runs, so + # that is the usual order. A path unit starts its service only when it is + # not running. `PathChanged=` because the fetch renames the file into place, + # and a `PathExists=` that already holds would re-fire on every re-arm. + # Gated on the service it triggers. + systemd.paths.hive-github-notify = lib.mkIf config.services.hyperhive.agent.github.enable { + description = "start hive-github-notify when the GitHub token appears"; + wantedBy = [ "multi-user.target" ]; + pathConfig.PathChanged = "/agents/${userName}/state/github-token"; + }; }; } diff --git a/nix/module-eval/agent-forge-bao.nix b/nix/module-eval/agent-forge-bao.nix index b73332dd..b4eb61ac 100644 --- a/nix/module-eval/agent-forge-bao.nix +++ b/nix/module-eval/agent-forge-bao.nix @@ -195,6 +195,23 @@ let agentForgeBao.systemd.paths.forge-avatar-sync.pathConfig.PathChanged == tokenFile agentForgeBao && builtins.elem "hive-agent-forge-token.service" agentForgeBao.systemd.services.forge-avatar-sync.after; } + { + # The poller waits for the token about ten minutes and then exits 0, which + # `on-failure` does not restart; the path unit starts it when either file + # it reads appears. Control: the avatar watcher beside it, in the same set. + name = "a token that lands after the forge poller gave up starts it again"; + ok = + let + path = agentForgeBao.systemd.paths.hive-forge-notify; + in + path.pathConfig.PathChanged == [ + (tokenFile agentForgeBao) + "/agents/${agentForgeBao.services.hyperhive.agent.user.name}/state/forge-token" + ] + && path.wantedBy == [ "multi-user.target" ] + && agentForgeBao.systemd.services ? hive-forge-notify + && agentForgeBao.systemd.paths ? forge-avatar-sync; + } { # Both readers take the fetched token first and fall back to the state # file, which is still the only copy for an agent with no store identity. diff --git a/nix/module-eval/agent-github-bao.nix b/nix/module-eval/agent-github-bao.nix index 0f7af1e3..0891e4b9 100644 --- a/nix/module-eval/agent-github-bao.nix +++ b/nix/module-eval/agent-github-bao.nix @@ -102,6 +102,21 @@ let name = "the fetch runs before the github poller starts"; ok = builtins.elem "hive-github-notify.service" (fetchUnit agentGithubBao).before; } + { + # The poller waits for the token about ten minutes and then exits 0, which + # `on-failure` does not restart; the path unit starts it when the token lands. + name = "a token that lands after the github poller gave up starts it again"; + ok = + let + name = agentGithubBao.services.hyperhive.agent.user.name; + path = agentGithubBao.systemd.paths.hive-github-notify; + in + path.pathConfig.PathChanged == "/agents/${name}/state/github-token" + && path.wantedBy == [ "multi-user.target" ] + && agentGithubBao.systemd.services ? hive-github-notify + && !(agentGithubOff.systemd.paths ? hive-github-notify) + && !(agentGithubOff.systemd.services ? hive-github-notify); + } { name = "the fetch re-runs every two minutes"; ok = agentGithubBao.systemd.timers.hive-agent-github-token.timerConfig.OnUnitInactiveSec == "2min";