Coverage for little_loops / issue_manager.py: 10%
522 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-04 12:21 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-04 12:21 -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 _check_issue_already_done(
164 issue_path: Path | None,
165 logger: Logger,
166) -> bool:
167 """Check if the issue file's frontmatter status indicates work is already complete.
169 Used as a pre-continuation guard (BUG-1759): when the inner Claude session hits
170 its context limit and emits CONTEXT_HANDOFF, but the issue was already marked
171 done before the context limit was reached, we should skip the handoff and
172 return success rather than triggering an unnecessary handoff cycle.
174 Args:
175 issue_path: Path to the issue file, or None if not available.
176 logger: Logger for diagnostic messages.
178 Returns:
179 True if the issue's status is 'done' or 'cancelled'.
180 """
181 if issue_path is None:
182 return False
183 if not issue_path.exists():
184 return False
185 try:
186 from little_loops.frontmatter import parse_frontmatter
188 fm = parse_frontmatter(issue_path.read_text(encoding="utf-8"))
189 return fm.get("status") in ("done", "cancelled")
190 except Exception:
191 return False
194def run_with_continuation(
195 initial_command: str,
196 logger: Logger,
197 timeout: int = 3600,
198 stream_output: bool = True,
199 max_continuations: int = 3,
200 repo_path: Path | None = None,
201 idle_timeout: int = 0,
202 resume_command: str | None = None,
203 on_usage: Callable[[int, int], None] | None = None,
204 preview_full: bool = False,
205 context_limit: int = 200_000,
206 sentinel_threshold: float = 0.60,
207 guillotine_threshold: float = 0.90,
208 issue_path: Path | None = None,
209) -> subprocess.CompletedProcess[str]:
210 """Run a Claude command with automatic continuation on context handoff.
212 Implements Options E, G, and J from BUG-1377:
214 Option G (sentinel write): after each session, if accurate token count from
215 on_usage exceeds sentinel_threshold without a CONTEXT_HANDOFF signal, writes
216 a sentinel file so the next iteration knows to send an explicit handoff instruction.
218 Option E (sentinel read): before starting the next session, reads the sentinel
219 and if present sends a ``--continue`` turn with an explicit "run /ll:handoff now"
220 instruction, triggering the standard CONTEXT_HANDOFF continuation flow.
222 Option J (guillotine): if usage exceeds guillotine_threshold OR stderr contains
223 "Prompt is too long", assembles a transcript-summary prompt and spawns a fresh
224 session (not --resume) starting at 0 tokens.
226 Args:
227 initial_command: Initial command to run
228 logger: Logger for output
229 timeout: Timeout per session in seconds
230 stream_output: Whether to stream output
231 max_continuations: Maximum number of continuation attempts
232 repo_path: Repository root path
233 idle_timeout: Kill process if no output for this many seconds (0 to disable)
234 resume_command: Command to use for continuation rounds instead of appending
235 ``--resume`` to ``initial_command``.
236 on_usage: Optional external usage callback; wrapped internally for tracking.
237 context_limit: Context window size in tokens (default 200K).
238 sentinel_threshold: Write sentinel when usage/context_limit >= this (default 0.60).
239 guillotine_threshold: Trigger J-path when usage/context_limit >= this (default 0.90).
241 Returns:
242 Final CompletedProcess result
243 """
244 all_stdout: list[str] = []
245 all_stderr: list[str] = []
246 current_command = initial_command
247 continuation_count = 0
248 result: subprocess.CompletedProcess[str] = subprocess.CompletedProcess(
249 args=[], returncode=1, stdout="", stderr=""
250 )
252 # Track token usage from on_usage callback (fires on stream-json result event).
253 _last_input: list[int] = [0]
254 _last_output: list[int] = [0]
255 _external_on_usage = on_usage
256 # Flag set when Option J fires; consumed in the NEXT iteration so Option E
257 # knows to consume the sentinel without attempting --continue.
258 _just_ran_fresh_session = False
260 def _tracking_usage(input_tokens: int, output_tokens: int) -> None:
261 _last_input[0] = input_tokens
262 _last_output[0] = output_tokens
263 if _external_on_usage is not None:
264 _external_on_usage(input_tokens, output_tokens)
266 while continuation_count <= max_continuations:
267 this_is_fresh = _just_ran_fresh_session
268 _just_ran_fresh_session = False
269 result = run_claude_command(
270 current_command,
271 logger,
272 timeout=timeout,
273 stream_output=stream_output,
274 idle_timeout=idle_timeout,
275 on_usage=_tracking_usage,
276 preview_full=preview_full,
277 )
279 all_stdout.append(result.stdout)
280 all_stderr.append(result.stderr)
282 # Check for context handoff signal (standard path: Claude emitted the signal)
283 if detect_context_handoff(result.stdout):
284 logger.info("Detected CONTEXT_HANDOFF signal")
286 # Pre-continuation guard: if the issue is already done/cancelled, the work
287 # is complete — return success without signalling handoff so the outer FSM
288 # doesn't waste a handoff cycle on finished work (BUG-1759 Incident 2).
289 already_done = _check_issue_already_done(issue_path, logger)
290 if already_done:
291 logger.info("Issue already done/cancelled; skipping handoff and returning success")
292 result = subprocess.CompletedProcess(
293 args=result.args,
294 returncode=0,
295 stdout=result.stdout,
296 stderr=result.stderr,
297 )
298 break
300 # Forward CONTEXT_HANDOFF signal to ll-auto's stdout so the outer FSM's
301 # signal_detector can detect it via the existing HANDOFF_SIGNAL pattern.
302 # The signal was already streamed to stdout via stream_callback; this
303 # explicit write ensures it lands even when stream_output is False.
304 handoff_message = "CONTEXT_HANDOFF: Ready for fresh session"
305 print(handoff_message)
306 logger.info("Forwarded handoff signal to stdout; exiting cleanly")
308 result = subprocess.CompletedProcess(
309 args=result.args,
310 returncode=0,
311 stdout=result.stdout + "\n" + handoff_message,
312 stderr=result.stderr,
313 )
314 break
316 total_tokens = _last_input[0] + _last_output[0]
317 usage_ratio = total_tokens / context_limit if context_limit > 0 else 0.0
318 prompt_too_long = "prompt is too long" in (result.stderr or "").lower()
320 # Option J: guillotine — context overflow with no handoff signal.
321 # Assemble transcript-summary prompt and start a FRESH session (not --resume).
322 if (
323 prompt_too_long or usage_ratio >= guillotine_threshold
324 ) and continuation_count < max_continuations:
325 trigger_reason = "Prompt is too long" if prompt_too_long else f"usage {usage_ratio:.0%}"
326 logger.warning(f"Option J triggered ({trigger_reason}): spawning fresh session")
327 try:
328 guillotine_cmd = assemble_guillotine_prompt(
329 original_command=initial_command,
330 captured_stdout="\n---CONTINUATION---\n".join(all_stdout),
331 token_stats={
332 "input_tokens": _last_input[0],
333 "output_tokens": _last_output[0],
334 "context_limit": context_limit,
335 "trigger_reason": trigger_reason,
336 },
337 )
338 except Exception as exc:
339 logger.warning(f"Failed to assemble guillotine prompt ({exc}), using bare restart")
340 guillotine_cmd = initial_command
341 continuation_count += 1
342 current_command = guillotine_cmd
343 # Reset per-round usage tracking for the fresh session
344 _last_input[0] = 0
345 _last_output[0] = 0
346 _just_ran_fresh_session = True
347 continue
349 # Option E: read sentinel from a PREVIOUS session (written by the Stop hook or by
350 # G-path in the preceding iteration). Must run BEFORE G writes the current-session
351 # sentinel so we don't immediately consume what we just wrote.
352 sentinel_data = read_sentinel(repo_path)
353 if sentinel_data is not None and this_is_fresh:
354 # The sentinel was written by the guillotine fresh session that just finished.
355 # The work is already done; do not attempt --continue.
356 logger.info(
357 "Fresh session wrote sentinel; consumed without --continue (work already done)"
358 )
359 elif sentinel_data is not None and continuation_count < max_continuations:
360 usage_pct = sentinel_data.get("usage_percent", int(usage_ratio * 100))
361 logger.info(
362 f"Sentinel detected ({usage_pct}% context used): "
363 "sending explicit handoff instruction via --continue"
364 )
365 continuation_count += 1
366 # Resume the existing session with an explicit handoff instruction.
367 # Claude receives this as a new user turn in the active session context.
368 explicit_handoff_instruction = (
369 f"Context limit is approaching ({usage_pct}% of the context window is used). "
370 "Please run /ll:handoff RIGHT NOW to save your progress to "
371 ".ll/ll-continue-prompt.md, then output "
372 '"CONTEXT_HANDOFF: Ready for fresh session" to signal continuation.'
373 )
374 current_command = explicit_handoff_instruction
375 # Reset tracking for the handoff turn
376 _last_input[0] = 0
377 _last_output[0] = 0
378 # Use --continue CLI flag so this turn continues the existing session
379 result = run_claude_command(
380 current_command,
381 logger,
382 timeout=timeout,
383 stream_output=stream_output,
384 idle_timeout=idle_timeout,
385 on_usage=_tracking_usage,
386 preview_full=preview_full,
387 resume_session=True,
388 )
389 all_stdout.append(result.stdout)
390 all_stderr.append(result.stderr)
392 # After explicit instruction, check for handoff signal
393 if detect_context_handoff(result.stdout):
394 logger.info("CONTEXT_HANDOFF detected after explicit handoff instruction")
395 prompt_content = read_continuation_prompt(repo_path)
396 if prompt_content and continuation_count < max_continuations:
397 continuation_count += 1
398 logger.info(f"Starting continuation session #{continuation_count}")
399 _base = resume_command if resume_command is not None else initial_command
400 current_command = f"{_base} --resume"
401 _last_input[0] = 0
402 _last_output[0] = 0
403 continue
404 break
406 # Option G (Python layer): write sentinel if usage is high for the NEXT session.
407 # Placed after E-path check so we don't immediately consume our own write.
408 if total_tokens > 0 and usage_ratio >= sentinel_threshold:
409 logger.info(f"Writing context-handoff sentinel ({usage_ratio:.0%} context used)")
410 write_sentinel(repo_path, token_count=total_tokens, context_limit=context_limit)
412 # No handoff signal, no prior-session sentinel, no overflow — done
413 break
415 return subprocess.CompletedProcess(
416 args=result.args,
417 returncode=result.returncode,
418 stdout="\n---CONTINUATION---\n".join(all_stdout),
419 stderr="\n---CONTINUATION---\n".join(all_stderr),
420 )
423def detect_plan_creation(output: str, issue_id: str) -> Path | None:
424 """Detect if manage-issue created a plan file awaiting approval.
426 Checks for plan file creation in thoughts/shared/plans/ matching the issue ID.
427 This happens when manage-issue creates a plan but waits for user approval.
429 Args:
430 output: Command stdout (unused, for future pattern matching)
431 issue_id: Issue ID (e.g., "BUG-280")
433 Returns:
434 Path to plan file if created, None otherwise
435 """
436 plans_dir = Path("thoughts/shared/plans")
437 if not plans_dir.exists():
438 return None
440 # Find plan files matching this issue ID (format: YYYY-MM-DD-ISSUE-ID-*.md)
441 # Use glob pattern with issue_id
442 pattern = f"*-{issue_id}-*.md"
443 matching_plans = list(plans_dir.glob(pattern))
445 if not matching_plans:
446 return None
448 # Return the most recently modified plan file
449 # (in case multiple exist, take the latest)
450 latest_plan = max(matching_plans, key=lambda p: p.stat().st_mtime)
451 return latest_plan
454def check_content_markers(issue_path: Path) -> bool:
455 """Check if issue file content contains implementation markers.
457 Looks for indicators that an implementation was completed, such as
458 Resolution sections or status markers added by manage-issue.
460 Args:
461 issue_path: Path to the issue file
463 Returns:
464 True if implementation markers found
465 """
466 try:
467 content = issue_path.read_text(encoding="utf-8")
468 except (OSError, UnicodeDecodeError):
469 return False
471 markers = [
472 "## Resolution",
473 "Status: Implemented",
474 "Status: Completed",
475 "**Completed**:",
476 ]
477 return any(marker in content for marker in markers)
480@dataclass
481class IssueProcessingResult:
482 """Result of processing a single issue in-place."""
484 success: bool
485 duration: float
486 issue_id: str
487 was_closed: bool = False
488 was_blocked: bool = False
489 failure_reason: str = ""
490 corrections: list[str] = field(default_factory=list)
491 plan_created: bool = False
492 plan_path: str = ""
495def process_issue_inplace(
496 info: IssueInfo,
497 config: BRConfig,
498 logger: Logger,
499 dry_run: bool = False,
500 on_model_detected: Callable[[str], None] | None = None,
501 on_usage: Callable[[int, int], None] | None = None,
502 preview_full: bool = False,
503 event_bus: EventBus | None = None,
504) -> IssueProcessingResult:
505 """Process a single issue through the 3-phase workflow in the current working tree.
507 This is the core processing logic extracted from AutoManager._process_issue(),
508 suitable for use outside of AutoManager (e.g., single-issue sprint waves).
510 Args:
511 info: Issue information
512 config: Project configuration
513 logger: Logger for output
514 dry_run: If True, only preview what would be done
515 on_model_detected: Optional callback invoked with the model name from the
516 first stream-json system/init event during this issue's processing.
517 on_usage: Optional callback invoked with (input_tokens, output_tokens) from
518 each stream-json result event. Passed through to all run_claude_command calls.
520 Returns:
521 IssueProcessingResult with outcome details
522 """
523 issue_start_time = time.time()
524 corrections: list[str] = []
526 logger.header(f"Processing: {info.issue_id} - {info.title}")
528 issue_timing: dict[str, float] = {}
530 # Track whether we used fallback path resolution for ready-issue.
531 validated_via_fallback = False
533 # Build on_usage closure that writes result_token_count to the context state file.
534 # Mirrors the on_model_detected closure pattern in AutoManager._process_issue.
535 _state_file = (config.repo_path or Path.cwd()) / ".ll" / "ll-context-state.json"
536 _external_on_usage = on_usage
538 def _on_usage_writer(input_tokens: int, output_tokens: int) -> None:
539 try:
540 state = json.loads(_state_file.read_text()) if _state_file.exists() else {}
541 state["result_token_count"] = input_tokens + output_tokens
542 _state_file.write_text(json.dumps(state))
543 except Exception:
544 pass # never block execution on state write failures
545 if _external_on_usage is not None:
546 _external_on_usage(input_tokens, output_tokens)
548 # Phase 1: Ready/verify the issue
549 logger.info(f"Phase 1: Verifying issue {info.issue_id}...")
550 with timed_phase(logger, "Phase 1 (ready-issue)") as phase1_timing:
551 if not dry_run:
552 _ready_slash = f"/ll:ready-issue {info.issue_id}"
553 _ready_cmd = expand_skill("ready-issue", [info.issue_id], config) or _ready_slash
554 result = run_claude_command(
555 _ready_cmd,
556 logger,
557 timeout=config.automation.timeout_seconds,
558 stream_output=config.automation.stream_output,
559 idle_timeout=config.automation.idle_timeout_seconds,
560 on_model_detected=on_model_detected,
561 preview_full=preview_full,
562 )
563 if result.returncode != 0:
564 logger.warning("ready-issue command failed to execute, continuing anyway...")
565 else:
566 # Parse the verdict from the output
567 parsed = parse_ready_issue_output(result.stdout)
568 logger.info(f"ready-issue verdict: {parsed['verdict']}")
570 # Validate that ready-issue analyzed the expected file
571 validated_path = parsed.get("validated_file_path")
572 if validated_path:
573 # Normalize paths for comparison (resolve to absolute)
574 expected_path = str(info.path.resolve())
575 # Handle both absolute and relative paths from ready_issue
576 validated_resolved = Path(validated_path).resolve()
577 if str(validated_resolved) != expected_path:
578 # Check if this is a legitimate rename (new file exists,
579 # old file doesn't) vs a mismatch error
580 old_file_exists = info.path.exists()
581 new_file_exists = validated_resolved.exists()
583 if new_file_exists and not old_file_exists:
584 # ready-issue renamed the file - update tracking
585 logger.info(
586 f"Issue file renamed: '{info.path.name}' -> "
587 f"'{validated_resolved.name}'"
588 )
589 info.path = validated_resolved
590 else:
591 # Genuine mismatch - attempt fallback with explicit path
592 logger.warning(
593 f"Path mismatch: ready-issue validated "
594 f"'{validated_path}' but expected '{info.path}'"
595 )
596 logger.info(
597 "Attempting fallback: retrying ready-issue "
598 "with explicit file path..."
599 )
601 # Compute relative path for the command
602 relative_path = _compute_relative_path(info.path)
604 # Retry with explicit path
605 _retry_slash = f"/ll:ready-issue {relative_path}"
606 _retry_cmd = (
607 expand_skill("ready-issue", [str(relative_path)], config)
608 or _retry_slash
609 )
610 retry_result = run_claude_command(
611 _retry_cmd,
612 logger,
613 timeout=config.automation.timeout_seconds,
614 stream_output=config.automation.stream_output,
615 idle_timeout=config.automation.idle_timeout_seconds,
616 on_model_detected=on_model_detected,
617 preview_full=preview_full,
618 )
620 if retry_result.returncode != 0:
621 logger.error(f"Fallback ready-issue failed for {info.issue_id}")
622 return IssueProcessingResult(
623 success=False,
624 duration=time.time() - issue_start_time,
625 issue_id=info.issue_id,
626 failure_reason="Fallback failed after path mismatch",
627 )
629 # Re-parse and validate retry output
630 retry_parsed = parse_ready_issue_output(retry_result.stdout)
631 retry_validated_path = retry_parsed.get("validated_file_path")
633 if retry_validated_path:
634 retry_resolved = Path(retry_validated_path).resolve()
635 if str(retry_resolved) != str(info.path.resolve()):
636 logger.error(
637 f"Fallback still mismatched: "
638 f"got '{retry_validated_path}', "
639 f"expected '{info.path}'"
640 )
641 return IssueProcessingResult(
642 success=False,
643 duration=time.time() - issue_start_time,
644 issue_id=info.issue_id,
645 failure_reason="Path mismatch persisted after fallback",
646 )
648 # Fallback succeeded - use retry result
649 logger.info("Fallback succeeded: validated correct file")
650 parsed = retry_parsed
651 validated_via_fallback = True
653 # Log and store any corrections made
654 if parsed.get("was_corrected"):
655 logger.info(f"Issue {info.issue_id} was auto-corrected")
656 phase_corrections = parsed.get("corrections", [])
657 for correction in phase_corrections:
658 logger.info(f" Correction: {correction}")
659 if phase_corrections:
660 corrections.extend(phase_corrections)
662 # Log any concerns found
663 if parsed["concerns"]:
664 for concern in parsed["concerns"]:
665 logger.warning(f" Concern: {concern}")
667 # Handle CLOSE verdict - issue should not be implemented
668 if parsed.get("should_close"):
669 close_reason = parsed.get("close_reason", "unknown")
670 logger.info(f"Issue {info.issue_id} should be closed (reason: {close_reason})")
672 # CRITICAL: Skip file operations for invalid references
673 if close_reason == "invalid_ref":
674 logger.warning(
675 f"Skipping {info.issue_id}: invalid reference - "
676 "no matching issue file exists"
677 )
678 return IssueProcessingResult(
679 success=False,
680 duration=time.time() - issue_start_time,
681 issue_id=info.issue_id,
682 failure_reason=f"Invalid reference: {close_reason}",
683 corrections=corrections,
684 )
686 # Also require validated_file_path to match before closing
687 close_validated_path = parsed.get("validated_file_path")
688 if not close_validated_path:
689 logger.warning(
690 f"Skipping close for {info.issue_id}: "
691 "ready-issue did not return validated file path"
692 )
693 return IssueProcessingResult(
694 success=False,
695 duration=time.time() - issue_start_time,
696 issue_id=info.issue_id,
697 failure_reason="CLOSE without validated file path",
698 corrections=corrections,
699 )
701 if close_issue(
702 info,
703 config,
704 logger,
705 close_reason,
706 parsed.get("close_status"),
707 event_bus=event_bus,
708 ):
709 return IssueProcessingResult(
710 success=True,
711 duration=time.time() - issue_start_time,
712 issue_id=info.issue_id,
713 was_closed=True,
714 corrections=corrections,
715 )
716 else:
717 return IssueProcessingResult(
718 success=False,
719 duration=time.time() - issue_start_time,
720 issue_id=info.issue_id,
721 failure_reason=f"CLOSE failed: {parsed.get('close_status', 'unknown')}",
722 corrections=corrections,
723 )
725 # Handle BLOCKED verdict - issue has open dependencies
726 if parsed.get("is_blocked"):
727 logger.warning(
728 f"Issue {info.issue_id} blocked — open dependency detected by ready-issue"
729 )
730 return IssueProcessingResult(
731 success=False,
732 was_blocked=True,
733 duration=time.time() - issue_start_time,
734 issue_id=info.issue_id,
735 failure_reason=f"BLOCKED: {parsed.get('concerns', [])}",
736 corrections=corrections,
737 )
739 # Check if issue is NOT READY (and not closeable)
740 if not parsed["is_ready"]:
741 logger.error(
742 f"Issue {info.issue_id} is NOT READY for implementation "
743 f"(verdict: {parsed['verdict']})"
744 )
745 return IssueProcessingResult(
746 success=False,
747 duration=time.time() - issue_start_time,
748 issue_id=info.issue_id,
749 failure_reason=(
750 f"NOT READY: {parsed['verdict']} - {len(parsed['concerns'])} concern(s)"
751 ),
752 corrections=corrections,
753 )
755 # Log if proceeding with corrected issue
756 if parsed.get("was_corrected"):
757 logger.success(f"Issue {info.issue_id} corrected and ready for implementation")
758 else:
759 logger.info(f"Would run: /ll:ready-issue {info.issue_id}")
760 issue_timing["ready"] = phase1_timing.get("elapsed", 0.0)
762 # Decision gate: invoke decide-issue when the issue requires a decision
763 if info.decision_needed is True and not dry_run:
764 logger.info(
765 f"Decision gate: {info.issue_id} has decision_needed=True, invoking decide-issue..."
766 )
767 _decide_slash = f"/ll:decide-issue {info.issue_id} --auto"
768 _decide_cmd = (
769 expand_skill("decide-issue", [info.issue_id, "--auto"], config) or _decide_slash
770 )
771 decide_result = run_claude_command(
772 _decide_cmd,
773 logger,
774 timeout=config.automation.timeout_seconds,
775 stream_output=config.automation.stream_output,
776 idle_timeout=config.automation.idle_timeout_seconds,
777 on_model_detected=on_model_detected,
778 preview_full=preview_full,
779 )
780 if decide_result.returncode != 0:
781 logger.warning("decide-issue command failed, continuing to implementation anyway...")
783 # Phase 2: Implement the issue (with automatic continuation on context handoff)
784 action = config.get_category_action(info.issue_type)
785 logger.info(f"Phase 2: Implementing {info.issue_id}...")
786 _baseline_sha_result = subprocess.run(
787 ["git", "rev-parse", "HEAD"], capture_output=True, text=True
788 )
789 _baseline_sha: str | None = (
790 _baseline_sha_result.stdout.strip() if _baseline_sha_result.returncode == 0 else None
791 )
792 with timed_phase(logger, "Phase 2 (implement)") as phase2_timing:
793 if not dry_run:
794 # Build manage-issue command
795 type_name = info.issue_type.rstrip("s") # bugs -> bug
797 # Use relative path if fallback was used, otherwise use issue_id
798 if validated_via_fallback:
799 issue_arg = _compute_relative_path(info.path)
800 else:
801 issue_arg = info.issue_id
803 # Use run_with_continuation to handle context exhaustion.
804 # Pre-expand the skill so the subprocess needs no ToolSearch.
805 # Pass the short slash command as resume_command so continuation
806 # rounds stay compact (not hundreds of lines).
807 _slash_cmd = f"/ll:manage-issue {type_name} {action} {issue_arg}"
808 _initial_cmd = (
809 expand_skill("manage-issue", [type_name, action, issue_arg], config) or _slash_cmd
810 )
811 result = run_with_continuation(
812 _initial_cmd,
813 logger,
814 timeout=config.automation.timeout_seconds,
815 stream_output=config.automation.stream_output,
816 max_continuations=config.automation.max_continuations,
817 repo_path=config.repo_path,
818 idle_timeout=config.automation.idle_timeout_seconds,
819 resume_command=_slash_cmd,
820 on_usage=_on_usage_writer,
821 preview_full=preview_full,
822 issue_path=info.path,
823 )
824 else:
825 logger.info(f"Would run: /ll:manage-issue {info.issue_type} {action} {info.issue_id}")
826 result = subprocess.CompletedProcess(args=[], returncode=0)
827 issue_timing["implement"] = phase2_timing.get("elapsed", 0.0)
829 # Handle implementation failure
830 if result.returncode != 0:
831 # Guard: if the issue's frontmatter already shows status: done by the
832 # subprocess (e.g., a guillotine fresh session that finished and then
833 # triggered a spurious Option E --continue failure), treat as success
834 # so Phase 3 runs.
835 already_done = False
836 if info.path.exists():
837 try:
838 from little_loops.frontmatter import parse_frontmatter
840 _fm = parse_frontmatter(info.path.read_text(encoding="utf-8"))
841 already_done = _fm.get("status") in ("done", "completed", "cancelled")
842 except Exception:
843 already_done = False
844 if already_done:
845 logger.warning(
846 f"Phase 2 exited non-zero but {info.issue_id} status is done/cancelled; "
847 "treating as success (continuation artefact)"
848 )
849 result = subprocess.CompletedProcess(
850 args=result.args, returncode=0, stdout=result.stdout, stderr=result.stderr
851 )
852 else:
853 error_output = result.stderr or result.stdout or "Unknown error"
854 failure_type, failure_reason_text = classify_failure(error_output, result.returncode)
856 if failure_type == FailureType.TRANSIENT:
857 # Transient failure - log but don't create bug issue
858 logger.warning(f"Transient failure for {info.issue_id}: {failure_reason_text}")
859 logger.warning("Not creating bug issue - this is a temporary error")
860 logger.info("Error output (first 500 chars):")
861 logger.info(error_output[:500])
863 return IssueProcessingResult(
864 success=False,
865 duration=time.time() - issue_start_time,
866 issue_id=info.issue_id,
867 failure_reason=f"Transient: {failure_reason_text}",
868 corrections=corrections,
869 )
871 # Real failure - create issue as before
872 logger.error(f"Implementation failed for {info.issue_id}")
874 failure_reason = ""
875 if not dry_run:
876 # Create new issue for the failure
877 new_issue = create_issue_from_failure(
878 error_output,
879 info,
880 config,
881 logger,
882 event_bus=event_bus,
883 )
884 failure_reason = str(new_issue) if new_issue else error_output
885 else:
886 logger.info("Would create new bug issue for this failure")
888 return IssueProcessingResult(
889 success=False,
890 duration=time.time() - issue_start_time,
891 issue_id=info.issue_id,
892 failure_reason=failure_reason,
893 corrections=corrections,
894 )
896 # Phase 3: Verify completion
897 logger.info(f"Phase 3: Verifying {info.issue_id} completion...")
898 verified = False
899 with timed_phase(logger, "Phase 3 (verify)") as phase3_timing:
900 if not dry_run:
901 verified = verify_issue_completed(info, config, logger)
903 # Fallback: Only complete lifecycle if:
904 # 1. Command returned success (returncode 0)
905 # 2. File wasn't moved to completed
906 # 3. There's EVIDENCE of actual work being done (code changes)
907 if not verified and result.returncode == 0:
908 # Check if a plan was created awaiting approval
909 plan_path = detect_plan_creation(result.stdout, info.issue_id)
910 if plan_path is not None:
911 logger.info(
912 f"Plan created at {plan_path}, awaiting approval - "
913 "issue will remain incomplete until plan is approved and implemented"
914 )
915 return IssueProcessingResult(
916 success=False,
917 duration=time.time() - issue_start_time,
918 issue_id=info.issue_id,
919 plan_created=True,
920 plan_path=str(plan_path),
921 failure_reason="", # Not a failure - plan awaiting approval
922 corrections=corrections,
923 )
925 logger.info(
926 "Command returned success but issue not moved - "
927 "checking for evidence of work..."
928 )
930 # Check issue file content for implementation markers
931 if check_content_markers(info.path):
932 logger.info(
933 "Implementation markers found in issue file - completing lifecycle..."
934 )
935 verified = complete_issue_lifecycle(info, config, logger, event_bus=event_bus)
936 if verified:
937 logger.success(f"Content marker completion succeeded for {info.issue_id}")
938 else:
939 logger.warning(f"Content marker completion failed for {info.issue_id}")
940 else:
941 # CRITICAL: Verify actual implementation work was done
942 work_done = verify_work_was_done(logger, baseline_sha=_baseline_sha)
943 if work_done:
944 logger.info("Evidence of code changes found - completing lifecycle...")
945 verified = complete_issue_lifecycle(
946 info, config, logger, event_bus=event_bus
947 )
948 if verified:
949 logger.success(f"Fallback completion succeeded for {info.issue_id}")
950 else:
951 logger.warning(f"Fallback completion failed for {info.issue_id}")
952 else:
953 # NO work was done - do NOT mark as completed
954 logger.error(
955 f"REFUSING to mark {info.issue_id} as completed: "
956 "no code changes detected despite returncode 0"
957 )
958 logger.error(
959 "This likely indicates the command was not executed "
960 "properly. Check command invocation and Claude CLI "
961 "output."
962 )
963 verified = False
964 else:
965 logger.info("Would verify issue moved to completed")
966 verified = True # In dry run, assume success
967 issue_timing["verify"] = phase3_timing.get("elapsed", 0.0)
969 # Record timing
970 total_issue_time = time.time() - issue_start_time
971 issue_timing["total"] = total_issue_time
972 logger.timing(f"Total processing time: {format_duration(total_issue_time)}")
974 if verified:
975 logger.success(f"Completed: {info.issue_id}")
976 else:
977 logger.warning(f"Issue {info.issue_id} was attempted but verification failed")
978 logger.info("This issue will be skipped on future runs (check logs above for details)")
980 return IssueProcessingResult(
981 success=verified,
982 duration=total_issue_time,
983 issue_id=info.issue_id,
984 corrections=corrections,
985 )
988class AutoManager:
989 """Automated issue manager for sequential processing.
991 Processes issues in priority order using Claude CLI commands,
992 with state persistence for resume capability.
993 """
995 def __init__(
996 self,
997 config: BRConfig,
998 dry_run: bool = False,
999 max_issues: int = 0,
1000 resume: bool = False,
1001 category: str | None = None,
1002 only_ids: list[str] | set[str] | None = None,
1003 skip_ids: set[str] | None = None,
1004 type_prefixes: set[str] | None = None,
1005 priority_filter: set[str] | None = None,
1006 label_filter: set[str] | None = None,
1007 verbose: bool = True,
1008 preview_full: bool = False,
1009 db_path: Path | None = None,
1010 ) -> None:
1011 """Initialize the auto manager.
1013 Args:
1014 config: Project configuration
1015 dry_run: If True, only preview what would be done
1016 max_issues: Maximum issues to process (0 = unlimited)
1017 resume: Whether to resume from previous state
1018 category: Optional category to filter (e.g., "bugs")
1019 only_ids: If provided, only process these issue IDs. When a list,
1020 issues are processed in list order (input sequence preserved).
1021 skip_ids: Issue IDs to skip (in addition to attempted issues)
1022 type_prefixes: If provided, only process issues with these type prefixes
1023 priority_filter: If provided, only process issues with these priority levels
1024 label_filter: If provided, only process issues that have at least one of these labels
1025 verbose: Whether to output progress messages
1026 preview_full: If True, show full command content without truncation (--verbose flag).
1027 """
1028 self.config = config
1029 self.dry_run = dry_run
1030 self.max_issues = max_issues
1031 self.resume = resume
1032 self.category = category
1033 self.only_ids = only_ids
1034 self.skip_ids = skip_ids or set()
1035 self.type_prefixes = type_prefixes
1036 self.priority_filter = priority_filter
1037 self.label_filter = label_filter
1038 self._preview_full = preview_full
1040 from little_loops.cli.output import use_color_enabled
1042 self.logger = Logger(verbose=verbose, use_color=use_color_enabled())
1043 self.event_bus = EventBus()
1044 self.event_bus.add_transport(SQLiteTransport(db_path or DEFAULT_DB_PATH))
1045 self.state_manager = StateManager(
1046 config.get_state_file(), self.logger, event_bus=self.event_bus
1047 )
1048 self.parser = IssueParser(config)
1049 self._detected_model: list[str] = []
1051 # Build dependency graph for dependency-aware sequencing (ENH-016)
1052 # Note: don't filter by type here — we need all issues for dependency resolution
1053 all_issues = find_issues(self.config, self.category)
1054 all_known_ids: set[str] | None = None
1055 try:
1056 from little_loops.dependency_mapper import gather_all_issue_ids
1058 issues_dir = config.project_root / config.issues.base_dir
1059 all_known_ids = gather_all_issue_ids(issues_dir, config=config)
1060 except Exception:
1061 self.logger.debug("Dependency mapping unavailable — skipping")
1062 self.dep_graph = DependencyGraph.from_issues(all_issues, all_known_ids=all_known_ids)
1064 # Warn about any cycles
1065 if self.dep_graph.has_cycles():
1066 cycles = self.dep_graph.detect_cycles()
1067 for cycle in cycles:
1068 self.logger.warning(f"Dependency cycle detected: {' -> '.join(cycle)}")
1070 self.processed_count = 0
1071 self._shutdown_requested = False
1073 signal.signal(signal.SIGINT, self._signal_handler)
1074 signal.signal(signal.SIGTERM, self._signal_handler)
1076 def _signal_handler(self, signum: int, frame: FrameType | None) -> None:
1077 """Handle shutdown signals gracefully."""
1078 self._shutdown_requested = True
1079 self.logger.warning(f"Received signal {signum}, shutting down gracefully...")
1081 def _get_next_issue(self) -> IssueInfo | None:
1082 """Get next issue respecting dependencies.
1084 Returns the highest priority issue whose blockers have all been
1085 completed. If no ready issues exist but blocked issues remain,
1086 logs warnings about what is blocking progress.
1088 Returns:
1089 Next IssueInfo to process, or None if no ready issues
1090 """
1091 # Get completed issues from state
1092 completed = set(self.state_manager.state.completed_issues)
1094 # Combine skip_ids from state and CLI argument
1095 skip_ids = self.state_manager.state.attempted_issues | self.skip_ids
1097 # Get issues that are ready (blockers satisfied)
1098 ready_issues = self.dep_graph.get_ready_issues(completed)
1100 # Filter by skip_ids, only_ids, type_prefixes, priority_filter, label_filter
1101 candidates = [
1102 i
1103 for i in ready_issues
1104 if i.issue_id not in skip_ids
1105 and (self.only_ids is None or any(_id_matches(i.issue_id, p) for p in self.only_ids))
1106 and (self.type_prefixes is None or i.issue_id.split("-", 1)[0] in self.type_prefixes)
1107 and (self.priority_filter is None or i.priority in self.priority_filter)
1108 and (
1109 self.label_filter is None or any(lb.lower() in self.label_filter for lb in i.labels)
1110 )
1111 ]
1113 if candidates:
1114 # When only_ids is a list, respect input order; otherwise use priority order
1115 only_ids = self.only_ids
1116 if isinstance(only_ids, list):
1117 candidates.sort(
1118 key=lambda x: next(
1119 (i for i, p in enumerate(only_ids) if _id_matches(x.issue_id, p)),
1120 len(only_ids),
1121 )
1122 )
1123 return candidates[0]
1125 # No ready candidates - check if there are blocked issues remaining
1126 all_in_graph = set(self.dep_graph.issues.keys())
1127 remaining = all_in_graph - completed - skip_ids
1128 if self.only_ids is not None:
1129 remaining = {r for r in remaining if any(_id_matches(r, p) for p in self.only_ids)}
1130 if self.type_prefixes is not None:
1131 remaining = {r for r in remaining if r.split("-", 1)[0] in self.type_prefixes}
1132 if self.priority_filter is not None:
1133 remaining = {
1134 r for r in remaining if self.dep_graph.issues[r].priority in self.priority_filter
1135 }
1136 if self.label_filter is not None:
1137 remaining = {
1138 r
1139 for r in remaining
1140 if any(lb.lower() in self.label_filter for lb in self.dep_graph.issues[r].labels)
1141 }
1143 if remaining:
1144 self._log_blocked_issues(remaining, completed)
1146 return None
1148 def _log_blocked_issues(self, remaining: set[str], completed: set[str]) -> None:
1149 """Log information about blocked issues when processing stalls.
1151 Args:
1152 remaining: Set of issue IDs that haven't been processed
1153 completed: Set of completed issue IDs
1154 """
1155 blocked_count = 0
1156 for issue_id in sorted(remaining):
1157 blockers = self.dep_graph.get_blocking_issues(issue_id, completed)
1158 if blockers:
1159 blocked_count += 1
1160 self.logger.info(f" {issue_id} blocked by: {', '.join(sorted(blockers))}")
1162 if blocked_count > 0:
1163 self.logger.warning(f"{blocked_count} issue(s) remain blocked - check dependencies")
1165 def run(self) -> int:
1166 """Run the automation loop.
1168 Returns:
1169 Exit code: 0 = success or empty queue, 1 = all issues gate-blocked when --only used
1170 """
1171 run_start_time = time.time()
1172 self.logger.info("Starting automated issue management...")
1174 if self.dry_run:
1175 self.logger.info("DRY RUN MODE - No actual changes will be made")
1177 if not self.dry_run:
1178 has_changes = check_git_status(self.logger)
1179 if has_changes:
1180 self.logger.warning("Proceeding anyway...")
1182 # Load or initialize state
1183 if self.resume:
1184 state = self.state_manager.load()
1185 if state:
1186 self.logger.info(f"Resuming from: {state.current_issue}")
1187 self.processed_count = len(state.completed_issues)
1188 else:
1189 # Fresh start
1190 self.state_manager._state = ProcessingState(timestamp=_iso_now())
1192 attempted_count = 0
1193 try:
1194 while not self._shutdown_requested:
1195 if self.max_issues > 0 and self.processed_count >= self.max_issues:
1196 self.logger.info(f"Reached max issues limit: {self.max_issues}")
1197 break
1199 info = self._get_next_issue()
1200 if not info:
1201 self.logger.success("No more issues to process!")
1202 break
1204 attempted_count += 1
1205 success = self._process_issue(info)
1206 if success:
1207 self.processed_count += 1
1209 except Exception as e:
1210 self.logger.error(f"Fatal error: {e}")
1211 return 1
1213 finally:
1214 if not self._shutdown_requested:
1215 self.state_manager.cleanup()
1216 self.event_bus.close_transports()
1218 self._log_timing_summary(run_start_time)
1219 self.logger.success(f"Processed {self.processed_count} issue(s)")
1220 if self.only_ids and attempted_count > 0 and self.processed_count == 0:
1221 return 1
1222 return 0
1224 def _log_timing_summary(self, run_start_time: float) -> None:
1225 """Log aggregate timing summary."""
1226 total_run_time = time.time() - run_start_time
1228 self.logger.info("")
1229 self.logger.header("PROCESSING SUMMARY")
1230 self.logger.timing(f"Total run time: {format_duration(total_run_time)}")
1231 self.logger.timing(f"Issues processed: {self.processed_count}")
1233 state = self.state_manager.state
1234 if state.timing:
1235 total_times = [t.get("total", 0) for t in state.timing.values()]
1236 if total_times:
1237 avg_time = sum(total_times) / len(total_times)
1238 self.logger.timing(f"Average per issue: {format_duration(avg_time)}")
1240 if state.failed_issues:
1241 self.logger.warning(f"Failed issues: {len(state.failed_issues)}")
1242 for issue_id, reason in state.failed_issues.items():
1243 self.logger.warning(f" - {issue_id}: {reason[:50]}...")
1245 # Log correction statistics for quality tracking
1246 if state.corrections:
1247 total_corrected = len(state.corrections)
1248 total_issues = len(state.completed_issues) + len(state.failed_issues)
1249 correction_rate = (total_corrected / total_issues * 100) if total_issues > 0 else 0
1250 self.logger.info(
1251 f"Auto-corrections: {total_corrected}/{total_issues} ({correction_rate:.1f}%)"
1252 )
1254 # Log most common correction types
1255 from collections import Counter
1257 all_corrections: list[str] = []
1258 for corrections in state.corrections.values():
1259 all_corrections.extend(corrections)
1260 if all_corrections:
1261 common = Counter(all_corrections).most_common(3)
1262 self.logger.info("Most common corrections:")
1263 for correction, count in common:
1264 # Truncate long correction descriptions
1265 display = correction[:60] + "..." if len(correction) > 60 else correction
1266 self.logger.info(f" - {display}: {count}")
1268 def _process_issue(self, info: IssueInfo) -> bool:
1269 """Process a single issue through the workflow.
1271 Delegates to process_issue_inplace() and maps the result back
1272 to state manager calls.
1274 Args:
1275 info: Issue information
1277 Returns:
1278 True if processing succeeded
1279 """
1280 # Pre-processing state updates (before delegating)
1281 self.state_manager.mark_attempted(info.issue_id, save=False)
1282 self.state_manager.update_current(str(info.path), "processing")
1284 on_model: Callable[[str], None] | None = None
1285 if not self._detected_model:
1287 def on_model(m: str) -> None:
1288 self._detected_model.append(m)
1289 self.logger.info(f"model: {m}")
1291 result = process_issue_inplace(
1292 info,
1293 self.config,
1294 self.logger,
1295 self.dry_run,
1296 on_model_detected=on_model,
1297 preview_full=self._preview_full,
1298 event_bus=self.event_bus,
1299 )
1301 # Map result back to state tracking
1302 if result.was_closed:
1303 self.state_manager.mark_completed(info.issue_id)
1304 elif result.was_blocked:
1305 # Blocked issues are skipped, not failed — leave in pending state
1306 self.logger.info(f"{info.issue_id} skipped — blocked by open dependency")
1307 elif result.success:
1308 self.state_manager.mark_completed(info.issue_id, {"total": result.duration})
1309 elif result.plan_created:
1310 # Don't mark as failed if a plan was created (awaiting approval)
1311 self.logger.info(
1312 f"{info.issue_id} has plan at {result.plan_path} - "
1313 "leaving in pending state for manual approval"
1314 )
1315 # Issue remains in pending state (not marked as failed)
1316 elif result.failure_reason:
1317 self.state_manager.mark_failed(info.issue_id, result.failure_reason)
1319 if result.corrections:
1320 self.state_manager.record_corrections(info.issue_id, result.corrections)
1322 return result.success