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
|
@@ -4,25 +4,39 @@ This module implements REST endpoints for managing work items
|
|
|
4
4
|
in project work queues.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
from typing import List, Optional
|
|
7
|
+
from typing import Dict, List, Optional
|
|
8
8
|
|
|
9
|
-
from fastapi import APIRouter, HTTPException, Query
|
|
9
|
+
from fastapi import APIRouter, HTTPException, Query, Request
|
|
10
10
|
|
|
11
11
|
from ...models.work import WorkPriority, WorkState
|
|
12
12
|
from ...work import WorkQueue
|
|
13
|
-
from ..errors import ProjectNotFoundError
|
|
14
13
|
from ..schemas import CreateWorkRequest, WorkItemResponse
|
|
15
14
|
|
|
16
15
|
router = APIRouter()
|
|
17
16
|
|
|
18
17
|
|
|
19
|
-
def _get_registry():
|
|
20
|
-
"""Get registry instance from app
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if registry is None:
|
|
18
|
+
def _get_registry(request: Request):
|
|
19
|
+
"""Get registry instance from app.state."""
|
|
20
|
+
if not hasattr(request.app.state, "registry") or request.app.state.registry is None:
|
|
24
21
|
raise RuntimeError("Registry not initialized")
|
|
25
|
-
return registry
|
|
22
|
+
return request.app.state.registry
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_work_queues(request: Request) -> Dict:
|
|
26
|
+
"""Get work queues dict from app.state (shared with daemon)."""
|
|
27
|
+
if (
|
|
28
|
+
not hasattr(request.app.state, "work_queues")
|
|
29
|
+
or request.app.state.work_queues is None
|
|
30
|
+
):
|
|
31
|
+
raise RuntimeError("Work queues not initialized")
|
|
32
|
+
return request.app.state.work_queues
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _get_daemon(request: Request):
|
|
36
|
+
"""Get daemon instance from app.state."""
|
|
37
|
+
if not hasattr(request.app.state, "daemon_instance"):
|
|
38
|
+
return None
|
|
39
|
+
return request.app.state.daemon_instance
|
|
26
40
|
|
|
27
41
|
|
|
28
42
|
def _work_item_to_response(work_item) -> WorkItemResponse:
|
|
@@ -51,10 +65,13 @@ def _work_item_to_response(work_item) -> WorkItemResponse:
|
|
|
51
65
|
|
|
52
66
|
|
|
53
67
|
@router.post("/projects/{project_id}/work", response_model=WorkItemResponse)
|
|
54
|
-
async def add_work(
|
|
68
|
+
async def add_work(
|
|
69
|
+
request: Request, project_id: str, work: CreateWorkRequest
|
|
70
|
+
) -> WorkItemResponse:
|
|
55
71
|
"""Add work item to project queue.
|
|
56
72
|
|
|
57
73
|
Args:
|
|
74
|
+
request: FastAPI request (for accessing app.state)
|
|
58
75
|
project_id: Project identifier
|
|
59
76
|
work: Work item creation request
|
|
60
77
|
|
|
@@ -80,22 +97,29 @@ async def add_work(project_id: str, work: CreateWorkRequest) -> WorkItemResponse
|
|
|
80
97
|
...
|
|
81
98
|
}
|
|
82
99
|
"""
|
|
83
|
-
registry = _get_registry()
|
|
100
|
+
registry = _get_registry(request)
|
|
101
|
+
work_queues = _get_work_queues(request)
|
|
102
|
+
daemon = _get_daemon(request)
|
|
84
103
|
|
|
85
104
|
# Get project
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
raise HTTPException(status_code=404, detail=str(e)) from e
|
|
105
|
+
project = registry.get(project_id)
|
|
106
|
+
if project is None:
|
|
107
|
+
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
|
90
108
|
|
|
91
|
-
# Get or create work queue
|
|
92
|
-
|
|
93
|
-
# For now, we'll need to integrate with project's work queue
|
|
94
|
-
# Access or create work queue
|
|
95
|
-
if not hasattr(project, "_work_queue"):
|
|
96
|
-
project._work_queue = WorkQueue(project_id)
|
|
109
|
+
# Get or create work queue (shared with daemon)
|
|
110
|
+
import logging
|
|
97
111
|
|
|
98
|
-
|
|
112
|
+
logger = logging.getLogger(__name__)
|
|
113
|
+
logger.info(
|
|
114
|
+
f"work_queues dict id: {id(work_queues)}, keys: {list(work_queues.keys())}"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if project_id not in work_queues:
|
|
118
|
+
logger.info(f"Creating new work queue for {project_id}")
|
|
119
|
+
work_queues[project_id] = WorkQueue(project_id)
|
|
120
|
+
logger.info(f"After creation, work_queues keys: {list(work_queues.keys())}")
|
|
121
|
+
|
|
122
|
+
queue = work_queues[project_id]
|
|
99
123
|
|
|
100
124
|
# Convert priority int to enum
|
|
101
125
|
priority = WorkPriority(work.priority)
|
|
@@ -105,16 +129,25 @@ async def add_work(project_id: str, work: CreateWorkRequest) -> WorkItemResponse
|
|
|
105
129
|
content=work.content, priority=priority, depends_on=work.depends_on
|
|
106
130
|
)
|
|
107
131
|
|
|
132
|
+
# Ensure daemon has a session for this project (creates if needed)
|
|
133
|
+
if daemon and not daemon.sessions.get(project_id):
|
|
134
|
+
# Session creation will be handled by daemon's main loop
|
|
135
|
+
# when it detects work in the queue
|
|
136
|
+
pass
|
|
137
|
+
|
|
108
138
|
return _work_item_to_response(work_item)
|
|
109
139
|
|
|
110
140
|
|
|
111
141
|
@router.get("/projects/{project_id}/work", response_model=List[WorkItemResponse])
|
|
112
142
|
async def list_work(
|
|
113
|
-
|
|
143
|
+
request: Request,
|
|
144
|
+
project_id: str,
|
|
145
|
+
state: Optional[str] = Query(None, description="Filter by state"),
|
|
114
146
|
) -> List[WorkItemResponse]:
|
|
115
147
|
"""List work items for project.
|
|
116
148
|
|
|
117
149
|
Args:
|
|
150
|
+
request: FastAPI request (for accessing app.state)
|
|
118
151
|
project_id: Project identifier
|
|
119
152
|
state: Optional state filter (pending, queued, in_progress, etc.)
|
|
120
153
|
|
|
@@ -136,19 +169,20 @@ async def list_work(
|
|
|
136
169
|
{"id": "work-1", "state": "queued", ...}
|
|
137
170
|
]
|
|
138
171
|
"""
|
|
139
|
-
registry = _get_registry()
|
|
172
|
+
registry = _get_registry(request)
|
|
173
|
+
work_queues = _get_work_queues(request)
|
|
140
174
|
|
|
141
175
|
# Get project
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
raise HTTPException(status_code=404, detail=str(e)) from e
|
|
176
|
+
project = registry.get(project_id)
|
|
177
|
+
if project is None:
|
|
178
|
+
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
|
146
179
|
|
|
147
|
-
# Get work queue
|
|
148
|
-
if not
|
|
149
|
-
|
|
180
|
+
# Get work queue (shared with daemon)
|
|
181
|
+
if project_id not in work_queues:
|
|
182
|
+
# Return empty list if no work queue exists yet
|
|
183
|
+
return []
|
|
150
184
|
|
|
151
|
-
queue =
|
|
185
|
+
queue = work_queues[project_id]
|
|
152
186
|
|
|
153
187
|
# Parse state filter
|
|
154
188
|
state_filter = None
|
|
@@ -169,10 +203,11 @@ async def list_work(
|
|
|
169
203
|
|
|
170
204
|
|
|
171
205
|
@router.get("/projects/{project_id}/work/{work_id}", response_model=WorkItemResponse)
|
|
172
|
-
async def get_work(project_id: str, work_id: str) -> WorkItemResponse:
|
|
206
|
+
async def get_work(request: Request, project_id: str, work_id: str) -> WorkItemResponse:
|
|
173
207
|
"""Get work item details.
|
|
174
208
|
|
|
175
209
|
Args:
|
|
210
|
+
request: FastAPI request (for accessing app.state)
|
|
176
211
|
project_id: Project identifier
|
|
177
212
|
work_id: Work item identifier
|
|
178
213
|
|
|
@@ -191,19 +226,19 @@ async def get_work(project_id: str, work_id: str) -> WorkItemResponse:
|
|
|
191
226
|
...
|
|
192
227
|
}
|
|
193
228
|
"""
|
|
194
|
-
registry = _get_registry()
|
|
229
|
+
registry = _get_registry(request)
|
|
230
|
+
work_queues = _get_work_queues(request)
|
|
195
231
|
|
|
196
232
|
# Get project
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
raise HTTPException(status_code=404, detail=str(e)) from e
|
|
233
|
+
project = registry.get(project_id)
|
|
234
|
+
if project is None:
|
|
235
|
+
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
|
201
236
|
|
|
202
|
-
# Get work queue
|
|
203
|
-
if not
|
|
237
|
+
# Get work queue (shared with daemon)
|
|
238
|
+
if project_id not in work_queues:
|
|
204
239
|
raise HTTPException(status_code=404, detail="Work queue not found")
|
|
205
240
|
|
|
206
|
-
queue =
|
|
241
|
+
queue = work_queues[project_id]
|
|
207
242
|
|
|
208
243
|
# Get work item
|
|
209
244
|
work_item = queue.get(work_id)
|
|
@@ -214,10 +249,11 @@ async def get_work(project_id: str, work_id: str) -> WorkItemResponse:
|
|
|
214
249
|
|
|
215
250
|
|
|
216
251
|
@router.post("/projects/{project_id}/work/{work_id}/cancel")
|
|
217
|
-
async def cancel_work(project_id: str, work_id: str) -> dict:
|
|
252
|
+
async def cancel_work(request: Request, project_id: str, work_id: str) -> dict:
|
|
218
253
|
"""Cancel pending work item.
|
|
219
254
|
|
|
220
255
|
Args:
|
|
256
|
+
request: FastAPI request (for accessing app.state)
|
|
221
257
|
project_id: Project identifier
|
|
222
258
|
work_id: Work item identifier
|
|
223
259
|
|
|
@@ -231,19 +267,19 @@ async def cancel_work(project_id: str, work_id: str) -> dict:
|
|
|
231
267
|
POST /api/projects/proj-123/work/work-xyz/cancel
|
|
232
268
|
Response: {"status": "cancelled", "id": "work-xyz"}
|
|
233
269
|
"""
|
|
234
|
-
registry = _get_registry()
|
|
270
|
+
registry = _get_registry(request)
|
|
271
|
+
work_queues = _get_work_queues(request)
|
|
235
272
|
|
|
236
273
|
# Get project
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
raise HTTPException(status_code=404, detail=str(e)) from e
|
|
274
|
+
project = registry.get(project_id)
|
|
275
|
+
if project is None:
|
|
276
|
+
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
|
241
277
|
|
|
242
|
-
# Get work queue
|
|
243
|
-
if not
|
|
278
|
+
# Get work queue (shared with daemon)
|
|
279
|
+
if project_id not in work_queues:
|
|
244
280
|
raise HTTPException(status_code=404, detail="Work queue not found")
|
|
245
281
|
|
|
246
|
-
queue =
|
|
282
|
+
queue = work_queues[project_id]
|
|
247
283
|
|
|
248
284
|
# Cancel work item
|
|
249
285
|
if not queue.cancel(work_id):
|
|
@@ -17,10 +17,14 @@ class RegisterProjectRequest(BaseModel):
|
|
|
17
17
|
|
|
18
18
|
Attributes:
|
|
19
19
|
path: Filesystem path to project directory
|
|
20
|
+
project_id: Optional project identifier (UUID generated if omitted)
|
|
20
21
|
name: Optional display name (derived from path if omitted)
|
|
21
22
|
"""
|
|
22
23
|
|
|
23
24
|
path: str = Field(..., description="Filesystem path to project")
|
|
25
|
+
project_id: Optional[str] = Field(
|
|
26
|
+
None, description="Project identifier (UUID generated if omitted)"
|
|
27
|
+
)
|
|
24
28
|
name: Optional[str] = Field(
|
|
25
29
|
None, description="Display name (derived from path if omitted)"
|
|
26
30
|
)
|
claude_mpm/commander/chat/cli.py
CHANGED
|
@@ -5,6 +5,7 @@ import logging
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
from typing import Optional
|
|
7
7
|
|
|
8
|
+
from claude_mpm.commander.env_loader import load_env
|
|
8
9
|
from claude_mpm.commander.instance_manager import InstanceManager
|
|
9
10
|
from claude_mpm.commander.llm.openrouter_client import (
|
|
10
11
|
OpenRouterClient,
|
|
@@ -19,6 +20,9 @@ from claude_mpm.commander.tmux_orchestrator import TmuxOrchestrator
|
|
|
19
20
|
|
|
20
21
|
from .repl import CommanderREPL
|
|
21
22
|
|
|
23
|
+
# Load environment variables at module import
|
|
24
|
+
load_env()
|
|
25
|
+
|
|
22
26
|
logger = logging.getLogger(__name__)
|
|
23
27
|
|
|
24
28
|
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Core coordination components for MPM Commander.
|
|
2
|
+
|
|
3
|
+
This module provides core components that coordinate between different
|
|
4
|
+
subsystems like events, work execution, and session management.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .block_manager import BlockManager
|
|
8
|
+
from .response_manager import ResponseManager, ResponseRoute
|
|
9
|
+
|
|
10
|
+
__all__ = ["BlockManager", "ResponseManager", "ResponseRoute"]
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""BlockManager for coordinating work blocking with events.
|
|
2
|
+
|
|
3
|
+
This module provides BlockManager which automatically blocks/unblocks
|
|
4
|
+
work items based on blocking event detection and resolution.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Dict, List, Optional, Set
|
|
9
|
+
|
|
10
|
+
from ..events.manager import EventManager
|
|
11
|
+
from ..models.events import Event
|
|
12
|
+
from ..models.work import WorkState
|
|
13
|
+
from ..work.executor import WorkExecutor
|
|
14
|
+
from ..work.queue import WorkQueue
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BlockManager:
|
|
20
|
+
"""Coordinates blocking events with work execution.
|
|
21
|
+
|
|
22
|
+
Monitors blocking events and automatically blocks/unblocks work items
|
|
23
|
+
based on event lifecycle. Tracks event-to-work relationships for precise
|
|
24
|
+
unblocking when events are resolved.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
event_manager: EventManager for querying blocking events
|
|
28
|
+
work_queues: Dict mapping project_id -> WorkQueue
|
|
29
|
+
work_executors: Dict mapping project_id -> WorkExecutor
|
|
30
|
+
|
|
31
|
+
Example:
|
|
32
|
+
>>> manager = BlockManager(event_manager, work_queues, work_executors)
|
|
33
|
+
>>> blocked = await manager.check_and_block(event)
|
|
34
|
+
>>> unblocked = await manager.check_and_unblock(event_id)
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
event_manager: EventManager,
|
|
40
|
+
work_queues: Dict[str, WorkQueue],
|
|
41
|
+
work_executors: Dict[str, WorkExecutor],
|
|
42
|
+
):
|
|
43
|
+
"""Initialize BlockManager.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
event_manager: EventManager instance
|
|
47
|
+
work_queues: Dict mapping project_id -> WorkQueue
|
|
48
|
+
work_executors: Dict mapping project_id -> WorkExecutor
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
ValueError: If any required parameter is None
|
|
52
|
+
"""
|
|
53
|
+
if event_manager is None:
|
|
54
|
+
raise ValueError("EventManager cannot be None")
|
|
55
|
+
if work_queues is None:
|
|
56
|
+
raise ValueError("work_queues cannot be None")
|
|
57
|
+
if work_executors is None:
|
|
58
|
+
raise ValueError("work_executors cannot be None")
|
|
59
|
+
|
|
60
|
+
self.event_manager = event_manager
|
|
61
|
+
self.work_queues = work_queues
|
|
62
|
+
self.work_executors = work_executors
|
|
63
|
+
|
|
64
|
+
# Track event-to-work mapping: event_id -> set of work_ids
|
|
65
|
+
self._event_work_mapping: Dict[str, Set[str]] = {}
|
|
66
|
+
|
|
67
|
+
logger.debug("BlockManager initialized")
|
|
68
|
+
|
|
69
|
+
async def check_and_block(self, event: Event) -> List[str]:
|
|
70
|
+
"""Check if event is blocking and block affected work.
|
|
71
|
+
|
|
72
|
+
When a blocking event is detected:
|
|
73
|
+
1. Determine blocking scope (project or all)
|
|
74
|
+
2. Find all in-progress work items in scope
|
|
75
|
+
3. Block each work item via WorkExecutor
|
|
76
|
+
4. Track event-to-work mapping for later unblocking
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
event: Event to check for blocking
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
List of work item IDs that were blocked
|
|
83
|
+
|
|
84
|
+
Example:
|
|
85
|
+
>>> event = Event(type=EventType.ERROR, ...)
|
|
86
|
+
>>> blocked = await manager.check_and_block(event)
|
|
87
|
+
>>> print(f"Blocked {len(blocked)} work items")
|
|
88
|
+
"""
|
|
89
|
+
if not event.is_blocking:
|
|
90
|
+
logger.debug("Event %s is not blocking, no action needed", event.id)
|
|
91
|
+
return []
|
|
92
|
+
|
|
93
|
+
logger.info(
|
|
94
|
+
"Processing blocking event %s (scope: %s): %s",
|
|
95
|
+
event.id,
|
|
96
|
+
event.blocking_scope,
|
|
97
|
+
event.title,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
blocked_work_ids = []
|
|
101
|
+
|
|
102
|
+
# Determine which projects to block based on scope
|
|
103
|
+
if event.blocking_scope == "all":
|
|
104
|
+
# Block all projects
|
|
105
|
+
target_projects = list(self.work_queues.keys())
|
|
106
|
+
logger.info("Event %s blocks ALL projects", event.id)
|
|
107
|
+
elif event.blocking_scope == "project":
|
|
108
|
+
# Block only this project
|
|
109
|
+
target_projects = [event.project_id]
|
|
110
|
+
logger.info("Event %s blocks project %s only", event.id, event.project_id)
|
|
111
|
+
else:
|
|
112
|
+
logger.warning(
|
|
113
|
+
"Unknown blocking scope '%s' for event %s",
|
|
114
|
+
event.blocking_scope,
|
|
115
|
+
event.id,
|
|
116
|
+
)
|
|
117
|
+
return []
|
|
118
|
+
|
|
119
|
+
# Block in-progress work in target projects
|
|
120
|
+
for project_id in target_projects:
|
|
121
|
+
queue = self.work_queues.get(project_id)
|
|
122
|
+
if not queue:
|
|
123
|
+
logger.debug("No work queue for project %s", project_id)
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
executor = self.work_executors.get(project_id)
|
|
127
|
+
if not executor:
|
|
128
|
+
logger.debug("No work executor for project %s", project_id)
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
# Get in-progress work items
|
|
132
|
+
in_progress = queue.list(WorkState.IN_PROGRESS)
|
|
133
|
+
|
|
134
|
+
for work_item in in_progress:
|
|
135
|
+
# Block the work item
|
|
136
|
+
block_reason = f"Event {event.id}: {event.title}"
|
|
137
|
+
success = await executor.handle_block(work_item.id, block_reason)
|
|
138
|
+
|
|
139
|
+
if success:
|
|
140
|
+
blocked_work_ids.append(work_item.id)
|
|
141
|
+
logger.info(
|
|
142
|
+
"Blocked work item %s for project %s: %s",
|
|
143
|
+
work_item.id,
|
|
144
|
+
project_id,
|
|
145
|
+
block_reason,
|
|
146
|
+
)
|
|
147
|
+
else:
|
|
148
|
+
logger.warning(
|
|
149
|
+
"Failed to block work item %s for project %s",
|
|
150
|
+
work_item.id,
|
|
151
|
+
project_id,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Track event-to-work mapping
|
|
155
|
+
if blocked_work_ids:
|
|
156
|
+
self._event_work_mapping[event.id] = set(blocked_work_ids)
|
|
157
|
+
logger.info(
|
|
158
|
+
"Event %s blocked %d work items: %s",
|
|
159
|
+
event.id,
|
|
160
|
+
len(blocked_work_ids),
|
|
161
|
+
blocked_work_ids,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
return blocked_work_ids
|
|
165
|
+
|
|
166
|
+
async def check_and_unblock(self, event_id: str) -> List[str]:
|
|
167
|
+
"""Unblock work items when event is resolved.
|
|
168
|
+
|
|
169
|
+
When a blocking event is resolved:
|
|
170
|
+
1. Look up which work items were blocked by this event
|
|
171
|
+
2. Unblock each work item via WorkExecutor
|
|
172
|
+
3. Remove event-to-work mapping
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
event_id: ID of resolved event
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
List of work item IDs that were unblocked
|
|
179
|
+
|
|
180
|
+
Example:
|
|
181
|
+
>>> unblocked = await manager.check_and_unblock("evt_123")
|
|
182
|
+
>>> print(f"Unblocked {len(unblocked)} work items")
|
|
183
|
+
"""
|
|
184
|
+
# Get work items blocked by this event
|
|
185
|
+
work_ids = self._event_work_mapping.pop(event_id, set())
|
|
186
|
+
|
|
187
|
+
if not work_ids:
|
|
188
|
+
logger.debug("No work items blocked by event %s", event_id)
|
|
189
|
+
return []
|
|
190
|
+
|
|
191
|
+
logger.info(
|
|
192
|
+
"Unblocking %d work items for resolved event %s", len(work_ids), event_id
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
unblocked_work_ids = []
|
|
196
|
+
|
|
197
|
+
# Unblock each work item
|
|
198
|
+
for work_id in work_ids:
|
|
199
|
+
# Find which project this work belongs to
|
|
200
|
+
project_id = self._find_work_project(work_id)
|
|
201
|
+
if not project_id:
|
|
202
|
+
logger.warning("Cannot find project for work item %s", work_id)
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
executor = self.work_executors.get(project_id)
|
|
206
|
+
if not executor:
|
|
207
|
+
logger.warning("No executor for project %s", project_id)
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
# Unblock the work item
|
|
211
|
+
success = await executor.handle_unblock(work_id)
|
|
212
|
+
|
|
213
|
+
if success:
|
|
214
|
+
unblocked_work_ids.append(work_id)
|
|
215
|
+
logger.info("Unblocked work item %s", work_id)
|
|
216
|
+
else:
|
|
217
|
+
logger.warning("Failed to unblock work item %s", work_id)
|
|
218
|
+
|
|
219
|
+
return unblocked_work_ids
|
|
220
|
+
|
|
221
|
+
def _find_work_project(self, work_id: str) -> Optional[str]:
|
|
222
|
+
"""Find which project a work item belongs to.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
work_id: Work item ID to search for
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
Project ID if found, None otherwise
|
|
229
|
+
"""
|
|
230
|
+
for project_id, queue in self.work_queues.items():
|
|
231
|
+
work_item = queue.get(work_id)
|
|
232
|
+
if work_item:
|
|
233
|
+
return project_id
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
def get_blocked_work(self, event_id: str) -> Set[str]:
|
|
237
|
+
"""Get work items blocked by a specific event.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
event_id: Event ID to check
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Set of work item IDs blocked by this event
|
|
244
|
+
|
|
245
|
+
Example:
|
|
246
|
+
>>> work_ids = manager.get_blocked_work("evt_123")
|
|
247
|
+
"""
|
|
248
|
+
return self._event_work_mapping.get(event_id, set()).copy()
|
|
249
|
+
|
|
250
|
+
def get_blocking_events(self, work_id: str) -> List[str]:
|
|
251
|
+
"""Get events that are blocking a specific work item.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
work_id: Work item ID to check
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
List of event IDs blocking this work item
|
|
258
|
+
|
|
259
|
+
Example:
|
|
260
|
+
>>> events = manager.get_blocking_events("work-123")
|
|
261
|
+
"""
|
|
262
|
+
blocking_events = []
|
|
263
|
+
for event_id, work_ids in self._event_work_mapping.items():
|
|
264
|
+
if work_id in work_ids:
|
|
265
|
+
blocking_events.append(event_id)
|
|
266
|
+
return blocking_events
|
|
267
|
+
|
|
268
|
+
def is_work_blocked(self, work_id: str) -> bool:
|
|
269
|
+
"""Check if a work item is currently blocked.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
work_id: Work item ID to check
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
True if work item is blocked by any event, False otherwise
|
|
276
|
+
|
|
277
|
+
Example:
|
|
278
|
+
>>> if manager.is_work_blocked("work-123"):
|
|
279
|
+
... print("Work is blocked")
|
|
280
|
+
"""
|
|
281
|
+
return len(self.get_blocking_events(work_id)) > 0
|
|
282
|
+
|
|
283
|
+
def clear_project_mappings(self, project_id: str) -> int:
|
|
284
|
+
"""Clear all event-work mappings for a project.
|
|
285
|
+
|
|
286
|
+
Called when a project is shut down or reset.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
project_id: Project ID to clear
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
Number of work items that had mappings removed
|
|
293
|
+
|
|
294
|
+
Example:
|
|
295
|
+
>>> count = manager.clear_project_mappings("proj_123")
|
|
296
|
+
"""
|
|
297
|
+
queue = self.work_queues.get(project_id)
|
|
298
|
+
if not queue:
|
|
299
|
+
return 0
|
|
300
|
+
|
|
301
|
+
# Get all work IDs for this project
|
|
302
|
+
all_work = queue.list()
|
|
303
|
+
project_work_ids = {w.id for w in all_work}
|
|
304
|
+
|
|
305
|
+
removed_count = 0
|
|
306
|
+
|
|
307
|
+
# Remove work items from event mappings
|
|
308
|
+
for event_id in list(self._event_work_mapping.keys()):
|
|
309
|
+
work_ids = self._event_work_mapping[event_id]
|
|
310
|
+
original_len = len(work_ids)
|
|
311
|
+
|
|
312
|
+
# Remove project work items
|
|
313
|
+
work_ids.difference_update(project_work_ids)
|
|
314
|
+
|
|
315
|
+
removed_count += original_len - len(work_ids)
|
|
316
|
+
|
|
317
|
+
# Remove empty mappings
|
|
318
|
+
if not work_ids:
|
|
319
|
+
del self._event_work_mapping[event_id]
|
|
320
|
+
|
|
321
|
+
logger.info(
|
|
322
|
+
"Cleared %d work item mappings for project %s", removed_count, project_id
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
return removed_count
|