use anyhow::Result;
use chrono::Local;
use std::io::Write;
use std::sync::Arc;
use crate::background;
use crate::board::render_board;
use crate::store::Store;
use crate::types::{Task, TaskFilter, TaskStatus, Workgroup};
#[path = "board_filter.rs"]
pub(crate) mod board_filter;
#[path = "board_poll.rs"]
mod board_poll;
use board_poll::{
anti_poll_status, task_fingerprint, watch_instead_of_polling_hint, write_board_marker,
AntiPollStatus, ForceMarkerState,
};
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>,
all_projects: bool,
limit: Option<usize>,
force: bool,
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)?;
let project_filter = board_filter::apply_board_filters(&mut tasks, mine, group, all_projects);
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 now = Local::now().timestamp();
let mut repeat_count = 0;
let mut force_state = ForceMarkerState::default();
let mut poll_notice = None;
let mut poll_failed = false;
if !json {
let anti_poll = anti_poll_status(&marker_path, &fingerprint, now, force);
force_state = anti_poll.1;
let watch_hint = watch_instead_of_polling_hint(&tasks);
repeat_count = match anti_poll.0 {
AntiPollStatus::Allowed(repeat_count) => repeat_count,
AntiPollStatus::Cooldown(elapsed) => {
poll_notice = Some(format!("[aid] Board checked {elapsed}s ago. {watch_hint}"));
0
}
AntiPollStatus::Repeat(repeat_count) => {
poll_notice = Some(format!("[aid] No changes after {repeat_count} checks. {watch_hint} Exiting."));
poll_failed = true;
repeat_count
}
AntiPollStatus::ForceBlocked => {
poll_notice = Some(format!("[aid] Repeated polling detected. Board locked for 60s. {watch_hint}"));
poll_failed = true;
0
}
};
}
let mut stdout = std::io::stdout();
write_board_output(
&mut stdout,
store,
&tasks,
group,
project_filter.as_ref().map(|f| f.as_deref()),
all_projects,
truncation,
json,
)?;
stdout.flush()?;
if let Some(notice) = poll_notice {
if poll_failed {
aid_warn!("{notice}");
} else {
aid_hint!("{notice}");
}
}
if !json {
write_board_marker(&marker_path, &fingerprint, now, repeat_count, force_state.count, force_state.window_start);
}
if poll_failed {
std::process::exit(1);
}
Ok(())
}
fn write_board_output<W: Write>(
writer: &mut W,
store: &Store,
tasks: &[Task],
group: Option<&str>,
project_filter: Option<Option<&str>>,
all_projects: bool,
truncation: Option<TruncationNotice>,
json: bool,
) -> Result<()> {
if json {
let payload: Vec<serde_json::Value> = tasks.iter().map(board_json_row).collect();
writeln!(writer, "{}", 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());
writeln!(
writer,
"{}",
board_filter::project_scope_banner(project_filter, all_projects)
)?;
if let Some(group_id) = group
&& let Some(header) = group_header(store, group_id)?
{
write!(writer, "{header}")?;
}
write!(writer, "{}", render_board(tasks, store)?)?;
if let Some(notices) = terminal_missing_result_notices(tasks) {
write!(writer, "{notices}")?;
}
if let Some(truncation) = truncation {
writeln!(writer, "{}", truncation_notice_message(truncation))?;
}
if let Some(warning) = long_running_warning(tasks, Local::now()) {
writeln!(writer, "{warning}")?;
}
if has_terminal_worktree
&& let Ok(stale_count) = crate::cmd::worktree::stale_worktree_count(None)
&& stale_count > 3
{
writeln!(writer, "[aid] Stale worktrees remain preserved until principal acceptance and custody GC")?;
}
Ok(())
}
fn terminal_missing_result_notices(tasks: &[Task]) -> Option<String> {
let mut notices = String::new();
for task in tasks {
if !matches!(task.status, TaskStatus::Done | TaskStatus::Failed) {
continue;
}
let result_path = crate::paths::task_dir(task.id.as_str()).join("result.md");
if result_path.exists() {
continue;
}
if task.status == TaskStatus::Done {
notices.push_str(&format!(
"Status: DONE {} (no result file - see --output / output.md)\n",
task.id
));
} else {
notices.push_str(&format!("Status: FAILED {}\n", task.id));
}
}
if notices.is_empty() { None } else { Some(notices) }
}
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 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(),
"outcome": task.outcome().as_str(),
"prompt": task.prompt,
"requested_model": task.requested_model, "observed_model": task.observed_model, "attribution_source": task.attribution_source.map(|value| value.as_str()),
"tokens": task.tokens,
"duration_ms": task.duration_ms,
"cost_usd": task.cost_usd,
"project_id": task.project_id,
"workgroup_id": task.workgroup_id,
"worktree_branch": task.worktree_branch,
"verify_status": task.verify_status.as_str(),
"delivery_assessment": task.delivery_assessment().map(|delivery| delivery.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)]
#[path = "board_tests.rs"]
mod tests;