Watch
0
0
Fork
You've already forked hyperhive
0

acp runtime: bound the agent's output queue between turns

This commit is contained in:
damocles 2026-11-11 22:50:33 +01:00 • committed by mara
commit e58e842a9f

View file

@ -4,7 +4,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::path::Path; use std::path::Path;
use std::process::Stdio; use std::process::Stdio;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, PoisonError}; use std::sync::{Arc, Mutex, PoisonError};
use serde_json::{Value, json}; use serde_json::{Value, json};
@ -43,7 +43,7 @@ pub(super) struct Connection {
stdin: Arc<tokio::sync::Mutex<ChildStdin>>, stdin: Arc<tokio::sync::Mutex<ChildStdin>>,
next_id: AtomicU64, next_id: AtomicU64,
pending: Pending, pending: Pending,
pub(super) incoming: mpsc::UnboundedReceiver<Incoming>, pub(super) incoming: mpsc::Receiver<Incoming>,
} }
impl Connection { impl Connection {
@ -80,13 +80,17 @@ impl Connection {
}; };
let stdin = Arc::new(tokio::sync::Mutex::new(stdin)); let stdin = Arc::new(tokio::sync::Mutex::new(stdin));
let pending: Pending = Arc::default(); let pending: Pending = Arc::default();
let (tx, incoming) = mpsc::unbounded_channel(); let (tx, incoming) = mpsc::channel(INCOMING_CAP);
let tx = Feed {
tx,
full: Arc::default(),
};
let stderr_tx = tx.clone(); let stderr_tx = tx.clone();
tokio::spawn(async move { tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines(); let mut lines = BufReader::new(stderr).lines();
while let Some(line) = next_line(&mut lines, "stderr").await { while let Some(line) = next_line(&mut lines, "stderr").await {
if stderr_tx.send(Incoming::Stderr(line)).is_err() { if !stderr_tx.push(Incoming::Stderr(line)) {
break; break;
} }
} }
@ -110,7 +114,7 @@ impl Connection {
.lock() .lock()
.unwrap_or_else(PoisonError::into_inner) .unwrap_or_else(PoisonError::into_inner)
.clear(); .clear();
let _ = reader.tx.send(Incoming::Closed); reader.tx.push(Incoming::Closed);
}); });
Ok(Self { Ok(Self {
@ -203,12 +207,45 @@ impl Drop for Connection {
} }
} }
/// How many items wait for the harness before the oldest news is dropped. A
/// turn drains the queue as it goes, so only an agent that talks while no turn
/// runs gets near this; its items are thrown away at the next prompt anyway.
const INCOMING_CAP: usize = 4096;
/// The sending half of the agent's output queue. A full queue drops the new
/// item, and says so once.
#[derive(Clone)]
struct Feed {
tx: mpsc::Sender<Incoming>,
full: Arc<AtomicBool>,
}
impl Feed {
/// Queues `item`. `false` once the receiver is gone, which ends the
/// caller's loop; a full queue drops the item and answers `true`.
fn push(&self, item: Incoming) -> bool {
match self.tx.try_send(item) {
Ok(()) => true,
Err(mpsc::error::TrySendError::Full(_)) => {
if !self.full.swap(true, Ordering::Relaxed) {
tracing::warn!(
cap = INCOMING_CAP,
"ACP agent output is not being read; dropping what it sends until a turn drains it"
);
}
true
}
Err(mpsc::error::TrySendError::Closed(_)) => false,
}
}
}
/// The stdout side: routes responses to their waiting request, answers the /// The stdout side: routes responses to their waiting request, answers the
/// agent's own requests, and queues its notifications. /// agent's own requests, and queues its notifications.
struct Reader { struct Reader {
stdin: Arc<tokio::sync::Mutex<ChildStdin>>, stdin: Arc<tokio::sync::Mutex<ChildStdin>>,
pending: Pending, pending: Pending,
tx: mpsc::UnboundedSender<Incoming>, tx: Feed,
permit: PermissionPolicy, permit: PermissionPolicy,
servers: Vec<String>, servers: Vec<String>,
} }
@ -217,7 +254,7 @@ impl Reader {
async fn dispatch(&self, line: &str) { async fn dispatch(&self, line: &str) {
let Ok(message) = serde_json::from_str::<Value>(line) else { let Ok(message) = serde_json::from_str::<Value>(line) else {
if !line.trim().is_empty() { if !line.trim().is_empty() {
let _ = self.tx.send(Incoming::Stdout(line.to_owned())); self.tx.push(Incoming::Stdout(line.to_owned()));
} }
return; return;
}; };
@ -242,7 +279,7 @@ impl Reader {
} }
(Some("session/update"), None) => { (Some("session/update"), None) => {
let params = message.get("params").cloned().unwrap_or(Value::Null); let params = message.get("params").cloned().unwrap_or(Value::Null);
let _ = self.tx.send(Incoming::Update(params)); self.tx.push(Incoming::Update(params));
} }
_ => {} _ => {}
} }
@ -353,6 +390,46 @@ pub(super) fn permission_outcome(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{Feed, Incoming};
use tokio::sync::mpsc;
fn feed(cap: usize) -> (Feed, mpsc::Receiver<Incoming>) {
let (tx, rx) = mpsc::channel(cap);
(
Feed {
tx,
full: std::sync::Arc::default(),
},
rx,
)
}
#[test]
fn a_full_queue_drops_the_new_item_and_keeps_what_waits() {
let (feed, mut rx) = feed(2);
assert!(feed.push(Incoming::Stderr("one".into())));
assert!(feed.push(Incoming::Stderr("two".into())));
assert!(
feed.push(Incoming::Stderr("three".into())),
"a drop is not an error"
);
let mut got = Vec::new();
while let Ok(Incoming::Stderr(line)) = rx.try_recv() {
got.push(line);
}
assert_eq!(got, ["one", "two"]);
// Draining makes room again.
assert!(feed.push(Incoming::Stderr("four".into())));
assert!(matches!(rx.try_recv(), Ok(Incoming::Stderr(l)) if l == "four"));
}
#[test]
fn a_gone_receiver_ends_the_pushing_loop() {
let (feed, rx) = feed(2);
drop(rx);
assert!(!feed.push(Incoming::Stderr("x".into())));
}
use super::{group_id, next_line, permission_outcome}; use super::{group_id, next_line, permission_outcome};
use crate::acp::{PermissionAsk, PermissionPolicy}; use crate::acp::{PermissionAsk, PermissionPolicy};
use serde_json::json; use serde_json::json;