Skip to main content

verbs/
agent_fanout.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Pure agent fanout planning and plan dry-run report assembly.
3//!
4//! Owns:
5//! - lane string parse (`thread=path:title`) and thread-name validation
6//! - pure empty-lane / duplicate-thread preflight
7//! - parent-thread label selection from HEAD attach facts
8//! - parent/child task body and delegated-by string rules
9//! - dry-run `agent fanout start` command argv assembly
10//! - plan dry-run report fields (stable JSON names, no verification)
11//!
12//! Worktree target resolution, agent-task store writes, thread spawn, and
13//! registry linking stay CLI-owned. Live-reservation / existing-thread /
14//! path-collision **decisions** after the CLI gathers facts are pure here.
15
16use std::{
17    collections::BTreeSet,
18    path::{Path, PathBuf},
19};
20
21use repo::{ThreadId, ThreadIdError, shell_quote};
22use serde::Serialize;
23
24// ---------------------------------------------------------------------------
25// Types
26// ---------------------------------------------------------------------------
27
28/// One child lane after successful parse.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct FanoutNodeSpec {
31    pub thread: String,
32    pub path: PathBuf,
33    pub title: String,
34}
35
36/// CLI-gathered availability facts for one fanout lane start preflight.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct FanoutLaneAvailability {
39    pub thread: String,
40    /// Active agent reservation already owns this thread name.
41    pub has_live_owner: bool,
42    /// Thread ref already exists in the repository.
43    pub thread_ref_exists: bool,
44    /// Active thread record (ThreadManager) already exists.
45    pub active_thread_record: bool,
46    /// Resolved absolute/normalized checkout path for collision detection.
47    pub resolved_path: PathBuf,
48}
49
50/// Pure preflight refusal after live I/O facts are known.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum FanoutLanePreflightBlock {
53    LiveOwner { thread: String },
54    ThreadExists { thread: String },
55    ActiveThreadRecord { thread: String },
56    DuplicatePath { thread: String },
57}
58
59impl FanoutLanePreflightBlock {
60    pub fn kind(&self) -> &'static str {
61        match self {
62            Self::LiveOwner { .. } => "agent_fanout_live_owner",
63            Self::ThreadExists { .. } | Self::ActiveThreadRecord { .. } => {
64                "agent_fanout_thread_exists"
65            }
66            Self::DuplicatePath { .. } => "agent_fanout_duplicate_path",
67        }
68    }
69
70    pub fn thread(&self) -> &str {
71        match self {
72            Self::LiveOwner { thread }
73            | Self::ThreadExists { thread }
74            | Self::ActiveThreadRecord { thread }
75            | Self::DuplicatePath { thread } => thread,
76        }
77    }
78}
79
80/// Pure fanout-start preflight from CLI-gathered per-lane facts.
81///
82/// Checks live owner, existing thread ref/record, then path collisions across
83/// the lane set (in order).
84pub fn check_fanout_start_preflight(
85    lanes: &[FanoutLaneAvailability],
86) -> Result<(), FanoutLanePreflightBlock> {
87    let mut seen_paths = BTreeSet::new();
88    for lane in lanes {
89        if lane.has_live_owner {
90            return Err(FanoutLanePreflightBlock::LiveOwner {
91                thread: lane.thread.clone(),
92            });
93        }
94        if lane.thread_ref_exists {
95            return Err(FanoutLanePreflightBlock::ThreadExists {
96                thread: lane.thread.clone(),
97            });
98        }
99        if lane.active_thread_record {
100            return Err(FanoutLanePreflightBlock::ActiveThreadRecord {
101                thread: lane.thread.clone(),
102            });
103        }
104        if !seen_paths.insert(lane.resolved_path.clone()) {
105            return Err(FanoutLanePreflightBlock::DuplicatePath {
106                thread: lane.thread.clone(),
107            });
108        }
109    }
110    Ok(())
111}
112
113impl FanoutNodeSpec {
114    /// Reconstruct the CLI `--lane` value form: `thread=path:title`.
115    pub fn to_lane_arg(&self) -> String {
116        format!("{}={}:{}", self.thread, self.path.display(), self.title)
117    }
118}
119
120/// Caller-supplied fanout plan/start inputs (plus resolved base facts).
121///
122/// Field names mirror the CLI `agent fanout plan|start` surface. HEAD resolution
123/// and repository I/O stay caller-owned; pass already-resolved base fields.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct FanoutPlanRequest {
126    pub title: String,
127    /// Raw `--lane` values: `thread=path:title`.
128    pub lanes: Vec<String>,
129    pub coordination_discussion_id: Option<String>,
130    /// Full HEAD state id (caller-resolved).
131    pub base_state: String,
132    /// Short tree/root id for the HEAD state (caller-resolved).
133    pub base_root: String,
134    /// Parent coordination thread name from HEAD (or `"detached"`).
135    pub parent_thread: String,
136}
137
138/// Resolved base facts the CLI gathers from HEAD before planning.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct FanoutBaseFacts {
141    /// Full HEAD state id string.
142    pub head_state_full: String,
143    /// Short tree id of the HEAD state.
144    pub head_tree_short: String,
145    /// Attached thread name when HEAD is attached; `None` when detached.
146    pub head_thread: Option<String>,
147}
148
149/// Selected base for fanout parent/child tasks and lane starts.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct FanoutBaseSelection {
152    pub base_state: String,
153    pub base_root: String,
154    pub parent_thread: String,
155}
156
157/// Pure plan produced by [`plan_fanout`].
158///
159/// CLI executes task creation, thread spawn, and registry linking from `nodes`
160/// and the body/delegated-by helpers. Dry-run commands are for plan JSON.
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct FanoutPlan {
163    pub title: String,
164    pub parent_thread: String,
165    pub base_state: String,
166    pub base_root: String,
167    pub coordination_discussion_id: Option<String>,
168    pub nodes: Vec<FanoutNodeSpec>,
169    /// Parent coordination task body (bullet list of lanes).
170    pub parent_body: String,
171    /// Dry-run start commands for plan JSON (`commands` field).
172    pub start_commands: Vec<FanoutCommandSpec>,
173}
174
175/// One recommended command in fanout plan JSON.
176#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
177pub struct FanoutCommandSpec {
178    pub lane_thread: String,
179    pub command: String,
180    pub argv: Vec<String>,
181}
182
183/// Machine JSON domain fields for `agent fanout plan` (no verification envelope).
184///
185/// Field names match the public `agent_fanout_plan` contract. `parent_task` and
186/// per-lane `task` stay `null` for dry-run; CLI attaches verification.
187#[derive(Debug, Clone, Serialize)]
188pub struct FanoutPlanReport {
189    pub output_kind: &'static str,
190    pub title: String,
191    pub parent_thread: String,
192    pub base_state: String,
193    pub base_root: String,
194    pub coordination_discussion_id: Option<String>,
195    pub parent_task: Option<FanoutTaskPlaceholder>,
196    pub lanes: Vec<FanoutLaneReport>,
197    pub commands: Vec<FanoutCommandSpec>,
198}
199
200/// Placeholder so plan JSON keeps a null `parent_task` / lane `task` slot.
201///
202/// Never constructed for dry-run; exists only for typed `Option` serialization.
203#[derive(Debug, Clone, Serialize)]
204pub struct FanoutTaskPlaceholder {}
205
206/// One planned lane in dry-run JSON (`status = "planned"`).
207#[derive(Debug, Clone, Serialize)]
208pub struct FanoutLaneReport {
209    pub thread: String,
210    pub path: String,
211    pub title: String,
212    pub task: Option<FanoutTaskPlaceholder>,
213    pub session_id: Option<String>,
214    pub status: String,
215}
216
217// ---------------------------------------------------------------------------
218// Errors
219// ---------------------------------------------------------------------------
220
221/// Failures from pure fanout planning / lane parse.
222#[derive(Debug, Clone, PartialEq, Eq)]
223pub enum FanoutPlanError {
224    /// No `--lane` values were supplied.
225    LaneRequired,
226    /// A lane string was not `thread=path:title` with non-empty path/title.
227    LaneInvalid { raw: String },
228    /// Thread name failed the safe-slug / reserved-structure rule.
229    InvalidThreadName { raw: String, source: ThreadIdError },
230    /// The same child thread name appeared more than once.
231    DuplicateThread { thread: String },
232}
233
234impl std::fmt::Display for FanoutPlanError {
235    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
236        match self {
237            Self::LaneRequired => write!(
238                f,
239                "agent fanout requires at least one --lane <thread>=<path>:<title>"
240            ),
241            Self::LaneInvalid { raw } => write!(f, "invalid fanout lane '{raw}'"),
242            Self::InvalidThreadName { source, .. } => write!(f, "{source}"),
243            Self::DuplicateThread { thread } => {
244                write!(f, "fanout lane '{thread}' is listed more than once")
245            }
246        }
247    }
248}
249
250impl std::error::Error for FanoutPlanError {
251    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
252        match self {
253            Self::InvalidThreadName { source, .. } => Some(source),
254            _ => None,
255        }
256    }
257}
258
259// ---------------------------------------------------------------------------
260// Base selection / naming rules
261// ---------------------------------------------------------------------------
262
263/// Parent-thread label for fanout when HEAD attach facts are already known.
264///
265/// Detached HEAD uses the stable label `"detached"` (matches CLI).
266pub fn select_fanout_parent_thread(head_thread: Option<&str>) -> String {
267    match head_thread.map(str::trim).filter(|s| !s.is_empty()) {
268        Some(thread) => thread.to_string(),
269        None => "detached".to_string(),
270    }
271}
272
273/// Select fanout base state / root / parent thread from caller-resolved HEAD facts.
274///
275/// Fanout always anchors children at the current HEAD state (no `--from`).
276pub fn select_fanout_base(facts: &FanoutBaseFacts) -> FanoutBaseSelection {
277    FanoutBaseSelection {
278        base_state: facts.head_state_full.clone(),
279        base_root: facts.head_tree_short.clone(),
280        parent_thread: select_fanout_parent_thread(facts.head_thread.as_deref()),
281    }
282}
283
284/// Parent coordination task body: one bullet per lane (`- thread: title`).
285pub fn fanout_parent_body(nodes: &[FanoutNodeSpec]) -> String {
286    nodes
287        .iter()
288        .map(|node| format!("- {}: {}", node.thread, node.title))
289        .collect::<Vec<_>>()
290        .join("\n")
291}
292
293/// Child task body linking back to the parent task id.
294pub fn fanout_child_body(parent_task_id: &str) -> String {
295    format!("Fan-out child lane for parent task {parent_task_id}")
296}
297
298/// `delegated_by` value written on the parent coordination task at start.
299pub fn fanout_parent_delegated_by() -> &'static str {
300    "heddle agent fanout start"
301}
302
303/// Attach-precedence / winning-attach-rule token for fanout-started reservations.
304pub fn fanout_start_attach_rule() -> &'static str {
305    "agent-fanout-start"
306}
307
308// ---------------------------------------------------------------------------
309// Lane parse
310// ---------------------------------------------------------------------------
311
312/// Parse a single `--lane` value: `thread=path:title`.
313pub fn parse_fanout_lane(raw: &str) -> Result<FanoutNodeSpec, FanoutPlanError> {
314    let (thread, rest) = raw
315        .split_once('=')
316        .ok_or_else(|| FanoutPlanError::LaneInvalid {
317            raw: raw.to_string(),
318        })?;
319    let (path, title) = rest
320        .split_once(':')
321        .ok_or_else(|| FanoutPlanError::LaneInvalid {
322            raw: raw.to_string(),
323        })?;
324    let thread = thread.trim();
325    let path = path.trim();
326    let title = title.trim();
327    if path.is_empty() || title.is_empty() {
328        return Err(FanoutPlanError::LaneInvalid {
329            raw: raw.to_string(),
330        });
331    }
332    let validated = ThreadId::new(thread).map_err(|source| FanoutPlanError::InvalidThreadName {
333        raw: raw.to_string(),
334        source,
335    })?;
336    Ok(FanoutNodeSpec {
337        thread: validated.as_str().to_string(),
338        path: PathBuf::from(path),
339        title: title.to_string(),
340    })
341}
342
343/// Parse all `--lane` values; requires at least one.
344pub fn parse_fanout_lanes(raw_lanes: &[String]) -> Result<Vec<FanoutNodeSpec>, FanoutPlanError> {
345    if raw_lanes.is_empty() {
346        return Err(FanoutPlanError::LaneRequired);
347    }
348    raw_lanes.iter().map(|raw| parse_fanout_lane(raw)).collect()
349}
350
351// ---------------------------------------------------------------------------
352// Plan / report
353// ---------------------------------------------------------------------------
354
355/// Pure preflight: parse lanes, reject duplicate thread names, assemble plan.
356///
357/// Does not open the repository or touch the filesystem. CLI still runs I/O
358/// preflight (live reservations, existing threads, resolved path collisions)
359/// before start mutations.
360pub fn plan_fanout(request: &FanoutPlanRequest) -> Result<FanoutPlan, FanoutPlanError> {
361    let nodes = parse_fanout_lanes(&request.lanes)?;
362    ensure_unique_thread_names(&nodes)?;
363    let parent_body = fanout_parent_body(&nodes);
364    let start_commands = assemble_fanout_start_commands(
365        &request.title,
366        request.coordination_discussion_id.as_deref(),
367        &nodes,
368    );
369    Ok(FanoutPlan {
370        title: request.title.clone(),
371        parent_thread: request.parent_thread.clone(),
372        base_state: request.base_state.clone(),
373        base_root: request.base_root.clone(),
374        coordination_discussion_id: request.coordination_discussion_id.clone(),
375        nodes,
376        parent_body,
377        start_commands,
378    })
379}
380
381/// Reject duplicate child thread names within one fanout.
382pub fn ensure_unique_thread_names(nodes: &[FanoutNodeSpec]) -> Result<(), FanoutPlanError> {
383    let mut seen = BTreeSet::new();
384    for node in nodes {
385        if !seen.insert(node.thread.as_str()) {
386            return Err(FanoutPlanError::DuplicateThread {
387                thread: node.thread.clone(),
388            });
389        }
390    }
391    Ok(())
392}
393
394/// Build dry-run `heddle agent fanout start ...` argv + shell-quoted command.
395pub fn assemble_fanout_start_commands(
396    title: &str,
397    coordination_discussion_id: Option<&str>,
398    nodes: &[FanoutNodeSpec],
399) -> Vec<FanoutCommandSpec> {
400    let mut argv = vec![
401        "heddle".to_string(),
402        "agent".to_string(),
403        "fanout".to_string(),
404        "start".to_string(),
405        "--title".to_string(),
406        title.to_string(),
407    ];
408    if let Some(discussion_id) = coordination_discussion_id {
409        argv.push("--coordination-discussion-id".to_string());
410        argv.push(discussion_id.to_string());
411    }
412    for node in nodes {
413        argv.push("--lane".to_string());
414        argv.push(node.to_lane_arg());
415    }
416    let command = argv
417        .iter()
418        .map(|arg| shell_quote(arg))
419        .collect::<Vec<_>>()
420        .join(" ");
421    vec![FanoutCommandSpec {
422        lane_thread: "all".to_string(),
423        command,
424        argv,
425    }]
426}
427
428/// Assemble plan dry-run report fields from a pure plan.
429pub fn assemble_fanout_plan_report(plan: &FanoutPlan) -> FanoutPlanReport {
430    FanoutPlanReport {
431        output_kind: "agent_fanout_plan",
432        title: plan.title.clone(),
433        parent_thread: plan.parent_thread.clone(),
434        base_state: plan.base_state.clone(),
435        base_root: plan.base_root.clone(),
436        coordination_discussion_id: plan.coordination_discussion_id.clone(),
437        parent_task: None,
438        lanes: plan
439            .nodes
440            .iter()
441            .map(|node| FanoutLaneReport {
442                thread: node.thread.clone(),
443                path: path_display(&node.path),
444                title: node.title.clone(),
445                task: None,
446                session_id: None,
447                status: "planned".to_string(),
448            })
449            .collect(),
450        commands: plan.start_commands.clone(),
451    }
452}
453
454fn path_display(path: &Path) -> String {
455    path.display().to_string()
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461
462    fn sample_request(lanes: &[&str]) -> FanoutPlanRequest {
463        FanoutPlanRequest {
464            title: "Coordinate fanout".to_string(),
465            lanes: lanes.iter().map(|s| (*s).to_string()).collect(),
466            coordination_discussion_id: Some("discussion-123".to_string()),
467            base_state: "state-full".to_string(),
468            base_root: "tree-short".to_string(),
469            parent_thread: "main".to_string(),
470        }
471    }
472
473    #[test]
474    fn parse_lane_accepts_thread_path_title() {
475        let node = parse_fanout_lane("feature/a=../a:Implement a").unwrap();
476        assert_eq!(node.thread, "feature/a");
477        assert_eq!(node.path, PathBuf::from("../a"));
478        assert_eq!(node.title, "Implement a");
479        assert_eq!(node.to_lane_arg(), "feature/a=../a:Implement a");
480    }
481
482    #[test]
483    fn parse_lane_trims_whitespace() {
484        let node = parse_fanout_lane("  feature/b = ./b : Title B  ").unwrap();
485        assert_eq!(node.thread, "feature/b");
486        assert_eq!(node.path, PathBuf::from("./b"));
487        assert_eq!(node.title, "Title B");
488    }
489
490    #[test]
491    fn parse_lane_rejects_missing_separators_and_empty_parts() {
492        assert!(matches!(
493            parse_fanout_lane("no-equals"),
494            Err(FanoutPlanError::LaneInvalid { .. })
495        ));
496        assert!(matches!(
497            parse_fanout_lane("thread=only-path"),
498            Err(FanoutPlanError::LaneInvalid { .. })
499        ));
500        assert!(matches!(
501            parse_fanout_lane("thread=:title"),
502            Err(FanoutPlanError::LaneInvalid { .. })
503        ));
504        assert!(matches!(
505            parse_fanout_lane("thread=path:"),
506            Err(FanoutPlanError::LaneInvalid { .. })
507        ));
508    }
509
510    #[test]
511    fn parse_lane_rejects_invalid_thread_name() {
512        let err = parse_fanout_lane("bad name=./p:Title").unwrap_err();
513        assert!(matches!(err, FanoutPlanError::InvalidThreadName { .. }));
514    }
515
516    #[test]
517    fn parse_lanes_requires_at_least_one() {
518        assert_eq!(parse_fanout_lanes(&[]), Err(FanoutPlanError::LaneRequired));
519    }
520
521    #[test]
522    fn select_parent_thread_attached_and_detached() {
523        assert_eq!(select_fanout_parent_thread(Some("main")), "main");
524        assert_eq!(select_fanout_parent_thread(Some("  ")), "detached");
525        assert_eq!(select_fanout_parent_thread(None), "detached");
526    }
527
528    #[test]
529    fn select_base_uses_head_facts() {
530        let selection = select_fanout_base(&FanoutBaseFacts {
531            head_state_full: "abc".into(),
532            head_tree_short: "def".into(),
533            head_thread: Some("main".into()),
534        });
535        assert_eq!(selection.base_state, "abc");
536        assert_eq!(selection.base_root, "def");
537        assert_eq!(selection.parent_thread, "main");
538
539        let detached = select_fanout_base(&FanoutBaseFacts {
540            head_state_full: "abc".into(),
541            head_tree_short: "def".into(),
542            head_thread: None,
543        });
544        assert_eq!(detached.parent_thread, "detached");
545    }
546
547    #[test]
548    fn parent_and_child_body_rules() {
549        let nodes = vec![
550            FanoutNodeSpec {
551                thread: "feature/a".into(),
552                path: PathBuf::from("../a"),
553                title: "Task A".into(),
554            },
555            FanoutNodeSpec {
556                thread: "feature/b".into(),
557                path: PathBuf::from("../b"),
558                title: "Task B".into(),
559            },
560        ];
561        assert_eq!(
562            fanout_parent_body(&nodes),
563            "- feature/a: Task A\n- feature/b: Task B"
564        );
565        assert_eq!(
566            fanout_child_body("task-1"),
567            "Fan-out child lane for parent task task-1"
568        );
569        assert_eq!(fanout_parent_delegated_by(), "heddle agent fanout start");
570        assert_eq!(fanout_start_attach_rule(), "agent-fanout-start");
571    }
572
573    #[test]
574    fn plan_fanout_builds_nodes_body_and_start_command() {
575        let plan = plan_fanout(&sample_request(&[
576            "feature/a=../a:Implement a",
577            "feature/b=../b:Implement b",
578        ]))
579        .unwrap();
580
581        assert_eq!(plan.nodes.len(), 2);
582        assert_eq!(plan.parent_thread, "main");
583        assert_eq!(plan.base_state, "state-full");
584        assert_eq!(plan.base_root, "tree-short");
585        assert_eq!(
586            plan.coordination_discussion_id.as_deref(),
587            Some("discussion-123")
588        );
589        assert!(plan.parent_body.contains("feature/a: Implement a"));
590        assert_eq!(plan.start_commands.len(), 1);
591        assert_eq!(plan.start_commands[0].lane_thread, "all");
592        let argv = &plan.start_commands[0].argv;
593        assert_eq!(argv[0], "heddle");
594        assert_eq!(argv[1], "agent");
595        assert_eq!(argv[2], "fanout");
596        assert_eq!(argv[3], "start");
597        assert!(argv.contains(&"--title".to_string()));
598        assert!(argv.contains(&"Coordinate fanout".to_string()));
599        assert!(argv.contains(&"--coordination-discussion-id".to_string()));
600        assert!(argv.contains(&"discussion-123".to_string()));
601        assert!(argv.contains(&"feature/a=../a:Implement a".to_string()));
602        assert!(argv.contains(&"feature/b=../b:Implement b".to_string()));
603        assert!(
604            plan.start_commands[0]
605                .command
606                .contains("agent fanout start")
607        );
608    }
609
610    #[test]
611    fn fanout_start_preflight_blocks_in_priority_order() {
612        let ok = FanoutLaneAvailability {
613            thread: "a".into(),
614            has_live_owner: false,
615            thread_ref_exists: false,
616            active_thread_record: false,
617            resolved_path: PathBuf::from("/tmp/a"),
618        };
619        assert!(check_fanout_start_preflight(std::slice::from_ref(&ok)).is_ok());
620
621        let mut live = ok.clone();
622        live.has_live_owner = true;
623        assert!(matches!(
624            check_fanout_start_preflight(&[live]),
625            Err(FanoutLanePreflightBlock::LiveOwner { .. })
626        ));
627
628        let mut exists = ok.clone();
629        exists.thread_ref_exists = true;
630        assert!(matches!(
631            check_fanout_start_preflight(&[exists]),
632            Err(FanoutLanePreflightBlock::ThreadExists { .. })
633        ));
634
635        let dup_a = ok.clone();
636        let mut dup_b = ok;
637        dup_b.thread = "b".into();
638        // same resolved path
639        assert!(matches!(
640            check_fanout_start_preflight(&[dup_a, dup_b]),
641            Err(FanoutLanePreflightBlock::DuplicatePath { thread }) if thread == "b"
642        ));
643    }
644
645    #[test]
646    fn plan_fanout_rejects_duplicate_threads() {
647        let err = plan_fanout(&sample_request(&[
648            "feature/dup=../a:First",
649            "feature/dup=../b:Second",
650        ]))
651        .unwrap_err();
652        assert_eq!(
653            err,
654            FanoutPlanError::DuplicateThread {
655                thread: "feature/dup".into()
656            }
657        );
658    }
659
660    #[test]
661    fn assemble_plan_report_matches_dry_run_contract() {
662        let plan = plan_fanout(&sample_request(&["feature/a=../a:Implement a"])).unwrap();
663        let report = assemble_fanout_plan_report(&plan);
664        assert_eq!(report.output_kind, "agent_fanout_plan");
665        assert!(report.parent_task.is_none());
666        assert_eq!(report.lanes.len(), 1);
667        assert_eq!(report.lanes[0].status, "planned");
668        assert_eq!(report.lanes[0].thread, "feature/a");
669        assert_eq!(report.lanes[0].path, "../a");
670        assert!(report.lanes[0].task.is_none());
671        assert!(report.lanes[0].session_id.is_none());
672        assert_eq!(report.commands[0].argv[2], "fanout");
673        assert_eq!(report.commands[0].argv[3], "start");
674
675        let json = serde_json::to_value(&report).unwrap();
676        assert_eq!(json["output_kind"], "agent_fanout_plan");
677        assert!(json["parent_task"].is_null());
678        assert_eq!(json["lanes"][0]["status"], "planned");
679        assert!(json["lanes"][0]["task"].is_null());
680    }
681}