Coverage for little_loops/cli/sprint.py: 86%

1"""ll-sprint: Sprint and sequence management with dependency-aware execution."""

2

3 from __future__ import annotations

4

5 import argparse

6 import signal

7 import sys

8 from pathlib import Path

9 from types import FrameType

10 from typing import Any

11

12 from little_loops.cli_args import (

13 add_config_arg,

14 add_dry_run_arg,

15 add_max_workers_arg,

16 add_quiet_arg,

17 add_resume_arg,

18 add_skip_analysis_arg,

19 add_skip_arg,

20 add_timeout_arg,

21 add_type_arg,

22 parse_issue_ids,

23 parse_issue_types,

24)

25 from little_loops.config import BRConfig

26 from little_loops.dependency_graph import (

27 DependencyGraph,

28 WaveContentionNote,

29 refine_waves_for_contention,

30)

31 from little_loops.logger import Logger, format_duration

32 from little_loops.parallel.orchestrator import ParallelOrchestrator

33 from little_loops.sprint import SprintManager, SprintOptions, SprintState

34

35# Module-level shutdown flag for ll-sprint signal handling (ENH-183)

36 _sprint_shutdown_requested: bool = False

37

38

39 def _sprint_signal_handler(signum: int, frame: FrameType | None) -> None:

40 """Handle shutdown signals gracefully for ll-sprint.

41

42 First signal: Set shutdown flag for graceful exit after current wave.

43 Second signal: Force immediate exit.

44 """

45 global _sprint_shutdown_requested

46 if _sprint_shutdown_requested:

47 # Second signal - force exit

48 print("\nForce shutdown requested", file=sys.stderr)

49 sys.exit(1)

50 _sprint_shutdown_requested = True

51 print("\nShutdown requested, will exit after current wave...", file=sys.stderr)

52

53

54 def main_sprint() -> int:

55 """Entry point for ll-sprint command.

56

57 Manage and execute sprint/sequence definitions.

58

59 Returns:

60 Exit code (0 = success)

61 """

62 parser = argparse.ArgumentParser(

63 prog="ll-sprint",

64 description="Manage and execute sprint/sequence definitions",

65 formatter_class=argparse.RawDescriptionHelpFormatter,

66 epilog="""

67 Examples:

68 %(prog)s create sprint-1 --issues BUG-001,FEAT-010 --description "Q1 fixes"

69 %(prog)s run sprint-1

70 %(prog)s run sprint-1 --dry-run

71 %(prog)s list

72 %(prog)s show sprint-1

73 %(prog)s edit sprint-1 --add BUG-045,ENH-050

74 %(prog)s edit sprint-1 --remove BUG-001

75 %(prog)s edit sprint-1 --prune

76 %(prog)s edit sprint-1 --revalidate

77 %(prog)s delete sprint-1

78 %(prog)s analyze sprint-1

79 %(prog)s analyze sprint-1 --format json

80""",

81 )

82

83 subparsers = parser.add_subparsers(dest="command", help="Available commands")

84

85 # create subcommand

86 create_parser = subparsers.add_parser("create", help="Create a new sprint")

87 create_parser.add_argument("name", help="Sprint name (used as filename)")

88 create_parser.add_argument(

89 "--issues",

90 required=True,

91 help="Comma-separated issue IDs (e.g., BUG-001,FEAT-010)",

92 )

93 create_parser.add_argument("--description", "-d", default="", help="Sprint description")

94 add_max_workers_arg(create_parser, default=2)

95 add_timeout_arg(create_parser, default=3600)

96 add_skip_arg(

97 create_parser,

98 help_text=(

99 "Comma-separated list of issue IDs to exclude from sprint (e.g., BUG-003,FEAT-004)"

100 ),

101 )

102 add_type_arg(create_parser)

103

104 # run subcommand

105 run_parser = subparsers.add_parser("run", help="Execute a sprint")

106 run_parser.add_argument("sprint", help="Sprint name to execute")

107 add_dry_run_arg(run_parser)

108 add_max_workers_arg(run_parser)

109 add_timeout_arg(run_parser)

110 add_config_arg(run_parser)

111 add_resume_arg(run_parser)

112 add_quiet_arg(run_parser)

113 add_skip_arg(

114 run_parser,

115 help_text=(

116 "Comma-separated list of issue IDs to skip during execution (e.g., BUG-003,FEAT-004)"

117 ),

118 )

119 add_skip_analysis_arg(run_parser)

120 add_type_arg(run_parser)

121

122 # list subcommand

123 list_parser = subparsers.add_parser("list", help="List all sprints")

124 list_parser.add_argument(

125 "--verbose", "-v", action="store_true", help="Show detailed information"

126 )

127

128 # show subcommand

129 show_parser = subparsers.add_parser("show", help="Show sprint details")

130 show_parser.add_argument("sprint", help="Sprint name to show")

131 add_config_arg(show_parser)

132 add_skip_analysis_arg(show_parser)

133

134 # edit subcommand

135 edit_parser = subparsers.add_parser("edit", help="Edit a sprint's issue list")

136 edit_parser.add_argument("sprint", help="Sprint name to edit")

137 edit_parser.add_argument(

138 "--add",

139 default=None,

140 help="Comma-separated issue IDs to add (e.g., BUG-045,ENH-050)",

141 )

142 edit_parser.add_argument(

143 "--remove",

144 default=None,

145 help="Comma-separated issue IDs to remove",

146 )

147 edit_parser.add_argument(

148 "--prune",

149 action="store_true",

150 help="Remove invalid (missing file) and completed issue references",

151 )

152 edit_parser.add_argument(

153 "--revalidate",

154 action="store_true",

155 help="Re-run dependency analysis after edits",

156 )

157 add_config_arg(edit_parser)

158

159 # delete subcommand

160 delete_parser = subparsers.add_parser("delete", help="Delete a sprint")

161 delete_parser.add_argument("sprint", help="Sprint name to delete")

162

163 # analyze subcommand

164 analyze_parser = subparsers.add_parser(

165 "analyze", help="Analyze sprint for file conflicts between issues"

166 )

167 analyze_parser.add_argument("sprint", help="Sprint name to analyze")

168 analyze_parser.add_argument(

169 "-f",

170 "--format",

171 type=str,

172 choices=["text", "json"],

173 default="text",

174 help="Output format (default: text)",

175 )

176 add_config_arg(analyze_parser)

177

178 args = parser.parse_args()

179

180 if not args.command:

181 parser.print_help()

182 return 1

183

184 # Commands that don't need project root

185 if args.command == "list":

186 return _cmd_sprint_list(args, SprintManager())

187 if args.command == "delete":

188 return _cmd_sprint_delete(args, SprintManager())

189

190 # Commands that need project root

191 project_root = args.config if hasattr(args, "config") and args.config else Path.cwd()

192 config = BRConfig(project_root)

193 manager = SprintManager(config=config)

194

195 if args.command == "create":

196 return _cmd_sprint_create(args, manager)

197 if args.command == "show":

198 return _cmd_sprint_show(args, manager)

199 if args.command == "edit":

200 return _cmd_sprint_edit(args, manager)

201 if args.command == "run":

202 return _cmd_sprint_run(args, manager, config)

203 if args.command == "analyze":

204 return _cmd_sprint_analyze(args, manager)

205

206 return 1

207

208

209 def _cmd_sprint_create(args: argparse.Namespace, manager: SprintManager) -> int:

210 """Create a new sprint."""

211 logger = Logger()

212 issues = [i.strip().upper() for i in args.issues.split(",")]

213

214 # Apply skip filter if provided

215 skip_ids = parse_issue_ids(args.skip)

216 if skip_ids:

217 original_count = len(issues)

218 issues = [i for i in issues if i not in skip_ids]

219 skipped = original_count - len(issues)

220 if skipped > 0:

221 logger.info(

222 f"Skipping {skipped} issue(s): "

223 f"{', '.join(sorted(skip_ids & set(issues) | skip_ids))}"

224 )

225

226 # Apply type filter if provided

227 type_prefixes = parse_issue_types(getattr(args, "type", None))

228 if type_prefixes:

229 original_count = len(issues)

230 issues = [i for i in issues if i.split("-", 1)[0] in type_prefixes]

231 filtered = original_count - len(issues)

232 if filtered > 0:

233 logger.info(f"Filtered {filtered} issue(s) by type: {', '.join(sorted(type_prefixes))}")

234

235 # Validate issues exist

236 valid = manager.validate_issues(issues)

237 invalid = set(issues) - set(valid.keys())

238

239 if invalid:

240 logger.warning(f"Issue IDs not found: {', '.join(sorted(invalid))}")

241

242 options = SprintOptions(

243 max_workers=args.max_workers,

244 timeout=args.timeout,

245 )

246

247 sprint = manager.create(

248 name=args.name,

249 issues=issues,

250 description=args.description,

251 options=options,

252 )

253

254 logger.success(f"Created sprint: {sprint.name}")

255 logger.info(f" Description: {sprint.description or '(none)'}")

256 logger.info(f" Issues: {', '.join(sprint.issues)}")

257 logger.info(f" File: .sprints/{sprint.name}.yaml")

258

259 if invalid:

260 logger.warning(f" Invalid issues: {', '.join(sorted(invalid))}")

261

262 return 0

263

264

265 def _render_execution_plan(

266 waves: list[list[Any]],

267 dep_graph: DependencyGraph,

268 contention_notes: list[WaveContentionNote | None] | None = None,

269) -> str:

270 """Render execution plan with wave groupings.

271

272 Args:

273 waves: List of execution waves from get_execution_waves()

274 dep_graph: DependencyGraph for looking up blockers

275 contention_notes: Optional per-wave contention annotations from

276 refine_waves_for_contention(). Same length as waves.

277

278 Returns:

279 Formatted string showing wave structure

280 """

281 if not waves:

282 return ""

283

284 # Build logical wave groups: consecutive sub-waves from the same

285 # parent_wave_index are grouped together.

286 logical_waves: list[list[int]] = [] # each entry is list of indices into waves

287 notes = contention_notes or [None] * len(waves)

288

289 for idx in range(len(waves)):

290 note = notes[idx] if idx < len(notes) else None

291 if note is not None:

292 # Check if this belongs to the same parent as the previous group

293 if logical_waves and notes[logical_waves[-1][0]] is not None:

294 prev_note = notes[logical_waves[-1][0]]

295 if prev_note and prev_note.parent_wave_index == note.parent_wave_index:

296 logical_waves[-1].append(idx)

297 continue

298 logical_waves.append([idx])

299 else:

300 logical_waves.append([idx])

301

302 total_issues = sum(len(wave) for wave in waves)

303 num_logical = len(logical_waves)

304 lines: list[str] = []

305

306 lines.append("")

307 lines.append("=" * 70)

308 wave_word = "wave" if num_logical == 1 else "waves"

309 lines.append(f"EXECUTION PLAN ({total_issues} issues, {num_logical} {wave_word})")

310 lines.append("=" * 70)

311

312 for logical_idx, group in enumerate(logical_waves):

313 lines.append("")

314 logical_num = logical_idx + 1

315 group_issues = [issue for widx in group for issue in waves[widx]]

316 group_count = len(group_issues)

317 is_contention = len(group) > 1

318

319 if is_contention:

320 # Multiple sub-waves from overlap splitting

321 lines.append(

322 f"Wave {logical_num} ({group_count} issues, serialized \u2014 file overlap):"

323 )

324 step = 0

325 for widx in group:

326 for issue in waves[widx]:

327 step += 1

328 lines.append(f" Step {step}/{group_count}:")

329

330 # Truncate title if too long

331 title = issue.title

332 if len(title) > 45:

333 title = title[:42] + "..."

334

335 lines.append(

336 f" \u2514\u2500\u2500 {issue.issue_id}: {title} ({issue.priority})"

337 )

338

339 # Show blockers for this issue

340 blockers = dep_graph.blocked_by.get(issue.issue_id, set())

341 if blockers:

342 blockers_str = ", ".join(sorted(blockers))

343 lines.append(f" blocked by: {blockers_str}")

344

345 # Show contended files once at the end of the group

346 first_note = notes[group[0]]

347 if first_note:

348 paths_str = ", ".join(first_note.contended_paths[:2])

349 extra = len(first_note.contended_paths) - 2

350 if extra > 0:

351 paths_str += f" +{extra} more"

352 lines.append(f" Contended files: {paths_str}")

353 else:

354 # Single wave (no overlap splitting)

355 widx = group[0]

356 wave = waves[widx]

357

358 if logical_num == 1:

359 parallel_note = "(parallel)" if len(wave) > 1 else ""

360 else:

361 parallel_note = f"(after Wave {logical_num - 1})"

362 if len(wave) > 1:

363 parallel_note += " parallel"

364 lines.append(f"Wave {logical_num} {parallel_note}:".strip())

365

366 for i, issue in enumerate(wave):

367 is_last = i == len(wave) - 1

368 prefix = " \u2514\u2500\u2500 " if is_last else " \u251c\u2500\u2500 "

369

370 # Truncate title if too long

371 title = issue.title

372 if len(title) > 45:

373 title = title[:42] + "..."

374

375 lines.append(f"{prefix}{issue.issue_id}: {title} ({issue.priority})")

376

377 # Show blockers for this issue

378 blockers = dep_graph.blocked_by.get(issue.issue_id, set())

379 if blockers:

380 blocker_prefix = (

381 " \u2514\u2500\u2500 " if is_last else " \u2502 \u2514\u2500\u2500 "

382 )

383 blockers_str = ", ".join(sorted(blockers))

384 lines.append(f"{blocker_prefix}blocked by: {blockers_str}")

385

386 return "\n".join(lines)

387

388

389 def _render_dependency_graph(

390 waves: list[list[Any]],

391 dep_graph: DependencyGraph,

392) -> str:

393 """Render ASCII dependency graph.

394

395 Args:

396 waves: List of execution waves

397 dep_graph: DependencyGraph for looking up relationships

398

399 Returns:

400 Formatted string showing dependency arrows

401 """

402 if not waves or len(waves) <= 1:

403 return ""

404

405 # Don't render graph if there are no actual dependency edges

406 # (waves > 1 can happen from file overlap splitting alone)

407 all_ids = {issue.issue_id for wave in waves for issue in wave}

408 has_edges = any(dep_graph.blocks.get(issue_id, set()) & all_ids for issue_id in all_ids)

409 if not has_edges:

410 return ""

411

412 lines: list[str] = []

413 lines.append("")

414 lines.append("=" * 70)

415 lines.append("DEPENDENCY GRAPH")

416 lines.append("=" * 70)

417 lines.append("")

418

419 # Build chains: track which issues block what

420 # Show each independent chain on its own line

421 chains: list[str] = []

422 visited: set[str] = set()

423

424 def build_chain(issue_id: str) -> str:

425 """Recursively build chain string from issue."""

426 if issue_id in visited:

427 return issue_id

428 visited.add(issue_id)

429

430 blocked_issues = sorted(dep_graph.blocks.get(issue_id, set()))

431 if not blocked_issues:

432 return issue_id

433

434 if len(blocked_issues) == 1:

435 return f"{issue_id} \u2500\u2500\u2192 {build_chain(blocked_issues[0])}"

436 else:

437 # Multiple branches - show first inline, note others

438 result = f"{issue_id} \u2500\u2500\u2192 {build_chain(blocked_issues[0])}"

439 for other in blocked_issues[1:]:

440 if other not in visited:

441 chains.append(f" {issue_id} \u2500\u2500\u2192 {build_chain(other)}")

442 return result

443

444 # Find root issues structurally (not blocked by anything in this graph)

445 roots = [iid for iid in sorted(all_ids) if not (dep_graph.blocked_by.get(iid, set()) & all_ids)]

446

447 for root in roots:

448 if root not in visited:

449 chain = build_chain(root)

450 if chain and "──→" in chain:

451 chains.append(f" {chain}")

452

453 lines.extend(chains)

454 lines.append("")

455 lines.append("Legend: \u2500\u2500\u2192 blocks (must complete before)")

456

457 return "\n".join(lines)

458

459

460 def _render_health_summary(

461 waves: list[list[Any]],

462 contention_notes: list[WaveContentionNote | None] | None,

463 has_cycles: bool,

464 invalid: set[str],

465 dep_report: Any | None = None,

466 issue_to_wave: dict[str, int] | None = None,

467) -> str:

468 """Render a one-line sprint health summary.

469

470 Returns:

471 Health summary string like "OK -- 5 issues in 1 wave, contention serialized"

472 """

473 total_issues = sum(len(w) for w in waves)

474

475 if has_cycles:

476 return "BLOCKED -- dependency cycles detected"

477

478 if invalid:

479 return f"WARNING -- {len(invalid)} issue(s) not found on disk"

480

481 # Check for novel (unsatisfied) high-confidence proposals

482 if dep_report and dep_report.proposals and issue_to_wave is not None:

483 novel_count = 0

484 for p in dep_report.proposals:

485 target_wave = issue_to_wave.get(p.target_id)

486 source_wave = issue_to_wave.get(p.source_id)

487 if target_wave is None or source_wave is None or target_wave >= source_wave:

488 if p.confidence >= 0.5:

489 novel_count += 1

490 if novel_count > 0:

491 return f"REVIEW -- {novel_count} potential dependency(ies) to review"

492

493 # Count logical waves (group contention sub-waves)

494 notes = contention_notes or [None] * len(waves)

495 logical_count = 0

496 has_contention = False

497 prev_parent: int | None = None

498 for idx in range(len(waves)):

499 note = notes[idx] if idx < len(notes) else None

500 if note is not None:

501 has_contention = True

502 if prev_parent is None or note.parent_wave_index != prev_parent:

503 logical_count += 1

504 prev_parent = note.parent_wave_index

505 else:

506 logical_count += 1

507 prev_parent = None

508

509 wave_word = "wave" if logical_count == 1 else "waves"

510 suffix = ", overlap serialized" if has_contention else ", all parallelizable"

511 if logical_count == 1 and total_issues == 1:

512 suffix = ""

513

514 return f"OK -- {total_issues} issues in {logical_count} {wave_word}{suffix}"

515

516

517 def _cmd_sprint_show(args: argparse.Namespace, manager: SprintManager) -> int:

518 """Show sprint details with dependency visualization."""

519 logger = Logger()

520 sprint = manager.load(args.sprint)

521 if not sprint:

522 logger.error(f"Sprint not found: {args.sprint}")

523 return 1

524

525 # Validate issues

526 valid = manager.validate_issues(sprint.issues)

527 invalid = set(sprint.issues) - set(valid.keys())

528

529 # Load full IssueInfo objects for dependency analysis

530 issue_infos = manager.load_issue_infos(list(valid.keys()))

531 dep_graph: DependencyGraph | None = None

532 waves: list[list[Any]] = []

533 contention_notes: list[WaveContentionNote | None] | None = None

534 has_cycles = False

535

536 # Gather all issue IDs on disk to avoid false "nonexistent" warnings

537 from little_loops.dependency_mapper import gather_all_issue_ids

538

539 config = manager.config

540 issues_dir = config.project_root / config.issues.base_dir if config else Path(".issues")

541 all_known_ids = gather_all_issue_ids(issues_dir)

542

543 if issue_infos:

544 dep_graph = DependencyGraph.from_issues(issue_infos, all_known_ids=all_known_ids)

545 has_cycles = dep_graph.has_cycles()

546

547 if not has_cycles:

548 waves = dep_graph.get_execution_waves()

549 waves, contention_notes = refine_waves_for_contention(waves)

550

551 print(f"Sprint: {sprint.name}")

552 print(f"Description: {sprint.description or '(none)'}")

553 print(f"Created: {sprint.created}")

554

555 # Options on a single compact line right after metadata

556 if sprint.options:

557 opts = sprint.options

558 print(

559 f"Options: max_workers={opts.max_workers}, timeout={opts.timeout}s, max_iterations={opts.max_iterations}"

560 )

561

562 # Dependency analysis (ENH-301) - run before health summary so we can reference it

563 dep_report: Any = None

564 issue_to_wave: dict[str, int] = {}

565 if issue_infos and not args.skip_analysis:

566 from little_loops.dependency_mapper import analyze_dependencies

567

568 issue_contents = _build_issue_contents(issue_infos)

569 dep_report = analyze_dependencies(issue_infos, issue_contents, all_known_ids=all_known_ids)

570

571 # Build wave ordering map so we can filter already-satisfied proposals

572 for wave_idx, wave in enumerate(waves):

573 for issue in wave:

574 issue_to_wave[issue.issue_id] = wave_idx

575

576 # Sprint health summary

577 if waves:

578 health = _render_health_summary(

579 waves,

580 contention_notes,

581 has_cycles,

582 invalid,

583 dep_report=dep_report,

584 issue_to_wave=issue_to_wave if issue_to_wave else None,

585 )

586 print(f"Sprint health: {health}")

587

588 # Show execution plan if we have dependency info and no cycles

589 if waves and dep_graph:

590 print(_render_execution_plan(waves, dep_graph, contention_notes))

591 print(_render_dependency_graph(waves, dep_graph))

592 else:

593 # Fallback to simple list if no valid issues or cycles

594 print(f"Issues ({len(sprint.issues)}):")

595 for issue_id in sprint.issues:

596 status = "valid" if issue_id in valid else "NOT FOUND"

597 print(f" - {issue_id} ({status})")

598

599 # Warn about cycles if detected

600 if has_cycles and dep_graph:

601 cycles = dep_graph.detect_cycles()

602 print("\nWarning: Dependency cycles detected:")

603 for cycle in cycles:

604 print(f" {' -> '.join(cycle)}")

605

606 # Render dependency analysis output

607 if dep_report is not None:

608 _render_dependency_analysis(

609 dep_report, logger, issue_to_wave=issue_to_wave if issue_to_wave else None

610 )

611

612 if invalid:

613 print(f"\nWarning: {len(invalid)} issue(s) not found")

614

615 return 0

616

617

618 def _cmd_sprint_edit(args: argparse.Namespace, manager: SprintManager) -> int:

619 """Edit a sprint's issue list."""

620 import re

621

622 logger = Logger()

623 sprint = manager.load(args.sprint)

624 if not sprint:

625 logger.error(f"Sprint not found: {args.sprint}")

626 return 1

627

628 if not args.add and not args.remove and not args.prune and not args.revalidate:

629 logger.error("No edit flags specified. Use --add, --remove, --prune, or --revalidate.")

630 return 1

631

632 original_issues = list(sprint.issues)

633 changed = False

634

635 # --add: add new issue IDs

636 if args.add:

637 add_ids = parse_issue_ids(args.add)

638 if add_ids:

639 valid = manager.validate_issues(list(add_ids))

640 invalid = add_ids - set(valid.keys())

641 if invalid:

642 logger.warning(f"Issue IDs not found (skipping): {', '.join(sorted(invalid))}")

643

644 existing = set(sprint.issues)

645 added = []

646 for issue_id in sorted(valid.keys()):

647 if issue_id not in existing:

648 sprint.issues.append(issue_id)

649 added.append(issue_id)

650 else:

651 logger.info(f"Already in sprint: {issue_id}")

652 if added:

653 logger.success(f"Added: {', '.join(added)}")

654 changed = True

655

656 # --remove: remove issue IDs

657 if args.remove:

658 remove_ids = parse_issue_ids(args.remove)

659 if remove_ids:

660 before = len(sprint.issues)

661 sprint.issues = [i for i in sprint.issues if i not in remove_ids]

662 removed_count = before - len(sprint.issues)

663 not_found = remove_ids - set(original_issues)

664 if not_found:

665 logger.warning(f"Not in sprint: {', '.join(sorted(not_found))}")

666 if removed_count > 0:

667 logger.success(f"Removed {removed_count} issue(s)")

668 changed = True

669

670 # --prune: remove invalid and completed references

671 if args.prune:

672 valid = manager.validate_issues(sprint.issues)

673 invalid_ids = set(sprint.issues) - set(valid.keys())

674

675 # Also detect completed issues

676 completed_ids: set[str] = set()

677 if manager.config:

678 completed_dir = manager.config.get_completed_dir()

679 if completed_dir.exists():

680 for path in completed_dir.glob("*.md"):

681 match = re.search(r"(BUG|FEAT|ENH)-(\d+)", path.name)

682 if match:

683 completed_ids.add(f"{match.group(1)}-{match.group(2)}")

684

685 prune_ids = invalid_ids | (completed_ids & set(sprint.issues))

686 if prune_ids:

687 sprint.issues = [i for i in sprint.issues if i not in prune_ids]

688 pruned_invalid = invalid_ids & prune_ids

689 pruned_completed = (completed_ids & set(original_issues)) - invalid_ids

690 if pruned_invalid:

691 logger.success(f"Pruned invalid: {', '.join(sorted(pruned_invalid))}")

692 if pruned_completed:

693 logger.success(f"Pruned completed: {', '.join(sorted(pruned_completed))}")

694 changed = True

695 else:

696 logger.info("Nothing to prune — all issues are valid and active")

697

698 # Save if changed

699 if changed:

700 sprint.save(manager.sprints_dir)

701 logger.success(f"Saved {args.sprint} ({len(sprint.issues)} issues)")

702 if original_issues != sprint.issues:

703 logger.info(f" Was: {', '.join(original_issues)}")

704 logger.info(f" Now: {', '.join(sprint.issues)}")

705

706 # --revalidate: re-run dependency analysis

707 if args.revalidate:

708 valid = manager.validate_issues(sprint.issues)

709 issue_infos = manager.load_issue_infos(list(valid.keys()))

710 if issue_infos:

711 from little_loops.dependency_mapper import (

712 analyze_dependencies,

713 gather_all_issue_ids,

714 )

715

716 _config = manager.config

717 _issues_dir = (

718 _config.project_root / _config.issues.base_dir if _config else Path(".issues")

719 )

720 _all_known_ids = gather_all_issue_ids(_issues_dir)

721 issue_contents = _build_issue_contents(issue_infos)

722 dep_report = analyze_dependencies(

723 issue_infos, issue_contents, all_known_ids=_all_known_ids

724 )

725 _render_dependency_analysis(dep_report, logger)

726 else:

727 logger.info("No valid issues to analyze")

728

729 invalid = set(sprint.issues) - set(valid.keys())

730 if invalid:

731 logger.warning(f"{len(invalid)} issue(s) not found: {', '.join(sorted(invalid))}")

732

733 return 0

734

735

736 def _cmd_sprint_list(args: argparse.Namespace, manager: SprintManager) -> int:

737 """List all sprints."""

738 sprints = manager.list_all()

739

740 if not sprints:

741 print("No sprints defined")

742 return 0

743

744 print(f"Available sprints ({len(sprints)}):")

745

746 for sprint in sprints:

747 if args.verbose:

748 print(f"\n{sprint.name}:")

749 print(f" Description: {sprint.description or '(none)'}")

750 print(f" Issues: {', '.join(sprint.issues)}")

751 print(f" Created: {sprint.created}")

752 else:

753 desc = f" - {sprint.description}" if sprint.description else ""

754 print(f" {sprint.name}{desc}")

755

756 return 0

757

758

759 def _cmd_sprint_delete(args: argparse.Namespace, manager: SprintManager) -> int:

760 """Delete a sprint."""

761 logger = Logger()

762 if not manager.delete(args.sprint):

763 logger.error(f"Sprint not found: {args.sprint}")

764 return 1

765

766 logger.success(f"Deleted sprint: {args.sprint}")

767 return 0

768

769

770 def _cmd_sprint_analyze(args: argparse.Namespace, manager: SprintManager) -> int:

771 """Analyze sprint for file conflicts between issues."""

772 import json as _json

773

774 from little_loops.parallel.file_hints import FileHints, extract_file_hints

775

776 logger = Logger()

777 sprint = manager.load(args.sprint)

778 if not sprint:

779 logger.error(f"Sprint not found: {args.sprint}")

780 return 1

781

782 # Validate issues

783 valid = manager.validate_issues(sprint.issues)

784 invalid = set(sprint.issues) - set(valid.keys())

785

786 if invalid:

787 logger.warning(f"Issue IDs not found: {', '.join(sorted(invalid))}")

788

789 # Load full IssueInfo objects

790 issue_infos = manager.load_issue_infos(list(valid.keys()))

791 if not issue_infos:

792 logger.error("No valid issue files found")

793 return 1

794

795 # Gather all known IDs

796 from little_loops.dependency_mapper import gather_all_issue_ids

797

798 config = manager.config

799 issues_dir = config.project_root / config.issues.base_dir if config else Path(".issues")

800 all_known_ids = gather_all_issue_ids(issues_dir)

801

802 # Build dependency graph

803 dep_graph = DependencyGraph.from_issues(issue_infos, all_known_ids=all_known_ids)

804 has_cycles = dep_graph.has_cycles()

805

806 if has_cycles:

807 cycles = dep_graph.detect_cycles()

808 for cycle in cycles:

809 logger.error(f"Dependency cycle detected: {' -> '.join(cycle)}")

810 return 1

811

812 # Generate waves and refine for contention

813 waves = dep_graph.get_execution_waves()

814 waves, contention_notes = refine_waves_for_contention(waves)

815

816 # Extract file hints and detect pairwise conflicts

817 hints: dict[str, FileHints] = {}

818 for info in issue_infos:

819 content = info.path.read_text() if info.path.exists() else ""

820 hints[info.issue_id] = extract_file_hints(content, info.issue_id)

821

822 conflict_pairs: list[dict[str, Any]] = []

823 for i, a in enumerate(issue_infos):

824 for b in issue_infos[i + 1 :]:

825 if hints[a.issue_id].overlaps_with(hints[b.issue_id]):

826 overlapping = sorted(hints[a.issue_id].get_overlapping_paths(hints[b.issue_id]))

827 conflict_pairs.append(

828 {

829 "issue_a": a.issue_id,

830 "issue_b": b.issue_id,

831 "overlapping_files": overlapping,

832 }

833 )

834

835 # Build parallel-safe groups from waves (issues in same wave with no conflicts)

836 conflicting_ids = set()

837 for pair in conflict_pairs:

838 conflicting_ids.add(pair["issue_a"])

839 conflicting_ids.add(pair["issue_b"])

840

841 parallel_safe: list[list[str]] = []

842 notes_list = contention_notes or [None] * len(waves)

843 for idx, wave in enumerate(waves):

844 note = notes_list[idx] if idx < len(notes_list) else None

845 if note is None and len(wave) > 1:

846 # Non-contention wave with multiple issues = parallel-safe group

847 group = [issue.issue_id for issue in wave]

848 parallel_safe.append(group)

849

850 has_conflicts = len(conflict_pairs) > 0

851

852 # Build wave plan for report

853 wave_plan: list[dict[str, Any]] = []

854 for idx, wave in enumerate(waves):

855 note = notes_list[idx] if idx < len(notes_list) else None

856 wave_info: dict[str, Any] = {

857 "wave": idx + 1,

858 "issues": [issue.issue_id for issue in wave],

859 }

860 if note is not None:

861 wave_info["serialized"] = True

862 wave_info["sub_wave"] = note.sub_wave_index + 1

863 wave_info["total_sub_waves"] = note.total_sub_waves

864 wave_info["contended_paths"] = note.contended_paths

865 else:

866 wave_info["serialized"] = False

867 wave_plan.append(wave_info)

868

869 if args.format == "json":

870 data = {

871 "sprint": sprint.name,

872 "issue_count": len(issue_infos),

873 "has_conflicts": has_conflicts,

874 "conflicts": conflict_pairs,

875 "waves": wave_plan,

876 "parallel_safe_groups": parallel_safe,

877 }

878 print(_json.dumps(data, indent=2))

879 else:

880 # Text report

881 total_issues = len(issue_infos)

882 print(f"Sprint: {sprint.name}")

883 print(f"Issues: {total_issues}")

884 print()

885 print("=" * 70)

886 print("CONFLICT ANALYSIS")

887 print("=" * 70)

888

889 if not has_conflicts:

890 print()

891 print("No file conflicts detected. All issues can run in parallel.")

892 else:

893 print()

894 print(f"Conflicts found: {len(conflict_pairs)} pair(s)")

895

896 for i, pair in enumerate(conflict_pairs, 1):

897 print()

898 print(f" {i}. {pair['issue_a']} <-> {pair['issue_b']}")

899 files_str = ", ".join(pair["overlapping_files"][:3])

900 if len(pair["overlapping_files"]) > 3:

901 files_str += f" +{len(pair['overlapping_files']) - 3} more"

902 print(f" Overlapping files: {files_str}")

903 print(" Recommendation: Serialize execution")

904

905 # Execution plan

906 print()

907 print(_render_execution_plan(waves, dep_graph, contention_notes))

908

909 # Parallel-safe groups

910 if parallel_safe:

911 print()

912 print("Parallel-safe groups:")

913 for group in parallel_safe:

914 print(f" - {', '.join(group)} (no shared files)")

915

916 return 1 if has_conflicts else 0

917

918

919 def _get_sprint_state_file() -> Path:

920 """Get path to sprint state file."""

921 return Path.cwd() / ".sprint-state.json"

922

923

924 def _load_sprint_state(logger: Logger) -> SprintState | None:

925 """Load sprint state from file."""

926 import json

927

928 state_file = _get_sprint_state_file()

929 if not state_file.exists():

930 return None

931 try:

932 data = json.loads(state_file.read_text())

933 state = SprintState.from_dict(data)

934 logger.info(f"State loaded from {state_file}")

935 return state

936 except (json.JSONDecodeError, KeyError) as e:

937 logger.warning(f"Failed to load state: {e}")

938 return None

939

940

941 def _save_sprint_state(state: SprintState, logger: Logger) -> None:

942 """Save sprint state to file."""

943 import json

944 from datetime import datetime

945

946 state.last_checkpoint = datetime.now().isoformat()

947 state_file = _get_sprint_state_file()

948 state_file.write_text(json.dumps(state.to_dict(), indent=2))

949 logger.info(f"State saved to {state_file}")

950

951

952 def _cleanup_sprint_state(logger: Logger) -> None:

953 """Remove sprint state file."""

954 state_file = _get_sprint_state_file()

955 if state_file.exists():

956 state_file.unlink()

957 logger.info("Sprint state file cleaned up")

958

959

960 def _build_issue_contents(issue_infos: list) -> dict[str, str]:

961 """Build issue_id -> file content mapping for dependency analysis."""

962 return {info.issue_id: info.path.read_text() for info in issue_infos if info.path.exists()}

963

964

965 def _render_dependency_analysis(

966 report: Any,

967 logger: Logger,

968 issue_to_wave: dict[str, int] | None = None,

969) -> None:

970 """Display dependency analysis results in CLI format.

971

972 Args:

973 report: DependencyReport from analyze_dependencies()

974 logger: Logger instance

975 issue_to_wave: Optional mapping of issue_id -> wave index. When

976 provided, proposals where the target already runs before the

977 source in wave ordering are counted as "already handled".

978 """

979 if not report.proposals and not report.validation.has_issues:

980 return

981

982 logger.header("Dependency Analysis", char="-", width=60)

983

984 if report.proposals:

985 # Partition proposals into novel vs already-satisfied

986 novel: list[Any] = []

987 satisfied_count = 0

988 for p in report.proposals:

989 if issue_to_wave is not None:

990 target_wave = issue_to_wave.get(p.target_id)

991 source_wave = issue_to_wave.get(p.source_id)

992 if (

993 target_wave is not None

994 and source_wave is not None

995 and target_wave < source_wave

996 ):

997 satisfied_count += 1

998 continue

999 novel.append(p)

1000

1001 if novel:

1002 logger.warning(f"Found {len(novel)} potential missing dependency(ies):")

1003 for p in novel:

1004 if p.conflict_score >= 0.7:

1005 conflict = "HIGH"

1006 elif p.conflict_score >= 0.4:

1007 conflict = "MEDIUM"

1008 else:

1009 conflict = "LOW"

1010 logger.warning(

1011 f" {p.source_id} may depend on {p.target_id} "

1012 f"({conflict} conflict, {p.confidence:.0%} confidence)"

1013 )

1014 if p.overlapping_files:

1015 files = ", ".join(p.overlapping_files[:3])

1016 if len(p.overlapping_files) > 3:

1017 files += " and more"

1018 logger.info(f" Shared files: {files}")

1019

1020 if satisfied_count > 0:

1021 total = len(report.proposals)

1022 if not novel:

1023 dep_word = "dependency" if total == 1 else "dependencies"

1024 logger.info(f"All {total} potential {dep_word} already handled by wave ordering.")

1025 else:

1026 logger.info(f"({satisfied_count} additional already handled by wave ordering)")

1027

1028 if report.validation.has_issues:

1029 v = report.validation

1030 if v.broken_refs:

1031 for issue_id, ref_id in v.broken_refs:

1032 logger.warning(f" {issue_id}: references nonexistent {ref_id}")

1033 if v.stale_completed_refs:

1034 for issue_id, ref_id in v.stale_completed_refs:

1035 logger.warning(f" {issue_id}: blocked by {ref_id} (completed)")

1036 if v.missing_backlinks:

1037 for issue_id, ref_id in v.missing_backlinks:

1038 logger.warning(f" {issue_id} blocked by {ref_id}, but {ref_id} missing backlink")

1039

1040 logger.info("Run /ll:map-dependencies to apply discovered dependencies")

1041 print() # blank line separator

1042

1043

1044 def _cmd_sprint_run(

1045 args: argparse.Namespace,

1046 manager: SprintManager,

1047 config: BRConfig,

1048) -> int:

1049 """Execute a sprint with dependency-aware scheduling."""

1050 from datetime import datetime

1051

1052 logger = Logger(verbose=not args.quiet)

1053

1054 # Setup signal handlers for graceful shutdown (ENH-183)

1055 global _sprint_shutdown_requested

1056 _sprint_shutdown_requested = False # Reset in case of multiple runs

1057 signal.signal(signal.SIGINT, _sprint_signal_handler)

1058 signal.signal(signal.SIGTERM, _sprint_signal_handler)

1059

1060 sprint = manager.load(args.sprint)

1061 if not sprint:

1062 logger.error(f"Sprint not found: {args.sprint}")

1063 return 1

1064

1065 # Apply skip filter if provided

1066 issues_to_process = list(sprint.issues)

1067 skip_ids = parse_issue_ids(args.skip)

1068 if skip_ids:

1069 original_count = len(issues_to_process)

1070 issues_to_process = [i for i in issues_to_process if i not in skip_ids]

1071 skipped = original_count - len(issues_to_process)

1072 if skipped > 0:

1073 logger.info(f"Skipping {skipped} issue(s): {', '.join(sorted(skip_ids))}")

1074

1075 # Apply type filter if provided

1076 type_prefixes = parse_issue_types(getattr(args, "type", None))

1077 if type_prefixes:

1078 original_count = len(issues_to_process)

1079 issues_to_process = [i for i in issues_to_process if i.split("-", 1)[0] in type_prefixes]

1080 filtered = original_count - len(issues_to_process)

1081 if filtered > 0:

1082 logger.info(f"Filtered {filtered} issue(s) by type: {', '.join(sorted(type_prefixes))}")

1083

1084 # Validate issues exist

1085 valid = manager.validate_issues(issues_to_process)

1086 invalid = set(issues_to_process) - set(valid.keys())

1087

1088 if invalid:

1089 logger.error(f"Issue IDs not found: {', '.join(sorted(invalid))}")

1090 logger.info("Cannot execute sprint with missing issues")

1091 return 1

1092

1093 # Load full IssueInfo objects for dependency analysis

1094 issue_infos = manager.load_issue_infos(issues_to_process)

1095 if not issue_infos:

1096 logger.error("No issue files found")

1097 return 1

1098

1099 # Gather all issue IDs on disk to avoid false "nonexistent" warnings

1100 from little_loops.dependency_mapper import gather_all_issue_ids

1101

1102 issues_dir = config.project_root / config.issues.base_dir

1103 all_known_ids = gather_all_issue_ids(issues_dir)

1104

1105 # Dependency analysis (ENH-301)

1106 if not getattr(args, "skip_analysis", False):

1107 from little_loops.dependency_mapper import analyze_dependencies

1108

1109 issue_contents = _build_issue_contents(issue_infos)

1110 dep_report = analyze_dependencies(issue_infos, issue_contents, all_known_ids=all_known_ids)

1111 _render_dependency_analysis(dep_report, logger)

1112

1113 # Build dependency graph

1114 dep_graph = DependencyGraph.from_issues(issue_infos, all_known_ids=all_known_ids)

1115

1116 # Detect cycles

1117 if dep_graph.has_cycles():

1118 cycles = dep_graph.detect_cycles()

1119 for cycle in cycles:

1120 logger.error(f"Dependency cycle detected: {' -> '.join(cycle)}")

1121 return 1

1122

1123 # Get execution waves

1124 try:

1125 waves = dep_graph.get_execution_waves()

1126 except ValueError as e:

1127 logger.error(str(e))

1128 return 1

1129

1130 # Refine waves for file overlap (ENH-306)

1131 waves, contention_notes = refine_waves_for_contention(waves)

1132

1133 # Display execution plan

1134 logger.info(f"Running sprint: {sprint.name}")

1135 logger.info("Dependency analysis:")

1136 for i, wave in enumerate(waves, 1):

1137 issue_ids = ", ".join(issue.issue_id for issue in wave)

1138 note = contention_notes[i - 1] if contention_notes else None

1139 if note:

1140 logger.info(

1141 f" Wave {i}: {issue_ids}"

1142 f" [sub-wave {note.sub_wave_index + 1}/{note.total_sub_waves}]"

1143 )

1144 else:

1145 logger.info(f" Wave {i}: {issue_ids}")

1146

1147 if args.dry_run:

1148 logger.info("\nDry run mode - no changes will be made")

1149 return 0

1150

1151 # Initialize or load state

1152 state: SprintState

1153 start_wave = 1

1154

1155 if args.resume:

1156 loaded_state = _load_sprint_state(logger)

1157 if loaded_state and loaded_state.sprint_name == args.sprint:

1158 state = loaded_state

1159 # Find first incomplete wave by checking completed issues

1160 completed_set = set(state.completed_issues)

1161 for i, wave in enumerate(waves, 1):

1162 wave_issue_ids = {issue.issue_id for issue in wave}

1163 if not wave_issue_ids.issubset(completed_set):

1164 start_wave = i

1165 break

1166 else:

1167 # All waves completed

1168 logger.info("Sprint already completed - nothing to resume")

1169 _cleanup_sprint_state(logger)

1170 return 0

1171 logger.info(f"Resuming from wave {start_wave}/{len(waves)}")

1172 logger.info(f" Previously completed: {len(state.completed_issues)} issues")

1173 else:

1174 if loaded_state:

1175 logger.warning(

1176 f"State file is for sprint '{loaded_state.sprint_name}', "

1177 f"not '{args.sprint}' - starting fresh"

1178 )

1179 else:

1180 logger.warning("No valid state found - starting fresh")

1181 state = SprintState(

1182 sprint_name=args.sprint,

1183 started_at=datetime.now().isoformat(),

1184 )

1185 else:

1186 # Fresh start - delete any old state

1187 _cleanup_sprint_state(logger)

1188 state = SprintState(

1189 sprint_name=args.sprint,

1190 started_at=datetime.now().isoformat(),

1191 )

1192

1193 # Track exit status for error handling (ENH-185)

1194 exit_code = 0

1195

1196 try:

1197 # Determine max workers

1198 max_workers = args.max_workers or (sprint.options.max_workers if sprint.options else 2)

1199

1200 # Execute wave by wave

1201 completed: set[str] = set(state.completed_issues)

1202 failed_waves = 0

1203 total_duration = 0.0

1204 total_waves = len(waves)

1205

1206 for wave_num, wave in enumerate(waves, 1):

1207 # Check for shutdown request (ENH-183)

1208 if _sprint_shutdown_requested:

1209 logger.warning("Shutdown requested - saving state and exiting")

1210 _save_sprint_state(state, logger)

1211 exit_code = 1

1212 return exit_code

1213

1214 # Skip already-completed waves when resuming

1215 if wave_num < start_wave:

1216 continue

1217

1218 wave_ids = [issue.issue_id for issue in wave]

1219 state.current_wave = wave_num

1220 logger.info(f"\nProcessing wave {wave_num}/{total_waves}: {', '.join(wave_ids)}")

1221

1222 if len(wave) == 1:

1223 # Single issue — process in-place (no worktree overhead)

1224 from little_loops.issue_manager import process_issue_inplace

1225

1226 issue_result = process_issue_inplace(

1227 info=wave[0],

1228 config=config,

1229 logger=logger,

1230 dry_run=args.dry_run,

1231 )

1232 total_duration += issue_result.duration

1233 if issue_result.success:

1234 completed.update(wave_ids)

1235 state.completed_issues.extend(wave_ids)

1236 state.timing[wave_ids[0]] = {"total": issue_result.duration}

1237 logger.success(f"Wave {wave_num}/{total_waves} completed: {wave_ids[0]}")

1238 else:

1239 failed_waves += 1

1240 completed.update(wave_ids)

1241 state.completed_issues.extend(wave_ids)

1242 state.failed_issues[wave_ids[0]] = "Issue processing failed"

1243 logger.warning(f"Wave {wave_num}/{total_waves} had failures")

1244 _save_sprint_state(state, logger)

1245 if wave_num < total_waves:

1246 logger.info(f"Continuing to wave {wave_num + 1}/{total_waves}...")

1247 # Check for shutdown before next wave (ENH-183)

1248 if _sprint_shutdown_requested:

1249 logger.warning("Shutdown requested - exiting after wave completion")

1250 exit_code = 1

1251 return exit_code

1252 else:

1253 # Multi-issue — use ParallelOrchestrator with worktrees

1254 only_ids = set(wave_ids)

1255 parallel_config = config.create_parallel_config(

1256 max_workers=min(max_workers, len(wave)),

1257 only_ids=only_ids,

1258 dry_run=args.dry_run,

1259 overlap_detection=True,

1260 serialize_overlapping=True,

1261 )

1262

1263 orchestrator = ParallelOrchestrator(

1264 parallel_config, config, Path.cwd(), wave_label=f"Wave {wave_num}/{total_waves}"

1265 )

1266 result = orchestrator.run()

1267 total_duration += orchestrator.execution_duration

1268

1269 # Track completed/failed from this wave using per-issue results

1270 actually_completed = set(orchestrator.queue.completed_ids)

1271 actually_failed = set(orchestrator.queue.failed_ids)

1272

1273 for issue_id in wave_ids:

1274 if issue_id in actually_completed:

1275 completed.add(issue_id)

1276 state.completed_issues.append(issue_id)

1277 state.timing[issue_id] = {

1278 "total": orchestrator.execution_duration / len(wave)

1279 }

1280 elif issue_id in actually_failed:

1281 completed.add(issue_id)

1282 state.completed_issues.append(issue_id)

1283 state.failed_issues[issue_id] = "Issue failed during wave execution"

1284 # else: issue was neither completed nor failed (interrupted/stranded)

1285 # — leave untracked so it can be retried on resume

1286

1287 # Sequential retry for failed issues (ENH-308)

1288 if actually_failed:

1289 logger.info(f"Retrying {len(actually_failed)} failed issue(s) sequentially...")

1290 from little_loops.issue_manager import process_issue_inplace

1291

1292 retried_ok = 0

1293 for issue in wave:

1294 if issue.issue_id not in actually_failed:

1295 continue

1296 logger.info(f" Retrying {issue.issue_id} in-place...")

1297 retry_result = process_issue_inplace(

1298 info=issue,

1299 config=config,

1300 logger=logger,

1301 dry_run=args.dry_run,

1302 )

1303 total_duration += retry_result.duration

1304 if retry_result.success:

1305 retried_ok += 1

1306 state.failed_issues.pop(issue.issue_id, None)

1307 state.timing[issue.issue_id] = {"total": retry_result.duration}

1308 logger.success(f" Retry succeeded: {issue.issue_id}")

1309 else:

1310 logger.warning(f" Retry failed: {issue.issue_id}")

1311 if retried_ok > 0:

1312 logger.info(

1313 f"Sequential retry recovered {retried_ok}/{len(actually_failed)} issue(s)"

1314 )

1315

1316 # Check whether failures remain after retry (ENH-308)

1317 remaining_failures = {iid for iid in actually_failed if iid in state.failed_issues}

1318 if result == 0 or not remaining_failures:

1319 logger.success(

1320 f"Wave {wave_num}/{total_waves} completed: {', '.join(wave_ids)}"

1321 )

1322 else:

1323 failed_waves += 1

1324 logger.warning(f"Wave {wave_num}/{total_waves} had failures")

1325 _save_sprint_state(state, logger)

1326 if wave_num < total_waves:

1327 logger.info(f"Continuing to wave {wave_num + 1}/{total_waves}...")

1328 # Check for shutdown before next wave (ENH-183)

1329 if _sprint_shutdown_requested:

1330 logger.warning("Shutdown requested - exiting after wave completion")

1331 exit_code = 1

1332 return exit_code

1333

1334 wave_word = "wave" if len(waves) == 1 else "waves"

1335 logger.info(

1336 f"\nSprint completed: {len(completed)} issues processed ({len(waves)} {wave_word})"

1337 )

1338 logger.timing(f"Total execution time: {format_duration(total_duration)}")

1339 if failed_waves > 0:

1340 logger.warning(f"{failed_waves} wave(s) had failures")

1341 exit_code = 1

1342 else:

1343 # Clean up state on successful completion

1344 _cleanup_sprint_state(logger)

1345

1346 except KeyboardInterrupt:

1347 # Belt-and-suspenders with signal handler (ENH-185)

1348 logger.warning("Sprint interrupted by user (KeyboardInterrupt)")

1349 exit_code = 130

1350

1351 except Exception as e:

1352 # Catch unexpected exceptions (ENH-185)

1353 logger.error(f"Sprint failed unexpectedly: {e}")

1354 exit_code = 1

1355

1356 finally:

1357 # Guaranteed state save on any non-success exit (ENH-185)

1358 if exit_code != 0:

1359 _save_sprint_state(state, logger)

1360 logger.info("State saved before exit")

1361

1362 return exit_code