From b480ea16dd708453274c5a22630bd93124f119fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Fri, 6 Nov 2026 23:21:04 +0800 Subject: [PATCH] A finished job closes the tabs it left open, keeping the page it ended on Every browser_open is a new tab and nothing closed them. When a job ends (answered, stopped or failed) each bot browser it used keeps only its front page; a browser that isn't open isn't started for this, and one another job is still using is left alone. The six-tab limit stays for long jobs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4 --- src/agent.rs | 130 ++++++++++++++++++++++++++++++++++++++++++++++ src/botbrowser.rs | 8 +++ src/browser.rs | 12 +++++ 3 files changed, 150 insertions(+) diff --git a/src/agent.rs b/src/agent.rs index c0350d8..a4a3db3 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -154,6 +154,76 @@ fn target_for(job: &Job, args: &str) -> Result { job.computers.iter().find(|t| t.name == want).cloned().ok_or_else(|| not_here(job, &want)) } +/// One of the bot's own browsers (where, and which profile). +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +enum BotBrowser { + Here(String), + Box(String), + Device(String, String), +} + +/// How many running jobs are using each bot browser (bots share the box's Chromium). +static BROWSER_JOBS: Mutex> = Mutex::new(Vec::new()); + +/// The bot's browsers a job used. When the job ends — answered, stopped or failed — each closes +/// the tabs the job left open and keeps the page it ended on (every browser_open is a new tab; +/// nothing used to close them, and the box ran out of memory with 19). A browser another job is +/// still using is left alone. +struct UsedBrowsers { + list: Vec, + via_name: String, +} + +/// A job starts using a bot browser. +fn claim(b: &BotBrowser) { + BROWSER_JOBS.lock().unwrap().push(b.clone()); +} + +/// A job is done with a bot browser: true when another job is still using it. +fn release(b: &BotBrowser) -> bool { + let mut jobs = BROWSER_JOBS.lock().unwrap(); + if let Some(i) = jobs.iter().position(|x| x == b) { + jobs.remove(i); + } + jobs.contains(b) +} + +impl UsedBrowsers { + fn add(&mut self, b: BotBrowser) { + if !self.list.contains(&b) { + claim(&b); + self.list.push(b); + } + } + + fn close_tabs(&mut self) { + for b in std::mem::take(&mut self.list) { + if release(&b) { + continue; + } + let args = |p: &str| if p.is_empty() { r#"{"action":"tidy"}"#.to_string() } else { serde_json::json!({"action": "tidy", "profile": p}).to_string() }; + let never = AtomicBool::new(false); + match &b { + BotBrowser::Here(p) => { + let _ = web::run_full("browser_tabs", &args(p), &never); + } + BotBrowser::Box(p) => { + let _ = crate::botbrowser::in_box(|| web::run_full("browser_tabs", &args(p), &never)); + } + BotBrowser::Device(host, p) => { + let _ = crate::remote::run_on_device(host, "browser_tabs", &args(p), &self.via_name); + } + } + } + } +} + +impl Drop for UsedBrowsers { + fn drop(&mut self) { + self.close_tabs(); + } +} + enum BrowserAt { Here, Box, @@ -575,6 +645,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< // The screen and browser steps of this turn (to stop the same step being tried over and over). let mut tried: Vec = Vec::new(); let mut local_on = job.local_execution; + let mut browsers = UsedBrowsers { list: vec![], via_name: job.via_name.clone() }; maybe_compact(&job, &mut history, COMPACT_THRESHOLD_PERCENT, &cancel, &send); for step in 0..MAX_STEPS { if step > 0 { @@ -626,6 +697,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< // Something typed while it answered keeps the same turn going. if job.steer.lock().unwrap().is_empty() { send(StreamEvent::Done); + browsers.close_tabs(); learn(&job, &history, &cancel, &send); return; } @@ -707,6 +779,14 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< let at = web::is_web_tool(&call.name).then(|| browser_place(&job, &call.name, &call.args)); if let Some((place, on)) = at.filter(|(place, _)| call.name == "web_fetch" || bots_browser(place, &call.name, &call.args)) { send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running)); + if call.name != "web_fetch" { + let profile = tools::args_of(&call.args).get("profile").and_then(|p| p.as_str()).unwrap_or("").trim().to_string(); + browsers.add(match &place { + BrowserAt::Here => BotBrowser::Here(profile), + BrowserAt::Box => BotBrowser::Box(profile), + BrowserAt::Device(host) => BotBrowser::Device(host.clone(), profile), + }); + } // 裝置連結: the AnyBot browser of another computer (its own logins). let (output, ok, img) = match place { BrowserAt::Device(host) => match crate::remote::run_on_device(&host, &call.name, &call.args, &job.via_name) { @@ -1556,6 +1636,46 @@ printf 'a,b\n1,2\n' > report.csv && printf 'hello box' > '/tmp/anybot-up/上傳 assert!(results[17].contains("cleaned"), "{}", results[17]); } + /// When a job ends, the tabs it left open in the box's browser close; the page it ended on + /// stays. Needs the box: `cargo test a_finished_job_closes_its_tabs -- --ignored --nocapture`. + #[test] + #[ignore] + fn a_finished_job_closes_its_tabs() { + let own = crate::model::BOT_COMPUTER_NAME; + let setup = "mkdir -p /tmp/anybot-tabs && cd /tmp/anybot-tabs && echo 'tab分頁' > index.html && (setsid python3 -m http.server 8770 --bind 127.0.0.1 >/dev/null 2>&1 &) ; sleep 1; echo served"; + let port = fake_model(vec![ + ("shell", serde_json::json!({"command": setup})), + ("browser_open", serde_json::json!({"url": "http://127.0.0.1:8770/?n=1"})), + ("browser_open", serde_json::json!({"url": "http://127.0.0.1:8770/?n=2"})), + ("browser_open", serde_json::json!({"url": "http://127.0.0.1:8770/?n=3"})), + ("", serde_json::json!("好了")), + ]); + let job = Job { + req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("開三頁")], tools: tools::specs(), session: String::new() }, + computers: vec![tools::Target { name: own.into(), place: tools::Place::Box { dir: crate::cloudbox::bot_dir(1) } }], + default_computer: own.into(), + roles: tools::Roles { own: own.into(), person: None, server: true }, + local_execution: false, + direct: Arc::new(AtomicBool::new(true)), + ..test_job() + }; + let (tx, rx) = channel(); + spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default()); + loop { + match rx.recv_timeout(Duration::from_secs(120)).expect("loop stalled") { + (_, StreamEvent::Done) => break, + (_, StreamEvent::Error(e)) => panic!("{e}"), + _ => {} + } + } + std::thread::sleep(Duration::from_secs(2)); + let box_sh = |script: &str| String::from_utf8_lossy(&std::process::Command::new("docker").args(["exec", crate::cloudbox::container(), "sh", "-c", script]).output().unwrap().stdout).to_string(); + let left = box_sh(r#"curl -s 127.0.0.1:9222/json/list | python3 -c 'import json,sys; print("\n".join(t["url"] for t in json.load(sys.stdin) if t["type"] == "page"))'"#); + println!("left open:\n{left}"); + box_sh(r#"pkill -f 'http[.]server 8770'; rm -rf /tmp/anybot-tabs"#); + assert_eq!(left.trim(), "http://127.0.0.1:8770/?n=3", "{left}"); + } + /// Words reach the control they name on a form laid out like httpbin's (each label and its /// input inside a

): typing into 「Customer name:」, ticking 「Large」, pressing 「Submit /// order」. They used to land on the

and do nothing. Needs the box: @@ -1622,6 +1742,16 @@ echo 'done送出了' > done.html; (setsid pyt assert!(results[8].contains("cleaned"), "{}", results[8]); } + #[test] + fn a_browser_another_job_uses_keeps_its_tabs() { + let b = BotBrowser::Device("test-host-tabs".into(), String::new()); + claim(&b); + claim(&b); + assert!(release(&b), "the other job still uses it"); + assert!(!release(&b), "the last one out closes the tabs"); + assert!(!release(&b)); + } + #[test] fn the_same_miss_counts_as_the_same_step() { // Clicks a few pixels apart on the same strip are one step; another computer or target isn't. diff --git a/src/botbrowser.rs b/src/botbrowser.rs index df5cd3e..2bae05b 100644 --- a/src/botbrowser.rs +++ b/src/botbrowser.rs @@ -251,6 +251,14 @@ pub fn using_box() -> bool { } /// Start this profile's window if it isn't running. Returns (browser name, port, just started). +/// The debugging port of a bot browser that is already open (None when it isn't; never starts one). +pub fn running_port(profile: Option<&str>) -> Option { + if using_box() { + return crate::cloudbox::browser_running().then(crate::cloudbox::browser_port).and_then(Result::ok); + } + live_port(&resolve(profile).ok()?) +} + pub fn ensure(profile: &str, first_url: Option<&str>) -> Result<(&'static str, u16, bool), String> { if using_box() { let fresh = !crate::cloudbox::browser_running(); diff --git a/src/browser.rs b/src/browser.rs index e56c00c..686ae88 100644 --- a/src/browser.rs +++ b/src/browser.rs @@ -823,7 +823,19 @@ fn shrink(b64: &str) -> Option { Some(base64::engine::general_purpose::STANDARD.encode(out.into_inner())) } +/// A job is done with this browser: keep the page it ended on, close the rest (the list is +/// latest-used first). A browser that isn't open stays closed. Not offered to the model; the +/// loop calls it (browser_tabs action "tidy") when a job ends. +fn tidy(profile: Option<&str>) -> Result { + let Some(port) = botbrowser::running_port(profile) else { return Ok("瀏覽器沒有開著。".into()) }; + let closed = targets(port)?.iter().skip(1).filter(|t| botbrowser::local().get(&format!("http://127.0.0.1:{port}/json/close/{}", t["id"].as_str().unwrap_or(""))).call().is_ok()).count(); + Ok(format!("關了 {closed} 個用完的分頁。")) +} + fn tabs(args: &Value, profile: Option<&str>) -> Result { + if s(args, "action") == "tidy" { + return tidy(profile); + } let (_, port) = port_of(profile)?; let http = |path: &str, put: bool| -> Result { let url = format!("http://127.0.0.1:{port}{path}");