Coverage for little_loops / issue_manager.py: 10%
515 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-28 13:07 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-28 13:07 -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.session_store import DEFAULT_DB_PATH, SQLiteTransport
37from little_loops.skill_expander import expand_skill
38from little_loops.state import ProcessingState, StateManager, _iso_now
39from little_loops.subprocess_utils import (
40 assemble_guillotine_prompt,
41 detect_context_handoff,
42 read_continuation_prompt,
43 read_sentinel,
44 write_sentinel,
45)
46from little_loops.subprocess_utils import (
47 run_claude_command as _run_claude_base,
48)
51def _compute_relative_path(abs_path: Path, base_dir: Path | None = None) -> str:
52 """Compute relative path from base directory for command input.
54 Used for fallback retry when ready-issue resolves to wrong file -
55 allows retrying with explicit file path instead of ambiguous ID.
57 Args:
58 abs_path: Absolute path to the file
59 base_dir: Base directory (defaults to cwd)
61 Returns:
62 Relative path string suitable for ready-issue command
63 """
64 base = base_dir or Path.cwd()
65 try:
66 return str(abs_path.relative_to(base))
67 except ValueError:
68 # Path not relative to base, use absolute
69 return str(abs_path)
72@contextmanager
73def timed_phase(
74 logger: Logger,
75 phase_name: str,
76) -> Generator[dict[str, float], None, None]:
77 """Context manager for timing phases.
79 Yields a dict that will be populated with 'elapsed' after the context exits.
81 Args:
82 logger: Logger for output
83 phase_name: Name of the phase being timed
85 Yields:
86 Dict that will contain 'elapsed' key after context exits
87 """
88 timing_result: dict[str, float] = {}
89 start = time.time()
90 try:
91 yield timing_result
92 finally:
93 elapsed = time.time() - start
94 timing_result["elapsed"] = elapsed
95 logger.timing(f"{phase_name} completed in {format_duration(elapsed)}")
98def run_claude_command(
99 command: str,
100 logger: Logger,
101 timeout: int = 3600,
102 stream_output: bool = True,
103 idle_timeout: int = 0,
104 on_model_detected: Callable[[str], None] | None = None,
105 on_usage: Callable[[int, int], None] | None = None,
106 preview_full: bool = False,
107 resume_session: bool = False,
108) -> subprocess.CompletedProcess[str]:
109 """Invoke Claude CLI command with real-time output streaming.
111 Args:
112 command: Command to pass to Claude CLI
113 logger: Logger for output
114 timeout: Timeout in seconds
115 stream_output: Whether to stream output to console
116 idle_timeout: Kill process if no output for this many seconds (0 to disable)
117 on_model_detected: Optional callback invoked with the model name from the
118 stream-json system/init event.
119 preview_full: If True, display the full command without truncation (for --verbose).
120 resume_session: If True, passes --continue to the Claude CLI to continue the
121 most recent conversation (used for Option E explicit-handoff path).
123 Returns:
124 CompletedProcess with stdout/stderr captured
125 """
126 from little_loops.cli.output import terminal_width
128 lines = command.strip().splitlines()
129 line_count = len(lines)
130 tw = terminal_width()
131 max_line = tw - 4
132 resume_flag = " --continue" if resume_session else ""
133 logger.info(
134 f"Running: claude --dangerously-skip-permissions{resume_flag} -p ({line_count} lines)"
135 )
136 show_count = line_count if preview_full else min(5, line_count)
137 for line in lines[:show_count]:
138 display = (
139 line if preview_full else (line[:max_line] + "..." if len(line) > max_line else line)
140 )
141 logger.info(f" {display}")
142 if line_count > show_count:
143 logger.info(f" ... ({line_count - show_count} more lines)")
145 def stream_callback(line: str, is_stderr: bool) -> None:
146 if stream_output:
147 if is_stderr:
148 print(f" {line}", file=sys.stderr)
149 else:
150 print(f" {line}")
152 return _run_claude_base(
153 command=command,
154 timeout=timeout,
155 stream_callback=stream_callback if stream_output else None,
156 idle_timeout=idle_timeout,
157 on_model_detected=on_model_detected,
158 on_usage=on_usage,
159 resume_session=resume_session,
160 )
163def run_with_continuation(
164 initial_command: str,
165 logger: Logger,
166 timeout: int = 3600,
167 stream_output: bool = True,
168 max_continuations: int = 3,
169 repo_path: Path | None = None,
170 idle_timeout: int = 0,
171 resume_command: str | None = None,
172 on_usage: Callable[[int, int], None] | None = None,
173 preview_full: bool = False,
174 context_limit: int = 200_000,
175 sentinel_threshold: float = 0.60,
176 guillotine_threshold: float = 0.90,
177) -> subprocess.CompletedProcess[str]:
178 """Run a Claude command with automatic continuation on context handoff.
180 Implements Options E, G, and J from BUG-1377:
182 Option G (sentinel write): after each session, if accurate token count from
183 on_usage exceeds sentinel_threshold without a CONTEXT_HANDOFF signal, writes
184 a sentinel file so the next iteration knows to send an explicit handoff instruction.
186 Option E (sentinel read): before starting the next session, reads the sentinel
187 and if present sends a ``--continue`` turn with an explicit "run /ll:handoff now"
188 instruction, triggering the standard CONTEXT_HANDOFF continuation flow.
190 Option J (guillotine): if usage exceeds guillotine_threshold OR stderr contains
191 "Prompt is too long", assembles a transcript-summary prompt and spawns a fresh
192 session (not --resume) starting at 0 tokens.
194 Args:
195 initial_command: Initial command to run
196 logger: Logger for output
197 timeout: Timeout per session in seconds
198 stream_output: Whether to stream output
199 max_continuations: Maximum number of continuation attempts
200 repo_path: Repository root path
201 idle_timeout: Kill process if no output for this many seconds (0 to disable)
202 resume_command: Command to use for continuation rounds instead of appending
203 ``--resume`` to ``initial_command``.
204 on_usage: Optional external usage callback; wrapped internally for tracking.
205 context_limit: Context window size in tokens (default 200K).
206 sentinel_threshold: Write sentinel when usage/context_limit >= this (default 0.60).
207 guillotine_threshold: Trigger J-path when usage/context_limit >= this (default 0.90).
209 Returns:
210 Final CompletedProcess result
211 """
212 all_stdout: list[str] = []
213 all_stderr: list[str] = []
214 current_command = initial_command
215 continuation_count = 0
216 result: subprocess.CompletedProcess[str] = subprocess.CompletedProcess(
217 args=[], returncode=1, stdout="", stderr=""
218 )
220 # Track token usage from on_usage callback (fires on stream-json result event).
221 _last_input: list[int] = [0]
222 _last_output: list[int] = [0]
223 _external_on_usage = on_usage
224 # Flag set when Option J fires; consumed in the NEXT iteration so Option E
225 # knows to consume the sentinel without attempting --continue.
226 _just_ran_fresh_session = False
228 def _tracking_usage(input_tokens: int, output_tokens: int) -> None:
229 _last_input[0] = input_tokens
230 _last_output[0] = output_tokens
231 if _external_on_usage is not None:
232 _external_on_usage(input_tokens, output_tokens)
234 while continuation_count <= max_continuations:
235 this_is_fresh = _just_ran_fresh_session
236 _just_ran_fresh_session = False
237 result = run_claude_command(
238 current_command,
239 logger,
240 timeout=timeout,
241 stream_output=stream_output,
242 idle_timeout=idle_timeout,
243 on_usage=_tracking_usage,
244 preview_full=preview_full,
245 )
247 all_stdout.append(result.stdout)
248 all_stderr.append(result.stderr)
250 # Check for context handoff signal (standard path: Claude emitted the signal)
251 if detect_context_handoff(result.stdout):
252 logger.info("Detected CONTEXT_HANDOFF signal")
254 # Read continuation prompt
255 prompt_content = read_continuation_prompt(repo_path)
256 if not prompt_content:
257 logger.warning("Context handoff signaled but no continuation prompt found")
258 all_stderr.append("Handoff detected but no continuation prompt found")
259 result = subprocess.CompletedProcess(
260 args=result.args, returncode=1, stdout=result.stdout, stderr=result.stderr
261 )
262 break
264 if continuation_count >= max_continuations:
265 logger.warning(f"Reached max continuations ({max_continuations}), stopping")
266 break
268 continuation_count += 1
269 logger.info(f"Starting continuation session #{continuation_count}")
271 _base = resume_command if resume_command is not None else initial_command
272 current_command = f"{_base} --resume"
273 continue
275 total_tokens = _last_input[0] + _last_output[0]
276 usage_ratio = total_tokens / context_limit if context_limit > 0 else 0.0
277 prompt_too_long = "prompt is too long" in (result.stderr or "").lower()
279 # Option J: guillotine — context overflow with no handoff signal.
280 # Assemble transcript-summary prompt and start a FRESH session (not --resume).
281 if (
282 prompt_too_long or usage_ratio >= guillotine_threshold
283 ) and continuation_count < max_continuations:
284 trigger_reason = "Prompt is too long" if prompt_too_long else f"usage {usage_ratio:.0%}"
285 logger.warning(f"Option J triggered ({trigger_reason}): spawning fresh session")
286 try:
287 guillotine_cmd = assemble_guillotine_prompt(
288 original_command=initial_command,
289 captured_stdout="\n---CONTINUATION---\n".join(all_stdout),
290 token_stats={
291 "input_tokens": _last_input[0],
292 "output_tokens": _last_output[0],
293 "context_limit": context_limit,
294 "trigger_reason": trigger_reason,
295 },
296 )
297 except Exception as exc:
298 logger.warning(f"Failed to assemble guillotine prompt ({exc}), using bare restart")
299 guillotine_cmd = initial_command
300 continuation_count += 1
301 current_command = guillotine_cmd
302 # Reset per-round usage tracking for the fresh session
303 _last_input[0] = 0
304 _last_output[0] = 0
305 _just_ran_fresh_session = True
306 continue
308 # Option E: read sentinel from a PREVIOUS session (written by the Stop hook or by
309 # G-path in the preceding iteration). Must run BEFORE G writes the current-session
310 # sentinel so we don't immediately consume what we just wrote.
311 sentinel_data = read_sentinel(repo_path)
312 if sentinel_data is not None and this_is_fresh:
313 # The sentinel was written by the guillotine fresh session that just finished.
314 # The work is already done; do not attempt --continue.
315 logger.info(
316 "Fresh session wrote sentinel; consumed without --continue (work already done)"
317 )
318 elif sentinel_data is not None and continuation_count < max_continuations:
319 usage_pct = sentinel_data.get("usage_percent", int(usage_ratio * 100))
320 logger.info(
321 f"Sentinel detected ({usage_pct}% context used): "
322 "sending explicit handoff instruction via --continue"
323 )
324 continuation_count += 1
325 # Resume the existing session with an explicit handoff instruction.
326 # Claude receives this as a new user turn in the active session context.
327 explicit_handoff_instruction = (
328 f"Context limit is approaching ({usage_pct}% of the context window is used). "
329 "Please run /ll:handoff RIGHT NOW to save your progress to "
330 ".ll/ll-continue-prompt.md, then output "
331 '"CONTEXT_HANDOFF: Ready for fresh session" to signal continuation.'
332 )
333 current_command = explicit_handoff_instruction
334 # Reset tracking for the handoff turn
335 _last_input[0] = 0
336 _last_output[0] = 0
337 # Use --continue CLI flag so this turn continues the existing session
338 result = run_claude_command(
339 current_command,
340 logger,
341 timeout=timeout,
342 stream_output=stream_output,
343 idle_timeout=idle_timeout,
344 on_usage=_tracking_usage,
345 preview_full=preview_full,
346 resume_session=True,
347 )
348 all_stdout.append(result.stdout)
349 all_stderr.append(result.stderr)
351 # After explicit instruction, check for handoff signal
352 if detect_context_handoff(result.stdout):
353 logger.info("CONTEXT_HANDOFF detected after explicit handoff instruction")
354 prompt_content = read_continuation_prompt(repo_path)
355 if prompt_content and continuation_count < max_continuations:
356 continuation_count += 1
357 logger.info(f"Starting continuation session #{continuation_count}")
358 _base = resume_command if resume_command is not None else initial_command
359 current_command = f"{_base} --resume"
360 _last_input[0] = 0
361 _last_output[0] = 0
362 continue
363 break
365 # Option G (Python layer): write sentinel if usage is high for the NEXT session.
366 # Placed after E-path check so we don't immediately consume our own write.
367 if total_tokens > 0 and usage_ratio >= sentinel_threshold:
368 logger.info(f"Writing context-handoff sentinel ({usage_ratio:.0%} context used)")
369 write_sentinel(repo_path, token_count=total_tokens, context_limit=context_limit)
371 # No handoff signal, no prior-session sentinel, no overflow — done
372 break
374 return subprocess.CompletedProcess(
375 args=result.args,
376 returncode=result.returncode,
377 stdout="\n---CONTINUATION---\n".join(all_stdout),
378 stderr="\n---CONTINUATION---\n".join(all_stderr),
379 )
382def detect_plan_creation(output: str, issue_id: str) -> Path | None:
383 """Detect if manage-issue created a plan file awaiting approval.
385 Checks for plan file creation in thoughts/shared/plans/ matching the issue ID.
386 This happens when manage-issue creates a plan but waits for user approval.
388 Args:
389 output: Command stdout (unused, for future pattern matching)
390 issue_id: Issue ID (e.g., "BUG-280")
392 Returns:
393 Path to plan file if created, None otherwise
394 """
395 plans_dir = Path("thoughts/shared/plans")
396 if not plans_dir.exists():
397 return None
399 # Find plan files matching this issue ID (format: YYYY-MM-DD-ISSUE-ID-*.md)
400 # Use glob pattern with issue_id
401 pattern = f"*-{issue_id}-*.md"
402 matching_plans = list(plans_dir.glob(pattern))
404 if not matching_plans:
405 return None
407 # Return the most recently modified plan file
408 # (in case multiple exist, take the latest)
409 latest_plan = max(matching_plans, key=lambda p: p.stat().st_mtime)
410 return latest_plan
413def check_content_markers(issue_path: Path) -> bool:
414 """Check if issue file content contains implementation markers.
416 Looks for indicators that an implementation was completed, such as
417 Resolution sections or status markers added by manage-issue.
419 Args:
420 issue_path: Path to the issue file
422 Returns:
423 True if implementation markers found
424 """
425 try:
426 content = issue_path.read_text(encoding="utf-8")
427 except (OSError, UnicodeDecodeError):
428 return False
430 markers = [
431 "## Resolution",
432 "Status: Implemented",
433 "Status: Completed",
434 "**Completed**:",
435 ]
436 return any(marker in content for marker in markers)
439@dataclass
440class IssueProcessingResult:
441 """Result of processing a single issue in-place."""
443 success: bool
444 duration: float
445 issue_id: str
446 was_closed: bool = False
447 was_blocked: bool = False
448 failure_reason: str = ""
449 corrections: list[str] = field(default_factory=list)
450 plan_created: bool = False
451 plan_path: str = ""
454def process_issue_inplace(
455 info: IssueInfo,
456 config: BRConfig,
457 logger: Logger,
458 dry_run: bool = False,
459 on_model_detected: Callable[[str], None] | None = None,
460 on_usage: Callable[[int, int], None] | None = None,
461 preview_full: bool = False,
462 event_bus: EventBus | None = None,
463) -> IssueProcessingResult:
464 """Process a single issue through the 3-phase workflow in the current working tree.
466 This is the core processing logic extracted from AutoManager._process_issue(),
467 suitable for use outside of AutoManager (e.g., single-issue sprint waves).
469 Args:
470 info: Issue information
471 config: Project configuration
472 logger: Logger for output
473 dry_run: If True, only preview what would be done
474 on_model_detected: Optional callback invoked with the model name from the
475 first stream-json system/init event during this issue's processing.
476 on_usage: Optional callback invoked with (input_tokens, output_tokens) from
477 each stream-json result event. Passed through to all run_claude_command calls.
479 Returns:
480 IssueProcessingResult with outcome details
481 """
482 issue_start_time = time.time()
483 corrections: list[str] = []
485 logger.header(f"Processing: {info.issue_id} - {info.title}")
487 issue_timing: dict[str, float] = {}
489 # Track whether we used fallback path resolution for ready-issue.
490 validated_via_fallback = False
492 # Build on_usage closure that writes result_token_count to the context state file.
493 # Mirrors the on_model_detected closure pattern in AutoManager._process_issue.
494 _state_file = (config.repo_path or Path.cwd()) / ".ll" / "ll-context-state.json"
495 _external_on_usage = on_usage
497 def _on_usage_writer(input_tokens: int, output_tokens: int) -> None:
498 try:
499 state = json.loads(_state_file.read_text()) if _state_file.exists() else {}
500 state["result_token_count"] = input_tokens + output_tokens
501 _state_file.write_text(json.dumps(state))
502 except Exception:
503 pass # never block execution on state write failures
504 if _external_on_usage is not None:
505 _external_on_usage(input_tokens, output_tokens)
507 # Phase 1: Ready/verify the issue
508 logger.info(f"Phase 1: Verifying issue {info.issue_id}...")
509 with timed_phase(logger, "Phase 1 (ready-issue)") as phase1_timing:
510 if not dry_run:
511 _ready_slash = f"/ll:ready-issue {info.issue_id}"
512 _ready_cmd = expand_skill("ready-issue", [info.issue_id], config) or _ready_slash
513 result = run_claude_command(
514 _ready_cmd,
515 logger,
516 timeout=config.automation.timeout_seconds,
517 stream_output=config.automation.stream_output,
518 idle_timeout=config.automation.idle_timeout_seconds,
519 on_model_detected=on_model_detected,
520 preview_full=preview_full,
521 )
522 if result.returncode != 0:
523 logger.warning("ready-issue command failed to execute, continuing anyway...")
524 else:
525 # Parse the verdict from the output
526 parsed = parse_ready_issue_output(result.stdout)
527 logger.info(f"ready-issue verdict: {parsed['verdict']}")
529 # Validate that ready-issue analyzed the expected file
530 validated_path = parsed.get("validated_file_path")
531 if validated_path:
532 # Normalize paths for comparison (resolve to absolute)
533 expected_path = str(info.path.resolve())
534 # Handle both absolute and relative paths from ready_issue
535 validated_resolved = Path(validated_path).resolve()
536 if str(validated_resolved) != expected_path:
537 # Check if this is a legitimate rename (new file exists,
538 # old file doesn't) vs a mismatch error
539 old_file_exists = info.path.exists()
540 new_file_exists = validated_resolved.exists()
542 if new_file_exists and not old_file_exists:
543 # ready-issue renamed the file - update tracking
544 logger.info(
545 f"Issue file renamed: '{info.path.name}' -> "
546 f"'{validated_resolved.name}'"
547 )
548 info.path = validated_resolved
549 else:
550 # Genuine mismatch - attempt fallback with explicit path
551 logger.warning(
552 f"Path mismatch: ready-issue validated "
553 f"'{validated_path}' but expected '{info.path}'"
554 )
555 logger.info(
556 "Attempting fallback: retrying ready-issue "
557 "with explicit file path..."
558 )
560 # Compute relative path for the command
561 relative_path = _compute_relative_path(info.path)
563 # Retry with explicit path
564 _retry_slash = f"/ll:ready-issue {relative_path}"
565 _retry_cmd = (
566 expand_skill("ready-issue", [str(relative_path)], config)
567 or _retry_slash
568 )
569 retry_result = run_claude_command(
570 _retry_cmd,
571 logger,
572 timeout=config.automation.timeout_seconds,
573 stream_output=config.automation.stream_output,
574 idle_timeout=config.automation.idle_timeout_seconds,
575 on_model_detected=on_model_detected,
576 preview_full=preview_full,
577 )
579 if retry_result.returncode != 0:
580 logger.error(f"Fallback ready-issue failed for {info.issue_id}")
581 return IssueProcessingResult(
582 success=False,
583 duration=time.time() - issue_start_time,
584 issue_id=info.issue_id,
585 failure_reason="Fallback failed after path mismatch",
586 )
588 # Re-parse and validate retry output
589 retry_parsed = parse_ready_issue_output(retry_result.stdout)
590 retry_validated_path = retry_parsed.get("validated_file_path")
592 if retry_validated_path:
593 retry_resolved = Path(retry_validated_path).resolve()
594 if str(retry_resolved) != str(info.path.resolve()):
595 logger.error(
596 f"Fallback still mismatched: "
597 f"got '{retry_validated_path}', "
598 f"expected '{info.path}'"
599 )
600 return IssueProcessingResult(
601 success=False,
602 duration=time.time() - issue_start_time,
603 issue_id=info.issue_id,
604 failure_reason="Path mismatch persisted after fallback",
605 )
607 # Fallback succeeded - use retry result
608 logger.info("Fallback succeeded: validated correct file")
609 parsed = retry_parsed
610 validated_via_fallback = True
612 # Log and store any corrections made
613 if parsed.get("was_corrected"):
614 logger.info(f"Issue {info.issue_id} was auto-corrected")
615 phase_corrections = parsed.get("corrections", [])
616 for correction in phase_corrections:
617 logger.info(f" Correction: {correction}")
618 if phase_corrections:
619 corrections.extend(phase_corrections)
621 # Log any concerns found
622 if parsed["concerns"]:
623 for concern in parsed["concerns"]:
624 logger.warning(f" Concern: {concern}")
626 # Handle CLOSE verdict - issue should not be implemented
627 if parsed.get("should_close"):
628 close_reason = parsed.get("close_reason", "unknown")
629 logger.info(f"Issue {info.issue_id} should be closed (reason: {close_reason})")
631 # CRITICAL: Skip file operations for invalid references
632 if close_reason == "invalid_ref":
633 logger.warning(
634 f"Skipping {info.issue_id}: invalid reference - "
635 "no matching issue file exists"
636 )
637 return IssueProcessingResult(
638 success=False,
639 duration=time.time() - issue_start_time,
640 issue_id=info.issue_id,
641 failure_reason=f"Invalid reference: {close_reason}",
642 corrections=corrections,
643 )
645 # Also require validated_file_path to match before closing
646 close_validated_path = parsed.get("validated_file_path")
647 if not close_validated_path:
648 logger.warning(
649 f"Skipping close for {info.issue_id}: "
650 "ready-issue did not return validated file path"
651 )
652 return IssueProcessingResult(
653 success=False,
654 duration=time.time() - issue_start_time,
655 issue_id=info.issue_id,
656 failure_reason="CLOSE without validated file path",
657 corrections=corrections,
658 )
660 if close_issue(
661 info,
662 config,
663 logger,
664 close_reason,
665 parsed.get("close_status"),
666 event_bus=event_bus,
667 ):
668 return IssueProcessingResult(
669 success=True,
670 duration=time.time() - issue_start_time,
671 issue_id=info.issue_id,
672 was_closed=True,
673 corrections=corrections,
674 )
675 else:
676 return IssueProcessingResult(
677 success=False,
678 duration=time.time() - issue_start_time,
679 issue_id=info.issue_id,
680 failure_reason=f"CLOSE failed: {parsed.get('close_status', 'unknown')}",
681 corrections=corrections,
682 )
684 # Handle BLOCKED verdict - issue has open dependencies
685 if parsed.get("is_blocked"):
686 logger.warning(
687 f"Issue {info.issue_id} blocked — open dependency detected by ready-issue"
688 )
689 return IssueProcessingResult(
690 success=False,
691 was_blocked=True,
692 duration=time.time() - issue_start_time,
693 issue_id=info.issue_id,
694 failure_reason=f"BLOCKED: {parsed.get('concerns', [])}",
695 corrections=corrections,
696 )
698 # Check if issue is NOT READY (and not closeable)
699 if not parsed["is_ready"]:
700 logger.error(
701 f"Issue {info.issue_id} is NOT READY for implementation "
702 f"(verdict: {parsed['verdict']})"
703 )
704 return IssueProcessingResult(
705 success=False,
706 duration=time.time() - issue_start_time,
707 issue_id=info.issue_id,
708 failure_reason=(
709 f"NOT READY: {parsed['verdict']} - {len(parsed['concerns'])} concern(s)"
710 ),
711 corrections=corrections,
712 )
714 # Log if proceeding with corrected issue
715 if parsed.get("was_corrected"):
716 logger.success(f"Issue {info.issue_id} corrected and ready for implementation")
717 else:
718 logger.info(f"Would run: /ll:ready-issue {info.issue_id}")
719 issue_timing["ready"] = phase1_timing.get("elapsed", 0.0)
721 # Decision gate: invoke decide-issue when the issue requires a decision
722 if info.decision_needed is True and not dry_run:
723 logger.info(
724 f"Decision gate: {info.issue_id} has decision_needed=True, invoking decide-issue..."
725 )
726 _decide_slash = f"/ll:decide-issue {info.issue_id} --auto"
727 _decide_cmd = (
728 expand_skill("decide-issue", [info.issue_id, "--auto"], config) or _decide_slash
729 )
730 decide_result = run_claude_command(
731 _decide_cmd,
732 logger,
733 timeout=config.automation.timeout_seconds,
734 stream_output=config.automation.stream_output,
735 idle_timeout=config.automation.idle_timeout_seconds,
736 on_model_detected=on_model_detected,
737 preview_full=preview_full,
738 )
739 if decide_result.returncode != 0:
740 logger.warning("decide-issue command failed, continuing to implementation anyway...")
742 # Phase 2: Implement the issue (with automatic continuation on context handoff)
743 action = config.get_category_action(info.issue_type)
744 logger.info(f"Phase 2: Implementing {info.issue_id}...")
745 _baseline_sha_result = subprocess.run(
746 ["git", "rev-parse", "HEAD"], capture_output=True, text=True
747 )
748 _baseline_sha: str | None = (
749 _baseline_sha_result.stdout.strip() if _baseline_sha_result.returncode == 0 else None
750 )
751 with timed_phase(logger, "Phase 2 (implement)") as phase2_timing:
752 if not dry_run:
753 # Build manage-issue command
754 type_name = info.issue_type.rstrip("s") # bugs -> bug
756 # Use relative path if fallback was used, otherwise use issue_id
757 if validated_via_fallback:
758 issue_arg = _compute_relative_path(info.path)
759 else:
760 issue_arg = info.issue_id
762 # Use run_with_continuation to handle context exhaustion.
763 # Pre-expand the skill so the subprocess needs no ToolSearch.
764 # Pass the short slash command as resume_command so continuation
765 # rounds stay compact (not hundreds of lines).
766 _slash_cmd = f"/ll:manage-issue {type_name} {action} {issue_arg}"
767 _initial_cmd = (
768 expand_skill("manage-issue", [type_name, action, issue_arg], config) or _slash_cmd
769 )
770 result = run_with_continuation(
771 _initial_cmd,
772 logger,
773 timeout=config.automation.timeout_seconds,
774 stream_output=config.automation.stream_output,
775 max_continuations=config.automation.max_continuations,
776 repo_path=config.repo_path,
777 idle_timeout=config.automation.idle_timeout_seconds,
778 resume_command=_slash_cmd,
779 on_usage=_on_usage_writer,
780 preview_full=preview_full,
781 )
782 else:
783 logger.info(f"Would run: /ll:manage-issue {info.issue_type} {action} {info.issue_id}")
784 result = subprocess.CompletedProcess(args=[], returncode=0)
785 issue_timing["implement"] = phase2_timing.get("elapsed", 0.0)
787 # Handle implementation failure
788 if result.returncode != 0:
789 # Guard: if the issue's frontmatter already shows status: done by the
790 # subprocess (e.g., a guillotine fresh session that finished and then
791 # triggered a spurious Option E --continue failure), treat as success
792 # so Phase 3 runs.
793 already_done = False
794 if info.path.exists():
795 try:
796 from little_loops.frontmatter import parse_frontmatter
798 _fm = parse_frontmatter(info.path.read_text(encoding="utf-8"))
799 already_done = _fm.get("status") in ("done", "completed", "cancelled")
800 except Exception:
801 already_done = False
802 if already_done:
803 logger.warning(
804 f"Phase 2 exited non-zero but {info.issue_id} status is done/cancelled; "
805 "treating as success (continuation artefact)"
806 )
807 result = subprocess.CompletedProcess(
808 args=result.args, returncode=0, stdout=result.stdout, stderr=result.stderr
809 )
810 else:
811 error_output = result.stderr or result.stdout or "Unknown error"
812 failure_type, failure_reason_text = classify_failure(error_output, result.returncode)
814 if failure_type == FailureType.TRANSIENT:
815 # Transient failure - log but don't create bug issue
816 logger.warning(f"Transient failure for {info.issue_id}: {failure_reason_text}")
817 logger.warning("Not creating bug issue - this is a temporary error")
818 logger.info("Error output (first 500 chars):")
819 logger.info(error_output[:500])
821 return IssueProcessingResult(
822 success=False,
823 duration=time.time() - issue_start_time,
824 issue_id=info.issue_id,
825 failure_reason=f"Transient: {failure_reason_text}",
826 corrections=corrections,
827 )
829 # Real failure - create issue as before
830 logger.error(f"Implementation failed for {info.issue_id}")
832 failure_reason = ""
833 if not dry_run:
834 # Create new issue for the failure
835 new_issue = create_issue_from_failure(
836 error_output,
837 info,
838 config,
839 logger,
840 event_bus=event_bus,
841 )
842 failure_reason = str(new_issue) if new_issue else error_output
843 else:
844 logger.info("Would create new bug issue for this failure")
846 return IssueProcessingResult(
847 success=False,
848 duration=time.time() - issue_start_time,
849 issue_id=info.issue_id,
850 failure_reason=failure_reason,
851 corrections=corrections,
852 )
854 # Phase 3: Verify completion
855 logger.info(f"Phase 3: Verifying {info.issue_id} completion...")
856 verified = False
857 with timed_phase(logger, "Phase 3 (verify)") as phase3_timing:
858 if not dry_run:
859 verified = verify_issue_completed(info, config, logger)
861 # Fallback: Only complete lifecycle if:
862 # 1. Command returned success (returncode 0)
863 # 2. File wasn't moved to completed
864 # 3. There's EVIDENCE of actual work being done (code changes)
865 if not verified and result.returncode == 0:
866 # Check if a plan was created awaiting approval
867 plan_path = detect_plan_creation(result.stdout, info.issue_id)
868 if plan_path is not None:
869 logger.info(
870 f"Plan created at {plan_path}, awaiting approval - "
871 "issue will remain incomplete until plan is approved and implemented"
872 )
873 return IssueProcessingResult(
874 success=False,
875 duration=time.time() - issue_start_time,
876 issue_id=info.issue_id,
877 plan_created=True,
878 plan_path=str(plan_path),
879 failure_reason="", # Not a failure - plan awaiting approval
880 corrections=corrections,
881 )
883 logger.info(
884 "Command returned success but issue not moved - "
885 "checking for evidence of work..."
886 )
888 # Check issue file content for implementation markers
889 if check_content_markers(info.path):
890 logger.info(
891 "Implementation markers found in issue file - completing lifecycle..."
892 )
893 verified = complete_issue_lifecycle(info, config, logger, event_bus=event_bus)
894 if verified:
895 logger.success(f"Content marker completion succeeded for {info.issue_id}")
896 else:
897 logger.warning(f"Content marker completion failed for {info.issue_id}")
898 else:
899 # CRITICAL: Verify actual implementation work was done
900 work_done = verify_work_was_done(logger, baseline_sha=_baseline_sha)
901 if work_done:
902 logger.info("Evidence of code changes found - completing lifecycle...")
903 verified = complete_issue_lifecycle(
904 info, config, logger, event_bus=event_bus
905 )
906 if verified:
907 logger.success(f"Fallback completion succeeded for {info.issue_id}")
908 else:
909 logger.warning(f"Fallback completion failed for {info.issue_id}")
910 else:
911 # NO work was done - do NOT mark as completed
912 logger.error(
913 f"REFUSING to mark {info.issue_id} as completed: "
914 "no code changes detected despite returncode 0"
915 )
916 logger.error(
917 "This likely indicates the command was not executed "
918 "properly. Check command invocation and Claude CLI "
919 "output."
920 )
921 verified = False
922 else:
923 logger.info("Would verify issue moved to completed")
924 verified = True # In dry run, assume success
925 issue_timing["verify"] = phase3_timing.get("elapsed", 0.0)
927 # Record timing
928 total_issue_time = time.time() - issue_start_time
929 issue_timing["total"] = total_issue_time
930 logger.timing(f"Total processing time: {format_duration(total_issue_time)}")
932 if verified:
933 logger.success(f"Completed: {info.issue_id}")
934 else:
935 logger.warning(f"Issue {info.issue_id} was attempted but verification failed")
936 logger.info("This issue will be skipped on future runs (check logs above for details)")
938 return IssueProcessingResult(
939 success=verified,
940 duration=total_issue_time,
941 issue_id=info.issue_id,
942 corrections=corrections,
943 )
946class AutoManager:
947 """Automated issue manager for sequential processing.
949 Processes issues in priority order using Claude CLI commands,
950 with state persistence for resume capability.
951 """
953 def __init__(
954 self,
955 config: BRConfig,
956 dry_run: bool = False,
957 max_issues: int = 0,
958 resume: bool = False,
959 category: str | None = None,
960 only_ids: list[str] | set[str] | None = None,
961 skip_ids: set[str] | None = None,
962 type_prefixes: set[str] | None = None,
963 priority_filter: set[str] | None = None,
964 label_filter: set[str] | None = None,
965 verbose: bool = True,
966 preview_full: bool = False,
967 db_path: Path | None = None,
968 ) -> None:
969 """Initialize the auto manager.
971 Args:
972 config: Project configuration
973 dry_run: If True, only preview what would be done
974 max_issues: Maximum issues to process (0 = unlimited)
975 resume: Whether to resume from previous state
976 category: Optional category to filter (e.g., "bugs")
977 only_ids: If provided, only process these issue IDs. When a list,
978 issues are processed in list order (input sequence preserved).
979 skip_ids: Issue IDs to skip (in addition to attempted issues)
980 type_prefixes: If provided, only process issues with these type prefixes
981 priority_filter: If provided, only process issues with these priority levels
982 label_filter: If provided, only process issues that have at least one of these labels
983 verbose: Whether to output progress messages
984 preview_full: If True, show full command content without truncation (--verbose flag).
985 """
986 self.config = config
987 self.dry_run = dry_run
988 self.max_issues = max_issues
989 self.resume = resume
990 self.category = category
991 self.only_ids = only_ids
992 self.skip_ids = skip_ids or set()
993 self.type_prefixes = type_prefixes
994 self.priority_filter = priority_filter
995 self.label_filter = label_filter
996 self._preview_full = preview_full
998 from little_loops.cli.output import use_color_enabled
1000 self.logger = Logger(verbose=verbose, use_color=use_color_enabled())
1001 self.event_bus = EventBus()
1002 self.event_bus.add_transport(SQLiteTransport(db_path or DEFAULT_DB_PATH))
1003 self.state_manager = StateManager(
1004 config.get_state_file(), self.logger, event_bus=self.event_bus
1005 )
1006 self.parser = IssueParser(config)
1007 self._detected_model: list[str] = []
1009 # Build dependency graph for dependency-aware sequencing (ENH-016)
1010 # Note: don't filter by type here — we need all issues for dependency resolution
1011 all_issues = find_issues(self.config, self.category)
1012 all_known_ids: set[str] | None = None
1013 try:
1014 from little_loops.dependency_mapper import gather_all_issue_ids
1016 issues_dir = config.project_root / config.issues.base_dir
1017 all_known_ids = gather_all_issue_ids(issues_dir, config=config)
1018 except Exception:
1019 self.logger.debug("Dependency mapping unavailable — skipping")
1020 self.dep_graph = DependencyGraph.from_issues(all_issues, all_known_ids=all_known_ids)
1022 # Warn about any cycles
1023 if self.dep_graph.has_cycles():
1024 cycles = self.dep_graph.detect_cycles()
1025 for cycle in cycles:
1026 self.logger.warning(f"Dependency cycle detected: {' -> '.join(cycle)}")
1028 self.processed_count = 0
1029 self._shutdown_requested = False
1031 signal.signal(signal.SIGINT, self._signal_handler)
1032 signal.signal(signal.SIGTERM, self._signal_handler)
1034 def _signal_handler(self, signum: int, frame: FrameType | None) -> None:
1035 """Handle shutdown signals gracefully."""
1036 self._shutdown_requested = True
1037 self.logger.warning(f"Received signal {signum}, shutting down gracefully...")
1039 def _get_next_issue(self) -> IssueInfo | None:
1040 """Get next issue respecting dependencies.
1042 Returns the highest priority issue whose blockers have all been
1043 completed. If no ready issues exist but blocked issues remain,
1044 logs warnings about what is blocking progress.
1046 Returns:
1047 Next IssueInfo to process, or None if no ready issues
1048 """
1049 # Get completed issues from state
1050 completed = set(self.state_manager.state.completed_issues)
1052 # Combine skip_ids from state and CLI argument
1053 skip_ids = self.state_manager.state.attempted_issues | self.skip_ids
1055 # Get issues that are ready (blockers satisfied)
1056 ready_issues = self.dep_graph.get_ready_issues(completed)
1058 # Filter by skip_ids, only_ids, type_prefixes, priority_filter, label_filter
1059 candidates = [
1060 i
1061 for i in ready_issues
1062 if i.issue_id not in skip_ids
1063 and (self.only_ids is None or any(_id_matches(i.issue_id, p) for p in self.only_ids))
1064 and (self.type_prefixes is None or i.issue_id.split("-", 1)[0] in self.type_prefixes)
1065 and (self.priority_filter is None or i.priority in self.priority_filter)
1066 and (
1067 self.label_filter is None or any(lb.lower() in self.label_filter for lb in i.labels)
1068 )
1069 ]
1071 if candidates:
1072 # When only_ids is a list, respect input order; otherwise use priority order
1073 only_ids = self.only_ids
1074 if isinstance(only_ids, list):
1075 candidates.sort(
1076 key=lambda x: next(
1077 (i for i, p in enumerate(only_ids) if _id_matches(x.issue_id, p)),
1078 len(only_ids),
1079 )
1080 )
1081 return candidates[0]
1083 # No ready candidates - check if there are blocked issues remaining
1084 all_in_graph = set(self.dep_graph.issues.keys())
1085 remaining = all_in_graph - completed - skip_ids
1086 if self.only_ids is not None:
1087 remaining = {r for r in remaining if any(_id_matches(r, p) for p in self.only_ids)}
1088 if self.type_prefixes is not None:
1089 remaining = {r for r in remaining if r.split("-", 1)[0] in self.type_prefixes}
1090 if self.priority_filter is not None:
1091 remaining = {
1092 r for r in remaining if self.dep_graph.issues[r].priority in self.priority_filter
1093 }
1094 if self.label_filter is not None:
1095 remaining = {
1096 r
1097 for r in remaining
1098 if any(lb.lower() in self.label_filter for lb in self.dep_graph.issues[r].labels)
1099 }
1101 if remaining:
1102 self._log_blocked_issues(remaining, completed)
1104 return None
1106 def _log_blocked_issues(self, remaining: set[str], completed: set[str]) -> None:
1107 """Log information about blocked issues when processing stalls.
1109 Args:
1110 remaining: Set of issue IDs that haven't been processed
1111 completed: Set of completed issue IDs
1112 """
1113 blocked_count = 0
1114 for issue_id in sorted(remaining):
1115 blockers = self.dep_graph.get_blocking_issues(issue_id, completed)
1116 if blockers:
1117 blocked_count += 1
1118 self.logger.info(f" {issue_id} blocked by: {', '.join(sorted(blockers))}")
1120 if blocked_count > 0:
1121 self.logger.warning(f"{blocked_count} issue(s) remain blocked - check dependencies")
1123 def run(self) -> int:
1124 """Run the automation loop.
1126 Returns:
1127 Exit code: 0 = success or empty queue, 1 = all issues gate-blocked when --only used
1128 """
1129 run_start_time = time.time()
1130 self.logger.info("Starting automated issue management...")
1132 if self.dry_run:
1133 self.logger.info("DRY RUN MODE - No actual changes will be made")
1135 if not self.dry_run:
1136 has_changes = check_git_status(self.logger)
1137 if has_changes:
1138 self.logger.warning("Proceeding anyway...")
1140 # Load or initialize state
1141 if self.resume:
1142 state = self.state_manager.load()
1143 if state:
1144 self.logger.info(f"Resuming from: {state.current_issue}")
1145 self.processed_count = len(state.completed_issues)
1146 else:
1147 # Fresh start
1148 self.state_manager._state = ProcessingState(timestamp=_iso_now())
1150 attempted_count = 0
1151 try:
1152 while not self._shutdown_requested:
1153 if self.max_issues > 0 and self.processed_count >= self.max_issues:
1154 self.logger.info(f"Reached max issues limit: {self.max_issues}")
1155 break
1157 info = self._get_next_issue()
1158 if not info:
1159 self.logger.success("No more issues to process!")
1160 break
1162 attempted_count += 1
1163 success = self._process_issue(info)
1164 if success:
1165 self.processed_count += 1
1167 except Exception as e:
1168 self.logger.error(f"Fatal error: {e}")
1169 return 1
1171 finally:
1172 if not self._shutdown_requested:
1173 self.state_manager.cleanup()
1174 self.event_bus.close_transports()
1176 self._log_timing_summary(run_start_time)
1177 self.logger.success(f"Processed {self.processed_count} issue(s)")
1178 if self.only_ids and attempted_count > 0 and self.processed_count == 0:
1179 return 1
1180 return 0
1182 def _log_timing_summary(self, run_start_time: float) -> None:
1183 """Log aggregate timing summary."""
1184 total_run_time = time.time() - run_start_time
1186 self.logger.info("")
1187 self.logger.header("PROCESSING SUMMARY")
1188 self.logger.timing(f"Total run time: {format_duration(total_run_time)}")
1189 self.logger.timing(f"Issues processed: {self.processed_count}")
1191 state = self.state_manager.state
1192 if state.timing:
1193 total_times = [t.get("total", 0) for t in state.timing.values()]
1194 if total_times:
1195 avg_time = sum(total_times) / len(total_times)
1196 self.logger.timing(f"Average per issue: {format_duration(avg_time)}")
1198 if state.failed_issues:
1199 self.logger.warning(f"Failed issues: {len(state.failed_issues)}")
1200 for issue_id, reason in state.failed_issues.items():
1201 self.logger.warning(f" - {issue_id}: {reason[:50]}...")
1203 # Log correction statistics for quality tracking
1204 if state.corrections:
1205 total_corrected = len(state.corrections)
1206 total_issues = len(state.completed_issues) + len(state.failed_issues)
1207 correction_rate = (total_corrected / total_issues * 100) if total_issues > 0 else 0
1208 self.logger.info(
1209 f"Auto-corrections: {total_corrected}/{total_issues} ({correction_rate:.1f}%)"
1210 )
1212 # Log most common correction types
1213 from collections import Counter
1215 all_corrections: list[str] = []
1216 for corrections in state.corrections.values():
1217 all_corrections.extend(corrections)
1218 if all_corrections:
1219 common = Counter(all_corrections).most_common(3)
1220 self.logger.info("Most common corrections:")
1221 for correction, count in common:
1222 # Truncate long correction descriptions
1223 display = correction[:60] + "..." if len(correction) > 60 else correction
1224 self.logger.info(f" - {display}: {count}")
1226 def _process_issue(self, info: IssueInfo) -> bool:
1227 """Process a single issue through the workflow.
1229 Delegates to process_issue_inplace() and maps the result back
1230 to state manager calls.
1232 Args:
1233 info: Issue information
1235 Returns:
1236 True if processing succeeded
1237 """
1238 # Pre-processing state updates (before delegating)
1239 self.state_manager.mark_attempted(info.issue_id, save=False)
1240 self.state_manager.update_current(str(info.path), "processing")
1242 on_model: Callable[[str], None] | None = None
1243 if not self._detected_model:
1245 def on_model(m: str) -> None:
1246 self._detected_model.append(m)
1247 self.logger.info(f"model: {m}")
1249 result = process_issue_inplace(
1250 info,
1251 self.config,
1252 self.logger,
1253 self.dry_run,
1254 on_model_detected=on_model,
1255 preview_full=self._preview_full,
1256 event_bus=self.event_bus,
1257 )
1259 # Map result back to state tracking
1260 if result.was_closed:
1261 self.state_manager.mark_completed(info.issue_id)
1262 elif result.was_blocked:
1263 # Blocked issues are skipped, not failed — leave in pending state
1264 self.logger.info(f"{info.issue_id} skipped — blocked by open dependency")
1265 elif result.success:
1266 self.state_manager.mark_completed(info.issue_id, {"total": result.duration})
1267 elif result.plan_created:
1268 # Don't mark as failed if a plan was created (awaiting approval)
1269 self.logger.info(
1270 f"{info.issue_id} has plan at {result.plan_path} - "
1271 "leaving in pending state for manual approval"
1272 )
1273 # Issue remains in pending state (not marked as failed)
1274 elif result.failure_reason:
1275 self.state_manager.mark_failed(info.issue_id, result.failure_reason)
1277 if result.corrections:
1278 self.state_manager.record_corrections(info.issue_id, result.corrections)
1280 return result.success