Skip to main content

orch/cli/
task.rs

1use crate::backends::ExternalTask;
2use crate::backends::github::GitHubBackend;
3use crate::backends::{ExternalBackend, ExternalId, Status};
4use crate::cli;
5use crate::cli::init_task_manager;
6use crate::cli::task_ref;
7use crate::cli::tree::TreeNode;
8use crate::cli::tree::build_forest;
9use crate::cli::tree::render_forest;
10use crate::cli::tree::render_single_tree;
11use crate::cmd::SyncCommandErrorContext;
12use crate::config;
13use crate::engine;
14use crate::engine::cleanup;
15use crate::engine::dependencies;
16use crate::engine::dependencies::parse_depends_on_token;
17use crate::engine::priority::Priority;
18use crate::engine::priority::is_priority_label;
19use crate::engine::router::Router;
20use crate::engine::router::get_route_result;
21use crate::engine::runner::TaskRunner;
22use crate::engine::runner::agents::patterns;
23use crate::engine::tasks::store_task_to_external;
24use crate::engine::tasks::{CreateTaskRequest, Task, TaskFilter, TaskType, status_to_task_status};
25use crate::home;
26use crate::store;
27use crate::store::BlockerSnapshot;
28use crate::store::DepSource;
29use crate::store::NewDependency;
30use crate::store::TaskStatus;
31use crate::store::TaskStore;
32use crate::store::UpsertExternal;
33use crate::tmux::TmuxManager;
34use anyhow::Context;
35use chrono::{DateTime, Utc};
36use futures::future::join_all;
37use std::collections::HashMap;
38use std::fs;
39use std::process::Command;
40use std::sync::Arc;
41
42/// Format a timestamp as a human-readable age (e.g. "5m", "2h", "3d").
43fn format_age(updated_at: &str) -> String {
44    let Ok(dt) = DateTime::parse_from_rfc3339(updated_at) else {
45        return "-".to_string();
46    };
47    let secs = (Utc::now() - dt.with_timezone(&Utc)).num_seconds();
48    if secs < 0 {
49        return "now".to_string();
50    }
51    if secs < 60 {
52        format!("{secs}s")
53    } else if secs < 3600 {
54        format!("{}m", secs / 60)
55    } else if secs < 86400 {
56        format!("{}h", secs / 3600)
57    } else {
58        let days = secs / 86400;
59        if days >= 365 {
60            let years = days / 365;
61            format!("{years}y")
62        } else {
63            format!("{days}d")
64        }
65    }
66}
67
68/// Truncate an error message to a maximum length, appending an ellipsis when truncated.
69/// Format one `review_attempt_failed` activity row for `orch task inspect`.
70fn review_attempt_line(activity: &store::TaskActivity) -> String {
71    let field = |key: &str| {
72        activity
73            .details
74            .get(key)
75            .and_then(|v| v.as_str())
76            .unwrap_or("-")
77            .to_string()
78    };
79    let external = activity
80        .details
81        .get("external")
82        .and_then(serde_json::Value::as_bool)
83        .unwrap_or(false);
84    format!(
85        "{}  {}{}: {}",
86        activity.timestamp,
87        field("cause"),
88        if external { " (external)" } else { "" },
89        truncate_err(field("reason").trim(), 160)
90    )
91}
92
93fn truncate_err(err: &str, max: usize) -> String {
94    if err.len() > max {
95        let mut end = max;
96        while end > 0 && !err.is_char_boundary(end) {
97            end -= 1;
98        }
99        format!("{}...", &err[..end])
100    } else {
101        err.to_string()
102    }
103}
104
105fn format_run_duration(duration_secs: f64) -> String {
106    if duration_secs < 60.0 {
107        format!("{duration_secs:.0}s")
108    } else {
109        format!("{:.1}m", duration_secs / 60.0)
110    }
111}
112
113fn format_run_cost(cost: f64) -> String {
114    if cost <= 0.0 {
115        "-".to_string()
116    } else {
117        format!("${cost:.2}")
118    }
119}
120
121fn format_run_text(text: &str, verbose: bool, max: usize) -> String {
122    if verbose {
123        text.to_string()
124    } else if text.is_empty() {
125        String::new()
126    } else {
127        truncate_err(text, max)
128    }
129}
130
131/// Store-first status update for CLI: updates SQLite first, then mirrors to backend.
132async fn update_status_store_first(
133    store: Option<&Arc<TaskStore>>,
134    backend: &Arc<dyn ExternalBackend>,
135    repo: &str,
136    id: &ExternalId,
137    status: Status,
138) -> anyhow::Result<()> {
139    let db_status = status_to_task_status(status);
140    if let Some(s) = store
141        && let Ok(Some(store_id)) = s.resolve_task_id(repo, &id.0).await
142    {
143        s.update_status(store_id, db_status).await?;
144    }
145    if let Err(e) = backend.update_status(id, status).await {
146        tracing::warn!(
147            task_id = id.0,
148            ?status,
149            err = %e,
150            "failed to mirror status to backend — store is authoritative"
151        );
152    }
153    Ok(())
154}
155
156fn matches_project_filter(repo: &str, project: &str) -> bool {
157    let repo_name = repo.rsplit('/').next().unwrap_or(repo);
158    repo == project || repo_name == project || repo.ends_with(&format!("/{project}"))
159}
160
161fn waiting_title(title: &str, status: &str, blockers: &[String]) -> String {
162    if status == "waiting" && !blockers.is_empty() {
163        format!("{title} [waiting on {}]", blockers.join(", "))
164    } else {
165        title.to_string()
166    }
167}
168
169fn dep_label(task_repo: &str, dep_repo: &str, number: i64) -> String {
170    if task_repo == dep_repo {
171        format!("#{number}")
172    } else {
173        format!("{dep_repo}#{number}")
174    }
175}
176
177async fn refuse_external_claim(
178    store: &TaskStore,
179    repo: &str,
180    external_id: &str,
181) -> anyhow::Result<()> {
182    if let Some(id) = store.resolve_task_id(repo, external_id).await? {
183        dependencies::refuse_if_waiting(store, id).await?;
184    }
185    Ok(())
186}
187
188/// Refuse to move a done task back into the pipeline.
189///
190/// Done is terminal: a done task keeps the body it was created with, so
191/// reviving one re-runs a prompt that may be months stale (#3780)
192fn refuse_if_done(resolved: &store::CliTaskRef, action: &str) -> anyhow::Result<()> {
193    if resolved.status != TaskStatus::Done.as_str() {
194        return Ok(());
195    }
196    anyhow::bail!(
197        "{} is done — {action} does not revive done tasks; file a new issue or task for follow-up work",
198        resolved.display_spec()
199    )
200}
201
202/// True when the task still has an open dependency and must stay `waiting`.
203async fn release_or_keep_waiting(store: &TaskStore, task_id: i64) -> anyhow::Result<bool> {
204    Ok(store.status_after_unblock(task_id).await? == TaskStatus::Waiting)
205}
206
207/// List tasks with optional filters.
208pub async fn list(
209    status: Option<String>,
210    source: Option<String>,
211    global: bool,
212    project: Option<String>,
213) -> anyhow::Result<()> {
214    // When no project context is available (e.g. running from a worktree without
215    // .orch.yml), fall back to a store-only listing across all configured projects
216    // instead of printing a fatal error.
217    if global || project.is_some() {
218        return list_from_global_store(status, source, project.as_deref()).await;
219    }
220
221    let task_manager = match init_task_manager().await {
222        Ok(tm) => tm,
223        Err(e) => {
224            tracing::debug!("no project context available, falling back to global store: {e:#}");
225            return list_from_global_store(status, source, project.as_deref()).await;
226        }
227    };
228    let filter = TaskFilter { status, source };
229    let tasks = task_manager.list_tasks(filter).await?;
230
231    if tasks.is_empty() {
232        println!("No tasks found.");
233        return Ok(());
234    }
235
236    let store: Option<Arc<TaskStore>> = cli::init_store().await.ok().map(Arc::new);
237    let repo = config::get_current_repo().unwrap_or_default();
238
239    // Preload all store tasks for this repo into a map keyed by external_id.
240    // This avoids 4 SQL queries per external task (resolve_id + get, twice for agent and attempts).
241    let store_by_ext_id: HashMap<String, store::Task> = if let Some(ref s) = store {
242        s.list_all(&repo)
243            .await
244            .unwrap_or_default()
245            .into_iter()
246            .filter_map(|t| t.external_id.clone().map(|eid| (eid, t)))
247            .collect()
248    } else {
249        HashMap::new()
250    };
251    let waiting_on = if let Some(ref s) = store {
252        s.unmet_labels_by_task().await.unwrap_or_default()
253    } else {
254        HashMap::new()
255    };
256
257    println!(
258        "{:<15} {:<12} {:<20} {:<24} {:<10} {:<6} {:<5} TITLE",
259        "ID", "TYPE", "STATUS", "PROJECT", "AGENT", "AGE", "TRIES"
260    );
261    println!("{}", "-".repeat(125));
262
263    for task in tasks {
264        match task {
265            Task::External(ext) => {
266                let status = ext
267                    .labels
268                    .iter()
269                    .find(|l| l.starts_with("status:"))
270                    .map_or_else(|| "unknown".to_string(), |s| s.replace("status:", ""));
271                let store_task = store_by_ext_id.get(&ext.id.0);
272                let agent = store_task
273                    .and_then(|t| t.agent.as_deref())
274                    .unwrap_or("")
275                    .to_string();
276                let age = format_age(&ext.updated_at);
277                let project = store_task.map_or(repo.as_str(), |t| t.repo.as_str());
278                let tries = store_task
279                    .map(|t| {
280                        if t.attempts > 0 {
281                            t.attempts.to_string()
282                        } else {
283                            String::new()
284                        }
285                    })
286                    .unwrap_or_default();
287                let blockers = store_task
288                    .and_then(|row| waiting_on.get(&row.id))
289                    .map_or(&[][..], Vec::as_slice);
290                let title = waiting_title(&ext.title, &status, blockers);
291                println!(
292                    "{:<15} {:<12} {:<20} {:<24} {:<10} {:<6} {:<5} {}",
293                    ext.id.0, "external", status, project, agent, age, tries, title
294                );
295            }
296            Task::Internal(int) => {
297                let agent = int.agent.as_deref().unwrap_or("-");
298                let age = format_age(&int.updated_at);
299                let project = &int.repo;
300                let tries = if int.attempts > 0 {
301                    int.attempts.to_string()
302                } else {
303                    "-".to_string()
304                };
305                let blockers = waiting_on.get(&int.id).map_or(&[][..], Vec::as_slice);
306                let title = if int.status == TaskStatus::Blocked {
307                    if let Some(ref reason) = int.block_reason {
308                        format!("{} [blocked: {}]", int.title, reason)
309                    } else {
310                        int.title.clone()
311                    }
312                } else {
313                    waiting_title(&int.title, int.status.as_str(), blockers)
314                };
315                println!(
316                    "{:<15} {:<12} {:<20} {:<24} {:<10} {:<6} {:<5} {}",
317                    format!("internal:{}", int.id),
318                    "internal",
319                    int.status.as_str(),
320                    project,
321                    agent,
322                    age,
323                    tries,
324                    title
325                );
326            }
327        }
328    }
329
330    Ok(())
331}
332
333/// Fallback listing used when no project context is available (no `.orch.yml` in CWD or parents).
334///
335/// Queries the global SQLite store directly without scoping to a specific repo.
336/// This suppresses the fatal "no valid projects" error that previously appeared
337/// when agents ran `orch task list` from inside a worktree.
338async fn list_from_global_store(
339    status: Option<String>,
340    source: Option<String>,
341    project: Option<&str>,
342) -> anyhow::Result<()> {
343    let Ok(store) = cli::init_store().await else {
344        println!("No tasks found.");
345        return Ok(());
346    };
347
348    let tasks = if let Some(ref status_str) = status {
349        if status_str == "all" {
350            store.list_all_global().await?
351        } else {
352            let task_status = TaskStatus::from_str(status_str)
353                .ok_or_else(|| anyhow::anyhow!(
354                    "invalid status: '{status_str}'. allowed: new, routed, in_progress, needs_review, in_review, approved, blocked, waiting, done, all"
355                ))?;
356            store.list_all_by_status_global(task_status).await?
357        }
358    } else {
359        store.list_all_active_global().await?
360    };
361
362    // Apply optional source filter.
363    let tasks: Vec<_> = tasks
364        .into_iter()
365        .filter(|t| source.as_ref().is_none_or(|s| &t.source == s))
366        .filter(|t| project.is_none_or(|p| matches_project_filter(&t.repo, p)))
367        .collect();
368
369    if tasks.is_empty() {
370        println!("No tasks found.");
371        return Ok(());
372    }
373
374    let waiting_on = store.unmet_labels_by_task().await.unwrap_or_default();
375
376    println!(
377        "{:<15} {:<12} {:<20} {:<24} {:<10} {:<6} {:<5} TITLE",
378        "ID", "TYPE", "STATUS", "PROJECT", "AGENT", "AGE", "TRIES"
379    );
380    println!("{}", "-".repeat(125));
381
382    for task in tasks {
383        let type_str = if task.origin == "internal" {
384            "internal"
385        } else {
386            "external"
387        };
388        let project = task.repo.as_str();
389        let id_str = if task.origin == "internal" {
390            format!("internal:{}", task.id)
391        } else {
392            task.external_id
393                .clone()
394                .unwrap_or_else(|| task.id.to_string())
395        };
396        let agent = task.agent.as_deref().unwrap_or("-");
397        let age = format_age(&task.updated_at);
398        let tries = if task.attempts > 0 {
399            task.attempts.to_string()
400        } else {
401            "-".to_string()
402        };
403        let blockers = waiting_on.get(&task.id).map_or(&[][..], Vec::as_slice);
404        let title = if task.status == TaskStatus::Blocked {
405            if let Some(ref reason) = task.block_reason {
406                format!("{} [blocked: {}]", task.title, reason)
407            } else {
408                task.title.clone()
409            }
410        } else {
411            waiting_title(&task.title, task.status.as_str(), blockers)
412        };
413        println!(
414            "{:<15} {:<12} {:<20} {:<24} {:<10} {:<6} {:<5} {}",
415            id_str,
416            type_str,
417            task.status.as_str(),
418            project,
419            agent,
420            age,
421            tries,
422            title
423        );
424    }
425
426    Ok(())
427}
428
429/// Create a new task.
430pub async fn add(
431    title: String,
432    body: Option<String>,
433    labels: Vec<String>,
434    source: String,
435    depends_on: Vec<String>,
436) -> anyhow::Result<()> {
437    let task_manager = init_task_manager().await?;
438
439    // Labels or dependencies create a GitHub issue. Dependencies need the native link
440    let task_type = if !labels.is_empty() || !depends_on.is_empty() {
441        TaskType::External
442    } else {
443        TaskType::Internal
444    };
445
446    let req = CreateTaskRequest {
447        title,
448        body: body.unwrap_or_default(),
449        task_type,
450        labels,
451        source,
452        source_id: String::new(),
453    };
454    let task = task_manager.create_task(req).await?;
455
456    match task {
457        Task::Internal(t) => {
458            println!("Created internal task #{}: {}", t.id, t.title);
459        }
460        Task::External(t) => {
461            if !depends_on.is_empty() {
462                link_created_dependencies(&t, &depends_on).await?;
463            }
464            println!("Created external task #{}: {}", t.id.0, t.title);
465        }
466    }
467
468    Ok(())
469}
470
471async fn link_created_dependencies(
472    task: &ExternalTask,
473    depends_on: &[String],
474) -> anyhow::Result<()> {
475    let repo =
476        config::get_current_repo().context("'repo' not set — ensure .orch.yml has gh.repo")?;
477    let issue_number: i64 = task
478        .id
479        .0
480        .parse()
481        .with_context(|| format!("created issue id '{}' is not a number", task.id.0))?;
482    let backend = GitHubBackend::new(repo.clone())?;
483    let store = cli::init_store().await?;
484    let store_id = store
485        .upsert_external(&UpsertExternal {
486            repo: &repo,
487            ext_id: &task.id.0,
488            title: &task.title,
489            body: &task.body,
490            author: &task.author,
491            url: &task.url,
492            labels: &task.labels,
493            origin: "github",
494        })
495        .await?;
496    let mut rows = Vec::new();
497    for token in depends_on {
498        let Some(dep) = parse_depends_on_token(token, &repo) else {
499            anyhow::bail!("invalid --depends-on value '{token}'");
500        };
501        let source = match backend
502            .add_dependency(issue_number, &dep.repo, dep.number)
503            .await
504        {
505            Ok(()) => DepSource::Github,
506            Err(err) => {
507                eprintln!(
508                    "warning: could not link {}#{} on GitHub ({err}). Stored the dependency locally.",
509                    dep.repo, dep.number
510                );
511                DepSource::Cli
512            }
513        };
514        rows.push(NewDependency {
515            repo: dep.repo,
516            number: dep.number,
517            source,
518        });
519    }
520    store.replace_dependencies(store_id, &rows).await?;
521    Ok(())
522}
523
524/// Get task details by ID.
525pub async fn get(id: &str) -> anyhow::Result<()> {
526    let store = cli::init_store().await?;
527    let resolved = task_ref::resolve(&store, id).await?;
528    let store_arc: Option<Arc<TaskStore>> = Some(Arc::new(store.clone()));
529    let repo = resolved.repo.clone();
530
531    if resolved.is_internal() {
532        let store_id = resolved
533            .store_id
534            .context("internal task missing store id")?;
535        let int = store.get(store_id).await?;
536        println!("ID: {} (internal)", int.id);
537        println!("Title: {}", int.title);
538        println!("Status: {}", int.status.as_str());
539        println!("Source: {}", int.source);
540        if let Some(model) = &int.model {
541            println!("Model: {model}");
542        }
543        if !int.complexity.is_empty() {
544            println!("Complexity: {}", int.complexity);
545        }
546        if let Some(reason) = &int.block_reason {
547            println!("Block reason: {reason}");
548        }
549        if int.status == TaskStatus::Waiting {
550            let blockers = store.unmet_blocker_labels(int.id).await.unwrap_or_default();
551            if !blockers.is_empty() {
552                println!("Waiting on: {}", blockers.join(", "));
553            }
554        }
555        if int.route_attempts > 0 {
556            println!("Route attempts: {}", int.route_attempts);
557        }
558        if int.attempts > 0 {
559            println!("Attempts: {}", int.attempts);
560        }
561        if int.review_cycles > 0 {
562            println!("Review cycles: {}", int.review_cycles);
563        }
564        if let Some(pr) = int.pr_number {
565            println!("PR: #{pr}");
566        }
567        if !int.branch.is_empty() {
568            println!("Branch: {}", int.branch);
569        }
570        if !int.last_error.is_empty() {
571            let err = truncate_err(&int.last_error, 200);
572            println!("Last error: {err}");
573        }
574        if let Some(parent) = int.parent_id {
575            println!("Parent: internal:{parent}");
576        }
577        if int.input_tokens > 0 || int.output_tokens > 0 {
578            println!(
579                "Tokens: {} in / {} out — ${:.6}",
580                int.input_tokens, int.output_tokens, int.total_cost_usd
581            );
582        }
583        println!("Created: {}", int.created_at);
584        println!("Updated: {}", int.updated_at);
585        println!("\n{}", int.body);
586        return Ok(());
587    }
588
589    let ext_id = ExternalId(resolved.external_id.clone());
590    let backend = GitHubBackend::new(repo.clone())?;
591    let ext = match backend.get_task(&ext_id).await {
592        Ok(ext) => ext,
593        Err(err) => match resolved.store_id {
594            Some(store_id) => store_task_to_external(&store.get(store_id).await?),
595            None => return Err(err),
596        },
597    };
598
599    println!("ID: {} (external)", ext.id.0);
600    println!("Title: {}", ext.title);
601    println!("State: {}", ext.state);
602    println!("Labels: {}", ext.labels.join(", "));
603    println!("Author: {}", ext.author);
604    println!("URL: {}", ext.url);
605    println!("Created: {}", ext.created_at);
606    println!("Updated: {}", ext.updated_at);
607
608    if let Some(ref s) = store_arc
609        && let Ok(Some(t)) = s.get_by_external_id(&repo, &ext.id.0).await
610    {
611        if let Some(ref agent) = t.agent {
612            println!("Agent: {agent}");
613        }
614        if let Some(ref model) = t.model {
615            println!("Model: {model}");
616        }
617        if !t.complexity.is_empty() {
618            println!("Complexity: {}", t.complexity);
619        }
620        if t.route_attempts > 0 {
621            println!("Route attempts: {}", t.route_attempts);
622        }
623        if t.attempts > 0 {
624            println!("Attempts: {}", t.attempts);
625        }
626        if t.review_cycles > 0 {
627            println!("Review cycles: {}", t.review_cycles);
628        }
629        if let Some(pr) = t.pr_number
630            && pr > 0
631        {
632            println!("PR: #{pr}");
633        }
634        if !t.branch.is_empty() {
635            println!("Branch: {}", t.branch);
636        }
637        if !t.last_error.is_empty() {
638            println!("Last error: {}", truncate_err(&t.last_error, 200));
639        }
640        if t.status == TaskStatus::Waiting {
641            let blockers = s.unmet_blocker_labels(t.id).await.unwrap_or_default();
642            if !blockers.is_empty() {
643                println!("Waiting on: {}", blockers.join(", "));
644            }
645        }
646    }
647    let usage = store::get_token_usage(&store_arc, &repo, &ext.id.0).await;
648    let cost = store::get_cost_estimate(&store_arc, &repo, &ext.id.0).await;
649    if usage.total_tokens() > 0 || cost.total_cost_usd > 0.0 {
650        println!(
651            "Tokens: {} in / {} out — ${:.6}",
652            usage.input_tokens, usage.output_tokens, cost.total_cost_usd
653        );
654    }
655
656    println!("\n{}", ext.body);
657
658    Ok(())
659}
660
661/// Show task status summary.
662pub async fn status(json: bool) -> anyhow::Result<()> {
663    let task_manager = init_task_manager().await?;
664
665    let statuses = [
666        Status::New,
667        Status::Routed,
668        Status::InProgress,
669        Status::Done,
670        Status::Blocked,
671        Status::InReview,
672        Status::Approved,
673        Status::NeedsReview,
674        Status::Waiting,
675    ];
676
677    // Fetch all tasks from both backends
678    let all_external = task_manager.list_all_external_tasks().await?;
679    let all_internal = task_manager.list_all_internal().await.unwrap_or_default();
680
681    // Count external tasks per status via labels
682    let ext_counts: Vec<usize> = statuses
683        .iter()
684        .map(|s| {
685            let label = s.as_label().to_string();
686            all_external
687                .iter()
688                .filter(|t| t.labels.contains(&label))
689                .count()
690        })
691        .collect();
692
693    // Count internal tasks per status
694    let int_counts: Vec<usize> = statuses
695        .iter()
696        .map(|s| {
697            let ts = match s {
698                Status::New => TaskStatus::New,
699                Status::Routed => TaskStatus::Routed,
700                Status::InProgress => TaskStatus::InProgress,
701                Status::Done => TaskStatus::Done,
702                Status::Blocked => TaskStatus::Blocked,
703                Status::InReview => TaskStatus::InReview,
704                Status::Approved => TaskStatus::Approved,
705                Status::NeedsReview => TaskStatus::NeedsReview,
706                Status::Waiting => TaskStatus::Waiting,
707            };
708            all_internal.iter().filter(|t| t.status == ts).count()
709        })
710        .collect();
711
712    // Calculate total cost using a single aggregate query instead of per-task lookups.
713    let store: Option<Arc<TaskStore>> = cli::init_store().await.ok().map(Arc::new);
714    let repo = config::get_current_repo().unwrap_or_default();
715    let mut total_input_tokens: u64 = 0;
716    let mut total_output_tokens: u64 = 0;
717    let mut total_cost: f64 = 0.0;
718
719    if let Some(ref s) = store {
720        if let Ok((input, output, cost)) = s.cost_summary(&repo).await {
721            total_input_tokens = u64::try_from(input).unwrap_or(0);
722            total_output_tokens = u64::try_from(output).unwrap_or(0);
723            total_cost = cost;
724        }
725    } else {
726        // No store available: sum up internal task costs directly from already-loaded data.
727        for task in &all_internal {
728            total_input_tokens += u64::try_from(task.input_tokens).unwrap_or(0);
729            total_output_tokens += u64::try_from(task.output_tokens).unwrap_or(0);
730            total_cost += task.total_cost_usd;
731        }
732    }
733
734    // The engine process owns the live rate-limit state, so it leaves the reset epoch in
735    // KV for this one. Stale entries read as expired and are dropped.
736    let rate_limited_until = match store.as_ref() {
737        Some(s) => s
738            .kv_get(engine::RATE_LIMITED_UNTIL_KEY)
739            .await
740            .ok()
741            .flatten()
742            .and_then(|v| v.parse::<i64>().ok())
743            .filter(|reset_at| *reset_at > chrono::Utc::now().timestamp()),
744        None => None,
745    };
746
747    if json {
748        let mut map = serde_json::Map::new();
749        for (i, s) in statuses.iter().enumerate() {
750            let key = s.as_label().replace("status:", "");
751            let mut entry = serde_json::Map::new();
752            entry.insert(
753                "external".to_string(),
754                serde_json::Value::Number(ext_counts[i].into()),
755            );
756            entry.insert(
757                "internal".to_string(),
758                serde_json::Value::Number(int_counts[i].into()),
759            );
760            map.insert(key, serde_json::Value::Object(entry));
761        }
762
763        map.insert(
764            "total_cost_usd".to_string(),
765            serde_json::Value::Number(
766                serde_json::Number::from_f64(total_cost).unwrap_or(serde_json::Number::from(0)),
767            ),
768        );
769        map.insert(
770            "total_input_tokens".to_string(),
771            serde_json::Value::Number(total_input_tokens.into()),
772        );
773        map.insert(
774            "total_output_tokens".to_string(),
775            serde_json::Value::Number(total_output_tokens.into()),
776        );
777        map.insert(
778            "total_tokens".to_string(),
779            serde_json::Value::Number((total_input_tokens + total_output_tokens).into()),
780        );
781
782        if let Some(reset_at) = rate_limited_until {
783            map.insert(
784                "github_rate_limited_until".to_string(),
785                serde_json::Value::Number(reset_at.into()),
786            );
787        }
788
789        println!("{}", serde_json::to_string_pretty(&map)?);
790    } else {
791        if let Some(reset_at) = rate_limited_until {
792            println!(
793                "GitHub: rate limited until {}",
794                engine::format_rate_limited_until(reset_at)
795            );
796            println!();
797        }
798
799        println!("{:<20} {:>8}  {:>8}", "STATUS", "EXTERNAL", "INTERNAL");
800        println!("{}", "-".repeat(42));
801        let total_ext: usize = ext_counts.iter().sum();
802        let total_int: usize = int_counts.iter().sum();
803        for (i, s) in statuses.iter().enumerate() {
804            let ext = ext_counts[i];
805            let int = int_counts[i];
806            if ext > 0 || int > 0 {
807                println!(
808                    "{:<20} {:>8}  {:>8}",
809                    s.as_label().replace("status:", ""),
810                    ext,
811                    int
812                );
813            }
814        }
815        println!("{}", "-".repeat(42));
816        println!("{:<20} {:>8}  {:>8}", "total", total_ext, total_int);
817
818        // Show cost summary if any
819        if total_cost > 0.0 {
820            println!();
821            println!("Cost summary:");
822            println!(
823                "  Total tokens: {}",
824                total_input_tokens + total_output_tokens
825            );
826            println!("  Total cost:   ${total_cost:.6}");
827        }
828    }
829
830    Ok(())
831}
832
833/// Route a task to an agent.
834pub async fn route(id: &str) -> anyhow::Result<()> {
835    let store: Arc<TaskStore> = Arc::new(cli::init_store().await?);
836    let resolved = task_ref::resolve(&store, id).await?;
837    let repo = resolved.repo.clone();
838    let ext_id = ExternalId(resolved.external_id.clone());
839
840    let (task, backend) = if resolved.is_internal() {
841        let store_id = resolved
842            .store_id
843            .context("internal task missing store id")?;
844        dependencies::refuse_if_waiting(&store, store_id).await?;
845        (
846            store_task_to_external(&store.get(store_id).await?),
847            None::<Arc<dyn ExternalBackend>>,
848        )
849    } else {
850        let backend: Arc<dyn ExternalBackend> = Arc::new(GitHubBackend::new(repo.clone())?);
851        refuse_external_claim(&store, &repo, &ext_id.0).await?;
852        (backend.get_task(&ext_id).await?, Some(backend))
853    };
854
855    let mut router = Router::from_config();
856    let result = router.route(&task, &store, &repo).await?;
857
858    router
859        .store_route_result(&ext_id.0, &result, &store, &repo)
860        .await?;
861
862    if let Some(backend) = backend {
863        let labels = vec![
864            format!("agent:{}", result.agent),
865            format!("complexity:{}", result.complexity),
866        ];
867        backend.set_labels(&ext_id, &labels).await?;
868        update_status_store_first(Some(&store), &backend, &repo, &ext_id, Status::Routed).await?;
869    } else if let Some(store_id) = resolved.store_id {
870        store.update_status(store_id, TaskStatus::Routed).await?;
871    }
872
873    println!(
874        "Routed task {} → {} (complexity: {}, reason: {})",
875        resolved.display_spec(),
876        result.agent,
877        result.complexity,
878        result.reason
879    );
880
881    Ok(())
882}
883
884/// Run a task (manual execution).
885pub async fn run(id: Option<String>) -> anyhow::Result<()> {
886    let store: Arc<TaskStore> = Arc::new(cli::init_store().await?);
887
888    let (repo, task_id, backend) = if let Some(id) = id {
889        let resolved = task_ref::resolve(&store, &id).await?;
890        let backend: Option<Arc<dyn ExternalBackend>> = if resolved.is_internal() {
891            None
892        } else {
893            Some(Arc::new(GitHubBackend::new(resolved.repo.clone())?))
894        };
895        (resolved.repo, resolved.external_id, backend)
896    } else {
897        let repo =
898            config::get_current_repo().context("'repo' not set — ensure .orch.yml has gh.repo")?;
899        let backend: Arc<dyn ExternalBackend> = Arc::new(GitHubBackend::new(repo.clone())?);
900        let routed = backend.list_by_status(Status::Routed).await?;
901        let task_id = if let Some(task) = routed.first() {
902            task.id.0.clone()
903        } else {
904            let new = backend.list_by_status(Status::New).await?;
905            if let Some(task) = new.first() {
906                task.id.0.clone()
907            } else {
908                anyhow::bail!("no runnable tasks found");
909            }
910        };
911        (repo, task_id, Some(backend))
912    };
913
914    let route_result = get_route_result(&store, &repo, &task_id).await.ok();
915    let agent = route_result.as_ref().map(|r| r.agent.clone());
916    let model = route_result.as_ref().and_then(|r| r.model.clone());
917
918    println!(
919        "Running task #{} (agent: {}, model: {})",
920        task_id,
921        agent.as_deref().unwrap_or("default"),
922        model.as_deref().unwrap_or("default")
923    );
924
925    refuse_external_claim(&store, &repo, &task_id).await?;
926
927    let ext_id = ExternalId(task_id.clone());
928    let started_at = Utc::now();
929    if let Some(ref backend) = backend {
930        update_status_store_first(Some(&store), backend, &repo, &ext_id, Status::InProgress)
931            .await?;
932    } else if let Some(store_id) = store.resolve_task_id(&repo, &task_id).await? {
933        store
934            .update_status(store_id, TaskStatus::InProgress)
935            .await?;
936    }
937
938    let runner = TaskRunner::new(repo).with_store(store);
939    runner
940        .run(
941            &task_id,
942            agent.as_deref(),
943            model.as_deref(),
944            backend.as_deref(),
945            &started_at,
946        )
947        .await?;
948
949    println!("Task #{task_id} completed");
950    Ok(())
951}
952
953/// Retry a task (reset to new).
954///
955/// When `agent` is provided, the task is force-routed to that agent/model and
956/// set directly to `Routed` status, bypassing the router entirely.
957pub async fn retry(id: &str, agent: Option<String>, model: Option<String>) -> anyhow::Result<()> {
958    let s = Arc::new(cli::init_store().await?);
959    let resolved = task_ref::resolve(&s, id).await?;
960    refuse_if_done(&resolved, "retry")?;
961    let store = Some(Arc::clone(&s));
962    let repo = resolved.repo.clone();
963    let shown = resolved.display_spec();
964
965    if resolved.is_internal() {
966        let store_id = resolved
967            .store_id
968            .context("internal task missing store id")?;
969        dependencies::refuse_if_waiting(&s, store_id).await?;
970        store::store_reset_counters(&store, &repo, &resolved.external_id).await;
971
972        if let Some(ref forced_agent) = agent {
973            let fields = retry_force_fields(forced_agent, model.as_deref());
974            s.set_fields(store_id, &fields).await?;
975            s.update_status(store_id, TaskStatus::Routed).await?;
976            let model_hint = model.as_deref().unwrap_or("(router default)");
977            println!(
978                "Task {shown} force-routed to {forced_agent}:{model_hint} (set to routed, dispatches on next tick)"
979            );
980        } else {
981            s.update_status(store_id, TaskStatus::New).await?;
982            println!("Task {shown} reset to new (attempts reset, will be re-routed)");
983        }
984        return Ok(());
985    }
986
987    let backend: Arc<dyn ExternalBackend> = Arc::new(GitHubBackend::new(repo.clone())?);
988    let ext_id = ExternalId(resolved.external_id.clone());
989    refuse_external_claim(&s, &repo, &ext_id.0).await?;
990
991    let task = backend.get_task(&ext_id).await?;
992    for label in &task.labels {
993        if label.starts_with("agent:") {
994            backend.remove_label(&ext_id, label).await?;
995        }
996    }
997
998    store::store_reset_counters(&store, &repo, &ext_id.0).await;
999
1000    if let Some(ref forced_agent) = agent {
1001        backend
1002            .set_labels(&ext_id, &[format!("agent:{forced_agent}")])
1003            .await?;
1004
1005        if let Some(store_id) = resolved.store_id {
1006            let fields = retry_force_fields(forced_agent, model.as_deref());
1007            s.set_fields(store_id, &fields).await?;
1008        }
1009
1010        update_status_store_first(store.as_ref(), &backend, &repo, &ext_id, Status::Routed).await?;
1011        let model_hint = model.as_deref().unwrap_or("(router default)");
1012        println!(
1013            "Task {shown} force-routed to {forced_agent}:{model_hint} (set to routed, dispatches on next tick)"
1014        );
1015    } else {
1016        update_status_store_first(store.as_ref(), &backend, &repo, &ext_id, Status::New).await?;
1017        println!("Task {shown} reset to new (attempts reset, will be re-routed)");
1018    }
1019
1020    Ok(())
1021}
1022
1023fn retry_force_fields<'a>(
1024    forced_agent: &'a str,
1025    model: Option<&'a str>,
1026) -> Vec<(&'a str, serde_json::Value)> {
1027    let mut fields = vec![
1028        ("agent", serde_json::json!(forced_agent)),
1029        (
1030            "route_reason",
1031            serde_json::json!(format!("forced via retry --agent {forced_agent}")),
1032        ),
1033    ];
1034    if let Some(model) = model {
1035        fields.push(("model", serde_json::json!(model)));
1036    }
1037    fields
1038}
1039
1040/// Unblock a task or all blocked tasks.
1041pub async fn unblock(id: &str) -> anyhow::Result<()> {
1042    if id == "all" {
1043        let repo =
1044            config::get_current_repo().context("'repo' not set — ensure .orch.yml has gh.repo")?;
1045        let backend: Arc<dyn ExternalBackend> = Arc::new(GitHubBackend::new(repo.clone())?);
1046        let store = cli::init_store().await.ok().map(Arc::new);
1047        let blocked = backend.list_by_status(Status::Blocked).await?;
1048        let needs_review = backend.list_by_status(Status::NeedsReview).await?;
1049
1050        let tasks_to_unblock: Vec<_> = blocked.iter().chain(needs_review.iter()).collect();
1051        let external_count = tasks_to_unblock.len();
1052        let ext_futs = tasks_to_unblock.into_iter().map(|task| {
1053            let store = store.clone();
1054            let backend = Arc::clone(&backend);
1055            let repo = repo.clone();
1056            let id = task.id.clone();
1057            async move {
1058                store::store_reset_counters(&store, &repo, &id.0).await;
1059                if let Some(ref s) = store
1060                    && let Ok(Some(store_id)) = s.resolve_task_id(&repo, &id.0).await
1061                {
1062                    match s.status_after_unblock(store_id).await {
1063                        Ok(TaskStatus::Waiting) => {
1064                            let _ = backend.update_status(&id, Status::Waiting).await;
1065                            return Ok(());
1066                        }
1067                        Ok(_) => {}
1068                        Err(err) => return Err(err),
1069                    }
1070                }
1071                update_status_store_first(store.as_ref(), &backend, &repo, &id, Status::New).await
1072            }
1073        });
1074        for result in join_all(ext_futs).await {
1075            result?;
1076        }
1077
1078        let mut internal_count = 0;
1079        if let Some(ref s) = store {
1080            let internal_blocked = s
1081                .list_internal_by_status(&repo, TaskStatus::Blocked)
1082                .await
1083                .unwrap_or_default();
1084            let internal_needs_review = s
1085                .list_internal_by_status(&repo, TaskStatus::NeedsReview)
1086                .await
1087                .unwrap_or_default();
1088            let tasks_internal: Vec<_> = internal_blocked
1089                .iter()
1090                .chain(internal_needs_review.iter())
1091                .collect();
1092            internal_count = tasks_internal.len();
1093            let int_futs = tasks_internal.into_iter().map(|task| {
1094                let store = store.clone();
1095                let s = Arc::clone(s);
1096                let repo = repo.clone();
1097                let ext_id = task
1098                    .external_id
1099                    .clone()
1100                    .unwrap_or_else(|| format!("internal:{}", task.id));
1101                let task_id = task.id;
1102                async move {
1103                    store::store_reset_counters(&store, &repo, &ext_id).await;
1104                    match s.status_after_unblock(task_id).await? {
1105                        TaskStatus::Waiting => Ok(()),
1106                        _ => s.update_status(task_id, TaskStatus::New).await,
1107                    }
1108                }
1109            });
1110            for result in join_all(int_futs).await {
1111                result?;
1112            }
1113        }
1114
1115        let total = external_count + internal_count;
1116        println!(
1117            "Unblocked {total} tasks ({external_count} external, {internal_count} internal) (attempts reset)"
1118        );
1119        return Ok(());
1120    }
1121
1122    let s = Arc::new(cli::init_store().await?);
1123    let resolved = task_ref::resolve(&s, id).await?;
1124    refuse_if_done(&resolved, "unblock")?;
1125    let store = Some(Arc::clone(&s));
1126    let repo = resolved.repo.clone();
1127    let shown = resolved.display_spec();
1128
1129    if resolved.is_internal() {
1130        let store_id = resolved
1131            .store_id
1132            .context("internal task missing store id")?;
1133        store::store_reset_counters(&store, &repo, &resolved.external_id).await;
1134        if release_or_keep_waiting(&s, store_id).await? {
1135            println!("Task {shown} stays waiting on an open dependency");
1136        } else {
1137            s.update_status(store_id, TaskStatus::New).await?;
1138            println!("Unblocked {shown} (attempts reset, will be re-routed)");
1139        }
1140        return Ok(());
1141    }
1142
1143    let backend: Arc<dyn ExternalBackend> = Arc::new(GitHubBackend::new(repo.clone())?);
1144    let ext_id = ExternalId(resolved.external_id.clone());
1145    store::store_reset_counters(&store, &repo, &ext_id.0).await;
1146    if let Some(store_id) = resolved.store_id
1147        && release_or_keep_waiting(&s, store_id).await?
1148    {
1149        let _ = backend.update_status(&ext_id, Status::Waiting).await;
1150        println!("Task {shown} stays waiting on an open dependency");
1151        return Ok(());
1152    }
1153    update_status_store_first(store.as_ref(), &backend, &repo, &ext_id, Status::New).await?;
1154    println!("Unblocked {shown} (attempts reset)");
1155
1156    Ok(())
1157}
1158
1159/// Kill tmux sessions and remove the worktree for a closed task.
1160///
1161/// Sessions are killed first (before worktree removal) so that the tmux-active
1162/// guard in `cleanup_task_worktree_with_opts` does not skip the removal.
1163/// Errors are logged but do not fail the close operation.
1164async fn cleanup_sessions_and_worktree(task_id: &str, repo: &str, store: Option<&Arc<TaskStore>>) {
1165    let tmux = TmuxManager::new();
1166
1167    // Session names follow `orch-{project}-{task_id}` and optionally
1168    // `orch-{project}-{task_id}-{suffix}` (e.g. `-review`).
1169    // Build a pattern that matches the task_id as a complete dash-delimited segment.
1170    let safe_id = task_id.replace(':', "-");
1171    let pattern = format!("-{safe_id}");
1172
1173    match tmux.list_sessions().await {
1174        Ok(sessions) => {
1175            for sess in sessions {
1176                let name = &sess.name;
1177                if let Some(pos) = name.find(&pattern) {
1178                    let after = &name[pos + pattern.len()..];
1179                    if after.is_empty() || after.starts_with('-') {
1180                        tracing::info!(
1181                            session = name,
1182                            task_id,
1183                            "killing tmux session for closed task"
1184                        );
1185                        match tmux.kill_session(name).await {
1186                            Err(e) => {
1187                                tracing::warn!(session = name, err = %e, "failed to kill session on close");
1188                            }
1189                            _ => {
1190                                println!("  killed session {name}");
1191                            }
1192                        }
1193                    }
1194                }
1195            }
1196        }
1197        Err(e) => {
1198            tracing::warn!(task_id, err = %e, "could not list tmux sessions during close cleanup");
1199        }
1200    }
1201
1202    // Remove the worktree (sessions are already gone so the tmux guard won't block).
1203    if let Some(s) = store {
1204        match cleanup::cleanup_task_worktree(task_id, repo, s).await {
1205            Ok(()) => {}
1206            Err(e) => {
1207                tracing::warn!(task_id, err = %e, "worktree cleanup failed after task close");
1208            }
1209        }
1210    }
1211}
1212
1213/// Mark a task as done (without running an agent).
1214pub async fn close(id: &str, note: Option<&str>) -> anyhow::Result<()> {
1215    let s = cli::init_store().await?;
1216    let resolved = task_ref::resolve(&s, id).await?;
1217    let store = Some(Arc::new(s.clone()));
1218    let repo = resolved.repo.clone();
1219    let shown = resolved.display_spec();
1220
1221    if resolved.is_internal() {
1222        let store_id = resolved
1223            .store_id
1224            .context("internal task missing store id")?;
1225        s.update_status(store_id, TaskStatus::Done).await?;
1226        println!("Closed {shown} (marked done)");
1227        cleanup_sessions_and_worktree(&resolved.external_id, &repo, store.as_ref()).await;
1228        return Ok(());
1229    }
1230
1231    let backend: Arc<dyn ExternalBackend> = Arc::new(GitHubBackend::new(repo.clone())?);
1232    let ext_id = ExternalId(resolved.external_id.clone());
1233
1234    if let Some(text) = note {
1235        backend.post_comment(&ext_id, text).await?;
1236    }
1237
1238    update_status_store_first(store.as_ref(), &backend, &repo, &ext_id, Status::Done).await?;
1239    println!("Closed {shown} (marked done)");
1240    cleanup_sessions_and_worktree(&resolved.external_id, &repo, store.as_ref()).await;
1241    Ok(())
1242}
1243
1244/// Set a task's priority label, on GitHub for external tasks and in the store for all.
1245pub async fn priority(id: &str, level: &str) -> anyhow::Result<()> {
1246    let wanted: Priority = level.parse()?;
1247    let s = cli::init_store().await?;
1248    let resolved = task_ref::resolve(&s, id).await?;
1249    let store_id = resolved.store_id.context("task missing store id")?;
1250    let task = s.get(store_id).await?;
1251
1252    if !resolved.is_internal() {
1253        let backend: Arc<dyn ExternalBackend> =
1254            Arc::new(GitHubBackend::new(resolved.repo.clone())?);
1255        let ext_id = ExternalId(resolved.external_id.clone());
1256        for label in task.labels.iter().filter(|l| is_priority_label(l)) {
1257            backend.remove_label(&ext_id, label).await?;
1258        }
1259        let added = wanted.apply_to(&[]);
1260        if !added.is_empty() {
1261            backend.set_labels(&ext_id, &added).await?;
1262        }
1263    }
1264
1265    let labels = wanted.apply_to(&task.labels);
1266    s.set_fields(store_id, &[("labels", serde_json::json!(labels))])
1267        .await?;
1268    println!(
1269        "{} priority set to {}",
1270        resolved.display_spec(),
1271        wanted.as_str()
1272    );
1273    Ok(())
1274}
1275
1276/// Attach to a running agent's tmux session.
1277pub async fn attach(id: &str) -> anyhow::Result<()> {
1278    let store = cli::init_store().await?;
1279    let task = task_ref::resolve(&store, id).await?;
1280    let tmux = TmuxManager::new();
1281    let session = tmux.session_name(&task.repo, &task.external_id);
1282    let status = Command::new("tmux")
1283        .args(["attach-session", "-t", &session])
1284        .status_with_context()?;
1285
1286    if !status.success() {
1287        anyhow::bail!("no active session for task {}", task.display_spec());
1288    }
1289    Ok(())
1290}
1291
1292/// List active agent tmux sessions.
1293pub async fn live() -> anyhow::Result<()> {
1294    let tmux = TmuxManager::new();
1295    let sessions = tmux.list_sessions().await?;
1296
1297    if sessions.is_empty() {
1298        println!("No active agent sessions.");
1299        return Ok(());
1300    }
1301
1302    let store: Option<Arc<TaskStore>> = cli::init_store().await.ok().map(Arc::new);
1303    let repo = config::get_current_repo().unwrap_or_default();
1304
1305    println!("{:<30} {:<15} {:<10} ACTIVE", "SESSION", "TASK", "AGENT");
1306    println!("{}", "-".repeat(70));
1307
1308    let active_map = tmux.batch_session_active().await;
1309    let agent_futs = sessions.iter().map(|session| {
1310        let store = store.clone();
1311        let repo = repo.clone();
1312        let task_id = session.task_id.clone();
1313        async move {
1314            store::opt_store_get_task(&store, &repo, &task_id)
1315                .await
1316                .and_then(|t| t.agent)
1317                .unwrap_or_default()
1318        }
1319    });
1320    let agents = join_all(agent_futs).await;
1321    for (session, agent) in sessions.iter().zip(agents.iter()) {
1322        let active = active_map.get(&session.name).copied().unwrap_or(false);
1323        println!(
1324            "{:<30} {:<15} {:<10} {}",
1325            session.name,
1326            session.task_id,
1327            agent,
1328            if active { "yes" } else { "no" },
1329        );
1330    }
1331
1332    Ok(())
1333}
1334
1335/// Kill a running agent tmux session.
1336pub async fn kill(id: &str) -> anyhow::Result<()> {
1337    let store = cli::init_store().await?;
1338    let task = task_ref::resolve(&store, id).await?;
1339    let tmux = TmuxManager::new();
1340    let session = tmux.session_name(&task.repo, &task.external_id);
1341    if let Some(store_id) = task.store_id {
1342        store
1343            .finalize_incomplete_runs(store_id, "killed", "killed by orch task kill")
1344            .await?;
1345    }
1346    tmux.kill_session(&session).await?;
1347    println!("Killed session for task {}", task.display_spec());
1348    Ok(())
1349}
1350
1351/// Publish an internal task to GitHub.
1352pub async fn publish(id: i64, labels: Vec<String>) -> anyhow::Result<()> {
1353    let task_manager = init_task_manager().await?;
1354    let ext_id = task_manager.publish_task(id, &labels).await?;
1355    println!("Published task #{} as GitHub issue #{}", id, ext_id.0);
1356    Ok(())
1357}
1358
1359/// Show token cost breakdown for a task.
1360pub async fn cost(id: &str) -> anyhow::Result<()> {
1361    // Delegate to cli::cost::show_task for consistent formatting
1362    super::cost::show_task(id).await
1363}
1364
1365/// Show task tree view (parent-child relationships).
1366pub async fn tree(id: Option<i64>, deps: bool) -> anyhow::Result<()> {
1367    if deps {
1368        return dependency_tree(id).await;
1369    }
1370
1371    let task_manager = init_task_manager().await?;
1372
1373    // If a specific task ID is provided, show just that tree
1374    if let Some(task_id) = id {
1375        // Find the requested task in the forest
1376        fn find_node_in_forest<'a>(forest: &'a [TreeNode], id: &str) -> Option<&'a TreeNode> {
1377            for root in forest {
1378                if root.id == id {
1379                    return Some(root);
1380                }
1381                if let Some(found) = find_node_in_forest(&root.children, id) {
1382                    return Some(found);
1383                }
1384            }
1385            None
1386        }
1387
1388        let task = task_manager.get_task(task_id).await?;
1389
1390        // For a single task, we need to find its children
1391        // First get all tasks to build the full hierarchy
1392        let all_tasks = task_manager.list_tasks(TaskFilter::default()).await?;
1393        let forest = build_forest(&all_tasks);
1394
1395        let target_id = task_id.to_string();
1396        if let Some(node) = find_node_in_forest(&forest, &target_id) {
1397            let output = render_single_tree(node, true);
1398            print!("{output}");
1399        } else {
1400            // Task exists but not in any tree - show as standalone
1401            match &task {
1402                Task::External(ext) => {
1403                    let node = TreeNode::from_external(ext);
1404                    let output = render_single_tree(&node, true);
1405                    print!("{output}");
1406                }
1407                Task::Internal(int) => {
1408                    let node = TreeNode::from_internal(int);
1409                    let output = render_single_tree(&node, true);
1410                    print!("{output}");
1411                }
1412            }
1413        }
1414        return Ok(());
1415    }
1416
1417    // No ID provided - show all root tasks
1418    let filter = TaskFilter::default();
1419    let tasks = task_manager.list_tasks(filter).await?;
1420
1421    if tasks.is_empty() {
1422        println!("No tasks found.");
1423        return Ok(());
1424    }
1425
1426    let forest = build_forest(&tasks);
1427    let output = render_forest(&forest);
1428    print!("{output}");
1429
1430    Ok(())
1431}
1432
1433async fn dependency_tree(focus: Option<i64>) -> anyhow::Result<()> {
1434    let store = cli::init_store().await?;
1435    let focus_id = match focus {
1436        Some(id) => Some(task_ref::resolve_store_id(&store, &id.to_string()).await?),
1437        None => None,
1438    };
1439    let edges = store.list_dependency_edges().await?;
1440    let mut by_task: HashMap<i64, Vec<_>> = HashMap::new();
1441    for edge in edges {
1442        if let Some(focus_id) = focus_id
1443            && edge.task_id != focus_id
1444        {
1445            continue;
1446        }
1447        by_task.entry(edge.task_id).or_default().push(edge);
1448    }
1449    if by_task.is_empty() {
1450        println!("No dependency edges.");
1451        return Ok(());
1452    }
1453    let mut ids: Vec<i64> = by_task.keys().copied().collect();
1454    ids.sort_unstable();
1455    for id in ids {
1456        let group = &by_task[&id];
1457        let head = &group[0];
1458        let number = head
1459            .task_external_id
1460            .clone()
1461            .unwrap_or_else(|| format!("task:{id}"));
1462        let status = store.get(id).await.map_or_else(
1463            |_| "unknown".to_string(),
1464            |task| task.status.as_str().to_string(),
1465        );
1466        println!("{number} {} [{status}]", head.task_title);
1467        for edge in group {
1468            let label = dep_label(
1469                &edge.task_repo,
1470                &edge.depends_on_repo,
1471                edge.depends_on_number,
1472            );
1473            let snap = store
1474                .blocker_snapshot(&edge.depends_on_repo, edge.depends_on_number)
1475                .await
1476                .ok()
1477                .flatten();
1478            let state = blocker_state_label(snap.as_ref());
1479            println!("  blocked by {label} [{state}]");
1480        }
1481    }
1482    Ok(())
1483}
1484
1485fn blocker_state_label(snap: Option<&BlockerSnapshot>) -> String {
1486    match snap {
1487        Some(snap) => snap
1488            .unsatisfied_reason()
1489            .unwrap_or_else(|| "done".to_string()),
1490        None => "open".to_string(),
1491    }
1492}
1493
1494/// List what a task waits on, and what waits on it.
1495pub async fn deps(id: &str) -> anyhow::Result<()> {
1496    let store = cli::init_store().await?;
1497    let task_id = task_ref::resolve_store_id(&store, id).await?;
1498    let task = store.get(task_id).await?;
1499    let shown = task
1500        .external_id
1501        .clone()
1502        .unwrap_or_else(|| format!("internal:{}", task.id));
1503    println!("{shown} {} [{}]", task.title, task.status.as_str());
1504    println!("Waiting on:");
1505    let dependencies = store.list_dependencies(task_id).await?;
1506    if dependencies.is_empty() {
1507        println!("  (none)");
1508    }
1509    for dep in dependencies {
1510        let label = dep_label(&task.repo, &dep.depends_on_repo, dep.depends_on_number);
1511        let snap = store
1512            .blocker_snapshot(&dep.depends_on_repo, dep.depends_on_number)
1513            .await?;
1514        let state = blocker_state_label(snap.as_ref());
1515        let local = dep.blocker_status.as_deref().unwrap_or("untracked");
1516        println!("  {label}  {state}  local:{local}  source:{}", dep.source);
1517    }
1518    println!("Waiting on this:");
1519    let number = task
1520        .external_id
1521        .as_deref()
1522        .and_then(|value| value.parse::<i64>().ok())
1523        .unwrap_or(0);
1524    let dependents = if number > 0 {
1525        store.list_dependents(&task.repo, number).await?
1526    } else {
1527        Vec::new()
1528    };
1529    if dependents.is_empty() {
1530        println!("  (none)");
1531    }
1532    for dep in dependents {
1533        let waiter = store.get(dep.task_id).await?;
1534        let label = waiter
1535            .external_id
1536            .clone()
1537            .unwrap_or_else(|| format!("internal:{}", waiter.id));
1538        println!("  {label}  {}  {}", waiter.status.as_str(), waiter.title);
1539    }
1540    Ok(())
1541}
1542
1543/// Show logs / post-mortem for a task (internal or external).
1544pub async fn logs(id: &str) -> anyhow::Result<()> {
1545    let store_owned = cli::init_store().await?;
1546    let resolved = task_ref::resolve(&store_owned, id).await?;
1547    let store: Option<Arc<TaskStore>> = Some(Arc::new(store_owned.clone()));
1548    let repo = resolved.repo.clone();
1549    let task_key = resolved.external_id.clone();
1550
1551    if let Some(store_id) = resolved.store_id {
1552        if let Ok(task) = store_owned.get(store_id).await {
1553            if resolved.is_internal() {
1554                println!("ID: {} (internal)", task.id);
1555                println!("Title: {}", task.title);
1556                println!("Status: {}", task.status.as_str());
1557                if let Some(agent) = &task.agent {
1558                    println!("Agent: {agent}");
1559                }
1560                if let Some(reason) = &task.block_reason {
1561                    println!("Block reason: {reason}");
1562                }
1563                println!("Created: {}", task.created_at);
1564                println!("Updated: {}", task.updated_at);
1565            } else {
1566                println!("ID: {task_key} (external)");
1567                println!("Title: {}", task.title);
1568                println!("State: {}", task.status.as_str());
1569                println!("Labels: {}", task.labels.join(", "));
1570                println!("Author: {}", task.author);
1571                println!("URL: {}", task.url);
1572                println!("Created: {}", task.created_at);
1573                println!("Updated: {}", task.updated_at);
1574            }
1575        }
1576    } else {
1577        println!("ID: {}", resolved.display_spec());
1578    }
1579
1580    // Task store fields
1581    {
1582        {
1583            // Agent, model, and attempts from store — single DB round-trip.
1584            if let Some(ref s) = store
1585                && let Ok(Some(t)) = s.get_by_external_id(&repo, &task_key).await
1586            {
1587                if let Some(ref agent) = t.agent {
1588                    println!("Agent: {agent}");
1589                }
1590                if let Some(ref model) = t.model {
1591                    println!("Model: {model}");
1592                }
1593                if t.attempts > 0 {
1594                    println!("Attempts: {}", t.attempts);
1595                }
1596            }
1597
1598            // Token & cost summary
1599            let usage = store::get_token_usage(&store, &repo, &task_key).await;
1600            let cost = store::get_cost_estimate(&store, &repo, &task_key).await;
1601            let total_tokens = usage.total_tokens();
1602            if total_tokens > 0 || cost.total_cost_usd > 0.0 {
1603                println!("\nCost summary:");
1604                println!("  input tokens:  {}", usage.input_tokens);
1605                println!("  output tokens: {}", usage.output_tokens);
1606                println!("  total tokens:  {total_tokens}");
1607                println!("  estimated $:   ${:.6}", cost.total_cost_usd);
1608            }
1609
1610            // Memory (recent attempts)
1611            {
1612                let mem = store::get_recent_memory(&store, &repo, &task_key, 10).await;
1613                if !mem.is_empty() {
1614                    println!("\nMemory (recent attempts):");
1615                    for m in mem {
1616                        println!(
1617                            "- Attempt {} @ {}: agent={} model={}",
1618                            m.attempt,
1619                            m.timestamp,
1620                            m.agent,
1621                            m.model.clone().unwrap_or_default()
1622                        );
1623                        if let Some(err) = m.error.as_ref() {
1624                            println!("    Error: {err}");
1625                        }
1626                        if !m.learnings.is_empty() {
1627                            println!("    Learnings:");
1628                            for l in &m.learnings {
1629                                println!("      - {l}");
1630                            }
1631                        }
1632                        if !m.files_modified.is_empty() {
1633                            println!("    Files modified:");
1634                            for f in &m.files_modified {
1635                                println!("      - {f}");
1636                            }
1637                        }
1638                    }
1639                }
1640            }
1641        }
1642    }
1643
1644    // Show agent output from last attempt if available
1645    let attempt_n = store::opt_store_get_task(&store, &repo, &task_key)
1646        .await
1647        .map_or(0, |t| u32::try_from(t.attempts).unwrap_or(0));
1648    if attempt_n > 0 {
1649        match home::task_attempt_dir(&repo, &task_key, attempt_n) {
1650            Ok(attempt_dir) => {
1651                let output_file = attempt_dir.join("output.json");
1652                let exit_file = attempt_dir.join("exit.txt");
1653
1654                if output_file.exists()
1655                    && let Ok(content) = fs::read_to_string(&output_file)
1656                {
1657                    let tail = patterns::safe_tail(&content, 2000);
1658                    let truncated = if content.len() > 2000 {
1659                        format!(
1660                            "...({} chars truncated)...\n{}",
1661                            content.len() - tail.len(),
1662                            tail
1663                        )
1664                    } else {
1665                        content.clone()
1666                    };
1667                    println!("\n--- Last attempt output (attempt {attempt_n}) ---");
1668                    println!("{truncated}");
1669                }
1670
1671                if exit_file.exists()
1672                    && let Ok(exit_code) = fs::read_to_string(&exit_file)
1673                {
1674                    println!("Exit code: {}", exit_code.trim());
1675                }
1676            }
1677            Err(e) => {
1678                println!("(could not resolve attempt dir: {e})");
1679            }
1680        }
1681    }
1682
1683    // Show review agent output if available
1684    let review_task_id = format!("{task_key}-review");
1685    if let Ok(review_attempt_dir) = home::task_attempt_dir(&repo, &review_task_id, 1) {
1686        let review_output_file = review_attempt_dir.join("output.json");
1687        if review_output_file.exists()
1688            && let Ok(content) = fs::read_to_string(&review_output_file)
1689        {
1690            println!("\n--- Review agent output ---");
1691            println!("{content}");
1692        }
1693    }
1694
1695    // Show audit trail from task_runs table (if available in store)
1696    if let Ok(store) = cli::init_store().await
1697        && let Ok(Some(store_id)) = store.resolve_task_id(&repo, &task_key).await
1698        && let Ok(runs) = store.get_runs(store_id).await
1699        && !runs.is_empty()
1700    {
1701        println!("\n--- Run audit trail ({} runs) ---", runs.len());
1702        for run in &runs {
1703            println!(
1704                "\n  Run #{} [{}] agent={} model={} outcome={}",
1705                run.attempt, run.run_type, run.agent, run.model, run.outcome,
1706            );
1707            if let Some(code) = run.exit_code {
1708                println!("    exit_code: {code}");
1709            }
1710            if !run.error.is_empty() {
1711                println!("    error: {}", run.error);
1712            }
1713            if run.total_cost_usd > 0.0 {
1714                println!(
1715                    "    tokens: {}in/{}out  cost: ${:.6}  duration: {:.1}s",
1716                    run.input_tokens, run.output_tokens, run.total_cost_usd, run.duration_secs,
1717                );
1718            }
1719            println!("    started: {}", run.started_at);
1720            if let Some(ref completed) = run.completed_at {
1721                println!("    completed: {completed}");
1722            }
1723        }
1724    }
1725
1726    // If tmux session is live, append recent pane capture
1727    let tmux = TmuxManager::new();
1728    let session = tmux.session_name(&repo, &task_key);
1729    if tmux.session_exists(&session).await {
1730        println!("\nLive tmux session detected: {session} — appending recent output:\n---");
1731        match tmux.capture_pane(&session, 200).await {
1732            Ok(output) => {
1733                println!("{output}");
1734            }
1735            Err(e) => {
1736                println!("(tmux capture failed: {e})");
1737            }
1738        }
1739    }
1740
1741    Ok(())
1742}
1743
1744/// Show run history for a task.
1745pub async fn runs(id: &str, verbose: bool) -> anyhow::Result<()> {
1746    let store = cli::init_store().await?;
1747    let task_id = task_ref::resolve_store_id(&store, id).await?;
1748
1749    let runs = store.get_runs(task_id).await?;
1750    if runs.is_empty() {
1751        println!("No runs found for task {id}.");
1752        return Ok(());
1753    }
1754
1755    println!(
1756        "{:<4} {:<8} {:<18} {:<12} {:<8} {:<8} ERROR",
1757        "RUN", "AGENT", "MODEL", "OUTCOME", "DURATION", "COST"
1758    );
1759    println!("{}", "-".repeat(90));
1760
1761    for run in runs {
1762        let model = if run.model.is_empty() {
1763            "-"
1764        } else {
1765            &run.model
1766        };
1767        let error = format_run_text(&run.error, verbose, 48);
1768        println!(
1769            "{:<4} {:<8} {:<18} {:<12} {:<8} {:<8} {}",
1770            run.attempt,
1771            run.agent,
1772            model,
1773            run.outcome,
1774            format_run_duration(run.duration_secs),
1775            format_run_cost(run.total_cost_usd),
1776            error,
1777        );
1778
1779        if verbose {
1780            if !run.stdout.is_empty() {
1781                println!("  stdout:\n{}", run.stdout);
1782            }
1783            if !run.stderr.is_empty() {
1784                println!("  stderr:\n{}", run.stderr);
1785            }
1786            if !run.parsed_response.is_empty() {
1787                println!("  parsed_response:\n{}", run.parsed_response);
1788            }
1789            println!();
1790        }
1791    }
1792
1793    Ok(())
1794}
1795
1796/// Show lifecycle activity timeline for a task.
1797pub async fn activity_log(id: &str, limit: Option<usize>, json: bool) -> anyhow::Result<()> {
1798    let store = cli::init_store().await?;
1799    let task_id = task_ref::resolve_store_id(&store, id).await?;
1800
1801    let events = store.get_activity(task_id, limit).await?;
1802    if events.is_empty() {
1803        println!("No activity found for task {id}.");
1804        return Ok(());
1805    }
1806
1807    println!(
1808        "{:<20} {:<16} {:<12} {:<12} {:<10} {:<14} DETAILS",
1809        "TIME", "EVENT", "FROM", "TO", "AGENT", "MODEL"
1810    );
1811    println!("{}", "-".repeat(120));
1812
1813    for event in events {
1814        let details = if json {
1815            serde_json::to_string(&event.details).unwrap_or_else(|_| "{}".to_string())
1816        } else if event.details.is_null() || event.details == serde_json::json!({}) {
1817            String::new()
1818        } else {
1819            let compact =
1820                serde_json::to_string(&event.details).unwrap_or_else(|_| "{}".to_string());
1821            truncate_err(&compact, 80)
1822        };
1823        println!(
1824            "{:<20} {:<16} {:<12} {:<12} {:<10} {:<14} {}",
1825            event.timestamp,
1826            event.event_type,
1827            event.from_status.as_deref().unwrap_or("-"),
1828            event.to_status.as_deref().unwrap_or("-"),
1829            event.agent.as_deref().unwrap_or("-"),
1830            event.model.as_deref().unwrap_or("-"),
1831            details
1832        );
1833    }
1834
1835    Ok(())
1836}
1837
1838/// Show task routing history with attempt timeline.
1839pub async fn history(id: &str) -> anyhow::Result<()> {
1840    let store = cli::init_store().await?;
1841    let task_id = task_ref::resolve_store_id(&store, id).await?;
1842
1843    let task = store.get(task_id).await?;
1844    let events = store.get_activity(task_id, None).await?;
1845
1846    // Header
1847    println!("Routing History for Task {id}");
1848    println!("{}", "=".repeat(60));
1849    println!("Title: {}", task.title);
1850    println!("Status: {:?}", task.status);
1851    println!();
1852
1853    // Find routing events (routed, rerouted)
1854    let routing_events: Vec<_> = events
1855        .iter()
1856        .filter(|e| e.event_type == "routed" || e.event_type == "rerouted")
1857        .collect();
1858
1859    if routing_events.is_empty() {
1860        println!("No routing history found. Task has not been routed yet.");
1861        return Ok(());
1862    }
1863
1864    // Display routing timeline
1865    println!("Attempt Timeline:");
1866    println!("{}", "-".repeat(60));
1867
1868    for (i, event) in routing_events.iter().enumerate() {
1869        let attempt_num = i + 1;
1870        let agent = event.agent.as_deref().unwrap_or("-");
1871        let model = event.model.as_deref().unwrap_or("-");
1872        let timestamp = &event.timestamp;
1873
1874        if event.event_type == "routed" {
1875            println!("\nAttempt #{attempt_num}");
1876            println!("  Time:     {timestamp}");
1877            println!("  Agent:    {agent}");
1878            println!("  Model:    {model}");
1879
1880            // Extract routing details
1881            if let Some(reason) = event.details.get("reason").and_then(|r| r.as_str()) {
1882                println!("  Reason:   {reason}");
1883            }
1884            if let Some(complexity) = event.details.get("complexity").and_then(|c| c.as_str()) {
1885                println!("  Complexity: {complexity}");
1886            }
1887        } else if event.event_type == "rerouted" {
1888            println!("  → Rerouted at {timestamp}");
1889
1890            // Check for failure reason
1891            if let Some(failure) = event.details.get("failure_reason").and_then(|f| f.as_str()) {
1892                println!("    Failure: {failure}");
1893            } else if let Some(failure) = event.details.get("silence_duration_secs") {
1894                println!("    Failure: Agent silent for {failure}s");
1895            }
1896
1897            // Show from/to agent if available
1898            if let Some(from) = event.details.get("from_agent").and_then(|a| a.as_str())
1899                && let Some(to) = event.details.get("to_agent").and_then(|a| a.as_str())
1900            {
1901                println!("    Change:   {from} → {to}");
1902            }
1903
1904            // Show if agents were exhausted
1905            if event.details.get("agents_exhausted").is_some() {
1906                println!("    Note:     All agents exhausted");
1907            }
1908            if event.details.get("no_fallback_available").is_some() {
1909                println!("    Note:     No fallback available");
1910            }
1911        }
1912    }
1913
1914    // Summary
1915    println!("\n{}", "-".repeat(60));
1916    println!("Summary:");
1917    println!("  Total routing attempts: {}", routing_events.len());
1918
1919    // Get cost summary from task
1920    let total_tokens = task.input_tokens + task.output_tokens;
1921    if total_tokens > 0 {
1922        println!("  Input tokens:  {}", task.input_tokens);
1923        println!("  Output tokens: {}", task.output_tokens);
1924        println!(
1925            "  Total cost:    ${:.6}",
1926            task.input_cost_usd + task.output_cost_usd
1927        );
1928    }
1929
1930    // Show current assigned agent/model if available
1931    if let Some(ref agent) = task.agent {
1932        println!("  Current agent: {agent}");
1933    }
1934    if let Some(ref model) = task.model {
1935        println!("  Current model: {model}");
1936    }
1937
1938    Ok(())
1939}
1940
1941/// Unified diagnostic view for a task: status, agent, attempts, last error, PR, block reason.
1942pub async fn inspect(id: &str) -> anyhow::Result<()> {
1943    let store = cli::init_store().await?;
1944    let task_id = task_ref::resolve_store_id(&store, id).await?;
1945
1946    let task = store.get(task_id).await?;
1947
1948    // ── Identity ─────────────────────────────────────────────────────────────
1949    println!("Task: {}", task.title);
1950    println!(
1951        "  ID      : {} (internal:{})",
1952        task.external_id.as_deref().unwrap_or("-"),
1953        task.id
1954    );
1955    println!("  Status  : {}", task.status.as_str());
1956    println!(
1957        "  Priority: {}",
1958        Priority::from_labels(&task.labels).as_str()
1959    );
1960    if task.status == TaskStatus::Waiting {
1961        let blockers = store
1962            .unmet_blocker_labels(task.id)
1963            .await
1964            .unwrap_or_default();
1965        if !blockers.is_empty() {
1966            println!("  Waiting : {}", blockers.join(", "));
1967        }
1968    }
1969    println!(
1970        "  Created : {} ({})",
1971        task.created_at,
1972        format_age(&task.created_at)
1973    );
1974    println!(
1975        "  Updated : {} ({})",
1976        task.updated_at,
1977        format_age(&task.updated_at)
1978    );
1979    if !task.url.is_empty() {
1980        println!("  URL     : {}", task.url);
1981    }
1982
1983    // ── Routing ───────────────────────────────────────────────────────────────
1984    println!();
1985    println!("Routing:");
1986    println!("  Agent      : {}", task.agent.as_deref().unwrap_or("-"));
1987    println!("  Model      : {}", task.model.as_deref().unwrap_or("-"));
1988    if !task.complexity.is_empty() {
1989        println!("  Complexity : {}", task.complexity);
1990    }
1991    if task.route_attempts > 0 {
1992        println!("  Route attempts : {}", task.route_attempts);
1993    }
1994    if !task.route_reason.is_empty() {
1995        println!("  Reason     : {}", truncate_err(&task.route_reason, 120));
1996    }
1997
1998    // ── Execution ─────────────────────────────────────────────────────────────
1999    println!();
2000    println!("Execution:");
2001    println!("  Attempts : {}", task.attempts);
2002    if !task.branch.is_empty() {
2003        println!("  Branch   : {}", task.branch);
2004    }
2005    if !task.worktree.is_empty() {
2006        println!("  Worktree : {}", task.worktree);
2007    }
2008    if !task.last_error.is_empty() {
2009        println!("  Last error : {}", truncate_err(&task.last_error, 200));
2010    }
2011    if !task.summary.is_empty() {
2012        println!("  Summary  : {}", truncate_err(&task.summary, 200));
2013    }
2014
2015    // ── PR / Review ───────────────────────────────────────────────────────────
2016    if task.pr_number.is_some() || task.review_cycles > 0 || !task.pr_review_context.is_empty() {
2017        println!();
2018        println!("PR / Review:");
2019        if let Some(pr) = task.pr_number {
2020            println!("  PR number     : {pr}");
2021        }
2022        if task.review_cycles > 0 {
2023            println!("  Review cycles : {}", task.review_cycles);
2024        }
2025        if !task.pr_review_context.is_empty() {
2026            println!(
2027                "  Review context: {}",
2028                truncate_err(&task.pr_review_context, 200)
2029            );
2030        }
2031    }
2032
2033    // Each failed review attempt, so a rebroadcast escalation shows what kept failing
2034    // instead of only the final "escalated after N retries"
2035    let attempts: Vec<_> = store
2036        .get_activity(task.id, None)
2037        .await
2038        .unwrap_or_default()
2039        .into_iter()
2040        .filter(|a| a.event_type == "review_attempt_failed")
2041        .collect();
2042    if !attempts.is_empty() {
2043        println!();
2044        println!("Review attempts ({} failed):", attempts.len());
2045        for activity in attempts.iter().rev().take(10).rev() {
2046            println!("  {}", review_attempt_line(activity));
2047        }
2048    }
2049
2050    // ── Block reason ─────────────────────────────────────────────────────────
2051    if let Some(ref reason) = task.block_reason {
2052        println!();
2053        println!("Blocked:");
2054        println!("  Reason : {reason}");
2055        if task.auto_unblock_count > 0 {
2056            println!("  Auto-unblock attempts : {}", task.auto_unblock_count);
2057        }
2058    }
2059
2060    // ── Cost ─────────────────────────────────────────────────────────────────
2061    let total_tokens = task.input_tokens + task.output_tokens;
2062    if total_tokens > 0 || task.total_cost_usd > 0.0 {
2063        println!();
2064        println!("Cost:");
2065        println!(
2066            "  Tokens : {}in / {}out",
2067            task.input_tokens, task.output_tokens
2068        );
2069        println!("  Total  : ${:.6}", task.total_cost_usd);
2070    }
2071
2072    // ── Live session ─────────────────────────────────────────────────────────
2073    let task_key = task
2074        .external_id
2075        .clone()
2076        .unwrap_or_else(|| format!("internal:{}", task.id));
2077    let tmux = TmuxManager::new();
2078    let session = tmux.session_name(&task.repo, &task_key);
2079    if tmux.session_exists(&session).await {
2080        println!();
2081        println!("Live session: {session} (use `orch task attach {id}` to connect)");
2082    }
2083
2084    Ok(())
2085}
2086
2087#[cfg(test)]
2088mod tests {
2089    use super::*;
2090
2091    #[test]
2092    fn format_age_seconds() {
2093        let ts = (Utc::now() - chrono::Duration::seconds(30)).to_rfc3339();
2094        let age = format_age(&ts);
2095        assert!(age.ends_with('s'), "expected seconds format, got {age}");
2096    }
2097
2098    #[test]
2099    fn format_age_minutes() {
2100        let ts = (Utc::now() - chrono::Duration::minutes(5)).to_rfc3339();
2101        let age = format_age(&ts);
2102        assert!(age.ends_with('m'), "expected minutes format, got {age}");
2103    }
2104
2105    #[test]
2106    fn format_age_hours() {
2107        let ts = (Utc::now() - chrono::Duration::hours(3)).to_rfc3339();
2108        let age = format_age(&ts);
2109        assert!(age.ends_with('h'), "expected hours format, got {age}");
2110    }
2111
2112    #[test]
2113    fn format_age_days() {
2114        let ts = (Utc::now() - chrono::Duration::days(2)).to_rfc3339();
2115        let age = format_age(&ts);
2116        assert!(age.ends_with('d'), "expected days format, got {age}");
2117    }
2118
2119    #[test]
2120    fn format_age_invalid_returns_dash() {
2121        let age = format_age("not-a-timestamp");
2122        assert_eq!(age, "-");
2123    }
2124
2125    fn task_ref(status: &str) -> store::CliTaskRef {
2126        store::CliTaskRef {
2127            store_id: Some(7),
2128            repo: "owner/repo".to_string(),
2129            origin: "github".to_string(),
2130            title: "t".to_string(),
2131            external_id: "58".to_string(),
2132            status: status.to_string(),
2133        }
2134    }
2135
2136    #[test]
2137    fn refuse_if_done_rejects_done_tasks() {
2138        for action in ["retry", "unblock"] {
2139            let err = refuse_if_done(&task_ref("done"), action)
2140                .expect_err("done task must not be revived");
2141            let msg = err.to_string();
2142            assert!(msg.contains("is done"), "got: {msg}");
2143            assert!(msg.contains(action), "got: {msg}");
2144        }
2145    }
2146
2147    #[test]
2148    fn refuse_if_done_allows_other_statuses() {
2149        for status in ["blocked", "needs_review", "new", "in_review", ""] {
2150            assert!(refuse_if_done(&task_ref(status), "unblock").is_ok());
2151        }
2152    }
2153
2154    #[test]
2155    fn matches_project_filter_accepts_exact_repo_slug() {
2156        assert!(matches_project_filter(
2157            "gabrielkoerich/orch",
2158            "gabrielkoerich/orch"
2159        ));
2160    }
2161
2162    #[test]
2163    fn matches_project_filter_accepts_repo_name_suffix() {
2164        assert!(matches_project_filter("gabrielkoerich/orch", "orch"));
2165    }
2166
2167    #[test]
2168    fn matches_project_filter_rejects_non_matching_repo() {
2169        assert!(!matches_project_filter("gabrielkoerich/orch", "bean"));
2170    }
2171
2172    /// Verify the forced-routing field construction for agent-only case.
2173    #[test]
2174    fn retry_force_route_fields_agent_only() {
2175        let forced_agent = "claude";
2176        let model: Option<String> = None;
2177        let fields: Vec<(&str, serde_json::Value)> = {
2178            let mut f = vec![
2179                ("agent", serde_json::json!(forced_agent)),
2180                (
2181                    "route_reason",
2182                    serde_json::json!(format!("forced via retry --agent {forced_agent}")),
2183                ),
2184            ];
2185            if let Some(ref m) = model {
2186                f.push(("model", serde_json::json!(m)));
2187            }
2188            f
2189        };
2190        assert_eq!(fields.len(), 2);
2191        assert_eq!(fields[0].1, serde_json::json!("claude"));
2192        assert_eq!(
2193            fields[1].1,
2194            serde_json::json!("forced via retry --agent claude")
2195        );
2196    }
2197
2198    /// Verify the forced-routing field construction for agent+model case.
2199    #[test]
2200    fn retry_force_route_fields_agent_and_model() {
2201        let fields = retry_force_fields("claude", Some("opus"));
2202        assert_eq!(fields.len(), 3);
2203        assert_eq!(fields[0].1, serde_json::json!("claude"));
2204        assert_eq!(fields[2].0, "model");
2205        assert_eq!(fields[2].1, serde_json::json!("opus"));
2206    }
2207
2208    /// Verify that the model hint display is correct in both cases.
2209    #[test]
2210    fn retry_model_hint_display() {
2211        let model_some: Option<String> = Some("opus".to_string());
2212        let model_none: Option<String> = None;
2213        assert_eq!(model_some.as_deref().unwrap_or("(router default)"), "opus");
2214        assert_eq!(
2215            model_none.as_deref().unwrap_or("(router default)"),
2216            "(router default)"
2217        );
2218    }
2219
2220    #[test]
2221    fn review_attempt_line_shows_cause_and_reason() {
2222        let activity = store::TaskActivity {
2223            id: 1,
2224            task_id: 7,
2225            timestamp: "2026-10-25T22:40:00Z".to_string(),
2226            event_type: "review_attempt_failed".to_string(),
2227            from_status: None,
2228            to_status: None,
2229            agent: None,
2230            model: None,
2231            details: serde_json::json!({
2232                "cause": "rate_limit",
2233                "external": true,
2234                "reason": "claude failed: rate limit reached",
2235            }),
2236        };
2237        let line = review_attempt_line(&activity);
2238        assert_eq!(
2239            line,
2240            "2026-10-25T22:40:00Z  rate_limit (external): claude failed: rate limit reached"
2241        );
2242    }
2243
2244    #[test]
2245    fn truncate_err_under_limit() {
2246        let s = "short error";
2247        assert_eq!(truncate_err(s, 50), "short error");
2248    }
2249
2250    #[test]
2251    fn truncate_err_exact_limit() {
2252        let s = "short error";
2253        assert_eq!(truncate_err(s, 11), "short error");
2254    }
2255
2256    #[test]
2257    fn truncate_err_truncates() {
2258        let s = "this is a long error message that should be cut";
2259        let result = truncate_err(s, 10);
2260        assert!(result.ends_with("..."));
2261        assert!(result.len() <= 13); // 10 chars + "..."
2262    }
2263
2264    #[test]
2265    fn truncate_err_multi_byte_char_boundary() {
2266        // "héllo world" bytes: h(0)=1, é(1-2)=2, l(3)=1, l(4)=1, o(5)=1, sp(6)=1, w(7)=1...
2267        // byte 3 = 'l' (valid boundary); byte 4 = 'l' (valid); byte 5 = 'o' (valid)
2268        // No panic = success; exact result varies by where the boundary lands
2269        let s = "héllo world";
2270        let result = truncate_err(s, 3);
2271        assert!(result.ends_with("..."));
2272        assert!(!result.contains("panic"));
2273    }
2274
2275    #[test]
2276    fn truncate_err_emoji() {
2277        // "🔥 error message": 🔥=4 bytes, space=1, e=1, r=1, r=1, o=1, sp=1, m=1...
2278        // byte 5 = 'e' (valid boundary); walk-back not needed
2279        let s = "🔥 error message";
2280        let result = truncate_err(s, 5);
2281        assert_eq!(result, "🔥 ...");
2282    }
2283
2284    #[test]
2285    fn truncate_err_cjk() {
2286        // "你好世界": 你=3 bytes(0-2), 好=3 bytes(3-5), 世=3 bytes(6-8), 界=3 bytes(9-11)
2287        // byte 4 falls inside 好; walk back to byte 3 (= end of 你)
2288        let s = "你好世界";
2289        let result = truncate_err(s, 4);
2290        assert_eq!(result, "你...");
2291    }
2292}