Coverage for little_loops / issue_manager.py: 10%

537 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-16 13:08 -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 

19from typing import TYPE_CHECKING 

20 

21if TYPE_CHECKING: 

22 from little_loops.parallel.types import SprintWorkerContext 

23 

24from little_loops.cli_args import _id_matches 

25from little_loops.config import BRConfig 

26from little_loops.dependency_graph import DependencyGraph 

27from little_loops.events import EventBus 

28from little_loops.git_operations import check_git_status, verify_work_was_done 

29from little_loops.issue_lifecycle import ( 

30 FailureType, 

31 classify_failure, 

32 close_issue, 

33 complete_issue_lifecycle, 

34 create_issue_from_failure, 

35 verify_issue_completed, 

36) 

37from little_loops.issue_parser import IssueInfo, IssueParser, find_issues 

38from little_loops.logger import Logger, format_duration 

39from little_loops.output_parsing import parse_ready_issue_output 

40from little_loops.session_store import DEFAULT_DB_PATH, SQLiteTransport 

41from little_loops.skill_expander import expand_skill 

42from little_loops.state import ProcessingState, StateManager, _iso_now 

43from little_loops.subprocess_utils import ( 

44 assemble_guillotine_prompt, 

45 detect_context_handoff, 

46 read_continuation_prompt, 

47 read_sentinel, 

48 write_sentinel, 

49) 

50from little_loops.subprocess_utils import ( 

51 run_claude_command as _run_claude_base, 

52) 

53 

54 

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

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

57 

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

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

60 

61 Args: 

62 abs_path: Absolute path to the file 

63 base_dir: Base directory (defaults to cwd) 

64 

65 Returns: 

66 Relative path string suitable for ready-issue command 

67 """ 

68 base = base_dir or Path.cwd() 

69 try: 

70 return str(abs_path.relative_to(base)) 

71 except ValueError: 

72 # Path not relative to base, use absolute 

73 return str(abs_path) 

74 

75 

76@contextmanager 

77def timed_phase( 

78 logger: Logger, 

79 phase_name: str, 

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

81 """Context manager for timing phases. 

82 

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

84 

85 Args: 

86 logger: Logger for output 

87 phase_name: Name of the phase being timed 

88 

89 Yields: 

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

91 """ 

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

93 start = time.time() 

94 try: 

95 yield timing_result 

96 finally: 

97 elapsed = time.time() - start 

98 timing_result["elapsed"] = elapsed 

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

100 

101 

102def run_claude_command( 

103 command: str, 

104 logger: Logger, 

105 timeout: int = 3600, 

106 stream_output: bool = True, 

107 idle_timeout: int = 0, 

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

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

110 preview_full: bool = False, 

111 resume_session: bool = False, 

112) -> subprocess.CompletedProcess[str]: 

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

114 

115 Args: 

116 command: Command to pass to Claude CLI 

117 logger: Logger for output 

118 timeout: Timeout in seconds 

119 stream_output: Whether to stream output to console 

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

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

122 stream-json system/init event. 

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

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

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

126 

127 Returns: 

128 CompletedProcess with stdout/stderr captured 

129 """ 

130 from little_loops.cli.output import terminal_width 

131 

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

133 line_count = len(lines) 

134 tw = terminal_width() 

135 max_line = tw - 4 

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

137 logger.info( 

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

139 ) 

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

141 for line in lines[:show_count]: 

142 display = ( 

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

144 ) 

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

146 if line_count > show_count: 

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

148 

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

150 if stream_output: 

151 if is_stderr: 

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

153 else: 

154 print(f" {line}") 

155 

156 return _run_claude_base( 

157 command=command, 

158 timeout=timeout, 

159 stream_callback=stream_callback if stream_output else None, 

160 idle_timeout=idle_timeout, 

161 on_model_detected=on_model_detected, 

162 on_usage=on_usage, 

163 resume_session=resume_session, 

164 ) 

165 

166 

167def _check_issue_already_done( 

168 issue_path: Path | None, 

169 logger: Logger, 

170) -> bool: 

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

172 

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

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

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

176 return success rather than triggering an unnecessary handoff cycle. 

177 

178 Args: 

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

180 logger: Logger for diagnostic messages. 

181 

182 Returns: 

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

184 """ 

185 if issue_path is None: 

186 return False 

187 if not issue_path.exists(): 

188 return False 

189 try: 

190 from little_loops.frontmatter import parse_frontmatter 

191 

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

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

194 except Exception: 

195 return False 

196 

197 

198def run_with_continuation( 

199 initial_command: str, 

200 logger: Logger, 

201 timeout: int = 3600, 

202 stream_output: bool = True, 

203 max_continuations: int = 3, 

204 repo_path: Path | None = None, 

205 idle_timeout: int = 0, 

206 resume_command: str | None = None, 

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

208 preview_full: bool = False, 

209 context_limit: int = 200_000, 

210 sentinel_threshold: float = 0.60, 

211 guillotine_threshold: float = 0.90, 

212 issue_path: Path | None = None, 

213 run_dir: str | None = None, 

214 sprint_context: SprintWorkerContext | None = None, 

215) -> subprocess.CompletedProcess[str]: 

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

217 

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

219 

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

221 on_usage exceeds sentinel_threshold without a CONTEXT_HANDOFF signal, writes 

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

223 

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

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

226 instruction, triggering the standard CONTEXT_HANDOFF continuation flow. 

227 

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

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

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

231 

232 Args: 

233 initial_command: Initial command to run 

234 logger: Logger for output 

235 timeout: Timeout per session in seconds 

236 stream_output: Whether to stream output 

237 max_continuations: Maximum number of continuation attempts 

238 repo_path: Repository root path 

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

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

241 ``--resume`` to ``initial_command``. 

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

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

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

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

246 

247 Returns: 

248 Final CompletedProcess result 

249 """ 

250 all_stdout: list[str] = [] 

251 all_stderr: list[str] = [] 

252 current_command = initial_command 

253 continuation_count = 0 

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

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

256 ) 

257 

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

259 _last_input: list[int] = [0] 

260 _last_output: list[int] = [0] 

261 _external_on_usage = on_usage 

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

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

264 _just_ran_fresh_session = False 

265 

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

267 _last_input[0] = input_tokens 

268 _last_output[0] = output_tokens 

269 if _external_on_usage is not None: 

270 _external_on_usage(input_tokens, output_tokens) 

271 

272 while continuation_count <= max_continuations: 

273 this_is_fresh = _just_ran_fresh_session 

274 _just_ran_fresh_session = False 

275 result = run_claude_command( 

276 current_command, 

277 logger, 

278 timeout=timeout, 

279 stream_output=stream_output, 

280 idle_timeout=idle_timeout, 

281 on_usage=_tracking_usage, 

282 preview_full=preview_full, 

283 ) 

284 

285 all_stdout.append(result.stdout) 

286 all_stderr.append(result.stderr) 

287 

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

289 if detect_context_handoff(result.stdout): 

290 logger.info("Detected CONTEXT_HANDOFF signal") 

291 

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

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

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

295 already_done = _check_issue_already_done(issue_path, logger) 

296 if already_done: 

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

298 result = subprocess.CompletedProcess( 

299 args=result.args, 

300 returncode=0, 

301 stdout=result.stdout, 

302 stderr=result.stderr, 

303 ) 

304 break 

305 

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

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

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

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

310 handoff_message = "CONTEXT_HANDOFF: Ready for fresh session" 

311 print(handoff_message) 

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

313 

314 result = subprocess.CompletedProcess( 

315 args=result.args, 

316 returncode=0, 

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

318 stderr=result.stderr, 

319 ) 

320 break 

321 

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

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

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

325 

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

327 # When run_dir is set (loop context), write a resume file and invoke /ll:resume. 

328 # Otherwise fall back to the transcript-summary blob (non-loop ll-auto runs). 

329 if ( 

330 prompt_too_long or usage_ratio >= guillotine_threshold 

331 ) and continuation_count < max_continuations: 

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

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

334 if run_dir is not None: 

335 try: 

336 guillotine_file = Path(run_dir) / "guillotine-prompt.md" 

337 guillotine_file.parent.mkdir(parents=True, exist_ok=True) 

338 task_first_line = (initial_command.strip().splitlines() or [""])[0] 

339 sprint_framing = "" 

340 if sprint_context is not None: 

341 sprint_framing = ( 

342 f"## Sprint Worker Context\n" 

343 f"You are a sprint worker. Process exactly ONE issue: " 

344 f"{sprint_context.issue_id}\n" 

345 f"After completing this issue, exit immediately — " 

346 f"do NOT process other issues.\n" 

347 f"Do NOT ask for further instructions. Exit with code 0.\n" 

348 f"Branch: {sprint_context.branch}\n\n" 

349 ) 

350 guillotine_file.write_text( 

351 sprint_framing + f"## Intent\n" 

352 f"Resume an interrupted automation session that hit the context limit.\n" 

353 f"Original task: {task_first_line}\n" 

354 f"Trigger reason: {trigger_reason} " 

355 f"({_last_input[0] + _last_output[0]:,} / {context_limit:,} tokens)\n" 

356 f"\n" 

357 f"## Next Steps\n" 

358 f"1. Check `git log` to see what was committed in the previous session\n" 

359 f"2. Check the issue file status — if already done/cancelled, stop\n" 

360 f"3. Review `.loops/tmp/scratch/` for partial progress notes\n" 

361 f"4. Continue the original task from where it left off, " 

362 f"skipping already-completed work\n", 

363 encoding="utf-8", 

364 ) 

365 guillotine_cmd = f"/ll:resume {guillotine_file}" 

366 logger.info(f"Option J resume file written: {guillotine_file}") 

367 except Exception as exc: 

368 logger.warning( 

369 f"Failed to write guillotine resume file ({exc}), " 

370 "falling back to summary blob" 

371 ) 

372 guillotine_cmd = initial_command 

373 else: 

374 try: 

375 guillotine_cmd = assemble_guillotine_prompt( 

376 original_command=initial_command, 

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

378 token_stats={ 

379 "input_tokens": _last_input[0], 

380 "output_tokens": _last_output[0], 

381 "context_limit": context_limit, 

382 "trigger_reason": trigger_reason, 

383 }, 

384 sprint_context=sprint_context, 

385 ) 

386 except Exception as exc: 

387 logger.warning( 

388 f"Failed to assemble guillotine prompt ({exc}), using bare restart" 

389 ) 

390 guillotine_cmd = initial_command 

391 continuation_count += 1 

392 current_command = guillotine_cmd 

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

394 _last_input[0] = 0 

395 _last_output[0] = 0 

396 _just_ran_fresh_session = True 

397 continue 

398 

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

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

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

402 sentinel_data = read_sentinel(repo_path) 

403 if sentinel_data is not None and this_is_fresh: 

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

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

406 logger.info( 

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

408 ) 

409 elif sentinel_data is not None and continuation_count < max_continuations: 

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

411 logger.info( 

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

413 "sending explicit handoff instruction via --continue" 

414 ) 

415 continuation_count += 1 

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

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

418 explicit_handoff_instruction = ( 

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

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

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

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

423 ) 

424 current_command = explicit_handoff_instruction 

425 # Reset tracking for the handoff turn 

426 _last_input[0] = 0 

427 _last_output[0] = 0 

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

429 result = run_claude_command( 

430 current_command, 

431 logger, 

432 timeout=timeout, 

433 stream_output=stream_output, 

434 idle_timeout=idle_timeout, 

435 on_usage=_tracking_usage, 

436 preview_full=preview_full, 

437 resume_session=True, 

438 ) 

439 all_stdout.append(result.stdout) 

440 all_stderr.append(result.stderr) 

441 

442 # After explicit instruction, check for handoff signal 

443 if detect_context_handoff(result.stdout): 

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

445 prompt_content = read_continuation_prompt(repo_path) 

446 if prompt_content and continuation_count < max_continuations: 

447 continuation_count += 1 

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

449 _base = resume_command if resume_command is not None else initial_command 

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

451 _last_input[0] = 0 

452 _last_output[0] = 0 

453 continue 

454 break 

455 

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

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

458 if total_tokens > 0 and usage_ratio >= sentinel_threshold: 

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

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

461 

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

463 break 

464 

465 return subprocess.CompletedProcess( 

466 args=result.args, 

467 returncode=result.returncode, 

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

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

470 ) 

471 

472 

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

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

475 

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

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

478 

479 Args: 

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

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

482 

483 Returns: 

484 Path to plan file if created, None otherwise 

485 """ 

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

487 if not plans_dir.exists(): 

488 return None 

489 

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

491 # Use glob pattern with issue_id 

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

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

494 

495 if not matching_plans: 

496 return None 

497 

498 # Return the most recently modified plan file 

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

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

501 return latest_plan 

502 

503 

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

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

506 

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

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

509 

510 Args: 

511 issue_path: Path to the issue file 

512 

513 Returns: 

514 True if implementation markers found 

515 """ 

516 try: 

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

518 except (OSError, UnicodeDecodeError): 

519 return False 

520 

521 markers = [ 

522 "## Resolution", 

523 "Status: Implemented", 

524 "Status: Completed", 

525 "**Completed**:", 

526 ] 

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

528 

529 

530@dataclass 

531class IssueProcessingResult: 

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

533 

534 success: bool 

535 duration: float 

536 issue_id: str 

537 was_closed: bool = False 

538 was_blocked: bool = False 

539 failure_reason: str = "" 

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

541 plan_created: bool = False 

542 plan_path: str = "" 

543 

544 

545def process_issue_inplace( 

546 info: IssueInfo, 

547 config: BRConfig, 

548 logger: Logger, 

549 dry_run: bool = False, 

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

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

552 preview_full: bool = False, 

553 event_bus: EventBus | None = None, 

554 sprint_context: SprintWorkerContext | None = None, 

555) -> IssueProcessingResult: 

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

557 

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

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

560 

561 Args: 

562 info: Issue information 

563 config: Project configuration 

564 logger: Logger for output 

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

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

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

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

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

570 

571 Returns: 

572 IssueProcessingResult with outcome details 

573 """ 

574 issue_start_time = time.time() 

575 corrections: list[str] = [] 

576 

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

578 

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

580 

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

582 validated_via_fallback = False 

583 

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

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

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

587 _external_on_usage = on_usage 

588 

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

590 try: 

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

592 state["result_token_count"] = input_tokens + output_tokens 

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

594 except Exception: 

595 pass # never block execution on state write failures 

596 if _external_on_usage is not None: 

597 _external_on_usage(input_tokens, output_tokens) 

598 

599 # Phase 1: Ready/verify the issue 

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

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

602 if not dry_run: 

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

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

605 result = run_claude_command( 

606 _ready_cmd, 

607 logger, 

608 timeout=config.automation.timeout_seconds, 

609 stream_output=config.automation.stream_output, 

610 idle_timeout=config.automation.idle_timeout_seconds, 

611 on_model_detected=on_model_detected, 

612 preview_full=preview_full, 

613 ) 

614 if result.returncode != 0: 

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

616 else: 

617 # Parse the verdict from the output 

618 parsed = parse_ready_issue_output(result.stdout) 

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

620 

621 # Validate that ready-issue analyzed the expected file 

622 validated_path = parsed.get("validated_file_path") 

623 if validated_path: 

624 # Normalize paths for comparison (resolve to absolute) 

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

626 # Handle both absolute and relative paths from ready_issue 

627 validated_resolved = Path(validated_path).resolve() 

628 if str(validated_resolved) != expected_path: 

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

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

631 old_file_exists = info.path.exists() 

632 new_file_exists = validated_resolved.exists() 

633 

634 if new_file_exists and not old_file_exists: 

635 # ready-issue renamed the file - update tracking 

636 logger.info( 

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

638 f"'{validated_resolved.name}'" 

639 ) 

640 info.path = validated_resolved 

641 else: 

642 # Genuine mismatch - attempt fallback with explicit path 

643 logger.warning( 

644 f"Path mismatch: ready-issue validated " 

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

646 ) 

647 logger.info( 

648 "Attempting fallback: retrying ready-issue " 

649 "with explicit file path..." 

650 ) 

651 

652 # Compute relative path for the command 

653 relative_path = _compute_relative_path(info.path) 

654 

655 # Retry with explicit path 

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

657 _retry_cmd = ( 

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

659 or _retry_slash 

660 ) 

661 retry_result = run_claude_command( 

662 _retry_cmd, 

663 logger, 

664 timeout=config.automation.timeout_seconds, 

665 stream_output=config.automation.stream_output, 

666 idle_timeout=config.automation.idle_timeout_seconds, 

667 on_model_detected=on_model_detected, 

668 preview_full=preview_full, 

669 ) 

670 

671 if retry_result.returncode != 0: 

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

673 return IssueProcessingResult( 

674 success=False, 

675 duration=time.time() - issue_start_time, 

676 issue_id=info.issue_id, 

677 failure_reason="Fallback failed after path mismatch", 

678 ) 

679 

680 # Re-parse and validate retry output 

681 retry_parsed = parse_ready_issue_output(retry_result.stdout) 

682 retry_validated_path = retry_parsed.get("validated_file_path") 

683 

684 if retry_validated_path: 

685 retry_resolved = Path(retry_validated_path).resolve() 

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

687 logger.error( 

688 f"Fallback still mismatched: " 

689 f"got '{retry_validated_path}', " 

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

691 ) 

692 return IssueProcessingResult( 

693 success=False, 

694 duration=time.time() - issue_start_time, 

695 issue_id=info.issue_id, 

696 failure_reason="Path mismatch persisted after fallback", 

697 ) 

698 

699 # Fallback succeeded - use retry result 

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

701 parsed = retry_parsed 

702 validated_via_fallback = True 

703 

704 # Log and store any corrections made 

705 if parsed.get("was_corrected"): 

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

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

708 for correction in phase_corrections: 

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

710 if phase_corrections: 

711 corrections.extend(phase_corrections) 

712 

713 # Log any concerns found 

714 if parsed["concerns"]: 

715 for concern in parsed["concerns"]: 

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

717 

718 # Handle CLOSE verdict - issue should not be implemented 

719 if parsed.get("should_close"): 

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

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

722 

723 # CRITICAL: Skip file operations for invalid references 

724 if close_reason == "invalid_ref": 

725 logger.warning( 

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

727 "no matching issue file exists" 

728 ) 

729 return IssueProcessingResult( 

730 success=False, 

731 duration=time.time() - issue_start_time, 

732 issue_id=info.issue_id, 

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

734 corrections=corrections, 

735 ) 

736 

737 # Also require validated_file_path to match before closing 

738 close_validated_path = parsed.get("validated_file_path") 

739 if not close_validated_path: 

740 logger.warning( 

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

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

743 ) 

744 return IssueProcessingResult( 

745 success=False, 

746 duration=time.time() - issue_start_time, 

747 issue_id=info.issue_id, 

748 failure_reason="CLOSE without validated file path", 

749 corrections=corrections, 

750 ) 

751 

752 if close_issue( 

753 info, 

754 config, 

755 logger, 

756 close_reason, 

757 parsed.get("close_status"), 

758 event_bus=event_bus, 

759 ): 

760 return IssueProcessingResult( 

761 success=True, 

762 duration=time.time() - issue_start_time, 

763 issue_id=info.issue_id, 

764 was_closed=True, 

765 corrections=corrections, 

766 ) 

767 else: 

768 return IssueProcessingResult( 

769 success=False, 

770 duration=time.time() - issue_start_time, 

771 issue_id=info.issue_id, 

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

773 corrections=corrections, 

774 ) 

775 

776 # Handle BLOCKED verdict - issue has open dependencies 

777 if parsed.get("is_blocked"): 

778 logger.warning( 

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

780 ) 

781 return IssueProcessingResult( 

782 success=False, 

783 was_blocked=True, 

784 duration=time.time() - issue_start_time, 

785 issue_id=info.issue_id, 

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

787 corrections=corrections, 

788 ) 

789 

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

791 if not parsed["is_ready"]: 

792 logger.error( 

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

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

795 ) 

796 return IssueProcessingResult( 

797 success=False, 

798 duration=time.time() - issue_start_time, 

799 issue_id=info.issue_id, 

800 failure_reason=( 

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

802 ), 

803 corrections=corrections, 

804 ) 

805 

806 # Log if proceeding with corrected issue 

807 if parsed.get("was_corrected"): 

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

809 else: 

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

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

812 

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

814 if info.decision_needed is True and not dry_run: 

815 logger.info( 

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

817 ) 

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

819 _decide_cmd = ( 

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

821 ) 

822 decide_result = run_claude_command( 

823 _decide_cmd, 

824 logger, 

825 timeout=config.automation.timeout_seconds, 

826 stream_output=config.automation.stream_output, 

827 idle_timeout=config.automation.idle_timeout_seconds, 

828 on_model_detected=on_model_detected, 

829 preview_full=preview_full, 

830 ) 

831 if decide_result.returncode != 0: 

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

833 

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

835 action = config.get_category_action(info.issue_type) 

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

837 _baseline_sha_result = subprocess.run( 

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

839 ) 

840 _baseline_sha: str | None = ( 

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

842 ) 

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

844 if not dry_run: 

845 # Build manage-issue command 

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

847 

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

849 if validated_via_fallback: 

850 issue_arg = _compute_relative_path(info.path) 

851 else: 

852 issue_arg = info.issue_id 

853 

854 # Use run_with_continuation to handle context exhaustion. 

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

856 # Pass the short slash command as resume_command so continuation 

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

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

859 _initial_cmd = ( 

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

861 ) 

862 result = run_with_continuation( 

863 _initial_cmd, 

864 logger, 

865 timeout=config.automation.timeout_seconds, 

866 stream_output=config.automation.stream_output, 

867 max_continuations=config.automation.max_continuations, 

868 repo_path=config.repo_path, 

869 idle_timeout=config.automation.idle_timeout_seconds, 

870 resume_command=_slash_cmd, 

871 on_usage=_on_usage_writer, 

872 preview_full=preview_full, 

873 issue_path=info.path, 

874 sprint_context=sprint_context, 

875 ) 

876 else: 

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

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

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

880 

881 # Handle implementation failure 

882 if result.returncode != 0: 

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

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

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

886 # so Phase 3 runs. 

887 already_done = False 

888 if info.path.exists(): 

889 try: 

890 from little_loops.frontmatter import parse_frontmatter 

891 

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

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

894 except Exception: 

895 already_done = False 

896 if already_done: 

897 logger.warning( 

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

899 "treating as success (continuation artefact)" 

900 ) 

901 result = subprocess.CompletedProcess( 

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

903 ) 

904 else: 

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

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

907 

908 if failure_type == FailureType.TRANSIENT: 

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

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

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

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

913 logger.info(error_output[:500]) 

914 

915 return IssueProcessingResult( 

916 success=False, 

917 duration=time.time() - issue_start_time, 

918 issue_id=info.issue_id, 

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

920 corrections=corrections, 

921 ) 

922 

923 # Real failure - create issue as before 

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

925 

926 failure_reason = "" 

927 if not dry_run: 

928 # Create new issue for the failure 

929 new_issue = create_issue_from_failure( 

930 error_output, 

931 info, 

932 config, 

933 logger, 

934 event_bus=event_bus, 

935 ) 

936 failure_reason = str(new_issue) if new_issue else error_output 

937 else: 

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

939 

940 return IssueProcessingResult( 

941 success=False, 

942 duration=time.time() - issue_start_time, 

943 issue_id=info.issue_id, 

944 failure_reason=failure_reason, 

945 corrections=corrections, 

946 ) 

947 

948 # Phase 3: Verify completion 

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

950 verified = False 

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

952 if not dry_run: 

953 verified = verify_issue_completed(info, config, logger) 

954 

955 # Fallback: Only complete lifecycle if: 

956 # 1. Command returned success (returncode 0) 

957 # 2. File wasn't moved to completed 

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

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

960 # Check if a plan was created awaiting approval 

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

962 if plan_path is not None: 

963 logger.info( 

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

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

966 ) 

967 return IssueProcessingResult( 

968 success=False, 

969 duration=time.time() - issue_start_time, 

970 issue_id=info.issue_id, 

971 plan_created=True, 

972 plan_path=str(plan_path), 

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

974 corrections=corrections, 

975 ) 

976 

977 logger.info( 

978 "Command returned success but issue not moved - " 

979 "checking for evidence of work..." 

980 ) 

981 

982 # Check issue file content for implementation markers 

983 if check_content_markers(info.path): 

984 logger.info( 

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

986 ) 

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

988 if verified: 

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

990 else: 

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

992 else: 

993 # CRITICAL: Verify actual implementation work was done 

994 work_done = verify_work_was_done(logger, baseline_sha=_baseline_sha) 

995 if work_done: 

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

997 verified = complete_issue_lifecycle( 

998 info, config, logger, event_bus=event_bus 

999 ) 

1000 if verified: 

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

1002 else: 

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

1004 else: 

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

1006 logger.error( 

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

1008 "no code changes detected despite returncode 0" 

1009 ) 

1010 logger.error( 

1011 "This likely indicates the command was not executed " 

1012 "properly. Check command invocation and Claude CLI " 

1013 "output." 

1014 ) 

1015 verified = False 

1016 else: 

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

1018 verified = True # In dry run, assume success 

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

1020 

1021 # Record timing 

1022 total_issue_time = time.time() - issue_start_time 

1023 issue_timing["total"] = total_issue_time 

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

1025 

1026 if verified: 

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

1028 else: 

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

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

1031 

1032 return IssueProcessingResult( 

1033 success=verified, 

1034 duration=total_issue_time, 

1035 issue_id=info.issue_id, 

1036 corrections=corrections, 

1037 ) 

1038 

1039 

1040class AutoManager: 

1041 """Automated issue manager for sequential processing. 

1042 

1043 Processes issues in priority order using Claude CLI commands, 

1044 with state persistence for resume capability. 

1045 """ 

1046 

1047 def __init__( 

1048 self, 

1049 config: BRConfig, 

1050 dry_run: bool = False, 

1051 max_issues: int = 0, 

1052 resume: bool = False, 

1053 category: str | None = None, 

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

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

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

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

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

1059 verbose: bool = True, 

1060 preview_full: bool = False, 

1061 db_path: Path | None = None, 

1062 ) -> None: 

1063 """Initialize the auto manager. 

1064 

1065 Args: 

1066 config: Project configuration 

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

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

1069 resume: Whether to resume from previous state 

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

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

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

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

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

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

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

1077 verbose: Whether to output progress messages 

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

1079 """ 

1080 self.config = config 

1081 self.dry_run = dry_run 

1082 self.max_issues = max_issues 

1083 self.resume = resume 

1084 self.category = category 

1085 self.only_ids = only_ids 

1086 self.skip_ids = skip_ids or set() 

1087 self.type_prefixes = type_prefixes 

1088 self.priority_filter = priority_filter 

1089 self.label_filter = label_filter 

1090 self._preview_full = preview_full 

1091 

1092 from little_loops.cli.output import use_color_enabled 

1093 

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

1095 self.event_bus = EventBus() 

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

1097 self.state_manager = StateManager( 

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

1099 ) 

1100 self.parser = IssueParser(config) 

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

1102 

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

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

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

1106 all_known_ids: set[str] | None = None 

1107 try: 

1108 from little_loops.dependency_mapper import gather_all_issue_ids 

1109 

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

1111 all_known_ids = gather_all_issue_ids(issues_dir, config=config) 

1112 except Exception: 

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

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

1115 

1116 # Warn about any cycles 

1117 if self.dep_graph.has_cycles(): 

1118 cycles = self.dep_graph.detect_cycles() 

1119 for cycle in cycles: 

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

1121 

1122 self.processed_count = 0 

1123 self._shutdown_requested = False 

1124 

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

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

1127 

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

1129 """Handle shutdown signals gracefully.""" 

1130 self._shutdown_requested = True 

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

1132 

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

1134 """Get next issue respecting dependencies. 

1135 

1136 Returns the highest priority issue whose blockers have all been 

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

1138 logs warnings about what is blocking progress. 

1139 

1140 Returns: 

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

1142 """ 

1143 # Get completed issues from state 

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

1145 

1146 # Combine skip_ids from state and CLI argument 

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

1148 

1149 # Get issues that are ready (blockers satisfied) 

1150 ready_issues = self.dep_graph.get_ready_issues(completed) 

1151 

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

1153 candidates = [ 

1154 i 

1155 for i in ready_issues 

1156 if i.issue_id not in skip_ids 

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

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

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

1160 and ( 

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

1162 ) 

1163 ] 

1164 

1165 if candidates: 

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

1167 only_ids = self.only_ids 

1168 if isinstance(only_ids, list): 

1169 candidates.sort( 

1170 key=lambda x: next( 

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

1172 len(only_ids), 

1173 ) 

1174 ) 

1175 return candidates[0] 

1176 

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

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

1179 remaining = all_in_graph - completed - skip_ids 

1180 if self.only_ids is not None: 

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

1182 if self.type_prefixes is not None: 

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

1184 if self.priority_filter is not None: 

1185 remaining = { 

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

1187 } 

1188 if self.label_filter is not None: 

1189 remaining = { 

1190 r 

1191 for r in remaining 

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

1193 } 

1194 

1195 if remaining: 

1196 self._log_blocked_issues(remaining, completed) 

1197 

1198 return None 

1199 

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

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

1202 

1203 Args: 

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

1205 completed: Set of completed issue IDs 

1206 """ 

1207 blocked_count = 0 

1208 for issue_id in sorted(remaining): 

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

1210 if blockers: 

1211 blocked_count += 1 

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

1213 

1214 if blocked_count > 0: 

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

1216 

1217 def run(self) -> int: 

1218 """Run the automation loop. 

1219 

1220 Returns: 

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

1222 """ 

1223 run_start_time = time.time() 

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

1225 

1226 if self.dry_run: 

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

1228 

1229 if not self.dry_run: 

1230 has_changes = check_git_status(self.logger) 

1231 if has_changes: 

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

1233 

1234 # Load or initialize state 

1235 if self.resume: 

1236 state = self.state_manager.load() 

1237 if state: 

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

1239 self.processed_count = len(state.completed_issues) 

1240 else: 

1241 # Fresh start 

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

1243 

1244 attempted_count = 0 

1245 try: 

1246 while not self._shutdown_requested: 

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

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

1249 break 

1250 

1251 info = self._get_next_issue() 

1252 if not info: 

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

1254 break 

1255 

1256 attempted_count += 1 

1257 success = self._process_issue(info) 

1258 if success: 

1259 self.processed_count += 1 

1260 

1261 except Exception as e: 

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

1263 return 1 

1264 

1265 finally: 

1266 if not self._shutdown_requested: 

1267 self.state_manager.cleanup() 

1268 self.event_bus.close_transports() 

1269 

1270 self._log_timing_summary(run_start_time) 

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

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

1273 return 1 

1274 return 0 

1275 

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

1277 """Log aggregate timing summary.""" 

1278 total_run_time = time.time() - run_start_time 

1279 

1280 self.logger.info("") 

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

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

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

1284 

1285 state = self.state_manager.state 

1286 if state.timing: 

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

1288 if total_times: 

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

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

1291 

1292 if state.failed_issues: 

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

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

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

1296 

1297 # Log correction statistics for quality tracking 

1298 if state.corrections: 

1299 total_corrected = len(state.corrections) 

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

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

1302 self.logger.info( 

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

1304 ) 

1305 

1306 # Log most common correction types 

1307 from collections import Counter 

1308 

1309 all_corrections: list[str] = [] 

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

1311 all_corrections.extend(corrections) 

1312 if all_corrections: 

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

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

1315 for correction, count in common: 

1316 # Truncate long correction descriptions 

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

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

1319 

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

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

1322 

1323 Delegates to process_issue_inplace() and maps the result back 

1324 to state manager calls. 

1325 

1326 Args: 

1327 info: Issue information 

1328 

1329 Returns: 

1330 True if processing succeeded 

1331 """ 

1332 # Pre-processing state updates (before delegating) 

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

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

1335 

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

1337 if not self._detected_model: 

1338 

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

1340 self._detected_model.append(m) 

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

1342 

1343 result = process_issue_inplace( 

1344 info, 

1345 self.config, 

1346 self.logger, 

1347 self.dry_run, 

1348 on_model_detected=on_model, 

1349 preview_full=self._preview_full, 

1350 event_bus=self.event_bus, 

1351 ) 

1352 

1353 # Map result back to state tracking 

1354 if result.was_closed: 

1355 self.state_manager.mark_completed(info.issue_id) 

1356 elif result.was_blocked: 

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

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

1359 elif result.success: 

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

1361 elif result.plan_created: 

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

1363 self.logger.info( 

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

1365 "leaving in pending state for manual approval" 

1366 ) 

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

1368 elif result.failure_reason: 

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

1370 

1371 if result.corrections: 

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

1373 

1374 return result.success