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