1"""Automated issue management for little-loops.

2

3 Provides the AutoManager class for sequential issue processing with

4 Claude CLI integration and state persistence for resume capability.

5"""

6

7 from __future__ import annotations

8

9 import json

10 import re

11 import signal

12 import subprocess

13 import sys

14 import time

15 from collections.abc import Callable, Generator

16 from contextlib import contextmanager

17 from dataclasses import dataclass, field

18 from pathlib import Path

19 from types import FrameType

20 from typing import TYPE_CHECKING

21

22 if TYPE_CHECKING:

23 from little_loops.parallel.types import SprintWorkerContext

24

25 from little_loops.cli_args import _id_matches

26 from little_loops.config import BRConfig

27 from little_loops.context_window import context_window_for

28 from little_loops.dependency_graph import DependencyGraph

29 from little_loops.events import EventBus

30 from little_loops.git_operations import check_git_status, verify_work_was_done

31 from 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)

39 from little_loops.issue_parser import IssueInfo, IssueParser, find_issues

40 from little_loops.learning_tests.extractor import resolve_learning_targets

41 from little_loops.learning_tests.gate import run_learning_gate_for_issue

42 from little_loops.logger import Logger, format_duration

43 from little_loops.output_parsing import parse_ready_issue_output

44 from little_loops.session_store import DEFAULT_DB_PATH, SQLiteTransport

45 from little_loops.skill_expander import expand_skill

46 from little_loops.state import ProcessingState, StateManager, _iso_now

47 from little_loops.subprocess_utils import (

48 assemble_guillotine_prompt,

49 detect_context_handoff,

50 read_continuation_prompt,

51 read_sentinel,

52)

53 from little_loops.subprocess_utils import (

54 run_claude_command as _run_claude_base,

55)

56

57

58 def _compute_relative_path(abs_path: Path, base_dir: Path | None = None) -> str:

59 """Compute relative path from base directory for command input.

60

61 Used for fallback retry when ready-issue resolves to wrong file -

62 allows retrying with explicit file path instead of ambiguous ID.

63

64 Args:

65 abs_path: Absolute path to the file

66 base_dir: Base directory (defaults to cwd)

67

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)

77

78

79@contextmanager

80 def timed_phase(

81 logger: Logger,

82 phase_name: str,

83) -> Generator[dict[str, float], None, None]:

84 """Context manager for timing phases.

85

86 Yields a dict that will be populated with 'elapsed' after the context exits.

87

88 Args:

89 logger: Logger for output

90 phase_name: Name of the phase being timed

91

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)}")

103

104

105 def 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.

117

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).

129

130 Returns:

131 CompletedProcess with stdout/stderr captured

132 """

133 from little_loops.cli.output import terminal_width

134

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)")

151

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}")

158

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 )

168

169

170 def _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.

175

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.

180

181 Args:

182 issue_path: Path to the issue file, or None if not available.

183 logger: Logger for diagnostic messages.

184

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

194

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

199

200

201 def 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.

218

219 Implements Options E, G, and J from BUG-1377:

220

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.

224

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.

230

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).

243

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 )

254

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

262

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)

268

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 )

281

282 all_stdout.append(result.stdout)

283 all_stderr.append(result.stderr)

284

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")

288

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

302

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")

310

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

318

319 prompt_too_long = "prompt is too long" in (result.stderr or "").lower()

320

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

419

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)

462

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

476

477 # No handoff signal, no prior-session sentinel, no overflow — done

478 break

479

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 )

486

487

488 def detect_plan_creation(output: str, issue_id: str) -> Path | None:

489 """Detect if manage-issue created a plan file awaiting approval.

490

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.

493

494 Args:

495 output: Command stdout (unused, for future pattern matching)

496 issue_id: Issue ID (e.g., "BUG-280")

497

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

504

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))

509

510 if not matching_plans:

511 return None

512

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

517

518

519 def check_content_markers(issue_path: Path) -> bool:

520 """Check if issue file content contains implementation markers.

521

522 Looks for indicators that an implementation was completed, such as

523 Resolution sections or status markers added by manage-issue.

524

525 Args:

526 issue_path: Path to the issue file

527

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

535

536 markers = [

537 "## Resolution",

538 "Status: Implemented",

539 "Status: Completed",

540 "**Completed**:",

541 ]

542 return any(marker in content for marker in markers)

543

544

545@dataclass

546 class IssueProcessingResult:

547 """Result of processing a single issue in-place."""

548

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 = ""

558

559

560 def 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.

574

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).

577

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.

587

588 Returns:

589 IssueProcessingResult with outcome details

590 """

591 issue_start_time = time.time()

592 corrections: list[str] = []

593

594 logger.header(f"Processing: {info.issue_id} - {info.title}")

595

596 issue_timing: dict[str, float] = {}

597

598 # Track whether we used fallback path resolution for ready-issue.

599 validated_via_fallback = False

600

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

605

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)

615

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']}")

637

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()

650

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 )

668

669 # Compute relative path for the command

670 relative_path = _compute_relative_path(info.path)

671

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 )

687

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 )

696

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")

700

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 )

715

716 # Fallback succeeded - use retry result

717 logger.info("Fallback succeeded: validated correct file")

718 parsed = retry_parsed

719 validated_via_fallback = True

720

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)

729

730 # Log any concerns found

731 if parsed["concerns"]:

732 for concern in parsed["concerns"]:

733 logger.warning(f" Concern: {concern}")

734

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})")

739

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 )

753

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 )

768

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 )

792

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 )

806

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 )

822

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)

829

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...")

850

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 )

871

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

885

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

891

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)

919

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

930

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)

946

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])

955

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 )

963

964 # Real failure - create issue as before

965 logger.error(f"Implementation failed for {info.issue_id}")

966

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")

980

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 )

988

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)

995

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 )

1017

1018 logger.info(

1019 "Command returned success but issue not moved - "

1020 "checking for evidence of work..."

1021 )

1022

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)

1061

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)}")

1066

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)")

1072

1073 return IssueProcessingResult(

1074 success=verified,

1075 duration=total_issue_time,

1076 issue_id=info.issue_id,

1077 corrections=corrections,

1078 )

1079

1080

1081 class AutoManager:

1082 """Automated issue manager for sequential processing.

1083

1084 Processes issues in priority order using Claude CLI commands,

1085 with state persistence for resume capability.

1086 """

1087

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.

1106

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

1134

1135 from little_loops.cli.output import use_color_enabled

1136

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] = []

1145

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

1152

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)

1158

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)}")

1164

1165 self.processed_count = 0

1166 self._shutdown_requested = False

1167

1168 signal.signal(signal.SIGINT, self._signal_handler)

1169 signal.signal(signal.SIGTERM, self._signal_handler)

1170

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...")

1175

1176 def _get_next_issue(self) -> IssueInfo | None:

1177 """Get next issue respecting dependencies.

1178

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.

1182

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)

1188

1189 # Combine skip_ids from state and CLI argument

1190 skip_ids = self.state_manager.state.attempted_issues | self.skip_ids

1191

1192 # Get issues that are ready (blockers satisfied)

1193 ready_issues = self.dep_graph.get_ready_issues(completed)

1194

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 ]

1207

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]

1219

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 }

1237

1238 if remaining:

1239 self._log_blocked_issues(remaining, completed)

1240

1241 return None

1242

1243 def _log_blocked_issues(self, remaining: set[str], completed: set[str]) -> None:

1244 """Log information about blocked issues when processing stalls.

1245

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))}")

1256

1257 if blocked_count > 0:

1258 self.logger.warning(f"{blocked_count} issue(s) remain blocked - check dependencies")

1259

1260 def run(self) -> int:

1261 """Run the automation loop.

1262

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...")

1268

1269 if self.dry_run:

1270 self.logger.info("DRY RUN MODE - No actual changes will be made")

1271

1272 if not self.dry_run:

1273 has_changes = check_git_status(self.logger)

1274 if has_changes:

1275 self.logger.warning("Proceeding anyway...")

1276

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())

1286

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

1293

1294 info = self._get_next_issue()

1295 if not info:

1296 self.logger.success("No more issues to process!")

1297 break

1298

1299 attempted_count += 1

1300 success = self._process_issue(info)

1301 if success:

1302 self.processed_count += 1

1303

1304 except Exception as e:

1305 self.logger.error(f"Fatal error: {e}")

1306 return 1

1307

1308 finally:

1309 if not self._shutdown_requested:

1310 self.state_manager.cleanup()

1311 self.event_bus.close_transports()

1312

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

1318

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

1322

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}")

1327

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)}")

1334

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]}...")

1339

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 )

1348

1349 # Log most common correction types

1350 from collections import Counter

1351

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}")

1362

1363 def _process_issue(self, info: IssueInfo) -> bool:

1364 """Process a single issue through the workflow.

1365

1366 Delegates to process_issue_inplace() and maps the result back

1367 to state manager calls.

1368

1369 Args:

1370 info: Issue information

1371

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")

1378

1379 on_model: Callable[[str], None] | None = None

1380 if not self._detected_model:

1381

1382 def on_model(m: str) -> None:

1383 self._detected_model.append(m)

1384 self.logger.info(f"model: {m}")

1385

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 )

1400

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)

1418

1419 if result.corrections:

1420 self.state_manager.record_corrections(info.issue_id, result.corrections)

1421

1422 return result.success