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