feat: capture normalised bash command heads for the favorite-tools stat

This commit is contained in:
damocles 2026-06-06 00:18:55 +02:00 committed by mara
commit 1569d55f78
7 changed files with 252 additions and 17 deletions

View file

@ -11,6 +11,7 @@ anyhow.workspace = true
hive-sh4re.workspace = true
libc.workspace = true
rmcp.workspace = true
rusqlite.workspace = true
schemars.workspace = true
serde.workspace = true
serde_json.workspace = true

View file

@ -8,3 +8,4 @@ pub mod paths;
pub mod protocol;
pub mod runner;
pub mod socket;
pub mod stats;

View file

@ -20,12 +20,13 @@ pub fn daemon_socket() -> PathBuf {
.map_or_else(|| PathBuf::from(DEFAULT_DAEMON_SOCKET), PathBuf::from)
}
/// Base directory for task files. Uses `HYPERHIVE_HARNESS_DIR` if set
/// (injected by hive-c0re meta flake after the harness/state split);
/// falls back to a sibling of the state dir for pre-split deployments.
/// Base harness directory. Uses `HYPERHIVE_HARNESS_DIR` if set (injected
/// by the hive-c0re meta flake after the harness/state split); falls
/// back to a `harness/` sibling of the state dir for pre-split
/// deployments. Shared by every per-agent harness artifact path below.
#[must_use]
pub fn tasks_dir() -> PathBuf {
let base = if let Some(p) = std::env::var_os("HYPERHIVE_HARNESS_DIR") {
pub fn harness_dir() -> PathBuf {
if let Some(p) = std::env::var_os("HYPERHIVE_HARNESS_DIR") {
PathBuf::from(p)
} else {
// Pre-split fallback: derive harness/ as a sibling of state/.
@ -34,8 +35,21 @@ pub fn tasks_dir() -> PathBuf {
state_path
.parent()
.map_or_else(|| PathBuf::from(state), |p| p.join("harness"))
};
base.join("bash-tasks")
}
}
/// Base directory for task files.
#[must_use]
pub fn tasks_dir() -> PathBuf {
harness_dir().join("bash-tasks")
}
/// Per-agent turn-stats sqlite — the harness writes one row per claude
/// turn here; the runner appends normalised bash-command heads to its
/// `bash_commands` table for the /stats "favorite tools" view.
#[must_use]
pub fn turn_stats_db() -> PathBuf {
harness_dir().join("hyperhive-turn-stats.sqlite")
}
/// Hyperhive control socket — the daemon writes wake signals here so
@ -56,16 +70,7 @@ pub fn hyperhive_socket() -> PathBuf {
/// code across crates — keep them in sync if the fallback logic changes.
#[must_use]
pub fn mcp_loose_ends_dir() -> PathBuf {
let base = if let Some(p) = std::env::var_os("HYPERHIVE_HARNESS_DIR") {
PathBuf::from(p)
} else {
let state = std::env::var("HYPERHIVE_STATE_DIR").unwrap_or_default();
let state_path = PathBuf::from(&state);
state_path
.parent()
.map_or_else(|| PathBuf::from(state), |p| p.join("harness"))
};
base.join("mcp-loose-ends")
harness_dir().join("mcp-loose-ends")
}
/// Full path for a task's JSON metadata file.

View file

@ -303,6 +303,10 @@ async fn run_task(mut task: TaskFile, socket: &Path) {
tracing::warn!(id = %id, error = ?e, "bash_runner: write running state failed");
}
refresh_loose_ends();
// Best-effort: tally the normalised command head for the /stats
// "favorite tools" view. Counted once per execution, regardless of
// exit status. Never fails the task.
crate::stats::record_command(&task.cmd);
let out_path = paths::task_out(&id);
let err_path = paths::task_err(&id);

213
hive-bash-mcp/src/stats.rs Normal file
View file

@ -0,0 +1,213 @@
//! Capture of normalised shell-command "heads" per bash task into the
//! per-agent turn-stats sqlite (`bash_commands` table), backing the
//! "favorite tools" doughnut on the /stats page.
//!
//! Best-effort: a missing db, a lock, or a parse miss is logged and
//! swallowed — recording a stat never affects task execution. The
//! read/aggregation side lives host-side in `hive-c0re` and reads the
//! same `bash_commands(ts INTEGER, head TEXT)` rows.
//!
//! Normalisation is deliberately rough (quoting-naive) — it just needs
//! to name the tool an operator would recognise. The dominant agent
//! pattern is `cd <repo> && <tool> …`, so we look past leading
//! `cd …`-style builtins and prefix-runners like `sudo`/`env` to find
//! the first real command, then take its basename.
use std::time::{SystemTime, UNIX_EPOCH};
use rusqlite::{Connection, params};
use crate::paths;
/// Builtins whose presence as a segment head means the *real* command
/// is in a later segment (e.g. the `cargo` in `cd repo && cargo build`),
/// so we skip the whole segment.
const SEGMENT_BUILTINS: &[&str] = &[
"cd", "pushd", "popd", "export", "set", "unset", "source", ".", ":", "true", "false",
];
/// Prefix-runners that wrap the command following them in the SAME
/// segment (e.g. the `cargo` in `env FOO=bar cargo build`). We skip the
/// runner plus any following flags / env-assignments and unwrap to the
/// wrapped command.
const PREFIX_RUNNERS: &[&str] = &[
"env", "exec", "sudo", "doas", "nice", "ionice", "stdbuf", "time", "command", "nohup",
];
/// Does `tok` look like a `VAR=value` shell assignment?
fn is_assignment(tok: &str) -> bool {
match tok.split_once('=') {
Some((name, _)) if !name.is_empty() => name.chars().enumerate().all(|(i, c)| {
c == '_'
|| if i == 0 {
c.is_ascii_alphabetic()
} else {
c.is_ascii_alphanumeric()
}
}),
_ => false,
}
}
/// Strip a path prefix: `/nix/store/…/bin/git` -> `git`.
fn basename(tok: &str) -> &str {
tok.rsplit('/').next().unwrap_or(tok)
}
/// Resolve the head command of a single segment (no sequencing
/// operators inside). `None` for an empty or builtin-only segment.
fn segment_head(seg: &str) -> Option<String> {
let mut tokens = seg.split_whitespace().peekable();
// Skip leading env-assignments (`FOO=bar cmd`).
while tokens.peek().is_some_and(|t| is_assignment(t)) {
tokens.next();
}
let mut head = tokens.next()?;
// Unwrap prefix-runners: skip the runner, its option flags, and any
// further env-assignments, then take the wrapped command. Bounded
// loop guards against a pathological `sudo sudo sudo …`.
let mut guard = 0;
while PREFIX_RUNNERS.contains(&basename(head)) && guard < 8 {
guard += 1;
while tokens.peek().is_some_and(|t| t.starts_with('-')) {
tokens.next();
}
while tokens.peek().is_some_and(|t| is_assignment(t)) {
tokens.next();
}
match tokens.next() {
Some(next) => head = next,
None => return None, // bare `sudo` with nothing after it
}
}
let base = basename(head);
if base.is_empty() || SEGMENT_BUILTINS.contains(&base) {
return None;
}
Some(base.to_owned())
}
/// Extract a normalised tool name from a shell command line: the
/// basename of the first real command, looking past `cd repo &&`
/// prefixes, env-assignments, and prefix-runners like `sudo`/`env`.
/// `None` when no real command can be identified.
#[must_use]
pub fn command_head(cmd: &str) -> Option<String> {
// Split into segments on shell sequencing operators. Naive: doesn't
// honour quotes/escapes — acceptable for a best-effort tally. Order
// matters: `||` before `|` so the former isn't shredded by the latter.
let mut segments: Vec<&str> = vec![cmd];
for sep in ["&&", "||", ";", "|", "\n"] {
segments = segments.into_iter().flat_map(|s| s.split(sep)).collect();
}
segments.into_iter().find_map(segment_head)
}
/// Record one bash task's normalised command head. Best-effort: any
/// error (no db yet, locked, unparseable command) is logged at debug
/// and swallowed.
pub fn record_command(cmd: &str) {
let Some(head) = command_head(cmd) else {
return;
};
if let Err(e) = record_head(&head) {
tracing::debug!(error = ?e, head = %head, "bash stats: record failed");
}
}
fn record_head(head: &str) -> rusqlite::Result<()> {
let conn = Connection::open(paths::turn_stats_db())?;
// The harness writes turn rows to this same db (rollback-journal,
// not WAL) and sibling bash tasks may write concurrently — wait out
// a transient lock rather than dropping the stat on SQLITE_BUSY.
conn.busy_timeout(std::time::Duration::from_secs(2))?;
conn.execute(
"CREATE TABLE IF NOT EXISTS bash_commands (ts INTEGER NOT NULL, head TEXT NOT NULL)",
[],
)?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
.cast_signed();
conn.execute(
"INSERT INTO bash_commands (ts, head) VALUES (?1, ?2)",
params![now, head],
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn skips_leading_cd_to_the_real_tool() {
assert_eq!(
command_head("cd /repo && cargo build").as_deref(),
Some("cargo")
);
assert_eq!(
command_head("cd /a/b && export X=1; hive-forge view 1").as_deref(),
Some("hive-forge")
);
}
#[test]
fn strips_store_path_to_basename() {
assert_eq!(
command_head("/nix/store/abc123/bin/git status").as_deref(),
Some("git")
);
assert_eq!(
command_head("VAR=1 ./script.sh").as_deref(),
Some("script.sh")
);
}
#[test]
fn skips_leading_env_assignments() {
assert_eq!(
command_head("FOO=bar BAZ=qux make").as_deref(),
Some("make")
);
}
#[test]
fn unwraps_prefix_runners() {
assert_eq!(
command_head("sudo systemctl restart nginx").as_deref(),
Some("systemctl")
);
assert_eq!(
command_head("env RUST_LOG=debug cargo test").as_deref(),
Some("cargo")
);
assert_eq!(command_head("time nix build").as_deref(), Some("nix"));
}
#[test]
fn first_command_of_a_pipeline() {
assert_eq!(
command_head("git log --oneline | head -5").as_deref(),
Some("git")
);
}
#[test]
fn none_for_empty_or_builtin_only() {
assert_eq!(command_head(""), None);
assert_eq!(command_head(" "), None);
assert_eq!(command_head("cd /only/dir"), None);
assert_eq!(command_head("sudo"), None);
}
#[test]
fn assignment_detector() {
assert!(is_assignment("FOO=bar"));
assert!(is_assignment("RUST_LOG=debug"));
assert!(!is_assignment("--flag"));
assert!(!is_assignment("=leading"));
assert!(!is_assignment("git"));
}
}