Coverage for little_loops / issue_manager.py: 10%
563 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-29 00:54 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-29 00:54 -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 re
11import signal
12import subprocess
13import sys
14import time
15from collections.abc import Callable, Generator
16from contextlib import contextmanager
17from dataclasses import dataclass, field
18from pathlib import Path
19from types import FrameType
20from typing import TYPE_CHECKING
22if TYPE_CHECKING:
23 from little_loops.parallel.types import SprintWorkerContext
25from little_loops.cli_args import _id_matches
26from little_loops.config import BRConfig
27from little_loops.context_window import context_window_for
28from little_loops.dependency_graph import DependencyGraph
29from little_loops.events import EventBus
30from little_loops.git_operations import check_git_status, verify_work_was_done
31from little_loops.issue_lifecycle import (
32 FailureType,
33 classify_failure,
34 close_issue,
35 complete_issue_lifecycle,
36 create_issue_from_failure,
37 verify_issue_completed,
38)
39from little_loops.issue_parser import IssueInfo, IssueParser, find_issues
40from little_loops.learning_tests.extractor import resolve_learning_targets
41from little_loops.learning_tests.gate import run_learning_gate_for_issue
42from little_loops.logger import Logger, format_duration
43from little_loops.output_parsing import parse_ready_issue_output
44from little_loops.session_store import DEFAULT_DB_PATH, SQLiteTransport
45from little_loops.skill_expander import expand_skill
46from little_loops.state import ProcessingState, StateManager, _iso_now
47from little_loops.subprocess_utils import (
48 assemble_guillotine_prompt,
49 detect_context_handoff,
50 read_continuation_prompt,
51 read_sentinel,
52)
53from little_loops.subprocess_utils import (
54 run_claude_command as _run_claude_base,
55)
58def _compute_relative_path(abs_path: Path, base_dir: Path | None = None) -> str:
59 """Compute relative path from base directory for command input.
61 Used for fallback retry when ready-issue resolves to wrong file -
62 allows retrying with explicit file path instead of ambiguous ID.
64 Args:
65 abs_path: Absolute path to the file
66 base_dir: Base directory (defaults to cwd)
68 Returns:
69 Relative path string suitable for ready-issue command
70 """
71 base = base_dir or Path.cwd()
72 try:
73 return str(abs_path.relative_to(base))
74 except ValueError:
75 # Path not relative to base, use absolute
76 return str(abs_path)
79@contextmanager
80def timed_phase(
81 logger: Logger,
82 phase_name: str,
83) -> Generator[dict[str, float], None, None]:
84 """Context manager for timing phases.
86 Yields a dict that will be populated with 'elapsed' after the context exits.
88 Args:
89 logger: Logger for output
90 phase_name: Name of the phase being timed
92 Yields:
93 Dict that will contain 'elapsed' key after context exits
94 """
95 timing_result: dict[str, float] = {}
96 start = time.time()
97 try:
98 yield timing_result
99 finally:
100 elapsed = time.time() - start
101 timing_result["elapsed"] = elapsed
102 logger.timing(f"{phase_name} completed in {format_duration(elapsed)}")
105def run_claude_command(
106 command: str,
107 logger: Logger,
108 timeout: int = 3600,
109 stream_output: bool = True,
110 idle_timeout: int = 0,
111 on_model_detected: Callable[[str], None] | None = None,
112 on_usage: Callable[[int, int], None] | None = None,
113 preview_full: bool = False,
114 resume_session: bool = False,
115) -> subprocess.CompletedProcess[str]:
116 """Invoke Claude CLI command with real-time output streaming.
118 Args:
119 command: Command to pass to Claude CLI
120 logger: Logger for output
121 timeout: Timeout in seconds
122 stream_output: Whether to stream output to console
123 idle_timeout: Kill process if no output for this many seconds (0 to disable)
124 on_model_detected: Optional callback invoked with the model name from the
125 stream-json system/init event.
126 preview_full: If True, display the full command without truncation (for --verbose).
127 resume_session: If True, passes --continue to the Claude CLI to continue the
128 most recent conversation (used for Option E explicit-handoff path).
130 Returns:
131 CompletedProcess with stdout/stderr captured
132 """
133 from little_loops.cli.output import terminal_width
135 lines = command.strip().splitlines()
136 line_count = len(lines)
137 tw = terminal_width()
138 max_line = tw - 4
139 resume_flag = " --continue" if resume_session else ""
140 logger.info(
141 f"Running: claude --dangerously-skip-permissions{resume_flag} -p ({line_count} lines)"
142 )
143 show_count = line_count if preview_full else min(5, line_count)
144 for line in lines[:show_count]:
145 display = (
146 line if preview_full else (line[:max_line] + "..." if len(line) > max_line else line)
147 )
148 logger.info(f" {display}")
149 if line_count > show_count:
150 logger.info(f" ... ({line_count - show_count} more lines)")
152 def stream_callback(line: str, is_stderr: bool) -> None:
153 if stream_output:
154 if is_stderr:
155 print(f" {line}", file=sys.stderr)
156 else:
157 print(f" {line}")
159 return _run_claude_base(
160 command=command,
161 timeout=timeout,
162 stream_callback=stream_callback if stream_output else None,
163 idle_timeout=idle_timeout,
164 on_model_detected=on_model_detected,
165 on_usage=on_usage,
166 resume_session=resume_session,
167 )
170def _check_issue_already_done(
171 issue_path: Path | None,
172 logger: Logger,
173) -> bool:
174 """Check if the issue file's frontmatter status indicates work is already complete.
176 Used as a pre-continuation guard (BUG-1759): when the inner Claude session hits
177 its context limit and emits CONTEXT_HANDOFF, but the issue was already marked
178 done before the context limit was reached, we should skip the handoff and
179 return success rather than triggering an unnecessary handoff cycle.
181 Args:
182 issue_path: Path to the issue file, or None if not available.
183 logger: Logger for diagnostic messages.
185 Returns:
186 True if the issue's status is 'done' or 'cancelled'.
187 """
188 if issue_path is None:
189 return False
190 if not issue_path.exists():
191 return False
192 try:
193 from little_loops.frontmatter import parse_frontmatter
195 fm = parse_frontmatter(issue_path.read_text(encoding="utf-8"))
196 return fm.get("status") in ("done", "cancelled")
197 except Exception:
198 return False
201def run_with_continuation(
202 initial_command: str,
203 logger: Logger,
204 timeout: int = 3600,
205 stream_output: bool = True,
206 max_continuations: int = 3,
207 repo_path: Path | None = None,
208 idle_timeout: int = 0,
209 resume_command: str | None = None,
210 on_usage: Callable[[int, int], None] | None = None,
211 preview_full: bool = False,
212 context_limit: int = 200_000,
213 issue_path: Path | None = None,
214 run_dir: str | None = None,
215 sprint_context: SprintWorkerContext | None = None,
216) -> subprocess.CompletedProcess[str]:
217 """Run a Claude command with automatic continuation on context handoff.
219 Implements Options E, G, and J from BUG-1377:
221 Option E (sentinel read): before starting the next session, reads the sentinel
222 and if present sends a ``--continue`` turn with an explicit "run /ll:handoff now"
223 instruction, triggering the standard CONTEXT_HANDOFF continuation flow.
225 Option J (guillotine): if stderr contains "Prompt is too long", assembles a
226 transcript-summary prompt and spawns a fresh session (not --resume) starting at 0
227 tokens. The cumulative-token usage_ratio arm was removed (BUG-2280): cumulative
228 result-event tokens are incommensurable with the single-window context limit and
229 produced false-positive guillotine fires on every healthy multi-turn session.
231 Args:
232 initial_command: Initial command to run
233 logger: Logger for output
234 timeout: Timeout per session in seconds
235 stream_output: Whether to stream output
236 max_continuations: Maximum number of continuation attempts
237 repo_path: Repository root path
238 idle_timeout: Kill process if no output for this many seconds (0 to disable)
239 resume_command: Command to use for continuation rounds instead of appending
240 ``--resume`` to ``initial_command``.
241 on_usage: Optional external usage callback; wrapped internally for tracking.
242 context_limit: Context window size in tokens (default 200K).
244 Returns:
245 Final CompletedProcess result
246 """
247 all_stdout: list[str] = []
248 all_stderr: list[str] = []
249 current_command = initial_command
250 continuation_count = 0
251 result: subprocess.CompletedProcess[str] = subprocess.CompletedProcess(
252 args=[], returncode=1, stdout="", stderr=""
253 )
255 # Track token usage from on_usage callback (fires on stream-json result event).
256 _last_input: list[int] = [0]
257 _last_output: list[int] = [0]
258 _external_on_usage = on_usage
259 # Flag set when Option J fires; consumed in the NEXT iteration so Option E
260 # knows to consume the sentinel without attempting --continue.
261 _just_ran_fresh_session = False
263 def _tracking_usage(input_tokens: int, output_tokens: int) -> None:
264 _last_input[0] = input_tokens
265 _last_output[0] = output_tokens
266 if _external_on_usage is not None:
267 _external_on_usage(input_tokens, output_tokens)
269 while continuation_count <= max_continuations:
270 this_is_fresh = _just_ran_fresh_session
271 _just_ran_fresh_session = False
272 result = run_claude_command(
273 current_command,
274 logger,
275 timeout=timeout,
276 stream_output=stream_output,
277 idle_timeout=idle_timeout,
278 on_usage=_tracking_usage,
279 preview_full=preview_full,
280 )
282 all_stdout.append(result.stdout)
283 all_stderr.append(result.stderr)
285 # Check for context handoff signal (standard path: Claude emitted the signal)
286 if detect_context_handoff(result.stdout):
287 logger.info("Detected CONTEXT_HANDOFF signal")
289 # Pre-continuation guard: if the issue is already done/cancelled, the work
290 # is complete — return success without signalling handoff so the outer FSM
291 # doesn't waste a handoff cycle on finished work (BUG-1759 Incident 2).
292 already_done = _check_issue_already_done(issue_path, logger)
293 if already_done:
294 logger.info("Issue already done/cancelled; skipping handoff and returning success")
295 result = subprocess.CompletedProcess(
296 args=result.args,
297 returncode=0,
298 stdout=result.stdout,
299 stderr=result.stderr,
300 )
301 break
303 # Forward CONTEXT_HANDOFF signal to ll-auto's stdout so the outer FSM's
304 # signal_detector can detect it via the existing HANDOFF_SIGNAL pattern.
305 # The signal was already streamed to stdout via stream_callback; this
306 # explicit write ensures it lands even when stream_output is False.
307 handoff_message = "CONTEXT_HANDOFF: Ready for fresh session"
308 print(handoff_message)
309 logger.info("Forwarded handoff signal to stdout; exiting cleanly")
311 result = subprocess.CompletedProcess(
312 args=result.args,
313 returncode=0,
314 stdout=result.stdout + "\n" + handoff_message,
315 stderr=result.stderr,
316 )
317 break
319 prompt_too_long = "prompt is too long" in (result.stderr or "").lower()
321 # Option J: guillotine — context overflow with no handoff signal.
322 # Fires only on the reliable "Prompt is too long" stderr signal (BUG-2280).
323 # When run_dir is set (loop context), write a resume file and invoke /ll:resume.
324 # Otherwise fall back to the transcript-summary blob (non-loop ll-auto runs).
325 if prompt_too_long and continuation_count < max_continuations:
326 # Pre-continuation guard (BUG-2281): mirror the CONTEXT_HANDOFF branch —
327 # if the issue is already done/cancelled, return success without spawning.
328 if _check_issue_already_done(issue_path, logger):
329 logger.info("Issue already done/cancelled; skipping Option J continuation")
330 result = subprocess.CompletedProcess(
331 args=result.args,
332 returncode=0,
333 stdout=result.stdout,
334 stderr=result.stderr,
335 )
336 break
337 trigger_reason = "Prompt is too long"
338 logger.warning(f"Option J triggered ({trigger_reason}): spawning fresh session")
339 if run_dir is not None:
340 try:
341 guillotine_file = Path(run_dir) / "guillotine-prompt.md"
342 guillotine_file.parent.mkdir(parents=True, exist_ok=True)
343 task_first_line = (initial_command.strip().splitlines() or [""])[0]
344 sprint_framing = ""
345 if sprint_context is not None:
346 sprint_framing = (
347 f"## Sprint Worker Context\n"
348 f"You are a sprint worker. Process exactly ONE issue: "
349 f"{sprint_context.issue_id}\n"
350 f"After completing this issue, exit immediately — "
351 f"do NOT process other issues.\n"
352 f"Do NOT ask for further instructions. Exit with code 0.\n"
353 f"Branch: {sprint_context.branch}\n\n"
354 )
355 elif issue_path is not None:
356 _id_match = re.search(r"(BUG|FEAT|ENH|EPIC)-\d+", issue_path.name)
357 if _id_match:
358 sprint_framing = (
359 f"## Scope Constraint\n"
360 f"Process exactly ONE issue: {_id_match.group()}\n"
361 f"After completing this issue, exit immediately — "
362 f"do NOT process other issues.\n"
363 f"Do NOT ask for further instructions. Exit with code 0.\n\n"
364 )
365 guillotine_file.write_text(
366 sprint_framing + f"## Intent\n"
367 f"Resume an interrupted automation session that hit the context limit.\n"
368 f"Original task: {task_first_line}\n"
369 f"Trigger reason: {trigger_reason} "
370 f"({_last_input[0] + _last_output[0]:,} / {context_limit:,} tokens)\n"
371 f"\n"
372 f"## Next Steps\n"
373 f"1. Check `git log` to see what was committed in the previous session\n"
374 f"2. Check the issue file status — if already done/cancelled, stop\n"
375 f"3. Review `.loops/tmp/scratch/` for partial progress notes\n"
376 f"4. Continue the original task from where it left off, "
377 f"skipping already-completed work\n",
378 encoding="utf-8",
379 )
380 guillotine_cmd = f"/ll:resume {guillotine_file}"
381 logger.info(f"Option J resume file written: {guillotine_file}")
382 except Exception as exc:
383 logger.warning(
384 f"Failed to write guillotine resume file ({exc}), "
385 "falling back to summary blob"
386 )
387 guillotine_cmd = initial_command
388 else:
389 try:
390 _path_id: str | None = None
391 if issue_path is not None:
392 _pid_match = re.search(r"(BUG|FEAT|ENH|EPIC)-\d+", issue_path.name)
393 if _pid_match:
394 _path_id = _pid_match.group()
395 guillotine_cmd = assemble_guillotine_prompt(
396 original_command=initial_command,
397 captured_stdout="\n---CONTINUATION---\n".join(all_stdout),
398 token_stats={
399 "input_tokens": _last_input[0],
400 "output_tokens": _last_output[0],
401 "context_limit": context_limit,
402 "trigger_reason": trigger_reason,
403 },
404 sprint_context=sprint_context,
405 issue_id=_path_id,
406 )
407 except Exception as exc:
408 logger.warning(
409 f"Failed to assemble guillotine prompt ({exc}), using bare restart"
410 )
411 guillotine_cmd = initial_command
412 continuation_count += 1
413 current_command = guillotine_cmd
414 # Reset per-round usage tracking for the fresh session
415 _last_input[0] = 0
416 _last_output[0] = 0
417 _just_ran_fresh_session = True
418 continue
420 # Option E: read sentinel from a PREVIOUS session (written by the Stop hook or by
421 # G-path in the preceding iteration). Must run BEFORE G writes the current-session
422 # sentinel so we don't immediately consume what we just wrote.
423 sentinel_data = read_sentinel(repo_path)
424 if sentinel_data is not None and this_is_fresh:
425 # The sentinel was written by the guillotine fresh session that just finished.
426 # The work is already done; do not attempt --continue.
427 logger.info(
428 "Fresh session wrote sentinel; consumed without --continue (work already done)"
429 )
430 elif sentinel_data is not None and continuation_count < max_continuations:
431 usage_pct = sentinel_data.get("usage_percent", 0)
432 logger.info(
433 f"Sentinel detected ({usage_pct}% context used): "
434 "sending explicit handoff instruction via --continue"
435 )
436 continuation_count += 1
437 # Resume the existing session with an explicit handoff instruction.
438 # Claude receives this as a new user turn in the active session context.
439 explicit_handoff_instruction = (
440 f"Context limit is approaching ({usage_pct}% of the context window is used). "
441 "Please run /ll:handoff RIGHT NOW to save your progress to "
442 ".ll/ll-continue-prompt.md, then output "
443 '"CONTEXT_HANDOFF: Ready for fresh session" to signal continuation.'
444 )
445 current_command = explicit_handoff_instruction
446 # Reset tracking for the handoff turn
447 _last_input[0] = 0
448 _last_output[0] = 0
449 # Use --continue CLI flag so this turn continues the existing session
450 result = run_claude_command(
451 current_command,
452 logger,
453 timeout=timeout,
454 stream_output=stream_output,
455 idle_timeout=idle_timeout,
456 on_usage=_tracking_usage,
457 preview_full=preview_full,
458 resume_session=True,
459 )
460 all_stdout.append(result.stdout)
461 all_stderr.append(result.stderr)
463 # After explicit instruction, check for handoff signal
464 if detect_context_handoff(result.stdout):
465 logger.info("CONTEXT_HANDOFF detected after explicit handoff instruction")
466 prompt_content = read_continuation_prompt(repo_path)
467 if prompt_content and continuation_count < max_continuations:
468 continuation_count += 1
469 logger.info(f"Starting continuation session #{continuation_count}")
470 _base = resume_command if resume_command is not None else initial_command
471 current_command = f"{_base} --resume"
472 _last_input[0] = 0
473 _last_output[0] = 0
474 continue
475 break
477 # No handoff signal, no prior-session sentinel, no overflow — done
478 break
480 return subprocess.CompletedProcess(
481 args=result.args,
482 returncode=result.returncode,
483 stdout="\n---CONTINUATION---\n".join(all_stdout),
484 stderr="\n---CONTINUATION---\n".join(all_stderr),
485 )
488def detect_plan_creation(output: str, issue_id: str) -> Path | None:
489 """Detect if manage-issue created a plan file awaiting approval.
491 Checks for plan file creation in thoughts/shared/plans/ matching the issue ID.
492 This happens when manage-issue creates a plan but waits for user approval.
494 Args:
495 output: Command stdout (unused, for future pattern matching)
496 issue_id: Issue ID (e.g., "BUG-280")
498 Returns:
499 Path to plan file if created, None otherwise
500 """
501 plans_dir = Path("thoughts/shared/plans")
502 if not plans_dir.exists():
503 return None
505 # Find plan files matching this issue ID (format: YYYY-MM-DD-ISSUE-ID-*.md)
506 # Use glob pattern with issue_id
507 pattern = f"*-{issue_id}-*.md"
508 matching_plans = list(plans_dir.glob(pattern))
510 if not matching_plans:
511 return None
513 # Return the most recently modified plan file
514 # (in case multiple exist, take the latest)
515 latest_plan = max(matching_plans, key=lambda p: p.stat().st_mtime)
516 return latest_plan
519def check_content_markers(issue_path: Path) -> bool:
520 """Check if issue file content contains implementation markers.
522 Looks for indicators that an implementation was completed, such as
523 Resolution sections or status markers added by manage-issue.
525 Args:
526 issue_path: Path to the issue file
528 Returns:
529 True if implementation markers found
530 """
531 try:
532 content = issue_path.read_text(encoding="utf-8")
533 except (OSError, UnicodeDecodeError):
534 return False
536 markers = [
537 "## Resolution",
538 "Status: Implemented",
539 "Status: Completed",
540 "**Completed**:",
541 ]
542 return any(marker in content for marker in markers)
545@dataclass
546class IssueProcessingResult:
547 """Result of processing a single issue in-place."""
549 success: bool
550 duration: float
551 issue_id: str
552 was_closed: bool = False
553 was_blocked: bool = False
554 failure_reason: str = ""
555 corrections: list[str] = field(default_factory=list)
556 plan_created: bool = False
557 plan_path: str = ""
560def process_issue_inplace(
561 info: IssueInfo,
562 config: BRConfig,
563 logger: Logger,
564 dry_run: bool = False,
565 on_model_detected: Callable[[str], None] | None = None,
566 on_usage: Callable[[int, int], None] | None = None,
567 preview_full: bool = False,
568 event_bus: EventBus | None = None,
569 sprint_context: SprintWorkerContext | None = None,
570 context_limit: int | None = None,
571 skip_learning_gate: bool = False,
572) -> IssueProcessingResult:
573 """Process a single issue through the 3-phase workflow in the current working tree.
575 This is the core processing logic extracted from AutoManager._process_issue(),
576 suitable for use outside of AutoManager (e.g., single-issue sprint waves).
578 Args:
579 info: Issue information
580 config: Project configuration
581 logger: Logger for output
582 dry_run: If True, only preview what would be done
583 on_model_detected: Optional callback invoked with the model name from the
584 first stream-json system/init event during this issue's processing.
585 on_usage: Optional callback invoked with (input_tokens, output_tokens) from
586 each stream-json result event. Passed through to all run_claude_command calls.
588 Returns:
589 IssueProcessingResult with outcome details
590 """
591 issue_start_time = time.time()
592 corrections: list[str] = []
594 logger.header(f"Processing: {info.issue_id} - {info.title}")
596 issue_timing: dict[str, float] = {}
598 # Track whether we used fallback path resolution for ready-issue.
599 validated_via_fallback = False
601 # Build on_usage closure that writes result_token_count to the context state file.
602 # Mirrors the on_model_detected closure pattern in AutoManager._process_issue.
603 _state_file = (config.repo_path or Path.cwd()) / ".ll" / "ll-context-state.json"
604 _external_on_usage = on_usage
606 def _on_usage_writer(input_tokens: int, output_tokens: int) -> None:
607 try:
608 state = json.loads(_state_file.read_text()) if _state_file.exists() else {}
609 state["result_token_count"] = input_tokens + output_tokens
610 _state_file.write_text(json.dumps(state))
611 except Exception:
612 pass # never block execution on state write failures
613 if _external_on_usage is not None:
614 _external_on_usage(input_tokens, output_tokens)
616 # Phase 1: Ready/verify the issue
617 logger.info(f"Phase 1: Verifying issue {info.issue_id}...")
618 with timed_phase(logger, "Phase 1 (ready-issue)") as phase1_timing:
619 if not dry_run:
620 _ready_slash = f"/ll:ready-issue {info.issue_id}"
621 _ready_cmd = expand_skill("ready-issue", [info.issue_id], config) or _ready_slash
622 result = run_claude_command(
623 _ready_cmd,
624 logger,
625 timeout=config.automation.timeout_seconds,
626 stream_output=config.automation.stream_output,
627 idle_timeout=config.automation.idle_timeout_seconds,
628 on_model_detected=on_model_detected,
629 preview_full=preview_full,
630 )
631 if result.returncode != 0:
632 logger.warning("ready-issue command failed to execute, continuing anyway...")
633 else:
634 # Parse the verdict from the output
635 parsed = parse_ready_issue_output(result.stdout)
636 logger.info(f"ready-issue verdict: {parsed['verdict']}")
638 # Validate that ready-issue analyzed the expected file
639 validated_path = parsed.get("validated_file_path")
640 if validated_path:
641 # Normalize paths for comparison (resolve to absolute)
642 expected_path = str(info.path.resolve())
643 # Handle both absolute and relative paths from ready_issue
644 validated_resolved = Path(validated_path).resolve()
645 if str(validated_resolved) != expected_path:
646 # Check if this is a legitimate rename (new file exists,
647 # old file doesn't) vs a mismatch error
648 old_file_exists = info.path.exists()
649 new_file_exists = validated_resolved.exists()
651 if new_file_exists and not old_file_exists:
652 # ready-issue renamed the file - update tracking
653 logger.info(
654 f"Issue file renamed: '{info.path.name}' -> "
655 f"'{validated_resolved.name}'"
656 )
657 info.path = validated_resolved
658 else:
659 # Genuine mismatch - attempt fallback with explicit path
660 logger.warning(
661 f"Path mismatch: ready-issue validated "
662 f"'{validated_path}' but expected '{info.path}'"
663 )
664 logger.info(
665 "Attempting fallback: retrying ready-issue "
666 "with explicit file path..."
667 )
669 # Compute relative path for the command
670 relative_path = _compute_relative_path(info.path)
672 # Retry with explicit path
673 _retry_slash = f"/ll:ready-issue {relative_path}"
674 _retry_cmd = (
675 expand_skill("ready-issue", [str(relative_path)], config)
676 or _retry_slash
677 )
678 retry_result = run_claude_command(
679 _retry_cmd,
680 logger,
681 timeout=config.automation.timeout_seconds,
682 stream_output=config.automation.stream_output,
683 idle_timeout=config.automation.idle_timeout_seconds,
684 on_model_detected=on_model_detected,
685 preview_full=preview_full,
686 )
688 if retry_result.returncode != 0:
689 logger.error(f"Fallback ready-issue failed for {info.issue_id}")
690 return IssueProcessingResult(
691 success=False,
692 duration=time.time() - issue_start_time,
693 issue_id=info.issue_id,
694 failure_reason="Fallback failed after path mismatch",
695 )
697 # Re-parse and validate retry output
698 retry_parsed = parse_ready_issue_output(retry_result.stdout)
699 retry_validated_path = retry_parsed.get("validated_file_path")
701 if retry_validated_path:
702 retry_resolved = Path(retry_validated_path).resolve()
703 if str(retry_resolved) != str(info.path.resolve()):
704 logger.error(
705 f"Fallback still mismatched: "
706 f"got '{retry_validated_path}', "
707 f"expected '{info.path}'"
708 )
709 return IssueProcessingResult(
710 success=False,
711 duration=time.time() - issue_start_time,
712 issue_id=info.issue_id,
713 failure_reason="Path mismatch persisted after fallback",
714 )
716 # Fallback succeeded - use retry result
717 logger.info("Fallback succeeded: validated correct file")
718 parsed = retry_parsed
719 validated_via_fallback = True
721 # Log and store any corrections made
722 if parsed.get("was_corrected"):
723 logger.info(f"Issue {info.issue_id} was auto-corrected")
724 phase_corrections = parsed.get("corrections", [])
725 for correction in phase_corrections:
726 logger.info(f" Correction: {correction}")
727 if phase_corrections:
728 corrections.extend(phase_corrections)
730 # Log any concerns found
731 if parsed["concerns"]:
732 for concern in parsed["concerns"]:
733 logger.warning(f" Concern: {concern}")
735 # Handle CLOSE verdict - issue should not be implemented
736 if parsed.get("should_close"):
737 close_reason = parsed.get("close_reason", "unknown")
738 logger.info(f"Issue {info.issue_id} should be closed (reason: {close_reason})")
740 # CRITICAL: Skip file operations for invalid references
741 if close_reason == "invalid_ref":
742 logger.warning(
743 f"Skipping {info.issue_id}: invalid reference - "
744 "no matching issue file exists"
745 )
746 return IssueProcessingResult(
747 success=False,
748 duration=time.time() - issue_start_time,
749 issue_id=info.issue_id,
750 failure_reason=f"Invalid reference: {close_reason}",
751 corrections=corrections,
752 )
754 # Also require validated_file_path to match before closing
755 close_validated_path = parsed.get("validated_file_path")
756 if not close_validated_path:
757 logger.warning(
758 f"Skipping close for {info.issue_id}: "
759 "ready-issue did not return validated file path"
760 )
761 return IssueProcessingResult(
762 success=False,
763 duration=time.time() - issue_start_time,
764 issue_id=info.issue_id,
765 failure_reason="CLOSE without validated file path",
766 corrections=corrections,
767 )
769 if close_issue(
770 info,
771 config,
772 logger,
773 close_reason,
774 parsed.get("close_status"),
775 event_bus=event_bus,
776 ):
777 return IssueProcessingResult(
778 success=True,
779 duration=time.time() - issue_start_time,
780 issue_id=info.issue_id,
781 was_closed=True,
782 corrections=corrections,
783 )
784 else:
785 return IssueProcessingResult(
786 success=False,
787 duration=time.time() - issue_start_time,
788 issue_id=info.issue_id,
789 failure_reason=f"CLOSE failed: {parsed.get('close_status', 'unknown')}",
790 corrections=corrections,
791 )
793 # Handle BLOCKED verdict - issue has open dependencies
794 if parsed.get("is_blocked"):
795 logger.warning(
796 f"Issue {info.issue_id} blocked — open dependency detected by ready-issue"
797 )
798 return IssueProcessingResult(
799 success=False,
800 was_blocked=True,
801 duration=time.time() - issue_start_time,
802 issue_id=info.issue_id,
803 failure_reason=f"BLOCKED: {parsed.get('concerns', [])}",
804 corrections=corrections,
805 )
807 # Check if issue is NOT READY (and not closeable)
808 if not parsed["is_ready"]:
809 logger.error(
810 f"Issue {info.issue_id} is NOT READY for implementation "
811 f"(verdict: {parsed['verdict']})"
812 )
813 return IssueProcessingResult(
814 success=False,
815 duration=time.time() - issue_start_time,
816 issue_id=info.issue_id,
817 failure_reason=(
818 f"NOT READY: {parsed['verdict']} - {len(parsed['concerns'])} concern(s)"
819 ),
820 corrections=corrections,
821 )
823 # Log if proceeding with corrected issue
824 if parsed.get("was_corrected"):
825 logger.success(f"Issue {info.issue_id} corrected and ready for implementation")
826 else:
827 logger.info(f"Would run: /ll:ready-issue {info.issue_id}")
828 issue_timing["ready"] = phase1_timing.get("elapsed", 0.0)
830 # Decision gate: invoke decide-issue when the issue requires a decision
831 if info.decision_needed is True and not dry_run:
832 logger.info(
833 f"Decision gate: {info.issue_id} has decision_needed=True, invoking decide-issue..."
834 )
835 _decide_slash = f"/ll:decide-issue {info.issue_id} --auto"
836 _decide_cmd = (
837 expand_skill("decide-issue", [info.issue_id, "--auto"], config) or _decide_slash
838 )
839 decide_result = run_claude_command(
840 _decide_cmd,
841 logger,
842 timeout=config.automation.timeout_seconds,
843 stream_output=config.automation.stream_output,
844 idle_timeout=config.automation.idle_timeout_seconds,
845 on_model_detected=on_model_detected,
846 preview_full=preview_full,
847 )
848 if decide_result.returncode != 0:
849 logger.warning("decide-issue command failed, continuing to implementation anyway...")
851 # Learning gate: per-issue proof-first-task check (ENH-2319)
852 # Use `is True` (not truthiness) so MagicMock auto-specs in tests don't
853 # accidentally trigger the gate when `enabled` is not explicitly set.
854 if config.learning_tests.enabled is True and not dry_run:
855 targets = resolve_learning_targets(info)
856 if targets:
857 logger.info(f"Learning gate: checking {len(targets)} target(s): {', '.join(targets)}")
858 gate_cwd = config.repo_path or Path.cwd()
859 verdict = run_learning_gate_for_issue(info.path, skip=skip_learning_gate, cwd=gate_cwd)
860 if verdict == "skipped":
861 logger.info(f"Learning gate skipped for {info.issue_id} (--skip-learning-gate)")
862 elif verdict == "blocked":
863 logger.warning(f"Learning gate blocked {info.issue_id}: unproven external-API deps")
864 return IssueProcessingResult(
865 success=False,
866 duration=time.time() - issue_start_time,
867 issue_id=info.issue_id,
868 failure_reason="Learning gate blocked: unproven external-API deps",
869 corrections=corrections,
870 )
872 # Phase 2: Implement the issue (with automatic continuation on context handoff)
873 action = config.get_category_action(info.issue_type)
874 logger.info(f"Phase 2: Implementing {info.issue_id}...")
875 _baseline_sha_result = subprocess.run(
876 ["git", "rev-parse", "HEAD"], capture_output=True, text=True
877 )
878 _baseline_sha: str | None = (
879 _baseline_sha_result.stdout.strip() if _baseline_sha_result.returncode == 0 else None
880 )
881 with timed_phase(logger, "Phase 2 (implement)") as phase2_timing:
882 if not dry_run:
883 # Build manage-issue command
884 type_name = info.issue_type.rstrip("s") # bugs -> bug
886 # Use relative path if fallback was used, otherwise use issue_id
887 if validated_via_fallback:
888 issue_arg = _compute_relative_path(info.path)
889 else:
890 issue_arg = info.issue_id
892 # Use run_with_continuation to handle context exhaustion.
893 # Pre-expand the skill so the subprocess needs no ToolSearch.
894 # Pass the short slash command as resume_command so continuation
895 # rounds stay compact (not hundreds of lines).
896 _slash_cmd = f"/ll:manage-issue {type_name} {action} {issue_arg}"
897 _initial_cmd = (
898 expand_skill("manage-issue", [type_name, action, issue_arg], config) or _slash_cmd
899 )
900 result = run_with_continuation(
901 _initial_cmd,
902 logger,
903 timeout=config.automation.timeout_seconds,
904 stream_output=config.automation.stream_output,
905 max_continuations=config.automation.max_continuations,
906 repo_path=config.repo_path,
907 idle_timeout=config.automation.idle_timeout_seconds,
908 resume_command=_slash_cmd,
909 on_usage=_on_usage_writer,
910 preview_full=preview_full,
911 issue_path=info.path,
912 sprint_context=sprint_context,
913 context_limit=context_window_for(None, override=context_limit),
914 )
915 else:
916 logger.info(f"Would run: /ll:manage-issue {info.issue_type} {action} {info.issue_id}")
917 result = subprocess.CompletedProcess(args=[], returncode=0)
918 issue_timing["implement"] = phase2_timing.get("elapsed", 0.0)
920 # Handle implementation failure
921 if result.returncode != 0:
922 # Guard: if the issue's frontmatter already shows status: done by the
923 # subprocess (e.g., a guillotine fresh session that finished and then
924 # triggered a spurious Option E --continue failure), treat as success
925 # so Phase 3 runs.
926 already_done = False
927 if info.path.exists():
928 try:
929 from little_loops.frontmatter import parse_frontmatter
931 _fm = parse_frontmatter(info.path.read_text(encoding="utf-8"))
932 already_done = _fm.get("status") in ("done", "completed", "cancelled")
933 except Exception:
934 already_done = False
935 if already_done:
936 logger.warning(
937 f"Phase 2 exited non-zero but {info.issue_id} status is done/cancelled; "
938 "treating as success (continuation artefact)"
939 )
940 result = subprocess.CompletedProcess(
941 args=result.args, returncode=0, stdout=result.stdout, stderr=result.stderr
942 )
943 else:
944 error_output = result.stderr or result.stdout or "Unknown error"
945 failure_type, failure_reason_text = classify_failure(error_output, result.returncode)
947 if failure_type in (FailureType.TRANSIENT, FailureType.NON_RECOVERABLE):
948 # Transient or non-recoverable failure — log but don't create bug issue.
949 # NON_RECOVERABLE (auth/credential) is not a code bug; retrying won't help.
950 label = "Transient" if failure_type == FailureType.TRANSIENT else "Non-recoverable"
951 logger.warning(f"{label} failure for {info.issue_id}: {failure_reason_text}")
952 logger.warning("Not creating bug issue - this is not a code defect")
953 logger.info("Error output (first 500 chars):")
954 logger.info(error_output[:500])
956 return IssueProcessingResult(
957 success=False,
958 duration=time.time() - issue_start_time,
959 issue_id=info.issue_id,
960 failure_reason=f"{label}: {failure_reason_text}",
961 corrections=corrections,
962 )
964 # Real failure - create issue as before
965 logger.error(f"Implementation failed for {info.issue_id}")
967 failure_reason = ""
968 if not dry_run:
969 # Create new issue for the failure
970 new_issue = create_issue_from_failure(
971 error_output,
972 info,
973 config,
974 logger,
975 event_bus=event_bus,
976 )
977 failure_reason = str(new_issue) if new_issue else error_output
978 else:
979 logger.info("Would create new bug issue for this failure")
981 return IssueProcessingResult(
982 success=False,
983 duration=time.time() - issue_start_time,
984 issue_id=info.issue_id,
985 failure_reason=failure_reason,
986 corrections=corrections,
987 )
989 # Phase 3: Verify completion
990 logger.info(f"Phase 3: Verifying {info.issue_id} completion...")
991 verified = False
992 with timed_phase(logger, "Phase 3 (verify)") as phase3_timing:
993 if not dry_run:
994 verified = verify_issue_completed(info, config, logger)
996 # Fallback: Only complete lifecycle if:
997 # 1. Command returned success (returncode 0)
998 # 2. File wasn't moved to completed
999 # 3. There's EVIDENCE of actual work being done (code changes)
1000 if not verified and result.returncode == 0:
1001 # Check if a plan was created awaiting approval
1002 plan_path = detect_plan_creation(result.stdout, info.issue_id)
1003 if plan_path is not None:
1004 logger.info(
1005 f"Plan created at {plan_path}, awaiting approval - "
1006 "issue will remain incomplete until plan is approved and implemented"
1007 )
1008 return IssueProcessingResult(
1009 success=False,
1010 duration=time.time() - issue_start_time,
1011 issue_id=info.issue_id,
1012 plan_created=True,
1013 plan_path=str(plan_path),
1014 failure_reason="", # Not a failure - plan awaiting approval
1015 corrections=corrections,
1016 )
1018 logger.info(
1019 "Command returned success but issue not moved - "
1020 "checking for evidence of work..."
1021 )
1023 # Check issue file content for implementation markers
1024 if check_content_markers(info.path):
1025 logger.info(
1026 "Implementation markers found in issue file - completing lifecycle..."
1027 )
1028 verified = complete_issue_lifecycle(info, config, logger, event_bus=event_bus)
1029 if verified:
1030 logger.success(f"Content marker completion succeeded for {info.issue_id}")
1031 else:
1032 logger.warning(f"Content marker completion failed for {info.issue_id}")
1033 else:
1034 # CRITICAL: Verify actual implementation work was done
1035 work_done = verify_work_was_done(logger, baseline_sha=_baseline_sha)
1036 if work_done:
1037 logger.info("Evidence of code changes found - completing lifecycle...")
1038 verified = complete_issue_lifecycle(
1039 info, config, logger, event_bus=event_bus
1040 )
1041 if verified:
1042 logger.success(f"Fallback completion succeeded for {info.issue_id}")
1043 else:
1044 logger.warning(f"Fallback completion failed for {info.issue_id}")
1045 else:
1046 # NO work was done - do NOT mark as completed
1047 logger.error(
1048 f"REFUSING to mark {info.issue_id} as completed: "
1049 "no code changes detected despite returncode 0"
1050 )
1051 logger.error(
1052 "This likely indicates the command was not executed "
1053 "properly. Check command invocation and Claude CLI "
1054 "output."
1055 )
1056 verified = False
1057 else:
1058 logger.info("Would verify issue moved to completed")
1059 verified = True # In dry run, assume success
1060 issue_timing["verify"] = phase3_timing.get("elapsed", 0.0)
1062 # Record timing
1063 total_issue_time = time.time() - issue_start_time
1064 issue_timing["total"] = total_issue_time
1065 logger.timing(f"Total processing time: {format_duration(total_issue_time)}")
1067 if verified:
1068 logger.success(f"Completed: {info.issue_id}")
1069 else:
1070 logger.warning(f"Issue {info.issue_id} was attempted but verification failed")
1071 logger.info("This issue will be skipped on future runs (check logs above for details)")
1073 return IssueProcessingResult(
1074 success=verified,
1075 duration=total_issue_time,
1076 issue_id=info.issue_id,
1077 corrections=corrections,
1078 )
1081class AutoManager:
1082 """Automated issue manager for sequential processing.
1084 Processes issues in priority order using Claude CLI commands,
1085 with state persistence for resume capability.
1086 """
1088 def __init__(
1089 self,
1090 config: BRConfig,
1091 dry_run: bool = False,
1092 max_issues: int = 0,
1093 resume: bool = False,
1094 category: str | None = None,
1095 only_ids: list[str] | set[str] | None = None,
1096 skip_ids: set[str] | None = None,
1097 type_prefixes: set[str] | None = None,
1098 priority_filter: set[str] | None = None,
1099 label_filter: set[str] | None = None,
1100 verbose: bool = True,
1101 preview_full: bool = False,
1102 db_path: Path | None = None,
1103 skip_learning_gate: bool = False,
1104 ) -> None:
1105 """Initialize the auto manager.
1107 Args:
1108 config: Project configuration
1109 dry_run: If True, only preview what would be done
1110 max_issues: Maximum issues to process (0 = unlimited)
1111 resume: Whether to resume from previous state
1112 category: Optional category to filter (e.g., "bugs")
1113 only_ids: If provided, only process these issue IDs. When a list,
1114 issues are processed in list order (input sequence preserved).
1115 skip_ids: Issue IDs to skip (in addition to attempted issues)
1116 type_prefixes: If provided, only process issues with these type prefixes
1117 priority_filter: If provided, only process issues with these priority levels
1118 label_filter: If provided, only process issues that have at least one of these labels
1119 verbose: Whether to output progress messages
1120 preview_full: If True, show full command content without truncation (--verbose flag).
1121 """
1122 self.config = config
1123 self.dry_run = dry_run
1124 self.max_issues = max_issues
1125 self.resume = resume
1126 self.category = category
1127 self.only_ids = only_ids
1128 self.skip_ids = skip_ids or set()
1129 self.type_prefixes = type_prefixes
1130 self.priority_filter = priority_filter
1131 self.label_filter = label_filter
1132 self._preview_full = preview_full
1133 self.skip_learning_gate = skip_learning_gate
1135 from little_loops.cli.output import use_color_enabled
1137 self.logger = Logger(verbose=verbose, use_color=use_color_enabled())
1138 self.event_bus = EventBus()
1139 self.event_bus.add_transport(SQLiteTransport(db_path or DEFAULT_DB_PATH))
1140 self.state_manager = StateManager(
1141 config.get_state_file(), self.logger, event_bus=self.event_bus
1142 )
1143 self.parser = IssueParser(config)
1144 self._detected_model: list[str] = []
1146 # Build dependency graph for dependency-aware sequencing (ENH-016)
1147 # Note: don't filter by type here — we need all issues for dependency resolution
1148 all_issues = find_issues(self.config, self.category)
1149 all_known_ids: set[str] | None = None
1150 try:
1151 from little_loops.dependency_mapper import gather_all_issue_ids
1153 issues_dir = config.project_root / config.issues.base_dir
1154 all_known_ids = gather_all_issue_ids(issues_dir, config=config)
1155 except Exception:
1156 self.logger.debug("Dependency mapping unavailable — skipping")
1157 self.dep_graph = DependencyGraph.from_issues(all_issues, all_known_ids=all_known_ids)
1159 # Warn about any cycles
1160 if self.dep_graph.has_cycles():
1161 cycles = self.dep_graph.detect_cycles()
1162 for cycle in cycles:
1163 self.logger.warning(f"Dependency cycle detected: {' -> '.join(cycle)}")
1165 self.processed_count = 0
1166 self._shutdown_requested = False
1168 signal.signal(signal.SIGINT, self._signal_handler)
1169 signal.signal(signal.SIGTERM, self._signal_handler)
1171 def _signal_handler(self, signum: int, frame: FrameType | None) -> None:
1172 """Handle shutdown signals gracefully."""
1173 self._shutdown_requested = True
1174 self.logger.warning(f"Received signal {signum}, shutting down gracefully...")
1176 def _get_next_issue(self) -> IssueInfo | None:
1177 """Get next issue respecting dependencies.
1179 Returns the highest priority issue whose blockers have all been
1180 completed. If no ready issues exist but blocked issues remain,
1181 logs warnings about what is blocking progress.
1183 Returns:
1184 Next IssueInfo to process, or None if no ready issues
1185 """
1186 # Get completed issues from state
1187 completed = set(self.state_manager.state.completed_issues)
1189 # Combine skip_ids from state and CLI argument
1190 skip_ids = self.state_manager.state.attempted_issues | self.skip_ids
1192 # Get issues that are ready (blockers satisfied)
1193 ready_issues = self.dep_graph.get_ready_issues(completed)
1195 # Filter by skip_ids, only_ids, type_prefixes, priority_filter, label_filter
1196 candidates = [
1197 i
1198 for i in ready_issues
1199 if i.issue_id not in skip_ids
1200 and (self.only_ids is None or any(_id_matches(i.issue_id, p) for p in self.only_ids))
1201 and (self.type_prefixes is None or i.issue_id.split("-", 1)[0] in self.type_prefixes)
1202 and (self.priority_filter is None or i.priority in self.priority_filter)
1203 and (
1204 self.label_filter is None or any(lb.lower() in self.label_filter for lb in i.labels)
1205 )
1206 ]
1208 if candidates:
1209 # When only_ids is a list, respect input order; otherwise use priority order
1210 only_ids = self.only_ids
1211 if isinstance(only_ids, list):
1212 candidates.sort(
1213 key=lambda x: next(
1214 (i for i, p in enumerate(only_ids) if _id_matches(x.issue_id, p)),
1215 len(only_ids),
1216 )
1217 )
1218 return candidates[0]
1220 # No ready candidates - check if there are blocked issues remaining
1221 all_in_graph = set(self.dep_graph.issues.keys())
1222 remaining = all_in_graph - completed - skip_ids
1223 if self.only_ids is not None:
1224 remaining = {r for r in remaining if any(_id_matches(r, p) for p in self.only_ids)}
1225 if self.type_prefixes is not None:
1226 remaining = {r for r in remaining if r.split("-", 1)[0] in self.type_prefixes}
1227 if self.priority_filter is not None:
1228 remaining = {
1229 r for r in remaining if self.dep_graph.issues[r].priority in self.priority_filter
1230 }
1231 if self.label_filter is not None:
1232 remaining = {
1233 r
1234 for r in remaining
1235 if any(lb.lower() in self.label_filter for lb in self.dep_graph.issues[r].labels)
1236 }
1238 if remaining:
1239 self._log_blocked_issues(remaining, completed)
1241 return None
1243 def _log_blocked_issues(self, remaining: set[str], completed: set[str]) -> None:
1244 """Log information about blocked issues when processing stalls.
1246 Args:
1247 remaining: Set of issue IDs that haven't been processed
1248 completed: Set of completed issue IDs
1249 """
1250 blocked_count = 0
1251 for issue_id in sorted(remaining):
1252 blockers = self.dep_graph.get_blocking_issues(issue_id, completed)
1253 if blockers:
1254 blocked_count += 1
1255 self.logger.info(f" {issue_id} blocked by: {', '.join(sorted(blockers))}")
1257 if blocked_count > 0:
1258 self.logger.warning(f"{blocked_count} issue(s) remain blocked - check dependencies")
1260 def run(self) -> int:
1261 """Run the automation loop.
1263 Returns:
1264 Exit code: 0 = success or empty queue, 1 = all issues gate-blocked when --only used
1265 """
1266 run_start_time = time.time()
1267 self.logger.info("Starting automated issue management...")
1269 if self.dry_run:
1270 self.logger.info("DRY RUN MODE - No actual changes will be made")
1272 if not self.dry_run:
1273 has_changes = check_git_status(self.logger)
1274 if has_changes:
1275 self.logger.warning("Proceeding anyway...")
1277 # Load or initialize state
1278 if self.resume:
1279 state = self.state_manager.load()
1280 if state:
1281 self.logger.info(f"Resuming from: {state.current_issue}")
1282 self.processed_count = len(state.completed_issues)
1283 else:
1284 # Fresh start
1285 self.state_manager._state = ProcessingState(timestamp=_iso_now())
1287 attempted_count = 0
1288 try:
1289 while not self._shutdown_requested:
1290 if self.max_issues > 0 and self.processed_count >= self.max_issues:
1291 self.logger.info(f"Reached max issues limit: {self.max_issues}")
1292 break
1294 info = self._get_next_issue()
1295 if not info:
1296 self.logger.success("No more issues to process!")
1297 break
1299 attempted_count += 1
1300 success = self._process_issue(info)
1301 if success:
1302 self.processed_count += 1
1304 except Exception as e:
1305 self.logger.error(f"Fatal error: {e}")
1306 return 1
1308 finally:
1309 if not self._shutdown_requested:
1310 self.state_manager.cleanup()
1311 self.event_bus.close_transports()
1313 self._log_timing_summary(run_start_time)
1314 self.logger.success(f"Processed {self.processed_count} issue(s)")
1315 if self.only_ids and attempted_count > 0 and self.processed_count == 0:
1316 return 1
1317 return 0
1319 def _log_timing_summary(self, run_start_time: float) -> None:
1320 """Log aggregate timing summary."""
1321 total_run_time = time.time() - run_start_time
1323 self.logger.info("")
1324 self.logger.header("PROCESSING SUMMARY")
1325 self.logger.timing(f"Total run time: {format_duration(total_run_time)}")
1326 self.logger.timing(f"Issues processed: {self.processed_count}")
1328 state = self.state_manager.state
1329 if state.timing:
1330 total_times = [t.get("total", 0) for t in state.timing.values()]
1331 if total_times:
1332 avg_time = sum(total_times) / len(total_times)
1333 self.logger.timing(f"Average per issue: {format_duration(avg_time)}")
1335 if state.failed_issues:
1336 self.logger.warning(f"Failed issues: {len(state.failed_issues)}")
1337 for issue_id, reason in state.failed_issues.items():
1338 self.logger.warning(f" - {issue_id}: {reason[:50]}...")
1340 # Log correction statistics for quality tracking
1341 if state.corrections:
1342 total_corrected = len(state.corrections)
1343 total_issues = len(state.completed_issues) + len(state.failed_issues)
1344 correction_rate = (total_corrected / total_issues * 100) if total_issues > 0 else 0
1345 self.logger.info(
1346 f"Auto-corrections: {total_corrected}/{total_issues} ({correction_rate:.1f}%)"
1347 )
1349 # Log most common correction types
1350 from collections import Counter
1352 all_corrections: list[str] = []
1353 for corrections in state.corrections.values():
1354 all_corrections.extend(corrections)
1355 if all_corrections:
1356 common = Counter(all_corrections).most_common(3)
1357 self.logger.info("Most common corrections:")
1358 for correction, count in common:
1359 # Truncate long correction descriptions
1360 display = correction[:60] + "..." if len(correction) > 60 else correction
1361 self.logger.info(f" - {display}: {count}")
1363 def _process_issue(self, info: IssueInfo) -> bool:
1364 """Process a single issue through the workflow.
1366 Delegates to process_issue_inplace() and maps the result back
1367 to state manager calls.
1369 Args:
1370 info: Issue information
1372 Returns:
1373 True if processing succeeded
1374 """
1375 # Pre-processing state updates (before delegating)
1376 self.state_manager.mark_attempted(info.issue_id, save=False)
1377 self.state_manager.update_current(str(info.path), "processing")
1379 on_model: Callable[[str], None] | None = None
1380 if not self._detected_model:
1382 def on_model(m: str) -> None:
1383 self._detected_model.append(m)
1384 self.logger.info(f"model: {m}")
1386 resolved_limit = context_window_for(
1387 self._detected_model[0] if self._detected_model else None
1388 )
1389 result = process_issue_inplace(
1390 info,
1391 self.config,
1392 self.logger,
1393 self.dry_run,
1394 on_model_detected=on_model,
1395 preview_full=self._preview_full,
1396 event_bus=self.event_bus,
1397 context_limit=resolved_limit,
1398 skip_learning_gate=self.skip_learning_gate,
1399 )
1401 # Map result back to state tracking
1402 if result.was_closed:
1403 self.state_manager.mark_completed(info.issue_id)
1404 elif result.was_blocked:
1405 # Blocked issues are skipped, not failed — leave in pending state
1406 self.logger.info(f"{info.issue_id} skipped — blocked by open dependency")
1407 elif result.success:
1408 self.state_manager.mark_completed(info.issue_id, {"total": result.duration})
1409 elif result.plan_created:
1410 # Don't mark as failed if a plan was created (awaiting approval)
1411 self.logger.info(
1412 f"{info.issue_id} has plan at {result.plan_path} - "
1413 "leaving in pending state for manual approval"
1414 )
1415 # Issue remains in pending state (not marked as failed)
1416 elif result.failure_reason:
1417 self.state_manager.mark_failed(info.issue_id, result.failure_reason)
1419 if result.corrections:
1420 self.state_manager.record_corrections(info.issue_id, result.corrections)
1422 return result.success