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