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