Skip to main content

heddle_core/
thread.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Thread list/show domain: collection, auto filter, and Git-ref splitting.
3//!
4//! Owns the rich thread summary assembly used by `heddle thread list` /
5//! `heddle thread show` (git branch tips, task assignment, recommended
6//! action, auto flag). CLI opens the repo, calls [`list_threads`] /
7//! [`find_thread_summary`], then attaches verification and renders.
8//!
9//! Status keeps its own thinner [`crate::status::StatusThreadSummary`]
10//! path; the two share underlying repo primitives but not the same
11//! report shape.
12
13use 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/// Options for [`list_threads`].
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
52pub struct ThreadListOptions {
53    /// When `false` (default for CLI), harness-created (`auto`) threads are
54    /// omitted unless they are the current checkout lane.
55    pub include_auto: bool,
56    /// When `false` (default for CLI), abandoned threads are omitted unless
57    /// they are the current checkout lane.
58    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/// Machine domain report for `heddle thread list` (threads + git-only refs).
78///
79/// Presentation fields (repository label/context, verification, top-level
80/// recommended action) stay on the CLI attach path so JSON output_kind
81/// contract for the domain rows remains stable here.
82#[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/// One thread row for list/show machine output.
91///
92/// Field names match the historical CLI `ThreadSummary` JSON contract.
93#[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    /// Mirror of [`repo::ThreadRecord::auto`]. `true` when the thread
154    /// was created by a harness integration rather than an explicit
155    /// user verb. Used by `heddle thread list` (default-hides) and
156    /// `heddle thread cleanup --auto`.
157    pub auto: bool,
158    /// Mirror of [`repo::ThreadRecord::shared_target_dir`].
159    pub shared_target_dir: Option<String>,
160}
161
162/// Stable alias matching historical CLI naming (`ThreadSummary`).
163pub type ThreadSummary = ThreadListEntry;
164
165/// Git-only branch tip that is not yet a Heddle thread tip.
166#[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/// Actor attribution nested on a thread list entry.
176#[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/// Assigned agent task nested on a thread list entry.
185#[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
300/// Collect, filter, and split thread list domain for an opened repository.
301pub fn list_threads(repo: &Repository, options: ThreadListOptions) -> Result<ThreadListReport> {
302    list_threads_inner(repo, options, None)
303}
304
305/// List threads while reusing a worktree comparison already required by the
306/// caller's verification envelope.
307pub 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        // Always keep the current thread visible even if it's auto:
323        // hiding it from the user who is *standing in it* would be
324        // worse than the noise it adds.
325        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
346/// Collect full thread summaries (no auto filter / git-ref split).
347pub 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
572/// Look up a single thread summary by name.
573pub 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
579/// Move available Git-only branch tips out of the main thread list.
580pub 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
593/// True when the entry is an imported Git branch already mapped into Heddle.
594pub 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
604/// True when the entry is a Git branch tip without a Heddle tip.
605pub 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
614/// Visibility label for an isolated thread mode.
615pub 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        // init_default may seed `main`; filter to pure empty by checking
978        // that list_threads always succeeds and reports output_kind.
979        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        // A fresh default repo may include the default lane only.
983        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}