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