Coverage for little_loops / issue_manager.py: 10%
510 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-11 00:29 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-11 00:29 -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 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
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)
50def _compute_relative_path(abs_path: Path, base_dir: Path | None = None) -> str:
51 """Compute relative path from base directory for command input.
53 Used for fallback retry when ready-issue resolves to wrong file -
54 allows retrying with explicit file path instead of ambiguous ID.
56 Args:
57 abs_path: Absolute path to the file
58 base_dir: Base directory (defaults to cwd)
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)
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.
78 Yields a dict that will be populated with 'elapsed' after the context exits.
80 Args:
81 logger: Logger for output
82 phase_name: Name of the phase being timed
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)}")
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.
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).
122 Returns:
123 CompletedProcess with stdout/stderr captured
124 """
125 from little_loops.cli.output import terminal_width
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)")
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}")
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 )
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.
179 Implements Options E, G, and J from BUG-1377:
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.
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.
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.
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).
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 )
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
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)
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 )
246 all_stdout.append(result.stdout)
247 all_stderr.append(result.stderr)
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")
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
263 if continuation_count >= max_continuations:
264 logger.warning(f"Reached max continuations ({max_continuations}), stopping")
265 break
267 continuation_count += 1
268 logger.info(f"Starting continuation session #{continuation_count}")
270 _base = resume_command if resume_command is not None else initial_command
271 current_command = f"{_base} --resume"
272 continue
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()
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
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)
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
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)
370 # No handoff signal, no prior-session sentinel, no overflow — done
371 break
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 )
381def detect_plan_creation(output: str, issue_id: str) -> Path | None:
382 """Detect if manage-issue created a plan file awaiting approval.
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.
387 Args:
388 output: Command stdout (unused, for future pattern matching)
389 issue_id: Issue ID (e.g., "BUG-280")
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
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))
403 if not matching_plans:
404 return None
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
412def check_content_markers(issue_path: Path) -> bool:
413 """Check if issue file content contains implementation markers.
415 Looks for indicators that an implementation was completed, such as
416 Resolution sections or status markers added by manage-issue.
418 Args:
419 issue_path: Path to the issue file
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
429 markers = [
430 "## Resolution",
431 "Status: Implemented",
432 "Status: Completed",
433 "**Completed**:",
434 ]
435 return any(marker in content for marker in markers)
438@dataclass
439class IssueProcessingResult:
440 """Result of processing a single issue in-place."""
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 = ""
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.
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).
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.
477 Returns:
478 IssueProcessingResult with outcome details
479 """
480 issue_start_time = time.time()
481 corrections: list[str] = []
483 logger.header(f"Processing: {info.issue_id} - {info.title}")
485 issue_timing: dict[str, float] = {}
487 # Track whether we used fallback path resolution for ready-issue.
488 validated_via_fallback = False
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
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)
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']}")
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()
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 )
558 # Compute relative path for the command
559 relative_path = _compute_relative_path(info.path)
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 )
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 )
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")
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 )
605 # Fallback succeeded - use retry result
606 logger.info("Fallback succeeded: validated correct file")
607 parsed = retry_parsed
608 validated_via_fallback = True
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)
619 # Log any concerns found
620 if parsed["concerns"]:
621 for concern in parsed["concerns"]:
622 logger.warning(f" Concern: {concern}")
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})")
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 )
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 )
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 )
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 )
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 )
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)
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...")
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 with timed_phase(logger, "Phase 2 (implement)") as phase2_timing:
743 if not dry_run:
744 # Build manage-issue command
745 type_name = info.issue_type.rstrip("s") # bugs -> bug
747 # Use relative path if fallback was used, otherwise use issue_id
748 if validated_via_fallback:
749 issue_arg = _compute_relative_path(info.path)
750 else:
751 issue_arg = info.issue_id
753 # Use run_with_continuation to handle context exhaustion.
754 # Pre-expand the skill so the subprocess needs no ToolSearch.
755 # Pass the short slash command as resume_command so continuation
756 # rounds stay compact (not hundreds of lines).
757 _slash_cmd = f"/ll:manage-issue {type_name} {action} {issue_arg}"
758 _initial_cmd = (
759 expand_skill("manage-issue", [type_name, action, issue_arg], config) or _slash_cmd
760 )
761 result = run_with_continuation(
762 _initial_cmd,
763 logger,
764 timeout=config.automation.timeout_seconds,
765 stream_output=config.automation.stream_output,
766 max_continuations=config.automation.max_continuations,
767 repo_path=config.repo_path,
768 idle_timeout=config.automation.idle_timeout_seconds,
769 resume_command=_slash_cmd,
770 on_usage=_on_usage_writer,
771 preview_full=preview_full,
772 )
773 else:
774 logger.info(f"Would run: /ll:manage-issue {info.issue_type} {action} {info.issue_id}")
775 result = subprocess.CompletedProcess(args=[], returncode=0)
776 issue_timing["implement"] = phase2_timing.get("elapsed", 0.0)
778 # Handle implementation failure
779 if result.returncode != 0:
780 # Guard: if the issue's frontmatter already shows status: done by the
781 # subprocess (e.g., a guillotine fresh session that finished and then
782 # triggered a spurious Option E --continue failure), treat as success
783 # so Phase 3 runs.
784 already_done = False
785 if info.path.exists():
786 try:
787 from little_loops.frontmatter import parse_frontmatter
789 _fm = parse_frontmatter(info.path.read_text(encoding="utf-8"))
790 already_done = _fm.get("status") in ("done", "cancelled")
791 except Exception:
792 already_done = False
793 if already_done:
794 logger.warning(
795 f"Phase 2 exited non-zero but {info.issue_id} status is done/cancelled; "
796 "treating as success (continuation artefact)"
797 )
798 result = subprocess.CompletedProcess(
799 args=result.args, returncode=0, stdout=result.stdout, stderr=result.stderr
800 )
801 else:
802 error_output = result.stderr or result.stdout or "Unknown error"
803 failure_type, failure_reason_text = classify_failure(error_output, result.returncode)
805 if failure_type == FailureType.TRANSIENT:
806 # Transient failure - log but don't create bug issue
807 logger.warning(f"Transient failure for {info.issue_id}: {failure_reason_text}")
808 logger.warning("Not creating bug issue - this is a temporary error")
809 logger.info("Error output (first 500 chars):")
810 logger.info(error_output[:500])
812 return IssueProcessingResult(
813 success=False,
814 duration=time.time() - issue_start_time,
815 issue_id=info.issue_id,
816 failure_reason=f"Transient: {failure_reason_text}",
817 corrections=corrections,
818 )
820 # Real failure - create issue as before
821 logger.error(f"Implementation failed for {info.issue_id}")
823 failure_reason = ""
824 if not dry_run:
825 # Create new issue for the failure
826 new_issue = create_issue_from_failure(
827 error_output,
828 info,
829 config,
830 logger,
831 )
832 failure_reason = str(new_issue) if new_issue else error_output
833 else:
834 logger.info("Would create new bug issue for this failure")
836 return IssueProcessingResult(
837 success=False,
838 duration=time.time() - issue_start_time,
839 issue_id=info.issue_id,
840 failure_reason=failure_reason,
841 corrections=corrections,
842 )
844 # Phase 3: Verify completion
845 logger.info(f"Phase 3: Verifying {info.issue_id} completion...")
846 verified = False
847 with timed_phase(logger, "Phase 3 (verify)") as phase3_timing:
848 if not dry_run:
849 verified = verify_issue_completed(info, config, logger)
851 # Fallback: Only complete lifecycle if:
852 # 1. Command returned success (returncode 0)
853 # 2. File wasn't moved to completed
854 # 3. There's EVIDENCE of actual work being done (code changes)
855 if not verified and result.returncode == 0:
856 # Check if a plan was created awaiting approval
857 plan_path = detect_plan_creation(result.stdout, info.issue_id)
858 if plan_path is not None:
859 logger.info(
860 f"Plan created at {plan_path}, awaiting approval - "
861 "issue will remain incomplete until plan is approved and implemented"
862 )
863 return IssueProcessingResult(
864 success=False,
865 duration=time.time() - issue_start_time,
866 issue_id=info.issue_id,
867 plan_created=True,
868 plan_path=str(plan_path),
869 failure_reason="", # Not a failure - plan awaiting approval
870 corrections=corrections,
871 )
873 logger.info(
874 "Command returned success but issue not moved - "
875 "checking for evidence of work..."
876 )
878 # Check issue file content for implementation markers
879 if check_content_markers(info.path):
880 logger.info(
881 "Implementation markers found in issue file - completing lifecycle..."
882 )
883 verified = complete_issue_lifecycle(info, config, logger)
884 if verified:
885 logger.success(f"Content marker completion succeeded for {info.issue_id}")
886 else:
887 logger.warning(f"Content marker completion failed for {info.issue_id}")
888 else:
889 # CRITICAL: Verify actual implementation work was done
890 work_done = verify_work_was_done(logger)
891 if work_done:
892 logger.info("Evidence of code changes found - completing lifecycle...")
893 verified = complete_issue_lifecycle(info, config, logger)
894 if verified:
895 logger.success(f"Fallback completion succeeded for {info.issue_id}")
896 else:
897 logger.warning(f"Fallback completion failed for {info.issue_id}")
898 else:
899 # NO work was done - do NOT mark as completed
900 logger.error(
901 f"REFUSING to mark {info.issue_id} as completed: "
902 "no code changes detected despite returncode 0"
903 )
904 logger.error(
905 "This likely indicates the command was not executed "
906 "properly. Check command invocation and Claude CLI "
907 "output."
908 )
909 verified = False
910 else:
911 logger.info("Would verify issue moved to completed")
912 verified = True # In dry run, assume success
913 issue_timing["verify"] = phase3_timing.get("elapsed", 0.0)
915 # Record timing
916 total_issue_time = time.time() - issue_start_time
917 issue_timing["total"] = total_issue_time
918 logger.timing(f"Total processing time: {format_duration(total_issue_time)}")
920 if verified:
921 logger.success(f"Completed: {info.issue_id}")
922 else:
923 logger.warning(f"Issue {info.issue_id} was attempted but verification failed")
924 logger.info("This issue will be skipped on future runs (check logs above for details)")
926 return IssueProcessingResult(
927 success=verified,
928 duration=total_issue_time,
929 issue_id=info.issue_id,
930 corrections=corrections,
931 )
934class AutoManager:
935 """Automated issue manager for sequential processing.
937 Processes issues in priority order using Claude CLI commands,
938 with state persistence for resume capability.
939 """
941 def __init__(
942 self,
943 config: BRConfig,
944 dry_run: bool = False,
945 max_issues: int = 0,
946 resume: bool = False,
947 category: str | None = None,
948 only_ids: list[str] | set[str] | None = None,
949 skip_ids: set[str] | None = None,
950 type_prefixes: set[str] | None = None,
951 priority_filter: set[str] | None = None,
952 label_filter: set[str] | None = None,
953 verbose: bool = True,
954 preview_full: bool = False,
955 ) -> None:
956 """Initialize the auto manager.
958 Args:
959 config: Project configuration
960 dry_run: If True, only preview what would be done
961 max_issues: Maximum issues to process (0 = unlimited)
962 resume: Whether to resume from previous state
963 category: Optional category to filter (e.g., "bugs")
964 only_ids: If provided, only process these issue IDs. When a list,
965 issues are processed in list order (input sequence preserved).
966 skip_ids: Issue IDs to skip (in addition to attempted issues)
967 type_prefixes: If provided, only process issues with these type prefixes
968 priority_filter: If provided, only process issues with these priority levels
969 label_filter: If provided, only process issues that have at least one of these labels
970 verbose: Whether to output progress messages
971 preview_full: If True, show full command content without truncation (--verbose flag).
972 """
973 self.config = config
974 self.dry_run = dry_run
975 self.max_issues = max_issues
976 self.resume = resume
977 self.category = category
978 self.only_ids = only_ids
979 self.skip_ids = skip_ids or set()
980 self.type_prefixes = type_prefixes
981 self.priority_filter = priority_filter
982 self.label_filter = label_filter
983 self._preview_full = preview_full
985 from little_loops.cli.output import use_color_enabled
987 self.logger = Logger(verbose=verbose, use_color=use_color_enabled())
988 self.event_bus = EventBus()
989 self.state_manager = StateManager(
990 config.get_state_file(), self.logger, event_bus=self.event_bus
991 )
992 self.parser = IssueParser(config)
993 self._detected_model: list[str] = []
995 # Build dependency graph for dependency-aware sequencing (ENH-016)
996 # Note: don't filter by type here — we need all issues for dependency resolution
997 all_issues = find_issues(self.config, self.category)
998 all_known_ids: set[str] | None = None
999 try:
1000 from little_loops.dependency_mapper import gather_all_issue_ids
1002 issues_dir = config.project_root / config.issues.base_dir
1003 all_known_ids = gather_all_issue_ids(issues_dir, config=config)
1004 except Exception:
1005 self.logger.debug("Dependency mapping unavailable — skipping")
1006 self.dep_graph = DependencyGraph.from_issues(all_issues, all_known_ids=all_known_ids)
1008 # Warn about any cycles
1009 if self.dep_graph.has_cycles():
1010 cycles = self.dep_graph.detect_cycles()
1011 for cycle in cycles:
1012 self.logger.warning(f"Dependency cycle detected: {' -> '.join(cycle)}")
1014 self.processed_count = 0
1015 self._shutdown_requested = False
1017 signal.signal(signal.SIGINT, self._signal_handler)
1018 signal.signal(signal.SIGTERM, self._signal_handler)
1020 def _signal_handler(self, signum: int, frame: FrameType | None) -> None:
1021 """Handle shutdown signals gracefully."""
1022 self._shutdown_requested = True
1023 self.logger.warning(f"Received signal {signum}, shutting down gracefully...")
1025 def _get_next_issue(self) -> IssueInfo | None:
1026 """Get next issue respecting dependencies.
1028 Returns the highest priority issue whose blockers have all been
1029 completed. If no ready issues exist but blocked issues remain,
1030 logs warnings about what is blocking progress.
1032 Returns:
1033 Next IssueInfo to process, or None if no ready issues
1034 """
1035 # Get completed issues from state
1036 completed = set(self.state_manager.state.completed_issues)
1038 # Combine skip_ids from state and CLI argument
1039 skip_ids = self.state_manager.state.attempted_issues | self.skip_ids
1041 # Get issues that are ready (blockers satisfied)
1042 ready_issues = self.dep_graph.get_ready_issues(completed)
1044 # Filter by skip_ids, only_ids, type_prefixes, priority_filter, label_filter
1045 candidates = [
1046 i
1047 for i in ready_issues
1048 if i.issue_id not in skip_ids
1049 and (self.only_ids is None or any(_id_matches(i.issue_id, p) for p in self.only_ids))
1050 and (self.type_prefixes is None or i.issue_id.split("-", 1)[0] in self.type_prefixes)
1051 and (self.priority_filter is None or i.priority in self.priority_filter)
1052 and (
1053 self.label_filter is None or any(lb.lower() in self.label_filter for lb in i.labels)
1054 )
1055 ]
1057 if candidates:
1058 # When only_ids is a list, respect input order; otherwise use priority order
1059 only_ids = self.only_ids
1060 if isinstance(only_ids, list):
1061 candidates.sort(
1062 key=lambda x: next(
1063 (i for i, p in enumerate(only_ids) if _id_matches(x.issue_id, p)),
1064 len(only_ids),
1065 )
1066 )
1067 return candidates[0]
1069 # No ready candidates - check if there are blocked issues remaining
1070 all_in_graph = set(self.dep_graph.issues.keys())
1071 remaining = all_in_graph - completed - skip_ids
1072 if self.only_ids is not None:
1073 remaining = {r for r in remaining if any(_id_matches(r, p) for p in self.only_ids)}
1074 if self.type_prefixes is not None:
1075 remaining = {r for r in remaining if r.split("-", 1)[0] in self.type_prefixes}
1076 if self.priority_filter is not None:
1077 remaining = {
1078 r for r in remaining if self.dep_graph.issues[r].priority in self.priority_filter
1079 }
1080 if self.label_filter is not None:
1081 remaining = {
1082 r
1083 for r in remaining
1084 if any(lb.lower() in self.label_filter for lb in self.dep_graph.issues[r].labels)
1085 }
1087 if remaining:
1088 self._log_blocked_issues(remaining, completed)
1090 return None
1092 def _log_blocked_issues(self, remaining: set[str], completed: set[str]) -> None:
1093 """Log information about blocked issues when processing stalls.
1095 Args:
1096 remaining: Set of issue IDs that haven't been processed
1097 completed: Set of completed issue IDs
1098 """
1099 blocked_count = 0
1100 for issue_id in sorted(remaining):
1101 blockers = self.dep_graph.get_blocking_issues(issue_id, completed)
1102 if blockers:
1103 blocked_count += 1
1104 self.logger.info(f" {issue_id} blocked by: {', '.join(sorted(blockers))}")
1106 if blocked_count > 0:
1107 self.logger.warning(f"{blocked_count} issue(s) remain blocked - check dependencies")
1109 def run(self) -> int:
1110 """Run the automation loop.
1112 Returns:
1113 Exit code: 0 = success or empty queue, 1 = all issues gate-blocked when --only used
1114 """
1115 run_start_time = time.time()
1116 self.logger.info("Starting automated issue management...")
1118 if self.dry_run:
1119 self.logger.info("DRY RUN MODE - No actual changes will be made")
1121 if not self.dry_run:
1122 has_changes = check_git_status(self.logger)
1123 if has_changes:
1124 self.logger.warning("Proceeding anyway...")
1126 # Load or initialize state
1127 if self.resume:
1128 state = self.state_manager.load()
1129 if state:
1130 self.logger.info(f"Resuming from: {state.current_issue}")
1131 self.processed_count = len(state.completed_issues)
1132 else:
1133 # Fresh start
1134 self.state_manager._state = ProcessingState(timestamp=_iso_now())
1136 attempted_count = 0
1137 try:
1138 while not self._shutdown_requested:
1139 if self.max_issues > 0 and self.processed_count >= self.max_issues:
1140 self.logger.info(f"Reached max issues limit: {self.max_issues}")
1141 break
1143 info = self._get_next_issue()
1144 if not info:
1145 self.logger.success("No more issues to process!")
1146 break
1148 attempted_count += 1
1149 success = self._process_issue(info)
1150 if success:
1151 self.processed_count += 1
1153 except Exception as e:
1154 self.logger.error(f"Fatal error: {e}")
1155 return 1
1157 finally:
1158 if not self._shutdown_requested:
1159 self.state_manager.cleanup()
1161 self._log_timing_summary(run_start_time)
1162 self.logger.success(f"Processed {self.processed_count} issue(s)")
1163 if self.only_ids and attempted_count > 0 and self.processed_count == 0:
1164 return 1
1165 return 0
1167 def _log_timing_summary(self, run_start_time: float) -> None:
1168 """Log aggregate timing summary."""
1169 total_run_time = time.time() - run_start_time
1171 self.logger.info("")
1172 self.logger.header("PROCESSING SUMMARY")
1173 self.logger.timing(f"Total run time: {format_duration(total_run_time)}")
1174 self.logger.timing(f"Issues processed: {self.processed_count}")
1176 state = self.state_manager.state
1177 if state.timing:
1178 total_times = [t.get("total", 0) for t in state.timing.values()]
1179 if total_times:
1180 avg_time = sum(total_times) / len(total_times)
1181 self.logger.timing(f"Average per issue: {format_duration(avg_time)}")
1183 if state.failed_issues:
1184 self.logger.warning(f"Failed issues: {len(state.failed_issues)}")
1185 for issue_id, reason in state.failed_issues.items():
1186 self.logger.warning(f" - {issue_id}: {reason[:50]}...")
1188 # Log correction statistics for quality tracking
1189 if state.corrections:
1190 total_corrected = len(state.corrections)
1191 total_issues = len(state.completed_issues) + len(state.failed_issues)
1192 correction_rate = (total_corrected / total_issues * 100) if total_issues > 0 else 0
1193 self.logger.info(
1194 f"Auto-corrections: {total_corrected}/{total_issues} ({correction_rate:.1f}%)"
1195 )
1197 # Log most common correction types
1198 from collections import Counter
1200 all_corrections: list[str] = []
1201 for corrections in state.corrections.values():
1202 all_corrections.extend(corrections)
1203 if all_corrections:
1204 common = Counter(all_corrections).most_common(3)
1205 self.logger.info("Most common corrections:")
1206 for correction, count in common:
1207 # Truncate long correction descriptions
1208 display = correction[:60] + "..." if len(correction) > 60 else correction
1209 self.logger.info(f" - {display}: {count}")
1211 def _process_issue(self, info: IssueInfo) -> bool:
1212 """Process a single issue through the workflow.
1214 Delegates to process_issue_inplace() and maps the result back
1215 to state manager calls.
1217 Args:
1218 info: Issue information
1220 Returns:
1221 True if processing succeeded
1222 """
1223 # Pre-processing state updates (before delegating)
1224 self.state_manager.mark_attempted(info.issue_id, save=False)
1225 self.state_manager.update_current(str(info.path), "processing")
1227 on_model: Callable[[str], None] | None = None
1228 if not self._detected_model:
1230 def on_model(m: str) -> None:
1231 self._detected_model.append(m)
1232 self.logger.info(f"model: {m}")
1234 result = process_issue_inplace(
1235 info,
1236 self.config,
1237 self.logger,
1238 self.dry_run,
1239 on_model_detected=on_model,
1240 preview_full=self._preview_full,
1241 )
1243 # Map result back to state tracking
1244 if result.was_closed:
1245 self.state_manager.mark_completed(info.issue_id)
1246 elif result.was_blocked:
1247 # Blocked issues are skipped, not failed — leave in pending state
1248 self.logger.info(f"{info.issue_id} skipped — blocked by open dependency")
1249 elif result.success:
1250 self.state_manager.mark_completed(info.issue_id, {"total": result.duration})
1251 elif result.plan_created:
1252 # Don't mark as failed if a plan was created (awaiting approval)
1253 self.logger.info(
1254 f"{info.issue_id} has plan at {result.plan_path} - "
1255 "leaving in pending state for manual approval"
1256 )
1257 # Issue remains in pending state (not marked as failed)
1258 elif result.failure_reason:
1259 self.state_manager.mark_failed(info.issue_id, result.failure_reason)
1261 if result.corrections:
1262 self.state_manager.record_corrections(info.issue_id, result.corrections)
1264 return result.success