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