feat(#2693): let the operator pin the claude-code every agent runs

Agents run whatever `claude-code` the meta flake's `nixpkgs` resolves
to, and that is normally a release channel. This one package moves fast
enough that stable trails unstable by weeks — 26.05 is on 2.1.187 while
unstable carries 2.1.220 — and an agent cannot fix it for itself: it
only ever sees the single nixpkgs hive-c0re injects, so an `agent.nix`
has no other tree to reach for.

New host option `services.hyperhive.c0re.claudeCodePackage` takes the
package directly and rides the existing `hyperhiveDocs` threading path —
serveConfigJson -> HiveEnv -> render_flake — to reach each agent as
`hyperhive.claudeCodePath`. Null (the default) is today's behaviour.

What travels is the store *path*, as a plain string literal, not a flake
input: containers share the host's `/nix/store`, so the build is already
reachable inside them with its whole closure and has nothing to travel.
An input would be worse than useless — a `path:/nix/store/<pkg>` input
is re-copied as a reference-less `-source`, which strips exactly the
closure the binary needs.

The catch is that a path written into a generated flake is text, so
nothing in the container's closure keeps the binary alive. The host does
that instead, and gets it for free: the package is interpolated into
`/etc/hyperhive/serve.json`, `builtins.toJSON` preserves string context,
so the /etc entry references it and the system closure gc-roots it for
as long as that generation is the one the agents were rendered from. An
assertion pins that property, because losing the context is invisible at
eval and at deploy — it would surface only as every agent failing to
spawn `claude` whenever the next gc ran.

Container side wraps the path in a symlink farm rather than putting it
on PATH directly: `systemd.services.<name>.path` and
`environment.systemPackages` both coerce a store-path *string* through
`lib.toDerivation`, i.e. `builtins.storePath`, which pure evaluation
rejects. Interpolating the path into a builder is just text and
evaluates anywhere. `claude-code` drops out of systemPackages when a
pin is set, so there is exactly one claude in the container.

Refs #2693
This commit is contained in:
atlas 2026-07-27 13:06:22 +02:00 committed by mara
commit b08176f089
7 changed files with 281 additions and 1 deletions

View file

@ -59,6 +59,22 @@ let
fi
'';
# Store path of the `claude-code` every agent runs, or "" for "each
# agent keeps the one out of its own nixpkgs". meta.rs writes it into
# each agent's generated flake as a plain string literal, and the agent
# module puts its `bin/` on the harness PATH.
#
# This interpolation is also the package's gc root, and the only one:
# it carries store context, `builtins.toJSON` preserves that, so the
# /etc entry below genuinely references the package and the host's
# system closure holds it alive. Nothing on the container side can —
# a path spelled out in a generated flake is text, not a reference.
# Hence the assertion further down: do NOT discard this context, and
# do not hand meta.rs the path by a route that drops it. The failure
# mode is a garbage-collected `claude` and a hive that can't take a
# turn, weeks after the commit that caused it.
claudeCodePath = if cfg.claudeCodePackage == null then "" else "${cfg.claudeCodePackage}";
# The `hive-c0re serve` config JSON. Keys are snake_case to match the
# `ServeConfig` serde shape the daemon deserialises (the
# container-injected HiveEnv fields, flattened, plus the hive-c0re-local
@ -76,6 +92,7 @@ let
hyperhive_flake = cfg.hyperhiveFlake;
hyperhive_docs_flake = cfg.hyperhiveDocs;
nixpkgs_flake = cfg.nixpkgsFlake;
claude_code_path = claudeCodePath;
dashboard_port = cfg.dashboardPort;
operator_pronouns = cfg.operatorPronouns;
context_window_tokens = cfg.contextWindowTokens;
@ -94,6 +111,27 @@ in
];
config = lib.mkIf cfg.enable {
assertions = [
{
# The pinned claude reaches agents as a bare path, so this
# string's store context is the whole reason the binary survives
# a `nix-collect-garbage`. Losing it is invisible at eval and at
# deploy — it only shows up as every agent failing to spawn
# `claude`, at whatever unrelated moment the gc runs. Cheap
# enough to just check.
assertion = cfg.claudeCodePackage == null || builtins.hasContext claudeCodePath;
message = ''
services.hyperhive.c0re.claudeCodePackage lost its store
context on the way into /etc/hyperhive/serve.json, so the
package is no longer gc-rooted by the system closure and
`nix-collect-garbage` may delete the claude every agent runs.
Something on that path discarded the context (e.g.
builtins.unsafeDiscardStringContext, toString, or reading the
path back out of a plain file) undo it.
'';
}
];
environment.systemPackages = [
cfg.package
pkgs.git

View file

@ -128,6 +128,45 @@
of the host's channel.
'';
};
claudeCodePackage = lib.mkOption {
type = lib.types.nullOr lib.types.package;
default = null;
example = lib.literalExpression "inputs.nixpkgs-unstable.legacyPackages.x86_64-linux.claude-code";
description = ''
The `claude-code` build every agent runs, or `null` (the
default) to leave each agent on the `claude-code` from its own
nixpkgs i.e. whatever `nixpkgsFlake` resolves to.
This is the one binary the whole hive is built around, and it
moves fast enough that a release channel routinely trails
unstable by weeks on it. An agent cannot fix that for itself:
agents evaluate against the single nixpkgs hive-c0re injects,
so an `agent.nix` has no other tree to reach for. Set this from
a second nixpkgs in the host flake and every agent follows,
without moving the nixpkgs the rest of the container is built
from.
What travels into the container is the **store path**, not the
derivation: agents share the host's `/nix/store`, so the binary
and its full closure are already reachable there nothing
needs rebuilding or copying. hive-c0re writes the path into
each agent's generated flake as a plain string literal (a bare
path fed to `lib.types.package` would run `builtins.storePath`,
which is illegal under pure evaluation) and the agent module
puts its `bin/` on the harness's PATH.
The flip side of a plain string is that nothing in the agent's
own closure refers to it, so the container cannot keep it
alive. The **host** does that instead: this package is
interpolated into `/etc/hyperhive/serve.json`, which puts it in
the host's system closure so it is gc-rooted by the running
generation for exactly as long as that generation is the one
the agents were rendered from. The cost is that
`nix-collect-garbage` cannot reclaim an old `claude-code` until
every agent has been rebuilt past it and the old generations
are gone.
'';
};
dashboardPort = lib.mkOption {
type = lib.types.port;
default = 7000;