Coverage for little_loops / issue_manager.py: 12%
414 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-04-11 23:20 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-04-11 23:20 -0500
1"""Automated issue management for little-loops.
3Provides the AutoManager class for sequential issue processing with
4Claude CLI integration and state persistence for resume capability.
5"""
7from __future__ import annotations
9import signal
10import subprocess
11import sys
12import time
13from collections.abc import Callable, Generator
14from contextlib import contextmanager
15from dataclasses import dataclass, field
16from pathlib import Path
17from types import FrameType
19from little_loops.cli_args import _id_matches
20from little_loops.config import BRConfig
21from little_loops.dependency_graph import DependencyGraph
22from little_loops.events import EventBus
23from little_loops.git_operations import check_git_status, verify_work_was_done
24from little_loops.issue_lifecycle import (
25 FailureType,
26 classify_failure,
27 close_issue,
28 complete_issue_lifecycle,
29 create_issue_from_failure,
30 verify_issue_completed,
31)
32from little_loops.issue_parser import IssueInfo, IssueParser, find_issues
33from little_loops.logger import Logger, format_duration
34from little_loops.output_parsing import parse_ready_issue_output
35from little_loops.skill_expander import expand_skill
36from little_loops.state import ProcessingState, StateManager, _iso_now
37from little_loops.subprocess_utils import (
38 detect_context_handoff,
39 read_continuation_prompt,
40)
41from little_loops.subprocess_utils import (
42 run_claude_command as _run_claude_base,
43)
46def _compute_relative_path(abs_path: Path, base_dir: Path | None = None) -> str:
47 """Compute relative path from base directory for command input.
49 Used for fallback retry when ready-issue resolves to wrong file -
50 allows retrying with explicit file path instead of ambiguous ID.
52 Args:
53 abs_path: Absolute path to the file
54 base_dir: Base directory (defaults to cwd)
56 Returns:
57 Relative path string suitable for ready-issue command
58 """
59 base = base_dir or Path.cwd()
60 try:
61 return str(abs_path.relative_to(base))
62 except ValueError:
63 # Path not relative to base, use absolute
64 return str(abs_path)
67@contextmanager
68def timed_phase(
69 logger: Logger,
70 phase_name: str,
71) -> Generator[dict[str, float], None, None]:
72 """Context manager for timing phases.
74 Yields a dict that will be populated with 'elapsed' after the context exits.
76 Args:
77 logger: Logger for output
78 phase_name: Name of the phase being timed
80 Yields:
81 Dict that will contain 'elapsed' key after context exits
82 """
83 timing_result: dict[str, float] = {}
84 start = time.time()
85 try:
86 yield timing_result
87 finally:
88 elapsed = time.time() - start
89 timing_result["elapsed"] = elapsed
90 logger.timing(f"{phase_name} completed in {format_duration(elapsed)}")
93def run_claude_command(
94 command: str,
95 logger: Logger,
96 timeout: int = 3600,
97 stream_output: bool = True,
98 idle_timeout: int = 0,
99 on_model_detected: Callable[[str], None] | None = None,
100 preview_full: bool = False,
101) -> subprocess.CompletedProcess[str]:
102 """Invoke Claude CLI command with real-time output streaming.
104 Args:
105 command: Command to pass to Claude CLI
106 logger: Logger for output
107 timeout: Timeout in seconds
108 stream_output: Whether to stream output to console
109 idle_timeout: Kill process if no output for this many seconds (0 to disable)
110 on_model_detected: Optional callback invoked with the model name from the
111 stream-json system/init event.
112 preview_full: If True, display the full command without truncation (for --verbose).
114 Returns:
115 CompletedProcess with stdout/stderr captured
116 """
117 from little_loops.cli.output import terminal_width
119 lines = command.strip().splitlines()
120 line_count = len(lines)
121 tw = terminal_width()
122 max_line = tw - 4
123 logger.info(f"Running: claude --dangerously-skip-permissions -p ({line_count} lines)")
124 show_count = line_count if preview_full else min(5, line_count)
125 for line in lines[:show_count]:
126 display = (
127 line if preview_full else (line[:max_line] + "..." if len(line) > max_line else line)
128 )
129 logger.info(f" {display}")
130 if line_count > show_count:
131 logger.info(f" ... ({line_count - show_count} more lines)")
133 def stream_callback(line: str, is_stderr: bool) -> None:
134 if stream_output:
135 if is_stderr:
136 print(f" {line}", file=sys.stderr)
137 else:
138 print(f" {line}")
140 return _run_claude_base(
141 command=command,
142 timeout=timeout,
143 stream_callback=stream_callback if stream_output else None,
144 idle_timeout=idle_timeout,
145 on_model_detected=on_model_detected,
146 )
149def run_with_continuation(
150 initial_command: str,
151 logger: Logger,
152 timeout: int = 3600,
153 stream_output: bool = True,
154 max_continuations: int = 3,
155 repo_path: Path | None = None,
156 idle_timeout: int = 0,
157 resume_command: str | None = None,
158 preview_full: bool = False,
159) -> subprocess.CompletedProcess[str]:
160 """Run a Claude command with automatic continuation on context handoff.
162 If the command signals CONTEXT_HANDOFF, reads the continuation prompt
163 and spawns a fresh Claude session to continue the work.
165 Args:
166 initial_command: Initial command to run
167 logger: Logger for output
168 timeout: Timeout per session in seconds
169 stream_output: Whether to stream output
170 max_continuations: Maximum number of continuation attempts
171 repo_path: Repository root path
172 idle_timeout: Kill process if no output for this many seconds (0 to disable)
173 resume_command: Command to use for continuation rounds instead of appending
174 ``--resume`` to ``initial_command``. Useful when ``initial_command``
175 is expanded skill content (hundreds of lines) — the short slash
176 command is passed here so continuation rounds stay compact.
178 Returns:
179 Final CompletedProcess result
180 """
181 all_stdout: list[str] = []
182 all_stderr: list[str] = []
183 current_command = initial_command
184 continuation_count = 0
185 result: subprocess.CompletedProcess[str] = subprocess.CompletedProcess(
186 args=[], returncode=1, stdout="", stderr=""
187 )
189 while continuation_count <= max_continuations:
190 result = run_claude_command(
191 current_command,
192 logger,
193 timeout=timeout,
194 stream_output=stream_output,
195 idle_timeout=idle_timeout,
196 preview_full=preview_full,
197 )
199 all_stdout.append(result.stdout)
200 all_stderr.append(result.stderr)
202 # Check for context handoff signal
203 if detect_context_handoff(result.stdout):
204 logger.info("Detected CONTEXT_HANDOFF signal")
206 # Read continuation prompt
207 prompt_content = read_continuation_prompt(repo_path)
208 if not prompt_content:
209 logger.warning("Context handoff signaled but no continuation prompt found")
210 all_stderr.append("Handoff detected but no continuation prompt found")
211 result = subprocess.CompletedProcess(
212 args=result.args, returncode=1, stdout=result.stdout, stderr=result.stderr
213 )
214 break
216 if continuation_count >= max_continuations:
217 logger.warning(f"Reached max continuations ({max_continuations}), stopping")
218 break
220 continuation_count += 1
221 logger.info(f"Starting continuation session #{continuation_count}")
223 # Re-invoke with --resume so the skill lifecycle (including
224 # completion/file-move) runs in the new session. When a short
225 # resume_command was provided (e.g. the original slash command),
226 # use that instead of appending --resume to the (potentially
227 # multi-hundred-line) expanded initial_command.
228 _base = resume_command if resume_command is not None else initial_command
229 current_command = f"{_base} --resume"
230 continue
232 # No handoff signal, we're done
233 break
235 return subprocess.CompletedProcess(
236 args=result.args,
237 returncode=result.returncode,
238 stdout="\n---CONTINUATION---\n".join(all_stdout),
239 stderr="\n---CONTINUATION---\n".join(all_stderr),
240 )
243def detect_plan_creation(output: str, issue_id: str) -> Path | None:
244 """Detect if manage-issue created a plan file awaiting approval.
246 Checks for plan file creation in thoughts/shared/plans/ matching the issue ID.
247 This happens when manage-issue creates a plan but waits for user approval.
249 Args:
250 output: Command stdout (unused, for future pattern matching)
251 issue_id: Issue ID (e.g., "BUG-280")
253 Returns:
254 Path to plan file if created, None otherwise
255 """
256 plans_dir = Path("thoughts/shared/plans")
257 if not plans_dir.exists():
258 return None
260 # Find plan files matching this issue ID (format: YYYY-MM-DD-ISSUE-ID-*.md)
261 # Use glob pattern with issue_id
262 pattern = f"*-{issue_id}-*.md"
263 matching_plans = list(plans_dir.glob(pattern))
265 if not matching_plans:
266 return None
268 # Return the most recently modified plan file
269 # (in case multiple exist, take the latest)
270 latest_plan = max(matching_plans, key=lambda p: p.stat().st_mtime)
271 return latest_plan
274def check_content_markers(issue_path: Path) -> bool:
275 """Check if issue file content contains implementation markers.
277 Looks for indicators that an implementation was completed, such as
278 Resolution sections or status markers added by manage-issue.
280 Args:
281 issue_path: Path to the issue file
283 Returns:
284 True if implementation markers found
285 """
286 try:
287 content = issue_path.read_text(encoding="utf-8")
288 except (OSError, UnicodeDecodeError):
289 return False
291 markers = [
292 "## Resolution",
293 "Status: Implemented",
294 "Status: Completed",
295 "**Completed**:",
296 ]
297 return any(marker in content for marker in markers)
300@dataclass
301class IssueProcessingResult:
302 """Result of processing a single issue in-place."""
304 success: bool
305 duration: float
306 issue_id: str
307 was_closed: bool = False
308 was_blocked: bool = False
309 failure_reason: str = ""
310 corrections: list[str] = field(default_factory=list)
311 plan_created: bool = False
312 plan_path: str = ""
315def process_issue_inplace(
316 info: IssueInfo,
317 config: BRConfig,
318 logger: Logger,
319 dry_run: bool = False,
320 on_model_detected: Callable[[str], None] | None = None,
321 preview_full: bool = False,
322) -> IssueProcessingResult:
323 """Process a single issue through the 3-phase workflow in the current working tree.
325 This is the core processing logic extracted from AutoManager._process_issue(),
326 suitable for use outside of AutoManager (e.g., single-issue sprint waves).
328 Args:
329 info: Issue information
330 config: Project configuration
331 logger: Logger for output
332 dry_run: If True, only preview what would be done
333 on_model_detected: Optional callback invoked with the model name from the
334 first stream-json system/init event during this issue's processing.
336 Returns:
337 IssueProcessingResult with outcome details
338 """
339 issue_start_time = time.time()
340 corrections: list[str] = []
342 logger.header(f"Processing: {info.issue_id} - {info.title}")
344 issue_timing: dict[str, float] = {}
346 # Track whether we used fallback path resolution for ready-issue.
347 validated_via_fallback = False
349 # Phase 1: Ready/verify the issue
350 logger.info(f"Phase 1: Verifying issue {info.issue_id}...")
351 with timed_phase(logger, "Phase 1 (ready-issue)") as phase1_timing:
352 if not dry_run:
353 _ready_slash = f"/ll:ready-issue {info.issue_id}"
354 _ready_cmd = expand_skill("ready-issue", [info.issue_id], config) or _ready_slash
355 result = run_claude_command(
356 _ready_cmd,
357 logger,
358 timeout=config.automation.timeout_seconds,
359 stream_output=config.automation.stream_output,
360 idle_timeout=config.automation.idle_timeout_seconds,
361 on_model_detected=on_model_detected,
362 preview_full=preview_full,
363 )
364 if result.returncode != 0:
365 logger.warning("ready-issue command failed to execute, continuing anyway...")
366 else:
367 # Parse the verdict from the output
368 parsed = parse_ready_issue_output(result.stdout)
369 logger.info(f"ready-issue verdict: {parsed['verdict']}")
371 # Validate that ready-issue analyzed the expected file
372 validated_path = parsed.get("validated_file_path")
373 if validated_path:
374 # Normalize paths for comparison (resolve to absolute)
375 expected_path = str(info.path.resolve())
376 # Handle both absolute and relative paths from ready_issue
377 validated_resolved = Path(validated_path).resolve()
378 if str(validated_resolved) != expected_path:
379 # Check if this is a legitimate rename (new file exists,
380 # old file doesn't) vs a mismatch error
381 old_file_exists = info.path.exists()
382 new_file_exists = validated_resolved.exists()
384 if new_file_exists and not old_file_exists:
385 # ready-issue renamed the file - update tracking
386 logger.info(
387 f"Issue file renamed: '{info.path.name}' -> "
388 f"'{validated_resolved.name}'"
389 )
390 info.path = validated_resolved
391 else:
392 # Genuine mismatch - attempt fallback with explicit path
393 logger.warning(
394 f"Path mismatch: ready-issue validated "
395 f"'{validated_path}' but expected '{info.path}'"
396 )
397 logger.info(
398 "Attempting fallback: retrying ready-issue "
399 "with explicit file path..."
400 )
402 # Compute relative path for the command
403 relative_path = _compute_relative_path(info.path)
405 # Retry with explicit path
406 _retry_slash = f"/ll:ready-issue {relative_path}"
407 _retry_cmd = (
408 expand_skill("ready-issue", [str(relative_path)], config)
409 or _retry_slash
410 )
411 retry_result = run_claude_command(
412 _retry_cmd,
413 logger,
414 timeout=config.automation.timeout_seconds,
415 stream_output=config.automation.stream_output,
416 idle_timeout=config.automation.idle_timeout_seconds,
417 on_model_detected=on_model_detected,
418 preview_full=preview_full,
419 )
421 if retry_result.returncode != 0:
422 logger.error(f"Fallback ready-issue failed for {info.issue_id}")
423 return IssueProcessingResult(
424 success=False,
425 duration=time.time() - issue_start_time,
426 issue_id=info.issue_id,
427 failure_reason="Fallback failed after path mismatch",
428 )
430 # Re-parse and validate retry output
431 retry_parsed = parse_ready_issue_output(retry_result.stdout)
432 retry_validated_path = retry_parsed.get("validated_file_path")
434 if retry_validated_path:
435 retry_resolved = Path(retry_validated_path).resolve()
436 if str(retry_resolved) != str(info.path.resolve()):
437 logger.error(
438 f"Fallback still mismatched: "
439 f"got '{retry_validated_path}', "
440 f"expected '{info.path}'"
441 )
442 return IssueProcessingResult(
443 success=False,
444 duration=time.time() - issue_start_time,
445 issue_id=info.issue_id,
446 failure_reason="Path mismatch persisted after fallback",
447 )
449 # Fallback succeeded - use retry result
450 logger.info("Fallback succeeded: validated correct file")
451 parsed = retry_parsed
452 validated_via_fallback = True
454 # Log and store any corrections made
455 if parsed.get("was_corrected"):
456 logger.info(f"Issue {info.issue_id} was auto-corrected")
457 phase_corrections = parsed.get("corrections", [])
458 for correction in phase_corrections:
459 logger.info(f" Correction: {correction}")
460 if phase_corrections:
461 corrections.extend(phase_corrections)
463 # Log any concerns found
464 if parsed["concerns"]:
465 for concern in parsed["concerns"]:
466 logger.warning(f" Concern: {concern}")
468 # Handle CLOSE verdict - issue should not be implemented
469 if parsed.get("should_close"):
470 close_reason = parsed.get("close_reason", "unknown")
471 logger.info(f"Issue {info.issue_id} should be closed (reason: {close_reason})")
473 # CRITICAL: Skip file operations for invalid references
474 if close_reason == "invalid_ref":
475 logger.warning(
476 f"Skipping {info.issue_id}: invalid reference - "
477 "no matching issue file exists"
478 )
479 return IssueProcessingResult(
480 success=False,
481 duration=time.time() - issue_start_time,
482 issue_id=info.issue_id,
483 failure_reason=f"Invalid reference: {close_reason}",
484 corrections=corrections,
485 )
487 # Also require validated_file_path to match before closing
488 close_validated_path = parsed.get("validated_file_path")
489 if not close_validated_path:
490 logger.warning(
491 f"Skipping close for {info.issue_id}: "
492 "ready-issue did not return validated file path"
493 )
494 return IssueProcessingResult(
495 success=False,
496 duration=time.time() - issue_start_time,
497 issue_id=info.issue_id,
498 failure_reason="CLOSE without validated file path",
499 corrections=corrections,
500 )
502 if close_issue(
503 info,
504 config,
505 logger,
506 close_reason,
507 parsed.get("close_status"),
508 ):
509 return IssueProcessingResult(
510 success=True,
511 duration=time.time() - issue_start_time,
512 issue_id=info.issue_id,
513 was_closed=True,
514 corrections=corrections,
515 )
516 else:
517 return IssueProcessingResult(
518 success=False,
519 duration=time.time() - issue_start_time,
520 issue_id=info.issue_id,
521 failure_reason=f"CLOSE failed: {parsed.get('close_status', 'unknown')}",
522 corrections=corrections,
523 )
525 # Handle BLOCKED verdict - issue has open dependencies
526 if parsed.get("is_blocked"):
527 logger.warning(
528 f"Issue {info.issue_id} blocked — open dependency detected by ready-issue"
529 )
530 return IssueProcessingResult(
531 success=False,
532 was_blocked=True,
533 duration=time.time() - issue_start_time,
534 issue_id=info.issue_id,
535 failure_reason=f"BLOCKED: {parsed.get('concerns', [])}",
536 corrections=corrections,
537 )
539 # Check if issue is NOT READY (and not closeable)
540 if not parsed["is_ready"]:
541 logger.error(
542 f"Issue {info.issue_id} is NOT READY for implementation "
543 f"(verdict: {parsed['verdict']})"
544 )
545 return IssueProcessingResult(
546 success=False,
547 duration=time.time() - issue_start_time,
548 issue_id=info.issue_id,
549 failure_reason=(
550 f"NOT READY: {parsed['verdict']} - {len(parsed['concerns'])} concern(s)"
551 ),
552 corrections=corrections,
553 )
555 # Log if proceeding with corrected issue
556 if parsed.get("was_corrected"):
557 logger.success(f"Issue {info.issue_id} corrected and ready for implementation")
558 else:
559 logger.info(f"Would run: /ll:ready-issue {info.issue_id}")
560 issue_timing["ready"] = phase1_timing.get("elapsed", 0.0)
562 # Phase 2: Implement the issue (with automatic continuation on context handoff)
563 action = config.get_category_action(info.issue_type)
564 logger.info(f"Phase 2: Implementing {info.issue_id}...")
565 with timed_phase(logger, "Phase 2 (implement)") as phase2_timing:
566 if not dry_run:
567 # Build manage-issue command
568 type_name = info.issue_type.rstrip("s") # bugs -> bug
570 # Use relative path if fallback was used, otherwise use issue_id
571 if validated_via_fallback:
572 issue_arg = _compute_relative_path(info.path)
573 else:
574 issue_arg = info.issue_id
576 # Use run_with_continuation to handle context exhaustion.
577 # Pre-expand the skill so the subprocess needs no ToolSearch.
578 # Pass the short slash command as resume_command so continuation
579 # rounds stay compact (not hundreds of lines).
580 _slash_cmd = f"/ll:manage-issue {type_name} {action} {issue_arg}"
581 _initial_cmd = (
582 expand_skill("manage-issue", [type_name, action, issue_arg], config) or _slash_cmd
583 )
584 result = run_with_continuation(
585 _initial_cmd,
586 logger,
587 timeout=config.automation.timeout_seconds,
588 stream_output=config.automation.stream_output,
589 max_continuations=config.automation.max_continuations,
590 repo_path=config.repo_path,
591 idle_timeout=config.automation.idle_timeout_seconds,
592 resume_command=_slash_cmd,
593 preview_full=preview_full,
594 )
595 else:
596 logger.info(f"Would run: /ll:manage-issue {info.issue_type} {action} {info.issue_id}")
597 result = subprocess.CompletedProcess(args=[], returncode=0)
598 issue_timing["implement"] = phase2_timing.get("elapsed", 0.0)
600 # Handle implementation failure
601 if result.returncode != 0:
602 error_output = result.stderr or result.stdout or "Unknown error"
603 failure_type, failure_reason_text = classify_failure(error_output, result.returncode)
605 if failure_type == FailureType.TRANSIENT:
606 # Transient failure - log but don't create bug issue
607 logger.warning(f"Transient failure for {info.issue_id}: {failure_reason_text}")
608 logger.warning("Not creating bug issue - this is a temporary error")
609 logger.info("Error output (first 500 chars):")
610 logger.info(error_output[:500])
612 return IssueProcessingResult(
613 success=False,
614 duration=time.time() - issue_start_time,
615 issue_id=info.issue_id,
616 failure_reason=f"Transient: {failure_reason_text}",
617 corrections=corrections,
618 )
620 # Real failure - create issue as before
621 logger.error(f"Implementation failed for {info.issue_id}")
623 failure_reason = ""
624 if not dry_run:
625 # Create new issue for the failure
626 new_issue = create_issue_from_failure(
627 error_output,
628 info,
629 config,
630 logger,
631 )
632 failure_reason = str(new_issue) if new_issue else error_output
633 else:
634 logger.info("Would create new bug issue for this failure")
636 return IssueProcessingResult(
637 success=False,
638 duration=time.time() - issue_start_time,
639 issue_id=info.issue_id,
640 failure_reason=failure_reason,
641 corrections=corrections,
642 )
644 # Phase 3: Verify completion
645 logger.info(f"Phase 3: Verifying {info.issue_id} completion...")
646 verified = False
647 with timed_phase(logger, "Phase 3 (verify)") as phase3_timing:
648 if not dry_run:
649 verified = verify_issue_completed(info, config, logger)
651 # Fallback: Only complete lifecycle if:
652 # 1. Command returned success (returncode 0)
653 # 2. File wasn't moved to completed
654 # 3. There's EVIDENCE of actual work being done (code changes)
655 if not verified and result.returncode == 0:
656 # Check if a plan was created awaiting approval
657 plan_path = detect_plan_creation(result.stdout, info.issue_id)
658 if plan_path is not None:
659 logger.info(
660 f"Plan created at {plan_path}, awaiting approval - "
661 "issue will remain incomplete until plan is approved and implemented"
662 )
663 return IssueProcessingResult(
664 success=False,
665 duration=time.time() - issue_start_time,
666 issue_id=info.issue_id,
667 plan_created=True,
668 plan_path=str(plan_path),
669 failure_reason="", # Not a failure - plan awaiting approval
670 corrections=corrections,
671 )
673 logger.info(
674 "Command returned success but issue not moved - "
675 "checking for evidence of work..."
676 )
678 # Check issue file content for implementation markers
679 if check_content_markers(info.path):
680 logger.info(
681 "Implementation markers found in issue file - completing lifecycle..."
682 )
683 verified = complete_issue_lifecycle(info, config, logger)
684 if verified:
685 logger.success(f"Content marker completion succeeded for {info.issue_id}")
686 else:
687 logger.warning(f"Content marker completion failed for {info.issue_id}")
688 else:
689 # CRITICAL: Verify actual implementation work was done
690 work_done = verify_work_was_done(logger)
691 if work_done:
692 logger.info("Evidence of code changes found - completing lifecycle...")
693 verified = complete_issue_lifecycle(info, config, logger)
694 if verified:
695 logger.success(f"Fallback completion succeeded for {info.issue_id}")
696 else:
697 logger.warning(f"Fallback completion failed for {info.issue_id}")
698 else:
699 # NO work was done - do NOT mark as completed
700 logger.error(
701 f"REFUSING to mark {info.issue_id} as completed: "
702 "no code changes detected despite returncode 0"
703 )
704 logger.error(
705 "This likely indicates the command was not executed "
706 "properly. Check command invocation and Claude CLI "
707 "output."
708 )
709 verified = False
710 else:
711 logger.info("Would verify issue moved to completed")
712 verified = True # In dry run, assume success
713 issue_timing["verify"] = phase3_timing.get("elapsed", 0.0)
715 # Record timing
716 total_issue_time = time.time() - issue_start_time
717 issue_timing["total"] = total_issue_time
718 logger.timing(f"Total processing time: {format_duration(total_issue_time)}")
720 if verified:
721 logger.success(f"Completed: {info.issue_id}")
722 else:
723 logger.warning(f"Issue {info.issue_id} was attempted but verification failed")
724 logger.info("This issue will be skipped on future runs (check logs above for details)")
726 return IssueProcessingResult(
727 success=verified,
728 duration=total_issue_time,
729 issue_id=info.issue_id,
730 corrections=corrections,
731 )
734class AutoManager:
735 """Automated issue manager for sequential processing.
737 Processes issues in priority order using Claude CLI commands,
738 with state persistence for resume capability.
739 """
741 def __init__(
742 self,
743 config: BRConfig,
744 dry_run: bool = False,
745 max_issues: int = 0,
746 resume: bool = False,
747 category: str | None = None,
748 only_ids: list[str] | set[str] | None = None,
749 skip_ids: set[str] | None = None,
750 type_prefixes: set[str] | None = None,
751 priority_filter: set[str] | None = None,
752 verbose: bool = True,
753 preview_full: bool = False,
754 ) -> None:
755 """Initialize the auto manager.
757 Args:
758 config: Project configuration
759 dry_run: If True, only preview what would be done
760 max_issues: Maximum issues to process (0 = unlimited)
761 resume: Whether to resume from previous state
762 category: Optional category to filter (e.g., "bugs")
763 only_ids: If provided, only process these issue IDs. When a list,
764 issues are processed in list order (input sequence preserved).
765 skip_ids: Issue IDs to skip (in addition to attempted issues)
766 type_prefixes: If provided, only process issues with these type prefixes
767 priority_filter: If provided, only process issues with these priority levels
768 verbose: Whether to output progress messages
769 preview_full: If True, show full command content without truncation (--verbose flag).
770 """
771 self.config = config
772 self.dry_run = dry_run
773 self.max_issues = max_issues
774 self.resume = resume
775 self.category = category
776 self.only_ids = only_ids
777 self.skip_ids = skip_ids or set()
778 self.type_prefixes = type_prefixes
779 self.priority_filter = priority_filter
780 self._preview_full = preview_full
782 self.logger = Logger(verbose=verbose)
783 self.event_bus = EventBus()
784 self.state_manager = StateManager(
785 config.get_state_file(), self.logger, event_bus=self.event_bus
786 )
787 self.parser = IssueParser(config)
788 self._detected_model: list[str] = []
790 # Build dependency graph for dependency-aware sequencing (ENH-016)
791 # Note: don't filter by type here — we need all issues for dependency resolution
792 all_issues = find_issues(self.config, self.category)
793 all_known_ids: set[str] | None = None
794 try:
795 from little_loops.dependency_mapper import gather_all_issue_ids
797 issues_dir = config.project_root / config.issues.base_dir
798 all_known_ids = gather_all_issue_ids(issues_dir, config=config)
799 except Exception:
800 self.logger.debug("Dependency mapping unavailable — skipping")
801 self.dep_graph = DependencyGraph.from_issues(all_issues, all_known_ids=all_known_ids)
803 # Warn about any cycles
804 if self.dep_graph.has_cycles():
805 cycles = self.dep_graph.detect_cycles()
806 for cycle in cycles:
807 self.logger.warning(f"Dependency cycle detected: {' -> '.join(cycle)}")
809 self.processed_count = 0
810 self._shutdown_requested = False
812 signal.signal(signal.SIGINT, self._signal_handler)
813 signal.signal(signal.SIGTERM, self._signal_handler)
815 def _signal_handler(self, signum: int, frame: FrameType | None) -> None:
816 """Handle shutdown signals gracefully."""
817 self._shutdown_requested = True
818 self.logger.warning(f"Received signal {signum}, shutting down gracefully...")
820 def _get_next_issue(self) -> IssueInfo | None:
821 """Get next issue respecting dependencies.
823 Returns the highest priority issue whose blockers have all been
824 completed. If no ready issues exist but blocked issues remain,
825 logs warnings about what is blocking progress.
827 Returns:
828 Next IssueInfo to process, or None if no ready issues
829 """
830 # Get completed issues from state
831 completed = set(self.state_manager.state.completed_issues)
833 # Combine skip_ids from state and CLI argument
834 skip_ids = self.state_manager.state.attempted_issues | self.skip_ids
836 # Get issues that are ready (blockers satisfied)
837 ready_issues = self.dep_graph.get_ready_issues(completed)
839 # Filter by skip_ids, only_ids, type_prefixes, priority_filter
840 candidates = [
841 i
842 for i in ready_issues
843 if i.issue_id not in skip_ids
844 and (self.only_ids is None or any(_id_matches(i.issue_id, p) for p in self.only_ids))
845 and (self.type_prefixes is None or i.issue_id.split("-", 1)[0] in self.type_prefixes)
846 and (self.priority_filter is None or i.priority in self.priority_filter)
847 ]
849 if candidates:
850 # When only_ids is a list, respect input order; otherwise use priority order
851 only_ids = self.only_ids
852 if isinstance(only_ids, list):
853 candidates.sort(
854 key=lambda x: next(
855 (i for i, p in enumerate(only_ids) if _id_matches(x.issue_id, p)),
856 len(only_ids),
857 )
858 )
859 return candidates[0]
861 # No ready candidates - check if there are blocked issues remaining
862 all_in_graph = set(self.dep_graph.issues.keys())
863 remaining = all_in_graph - completed - skip_ids
864 if self.only_ids is not None:
865 remaining = {r for r in remaining if any(_id_matches(r, p) for p in self.only_ids)}
866 if self.type_prefixes is not None:
867 remaining = {r for r in remaining if r.split("-", 1)[0] in self.type_prefixes}
868 if self.priority_filter is not None:
869 remaining = {
870 r for r in remaining if self.dep_graph.issues[r].priority in self.priority_filter
871 }
873 if remaining:
874 self._log_blocked_issues(remaining, completed)
876 return None
878 def _log_blocked_issues(self, remaining: set[str], completed: set[str]) -> None:
879 """Log information about blocked issues when processing stalls.
881 Args:
882 remaining: Set of issue IDs that haven't been processed
883 completed: Set of completed issue IDs
884 """
885 blocked_count = 0
886 for issue_id in sorted(remaining):
887 blockers = self.dep_graph.get_blocking_issues(issue_id, completed)
888 if blockers:
889 blocked_count += 1
890 self.logger.info(f" {issue_id} blocked by: {', '.join(sorted(blockers))}")
892 if blocked_count > 0:
893 self.logger.warning(f"{blocked_count} issue(s) remain blocked - check dependencies")
895 def run(self) -> int:
896 """Run the automation loop.
898 Returns:
899 Exit code (0 = success)
900 """
901 run_start_time = time.time()
902 self.logger.info("Starting automated issue management...")
904 if self.dry_run:
905 self.logger.info("DRY RUN MODE - No actual changes will be made")
907 if not self.dry_run:
908 has_changes = check_git_status(self.logger)
909 if has_changes:
910 self.logger.warning("Proceeding anyway...")
912 # Load or initialize state
913 if self.resume:
914 state = self.state_manager.load()
915 if state:
916 self.logger.info(f"Resuming from: {state.current_issue}")
917 self.processed_count = len(state.completed_issues)
918 else:
919 # Fresh start
920 self.state_manager._state = ProcessingState(timestamp=_iso_now())
922 try:
923 while not self._shutdown_requested:
924 if self.max_issues > 0 and self.processed_count >= self.max_issues:
925 self.logger.info(f"Reached max issues limit: {self.max_issues}")
926 break
928 info = self._get_next_issue()
929 if not info:
930 self.logger.success("No more issues to process!")
931 break
933 success = self._process_issue(info)
934 if success:
935 self.processed_count += 1
937 except Exception as e:
938 self.logger.error(f"Fatal error: {e}")
939 return 1
941 finally:
942 if not self._shutdown_requested:
943 self.state_manager.cleanup()
945 self._log_timing_summary(run_start_time)
946 self.logger.success(f"Processed {self.processed_count} issue(s)")
947 return 0
949 def _log_timing_summary(self, run_start_time: float) -> None:
950 """Log aggregate timing summary."""
951 total_run_time = time.time() - run_start_time
953 self.logger.info("")
954 self.logger.header("PROCESSING SUMMARY")
955 self.logger.timing(f"Total run time: {format_duration(total_run_time)}")
956 self.logger.timing(f"Issues processed: {self.processed_count}")
958 state = self.state_manager.state
959 if state.timing:
960 total_times = [t.get("total", 0) for t in state.timing.values()]
961 if total_times:
962 avg_time = sum(total_times) / len(total_times)
963 self.logger.timing(f"Average per issue: {format_duration(avg_time)}")
965 if state.failed_issues:
966 self.logger.warning(f"Failed issues: {len(state.failed_issues)}")
967 for issue_id, reason in state.failed_issues.items():
968 self.logger.warning(f" - {issue_id}: {reason[:50]}...")
970 # Log correction statistics for quality tracking
971 if state.corrections:
972 total_corrected = len(state.corrections)
973 total_issues = len(state.completed_issues) + len(state.failed_issues)
974 correction_rate = (total_corrected / total_issues * 100) if total_issues > 0 else 0
975 self.logger.info(
976 f"Auto-corrections: {total_corrected}/{total_issues} ({correction_rate:.1f}%)"
977 )
979 # Log most common correction types
980 from collections import Counter
982 all_corrections: list[str] = []
983 for corrections in state.corrections.values():
984 all_corrections.extend(corrections)
985 if all_corrections:
986 common = Counter(all_corrections).most_common(3)
987 self.logger.info("Most common corrections:")
988 for correction, count in common:
989 # Truncate long correction descriptions
990 display = correction[:60] + "..." if len(correction) > 60 else correction
991 self.logger.info(f" - {display}: {count}")
993 def _process_issue(self, info: IssueInfo) -> bool:
994 """Process a single issue through the workflow.
996 Delegates to process_issue_inplace() and maps the result back
997 to state manager calls.
999 Args:
1000 info: Issue information
1002 Returns:
1003 True if processing succeeded
1004 """
1005 # Pre-processing state updates (before delegating)
1006 self.state_manager.mark_attempted(info.issue_id, save=False)
1007 self.state_manager.update_current(str(info.path), "processing")
1009 on_model: Callable[[str], None] | None = None
1010 if not self._detected_model:
1012 def on_model(m: str) -> None:
1013 self._detected_model.append(m)
1014 self.logger.info(f"model: {m}")
1016 result = process_issue_inplace(
1017 info,
1018 self.config,
1019 self.logger,
1020 self.dry_run,
1021 on_model_detected=on_model,
1022 preview_full=self._preview_full,
1023 )
1025 # Map result back to state tracking
1026 if result.was_closed:
1027 self.state_manager.mark_completed(info.issue_id)
1028 elif result.was_blocked:
1029 # Blocked issues are skipped, not failed — leave in pending state
1030 self.logger.info(f"{info.issue_id} skipped — blocked by open dependency")
1031 elif result.success:
1032 self.state_manager.mark_completed(info.issue_id, {"total": result.duration})
1033 elif result.plan_created:
1034 # Don't mark as failed if a plan was created (awaiting approval)
1035 self.logger.info(
1036 f"{info.issue_id} has plan at {result.plan_path} - "
1037 "leaving in pending state for manual approval"
1038 )
1039 # Issue remains in pending state (not marked as failed)
1040 elif result.failure_reason:
1041 self.state_manager.mark_failed(info.issue_id, result.failure_reason)
1043 if result.corrections:
1044 self.state_manager.record_corrections(info.issue_id, result.corrections)
1046 return result.success