Compare commits
30 changed files with 200 additions and 541 deletions
|
|
@ -129,6 +129,3 @@ The docs below own the details — this section just points at them.
|
|||
window:** → [`docs/turn-loop.md`](docs/turn-loop.md).
|
||||
- **Two-step spawn, approval flow, flake.lock validation:** →
|
||||
[`docs/approvals.md`](docs/approvals.md).
|
||||
- **Pre-push lint hook** (catches tracker-tag and comment-block failures
|
||||
before CI does — install once per clone):
|
||||
`ln -sf ../../scripts/pre-push .git/hooks/pre-push`
|
||||
|
|
|
|||
|
|
@ -5,12 +5,11 @@ _implementation_ work — container network isolation, the unifying
|
|||
gateway, core-daemon privsep — is tracked as `area:ops` issues on
|
||||
the forge.
|
||||
|
||||
The operator/agent boundary is now technically enforced, not just a
|
||||
convention. Containers run in private netns (network isolation is
|
||||
always on), the gateway proxies all operator-facing traffic, and
|
||||
`hive-c0re` runs as the unprivileged `hive-core` user. All three
|
||||
`area:ops` pillars — network isolation, the gateway, and privsep —
|
||||
are complete and active.
|
||||
Today "the operator surface" and "the agent surface" are a
|
||||
_convention_, not a boundary — nothing stops a container from
|
||||
curling the core daemon on `localhost:<port>`, or another agent's
|
||||
web UI. Network isolation, the gateway, and privsep together turn
|
||||
that convention into an enforced boundary.
|
||||
|
||||
## Two principals, two paths
|
||||
|
||||
|
|
@ -40,17 +39,14 @@ agent page).
|
|||
|
||||
## Why network isolation is the load-bearing step
|
||||
|
||||
Without network isolation, containers share the host network namespace
|
||||
and can reach `localhost:<core-port>`, the dashboard, and every other
|
||||
agent's web port — the operator/agent split is on the honour system and
|
||||
every boundary claim above is aspirational. Network isolation is what
|
||||
makes the boundary _real_; the gateway and privsep are ergonomics and
|
||||
Containers currently share the host network namespace, so a
|
||||
container can reach `localhost:<core-port>`, the dashboard, and
|
||||
every other agent's web port. Until that changes, the
|
||||
operator/agent split is on the honour system — every boundary
|
||||
claim above is aspirational. Network isolation is what makes the
|
||||
boundary _real_; the gateway and privsep are ergonomics and
|
||||
defence-in-depth layered on top.
|
||||
|
||||
Network isolation is now complete and always on: every agent container
|
||||
runs in a private netns behind the hive bridge. The shared-netns mode
|
||||
was removed. See `docs/network.md`.
|
||||
|
||||
The `area:ops` issues followed this sequencing:
|
||||
|
||||
1. **Gateway** — pure ergonomics win, unblocks same-origin (lets the
|
||||
|
|
@ -58,8 +54,7 @@ The `area:ops` issues followed this sequencing:
|
|||
behavioural risk. An nginx nixos-container now sits in front of all
|
||||
surfaces; per-agent UIs are proxied under `/agent/<name>/`.
|
||||
2. **Network isolation** — the load-bearing step that turns the
|
||||
honour-system split into an enforced boundary. **Complete** —
|
||||
always-on, unconditional; the shared-netns mode was removed.
|
||||
honour-system split into an enforced boundary. In progress.
|
||||
3. **Privsep** — defence in depth on the core process; `hive-c0re`
|
||||
runs as the unprivileged `hive-core` user and delegates root
|
||||
operations to `hive-priv`, a narrow socket-activated helper. See
|
||||
|
|
|
|||
42
docs/ci.md
42
docs/ci.md
|
|
@ -1,46 +1,6 @@
|
|||
# hive-ci: Forgejo Actions Runner
|
||||
|
||||
The `hive-ci` module runs a Forgejo Actions runner in a `hive-ci` nixos-container,
|
||||
executing CI jobs from `.forgejo/workflows/ci.yml` on every PR.
|
||||
|
||||
## CI checks
|
||||
|
||||
Three jobs run on every PR (and on `workflow_dispatch` for manual re-triggers):
|
||||
|
||||
| Job | What it runs | Currently required |
|
||||
| --- | --- | --- |
|
||||
| **nix flake check** | treefmt + rustfmt formatting, `cargo clippy -D warnings`, `cargo test`, module evaluation | yes |
|
||||
| **tracker-tag lint** | flags `#NNN` issue tags in source and comments (`scripts/check-issue-refs.sh`) | no (red, non-blocking) |
|
||||
| **comment-block lint** | flags contiguous comment blocks over 30 lines (`scripts/check-comment-blocks.sh`) | no (red, non-blocking) |
|
||||
|
||||
The tracker-tag and comment-block checks are non-blocking today (a hit fails the
|
||||
check but does not prevent merge) while the legacy backlog is cleaned up. They are
|
||||
expected to become required checks once the tree is clean.
|
||||
|
||||
### Running checks locally
|
||||
|
||||
Don't run `nix flake check` directly — it dispatches to the shared build farm and
|
||||
wastes a remote-builder slot. Use the devshell equivalents instead:
|
||||
|
||||
```sh
|
||||
nix develop -c cargo clippy --all-targets -- -D warnings
|
||||
nix develop -c cargo test
|
||||
nix develop -c treefmt # same as nix fmt; treefmt covers rustfmt + nixfmt + taplo
|
||||
sh scripts/check-issue-refs.sh # tracker-tag lint
|
||||
sh scripts/check-comment-blocks.sh # comment-block lint
|
||||
```
|
||||
|
||||
A git pre-push hook that automates the two lint checks is provided at
|
||||
`scripts/pre-push`. Install it once per clone:
|
||||
|
||||
```sh
|
||||
ln -sf ../../scripts/pre-push .git/hooks/pre-push
|
||||
```
|
||||
|
||||
After that, any `git push` automatically runs both lints and aborts with a
|
||||
diagnostic if either fails — catching the issue locally before CI sees it.
|
||||
Note that the hook does **not** run `cargo clippy` or `cargo test` (those are
|
||||
slow); run those manually before pushing Rust changes.
|
||||
The `hive-ci` module runs a Forgejo Actions runner in a `hive-ci` nixos-container, executing CI jobs from `.forgejo/workflows/ci.yml` (e.g., `nix flake check` on every PR).
|
||||
|
||||
## Operator bootstrap
|
||||
|
||||
|
|
|
|||
|
|
@ -117,28 +117,19 @@ render.
|
|||
|
||||
## Auto-update sweep
|
||||
|
||||
On startup, `auto_update.rs` rebuilds containers that actually need it. Two skip
|
||||
rules keep boot-time work minimal:
|
||||
|
||||
1. **Stopped containers** are deferred: the startup sweep enqueues nothing for them.
|
||||
When the operator later starts a stopped container (via the dashboard or the
|
||||
`start` MCP tool), both `run_start` (queue path) and `handle_start` (socket path)
|
||||
check the rev marker first — if it's stale, the start is silently upgraded to a
|
||||
full rebuild+start so the container runs current nix derivations.
|
||||
|
||||
2. **Running containers with a matching rev marker** are skipped: if the per-agent
|
||||
`.{name}.hyperhive-rev` file under `/var/lib/hyperhive/applied/` already holds
|
||||
the current flake rev, no nix work is needed and the entry is omitted entirely.
|
||||
On startup, `auto_update.rs` rebuilds every known container unconditionally.
|
||||
`nixos-container update` is a no-op at the nix level when nothing changed (same
|
||||
store path), so the cost is low and avoids rev-marker staleness — all agents always
|
||||
need an update pass when any meta commit lands.
|
||||
|
||||
`auto_update::run` enqueues a single `StartupSweep` parent entry (`kind =
|
||||
startup_sweep`, `agent = "hyperhive"`) followed by per-agent `Rebuild` children
|
||||
for the agents that do need rebuilding (`source = startup_sweep`, `parent_id =
|
||||
sweep_id`). The sweep description records the rebuild / deferred / skipped counts
|
||||
so the operator can see at a glance how much work the boot triggered. The child
|
||||
(`source = startup_sweep`, `parent_id = sweep_id`). The worker processes the parent
|
||||
by bumping the meta `hyperhive` input lock, then transitions it to Done. The child
|
||||
rebuilds drain sequentially through the queue; the dashboard renders them nested
|
||||
under the parent.
|
||||
under the parent so the operator can see the whole boot-time sweep in one group.
|
||||
|
||||
Before the sweep-grouping change, each boot enqueued flat `Rebuild` entries with
|
||||
Before this change, each boot enqueued flat `Rebuild` entries with
|
||||
`source = AutoUpdate` and no parent — visible but ungrouped.
|
||||
|
||||
## Meta flake
|
||||
|
|
|
|||
|
|
@ -323,10 +323,11 @@ connects to the compositor at `127.0.0.1:<vnc_port>`.
|
|||
## Nix options reference (`nix/docs/default.nix`)
|
||||
|
||||
`pkgs.nixosOptionsDoc` over two evaluated module trees:
|
||||
`hostEval` (a stub NixOS system loading `hive-c0re.nix` with every
|
||||
hyperhive subsystem `mkForce false` so heavy build inputs stay out of
|
||||
the eval) and `agentEval` (evaluates `agent-base.nix` fresh for the
|
||||
per-agent options tree).
|
||||
`hostEval` (a stub NixOS system loading `self.nixosModules.default`
|
||||
with every hyperhive subsystem `mkForce false` so heavy build
|
||||
inputs stay out of the eval) and `agentEval` (reuses the already-evaluated
|
||||
`agent-base` container config so the per-agent options tree is
|
||||
identical to what a real agent container sees).
|
||||
|
||||
Three output trees consumed by `flake.nix`, all **markdown**:
|
||||
|
||||
|
|
@ -355,63 +356,3 @@ Host options live entirely under `services.hyperhive.*`. The
|
|||
options tree picks up everything under that root — picking against
|
||||
stray roots produces an empty tree and renders the host page as
|
||||
template chrome with no `<h2>` headers.
|
||||
|
||||
### Docs drv stability: `nixSrc` + stub overlay
|
||||
|
||||
Naively, the docs evaluation depends on `self` (the flake's store path),
|
||||
so every commit — even Rust-only or frontend-only changes — produces new
|
||||
docs drv hashes. The remote builder must rebuild docs from scratch for
|
||||
every PR branch, and if its store is full the build fails with a cached
|
||||
failure that blocks CI for the whole branch.
|
||||
|
||||
The fix (`nix/docs/default.nix`):
|
||||
|
||||
1. **`nixSrc`** — `builtins.path` on the `nix/` directory, wrapped in
|
||||
`builtins.unsafeDiscardStringContext` to strip `self`'s store-path
|
||||
context. The resulting store path is content-addressed from the nix/
|
||||
file contents only. Docs drvs only change when a `.nix` file changes.
|
||||
|
||||
2. **`docsStubOverlay`** — replaces `self.overlays.default` with stub
|
||||
packages (`pkgs.emptyFile` / `pkgs.emptyDirectory`) for the docs eval.
|
||||
`nixosOptionsDoc` renders `defaultText` for all package options anyway;
|
||||
the stubs prevent attribute-missing eval errors without pulling in the
|
||||
Rust or frontend build closure.
|
||||
|
||||
3. Both `hostEval` and `agentEval` are evaluated from `nixSrc` paths
|
||||
(not `self`), so the docs drv dependency chain ends at `nixSrc`.
|
||||
|
||||
Why `builtins.unsafeDiscardStringContext`? The path string
|
||||
`toString self + "/nix"` carries `self`'s string context, which would
|
||||
make `builtins.path` include `self` as a build dependency even after
|
||||
content-addressing the directory. Discarding the context makes the
|
||||
resulting `nixSrc` truly independent of `self`'s store path.
|
||||
|
||||
### `nix fmt` fails in a git worktree with "object not found"
|
||||
|
||||
`nix fmt` (and any `nix` command that fetches a `git+file://` flake
|
||||
URL) uses libgit2 internally to compute `revCount` — the number of
|
||||
commits reachable from HEAD. This walk fails with:
|
||||
|
||||
```
|
||||
error: getting Git object '<hash>': object not found (libgit2 error code = 9)
|
||||
```
|
||||
|
||||
when a commit that was reachable at some earlier evaluation is now gone
|
||||
(GC'd, rebased away, or pruned). The failure is persistent: clearing
|
||||
`~/.cache/nix/{eval-cache-v6,gitv3,fetcher-cache-v4.sqlite}` does not
|
||||
help because the missing object is a structural gap in the git object
|
||||
graph itself, not in nix's caches.
|
||||
|
||||
**Workaround: use a plain clone, not a git worktree.**
|
||||
|
||||
```bash
|
||||
git clone http://<forge>/hyperhive/hyperhive.git ~/hh-work
|
||||
cd ~/hh-work && nix fmt
|
||||
```
|
||||
|
||||
The root cause is specific to worktrees: a worktree shares the object
|
||||
store with its parent repo. If the parent repo's history was rewritten
|
||||
(rebase, force-push, `git gc --prune`) while the worktree was checked
|
||||
out at a branch tip that references the pruned commits via its reflog or
|
||||
history, libgit2's rev-walk encounters the gap. A plain clone has its
|
||||
own self-consistent object store and is immune to the issue.
|
||||
|
|
|
|||
103
docs/network.md
103
docs/network.md
|
|
@ -1,14 +1,31 @@
|
|||
# hive-network
|
||||
|
||||
Host-side bridge + per-agent private-netns isolation — always on
|
||||
whenever hyperhive is enabled. Configured via
|
||||
`services.hyperhive.network.*`.
|
||||
Host-side bridge + per-agent DNS resolver — the foundation that
|
||||
makes container netns isolation safe to land. Configured via
|
||||
`services.hyperhive.network.*`; off by default during rollout.
|
||||
|
||||
> **Historical note:** the bridge and private-netns isolation landed in
|
||||
> two separate phases. `services.hyperhive.network.enable` and
|
||||
> `services.hyperhive.network.isolateContainers` are retained as
|
||||
> deprecated no-op options so existing configs eval without change; both
|
||||
> are ignored — isolation is the only mode.
|
||||
## Why ship before netns isolation
|
||||
|
||||
If netns isolation lands first, agent containers lose
|
||||
`/etc/resolv.conf` propagation from the host and DNS breaks until a
|
||||
separate resolver is up. Inverting the sequence — bridge + dnsmasq
|
||||
first, netns flip second — makes the flag day boring: the resolver
|
||||
endpoint is already live, agents just discover it via veth instead
|
||||
of shared netns.
|
||||
|
||||
## v1 vs v2
|
||||
|
||||
| feature | v1 (this PR) | v2 (after netns isolation) |
|
||||
| ------------------------ | ------------------------------------------------------- | ----------------------------------------------- |
|
||||
| bridge interface | created on host, no slave NICs | per-agent veth pairs attach |
|
||||
| dnsmasq binding | bridge IP (reachable via host loopback in shared netns) | bridge IP (reachable via veth in private netns) |
|
||||
| agent container netns | shared host | private |
|
||||
| agent `/etc/resolv.conf` | unchanged (host DNS) | `nameserver <bridge-ip>` |
|
||||
| `address` rules target | `<bridge-ip>` (works in both modes) | unchanged from v1 |
|
||||
|
||||
The `address` rules ship pointing at the bridge IP from v1 so the
|
||||
DNS contract is fixed before any container actually depends on it
|
||||
— minimises the things that flip on netns day.
|
||||
|
||||
## Container shape (where dnsmasq lives)
|
||||
|
||||
|
|
@ -16,7 +33,9 @@ Co-located in the existing `hive-gateway` container — single
|
|||
front-door for both DNS and HTTP, saves a sibling container, single
|
||||
systemd-unit / state surface to monitor. The gateway shares host
|
||||
netns (`privateNetwork = false`) so dnsmasq's `bind-interfaces`
|
||||
listener on `bridgeIp` is on the host's bridge interface.
|
||||
listener on `bridgeIp` works without any veth gymnastics today; when
|
||||
agent containers flip to private netns the binding doesn't change
|
||||
(it's still on the host's bridge interface).
|
||||
|
||||
## Configuration
|
||||
|
||||
|
|
@ -25,14 +44,19 @@ listener on `bridgeIp` is on the host's bridge interface.
|
|||
services.hyperhive = {
|
||||
enable = true;
|
||||
domain = "darkest.space";
|
||||
# network.bridgeIp = "10.42.0.1"; # default
|
||||
# network.upstreamDns = [ "1.1.1.1" "9.9.9.9" ]; # default
|
||||
network.enable = true; # opt in to bridge + DNS
|
||||
network.bridgeIp = "10.42.0.1"; # default
|
||||
network.upstreamDns = [ # default Cloudflare + Quad9
|
||||
"1.1.1.1"
|
||||
"9.9.9.9"
|
||||
];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Requires `services.hyperhive.domain` to be set — the dnsmasq resolver
|
||||
is authoritative for `<hive-domain>` and its sub-domains.
|
||||
Asserts `services.hyperhive.domain != null` (resolver needs a domain
|
||||
to be authoritative for) + `services.hyperhive.gateway.enable =
|
||||
true` (resolver lives in the gateway container).
|
||||
|
||||
## Bridge addressing
|
||||
|
||||
|
|
@ -67,14 +91,15 @@ agent containers.
|
|||
## Firewall posture
|
||||
|
||||
`networking.firewall.interfaces.<bridge>.allowedUDPPorts = [ 53 ]`
|
||||
`networking.firewall.interfaces.<bridge>.allowedTCPPorts = [ 53 80 443 ]`
|
||||
|
||||
- Port 53 opens the resolver on the bridge interface only. Other
|
||||
interfaces stay closed. The hive resolver isn't an external-facing
|
||||
service.
|
||||
- Ports 80 and 443 let isolated agents reach nginx (gateway
|
||||
container, shared host netns) for the forge sub-domain, per-agent
|
||||
UI proxies, and any other HTTP services.
|
||||
- `allowedTCPPorts = [ 53 ]` opens the resolver on the bridge
|
||||
interface only. Other interfaces stay closed. The hive resolver
|
||||
isn't an external-facing service.
|
||||
|
||||
When `isolateContainers = true`, `allowedTCPPorts` is extended with
|
||||
`[ 80 443 ]` so isolated agents can reach nginx (gateway container,
|
||||
shared host netns) for the forge sub-domain, per-agent UI proxies,
|
||||
and any other HTTP services.
|
||||
|
||||
### Reaching host services (`exposeHostPorts`)
|
||||
|
||||
|
|
@ -101,16 +126,19 @@ DNS/gateway), so only expose services safe for any agent to reach.
|
|||
|
||||
## Container isolation
|
||||
|
||||
Each agent container runs in a private network namespace with a dedicated
|
||||
veth pair attached to the bridge. The following table summarises what
|
||||
the nix side sets up unconditionally:
|
||||
`services.hyperhive.network.isolateContainers` (default `false`) flips
|
||||
agent containers from shared host netns to private netns. Set only after
|
||||
`enable = true` is stable in production — an assertion blocks the reverse.
|
||||
|
||||
### What the nix side does when `isolateContainers = true`
|
||||
|
||||
| effect | mechanism |
|
||||
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| IP forwarding | `boot.kernel.sysctl."net.ipv4.ip_forward" = 1` |
|
||||
| Internet NAT | `networking.nat { enable = true; internalInterfaces = [ bridgeName ]; }` — MASQUERADE on packets leaving via any external NIC |
|
||||
| Loopback DROP | `networking.firewall.extraInputRules` — drops bridge-subnet → `127.0.0.0/8` traffic; defence-in-depth against routing table leaks |
|
||||
| Gateway access | `networking.firewall.interfaces.<bridge>.allowedTCPPorts = [ 80 443 ]` — lets isolated agents (private netns, veth on bridge) reach nginx on the host |
|
||||
| Gateway access | `networking.firewall.interfaces.<bridge>.allowedTCPPorts = [ 80 443 ]` — lets isolated agents reach nginx on the host (shared netns) |
|
||||
| Forge URL | `HIVE_FORGE_URL` flips from `http://127.0.0.1:3000` to `http://forge.<domain>` — agents resolve via dnsmasq, nginx proxies to forgejo |
|
||||
| c0re signal | `HIVE_NETWORK_ISOLATION=1`, `HIVE_NETWORK_BRIDGE`, `HIVE_NETWORK_SUBNET` in `systemd.services.hive-c0re.environment` |
|
||||
|
||||
`HIVE_NETWORK_SUBNET` is the host-side bridge IP + prefix (e.g.
|
||||
|
|
@ -120,7 +148,7 @@ address arithmetic.
|
|||
|
||||
### What the Rust side does
|
||||
|
||||
`hive-c0re` reads `HIVE_NETWORK_ISOLATION` and passes
|
||||
`hive-c0re` reads `HIVE_NETWORK_ISOLATION` and, when set, passes
|
||||
`PRIVATE_NETWORK=1`, `LOCAL_ADDRESS=<deterministic-ip>`,
|
||||
`HOST_ADDRESS=<bridge-ip>`, and `HOST_BRIDGE=<bridgeName>` via
|
||||
`lifecycle::set_nspawn_flags` when creating or updating containers. Each
|
||||
|
|
@ -143,17 +171,21 @@ but no path off the bridge subnet (no internet, no `api.anthropic.com`).
|
|||
nixos-container copies the **host's** `/etc/resolv.conf` into the container
|
||||
at every start. The host resolver (e.g. `127.0.0.53` from systemd-resolved,
|
||||
or a LAN router) is unreachable from a private netns and isn't
|
||||
authoritative for the hive's own zones, so it is replaced with the
|
||||
bridge dnsmasq at boot. Because the copy happens on every start, a
|
||||
authoritative for the hive's own zones, so it must be replaced with the
|
||||
bridge dnsmasq (the gateway IP). Because the copy happens on every start, a
|
||||
declarative `environment.etc."resolv.conf"` would be clobbered — so the
|
||||
wiring is runtime:
|
||||
|
||||
- `hive-priv` drops a marker file (`/etc/hyperhive-bridge-dns`, carrying the
|
||||
gateway IP) into each container's `/etc`.
|
||||
gateway IP) into the container's `/etc` **only when isolated**, removing
|
||||
it otherwise — so one shared container toplevel behaves correctly in both
|
||||
netns modes.
|
||||
- the `hyperhive-isolated-dns` oneshot (harness-base.nix), gated on that
|
||||
marker, rewrites `/etc/resolv.conf` to `nameserver <gateway-ip>` at boot.
|
||||
It is ordered `before` the harness (`hive-ag3nt`), the matrix daemon, and
|
||||
`tea-login` so the resolver is correct before the first DNS lookup.
|
||||
`tea-login` so the resolver is correct before the first DNS lookup; it's
|
||||
an instant no-op in shared-netns mode (the marker is absent, so
|
||||
`ConditionPathExists` skips it).
|
||||
|
||||
**Why isolation is safe**: all hive-c0re communication goes
|
||||
through unix domain sockets (`/run/hive/mcp.sock` for agent requests,
|
||||
|
|
@ -168,6 +200,19 @@ against a compromised agent reaching the c0re dashboard HTTP at
|
|||
`127.0.0.1`). Agents have no legitimate reason to reach the dashboard over
|
||||
loopback — the hive-c0re admin socket is a UDS, not TCP.
|
||||
|
||||
### Prerequisites before flipping on
|
||||
|
||||
- All agents must have `hyperhive.web.useUnixSocket = true`. Agents that
|
||||
still bind TCP on `0.0.0.0:<port>` will be reachable at their bridge IP
|
||||
from other agents on the same subnet — defeating the isolation goal. The
|
||||
gateway routes via unix sockets so gateway reach is unaffected.
|
||||
|
||||
### Migration behaviour
|
||||
|
||||
Containers are destroyed and re-created when the flag flips. Agent state
|
||||
under `/agents/<name>/state/` is bind-mounted and survives; the container
|
||||
rootfs is recreated cleanly from the nix store.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- `docs/gateway.md` — vhost map + the gateway container's other duties
|
||||
|
|
|
|||
|
|
@ -121,9 +121,12 @@ suffix, so the terminal degrades cleanly against older event shapes.
|
|||
`renderRichToolUse` (Write/Edit/send/ask/answer get
|
||||
custom renderings); on miss fall through to a flat
|
||||
`.tool-use` row with `fmtToolUse → fmtArgsGeneric`.
|
||||
`fmtToolUse` surfaces the salient arg per built-in tool
|
||||
(see [`fmtToolUse` patterns](#fmttooluse-patterns) below);
|
||||
`fmtArgsGeneric` handles everything else.
|
||||
`fmtToolUse` surfaces the salient arg per built-in tool:
|
||||
`recv` shows `wait <N>s` / `max <N>` when set; `Bash`
|
||||
flags `[bg]`; `remind` shows `+Xm "preview"`; matrix
|
||||
tools show `→ <room>/<user>: "body"` or `<room> [limit]`;
|
||||
scheduling tools show `#id · fields`; `fmtArgsGeneric`
|
||||
handles anything else.
|
||||
4. `type == "user"` → walk `message.content[]` for
|
||||
`tool_result`; `renderToolResult` correlates via
|
||||
`tool_use_id → toolNameById` to default-open `recv`
|
||||
|
|
@ -131,62 +134,6 @@ suffix, so the terminal degrades cleanly against older event shapes.
|
|||
long = collapsed details.
|
||||
5. Unrecognised shape → `.sys` row (amber, `!` glyph).
|
||||
|
||||
### `fmtToolUse` patterns
|
||||
|
||||
The `short` name strips the `mcp__hyperhive__` / `mcp__bash__` /
|
||||
`mcp__matrix__` prefix and appends `*` (e.g. `recv*`, `run*`,
|
||||
`send_message*`). Unprefixed tools (Read, Write, etc.) keep their
|
||||
name as-is.
|
||||
|
||||
| Tool | Rendered as |
|
||||
|------|-------------|
|
||||
| **Claude built-ins** | |
|
||||
| `Read` | `Read <path>` |
|
||||
| `Write` | rich diff row `Write <path> · +N` |
|
||||
| `Edit` | rich diff row `Edit <path> · -N +N` |
|
||||
| `Glob` | `Glob <pattern>` |
|
||||
| `Grep` | `Grep <pattern>` |
|
||||
| `Bash` | `Bash [bg] $ <cmd>` (also rich renderer for full body) |
|
||||
| `TodoWrite` | `TodoWrite (N items)` |
|
||||
| **Core hyperhive** | |
|
||||
| `send*` | rich renderer: `send* → to · NL` (default-open body) |
|
||||
| `recv*` | `recv*()` · `recv* wait Ns` · `recv* max N` |
|
||||
| `ask*` | rich renderer: `ask* → to` (inline answer form for operator) |
|
||||
| `answer*` | rich renderer: `answer* #id` |
|
||||
| `remind*` | `remind* +Xm "preview"` or `remind* at HH:MMZ "preview"` |
|
||||
| `set_status*` | `set_status* "text"` |
|
||||
| `get_loose_ends*` | `get_loose_ends*()` or `get_loose_ends* [agent]` |
|
||||
| `get_agent_meta*` | `get_agent_meta*()` or `get_agent_meta* name` |
|
||||
| `cancel_loose_end*` | `cancel_loose_end* kind #id` |
|
||||
| `ack_until*` | `ack_until* ≤N` |
|
||||
| **Lifecycle** | |
|
||||
| `kill*/restart*/start*/update*` | `kill* name` (etc.) |
|
||||
| `get_logs*` | `get_logs* name` or `get_logs* name NL` |
|
||||
| `get_host_journal*` | `get_host_journal*()` or with `[container] · [/grep/] · NL` |
|
||||
| **Approvals / config** | |
|
||||
| `request_apply_commit*` | `request_apply_commit* agent @ sha12` |
|
||||
| `request_init_config*` | `request_init_config* name` |
|
||||
| `request_update_meta_inputs*` | `request_update_meta_inputs* [inp1, …]` or `all` |
|
||||
| **Scheduling** | |
|
||||
| `list_schedules*` | `list_schedules*()` |
|
||||
| `cancel_schedule*` | `cancel_schedule* #id all` or `#id [t1, t2]` |
|
||||
| `fire_schedule_now*` | `fire_schedule_now* #id` |
|
||||
| `edit_schedule*` | `edit_schedule* #id · body · interval · next · +N tgt · -N tgt` (only changed fields shown) |
|
||||
| `request_schedule_prompt*` | `request_schedule_prompt* → t1, t2 at HH:MMZ` (+ `+Ns` if recurring) |
|
||||
| **Bash MCP** | |
|
||||
| `run*` | `run* [bg] $ cmd` (also rich renderer for full cmd body) |
|
||||
| `status*` (bash) | `status* id:xyz` or `status* id:xyz · wait Ns` |
|
||||
| `kill*` (bash) | `kill* id` or `kill* id [force]` |
|
||||
| **Matrix MCP** | |
|
||||
| `send_message*/send_dm*/send_reply*` | `send_message* → room: "body"` / `send_dm* → @user: "body"` |
|
||||
| `send_reaction*` | `send_reaction* room emoji` |
|
||||
| `read_room*` | `read_room* room` or `read_room* room [N]` |
|
||||
| `mark_read*` | `mark_read* room` |
|
||||
| `join_room*/open_dm*` | `join_room* room` / `open_dm* @user` |
|
||||
| `invite_user*` | `invite_user* @user → room` |
|
||||
| `download_file*` | `download_file* room` |
|
||||
| **Everything else** | `fmtArgsGeneric` — see [Extra-MCP tools](#extra-mcp-tools) |
|
||||
|
||||
## Markdown
|
||||
|
||||
`mdNode(text)` wraps `marked.parse(text)` (the `marked` v4.x npm
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@ invocation regardless of tool groups.
|
|||
Submit a shell command for background execution (runs via `bash`).
|
||||
Stdout and stderr stream to `harness/bash-tasks/<id>.{out,err}`.
|
||||
When the task completes (or times out, or the process errors), the
|
||||
harness fires a wake with `from: "bash-task-<id>"`; the body contains
|
||||
the exit code and last stdout lines. Handle the completion on a future
|
||||
turn.
|
||||
harness fires a wake with `from: "bash-task-<id>"` and the exit code
|
||||
|
||||
- last stdout lines in the body; handle it on a future turn.
|
||||
|
||||
* `timeout_secs` — kill the task after N seconds and mark it
|
||||
`timed_out`. Omit for no timeout (runs until natural exit).
|
||||
|
|
|
|||
|
|
@ -178,20 +178,9 @@ plain comment show under `last comment`, not `reviews`.
|
|||
|
||||
### Repo management
|
||||
|
||||
Agents **cannot create repos directly via forge token** — Forgejo
|
||||
disables push-to-create and the agent token doesn't have the Create
|
||||
scope. Two paths exist depending on where the repo should live:
|
||||
|
||||
**Agent repos (`agents/<name>`)** — Use the `mcp__hyperhive__create_repo`
|
||||
MCP tool (requires the `forge` tool group). hive-c0re creates the repo in
|
||||
the c0re-owned `agents/` org, adds you as a write collaborator (not
|
||||
owner), and enables branch protection (operator-team merge approval
|
||||
required — you cannot self-merge). Clone URL is returned immediately.
|
||||
This is the standard path for agents that need a working repo.
|
||||
|
||||
**Other repos** — Use the CLI verbs below (`repo-create` / `repo-add-collaborator`).
|
||||
These use the agent's own forge token so the repo lands under the agent's
|
||||
user account or an org the agent belongs to.
|
||||
Three verbs create repos and manage their membership — the supported
|
||||
path for workflows that need a fresh repo (agents cannot push to
|
||||
non-existent repos; the Forgejo instance disables push-to-create).
|
||||
|
||||
**`repo-create <name>`** — create a repo under the authenticated user
|
||||
and print its URL. Key flags:
|
||||
|
|
@ -206,7 +195,8 @@ and print its URL. Key flags:
|
|||
active repo (`-r`/`HIVE_FORGE_REPO`). Companion to `repo-create`. The
|
||||
`--permission` flag accepts `read` / `write` (default) / `admin`.
|
||||
`hive-c0re` uses this internally when an agent's config repo is
|
||||
initialised.
|
||||
initialised (`create_repo` → `repo-add-collaborator` → agent gets
|
||||
write access).
|
||||
|
||||
**`repo-labels [PATTERN]`** — list every label defined on the repo,
|
||||
optionally filtered by a name substring (case-sensitive). Distinct from
|
||||
|
|
|
|||
|
|
@ -17,11 +17,6 @@ room you haven't read yet.
|
|||
accepts `!id:server` or `#alias:server`, daemon resolves either
|
||||
- `send_dm(user_id, body)` — open (or reuse) the DM room with
|
||||
`user_id` and post `body` to it
|
||||
- `open_dm(user_id)` — resolve (find-or-create) the DM room with
|
||||
`user_id` and return its room id **without sending anything**.
|
||||
Use the returned id with room-based tools (`send_message`,
|
||||
`send_file`, …) to deliver into a DM when `send_dm`'s body
|
||||
parameter is inconvenient (e.g. for file attachments)
|
||||
- `send_reply(room, event_id, body)` — threaded reply to a specific
|
||||
event
|
||||
- `send_reaction(room, event_id, key)` — react to a message with an
|
||||
|
|
|
|||
|
|
@ -616,17 +616,6 @@ at_unix_timestamp?)`, `request_next_turn()`.
|
|||
- **Scheduling + diagnostics** (`scheduling`, `diagnostics`) —
|
||||
scheduled prompts, `get_logs`. See
|
||||
[`docs/tools/scheduling.md`](tools/scheduling.md).
|
||||
- **Forge repos** (`forge`) — `create_repo` — the only agent path to
|
||||
create a repo under the `agents/` org (direct forge token creation is
|
||||
disabled for agents). The repo is created in the c0re-owned `agents`
|
||||
org; the calling agent gets write collaborator access; the default
|
||||
branch is branch-protected (operator-team must approve merges, so the
|
||||
agent cannot self-merge). Opt-in; not in any default preset.
|
||||
See [`docs/tools/forge.md — Repo management`](tools/forge.md).
|
||||
- **Web egress** (`web_tools`) — enables Claude's built-in `WebFetch`
|
||||
and `WebSearch` tools (not MCP tools; added directly to the
|
||||
`--allowedTools` list). Off by default; add the group in the
|
||||
P3RM1SS10NS tab and rebuild to enable.
|
||||
- **Capability-gated** — `get_host_journal` (requires
|
||||
`read_host_journal` capability set via the P3RM1SS10NS tab;
|
||||
orthogonal to tool groups). Full list of capabilities and their
|
||||
|
|
|
|||
|
|
@ -37,9 +37,7 @@ This doc has been split for readability. Pick the section you need:
|
|||
[`web-ui/dashboard.md`](web-ui/dashboard.md) (Dashboard
|
||||
endpoints, Dashboard event channel).
|
||||
- **"How does the per-agent terminal render tool calls?"** →
|
||||
[`terminal-rendering.md`](terminal-rendering.md) (full row
|
||||
taxonomy + dispatch walkthrough); for a high-level summary see
|
||||
[`web-ui/agent.md`](web-ui/agent.md) (Per-stream rendering).
|
||||
[`web-ui/agent.md`](web-ui/agent.md) (Live view).
|
||||
- **"What slash commands does the agent accept?"** →
|
||||
[`web-ui/agent.md`](web-ui/agent.md) (Terminal-embedded prompt).
|
||||
- **"What are the per-agent HTTP endpoints?"** →
|
||||
|
|
|
|||
|
|
@ -96,8 +96,8 @@ through. Three flex columns:
|
|||
closes the menu without an extra POST.
|
||||
A second separator + **effort quick-picker** section labelled
|
||||
`effort` appears when the backend declares effort levels in
|
||||
`state.available_efforts`. One button per level (e.g. `low`,
|
||||
`medium`, `high`, `xhigh`, `max`); clicking POSTs `/api/effort` (same endpoint as
|
||||
`state.available_efforts`. One button per level (e.g. `medium`,
|
||||
`high`, `xhigh`); clicking POSTs `/api/effort` (same endpoint as
|
||||
the `/effort <level>` slash command). The active level's button is
|
||||
highlighted; `renderEffortChip` keeps the picker in sync with
|
||||
`StateSnapshot.effort` from the cold-load snapshot.
|
||||
|
|
@ -219,8 +219,7 @@ SSE frame and the replayed history rows. The web UI:
|
|||
- terminal-themed: phosphor mauve glow, Crust bg,
|
||||
backdrop-filter blur, row fade-in slide-up.
|
||||
|
||||
Per-stream rendering (see [`docs/terminal-rendering.md`](../terminal-rendering.md) for
|
||||
the full row taxonomy and dispatch logic):
|
||||
Per-stream rendering:
|
||||
|
||||
- `Stream` `tool_use` →
|
||||
- `Write` / `Edit`: collapsed `<details>` with a +/- diff body
|
||||
|
|
@ -228,18 +227,13 @@ the full row taxonomy and dispatch logic):
|
|||
`input.new_string` or every line of `input.content`).
|
||||
Summary carries the path + line counts.
|
||||
- others (`Read /path`, `Bash $ cmd`, `mcp__hyperhive__send →
|
||||
operator: "..."`, etc.): flat one-line per-tool format with
|
||||
per-tool salient-arg extraction (`fmtToolUse`).
|
||||
operator: "..."`, etc.): flat one-line per-tool format.
|
||||
- `Stream` `tool_result` short → flat `← ...`; long → collapsed
|
||||
`<details>` `▸ ← Nl · headline` (click to expand full body).
|
||||
- `Stream` `thinking` → `.thinking` row with a `💭 thinking …`
|
||||
indicator.
|
||||
- `Stream` `system` → handled by subtype: `plugin_install` and
|
||||
`compact_boundary` emit muted notes; `commands_changed` emits an
|
||||
expandable details row listing slash commands; `thinking_tokens`
|
||||
updates a single in-place `🧠` counter; `init`, `result`, and
|
||||
`rate_limit_event` are dropped (noise / used elsewhere); other
|
||||
subtypes → muted `⚙ <subtype>` note.
|
||||
- `Stream` `thinking` → text content if claude provided one,
|
||||
otherwise the bare `· thinking …` indicator.
|
||||
- `Stream` `system init`, `result`, `rate_limit_event` are
|
||||
dropped — too noisy.
|
||||
- `Note` → `· text`.
|
||||
- `TurnStart` → `◆ TURN ← <from>` with the wake-prompt body; a
|
||||
muted `· HH:MM:SS` time suffix from the event `ts`.
|
||||
|
|
@ -269,7 +263,7 @@ Slash commands today:
|
|||
`/state/hyperhive-model` so the override survives harness
|
||||
restart / rebuild.
|
||||
- `/effort <level>` — `POST /api/effort` setting the claude effort
|
||||
level (`low` / `medium` / `high` / `xhigh` / `max`). Takes effect on the next
|
||||
level (`medium` / `high` / `xhigh`). Takes effect on the next
|
||||
turn. The overflow menu surfaces an effort picker that calls the
|
||||
same endpoint; both stay in sync via `StateSnapshot.effort`.
|
||||
- `/new-session` — `POST /api/new-session` (confirms first).
|
||||
|
|
@ -310,7 +304,7 @@ shaped).
|
|||
future turns. `Bus::set_model` emits `ModelChanged`.
|
||||
- `POST /api/effort` (`effort=<level>`) — switch the claude
|
||||
effort level for future sessions. Validated server-side against
|
||||
`EFFORT_LEVELS` (`low`/`medium`/`high`/`xhigh`/`max`) — unknown values are
|
||||
`EFFORT_LEVELS` (`medium`/`high`/`xhigh`) — unknown values are
|
||||
rejected rather than forwarded to `claude --effort`. Persists via
|
||||
`Bus::set_effort`, which emits `EffortChanged`. Applies on the next
|
||||
session start (no mid-session swap).
|
||||
|
|
|
|||
|
|
@ -1626,40 +1626,6 @@ window.marked = marked;
|
|||
}
|
||||
case 'mcp__hyperhive__request_apply_commit':
|
||||
return short + ' ' + (input.agent || '') + ' @ ' + (input.commit_ref || '').slice(0, 12);
|
||||
case 'mcp__hyperhive__request_init_config':
|
||||
return short + ' ' + (input.name || '?');
|
||||
case 'mcp__hyperhive__request_update_meta_inputs': {
|
||||
const ins = Array.isArray(input.inputs) && input.inputs.length
|
||||
? '[' + input.inputs.slice(0, 4).join(', ')
|
||||
+ (input.inputs.length > 4 ? ', …' : '') + ']'
|
||||
: 'all';
|
||||
return short + ' ' + ins;
|
||||
}
|
||||
case 'mcp__hyperhive__list_schedules':
|
||||
return short + '()';
|
||||
case 'mcp__hyperhive__cancel_schedule':
|
||||
return short + ' #' + (input.id != null ? input.id : '?')
|
||||
+ (Array.isArray(input.targets) && input.targets.length
|
||||
? ' [' + input.targets.join(', ') + ']' : ' all');
|
||||
case 'mcp__hyperhive__fire_schedule_now':
|
||||
return short + ' #' + (input.id != null ? input.id : '?');
|
||||
case 'mcp__hyperhive__edit_schedule': {
|
||||
const parts = ['#' + (input.id != null ? input.id : '?')];
|
||||
if (input.body != null) parts.push('body');
|
||||
if (input.interval_seconds != null) parts.push('interval');
|
||||
if (input.next_fire_at_unix != null) parts.push('next');
|
||||
if (input.targets_add && input.targets_add.length) parts.push('+' + input.targets_add.length + ' tgt');
|
||||
if (input.targets_remove && input.targets_remove.length) parts.push('-' + input.targets_remove.length + ' tgt');
|
||||
return short + ' ' + parts.join(' · ');
|
||||
}
|
||||
case 'mcp__hyperhive__request_schedule_prompt': {
|
||||
const tgts = Array.isArray(input.targets) ? input.targets : [];
|
||||
const when = input.first_fire_at_unix != null
|
||||
? new Date(input.first_fire_at_unix * 1000).toISOString().slice(11, 16) + 'Z'
|
||||
: '?';
|
||||
return short + ' → ' + (tgts.length ? tgts.join(', ') : '?') + ' at ' + when
|
||||
+ (input.interval_seconds != null ? ' +' + input.interval_seconds + 's' : '');
|
||||
}
|
||||
case 'mcp__bash__run': {
|
||||
// Rich renderer handles the full body; this summary covers any
|
||||
// fallback path and the details summary line.
|
||||
|
|
|
|||
|
|
@ -583,7 +583,7 @@ pub const DEFAULT_EFFORT: &str = "medium";
|
|||
|
||||
/// Valid claude `--effort` levels, ascending. The operator picker is
|
||||
/// constrained to these; [`is_valid_effort`] guards the persist path.
|
||||
pub const EFFORT_LEVELS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"];
|
||||
pub const EFFORT_LEVELS: [&str; 3] = ["medium", "high", "xhigh"];
|
||||
|
||||
/// True iff `level` is one of [`EFFORT_LEVELS`].
|
||||
#[must_use]
|
||||
|
|
@ -1271,7 +1271,7 @@ mod tests {
|
|||
}
|
||||
// Out-of-set, empty, and wrong-case inputs are rejected so a bad
|
||||
// picker POST never reaches `claude --effort`.
|
||||
for bad in ["lowest", "", "MEDIUM", "ultra", "high "] {
|
||||
for bad in ["low", "", "MEDIUM", "ultra", "high "] {
|
||||
assert!(!is_valid_effort(bad), "{bad:?} should be rejected");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2182,14 +2182,21 @@ const EXTRA_MCP_PATH: &str = "/etc/hyperhive/extra-mcp.json";
|
|||
const SEND_ALLOW_PATH: &str = "/etc/hyperhive/send-allow.json";
|
||||
|
||||
/// Enforce the per-agent send allow-list. Returns `Ok` when the
|
||||
/// recipient is permitted (no list configured, `<parent>` sentinel
|
||||
/// always allowed, or `to` is in the list); returns `Err(refusal)`
|
||||
/// with a claude-readable string when blocked <20><><EFBFBD> the harness surfaces
|
||||
/// the refusal as the tool result so claude knows the message didn't
|
||||
/// land and can react (e.g. route via `<parent>` instead).
|
||||
/// recipient is permitted (no list configured, manager always
|
||||
/// allowed, or `to` is in the list); returns `Err(refusal)` with a
|
||||
/// claude-readable string when blocked — the harness surfaces the
|
||||
/// refusal as the tool result so claude knows the message didn't
|
||||
/// land and can react (e.g. route via the manager instead).
|
||||
fn check_send_allowed(to: &str) -> Result<(), String> {
|
||||
if to == hive_sh4re::MANAGER_AGENT {
|
||||
// Always allow agents to talk to the manager — otherwise a
|
||||
// misconfigured allow-list could leave a sub-agent unable
|
||||
// to ask for help.
|
||||
return Ok(());
|
||||
}
|
||||
if to == hive_sh4re::PARENT_RECIPIENT {
|
||||
// Always allow `<parent>` — the allow-list constrains peer
|
||||
// Always allow `<parent>` — same escape-hatch rationale as
|
||||
// the manager exception. The allow-list constrains peer
|
||||
// chatter, not the structural reporting line; the operator
|
||||
// can rewire who the parent IS via `set_parent` without
|
||||
// having to remember to update the per-agent allow-list.
|
||||
|
|
|
|||
|
|
@ -461,7 +461,7 @@ struct StateSnapshot {
|
|||
/// runtime via `POST /api/effort`; applies on the next session.
|
||||
effort: String,
|
||||
/// Selectable effort levels for the picker, ascending. Fixed set
|
||||
/// (`low`, `medium`, `high`, `xhigh`, `max`) — sourced from
|
||||
/// (`medium`, `high`, `xhigh`) — sourced from
|
||||
/// [`crate::events::EFFORT_LEVELS`], not operator-configurable like
|
||||
/// `available_models`. The frontend renders one button per entry.
|
||||
available_efforts: Vec<String>,
|
||||
|
|
|
|||
|
|
@ -1,13 +1,8 @@
|
|||
//! Startup auto-update: on `hive-c0re serve` boot, rebuild containers that
|
||||
//! actually need it. Two skip rules keep boot-time work minimal:
|
||||
//!
|
||||
//! 1. **Stopped containers** are deferred — they will be rebuilt the first
|
||||
//! time the operator starts them (see `rebuild_queue::run_start` and
|
||||
//! `socket_server::handle_start`).
|
||||
//! 2. **Running containers whose rev marker matches** the current hyperhive
|
||||
//! flake path are skipped — nothing changed, no nix work to do.
|
||||
//!
|
||||
//! See `docs/coordinator.md::Auto-update sweep`.
|
||||
//! Startup auto-update: on `hive-c0re serve` boot, rebuild every known
|
||||
//! container unconditionally. `nixos-container update` is a no-op at the
|
||||
//! nix level when nothing changed (same store path), so the cost is low
|
||||
//! and avoids rev-marker staleness (all agents always need an update pass
|
||||
//! when any meta commit lands). See `docs/coordinator.md::Auto-update sweep`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
|
@ -327,13 +322,11 @@ pub fn topology_sort(
|
|||
});
|
||||
}
|
||||
|
||||
/// Rebuild containers that need it on startup. Skips:
|
||||
/// - **Stopped containers**: deferred to on-start (`run_start` / `handle_start`
|
||||
/// upgrades a plain start to rebuild+start when the rev marker is stale).
|
||||
/// - **Running containers with a matching rev marker**: no nix work needed.
|
||||
///
|
||||
/// Enqueues a `StartupSweep` parent entry followed by per-agent `Rebuild`
|
||||
/// children linked via `parent_id`. Returns Ok even if some rebuilds failed.
|
||||
/// Rebuild every container on startup. Enqueues a `StartupSweep` parent
|
||||
/// entry (agent = `"hyperhive"`) followed by per-agent `Rebuild` children
|
||||
/// linked via `parent_id`. The dashboard renders them nested so the operator
|
||||
/// can see at a glance "boot N agents, here is each rebuild's status".
|
||||
/// Returns Ok even if some rebuilds failed.
|
||||
pub async fn run(coord: Arc<Coordinator>) -> Result<()> {
|
||||
let containers = match lifecycle::list().await {
|
||||
Ok(c) => c,
|
||||
|
|
@ -343,7 +336,22 @@ pub async fn run(coord: Arc<Coordinator>) -> Result<()> {
|
|||
}
|
||||
};
|
||||
|
||||
let current_rev = current_flake_rev(&coord.hyperhive_flake);
|
||||
// Enqueue the parent sweep entry. The worker processes it trivially
|
||||
// (no-op dispatch) so it completes quickly; its purpose is to give the
|
||||
// dashboard a "why" header for the per-agent child rebuilds below.
|
||||
let sweep_id = coord.rebuild_queue.enqueue(
|
||||
crate::rebuild_queue::QueueKind::StartupSweep,
|
||||
"hyperhive".to_owned(),
|
||||
crate::rebuild_queue::QueueSource::AutoUpdate,
|
||||
format!("startup sweep ({} containers)", containers.len()),
|
||||
None,
|
||||
);
|
||||
|
||||
tracing::info!(
|
||||
agents = containers.len(),
|
||||
sweep_id,
|
||||
"auto-update: queueing all on startup"
|
||||
);
|
||||
|
||||
// Resolve container names to logical agent names, then sort by
|
||||
// topology depth so parents are always rebuilt before their
|
||||
|
|
@ -355,56 +363,7 @@ pub async fn run(coord: Arc<Coordinator>) -> Result<()> {
|
|||
.collect();
|
||||
let topo = crate::topology::read();
|
||||
topology_sort(&mut logical_names, &topo);
|
||||
|
||||
// Pre-classify: decide which agents need a rebuild now vs can be skipped.
|
||||
let mut to_rebuild: Vec<String> = Vec::new();
|
||||
let mut n_deferred = 0usize;
|
||||
let mut n_skipped = 0usize;
|
||||
for name in &logical_names {
|
||||
// Idea 2: stopped containers are deferred — rebuild happens the first
|
||||
// time the operator starts them.
|
||||
if !lifecycle::is_running(name).await {
|
||||
n_deferred += 1;
|
||||
tracing::debug!(%name, "startup sweep: stopped — deferring rebuild to on-start");
|
||||
continue;
|
||||
}
|
||||
// Idea 1: running containers with a matching rev marker need no rebuild.
|
||||
if let Some(ref rev) = current_rev {
|
||||
let stored = std::fs::read_to_string(rev_marker_path(name)).ok();
|
||||
if stored.as_deref() == Some(rev.as_str()) {
|
||||
n_skipped += 1;
|
||||
tracing::debug!(%name, "startup sweep: rev unchanged — skipping rebuild");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
to_rebuild.push(name.clone());
|
||||
}
|
||||
|
||||
// Enqueue the parent sweep entry. The worker processes it trivially
|
||||
// (no-op dispatch); its purpose is to give the dashboard a "why" header.
|
||||
let sweep_id = coord.rebuild_queue.enqueue(
|
||||
crate::rebuild_queue::QueueKind::StartupSweep,
|
||||
"hyperhive".to_owned(),
|
||||
crate::rebuild_queue::QueueSource::AutoUpdate,
|
||||
format!(
|
||||
"startup sweep: {} rebuild(s), {} deferred (stopped), {} skipped (up-to-date)",
|
||||
to_rebuild.len(),
|
||||
n_deferred,
|
||||
n_skipped,
|
||||
),
|
||||
None,
|
||||
);
|
||||
|
||||
tracing::info!(
|
||||
total = containers.len(),
|
||||
rebuilds = to_rebuild.len(),
|
||||
deferred = n_deferred,
|
||||
skipped = n_skipped,
|
||||
sweep_id,
|
||||
"auto-update: startup sweep"
|
||||
);
|
||||
|
||||
for name in to_rebuild {
|
||||
for name in logical_names {
|
||||
coord.rebuild_queue.enqueue(
|
||||
crate::rebuild_queue::QueueKind::Rebuild,
|
||||
name,
|
||||
|
|
|
|||
|
|
@ -1681,7 +1681,7 @@ async fn post_request_spawn(
|
|||
hive_sh4re::ApprovalKind::Spawn,
|
||||
"",
|
||||
None,
|
||||
"operator",
|
||||
hive_sh4re::MANAGER_AGENT,
|
||||
) {
|
||||
Ok(id) => {
|
||||
tracing::info!(%id, %name, "operator: spawn approval queued via dashboard");
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::Result;
|
||||
use hive_sh4re::LooseEnd;
|
||||
use hive_sh4re::{LooseEnd, MANAGER_AGENT};
|
||||
|
||||
use crate::coordinator::Coordinator;
|
||||
|
||||
|
|
@ -56,12 +56,12 @@ pub fn for_agent(coord: &Coordinator, agent: &str) -> Result<Vec<LooseEnd>> {
|
|||
}
|
||||
// Show each pending approval to the agent that submitted it. The
|
||||
// submitter column is NULL for rows predating it; those count as
|
||||
// operator-initiated (no agent tracking predates the column).
|
||||
// the root agent's.
|
||||
for a in coord.approvals.pending()? {
|
||||
let submitter = coord
|
||||
.approvals
|
||||
.submitter_of(a.id)?
|
||||
.unwrap_or_else(|| "operator".to_owned());
|
||||
.unwrap_or_else(|| MANAGER_AGENT.to_owned());
|
||||
if submitter != agent {
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -203,14 +203,13 @@ pub fn handle_cancel_loose_end(
|
|||
// may withdraw it. Without the ownership check, any
|
||||
// approvals-group agent could cancel any other's approval by
|
||||
// id. A NULL submitter (legacy row predating the column) is
|
||||
// treated as operator-initiated (no agent tracking predates
|
||||
// the column).
|
||||
// owned by the root agent.
|
||||
check_can_cancel_approval(canceller)?;
|
||||
let submitter = coord
|
||||
.approvals
|
||||
.submitter_of(id)
|
||||
.map_err(|e| format!("{e:#}"))?
|
||||
.unwrap_or_else(|| "operator".to_owned());
|
||||
.unwrap_or_else(|| hive_sh4re::MANAGER_AGENT.to_owned());
|
||||
if submitter != canceller {
|
||||
return Err(format!(
|
||||
"cancel_loose_end: approval {id} was submitted by {submitter}, \
|
||||
|
|
|
|||
|
|
@ -1034,33 +1034,11 @@ async fn rebuild_for_entry(
|
|||
/// Uses the cold-start fallback (stop + kill + start retry) so the
|
||||
/// deferred start-after-rebuild keeps the same activation-error recovery
|
||||
/// it had when it ran inline on the build lane.
|
||||
///
|
||||
/// If the hyperhive flake rev has changed since the container was last built
|
||||
/// (i.e. the rev marker is stale or missing), the start is upgraded to a full
|
||||
/// rebuild so the container runs current nix derivations. This is the
|
||||
/// "deferred stopped container" path from `auto_update::run`.
|
||||
async fn run_start(
|
||||
coord: &std::sync::Arc<crate::coordinator::Coordinator>,
|
||||
entry: &QueueEntry,
|
||||
) -> anyhow::Result<()> {
|
||||
let name = &entry.agent;
|
||||
// Upgrade to rebuild+start if the rev marker is stale.
|
||||
let current_rev = crate::auto_update::current_flake_rev(&coord.hyperhive_flake);
|
||||
if let Some(ref rev) = current_rev {
|
||||
let stored = std::fs::read_to_string(crate::auto_update::rev_marker_path(name)).ok();
|
||||
if stored.as_deref() != Some(rev.as_str()) {
|
||||
tracing::info!(%name, "start: rev stale — upgrading to rebuild+start");
|
||||
return crate::auto_update::rebuild_agent(
|
||||
coord,
|
||||
name,
|
||||
rev,
|
||||
Some(entry.id),
|
||||
true,
|
||||
Some(entry.source),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
let _guard = coord.transient_guard(name, crate::coordinator::TransientKind::Starting);
|
||||
coord.set_queue_step(Some(entry.id), "nixos-container start");
|
||||
crate::lifecycle::start_with_fallback(name).await?;
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
|||
hive_sh4re::ApprovalKind::Spawn,
|
||||
"",
|
||||
None,
|
||||
"operator",
|
||||
hive_sh4re::MANAGER_AGENT,
|
||||
)?;
|
||||
tracing::info!(%id, %name, "spawn approval queued");
|
||||
HostResponse::success()
|
||||
|
|
|
|||
|
|
@ -803,25 +803,6 @@ async fn handle_start(coord: &Arc<Coordinator>, agent: &str, name: &str) -> Agen
|
|||
return err;
|
||||
}
|
||||
tracing::info!(%agent, %name, "start container");
|
||||
// If the hyperhive rev is stale, route through the rebuild queue so the
|
||||
// container runs current nix derivations before it starts. Same logic as
|
||||
// `run_start`; this covers the MCP `start` tool path.
|
||||
let current_rev = crate::auto_update::current_flake_rev(&coord.hyperhive_flake);
|
||||
if let Some(ref rev) = current_rev {
|
||||
let stored = std::fs::read_to_string(crate::auto_update::rev_marker_path(name)).ok();
|
||||
if stored.as_deref() != Some(rev.as_str()) {
|
||||
tracing::info!(%agent, %name, "start: rev stale — enqueuing rebuild");
|
||||
coord.rebuild_queue.enqueue(
|
||||
crate::rebuild_queue::QueueKind::Rebuild,
|
||||
name.to_owned(),
|
||||
crate::rebuild_queue::QueueSource::Manual,
|
||||
format!("start {name}: rev stale — rebuilding first"),
|
||||
None,
|
||||
);
|
||||
coord.emit_rebuild_queue_snapshot();
|
||||
return AgentResponse::Ok;
|
||||
}
|
||||
}
|
||||
match crate::lifecycle::start(name).await {
|
||||
Ok(()) => {
|
||||
coord.kick_agent(name, "container started");
|
||||
|
|
@ -1847,8 +1828,8 @@ pub(crate) fn submit_init_config(
|
|||
description.as_deref(),
|
||||
// `parent` is the requesting agent (becomes the new child's
|
||||
// parent); it's also the submitter the approval events route
|
||||
// back to. No declared parent = operator-initiated path.
|
||||
parent.unwrap_or("operator"),
|
||||
// back to. No declared parent = operator/root path.
|
||||
parent.unwrap_or(hive_sh4re::MANAGER_AGENT),
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("queue approval row: {e:#}"))?;
|
||||
tracing::info!(%id, %name, "init_config approval queued");
|
||||
|
|
|
|||
|
|
@ -11,50 +11,17 @@
|
|||
# prose `/docs/` tree. Full pipeline + subtree-pick / output-tree
|
||||
# rationale: docs/gotchas.md::Nix options reference.
|
||||
let
|
||||
# Content-addressed narrow source covering only the nix/ directory.
|
||||
# `builtins.unsafeDiscardStringContext` strips `self`'s store-path
|
||||
# context so `builtins.path` hashes only the nix/ file content, not
|
||||
# the full flake source (Rust, frontend, markdown, …). Result: the
|
||||
# docs drvs only change when a .nix file changes, not on every
|
||||
# commit — the muede-pc2 remote builder can reuse its cached result
|
||||
# for any commit that doesn't touch nix/.
|
||||
nixSrc = builtins.path {
|
||||
path = builtins.unsafeDiscardStringContext (toString self + "/nix");
|
||||
name = "hyperhive-nix-src";
|
||||
};
|
||||
|
||||
# Stub overlay that satisfies pkgs.hyperhive-* references in module
|
||||
# option defaults without depending on self's Rust / frontend builds.
|
||||
# nixosOptionsDoc renders `defaultText` for these options anyway; the
|
||||
# stubs just prevent attribute-missing eval errors.
|
||||
docsStubOverlay = _final: _prev: {
|
||||
hyperhive = pkgs.emptyFile;
|
||||
hyperhive-frontend = pkgs.emptyDirectory;
|
||||
hyperhive-assets = pkgs.emptyDirectory;
|
||||
hyperhive-docs = pkgs.emptyDirectory;
|
||||
};
|
||||
|
||||
# Stub host system: every hyperhive subsystem `mkForce false` so
|
||||
# heavy build inputs (matrix container, forge, etc.) stay out of
|
||||
# the eval — only option *declarations* matter for the doc walk.
|
||||
# Import hive-c0re.nix from the content-addressed nixSrc with stub
|
||||
# package args so the eval doesn't depend on self's Rust builds.
|
||||
hostEval = nixosSystem {
|
||||
system = pkgs.stdenv.hostPlatform.system;
|
||||
modules = [
|
||||
(import "${nixSrc}/modules/hive-c0re.nix" {
|
||||
hyperhivePackage = _system: pkgs.emptyFile;
|
||||
hyperhiveFrontend = _system: pkgs.emptyFile;
|
||||
hyperhiveAssets = _system: pkgs.emptyDirectory;
|
||||
hyperhiveFlake = "";
|
||||
hyperhiveDocs = "";
|
||||
agentBaseToplevel = pkgs.emptyFile;
|
||||
managerToplevel = pkgs.emptyFile;
|
||||
})
|
||||
self.nixosModules.default
|
||||
(
|
||||
{ lib, ... }:
|
||||
{
|
||||
nixpkgs.overlays = [ docsStubOverlay ];
|
||||
nixpkgs.overlays = [ self.overlays.default ];
|
||||
fileSystems."/" = {
|
||||
device = "/dev/null";
|
||||
fsType = "tmpfs";
|
||||
|
|
@ -68,23 +35,14 @@ let
|
|||
];
|
||||
};
|
||||
|
||||
# Agent module eval from the content-addressed nixSrc. Relative
|
||||
# imports inside agent-base.nix (e.g. ./harness-base.nix) resolve
|
||||
# correctly against the nixSrc directory tree.
|
||||
agentEval = nixosSystem {
|
||||
system = pkgs.stdenv.hostPlatform.system;
|
||||
modules = [
|
||||
"${nixSrc}/templates/agent-base.nix"
|
||||
{ nixpkgs.overlays = [ docsStubOverlay ]; }
|
||||
];
|
||||
};
|
||||
# Reuse the already-evaluated agent-base config — its options tree is
|
||||
# identical to what a real agent container sees, no second eval needed.
|
||||
agentEval = self.nixosConfigurations.agent-base;
|
||||
|
||||
# Rewrite option declaration paths from nix-store absolute paths to
|
||||
# forge URLs so rendered docs link back to source.
|
||||
# nixSrc is a content-addressed copy of nix/; strip its store prefix
|
||||
# and prepend nix/ to recover the repo-relative path.
|
||||
forgeRoot = "https://forge.darkest.space/hyperhive/hyperhive/src/branch/main";
|
||||
nixSrcPrefix = builtins.unsafeDiscardStringContext (toString nixSrc + "/");
|
||||
storePrefix = toString self + "/";
|
||||
transformOptions =
|
||||
opt:
|
||||
opt
|
||||
|
|
@ -94,8 +52,8 @@ let
|
|||
let
|
||||
declStr = toString decl;
|
||||
relPath =
|
||||
if lib.hasPrefix nixSrcPrefix declStr then
|
||||
"nix/" + lib.removePrefix nixSrcPrefix declStr
|
||||
if lib.hasPrefix storePrefix declStr then
|
||||
lib.removePrefix storePrefix declStr
|
||||
else
|
||||
baseNameOf declStr;
|
||||
in
|
||||
|
|
@ -178,7 +136,7 @@ let
|
|||
`hyperhive.forge.*`, `hyperhive.matrix.*`, `hyperhive.gui.*`).
|
||||
|
||||
Regenerate with `nix build .#docs` (bundle), `.#docs-host`, or
|
||||
`.#docs-agent`. Consumed by the website repo to render `/options/`.
|
||||
`.#docs-agent`.
|
||||
'';
|
||||
|
||||
hostMD = mkMarkdownPage "docs-host" "hyperhive — host options" hostDoc;
|
||||
|
|
|
|||
|
|
@ -795,9 +795,10 @@ in
|
|||
managerToplevel
|
||||
];
|
||||
|
||||
# Unprivileged coordinator user. hive-c0re runs as this user;
|
||||
# privileged operations are delegated to hive-priv which runs as
|
||||
# root, socket-activated at /run/hive/priv.sock.
|
||||
# Unprivileged coordinator user. hive-c0re runs as this user
|
||||
# (privsep phase 2); privileged operations are delegated to
|
||||
# hive-priv which runs as root, socket-activated at
|
||||
# /run/hive/priv.sock.
|
||||
users.users.hive-core = {
|
||||
isSystemUser = true;
|
||||
group = "hive-core";
|
||||
|
|
|
|||
|
|
@ -423,7 +423,7 @@ in
|
|||
#
|
||||
# /run/hive-agent — per-agent UDS socket dir, written by c0re's
|
||||
# set_nspawn_flags when agents start. Owned by `hive-core` (the
|
||||
# unprivileged coordinator user): c0re does the
|
||||
# unprivileged coordinator user, privsep phase 2): c0re does the
|
||||
# `create_dir_all(/run/hive-agent/<name>)` itself, so a root-owned
|
||||
# parent would EACCES on the very first agent create on a fresh host
|
||||
# (hive-priv only chowns the subdir afterwards, it doesn't make it).
|
||||
|
|
@ -949,6 +949,7 @@ in
|
|||
# container. Listens on the bridge interface from
|
||||
# `services.hyperhive.network`; authoritative for the hive
|
||||
# domain + sub-domains, forwards everything else upstream.
|
||||
# No-op when `network.enable = false`.
|
||||
services.dnsmasq = lib.mkIf networkCfg.enable {
|
||||
enable = true;
|
||||
# Don't substitute the container's /etc/resolv.conf —
|
||||
|
|
|
|||
|
|
@ -8,8 +8,9 @@ let
|
|||
in
|
||||
{
|
||||
# Hive-internal network — host-side bridge + per-agent DNS resolver.
|
||||
# Always active when hyperhive is enabled: agent containers run in
|
||||
# private netns behind the bridge. Full design: docs/network.md.
|
||||
# Containers stay on shared host netns at v1; this module stands the
|
||||
# bridge + resolver up so the endpoint is in place before network
|
||||
# isolation flips containers to private netns. Full design: docs/network.md.
|
||||
|
||||
options.services.hyperhive.network = {
|
||||
enable = lib.mkOption {
|
||||
|
|
|
|||
|
|
@ -219,11 +219,9 @@ in
|
|||
|
||||
options.hyperhive.effortLevel = lib.mkOption {
|
||||
type = lib.types.enum [
|
||||
"low"
|
||||
"medium"
|
||||
"high"
|
||||
"xhigh"
|
||||
"max"
|
||||
];
|
||||
default = "medium";
|
||||
example = "high";
|
||||
|
|
@ -233,12 +231,12 @@ in
|
|||
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.
|
||||
`"medium"` (the default) keeps token spend low and works well with
|
||||
the harness across families. `"high"` matches the current platform
|
||||
default; `"xhigh"` is recommended for coding / high-autonomy work on
|
||||
capable models (Opus 4.8+) at higher token 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.
|
||||
'';
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
#!/bin/sh
|
||||
# Git pre-push hook: runs the tracker-tag lint and the comment-block
|
||||
# lint against the working tree before any push lands on the remote.
|
||||
# Catches issues that would fail CI and require a follow-up commit
|
||||
# (common failure mode: a nix comment containing a hash-issue-number
|
||||
# tag added mid-session).
|
||||
#
|
||||
# Install (once per clone):
|
||||
# ln -sf ../../scripts/pre-push .git/hooks/pre-push
|
||||
#
|
||||
# The hook runs against the full working tree (not just staged or
|
||||
# pushed files) to match what CI sees: `nix flake check` builds from
|
||||
# the committed tree, so an untracked hit on a staged file would still
|
||||
# trip CI.
|
||||
set -eu
|
||||
|
||||
repo_root="$(git rev-parse --show-toplevel)"
|
||||
|
||||
echo "pre-push: running tracker-tag lint..." >&2
|
||||
if ! sh "$repo_root/scripts/check-issue-refs.sh"; then
|
||||
echo "pre-push: tracker-tag lint FAILED — fix before pushing" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pre-push: running comment-block lint..." >&2
|
||||
if ! sh "$repo_root/scripts/check-comment-blocks.sh"; then
|
||||
echo "pre-push: comment-block lint FAILED — fix before pushing" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pre-push: lints passed ✓" >&2
|
||||
exit 0
|
||||
Loading…
Reference in a new issue