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