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