use std::{
path::{Path, PathBuf},
sync::{Arc, Mutex},
time::Duration,
};
use rho_sdk::{
tool::{tool_progress_channel, Tool, ToolContext, ToolInvocation},
CancellationToken, ToolCallId,
};
use serde_json::{json, Value};
use tokio::task::JoinHandle;
use crate::tools::{
mcp::exported_name::namespaced_tool_name, process::ProcessManager, sdk_registry::AppToolSet,
workflow_tracker::WorkflowRunTracker,
};
pub(super) const WAKE_COMMAND: &str = "/rho-wake";
const SERVER: &str = "t3-code";
const SIGNAL_POLL: Duration = Duration::from_millis(500);
const TITLE_LABEL_CHARS: usize = 60;
pub(super) struct T3Wake {
signal_dir: PathBuf,
armed: Option<Watcher>,
ended: Vec<EndedWatcher>,
}
struct Watcher {
task_id: String,
signal: WakeSignal,
lock: std::fs::File,
notifier: Option<Notifier>,
}
struct EndedWatcher {
task_id: String,
_lock: std::fs::File,
}
struct Notifier {
task: JoinHandle<()>,
state: Arc<Mutex<NotifierState>>,
}
#[derive(Default)]
struct NotifierState {
quiet: bool,
fired: bool,
}
impl Notifier {
fn spawn(background: Background, signal: WakeSignal) -> Self {
let state = Arc::new(Mutex::new(NotifierState::default()));
let publish = Arc::clone(&state);
let task = tokio::spawn(async move {
background.clone().result_ready().await;
let mut state = publish.lock().unwrap_or_else(|error| error.into_inner());
if state.quiet {
return;
}
match signal.fire(&finished_summary(&background)) {
Ok(()) => state.fired = true,
Err(error) => tracing::warn!(%error, "could not signal the T3 wake watcher"),
}
});
Self { task, state }
}
fn quiet(self) -> bool {
let fired = {
let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner());
state.quiet = true;
state.fired
};
self.task.abort();
fired
}
}
#[derive(Debug, PartialEq)]
pub(super) enum PromptOrigin {
WatcherNotice,
Other,
}
#[derive(Clone)]
pub(super) struct Background {
pub(super) session_id: String,
pub(super) processes: Option<ProcessManager>,
pub(super) workflows: WorkflowRunTracker,
}
impl Background {
pub(super) fn of(tools: &AppToolSet, session_id: String) -> Self {
Self {
session_id,
processes: tools.processes().cloned(),
workflows: tools.workflow_tracker().clone(),
}
}
fn outstanding(&self) -> bool {
self.processes.as_ref().is_some_and(|processes| {
processes.has_pending_notification()
|| processes
.live_summaries()
.iter()
.any(|summary| summary.state.is_live())
}) || self
.workflows
.has_active_or_pending_notification(&self.session_id)
}
fn live_labels(&self) -> Vec<String> {
let processes = self
.processes
.iter()
.flat_map(ProcessManager::live_summaries)
.filter(|summary| summary.state.is_live())
.map(|summary| summary.command);
let workflows = self
.workflows
.rail_summaries(&self.session_id)
.into_iter()
.filter(|summary| summary.is_live())
.map(|summary| format!("workflow {}", summary.workflow_name));
processes.chain(workflows).collect()
}
fn has_result(&self) -> bool {
self.processes
.as_ref()
.is_some_and(ProcessManager::has_pending_notification)
|| self.workflows.has_pending_notification(&self.session_id)
}
async fn result_ready(self) {
loop {
let process_exit = self.processes.as_ref().map(ProcessManager::notified_owned);
let workflow_finish = self.workflows.notified_owned();
if self.has_result() {
return;
}
let process_exit = async move {
match process_exit {
Some(exit) => exit.await,
None => std::future::pending().await,
}
};
tokio::select! {
() = process_exit => {}
() = workflow_finish => {}
}
}
}
}
impl T3Wake {
pub(super) fn new(signal_dir: PathBuf) -> Self {
Self {
signal_dir,
armed: None,
ended: Vec::new(),
}
}
pub(super) fn prompt_started(&mut self, text: Option<&str>) -> PromptOrigin {
let mentions = |id: &str| text.is_some_and(|text| text.contains(id));
if let Some(watcher) = &mut self.armed {
let fired = watcher.notifier.take().is_some_and(Notifier::quiet);
if fired || mentions(&watcher.task_id) {
let watcher = self.armed.take().expect("armed watcher");
if !fired {
watcher.signal.remove();
}
self.ended.push(EndedWatcher {
task_id: watcher.task_id,
_lock: watcher.lock,
});
}
}
let before = self.ended.len();
self.ended.retain(|ended| !mentions(&ended.task_id));
if self.ended.len() < before {
PromptOrigin::WatcherNotice
} else {
PromptOrigin::Other
}
}
pub(super) async fn prompt_ended(
&mut self,
tools: &AppToolSet,
background: Background,
) -> anyhow::Result<()> {
if !background.outstanding() {
if let Some(watcher) = self.armed.take() {
self.ended.push(retire(tools, watcher).await);
}
return Ok(());
}
if self.armed.is_none() {
let labels = background.live_labels();
self.armed = Some(arm(tools, &self.signal_dir, &labels).await?);
}
let watcher = self.armed.as_mut().expect("armed watcher");
watcher.notifier = Some(Notifier::spawn(background, watcher.signal.clone()));
Ok(())
}
}
impl Drop for T3Wake {
fn drop(&mut self) {
if let Some(watcher) = &mut self.armed {
if let Some(notifier) = watcher.notifier.take() {
notifier.quiet();
}
}
}
}
async fn arm(tools: &AppToolSet, signal_dir: &Path, labels: &[String]) -> anyhow::Result<Watcher> {
let delegate = t3_tool(tools, "delegate_task")?;
let (signal, lock) = WakeSignal::create(signal_dir)?;
let arguments = json!({
"task": format!("{WAKE_COMMAND} {}", signal.token),
"title": watcher_title(labels),
"mode": "async",
});
let task_id = delegate
.call(invocation(arguments), host_context())
.await
.map_err(|error| anyhow::anyhow!("delegate_task failed: {}", error.message()))
.and_then(|output| {
if output.is_failure() {
anyhow::bail!("delegate_task failed: {}", output.content());
}
output
.structured_content()
.and_then(|data| data.get("taskId"))
.and_then(Value::as_str)
.filter(|id| !id.is_empty())
.map(str::to_owned)
.ok_or_else(|| anyhow::anyhow!("delegate_task returned no taskId"))
});
let task_id = match task_id {
Ok(task_id) => task_id,
Err(error) => {
signal.cleanup();
return Err(error);
}
};
Ok(Watcher {
task_id,
signal,
lock,
notifier: None,
})
}
async fn retire(tools: &AppToolSet, mut watcher: Watcher) -> EndedWatcher {
if let Some(notifier) = watcher.notifier.take() {
notifier.quiet();
}
watcher.signal.remove();
let cancelled = match t3_tool(tools, "task_cancel") {
Ok(cancel) => cancel
.call(
invocation(json!({"taskId": watcher.task_id})),
host_context(),
)
.await
.map_err(|error| error.message().to_owned())
.and_then(|output| {
if output.is_failure() {
Err(output.content().to_owned())
} else {
Ok(())
}
}),
Err(error) => Err(error.to_string()),
};
if let Err(error) = cancelled {
tracing::warn!(%error, task_id = %watcher.task_id, "could not cancel T3 wake watcher");
}
EndedWatcher {
task_id: watcher.task_id,
_lock: watcher.lock,
}
}
fn t3_tool(tools: &AppToolSet, name: &str) -> anyhow::Result<Arc<dyn Tool>> {
tools
.tool_named(&namespaced_tool_name(SERVER, name))
.ok_or_else(|| anyhow::anyhow!("MCP server `{SERVER}` provides no `{name}` tool"))
}
fn invocation(arguments: Value) -> ToolInvocation {
let id = ToolCallId::from_string(format!("t3-wake-{}", uuid::Uuid::new_v4()))
.expect("uuid call ids are valid");
ToolInvocation::new(id, arguments)
}
fn host_context() -> ToolContext {
let (progress, _receiver) = tool_progress_channel(std::num::NonZeroUsize::MIN);
ToolContext::new( None, CancellationToken::new(), progress)
}
fn watcher_title(labels: &[String]) -> String {
let Some(first) = labels.first() else {
return "waiting: background results".into();
};
let first = first.lines().next().unwrap_or_default().trim();
let mut label: String = first.chars().take(TITLE_LABEL_CHARS).collect();
if label.len() < first.len() {
label.push('…');
}
match labels.len() {
1 => format!("waiting: {label}"),
count => format!("waiting: {label} (+{} more)", count - 1),
}
}
fn finished_summary(background: &Background) -> String {
let process = background.processes.iter().find_map(|processes| {
processes
.live_summaries()
.into_iter()
.find(|summary| !summary.state.is_live())
});
match process {
Some(summary) => match summary.exit_code {
Some(code) => format!("{} exited {code}", summary.command),
None => format!("{} finished", summary.command),
},
None => "background work finished".into(),
}
}
pub(super) fn signal_dir() -> anyhow::Result<PathBuf> {
Ok(crate::paths::rho_dir()?.join("acp-wakes"))
}
const PARENT_ENDED: &str = "the Rho session that started this background work ended before it \
finished";
#[derive(Clone, Debug, PartialEq)]
pub(super) struct WakeSignal {
token: String,
path: PathBuf,
}
impl WakeSignal {
fn create(dir: &Path) -> anyhow::Result<(Self, std::fs::File)> {
std::fs::create_dir_all(dir)?;
let token = uuid::Uuid::new_v4().to_string();
let signal = Self {
path: dir.join(&token),
token,
};
let lock = std::fs::File::create(signal.lock_path())?;
lock.lock()?;
Ok((signal, lock))
}
pub(super) fn parse(token: &str) -> Option<Self> {
Self::parse_in(&signal_dir().ok()?, token)
}
fn parse_in(dir: &Path, token: &str) -> Option<Self> {
let token = uuid::Uuid::parse_str(token.trim()).ok()?.to_string();
Some(Self {
path: dir.join(&token),
token,
})
}
#[cfg(test)]
pub(super) fn token(&self) -> &str {
&self.token
}
fn lock_path(&self) -> PathBuf {
self.path.with_extension("lock")
}
fn fire(&self, summary: &str) -> std::io::Result<()> {
let staging = self.path.with_extension("tmp");
std::fs::write(&staging, summary)?;
std::fs::rename(staging, &self.path)
}
fn remove(&self) {
let _ = std::fs::remove_file(&self.path);
}
fn cleanup(&self) {
self.remove();
let _ = std::fs::remove_file(self.lock_path());
}
fn parent_ended(&self) -> bool {
match std::fs::File::open(self.lock_path()) {
Ok(lock) => lock.try_lock().is_ok(),
Err(error) => error.kind() == std::io::ErrorKind::NotFound,
}
}
pub(super) async fn wait(&self, cancel: &CancellationToken) -> Option<String> {
loop {
if let Ok(summary) = std::fs::read_to_string(&self.path) {
self.cleanup();
return Some(summary);
}
if self.parent_ended() {
self.cleanup();
return Some(PARENT_ENDED.into());
}
tokio::select! {
() = cancel.cancelled() => {
self.cleanup();
return None;
}
() = tokio::time::sleep(SIGNAL_POLL) => {}
}
}
}
}
#[cfg(test)]
#[path = "t3_wake_tests.rs"]
mod tests;