use anyhow::Result;
use chrono::Local;
use std::sync::Arc;
use crate::background;
use crate::board::render_board;
use crate::session;
use crate::store::Store;
use crate::types::{Task, TaskFilter, TaskStatus, Workgroup};
const DEFAULT_TASK_LIMIT: usize = 50;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct TruncationNotice {
shown: usize,
total: usize,
}
pub fn run(
store: &Arc<Store>,
running: bool,
today: bool,
mine: bool,
group: Option<&str>,
limit: Option<usize>,
json: bool,
) -> Result<()> {
let filter = if running {
TaskFilter::Running
} else if today {
TaskFilter::Today
} else {
TaskFilter::All
};
background::check_zombie_tasks(store)?;
let mut tasks = store.list_tasks(filter)?;
if mine {
tasks.retain(session::matches_current);
}
if let Some(group_id) = group {
tasks.retain(|task| task.workgroup_id.as_deref() == Some(group_id));
}
let truncation = apply_limit(&mut tasks, limit, running, today, mine, group);
let fingerprint = task_fingerprint(&tasks);
let marker_path = crate::paths::aid_dir().join("board-last.txt");
let mut repeat_count: u32 = 0;
if let Ok(prev) = std::fs::read_to_string(&marker_path) {
let parts: Vec<&str> = prev.splitn(3, '\n').collect();
let prev_fp = parts.get(1).copied().unwrap_or("");
let prev_count: u32 = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
if prev_fp == fingerprint {
repeat_count = prev_count + 1;
if repeat_count >= 3 {
aid_warn!("[aid] No changes after {} checks. Use `aid watch --quiet` instead of polling. Exiting.", repeat_count);
let content = format!("{}\n{}\n{}", Local::now().timestamp(), fingerprint, repeat_count);
let _ = std::fs::write(&marker_path, &content);
std::process::exit(1);
}
aid_hint!("[aid] No status changes since last check ({repeat_count}x). Use `aid watch --quiet` for automatic notification instead of polling.");
}
}
let content = format!("{}\n{}\n{}", Local::now().timestamp(), fingerprint, repeat_count);
let _ = std::fs::write(&marker_path, &content);
if json {
let payload: Vec<serde_json::Value> = tasks
.iter()
.map(board_json_row)
.collect();
println!("{}", serde_json::to_string(&payload)?);
return Ok(());
}
let has_terminal_worktree = tasks.iter().any(|task| {
matches!(
task.status,
TaskStatus::Done | TaskStatus::Failed | TaskStatus::Merged | TaskStatus::Skipped | TaskStatus::Stopped
) && task.worktree_path.is_some()
});
if let Some(group_id) = group
&& let Some(header) = group_header(store, group_id)?
{
print!("{header}");
}
print!("{}", render_board(&tasks, store)?);
if let Some(truncation) = truncation {
println!("{}", truncation_notice_message(truncation));
}
if let Some(warning) = long_running_warning(&tasks, Local::now()) {
println!("{warning}");
}
if has_terminal_worktree
&& let Ok(stale_count) = crate::cmd::worktree::stale_worktree_count(None)
&& stale_count > 3
{
println!("[aid] Tip: run `aid worktree prune` to clean up stale worktrees");
}
Ok(())
}
fn group_header(store: &Store, group_id: &str) -> Result<Option<String>> {
let Some(workgroup) = store.get_workgroup(group_id)? else {
return Ok(None);
};
Ok(Some(format_group_header(&workgroup)))
}
fn format_group_header(workgroup: &Workgroup) -> String {
if workgroup.name == workgroup.id.as_str() {
format!("Workgroup: {}\n\n", workgroup.id)
} else {
format!("Workgroup: {} ({})\n\n", workgroup.id, workgroup.name)
}
}
pub(crate) fn apply_limit(
tasks: &mut Vec<Task>,
limit: Option<usize>,
running: bool,
today: bool,
mine: bool,
group: Option<&str>,
) -> Option<TruncationNotice> {
let effective_limit = match limit {
Some(n) => Some(n),
None if group.is_none() && !running && !today && !mine => Some(DEFAULT_TASK_LIMIT),
None => None,
}?;
if tasks.len() <= effective_limit {
return None;
}
let total = tasks.len();
tasks.truncate(effective_limit);
Some(TruncationNotice { shown: effective_limit, total })
}
pub(crate) fn truncation_notice_message(truncation: TruncationNotice) -> String {
format!(
"[aid] Showing {} of {} tasks. Use --limit N or --today/--running for more.",
truncation.shown, truncation.total
)
}
fn task_fingerprint(tasks: &[crate::types::Task]) -> String {
let mut parts: Vec<String> = tasks
.iter()
.map(|t| format!("{}:{}", t.id, t.status.label()))
.collect();
parts.sort();
parts.join(",")
}
fn long_running_warning(tasks: &[crate::types::Task], now: chrono::DateTime<Local>) -> Option<String> {
let count = tasks
.iter()
.filter(|task| task.status == TaskStatus::Running)
.filter(|task| (now - task.created_at).num_hours() >= 1)
.count();
if count == 0 {
return None;
}
Some(format!(
"[aid] Warning: {} task(s) running >1h — may be stale. Use `aid stop <id>` to clean up.",
count
))
}
fn board_json_row(task: &Task) -> serde_json::Value {
serde_json::json!({
"id": task.id.as_str(),
"agent": task.agent_display_name(),
"status": task.status.as_str(),
"prompt": task.prompt,
"model": task.model,
"tokens": task.tokens,
"duration_ms": task.duration_ms,
"cost_usd": task.cost_usd,
"workgroup_id": task.workgroup_id,
"worktree_branch": task.worktree_branch,
"verify_status": task.verify_status.as_str(),
"pending_reason": task.pending_reason,
"created_at": task.created_at.to_rfc3339(),
"completed_at": task.completed_at.map(|dt| dt.to_rfc3339()),
})
}
#[cfg(test)]
mod tests {
use super::{apply_limit, board_json_row, long_running_warning, truncation_notice_message, TruncationNotice};
use chrono::{Duration, Local};
use crate::paths::AidHomeGuard;
use crate::store::Store;
use crate::types::{AgentKind, Task, TaskId, TaskStatus, VerifyStatus};
#[test]
fn long_running_warning_counts_running_tasks_older_than_one_hour() {
let now = Local::now();
let tasks = vec![
make_task("t-1001", TaskStatus::Running, now - Duration::hours(1)),
make_task("t-1002", TaskStatus::Running, now - Duration::minutes(59)),
make_task("t-1003", TaskStatus::Done, now - Duration::hours(3)),
];
let warning = long_running_warning(&tasks, now).unwrap();
assert!(warning.contains("1 task(s) running >1h"));
}
#[test]
fn board_with_limit_truncates_output() {
let now = Local::now();
let mut tasks = vec![
make_task("t-1001", TaskStatus::Done, now),
make_task("t-1002", TaskStatus::Done, now),
make_task("t-1003", TaskStatus::Done, now),
];
let truncation = apply_limit(&mut tasks, Some(2), false, false, false, None);
assert_eq!(tasks.len(), 2);
assert_eq!(truncation, Some(TruncationNotice { shown: 2, total: 3 }));
assert_eq!(
truncation_notice_message(truncation.unwrap()),
"[aid] Showing 2 of 3 tasks. Use --limit N or --today/--running for more."
);
}
#[test]
fn board_json_row_includes_pending_reason() {
let mut task = make_task("t-1004", TaskStatus::Failed, Local::now());
task.pending_reason = Some("worker_capacity".to_string());
let row = board_json_row(&task);
assert_eq!(row["pending_reason"], "worker_capacity");
}
#[test]
fn format_group_header_includes_custom_name() {
let temp = tempfile::tempdir().unwrap();
let _guard = AidHomeGuard::set(temp.path());
let store = Store::open_memory().unwrap();
let workgroup = store
.create_workgroup("My Batch", "", Some("seed"), Some("wg-batch"))
.unwrap();
assert_eq!(
super::format_group_header(&workgroup),
"Workgroup: wg-batch (My Batch)\n\n"
);
}
fn make_task(task_id: &str, status: TaskStatus, created_at: chrono::DateTime<Local>) -> Task {
Task {
id: TaskId(task_id.to_string()),
agent: AgentKind::Codex,
custom_agent_name: None,
prompt: "prompt".to_string(),
resolved_prompt: None,
category: None,
status,
parent_task_id: None,
workgroup_id: None,
caller_kind: None,
caller_session_id: None,
agent_session_id: None,
repo_path: None,
worktree_path: None,
worktree_branch: None,
start_sha: None,
log_path: None,
output_path: None,
tokens: None,
prompt_tokens: None,
duration_ms: None,
model: None,
cost_usd: None,
exit_code: None,
created_at,
completed_at: None,
verify: None,
verify_status: VerifyStatus::Skipped,
pending_reason: None,
read_only: false,
budget: false,
}
}
}