1use 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 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 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 let existing_ref = repo.refs().get_thread(&ThreadName::new(&thread_name))?;
381 if let Some(existing) = existing_ref
382 && existing != anchor
383 {
384 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 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 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 repo.oplog()
525 .record_thread_create(&tn, &anchor, None, Some(&repo.op_scope()))?;
526 }
527
528 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 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
570fn 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 ephemeral: None,
620 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 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
1366fn 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
1398pub 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(®istry, &args.session)?;
1412
1413 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
1458pub 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(®istry, &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
1481pub 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}