use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;
use uuid::Uuid;
use super::types::{BgTaskMeta, PushOrigin, QueuedUserMessage};
#[derive(Debug, Clone)]
pub struct CmdResult {
pub success: bool,
pub code: i32,
pub output: String,
}
pub struct HookContext {
pub result: CmdResult,
pub elapsed_secs: f32,
}
pub type CompletionHook = Box<
dyn FnOnce(HookContext) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>
+ Send,
>;
#[derive(Debug, Clone)]
pub struct RunningTask {
pub label: String,
pub started: std::time::Instant,
}
pub struct BackgroundTaskManager {
running: Mutex<HashMap<Uuid, Vec<RunningTask>>>,
}
use super::work_status::{CommandExit, WorkStatus};
impl BackgroundTaskManager {
pub fn new() -> Self {
Self {
running: Mutex::new(HashMap::new()),
}
}
pub fn running_for(&self, session_id: Uuid) -> usize {
self.running
.lock()
.map(|m| m.get(&session_id).map(Vec::len).unwrap_or(0))
.unwrap_or(0)
}
pub fn running_tasks(&self, session_id: Uuid) -> Vec<RunningTask> {
self.running
.lock()
.map(|m| m.get(&session_id).cloned().unwrap_or_default())
.unwrap_or_default()
}
fn mark_started(&self, session_id: Uuid, label: &str) {
if let Ok(mut m) = self.running.lock() {
m.entry(session_id).or_default().push(RunningTask {
label: label.to_string(),
started: std::time::Instant::now(),
});
}
}
fn mark_finished(&self, session_id: Uuid, label: &str) {
if let Ok(mut m) = self.running.lock()
&& let Some(tasks) = m.get_mut(&session_id)
{
if let Some(pos) = tasks.iter().position(|t| t.label == label) {
tasks.remove(pos);
}
if tasks.is_empty() {
m.remove(&session_id);
}
}
}
pub fn mirror_started(&self, session_id: Uuid, label: &str) {
self.mark_started(session_id, label);
}
pub fn mirror_finished(&self, session_id: Uuid, label: &str) {
self.mark_finished(session_id, label);
}
pub fn spawn_command(
self: std::sync::Arc<Self>,
session_id: Uuid,
cwd: PathBuf,
label: String,
command: String,
) {
self.spawn_inner(session_id, cwd, label, command, None);
}
pub fn spawn_command_with_hook(
self: std::sync::Arc<Self>,
session_id: Uuid,
cwd: PathBuf,
label: String,
command: String,
hook: CompletionHook,
) {
self.spawn_inner(session_id, cwd, label, command, Some(hook));
}
fn spawn_inner(
self: std::sync::Arc<Self>,
session_id: Uuid,
cwd: PathBuf,
label: String,
command: String,
hook: Option<CompletionHook>,
) {
self.mark_started(session_id, &label);
let this = std::sync::Arc::clone(&self);
let task_id = Uuid::new_v4();
if let Err(e) = WorkStatus::new_command(
&task_id.to_string(),
&session_id.to_string(),
&label,
&command,
) {
tracing::warn!(
target: "background_task",
"Could not write detached status for {task_id}: {e}"
);
}
tokio::spawn(async move {
tracing::info!(
target: "background_task",
"Background task '{label}' started for session {session_id} \
(id={task_id}, cwd={})",
cwd.display()
);
if let Some(repo) = task_repo() {
let cwd_str = cwd.to_string_lossy().to_string();
if let Err(e) = repo
.record(task_id, session_id, &label, &command, &cwd_str)
.await
{
tracing::error!(
target: "background_task",
"Failed to persist background task '{label}': {e:#}"
);
}
}
let started = std::time::Instant::now();
let result = run_detached(&command, &cwd, session_id, DETACH_CEILING).await;
let elapsed_secs = started.elapsed().as_secs_f32();
tracing::info!(
target: "background_task",
"Background task '{label}' for session {session_id} finished \
(success={}, exit={}, elapsed={:.1}s)",
result.success,
result.code,
elapsed_secs
);
if let Err(e) = WorkStatus::finish_command(
&task_id.to_string(),
&session_id.to_string(),
&label,
&command,
CommandExit {
success: result.success,
code: result.code,
elapsed_secs,
output_bytes: result.output.len(),
},
) {
tracing::warn!(
target: "background_task",
"Could not write detached status for {task_id}: {e}"
);
}
if let Some(repo) = task_repo()
&& let Err(e) = repo.clear(task_id).await
{
tracing::error!(
target: "background_task",
"Failed to clear background task '{label}' after completion: {e:#}"
);
}
this.mark_finished(session_id, &label);
if let Some(hook) = hook {
hook(HookContext {
result,
elapsed_secs,
})
.await;
return;
}
let msg = completion_message(&label, &command, &result, elapsed_secs);
let outcome = super::session_routes::deliver_to_session(session_id, msg, true);
match outcome {
super::session_routes::Delivery::Redirected { to } => {
tracing::info!(
target: "background_task",
"Background task '{label}' completion for session {session_id} was \
redirected to session {to}, which now owns its channel"
);
}
super::session_routes::Delivery::Parked => {
tracing::info!(
target: "background_task",
"Background task '{label}' completion for session {session_id} is \
parked until its channel claims the session"
);
}
super::session_routes::Delivery::NoRoute => {
tracing::warn!(
target: "background_task",
"Background task '{label}' completion for session {session_id} had \
nowhere to go; the session will not hear about it"
);
}
super::session_routes::Delivery::RefusedInFlight { .. } => {
tracing::warn!(
target: "background_task",
"Background task '{label}' completion for session {session_id} was \
refused by the mid-turn gate despite interrupt=true"
);
}
super::session_routes::Delivery::Delivered => {}
}
});
}
}
pub(super) fn task_repo() -> Option<crate::db::BackgroundTaskRepository> {
crate::db::global_pool().map(|p| crate::db::BackgroundTaskRepository::new(p.clone()))
}
pub(crate) const DETACH_CEILING: std::time::Duration = std::time::Duration::from_secs(30 * 60);
pub(crate) async fn run_detached(
command: &str,
cwd: &std::path::Path,
session_id: Uuid,
ceiling: std::time::Duration,
) -> CmdResult {
use crate::utils::shell::PushShellCommand;
use tokio::process::Command;
let (shell, shell_arg) = crate::utils::shell::shell_pair();
let log_path = std::env::temp_dir().join(format!(
"opencrabs-bg-{}-{}-{}.log",
session_id.as_simple(),
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or_default(),
));
let launch_fail = |note: String| {
tracing::error!(
target: "background_task",
"Background command could not be launched in {}: {note}",
cwd.display()
);
CmdResult {
success: false,
code: -1,
output: format!("failed to launch: {note}"),
}
};
let log = match std::fs::File::create(&log_path) {
Ok(f) => f,
Err(e) => return launch_fail(e.to_string()),
};
let stderr_log = match log.try_clone() {
Ok(f) => f,
Err(e) => {
let _ = std::fs::remove_file(&log_path);
return launch_fail(e.to_string());
}
};
let mut cmd = Command::new(shell);
cmd.push_shell_command(shell_arg, command)
.current_dir(cwd)
.env("OPENCRABS_SESSION_ID", session_id.to_string())
.stdin(std::process::Stdio::null())
.stdout(log)
.stderr(stderr_log)
.kill_on_drop(true);
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
let _ = std::fs::remove_file(&log_path);
return launch_fail(e.to_string());
}
};
let outcome = match tokio::time::timeout(ceiling, child.wait()).await {
Ok(Ok(status)) => CmdResult {
success: status.success(),
code: status.code().unwrap_or(-1),
output: read_log_tail(&log_path),
},
Ok(Err(e)) => {
tracing::error!(
target: "background_task",
"Background command could not be waited on in {}: {e}",
cwd.display()
);
CmdResult {
success: false,
code: -1,
output: format!("failed to run: {e}"),
}
}
Err(_) => {
let _ = child.start_kill();
let _ = child.wait().await;
CmdResult {
success: false,
code: -1,
output: format!(
"[detached ceiling reached after {}s; the command was \
stopped and may have been still running]\n{}",
ceiling.as_secs(),
read_log_tail(&log_path),
),
}
}
};
let _ = std::fs::remove_file(&log_path);
outcome
}
fn read_log_tail(path: &std::path::Path) -> String {
use std::io::{Read, Seek, SeekFrom};
const MAX_BYTES: u64 = 64 * 1024;
let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
let Ok(mut file) = std::fs::File::open(path) else {
return String::new();
};
if size > MAX_BYTES && file.seek(SeekFrom::End(-(MAX_BYTES as i64))).is_err() {
return String::new();
}
let mut buf = Vec::new();
let _ = file.read_to_end(&mut buf);
let mut text = String::from_utf8_lossy(&buf).into_owned();
if size > MAX_BYTES
&& let Some(nl) = text.find('\n')
{
text = text[nl + 1..].to_string();
}
text
}
pub(crate) fn short_label(command: &str) -> String {
let after_cd = crate::utils::command_label::command_label(command);
let label: String = after_cd.chars().take(60).collect();
if after_cd.chars().count() > 60 {
format!("{label}…")
} else {
label
}
}
pub(crate) fn claude_task_label(task_id: &str) -> String {
format!("claude-cli {task_id}")
}
pub(crate) type ClaudeTurnTasks = std::collections::HashSet<(Uuid, String)>;
pub(crate) fn clear_claude_mirrors_for_session(
tasks: &mut ClaudeTurnTasks,
mgr: &BackgroundTaskManager,
session_id: Uuid,
) {
tasks.retain(|(sid, id)| {
if *sid != session_id {
return true;
}
mgr.mirror_finished(*sid, &claude_task_label(id));
false
});
}
pub(crate) fn claude_needs_survival_delivery(
turn_started: &ClaudeTurnTasks,
session_id: Uuid,
task_id: &str,
) -> bool {
!turn_started.contains(&(session_id, task_id.to_string()))
}
pub(crate) fn claude_completion_message(
task_id: &str,
status: &str,
summary: Option<&str>,
) -> QueuedUserMessage {
let label = claude_task_label(task_id);
let context = format!(
"[System: a background claude task reported completion after the turn \
that started it already ended.\n\
Task: {label}\n\
Status: {status}\n\
Summary: {}\n\n\
Deliver this result to the user and continue anything that was \
waiting on it. Do not re-run the task.]",
summary.unwrap_or("(no summary provided)"),
);
let display = format!(
"🔧 background task {}: {label}",
if status == "completed" {
"finished"
} else {
"failed"
}
);
let mut msg = QueuedUserMessage::system(context, display);
msg.origin = PushOrigin::BackgroundTask;
msg
}
pub(crate) fn tail_lines(text: &str, n: usize) -> String {
let lines: Vec<&str> = text.lines().collect();
let start = lines.len().saturating_sub(n);
lines[start..].join("\n")
}
pub(crate) fn completion_message(
label: &str,
command: &str,
result: &CmdResult,
elapsed_secs: f32,
) -> QueuedUserMessage {
let status = if result.success {
"exit 0 (success)".to_string()
} else {
format!("exit {} (failure)", result.code)
};
let tail = tail_lines(&result.output, 50);
let context = format!(
"[System: the background task you started has finished.\n\
Task: {label}\n\
Command: {command}\n\
Status: {status}\n\
Output (last 50 lines):\n{tail}\n\n\
Report the result to the user and continue anything that was waiting on it. \
Do not re-run the command — this IS its result.]"
);
let display = format!(
"🔧 background task {}: {label}",
if result.success { "finished" } else { "failed" }
);
let mut msg = QueuedUserMessage::system(context, display);
msg.origin = PushOrigin::BackgroundTask;
msg.bg_meta = Some(BgTaskMeta {
success: result.success,
label: label.to_string(),
elapsed_secs,
tail,
});
msg
}
pub(crate) fn format_elapsed(secs: f32) -> String {
let total = secs.max(0.0).round() as u64;
if total < 60 {
format!("{total}s")
} else if total < 3600 {
format!("{}m {}s", total / 60, total % 60)
} else {
format!("{}h {}m", total / 3600, (total % 3600) / 60)
}
}