Coverage for little_loops / issue_manager.py: 13%

382 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-03-18 16:18 -0500

1"""Automated issue management for little-loops. 

2 

3Provides the AutoManager class for sequential issue processing with 

4Claude CLI integration and state persistence for resume capability. 

5""" 

6 

7from __future__ import annotations 

8 

9import signal 

10import subprocess 

11import sys 

12import time 

13from collections.abc import Generator 

14from contextlib import contextmanager 

15from dataclasses import dataclass, field 

16from datetime import datetime 

17from pathlib import Path 

18from types import FrameType 

19 

20from little_loops.config import BRConfig 

21from little_loops.dependency_graph import DependencyGraph 

22from little_loops.git_operations import check_git_status, verify_work_was_done 

23from little_loops.issue_lifecycle import ( 

24 FailureType, 

25 classify_failure, 

26 close_issue, 

27 complete_issue_lifecycle, 

28 create_issue_from_failure, 

29 verify_issue_completed, 

30) 

31from little_loops.issue_parser import IssueInfo, IssueParser, find_issues 

32from little_loops.logger import Logger, format_duration 

33from little_loops.output_parsing import parse_ready_issue_output 

34from little_loops.state import ProcessingState, StateManager 

35from little_loops.subprocess_utils import ( 

36 detect_context_handoff, 

37 read_continuation_prompt, 

38) 

39from little_loops.subprocess_utils import ( 

40 run_claude_command as _run_claude_base, 

41) 

42 

43 

44def _compute_relative_path(abs_path: Path, base_dir: Path | None = None) -> str: 

45 """Compute relative path from base directory for command input. 

46 

47 Used for fallback retry when ready-issue resolves to wrong file - 

48 allows retrying with explicit file path instead of ambiguous ID. 

49 

50 Args: 

51 abs_path: Absolute path to the file 

52 base_dir: Base directory (defaults to cwd) 

53 

54 Returns: 

55 Relative path string suitable for ready-issue command 

56 """ 

57 base = base_dir or Path.cwd() 

58 try: 

59 return str(abs_path.relative_to(base)) 

60 except ValueError: 

61 # Path not relative to base, use absolute 

62 return str(abs_path) 

63 

64 

65@contextmanager 

66def timed_phase( 

67 logger: Logger, 

68 phase_name: str, 

69) -> Generator[dict[str, float], None, None]: 

70 """Context manager for timing phases. 

71 

72 Yields a dict that will be populated with 'elapsed' after the context exits. 

73 

74 Args: 

75 logger: Logger for output 

76 phase_name: Name of the phase being timed 

77 

78 Yields: 

79 Dict that will contain 'elapsed' key after context exits 

80 """ 

81 timing_result: dict[str, float] = {} 

82 start = time.time() 

83 try: 

84 yield timing_result 

85 finally: 

86 elapsed = time.time() - start 

87 timing_result["elapsed"] = elapsed 

88 logger.timing(f"{phase_name} completed in {format_duration(elapsed)}") 

89 

90 

91def run_claude_command( 

92 command: str, 

93 logger: Logger, 

94 timeout: int = 3600, 

95 stream_output: bool = True, 

96 idle_timeout: int = 0, 

97) -> subprocess.CompletedProcess[str]: 

98 """Invoke Claude CLI command with real-time output streaming. 

99 

100 Args: 

101 command: Command to pass to Claude CLI 

102 logger: Logger for output 

103 timeout: Timeout in seconds 

104 stream_output: Whether to stream output to console 

105 idle_timeout: Kill process if no output for this many seconds (0 to disable) 

106 

107 Returns: 

108 CompletedProcess with stdout/stderr captured 

109 """ 

110 logger.info(f"Running: claude --dangerously-skip-permissions -p {command!r}") 

111 

112 def stream_callback(line: str, is_stderr: bool) -> None: 

113 if stream_output: 

114 if is_stderr: 

115 print(f" {line}", file=sys.stderr) 

116 else: 

117 print(f" {line}") 

118 

119 return _run_claude_base( 

120 command=command, 

121 timeout=timeout, 

122 stream_callback=stream_callback if stream_output else None, 

123 idle_timeout=idle_timeout, 

124 ) 

125 

126 

127def run_with_continuation( 

128 initial_command: str, 

129 logger: Logger, 

130 timeout: int = 3600, 

131 stream_output: bool = True, 

132 max_continuations: int = 3, 

133 repo_path: Path | None = None, 

134 idle_timeout: int = 0, 

135) -> subprocess.CompletedProcess[str]: 

136 """Run a Claude command with automatic continuation on context handoff. 

137 

138 If the command signals CONTEXT_HANDOFF, reads the continuation prompt 

139 and spawns a fresh Claude session to continue the work. 

140 

141 Args: 

142 initial_command: Initial command to run 

143 logger: Logger for output 

144 timeout: Timeout per session in seconds 

145 stream_output: Whether to stream output 

146 max_continuations: Maximum number of continuation attempts 

147 repo_path: Repository root path 

148 idle_timeout: Kill process if no output for this many seconds (0 to disable) 

149 

150 Returns: 

151 Final CompletedProcess result 

152 """ 

153 all_stdout: list[str] = [] 

154 all_stderr: list[str] = [] 

155 current_command = initial_command 

156 continuation_count = 0 

157 result: subprocess.CompletedProcess[str] = subprocess.CompletedProcess( 

158 args=[], returncode=1, stdout="", stderr="" 

159 ) 

160 

161 while continuation_count <= max_continuations: 

162 result = run_claude_command( 

163 current_command, 

164 logger, 

165 timeout=timeout, 

166 stream_output=stream_output, 

167 idle_timeout=idle_timeout, 

168 ) 

169 

170 all_stdout.append(result.stdout) 

171 all_stderr.append(result.stderr) 

172 

173 # Check for context handoff signal 

174 if detect_context_handoff(result.stdout): 

175 logger.info("Detected CONTEXT_HANDOFF signal") 

176 

177 # Read continuation prompt 

178 prompt_content = read_continuation_prompt(repo_path) 

179 if not prompt_content: 

180 logger.warning("Context handoff signaled but no continuation prompt found") 

181 break 

182 

183 if continuation_count >= max_continuations: 

184 logger.warning(f"Reached max continuations ({max_continuations}), stopping") 

185 break 

186 

187 continuation_count += 1 

188 logger.info(f"Starting continuation session #{continuation_count}") 

189 

190 # Re-invoke the original command with --resume flag so the skill 

191 # lifecycle (including completion/file-move) runs in the new session. 

192 # The skill reads .claude/ll-continue-prompt.md for context. 

193 current_command = f"{initial_command} --resume" 

194 continue 

195 

196 # No handoff signal, we're done 

197 break 

198 

199 return subprocess.CompletedProcess( 

200 args=result.args, 

201 returncode=result.returncode, 

202 stdout="\n---CONTINUATION---\n".join(all_stdout), 

203 stderr="\n---CONTINUATION---\n".join(all_stderr), 

204 ) 

205 

206 

207def detect_plan_creation(output: str, issue_id: str) -> Path | None: 

208 """Detect if manage-issue created a plan file awaiting approval. 

209 

210 Checks for plan file creation in thoughts/shared/plans/ matching the issue ID. 

211 This happens when manage-issue creates a plan but waits for user approval. 

212 

213 Args: 

214 output: Command stdout (unused, for future pattern matching) 

215 issue_id: Issue ID (e.g., "BUG-280") 

216 

217 Returns: 

218 Path to plan file if created, None otherwise 

219 """ 

220 plans_dir = Path("thoughts/shared/plans") 

221 if not plans_dir.exists(): 

222 return None 

223 

224 # Find plan files matching this issue ID (format: YYYY-MM-DD-ISSUE-ID-*.md) 

225 # Use glob pattern with issue_id 

226 pattern = f"*-{issue_id}-*.md" 

227 matching_plans = list(plans_dir.glob(pattern)) 

228 

229 if not matching_plans: 

230 return None 

231 

232 # Return the most recently modified plan file 

233 # (in case multiple exist, take the latest) 

234 latest_plan = max(matching_plans, key=lambda p: p.stat().st_mtime) 

235 return latest_plan 

236 

237 

238def check_content_markers(issue_path: Path) -> bool: 

239 """Check if issue file content contains implementation markers. 

240 

241 Looks for indicators that an implementation was completed, such as 

242 Resolution sections or status markers added by manage-issue. 

243 

244 Args: 

245 issue_path: Path to the issue file 

246 

247 Returns: 

248 True if implementation markers found 

249 """ 

250 try: 

251 content = issue_path.read_text(encoding="utf-8") 

252 except (OSError, UnicodeDecodeError): 

253 return False 

254 

255 markers = [ 

256 "## Resolution", 

257 "Status: Implemented", 

258 "Status: Completed", 

259 "**Completed**:", 

260 ] 

261 return any(marker in content for marker in markers) 

262 

263 

264@dataclass 

265class IssueProcessingResult: 

266 """Result of processing a single issue in-place.""" 

267 

268 success: bool 

269 duration: float 

270 issue_id: str 

271 was_closed: bool = False 

272 was_blocked: bool = False 

273 failure_reason: str = "" 

274 corrections: list[str] = field(default_factory=list) 

275 plan_created: bool = False 

276 plan_path: str = "" 

277 

278 

279def process_issue_inplace( 

280 info: IssueInfo, 

281 config: BRConfig, 

282 logger: Logger, 

283 dry_run: bool = False, 

284) -> IssueProcessingResult: 

285 """Process a single issue through the 3-phase workflow in the current working tree. 

286 

287 This is the core processing logic extracted from AutoManager._process_issue(), 

288 suitable for use outside of AutoManager (e.g., single-issue sprint waves). 

289 

290 Args: 

291 info: Issue information 

292 config: Project configuration 

293 logger: Logger for output 

294 dry_run: If True, only preview what would be done 

295 

296 Returns: 

297 IssueProcessingResult with outcome details 

298 """ 

299 issue_start_time = time.time() 

300 corrections: list[str] = [] 

301 

302 logger.header(f"Processing: {info.issue_id} - {info.title}") 

303 

304 issue_timing: dict[str, float] = {} 

305 

306 # Track whether we used fallback path resolution for ready-issue. 

307 validated_via_fallback = False 

308 

309 # Phase 1: Ready/verify the issue 

310 logger.info(f"Phase 1: Verifying issue {info.issue_id}...") 

311 with timed_phase(logger, "Phase 1 (ready-issue)") as phase1_timing: 

312 if not dry_run: 

313 result = run_claude_command( 

314 f"/ll:ready-issue {info.issue_id}", 

315 logger, 

316 timeout=config.automation.timeout_seconds, 

317 stream_output=config.automation.stream_output, 

318 idle_timeout=config.automation.idle_timeout_seconds, 

319 ) 

320 if result.returncode != 0: 

321 logger.warning("ready-issue command failed to execute, continuing anyway...") 

322 else: 

323 # Parse the verdict from the output 

324 parsed = parse_ready_issue_output(result.stdout) 

325 logger.info(f"ready-issue verdict: {parsed['verdict']}") 

326 

327 # Validate that ready-issue analyzed the expected file 

328 validated_path = parsed.get("validated_file_path") 

329 if validated_path: 

330 # Normalize paths for comparison (resolve to absolute) 

331 expected_path = str(info.path.resolve()) 

332 # Handle both absolute and relative paths from ready_issue 

333 validated_resolved = Path(validated_path).resolve() 

334 if str(validated_resolved) != expected_path: 

335 # Check if this is a legitimate rename (new file exists, 

336 # old file doesn't) vs a mismatch error 

337 old_file_exists = info.path.exists() 

338 new_file_exists = validated_resolved.exists() 

339 

340 if new_file_exists and not old_file_exists: 

341 # ready-issue renamed the file - update tracking 

342 logger.info( 

343 f"Issue file renamed: '{info.path.name}' -> " 

344 f"'{validated_resolved.name}'" 

345 ) 

346 info.path = validated_resolved 

347 else: 

348 # Genuine mismatch - attempt fallback with explicit path 

349 logger.warning( 

350 f"Path mismatch: ready-issue validated " 

351 f"'{validated_path}' but expected '{info.path}'" 

352 ) 

353 logger.info( 

354 "Attempting fallback: retrying ready-issue " 

355 "with explicit file path..." 

356 ) 

357 

358 # Compute relative path for the command 

359 relative_path = _compute_relative_path(info.path) 

360 

361 # Retry with explicit path 

362 retry_result = run_claude_command( 

363 f"/ll:ready-issue {relative_path}", 

364 logger, 

365 timeout=config.automation.timeout_seconds, 

366 stream_output=config.automation.stream_output, 

367 idle_timeout=config.automation.idle_timeout_seconds, 

368 ) 

369 

370 if retry_result.returncode != 0: 

371 logger.error(f"Fallback ready-issue failed for {info.issue_id}") 

372 return IssueProcessingResult( 

373 success=False, 

374 duration=time.time() - issue_start_time, 

375 issue_id=info.issue_id, 

376 failure_reason="Fallback failed after path mismatch", 

377 ) 

378 

379 # Re-parse and validate retry output 

380 retry_parsed = parse_ready_issue_output(retry_result.stdout) 

381 retry_validated_path = retry_parsed.get("validated_file_path") 

382 

383 if retry_validated_path: 

384 retry_resolved = Path(retry_validated_path).resolve() 

385 if str(retry_resolved) != str(info.path.resolve()): 

386 logger.error( 

387 f"Fallback still mismatched: " 

388 f"got '{retry_validated_path}', " 

389 f"expected '{info.path}'" 

390 ) 

391 return IssueProcessingResult( 

392 success=False, 

393 duration=time.time() - issue_start_time, 

394 issue_id=info.issue_id, 

395 failure_reason="Path mismatch persisted after fallback", 

396 ) 

397 

398 # Fallback succeeded - use retry result 

399 logger.info("Fallback succeeded: validated correct file") 

400 parsed = retry_parsed 

401 validated_via_fallback = True 

402 

403 # Log and store any corrections made 

404 if parsed.get("was_corrected"): 

405 logger.info(f"Issue {info.issue_id} was auto-corrected") 

406 phase_corrections = parsed.get("corrections", []) 

407 for correction in phase_corrections: 

408 logger.info(f" Correction: {correction}") 

409 if phase_corrections: 

410 corrections.extend(phase_corrections) 

411 

412 # Log any concerns found 

413 if parsed["concerns"]: 

414 for concern in parsed["concerns"]: 

415 logger.warning(f" Concern: {concern}") 

416 

417 # Handle CLOSE verdict - issue should not be implemented 

418 if parsed.get("should_close"): 

419 close_reason = parsed.get("close_reason", "unknown") 

420 logger.info(f"Issue {info.issue_id} should be closed (reason: {close_reason})") 

421 

422 # CRITICAL: Skip file operations for invalid references 

423 if close_reason == "invalid_ref": 

424 logger.warning( 

425 f"Skipping {info.issue_id}: invalid reference - " 

426 "no matching issue file exists" 

427 ) 

428 return IssueProcessingResult( 

429 success=False, 

430 duration=time.time() - issue_start_time, 

431 issue_id=info.issue_id, 

432 failure_reason=f"Invalid reference: {close_reason}", 

433 corrections=corrections, 

434 ) 

435 

436 # Also require validated_file_path to match before closing 

437 close_validated_path = parsed.get("validated_file_path") 

438 if not close_validated_path: 

439 logger.warning( 

440 f"Skipping close for {info.issue_id}: " 

441 "ready-issue did not return validated file path" 

442 ) 

443 return IssueProcessingResult( 

444 success=False, 

445 duration=time.time() - issue_start_time, 

446 issue_id=info.issue_id, 

447 failure_reason="CLOSE without validated file path", 

448 corrections=corrections, 

449 ) 

450 

451 if close_issue( 

452 info, 

453 config, 

454 logger, 

455 close_reason, 

456 parsed.get("close_status"), 

457 ): 

458 return IssueProcessingResult( 

459 success=True, 

460 duration=time.time() - issue_start_time, 

461 issue_id=info.issue_id, 

462 was_closed=True, 

463 corrections=corrections, 

464 ) 

465 else: 

466 return IssueProcessingResult( 

467 success=False, 

468 duration=time.time() - issue_start_time, 

469 issue_id=info.issue_id, 

470 failure_reason=f"CLOSE failed: {parsed.get('close_status', 'unknown')}", 

471 corrections=corrections, 

472 ) 

473 

474 # Handle BLOCKED verdict - issue has open dependencies 

475 if parsed.get("is_blocked"): 

476 logger.warning( 

477 f"Issue {info.issue_id} blocked — open dependency detected by ready-issue" 

478 ) 

479 return IssueProcessingResult( 

480 success=False, 

481 was_blocked=True, 

482 duration=time.time() - issue_start_time, 

483 issue_id=info.issue_id, 

484 failure_reason=f"BLOCKED: {parsed.get('concerns', [])}", 

485 corrections=corrections, 

486 ) 

487 

488 # Check if issue is NOT READY (and not closeable) 

489 if not parsed["is_ready"]: 

490 logger.error( 

491 f"Issue {info.issue_id} is NOT READY for implementation " 

492 f"(verdict: {parsed['verdict']})" 

493 ) 

494 return IssueProcessingResult( 

495 success=False, 

496 duration=time.time() - issue_start_time, 

497 issue_id=info.issue_id, 

498 failure_reason=( 

499 f"NOT READY: {parsed['verdict']} - {len(parsed['concerns'])} concern(s)" 

500 ), 

501 corrections=corrections, 

502 ) 

503 

504 # Log if proceeding with corrected issue 

505 if parsed.get("was_corrected"): 

506 logger.success(f"Issue {info.issue_id} corrected and ready for implementation") 

507 else: 

508 logger.info(f"Would run: /ll:ready-issue {info.issue_id}") 

509 issue_timing["ready"] = phase1_timing.get("elapsed", 0.0) 

510 

511 # Phase 2: Implement the issue (with automatic continuation on context handoff) 

512 action = config.get_category_action(info.issue_type) 

513 logger.info(f"Phase 2: Implementing {info.issue_id}...") 

514 with timed_phase(logger, "Phase 2 (implement)") as phase2_timing: 

515 if not dry_run: 

516 # Build manage-issue command 

517 type_name = info.issue_type.rstrip("s") # bugs -> bug 

518 

519 # Use relative path if fallback was used, otherwise use issue_id 

520 if validated_via_fallback: 

521 issue_arg = _compute_relative_path(info.path) 

522 else: 

523 issue_arg = info.issue_id 

524 

525 # Use run_with_continuation to handle context exhaustion 

526 result = run_with_continuation( 

527 f"/ll:manage-issue {type_name} {action} {issue_arg}", 

528 logger, 

529 timeout=config.automation.timeout_seconds, 

530 stream_output=config.automation.stream_output, 

531 max_continuations=config.automation.max_continuations, 

532 repo_path=config.repo_path, 

533 idle_timeout=config.automation.idle_timeout_seconds, 

534 ) 

535 else: 

536 logger.info(f"Would run: /ll:manage-issue {info.issue_type} {action} {info.issue_id}") 

537 result = subprocess.CompletedProcess(args=[], returncode=0) 

538 issue_timing["implement"] = phase2_timing.get("elapsed", 0.0) 

539 

540 # Handle implementation failure 

541 if result.returncode != 0: 

542 error_output = result.stderr or result.stdout or "Unknown error" 

543 failure_type, failure_reason_text = classify_failure(error_output, result.returncode) 

544 

545 if failure_type == FailureType.TRANSIENT: 

546 # Transient failure - log but don't create bug issue 

547 logger.warning(f"Transient failure for {info.issue_id}: {failure_reason_text}") 

548 logger.warning("Not creating bug issue - this is a temporary error") 

549 logger.info("Error output (first 500 chars):") 

550 logger.info(error_output[:500]) 

551 

552 return IssueProcessingResult( 

553 success=False, 

554 duration=time.time() - issue_start_time, 

555 issue_id=info.issue_id, 

556 failure_reason=f"Transient: {failure_reason_text}", 

557 corrections=corrections, 

558 ) 

559 

560 # Real failure - create issue as before 

561 logger.error(f"Implementation failed for {info.issue_id}") 

562 

563 failure_reason = "" 

564 if not dry_run: 

565 # Create new issue for the failure 

566 new_issue = create_issue_from_failure( 

567 error_output, 

568 info, 

569 config, 

570 logger, 

571 ) 

572 failure_reason = str(new_issue) if new_issue else error_output 

573 else: 

574 logger.info("Would create new bug issue for this failure") 

575 

576 return IssueProcessingResult( 

577 success=False, 

578 duration=time.time() - issue_start_time, 

579 issue_id=info.issue_id, 

580 failure_reason=failure_reason, 

581 corrections=corrections, 

582 ) 

583 

584 # Phase 3: Verify completion 

585 logger.info(f"Phase 3: Verifying {info.issue_id} completion...") 

586 verified = False 

587 with timed_phase(logger, "Phase 3 (verify)") as phase3_timing: 

588 if not dry_run: 

589 verified = verify_issue_completed(info, config, logger) 

590 

591 # Fallback: Only complete lifecycle if: 

592 # 1. Command returned success (returncode 0) 

593 # 2. File wasn't moved to completed 

594 # 3. There's EVIDENCE of actual work being done (code changes) 

595 if not verified and result.returncode == 0: 

596 # Check if a plan was created awaiting approval 

597 plan_path = detect_plan_creation(result.stdout, info.issue_id) 

598 if plan_path is not None: 

599 logger.info( 

600 f"Plan created at {plan_path}, awaiting approval - " 

601 "issue will remain incomplete until plan is approved and implemented" 

602 ) 

603 return IssueProcessingResult( 

604 success=False, 

605 duration=time.time() - issue_start_time, 

606 issue_id=info.issue_id, 

607 plan_created=True, 

608 plan_path=str(plan_path), 

609 failure_reason="", # Not a failure - plan awaiting approval 

610 corrections=corrections, 

611 ) 

612 

613 logger.info( 

614 "Command returned success but issue not moved - " 

615 "checking for evidence of work..." 

616 ) 

617 

618 # Check issue file content for implementation markers 

619 if check_content_markers(info.path): 

620 logger.info( 

621 "Implementation markers found in issue file - completing lifecycle..." 

622 ) 

623 verified = complete_issue_lifecycle(info, config, logger) 

624 if verified: 

625 logger.success(f"Content marker completion succeeded for {info.issue_id}") 

626 else: 

627 logger.warning(f"Content marker completion failed for {info.issue_id}") 

628 else: 

629 # CRITICAL: Verify actual implementation work was done 

630 work_done = verify_work_was_done(logger) 

631 if work_done: 

632 logger.info("Evidence of code changes found - completing lifecycle...") 

633 verified = complete_issue_lifecycle(info, config, logger) 

634 if verified: 

635 logger.success(f"Fallback completion succeeded for {info.issue_id}") 

636 else: 

637 logger.warning(f"Fallback completion failed for {info.issue_id}") 

638 else: 

639 # NO work was done - do NOT mark as completed 

640 logger.error( 

641 f"REFUSING to mark {info.issue_id} as completed: " 

642 "no code changes detected despite returncode 0" 

643 ) 

644 logger.error( 

645 "This likely indicates the command was not executed " 

646 "properly. Check command invocation and Claude CLI " 

647 "output." 

648 ) 

649 verified = False 

650 else: 

651 logger.info("Would verify issue moved to completed") 

652 verified = True # In dry run, assume success 

653 issue_timing["verify"] = phase3_timing.get("elapsed", 0.0) 

654 

655 # Record timing 

656 total_issue_time = time.time() - issue_start_time 

657 issue_timing["total"] = total_issue_time 

658 logger.timing(f"Total processing time: {format_duration(total_issue_time)}") 

659 

660 if verified: 

661 logger.success(f"Completed: {info.issue_id}") 

662 else: 

663 logger.warning(f"Issue {info.issue_id} was attempted but verification failed") 

664 logger.info("This issue will be skipped on future runs (check logs above for details)") 

665 

666 return IssueProcessingResult( 

667 success=verified, 

668 duration=total_issue_time, 

669 issue_id=info.issue_id, 

670 corrections=corrections, 

671 ) 

672 

673 

674class AutoManager: 

675 """Automated issue manager for sequential processing. 

676 

677 Processes issues in priority order using Claude CLI commands, 

678 with state persistence for resume capability. 

679 """ 

680 

681 def __init__( 

682 self, 

683 config: BRConfig, 

684 dry_run: bool = False, 

685 max_issues: int = 0, 

686 resume: bool = False, 

687 category: str | None = None, 

688 only_ids: list[str] | set[str] | None = None, 

689 skip_ids: set[str] | None = None, 

690 type_prefixes: set[str] | None = None, 

691 verbose: bool = True, 

692 ) -> None: 

693 """Initialize the auto manager. 

694 

695 Args: 

696 config: Project configuration 

697 dry_run: If True, only preview what would be done 

698 max_issues: Maximum issues to process (0 = unlimited) 

699 resume: Whether to resume from previous state 

700 category: Optional category to filter (e.g., "bugs") 

701 only_ids: If provided, only process these issue IDs. When a list, 

702 issues are processed in list order (input sequence preserved). 

703 skip_ids: Issue IDs to skip (in addition to attempted issues) 

704 type_prefixes: If provided, only process issues with these type prefixes 

705 verbose: Whether to output progress messages 

706 """ 

707 self.config = config 

708 self.dry_run = dry_run 

709 self.max_issues = max_issues 

710 self.resume = resume 

711 self.category = category 

712 self.only_ids = only_ids 

713 self.skip_ids = skip_ids or set() 

714 self.type_prefixes = type_prefixes 

715 

716 self.logger = Logger(verbose=verbose) 

717 self.state_manager = StateManager(config.get_state_file(), self.logger) 

718 self.parser = IssueParser(config) 

719 

720 # Build dependency graph for dependency-aware sequencing (ENH-016) 

721 # Note: don't filter by type here — we need all issues for dependency resolution 

722 all_issues = find_issues(self.config, self.category) 

723 all_known_ids: set[str] | None = None 

724 try: 

725 from little_loops.dependency_mapper import gather_all_issue_ids 

726 

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

728 all_known_ids = gather_all_issue_ids(issues_dir, config=config) 

729 except Exception: 

730 self.logger.debug("Dependency mapping unavailable — skipping") 

731 self.dep_graph = DependencyGraph.from_issues(all_issues, all_known_ids=all_known_ids) 

732 

733 # Warn about any cycles 

734 if self.dep_graph.has_cycles(): 

735 cycles = self.dep_graph.detect_cycles() 

736 for cycle in cycles: 

737 self.logger.warning(f"Dependency cycle detected: {' -> '.join(cycle)}") 

738 

739 self.processed_count = 0 

740 self._shutdown_requested = False 

741 

742 signal.signal(signal.SIGINT, self._signal_handler) 

743 signal.signal(signal.SIGTERM, self._signal_handler) 

744 

745 def _signal_handler(self, signum: int, frame: FrameType | None) -> None: 

746 """Handle shutdown signals gracefully.""" 

747 self._shutdown_requested = True 

748 self.logger.warning(f"Received signal {signum}, shutting down gracefully...") 

749 

750 def _get_next_issue(self) -> IssueInfo | None: 

751 """Get next issue respecting dependencies. 

752 

753 Returns the highest priority issue whose blockers have all been 

754 completed. If no ready issues exist but blocked issues remain, 

755 logs warnings about what is blocking progress. 

756 

757 Returns: 

758 Next IssueInfo to process, or None if no ready issues 

759 """ 

760 # Get completed issues from state 

761 completed = set(self.state_manager.state.completed_issues) 

762 

763 # Combine skip_ids from state and CLI argument 

764 skip_ids = self.state_manager.state.attempted_issues | self.skip_ids 

765 

766 # Get issues that are ready (blockers satisfied) 

767 ready_issues = self.dep_graph.get_ready_issues(completed) 

768 

769 # Filter by skip_ids, only_ids, type_prefixes 

770 candidates = [ 

771 i 

772 for i in ready_issues 

773 if i.issue_id not in skip_ids 

774 and (self.only_ids is None or i.issue_id in self.only_ids) 

775 and (self.type_prefixes is None or i.issue_id.split("-", 1)[0] in self.type_prefixes) 

776 ] 

777 

778 if candidates: 

779 # When only_ids is a list, respect input order; otherwise use priority order 

780 only_ids = self.only_ids 

781 if isinstance(only_ids, list): 

782 order = {issue_id: i for i, issue_id in enumerate(only_ids)} 

783 candidates.sort(key=lambda x: order.get(x.issue_id, len(only_ids))) 

784 return candidates[0] 

785 

786 # No ready candidates - check if there are blocked issues remaining 

787 all_in_graph = set(self.dep_graph.issues.keys()) 

788 remaining = all_in_graph - completed - skip_ids 

789 if self.only_ids is not None: 

790 remaining = remaining & set(self.only_ids) 

791 if self.type_prefixes is not None: 

792 remaining = {r for r in remaining if r.split("-", 1)[0] in self.type_prefixes} 

793 

794 if remaining: 

795 self._log_blocked_issues(remaining, completed) 

796 

797 return None 

798 

799 def _log_blocked_issues(self, remaining: set[str], completed: set[str]) -> None: 

800 """Log information about blocked issues when processing stalls. 

801 

802 Args: 

803 remaining: Set of issue IDs that haven't been processed 

804 completed: Set of completed issue IDs 

805 """ 

806 blocked_count = 0 

807 for issue_id in sorted(remaining): 

808 blockers = self.dep_graph.get_blocking_issues(issue_id, completed) 

809 if blockers: 

810 blocked_count += 1 

811 self.logger.info(f" {issue_id} blocked by: {', '.join(sorted(blockers))}") 

812 

813 if blocked_count > 0: 

814 self.logger.warning(f"{blocked_count} issue(s) remain blocked - check dependencies") 

815 

816 def run(self) -> int: 

817 """Run the automation loop. 

818 

819 Returns: 

820 Exit code (0 = success) 

821 """ 

822 run_start_time = time.time() 

823 self.logger.info("Starting automated issue management...") 

824 

825 if self.dry_run: 

826 self.logger.info("DRY RUN MODE - No actual changes will be made") 

827 

828 if not self.dry_run: 

829 has_changes = check_git_status(self.logger) 

830 if has_changes: 

831 self.logger.warning("Proceeding anyway...") 

832 

833 # Load or initialize state 

834 if self.resume: 

835 state = self.state_manager.load() 

836 if state: 

837 self.logger.info(f"Resuming from: {state.current_issue}") 

838 self.processed_count = len(state.completed_issues) 

839 else: 

840 # Fresh start 

841 self.state_manager._state = ProcessingState(timestamp=datetime.now().isoformat()) 

842 

843 try: 

844 while not self._shutdown_requested: 

845 if self.max_issues > 0 and self.processed_count >= self.max_issues: 

846 self.logger.info(f"Reached max issues limit: {self.max_issues}") 

847 break 

848 

849 info = self._get_next_issue() 

850 if not info: 

851 self.logger.success("No more issues to process!") 

852 break 

853 

854 success = self._process_issue(info) 

855 if success: 

856 self.processed_count += 1 

857 

858 except Exception as e: 

859 self.logger.error(f"Fatal error: {e}") 

860 return 1 

861 

862 finally: 

863 if not self._shutdown_requested: 

864 self.state_manager.cleanup() 

865 

866 self._log_timing_summary(run_start_time) 

867 self.logger.success(f"Processed {self.processed_count} issue(s)") 

868 return 0 

869 

870 def _log_timing_summary(self, run_start_time: float) -> None: 

871 """Log aggregate timing summary.""" 

872 total_run_time = time.time() - run_start_time 

873 

874 self.logger.info("") 

875 self.logger.header("PROCESSING SUMMARY") 

876 self.logger.timing(f"Total run time: {format_duration(total_run_time)}") 

877 self.logger.timing(f"Issues processed: {self.processed_count}") 

878 

879 state = self.state_manager.state 

880 if state.timing: 

881 total_times = [t.get("total", 0) for t in state.timing.values()] 

882 if total_times: 

883 avg_time = sum(total_times) / len(total_times) 

884 self.logger.timing(f"Average per issue: {format_duration(avg_time)}") 

885 

886 if state.failed_issues: 

887 self.logger.warning(f"Failed issues: {len(state.failed_issues)}") 

888 for issue_id, reason in state.failed_issues.items(): 

889 self.logger.warning(f" - {issue_id}: {reason[:50]}...") 

890 

891 # Log correction statistics for quality tracking 

892 if state.corrections: 

893 total_corrected = len(state.corrections) 

894 total_issues = len(state.completed_issues) + len(state.failed_issues) 

895 correction_rate = (total_corrected / total_issues * 100) if total_issues > 0 else 0 

896 self.logger.info( 

897 f"Auto-corrections: {total_corrected}/{total_issues} ({correction_rate:.1f}%)" 

898 ) 

899 

900 # Log most common correction types 

901 from collections import Counter 

902 

903 all_corrections: list[str] = [] 

904 for corrections in state.corrections.values(): 

905 all_corrections.extend(corrections) 

906 if all_corrections: 

907 common = Counter(all_corrections).most_common(3) 

908 self.logger.info("Most common corrections:") 

909 for correction, count in common: 

910 # Truncate long correction descriptions 

911 display = correction[:60] + "..." if len(correction) > 60 else correction 

912 self.logger.info(f" - {display}: {count}") 

913 

914 def _process_issue(self, info: IssueInfo) -> bool: 

915 """Process a single issue through the workflow. 

916 

917 Delegates to process_issue_inplace() and maps the result back 

918 to state manager calls. 

919 

920 Args: 

921 info: Issue information 

922 

923 Returns: 

924 True if processing succeeded 

925 """ 

926 # Pre-processing state updates (before delegating) 

927 self.state_manager.mark_attempted(info.issue_id, save=False) 

928 self.state_manager.update_current(str(info.path), "processing") 

929 

930 result = process_issue_inplace(info, self.config, self.logger, self.dry_run) 

931 

932 # Map result back to state tracking 

933 if result.was_closed: 

934 self.state_manager.mark_completed(info.issue_id) 

935 elif result.was_blocked: 

936 # Blocked issues are skipped, not failed — leave in pending state 

937 self.logger.info(f"{info.issue_id} skipped — blocked by open dependency") 

938 elif result.success: 

939 self.state_manager.mark_completed(info.issue_id, {"total": result.duration}) 

940 elif result.plan_created: 

941 # Don't mark as failed if a plan was created (awaiting approval) 

942 self.logger.info( 

943 f"{info.issue_id} has plan at {result.plan_path} - " 

944 "leaving in pending state for manual approval" 

945 ) 

946 # Issue remains in pending state (not marked as failed) 

947 elif result.failure_reason: 

948 self.state_manager.mark_failed(info.issue_id, result.failure_reason) 

949 

950 if result.corrections: 

951 self.state_manager.record_corrections(info.issue_id, result.corrections) 

952 

953 return result.success