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