use std::{
collections::HashMap,
path::PathBuf,
sync::{Arc, Mutex},
time::Instant,
};
use serde_json::json;
use tokio::sync::watch;
use crate::{
a2a::{A2aArtifact, ArtifactPart},
detached::DetachedRegistry,
};
pub(crate) const RESIDUE_ARTIFACT: &str = "residue";
pub(crate) const PHASE_QUEUED: &str = "queued";
pub(crate) const PHASE_TURN: &str = "turn";
pub(crate) const PHASE_INFERENCE: &str = "inference";
pub(crate) const PHASE_INPUT: &str = "input";
pub(crate) const PHASE_DELEGATION: &str = "delegation";
#[derive(Debug, Clone)]
pub(crate) struct CancelSignal {
tx: Arc<watch::Sender<bool>>,
phase: Arc<Mutex<Option<&'static str>>>,
}
impl CancelSignal {
pub(crate) fn new() -> Self {
let (tx, _rx) = watch::channel(false);
Self {
tx: Arc::new(tx),
phase: Arc::new(Mutex::new(None)),
}
}
pub(crate) fn note_phase(&self, phase: &'static str) {
self.phase
.lock()
.expect("cancel phase mutex")
.get_or_insert(phase);
}
pub(crate) fn phase(&self) -> &'static str {
self.phase
.lock()
.expect("cancel phase mutex")
.unwrap_or(PHASE_TURN)
}
pub(crate) fn cancel(&self) {
self.tx.send_replace(true);
}
pub(crate) fn is_canceled(&self) -> bool {
*self.tx.borrow()
}
pub(crate) async fn canceled(&self) {
let mut rx = self.tx.subscribe();
loop {
if *rx.borrow_and_update() {
return;
}
if rx.changed().await.is_err() {
std::future::pending::<()>().await;
}
}
}
}
impl Default for CancelSignal {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub(crate) struct LiveDelegation {
pub(crate) workdir: PathBuf,
pub(crate) capsule: String,
pub(crate) version: String,
pub(crate) child_session_id: String,
pub(crate) child_workdir: String,
pub(crate) started: Instant,
}
#[derive(Debug, Default)]
pub(crate) struct LiveDelegations {
inner: Mutex<HashMap<String, LiveDelegation>>,
}
impl LiveDelegations {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn register(&self, delegation_id: String, delegation: LiveDelegation) {
self.inner
.lock()
.expect("live delegations mutex")
.insert(delegation_id, delegation);
}
pub(crate) fn finish(&self, delegation_id: &str) -> Option<LiveDelegation> {
self.inner
.lock()
.expect("live delegations mutex")
.remove(delegation_id)
}
pub(crate) fn release(&self, delegation_id: &str) {
self.inner
.lock()
.expect("live delegations mutex")
.remove(delegation_id);
}
pub(crate) fn live(&self) -> Vec<ResidueItem> {
let mut items: Vec<ResidueItem> = self
.inner
.lock()
.expect("live delegations mutex")
.iter()
.map(|(delegation_id, delegation)| ResidueItem::Delegation {
delegation_id: delegation_id.clone(),
capsule: delegation.capsule.clone(),
version: delegation.version.clone(),
child_session_id: delegation.child_session_id.clone(),
child_workdir: delegation.child_workdir.clone(),
})
.collect();
items.sort_by(|a, b| a.id().cmp(b.id()));
items
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ResidueItem {
DetachedShell {
work_id: String,
binary: String,
command: String,
started_at_ms: u64,
},
Delegation {
delegation_id: String,
capsule: String,
version: String,
child_session_id: String,
child_workdir: String,
},
}
impl ResidueItem {
pub(crate) fn id(&self) -> &str {
match self {
Self::DetachedShell { work_id, .. } => work_id,
Self::Delegation { delegation_id, .. } => delegation_id,
}
}
pub(crate) fn kind(&self) -> &'static str {
match self {
Self::DetachedShell { .. } => "detached_shell",
Self::Delegation { .. } => "delegation",
}
}
fn to_json(&self) -> serde_json::Value {
match self {
Self::DetachedShell {
work_id,
binary,
command,
started_at_ms,
} => json!({
"kind": self.kind(),
"work_id": work_id,
"binary": binary,
"command": command,
"started_at_ms": started_at_ms,
}),
Self::Delegation {
delegation_id,
capsule,
version,
child_session_id,
child_workdir,
} => json!({
"kind": self.kind(),
"delegation_id": delegation_id,
"capsule": capsule,
"version": version,
"child_session_id": child_session_id,
"child_workdir": child_workdir,
}),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct Residue {
items: Vec<ResidueItem>,
}
impl Residue {
pub(crate) fn snapshot(
detached: Option<&Arc<DetachedRegistry>>,
delegations: &LiveDelegations,
) -> Self {
let mut items: Vec<ResidueItem> = detached
.map(|registry| {
let mut work: Vec<_> = registry.outstanding();
work.sort_by(|a, b| a.work_id.cmp(&b.work_id));
work.into_iter()
.map(|work| ResidueItem::DetachedShell {
work_id: work.work_id,
binary: work.binary,
command: work.command,
started_at_ms: work.started_at_ms,
})
.collect()
})
.unwrap_or_default();
items.extend(delegations.live());
Self { items }
}
pub(crate) fn detached_work_ids(&self) -> Vec<String> {
self.items
.iter()
.filter(|item| matches!(item, ResidueItem::DetachedShell { .. }))
.map(|item| item.id().to_string())
.collect()
}
pub(crate) fn delegation_ids(&self) -> Vec<String> {
self.items
.iter()
.filter(|item| matches!(item, ResidueItem::Delegation { .. }))
.map(|item| item.id().to_string())
.collect()
}
pub(crate) fn into_json_items(self) -> Vec<serde_json::Value> {
self.items.iter().map(ResidueItem::to_json).collect()
}
pub(crate) fn into_artifact(self) -> Option<A2aArtifact> {
if self.items.is_empty() {
return None;
}
Some(A2aArtifact {
name: RESIDUE_ARTIFACT.to_string(),
parts: self
.items
.iter()
.map(|item| ArtifactPart {
text: item.to_json().to_string(),
})
.collect(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn live_delegation(capsule: &str) -> LiveDelegation {
LiveDelegation {
workdir: PathBuf::from("/tmp/child"),
capsule: capsule.to_string(),
version: "0.1.0".to_string(),
child_session_id: "ses_child".to_string(),
child_workdir: ".murmur/children/child-1".to_string(),
started: Instant::now(),
}
}
#[test]
fn a_registered_delegation_is_live_until_it_finishes() {
let live = LiveDelegations::new();
assert!(live.live().is_empty());
live.register("dlg_one".to_string(), live_delegation("worker"));
let items = live.live();
assert_eq!(items.len(), 1);
assert_eq!(items[0].id(), "dlg_one");
assert_eq!(items[0].kind(), "delegation");
let finished = live.finish("dlg_one").expect("the entry is still there");
assert_eq!(finished.capsule, "worker");
assert!(live.live().is_empty());
assert!(live.finish("dlg_one").is_none());
}
#[test]
fn releasing_drops_a_delegation_that_will_never_report() {
let live = LiveDelegations::new();
live.register("dlg_one".to_string(), live_delegation("worker"));
live.release("dlg_one");
assert!(live.live().is_empty());
live.release("dlg_missing");
}
#[test]
fn live_delegations_are_listed_in_id_order() {
let live = LiveDelegations::new();
live.register("dlg_b".to_string(), live_delegation("second"));
live.register("dlg_a".to_string(), live_delegation("first"));
let listed = live.live();
let ids: Vec<&str> = listed.iter().map(ResidueItem::id).collect();
assert_eq!(ids, vec!["dlg_a", "dlg_b"]);
}
#[test]
fn an_empty_residue_produces_no_artifact() {
assert!(Residue::default().into_artifact().is_none());
assert!(Residue::snapshot(None, &LiveDelegations::new())
.into_artifact()
.is_none());
}
#[test]
fn a_residue_produces_one_json_part_per_item() {
let live = LiveDelegations::new();
live.register("dlg_one".to_string(), live_delegation("worker"));
let residue = Residue {
items: vec![
ResidueItem::DetachedShell {
work_id: "wrk_abc".to_string(),
binary: "sleep".to_string(),
command: "sleep 30".to_string(),
started_at_ms: 17,
},
live.live().remove(0),
],
};
assert_eq!(residue.detached_work_ids(), vec!["wrk_abc".to_string()]);
assert_eq!(residue.delegation_ids(), vec!["dlg_one".to_string()]);
let artifact = residue.into_artifact().expect("two items is not empty");
assert_eq!(artifact.name, "residue");
assert_eq!(artifact.parts.len(), 2);
let shell: serde_json::Value = serde_json::from_str(&artifact.parts[0].text).unwrap();
assert_eq!(shell["kind"], "detached_shell");
assert_eq!(shell["work_id"], "wrk_abc");
assert_eq!(shell["command"], "sleep 30");
assert_eq!(shell["binary"], "sleep");
assert_eq!(shell["started_at_ms"], 17);
let delegation: serde_json::Value = serde_json::from_str(&artifact.parts[1].text).unwrap();
assert_eq!(delegation["kind"], "delegation");
assert_eq!(delegation["delegation_id"], "dlg_one");
assert_eq!(delegation["capsule"], "worker");
assert_eq!(delegation["child_session_id"], "ses_child");
}
#[tokio::test]
async fn a_cancel_signal_resolves_before_and_after_it_is_raised() {
let signal = CancelSignal::new();
assert!(!signal.is_canceled());
let observer = signal.clone();
let waiting = tokio::spawn(async move { observer.canceled().await });
signal.cancel();
waiting.await.unwrap();
assert!(signal.is_canceled());
tokio::time::timeout(std::time::Duration::from_secs(5), signal.canceled())
.await
.expect("an already-cancelled signal does not wait");
}
}