Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4017a57350 | ||
|
|
e9df5def02 |
12 changed files with 239 additions and 16 deletions
|
|
@ -110,6 +110,19 @@ at_unix_timestamp?)`, `request_next_turn()`.
|
|||
`homeserver`); omitted for agents with no matrix provisioning. Omit
|
||||
`name` to query self.
|
||||
|
||||
**Always-on, no tool group** (like `set_status`): `compact()`.
|
||||
|
||||
- `compact` — agent self-service equivalent of the operator dashboard's
|
||||
`/compact` button. No args. Gated server-side on the last completed
|
||||
turn's context usage: refused (with an explanation, no side effect)
|
||||
unless usage is above 66% of the effective context window. On a
|
||||
pass, queues the same deferred `compact_pending` flag the dashboard
|
||||
button sets — consumed at the end of the current turn, so it never
|
||||
races a live claude process, and the usual pre-compaction
|
||||
notes-checkpoint turn still fires first. Dispatched through the
|
||||
in-agent socket (`hive-agent-sock::Request::Compact`), not the
|
||||
broker — see `hive-agent/src/todo_server.rs`.
|
||||
|
||||
## Privileged tools (by tool group)
|
||||
|
||||
- **Bash execution** (`execution`) — background shell tasks. See
|
||||
|
|
|
|||
|
|
@ -587,6 +587,29 @@ impl AgentServer {
|
|||
.await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Compact the current session's context, mirroring the operator's \
|
||||
dashboard `/compact` button. Gated: only honoured when this agent's last \
|
||||
completed turn used more than 66% of the effective context window — below \
|
||||
that the call is refused with an explanation and has no effect. On a pass, \
|
||||
queues compaction for the end of the current turn (same deferred mechanism \
|
||||
the dashboard button uses, so it never races a live claude process); the \
|
||||
usual pre-compaction notes-checkpoint turn still fires first. No args."
|
||||
)]
|
||||
async fn compact(&self) -> String {
|
||||
run_tool_envelope("compact", String::new(), async move {
|
||||
match dial_agent_socket(&hive_agent_sock::Request::Compact).await {
|
||||
Some(hive_agent_sock::Response::Ok) => {
|
||||
"compact queued — will run at the end of the current turn".to_owned()
|
||||
}
|
||||
Some(hive_agent_sock::Response::Err { message }) => message,
|
||||
Some(other) => format!("compact: unexpected response: {other:?}"),
|
||||
None => "compact failed: in-agent socket unavailable".to_owned(),
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
// IMPORTANT: this tool is only available when the `lifecycle` tool group
|
||||
// is granted to this agent. hive-c0re enforces the topology check
|
||||
// server-side: the call is rejected unless `name` is a direct child.
|
||||
|
|
|
|||
|
|
@ -83,6 +83,14 @@ pub enum Request {
|
|||
/// reminder-activity chart data (was `hive-core-agent-sock`'s
|
||||
/// `ReminderRollup` against the broker; now served from the local store).
|
||||
ReminderRollup { since_secs: u64 },
|
||||
/// Agent self-service request to compact the current session, mirroring
|
||||
/// the operator's `/compact` dashboard button. Gated server-side: only
|
||||
/// honoured when the last completed turn's context usage is above 66%
|
||||
/// of the effective context window — below that, `Response::Err`
|
||||
/// explains why and takes no action. On a pass, queues the same
|
||||
/// deferred `compact_pending` flag the operator's button sets (consumed
|
||||
/// at the next turn boundary), so it never races a live claude process.
|
||||
Compact,
|
||||
}
|
||||
|
||||
/// A response on the in-agent socket. Serialised with a `kind` tag,
|
||||
|
|
|
|||
|
|
@ -491,8 +491,9 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
|
|||
let store = Arc::new(store);
|
||||
let wake = todo_wake.clone();
|
||||
let reminders = reminder_store.clone();
|
||||
let bus_for_socket = bus.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = todo_server::run(store, wake, reminders).await {
|
||||
if let Err(e) = todo_server::run(store, wake, reminders, bus_for_socket).await {
|
||||
tracing::error!(error = %e, "in-agent todo socket exited with error");
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
//! In-agent socket server (loose-ends v2 + harness-local reminders). Binds
|
||||
//! the harness-owned `HIVE_AGENT_SOCKET` and serves the `hive-agent-sock`
|
||||
//! protocol to the in-container producers (matrix / bash daemons,
|
||||
//! forge-notify) and to `hive-agent-mcp`'s `remind`/`get_loose_ends`/
|
||||
//! `cancel_loose_end` tool impls. Todo ops hit the harness-local [`Todos`]
|
||||
//! store; a new-or-changed upsert fires an in-process [`Notify`] so the
|
||||
//! serve loop drives a turn. Reminder ops hit the harness-local
|
||||
//! [`Reminders`] store (`None` when the store failed to open — every
|
||||
//! reminder op then returns `Response::Err`); a reminder *firing* is a
|
||||
//! separate path (`reminder_timer`), not driven through this socket. No
|
||||
//! hive-c0re round-trip, no broker long-poll, no marker files.
|
||||
//! In-agent socket server (loose-ends v2 + harness-local reminders +
|
||||
//! self-service compact). Binds the harness-owned `HIVE_AGENT_SOCKET` and
|
||||
//! serves the `hive-agent-sock` protocol to the in-container producers
|
||||
//! (matrix / bash daemons, forge-notify) and to `hive-agent-mcp`'s
|
||||
//! `remind`/`get_loose_ends`/`cancel_loose_end`/`compact` tool impls. Todo
|
||||
//! ops hit the harness-local [`Todos`] store; a new-or-changed upsert fires
|
||||
//! an in-process [`Notify`] so the serve loop drives a turn. Reminder ops
|
||||
//! hit the harness-local [`Reminders`] store (`None` when the store failed
|
||||
//! to open — every reminder op then returns `Response::Err`); a reminder
|
||||
//! *firing* is a separate path (`reminder_timer`), not driven through this
|
||||
//! socket. `Request::Compact` is the odd one out — it doesn't touch either
|
||||
//! store, just the harness's [`Bus`] (gate-checked context usage, then the
|
||||
//! same deferred `compact_pending` flag the operator dashboard's
|
||||
//! `/compact` button sets). No hive-c0re round-trip, no broker long-poll,
|
||||
//! no marker files.
|
||||
//!
|
||||
//! One request/response line per connection, matching the producers'
|
||||
//! existing best-effort JSON-line clients (they just change which socket
|
||||
|
|
@ -24,9 +28,17 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
|||
use tokio::net::{UnixListener, UnixStream};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
use crate::events::Bus;
|
||||
use crate::reminders::{Reminder, Reminders};
|
||||
use crate::todos::{Todo, Todos};
|
||||
|
||||
/// Context-usage floor for `Request::Compact`: below this fraction of the
|
||||
/// effective context window, an agent-initiated compact is refused (nothing
|
||||
/// meaningful to reclaim yet, and it'd just burn a compaction pass on a
|
||||
/// near-empty session). Same number a human operator would eyeball off the
|
||||
/// dashboard's `ctx·Nk` badge before hitting the `/compact` button.
|
||||
const COMPACT_MIN_USAGE_FRACTION: f64 = 0.66;
|
||||
|
||||
/// Resolve the in-agent socket path from `HIVE_AGENT_SOCKET`. `None` when
|
||||
/// unset/empty (e.g. a standalone dev run) — the server then stays off.
|
||||
fn socket_path() -> Option<PathBuf> {
|
||||
|
|
@ -72,6 +84,7 @@ pub async fn run(
|
|||
store: Arc<Todos>,
|
||||
wake: Arc<Notify>,
|
||||
reminders: Option<Arc<Reminders>>,
|
||||
bus: Bus,
|
||||
) -> Result<()> {
|
||||
let Some(path) = socket_path() else {
|
||||
tracing::info!("HIVE_AGENT_SOCKET unset — in-agent todo socket disabled");
|
||||
|
|
@ -85,8 +98,11 @@ pub async fn run(
|
|||
let store = store.clone();
|
||||
let wake = wake.clone();
|
||||
let reminders = reminders.clone();
|
||||
let bus = bus.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_conn(stream, &store, &wake, reminders.as_deref()).await {
|
||||
if let Err(e) =
|
||||
handle_conn(stream, &store, &wake, reminders.as_deref(), &bus).await
|
||||
{
|
||||
tracing::warn!(error = ?e, "in-agent todo connection failed");
|
||||
}
|
||||
});
|
||||
|
|
@ -117,6 +133,7 @@ async fn handle_conn(
|
|||
store: &Todos,
|
||||
wake: &Notify,
|
||||
reminders: Option<&Reminders>,
|
||||
bus: &Bus,
|
||||
) -> Result<()> {
|
||||
let (read, mut write) = stream.into_split();
|
||||
let mut reader = BufReader::new(read);
|
||||
|
|
@ -125,7 +142,7 @@ async fn handle_conn(
|
|||
return Ok(());
|
||||
}
|
||||
let resp = match serde_json::from_str::<Request>(line.trim()) {
|
||||
Ok(req) => dispatch(req, store, wake, reminders),
|
||||
Ok(req) => dispatch(req, store, wake, reminders, bus),
|
||||
Err(e) => Response::Err {
|
||||
message: format!("bad request: {e}"),
|
||||
},
|
||||
|
|
@ -140,7 +157,13 @@ async fn handle_conn(
|
|||
/// Apply one request to the store, firing `wake` on a new/changed upsert so
|
||||
/// the serve loop runs a turn. `reminders` is `None` when that store
|
||||
/// failed to open at boot — every reminder op then returns an `Err`.
|
||||
fn dispatch(req: Request, store: &Todos, wake: &Notify, reminders: Option<&Reminders>) -> Response {
|
||||
fn dispatch(
|
||||
req: Request,
|
||||
store: &Todos,
|
||||
wake: &Notify,
|
||||
reminders: Option<&Reminders>,
|
||||
bus: &Bus,
|
||||
) -> Response {
|
||||
match req {
|
||||
Request::UpsertTodo {
|
||||
subsystem,
|
||||
|
|
@ -233,9 +256,52 @@ fn dispatch(req: Request, store: &Todos, wake: &Notify, reminders: Option<&Remin
|
|||
}
|
||||
None => no_reminders_store(),
|
||||
},
|
||||
Request::Compact => compact(bus),
|
||||
}
|
||||
}
|
||||
|
||||
/// `Request::Compact` handler: gate on context usage, then queue the same
|
||||
/// deferred `compact_pending` flag the operator's `/compact` button sets.
|
||||
/// Mirrors `hive-agent::web_ui::actions::post_compact` but reachable from
|
||||
/// the agent's own MCP tool instead of the dashboard, and refuses below
|
||||
/// [`COMPACT_MIN_USAGE_FRACTION`] instead of always honouring the request —
|
||||
/// an agent can call this speculatively, a human clicking the dashboard
|
||||
/// button already made the judgment call.
|
||||
fn compact(bus: &Bus) -> Response {
|
||||
let Some(usage) = bus.last_ctx_usage() else {
|
||||
return Response::Err {
|
||||
message: "compact refused: no completed turn yet — nothing to compact".to_owned(),
|
||||
};
|
||||
};
|
||||
let model = bus.model();
|
||||
let window = bus.effective_context_window(&model);
|
||||
if window == 0 {
|
||||
return Response::Err {
|
||||
message: "compact refused: effective context window is unknown (0)".to_owned(),
|
||||
};
|
||||
}
|
||||
// Token counts stay well under 2^53 in practice, so the f64 conversion
|
||||
// is exact; this is a threshold ratio, not a byte-count display, but the
|
||||
// same "cosmetic precision loss" reasoning as `hivectl::quota::human_bytes` applies.
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let fraction = usage.context_tokens() as f64 / window as f64;
|
||||
if fraction < COMPACT_MIN_USAGE_FRACTION {
|
||||
return Response::Err {
|
||||
message: format!(
|
||||
"compact refused: context usage is {:.0}% of the {window}-token window, \
|
||||
below the {:.0}% floor — not worth compacting yet",
|
||||
fraction * 100.0,
|
||||
COMPACT_MIN_USAGE_FRACTION * 100.0
|
||||
),
|
||||
};
|
||||
}
|
||||
bus.request_compact();
|
||||
bus.emit(crate::events::LiveEvent::Note {
|
||||
text: "agent: self-requested /compact — running at the end of the current turn".into(),
|
||||
});
|
||||
Response::Ok
|
||||
}
|
||||
|
||||
/// Shared "reminders db unavailable" response for every reminder op when
|
||||
/// the store failed to open at boot (see `main.rs`'s best-effort open).
|
||||
fn no_reminders_store() -> Response {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
name = "hive-host-sock"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
readme = "README.md"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
|
|||
24
hive-host-sock/README.md
Normal file
24
hive-host-sock/README.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# hive-host-sock
|
||||
|
||||
Wire types for the **host admin socket** (`/run/hyperhive/host.sock`) — the
|
||||
host-control protocol spoken between the `hivectl` operator CLI and the
|
||||
`hive-c0re` daemon.
|
||||
|
||||
## Why it's its own crate
|
||||
|
||||
Re-homed out of `hive-sh4re` so a standalone `hivectl` depends on **just this
|
||||
protocol crate** instead of the whole daemon-shared crate. `hivectl` drives the
|
||||
full hive (spawn / kill / destroy / rebuild / deploy) over this socket without
|
||||
linking `hive-c0re`; keeping the request/response shapes here is what makes that
|
||||
thin dependency possible.
|
||||
|
||||
## Shape
|
||||
|
||||
Serde-derived request/response enums for the host admin protocol. The larger
|
||||
shared payload types some variants reference (`Approval`, `AgentStatusRow`,
|
||||
`jobs::DagView`) stay in `hive-sh4re` — this crate is only the protocol
|
||||
envelope, no server or client implementation.
|
||||
|
||||
See `docs/boundary.md` (host admin socket access) for the trust model around who
|
||||
may connect to the socket, and `hive-priv-sock` for the sibling split on the
|
||||
privileged-helper socket.
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
name = "hive-jobq"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
readme = "README.md"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
|
|||
58
hive-jobq/README.md
Normal file
58
hive-jobq/README.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# hive-jobq
|
||||
|
||||
A persistent job-DAG scheduler, extracted from hive-c0re's in-tree `job_queue`
|
||||
as a **domain-agnostic** library. It schedules a single persistent graph of
|
||||
nodes over named resources; it knows nothing about containers, rebuilds, or any
|
||||
hyperhive type — the node payload `N` and resource name `R` are both generic, so
|
||||
the caller supplies its own domain.
|
||||
|
||||
## When to use it
|
||||
|
||||
Reach for this crate whenever you need to run a DAG of interdependent work items
|
||||
under bounded, named concurrency — the hive-c0re rebuild/lifecycle queue is the
|
||||
first consumer, but nothing here is specific to it. The caller defines the node
|
||||
kinds, wires deps, and supplies a runner; the scheduler decides what can start.
|
||||
|
||||
## Model
|
||||
|
||||
One **persistent graph** for the whole system, not a DAG per job. Enqueuing
|
||||
inserts a self-contained sub-DAG and returns the new node ids; the scheduler
|
||||
runs a continuous loop, starting every node whose deps are satisfied:
|
||||
|
||||
- **Resource deps** are named counting semaphores over a caller-chosen type `R`
|
||||
— e.g. `build-slot` (capacity N), `agent/<name>` (capacity 1), or any
|
||||
unconfigured name (capacity 1, created on use). A node acquires *all* its
|
||||
resource deps atomically at start (all-or-nothing) — no hold-and-wait, so no
|
||||
deadlock.
|
||||
- **Node deps** wait on another node per `DepWhen`: `AfterOk` needs success (a
|
||||
failed dep cancels the dependent), `AfterAny` only needs terminal.
|
||||
|
||||
A node carries two independent axes: its `Dep`s (ordering + resource needs) and
|
||||
its `parent` (structural grouping). The **parent chain**, not the node edges, is
|
||||
what the scheduler consults for resource re-entrancy: a resource unit is held
|
||||
for the acquiring node *plus its whole parent subtree*, and a descendant needing
|
||||
a resource an ancestor already holds re-uses that grant (a re-entrant borrow,
|
||||
one branch at a time) rather than taking a fresh unit.
|
||||
|
||||
A `NodeId` is opaque, stable, and monotonic (safe to persist). The scheduler is
|
||||
single-threaded — it owns the resource table and mutates it directly.
|
||||
|
||||
## Shape
|
||||
|
||||
- **`Graph<N, R>`** — the persistent node store. `insert` mints ids and
|
||||
validates dep/parent references; `set_state` is the single state-transition
|
||||
choke point (and where each node's lifecycle timestamps —
|
||||
`started_at` / `finished_at`, `DateTime<Utc>` — are stamped).
|
||||
- **`Node<N, R>`** — `{ id, parent, payload, deps, state, started_at,
|
||||
finished_at, error }`. All fields public; derives serde for persistence + the
|
||||
wire.
|
||||
- **`Scheduler<N, R>`** — drives the graph: `settle()` starts every ready node
|
||||
(acquiring resources atomically), `complete(id, outcome)` reports a finished
|
||||
node's result and rolls terminality up the parent chain, releasing grants once
|
||||
a subtree is done. `Outcome::{Done, Failed(String)}` — the failure reason
|
||||
rides `Failed` onto the node's `error`.
|
||||
- **`ResourceTable<R>`** — per-name capacities; unconfigured names default to
|
||||
capacity 1.
|
||||
|
||||
See the crate-root and `scheduler` module `//!` docs for the full borrow/release
|
||||
model.
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
name = "hive-priv-sock"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
readme = "README.md"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
|
|||
21
hive-priv-sock/README.md
Normal file
21
hive-priv-sock/README.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# hive-priv-sock
|
||||
|
||||
Wire types for the **`hive-priv` privileged-helper socket**
|
||||
(`/run/hive/priv.sock`) — the contract between `hive-priv` (the root helper,
|
||||
server) and `hive-c0re` (client, via its `priv_client`).
|
||||
|
||||
## Why it's its own crate
|
||||
|
||||
Split out of `hive-sh4re` so `hive-priv` — a **root-privileged** binary —
|
||||
depends on just this narrow protocol crate instead of the much larger
|
||||
daemon-shared crate. Two wins: fewer dependencies in a root process's supply
|
||||
chain, and a small, self-contained interface makes the privilege boundary this
|
||||
crate encodes easier to audit. Mirrors `hive-host-sock`'s split for the host
|
||||
admin socket.
|
||||
|
||||
## Shape
|
||||
|
||||
Serde-derived request/response types only — no server or client logic. Both
|
||||
sides import them so the shapes stay in sync. See `docs/boundary.md` +
|
||||
`docs/security.md` for the privilege boundary these types sit on, and
|
||||
`hive-priv/README` for the helper itself.
|
||||
|
|
@ -640,7 +640,13 @@ impl ToolGroup {
|
|||
/// dark on the dashboard. The server-side `SetStatus` handler has no
|
||||
/// tool-group check either (only length validation), so listing it here
|
||||
/// keeps the `--allowedTools` list honest with that reality.
|
||||
pub const ALWAYS_ON_TOOLS: &'static [&'static str] = &["set_status"];
|
||||
///
|
||||
/// `compact` lives here too: it's pure self-management (no cross-agent
|
||||
/// effect, no privilege), gated server-side on context usage rather
|
||||
/// than on tool groups, and every agent should be able to reach for it
|
||||
/// regardless of which optional groups it's been granted — same
|
||||
/// reasoning as `set_status`.
|
||||
pub const ALWAYS_ON_TOOLS: &'static [&'static str] = &["set_status", "compact"];
|
||||
|
||||
/// The Claude built-in tool names enabled by this group. Only
|
||||
/// `WebTools` returns a non-empty slice; all other groups return `&[]`
|
||||
|
|
|
|||
Loading…
Reference in a new issue