From 2cbd7cdd8ea3178868d3cf8d90d72fd9b114ffbb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 03:35:14 +0000 Subject: [PATCH 01/30] Goals: keep an agent working until a check command passes The goal tool sets an agent, or a task it made, a goal: what done looks like and a shell command that exits 0 once it is. The lookout runs the check each time the agent's turn completes; while it fails, the agent gets a turn with its exit code and the end of its output, and its lead hears nothing. The goal ends once the check passes, the budget is spent, two turns it started call no tool, or the agent gives up with blocked, and the lead hears that last turn with how it ended. No judge, nothing in the daemon. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHCyGRHdT5WYDPKVFY --- docs/LOOKOUT.md | 3 + docs/SONNY.md | 33 ++ sonny/src/approvals.rs | 3 +- sonny/src/lookout/goals.rs | 619 +++++++++++++++++++++++++++++++++++ sonny/src/lookout/mod.rs | 22 +- sonny/src/lookout/updates.rs | 91 ++++- sonny/src/served.rs | 42 ++- tests/test_served.py | 51 +++ 8 files changed, 854 insertions(+), 10 deletions(-) create mode 100644 sonny/src/lookout/goals.rs diff --git a/docs/LOOKOUT.md b/docs/LOOKOUT.md index f9f0a6790..058d03ca6 100644 --- a/docs/LOOKOUT.md +++ b/docs/LOOKOUT.md @@ -156,6 +156,9 @@ subscriptions of their own: - a running turn that has printed nothing for 30 minutes, once per silence; a turn waiting on the person, on other turns or for a slot is not silent; +- a task's turns its goal (SONNY.md) held, once the goal ends: only the + last, with how it ended (``turn:calc.fix/4 completed, asked by goal, + and met its goal: `cargo test` exits 0``); - turns the person asked for in a task themselves, listed apart as theirs; - what was saved to or removed from the person's and the project's memory, by path, less what the subscriber saved itself: the folders its own diff --git a/docs/SONNY.md b/docs/SONNY.md index a3585f8a6..70c22ec7d 100644 --- a/docs/SONNY.md +++ b/docs/SONNY.md @@ -241,6 +241,39 @@ its folder rather than trusting a compacted conversation for ids and times, report a source it could not read by name, re-check items right before posting, and record a post only once the destination confirms it. +## Goals + +A goal keeps an agent working until a command says its work is done, with +no judge and nothing in the daemon ([goals.rs](../sonny/src/lookout/goals.rs)). +An agent sets one with the `goal` tool, on itself or on a task it made: +`set {done_when, check, bot, budget_tokens, timeout_s}`, `blocked` WHY to +give its own up, `clear` NAME, or nothing to list them. The check is a +shell command (`cargo test`, `make lint`, `test -z "$(git status --short)"`) +that exits 0 once done; the lookout runs it in the agent's folder, as the +person and outside any sandbox like a trigger's `if`, so `set` waits for a +verdict wherever the agent's other calls do (`/approvals`). Each goal is +`STORE.lookout/goals/BOT.json`; setting the same goal again changes +nothing. + +Each time a turn of that agent completes, the lookout runs the check, for +up to `timeout_s` (600 when left out, at most 3,600), killing its process +group past that. While it fails, it queues the agent a turn (origin +`goal`) with its exit code and the last 60 lines (8 KiB) it printed: + +``` +Your goal is not met yet: `cargo test` exits 101. +... +Keep working until the tests pass. When no way remains, give the goal up with the goal tool's blocked, saying why. +``` + +The goal ends, and its file goes, at the first of: the check exits 0; the +agent's tokens since `set` reach `budget_tokens`; two turns in a row that +the goal started call no tool, since the agent only answers and another +turn would too; the agent calls `blocked`; or someone clears it or the +agent is deleted. A turn that fails or is stopped ends nothing and starts +nothing: the lead hears it as any turn. Its lead hears none of the turns +the goal held until the last, told with how the goal ended (below). + ## Memory Memory is what earlier agents learned that a later one needs: decisions, diff --git a/sonny/src/approvals.rs b/sonny/src/approvals.rs index 19e3ae00d..f61f72cd3 100644 --- a/sonny/src/approvals.rs +++ b/sonny/src/approvals.rs @@ -333,7 +333,8 @@ mod tests { "new_project", "subscriptions", "run_in_pane", - "triggers" + "triggers", + "goal" ]) ); } diff --git a/sonny/src/lookout/goals.rs b/sonny/src/lookout/goals.rs new file mode 100644 index 000000000..2e74f84b5 --- /dev/null +++ b/sonny/src/lookout/goals.rs @@ -0,0 +1,619 @@ +//! Goals: an agent keeps working until a command says its work is done. +//! A goal is what done looks like, in words, and a check: a command that +//! exits 0 once it is. Each time a turn of the agent that has one +//! completes, the lookout runs the check in the agent's folder. While it +//! fails, the agent gets its output and works again, and its lead hears +//! nothing of those turns; once it passes, or the agent gives up, or the +//! goal's budget is spent, or two turns in a row the goal started call no +//! tool, the goal ends and the lead hears that turn with how it ended. +//! No model judges: a command says when the work is done, as it does in +//! CI. +//! +//! Each agent's goal is one file, `STORE.lookout/goals/BOT.json`, which +//! its `goal` tool writes (a lead's for its tasks too) and the lookout +//! reads again when a call changed it, and removes when the goal ends. A +//! check runs as the person, outside every sandbox, as a trigger's `if` +//! does: setting one waits for a verdict where the agent's other calls do. +use agent_client::Client; +use serde_json::{Value, json}; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +/// How long a check may run unless the goal says, and at most. +pub const TIMEOUT_S: u64 = 10 * 60; +pub const TIMEOUT_MAX_S: u64 = 60 * 60; +/// How much of a failing check's output the agent is shown: its end. +const OUTPUT_LINES: usize = 60; +const OUTPUT_BYTES: usize = 8 * 1024; +/// Turns in a row the goal started that called no tool, after which it +/// stops: the agent only answers, and another turn would too. +const IDLE_TURNS: u32 = 2; +/// What a turn the goal started carries as its request ID, before the +/// agent and turn it follows: not the lookout's own note, since the +/// turn is the agent's work and its end is news. +const REQUEST: &str = "goal-"; + +#[derive(Clone, Debug, PartialEq)] +pub struct Goal { + pub bot: String, + pub bot_id: i64, + pub done_when: String, + pub check: String, + /// The agent's folder, where the check runs. + pub workspace: PathBuf, + pub set_by: String, + /// When it was set: a goal set again since is another goal. + pub set_ms: u64, + /// Tokens the agent may spend on it, counted from `tokens_at_set`. + pub budget_tokens: Option, + pub tokens_at_set: u64, + pub timeout_s: u64, + /// Why the agent gave up, told once its turn ends. + pub blocked: Option, +} + +impl Goal { + fn to_json(&self) -> Value { + json!({"bot": self.bot, "bot_id": self.bot_id, "done_when": self.done_when, "check": self.check, + "workspace": self.workspace, "set_by": self.set_by, "set_ms": self.set_ms, + "budget_tokens": self.budget_tokens, "tokens_at_set": self.tokens_at_set, + "timeout_s": self.timeout_s, "blocked": self.blocked}) + } + + fn from_json(value: &Value) -> Option { + let text = |key: &str| value[key].as_str().map(str::to_owned); + Some(Goal { + bot: text("bot")?, + bot_id: value["bot_id"].as_i64()?, + done_when: text("done_when")?, + check: text("check")?, + workspace: PathBuf::from(text("workspace")?), + set_by: text("set_by")?, + set_ms: value["set_ms"].as_u64()?, + budget_tokens: value["budget_tokens"].as_u64(), + tokens_at_set: value["tokens_at_set"].as_u64().unwrap_or(0), + timeout_s: value["timeout_s"].as_u64().unwrap_or(TIMEOUT_S), + blocked: text("blocked"), + }) + } + + /// What the agent and its lead are shown of it. + fn shown(&self) -> Value { + json!({"bot": self.bot, "done_when": self.done_when, "check": self.check, + "budget_tokens": self.budget_tokens, "timeout_s": self.timeout_s, "set_by": self.set_by, + "blocked": self.blocked}) + } +} + +pub fn dir(store: &Path) -> PathBuf { + super::subs::dir(store).join("goals") +} + +fn file(store: &Path, bot: &str) -> PathBuf { + dir(store).join(format!("{bot}.json")) +} + +pub fn read(store: &Path, bot: &str) -> Option { + let text = std::fs::read_to_string(file(store, bot)).ok()?; + Goal::from_json(&serde_json::from_str(&text).ok()?) +} + +pub fn all(store: &Path) -> HashMap { + let Ok(entries) = std::fs::read_dir(dir(store)) else { + return HashMap::new(); + }; + entries + .filter_map(|entry| { + let name = entry.ok()?.file_name().to_string_lossy().into_owned(); + let bot = name.strip_suffix(".json")?; + Some((bot.to_owned(), read(store, bot)?)) + }) + .collect() +} + +/// Written whole and moved in, so the lookout never reads half of one. +fn write(store: &Path, goal: &Goal) -> Result<(), String> { + let dir = dir(store); + std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; + let path = file(store, &goal.bot); + let partial = dir.join(format!(".{}.{}.partial", goal.bot, std::process::id())); + std::fs::write(&partial, goal.to_json().to_string()) + .and_then(|()| std::fs::rename(&partial, &path)) + .map_err(|e| { + let _ = std::fs::remove_file(&partial); + format!("{}: {e}", path.display()) + }) +} + +/// The goal ends: its file goes, unless it was set again since. +fn end(store: &Path, goal: &Goal) { + if read(store, &goal.bot).is_some_and(|now| now.set_ms == goal.set_ms) { + let _ = std::fs::remove_file(file(store, &goal.bot)); + } +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_millis() as u64) +} + +/// The `goal` tool, as `sonny tool goal` runs it for `caller`: set one on +/// itself or on a task it made, give its own up, clear one, or list the +/// goals it and its tasks have. +pub async fn call( + store: &Path, + client: &Client, + caller: &str, + arguments: &Value, +) -> Result { + let target = |bot: &Value| bot.as_str().unwrap_or(caller).to_owned(); + // A goal goes on the caller or on an agent it made, whose record says + // so: the record also gives its folder, id and spend. + let record = async |bot: &str| -> Result { + let record = (client.request("inspect", json!({"bot": bot})).await) + .map_err(|e| format!("bot_not_found: {bot}: {e}"))?; + if bot != caller && record["created_by"].as_str() != Some(caller) { + return Err(format!( + "not_your_task: {bot} was not made by {caller}; a goal goes on you or on a task you made" + )); + } + Ok(record) + }; + if let Some(set) = arguments.get("set") { + let bot = target(&set["bot"]); + let record = record(&bot).await?; + let text = |key: &str| set[key].as_str().map(str::trim).unwrap_or_default(); + let (done_when, check) = (text("done_when"), text("check")); + if done_when.is_empty() || check.is_empty() { + return Err("invalid_goal: done_when and check are both needed".into()); + } + let timeout_s = set["timeout_s"].as_u64().unwrap_or(TIMEOUT_S); + if !(1..=TIMEOUT_MAX_S).contains(&timeout_s) { + return Err(format!("invalid_goal: timeout_s is 1 to {TIMEOUT_MAX_S}")); + } + let budget_tokens = set["budget_tokens"].as_u64().filter(|b| *b > 0); + let workspace = record["workspace"].as_str().unwrap_or("/"); + let goal = Goal { + bot: bot.clone(), + bot_id: record["bot_id"].as_i64().unwrap_or_default(), + done_when: done_when.to_owned(), + check: check.to_owned(), + workspace: PathBuf::from(workspace), + set_by: caller.to_owned(), + set_ms: now_ms(), + budget_tokens, + tokens_at_set: record["tokens_used"].as_u64().unwrap_or(0), + timeout_s, + blocked: None, + }; + // The same goal again changes nothing, its budget still counted + // from when it was first set. + if let Some(had) = read(store, &bot).filter(|had| { + ( + had.bot_id, + &had.done_when, + &had.check, + had.budget_tokens, + had.timeout_s, + ) == ( + goal.bot_id, + &goal.done_when, + &goal.check, + goal.budget_tokens, + goal.timeout_s, + ) && had.blocked.is_none() + }) { + return Ok(json!({"goal": had.shown(), "duplicate": true})); + } + write(store, &goal)?; + return Ok(json!({"goal": goal.shown(), "duplicate": false})); + } + if let Some(why) = arguments.get("blocked") { + let why = why.as_str().map(str::trim).unwrap_or_default(); + if why.is_empty() { + return Err("invalid_goal: blocked says why".into()); + } + let Some(mut goal) = read(store, caller) else { + return Err("no_goal: you have no goal to give up".into()); + }; + goal.blocked = Some(why.to_owned()); + write(store, &goal)?; + return Ok(json!({"goal": goal.shown(), "ends": "when this turn ends"})); + } + if let Some(bot) = arguments.get("clear") { + let bot = target(bot); + record(&bot).await?; + let had = read(store, &bot); + if let Some(goal) = &had { + end(store, goal); + } + return Ok(json!({"bot": bot, "cleared": had.is_some()})); + } + let mine: Vec = (all(store).into_values()) + .filter(|goal| goal.bot == caller || goal.set_by == caller) + .map(|goal| goal.shown()) + .collect(); + Ok(json!({"goals": mine})) +} + +/// The `goal` tool's declaration. +pub fn tool(name: &str, tag: &str) -> Value { + json!({ + "name": name, + "description": "A goal keeps an agent working until a command says its work is done. Set one on \ + yourself, or on a task you made, with what done looks like and a check: a shell command, run in \ + that agent's folder, that exits 0 once it is (tests passing, a build, a file count). Each time \ + its turn completes, the check runs; while it fails, the agent is shown its output and works \ + again, and its lead hears nothing of those turns. The goal ends once the check passes, the \ + budget is spent, two turns it started call no tool, or the agent gives up with blocked and why, \ + and its lead hears that turn with how it ended. Setting the same goal again changes nothing; \ + with nothing given, the result lists your goals and your tasks'.", + "parameters": { + "type": "object", + "properties": { + "set": { + "type": "object", + "properties": { + "done_when": {"type": "string", "minLength": 1, + "description": "What done looks like, in words."}, + "check": {"type": "string", "minLength": 1, + "description": "The command, as sh -c runs it, that exits 0 once it is."}, + "bot": {"type": "string", "minLength": 1, + "description": "A task you made; you when left out."}, + "budget_tokens": {"type": "integer", "minimum": 1, + "description": "Tokens the agent may spend on it before it stops."}, + "timeout_s": {"type": "integer", "minimum": 1, "maximum": TIMEOUT_MAX_S, + "description": "How long the check may run; 600 when left out."} + }, + "required": ["done_when", "check"], + "additionalProperties": false + }, + "blocked": {"type": "string", "minLength": 1, + "description": "Give your own goal up, saying why no way remains."}, + "clear": {"type": "string", "minLength": 1, + "description": "Clear the goal of a task you made, or your own by your name."} + }, + "additionalProperties": false + }, + "answered_by": tag + }) +} + +/// How a turn the lookout held for a goal ends: `None` when the agent +/// works again and the turn is no news; else what its lead hears after +/// the turn's status, empty when the goal went meanwhile. +#[derive(Debug, PartialEq)] +pub struct Settled { + pub bot: String, + pub turn: i64, + pub outcome: Option, +} + +/// The lookout's goals, and what it follows of each agent's turns. +pub struct Goals { + store: PathBuf, + socket: PathBuf, + goals: HashMap, + /// Agents whose running turn called a tool. + called: HashSet, + /// Agents whose running turn the goal started. + continued: HashSet, + /// Agents whose running turn called the goal tool. + asked: HashSet, + /// Turns in a row the goal started that called no tool. + idle: HashMap, +} + +impl Goals { + pub fn new(store: &Path, socket: &Path) -> Goals { + Goals { + store: store.to_owned(), + socket: socket.to_owned(), + goals: all(store), + called: HashSet::new(), + continued: HashSet::new(), + asked: HashSet::new(), + idle: HashMap::new(), + } + } + + pub fn reread(&mut self) { + self.goals = all(&self.store); + self.idle.retain(|bot, _| self.goals.contains_key(bot)); + } + + /// An event the lookout read: the turn it holds for a goal, whose news + /// waits for `done` to settle it, when its end is one. + pub fn event( + &mut self, + event: &Value, + live: bool, + client: &std::sync::Arc, + done: &tokio::sync::mpsc::Sender, + ) -> Option<(String, i64)> { + let bot = event["bot"].as_str()?; + let data = &event["data"]; + match event["event"].as_str()? { + "accepted" => { + self.called.remove(bot); + match data["request_id"] + .as_str() + .is_some_and(|id| id.starts_with(REQUEST)) + { + true => self.continued.insert(bot.to_owned()), + false => self.continued.remove(bot), + }; + None + } + "tool_started" => { + self.called.insert(bot.to_owned()); + // A goal set in this turn is checked as it ends, before + // the lookout would read the files again. + if data["name"] == crate::served::GOAL { + self.asked.insert(bot.to_owned()); + } + None + } + // A new agent under the name has no goal of the last one's. + "deleted" => { + if let Some(goal) = self.goals.remove(bot) { + end(&self.store, &goal); + } + None + } + "turn_finished" if live => { + let turn = event["turn"].as_i64()?; + let status = data["status"].as_str().unwrap_or_default(); + if self.asked.remove(bot) { + match read(&self.store, bot) { + Some(goal) => self.goals.insert(bot.to_owned(), goal), + None => self.goals.remove(bot), + }; + } + let goal = self.goals.get(bot)?.clone(); + // An interrupted or failed turn pauses it: the next turn + // that completes is checked. A steer's is part of another. + if status != "completed" && goal.blocked.is_none() { + return None; + } + let idle = match self.continued.contains(bot) && !self.called.contains(bot) { + true => self.idle.get(bot).copied().unwrap_or(0) + 1, + false => 0, + }; + self.idle.insert(bot.to_owned(), idle); + let (client, done, store, socket) = ( + client.clone(), + done.clone(), + self.store.clone(), + self.socket.clone(), + ); + let held = (bot.to_owned(), turn); + let bot = bot.to_owned(); + tokio::spawn(async move { + let outcome = pursue(&client, &store, &socket, &goal, turn, idle).await; + let _ = done.send(Settled { bot, turn, outcome }).await; + }); + Some(held) + } + _ => None, + } + } +} + +/// After a turn of an agent with a goal: whether it works again, or how +/// the goal ended. +async fn pursue( + client: &Client, + store: &Path, + socket: &Path, + goal: &Goal, + turn: i64, + idle: u32, +) -> Option { + if let Some(why) = &goal.blocked { + end(store, goal); + return Some(format!(", and gave up its goal: {why}")); + } + let check = format!("`{}`", goal.check); + let (code, output) = run(goal, store, socket).await; + if code == Some(0) { + end(store, goal); + return Some(format!(", and met its goal: {check} exits 0")); + } + let exits = match code { + Some(code) => format!("exits {code}"), + None => "did not finish".to_owned(), + }; + // Cleared or set again while the check ran: this turn is plain news. + if read(store, &goal.bot).is_none_or(|now| now.set_ms != goal.set_ms) { + return Some(String::new()); + } + if let Some(budget) = goal.budget_tokens { + let record = client.request("inspect", json!({"bot": goal.bot})).await; + let used = (record.ok()) + .and_then(|r| r["tokens_used"].as_u64()) + .unwrap_or(0); + if used.saturating_sub(goal.tokens_at_set) >= budget { + end(store, goal); + return Some(format!( + ", and stopped at its goal's budget of {budget} tokens: {check} still {exits}" + )); + } + } + if idle >= IDLE_TURNS { + end(store, goal); + return Some(format!( + ", and stopped pursuing its goal: {IDLE_TURNS} turns in a row it started called no tool, \ + and {check} still {exits}" + )); + } + let prompt = format!( + "Your goal is not met yet: {check} {exits}.\n```\n{output}\n```\nKeep working until {}. When no way \ + remains, give the goal up with the goal tool's blocked, saying why.", + goal.done_when + ); + let asked = json!({"bot": goal.bot, "bot_id": goal.bot_id, "prompt": prompt, "delivery": "queue", + "origin": "goal", "request_id": format!("{REQUEST}{}-{turn}", goal.bot)}); + match client.request("submit", asked).await { + Ok(_) => None, + Err(error) => Some(format!( + ", and could not go on with its goal: {}", + error.code + )), + } +} + +/// The check, in its own process group in the agent's folder, for up to +/// its timeout: its exit code, `None` when it did not finish, and the end +/// of what it printed. +async fn run(goal: &Goal, store: &Path, socket: &Path) -> (Option, String) { + let mut command = tokio::process::Command::new("sh"); + command + .arg("-c") + .arg(&goal.check) + .current_dir(&goal.workspace) + .env("AGENT_STORE", store) + .env("AGENT_SOCKET", socket) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .process_group(0) + .kill_on_drop(true); + let child = match command.spawn() { + Ok(child) => child, + Err(error) => return (None, format!("the check did not start: {error}")), + }; + let group = child.id(); + let waited = tokio::time::timeout( + Duration::from_secs(goal.timeout_s), + child.wait_with_output(), + ) + .await; + let output = match waited { + Ok(Ok(output)) => output, + Ok(Err(error)) => return (None, format!("the check failed to run: {error}")), + Err(_) => { + if let Some(group) = group { + // SAFETY: a signal to the process group the check leads. + unsafe { libc::kill(-(group as i32), libc::SIGKILL) }; + } + return ( + None, + format!("the check ran past {} s and was stopped", goal.timeout_s), + ); + } + }; + let mut text = String::from_utf8_lossy(&output.stdout).into_owned(); + let errors = String::from_utf8_lossy(&output.stderr); + if !errors.trim().is_empty() { + if !text.is_empty() && !text.ends_with('\n') { + text.push('\n'); + } + text.push_str(&errors); + } + (output.status.code(), tail(&text)) +} + +/// The end of a check's output: its last lines, within a few KB. +fn tail(text: &str) -> String { + let lines: Vec<&str> = text.trim_end().lines().collect(); + let mut kept = lines[lines.len().saturating_sub(OUTPUT_LINES)..].join("\n"); + if kept.len() > OUTPUT_BYTES { + let mut from = kept.len() - OUTPUT_BYTES; + while !kept.is_char_boundary(from) { + from += 1; + } + kept = kept[from..].to_owned(); + } + // Fences in the output would end the block the agent reads it in. + kept.replace("```", "'''") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("sonny-goals-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn goal(store: &Path, bot: &str) -> Goal { + Goal { + bot: bot.into(), + bot_id: 7, + done_when: "tests pass".into(), + check: "true".into(), + workspace: store.parent().unwrap().to_owned(), + set_by: "p.lead".into(), + set_ms: 1, + budget_tokens: Some(10), + tokens_at_set: 3, + timeout_s: 5, + blocked: None, + } + } + + #[test] + fn a_goal_reads_back_as_written_and_ends_only_as_set() { + let dir = scratch("read"); + let store = dir.join("state.sqlite"); + let first = goal(&store, "p.fix"); + write(&store, &first).unwrap(); + assert_eq!(read(&store, "p.fix"), Some(first.clone())); + assert_eq!(all(&store).len(), 1); + // Set again since: the old one's end leaves the new one. + let again = Goal { + set_ms: 2, + ..first.clone() + }; + write(&store, &again).unwrap(); + end(&store, &first); + assert_eq!(read(&store, "p.fix"), Some(again.clone())); + end(&store, &again); + assert_eq!(read(&store, "p.fix"), None); + } + + #[test] + fn a_check_says_its_code_and_the_end_of_what_it_printed() { + let dir = scratch("run"); + let store = dir.join("state.sqlite"); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let mut failing = goal(&store, "p.fix"); + failing.check = "seq 1 100; echo '```' >&2; exit 3".into(); + let (code, output) = runtime.block_on(run(&failing, &store, &store)); + assert_eq!(code, Some(3)); + assert!(output.starts_with("42\n"), "{output}"); + assert!(output.ends_with("100\n'''"), "{output}"); + let mut slow = goal(&store, "p.fix"); + slow.check = "sleep 30".into(); + slow.timeout_s = 1; + let (code, output) = runtime.block_on(run(&slow, &store, &store)); + assert_eq!(code, None); + assert!(output.contains("ran past 1 s"), "{output}"); + let mut here = goal(&store, "p.fix"); + here.check = format!("test \"$AGENT_STORE\" = '{}' && pwd", store.display()); + let (code, output) = runtime.block_on(run(&here, &store, &store)); + assert_eq!(code, Some(0)); + assert_eq!( + std::fs::canonicalize(output.trim()).unwrap(), + std::fs::canonicalize(&dir).unwrap() + ); + } + + #[test] + fn the_tail_keeps_the_last_lines_within_its_bytes() { + let long = "x".repeat(OUTPUT_BYTES * 2); + assert_eq!(tail(&long).len(), OUTPUT_BYTES); + let wide = "é".repeat(OUTPUT_BYTES); + assert!(tail(&wide).len() <= OUTPUT_BYTES); + assert_eq!(tail("a\nb\n\n"), "a\nb"); + } +} diff --git a/sonny/src/lookout/mod.rs b/sonny/src/lookout/mod.rs index 19a06af5d..df7d07008 100644 --- a/sonny/src/lookout/mod.rs +++ b/sonny/src/lookout/mod.rs @@ -15,6 +15,7 @@ //! tools Sonny answers (`served`). //! //! A subscriber hears it all in one note, which starts "Task updates: ". +pub(crate) mod goals; pub(crate) mod subs; mod updates; @@ -1225,7 +1226,7 @@ async fn serve(settings: Settings) -> Result<(), String> { store: settings.store.clone(), records: HashMap::new(), subscribers: HashMap::new(), - socket: settings.socket, + socket: settings.socket.clone(), judge: settings.judge, lookouts: HashMap::new(), judges: HashMap::new(), @@ -1234,6 +1235,7 @@ async fn serve(settings: Settings) -> Result<(), String> { live: false, cursor: None, saved: None, + goals: goals::Goals::new(&settings.store, &settings.socket), resume: std::fs::read_to_string(cursor_path(&settings.store)) .ok() .and_then(|text| text.trim().parse().ok()), @@ -1294,6 +1296,8 @@ struct Service { /// a lookout started again tells what came after it. cursor: Option, saved: Option, + /// The agents' goals, whose turns it holds until their checks say. + goals: goals::Goals, /// Where the last lookout left off: events after it are news. resume: Option, } @@ -1402,6 +1406,7 @@ impl Service { // again. self.resume = self.floor().or(self.resume); let (done, mut results) = tokio::sync::mpsc::channel(64); + let (checked, mut settled) = tokio::sync::mpsc::channel(64); // Checks a dropped connection left under way answer the old // channel, never this one: none is under way here yet. for lookout in self.lookouts.values_mut() { @@ -1445,6 +1450,13 @@ impl Service { self.records.remove(name); self.lookouts.remove(name); } + // A turn whose goal's check is to run is news only once + // it says how the goal stands. + if let Some((bot, turn)) = self.goals.event(&event, live, &client, &checked) { + for lookout in self.lookouts.values_mut() { + lookout.updates.goal_turn(&bot, turn); + } + } for lookout in self.lookouts.values_mut() { lookout.event(&event); // The oldest event whose news is not told yet: a @@ -1471,6 +1483,11 @@ impl Service { continue; } } + Some(goals::Settled { bot, turn, outcome }) = settled.recv() => { + for lookout in self.lookouts.values_mut() { + lookout.updates.goal_settled(&bot, turn, outcome.as_deref()); + } + } Some((lead, id, seen, step)) = results.recv() => { // From a lookout since deleted. if self.lookouts.get(&lead).is_none_or(|l| l.id != id) { @@ -1511,6 +1528,7 @@ impl Service { // one this lookout answered, else now and then. if served.subscribed() || reread.is_none_or(|at| now >= at + REREAD) { reread = Some(now); + self.goals.reread(); let read: HashMap> = subs::all(&self.store).into_iter().collect(); if read != self.subscribers { self.subscribers = read; @@ -3727,6 +3745,7 @@ mod tests { live: false, cursor: None, saved: None, + goals: goals::Goals::new(&store, Path::new("")), resume: None, }; service.lookouts.insert("calc.lead".into(), lookout()); @@ -3790,6 +3809,7 @@ mod tests { live: true, cursor: None, saved: None, + goals: goals::Goals::new(Path::new("/nonexistent"), Path::new("")), resume: None, }; // Named like a lead, it is a bot like any other. diff --git a/sonny/src/lookout/updates.rs b/sonny/src/lookout/updates.rs index 20c8a37c3..6b26a7e15 100644 --- a/sonny/src/lookout/updates.rs +++ b/sonny/src/lookout/updates.rs @@ -33,6 +33,8 @@ const PENDING: usize = 128; /// How long a trigger has to pass a task's answer on before the turn is /// news after all. const FORWARD: Duration = Duration::from_secs(15); +/// How long a goal's turn waits for its check, which says sooner. +const GOAL_HOLD: Duration = Duration::from_secs(super::goals::TIMEOUT_MAX_S + 60); const MEMORY_LINES: usize = 20; const ACTIVE: [&str; 5] = ["running", "waiting", "paced", "queued", "ready"]; const APPROVAL: &str = "waiting for the person's approval"; @@ -55,6 +57,8 @@ struct Turn { which: Option, /// The turn resolved the task, with the task's own `resolve_task`. resolved: bool, + /// How the task's goal ended with this turn. + goal: Option, } /// A message written in a task: who wrote it, and the words, only when @@ -175,6 +179,8 @@ pub struct Updates { /// Turns whose answer a trigger passes to the lead: when they become /// news if it does not, and the news. forwarding: HashMap, Option)>, + /// Turns whose end waits for their goal's check, until it says. + goaled: HashSet, /// Approvals not yet answered: when they become news, and the call. approvals: HashMap)>, /// Each task's news, in the order it first came. @@ -212,6 +218,7 @@ impl Updates { origin: HashMap::new(), answer_to: HashMap::new(), forwarding: HashMap::new(), + goaled: HashSet::new(), approvals: HashMap::new(), tasks: Vec::new(), quiet: HashMap::new(), @@ -409,7 +416,12 @@ impl Updates { .creator(name) .and_then(|c| self.bots.get(c)) .and_then(|c| c.id); - if answered.is_some() && answered == creator { + if self.goaled.remove(&key(turn)) { + // News once its goal's check says, or once a check + // could have run its longest. + self.forwarding + .insert(key(turn), (now + GOAL_HOLD, status, from, origin)); + } else if answered.is_some() && answered == creator { self.forwarding .insert(key(turn), (now + FORWARD, status, from, origin)); } else { @@ -533,6 +545,7 @@ impl Updates { count: 1, which, resolved, + goal: None, }; news.merge(match theirs { true => News { @@ -585,6 +598,36 @@ impl Updates { } } + /// A turn about to end whose goal's check runs once it does: its end + /// waits for `goal_settled`. + pub fn goal_turn(&mut self, name: &str, turn: i64) { + if self.live { + self.goaled.insert((name.to_owned(), turn)); + } + } + + /// The goal's check of a turn said: no news when the task works again + /// (`None`), else the turn, with how its goal ended after its status. + pub fn goal_settled(&mut self, name: &str, turn: i64, outcome: Option<&str>) { + self.goaled.remove(&(name.to_owned(), turn)); + let Some((_, status, from, origin)) = self.forwarding.remove(&(name.to_owned(), turn)) + else { + return; + }; + let Some(outcome) = outcome else { + return; + }; + self.tell(name, turn, &status, from, None, origin); + let news = (self.tasks.iter_mut()).find(|(task, _)| task == name); + if let Some((_, news)) = news { + for told in [&mut news.act, &mut news.theirs].into_iter().flatten() { + if told.turn == turn && !outcome.is_empty() { + told.goal = Some(outcome.to_owned()); + } + } + } + } + /// The lead read these turns with its wait tool: what they said is no /// news to it any more. pub fn read(&mut self, handles: &HashSet) { @@ -753,6 +796,7 @@ impl Updates { pub fn forget(&mut self) { self.tasks.clear(); self.forwarding.clear(); + self.goaled.clear(); self.approvals.clear(); } @@ -790,6 +834,7 @@ impl Updates { fn drop_news(&mut self, name: &str) { self.tasks.retain(|(task, _)| task != name); self.forwarding.retain(|(task, _), _| task != name); + self.goaled.retain(|(task, _)| task != name); self.approvals.retain(|(task, _), _| task != name); self.noted.retain(|(task, _)| task != name); } @@ -868,6 +913,9 @@ fn line(name: &str, theirs: bool, t: &Turn) -> Line { if t.resolved { text.push_str(", and resolved itself"); } + if let Some(goal) = &t.goal { + text.push_str(goal); + } if t.count > 1 { text.push_str(&format!( ", and {} earlier since turn:{name}/{}", @@ -1165,6 +1213,47 @@ mod tests { assert!(updates.due(false)); } + #[test] + fn a_goal_s_turns_are_news_only_once_it_ends() { + let mut updates = fleet(); + let lead = json!({"from": {"bot": "shop.lead", "turn": 9}}); + let goal = json!({"origin": "goal"}); + // The check failed and the task works again: no news. + updates.goal_turn("shop.fix", 4); + turn(&mut updates, "shop.fix", 4, lead.clone(), "completed"); + assert!(!updates.due(false), "held for its check"); + updates.goal_settled("shop.fix", 4, None); + assert!(!updates.due(false), "it works again"); + // The check passed: the turn, with how the goal ended. + updates.goal_turn("shop.fix", 5); + turn(&mut updates, "shop.fix", 5, goal.clone(), "completed"); + updates.goal_settled( + "shop.fix", + 5, + Some(", and met its goal: `make test` exits 0"), + ); + let told = updates.take(); + assert_eq!( + text(&told), + "- shop.fix: turn:shop.fix/5 completed, asked by goal, and met its goal: `make test` exits 0" + ); + updates.sent(told); + // Cleared while its check ran: plain news. + updates.goal_turn("shop.docs", 2); + turn(&mut updates, "shop.docs", 2, lead, "completed"); + updates.goal_settled("shop.docs", 2, Some("")); + assert_eq!( + text(&updates.take()), + "- shop.docs: turn:shop.docs/2 completed, asked by you" + ); + // A check that never says lets the turn go once it could have run + // its longest. + updates.goal_turn("shop.docs", 3); + turn(&mut updates, "shop.docs", 3, goal, "completed"); + updates.expire(Instant::now() + GOAL_HOLD); + assert!(text(&updates.take()).starts_with("- shop.docs: turn:shop.docs/3 completed")); + } + #[test] fn a_message_that_started_a_turn_told_as_ended_is_not_told_twice() { let mut updates = fleet(); diff --git a/sonny/src/served.rs b/sonny/src/served.rs index 46b6c20c1..bd5d7ee54 100644 --- a/sonny/src/served.rs +++ b/sonny/src/served.rs @@ -30,6 +30,7 @@ pub const PROJECT: &str = "new_project"; pub const SUBSCRIPTIONS: &str = "subscriptions"; pub const TRIGGERS: &str = "triggers"; pub const MEMORY: &str = "memory"; +pub const GOAL: &str = "goal"; pub const EXEC: &str = crate::exec::NAME; /// The tools whose calls wait for a verdict wherever the agent's other @@ -37,9 +38,18 @@ pub const EXEC: &str = crate::exec::NAME; /// lead, outside the agent's sandbox as a pane's command is, a removal can /// drop work, a subscription sends other bots' words to the subscriber's /// judge, a trigger is a job that runs as the person, its `if` -/// command outside any sandbox, and a script calls MCP servers, which run -/// as the person too. -pub const GATED: [&str; 7] = [START, REMOVE, PROJECT, SUBSCRIPTIONS, RUN, TRIGGERS, EXEC]; +/// command outside any sandbox, a script calls MCP servers, which run +/// as the person too, and so does a goal's check. +pub const GATED: [&str; 8] = [ + START, + REMOVE, + PROJECT, + SUBSCRIPTIONS, + RUN, + TRIGGERS, + EXEC, + GOAL, +]; /// The most of what a call printed that its result holds, under the /// daemon's bound on an answer. @@ -59,7 +69,13 @@ pub fn tools(lead: bool) -> Vec { if lead { tools.extend([start(), remove(), project(), subscriptions()]); } - tools.extend([show(), run(), triggers(), memory()]); + tools.extend([ + show(), + run(), + triggers(), + memory(), + crate::lookout::goals::tool(GOAL, TAG), + ]); tools.extend(crate::exec::tool()); tools } @@ -476,6 +492,16 @@ async fn called(name: &str, arguments: &Value) -> Answer { .map(|listed| listed.to_string()) .map_err(|e| ("subscription_refused", e)) } + GOAL => { + let (store, socket) = crate::daemon_paths(None, None).map_err(|e| ("no_store", e))?; + let (client, _events) = Client::connect(&socket) + .await + .map_err(|e| ("goal_refused", e.to_string()))?; + crate::lookout::goals::call(&store, &client, &caller, arguments) + .await + .map(|said| said.to_string()) + .map_err(|e| ("goal_refused", e)) + } MEMORY => { let home = std::env::var_os("HOME") .map(PathBuf::from) @@ -609,7 +635,8 @@ async fn serve( "call_id": call["call_id"]}); match answer { Ok(output) => { - if call["name"] == SUBSCRIPTIONS { + // Files the lookout reads, read now. + if call["name"] == SUBSCRIPTIONS || call["name"] == GOAL { subscribed.store(true, Ordering::Relaxed); } asked["output"] = json!(cut(output)) @@ -877,7 +904,7 @@ mod tests { .filter(|name| name != EXEC) .collect() }; - assert_eq!(names(tools(false)), [SHOW, RUN, TRIGGERS, MEMORY]); + assert_eq!(names(tools(false)), [SHOW, RUN, TRIGGERS, MEMORY, GOAL]); assert_eq!( names(tools(true)), [ @@ -888,7 +915,8 @@ mod tests { SHOW, RUN, TRIGGERS, - MEMORY + MEMORY, + GOAL ] ); for tool in tools(true).into_iter().filter(|t| t["name"] != EXEC) { diff --git a/tests/test_served.py b/tests/test_served.py index 64f19290c..3ae5dcc51 100644 --- a/tests/test_served.py +++ b/tests/test_served.py @@ -283,6 +283,57 @@ def test_a_lead_made_for_a_subfolder_reads_its_own_memory_and_follows_its_folder self.assertTrue(kept.name.endswith(f'-{lead["bot_id"]}'), kept.name) @unittest.skipIf(sys.platform == 'darwin', 'launchd keeps triggers on a Mac') + def note_for(self, bot, having, timeout=30): + """The first note the lookout gave `bot` that says `having`.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + for turn in self.daemon.request('turns', bot=bot)['result']['turns']: + text = self.daemon.request('prompts', bot=bot, turn=turn['turn'])['result']['prompts'] + if text and text[0]['text'].startswith('Task updates: ') and having in text[0]['text']: + return text[0]['text'] + time.sleep(.2) + self.fail(f'{bot} heard nothing saying {having!r}') + + def goal_run(self, check): + """calc.fix sets itself a goal with `check`, watched by a lead that + hears its turns: the lead's note once the goal ends, and the + prompts of calc.fix's turns.""" + self.daemon.request('create', bot='calc.lead', workspace=str(self.path), model='openai/synthetic-model', + instructions='Lead.') + self.daemon.request('create', bot='calc.fix', workspace=str(self.path), model='openai/synthetic-model', + instructions='Task.', declare=[declared('goal')]) + folder = self.path / 'state.sqlite.lookout' + folder.mkdir(exist_ok=True) + watch = {'name': 'tasks', 'tasks_of': [], 'bots': ['calc.fix'], 'on': ['said', 'ended'], 'updates': True} + (folder / 'calc.lead.json').write_text(json.dumps({'subscriptions': [watch]})) + self.lookout() + set_ = json.loads(self.call('calc.fix', 'goal', {'set': {'done_when': 'the flag exists', 'check': check}})) + self.assertIn('goal', set_, set_) + self.assertEqual(set_['goal']['check'], check) + note = self.note_for('calc.lead', 'its goal') + prompts = [self.daemon.request('prompts', bot='calc.fix', turn=t['turn'])['result']['prompts'][0]['text'] + for t in self.daemon.request('turns', bot='calc.fix')['result']['turns']] + return note, prompts + + def test_a_goal_keeps_its_agent_working_until_its_check_passes(self): + # Fails once, leaving the flag that passes it the next time. + note, prompts = self.goal_run('test -f flag || { touch flag; echo not yet; exit 1; }') + self.assertEqual(len(prompts), 2, prompts) + self.assertTrue(prompts[1].startswith('Your goal is not met yet: `test -f flag'), prompts[1]) + self.assertIn('exits 1.\n```\nnot yet\n```\nKeep working until the flag exists.', prompts[1]) + # The lead hears the turn that met it, and nothing of the one before. + self.assertIn('- calc.fix: turn:calc.fix/2 completed, asked by goal, and met its goal:', note) + self.assertNotIn('calc.fix/1', note) + self.assertFalse((self.path / 'state.sqlite.lookout/goals/calc.fix.json').exists()) + + def test_a_goal_whose_agent_only_answers_stops(self): + note, prompts = self.goal_run('false') + # The turn that set it called the tool; two that only answered end it. + self.assertEqual(len(prompts), 3, prompts) + self.assertIn('turn:calc.fix/3 completed, asked by goal, and stopped pursuing its goal: 2 turns in a row', + note) + self.assertIn('`false` still exits 1', note) + def test_a_trigger_off_a_mac_is_refused_and_says_why(self): self.daemon.request('create', bot='calc.lead', workspace=str(self.path), model='openai/synthetic-model', instructions='Lead.', declare=[declared('triggers')]) From 2380cad300f47186f0590b1954430e3ca2498d8f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 04:18:16 +0000 Subject: [PATCH 02/30] Goals: the lead-reaction test's lookout gets its goals --- sonny/src/lookout/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/sonny/src/lookout/mod.rs b/sonny/src/lookout/mod.rs index 4b291fcad..9fde3d6cf 100644 --- a/sonny/src/lookout/mod.rs +++ b/sonny/src/lookout/mod.rs @@ -3192,6 +3192,7 @@ mod tests { resume: None, reacting: HashMap::new(), taken: HashMap::new(), + goals: goals::Goals::new(Path::new("/nonexistent"), Path::new("")), }; let started = |bot: &str, arguments: Value| { json!({"event": "tool_started", "bot": bot, "turn": 3, "data": {"call_id": "c1", From 8fd8d02ceeeecc133b678ac6885bdac340e1e9d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 04:52:18 +0000 Subject: [PATCH 03/30] Goals: answer Codex's review - A check's output is read as it prints, keeping only its end. - A turn's goal is read fresh as the turn ends, so one a lead set or cleared meanwhile counts. - One check at a time per agent; a turn that ends while one runs is plain news. - A task is the caller's by its creator's id, not its name. - A turn waiting for its check keeps the replay cursor before it, so a restarted lookout runs the check again. - The check runs without the provider keys in ~/.agent/env. - The macOS skip is back on the trigger test, not on note_for. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHCyGRHdT5WYDPKVFY --- docs/SONNY.md | 5 +- sonny/src/lookout/goals.rs | 136 ++++++++++++++++++++++++----------- sonny/src/lookout/mod.rs | 13 ++-- sonny/src/lookout/updates.rs | 11 ++- tests/test_served.py | 2 +- 5 files changed, 117 insertions(+), 50 deletions(-) diff --git a/docs/SONNY.md b/docs/SONNY.md index 70c22ec7d..a14bf294a 100644 --- a/docs/SONNY.md +++ b/docs/SONNY.md @@ -250,8 +250,9 @@ An agent sets one with the `goal` tool, on itself or on a task it made: give its own up, `clear` NAME, or nothing to list them. The check is a shell command (`cargo test`, `make lint`, `test -z "$(git status --short)"`) that exits 0 once done; the lookout runs it in the agent's folder, as the -person and outside any sandbox like a trigger's `if`, so `set` waits for a -verdict wherever the agent's other calls do (`/approvals`). Each goal is +person and outside any sandbox like a trigger's `if`, though without the +provider keys in `~/.agent/env`, so `set` waits for a verdict wherever the +agent's other calls do (`/approvals`). Each goal is `STORE.lookout/goals/BOT.json`; setting the same goal again changes nothing. diff --git a/sonny/src/lookout/goals.rs b/sonny/src/lookout/goals.rs index 2e74f84b5..3ca9618e7 100644 --- a/sonny/src/lookout/goals.rs +++ b/sonny/src/lookout/goals.rs @@ -13,7 +13,8 @@ //! its `goal` tool writes (a lead's for its tasks too) and the lookout //! reads again when a call changed it, and removes when the goal ends. A //! check runs as the person, outside every sandbox, as a trigger's `if` -//! does: setting one waits for a verdict where the agent's other calls do. +//! does, without the provider keys in ~/.agent/env: setting one waits for +//! a verdict where the agent's other calls do. use agent_client::Client; use serde_json::{Value, json}; use std::collections::{HashMap, HashSet}; @@ -150,11 +151,15 @@ pub async fn call( ) -> Result { let target = |bot: &Value| bot.as_str().unwrap_or(caller).to_owned(); // A goal goes on the caller or on an agent it made, whose record says - // so: the record also gives its folder, id and spend. + // so by id, since a name can be another bot's since: the record also + // gives its folder, id and spend. + let inspect = async |bot: &str| -> Result { + (client.request("inspect", json!({"bot": bot})).await) + .map_err(|e| format!("bot_not_found: {bot}: {e}")) + }; let record = async |bot: &str| -> Result { - let record = (client.request("inspect", json!({"bot": bot})).await) - .map_err(|e| format!("bot_not_found: {bot}: {e}"))?; - if bot != caller && record["created_by"].as_str() != Some(caller) { + let record = inspect(bot).await?; + if bot != caller && record["created_by_id"] != inspect(caller).await?["bot_id"] { return Err(format!( "not_your_task: {bot} was not made by {caller}; a goal goes on you or on a task you made" )); @@ -300,8 +305,9 @@ pub struct Goals { called: HashSet, /// Agents whose running turn the goal started. continued: HashSet, - /// Agents whose running turn called the goal tool. - asked: HashSet, + /// Agents whose goal's check runs: a turn that ends meanwhile is + /// plain news, and the check's own turn is checked in its place. + checking: HashSet, /// Turns in a row the goal started that called no tool. idle: HashMap, } @@ -314,11 +320,21 @@ impl Goals { goals: all(store), called: HashSet::new(), continued: HashSet::new(), - asked: HashSet::new(), + checking: HashSet::new(), idle: HashMap::new(), } } + /// The check of `bot` said how its goal stands. + pub fn settled(&mut self, bot: &str) { + self.checking.remove(bot); + } + + /// Checks under way answer a connection since dropped, never the next. + pub fn reconnected(&mut self) { + self.checking.clear(); + } + pub fn reread(&mut self) { self.goals = all(&self.store); self.idle.retain(|bot, _| self.goals.contains_key(bot)); @@ -349,11 +365,6 @@ impl Goals { } "tool_started" => { self.called.insert(bot.to_owned()); - // A goal set in this turn is checked as it ends, before - // the lookout would read the files again. - if data["name"] == crate::served::GOAL { - self.asked.insert(bot.to_owned()); - } None } // A new agent under the name has no goal of the last one's. @@ -366,13 +377,16 @@ impl Goals { "turn_finished" if live => { let turn = event["turn"].as_i64()?; let status = data["status"].as_str().unwrap_or_default(); - if self.asked.remove(bot) { - match read(&self.store, bot) { - Some(goal) => self.goals.insert(bot.to_owned(), goal), - None => self.goals.remove(bot), - }; - } + // Its goal as it is now, which its lead may have set or + // cleared in the meantime. + match read(&self.store, bot) { + Some(goal) => self.goals.insert(bot.to_owned(), goal), + None => self.goals.remove(bot), + }; let goal = self.goals.get(bot)?.clone(); + if self.checking.contains(bot) { + return None; + } // An interrupted or failed turn pauses it: the next turn // that completes is checked. A steer's is part of another. if status != "completed" && goal.blocked.is_none() { @@ -391,6 +405,7 @@ impl Goals { ); let held = (bot.to_owned(), turn); let bot = bot.to_owned(); + self.checking.insert(bot.clone()); tokio::spawn(async move { let outcome = pursue(&client, &store, &socket, &goal, turn, idle).await; let _ = done.send(Settled { bot, turn, outcome }).await; @@ -481,39 +496,68 @@ async fn run(goal: &Goal, store: &Path, socket: &Path) -> (Option, String) .stderr(std::process::Stdio::piped()) .process_group(0) .kill_on_drop(true); - let child = match command.spawn() { + // The provider keys in ~/.agent/env are the daemon's: no agent's own + // command has them, nor does its check. + if let Some(file) = agent_client::env_file::path() { + for (key, _) in agent_client::env_file::read(&file).unwrap_or_default() { + command.env_remove(key); + } + } + let mut child = match command.spawn() { Ok(child) => child, Err(error) => return (None, format!("the check did not start: {error}")), }; let group = child.id(); - let waited = tokio::time::timeout( - Duration::from_secs(goal.timeout_s), - child.wait_with_output(), - ) - .await; - let output = match waited { - Ok(Ok(output)) => output, - Ok(Err(error)) => return (None, format!("the check failed to run: {error}")), - Err(_) => { - if let Some(group) = group { - // SAFETY: a signal to the process group the check leads. - unsafe { libc::kill(-(group as i32), libc::SIGKILL) }; - } - return ( - None, - format!("the check ran past {} s and was stopped", goal.timeout_s), - ); - } + // Read as it prints, keeping only the end, so a check that prints + // without end holds a few KB, not all of it. + let (stdout, stderr) = (child.stdout.take(), child.stderr.take()); + let ran = async { + let (stdout, stderr) = tokio::join!(last(stdout), last(stderr)); + (child.wait().await, stdout, stderr) }; - let mut text = String::from_utf8_lossy(&output.stdout).into_owned(); - let errors = String::from_utf8_lossy(&output.stderr); + let (status, stdout, stderr) = + match tokio::time::timeout(Duration::from_secs(goal.timeout_s), ran).await { + Ok((Ok(status), stdout, stderr)) => (status, stdout, stderr), + Ok((Err(error), ..)) => return (None, format!("the check failed to run: {error}")), + Err(_) => { + if let Some(group) = group { + // SAFETY: a signal to the process group the check leads. + unsafe { libc::kill(-(group as i32), libc::SIGKILL) }; + } + return ( + None, + format!("the check ran past {} s and was stopped", goal.timeout_s), + ); + } + }; + let mut text = String::from_utf8_lossy(&stdout).into_owned(); + let errors = String::from_utf8_lossy(&stderr); if !errors.trim().is_empty() { if !text.is_empty() && !text.ends_with('\n') { text.push('\n'); } text.push_str(&errors); } - (output.status.code(), tail(&text)) + (status.code(), tail(&text)) +} + +/// The last `OUTPUT_BYTES` a pipe gave, read until it closes. +async fn last(pipe: Option) -> Vec { + use tokio::io::AsyncReadExt; + let Some(mut pipe) = pipe else { + return Vec::new(); + }; + let (mut kept, mut chunk) = (Vec::new(), [0u8; 8192]); + while let Ok(read) = pipe.read(&mut chunk).await + && read > 0 + { + kept.extend_from_slice(&chunk[..read]); + if kept.len() > 2 * OUTPUT_BYTES { + kept.drain(..kept.len() - OUTPUT_BYTES); + } + } + let from = kept.len().saturating_sub(OUTPUT_BYTES); + kept.split_off(from) } /// The end of a check's output: its last lines, within a few KB. @@ -592,6 +636,16 @@ mod tests { assert_eq!(code, Some(3)); assert!(output.starts_with("42\n"), "{output}"); assert!(output.ends_with("100\n'''"), "{output}"); + // One that prints without end keeps only the end of it. + let mut loud = goal(&store, "p.fix"); + loud.check = "head -c 50000000 /dev/zero | tr '\\0' x; echo; echo last".into(); + let (code, output) = runtime.block_on(run(&loud, &store, &store)); + assert_eq!(code, Some(0)); + assert!( + output.len() <= OUTPUT_BYTES && output.ends_with("\nlast"), + "{}", + output.len() + ); let mut slow = goal(&store, "p.fix"); slow.check = "sleep 30".into(); slow.timeout_s = 1; diff --git a/sonny/src/lookout/mod.rs b/sonny/src/lookout/mod.rs index 9fde3d6cf..993930d5c 100644 --- a/sonny/src/lookout/mod.rs +++ b/sonny/src/lookout/mod.rs @@ -1488,6 +1488,7 @@ impl Service { lookout.checking = 0; lookout.judging = 0; } + self.goals.reconnected(); // Often enough for a note whose news has settled; subscription // files less often. let mut tick = tokio::time::interval(SETTLE / 2); @@ -1531,12 +1532,12 @@ impl Service { self.lookouts.remove(name); } // A turn whose goal's check is to run is news only once - // it says how the goal stands. - if let Some((bot, turn)) = self.goals.event(&event, live, &client, &checked) { - for lookout in self.lookouts.values_mut() { - lookout.updates.goal_turn(&bot, turn); - } + // it says how the goal stands. + if let Some((bot, turn)) = self.goals.event(&event, live, &client, &checked) { + for lookout in self.lookouts.values_mut() { + lookout.updates.goal_turn(&bot, turn); } + } for lookout in self.lookouts.values_mut() { lookout.event(&event); // The oldest event whose news is not told yet: a @@ -1564,6 +1565,7 @@ impl Service { } } Some(goals::Settled { bot, turn, outcome }) = settled.recv() => { + self.goals.settled(&bot); for lookout in self.lookouts.values_mut() { lookout.updates.goal_settled(&bot, turn, outcome.as_deref()); } @@ -2755,6 +2757,7 @@ impl Lookout { !self.due.is_empty() || self.checking > 0 || !self.held.is_empty() + || self.updates.goal_pending() || self.updates.news(self.urgent.is_some()) } diff --git a/sonny/src/lookout/updates.rs b/sonny/src/lookout/updates.rs index 6b26a7e15..e5821452b 100644 --- a/sonny/src/lookout/updates.rs +++ b/sonny/src/lookout/updates.rs @@ -416,7 +416,7 @@ impl Updates { .creator(name) .and_then(|c| self.bots.get(c)) .and_then(|c| c.id); - if self.goaled.remove(&key(turn)) { + if self.goaled.contains(&key(turn)) { // News once its goal's check says, or once a check // could have run its longest. self.forwarding @@ -583,6 +583,7 @@ impl Updates { .map(|(key, _)| key.clone()) .collect(); for key in late { + self.goaled.remove(&key); if let Some((_, status, from, origin)) = self.forwarding.remove(&key) { self.tell(&key.0, key.1, &status, from, None, origin); } @@ -606,6 +607,12 @@ impl Updates { } } + /// Whether a turn waits for its goal's check: the replay after a + /// restart starts before it, so the check runs again. + pub fn goal_pending(&self) -> bool { + !self.goaled.is_empty() + } + /// The goal's check of a turn said: no news when the task works again /// (`None`), else the turn, with how its goal ended after its status. pub fn goal_settled(&mut self, name: &str, turn: i64, outcome: Option<&str>) { @@ -645,6 +652,8 @@ impl Updates { self.tasks.retain(|(_, news)| !news.empty()); self.forwarding .retain(|(name, turn), _| !handles.contains(&format!("turn:{name}/{turn}"))); + self.goaled + .retain(|(name, turn)| !handles.contains(&format!("turn:{name}/{turn}"))); } /// Whether there is news for the lead: its own, or `held` other news. diff --git a/tests/test_served.py b/tests/test_served.py index 3ae5dcc51..fd38ee856 100644 --- a/tests/test_served.py +++ b/tests/test_served.py @@ -282,7 +282,6 @@ def test_a_lead_made_for_a_subfolder_reads_its_own_memory_and_follows_its_folder [kept] = (self.path / '.agent/sonny/instructions').iterdir() self.assertTrue(kept.name.endswith(f'-{lead["bot_id"]}'), kept.name) - @unittest.skipIf(sys.platform == 'darwin', 'launchd keeps triggers on a Mac') def note_for(self, bot, having, timeout=30): """The first note the lookout gave `bot` that says `having`.""" deadline = time.monotonic() + timeout @@ -334,6 +333,7 @@ def test_a_goal_whose_agent_only_answers_stops(self): note) self.assertIn('`false` still exits 1', note) + @unittest.skipIf(sys.platform == 'darwin', 'launchd keeps triggers on a Mac') def test_a_trigger_off_a_mac_is_refused_and_says_why(self): self.daemon.request('create', bot='calc.lead', workspace=str(self.path), model='openai/synthetic-model', instructions='Lead.', declare=[declared('triggers')]) From 31231ed4d740209fca049b63397b535db4398741 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 04:56:13 +0000 Subject: [PATCH 04/30] Triggers: restamp reads the job file by its new name Main does not build since #344 renamed Places::plist to Places::job while #318's restamp still called the old name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHCyGRHdT5WYDPKVFY --- sonny/src/trigger.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sonny/src/trigger.rs b/sonny/src/trigger.rs index 35ed6cacb..b14ea794e 100644 --- a/sonny/src/trigger.rs +++ b/sonny/src/trigger.rs @@ -2374,7 +2374,7 @@ pub fn restamp(places: &Places, identity: &str, lineage: &str, launchd: Loader) return; }; // Read again under the lock: an `rm`, `add` or fire may have come first. - let path = places.plist(&trigger.name); + let path = places.job(&trigger.name); let Ok(text) = std::fs::read_to_string(&path) else { continue; }; From bf1e342bd437953bab3346bcc2b59337f45538c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 05:05:46 +0000 Subject: [PATCH 05/30] Goals: answer Codex's second review - A goal left by an agent deleted while the lookout was away is dropped when another agent with that name ends a turn. - A goal's next turn is not news while it runs, as its end is. - A goal cleared or set again while its check ran is plain news, even when that check passed. - Listing goals shows a lead its tasks' goals, whoever set them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHCyGRHdT5WYDPKVFY --- sonny/src/lookout/goals.rs | 36 +++++++++++++++++++++++++++--------- sonny/src/lookout/mod.rs | 12 +++++++++--- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/sonny/src/lookout/goals.rs b/sonny/src/lookout/goals.rs index 3ca9618e7..6e86855ea 100644 --- a/sonny/src/lookout/goals.rs +++ b/sonny/src/lookout/goals.rs @@ -33,7 +33,7 @@ const IDLE_TURNS: u32 = 2; /// What a turn the goal started carries as its request ID, before the /// agent and turn it follows: not the lookout's own note, since the /// turn is the agent's work and its end is news. -const REQUEST: &str = "goal-"; +pub(crate) const REQUEST: &str = "goal-"; #[derive(Clone, Debug, PartialEq)] pub struct Goal { @@ -236,10 +236,18 @@ pub async fn call( } return Ok(json!({"bot": bot, "cleared": had.is_some()})); } - let mine: Vec = (all(store).into_values()) - .filter(|goal| goal.bot == caller || goal.set_by == caller) - .map(|goal| goal.shown()) - .collect(); + // Its own, and its tasks', whoever set them. + let me = inspect(caller).await?["bot_id"].clone(); + let mut mine = Vec::new(); + for goal in all(store).into_values() { + if goal.bot == caller + || inspect(&goal.bot) + .await + .is_ok_and(|task| task["created_by_id"] == me) + { + mine.push(goal.shown()); + } + } Ok(json!({"goals": mine})) } @@ -342,9 +350,12 @@ impl Goals { /// An event the lookout read: the turn it holds for a goal, whose news /// waits for `done` to settle it, when its end is one. + /// `bot_id` is the id of the agent the event names, as the lookout + /// knows it now. pub fn event( &mut self, event: &Value, + bot_id: Option, live: bool, client: &std::sync::Arc, done: &tokio::sync::mpsc::Sender, @@ -384,6 +395,13 @@ impl Goals { None => self.goals.remove(bot), }; let goal = self.goals.get(bot)?.clone(); + // Left by an agent deleted while the lookout was away, whose + // name another has since. + if bot_id.is_some_and(|id| id != goal.bot_id) { + end(&self.store, &goal); + self.goals.remove(bot); + return None; + } if self.checking.contains(bot) { return None; } @@ -433,6 +451,10 @@ async fn pursue( } let check = format!("`{}`", goal.check); let (code, output) = run(goal, store, socket).await; + // Cleared or set again while the check ran: this turn is plain news. + if read(store, &goal.bot).is_none_or(|now| now.set_ms != goal.set_ms) { + return Some(String::new()); + } if code == Some(0) { end(store, goal); return Some(format!(", and met its goal: {check} exits 0")); @@ -441,10 +463,6 @@ async fn pursue( Some(code) => format!("exits {code}"), None => "did not finish".to_owned(), }; - // Cleared or set again while the check ran: this turn is plain news. - if read(store, &goal.bot).is_none_or(|now| now.set_ms != goal.set_ms) { - return Some(String::new()); - } if let Some(budget) = goal.budget_tokens { let record = client.request("inspect", json!({"bot": goal.bot})).await; let used = (record.ok()) diff --git a/sonny/src/lookout/mod.rs b/sonny/src/lookout/mod.rs index 993930d5c..0a03e627b 100644 --- a/sonny/src/lookout/mod.rs +++ b/sonny/src/lookout/mod.rs @@ -1533,7 +1533,10 @@ impl Service { } // A turn whose goal's check is to run is news only once // it says how the goal stands. - if let Some((bot, turn)) = self.goals.event(&event, live, &client, &checked) { + let bot_id = (event["bot"].as_str()) + .and_then(|bot| self.records.get(bot)) + .and_then(|record| record["bot_id"].as_i64()); + if let Some((bot, turn)) = self.goals.event(&event, bot_id, live, &client, &checked) { for lookout in self.lookouts.values_mut() { lookout.updates.goal_turn(&bot, turn); } @@ -2273,8 +2276,11 @@ impl Lookout { /// Who wrote a turn, as the note names them; `None` for the lead, whose /// words are no news to it. fn author(&self, data: &Value) -> Option { - // A lookout's own note to a lead another subscriber watches. - if (data["request_id"].as_str()).is_some_and(|id| id.starts_with("lookout-")) { + // A lookout's own note to a lead another subscriber watches, or a + // goal's next turn, whose end alone is news. + if (data["request_id"].as_str()) + .is_some_and(|id| id.starts_with("lookout-") || id.starts_with(goals::REQUEST)) + { return None; } match (data["from"]["bot"].as_str(), data["origin"].as_str()) { From e79ca9916d365aaf915efeb0d114ded834660a6a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 05:17:17 +0000 Subject: [PATCH 06/30] Goals: a turn its check holds is not judged, and a goal given up while its check ran ends so Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHCyGRHdT5WYDPKVFY --- sonny/src/lookout/goals.rs | 13 ++++++++++--- sonny/src/lookout/mod.rs | 6 +++++- sonny/src/lookout/updates.rs | 5 +++++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/sonny/src/lookout/goals.rs b/sonny/src/lookout/goals.rs index 6e86855ea..53f7bc9fe 100644 --- a/sonny/src/lookout/goals.rs +++ b/sonny/src/lookout/goals.rs @@ -451,9 +451,16 @@ async fn pursue( } let check = format!("`{}`", goal.check); let (code, output) = run(goal, store, socket).await; - // Cleared or set again while the check ran: this turn is plain news. - if read(store, &goal.bot).is_none_or(|now| now.set_ms != goal.set_ms) { - return Some(String::new()); + // Cleared or set again while the check ran: this turn is plain news; + // given up meanwhile, it ends so. + match read(store, &goal.bot) { + Some(now) if now.set_ms == goal.set_ms => { + if let Some(why) = &now.blocked { + end(store, &now); + return Some(format!(", and gave up its goal: {why}")); + } + } + _ => return Some(String::new()), } if code == Some(0) { end(store, goal); diff --git a/sonny/src/lookout/mod.rs b/sonny/src/lookout/mod.rs index dd49d2053..f366f7f02 100644 --- a/sonny/src/lookout/mod.rs +++ b/sonny/src/lookout/mod.rs @@ -2454,7 +2454,11 @@ impl Lookout { if denied > 0 { status.push_str(&format!(", after {denied} calls denied by the approver")); } - self.due(bot, turn, Kind::Ended, None, Some(status), checkpoint); + // A turn its goal's check holds is not judged: the lead + // hears how the goal ended in its task updates. + if !self.updates.goal_held(bot, turn) { + self.due(bot, turn, Kind::Ended, None, Some(status), checkpoint); + } self.passed(bot, reply); } _ => {} diff --git a/sonny/src/lookout/updates.rs b/sonny/src/lookout/updates.rs index e5821452b..5f9b4a97d 100644 --- a/sonny/src/lookout/updates.rs +++ b/sonny/src/lookout/updates.rs @@ -607,6 +607,11 @@ impl Updates { } } + /// Whether this turn waits for its goal's check. + pub fn goal_held(&self, name: &str, turn: i64) -> bool { + self.goaled.contains(&(name.to_owned(), turn)) + } + /// Whether a turn waits for its goal's check: the replay after a /// restart starts before it, so the check runs again. pub fn goal_pending(&self) -> bool { From 97d3e40d36cebf740b75bb698bbe31c7510b0dd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 05:22:57 +0000 Subject: [PATCH 07/30] Goals: a goal set again counts its turns without a tool from 0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHCyGRHdT5WYDPKVFY --- sonny/src/lookout/goals.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/sonny/src/lookout/goals.rs b/sonny/src/lookout/goals.rs index 53f7bc9fe..9606dbf19 100644 --- a/sonny/src/lookout/goals.rs +++ b/sonny/src/lookout/goals.rs @@ -317,7 +317,7 @@ pub struct Goals { /// plain news, and the check's own turn is checked in its place. checking: HashSet, /// Turns in a row the goal started that called no tool. - idle: HashMap, + idle: HashMap, } impl Goals { @@ -411,10 +411,14 @@ impl Goals { return None; } let idle = match self.continued.contains(bot) && !self.called.contains(bot) { - true => self.idle.get(bot).copied().unwrap_or(0) + 1, + // Counted for this goal only: one set since starts at 0. + true => match self.idle.get(bot) { + Some((set_ms, idle)) if *set_ms == goal.set_ms => idle + 1, + _ => 1, + }, false => 0, }; - self.idle.insert(bot.to_owned(), idle); + self.idle.insert(bot.to_owned(), (goal.set_ms, idle)); let (client, done, store, socket) = ( client.clone(), done.clone(), From 6aa2f0ee13feee2b97ec7afcbca5d67e97b61957 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 05:31:21 +0000 Subject: [PATCH 08/30] Goals: a dropped connection stops the checks under way, which the replay runs again Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHCyGRHdT5WYDPKVFY --- sonny/src/lookout/goals.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/sonny/src/lookout/goals.rs b/sonny/src/lookout/goals.rs index 9606dbf19..e5959a18e 100644 --- a/sonny/src/lookout/goals.rs +++ b/sonny/src/lookout/goals.rs @@ -315,7 +315,7 @@ pub struct Goals { continued: HashSet, /// Agents whose goal's check runs: a turn that ends meanwhile is /// plain news, and the check's own turn is checked in its place. - checking: HashSet, + checking: HashMap, /// Turns in a row the goal started that called no tool. idle: HashMap, } @@ -328,7 +328,7 @@ impl Goals { goals: all(store), called: HashSet::new(), continued: HashSet::new(), - checking: HashSet::new(), + checking: HashMap::new(), idle: HashMap::new(), } } @@ -338,9 +338,12 @@ impl Goals { self.checking.remove(bot); } - /// Checks under way answer a connection since dropped, never the next. + /// Checks under way answer a connection since dropped, never the next: + /// they stop, and the replay runs them again. pub fn reconnected(&mut self) { - self.checking.clear(); + for (_, check) in self.checking.drain() { + check.abort(); + } } pub fn reread(&mut self) { @@ -402,7 +405,7 @@ impl Goals { self.goals.remove(bot); return None; } - if self.checking.contains(bot) { + if self.checking.contains_key(bot) { return None; } // An interrupted or failed turn pauses it: the next turn @@ -427,11 +430,12 @@ impl Goals { ); let held = (bot.to_owned(), turn); let bot = bot.to_owned(); - self.checking.insert(bot.clone()); - tokio::spawn(async move { + let checking = bot.clone(); + let pursuit = tokio::spawn(async move { let outcome = pursue(&client, &store, &socket, &goal, turn, idle).await; let _ = done.send(Settled { bot, turn, outcome }).await; }); + self.checking.insert(checking, pursuit.abort_handle()); Some(held) } _ => None, From e16037742d2c98348617450d4dd4441f492a48d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 05:40:31 +0000 Subject: [PATCH 09/30] Goals: a stopped check takes what it started with it, and a replayed turn is not counted twice Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHCyGRHdT5WYDPKVFY --- sonny/src/lookout/goals.rs | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/sonny/src/lookout/goals.rs b/sonny/src/lookout/goals.rs index e5959a18e..e0e7fac1d 100644 --- a/sonny/src/lookout/goals.rs +++ b/sonny/src/lookout/goals.rs @@ -317,7 +317,9 @@ pub struct Goals { /// plain news, and the check's own turn is checked in its place. checking: HashMap, /// Turns in a row the goal started that called no tool. - idle: HashMap, + /// Keyed by the goal's `set_ms` and the last turn counted, so a turn + /// replayed after a reconnect is not counted again. + idle: HashMap, } impl Goals { @@ -416,12 +418,13 @@ impl Goals { let idle = match self.continued.contains(bot) && !self.called.contains(bot) { // Counted for this goal only: one set since starts at 0. true => match self.idle.get(bot) { - Some((set_ms, idle)) if *set_ms == goal.set_ms => idle + 1, + Some((set_ms, at, idle)) if *set_ms == goal.set_ms && *at == turn => *idle, + Some((set_ms, _, idle)) if *set_ms == goal.set_ms => idle + 1, _ => 1, }, false => 0, }; - self.idle.insert(bot.to_owned(), (goal.set_ms, idle)); + self.idle.insert(bot.to_owned(), (goal.set_ms, turn, idle)); let (client, done, store, socket) = ( client.clone(), done.clone(), @@ -540,7 +543,9 @@ async fn run(goal: &Goal, store: &Path, socket: &Path) -> (Option, String) Ok(child) => child, Err(error) => return (None, format!("the check did not start: {error}")), }; - let group = child.id(); + // Whatever the check started goes with it, however it ends: done, past + // its timeout, or its pursuit stopped. + let _group = child.id().map(|id| Group(id as i32)); // Read as it prints, keeping only the end, so a check that prints // without end holds a few KB, not all of it. let (stdout, stderr) = (child.stdout.take(), child.stderr.take()); @@ -553,10 +558,6 @@ async fn run(goal: &Goal, store: &Path, socket: &Path) -> (Option, String) Ok((Ok(status), stdout, stderr)) => (status, stdout, stderr), Ok((Err(error), ..)) => return (None, format!("the check failed to run: {error}")), Err(_) => { - if let Some(group) = group { - // SAFETY: a signal to the process group the check leads. - unsafe { libc::kill(-(group as i32), libc::SIGKILL) }; - } return ( None, format!("the check ran past {} s and was stopped", goal.timeout_s), @@ -574,6 +575,16 @@ async fn run(goal: &Goal, store: &Path, socket: &Path) -> (Option, String) (status.code(), tail(&text)) } +/// A check's process group, killed when dropped. +struct Group(i32); + +impl Drop for Group { + fn drop(&mut self) { + // SAFETY: a signal to the process group the check leads. + unsafe { libc::killpg(self.0, libc::SIGKILL) }; + } +} + /// The last `OUTPUT_BYTES` a pipe gave, read until it closes. async fn last(pipe: Option) -> Vec { use tokio::io::AsyncReadExt; From 025fab9506ea8bd32b94c422a22405f2152c3f73 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 05:46:16 +0000 Subject: [PATCH 10/30] Goals: the lead hears how a goal ended in the lookout's words only The reason an agent gives up for, and a check it set itself, are its own text; the lead reads them in the turn or with the goal tool. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHCyGRHdT5WYDPKVFY --- docs/LOOKOUT.md | 2 +- docs/SONNY.md | 5 ++++- sonny/src/lookout/goals.rs | 19 ++++++++++++------- sonny/src/lookout/updates.rs | 8 ++------ tests/test_served.py | 2 +- 5 files changed, 20 insertions(+), 16 deletions(-) diff --git a/docs/LOOKOUT.md b/docs/LOOKOUT.md index 767b394f0..a2188c2f5 100644 --- a/docs/LOOKOUT.md +++ b/docs/LOOKOUT.md @@ -217,7 +217,7 @@ subscriptions of their own: not silent; - a task's turns its goal (SONNY.md) held, once the goal ends: only the last, with how it ended (``turn:calc.fix/4 completed, asked by goal, - and met its goal: `cargo test` exits 0``); + and met its goal: its check exits 0``); - turns the person asked for in a task themselves, listed apart as theirs; - what was saved to or removed from the person's and the project's memory, by path, less what the subscriber saved itself: the folders its own diff --git a/docs/SONNY.md b/docs/SONNY.md index 70f3d7866..b0f221946 100644 --- a/docs/SONNY.md +++ b/docs/SONNY.md @@ -318,7 +318,10 @@ the goal started call no tool, since the agent only answers and another turn would too; the agent calls `blocked`; or someone clears it or the agent is deleted. A turn that fails or is stopped ends nothing and starts nothing: the lead hears it as any turn. Its lead hears none of the turns -the goal held until the last, told with how the goal ended (below). +the goal held until the last, told with how the goal ended (below). That +line is the lookout's own words only: the agent's reason for giving up, +and a check it set itself, are its text, which the lead reads in the turn +or with `goal`. ## Memory diff --git a/sonny/src/lookout/goals.rs b/sonny/src/lookout/goals.rs index e0e7fac1d..a3d01fe30 100644 --- a/sonny/src/lookout/goals.rs +++ b/sonny/src/lookout/goals.rs @@ -456,9 +456,12 @@ async fn pursue( turn: i64, idle: u32, ) -> Option { - if let Some(why) = &goal.blocked { + // What the lead hears is the lookout's own words only: the reason an + // agent gives and the check it may have set are its text, which the + // lead reads in the turn itself. + if goal.blocked.is_some() { end(store, goal); - return Some(format!(", and gave up its goal: {why}")); + return Some(GAVE_UP.to_owned()); } let check = format!("`{}`", goal.check); let (code, output) = run(goal, store, socket).await; @@ -466,16 +469,16 @@ async fn pursue( // given up meanwhile, it ends so. match read(store, &goal.bot) { Some(now) if now.set_ms == goal.set_ms => { - if let Some(why) = &now.blocked { + if now.blocked.is_some() { end(store, &now); - return Some(format!(", and gave up its goal: {why}")); + return Some(GAVE_UP.to_owned()); } } _ => return Some(String::new()), } if code == Some(0) { end(store, goal); - return Some(format!(", and met its goal: {check} exits 0")); + return Some(", and met its goal: its check exits 0".to_owned()); } let exits = match code { Some(code) => format!("exits {code}"), @@ -489,7 +492,7 @@ async fn pursue( if used.saturating_sub(goal.tokens_at_set) >= budget { end(store, goal); return Some(format!( - ", and stopped at its goal's budget of {budget} tokens: {check} still {exits}" + ", and stopped at its goal's budget of {budget} tokens: its check still {exits}" )); } } @@ -497,7 +500,7 @@ async fn pursue( end(store, goal); return Some(format!( ", and stopped pursuing its goal: {IDLE_TURNS} turns in a row it started called no tool, \ - and {check} still {exits}" + and its check still {exits}" )); } let prompt = format!( @@ -516,6 +519,8 @@ async fn pursue( } } +const GAVE_UP: &str = ", and gave up its goal, saying why in that turn"; + /// The check, in its own process group in the agent's folder, for up to /// its timeout: its exit code, `None` when it did not finish, and the end /// of what it printed. diff --git a/sonny/src/lookout/updates.rs b/sonny/src/lookout/updates.rs index 5f9b4a97d..40ff12726 100644 --- a/sonny/src/lookout/updates.rs +++ b/sonny/src/lookout/updates.rs @@ -1241,15 +1241,11 @@ mod tests { // The check passed: the turn, with how the goal ended. updates.goal_turn("shop.fix", 5); turn(&mut updates, "shop.fix", 5, goal.clone(), "completed"); - updates.goal_settled( - "shop.fix", - 5, - Some(", and met its goal: `make test` exits 0"), - ); + updates.goal_settled("shop.fix", 5, Some(", and met its goal: its check exits 0")); let told = updates.take(); assert_eq!( text(&told), - "- shop.fix: turn:shop.fix/5 completed, asked by goal, and met its goal: `make test` exits 0" + "- shop.fix: turn:shop.fix/5 completed, asked by goal, and met its goal: its check exits 0" ); updates.sent(told); // Cleared while its check ran: plain news. diff --git a/tests/test_served.py b/tests/test_served.py index a3ce4d296..b40752256 100644 --- a/tests/test_served.py +++ b/tests/test_served.py @@ -331,7 +331,7 @@ def test_a_goal_whose_agent_only_answers_stops(self): self.assertEqual(len(prompts), 3, prompts) self.assertIn('turn:calc.fix/3 completed, asked by goal, and stopped pursuing its goal: 2 turns in a row', note) - self.assertIn('`false` still exits 1', note) + self.assertIn('its check still exits 1', note) @unittest.skipIf(sys.platform == 'darwin', 'launchd keeps triggers on a Mac') def test_a_trigger_with_no_systemd_running_is_refused_and_says_why(self): From e849c96754043f0031121fc4059158c84755d525 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 05:55:56 +0000 Subject: [PATCH 11/30] Goals: a lookout giving way stops its checks, and only a heard task's turn waits on one Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHCyGRHdT5WYDPKVFY --- sonny/src/lookout/goals.rs | 26 +++++++++++++++++++++++++- sonny/src/lookout/mod.rs | 4 +++- sonny/src/lookout/updates.rs | 5 +++-- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/sonny/src/lookout/goals.rs b/sonny/src/lookout/goals.rs index a3d01fe30..e4139c5de 100644 --- a/sonny/src/lookout/goals.rs +++ b/sonny/src/lookout/goals.rs @@ -550,7 +550,7 @@ async fn run(goal: &Goal, store: &Path, socket: &Path) -> (Option, String) }; // Whatever the check started goes with it, however it ends: done, past // its timeout, or its pursuit stopped. - let _group = child.id().map(|id| Group(id as i32)); + let _group = child.id().map(|id| Group::new(id as i32)); // Read as it prints, keeping only the end, so a check that prints // without end holds a few KB, not all of it. let (stdout, stderr) = (child.stdout.take(), child.stderr.take()); @@ -580,13 +580,37 @@ async fn run(goal: &Goal, store: &Path, socket: &Path) -> (Option, String) (status.code(), tail(&text)) } +/// The process groups of the checks running now. +static GROUPS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +/// Stop every check under way: the lookout is giving way, and the one +/// that takes over runs them again. +pub fn stop() { + for group in GROUPS.lock().map(|g| g.clone()).unwrap_or_default() { + // SAFETY: a signal to a process group a check leads. + unsafe { libc::killpg(group, libc::SIGKILL) }; + } +} + /// A check's process group, killed when dropped. struct Group(i32); +impl Group { + fn new(id: i32) -> Group { + if let Ok(mut groups) = GROUPS.lock() { + groups.push(id); + } + Group(id) + } +} + impl Drop for Group { fn drop(&mut self) { // SAFETY: a signal to the process group the check leads. unsafe { libc::killpg(self.0, libc::SIGKILL) }; + if let Ok(mut groups) = GROUPS.lock() { + groups.retain(|g| *g != self.0); + } } } diff --git a/sonny/src/lookout/mod.rs b/sonny/src/lookout/mod.rs index f366f7f02..68ffb6506 100644 --- a/sonny/src/lookout/mod.rs +++ b/sonny/src/lookout/mod.rs @@ -1261,6 +1261,7 @@ async fn serve(settings: Settings) -> Result<(), String> { term.recv().await; crate::served::stop(); sources::stop(); + goals::stop(); std::process::exit(0); }); } @@ -2455,7 +2456,8 @@ impl Lookout { status.push_str(&format!(", after {denied} calls denied by the approver")); } // A turn its goal's check holds is not judged: the lead - // hears how the goal ended in its task updates. + // hears how the goal ended in its task updates, which hold + // only the turns of tasks it hears of. if !self.updates.goal_held(bot, turn) { self.due(bot, turn, Kind::Ended, None, Some(status), checkpoint); } diff --git a/sonny/src/lookout/updates.rs b/sonny/src/lookout/updates.rs index 40ff12726..338f641ba 100644 --- a/sonny/src/lookout/updates.rs +++ b/sonny/src/lookout/updates.rs @@ -600,9 +600,10 @@ impl Updates { } /// A turn about to end whose goal's check runs once it does: its end - /// waits for `goal_settled`. + /// waits for `goal_settled`, if it is the turn of a task whose updates + /// the lead hears; another's end is judged as any turn's. pub fn goal_turn(&mut self, name: &str, turn: i64) { - if self.live { + if self.live && self.task(name) { self.goaled.insert((name.to_owned(), turn)); } } From 09f8d934ee306fcd2c6a455c9077efb57a72dfd9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 06:06:39 +0000 Subject: [PATCH 12/30] Goals: a check runs where its turn worked, and how a goal ended survives a later turn's news Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHCyGRHdT5WYDPKVFY --- sonny/src/lookout/goals.rs | 24 ++++++++++++++-- sonny/src/lookout/updates.rs | 55 ++++++++++++++++++++++++++---------- 2 files changed, 62 insertions(+), 17 deletions(-) diff --git a/sonny/src/lookout/goals.rs b/sonny/src/lookout/goals.rs index e4139c5de..1e93aacd1 100644 --- a/sonny/src/lookout/goals.rs +++ b/sonny/src/lookout/goals.rs @@ -320,6 +320,9 @@ pub struct Goals { /// Keyed by the goal's `set_ms` and the last turn counted, so a turn /// replayed after a reconnect is not counted again. idle: HashMap, + /// The folder each turn was sent to: a later turn can move its agent, + /// and the check runs where the turn worked. + worked: HashMap<(String, i64), PathBuf>, } impl Goals { @@ -332,6 +335,7 @@ impl Goals { continued: HashSet::new(), checking: HashMap::new(), idle: HashMap::new(), + worked: HashMap::new(), } } @@ -367,6 +371,14 @@ impl Goals { ) -> Option<(String, i64)> { let bot = event["bot"].as_str()?; let data = &event["data"]; + if let (Some("accepted" | "queued"), Some(turn), Some(workspace)) = ( + event["event"].as_str(), + event["turn"].as_i64(), + data["workspace"].as_str(), + ) { + self.worked + .insert((bot.to_owned(), turn), PathBuf::from(workspace)); + } match event["event"].as_str()? { "accepted" => { self.called.remove(bot); @@ -385,13 +397,18 @@ impl Goals { } // A new agent under the name has no goal of the last one's. "deleted" => { + self.worked.retain(|(name, _), _| name != bot); if let Some(goal) = self.goals.remove(bot) { end(&self.store, &goal); } None } - "turn_finished" if live => { + "turn_finished" => { let turn = event["turn"].as_i64()?; + let worked = self.worked.remove(&(bot.to_owned(), turn)); + if !live { + return None; + } let status = data["status"].as_str().unwrap_or_default(); // Its goal as it is now, which its lead may have set or // cleared in the meantime. @@ -399,7 +416,10 @@ impl Goals { Some(goal) => self.goals.insert(bot.to_owned(), goal), None => self.goals.remove(bot), }; - let goal = self.goals.get(bot)?.clone(); + let mut goal = self.goals.get(bot)?.clone(); + if let Some(worked) = worked { + goal.workspace = worked; + } // Left by an agent deleted while the lookout was away, whose // name another has since. if bot_id.is_some_and(|id| id != goal.bot_id) { diff --git a/sonny/src/lookout/updates.rs b/sonny/src/lookout/updates.rs index 338f641ba..676a31de8 100644 --- a/sonny/src/lookout/updates.rs +++ b/sonny/src/lookout/updates.rs @@ -81,11 +81,18 @@ impl News { fn merge(&mut self, news: News) { fn one(had: Option, new: Option) -> Option { match (had, new) { - (Some(a), Some(t)) => Some(Turn { - first: a.first.min(t.first), - count: a.count + t.count, - ..if t.turn >= a.turn { t } else { a } - }), + (Some(a), Some(t)) => { + let (first, count) = (a.first.min(t.first), a.count + t.count); + let (newer, older) = if t.turn >= a.turn { (t, a) } else { (a, t) }; + // How a goal ended stays news when a later turn joins it. + let goal = newer.goal.clone().or(older.goal); + Some(Turn { + first, + count, + goal, + ..newer + }) + } (a, t) => a.or(t), } } @@ -513,6 +520,21 @@ impl Updates { from: Option, which: Option, origin: Option, + ) { + self.tell_ended(name, turn, status, from, which, origin, None); + } + + /// `tell`, with how the task's goal ended with the turn. + #[allow(clippy::too_many_arguments)] + fn tell_ended( + &mut self, + name: &str, + turn: i64, + status: &str, + from: Option, + which: Option, + origin: Option, + goal: Option, ) { // A note while the turn still runs leaves its resolve for the last. let resolved = ![APPROVAL, QUIET].contains(&status) @@ -545,7 +567,7 @@ impl Updates { count: 1, which, resolved, - goal: None, + goal, }; news.merge(match theirs { true => News { @@ -630,15 +652,8 @@ impl Updates { let Some(outcome) = outcome else { return; }; - self.tell(name, turn, &status, from, None, origin); - let news = (self.tasks.iter_mut()).find(|(task, _)| task == name); - if let Some((_, news)) = news { - for told in [&mut news.act, &mut news.theirs].into_iter().flatten() { - if told.turn == turn && !outcome.is_empty() { - told.goal = Some(outcome.to_owned()); - } - } - } + let goal = Some(outcome.to_owned()).filter(|outcome| !outcome.is_empty()); + self.tell_ended(name, turn, &status, from, None, origin, goal); } /// The lead read these turns with its wait tool: what they said is no @@ -1249,6 +1264,16 @@ mod tests { "- shop.fix: turn:shop.fix/5 completed, asked by goal, and met its goal: its check exits 0" ); updates.sent(told); + // How it ended stays news when a later turn joins it, whichever + // settles first. + updates.goal_turn("shop.fix", 6); + turn(&mut updates, "shop.fix", 6, goal.clone(), "completed"); + turn(&mut updates, "shop.fix", 7, lead.clone(), "completed"); + updates.goal_settled("shop.fix", 6, Some(", and met its goal: its check exits 0")); + turn(&mut updates, "shop.fix", 8, lead.clone(), "completed"); + let told = updates.take(); + assert!(text(&told).contains("met its goal"), "{}", text(&told)); + updates.sent(told); // Cleared while its check ran: plain news. updates.goal_turn("shop.docs", 2); turn(&mut updates, "shop.docs", 2, lead, "completed"); From 81aa809a165d2487f083ffb615c4e64824ca1d30 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 06:17:19 +0000 Subject: [PATCH 13/30] Goals: a check goes without every provider key an agent's shell does Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHCyGRHdT5WYDPKVFY --- docs/SONNY.md | 2 +- sonny/src/lookout/goals.rs | 14 ++++++-------- sonny/src/task.rs | 2 +- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/docs/SONNY.md b/docs/SONNY.md index b0f221946..0b39c4a43 100644 --- a/docs/SONNY.md +++ b/docs/SONNY.md @@ -296,7 +296,7 @@ give its own up, `clear` NAME, or nothing to list them. The check is a shell command (`cargo test`, `make lint`, `test -z "$(git status --short)"`) that exits 0 once done; the lookout runs it in the agent's folder, as the person and outside any sandbox like a trigger's `if`, though without the -provider keys in `~/.agent/env`, so `set` waits for a verdict wherever the +provider keys an agent's shell goes without, so `set` waits for a verdict wherever the agent's other calls do (`/approvals`). Each goal is `STORE.lookout/goals/BOT.json`; setting the same goal again changes nothing. diff --git a/sonny/src/lookout/goals.rs b/sonny/src/lookout/goals.rs index 1e93aacd1..f966602ba 100644 --- a/sonny/src/lookout/goals.rs +++ b/sonny/src/lookout/goals.rs @@ -13,8 +13,8 @@ //! its `goal` tool writes (a lead's for its tasks too) and the lookout //! reads again when a call changed it, and removes when the goal ends. A //! check runs as the person, outside every sandbox, as a trigger's `if` -//! does, without the provider keys in ~/.agent/env: setting one waits for -//! a verdict where the agent's other calls do. +//! does, without the provider keys an agent's shell goes without: +//! setting one waits for a verdict where the agent's other calls do. use agent_client::Client; use serde_json::{Value, json}; use std::collections::{HashMap, HashSet}; @@ -557,12 +557,10 @@ async fn run(goal: &Goal, store: &Path, socket: &Path) -> (Option, String) .stderr(std::process::Stdio::piped()) .process_group(0) .kill_on_drop(true); - // The provider keys in ~/.agent/env are the daemon's: no agent's own - // command has them, nor does its check. - if let Some(file) = agent_client::env_file::path() { - for (key, _) in agent_client::env_file::read(&file).unwrap_or_default() { - command.env_remove(key); - } + // The provider keys are the daemon's: no agent's own command has them, + // nor does its check. + for key in crate::task::kept_keys() { + command.env_remove(key); } let mut child = match command.spawn() { Ok(child) => child, diff --git a/sonny/src/task.rs b/sonny/src/task.rs index d79f7b2f4..ffb5d9764 100644 --- a/sonny/src/task.rs +++ b/sonny/src/task.rs @@ -641,7 +641,7 @@ fn within(copy: &Path, prefix: &Path) -> PathBuf { /// The provider credentials this process may hold, as the daemon keeps /// them from a bot's shell: the well-known keys, Bedrock's, the key a /// provider in `AGENT_PROVIDER` names, and what `~/.agent/env` sets. -fn kept_keys() -> Vec { +pub(crate) fn kept_keys() -> Vec { let mut keys: Vec = [ "ANTHROPIC_API_KEY", "OPENAI_API_KEY", From 82305f6711ba9c45660e8e50c45c4935fa9a6103 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 06:28:35 +0000 Subject: [PATCH 14/30] Goals: tests for a goal its lead sets, a turn that ends mid-check, and a lookout that gives way mid-check Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHCyGRHdT5WYDPKVFY --- tests/test_served.py | 82 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 74 insertions(+), 8 deletions(-) diff --git a/tests/test_served.py b/tests/test_served.py index b40752256..0e437d5d5 100644 --- a/tests/test_served.py +++ b/tests/test_served.py @@ -293,19 +293,33 @@ def note_for(self, bot, having, timeout=30): time.sleep(.2) self.fail(f'{bot} heard nothing saying {having!r}') - def goal_run(self, check): - """calc.fix sets itself a goal with `check`, watched by a lead that - hears its turns: the lead's note once the goal ends, and the - prompts of calc.fix's turns.""" - self.daemon.request('create', bot='calc.lead', workspace=str(self.path), model='openai/synthetic-model', - instructions='Lead.') + def goal_watch(self): + """calc.fix, calc.lead's task with the goal tool, watched by its + lead, which hears its turns; the lookout serving them.""" + lead = self.daemon.request('create', bot='calc.lead', workspace=str(self.path), + model='openai/synthetic-model', instructions='Lead.', + declare=[declared('goal')])['result'] self.daemon.request('create', bot='calc.fix', workspace=str(self.path), model='openai/synthetic-model', - instructions='Task.', declare=[declared('goal')]) + instructions='Task.', declare=[declared('goal')], created_by='calc.lead', + created_by_id=lead['bot_id']) folder = self.path / 'state.sqlite.lookout' folder.mkdir(exist_ok=True) watch = {'name': 'tasks', 'tasks_of': [], 'bots': ['calc.fix'], 'on': ['said', 'ended'], 'updates': True} (folder / 'calc.lead.json').write_text(json.dumps({'subscriptions': [watch]})) - self.lookout() + return self.lookout() + + def lines_of(self, path, at_least, timeout=30): + """The lines of `path` once it has `at_least` of them.""" + deadline = time.monotonic() + timeout + while not path.exists() or len(path.read_text().splitlines()) < at_least: + self.assertLess(time.monotonic(), deadline, f'{path.name} has fewer than {at_least} lines') + time.sleep(.05) + return path.read_text().splitlines() + + def goal_run(self, check): + """calc.fix sets itself a goal with `check`: the lead's note once + the goal ends, and the prompts of calc.fix's turns.""" + self.goal_watch() set_ = json.loads(self.call('calc.fix', 'goal', {'set': {'done_when': 'the flag exists', 'check': check}})) self.assertIn('goal', set_, set_) self.assertEqual(set_['goal']['check'], check) @@ -333,6 +347,58 @@ def test_a_goal_whose_agent_only_answers_stops(self): note) self.assertIn('its check still exits 1', note) + def test_a_goal_its_lead_sets_counts_from_the_task_turn_that_ends_next(self): + self.goal_watch() + ran = self.path / 'ran' + set_ = json.loads(self.call('calc.lead', 'goal', {'set': {'bot': 'calc.fix', 'done_when': 'it ran', + 'check': f'echo . >> {ran}'}})) + self.assertEqual(set_['goal']['bot'], 'calc.fix', set_) + # Ends at once, well before the lookout reads its goals again. + turn = self.daemon.request('submit', bot='calc.fix', request_id='work', prompt='Work.')['result']['turn'] + self.assertEqual(self.daemon.finished(turn)['data']['status'], 'completed') + note = self.note_for('calc.lead', 'its goal') + self.assertIn(f'- calc.fix: turn:calc.fix/{turn} completed, asked by the person, and met its goal:', note) + self.assertEqual(len(ran.read_text().splitlines()), 1) + + def test_a_turn_that_ends_while_its_goal_is_checked_starts_no_other_check(self): + self.goal_watch() + runs = self.path / 'runs' + # Slow, and passing the second time it runs. + check = f'echo . >> {runs}; sleep 2; test $(wc -l < {runs}) -ge 2' + self.call('calc.fix', 'goal', {'set': {'done_when': 'it ran twice', 'check': check}}) + self.lines_of(runs, 1) + # The person writes in while it runs; the turn ends before it does. + turn = self.daemon.request('submit', bot='calc.fix', request_id='more', prompt='More.')['result']['turn'] + self.assertEqual(self.daemon.finished(turn)['data']['status'], 'completed') + note = self.note_for('calc.lead', 'its goal') + self.assertIn('asked by goal, and met its goal:', note) + # One check for the turn that set it, one for the goal's own turn. + self.assertEqual(len(runs.read_text().splitlines()), 2) + + def test_a_goal_checked_when_its_lookout_gives_way_is_checked_by_the_next(self): + old = self.goal_watch() + runs = self.path / 'runs' + check = f'echo . >> {runs}; sleep 30; exit 1' + self.call('calc.fix', 'goal', {'set': {'done_when': 'it ran again', 'check': check}}) + self.lines_of(runs, 1) + # The cursor it keeps stays before the turn's end, which waits on + # the check. + [ended] = [e['cursor'] for e in self.daemon.request('events', bot='calc.fix', after=0, limit=256) + ['result']['events'] if e['event'] == 'turn_finished'] + saved = self.lines_of(self.path / 'state.sqlite.lookout.cursor', 1) + self.assertLess(int(saved[0]), ended) + # Another build's lookout takes over, stopping this one's check. + (self.path / 'state.sqlite.lookout.sonny').write_text('0.0.1 1') + (self.path / 'state.sqlite.lookout/goals/calc.fix.json').write_text(json.dumps(dict( + json.loads((self.path / 'state.sqlite.lookout/goals/calc.fix.json').read_text()), + check=f'echo . >> {runs}'))) + self.lookout() + old.wait(timeout=10) + # The turn that set it is checked again, and now passes. + note = self.note_for('calc.lead', 'its goal') + self.assertIn('- calc.fix: turn:calc.fix/1 completed, asked by the person, and met its goal:', note) + self.assertEqual(len(self.lines_of(runs, 2)), 2) + @unittest.skipIf(sys.platform == 'darwin', 'launchd keeps triggers on a Mac') def test_a_trigger_with_no_systemd_running_is_refused_and_says_why(self): self.daemon.request('create', bot='calc.lead', workspace=str(self.path), model='openai/synthetic-model', From 13863f464213f30dc399c8081a424a6989efe985 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 06:33:14 +0000 Subject: [PATCH 15/30] Goals: a turn the lead's wait read still says how its goal ended Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHCyGRHdT5WYDPKVFY --- sonny/src/lookout/updates.rs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/sonny/src/lookout/updates.rs b/sonny/src/lookout/updates.rs index 676a31de8..4170de7ad 100644 --- a/sonny/src/lookout/updates.rs +++ b/sonny/src/lookout/updates.rs @@ -671,10 +671,11 @@ impl Updates { } } self.tasks.retain(|(_, news)| !news.empty()); - self.forwarding - .retain(|(name, turn), _| !handles.contains(&format!("turn:{name}/{turn}"))); - self.goaled - .retain(|(name, turn)| !handles.contains(&format!("turn:{name}/{turn}"))); + // A turn its goal's check holds stays: the wait read how it ended, + // not how its goal did. + self.forwarding.retain(|key, _| { + self.goaled.contains(key) || !handles.contains(&format!("turn:{}/{}", key.0, key.1)) + }); } /// Whether there is news for the lead: its own, or `held` other news. @@ -1274,6 +1275,15 @@ mod tests { let told = updates.take(); assert!(text(&told).contains("met its goal"), "{}", text(&told)); updates.sent(told); + // The lead's wait read the turn while its check ran: how the goal + // ended is still news. + updates.goal_turn("shop.fix", 9); + turn(&mut updates, "shop.fix", 9, goal.clone(), "completed"); + updates.read(&["turn:shop.fix/9".to_owned()].into()); + updates.goal_settled("shop.fix", 9, Some(", and met its goal: its check exits 0")); + let told = updates.take(); + assert!(text(&told).contains("turn:shop.fix/9 completed, asked by goal, and met its goal")); + updates.sent(told); // Cleared while its check ran: plain news. updates.goal_turn("shop.docs", 2); turn(&mut updates, "shop.docs", 2, lead, "completed"); From acf562c1570c2848442c23b0e8092625f9d01baa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 07:20:31 +0000 Subject: [PATCH 16/30] Goals: the held-turn test asks due() as main's lookout has it Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHCyGRHdT5WYDPKVFY --- sonny/src/lookout/updates.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sonny/src/lookout/updates.rs b/sonny/src/lookout/updates.rs index 1034f441b..956150630 100644 --- a/sonny/src/lookout/updates.rs +++ b/sonny/src/lookout/updates.rs @@ -1354,9 +1354,9 @@ mod tests { // The check failed and the task works again: no news. updates.goal_turn("shop.fix", 4); turn(&mut updates, "shop.fix", 4, lead.clone(), "completed"); - assert!(!updates.due(false), "held for its check"); + assert!(!updates.due(), "held for its check"); updates.goal_settled("shop.fix", 4, None); - assert!(!updates.due(false), "it works again"); + assert!(!updates.due(), "it works again"); // The check passed: the turn, with how the goal ended. updates.goal_turn("shop.fix", 5); turn(&mut updates, "shop.fix", 5, goal.clone(), "completed"); From a9f51b94f394f5fb8c6ebf8269078e94f37ae74b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 07:27:36 +0000 Subject: [PATCH 17/30] Goals: a check under way keeps the replay cursor before its turn, watched or not Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHCyGRHdT5WYDPKVFY --- sonny/src/lookout/goals.rs | 15 ++++++++++++--- sonny/src/lookout/mod.rs | 11 +++++++---- tests/test_served.py | 19 +++++++++++++++++++ 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/sonny/src/lookout/goals.rs b/sonny/src/lookout/goals.rs index f966602ba..fba0c7c94 100644 --- a/sonny/src/lookout/goals.rs +++ b/sonny/src/lookout/goals.rs @@ -315,7 +315,8 @@ pub struct Goals { continued: HashSet, /// Agents whose goal's check runs: a turn that ends meanwhile is /// plain news, and the check's own turn is checked in its place. - checking: HashMap, + /// Each with the cursor of the turn's end, which a restart replays. + checking: HashMap)>, /// Turns in a row the goal started that called no tool. /// Keyed by the goal's `set_ms` and the last turn counted, so a turn /// replayed after a reconnect is not counted again. @@ -347,11 +348,18 @@ impl Goals { /// Checks under way answer a connection since dropped, never the next: /// they stop, and the replay runs them again. pub fn reconnected(&mut self) { - for (_, check) in self.checking.drain() { + for (_, (check, _)) in self.checking.drain() { check.abort(); } } + /// The end of the oldest turn whose check is under way: what the + /// lookout keeps for the next one is from before it, so a restart, or + /// one with no subscriber to hold the turn, runs its check again. + pub fn floor(&self) -> Option { + self.checking.values().filter_map(|(_, at)| *at).min() + } + pub fn reread(&mut self) { self.goals = all(&self.store); self.idle.retain(|bot, _| self.goals.contains_key(bot)); @@ -458,7 +466,8 @@ impl Goals { let outcome = pursue(&client, &store, &socket, &goal, turn, idle).await; let _ = done.send(Settled { bot, turn, outcome }).await; }); - self.checking.insert(checking, pursuit.abort_handle()); + self.checking + .insert(checking, (pursuit.abort_handle(), event["cursor"].as_i64())); Some(held) } _ => None, diff --git a/sonny/src/lookout/mod.rs b/sonny/src/lookout/mod.rs index 790436848..6bf35f2f5 100644 --- a/sonny/src/lookout/mod.rs +++ b/sonny/src/lookout/mod.rs @@ -965,16 +965,19 @@ impl Service { } } - /// Up to just before the oldest news a lookout has not told, which a - /// restart or a reconnect takes up again: news only in memory, or a - /// read a dropped connection cut short, is told again, not lost. + /// Up to just before the oldest news a lookout has not told, or the + /// oldest turn whose goal's check runs, which a restart or a reconnect + /// takes up again: news only in memory, or a read or check a dropped + /// connection cut short, is told or run again, not lost. fn floor(&mut self) -> Option { for lookout in self.lookouts.values_mut() { if !lookout.untold() { lookout.from = None; } } - let floor = self.lookouts.values().filter_map(|l| l.from).min(); + let floor = (self.lookouts.values().filter_map(|l| l.from)) + .chain(self.goals.floor()) + .min(); match (self.cursor, floor) { (Some(at), Some(floor)) => Some(at.min(floor - 1)), (at, _) => at, diff --git a/tests/test_served.py b/tests/test_served.py index 50c90f013..b00acb88e 100644 --- a/tests/test_served.py +++ b/tests/test_served.py @@ -408,6 +408,25 @@ def test_a_goal_checked_when_its_lookout_gives_way_is_checked_by_the_next(self): self.assertIn('- calc.fix: turn:calc.fix/1 completed, asked by the person, and met its goal:', note) self.assertEqual(len(self.lines_of(runs, 2)), 2) + def test_a_goal_no_one_watches_is_checked_again_after_a_restart(self): + self.daemon.request('create', bot='calc.lead', workspace=str(self.path), model='openai/synthetic-model', + instructions='Lead.', declare=[declared('goal')]) + old = self.lookout() + runs = self.path / 'runs' + self.call('calc.lead', 'goal', {'set': {'done_when': 'it ran again', 'check': f'echo . >> {runs}; sleep 30'}}) + self.lines_of(runs, 1) + [ended] = [e['cursor'] for e in self.daemon.request('events', bot='calc.lead', after=0, limit=256) + ['result']['events'] if e['event'] == 'turn_finished'] + # Past a save of the cursor, which stays before the turn's end + # though no subscriber holds it. + time.sleep(1.5) + saved = self.lines_of(self.path / 'state.sqlite.lookout.cursor', 1) + self.assertLess(int(saved[0]), ended) + old.kill() + old.wait(timeout=10) + self.lookout() + self.assertEqual(len(self.lines_of(runs, 2)), 2) + @unittest.skipIf(sys.platform == 'darwin', 'launchd keeps triggers on a Mac') def test_a_trigger_with_no_systemd_running_is_refused_and_says_why(self): self.daemon.request('create', bot='calc.lead', workspace=str(self.path), model='openai/synthetic-model', From 968a2e527dde3898d90b2d3578ecfa05825b78d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 07:28:21 +0000 Subject: [PATCH 18/30] Goals: a wait that reads a turn leaves how its goal ended to tell Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHCyGRHdT5WYDPKVFY --- sonny/src/lookout/updates.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/sonny/src/lookout/updates.rs b/sonny/src/lookout/updates.rs index 956150630..ef7aadc11 100644 --- a/sonny/src/lookout/updates.rs +++ b/sonny/src/lookout/updates.rs @@ -740,9 +740,11 @@ impl Updates { pub fn read(&mut self, handles: &HashSet) { for (name, news) in &mut self.tasks { for kind in [&mut news.act, &mut news.theirs] { + // How a goal ended is no part of what the wait read. if kind.as_ref().is_some_and(|t| { t.status != APPROVAL && t.status != QUIET + && t.goal.is_none() && handles.contains(&format!("turn:{name}/{}", t.turn)) }) { *kind = None; @@ -1386,6 +1388,20 @@ mod tests { let told = updates.take(); assert!(text(&told).contains("turn:shop.fix/9 completed, asked by goal, and met its goal")); updates.sent(told); + // Or read it once its check had said. + updates.goal_turn("shop.fix", 10); + turn(&mut updates, "shop.fix", 10, goal.clone(), "completed"); + updates.goal_settled( + "shop.fix", + 10, + Some(", and met its goal: its check exits 0"), + ); + updates.read(&["turn:shop.fix/10".to_owned()].into()); + let told = updates.take(); + assert!( + text(&told).contains("turn:shop.fix/10 completed, asked by goal, and met its goal") + ); + updates.sent(told); // Cleared while its check ran: plain news. updates.goal_turn("shop.docs", 2); turn(&mut updates, "shop.docs", 2, lead, "completed"); From 48a7962ddec633eb7c3295fe48ad29d9300683b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 07:32:25 +0000 Subject: [PATCH 19/30] Goals: a trigger passing a turn's answer on leaves how its goal ended to tell Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHCyGRHdT5WYDPKVFY --- sonny/src/lookout/updates.rs | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/sonny/src/lookout/updates.rs b/sonny/src/lookout/updates.rs index ef7aadc11..3f1bfef08 100644 --- a/sonny/src/lookout/updates.rs +++ b/sonny/src/lookout/updates.rs @@ -355,7 +355,19 @@ impl Updates { if let (Some(task), Some(of)) = (data["from"]["bot"].as_str(), data["from"]["turn"].as_i64()) { - self.forwarding.remove(&(task.to_owned(), of)); + let key = (task.to_owned(), of); + // How its goal ends is still to tell: only its + // answer went on. + match self.goaled.contains(&key) { + true => { + if let Some(forward) = self.forwarding.get_mut(&key) { + forward.reply = None; + } + } + false => { + self.forwarding.remove(&key); + } + } } } else if let Some(origin) = data["origin"].as_str() { self.origin.insert(key(turn), origin.to_owned()); @@ -1402,6 +1414,28 @@ mod tests { text(&told).contains("turn:shop.fix/10 completed, asked by goal, and met its goal") ); updates.sent(told); + // A trigger passed its answer on while its check ran: how the goal + // ended is still news, without the answer. + updates.goal_turn("shop.fix", 11); + let fire = json!({"origin": "goal", "request_id": "trigger_t1.to.1"}); + turn(&mut updates, "shop.fix", 11, fire, "completed"); + updates.replied("shop.fix", 11, "passed on"); + let passed = json!({"from": {"bot": "shop.fix", "turn": 11}}); + updates.event( + &ev("shop.lead", Some(10), "accepted", passed), + Instant::now(), + ); + updates.goal_settled( + "shop.fix", + 11, + Some(", and met its goal: its check exits 0"), + ); + let told = text(&updates.take()); + assert!(told.contains("turn:shop.fix/11 completed"), "{told}"); + assert!( + told.contains("met its goal") && !told.contains("passed on"), + "{told}" + ); // Cleared while its check ran: plain news. updates.goal_turn("shop.docs", 2); turn(&mut updates, "shop.docs", 2, lead, "completed"); From 107d22e194e051d12929feef1ebc587d89a57844 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 07:36:38 +0000 Subject: [PATCH 20/30] Goals: deleting an agent stops its check Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHCyGRHdT5WYDPKVFY --- sonny/src/lookout/goals.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sonny/src/lookout/goals.rs b/sonny/src/lookout/goals.rs index fba0c7c94..c802bd764 100644 --- a/sonny/src/lookout/goals.rs +++ b/sonny/src/lookout/goals.rs @@ -406,6 +406,9 @@ impl Goals { // A new agent under the name has no goal of the last one's. "deleted" => { self.worked.retain(|(name, _), _| name != bot); + if let Some((check, _)) = self.checking.remove(bot) { + check.abort(); + } if let Some(goal) = self.goals.remove(bot) { end(&self.store, &goal); } From e7083fd5b1762d5157647e8ffd920c81e0ceab89 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 07:41:42 +0000 Subject: [PATCH 21/30] Goals: a goal set again while its last one is checked is checked afresh Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHCyGRHdT5WYDPKVFY --- sonny/src/lookout/goals.rs | 61 +++++++++++++++++++++++++++++--------- sonny/src/lookout/mod.rs | 2 +- 2 files changed, 48 insertions(+), 15 deletions(-) diff --git a/sonny/src/lookout/goals.rs b/sonny/src/lookout/goals.rs index c802bd764..64f3797c0 100644 --- a/sonny/src/lookout/goals.rs +++ b/sonny/src/lookout/goals.rs @@ -315,8 +315,7 @@ pub struct Goals { continued: HashSet, /// Agents whose goal's check runs: a turn that ends meanwhile is /// plain news, and the check's own turn is checked in its place. - /// Each with the cursor of the turn's end, which a restart replays. - checking: HashMap)>, + checking: HashMap, /// Turns in a row the goal started that called no tool. /// Keyed by the goal's `set_ms` and the last turn counted, so a turn /// replayed after a reconnect is not counted again. @@ -326,6 +325,16 @@ pub struct Goals { worked: HashMap<(String, i64), PathBuf>, } +/// A goal's check under way. +struct Check { + pursuit: tokio::task::AbortHandle, + /// The cursor of the turn's end, which a restart replays. + at: Option, + turn: i64, + /// The goal checked: one set since is checked afresh. + set_ms: u64, +} + impl Goals { pub fn new(store: &Path, socket: &Path) -> Goals { Goals { @@ -340,16 +349,22 @@ impl Goals { } } - /// The check of `bot` said how its goal stands. - pub fn settled(&mut self, bot: &str) { - self.checking.remove(bot); + /// The check of `bot`'s `turn` said how its goal stands. + pub fn settled(&mut self, bot: &str, turn: i64) { + if self + .checking + .get(bot) + .is_some_and(|check| check.turn == turn) + { + self.checking.remove(bot); + } } /// Checks under way answer a connection since dropped, never the next: /// they stop, and the replay runs them again. pub fn reconnected(&mut self) { - for (_, (check, _)) in self.checking.drain() { - check.abort(); + for (_, check) in self.checking.drain() { + check.pursuit.abort(); } } @@ -357,7 +372,7 @@ impl Goals { /// lookout keeps for the next one is from before it, so a restart, or /// one with no subscriber to hold the turn, runs its check again. pub fn floor(&self) -> Option { - self.checking.values().filter_map(|(_, at)| *at).min() + self.checking.values().filter_map(|check| check.at).min() } pub fn reread(&mut self) { @@ -406,8 +421,8 @@ impl Goals { // A new agent under the name has no goal of the last one's. "deleted" => { self.worked.retain(|(name, _), _| name != bot); - if let Some((check, _)) = self.checking.remove(bot) { - check.abort(); + if let Some(check) = self.checking.remove(bot) { + check.pursuit.abort(); } if let Some(goal) = self.goals.remove(bot) { end(&self.store, &goal); @@ -438,8 +453,20 @@ impl Goals { self.goals.remove(bot); return None; } - if self.checking.contains_key(bot) { - return None; + match self.checking.get(bot) { + Some(check) if check.set_ms == goal.set_ms => return None, + // The goal it checks was set again: its turn is plain + // news, and this one is checked for the new goal. + Some(_) => { + let check = self.checking.remove(bot)?; + check.pursuit.abort(); + let _ = done.try_send(Settled { + bot: bot.to_owned(), + turn: check.turn, + outcome: Some(String::new()), + }); + } + None => {} } // An interrupted or failed turn pauses it: the next turn // that completes is checked. A steer's is part of another. @@ -463,14 +490,20 @@ impl Goals { self.socket.clone(), ); let held = (bot.to_owned(), turn); + let set_ms = goal.set_ms; let bot = bot.to_owned(); let checking = bot.clone(); let pursuit = tokio::spawn(async move { let outcome = pursue(&client, &store, &socket, &goal, turn, idle).await; let _ = done.send(Settled { bot, turn, outcome }).await; }); - self.checking - .insert(checking, (pursuit.abort_handle(), event["cursor"].as_i64())); + let check = Check { + pursuit: pursuit.abort_handle(), + at: event["cursor"].as_i64(), + turn, + set_ms, + }; + self.checking.insert(checking, check); Some(held) } _ => None, diff --git a/sonny/src/lookout/mod.rs b/sonny/src/lookout/mod.rs index 6bf35f2f5..457cce671 100644 --- a/sonny/src/lookout/mod.rs +++ b/sonny/src/lookout/mod.rs @@ -713,7 +713,7 @@ impl Service { } } Some(goals::Settled { bot, turn, outcome }) = settled.recv() => { - self.goals.settled(&bot); + self.goals.settled(&bot, turn); for lookout in self.lookouts.values_mut() { lookout.updates.goal_settled(&bot, turn, outcome.as_deref()); } From b3c9ede63b59d325a34b551b41a9dd90d35fe082 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 07:48:08 +0000 Subject: [PATCH 22/30] Goals: how a goal ended is news even while the lead waits on its turn Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHCyGRHdT5WYDPKVFY --- sonny/src/lookout/updates.rs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/sonny/src/lookout/updates.rs b/sonny/src/lookout/updates.rs index 3f1bfef08..fda2a9351 100644 --- a/sonny/src/lookout/updates.rs +++ b/sonny/src/lookout/updates.rs @@ -620,7 +620,8 @@ impl Updates { return; } let handle = format!("turn:{name}/{turn}"); - if self.bots[&self.lead].waiting_on.contains(&handle) { + // A wait returns the turn, never how its goal ended. + if goal.is_none() && self.bots[&self.lead].waiting_on.contains(&handle) { return; } let by = match status { @@ -1414,6 +1415,31 @@ mod tests { text(&told).contains("turn:shop.fix/10 completed, asked by goal, and met its goal") ); updates.sent(told); + // Its check said while the lead's wait still held the turn. + updates.goal_turn("shop.fix", 12); + turn(&mut updates, "shop.fix", 12, goal.clone(), "completed"); + updates + .bots + .get_mut("shop.lead") + .unwrap() + .waiting_on + .push("turn:shop.fix/12".to_owned()); + updates.goal_settled( + "shop.fix", + 12, + Some(", and met its goal: its check exits 0"), + ); + updates + .bots + .get_mut("shop.lead") + .unwrap() + .waiting_on + .clear(); + let told = updates.take(); + assert!( + text(&told).contains("turn:shop.fix/12 completed, asked by goal, and met its goal") + ); + updates.sent(told); // A trigger passed its answer on while its check ran: how the goal // ended is still news, without the answer. updates.goal_turn("shop.fix", 11); From 7c241298e8c7ff7b6c7c48037fca21aa4a21d71f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 08:35:01 +0000 Subject: [PATCH 23/30] Daemon companions: commands the daemon keeps running for as long as it runs `agent companion add/rm/ls` keeps a command in the store that every daemon on it starts once it listens, restarts with backoff and stops at shutdown. Sonny's lookout and approver now run as companions, so they share the daemon's life cycle whether or not a window is open. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHCyGRHdT5WYDPKVFY --- bench/socket_client.py | 4 +- docs/CLI.md | 36 +++- docs/LOOKOUT.md | 18 +- sonny/README.md | 11 +- sonny/src/acp.rs | 4 +- sonny/src/lookout/mod.rs | 54 ++++- sonny/src/main.rs | 126 ++++++------ src/cli.rs | 16 +- src/client.rs | 52 +++++ src/server/companions.rs | 435 +++++++++++++++++++++++++++++++++++++++ src/server/mod.rs | 96 ++++++++- src/server/session.rs | 12 +- src/store/db.rs | 29 ++- tests/test_acp.py | 14 +- tests/test_companions.py | 101 +++++++++ 15 files changed, 901 insertions(+), 107 deletions(-) create mode 100644 src/server/companions.rs create mode 100644 tests/test_companions.py diff --git a/bench/socket_client.py b/bench/socket_client.py index df000b9b8..6c499e49f 100644 --- a/bench/socket_client.py +++ b/bench/socket_client.py @@ -76,7 +76,7 @@ def close(self): class SocketClient: - def __init__(self, binary, path, url, tools, extra=(), settings=None): + def __init__(self, binary, path, url, tools, extra=(), settings=None, env=None): # Keep AF_UNIX paths short even in deep remote snapshot directories. self.directory = tempfile.TemporaryDirectory(prefix='agent-bench-', dir='/tmp') self.socket_path = Path(self.directory.name) / 'daemon.sock' @@ -85,7 +85,7 @@ def __init__(self, binary, path, url, tools, extra=(), settings=None): self.process = subprocess.Popen( [str(binary), *serve_args(path, url, extra=extra), '--socket', str(self.socket_path)], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - start_new_session=True, env=clean_env()) + start_new_session=True, env=env or clean_env()) try: deadline = time.monotonic() + 5 while True: diff --git a/docs/CLI.md b/docs/CLI.md index 1321b8fdc..3bf4bce89 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -1,7 +1,7 @@ # CLI contract Agent uses flat verbs: `run`, `follow`, `fork`, `interrupt`, `wait`, `ls`, -`inspect`, `turns`, `usage`, `rm`, `prune`, `approvals`, `answer`, `models`, `start`, `stats`, +`inspect`, `turns`, `usage`, `rm`, `prune`, `approvals`, `answer`, `models`, `companion`, `start`, `stats`, `shutdown`, and `serve`. All named bots use the same commands. There is no parent/child command hierarchy. @@ -177,6 +177,40 @@ for both client commands and `serve`. Keep that registry, the implementation, and behavior tests aligned when adding an option. No CLI framework or runtime dependency is required. +## Companions + +A companion is a command the daemon keeps running for as long as it runs: +a client's helper that agents wait on, such as Sonny's lookout or the +approver, so it lives as long as the daemon whoever started either. The +daemon knows nothing of what one is. + +```sh +agent companion add NAME [--cwd DIR] [--env KEY=VALUE]... -- COMMAND [ARGS]... +agent companion rm NAME +agent companion ls +``` + +`add` keeps the companion in the store under NAME (1 to 64 letters, +digits, `.`, `_` or `-`, not starting with `.`) and starts it now; every +later daemon on that store starts it once it listens. The same spec again +changes nothing (`changed: false`); another spec under the name replaces +the old one, which is stopped first. `rm` stops and forgets one +(`removed: false` when there was none); `ls` lists them, as `stats` does +under `companions`, each with its spec, `running`, `pid`, `started_ms`, +`restarts` and `last_exit` (`code` and `signal`, or `spawn_failed` with +why, and `at_ms`). Each runs in its own process group with the daemon's +environment, its `--env` over that, and `AGENT_STORE`, `AGENT_SOCKET`, +`AGENT_BIN` and `AGENT_COMPANION` (its name), never a bot shell's marks; +its stdin is empty and its output goes to `STORE.companions/NAME.log`. One +that exits starts again after 1 s, the wait doubling up to 60 s while it +keeps exiting within a minute of starting. The daemon's shutdown stops +each with SIGTERM to its group, then SIGKILL after 5 s. A companion's own +sessions keep no daemon from `--idle-exit`, and a daemon on stdio, with no +socket to reach it on, runs none. The request is `companions` with `add` +(`name`, `command`, `args`, `env`, `cwd`), `remove` (a name) or neither; +a sandboxed bot's commands may not make it, since a companion runs as the +daemon does. + ## Output and status Default output is machine-readable JSON. `run` and `follow` stream one JSON diff --git a/docs/LOOKOUT.md b/docs/LOOKOUT.md index f99f3f38f..66e72eed1 100644 --- a/docs/LOOKOUT.md +++ b/docs/LOOKOUT.md @@ -20,19 +20,23 @@ and the rest, `sonny/src/served.rs`). ## One lookout a daemon -Sonny starts `sonny lookout --store STORE --socket SOCKET` detached when it -opens a project, and again after the daemon restarts. A lookout holds +`sonny lookout --store STORE --socket SOCKET` runs as the daemon's +companion `sonny-lookout` (CLI.md, Companions): Sonny adds it when it opens +a project, and the daemon keeps it running for as long as it runs, starts +it again if it exits, and starts it with every later daemon on that store, +whether or not a window is open. A lookout holds `STORE.lookout.lock` while it runs, so a second one prints `{"event":"already_watching"}` and exits, unless another build of Sonny started the one running, as an upgrade leaves it: that one is stopped, and the new one takes its place, so the tools the new Sonny gives its agents -are answered. `STORE.lookout.sonny` says which build runs. It watches for every bot with +are answered; the daemon's companion takes the place of one a window +started detached, of any build. `STORE.lookout.sonny` says which build runs. It watches for every bot with subscriptions on the daemon, whichever Sonny opened it, so a lead hears from its tasks even while no window shows that project. It exits -once the daemon has been unreachable for 30 seconds; the Sonny that starts -the next daemon starts the next lookout. Its own lines go to -`STORE.lookout.log`, and each subscriber it watches (`seated`) and each -note it sends (`woke`) to `STORE.lookout.jsonl`. +once the daemon has been unreachable for 30 seconds, and the daemon stops +it as it stops. Its own lines go to `STORE.companions/sonny-lookout.log`, +and each subscriber it watches (`seated`) and each note it sends (`woke`) +to `STORE.lookout.jsonl`. It keeps a cursor in `STORE.lookout.cursor`: the newest event it saw, or just before the oldest one whose news it has not told yet. A lookout that diff --git a/sonny/README.md b/sonny/README.md index 11aab072c..896aafdc2 100644 --- a/sonny/README.md +++ b/sonny/README.md @@ -50,7 +50,9 @@ folder under `~/.agent`. `~/.agent/env` (`KEY=VALUE` lines, readable only by you) holds the provider keys, and `AWS_PROFILE` for Bedrock, which Sonny applies over your shell's to -the daemon, the approver and the lookout. Settings go in +the daemon it starts; the approver and the lookout run as that daemon's +companions, with its environment, so they live as long as it does whether +or not a window is open. Settings go in `~/.agent/config.toml`, as Codex keeps them: `provider` (the daemon's `--provider` specs, after the shell's `AGENT_PROVIDER`), `model` (a new agent's when none is named) and the efforts below. When no provider is named @@ -176,7 +178,7 @@ notifies, `herdr agent wait` and `herdr agent prompt` reach it by name 0.9.2 lists Sonny but takes no command to restart it). Sonny reports from a pane of the Herdr server on its own machine: one reached by plain `ssh` from a Herdr pane is not listed, one in a remote machine Herdr itself connects to is. -The daemon, lookout and approver Sonny starts run in a session of their own, +The daemon Sonny starts, and the lookout and approver it keeps, run in a session of their own, so closing their pane or stopping Herdr leaves them and their turns running. In cmux, which reads OSC 7501 and OSC 777 itself, the workspace's sidebar row shows which task is working, waiting on an approval or done, and a @@ -188,8 +190,9 @@ then cmux offers it to run by hand); quitting Sonny clears it. cmux lets only programs started in its tabs reach it, by default; agents' shells run in the daemon, not in cmux, so set cmux's Settings > Automation to "Automation mode" (your own programs, no ancestry check) for agents to run -commands in tabs and read them. The lookout Sonny starts in cmux reaches it -either way; one started outside cmux needs Automation mode too. +commands in tabs and read them. The lookout, the daemon's companion, reaches it +either way when the daemon started from a cmux tab; otherwise it needs +Automation mode too. The place a project was laid out in is kept in `~/.agent/sonny/places`, so an agent's `run_in_pane` and `show_agent` calls go to that Herdr or cmux workspace or diff --git a/sonny/src/acp.rs b/sonny/src/acp.rs index 9d8ab7935..937d5b726 100644 --- a/sonny/src/acp.rs +++ b/sonny/src/acp.rs @@ -1476,7 +1476,7 @@ async fn services( if let Some(project) = project { lookout::subscribe_lead(&agent.options.store, bot, project)?; } - crate::start_lookout(&agent.options)?; + crate::start_lookout(client, &agent.options).await?; if approvals::mode(&record) == Some("auto") { let model = format!( "{}/{}", @@ -1562,7 +1562,7 @@ async fn setup_lead( } } lookout::subscribe_lead(&agent.options.store, &lead.bot, &lead.project)?; - crate::start_lookout(&agent.options)?; + crate::start_lookout(client, &agent.options).await?; if gating.or(mode) == Some("auto") { let model = format!( "{}/{}", diff --git a/sonny/src/lookout/mod.rs b/sonny/src/lookout/mod.rs index 457cce671..0bc29c2c1 100644 --- a/sonny/src/lookout/mod.rs +++ b/sonny/src/lookout/mod.rs @@ -292,7 +292,7 @@ fn beside(store: &Path, suffix: &str) -> PathBuf { /// This Sonny, told apart from another build of it: its version and when /// its binary was written. -fn identity() -> String { +pub(crate) fn identity() -> String { let written = std::env::current_exe() .and_then(std::fs::metadata) .and_then(|meta| meta.modified()) @@ -308,7 +308,9 @@ fn identity() -> String { /// when the lookout holding it is this Sonny's own. fn replace(store: &Path) -> Result, String> { let theirs = std::fs::read_to_string(beside(store, ".lookout.sonny")).unwrap_or_default(); - if theirs == identity() { + // The daemon's own companion takes the place of one a window started + // before Sonny kept it as one, of this build too. + if theirs == identity() && std::env::var_os("AGENT_COMPANION").is_none() { return Ok(None); } let pid: Option = std::fs::read_to_string(beside(store, ".lookout.lock")) @@ -343,8 +345,14 @@ fn lock(store: &std::path::Path) -> Result, String> { .open(&path) .map_err(|e| format!("{}: {e}", std::path::Path::new(&path).display()))?; // SAFETY: flock on a descriptor `file` owns and keeps open; the lock is - // released when the process exits. - if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 { + // released when the process exits. Tried twice, since `running` holds + // it for an instant. + let take = || unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0; + let taken = take() || { + std::thread::sleep(Duration::from_millis(50)); + take() + }; + if taken { // Its process, which `/status` asks after. use std::io::Write; let _ = file.set_len(0); @@ -1010,14 +1018,24 @@ fn cursor_path(store: &Path) -> PathBuf { /// The most of the log `status` reads, from its end. const STATUS_BYTES: u64 = 1 << 20; +/// Whether a lookout watches `store`'s daemon: one holds its lock. The pid +/// the file names is no answer, since after a restart another process may +/// have it. The lock is tried shared and let go at once; a lookout +/// starting meanwhile tries again (`lock`). +pub fn running(store: &std::path::Path) -> bool { + use std::os::fd::AsRawFd; + let Ok(file) = std::fs::File::open(beside(store, ".lookout.lock")) else { + return false; + }; + // SAFETY: flock on a descriptor `file` owns; closing it lets go. + let taken = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_SH | libc::LOCK_NB) } == 0; + !taken && std::io::Error::last_os_error().raw_os_error() == Some(libc::EWOULDBLOCK) +} + /// One line on the lookout for a subscriber, as `/status` shows it: /// whether one runs and watches it, and today's notes. pub fn status(store: &std::path::Path, lead: &str) -> String { - let pid: Option = std::fs::read_to_string(beside(store, ".lookout.lock")) - .ok() - .and_then(|pid| pid.trim().parse().ok()); - // SAFETY: signal 0 sends nothing; it only asks whether the process lives. - if !pid.is_some_and(|pid| pid > 0 && unsafe { libc::kill(pid, 0) } == 0) { + if !running(store) { return "Lookout · not running".to_owned(); } let (mut seated, mut notes) = (false, 0); @@ -2313,6 +2331,24 @@ mod tests { assert!(lookout.updates.due()); } + #[test] + fn a_lookout_runs_while_one_holds_its_lock_whatever_pid_it_names() { + let dir = std::env::temp_dir().join(format!("lookout-running-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let store = dir.join("state.sqlite"); + assert!(!running(&store)); + // A lookout gone before a restart, its pid now another process's. + std::fs::write(dir.join("state.sqlite.lookout.lock"), "1").unwrap(); + assert!(!running(&store)); + let held = lock(&store).unwrap().unwrap(); + assert!(running(&store)); + // Asking leaves it held. + assert!(lock(&store).unwrap().is_none()); + drop(held); + assert!(!running(&store)); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn status_says_whether_it_watches_from_the_start() { let dir = std::env::temp_dir().join(format!("lookout-status-{}", std::process::id())); diff --git a/sonny/src/main.rs b/sonny/src/main.rs index 975a7ff8c..cfdb9aa24 100644 --- a/sonny/src/main.rs +++ b/sonny/src/main.rs @@ -92,7 +92,7 @@ use serde_json::{Value, json}; use std::{ collections::{BTreeMap, HashMap}, io::Write, - path::PathBuf, + path::{Path, PathBuf}, sync::Arc, time::{Duration, Instant}, }; @@ -406,74 +406,74 @@ fn apply_env_file(command: &mut std::process::Command) -> Result<(), String> { Ok(()) } -/// An `auto` gate needs an approver serving it: when none does, one is -/// started the way the CLI starts it, detached, logging beside the store, -/// judged by `model` unless a judge is set. +/// The daemon's companion that answers an `auto` gate: `agent approver`, +/// judged by `model` unless a judge is set, which the daemon then keeps +/// running for as long as it runs, as it does the lookout. One kept already +/// keeps the judge it was given first, so leads on other models do not +/// start it again. async fn serve_auto(client: &Client, options: &Options, model: &str) -> Result<(), String> { - let stats = client - .request("stats", json!({})) + let bin = agent_bin().ok_or("Agent's binary was not found to judge calls; set AGENT_BIN")?; + let args = daemon_args("approver", options); + let kept = client + .request("companions", json!({})) .await .map_err(|e| e.to_string())?; - if stats["approvers"] - .as_array() - .is_some_and(|tags| tags.iter().any(|tag| tag == "auto")) + if (kept["companions"].as_array().into_iter().flatten()) + .any(|c| c["name"] == APPROVER && c["command"] == json!(bin) && c["args"] == json!(args)) { return Ok(()); } - let bin = agent_bin().ok_or("Agent's binary was not found to judge calls; set AGENT_BIN")?; - let mut log = options.store.clone().into_os_string(); - log.push(".approver.log"); - let log = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&log) - .map_err(|e| e.to_string())?; - let mut command = agent_command(&bin)?; - agent_client::own_session(&mut command); - command - .env("AGENT_MODEL", model) - .arg("approver") - .arg("--store") - .arg(&options.store) - .arg("--socket") - .arg(&options.socket) - .stdin(std::process::Stdio::null()) - .stdout(log.try_clone().map_err(|e| e.to_string())?) - .stderr(log) - .spawn() - .map_err(|e| format!("{}: {e}", bin.display()))?; - Ok(()) + keep(client, APPROVER, &bin, args, json!({"AGENT_MODEL": model})).await } /// The lookout, `sonny lookout`, which tells leads what happens in their -/// tasks and answers Sonny's tools: started detached, logging beside the -/// store. One already watching this daemon keeps it, and the new one -/// exits. -fn start_lookout(options: &Options) -> Result<(), String> { +/// tasks and answers Sonny's tools: the daemon's companion, so it runs for +/// as long as the daemon does, whether or not a window is open, and comes +/// back with the next daemon. This Sonny's own build replaces another's: +/// the build is part of what the daemon keeps, so a Sonny upgraded in +/// place has the daemon start the new one. +async fn start_lookout(client: &Client, options: &Options) -> Result<(), String> { let me = std::env::current_exe().map_err(|e| e.to_string())?; - let mut log = options.store.clone().into_os_string(); - log.push(".lookout.log"); - let log = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&log) - .map_err(|e| e.to_string())?; - let mut command = agent_command(&me)?; - agent_client::own_session(&mut command); - let mut child = command - .arg("lookout") - .arg("--store") - .arg(&options.store) - .arg("--socket") - .arg(&options.socket) - .stdin(std::process::Stdio::null()) - .stdout(log.try_clone().map_err(|e| e.to_string())?) - .stderr(log) - .spawn() - .map_err(|e| format!("{}: {e}", me.display()))?; - // Reaped when it exits, as it does at once when another one watches. - std::thread::spawn(move || child.wait()); - Ok(()) + keep( + client, + LOOKOUT, + &me, + daemon_args("lookout", options), + json!({"SONNY_BUILD": lookout::identity()}), + ) + .await +} + +/// What Sonny names its companions in the daemon. +const LOOKOUT: &str = "sonny-lookout"; +const APPROVER: &str = "approver-auto"; + +fn daemon_args(command: &str, options: &Options) -> Vec { + let path = |path: &Path| path.to_string_lossy().into_owned(); + vec![ + command.to_owned(), + "--store".to_owned(), + path(&options.store), + "--socket".to_owned(), + path(&options.socket), + ] +} + +/// Ask the daemon to keep `name` running `bin` with `args`; the same again +/// changes nothing. +async fn keep( + client: &Client, + name: &str, + bin: &Path, + args: Vec, + env: Value, +) -> Result<(), String> { + let add = json!({"name": name, "command": bin, "args": args, "env": env}); + client + .request("companions", json!({"add": add})) + .await + .map(drop) + .map_err(|e| format!("{name}: {e}")) } /// The daemon, started the way the CLI starts it when it is not running. @@ -861,7 +861,7 @@ async fn run() -> Result<(), String> { app.notice = Some(format!("the lookout: {error}")); } // It answers the tools Sonny gives every agent, a lead or not. - if let Err(error) = start_lookout(&app.options) { + if let Err(error) = start_lookout(&app.client, &app.options).await { app.notice = Some(error); } // The lead told what its folder tells it today (skills and memory added @@ -3251,9 +3251,9 @@ impl App { } self.approvals_for(self.running.as_ref().map(|r| r.turn)) .await; - // A daemon that restarted took the approver and the lookout - // Sonny started with it. - if let Err(error) = start_lookout(&self.options) { + // A daemon that restarted brings back its companions; one + // started anew, as by another store's Sonny, gets them now. + if let Err(error) = start_lookout(&self.client, &self.options).await { self.notice = Some(error); } if self.mode == Some("auto") diff --git a/src/cli.rs b/src/cli.rs index 714681c80..c633558fe 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -152,6 +152,14 @@ const COMMANDS: &[Command] = &[ settings: "", startup: false, }, + Command { + name: "companion", + about: "Keep a command running for as long as the daemon runs, stop one, or list them", + usage: "companion (add NAME [--cwd DIR] [--env KEY=VALUE]... -- COMMAND [ARGS]... | rm NAME | ls)", + flags: "--cwd --env --pretty", + settings: "", + startup: true, + }, Command { name: "start", about: "Start the store's daemon if none runs; prints its ready line", @@ -379,6 +387,8 @@ fn print_flags(flags: &str) { "MODE", "A new bot's command sandbox: read-only, workspace-write or danger-full-access; default none", ), + "--cwd" => ("DIR", "The folder a companion runs in"), + "--env" => ("KEY=VALUE", "A variable a companion gets; repeat for more"), "--writable" => ( "PATH", "Another folder a workspace-write bot may change; repeat for more", @@ -537,7 +547,7 @@ pub fn prepare(args: Vec) -> Result>> { let mut positional = 0; while let Some(arg) = iter.next() { if arg == "--" { - if matches!(c.name, "run" | "wait") { + if matches!(c.name, "run" | "wait" | "companion") { out.push(arg); } for value in iter { @@ -570,7 +580,7 @@ pub fn prepare(args: Vec) -> Result>> { if flags.iter().any(|f| f == flag) && !matches!( flag, - "--provider" | "--image" | "--writable" | "--unsandboxed" + "--provider" | "--image" | "--writable" | "--unsandboxed" | "--env" ) { return fail_with("usage", format!("{flag} may only be specified once")); @@ -683,7 +693,7 @@ pub fn prepare(args: Vec) -> Result>> { if positional != 1 { return fail_with("usage", "login needs a provider: chatgpt"); } - } else if !matches!(c.name, "run" | "wait") && positional != 0 { + } else if !matches!(c.name, "run" | "wait" | "companion") && positional != 0 { return fail_with("usage", format!("{} takes no positional arguments", c.name)); } if c.name == "run" && has("--fork") { diff --git a/src/client.rs b/src/client.rs index afae8e1af..c25d99a53 100644 --- a/src/client.rs +++ b/src/client.rs @@ -133,6 +133,9 @@ struct Options { settings: serde_json::Map, settings_flags: Vec, positional: Vec, + /// A companion's folder and variables. + cwd: Option, + env: Vec, /// The `--store` and `--socket` flags, shell-quoted, that reach this /// daemon from any shell; empty when both are the defaults. target: String, @@ -202,6 +205,8 @@ fn parse(args: &[String]) -> Result { settings: serde_json::Map::new(), settings_flags: Vec::new(), positional: Vec::new(), + cwd: None, + env: Vec::new(), target: String::new(), }; let mut iter = args.iter(); @@ -240,6 +245,8 @@ fn parse(args: &[String]) -> Result { match flag { "--store" => store = Some(PathBuf::from(value)), "--socket" => socket = Some(PathBuf::from(value)), + "--cwd" => options.cwd = Some(std::path::absolute(&value)?), + "--env" => options.env.push(value), "--provider" => { options.providers.push(value); options.providers_explicit = true; @@ -1120,6 +1127,7 @@ pub fn main(args: Vec) -> Result { "approvals" => approvals(&options), "answer" => answer(&options), "approver" => approver(&options), + "companion" => companion(&options), "models" => models(&options), // The daemon's ready line, from the running one or one started now, // with the socket it answered on, so a caller that reached this @@ -1930,6 +1938,50 @@ fn approver(options: &Options) -> Result { }) } +/// `companion add`, `rm` or `ls`: what the daemon keeps running beside it. +fn companion(options: &Options) -> Result { + let usage = || { + fail_with( + "usage", + "companion needs add NAME -- COMMAND..., rm NAME or ls", + ) + }; + let request = match options + .positional + .iter() + .map(String::as_str) + .collect::>()[..] + { + ["ls"] => json!({}), + ["rm", name] => json!({"remove": name}), + ["add", name, command, ..] => { + let mut env = serde_json::Map::new(); + for pair in &options.env { + let Some((key, value)) = pair.split_once('=') else { + return fail_with("usage", format!("--env needs KEY=VALUE, not {pair}")); + }; + env.insert(key.to_owned(), json!(value)); + } + let cwd = (options.cwd.as_ref()) + .map(|cwd| { + cwd.to_str() + .ok_or(Error::with("usage", "--cwd needs a UTF-8 path")) + }) + .transpose()?; + json!({"add": {"name": name, "command": command, + "args": options.positional[3..], "env": env, "cwd": cwd}}) + } + _ => return usage(), + }; + if !request["add"].is_object() && (options.cwd.is_some() || !options.env.is_empty()) { + return fail_with("usage", "--cwd and --env go with companion add"); + } + let mut connection = ensure_daemon(options)?; + let answer = connection.request("companions", request)?; + print_json(&answer, options.pretty)?; + Ok(0) +} + fn answer(options: &Options) -> Result { // The same kind of guard as the creator identity: it stops a bot's own // shell from answering by accident, not a determined command. diff --git a/src/server/companions.rs b/src/server/companions.rs new file mode 100644 index 000000000..5d71554a8 --- /dev/null +++ b/src/server/companions.rs @@ -0,0 +1,435 @@ +//! Companions: commands a client asks the daemon to keep running for as +//! long as it runs, such as a client's own helpers that agents wait on. +//! The daemon knows nothing of what they are. Each is kept in the store +//! by name, started once the daemon listens, started again with backoff +//! when it exits, and stopped when the daemon stops, so it lives as long +//! as the daemon whoever started it. +use agent_runtime::{Error, Result, fail_with}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::{ + collections::{BTreeMap, HashMap}, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; +use tokio::{sync::watch, task::JoinHandle}; + +/// The first wait before a companion that exited starts again, doubled +/// each time it exits again soon, up to `BACKOFF_MAX`. +const BACKOFF: Duration = Duration::from_secs(1); +const BACKOFF_MAX: Duration = Duration::from_secs(60); +/// A run this long counts as healthy: the next wait starts over. +const HEALTHY: Duration = Duration::from_secs(60); +/// How long a stopped companion has after SIGTERM before SIGKILL. +const GRACE: Duration = Duration::from_secs(5); +const NAME_BYTES: usize = 64; + +/// What a companion runs: a command and its arguments, as `exec` takes +/// them, with variables over the daemon's own and a folder to run in. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Spec { + pub command: String, + #[serde(default)] + pub args: Vec, + #[serde(default)] + pub env: BTreeMap, + pub cwd: Option, +} + +/// A companion's add request: its name and what it runs. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Added { + pub name: String, + pub command: String, + #[serde(default)] + pub args: Vec, + #[serde(default)] + pub env: BTreeMap, + pub cwd: Option, +} + +impl Added { + pub fn split(self) -> (String, Spec) { + let spec = Spec { + command: self.command, + args: self.args, + env: self.env, + cwd: self.cwd, + }; + (self.name, spec) + } +} + +impl Spec { + pub fn check(&self, name: &str) -> Result<()> { + let refuse = |why: String| fail_with("invalid_companion", format!("{name}: {why}")); + if name.is_empty() + || name.len() > NAME_BYTES + || name.starts_with('.') + || !name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)) + { + return fail_with( + "invalid_companion", + format!( + "a companion's name is 1 to {NAME_BYTES} letters, digits, '.', '_' or '-', not starting with '.'" + ), + ); + } + if self.command.is_empty() { + return refuse("command is empty".into()); + } + if let Some(key) = self + .env + .keys() + .find(|key| key.is_empty() || key.contains(['=', '\0'])) + { + return refuse(format!("{key:?} is not a variable name")); + } + if self + .cwd + .as_deref() + .is_some_and(|cwd| !Path::new(cwd).is_absolute()) + { + return refuse("cwd must be an absolute path".into()); + } + let nul = |text: &String| text.contains('\0'); + if nul(&self.command) || self.args.iter().any(nul) || self.env.values().any(nul) { + return refuse("a NUL byte".into()); + } + Ok(()) + } +} + +/// How a companion is doing, as `companions` lists it. +#[derive(Default)] +struct Status { + pid: Option, + started_ms: Option, + /// Starts after the first. + restarts: u64, + last_exit: Option, +} + +struct Kept { + spec: Spec, + status: Arc>, + stop: watch::Sender, + task: JoinHandle<()>, +} + +/// The companions this daemon keeps, by name. +pub struct Companions { + /// The variables every companion gets over the daemon's own: where + /// the daemon is. + environment: Vec<(String, String)>, + /// Where each companion's output goes, `STORE.companions/NAME.log`. + logs: PathBuf, + /// Whether they run: only a daemon on a socket has one to reach it on. + runs: bool, + kept: HashMap, +} + +impl Companions { + pub fn new(store: &Path, environment: Vec<(String, String)>, runs: bool) -> Self { + let mut logs = store.as_os_str().to_owned(); + logs.push(".companions"); + Self { + environment, + logs: logs.into(), + runs, + kept: HashMap::new(), + } + } + + /// Keep `spec` running under `name`: a new one starts, one whose spec + /// changed starts again once the old run has stopped, and the same + /// spec again changes nothing. + pub fn keep(&mut self, name: &str, spec: Spec) { + if self.kept.get(name).is_some_and(|kept| kept.spec == spec) { + return; + } + let before = self.kept.remove(name).map(|kept| { + let _ = kept.stop.send(true); + kept.task + }); + let status = Arc::new(Mutex::new(Status::default())); + let (stop, stopped) = watch::channel(false); + let run = Run { + name: name.to_owned(), + spec: spec.clone(), + environment: self.environment.clone(), + log: self.logs.join(format!("{name}.log")), + status: status.clone(), + }; + let runs = self.runs; + let task = tokio::spawn(async move { + if let Some(before) = before { + let _ = before.await; + } + if runs { + run.keep(stopped).await; + } + }); + self.kept.insert( + name.to_owned(), + Kept { + spec, + status, + stop, + task, + }, + ); + } + + /// Stop `name`'s companion; whether one was kept. + pub fn drop_one(&mut self, name: &str) -> bool { + self.kept + .remove(name) + .map(|kept| kept.stop.send(true)) + .is_some() + } + + /// Stop every companion and wait for them to exit. + pub async fn stop(&mut self) { + let tasks: Vec<_> = self + .kept + .drain() + .map(|(_, kept)| { + let _ = kept.stop.send(true); + kept.task + }) + .collect(); + futures_util::future::join_all(tasks).await; + } + + /// Whether `pid` is a companion's process: its sessions keep no daemon + /// from going idle. + pub fn runs_pid(&self, pid: u32) -> bool { + self.kept + .values() + .any(|kept| kept.status.lock().unwrap_or_else(|e| e.into_inner()).pid == Some(pid)) + } + + /// Each companion, by name, with what it runs and how it is doing. + pub fn listed(&self) -> Value { + let mut names: Vec<_> = self.kept.keys().collect(); + names.sort(); + let listed: Vec = names + .into_iter() + .map(|name| self.shown(name).expect("kept")) + .collect(); + json!(listed) + } + + pub fn shown(&self, name: &str) -> Option { + let kept = self.kept.get(name)?; + let status = kept.status.lock().unwrap_or_else(|e| e.into_inner()); + let mut shown = json!({"name": name, "command": kept.spec.command, + "args": kept.spec.args, "env": kept.spec.env, "cwd": kept.spec.cwd, + "running": status.pid.is_some(), "pid": status.pid, + "started_ms": status.started_ms, "restarts": status.restarts, + "last_exit": status.last_exit}); + if !self.runs { + shown["running"] = json!(false); + } + Some(shown) + } +} + +/// One companion's runs, one after another. +struct Run { + name: String, + spec: Spec, + environment: Vec<(String, String)>, + log: PathBuf, + status: Arc>, +} + +impl Run { + fn status(&self) -> std::sync::MutexGuard<'_, Status> { + self.status.lock().unwrap_or_else(|e| e.into_inner()) + } + + async fn keep(self, mut stop: watch::Receiver) { + let mut backoff = BACKOFF; + let mut first = true; + while !*stop.borrow() { + if !first { + self.status().restarts += 1; + } + first = false; + let started = Instant::now(); + let exit = match self.spawn() { + Ok(mut child) => { + { + let mut status = self.status(); + status.pid = child.id(); + status.started_ms = Some(epoch_ms()); + } + let exit = tokio::select! { + exit = child.wait() => exited(exit), + _ = stop.changed() => { + terminate(&mut child).await; + self.status().pid = None; + return; + } + }; + self.status().pid = None; + exit + } + Err(error) => json!({"error": "spawn_failed", "detail": error}), + }; + let mut exit = exit; + exit["at_ms"] = json!(epoch_ms()); + self.status().last_exit = Some(exit); + if started.elapsed() >= HEALTHY { + backoff = BACKOFF; + } + tokio::select! { + _ = tokio::time::sleep(backoff) => {} + _ = stop.changed() => return, + } + backoff = (backoff * 2).min(BACKOFF_MAX); + } + } + + fn spawn(&self) -> std::result::Result { + std::fs::create_dir_all(self.log.parent().expect("in its folder")) + .map_err(|e| e.to_string())?; + let log = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&self.log) + .map_err(|e| format!("{}: {e}", self.log.display()))?; + let mut command = tokio::process::Command::new(&self.spec.command); + command + .args(&self.spec.args) + .envs(self.environment.iter().map(|(k, v)| (k, v))) + .env("AGENT_COMPANION", &self.name) + .envs(&self.spec.env) + .stdin(std::process::Stdio::null()) + .stdout(log.try_clone().map_err(|e| e.to_string())?) + .stderr(log) + // Its own group, so stopping it stops what it started too. + .process_group(0) + .kill_on_drop(true); + if let Some(cwd) = &self.spec.cwd { + command.current_dir(cwd); + } + command + .spawn() + .map_err(|e| format!("{}: {e}", self.spec.command)) + } +} + +fn exited(exit: std::io::Result) -> Value { + use std::os::unix::process::ExitStatusExt; + match exit { + Ok(status) => json!({"code": status.code(), "signal": status.signal()}), + Err(error) => json!({"error": "wait_failed", "detail": error.to_string()}), + } +} + +/// SIGTERM to its group, then SIGKILL once its grace is up. +async fn terminate(child: &mut tokio::process::Child) { + let Some(pid) = child.id() else { return }; + let group = -(pid as i32); + // SAFETY: a signal to the process group this companion leads. + unsafe { libc::kill(group, libc::SIGTERM) }; + if tokio::time::timeout(GRACE, child.wait()).await.is_err() { + // SAFETY: as above. + unsafe { libc::kill(group, libc::SIGKILL) }; + let _ = child.wait().await; + } +} + +fn epoch_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_millis() as i64) +} + +/// The spec a stored row holds. +pub fn stored(name: &str, spec: &str) -> Result { + serde_json::from_str(spec).map_err(|_| Error::with("store_companion_invalid", name.to_owned())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn spec(command: &str, args: &[&str]) -> Spec { + Spec { + command: command.into(), + args: args.iter().map(|a| a.to_string()).collect(), + env: BTreeMap::new(), + cwd: None, + } + } + + #[test] + fn a_companion_s_name_and_spec_are_checked() { + assert!(spec("sonny", &["lookout"]).check("lookout").is_ok()); + for name in ["", ".hidden", "a b", &"x".repeat(65)] { + assert_eq!( + spec("sonny", &[]).check(name).unwrap_err().code, + "invalid_companion" + ); + } + assert!(spec("", &[]).check("x").is_err()); + let mut relative = spec("sonny", &[]); + relative.cwd = Some("here".into()); + assert!(relative.check("x").is_err()); + let mut bad = spec("sonny", &[]); + bad.env.insert("A=B".into(), "1".into()); + assert!(bad.check("x").is_err()); + } + + async fn until(what: impl Fn() -> bool) { + let deadline = Instant::now() + Duration::from_secs(10); + while !what() { + assert!(Instant::now() < deadline, "timed out"); + tokio::time::sleep(Duration::from_millis(20)).await; + } + } + + #[tokio::test] + async fn a_companion_runs_again_when_it_exits_and_stops_with_the_daemon() { + let dir = std::env::temp_dir().join(format!("companions-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let store = dir.join("state.sqlite"); + let mut companions = + Companions::new(&store, vec![("AGENT_SOCKET".into(), "/s".into())], true); + let marks = dir.join("marks"); + let script = format!( + "echo \"$AGENT_COMPANION $AGENT_SOCKET\" >> {}; exit 3", + marks.display() + ); + companions.keep("once", spec("sh", &["-c", &script])); + until(|| std::fs::read_to_string(&marks).is_ok_and(|text| text.lines().count() >= 2)).await; + assert_eq!( + std::fs::read_to_string(&marks).unwrap().lines().next(), + Some("once /s") + ); + let shown = companions.shown("once").unwrap(); + assert!(shown["restarts"].as_u64().unwrap() >= 1, "{shown}"); + assert_eq!(shown["last_exit"]["code"], 3); + // The same spec again keeps the run it has. + companions.keep("long", spec("sleep", &["30"])); + until(|| companions.shown("long").unwrap()["running"] == true).await; + let pid = companions.shown("long").unwrap()["pid"].as_u64().unwrap() as u32; + assert!(companions.runs_pid(pid)); + companions.keep("long", spec("sleep", &["30"])); + assert_eq!(companions.shown("long").unwrap()["pid"], json!(pid)); + companions.stop().await; + // SAFETY: signal 0 only asks whether it lives. + assert_ne!(unsafe { libc::kill(pid as i32, 0) }, 0); + assert_eq!(companions.listed(), json!([])); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/server/mod.rs b/src/server/mod.rs index 52ca2501d..6280fd7a0 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1,5 +1,6 @@ //! One process hosts every bot. Client sessions (stdio or Unix socket) speak //! the same JSONL protocol; turns run as tasks; events fan out through a hub. +mod companions; mod handles; mod hub; mod session; @@ -488,6 +489,14 @@ enum Command { tag: String, lease: u64, }, + /// The daemon's companions: with `add`, keep one running under its + /// name for as long as the daemon runs (the same again changes + /// nothing, another spec runs anew); with `remove`, stop and forget + /// one; with neither, list them. + Companions { + add: Option, + remove: Option, + }, /// The daemon's live state for a fleet controller: sessions, turns, /// connections, pools, storage worker, and handle registry. Stats, @@ -889,6 +898,7 @@ fn gate( } struct Service { store: Store, + companions: companions::Companions, transport: Arc, providers: Arc>, registry: Registry, @@ -1253,6 +1263,16 @@ pub async fn run(config: Configuration) -> Result<()> { .socket .as_deref() .and_then(|socket| socket::capabilities(socket, sender.clone(), sessions_next.clone())); + // Where the daemon is, not a bot's shell's marks: a companion is no + // bot's command. + let mut companions = companions::Companions::new( + &config.store, + (environment.iter()) + .filter(|(key, _)| key != "AGENT_SHELL_CONTEXT") + .cloned() + .collect(), + config.socket.is_some(), + ); let registry = registry .with_credentials(credentials) .with_environment(environment) @@ -1275,6 +1295,8 @@ pub async fn run(config: Configuration) -> Result<()> { let mut sessions: HashMap = HashMap::new(); // The sandboxed bot each session on a bot's own socket answers as. let mut callers: HashMap = HashMap::new(); + // The process at each session's other end, where the system says. + let mut peers: HashMap = HashMap::new(); let (stdout, stdout_writer) = Output::stdout(); let stdio_owner = config.socket.is_none(); let mut socket_owner = None; @@ -1361,8 +1383,13 @@ pub async fn run(config: Configuration) -> Result<()> { handles.wake(waiting.bot, waiting.turn); } } + // Companions start once the daemon listens, as they were last kept. + for (name, spec) in store.op("companions", |db| db.companions()).await? { + companions.keep(&name, companions::stored(&name, &spec)?); + } let mut service = Service { store, + companions, identity, transport, providers: Arc::new(providers), @@ -1429,10 +1456,12 @@ pub async fn run(config: Configuration) -> Result<()> { _ = interrupt.recv() => break, _ = tokio::time::sleep_until(drain_until.unwrap_or(undrained)), if drain_until.is_some() => break, _ = tokio::time::sleep(idle_exit.unwrap_or(Duration::MAX).min(Duration::from_secs(3600))), if idle_exit.is_some() => { - // Idle means no client, no live turn, and no running command. - // Parked turns are durable and resume on the next start. + // Idle means no client but companions, no live turn, and no + // running command. Parked turns are durable and resume on + // the next start. let running = service.store.op("running_processes", |db| db.running_processes()).await?; - if sessions.is_empty() && service.active.is_empty() && service.admissions.is_empty() && running == 0 { + let clients = sessions.keys().any(|id| !peers.get(id).is_some_and(|pid| service.companions.runs_pid(*pid))); + if !clients && service.active.is_empty() && service.admissions.is_empty() && running == 0 { if last_activity.elapsed() >= idle_exit.unwrap() { break; } } else { last_activity = std::time::Instant::now(); @@ -1471,17 +1500,23 @@ pub async fn run(config: Configuration) -> Result<()> { } message = inbound.recv() => { let Some(message) = message else { break }; - last_activity = std::time::Instant::now(); + // What a companion sends is no client's activity. + let session = match &message { Inbound::Open(id, ..) | Inbound::Request(id, _) | Inbound::Closed(id) => *id }; + if !peers.get(&session).is_some_and(|pid| service.companions.runs_pid(*pid)) { + last_activity = std::time::Instant::now(); + } match message { - Inbound::Open(id, output, caller) => { + Inbound::Open(id, output, caller, peer) => { let _ = output.try_send(ready.clone()); sessions.insert(id, output); if let Some(caller) = caller { callers.insert(id, caller); } + if let Some(peer) = peer { peers.insert(id, peer); } service.sessions = sessions.len(); } Inbound::Closed(id) => { sessions.remove(&id); callers.remove(&id); + peers.remove(&id); service.sessions = sessions.len(); service.hub.close_session(id); service.handles.close_session(id); @@ -1584,6 +1619,8 @@ pub async fn run(config: Configuration) -> Result<()> { Ok(()) } .await; + // Companions go with the daemon. + service.companions.stop().await; // The stdio owner's output is gone: no one is left to answer, and a // blocked stdout write cannot be cancelled. The next start ends the rest. if stdio_owner @@ -1711,6 +1748,8 @@ impl Service { } Command::ToolResult { .. } => return refuse("answer a declared tool call"), Command::Shutdown { .. } => return refuse("stop the daemon"), + // A companion runs as the daemon does, outside any sandbox. + Command::Companions { .. } => return refuse("manage the daemon's companions"), _ => {} } let me = self @@ -2951,6 +2990,32 @@ impl Service { }); Err(Error::new("deferred")) } + Command::Companions { add, remove } => match (add, remove) { + (Some(_), Some(_)) => { + fail_with("usage", "companions takes add or remove, not both") + } + (Some(added), None) => { + let (name, spec) = added.split(); + spec.check(&name)?; + let text = serde_json::to_string(&spec) + .map_err(|e| Error::with("invalid_companion", e.to_string()))?; + let kept = name.clone(); + let changed = store + .op("put_companion", move |db| db.put_companion(&kept, &text)) + .await?; + self.companions.keep(&name, spec); + Ok(json!({"companion": self.companions.shown(&name), "changed": changed})) + } + (None, Some(name)) => { + let gone = name.clone(); + let removed = store + .op("remove_companion", move |db| db.remove_companion(&gone)) + .await?; + self.companions.drop_one(&name); + Ok(json!({"name": name, "removed": removed})) + } + (None, None) => Ok(json!({"companions": self.companions.listed()})), + }, Command::Stats => { let (waiting, running, queued, paced, pending_bytes, approvals) = store .op("counts", |db| { @@ -2979,6 +3044,7 @@ impl Service { "paced_turns": paced, "approval_requests": approvals, "approvers": self.hub.served(), + "companions": self.companions.listed(), "queued_turns": queued, "pending_bytes": pending_bytes, "pending_limit": self.limits.pending, @@ -4173,6 +4239,11 @@ mod tests { let ending: Arc = Arc::default(); let mut service = Service { store: store.clone(), + companions: companions::Companions::new( + std::path::Path::new("/nonexistent"), + Vec::new(), + false, + ), identity: 0, transport: Transport::new(64, 1).unwrap(), providers: Arc::new(HashMap::new()), @@ -4338,6 +4409,11 @@ mod tests { let (cancel, cancelled) = watch::channel(None); let mut service = Service { store: store.clone(), + companions: companions::Companions::new( + std::path::Path::new("/nonexistent"), + Vec::new(), + false, + ), identity: 0, transport: Transport::new(64, 1).unwrap(), providers: Arc::new(HashMap::new()), @@ -4546,6 +4622,11 @@ mod tests { let mut service = Service { identity: 0, store: store.clone(), + companions: companions::Companions::new( + std::path::Path::new("/nonexistent"), + Vec::new(), + false, + ), transport, providers: Arc::new(HashMap::from([("openai".to_owned(), provider)])), registry, @@ -4778,6 +4859,11 @@ mod tests { Service { identity: 0, store: store.clone(), + companions: companions::Companions::new( + std::path::Path::new("/nonexistent"), + Vec::new(), + false, + ), transport: Transport::new(64, 1).unwrap(), providers: Arc::new(HashMap::new()), registry: Registry::new("echo").unwrap(), diff --git a/src/server/session.rs b/src/server/session.rs index e0fe796fe..56b1a7d1e 100644 --- a/src/server/session.rs +++ b/src/server/session.rs @@ -12,8 +12,9 @@ use tokio::{ // box for every request. Open/Closed messages are infrequent control traffic. #[allow(clippy::large_enum_variant)] pub enum Inbound { - /// A connection, and the sandboxed bot whose own socket it came in on. - Open(u64, Output, Option), + /// A connection, the sandboxed bot whose own socket it came in on, and + /// the process at its other end, where the system says. + Open(u64, Output, Option, Option), Request(u64, Result), Closed(u64), } @@ -77,9 +78,14 @@ pub async fn accept( ) { while let Ok((stream, _)) = listener.accept().await { let id = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let peer = stream + .peer_cred() + .ok() + .and_then(|cred| cred.pid()) + .and_then(|pid| u32::try_from(pid).ok()); let (read, write) = stream.into_split(); if sender - .send(Inbound::Open(id, Output::writer(write), caller)) + .send(Inbound::Open(id, Output::writer(write), caller, peer)) .await .is_err() { diff --git a/src/store/db.rs b/src/store/db.rs index bf8c01a0a..54548b4a3 100644 --- a/src/store/db.rs +++ b/src/store/db.rs @@ -1571,7 +1571,7 @@ impl Database { /// Stored schema version, kept in `PRAGMA user_version`. Stores created /// before versioning and stores from newer binaries are rejected; an older /// versioned store is migrated forward, one version at a time, at open. - pub const SCHEMA: i32 = 60; + pub const SCHEMA: i32 = 61; /// Verbatim user prompts a compaction keeps: per-prompt text, and the /// total text plus `(ordinal, String)` entry metadata. Empty entries cost /// space too, so the retained list cannot grow with conversation length. @@ -1751,6 +1751,7 @@ impl Database { INSERT OR IGNORE INTO turn_sequence VALUES (1,0); CREATE TABLE IF NOT EXISTS checkpoints(bot TEXT NOT NULL REFERENCES bots(name), head INTEGER NOT NULL REFERENCES nodes(id), PRIMARY KEY(bot,head)); + CREATE TABLE IF NOT EXISTS companions(name TEXT PRIMARY KEY, spec TEXT NOT NULL); CREATE TABLE IF NOT EXISTS tools(turn INTEGER NOT NULL REFERENCES turns(id), call_id TEXT NOT NULL, status TEXT NOT NULL, custom INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(turn,call_id)); CREATE TABLE IF NOT EXISTS approvals(id INTEGER PRIMARY KEY AUTOINCREMENT, turn INTEGER NOT NULL REFERENCES turns(id), @@ -4119,6 +4120,30 @@ impl Database { } /// Durable lineage, drawn at creation and copied with the database. The /// daemon also binds it to the physical file for its cache namespace. + /// Every companion, by name, as `put_companion` kept it. + pub fn companions(&self) -> Result> { + let mut statement = self + .conn + .prepare_cached("SELECT name,spec FROM companions ORDER BY name")?; + let rows = statement.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?; + Ok(rows.collect::>()?) + } + /// Keep a companion's spec under its name; whether it changed. + pub fn put_companion(&mut self, name: &str, spec: &str) -> Result { + let changed = self.conn.execute( + "INSERT INTO companions(name,spec) VALUES(?1,?2) + ON CONFLICT(name) DO UPDATE SET spec=?2 WHERE spec<>?2", + params![name, spec], + )?; + Ok(changed == 1) + } + /// Forget a companion; whether there was one. + pub fn remove_companion(&mut self, name: &str) -> Result { + Ok(self + .conn + .execute("DELETE FROM companions WHERE name=?", [name])? + == 1) + } pub fn store_identity(&self) -> Result { Ok(self .conn @@ -10346,6 +10371,8 @@ fn migrate(conn: &Connection, from: i32) -> Result<()> { ALTER TABLE bots DROP COLUMN fallbacks;", )?; } + // 60 -> 61: the daemon's companions, in a table the schema creates; + // there were none. Ok(()) } /// 53 -> 54: a turn's denial count became a window of its last decided diff --git a/tests/test_acp.py b/tests/test_acp.py index b6f7d4946..d175e3c02 100644 --- a/tests/test_acp.py +++ b/tests/test_acp.py @@ -118,7 +118,9 @@ def setUp(self): super().setUp() self.sonny = self.binary.parent / 'sonny' self.store = self.path / 'state.sqlite' - self.daemon = SocketClient(self.binary, self.store, self.url, 'echo') + # Its companions, Sonny's lookout among them, have its environment. + self.daemon = SocketClient(self.binary, self.store, self.url, 'echo', + env={**clean_env(), 'HOME': str(self.path)}) self.addCleanup(self.daemon.close) self.t3 = http.server.ThreadingHTTPServer(('127.0.0.1', 0), T3Tools) self.t3.calls, self.t3.headers, self.t3.delegating = queue.Queue(), [], True @@ -130,12 +132,6 @@ def setUp(self): 'GIT_COMMITTER_NAME': 't', 'GIT_COMMITTER_EMAIL': 't@example.com', 'T3_ACP_MCP_ENDPOINT': f'http://127.0.0.1:{self.t3.server_port}/mcp', 'T3_ACP_MCP_AUTHORIZATION': 'Bearer thread-1'} - # The lookout the threads find running. - lookout = subprocess.Popen([str(self.sonny), 'lookout', '--store', str(self.store), - '--socket', str(self.daemon.socket_path)], - env=self.env, stdout=subprocess.PIPE, text=True) - self.addCleanup(lambda: (lookout.kill(), lookout.wait(timeout=5), lookout.stdout.close())) - self.assertEqual(json.loads(lookout.stdout.readline())['event'], 'watching') def repo(self): repo = self.path / 'calc' @@ -179,6 +175,10 @@ def test_a_thread_is_its_folders_lead_and_a_task_it_starts_opens_as_a_thread_of_ thread.send({'id': asked['id'], 'result': {'outcome': {'outcome': 'selected', 'optionId': 'allow'}}}) answered, updates = thread.answer_of(prompt) self.assertEqual(answered['result'], {'stopReason': 'end_turn'}) + # The lookout that answered it is the daemon's, not the thread's. + [lookout] = self.daemon.request('companions')['result']['companions'] + self.assertEqual((lookout['name'], lookout['command'], lookout['args'][0]), + ('sonny-lookout', str(self.sonny.resolve()), 'lookout')) # The task was T3 Code's child of this prompt before it was answered. delegated = self.t3.calls.get_nowait() kinds = [u['sessionUpdate'] for u in updates] diff --git a/tests/test_companions.py b/tests/test_companions.py new file mode 100644 index 000000000..528350038 --- /dev/null +++ b/tests/test_companions.py @@ -0,0 +1,101 @@ +"""Companions: commands the daemon keeps running for as long as it runs.""" +import json +import os +import subprocess +import time +import unittest + +from bench.targets import clean_env +from tests.test_runtime import ModelFixture + + +def alive(pid): + try: + os.kill(pid, 0) + return True + except OSError: + return False + + +@unittest.skipUnless(os.environ.get('AGENT_TEST_RUNTIME') == '1', 'set AGENT_TEST_RUNTIME=1 after a Rust release build') +class CompanionTests(ModelFixture): + def setUp(self): + super().setUp() + self.store = self.path / 'state.sqlite' + self.daemon = ['--store', str(self.store), '--provider', f'openai=responses,{self.url}'] + self.addCleanup(lambda: subprocess.run([str(self.binary), 'shutdown', '--store', str(self.store)], + env=clean_env(), capture_output=True, timeout=35)) + + def agent(self, *args, check=True): + result = subprocess.run([str(self.binary), *args], env=clean_env(), capture_output=True, text=True, + timeout=30, cwd=self.path) + if check: + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + return result + + def listed(self): + return {c['name']: c for c in json.loads(self.agent('companion', *self.daemon, 'ls').stdout)['companions']} + + def until(self, what, timeout=15): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + value = what() + if value: + return value + time.sleep(0.1) + self.fail('timed out') + + def test_a_companion_lives_and_dies_with_the_daemon_and_comes_back_with_the_next(self): + marks = self.path / 'marks' + script = f'echo "$AGENT_COMPANION $AGENT_SOCKET $GREETING" >> {marks}; exec sleep 600' + added = json.loads(self.agent('companion', *self.daemon, 'add', 'helper', '--env', 'GREETING=hi', + '--cwd', str(self.path), '--', 'sh', '-c', script).stdout) + self.assertTrue(added['changed']) + self.assertEqual((added['companion']['command'], added['companion']['args']), ('sh', ['-c', script])) + pid = self.until(lambda: self.listed()['helper']['pid']) + socket = json.loads(self.agent('start', *self.daemon).stdout)['socket'] + self.until(lambda: marks.exists() and marks.read_text()) + self.assertEqual(marks.read_text().split(), ['helper', socket, 'hi']) + # The same spec again keeps the run it has. + again = json.loads(self.agent('companion', *self.daemon, 'add', 'helper', '--env', 'GREETING=hi', + '--cwd', str(self.path), '--', 'sh', '-c', script).stdout) + self.assertFalse(again['changed']) + self.assertEqual(self.listed()['helper']['pid'], pid) + self.assertEqual(json.loads(self.agent('stats', *self.daemon).stdout)['companions'][0]['pid'], pid) + # It stops with the daemon, and the next daemon starts it again. + self.agent('shutdown', '--store', str(self.store)) + self.assertFalse(alive(pid)) + self.agent('start', *self.daemon) + second = self.until(lambda: self.listed()['helper']['pid']) + self.assertNotEqual(second, pid) + self.assertTrue(alive(second)) + removed = json.loads(self.agent('companion', *self.daemon, 'rm', 'helper').stdout) + self.assertEqual(removed, {'name': 'helper', 'removed': True}) + self.until(lambda: not alive(second)) + self.assertEqual(self.listed(), {}) + self.assertFalse(json.loads(self.agent('companion', *self.daemon, 'rm', 'helper').stdout)['removed']) + + def test_a_companion_that_exits_starts_again(self): + self.agent('companion', *self.daemon, 'add', 'flaky', '--', 'sh', '-c', 'exit 7') + shown = self.until(lambda: (c := self.listed()['flaky'])['restarts'] >= 1 and c) + self.assertEqual(shown['last_exit']['code'], 7) + + def test_a_companion_s_session_keeps_no_daemon_from_going_idle(self): + daemon = json.loads(self.agent('start', *self.daemon, '--idle-exit', '2s').stdout)['pid'] + # It holds a session open for as long as it runs. + self.agent('companion', '--store', str(self.store), 'add', 'watcher', '--', + str(self.binary), 'follow', '--all', '--store', str(self.store)) + pid = self.until(lambda: self.listed()['watcher']['pid']) + # Asked nothing more, the daemon goes, and its companion with it. + self.until(lambda: not alive(daemon), timeout=20) + self.until(lambda: not alive(pid)) + + def test_a_bad_companion_is_refused_by_name(self): + refused = self.agent('companion', *self.daemon, 'add', '.x', '--', 'true', check=False) + self.assertNotEqual(refused.returncode, 0) + self.assertEqual(json.loads(refused.stderr.strip().splitlines()[-1])['error'], 'invalid_companion') + self.assertNotEqual(self.agent('companion', *self.daemon, 'add', 'x', check=False).returncode, 0) + + +if __name__ == '__main__': + unittest.main() From 2983abd9c1d2e37aa7f0ec8a2d32d0ccf2f543ae Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 08:41:30 +0000 Subject: [PATCH 24/30] Companions: log setup off the runtime thread, the whole group stopped, a removed one waited for, and Sonny's take store and socket from the daemon Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHCyGRHdT5WYDPKVFY --- sonny/src/acp.rs | 8 ++-- sonny/src/main.rs | 33 +++++++-------- src/server/companions.rs | 89 ++++++++++++++++++++++++++++++++-------- 3 files changed, 89 insertions(+), 41 deletions(-) diff --git a/sonny/src/acp.rs b/sonny/src/acp.rs index 937d5b726..93b811c85 100644 --- a/sonny/src/acp.rs +++ b/sonny/src/acp.rs @@ -1476,14 +1476,14 @@ async fn services( if let Some(project) = project { lookout::subscribe_lead(&agent.options.store, bot, project)?; } - crate::start_lookout(client, &agent.options).await?; + crate::start_lookout(client).await?; if approvals::mode(&record) == Some("auto") { let model = format!( "{}/{}", record["provider"].as_str().unwrap_or(""), record["model"].as_str().unwrap_or("") ); - crate::serve_auto(client, &agent.options, &model).await?; + crate::serve_auto(client, &model).await?; } Ok(()) } @@ -1562,14 +1562,14 @@ async fn setup_lead( } } lookout::subscribe_lead(&agent.options.store, &lead.bot, &lead.project)?; - crate::start_lookout(client, &agent.options).await?; + crate::start_lookout(client).await?; if gating.or(mode) == Some("auto") { let model = format!( "{}/{}", record["provider"].as_str().unwrap_or(""), record["model"].as_str().unwrap_or("") ); - crate::serve_auto(client, &agent.options, &model).await?; + crate::serve_auto(client, &model).await?; } Ok(()) } diff --git a/sonny/src/main.rs b/sonny/src/main.rs index cfdb9aa24..20dafd131 100644 --- a/sonny/src/main.rs +++ b/sonny/src/main.rs @@ -411,9 +411,9 @@ fn apply_env_file(command: &mut std::process::Command) -> Result<(), String> { /// running for as long as it runs, as it does the lookout. One kept already /// keeps the judge it was given first, so leads on other models do not /// start it again. -async fn serve_auto(client: &Client, options: &Options, model: &str) -> Result<(), String> { +async fn serve_auto(client: &Client, model: &str) -> Result<(), String> { let bin = agent_bin().ok_or("Agent's binary was not found to judge calls; set AGENT_BIN")?; - let args = daemon_args("approver", options); + let args = daemon_args("approver"); let kept = client .request("companions", json!({})) .await @@ -432,13 +432,13 @@ async fn serve_auto(client: &Client, options: &Options, model: &str) -> Result<( /// back with the next daemon. This Sonny's own build replaces another's: /// the build is part of what the daemon keeps, so a Sonny upgraded in /// place has the daemon start the new one. -async fn start_lookout(client: &Client, options: &Options) -> Result<(), String> { +async fn start_lookout(client: &Client) -> Result<(), String> { let me = std::env::current_exe().map_err(|e| e.to_string())?; keep( client, LOOKOUT, &me, - daemon_args("lookout", options), + daemon_args("lookout"), json!({"SONNY_BUILD": lookout::identity()}), ) .await @@ -448,15 +448,11 @@ async fn start_lookout(client: &Client, options: &Options) -> Result<(), String> const LOOKOUT: &str = "sonny-lookout"; const APPROVER: &str = "approver-auto"; -fn daemon_args(command: &str, options: &Options) -> Vec { - let path = |path: &Path| path.to_string_lossy().into_owned(); - vec![ - command.to_owned(), - "--store".to_owned(), - path(&options.store), - "--socket".to_owned(), - path(&options.socket), - ] +/// A companion's arguments: its store and socket come from the daemon +/// that runs it (`AGENT_STORE`, `AGENT_SOCKET`), which may serve the store +/// on another socket later. +fn daemon_args(command: &str) -> Vec { + vec![command.to_owned()] } /// Ask the daemon to keep `name` running `bin` with `args`; the same again @@ -861,7 +857,7 @@ async fn run() -> Result<(), String> { app.notice = Some(format!("the lookout: {error}")); } // It answers the tools Sonny gives every agent, a lead or not. - if let Err(error) = start_lookout(&app.client, &app.options).await { + if let Err(error) = start_lookout(&app.client).await { app.notice = Some(error); } // The lead told what its folder tells it today (skills and memory added @@ -921,7 +917,7 @@ async fn run() -> Result<(), String> { if fresh { app.gating = Some(approvals::MODES[0].0); } else if app.mode == Some("auto") - && let Err(error) = serve_auto(&app.client, &app.options, &app.model).await + && let Err(error) = serve_auto(&app.client, &app.model).await { app.notice = Some(error); } @@ -2827,8 +2823,7 @@ impl App { if let Some(mode) = gating { self.mode = Some(mode); if mode == "auto" - && let Err(error) = - serve_auto(&self.client, &self.options, &self.model).await + && let Err(error) = serve_auto(&self.client, &self.model).await { self.notice = Some(error); } @@ -3253,11 +3248,11 @@ impl App { .await; // A daemon that restarted brings back its companions; one // started anew, as by another store's Sonny, gets them now. - if let Err(error) = start_lookout(&self.client, &self.options).await { + if let Err(error) = start_lookout(&self.client).await { self.notice = Some(error); } if self.mode == Some("auto") - && let Err(error) = serve_auto(&self.client, &self.options, &self.model).await + && let Err(error) = serve_auto(&self.client, &self.model).await { self.notice = Some(error); } diff --git a/src/server/companions.rs b/src/server/companions.rs index 5d71554a8..1057ef46a 100644 --- a/src/server/companions.rs +++ b/src/server/companions.rs @@ -132,6 +132,9 @@ pub struct Companions { /// Whether they run: only a daemon on a socket has one to reach it on. runs: bool, kept: HashMap, + /// Companions removed and still stopping: shutdown waits for them, and + /// one added again under the name starts once its last has stopped. + stopping: HashMap>, } impl Companions { @@ -143,6 +146,7 @@ impl Companions { logs: logs.into(), runs, kept: HashMap::new(), + stopping: HashMap::new(), } } @@ -153,10 +157,13 @@ impl Companions { if self.kept.get(name).is_some_and(|kept| kept.spec == spec) { return; } - let before = self.kept.remove(name).map(|kept| { - let _ = kept.stop.send(true); - kept.task - }); + let before = match self.kept.remove(name) { + Some(kept) => { + let _ = kept.stop.send(true); + Some(kept.task) + } + None => self.stopping.remove(name), + }; let status = Arc::new(Mutex::new(Status::default())); let (stop, stopped) = watch::channel(false); let run = Run { @@ -188,10 +195,13 @@ impl Companions { /// Stop `name`'s companion; whether one was kept. pub fn drop_one(&mut self, name: &str) -> bool { - self.kept - .remove(name) - .map(|kept| kept.stop.send(true)) - .is_some() + self.stopping.retain(|_, task| !task.is_finished()); + let Some(kept) = self.kept.remove(name) else { + return false; + }; + let _ = kept.stop.send(true); + self.stopping.insert(name.to_owned(), kept.task); + true } /// Stop every companion and wait for them to exit. @@ -203,6 +213,7 @@ impl Companions { let _ = kept.stop.send(true); kept.task }) + .chain(self.stopping.drain().map(|(_, task)| task)) .collect(); futures_util::future::join_all(tasks).await; } @@ -264,7 +275,7 @@ impl Run { } first = false; let started = Instant::now(); - let exit = match self.spawn() { + let exit = match self.spawn().await { Ok(mut child) => { { let mut status = self.status(); @@ -298,14 +309,20 @@ impl Run { } } - fn spawn(&self) -> std::result::Result { - std::fs::create_dir_all(self.log.parent().expect("in its folder")) + async fn spawn(&self) -> std::result::Result { + // Off the runtime thread: a slow disk, or a FIFO at the log's + // path, holds only this companion. + tokio::fs::create_dir_all(self.log.parent().expect("in its folder")) + .await .map_err(|e| e.to_string())?; - let log = std::fs::OpenOptions::new() + let log = tokio::fs::OpenOptions::new() .create(true) .append(true) .open(&self.log) - .map_err(|e| format!("{}: {e}", self.log.display()))?; + .await + .map_err(|e| format!("{}: {e}", self.log.display()))? + .into_std() + .await; let mut command = tokio::process::Command::new(&self.spec.command); command .args(&self.spec.args) @@ -335,17 +352,22 @@ fn exited(exit: std::io::Result) -> Value { } } -/// SIGTERM to its group, then SIGKILL once its grace is up. +/// SIGTERM to its group, then SIGKILL to what is left of it once its +/// grace is up, the companion itself gone or not. async fn terminate(child: &mut tokio::process::Child) { let Some(pid) = child.id() else { return }; let group = -(pid as i32); + let deadline = tokio::time::Instant::now() + GRACE; // SAFETY: a signal to the process group this companion leads. unsafe { libc::kill(group, libc::SIGTERM) }; - if tokio::time::timeout(GRACE, child.wait()).await.is_err() { - // SAFETY: as above. - unsafe { libc::kill(group, libc::SIGKILL) }; - let _ = child.wait().await; + let _ = tokio::time::timeout_at(deadline, child.wait()).await; + // SAFETY: signal 0 only asks whether any of the group lives. + while unsafe { libc::kill(group, 0) } == 0 && tokio::time::Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(50)).await; } + // SAFETY: as above; a group already gone is no error to mind. + unsafe { libc::kill(group, libc::SIGKILL) }; + let _ = child.wait().await; } fn epoch_ms() -> i64 { @@ -432,4 +454,35 @@ mod tests { assert_eq!(companions.listed(), json!([])); let _ = std::fs::remove_dir_all(&dir); } + + #[tokio::test] + async fn a_removed_companion_s_group_is_stopped_before_the_daemon_is() { + let dir = std::env::temp_dir().join(format!("companions-rm-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut companions = Companions::new(&dir.join("state.sqlite"), vec![], true); + // It exits on SIGTERM; what it started does not. + let started = dir.join("started"); + let script = format!( + "(trap '' TERM; exec sleep 30) & echo $! > {}; wait", + started.display() + ); + companions.keep("stubborn", spec("sh", &["-c", &script])); + until(|| std::fs::read_to_string(&started).is_ok_and(|pid| pid.ends_with('\n'))).await; + let pid: i32 = std::fs::read_to_string(&started) + .unwrap() + .trim() + .parse() + .unwrap(); + assert!(companions.drop_one("stubborn")); + assert_eq!(companions.listed(), json!([])); + companions.stop().await; + // Gone, or a zombie no init here reaps, once SIGKILL lands. + let dead = || { + let state = std::fs::read_to_string(format!("/proc/{pid}/stat")).unwrap_or_default(); + // SAFETY: signal 0 only asks whether it lives. + (unsafe { libc::kill(pid, 0) }) != 0 || state.contains(") Z ") + }; + until(dead).await; + let _ = std::fs::remove_dir_all(&dir); + } } From 39328ab4bba34b9d8a8743690e77c9eb37fd04b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 09:06:44 +0000 Subject: [PATCH 25/30] Companions: idle-exit counts from the last client's activity, a companion's whole group is no client, and a log that is no file fails at once Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHCyGRHdT5WYDPKVFY --- src/server/companions.rs | 46 +++++++++++++++++++++++++++++++++------- src/server/mod.rs | 16 +++++++++++++- tests/test_companions.py | 16 ++++++++++++++ 3 files changed, 69 insertions(+), 9 deletions(-) diff --git a/src/server/companions.rs b/src/server/companions.rs index 1057ef46a..2eb8c7cee 100644 --- a/src/server/companions.rs +++ b/src/server/companions.rs @@ -218,12 +218,15 @@ impl Companions { futures_util::future::join_all(tasks).await; } - /// Whether `pid` is a companion's process: its sessions keep no daemon - /// from going idle. + /// Whether `pid` is a companion's process, or one in its group (what a + /// companion's script runs): its sessions keep no daemon from going idle. pub fn runs_pid(&self, pid: u32) -> bool { - self.kept - .values() - .any(|kept| kept.status.lock().unwrap_or_else(|e| e.into_inner()).pid == Some(pid)) + // SAFETY: getpgid only reads the process's group. + let group = unsafe { libc::getpgid(pid as i32) }; + self.kept.values().any(|kept| { + let leader = kept.status.lock().unwrap_or_else(|e| e.into_inner()).pid; + leader.is_some_and(|leader| leader == pid || leader as i32 == group) + }) } /// Each companion, by name, with what it runs and how it is doing. @@ -315,14 +318,20 @@ impl Run { tokio::fs::create_dir_all(self.log.parent().expect("in its folder")) .await .map_err(|e| e.to_string())?; + // Nonblocking, so a FIFO there with no reader fails at once rather + // than holding the companion, and its stopping, for ever. let log = tokio::fs::OpenOptions::new() .create(true) .append(true) + .custom_flags(libc::O_NONBLOCK) .open(&self.log) .await - .map_err(|e| format!("{}: {e}", self.log.display()))? - .into_std() - .await; + .map_err(|e| format!("{}: {e}", self.log.display()))?; + let regular = log.metadata().await.is_ok_and(|meta| meta.is_file()); + if !regular { + return Err(format!("{}: not a regular file", self.log.display())); + } + let log = log.into_std().await; let mut command = tokio::process::Command::new(&self.spec.command); command .args(&self.spec.args) @@ -455,6 +464,27 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[tokio::test] + async fn a_log_that_is_no_file_fails_its_companion_at_once() { + let dir = std::env::temp_dir().join(format!("companions-fifo-{}", std::process::id())); + std::fs::create_dir_all(dir.join("state.sqlite.companions")).unwrap(); + let fifo = std::ffi::CString::new( + dir.join("state.sqlite.companions/piped.log") + .to_string_lossy() + .as_bytes(), + ) + .unwrap(); + // SAFETY: a FIFO made at a path this test owns. + assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }, 0); + let mut companions = Companions::new(&dir.join("state.sqlite"), vec![], true); + companions.keep("piped", spec("sleep", &["30"])); + until(|| companions.shown("piped").unwrap()["last_exit"]["error"] == "spawn_failed").await; + tokio::time::timeout(Duration::from_secs(5), companions.stop()) + .await + .expect("stops"); + let _ = std::fs::remove_dir_all(&dir); + } + #[tokio::test] async fn a_removed_companion_s_group_is_stopped_before_the_daemon_is() { let dir = std::env::temp_dir().join(format!("companions-rm-{}", std::process::id())); diff --git a/src/server/mod.rs b/src/server/mod.rs index 6280fd7a0..6e91aaa3e 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1455,7 +1455,9 @@ pub async fn run(config: Configuration) -> Result<()> { _ = terminate.recv() => break, _ = interrupt.recv() => break, _ = tokio::time::sleep_until(drain_until.unwrap_or(undrained)), if drain_until.is_some() => break, - _ = tokio::time::sleep(idle_exit.unwrap_or(Duration::MAX).min(Duration::from_secs(3600))), if idle_exit.is_some() => { + // Due from the last client's activity, so what a companion + // sends, which wakes this loop, never puts it off. + _ = tokio::time::sleep_until(idle_due(last_activity, idle_exit)), if idle_exit.is_some() => { // Idle means no client but companions, no live turn, and no // running command. Parked turns are durable and resume on // the next start. @@ -3706,6 +3708,18 @@ impl Service { } } +/// When the daemon next looks whether it has been idle `idle_exit` since +/// `last_activity`: then, or within the hour for a wait too long to add. +fn idle_due( + last_activity: std::time::Instant, + idle_exit: Option, +) -> tokio::time::Instant { + let last = tokio::time::Instant::from_std(last_activity); + idle_exit + .and_then(|idle| last.checked_add(idle)) + .unwrap_or_else(|| tokio::time::Instant::now() + Duration::from_secs(3600)) +} + /// Commit a finished turn. A group that cannot commit (a full disk, say) /// leaves nothing durable, so the completion is tried again, with backoff, /// while the bot stays busy and other bots go on. An interrupt that arrives diff --git a/tests/test_companions.py b/tests/test_companions.py index 528350038..0798c154a 100644 --- a/tests/test_companions.py +++ b/tests/test_companions.py @@ -90,6 +90,22 @@ def test_a_companion_s_session_keeps_no_daemon_from_going_idle(self): self.until(lambda: not alive(daemon), timeout=20) self.until(lambda: not alive(pid)) + def test_a_companion_that_keeps_asking_keeps_no_daemon_from_going_idle(self): + daemon = json.loads(self.agent('start', *self.daemon, '--idle-exit', '2s').stdout)['pid'] + # One connection, a request every 0.3 s, from a process the + # companion's script starts rather than the companion itself. + asker = ('import json, os, socket, time\n' + 's = socket.socket(socket.AF_UNIX); s.connect(os.environ["AGENT_SOCKET"])\n' + 'r = s.makefile("r"); r.readline()\n' + 'n = 0\n' + 'while True:\n' + ' n += 1; s.sendall((json.dumps({"id": n, "op": "stats"}) + "\\n").encode()); r.readline()\n' + ' time.sleep(0.3)\n') + self.agent('companion', '--store', str(self.store), 'add', 'asker', '--env', f'ASKER={asker}', '--', + 'sh', '-c', 'python3 -c "$ASKER"; exit 1') + self.until(lambda: self.listed()['asker']['pid']) + self.until(lambda: not alive(daemon), timeout=20) + def test_a_bad_companion_is_refused_by_name(self): refused = self.agent('companion', *self.daemon, 'add', '.x', '--', 'true', check=False) self.assertNotEqual(refused.returncode, 0) From f0e8a4bfc5d8dbc5f9b77782761e47a2e26846c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 09:18:04 +0000 Subject: [PATCH 26/30] Companions: a session is a companion's or not once, as it opens Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHCyGRHdT5WYDPKVFY --- src/server/mod.rs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/server/mod.rs b/src/server/mod.rs index 6e91aaa3e..273b10aff 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1295,8 +1295,9 @@ pub async fn run(config: Configuration) -> Result<()> { let mut sessions: HashMap = HashMap::new(); // The sandboxed bot each session on a bot's own socket answers as. let mut callers: HashMap = HashMap::new(); - // The process at each session's other end, where the system says. - let mut peers: HashMap = HashMap::new(); + // Sessions a companion opened, known once as each opens: what they + // send is no client's activity. + let mut companion_sessions: std::collections::HashSet = std::collections::HashSet::new(); let (stdout, stdout_writer) = Output::stdout(); let stdio_owner = config.socket.is_none(); let mut socket_owner = None; @@ -1462,7 +1463,7 @@ pub async fn run(config: Configuration) -> Result<()> { // running command. Parked turns are durable and resume on // the next start. let running = service.store.op("running_processes", |db| db.running_processes()).await?; - let clients = sessions.keys().any(|id| !peers.get(id).is_some_and(|pid| service.companions.runs_pid(*pid))); + let clients = sessions.keys().any(|id| !companion_sessions.contains(id)); if !clients && service.active.is_empty() && service.admissions.is_empty() && running == 0 { if last_activity.elapsed() >= idle_exit.unwrap() { break; } } else { @@ -1503,22 +1504,25 @@ pub async fn run(config: Configuration) -> Result<()> { message = inbound.recv() => { let Some(message) = message else { break }; // What a companion sends is no client's activity. - let session = match &message { Inbound::Open(id, ..) | Inbound::Request(id, _) | Inbound::Closed(id) => *id }; - if !peers.get(&session).is_some_and(|pid| service.companions.runs_pid(*pid)) { + let companion = match &message { + Inbound::Open(_, _, _, peer) => peer.is_some_and(|pid| service.companions.runs_pid(pid)), + Inbound::Request(id, _) | Inbound::Closed(id) => companion_sessions.contains(id), + }; + if !companion { last_activity = std::time::Instant::now(); } match message { - Inbound::Open(id, output, caller, peer) => { + Inbound::Open(id, output, caller, _) => { let _ = output.try_send(ready.clone()); sessions.insert(id, output); if let Some(caller) = caller { callers.insert(id, caller); } - if let Some(peer) = peer { peers.insert(id, peer); } + if companion { companion_sessions.insert(id); } service.sessions = sessions.len(); } Inbound::Closed(id) => { sessions.remove(&id); callers.remove(&id); - peers.remove(&id); + companion_sessions.remove(&id); service.sessions = sessions.len(); service.hub.close_session(id); service.handles.close_session(id); From 7afaf751c5de62d927cb746eca6365d8a8518abb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 01:03:22 +0000 Subject: [PATCH 27/30] Sonny: /goal sets the agent a goal by hand, checked at once when it is idle `/goal CHECK` keeps the agent the window shows working until CHECK exits 0 in its folder; `/goal` shows its goal and `/goal clear` clears it. With no turn running, the goal file carries the agent's last turn as `check_after` and the lookout runs the check on its next tick, as if that turn had just completed, so a failing check starts the agent's goal turn at once. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHCyGRHdT5WYDPKVFY --- docs/SONNY.md | 8 ++ sonny/README.md | 4 +- sonny/src/lookout/goals.rs | 154 +++++++++++++++++++++++++++++++------ sonny/src/lookout/mod.rs | 5 ++ sonny/src/main.rs | 66 +++++++++++++++- tests/test_served.py | 22 ++++++ 6 files changed, 234 insertions(+), 25 deletions(-) diff --git a/docs/SONNY.md b/docs/SONNY.md index 4472d138f..9b0fd3624 100644 --- a/docs/SONNY.md +++ b/docs/SONNY.md @@ -301,6 +301,14 @@ agent's other calls do (`/approvals`). Each goal is `STORE.lookout/goals/BOT.json`; setting the same goal again changes nothing. +The person sets one in a window with `/goal CHECK` on the agent it shows: +done once `CHECK` exits 0, with no budget. With no turn running, the +lookout checks it at once, as if the agent's last turn had just completed +(the file's `check_after`), so the agent starts on it without a message; +otherwise the running turn's end is checked. `/goal` shows the agent's +goal, and `/goal clear` clears it. Being the person's, it waits for no +verdict. + Each time a turn of that agent completes, the lookout runs the check, for up to `timeout_s` (600 when left out, at most 3,600), killing its process group past that. While it fails, it queues the agent a turn (origin diff --git a/sonny/README.md b/sonny/README.md index bb8dfdcf3..d6b540224 100644 --- a/sonny/README.md +++ b/sonny/README.md @@ -56,7 +56,9 @@ of the first rather than above it. `sonny` opens the one last written to in that folder, else the first. `--lead NAME` opens another (`--lead lead` the first), and `--new-lead NAME` makes one (numbered, as `lead.2`, with no name). In a window, `/lead` lists the folder's leads, `/lead NAME` opens one -and `/lead new [NAME]` makes one. A lead hears of, shows and lists in +and `/lead new [NAME]` makes one. `/goal CHECK` keeps the agent working +until `CHECK`, run in its folder, exits 0 (`/goal` shows it, `/goal clear` +clears it; see [Goals](../docs/SONNY.md#goals)). A lead hears of, shows and lists in `/status` only its own tasks. It is told to send work for another lead's task to that lead, to leave changes it did not make alone and never to switch the folder's branch, since the leads share one checkout; the instructions, diff --git a/sonny/src/lookout/goals.rs b/sonny/src/lookout/goals.rs index 17ce3d5cb..c11a628dc 100644 --- a/sonny/src/lookout/goals.rs +++ b/sonny/src/lookout/goals.rs @@ -52,6 +52,10 @@ pub struct Goal { pub timeout_s: u64, /// Why the agent gave up, told once its turn ends. pub blocked: Option, + /// Set by the person on an agent with no turn running: the lookout + /// checks it at once, as if this turn, the agent's last, had just + /// completed, rather than waiting for the next one. + pub check_after: Option, } impl Goal { @@ -59,7 +63,7 @@ impl Goal { json!({"bot": self.bot, "bot_id": self.bot_id, "done_when": self.done_when, "check": self.check, "workspace": self.workspace, "set_by": self.set_by, "set_ms": self.set_ms, "budget_tokens": self.budget_tokens, "tokens_at_set": self.tokens_at_set, - "timeout_s": self.timeout_s, "blocked": self.blocked}) + "timeout_s": self.timeout_s, "blocked": self.blocked, "check_after": self.check_after}) } fn from_json(value: &Value) -> Option { @@ -76,6 +80,7 @@ impl Goal { tokens_at_set: value["tokens_at_set"].as_u64().unwrap_or(0), timeout_s: value["timeout_s"].as_u64().unwrap_or(TIMEOUT_S), blocked: text("blocked"), + check_after: value["check_after"].as_i64(), }) } @@ -167,6 +172,58 @@ fn end(store: &Path, goal: &Goal) { } } +/// The person's goal for `bot`, from Sonny's `/goal CHECK`: done once +/// `check` exits 0 in its folder. `record` is the agent's as `inspect` +/// gives it; `last` its last turn when none is running, so the lookout +/// checks at once. +pub fn set_by_person( + store: &Path, + record: &Value, + check: &str, + last: Option, +) -> Result { + let bot = record["name"].as_str().ok_or("the agent has no name")?; + let goal = Goal { + bot: bot.to_owned(), + bot_id: record["bot_id"].as_i64().unwrap_or_default(), + done_when: format!("`{check}` exits 0"), + check: check.to_owned(), + workspace: PathBuf::from(record["workspace"].as_str().unwrap_or("/")), + set_by: "the person".to_owned(), + set_ms: now_ms(), + budget_tokens: None, + tokens_at_set: record["tokens_used"].as_u64().unwrap_or(0), + timeout_s: TIMEOUT_S, + blocked: None, + check_after: last, + }; + write(store, &goal)?; + Ok(goal.shown()) +} + +/// Clear `bot`'s goal: whether it had one. +pub fn clear(store: &Path, bot: &str) -> bool { + let had = read(store, bot); + if let Some(goal) = &had { + end(store, goal); + } + had.is_some() +} + +/// What `bot`'s goal is, as its agent is shown it. +pub fn shown(store: &Path, bot: &str) -> Option { + read(store, bot).map(|goal| goal.shown()) +} + +/// A goal checked without waiting for a turn no longer asks to be, unless +/// it was set again since. +fn checked_now(store: &Path, goal: &Goal) { + if let Some(mut now) = read(store, &goal.bot).filter(|now| now.set_ms == goal.set_ms) { + now.check_after = None; + let _ = write(store, &now); + } +} + fn now_ms() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -225,6 +282,7 @@ pub async fn call( tokens_at_set: record["tokens_used"].as_u64().unwrap_or(0), timeout_s, blocked: None, + check_after: None, }; // The same goal again changes nothing, its budget still counted // from when it was first set. @@ -517,32 +575,81 @@ impl Goals { false => 0, }; self.idle.insert(bot.to_owned(), (goal.set_ms, turn, idle)); - let (client, done, store, socket) = ( - client.clone(), - done.clone(), - self.store.clone(), - self.socket.clone(), - ); - let held = (bot.to_owned(), turn); - let set_ms = goal.set_ms; - let bot = bot.to_owned(); - let checking = bot.clone(); - let pursuit = tokio::spawn(async move { - let outcome = pursue(&client, &store, &socket, &goal, turn, idle).await; - let _ = done.send(Settled { bot, turn, outcome }).await; - }); - let check = Check { - pursuit: pursuit.abort_handle(), - at: event["cursor"].as_i64(), - turn, - set_ms, - }; - self.checking.insert(checking, check); - Some(held) + // This turn's end checks it: no other check is due. + if goal.check_after.is_some() { + checked_now(&self.store, &goal); + } + self.pursue(goal, turn, idle, event["cursor"].as_i64(), client, done); + Some((bot.to_owned(), turn)) } _ => None, } } + + /// The goals the person set on an agent with no turn running: each is + /// checked now, as after the agent's last turn, whose news was told + /// already, so nothing is held for it. + pub fn check_now( + &mut self, + client: &std::sync::Arc, + done: &tokio::sync::mpsc::Sender, + ) { + for (bot, goal) in all(&self.store) { + let Some(turn) = goal.check_after else { + continue; + }; + checked_now(&self.store, &goal); + match self.checking.get(&bot) { + Some(check) if check.set_ms == goal.set_ms => continue, + // A check of the goal it replaces: its turn is plain news. + Some(_) => { + if let Some(check) = self.checking.remove(&bot) { + check.pursuit.abort(); + let _ = done.try_send(Settled { + bot: bot.clone(), + turn: check.turn, + outcome: Some(String::new()), + }); + } + } + None => {} + } + self.goals.insert(bot.clone(), goal.clone()); + self.idle.insert(bot, (goal.set_ms, turn, 0)); + self.pursue(goal, turn, 0, None, client, done); + } + } + + fn pursue( + &mut self, + goal: Goal, + turn: i64, + idle: u32, + at: Option, + client: &std::sync::Arc, + done: &tokio::sync::mpsc::Sender, + ) { + let (client, done, store, socket) = ( + client.clone(), + done.clone(), + self.store.clone(), + self.socket.clone(), + ); + let set_ms = goal.set_ms; + let bot = goal.bot.clone(); + let checking = bot.clone(); + let pursuit = tokio::spawn(async move { + let outcome = pursue(&client, &store, &socket, &goal, turn, idle).await; + let _ = done.send(Settled { bot, turn, outcome }).await; + }); + let check = Check { + pursuit: pursuit.abort_handle(), + at, + turn, + set_ms, + }; + self.checking.insert(checking, check); + } } /// After a turn of an agent with a goal: whether it works again, or how @@ -770,6 +877,7 @@ mod tests { tokens_at_set: 3, timeout_s: 5, blocked: None, + check_after: Some(4), } } diff --git a/sonny/src/lookout/mod.rs b/sonny/src/lookout/mod.rs index 3a39e9a10..4316c79ee 100644 --- a/sonny/src/lookout/mod.rs +++ b/sonny/src/lookout/mod.rs @@ -791,6 +791,11 @@ impl Service { _ = tick.tick() => { let now = Instant::now(); self.save_cursor(); + // A goal the person just set, on an agent with no turn + // running, is checked at once. + if live { + self.goals.check_now(&client, &checked); + } // What `subscriptions` calls wrote since: at once for // one this lookout answered, else now and then. if served.subscribed() || reread.is_none_or(|at| now >= at + REREAD) { diff --git a/sonny/src/main.rs b/sonny/src/main.rs index 31e565e80..a2ddacbd6 100644 --- a/sonny/src/main.rs +++ b/sonny/src/main.rs @@ -1474,7 +1474,7 @@ fn hold(unsent: &mut Vec, record: String) { /// The slash commands, as the menu lists them: name and what it does. /// `/agents` and `/ps` also open `/status`, `/triggers` and `/lookout` /// open `/background`, and `/settings` opens `/config`, unlisted. -const COMMANDS: [(&str, &str); 19] = [ +const COMMANDS: [(&str, &str); 20] = [ ( "/side", "a side chat with a copy of this agent, which carries on", @@ -1492,6 +1492,10 @@ const COMMANDS: [(&str, &str); 19] = [ ("/instructions", "the lead's, from its folder again"), ("/status", "this project's agents"), ("/lead", "the folder's leads: open another, or make one"), + ( + "/goal", + "keep the agent working until a command exits 0, or clear it", + ), ( "/margin", "the agent's notes beside the conversation, shown or closed", @@ -7742,6 +7746,14 @@ impl App { self.lead_command(rest.trim()).await; return; } + if let Some(rest) = prompt + .strip_prefix("/goal") + .filter(|rest| rest.is_empty() || rest.starts_with(char::is_whitespace)) + { + self.input.clear(); + self.notice = Some(self.goal_command(rest.trim()).await); + return; + } if let Some(question) = prompt .strip_prefix("/side") .filter(|rest| rest.is_empty() || rest.starts_with(char::is_whitespace)) @@ -8193,6 +8205,58 @@ impl App { /// `/lead`: the folder's leads; `/lead NAME` opens this window on one, /// and `/lead new [NAME]` makes one and opens it. + /// `/goal` shows this agent's goal, `/goal clear` clears it, and + /// `/goal CHECK` sets one: the agent works until `CHECK`, run in its + /// folder, exits 0. With no turn running, the lookout checks it now. + async fn goal_command(&mut self, rest: &str) -> String { + let store = &self.options.store; + match rest { + "" => match lookout::goals::shown(store, &self.bot) { + Some(goal) => format!( + "goal: done when {}; /goal clear clears it", + goal["done_when"].as_str().unwrap_or_default() + ), + None => "no goal; /goal CHECK keeps the agent working until CHECK exits 0".into(), + }, + "clear" => match lookout::goals::clear(store, &self.bot) { + true => "goal cleared".into(), + false => "no goal to clear".into(), + }, + check => { + let record = match self + .client + .request("inspect", json!({"bot": self.bot})) + .await + { + Ok(record) => record, + Err(error) => return error.to_string(), + }; + // Its last turn, when none runs: the check runs as if it + // had just completed. + let last = match record["running_turn"].is_null() { + true => (self.client) + .request( + "turns", + json!({"bot": self.bot, "limit": 1, "newest": true}), + ) + .await + .ok() + .and_then(|turns| turns["turns"][0]["turn"].as_i64()), + false => None, + }; + match lookout::goals::set_by_person(store, &record, check, last) { + Ok(_) if last.is_some() => { + format!("goal set: checking `{check}` now, and working until it exits 0") + } + Ok(_) => format!( + "goal set: `{check}` is checked when the agent's turn ends, and it works until it exits 0" + ), + Err(error) => error, + } + } + } + } + async fn lead_command(&mut self, rest: &str) { let Some(project) = self.project.clone().filter(|_| self.options.side.is_none()) else { self.notice = Some("/lead is for a folder's lead, not this agent".into()); diff --git a/tests/test_served.py b/tests/test_served.py index 36d322788..c4d7f6367 100644 --- a/tests/test_served.py +++ b/tests/test_served.py @@ -355,6 +355,28 @@ def test_a_goal_keeps_its_agent_working_until_its_check_passes(self): self.assertNotIn('calc.fix/1', note) self.assertFalse((self.path / 'state.sqlite.lookout/goals/calc.fix.json').exists()) + def test_a_goal_the_person_sets_on_an_idle_agent_is_checked_at_once(self): + self.goal_watch() + turn = self.daemon.request('submit', bot='calc.fix', request_id='work', prompt='Work.')['result']['turn'] + self.assertEqual(self.daemon.finished(turn)['data']['status'], 'completed') + fix = self.daemon.request('inspect', bot='calc.fix')['result'] + # As Sonny's `/goal CHECK` writes it, with no turn running. + check = 'test -f flag || { touch flag; echo not yet; exit 1; }' + goal = {'bot': 'calc.fix', 'bot_id': fix['bot_id'], 'done_when': f'`{check}` exits 0', 'check': check, + 'workspace': str(self.path), 'set_by': 'the person', 'set_ms': int(time.time() * 1000), + 'tokens_at_set': fix['tokens_used'], 'timeout_s': 600, 'check_after': turn} + goals = self.path / 'state.sqlite.lookout/goals' + goals.mkdir(parents=True, exist_ok=True) + (goals / 'calc.fix.json').write_text(json.dumps(goal)) + # It fails now, with no turn to wait for, and the agent works again. + note = self.note_for('calc.lead', 'its goal') + prompts = [self.daemon.request('prompts', bot='calc.fix', turn=t['turn'])['result']['prompts'][0]['text'] + for t in self.daemon.request('turns', bot='calc.fix')['result']['turns']] + self.assertEqual(len(prompts), 2, prompts) + self.assertTrue(prompts[1].startswith('Your goal is not met yet: `test -f flag'), prompts[1]) + self.assertIn(f'- calc.fix: turn:calc.fix/{turn + 1} completed, asked by goal, and met its goal:', note) + self.assertFalse((goals / 'calc.fix.json').exists()) + def test_a_goal_whose_agent_only_answers_stops(self): note, prompts = self.goal_run('false') # The turn that set it called the tool; two that only answered end it. From 30a928fa7ca8ccf407e141b911334699e8927488 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 01:10:58 +0000 Subject: [PATCH 28/30] Goals: /goal starts an agent with no turn yet, and a check-at-once mark stays until its check says Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHCyGRHdT5WYDPKVFY --- sonny/src/lookout/goals.rs | 11 ++++++++--- sonny/src/main.rs | 23 +++++++++++++---------- tests/test_served.py | 17 +++++++++++++++++ 3 files changed, 38 insertions(+), 13 deletions(-) diff --git a/sonny/src/lookout/goals.rs b/sonny/src/lookout/goals.rs index c11a628dc..667adb59a 100644 --- a/sonny/src/lookout/goals.rs +++ b/sonny/src/lookout/goals.rs @@ -53,8 +53,8 @@ pub struct Goal { /// Why the agent gave up, told once its turn ends. pub blocked: Option, /// Set by the person on an agent with no turn running: the lookout - /// checks it at once, as if this turn, the agent's last, had just - /// completed, rather than waiting for the next one. + /// checks it at once, as if this turn, the agent's last (0 when it has + /// none), had just completed, rather than waiting for the next one. pub check_after: Option, } @@ -449,6 +449,10 @@ impl Goals { { self.checking.remove(bot); } + // A goal checked at once was: what follows is the agent's turns. + if let Some(goal) = read(&self.store, bot).filter(|goal| goal.check_after == Some(turn)) { + checked_now(&self.store, &goal); + } } /// Checks under way answer a connection since dropped, never the next: @@ -598,7 +602,8 @@ impl Goals { let Some(turn) = goal.check_after else { continue; }; - checked_now(&self.store, &goal); + // The mark stays until the check says, so a lookout that + // reconnects or restarts meanwhile runs it again. match self.checking.get(&bot) { Some(check) if check.set_ms == goal.set_ms => continue, // A check of the goal it replaces: its turn is plain news. diff --git a/sonny/src/main.rs b/sonny/src/main.rs index a2ddacbd6..4fe08de22 100644 --- a/sonny/src/main.rs +++ b/sonny/src/main.rs @@ -8231,17 +8231,20 @@ impl App { Ok(record) => record, Err(error) => return error.to_string(), }; - // Its last turn, when none runs: the check runs as if it - // had just completed. + // Its last turn, 0 for an agent with none, when none runs: + // the check runs as if it had just completed. let last = match record["running_turn"].is_null() { - true => (self.client) - .request( - "turns", - json!({"bot": self.bot, "limit": 1, "newest": true}), - ) - .await - .ok() - .and_then(|turns| turns["turns"][0]["turn"].as_i64()), + true => Some( + (self.client) + .request( + "turns", + json!({"bot": self.bot, "limit": 1, "newest": true}), + ) + .await + .ok() + .and_then(|turns| turns["turns"][0]["turn"].as_i64()) + .unwrap_or(0), + ), false => None, }; match lookout::goals::set_by_person(store, &record, check, last) { diff --git a/tests/test_served.py b/tests/test_served.py index c4d7f6367..a411aa82f 100644 --- a/tests/test_served.py +++ b/tests/test_served.py @@ -377,6 +377,23 @@ def test_a_goal_the_person_sets_on_an_idle_agent_is_checked_at_once(self): self.assertIn(f'- calc.fix: turn:calc.fix/{turn + 1} completed, asked by goal, and met its goal:', note) self.assertFalse((goals / 'calc.fix.json').exists()) + def test_a_goal_the_person_sets_on_an_agent_with_no_turn_yet_is_checked_at_once(self): + self.goal_watch() + fix = self.daemon.request('inspect', bot='calc.fix')['result'] + goal = {'bot': 'calc.fix', 'bot_id': fix['bot_id'], 'done_when': '`true` exits 0', 'check': 'true', + 'workspace': str(self.path), 'set_by': 'the person', 'set_ms': int(time.time() * 1000), + 'tokens_at_set': 0, 'timeout_s': 600, 'check_after': 0} + goals = self.path / 'state.sqlite.lookout/goals' + goals.mkdir(parents=True, exist_ok=True) + (goals / 'calc.fix.json').write_text(json.dumps(goal)) + met = goals / 'met/calc.fix.json' + deadline = time.monotonic() + 30 + while not met.exists(): + self.assertLess(time.monotonic(), deadline, 'the goal was never checked') + time.sleep(.1) + self.assertEqual(json.loads(met.read_text())['turn'], 0) + self.assertFalse((goals / 'calc.fix.json').exists()) + def test_a_goal_whose_agent_only_answers_stops(self): note, prompts = self.goal_run('false') # The turn that set it called the tool; two that only answered end it. From 9e61b79c8a053cf025f15b1c094a4f7d60d41021 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 01:19:46 +0000 Subject: [PATCH 29/30] Goals: a check settles only what is its own goal's, by when it was set Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHCyGRHdT5WYDPKVFY --- sonny/src/lookout/goals.rs | 24 +++++++++++++++++++----- sonny/src/lookout/mod.rs | 4 ++-- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/sonny/src/lookout/goals.rs b/sonny/src/lookout/goals.rs index 667adb59a..9eaa7020c 100644 --- a/sonny/src/lookout/goals.rs +++ b/sonny/src/lookout/goals.rs @@ -392,6 +392,8 @@ pub fn tool(name: &str, tag: &str) -> Value { pub struct Settled { pub bot: String, pub turn: i64, + /// The goal checked, by when it was set. + pub set_ms: u64, pub outcome: Option, } @@ -440,17 +442,20 @@ impl Goals { } } - /// The check of `bot`'s `turn` said how its goal stands. - pub fn settled(&mut self, bot: &str, turn: i64) { + /// The check of `bot`'s `turn`, for the goal set at `set_ms`, said how + /// it stands; one of a goal since replaced touches nothing of its own. + pub fn settled(&mut self, bot: &str, turn: i64, set_ms: u64) { if self .checking .get(bot) - .is_some_and(|check| check.turn == turn) + .is_some_and(|check| (check.turn, check.set_ms) == (turn, set_ms)) { self.checking.remove(bot); } // A goal checked at once was: what follows is the agent's turns. - if let Some(goal) = read(&self.store, bot).filter(|goal| goal.check_after == Some(turn)) { + if let Some(goal) = read(&self.store, bot) + .filter(|goal| goal.set_ms == set_ms && goal.check_after == Some(turn)) + { checked_now(&self.store, &goal); } } @@ -559,6 +564,7 @@ impl Goals { let _ = done.try_send(Settled { bot: bot.to_owned(), turn: check.turn, + set_ms: check.set_ms, outcome: Some(String::new()), }); } @@ -613,6 +619,7 @@ impl Goals { let _ = done.try_send(Settled { bot: bot.clone(), turn: check.turn, + set_ms: check.set_ms, outcome: Some(String::new()), }); } @@ -645,7 +652,14 @@ impl Goals { let checking = bot.clone(); let pursuit = tokio::spawn(async move { let outcome = pursue(&client, &store, &socket, &goal, turn, idle).await; - let _ = done.send(Settled { bot, turn, outcome }).await; + let _ = done + .send(Settled { + bot, + turn, + set_ms, + outcome, + }) + .await; }); let check = Check { pursuit: pursuit.abort_handle(), diff --git a/sonny/src/lookout/mod.rs b/sonny/src/lookout/mod.rs index 4316c79ee..be93d2bf9 100644 --- a/sonny/src/lookout/mod.rs +++ b/sonny/src/lookout/mod.rs @@ -766,8 +766,8 @@ impl Service { continue; } } - Some(goals::Settled { bot, turn, outcome }) = settled.recv() => { - self.goals.settled(&bot, turn); + Some(goals::Settled { bot, turn, set_ms, outcome }) = settled.recv() => { + self.goals.settled(&bot, turn, set_ms); for lookout in self.lookouts.values_mut() { lookout.updates.goal_settled(&bot, turn, outcome.as_deref()); } From b4f49199a33a3779bdade46197cfc50f1ce5f2a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 01:26:11 +0000 Subject: [PATCH 30/30] Goals: /goal WHAT goes to the agent as typed, which sets its own goal George picked the Codex shape: the person's words reach the agent as their message, and the agent sets the goal and its check with its goal tool, whose description now says so. /goal shows the goals of the agent and its tasks, and /goal clear clears its own, through the tool's own call. The person-set check and check-at-once machinery is gone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHCyGRHdT5WYDPKVFY --- docs/SONNY.md | 12 +-- sonny/README.md | 6 +- sonny/src/lookout/goals.rs | 182 ++++++------------------------------- sonny/src/lookout/mod.rs | 9 +- sonny/src/main.rs | 141 +++++++++++++++++----------- tests/test_served.py | 39 -------- 6 files changed, 127 insertions(+), 262 deletions(-) diff --git a/docs/SONNY.md b/docs/SONNY.md index 9b0fd3624..18f1fb71f 100644 --- a/docs/SONNY.md +++ b/docs/SONNY.md @@ -301,13 +301,11 @@ agent's other calls do (`/approvals`). Each goal is `STORE.lookout/goals/BOT.json`; setting the same goal again changes nothing. -The person sets one in a window with `/goal CHECK` on the agent it shows: -done once `CHECK` exits 0, with no budget. With no turn running, the -lookout checks it at once, as if the agent's last turn had just completed -(the file's `check_after`), so the agent starts on it without a message; -otherwise the running turn's end is checked. `/goal` shows the agent's -goal, and `/goal clear` clears it. Being the person's, it waits for no -verdict. +In a window, `/goal WHAT` sends the agent `/goal WHAT` as the person's +message, and the goal tool's description asks it to set a goal on itself +for it: like Codex's `/goal`, the agent picks the check. `/goal` shows the +goals of the agent and its tasks, and `/goal clear` clears its own, as the +tool would. Each time a turn of that agent completes, the lookout runs the check, for up to `timeout_s` (600 when left out, at most 3,600), killing its process diff --git a/sonny/README.md b/sonny/README.md index d6b540224..25054475c 100644 --- a/sonny/README.md +++ b/sonny/README.md @@ -56,9 +56,9 @@ of the first rather than above it. `sonny` opens the one last written to in that folder, else the first. `--lead NAME` opens another (`--lead lead` the first), and `--new-lead NAME` makes one (numbered, as `lead.2`, with no name). In a window, `/lead` lists the folder's leads, `/lead NAME` opens one -and `/lead new [NAME]` makes one. `/goal CHECK` keeps the agent working -until `CHECK`, run in its folder, exits 0 (`/goal` shows it, `/goal clear` -clears it; see [Goals](../docs/SONNY.md#goals)). A lead hears of, shows and lists in +and `/lead new [NAME]` makes one. `/goal WHAT` asks the agent to set a goal +on itself and work until WHAT is done (`/goal` shows goals, `/goal clear` +clears its own; see [Goals](../docs/SONNY.md#goals)). A lead hears of, shows and lists in `/status` only its own tasks. It is told to send work for another lead's task to that lead, to leave changes it did not make alone and never to switch the folder's branch, since the leads share one checkout; the instructions, diff --git a/sonny/src/lookout/goals.rs b/sonny/src/lookout/goals.rs index 9eaa7020c..88e56ea7c 100644 --- a/sonny/src/lookout/goals.rs +++ b/sonny/src/lookout/goals.rs @@ -52,10 +52,6 @@ pub struct Goal { pub timeout_s: u64, /// Why the agent gave up, told once its turn ends. pub blocked: Option, - /// Set by the person on an agent with no turn running: the lookout - /// checks it at once, as if this turn, the agent's last (0 when it has - /// none), had just completed, rather than waiting for the next one. - pub check_after: Option, } impl Goal { @@ -63,7 +59,7 @@ impl Goal { json!({"bot": self.bot, "bot_id": self.bot_id, "done_when": self.done_when, "check": self.check, "workspace": self.workspace, "set_by": self.set_by, "set_ms": self.set_ms, "budget_tokens": self.budget_tokens, "tokens_at_set": self.tokens_at_set, - "timeout_s": self.timeout_s, "blocked": self.blocked, "check_after": self.check_after}) + "timeout_s": self.timeout_s, "blocked": self.blocked}) } fn from_json(value: &Value) -> Option { @@ -80,7 +76,6 @@ impl Goal { tokens_at_set: value["tokens_at_set"].as_u64().unwrap_or(0), timeout_s: value["timeout_s"].as_u64().unwrap_or(TIMEOUT_S), blocked: text("blocked"), - check_after: value["check_after"].as_i64(), }) } @@ -172,58 +167,6 @@ fn end(store: &Path, goal: &Goal) { } } -/// The person's goal for `bot`, from Sonny's `/goal CHECK`: done once -/// `check` exits 0 in its folder. `record` is the agent's as `inspect` -/// gives it; `last` its last turn when none is running, so the lookout -/// checks at once. -pub fn set_by_person( - store: &Path, - record: &Value, - check: &str, - last: Option, -) -> Result { - let bot = record["name"].as_str().ok_or("the agent has no name")?; - let goal = Goal { - bot: bot.to_owned(), - bot_id: record["bot_id"].as_i64().unwrap_or_default(), - done_when: format!("`{check}` exits 0"), - check: check.to_owned(), - workspace: PathBuf::from(record["workspace"].as_str().unwrap_or("/")), - set_by: "the person".to_owned(), - set_ms: now_ms(), - budget_tokens: None, - tokens_at_set: record["tokens_used"].as_u64().unwrap_or(0), - timeout_s: TIMEOUT_S, - blocked: None, - check_after: last, - }; - write(store, &goal)?; - Ok(goal.shown()) -} - -/// Clear `bot`'s goal: whether it had one. -pub fn clear(store: &Path, bot: &str) -> bool { - let had = read(store, bot); - if let Some(goal) = &had { - end(store, goal); - } - had.is_some() -} - -/// What `bot`'s goal is, as its agent is shown it. -pub fn shown(store: &Path, bot: &str) -> Option { - read(store, bot).map(|goal| goal.shown()) -} - -/// A goal checked without waiting for a turn no longer asks to be, unless -/// it was set again since. -fn checked_now(store: &Path, goal: &Goal) { - if let Some(mut now) = read(store, &goal.bot).filter(|now| now.set_ms == goal.set_ms) { - now.check_after = None; - let _ = write(store, &now); - } -} - fn now_ms() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -282,7 +225,6 @@ pub async fn call( tokens_at_set: record["tokens_used"].as_u64().unwrap_or(0), timeout_s, blocked: None, - check_after: None, }; // The same goal again changes nothing, its budget still counted // from when it was first set. @@ -353,7 +295,8 @@ pub fn tool(name: &str, tag: &str) -> Value { again, and its lead hears nothing of those turns. The goal ends once the check passes, the \ budget is spent, two turns it started call no tool, or the agent gives up with blocked and why, \ and its lead hears that turn with how it ended. Setting the same goal again changes nothing; \ - with nothing given, the result lists your goals and your tasks'.", + with nothing given, the result lists your goals and your tasks'. A person's message that \ + starts with /goal asks you to set one on yourself for what it says.", "parameters": { "type": "object", "properties": { @@ -392,8 +335,6 @@ pub fn tool(name: &str, tag: &str) -> Value { pub struct Settled { pub bot: String, pub turn: i64, - /// The goal checked, by when it was set. - pub set_ms: u64, pub outcome: Option, } @@ -442,22 +383,15 @@ impl Goals { } } - /// The check of `bot`'s `turn`, for the goal set at `set_ms`, said how - /// it stands; one of a goal since replaced touches nothing of its own. - pub fn settled(&mut self, bot: &str, turn: i64, set_ms: u64) { + /// The check of `bot`'s `turn` said how its goal stands. + pub fn settled(&mut self, bot: &str, turn: i64) { if self .checking .get(bot) - .is_some_and(|check| (check.turn, check.set_ms) == (turn, set_ms)) + .is_some_and(|check| check.turn == turn) { self.checking.remove(bot); } - // A goal checked at once was: what follows is the agent's turns. - if let Some(goal) = read(&self.store, bot) - .filter(|goal| goal.set_ms == set_ms && goal.check_after == Some(turn)) - { - checked_now(&self.store, &goal); - } } /// Checks under way answer a connection since dropped, never the next: @@ -564,7 +498,6 @@ impl Goals { let _ = done.try_send(Settled { bot: bot.to_owned(), turn: check.turn, - set_ms: check.set_ms, outcome: Some(String::new()), }); } @@ -585,90 +518,32 @@ impl Goals { false => 0, }; self.idle.insert(bot.to_owned(), (goal.set_ms, turn, idle)); - // This turn's end checks it: no other check is due. - if goal.check_after.is_some() { - checked_now(&self.store, &goal); - } - self.pursue(goal, turn, idle, event["cursor"].as_i64(), client, done); - Some((bot.to_owned(), turn)) + let (client, done, store, socket) = ( + client.clone(), + done.clone(), + self.store.clone(), + self.socket.clone(), + ); + let held = (bot.to_owned(), turn); + let set_ms = goal.set_ms; + let bot = bot.to_owned(); + let checking = bot.clone(); + let pursuit = tokio::spawn(async move { + let outcome = pursue(&client, &store, &socket, &goal, turn, idle).await; + let _ = done.send(Settled { bot, turn, outcome }).await; + }); + let check = Check { + pursuit: pursuit.abort_handle(), + at: event["cursor"].as_i64(), + turn, + set_ms, + }; + self.checking.insert(checking, check); + Some(held) } _ => None, } } - - /// The goals the person set on an agent with no turn running: each is - /// checked now, as after the agent's last turn, whose news was told - /// already, so nothing is held for it. - pub fn check_now( - &mut self, - client: &std::sync::Arc, - done: &tokio::sync::mpsc::Sender, - ) { - for (bot, goal) in all(&self.store) { - let Some(turn) = goal.check_after else { - continue; - }; - // The mark stays until the check says, so a lookout that - // reconnects or restarts meanwhile runs it again. - match self.checking.get(&bot) { - Some(check) if check.set_ms == goal.set_ms => continue, - // A check of the goal it replaces: its turn is plain news. - Some(_) => { - if let Some(check) = self.checking.remove(&bot) { - check.pursuit.abort(); - let _ = done.try_send(Settled { - bot: bot.clone(), - turn: check.turn, - set_ms: check.set_ms, - outcome: Some(String::new()), - }); - } - } - None => {} - } - self.goals.insert(bot.clone(), goal.clone()); - self.idle.insert(bot, (goal.set_ms, turn, 0)); - self.pursue(goal, turn, 0, None, client, done); - } - } - - fn pursue( - &mut self, - goal: Goal, - turn: i64, - idle: u32, - at: Option, - client: &std::sync::Arc, - done: &tokio::sync::mpsc::Sender, - ) { - let (client, done, store, socket) = ( - client.clone(), - done.clone(), - self.store.clone(), - self.socket.clone(), - ); - let set_ms = goal.set_ms; - let bot = goal.bot.clone(); - let checking = bot.clone(); - let pursuit = tokio::spawn(async move { - let outcome = pursue(&client, &store, &socket, &goal, turn, idle).await; - let _ = done - .send(Settled { - bot, - turn, - set_ms, - outcome, - }) - .await; - }); - let check = Check { - pursuit: pursuit.abort_handle(), - at, - turn, - set_ms, - }; - self.checking.insert(checking, check); - } } /// After a turn of an agent with a goal: whether it works again, or how @@ -896,7 +771,6 @@ mod tests { tokens_at_set: 3, timeout_s: 5, blocked: None, - check_after: Some(4), } } diff --git a/sonny/src/lookout/mod.rs b/sonny/src/lookout/mod.rs index be93d2bf9..3a39e9a10 100644 --- a/sonny/src/lookout/mod.rs +++ b/sonny/src/lookout/mod.rs @@ -766,8 +766,8 @@ impl Service { continue; } } - Some(goals::Settled { bot, turn, set_ms, outcome }) = settled.recv() => { - self.goals.settled(&bot, turn, set_ms); + Some(goals::Settled { bot, turn, outcome }) = settled.recv() => { + self.goals.settled(&bot, turn); for lookout in self.lookouts.values_mut() { lookout.updates.goal_settled(&bot, turn, outcome.as_deref()); } @@ -791,11 +791,6 @@ impl Service { _ = tick.tick() => { let now = Instant::now(); self.save_cursor(); - // A goal the person just set, on an agent with no turn - // running, is checked at once. - if live { - self.goals.check_now(&client, &checked); - } // What `subscriptions` calls wrote since: at once for // one this lookout answered, else now and then. if served.subscribed() || reread.is_none_or(|at| now >= at + REREAD) { diff --git a/sonny/src/main.rs b/sonny/src/main.rs index 4fe08de22..f9013f430 100644 --- a/sonny/src/main.rs +++ b/sonny/src/main.rs @@ -1494,7 +1494,7 @@ const COMMANDS: [(&str, &str); 20] = [ ("/lead", "the folder's leads: open another, or make one"), ( "/goal", - "keep the agent working until a command exits 0, or clear it", + "ask the agent to work until something is done; show or clear goals", ), ( "/margin", @@ -7746,12 +7746,14 @@ impl App { self.lead_command(rest.trim()).await; return; } + // `/goal WHAT` goes to the agent as typed, which sets its goal. if let Some(rest) = prompt .strip_prefix("/goal") - .filter(|rest| rest.is_empty() || rest.starts_with(char::is_whitespace)) + .map(str::trim) + .filter(|rest| matches!(*rest, "" | "clear")) { self.input.clear(); - self.notice = Some(self.goal_command(rest.trim()).await); + self.notice = Some(self.goal_command(rest == "clear").await); return; } if let Some(question) = prompt @@ -8203,63 +8205,46 @@ impl App { } } - /// `/lead`: the folder's leads; `/lead NAME` opens this window on one, - /// and `/lead new [NAME]` makes one and opens it. - /// `/goal` shows this agent's goal, `/goal clear` clears it, and - /// `/goal CHECK` sets one: the agent works until `CHECK`, run in its - /// folder, exits 0. With no turn running, the lookout checks it now. - async fn goal_command(&mut self, rest: &str) -> String { - let store = &self.options.store; - match rest { - "" => match lookout::goals::shown(store, &self.bot) { - Some(goal) => format!( - "goal: done when {}; /goal clear clears it", - goal["done_when"].as_str().unwrap_or_default() - ), - None => "no goal; /goal CHECK keeps the agent working until CHECK exits 0".into(), - }, - "clear" => match lookout::goals::clear(store, &self.bot) { + /// `/goal` shows the goals of this agent and its tasks, and `/goal + /// clear` clears its own, as its goal tool would. + async fn goal_command(&mut self, clear: bool) -> String { + let arguments = match clear { + true => json!({"clear": self.bot}), + false => json!({}), + }; + let said = + lookout::goals::call(&self.options.store, &self.client, &self.bot, &arguments).await; + match said { + Err(error) => error, + Ok(said) if clear => match said["cleared"] == true { true => "goal cleared".into(), false => "no goal to clear".into(), }, - check => { - let record = match self - .client - .request("inspect", json!({"bot": self.bot})) - .await - { - Ok(record) => record, - Err(error) => return error.to_string(), - }; - // Its last turn, 0 for an agent with none, when none runs: - // the check runs as if it had just completed. - let last = match record["running_turn"].is_null() { - true => Some( - (self.client) - .request( - "turns", - json!({"bot": self.bot, "limit": 1, "newest": true}), - ) - .await - .ok() - .and_then(|turns| turns["turns"][0]["turn"].as_i64()) - .unwrap_or(0), - ), - false => None, - }; - match lookout::goals::set_by_person(store, &record, check, last) { - Ok(_) if last.is_some() => { - format!("goal set: checking `{check}` now, and working until it exits 0") - } - Ok(_) => format!( - "goal set: `{check}` is checked when the agent's turn ends, and it works until it exits 0" - ), - Err(error) => error, + Ok(said) => { + let goals = said["goals"].as_array().cloned().unwrap_or_default(); + if goals.is_empty() { + return "no goal; /goal WHAT asks the agent to work until WHAT is done".into(); } + let shown: Vec = goals + .iter() + .map(|goal| { + format!( + "{}: done when {}", + goal["bot"].as_str().unwrap_or_default(), + goal["done_when"].as_str().unwrap_or_default() + ) + }) + .collect(); + format!( + "goals: {}; /goal clear clears this agent's", + shown.join("; ") + ) } } } + /// `/lead`: the folder's leads; `/lead NAME` opens this window on one, + /// and `/lead new [NAME]` makes one and opens it. async fn lead_command(&mut self, rest: &str) { let Some(project) = self.project.clone().filter(|_| self.options.side.is_none()) else { self.notice = Some("/lead is for a folder's lead, not this agent".into()); @@ -9768,6 +9753,58 @@ mod tests { ); } + #[tokio::test] + async fn goal_words_go_to_the_agent_as_typed_and_goal_alone_shows_or_clears() { + let asked = Arc::new(std::sync::Mutex::new(Vec::new())); + let seen = asked.clone(); + let client = daemon_answering(move |asked| { + seen.lock().unwrap().push(asked.clone()); + match asked["op"].as_str() { + Some("inspect") => json!({"bot_id": 1, "workspace": "/"}), + Some("submit") => json!({"turn": 1}), + _ => json!({}), + } + }) + .await; + let mut app = app(client); + let store = std::env::temp_dir().join(format!("goal-window-{}", std::process::id())); + app.options.store = store.clone(); + app.input.set("/goal"); + app.submit().await; + assert_eq!( + app.notice.as_deref(), + Some("no goal; /goal WHAT asks the agent to work until WHAT is done") + ); + // The words reach the agent as the person typed them. + app.input.set("/goal the tests pass"); + app.submit().await; + let ops = asked.lock().unwrap().clone(); + let submits: Vec<_> = ops.iter().filter(|op| op["op"] == "submit").collect(); + assert_eq!(submits.len(), 1); + assert!(submits[0].to_string().contains("/goal the tests pass")); + // The agent sets one with its tool; /goal shows it and clears it. + let set = json!({"set": {"done_when": "the tests pass", "check": "true"}}); + lookout::goals::call(&store, &app.client, "b", &set) + .await + .unwrap(); + app.input.set("/goal"); + app.submit().await; + assert_eq!( + app.notice.as_deref(), + Some("goals: b: done when the tests pass; /goal clear clears this agent's") + ); + app.input.set("/goal clear"); + app.submit().await; + assert_eq!(app.notice.as_deref(), Some("goal cleared")); + assert!(lookout::goals::read(&store, "b").is_none()); + app.input.set("/goal clear"); + app.submit().await; + assert_eq!(app.notice.as_deref(), Some("no goal to clear")); + let ops = asked.lock().unwrap().clone(); + assert_eq!(ops.iter().filter(|op| op["op"] == "submit").count(), 1); + std::fs::remove_dir_all(lookout::subs::dir(&store)).unwrap(); + } + #[tokio::test] async fn a_turn_the_person_steered_from_here_is_theirs() { let next = std::sync::atomic::AtomicI64::new(2); diff --git a/tests/test_served.py b/tests/test_served.py index a411aa82f..36d322788 100644 --- a/tests/test_served.py +++ b/tests/test_served.py @@ -355,45 +355,6 @@ def test_a_goal_keeps_its_agent_working_until_its_check_passes(self): self.assertNotIn('calc.fix/1', note) self.assertFalse((self.path / 'state.sqlite.lookout/goals/calc.fix.json').exists()) - def test_a_goal_the_person_sets_on_an_idle_agent_is_checked_at_once(self): - self.goal_watch() - turn = self.daemon.request('submit', bot='calc.fix', request_id='work', prompt='Work.')['result']['turn'] - self.assertEqual(self.daemon.finished(turn)['data']['status'], 'completed') - fix = self.daemon.request('inspect', bot='calc.fix')['result'] - # As Sonny's `/goal CHECK` writes it, with no turn running. - check = 'test -f flag || { touch flag; echo not yet; exit 1; }' - goal = {'bot': 'calc.fix', 'bot_id': fix['bot_id'], 'done_when': f'`{check}` exits 0', 'check': check, - 'workspace': str(self.path), 'set_by': 'the person', 'set_ms': int(time.time() * 1000), - 'tokens_at_set': fix['tokens_used'], 'timeout_s': 600, 'check_after': turn} - goals = self.path / 'state.sqlite.lookout/goals' - goals.mkdir(parents=True, exist_ok=True) - (goals / 'calc.fix.json').write_text(json.dumps(goal)) - # It fails now, with no turn to wait for, and the agent works again. - note = self.note_for('calc.lead', 'its goal') - prompts = [self.daemon.request('prompts', bot='calc.fix', turn=t['turn'])['result']['prompts'][0]['text'] - for t in self.daemon.request('turns', bot='calc.fix')['result']['turns']] - self.assertEqual(len(prompts), 2, prompts) - self.assertTrue(prompts[1].startswith('Your goal is not met yet: `test -f flag'), prompts[1]) - self.assertIn(f'- calc.fix: turn:calc.fix/{turn + 1} completed, asked by goal, and met its goal:', note) - self.assertFalse((goals / 'calc.fix.json').exists()) - - def test_a_goal_the_person_sets_on_an_agent_with_no_turn_yet_is_checked_at_once(self): - self.goal_watch() - fix = self.daemon.request('inspect', bot='calc.fix')['result'] - goal = {'bot': 'calc.fix', 'bot_id': fix['bot_id'], 'done_when': '`true` exits 0', 'check': 'true', - 'workspace': str(self.path), 'set_by': 'the person', 'set_ms': int(time.time() * 1000), - 'tokens_at_set': 0, 'timeout_s': 600, 'check_after': 0} - goals = self.path / 'state.sqlite.lookout/goals' - goals.mkdir(parents=True, exist_ok=True) - (goals / 'calc.fix.json').write_text(json.dumps(goal)) - met = goals / 'met/calc.fix.json' - deadline = time.monotonic() + 30 - while not met.exists(): - self.assertLess(time.monotonic(), deadline, 'the goal was never checked') - time.sleep(.1) - self.assertEqual(json.loads(met.read_text())['turn'], 0) - self.assertFalse((goals / 'calc.fix.json').exists()) - def test_a_goal_whose_agent_only_answers_stops(self): note, prompts = self.goal_run('false') # The turn that set it called the tool; two that only answered end it.