claude-mpm 5.6.10__py3-none-any.whl → 5.6.17__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.
Potentially problematic release.
This version of claude-mpm might be problematic. Click here for more details.
- claude_mpm/VERSION +1 -1
- claude_mpm/cli/commands/commander.py +173 -3
- claude_mpm/cli/parsers/commander_parser.py +41 -8
- claude_mpm/cli/startup.py +104 -1
- claude_mpm/cli/startup_display.py +2 -1
- claude_mpm/commander/__init__.py +6 -0
- claude_mpm/commander/adapters/__init__.py +32 -3
- claude_mpm/commander/adapters/auggie.py +260 -0
- claude_mpm/commander/adapters/base.py +98 -1
- claude_mpm/commander/adapters/claude_code.py +32 -1
- claude_mpm/commander/adapters/codex.py +237 -0
- claude_mpm/commander/adapters/example_usage.py +310 -0
- claude_mpm/commander/adapters/mpm.py +389 -0
- claude_mpm/commander/adapters/registry.py +204 -0
- claude_mpm/commander/api/app.py +32 -16
- claude_mpm/commander/api/routes/messages.py +11 -11
- claude_mpm/commander/api/routes/projects.py +20 -20
- claude_mpm/commander/api/routes/sessions.py +19 -21
- claude_mpm/commander/api/routes/work.py +86 -50
- claude_mpm/commander/api/schemas.py +4 -0
- claude_mpm/commander/chat/cli.py +4 -0
- claude_mpm/commander/core/__init__.py +10 -0
- claude_mpm/commander/core/block_manager.py +325 -0
- claude_mpm/commander/core/response_manager.py +323 -0
- claude_mpm/commander/daemon.py +206 -10
- claude_mpm/commander/env_loader.py +59 -0
- claude_mpm/commander/memory/__init__.py +45 -0
- claude_mpm/commander/memory/compression.py +347 -0
- claude_mpm/commander/memory/embeddings.py +230 -0
- claude_mpm/commander/memory/entities.py +310 -0
- claude_mpm/commander/memory/example_usage.py +290 -0
- claude_mpm/commander/memory/integration.py +325 -0
- claude_mpm/commander/memory/search.py +381 -0
- claude_mpm/commander/memory/store.py +657 -0
- claude_mpm/commander/registry.py +10 -4
- claude_mpm/commander/runtime/monitor.py +32 -2
- claude_mpm/commander/work/executor.py +38 -20
- claude_mpm/commander/workflow/event_handler.py +25 -3
- claude_mpm/core/claude_runner.py +143 -0
- claude_mpm/core/output_style_manager.py +34 -7
- claude_mpm/hooks/claude_hooks/__pycache__/event_handlers.cpython-311.pyc +0 -0
- claude_mpm/hooks/claude_hooks/__pycache__/event_handlers.cpython-314.pyc +0 -0
- claude_mpm/hooks/claude_hooks/__pycache__/installer.cpython-311.pyc +0 -0
- claude_mpm/hooks/claude_hooks/auto_pause_handler.py +0 -0
- claude_mpm/hooks/claude_hooks/event_handlers.py +22 -0
- claude_mpm/hooks/claude_hooks/hook_handler.py +0 -0
- claude_mpm/hooks/claude_hooks/memory_integration.py +0 -0
- claude_mpm/hooks/claude_hooks/response_tracking.py +0 -0
- claude_mpm/hooks/claude_hooks/services/__pycache__/connection_manager.cpython-311.pyc +0 -0
- claude_mpm/hooks/templates/pre_tool_use_template.py +0 -0
- claude_mpm/scripts/start_activity_logging.py +0 -0
- claude_mpm/skills/__init__.py +2 -1
- claude_mpm/skills/bundled/pm/mpm-session-pause/SKILL.md +170 -0
- claude_mpm/skills/registry.py +295 -90
- {claude_mpm-5.6.10.dist-info → claude_mpm-5.6.17.dist-info}/METADATA +5 -3
- {claude_mpm-5.6.10.dist-info → claude_mpm-5.6.17.dist-info}/RECORD +55 -36
- {claude_mpm-5.6.10.dist-info → claude_mpm-5.6.17.dist-info}/WHEEL +0 -0
- {claude_mpm-5.6.10.dist-info → claude_mpm-5.6.17.dist-info}/entry_points.txt +0 -0
- {claude_mpm-5.6.10.dist-info → claude_mpm-5.6.17.dist-info}/licenses/LICENSE +0 -0
- {claude_mpm-5.6.10.dist-info → claude_mpm-5.6.17.dist-info}/licenses/LICENSE-FAQ.md +0 -0
- {claude_mpm-5.6.10.dist-info → claude_mpm-5.6.17.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""ResponseManager for centralized response routing and validation.
|
|
2
|
+
|
|
3
|
+
This module provides ResponseManager which handles response validation,
|
|
4
|
+
routing, and delivery to runtime sessions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
from ..events.manager import EventManager
|
|
13
|
+
from ..models.events import Event, EventType
|
|
14
|
+
from ..runtime.executor import RuntimeExecutor
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _utc_now() -> datetime:
|
|
20
|
+
"""Return current UTC time with timezone info."""
|
|
21
|
+
return datetime.now(timezone.utc)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ResponseRoute:
|
|
26
|
+
"""Encapsulates a validated response ready for delivery.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
event: Event being responded to
|
|
30
|
+
response: User's response text
|
|
31
|
+
valid: Whether validation passed
|
|
32
|
+
validation_errors: List of validation error messages
|
|
33
|
+
timestamp: When the route was created
|
|
34
|
+
delivered: Whether response has been delivered
|
|
35
|
+
delivery_timestamp: When the response was delivered
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
event: Event
|
|
39
|
+
response: str
|
|
40
|
+
valid: bool
|
|
41
|
+
validation_errors: List[str] = field(default_factory=list)
|
|
42
|
+
timestamp: datetime = field(default_factory=_utc_now)
|
|
43
|
+
delivered: bool = False
|
|
44
|
+
delivery_timestamp: Optional[datetime] = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ResponseManager:
|
|
48
|
+
"""Centralizes response validation, routing, and delivery.
|
|
49
|
+
|
|
50
|
+
Provides centralized response handling with validation and routing
|
|
51
|
+
capabilities for event responses.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
event_manager: EventManager for retrieving events
|
|
55
|
+
runtime_executor: Optional RuntimeExecutor for response delivery
|
|
56
|
+
_response_history: History of all response attempts per event
|
|
57
|
+
|
|
58
|
+
Example:
|
|
59
|
+
>>> manager = ResponseManager(event_manager, runtime_executor)
|
|
60
|
+
>>> valid, errors = manager.validate_response(event, "staging")
|
|
61
|
+
>>> if valid:
|
|
62
|
+
... route = manager.validate_and_route(event_id, "staging")
|
|
63
|
+
... success = await manager.deliver_response(route)
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
event_manager: EventManager,
|
|
69
|
+
runtime_executor: Optional[RuntimeExecutor] = None,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Initialize ResponseManager.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
event_manager: EventManager instance for retrieving events
|
|
75
|
+
runtime_executor: Optional RuntimeExecutor for response delivery
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
ValueError: If event_manager is None
|
|
79
|
+
"""
|
|
80
|
+
if event_manager is None:
|
|
81
|
+
raise ValueError("EventManager cannot be None")
|
|
82
|
+
|
|
83
|
+
self.event_manager = event_manager
|
|
84
|
+
self.runtime_executor = runtime_executor
|
|
85
|
+
self._response_history: Dict[str, List[ResponseRoute]] = {}
|
|
86
|
+
|
|
87
|
+
logger.debug(
|
|
88
|
+
"ResponseManager initialized (runtime_executor: %s)",
|
|
89
|
+
"enabled" if runtime_executor else "disabled",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def validate_response(self, event: Event, response: str) -> Tuple[bool, List[str]]:
|
|
93
|
+
"""Validate response against event constraints.
|
|
94
|
+
|
|
95
|
+
Validation rules:
|
|
96
|
+
1. Empty responses: Not allowed for blocking events
|
|
97
|
+
2. DECISION_NEEDED options: Response must match one of the options
|
|
98
|
+
3. Response whitespace: Stripped before validation
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
event: Event being responded to
|
|
102
|
+
response: User's response
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Tuple of (is_valid, list_of_error_messages)
|
|
106
|
+
|
|
107
|
+
Example:
|
|
108
|
+
>>> valid, errors = manager.validate_response(event, "staging")
|
|
109
|
+
>>> if not valid:
|
|
110
|
+
... for error in errors:
|
|
111
|
+
... print(f"Validation error: {error}")
|
|
112
|
+
"""
|
|
113
|
+
errors: List[str] = []
|
|
114
|
+
|
|
115
|
+
# Strip whitespace for validation
|
|
116
|
+
response_stripped = response.strip()
|
|
117
|
+
|
|
118
|
+
# Rule 1: Empty responses not allowed for blocking events
|
|
119
|
+
if event.is_blocking and not response_stripped:
|
|
120
|
+
errors.append("Response cannot be empty for blocking events")
|
|
121
|
+
|
|
122
|
+
# Rule 2: DECISION_NEEDED events must use one of the provided options
|
|
123
|
+
if event.type == EventType.DECISION_NEEDED and event.options:
|
|
124
|
+
if response_stripped not in event.options:
|
|
125
|
+
errors.append(
|
|
126
|
+
f"Response must be one of: {', '.join(event.options)}. "
|
|
127
|
+
f"Got: '{response_stripped}'"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Future validation rules can be added here:
|
|
131
|
+
# - Max length check
|
|
132
|
+
# - Format validation (e.g., regex patterns)
|
|
133
|
+
# - Custom validators per event type
|
|
134
|
+
# - Conditional validation based on event context
|
|
135
|
+
|
|
136
|
+
is_valid = len(errors) == 0
|
|
137
|
+
return is_valid, errors
|
|
138
|
+
|
|
139
|
+
def validate_and_route(
|
|
140
|
+
self, event_id: str, response: str
|
|
141
|
+
) -> Optional[ResponseRoute]:
|
|
142
|
+
"""Create a validated ResponseRoute for an event.
|
|
143
|
+
|
|
144
|
+
Retrieves the event, validates the response, and creates a ResponseRoute
|
|
145
|
+
with validation results.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
event_id: ID of event to respond to
|
|
149
|
+
response: User's response
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
ResponseRoute with validation results, or None if event not found
|
|
153
|
+
|
|
154
|
+
Example:
|
|
155
|
+
>>> route = manager.validate_and_route("evt_123", "staging")
|
|
156
|
+
>>> if route and route.valid:
|
|
157
|
+
... await manager.deliver_response(route)
|
|
158
|
+
>>> elif route:
|
|
159
|
+
... print(f"Validation failed: {route.validation_errors}")
|
|
160
|
+
"""
|
|
161
|
+
# Get the event
|
|
162
|
+
event = self.event_manager.get(event_id)
|
|
163
|
+
if not event:
|
|
164
|
+
logger.warning("Event not found: %s", event_id)
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
# Validate response
|
|
168
|
+
valid, errors = self.validate_response(event, response)
|
|
169
|
+
|
|
170
|
+
# Create route
|
|
171
|
+
route = ResponseRoute(
|
|
172
|
+
event=event,
|
|
173
|
+
response=response,
|
|
174
|
+
valid=valid,
|
|
175
|
+
validation_errors=errors,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
logger.debug(
|
|
179
|
+
"Created route for event %s: valid=%s, errors=%s",
|
|
180
|
+
event_id,
|
|
181
|
+
valid,
|
|
182
|
+
errors,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
return route
|
|
186
|
+
|
|
187
|
+
async def deliver_response(self, route: ResponseRoute) -> bool:
|
|
188
|
+
"""Deliver a validated response to the runtime.
|
|
189
|
+
|
|
190
|
+
Records the response in event history and attempts delivery to the
|
|
191
|
+
runtime executor if available.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
route: ResponseRoute to deliver
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
True if delivery successful, False otherwise
|
|
198
|
+
|
|
199
|
+
Raises:
|
|
200
|
+
ValueError: If route validation failed
|
|
201
|
+
|
|
202
|
+
Example:
|
|
203
|
+
>>> route = manager.validate_and_route("evt_123", "yes")
|
|
204
|
+
>>> if route and route.valid:
|
|
205
|
+
... success = await manager.deliver_response(route)
|
|
206
|
+
... if success:
|
|
207
|
+
... print("Response delivered successfully")
|
|
208
|
+
"""
|
|
209
|
+
if not route.valid:
|
|
210
|
+
error_msg = "; ".join(route.validation_errors)
|
|
211
|
+
raise ValueError(f"Cannot deliver invalid response: {error_msg}")
|
|
212
|
+
|
|
213
|
+
# Mark route as delivered
|
|
214
|
+
route.delivered = True
|
|
215
|
+
route.delivery_timestamp = _utc_now()
|
|
216
|
+
|
|
217
|
+
# Track in history
|
|
218
|
+
self._add_to_history(route)
|
|
219
|
+
|
|
220
|
+
# For non-blocking events, no runtime delivery needed
|
|
221
|
+
if not route.event.is_blocking:
|
|
222
|
+
logger.debug(
|
|
223
|
+
"Event %s is non-blocking, no runtime delivery needed",
|
|
224
|
+
route.event.id,
|
|
225
|
+
)
|
|
226
|
+
return True
|
|
227
|
+
|
|
228
|
+
# Deliver to runtime if executor available
|
|
229
|
+
if not self.runtime_executor:
|
|
230
|
+
logger.warning(
|
|
231
|
+
"No runtime executor available, cannot deliver response for event %s",
|
|
232
|
+
route.event.id,
|
|
233
|
+
)
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
# Note: Actual delivery is handled by EventHandler which has session context
|
|
237
|
+
# ResponseManager just validates and tracks responses
|
|
238
|
+
# The EventHandler will call executor.send_message() with session's active_pane
|
|
239
|
+
logger.info(
|
|
240
|
+
"Response validated and ready for delivery (event %s): %s",
|
|
241
|
+
route.event.id,
|
|
242
|
+
route.response[:50],
|
|
243
|
+
)
|
|
244
|
+
return True
|
|
245
|
+
|
|
246
|
+
def _add_to_history(self, route: ResponseRoute) -> None:
|
|
247
|
+
"""Add response route to history tracking.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
route: ResponseRoute to record
|
|
251
|
+
"""
|
|
252
|
+
event_id = route.event.id
|
|
253
|
+
if event_id not in self._response_history:
|
|
254
|
+
self._response_history[event_id] = []
|
|
255
|
+
|
|
256
|
+
self._response_history[event_id].append(route)
|
|
257
|
+
logger.debug(
|
|
258
|
+
"Added response to history for event %s (total: %d)",
|
|
259
|
+
event_id,
|
|
260
|
+
len(self._response_history[event_id]),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
def get_response_history(self, event_id: str) -> List[ResponseRoute]:
|
|
264
|
+
"""Get all response attempts for an event (for audit trail).
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
event_id: Event ID to query
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
List of ResponseRoute objects for this event (chronological order)
|
|
271
|
+
|
|
272
|
+
Example:
|
|
273
|
+
>>> history = manager.get_response_history("evt_123")
|
|
274
|
+
>>> for i, route in enumerate(history, 1):
|
|
275
|
+
... status = "valid" if route.valid else "invalid"
|
|
276
|
+
... print(f"Attempt {i} ({status}): {route.response}")
|
|
277
|
+
"""
|
|
278
|
+
return self._response_history.get(event_id, []).copy()
|
|
279
|
+
|
|
280
|
+
def clear_history(self, event_id: str) -> int:
|
|
281
|
+
"""Clear response history for an event.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
event_id: Event ID to clear
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
Number of history entries removed
|
|
288
|
+
|
|
289
|
+
Example:
|
|
290
|
+
>>> removed = manager.clear_history("evt_123")
|
|
291
|
+
>>> print(f"Cleared {removed} history entries")
|
|
292
|
+
"""
|
|
293
|
+
history = self._response_history.pop(event_id, [])
|
|
294
|
+
count = len(history)
|
|
295
|
+
if count > 0:
|
|
296
|
+
logger.debug("Cleared %d history entries for event %s", count, event_id)
|
|
297
|
+
return count
|
|
298
|
+
|
|
299
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
300
|
+
"""Get statistics about response history.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
Dict with statistics about tracked responses
|
|
304
|
+
|
|
305
|
+
Example:
|
|
306
|
+
>>> stats = manager.get_stats()
|
|
307
|
+
>>> print(f"Total events with history: {stats['total_events']}")
|
|
308
|
+
>>> print(f"Total response attempts: {stats['total_responses']}")
|
|
309
|
+
"""
|
|
310
|
+
total_events = len(self._response_history)
|
|
311
|
+
total_responses = sum(len(routes) for routes in self._response_history.values())
|
|
312
|
+
valid_responses = sum(
|
|
313
|
+
sum(1 for route in routes if route.valid)
|
|
314
|
+
for routes in self._response_history.values()
|
|
315
|
+
)
|
|
316
|
+
invalid_responses = total_responses - valid_responses
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
"total_events": total_events,
|
|
320
|
+
"total_responses": total_responses,
|
|
321
|
+
"valid_responses": valid_responses,
|
|
322
|
+
"invalid_responses": invalid_responses,
|
|
323
|
+
}
|
claude_mpm/commander/daemon.py
CHANGED
|
@@ -15,12 +15,23 @@ from .api.app import (
|
|
|
15
15
|
app,
|
|
16
16
|
)
|
|
17
17
|
from .config import DaemonConfig
|
|
18
|
+
from .core.block_manager import BlockManager
|
|
19
|
+
from .env_loader import load_env
|
|
18
20
|
from .events.manager import EventManager
|
|
19
21
|
from .inbox import Inbox
|
|
22
|
+
from .models.events import EventStatus
|
|
23
|
+
from .parsing.output_parser import OutputParser
|
|
20
24
|
from .persistence import EventStore, StateStore
|
|
21
25
|
from .project_session import ProjectSession, SessionState
|
|
22
26
|
from .registry import ProjectRegistry
|
|
27
|
+
from .runtime.monitor import RuntimeMonitor
|
|
23
28
|
from .tmux_orchestrator import TmuxOrchestrator
|
|
29
|
+
from .work.executor import WorkExecutor
|
|
30
|
+
from .work.queue import WorkQueue
|
|
31
|
+
from .workflow.event_handler import EventHandler
|
|
32
|
+
|
|
33
|
+
# Load environment variables at module import
|
|
34
|
+
load_env()
|
|
24
35
|
|
|
25
36
|
logger = logging.getLogger(__name__)
|
|
26
37
|
|
|
@@ -38,6 +49,11 @@ class CommanderDaemon:
|
|
|
38
49
|
event_manager: Event manager
|
|
39
50
|
inbox: Event inbox
|
|
40
51
|
sessions: Active project sessions by project_id
|
|
52
|
+
work_queues: Work queues by project_id
|
|
53
|
+
work_executors: Work executors by project_id
|
|
54
|
+
block_manager: Block manager for automatic work blocking
|
|
55
|
+
runtime_monitor: Runtime monitor for output monitoring
|
|
56
|
+
event_handler: Event handler for blocking event workflow
|
|
41
57
|
state_store: StateStore for project/session persistence
|
|
42
58
|
event_store: EventStore for event queue persistence
|
|
43
59
|
running: Whether daemon is currently running
|
|
@@ -68,6 +84,8 @@ class CommanderDaemon:
|
|
|
68
84
|
self.event_manager = EventManager()
|
|
69
85
|
self.inbox = Inbox(self.event_manager, self.registry)
|
|
70
86
|
self.sessions: Dict[str, ProjectSession] = {}
|
|
87
|
+
self.work_queues: Dict[str, WorkQueue] = {}
|
|
88
|
+
self.work_executors: Dict[str, WorkExecutor] = {}
|
|
71
89
|
self._running = False
|
|
72
90
|
self._server_task: Optional[asyncio.Task] = None
|
|
73
91
|
self._main_loop_task: Optional[asyncio.Task] = None
|
|
@@ -76,6 +94,30 @@ class CommanderDaemon:
|
|
|
76
94
|
self.state_store = StateStore(config.state_dir)
|
|
77
95
|
self.event_store = EventStore(config.state_dir)
|
|
78
96
|
|
|
97
|
+
# Initialize BlockManager with work queues and executors
|
|
98
|
+
self.block_manager = BlockManager(
|
|
99
|
+
event_manager=self.event_manager,
|
|
100
|
+
work_queues=self.work_queues,
|
|
101
|
+
work_executors=self.work_executors,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Initialize RuntimeMonitor with BlockManager
|
|
105
|
+
parser = OutputParser(self.event_manager)
|
|
106
|
+
self.runtime_monitor = RuntimeMonitor(
|
|
107
|
+
orchestrator=self.orchestrator,
|
|
108
|
+
parser=parser,
|
|
109
|
+
event_manager=self.event_manager,
|
|
110
|
+
poll_interval=config.poll_interval,
|
|
111
|
+
block_manager=self.block_manager,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Initialize EventHandler with BlockManager
|
|
115
|
+
self.event_handler = EventHandler(
|
|
116
|
+
inbox=self.inbox,
|
|
117
|
+
session_manager=self.sessions,
|
|
118
|
+
block_manager=self.block_manager,
|
|
119
|
+
)
|
|
120
|
+
|
|
79
121
|
# Configure logging
|
|
80
122
|
logging.basicConfig(
|
|
81
123
|
level=getattr(logging, config.log_level.upper()),
|
|
@@ -122,12 +164,16 @@ class CommanderDaemon:
|
|
|
122
164
|
# Set up signal handlers
|
|
123
165
|
self._setup_signal_handlers()
|
|
124
166
|
|
|
125
|
-
# Inject
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
167
|
+
# Inject daemon instances into API app.state (BEFORE lifespan runs)
|
|
168
|
+
app.state.registry = self.registry
|
|
169
|
+
app.state.tmux = self.orchestrator
|
|
170
|
+
app.state.event_manager = self.event_manager
|
|
171
|
+
app.state.inbox = self.inbox
|
|
172
|
+
app.state.work_queues = self.work_queues
|
|
173
|
+
app.state.daemon_instance = self
|
|
174
|
+
app.state.session_manager = self.sessions
|
|
175
|
+
app.state.event_handler = self.event_handler
|
|
176
|
+
logger.info(f"Injected work_queues dict id: {id(self.work_queues)}")
|
|
131
177
|
|
|
132
178
|
# Start API server in background
|
|
133
179
|
logger.info(f"Starting API server on {self.config.host}:{self.config.port}")
|
|
@@ -171,6 +217,16 @@ class CommanderDaemon:
|
|
|
171
217
|
except Exception as e:
|
|
172
218
|
logger.error(f"Error stopping session {project_id}: {e}")
|
|
173
219
|
|
|
220
|
+
# Clear BlockManager project mappings
|
|
221
|
+
for project_id in list(self.work_queues.keys()):
|
|
222
|
+
try:
|
|
223
|
+
removed = self.block_manager.clear_project_mappings(project_id)
|
|
224
|
+
logger.debug(
|
|
225
|
+
f"Cleared {removed} work mappings for project {project_id}"
|
|
226
|
+
)
|
|
227
|
+
except Exception as e:
|
|
228
|
+
logger.error(f"Error clearing mappings for {project_id}: {e}")
|
|
229
|
+
|
|
174
230
|
# Cancel main loop task
|
|
175
231
|
if self._main_loop_task and not self._main_loop_task.done():
|
|
176
232
|
self._main_loop_task.cancel()
|
|
@@ -210,9 +266,19 @@ class CommanderDaemon:
|
|
|
210
266
|
|
|
211
267
|
while self._running:
|
|
212
268
|
try:
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
269
|
+
logger.info(f"🔄 Main loop iteration (running={self._running})")
|
|
270
|
+
logger.info(
|
|
271
|
+
f"work_queues dict id: {id(self.work_queues)}, keys: {list(self.work_queues.keys())}"
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
# Check for resolved events and resume sessions
|
|
275
|
+
await self._check_and_resume_sessions()
|
|
276
|
+
|
|
277
|
+
# Check each ProjectSession for runnable work
|
|
278
|
+
logger.info(
|
|
279
|
+
f"Checking for pending work across {len(self.work_queues)} queues"
|
|
280
|
+
)
|
|
281
|
+
await self._execute_pending_work()
|
|
216
282
|
|
|
217
283
|
# Periodic state persistence
|
|
218
284
|
current_time = asyncio.get_event_loop().time()
|
|
@@ -282,7 +348,26 @@ class CommanderDaemon:
|
|
|
282
348
|
if project is None:
|
|
283
349
|
raise ValueError(f"Project not found: {project_id}")
|
|
284
350
|
|
|
285
|
-
|
|
351
|
+
# Create work queue for project if not exists
|
|
352
|
+
if project_id not in self.work_queues:
|
|
353
|
+
self.work_queues[project_id] = WorkQueue(project_id)
|
|
354
|
+
logger.debug(f"Created work queue for project {project_id}")
|
|
355
|
+
|
|
356
|
+
# Create work executor for project if not exists
|
|
357
|
+
if project_id not in self.work_executors:
|
|
358
|
+
from .runtime.executor import RuntimeExecutor
|
|
359
|
+
|
|
360
|
+
runtime_executor = RuntimeExecutor(self.orchestrator)
|
|
361
|
+
self.work_executors[project_id] = WorkExecutor(
|
|
362
|
+
runtime=runtime_executor, queue=self.work_queues[project_id]
|
|
363
|
+
)
|
|
364
|
+
logger.debug(f"Created work executor for project {project_id}")
|
|
365
|
+
|
|
366
|
+
session = ProjectSession(
|
|
367
|
+
project=project,
|
|
368
|
+
orchestrator=self.orchestrator,
|
|
369
|
+
monitor=self.runtime_monitor,
|
|
370
|
+
)
|
|
286
371
|
self.sessions[project_id] = session
|
|
287
372
|
|
|
288
373
|
logger.info(f"Created new session for project {project_id}")
|
|
@@ -363,6 +448,117 @@ class CommanderDaemon:
|
|
|
363
448
|
except Exception as e:
|
|
364
449
|
logger.error(f"Failed to save state: {e}", exc_info=True)
|
|
365
450
|
|
|
451
|
+
async def _check_and_resume_sessions(self) -> None:
|
|
452
|
+
"""Check for resolved events and resume paused sessions.
|
|
453
|
+
|
|
454
|
+
Iterates through all paused sessions, checks if their blocking events
|
|
455
|
+
have been resolved, and resumes execution if ready.
|
|
456
|
+
"""
|
|
457
|
+
for project_id, session in list(self.sessions.items()):
|
|
458
|
+
# Skip non-paused sessions
|
|
459
|
+
if session.state != SessionState.PAUSED:
|
|
460
|
+
continue
|
|
461
|
+
|
|
462
|
+
# Check if pause reason (event ID) is resolved
|
|
463
|
+
if not session.pause_reason:
|
|
464
|
+
logger.warning(f"Session {project_id} paused with no reason, resuming")
|
|
465
|
+
await session.resume()
|
|
466
|
+
continue
|
|
467
|
+
|
|
468
|
+
# Check if event is resolved
|
|
469
|
+
event = self.event_manager.get(session.pause_reason)
|
|
470
|
+
if event and event.status == EventStatus.RESOLVED:
|
|
471
|
+
logger.info(
|
|
472
|
+
f"Event {event.id} resolved, resuming session for {project_id}"
|
|
473
|
+
)
|
|
474
|
+
await session.resume()
|
|
475
|
+
|
|
476
|
+
# Unblock any work items that were blocked by this event
|
|
477
|
+
if project_id in self.work_executors:
|
|
478
|
+
executor = self.work_executors[project_id]
|
|
479
|
+
queue = self.work_queues[project_id]
|
|
480
|
+
|
|
481
|
+
# Find work items blocked by this event
|
|
482
|
+
blocked_items = [
|
|
483
|
+
item
|
|
484
|
+
for item in queue.list()
|
|
485
|
+
if item.state.value == "blocked"
|
|
486
|
+
and item.metadata.get("block_reason") == event.id
|
|
487
|
+
]
|
|
488
|
+
|
|
489
|
+
for item in blocked_items:
|
|
490
|
+
await executor.handle_unblock(item.id)
|
|
491
|
+
logger.info(f"Unblocked work item {item.id}")
|
|
492
|
+
|
|
493
|
+
async def _execute_pending_work(self) -> None:
|
|
494
|
+
"""Execute pending work for all ready sessions.
|
|
495
|
+
|
|
496
|
+
Scans all work queues for pending work. For projects with work but no session,
|
|
497
|
+
auto-creates a session. Then executes the next available work item via WorkExecutor.
|
|
498
|
+
"""
|
|
499
|
+
# First pass: Auto-create and start sessions for projects with pending work
|
|
500
|
+
for project_id, queue in list(self.work_queues.items()):
|
|
501
|
+
logger.info(
|
|
502
|
+
f"Checking queue for {project_id}: pending={queue.pending_count}"
|
|
503
|
+
)
|
|
504
|
+
# Skip if no pending work
|
|
505
|
+
if queue.pending_count == 0:
|
|
506
|
+
continue
|
|
507
|
+
|
|
508
|
+
# Auto-create session if needed
|
|
509
|
+
if project_id not in self.sessions:
|
|
510
|
+
try:
|
|
511
|
+
logger.info(
|
|
512
|
+
f"Auto-creating session for project {project_id} with pending work"
|
|
513
|
+
)
|
|
514
|
+
session = self.get_or_create_session(project_id)
|
|
515
|
+
|
|
516
|
+
# Start the session so it's ready for work
|
|
517
|
+
if session.state.value == "idle":
|
|
518
|
+
logger.info(f"Auto-starting session for {project_id}")
|
|
519
|
+
await session.start()
|
|
520
|
+
except Exception as e:
|
|
521
|
+
logger.error(
|
|
522
|
+
f"Failed to auto-create/start session for {project_id}: {e}",
|
|
523
|
+
exc_info=True,
|
|
524
|
+
)
|
|
525
|
+
continue
|
|
526
|
+
|
|
527
|
+
# Second pass: Execute work for ready sessions
|
|
528
|
+
for project_id, session in list(self.sessions.items()):
|
|
529
|
+
# Skip sessions that aren't ready for work
|
|
530
|
+
if not session.is_ready():
|
|
531
|
+
continue
|
|
532
|
+
|
|
533
|
+
# Skip if no work queue exists
|
|
534
|
+
if project_id not in self.work_queues:
|
|
535
|
+
continue
|
|
536
|
+
|
|
537
|
+
# Get work executor for project
|
|
538
|
+
executor = self.work_executors.get(project_id)
|
|
539
|
+
if not executor:
|
|
540
|
+
logger.warning(
|
|
541
|
+
f"No work executor found for project {project_id}, skipping"
|
|
542
|
+
)
|
|
543
|
+
continue
|
|
544
|
+
|
|
545
|
+
# Check if there's work available
|
|
546
|
+
queue = self.work_queues[project_id]
|
|
547
|
+
if queue.pending_count == 0:
|
|
548
|
+
continue
|
|
549
|
+
|
|
550
|
+
# Try to execute next work item
|
|
551
|
+
try:
|
|
552
|
+
# Pass the session's active pane for execution
|
|
553
|
+
executed = await executor.execute_next(pane_target=session.active_pane)
|
|
554
|
+
if executed:
|
|
555
|
+
logger.info(f"Started work execution for project {project_id}")
|
|
556
|
+
except Exception as e:
|
|
557
|
+
logger.error(
|
|
558
|
+
f"Error executing work for project {project_id}: {e}",
|
|
559
|
+
exc_info=True,
|
|
560
|
+
)
|
|
561
|
+
|
|
366
562
|
|
|
367
563
|
async def main(config: Optional[DaemonConfig] = None) -> None:
|
|
368
564
|
"""Main entry point for running the daemon.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Environment variable loader for Commander.
|
|
2
|
+
|
|
3
|
+
This module handles automatic loading of .env and .env.local files
|
|
4
|
+
at Commander startup. Environment files are loaded with the following precedence:
|
|
5
|
+
1. Existing environment variables (not overridden)
|
|
6
|
+
2. .env.local (local overrides)
|
|
7
|
+
3. .env (defaults)
|
|
8
|
+
|
|
9
|
+
Example:
|
|
10
|
+
>>> from claude_mpm.commander.env_loader import load_env
|
|
11
|
+
>>> load_env()
|
|
12
|
+
# Automatically loads .env.local and .env from project root
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from dotenv import load_dotenv
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_env() -> None:
|
|
24
|
+
"""Load environment variables from .env and .env.local files.
|
|
25
|
+
|
|
26
|
+
Searches for .env and .env.local in the project root directory
|
|
27
|
+
(parent of src/claude_mpm). Files are loaded with override=False,
|
|
28
|
+
meaning existing environment variables take precedence.
|
|
29
|
+
|
|
30
|
+
Precedence (highest to lowest):
|
|
31
|
+
1. Existing environment variables
|
|
32
|
+
2. .env.local
|
|
33
|
+
3. .env
|
|
34
|
+
|
|
35
|
+
Example:
|
|
36
|
+
>>> load_env()
|
|
37
|
+
# Loads .env.local and .env if they exist
|
|
38
|
+
"""
|
|
39
|
+
# Find project root (parent of src/claude_mpm)
|
|
40
|
+
# Current file: src/claude_mpm/commander/env_loader.py
|
|
41
|
+
# Project root: ../../../ (3 levels up)
|
|
42
|
+
current_file = Path(__file__)
|
|
43
|
+
project_root = current_file.parent.parent.parent.parent
|
|
44
|
+
|
|
45
|
+
# Try loading .env.local first (higher priority)
|
|
46
|
+
env_local = project_root / ".env.local"
|
|
47
|
+
if env_local.exists():
|
|
48
|
+
load_dotenv(env_local, override=False)
|
|
49
|
+
logger.debug(f"Loaded environment from {env_local}")
|
|
50
|
+
|
|
51
|
+
# Then load .env (lower priority)
|
|
52
|
+
env_file = project_root / ".env"
|
|
53
|
+
if env_file.exists():
|
|
54
|
+
load_dotenv(env_file, override=False)
|
|
55
|
+
logger.debug(f"Loaded environment from {env_file}")
|
|
56
|
+
|
|
57
|
+
# Log if neither file exists
|
|
58
|
+
if not env_local.exists() and not env_file.exists():
|
|
59
|
+
logger.debug("No .env or .env.local files found in project root")
|