Skip to main content

orch/cli/
task.rs

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