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