From 374f01e84f5ec559f23611ca4efa77529df91857 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 03:47:01 +0000 Subject: [PATCH] Stop a paused script's tool calls at the call, not at the next look An end-to-end test (a real exec script calling the stand-in MCP server) showed that a pause during a slow read did not stop the script's next call: the stop flag was looked at every 50 ms and by QuickJS's interrupt every few thousand instructions, so a read answered just after the pause let the script call its next tool, which reached the server. The flag is now a Halt: stopping it wakes the run at once, and every tool call and store() looks at it as it is about to happen, so a stopped script makes none whatever its JavaScript does before the interrupt ends it. The stand-in MCP server notes each call as it arrives ($MCP_CALLS) and its `slow` can wait for a file (`until`), so the test pauses during the read, lets it answer right after, and checks the server saw only the read. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGG873rfqV9CdHz7KrE --- sonny/FEATURES.md | 2 +- sonny/src/exec.rs | 89 ++++++++++++++++++++++++++++++++------------- sonny/src/served.rs | 84 ++++++++++++++++++++++++++++++++++++++++++ tests/mcp_server.py | 13 +++++-- 4 files changed, 159 insertions(+), 29 deletions(-) diff --git a/sonny/FEATURES.md b/sonny/FEATURES.md index 13070b18b..7fd9f0860 100644 --- a/sonny/FEATURES.md +++ b/sonny/FEATURES.md @@ -141,7 +141,7 @@ names it: upgrade step `ID`. Both name the release they are deleted in. | Automations (`sonny automation`, triggers before 0.8.0): wake an agent at a time, on a file, a commit, or by name | shipped | app triggers 10-10 #89-#92; moved into Sonny 10-27 #189; skipped checks, `--replace` 10-28 #241; a tool 10-28 #278; Linux via systemd 10-29 #344; `--start` agents get Sonny's tools 10-30 #403; a fire's turn that failed is told to the agent that set it up 10-30; `--start` agents gated and sandboxed as `--new` makes them 10-31; each HOME's labels its own, made-before ones relabeled once, a job whose HOME is gone unloads itself 10-31 #453; one scheduler job per HOME runs every trigger, in place of a LaunchAgent or unit per trigger that each lead look rewrote (a background-item alert each time), old jobs moved over once, changed 10-31 #529; a repeating trigger's fire that finds its agent working waits for the work, one fire of it at a time, as anacron runs a missed job once (was skipped), changed 10-31; a scheduler started after its HOME was deleted lets its job go rather than make HOME again (#607 review) 11-01; an `--if` that cannot run (an error, or past 60 s) tells the agent once when it breaks and once when it works again, as alerts fire on a change of state (was silent) 11-01; an `--if` answers as `test` and `grep -q` do: exit 0 sends, exit 1 skips, and any other exit (a command not found, a crash, a signal) could not check and fails the run, told once with the end of its stderr, where it skipped as a quiet no; Sonny's own gates follow (`memory changed` exits 2 when it cannot read, `sonny ask open` too) (H7) 11-05; an `--if` waits out a `/pause` before it runs, as the turn a fire sends does (was run during it; Monday rehearsal) 11-01; after a restore, each folder's lead sets up again from the backup's list the triggers the person asked for 11-01; `--shadow` (the tool's `shadow`): each fire goes to a read-only copy of the agent with no shell (its commands would reach the daemon's socket), none of the person's `allow` command rules, no gate a person answers and of Sonny's tools only `exec`, whose MCP calls that do not say they only read are refused, and the copy's answer comes to whoever set it up as a report, a dry run for moving a job from elsewhere onto a trigger 11-01; renamed automations for agents too: the `automations` tool and its `run` (was `triggers` and `fire`), `~/.agent/automation`, `~/.agent/automations` (moved once), `[automation NAME · …]` and `origin: "automation"` (George, 11-01) 11-01; the tool and the skill say a turn cap counts the agent's history and is only for a spending limit the person asks for (every cap an agent set itself failed its first run in the rename eval) 11-01 | Work that starts while nobody is at the terminal; an agent hears when its own automation fails instead of only the person seeing it | ? | | Turn-end triggers | removed | added 10-10 #89-#92; removed 10-27 #189 | Leads hear of task turns from task updates and the lookout | ? | | Goals: keep an agent working until a check command passes | experimental | added 10-29 #341; `/goal WHAT` asks the agent to set one, `/goal` shows, `/goal clear` clears 10-30 #397; how a goal ended survives a lookout restart, and a turn that ends during a check is checked after it 10-31; a check waits for a place among the processes Sonny runs for agents (`sonny.tool_processes`), within its timeout 10-31 | Autonomy with a check, not a model's say-so | ? | -| MCP servers through one `exec` script tool (`/mcp`, `sonny mcp tool`) | experimental | added 10-29 #321; saved files kept a week 10-29 #327; four scripts at once 10-29 #334; brought in from Claude Code, Codex, OpenCode and Kiro (`sonny mcp import`, offered by setup) 10-30 #415; each call approved on its own by Codex's `approval_mode` (default `auto`), not the script, 10-30 #393; bounded as it runs (output 1 MB head and tail, 8 calls in flight, 16 MB results, one deadline from the call, colliding tool names refused, cache 0600 by digest) 10-31 #474; runs in the tool host, apart from the lookout, 10-31 #481; `image()` shows the model the picture, in order with the text, not a saved file's path, 10-31 #494 | The person's MCP servers without loading every tool's schema; per-call approval as Codex does, so reads run and only risky calls ask | ? | +| MCP servers through one `exec` script tool (`/mcp`, `sonny mcp tool`) | experimental | added 10-29 #321; saved files kept a week 10-29 #327; four scripts at once 10-29 #334; brought in from Claude Code, Codex, OpenCode and Kiro (`sonny mcp import`, offered by setup) 10-30 #415; each call approved on its own by Codex's `approval_mode` (default `auto`), not the script, 10-30 #393; bounded as it runs (output 1 MB head and tail, 8 calls in flight, 16 MB results, one deadline from the call, colliding tool names refused, cache 0600 by digest) 10-31 #474; runs in the tool host, apart from the lookout, 10-31 #481; `image()` shows the model the picture, in order with the text, not a saved file's path, 10-31 #494; a script whose turn was paused or ended makes no tool call and stores nothing from then on, checked as each call is about to reach its server, and its run ends the moment it is stopped rather than at a look every 50 ms (a read answered just after a pause let the script make its next call) 11-05 | The person's MCP servers without loading every tool's schema; per-call approval as Codex does, so reads run and only risky calls ask | ? | | Commons: threads every agent of every project reads and writes (`commons` tool, `/commons`); `[sonny] commons = false` turns it off; a new thread invites agents whose turns touch its title, at random and all at once, as many as fill three seats with 90% odds at the week's answer rate (Laplace), at most the day's threads (every tenth thread one invite is a golden ticket to any agent, paying twice, its time the agent's own), the square root of the week's agents threads a day (three at least) plus one per answered thread that week (twice that at most), none from a thread an invited turn starts; an invite pays for its turn and, for the first three to reply, 1M tokens in its thread, replies waking them while they last; posting in a thread subscribes the agent to it in the lookout (`commons.THREAD`), which tells it of new posts at most once every 15 minutes (`[sonny.sources.commons] digest`, which takes `steer` too), and removing that leaves the thread; `commons_invites`, `commons_invite_tokens` and `sonny.sources.commons.digest` in /config | experimental | added 10-30 #392; on/off setting and invites 10-30; an empty commons counts 0, not null 10-30; each post names its model as people say it (`Opus 5.5`, `GPT 6.1 Sol`) 10-31; invites a day grow with the square root of the week's agents, golden tickets pay twice as free time 10-31; invites overbook to fill three seats at the week's answer rate 10-31; replies reach posters through lookout subscriptions with an hourly digest instead of attach notes, and agents that had posted are subscribed once on upgrade 10-31; the commons is set as an ordinary source, `[sonny.sources.commons] digest` (15 minutes) and `steer` replacing `[sonny] commons_digest` (an hour), moved once on upgrade 11-01; an agent that removed its subscription to a thread is no longer woken by the thread's replies through its invite's tokens, which ignored the removal (H12) 11-05 | A pull-only place for agents to work together across projects, even when it isn't immediately productive; reads are counted so later we can see what mattered | ? | | `/sky`: every agent the daemon keeps, drawn as a sky in the pane: leads on the inner ring, the agents each made fanned out behind it, the person in the middle, on a circle; a calm window to watch live: dim lines for who made whom and a dim curve for every pair that has talked, past turns and live (the last 96 pairs, as in 0.7.0), denser and brighter the more the pair talked this hour, a message as a spark (blue between agents, magenta with you) that bursts round its sender and its receiver, sized by how much the agent is messaged, a commons post as a small ripple, a tool call as a cyan twinkle in a shape for its kind; working agents breathe (4.6s) as a block, idle ones rest as a single dot; a yellow `?` or red `✗` by the name of one that needs you or failed; a chat log beside the sky when wide (under it when narrow), newest at the bottom, fading as it rises; `?` swaps it for a legend and stats; `z` zen hides the log, footer and every name but what needs you or failed; `m` colours each agent by its model in place, the footer naming the colours; arrows pick a star and Enter centres on it, rings by hops, the log filtered, stars gliding to their places, Esc back; under 60 columns or 24 rows a list instead; `r` replays the last hour (space pause, ←→ step, `[` `]` speed, Esc live); `sonny sky [--center NAME] [--replay [WHEN]]` draws it on its own | experimental | added 10-30 #457; on its own in the whole terminal, `sonny sky` 10-30; lines at full weight, states, tool shapes, the list for small panes, a legend on `?` 10-30; back on the visual system: magenta to and from you, blue between agents, the commons ripple without a hue, lines in the measured muted gray, `sonny sky` asks the theme and honours `NO_COLOR` 10-31; the agreed sky design, step 1: hues down to needs-you and failed (plus the cyan twinkle), a measured faint weight for structure (in the visual system), short trails, the side chat log, stats on `?`, breathing, zen on `z`, names kept clear of each other and of stars 10-31; step 2: lenses on Tab, centring on any agent (arrows and Enter, `--center`) 10-31; step 3: an ellipse that fills the pane (at most twice as wide as high), arms in the order projects started and as wide as the square root of their size, layout changes gliding over a third of a second 10-31; the receive flash back, sized by how much the agent is being messaged (a halo in braille dots whatever the pane, six off its star for one message, its area growing with the heat: 6, 8.5, 12, 17 dots, fading in place), and the commons ripple sized by how many agents its thread reaches (George, 10-31) 10-31; step 4: folding at scale, a star only for who needs you, failed, works, glows or leads, as many as the pane has room for, the rest a faint dot each so a cloud's density says its size, counts only on the biggest clouds, names spaced a row apart and placed beside, over or under their star, leads of the biggest projects named first, the newest dozen sparks; 10,000 agents draw in about 3 ms a frame (was 6.6) 10-31; the receive burst moves as in 0.7.0 again (pops out, shrinks into its star, fades), sized from 0.7.0's for one message up to 0.7.0's commons ripple at most (5 dots, growing by the square root of two each time the heat doubles, 24 at most), and the side chat log at most 12 lines tall (George, 10-31) 10-31; back toward 0.7.0 (George, 10-31): every pair that has talked keeps its curve again at 0.7.0's weight, an agent at rest is a dot and a block only while it works or is lit, the sender bursts as well as the receiver; one view, its curves weighted by the hour (past turns counted by the time the daemon wrote them), the talk and models lenses gone, `m` colouring agents by model instead; sparks and bursts coloured where 0.7.0 coloured them, in the visual system's hues (blue between agents, magenta with you); a circle again; a clear cell either side of each name (George, 10-31) 10-31; step 5: replay, `r` or `sonny sky --replay [WHEN]`, from the daemon's event times (`follow` from `since_ms`), the sky starting from who was here then, all idle, playing what was said and done eight times as fast, the chat log saying when, a replay line in the footer 10-31; filled to the rim (George, 10-31): agents that made none on the outer edge as a tree's leaves (d3's cluster layout), every star named where there is room, the working and lit first, two cells between names on a row, stretched up to twice as wide as high to fill a wide pane, throwaway copies (the approver's judges, suggestions) folded into the agent that made them, a judge's question drawn from the edge as "a judge" 10-31; the replay's tape stays within its bound while the past loads and while paused, a frame takes in at most half a frame's work, the hour's pair counts kept as messages come rather than counted again each frame 10-31; step 6: a haiku from the busiest agent (messages and tool calls over 10 minutes, passing only to one 50% busier after a 10-minute hold), written by a copy of it allowed no tool, streamed a word at a time atop the log or top right in zen, signed, 8 a day at most, kept in `~/.agent/sonny/haiku.json` so every sky shows the same one, `sky_haikus`/`sky_haiku_window`/`sky_haiku_margin`/`sky_haiku_hold` in `[sonny]` 10-31; a replay draws the curve of every pair that had talked before it from the first frame (the last message of each, read from the turns kept, as live), and shows the haiku up at its time, the day's haikus kept in the file (George, 10-31) 10-31; schedules: a tick just outside each agent a trigger wakes, lit as its next run nears, a spark and a log line when one is made, `?` says what is next up; who it comes from: a reviewer's no streaks red onto the agent, a hand-off yellow, the judge's own prompt not drawn; commons invites from the thread's starter, margin notes from you, a goal pulses; the haiku and the log keep two blank rows from the key hints and the footer; a haiku signature too long for its column breaks after the name, the time on the row under it where the name starts, so "busiest for" is never cut (George, 11-02) 11-02; a haiku the copy did not write (an empty or refused reply, a turn that failed or ran long, a daemon it could not reach) leaves the one before showing in every sky, its claim still counted and not tried again that hold, and `haiku.json` says why under `unwritten`; a file an earlier claim had emptied shows the last haiku of the day (Sky went blank and stayed so, George, 11-03; D1) 11-05 | George wanted to see the population of agents and how they relate, lighting up as they talk, while working; it reads only what the daemon already tells any client (`bots`, `follow` of every agent); dim braille all but vanished in some themes and a small pane had no room for a sky (btop changes layout by size); George keeps it open in the background to watch live and wanted it calm, with a game-style chat log beside it (Weiser and Brown's calm technology; Bartram et al.: slow motion distracts least); George wanted lenses on the same data and to see the world from a busy lead's side (Yee et al. 2001, animated radial re-centring; Furnas, focus plus context); replay, the sky design's ninth call, plays back what happened while George was away, as a game's replay does, which needed only when each event was written from the daemon | ? | | Browser use: agent-browser ships with Sonny (a Homebrew dependency), its own skill as its binary serves it, first-run setup offers Chrome for Testing when it finds no Chrome; agents drive it with the `browser` tool the tool host answers, in a session of their own, screenshots as pictures, nothing that reaches past the browser, the actions in `sonny.browser_confirm` through the agent's approvals | experimental | added 10-31 #495; the `browser` tool 10-31; checked each week against the latest agent-browser, brought to 0.41.1 (its new flags but those that reach past the session, word counting as its source counts) 11-01; run only from a copy no agent can swap 11-02; sessions walled off from sandboxed agents 11-02; an agent counts as writing the copy's folder only as its sandbox lets it, so a lead opened in HOME, whose sandbox keeps `~/.agent` read-only, no longer stops the browser and the desktop for every agent; a copy linked to from elsewhere is copied again, and a refusal says why and what to change (J7) 11-05 | George wanted agents clicking around on the web, fast; Vercel's agent-browser was fastest of what we timed (a warm Rust daemon over CDP, about 15 ms a click), and its skill comes from the binary so it never goes stale; letting its command out of the sandbox by prefix let every flag out with it (`--executable-path` runs any program), so, as Codex and Claude Code do, the browser is a tool with its own policy (George, 10-31); it opens file pages anywhere and new-tab links, as the sandbox reads anywhere, since we follow Codex where an earlier choice was stricter (George, 10-31 15:16Z); a weekly check opens an issue for what a release documents that the tool does not pass, and fails for what the tool passes that a release dropped, since George wants to hear of new features rather than have a check fail on them (George, 11-01 23:38Z); "let’s use all the latest" (George, 11-02 00:06Z); a session does what it is asked for whoever reaches its socket, and a sandboxed agent with its network on reaches every Unix socket, so its sockets are in the folder the daemon walls off (George, 11-02) | ? | diff --git a/sonny/src/exec.rs b/sonny/src/exec.rs index 6610e6c0a..d25a9e49d 100644 --- a/sonny/src/exec.rs +++ b/sonny/src/exec.rs @@ -731,7 +731,7 @@ pub fn whole_file() -> Option { /// Run a script against the servers `hub` serves, keeping what it saves /// under `dir`. -async fn run( +pub(crate) async fn run( hub: &Hub, dir: Option<&Path>, bot: &str, @@ -801,12 +801,11 @@ async fn run_keeping( said: Arc::default(), pictures: Arc::default(), calls: Arc::default(), - stopped: Arc::default(), + halt: Arc::default(), steps: steps.map(Arc::new), clock: Clock::new(deadline, running), }; - let stopped = Arc::clone(&run.stopped); - let _stop = Stop(Arc::clone(&stopped)); + let _stop = Stop(Arc::clone(&run.halt)); let (output, calls, pictures) = ( Arc::clone(&run.said), Arc::clone(&run.calls), @@ -1071,11 +1070,52 @@ fn ceil(text: &str, mut at: usize) -> usize { at } -/// Sets the script's stop flag when the answer is dropped or done. -struct Stop(Arc); +/// Halts the script when the answer is dropped or done. +struct Stop(Arc); impl Drop for Stop { fn drop(&mut self) { - self.0.store(true, Ordering::SeqCst); + self.0.stop(); + } +} + +/// Whether a script was stopped, its answer dropped as an interrupted or +/// paused turn drops it: from then on it makes no call and stores nothing, +/// whatever its JavaScript does before the interrupt ends it, and its run +/// ends at once rather than at the next look. +#[derive(Default)] +struct Halt { + stopped: AtomicBool, + woken: tokio::sync::Notify, +} + +impl Halt { + fn stop(&self) { + self.stopped.store(true, Ordering::SeqCst); + self.woken.notify_waiters(); + } + + fn is_stopped(&self) -> bool { + self.stopped.load(Ordering::SeqCst) + } + + /// Done once the script is stopped. + async fn stopped(&self) { + loop { + // Made before the look, so a stop between the two still wakes it. + let woken = self.woken.notified(); + if self.is_stopped() { + return; + } + woken.await; + } + } + + /// The refusal of what a stopped script asks for. + fn refuse(&self, what: &str) -> Result<(), String> { + match self.is_stopped() { + true => Err(format!("{what} refused: its turn ended")), + false => Ok(()), + } } } @@ -1166,7 +1206,7 @@ struct Run { said: Arc>, pictures: Arc>>, calls: Arc>>, - stopped: Arc, + halt: Arc, steps: Option>, clock: Arc, } @@ -1342,25 +1382,23 @@ impl Run { async fn script(self, code: String) -> Result { let runtime = AsyncRuntime::new().map_err(|e| e.to_string())?; runtime.set_memory_limit(MEMORY).await; - let (stopped, clock) = (Arc::clone(&self.stopped), Arc::clone(&self.clock)); + let (halt, clock) = (Arc::clone(&self.halt), Arc::clone(&self.clock)); runtime - .set_interrupt_handler(Some(Box::new(move || { - stopped.load(Ordering::SeqCst) || clock.check() - }))) + .set_interrupt_handler(Some(Box::new(move || halt.is_stopped() || clock.check()))) .await; let context = AsyncContext::full(&runtime) .await .map_err(|e| e.to_string())?; - let stopped = Arc::clone(&self.stopped); + let halt = Arc::clone(&self.halt); let ran = context.async_with(async |ctx| self.evaluate(ctx, &code).await); let ended = tokio::select! { ended = ran => ended, _ = self.clock.run_out() => Ended::Terminated("it ran out of time".into()), - _ = stopped_flag(&stopped) => Ended::Terminated("its turn ended".into()), + _ = halt.stopped() => Ended::Terminated("its turn ended".into()), }; Ok(match ended { // An interrupt ends the evaluation as an uncatchable error. - Ended::Failed(_) if self.stopped.load(Ordering::SeqCst) => { + Ended::Failed(_) if self.halt.is_stopped() => { Ended::Terminated("its turn ended".into()) } Ended::Failed(_) if self.clock.out() => Ended::Terminated("it ran out of time".into()), @@ -1429,6 +1467,7 @@ impl Run { steps: self.steps.clone(), clock: Arc::clone(&self.clock), at_once: Arc::new(tokio::sync::Semaphore::new(CALLS)), + halt: Arc::clone(&self.halt), }; globals.set( "__call", @@ -1471,14 +1510,13 @@ impl Run { })) })?, )?; - let store = self.store.clone(); + let (store, halt) = (self.store.clone(), Arc::clone(&self.halt)); globals.set( "__store", Function::new(ctx.clone(), move |key: String, value: String| { said_of( - store - .as_deref() - .ok_or("no HOME".into()) + (halt.refuse("store()")) + .and_then(|()| store.as_deref().ok_or("no HOME".into())) .and_then(|file| keep(file, &key, &value)) .map(|()| "stored".to_owned()), ) @@ -1496,12 +1534,6 @@ impl Run { } } -async fn stopped_flag(stopped: &AtomicBool) { - while !stopped.load(Ordering::SeqCst) { - tokio::time::sleep(Duration::from_millis(50)).await; - } -} - /// What a host function says to the script: `{ok}` or `{error}`. fn said_of(result: Result) -> String { match result { @@ -1523,6 +1555,7 @@ struct Caller { clock: Arc, /// Places for the script's calls in flight. at_once: Arc, + halt: Arc, } impl Caller { @@ -1536,6 +1569,8 @@ impl Caller { } async fn called(&self, id: String, arguments: String) -> Result { + let named = format!("tools.{id}"); + self.halt.refuse(&named)?; let Some(nested) = self.nested.get(&id).cloned() else { let mut error = format!("tools.{id} is not a tool; ALL_TOOLS lists them"); if !self.failed.is_empty() { @@ -1575,6 +1610,10 @@ impl Caller { // A place among the script's calls in flight, held until its // result is the script's. let _place = self.at_once.acquire().await.map_err(|e| e.to_string())?; + // The last look before the call reaches its server: a script + // stopped meanwhile, though its JavaScript runs on until the + // interrupt ends it, makes none. + self.halt.refuse(&named)?; *self .calls .lock() diff --git a/sonny/src/served.rs b/sonny/src/served.rs index d69e97fd8..e4e492086 100644 --- a/sonny/src/served.rs +++ b/sonny/src/served.rs @@ -1860,6 +1860,90 @@ mod tests { assert_eq!(server.serving.lock().unwrap().jobs.len(), 1); } + #[tokio::test] + async fn a_pause_stops_a_script_s_tool_calls_even_one_answered_right_after_it() { + use agent_client::config::McpServer; + let dir = std::env::temp_dir().join(format!("sonny-pause-exec-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (log, gate) = (dir.join("calls"), dir.join("go")); + let reached = || std::fs::read_to_string(&log).unwrap_or_default(); + // A server that notes each call as it arrives; its slow read + // answers once the test lets it. + let server = McpServer { + command: Some("python3".into()), + args: vec![concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/mcp_server.py").into()], + env: [("MCP_CALLS".to_owned(), log.display().to_string())].into(), + enabled: true, + ..McpServer::default() + }; + let hub = crate::mcp::Hub::new(Some(dir.join("mcp"))); + hub.configure(&[("test".to_owned(), server)].into()).await; + // A script that reads slowly, then keeps a note and writes, each + // tried whatever the one before said. + let input = format!( + "await tools.mcp__test__slow({{until: {}}});\n\ + try {{ store('note', 1); }} catch (e) {{}}\n\ + await tools.mcp__test__echo({{text: 'written'}});\ntext('done');", + serde_json::to_string(&gate.display().to_string()).unwrap() + ); + let client = crate::tests::daemon_answering(|asked| match asked["op"].as_str() { + Some("tool_calls") => json!({ + "calls": [{"bot": "b", "turn": 1, "call_id": "c1", "name": EXEC}], + "next_after": null}), + _ => json!({}), + }) + .await; + let server = Server { + nudge: Arc::new(Notify::new()), + serving: Default::default(), + }; + let run = { + let (hub, dir, input) = (hub.clone(), dir.clone(), input.clone()); + move |_call: Value| { + let (hub, dir, input) = (hub.clone(), dir.clone(), input.clone()); + async move { + crate::exec::run(&hub, Some(&dir), "b", &input, Duration::from_secs(30), None) + .await + .map_err(|e| ("exec_refused", e)) + } + } + }; + sweep(&client, &server.serving, &server.nudge, run.clone()) + .await + .unwrap(); + // The read reaches the server, and the script waits on it. + let started = std::time::Instant::now(); + while reached() != "slow\n" { + assert!(started.elapsed() < Duration::from_secs(20), "{}", reached()); + tokio::time::sleep(Duration::from_millis(10)).await; + } + // Paused during the read, which answers the moment after. + server.event(&json!({"event": "paused", "pause": {"reason": "stop"}})); + std::fs::write(&gate, "").unwrap(); + // Long enough for the read's answer to come back and a script that + // went on to write. + tokio::time::sleep(Duration::from_millis(1500)).await; + assert_eq!(reached(), "slow\n"); + assert!(!dir.join("store").join("b.json").exists()); + { + let calls = server.serving.lock().unwrap(); + let key = (1, "c1".to_owned()); + let Some(Job::Answered(answered)) = calls.jobs.get(&key) else { + panic!("the call is answered"); + }; + assert_eq!(answered["error"]["code"], PAUSED.0); + } + // While the pause holds, the call listed again starts nothing. + sweep(&client, &server.serving, &server.nudge, run) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(300)).await; + assert_eq!(reached(), "slow\n"); + hub.stop().await; + let _ = std::fs::remove_dir_all(&dir); + } + #[tokio::test] async fn a_call_stopped_while_the_calls_were_listed_does_not_start_again() { use std::sync::atomic::{AtomicUsize, Ordering}; diff --git a/tests/mcp_server.py b/tests/mcp_server.py index 22284bcbe..982884f56 100644 --- a/tests/mcp_server.py +++ b/tests/mcp_server.py @@ -1,7 +1,9 @@ """A stand-in stdio MCP server for Sonny's tests: `echo`, `add` (with structured output), `fail` (a tool error) and `slow`. Each start appends its pid to $MCP_STARTS when that is set, and waits $MCP_START_MS before it -answers; calls are answered at once, each on a thread of its own.""" +answers; calls are answered at once, each on a thread of its own, and each +call's tool name is appended to $MCP_CALLS when that is set, as it +arrives: what reached the server, whatever happens to the answer.""" import json import os import sys @@ -23,8 +25,8 @@ 'required': ['a', 'b']}, 'outputSchema': {'type': 'object', 'properties': {'sum': {'type': 'number'}}, 'required': ['sum']}}, {'name': 'fail', 'description': 'Always fails.', 'inputSchema': {'type': 'object'}}, - {'name': 'slow', 'description': 'Sleeps for ms milliseconds.', - 'inputSchema': {'type': 'object', 'properties': {'ms': {'type': 'number'}}}}, + {'name': 'slow', 'description': 'Sleeps for ms milliseconds, or until the file until names exists.', + 'inputSchema': {'type': 'object', 'properties': {'ms': {'type': 'number'}, 'until': {'type': 'string'}}}}, ] @@ -45,6 +47,8 @@ def result(name, arguments): return {'content': [{'type': 'text', 'text': str(total)}], 'structuredContent': {'sum': total}} if name == 'slow': time.sleep(arguments.get('ms', 0) / 1000) + while arguments.get('until') and not os.path.exists(arguments['until']): + time.sleep(0.01) return {'content': [{'type': 'text', 'text': 'slept'}]} return {'content': [{'type': 'text', 'text': 'it failed'}], 'isError': True} @@ -64,6 +68,9 @@ def result(name, arguments): elif method == 'tools/call': # Each call on a thread of its own, so calls overlap. params = message['params'] + if os.environ.get('MCP_CALLS'): + with open(os.environ['MCP_CALLS'], 'a') as f: + f.write(f"{params['name']}\n") threading.Thread(target=lambda request=request, params=params: send( {'jsonrpc': '2.0', 'id': request, 'result': result(params['name'], params.get('arguments') or {})}), daemon=True).start()