pub mod protocol;
pub mod transport;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::mpsc::{sync_channel, Receiver, SyncSender, TrySendError};
use std::sync::{Arc, Mutex, PoisonError};
use std::thread::JoinHandle;
use std::time::Duration;
use newt_core::lifecycle::{self, LifecycleEnvelope, LifecycleEvent, Subscription};
use protocol::{Call, PaneAgentState, SessionStartSource};
#[cfg(unix)]
use transport::SocketSink;
use transport::{cli, Sink};
const MAX_ONESHOTS: usize = 8;
const SHUTDOWN_GRACE: Duration = Duration::from_millis(300);
#[derive(Clone, Debug, PartialEq, Eq)]
struct HerdrEnv {
pane: String,
socket: Option<PathBuf>,
bin: Option<PathBuf>,
}
impl HerdrEnv {
fn from_parts(
env: Option<&str>,
pane: Option<&str>,
socket: Option<&str>,
bin: Option<&str>,
) -> Option<Self> {
if env != Some("1") {
return None;
}
let pane = pane.filter(|p| !p.is_empty())?;
let socket = socket.filter(|s| !s.is_empty()).map(PathBuf::from);
let bin = bin.filter(|b| !b.is_empty()).map(PathBuf::from);
if socket.is_none() && bin.is_none() {
return None;
}
Some(Self {
pane: pane.to_string(),
socket,
bin,
})
}
fn from_process_env() -> Option<Self> {
let env = std::env::var("HERDR_ENV").ok();
let pane = std::env::var("HERDR_PANE_ID").ok();
let socket = std::env::var("HERDR_SOCKET_PATH").ok();
let bin = std::env::var("HERDR_BIN_PATH").ok();
Self::from_parts(
env.as_deref(),
pane.as_deref(),
socket.as_deref(),
bin.as_deref(),
)
}
fn sink(&self) -> Box<dyn Sink> {
#[cfg(unix)]
if let Some(socket) = &self.socket {
return Box::new(SocketSink::new(socket.clone()));
}
if let Some(bin) = &self.bin {
return Box::new(cli::CliSink::new(bin.clone()));
}
Box::new(NullSink)
}
}
struct NullSink;
impl Sink for NullSink {
fn deliver(&mut self, _call: &protocol::Call) -> bool {
false
}
}
type Report = (PaneAgentState, Option<String>);
#[derive(Debug)]
struct Machine {
logical: Report,
delivered: Option<Report>,
depth: u32,
pre_prompt: Report,
}
impl Machine {
fn new() -> Self {
Self {
logical: (PaneAgentState::Idle, None),
delivered: None,
depth: 0,
pre_prompt: (PaneAgentState::Idle, None),
}
}
fn set(&mut self, report: Report) {
if self.depth == 0 {
self.logical = report;
} else {
self.pre_prompt = report;
}
}
fn prompt_open(&mut self) {
self.depth += 1;
if self.depth == 1 {
self.pre_prompt = self.logical.clone();
self.logical = (PaneAgentState::Blocked, None);
}
}
fn prompt_close(&mut self) {
if self.depth > 0 {
self.depth -= 1;
if self.depth == 0 {
self.logical = self.pre_prompt.clone();
}
}
}
fn pending(&self) -> Option<Report> {
(self.delivered.as_ref() != Some(&self.logical)).then(|| self.logical.clone())
}
fn mark_delivered(&mut self, report: Report) {
self.delivered = Some(report);
}
}
#[derive(Debug)]
struct Inner {
machine: Machine,
desired_session: Option<(String, SessionStartSource)>,
delivered_session: Option<String>,
oneshots: VecDeque<Call>,
coalesced: u64,
shutdown: bool,
}
struct Adapter {
pane: String,
title: Option<String>,
inner: Arc<Mutex<Inner>>,
wake: SyncSender<()>,
}
impl Adapter {
fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
self.inner.lock().unwrap_or_else(PoisonError::into_inner)
}
fn belongs_here(inner: &Inner, envelope: &LifecycleEnvelope) -> bool {
if matches!(envelope.event, LifecycleEvent::SessionStarted { .. }) {
return true;
}
match (&inner.desired_session, &envelope.session_id) {
(Some((mine, _)), Some(theirs)) => mine == theirs,
_ => true,
}
}
fn on_event(&self, envelope: &LifecycleEnvelope) {
{
let mut inner = self.lock();
if !Self::belongs_here(&inner, envelope) {
return;
}
let event = &envelope.event;
match event {
LifecycleEvent::SessionStarted { session_id } => {
inner.desired_session = Some((session_id.clone(), SessionStartSource::Startup));
if let Some(title) = &self.title {
push_oneshot(
&mut inner,
protocol::report_metadata_title(&self.pane, title),
);
}
}
LifecycleEvent::Waiting | LifecycleEvent::TurnCompleted => {
inner.machine.set((PaneAgentState::Idle, None));
}
LifecycleEvent::TurnStarted => {
inner.machine.set((PaneAgentState::Working, None));
}
LifecycleEvent::Thinking => {
inner
.machine
.set((PaneAgentState::Working, Some("thinking".into())));
}
LifecycleEvent::ToolActivity { tool } => {
inner
.machine
.set((PaneAgentState::Working, Some(tool.clone())));
}
LifecycleEvent::Blocked => inner.machine.prompt_open(),
LifecycleEvent::Unblocked => inner.machine.prompt_close(),
LifecycleEvent::TurnFailed { .. } => {
inner
.machine
.set((PaneAgentState::Idle, Some("failed".into())));
}
LifecycleEvent::TurnCancelled => {
inner
.machine
.set((PaneAgentState::Idle, Some("cancelled".into())));
}
LifecycleEvent::SessionEnded => inner.shutdown = true,
_ => {}
}
}
self.nudge();
}
fn nudge(&self) {
match self.wake.try_send(()) {
Ok(()) => {}
Err(TrySendError::Full(())) => self.lock().coalesced += 1,
Err(TrySendError::Disconnected(())) => {}
}
}
}
fn push_oneshot(inner: &mut Inner, call: Call) {
if inner.oneshots.len() < MAX_ONESHOTS {
inner.oneshots.push_back(call);
}
}
#[derive(Debug)]
enum Step {
Stop,
Once(Call),
Session(String, Call),
State(Report, Call),
Park,
}
fn next_step(inner: &Arc<Mutex<Inner>>, pane: &str) -> Step {
let mut guard = inner.lock().unwrap_or_else(PoisonError::into_inner);
if guard.shutdown {
return Step::Stop;
}
if let Some((id, source)) = guard.desired_session.clone() {
if guard.delivered_session.as_deref() != Some(id.as_str()) {
let call = protocol::report_agent_session(pane, &id, source);
return Step::Session(id, call);
}
}
if let Some(call) = guard.oneshots.pop_front() {
return Step::Once(call);
}
match guard.machine.pending() {
Some(report) => {
let call = protocol::report_agent(pane, report.0, report.1.as_deref());
Step::State(report, call)
}
None => Step::Park,
}
}
fn worker_loop(
inner: Arc<Mutex<Inner>>,
pane: String,
mut sink: Box<dyn Sink>,
wake: Receiver<()>,
) {
while wake.recv().is_ok() {
loop {
match next_step(&inner, &pane) {
Step::Stop => {
let _ = sink.deliver(&protocol::release_agent(&pane));
return;
}
Step::Once(call) => {
let _ = sink.deliver(&call);
}
Step::Session(id, call) => {
if sink.deliver(&call) {
inner
.lock()
.unwrap_or_else(PoisonError::into_inner)
.delivered_session = Some(id);
} else {
break;
}
}
Step::State(report, call) => {
if sink.deliver(&call) {
inner
.lock()
.unwrap_or_else(PoisonError::into_inner)
.machine
.mark_delivered(report);
} else {
break;
}
}
Step::Park => break,
}
}
}
let _ = sink.deliver(&protocol::release_agent(&pane));
}
struct Reporter {
inner: Arc<Mutex<Inner>>,
wake: SyncSender<()>,
worker: Option<JoinHandle<()>>,
}
impl Reporter {
fn spawn(env: &HerdrEnv, title: Option<String>, sink: Box<dyn Sink>) -> (Self, Adapter) {
let inner = Arc::new(Mutex::new(Inner {
machine: Machine::new(),
desired_session: None,
delivered_session: None,
oneshots: VecDeque::new(),
coalesced: 0,
shutdown: false,
}));
let (wake, rx) = sync_channel::<()>(1);
let worker = {
let inner = Arc::clone(&inner);
let pane = env.pane.clone();
std::thread::Builder::new()
.name("herdr-reporter".into())
.spawn(move || worker_loop(inner, pane, sink, rx))
.ok()
};
let adapter = Adapter {
pane: env.pane.clone(),
title,
inner: Arc::clone(&inner),
wake: wake.clone(),
};
(
Self {
inner,
wake,
worker,
},
adapter,
)
}
fn shutdown(&mut self) {
{
let mut inner = self.inner.lock().unwrap_or_else(PoisonError::into_inner);
inner.shutdown = true;
}
let _ = self.wake.try_send(());
let Some(worker) = self.worker.take() else {
return;
};
let deadline = std::time::Instant::now() + SHUTDOWN_GRACE;
while !worker.is_finished() && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
if worker.is_finished() {
let _ = worker.join();
}
}
}
pub struct SessionGuard {
subscription: Option<Subscription>,
reporter: Option<Reporter>,
}
impl Drop for SessionGuard {
fn drop(&mut self) {
self.subscription = None;
if let Some(reporter) = &mut self.reporter {
reporter.shutdown();
}
}
}
pub fn session_guard(workspace: &str) -> SessionGuard {
let Some(env) = HerdrEnv::from_process_env() else {
return SessionGuard {
subscription: None,
reporter: None,
};
};
let title = std::path::Path::new(workspace)
.file_name()
.map(|n| n.to_string_lossy().into_owned());
install(&env, title, env.sink())
}
fn install(env: &HerdrEnv, title: Option<String>, sink: Box<dyn Sink>) -> SessionGuard {
let (reporter, adapter) = Reporter::spawn(env, title, sink);
let subscription = lifecycle::subscribe(move |envelope| adapter.on_event(envelope));
SessionGuard {
subscription: Some(subscription),
reporter: Some(reporter),
}
}
#[cfg(test)]
impl Adapter {
fn on_bare(&self, session: Option<&str>, event: LifecycleEvent) {
self.on_event(&LifecycleEnvelope {
session_id: session.map(str::to_string),
event,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::time::Instant;
#[derive(Clone)]
struct FakeSink {
calls: Arc<Mutex<Vec<Call>>>,
gate: Option<Arc<Mutex<()>>>,
entered_gate: Option<Arc<(Mutex<bool>, std::sync::Condvar)>>,
fail_first: Arc<AtomicUsize>,
panics: bool,
}
impl FakeSink {
fn new() -> Self {
Self {
calls: Arc::new(Mutex::new(Vec::new())),
gate: None,
entered_gate: None,
fail_first: Arc::new(AtomicUsize::new(0)),
panics: false,
}
}
fn gated(gate: &Arc<Mutex<()>>) -> Self {
Self {
gate: Some(Arc::clone(gate)),
entered_gate: Some(Arc::new((Mutex::new(false), std::sync::Condvar::new()))),
..Self::new()
}
}
fn wait_entered_gate(&self) {
let entered = self
.entered_gate
.as_ref()
.expect("wait_entered_gate requires FakeSink::gated");
let (lock, cv) = &**entered;
let guard = lock.lock().unwrap_or_else(PoisonError::into_inner);
let _guard = cv
.wait_while(guard, |entered| !*entered)
.unwrap_or_else(PoisonError::into_inner);
}
fn failing(n: usize) -> Self {
let sink = Self::new();
sink.fail_first.store(n, Ordering::SeqCst);
sink
}
fn panicking() -> Self {
Self {
panics: true,
..Self::new()
}
}
fn seen(&self) -> Vec<(String, String)> {
self.calls
.lock()
.unwrap()
.iter()
.map(|c| {
let detail = match c.method {
"pane.report_agent" => c.params["state"].as_str().unwrap_or("").to_string(),
"pane.report_metadata" => {
c.params["title"].as_str().unwrap_or("").to_string()
}
"pane.report_agent_session" => c.params["agent_session_id"]
.as_str()
.unwrap_or("")
.to_string(),
_ => String::new(),
};
(c.method.to_string(), detail)
})
.collect()
}
fn states(&self) -> Vec<String> {
self.seen()
.into_iter()
.filter(|(m, _)| m == "pane.report_agent")
.map(|(_, s)| s)
.collect()
}
fn messages(&self) -> Vec<Option<String>> {
self.calls
.lock()
.unwrap()
.iter()
.filter(|c| c.method == "pane.report_agent")
.map(|c| {
c.params
.get("message")
.and_then(|m| m.as_str())
.map(str::to_string)
})
.collect()
}
fn count(&self) -> usize {
self.calls.lock().unwrap().len()
}
}
impl Sink for FakeSink {
fn deliver(&mut self, call: &Call) -> bool {
if self.panics {
panic!("sink exploded");
}
if let Some(gate) = &self.gate {
if let Some(entered) = &self.entered_gate {
let (lock, cv) = &**entered;
*lock.lock().unwrap_or_else(PoisonError::into_inner) = true;
cv.notify_all();
}
let _held = gate.lock().unwrap_or_else(PoisonError::into_inner);
}
self.calls.lock().unwrap().push(call.clone());
if self.fail_first.load(Ordering::SeqCst) > 0 {
self.fail_first.fetch_sub(1, Ordering::SeqCst);
return false;
}
true
}
}
fn test_env() -> HerdrEnv {
HerdrEnv::from_parts(Some("1"), Some("w1:p2"), Some("/tmp/herdr-test.sock"), None).unwrap()
}
fn harness(sink: FakeSink) -> (Adapter, Reporter) {
let (reporter, adapter) = Reporter::spawn(&test_env(), None, Box::new(sink));
(adapter, reporter)
}
fn worker_is_idle(inner: &Arc<Mutex<Inner>>) -> bool {
let guard = inner.lock().unwrap_or_else(PoisonError::into_inner);
let identity_settled = match &guard.desired_session {
Some((id, _)) => guard.delivered_session.as_deref() == Some(id.as_str()),
None => true,
};
!guard.shutdown
&& guard.oneshots.is_empty()
&& guard.machine.pending().is_none()
&& identity_settled
}
fn eventually(mut f: impl FnMut() -> bool) -> bool {
let deadline = Instant::now() + newt_core::test_guard::HANG_GUARD;
while Instant::now() < deadline {
if f() {
return true;
}
std::thread::sleep(Duration::from_millis(2));
}
f()
}
#[test]
fn env_absent_or_incomplete_disables_integration() {
assert_eq!(HerdrEnv::from_parts(None, None, None, None), None);
assert_eq!(
HerdrEnv::from_parts(Some("0"), Some("p"), Some("/s"), None),
None
);
assert_eq!(
HerdrEnv::from_parts(Some("1"), None, Some("/s"), None),
None
);
assert_eq!(
HerdrEnv::from_parts(Some("1"), Some(""), Some("/s"), None),
None
);
assert_eq!(HerdrEnv::from_parts(Some("1"), Some("p"), None, None), None);
assert_eq!(
HerdrEnv::from_parts(Some("1"), Some("p"), Some(""), None),
None
);
assert_eq!(
HerdrEnv::from_parts(Some("1"), Some("p"), Some(""), Some("")),
None
);
assert!(HerdrEnv::from_parts(Some("1"), Some("p"), Some("/s"), None).is_some());
let env = HerdrEnv::from_parts(Some("1"), Some("p"), None, Some("/usr/bin/herdr"));
assert!(env.is_some());
let env = env.unwrap();
assert_eq!(env.socket, None);
assert_eq!(env.bin, Some(PathBuf::from("/usr/bin/herdr")));
}
#[test]
fn no_herdr_means_nosubscription_and_no_worker() {
let guard = SessionGuard {
subscription: None,
reporter: None,
};
assert!(guard.subscription.is_none() && guard.reporter.is_none());
}
#[test]
fn lifecycle_vocabulary_maps_to_herdr_states() {
let sink = FakeSink::new();
let (adapter, mut reporter) = harness(sink.clone());
for event in [
LifecycleEvent::SessionStarted {
session_id: "s-1".into(),
},
LifecycleEvent::Waiting,
LifecycleEvent::TurnStarted,
LifecycleEvent::Thinking,
LifecycleEvent::ToolActivity {
tool: "read_file".into(),
},
LifecycleEvent::Blocked,
LifecycleEvent::Unblocked,
LifecycleEvent::TurnCompleted,
LifecycleEvent::TurnFailed { reason: None },
LifecycleEvent::TurnCancelled,
] {
adapter.on_bare(None, event);
assert!(eventually(|| worker_is_idle(&reporter.inner)));
}
reporter.shutdown();
assert_eq!(
sink.seen().first(),
Some(&("pane.report_agent_session".to_string(), "s-1".to_string())),
"the session identity is announced first"
);
assert_eq!(
sink.states(),
[
"idle", "working", "working", "working", "blocked", "working", "idle", "idle", "idle", ]
);
assert_eq!(
sink.messages(),
[
None,
None,
Some("thinking".into()),
Some("read_file".into()),
None,
Some("read_file".into()),
None,
Some("failed".into()),
Some("cancelled".into()),
],
"tool activity and turn outcome ride along as status tokens"
);
}
#[test]
fn a_command_line_never_becomes_working() {
let sink = FakeSink::new();
let (adapter, mut reporter) = harness(sink.clone());
adapter.on_bare(
None,
LifecycleEvent::SessionStarted {
session_id: "s-cmd".into(),
},
);
assert!(eventually(|| worker_is_idle(&reporter.inner)));
for _ in 0..4 {
adapter.on_bare(None, LifecycleEvent::Waiting);
assert!(eventually(|| worker_is_idle(&reporter.inner)));
}
reporter.shutdown();
assert!(
sink.states().iter().all(|s| s == "idle"),
"no command may produce a Working report, got {:?}",
sink.states()
);
}
#[test]
fn a_solve_run_reports_working_then_idle() {
let sink = FakeSink::new();
let (adapter, mut reporter) = harness(sink.clone());
adapter.on_bare(
None,
LifecycleEvent::SessionStarted {
session_id: "session-solve".into(),
},
);
assert!(eventually(|| worker_is_idle(&reporter.inner)));
adapter.on_bare(None, LifecycleEvent::TurnStarted);
assert!(eventually(|| worker_is_idle(&reporter.inner)));
adapter.on_bare(None, LifecycleEvent::TurnCompleted);
assert!(eventually(|| worker_is_idle(&reporter.inner)));
reporter.shutdown();
assert_eq!(
sink.seen().first(),
Some(&(
"pane.report_agent_session".to_string(),
"session-solve".to_string()
)),
"solve announces its own session identity first"
);
assert_eq!(sink.states(), ["idle", "working", "idle"]);
}
#[test]
fn blocked_state_survives_nested_prompts() {
let sink = FakeSink::new();
let (adapter, mut reporter) = harness(sink.clone());
for event in [
LifecycleEvent::TurnStarted,
LifecycleEvent::Blocked,
LifecycleEvent::Blocked,
LifecycleEvent::Unblocked,
LifecycleEvent::Unblocked,
LifecycleEvent::Unblocked, ] {
adapter.on_bare(None, event);
assert!(eventually(|| worker_is_idle(&reporter.inner)));
}
reporter.shutdown();
assert_eq!(sink.states(), ["working", "blocked", "working"]);
}
#[test]
fn a_turn_ending_under_a_prompt_does_not_clobber_blocked() {
let mut machine = Machine::new();
machine.set((PaneAgentState::Working, None));
machine.prompt_open();
assert_eq!(machine.logical.0, PaneAgentState::Blocked);
machine.set((PaneAgentState::Idle, None));
assert_eq!(machine.logical.0, PaneAgentState::Blocked, "still blocked");
machine.prompt_close();
assert_eq!(machine.logical.0, PaneAgentState::Idle, "then restored");
}
#[test]
fn a_wedged_herdr_cannot_slow_or_stall_the_agent() {
let gate = Arc::new(Mutex::new(()));
let held = gate.lock().unwrap();
let sink = FakeSink::gated(&gate);
let (adapter, mut reporter) = harness(sink.clone());
adapter.on_bare(None, LifecycleEvent::TurnStarted);
sink.wait_entered_gate();
const FLOOD: usize = 20_000;
for i in 0..FLOOD {
adapter.on_bare(
None,
if i % 2 == 0 {
LifecycleEvent::Thinking
} else {
LifecycleEvent::ToolActivity {
tool: "read_file".into(),
}
},
);
}
adapter.on_bare(None, LifecycleEvent::Waiting);
{
let inner = reporter.inner.lock().unwrap();
assert!(
inner.coalesced > 0,
"a flood against a busy worker must coalesce, not queue"
);
assert!(inner.oneshots.len() <= MAX_ONESHOTS, "queues stay bounded");
}
drop(held); assert!(
eventually(|| sink.states().last().map(String::as_str) == Some("idle")),
"after recovery the latest state wins; saw {:?}",
sink.states()
);
assert!(
sink.count() < FLOOD / 10,
"coalescing must not replay every dropped event ({} calls)",
sink.count()
);
reporter.shutdown();
}
#[test]
fn a_failed_delivery_is_redelivered() {
let sink = FakeSink::failing(2);
let (adapter, mut reporter) = harness(sink.clone());
adapter.on_bare(None, LifecycleEvent::TurnStarted);
assert!(eventually(|| sink.count() >= 1));
adapter.on_bare(None, LifecycleEvent::TurnStarted); assert!(eventually(|| sink.states().len() >= 2));
adapter.on_bare(None, LifecycleEvent::TurnStarted);
assert!(eventually(|| {
let states = sink.states();
states.len() >= 3 && states.iter().all(|s| s == "working")
}));
reporter.shutdown();
}
#[test]
fn a_dead_reporter_thread_does_not_reach_the_agent() {
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let (adapter, mut reporter) = harness(FakeSink::panicking());
adapter.on_bare(None, LifecycleEvent::TurnStarted);
assert!(eventually(|| reporter
.worker
.as_ref()
.is_some_and(JoinHandle::is_finished)));
for _ in 0..1_000 {
adapter.on_bare(None, LifecycleEvent::Thinking);
adapter.on_bare(None, LifecycleEvent::Waiting);
}
std::panic::set_hook(hook);
reporter.shutdown();
}
#[test]
fn shutdown_releases_authority_last() {
let sink = FakeSink::new();
let (adapter, mut reporter) = harness(sink.clone());
adapter.on_bare(None, LifecycleEvent::TurnStarted);
assert!(eventually(|| !sink.states().is_empty()));
reporter.shutdown();
let seen = sink.seen();
assert_eq!(seen.last().unwrap().0, "pane.release_agent");
let calls = sink.calls.lock().unwrap();
let release = calls.last().unwrap();
assert_eq!(release.params["pane_id"], "w1:p2");
assert_eq!(release.params["source"], protocol::SOURCE);
assert_eq!(release.params["agent"], protocol::AGENT);
}
#[test]
fn teardown_is_prompt_even_when_herdr_is_stuck() {
let gate = Arc::new(Mutex::new(()));
let held = gate.lock().unwrap();
let sink = FakeSink::gated(&gate);
let (adapter, mut reporter) = harness(sink.clone());
adapter.on_bare(None, LifecycleEvent::TurnStarted);
sink.wait_entered_gate();
let watchdog_fired = Arc::new(AtomicBool::new(false));
let done = Arc::new(AtomicBool::new(false));
{
let watchdog_fired = Arc::clone(&watchdog_fired);
let done = Arc::clone(&done);
std::thread::spawn(move || {
std::thread::sleep(newt_core::test_guard::HANG_GUARD);
if !done.load(Ordering::SeqCst) {
watchdog_fired.store(true, Ordering::SeqCst);
eprintln!(
"hung: reporter.shutdown() did not return within the {}s hang \
guard — a stuck worker must be detached, not joined",
newt_core::test_guard::HANG_GUARD.as_secs()
);
std::process::exit(101);
}
});
}
reporter.shutdown();
done.store(true, Ordering::SeqCst);
assert!(
!watchdog_fired.load(Ordering::SeqCst),
"shutdown() returned only after the watchdog already declared it hung"
);
drop(held);
}
#[test]
fn a_dropped_adapter_still_releases_authority() {
let sink = FakeSink::new();
let (adapter, mut reporter) = harness(sink.clone());
adapter.on_bare(None, LifecycleEvent::TurnStarted);
assert!(eventually(|| !sink.states().is_empty()));
let worker = reporter.worker.take().unwrap();
drop(adapter);
drop(reporter); let _ = worker.join();
assert_eq!(sink.seen().last().unwrap().0, "pane.release_agent");
}
#[test]
fn the_guard_subscribes_to_the_core_lifecycle_seam() {
let sink = FakeSink::new();
let guard = install(&test_env(), Some("repo".into()), Box::new(sink.clone()));
lifecycle::emit(LifecycleEvent::TurnStarted);
assert!(eventually(|| sink
.states()
.contains(&"working".to_string())));
drop(guard);
let after = sink.count();
lifecycle::emit(LifecycleEvent::Waiting);
std::thread::sleep(Duration::from_millis(50));
assert_eq!(sink.count(), after, "a dropped guard receives nothing more");
}
fn start_session(adapter: &Adapter, reporter: &Reporter, id: &str) {
adapter.on_bare(
Some(id),
LifecycleEvent::SessionStarted {
session_id: id.into(),
},
);
assert!(eventually(|| worker_is_idle(&reporter.inner)));
}
#[test]
fn an_undelivered_session_identity_is_not_idle() {
let sink = FakeSink::new();
let (adapter, reporter) = harness(sink);
start_session(&adapter, &reporter, "s1");
assert!(
worker_is_idle(&reporter.inner),
"precondition: a delivered session with a settled machine is idle"
);
{
let mut g = reporter.inner.lock().unwrap();
g.desired_session = Some(("s2".to_string(), SessionStartSource::Startup));
assert!(g.oneshots.is_empty(), "fixture leaves no queued one-shots");
assert!(
g.machine.pending().is_none(),
"fixture leaves no pending state"
);
}
assert!(
!worker_is_idle(&reporter.inner),
"an identity still in flight is not idle"
);
}
#[test]
fn session_identity_survives_transient_delivery_failure() {
let sink = FakeSink::failing(2);
let calls = Arc::clone(&sink.calls);
let (adapter, reporter) = harness(sink);
adapter.on_bare(
Some("s1"),
LifecycleEvent::SessionStarted {
session_id: "s1".into(),
},
);
assert!(eventually(|| {
adapter.on_bare(Some("s1"), LifecycleEvent::Waiting);
reporter.inner.lock().unwrap().delivered_session.as_deref() == Some("s1")
}));
assert_eq!(
reporter.inner.lock().unwrap().delivered_session.as_deref(),
Some("s1"),
"identity converges despite two failed deliveries"
);
let sessions = calls
.lock()
.unwrap()
.iter()
.filter(|c| c.method == "pane.report_agent_session")
.count();
assert!(
sessions >= 3,
"the failed attempts were retried, not dropped (saw {sessions})"
);
}
#[test]
fn new_coalesces_to_the_newest_session_id() {
let sink = FakeSink::new();
let calls = Arc::clone(&sink.calls);
let (adapter, reporter) = harness(sink);
for id in ["s1", "s2", "s3"] {
adapter.on_bare(
Some(id),
LifecycleEvent::SessionStarted {
session_id: id.into(),
},
);
}
assert!(eventually(|| reporter
.inner
.lock()
.unwrap()
.delivered_session
.as_deref()
== Some("s3")));
assert_eq!(
reporter.inner.lock().unwrap().delivered_session.as_deref(),
Some("s3"),
"the newest identity wins"
);
let ids: Vec<String> = calls
.lock()
.unwrap()
.iter()
.filter(|c| c.method == "pane.report_agent_session")
.filter_map(|c| {
c.params
.get("agent_session_id")
.and_then(|v| v.as_str())
.map(str::to_string)
})
.collect();
assert_eq!(
ids.last().map(String::as_str),
Some("s3"),
"last reported id is the newest, saw {ids:?}"
);
}
#[test]
fn a_thinking_does_not_reach_b() {
let sink = FakeSink::new();
let (adapter, reporter) = harness(sink);
start_session(&adapter, &reporter, "B");
adapter.on_bare(Some("A"), LifecycleEvent::Thinking);
assert!(eventually(|| worker_is_idle(&reporter.inner)));
assert_eq!(
reporter.inner.lock().unwrap().machine.logical.0,
PaneAgentState::Idle,
"A's Thinking must not put B's pane to Working"
);
}
#[test]
fn b_waiting_does_not_reach_a() {
let sink = FakeSink::new();
let (adapter, reporter) = harness(sink);
start_session(&adapter, &reporter, "A");
adapter.on_bare(Some("A"), LifecycleEvent::TurnStarted);
assert!(eventually(|| worker_is_idle(&reporter.inner)));
adapter.on_bare(Some("B"), LifecycleEvent::Waiting);
assert!(eventually(|| worker_is_idle(&reporter.inner)));
assert_eq!(
reporter.inner.lock().unwrap().machine.logical.0,
PaneAgentState::Working,
"B's Waiting must not idle A's pane"
);
}
#[test]
fn a_session_ended_does_not_shut_down_b_and_b_keeps_reporting() {
let sink = FakeSink::new();
let (adapter, reporter) = harness(sink);
start_session(&adapter, &reporter, "B");
adapter.on_bare(Some("A"), LifecycleEvent::SessionEnded);
assert!(eventually(|| worker_is_idle(&reporter.inner)));
assert!(
!reporter.inner.lock().unwrap().shutdown,
"A's SessionEnded must not shut down B's reporter"
);
adapter.on_bare(Some("B"), LifecycleEvent::TurnStarted);
assert!(eventually(|| worker_is_idle(&reporter.inner)));
assert_eq!(
reporter.inner.lock().unwrap().machine.logical.0,
PaneAgentState::Working,
"B continues reporting after A ended"
);
}
}