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