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