Coverage for little_loops / issue_manager.py: 88%

512 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-05-22 16:19 -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.skill_expander import expand_skill 

37from little_loops.state import ProcessingState, StateManager, _iso_now 

38from little_loops.subprocess_utils import ( 

39 assemble_guillotine_prompt, 

40 detect_context_handoff, 

41 read_continuation_prompt, 

42 read_sentinel, 

43 write_sentinel, 

44) 

45from little_loops.subprocess_utils import ( 

46 run_claude_command as _run_claude_base, 

47) 

48 

49 

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

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

52 

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

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

55 

56 Args: 

57 abs_path: Absolute path to the file 

58 base_dir: Base directory (defaults to cwd) 

59 

60 Returns: 

61 Relative path string suitable for ready-issue command 

62 """ 

63 base = base_dir or Path.cwd() 

64 try: 

65 return str(abs_path.relative_to(base)) 

66 except ValueError: 

67 # Path not relative to base, use absolute 

68 return str(abs_path) 

69 

70 

71@contextmanager 

72def timed_phase( 

73 logger: Logger, 

74 phase_name: str, 

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

76 """Context manager for timing phases. 

77 

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

79 

80 Args: 

81 logger: Logger for output 

82 phase_name: Name of the phase being timed 

83 

84 Yields: 

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

86 """ 

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

88 start = time.time() 

89 try: 

90 yield timing_result 

91 finally: 

92 elapsed = time.time() - start 

93 timing_result["elapsed"] = elapsed 

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

95 

96 

97def run_claude_command( 

98 command: str, 

99 logger: Logger, 

100 timeout: int = 3600, 

101 stream_output: bool = True, 

102 idle_timeout: int = 0, 

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

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

105 preview_full: bool = False, 

106 resume_session: bool = False, 

107) -> subprocess.CompletedProcess[str]: 

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

109 

110 Args: 

111 command: Command to pass to Claude CLI 

112 logger: Logger for output 

113 timeout: Timeout in seconds 

114 stream_output: Whether to stream output to console 

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

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

117 stream-json system/init event. 

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

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

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

121 

122 Returns: 

123 CompletedProcess with stdout/stderr captured 

124 """ 

125 from little_loops.cli.output import terminal_width 

126 

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

128 line_count = len(lines) 

129 tw = terminal_width() 

130 max_line = tw - 4 

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

132 logger.info( 

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

134 ) 

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

136 for line in lines[:show_count]: 

137 display = ( 

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

139 ) 

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

141 if line_count > show_count: 

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

143 

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

145 if stream_output: 

146 if is_stderr: 

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

148 else: 

149 print(f" {line}") 

150 

151 return _run_claude_base( 

152 command=command, 

153 timeout=timeout, 

154 stream_callback=stream_callback if stream_output else None, 

155 idle_timeout=idle_timeout, 

156 on_model_detected=on_model_detected, 

157 on_usage=on_usage, 

158 resume_session=resume_session, 

159 ) 

160 

161 

162def run_with_continuation( 

163 initial_command: str, 

164 logger: Logger, 

165 timeout: int = 3600, 

166 stream_output: bool = True, 

167 max_continuations: int = 3, 

168 repo_path: Path | None = None, 

169 idle_timeout: int = 0, 

170 resume_command: str | None = None, 

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

172 preview_full: bool = False, 

173 context_limit: int = 200_000, 

174 sentinel_threshold: float = 0.60, 

175 guillotine_threshold: float = 0.90, 

176) -> subprocess.CompletedProcess[str]: 

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

178 

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

180 

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

182 on_usage exceeds sentinel_threshold without a CONTEXT_HANDOFF signal, writes 

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

184 

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

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

187 instruction, triggering the standard CONTEXT_HANDOFF continuation flow. 

188 

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

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

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

192 

193 Args: 

194 initial_command: Initial command to run 

195 logger: Logger for output 

196 timeout: Timeout per session in seconds 

197 stream_output: Whether to stream output 

198 max_continuations: Maximum number of continuation attempts 

199 repo_path: Repository root path 

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

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

202 ``--resume`` to ``initial_command``. 

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

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

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

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

207 

208 Returns: 

209 Final CompletedProcess result 

210 """ 

211 all_stdout: list[str] = [] 

212 all_stderr: list[str] = [] 

213 current_command = initial_command 

214 continuation_count = 0 

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

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

217 ) 

218 

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

220 _last_input: list[int] = [0] 

221 _last_output: list[int] = [0] 

222 _external_on_usage = on_usage 

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

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

225 _just_ran_fresh_session = False 

226 

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

228 _last_input[0] = input_tokens 

229 _last_output[0] = output_tokens 

230 if _external_on_usage is not None: 

231 _external_on_usage(input_tokens, output_tokens) 

232 

233 while continuation_count <= max_continuations: 

234 this_is_fresh = _just_ran_fresh_session 

235 _just_ran_fresh_session = False 

236 result = run_claude_command( 

237 current_command, 

238 logger, 

239 timeout=timeout, 

240 stream_output=stream_output, 

241 idle_timeout=idle_timeout, 

242 on_usage=_tracking_usage, 

243 preview_full=preview_full, 

244 ) 

245 

246 all_stdout.append(result.stdout) 

247 all_stderr.append(result.stderr) 

248 

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

250 if detect_context_handoff(result.stdout): 

251 logger.info("Detected CONTEXT_HANDOFF signal") 

252 

253 # Read continuation prompt 

254 prompt_content = read_continuation_prompt(repo_path) 

255 if not prompt_content: 

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

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

258 result = subprocess.CompletedProcess( 

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

260 ) 

261 break 

262 

263 if continuation_count >= max_continuations: 

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

265 break 

266 

267 continuation_count += 1 

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

269 

270 _base = resume_command if resume_command is not None else initial_command 

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

272 continue 

273 

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

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

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

277 

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

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

280 if ( 

281 prompt_too_long or usage_ratio >= guillotine_threshold 

282 ) and continuation_count < max_continuations: 

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

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

285 try: 

286 guillotine_cmd = assemble_guillotine_prompt( 

287 original_command=initial_command, 

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

289 token_stats={ 

290 "input_tokens": _last_input[0], 

291 "output_tokens": _last_output[0], 

292 "context_limit": context_limit, 

293 "trigger_reason": trigger_reason, 

294 }, 

295 ) 

296 except Exception as exc: 

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

298 guillotine_cmd = initial_command 

299 continuation_count += 1 

300 current_command = guillotine_cmd 

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

302 _last_input[0] = 0 

303 _last_output[0] = 0 

304 _just_ran_fresh_session = True 

305 continue 

306 

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

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

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

310 sentinel_data = read_sentinel(repo_path) 

311 if sentinel_data is not None and this_is_fresh: 

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

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

314 logger.info( 

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

316 ) 

317 elif sentinel_data is not None and continuation_count < max_continuations: 

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

319 logger.info( 

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

321 "sending explicit handoff instruction via --continue" 

322 ) 

323 continuation_count += 1 

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

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

326 explicit_handoff_instruction = ( 

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

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

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

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

331 ) 

332 current_command = explicit_handoff_instruction 

333 # Reset tracking for the handoff turn 

334 _last_input[0] = 0 

335 _last_output[0] = 0 

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

337 result = run_claude_command( 

338 current_command, 

339 logger, 

340 timeout=timeout, 

341 stream_output=stream_output, 

342 idle_timeout=idle_timeout, 

343 on_usage=_tracking_usage, 

344 preview_full=preview_full, 

345 resume_session=True, 

346 ) 

347 all_stdout.append(result.stdout) 

348 all_stderr.append(result.stderr) 

349 

350 # After explicit instruction, check for handoff signal 

351 if detect_context_handoff(result.stdout): 

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

353 prompt_content = read_continuation_prompt(repo_path) 

354 if prompt_content and continuation_count < max_continuations: 

355 continuation_count += 1 

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

357 _base = resume_command if resume_command is not None else initial_command 

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

359 _last_input[0] = 0 

360 _last_output[0] = 0 

361 continue 

362 break 

363 

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

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

366 if total_tokens > 0 and usage_ratio >= sentinel_threshold: 

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

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

369 

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

371 break 

372 

373 return subprocess.CompletedProcess( 

374 args=result.args, 

375 returncode=result.returncode, 

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

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

378 ) 

379 

380 

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

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

383 

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

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

386 

387 Args: 

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

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

390 

391 Returns: 

392 Path to plan file if created, None otherwise 

393 """ 

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

395 if not plans_dir.exists(): 

396 return None 

397 

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

399 # Use glob pattern with issue_id 

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

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

402 

403 if not matching_plans: 

404 return None 

405 

406 # Return the most recently modified plan file 

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

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

409 return latest_plan 

410 

411 

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

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

414 

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

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

417 

418 Args: 

419 issue_path: Path to the issue file 

420 

421 Returns: 

422 True if implementation markers found 

423 """ 

424 try: 

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

426 except (OSError, UnicodeDecodeError): 

427 return False 

428 

429 markers = [ 

430 "## Resolution", 

431 "Status: Implemented", 

432 "Status: Completed", 

433 "**Completed**:", 

434 ] 

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

436 

437 

438@dataclass 

439class IssueProcessingResult: 

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

441 

442 success: bool 

443 duration: float 

444 issue_id: str 

445 was_closed: bool = False 

446 was_blocked: bool = False 

447 failure_reason: str = "" 

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

449 plan_created: bool = False 

450 plan_path: str = "" 

451 

452 

453def process_issue_inplace( 

454 info: IssueInfo, 

455 config: BRConfig, 

456 logger: Logger, 

457 dry_run: bool = False, 

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

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

460 preview_full: bool = False, 

461) -> IssueProcessingResult: 

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

463 

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

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

466 

467 Args: 

468 info: Issue information 

469 config: Project configuration 

470 logger: Logger for output 

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

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

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

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

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

476 

477 Returns: 

478 IssueProcessingResult with outcome details 

479 """ 

480 issue_start_time = time.time() 

481 corrections: list[str] = [] 

482 

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

484 

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

486 

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

488 validated_via_fallback = False 

489 

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

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

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

493 _external_on_usage = on_usage 

494 

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

496 try: 

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

498 state["result_token_count"] = input_tokens + output_tokens 

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

500 except Exception: 

501 pass # never block execution on state write failures 

502 if _external_on_usage is not None: 

503 _external_on_usage(input_tokens, output_tokens) 

504 

505 # Phase 1: Ready/verify the issue 

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

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

508 if not dry_run: 

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

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

511 result = run_claude_command( 

512 _ready_cmd, 

513 logger, 

514 timeout=config.automation.timeout_seconds, 

515 stream_output=config.automation.stream_output, 

516 idle_timeout=config.automation.idle_timeout_seconds, 

517 on_model_detected=on_model_detected, 

518 preview_full=preview_full, 

519 ) 

520 if result.returncode != 0: 

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

522 else: 

523 # Parse the verdict from the output 

524 parsed = parse_ready_issue_output(result.stdout) 

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

526 

527 # Validate that ready-issue analyzed the expected file 

528 validated_path = parsed.get("validated_file_path") 

529 if validated_path: 

530 # Normalize paths for comparison (resolve to absolute) 

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

532 # Handle both absolute and relative paths from ready_issue 

533 validated_resolved = Path(validated_path).resolve() 

534 if str(validated_resolved) != expected_path: 

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

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

537 old_file_exists = info.path.exists() 

538 new_file_exists = validated_resolved.exists() 

539 

540 if new_file_exists and not old_file_exists: 

541 # ready-issue renamed the file - update tracking 

542 logger.info( 

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

544 f"'{validated_resolved.name}'" 

545 ) 

546 info.path = validated_resolved 

547 else: 

548 # Genuine mismatch - attempt fallback with explicit path 

549 logger.warning( 

550 f"Path mismatch: ready-issue validated " 

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

552 ) 

553 logger.info( 

554 "Attempting fallback: retrying ready-issue " 

555 "with explicit file path..." 

556 ) 

557 

558 # Compute relative path for the command 

559 relative_path = _compute_relative_path(info.path) 

560 

561 # Retry with explicit path 

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

563 _retry_cmd = ( 

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

565 or _retry_slash 

566 ) 

567 retry_result = run_claude_command( 

568 _retry_cmd, 

569 logger, 

570 timeout=config.automation.timeout_seconds, 

571 stream_output=config.automation.stream_output, 

572 idle_timeout=config.automation.idle_timeout_seconds, 

573 on_model_detected=on_model_detected, 

574 preview_full=preview_full, 

575 ) 

576 

577 if retry_result.returncode != 0: 

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

579 return IssueProcessingResult( 

580 success=False, 

581 duration=time.time() - issue_start_time, 

582 issue_id=info.issue_id, 

583 failure_reason="Fallback failed after path mismatch", 

584 ) 

585 

586 # Re-parse and validate retry output 

587 retry_parsed = parse_ready_issue_output(retry_result.stdout) 

588 retry_validated_path = retry_parsed.get("validated_file_path") 

589 

590 if retry_validated_path: 

591 retry_resolved = Path(retry_validated_path).resolve() 

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

593 logger.error( 

594 f"Fallback still mismatched: " 

595 f"got '{retry_validated_path}', " 

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

597 ) 

598 return IssueProcessingResult( 

599 success=False, 

600 duration=time.time() - issue_start_time, 

601 issue_id=info.issue_id, 

602 failure_reason="Path mismatch persisted after fallback", 

603 ) 

604 

605 # Fallback succeeded - use retry result 

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

607 parsed = retry_parsed 

608 validated_via_fallback = True 

609 

610 # Log and store any corrections made 

611 if parsed.get("was_corrected"): 

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

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

614 for correction in phase_corrections: 

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

616 if phase_corrections: 

617 corrections.extend(phase_corrections) 

618 

619 # Log any concerns found 

620 if parsed["concerns"]: 

621 for concern in parsed["concerns"]: 

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

623 

624 # Handle CLOSE verdict - issue should not be implemented 

625 if parsed.get("should_close"): 

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

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

628 

629 # CRITICAL: Skip file operations for invalid references 

630 if close_reason == "invalid_ref": 

631 logger.warning( 

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

633 "no matching issue file exists" 

634 ) 

635 return IssueProcessingResult( 

636 success=False, 

637 duration=time.time() - issue_start_time, 

638 issue_id=info.issue_id, 

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

640 corrections=corrections, 

641 ) 

642 

643 # Also require validated_file_path to match before closing 

644 close_validated_path = parsed.get("validated_file_path") 

645 if not close_validated_path: 

646 logger.warning( 

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

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

649 ) 

650 return IssueProcessingResult( 

651 success=False, 

652 duration=time.time() - issue_start_time, 

653 issue_id=info.issue_id, 

654 failure_reason="CLOSE without validated file path", 

655 corrections=corrections, 

656 ) 

657 

658 if close_issue( 

659 info, 

660 config, 

661 logger, 

662 close_reason, 

663 parsed.get("close_status"), 

664 ): 

665 return IssueProcessingResult( 

666 success=True, 

667 duration=time.time() - issue_start_time, 

668 issue_id=info.issue_id, 

669 was_closed=True, 

670 corrections=corrections, 

671 ) 

672 else: 

673 return IssueProcessingResult( 

674 success=False, 

675 duration=time.time() - issue_start_time, 

676 issue_id=info.issue_id, 

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

678 corrections=corrections, 

679 ) 

680 

681 # Handle BLOCKED verdict - issue has open dependencies 

682 if parsed.get("is_blocked"): 

683 logger.warning( 

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

685 ) 

686 return IssueProcessingResult( 

687 success=False, 

688 was_blocked=True, 

689 duration=time.time() - issue_start_time, 

690 issue_id=info.issue_id, 

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

692 corrections=corrections, 

693 ) 

694 

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

696 if not parsed["is_ready"]: 

697 logger.error( 

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

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

700 ) 

701 return IssueProcessingResult( 

702 success=False, 

703 duration=time.time() - issue_start_time, 

704 issue_id=info.issue_id, 

705 failure_reason=( 

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

707 ), 

708 corrections=corrections, 

709 ) 

710 

711 # Log if proceeding with corrected issue 

712 if parsed.get("was_corrected"): 

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

714 else: 

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

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

717 

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

719 if info.decision_needed is True and not dry_run: 

720 logger.info( 

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

722 ) 

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

724 _decide_cmd = ( 

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

726 ) 

727 decide_result = run_claude_command( 

728 _decide_cmd, 

729 logger, 

730 timeout=config.automation.timeout_seconds, 

731 stream_output=config.automation.stream_output, 

732 idle_timeout=config.automation.idle_timeout_seconds, 

733 on_model_detected=on_model_detected, 

734 preview_full=preview_full, 

735 ) 

736 if decide_result.returncode != 0: 

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

738 

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

740 action = config.get_category_action(info.issue_type) 

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

742 _baseline_sha_result = subprocess.run( 

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

744 ) 

745 _baseline_sha: str | None = ( 

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

747 ) 

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

749 if not dry_run: 

750 # Build manage-issue command 

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

752 

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

754 if validated_via_fallback: 

755 issue_arg = _compute_relative_path(info.path) 

756 else: 

757 issue_arg = info.issue_id 

758 

759 # Use run_with_continuation to handle context exhaustion. 

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

761 # Pass the short slash command as resume_command so continuation 

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

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

764 _initial_cmd = ( 

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

766 ) 

767 result = run_with_continuation( 

768 _initial_cmd, 

769 logger, 

770 timeout=config.automation.timeout_seconds, 

771 stream_output=config.automation.stream_output, 

772 max_continuations=config.automation.max_continuations, 

773 repo_path=config.repo_path, 

774 idle_timeout=config.automation.idle_timeout_seconds, 

775 resume_command=_slash_cmd, 

776 on_usage=_on_usage_writer, 

777 preview_full=preview_full, 

778 ) 

779 else: 

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

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

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

783 

784 # Handle implementation failure 

785 if result.returncode != 0: 

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

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

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

789 # so Phase 3 runs. 

790 already_done = False 

791 if info.path.exists(): 

792 try: 

793 from little_loops.frontmatter import parse_frontmatter 

794 

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

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

797 except Exception: 

798 already_done = False 

799 if already_done: 

800 logger.warning( 

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

802 "treating as success (continuation artefact)" 

803 ) 

804 result = subprocess.CompletedProcess( 

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

806 ) 

807 else: 

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

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

810 

811 if failure_type == FailureType.TRANSIENT: 

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

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

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

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

816 logger.info(error_output[:500]) 

817 

818 return IssueProcessingResult( 

819 success=False, 

820 duration=time.time() - issue_start_time, 

821 issue_id=info.issue_id, 

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

823 corrections=corrections, 

824 ) 

825 

826 # Real failure - create issue as before 

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

828 

829 failure_reason = "" 

830 if not dry_run: 

831 # Create new issue for the failure 

832 new_issue = create_issue_from_failure( 

833 error_output, 

834 info, 

835 config, 

836 logger, 

837 ) 

838 failure_reason = str(new_issue) if new_issue else error_output 

839 else: 

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

841 

842 return IssueProcessingResult( 

843 success=False, 

844 duration=time.time() - issue_start_time, 

845 issue_id=info.issue_id, 

846 failure_reason=failure_reason, 

847 corrections=corrections, 

848 ) 

849 

850 # Phase 3: Verify completion 

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

852 verified = False 

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

854 if not dry_run: 

855 verified = verify_issue_completed(info, config, logger) 

856 

857 # Fallback: Only complete lifecycle if: 

858 # 1. Command returned success (returncode 0) 

859 # 2. File wasn't moved to completed 

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

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

862 # Check if a plan was created awaiting approval 

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

864 if plan_path is not None: 

865 logger.info( 

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

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

868 ) 

869 return IssueProcessingResult( 

870 success=False, 

871 duration=time.time() - issue_start_time, 

872 issue_id=info.issue_id, 

873 plan_created=True, 

874 plan_path=str(plan_path), 

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

876 corrections=corrections, 

877 ) 

878 

879 logger.info( 

880 "Command returned success but issue not moved - " 

881 "checking for evidence of work..." 

882 ) 

883 

884 # Check issue file content for implementation markers 

885 if check_content_markers(info.path): 

886 logger.info( 

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

888 ) 

889 verified = complete_issue_lifecycle(info, config, logger) 

890 if verified: 

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

892 else: 

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

894 else: 

895 # CRITICAL: Verify actual implementation work was done 

896 work_done = verify_work_was_done(logger, baseline_sha=_baseline_sha) 

897 if work_done: 

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

899 verified = complete_issue_lifecycle(info, config, logger) 

900 if verified: 

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

902 else: 

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

904 else: 

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

906 logger.error( 

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

908 "no code changes detected despite returncode 0" 

909 ) 

910 logger.error( 

911 "This likely indicates the command was not executed " 

912 "properly. Check command invocation and Claude CLI " 

913 "output." 

914 ) 

915 verified = False 

916 else: 

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

918 verified = True # In dry run, assume success 

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

920 

921 # Record timing 

922 total_issue_time = time.time() - issue_start_time 

923 issue_timing["total"] = total_issue_time 

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

925 

926 if verified: 

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

928 else: 

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

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

931 

932 return IssueProcessingResult( 

933 success=verified, 

934 duration=total_issue_time, 

935 issue_id=info.issue_id, 

936 corrections=corrections, 

937 ) 

938 

939 

940class AutoManager: 

941 """Automated issue manager for sequential processing. 

942 

943 Processes issues in priority order using Claude CLI commands, 

944 with state persistence for resume capability. 

945 """ 

946 

947 def __init__( 

948 self, 

949 config: BRConfig, 

950 dry_run: bool = False, 

951 max_issues: int = 0, 

952 resume: bool = False, 

953 category: str | None = None, 

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

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

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

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

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

959 verbose: bool = True, 

960 preview_full: bool = False, 

961 ) -> None: 

962 """Initialize the auto manager. 

963 

964 Args: 

965 config: Project configuration 

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

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

968 resume: Whether to resume from previous state 

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

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

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

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

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

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

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

976 verbose: Whether to output progress messages 

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

978 """ 

979 self.config = config 

980 self.dry_run = dry_run 

981 self.max_issues = max_issues 

982 self.resume = resume 

983 self.category = category 

984 self.only_ids = only_ids 

985 self.skip_ids = skip_ids or set() 

986 self.type_prefixes = type_prefixes 

987 self.priority_filter = priority_filter 

988 self.label_filter = label_filter 

989 self._preview_full = preview_full 

990 

991 from little_loops.cli.output import use_color_enabled 

992 

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

994 self.event_bus = EventBus() 

995 self.state_manager = StateManager( 

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

997 ) 

998 self.parser = IssueParser(config) 

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

1000 

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

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

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

1004 all_known_ids: set[str] | None = None 

1005 try: 

1006 from little_loops.dependency_mapper import gather_all_issue_ids 

1007 

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

1009 all_known_ids = gather_all_issue_ids(issues_dir, config=config) 

1010 except Exception: 

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

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

1013 

1014 # Warn about any cycles 

1015 if self.dep_graph.has_cycles(): 

1016 cycles = self.dep_graph.detect_cycles() 

1017 for cycle in cycles: 

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

1019 

1020 self.processed_count = 0 

1021 self._shutdown_requested = False 

1022 

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

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

1025 

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

1027 """Handle shutdown signals gracefully.""" 

1028 self._shutdown_requested = True 

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

1030 

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

1032 """Get next issue respecting dependencies. 

1033 

1034 Returns the highest priority issue whose blockers have all been 

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

1036 logs warnings about what is blocking progress. 

1037 

1038 Returns: 

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

1040 """ 

1041 # Get completed issues from state 

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

1043 

1044 # Combine skip_ids from state and CLI argument 

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

1046 

1047 # Get issues that are ready (blockers satisfied) 

1048 ready_issues = self.dep_graph.get_ready_issues(completed) 

1049 

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

1051 candidates = [ 

1052 i 

1053 for i in ready_issues 

1054 if i.issue_id not in skip_ids 

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

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

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

1058 and ( 

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

1060 ) 

1061 ] 

1062 

1063 if candidates: 

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

1065 only_ids = self.only_ids 

1066 if isinstance(only_ids, list): 

1067 candidates.sort( 

1068 key=lambda x: next( 

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

1070 len(only_ids), 

1071 ) 

1072 ) 

1073 return candidates[0] 

1074 

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

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

1077 remaining = all_in_graph - completed - skip_ids 

1078 if self.only_ids is not None: 

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

1080 if self.type_prefixes is not None: 

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

1082 if self.priority_filter is not None: 

1083 remaining = { 

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

1085 } 

1086 if self.label_filter is not None: 

1087 remaining = { 

1088 r 

1089 for r in remaining 

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

1091 } 

1092 

1093 if remaining: 

1094 self._log_blocked_issues(remaining, completed) 

1095 

1096 return None 

1097 

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

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

1100 

1101 Args: 

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

1103 completed: Set of completed issue IDs 

1104 """ 

1105 blocked_count = 0 

1106 for issue_id in sorted(remaining): 

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

1108 if blockers: 

1109 blocked_count += 1 

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

1111 

1112 if blocked_count > 0: 

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

1114 

1115 def run(self) -> int: 

1116 """Run the automation loop. 

1117 

1118 Returns: 

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

1120 """ 

1121 run_start_time = time.time() 

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

1123 

1124 if self.dry_run: 

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

1126 

1127 if not self.dry_run: 

1128 has_changes = check_git_status(self.logger) 

1129 if has_changes: 

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

1131 

1132 # Load or initialize state 

1133 if self.resume: 

1134 state = self.state_manager.load() 

1135 if state: 

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

1137 self.processed_count = len(state.completed_issues) 

1138 else: 

1139 # Fresh start 

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

1141 

1142 attempted_count = 0 

1143 try: 

1144 while not self._shutdown_requested: 

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

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

1147 break 

1148 

1149 info = self._get_next_issue() 

1150 if not info: 

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

1152 break 

1153 

1154 attempted_count += 1 

1155 success = self._process_issue(info) 

1156 if success: 

1157 self.processed_count += 1 

1158 

1159 except Exception as e: 

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

1161 return 1 

1162 

1163 finally: 

1164 if not self._shutdown_requested: 

1165 self.state_manager.cleanup() 

1166 

1167 self._log_timing_summary(run_start_time) 

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

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

1170 return 1 

1171 return 0 

1172 

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

1174 """Log aggregate timing summary.""" 

1175 total_run_time = time.time() - run_start_time 

1176 

1177 self.logger.info("") 

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

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

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

1181 

1182 state = self.state_manager.state 

1183 if state.timing: 

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

1185 if total_times: 

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

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

1188 

1189 if state.failed_issues: 

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

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

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

1193 

1194 # Log correction statistics for quality tracking 

1195 if state.corrections: 

1196 total_corrected = len(state.corrections) 

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

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

1199 self.logger.info( 

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

1201 ) 

1202 

1203 # Log most common correction types 

1204 from collections import Counter 

1205 

1206 all_corrections: list[str] = [] 

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

1208 all_corrections.extend(corrections) 

1209 if all_corrections: 

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

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

1212 for correction, count in common: 

1213 # Truncate long correction descriptions 

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

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

1216 

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

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

1219 

1220 Delegates to process_issue_inplace() and maps the result back 

1221 to state manager calls. 

1222 

1223 Args: 

1224 info: Issue information 

1225 

1226 Returns: 

1227 True if processing succeeded 

1228 """ 

1229 # Pre-processing state updates (before delegating) 

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

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

1232 

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

1234 if not self._detected_model: 

1235 

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

1237 self._detected_model.append(m) 

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

1239 

1240 result = process_issue_inplace( 

1241 info, 

1242 self.config, 

1243 self.logger, 

1244 self.dry_run, 

1245 on_model_detected=on_model, 

1246 preview_full=self._preview_full, 

1247 ) 

1248 

1249 # Map result back to state tracking 

1250 if result.was_closed: 

1251 self.state_manager.mark_completed(info.issue_id) 

1252 elif result.was_blocked: 

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

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

1255 elif result.success: 

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

1257 elif result.plan_created: 

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

1259 self.logger.info( 

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

1261 "leaving in pending state for manual approval" 

1262 ) 

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

1264 elif result.failure_reason: 

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

1266 

1267 if result.corrections: 

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

1269 

1270 return result.success