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