1use std::{
14 collections::{BTreeSet, HashMap, HashSet},
15 path::{Path, PathBuf},
16};
17
18use anyhow::Result;
19use chrono::Utc;
20use cli_shared::UserConfig;
21use objects::{
22 object::Tree,
23 store::{
24 ActorPresence, ActorPresenceStatus, ActorPresenceStore, AgentTaskRecord, AgentTaskStore,
25 },
26 worktree::WorktreeStatus,
27};
28use repo::{
29 AgentUsageSummary, GitOverlayBranchTip, GitRemoteTrackingStatus, Repository,
30 RepositoryOperationStatus, Thread, ThreadConfidenceSummary, ThreadFreshness,
31 ThreadImpactCategory, ThreadIntegrationPolicy, ThreadManager, ThreadMode, ThreadRuntimeOverlay,
32 ThreadState, ThreadVerificationSummary, ThreadView, describe_thread_advice,
33 refresh_thread_freshness, shell_quote,
34};
35use serde::Serialize;
36use sley::Repository as SleyRepository;
37
38use crate::{
39 ActionTemplate,
40 status::{
41 CoordinationStatus,
42 next_action::{
43 NextActionInput, canonical_git_repair_ref_preview_command, contextual_thread_action,
44 effective_next_action, heddle_action,
45 },
46 },
47 verify::{action_template, serialize_empty_action_as_null},
48};
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
52pub struct ThreadListOptions {
53 pub include_auto: bool,
56 pub include_abandoned: bool,
59}
60
61impl ThreadListOptions {
62 pub fn new() -> Self {
63 Self::default()
64 }
65
66 pub fn include_auto(mut self, include_auto: bool) -> Self {
67 self.include_auto = include_auto;
68 self
69 }
70
71 pub fn include_abandoned(mut self, include_abandoned: bool) -> Self {
72 self.include_abandoned = include_abandoned;
73 self
74 }
75}
76
77#[derive(Debug, Clone, Serialize)]
83pub struct ThreadListReport {
84 pub output_kind: &'static str,
85 pub threads: Vec<ThreadListEntry>,
86 pub available_git_refs: Vec<AvailableGitRef>,
87 pub current: Option<String>,
88}
89
90#[derive(Debug, Clone, Serialize)]
94pub struct ThreadListEntry {
95 pub name: String,
96 pub operation: Option<RepositoryOperationStatus>,
97 pub remote_tracking: Option<GitRemoteTrackingStatus>,
98 pub base_state: Option<String>,
99 pub base_root: Option<String>,
100 pub current_state: Option<String>,
101 pub path: Option<String>,
102 pub execution_path: Option<String>,
103 #[serde(skip_serializing_if = "Option::is_none")]
104 pub session_id: Option<String>,
105 #[serde(skip_serializing_if = "Option::is_none")]
106 pub heddle_session_id: Option<String>,
107 pub actor: Option<ThreadActorInfo>,
108 pub harness: Option<String>,
109 pub thinking_level: Option<String>,
110 #[serde(skip_serializing_if = "Option::is_none")]
111 pub native_actor_key: Option<String>,
112 #[serde(skip_serializing_if = "Option::is_none")]
113 pub native_parent_actor_key: Option<String>,
114 #[serde(skip_serializing_if = "Option::is_none")]
115 pub probe_source: Option<String>,
116 #[serde(skip_serializing_if = "Option::is_none")]
117 pub probe_confidence: Option<f32>,
118 pub usage_summary: Option<AgentUsageSummary>,
119 pub last_progress_at: Option<String>,
120 pub last_activity_at: Option<String>,
121 pub report_flush_state: Option<String>,
122 pub attach_reason: Option<String>,
123 pub thread_mode: Option<ThreadMode>,
124 pub thread_state: Option<ThreadState>,
125 pub freshness: Option<ThreadFreshness>,
126 pub visibility: String,
127 pub target_thread: Option<String>,
128 pub parent_thread: Option<String>,
129 pub child_threads: Vec<String>,
130 pub sibling_threads: Vec<String>,
131 pub stack_depth: usize,
132 pub stale_from_parent: bool,
133 pub task: Option<String>,
134 pub task_assignment_id: Option<String>,
135 pub task_summary: Option<ThreadTaskSummary>,
136 pub changed_paths: Vec<String>,
137 pub promotion_suggested: bool,
138 pub impact_categories: Vec<ThreadImpactCategory>,
139 pub heavy_impact_paths: Vec<String>,
140 pub verification_summary: ThreadVerificationSummary,
141 pub confidence_summary: ThreadConfidenceSummary,
142 pub integration_policy_result: ThreadIntegrationPolicy,
143 pub coordination_status: CoordinationStatus,
144 pub is_current: bool,
145 pub is_isolated: bool,
146 pub thread_health: String,
147 pub blockers: Vec<String>,
148 #[serde(serialize_with = "serialize_empty_action_as_null")]
149 pub recommended_action: String,
150 pub recommended_action_template: Option<ActionTemplate>,
151 pub git_branch_tip: Option<String>,
152 pub history_imported: bool,
153 pub auto: bool,
158 pub shared_target_dir: Option<String>,
160}
161
162pub type ThreadSummary = ThreadListEntry;
164
165#[derive(Debug, Clone, Serialize)]
167pub struct AvailableGitRef {
168 pub name: String,
169 pub git_commit: String,
170 #[serde(serialize_with = "serialize_empty_action_as_null")]
171 pub recommended_action: String,
172 pub recommended_action_template: Option<ActionTemplate>,
173}
174
175#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
177pub struct ThreadActorInfo {
178 #[serde(skip_serializing_if = "Option::is_none")]
179 pub provider: Option<String>,
180 #[serde(skip_serializing_if = "Option::is_none")]
181 pub model: Option<String>,
182}
183
184#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
186pub struct ThreadTaskSummary {
187 pub task_id: String,
188 pub title: String,
189 pub status: String,
190 pub target_thread: String,
191 pub updated_at: String,
192 pub completed_at: Option<String>,
193 pub coordination_discussion_id: Option<String>,
194}
195
196impl From<&AgentTaskRecord> for ThreadTaskSummary {
197 fn from(task: &AgentTaskRecord) -> Self {
198 Self {
199 task_id: task.task_id.clone(),
200 title: task.title.clone(),
201 status: task.status.to_string(),
202 target_thread: task.target_thread.clone(),
203 updated_at: task.updated_at.to_rfc3339(),
204 completed_at: task.completed_at.map(|time| time.to_rfc3339()),
205 coordination_discussion_id: task.coordination_discussion_id.clone(),
206 }
207 }
208}
209
210impl ThreadListEntry {
211 fn from_view(view: ThreadView, coordination_status: CoordinationStatus) -> Self {
212 let mode = view.record.mode.clone();
213 Self {
214 name: view.record.thread,
215 operation: None,
216 remote_tracking: None,
217 base_state: Some(view.record.base_state),
218 base_root: Some(view.record.base_root),
219 current_state: view.record.current_state,
220 path: view
221 .runtime
222 .materialized_path
223 .as_ref()
224 .or(view.runtime.path.as_ref())
225 .and_then(|p| display_path_string(p)),
226 execution_path: view
227 .runtime
228 .execution_path
229 .as_ref()
230 .and_then(|p| display_path_string(p)),
231 session_id: view.runtime.session_id,
232 heddle_session_id: view.runtime.heddle_session_id,
233 actor: match (view.runtime.provider, view.runtime.model) {
234 (None, None) => None,
235 (provider, model) => Some(ThreadActorInfo { provider, model }),
236 },
237 harness: view.runtime.harness,
238 thinking_level: view.runtime.thinking_level,
239 native_actor_key: view.runtime.native_actor_key,
240 native_parent_actor_key: view.runtime.native_parent_actor_key,
241 probe_source: view.runtime.probe_source,
242 probe_confidence: view.runtime.probe_confidence,
243 usage_summary: view.runtime.usage_summary,
244 last_progress_at: view.runtime.last_progress_at.map(|ts| ts.to_rfc3339()),
245 last_activity_at: Some(view.record.updated_at.to_rfc3339()),
246 report_flush_state: view.runtime.report_flush_state,
247 attach_reason: view.runtime.attach_reason,
248 thread_mode: Some(mode.clone()),
249 thread_state: Some(view.record.state),
250 freshness: Some(view.record.freshness),
251 visibility: if view.is_isolated {
252 visibility_label(&mode).to_string()
253 } else {
254 "ref_only".to_string()
255 },
256 target_thread: view.record.target_thread,
257 parent_thread: view.record.parent_thread,
258 child_threads: Vec::new(),
259 sibling_threads: Vec::new(),
260 stack_depth: 0,
261 stale_from_parent: false,
262 task: view.record.task,
263 task_assignment_id: None,
264 task_summary: None,
265 changed_paths: view.record.changed_paths,
266 promotion_suggested: view.record.promotion_suggested,
267 impact_categories: view.record.impact_categories,
268 heavy_impact_paths: view.record.heavy_impact_paths,
269 verification_summary: view.record.verification_summary,
270 confidence_summary: view.record.confidence_summary,
271 integration_policy_result: view.record.integration_policy_result,
272 coordination_status,
273 is_current: view.is_current,
274 is_isolated: view.is_isolated,
275 thread_health: "clean".to_string(),
276 blockers: Vec::new(),
277 recommended_action: String::new(),
278 recommended_action_template: None,
279 git_branch_tip: None,
280 history_imported: true,
281 auto: view.record.auto,
282 shared_target_dir: view
283 .record
284 .shared_target_dir
285 .as_ref()
286 .map(|p| p.display().to_string()),
287 }
288 }
289}
290
291fn display_path_string(path: &Path) -> Option<String> {
292 let rendered = path.display().to_string();
293 if rendered.trim().is_empty() {
294 None
295 } else {
296 Some(rendered)
297 }
298}
299
300pub fn list_threads(repo: &Repository, options: ThreadListOptions) -> Result<ThreadListReport> {
302 list_threads_inner(repo, options, None)
303}
304
305pub fn list_threads_with_worktree_status(
308 repo: &Repository,
309 options: ThreadListOptions,
310 worktree_status: &WorktreeStatus,
311) -> Result<ThreadListReport> {
312 list_threads_inner(repo, options, Some(worktree_status))
313}
314
315fn list_threads_inner(
316 repo: &Repository,
317 options: ThreadListOptions,
318 worktree_status: Option<&WorktreeStatus>,
319) -> Result<ThreadListReport> {
320 let mut summaries = collect_thread_summaries_inner(repo, worktree_status)?;
321 if !options.include_auto {
322 summaries.retain(|summary| summary.is_current || !summary.auto);
326 }
327 if !options.include_abandoned {
328 summaries.retain(|summary| {
329 summary.is_current || !matches!(summary.thread_state, Some(ThreadState::Abandoned))
330 });
331 }
332 let available_git_refs = split_available_git_refs(&mut summaries);
333 let current = summaries
334 .iter()
335 .find(|summary| summary.is_current)
336 .map(|summary| summary.name.clone())
337 .or(repo.current_lane()?);
338 Ok(ThreadListReport {
339 output_kind: "thread_list",
340 threads: summaries,
341 available_git_refs,
342 current,
343 })
344}
345
346pub fn collect_thread_summaries(repo: &Repository) -> Result<Vec<ThreadListEntry>> {
348 collect_thread_summaries_inner(repo, None)
349}
350
351fn collect_thread_summaries_inner(
352 repo: &Repository,
353 worktree_status: Option<&WorktreeStatus>,
354) -> Result<Vec<ThreadListEntry>> {
355 let thread_refs = repo.refs().list_threads_with_states()?;
356 let thread_ref_names = thread_refs
357 .iter()
358 .map(|(name, _)| name.to_string())
359 .collect::<HashSet<_>>();
360 let thread_ref_states = thread_refs
361 .iter()
362 .map(|(name, state)| (name.to_string(), *state))
363 .collect::<HashMap<_, _>>();
364 let current = repo.current_lane()?;
365 let operation = repo.operation_status()?;
366 let remote_tracking = repo.git_remote_tracking_status().unwrap_or(None);
367 let import_hint = repo.git_import_guidance().unwrap_or(None);
368 let branch_tips = repo
369 .git_overlay_branch_tips()
370 .unwrap_or_default()
371 .into_iter()
372 .map(|tip| (tip.branch.clone(), tip))
373 .collect::<HashMap<_, _>>();
374 let registry = ActorPresenceStore::new(repo.heddle_dir());
375 let task_store = AgentTaskStore::new(repo.heddle_dir());
376 let thread_manager = ThreadManager::new(repo.heddle_dir());
377 let mut entries_by_thread: HashMap<String, Vec<ActorPresence>> = HashMap::new();
378 let mut threads_by_name: HashMap<String, Thread> = HashMap::new();
379 for entry in registry.list()? {
380 entries_by_thread
381 .entry(entry.thread.clone())
382 .or_default()
383 .push(entry);
384 }
385 for mut thread in thread_manager.list()? {
386 refresh_thread_freshness(repo, &mut thread)?;
387 threads_by_name.insert(thread.thread.clone(), thread);
388 }
389
390 let mut names: BTreeSet<String> = thread_ref_names.iter().cloned().collect();
391 names.extend(current.iter().cloned());
392 names.extend(entries_by_thread.keys().cloned());
393 names.extend(threads_by_name.keys().cloned());
394 names.extend(branch_tips.keys().cloned());
395
396 let mut summaries = Vec::new();
397 for name in names {
398 let entries = entries_by_thread.remove(&name).unwrap_or_default();
399 let task_assignment_id = task_assignment_id_from_entries(&entries);
400 let task_summary = task_summary_for_assignment(&task_store, task_assignment_id.as_deref())?;
401 let (view, coordination_status) = build_thread_view(
402 repo,
403 current.as_ref() == Some(&name),
404 name.clone(),
405 thread_ref_states.get(&name).copied(),
406 entries,
407 threads_by_name.remove(&name),
408 branch_tips.get(&name).cloned(),
409 )?;
410 let mut summary = ThreadListEntry::from_view(view, coordination_status);
411 summary.task_assignment_id = task_assignment_id;
412 summary.task_summary = task_summary;
413 if let Some(branch_tip) = branch_tips.get(&summary.name) {
414 summary.git_branch_tip = Some(branch_tip.git_commit.clone());
415 summary.history_imported = branch_tip.history_imported;
416 }
417 let has_heddle_tip = thread_ref_names.contains(&summary.name);
418 let needs_advice = summary.thread_state != Some(ThreadState::Active)
419 || summary.freshness == Some(ThreadFreshness::Stale)
420 || !summary.changed_paths.is_empty()
421 || summary.promotion_suggested;
422 if needs_advice {
423 let thread = Thread {
424 id: summary.name.clone(),
425 thread: summary.name.clone(),
426 target_thread: summary.target_thread.clone(),
427 parent_thread: summary.parent_thread.clone(),
428 mode: summary
429 .thread_mode
430 .clone()
431 .unwrap_or(ThreadMode::Materialized),
432 state: summary.thread_state.clone().unwrap_or(ThreadState::Active),
433 base_state: summary.base_state.clone().unwrap_or_default(),
434 base_root: summary.base_root.clone().unwrap_or_default(),
435 current_state: summary.current_state.clone(),
436 merged_state: None,
437 task: summary.task.clone(),
438 execution_path: summary
439 .execution_path
440 .as_ref()
441 .map(PathBuf::from)
442 .unwrap_or_else(|| repo.root().to_path_buf()),
443 materialized_path: summary.path.as_ref().map(PathBuf::from),
444 changed_paths: summary.changed_paths.clone(),
445 impact_categories: summary.impact_categories.clone(),
446 heavy_impact_paths: summary.heavy_impact_paths.clone(),
447 promotion_suggested: summary.promotion_suggested,
448 freshness: summary
449 .freshness
450 .clone()
451 .unwrap_or(ThreadFreshness::Unknown),
452 verification_summary: summary.verification_summary.clone(),
453 confidence_summary: summary.confidence_summary.clone(),
454 integration_policy_result: summary.integration_policy_result.clone(),
455 created_at: Utc::now(),
456 updated_at: Utc::now(),
457 ephemeral: None,
458 auto: summary.auto,
459 shared_target_dir: summary.shared_target_dir.as_ref().map(PathBuf::from),
460 };
461 let advice = describe_thread_advice(&thread, false, 0, false);
462 summary.thread_health = advice.thread_health;
463 summary.blockers = advice.blockers;
464 summary.recommended_action = advice.recommended_action;
465 }
466 apply_terminal_thread_advice(&mut summary);
467 apply_materialized_merge_advice(repo, &mut summary);
468 if let Some(branch_tip) = branch_tips.get(&summary.name)
469 && !has_heddle_tip
470 {
471 summary.blockers.clear();
472 summary.thread_health = if branch_tip.history_imported {
473 "imported".to_string()
474 } else {
475 "git_backed".to_string()
476 };
477 if summary.is_current {
478 summary.recommended_action.clear();
479 } else {
480 summary.recommended_action = if branch_tip.branch.starts_with('-') {
481 format!(
482 "heddle thread switch -- {}",
483 shell_quote(&branch_tip.branch)
484 )
485 } else {
486 format!("heddle thread switch {}", shell_quote(&branch_tip.branch))
487 };
488 }
489 }
490 if repo.capability() == repo::RepositoryCapability::GitOverlay
491 && summary.history_imported
492 && summary.current_state.is_some()
493 && remote_tracking_local_ref(repo, &summary.name).is_some()
494 {
495 summary.thread_health = "remote_tracking".to_string();
496 summary.coordination_status = CoordinationStatus::Clean;
497 summary.blockers.clear();
498 summary.recommended_action =
499 canonical_git_repair_ref_preview_command(None, &summary.name);
500 }
501 if summary.is_current {
502 enrich_current_summary_with_dirty_paths(repo, &mut summary, worktree_status)?;
503 summary.operation = operation.clone();
504 summary.remote_tracking = remote_tracking.clone();
505 summary.recommended_action = effective_next_action(
506 NextActionInput::default(
507 operation.as_ref(),
508 remote_tracking.as_ref(),
509 import_hint.as_ref(),
510 Some(&summary.recommended_action),
511 )
512 .with_source_authority(repo.source_authority())
513 .current_thread(Some(&summary.thread_health)),
514 );
515 summary.recommended_action = contextual_thread_action(
516 repo,
517 &summary.name,
518 summary.target_thread.as_deref(),
519 &summary.recommended_action,
520 );
521 }
522 summaries.push(summary);
523 }
524
525 let mut children_by_parent: HashMap<String, Vec<String>> = HashMap::new();
526 for summary in &summaries {
527 if let Some(parent) = &summary.parent_thread {
528 children_by_parent
529 .entry(parent.clone())
530 .or_default()
531 .push(summary.name.clone());
532 }
533 }
534 for summary in &mut summaries {
535 if let Some(children) = children_by_parent.get(&summary.name) {
536 let mut children = children.clone();
537 children.sort();
538 summary.child_threads = children;
539 }
540 }
541
542 let parents_by_name = summaries
543 .iter()
544 .map(|summary| (summary.name.clone(), summary.parent_thread.clone()))
545 .collect::<HashMap<_, _>>();
546 for summary in &mut summaries {
547 summary.sibling_threads = summary
548 .parent_thread
549 .as_ref()
550 .and_then(|parent| children_by_parent.get(parent))
551 .map(|children| {
552 children
553 .iter()
554 .filter(|child| *child != &summary.name)
555 .cloned()
556 .collect()
557 })
558 .unwrap_or_default();
559 summary.stack_depth = stack_depth(&parents_by_name, &summary.name);
560 summary.stale_from_parent =
561 summary.parent_thread.is_some() && summary.freshness == Some(ThreadFreshness::Stale);
562 if summary.last_progress_at.is_some() {
563 summary.last_activity_at = summary.last_progress_at.clone();
564 }
565 summary.recommended_action_template = action_template(&summary.recommended_action);
566 }
567
568 summaries.sort_by(|a, b| a.name.cmp(&b.name));
569 Ok(summaries)
570}
571
572pub fn find_thread_summary(repo: &Repository, name: &str) -> Result<Option<ThreadListEntry>> {
574 Ok(collect_thread_summaries(repo)?
575 .into_iter()
576 .find(|summary| summary.name == name))
577}
578
579pub fn split_available_git_refs(summaries: &mut Vec<ThreadListEntry>) -> Vec<AvailableGitRef> {
581 let mut available = Vec::new();
582 summaries.retain(|summary| {
583 if thread_is_available_git_ref(summary) {
584 available.push(available_git_ref_from_summary(summary));
585 false
586 } else {
587 true
588 }
589 });
590 available
591}
592
593pub fn thread_is_imported_git_ref(entry: &ThreadListEntry) -> bool {
595 !entry.is_current
596 && entry.path.is_none()
597 && entry.execution_path.is_none()
598 && entry.target_thread.is_none()
599 && entry.current_state.is_some()
600 && entry.history_imported
601 && (entry.git_branch_tip.is_some() || entry.name.starts_with("origin/"))
602}
603
604pub fn thread_is_available_git_ref(entry: &ThreadListEntry) -> bool {
606 !entry.is_current
607 && entry.path.is_none()
608 && entry.execution_path.is_none()
609 && entry.target_thread.is_none()
610 && entry.current_state.is_none()
611 && entry.git_branch_tip.is_some()
612}
613
614pub fn visibility_label(mode: &ThreadMode) -> &'static str {
616 match mode {
617 ThreadMode::Materialized => "materialized",
618 ThreadMode::Virtualized => "virtualized",
619 ThreadMode::Solid => "solid",
620 }
621}
622
623fn available_git_ref_from_summary(summary: &ThreadListEntry) -> AvailableGitRef {
624 AvailableGitRef {
625 name: summary.name.clone(),
626 git_commit: summary.git_branch_tip.clone().unwrap_or_default(),
627 recommended_action: summary.recommended_action.clone(),
628 recommended_action_template: summary
629 .recommended_action_template
630 .clone()
631 .or_else(|| action_template(&summary.recommended_action)),
632 }
633}
634
635fn enrich_current_summary_with_dirty_paths(
636 repo: &Repository,
637 summary: &mut ThreadListEntry,
638 worktree_status: Option<&WorktreeStatus>,
639) -> Result<()> {
640 let status = match worktree_status {
641 Some(status) => status.clone(),
642 None => {
643 let baseline = match repo.current_state_for_worktree_status()? {
644 Some(state) => repo.require_tree_for_worktree_status(&state.tree)?,
645 None => Tree::new(),
646 };
647 let options = UserConfig::default().worktree_status_options(Some(repo.config()));
648 repo.compare_worktree_cached_with_options(&baseline, &options)?
649 }
650 };
651 let mut paths = summary
652 .changed_paths
653 .iter()
654 .cloned()
655 .collect::<BTreeSet<_>>();
656 paths.extend(
657 status
658 .modified
659 .iter()
660 .chain(status.added.iter())
661 .chain(status.deleted.iter())
662 .map(|path| path.to_string_lossy().to_string()),
663 );
664 summary.changed_paths = paths.into_iter().collect();
665 Ok(())
666}
667
668fn stack_depth(parents_by_name: &HashMap<String, Option<String>>, thread: &str) -> usize {
669 let mut depth = 0usize;
670 let mut cursor = parents_by_name.get(thread).cloned().flatten();
671 while let Some(parent) = cursor {
672 depth += 1;
673 cursor = parents_by_name.get(&parent).cloned().flatten();
674 }
675 depth
676}
677
678fn primary_agent_entry(entries: &[ActorPresence]) -> Option<&ActorPresence> {
679 entries
680 .iter()
681 .filter(|entry| entry.status == ActorPresenceStatus::Active)
682 .max_by_key(|entry| entry.started_at)
683 .or_else(|| entries.iter().max_by_key(|entry| entry.started_at))
684}
685
686fn task_assignment_id_from_entries(entries: &[ActorPresence]) -> Option<String> {
687 primary_agent_entry(entries).and_then(|entry| entry.task_assignment_id.clone())
688}
689
690fn task_summary_for_assignment(
691 store: &AgentTaskStore,
692 task_assignment_id: Option<&str>,
693) -> Result<Option<ThreadTaskSummary>> {
694 let Some(task_assignment_id) = task_assignment_id else {
695 return Ok(None);
696 };
697 Ok(store
698 .load(task_assignment_id)?
699 .as_ref()
700 .map(ThreadTaskSummary::from))
701}
702
703fn build_thread_view(
704 repo: &Repository,
705 is_current: bool,
706 name: String,
707 ref_state: Option<objects::object::StateId>,
708 entries: Vec<ActorPresence>,
709 thread: Option<Thread>,
710 branch_tip: Option<GitOverlayBranchTip>,
711) -> Result<(ThreadView, CoordinationStatus)> {
712 let current_state = ref_state
713 .or_else(|| {
714 (is_current && repo.capability() == repo::RepositoryCapability::GitOverlay)
715 .then(|| {
716 branch_tip
717 .as_ref()
718 .and_then(|tip| tip.mapped_state)
719 .or_else(|| {
720 repo.git_overlay_mapped_state_for_branch(&name)
721 .ok()
722 .flatten()
723 })
724 })
725 .flatten()
726 })
727 .map(|id| id.short());
728 let has_heddle_tip = current_state.is_some();
729 let active: Vec<&ActorPresence> = entries
730 .iter()
731 .filter(|entry| entry.status == ActorPresenceStatus::Active)
732 .collect();
733 let complete: Vec<&ActorPresence> = entries
734 .iter()
735 .filter(|entry| entry.status == ActorPresenceStatus::Complete)
736 .collect();
737
738 let primary = active
739 .iter()
740 .max_by_key(|entry| entry.started_at)
741 .copied()
742 .or_else(|| entries.iter().max_by_key(|entry| entry.started_at));
743 let base_state = thread
744 .as_ref()
745 .map(|thread| thread.base_state.clone())
746 .or_else(|| primary.map(|entry| entry.base_state.clone()))
747 .or(current_state.clone());
748 let base_root = thread.as_ref().map(|thread| thread.base_root.clone());
749 let runtime = ThreadRuntimeOverlay {
750 path: thread
751 .as_ref()
752 .and_then(|thread| thread.materialized_path.clone())
753 .or_else(|| primary.and_then(|entry| entry.path.clone())),
754 execution_path: thread.as_ref().map(|thread| thread.execution_path.clone()),
755 materialized_path: thread
756 .as_ref()
757 .and_then(|thread| thread.materialized_path.clone()),
758 session_id: primary.map(|entry| entry.session_id.clone()),
759 heddle_session_id: primary.and_then(|entry| entry.heddle_session_id.clone()),
760 harness: primary.and_then(|entry| entry.harness.clone()),
761 thinking_level: primary.and_then(|entry| entry.thinking_level.clone()),
762 native_actor_key: primary.and_then(|entry| entry.native_actor_key.clone()),
763 native_parent_actor_key: primary.and_then(|entry| entry.native_parent_actor_key.clone()),
764 probe_source: primary.and_then(|entry| entry.probe_source.clone()),
765 probe_confidence: primary.and_then(|entry| entry.probe_confidence),
766 usage_summary: primary.map(|entry| entry.usage_summary.clone()),
767 last_progress_at: primary.and_then(|entry| entry.last_progress_at),
768 report_flush_state: primary.and_then(|entry| entry.report_flush_state.clone()),
769 attach_reason: primary.and_then(|entry| entry.attach_reason.clone()),
770 provider: primary.and_then(|entry| entry.provider.clone()),
771 model: primary.and_then(|entry| entry.model.clone()),
772 thread_mode: thread.as_ref().map(|thread| thread.mode.clone()),
773 thread_state: thread.as_ref().map(|thread| thread.state.clone()),
774 };
775 let thread_record = thread.as_ref().map(|thread| thread.to_record());
776 let thread_state_for_status = thread_record.as_ref().map(|thread| thread.state.clone());
777 let coordination_status = if matches!(
778 thread_state_for_status,
779 Some(ThreadState::Merged | ThreadState::Abandoned)
780 ) {
781 CoordinationStatus::Clean
782 } else if thread_state_for_status == Some(ThreadState::Blocked) {
783 CoordinationStatus::Blocked
784 } else if thread_state_for_status == Some(ThreadState::Ready) {
785 CoordinationStatus::MergeReady
786 } else if active.len() > 1 {
787 CoordinationStatus::Blocked
788 } else if !active.is_empty()
789 && complete
790 .iter()
791 .any(|entry| entry.base_state != active[0].base_state)
792 {
793 CoordinationStatus::Diverged
794 } else if !complete.is_empty() {
795 CoordinationStatus::MergeReady
796 } else if base_state.is_some() && current_state.is_some() && base_state != current_state {
797 CoordinationStatus::Ahead
798 } else {
799 CoordinationStatus::Clean
800 };
801
802 let view = match thread {
803 Some(mut thread) => {
804 thread.current_state = current_state;
805 thread.to_view(runtime, is_current)
806 }
807 None => ThreadView::from_record(
808 repo::ThreadRecord {
809 id: name.clone(),
810 thread: name.clone(),
811 target_thread: None,
812 parent_thread: None,
813 mode: ThreadMode::Materialized,
814 state: ThreadState::Active,
815 base_state: base_state.unwrap_or_default(),
816 base_root: base_root.unwrap_or_default(),
817 current_state,
818 merged_state: None,
819 task: None,
820 changed_paths: Vec::new(),
821 impact_categories: Vec::new(),
822 heavy_impact_paths: Vec::new(),
823 promotion_suggested: false,
824 freshness: ThreadFreshness::Unknown,
825 verification_summary: Default::default(),
826 confidence_summary: Default::default(),
827 integration_policy_result: Default::default(),
828 created_at: Utc::now(),
829 updated_at: Utc::now(),
830 ephemeral: None,
831 auto: false,
832 shared_target_dir: None,
833 },
834 runtime,
835 is_current,
836 ),
837 };
838
839 if let Some(branch_tip) = branch_tip
840 && !has_heddle_tip
841 && view.record.current_state.is_none()
842 {
843 let mut record = view.record.clone();
844 record.current_state = None;
845 let mut runtime = view.runtime.clone();
846 if runtime.attach_reason.is_none() {
847 runtime.attach_reason = Some(format!(
848 "using Git-backed branch tip {}",
849 branch_tip.git_commit
850 ));
851 }
852 return Ok((
853 ThreadView::from_record(record, runtime, is_current),
854 coordination_status,
855 ));
856 }
857
858 Ok((view, coordination_status))
859}
860
861fn apply_materialized_merge_advice(repo: &Repository, summary: &mut ThreadListEntry) {
862 let Some(action) = materialized_merge_resolve_action(repo, summary) else {
863 return;
864 };
865 summary.thread_health = "blocked".to_string();
866 if summary.blockers.is_empty() {
867 summary
868 .blockers
869 .push("Merge conflicts need resolution".to_string());
870 }
871 summary.recommended_action = action;
872 summary.recommended_action_template = action_template(&summary.recommended_action);
873}
874
875fn materialized_merge_resolve_action(
876 repo: &Repository,
877 summary: &ThreadListEntry,
878) -> Option<String> {
879 if let Some(path) = summary.execution_path.as_deref() {
880 let path = PathBuf::from(path);
881 if !path.exists() {
882 return None;
883 }
884 let thread_repo = Repository::open(&path).ok()?;
885 return thread_repo
886 .merge_state_manager()
887 .is_merge_in_progress()
888 .then(|| {
889 heddle_action(vec![
890 "--repo".to_string(),
891 path.display().to_string(),
892 "resolve".to_string(),
893 "--list".to_string(),
894 ])
895 });
896 }
897
898 (summary.is_current && repo.merge_state_manager().is_merge_in_progress())
899 .then(|| heddle_action(["resolve", "--list"]))
900}
901
902fn apply_terminal_thread_advice(summary: &mut ThreadListEntry) {
903 match summary.thread_state {
904 Some(ThreadState::Merged) => {
905 summary.thread_health = "clean".to_string();
906 summary.blockers.clear();
907 summary.recommended_action = "heddle thread cleanup --merged --dry-run".to_string();
908 summary.coordination_status = CoordinationStatus::Clean;
909 }
910 Some(ThreadState::Abandoned) => {
911 summary.thread_health = "clean".to_string();
912 summary.blockers.clear();
913 summary.recommended_action.clear();
914 summary.coordination_status = CoordinationStatus::Clean;
915 }
916 _ => {}
917 }
918}
919
920fn remote_tracking_local_ref(repo: &Repository, thread_name: &str) -> Option<String> {
921 let git = SleyRepository::discover(repo.root()).ok()?;
922 let remotes = git.remote_names().ok()?;
923 remotes
924 .iter()
925 .find_map(|remote| thread_name.strip_prefix(&format!("{remote}/")))
926 .filter(|branch| !branch.is_empty())
927 .map(str::to_string)
928}
929
930#[cfg(test)]
931mod tests {
932 use chrono::Utc;
933 use objects::object::ThreadName;
934 use repo::{
935 Thread, ThreadConfidenceSummary, ThreadFreshness, ThreadIntegrationPolicy, ThreadManager,
936 ThreadMode, ThreadState, ThreadVerificationSummary,
937 };
938 use tempfile::TempDir;
939
940 use super::*;
941
942 fn sample_thread(name: &str, auto: bool) -> Thread {
943 Thread {
944 id: name.to_string(),
945 thread: name.to_string(),
946 target_thread: None,
947 parent_thread: None,
948 mode: ThreadMode::Materialized,
949 state: ThreadState::Active,
950 base_state: String::new(),
951 base_root: String::new(),
952 current_state: None,
953 merged_state: None,
954 task: None,
955 execution_path: PathBuf::from("/tmp"),
956 materialized_path: None,
957 changed_paths: Vec::new(),
958 impact_categories: Vec::new(),
959 heavy_impact_paths: Vec::new(),
960 promotion_suggested: false,
961 freshness: ThreadFreshness::Unknown,
962 verification_summary: ThreadVerificationSummary::default(),
963 confidence_summary: ThreadConfidenceSummary::default(),
964 integration_policy_result: ThreadIntegrationPolicy::default(),
965 created_at: Utc::now(),
966 updated_at: Utc::now(),
967 ephemeral: None,
968 auto,
969 shared_target_dir: None,
970 }
971 }
972
973 #[test]
974 fn list_threads_empty_repo_returns_empty_domain_lists() {
975 let temp = TempDir::new().unwrap();
976 let repo = Repository::init_default(temp.path()).unwrap();
977 let report = list_threads(&repo, ThreadListOptions::new()).unwrap();
980 assert_eq!(report.output_kind, "thread_list");
981 assert!(report.available_git_refs.is_empty());
982 assert!(
984 report
985 .threads
986 .iter()
987 .all(|t| t.name == "main" || t.is_current),
988 "unexpected threads: {:?}",
989 report.threads.iter().map(|t| &t.name).collect::<Vec<_>>()
990 );
991 let value = serde_json::to_value(&report).unwrap();
992 assert_eq!(value["output_kind"], "thread_list");
993 assert!(value["threads"].is_array());
994 assert!(value["available_git_refs"].is_array());
995 }
996
997 #[test]
998 fn list_threads_sorts_by_name() {
999 let temp = TempDir::new().unwrap();
1000 let repo = Repository::init_default(temp.path()).unwrap();
1001 let manager = ThreadManager::new(repo.heddle_dir());
1002 for name in ["zeta", "alpha", "mid"] {
1003 manager.save(&sample_thread(name, false)).unwrap();
1004 let _ = repo.refs().set_thread(
1005 &ThreadName::new(name),
1006 &objects::object::StateId::from_bytes([0u8; 32]),
1007 );
1008 }
1009
1010 let report = list_threads(&repo, ThreadListOptions::new().include_auto(true)).unwrap();
1011 let names: Vec<&str> = report.threads.iter().map(|t| t.name.as_str()).collect();
1012 let mut sorted = names.clone();
1013 sorted.sort();
1014 assert_eq!(names, sorted, "thread list must be sorted by name");
1015 assert!(names.contains(&"alpha"));
1016 assert!(names.contains(&"mid"));
1017 assert!(names.contains(&"zeta"));
1018 }
1019
1020 #[test]
1021 fn list_threads_hides_auto_unless_include_auto_or_current() {
1022 let temp = TempDir::new().unwrap();
1023 let repo = Repository::init_default(temp.path()).unwrap();
1024 let manager = ThreadManager::new(repo.heddle_dir());
1025 manager.save(&sample_thread("user-feature", false)).unwrap();
1026 manager
1027 .save(&sample_thread("harness-session", true))
1028 .unwrap();
1029
1030 let filtered = list_threads(&repo, ThreadListOptions::new()).unwrap();
1031 let filtered_names: Vec<&str> = filtered.threads.iter().map(|t| t.name.as_str()).collect();
1032 assert!(
1033 filtered_names.contains(&"user-feature"),
1034 "user thread should remain: {filtered_names:?}"
1035 );
1036 assert!(
1037 !filtered_names.contains(&"harness-session"),
1038 "auto thread should be hidden by default: {filtered_names:?}"
1039 );
1040
1041 let all = list_threads(&repo, ThreadListOptions::new().include_auto(true)).unwrap();
1042 let all_names: Vec<&str> = all.threads.iter().map(|t| t.name.as_str()).collect();
1043 assert!(
1044 all_names.contains(&"harness-session"),
1045 "include_auto must surface harness thread: {all_names:?}"
1046 );
1047 }
1048
1049 #[test]
1050 fn available_git_ref_serializes_empty_recommended_action_as_null() {
1051 let value = serde_json::to_value(AvailableGitRef {
1052 name: "main".to_string(),
1053 git_commit: "0123456789abcdef".to_string(),
1054 recommended_action: String::new(),
1055 recommended_action_template: None,
1056 })
1057 .unwrap();
1058 assert!(value["recommended_action"].is_null());
1059 }
1060
1061 #[test]
1062 fn split_available_git_refs_moves_git_only_tips() {
1063 let mut summaries = vec![
1064 ThreadListEntry {
1065 name: "feature".into(),
1066 operation: None,
1067 remote_tracking: None,
1068 base_state: None,
1069 base_root: None,
1070 current_state: Some("abc".into()),
1071 path: None,
1072 execution_path: None,
1073 session_id: None,
1074 heddle_session_id: None,
1075 actor: None,
1076 harness: None,
1077 thinking_level: None,
1078 native_actor_key: None,
1079 native_parent_actor_key: None,
1080 probe_source: None,
1081 probe_confidence: None,
1082 usage_summary: None,
1083 last_progress_at: None,
1084 last_activity_at: None,
1085 report_flush_state: None,
1086 attach_reason: None,
1087 thread_mode: None,
1088 thread_state: None,
1089 freshness: None,
1090 visibility: "ref_only".into(),
1091 target_thread: None,
1092 parent_thread: None,
1093 child_threads: vec![],
1094 sibling_threads: vec![],
1095 stack_depth: 0,
1096 stale_from_parent: false,
1097 task: None,
1098 task_assignment_id: None,
1099 task_summary: None,
1100 changed_paths: vec![],
1101 promotion_suggested: false,
1102 impact_categories: vec![],
1103 heavy_impact_paths: vec![],
1104 verification_summary: Default::default(),
1105 confidence_summary: Default::default(),
1106 integration_policy_result: Default::default(),
1107 coordination_status: CoordinationStatus::Clean,
1108 is_current: false,
1109 is_isolated: false,
1110 thread_health: "clean".into(),
1111 blockers: vec![],
1112 recommended_action: String::new(),
1113 recommended_action_template: None,
1114 git_branch_tip: None,
1115 history_imported: true,
1116 auto: false,
1117 shared_target_dir: None,
1118 },
1119 ThreadListEntry {
1120 name: "origin/main".into(),
1121 operation: None,
1122 remote_tracking: None,
1123 base_state: None,
1124 base_root: None,
1125 current_state: None,
1126 path: None,
1127 execution_path: None,
1128 session_id: None,
1129 heddle_session_id: None,
1130 actor: None,
1131 harness: None,
1132 thinking_level: None,
1133 native_actor_key: None,
1134 native_parent_actor_key: None,
1135 probe_source: None,
1136 probe_confidence: None,
1137 usage_summary: None,
1138 last_progress_at: None,
1139 last_activity_at: None,
1140 report_flush_state: None,
1141 attach_reason: None,
1142 thread_mode: None,
1143 thread_state: None,
1144 freshness: None,
1145 visibility: "ref_only".into(),
1146 target_thread: None,
1147 parent_thread: None,
1148 child_threads: vec![],
1149 sibling_threads: vec![],
1150 stack_depth: 0,
1151 stale_from_parent: false,
1152 task: None,
1153 task_assignment_id: None,
1154 task_summary: None,
1155 changed_paths: vec![],
1156 promotion_suggested: false,
1157 impact_categories: vec![],
1158 heavy_impact_paths: vec![],
1159 verification_summary: Default::default(),
1160 confidence_summary: Default::default(),
1161 integration_policy_result: Default::default(),
1162 coordination_status: CoordinationStatus::Clean,
1163 is_current: false,
1164 is_isolated: false,
1165 thread_health: "git_backed".into(),
1166 blockers: vec![],
1167 recommended_action: "heddle thread switch origin/main".into(),
1168 recommended_action_template: None,
1169 git_branch_tip: Some("deadbeef".into()),
1170 history_imported: false,
1171 auto: false,
1172 shared_target_dir: None,
1173 },
1174 ];
1175
1176 let available = split_available_git_refs(&mut summaries);
1177 assert_eq!(summaries.len(), 1);
1178 assert_eq!(summaries[0].name, "feature");
1179 assert_eq!(available.len(), 1);
1180 assert_eq!(available[0].name, "origin/main");
1181 assert_eq!(available[0].git_commit, "deadbeef");
1182 }
1183}