Coverage for little_loops / issue_manager.py: 10%

549 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-26 17:38 -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 re 

11import signal 

12import subprocess 

13import sys 

14import time 

15from collections.abc import Callable, Generator 

16from contextlib import contextmanager 

17from dataclasses import dataclass, field 

18from pathlib import Path 

19from types import FrameType 

20from typing import TYPE_CHECKING 

21 

22if TYPE_CHECKING: 

23 from little_loops.parallel.types import SprintWorkerContext 

24 

25from little_loops.cli_args import _id_matches 

26from little_loops.config import BRConfig 

27from little_loops.context_window import context_window_for 

28from little_loops.dependency_graph import DependencyGraph 

29from little_loops.events import EventBus 

30from little_loops.git_operations import check_git_status, verify_work_was_done 

31from little_loops.issue_lifecycle import ( 

32 FailureType, 

33 classify_failure, 

34 close_issue, 

35 complete_issue_lifecycle, 

36 create_issue_from_failure, 

37 verify_issue_completed, 

38) 

39from little_loops.issue_parser import IssueInfo, IssueParser, find_issues 

40from little_loops.logger import Logger, format_duration 

41from little_loops.output_parsing import parse_ready_issue_output 

42from little_loops.session_store import DEFAULT_DB_PATH, SQLiteTransport 

43from little_loops.skill_expander import expand_skill 

44from little_loops.state import ProcessingState, StateManager, _iso_now 

45from little_loops.subprocess_utils import ( 

46 assemble_guillotine_prompt, 

47 detect_context_handoff, 

48 read_continuation_prompt, 

49 read_sentinel, 

50) 

51from little_loops.subprocess_utils import ( 

52 run_claude_command as _run_claude_base, 

53) 

54 

55 

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

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

58 

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

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

61 

62 Args: 

63 abs_path: Absolute path to the file 

64 base_dir: Base directory (defaults to cwd) 

65 

66 Returns: 

67 Relative path string suitable for ready-issue command 

68 """ 

69 base = base_dir or Path.cwd() 

70 try: 

71 return str(abs_path.relative_to(base)) 

72 except ValueError: 

73 # Path not relative to base, use absolute 

74 return str(abs_path) 

75 

76 

77@contextmanager 

78def timed_phase( 

79 logger: Logger, 

80 phase_name: str, 

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

82 """Context manager for timing phases. 

83 

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

85 

86 Args: 

87 logger: Logger for output 

88 phase_name: Name of the phase being timed 

89 

90 Yields: 

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

92 """ 

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

94 start = time.time() 

95 try: 

96 yield timing_result 

97 finally: 

98 elapsed = time.time() - start 

99 timing_result["elapsed"] = elapsed 

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

101 

102 

103def run_claude_command( 

104 command: str, 

105 logger: Logger, 

106 timeout: int = 3600, 

107 stream_output: bool = True, 

108 idle_timeout: int = 0, 

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

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

111 preview_full: bool = False, 

112 resume_session: bool = False, 

113) -> subprocess.CompletedProcess[str]: 

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

115 

116 Args: 

117 command: Command to pass to Claude CLI 

118 logger: Logger for output 

119 timeout: Timeout in seconds 

120 stream_output: Whether to stream output to console 

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

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

123 stream-json system/init event. 

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

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

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

127 

128 Returns: 

129 CompletedProcess with stdout/stderr captured 

130 """ 

131 from little_loops.cli.output import terminal_width 

132 

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

134 line_count = len(lines) 

135 tw = terminal_width() 

136 max_line = tw - 4 

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

138 logger.info( 

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

140 ) 

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

142 for line in lines[:show_count]: 

143 display = ( 

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

145 ) 

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

147 if line_count > show_count: 

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

149 

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

151 if stream_output: 

152 if is_stderr: 

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

154 else: 

155 print(f" {line}") 

156 

157 return _run_claude_base( 

158 command=command, 

159 timeout=timeout, 

160 stream_callback=stream_callback if stream_output else None, 

161 idle_timeout=idle_timeout, 

162 on_model_detected=on_model_detected, 

163 on_usage=on_usage, 

164 resume_session=resume_session, 

165 ) 

166 

167 

168def _check_issue_already_done( 

169 issue_path: Path | None, 

170 logger: Logger, 

171) -> bool: 

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

173 

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

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

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

177 return success rather than triggering an unnecessary handoff cycle. 

178 

179 Args: 

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

181 logger: Logger for diagnostic messages. 

182 

183 Returns: 

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

185 """ 

186 if issue_path is None: 

187 return False 

188 if not issue_path.exists(): 

189 return False 

190 try: 

191 from little_loops.frontmatter import parse_frontmatter 

192 

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

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

195 except Exception: 

196 return False 

197 

198 

199def run_with_continuation( 

200 initial_command: str, 

201 logger: Logger, 

202 timeout: int = 3600, 

203 stream_output: bool = True, 

204 max_continuations: int = 3, 

205 repo_path: Path | None = None, 

206 idle_timeout: int = 0, 

207 resume_command: str | None = None, 

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

209 preview_full: bool = False, 

210 context_limit: int = 200_000, 

211 issue_path: Path | None = None, 

212 run_dir: str | None = None, 

213 sprint_context: SprintWorkerContext | None = None, 

214) -> subprocess.CompletedProcess[str]: 

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

216 

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

218 

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

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

221 instruction, triggering the standard CONTEXT_HANDOFF continuation flow. 

222 

223 Option J (guillotine): if stderr contains "Prompt is too long", assembles a 

224 transcript-summary prompt and spawns a fresh session (not --resume) starting at 0 

225 tokens. The cumulative-token usage_ratio arm was removed (BUG-2280): cumulative 

226 result-event tokens are incommensurable with the single-window context limit and 

227 produced false-positive guillotine fires on every healthy multi-turn session. 

228 

229 Args: 

230 initial_command: Initial command to run 

231 logger: Logger for output 

232 timeout: Timeout per session in seconds 

233 stream_output: Whether to stream output 

234 max_continuations: Maximum number of continuation attempts 

235 repo_path: Repository root path 

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

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

238 ``--resume`` to ``initial_command``. 

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

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

241 

242 Returns: 

243 Final CompletedProcess result 

244 """ 

245 all_stdout: list[str] = [] 

246 all_stderr: list[str] = [] 

247 current_command = initial_command 

248 continuation_count = 0 

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

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

251 ) 

252 

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

254 _last_input: list[int] = [0] 

255 _last_output: list[int] = [0] 

256 _external_on_usage = on_usage 

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

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

259 _just_ran_fresh_session = False 

260 

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

262 _last_input[0] = input_tokens 

263 _last_output[0] = output_tokens 

264 if _external_on_usage is not None: 

265 _external_on_usage(input_tokens, output_tokens) 

266 

267 while continuation_count <= max_continuations: 

268 this_is_fresh = _just_ran_fresh_session 

269 _just_ran_fresh_session = False 

270 result = run_claude_command( 

271 current_command, 

272 logger, 

273 timeout=timeout, 

274 stream_output=stream_output, 

275 idle_timeout=idle_timeout, 

276 on_usage=_tracking_usage, 

277 preview_full=preview_full, 

278 ) 

279 

280 all_stdout.append(result.stdout) 

281 all_stderr.append(result.stderr) 

282 

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

284 if detect_context_handoff(result.stdout): 

285 logger.info("Detected CONTEXT_HANDOFF signal") 

286 

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

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

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

290 already_done = _check_issue_already_done(issue_path, logger) 

291 if already_done: 

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

293 result = subprocess.CompletedProcess( 

294 args=result.args, 

295 returncode=0, 

296 stdout=result.stdout, 

297 stderr=result.stderr, 

298 ) 

299 break 

300 

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

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

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

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

305 handoff_message = "CONTEXT_HANDOFF: Ready for fresh session" 

306 print(handoff_message) 

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

308 

309 result = subprocess.CompletedProcess( 

310 args=result.args, 

311 returncode=0, 

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

313 stderr=result.stderr, 

314 ) 

315 break 

316 

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

318 

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

320 # Fires only on the reliable "Prompt is too long" stderr signal (BUG-2280). 

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

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

323 if prompt_too_long and continuation_count < max_continuations: 

324 # Pre-continuation guard (BUG-2281): mirror the CONTEXT_HANDOFF branch — 

325 # if the issue is already done/cancelled, return success without spawning. 

326 if _check_issue_already_done(issue_path, logger): 

327 logger.info("Issue already done/cancelled; skipping Option J continuation") 

328 result = subprocess.CompletedProcess( 

329 args=result.args, 

330 returncode=0, 

331 stdout=result.stdout, 

332 stderr=result.stderr, 

333 ) 

334 break 

335 trigger_reason = "Prompt is too long" 

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

337 if run_dir is not None: 

338 try: 

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

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

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

342 sprint_framing = "" 

343 if sprint_context is not None: 

344 sprint_framing = ( 

345 f"## Sprint Worker Context\n" 

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

347 f"{sprint_context.issue_id}\n" 

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

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

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

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

352 ) 

353 elif issue_path is not None: 

354 _id_match = re.search(r"(BUG|FEAT|ENH|EPIC)-\d+", issue_path.name) 

355 if _id_match: 

356 sprint_framing = ( 

357 f"## Scope Constraint\n" 

358 f"Process exactly ONE issue: {_id_match.group()}\n" 

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

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

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

362 ) 

363 guillotine_file.write_text( 

364 sprint_framing + f"## Intent\n" 

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

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

367 f"Trigger reason: {trigger_reason} " 

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

369 f"\n" 

370 f"## Next Steps\n" 

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

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

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

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

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

376 encoding="utf-8", 

377 ) 

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

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

380 except Exception as exc: 

381 logger.warning( 

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

383 "falling back to summary blob" 

384 ) 

385 guillotine_cmd = initial_command 

386 else: 

387 try: 

388 _path_id: str | None = None 

389 if issue_path is not None: 

390 _pid_match = re.search(r"(BUG|FEAT|ENH|EPIC)-\d+", issue_path.name) 

391 if _pid_match: 

392 _path_id = _pid_match.group() 

393 guillotine_cmd = assemble_guillotine_prompt( 

394 original_command=initial_command, 

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

396 token_stats={ 

397 "input_tokens": _last_input[0], 

398 "output_tokens": _last_output[0], 

399 "context_limit": context_limit, 

400 "trigger_reason": trigger_reason, 

401 }, 

402 sprint_context=sprint_context, 

403 issue_id=_path_id, 

404 ) 

405 except Exception as exc: 

406 logger.warning( 

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

408 ) 

409 guillotine_cmd = initial_command 

410 continuation_count += 1 

411 current_command = guillotine_cmd 

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

413 _last_input[0] = 0 

414 _last_output[0] = 0 

415 _just_ran_fresh_session = True 

416 continue 

417 

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

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

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

421 sentinel_data = read_sentinel(repo_path) 

422 if sentinel_data is not None and this_is_fresh: 

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

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

425 logger.info( 

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

427 ) 

428 elif sentinel_data is not None and continuation_count < max_continuations: 

429 usage_pct = sentinel_data.get("usage_percent", 0) 

430 logger.info( 

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

432 "sending explicit handoff instruction via --continue" 

433 ) 

434 continuation_count += 1 

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

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

437 explicit_handoff_instruction = ( 

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

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

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

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

442 ) 

443 current_command = explicit_handoff_instruction 

444 # Reset tracking for the handoff turn 

445 _last_input[0] = 0 

446 _last_output[0] = 0 

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

448 result = run_claude_command( 

449 current_command, 

450 logger, 

451 timeout=timeout, 

452 stream_output=stream_output, 

453 idle_timeout=idle_timeout, 

454 on_usage=_tracking_usage, 

455 preview_full=preview_full, 

456 resume_session=True, 

457 ) 

458 all_stdout.append(result.stdout) 

459 all_stderr.append(result.stderr) 

460 

461 # After explicit instruction, check for handoff signal 

462 if detect_context_handoff(result.stdout): 

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

464 prompt_content = read_continuation_prompt(repo_path) 

465 if prompt_content and continuation_count < max_continuations: 

466 continuation_count += 1 

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

468 _base = resume_command if resume_command is not None else initial_command 

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

470 _last_input[0] = 0 

471 _last_output[0] = 0 

472 continue 

473 break 

474 

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

476 break 

477 

478 return subprocess.CompletedProcess( 

479 args=result.args, 

480 returncode=result.returncode, 

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

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

483 ) 

484 

485 

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

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

488 

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

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

491 

492 Args: 

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

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

495 

496 Returns: 

497 Path to plan file if created, None otherwise 

498 """ 

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

500 if not plans_dir.exists(): 

501 return None 

502 

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

504 # Use glob pattern with issue_id 

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

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

507 

508 if not matching_plans: 

509 return None 

510 

511 # Return the most recently modified plan file 

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

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

514 return latest_plan 

515 

516 

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

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

519 

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

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

522 

523 Args: 

524 issue_path: Path to the issue file 

525 

526 Returns: 

527 True if implementation markers found 

528 """ 

529 try: 

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

531 except (OSError, UnicodeDecodeError): 

532 return False 

533 

534 markers = [ 

535 "## Resolution", 

536 "Status: Implemented", 

537 "Status: Completed", 

538 "**Completed**:", 

539 ] 

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

541 

542 

543@dataclass 

544class IssueProcessingResult: 

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

546 

547 success: bool 

548 duration: float 

549 issue_id: str 

550 was_closed: bool = False 

551 was_blocked: bool = False 

552 failure_reason: str = "" 

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

554 plan_created: bool = False 

555 plan_path: str = "" 

556 

557 

558def process_issue_inplace( 

559 info: IssueInfo, 

560 config: BRConfig, 

561 logger: Logger, 

562 dry_run: bool = False, 

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

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

565 preview_full: bool = False, 

566 event_bus: EventBus | None = None, 

567 sprint_context: SprintWorkerContext | None = None, 

568 context_limit: int | None = None, 

569) -> IssueProcessingResult: 

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

571 

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

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

574 

575 Args: 

576 info: Issue information 

577 config: Project configuration 

578 logger: Logger for output 

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

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

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

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

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

584 

585 Returns: 

586 IssueProcessingResult with outcome details 

587 """ 

588 issue_start_time = time.time() 

589 corrections: list[str] = [] 

590 

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

592 

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

594 

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

596 validated_via_fallback = False 

597 

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

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

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

601 _external_on_usage = on_usage 

602 

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

604 try: 

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

606 state["result_token_count"] = input_tokens + output_tokens 

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

608 except Exception: 

609 pass # never block execution on state write failures 

610 if _external_on_usage is not None: 

611 _external_on_usage(input_tokens, output_tokens) 

612 

613 # Phase 1: Ready/verify the issue 

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

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

616 if not dry_run: 

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

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

619 result = run_claude_command( 

620 _ready_cmd, 

621 logger, 

622 timeout=config.automation.timeout_seconds, 

623 stream_output=config.automation.stream_output, 

624 idle_timeout=config.automation.idle_timeout_seconds, 

625 on_model_detected=on_model_detected, 

626 preview_full=preview_full, 

627 ) 

628 if result.returncode != 0: 

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

630 else: 

631 # Parse the verdict from the output 

632 parsed = parse_ready_issue_output(result.stdout) 

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

634 

635 # Validate that ready-issue analyzed the expected file 

636 validated_path = parsed.get("validated_file_path") 

637 if validated_path: 

638 # Normalize paths for comparison (resolve to absolute) 

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

640 # Handle both absolute and relative paths from ready_issue 

641 validated_resolved = Path(validated_path).resolve() 

642 if str(validated_resolved) != expected_path: 

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

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

645 old_file_exists = info.path.exists() 

646 new_file_exists = validated_resolved.exists() 

647 

648 if new_file_exists and not old_file_exists: 

649 # ready-issue renamed the file - update tracking 

650 logger.info( 

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

652 f"'{validated_resolved.name}'" 

653 ) 

654 info.path = validated_resolved 

655 else: 

656 # Genuine mismatch - attempt fallback with explicit path 

657 logger.warning( 

658 f"Path mismatch: ready-issue validated " 

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

660 ) 

661 logger.info( 

662 "Attempting fallback: retrying ready-issue " 

663 "with explicit file path..." 

664 ) 

665 

666 # Compute relative path for the command 

667 relative_path = _compute_relative_path(info.path) 

668 

669 # Retry with explicit path 

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

671 _retry_cmd = ( 

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

673 or _retry_slash 

674 ) 

675 retry_result = run_claude_command( 

676 _retry_cmd, 

677 logger, 

678 timeout=config.automation.timeout_seconds, 

679 stream_output=config.automation.stream_output, 

680 idle_timeout=config.automation.idle_timeout_seconds, 

681 on_model_detected=on_model_detected, 

682 preview_full=preview_full, 

683 ) 

684 

685 if retry_result.returncode != 0: 

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

687 return IssueProcessingResult( 

688 success=False, 

689 duration=time.time() - issue_start_time, 

690 issue_id=info.issue_id, 

691 failure_reason="Fallback failed after path mismatch", 

692 ) 

693 

694 # Re-parse and validate retry output 

695 retry_parsed = parse_ready_issue_output(retry_result.stdout) 

696 retry_validated_path = retry_parsed.get("validated_file_path") 

697 

698 if retry_validated_path: 

699 retry_resolved = Path(retry_validated_path).resolve() 

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

701 logger.error( 

702 f"Fallback still mismatched: " 

703 f"got '{retry_validated_path}', " 

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

705 ) 

706 return IssueProcessingResult( 

707 success=False, 

708 duration=time.time() - issue_start_time, 

709 issue_id=info.issue_id, 

710 failure_reason="Path mismatch persisted after fallback", 

711 ) 

712 

713 # Fallback succeeded - use retry result 

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

715 parsed = retry_parsed 

716 validated_via_fallback = True 

717 

718 # Log and store any corrections made 

719 if parsed.get("was_corrected"): 

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

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

722 for correction in phase_corrections: 

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

724 if phase_corrections: 

725 corrections.extend(phase_corrections) 

726 

727 # Log any concerns found 

728 if parsed["concerns"]: 

729 for concern in parsed["concerns"]: 

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

731 

732 # Handle CLOSE verdict - issue should not be implemented 

733 if parsed.get("should_close"): 

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

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

736 

737 # CRITICAL: Skip file operations for invalid references 

738 if close_reason == "invalid_ref": 

739 logger.warning( 

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

741 "no matching issue file exists" 

742 ) 

743 return IssueProcessingResult( 

744 success=False, 

745 duration=time.time() - issue_start_time, 

746 issue_id=info.issue_id, 

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

748 corrections=corrections, 

749 ) 

750 

751 # Also require validated_file_path to match before closing 

752 close_validated_path = parsed.get("validated_file_path") 

753 if not close_validated_path: 

754 logger.warning( 

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

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

757 ) 

758 return IssueProcessingResult( 

759 success=False, 

760 duration=time.time() - issue_start_time, 

761 issue_id=info.issue_id, 

762 failure_reason="CLOSE without validated file path", 

763 corrections=corrections, 

764 ) 

765 

766 if close_issue( 

767 info, 

768 config, 

769 logger, 

770 close_reason, 

771 parsed.get("close_status"), 

772 event_bus=event_bus, 

773 ): 

774 return IssueProcessingResult( 

775 success=True, 

776 duration=time.time() - issue_start_time, 

777 issue_id=info.issue_id, 

778 was_closed=True, 

779 corrections=corrections, 

780 ) 

781 else: 

782 return IssueProcessingResult( 

783 success=False, 

784 duration=time.time() - issue_start_time, 

785 issue_id=info.issue_id, 

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

787 corrections=corrections, 

788 ) 

789 

790 # Handle BLOCKED verdict - issue has open dependencies 

791 if parsed.get("is_blocked"): 

792 logger.warning( 

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

794 ) 

795 return IssueProcessingResult( 

796 success=False, 

797 was_blocked=True, 

798 duration=time.time() - issue_start_time, 

799 issue_id=info.issue_id, 

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

801 corrections=corrections, 

802 ) 

803 

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

805 if not parsed["is_ready"]: 

806 logger.error( 

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

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

809 ) 

810 return IssueProcessingResult( 

811 success=False, 

812 duration=time.time() - issue_start_time, 

813 issue_id=info.issue_id, 

814 failure_reason=( 

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

816 ), 

817 corrections=corrections, 

818 ) 

819 

820 # Log if proceeding with corrected issue 

821 if parsed.get("was_corrected"): 

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

823 else: 

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

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

826 

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

828 if info.decision_needed is True and not dry_run: 

829 logger.info( 

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

831 ) 

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

833 _decide_cmd = ( 

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

835 ) 

836 decide_result = run_claude_command( 

837 _decide_cmd, 

838 logger, 

839 timeout=config.automation.timeout_seconds, 

840 stream_output=config.automation.stream_output, 

841 idle_timeout=config.automation.idle_timeout_seconds, 

842 on_model_detected=on_model_detected, 

843 preview_full=preview_full, 

844 ) 

845 if decide_result.returncode != 0: 

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

847 

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

849 action = config.get_category_action(info.issue_type) 

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

851 _baseline_sha_result = subprocess.run( 

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

853 ) 

854 _baseline_sha: str | None = ( 

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

856 ) 

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

858 if not dry_run: 

859 # Build manage-issue command 

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

861 

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

863 if validated_via_fallback: 

864 issue_arg = _compute_relative_path(info.path) 

865 else: 

866 issue_arg = info.issue_id 

867 

868 # Use run_with_continuation to handle context exhaustion. 

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

870 # Pass the short slash command as resume_command so continuation 

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

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

873 _initial_cmd = ( 

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

875 ) 

876 result = run_with_continuation( 

877 _initial_cmd, 

878 logger, 

879 timeout=config.automation.timeout_seconds, 

880 stream_output=config.automation.stream_output, 

881 max_continuations=config.automation.max_continuations, 

882 repo_path=config.repo_path, 

883 idle_timeout=config.automation.idle_timeout_seconds, 

884 resume_command=_slash_cmd, 

885 on_usage=_on_usage_writer, 

886 preview_full=preview_full, 

887 issue_path=info.path, 

888 sprint_context=sprint_context, 

889 context_limit=context_window_for(None, override=context_limit), 

890 ) 

891 else: 

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

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

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

895 

896 # Handle implementation failure 

897 if result.returncode != 0: 

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

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

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

901 # so Phase 3 runs. 

902 already_done = False 

903 if info.path.exists(): 

904 try: 

905 from little_loops.frontmatter import parse_frontmatter 

906 

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

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

909 except Exception: 

910 already_done = False 

911 if already_done: 

912 logger.warning( 

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

914 "treating as success (continuation artefact)" 

915 ) 

916 result = subprocess.CompletedProcess( 

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

918 ) 

919 else: 

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

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

922 

923 if failure_type in (FailureType.TRANSIENT, FailureType.NON_RECOVERABLE): 

924 # Transient or non-recoverable failure — log but don't create bug issue. 

925 # NON_RECOVERABLE (auth/credential) is not a code bug; retrying won't help. 

926 label = "Transient" if failure_type == FailureType.TRANSIENT else "Non-recoverable" 

927 logger.warning(f"{label} failure for {info.issue_id}: {failure_reason_text}") 

928 logger.warning("Not creating bug issue - this is not a code defect") 

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

930 logger.info(error_output[:500]) 

931 

932 return IssueProcessingResult( 

933 success=False, 

934 duration=time.time() - issue_start_time, 

935 issue_id=info.issue_id, 

936 failure_reason=f"{label}: {failure_reason_text}", 

937 corrections=corrections, 

938 ) 

939 

940 # Real failure - create issue as before 

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

942 

943 failure_reason = "" 

944 if not dry_run: 

945 # Create new issue for the failure 

946 new_issue = create_issue_from_failure( 

947 error_output, 

948 info, 

949 config, 

950 logger, 

951 event_bus=event_bus, 

952 ) 

953 failure_reason = str(new_issue) if new_issue else error_output 

954 else: 

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

956 

957 return IssueProcessingResult( 

958 success=False, 

959 duration=time.time() - issue_start_time, 

960 issue_id=info.issue_id, 

961 failure_reason=failure_reason, 

962 corrections=corrections, 

963 ) 

964 

965 # Phase 3: Verify completion 

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

967 verified = False 

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

969 if not dry_run: 

970 verified = verify_issue_completed(info, config, logger) 

971 

972 # Fallback: Only complete lifecycle if: 

973 # 1. Command returned success (returncode 0) 

974 # 2. File wasn't moved to completed 

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

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

977 # Check if a plan was created awaiting approval 

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

979 if plan_path is not None: 

980 logger.info( 

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

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

983 ) 

984 return IssueProcessingResult( 

985 success=False, 

986 duration=time.time() - issue_start_time, 

987 issue_id=info.issue_id, 

988 plan_created=True, 

989 plan_path=str(plan_path), 

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

991 corrections=corrections, 

992 ) 

993 

994 logger.info( 

995 "Command returned success but issue not moved - " 

996 "checking for evidence of work..." 

997 ) 

998 

999 # Check issue file content for implementation markers 

1000 if check_content_markers(info.path): 

1001 logger.info( 

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

1003 ) 

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

1005 if verified: 

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

1007 else: 

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

1009 else: 

1010 # CRITICAL: Verify actual implementation work was done 

1011 work_done = verify_work_was_done(logger, baseline_sha=_baseline_sha) 

1012 if work_done: 

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

1014 verified = complete_issue_lifecycle( 

1015 info, config, logger, event_bus=event_bus 

1016 ) 

1017 if verified: 

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

1019 else: 

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

1021 else: 

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

1023 logger.error( 

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

1025 "no code changes detected despite returncode 0" 

1026 ) 

1027 logger.error( 

1028 "This likely indicates the command was not executed " 

1029 "properly. Check command invocation and Claude CLI " 

1030 "output." 

1031 ) 

1032 verified = False 

1033 else: 

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

1035 verified = True # In dry run, assume success 

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

1037 

1038 # Record timing 

1039 total_issue_time = time.time() - issue_start_time 

1040 issue_timing["total"] = total_issue_time 

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

1042 

1043 if verified: 

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

1045 else: 

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

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

1048 

1049 return IssueProcessingResult( 

1050 success=verified, 

1051 duration=total_issue_time, 

1052 issue_id=info.issue_id, 

1053 corrections=corrections, 

1054 ) 

1055 

1056 

1057class AutoManager: 

1058 """Automated issue manager for sequential processing. 

1059 

1060 Processes issues in priority order using Claude CLI commands, 

1061 with state persistence for resume capability. 

1062 """ 

1063 

1064 def __init__( 

1065 self, 

1066 config: BRConfig, 

1067 dry_run: bool = False, 

1068 max_issues: int = 0, 

1069 resume: bool = False, 

1070 category: str | None = None, 

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

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

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

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

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

1076 verbose: bool = True, 

1077 preview_full: bool = False, 

1078 db_path: Path | None = None, 

1079 ) -> None: 

1080 """Initialize the auto manager. 

1081 

1082 Args: 

1083 config: Project configuration 

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

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

1086 resume: Whether to resume from previous state 

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

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

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

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

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

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

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

1094 verbose: Whether to output progress messages 

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

1096 """ 

1097 self.config = config 

1098 self.dry_run = dry_run 

1099 self.max_issues = max_issues 

1100 self.resume = resume 

1101 self.category = category 

1102 self.only_ids = only_ids 

1103 self.skip_ids = skip_ids or set() 

1104 self.type_prefixes = type_prefixes 

1105 self.priority_filter = priority_filter 

1106 self.label_filter = label_filter 

1107 self._preview_full = preview_full 

1108 

1109 from little_loops.cli.output import use_color_enabled 

1110 

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

1112 self.event_bus = EventBus() 

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

1114 self.state_manager = StateManager( 

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

1116 ) 

1117 self.parser = IssueParser(config) 

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

1119 

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

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

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

1123 all_known_ids: set[str] | None = None 

1124 try: 

1125 from little_loops.dependency_mapper import gather_all_issue_ids 

1126 

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

1128 all_known_ids = gather_all_issue_ids(issues_dir, config=config) 

1129 except Exception: 

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

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

1132 

1133 # Warn about any cycles 

1134 if self.dep_graph.has_cycles(): 

1135 cycles = self.dep_graph.detect_cycles() 

1136 for cycle in cycles: 

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

1138 

1139 self.processed_count = 0 

1140 self._shutdown_requested = False 

1141 

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

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

1144 

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

1146 """Handle shutdown signals gracefully.""" 

1147 self._shutdown_requested = True 

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

1149 

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

1151 """Get next issue respecting dependencies. 

1152 

1153 Returns the highest priority issue whose blockers have all been 

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

1155 logs warnings about what is blocking progress. 

1156 

1157 Returns: 

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

1159 """ 

1160 # Get completed issues from state 

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

1162 

1163 # Combine skip_ids from state and CLI argument 

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

1165 

1166 # Get issues that are ready (blockers satisfied) 

1167 ready_issues = self.dep_graph.get_ready_issues(completed) 

1168 

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

1170 candidates = [ 

1171 i 

1172 for i in ready_issues 

1173 if i.issue_id not in skip_ids 

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

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

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

1177 and ( 

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

1179 ) 

1180 ] 

1181 

1182 if candidates: 

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

1184 only_ids = self.only_ids 

1185 if isinstance(only_ids, list): 

1186 candidates.sort( 

1187 key=lambda x: next( 

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

1189 len(only_ids), 

1190 ) 

1191 ) 

1192 return candidates[0] 

1193 

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

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

1196 remaining = all_in_graph - completed - skip_ids 

1197 if self.only_ids is not None: 

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

1199 if self.type_prefixes is not None: 

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

1201 if self.priority_filter is not None: 

1202 remaining = { 

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

1204 } 

1205 if self.label_filter is not None: 

1206 remaining = { 

1207 r 

1208 for r in remaining 

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

1210 } 

1211 

1212 if remaining: 

1213 self._log_blocked_issues(remaining, completed) 

1214 

1215 return None 

1216 

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

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

1219 

1220 Args: 

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

1222 completed: Set of completed issue IDs 

1223 """ 

1224 blocked_count = 0 

1225 for issue_id in sorted(remaining): 

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

1227 if blockers: 

1228 blocked_count += 1 

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

1230 

1231 if blocked_count > 0: 

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

1233 

1234 def run(self) -> int: 

1235 """Run the automation loop. 

1236 

1237 Returns: 

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

1239 """ 

1240 run_start_time = time.time() 

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

1242 

1243 if self.dry_run: 

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

1245 

1246 if not self.dry_run: 

1247 has_changes = check_git_status(self.logger) 

1248 if has_changes: 

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

1250 

1251 # Load or initialize state 

1252 if self.resume: 

1253 state = self.state_manager.load() 

1254 if state: 

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

1256 self.processed_count = len(state.completed_issues) 

1257 else: 

1258 # Fresh start 

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

1260 

1261 attempted_count = 0 

1262 try: 

1263 while not self._shutdown_requested: 

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

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

1266 break 

1267 

1268 info = self._get_next_issue() 

1269 if not info: 

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

1271 break 

1272 

1273 attempted_count += 1 

1274 success = self._process_issue(info) 

1275 if success: 

1276 self.processed_count += 1 

1277 

1278 except Exception as e: 

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

1280 return 1 

1281 

1282 finally: 

1283 if not self._shutdown_requested: 

1284 self.state_manager.cleanup() 

1285 self.event_bus.close_transports() 

1286 

1287 self._log_timing_summary(run_start_time) 

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

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

1290 return 1 

1291 return 0 

1292 

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

1294 """Log aggregate timing summary.""" 

1295 total_run_time = time.time() - run_start_time 

1296 

1297 self.logger.info("") 

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

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

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

1301 

1302 state = self.state_manager.state 

1303 if state.timing: 

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

1305 if total_times: 

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

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

1308 

1309 if state.failed_issues: 

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

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

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

1313 

1314 # Log correction statistics for quality tracking 

1315 if state.corrections: 

1316 total_corrected = len(state.corrections) 

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

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

1319 self.logger.info( 

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

1321 ) 

1322 

1323 # Log most common correction types 

1324 from collections import Counter 

1325 

1326 all_corrections: list[str] = [] 

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

1328 all_corrections.extend(corrections) 

1329 if all_corrections: 

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

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

1332 for correction, count in common: 

1333 # Truncate long correction descriptions 

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

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

1336 

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

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

1339 

1340 Delegates to process_issue_inplace() and maps the result back 

1341 to state manager calls. 

1342 

1343 Args: 

1344 info: Issue information 

1345 

1346 Returns: 

1347 True if processing succeeded 

1348 """ 

1349 # Pre-processing state updates (before delegating) 

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

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

1352 

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

1354 if not self._detected_model: 

1355 

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

1357 self._detected_model.append(m) 

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

1359 

1360 resolved_limit = context_window_for( 

1361 self._detected_model[0] if self._detected_model else None 

1362 ) 

1363 result = process_issue_inplace( 

1364 info, 

1365 self.config, 

1366 self.logger, 

1367 self.dry_run, 

1368 on_model_detected=on_model, 

1369 preview_full=self._preview_full, 

1370 event_bus=self.event_bus, 

1371 context_limit=resolved_limit, 

1372 ) 

1373 

1374 # Map result back to state tracking 

1375 if result.was_closed: 

1376 self.state_manager.mark_completed(info.issue_id) 

1377 elif result.was_blocked: 

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

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

1380 elif result.success: 

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

1382 elif result.plan_created: 

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

1384 self.logger.info( 

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

1386 "leaving in pending state for manual approval" 

1387 ) 

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

1389 elif result.failure_reason: 

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

1391 

1392 if result.corrections: 

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

1394 

1395 return result.success