Skip to main content

cli/cli/commands/
agent_cmd.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Stable JSON-first agent reservation API.
3
4use std::{collections::BTreeSet, path::PathBuf};
5
6use anyhow::{Result, anyhow};
7use chrono::Utc;
8use objects::{
9    object::ThreadName,
10    store::{
11        AgentEntry, AgentRegistry, AgentStatus, AgentTaskRecord, AgentTaskStatus, AgentTaskStore,
12        AgentUsageSummary, ObjectStore, ReserveOutcome, current_boot_id, validate_task_id,
13    },
14};
15use oplog::OpLogRecorder;
16use refs::{Head, RefExpectation};
17use repo::{
18    Repository, Thread, ThreadConfidenceSummary, ThreadFreshness, ThreadId,
19    ThreadIntegrationPolicy, ThreadManager, ThreadMode, ThreadState, ThreadVerificationSummary,
20};
21use schemars::JsonSchema;
22use serde::Serialize;
23
24use super::{
25    advice::RecoveryAdvice,
26    thread::thread_name_invalid_advice,
27    verification_health::{
28        GitOverlayMutationPreflight, RepositoryVerificationState,
29        build_repository_verification_state, git_overlay_mutation_preflight_advice,
30    },
31    worktree_cmd::helpers::plan_worktree_target,
32    worktree_safety::ensure_worktree_clean,
33};
34use crate::cli::{
35    Cli,
36    cli_args::{
37        AgentApiListArgs, AgentFanoutCommands, AgentFanoutPlanArgs, AgentFanoutStartArgs,
38        AgentHeartbeatArgs, AgentReleaseArgs, AgentReleaseStatusArg, AgentReserveArgs,
39        AgentTaskCommands, AgentTaskCreateArgs, AgentTaskListArgs, AgentTaskShowArgs,
40        AgentTaskStatusArg, AgentTaskUpdateArgs, ThreadStartArgs, WorkspaceModeArg,
41    },
42    render::shell_quote,
43    should_output_json,
44};
45
46#[derive(Serialize, JsonSchema)]
47pub struct AgentReservationOutput {
48    pub session_id: String,
49    pub reservation_token: Option<String>,
50    pub thread: String,
51    pub anchor_state: Option<String>,
52    pub anchor_root: Option<String>,
53    pub task_assignment_id: Option<String>,
54    /// Lifecycle status as a stable kebab-case string
55    /// (`active|abandoned|complete|merged`). Mirrors
56    /// `objects::store::AgentStatus` but kept as a `String` here so
57    /// the schema lives entirely in the CLI crate.
58    pub status: String,
59    pub path: Option<String>,
60    pub task: Option<String>,
61    pub provider: Option<String>,
62    pub model: Option<String>,
63    pub harness: Option<String>,
64    pub thinking_level: Option<String>,
65    pub probe_source: Option<String>,
66    pub probe_confidence: Option<f32>,
67}
68
69#[derive(Serialize, JsonSchema)]
70pub(crate) struct AgentReservationEnvelope {
71    pub reservation: AgentReservationOutput,
72    #[allow(dead_code)]
73    #[serde(skip_serializing)]
74    #[serde(rename = "verification")]
75    pub trust: RepositoryVerificationState,
76}
77
78#[derive(Serialize, JsonSchema)]
79pub(crate) struct AgentReservationListOutput {
80    pub reservations: Vec<AgentReservationOutput>,
81    pub alive_only: bool,
82    pub thread: Option<String>,
83    #[serde(rename = "verification")]
84    pub trust: RepositoryVerificationState,
85}
86
87#[derive(Serialize, JsonSchema)]
88pub(crate) struct AgentTaskEnvelope {
89    pub output_kind: &'static str,
90    pub task: AgentTaskOutput,
91    #[serde(rename = "verification")]
92    pub trust: RepositoryVerificationState,
93}
94
95#[derive(Serialize, JsonSchema)]
96pub(crate) struct AgentTaskListOutput {
97    pub output_kind: &'static str,
98    pub tasks: Vec<AgentTaskOutput>,
99    pub thread: Option<String>,
100    pub status: Option<String>,
101    #[serde(rename = "verification")]
102    pub trust: RepositoryVerificationState,
103}
104
105#[derive(Serialize, JsonSchema)]
106pub(crate) struct AgentTaskOutput {
107    pub schema_version: u32,
108    pub task_id: String,
109    pub title: String,
110    pub body: String,
111    pub status: String,
112    pub target_thread: String,
113    pub base_state: Option<String>,
114    pub base_root: Option<String>,
115    pub parent_task_id: Option<String>,
116    pub coordination_discussion_id: Option<String>,
117    pub allow_offline: bool,
118    pub delegated_by: Option<String>,
119    pub created_at: String,
120    pub updated_at: String,
121    pub completed_at: Option<String>,
122}
123
124#[derive(Serialize, JsonSchema)]
125pub(crate) struct AgentFanoutOutput {
126    pub output_kind: &'static str,
127    pub title: String,
128    pub parent_thread: String,
129    pub base_state: String,
130    pub base_root: String,
131    pub coordination_discussion_id: Option<String>,
132    pub parent_task: Option<AgentTaskOutput>,
133    pub lanes: Vec<AgentFanoutLaneOutput>,
134    pub commands: Vec<AgentFanoutCommandOutput>,
135    #[serde(rename = "verification")]
136    pub trust: RepositoryVerificationState,
137}
138
139#[derive(Serialize, JsonSchema)]
140pub(crate) struct AgentFanoutLaneOutput {
141    pub thread: String,
142    pub path: String,
143    pub title: String,
144    pub task: Option<AgentTaskOutput>,
145    pub session_id: Option<String>,
146    pub status: String,
147}
148
149#[derive(Serialize, JsonSchema)]
150pub(crate) struct AgentFanoutCommandOutput {
151    pub lane_thread: String,
152    pub command: String,
153    pub argv: Vec<String>,
154}
155
156impl From<&AgentEntry> for AgentReservationOutput {
157    fn from(entry: &AgentEntry) -> Self {
158        Self {
159            session_id: entry.session_id.clone(),
160            reservation_token: entry.reservation_token.clone(),
161            thread: entry.thread.clone(),
162            anchor_state: entry.anchor_state.clone(),
163            anchor_root: entry.anchor_root.clone(),
164            task_assignment_id: entry.task_assignment_id.clone(),
165            status: entry.status.to_string(),
166            path: entry.path.as_ref().map(|path| path.display().to_string()),
167            task: entry.attach_reason.clone(),
168            provider: entry.provider.clone(),
169            model: entry.model.clone(),
170            harness: entry.harness.clone(),
171            thinking_level: entry.thinking_level.clone(),
172            probe_source: entry.probe_source.clone(),
173            probe_confidence: entry.probe_confidence,
174        }
175    }
176}
177
178impl From<&AgentTaskRecord> for AgentTaskOutput {
179    fn from(task: &AgentTaskRecord) -> Self {
180        Self {
181            schema_version: task.schema_version,
182            task_id: task.task_id.clone(),
183            title: task.title.clone(),
184            body: task.body.clone(),
185            status: task.status.to_string(),
186            target_thread: task.target_thread.clone(),
187            base_state: task.base_state.clone(),
188            base_root: task.base_root.clone(),
189            parent_task_id: task.parent_task_id.clone(),
190            coordination_discussion_id: task.coordination_discussion_id.clone(),
191            allow_offline: task.allow_offline,
192            delegated_by: task.delegated_by.clone(),
193            created_at: task.created_at.to_rfc3339(),
194            updated_at: task.updated_at.to_rfc3339(),
195            completed_at: task.completed_at.map(|time| time.to_rfc3339()),
196        }
197    }
198}
199
200fn live_owner_conflict_advice(
201    thread: &str,
202    requested_anchor_full: &str,
203    owner: &AgentEntry,
204) -> RecoveryAdvice {
205    let kind = if owner.anchor_state.as_deref() == Some(requested_anchor_full) {
206        "live_owner"
207    } else {
208        "anchor_drift"
209    };
210    let primary_command = format!("heddle thread show {thread}");
211    if kind == "live_owner" {
212        RecoveryAdvice::safety_refusal(
213            "live_owner",
214            format!(
215                "thread '{thread}' already has a live reservation on session '{}'",
216                owner.session_id
217            ),
218            format!(
219                "Inspect it with `{primary_command}`, or release that session before starting another writer."
220            ),
221            format!(
222                "thread '{thread}' is reserved by live session '{}' at anchor {}",
223                owner.session_id,
224                owner.anchor_state.as_deref().unwrap_or("<unknown>")
225            ),
226            "starting another writer could create competing histories for the same thread",
227            "no thread refs or reservation records were changed",
228            primary_command.clone(),
229            vec![primary_command],
230        )
231    } else {
232        RecoveryAdvice::safety_refusal(
233            "anchor_drift",
234            format!(
235                "thread '{thread}' is reserved by session '{}' on anchor {}, but reservation requested {requested_anchor_full}",
236                owner.session_id,
237                owner.anchor_state.as_deref().unwrap_or("<unknown>")
238            ),
239            "Refresh the thread or rebase before retrying.".to_string(),
240            format!("thread '{thread}' has an active reservation at a different anchor"),
241            "starting from the requested anchor could fork the same thread name into competing histories",
242            "no thread refs or reservation records were changed",
243            primary_command.clone(),
244            vec![primary_command],
245        )
246    }
247}
248
249fn anchor_drift_no_owner_advice(
250    thread: &str,
251    requested_anchor_full: &str,
252    reserved_anchor: &str,
253) -> RecoveryAdvice {
254    let primary_command = format!("heddle thread show {thread}");
255    RecoveryAdvice::safety_refusal(
256        "anchor_drift",
257        format!(
258            "thread '{thread}' is anchored at {reserved_anchor}, but reservation requested {requested_anchor_full}"
259        ),
260        "Refresh the thread or rebase before retrying.".to_string(),
261        format!("thread '{thread}' already points at a different anchor"),
262        "starting from the requested anchor could fork the same thread name into competing histories",
263        "no thread refs or reservation records were changed",
264        primary_command.clone(),
265        vec![primary_command],
266    )
267}
268
269fn agent_task_not_found_advice(task_id: &str) -> RecoveryAdvice {
270    RecoveryAdvice::safety_refusal(
271        "agent_task_not_found",
272        format!("agent task '{task_id}' not found"),
273        "Create the task locally, or reserve without --task-id if no task assignment exists.",
274        format!("no task record exists for {task_id}"),
275        "continuing would attach an unverifiable task provenance id to the reservation",
276        "no thread refs or reservation records were changed",
277        format!("heddle agent task show {task_id}"),
278        vec![format!("heddle agent task show {task_id}")],
279    )
280}
281
282fn agent_task_mismatch_advice(task_id: &str, message: String) -> RecoveryAdvice {
283    RecoveryAdvice::safety_refusal(
284        "agent_task_mismatch",
285        message.clone(),
286        "Reserve the task on its target thread and base, or update the local task record first.",
287        message,
288        "continuing would attach task provenance to work outside the delegated target",
289        "no thread refs or reservation records were changed",
290        format!("heddle agent task show {task_id}"),
291        vec![format!("heddle agent task show {task_id}")],
292    )
293}
294
295fn load_task_for_reservation(
296    repo: &Repository,
297    task_id: &str,
298    thread_name: &str,
299    anchor_full: &str,
300    anchor_short: &str,
301    anchor_root: &str,
302) -> Result<AgentTaskRecord> {
303    validate_task_id(task_id).map_err(|err| anyhow!(err))?;
304    let store = AgentTaskStore::new(repo.heddle_dir());
305    let task = store
306        .load(task_id)?
307        .ok_or_else(|| anyhow!(agent_task_not_found_advice(task_id)))?;
308    if task.target_thread != thread_name {
309        return Err(anyhow!(agent_task_mismatch_advice(
310            task_id,
311            format!(
312                "agent task '{task_id}' targets thread '{}', but reservation requested '{}'",
313                task.target_thread, thread_name
314            ),
315        )));
316    }
317    if let Some(base_state) = task.base_state.as_deref()
318        && base_state != anchor_full
319        && base_state != anchor_short
320    {
321        return Err(anyhow!(agent_task_mismatch_advice(
322            task_id,
323            format!(
324                "agent task '{task_id}' base_state is {base_state}, but reservation anchor is {anchor_full}"
325            ),
326        )));
327    }
328    if let Some(base_root) = task.base_root.as_deref()
329        && base_root != anchor_root
330    {
331        return Err(anyhow!(agent_task_mismatch_advice(
332            task_id,
333            format!(
334                "agent task '{task_id}' base_root is {base_root}, but reservation anchor root is {anchor_root}"
335            ),
336        )));
337    }
338    Ok(task)
339}
340
341pub fn cmd_agent_reserve(cli: &Cli, args: AgentReserveArgs) -> Result<()> {
342    // User/external creation boundary: `agent reserve` persists a thread
343    // record, so reject an unsafe thread name here too (same early-reject
344    // rule as `start_thread` / `thread create`). (heddle#464 close-the-class.)
345    ThreadId::new(args.thread.as_str()).map_err(|err| anyhow!(thread_name_invalid_advice(&err)))?;
346    let repo = cli.open_repo()?;
347    let anchor = match &args.anchor {
348        Some(spec) => repo
349            .resolve_state(spec)?
350            .ok_or_else(|| anyhow!("anchor state '{}' not found", spec))?,
351        None => repo
352            .head()?
353            .ok_or_else(|| anyhow!("repository has no HEAD state to reserve from"))?,
354    };
355    let anchor_root = repo
356        .store()
357        .get_state(&anchor)?
358        .map(|state| state.tree.short())
359        .unwrap_or_default();
360    let anchor_full = anchor.to_string_full();
361    let anchor_short = anchor.short();
362    let thread_name = args.thread.clone();
363    let task_record = match args.task_id.as_deref() {
364        Some(task_id) => Some(load_task_for_reservation(
365            &repo,
366            task_id,
367            &thread_name,
368            &anchor_full,
369            &anchor_short,
370            &anchor_root,
371        )?),
372        None => None,
373    };
374
375    // Hard pre-check: a thread ref already pointing at a different
376    // state without any live owner is an anchor-drift case the caller
377    // must resolve before we hand them a fresh reservation. We surface
378    // it here (rather than letting set_thread_cas fail later) so the
379    // standard JSON error envelope can describe the conflict.
380    let existing_ref = repo.refs().get_thread(&ThreadName::new(&thread_name))?;
381    if let Some(existing) = existing_ref
382        && existing != anchor
383    {
384        // Look for a live owner first — if one exists, route through
385        // the shared reservation advice so the caller sees the owner's
386        // session_id alongside the drift in the standard error envelope.
387        let registry = AgentRegistry::new(repo.heddle_dir());
388        registry.reap_dead_for_thread(&thread_name)?;
389        if let Some(owner) = registry
390            .list()?
391            .into_iter()
392            .find(|entry| entry.status == AgentStatus::Active && entry.thread == thread_name)
393        {
394            return Err(anyhow!(live_owner_conflict_advice(
395                &thread_name,
396                &anchor_full,
397                &owner
398            )));
399        }
400        return Err(anyhow!(anchor_drift_no_owner_advice(
401            &thread_name,
402            &anchor_full,
403            &existing.to_string_full(),
404        )));
405    }
406
407    let registry = AgentRegistry::new(repo.heddle_dir());
408    let task = args.task.clone();
409    let task_assignment_id = task_record.as_ref().map(|task| task.task_id.clone());
410    let anchor_full_for_entry = anchor_full.clone();
411    let anchor_short = anchor.short();
412    let reservation_path = existing_thread_execution_path(&repo, &thread_name)?;
413    let probe = crate::harness::probe_current_process_harness(
414        &repo,
415        std::env::var("HEDDLE_AGENT_PROVIDER")
416            .ok()
417            .and_then(crate::attribution::clean_attribution_value),
418        std::env::var("HEDDLE_AGENT_MODEL")
419            .ok()
420            .and_then(crate::attribution::clean_attribution_value),
421        std::env::var("HEDDLE_AGENT_POLICY")
422            .ok()
423            .and_then(crate::attribution::clean_attribution_value),
424    )?;
425    // `--hold-for-pid PID` binds the reservation to an external
426    // process (typically the orchestrator that wraps the heddle
427    // CLI). Without it we record this one-shot CLI's pid, which
428    // exits before the next liveness check — fine when the calling
429    // script doesn't care about reaping, but means the dead-pid
430    // reaper would recycle the reservation immediately if a second
431    // agent races in. With `--hold-for-pid` the reservation tracks
432    // the orchestrator's lifetime instead.
433    let recorded_pid = args.hold_for_pid.unwrap_or_else(std::process::id);
434    let outcome = registry.try_reserve_thread(&thread_name, |session_id| {
435        Ok(AgentEntry {
436            session_id: session_id.to_string(),
437            client_instance_id: None,
438            native_actor_key: None,
439            native_parent_actor_key: None,
440            native_instance_key: None,
441            heddle_session_id: None,
442            thread_id: Some(thread_name.clone()),
443            thread: thread_name.clone(),
444            pid: Some(recorded_pid),
445            boot_id: current_boot_id(),
446            liveness_path: Some(
447                repo.heddle_dir()
448                    .join("agents")
449                    .join(format!("{session_id}.live")),
450            ),
451            heartbeat_at: Some(Utc::now()),
452            anchor_state: Some(anchor_full_for_entry.clone()),
453            anchor_root: Some(anchor_root.clone()),
454            reservation_token: Some(objects::store::generate_agent_id()),
455            path: reservation_path.clone(),
456            base_state: anchor_short.clone(),
457            started_at: Utc::now(),
458            provider: probe.provider.clone(),
459            model: probe.model.clone(),
460            harness: probe
461                .harness
462                .clone()
463                .or_else(|| Some("heddle-agent-api".to_string())),
464            thinking_level: probe.thinking_level.clone(),
465            usage_summary: AgentUsageSummary::default(),
466            last_progress_at: None,
467            report_flush_state: None,
468            attach_reason: task.clone(),
469            task_assignment_id: task_assignment_id.clone(),
470            attach_precedence: vec!["agent-reserve".to_string()],
471            winning_attach_rule: Some("agent-reserve".to_string()),
472            probe_source: probe
473                .probe_source
474                .clone()
475                .or_else(|| Some("agent_api".to_string())),
476            probe_confidence: probe.confidence.or(Some(1.0)),
477            status: AgentStatus::Active,
478            completed_at: None,
479            context_queries: vec![],
480        })
481    })?;
482
483    let entry = match outcome {
484        ReserveOutcome::Reserved(entry) => entry,
485        ReserveOutcome::LiveOwner(existing) => {
486            return Err(anyhow!(live_owner_conflict_advice(
487                &thread_name,
488                &anchor_full,
489                &existing,
490            )));
491        }
492    };
493
494    // We hold the reservation. The remaining steps (CAS, oplog record,
495    // thread metadata, JSON emit) must be all-or-nothing from the
496    // caller's perspective: if any step fails after `try_reserve_thread`
497    // wrote the Active entry, we have to mark that entry Abandoned
498    // before returning the error. Otherwise the caller never sees a
499    // session_id (no successful JSON output), but the registry retains
500    // a live-owner row that ghost-blocks subsequent reservers — which
501    // is exactly what `try_reserve_thread`'s own conflict logic would
502    // hit, until pid-liveness reaping eventually clears it.
503    //
504    // The race the reviewer flagged: another writer advances the
505    // thread ref between the pre-check at line 161 and the CAS below,
506    // causing `set_thread_cas` to return ExpectationViolated. The
507    // fallible closure here ensures we abandon the orphaned reservation
508    // before bubbling that error up.
509    let tn = ThreadName::new(&thread_name);
510    let post_reserve = (|| -> Result<()> {
511        if let Some(existing) = existing_ref {
512            repo.refs()
513                .set_thread_cas(&tn, RefExpectation::Value(existing), &anchor)?;
514        } else {
515            repo.refs()
516                .set_thread_cas(&tn, RefExpectation::Missing, &anchor)?;
517            // Agent-reservation flow writes the ThreadManager record via
518            // `ensure_thread_record` below, after this op is recorded —
519            // so there's no record to snapshot at recording time. Pass
520            // `None`; the op records as `ThreadCreate` with no
521            // manager snapshot. Reservations are an agent-internal API
522            // that aren't expected to participate in human undo/redo
523            // flows in 0.3. heddle#23 r2.
524            repo.oplog()
525                .record_thread_create(&tn, &anchor, None, Some(&repo.op_scope()))?;
526        }
527
528        // Ensure a Thread record exists so downstream commands
529        // (`agent ready`, `thread show`, `ready`, `merge --preview`) have
530        // first-class metadata to work with. `start_thread` does the
531        // same; we mirror just the minimum required for the agent API.
532        ensure_thread_record(&repo, &thread_name, &anchor, &args.task)?;
533
534        render_agent_reservation_envelope(&repo, &entry)?;
535        Ok(())
536    })();
537
538    if let Err(err) = post_reserve {
539        // Best-effort abandon: if the registry write itself fails (FS
540        // error mid-cleanup), surface the original error and let the
541        // pid-based dead-owner reaper recycle the orphan on the next
542        // reserve. Logging the secondary failure would be ideal but
543        // would make the error wire-format dependent on transient FS
544        // state, so we keep the structured surface clean.
545        let _ = registry.update_entry(&entry.session_id, |e| {
546            e.status = AgentStatus::Abandoned;
547            e.completed_at = Some(Utc::now());
548        });
549        return Err(err);
550    }
551
552    Ok(())
553}
554
555fn existing_thread_execution_path(
556    repo: &Repository,
557    thread_name: &str,
558) -> Result<Option<std::path::PathBuf>> {
559    let Some(thread) = ThreadManager::new(repo.heddle_dir()).find_by_thread(thread_name)? else {
560        return Ok(None);
561    };
562    let path = if !thread.execution_path.as_os_str().is_empty() {
563        Some(thread.execution_path)
564    } else {
565        thread.materialized_path
566    };
567    Ok(path.map(|path| path.canonicalize().unwrap_or(path)))
568}
569
570/// Persist a minimal `Thread` record for `thread_name` if one does not
571/// already exist. Mirrors the relevant fields from `start_thread`.
572fn ensure_thread_record(
573    repo: &Repository,
574    thread_name: &str,
575    anchor: &objects::object::ChangeId,
576    task: &Option<String>,
577) -> Result<()> {
578    let manager = ThreadManager::new(repo.heddle_dir());
579    if manager.load(thread_name)?.is_some() {
580        return Ok(());
581    }
582    let state = repo
583        .store()
584        .get_state(anchor)?
585        .ok_or_else(|| anyhow!("anchor state '{}' not found", anchor.short()))?;
586    let base_short = anchor.short();
587    let base_root = state.tree.short();
588    let target_thread = match repo.head_ref()? {
589        Head::Attached { thread } if thread != thread_name => Some(thread.to_string()),
590        _ => None,
591    };
592    let thread_state = Thread {
593        id: thread_name.to_string(),
594        thread: thread_name.to_string(),
595        target_thread,
596        parent_thread: None,
597        mode: ThreadMode::Materialized,
598        state: ThreadState::Active,
599        base_state: base_short.clone(),
600        base_root,
601        current_state: Some(base_short),
602        merged_state: None,
603        task: task.clone(),
604        execution_path: repo.root().to_path_buf(),
605        materialized_path: None,
606        changed_paths: vec![],
607        impact_categories: vec![],
608        heavy_impact_paths: vec![],
609        promotion_suggested: false,
610        freshness: ThreadFreshness::Current,
611        verification_summary: ThreadVerificationSummary::default(),
612        confidence_summary: ThreadConfidenceSummary::default(),
613        integration_policy_result: ThreadIntegrationPolicy::default(),
614        created_at: Utc::now(),
615        updated_at: Utc::now(),
616        // Reservation-API-created threads aren't ephemeral by
617        // default; orchestrators that want TTL-bounded threads pass
618        // through `heddle thread create --ephemeral` instead.
619        ephemeral: None,
620        // Reservation API threads are user-orchestrated, not
621        // harness-auto-created — leave them visible in the default
622        // `thread list` view.
623        auto: false,
624        shared_target_dir: None,
625    };
626    manager.save(&thread_state)?;
627    Ok(())
628}
629
630pub fn cmd_agent_heartbeat(cli: &Cli, args: AgentHeartbeatArgs) -> Result<()> {
631    let repo = cli.open_repo()?;
632    let registry = AgentRegistry::new(repo.heddle_dir());
633    let entry = registry
634        .update_entry(&args.session, |entry| {
635            entry.heartbeat_at = Some(Utc::now());
636            entry.last_progress_at = Some(Utc::now());
637        })?
638        .ok_or_else(|| anyhow!(agent_session_not_found_advice(&args.session)))?;
639    render_agent_reservation_envelope(&repo, &entry)
640}
641
642pub fn cmd_agent_release(cli: &Cli, args: AgentReleaseArgs) -> Result<()> {
643    let repo = cli.open_repo()?;
644    let registry = AgentRegistry::new(repo.heddle_dir());
645    let status = match args.status {
646        AgentReleaseStatusArg::Complete => AgentStatus::Complete,
647        AgentReleaseStatusArg::Abandoned => AgentStatus::Abandoned,
648    };
649    let entry = registry
650        .update_entry(&args.session, |entry| {
651            entry.status = status.clone();
652            entry.completed_at = match entry.status {
653                AgentStatus::Active => None,
654                AgentStatus::Abandoned | AgentStatus::Complete | AgentStatus::Merged => {
655                    Some(Utc::now())
656                }
657            };
658        })?
659        .ok_or_else(|| anyhow!(agent_session_not_found_advice(&args.session)))?;
660    render_agent_reservation_envelope(&repo, &entry)
661}
662
663pub fn cmd_agent_list(cli: &Cli, args: AgentApiListArgs) -> Result<()> {
664    let repo = cli.open_repo()?;
665    let registry = AgentRegistry::new(repo.heddle_dir());
666    if args.alive_only {
667        // Sweep dead reservations before reporting so callers asking
668        // "who is alive?" see a pid-checked, current view.
669        registry.reap_dead()?;
670    }
671    let entries: Vec<_> = registry
672        .list()?
673        .into_iter()
674        .filter(|entry| {
675            args.thread
676                .as_ref()
677                .is_none_or(|thread| &entry.thread == thread)
678        })
679        .filter(|entry| !args.alive_only || entry.status == AgentStatus::Active)
680        .map(|entry| AgentReservationOutput::from(&entry))
681        .collect();
682    render_agent_list(
683        AgentReservationListOutput {
684            reservations: entries,
685            alive_only: args.alive_only,
686            thread: args.thread.clone(),
687            trust: build_repository_verification_state(&repo),
688        },
689        should_output_json(cli, Some(repo.config())),
690    )
691}
692
693pub fn cmd_agent_task(cli: &Cli, command: AgentTaskCommands) -> Result<()> {
694    match command {
695        AgentTaskCommands::Create(args) => cmd_agent_task_create(cli, args),
696        AgentTaskCommands::List(args) => cmd_agent_task_list(cli, args),
697        AgentTaskCommands::Show(args) => cmd_agent_task_show(cli, args),
698        AgentTaskCommands::Update(args) => cmd_agent_task_update(cli, args),
699    }
700}
701
702pub fn cmd_agent_fanout(cli: &Cli, command: AgentFanoutCommands) -> Result<()> {
703    match command {
704        AgentFanoutCommands::Plan(args) => cmd_agent_fanout_plan(cli, args),
705        AgentFanoutCommands::Start(args) => cmd_agent_fanout_start(cli, args),
706    }
707}
708
709#[derive(Clone, Debug)]
710struct FanoutLaneSpec {
711    thread: String,
712    path: PathBuf,
713    title: String,
714}
715
716fn cmd_agent_fanout_plan(cli: &Cli, args: AgentFanoutPlanArgs) -> Result<()> {
717    let repo = cli.open_repo()?;
718    let lanes = parse_fanout_lanes(&args.lane)?;
719    let (base_state, base_root) = fanout_base(&repo)?;
720    let parent_thread = fanout_parent_thread(&repo)?;
721    let output = build_fanout_output(
722        &repo,
723        "agent_fanout_plan",
724        args.title,
725        parent_thread,
726        base_state,
727        base_root,
728        args.coordination_discussion_id,
729        None,
730        lanes
731            .into_iter()
732            .map(|lane| AgentFanoutLaneOutput {
733                thread: lane.thread,
734                path: lane.path.display().to_string(),
735                title: lane.title,
736                task: None,
737                session_id: None,
738                status: "planned".to_string(),
739            })
740            .collect(),
741    );
742    render_agent_fanout_output(output, should_output_json(cli, Some(repo.config())))
743}
744
745fn cmd_agent_fanout_start(cli: &Cli, args: AgentFanoutStartArgs) -> Result<()> {
746    let repo = cli.open_repo()?;
747    if let Some(advice) = git_overlay_mutation_preflight_advice(
748        &repo,
749        "agent fanout start",
750        GitOverlayMutationPreflight::capture_like(),
751    )? {
752        return Err(anyhow!(advice));
753    }
754    ensure_worktree_clean(&repo, "agent fanout start")?;
755
756    let lanes = parse_fanout_lanes(&args.lane)?;
757    let (base_state, base_root) = fanout_base(&repo)?;
758    let parent_thread = fanout_parent_thread(&repo)?;
759    preflight_fanout_start(&repo, &lanes)?;
760    let store = AgentTaskStore::new(repo.heddle_dir());
761
762    let mut parent = AgentTaskRecord::new(String::new(), args.title.clone(), parent_thread.clone());
763    parent.body = fanout_parent_body(&lanes);
764    parent.base_state = Some(base_state.clone());
765    parent.base_root = Some(base_root.clone());
766    parent.coordination_discussion_id = args.coordination_discussion_id.clone();
767    parent.allow_offline = true;
768    parent.delegated_by = Some("heddle agent fanout start".to_string());
769    parent.status = AgentTaskStatus::InProgress;
770    let parent = store.create(parent)?;
771
772    let mut created_task_ids = vec![parent.task_id.clone()];
773    let start_result = (|| -> Result<Vec<AgentFanoutLaneOutput>> {
774        let mut outputs = Vec::new();
775        for lane in lanes {
776            let mut child =
777                AgentTaskRecord::new(String::new(), lane.title.clone(), lane.thread.clone());
778            child.body = format!("Fan-out child lane for parent task {}", parent.task_id);
779            child.base_state = Some(base_state.clone());
780            child.base_root = Some(base_root.clone());
781            child.parent_task_id = Some(parent.task_id.clone());
782            child.coordination_discussion_id = args.coordination_discussion_id.clone();
783            child.allow_offline = true;
784            child.delegated_by = Some(parent.task_id.clone());
785            child.status = AgentTaskStatus::InProgress;
786            let child = store.create(child)?;
787            created_task_ids.push(child.task_id.clone());
788
789            let started = super::thread::start_thread(
790                &repo,
791                ThreadStartArgs {
792                    name: lane.thread.clone(),
793                    from: Some(base_state.clone()),
794                    path: Some(lane.path.clone()),
795                    workspace: WorkspaceModeArg::Auto,
796                    agent_provider: None,
797                    agent_model: None,
798                    task: Some(lane.title.clone()),
799                    parent_thread: Some(parent_thread.clone()),
800                    automated: true,
801                    print_cd_path: false,
802                    daemon: true,
803                    no_daemon: false,
804                    shared_target: false,
805                    hydrate: false,
806                },
807            )?;
808            let session_id = started
809                .thread
810                .as_ref()
811                .and_then(|thread| thread.session_id.clone());
812            if let Some(session_id) = session_id.as_deref() {
813                let registry = AgentRegistry::new(repo.heddle_dir());
814                let _ = registry.update_entry(session_id, |entry| {
815                    entry.task_assignment_id = Some(child.task_id.clone());
816                    entry.attach_reason = Some(lane.title.clone());
817                    entry
818                        .attach_precedence
819                        .push("agent-fanout-start".to_string());
820                    entry.winning_attach_rule = Some("agent-fanout-start".to_string());
821                })?;
822            }
823            outputs.push(AgentFanoutLaneOutput {
824                thread: lane.thread,
825                path: lane.path.display().to_string(),
826                title: lane.title,
827                task: Some(AgentTaskOutput::from(&child)),
828                session_id,
829                status: "started".to_string(),
830            });
831        }
832        Ok(outputs)
833    })();
834
835    let outputs = match start_result {
836        Ok(outputs) => outputs,
837        Err(err) => {
838            abandon_fanout_tasks(&store, &created_task_ids);
839            return Err(err);
840        }
841    };
842
843    let output = build_fanout_output(
844        &repo,
845        "agent_fanout_start",
846        args.title,
847        parent_thread,
848        base_state,
849        base_root,
850        args.coordination_discussion_id,
851        Some(AgentTaskOutput::from(&parent)),
852        outputs,
853    );
854    render_agent_fanout_output(output, should_output_json(cli, Some(repo.config())))
855}
856
857fn cmd_agent_task_create(cli: &Cli, args: AgentTaskCreateArgs) -> Result<()> {
858    ThreadId::new(args.thread.as_str()).map_err(|err| anyhow!(thread_name_invalid_advice(&err)))?;
859    if let Some(task_id) = args.task_id.as_deref() {
860        validate_task_id(task_id).map_err(|err| anyhow!(err))?;
861    }
862    let repo = cli.open_repo()?;
863    let mut record = AgentTaskRecord::new(
864        args.task_id.unwrap_or_default(),
865        args.title,
866        args.thread.clone(),
867    );
868    record.body = args.body.unwrap_or_default();
869    record.base_state = args.base_state;
870    record.base_root = args.base_root;
871    record.parent_task_id = args.parent_task_id;
872    record.coordination_discussion_id = args.coordination_discussion_id;
873    record.allow_offline = args.allow_offline;
874    record.delegated_by = args.delegated_by;
875    let store = AgentTaskStore::new(repo.heddle_dir());
876    let created = store.create(record)?;
877    render_agent_task_envelope(
878        &repo,
879        &created,
880        "agent_task_create",
881        should_output_json(cli, Some(repo.config())),
882    )
883}
884
885fn cmd_agent_task_list(cli: &Cli, args: AgentTaskListArgs) -> Result<()> {
886    let repo = cli.open_repo()?;
887    let status_filter = args.status.as_ref().map(agent_task_status_from_arg);
888    let store = AgentTaskStore::new(repo.heddle_dir());
889    let tasks: Vec<_> = store
890        .list()?
891        .into_iter()
892        .filter(|task| {
893            args.thread
894                .as_ref()
895                .is_none_or(|thread| &task.target_thread == thread)
896        })
897        .filter(|task| {
898            status_filter
899                .as_ref()
900                .is_none_or(|status| &task.status == status)
901        })
902        .map(|task| AgentTaskOutput::from(&task))
903        .collect();
904    render_agent_task_list(
905        AgentTaskListOutput {
906            output_kind: "agent_task_list",
907            tasks,
908            thread: args.thread,
909            status: status_filter.map(|status| status.to_string()),
910            trust: build_repository_verification_state(&repo),
911        },
912        should_output_json(cli, Some(repo.config())),
913    )
914}
915
916fn cmd_agent_task_show(cli: &Cli, args: AgentTaskShowArgs) -> Result<()> {
917    validate_task_id(&args.task_id).map_err(|err| anyhow!(err))?;
918    let repo = cli.open_repo()?;
919    let store = AgentTaskStore::new(repo.heddle_dir());
920    let task = store
921        .load(&args.task_id)?
922        .ok_or_else(|| anyhow!(agent_task_not_found_advice(&args.task_id)))?;
923    render_agent_task_envelope(
924        &repo,
925        &task,
926        "agent_task_show",
927        should_output_json(cli, Some(repo.config())),
928    )
929}
930
931fn cmd_agent_task_update(cli: &Cli, args: AgentTaskUpdateArgs) -> Result<()> {
932    validate_task_id(&args.task_id).map_err(|err| anyhow!(err))?;
933    if let Some(thread) = args.thread.as_deref() {
934        ThreadId::new(thread).map_err(|err| anyhow!(thread_name_invalid_advice(&err)))?;
935    }
936    let repo = cli.open_repo()?;
937    let store = AgentTaskStore::new(repo.heddle_dir());
938    let updated = store
939        .update(&args.task_id, |task| {
940            if let Some(title) = args.title.clone() {
941                task.title = title;
942            }
943            if let Some(body) = args.body.clone() {
944                task.body = body;
945            }
946            if let Some(status) = args.status.as_ref() {
947                task.status = agent_task_status_from_arg(status);
948            }
949            if let Some(thread) = args.thread.clone() {
950                task.target_thread = thread;
951            }
952            if let Some(base_state) = args.base_state.clone() {
953                task.base_state = Some(base_state);
954            }
955            if let Some(base_root) = args.base_root.clone() {
956                task.base_root = Some(base_root);
957            }
958            if let Some(parent_task_id) = args.parent_task_id.clone() {
959                task.parent_task_id = Some(parent_task_id);
960            }
961            if let Some(discussion_id) = args.coordination_discussion_id.clone() {
962                task.coordination_discussion_id = Some(discussion_id);
963            }
964            if args.allow_offline {
965                task.allow_offline = true;
966            }
967            if args.no_allow_offline {
968                task.allow_offline = false;
969            }
970            if let Some(delegated_by) = args.delegated_by.clone() {
971                task.delegated_by = Some(delegated_by);
972            }
973        })?
974        .ok_or_else(|| anyhow!(agent_task_not_found_advice(&args.task_id)))?;
975    render_agent_task_envelope(
976        &repo,
977        &updated,
978        "agent_task_update",
979        should_output_json(cli, Some(repo.config())),
980    )
981}
982
983fn agent_task_status_from_arg(status: &AgentTaskStatusArg) -> AgentTaskStatus {
984    match status {
985        AgentTaskStatusArg::Open => AgentTaskStatus::Open,
986        AgentTaskStatusArg::InProgress => AgentTaskStatus::InProgress,
987        AgentTaskStatusArg::Blocked => AgentTaskStatus::Blocked,
988        AgentTaskStatusArg::Complete => AgentTaskStatus::Complete,
989        AgentTaskStatusArg::Abandoned => AgentTaskStatus::Abandoned,
990    }
991}
992
993fn parse_fanout_lanes(raw_lanes: &[String]) -> Result<Vec<FanoutLaneSpec>> {
994    if raw_lanes.is_empty() {
995        return Err(anyhow!(RecoveryAdvice::invalid_usage(
996            "agent_fanout_lane_required",
997            "agent fanout requires at least one --lane <thread>=<path>:<title>",
998            "Pass --lane once for each child checkout to create.",
999            "heddle agent fanout plan --title <title> --lane <thread>=<path>:<title>",
1000        )));
1001    }
1002    raw_lanes.iter().map(|raw| parse_fanout_lane(raw)).collect()
1003}
1004
1005fn parse_fanout_lane(raw: &str) -> Result<FanoutLaneSpec> {
1006    let (thread, rest) = raw.split_once('=').ok_or_else(|| {
1007        anyhow!(RecoveryAdvice::invalid_usage(
1008            "agent_fanout_lane_invalid",
1009            format!("invalid fanout lane '{raw}'"),
1010            "Use <thread>=<path>:<title>.",
1011            "heddle agent fanout plan --title <title> --lane feature/a=../a:Task title",
1012        ))
1013    })?;
1014    let (path, title) = rest.split_once(':').ok_or_else(|| {
1015        anyhow!(RecoveryAdvice::invalid_usage(
1016            "agent_fanout_lane_invalid",
1017            format!("invalid fanout lane '{raw}'"),
1018            "Use <thread>=<path>:<title>.",
1019            "heddle agent fanout plan --title <title> --lane feature/a=../a:Task title",
1020        ))
1021    })?;
1022    let thread = thread.trim();
1023    let path = path.trim();
1024    let title = title.trim();
1025    if path.is_empty() || title.is_empty() {
1026        return Err(anyhow!(RecoveryAdvice::invalid_usage(
1027            "agent_fanout_lane_invalid",
1028            format!("invalid fanout lane '{raw}'"),
1029            "Thread, path, and title must all be non-empty.",
1030            "heddle agent fanout plan --title <title> --lane feature/a=../a:Task title",
1031        )));
1032    }
1033    ThreadId::new(thread).map_err(|err| anyhow!(thread_name_invalid_advice(&err)))?;
1034    Ok(FanoutLaneSpec {
1035        thread: thread.to_string(),
1036        path: PathBuf::from(path),
1037        title: title.to_string(),
1038    })
1039}
1040
1041fn fanout_base(repo: &Repository) -> Result<(String, String)> {
1042    let head = repo
1043        .head()?
1044        .ok_or_else(|| anyhow!("repository has no HEAD state for agent fanout"))?;
1045    let state = repo
1046        .store()
1047        .get_state(&head)?
1048        .ok_or_else(|| anyhow!("HEAD state '{}' not found", head.short()))?;
1049    Ok((head.to_string_full(), state.tree.short()))
1050}
1051
1052fn fanout_parent_thread(repo: &Repository) -> Result<String> {
1053    Ok(match repo.head_ref()? {
1054        Head::Attached { thread } => thread.to_string(),
1055        Head::Detached { .. } => "detached".to_string(),
1056    })
1057}
1058
1059fn fanout_parent_body(lanes: &[FanoutLaneSpec]) -> String {
1060    lanes
1061        .iter()
1062        .map(|lane| format!("- {}: {}", lane.thread, lane.title))
1063        .collect::<Vec<_>>()
1064        .join("\n")
1065}
1066
1067fn abandon_fanout_tasks(store: &AgentTaskStore, task_ids: &[String]) {
1068    for task_id in task_ids {
1069        let _ = store.update(task_id, |task| {
1070            task.status = AgentTaskStatus::Abandoned;
1071        });
1072    }
1073}
1074
1075fn preflight_fanout_start(repo: &Repository, lanes: &[FanoutLaneSpec]) -> Result<()> {
1076    let mut seen_threads = BTreeSet::new();
1077    let mut seen_paths = BTreeSet::new();
1078    let manager = ThreadManager::new(repo.heddle_dir());
1079    for lane in lanes {
1080        if !seen_threads.insert(lane.thread.clone()) {
1081            return Err(anyhow!(fanout_lane_unavailable_advice(
1082                "agent_fanout_duplicate_thread",
1083                lane,
1084                format!("fanout lane '{}' is listed more than once", lane.thread),
1085                "Use each child thread name once per fanout.",
1086            )));
1087        }
1088        if super::thread::find_active_thread_entry(repo, &lane.thread)?.is_some() {
1089            return Err(anyhow!(fanout_lane_unavailable_advice(
1090                "agent_fanout_live_owner",
1091                lane,
1092                format!(
1093                    "fanout lane '{}' already has an active agent reservation",
1094                    lane.thread
1095                ),
1096                "Release the active reservation or choose a fresh child thread.",
1097            )));
1098        }
1099        if repo
1100            .refs()
1101            .get_thread(&ThreadName::new(&lane.thread))?
1102            .is_some()
1103        {
1104            return Err(anyhow!(fanout_lane_unavailable_advice(
1105                "agent_fanout_thread_exists",
1106                lane,
1107                format!("fanout lane '{}' already exists", lane.thread),
1108                "Choose a fresh child thread or inspect the existing thread before retrying.",
1109            )));
1110        }
1111        if let Some(existing) = manager.find_by_thread(&lane.thread)?
1112            && existing.state == ThreadState::Active
1113        {
1114            return Err(anyhow!(fanout_lane_unavailable_advice(
1115                "agent_fanout_thread_exists",
1116                lane,
1117                format!(
1118                    "fanout lane '{}' already has an active thread record",
1119                    lane.thread
1120                ),
1121                "Drop or finish the existing thread before reusing the lane name.",
1122            )));
1123        }
1124        let prepared = plan_worktree_target(repo, &lane.path, Some(&lane.thread))?;
1125        if !seen_paths.insert(prepared.path.clone()) {
1126            return Err(anyhow!(fanout_lane_unavailable_advice(
1127                "agent_fanout_duplicate_path",
1128                lane,
1129                format!(
1130                    "fanout lane '{}' resolves to a checkout path used by another lane",
1131                    lane.thread
1132                ),
1133                "Use a distinct checkout path for each child lane.",
1134            )));
1135        }
1136    }
1137    Ok(())
1138}
1139
1140fn fanout_lane_unavailable_advice(
1141    kind: &'static str,
1142    lane: &FanoutLaneSpec,
1143    error: String,
1144    guidance: &'static str,
1145) -> RecoveryAdvice {
1146    RecoveryAdvice::invalid_usage(
1147        kind,
1148        error,
1149        guidance,
1150        format!(
1151            "heddle agent fanout plan --title <title> --lane {}=<path>:<title>",
1152            lane.thread
1153        ),
1154    )
1155}
1156
1157#[allow(clippy::too_many_arguments)]
1158fn build_fanout_output(
1159    repo: &Repository,
1160    output_kind: &'static str,
1161    title: String,
1162    parent_thread: String,
1163    base_state: String,
1164    base_root: String,
1165    coordination_discussion_id: Option<String>,
1166    parent_task: Option<AgentTaskOutput>,
1167    lanes: Vec<AgentFanoutLaneOutput>,
1168) -> AgentFanoutOutput {
1169    let commands = if output_kind == "agent_fanout_plan" {
1170        let mut argv = vec![
1171            "heddle".to_string(),
1172            "agent".to_string(),
1173            "fanout".to_string(),
1174            "start".to_string(),
1175            "--title".to_string(),
1176            title.clone(),
1177        ];
1178        if let Some(discussion_id) = coordination_discussion_id.as_ref() {
1179            argv.push("--coordination-discussion-id".to_string());
1180            argv.push(discussion_id.clone());
1181        }
1182        for lane in &lanes {
1183            argv.push("--lane".to_string());
1184            argv.push(format!("{}={}:{}", lane.thread, lane.path, lane.title));
1185        }
1186        let command = argv
1187            .iter()
1188            .map(|arg| shell_quote(arg))
1189            .collect::<Vec<_>>()
1190            .join(" ");
1191        vec![AgentFanoutCommandOutput {
1192            lane_thread: "all".to_string(),
1193            command,
1194            argv,
1195        }]
1196    } else {
1197        Vec::new()
1198    };
1199    AgentFanoutOutput {
1200        output_kind,
1201        title,
1202        parent_thread,
1203        base_state,
1204        base_root,
1205        coordination_discussion_id,
1206        parent_task,
1207        lanes,
1208        commands,
1209        trust: build_repository_verification_state(repo),
1210    }
1211}
1212
1213fn render_agent_fanout_output(output: AgentFanoutOutput, json: bool) -> Result<()> {
1214    if json {
1215        println!("{}", serde_json::to_string(&output)?);
1216        return Ok(());
1217    }
1218    println!(
1219        "Agent fanout {}: {}",
1220        output
1221            .output_kind
1222            .strip_prefix("agent_fanout_")
1223            .unwrap_or(output.output_kind),
1224        output.title
1225    );
1226    if let Some(parent_task) = &output.parent_task {
1227        println!(
1228            "  parent task: {}",
1229            crate::cli::style::accent(&parent_task.task_id)
1230        );
1231    }
1232    for lane in &output.lanes {
1233        println!(
1234            "  {} [{}] {}",
1235            crate::cli::style::accent(&lane.thread),
1236            lane.status,
1237            crate::cli::style::dim(&lane.path),
1238        );
1239        if let Some(task) = &lane.task {
1240            println!("    task: {}", crate::cli::style::dim(&task.task_id));
1241        }
1242    }
1243    if output.output_kind == "agent_fanout_plan" {
1244        println!("Commands:");
1245        for command in &output.commands {
1246            println!("  {}", command.command);
1247        }
1248    }
1249    Ok(())
1250}
1251
1252fn render_agent_list(output: AgentReservationListOutput, json: bool) -> Result<()> {
1253    if json {
1254        println!("{}", serde_json::to_string(&output)?);
1255        return Ok(());
1256    }
1257    let entries = output.reservations;
1258    if entries.is_empty() {
1259        println!("No agent reservations.");
1260        return Ok(());
1261    }
1262    println!("Agent reservations ({}):", entries.len());
1263    for entry in entries {
1264        println!(
1265            "  {} [{}] thread={}",
1266            crate::cli::style::accent(&entry.session_id),
1267            entry.status,
1268            entry.thread,
1269        );
1270        if let Some(task) = &entry.task {
1271            println!("    task: {}", crate::cli::style::dim(task));
1272        }
1273        if let Some(path) = &entry.path
1274            && !path.is_empty()
1275        {
1276            println!("    path: {}", crate::cli::style::dim(path));
1277        }
1278    }
1279    Ok(())
1280}
1281
1282fn render_agent_task_envelope(
1283    repo: &Repository,
1284    task: &AgentTaskRecord,
1285    output_kind: &'static str,
1286    json: bool,
1287) -> Result<()> {
1288    let output = AgentTaskEnvelope {
1289        output_kind,
1290        task: AgentTaskOutput::from(task),
1291        trust: build_repository_verification_state(repo),
1292    };
1293    if json {
1294        println!("{}", serde_json::to_string(&output)?);
1295        return Ok(());
1296    }
1297    println!(
1298        "Agent task {} [{}]",
1299        crate::cli::style::accent(&output.task.task_id),
1300        output.task.status
1301    );
1302    println!("  title: {}", output.task.title);
1303    println!("  thread: {}", output.task.target_thread);
1304    if !output.task.body.is_empty() {
1305        println!("  body: {}", output.task.body);
1306    }
1307    if let Some(base_state) = &output.task.base_state {
1308        println!("  base_state: {}", crate::cli::style::dim(base_state));
1309    }
1310    if let Some(base_root) = &output.task.base_root {
1311        println!("  base_root: {}", crate::cli::style::dim(base_root));
1312    }
1313    Ok(())
1314}
1315
1316fn render_agent_task_list(output: AgentTaskListOutput, json: bool) -> Result<()> {
1317    if json {
1318        println!("{}", serde_json::to_string(&output)?);
1319        return Ok(());
1320    }
1321    if output.tasks.is_empty() {
1322        println!("No agent tasks.");
1323        return Ok(());
1324    }
1325    println!("Agent tasks ({}):", output.tasks.len());
1326    for task in output.tasks {
1327        println!(
1328            "  {} [{}] thread={} title={}",
1329            crate::cli::style::accent(&task.task_id),
1330            task.status,
1331            task.target_thread,
1332            task.title,
1333        );
1334    }
1335    Ok(())
1336}
1337
1338fn reservation_envelope(repo: &Repository, entry: &AgentEntry) -> AgentReservationEnvelope {
1339    AgentReservationEnvelope {
1340        reservation: AgentReservationOutput::from(entry),
1341        trust: build_repository_verification_state(repo),
1342    }
1343}
1344
1345fn render_agent_reservation_envelope(repo: &Repository, entry: &AgentEntry) -> Result<()> {
1346    println!(
1347        "{}",
1348        serde_json::to_string(&reservation_envelope(repo, entry))?
1349    );
1350    Ok(())
1351}
1352
1353fn agent_session_not_found_advice(session_id: &str) -> RecoveryAdvice {
1354    RecoveryAdvice::safety_refusal(
1355        "agent_session_not_found",
1356        format!("agent session '{session_id}' not found"),
1357        "Reserve the thread again before retrying the guarded agent command.",
1358        format!("no reservation entry exists for session {session_id}"),
1359        "continuing would let an unknown session mutate repository state",
1360        "no session heartbeat, capture, readiness, refs, or worktree changes were applied",
1361        "heddle agent reserve --thread <thread>",
1362        vec!["heddle agent reserve --thread <thread>".to_string()],
1363    )
1364}
1365
1366/// Resolve `--session SID` to an Active reservation, refresh its
1367/// heartbeat, and return the entry. Errors out cleanly when the
1368/// session is missing, terminal, or reaped between calls — that's the
1369/// signal the orchestrator must re-reserve before continuing.
1370fn validate_active_session(
1371    registry: &AgentRegistry,
1372    session_id: &str,
1373) -> Result<objects::store::AgentEntry> {
1374    let entry = registry
1375        .update_entry(session_id, |entry| {
1376            entry.heartbeat_at = Some(Utc::now());
1377            entry.last_progress_at = Some(Utc::now());
1378        })?
1379        .ok_or_else(|| anyhow!(agent_session_not_found_advice(session_id)))?;
1380    if entry.status != AgentStatus::Active {
1381        return Err(anyhow!(RecoveryAdvice::safety_refusal(
1382            "agent_session_inactive",
1383            format!(
1384                "agent session '{}' is no longer active (status: {})",
1385                session_id, entry.status
1386            ),
1387            "Reserve the thread again before retrying the guarded agent command.",
1388            format!("session {session_id} has terminal status {}", entry.status),
1389            "continuing would let a stale reservation write or mark readiness after ownership ended",
1390            "the session heartbeat was refreshed, but no capture, ready, refs, or worktree changes were applied",
1391            "heddle agent reserve --thread <thread>",
1392            vec!["heddle agent reserve --thread <thread>".to_string()],
1393        )));
1394    }
1395    Ok(entry)
1396}
1397
1398/// `heddle agent capture --session <SID>`: a session-validated
1399/// alias for `heddle capture` that proves the caller still owns the
1400/// reservation it claims to before writing.
1401pub async fn cmd_agent_capture(
1402    cli: &Cli,
1403    args: crate::cli::cli_args::AgentCaptureArgs,
1404) -> Result<()> {
1405    let repo_path = cli
1406        .repo
1407        .clone()
1408        .unwrap_or(std::env::current_dir().map_err(anyhow::Error::from)?);
1409    let repo = Repository::open(&repo_path)?;
1410    let registry = AgentRegistry::new(repo.heddle_dir());
1411    let entry = validate_active_session(&registry, &args.session)?;
1412
1413    // Confirm the reservation still names the thread the caller is
1414    // attached to. We don't switch threads here — the agent must
1415    // already be on its reserved thread when invoking capture.
1416    if let Some(current) = repo.current_lane()?
1417        && current != entry.thread
1418    {
1419        return Err(anyhow!(RecoveryAdvice::safety_refusal(
1420            "agent_session_thread_mismatch",
1421            format!(
1422                "agent session '{}' reserved thread '{}', but the current thread is '{}'",
1423                args.session, entry.thread, current
1424            ),
1425            format!(
1426                "Switch to the reserved thread with `heddle switch {}` before capturing.",
1427                entry.thread
1428            ),
1429            format!(
1430                "session {} owns thread {}, current checkout is attached to {}",
1431                args.session, entry.thread, current
1432            ),
1433            "capturing from the wrong thread would attribute work to a reservation that does not own this checkout",
1434            "the session heartbeat was refreshed, but no capture, refs, or worktree changes were applied",
1435            format!("heddle switch {}", entry.thread),
1436            vec![format!("heddle switch {}", entry.thread)],
1437        )));
1438    }
1439
1440    super::snapshot::cmd_snapshot(
1441        cli,
1442        args.message.clone(),
1443        args.confidence,
1444        false,
1445        super::snapshot::SnapshotAgentOverrides {
1446            provider: entry.provider.clone(),
1447            model: entry.model.clone(),
1448            session: Some(args.session.clone()),
1449            segment: None,
1450            policy: None,
1451            no_policy: false,
1452            no_agent: false,
1453        },
1454    )
1455    .await
1456}
1457
1458/// `heddle agent ready --session <SID>`: a session-validated alias
1459/// for `heddle ready` that ensures the caller still owns the
1460/// reservation it's trying to mark ready.
1461pub async fn cmd_agent_ready(cli: &Cli, args: crate::cli::cli_args::AgentReadyArgs) -> Result<()> {
1462    let repo_path = cli
1463        .repo
1464        .clone()
1465        .unwrap_or(std::env::current_dir().map_err(anyhow::Error::from)?);
1466    let repo = Repository::open(&repo_path)?;
1467    let registry = AgentRegistry::new(repo.heddle_dir());
1468    let entry = validate_active_session(&registry, &args.session)?;
1469
1470    super::ready_cmd::cmd_ready(
1471        cli,
1472        crate::cli::cli_args::ReadyArgs {
1473            thread: Some(entry.thread.clone()),
1474            message: args.message.clone(),
1475            confidence: args.confidence,
1476        },
1477    )
1478    .await
1479}
1480
1481/// Return the combined JSON schema for the public agent-API output
1482/// types. Snapshot-tested in `tests/agent_api_schema.rs` so any
1483/// breaking change to the wire shape is caught at PR review.
1484pub fn agent_api_schema() -> serde_json::Value {
1485    serde_json::json!({
1486        "AgentReservationEnvelope": schemars::schema_for!(AgentReservationEnvelope),
1487        "AgentReservationListOutput": schemars::schema_for!(AgentReservationListOutput),
1488        "AgentReservationOutput": schemars::schema_for!(AgentReservationOutput),
1489        "AgentTaskEnvelope": schemars::schema_for!(AgentTaskEnvelope),
1490        "AgentTaskListOutput": schemars::schema_for!(AgentTaskListOutput),
1491        "AgentTaskOutput": schemars::schema_for!(AgentTaskOutput),
1492        "AgentFanoutOutput": schemars::schema_for!(AgentFanoutOutput),
1493        "AgentFanoutLaneOutput": schemars::schema_for!(AgentFanoutLaneOutput),
1494        "AgentFanoutCommandOutput": schemars::schema_for!(AgentFanoutCommandOutput),
1495    })
1496}