use std::{
collections::{BTreeSet, HashMap},
path::{Path, PathBuf},
process,
};
use anyhow::{Context, Result, anyhow};
use chrono::{DateTime, Utc};
use objects::{
object::{ChangeId, State, ThreadName, Tree},
store::{AgentEntry, AgentRegistry, AgentStatus, ObjectStore, current_boot_id},
};
use oplog::OpLogRecorder;
use refs::{Head, RefExpectation, RefUpdate};
use repo::{
AgentUsageSummary, GitOverlayBranchTip, GitOverlayImportHint, GitRemoteTrackingStatus,
Repository, RepositoryOperationStatus, Thread, ThreadCaptureOutcome, ThreadConfidenceSummary,
ThreadFreshness, ThreadId, ThreadIdError, ThreadImpactCategory, ThreadIntegrationPolicy,
ThreadManager, ThreadMode, ThreadRuntimeOverlay, ThreadState, ThreadVerificationSummary,
ThreadView, describe_thread_advice,
};
use serde::Serialize;
use sley::Repository as SleyRepository;
use super::{
action_line::{print_nested_next_step, print_nested_optional, print_next_step, print_optional},
advice::RecoveryAdvice,
command_catalog::{ActionTemplate, heddle_action, recommended_action_template},
git_overlay_health::{
GitOverlayMutationPreflight, RepositoryVerificationState,
build_repository_verification_state, canonical_adopt_ref_command,
canonical_bridge_reconcile_ref_preview_command, git_overlay_mutation_preflight_advice,
override_trust_recommended_action, serialize_empty_action_as_null,
},
mount_lifecycle,
next_action::{
NextActionInput, NextActionValidationContext, effective_next_action,
thread_recovery_action_is_primary as shared_thread_recovery_action_is_primary,
write_full_command_json,
},
operator_loop::{primary_next_action, primary_next_action_with_verification},
snapshot::{ensure_current_state, summarize_confidence, summarize_verification},
start_atomic,
thread_cmd::{refresh_thread_freshness, thread_not_found_advice},
worktree_cmd::{
helpers::{plan_worktree_target, write_isolated_checkout},
shared_target,
},
worktree_safety::ensure_worktree_clean,
};
use crate::{
cli::{
Cli, ThreadListArgs, ThreadStartArgs, WorkspaceModeArg, should_output_json, style,
worktree_status_options,
},
config::{UserConfig, UserThreadWorkspaceMode},
};
pub(crate) const DEFAULT_AVAILABLE_GIT_REF_LIMIT: usize = 5;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum CoordinationStatus {
Clean,
Ahead,
Diverged,
Blocked,
MergeReady,
}
impl std::fmt::Display for CoordinationStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Clean => write!(f, "clean"),
Self::Ahead => write!(f, "ahead"),
Self::Diverged => write!(f, "diverged"),
Self::Blocked => write!(f, "blocked"),
Self::MergeReady => write!(f, "merge-ready"),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ThreadSummary {
pub name: String,
pub operation: Option<RepositoryOperationStatus>,
pub remote_tracking: Option<GitRemoteTrackingStatus>,
pub base_state: Option<String>,
pub base_root: Option<String>,
pub current_state: Option<String>,
pub path: Option<String>,
pub execution_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub heddle_session_id: Option<String>,
pub actor: Option<ThreadActorInfo>,
pub harness: Option<String>,
pub thinking_level: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub native_actor_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub native_parent_actor_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub probe_source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub probe_confidence: Option<f32>,
pub usage_summary: Option<AgentUsageSummary>,
pub last_progress_at: Option<String>,
pub last_activity_at: Option<String>,
pub report_flush_state: Option<String>,
pub attach_reason: Option<String>,
pub thread_mode: Option<ThreadMode>,
pub thread_state: Option<ThreadState>,
pub freshness: Option<ThreadFreshness>,
pub visibility: String,
pub target_thread: Option<String>,
pub parent_thread: Option<String>,
pub child_threads: Vec<String>,
pub sibling_threads: Vec<String>,
pub stack_depth: usize,
pub stale_from_parent: bool,
pub task: Option<String>,
pub changed_paths: Vec<String>,
pub promotion_suggested: bool,
pub impact_categories: Vec<ThreadImpactCategory>,
pub heavy_impact_paths: Vec<String>,
pub verification_summary: ThreadVerificationSummary,
pub confidence_summary: ThreadConfidenceSummary,
pub integration_policy_result: ThreadIntegrationPolicy,
pub coordination_status: CoordinationStatus,
pub is_current: bool,
pub is_isolated: bool,
pub thread_health: String,
pub blockers: Vec<String>,
#[serde(serialize_with = "serialize_empty_action_as_null")]
pub recommended_action: String,
pub recommended_action_template: Option<ActionTemplate>,
pub git_branch_tip: Option<String>,
pub history_imported: bool,
pub auto: bool,
pub shared_target_dir: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct AvailableGitRef {
pub name: String,
pub git_commit: String,
#[serde(serialize_with = "serialize_empty_action_as_null")]
pub recommended_action: String,
pub recommended_action_template: Option<ActionTemplate>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ThreadActorInfo {
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
impl ThreadSummary {
fn from_view(view: ThreadView, coordination_status: CoordinationStatus) -> Self {
let mode = view.record.mode.clone();
ThreadSummary {
name: view.record.thread,
operation: None,
remote_tracking: None,
base_state: Some(view.record.base_state),
base_root: Some(view.record.base_root),
current_state: view.record.current_state,
path: view
.runtime
.materialized_path
.as_ref()
.or(view.runtime.path.as_ref())
.and_then(|p| display_path_string(p)),
execution_path: view
.runtime
.execution_path
.as_ref()
.and_then(|p| display_path_string(p)),
session_id: view.runtime.session_id,
heddle_session_id: view.runtime.heddle_session_id,
actor: match (view.runtime.provider, view.runtime.model) {
(None, None) => None,
(provider, model) => Some(ThreadActorInfo { provider, model }),
},
harness: view.runtime.harness,
thinking_level: view.runtime.thinking_level,
native_actor_key: view.runtime.native_actor_key,
native_parent_actor_key: view.runtime.native_parent_actor_key,
probe_source: view.runtime.probe_source,
probe_confidence: view.runtime.probe_confidence,
usage_summary: view.runtime.usage_summary,
last_progress_at: view.runtime.last_progress_at.map(|ts| ts.to_rfc3339()),
last_activity_at: Some(view.record.updated_at.to_rfc3339()),
report_flush_state: view.runtime.report_flush_state,
attach_reason: view.runtime.attach_reason,
thread_mode: Some(mode.clone()),
thread_state: Some(view.record.state),
freshness: Some(view.record.freshness),
visibility: if view.is_isolated {
visibility_label(&mode).to_string()
} else {
"ref_only".to_string()
},
target_thread: view.record.target_thread,
parent_thread: view.record.parent_thread,
child_threads: Vec::new(),
sibling_threads: Vec::new(),
stack_depth: 0,
stale_from_parent: false,
task: view.record.task,
changed_paths: view.record.changed_paths,
promotion_suggested: view.record.promotion_suggested,
impact_categories: view.record.impact_categories,
heavy_impact_paths: view.record.heavy_impact_paths,
verification_summary: view.record.verification_summary,
confidence_summary: view.record.confidence_summary,
integration_policy_result: view.record.integration_policy_result,
coordination_status,
is_current: view.is_current,
is_isolated: view.is_isolated,
thread_health: "clean".to_string(),
blockers: Vec::new(),
recommended_action: String::new(),
recommended_action_template: None,
git_branch_tip: None,
history_imported: true,
auto: view.record.auto,
shared_target_dir: view
.record
.shared_target_dir
.as_ref()
.map(|p| p.display().to_string()),
}
}
}
fn display_path_string(path: &Path) -> Option<String> {
let rendered = path.display().to_string();
if rendered.trim().is_empty() {
None
} else {
Some(rendered)
}
}
#[derive(Serialize)]
struct ThreadListOutput {
output_kind: &'static str,
repository_capability: String,
repository_label: String,
#[serde(skip_serializing_if = "Option::is_none")]
repository_context: Option<crate::cli::render::RepositoryContextInfo>,
storage_model: String,
hosted_enabled: bool,
threads: Vec<ThreadSummary>,
available_git_refs: Vec<AvailableGitRef>,
current: Option<String>,
#[serde(rename = "verification")]
trust: RepositoryVerificationState,
#[serde(serialize_with = "serialize_empty_action_as_null")]
recommended_action: String,
recommended_action_template: Option<ActionTemplate>,
recovery_commands: Vec<String>,
recovery_action_templates: Vec<ActionTemplate>,
#[serde(skip)]
git_overlay_import_hint: Option<ThreadListGitOverlayImportHintOutput>,
}
#[derive(Serialize)]
struct ThreadListGitOverlayImportHintOutput {
current_branch: String,
missing_branch_count: usize,
missing_branches: Vec<String>,
recommended_command: String,
}
#[derive(Serialize)]
struct ThreadShowOutput {
output_kind: &'static str,
repository_label: String,
#[serde(skip_serializing_if = "Option::is_none")]
repository_context: Option<crate::cli::render::RepositoryContextInfo>,
#[serde(flatten)]
summary: ThreadSummary,
#[serde(serialize_with = "serialize_empty_action_as_null")]
next_action: String,
next_action_template: Option<ActionTemplate>,
recommended_action_template: Option<ActionTemplate>,
#[serde(rename = "verification")]
trust: RepositoryVerificationState,
recovery_commands: Vec<String>,
}
#[derive(Serialize)]
pub(crate) struct ThreadOpOutput {
pub output_kind: &'static str,
pub status: &'static str,
pub action: &'static str,
pub name: String,
pub message: String,
pub next_action: Option<String>,
pub next_action_template: Option<ActionTemplate>,
pub recommended_action: Option<String>,
pub recommended_action_template: Option<ActionTemplate>,
pub thread: Option<ThreadSummary>,
pub path: Option<String>,
pub execution_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fskit_readiness: Option<mount_lifecycle::FskitReadinessReport>,
#[allow(dead_code)]
#[serde(skip_serializing)]
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "verification")]
pub trust: Option<RepositoryVerificationState>,
}
#[derive(Serialize)]
pub(crate) struct ThreadCaptureOutput {
pub change_id: String,
pub created_at: String,
pub intent: Option<String>,
pub confidence: Option<f32>,
pub agent: Option<String>,
pub message: String,
pub summary: Option<ThreadCaptureSummary>,
}
#[derive(Serialize)]
pub(crate) struct ThreadCaptureSummary {
pub added: usize,
pub modified: usize,
pub deleted: usize,
pub total: usize,
}
pub fn cmd_start(cli: &Cli, args: ThreadStartArgs) -> Result<()> {
let repo = cli.open_repo()?;
if let Some(advice) = git_overlay_mutation_preflight_advice(
&repo,
"start",
GitOverlayMutationPreflight::capture_like(),
)? {
return Err(anyhow!(advice));
}
if args.path.is_some() {
ensure_worktree_clean(&repo, "start thread")?;
}
let print_cd = args.print_cd_path;
let output = start_thread(&repo, args)?;
if print_cd {
return render_cd_path(&output);
}
render_thread_op(cli, output)
}
fn render_cd_path(output: &ThreadOpOutput) -> Result<()> {
let thread_name = output
.thread
.as_ref()
.map(|t| t.name.clone())
.unwrap_or_else(|| output.name.clone());
let path = output
.thread
.as_ref()
.and_then(|t| t.path.as_deref())
.ok_or_else(|| {
anyhow!(RecoveryAdvice::thread_checkout_unavailable(
&thread_name,
"--print-cd-path",
))
})?;
println!("{path}");
Ok(())
}
pub(crate) fn cmd_thread_captures(
cli: &Cli,
repo: &Repository,
thread: &str,
limit: usize,
) -> Result<()> {
let captures = collect_thread_captures(repo, thread, limit)?;
if should_output_json(cli, Some(repo.config())) {
println!("{}", serde_json::to_string(&captures)?);
return Ok(());
}
println!("{}", style::section(&format!("Saved states on {thread}")));
if captures.is_empty() {
println!("{}", style::dim(" No saved states on this thread yet."));
return Ok(());
}
for capture in captures {
let confidence = capture
.confidence
.map(|value| format!("{value:.2}"))
.unwrap_or_else(|| "None".to_string());
println!(
" {} {} {}",
style::accent(&capture.change_id),
capture.message,
style::dim(&format!("confidence {confidence}"))
);
println!(" {}", style::dim(&capture.created_at));
if let Some(agent) = capture.agent {
println!(" {}", style::field("Agent", &agent));
}
}
Ok(())
}
fn collect_thread_captures(
repo: &Repository,
thread: &str,
limit: usize,
) -> Result<Vec<ThreadCaptureOutput>> {
let current = repo
.refs()
.get_thread(&ThreadName::new(thread))?
.ok_or_else(|| anyhow!(thread_not_found_advice(thread, "list thread captures")))?;
let base = ThreadManager::new(repo.heddle_dir())
.load(thread)?
.map(|thread| thread.base_state);
let mut out = Vec::new();
let mut cursor = Some(current);
while let Some(change_id) = cursor {
if base.as_deref() == Some(change_id.short().as_str())
|| base.as_deref().and_then(|base| ChangeId::parse(base).ok()) == Some(change_id)
{
break;
}
let Some(state) = repo.store().get_state(&change_id)? else {
break;
};
if state
.intent
.as_deref()
.is_some_and(|intent| !intent.starts_with("Bootstrap "))
{
let summary = capture_diff_summary(repo, &state);
out.push(thread_capture_output(&state, summary));
}
if out.len() >= limit {
break;
}
cursor = state.parents.first().copied();
}
Ok(out)
}
fn capture_diff_summary(repo: &Repository, state: &State) -> Option<ThreadCaptureSummary> {
let parent_id = state.parents.first().copied()?;
let parent = repo.store().get_state(&parent_id).ok().flatten()?;
let changes = repo.diff_trees(&parent.tree, &state.tree).ok()?;
Some(ThreadCaptureSummary {
added: changes.added_count(),
modified: changes.modified_count(),
deleted: changes.deleted_count(),
total: changes.len(),
})
}
fn thread_capture_output(
state: &State,
summary: Option<ThreadCaptureSummary>,
) -> ThreadCaptureOutput {
let agent = state
.attribution
.agent
.as_ref()
.map(|agent| format!("{}/{}", agent.provider, agent.model));
let message = state
.intent
.clone()
.unwrap_or_else(|| format!("Capture {}", state.change_id.short()));
ThreadCaptureOutput {
change_id: state.change_id.short(),
created_at: state.created_at.to_rfc3339(),
intent: state.intent.clone(),
confidence: state.confidence,
agent,
message,
summary,
}
}
pub fn collect_thread_summaries(repo: &Repository) -> Result<Vec<ThreadSummary>> {
let thread_refs = repo.refs().list_threads()?;
let current = repo.current_lane()?;
let operation = repo.operation_status()?;
let remote_tracking = repo.git_remote_tracking_status().unwrap_or(None);
let import_hint = repo.git_overlay_import_hint().unwrap_or(None);
let branch_tips = repo
.git_overlay_branch_tips()
.unwrap_or_default()
.into_iter()
.map(|tip| (tip.branch.clone(), tip))
.collect::<HashMap<_, _>>();
let registry = AgentRegistry::new(repo.heddle_dir());
let thread_manager = ThreadManager::new(repo.heddle_dir());
let mut entries_by_thread: HashMap<String, Vec<AgentEntry>> = HashMap::new();
let mut threads_by_name: HashMap<String, Thread> = HashMap::new();
for entry in registry.list()? {
entries_by_thread
.entry(entry.thread.clone())
.or_default()
.push(entry);
}
for mut thread in thread_manager.list()? {
if thread.state == ThreadState::Abandoned
&& repo
.refs()
.get_thread(&ThreadName::new(&thread.thread))?
.is_none()
{
continue;
}
refresh_thread_freshness(repo, &mut thread)?;
threads_by_name.insert(thread.thread.clone(), thread);
}
let mut names: BTreeSet<String> = thread_refs.iter().map(|t| t.to_string()).collect();
names.extend(current.iter().cloned());
names.extend(entries_by_thread.keys().cloned());
names.extend(threads_by_name.keys().cloned());
names.extend(branch_tips.keys().cloned());
let mut summaries = Vec::new();
for name in names {
let (view, coordination_status) = build_thread_view(
repo,
current.as_ref() == Some(&name),
name.clone(),
entries_by_thread.remove(&name).unwrap_or_default(),
threads_by_name.remove(&name),
branch_tips.get(&name).cloned(),
)?;
let mut summary = ThreadSummary::from_view(view, coordination_status);
if let Some(branch_tip) = branch_tips.get(&summary.name) {
summary.git_branch_tip = Some(branch_tip.git_commit.clone());
summary.history_imported = branch_tip.history_imported;
}
let has_heddle_tip = thread_refs.iter().any(|thread| thread == &summary.name);
let thread = Thread {
id: summary.name.clone(),
thread: summary.name.clone(),
target_thread: summary.target_thread.clone(),
parent_thread: summary.parent_thread.clone(),
mode: summary
.thread_mode
.clone()
.unwrap_or(ThreadMode::Materialized),
state: summary.thread_state.clone().unwrap_or(ThreadState::Active),
base_state: summary.base_state.clone().unwrap_or_default(),
base_root: summary.base_root.clone().unwrap_or_default(),
current_state: summary.current_state.clone(),
merged_state: None,
task: summary.task.clone(),
execution_path: summary
.execution_path
.as_ref()
.map(PathBuf::from)
.unwrap_or_else(|| repo.root().to_path_buf()),
materialized_path: summary.path.as_ref().map(PathBuf::from),
changed_paths: summary.changed_paths.clone(),
impact_categories: summary.impact_categories.clone(),
heavy_impact_paths: summary.heavy_impact_paths.clone(),
promotion_suggested: summary.promotion_suggested,
freshness: summary
.freshness
.clone()
.unwrap_or(ThreadFreshness::Unknown),
verification_summary: summary.verification_summary.clone(),
confidence_summary: summary.confidence_summary.clone(),
integration_policy_result: summary.integration_policy_result.clone(),
created_at: Utc::now(),
updated_at: Utc::now(),
ephemeral: None,
auto: summary.auto,
shared_target_dir: summary.shared_target_dir.as_ref().map(PathBuf::from),
};
let advice = describe_thread_advice(&thread, false, 0, false);
summary.thread_health = advice.thread_health;
summary.blockers = advice.blockers;
summary.recommended_action = advice.recommended_action;
apply_terminal_thread_advice(&mut summary);
apply_materialized_merge_advice(repo, &mut summary);
if let Some(branch_tip) = branch_tips.get(&summary.name)
&& !has_heddle_tip
{
if branch_tip.history_imported {
summary.blockers.clear();
if !summary.is_current {
summary.recommended_action = canonical_adopt_ref_command(&branch_tip.branch);
}
} else {
summary.thread_health = "tip_only".to_string();
summary.recommended_action = canonical_adopt_ref_command(&branch_tip.branch);
if summary.is_current {
summary.blockers = vec![
"Heddle has not imported this Git branch history yet; import before using history-oriented commands".to_string(),
];
} else {
summary.blockers.clear();
}
}
}
if summary.history_imported
&& summary.current_state.is_some()
&& remote_tracking_local_ref(repo, &summary.name).is_some()
{
summary.thread_health = "remote_tracking".to_string();
summary.coordination_status = CoordinationStatus::Clean;
summary.blockers.clear();
summary.recommended_action =
canonical_bridge_reconcile_ref_preview_command(None, &summary.name);
}
if summary.is_current {
enrich_current_summary_with_dirty_paths(repo, &mut summary)?;
summary.operation = operation.clone();
summary.remote_tracking = remote_tracking.clone();
summary.recommended_action = current_thread_next_action(
operation.as_ref(),
remote_tracking.as_ref(),
import_hint.as_ref(),
Some(&summary.thread_health),
Some(&summary.recommended_action),
);
summary.recommended_action = contextual_thread_action(
repo,
&summary.name,
summary.target_thread.as_deref(),
&summary.recommended_action,
);
}
summaries.push(summary);
}
let mut children_by_parent: HashMap<String, Vec<String>> = HashMap::new();
for summary in &summaries {
if let Some(parent) = &summary.parent_thread {
children_by_parent
.entry(parent.clone())
.or_default()
.push(summary.name.clone());
}
}
for summary in &mut summaries {
if let Some(children) = children_by_parent.remove(&summary.name) {
let mut children = children;
children.sort();
summary.child_threads = children;
}
}
let summaries_by_name = summaries
.iter()
.map(|summary| (summary.name.clone(), summary.clone()))
.collect::<HashMap<_, _>>();
let mut siblings_by_thread: HashMap<String, Vec<String>> = HashMap::new();
for summary in &summaries {
if let Some(parent) = &summary.parent_thread {
let siblings = summaries_by_name
.values()
.filter(|candidate| candidate.parent_thread.as_deref() == Some(parent.as_str()))
.filter(|candidate| candidate.name != summary.name)
.map(|candidate| candidate.name.clone())
.collect::<Vec<_>>();
siblings_by_thread.insert(summary.name.clone(), siblings);
}
}
for summary in &mut summaries {
summary.sibling_threads = siblings_by_thread.remove(&summary.name).unwrap_or_default();
summary.stack_depth = stack_depth(&summaries_by_name, &summary.name);
summary.stale_from_parent =
summary.parent_thread.is_some() && summary.freshness == Some(ThreadFreshness::Stale);
if summary.last_progress_at.is_some() {
summary.last_activity_at = summary.last_progress_at.clone();
}
summary.recommended_action_template =
recommended_action_template(&summary.recommended_action);
}
summaries.sort_by(|a, b| a.name.cmp(&b.name));
Ok(summaries)
}
fn enrich_current_summary_with_dirty_paths(
repo: &Repository,
summary: &mut ThreadSummary,
) -> Result<()> {
let baseline = match repo.current_state()? {
Some(state) => repo.require_tree(&state.tree)?,
None => Tree::new(),
};
let status = repo.compare_worktree_cached_with_options(
&baseline,
&worktree_status_options(Some(repo.config())),
)?;
let mut paths = summary
.changed_paths
.iter()
.cloned()
.collect::<BTreeSet<_>>();
paths.extend(
status
.modified
.iter()
.chain(status.added.iter())
.chain(status.deleted.iter())
.map(|path| path.to_string_lossy().to_string()),
);
summary.changed_paths = paths.into_iter().collect();
Ok(())
}
pub(crate) fn suppress_thread_actions_while_trust_blocked(
summaries: &mut [ThreadSummary],
trust: &RepositoryVerificationState,
) {
if trust.verified {
return;
}
let blocker = if trust.summary.trim().is_empty() {
format!("Repository verification is {}", trust.status)
} else {
trust.summary.clone()
};
for summary in summaries {
if summary.thread_health == "remote_tracking" {
summary.recommended_action_template =
recommended_action_template(&summary.recommended_action);
continue;
}
summary.thread_health = trust.status.clone();
summary.coordination_status = CoordinationStatus::Blocked;
if !summary.blockers.iter().any(|existing| existing == &blocker) {
summary.blockers.insert(0, blocker.clone());
}
if trust.status == "needs_import"
&& summary
.recommended_action
.starts_with("heddle adopt --ref ")
{
summary.recommended_action_template =
recommended_action_template(&summary.recommended_action);
continue;
}
summary.recommended_action.clear();
summary.recommended_action_template = None;
}
}
fn stack_depth(summaries_by_name: &HashMap<String, ThreadSummary>, thread: &str) -> usize {
let mut depth = 0usize;
let mut cursor = summaries_by_name
.get(thread)
.and_then(|summary| summary.parent_thread.clone());
while let Some(parent) = cursor {
depth += 1;
cursor = summaries_by_name
.get(&parent)
.and_then(|summary| summary.parent_thread.clone());
}
depth
}
fn build_thread_view(
repo: &Repository,
is_current: bool,
name: String,
entries: Vec<AgentEntry>,
thread: Option<Thread>,
branch_tip: Option<GitOverlayBranchTip>,
) -> Result<(ThreadView, CoordinationStatus)> {
let ref_state = repo.refs().get_thread(&ThreadName::new(&name))?;
let current_state = ref_state
.or_else(|| {
(is_current && repo.capability() == repo::RepositoryCapability::GitOverlay)
.then(|| {
branch_tip
.as_ref()
.and_then(|tip| tip.mapped_change)
.or_else(|| {
repo.git_overlay_mapped_change_for_branch(&name)
.ok()
.flatten()
})
})
.flatten()
})
.map(|id| id.short());
let has_heddle_tip = current_state.is_some();
let active: Vec<&AgentEntry> = entries
.iter()
.filter(|entry| entry.status == AgentStatus::Active)
.collect();
let complete: Vec<&AgentEntry> = entries
.iter()
.filter(|entry| entry.status == AgentStatus::Complete)
.collect();
let primary = active
.iter()
.max_by_key(|entry| entry.started_at)
.copied()
.or_else(|| entries.iter().max_by_key(|entry| entry.started_at));
let base_state = thread
.as_ref()
.map(|thread| thread.base_state.clone())
.or_else(|| primary.map(|entry| entry.base_state.clone()))
.or(current_state.clone());
let base_root = thread.as_ref().map(|thread| thread.base_root.clone());
let runtime = ThreadRuntimeOverlay {
path: thread
.as_ref()
.and_then(|thread| thread.materialized_path.clone())
.or_else(|| primary.and_then(|entry| entry.path.clone())),
execution_path: thread.as_ref().map(|thread| thread.execution_path.clone()),
materialized_path: thread
.as_ref()
.and_then(|thread| thread.materialized_path.clone()),
session_id: primary.map(|entry| entry.session_id.clone()),
heddle_session_id: primary.and_then(|entry| entry.heddle_session_id.clone()),
harness: primary.and_then(|entry| entry.harness.clone()),
thinking_level: primary.and_then(|entry| entry.thinking_level.clone()),
native_actor_key: primary.and_then(|entry| entry.native_actor_key.clone()),
native_parent_actor_key: primary.and_then(|entry| entry.native_parent_actor_key.clone()),
probe_source: primary.and_then(|entry| entry.probe_source.clone()),
probe_confidence: primary.and_then(|entry| entry.probe_confidence),
usage_summary: primary.map(|entry| entry.usage_summary.clone()),
last_progress_at: primary.and_then(|entry| entry.last_progress_at),
report_flush_state: primary.and_then(|entry| entry.report_flush_state.clone()),
attach_reason: primary.and_then(|entry| entry.attach_reason.clone()),
provider: primary.and_then(|entry| entry.provider.clone()),
model: primary.and_then(|entry| entry.model.clone()),
thread_mode: thread.as_ref().map(|thread| thread.mode.clone()),
thread_state: thread.as_ref().map(|thread| thread.state.clone()),
};
let thread_record = thread.as_ref().map(|thread| thread.to_record());
let thread_state_for_status = thread_record.as_ref().map(|thread| thread.state.clone());
let coordination_status = if matches!(
thread_state_for_status,
Some(ThreadState::Merged | ThreadState::Abandoned)
) {
CoordinationStatus::Clean
} else if thread_state_for_status == Some(ThreadState::Blocked) {
CoordinationStatus::Blocked
} else if thread_state_for_status == Some(ThreadState::Ready) {
CoordinationStatus::MergeReady
} else if active.len() > 1 {
CoordinationStatus::Blocked
} else if !active.is_empty()
&& complete
.iter()
.any(|entry| entry.base_state != active[0].base_state)
{
CoordinationStatus::Diverged
} else if !complete.is_empty() {
CoordinationStatus::MergeReady
} else if base_state.is_some() && current_state.is_some() && base_state != current_state {
CoordinationStatus::Ahead
} else {
CoordinationStatus::Clean
};
let view = match thread {
Some(mut thread) => {
thread.current_state = current_state;
thread.to_view(runtime, is_current)
}
None => ThreadView::from_record(
repo::ThreadRecord {
id: name.clone(),
thread: name.clone(),
target_thread: None,
parent_thread: None,
mode: ThreadMode::Materialized,
state: ThreadState::Active,
base_state: base_state.unwrap_or_default(),
base_root: base_root.unwrap_or_default(),
current_state,
merged_state: None,
task: None,
changed_paths: Vec::new(),
impact_categories: Vec::new(),
heavy_impact_paths: Vec::new(),
promotion_suggested: false,
freshness: ThreadFreshness::Unknown,
verification_summary: Default::default(),
confidence_summary: Default::default(),
integration_policy_result: Default::default(),
created_at: Utc::now(),
updated_at: Utc::now(),
ephemeral: None,
auto: false,
shared_target_dir: None,
},
runtime,
is_current,
),
};
if let Some(branch_tip) = branch_tip
&& !has_heddle_tip
&& view.record.current_state.is_none()
{
let mut record = view.record.clone();
record.current_state = None;
let mut runtime = view.runtime.clone();
if runtime.attach_reason.is_none() {
runtime.attach_reason = Some(format!(
"auto-adopted Git branch tip {}",
branch_tip.git_commit
));
}
return Ok((
ThreadView::from_record(record, runtime, is_current),
coordination_status,
));
}
Ok((view, coordination_status))
}
pub fn find_thread_summary(repo: &Repository, name: &str) -> Result<Option<ThreadSummary>> {
Ok(collect_thread_summaries(repo)?
.into_iter()
.find(|summary| summary.name == name))
}
pub fn find_thread_summary_single(repo: &Repository, name: &str) -> Result<Option<ThreadSummary>> {
let current = repo.current_lane()?;
let is_current = current.as_deref() == Some(name);
let thread_manager = ThreadManager::new(repo.heddle_dir());
let mut thread_record = thread_manager.find_by_thread(name)?;
if let Some(thread) = thread_record.as_mut() {
refresh_thread_freshness(repo, thread)?;
}
let registry = AgentRegistry::new(repo.heddle_dir());
let entries: Vec<AgentEntry> = registry
.list()?
.into_iter()
.filter(|entry| entry.thread == name)
.collect();
let (view, coordination_status) = build_thread_view(
repo,
is_current,
name.to_string(),
entries,
thread_record,
None, )?;
let mut summary = ThreadSummary::from_view(view, coordination_status);
let thread_for_advice = Thread {
id: summary.name.clone(),
thread: summary.name.clone(),
target_thread: summary.target_thread.clone(),
parent_thread: summary.parent_thread.clone(),
mode: summary
.thread_mode
.clone()
.unwrap_or(ThreadMode::Materialized),
state: summary.thread_state.clone().unwrap_or(ThreadState::Active),
base_state: summary.base_state.clone().unwrap_or_default(),
base_root: summary.base_root.clone().unwrap_or_default(),
current_state: summary.current_state.clone(),
merged_state: None,
task: summary.task.clone(),
execution_path: summary
.execution_path
.as_ref()
.map(PathBuf::from)
.unwrap_or_else(|| repo.root().to_path_buf()),
materialized_path: summary.path.as_ref().map(PathBuf::from),
changed_paths: summary.changed_paths.clone(),
impact_categories: summary.impact_categories.clone(),
heavy_impact_paths: summary.heavy_impact_paths.clone(),
promotion_suggested: summary.promotion_suggested,
freshness: summary
.freshness
.clone()
.unwrap_or(ThreadFreshness::Unknown),
verification_summary: summary.verification_summary.clone(),
confidence_summary: summary.confidence_summary.clone(),
integration_policy_result: summary.integration_policy_result.clone(),
created_at: Utc::now(),
updated_at: Utc::now(),
ephemeral: None,
auto: summary.auto,
shared_target_dir: summary.shared_target_dir.as_ref().map(PathBuf::from),
};
let advice = describe_thread_advice(&thread_for_advice, false, 0, false);
summary.thread_health = advice.thread_health;
summary.blockers = advice.blockers;
summary.recommended_action = advice.recommended_action;
apply_terminal_thread_advice(&mut summary);
apply_materialized_merge_advice(repo, &mut summary);
if is_current {
summary.recommended_action =
primary_next_action(None, None, None, Some(&summary.recommended_action));
summary.recommended_action = contextual_thread_action(
repo,
&summary.name,
summary.target_thread.as_deref(),
&summary.recommended_action,
);
}
summary.recommended_action_template = recommended_action_template(&summary.recommended_action);
Ok(Some(summary))
}
fn apply_materialized_merge_advice(repo: &Repository, summary: &mut ThreadSummary) {
let Some(action) = materialized_merge_resolve_action(repo, summary) else {
return;
};
summary.thread_health = "blocked".to_string();
if summary.blockers.is_empty() {
summary
.blockers
.push("Merge conflicts need resolution".to_string());
}
summary.recommended_action = action;
summary.recommended_action_template = recommended_action_template(&summary.recommended_action);
}
fn materialized_merge_resolve_action(repo: &Repository, summary: &ThreadSummary) -> Option<String> {
if let Some(path) = summary.execution_path.as_deref() {
let path = PathBuf::from(path);
if !path.exists() {
return None;
}
let thread_repo = Repository::open(&path).ok()?;
return thread_repo
.merge_state_manager()
.is_merge_in_progress()
.then(|| {
heddle_action(vec![
"--repo".to_string(),
path.display().to_string(),
"resolve".to_string(),
"--list".to_string(),
])
});
}
(summary.is_current && repo.merge_state_manager().is_merge_in_progress())
.then(|| heddle_action(["resolve", "--list"]))
}
pub(crate) fn contextual_thread_action(
repo: &Repository,
thread_id: &str,
target_thread: Option<&str>,
action: &str,
) -> String {
super::thread_landing::contextual_thread_action(repo, thread_id, target_thread, action)
}
pub(crate) fn current_thread_next_action_with_verification(
operation: Option<&RepositoryOperationStatus>,
remote_tracking: Option<&GitRemoteTrackingStatus>,
import_hint: Option<&GitOverlayImportHint>,
thread_health: Option<&str>,
thread_action: Option<&str>,
trust: &RepositoryVerificationState,
) -> String {
let fallback = non_empty_action_ref(thread_action)
.or_else(|| non_empty_action_ref(Some(trust.recommended_action.as_str())));
effective_next_action(
NextActionInput::default(operation, remote_tracking, import_hint, fallback)
.current_thread(thread_health)
.with_verification(trust),
)
}
pub(crate) fn current_thread_next_action(
operation: Option<&RepositoryOperationStatus>,
remote_tracking: Option<&GitRemoteTrackingStatus>,
import_hint: Option<&GitOverlayImportHint>,
thread_health: Option<&str>,
thread_action: Option<&str>,
) -> String {
effective_next_action(
NextActionInput::default(operation, remote_tracking, import_hint, thread_action)
.current_thread(thread_health),
)
}
pub(crate) fn thread_recovery_action_is_primary(
thread_health: Option<&str>,
thread_action: &str,
) -> bool {
shared_thread_recovery_action_is_primary(thread_health, thread_action)
}
fn non_empty_action_ref(action: Option<&str>) -> Option<&str> {
action.filter(|action| !action.trim().is_empty())
}
fn apply_terminal_thread_advice(summary: &mut ThreadSummary) {
match summary.thread_state {
Some(ThreadState::Merged) => {
summary.thread_health = "clean".to_string();
summary.blockers.clear();
summary.recommended_action = "heddle thread cleanup --merged --dry-run".to_string();
summary.coordination_status = CoordinationStatus::Clean;
}
Some(ThreadState::Abandoned) => {
summary.thread_health = "clean".to_string();
summary.blockers.clear();
summary.recommended_action.clear();
summary.coordination_status = CoordinationStatus::Clean;
}
_ => {}
}
}
pub(crate) fn visibility_label(mode: &ThreadMode) -> &'static str {
match mode {
ThreadMode::Materialized => "materialized",
ThreadMode::Virtualized => "virtualized",
ThreadMode::Solid => "solid",
}
}
pub(crate) fn thread_workspace_label(mode: &ThreadMode) -> &'static str {
match mode {
ThreadMode::Materialized => "main checkout",
ThreadMode::Virtualized => "virtual checkout",
ThreadMode::Solid => "isolated checkout",
}
}
pub(crate) fn thread_human_visibility(summary: &ThreadSummary) -> &str {
if thread_is_imported_git_ref(summary) {
return "imported Git branch";
}
if !summary.is_isolated && summary.path.is_none() && summary.execution_path.is_none() {
return "no dedicated checkout";
}
summary
.thread_mode
.as_ref()
.map(thread_workspace_label)
.unwrap_or(&summary.visibility)
}
fn thread_liveness_glyph(entry: &ThreadSummary) -> String {
let path = entry.path.as_deref().or(entry.execution_path.as_deref());
let Some(path) = path else {
return String::new();
};
if std::path::Path::new(path).exists() {
format!(" {}", style::accent("●"))
} else {
format!(" {}", style::warn("✗"))
}
}
pub(crate) fn git_history_label(history_imported: bool) -> &'static str {
if history_imported {
"full history available"
} else {
"tip available"
}
}
fn render_repository_context_lines(context: Option<&crate::cli::render::RepositoryContextInfo>) {
let Some(context) = context else {
return;
};
if let Some(parent_repository) = &context.parent_repository {
println!("Parent repo: {}", parent_repository);
}
if let Some(target_thread) = &context.target_thread {
println!("Target thread: {}", target_thread);
}
if let Some(parent_thread) = &context.parent_thread {
println!("Parent thread: {}", parent_thread);
}
}
pub(crate) fn split_available_git_refs(summaries: &mut Vec<ThreadSummary>) -> Vec<AvailableGitRef> {
let mut available = Vec::new();
summaries.retain(|summary| {
if thread_is_available_git_ref(summary) {
available.push(available_git_ref_from_summary(summary));
false
} else {
true
}
});
available
}
fn available_git_ref_from_summary(summary: &ThreadSummary) -> AvailableGitRef {
AvailableGitRef {
name: summary.name.clone(),
git_commit: summary.git_branch_tip.clone().unwrap_or_default(),
recommended_action: summary.recommended_action.clone(),
recommended_action_template: summary
.recommended_action_template
.clone()
.or_else(|| recommended_action_template(&summary.recommended_action)),
}
}
pub(crate) fn cmd_thread_list(cli: &Cli, repo: &Repository, args: ThreadListArgs) -> Result<()> {
let as_json = should_output_json(cli, Some(repo.config()));
let current = repo.current_lane()?;
let mut summaries = collect_thread_summaries(repo)?;
let mut trust = build_repository_verification_state(repo);
if !args.include_auto {
summaries.retain(|summary| summary.is_current || !summary.auto);
}
let available_git_refs = split_available_git_refs(&mut summaries);
suppress_thread_actions_while_trust_blocked(&mut summaries, &trust);
let current_summary = summaries.iter().find(|summary| summary.is_current);
if let Some(current) = current_summary
&& !trust.recommended_action.is_empty()
{
let contextual = contextual_thread_action(
repo,
¤t.name,
current.target_thread.as_deref(),
&trust.recommended_action,
);
if contextual != trust.recommended_action {
override_trust_recommended_action(&mut trust, contextual);
}
}
let current_action = summaries
.iter()
.find(|summary| summary.is_current)
.map(|summary| summary.recommended_action.as_str());
let recommended_action =
primary_next_action_with_verification(None, None, None, current_action, &trust);
if let Some(current) = current_summary
&& trust.verified
&& !recommended_action.is_empty()
&& trust.recommended_action != recommended_action
&& thread_recovery_action_is_primary(Some(¤t.thread_health), &recommended_action)
{
override_trust_recommended_action(&mut trust, recommended_action.clone());
}
let presentation = crate::cli::render::repository_presentation(
repo,
current_summary.and_then(|summary| summary.target_thread.as_deref()),
current_summary.and_then(|summary| summary.parent_thread.as_deref()),
);
let current = current_summary
.map(|summary| summary.name.clone())
.or(current);
let output = ThreadListOutput {
output_kind: "thread_list",
repository_capability: repo.capability_label().to_string(),
repository_label: presentation.label,
repository_context: presentation.context,
storage_model: repo.storage_model_label().to_string(),
hosted_enabled: repo.hosted_enabled(),
recommended_action: recommended_action.clone(),
recommended_action_template: recommended_action_template(&recommended_action),
recovery_commands: trust.recovery_commands.clone(),
recovery_action_templates: trust.recovery_action_templates.clone(),
trust,
git_overlay_import_hint: if as_json {
None
} else {
repo.git_overlay_import_hint()?
.map(|hint| ThreadListGitOverlayImportHintOutput {
current_branch: hint.current_branch,
missing_branch_count: hint.missing_branch_count,
missing_branches: hint.missing_branches,
recommended_command: hint.recommended_command,
})
},
threads: summaries,
available_git_refs,
current,
};
if as_json {
write_full_command_json(
&output,
NextActionValidationContext::new(&["thread", "list"], repo.capability()),
)?;
} else if output.threads.is_empty() && output.available_git_refs.is_empty() {
println!("No threads");
} else {
println!(
"{} {} {}",
style::bold("Threads"),
style::dim("in"),
output.repository_label
);
println!("Repository: {}", output.repository_label);
render_repository_context_lines(output.repository_context.as_ref());
if output.hosted_enabled {
println!("Hosted: {}", style::accent("enabled"));
}
let trust_only_blocks_on_this_ready_thread = output.trust.workflow_status == "ready"
&& output.trust.recommended_action == output.recommended_action;
if !output.trust.verified
&& !trust_only_blocks_on_this_ready_thread
&& !output.recommended_action.is_empty()
{
println!("Verification: {}", style::warn(&output.trust.summary));
print_next_step(&output.recommended_action);
}
if output.trust.verified
&& let Some(hint) = &output.git_overlay_import_hint
{
println!(
"{}",
crate::cli::render::git_only_branch_summary(
&hint.missing_branches,
hint.missing_branch_count,
)
);
if output.available_git_refs.is_empty() {
print_optional(&hint.recommended_command);
}
}
render_thread_sections(&output.threads, cli.verbose > 0);
render_available_git_refs(&output.available_git_refs, cli.verbose > 0);
}
Ok(())
}
type ThreadSectionPredicate = fn(&ThreadSummary) -> bool;
type ThreadSection = (&'static str, ThreadSectionPredicate);
fn render_thread_sections(threads: &[ThreadSummary], verbose: bool) {
let sections: [ThreadSection; 5] = [
("Current", |entry| entry.is_current),
("Needs attention", thread_needs_attention),
("Ready to merge", thread_ready_to_merge),
("Imported Git refs", thread_is_imported_git_ref),
("Other threads", |_| true),
];
let mut printed = vec![false; threads.len()];
for (label, predicate) in sections {
let indexes = threads
.iter()
.enumerate()
.filter_map(|(index, entry)| (!printed[index] && predicate(entry)).then_some(index))
.collect::<Vec<_>>();
if indexes.is_empty() {
continue;
}
println!();
println!("{}", style::bold(label));
for index in indexes {
printed[index] = true;
render_thread_entry(&threads[index], verbose);
}
}
}
fn thread_needs_attention(entry: &ThreadSummary) -> bool {
!entry.blockers.is_empty()
|| entry.operation.is_some()
|| entry.coordination_status == CoordinationStatus::Blocked
|| entry.coordination_status == CoordinationStatus::Diverged
}
fn thread_ready_to_merge(entry: &ThreadSummary) -> bool {
entry.coordination_status == CoordinationStatus::MergeReady
|| (entry.coordination_status == CoordinationStatus::Ahead
&& entry.thread_state != Some(ThreadState::Merged)
&& entry.target_thread.is_some())
}
pub(crate) fn thread_is_imported_git_ref(entry: &ThreadSummary) -> bool {
!entry.is_current
&& entry.path.is_none()
&& entry.execution_path.is_none()
&& entry.target_thread.is_none()
&& entry.current_state.is_some()
&& entry.history_imported
&& (entry.git_branch_tip.is_some() || entry.name.starts_with("origin/"))
}
pub(crate) fn thread_is_available_git_ref(entry: &ThreadSummary) -> bool {
!entry.is_current
&& entry.path.is_none()
&& entry.execution_path.is_none()
&& entry.target_thread.is_none()
&& entry.current_state.is_none()
&& entry.git_branch_tip.is_some()
}
fn remote_tracking_local_ref(repo: &Repository, thread_name: &str) -> Option<String> {
let git = SleyRepository::discover(repo.root()).ok()?;
let remotes = git.remote_names().ok()?;
remotes
.iter()
.find_map(|remote| thread_name.strip_prefix(&format!("{remote}/")))
.filter(|branch| !branch.is_empty())
.map(str::to_string)
}
fn render_available_git_refs(refs: &[AvailableGitRef], verbose: bool) {
if refs.is_empty() {
return;
}
println!();
println!("{}", style::bold("Optional Git-only branches"));
let visible_count = if verbose {
refs.len()
} else {
refs.len().min(DEFAULT_AVAILABLE_GIT_REF_LIMIT)
};
for entry in refs.iter().take(visible_count) {
println!(
"{} {} {}",
style::dim("-"),
style::bold(&entry.name),
style::dim("(available)")
);
if verbose {
println!(" git tip: {}", style::dim(&entry.git_commit));
}
if !entry.recommended_action.is_empty() {
print_nested_optional(&entry.recommended_action);
}
}
println!(
" {}",
style::dim("adopt when you want to work on this branch in Heddle")
);
if !verbose && refs.len() > visible_count {
let remaining = refs.len() - visible_count;
println!(
" {}",
style::dim(&format!(
"... {remaining} more Git-only branch(es); use --output json or -v to inspect all"
))
);
}
}
fn render_thread_entry(entry: &ThreadSummary, verbose: bool) {
let prefix = if entry.is_current {
style::accent("*")
} else {
style::dim("-")
};
let liveness = thread_liveness_glyph(entry);
if verbose {
let state = entry.current_state.as_deref().unwrap_or("(no state)");
println!(
"{} {} {} {} {}{}",
prefix,
style::bold(&entry.name),
style::dim(state),
style::thread_state(&entry.coordination_status.to_string()),
style::dim(thread_human_visibility(entry)),
liveness,
);
} else {
println!(
"{} {} {} {}{}",
prefix,
style::bold(&entry.name),
style::thread_state(&entry.coordination_status.to_string()),
style::dim(thread_human_visibility(entry)),
liveness,
);
}
if let Some(path) = &entry.path {
println!(" path: {}", path);
} else if let Some(path) = &entry.execution_path {
println!(" execution root: {}", path);
}
if verbose && let Some(git_branch_tip) = &entry.git_branch_tip {
println!(
" git tip: {} {}",
style::dim(git_branch_tip),
style::dim(&format!("({})", git_history_label(entry.history_imported)))
);
}
if let Some(state) = &entry.thread_state
&& (verbose || matches!(state, ThreadState::Merged | ThreadState::Abandoned))
{
println!(" lifecycle: {}", style::thread_state(&state.to_string()));
}
if let Some(freshness) = &entry.freshness
&& *freshness != ThreadFreshness::Unknown
&& !matches!(
entry.thread_state,
Some(ThreadState::Merged | ThreadState::Abandoned)
)
{
println!(" sync: {}", style::thread_state(&freshness.to_string()));
}
if let Some(operation) = &entry.operation {
println!(
" in progress: {} {} ({})",
style::warn(&operation.scope.to_string()),
style::warn(&operation.kind.to_string()),
style::dim(&operation.state)
);
}
if let Some(remote_tracking) = &entry.remote_tracking {
if remote_tracking.behind == 0 && remote_tracking.ahead > 0 {
println!(" sync: {}", style::accent(&remote_tracking.message));
} else {
println!(" sync: {}", style::warn(&remote_tracking.message));
}
}
if verbose
&& let Some(actor) = &entry.actor
&& let Some(text) =
crate::cli::render::actor_display(actor.provider.as_deref(), actor.model.as_deref())
{
println!(" actor: {text}");
}
if let Some(task) = &entry.task {
println!(" task: {}", task);
}
if verbose && let Some(parent) = &entry.parent_thread {
println!(" parent: {}", parent);
}
if verbose && !entry.child_threads.is_empty() {
println!(" children: {}", entry.child_threads.join(", "));
}
if verbose && entry.promotion_suggested && !entry.heavy_impact_paths.is_empty() {
println!(
" promotion: suggested ({})",
crate::cli::render::preview_list(
&entry.heavy_impact_paths,
entry.heavy_impact_paths.len(),
)
);
}
if verbose && !entry.impact_categories.is_empty() {
println!(
" impacts: {}",
entry
.impact_categories
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
);
}
if !entry.blockers.is_empty() {
println!(
" blocked by: {}",
style::warn(&entry.blockers.join(" | "))
);
}
if !entry.recommended_action.is_empty() && thread_is_available_git_ref(entry) {
print_nested_optional(&entry.recommended_action);
} else if !entry.recommended_action.is_empty() {
print_nested_next_step(&entry.recommended_action);
}
}
pub(crate) fn start_transaction_id(
scope: &str,
name: &str,
base_state: &ChangeId,
start_epoch: DateTime<Utc>,
) -> String {
format!(
"thread-start:{scope}:{name}:{}:{}",
base_state.to_string_full(),
start_epoch.timestamp_nanos_opt().unwrap_or_default(),
)
}
pub(crate) fn resolve_start_epoch(repo: &Repository, name: &str) -> Result<DateTime<Utc>> {
let prior_active = ThreadManager::new(repo.heddle_dir())
.load(name)?
.filter(|thread| thread.state == ThreadState::Active);
Ok(prior_active.map_or_else(Utc::now, |thread| thread.created_at))
}
pub(crate) fn thread_name_invalid_advice(err: &ThreadIdError) -> RecoveryAdvice {
RecoveryAdvice::invalid_usage(
"thread_name_invalid",
err.to_string(),
"Choose a thread name using only letters, digits, and _ - . / @ : + = \
(no spaces or shell metacharacters).",
"heddle start <name>",
)
}
pub(crate) fn start_thread(repo: &Repository, args: ThreadStartArgs) -> Result<ThreadOpOutput> {
ThreadId::new(args.name.as_str()).map_err(|err| anyhow!(thread_name_invalid_advice(&err)))?;
let existing = find_active_thread_entry(repo, &args.name)?;
if let Some(entry) = existing {
if let Some(ref requested_path) = args.path {
let requested = normalize_path_for_containment(&absolute_path(requested_path)?)?;
let existing_path = entry
.path
.as_ref()
.ok_or_else(|| anyhow!(active_reservation_advice(&args.name, None)))?;
let existing_path_normalized = normalize_path_for_containment(existing_path)
.unwrap_or_else(|_| existing_path.clone());
if existing_path_normalized != requested {
return Err(anyhow!(active_reservation_advice(
&args.name,
Some(existing_path.display().to_string())
)));
}
}
let path = entry.path.map(|path| path.display().to_string());
return Err(anyhow!(active_reservation_advice(&args.name, path)));
}
let existing_thread_state = repo.refs().get_thread(&ThreadName::new(&args.name))?;
let base_state = match (&args.from, existing_thread_state) {
(Some(spec), Some(existing)) => {
let requested = repo.resolve_state(spec)?.ok_or_else(|| {
anyhow!(RecoveryAdvice::thread_referenced_state_missing(
spec, "State"
))
})?;
if requested != existing {
return Err(anyhow!(thread_anchor_mismatch_advice(
&args.name, &existing, &requested
)));
}
existing
}
(None, Some(existing)) => existing,
(Some(spec), None) => repo.resolve_state(spec)?.ok_or_else(|| {
anyhow!(RecoveryAdvice::thread_referenced_state_missing(
spec, "State"
))
})?,
(None, None) => ensure_current_state(
repo,
&UserConfig::load_default().unwrap_or_default(),
Some(format!(
"Bootstrap git-overlay before starting {}",
args.name
)),
)?,
};
let actor_identity = resolve_start_actor_identity(repo, &args)?;
let thread_mode = resolve_thread_mode(repo, &args);
if args.workspace == WorkspaceModeArg::Materialized
&& !objects::fs_clone::filesystem_supports_reflink(repo.root())
{
eprintln!(
"{}: this filesystem doesn't support reflinks/clonefile, so \
`--workspace materialized` will fall back to per-file copies — \
disk usage will match `--workspace solid`. \
Use `--workspace solid` to make this explicit, or `--workspace auto` \
to let heddle pick the right mode for this host.",
style::warn("note"),
);
}
let path = match thread_mode {
ThreadMode::Materialized => args
.path
.clone()
.unwrap_or_else(|| default_lightweight_thread_path(repo, &args.name)),
ThreadMode::Solid => args
.path
.clone()
.unwrap_or_else(|| default_thread_path(repo, &args.name)),
ThreadMode::Virtualized => default_virtualized_thread_path(repo, &args.name),
};
if args.path.is_some() {
ensure_explicit_start_path_outside_tracked_tree(repo, &args.name, &path)?;
}
let scope = repo.op_scope();
let start_epoch = resolve_start_epoch(repo, &args.name)?;
let transaction_id = start_transaction_id(&scope, &args.name, &base_state, start_epoch);
if !repo
.oplog()
.committed_batch_records(&transaction_id)?
.is_empty()
{
return finalize_committed_start(repo, &args, base_state, &actor_identity);
}
let prepared_target = plan_worktree_target(repo, &path, Some(&args.name))?;
let target_dir_created = prepared_target.target_dir_created;
let abs_path = normalize_path_for_containment(&prepared_target.path)?;
let shared_target_dir_path: Option<PathBuf> = if args.shared_target
&& matches!(thread_mode, ThreadMode::Solid | ThreadMode::Materialized)
{
if shared_target::workspace_root_is_rust(repo) {
Some(shared_target::shared_target_dir(repo)?)
} else {
tracing::debug!(
repo = %repo.root().display(),
"--shared-target requested in a non-Rust repo (no top-level Cargo.toml); skipping"
);
None
}
} else {
None
};
if !args.shared_target
&& matches!(thread_mode, ThreadMode::Solid | ThreadMode::Materialized)
&& shared_target::should_advise_shared_target(repo)
{
shared_target::print_advisory(&args.name);
}
let current_target_thread = match repo.head_ref()? {
Head::Attached { thread } => Some(thread.to_string()),
Head::Detached { .. } => None,
};
let base_short = base_state.short();
let base_state_summary = repo
.store()
.get_state(&base_state)?
.map(|state| {
(
state.tree.short(),
summarize_verification(state.verification.as_ref()),
summarize_confidence(state.confidence),
)
})
.ok_or_else(|| {
anyhow!(RecoveryAdvice::thread_referenced_state_missing(
&base_state.short(),
"Base state",
))
})?;
let (base_root, verification_summary, confidence_summary) = base_state_summary;
let thread_state = Thread {
id: args.name.clone(),
thread: args.name.clone(),
target_thread: current_target_thread.clone(),
parent_thread: args.parent_thread.clone(),
mode: thread_mode.clone(),
state: ThreadState::Active,
base_state: base_short.clone(),
base_root: base_root.clone(),
current_state: Some(base_short.clone()),
merged_state: None,
task: args.task.clone(),
execution_path: abs_path.clone(),
materialized_path: match thread_mode {
ThreadMode::Solid | ThreadMode::Materialized => Some(abs_path.clone()),
ThreadMode::Virtualized => Some(abs_path.clone()),
},
changed_paths: vec![],
impact_categories: vec![],
heavy_impact_paths: vec![],
promotion_suggested: false,
freshness: ThreadFreshness::Current,
verification_summary,
confidence_summary,
integration_policy_result: ThreadIntegrationPolicy::default(),
created_at: start_epoch,
updated_at: Utc::now(),
ephemeral: None,
auto: false,
shared_target_dir: shared_target_dir_path.clone(),
};
let mount_ownership = mount_lifecycle::MountOwnership::from_flags(args.daemon, args.no_daemon);
let hydrate_requested =
args.hydrate && matches!(thread_mode, ThreadMode::Solid | ThreadMode::Materialized);
let start_output = repo::atomic::execute(
repo,
start_atomic::StartThread {
transaction_id,
name: args.name.clone(),
base_state,
existing_thread_state,
thread_mode: thread_mode.clone(),
abs_path: abs_path.clone(),
target_dir_created,
shared_target_dir: shared_target_dir_path,
hydrate: hydrate_requested,
mount_ownership,
record: thread_state,
},
)
.map_err(|e| anyhow!(e))?;
finalize_thread_start(
repo,
&args,
&thread_mode,
&abs_path,
base_state,
&base_short,
&base_root,
&actor_identity,
hydrate_requested,
start_output.linked,
start_output.fskit_readiness,
)
}
fn finalize_committed_start(
repo: &Repository,
args: &ThreadStartArgs,
base_state: ChangeId,
actor_identity: &StartActorIdentity,
) -> Result<ThreadOpOutput> {
let committed = ThreadManager::new(repo.heddle_dir())
.load(&args.name)?
.ok_or_else(|| {
anyhow!(
"thread '{}' has a committed start transaction but no durable record to \
complete the interrupted start from",
args.name
)
})?;
let abs_path = committed.execution_path.clone();
let base_short = base_state.short();
finalize_thread_start(
repo,
args,
&committed.mode,
&abs_path,
base_state,
&base_short,
&committed.base_root,
actor_identity,
false,
Vec::new(),
None,
)
}
#[allow(clippy::too_many_arguments)]
fn finalize_thread_start(
repo: &Repository,
args: &ThreadStartArgs,
thread_mode: &ThreadMode,
abs_path: &Path,
base_state: ChangeId,
base_short: &str,
base_root: &str,
actor_identity: &StartActorIdentity,
hydrate_requested: bool,
linked: Vec<String>,
fskit_readiness: Option<mount_lifecycle::FskitReadinessReport>,
) -> Result<ThreadOpOutput> {
if hydrate_requested {
if linked.is_empty() {
eprintln!(
"{}: --hydrate found no ignored dependency directories at the origin \
checkout root to link.",
style::warn("note"),
);
} else {
eprintln!(
"{}: hydrated {} ignored dependency dir(s) into '{}' via symlink: {} \
(shared with the origin checkout; they stay ignored and are not captured).",
style::warn("note"),
linked.len(),
args.name,
linked.join(", "),
);
}
}
let registry = AgentRegistry::new(repo.heddle_dir());
let path_for_entry = abs_path.to_path_buf();
let thread_name = args.name.clone();
let entry = registry.create_generated_entry_for_thread(&thread_name, |session_id| {
Ok(AgentEntry {
session_id: session_id.to_string(),
client_instance_id: None,
native_actor_key: actor_identity.native_actor_key.clone(),
native_parent_actor_key: actor_identity.native_parent_actor_key.clone(),
native_instance_key: actor_identity.native_instance_key.clone(),
heddle_session_id: None,
thread_id: Some(thread_name.clone()),
thread: thread_name.clone(),
pid: Some(process::id()),
boot_id: current_boot_id(),
liveness_path: Some(
repo.heddle_dir()
.join("agents")
.join(format!("{session_id}.live")),
),
heartbeat_at: Some(Utc::now()),
anchor_state: Some(base_state.to_string_full()),
anchor_root: Some(base_root.to_string()),
reservation_token: Some(objects::store::generate_agent_id()),
path: match thread_mode {
ThreadMode::Solid | ThreadMode::Materialized | ThreadMode::Virtualized => {
Some(path_for_entry.clone())
}
},
base_state: base_short.to_string(),
started_at: Utc::now(),
provider: actor_identity.provider.clone(),
model: actor_identity.model.clone(),
harness: actor_identity.harness.clone(),
thinking_level: actor_identity.thinking_level.clone(),
usage_summary: AgentUsageSummary::default(),
last_progress_at: None,
report_flush_state: None,
attach_reason: Some(format!(
"actor {session_id} was created when thread {} started",
thread_name
)),
attach_precedence: vec!["thread-start".to_string()],
winning_attach_rule: Some("thread-start".to_string()),
probe_source: actor_identity.probe_source.clone(),
probe_confidence: actor_identity.probe_confidence,
status: AgentStatus::Active,
completed_at: None,
context_queries: vec![],
})
})?;
let summary = find_thread_summary(repo, &args.name)?;
let message = match thread_mode {
ThreadMode::Materialized | ThreadMode::Solid => {
format!(
"Started isolated thread '{}' at '{}' (Heddle-managed checkout, no .git directory)",
args.name,
abs_path.display()
)
}
ThreadMode::Virtualized => {
format!(
"Started virtualized thread '{}' mounted at '{}'",
args.name,
abs_path.display()
)
}
};
let mut output = thread_op_output(
"thread_start",
"start",
args.name.clone(),
message,
summary.as_ref().and_then(|thread| thread.path.clone()),
Some(abs_path.display().to_string()),
Some(build_repository_verification_state(repo)),
summary.map(|mut thread| {
thread.session_id = Some(entry.session_id.clone());
thread
}),
);
output.fskit_readiness = fskit_readiness;
Ok(output)
}
#[derive(Debug, Clone)]
struct StartActorIdentity {
provider: Option<String>,
model: Option<String>,
harness: Option<String>,
thinking_level: Option<String>,
native_actor_key: Option<String>,
native_parent_actor_key: Option<String>,
native_instance_key: Option<String>,
probe_source: Option<String>,
probe_confidence: Option<f32>,
}
fn resolve_start_actor_identity(
repo: &Repository,
args: &ThreadStartArgs,
) -> Result<StartActorIdentity> {
let explicit_provider = non_empty_identity_value(args.agent_provider.clone());
let explicit_model = non_empty_identity_value(args.agent_model.clone());
let probe = crate::harness::probe_current_process_harness(
repo,
explicit_provider.clone(),
explicit_model.clone(),
None,
)?;
let explicit_identity = explicit_provider.is_some() || explicit_model.is_some();
let provider = explicit_provider.or_else(|| non_empty_identity_value(probe.provider.clone()));
let model = explicit_model.or_else(|| non_empty_identity_value(probe.model.clone()));
let harness = non_empty_identity_value(probe.harness.clone());
let thinking_level = non_empty_identity_value(probe.thinking_level.clone());
let native_actor_key = non_empty_identity_value(probe.native_actor_key.clone());
let native_parent_actor_key = non_empty_identity_value(probe.native_parent_actor_key.clone());
let native_instance_key = non_empty_identity_value(probe.native_instance_key.clone());
let detected_identity = provider.is_some()
|| model.is_some()
|| harness.is_some()
|| thinking_level.is_some()
|| native_actor_key.is_some()
|| native_parent_actor_key.is_some()
|| native_instance_key.is_some();
Ok(StartActorIdentity {
provider,
model,
harness,
thinking_level,
native_actor_key,
native_parent_actor_key,
native_instance_key,
probe_source: if explicit_identity {
Some("explicit_payload".to_string())
} else if detected_identity {
probe.probe_source
} else {
Some("explicit_payload".to_string())
},
probe_confidence: if explicit_identity {
Some(1.0)
} else if detected_identity {
probe.confidence
} else {
Some(1.0)
},
})
}
fn non_empty_identity_value(value: Option<String>) -> Option<String> {
value.and_then(|value| {
if value.trim().is_empty() {
None
} else {
Some(value)
}
})
}
fn active_reservation_advice(thread: &str, existing_path: Option<String>) -> RecoveryAdvice {
let location = existing_path
.as_ref()
.map(|path| format!(" at '{path}'"))
.unwrap_or_default();
let primary_command = format!("heddle thread show {thread}");
RecoveryAdvice::safety_refusal(
"active_thread_reservation",
format!("Thread '{thread}' already has an active reservation{location}"),
format!(
"Inspect it with `{primary_command}`, or release that session before starting another writer."
),
format!("thread '{thread}' already has an active writer reservation{location}"),
"starting another writer could create competing worktree materializations for the same thread",
"no worktree, refs, or reservation records were changed",
primary_command.clone(),
vec![primary_command],
)
}
fn thread_anchor_mismatch_advice(
thread: &str,
existing: &ChangeId,
requested: &ChangeId,
) -> RecoveryAdvice {
RecoveryAdvice::safety_refusal(
"thread_anchor_mismatch",
format!(
"Thread '{thread}' is anchored at {}, but --from resolved to {}",
existing.short(),
requested.short()
),
format!(
"Start a new thread name, or inspect this thread with `heddle thread show {thread}` before refreshing or rebasing it."
),
format!(
"thread '{thread}' already points at {}, while --from resolved to {}",
existing.short(),
requested.short()
),
"attaching another workspace from a different base could fork the same thread name into competing histories",
"no worktree, refs, or reservation records were changed",
format!("heddle thread show {thread}"),
vec![format!("heddle thread show {thread}")],
)
}
fn resolve_thread_mode(repo: &Repository, args: &ThreadStartArgs) -> ThreadMode {
match args.workspace {
WorkspaceModeArg::Materialized => ThreadMode::Materialized,
WorkspaceModeArg::Virtualized => ThreadMode::Virtualized,
WorkspaceModeArg::Solid => ThreadMode::Solid,
WorkspaceModeArg::Auto => {
let candidate = if args.path.is_some() {
ThreadMode::Materialized
} else {
match resolve_auto_workspace_default(repo, args) {
UserThreadWorkspaceMode::Materialized => ThreadMode::Materialized,
UserThreadWorkspaceMode::Virtualized => ThreadMode::Virtualized,
UserThreadWorkspaceMode::Solid => ThreadMode::Solid,
UserThreadWorkspaceMode::Auto => ThreadMode::Materialized,
}
};
if candidate == ThreadMode::Materialized
&& !objects::fs_clone::filesystem_supports_reflink(repo.root())
{
tracing::debug!(
root = %repo.root().display(),
"Auto workspace: filesystem does not support reflinks; \
falling back to `solid` so the mode label reflects disk truth"
);
return ThreadMode::Solid;
}
candidate
}
}
}
fn resolve_auto_workspace_default(
_repo: &Repository,
args: &ThreadStartArgs,
) -> UserThreadWorkspaceMode {
let user_config = UserConfig::load_default().unwrap_or_default();
if args.parent_thread.is_some() || args.automated {
user_config
.worktree
.thread_workspace
.delegated_default
.unwrap_or(UserThreadWorkspaceMode::Materialized)
} else {
user_config.worktree.thread_workspace.top_level_default
}
}
pub(crate) fn cmd_thread_create(
cli: &Cli,
repo: &Repository,
name: String,
ephemeral: bool,
ttl_secs: Option<u32>,
) -> Result<()> {
ThreadId::new(name.as_str()).map_err(|err| anyhow!(thread_name_invalid_advice(&err)))?;
let _ = (ephemeral, ttl_secs);
let current = ensure_current_state(
repo,
&UserConfig::load_default().unwrap_or_default(),
Some(format!(
"Bootstrap git-overlay before creating thread {}",
name
)),
)?;
repo.refs()
.set_thread_cas(&ThreadName::new(&name), RefExpectation::Missing, ¤t)?;
let base_short = current.short();
let (base_root, verification_summary, confidence_summary) = repo
.store()
.get_state(¤t)?
.map(|state| {
(
state.tree.short(),
summarize_verification(state.verification.as_ref()),
summarize_confidence(state.confidence),
)
})
.ok_or_else(|| {
anyhow!(RecoveryAdvice::thread_referenced_state_missing(
&base_short,
"Base state",
))
})?;
let target_thread = match repo.head_ref()? {
Head::Attached { thread } => Some(thread.to_string()),
Head::Detached { .. } => None,
};
let thread_manager = ThreadManager::new(repo.heddle_dir());
let now = Utc::now();
let thread_state = Thread {
id: name.clone(),
thread: name.clone(),
target_thread,
parent_thread: None,
mode: ThreadMode::Materialized,
state: ThreadState::Active,
base_state: base_short.clone(),
base_root,
current_state: Some(base_short.clone()),
merged_state: None,
task: None,
execution_path: PathBuf::new(),
materialized_path: None,
changed_paths: vec![],
impact_categories: vec![],
heavy_impact_paths: vec![],
promotion_suggested: false,
freshness: ThreadFreshness::Current,
verification_summary,
confidence_summary,
integration_policy_result: ThreadIntegrationPolicy::default(),
created_at: now,
updated_at: now,
ephemeral: if ephemeral {
Some(repo::EphemeralMarker::new(ttl_secs.unwrap_or(24 * 3600)))
} else {
None
},
auto: false,
shared_target_dir: None,
};
thread_manager.save(&thread_state)?;
let manager_snapshot = thread_manager.snapshot_thread_record(&name)?;
repo.oplog().record_thread_create(
&ThreadName::new(&name),
¤t,
manager_snapshot,
Some(&repo.op_scope()),
)?;
let output = thread_op_output(
"thread_create",
"thread create",
name.clone(),
format!("Created thread '{}' at {}", name, current.short()),
None,
None,
Some(build_repository_verification_state(repo)),
find_thread_summary(repo, &name)?,
);
render_thread_op(cli, output)
}
fn open_main_repo_from_worktree_if_needed(repo: &Repository) -> Result<Option<Repository>> {
let expected_for_main = repo.root().join(".heddle");
if expected_for_main == repo.heddle_dir() {
return Ok(None);
}
let main_root = repo.heddle_dir().parent().ok_or_else(|| {
anyhow!(
"heddle dir {} has no parent (the main repo root); cannot route HEAD write",
repo.heddle_dir().display()
)
})?;
Ok(Some(Repository::open(main_root)?))
}
pub(crate) fn cmd_thread_current(cli: &Cli, repo: &Repository) -> Result<()> {
let name = if let Some(lane) = repo.current_lane()? {
lane
} else if let Some(thread) = super::thread_cmd::current_thread(repo)? {
thread.thread
} else {
return Err(anyhow!(RecoveryAdvice::no_current_thread(
"thread current",
None,
"heddle thread list",
)));
};
let explicit_json = matches!(
cli.output,
Some(crate::cli::OutputMode::Json | crate::cli::OutputMode::JsonCompact)
);
if explicit_json {
#[derive(Serialize)]
struct CurrentOutput<'a> {
thread: &'a str,
}
println!(
"{}",
serde_json::to_string(&CurrentOutput { thread: &name })?
);
} else {
println!("{name}");
}
Ok(())
}
pub(crate) fn cmd_thread_cd(repo: &Repository, name: String) -> Result<()> {
let manager = ThreadManager::new(repo.heddle_dir());
let thread = manager
.find_by_thread(&name)?
.ok_or_else(|| anyhow!(thread_not_found_advice(&name, "locate thread worktree")))?;
let path = thread.execution_path;
if path.as_os_str().is_empty() {
return Err(anyhow!(RecoveryAdvice::thread_worktree_unavailable(
&name,
"thread cd",
format!(
"thread `{name}` has no recorded on-disk worktree; it may be virtualized or metadata-only"
),
format!("heddle thread show {name}"),
)));
}
if !path.exists() {
return Err(anyhow!(RecoveryAdvice::thread_worktree_unavailable(
&name,
"thread cd",
format!(
"thread `{name}` is registered at `{}` but that path no longer exists",
path.display()
),
format!("heddle start {name} --path <dir>"),
)));
}
println!("{}", path.display());
Ok(())
}
pub(crate) fn cmd_thread_switch(
cli: &Cli,
repo: &Repository,
name: String,
print_cd_path: bool,
force: bool,
) -> Result<()> {
let state = repo
.refs()
.get_thread(&ThreadName::new(&name))?
.ok_or_else(|| anyhow!(thread_not_found_advice(&name, "switch thread")))?;
if !force {
ensure_worktree_clean(repo, "switch threads")?;
}
let auto_capture_outcome =
if force {
None
} else {
match repo.head_ref()? {
Head::Attached {
thread: source_thread,
} if source_thread != name => {
let manager = ThreadManager::new(repo.heddle_dir());
let source_record = manager.find_by_thread(&source_thread)?;
let source_mode = source_record.as_ref().map(|t| t.mode.clone());
let source_path = source_record
.map(|t| t.execution_path)
.filter(|p| !p.as_os_str().is_empty());
let mode_safe_to_capture = matches!(
source_mode,
Some(ThreadMode::Materialized) | Some(ThreadMode::Solid)
);
match (mode_safe_to_capture, source_path) {
(true, Some(path)) if path.exists() => {
let outcome = repo
.capture_thread_from_disk(&source_thread, &path)
.with_context(|| {
format!("auto-capture of '{source_thread}' before switch to '{name}'")
})?;
Some((source_thread, outcome))
}
_ => None,
}
}
_ => None,
}
};
let manager = ThreadManager::new(repo.heddle_dir());
let dedicated_worktree = manager
.find_by_thread(&name)?
.map(|thread| thread.execution_path)
.filter(|path| !path.as_os_str().is_empty() && path != repo.root());
if let Some(path) = dedicated_worktree {
if !path.exists() {
write_isolated_checkout(repo, &path, &state, Some(&name))?;
}
let head_target_repo = open_main_repo_from_worktree_if_needed(repo)?;
let head_repo = head_target_repo.as_ref().unwrap_or(repo);
head_repo.refs().write_head(&Head::Attached {
thread: ThreadName::new(&name),
})?;
} else if open_main_repo_from_worktree_if_needed(repo)?.is_some() {
return Err(anyhow!(thread_switch_would_overwrite_worktree_advice(
&name
)));
} else {
if force {
repo.goto_discard_local(&state)?;
} else {
repo.goto(&state)?;
}
repo.refs().write_head(&Head::Attached {
thread: ThreadName::new(&name),
})?;
if repo.capability() == repo::RepositoryCapability::GitOverlay
&& repo.root().join(".git").exists()
{
let mut bridge = crate::bridge::GitBridge::new(repo);
match bridge.write_through_thread_checkout(&name)? {
crate::bridge::WriteThroughOutcome::Wrote(_) => {}
crate::bridge::WriteThroughOutcome::Skipped(reason) => {
return Err(anyhow!(thread_switch_git_checkout_skipped_advice(
&name,
reason.to_string()
)));
}
}
}
}
let summary = find_thread_summary(repo, &name)?;
if print_cd_path {
let path = summary
.as_ref()
.and_then(|t| t.execution_path.clone())
.ok_or_else(|| {
anyhow!(RecoveryAdvice::thread_checkout_unavailable(
&name,
"--print-cd-path",
))
})?;
println!("{path}");
return Ok(());
}
let mut message = format!("Switched to thread '{}'", name);
if let Some(thread) = &summary
&& thread.coordination_status != CoordinationStatus::Clean
{
message.push_str(&format!(" [{}]", thread.coordination_status));
}
if let Some((source_thread, ThreadCaptureOutcome::Captured { state_id })) = auto_capture_outcome
{
message.push_str(&format!(
" (auto-captured '{source_thread}' → {})",
state_id.short()
));
}
render_thread_op(
cli,
thread_op_output(
"thread_switch",
"thread switch",
name,
message,
summary.as_ref().and_then(|thread| thread.path.clone()),
summary
.as_ref()
.and_then(|thread| thread.execution_path.clone()),
Some(build_repository_verification_state(repo)),
summary,
),
)
}
fn thread_switch_would_overwrite_worktree_advice(thread: &str) -> RecoveryAdvice {
let primary_command = format!("heddle start {thread} --path <dir>");
RecoveryAdvice::safety_refusal(
"thread_switch_would_overwrite_worktree",
format!("thread '{thread}' has no dedicated worktree"),
format!(
"Run `{primary_command}` to give it a dedicated worktree, or cd to the main repo root and retry `heddle thread switch {thread}`."
),
"the current directory is another thread's dedicated worktree",
"switching here would overwrite this directory's files with the target thread tree",
"the source thread was auto-captured when needed; no checkout files were overwritten",
primary_command.clone(),
vec![primary_command, format!("heddle thread switch {thread}")],
)
}
fn thread_switch_git_checkout_skipped_advice(thread: &str, reason: String) -> RecoveryAdvice {
let primary_command = canonical_bridge_reconcile_ref_preview_command(Some("heddle"), thread);
RecoveryAdvice::safety_refusal(
"thread_switch_git_checkout_skipped",
format!("switched Heddle to '{thread}', but could not update Git checkout: {reason}"),
format!("Inspect the Git/Heddle checkout mapping with `{primary_command}`."),
format!(
"Git checkout write-through was skipped after Heddle switched to '{thread}': {reason}"
),
"Git and Heddle may now point at different checkout states until reconciliation runs",
format!("Heddle HEAD was switched to '{thread}'; Git checkout was left unchanged"),
primary_command.clone(),
vec![primary_command],
)
}
pub fn cmd_thread_show(cli: &Cli, repo: &Repository, name: Option<String>) -> Result<()> {
let name = super::thread_cmd::resolve_thread_name_or_current(
repo,
name,
"thread show",
"heddle thread show <THREAD>",
)?;
let summary = find_thread_summary(repo, &name)?
.ok_or_else(|| anyhow!(thread_not_found_advice(&name, "show thread")))?;
show_thread_summary(cli, repo, &summary)
}
pub(crate) fn show_thread_summary(
cli: &Cli,
repo: &Repository,
summary: &ThreadSummary,
) -> Result<()> {
let mut trust = build_repository_verification_state(repo);
let mut summary = summary.clone();
if !trust.verified {
summary.thread_health = trust.status.clone();
summary.recommended_action = trust.recommended_action.clone();
summary.recommended_action_template = trust.recommended_action_template.clone();
} else {
let action = primary_next_action_with_verification(
None,
None,
None,
Some(&summary.recommended_action),
&trust,
);
let action = contextual_thread_action(
repo,
&summary.name,
summary.target_thread.as_deref(),
&action,
);
if !action.is_empty() {
summary.recommended_action = action;
summary.recommended_action_template =
recommended_action_template(&summary.recommended_action);
}
}
if !trust.recommended_action.is_empty() {
let contextual = contextual_thread_action(
repo,
&summary.name,
summary.target_thread.as_deref(),
&trust.recommended_action,
);
if contextual != trust.recommended_action {
override_trust_recommended_action(&mut trust, contextual);
}
}
if trust.verified
&& !summary.recommended_action.is_empty()
&& trust.recommended_action != summary.recommended_action
&& thread_recovery_action_is_primary(
Some(&summary.thread_health),
&summary.recommended_action,
)
{
override_trust_recommended_action(&mut trust, summary.recommended_action.clone());
}
let presentation = crate::cli::render::repository_presentation(
repo,
summary.target_thread.as_deref(),
summary.parent_thread.as_deref(),
);
if should_output_json(cli, Some(repo.config())) {
let output = ThreadShowOutput {
output_kind: "thread_show",
repository_label: presentation.label,
repository_context: presentation.context,
next_action: summary.recommended_action.clone(),
next_action_template: recommended_action_template(&summary.recommended_action),
recommended_action_template: recommended_action_template(&summary.recommended_action),
summary,
recovery_commands: trust.recovery_commands.clone(),
trust,
};
write_full_command_json(
&output,
NextActionValidationContext::new(&["thread", "show"], repo.capability()),
)?;
} else {
println!("Repository: {}", presentation.label);
render_repository_context_lines(presentation.context.as_ref());
if repo.hosted_enabled() {
println!("Hosted: enabled");
}
let trust_only_blocks_on_this_ready_thread = trust.workflow_status == "ready"
&& trust.recommended_action == summary.recommended_action;
let mut next_step_printed = false;
if !trust.verified
&& !trust_only_blocks_on_this_ready_thread
&& !trust.recommended_action.is_empty()
{
println!("Verification: {}", style::warn(&trust.summary));
print_next_step(&trust.recommended_action);
next_step_printed = true;
}
if let Some(operation) = &summary.operation {
println!(
"In progress: {} {} ({})",
operation.scope, operation.kind, operation.state
);
}
if let Some(remote_tracking) = &summary.remote_tracking {
if remote_tracking.behind == 0 && remote_tracking.ahead > 0 {
println!("Remote sync: {}", remote_tracking.message);
} else {
println!("Remote drift: {}", remote_tracking.message);
}
}
println!();
if summary.is_current {
println!("Thread: {} {}", summary.name, style::dim("(current)"));
} else {
println!("Thread: {}", summary.name);
}
println!("Status: {}", summary.coordination_status);
if cli.verbose > 0
&& let Some(base) = &summary.base_state
{
println!("Base: {}", base);
}
if cli.verbose > 0
&& let Some(base_root) = &summary.base_root
&& !base_root.is_empty()
{
println!("Base tree: {}", base_root);
}
if cli.verbose > 0
&& let Some(current) = &summary.current_state
{
println!("Current: {}", current);
}
if cli.verbose > 0
&& let Some(git_branch_tip) = &summary.git_branch_tip
{
println!("Git tip: {}", git_branch_tip);
println!("History: {}", git_history_label(summary.history_imported));
}
if let Some(path) = &summary.path {
println!("Path: {}", path);
} else if let Some(path) = &summary.execution_path {
println!("Execution root: {}", path);
}
if let Some(mode) = &summary.thread_mode {
let checkout = if summary.is_isolated {
thread_workspace_label(mode)
} else {
"no dedicated checkout"
};
println!("Checkout: {}", checkout);
} else {
println!("Checkout: {}", summary.visibility);
}
if cli.verbose > 0
&& let Some(shared) = &summary.shared_target_dir
{
println!("Shared cargo target: {}", shared);
}
if let Some(state) = &summary.thread_state
&& (cli.verbose > 0 || matches!(state, ThreadState::Merged | ThreadState::Abandoned))
{
println!("Lifecycle: {}", state);
}
if let Some(freshness) = &summary.freshness
&& *freshness != ThreadFreshness::Unknown
&& !matches!(
summary.thread_state,
Some(ThreadState::Merged | ThreadState::Abandoned)
)
{
println!("Sync: {}", freshness);
}
if let Some(target) = &summary.target_thread {
println!("Target thread: {}", target);
}
if cli.verbose > 0
&& let Some(parent) = &summary.parent_thread
{
println!("Parent thread: {}", parent);
}
if cli.verbose > 0 && !summary.child_threads.is_empty() {
println!("Child threads: {}", summary.child_threads.join(", "));
}
if cli.verbose > 0 && !summary.sibling_threads.is_empty() {
println!("Sibling threads: {}", summary.sibling_threads.join(", "));
}
if cli.verbose > 0 && summary.stack_depth > 0 {
println!("Stack depth: {}", summary.stack_depth);
}
if summary.stale_from_parent {
println!("Parent drift: parent moved since this thread last refreshed");
}
if cli.verbose > 0 {
if let Some(actor) = &summary.actor
&& let Some(text) = crate::cli::render::actor_display(
actor.provider.as_deref(),
actor.model.as_deref(),
)
{
println!("Actor: {text}");
}
if let Some(session_id) = &summary.session_id {
println!("Session: {}", session_id);
}
if let Some(session) = &summary.heddle_session_id {
println!("Heddle session: {}", session);
}
if let Some(harness) = &summary.harness {
println!("Harness: {}", harness);
}
if let Some(thinking_level) = &summary.thinking_level {
println!("Thinking: {}", thinking_level);
}
if let Some(last_progress_at) = &summary.last_progress_at {
println!("Last progress: {}", last_progress_at);
}
}
if cli.verbose > 0
&& let Some(last_activity_at) = &summary.last_activity_at
{
println!("Last activity: {}", last_activity_at);
}
if cli.verbose > 0
&& let Some(report_flush_state) = &summary.report_flush_state
{
println!("Report flush: {}", report_flush_state);
}
if cli.verbose > 0
&& let Some(attach_reason) = &summary.attach_reason
{
println!("Attach: {}", attach_reason);
}
if cli.verbose > 0
&& let Some(usage_summary) = &summary.usage_summary
{
let mut parts = Vec::new();
if let Some(input) = usage_summary.input_tokens {
parts.push(format!("input {}", input));
}
if let Some(output) = usage_summary.output_tokens {
parts.push(format!("output {}", output));
}
if let Some(reasoning) = usage_summary.reasoning_tokens {
parts.push(format!("reasoning {}", reasoning));
}
if let Some(tool_calls) = usage_summary.tool_calls {
parts.push(format!("tools {}", tool_calls));
}
if let Some(cost) = usage_summary.cost_micros_usd {
parts.push(format!("cost {}uUSD", cost));
}
if !parts.is_empty() {
println!("Usage: {}", parts.join(" · "));
}
}
if let Some(task) = &summary.task {
println!("Task: {}", task);
}
let captures = if cli.verbose > 0 {
collect_thread_captures(repo, &summary.name, 5).unwrap_or_default()
} else {
Vec::new()
};
if !captures.is_empty() {
println!();
println!("{}", style::section("Recent saved states"));
for capture in captures {
println!(
" {} {}",
style::accent(&capture.change_id),
capture.message
);
}
}
if summary.promotion_suggested && !summary.heavy_impact_paths.is_empty() {
println!(
"Promotion suggested: {}",
crate::cli::render::preview_list(
&summary.heavy_impact_paths,
summary.heavy_impact_paths.len(),
)
);
}
if !summary.impact_categories.is_empty() {
println!(
"Impact categories: {}",
summary
.impact_categories
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
);
}
if !summary.blockers.is_empty() {
println!("Blocked by: {}", summary.blockers.join(" | "));
}
if !summary.recommended_action.is_empty() && !next_step_printed {
print_next_step(&summary.recommended_action);
}
}
Ok(())
}
#[derive(Clone, Copy)]
pub(crate) enum DropMode {
Drop,
DeleteThread,
}
impl DropMode {
fn retry_command(self, current: &str) -> String {
match self {
DropMode::Drop => format!("heddle thread drop {current}"),
DropMode::DeleteThread => format!("heddle thread drop {current} --delete-thread"),
}
}
}
pub(crate) fn current_thread_drop_recovery(
repo: &Repository,
current: &str,
mode: DropMode,
) -> (String, Vec<String>, String) {
const SWITCH: &str = "heddle thread switch <other>";
const CREATE: &str = "heddle thread create <other>";
let retry = mode.retry_command(current);
let has_other = repo
.refs()
.list_threads()
.map(|threads| threads.iter().any(|name| name.as_str() != current))
.unwrap_or(false);
if has_other {
(
SWITCH.to_string(),
vec![SWITCH.to_string(), CREATE.to_string()],
format!(
"Switch to another thread with `{SWITCH}` (or start one with `{CREATE}`), then retry `{retry}`."
),
)
} else {
(
CREATE.to_string(),
vec![CREATE.to_string(), SWITCH.to_string()],
format!(
"No other thread exists yet. Create one with `{CREATE}`, switch to it, then retry `{retry}`."
),
)
}
}
pub(crate) fn cmd_thread_delete(cli: &Cli, repo: &Repository, name: String) -> Result<()> {
if let Head::Attached { thread } = repo.head_ref()?
&& thread == name
{
let (primary, recovery, hint) =
current_thread_drop_recovery(repo, &name, DropMode::DeleteThread);
return Err(anyhow!(RecoveryAdvice::safety_refusal(
"branch_delete_current",
format!("Refusing to delete current thread '{name}'"),
hint,
format!("HEAD is attached to '{name}'"),
"deleting the attached thread would strand the current checkout without its branch ref",
"no refs were moved or deleted",
primary,
recovery,
)));
}
let thread_name = ThreadName::new(&name);
let state = repo
.refs()
.delete_thread(&thread_name)?
.ok_or_else(|| anyhow!(thread_not_found_advice(&name, "delete thread")))?;
repo.oplog()
.record_thread_delete(&thread_name, &state, Some(&repo.op_scope()))?;
let output = thread_op_output(
"thread_drop",
"thread drop",
name.clone(),
format!("Deleted thread '{}'", name),
None,
None,
Some(build_repository_verification_state(repo)),
None,
);
render_thread_op(cli, output)
}
pub(crate) fn cmd_thread_rename(
cli: &Cli,
repo: &Repository,
old: String,
new: String,
) -> Result<()> {
ThreadId::new(new.as_str()).map_err(|err| anyhow!(thread_name_invalid_advice(&err)))?;
let old_tn = ThreadName::new(&old);
let new_tn = ThreadName::new(&new);
let state = repo
.refs()
.get_thread(&old_tn)?
.ok_or_else(|| anyhow!(thread_not_found_advice(&old, "rename thread")))?;
let mut updates = vec![
RefUpdate::Thread {
name: new_tn.clone(),
expected: RefExpectation::Missing,
new: Some(state),
},
RefUpdate::Thread {
name: old_tn.clone(),
expected: RefExpectation::Value(state),
new: None,
},
];
if let Head::Attached { thread } = repo.head_ref()?
&& thread == old
{
updates.push(RefUpdate::Head {
expected: RefExpectation::Value(Head::Attached {
thread: old_tn.clone(),
}),
new: Head::Attached {
thread: new_tn.clone(),
},
});
}
repo.refs().update_refs(&updates)?;
repo.oplog()
.record_thread_rename(&old_tn, &new_tn, &state, Some(&repo.op_scope()))?;
let output = thread_op_output(
"thread_rename",
"thread rename",
new.clone(),
format!("Renamed thread '{}' to '{}'", old, new),
None,
None,
Some(build_repository_verification_state(repo)),
find_thread_summary(repo, &new)?,
);
render_thread_op(cli, output)
}
fn render_thread_op(cli: &Cli, output: ThreadOpOutput) -> Result<()> {
if should_output_json(cli, None) {
println!("{}", serde_json::to_string(&output)?);
} else {
println!("{}", style::accent(&output.message));
if let Some(thread) = &output.thread {
if let Some(path) = &thread.path {
println!("Path: {}", style::dim(path));
println!("Run this to switch shells:");
println!(
" cd {}",
style::accent(&crate::cli::render::shell_quote(path))
);
} else if let Some(path) = non_empty_string(thread.execution_path.as_deref()) {
println!("Execution root: {}", style::dim(path));
}
if !thread.recommended_action.is_empty() {
print_next_step(&thread.recommended_action);
}
}
}
Ok(())
}
fn non_empty_string(value: Option<&str>) -> Option<&str> {
value.and_then(|value| {
if value.trim().is_empty() {
None
} else {
Some(value)
}
})
}
#[allow(clippy::too_many_arguments)]
fn thread_op_output(
output_kind: &'static str,
action: &'static str,
name: String,
message: String,
path: Option<String>,
execution_path: Option<String>,
trust: Option<RepositoryVerificationState>,
thread: Option<ThreadSummary>,
) -> ThreadOpOutput {
let recommended_action = thread
.as_ref()
.and_then(|thread| non_empty_action(&thread.recommended_action))
.or_else(|| {
trust
.as_ref()
.and_then(|trust| non_empty_action(&trust.recommended_action))
});
let recommended_action_template = recommended_action
.as_deref()
.and_then(recommended_action_template);
ThreadOpOutput {
output_kind,
status: "completed",
action,
name,
message,
next_action: recommended_action.clone(),
next_action_template: recommended_action_template.clone(),
recommended_action,
recommended_action_template,
thread,
path,
execution_path,
fskit_readiness: None,
trust,
}
}
fn non_empty_action(action: &str) -> Option<String> {
(!action.trim().is_empty()).then(|| action.to_string())
}
fn default_thread_checkout_path(repo: &Repository, name: &str) -> PathBuf {
repo.managed_checkout_path(name)
}
fn default_thread_path(repo: &Repository, name: &str) -> PathBuf {
default_thread_checkout_path(repo, name)
}
fn ensure_explicit_start_path_outside_tracked_tree(
repo: &Repository,
name: &str,
path: &Path,
) -> Result<()> {
if repo.capability() != repo::RepositoryCapability::GitOverlay {
return Ok(());
}
let requested = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()?.join(path)
};
let requested_for_check = normalize_path_for_containment(&requested)?;
let heddle_dir = normalize_path_for_containment(repo.heddle_dir())?;
if requested_for_check == heddle_dir || requested_for_check.starts_with(&heddle_dir) {
return Ok(());
}
let repo_root = normalize_path_for_containment(repo.root())?;
if requested_for_check == repo_root || requested_for_check.starts_with(&repo_root) {
let suggested = default_thread_path(repo, name);
let suggested_command = format!("heddle start {name} --path {}", suggested.display());
return Err(anyhow!(RecoveryAdvice::safety_refusal(
"thread_start_path_inside_repo",
format!(
"Refusing to start thread '{name}' inside the current repository at '{}'",
requested_for_check.display()
),
format!(
"Choose a checkout under `.heddle/threads` (the default) or a sibling outside the repository, for example `{suggested_command}`."
),
format!(
"requested checkout path '{}' is inside the tracked working tree of repository '{}'",
requested_for_check.display(),
repo_root.display()
),
"starting an isolated checkout inside the source worktree would make Heddle report the nested checkout as unsaved work",
"no thread refs, checkout directories, mounts, or worktree files were changed",
suggested_command.clone(),
vec![suggested_command],
)));
}
Ok(())
}
fn normalize_path_for_containment(path: &Path) -> Result<PathBuf> {
let mut ancestor = path;
while !ancestor.exists() {
ancestor = ancestor
.parent()
.ok_or_else(|| anyhow!("path '{}' has no usable ancestor", path.display()))?;
}
let mut normalized = ancestor.canonicalize()?;
let remainder = path.strip_prefix(ancestor).with_context(|| {
format!(
"path '{}' could not be normalized relative to '{}'",
path.display(),
ancestor.display()
)
})?;
for component in remainder.components() {
match component {
std::path::Component::Normal(part) => normalized.push(part),
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
normalized.pop();
}
std::path::Component::Prefix(_) | std::path::Component::RootDir => {}
}
}
Ok(normalized)
}
fn default_lightweight_thread_path(repo: &Repository, name: &str) -> PathBuf {
default_thread_checkout_path(repo, name)
}
fn default_virtualized_thread_path(repo: &Repository, name: &str) -> PathBuf {
default_thread_checkout_path(repo, name)
}
fn absolute_path(path: &std::path::Path) -> Result<PathBuf> {
if path.is_absolute() {
Ok(path.to_path_buf())
} else {
Ok(std::env::current_dir()?.join(path))
}
}
pub(crate) fn find_active_thread_entry(
repo: &Repository,
thread: &str,
) -> Result<Option<AgentEntry>> {
let registry = AgentRegistry::new(repo.heddle_dir());
Ok(registry
.list()?
.into_iter()
.filter(|entry| entry.thread == thread && entry.status == AgentStatus::Active)
.max_by_key(|entry| entry.started_at))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_execution_paths_are_suppressed() {
assert_eq!(display_path_string(&PathBuf::new()), None);
assert_eq!(non_empty_string(Some("")), None);
assert_eq!(non_empty_string(Some(" ")), None);
}
#[test]
fn git_checkout_skipped_after_thread_switch_uses_reconcile_advice() {
let advice =
thread_switch_git_checkout_skipped_advice("feature/git", "dirty Git index".to_string());
assert_eq!(advice.kind, "thread_switch_git_checkout_skipped");
assert!(advice.error.contains("switched Heddle to 'feature/git'"));
assert!(advice.unsafe_condition.contains("dirty Git index"));
assert_eq!(
advice.primary_command,
"heddle bridge git reconcile --prefer heddle --ref feature/git --preview"
);
assert!(advice.preserved.contains("Git checkout was left unchanged"));
}
#[test]
fn slashed_thread_id_checkout_and_manifest_agree() {
let repo_dir = tempfile::TempDir::new().unwrap();
let repo = Repository::init_default(repo_dir.path()).unwrap();
let checkout = default_thread_checkout_path(&repo, "foo/bar");
let manifest = repo::thread_manifest::manifest_path(repo.heddle_dir(), "foo/bar");
let threads_root = repo.heddle_dir().join("threads");
assert_eq!(checkout.parent().unwrap(), manifest.parent().unwrap());
assert_eq!(checkout.parent().unwrap(), threads_root.join("foo%2Fbar"));
assert_eq!(
checkout.file_name().unwrap(),
repo.managed_checkout_source_root().file_name().unwrap()
);
assert!(checkout.starts_with(&threads_root));
assert!(manifest.starts_with(&threads_root));
let foo = default_thread_checkout_path(&repo, "foo");
let foo_dir = foo.parent().unwrap();
let bar_dir = checkout.parent().unwrap();
assert!(!bar_dir.starts_with(foo_dir) && !foo_dir.starts_with(bar_dir));
let other = default_thread_checkout_path(&repo, "foo-bar");
assert_ne!(checkout, other);
}
#[test]
fn available_git_ref_serializes_empty_recommended_action_as_null() {
let value = serde_json::to_value(AvailableGitRef {
name: "main".to_string(),
git_commit: "0123456789abcdef".to_string(),
recommended_action: String::new(),
recommended_action_template: None,
})
.unwrap();
assert!(value["recommended_action"].is_null());
}
}