diff --git a/docs/swarm/claude-login.md b/docs/swarm/claude-login.md index 18b06f92..dd3bb921 100644 --- a/docs/swarm/claude-login.md +++ b/docs/swarm/claude-login.md @@ -7,6 +7,9 @@ agent's harness installs them from there. Where it runs and how it's enabled: [`services.md`](services.md#claude-login-minter). +The service runs the `claude-code` that `services.hyperhive.c0re.claudeCodePackage` +pins for agents, and its container's own nixpkgs' where the hive pins none. + ## What happens on a login 1. The agent publishes `needs_login` in its turn-state header, and its card diff --git a/docs/swarm/review-classifier.md b/docs/swarm/review-classifier.md index 4d7819b2..3e99943f 100644 --- a/docs/swarm/review-classifier.md +++ b/docs/swarm/review-classifier.md @@ -135,6 +135,9 @@ config) and an empty directory of its own. The prompt reaches it on stdin as one user message in `--input-format stream-json`, so that it can carry content blocks (see [Shared context](#shared-context)). +The `claude` is the one `services.hyperhive.c0re.claudeCodePackage` pins for +agents, or its container's own nixpkgs' where the hive pins none. + On the ACP runtime, each classification is one prompt on a new `opencode acp` session. Its config and opencode's state live in a directory of its own, removed after it. Its working directory is one empty directory that every diff --git a/nix/host-modules/hive-c0re/options.nix b/nix/host-modules/hive-c0re/options.nix index a8153c42..ef2f41dc 100644 --- a/nix/host-modules/hive-c0re/options.nix +++ b/nix/host-modules/hive-c0re/options.nix @@ -158,6 +158,12 @@ `nix-collect-garbage` cannot reclaim an old `claude-code` until every agent has been rebuilt past it and the old generations are gone. + + The swarm's `swarm-claude-login` and `swarm-review-classifier` + containers, where this host runs them, follow the same pin and + keep their own nixpkgs' `claude-code` when it is `null`. They are + not agents, so pinning an older build for an agent-side + regression pins them too. ''; }; dashboardPort = lib.mkOption { diff --git a/nix/host-modules/swarm-claude-login.nix b/nix/host-modules/swarm-claude-login.nix index 29e15a14..0de53835 100644 --- a/nix/host-modules/swarm-claude-login.nix +++ b/nix/host-modules/swarm-claude-login.nix @@ -21,6 +21,16 @@ }: let hyperhiveCfg = config.services.hyperhive; + + # The `claude-code` the agents run, so this container does not trail them on + # the CLI; `fallback`, the container's own nixpkgs', only where the hive pins + # none. + claudeCode = + fallback: + if hyperhiveCfg.c0re.claudeCodePackage != null then + hyperhiveCfg.c0re.claudeCodePackage + else + fallback; cfg = hyperhiveCfg.deploy.claude-login; baoCfg = hyperhiveCfg.swarm.bao; baoDeploy = hyperhiveCfg.deploy.bao; @@ -175,7 +185,7 @@ in SWARM_CLAUDE_LOGIN_NATS_URL = "tls://${natsCfg.domain}:${toString natsCfg.port}"; SWARM_CLAUDE_LOGIN_OIDC_TOKEN_ENDPOINT = "${autheliaCfg.url}/api/oidc/token"; SWARM_CLAUDE_LOGIN_OIDC_CLIENT_ID = clientId; - SWARM_CLAUDE_LOGIN_CLAUDE = "${pkgs.claude-code}/bin/claude"; + SWARM_CLAUDE_LOGIN_CLAUDE = "${claudeCode pkgs.claude-code}/bin/claude"; } // lib.optionalAttrs (storeFiles ? claude-login-bao-server-ca) { BAO_CACERT = credential "claude-login-bao-server-ca"; diff --git a/nix/host-modules/swarm-review-classifier.nix b/nix/host-modules/swarm-review-classifier.nix index 3d7c6722..dda38452 100644 --- a/nix/host-modules/swarm-review-classifier.nix +++ b/nix/host-modules/swarm-review-classifier.nix @@ -22,6 +22,16 @@ }: let hyperhiveCfg = config.services.hyperhive; + + # The `claude-code` the agents run, so this container does not trail them on + # the CLI; `fallback`, the container's own nixpkgs', only where the hive pins + # none. + claudeCode = + fallback: + if hyperhiveCfg.c0re.claudeCodePackage != null then + hyperhiveCfg.c0re.claudeCodePackage + else + fallback; cfg = hyperhiveCfg.deploy.review-classifier; baoCfg = hyperhiveCfg.swarm.bao; baoDeploy = hyperhiveCfg.deploy.bao; @@ -195,7 +205,7 @@ in SWARM_REVIEW_CLASSIFIER_NATS_URL = "tls://${natsCfg.domain}:${toString natsCfg.port}"; SWARM_REVIEW_CLASSIFIER_OIDC_TOKEN_ENDPOINT = "${autheliaCfg.url}/api/oidc/token"; SWARM_REVIEW_CLASSIFIER_OIDC_CLIENT_ID = clientId; - SWARM_REVIEW_CLASSIFIER_CLAUDE = "${pkgs.claude-code}/bin/claude"; + SWARM_REVIEW_CLASSIFIER_CLAUDE = "${claudeCode pkgs.claude-code}/bin/claude"; SWARM_REVIEW_CLASSIFIER_OPENCODE = "${pkgs.opencode}/bin/opencode"; } // lib.optionalAttrs (storeFiles ? review-classifier-bao-server-ca) { diff --git a/nix/module-eval/swarm-services-switch.nix b/nix/module-eval/swarm-services-switch.nix index e86d7430..a3c1e948 100644 --- a/nix/module-eval/swarm-services-switch.nix +++ b/nix/module-eval/swarm-services-switch.nix @@ -99,6 +99,13 @@ let deploy.builder.enable = false; }; + # Same, with the claude-code build the agents run pinned on the hive. Any + # package serves: only the path the containers are pointed at is read. + swarmServicesPinnedClaude = hiveWithController { + deploy.allSwarmServices = true; + c0re.claudeCodePackage = pkgs.hello; + }; + # Same, with one of those services placed on another host. Every derivation # in that module is `mkDefault` so this stays expressible. swarmServicesBaoElsewhere = hive { @@ -273,6 +280,30 @@ let && !(swarmServicesBaoElsewhere.containers ? swarm-claude-login) && swarmServicesBaoElsewhere.services.hyperhive.deploy.nats.enable; } + { + # The CLI is the one the agents run, not the container's own nixpkgs'. + # Unpinned is the control: it falls back to the container's own `claude-code`. + name = "the claude login minter and the review classifier run the claude-code the hive pins"; + ok = + let + claudeOf = + host: container: unit: var: + host.containers.${container}.config.systemd.services.${unit}.environment.${var}; + pinned = "${pkgs.hello}/bin/claude"; + in + claudeOf swarmServicesPinnedClaude "swarm-claude-login" "swarm-claude-login" + "SWARM_CLAUDE_LOGIN_CLAUDE" == pinned + && + claudeOf swarmServicesPinnedClaude "swarm-review-classifier" "swarm-review-classifier" + "SWARM_REVIEW_CLASSIFIER_CLAUDE" == pinned + && lib.hasInfix "claude-code" ( + claudeOf swarmServicesHere "swarm-claude-login" "swarm-claude-login" "SWARM_CLAUDE_LOGIN_CLAUDE" + ) + && lib.hasInfix "claude-code" ( + claudeOf swarmServicesHere "swarm-review-classifier" "swarm-review-classifier" + "SWARM_REVIEW_CLASSIFIER_CLAUDE" + ); + } { # The switch fills in for an operator who has not spoken and yields to # one who has — that is what keeps a shared service placeable on a host