claude-task-master 0.1.4__py3-none-any.whl → 0.1.6__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.
- claude_task_master/__init__.py +1 -1
- claude_task_master/api/models.py +309 -0
- claude_task_master/api/routes.py +229 -0
- claude_task_master/api/routes_repo.py +317 -0
- claude_task_master/bin/claudetm +1 -1
- claude_task_master/cli.py +3 -1
- claude_task_master/cli_commands/mailbox.py +295 -0
- claude_task_master/cli_commands/workflow.py +37 -0
- claude_task_master/core/__init__.py +5 -0
- claude_task_master/core/agent_phases.py +1 -1
- claude_task_master/core/config.py +3 -3
- claude_task_master/core/orchestrator.py +432 -9
- claude_task_master/core/parallel.py +4 -4
- claude_task_master/core/plan_updater.py +199 -0
- claude_task_master/core/pr_context.py +176 -62
- claude_task_master/core/prompts.py +4 -0
- claude_task_master/core/prompts_plan_update.py +148 -0
- claude_task_master/core/prompts_planning.py +6 -2
- claude_task_master/core/state.py +5 -1
- claude_task_master/core/task_runner.py +73 -34
- claude_task_master/core/workflow_stages.py +229 -22
- claude_task_master/github/client_pr.py +86 -20
- claude_task_master/mailbox/__init__.py +23 -0
- claude_task_master/mailbox/merger.py +163 -0
- claude_task_master/mailbox/models.py +95 -0
- claude_task_master/mailbox/storage.py +209 -0
- claude_task_master/mcp/server.py +183 -0
- claude_task_master/mcp/tools.py +921 -0
- claude_task_master/webhooks/events.py +356 -2
- {claude_task_master-0.1.4.dist-info → claude_task_master-0.1.6.dist-info}/METADATA +223 -4
- {claude_task_master-0.1.4.dist-info → claude_task_master-0.1.6.dist-info}/RECORD +34 -26
- {claude_task_master-0.1.4.dist-info → claude_task_master-0.1.6.dist-info}/WHEEL +1 -1
- {claude_task_master-0.1.4.dist-info → claude_task_master-0.1.6.dist-info}/entry_points.txt +0 -0
- {claude_task_master-0.1.4.dist-info → claude_task_master-0.1.6.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 -
|
|
894
|
+
# Max attempts reached - stay on branch for easier resume
|
|
537
895
|
console.error(f"Max fix attempts ({max_fix_attempts}) reached")
|
|
538
|
-
|
|
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 -
|
|
912
|
+
# Fix failed - stay on branch for easier resume
|
|
549
913
|
console.error("Fix attempt failed")
|
|
550
|
-
|
|
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 -
|
|
928
|
+
# PR merge failed - stay on branch for easier resume
|
|
559
929
|
console.error("Fix PR merge failed")
|
|
560
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
409
|
+
except TimeoutError as e:
|
|
410
410
|
result.error = e
|
|
411
411
|
result.status = TaskStatus.FAILED
|
|
412
412
|
except Exception as e:
|