hatchery-node 0.2.78

Hatchery node: the complete node kit over the gate4agent transport (PTY/inline sessions, file browser, local git, worktrees, mailbox, browser station, session restore)
Documentation
//! ACP tool calls as extra evidence for a task check. The node remembers the last finished
//! tool calls of each task worktree (`task-<id>`) session: the command, whether the provider
//! said it failed, how long it took (the node's own clock: ACP sends no duration) and why it
//! did not run, if it did not. When the node sends its own `task.check_run`, it attaches the
//! last call that ran the same command (see [`take`]). The proof stays the node's run: a tool
//! call is only what the agent saw and never passes a check by itself.
//!
//! ACP turns every `tool_call_update` (also `in_progress`) into a completion, so the last
//! update of a call wins. Command text is kept only for matching; it is never logged.

use gate4agent_types::ProviderEvent;
use serde_json::Value;
use std::collections::{HashMap, VecDeque};
use std::sync::Mutex;
use std::time::{Instant, SystemTime, UNIX_EPOCH};

/// Finished calls kept per worktree.
const KEEP: usize = 32;
/// Calls started but not finished yet, per worktree (a provider may never finish some).
const OPEN_MAX: usize = 64;
const COMMAND_MAX: usize = 500;

/// A finished tool call of a session in a task worktree.
#[derive(Clone, Debug, PartialEq)]
pub struct ToolCall {
    pub instance_id: u64,
    pub tool_call_id: String,
    pub command: String,
    pub is_error: bool,
    pub duration_ms: Option<u64>,
    /// ACP `nonExecutionKind`: the call never ran (`user-rejected`, `cancelled`, ...).
    pub not_run: Option<String>,
    pub finished_at_ms: u64,
}

impl ToolCall {
    /// The `tool_call` object of a `task.check_run` payload.
    pub fn to_json(&self) -> Value {
        serde_json::json!({ "instance_id": self.instance_id, "tool_call_id": self.tool_call_id, "command": self.command, "is_error": self.is_error,
            "duration_ms": self.duration_ms, "not_run": self.not_run, "finished_at_ms": self.finished_at_ms })
    }
}

#[derive(Default)]
struct Workspace {
    open: HashMap<(u64, String), (String, Instant)>,
    done: VecDeque<ToolCall>,
}

static CALLS: Mutex<Option<HashMap<String, Workspace>>> = Mutex::new(None);

/// The command a tool call runs: `rawInput.command` as one shell line or `command` + `args`
/// (or an argv array), else a Bash-style title in backticks. `None`: not a command.
pub fn command_of(name: &str, input_json: &str) -> Option<String> {
    let input: Value = serde_json::from_str(input_json).unwrap_or(Value::Null);
    let words = |v: &Value| v.as_array().and_then(|a| a.iter().map(|x| x.as_str().map(str::to_owned)).collect::<Option<Vec<_>>>());
    let line = match &input["command"] {
        Value::String(c) => match words(&input["args"]) {
            Some(args) if !args.is_empty() => Some(format!("{c} {}", args.join(" "))),
            _ => Some(c.clone()),
        },
        v @ Value::Array(_) => words(v).map(|w| match w.as_slice() {
            // `["bash", "-lc", "<line>"]`: the line is the command.
            [sh, flag, line] if flag.starts_with('-') && flag.ends_with('c') && !sh.is_empty() => line.clone(),
            _ => w.join(" "),
        }),
        _ => None,
    }
    .or_else(|| name.trim().strip_prefix('`').and_then(|n| n.strip_suffix('`')).map(str::to_owned))?;
    let line = normalize(&line);
    (!line.is_empty() && line.len() <= COMMAND_MAX * 4).then_some(line)
}

fn normalize(s: &str) -> String {
    s.split_whitespace().collect::<Vec<_>>().join(" ")
}

/// The call ran `check`: the same line, or the line after a `cd ... &&` / `;` prefix.
pub fn runs(command: &str, check: &str) -> bool {
    let check = normalize(check);
    !check.is_empty() && (command == check || command.ends_with(&format!("&& {check}")) || command.ends_with(&format!("; {check}")))
}

fn now_ms() -> u64 {
    SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64)
}

/// Feed one provider event of an ACP session (only task worktrees are kept).
pub fn observe(workspace: &str, instance: u64, event: &ProviderEvent) {
    if !workspace.starts_with("task-") {
        return;
    }
    let mut guard = CALLS.lock().unwrap_or_else(|p| p.into_inner());
    let ws = guard.get_or_insert_with(HashMap::new).entry(workspace.to_owned()).or_default();
    match event {
        ProviderEvent::ToolStarted { id, name, input_json, .. } => {
            let Some(command) = command_of(name, input_json) else { return };
            if ws.open.len() >= OPEN_MAX {
                if let Some(oldest) = ws.open.iter().min_by_key(|(_, (_, at))| *at).map(|(k, _)| k.clone()) {
                    ws.open.remove(&oldest);
                }
            }
            ws.open.insert((instance, id.clone()), (command, Instant::now()));
        }
        ProviderEvent::ToolCompleted { id, is_error, duration_ms, non_execution_kind, .. } => {
            let key = (instance, id.clone());
            let Some((command, started)) = ws.open.get(&key).cloned() else { return };
            let call = ToolCall {
                instance_id: instance,
                tool_call_id: id.chars().take(128).collect(),
                command: command.chars().take(COMMAND_MAX).collect(),
                is_error: *is_error,
                duration_ms: duration_ms.or_else(|| Some(started.elapsed().as_millis() as u64)),
                not_run: non_execution_kind.as_ref().map(|k| k.chars().take(32).collect()),
                finished_at_ms: now_ms(),
            };
            // A later update of the same call replaces the earlier one.
            ws.done.retain(|c| !(c.instance_id == instance && c.tool_call_id == call.tool_call_id));
            ws.done.push_back(call);
            while ws.done.len() > KEEP {
                ws.done.pop_front();
            }
        }
        _ => {}
    }
}

/// The last finished call in `workspace` that ran `check`, removed so it backs one check run.
pub fn take(workspace: &str, check: &str) -> Option<ToolCall> {
    let mut guard = CALLS.lock().unwrap_or_else(|p| p.into_inner());
    let ws = guard.as_mut()?.get_mut(workspace)?;
    let at = ws.done.iter().rposition(|c| runs(&c.command, check))?;
    ws.done.remove(at)
}

/// A session ended: its unfinished calls go; finished ones stay for the next check.
pub fn forget_open(workspace: &str, instance: u64) {
    let mut guard = CALLS.lock().unwrap_or_else(|p| p.into_inner());
    if let Some(ws) = guard.as_mut().and_then(|m| m.get_mut(workspace)) {
        ws.open.retain(|(i, _), _| *i != instance);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn started(id: &str, name: &str, input: &str) -> ProviderEvent {
        ProviderEvent::ToolStarted { id: id.into(), name: name.into(), input_json: input.into(), agent_id: None }
    }

    fn finished(id: &str, is_error: bool, not_run: Option<&str>) -> ProviderEvent {
        ProviderEvent::ToolCompleted { id: id.into(), output: String::new(), is_error, duration_ms: None, agent_id: None, non_execution_kind: not_run.map(str::to_owned) }
    }

    #[test]
    fn reads_the_command_shapes_acp_agents_send() {
        assert_eq!(command_of("Bash", r#"{"command":"cargo  test -q","description":"x"}"#).as_deref(), Some("cargo test -q"));
        assert_eq!(command_of("t", r#"{"command":"cargo","args":["test","-q"]}"#).as_deref(), Some("cargo test -q"));
        assert_eq!(command_of("t", r#"{"command":["bash","-lc","cargo test -q"]}"#).as_deref(), Some("cargo test -q"));
        assert_eq!(command_of("`cargo test -q`", "{}").as_deref(), Some("cargo test -q"));
        assert_eq!(command_of("Read", r#"{"file_path":"/x"}"#), None);
    }

    #[test]
    fn matches_the_check_command_only() {
        assert!(runs("cargo test -q", "cargo  test -q"));
        assert!(runs("cd /w/task-7 && cargo test -q", "cargo test -q"));
        assert!(!runs("cargo test -q --no-run", "cargo test -q"));
        assert!(!runs("echo cargo test -q", "cargo test -q"));
        assert!(!runs("", ""));
    }

    #[test]
    fn keeps_the_last_finished_call_of_the_check_in_a_task_worktree() {
        let ws = "task-9001";
        observe(ws, 1, &started("a", "Bash", r#"{"command":"cargo test"}"#));
        observe(ws, 1, &started("b", "Bash", r#"{"command":"ls"}"#));
        observe(ws, 1, &finished("a", false, None));
        observe(ws, 1, &finished("a", true, None)); // the final update wins
        observe(ws, 1, &finished("b", false, None));
        observe(ws, 1, &finished("zzz", false, None)); // never started: ignored
        let call = take(ws, "cargo test").expect("the check call");
        assert_eq!((call.tool_call_id.as_str(), call.is_error, call.instance_id), ("a", true, 1));
        assert!(call.duration_ms.is_some() && call.finished_at_ms > 0);
        assert_eq!(take(ws, "cargo test"), None, "one call backs one check run");
        observe(ws, 2, &started("c", "Bash", r#"{"command":"cargo test"}"#));
        forget_open(ws, 2);
        observe(ws, 2, &finished("c", false, Some("cancelled")));
        assert_eq!(take(ws, "cargo test"), None, "an ended session's open calls are gone");
        observe("primary", 1, &started("d", "Bash", r#"{"command":"cargo test"}"#));
        observe("primary", 1, &finished("d", false, None));
        assert_eq!(take("primary", "cargo test"), None, "only task worktrees");
    }

    #[test]
    fn a_call_that_never_ran_says_why() {
        let ws = "task-9002";
        observe(ws, 1, &started("x", "`make check`", "{}"));
        observe(ws, 1, &finished("x", true, Some("user-rejected")));
        let call = take(ws, "make check").unwrap();
        assert_eq!(call.not_run.as_deref(), Some("user-rejected"));
        assert_eq!(call.to_json()["not_run"], "user-rejected");
    }
}