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

783 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-02-15 15:23 -0600

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

2 

3from __future__ import annotations 

4 

5import argparse 

6import signal 

7import sys 

8from pathlib import Path 

9from types import FrameType 

10from typing import Any 

11 

12from 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) 

25from little_loops.config import BRConfig 

26from little_loops.dependency_graph import ( 

27 DependencyGraph, 

28 WaveContentionNote, 

29 refine_waves_for_contention, 

30) 

31from little_loops.logger import Logger, format_duration 

32from little_loops.parallel.orchestrator import ParallelOrchestrator 

33from 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 

39def _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 

54def 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=""" 

67Examples: 

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 

209def _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 

265def _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 

389def _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 

460def _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 

517def _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 

618def _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 

736def _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 

759def _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 

770def _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 

919def _get_sprint_state_file() -> Path: 

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

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

922 

923 

924def _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 

941def _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 

952def _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 

960def _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 

965def _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 

1044def _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