Coverage for little_loops / issue_manager.py: 10%

522 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-04 12:21 -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 json 

10import signal 

11import subprocess 

12import sys 

13import time 

14from collections.abc import Callable, Generator 

15from contextlib import contextmanager 

16from dataclasses import dataclass, field 

17from pathlib import Path 

18from types import FrameType 

19 

20from little_loops.cli_args import _id_matches 

21from little_loops.config import BRConfig 

22from little_loops.dependency_graph import DependencyGraph 

23from little_loops.events import EventBus 

24from little_loops.git_operations import check_git_status, verify_work_was_done 

25from little_loops.issue_lifecycle import ( 

26 FailureType, 

27 classify_failure, 

28 close_issue, 

29 complete_issue_lifecycle, 

30 create_issue_from_failure, 

31 verify_issue_completed, 

32) 

33from little_loops.issue_parser import IssueInfo, IssueParser, find_issues 

34from little_loops.logger import Logger, format_duration 

35from little_loops.output_parsing import parse_ready_issue_output 

36from little_loops.session_store import DEFAULT_DB_PATH, SQLiteTransport 

37from little_loops.skill_expander import expand_skill 

38from little_loops.state import ProcessingState, StateManager, _iso_now 

39from little_loops.subprocess_utils import ( 

40 assemble_guillotine_prompt, 

41 detect_context_handoff, 

42 read_continuation_prompt, 

43 read_sentinel, 

44 write_sentinel, 

45) 

46from little_loops.subprocess_utils import ( 

47 run_claude_command as _run_claude_base, 

48) 

49 

50 

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

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

53 

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

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

56 

57 Args: 

58 abs_path: Absolute path to the file 

59 base_dir: Base directory (defaults to cwd) 

60 

61 Returns: 

62 Relative path string suitable for ready-issue command 

63 """ 

64 base = base_dir or Path.cwd() 

65 try: 

66 return str(abs_path.relative_to(base)) 

67 except ValueError: 

68 # Path not relative to base, use absolute 

69 return str(abs_path) 

70 

71 

72@contextmanager 

73def timed_phase( 

74 logger: Logger, 

75 phase_name: str, 

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

77 """Context manager for timing phases. 

78 

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

80 

81 Args: 

82 logger: Logger for output 

83 phase_name: Name of the phase being timed 

84 

85 Yields: 

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

87 """ 

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

89 start = time.time() 

90 try: 

91 yield timing_result 

92 finally: 

93 elapsed = time.time() - start 

94 timing_result["elapsed"] = elapsed 

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

96 

97 

98def run_claude_command( 

99 command: str, 

100 logger: Logger, 

101 timeout: int = 3600, 

102 stream_output: bool = True, 

103 idle_timeout: int = 0, 

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

105 on_usage: Callable[[int, int], None] | None = None, 

106 preview_full: bool = False, 

107 resume_session: bool = False, 

108) -> subprocess.CompletedProcess[str]: 

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

110 

111 Args: 

112 command: Command to pass to Claude CLI 

113 logger: Logger for output 

114 timeout: Timeout in seconds 

115 stream_output: Whether to stream output to console 

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

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

118 stream-json system/init event. 

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

120 resume_session: If True, passes --continue to the Claude CLI to continue the 

121 most recent conversation (used for Option E explicit-handoff path). 

122 

123 Returns: 

124 CompletedProcess with stdout/stderr captured 

125 """ 

126 from little_loops.cli.output import terminal_width 

127 

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

129 line_count = len(lines) 

130 tw = terminal_width() 

131 max_line = tw - 4 

132 resume_flag = " --continue" if resume_session else "" 

133 logger.info( 

134 f"Running: claude --dangerously-skip-permissions{resume_flag} -p ({line_count} lines)" 

135 ) 

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

137 for line in lines[:show_count]: 

138 display = ( 

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

140 ) 

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

142 if line_count > show_count: 

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

144 

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

146 if stream_output: 

147 if is_stderr: 

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

149 else: 

150 print(f" {line}") 

151 

152 return _run_claude_base( 

153 command=command, 

154 timeout=timeout, 

155 stream_callback=stream_callback if stream_output else None, 

156 idle_timeout=idle_timeout, 

157 on_model_detected=on_model_detected, 

158 on_usage=on_usage, 

159 resume_session=resume_session, 

160 ) 

161 

162 

163def _check_issue_already_done( 

164 issue_path: Path | None, 

165 logger: Logger, 

166) -> bool: 

167 """Check if the issue file's frontmatter status indicates work is already complete. 

168 

169 Used as a pre-continuation guard (BUG-1759): when the inner Claude session hits 

170 its context limit and emits CONTEXT_HANDOFF, but the issue was already marked 

171 done before the context limit was reached, we should skip the handoff and 

172 return success rather than triggering an unnecessary handoff cycle. 

173 

174 Args: 

175 issue_path: Path to the issue file, or None if not available. 

176 logger: Logger for diagnostic messages. 

177 

178 Returns: 

179 True if the issue's status is 'done' or 'cancelled'. 

180 """ 

181 if issue_path is None: 

182 return False 

183 if not issue_path.exists(): 

184 return False 

185 try: 

186 from little_loops.frontmatter import parse_frontmatter 

187 

188 fm = parse_frontmatter(issue_path.read_text(encoding="utf-8")) 

189 return fm.get("status") in ("done", "cancelled") 

190 except Exception: 

191 return False 

192 

193 

194def run_with_continuation( 

195 initial_command: str, 

196 logger: Logger, 

197 timeout: int = 3600, 

198 stream_output: bool = True, 

199 max_continuations: int = 3, 

200 repo_path: Path | None = None, 

201 idle_timeout: int = 0, 

202 resume_command: str | None = None, 

203 on_usage: Callable[[int, int], None] | None = None, 

204 preview_full: bool = False, 

205 context_limit: int = 200_000, 

206 sentinel_threshold: float = 0.60, 

207 guillotine_threshold: float = 0.90, 

208 issue_path: Path | None = None, 

209) -> subprocess.CompletedProcess[str]: 

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

211 

212 Implements Options E, G, and J from BUG-1377: 

213 

214 Option G (sentinel write): after each session, if accurate token count from 

215 on_usage exceeds sentinel_threshold without a CONTEXT_HANDOFF signal, writes 

216 a sentinel file so the next iteration knows to send an explicit handoff instruction. 

217 

218 Option E (sentinel read): before starting the next session, reads the sentinel 

219 and if present sends a ``--continue`` turn with an explicit "run /ll:handoff now" 

220 instruction, triggering the standard CONTEXT_HANDOFF continuation flow. 

221 

222 Option J (guillotine): if usage exceeds guillotine_threshold OR stderr contains 

223 "Prompt is too long", assembles a transcript-summary prompt and spawns a fresh 

224 session (not --resume) starting at 0 tokens. 

225 

226 Args: 

227 initial_command: Initial command to run 

228 logger: Logger for output 

229 timeout: Timeout per session in seconds 

230 stream_output: Whether to stream output 

231 max_continuations: Maximum number of continuation attempts 

232 repo_path: Repository root path 

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

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

235 ``--resume`` to ``initial_command``. 

236 on_usage: Optional external usage callback; wrapped internally for tracking. 

237 context_limit: Context window size in tokens (default 200K). 

238 sentinel_threshold: Write sentinel when usage/context_limit >= this (default 0.60). 

239 guillotine_threshold: Trigger J-path when usage/context_limit >= this (default 0.90). 

240 

241 Returns: 

242 Final CompletedProcess result 

243 """ 

244 all_stdout: list[str] = [] 

245 all_stderr: list[str] = [] 

246 current_command = initial_command 

247 continuation_count = 0 

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

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

250 ) 

251 

252 # Track token usage from on_usage callback (fires on stream-json result event). 

253 _last_input: list[int] = [0] 

254 _last_output: list[int] = [0] 

255 _external_on_usage = on_usage 

256 # Flag set when Option J fires; consumed in the NEXT iteration so Option E 

257 # knows to consume the sentinel without attempting --continue. 

258 _just_ran_fresh_session = False 

259 

260 def _tracking_usage(input_tokens: int, output_tokens: int) -> None: 

261 _last_input[0] = input_tokens 

262 _last_output[0] = output_tokens 

263 if _external_on_usage is not None: 

264 _external_on_usage(input_tokens, output_tokens) 

265 

266 while continuation_count <= max_continuations: 

267 this_is_fresh = _just_ran_fresh_session 

268 _just_ran_fresh_session = False 

269 result = run_claude_command( 

270 current_command, 

271 logger, 

272 timeout=timeout, 

273 stream_output=stream_output, 

274 idle_timeout=idle_timeout, 

275 on_usage=_tracking_usage, 

276 preview_full=preview_full, 

277 ) 

278 

279 all_stdout.append(result.stdout) 

280 all_stderr.append(result.stderr) 

281 

282 # Check for context handoff signal (standard path: Claude emitted the signal) 

283 if detect_context_handoff(result.stdout): 

284 logger.info("Detected CONTEXT_HANDOFF signal") 

285 

286 # Pre-continuation guard: if the issue is already done/cancelled, the work 

287 # is complete — return success without signalling handoff so the outer FSM 

288 # doesn't waste a handoff cycle on finished work (BUG-1759 Incident 2). 

289 already_done = _check_issue_already_done(issue_path, logger) 

290 if already_done: 

291 logger.info("Issue already done/cancelled; skipping handoff and returning success") 

292 result = subprocess.CompletedProcess( 

293 args=result.args, 

294 returncode=0, 

295 stdout=result.stdout, 

296 stderr=result.stderr, 

297 ) 

298 break 

299 

300 # Forward CONTEXT_HANDOFF signal to ll-auto's stdout so the outer FSM's 

301 # signal_detector can detect it via the existing HANDOFF_SIGNAL pattern. 

302 # The signal was already streamed to stdout via stream_callback; this 

303 # explicit write ensures it lands even when stream_output is False. 

304 handoff_message = "CONTEXT_HANDOFF: Ready for fresh session" 

305 print(handoff_message) 

306 logger.info("Forwarded handoff signal to stdout; exiting cleanly") 

307 

308 result = subprocess.CompletedProcess( 

309 args=result.args, 

310 returncode=0, 

311 stdout=result.stdout + "\n" + handoff_message, 

312 stderr=result.stderr, 

313 ) 

314 break 

315 

316 total_tokens = _last_input[0] + _last_output[0] 

317 usage_ratio = total_tokens / context_limit if context_limit > 0 else 0.0 

318 prompt_too_long = "prompt is too long" in (result.stderr or "").lower() 

319 

320 # Option J: guillotine — context overflow with no handoff signal. 

321 # Assemble transcript-summary prompt and start a FRESH session (not --resume). 

322 if ( 

323 prompt_too_long or usage_ratio >= guillotine_threshold 

324 ) and continuation_count < max_continuations: 

325 trigger_reason = "Prompt is too long" if prompt_too_long else f"usage {usage_ratio:.0%}" 

326 logger.warning(f"Option J triggered ({trigger_reason}): spawning fresh session") 

327 try: 

328 guillotine_cmd = assemble_guillotine_prompt( 

329 original_command=initial_command, 

330 captured_stdout="\n---CONTINUATION---\n".join(all_stdout), 

331 token_stats={ 

332 "input_tokens": _last_input[0], 

333 "output_tokens": _last_output[0], 

334 "context_limit": context_limit, 

335 "trigger_reason": trigger_reason, 

336 }, 

337 ) 

338 except Exception as exc: 

339 logger.warning(f"Failed to assemble guillotine prompt ({exc}), using bare restart") 

340 guillotine_cmd = initial_command 

341 continuation_count += 1 

342 current_command = guillotine_cmd 

343 # Reset per-round usage tracking for the fresh session 

344 _last_input[0] = 0 

345 _last_output[0] = 0 

346 _just_ran_fresh_session = True 

347 continue 

348 

349 # Option E: read sentinel from a PREVIOUS session (written by the Stop hook or by 

350 # G-path in the preceding iteration). Must run BEFORE G writes the current-session 

351 # sentinel so we don't immediately consume what we just wrote. 

352 sentinel_data = read_sentinel(repo_path) 

353 if sentinel_data is not None and this_is_fresh: 

354 # The sentinel was written by the guillotine fresh session that just finished. 

355 # The work is already done; do not attempt --continue. 

356 logger.info( 

357 "Fresh session wrote sentinel; consumed without --continue (work already done)" 

358 ) 

359 elif sentinel_data is not None and continuation_count < max_continuations: 

360 usage_pct = sentinel_data.get("usage_percent", int(usage_ratio * 100)) 

361 logger.info( 

362 f"Sentinel detected ({usage_pct}% context used): " 

363 "sending explicit handoff instruction via --continue" 

364 ) 

365 continuation_count += 1 

366 # Resume the existing session with an explicit handoff instruction. 

367 # Claude receives this as a new user turn in the active session context. 

368 explicit_handoff_instruction = ( 

369 f"Context limit is approaching ({usage_pct}% of the context window is used). " 

370 "Please run /ll:handoff RIGHT NOW to save your progress to " 

371 ".ll/ll-continue-prompt.md, then output " 

372 '"CONTEXT_HANDOFF: Ready for fresh session" to signal continuation.' 

373 ) 

374 current_command = explicit_handoff_instruction 

375 # Reset tracking for the handoff turn 

376 _last_input[0] = 0 

377 _last_output[0] = 0 

378 # Use --continue CLI flag so this turn continues the existing session 

379 result = run_claude_command( 

380 current_command, 

381 logger, 

382 timeout=timeout, 

383 stream_output=stream_output, 

384 idle_timeout=idle_timeout, 

385 on_usage=_tracking_usage, 

386 preview_full=preview_full, 

387 resume_session=True, 

388 ) 

389 all_stdout.append(result.stdout) 

390 all_stderr.append(result.stderr) 

391 

392 # After explicit instruction, check for handoff signal 

393 if detect_context_handoff(result.stdout): 

394 logger.info("CONTEXT_HANDOFF detected after explicit handoff instruction") 

395 prompt_content = read_continuation_prompt(repo_path) 

396 if prompt_content and continuation_count < max_continuations: 

397 continuation_count += 1 

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

399 _base = resume_command if resume_command is not None else initial_command 

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

401 _last_input[0] = 0 

402 _last_output[0] = 0 

403 continue 

404 break 

405 

406 # Option G (Python layer): write sentinel if usage is high for the NEXT session. 

407 # Placed after E-path check so we don't immediately consume our own write. 

408 if total_tokens > 0 and usage_ratio >= sentinel_threshold: 

409 logger.info(f"Writing context-handoff sentinel ({usage_ratio:.0%} context used)") 

410 write_sentinel(repo_path, token_count=total_tokens, context_limit=context_limit) 

411 

412 # No handoff signal, no prior-session sentinel, no overflow — done 

413 break 

414 

415 return subprocess.CompletedProcess( 

416 args=result.args, 

417 returncode=result.returncode, 

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

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

420 ) 

421 

422 

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

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

425 

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

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

428 

429 Args: 

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

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

432 

433 Returns: 

434 Path to plan file if created, None otherwise 

435 """ 

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

437 if not plans_dir.exists(): 

438 return None 

439 

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

441 # Use glob pattern with issue_id 

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

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

444 

445 if not matching_plans: 

446 return None 

447 

448 # Return the most recently modified plan file 

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

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

451 return latest_plan 

452 

453 

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

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

456 

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

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

459 

460 Args: 

461 issue_path: Path to the issue file 

462 

463 Returns: 

464 True if implementation markers found 

465 """ 

466 try: 

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

468 except (OSError, UnicodeDecodeError): 

469 return False 

470 

471 markers = [ 

472 "## Resolution", 

473 "Status: Implemented", 

474 "Status: Completed", 

475 "**Completed**:", 

476 ] 

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

478 

479 

480@dataclass 

481class IssueProcessingResult: 

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

483 

484 success: bool 

485 duration: float 

486 issue_id: str 

487 was_closed: bool = False 

488 was_blocked: bool = False 

489 failure_reason: str = "" 

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

491 plan_created: bool = False 

492 plan_path: str = "" 

493 

494 

495def process_issue_inplace( 

496 info: IssueInfo, 

497 config: BRConfig, 

498 logger: Logger, 

499 dry_run: bool = False, 

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

501 on_usage: Callable[[int, int], None] | None = None, 

502 preview_full: bool = False, 

503 event_bus: EventBus | None = None, 

504) -> IssueProcessingResult: 

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

506 

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

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

509 

510 Args: 

511 info: Issue information 

512 config: Project configuration 

513 logger: Logger for output 

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

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

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

517 on_usage: Optional callback invoked with (input_tokens, output_tokens) from 

518 each stream-json result event. Passed through to all run_claude_command calls. 

519 

520 Returns: 

521 IssueProcessingResult with outcome details 

522 """ 

523 issue_start_time = time.time() 

524 corrections: list[str] = [] 

525 

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

527 

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

529 

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

531 validated_via_fallback = False 

532 

533 # Build on_usage closure that writes result_token_count to the context state file. 

534 # Mirrors the on_model_detected closure pattern in AutoManager._process_issue. 

535 _state_file = (config.repo_path or Path.cwd()) / ".ll" / "ll-context-state.json" 

536 _external_on_usage = on_usage 

537 

538 def _on_usage_writer(input_tokens: int, output_tokens: int) -> None: 

539 try: 

540 state = json.loads(_state_file.read_text()) if _state_file.exists() else {} 

541 state["result_token_count"] = input_tokens + output_tokens 

542 _state_file.write_text(json.dumps(state)) 

543 except Exception: 

544 pass # never block execution on state write failures 

545 if _external_on_usage is not None: 

546 _external_on_usage(input_tokens, output_tokens) 

547 

548 # Phase 1: Ready/verify the issue 

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

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

551 if not dry_run: 

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

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

554 result = run_claude_command( 

555 _ready_cmd, 

556 logger, 

557 timeout=config.automation.timeout_seconds, 

558 stream_output=config.automation.stream_output, 

559 idle_timeout=config.automation.idle_timeout_seconds, 

560 on_model_detected=on_model_detected, 

561 preview_full=preview_full, 

562 ) 

563 if result.returncode != 0: 

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

565 else: 

566 # Parse the verdict from the output 

567 parsed = parse_ready_issue_output(result.stdout) 

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

569 

570 # Validate that ready-issue analyzed the expected file 

571 validated_path = parsed.get("validated_file_path") 

572 if validated_path: 

573 # Normalize paths for comparison (resolve to absolute) 

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

575 # Handle both absolute and relative paths from ready_issue 

576 validated_resolved = Path(validated_path).resolve() 

577 if str(validated_resolved) != expected_path: 

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

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

580 old_file_exists = info.path.exists() 

581 new_file_exists = validated_resolved.exists() 

582 

583 if new_file_exists and not old_file_exists: 

584 # ready-issue renamed the file - update tracking 

585 logger.info( 

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

587 f"'{validated_resolved.name}'" 

588 ) 

589 info.path = validated_resolved 

590 else: 

591 # Genuine mismatch - attempt fallback with explicit path 

592 logger.warning( 

593 f"Path mismatch: ready-issue validated " 

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

595 ) 

596 logger.info( 

597 "Attempting fallback: retrying ready-issue " 

598 "with explicit file path..." 

599 ) 

600 

601 # Compute relative path for the command 

602 relative_path = _compute_relative_path(info.path) 

603 

604 # Retry with explicit path 

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

606 _retry_cmd = ( 

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

608 or _retry_slash 

609 ) 

610 retry_result = run_claude_command( 

611 _retry_cmd, 

612 logger, 

613 timeout=config.automation.timeout_seconds, 

614 stream_output=config.automation.stream_output, 

615 idle_timeout=config.automation.idle_timeout_seconds, 

616 on_model_detected=on_model_detected, 

617 preview_full=preview_full, 

618 ) 

619 

620 if retry_result.returncode != 0: 

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

622 return IssueProcessingResult( 

623 success=False, 

624 duration=time.time() - issue_start_time, 

625 issue_id=info.issue_id, 

626 failure_reason="Fallback failed after path mismatch", 

627 ) 

628 

629 # Re-parse and validate retry output 

630 retry_parsed = parse_ready_issue_output(retry_result.stdout) 

631 retry_validated_path = retry_parsed.get("validated_file_path") 

632 

633 if retry_validated_path: 

634 retry_resolved = Path(retry_validated_path).resolve() 

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

636 logger.error( 

637 f"Fallback still mismatched: " 

638 f"got '{retry_validated_path}', " 

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

640 ) 

641 return IssueProcessingResult( 

642 success=False, 

643 duration=time.time() - issue_start_time, 

644 issue_id=info.issue_id, 

645 failure_reason="Path mismatch persisted after fallback", 

646 ) 

647 

648 # Fallback succeeded - use retry result 

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

650 parsed = retry_parsed 

651 validated_via_fallback = True 

652 

653 # Log and store any corrections made 

654 if parsed.get("was_corrected"): 

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

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

657 for correction in phase_corrections: 

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

659 if phase_corrections: 

660 corrections.extend(phase_corrections) 

661 

662 # Log any concerns found 

663 if parsed["concerns"]: 

664 for concern in parsed["concerns"]: 

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

666 

667 # Handle CLOSE verdict - issue should not be implemented 

668 if parsed.get("should_close"): 

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

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

671 

672 # CRITICAL: Skip file operations for invalid references 

673 if close_reason == "invalid_ref": 

674 logger.warning( 

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

676 "no matching issue file exists" 

677 ) 

678 return IssueProcessingResult( 

679 success=False, 

680 duration=time.time() - issue_start_time, 

681 issue_id=info.issue_id, 

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

683 corrections=corrections, 

684 ) 

685 

686 # Also require validated_file_path to match before closing 

687 close_validated_path = parsed.get("validated_file_path") 

688 if not close_validated_path: 

689 logger.warning( 

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

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

692 ) 

693 return IssueProcessingResult( 

694 success=False, 

695 duration=time.time() - issue_start_time, 

696 issue_id=info.issue_id, 

697 failure_reason="CLOSE without validated file path", 

698 corrections=corrections, 

699 ) 

700 

701 if close_issue( 

702 info, 

703 config, 

704 logger, 

705 close_reason, 

706 parsed.get("close_status"), 

707 event_bus=event_bus, 

708 ): 

709 return IssueProcessingResult( 

710 success=True, 

711 duration=time.time() - issue_start_time, 

712 issue_id=info.issue_id, 

713 was_closed=True, 

714 corrections=corrections, 

715 ) 

716 else: 

717 return IssueProcessingResult( 

718 success=False, 

719 duration=time.time() - issue_start_time, 

720 issue_id=info.issue_id, 

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

722 corrections=corrections, 

723 ) 

724 

725 # Handle BLOCKED verdict - issue has open dependencies 

726 if parsed.get("is_blocked"): 

727 logger.warning( 

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

729 ) 

730 return IssueProcessingResult( 

731 success=False, 

732 was_blocked=True, 

733 duration=time.time() - issue_start_time, 

734 issue_id=info.issue_id, 

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

736 corrections=corrections, 

737 ) 

738 

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

740 if not parsed["is_ready"]: 

741 logger.error( 

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

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

744 ) 

745 return IssueProcessingResult( 

746 success=False, 

747 duration=time.time() - issue_start_time, 

748 issue_id=info.issue_id, 

749 failure_reason=( 

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

751 ), 

752 corrections=corrections, 

753 ) 

754 

755 # Log if proceeding with corrected issue 

756 if parsed.get("was_corrected"): 

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

758 else: 

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

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

761 

762 # Decision gate: invoke decide-issue when the issue requires a decision 

763 if info.decision_needed is True and not dry_run: 

764 logger.info( 

765 f"Decision gate: {info.issue_id} has decision_needed=True, invoking decide-issue..." 

766 ) 

767 _decide_slash = f"/ll:decide-issue {info.issue_id} --auto" 

768 _decide_cmd = ( 

769 expand_skill("decide-issue", [info.issue_id, "--auto"], config) or _decide_slash 

770 ) 

771 decide_result = run_claude_command( 

772 _decide_cmd, 

773 logger, 

774 timeout=config.automation.timeout_seconds, 

775 stream_output=config.automation.stream_output, 

776 idle_timeout=config.automation.idle_timeout_seconds, 

777 on_model_detected=on_model_detected, 

778 preview_full=preview_full, 

779 ) 

780 if decide_result.returncode != 0: 

781 logger.warning("decide-issue command failed, continuing to implementation anyway...") 

782 

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

784 action = config.get_category_action(info.issue_type) 

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

786 _baseline_sha_result = subprocess.run( 

787 ["git", "rev-parse", "HEAD"], capture_output=True, text=True 

788 ) 

789 _baseline_sha: str | None = ( 

790 _baseline_sha_result.stdout.strip() if _baseline_sha_result.returncode == 0 else None 

791 ) 

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

793 if not dry_run: 

794 # Build manage-issue command 

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

796 

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

798 if validated_via_fallback: 

799 issue_arg = _compute_relative_path(info.path) 

800 else: 

801 issue_arg = info.issue_id 

802 

803 # Use run_with_continuation to handle context exhaustion. 

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

805 # Pass the short slash command as resume_command so continuation 

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

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

808 _initial_cmd = ( 

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

810 ) 

811 result = run_with_continuation( 

812 _initial_cmd, 

813 logger, 

814 timeout=config.automation.timeout_seconds, 

815 stream_output=config.automation.stream_output, 

816 max_continuations=config.automation.max_continuations, 

817 repo_path=config.repo_path, 

818 idle_timeout=config.automation.idle_timeout_seconds, 

819 resume_command=_slash_cmd, 

820 on_usage=_on_usage_writer, 

821 preview_full=preview_full, 

822 issue_path=info.path, 

823 ) 

824 else: 

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

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

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

828 

829 # Handle implementation failure 

830 if result.returncode != 0: 

831 # Guard: if the issue's frontmatter already shows status: done by the 

832 # subprocess (e.g., a guillotine fresh session that finished and then 

833 # triggered a spurious Option E --continue failure), treat as success 

834 # so Phase 3 runs. 

835 already_done = False 

836 if info.path.exists(): 

837 try: 

838 from little_loops.frontmatter import parse_frontmatter 

839 

840 _fm = parse_frontmatter(info.path.read_text(encoding="utf-8")) 

841 already_done = _fm.get("status") in ("done", "completed", "cancelled") 

842 except Exception: 

843 already_done = False 

844 if already_done: 

845 logger.warning( 

846 f"Phase 2 exited non-zero but {info.issue_id} status is done/cancelled; " 

847 "treating as success (continuation artefact)" 

848 ) 

849 result = subprocess.CompletedProcess( 

850 args=result.args, returncode=0, stdout=result.stdout, stderr=result.stderr 

851 ) 

852 else: 

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

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

855 

856 if failure_type == FailureType.TRANSIENT: 

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

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

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

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

861 logger.info(error_output[:500]) 

862 

863 return IssueProcessingResult( 

864 success=False, 

865 duration=time.time() - issue_start_time, 

866 issue_id=info.issue_id, 

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

868 corrections=corrections, 

869 ) 

870 

871 # Real failure - create issue as before 

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

873 

874 failure_reason = "" 

875 if not dry_run: 

876 # Create new issue for the failure 

877 new_issue = create_issue_from_failure( 

878 error_output, 

879 info, 

880 config, 

881 logger, 

882 event_bus=event_bus, 

883 ) 

884 failure_reason = str(new_issue) if new_issue else error_output 

885 else: 

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

887 

888 return IssueProcessingResult( 

889 success=False, 

890 duration=time.time() - issue_start_time, 

891 issue_id=info.issue_id, 

892 failure_reason=failure_reason, 

893 corrections=corrections, 

894 ) 

895 

896 # Phase 3: Verify completion 

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

898 verified = False 

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

900 if not dry_run: 

901 verified = verify_issue_completed(info, config, logger) 

902 

903 # Fallback: Only complete lifecycle if: 

904 # 1. Command returned success (returncode 0) 

905 # 2. File wasn't moved to completed 

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

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

908 # Check if a plan was created awaiting approval 

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

910 if plan_path is not None: 

911 logger.info( 

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

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

914 ) 

915 return IssueProcessingResult( 

916 success=False, 

917 duration=time.time() - issue_start_time, 

918 issue_id=info.issue_id, 

919 plan_created=True, 

920 plan_path=str(plan_path), 

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

922 corrections=corrections, 

923 ) 

924 

925 logger.info( 

926 "Command returned success but issue not moved - " 

927 "checking for evidence of work..." 

928 ) 

929 

930 # Check issue file content for implementation markers 

931 if check_content_markers(info.path): 

932 logger.info( 

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

934 ) 

935 verified = complete_issue_lifecycle(info, config, logger, event_bus=event_bus) 

936 if verified: 

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

938 else: 

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

940 else: 

941 # CRITICAL: Verify actual implementation work was done 

942 work_done = verify_work_was_done(logger, baseline_sha=_baseline_sha) 

943 if work_done: 

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

945 verified = complete_issue_lifecycle( 

946 info, config, logger, event_bus=event_bus 

947 ) 

948 if verified: 

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

950 else: 

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

952 else: 

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

954 logger.error( 

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

956 "no code changes detected despite returncode 0" 

957 ) 

958 logger.error( 

959 "This likely indicates the command was not executed " 

960 "properly. Check command invocation and Claude CLI " 

961 "output." 

962 ) 

963 verified = False 

964 else: 

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

966 verified = True # In dry run, assume success 

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

968 

969 # Record timing 

970 total_issue_time = time.time() - issue_start_time 

971 issue_timing["total"] = total_issue_time 

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

973 

974 if verified: 

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

976 else: 

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

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

979 

980 return IssueProcessingResult( 

981 success=verified, 

982 duration=total_issue_time, 

983 issue_id=info.issue_id, 

984 corrections=corrections, 

985 ) 

986 

987 

988class AutoManager: 

989 """Automated issue manager for sequential processing. 

990 

991 Processes issues in priority order using Claude CLI commands, 

992 with state persistence for resume capability. 

993 """ 

994 

995 def __init__( 

996 self, 

997 config: BRConfig, 

998 dry_run: bool = False, 

999 max_issues: int = 0, 

1000 resume: bool = False, 

1001 category: str | None = None, 

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

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

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

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

1006 label_filter: set[str] | None = None, 

1007 verbose: bool = True, 

1008 preview_full: bool = False, 

1009 db_path: Path | None = None, 

1010 ) -> None: 

1011 """Initialize the auto manager. 

1012 

1013 Args: 

1014 config: Project configuration 

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

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

1017 resume: Whether to resume from previous state 

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

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

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

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

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

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

1024 label_filter: If provided, only process issues that have at least one of these labels 

1025 verbose: Whether to output progress messages 

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

1027 """ 

1028 self.config = config 

1029 self.dry_run = dry_run 

1030 self.max_issues = max_issues 

1031 self.resume = resume 

1032 self.category = category 

1033 self.only_ids = only_ids 

1034 self.skip_ids = skip_ids or set() 

1035 self.type_prefixes = type_prefixes 

1036 self.priority_filter = priority_filter 

1037 self.label_filter = label_filter 

1038 self._preview_full = preview_full 

1039 

1040 from little_loops.cli.output import use_color_enabled 

1041 

1042 self.logger = Logger(verbose=verbose, use_color=use_color_enabled()) 

1043 self.event_bus = EventBus() 

1044 self.event_bus.add_transport(SQLiteTransport(db_path or DEFAULT_DB_PATH)) 

1045 self.state_manager = StateManager( 

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

1047 ) 

1048 self.parser = IssueParser(config) 

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

1050 

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

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

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

1054 all_known_ids: set[str] | None = None 

1055 try: 

1056 from little_loops.dependency_mapper import gather_all_issue_ids 

1057 

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

1059 all_known_ids = gather_all_issue_ids(issues_dir, config=config) 

1060 except Exception: 

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

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

1063 

1064 # Warn about any cycles 

1065 if self.dep_graph.has_cycles(): 

1066 cycles = self.dep_graph.detect_cycles() 

1067 for cycle in cycles: 

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

1069 

1070 self.processed_count = 0 

1071 self._shutdown_requested = False 

1072 

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

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

1075 

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

1077 """Handle shutdown signals gracefully.""" 

1078 self._shutdown_requested = True 

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

1080 

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

1082 """Get next issue respecting dependencies. 

1083 

1084 Returns the highest priority issue whose blockers have all been 

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

1086 logs warnings about what is blocking progress. 

1087 

1088 Returns: 

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

1090 """ 

1091 # Get completed issues from state 

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

1093 

1094 # Combine skip_ids from state and CLI argument 

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

1096 

1097 # Get issues that are ready (blockers satisfied) 

1098 ready_issues = self.dep_graph.get_ready_issues(completed) 

1099 

1100 # Filter by skip_ids, only_ids, type_prefixes, priority_filter, label_filter 

1101 candidates = [ 

1102 i 

1103 for i in ready_issues 

1104 if i.issue_id not in skip_ids 

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

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

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

1108 and ( 

1109 self.label_filter is None or any(lb.lower() in self.label_filter for lb in i.labels) 

1110 ) 

1111 ] 

1112 

1113 if candidates: 

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

1115 only_ids = self.only_ids 

1116 if isinstance(only_ids, list): 

1117 candidates.sort( 

1118 key=lambda x: next( 

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

1120 len(only_ids), 

1121 ) 

1122 ) 

1123 return candidates[0] 

1124 

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

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

1127 remaining = all_in_graph - completed - skip_ids 

1128 if self.only_ids is not None: 

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

1130 if self.type_prefixes is not None: 

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

1132 if self.priority_filter is not None: 

1133 remaining = { 

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

1135 } 

1136 if self.label_filter is not None: 

1137 remaining = { 

1138 r 

1139 for r in remaining 

1140 if any(lb.lower() in self.label_filter for lb in self.dep_graph.issues[r].labels) 

1141 } 

1142 

1143 if remaining: 

1144 self._log_blocked_issues(remaining, completed) 

1145 

1146 return None 

1147 

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

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

1150 

1151 Args: 

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

1153 completed: Set of completed issue IDs 

1154 """ 

1155 blocked_count = 0 

1156 for issue_id in sorted(remaining): 

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

1158 if blockers: 

1159 blocked_count += 1 

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

1161 

1162 if blocked_count > 0: 

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

1164 

1165 def run(self) -> int: 

1166 """Run the automation loop. 

1167 

1168 Returns: 

1169 Exit code: 0 = success or empty queue, 1 = all issues gate-blocked when --only used 

1170 """ 

1171 run_start_time = time.time() 

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

1173 

1174 if self.dry_run: 

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

1176 

1177 if not self.dry_run: 

1178 has_changes = check_git_status(self.logger) 

1179 if has_changes: 

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

1181 

1182 # Load or initialize state 

1183 if self.resume: 

1184 state = self.state_manager.load() 

1185 if state: 

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

1187 self.processed_count = len(state.completed_issues) 

1188 else: 

1189 # Fresh start 

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

1191 

1192 attempted_count = 0 

1193 try: 

1194 while not self._shutdown_requested: 

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

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

1197 break 

1198 

1199 info = self._get_next_issue() 

1200 if not info: 

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

1202 break 

1203 

1204 attempted_count += 1 

1205 success = self._process_issue(info) 

1206 if success: 

1207 self.processed_count += 1 

1208 

1209 except Exception as e: 

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

1211 return 1 

1212 

1213 finally: 

1214 if not self._shutdown_requested: 

1215 self.state_manager.cleanup() 

1216 self.event_bus.close_transports() 

1217 

1218 self._log_timing_summary(run_start_time) 

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

1220 if self.only_ids and attempted_count > 0 and self.processed_count == 0: 

1221 return 1 

1222 return 0 

1223 

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

1225 """Log aggregate timing summary.""" 

1226 total_run_time = time.time() - run_start_time 

1227 

1228 self.logger.info("") 

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

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

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

1232 

1233 state = self.state_manager.state 

1234 if state.timing: 

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

1236 if total_times: 

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

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

1239 

1240 if state.failed_issues: 

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

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

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

1244 

1245 # Log correction statistics for quality tracking 

1246 if state.corrections: 

1247 total_corrected = len(state.corrections) 

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

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

1250 self.logger.info( 

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

1252 ) 

1253 

1254 # Log most common correction types 

1255 from collections import Counter 

1256 

1257 all_corrections: list[str] = [] 

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

1259 all_corrections.extend(corrections) 

1260 if all_corrections: 

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

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

1263 for correction, count in common: 

1264 # Truncate long correction descriptions 

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

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

1267 

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

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

1270 

1271 Delegates to process_issue_inplace() and maps the result back 

1272 to state manager calls. 

1273 

1274 Args: 

1275 info: Issue information 

1276 

1277 Returns: 

1278 True if processing succeeded 

1279 """ 

1280 # Pre-processing state updates (before delegating) 

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

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

1283 

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

1285 if not self._detected_model: 

1286 

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

1288 self._detected_model.append(m) 

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

1290 

1291 result = process_issue_inplace( 

1292 info, 

1293 self.config, 

1294 self.logger, 

1295 self.dry_run, 

1296 on_model_detected=on_model, 

1297 preview_full=self._preview_full, 

1298 event_bus=self.event_bus, 

1299 ) 

1300 

1301 # Map result back to state tracking 

1302 if result.was_closed: 

1303 self.state_manager.mark_completed(info.issue_id) 

1304 elif result.was_blocked: 

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

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

1307 elif result.success: 

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

1309 elif result.plan_created: 

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

1311 self.logger.info( 

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

1313 "leaving in pending state for manual approval" 

1314 ) 

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

1316 elif result.failure_reason: 

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

1318 

1319 if result.corrections: 

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

1321 

1322 return result.success