Skip to main content

orch/cli/
task.rs

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