Coverage for little_loops / issue_manager.py: 12%

414 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-04-11 23:20 -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 Callable, Generator 

14from contextlib import contextmanager 

15from dataclasses import dataclass, field 

16from pathlib import Path 

17from types import FrameType 

18 

19from little_loops.cli_args import _id_matches 

20from little_loops.config import BRConfig 

21from little_loops.dependency_graph import DependencyGraph 

22from little_loops.events import EventBus 

23from little_loops.git_operations import check_git_status, verify_work_was_done 

24from little_loops.issue_lifecycle import ( 

25 FailureType, 

26 classify_failure, 

27 close_issue, 

28 complete_issue_lifecycle, 

29 create_issue_from_failure, 

30 verify_issue_completed, 

31) 

32from little_loops.issue_parser import IssueInfo, IssueParser, find_issues 

33from little_loops.logger import Logger, format_duration 

34from little_loops.output_parsing import parse_ready_issue_output 

35from little_loops.skill_expander import expand_skill 

36from little_loops.state import ProcessingState, StateManager, _iso_now 

37from little_loops.subprocess_utils import ( 

38 detect_context_handoff, 

39 read_continuation_prompt, 

40) 

41from little_loops.subprocess_utils import ( 

42 run_claude_command as _run_claude_base, 

43) 

44 

45 

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

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

48 

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

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

51 

52 Args: 

53 abs_path: Absolute path to the file 

54 base_dir: Base directory (defaults to cwd) 

55 

56 Returns: 

57 Relative path string suitable for ready-issue command 

58 """ 

59 base = base_dir or Path.cwd() 

60 try: 

61 return str(abs_path.relative_to(base)) 

62 except ValueError: 

63 # Path not relative to base, use absolute 

64 return str(abs_path) 

65 

66 

67@contextmanager 

68def timed_phase( 

69 logger: Logger, 

70 phase_name: str, 

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

72 """Context manager for timing phases. 

73 

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

75 

76 Args: 

77 logger: Logger for output 

78 phase_name: Name of the phase being timed 

79 

80 Yields: 

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

82 """ 

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

84 start = time.time() 

85 try: 

86 yield timing_result 

87 finally: 

88 elapsed = time.time() - start 

89 timing_result["elapsed"] = elapsed 

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

91 

92 

93def run_claude_command( 

94 command: str, 

95 logger: Logger, 

96 timeout: int = 3600, 

97 stream_output: bool = True, 

98 idle_timeout: int = 0, 

99 on_model_detected: Callable[[str], None] | None = None, 

100 preview_full: bool = False, 

101) -> subprocess.CompletedProcess[str]: 

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

103 

104 Args: 

105 command: Command to pass to Claude CLI 

106 logger: Logger for output 

107 timeout: Timeout in seconds 

108 stream_output: Whether to stream output to console 

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

110 on_model_detected: Optional callback invoked with the model name from the 

111 stream-json system/init event. 

112 preview_full: If True, display the full command without truncation (for --verbose). 

113 

114 Returns: 

115 CompletedProcess with stdout/stderr captured 

116 """ 

117 from little_loops.cli.output import terminal_width 

118 

119 lines = command.strip().splitlines() 

120 line_count = len(lines) 

121 tw = terminal_width() 

122 max_line = tw - 4 

123 logger.info(f"Running: claude --dangerously-skip-permissions -p ({line_count} lines)") 

124 show_count = line_count if preview_full else min(5, line_count) 

125 for line in lines[:show_count]: 

126 display = ( 

127 line if preview_full else (line[:max_line] + "..." if len(line) > max_line else line) 

128 ) 

129 logger.info(f" {display}") 

130 if line_count > show_count: 

131 logger.info(f" ... ({line_count - show_count} more lines)") 

132 

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

134 if stream_output: 

135 if is_stderr: 

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

137 else: 

138 print(f" {line}") 

139 

140 return _run_claude_base( 

141 command=command, 

142 timeout=timeout, 

143 stream_callback=stream_callback if stream_output else None, 

144 idle_timeout=idle_timeout, 

145 on_model_detected=on_model_detected, 

146 ) 

147 

148 

149def run_with_continuation( 

150 initial_command: str, 

151 logger: Logger, 

152 timeout: int = 3600, 

153 stream_output: bool = True, 

154 max_continuations: int = 3, 

155 repo_path: Path | None = None, 

156 idle_timeout: int = 0, 

157 resume_command: str | None = None, 

158 preview_full: bool = False, 

159) -> subprocess.CompletedProcess[str]: 

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

161 

162 If the command signals CONTEXT_HANDOFF, reads the continuation prompt 

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

164 

165 Args: 

166 initial_command: Initial command to run 

167 logger: Logger for output 

168 timeout: Timeout per session in seconds 

169 stream_output: Whether to stream output 

170 max_continuations: Maximum number of continuation attempts 

171 repo_path: Repository root path 

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

173 resume_command: Command to use for continuation rounds instead of appending 

174 ``--resume`` to ``initial_command``. Useful when ``initial_command`` 

175 is expanded skill content (hundreds of lines) — the short slash 

176 command is passed here so continuation rounds stay compact. 

177 

178 Returns: 

179 Final CompletedProcess result 

180 """ 

181 all_stdout: list[str] = [] 

182 all_stderr: list[str] = [] 

183 current_command = initial_command 

184 continuation_count = 0 

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

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

187 ) 

188 

189 while continuation_count <= max_continuations: 

190 result = run_claude_command( 

191 current_command, 

192 logger, 

193 timeout=timeout, 

194 stream_output=stream_output, 

195 idle_timeout=idle_timeout, 

196 preview_full=preview_full, 

197 ) 

198 

199 all_stdout.append(result.stdout) 

200 all_stderr.append(result.stderr) 

201 

202 # Check for context handoff signal 

203 if detect_context_handoff(result.stdout): 

204 logger.info("Detected CONTEXT_HANDOFF signal") 

205 

206 # Read continuation prompt 

207 prompt_content = read_continuation_prompt(repo_path) 

208 if not prompt_content: 

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

210 all_stderr.append("Handoff detected but no continuation prompt found") 

211 result = subprocess.CompletedProcess( 

212 args=result.args, returncode=1, stdout=result.stdout, stderr=result.stderr 

213 ) 

214 break 

215 

216 if continuation_count >= max_continuations: 

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

218 break 

219 

220 continuation_count += 1 

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

222 

223 # Re-invoke with --resume so the skill lifecycle (including 

224 # completion/file-move) runs in the new session. When a short 

225 # resume_command was provided (e.g. the original slash command), 

226 # use that instead of appending --resume to the (potentially 

227 # multi-hundred-line) expanded initial_command. 

228 _base = resume_command if resume_command is not None else initial_command 

229 current_command = f"{_base} --resume" 

230 continue 

231 

232 # No handoff signal, we're done 

233 break 

234 

235 return subprocess.CompletedProcess( 

236 args=result.args, 

237 returncode=result.returncode, 

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

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

240 ) 

241 

242 

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

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

245 

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

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

248 

249 Args: 

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

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

252 

253 Returns: 

254 Path to plan file if created, None otherwise 

255 """ 

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

257 if not plans_dir.exists(): 

258 return None 

259 

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

261 # Use glob pattern with issue_id 

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

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

264 

265 if not matching_plans: 

266 return None 

267 

268 # Return the most recently modified plan file 

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

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

271 return latest_plan 

272 

273 

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

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

276 

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

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

279 

280 Args: 

281 issue_path: Path to the issue file 

282 

283 Returns: 

284 True if implementation markers found 

285 """ 

286 try: 

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

288 except (OSError, UnicodeDecodeError): 

289 return False 

290 

291 markers = [ 

292 "## Resolution", 

293 "Status: Implemented", 

294 "Status: Completed", 

295 "**Completed**:", 

296 ] 

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

298 

299 

300@dataclass 

301class IssueProcessingResult: 

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

303 

304 success: bool 

305 duration: float 

306 issue_id: str 

307 was_closed: bool = False 

308 was_blocked: bool = False 

309 failure_reason: str = "" 

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

311 plan_created: bool = False 

312 plan_path: str = "" 

313 

314 

315def process_issue_inplace( 

316 info: IssueInfo, 

317 config: BRConfig, 

318 logger: Logger, 

319 dry_run: bool = False, 

320 on_model_detected: Callable[[str], None] | None = None, 

321 preview_full: bool = False, 

322) -> IssueProcessingResult: 

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

324 

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

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

327 

328 Args: 

329 info: Issue information 

330 config: Project configuration 

331 logger: Logger for output 

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

333 on_model_detected: Optional callback invoked with the model name from the 

334 first stream-json system/init event during this issue's processing. 

335 

336 Returns: 

337 IssueProcessingResult with outcome details 

338 """ 

339 issue_start_time = time.time() 

340 corrections: list[str] = [] 

341 

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

343 

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

345 

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

347 validated_via_fallback = False 

348 

349 # Phase 1: Ready/verify the issue 

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

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

352 if not dry_run: 

353 _ready_slash = f"/ll:ready-issue {info.issue_id}" 

354 _ready_cmd = expand_skill("ready-issue", [info.issue_id], config) or _ready_slash 

355 result = run_claude_command( 

356 _ready_cmd, 

357 logger, 

358 timeout=config.automation.timeout_seconds, 

359 stream_output=config.automation.stream_output, 

360 idle_timeout=config.automation.idle_timeout_seconds, 

361 on_model_detected=on_model_detected, 

362 preview_full=preview_full, 

363 ) 

364 if result.returncode != 0: 

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

366 else: 

367 # Parse the verdict from the output 

368 parsed = parse_ready_issue_output(result.stdout) 

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

370 

371 # Validate that ready-issue analyzed the expected file 

372 validated_path = parsed.get("validated_file_path") 

373 if validated_path: 

374 # Normalize paths for comparison (resolve to absolute) 

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

376 # Handle both absolute and relative paths from ready_issue 

377 validated_resolved = Path(validated_path).resolve() 

378 if str(validated_resolved) != expected_path: 

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

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

381 old_file_exists = info.path.exists() 

382 new_file_exists = validated_resolved.exists() 

383 

384 if new_file_exists and not old_file_exists: 

385 # ready-issue renamed the file - update tracking 

386 logger.info( 

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

388 f"'{validated_resolved.name}'" 

389 ) 

390 info.path = validated_resolved 

391 else: 

392 # Genuine mismatch - attempt fallback with explicit path 

393 logger.warning( 

394 f"Path mismatch: ready-issue validated " 

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

396 ) 

397 logger.info( 

398 "Attempting fallback: retrying ready-issue " 

399 "with explicit file path..." 

400 ) 

401 

402 # Compute relative path for the command 

403 relative_path = _compute_relative_path(info.path) 

404 

405 # Retry with explicit path 

406 _retry_slash = f"/ll:ready-issue {relative_path}" 

407 _retry_cmd = ( 

408 expand_skill("ready-issue", [str(relative_path)], config) 

409 or _retry_slash 

410 ) 

411 retry_result = run_claude_command( 

412 _retry_cmd, 

413 logger, 

414 timeout=config.automation.timeout_seconds, 

415 stream_output=config.automation.stream_output, 

416 idle_timeout=config.automation.idle_timeout_seconds, 

417 on_model_detected=on_model_detected, 

418 preview_full=preview_full, 

419 ) 

420 

421 if retry_result.returncode != 0: 

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

423 return IssueProcessingResult( 

424 success=False, 

425 duration=time.time() - issue_start_time, 

426 issue_id=info.issue_id, 

427 failure_reason="Fallback failed after path mismatch", 

428 ) 

429 

430 # Re-parse and validate retry output 

431 retry_parsed = parse_ready_issue_output(retry_result.stdout) 

432 retry_validated_path = retry_parsed.get("validated_file_path") 

433 

434 if retry_validated_path: 

435 retry_resolved = Path(retry_validated_path).resolve() 

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

437 logger.error( 

438 f"Fallback still mismatched: " 

439 f"got '{retry_validated_path}', " 

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

441 ) 

442 return IssueProcessingResult( 

443 success=False, 

444 duration=time.time() - issue_start_time, 

445 issue_id=info.issue_id, 

446 failure_reason="Path mismatch persisted after fallback", 

447 ) 

448 

449 # Fallback succeeded - use retry result 

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

451 parsed = retry_parsed 

452 validated_via_fallback = True 

453 

454 # Log and store any corrections made 

455 if parsed.get("was_corrected"): 

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

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

458 for correction in phase_corrections: 

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

460 if phase_corrections: 

461 corrections.extend(phase_corrections) 

462 

463 # Log any concerns found 

464 if parsed["concerns"]: 

465 for concern in parsed["concerns"]: 

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

467 

468 # Handle CLOSE verdict - issue should not be implemented 

469 if parsed.get("should_close"): 

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

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

472 

473 # CRITICAL: Skip file operations for invalid references 

474 if close_reason == "invalid_ref": 

475 logger.warning( 

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

477 "no matching issue file exists" 

478 ) 

479 return IssueProcessingResult( 

480 success=False, 

481 duration=time.time() - issue_start_time, 

482 issue_id=info.issue_id, 

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

484 corrections=corrections, 

485 ) 

486 

487 # Also require validated_file_path to match before closing 

488 close_validated_path = parsed.get("validated_file_path") 

489 if not close_validated_path: 

490 logger.warning( 

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

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

493 ) 

494 return IssueProcessingResult( 

495 success=False, 

496 duration=time.time() - issue_start_time, 

497 issue_id=info.issue_id, 

498 failure_reason="CLOSE without validated file path", 

499 corrections=corrections, 

500 ) 

501 

502 if close_issue( 

503 info, 

504 config, 

505 logger, 

506 close_reason, 

507 parsed.get("close_status"), 

508 ): 

509 return IssueProcessingResult( 

510 success=True, 

511 duration=time.time() - issue_start_time, 

512 issue_id=info.issue_id, 

513 was_closed=True, 

514 corrections=corrections, 

515 ) 

516 else: 

517 return IssueProcessingResult( 

518 success=False, 

519 duration=time.time() - issue_start_time, 

520 issue_id=info.issue_id, 

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

522 corrections=corrections, 

523 ) 

524 

525 # Handle BLOCKED verdict - issue has open dependencies 

526 if parsed.get("is_blocked"): 

527 logger.warning( 

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

529 ) 

530 return IssueProcessingResult( 

531 success=False, 

532 was_blocked=True, 

533 duration=time.time() - issue_start_time, 

534 issue_id=info.issue_id, 

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

536 corrections=corrections, 

537 ) 

538 

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

540 if not parsed["is_ready"]: 

541 logger.error( 

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

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

544 ) 

545 return IssueProcessingResult( 

546 success=False, 

547 duration=time.time() - issue_start_time, 

548 issue_id=info.issue_id, 

549 failure_reason=( 

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

551 ), 

552 corrections=corrections, 

553 ) 

554 

555 # Log if proceeding with corrected issue 

556 if parsed.get("was_corrected"): 

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

558 else: 

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

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

561 

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

563 action = config.get_category_action(info.issue_type) 

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

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

566 if not dry_run: 

567 # Build manage-issue command 

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

569 

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

571 if validated_via_fallback: 

572 issue_arg = _compute_relative_path(info.path) 

573 else: 

574 issue_arg = info.issue_id 

575 

576 # Use run_with_continuation to handle context exhaustion. 

577 # Pre-expand the skill so the subprocess needs no ToolSearch. 

578 # Pass the short slash command as resume_command so continuation 

579 # rounds stay compact (not hundreds of lines). 

580 _slash_cmd = f"/ll:manage-issue {type_name} {action} {issue_arg}" 

581 _initial_cmd = ( 

582 expand_skill("manage-issue", [type_name, action, issue_arg], config) or _slash_cmd 

583 ) 

584 result = run_with_continuation( 

585 _initial_cmd, 

586 logger, 

587 timeout=config.automation.timeout_seconds, 

588 stream_output=config.automation.stream_output, 

589 max_continuations=config.automation.max_continuations, 

590 repo_path=config.repo_path, 

591 idle_timeout=config.automation.idle_timeout_seconds, 

592 resume_command=_slash_cmd, 

593 preview_full=preview_full, 

594 ) 

595 else: 

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

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

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

599 

600 # Handle implementation failure 

601 if result.returncode != 0: 

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

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

604 

605 if failure_type == FailureType.TRANSIENT: 

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

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

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

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

610 logger.info(error_output[:500]) 

611 

612 return IssueProcessingResult( 

613 success=False, 

614 duration=time.time() - issue_start_time, 

615 issue_id=info.issue_id, 

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

617 corrections=corrections, 

618 ) 

619 

620 # Real failure - create issue as before 

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

622 

623 failure_reason = "" 

624 if not dry_run: 

625 # Create new issue for the failure 

626 new_issue = create_issue_from_failure( 

627 error_output, 

628 info, 

629 config, 

630 logger, 

631 ) 

632 failure_reason = str(new_issue) if new_issue else error_output 

633 else: 

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

635 

636 return IssueProcessingResult( 

637 success=False, 

638 duration=time.time() - issue_start_time, 

639 issue_id=info.issue_id, 

640 failure_reason=failure_reason, 

641 corrections=corrections, 

642 ) 

643 

644 # Phase 3: Verify completion 

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

646 verified = False 

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

648 if not dry_run: 

649 verified = verify_issue_completed(info, config, logger) 

650 

651 # Fallback: Only complete lifecycle if: 

652 # 1. Command returned success (returncode 0) 

653 # 2. File wasn't moved to completed 

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

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

656 # Check if a plan was created awaiting approval 

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

658 if plan_path is not None: 

659 logger.info( 

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

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

662 ) 

663 return IssueProcessingResult( 

664 success=False, 

665 duration=time.time() - issue_start_time, 

666 issue_id=info.issue_id, 

667 plan_created=True, 

668 plan_path=str(plan_path), 

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

670 corrections=corrections, 

671 ) 

672 

673 logger.info( 

674 "Command returned success but issue not moved - " 

675 "checking for evidence of work..." 

676 ) 

677 

678 # Check issue file content for implementation markers 

679 if check_content_markers(info.path): 

680 logger.info( 

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

682 ) 

683 verified = complete_issue_lifecycle(info, config, logger) 

684 if verified: 

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

686 else: 

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

688 else: 

689 # CRITICAL: Verify actual implementation work was done 

690 work_done = verify_work_was_done(logger) 

691 if work_done: 

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

693 verified = complete_issue_lifecycle(info, config, logger) 

694 if verified: 

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

696 else: 

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

698 else: 

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

700 logger.error( 

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

702 "no code changes detected despite returncode 0" 

703 ) 

704 logger.error( 

705 "This likely indicates the command was not executed " 

706 "properly. Check command invocation and Claude CLI " 

707 "output." 

708 ) 

709 verified = False 

710 else: 

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

712 verified = True # In dry run, assume success 

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

714 

715 # Record timing 

716 total_issue_time = time.time() - issue_start_time 

717 issue_timing["total"] = total_issue_time 

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

719 

720 if verified: 

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

722 else: 

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

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

725 

726 return IssueProcessingResult( 

727 success=verified, 

728 duration=total_issue_time, 

729 issue_id=info.issue_id, 

730 corrections=corrections, 

731 ) 

732 

733 

734class AutoManager: 

735 """Automated issue manager for sequential processing. 

736 

737 Processes issues in priority order using Claude CLI commands, 

738 with state persistence for resume capability. 

739 """ 

740 

741 def __init__( 

742 self, 

743 config: BRConfig, 

744 dry_run: bool = False, 

745 max_issues: int = 0, 

746 resume: bool = False, 

747 category: str | None = None, 

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

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

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

751 priority_filter: set[str] | None = None, 

752 verbose: bool = True, 

753 preview_full: bool = False, 

754 ) -> None: 

755 """Initialize the auto manager. 

756 

757 Args: 

758 config: Project configuration 

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

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

761 resume: Whether to resume from previous state 

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

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

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

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

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

767 priority_filter: If provided, only process issues with these priority levels 

768 verbose: Whether to output progress messages 

769 preview_full: If True, show full command content without truncation (--verbose flag). 

770 """ 

771 self.config = config 

772 self.dry_run = dry_run 

773 self.max_issues = max_issues 

774 self.resume = resume 

775 self.category = category 

776 self.only_ids = only_ids 

777 self.skip_ids = skip_ids or set() 

778 self.type_prefixes = type_prefixes 

779 self.priority_filter = priority_filter 

780 self._preview_full = preview_full 

781 

782 self.logger = Logger(verbose=verbose) 

783 self.event_bus = EventBus() 

784 self.state_manager = StateManager( 

785 config.get_state_file(), self.logger, event_bus=self.event_bus 

786 ) 

787 self.parser = IssueParser(config) 

788 self._detected_model: list[str] = [] 

789 

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

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

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

793 all_known_ids: set[str] | None = None 

794 try: 

795 from little_loops.dependency_mapper import gather_all_issue_ids 

796 

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

798 all_known_ids = gather_all_issue_ids(issues_dir, config=config) 

799 except Exception: 

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

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

802 

803 # Warn about any cycles 

804 if self.dep_graph.has_cycles(): 

805 cycles = self.dep_graph.detect_cycles() 

806 for cycle in cycles: 

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

808 

809 self.processed_count = 0 

810 self._shutdown_requested = False 

811 

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

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

814 

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

816 """Handle shutdown signals gracefully.""" 

817 self._shutdown_requested = True 

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

819 

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

821 """Get next issue respecting dependencies. 

822 

823 Returns the highest priority issue whose blockers have all been 

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

825 logs warnings about what is blocking progress. 

826 

827 Returns: 

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

829 """ 

830 # Get completed issues from state 

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

832 

833 # Combine skip_ids from state and CLI argument 

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

835 

836 # Get issues that are ready (blockers satisfied) 

837 ready_issues = self.dep_graph.get_ready_issues(completed) 

838 

839 # Filter by skip_ids, only_ids, type_prefixes, priority_filter 

840 candidates = [ 

841 i 

842 for i in ready_issues 

843 if i.issue_id not in skip_ids 

844 and (self.only_ids is None or any(_id_matches(i.issue_id, p) for p in self.only_ids)) 

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

846 and (self.priority_filter is None or i.priority in self.priority_filter) 

847 ] 

848 

849 if candidates: 

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

851 only_ids = self.only_ids 

852 if isinstance(only_ids, list): 

853 candidates.sort( 

854 key=lambda x: next( 

855 (i for i, p in enumerate(only_ids) if _id_matches(x.issue_id, p)), 

856 len(only_ids), 

857 ) 

858 ) 

859 return candidates[0] 

860 

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

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

863 remaining = all_in_graph - completed - skip_ids 

864 if self.only_ids is not None: 

865 remaining = {r for r in remaining if any(_id_matches(r, p) for p in self.only_ids)} 

866 if self.type_prefixes is not None: 

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

868 if self.priority_filter is not None: 

869 remaining = { 

870 r for r in remaining if self.dep_graph.issues[r].priority in self.priority_filter 

871 } 

872 

873 if remaining: 

874 self._log_blocked_issues(remaining, completed) 

875 

876 return None 

877 

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

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

880 

881 Args: 

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

883 completed: Set of completed issue IDs 

884 """ 

885 blocked_count = 0 

886 for issue_id in sorted(remaining): 

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

888 if blockers: 

889 blocked_count += 1 

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

891 

892 if blocked_count > 0: 

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

894 

895 def run(self) -> int: 

896 """Run the automation loop. 

897 

898 Returns: 

899 Exit code (0 = success) 

900 """ 

901 run_start_time = time.time() 

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

903 

904 if self.dry_run: 

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

906 

907 if not self.dry_run: 

908 has_changes = check_git_status(self.logger) 

909 if has_changes: 

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

911 

912 # Load or initialize state 

913 if self.resume: 

914 state = self.state_manager.load() 

915 if state: 

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

917 self.processed_count = len(state.completed_issues) 

918 else: 

919 # Fresh start 

920 self.state_manager._state = ProcessingState(timestamp=_iso_now()) 

921 

922 try: 

923 while not self._shutdown_requested: 

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

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

926 break 

927 

928 info = self._get_next_issue() 

929 if not info: 

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

931 break 

932 

933 success = self._process_issue(info) 

934 if success: 

935 self.processed_count += 1 

936 

937 except Exception as e: 

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

939 return 1 

940 

941 finally: 

942 if not self._shutdown_requested: 

943 self.state_manager.cleanup() 

944 

945 self._log_timing_summary(run_start_time) 

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

947 return 0 

948 

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

950 """Log aggregate timing summary.""" 

951 total_run_time = time.time() - run_start_time 

952 

953 self.logger.info("") 

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

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

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

957 

958 state = self.state_manager.state 

959 if state.timing: 

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

961 if total_times: 

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

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

964 

965 if state.failed_issues: 

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

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

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

969 

970 # Log correction statistics for quality tracking 

971 if state.corrections: 

972 total_corrected = len(state.corrections) 

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

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

975 self.logger.info( 

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

977 ) 

978 

979 # Log most common correction types 

980 from collections import Counter 

981 

982 all_corrections: list[str] = [] 

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

984 all_corrections.extend(corrections) 

985 if all_corrections: 

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

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

988 for correction, count in common: 

989 # Truncate long correction descriptions 

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

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

992 

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

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

995 

996 Delegates to process_issue_inplace() and maps the result back 

997 to state manager calls. 

998 

999 Args: 

1000 info: Issue information 

1001 

1002 Returns: 

1003 True if processing succeeded 

1004 """ 

1005 # Pre-processing state updates (before delegating) 

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

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

1008 

1009 on_model: Callable[[str], None] | None = None 

1010 if not self._detected_model: 

1011 

1012 def on_model(m: str) -> None: 

1013 self._detected_model.append(m) 

1014 self.logger.info(f"model: {m}") 

1015 

1016 result = process_issue_inplace( 

1017 info, 

1018 self.config, 

1019 self.logger, 

1020 self.dry_run, 

1021 on_model_detected=on_model, 

1022 preview_full=self._preview_full, 

1023 ) 

1024 

1025 # Map result back to state tracking 

1026 if result.was_closed: 

1027 self.state_manager.mark_completed(info.issue_id) 

1028 elif result.was_blocked: 

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

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

1031 elif result.success: 

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

1033 elif result.plan_created: 

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

1035 self.logger.info( 

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

1037 "leaving in pending state for manual approval" 

1038 ) 

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

1040 elif result.failure_reason: 

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

1042 

1043 if result.corrections: 

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

1045 

1046 return result.success