Coverage for little_loops/issue_manager.py: 13%

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

2

3 Provides the AutoManager class for sequential issue processing with

4 Claude CLI integration and state persistence for resume capability.

5"""

6

7 from __future__ import annotations

8

9 import signal

10 import subprocess

11 import sys

12 import time

13 from collections.abc import Generator

14 from contextlib import contextmanager

15 from dataclasses import dataclass, field

16 from datetime import datetime

17 from pathlib import Path

18 from types import FrameType

19

20 from little_loops.config import BRConfig

21 from little_loops.dependency_graph import DependencyGraph

22 from little_loops.git_operations import check_git_status, verify_work_was_done

23 from little_loops.issue_lifecycle import (

24 FailureType,

25 classify_failure,

26 close_issue,

27 complete_issue_lifecycle,

28 create_issue_from_failure,

29 verify_issue_completed,

30)

31 from little_loops.issue_parser import IssueInfo, IssueParser, find_issues

32 from little_loops.logger import Logger, format_duration

33 from little_loops.output_parsing import parse_ready_issue_output

34 from little_loops.state import ProcessingState, StateManager

35 from little_loops.subprocess_utils import (

36 detect_context_handoff,

37 read_continuation_prompt,

38)

39 from little_loops.subprocess_utils import (

40 run_claude_command as _run_claude_base,

41)

42

43

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

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

46

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

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

49

50 Args:

51 abs_path: Absolute path to the file

52 base_dir: Base directory (defaults to cwd)

53

54 Returns:

55 Relative path string suitable for ready-issue command

56 """

57 base = base_dir or Path.cwd()

58 try:

59 return str(abs_path.relative_to(base))

60 except ValueError:

61 # Path not relative to base, use absolute

62 return str(abs_path)

63

64

65@contextmanager

66 def timed_phase(

67 logger: Logger,

68 phase_name: str,

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

70 """Context manager for timing phases.

71

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

73

74 Args:

75 logger: Logger for output

76 phase_name: Name of the phase being timed

77

78 Yields:

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

80 """

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

82 start = time.time()

83 try:

84 yield timing_result

85 finally:

86 elapsed = time.time() - start

87 timing_result["elapsed"] = elapsed

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

89

90

91 def run_claude_command(

92 command: str,

93 logger: Logger,

94 timeout: int = 3600,

95 stream_output: bool = True,

96 idle_timeout: int = 0,

97) -> subprocess.CompletedProcess[str]:

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

99

100 Args:

101 command: Command to pass to Claude CLI

102 logger: Logger for output

103 timeout: Timeout in seconds

104 stream_output: Whether to stream output to console

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

106

107 Returns:

108 CompletedProcess with stdout/stderr captured

109 """

110 logger.info(f"Running: claude --dangerously-skip-permissions -p {command! r}")

111

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

113 if stream_output:

114 if is_stderr:

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

116 else:

117 print(f" {line}")

118

119 return _run_claude_base(

120 command=command,

121 timeout=timeout,

122 stream_callback=stream_callback if stream_output else None,

123 idle_timeout=idle_timeout,

124 )

125

126

127 def run_with_continuation(

128 initial_command: str,

129 logger: Logger,

130 timeout: int = 3600,

131 stream_output: bool = True,

132 max_continuations: int = 3,

133 repo_path: Path | None = None,

134 idle_timeout: int = 0,

135) -> subprocess.CompletedProcess[str]:

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

137

138 If the command signals CONTEXT_HANDOFF, reads the continuation prompt

139 and spawns a fresh Claude session to continue the work.

140

141 Args:

142 initial_command: Initial command to run

143 logger: Logger for output

144 timeout: Timeout per session in seconds

145 stream_output: Whether to stream output

146 max_continuations: Maximum number of continuation attempts

147 repo_path: Repository root path

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

149

150 Returns:

151 Final CompletedProcess result

152 """

153 all_stdout: list[str] = []

154 all_stderr: list[str] = []

155 current_command = initial_command

156 continuation_count = 0

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

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

159 )

160

161 while continuation_count <= max_continuations:

162 result = run_claude_command(

163 current_command,

164 logger,

165 timeout=timeout,

166 stream_output=stream_output,

167 idle_timeout=idle_timeout,

168 )

169

170 all_stdout.append(result.stdout)

171 all_stderr.append(result.stderr)

172

173 # Check for context handoff signal

174 if detect_context_handoff(result.stdout):

175 logger.info("Detected CONTEXT_HANDOFF signal")

176

177 # Read continuation prompt

178 prompt_content = read_continuation_prompt(repo_path)

179 if not prompt_content:

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

181 break

182

183 if continuation_count >= max_continuations:

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

185 break

186

187 continuation_count += 1

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

189

190 # Re-invoke the original command with --resume flag so the skill

191 # lifecycle (including completion/file-move) runs in the new session.

192 # The skill reads .claude/ll-continue-prompt.md for context.

193 current_command = f"{initial_command} --resume"

194 continue

195

196 # No handoff signal, we're done

197 break

198

199 return subprocess.CompletedProcess(

200 args=result.args,

201 returncode=result.returncode,

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

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

204 )

205

206

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

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

209

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

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

212

213 Args:

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

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

216

217 Returns:

218 Path to plan file if created, None otherwise

219 """

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

221 if not plans_dir.exists():

222 return None

223

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

225 # Use glob pattern with issue_id

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

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

228

229 if not matching_plans:

230 return None

231

232 # Return the most recently modified plan file

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

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

235 return latest_plan

236

237

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

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

240

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

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

243

244 Args:

245 issue_path: Path to the issue file

246

247 Returns:

248 True if implementation markers found

249 """

250 try:

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

252 except (OSError, UnicodeDecodeError):

253 return False

254

255 markers = [

256 "## Resolution",

257 "Status: Implemented",

258 "Status: Completed",

259 "**Completed**:",

260 ]

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

262

263

264@dataclass

265 class IssueProcessingResult:

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

267

268 success: bool

269 duration: float

270 issue_id: str

271 was_closed: bool = False

272 was_blocked: bool = False

273 failure_reason: str = ""

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

275 plan_created: bool = False

276 plan_path: str = ""

277

278

279 def process_issue_inplace(

280 info: IssueInfo,

281 config: BRConfig,

282 logger: Logger,

283 dry_run: bool = False,

284) -> IssueProcessingResult:

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

286

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

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

289

290 Args:

291 info: Issue information

292 config: Project configuration

293 logger: Logger for output

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

295

296 Returns:

297 IssueProcessingResult with outcome details

298 """

299 issue_start_time = time.time()

300 corrections: list[str] = []

301

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

303

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

305

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

307 validated_via_fallback = False

308

309 # Phase 1: Ready/verify the issue

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

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

312 if not dry_run:

313 result = run_claude_command(

314 f"/ll:ready-issue {info.issue_id}",

315 logger,

316 timeout=config.automation.timeout_seconds,

317 stream_output=config.automation.stream_output,

318 idle_timeout=config.automation.idle_timeout_seconds,

319 )

320 if result.returncode != 0:

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

322 else:

323 # Parse the verdict from the output

324 parsed = parse_ready_issue_output(result.stdout)

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

326

327 # Validate that ready-issue analyzed the expected file

328 validated_path = parsed.get("validated_file_path")

329 if validated_path:

330 # Normalize paths for comparison (resolve to absolute)

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

332 # Handle both absolute and relative paths from ready_issue

333 validated_resolved = Path(validated_path).resolve()

334 if str(validated_resolved) != expected_path:

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

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

337 old_file_exists = info.path.exists()

338 new_file_exists = validated_resolved.exists()

339

340 if new_file_exists and not old_file_exists:

341 # ready-issue renamed the file - update tracking

342 logger.info(

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

344 f"'{validated_resolved.name}'"

345 )

346 info.path = validated_resolved

347 else:

348 # Genuine mismatch - attempt fallback with explicit path

349 logger.warning(

350 f"Path mismatch: ready-issue validated "

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

352 )

353 logger.info(

354 "Attempting fallback: retrying ready-issue "

355 "with explicit file path..."

356 )

357

358 # Compute relative path for the command

359 relative_path = _compute_relative_path(info.path)

360

361 # Retry with explicit path

362 retry_result = run_claude_command(

363 f"/ll:ready-issue {relative_path}",

364 logger,

365 timeout=config.automation.timeout_seconds,

366 stream_output=config.automation.stream_output,

367 idle_timeout=config.automation.idle_timeout_seconds,

368 )

369

370 if retry_result.returncode != 0:

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

372 return IssueProcessingResult(

373 success=False,

374 duration=time.time() - issue_start_time,

375 issue_id=info.issue_id,

376 failure_reason="Fallback failed after path mismatch",

377 )

378

379 # Re-parse and validate retry output

380 retry_parsed = parse_ready_issue_output(retry_result.stdout)

381 retry_validated_path = retry_parsed.get("validated_file_path")

382

383 if retry_validated_path:

384 retry_resolved = Path(retry_validated_path).resolve()

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

386 logger.error(

387 f"Fallback still mismatched: "

388 f"got '{retry_validated_path}', "

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

390 )

391 return IssueProcessingResult(

392 success=False,

393 duration=time.time() - issue_start_time,

394 issue_id=info.issue_id,

395 failure_reason="Path mismatch persisted after fallback",

396 )

397

398 # Fallback succeeded - use retry result

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

400 parsed = retry_parsed

401 validated_via_fallback = True

402

403 # Log and store any corrections made

404 if parsed.get("was_corrected"):

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

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

407 for correction in phase_corrections:

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

409 if phase_corrections:

410 corrections.extend(phase_corrections)

411

412 # Log any concerns found

413 if parsed["concerns"]:

414 for concern in parsed["concerns"]:

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

416

417 # Handle CLOSE verdict - issue should not be implemented

418 if parsed.get("should_close"):

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

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

421

422 # CRITICAL: Skip file operations for invalid references

423 if close_reason == "invalid_ref":

424 logger.warning(

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

426 "no matching issue file exists"

427 )

428 return IssueProcessingResult(

429 success=False,

430 duration=time.time() - issue_start_time,

431 issue_id=info.issue_id,

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

433 corrections=corrections,

434 )

435

436 # Also require validated_file_path to match before closing

437 close_validated_path = parsed.get("validated_file_path")

438 if not close_validated_path:

439 logger.warning(

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

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

442 )

443 return IssueProcessingResult(

444 success=False,

445 duration=time.time() - issue_start_time,

446 issue_id=info.issue_id,

447 failure_reason="CLOSE without validated file path",

448 corrections=corrections,

449 )

450

451 if close_issue(

452 info,

453 config,

454 logger,

455 close_reason,

456 parsed.get("close_status"),

457 ):

458 return IssueProcessingResult(

459 success=True,

460 duration=time.time() - issue_start_time,

461 issue_id=info.issue_id,

462 was_closed=True,

463 corrections=corrections,

464 )

465 else:

466 return IssueProcessingResult(

467 success=False,

468 duration=time.time() - issue_start_time,

469 issue_id=info.issue_id,

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

471 corrections=corrections,

472 )

473

474 # Handle BLOCKED verdict - issue has open dependencies

475 if parsed.get("is_blocked"):

476 logger.warning(

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

478 )

479 return IssueProcessingResult(

480 success=False,

481 was_blocked=True,

482 duration=time.time() - issue_start_time,

483 issue_id=info.issue_id,

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

485 corrections=corrections,

486 )

487

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

489 if not parsed["is_ready"]:

490 logger.error(

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

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

493 )

494 return IssueProcessingResult(

495 success=False,

496 duration=time.time() - issue_start_time,

497 issue_id=info.issue_id,

498 failure_reason=(

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

500 ),

501 corrections=corrections,

502 )

503

504 # Log if proceeding with corrected issue

505 if parsed.get("was_corrected"):

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

507 else:

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

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

510

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

512 action = config.get_category_action(info.issue_type)

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

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

515 if not dry_run:

516 # Build manage-issue command

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

518

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

520 if validated_via_fallback:

521 issue_arg = _compute_relative_path(info.path)

522 else:

523 issue_arg = info.issue_id

524

525 # Use run_with_continuation to handle context exhaustion

526 result = run_with_continuation(

527 f"/ll:manage-issue {type_name} {action} {issue_arg}",

528 logger,

529 timeout=config.automation.timeout_seconds,

530 stream_output=config.automation.stream_output,

531 max_continuations=config.automation.max_continuations,

532 repo_path=config.repo_path,

533 idle_timeout=config.automation.idle_timeout_seconds,

534 )

535 else:

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

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

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

539

540 # Handle implementation failure

541 if result.returncode != 0:

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

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

544

545 if failure_type == FailureType.TRANSIENT:

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

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

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

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

550 logger.info(error_output[:500])

551

552 return IssueProcessingResult(

553 success=False,

554 duration=time.time() - issue_start_time,

555 issue_id=info.issue_id,

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

557 corrections=corrections,

558 )

559

560 # Real failure - create issue as before

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

562

563 failure_reason = ""

564 if not dry_run:

565 # Create new issue for the failure

566 new_issue = create_issue_from_failure(

567 error_output,

568 info,

569 config,

570 logger,

571 )

572 failure_reason = str(new_issue) if new_issue else error_output

573 else:

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

575

576 return IssueProcessingResult(

577 success=False,

578 duration=time.time() - issue_start_time,

579 issue_id=info.issue_id,

580 failure_reason=failure_reason,

581 corrections=corrections,

582 )

583

584 # Phase 3: Verify completion

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

586 verified = False

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

588 if not dry_run:

589 verified = verify_issue_completed(info, config, logger)

590

591 # Fallback: Only complete lifecycle if:

592 # 1. Command returned success (returncode 0)

593 # 2. File wasn't moved to completed

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

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

596 # Check if a plan was created awaiting approval

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

598 if plan_path is not None:

599 logger.info(

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

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

602 )

603 return IssueProcessingResult(

604 success=False,

605 duration=time.time() - issue_start_time,

606 issue_id=info.issue_id,

607 plan_created=True,

608 plan_path=str(plan_path),

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

610 corrections=corrections,

611 )

612

613 logger.info(

614 "Command returned success but issue not moved - "

615 "checking for evidence of work..."

616 )

617

618 # Check issue file content for implementation markers

619 if check_content_markers(info.path):

620 logger.info(

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

622 )

623 verified = complete_issue_lifecycle(info, config, logger)

624 if verified:

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

626 else:

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

628 else:

629 # CRITICAL: Verify actual implementation work was done

630 work_done = verify_work_was_done(logger)

631 if work_done:

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

633 verified = complete_issue_lifecycle(info, config, logger)

634 if verified:

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

636 else:

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

638 else:

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

640 logger.error(

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

642 "no code changes detected despite returncode 0"

643 )

644 logger.error(

645 "This likely indicates the command was not executed "

646 "properly. Check command invocation and Claude CLI "

647 "output."

648 )

649 verified = False

650 else:

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

652 verified = True # In dry run, assume success

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

654

655 # Record timing

656 total_issue_time = time.time() - issue_start_time

657 issue_timing["total"] = total_issue_time

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

659

660 if verified:

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

662 else:

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

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

665

666 return IssueProcessingResult(

667 success=verified,

668 duration=total_issue_time,

669 issue_id=info.issue_id,

670 corrections=corrections,

671 )

672

673

674 class AutoManager:

675 """Automated issue manager for sequential processing.

676

677 Processes issues in priority order using Claude CLI commands,

678 with state persistence for resume capability.

679 """

680

681 def __init__(

682 self,

683 config: BRConfig,

684 dry_run: bool = False,

685 max_issues: int = 0,

686 resume: bool = False,

687 category: str | None = None,

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

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

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

691 verbose: bool = True,

692 ) -> None:

693 """Initialize the auto manager.

694

695 Args:

696 config: Project configuration

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

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

699 resume: Whether to resume from previous state

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

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

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

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

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

705 verbose: Whether to output progress messages

706 """

707 self.config = config

708 self.dry_run = dry_run

709 self.max_issues = max_issues

710 self.resume = resume

711 self.category = category

712 self.only_ids = only_ids

713 self.skip_ids = skip_ids or set()

714 self.type_prefixes = type_prefixes

715

716 self.logger = Logger(verbose=verbose)

717 self.state_manager = StateManager(config.get_state_file(), self.logger)

718 self.parser = IssueParser(config)

719

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

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

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

723 all_known_ids: set[str] | None = None

724 try:

725 from little_loops.dependency_mapper import gather_all_issue_ids

726

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

728 all_known_ids = gather_all_issue_ids(issues_dir, config=config)

729 except Exception:

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

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

732

733 # Warn about any cycles

734 if self.dep_graph.has_cycles():

735 cycles = self.dep_graph.detect_cycles()

736 for cycle in cycles:

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

738

739 self.processed_count = 0

740 self._shutdown_requested = False

741

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

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

744

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

746 """Handle shutdown signals gracefully."""

747 self._shutdown_requested = True

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

749

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

751 """Get next issue respecting dependencies.

752

753 Returns the highest priority issue whose blockers have all been

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

755 logs warnings about what is blocking progress.

756

757 Returns:

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

759 """

760 # Get completed issues from state

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

762

763 # Combine skip_ids from state and CLI argument

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

765

766 # Get issues that are ready (blockers satisfied)

767 ready_issues = self.dep_graph.get_ready_issues(completed)

768

769 # Filter by skip_ids, only_ids, type_prefixes

770 candidates = [

771 i

772 for i in ready_issues

773 if i.issue_id not in skip_ids

774 and (self.only_ids is None or i.issue_id in self.only_ids)

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

776 ]

777

778 if candidates:

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

780 only_ids = self.only_ids

781 if isinstance(only_ids, list):

782 order = {issue_id: i for i, issue_id in enumerate(only_ids)}

783 candidates.sort(key=lambda x: order.get(x.issue_id, len(only_ids)))

784 return candidates[0]

785

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

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

788 remaining = all_in_graph - completed - skip_ids

789 if self.only_ids is not None:

790 remaining = remaining & set(self.only_ids)

791 if self.type_prefixes is not None:

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

793

794 if remaining:

795 self._log_blocked_issues(remaining, completed)

796

797 return None

798

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

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

801

802 Args:

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

804 completed: Set of completed issue IDs

805 """

806 blocked_count = 0

807 for issue_id in sorted(remaining):

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

809 if blockers:

810 blocked_count += 1

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

812

813 if blocked_count > 0:

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

815

816 def run(self) -> int:

817 """Run the automation loop.

818

819 Returns:

820 Exit code (0 = success)

821 """

822 run_start_time = time.time()

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

824

825 if self.dry_run:

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

827

828 if not self.dry_run:

829 has_changes = check_git_status(self.logger)

830 if has_changes:

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

832

833 # Load or initialize state

834 if self.resume:

835 state = self.state_manager.load()

836 if state:

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

838 self.processed_count = len(state.completed_issues)

839 else:

840 # Fresh start

841 self.state_manager._state = ProcessingState(timestamp=datetime.now().isoformat())

842

843 try:

844 while not self._shutdown_requested:

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

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

847 break

848

849 info = self._get_next_issue()

850 if not info:

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

852 break

853

854 success = self._process_issue(info)

855 if success:

856 self.processed_count += 1

857

858 except Exception as e:

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

860 return 1

861

862 finally:

863 if not self._shutdown_requested:

864 self.state_manager.cleanup()

865

866 self._log_timing_summary(run_start_time)

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

868 return 0

869

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

871 """Log aggregate timing summary."""

872 total_run_time = time.time() - run_start_time

873

874 self.logger.info("")

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

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

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

878

879 state = self.state_manager.state

880 if state.timing:

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

882 if total_times:

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

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

885

886 if state.failed_issues:

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

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

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

890

891 # Log correction statistics for quality tracking

892 if state.corrections:

893 total_corrected = len(state.corrections)

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

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

896 self.logger.info(

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

898 )

899

900 # Log most common correction types

901 from collections import Counter

902

903 all_corrections: list[str] = []

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

905 all_corrections.extend(corrections)

906 if all_corrections:

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

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

909 for correction, count in common:

910 # Truncate long correction descriptions

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

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

913

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

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

916

917 Delegates to process_issue_inplace() and maps the result back

918 to state manager calls.

919

920 Args:

921 info: Issue information

922

923 Returns:

924 True if processing succeeded

925 """

926 # Pre-processing state updates (before delegating)

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

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

929

930 result = process_issue_inplace(info, self.config, self.logger, self.dry_run)

931

932 # Map result back to state tracking

933 if result.was_closed:

934 self.state_manager.mark_completed(info.issue_id)

935 elif result.was_blocked:

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

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

938 elif result.success:

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

940 elif result.plan_created:

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

942 self.logger.info(

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

944 "leaving in pending state for manual approval"

945 )

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

947 elif result.failure_reason:

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

949

950 if result.corrections:

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

952

953 return result.success