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