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