claude-task-master 0.1.3__py3-none-any.whl → 0.1.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. claude_task_master/__init__.py +1 -1
  2. claude_task_master/api/models.py +309 -0
  3. claude_task_master/api/routes.py +229 -0
  4. claude_task_master/api/routes_repo.py +317 -0
  5. claude_task_master/bin/claudetm +1 -1
  6. claude_task_master/cli.py +3 -1
  7. claude_task_master/cli_commands/mailbox.py +295 -0
  8. claude_task_master/cli_commands/workflow.py +37 -0
  9. claude_task_master/core/__init__.py +5 -0
  10. claude_task_master/core/agent_phases.py +1 -1
  11. claude_task_master/core/orchestrator.py +432 -9
  12. claude_task_master/core/parallel.py +4 -4
  13. claude_task_master/core/plan_updater.py +199 -0
  14. claude_task_master/core/pr_context.py +179 -64
  15. claude_task_master/core/prompts.py +4 -0
  16. claude_task_master/core/prompts_plan_update.py +148 -0
  17. claude_task_master/core/state.py +5 -1
  18. claude_task_master/core/workflow_stages.py +229 -22
  19. claude_task_master/github/client_pr.py +86 -20
  20. claude_task_master/mailbox/__init__.py +23 -0
  21. claude_task_master/mailbox/merger.py +163 -0
  22. claude_task_master/mailbox/models.py +95 -0
  23. claude_task_master/mailbox/storage.py +209 -0
  24. claude_task_master/mcp/server.py +183 -0
  25. claude_task_master/mcp/tools.py +921 -0
  26. claude_task_master/webhooks/events.py +356 -2
  27. {claude_task_master-0.1.3.dist-info → claude_task_master-0.1.5.dist-info}/METADATA +223 -4
  28. {claude_task_master-0.1.3.dist-info → claude_task_master-0.1.5.dist-info}/RECORD +31 -23
  29. {claude_task_master-0.1.3.dist-info → claude_task_master-0.1.5.dist-info}/WHEEL +1 -1
  30. {claude_task_master-0.1.3.dist-info → claude_task_master-0.1.5.dist-info}/entry_points.txt +0 -0
  31. {claude_task_master-0.1.3.dist-info → claude_task_master-0.1.5.dist-info}/top_level.txt +0 -0
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import logging
6
6
  import subprocess
7
7
  import time
8
+ from datetime import datetime
8
9
  from typing import TYPE_CHECKING, Any
9
10
 
10
11
  from . import console
@@ -34,9 +35,11 @@ from .workflow_stages import WorkflowStageHandler
34
35
 
35
36
  if TYPE_CHECKING:
36
37
  from ..github import GitHubClient
38
+ from ..mailbox import MailboxStorage, MessageMerger
37
39
  from ..webhooks import WebhookClient
38
40
  from ..webhooks.events import EventType
39
41
  from .logger import TaskLogger
42
+ from .plan_updater import PlanUpdater
40
43
 
41
44
  logger = logging.getLogger(__name__)
42
45
 
@@ -223,6 +226,9 @@ class WorkLoopOrchestrator:
223
226
  self._stage_handler: WorkflowStageHandler | None = None
224
227
  self._pr_context: PRContextManager | None = None
225
228
  self._webhook_emitter: WebhookEmitter | None = None
229
+ self._mailbox_storage: MailboxStorage | None = None
230
+ self._message_merger: MessageMerger | None = None
231
+ self._plan_updater: PlanUpdater | None = None
226
232
 
227
233
  @property
228
234
  def github_client(self) -> GitHubClient:
@@ -269,6 +275,7 @@ class WorkLoopOrchestrator:
269
275
  state_manager=self.state_manager,
270
276
  github_client=self.github_client,
271
277
  pr_context=self.pr_context,
278
+ webhook_emitter=self.webhook_emitter,
272
279
  )
273
280
  return self._stage_handler
274
281
 
@@ -287,6 +294,41 @@ class WorkLoopOrchestrator:
287
294
  self._webhook_emitter = WebhookEmitter(self._webhook_client, run_id)
288
295
  return self._webhook_emitter
289
296
 
297
+ @property
298
+ def mailbox_storage(self) -> MailboxStorage:
299
+ """Get or lazily initialize mailbox storage."""
300
+ if self._mailbox_storage is None:
301
+ from ..mailbox import MailboxStorage
302
+
303
+ self._mailbox_storage = MailboxStorage(self.state_manager.state_dir)
304
+ logger.debug(
305
+ "Mailbox storage initialized: path=%s",
306
+ self._mailbox_storage.storage_path,
307
+ )
308
+ return self._mailbox_storage
309
+
310
+ @property
311
+ def message_merger(self) -> MessageMerger:
312
+ """Get or lazily initialize message merger."""
313
+ if self._message_merger is None:
314
+ from ..mailbox import MessageMerger
315
+
316
+ self._message_merger = MessageMerger()
317
+ return self._message_merger
318
+
319
+ @property
320
+ def plan_updater(self) -> PlanUpdater:
321
+ """Get or lazily initialize plan updater."""
322
+ if self._plan_updater is None:
323
+ from .plan_updater import PlanUpdater
324
+
325
+ self._plan_updater = PlanUpdater(
326
+ agent=self.agent,
327
+ state_manager=self.state_manager,
328
+ logger=self.logger,
329
+ )
330
+ return self._plan_updater
331
+
290
332
  def _get_total_tasks(self, state: TaskState) -> int:
291
333
  """Get total number of tasks from the plan.
292
334
 
@@ -412,6 +454,272 @@ class WorkLoopOrchestrator:
412
454
  auto_merged=state.options.auto_merge,
413
455
  )
414
456
 
457
+ def _check_and_process_mailbox(self, state: TaskState) -> bool:
458
+ """Check mailbox and update plan if messages exist.
459
+
460
+ This method is called after each task completion to check for
461
+ messages from other instances or external systems. If messages
462
+ are found, they are merged and used to update the plan.
463
+
464
+ The last_mailbox_check timestamp is always updated regardless of
465
+ whether messages were found, to track when the mailbox was last
466
+ monitored.
467
+
468
+ Args:
469
+ state: Current task state.
470
+
471
+ Returns:
472
+ True if plan was updated, False otherwise.
473
+ """
474
+ logger.debug(
475
+ "Mailbox check starting: task_index=%d, session_count=%d",
476
+ state.current_task_index,
477
+ state.session_count,
478
+ )
479
+
480
+ # Check if there are any messages in the mailbox
481
+ message_count = self.mailbox_storage.count()
482
+ if message_count == 0:
483
+ check_time = datetime.now()
484
+ logger.debug(
485
+ "Mailbox check complete: no messages, timestamp=%s",
486
+ check_time.isoformat(),
487
+ )
488
+ # Always update the timestamp to track when mailbox was checked
489
+ state.last_mailbox_check = check_time
490
+ self.state_manager.save_state(state)
491
+ return False
492
+
493
+ # Log that we're processing messages
494
+ logger.info(
495
+ "Mailbox check: found %d message(s) to process",
496
+ message_count,
497
+ )
498
+ console.info(f"Found {message_count} message(s) in mailbox - processing...")
499
+
500
+ # Get and clear messages atomically
501
+ messages = self.mailbox_storage.get_and_clear()
502
+ if not messages:
503
+ # Race condition - messages were cleared by another process
504
+ logger.warning(
505
+ "Mailbox race condition: messages disappeared between count (%d) and get",
506
+ message_count,
507
+ )
508
+ return False
509
+
510
+ # Log details of each message being processed
511
+ for msg in messages:
512
+ logger.info(
513
+ "Mailbox message: id=%s, sender=%s, priority=%s, timestamp=%s, content_length=%d",
514
+ msg.id,
515
+ msg.sender,
516
+ msg.priority.name if hasattr(msg.priority, "name") else msg.priority,
517
+ msg.timestamp.isoformat() if msg.timestamp else "none",
518
+ len(msg.content),
519
+ )
520
+
521
+ # Merge messages into a single change request
522
+ logger.debug(
523
+ "Merging %d mailbox messages from senders: %s",
524
+ len(messages),
525
+ [msg.sender for msg in messages],
526
+ )
527
+ try:
528
+ merged_content = self.message_merger.merge(messages)
529
+ logger.info(
530
+ "Mailbox messages merged successfully: total_length=%d, preview=%s...",
531
+ len(merged_content),
532
+ merged_content[:100].replace("\n", " "),
533
+ )
534
+ except ValueError as e:
535
+ logger.error(
536
+ "Failed to merge mailbox messages: error=%s, message_count=%d",
537
+ e,
538
+ len(messages),
539
+ )
540
+ console.warning(f"Failed to merge mailbox messages: {e}")
541
+ return False
542
+
543
+ # Update the plan with the merged content
544
+ logger.debug("Starting plan update from mailbox messages")
545
+ try:
546
+ # Capture task count before update for diff calculation
547
+ total_tasks_before = self._get_total_tasks(state)
548
+
549
+ console.info("Updating plan based on mailbox messages...")
550
+ result = self.plan_updater.update_plan(merged_content)
551
+
552
+ check_time = datetime.now()
553
+ if result.get("changes_made"):
554
+ console.success("Plan updated based on mailbox messages")
555
+ logger.info(
556
+ "Plan updated from mailbox: changes_made=True, "
557
+ "message_count=%d, senders=%s, timestamp=%s",
558
+ len(messages),
559
+ [msg.sender for msg in messages],
560
+ check_time.isoformat(),
561
+ )
562
+
563
+ # Update state to record the mailbox check
564
+ state.last_mailbox_check = check_time
565
+ self.state_manager.save_state(state)
566
+ logger.debug(
567
+ "State saved with mailbox check timestamp: %s",
568
+ check_time.isoformat(),
569
+ )
570
+
571
+ # Emit plan.updated webhook event
572
+ # Get task counts for the updated plan
573
+ updated_total_tasks = self._get_total_tasks(state)
574
+ updated_completed_tasks = self._get_completed_tasks(state)
575
+
576
+ # Calculate task diff
577
+ task_diff = updated_total_tasks - total_tasks_before
578
+ tasks_added = max(0, task_diff)
579
+ tasks_removed = max(0, -task_diff)
580
+
581
+ self.webhook_emitter.emit(
582
+ "plan.updated",
583
+ update_source="mailbox",
584
+ message=merged_content[:500]
585
+ if merged_content
586
+ else None, # Truncate long messages
587
+ total_tasks=updated_total_tasks,
588
+ completed_tasks=updated_completed_tasks,
589
+ tasks_added=tasks_added,
590
+ tasks_modified=None, # TODO: Calculate from plan diff (requires content comparison)
591
+ tasks_removed=tasks_removed,
592
+ )
593
+
594
+ return True
595
+ else:
596
+ console.info("Mailbox messages processed - no plan changes needed")
597
+ logger.info(
598
+ "Plan not modified from mailbox: message_count=%d, senders=%s, timestamp=%s",
599
+ len(messages),
600
+ [msg.sender for msg in messages],
601
+ check_time.isoformat(),
602
+ )
603
+
604
+ # Still record that we checked
605
+ state.last_mailbox_check = check_time
606
+ self.state_manager.save_state(state)
607
+ logger.debug(
608
+ "State saved with mailbox check timestamp: %s",
609
+ check_time.isoformat(),
610
+ )
611
+
612
+ # Emit plan.updated with no changes (still useful for tracking)
613
+ current_total_tasks = self._get_total_tasks(state)
614
+ current_completed_tasks = self._get_completed_tasks(state)
615
+
616
+ self.webhook_emitter.emit(
617
+ "plan.updated",
618
+ update_source="mailbox",
619
+ message=merged_content[:500] if merged_content else None, # Truncate
620
+ total_tasks=current_total_tasks,
621
+ completed_tasks=current_completed_tasks,
622
+ tasks_added=0,
623
+ tasks_modified=0,
624
+ tasks_removed=0,
625
+ )
626
+
627
+ return False
628
+
629
+ except ValueError as e:
630
+ # No plan exists - can't update
631
+ logger.error(
632
+ "Cannot update plan from mailbox: no plan exists, error=%s, message_count=%d",
633
+ e,
634
+ len(messages),
635
+ )
636
+ console.warning(f"Cannot update plan: {e}")
637
+ return False
638
+ except Exception as e:
639
+ # Other errors during plan update
640
+ logger.error(
641
+ "Error updating plan from mailbox: error=%s, type=%s, message_count=%d",
642
+ e,
643
+ type(e).__name__,
644
+ len(messages),
645
+ )
646
+ console.warning(f"Error updating plan from mailbox: {e}")
647
+ return False
648
+
649
+ def _emit_status_changed(
650
+ self,
651
+ previous_status: str,
652
+ new_status: str,
653
+ state: TaskState,
654
+ reason: str | None = None,
655
+ ) -> None:
656
+ """Emit a status.changed webhook event when status transitions.
657
+
658
+ Args:
659
+ previous_status: The status before the change.
660
+ new_status: The status after the change.
661
+ state: Current task state.
662
+ reason: Optional reason for the status change.
663
+ """
664
+ # Only emit if status actually changed
665
+ if previous_status == new_status:
666
+ return
667
+
668
+ self.webhook_emitter.emit(
669
+ "status.changed",
670
+ previous_status=previous_status,
671
+ new_status=new_status,
672
+ reason=reason,
673
+ task_index=state.current_task_index,
674
+ session_number=state.session_count,
675
+ )
676
+
677
+ def _emit_run_completed(
678
+ self,
679
+ state: TaskState,
680
+ exit_code: int,
681
+ result: str,
682
+ run_start_time: float,
683
+ error_message: str | None = None,
684
+ ) -> None:
685
+ """Emit a run.completed webhook event.
686
+
687
+ Args:
688
+ state: Current task state.
689
+ exit_code: Exit code (0=success, 1=blocked, 2=interrupted).
690
+ result: Outcome string ("success", "blocked", "failed", "interrupted").
691
+ run_start_time: Time when the run started (time.time() value).
692
+ error_message: Error message if run failed (optional).
693
+ """
694
+ # Get goal from state manager
695
+ goal = ""
696
+ try:
697
+ goal = self.state_manager.load_goal()
698
+ except Exception:
699
+ pass
700
+
701
+ # Get task counts
702
+ total_tasks = self._get_total_tasks(state)
703
+ completed_tasks = self._get_completed_tasks(state)
704
+
705
+ # Calculate duration
706
+ duration_seconds = time.time() - run_start_time if run_start_time > 0 else None
707
+
708
+ self.webhook_emitter.emit(
709
+ "run.completed",
710
+ goal=goal,
711
+ result=result,
712
+ exit_code=exit_code,
713
+ total_tasks=total_tasks,
714
+ completed_tasks=completed_tasks,
715
+ total_sessions=state.session_count,
716
+ duration_seconds=duration_seconds,
717
+ prs_created=0, # TODO: Track PR counts in state
718
+ prs_merged=0, # TODO: Track PR counts in state
719
+ final_status=state.status,
720
+ error_message=error_message,
721
+ )
722
+
415
723
  def run(self) -> int:
416
724
  """Run the main work loop until completion or blocked.
417
725
 
@@ -420,6 +728,9 @@ class WorkLoopOrchestrator:
420
728
  1: Blocked/Failed - max sessions reached or error.
421
729
  2: Paused - user interrupted.
422
730
  """
731
+ # Track run start time for duration calculation
732
+ run_start_time = time.time()
733
+
423
734
  # Load state with recovery
424
735
  try:
425
736
  state = self.state_manager.load_state()
@@ -437,8 +748,34 @@ class WorkLoopOrchestrator:
437
748
  console.warning(
438
749
  MaxSessionsReachedError(state.options.max_sessions, state.session_count).message
439
750
  )
751
+ self._emit_run_completed(state, 1, "blocked", run_start_time, "Max sessions reached")
440
752
  return 1
441
753
 
754
+ # Emit run.started webhook event
755
+ # Determine if this is a resumed run (session_count > 0 means we've run before)
756
+ is_resumed = state.session_count > 0
757
+ pr_mode = "per-task" if state.options.pr_per_task else "per-group"
758
+
759
+ # Load goal from state manager (stored in goal.txt)
760
+ goal = ""
761
+ try:
762
+ goal = self.state_manager.load_goal()
763
+ except Exception:
764
+ pass # Goal is optional for webhook
765
+
766
+ # Get working directory (parent of state_dir which is .claude-task-master/)
767
+ working_directory = str(self.state_manager.state_dir.parent)
768
+
769
+ self.webhook_emitter.emit(
770
+ "run.started",
771
+ goal=goal,
772
+ working_directory=working_directory,
773
+ max_sessions=state.options.max_sessions,
774
+ auto_merge=state.options.auto_merge,
775
+ pr_mode=pr_mode,
776
+ resumed=is_resumed,
777
+ )
778
+
442
779
  # Setup signal handlers and key listener
443
780
  register_handlers()
444
781
  reset_shutdown()
@@ -451,12 +788,15 @@ class WorkLoopOrchestrator:
451
788
  console.newline()
452
789
  console.warning(f"{reason} - pausing...")
453
790
  self.tracker.end_session(outcome="cancelled")
791
+ previous_status = state.status
454
792
  state.status = "paused"
793
+ self._emit_status_changed(previous_status, "paused", state, reason)
455
794
  self.state_manager.save_state(state)
456
795
  self.state_manager.create_state_backup()
457
796
  console.newline()
458
797
  console.info(self.tracker.get_cost_report())
459
798
  console.info("Use 'claudetm resume' to continue")
799
+ self._emit_run_completed(state, 2, "interrupted", run_start_time, reason)
460
800
  return 2
461
801
 
462
802
  try:
@@ -476,11 +816,14 @@ class WorkLoopOrchestrator:
476
816
  should_abort, abort_reason = self.tracker.should_abort()
477
817
  if should_abort:
478
818
  console.warning(f"Execution issue: {abort_reason}")
819
+ previous_status = state.status
479
820
  state.status = "blocked"
821
+ self._emit_status_changed(previous_status, "blocked", state, abort_reason)
480
822
  self.state_manager.save_state(state)
481
823
  stop_listening()
482
824
  unregister_handlers()
483
825
  console.info(self.tracker.get_cost_report())
826
+ self._emit_run_completed(state, 1, "blocked", run_start_time, abort_reason)
484
827
  return 1
485
828
 
486
829
  # Run workflow cycle
@@ -489,6 +832,9 @@ class WorkLoopOrchestrator:
489
832
  stop_listening()
490
833
  unregister_handlers()
491
834
  console.info(self.tracker.get_cost_report())
835
+ # Determine result string based on exit code
836
+ result_str = "success" if result == 0 else "blocked"
837
+ self._emit_run_completed(state, result, result_str, run_start_time)
492
838
  return result
493
839
 
494
840
  # Debug: check completion after each cycle
@@ -501,11 +847,18 @@ class WorkLoopOrchestrator:
501
847
  # Check session limit
502
848
  if state.options.max_sessions and state.session_count >= state.options.max_sessions:
503
849
  console.warning(f"Max sessions ({state.options.max_sessions}) reached")
850
+ previous_status = state.status
504
851
  state.status = "blocked"
852
+ self._emit_status_changed(
853
+ previous_status, "blocked", state, "Max sessions reached"
854
+ )
505
855
  self.state_manager.save_state(state)
506
856
  stop_listening()
507
857
  unregister_handlers()
508
858
  console.info(self.tracker.get_cost_report())
859
+ self._emit_run_completed(
860
+ state, 1, "blocked", run_start_time, "Max sessions reached"
861
+ )
509
862
  return 1
510
863
 
511
864
  # All complete - verify with retry loop for fixes
@@ -522,45 +875,68 @@ class WorkLoopOrchestrator:
522
875
  if verification["success"]:
523
876
  # Success! Checkout to main and cleanup
524
877
  self._checkout_to_main()
878
+ previous_status = state.status
525
879
  state.status = "success"
880
+ self._emit_status_changed(
881
+ previous_status, "success", state, "All tasks completed successfully"
882
+ )
526
883
  self.state_manager.save_state(state)
527
884
  self.state_manager.cleanup_on_success(state.run_id)
528
885
  console.success("All tasks completed successfully!")
529
886
  console.info(self.tracker.get_cost_report())
887
+ self._emit_run_completed(state, 0, "success", run_start_time)
530
888
  return 0
531
889
 
532
890
  # Verification failed
533
891
  console.warning("Success criteria verification failed")
534
892
 
535
893
  if fix_attempt >= max_fix_attempts:
536
- # Max attempts reached - checkout to main and fail
894
+ # Max attempts reached - stay on branch for easier resume
537
895
  console.error(f"Max fix attempts ({max_fix_attempts}) reached")
538
- self._checkout_to_main()
896
+ previous_status = state.status
539
897
  state.status = "blocked"
898
+ self._emit_status_changed(
899
+ previous_status, "blocked", state, "Max fix attempts reached"
900
+ )
540
901
  self.state_manager.save_state(state)
541
902
  console.info(self.tracker.get_cost_report())
903
+ self._emit_run_completed(
904
+ state, 1, "blocked", run_start_time, "Max fix attempts reached"
905
+ )
542
906
  return 1
543
907
 
544
908
  # Attempt to fix
545
909
  console.info(f"Attempting fix {fix_attempt + 1}/{max_fix_attempts}...")
546
910
 
547
911
  if not self._run_verification_fix(verification["details"], state):
548
- # Fix failed - checkout to main and fail
912
+ # Fix failed - stay on branch for easier resume
549
913
  console.error("Fix attempt failed")
550
- self._checkout_to_main()
914
+ previous_status = state.status
551
915
  state.status = "blocked"
916
+ self._emit_status_changed(
917
+ previous_status, "blocked", state, "Verification fix failed"
918
+ )
552
919
  self.state_manager.save_state(state)
553
920
  console.info(self.tracker.get_cost_report())
921
+ self._emit_run_completed(
922
+ state, 1, "failed", run_start_time, "Verification fix failed"
923
+ )
554
924
  return 1
555
925
 
556
926
  # Wait for PR to be created and merge it
557
927
  if not self._wait_for_fix_pr_merge(state):
558
- # PR merge failed - checkout to main and fail
928
+ # PR merge failed - stay on branch for easier resume
559
929
  console.error("Fix PR merge failed")
560
- self._checkout_to_main()
930
+ previous_status = state.status
561
931
  state.status = "blocked"
932
+ self._emit_status_changed(
933
+ previous_status, "blocked", state, "Fix PR merge failed"
934
+ )
562
935
  self.state_manager.save_state(state)
563
936
  console.info(self.tracker.get_cost_report())
937
+ self._emit_run_completed(
938
+ state, 1, "blocked", run_start_time, "Fix PR merge failed"
939
+ )
564
940
  return 1
565
941
 
566
942
  # PR merged - increment and retry verification
@@ -568,10 +944,17 @@ class WorkLoopOrchestrator:
568
944
  console.info("Fix PR merged - re-verifying...")
569
945
 
570
946
  # Should not reach here, but handle it gracefully
571
- self._checkout_to_main()
947
+ # Stay on branch for easier resume
948
+ previous_status = state.status
572
949
  state.status = "blocked"
950
+ self._emit_status_changed(
951
+ previous_status, "blocked", state, "Unexpected exit from verification loop"
952
+ )
573
953
  self.state_manager.save_state(state)
574
954
  console.info(self.tracker.get_cost_report())
955
+ self._emit_run_completed(
956
+ state, 1, "blocked", run_start_time, "Unexpected exit from verification loop"
957
+ )
575
958
  return 1
576
959
 
577
960
  except KeyboardInterrupt:
@@ -580,23 +963,30 @@ class WorkLoopOrchestrator:
580
963
  stop_listening()
581
964
  unregister_handlers()
582
965
  console.error(f"Orchestrator error: {e.message}")
966
+ previous_status = state.status
583
967
  state.status = "failed"
968
+ self._emit_status_changed(previous_status, "failed", state, e.message)
584
969
  try:
585
- self._checkout_to_main()
970
+ # Don't checkout to main on error - stay on branch for easier resume
586
971
  self.state_manager.save_state(state)
587
972
  except Exception:
588
973
  pass # Best effort - state save failed but we still return error
974
+ self._emit_run_completed(state, 1, "failed", run_start_time, e.message)
589
975
  return 1
590
976
  except Exception as e:
591
977
  stop_listening()
592
978
  unregister_handlers()
593
979
  console.error(f"Unexpected error: {type(e).__name__}: {e}")
980
+ previous_status = state.status
594
981
  state.status = "failed"
982
+ error_message = f"{type(e).__name__}: {e}"
983
+ self._emit_status_changed(previous_status, "failed", state, error_message)
595
984
  try:
596
- self._checkout_to_main()
985
+ # Don't checkout to main on error - stay on branch for easier resume
597
986
  self.state_manager.save_state(state)
598
987
  except Exception:
599
988
  pass # Best effort - state save failed but we still return error
989
+ self._emit_run_completed(state, 1, "failed", run_start_time, error_message)
600
990
  return 1
601
991
 
602
992
  def _run_workflow_cycle(self, state: TaskState) -> int | None:
@@ -646,24 +1036,38 @@ class WorkLoopOrchestrator:
646
1036
 
647
1037
  except NoPlanFoundError as e:
648
1038
  console.error(e.message)
1039
+ previous_status = state.status
649
1040
  state.status = "failed"
1041
+ self._emit_status_changed(previous_status, "failed", state, e.message)
650
1042
  self.state_manager.save_state(state)
651
1043
  return 1
652
1044
  except NoTasksFoundError:
653
1045
  return None # Continue to completion check
654
1046
  except ContentFilterError as e:
655
1047
  console.error(f"Content filter: {e.message}")
1048
+ previous_status = state.status
656
1049
  state.status = "blocked"
1050
+ self._emit_status_changed(
1051
+ previous_status, "blocked", state, f"Content filter: {e.message}"
1052
+ )
657
1053
  self.state_manager.save_state(state)
658
1054
  return 1
659
1055
  except CircuitBreakerError as e:
660
1056
  console.warning(f"Circuit breaker: {e.message}")
1057
+ previous_status = state.status
661
1058
  state.status = "blocked"
1059
+ self._emit_status_changed(
1060
+ previous_status, "blocked", state, f"Circuit breaker: {e.message}"
1061
+ )
662
1062
  self.state_manager.save_state(state)
663
1063
  return 1
664
1064
  except ConsecutiveFailuresError as e:
665
1065
  console.error(f"Consecutive failures: {e.message}")
1066
+ previous_status = state.status
666
1067
  state.status = "blocked"
1068
+ self._emit_status_changed(
1069
+ previous_status, "blocked", state, f"Consecutive failures: {e.message}"
1070
+ )
667
1071
  self.state_manager.save_state(state)
668
1072
  return 1
669
1073
  except AgentError as e:
@@ -776,6 +1180,25 @@ class WorkLoopOrchestrator:
776
1180
  branch=current_branch,
777
1181
  )
778
1182
 
1183
+ # Check mailbox for any messages after task completion
1184
+ # If messages exist, merge them and update the plan
1185
+ logger.debug(
1186
+ "Checking mailbox after task %d completion",
1187
+ state.current_task_index,
1188
+ )
1189
+ plan_updated = self._check_and_process_mailbox(state)
1190
+ if plan_updated:
1191
+ # Plan was updated - need to refresh total_tasks count
1192
+ # The task list may have changed
1193
+ old_total = total_tasks
1194
+ total_tasks = self._get_total_tasks(state)
1195
+ logger.info(
1196
+ "Plan updated from mailbox: old_total_tasks=%d, new_total_tasks=%d",
1197
+ old_total,
1198
+ total_tasks,
1199
+ )
1200
+ console.detail(f"Plan updated - new total tasks: {total_tasks}")
1201
+
779
1202
  # Determine if we should trigger PR workflow or continue to next task
780
1203
  # Two modes: pr_per_task=True (one PR per task) or grouped mode (one PR per group)
781
1204
  if state.options.pr_per_task:
@@ -13,7 +13,7 @@ from collections.abc import Callable
13
13
  from concurrent.futures import Future, ThreadPoolExecutor
14
14
  from dataclasses import dataclass, field
15
15
  from enum import Enum
16
- from typing import Any, Generic, TypeVar
16
+ from typing import Any, TypeVar
17
17
 
18
18
  from .circuit_breaker import CircuitBreaker, CircuitBreakerConfig, get_circuit_breaker
19
19
 
@@ -31,7 +31,7 @@ class TaskStatus(Enum):
31
31
 
32
32
 
33
33
  @dataclass
34
- class TaskResult(Generic[T]):
34
+ class TaskResult[T]:
35
35
  """Result of a parallel task execution."""
36
36
 
37
37
  task_id: str
@@ -82,7 +82,7 @@ class ParallelExecutorConfig:
82
82
 
83
83
 
84
84
  @dataclass
85
- class ParallelTask(Generic[T]):
85
+ class ParallelTask[T]:
86
86
  """A task to be executed in parallel."""
87
87
 
88
88
  task_id: str
@@ -406,7 +406,7 @@ class AsyncParallelExecutor:
406
406
  task_result = await asyncio.wait_for(coro, timeout=self.timeout)
407
407
  result.result = task_result
408
408
  result.status = TaskStatus.COMPLETED
409
- except asyncio.TimeoutError as e:
409
+ except TimeoutError as e:
410
410
  result.error = e
411
411
  result.status = TaskStatus.FAILED
412
412
  except Exception as e: