Coverage for src / core / protocols.py: 100%

282 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-01-04 04:43 +0000

1"""Protocol definitions for pipeline stage abstractions. 

2 

3This module defines Protocol classes that enable dependency injection and 

4testability for the MalaOrchestrator's pipeline stages. Each protocol 

5represents a stage boundary that the orchestrator interacts with. 

6 

7Design principles: 

8- Protocols use structural typing (typing.Protocol) for flexibility 

9- Methods match exactly what the orchestrator actually calls 

10- Result types are defined as local Protocol types to avoid import-time dependencies 

11- BeadsClient, ReviewRunner, and QualityGate conform to these protocols 

12 

13Usage: 

14 These protocols enable: 

15 1. Mock implementations for unit testing the orchestrator 

16 2. Alternative implementations (e.g., in-memory issue tracker) 

17 3. Clear contracts between orchestrator and its dependencies 

18""" 

19 

20from __future__ import annotations 

21 

22from dataclasses import dataclass 

23from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable 

24 

25if TYPE_CHECKING: 

26 from collections.abc import AsyncIterator, Iterator, Mapping, Sequence 

27 from pathlib import Path 

28 from types import TracebackType 

29 from typing import Self 

30 

31 

32# ============================================================================= 

33# Local Protocol Types 

34# ============================================================================= 

35# These Protocol types replace TYPE_CHECKING imports from domain/infra modules 

36# to satisfy the "Layered Architecture" contract. They define the structural 

37# shape that protocols.py needs without creating import-time dependencies. 

38# 

39# Each Protocol matches only the attributes/methods that protocols.py actually 

40# uses, following the Interface Segregation Principle. 

41# 

42# Note: These protocols use plain attributes (not @property) to be compatible 

43# with dataclass implementations. Protocol structural typing matches attributes 

44# regardless of whether they're defined as properties or plain attributes. 

45# ============================================================================= 

46 

47 

48@runtime_checkable 

49class JsonlEntryProtocol(Protocol): 

50 """Protocol for parsed JSONL log entries with byte offset tracking. 

51 

52 Matches the shape of session_log_parser.JsonlEntry for structural typing. 

53 """ 

54 

55 data: dict[str, Any] 

56 """The parsed JSON object from this line.""" 

57 

58 entry: object | None 

59 """The typed LogEntry if successfully parsed, None otherwise.""" 

60 

61 line_len: int 

62 """Length of the raw line in bytes (for offset tracking).""" 

63 

64 offset: int 

65 """Byte offset where this line started in the file.""" 

66 

67 

68@runtime_checkable 

69class ValidationSpecProtocol(Protocol): 

70 """Protocol for validation specification. 

71 

72 Matches the shape of validation.spec.ValidationSpec for structural typing. 

73 Only includes attributes/methods that protocols.py method signatures use. 

74 """ 

75 

76 commands: Sequence[Any] 

77 """List of validation commands to run.""" 

78 

79 scope: Any 

80 """The validation scope (per-issue or run-level).""" 

81 

82 

83@runtime_checkable 

84class ValidationEvidenceProtocol(Protocol): 

85 """Protocol for validation evidence from agent runs. 

86 

87 Matches the shape of quality_gate.ValidationEvidence for structural typing. 

88 """ 

89 

90 commands_ran: dict[Any, bool] 

91 """Mapping of CommandKind to whether it ran.""" 

92 

93 failed_commands: list[str] 

94 """List of validation commands that failed.""" 

95 

96 def has_any_evidence(self) -> bool: 

97 """Check if any validation command ran.""" 

98 ... 

99 

100 def to_evidence_dict(self) -> dict[str, bool]: 

101 """Convert evidence to a serializable dict keyed by CommandKind value.""" 

102 ... 

103 

104 

105@runtime_checkable 

106class CommitResultProtocol(Protocol): 

107 """Protocol for commit existence check results. 

108 

109 Matches the shape of quality_gate.CommitResult for structural typing. 

110 """ 

111 

112 exists: bool 

113 """Whether a matching commit exists.""" 

114 

115 commit_hash: str | None 

116 """The commit hash if found.""" 

117 

118 message: str | None 

119 """The commit message if found.""" 

120 

121 

122@runtime_checkable 

123class IssueResolutionProtocol(Protocol): 

124 """Protocol for issue resolution records. 

125 

126 Matches the shape of models.IssueResolution for structural typing. 

127 """ 

128 

129 outcome: Any 

130 """The resolution outcome (success, no_change, obsolete, etc.).""" 

131 

132 rationale: str 

133 """Explanation for the resolution.""" 

134 

135 

136@runtime_checkable 

137class GateResultProtocol(Protocol): 

138 """Protocol for quality gate check results. 

139 

140 Matches the shape of quality_gate.GateResult for structural typing. 

141 """ 

142 

143 passed: bool 

144 """Whether the quality gate passed.""" 

145 

146 failure_reasons: list[str] 

147 """List of reasons why the gate failed.""" 

148 

149 commit_hash: str | None 

150 """The commit hash if found.""" 

151 

152 validation_evidence: ValidationEvidenceProtocol | None 

153 """Evidence of validation commands executed.""" 

154 

155 no_progress: bool 

156 """Whether no progress was detected.""" 

157 

158 resolution: IssueResolutionProtocol | None 

159 """Issue resolution if applicable.""" 

160 

161 

162@runtime_checkable 

163class ReviewIssueProtocol(Protocol): 

164 """Protocol for review issues found during code review. 

165 

166 Matches the shape of cerberus_review.ReviewIssue for structural typing. 

167 """ 

168 

169 file: str 

170 """File path where the issue was found.""" 

171 

172 line_start: int 

173 """Starting line number.""" 

174 

175 line_end: int 

176 """Ending line number.""" 

177 

178 priority: int | None 

179 """Issue priority (0=P0, 1=P1, etc.).""" 

180 

181 title: str 

182 """Issue title.""" 

183 

184 body: str 

185 """Issue body/description.""" 

186 

187 reviewer: str 

188 """Which reviewer found this issue.""" 

189 

190 

191@runtime_checkable 

192class ReviewResultProtocol(Protocol): 

193 """Protocol for code review results. 

194 

195 Matches the shape of cerberus_review.ReviewResult for structural typing. 

196 """ 

197 

198 passed: bool 

199 """Whether the review passed.""" 

200 

201 issues: Sequence[ReviewIssueProtocol] 

202 """List of issues found during review.""" 

203 

204 parse_error: str | None 

205 """Parse error message if JSON parsing failed.""" 

206 

207 fatal_error: bool 

208 """Whether this is a fatal error (should not retry).""" 

209 

210 review_log_path: Path | None 

211 """Path to review session logs.""" 

212 

213 

214@runtime_checkable 

215class UnmetCriterionProtocol(Protocol): 

216 """Protocol for unmet criteria during epic verification. 

217 

218 Matches the shape of models.UnmetCriterion for structural typing. 

219 """ 

220 

221 criterion: str 

222 """The acceptance criterion not met.""" 

223 

224 evidence: str 

225 """Why it's considered unmet.""" 

226 

227 priority: int 

228 """Issue priority matching Cerberus levels (0-3). P0/P1 blocking, P2/P3 informational.""" 

229 

230 criterion_hash: str 

231 """SHA256 of criterion text, for deduplication.""" 

232 

233 

234@runtime_checkable 

235class EpicVerdictProtocol(Protocol): 

236 """Protocol for epic verification verdicts. 

237 

238 Matches the shape of models.EpicVerdict for structural typing. 

239 """ 

240 

241 passed: bool 

242 """Whether all acceptance criteria were met.""" 

243 

244 unmet_criteria: Sequence[UnmetCriterionProtocol] 

245 """List of criteria that were not satisfied.""" 

246 

247 confidence: float 

248 """Model confidence in the verdict (0.0 to 1.0).""" 

249 

250 reasoning: str 

251 """Explanation of the verification outcome.""" 

252 

253 

254@runtime_checkable 

255class DeadlockInfoProtocol(Protocol): 

256 """Protocol for deadlock detection information. 

257 

258 Matches the shape of domain.deadlock.DeadlockInfo for structural typing. 

259 """ 

260 

261 cycle: list[str] 

262 """List of agent IDs forming the deadlock cycle.""" 

263 

264 victim_id: str 

265 """Agent ID selected to be killed (youngest in cycle).""" 

266 

267 victim_issue_id: str | None 

268 """Issue ID the victim was working on.""" 

269 

270 blocked_on: str 

271 """Lock path the victim was waiting for.""" 

272 

273 blocker_id: str 

274 """Agent ID holding the lock the victim needs.""" 

275 

276 blocker_issue_id: str | None 

277 """Issue ID the blocker was working on.""" 

278 

279 

280@runtime_checkable 

281class LockEventProtocol(Protocol): 

282 """Protocol for lock events. 

283 

284 Matches the shape of core.models.LockEvent for structural typing. 

285 """ 

286 

287 event_type: Any 

288 """Type of lock event (LockEventType enum value).""" 

289 

290 agent_id: str 

291 """ID of the agent that emitted this event.""" 

292 

293 lock_path: str 

294 """Path to the lock file.""" 

295 

296 timestamp: float 

297 """Unix timestamp when the event occurred.""" 

298 

299 

300@runtime_checkable 

301class DeadlockMonitorProtocol(Protocol): 

302 """Protocol for deadlock monitor. 

303 

304 Matches the interface of domain.deadlock.DeadlockMonitor for structural typing. 

305 Only includes the handle_event method used by hooks. 

306 """ 

307 

308 async def handle_event(self, event: Any) -> Any: # noqa: ANN401 

309 """Process a lock event and check for deadlocks. 

310 

311 Args: 

312 event: The lock event to process (LockEvent). 

313 

314 Returns: 

315 DeadlockInfo if a deadlock is detected, None otherwise. 

316 """ 

317 ... 

318 

319 

320@runtime_checkable 

321class LogProvider(Protocol): 

322 """Protocol for abstracting SDK log storage and schema. 

323 

324 Provides methods for accessing session logs without hardcoding filesystem 

325 paths or Claude SDK's internal log format. This enables: 

326 - Testing with mock log providers that return synthetic events 

327 - Future support for remote log storage or SDK API access 

328 - Isolation from SDK log format changes 

329 

330 The canonical implementation is FileSystemLogProvider, which reads JSONL 

331 logs from the Claude SDK's ~/.claude/projects/{encoded-path}/ directory. 

332 Test implementations can return in-memory events for isolation. 

333 

334 Methods: 

335 get_log_path: Get the filesystem path for a session log. 

336 iter_events: Iterate over typed log entries from a session. 

337 get_end_offset: Get the byte offset at the end of a log file. 

338 """ 

339 

340 def get_log_path(self, repo_path: Path, session_id: str) -> Path: 

341 """Get the filesystem path for a session's log file. 

342 

343 This method computes the expected log file path based on the repo 

344 and session. The path may or may not exist yet. 

345 

346 Args: 

347 repo_path: Path to the repository the session was run in. 

348 session_id: Claude SDK session ID (UUID from ResultMessage). 

349 

350 Returns: 

351 Path to the JSONL log file. 

352 """ 

353 ... 

354 

355 def iter_events( 

356 self, log_path: Path, offset: int = 0 

357 ) -> Iterator[JsonlEntryProtocol]: 

358 """Iterate over parsed JSONL entries from a log file. 

359 

360 Reads the file starting from the given byte offset and yields 

361 structured entries. This enables incremental parsing across 

362 retry attempts. 

363 

364 Args: 

365 log_path: Path to the JSONL log file. 

366 offset: Byte offset to start reading from (default 0). 

367 

368 Yields: 

369 JsonlEntryProtocol objects for each successfully parsed JSON line. 

370 The entry field contains the typed LogEntry if parsing succeeded. 

371 

372 Note: 

373 - Lines that fail UTF-8 decoding are silently skipped 

374 - Empty lines are silently skipped 

375 - Lines that fail JSON parsing are silently skipped 

376 - If file doesn't exist, yields nothing 

377 """ 

378 ... 

379 

380 def get_end_offset(self, log_path: Path, start_offset: int = 0) -> int: 

381 """Get the byte offset at the end of a log file. 

382 

383 This is a lightweight method for getting the current file position. 

384 Use this when you only need the offset for retry scoping, not the 

385 parsed entries themselves. 

386 

387 Args: 

388 log_path: Path to the JSONL log file. 

389 start_offset: Byte offset to start from (default 0). 

390 

391 Returns: 

392 The byte offset at the end of the file, or start_offset if file 

393 doesn't exist or can't be read. 

394 """ 

395 ... 

396 

397 def extract_bash_commands(self, entry: JsonlEntryProtocol) -> list[tuple[str, str]]: 

398 """Extract Bash tool_use commands from an entry. 

399 

400 Args: 

401 entry: A JsonlEntryProtocol from iter_events. 

402 

403 Returns: 

404 List of (tool_id, command) tuples for Bash tool_use blocks. 

405 Returns empty list if entry is not an assistant message. 

406 """ 

407 ... 

408 

409 def extract_tool_results(self, entry: JsonlEntryProtocol) -> list[tuple[str, bool]]: 

410 """Extract tool_result entries from an entry. 

411 

412 Args: 

413 entry: A JsonlEntryProtocol from iter_events. 

414 

415 Returns: 

416 List of (tool_use_id, is_error) tuples for tool_result blocks. 

417 Returns empty list if entry is not a user message. 

418 """ 

419 ... 

420 

421 def extract_assistant_text_blocks(self, entry: JsonlEntryProtocol) -> list[str]: 

422 """Extract text content from assistant message blocks. 

423 

424 Args: 

425 entry: A JsonlEntryProtocol from iter_events. 

426 

427 Returns: 

428 List of text strings from text blocks in assistant messages. 

429 Returns empty list if entry is not an assistant message. 

430 """ 

431 ... 

432 

433 

434@runtime_checkable 

435class IssueProvider(Protocol): 

436 """Protocol for issue tracking operations. 

437 

438 Provides methods for fetching, claiming, closing, and marking issues. 

439 The orchestrator uses this to manage issue lifecycle during parallel 

440 processing. 

441 

442 The canonical implementation is BeadsClient, which wraps the bd CLI. 

443 Test implementations can use in-memory state for isolation. 

444 

445 Methods match BeadsClient's async API exactly so BeadsClient conforms 

446 to this protocol without adaptation. 

447 """ 

448 

449 async def get_ready_async( 

450 self, 

451 exclude_ids: set[str] | None = None, 

452 epic_id: str | None = None, 

453 only_ids: set[str] | None = None, 

454 suppress_warn_ids: set[str] | None = None, 

455 prioritize_wip: bool = False, 

456 focus: bool = True, 

457 orphans_only: bool = False, 

458 ) -> list[str]: 

459 """Get list of ready issue IDs, sorted by priority. 

460 

461 Args: 

462 exclude_ids: Set of issue IDs to exclude from results. 

463 epic_id: Optional epic ID to filter by - only return children. 

464 only_ids: Optional set of issue IDs to include exclusively. 

465 suppress_warn_ids: Set of issue IDs to suppress from warnings. 

466 prioritize_wip: If True, sort in_progress issues first. 

467 focus: If True, group tasks by parent epic. 

468 orphans_only: If True, only return issues with no parent epic. 

469 

470 Returns: 

471 List of issue IDs sorted by priority (lower = higher priority). 

472 """ 

473 ... 

474 

475 async def claim_async(self, issue_id: str) -> bool: 

476 """Claim an issue by setting status to in_progress. 

477 

478 Args: 

479 issue_id: The issue ID to claim. 

480 

481 Returns: 

482 True if successfully claimed, False otherwise. 

483 """ 

484 ... 

485 

486 async def close_async(self, issue_id: str) -> bool: 

487 """Close an issue by setting status to closed. 

488 

489 Args: 

490 issue_id: The issue ID to close. 

491 

492 Returns: 

493 True if successfully closed, False otherwise. 

494 """ 

495 ... 

496 

497 async def mark_needs_followup_async( 

498 self, issue_id: str, reason: str, log_path: Path | None = None 

499 ) -> bool: 

500 """Mark an issue as needing follow-up. 

501 

502 Called when the quality gate fails and the issue needs manual 

503 intervention or a follow-up task. 

504 

505 Args: 

506 issue_id: The issue ID to mark. 

507 reason: Description of why the quality gate failed. 

508 log_path: Optional path to the JSONL log file from the attempt. 

509 

510 Returns: 

511 True if successfully marked, False otherwise. 

512 """ 

513 ... 

514 

515 async def add_dependency_async(self, issue_id: str, depends_on_id: str) -> bool: 

516 """Add a dependency between two issues. 

517 

518 Creates a "blocks" relationship where depends_on_id blocks issue_id. 

519 Used by deadlock resolution to record that a victim issue depends on 

520 the blocker's issue. 

521 

522 Args: 

523 issue_id: The issue that depends on another. 

524 depends_on_id: The issue that blocks issue_id. 

525 

526 Returns: 

527 True if dependency added successfully, False otherwise. 

528 """ 

529 ... 

530 

531 async def get_issue_description_async(self, issue_id: str) -> str | None: 

532 """Get the description of an issue. 

533 

534 Args: 

535 issue_id: The issue ID to get description for. 

536 

537 Returns: 

538 The issue description string, or None if not found. 

539 """ 

540 ... 

541 

542 async def close_eligible_epics_async(self) -> bool: 

543 """Auto-close epics where all children are complete. 

544 

545 Returns: 

546 True if any epics were closed, False otherwise. 

547 """ 

548 ... 

549 

550 async def commit_issues_async(self) -> bool: 

551 """Commit .beads/issues.jsonl if it has changes. 

552 

553 Returns: 

554 True if commit succeeded, False otherwise. 

555 """ 

556 ... 

557 

558 async def reset_async( 

559 self, issue_id: str, log_path: Path | None = None, error: str | None = None 

560 ) -> bool: 

561 """Reset an issue back to ready status. 

562 

563 Called when an implementation attempt fails and the issue should be 

564 made available for retry. 

565 

566 Args: 

567 issue_id: The issue ID to reset. 

568 log_path: Optional path to the JSONL log file from the attempt. 

569 error: Optional error message describing the failure. 

570 

571 Returns: 

572 True if successfully reset, False otherwise. 

573 """ 

574 ... 

575 

576 async def get_epic_children_async(self, epic_id: str) -> set[str]: 

577 """Get all child issue IDs of an epic. 

578 

579 Args: 

580 epic_id: The epic ID to get children for. 

581 

582 Returns: 

583 Set of child issue IDs, or empty set if not found or on error. 

584 """ 

585 ... 

586 

587 async def get_parent_epic_async(self, issue_id: str) -> str | None: 

588 """Get the parent epic ID for an issue. 

589 

590 Args: 

591 issue_id: The issue ID to find the parent epic for. 

592 

593 Returns: 

594 The parent epic ID, or None if no parent epic exists (orphan). 

595 """ 

596 ... 

597 

598 async def create_issue_async( 

599 self, 

600 title: str, 

601 description: str, 

602 priority: str, 

603 tags: list[str] | None = None, 

604 parent_id: str | None = None, 

605 ) -> str | None: 

606 """Create a new issue for tracking. 

607 

608 Used to create tracking issues for low-priority review findings (P2/P3) 

609 that should be addressed later but don't block the current work. 

610 

611 Args: 

612 title: Issue title. 

613 description: Issue description (supports markdown). 

614 priority: Priority string (P1, P2, P3, etc.). 

615 tags: Optional list of tags to apply. 

616 parent_id: Optional parent epic ID to attach this issue to. 

617 

618 Returns: 

619 Created issue ID, or None on failure. 

620 """ 

621 ... 

622 

623 async def find_issue_by_tag_async(self, tag: str) -> str | None: 

624 """Find an existing issue with the given tag. 

625 

626 Used for deduplication when creating tracking issues. 

627 

628 Args: 

629 tag: The tag to search for. 

630 

631 Returns: 

632 Issue ID if found, None otherwise. 

633 """ 

634 ... 

635 

636 async def update_issue_description_async( 

637 self, issue_id: str, description: str 

638 ) -> bool: 

639 """Update an issue's description. 

640 

641 Used for appending new findings to existing tracking issues. 

642 

643 Args: 

644 issue_id: The issue ID to update. 

645 description: New description content (replaces existing). 

646 

647 Returns: 

648 True if successfully updated, False otherwise. 

649 """ 

650 ... 

651 

652 async def update_issue_async( 

653 self, 

654 issue_id: str, 

655 *, 

656 title: str | None = None, 

657 priority: str | None = None, 

658 ) -> bool: 

659 """Update an issue's title and/or priority. 

660 

661 Used for updating tracking issues when new findings change 

662 the count or highest priority. 

663 

664 Args: 

665 issue_id: The issue ID to update. 

666 title: New title (optional). 

667 priority: New priority string like "P2" (optional). 

668 

669 Returns: 

670 True if successfully updated, False otherwise. 

671 """ 

672 ... 

673 

674 

675@runtime_checkable 

676class CodeReviewer(Protocol): 

677 """Protocol for code review operations. 

678 

679 Provides a callable interface for reviewing commits and returning 

680 structured results. The orchestrator uses this to run post-commit 

681 code reviews via the Cerberus review-gate CLI. 

682 

683 The canonical implementation is DefaultReviewer in cerberus_review.py. 

684 Test implementations can return predetermined results for isolation. 

685 """ 

686 

687 async def __call__( 

688 self, 

689 diff_range: str, 

690 context_file: Path | None = None, 

691 timeout: int = 300, 

692 claude_session_id: str | None = None, 

693 *, 

694 commit_shas: Sequence[str] | None = None, 

695 ) -> ReviewResultProtocol: 

696 """Run code review on a diff range. 

697 

698 Args: 

699 diff_range: Git diff range to review (e.g., "baseline..HEAD"). 

700 context_file: Optional path to file with issue description context. 

701 timeout: Timeout in seconds for the review operation. 

702 claude_session_id: Optional Claude session ID for review attribution. 

703 commit_shas: Optional list of commit SHAs to review directly. 

704 When provided, reviewers should scope to these commits only. 

705 

706 Returns: 

707 ReviewResultProtocol with review outcome. On parse failure, 

708 returns passed=False with parse_error set. 

709 """ 

710 ... 

711 

712 

713@runtime_checkable 

714class GateChecker(Protocol): 

715 """Protocol for quality gate checking. 

716 

717 Provides methods for verifying agent work meets quality requirements. 

718 The orchestrator uses this after each agent attempt to determine if 

719 the issue was successfully resolved. 

720 

721 The canonical implementation is QualityGate, which conforms to this 

722 protocol. Test implementations can verify specific conditions for isolation. 

723 

724 Methods match QualityGate's API exactly so QualityGate conforms to this 

725 protocol without adaptation. 

726 """ 

727 

728 def check_with_resolution( 

729 self, 

730 issue_id: str, 

731 log_path: Path, 

732 baseline_timestamp: int | None = None, 

733 log_offset: int = 0, 

734 spec: ValidationSpecProtocol | None = None, 

735 ) -> GateResultProtocol: 

736 """Run quality gate check with support for no-op/obsolete resolutions. 

737 

738 This method is scope-aware and handles special resolution outcomes: 

739 - ISSUE_NO_CHANGE: Issue already addressed, no commit needed 

740 - ISSUE_OBSOLETE: Issue no longer relevant, no commit needed 

741 - ISSUE_ALREADY_COMPLETE: Work done in previous run 

742 

743 Args: 

744 issue_id: The issue ID to verify. 

745 log_path: Path to the JSONL log file from agent session. 

746 baseline_timestamp: Unix timestamp for commit freshness check. 

747 log_offset: Byte offset to start parsing from. 

748 spec: ValidationSpec for scope-aware evidence checking (required). 

749 

750 Returns: 

751 GateResultProtocol with pass/fail, failure reasons, and resolution. 

752 

753 Raises: 

754 ValueError: If spec is not provided. 

755 """ 

756 ... 

757 

758 def get_log_end_offset(self, log_path: Path, start_offset: int = 0) -> int: 

759 """Get the byte offset at the end of a log file. 

760 

761 This is a lightweight method for getting the current file position 

762 after reading from a given offset. Use this when you only need the 

763 offset for retry scoping, not the evidence itself. 

764 

765 Args: 

766 log_path: Path to the JSONL log file. 

767 start_offset: Byte offset to start from (default 0). 

768 

769 Returns: 

770 The byte offset at the end of the file, or start_offset if file 

771 doesn't exist or can't be read. 

772 """ 

773 ... 

774 

775 def check_no_progress( 

776 self, 

777 log_path: Path, 

778 log_offset: int, 

779 previous_commit_hash: str | None, 

780 current_commit_hash: str | None, 

781 spec: ValidationSpecProtocol | None = None, 

782 check_validation_evidence: bool = True, 

783 ) -> bool: 

784 """Check if no progress was made since the last attempt. 

785 

786 No progress is detected when ALL of these are true: 

787 - The commit hash hasn't changed (or both are None) 

788 - No uncommitted changes in the working tree 

789 - (Optionally) No new validation evidence was found after the log offset 

790 

791 Args: 

792 log_path: Path to the JSONL log file from agent session. 

793 log_offset: Byte offset marking the end of the previous attempt. 

794 previous_commit_hash: Commit hash from the previous attempt. 

795 current_commit_hash: Commit hash from this attempt. 

796 spec: Optional ValidationSpec for spec-driven evidence detection. 

797 check_validation_evidence: If True (default), also check for new validation 

798 evidence. Set to False for review retries where only commit/working-tree 

799 changes should gate progress. 

800 

801 Returns: 

802 True if no progress was made, False if progress was detected. 

803 """ 

804 ... 

805 

806 def parse_validation_evidence_with_spec( 

807 self, log_path: Path, spec: ValidationSpecProtocol, offset: int = 0 

808 ) -> ValidationEvidenceProtocol: 

809 """Parse JSONL log for validation evidence using spec-defined patterns. 

810 

811 Args: 

812 log_path: Path to the JSONL log file. 

813 spec: ValidationSpec defining detection patterns. 

814 offset: Byte offset to start parsing from (default 0). 

815 

816 Returns: 

817 ValidationEvidenceProtocol with flags indicating which validations ran. 

818 """ 

819 ... 

820 

821 def check_commit_exists( 

822 self, issue_id: str, baseline_timestamp: int | None = None 

823 ) -> CommitResultProtocol: 

824 """Check if a commit with bd-<issue_id> exists in recent history. 

825 

826 Searches commits from the last 30 days to accommodate long-running 

827 work that may span multiple days. 

828 

829 Args: 

830 issue_id: The issue ID to search for (without bd- prefix). 

831 baseline_timestamp: Unix timestamp. If provided, only accepts 

832 commits created after this time. 

833 

834 Returns: 

835 CommitResultProtocol indicating whether a matching commit exists. 

836 """ 

837 ... 

838 

839 

840@runtime_checkable 

841class EpicVerificationModel(Protocol): 

842 """Protocol for model-agnostic epic verification. 

843 

844 Provides an interface for verifying whether code changes satisfy 

845 an epic's acceptance criteria. The initial implementation uses 

846 Claude via SDK, but this protocol allows swapping to other models 

847 (Codex, Gemini, local models) without changing the verifier. 

848 

849 The canonical implementation is ClaudeEpicVerificationModel in 

850 src/epic_verifier.py. Test implementations can return predetermined 

851 verdicts for isolation. 

852 """ 

853 

854 async def verify( 

855 self, 

856 epic_criteria: str, 

857 commit_range: str, 

858 commit_list: str, 

859 spec_content: str | None, 

860 ) -> EpicVerdictProtocol: 

861 """Verify if the commit scope satisfies the epic's acceptance criteria. 

862 

863 Args: 

864 epic_criteria: The epic's acceptance criteria text. 

865 commit_range: Commit range hint covering child issue commits. 

866 commit_list: Authoritative list of commit SHAs to inspect. 

867 spec_content: Optional content of linked spec file. 

868 

869 Returns: 

870 Structured verdict with pass/fail and unmet criteria details. 

871 """ 

872 ... 

873 

874 

875# ============================================================================= 

876# SDK Client Protocol 

877# ============================================================================= 

878 

879 

880@runtime_checkable 

881class SDKClientProtocol(Protocol): 

882 """Protocol for Claude SDK client abstraction. 

883 

884 Enables the pipeline layer to use SDK clients without importing 

885 claude_agent_sdk directly. The canonical implementation is 

886 ClaudeSDKClient, wrapped by SDKClientFactory in infra. 

887 

888 This protocol captures the async context manager and streaming 

889 interface used by AgentSessionRunner. 

890 """ 

891 

892 async def __aenter__(self) -> Self: 

893 """Enter async context.""" 

894 ... 

895 

896 async def __aexit__( 

897 self, 

898 exc_type: type[BaseException] | None, 

899 exc_val: BaseException | None, 

900 exc_tb: TracebackType | None, 

901 ) -> None: 

902 """Exit async context.""" 

903 ... 

904 

905 async def query(self, prompt: str, session_id: str | None = None) -> None: 

906 """Send a query to the agent. 

907 

908 Args: 

909 prompt: The prompt text to send. 

910 session_id: Optional session ID for continuation. 

911 """ 

912 ... 

913 

914 def receive_response(self) -> AsyncIterator[object]: 

915 """Get an async iterator of response messages. 

916 

917 Returns: 

918 AsyncIterator yielding AssistantMessage, ResultMessage, etc. 

919 """ 

920 ... 

921 

922 async def disconnect(self) -> None: 

923 """Disconnect the client.""" 

924 ... 

925 

926 

927@runtime_checkable 

928class SDKClientFactoryProtocol(Protocol): 

929 """Protocol for SDK client factory. 

930 

931 Enables dependency injection of the factory into pipeline components, 

932 allowing tests to provide mock factories. 

933 """ 

934 

935 def create(self, options: object) -> SDKClientProtocol: 

936 """Create a new SDK client with the given options. 

937 

938 Args: 

939 options: ClaudeAgentOptions for the client. 

940 

941 Returns: 

942 SDKClientProtocol instance. 

943 """ 

944 ... 

945 

946 def create_options( 

947 self, 

948 *, 

949 cwd: str, 

950 permission_mode: str = "bypassPermissions", 

951 model: str = "opus", 

952 system_prompt: dict[str, str] | None = None, 

953 setting_sources: list[str] | None = None, 

954 mcp_servers: object | None = None, 

955 disallowed_tools: list[str] | None = None, 

956 env: dict[str, str] | None = None, 

957 hooks: dict[str, list[object]] | None = None, 

958 ) -> object: 

959 """Create SDK options without requiring SDK import in caller. 

960 

961 Args: 

962 cwd: Working directory for the agent. 

963 permission_mode: Permission mode. 

964 model: Model to use. 

965 system_prompt: System prompt configuration. 

966 setting_sources: List of setting sources. 

967 mcp_servers: List of MCP server configurations. 

968 disallowed_tools: List of tools to disallow. 

969 env: Environment variables for the agent. 

970 hooks: Hook configurations keyed by event type. 

971 

972 Returns: 

973 ClaudeAgentOptions instance. 

974 """ 

975 ... 

976 

977 def create_hook_matcher( 

978 self, 

979 matcher: object | None, 

980 hooks: list[object], 

981 ) -> object: 

982 """Create a HookMatcher for SDK hook registration. 

983 

984 Args: 

985 matcher: Optional matcher configuration. 

986 hooks: List of hook callables. 

987 

988 Returns: 

989 HookMatcher instance. 

990 """ 

991 ... 

992 

993 

994# ============================================================================= 

995# Command Runner Protocols 

996# ============================================================================= 

997 

998 

999@runtime_checkable 

1000class CommandResultProtocol(Protocol): 

1001 """Protocol for command execution results. 

1002 

1003 Matches the interface of src.infra.tools.command_runner.CommandResult 

1004 for structural typing without import-time dependencies. 

1005 """ 

1006 

1007 ok: bool 

1008 returncode: int 

1009 stdout: str 

1010 stderr: str 

1011 timed_out: bool 

1012 duration_seconds: float 

1013 

1014 def stdout_tail(self, max_chars: int = 800, max_lines: int = 20) -> str: 

1015 """Get truncated stdout. 

1016 

1017 Args: 

1018 max_chars: Maximum number of characters. 

1019 max_lines: Maximum number of lines. 

1020 

1021 Returns: 

1022 Truncated stdout string. 

1023 """ 

1024 ... 

1025 

1026 def stderr_tail(self, max_chars: int = 800, max_lines: int = 20) -> str: 

1027 """Get truncated stderr. 

1028 

1029 Args: 

1030 max_chars: Maximum number of characters. 

1031 max_lines: Maximum number of lines. 

1032 

1033 Returns: 

1034 Truncated stderr string. 

1035 """ 

1036 ... 

1037 

1038 

1039@runtime_checkable 

1040class CommandRunnerPort(Protocol): 

1041 """Protocol for abstracting command execution. 

1042 

1043 Enables dependency injection of command runners into domain modules, 

1044 allowing the core layer to define the interface without depending on 

1045 the infrastructure implementation. 

1046 

1047 The canonical implementation is CommandRunner in 

1048 src/infra/tools/command_runner.py. 

1049 """ 

1050 

1051 def run( 

1052 self, 

1053 cmd: list[str] | str, 

1054 env: Mapping[str, str] | None = None, 

1055 timeout: float | None = None, 

1056 use_process_group: bool | None = None, 

1057 shell: bool = False, 

1058 cwd: Path | None = None, 

1059 ) -> CommandResultProtocol: 

1060 """Run a command synchronously. 

1061 

1062 Args: 

1063 cmd: Command to run. Can be a list of strings or a shell string. 

1064 env: Environment variables to set (merged with os.environ). 

1065 timeout: Timeout for command execution in seconds. 

1066 use_process_group: Whether to use process group for termination. 

1067 shell: If True, run command through shell. 

1068 cwd: Override working directory for this command. 

1069 

1070 Returns: 

1071 CommandResultProtocol with execution details. 

1072 """ 

1073 ... 

1074 

1075 async def run_async( 

1076 self, 

1077 cmd: list[str] | str, 

1078 env: Mapping[str, str] | None = None, 

1079 timeout: float | None = None, 

1080 use_process_group: bool | None = None, 

1081 shell: bool = False, 

1082 cwd: Path | None = None, 

1083 ) -> CommandResultProtocol: 

1084 """Run a command asynchronously. 

1085 

1086 Args: 

1087 cmd: Command to run. Can be a list of strings or a shell string. 

1088 env: Environment variables to set (merged with os.environ). 

1089 timeout: Timeout for command execution in seconds. 

1090 use_process_group: Whether to use process group for termination. 

1091 shell: If True, run command through shell. 

1092 cwd: Override working directory for this command. 

1093 

1094 Returns: 

1095 CommandResultProtocol with execution details. 

1096 """ 

1097 ... 

1098 

1099 

1100@runtime_checkable 

1101class EnvConfigPort(Protocol): 

1102 """Protocol for abstracting environment configuration. 

1103 

1104 Enables dependency injection of environment config into domain modules, 

1105 allowing the core layer to define the interface without depending on 

1106 the infrastructure implementation. 

1107 

1108 The canonical implementation is in src/infra/tools/env.py. 

1109 """ 

1110 

1111 @property 

1112 def scripts_dir(self) -> Path: 

1113 """Path to the scripts directory (e.g., test-mutex.sh).""" 

1114 ... 

1115 

1116 @property 

1117 def cache_dir(self) -> Path: 

1118 """Path to the mala cache directory.""" 

1119 ... 

1120 

1121 @property 

1122 def lock_dir(self) -> Path: 

1123 """Path to the lock directory for multi-agent coordination.""" 

1124 ... 

1125 

1126 def find_cerberus_bin_path(self) -> Path | None: 

1127 """Find the cerberus plugin bin directory. 

1128 

1129 Returns: 

1130 Path to cerberus bin directory, or None if not found. 

1131 """ 

1132 ... 

1133 

1134 

1135@runtime_checkable 

1136class LockManagerPort(Protocol): 

1137 """Protocol for abstracting file-based locking operations. 

1138 

1139 Enables dependency injection of lock managers into domain modules, 

1140 allowing the core layer to define the interface without depending on 

1141 the infrastructure implementation. 

1142 

1143 The canonical implementation is in src/infra/tools/locking.py. 

1144 """ 

1145 

1146 def lock_path(self, filepath: str, repo_namespace: str | None = None) -> Path: 

1147 """Get the lock file path for a given filepath. 

1148 

1149 Args: 

1150 filepath: Path to the file to lock. 

1151 repo_namespace: Optional repo namespace for cross-repo disambiguation. 

1152 

1153 Returns: 

1154 Path to the lock file. 

1155 """ 

1156 ... 

1157 

1158 def try_lock( 

1159 self, filepath: str, agent_id: str, repo_namespace: str | None = None 

1160 ) -> bool: 

1161 """Try to acquire a lock without blocking. 

1162 

1163 Args: 

1164 filepath: Path to the file to lock. 

1165 agent_id: Identifier of the agent requesting the lock. 

1166 repo_namespace: Optional repo namespace for cross-repo disambiguation. 

1167 

1168 Returns: 

1169 True if lock was acquired, False if already held by another agent. 

1170 """ 

1171 ... 

1172 

1173 def wait_for_lock( 

1174 self, 

1175 filepath: str, 

1176 agent_id: str, 

1177 repo_namespace: str | None = None, 

1178 timeout_seconds: float = 30.0, 

1179 poll_interval_ms: int = 100, 

1180 ) -> bool: 

1181 """Wait for and acquire a lock on a file. 

1182 

1183 Args: 

1184 filepath: Path to the file to lock. 

1185 agent_id: Identifier of the agent requesting the lock. 

1186 repo_namespace: Optional repo namespace for cross-repo disambiguation. 

1187 timeout_seconds: Maximum time to wait for the lock in seconds. 

1188 poll_interval_ms: Polling interval in milliseconds. 

1189 

1190 Returns: 

1191 True if lock was acquired, False if timeout. 

1192 """ 

1193 ... 

1194 

1195 def release_lock( 

1196 self, filepath: str, agent_id: str, repo_namespace: str | None = None 

1197 ) -> bool: 

1198 """Release a lock on a file. 

1199 

1200 Only releases the lock if it is held by the specified agent_id. 

1201 This prevents accidental or malicious release of locks held by 

1202 other agents. 

1203 

1204 Args: 

1205 filepath: Path to the file to unlock. 

1206 agent_id: Identifier of the agent releasing the lock. 

1207 repo_namespace: Optional repo namespace for cross-repo disambiguation. 

1208 

1209 Returns: 

1210 True if lock was released, False if lock was not held by agent_id. 

1211 """ 

1212 ... 

1213 

1214 

1215@runtime_checkable 

1216class LoggerPort(Protocol): 

1217 """Protocol for console/terminal logging with colored output. 

1218 

1219 Enables dependency injection of loggers into domain modules, 

1220 allowing the core layer to define the interface without depending on 

1221 the infrastructure implementation. 

1222 

1223 The canonical implementation is in src/infra/io/log_output/console.py. 

1224 """ 

1225 

1226 def log( 

1227 self, 

1228 message: str, 

1229 *, 

1230 level: str = "info", 

1231 color: str | None = None, 

1232 ) -> None: 

1233 """Log a message to the console. 

1234 

1235 Args: 

1236 message: The message to log. 

1237 level: Log level (e.g., "info", "debug", "error"). 

1238 color: Optional color name (e.g., "cyan", "green", "red"). 

1239 """ 

1240 ... 

1241 

1242 

1243# ============================================================================= 

1244# Event Sink Protocol 

1245# ============================================================================= 

1246 

1247 

1248@dataclass 

1249class EventRunConfig: 

1250 """Configuration snapshot for a run, passed to on_run_started. 

1251 

1252 Mirrors the relevant fields from MalaOrchestrator for event reporting. 

1253 """ 

1254 

1255 repo_path: str 

1256 max_agents: int | None 

1257 timeout_minutes: int | None 

1258 max_issues: int | None 

1259 max_gate_retries: int 

1260 max_review_retries: int 

1261 epic_id: str | None = None 

1262 only_ids: list[str] | None = None 

1263 braintrust_enabled: bool = False 

1264 braintrust_disabled_reason: str | None = None # e.g., "add BRAINTRUST_API_KEY..." 

1265 review_enabled: bool = True # Cerberus code review enabled 

1266 review_disabled_reason: str | None = None 

1267 prioritize_wip: bool = False 

1268 orphans_only: bool = False 

1269 cli_args: dict[str, object] | None = None 

1270 

1271 

1272@runtime_checkable 

1273class MalaEventSink(Protocol): 

1274 """Protocol for receiving orchestrator events. 

1275 

1276 Implementations handle presentation (console, logging, metrics) while 

1277 the orchestrator focuses on coordination logic. Each method corresponds 

1278 to a semantic event in the orchestration flow. 

1279 

1280 All methods are synchronous and should be non-blocking. Implementations 

1281 that need async behavior should queue events internally. 

1282 """ 

1283 

1284 # ------------------------------------------------------------------------- 

1285 # Run lifecycle 

1286 # ------------------------------------------------------------------------- 

1287 

1288 def on_run_started(self, config: EventRunConfig) -> None: 

1289 """Called when the orchestrator run begins. 

1290 

1291 Args: 

1292 config: Run configuration snapshot. 

1293 """ 

1294 ... 

1295 

1296 def on_run_completed( 

1297 self, 

1298 success_count: int, 

1299 total_count: int, 

1300 run_validation_passed: bool, 

1301 abort_reason: str | None = None, 

1302 ) -> None: 

1303 """Called when the orchestrator run completes. 

1304 

1305 Args: 

1306 success_count: Number of issues completed successfully. 

1307 total_count: Total number of issues processed. 

1308 run_validation_passed: Whether Gate 4 (run-level validation) passed. 

1309 abort_reason: If run was aborted, the reason string. 

1310 """ 

1311 ... 

1312 

1313 def on_ready_issues(self, issue_ids: list[str]) -> None: 

1314 """Called when ready issues are fetched. 

1315 

1316 Args: 

1317 issue_ids: List of issue IDs ready for processing. 

1318 """ 

1319 ... 

1320 

1321 def on_waiting_for_agents(self, count: int) -> None: 

1322 """Called when waiting for agents to complete. 

1323 

1324 Args: 

1325 count: Number of active agents being waited on. 

1326 """ 

1327 ... 

1328 

1329 def on_no_more_issues(self, reason: str) -> None: 

1330 """Called when there are no more issues to process. 

1331 

1332 Args: 

1333 reason: Reason string (e.g., "limit_reached", "none_ready"). 

1334 """ 

1335 ... 

1336 

1337 # ------------------------------------------------------------------------- 

1338 # Agent lifecycle 

1339 # ------------------------------------------------------------------------- 

1340 

1341 def on_agent_started(self, agent_id: str, issue_id: str) -> None: 

1342 """Called when an agent is spawned for an issue. 

1343 

1344 Args: 

1345 agent_id: Unique agent identifier. 

1346 issue_id: Issue being worked on. 

1347 """ 

1348 ... 

1349 

1350 def on_agent_completed( 

1351 self, 

1352 agent_id: str, 

1353 issue_id: str, 

1354 success: bool, 

1355 duration_seconds: float, 

1356 summary: str, 

1357 ) -> None: 

1358 """Called when an agent completes (success or failure). 

1359 

1360 Args: 

1361 agent_id: Unique agent identifier. 

1362 issue_id: Issue that was worked on. 

1363 success: Whether the agent succeeded. 

1364 duration_seconds: Total execution time. 

1365 summary: Result summary or error message. 

1366 """ 

1367 ... 

1368 

1369 def on_claim_failed(self, agent_id: str, issue_id: str) -> None: 

1370 """Called when claiming an issue fails. 

1371 

1372 Args: 

1373 agent_id: Agent that attempted the claim. 

1374 issue_id: Issue that could not be claimed. 

1375 """ 

1376 ... 

1377 

1378 # ------------------------------------------------------------------------- 

1379 # SDK message streaming 

1380 # ------------------------------------------------------------------------- 

1381 

1382 def on_tool_use( 

1383 self, 

1384 agent_id: str, 

1385 tool_name: str, 

1386 description: str = "", 

1387 arguments: dict[str, Any] | None = None, 

1388 ) -> None: 

1389 """Called when an agent invokes a tool. 

1390 

1391 Args: 

1392 agent_id: Agent invoking the tool. 

1393 tool_name: Name of the tool being called. 

1394 description: Brief description of the action. 

1395 arguments: Tool arguments (may be truncated for display). 

1396 """ 

1397 ... 

1398 

1399 def on_agent_text(self, agent_id: str, text: str) -> None: 

1400 """Called when an agent emits text output. 

1401 

1402 Args: 

1403 agent_id: Agent emitting text. 

1404 text: Text content (may be truncated for display). 

1405 """ 

1406 ... 

1407 

1408 # ------------------------------------------------------------------------- 

1409 # Quality gate events 

1410 # ------------------------------------------------------------------------- 

1411 

1412 def on_gate_started( 

1413 self, 

1414 agent_id: str | None, 

1415 attempt: int, 

1416 max_attempts: int, 

1417 issue_id: str | None = None, 

1418 ) -> None: 

1419 """Called when a quality gate check begins. 

1420 

1421 Args: 

1422 agent_id: Agent ID (None for run-level gate). 

1423 attempt: Current attempt number (1-indexed). 

1424 max_attempts: Maximum retry attempts. 

1425 issue_id: Issue being validated (for display). 

1426 """ 

1427 ... 

1428 

1429 def on_gate_passed( 

1430 self, 

1431 agent_id: str | None, 

1432 issue_id: str | None = None, 

1433 ) -> None: 

1434 """Called when a quality gate passes. 

1435 

1436 Args: 

1437 agent_id: Agent ID (None for run-level gate). 

1438 issue_id: Issue being validated (for display). 

1439 """ 

1440 ... 

1441 

1442 def on_gate_failed( 

1443 self, 

1444 agent_id: str | None, 

1445 attempt: int, 

1446 max_attempts: int, 

1447 issue_id: str | None = None, 

1448 ) -> None: 

1449 """Called when a quality gate fails after all retries. 

1450 

1451 Args: 

1452 agent_id: Agent ID (None for run-level gate). 

1453 attempt: Final attempt number. 

1454 max_attempts: Maximum retry attempts. 

1455 issue_id: Issue being validated (for display). 

1456 """ 

1457 ... 

1458 

1459 def on_gate_retry( 

1460 self, 

1461 agent_id: str, 

1462 attempt: int, 

1463 max_attempts: int, 

1464 issue_id: str | None = None, 

1465 ) -> None: 

1466 """Called when retrying a quality gate after failure. 

1467 

1468 Args: 

1469 agent_id: Agent being retried. 

1470 attempt: Current retry attempt number. 

1471 max_attempts: Maximum retry attempts. 

1472 issue_id: Issue being validated (for display). 

1473 """ 

1474 ... 

1475 

1476 def on_gate_result( 

1477 self, 

1478 agent_id: str | None, 

1479 passed: bool, 

1480 failure_reasons: list[str] | None = None, 

1481 issue_id: str | None = None, 

1482 ) -> None: 

1483 """Called when a quality gate check completes with its result. 

1484 

1485 This provides the detailed gate result including failure reasons, 

1486 complementing the simpler on_gate_passed/on_gate_failed events. 

1487 

1488 Args: 

1489 agent_id: Agent ID (None for run-level gate). 

1490 passed: Whether the gate passed. 

1491 failure_reasons: List of failure reasons (if failed). 

1492 issue_id: Issue being validated (for display). 

1493 """ 

1494 ... 

1495 

1496 # ------------------------------------------------------------------------- 

1497 # Codex review events 

1498 # ------------------------------------------------------------------------- 

1499 

1500 def on_review_started( 

1501 self, 

1502 agent_id: str, 

1503 attempt: int, 

1504 max_attempts: int, 

1505 issue_id: str | None = None, 

1506 ) -> None: 

1507 """Called when a Codex review begins. 

1508 

1509 Args: 

1510 agent_id: Agent being reviewed. 

1511 attempt: Current attempt number. 

1512 max_attempts: Maximum review attempts. 

1513 issue_id: Issue being reviewed (for display). 

1514 """ 

1515 ... 

1516 

1517 def on_review_passed( 

1518 self, 

1519 agent_id: str, 

1520 issue_id: str | None = None, 

1521 ) -> None: 

1522 """Called when a Codex review passes. 

1523 

1524 Args: 

1525 agent_id: Agent that passed review. 

1526 issue_id: Issue being reviewed (for display). 

1527 """ 

1528 ... 

1529 

1530 def on_review_retry( 

1531 self, 

1532 agent_id: str, 

1533 attempt: int, 

1534 max_attempts: int, 

1535 error_count: int | None = None, 

1536 parse_error: str | None = None, 

1537 issue_id: str | None = None, 

1538 ) -> None: 

1539 """Called when retrying a Codex review after issues found. 

1540 

1541 Args: 

1542 agent_id: Agent being retried. 

1543 attempt: Current retry attempt number. 

1544 max_attempts: Maximum review attempts. 

1545 error_count: Number of errors found (if available). 

1546 parse_error: Parse error message (if review failed to parse). 

1547 issue_id: Issue being reviewed (for display). 

1548 """ 

1549 ... 

1550 

1551 def on_review_warning( 

1552 self, 

1553 message: str, 

1554 agent_id: str | None = None, 

1555 issue_id: str | None = None, 

1556 ) -> None: 

1557 """Called for review-related warnings (e.g., verdict mismatch). 

1558 

1559 Args: 

1560 message: Warning message. 

1561 agent_id: Associated agent (if any). 

1562 issue_id: Issue being reviewed (for display). 

1563 """ 

1564 ... 

1565 

1566 # ------------------------------------------------------------------------- 

1567 # Fixer agent events 

1568 # ------------------------------------------------------------------------- 

1569 

1570 def on_fixer_started( 

1571 self, 

1572 attempt: int, 

1573 max_attempts: int, 

1574 ) -> None: 

1575 """Called when a fixer agent is spawned. 

1576 

1577 Args: 

1578 attempt: Current fixer attempt number. 

1579 max_attempts: Maximum fixer attempts. 

1580 """ 

1581 ... 

1582 

1583 def on_fixer_completed(self, result: str) -> None: 

1584 """Called when a fixer agent completes. 

1585 

1586 Args: 

1587 result: Brief result description. 

1588 """ 

1589 ... 

1590 

1591 def on_fixer_failed(self, reason: str) -> None: 

1592 """Called when a fixer agent fails. 

1593 

1594 Args: 

1595 reason: Failure reason (e.g., "timeout", "error"). 

1596 """ 

1597 ... 

1598 

1599 # ------------------------------------------------------------------------- 

1600 # Issue lifecycle 

1601 # ------------------------------------------------------------------------- 

1602 

1603 def on_issue_closed(self, agent_id: str, issue_id: str) -> None: 

1604 """Called when an issue is closed after successful completion. 

1605 

1606 Args: 

1607 agent_id: Agent that completed the issue. 

1608 issue_id: Issue that was closed. 

1609 """ 

1610 ... 

1611 

1612 def on_issue_completed( 

1613 self, 

1614 agent_id: str, 

1615 issue_id: str, 

1616 success: bool, 

1617 duration_seconds: float, 

1618 summary: str, 

1619 ) -> None: 

1620 """Called when an issue implementation completes (success or failure). 

1621 

1622 This is the primary issue completion event, distinct from on_agent_completed 

1623 which tracks the agent lifecycle. Use this for issue-level tracking. 

1624 

1625 Args: 

1626 agent_id: Agent that worked on the issue. 

1627 issue_id: Issue that was completed. 

1628 success: Whether the issue was successfully implemented. 

1629 duration_seconds: Total time spent on the issue. 

1630 summary: Result summary or error message. 

1631 """ 

1632 ... 

1633 

1634 def on_epic_closed(self, agent_id: str) -> None: 

1635 """Called when a parent epic is auto-closed. 

1636 

1637 Args: 

1638 agent_id: Agent that triggered the epic closure. 

1639 """ 

1640 ... 

1641 

1642 def on_validation_started( 

1643 self, 

1644 agent_id: str, 

1645 issue_id: str | None = None, 

1646 ) -> None: 

1647 """Called when per-issue validation begins. 

1648 

1649 Args: 

1650 agent_id: Agent being validated. 

1651 issue_id: Issue being validated (for display). 

1652 """ 

1653 ... 

1654 

1655 def on_validation_result( 

1656 self, 

1657 agent_id: str, 

1658 passed: bool, 

1659 issue_id: str | None = None, 

1660 ) -> None: 

1661 """Called when per-issue validation completes. 

1662 

1663 Args: 

1664 agent_id: Agent that was validated. 

1665 passed: Whether validation passed. 

1666 issue_id: Issue being validated (for display). 

1667 """ 

1668 ... 

1669 

1670 def on_validation_step_running( 

1671 self, 

1672 step_name: str, 

1673 agent_id: str | None = None, 

1674 ) -> None: 

1675 """Called when a validation step starts. 

1676 

1677 Args: 

1678 step_name: Name of the validation step (e.g., "ruff", "pytest"). 

1679 agent_id: Associated agent (if any). 

1680 """ 

1681 ... 

1682 

1683 def on_validation_step_skipped( 

1684 self, 

1685 step_name: str, 

1686 reason: str, 

1687 agent_id: str | None = None, 

1688 ) -> None: 

1689 """Called when a validation step is skipped. 

1690 

1691 Args: 

1692 step_name: Name of the validation step. 

1693 reason: Reason for skipping (e.g., "cache hit", "no changes"). 

1694 agent_id: Associated agent (if any). 

1695 """ 

1696 ... 

1697 

1698 def on_validation_step_passed( 

1699 self, 

1700 step_name: str, 

1701 duration_seconds: float, 

1702 agent_id: str | None = None, 

1703 ) -> None: 

1704 """Called when a validation step succeeds. 

1705 

1706 Args: 

1707 step_name: Name of the validation step. 

1708 duration_seconds: Time taken to complete the step. 

1709 agent_id: Associated agent (if any). 

1710 """ 

1711 ... 

1712 

1713 def on_validation_step_failed( 

1714 self, 

1715 step_name: str, 

1716 exit_code: int, 

1717 agent_id: str | None = None, 

1718 ) -> None: 

1719 """Called when a validation step fails. 

1720 

1721 Args: 

1722 step_name: Name of the validation step. 

1723 exit_code: Exit code from the step. 

1724 agent_id: Associated agent (if any). 

1725 """ 

1726 ... 

1727 

1728 # ------------------------------------------------------------------------- 

1729 # Warnings and diagnostics 

1730 # ------------------------------------------------------------------------- 

1731 

1732 def on_warning(self, message: str, agent_id: str | None = None) -> None: 

1733 """Called for warning conditions. 

1734 

1735 Args: 

1736 message: Warning message. 

1737 agent_id: Associated agent (if any). 

1738 """ 

1739 ... 

1740 

1741 def on_log_timeout(self, agent_id: str, log_path: str) -> None: 

1742 """Called when waiting for a log file times out. 

1743 

1744 Args: 

1745 agent_id: Agent waiting for the log. 

1746 log_path: Path to the missing log file. 

1747 """ 

1748 ... 

1749 

1750 def on_locks_cleaned(self, agent_id: str, count: int) -> None: 

1751 """Called when stale locks are cleaned up for an agent. 

1752 

1753 Args: 

1754 agent_id: Agent whose locks were cleaned. 

1755 count: Number of locks cleaned. 

1756 """ 

1757 ... 

1758 

1759 def on_locks_released(self, count: int) -> None: 

1760 """Called when remaining locks are released at run end. 

1761 

1762 Args: 

1763 count: Number of locks released. 

1764 """ 

1765 ... 

1766 

1767 def on_issues_committed(self) -> None: 

1768 """Called when .beads/issues.jsonl is committed.""" 

1769 ... 

1770 

1771 def on_run_metadata_saved(self, path: str) -> None: 

1772 """Called when run metadata is saved. 

1773 

1774 Args: 

1775 path: Path to the saved metadata file. 

1776 """ 

1777 ... 

1778 

1779 def on_run_level_validation_disabled(self) -> None: 

1780 """Called when run-level validation is disabled.""" 

1781 ... 

1782 

1783 def on_abort_requested(self, reason: str) -> None: 

1784 """Called when a fatal error triggers a run abort. 

1785 

1786 Args: 

1787 reason: Description of the fatal error. 

1788 """ 

1789 ... 

1790 

1791 def on_tasks_aborting(self, count: int, reason: str) -> None: 

1792 """Called when active tasks are being aborted. 

1793 

1794 Args: 

1795 count: Number of active tasks being aborted. 

1796 reason: Reason for the abort. 

1797 """ 

1798 ... 

1799 

1800 # ------------------------------------------------------------------------- 

1801 # Epic verification lifecycle 

1802 # ------------------------------------------------------------------------- 

1803 

1804 def on_epic_verification_started(self, epic_id: str) -> None: 

1805 """Called when epic verification begins. 

1806 

1807 Args: 

1808 epic_id: The epic being verified. 

1809 """ 

1810 ... 

1811 

1812 def on_epic_verification_passed(self, epic_id: str, confidence: float) -> None: 

1813 """Called when epic verification passes. 

1814 

1815 Args: 

1816 epic_id: The epic that passed verification. 

1817 confidence: Confidence score (0.0 to 1.0). 

1818 """ 

1819 ... 

1820 

1821 def on_epic_verification_failed( 

1822 self, 

1823 epic_id: str, 

1824 unmet_count: int, 

1825 remediation_ids: list[str], 

1826 ) -> None: 

1827 """Called when epic verification fails with unmet criteria. 

1828 

1829 Args: 

1830 epic_id: The epic that failed verification. 

1831 unmet_count: Number of unmet criteria. 

1832 remediation_ids: IDs of created remediation issues. 

1833 """ 

1834 ... 

1835 

1836 def on_epic_remediation_created( 

1837 self, 

1838 epic_id: str, 

1839 issue_id: str, 

1840 criterion: str, 

1841 ) -> None: 

1842 """Called when a remediation issue is created for an unmet criterion. 

1843 

1844 Args: 

1845 epic_id: The epic the remediation is for. 

1846 issue_id: The created issue ID. 

1847 criterion: The unmet criterion text (may be truncated). 

1848 """ 

1849 ... 

1850 

1851 # ------------------------------------------------------------------------- 

1852 # Pipeline module events 

1853 # ------------------------------------------------------------------------- 

1854 

1855 def on_lifecycle_state(self, agent_id: str, state: str) -> None: 

1856 """Called when lifecycle state changes (verbose/debug). 

1857 

1858 Args: 

1859 agent_id: Agent whose lifecycle changed. 

1860 state: New lifecycle state name. 

1861 """ 

1862 ... 

1863 

1864 def on_log_waiting(self, agent_id: str) -> None: 

1865 """Called when waiting for session log file. 

1866 

1867 Args: 

1868 agent_id: Agent waiting for log. 

1869 """ 

1870 ... 

1871 

1872 def on_log_ready(self, agent_id: str) -> None: 

1873 """Called when session log file is ready. 

1874 

1875 Args: 

1876 agent_id: Agent whose log is ready. 

1877 """ 

1878 ... 

1879 

1880 def on_review_skipped_no_progress(self, agent_id: str) -> None: 

1881 """Called when review is skipped due to no progress. 

1882 

1883 Args: 

1884 agent_id: Agent whose review was skipped. 

1885 """ 

1886 ... 

1887 

1888 def on_fixer_text(self, attempt: int, text: str) -> None: 

1889 """Called when fixer agent emits text output. 

1890 

1891 Args: 

1892 attempt: Current fixer attempt number. 

1893 text: Text content. 

1894 """ 

1895 ... 

1896 

1897 def on_fixer_tool_use( 

1898 self, 

1899 attempt: int, 

1900 tool_name: str, 

1901 arguments: dict[str, Any] | None = None, 

1902 ) -> None: 

1903 """Called when fixer agent invokes a tool. 

1904 

1905 Args: 

1906 attempt: Current fixer attempt number. 

1907 tool_name: Name of the tool being called. 

1908 arguments: Tool arguments. 

1909 """ 

1910 ... 

1911 

1912 def on_deadlock_detected(self, info: DeadlockInfoProtocol) -> None: 

1913 """Called when a deadlock is detected and resolved. 

1914 

1915 Args: 

1916 info: Information about the detected deadlock, including the cycle 

1917 of agents, the victim selected for cancellation, and the 

1918 blocker holding the needed resource. 

1919 """ 

1920 ...