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