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