Both units ran at OOMScoreAdjust=0, so under container memory pressure the kernel picked purely on footprint — and the agent's own claude is often the fattest process in the container, which means the session supervising the work died before the work did. The sign is the load-bearing part and is easy to invert: a HIGHER OOMScoreAdjust means MORE likely to be killed, because the kernel adds it to the badness score it derives from the process's memory footprint and then kills the highest scorer. So hive-subagent-daemon gets +500 (first in line) and hive-agent gets -500 (last in line). Written backwards this makes the reported bug worse rather than better, so module-eval pins the order as an inequality. Both values are inherited by the nested claude each unit spawns as a child, so ordering the units orders the sessions underneath them. -500 rather than -1000 on the harness: fully exempting it would leave the kernel nothing to kill in a container whose only large process is the harness. Refs #4316
331 lines
15 KiB
Nix
331 lines
15 KiB
Nix
# The hive-agent harness service itself, plus the per-agent knobs it
|
|
# reads from its environment: model selection, effort level,
|
|
# compaction watermark, and the extra reverse-proxies of the per-agent
|
|
# web UI.
|
|
{
|
|
pkgs,
|
|
lib,
|
|
config,
|
|
...
|
|
}:
|
|
let
|
|
userName = config.hyperhive.user.name;
|
|
homeDir = "/home/${userName}";
|
|
in
|
|
{
|
|
options.hyperhive.model = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "haiku";
|
|
example = "sonnet";
|
|
description = ''
|
|
Claude model for this agent. Sets the `HIVE_DEFAULT_MODEL`
|
|
environment variable; the harness applies it at boot and it takes
|
|
priority over any persisted runtime override. The operator can still
|
|
switch the model at runtime via the per-agent web UI — that choice
|
|
is tracked in the state dir for the current session but is reset by
|
|
any rebuild that changes this option.
|
|
|
|
Valid values are the short model names that `claude --model` accepts:
|
|
`"haiku"`, `"sonnet"`, `"opus"` (or any future identifier). Context
|
|
window sizes are looked up at runtime from the
|
|
`HIVE_CONTEXT_WINDOW_TOKENS_<KEY_UPPER>` env vars injected by the
|
|
meta flake; override sizes via `services.hyperhive.c0re.contextWindowTokens`
|
|
on the host.
|
|
'';
|
|
};
|
|
|
|
options.hyperhive.availableModels = lib.mkOption {
|
|
type = lib.types.listOf lib.types.str;
|
|
default = [
|
|
"haiku"
|
|
"sonnet"
|
|
"opus"
|
|
];
|
|
example = [
|
|
"sonnet"
|
|
"opus"
|
|
];
|
|
description = ''
|
|
Models offered in the per-agent web UI's model quick-picker. Rendered
|
|
into the `HIVE_AVAILABLE_MODELS` environment variable (comma-separated)
|
|
which the harness surfaces to the agent UI, so the picker lists exactly
|
|
these models instead of a hardcoded set.
|
|
|
|
Configure hive-wide by setting a shared default (e.g. in your
|
|
`agent.nix` shared defaults) or per-agent to narrow the menu — for example a
|
|
haiku-only agent can hide `opus` and `sonnet`. The *current* model is
|
|
still set by `hyperhive.model` and remains switchable at runtime via the
|
|
UI; this option only controls which choices the picker presents.
|
|
|
|
Values are the short model names that `claude --model` accepts:
|
|
`"haiku"`, `"sonnet"`, `"opus"` (or any future identifier).
|
|
'';
|
|
};
|
|
|
|
options.hyperhive.effortLevel = lib.mkOption {
|
|
type = lib.types.enum [
|
|
"low"
|
|
"medium"
|
|
"high"
|
|
"xhigh"
|
|
"max"
|
|
];
|
|
default = "medium";
|
|
example = "high";
|
|
description = ''
|
|
Baseline claude effort level for this agent. Rendered into the
|
|
`HIVE_DEFAULT_EFFORT` environment variable; the harness resolves
|
|
effort as operator-override-file → this env → built-in `"medium"`,
|
|
and passes the result to `claude --effort` at turn launch.
|
|
|
|
Ascending scale: `"low"` (minimal thinking budget), `"medium"`
|
|
(default — balanced), `"high"` (platform default), `"xhigh"`
|
|
(recommended for autonomous coding on capable models), `"max"`
|
|
(maximum thinking budget, highest cost). The operator can override
|
|
at runtime per-agent via the web UI (applied on the next session);
|
|
any rebuild that changes this option resets that override.
|
|
'';
|
|
};
|
|
|
|
options.hyperhive.autoCompact = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = true;
|
|
description = ''
|
|
Enable proactive watermark-based compaction. When `true` (the
|
|
default) the harness automatically runs a notes-checkpoint turn
|
|
followed by `/compact` once the context window crosses 75% of
|
|
the model's limit, keeping later turns from hitting the hard
|
|
overflow path. Set to `false` to disable proactive compaction
|
|
entirely (`HIVE_COMPACT_WATERMARK_TOKENS=0`); the reactive path
|
|
(compact-on-overflow when the session is already past the limit)
|
|
still applies.
|
|
|
|
Disable for agents that run large-context models (sonnet/opus)
|
|
where the heuristic fires too early and discards useful history
|
|
before the session is actually close to the limit.
|
|
'';
|
|
};
|
|
|
|
options.hyperhive.useApiKey = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = false;
|
|
description = ''
|
|
Authenticate this agent's `claude` invocations with an API key
|
|
(`ANTHROPIC_API_KEY`/`ANTHROPIC_BASE_URL`, e.g. OpenRouter) rather
|
|
than a Claude OAuth session. Sets `HIVE_USE_API_KEY=1`, which the
|
|
harness reads (`hive_agent::login::using_api_key`) to report
|
|
`LoginState::Online` at boot without checking `~/.claude/` — an
|
|
api-key agent has no OAuth session to wait for, so it must never
|
|
park the turn loop expecting one. Also stamped into the consolidated
|
|
harness state file so hive-c0re's dashboard stops reading an empty
|
|
`~/.claude/` as "needs login" for this agent (see
|
|
`hive_c0re::container_view`'s `needs_login` computation).
|
|
|
|
Set this AND `hyperhive.backendEnvironmentFile` together — this
|
|
option changes what the harness believes about its own login state,
|
|
the other actually supplies the credentials `claude` reads. Neither
|
|
is useful alone.
|
|
'';
|
|
};
|
|
|
|
options.hyperhive.backendEnvironmentFile = lib.mkOption {
|
|
type = lib.types.nullOr lib.types.str;
|
|
default = null;
|
|
example = "/agents/myagent/harness/openrouter.env";
|
|
description = ''
|
|
Path (outside the nix store) to a systemd `EnvironmentFile` loaded
|
|
by the harness service — the mechanism for supplying
|
|
`ANTHROPIC_API_KEY`/`ANTHROPIC_BASE_URL` (or any other backend
|
|
credential `claude`/the harness reads from the environment) without
|
|
baking a secret into the nix store.
|
|
|
|
The file must use systemd `EnvironmentFile` syntax: one `KEY=value`
|
|
pair per line, no `export`, no shell quoting needed for simple
|
|
values. Example contents:
|
|
|
|
```
|
|
ANTHROPIC_API_KEY=sk-or-v1-...
|
|
ANTHROPIC_BASE_URL=https://openrouter.ai/api/v1
|
|
```
|
|
|
|
Place the file inside the agent's bind-mounted **harness** dir (e.g.
|
|
`/agents/<name>/harness/openrouter.env`, `$HYPERHIVE_HARNESS_DIR`),
|
|
not `state/` — `harness/` survives container rebuilds exactly like
|
|
`state/` does, but is never bind-mounted into a parent agent's
|
|
container (unlike `state/`, which a parent gets read-write for child
|
|
recovery — see `docs/agent-lifecycle/persistence.md`'s "Parent access to child
|
|
state"), so this credential is reachable by nothing but this agent
|
|
and the host. Permissions should be `0600`, owned by the agent's
|
|
unix user. Loaded with a leading `-` (optional `EnvironmentFile`),
|
|
so a path that doesn't exist yet — an operator setting this option
|
|
before creating the file, or a fresh host rebuild before state is
|
|
restored — makes systemd skip it rather than refuse to start the
|
|
harness. See `hyperhive.useApiKey`'s doc for the option this one is
|
|
paired with.
|
|
'';
|
|
};
|
|
|
|
options.hyperhive.extraWebProxies = lib.mkOption {
|
|
type = lib.types.attrsOf lib.types.str;
|
|
default = { };
|
|
example = lib.literalExpression ''{ "stats" = "http://127.0.0.1:3737"; }'';
|
|
description = ''
|
|
Transparent reverse-proxies mounted under `/extra/` in the per-agent web UI.
|
|
Each attribute name becomes the sub-path and the value is the upstream.
|
|
E.g. `{ "stats" = "http://127.0.0.1:3737"; }` mounts a proxy at
|
|
`/agent/<name>/extra/stats/` that forwards to port 3737 with the prefix
|
|
stripped. All user-declared proxies live under `/extra/` so they can
|
|
never conflict with native agent endpoints (`/api/*`, `/events/*`, etc.).
|
|
|
|
The upstream value is either an `http(s)://` URL or a Unix domain
|
|
socket, spelled `unix:<path>` (e.g. `unix:/run/myapp/http.sock`) — for
|
|
agents whose secondary web server only listens on a UDS.
|
|
|
|
Intended for agents that run secondary web servers in the same container.
|
|
Static assets served by the secondary app must use relative paths to
|
|
resolve correctly under the sub-path prefix.
|
|
|
|
Sets the `HIVE_EXTRA_WEB_PROXIES` environment variable (JSON object)
|
|
on the harness service unit.
|
|
'';
|
|
};
|
|
|
|
config = {
|
|
assertions = [
|
|
# hyperhive.model must be a non-empty string — an empty value causes
|
|
# the harness to pass an invalid model flag to claude.
|
|
{
|
|
assertion = config.hyperhive.model != "";
|
|
message = "hyperhive.model must not be empty (set it to e.g. \"haiku\" or \"sonnet\")";
|
|
}
|
|
# The current model must appear in the quick-picker menu, otherwise the
|
|
# UI would offer no way back to the model the agent is actually running.
|
|
{
|
|
assertion =
|
|
config.hyperhive.availableModels == [ ]
|
|
|| builtins.elem config.hyperhive.model config.hyperhive.availableModels;
|
|
message =
|
|
"hyperhive.model (\"${config.hyperhive.model}\") must be one of "
|
|
+ "hyperhive.availableModels ([ ${lib.concatStringsSep " " config.hyperhive.availableModels} ]) "
|
|
+ "— add it to the list or change the model.";
|
|
}
|
|
];
|
|
|
|
# HIVE_DEFAULT_MODEL seeds the initial model selection when no
|
|
# persisted model choice exists in the state dir.
|
|
environment.variables = {
|
|
HIVE_DEFAULT_MODEL = config.hyperhive.model;
|
|
# Comma-separated menu for the per-agent UI model quick-picker
|
|
# (see hyperhive.availableModels). The harness surfaces it to the
|
|
# frontend; an empty value falls back to the built-in default list.
|
|
HIVE_AVAILABLE_MODELS = lib.concatStringsSep "," config.hyperhive.availableModels;
|
|
# Per-agent baseline effort (see hyperhive.effortLevel). The
|
|
# harness resolves operator-override-file → this env → "medium"
|
|
# and passes it to claude --effort at turn launch.
|
|
HIVE_DEFAULT_EFFORT = config.hyperhive.effortLevel;
|
|
}
|
|
// lib.optionalAttrs (!config.hyperhive.autoCompact) {
|
|
# Zero watermark disables proactive compaction; the reactive path
|
|
# (compact-on-overflow) still fires when the session is truly full.
|
|
HIVE_COMPACT_WATERMARK_TOKENS = "0";
|
|
};
|
|
|
|
# Harness systemd unit. Unit shape (PATH wrapper-dir trick, env vars,
|
|
# RuntimeDirectory, User=, standalone-eval fallbacks):
|
|
# docs/agent-lifecycle/agent-hierarchy.md::Harness systemd unit shape. PATH /bin
|
|
# auto-append behaviour: docs/process/gotchas.md::systemd.services.*.path
|
|
# appends /bin to every entry.
|
|
systemd.services.hive-agent =
|
|
let
|
|
binary = "hive-agent";
|
|
in
|
|
{
|
|
description = "${binary} harness";
|
|
wantedBy = [ "multi-user.target" ];
|
|
after = [ "network.target" ];
|
|
# `/run/wrappers` before `/run/current-system/sw` so setuid
|
|
# `sudo` resolves first. Passing the bare prefixes (no trailing
|
|
# `/bin`) is intentional — see docs pointer above.
|
|
path = [
|
|
"/run/wrappers"
|
|
"/run/current-system/sw"
|
|
];
|
|
environment = {
|
|
SHELL = "${pkgs.bashInteractive}/bin/bash";
|
|
HOME = homeDir;
|
|
HIVE_STATIC_DIR = "${config.hyperhive.frontend.mergedDist}";
|
|
HIVE_ASSETS_DIR = "${config.hyperhive.packages.assets}/share/hyperhive";
|
|
# Unix-socket path for the harness web UI. All agents always bind
|
|
# here; there is no TCP fallback. Path matches
|
|
# `hive_c0re::agent_sockets::socket_path_for(name)` so lifecycle
|
|
# bind-mounts and gateway upstream config stay in sync.
|
|
HIVE_WEB_SOCKET = "/run/hive-agent/${userName}/web.sock";
|
|
# In-agent socket (loose-ends v2): the harness binds this and
|
|
# serves the `hive-agent-sock` todo protocol to the in-container
|
|
# producers (matrix daemon) + the MCP bridge (`get_loose_ends`).
|
|
# Same per-agent runtime dir as the web socket so all agent-user
|
|
# services in this container can reach it; purely in-container
|
|
# (never bind-mounted to the host — unlike hive-c0re's mcp.sock).
|
|
HIVE_AGENT_SOCKET = "/run/hive-agent/${userName}/agent.sock";
|
|
# Loopback URL of the persistent `hive-mcp-http` daemon that
|
|
# `render_claude_config` points claude at for the built-in
|
|
# surface (HTTP is the sole transport — no per-turn stdio child).
|
|
# Kept in sync with the `hive-mcp-http` unit's `--http` port
|
|
# (see ./mcp.nix) via the same option. Always set — network
|
|
# isolation is unconditional, so a fixed per-container port is
|
|
# collision-free.
|
|
HYPERHIVE_MCP_HTTP_PORT = toString config.hyperhive.mcp.httpPort;
|
|
}
|
|
// lib.optionalAttrs config.hyperhive.gui.enable {
|
|
# Tells the harness which fixed VNC port weston bound, and (by
|
|
# its presence) that gui is enabled — the harness `/screen/ws`
|
|
# relay reads this instead of a runtime marker file. The port is
|
|
# container-local + fixed (network isolation is unconditional),
|
|
# so the same value for every gui agent is fine. See
|
|
# ./weston-vnc.nix::hyperhive.gui.vncPort.
|
|
HIVE_GUI_VNC_PORT = toString config.hyperhive.gui.vncPort;
|
|
}
|
|
// lib.optionalAttrs (config.hyperhive.extraWebProxies != { }) {
|
|
# JSON object {"<path>": "<upstream>"} for the transparent
|
|
# reverse-proxies. See `hyperhive.extraWebProxies` option
|
|
# and `web_ui/proxy.rs::extra_proxy_service`.
|
|
HIVE_EXTRA_WEB_PROXIES = builtins.toJSON config.hyperhive.extraWebProxies;
|
|
}
|
|
// lib.optionalAttrs config.hyperhive.useApiKey {
|
|
# Tells the harness not to wait for a Claude OAuth session — see
|
|
# `hyperhive.useApiKey`'s own description for the full mechanism.
|
|
HIVE_USE_API_KEY = "1";
|
|
};
|
|
serviceConfig = {
|
|
ExecStart = "${config.hyperhive.packages.hive-agent}/bin/${binary}";
|
|
# Pin the journal identity to the binary name (otherwise systemd
|
|
# derives SyslogIdentifier from the ExecStart basename).
|
|
SyslogIdentifier = binary;
|
|
Restart = "on-failure";
|
|
RestartSec = 2;
|
|
# A LOWER OOMScoreAdjust means LESS likely to be killed, so this
|
|
# negative value puts the harness — and the agent's own `claude`,
|
|
# which it spawns as a child and which therefore inherits the
|
|
# value — last in line under memory pressure, behind
|
|
# `hive-subagent-daemon`'s `+500` (see ./mcp.nix). Losing a
|
|
# subagent costs one restartable task; losing this costs the
|
|
# session that was supervising it. Not `-1000`, which would
|
|
# exempt the harness entirely and leave the kernel with nothing
|
|
# to kill in a container whose only large process is this one.
|
|
OOMScoreAdjust = -500;
|
|
# Per-service runtime dir owned by `User=` below; the harness
|
|
# writes its regenerated claude-{mcp-config,settings,system-prompt}
|
|
# files here (`paths::config_dir`). Separate from /run/hive,
|
|
# which holds hive-c0re's mcp.sock.
|
|
RuntimeDirectory = "hive-config";
|
|
User = userName;
|
|
Group = userName;
|
|
}
|
|
// lib.optionalAttrs (config.hyperhive.backendEnvironmentFile != null) {
|
|
# See `hyperhive.backendEnvironmentFile`'s own description for
|
|
# the file shape and the leading-`-` rationale.
|
|
EnvironmentFile = "-${config.hyperhive.backendEnvironmentFile}";
|
|
};
|
|
};
|
|
};
|
|
}
|