From b82cf1ef8802056687861ff9de29500ef54ccd2d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 29 Sep 2026 04:18:24 +0530 Subject: [PATCH 01/56] feat(task): capture screenshots when a run stops Add a `capture` method to the `FlowRunner` trait and integrate it into the task lifecycle so that a screenshot of the task's surface is taken each time a run stops, before the surface is released. This allows finished or failed tasks to leave a final screenshot for the caller to read, stored in the task state as an artifact. Auto-committed-on: macbook Co-authored-by: Medulla --- crates/tinycomputer-engine/src/lib.rs | 4 +- .../tinycomputer-engine/src/task/artifact.rs | 79 +++++++++++++++++++ .../src/task/controller.rs | 2 +- crates/tinycomputer-engine/src/task/drive.rs | 4 + crates/tinycomputer-engine/src/task/mod.rs | 17 +++- crates/tinycomputer-engine/src/task/store.rs | 4 + 6 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 crates/tinycomputer-engine/src/task/artifact.rs diff --git a/crates/tinycomputer-engine/src/lib.rs b/crates/tinycomputer-engine/src/lib.rs index 1212d8cb..ebfaba90 100644 --- a/crates/tinycomputer-engine/src/lib.rs +++ b/crates/tinycomputer-engine/src/lib.rs @@ -49,7 +49,9 @@ pub use planner::{ }; pub use rescue::{Briefing, Guidance, MAX_RESCUE_STEPS, MAX_RESCUES, Rescuer, SCREEN_CHARS}; pub use shape::{Harvest, RECORDS_CHARS, Shaper}; -pub use task::{FlowFuture, FlowRunner, MAX_AWAIT_MS, MAX_TASKS, Tasks, TextFuture, capabilities}; +pub use task::{ + CaptureFuture, FlowFuture, FlowRunner, MAX_AWAIT_MS, MAX_TASKS, Tasks, TextFuture, capabilities, +}; pub use tinycomputer_bus::DesktopResponse; use tinycomputer_desktop::Desktop; pub use workspace::{BROWSER, Workspace}; diff --git a/crates/tinycomputer-engine/src/task/artifact.rs b/crates/tinycomputer-engine/src/task/artifact.rs new file mode 100644 index 00000000..73866463 --- /dev/null +++ b/crates/tinycomputer-engine/src/task/artifact.rs @@ -0,0 +1,79 @@ +//! The screenshot a stopped run leaves: taken before the task's surfaces are +//! released, kept in `TaskReport.artifacts`, and shown on the status that +//! has room for one. + +use std::time::Duration; + +use tinycomputer_bus::agent::TaskStatus; + +use super::FlowRunner; +use super::store::Cell; + +/// How long a capture may take before the task goes on without it: a +/// screenshot is evidence, never a reason to hold a task's final state back. +pub(super) const CAPTURE_TIMEOUT: Duration = Duration::from_secs(10); + +/// `status` with a screenshot of the task's surface attached, when the +/// status is one a caller acts on — a checkpoint, an approval, a person's +/// turn, or the end — and the runner can take one. Every screenshot taken +/// is also kept for `TaskReport.artifacts`. +pub(super) async fn captured(cell: &Cell, runner: &dyn FlowRunner, status: TaskStatus) -> TaskStatus { + if matches!( + status, + TaskStatus::Running | TaskStatus::NeedsInput { .. } | TaskStatus::NeedsPlan { .. } + ) { + return status; + } + let Some(shot) = capture(cell, runner).await else { + return status; + }; + match status { + TaskStatus::NeedsApproval { + action, + target, + screenshot: None, + } => TaskStatus::NeedsApproval { + action, + target, + screenshot: Some(shot), + }, + TaskStatus::Checkpoint { + reason, + location, + screenshot: None, + summary, + continuable, + } => TaskStatus::Checkpoint { + reason, + location, + screenshot: Some(shot), + summary, + continuable, + }, + TaskStatus::NeedsHuman { + reason, + screenshot: None, + } => TaskStatus::NeedsHuman { + reason, + screenshot: Some(shot), + }, + other => other, + } +} + +/// Takes a screenshot of the task's surface, keeps it for the report, and +/// returns it; `None` when the runner has none to give in time. +pub(super) async fn capture( + cell: &Cell, + runner: &dyn FlowRunner, +) -> Option { + let id = cell.view.borrow().id.clone(); + let shot = tokio::time::timeout(CAPTURE_TIMEOUT, runner.capture(&id)) + .await + .ok() + .flatten()?; + if let Ok(mut state) = cell.state.lock() { + state.artifacts.push(shot.clone()); + } + Some(shot) +} diff --git a/crates/tinycomputer-engine/src/task/controller.rs b/crates/tinycomputer-engine/src/task/controller.rs index 184642bc..3f7e9527 100644 --- a/crates/tinycomputer-engine/src/task/controller.rs +++ b/crates/tinycomputer-engine/src/task/controller.rs @@ -296,7 +296,7 @@ impl Tasks { flow: Some(state.flow.clone()), steps: state.steps.clone(), records: records(&state.reads), - artifacts: Vec::new(), + artifacts: state.artifacts.clone(), learned: state.learned.clone(), trace: state.exchanges.clone(), rescues: state.rescues.clone(), diff --git a/crates/tinycomputer-engine/src/task/drive.rs b/crates/tinycomputer-engine/src/task/drive.rs index cbbb8d2c..d917a1ed 100644 --- a/crates/tinycomputer-engine/src/task/drive.rs +++ b/crates/tinycomputer-engine/src/task/drive.rs @@ -7,6 +7,7 @@ use std::time::{Duration, Instant}; use tinycomputer_bus::agent::TaskStatus; +use super::artifact::{capture, captured}; use super::budget::{elapsed_budget_failed, run_request, stop_task}; use super::human::human_wall; use super::interpret::{Next, finished, run_outcome}; @@ -152,6 +153,7 @@ pub(super) async fn drive(cell: Arc, runner: Arc, runs: Ve continue; } }; + let status = captured(&cell, runner.as_ref(), status).await; let summary = redacted.redact(&stopped_summary(&status)); let ended = matches!( status, @@ -170,6 +172,8 @@ pub(super) async fn drive(cell: Arc, runner: Arc, runs: Ve /// Every run finished: the task is done, with what it read, shaped as its /// `output` asks when it asks. pub(super) async fn finish(cell: &Cell, runner: &dyn FlowRunner) { + // The last look at the surface, before shaping and before release. + let _ = capture(cell, runner).await; let (answer, records, harvest) = { let Ok(state) = cell.state.lock() else { return; diff --git a/crates/tinycomputer-engine/src/task/mod.rs b/crates/tinycomputer-engine/src/task/mod.rs index 0b257f00..f2434e46 100644 --- a/crates/tinycomputer-engine/src/task/mod.rs +++ b/crates/tinycomputer-engine/src/task/mod.rs @@ -33,8 +33,10 @@ //! `store`; the background run in `drive`, capped by `budget` and briefed by //! `brief`; answering a paused task in `resume`; rescuing a failed step in //! `recovery`; spotting a wall only a person can pass in `human`; and what -//! the caller sees in `publish`. +//! the caller sees in `publish`; the screenshots a stopped run leaves in +//! `artifact`. +mod artifact; mod brief; mod budget; mod controller; @@ -53,6 +55,7 @@ use std::future::Future; use std::pin::Pin; use tinycomputer_bus::agent::{TaskConstraints, TaskId}; +use tinycomputer_bus::browser::OutputRef; use tinycomputer_bus::{DesktopResponse, RunFlowRequest}; pub use controller::Tasks; @@ -65,6 +68,10 @@ pub type FlowFuture = Pin + Send>>; /// The future [`FlowRunner::visible_text`] returns. pub type TextFuture = Pin> + Send>>; +/// The future [`FlowRunner::capture`] returns: a held screenshot, if the +/// task's surface could take one. +pub type CaptureFuture = Pin> + Send>>; + /// Runs a task's flows, on surfaces that live as long as the task. pub trait FlowRunner: Send + Sync + 'static { /// Runs `request` for `task` within `constraints`, returning `RunFlow`'s @@ -83,6 +90,14 @@ pub trait FlowRunner: Send + Sync + 'static { Box::pin(async { Vec::new() }) } + /// A screenshot of the task's surface as it stands, held for the caller + /// to read. Taken when a run stops, before the task's surfaces are + /// released, so a finished or failed task still leaves one. `None` by + /// default, and whenever the surface cannot take one. + fn capture(&self, _task: &TaskId) -> CaptureFuture { + Box::pin(async { None }) + } + /// Lets go of whatever the task held, once it has ended. fn release(&self, _task: &TaskId) {} } diff --git a/crates/tinycomputer-engine/src/task/store.rs b/crates/tinycomputer-engine/src/task/store.rs index db082f36..b19ef18c 100644 --- a/crates/tinycomputer-engine/src/task/store.rs +++ b/crates/tinycomputer-engine/src/task/store.rs @@ -5,6 +5,7 @@ use std::collections::BTreeMap; use std::sync::atomic::Ordering; use std::sync::{Arc, Mutex}; +use tinycomputer_bus::browser::OutputRef; use tinycomputer_bus::agent::{ AgentResponse, Rescue, StartTaskRequest, TaskBudget, TaskConstraints, TaskId, TaskOutput, TaskStatus, TaskView, @@ -56,6 +57,8 @@ pub(super) struct State { pub(super) rescues: Vec, /// The shape the caller wants the answer in, if any. pub(super) output: Option, + /// Screenshots taken each time a run stopped, oldest first. + pub(super) artifacts: Vec, } /// A task's cumulative spend against its [`TaskBudget`], across every run. @@ -111,6 +114,7 @@ impl Tasks { spent: Spent::default(), rescues: Vec::new(), output: request.output.clone(), + artifacts: Vec::new(), }), worker: Mutex::new(None), rescuer: self.rescuer.clone(), From ce3df1f7f51c32802df9f80f93e00c7b6148c3f7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 29 Sep 2026 04:19:49 +0530 Subject: [PATCH 02/56] feat(runner): add capture method to WorkspaceRunner Implement the `capture` method on `WorkspaceRunner` to support taking screenshots of browser sessions. This enables the runner to capture the current state of a task's browser window by retrieving the active session and using the browser's screenshot functionality. Auto-committed-on: macbook Co-authored-by: Medulla --- .../tinycomputer/src/tinybus_module/runner.rs | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/tinycomputer/src/tinybus_module/runner.rs b/crates/tinycomputer/src/tinybus_module/runner.rs index 8c3d5a6e..e859babd 100644 --- a/crates/tinycomputer/src/tinybus_module/runner.rs +++ b/crates/tinycomputer/src/tinybus_module/runner.rs @@ -4,10 +4,14 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; -use tinycomputer_browser::{Browser, BrowserSurface, ScreenCursor, SessionOptions}; +use tinycomputer_browser::{ + Browser, BrowserSurface, ScreenCursor, ScreenshotRequest, SessionOptions, +}; use tinycomputer_bus::DesktopResponse; use tinycomputer_bus::agent::{SurfaceKind, TaskConstraints, TaskId}; -use tinycomputer_engine::{FlowFuture, FlowRunner, JevRuntime, TextFuture, Workspace}; +use tinycomputer_engine::{ + CaptureFuture, FlowFuture, FlowRunner, JevRuntime, TextFuture, Workspace, +}; use super::config::BrowserDefaults; use crate::Desktop; @@ -112,6 +116,22 @@ impl FlowRunner for WorkspaceRunner { }) } + fn capture(&self, task: &TaskId) -> CaptureFuture { + let session = self + .workspaces + .lock() + .ok() + .and_then(|workspaces| workspaces.get(task).and_then(|(_, browser)| browser.clone())) + .and_then(|browser| browser.session()); + let browser = self.browser.clone(); + Box::pin(async move { + browser + .screenshot(&session?, ScreenshotRequest::default()) + .await + .ok() + }) + } + fn release(&self, task: &TaskId) { let released = self .workspaces From 2d491c14febbd38ee2cdfc0defea19f8146aab66 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 29 Sep 2026 04:20:24 +0530 Subject: [PATCH 03/56] fix(browser): narrow refused-before-delivery to local lookups only The `refused_before_delivery` method on browser errors was too broad, including variants like `InvalidInput` and `StaleRef` that can also arrive in the engine's reply to a command. Only `NoSuchSession` and `NoSuchOutput` are guaranteed to be decided locally before any command reaches the browser, so the match is narrowed to those two. On the bus side, `TIMEOUT` and `PAGE_ERROR` now map to `inspect_state_then_retry_original` instead of a blind retry, because the action may already have reached the page and repeating it could duplicate the effect. Auto-committed-on: macbook Co-authored-by: Medulla --- crates/tinycomputer-browser/src/error/mod.rs | 15 +++++++-------- crates/tinycomputer-bus/src/browser/errors/mod.rs | 7 ++++++- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/crates/tinycomputer-browser/src/error/mod.rs b/crates/tinycomputer-browser/src/error/mod.rs index 0f4c7bd8..5a4d31a2 100644 --- a/crates/tinycomputer-browser/src/error/mod.rs +++ b/crates/tinycomputer-browser/src/error/mod.rs @@ -218,17 +218,16 @@ impl Error { } } - /// Whether this failure was decided before any command reached the - /// browser. + /// Whether this failure is always decided before any command reaches + /// the browser: a session or an output looked up locally and not found. + /// + /// Every other variant can also come back in the engine's reply to a + /// command it received — a stale ref, a refused origin, invalid input — + /// so its delivery stays unknown rather than claimed. fn refused_before_delivery(&self) -> bool { matches!( self, - Self::InvalidInput { .. } - | Self::NoSuchSession { .. } - | Self::StaleRef { .. } - | Self::BlockedByPolicy { .. } - | Self::NoSuchOutput { .. } - | Self::LimitExceeded { .. } + Self::NoSuchSession { .. } | Self::NoSuchOutput { .. } ) } diff --git a/crates/tinycomputer-bus/src/browser/errors/mod.rs b/crates/tinycomputer-bus/src/browser/errors/mod.rs index ba840236..da797bc2 100644 --- a/crates/tinycomputer-bus/src/browser/errors/mod.rs +++ b/crates/tinycomputer-bus/src/browser/errors/mod.rs @@ -157,6 +157,11 @@ pub fn code(name: &str) -> &'static str { /// (`refresh_snapshot_then_retry_original`), so an agent recovering from a /// stale ref does not care which surface it was on. /// +/// A failure that may have come after the action reached the page — a +/// timeout, a page error — is `inspect_state_then_retry_original`, never a +/// blind retry: a click or a form submission may already have happened, and +/// repeating it could duplicate the effect. Check the page first. +/// /// # Examples /// /// ``` @@ -177,7 +182,7 @@ pub fn recovery(name: &str) -> Option { STALE_REF => Some(hint("refresh_snapshot_then_retry_original", true, true)), NO_SUCH_ELEMENT => Some(hint("refresh_snapshot_then_choose_again", true, true)), NOT_ACTIONABLE => Some(hint("inspect_state_then_retry_original", true, true)), - TIMEOUT => Some(hint("retry_original", true, false)), + TIMEOUT | PAGE_ERROR => Some(hint("inspect_state_then_retry_original", true, true)), INVALID_INPUT => Some(hint("fix_request_then_retry", false, false)), NO_SUCH_SESSION => Some(hint("open_session_then_retry_original", false, false)), NO_SUCH_OUTPUT => Some(hint("capture_again_then_read", false, false)), From 59337e644b91b4ea17587dd9bdae64497f800755 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 29 Sep 2026 04:21:22 +0530 Subject: [PATCH 04/56] test(error): correct delivery disposition for engine-rejected commands and add recovery hint asserti Update the stale-ref and refused-navigation tests to reflect that the engine may reject a command after receiving it, so the delivery disposition is unknown rather than not delivered. Add a new test verifying that only local lookup errors claim nothing was delivered. Also add assertions that timeout and page-error envelopes include a recovery hint with an inspect-then-retry strategy, and that every recoverable error name has a corresponding recovery hint. Auto-committed-on: macbook Co-authored-by: Medulla --- .../src/error/error_tests.rs | 31 ++++++++++++++++--- .../src/browser/errors/errors_tests.rs | 18 +++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/crates/tinycomputer-browser/src/error/error_tests.rs b/crates/tinycomputer-browser/src/error/error_tests.rs index 26e68e7c..763c6858 100644 --- a/crates/tinycomputer-browser/src/error/error_tests.rs +++ b/crates/tinycomputer-browser/src/error/error_tests.rs @@ -149,7 +149,9 @@ fn a_stale_ref_envelope_matches_the_desktop_recovery() { .suggestion .is_some_and(|s| s.contains("BrowserSnapshot")) ); - assert_eq!(envelope.disposition.retry, RetryDisposition::Safe); + // The engine rejected the ref in its reply to a command it received, + // so nothing proves the command never arrived. + assert_eq!(envelope.disposition.retry, RetryDisposition::Unknown); } #[test] @@ -158,6 +160,10 @@ fn a_timeout_may_have_reached_the_page() { assert_eq!(envelope.code, "TIMEOUT"); assert_eq!(envelope.disposition.delivery, DeliveryDisposition::Unknown); assert!(envelope.suggestion.is_none()); + // A click may already have landed: inspect before repeating it. + let hint = envelope.recovery.expect("a timeout has a way out"); + assert_eq!(hint.strategy, "inspect_state_then_retry_original"); + assert!(hint.requires_fresh_snapshot); } #[test] @@ -173,10 +179,25 @@ fn a_refused_navigation_says_not_to_retry() { .suggestion .is_some_and(|s| s.starts_with("do not retry")) ); - assert_eq!( - envelope.disposition.delivery, - DeliveryDisposition::NotDelivered - ); + // The engine's domain filter can refuse it after receiving the command. + assert_eq!(envelope.disposition.delivery, DeliveryDisposition::Unknown); +} + +#[test] +fn only_local_lookups_claim_nothing_was_delivered() { + let not_delivered = |error: Error| { + error.envelope().disposition.delivery == DeliveryDisposition::NotDelivered + }; + assert!(not_delivered(Error::NoSuchSession { id: "s-1".into() })); + assert!(not_delivered(Error::NoSuchOutput { id: "o-1".into() })); + for error in every_variant().into_iter().filter(|error| { + !matches!( + error, + Error::NoSuchSession { .. } | Error::NoSuchOutput { .. } + ) + }) { + assert!(!not_delivered(error), "only a local lookup is provably undelivered"); + } } #[test] diff --git a/crates/tinycomputer-bus/src/browser/errors/errors_tests.rs b/crates/tinycomputer-bus/src/browser/errors/errors_tests.rs index 8c268a6d..b299ba5e 100644 --- a/crates/tinycomputer-bus/src/browser/errors/errors_tests.rs +++ b/crates/tinycomputer-bus/src/browser/errors/errors_tests.rs @@ -129,3 +129,21 @@ fn a_refused_navigation_offers_no_way_out() { } assert!(recovery(NO_SUCH_SESSION).is_some_and(|hint| !hint.retryable)); } + +#[test] +fn a_failure_after_delivery_says_inspect_before_retrying() { + for name in [TIMEOUT, PAGE_ERROR] { + let hint = recovery(name).expect("a recoverable failure has a way out"); + assert_eq!(hint.strategy, "inspect_state_then_retry_original", "{name}"); + assert!(hint.requires_fresh_snapshot, "{name}"); + } +} + +#[test] +fn every_recoverable_name_has_a_recovery_hint() { + for name in NAMES { + if is_agent_recoverable(name) { + assert!(recovery(name).is_some(), "{name} is recoverable without a hint"); + } + } +} From 1cd1743c3ac4da6141c5598f35de58ba9545289d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 29 Sep 2026 04:21:42 +0530 Subject: [PATCH 05/56] feat(sessions): guard concurrent session launches with an atomic reservation counter The session limit check was vulnerable to a race condition where two concurrent callers could both see room for the last slot and proceed to launch. A new atomic counter tracks in-flight launches, and a `Reservation` guard decrements it on drop, so the limit is enforced atomically from check through launch completion. Auto-committed-on: macbook Co-authored-by: Medulla --- .../tinycomputer-browser/src/sessions/mod.rs | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/crates/tinycomputer-browser/src/sessions/mod.rs b/crates/tinycomputer-browser/src/sessions/mod.rs index f70f6711..711d7b6f 100644 --- a/crates/tinycomputer-browser/src/sessions/mod.rs +++ b/crates/tinycomputer-browser/src/sessions/mod.rs @@ -6,7 +6,7 @@ use std::collections::HashMap; use std::path::PathBuf; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use serde_json::{Value, json}; @@ -32,6 +32,20 @@ pub struct Browser { outputs: Mutex, scratch: PathBuf, counter: AtomicU64, + /// Sessions being launched: they count against [`MAX_SESSIONS`] from + /// the moment the limit is checked, so two concurrent opens cannot both + /// see room for the last slot. + opening: AtomicUsize, +} + +/// One reserved launch slot, given back when the launch ends — inserted, +/// failed, or dropped mid-launch by a cancelled caller. +struct Reservation<'a>(&'a AtomicUsize); + +impl Drop for Reservation<'_> { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::SeqCst); + } } struct Session { @@ -102,6 +116,7 @@ impl Browser { outputs: Mutex::new(OutputStore::default()), scratch, counter: AtomicU64::new(0), + opening: AtomicUsize::new(0), } } @@ -112,11 +127,18 @@ impl Browser { /// [`Error::LimitExceeded`] when [`MAX_SESSIONS`] are open, and whatever /// the engine reports when the browser cannot be launched or reached. pub async fn open_session(&self, options: SessionOptions) -> Result { - if self.lock_sessions()?.len() >= MAX_SESSIONS { - return Err(Error::LimitExceeded { - message: format!("at most {MAX_SESSIONS} browser sessions may be open"), - }); - } + // Checked and reserved under the table's lock, so the check and the + // claim are one step for every concurrent caller. + let _reservation = { + let sessions = self.lock_sessions()?; + if sessions.len() + self.opening.load(Ordering::SeqCst) >= MAX_SESSIONS { + return Err(Error::LimitExceeded { + message: format!("at most {MAX_SESSIONS} browser sessions may be open"), + }); + } + self.opening.fetch_add(1, Ordering::SeqCst); + Reservation(&self.opening) + }; let id = SessionId::new(format!("s-{}", self.next())); let mut session = Session { engine: self.launcher.open(id.as_str()), From fe3a93ebeac83a8ec11178ef48b0c96ad4d79ca5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 29 Sep 2026 04:22:03 +0530 Subject: [PATCH 06/56] test(sessions): add concurrency tests for session cap enforcement Add two tests that verify the session limit is correctly enforced under concurrent access. The first test launches more sessions than the maximum allowed and confirms that exactly the excess number are refused with a LimitExceeded error while the remaining sessions succeed. The second test ensures that when a launch fails, its slot is returned to the pool so subsequent attempts are not blocked. A slow engine helper is introduced to make concurrent launches interleave reliably. Auto-committed-on: macbook Co-authored-by: Medulla --- .../sessions_tests/lifecycle_tests.rs | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/crates/tinycomputer-browser/src/sessions/sessions_tests/lifecycle_tests.rs b/crates/tinycomputer-browser/src/sessions/sessions_tests/lifecycle_tests.rs index 759c56ec..1b9b301d 100644 --- a/crates/tinycomputer-browser/src/sessions/sessions_tests/lifecycle_tests.rs +++ b/crates/tinycomputer-browser/src/sessions/sessions_tests/lifecycle_tests.rs @@ -81,3 +81,62 @@ fn the_default_scratch_space_is_private_to_the_process() { let browser = Browser::new(Arc::new(Fake::new())); assert!(format!("{browser:?}").contains(&std::process::id().to_string())); } + +/// An engine that yields before every reply, so concurrent launches +/// interleave the way real ones do. +#[derive(Debug)] +struct Slow; + +impl crate::engine::Engine for Slow { + fn execute(&mut self, command: serde_json::Value) -> crate::engine::Reply<'_> { + Box::pin(async move { + tokio::task::yield_now().await; + crate::fake::default_reply(&command) + }) + } +} + +impl crate::engine::Launcher for Slow { + fn open(&self, _session: &str) -> Box { + Box::new(Slow) + } +} + +#[tokio::test] +async fn concurrent_opens_never_exceed_the_cap() { + let browser = Arc::new(Browser::with_scratch(Arc::new(Slow), scratch("race"))); + let mut opens = tokio::task::JoinSet::new(); + for _ in 0..MAX_SESSIONS + 4 { + let browser = browser.clone(); + opens.spawn(async move { + browser + .open_session(SessionOptions { + endpoint: Some("ws://127.0.0.1:9222".to_owned()), + ..SessionOptions::default() + }) + .await + }); + } + let mut refused = 0; + while let Some(opened) = opens.join_next().await { + if matches!(opened.unwrap(), Err(Error::LimitExceeded { .. })) { + refused += 1; + } + } + assert_eq!(refused, 4); + assert_eq!(browser.list_sessions().await.unwrap().len(), MAX_SESSIONS); +} + +#[tokio::test] +async fn a_failed_launch_gives_its_slot_back() { + let fake = Fake::scripted(|command| { + (command["action"] == "launch").then(|| failure("Chrome exited")) + }); + let browser = Browser::with_scratch(Arc::new(fake), scratch("slot")); + for _ in 0..MAX_SESSIONS + 2 { + assert!(matches!( + browser.open_session(SessionOptions::default()).await, + Err(Error::BrowserUnavailable { .. }) + )); + } +} From 39bdddacef911070e095557df33520cdbce3e767 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 29 Sep 2026 04:22:18 +0530 Subject: [PATCH 07/56] fix(test): assert slot leak does not cause LimitExceeded The test for failed launches now explicitly checks that the error is not LimitExceeded, ensuring that leaked slot reservations after many failed launches do not cause the session limit to be reached prematurely. Auto-committed-on: macbook Co-authored-by: Medulla --- .../src/sessions/sessions_tests/lifecycle_tests.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/tinycomputer-browser/src/sessions/sessions_tests/lifecycle_tests.rs b/crates/tinycomputer-browser/src/sessions/sessions_tests/lifecycle_tests.rs index 1b9b301d..d4c47a1f 100644 --- a/crates/tinycomputer-browser/src/sessions/sessions_tests/lifecycle_tests.rs +++ b/crates/tinycomputer-browser/src/sessions/sessions_tests/lifecycle_tests.rs @@ -133,10 +133,13 @@ async fn a_failed_launch_gives_its_slot_back() { (command["action"] == "launch").then(|| failure("Chrome exited")) }); let browser = Browser::with_scratch(Arc::new(fake), scratch("slot")); + // Every launch fails; none may be refused for want of a slot, which is + // what leaked reservations would cause after MAX_SESSIONS attempts. for _ in 0..MAX_SESSIONS + 2 { - assert!(matches!( - browser.open_session(SessionOptions::default()).await, - Err(Error::BrowserUnavailable { .. }) - )); + let error = browser + .open_session(SessionOptions::default()) + .await + .unwrap_err(); + assert!(!matches!(error, Error::LimitExceeded { .. }), "{error:?}"); } } From b85a36f04698f2ca1a473fc265d78d343d12d836 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 29 Sep 2026 04:22:52 +0530 Subject: [PATCH 08/56] feat(dispatch): add background sweep for expired held outputs Add a periodic sweep that drops expired held outputs when no further output call arrives to expire them, preventing resource leaks from screenshots that are never read or released. The sweep runs on the Tokio runtime and stops automatically when the service's browser is dropped. Auto-committed-on: macbook Co-authored-by: Medulla --- .../src/tinybus_module/dispatch/service.rs | 33 +++++++++++++++++++ crates/tinycomputer/src/tinybus_module/mod.rs | 1 + 2 files changed, 34 insertions(+) diff --git a/crates/tinycomputer/src/tinybus_module/dispatch/service.rs b/crates/tinycomputer/src/tinybus_module/dispatch/service.rs index 746f9ccd..f65dc498 100644 --- a/crates/tinycomputer/src/tinybus_module/dispatch/service.rs +++ b/crates/tinycomputer/src/tinybus_module/dispatch/service.rs @@ -125,6 +125,39 @@ impl DesktopService { /// Whether the desktop surface is usable, from a `Permissions` reply: the /// accessibility permission must be granted (or not needed on this platform). +impl DesktopService { + /// Starts dropping expired held outputs every [`SWEEP_INTERVAL`], so a + /// screenshot a caller never reads or releases is freed after its time + /// to live even when no further output call arrives to expire it. The + /// sweep ends once the service's browser is gone. Needs a Tokio runtime, + /// which `setup` runs on. + /// + /// [`SWEEP_INTERVAL`]: tinycomputer_browser::SWEEP_INTERVAL + pub(crate) fn sweep_outputs(&self) { + tokio::spawn(sweep_every( + Arc::downgrade(&self.browser), + tinycomputer_browser::SWEEP_INTERVAL, + )); + } +} + +/// Sweeps `browser`'s held outputs every `every`, until it is dropped. +pub(in crate::tinybus_module) async fn sweep_every( + browser: std::sync::Weak, + every: std::time::Duration, +) { + let mut ticks = tokio::time::interval(every); + loop { + ticks.tick().await; + let Some(browser) = browser.upgrade() else { + return; + }; + // A poisoned output store is reported on the next output call; + // the sweep has no caller to report it to. + let _swept = browser.sweep_outputs(); + } +} + pub(in crate::tinybus_module) fn desktop_availability( permissions: &DesktopResponse, ) -> SurfaceAvailability { diff --git a/crates/tinycomputer/src/tinybus_module/mod.rs b/crates/tinycomputer/src/tinybus_module/mod.rs index c2f88178..aa13ffae 100644 --- a/crates/tinycomputer/src/tinybus_module/mod.rs +++ b/crates/tinycomputer/src/tinybus_module/mod.rs @@ -38,6 +38,7 @@ pub(crate) use dispatch::DesktopService; async fn setup(connection: Connection, config: Value) -> TinyBusResult<()> { let service = DesktopService::from_config(&config) .map_err(|error| tinybus::Error::failed(error.to_string()))?; + service.sweep_outputs(); connection .serve_at(names::OBJECT_PATH.try_into()?, service) From a3ba4ab723237d192c375aa6b7a2e4d2f1ea9561 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 29 Sep 2026 04:23:03 +0530 Subject: [PATCH 09/56] test(tinybus): add test that output sweep stops when browser is dropped Add an integration test that verifies the periodic sweep task spawned by sweep_every continues running while the browser reference is alive and terminates cleanly once the browser is dropped, ensuring the task does not leak or panic. Auto-committed-on: macbook Co-authored-by: Medulla --- .../tinybus_module_tests/browser_tests.rs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/browser_tests.rs b/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/browser_tests.rs index 3618abd2..1d3ad09a 100644 --- a/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/browser_tests.rs +++ b/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/browser_tests.rs @@ -306,3 +306,27 @@ async fn a_malformed_request_is_a_bus_error_not_a_panic() -> tinybus::Result<()> assert!(reply.is_err(), "a request with no session does not decode"); Ok(()) } + +#[tokio::test] +async fn the_output_sweep_runs_until_the_browser_is_gone() { + use crate::tinybus_module::dispatch::sweep_every; + + let scratch = Scratch::new("sweep"); + let browser = Arc::new(Browser::with_scratch( + Arc::new(ScriptedLauncher::default()), + scratch.0.clone(), + )); + let sweep = tokio::spawn(sweep_every( + Arc::downgrade(&browser), + std::time::Duration::from_millis(1), + )); + // It sweeps while the browser lives… + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + assert!(!sweep.is_finished()); + // …and ends on its own once the browser is dropped. + drop(browser); + tokio::time::timeout(std::time::Duration::from_secs(1), sweep) + .await + .expect("the sweep ends once the browser is gone") + .expect("the sweep does not panic"); +} From 5e1ad92c544749138c575d8a7207a4ff0c305293 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 29 Sep 2026 04:23:19 +0530 Subject: [PATCH 10/56] fix(dispatch): re-export sweep_every from service module The dispatch module now publicly re-exports the `sweep_every` function from the service submodule, making it available to parent modules for use in periodic cleanup operations. Auto-committed-on: macbook Co-authored-by: Medulla --- crates/tinycomputer/src/tinybus_module/dispatch/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinycomputer/src/tinybus_module/dispatch/mod.rs b/crates/tinycomputer/src/tinybus_module/dispatch/mod.rs index 2b6d02b1..c3a64aac 100644 --- a/crates/tinycomputer/src/tinybus_module/dispatch/mod.rs +++ b/crates/tinycomputer/src/tinybus_module/dispatch/mod.rs @@ -20,7 +20,7 @@ mod service; use browser::browser_reply; -pub(super) use service::desktop_availability; +pub(super) use service::{desktop_availability, sweep_every}; use tinybus::Result as TinyBusResult; use tinycomputer_bus::{ From bd55f9ffbac6bcabc4e69bfc785d001e434b9601 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 29 Sep 2026 04:24:09 +0530 Subject: [PATCH 11/56] feat(describe): make trace a required field in the TaskReport schema The TaskReport schema previously listed `trace` as optional with a default of true, but the TinyBus client rejects a confidential body containing only `{"id"}` as a stream handle. Making `trace` required ensures callers always include the flag, preventing client-side rejection. The change updates the schema definition, adds tests verifying the new required fields, and fixes the documentation example to include the trace parameter. Auto-committed-on: macbook Co-authored-by: Medulla --- .../tinycomputer-engine/src/task/describe.rs | 5 +- .../src/task/task_tests/describe_tests.rs | 67 ++++++++++++++++++- docs/crates/tinycomputer/calling-it.md | 6 +- 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/crates/tinycomputer-engine/src/task/describe.rs b/crates/tinycomputer-engine/src/task/describe.rs index 79d29bb8..f118c0ad 100644 --- a/crates/tinycomputer-engine/src/task/describe.rs +++ b/crates/tinycomputer-engine/src/task/describe.rs @@ -188,11 +188,10 @@ fn members() -> Vec { "id": {"type": "string"}, "trace": { "type": "boolean", - "default": true, - "description": "include every Jev exchange StartTask.trace recorded; false keeps the report small" + "description": "include every Jev exchange StartTask.trace recorded; false keeps the report small. Required: a confidential body of only {\"id\"} is refused by the TinyBus client as a stream handle" } }), - &["id"], + &["id", "trace"], ), "TaskReport", ), diff --git a/crates/tinycomputer-engine/src/task/task_tests/describe_tests.rs b/crates/tinycomputer-engine/src/task/task_tests/describe_tests.rs index 48561402..b04dbf95 100644 --- a/crates/tinycomputer-engine/src/task/task_tests/describe_tests.rs +++ b/crates/tinycomputer-engine/src/task/task_tests/describe_tests.rs @@ -125,10 +125,66 @@ async fn describe_schemas_name_every_request_field() { let plan = serde_json::to_value(tinycomputer_bus::agent::PlanTaskRequest::default()).unwrap(); assert_eq!(documented(&schema("PlanTask")), fields(&plan)); + + let id = tinycomputer_bus::agent::TaskId::new("t-1"); + let others = [ + ( + "AwaitTask", + serde_json::to_value(tinycomputer_bus::agent::AwaitTaskRequest { + id: id.clone(), + timeout_ms: 1, + }), + ), + ( + "ContinueTask", + serde_json::to_value(tinycomputer_bus::agent::ContinueTaskRequest { + id: id.clone(), + approve: Some(true), + answer: Some(String::new()), + ..tinycomputer_bus::agent::ContinueTaskRequest::default() + }), + ), + ( + "CancelTask", + serde_json::to_value(tinycomputer_bus::agent::TaskRef { id: id.clone() }), + ), + ( + "TaskReport", + serde_json::to_value(tinycomputer_bus::agent::TaskReportRequest::new(id)), + ), + ]; + for (member, request) in others { + assert_eq!( + documented(&schema(member)), + fields(&request.unwrap()), + "{member}" + ); + } + // Every member with a request object is compared above; the rest take + // no argument. + for member in &described.members { + if member.input["type"] != "object" { + assert_eq!(member.input["type"], "null", "{}", member.name); + } + } +} + +#[tokio::test] +async fn the_task_report_schema_requires_the_trace_flag() { + // A schema-driven caller sends only what is required; a body of only + // `{"id"}` would be refused client-side as a stream handle. + let (tasks, _) = controller(Vec::new()); + let described = capabilities(Vec::new(), true, &tasks); + let report = described + .members + .iter() + .find(|member| member.name == "TaskReport") + .unwrap(); + assert_eq!(report.input["required"], json!(["id", "trace"])); } #[tokio::test] -async fn describe_browser_examples_decode_as_their_members_requests() { +async fn every_describe_example_decodes_as_its_members_request() { use tinycomputer_bus::browser::{ Action, NavigateRequest, ReadOutputRequest, SessionOptions, SessionRequest, names, }; @@ -151,10 +207,15 @@ async fn describe_browser_examples_decode_as_their_members_requests() { serde_json::from_value::(request).is_ok() } "StartTask" => serde_json::from_value::(request).is_ok(), - _ => continue, + "AwaitTask" => serde_json::from_value::(request).is_ok(), + "ContinueTask" => { + serde_json::from_value::(request) + .is_ok() + } + other => panic!("the {other} example has no decoder here; add one"), }; assert!(decoded, "the {} example does not decode", example.title); seen += 1; } - assert!(seen >= 6, "the browser and output examples are checked"); + assert_eq!(seen, described.examples.len()); } diff --git a/docs/crates/tinycomputer/calling-it.md b/docs/crates/tinycomputer/calling-it.md index a6c05aa1..3be049ec 100644 --- a/docs/crates/tinycomputer/calling-it.md +++ b/docs/crates/tinycomputer/calling-it.md @@ -233,11 +233,13 @@ in. ``` `TaskReport` (also confidential, since it can carry the full trace) gives you -the whole history once you are done: +the whole history once you are done. Always send `trace`: a confidential call +whose body is only `{"id": …}` is refused by the TinyBus client before it +leaves, because that is the shape of a stream handle. ```json // TaskReport request -{ "id": "task-7f2a" } +{ "id": "task-7f2a", "trace": false } ``` ## Discover what you can call before calling it From 6f993195e16c433f6f6675cdcb31bac7fdd6ce77 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 29 Sep 2026 04:24:35 +0530 Subject: [PATCH 12/56] test(catalogue): add coverage for name-to-family mapping and summary constraints Add two new tests to the catalogue test suite: one that verifies every task and browser name has a corresponding catalogue entry with the correct family, and another that confirms the flow family contains exactly the expected flow methods. Also strengthen the existing summary test to reject multi-sentence summaries, and harden the browser names test by comparing against string literals instead of aliases so that identity changes require an intentional update. Auto-committed-on: macbook Co-authored-by: Medulla --- .../src/browser/names/names_tests.rs | 6 ++- .../src/catalogue/catalogue_tests.rs | 41 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/crates/tinycomputer-bus/src/browser/names/names_tests.rs b/crates/tinycomputer-bus/src/browser/names/names_tests.rs index 6706b190..bd372c3e 100644 --- a/crates/tinycomputer-bus/src/browser/names/names_tests.rs +++ b/crates/tinycomputer-bus/src/browser/names/names_tests.rs @@ -10,8 +10,10 @@ use super::{INTERFACE, METHODS, OBJECT_PATH, methods}; #[test] fn browser_members_share_the_module_interface() { - assert_eq!(INTERFACE, crate::names::INTERFACE); - assert_eq!(OBJECT_PATH, crate::names::OBJECT_PATH); + // Literals, not the aliases they are defined from: a change to the + // published identity must be made here on purpose. + assert_eq!(INTERFACE, "ai.tinyhumans.tinycomputer.Desktop"); + assert_eq!(OBJECT_PATH, "/ai/tinyhumans/tinycomputer/Desktop"); } #[test] diff --git a/crates/tinycomputer-bus/src/catalogue/catalogue_tests.rs b/crates/tinycomputer-bus/src/catalogue/catalogue_tests.rs index b0e0dcbb..22e6f7b6 100644 --- a/crates/tinycomputer-bus/src/catalogue/catalogue_tests.rs +++ b/crates/tinycomputer-bus/src/catalogue/catalogue_tests.rs @@ -36,6 +36,41 @@ fn every_browser_member_is_in_the_browser_family_and_nothing_else_is() { } } +#[test] +fn every_task_and_browser_name_has_a_catalogue_entry() { + for name in crate::agent::names::METHODS { + assert_eq!(member(name).map(|entry| entry.family), Some(Family::Task), "{name}"); + } + for name in crate::browser::names::METHODS { + assert_eq!( + member(name).map(|entry| entry.family), + Some(Family::Browser), + "{name}" + ); + } +} + +#[test] +fn the_flow_family_is_exactly_the_flow_members() { + use crate::names::methods; + let flow = [ + methods::RESOLVE_INTENT, + methods::RUN_GOAL, + methods::RUN_FLOW, + methods::VALIDATE_FLOW, + methods::FLOW_GUIDE, + ]; + for name in flow { + assert_eq!(member(name).map(|entry| entry.family), Some(Family::Flow), "{name}"); + } + let catalogued = MEMBERS + .iter() + .filter(|entry| entry.family == Family::Flow) + .map(|entry| entry.name) + .collect::>(); + assert_eq!(catalogued, flow); +} + #[test] fn task_confidentiality_agrees_with_the_task_names() { for entry in MEMBERS.iter().filter(|entry| entry.family == Family::Task) { @@ -53,6 +88,12 @@ fn every_summary_is_one_sentence() { for entry in MEMBERS { assert!(entry.summary.ends_with('.'), "{} summary", entry.name); assert!(entry.summary.len() < 120, "{} summary is long", entry.name); + let body = &entry.summary[..entry.summary.len() - 1]; + assert!( + !body.contains(". ") && !body.contains("? ") && !body.contains("! "), + "{} summary is more than one sentence", + entry.name + ); } } From 4c5d69185f7bb863c0901bf0131c6f0c18d1d17f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 29 Sep 2026 04:25:00 +0530 Subject: [PATCH 13/56] docs(technical): clarify browser member request shapes across docs Updated five documentation files to accurately describe which browser members take a session object, which take nothing, and which take an output, correcting the previous oversimplification that all thirteen browser members take a single object with a session. Auto-committed-on: macbook Co-authored-by: Medulla --- docs/crates/tinycomputer-bus/browser.md | 6 ++++-- docs/crates/tinycomputer/calling-it.md | 5 +++-- docs/crates/tinycomputer/members.md | 5 +++-- docs/technical/architecture.md | 6 ++++-- docs/technical/specs/desktop-module-contract.md | 7 +++++-- 5 files changed, 19 insertions(+), 10 deletions(-) diff --git a/docs/crates/tinycomputer-bus/browser.md b/docs/crates/tinycomputer-bus/browser.md index c33d4187..443a3efe 100644 --- a/docs/crates/tinycomputer-bus/browser.md +++ b/docs/crates/tinycomputer-bus/browser.md @@ -27,8 +27,10 @@ crate root, and that is deliberate: `SnapshotRequest` and `ScreenshotRequest` also exist for the desktop members, with different shapes. Namespacing the types means neither side ever shadows the other, and a caller cannot accidentally send a desktop `SnapshotRequest` where a browser one belongs, -they are different Rust types. Every browser member takes one JSON object, -with the session beside the member's own fields, and replies in the same +they are different Rust types. A browser member that acts on an open session +takes one JSON object, with the session beside the member's own fields; +`BrowserOpenSession` takes the new session's options, `BrowserListSessions` +nothing, and the output members an `output`. All reply in the same `DesktopResponse` envelope the desktop members use. These types were ported from a now-superseded `tinybrowser-bus` crate; a host diff --git a/docs/crates/tinycomputer/calling-it.md b/docs/crates/tinycomputer/calling-it.md index 3be049ec..80a32be8 100644 --- a/docs/crates/tinycomputer/calling-it.md +++ b/docs/crates/tinycomputer/calling-it.md @@ -116,8 +116,9 @@ Without `jev` configured, this comes back immediately as: ## Calling a browser member -The 13 `Browser…` members take one object, the session beside the member's -own fields, and answer in the same `DesktopResponse` envelope the desktop +The 13 `Browser…` members take one object — for a member acting on an open +session, the session beside the member's own fields; `BrowserListSessions` +takes nothing — and answer in the same `DesktopResponse` envelope the desktop members use. `BrowserOpenSession` first, then act on the session it hands back: diff --git a/docs/crates/tinycomputer/members.md b/docs/crates/tinycomputer/members.md index 66b76c0c..193293f2 100644 --- a/docs/crates/tinycomputer/members.md +++ b/docs/crates/tinycomputer/members.md @@ -11,8 +11,9 @@ so this list cannot silently drift from the code. Every member here except the eight task members (their own section below) takes at most one request payload and always answers with a `DesktopResponse` whose `ok` flag picks between `data` and a structured `error`. That includes -the 13 browser members: they take one object, the session beside the member's -own fields, and answer in the same envelope, reusing a desktop error code +the 13 browser members: those acting on an open session take one object, the +session beside the member's own fields (`BrowserListSessions` takes nothing), +and all answer in the same envelope, reusing a desktop error code wherever the meaning is shared. The module never answers with a bare `TinyBus` transport error for something the caller did; that is reserved for the module failing to even start the command, which is rare enough that you diff --git a/docs/technical/architecture.md b/docs/technical/architecture.md index 9c07ab8a..4dac90a1 100644 --- a/docs/technical/architecture.md +++ b/docs/technical/architecture.md @@ -189,8 +189,10 @@ its family, and one sentence on what it is for. The browser primitives are defined in `tinycomputer-bus/src/browser/`, implemented by `tinycomputer_browser::Browser`, and served with a `Browser` prefix, because several (`Snapshot`, `Screenshot`) would otherwise collide -with a desktop member of a different shape. Each takes one object with the -session beside the member's own fields, and replies in the same +with a desktop member of a different shape. A member that acts on an open +session takes one object with the session beside the member's own fields +(`BrowserOpenSession` takes the session's options, `BrowserListSessions` +nothing, the output members an `output`), and each replies in the same `DesktopResponse` envelope as a desktop member; a failure reuses the desktop's code where the meaning is shared, so `STALE_REF` means "snapshot again" on either surface. The module holds one `Browser`: the task runner opens each diff --git a/docs/technical/specs/desktop-module-contract.md b/docs/technical/specs/desktop-module-contract.md index 92ef4a80..663a5f0b 100644 --- a/docs/technical/specs/desktop-module-contract.md +++ b/docs/technical/specs/desktop-module-contract.md @@ -42,8 +42,11 @@ engine's argument types, the permission preflight, and the bus surface. share this interface because a TinyBus module exports one interface. - The thirteen browser members (2.6) close the list, each prefixed `Browser` (`tinycomputer_bus::browser::names`): sessions, navigate, snapshot, - perform, read, evaluate, screenshot, held outputs, and downloads. Each takes - one object — `{"session": …}` beside the member's own fields — and returns a + perform, read, evaluate, screenshot, held outputs, and downloads. A member + that acts on an open session takes one object, `{"session": …}` beside the + member's own fields; `BrowserOpenSession` takes `SessionOptions` (it makes + the session), `BrowserListSessions` takes nothing, and `BrowserReadOutput` + and `BrowserReleaseOutput` take `{"output": …}`. Every one returns a `DesktopResponse`. A failure's `code` is `browser::errors::code` of its wire name, which reuses the desktop's code where the meaning is the same (`STALE_REF`, `ELEMENT_NOT_FOUND`, `TIMEOUT`, `POLICY_DENIED`, From cb66fde72b95379e7bbbba3dd2ec7699e5725976 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 29 Sep 2026 04:25:16 +0530 Subject: [PATCH 14/56] docs(specs): clarify delivery status and recovery hint in desktop module contract The contract now explains that a timeout or page error after a successful click should prompt the user to inspect the page before retrying, and only local lookup failures are marked as `not_delivered` while all other failures have an `unknown` delivery status. Auto-committed-on: macbook Co-authored-by: Medulla --- docs/technical/specs/desktop-module-contract.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/technical/specs/desktop-module-contract.md b/docs/technical/specs/desktop-module-contract.md index 663a5f0b..785c9366 100644 --- a/docs/technical/specs/desktop-module-contract.md +++ b/docs/technical/specs/desktop-module-contract.md @@ -51,8 +51,11 @@ engine's argument types, the permission preflight, and the bus surface. wire name, which reuses the desktop's code where the meaning is the same (`STALE_REF`, `ELEMENT_NOT_FOUND`, `TIMEOUT`, `POLICY_DENIED`, `INVALID_ARGS`, `INTERNAL`); the full name is in `details.name`, the - recovery hint is `browser::errors::recovery`'s, and a call refused before - anything reached the browser is marked `not_delivered`. The members share + recovery hint is `browser::errors::recovery`'s — a timeout or page error, + which may follow a click that landed, says to inspect the page before + retrying — and only a call refused on a local lookup (an unknown session or + output) is marked `not_delivered`; every other failure's delivery is + `unknown`. The members share one `Browser` with the task runner, so `BrowserReadOutput` reads a screenshot a task view names and `BrowserListSessions` shows a task's session. The `ai.tinyhumans.tinycomputer.Browser` interface and its From 5c00305255053f7366d51ed81bcd86b4da70238a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 29 Sep 2026 04:25:26 +0530 Subject: [PATCH 15/56] docs(tinycomputer-browser): clarify timeout and page error retry guidance The documentation for `Timeout` and `PageError` now warns that a click or submission may have already landed, so callers should inspect the page before retrying rather than repeating the operation blindly. A new paragraph also explains that `Error::envelope` marks only `NoSuchSession` and `NoSuchOutput` as `not_delivered` because they are decided by local lookup, while all other variants can arrive in a command reply and are left `unknown`. Auto-committed-on: macbook Co-authored-by: Medulla --- docs/crates/tinycomputer-browser/errors.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/crates/tinycomputer-browser/errors.md b/docs/crates/tinycomputer-browser/errors.md index 67e05684..722827fd 100644 --- a/docs/crates/tinycomputer-browser/errors.md +++ b/docs/crates/tinycomputer-browser/errors.md @@ -26,15 +26,20 @@ a host is supposed to see and do about it. | `NoSuchElement` | `NoSuchElement` | Take a fresh snapshot and choose again. | | `StaleRef` | `StaleRef` | Same remedy as above, spelled out explicitly: the ref belonged to an earlier reading of this page. | | `NotActionable` | `NotActionable` | The element exists but could not be acted on right now: covered, disabled, or off-document. The message names the obstruction where the browser could identify it. | -| `Timeout` | `Timeout` | The operation ran out of time; retrying with a longer deadline is reasonable. | +| `Timeout` | `Timeout` | The operation ran out of time. A click or a submission may already have landed, so look at the page (take a fresh snapshot) before retrying, perhaps with a longer deadline; never repeat it blind. | | `BlockedByPolicy` | `BlockedByPolicy` | Never retry. The session's `allowed_origins` refused this destination, and the answer will not change. | | `BrowserUnavailable` | `BrowserUnavailable` | Not something a caller can fix by choosing differently: this is a host or deployment problem (no browser could be launched or reached). | -| `PageError` | `PageError` | The page itself raised a JavaScript exception, or the browser rejected a command. | +| `PageError` | `PageError` | The page itself raised a JavaScript exception, or the browser rejected a command. As with a timeout, inspect the page before retrying. | | `NoSuchOutput` | `NoSuchOutput` | The held screenshot or PDF being asked for is unknown or has expired. | | `LimitExceeded` | `LimitExceeded` | A bound was hit: too many sessions, too many held outputs, or an output larger than the module will hold. | | `ConnectionLost` | `NoSuchSession` (same wire name as `NoSuchSession`) | Open a new session. Kept as its own Rust variant, rather than folded into `NoSuchSession` at construction time, because it says something more specific ("the transport died") that is useful for the crate's own diagnostics. A caller across the bus is told exactly the same thing either way: don't retry into a socket that will never answer, open a fresh session instead. | | `ModuleFailed` | `ModuleFailed` | Anything that does not fit the categories above. | +`Error::envelope` marks only `NoSuchSession` and `NoSuchOutput` as +`not_delivered`: they are decided by a local lookup before any command is +sent. Every other variant can also arrive in the engine's reply to a command +it received, so its delivery is left `unknown` rather than claimed. + `errors::is_agent_recoverable` (in the bus crate) is the one further decision that gets made on top of this table: whether a model can plausibly recover by choosing differently, versus needing a human or an operator. From a6d2592028339128e3d99b7f1608ba85e0749e24 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 29 Sep 2026 04:26:03 +0530 Subject: [PATCH 16/56] feat(host): extract read_output and capture final screenshot in conclude Extract the chunk-by-chunk output reading logic from `browser_screenshot` into a new public `read_output` method on `Host`, so that callers can read any held output, not just a fresh screenshot. In `conclude`, use this method to save the task's last artifact as `final.png` before iterating over still-open browser sessions, which are now named `open-.png` instead of `final-.png`. This gives a clearer picture of what the task captured at the moment it stopped versus what is visible in sessions that remain open. Auto-committed-on: macbook Co-authored-by: Medulla --- crates/tinycomputer-examples/src/host/mod.rs | 20 ++++++++--- crates/tinycomputer-examples/src/task/mod.rs | 35 ++++++++++++-------- 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/crates/tinycomputer-examples/src/host/mod.rs b/crates/tinycomputer-examples/src/host/mod.rs index f3bf05e5..4436be35 100644 --- a/crates/tinycomputer-examples/src/host/mod.rs +++ b/crates/tinycomputer-examples/src/host/mod.rs @@ -317,17 +317,29 @@ impl Host { } /// A screenshot of `session`'s page as image bytes: `BrowserScreenshot`, - /// then `BrowserReadOutput` chunk by chunk until the end, then - /// `BrowserReleaseOutput` — the handle protocol a host follows. + /// then [`Host::read_output`]. /// /// # Errors /// - /// Fails on a transport error, an error envelope, a chunk that is not - /// base64, or an image whose length disagrees with its handle. + /// Fails on a transport error, an error envelope, or a bad chunk. pub async fn browser_screenshot(&self, session: &SessionId) -> Result, LabError> { use tinycomputer_bus::browser::names::methods; let request = SessionRequest::new(session.clone(), ScreenshotRequest::default()); let output: OutputRef = data(self.proxy.call(methods::SCREENSHOT, (request,)).await?)?; + self.read_output(&output).await + } + + /// A held output's bytes — a screenshot this host took, or one a task + /// view or report names: `BrowserReadOutput` chunk by chunk until the + /// end, then `BrowserReleaseOutput`, the handle protocol a host follows. + /// + /// # Errors + /// + /// Fails on a transport error, an error envelope (an expired output is + /// `OUTPUT_NOT_FOUND`), a chunk that is not base64, or an image whose + /// length disagrees with its handle. + pub async fn read_output(&self, output: &OutputRef) -> Result, LabError> { + use tinycomputer_bus::browser::names::methods; let mut bytes = Vec::new(); loop { let request = ReadOutputRequest { diff --git a/crates/tinycomputer-examples/src/task/mod.rs b/crates/tinycomputer-examples/src/task/mod.rs index ba17da9c..0e6d7f4e 100644 --- a/crates/tinycomputer-examples/src/task/mod.rs +++ b/crates/tinycomputer-examples/src/task/mod.rs @@ -98,9 +98,10 @@ pub fn passed(status: &TaskStatus) -> bool { } /// Collects what a stopped task did into `out`, all over the bus: the -/// report (`TaskReport`), a screenshot of each browser session still open -/// (`BrowserScreenshot` and `BrowserReadOutput`) — then closes it — and, for -/// a finished task, its records and any shaped result. +/// report (`TaskReport`); `final.png`, the screenshot the task took as it +/// stopped (`BrowserReadOutput` on the report's last artifact); an +/// `open-.png` of each browser session still open, which is then closed; +/// and, for a finished task, its records and any shaped result. /// /// # Errors /// @@ -124,25 +125,33 @@ pub async fn conclude(host: &Host, view: &TaskView, out: &Path) -> Result<(), La out.join("report.json"), serde_json::to_string_pretty(&report)?, )?; + // The screenshot the task took when it stopped, before its session was + // released — the only one left for a task that finished or failed. + if let Some(last) = report.artifacts.last() { + match host.read_output(last).await { + Ok(image) => { + std::fs::write(out.join("final.png"), image)?; + println!("screenshot: {} (taken as the task stopped)", out.join("final.png").display()); + } + Err(error) => println!("the task's screenshot could not be read: {error}"), + } + } + // Sessions still open — a task paused at a checkpoint keeps its own — are + // captured as they stand now, then closed. for (index, session) in host.browser_sessions().await?.iter().enumerate() { - let name = if index == 0 { - "final.png".to_owned() - } else { - format!("final-{index}.png") - }; + let name = format!("open-{index}.png"); match host.browser_screenshot(&session.id).await { Ok(image) => { std::fs::write(out.join(&name), image)?; - println!( - "screenshot: {} ({})", - out.join(&name).display(), - session.url - ); + println!("screenshot: {} ({})", out.join(&name).display(), session.url); } Err(error) => println!("screenshot of {} failed: {error}", session.id), } host.close_browser_session(&session.id).await?; } + if report.artifacts.is_empty() && !out.join("open-0.png").exists() { + println!("no screenshot: the task's surface could not take one"); + } if let TaskStatus::Done { records, result, .. } = &view.status From 97cc67b7bc4650ef7a4e02dd6f10d961a922810a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 29 Sep 2026 04:26:09 +0530 Subject: [PATCH 17/56] fix(dispatch): restrict sweep_every export to test builds The `sweep_every` function is now only re-exported under `#[cfg(test)]` since it is used solely in test code, reducing the public surface of the dispatch module in production builds. Auto-committed-on: macbook Co-authored-by: Medulla --- crates/tinycomputer/src/tinybus_module/dispatch/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/tinycomputer/src/tinybus_module/dispatch/mod.rs b/crates/tinycomputer/src/tinybus_module/dispatch/mod.rs index c3a64aac..4649c21e 100644 --- a/crates/tinycomputer/src/tinybus_module/dispatch/mod.rs +++ b/crates/tinycomputer/src/tinybus_module/dispatch/mod.rs @@ -20,7 +20,9 @@ mod service; use browser::browser_reply; -pub(super) use service::{desktop_availability, sweep_every}; +pub(super) use service::desktop_availability; +#[cfg(test)] +pub(super) use service::sweep_every; use tinybus::Result as TinyBusResult; use tinycomputer_bus::{ From bf0ccba67c0a13741fd0aae91e19b881caf6f7c2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 29 Sep 2026 04:26:31 +0530 Subject: [PATCH 18/56] test(task): add artifact tests module and capture support to test script Add a new `artifact_tests` module to the test suite and extend the `Script` test helper with a `capture` method that returns a preconfigured screenshot, enabling tests for artifact-related task behaviour. Auto-committed-on: macbook Co-authored-by: Medulla --- crates/tinycomputer-engine/src/task/task_tests.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/tinycomputer-engine/src/task/task_tests.rs b/crates/tinycomputer-engine/src/task/task_tests.rs index b3024d30..5abda45a 100644 --- a/crates/tinycomputer-engine/src/task/task_tests.rs +++ b/crates/tinycomputer-engine/src/task/task_tests.rs @@ -8,6 +8,7 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod approval_tests; +mod artifact_tests; mod describe_tests; mod errors_tests; mod human_tests; @@ -45,6 +46,8 @@ struct Script { screen: Mutex>, /// Tasks let go of, in order. released: Mutex>, + /// The screenshot `capture` hands back, if any. + shot: Mutex>, } impl FlowRunner for Script { @@ -69,6 +72,11 @@ impl FlowRunner for Script { Box::pin(async move { texts }) } + fn capture(&self, _task: &TaskId) -> super::CaptureFuture { + let shot = self.shot.lock().unwrap().clone(); + Box::pin(async move { shot }) + } + fn release(&self, task: &TaskId) { self.released.lock().unwrap().push(task.clone()); } From d2d465d48c9d4846edb6f9c248748b477a713b52 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 29 Sep 2026 04:27:01 +0530 Subject: [PATCH 19/56] test(runner): add assertion that capture returns none for RunsOnly The test for a runner that only runs flows now also verifies that calling capture on it returns None, ensuring the runner's behaviour is fully covered. Auto-committed-on: macbook Co-authored-by: Medulla --- .../src/task/task_tests/artifact_tests.rs | 106 ++++++++++++++++++ .../src/task/task_tests/runner_tests.rs | 1 + 2 files changed, 107 insertions(+) create mode 100644 crates/tinycomputer-engine/src/task/task_tests/artifact_tests.rs diff --git a/crates/tinycomputer-engine/src/task/task_tests/artifact_tests.rs b/crates/tinycomputer-engine/src/task/task_tests/artifact_tests.rs new file mode 100644 index 00000000..03db3915 --- /dev/null +++ b/crates/tinycomputer-engine/src/task/task_tests/artifact_tests.rs @@ -0,0 +1,106 @@ +//! Tests for the screenshot a stopped run leaves: on the status that has room +//! for one, and in the report, taken before the task's surfaces are released. + +use super::*; + +use tinycomputer_bus::browser::{OutputId, OutputRef}; + +fn shot() -> OutputRef { + OutputRef { + id: OutputId::new("o-1"), + total_bytes: 3, + sha256: "abc".to_owned(), + media_type: "image/png".to_owned(), + width: 1, + height: 1, + } +} + +fn with_shot(replies: Vec) -> (Tasks, Arc