Coverage for little_loops / cli / sprint / run.py: 7%

292 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-04-11 23:20 -0500

1"""ll-sprint run subcommand with signal handling and state management.""" 

2 

3from __future__ import annotations 

4 

5import os 

6import signal 

7import subprocess 

8import sys 

9from pathlib import Path 

10from types import FrameType 

11from typing import TYPE_CHECKING 

12 

13from little_loops.cli.sprint._helpers import _build_issue_contents, _render_dependency_analysis 

14from little_loops.cli_args import _id_matches, parse_issue_ids, parse_issue_types 

15from little_loops.dependency_graph import DependencyGraph, refine_waves_for_contention 

16from little_loops.logger import Logger, format_duration 

17from little_loops.parallel.orchestrator import ParallelOrchestrator 

18from little_loops.sprint import SprintManager, SprintState 

19 

20if TYPE_CHECKING: 

21 import argparse 

22 

23 from little_loops.config import BRConfig 

24 

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

26_sprint_shutdown_requested: bool = False 

27 

28 

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

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

31 

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

33 Second signal: Force immediate exit. 

34 """ 

35 global _sprint_shutdown_requested 

36 if _sprint_shutdown_requested: 

37 # Second signal - force exit 

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

39 sys.exit(1) 

40 _sprint_shutdown_requested = True 

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

42 

43 

44def _get_sprint_state_file() -> Path: 

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

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

47 

48 

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

50 """Load sprint state from file.""" 

51 import json 

52 

53 state_file = _get_sprint_state_file() 

54 if not state_file.exists(): 

55 return None 

56 try: 

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

58 state = SprintState.from_dict(data) 

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

60 return state 

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

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

63 return None 

64 

65 

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

67 """Save sprint state to file.""" 

68 import json 

69 from datetime import datetime 

70 

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

72 state_file = _get_sprint_state_file() 

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

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

75 

76 

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

78 """Remove sprint state file.""" 

79 state_file = _get_sprint_state_file() 

80 if state_file.exists(): 

81 state_file.unlink() 

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

83 

84 

85def _cmd_sprint_run( 

86 args: argparse.Namespace, 

87 manager: SprintManager, 

88 config: BRConfig, 

89) -> int: 

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

91 from datetime import datetime 

92 

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

94 

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

96 global _sprint_shutdown_requested 

97 _sprint_shutdown_requested = False # Reset in case of multiple runs 

98 signal.signal(signal.SIGINT, _sprint_signal_handler) 

99 signal.signal(signal.SIGTERM, _sprint_signal_handler) 

100 

101 handoff_threshold = getattr(args, "handoff_threshold", None) 

102 if handoff_threshold is not None: 

103 os.environ["LL_HANDOFF_THRESHOLD"] = str(handoff_threshold) 

104 

105 context_limit = getattr(args, "context_limit", None) 

106 if context_limit is not None: 

107 os.environ["LL_CONTEXT_LIMIT"] = str(context_limit) 

108 

109 sprint = manager.load(args.sprint) 

110 if not sprint: 

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

112 return 1 

113 

114 # Apply skip filter if provided 

115 issues_to_process = list(sprint.issues) 

116 skip_ids = parse_issue_ids(args.skip) 

117 if skip_ids: 

118 original_count = len(issues_to_process) 

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

120 skipped = original_count - len(issues_to_process) 

121 if skipped > 0: 

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

123 

124 # Apply only filter if provided 

125 only_ids = parse_issue_ids(getattr(args, "only", None)) 

126 if only_ids: 

127 invalid_only = {p for p in only_ids if not any(_id_matches(i, p) for i in sprint.issues)} 

128 if invalid_only: 

129 logger.error( 

130 f"Issue(s) not found in sprint definition: {', '.join(sorted(invalid_only))}" 

131 ) 

132 return 1 

133 issues_to_process = [ 

134 i for i in issues_to_process if any(_id_matches(i, p) for p in only_ids) 

135 ] 

136 logger.info( 

137 f"Processing only {len(issues_to_process)} issue(s): {', '.join(sorted(only_ids))}" 

138 ) 

139 

140 # Apply type filter if provided 

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

142 if type_prefixes: 

143 original_count = len(issues_to_process) 

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

145 filtered = original_count - len(issues_to_process) 

146 if filtered > 0: 

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

148 

149 # Pre-validate: skip issues already moved to completed/ (ENH-581) 

150 pre_completed_skipped: list[str] = [] 

151 if config is not None: 

152 completed_dir = config.get_completed_dir() 

153 if completed_dir.exists(): 

154 still_active: list[str] = [] 

155 for issue_id in issues_to_process: 

156 if list(completed_dir.glob(f"*-{issue_id}-*.md")): 

157 logger.info(f" {issue_id}: already in completed/, skipping") 

158 pre_completed_skipped.append(issue_id) 

159 else: 

160 still_active.append(issue_id) 

161 if pre_completed_skipped: 

162 logger.info( 

163 f"Pre-validation: {len(pre_completed_skipped)} issue(s) already completed, " 

164 f"{len(still_active)} active" 

165 ) 

166 issues_to_process = still_active 

167 

168 if pre_completed_skipped and not issues_to_process: 

169 logger.info("All sprint issues already completed - nothing to process") 

170 return 0 

171 

172 # Validate issues exist 

173 valid = manager.validate_issues(issues_to_process) 

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

175 

176 if invalid: 

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

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

179 return 1 

180 

181 # Load full IssueInfo objects for dependency analysis 

182 issue_infos = manager.load_issue_infos(issues_to_process) 

183 if not issue_infos: 

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

185 return 1 

186 

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

188 from little_loops.dependency_mapper import gather_all_issue_ids 

189 

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

191 all_known_ids = gather_all_issue_ids(issues_dir, config=config) 

192 

193 # Dependency analysis (ENH-301) 

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

195 from little_loops.dependency_mapper import analyze_dependencies 

196 

197 issue_contents = _build_issue_contents(issue_infos) 

198 dep_config = config.dependency_mapping 

199 dep_report = analyze_dependencies( 

200 issue_infos, issue_contents, all_known_ids=all_known_ids, config=dep_config 

201 ) 

202 _render_dependency_analysis(dep_report, logger, config=dep_config) 

203 

204 # Build dependency graph 

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

206 

207 # Detect cycles 

208 if dep_graph.has_cycles(): 

209 cycles = dep_graph.detect_cycles() 

210 for cycle in cycles: 

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

212 return 1 

213 

214 # Get execution waves 

215 try: 

216 waves = dep_graph.get_execution_waves() 

217 except ValueError as e: 

218 logger.error(str(e)) 

219 return 1 

220 

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

222 waves, contention_notes = refine_waves_for_contention(waves, config=config.dependency_mapping) 

223 

224 # Display execution plan 

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

226 logger.info("Dependency analysis:") 

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

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

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

230 if note: 

231 logger.info( 

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

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

234 ) 

235 else: 

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

237 

238 if args.dry_run: 

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

240 return 0 

241 

242 # Initialize or load state 

243 state: SprintState 

244 start_wave = 1 

245 

246 if args.resume: 

247 loaded_state = _load_sprint_state(logger) 

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

249 state = loaded_state 

250 # Find first incomplete wave by checking completed issues 

251 completed_set = set(state.completed_issues) 

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

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

254 if not wave_issue_ids.issubset(completed_set): 

255 start_wave = i 

256 break 

257 else: 

258 # All waves completed 

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

260 _cleanup_sprint_state(logger) 

261 return 0 

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

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

264 else: 

265 if loaded_state: 

266 logger.warning( 

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

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

269 ) 

270 else: 

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

272 state = SprintState( 

273 sprint_name=args.sprint, 

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

275 ) 

276 else: 

277 # Fresh start - delete any old state 

278 _cleanup_sprint_state(logger) 

279 state = SprintState( 

280 sprint_name=args.sprint, 

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

282 ) 

283 

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

285 exit_code = 0 

286 

287 try: 

288 # Determine max workers 

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

290 

291 # Execute wave by wave 

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

293 failed_waves = 0 

294 total_duration = 0.0 

295 total_waves = len(waves) 

296 

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

298 # Check for shutdown request (ENH-183) 

299 if _sprint_shutdown_requested: 

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

301 _save_sprint_state(state, logger) 

302 exit_code = 1 

303 return exit_code 

304 

305 # Skip already-completed waves when resuming 

306 if wave_num < start_wave: 

307 continue 

308 

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

310 state.current_wave = wave_num 

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

312 

313 wave_note = ( 

314 contention_notes[wave_num - 1] 

315 if contention_notes and wave_num - 1 < len(contention_notes) 

316 else None 

317 ) 

318 is_contention_subwave = wave_note is not None 

319 

320 if len(wave) == 1 or is_contention_subwave: 

321 # Single issue OR contention sub-wave — process in-place sequentially 

322 # (contention sub-waves are displayed as "serialized steps" so must run that way) 

323 from little_loops.issue_manager import process_issue_inplace 

324 

325 wave_failed = False 

326 for issue in wave: 

327 issue_result = process_issue_inplace( 

328 info=issue, 

329 config=config, 

330 logger=logger, 

331 dry_run=args.dry_run, 

332 ) 

333 total_duration += issue_result.duration 

334 if issue_result.success: 

335 completed.add(issue.issue_id) 

336 state.completed_issues.append(issue.issue_id) 

337 state.timing[issue.issue_id] = {"total": issue_result.duration} 

338 logger.success(f" {issue.issue_id}: completed") 

339 elif issue_result.was_blocked: 

340 completed.add(issue.issue_id) 

341 state.skipped_blocked_issues[issue.issue_id] = issue_result.failure_reason 

342 logger.warning(f" {issue.issue_id}: skipped (blocked by open dependency)") 

343 else: 

344 wave_failed = True 

345 completed.add(issue.issue_id) 

346 state.failed_issues[issue.issue_id] = "Issue processing failed" 

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

348 if wave_failed: 

349 failed_waves += 1 

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

351 else: 

352 logger.success( 

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

354 ) 

355 _save_sprint_state(state, logger) 

356 if wave_num < total_waves: 

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

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

359 if _sprint_shutdown_requested: 

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

361 exit_code = 1 

362 return exit_code 

363 else: 

364 # Multi-issue — use ParallelOrchestrator with worktrees 

365 only_ids = set(wave_ids) 

366 # Detect current branch for rebase/merge operations (BUG-439) 

367 _br = subprocess.run( 

368 ["git", "rev-parse", "--abbrev-ref", "HEAD"], 

369 capture_output=True, 

370 text=True, 

371 cwd=Path.cwd(), 

372 ) 

373 _base_branch = _br.stdout.strip() if _br.returncode == 0 else "main" 

374 # Runtime overlap detection disabled for sprints (ENH-512): 

375 # refine_waves_for_contention() already splits overlapping 

376 # issues into separate sub-waves before dispatch. 

377 parallel_config = config.create_parallel_config( 

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

379 only_ids=only_ids, 

380 dry_run=args.dry_run, 

381 overlap_detection=False, 

382 serialize_overlapping=True, 

383 base_branch=_base_branch, 

384 clean_start=True, # Sprint manages its own state; don't load stale orchestrator state 

385 ) 

386 

387 from little_loops.events import EventBus 

388 from little_loops.extension import wire_extensions 

389 

390 event_bus = EventBus() 

391 wire_extensions(event_bus, config.extensions) 

392 orchestrator = ParallelOrchestrator( 

393 parallel_config, 

394 config, 

395 Path.cwd(), 

396 wave_label=f"Wave {wave_num}/{total_waves}", 

397 event_bus=event_bus, 

398 ) 

399 result = orchestrator.run() 

400 total_duration += orchestrator.execution_duration 

401 

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

403 actually_completed = set(orchestrator.queue.completed_ids) 

404 actually_failed = set(orchestrator.queue.failed_ids) 

405 

406 for issue_id in wave_ids: 

407 if issue_id in actually_completed: 

408 completed.add(issue_id) 

409 state.completed_issues.append(issue_id) 

410 state.timing[issue_id] = { 

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

412 } 

413 elif issue_id in actually_failed: 

414 completed.add(issue_id) 

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

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

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

418 

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

420 if actually_failed: 

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

422 from little_loops.issue_manager import process_issue_inplace 

423 

424 retried_ok = 0 

425 for issue in wave: 

426 if issue.issue_id not in actually_failed: 

427 continue 

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

429 retry_result = process_issue_inplace( 

430 info=issue, 

431 config=config, 

432 logger=logger, 

433 dry_run=args.dry_run, 

434 ) 

435 total_duration += retry_result.duration 

436 if retry_result.success: 

437 retried_ok += 1 

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

439 state.completed_issues.append(issue.issue_id) 

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

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

442 elif retry_result.was_blocked: 

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

444 state.skipped_blocked_issues[issue.issue_id] = ( 

445 retry_result.failure_reason 

446 ) 

447 logger.warning( 

448 f" Retry skipped: {issue.issue_id} (blocked by open dependency)" 

449 ) 

450 else: 

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

452 if retried_ok > 0: 

453 logger.info( 

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

455 ) 

456 

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

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

459 if result == 0 or not remaining_failures: 

460 logger.success( 

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

462 ) 

463 else: 

464 failed_waves += 1 

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

466 _save_sprint_state(state, logger) 

467 if wave_num < total_waves: 

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

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

470 if _sprint_shutdown_requested: 

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

472 exit_code = 1 

473 return exit_code 

474 

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

476 skip_msg = ( 

477 f", {len(pre_completed_skipped)} already completed (skipped)" 

478 if pre_completed_skipped 

479 else "" 

480 ) 

481 blocked_msg = ( 

482 f", {len(state.skipped_blocked_issues)} skipped (blocked)" 

483 if state.skipped_blocked_issues 

484 else "" 

485 ) 

486 logger.info( 

487 f"\nSprint completed: {len(completed)} issues processed " 

488 f"({len(waves)} {wave_word}){skip_msg}{blocked_msg}" 

489 ) 

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

491 if failed_waves > 0: 

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

493 exit_code = 1 

494 else: 

495 # Clean up state on successful completion 

496 _cleanup_sprint_state(logger) 

497 

498 except KeyboardInterrupt: 

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

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

501 exit_code = 130 

502 

503 except Exception as e: 

504 # Catch unexpected exceptions (ENH-185) 

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

506 exit_code = 1 

507 

508 finally: 

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

510 if exit_code != 0: 

511 _save_sprint_state(state, logger) 

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

513 

514 return exit_code