Coverage for little_loops / issue_manager.py: 10%

515 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-05-28 13:07 -0500

1"""Automated issue management for little-loops. 

2 

3Provides the AutoManager class for sequential issue processing with 

4Claude CLI integration and state persistence for resume capability. 

5""" 

6 

7from __future__ import annotations 

8 

9import json 

10import signal 

11import subprocess 

12import sys 

13import time 

14from collections.abc import Callable, Generator 

15from contextlib import contextmanager 

16from dataclasses import dataclass, field 

17from pathlib import Path 

18from types import FrameType 

19 

20from little_loops.cli_args import _id_matches 

21from little_loops.config import BRConfig 

22from little_loops.dependency_graph import DependencyGraph 

23from little_loops.events import EventBus 

24from little_loops.git_operations import check_git_status, verify_work_was_done 

25from little_loops.issue_lifecycle import ( 

26 FailureType, 

27 classify_failure, 

28 close_issue, 

29 complete_issue_lifecycle, 

30 create_issue_from_failure, 

31 verify_issue_completed, 

32) 

33from little_loops.issue_parser import IssueInfo, IssueParser, find_issues 

34from little_loops.logger import Logger, format_duration 

35from little_loops.output_parsing import parse_ready_issue_output 

36from little_loops.session_store import DEFAULT_DB_PATH, SQLiteTransport 

37from little_loops.skill_expander import expand_skill 

38from little_loops.state import ProcessingState, StateManager, _iso_now 

39from little_loops.subprocess_utils import ( 

40 assemble_guillotine_prompt, 

41 detect_context_handoff, 

42 read_continuation_prompt, 

43 read_sentinel, 

44 write_sentinel, 

45) 

46from little_loops.subprocess_utils import ( 

47 run_claude_command as _run_claude_base, 

48) 

49 

50 

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

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

53 

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

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

56 

57 Args: 

58 abs_path: Absolute path to the file 

59 base_dir: Base directory (defaults to cwd) 

60 

61 Returns: 

62 Relative path string suitable for ready-issue command 

63 """ 

64 base = base_dir or Path.cwd() 

65 try: 

66 return str(abs_path.relative_to(base)) 

67 except ValueError: 

68 # Path not relative to base, use absolute 

69 return str(abs_path) 

70 

71 

72@contextmanager 

73def timed_phase( 

74 logger: Logger, 

75 phase_name: str, 

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

77 """Context manager for timing phases. 

78 

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

80 

81 Args: 

82 logger: Logger for output 

83 phase_name: Name of the phase being timed 

84 

85 Yields: 

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

87 """ 

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

89 start = time.time() 

90 try: 

91 yield timing_result 

92 finally: 

93 elapsed = time.time() - start 

94 timing_result["elapsed"] = elapsed 

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

96 

97 

98def run_claude_command( 

99 command: str, 

100 logger: Logger, 

101 timeout: int = 3600, 

102 stream_output: bool = True, 

103 idle_timeout: int = 0, 

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

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

106 preview_full: bool = False, 

107 resume_session: bool = False, 

108) -> subprocess.CompletedProcess[str]: 

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

110 

111 Args: 

112 command: Command to pass to Claude CLI 

113 logger: Logger for output 

114 timeout: Timeout in seconds 

115 stream_output: Whether to stream output to console 

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

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

118 stream-json system/init event. 

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

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

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

122 

123 Returns: 

124 CompletedProcess with stdout/stderr captured 

125 """ 

126 from little_loops.cli.output import terminal_width 

127 

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

129 line_count = len(lines) 

130 tw = terminal_width() 

131 max_line = tw - 4 

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

133 logger.info( 

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

135 ) 

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

137 for line in lines[:show_count]: 

138 display = ( 

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

140 ) 

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

142 if line_count > show_count: 

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

144 

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

146 if stream_output: 

147 if is_stderr: 

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

149 else: 

150 print(f" {line}") 

151 

152 return _run_claude_base( 

153 command=command, 

154 timeout=timeout, 

155 stream_callback=stream_callback if stream_output else None, 

156 idle_timeout=idle_timeout, 

157 on_model_detected=on_model_detected, 

158 on_usage=on_usage, 

159 resume_session=resume_session, 

160 ) 

161 

162 

163def run_with_continuation( 

164 initial_command: str, 

165 logger: Logger, 

166 timeout: int = 3600, 

167 stream_output: bool = True, 

168 max_continuations: int = 3, 

169 repo_path: Path | None = None, 

170 idle_timeout: int = 0, 

171 resume_command: str | None = None, 

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

173 preview_full: bool = False, 

174 context_limit: int = 200_000, 

175 sentinel_threshold: float = 0.60, 

176 guillotine_threshold: float = 0.90, 

177) -> subprocess.CompletedProcess[str]: 

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

179 

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

181 

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

183 on_usage exceeds sentinel_threshold without a CONTEXT_HANDOFF signal, writes 

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

185 

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

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

188 instruction, triggering the standard CONTEXT_HANDOFF continuation flow. 

189 

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

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

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

193 

194 Args: 

195 initial_command: Initial command to run 

196 logger: Logger for output 

197 timeout: Timeout per session in seconds 

198 stream_output: Whether to stream output 

199 max_continuations: Maximum number of continuation attempts 

200 repo_path: Repository root path 

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

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

203 ``--resume`` to ``initial_command``. 

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

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

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

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

208 

209 Returns: 

210 Final CompletedProcess result 

211 """ 

212 all_stdout: list[str] = [] 

213 all_stderr: list[str] = [] 

214 current_command = initial_command 

215 continuation_count = 0 

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

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

218 ) 

219 

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

221 _last_input: list[int] = [0] 

222 _last_output: list[int] = [0] 

223 _external_on_usage = on_usage 

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

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

226 _just_ran_fresh_session = False 

227 

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

229 _last_input[0] = input_tokens 

230 _last_output[0] = output_tokens 

231 if _external_on_usage is not None: 

232 _external_on_usage(input_tokens, output_tokens) 

233 

234 while continuation_count <= max_continuations: 

235 this_is_fresh = _just_ran_fresh_session 

236 _just_ran_fresh_session = False 

237 result = run_claude_command( 

238 current_command, 

239 logger, 

240 timeout=timeout, 

241 stream_output=stream_output, 

242 idle_timeout=idle_timeout, 

243 on_usage=_tracking_usage, 

244 preview_full=preview_full, 

245 ) 

246 

247 all_stdout.append(result.stdout) 

248 all_stderr.append(result.stderr) 

249 

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

251 if detect_context_handoff(result.stdout): 

252 logger.info("Detected CONTEXT_HANDOFF signal") 

253 

254 # Read continuation prompt 

255 prompt_content = read_continuation_prompt(repo_path) 

256 if not prompt_content: 

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

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

259 result = subprocess.CompletedProcess( 

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

261 ) 

262 break 

263 

264 if continuation_count >= max_continuations: 

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

266 break 

267 

268 continuation_count += 1 

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

270 

271 _base = resume_command if resume_command is not None else initial_command 

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

273 continue 

274 

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

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

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

278 

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

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

281 if ( 

282 prompt_too_long or usage_ratio >= guillotine_threshold 

283 ) and continuation_count < max_continuations: 

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

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

286 try: 

287 guillotine_cmd = assemble_guillotine_prompt( 

288 original_command=initial_command, 

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

290 token_stats={ 

291 "input_tokens": _last_input[0], 

292 "output_tokens": _last_output[0], 

293 "context_limit": context_limit, 

294 "trigger_reason": trigger_reason, 

295 }, 

296 ) 

297 except Exception as exc: 

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

299 guillotine_cmd = initial_command 

300 continuation_count += 1 

301 current_command = guillotine_cmd 

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

303 _last_input[0] = 0 

304 _last_output[0] = 0 

305 _just_ran_fresh_session = True 

306 continue 

307 

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

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

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

311 sentinel_data = read_sentinel(repo_path) 

312 if sentinel_data is not None and this_is_fresh: 

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

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

315 logger.info( 

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

317 ) 

318 elif sentinel_data is not None and continuation_count < max_continuations: 

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

320 logger.info( 

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

322 "sending explicit handoff instruction via --continue" 

323 ) 

324 continuation_count += 1 

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

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

327 explicit_handoff_instruction = ( 

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

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

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

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

332 ) 

333 current_command = explicit_handoff_instruction 

334 # Reset tracking for the handoff turn 

335 _last_input[0] = 0 

336 _last_output[0] = 0 

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

338 result = run_claude_command( 

339 current_command, 

340 logger, 

341 timeout=timeout, 

342 stream_output=stream_output, 

343 idle_timeout=idle_timeout, 

344 on_usage=_tracking_usage, 

345 preview_full=preview_full, 

346 resume_session=True, 

347 ) 

348 all_stdout.append(result.stdout) 

349 all_stderr.append(result.stderr) 

350 

351 # After explicit instruction, check for handoff signal 

352 if detect_context_handoff(result.stdout): 

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

354 prompt_content = read_continuation_prompt(repo_path) 

355 if prompt_content and continuation_count < max_continuations: 

356 continuation_count += 1 

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

358 _base = resume_command if resume_command is not None else initial_command 

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

360 _last_input[0] = 0 

361 _last_output[0] = 0 

362 continue 

363 break 

364 

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

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

367 if total_tokens > 0 and usage_ratio >= sentinel_threshold: 

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

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

370 

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

372 break 

373 

374 return subprocess.CompletedProcess( 

375 args=result.args, 

376 returncode=result.returncode, 

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

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

379 ) 

380 

381 

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

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

384 

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

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

387 

388 Args: 

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

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

391 

392 Returns: 

393 Path to plan file if created, None otherwise 

394 """ 

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

396 if not plans_dir.exists(): 

397 return None 

398 

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

400 # Use glob pattern with issue_id 

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

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

403 

404 if not matching_plans: 

405 return None 

406 

407 # Return the most recently modified plan file 

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

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

410 return latest_plan 

411 

412 

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

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

415 

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

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

418 

419 Args: 

420 issue_path: Path to the issue file 

421 

422 Returns: 

423 True if implementation markers found 

424 """ 

425 try: 

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

427 except (OSError, UnicodeDecodeError): 

428 return False 

429 

430 markers = [ 

431 "## Resolution", 

432 "Status: Implemented", 

433 "Status: Completed", 

434 "**Completed**:", 

435 ] 

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

437 

438 

439@dataclass 

440class IssueProcessingResult: 

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

442 

443 success: bool 

444 duration: float 

445 issue_id: str 

446 was_closed: bool = False 

447 was_blocked: bool = False 

448 failure_reason: str = "" 

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

450 plan_created: bool = False 

451 plan_path: str = "" 

452 

453 

454def process_issue_inplace( 

455 info: IssueInfo, 

456 config: BRConfig, 

457 logger: Logger, 

458 dry_run: bool = False, 

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

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

461 preview_full: bool = False, 

462 event_bus: EventBus | None = None, 

463) -> IssueProcessingResult: 

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

465 

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

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

468 

469 Args: 

470 info: Issue information 

471 config: Project configuration 

472 logger: Logger for output 

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

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

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

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

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

478 

479 Returns: 

480 IssueProcessingResult with outcome details 

481 """ 

482 issue_start_time = time.time() 

483 corrections: list[str] = [] 

484 

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

486 

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

488 

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

490 validated_via_fallback = False 

491 

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

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

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

495 _external_on_usage = on_usage 

496 

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

498 try: 

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

500 state["result_token_count"] = input_tokens + output_tokens 

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

502 except Exception: 

503 pass # never block execution on state write failures 

504 if _external_on_usage is not None: 

505 _external_on_usage(input_tokens, output_tokens) 

506 

507 # Phase 1: Ready/verify the issue 

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

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

510 if not dry_run: 

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

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

513 result = run_claude_command( 

514 _ready_cmd, 

515 logger, 

516 timeout=config.automation.timeout_seconds, 

517 stream_output=config.automation.stream_output, 

518 idle_timeout=config.automation.idle_timeout_seconds, 

519 on_model_detected=on_model_detected, 

520 preview_full=preview_full, 

521 ) 

522 if result.returncode != 0: 

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

524 else: 

525 # Parse the verdict from the output 

526 parsed = parse_ready_issue_output(result.stdout) 

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

528 

529 # Validate that ready-issue analyzed the expected file 

530 validated_path = parsed.get("validated_file_path") 

531 if validated_path: 

532 # Normalize paths for comparison (resolve to absolute) 

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

534 # Handle both absolute and relative paths from ready_issue 

535 validated_resolved = Path(validated_path).resolve() 

536 if str(validated_resolved) != expected_path: 

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

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

539 old_file_exists = info.path.exists() 

540 new_file_exists = validated_resolved.exists() 

541 

542 if new_file_exists and not old_file_exists: 

543 # ready-issue renamed the file - update tracking 

544 logger.info( 

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

546 f"'{validated_resolved.name}'" 

547 ) 

548 info.path = validated_resolved 

549 else: 

550 # Genuine mismatch - attempt fallback with explicit path 

551 logger.warning( 

552 f"Path mismatch: ready-issue validated " 

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

554 ) 

555 logger.info( 

556 "Attempting fallback: retrying ready-issue " 

557 "with explicit file path..." 

558 ) 

559 

560 # Compute relative path for the command 

561 relative_path = _compute_relative_path(info.path) 

562 

563 # Retry with explicit path 

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

565 _retry_cmd = ( 

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

567 or _retry_slash 

568 ) 

569 retry_result = run_claude_command( 

570 _retry_cmd, 

571 logger, 

572 timeout=config.automation.timeout_seconds, 

573 stream_output=config.automation.stream_output, 

574 idle_timeout=config.automation.idle_timeout_seconds, 

575 on_model_detected=on_model_detected, 

576 preview_full=preview_full, 

577 ) 

578 

579 if retry_result.returncode != 0: 

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

581 return IssueProcessingResult( 

582 success=False, 

583 duration=time.time() - issue_start_time, 

584 issue_id=info.issue_id, 

585 failure_reason="Fallback failed after path mismatch", 

586 ) 

587 

588 # Re-parse and validate retry output 

589 retry_parsed = parse_ready_issue_output(retry_result.stdout) 

590 retry_validated_path = retry_parsed.get("validated_file_path") 

591 

592 if retry_validated_path: 

593 retry_resolved = Path(retry_validated_path).resolve() 

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

595 logger.error( 

596 f"Fallback still mismatched: " 

597 f"got '{retry_validated_path}', " 

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

599 ) 

600 return IssueProcessingResult( 

601 success=False, 

602 duration=time.time() - issue_start_time, 

603 issue_id=info.issue_id, 

604 failure_reason="Path mismatch persisted after fallback", 

605 ) 

606 

607 # Fallback succeeded - use retry result 

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

609 parsed = retry_parsed 

610 validated_via_fallback = True 

611 

612 # Log and store any corrections made 

613 if parsed.get("was_corrected"): 

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

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

616 for correction in phase_corrections: 

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

618 if phase_corrections: 

619 corrections.extend(phase_corrections) 

620 

621 # Log any concerns found 

622 if parsed["concerns"]: 

623 for concern in parsed["concerns"]: 

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

625 

626 # Handle CLOSE verdict - issue should not be implemented 

627 if parsed.get("should_close"): 

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

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

630 

631 # CRITICAL: Skip file operations for invalid references 

632 if close_reason == "invalid_ref": 

633 logger.warning( 

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

635 "no matching issue file exists" 

636 ) 

637 return IssueProcessingResult( 

638 success=False, 

639 duration=time.time() - issue_start_time, 

640 issue_id=info.issue_id, 

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

642 corrections=corrections, 

643 ) 

644 

645 # Also require validated_file_path to match before closing 

646 close_validated_path = parsed.get("validated_file_path") 

647 if not close_validated_path: 

648 logger.warning( 

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

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

651 ) 

652 return IssueProcessingResult( 

653 success=False, 

654 duration=time.time() - issue_start_time, 

655 issue_id=info.issue_id, 

656 failure_reason="CLOSE without validated file path", 

657 corrections=corrections, 

658 ) 

659 

660 if close_issue( 

661 info, 

662 config, 

663 logger, 

664 close_reason, 

665 parsed.get("close_status"), 

666 event_bus=event_bus, 

667 ): 

668 return IssueProcessingResult( 

669 success=True, 

670 duration=time.time() - issue_start_time, 

671 issue_id=info.issue_id, 

672 was_closed=True, 

673 corrections=corrections, 

674 ) 

675 else: 

676 return IssueProcessingResult( 

677 success=False, 

678 duration=time.time() - issue_start_time, 

679 issue_id=info.issue_id, 

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

681 corrections=corrections, 

682 ) 

683 

684 # Handle BLOCKED verdict - issue has open dependencies 

685 if parsed.get("is_blocked"): 

686 logger.warning( 

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

688 ) 

689 return IssueProcessingResult( 

690 success=False, 

691 was_blocked=True, 

692 duration=time.time() - issue_start_time, 

693 issue_id=info.issue_id, 

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

695 corrections=corrections, 

696 ) 

697 

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

699 if not parsed["is_ready"]: 

700 logger.error( 

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

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

703 ) 

704 return IssueProcessingResult( 

705 success=False, 

706 duration=time.time() - issue_start_time, 

707 issue_id=info.issue_id, 

708 failure_reason=( 

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

710 ), 

711 corrections=corrections, 

712 ) 

713 

714 # Log if proceeding with corrected issue 

715 if parsed.get("was_corrected"): 

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

717 else: 

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

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

720 

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

722 if info.decision_needed is True and not dry_run: 

723 logger.info( 

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

725 ) 

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

727 _decide_cmd = ( 

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

729 ) 

730 decide_result = run_claude_command( 

731 _decide_cmd, 

732 logger, 

733 timeout=config.automation.timeout_seconds, 

734 stream_output=config.automation.stream_output, 

735 idle_timeout=config.automation.idle_timeout_seconds, 

736 on_model_detected=on_model_detected, 

737 preview_full=preview_full, 

738 ) 

739 if decide_result.returncode != 0: 

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

741 

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

743 action = config.get_category_action(info.issue_type) 

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

745 _baseline_sha_result = subprocess.run( 

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

747 ) 

748 _baseline_sha: str | None = ( 

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

750 ) 

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

752 if not dry_run: 

753 # Build manage-issue command 

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

755 

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

757 if validated_via_fallback: 

758 issue_arg = _compute_relative_path(info.path) 

759 else: 

760 issue_arg = info.issue_id 

761 

762 # Use run_with_continuation to handle context exhaustion. 

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

764 # Pass the short slash command as resume_command so continuation 

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

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

767 _initial_cmd = ( 

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

769 ) 

770 result = run_with_continuation( 

771 _initial_cmd, 

772 logger, 

773 timeout=config.automation.timeout_seconds, 

774 stream_output=config.automation.stream_output, 

775 max_continuations=config.automation.max_continuations, 

776 repo_path=config.repo_path, 

777 idle_timeout=config.automation.idle_timeout_seconds, 

778 resume_command=_slash_cmd, 

779 on_usage=_on_usage_writer, 

780 preview_full=preview_full, 

781 ) 

782 else: 

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

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

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

786 

787 # Handle implementation failure 

788 if result.returncode != 0: 

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

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

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

792 # so Phase 3 runs. 

793 already_done = False 

794 if info.path.exists(): 

795 try: 

796 from little_loops.frontmatter import parse_frontmatter 

797 

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

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

800 except Exception: 

801 already_done = False 

802 if already_done: 

803 logger.warning( 

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

805 "treating as success (continuation artefact)" 

806 ) 

807 result = subprocess.CompletedProcess( 

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

809 ) 

810 else: 

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

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

813 

814 if failure_type == FailureType.TRANSIENT: 

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

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

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

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

819 logger.info(error_output[:500]) 

820 

821 return IssueProcessingResult( 

822 success=False, 

823 duration=time.time() - issue_start_time, 

824 issue_id=info.issue_id, 

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

826 corrections=corrections, 

827 ) 

828 

829 # Real failure - create issue as before 

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

831 

832 failure_reason = "" 

833 if not dry_run: 

834 # Create new issue for the failure 

835 new_issue = create_issue_from_failure( 

836 error_output, 

837 info, 

838 config, 

839 logger, 

840 event_bus=event_bus, 

841 ) 

842 failure_reason = str(new_issue) if new_issue else error_output 

843 else: 

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

845 

846 return IssueProcessingResult( 

847 success=False, 

848 duration=time.time() - issue_start_time, 

849 issue_id=info.issue_id, 

850 failure_reason=failure_reason, 

851 corrections=corrections, 

852 ) 

853 

854 # Phase 3: Verify completion 

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

856 verified = False 

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

858 if not dry_run: 

859 verified = verify_issue_completed(info, config, logger) 

860 

861 # Fallback: Only complete lifecycle if: 

862 # 1. Command returned success (returncode 0) 

863 # 2. File wasn't moved to completed 

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

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

866 # Check if a plan was created awaiting approval 

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

868 if plan_path is not None: 

869 logger.info( 

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

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

872 ) 

873 return IssueProcessingResult( 

874 success=False, 

875 duration=time.time() - issue_start_time, 

876 issue_id=info.issue_id, 

877 plan_created=True, 

878 plan_path=str(plan_path), 

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

880 corrections=corrections, 

881 ) 

882 

883 logger.info( 

884 "Command returned success but issue not moved - " 

885 "checking for evidence of work..." 

886 ) 

887 

888 # Check issue file content for implementation markers 

889 if check_content_markers(info.path): 

890 logger.info( 

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

892 ) 

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

894 if verified: 

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

896 else: 

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

898 else: 

899 # CRITICAL: Verify actual implementation work was done 

900 work_done = verify_work_was_done(logger, baseline_sha=_baseline_sha) 

901 if work_done: 

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

903 verified = complete_issue_lifecycle( 

904 info, config, logger, event_bus=event_bus 

905 ) 

906 if verified: 

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

908 else: 

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

910 else: 

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

912 logger.error( 

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

914 "no code changes detected despite returncode 0" 

915 ) 

916 logger.error( 

917 "This likely indicates the command was not executed " 

918 "properly. Check command invocation and Claude CLI " 

919 "output." 

920 ) 

921 verified = False 

922 else: 

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

924 verified = True # In dry run, assume success 

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

926 

927 # Record timing 

928 total_issue_time = time.time() - issue_start_time 

929 issue_timing["total"] = total_issue_time 

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

931 

932 if verified: 

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

934 else: 

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

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

937 

938 return IssueProcessingResult( 

939 success=verified, 

940 duration=total_issue_time, 

941 issue_id=info.issue_id, 

942 corrections=corrections, 

943 ) 

944 

945 

946class AutoManager: 

947 """Automated issue manager for sequential processing. 

948 

949 Processes issues in priority order using Claude CLI commands, 

950 with state persistence for resume capability. 

951 """ 

952 

953 def __init__( 

954 self, 

955 config: BRConfig, 

956 dry_run: bool = False, 

957 max_issues: int = 0, 

958 resume: bool = False, 

959 category: str | None = None, 

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

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

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

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

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

965 verbose: bool = True, 

966 preview_full: bool = False, 

967 db_path: Path | None = None, 

968 ) -> None: 

969 """Initialize the auto manager. 

970 

971 Args: 

972 config: Project configuration 

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

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

975 resume: Whether to resume from previous state 

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

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

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

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

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

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

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

983 verbose: Whether to output progress messages 

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

985 """ 

986 self.config = config 

987 self.dry_run = dry_run 

988 self.max_issues = max_issues 

989 self.resume = resume 

990 self.category = category 

991 self.only_ids = only_ids 

992 self.skip_ids = skip_ids or set() 

993 self.type_prefixes = type_prefixes 

994 self.priority_filter = priority_filter 

995 self.label_filter = label_filter 

996 self._preview_full = preview_full 

997 

998 from little_loops.cli.output import use_color_enabled 

999 

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

1001 self.event_bus = EventBus() 

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

1003 self.state_manager = StateManager( 

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

1005 ) 

1006 self.parser = IssueParser(config) 

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

1008 

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

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

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

1012 all_known_ids: set[str] | None = None 

1013 try: 

1014 from little_loops.dependency_mapper import gather_all_issue_ids 

1015 

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

1017 all_known_ids = gather_all_issue_ids(issues_dir, config=config) 

1018 except Exception: 

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

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

1021 

1022 # Warn about any cycles 

1023 if self.dep_graph.has_cycles(): 

1024 cycles = self.dep_graph.detect_cycles() 

1025 for cycle in cycles: 

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

1027 

1028 self.processed_count = 0 

1029 self._shutdown_requested = False 

1030 

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

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

1033 

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

1035 """Handle shutdown signals gracefully.""" 

1036 self._shutdown_requested = True 

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

1038 

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

1040 """Get next issue respecting dependencies. 

1041 

1042 Returns the highest priority issue whose blockers have all been 

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

1044 logs warnings about what is blocking progress. 

1045 

1046 Returns: 

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

1048 """ 

1049 # Get completed issues from state 

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

1051 

1052 # Combine skip_ids from state and CLI argument 

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

1054 

1055 # Get issues that are ready (blockers satisfied) 

1056 ready_issues = self.dep_graph.get_ready_issues(completed) 

1057 

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

1059 candidates = [ 

1060 i 

1061 for i in ready_issues 

1062 if i.issue_id not in skip_ids 

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

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

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

1066 and ( 

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

1068 ) 

1069 ] 

1070 

1071 if candidates: 

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

1073 only_ids = self.only_ids 

1074 if isinstance(only_ids, list): 

1075 candidates.sort( 

1076 key=lambda x: next( 

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

1078 len(only_ids), 

1079 ) 

1080 ) 

1081 return candidates[0] 

1082 

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

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

1085 remaining = all_in_graph - completed - skip_ids 

1086 if self.only_ids is not None: 

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

1088 if self.type_prefixes is not None: 

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

1090 if self.priority_filter is not None: 

1091 remaining = { 

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

1093 } 

1094 if self.label_filter is not None: 

1095 remaining = { 

1096 r 

1097 for r in remaining 

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

1099 } 

1100 

1101 if remaining: 

1102 self._log_blocked_issues(remaining, completed) 

1103 

1104 return None 

1105 

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

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

1108 

1109 Args: 

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

1111 completed: Set of completed issue IDs 

1112 """ 

1113 blocked_count = 0 

1114 for issue_id in sorted(remaining): 

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

1116 if blockers: 

1117 blocked_count += 1 

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

1119 

1120 if blocked_count > 0: 

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

1122 

1123 def run(self) -> int: 

1124 """Run the automation loop. 

1125 

1126 Returns: 

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

1128 """ 

1129 run_start_time = time.time() 

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

1131 

1132 if self.dry_run: 

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

1134 

1135 if not self.dry_run: 

1136 has_changes = check_git_status(self.logger) 

1137 if has_changes: 

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

1139 

1140 # Load or initialize state 

1141 if self.resume: 

1142 state = self.state_manager.load() 

1143 if state: 

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

1145 self.processed_count = len(state.completed_issues) 

1146 else: 

1147 # Fresh start 

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

1149 

1150 attempted_count = 0 

1151 try: 

1152 while not self._shutdown_requested: 

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

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

1155 break 

1156 

1157 info = self._get_next_issue() 

1158 if not info: 

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

1160 break 

1161 

1162 attempted_count += 1 

1163 success = self._process_issue(info) 

1164 if success: 

1165 self.processed_count += 1 

1166 

1167 except Exception as e: 

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

1169 return 1 

1170 

1171 finally: 

1172 if not self._shutdown_requested: 

1173 self.state_manager.cleanup() 

1174 self.event_bus.close_transports() 

1175 

1176 self._log_timing_summary(run_start_time) 

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

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

1179 return 1 

1180 return 0 

1181 

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

1183 """Log aggregate timing summary.""" 

1184 total_run_time = time.time() - run_start_time 

1185 

1186 self.logger.info("") 

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

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

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

1190 

1191 state = self.state_manager.state 

1192 if state.timing: 

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

1194 if total_times: 

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

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

1197 

1198 if state.failed_issues: 

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

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

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

1202 

1203 # Log correction statistics for quality tracking 

1204 if state.corrections: 

1205 total_corrected = len(state.corrections) 

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

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

1208 self.logger.info( 

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

1210 ) 

1211 

1212 # Log most common correction types 

1213 from collections import Counter 

1214 

1215 all_corrections: list[str] = [] 

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

1217 all_corrections.extend(corrections) 

1218 if all_corrections: 

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

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

1221 for correction, count in common: 

1222 # Truncate long correction descriptions 

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

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

1225 

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

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

1228 

1229 Delegates to process_issue_inplace() and maps the result back 

1230 to state manager calls. 

1231 

1232 Args: 

1233 info: Issue information 

1234 

1235 Returns: 

1236 True if processing succeeded 

1237 """ 

1238 # Pre-processing state updates (before delegating) 

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

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

1241 

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

1243 if not self._detected_model: 

1244 

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

1246 self._detected_model.append(m) 

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

1248 

1249 result = process_issue_inplace( 

1250 info, 

1251 self.config, 

1252 self.logger, 

1253 self.dry_run, 

1254 on_model_detected=on_model, 

1255 preview_full=self._preview_full, 

1256 event_bus=self.event_bus, 

1257 ) 

1258 

1259 # Map result back to state tracking 

1260 if result.was_closed: 

1261 self.state_manager.mark_completed(info.issue_id) 

1262 elif result.was_blocked: 

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

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

1265 elif result.success: 

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

1267 elif result.plan_created: 

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

1269 self.logger.info( 

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

1271 "leaving in pending state for manual approval" 

1272 ) 

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

1274 elif result.failure_reason: 

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

1276 

1277 if result.corrections: 

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

1279 

1280 return result.success