Coverage for little_loops / issue_manager.py: 10%

563 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-29 00:54 -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.learning_tests.extractor import resolve_learning_targets 

41from little_loops.learning_tests.gate import run_learning_gate_for_issue 

42from little_loops.logger import Logger, format_duration 

43from little_loops.output_parsing import parse_ready_issue_output 

44from little_loops.session_store import DEFAULT_DB_PATH, SQLiteTransport 

45from little_loops.skill_expander import expand_skill 

46from little_loops.state import ProcessingState, StateManager, _iso_now 

47from little_loops.subprocess_utils import ( 

48 assemble_guillotine_prompt, 

49 detect_context_handoff, 

50 read_continuation_prompt, 

51 read_sentinel, 

52) 

53from little_loops.subprocess_utils import ( 

54 run_claude_command as _run_claude_base, 

55) 

56 

57 

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

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

60 

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

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

63 

64 Args: 

65 abs_path: Absolute path to the file 

66 base_dir: Base directory (defaults to cwd) 

67 

68 Returns: 

69 Relative path string suitable for ready-issue command 

70 """ 

71 base = base_dir or Path.cwd() 

72 try: 

73 return str(abs_path.relative_to(base)) 

74 except ValueError: 

75 # Path not relative to base, use absolute 

76 return str(abs_path) 

77 

78 

79@contextmanager 

80def timed_phase( 

81 logger: Logger, 

82 phase_name: str, 

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

84 """Context manager for timing phases. 

85 

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

87 

88 Args: 

89 logger: Logger for output 

90 phase_name: Name of the phase being timed 

91 

92 Yields: 

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

94 """ 

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

96 start = time.time() 

97 try: 

98 yield timing_result 

99 finally: 

100 elapsed = time.time() - start 

101 timing_result["elapsed"] = elapsed 

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

103 

104 

105def run_claude_command( 

106 command: str, 

107 logger: Logger, 

108 timeout: int = 3600, 

109 stream_output: bool = True, 

110 idle_timeout: int = 0, 

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

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

113 preview_full: bool = False, 

114 resume_session: bool = False, 

115) -> subprocess.CompletedProcess[str]: 

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

117 

118 Args: 

119 command: Command to pass to Claude CLI 

120 logger: Logger for output 

121 timeout: Timeout in seconds 

122 stream_output: Whether to stream output to console 

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

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

125 stream-json system/init event. 

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

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

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

129 

130 Returns: 

131 CompletedProcess with stdout/stderr captured 

132 """ 

133 from little_loops.cli.output import terminal_width 

134 

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

136 line_count = len(lines) 

137 tw = terminal_width() 

138 max_line = tw - 4 

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

140 logger.info( 

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

142 ) 

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

144 for line in lines[:show_count]: 

145 display = ( 

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

147 ) 

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

149 if line_count > show_count: 

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

151 

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

153 if stream_output: 

154 if is_stderr: 

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

156 else: 

157 print(f" {line}") 

158 

159 return _run_claude_base( 

160 command=command, 

161 timeout=timeout, 

162 stream_callback=stream_callback if stream_output else None, 

163 idle_timeout=idle_timeout, 

164 on_model_detected=on_model_detected, 

165 on_usage=on_usage, 

166 resume_session=resume_session, 

167 ) 

168 

169 

170def _check_issue_already_done( 

171 issue_path: Path | None, 

172 logger: Logger, 

173) -> bool: 

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

175 

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

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

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

179 return success rather than triggering an unnecessary handoff cycle. 

180 

181 Args: 

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

183 logger: Logger for diagnostic messages. 

184 

185 Returns: 

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

187 """ 

188 if issue_path is None: 

189 return False 

190 if not issue_path.exists(): 

191 return False 

192 try: 

193 from little_loops.frontmatter import parse_frontmatter 

194 

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

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

197 except Exception: 

198 return False 

199 

200 

201def run_with_continuation( 

202 initial_command: str, 

203 logger: Logger, 

204 timeout: int = 3600, 

205 stream_output: bool = True, 

206 max_continuations: int = 3, 

207 repo_path: Path | None = None, 

208 idle_timeout: int = 0, 

209 resume_command: str | None = None, 

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

211 preview_full: bool = False, 

212 context_limit: int = 200_000, 

213 issue_path: Path | None = None, 

214 run_dir: str | None = None, 

215 sprint_context: SprintWorkerContext | None = None, 

216) -> subprocess.CompletedProcess[str]: 

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

218 

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

220 

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

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

223 instruction, triggering the standard CONTEXT_HANDOFF continuation flow. 

224 

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

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

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

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

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

230 

231 Args: 

232 initial_command: Initial command to run 

233 logger: Logger for output 

234 timeout: Timeout per session in seconds 

235 stream_output: Whether to stream output 

236 max_continuations: Maximum number of continuation attempts 

237 repo_path: Repository root path 

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

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

240 ``--resume`` to ``initial_command``. 

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

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

243 

244 Returns: 

245 Final CompletedProcess result 

246 """ 

247 all_stdout: list[str] = [] 

248 all_stderr: list[str] = [] 

249 current_command = initial_command 

250 continuation_count = 0 

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

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

253 ) 

254 

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

256 _last_input: list[int] = [0] 

257 _last_output: list[int] = [0] 

258 _external_on_usage = on_usage 

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

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

261 _just_ran_fresh_session = False 

262 

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

264 _last_input[0] = input_tokens 

265 _last_output[0] = output_tokens 

266 if _external_on_usage is not None: 

267 _external_on_usage(input_tokens, output_tokens) 

268 

269 while continuation_count <= max_continuations: 

270 this_is_fresh = _just_ran_fresh_session 

271 _just_ran_fresh_session = False 

272 result = run_claude_command( 

273 current_command, 

274 logger, 

275 timeout=timeout, 

276 stream_output=stream_output, 

277 idle_timeout=idle_timeout, 

278 on_usage=_tracking_usage, 

279 preview_full=preview_full, 

280 ) 

281 

282 all_stdout.append(result.stdout) 

283 all_stderr.append(result.stderr) 

284 

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

286 if detect_context_handoff(result.stdout): 

287 logger.info("Detected CONTEXT_HANDOFF signal") 

288 

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

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

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

292 already_done = _check_issue_already_done(issue_path, logger) 

293 if already_done: 

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

295 result = subprocess.CompletedProcess( 

296 args=result.args, 

297 returncode=0, 

298 stdout=result.stdout, 

299 stderr=result.stderr, 

300 ) 

301 break 

302 

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

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

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

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

307 handoff_message = "CONTEXT_HANDOFF: Ready for fresh session" 

308 print(handoff_message) 

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

310 

311 result = subprocess.CompletedProcess( 

312 args=result.args, 

313 returncode=0, 

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

315 stderr=result.stderr, 

316 ) 

317 break 

318 

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

320 

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

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

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

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

325 if prompt_too_long and continuation_count < max_continuations: 

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

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

328 if _check_issue_already_done(issue_path, logger): 

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

330 result = subprocess.CompletedProcess( 

331 args=result.args, 

332 returncode=0, 

333 stdout=result.stdout, 

334 stderr=result.stderr, 

335 ) 

336 break 

337 trigger_reason = "Prompt is too long" 

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

339 if run_dir is not None: 

340 try: 

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

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

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

344 sprint_framing = "" 

345 if sprint_context is not None: 

346 sprint_framing = ( 

347 f"## Sprint Worker Context\n" 

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

349 f"{sprint_context.issue_id}\n" 

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

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

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

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

354 ) 

355 elif issue_path is not None: 

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

357 if _id_match: 

358 sprint_framing = ( 

359 f"## Scope Constraint\n" 

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

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

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

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

364 ) 

365 guillotine_file.write_text( 

366 sprint_framing + f"## Intent\n" 

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

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

369 f"Trigger reason: {trigger_reason} " 

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

371 f"\n" 

372 f"## Next Steps\n" 

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

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

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

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

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

378 encoding="utf-8", 

379 ) 

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

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

382 except Exception as exc: 

383 logger.warning( 

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

385 "falling back to summary blob" 

386 ) 

387 guillotine_cmd = initial_command 

388 else: 

389 try: 

390 _path_id: str | None = None 

391 if issue_path is not None: 

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

393 if _pid_match: 

394 _path_id = _pid_match.group() 

395 guillotine_cmd = assemble_guillotine_prompt( 

396 original_command=initial_command, 

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

398 token_stats={ 

399 "input_tokens": _last_input[0], 

400 "output_tokens": _last_output[0], 

401 "context_limit": context_limit, 

402 "trigger_reason": trigger_reason, 

403 }, 

404 sprint_context=sprint_context, 

405 issue_id=_path_id, 

406 ) 

407 except Exception as exc: 

408 logger.warning( 

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

410 ) 

411 guillotine_cmd = initial_command 

412 continuation_count += 1 

413 current_command = guillotine_cmd 

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

415 _last_input[0] = 0 

416 _last_output[0] = 0 

417 _just_ran_fresh_session = True 

418 continue 

419 

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

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

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

423 sentinel_data = read_sentinel(repo_path) 

424 if sentinel_data is not None and this_is_fresh: 

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

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

427 logger.info( 

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

429 ) 

430 elif sentinel_data is not None and continuation_count < max_continuations: 

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

432 logger.info( 

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

434 "sending explicit handoff instruction via --continue" 

435 ) 

436 continuation_count += 1 

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

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

439 explicit_handoff_instruction = ( 

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

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

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

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

444 ) 

445 current_command = explicit_handoff_instruction 

446 # Reset tracking for the handoff turn 

447 _last_input[0] = 0 

448 _last_output[0] = 0 

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

450 result = run_claude_command( 

451 current_command, 

452 logger, 

453 timeout=timeout, 

454 stream_output=stream_output, 

455 idle_timeout=idle_timeout, 

456 on_usage=_tracking_usage, 

457 preview_full=preview_full, 

458 resume_session=True, 

459 ) 

460 all_stdout.append(result.stdout) 

461 all_stderr.append(result.stderr) 

462 

463 # After explicit instruction, check for handoff signal 

464 if detect_context_handoff(result.stdout): 

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

466 prompt_content = read_continuation_prompt(repo_path) 

467 if prompt_content and continuation_count < max_continuations: 

468 continuation_count += 1 

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

470 _base = resume_command if resume_command is not None else initial_command 

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

472 _last_input[0] = 0 

473 _last_output[0] = 0 

474 continue 

475 break 

476 

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

478 break 

479 

480 return subprocess.CompletedProcess( 

481 args=result.args, 

482 returncode=result.returncode, 

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

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

485 ) 

486 

487 

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

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

490 

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

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

493 

494 Args: 

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

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

497 

498 Returns: 

499 Path to plan file if created, None otherwise 

500 """ 

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

502 if not plans_dir.exists(): 

503 return None 

504 

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

506 # Use glob pattern with issue_id 

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

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

509 

510 if not matching_plans: 

511 return None 

512 

513 # Return the most recently modified plan file 

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

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

516 return latest_plan 

517 

518 

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

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

521 

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

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

524 

525 Args: 

526 issue_path: Path to the issue file 

527 

528 Returns: 

529 True if implementation markers found 

530 """ 

531 try: 

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

533 except (OSError, UnicodeDecodeError): 

534 return False 

535 

536 markers = [ 

537 "## Resolution", 

538 "Status: Implemented", 

539 "Status: Completed", 

540 "**Completed**:", 

541 ] 

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

543 

544 

545@dataclass 

546class IssueProcessingResult: 

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

548 

549 success: bool 

550 duration: float 

551 issue_id: str 

552 was_closed: bool = False 

553 was_blocked: bool = False 

554 failure_reason: str = "" 

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

556 plan_created: bool = False 

557 plan_path: str = "" 

558 

559 

560def process_issue_inplace( 

561 info: IssueInfo, 

562 config: BRConfig, 

563 logger: Logger, 

564 dry_run: bool = False, 

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

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

567 preview_full: bool = False, 

568 event_bus: EventBus | None = None, 

569 sprint_context: SprintWorkerContext | None = None, 

570 context_limit: int | None = None, 

571 skip_learning_gate: bool = False, 

572) -> IssueProcessingResult: 

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

574 

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

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

577 

578 Args: 

579 info: Issue information 

580 config: Project configuration 

581 logger: Logger for output 

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

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

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

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

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

587 

588 Returns: 

589 IssueProcessingResult with outcome details 

590 """ 

591 issue_start_time = time.time() 

592 corrections: list[str] = [] 

593 

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

595 

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

597 

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

599 validated_via_fallback = False 

600 

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

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

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

604 _external_on_usage = on_usage 

605 

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

607 try: 

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

609 state["result_token_count"] = input_tokens + output_tokens 

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

611 except Exception: 

612 pass # never block execution on state write failures 

613 if _external_on_usage is not None: 

614 _external_on_usage(input_tokens, output_tokens) 

615 

616 # Phase 1: Ready/verify the issue 

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

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

619 if not dry_run: 

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

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

622 result = run_claude_command( 

623 _ready_cmd, 

624 logger, 

625 timeout=config.automation.timeout_seconds, 

626 stream_output=config.automation.stream_output, 

627 idle_timeout=config.automation.idle_timeout_seconds, 

628 on_model_detected=on_model_detected, 

629 preview_full=preview_full, 

630 ) 

631 if result.returncode != 0: 

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

633 else: 

634 # Parse the verdict from the output 

635 parsed = parse_ready_issue_output(result.stdout) 

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

637 

638 # Validate that ready-issue analyzed the expected file 

639 validated_path = parsed.get("validated_file_path") 

640 if validated_path: 

641 # Normalize paths for comparison (resolve to absolute) 

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

643 # Handle both absolute and relative paths from ready_issue 

644 validated_resolved = Path(validated_path).resolve() 

645 if str(validated_resolved) != expected_path: 

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

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

648 old_file_exists = info.path.exists() 

649 new_file_exists = validated_resolved.exists() 

650 

651 if new_file_exists and not old_file_exists: 

652 # ready-issue renamed the file - update tracking 

653 logger.info( 

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

655 f"'{validated_resolved.name}'" 

656 ) 

657 info.path = validated_resolved 

658 else: 

659 # Genuine mismatch - attempt fallback with explicit path 

660 logger.warning( 

661 f"Path mismatch: ready-issue validated " 

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

663 ) 

664 logger.info( 

665 "Attempting fallback: retrying ready-issue " 

666 "with explicit file path..." 

667 ) 

668 

669 # Compute relative path for the command 

670 relative_path = _compute_relative_path(info.path) 

671 

672 # Retry with explicit path 

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

674 _retry_cmd = ( 

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

676 or _retry_slash 

677 ) 

678 retry_result = run_claude_command( 

679 _retry_cmd, 

680 logger, 

681 timeout=config.automation.timeout_seconds, 

682 stream_output=config.automation.stream_output, 

683 idle_timeout=config.automation.idle_timeout_seconds, 

684 on_model_detected=on_model_detected, 

685 preview_full=preview_full, 

686 ) 

687 

688 if retry_result.returncode != 0: 

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

690 return IssueProcessingResult( 

691 success=False, 

692 duration=time.time() - issue_start_time, 

693 issue_id=info.issue_id, 

694 failure_reason="Fallback failed after path mismatch", 

695 ) 

696 

697 # Re-parse and validate retry output 

698 retry_parsed = parse_ready_issue_output(retry_result.stdout) 

699 retry_validated_path = retry_parsed.get("validated_file_path") 

700 

701 if retry_validated_path: 

702 retry_resolved = Path(retry_validated_path).resolve() 

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

704 logger.error( 

705 f"Fallback still mismatched: " 

706 f"got '{retry_validated_path}', " 

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

708 ) 

709 return IssueProcessingResult( 

710 success=False, 

711 duration=time.time() - issue_start_time, 

712 issue_id=info.issue_id, 

713 failure_reason="Path mismatch persisted after fallback", 

714 ) 

715 

716 # Fallback succeeded - use retry result 

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

718 parsed = retry_parsed 

719 validated_via_fallback = True 

720 

721 # Log and store any corrections made 

722 if parsed.get("was_corrected"): 

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

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

725 for correction in phase_corrections: 

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

727 if phase_corrections: 

728 corrections.extend(phase_corrections) 

729 

730 # Log any concerns found 

731 if parsed["concerns"]: 

732 for concern in parsed["concerns"]: 

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

734 

735 # Handle CLOSE verdict - issue should not be implemented 

736 if parsed.get("should_close"): 

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

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

739 

740 # CRITICAL: Skip file operations for invalid references 

741 if close_reason == "invalid_ref": 

742 logger.warning( 

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

744 "no matching issue file exists" 

745 ) 

746 return IssueProcessingResult( 

747 success=False, 

748 duration=time.time() - issue_start_time, 

749 issue_id=info.issue_id, 

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

751 corrections=corrections, 

752 ) 

753 

754 # Also require validated_file_path to match before closing 

755 close_validated_path = parsed.get("validated_file_path") 

756 if not close_validated_path: 

757 logger.warning( 

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

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

760 ) 

761 return IssueProcessingResult( 

762 success=False, 

763 duration=time.time() - issue_start_time, 

764 issue_id=info.issue_id, 

765 failure_reason="CLOSE without validated file path", 

766 corrections=corrections, 

767 ) 

768 

769 if close_issue( 

770 info, 

771 config, 

772 logger, 

773 close_reason, 

774 parsed.get("close_status"), 

775 event_bus=event_bus, 

776 ): 

777 return IssueProcessingResult( 

778 success=True, 

779 duration=time.time() - issue_start_time, 

780 issue_id=info.issue_id, 

781 was_closed=True, 

782 corrections=corrections, 

783 ) 

784 else: 

785 return IssueProcessingResult( 

786 success=False, 

787 duration=time.time() - issue_start_time, 

788 issue_id=info.issue_id, 

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

790 corrections=corrections, 

791 ) 

792 

793 # Handle BLOCKED verdict - issue has open dependencies 

794 if parsed.get("is_blocked"): 

795 logger.warning( 

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

797 ) 

798 return IssueProcessingResult( 

799 success=False, 

800 was_blocked=True, 

801 duration=time.time() - issue_start_time, 

802 issue_id=info.issue_id, 

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

804 corrections=corrections, 

805 ) 

806 

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

808 if not parsed["is_ready"]: 

809 logger.error( 

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

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

812 ) 

813 return IssueProcessingResult( 

814 success=False, 

815 duration=time.time() - issue_start_time, 

816 issue_id=info.issue_id, 

817 failure_reason=( 

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

819 ), 

820 corrections=corrections, 

821 ) 

822 

823 # Log if proceeding with corrected issue 

824 if parsed.get("was_corrected"): 

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

826 else: 

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

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

829 

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

831 if info.decision_needed is True and not dry_run: 

832 logger.info( 

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

834 ) 

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

836 _decide_cmd = ( 

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

838 ) 

839 decide_result = run_claude_command( 

840 _decide_cmd, 

841 logger, 

842 timeout=config.automation.timeout_seconds, 

843 stream_output=config.automation.stream_output, 

844 idle_timeout=config.automation.idle_timeout_seconds, 

845 on_model_detected=on_model_detected, 

846 preview_full=preview_full, 

847 ) 

848 if decide_result.returncode != 0: 

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

850 

851 # Learning gate: per-issue proof-first-task check (ENH-2319) 

852 # Use `is True` (not truthiness) so MagicMock auto-specs in tests don't 

853 # accidentally trigger the gate when `enabled` is not explicitly set. 

854 if config.learning_tests.enabled is True and not dry_run: 

855 targets = resolve_learning_targets(info) 

856 if targets: 

857 logger.info(f"Learning gate: checking {len(targets)} target(s): {', '.join(targets)}") 

858 gate_cwd = config.repo_path or Path.cwd() 

859 verdict = run_learning_gate_for_issue(info.path, skip=skip_learning_gate, cwd=gate_cwd) 

860 if verdict == "skipped": 

861 logger.info(f"Learning gate skipped for {info.issue_id} (--skip-learning-gate)") 

862 elif verdict == "blocked": 

863 logger.warning(f"Learning gate blocked {info.issue_id}: unproven external-API deps") 

864 return IssueProcessingResult( 

865 success=False, 

866 duration=time.time() - issue_start_time, 

867 issue_id=info.issue_id, 

868 failure_reason="Learning gate blocked: unproven external-API deps", 

869 corrections=corrections, 

870 ) 

871 

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

873 action = config.get_category_action(info.issue_type) 

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

875 _baseline_sha_result = subprocess.run( 

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

877 ) 

878 _baseline_sha: str | None = ( 

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

880 ) 

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

882 if not dry_run: 

883 # Build manage-issue command 

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

885 

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

887 if validated_via_fallback: 

888 issue_arg = _compute_relative_path(info.path) 

889 else: 

890 issue_arg = info.issue_id 

891 

892 # Use run_with_continuation to handle context exhaustion. 

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

894 # Pass the short slash command as resume_command so continuation 

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

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

897 _initial_cmd = ( 

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

899 ) 

900 result = run_with_continuation( 

901 _initial_cmd, 

902 logger, 

903 timeout=config.automation.timeout_seconds, 

904 stream_output=config.automation.stream_output, 

905 max_continuations=config.automation.max_continuations, 

906 repo_path=config.repo_path, 

907 idle_timeout=config.automation.idle_timeout_seconds, 

908 resume_command=_slash_cmd, 

909 on_usage=_on_usage_writer, 

910 preview_full=preview_full, 

911 issue_path=info.path, 

912 sprint_context=sprint_context, 

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

914 ) 

915 else: 

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

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

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

919 

920 # Handle implementation failure 

921 if result.returncode != 0: 

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

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

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

925 # so Phase 3 runs. 

926 already_done = False 

927 if info.path.exists(): 

928 try: 

929 from little_loops.frontmatter import parse_frontmatter 

930 

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

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

933 except Exception: 

934 already_done = False 

935 if already_done: 

936 logger.warning( 

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

938 "treating as success (continuation artefact)" 

939 ) 

940 result = subprocess.CompletedProcess( 

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

942 ) 

943 else: 

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

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

946 

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

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

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

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

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

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

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

954 logger.info(error_output[:500]) 

955 

956 return IssueProcessingResult( 

957 success=False, 

958 duration=time.time() - issue_start_time, 

959 issue_id=info.issue_id, 

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

961 corrections=corrections, 

962 ) 

963 

964 # Real failure - create issue as before 

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

966 

967 failure_reason = "" 

968 if not dry_run: 

969 # Create new issue for the failure 

970 new_issue = create_issue_from_failure( 

971 error_output, 

972 info, 

973 config, 

974 logger, 

975 event_bus=event_bus, 

976 ) 

977 failure_reason = str(new_issue) if new_issue else error_output 

978 else: 

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

980 

981 return IssueProcessingResult( 

982 success=False, 

983 duration=time.time() - issue_start_time, 

984 issue_id=info.issue_id, 

985 failure_reason=failure_reason, 

986 corrections=corrections, 

987 ) 

988 

989 # Phase 3: Verify completion 

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

991 verified = False 

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

993 if not dry_run: 

994 verified = verify_issue_completed(info, config, logger) 

995 

996 # Fallback: Only complete lifecycle if: 

997 # 1. Command returned success (returncode 0) 

998 # 2. File wasn't moved to completed 

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

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

1001 # Check if a plan was created awaiting approval 

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

1003 if plan_path is not None: 

1004 logger.info( 

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

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

1007 ) 

1008 return IssueProcessingResult( 

1009 success=False, 

1010 duration=time.time() - issue_start_time, 

1011 issue_id=info.issue_id, 

1012 plan_created=True, 

1013 plan_path=str(plan_path), 

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

1015 corrections=corrections, 

1016 ) 

1017 

1018 logger.info( 

1019 "Command returned success but issue not moved - " 

1020 "checking for evidence of work..." 

1021 ) 

1022 

1023 # Check issue file content for implementation markers 

1024 if check_content_markers(info.path): 

1025 logger.info( 

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

1027 ) 

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

1029 if verified: 

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

1031 else: 

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

1033 else: 

1034 # CRITICAL: Verify actual implementation work was done 

1035 work_done = verify_work_was_done(logger, baseline_sha=_baseline_sha) 

1036 if work_done: 

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

1038 verified = complete_issue_lifecycle( 

1039 info, config, logger, event_bus=event_bus 

1040 ) 

1041 if verified: 

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

1043 else: 

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

1045 else: 

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

1047 logger.error( 

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

1049 "no code changes detected despite returncode 0" 

1050 ) 

1051 logger.error( 

1052 "This likely indicates the command was not executed " 

1053 "properly. Check command invocation and Claude CLI " 

1054 "output." 

1055 ) 

1056 verified = False 

1057 else: 

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

1059 verified = True # In dry run, assume success 

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

1061 

1062 # Record timing 

1063 total_issue_time = time.time() - issue_start_time 

1064 issue_timing["total"] = total_issue_time 

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

1066 

1067 if verified: 

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

1069 else: 

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

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

1072 

1073 return IssueProcessingResult( 

1074 success=verified, 

1075 duration=total_issue_time, 

1076 issue_id=info.issue_id, 

1077 corrections=corrections, 

1078 ) 

1079 

1080 

1081class AutoManager: 

1082 """Automated issue manager for sequential processing. 

1083 

1084 Processes issues in priority order using Claude CLI commands, 

1085 with state persistence for resume capability. 

1086 """ 

1087 

1088 def __init__( 

1089 self, 

1090 config: BRConfig, 

1091 dry_run: bool = False, 

1092 max_issues: int = 0, 

1093 resume: bool = False, 

1094 category: str | None = None, 

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

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

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

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

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

1100 verbose: bool = True, 

1101 preview_full: bool = False, 

1102 db_path: Path | None = None, 

1103 skip_learning_gate: bool = False, 

1104 ) -> None: 

1105 """Initialize the auto manager. 

1106 

1107 Args: 

1108 config: Project configuration 

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

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

1111 resume: Whether to resume from previous state 

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

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

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

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

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

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

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

1119 verbose: Whether to output progress messages 

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

1121 """ 

1122 self.config = config 

1123 self.dry_run = dry_run 

1124 self.max_issues = max_issues 

1125 self.resume = resume 

1126 self.category = category 

1127 self.only_ids = only_ids 

1128 self.skip_ids = skip_ids or set() 

1129 self.type_prefixes = type_prefixes 

1130 self.priority_filter = priority_filter 

1131 self.label_filter = label_filter 

1132 self._preview_full = preview_full 

1133 self.skip_learning_gate = skip_learning_gate 

1134 

1135 from little_loops.cli.output import use_color_enabled 

1136 

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

1138 self.event_bus = EventBus() 

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

1140 self.state_manager = StateManager( 

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

1142 ) 

1143 self.parser = IssueParser(config) 

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

1145 

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

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

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

1149 all_known_ids: set[str] | None = None 

1150 try: 

1151 from little_loops.dependency_mapper import gather_all_issue_ids 

1152 

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

1154 all_known_ids = gather_all_issue_ids(issues_dir, config=config) 

1155 except Exception: 

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

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

1158 

1159 # Warn about any cycles 

1160 if self.dep_graph.has_cycles(): 

1161 cycles = self.dep_graph.detect_cycles() 

1162 for cycle in cycles: 

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

1164 

1165 self.processed_count = 0 

1166 self._shutdown_requested = False 

1167 

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

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

1170 

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

1172 """Handle shutdown signals gracefully.""" 

1173 self._shutdown_requested = True 

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

1175 

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

1177 """Get next issue respecting dependencies. 

1178 

1179 Returns the highest priority issue whose blockers have all been 

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

1181 logs warnings about what is blocking progress. 

1182 

1183 Returns: 

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

1185 """ 

1186 # Get completed issues from state 

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

1188 

1189 # Combine skip_ids from state and CLI argument 

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

1191 

1192 # Get issues that are ready (blockers satisfied) 

1193 ready_issues = self.dep_graph.get_ready_issues(completed) 

1194 

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

1196 candidates = [ 

1197 i 

1198 for i in ready_issues 

1199 if i.issue_id not in skip_ids 

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

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

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

1203 and ( 

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

1205 ) 

1206 ] 

1207 

1208 if candidates: 

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

1210 only_ids = self.only_ids 

1211 if isinstance(only_ids, list): 

1212 candidates.sort( 

1213 key=lambda x: next( 

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

1215 len(only_ids), 

1216 ) 

1217 ) 

1218 return candidates[0] 

1219 

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

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

1222 remaining = all_in_graph - completed - skip_ids 

1223 if self.only_ids is not None: 

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

1225 if self.type_prefixes is not None: 

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

1227 if self.priority_filter is not None: 

1228 remaining = { 

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

1230 } 

1231 if self.label_filter is not None: 

1232 remaining = { 

1233 r 

1234 for r in remaining 

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

1236 } 

1237 

1238 if remaining: 

1239 self._log_blocked_issues(remaining, completed) 

1240 

1241 return None 

1242 

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

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

1245 

1246 Args: 

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

1248 completed: Set of completed issue IDs 

1249 """ 

1250 blocked_count = 0 

1251 for issue_id in sorted(remaining): 

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

1253 if blockers: 

1254 blocked_count += 1 

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

1256 

1257 if blocked_count > 0: 

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

1259 

1260 def run(self) -> int: 

1261 """Run the automation loop. 

1262 

1263 Returns: 

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

1265 """ 

1266 run_start_time = time.time() 

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

1268 

1269 if self.dry_run: 

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

1271 

1272 if not self.dry_run: 

1273 has_changes = check_git_status(self.logger) 

1274 if has_changes: 

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

1276 

1277 # Load or initialize state 

1278 if self.resume: 

1279 state = self.state_manager.load() 

1280 if state: 

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

1282 self.processed_count = len(state.completed_issues) 

1283 else: 

1284 # Fresh start 

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

1286 

1287 attempted_count = 0 

1288 try: 

1289 while not self._shutdown_requested: 

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

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

1292 break 

1293 

1294 info = self._get_next_issue() 

1295 if not info: 

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

1297 break 

1298 

1299 attempted_count += 1 

1300 success = self._process_issue(info) 

1301 if success: 

1302 self.processed_count += 1 

1303 

1304 except Exception as e: 

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

1306 return 1 

1307 

1308 finally: 

1309 if not self._shutdown_requested: 

1310 self.state_manager.cleanup() 

1311 self.event_bus.close_transports() 

1312 

1313 self._log_timing_summary(run_start_time) 

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

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

1316 return 1 

1317 return 0 

1318 

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

1320 """Log aggregate timing summary.""" 

1321 total_run_time = time.time() - run_start_time 

1322 

1323 self.logger.info("") 

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

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

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

1327 

1328 state = self.state_manager.state 

1329 if state.timing: 

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

1331 if total_times: 

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

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

1334 

1335 if state.failed_issues: 

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

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

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

1339 

1340 # Log correction statistics for quality tracking 

1341 if state.corrections: 

1342 total_corrected = len(state.corrections) 

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

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

1345 self.logger.info( 

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

1347 ) 

1348 

1349 # Log most common correction types 

1350 from collections import Counter 

1351 

1352 all_corrections: list[str] = [] 

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

1354 all_corrections.extend(corrections) 

1355 if all_corrections: 

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

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

1358 for correction, count in common: 

1359 # Truncate long correction descriptions 

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

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

1362 

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

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

1365 

1366 Delegates to process_issue_inplace() and maps the result back 

1367 to state manager calls. 

1368 

1369 Args: 

1370 info: Issue information 

1371 

1372 Returns: 

1373 True if processing succeeded 

1374 """ 

1375 # Pre-processing state updates (before delegating) 

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

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

1378 

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

1380 if not self._detected_model: 

1381 

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

1383 self._detected_model.append(m) 

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

1385 

1386 resolved_limit = context_window_for( 

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

1388 ) 

1389 result = process_issue_inplace( 

1390 info, 

1391 self.config, 

1392 self.logger, 

1393 self.dry_run, 

1394 on_model_detected=on_model, 

1395 preview_full=self._preview_full, 

1396 event_bus=self.event_bus, 

1397 context_limit=resolved_limit, 

1398 skip_learning_gate=self.skip_learning_gate, 

1399 ) 

1400 

1401 # Map result back to state tracking 

1402 if result.was_closed: 

1403 self.state_manager.mark_completed(info.issue_id) 

1404 elif result.was_blocked: 

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

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

1407 elif result.success: 

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

1409 elif result.plan_created: 

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

1411 self.logger.info( 

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

1413 "leaving in pending state for manual approval" 

1414 ) 

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

1416 elif result.failure_reason: 

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

1418 

1419 if result.corrections: 

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

1421 

1422 return result.success