1use std::collections::{BTreeSet, HashMap};
27use std::path::{Path, PathBuf};
28
29use nils_common::{fs as common_fs, git as common_git, markdown as common_markdown};
30use plan_tooling::parse::{Sprint as ParsedSprint, parse_plan_with_display};
31use plan_tooling::split_prs::{
32 SplitPlanOptions, SplitPlanRecord, SplitPrGrouping, SplitPrStrategy, SplitScope,
33 build_split_plan_records, resolve_pr_grouping_by_sprint, select_sprints_for_scope,
34};
35
36use crate::commands::{PrGroupMapping, PrGrouping, SplitStrategy};
37
38pub const TASK_SPEC_HEADER: &str = "# task_id\tsummary\tbranch\tworktree\towner\tnotes\tpr_group";
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum TaskSpecScope {
42 Plan,
43 Sprint(i32),
44}
45
46#[derive(Debug, Clone)]
47pub struct TaskSpecBuildOptions {
48 pub owner_prefix: String,
49 pub branch_prefix: String,
50 pub worktree_prefix: String,
51 pub pr_grouping: Option<PrGrouping>,
52 pub default_pr_grouping: Option<PrGrouping>,
53 pub strategy: SplitStrategy,
54 pub pr_group: Vec<PrGroupMapping>,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct TaskSpecRow {
59 pub task_id: String,
60 pub summary: String,
61 pub branch: String,
62 pub worktree: String,
63 pub owner: String,
64 pub notes: String,
65 pub pr_group: String,
66 pub sprint: i32,
67 pub grouping: PrGrouping,
68}
69
70#[derive(Debug, Clone)]
71pub struct TaskSpecBuild {
72 pub plan_title: String,
73 pub display_plan_path: String,
74 pub sprint_name: Option<String>,
75 pub rows: Vec<TaskSpecRow>,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct RuntimeLaneMetadata {
80 pub execution_mode: String,
81 pub owner: String,
82 pub branch: String,
83 pub worktree: String,
84 pub notes: String,
85}
86
87pub fn build_task_spec(
88 plan_file: &Path,
89 scope: TaskSpecScope,
90 options: &TaskSpecBuildOptions,
91) -> Result<TaskSpecBuild, String> {
92 let display_path = plan_file.to_string_lossy().to_string();
93 let resolved_plan_path = resolve_plan_file(plan_file);
94 if !resolved_plan_path.is_file() {
95 return Err(format!("plan file not found: {display_path}"));
96 }
97
98 let (plan, parse_errors) = parse_plan_with_display(&resolved_plan_path, &display_path)
99 .map_err(|err| format!("{display_path}: {err}"))?;
100 if !parse_errors.is_empty() {
101 return Err(format!("{display_path}: {}", parse_errors.join(" | ")));
102 }
103
104 let split_scope = match scope {
105 TaskSpecScope::Plan => SplitScope::Plan,
106 TaskSpecScope::Sprint(sprint) => SplitScope::Sprint(sprint),
107 };
108
109 let selected_sprints = select_sprints_for_scope(&plan, split_scope)?;
110 let sprint_name = match scope {
111 TaskSpecScope::Plan => None,
112 TaskSpecScope::Sprint(_) => selected_sprints.first().map(|sprint| sprint.name.clone()),
113 };
114
115 let split_options = SplitPlanOptions {
116 pr_grouping: options.pr_grouping.map(to_split_grouping),
117 default_pr_grouping: options.default_pr_grouping.map(to_split_grouping),
118 strategy: to_split_strategy(options.strategy),
119 pr_group_entries: options
120 .pr_group
121 .iter()
122 .map(|entry| format!("{}={}", entry.task, entry.group))
123 .collect(),
124 owner_prefix: options.owner_prefix.clone(),
125 branch_prefix: options.branch_prefix.clone(),
126 worktree_prefix: options.worktree_prefix.clone(),
127 };
128 let grouping_by_sprint =
129 resolve_pr_grouping_by_sprint(&selected_sprints, &split_options).map(|resolved| {
130 resolved
131 .into_iter()
132 .map(|(sprint, value)| (sprint, from_split_grouping(value.grouping)))
133 .collect::<HashMap<_, _>>()
134 })?;
135
136 let split_records = build_split_plan_records(&selected_sprints, &split_options)?;
137 let rows = RuntimeMetadataMaterializer::new(&selected_sprints, options, grouping_by_sprint)?
138 .materialize_rows(&split_records)?;
139
140 Ok(TaskSpecBuild {
141 plan_title: plan.title,
142 display_plan_path: display_path,
143 sprint_name,
144 rows,
145 })
146}
147
148#[derive(Debug, Clone)]
149struct RuntimeTaskSeed {
150 sprint: i32,
151 ordinal: usize,
152 plan_task_id: String,
153 dependencies: Vec<String>,
154 first_validation: Option<String>,
155}
156
157#[derive(Debug, Clone)]
158struct RuntimeMetadataMaterializer {
159 owner_prefix: String,
160 branch_prefix: String,
161 worktree_prefix: String,
162 grouping_by_sprint: HashMap<i32, PrGrouping>,
163 strategy: SplitStrategy,
164 task_seed_by_id: HashMap<String, RuntimeTaskSeed>,
165}
166
167impl RuntimeMetadataMaterializer {
168 fn new(
169 selected_sprints: &[ParsedSprint],
170 options: &TaskSpecBuildOptions,
171 grouping_by_sprint: HashMap<i32, PrGrouping>,
172 ) -> Result<Self, String> {
173 let mut task_seed_by_id: HashMap<String, RuntimeTaskSeed> = HashMap::new();
174
175 for sprint in selected_sprints {
176 for (idx, task) in sprint.tasks.iter().enumerate() {
177 let ordinal = idx + 1;
178 let task_id = format!("S{}T{ordinal}", sprint.number);
179 let plan_task_id = if task.id.trim().is_empty() {
180 task_id.clone()
181 } else {
182 task.id.trim().to_string()
183 };
184 let dependencies = task
185 .dependencies
186 .clone()
187 .unwrap_or_default()
188 .into_iter()
189 .map(|dep| dep.id.trim().to_string())
190 .filter(|dep| !dep.is_empty())
191 .filter(|dep| !is_plan_placeholder(dep))
192 .collect::<Vec<_>>();
193 let first_validation = task
194 .validation
195 .iter()
196 .map(|validation| validation.trim().to_string())
197 .find(|validation| !validation.is_empty() && !is_plan_placeholder(validation));
198
199 let inserted = task_seed_by_id.insert(
200 task_id.clone(),
201 RuntimeTaskSeed {
202 sprint: sprint.number,
203 ordinal,
204 plan_task_id,
205 dependencies,
206 first_validation,
207 },
208 );
209 if inserted.is_some() {
210 return Err(format!(
211 "duplicate synthesized task id while materializing runtime metadata: {task_id}"
212 ));
213 }
214 }
215 }
216
217 Ok(Self {
218 owner_prefix: normalize_owner_prefix(&options.owner_prefix),
219 branch_prefix: normalize_branch_prefix(&options.branch_prefix),
220 worktree_prefix: normalize_worktree_prefix(&options.worktree_prefix),
221 grouping_by_sprint,
222 strategy: options.strategy,
223 task_seed_by_id,
224 })
225 }
226
227 fn materialize_rows(
228 &self,
229 split_records: &[SplitPlanRecord],
230 ) -> Result<Vec<TaskSpecRow>, String> {
231 let mut group_sizes: HashMap<(i32, String), usize> = HashMap::new();
232 let mut anchor_by_lane: HashMap<(i32, String), String> = HashMap::new();
233 for record in split_records {
234 let lane_key = (record.sprint, record.pr_group.clone());
235 *group_sizes.entry(lane_key.clone()).or_insert(0) += 1;
236 anchor_by_lane
237 .entry(lane_key)
238 .or_insert_with(|| record.task_id.clone());
239 }
240
241 let mut rows = Vec::with_capacity(split_records.len());
242 for record in split_records {
243 let seed = self
244 .task_seed_by_id
245 .get(&record.task_id)
246 .ok_or_else(|| format!("{}: missing parsed plan task metadata", record.task_id))?;
247 let lane_key = (record.sprint, record.pr_group.clone());
248 let shared_anchor = if group_sizes.get(&lane_key).copied().unwrap_or(0) > 1 {
249 anchor_by_lane.get(&lane_key).cloned()
250 } else {
251 None
252 };
253
254 let slug_fallback = format!("task-{}", seed.ordinal);
255 let slug = normalize_token(&record.summary, &slug_fallback, 48);
256 let grouping = *self.grouping_by_sprint.get(&record.sprint).ok_or_else(|| {
257 format!(
258 "{}: missing resolved grouping for sprint {}",
259 record.task_id, record.sprint
260 )
261 })?;
262 let notes =
263 synthesize_notes(seed, grouping, &record.pr_group, shared_anchor.as_deref());
264
265 rows.push(TaskSpecRow {
266 task_id: record.task_id.clone(),
267 summary: record.summary.clone(),
268 branch: format!(
269 "{}/s{}-t{}-{}",
270 self.branch_prefix, seed.sprint, seed.ordinal, slug
271 ),
272 worktree: format!(
273 "{}-s{}-t{}",
274 self.worktree_prefix, seed.sprint, seed.ordinal
275 ),
276 owner: format!("{}-s{}-t{}", self.owner_prefix, seed.sprint, seed.ordinal),
277 notes,
278 pr_group: record.pr_group.clone(),
279 sprint: record.sprint,
280 grouping,
281 });
282 }
283
284 let runtime_lane_metadata = runtime_lane_metadata_by_task(&rows, self.strategy);
285 for row in &mut rows {
286 let metadata = runtime_lane_metadata.get(&row.task_id).ok_or_else(|| {
287 format!(
288 "{}: missing runtime lane metadata after materialization",
289 row.task_id
290 )
291 })?;
292 row.owner = metadata.owner.clone();
293 row.branch = metadata.branch.clone();
294 row.worktree = metadata.worktree.clone();
295 row.notes = metadata.notes.clone();
296 }
297
298 Ok(rows)
299 }
300}
301
302fn synthesize_notes(
303 seed: &RuntimeTaskSeed,
304 grouping: PrGrouping,
305 pr_group: &str,
306 shared_anchor: Option<&str>,
307) -> String {
308 let mut notes = vec![
309 format!("sprint=S{}", seed.sprint),
310 format!("plan-task:{}", seed.plan_task_id),
311 ];
312 if !seed.dependencies.is_empty() {
313 notes.push(format!("deps={}", seed.dependencies.join(",")));
314 }
315 if let Some(first_validation) = &seed.first_validation {
316 notes.push(format!("validate={first_validation}"));
317 }
318 notes.push(format!("pr-grouping={}", pr_grouping_label(grouping)));
319 notes.push(format!("pr-group={pr_group}"));
320 if let Some(anchor) = shared_anchor {
321 notes.push(format!("shared-pr-anchor={anchor}"));
322 }
323
324 common_markdown::canonicalize_table_cell(¬es.join("; "))
325}
326
327fn pr_grouping_label(grouping: PrGrouping) -> &'static str {
328 match grouping {
329 PrGrouping::PerSprint => "per-sprint",
330 PrGrouping::Group => "group",
331 }
332}
333
334fn normalize_branch_prefix(value: &str) -> String {
335 let trimmed = value.trim().trim_end_matches('/');
336 if trimmed.is_empty() {
337 "feat".to_string()
338 } else {
339 trimmed.to_string()
340 }
341}
342
343fn normalize_worktree_prefix(value: &str) -> String {
344 let trimmed = value.trim().trim_end_matches(['-', '_']);
345 if trimmed.is_empty() {
346 "feat".to_string()
347 } else {
348 trimmed.to_string()
349 }
350}
351
352fn normalize_owner_prefix(value: &str) -> String {
353 let trimmed = value.trim();
354 if trimmed.is_empty() {
355 "subagent".to_string()
356 } else if trimmed.to_ascii_lowercase().contains("subagent") {
357 trimmed.to_string()
358 } else {
359 format!("subagent-{trimmed}")
360 }
361}
362
363fn normalize_token(value: &str, fallback: &str, max_len: usize) -> String {
364 let mut out = String::new();
365 let mut last_dash = false;
366 for ch in value.chars().flat_map(char::to_lowercase) {
367 if ch.is_ascii_alphanumeric() {
368 out.push(ch);
369 last_dash = false;
370 } else if !last_dash {
371 out.push('-');
372 last_dash = true;
373 }
374 }
375 let normalized = out.trim_matches('-').to_string();
376 let mut final_token = if normalized.is_empty() {
377 fallback.to_string()
378 } else {
379 normalized
380 };
381 if final_token.len() > max_len {
382 final_token.truncate(max_len);
383 final_token = final_token.trim_matches('-').to_string();
384 }
385 final_token
386}
387
388fn is_plan_placeholder(value: &str) -> bool {
389 let token = value.trim().to_ascii_lowercase();
390 if matches!(token.as_str(), "" | "-" | "none" | "n/a" | "na" | "...") {
391 return true;
392 }
393 if token.starts_with('<') && token.ends_with('>') {
394 return true;
395 }
396 token.contains("task ids")
397}
398
399pub fn render_tsv(rows: &[TaskSpecRow]) -> String {
400 let mut out = String::new();
401 out.push_str(TASK_SPEC_HEADER);
402 out.push('\n');
403 for row in rows {
404 out.push_str(&format!(
405 "{}\t{}\t{}\t{}\t{}\t{}\t{}\n",
406 row.task_id.replace('\t', " "),
407 row.summary.replace('\t', " "),
408 row.branch.replace('\t', " "),
409 row.worktree.replace('\t', " "),
410 row.owner.replace('\t', " "),
411 row.notes.replace('\t', " "),
412 row.pr_group.replace('\t', " "),
413 ));
414 }
415 out
416}
417
418pub fn write_tsv(path: &Path, rows: &[TaskSpecRow]) -> Result<(), String> {
419 common_fs::write_text(path, &render_tsv(rows)).map_err(|err| match err {
420 common_fs::WriteTextError::CreateParentDir { path, source } => {
421 format!(
422 "failed to create output directory {}: {source}",
423 path.display()
424 )
425 }
426 common_fs::WriteTextError::WriteFile { source, .. } => {
427 format!("failed to write task-spec {}: {source}", path.display())
428 }
429 })
430}
431
432pub fn execution_mode_by_task(
433 rows: &[TaskSpecRow],
434 strategy: SplitStrategy,
435) -> HashMap<String, String> {
436 runtime_lane_metadata_by_task(rows, strategy)
437 .into_iter()
438 .map(|(task_id, lane)| (task_id, lane.execution_mode))
439 .collect()
440}
441
442pub fn runtime_lane_metadata_by_task(
443 rows: &[TaskSpecRow],
444 strategy: SplitStrategy,
445) -> HashMap<String, RuntimeLaneMetadata> {
446 let execution_modes = execution_mode_from_rows(rows, strategy);
447 let row_by_task: HashMap<&str, &TaskSpecRow> =
448 rows.iter().map(|row| (row.task_id.as_str(), row)).collect();
449
450 let mut anchor_by_lane: HashMap<(i32, String), String> = HashMap::new();
451 for row in rows {
452 anchor_by_lane
453 .entry((row.sprint, row.pr_group.clone()))
454 .or_insert_with(|| {
455 canonical_lane_anchor_task_id(rows, row.sprint, &row.pr_group)
456 .unwrap_or_else(|| row.task_id.clone())
457 });
458 }
459
460 let mut out = HashMap::new();
461 for row in rows {
462 let execution_mode = execution_modes
463 .get(&row.task_id)
464 .cloned()
465 .unwrap_or_else(|| "pr-isolated".to_string());
466 let lane_key = (row.sprint, row.pr_group.clone());
467 let anchor_row = if execution_mode == "pr-isolated" {
468 row
469 } else {
470 anchor_by_lane
471 .get(&lane_key)
472 .and_then(|task_id| row_by_task.get(task_id.as_str()))
473 .copied()
474 .unwrap_or(row)
475 };
476
477 out.insert(
478 row.task_id.clone(),
479 RuntimeLaneMetadata {
480 execution_mode,
481 owner: anchor_row.owner.clone(),
482 branch: anchor_row.branch.clone(),
483 worktree: anchor_row.worktree.clone(),
484 notes: common_markdown::canonicalize_table_cell(&row.notes),
485 },
486 );
487 }
488
489 out
490}
491
492fn execution_mode_from_rows(
493 rows: &[TaskSpecRow],
494 _strategy: SplitStrategy,
495) -> HashMap<String, String> {
496 let mut sprint_group_set: HashMap<i32, BTreeSet<String>> = HashMap::new();
497 let mut sprint_group_sizes: HashMap<(i32, String), usize> = HashMap::new();
498 for row in rows {
499 sprint_group_set
500 .entry(row.sprint)
501 .or_default()
502 .insert(row.pr_group.clone());
503 *sprint_group_sizes
504 .entry((row.sprint, row.pr_group.clone()))
505 .or_insert(0) += 1;
506 }
507
508 let mut out = HashMap::new();
509 for row in rows {
510 let sprint_group_count = sprint_group_set
511 .get(&row.sprint)
512 .map(BTreeSet::len)
513 .unwrap_or(0);
514 let group_size = sprint_group_sizes
515 .get(&(row.sprint, row.pr_group.clone()))
516 .copied()
517 .unwrap_or(0);
518
519 let mode = if row.grouping == PrGrouping::PerSprint {
520 "per-sprint"
521 } else if sprint_group_count == 1 && group_size > 1 {
522 "per-sprint"
526 } else if group_size > 1 {
527 "pr-shared"
528 } else {
529 "pr-isolated"
530 };
531 out.insert(row.task_id.clone(), mode.to_string());
532 }
533
534 out
535}
536
537fn canonical_lane_anchor_task_id(
538 rows: &[TaskSpecRow],
539 sprint: i32,
540 pr_group: &str,
541) -> Option<String> {
542 let mut lane_rows = rows
543 .iter()
544 .filter(|row| row.sprint == sprint && row.pr_group == pr_group)
545 .collect::<Vec<_>>();
546 if lane_rows.is_empty() {
547 return None;
548 }
549
550 lane_rows.sort_unstable_by(|a, b| a.task_id.cmp(&b.task_id));
551 lane_rows.first().map(|row| row.task_id.clone())
552}
553
554pub fn default_plan_task_spec_path(plan_file: &Path) -> PathBuf {
555 let plan_stem = plan_file
556 .file_stem()
557 .and_then(|name| name.to_str())
558 .unwrap_or("plan")
559 .to_string();
560
561 state_dir()
562 .join("out")
563 .join("plan-issue-delivery")
564 .join(format!("{plan_stem}-plan-tasks.tsv"))
565}
566
567pub fn default_sprint_task_spec_path(plan_file: &Path, sprint: i32) -> PathBuf {
568 let plan_stem = plan_file
569 .file_stem()
570 .and_then(|name| name.to_str())
571 .unwrap_or("plan")
572 .to_string();
573
574 state_dir()
575 .join("out")
576 .join("plan-issue-delivery")
577 .join(format!("{plan_stem}-sprint-{sprint}-tasks.tsv"))
578}
579
580pub fn state_dir() -> PathBuf {
581 crate::state::state_dir()
582}
583
584pub fn resolve_plan_file(plan_file: &Path) -> PathBuf {
585 let repo_root = detect_repo_root();
586 resolve_repo_relative(&repo_root, plan_file)
587}
588
589fn detect_repo_root() -> PathBuf {
590 common_git::repo_root_or_cwd()
591}
592
593fn resolve_repo_relative(repo_root: &Path, path: &Path) -> PathBuf {
594 if path.is_absolute() {
595 return path.to_path_buf();
596 }
597 repo_root.join(path)
598}
599
600fn to_split_grouping(grouping: PrGrouping) -> SplitPrGrouping {
601 match grouping {
602 PrGrouping::PerSprint => SplitPrGrouping::PerSprint,
603 PrGrouping::Group => SplitPrGrouping::Group,
604 }
605}
606
607fn from_split_grouping(grouping: SplitPrGrouping) -> PrGrouping {
608 match grouping {
609 SplitPrGrouping::PerSprint => PrGrouping::PerSprint,
610 SplitPrGrouping::Group => PrGrouping::Group,
611 }
612}
613
614fn to_split_strategy(strategy: SplitStrategy) -> SplitPrStrategy {
615 match strategy {
616 SplitStrategy::Deterministic => SplitPrStrategy::Deterministic,
617 SplitStrategy::Auto => SplitPrStrategy::Auto,
618 }
619}
620
621#[cfg(test)]
622mod tests {
623 use super::*;
624
625 #[allow(clippy::too_many_arguments)]
626 fn spec_row(
627 task_id: &str,
628 sprint: i32,
629 pr_group: &str,
630 grouping: PrGrouping,
631 owner: &str,
632 branch: &str,
633 worktree: &str,
634 notes: &str,
635 ) -> TaskSpecRow {
636 TaskSpecRow {
637 task_id: task_id.to_string(),
638 summary: format!("Summary for {task_id}"),
639 branch: branch.to_string(),
640 worktree: worktree.to_string(),
641 owner: owner.to_string(),
642 notes: notes.to_string(),
643 pr_group: pr_group.to_string(),
644 sprint,
645 grouping,
646 }
647 }
648
649 #[test]
650 fn execution_mode_by_task_auto_single_lane_uses_per_sprint() {
651 let rows = vec![
652 spec_row(
653 "S1T1",
654 1,
655 "s1-auto-g1",
656 PrGrouping::Group,
657 "subagent-s1-t1",
658 "issue/s1-t1",
659 "wt-1",
660 "sprint=S1; plan-task:Task 1.1; pr-group=s1-auto-g1; shared-pr-anchor=S1T2",
661 ),
662 spec_row(
663 "S1T2",
664 1,
665 "s1-auto-g1",
666 PrGrouping::Group,
667 "subagent-s1-t2",
668 "issue/s1-t2",
669 "wt-2",
670 "sprint=S1; plan-task:Task 1.2; pr-group=s1-auto-g1; shared-pr-anchor=S1T2",
671 ),
672 ];
673
674 let modes = execution_mode_by_task(&rows, SplitStrategy::Auto);
675 assert_eq!(modes.get("S1T1").map(String::as_str), Some("per-sprint"));
676 assert_eq!(modes.get("S1T2").map(String::as_str), Some("per-sprint"));
677 }
678
679 #[test]
680 fn execution_mode_by_task_deterministic_single_lane_uses_per_sprint() {
681 let rows = vec![
682 spec_row(
683 "S1T1",
684 1,
685 "s1-serial",
686 PrGrouping::Group,
687 "subagent-s1-t1",
688 "issue/s1-t1",
689 "wt-1",
690 "sprint=S1; plan-task:Task 1.1; pr-group=s1-serial; shared-pr-anchor=S1T1",
691 ),
692 spec_row(
693 "S1T2",
694 1,
695 "s1-serial",
696 PrGrouping::Group,
697 "subagent-s1-t1",
698 "issue/s1-t1",
699 "wt-1",
700 "sprint=S1; plan-task:Task 1.2; pr-group=s1-serial; shared-pr-anchor=S1T1",
701 ),
702 ];
703
704 let modes = execution_mode_by_task(&rows, SplitStrategy::Deterministic);
705 assert_eq!(modes.get("S1T1").map(String::as_str), Some("per-sprint"));
706 assert_eq!(modes.get("S1T2").map(String::as_str), Some("per-sprint"));
707 }
708
709 #[test]
710 fn execution_mode_by_task_auto_multi_group_keeps_group_modes() {
711 let rows = vec![
712 spec_row(
713 "S2T1",
714 2,
715 "s2-auto-g1",
716 PrGrouping::Group,
717 "subagent-s2-t1",
718 "issue/s2-t1",
719 "wt-1",
720 "sprint=S2; plan-task:Task 2.1; pr-group=s2-auto-g1",
721 ),
722 spec_row(
723 "S2T2",
724 2,
725 "s2-auto-g1",
726 PrGrouping::Group,
727 "subagent-s2-t2",
728 "issue/s2-t2",
729 "wt-2",
730 "sprint=S2; plan-task:Task 2.2; pr-group=s2-auto-g1",
731 ),
732 spec_row(
733 "S2T3",
734 2,
735 "s2-auto-g2",
736 PrGrouping::Group,
737 "subagent-s2-t3",
738 "issue/s2-t3",
739 "wt-3",
740 "sprint=S2; plan-task:Task 2.3; pr-group=s2-auto-g2",
741 ),
742 ];
743
744 let modes = execution_mode_by_task(&rows, SplitStrategy::Auto);
745 assert_eq!(modes.get("S2T1").map(String::as_str), Some("pr-shared"));
746 assert_eq!(modes.get("S2T2").map(String::as_str), Some("pr-shared"));
747 assert_eq!(modes.get("S2T3").map(String::as_str), Some("pr-isolated"));
748 }
749
750 #[test]
751 fn canonical_lane_anchor_uses_stable_task_order_even_when_notes_disagree() {
752 let rows = vec![
753 spec_row(
754 "S1T1",
755 1,
756 "s1-auto-g1",
757 PrGrouping::Group,
758 "subagent-s1-t1",
759 "issue/s1-t1",
760 "wt-1",
761 "sprint=S1; plan-task:Task 1.1; pr-group=s1-auto-g1; shared-pr-anchor=S1T2",
762 ),
763 spec_row(
764 "S1T2",
765 1,
766 "s1-auto-g1",
767 PrGrouping::Group,
768 "subagent-s1-t2",
769 "issue/s1-t2",
770 "wt-2",
771 "sprint=S1; plan-task:Task 1.2; pr-group=s1-auto-g1; shared-pr-anchor=S1T2",
772 ),
773 ];
774
775 assert_eq!(
776 canonical_lane_anchor_task_id(&rows, 1, "s1-auto-g1"),
777 Some("S1T1".to_string())
778 );
779 }
780
781 #[test]
782 fn canonical_lane_anchor_uses_deterministic_task_id_fallback_when_note_absent() {
783 let rows = vec![
784 spec_row(
785 "S4T3",
786 4,
787 "s4-auto-g2",
788 PrGrouping::Group,
789 "subagent-s4-t3",
790 "issue/s4-t3",
791 "wt-3",
792 "sprint=S4; plan-task:Task 4.3; pr-group=s4-auto-g2",
793 ),
794 spec_row(
795 "S4T1",
796 4,
797 "s4-auto-g2",
798 PrGrouping::Group,
799 "subagent-s4-t1",
800 "issue/s4-t1",
801 "wt-1",
802 "sprint=S4; plan-task:Task 4.1; pr-group=s4-auto-g2",
803 ),
804 spec_row(
805 "S4T2",
806 4,
807 "s4-auto-g2",
808 PrGrouping::Group,
809 "subagent-s4-t2",
810 "issue/s4-t2",
811 "wt-2",
812 "sprint=S4; plan-task:Task 4.2; pr-group=s4-auto-g2",
813 ),
814 ];
815
816 assert_eq!(
817 canonical_lane_anchor_task_id(&rows, 4, "s4-auto-g2"),
818 Some("S4T1".to_string())
819 );
820 }
821
822 #[test]
823 fn runtime_lane_canonicalization_uses_shared_anchor_metadata() {
824 let rows = vec![
825 spec_row(
826 "S1T1",
827 1,
828 "s1-auto-g1",
829 PrGrouping::Group,
830 "subagent-s1-t1",
831 "issue/s1-t1",
832 "wt-1",
833 "sprint=S1; plan-task:Task 1.1; pr-group=s1-auto-g1; shared-pr-anchor=S1T2",
834 ),
835 spec_row(
836 "S1T2",
837 1,
838 "s1-auto-g1",
839 PrGrouping::Group,
840 "subagent-s1-t2",
841 "issue/s1-t2",
842 "wt-2",
843 "sprint=S1; plan-task:Task 1.2; pr-group=s1-auto-g1; shared-pr-anchor=S1T2",
844 ),
845 ];
846
847 let runtime_by_task = runtime_lane_metadata_by_task(&rows, SplitStrategy::Auto);
848 let expected_anchor = runtime_by_task
849 .get("S1T2")
850 .expect("anchor runtime lane metadata")
851 .clone();
852
853 for row in rows
854 .iter()
855 .filter(|row| row.sprint == 1 && row.pr_group == "s1-auto-g1")
856 {
857 let lane = runtime_by_task
858 .get(&row.task_id)
859 .expect("runtime lane metadata");
860 assert_eq!(lane.execution_mode, "per-sprint");
861 assert_eq!(
862 lane.owner, expected_anchor.owner,
863 "task {} owner should match anchor",
864 row.task_id
865 );
866 assert_eq!(
867 lane.branch, expected_anchor.branch,
868 "task {} branch should match anchor",
869 row.task_id
870 );
871 assert_eq!(
872 lane.worktree, expected_anchor.worktree,
873 "task {} worktree should match anchor",
874 row.task_id
875 );
876 }
877
878 let rerun = runtime_lane_metadata_by_task(&rows, SplitStrategy::Auto);
879 assert_eq!(runtime_by_task, rerun);
880 }
881}