zelos.execution_engine
Execution Engine — Dispatches Tasks to Agents, monitors lifecycle, enforces timeouts.
1""" 2Execution Engine — Dispatches Tasks to Agents, monitors lifecycle, enforces timeouts. 3""" 4 5import threading 6import time 7import uuid 8from collections.abc import Callable 9from dataclasses import dataclass, field 10 11from .event_bus import Event, EventBus 12from .task_graph import Task, TaskGraphEngine, TaskStatus 13 14 15@dataclass 16class InFlightTask: 17 task_id: str 18 agent_id: str 19 agent_name: str 20 started_at: float 21 timeout_at: float 22 heartbeat_at: float = 0.0 # v0.8.0: last heartbeat timestamp 23 heartbeat_timeout_ms: int = 30000 # v0.8.0: heartbeat timeout in ms 24 25 26@dataclass 27class AgentState: 28 agent_id: str 29 agent_name: str 30 status: str = "registered" # registered → connected → heartbeating → disconnected → shutdown 31 operational_state: str = "idle" 32 last_heartbeat_at: float = 0.0 33 heartbeat_interval_ms: int = 30000 34 endpoint: str | None = None 35 max_concurrent_tasks: int = 5 36 current_tasks: list[str] = field(default_factory=list) 37 capabilities: list[dict] = field(default_factory=list) 38 required_credentials: list[str] = field(default_factory=list) # v1.1.0 39 historical_success_rate: float = 0.0 40 total_completed: int = 0 41 total_failed: int = 0 42 43 44class ExecutionEngine: 45 """Kernel component — Task dispatch, lifecycle, timeouts, heartbeat tracking. 46 47 v1.3.0: MPC replan_check hook, incremental verification, diagnosis trigger. 48 """ 49 50 def __init__(self, task_graph: TaskGraphEngine, event_bus: EventBus): 51 self._task_graph = task_graph 52 self._event_bus = event_bus 53 task_graph._event_bus = event_bus # v0.9.0: wire lifecycle events 54 self._in_flight: dict[str, InFlightTask] = {} # task_id → InFlightTask 55 self._agents: dict[str, AgentState] = {} 56 self._agent_dispatch: Callable | None = None # Callback: (agent_id, task) → bool 57 self._agent_cancel: Callable | None = None 58 self._lock = threading.RLock() 59 self._monitor_thread: threading.Thread | None = None 60 self._running = False 61 self._task_inputs: dict[str, dict] = {} # v0.9.0: task input context 62 self._task_start_times: dict[str, float] = {} # v0.9.0: task start timestamps 63 self._credential_injector = None # v1.1.0: set by runtime 64 65 # v1.3.0: MPC Adaptive Loop 66 self._replan_callback: Callable | None = None # Runtime._on_replan 67 self._replan_rules: list = [] # ReplanRule instances 68 self._current_plan = None # PlannerPlan reference 69 self._replan_count: dict[str, int] = {} # goal_id → count 70 self._max_replans: int = 5 71 self._incremental_verifier = None # SchemaVerifier for per-task check 72 self._diagnosis_engine = None # DiagnosisEngine instance (from feature flag) 73 74 # ── Agent Management ── 75 76 def register_agent(self, agent_id: str, agent_name: str, **kwargs) -> AgentState: 77 state = AgentState(agent_id=agent_id, agent_name=agent_name, **kwargs) 78 with self._lock: 79 self._agents[agent_id] = state 80 state.status = "connected" 81 return state 82 83 def heartbeat(self, agent_id: str) -> bool: 84 with self._lock: 85 agent = self._agents.get(agent_id) 86 if not agent: 87 return False 88 agent.last_heartbeat_at = time.time() 89 if agent.status == "connected": 90 agent.status = "heartbeating" 91 elif agent.status == "disconnected": 92 agent.status = "heartbeating" 93 # Update operational state 94 agent.operational_state = "busy" if agent.current_tasks else "idle" 95 return True 96 97 def remove_agent(self, agent_id: str) -> None: 98 with self._lock: 99 self._agents.pop(agent_id, None) 100 # Cancel all in-flight tasks for this agent 101 for tid in list(self._in_flight.keys()): 102 if self._in_flight[tid].agent_id == agent_id: 103 self.cancel_task(tid) 104 105 def get_agent(self, agent_id: str) -> AgentState | None: 106 return self._agents.get(agent_id) 107 108 def list_agents(self) -> list[AgentState]: 109 return list(self._agents.values()) 110 111 # ── Dispatch ── 112 113 def set_task_input_context(self, task_id: str, input_context: dict) -> None: 114 """v0.9.0: Set the input context for a task before dispatch.""" 115 with self._lock: 116 self._task_inputs[task_id] = input_context 117 118 def dispatch(self, task_id: str, agent_id: str) -> bool: 119 """Dispatch a task to an agent. Returns True if agent accepted.""" 120 task = self._task_graph.get_task(task_id) 121 agent = self._agents.get(agent_id) 122 if not task or not agent: 123 return False 124 125 with self._lock: 126 self._task_graph.transition(task_id, TaskStatus.STARTED, agent_id=agent_id) 127 hb_timeout = getattr(task, 'heartbeat_timeout_ms', 0) or agent.heartbeat_interval_ms * 3 128 now = time.time() 129 in_flight = InFlightTask( 130 task_id=task_id, 131 agent_id=agent_id, 132 agent_name=agent.agent_name, 133 started_at=now, 134 timeout_at=now + (task.timeout_ms / 1000), 135 heartbeat_at=now, 136 heartbeat_timeout_ms=hb_timeout, 137 ) 138 self._in_flight[task_id] = in_flight 139 self._task_start_times[task_id] = now # v0.9.0 140 agent.current_tasks.append(task_id) 141 agent.operational_state = "busy" 142 143 # v1.1.0: Inject credentials before dispatch 144 if self._credential_injector and agent.required_credentials: 145 try: 146 self._credential_injector.inject(task, agent_id, agent.required_credentials) 147 except Exception: 148 # Credential failure → reject task for retry 149 self._in_flight.pop(task_id, None) 150 agent.current_tasks.remove(task_id) 151 try: 152 self._task_graph.transition(task_id, TaskStatus.FAILED) 153 except ValueError: 154 pass 155 return False 156 157 # v0.9.0: Publish task.started event with input context 158 input_ctx = self._task_inputs.pop(task_id, None) 159 self._event_bus.publish(Event( 160 event_id=str(uuid.uuid4()), 161 event_type="task.started", 162 source="execution_engine", 163 timestamp=now, 164 correlation_id=task.plan_id, 165 payload={ 166 "task_id": task_id, 167 "agent_id": agent_id, 168 "agent_name": agent.agent_name, 169 "required_capability": task.required_capability, 170 "input_context": input_ctx or {}, 171 }, 172 )) 173 174 # Call the agent dispatch callback (in-process: direct function call) 175 if self._agent_dispatch: 176 self._agent_dispatch(agent_id, task) 177 178 return True 179 180 def reject_task(self, task_id: str) -> None: 181 """Agent rejected the task → re-schedule.""" 182 with self._lock: 183 self._in_flight.pop(task_id, None) 184 try: 185 self._task_graph.transition(task_id, TaskStatus.READY) 186 except ValueError: 187 pass 188 189 # ── Result Handling ── 190 191 def submit_result(self, task_id: str, agent_id: str, result: dict) -> bool: 192 """ 193 Agent returns task result. result = {status: "completed"|"failed", artifact?: ..., error?: ...} 194 195 v0.8.0: If error code matches task.non_retryable_errors, transition to FATAL_FAILED. 196 """ 197 with self._lock: 198 inflight = self._in_flight.pop(task_id, None) 199 if not inflight: 200 return False 201 202 agent = self._agents.get(agent_id) 203 if agent and task_id in agent.current_tasks: 204 agent.current_tasks.remove(task_id) 205 if not agent.current_tasks: 206 agent.operational_state = "idle" 207 208 status = result.get("status") 209 duration_ms = (time.time() - self._task_start_times.pop(task_id, time.time())) * 1000 210 task = self._task_graph.get_task(task_id) 211 212 if status == "completed": 213 try: 214 self._task_graph.transition(task_id, TaskStatus.COMPLETED) 215 except ValueError: 216 return False 217 if agent: 218 agent.total_completed += 1 219 # v0.9.0: Publish task.completed with artifact 220 artifact = result.get("artifact", {}) 221 payload_size = len(str(artifact)) 222 event_payload = { 223 "task_id": task_id, "agent_id": agent_id, 224 "duration_ms": int(duration_ms), 225 } 226 if payload_size > 100000: 227 event_payload["content_ref"] = f"storage:task:{task_id}:artifact" 228 event_payload["output_artifact_summary"] = f"Large artifact ({payload_size} bytes)" 229 else: 230 event_payload["output_artifact"] = artifact 231 self._event_bus.publish(Event( 232 event_id=str(uuid.uuid4()), 233 event_type="task.completed", 234 source="execution_engine", 235 timestamp=time.time(), 236 correlation_id=task.plan_id if task else "", 237 payload=event_payload, 238 )) 239 else: 240 # v0.8.0: Check non_retryable_errors 241 error_code = (result.get("error") or {}).get("code", "") 242 if task and error_code and task.non_retryable_errors: 243 if error_code in task.non_retryable_errors: 244 try: 245 self._task_graph.transition(task_id, TaskStatus.FATAL_FAILED) 246 except ValueError: 247 self._task_graph.transition(task_id, TaskStatus.FAILED) 248 if agent: 249 agent.total_failed += 1 250 return True 251 try: 252 self._task_graph.transition(task_id, TaskStatus.FAILED) 253 except ValueError: 254 return False 255 if agent: 256 agent.total_failed += 1 257 # v0.9.0: Publish task.failed with error 258 self._event_bus.publish(Event( 259 event_id=str(uuid.uuid4()), 260 event_type="task.failed", 261 source="execution_engine", 262 timestamp=time.time(), 263 correlation_id=task.plan_id if task else "", 264 payload={ 265 "task_id": task_id, "agent_id": agent_id, 266 "error": result.get("error", {}), 267 "duration_ms": int(duration_ms), 268 }, 269 )) 270 271 # ── v1.3.0: MPC Replan Check (outside lock to avoid deadlock) ── 272 self._mpc_replan_check(task_id, result) 273 return True 274 275 def _mpc_replan_check(self, task_id: str, result: dict) -> None: 276 """v1.3.0: After task result is handled, check if replan is needed.""" 277 if not self._replan_callback or not self._replan_rules: 278 return 279 if not self._current_plan: 280 return 281 282 # Only trigger replan on failed tasks 283 status = result.get("status") 284 if status == "completed": 285 return 286 287 goal_id = getattr(self._current_plan, 'goal_id', 'unknown') 288 count = self._replan_count.get(goal_id, 0) 289 if count >= self._max_replans: 290 self._event_bus.publish(Event( 291 event_id=str(uuid.uuid4()), 292 event_type="goal.replan_limit_exceeded", 293 source="execution_engine", 294 timestamp=time.time(), 295 correlation_id=goal_id, 296 payload={ 297 "goal_id": goal_id, 298 "replan_count": count, 299 "max_replans": self._max_replans, 300 "reason": f"Exceeded max replans ({self._max_replans})", 301 }, 302 )) 303 return 304 305 artifact = result.get("artifact", {}) 306 verdict = self._run_incremental_verify(task_id, artifact) 307 308 from .replan_rules import ReplanContext 309 ctx = ReplanContext( 310 task_id=task_id, 311 artifact=artifact, 312 verdict=verdict, 313 current_plan=self._current_plan, 314 ) 315 316 diagnosis = self._run_diagnosis_if_applicable(task_id, result) 317 318 for rule in self._replan_rules: 319 if rule.should_replan(ctx): 320 new_plan = self._replan_callback( 321 plan_id=self._current_plan.plan_id, 322 trigger=rule.trigger_reason, 323 context={ 324 "failed_task_id": task_id, 325 "verdict": verdict, 326 "diagnosis": diagnosis, 327 "completed_tasks": self._get_completed_task_ids(), 328 "failed_task": self._task_graph.get_task(task_id), 329 }, 330 ) 331 if new_plan: 332 self._current_plan = new_plan 333 self._replan_count[goal_id] = count + 1 334 self._event_bus.publish(Event( 335 event_id=str(uuid.uuid4()), 336 event_type="execution_plan.modified", 337 source="execution_engine", 338 timestamp=time.time(), 339 correlation_id=new_plan.plan_id, 340 payload={ 341 "plan_id": new_plan.plan_id, 342 "trigger_reason": rule.trigger_reason, 343 "failed_task_id": task_id, 344 "replan_count": count + 1, 345 }, 346 )) 347 break 348 349 # ── v1.3.0: MPC Support Methods ── 350 351 def set_replan_callback(self, callback: Callable) -> None: 352 """v1.3.0: Inject Runtime._on_replan as the replan callback.""" 353 self._replan_callback = callback 354 355 def set_current_plan(self, plan) -> None: 356 """v1.3.0: Set the current ExecutionPlan reference.""" 357 self._current_plan = plan 358 359 def set_diagnosis_engine(self, engine) -> None: 360 """v1.3.0: Inject DiagnosisEngine for failure analysis.""" 361 self._diagnosis_engine = engine 362 363 def _run_incremental_verify(self, task_id: str, artifact: dict | None): 364 """v1.3.0: Lightweight per-task verification after completion.""" 365 verifier = self._incremental_verifier 366 if not verifier: 367 from .verifier import Verdict 368 return Verdict(verdict="passed", score=1.0, verifier_id="incremental") 369 370 if artifact is None or (isinstance(artifact, dict) and not artifact): 371 from .verifier import Verdict 372 return Verdict(verdict="failed", score=0.0, verifier_id="incremental", 373 summary="Empty artifact") 374 375 task = self._task_graph.get_task(task_id) 376 rules = ["non_empty"] 377 expected_schema = getattr(task, 'expected_output_schema', None) if task else None 378 if expected_schema: 379 rules.append("schema") 380 381 from .verifier import VerificationCriteria 382 criteria = VerificationCriteria( 383 expected_output_schema=expected_schema or {}, 384 rules=rules, 385 ) 386 return verifier.verify(artifact, criteria) 387 388 def _run_diagnosis_if_applicable(self, task_id: str, result: dict): 389 """v1.3.0: Run DiagnosisEngine if test_output is present in result.""" 390 if not self._diagnosis_engine: 391 return None 392 test_output = ( 393 result.get("test_output") 394 or (result.get("error") or {}).get("test_output", "") 395 ) 396 if not test_output: 397 return None 398 return self._diagnosis_engine.diagnose(test_output) 399 400 def _get_completed_task_ids(self) -> list[str]: 401 """v1.3.0: Return task_ids of all COMPLETED tasks in current plan.""" 402 if not self._current_plan: 403 return [] 404 completed = [] 405 for task in self._task_graph.list_tasks(): 406 if task.status == TaskStatus.COMPLETED: 407 completed.append(task.task_id) 408 return completed 409 410 # ── Cancellation ── 411 412 def cancel_task(self, task_id: str) -> bool: 413 with self._lock: 414 inflight = self._in_flight.pop(task_id, None) 415 if inflight: 416 agent = self._agents.get(inflight.agent_id) 417 if agent and task_id in agent.current_tasks: 418 agent.current_tasks.remove(task_id) 419 try: 420 self._task_graph.transition(task_id, TaskStatus.CANCELLED) 421 except ValueError: 422 pass 423 if self._agent_cancel: 424 self._agent_cancel(inflight.agent_id, task_id) 425 return True 426 return False 427 428 # ── v0.8.0: Heartbeat ── 429 430 def submit_heartbeat(self, task_id: str, agent_id: str = "") -> bool: 431 """v0.8.0: Update heartbeat timestamp for an in-flight task.""" 432 with self._lock: 433 ft = self._in_flight.get(task_id) 434 if not ft: 435 return False 436 if agent_id and ft.agent_id != agent_id: 437 return False 438 ft.heartbeat_at = time.time() 439 return True 440 441 def _check_heartbeat_timeouts(self) -> list[str]: 442 """v0.8.0: Check for heartbeat timeouts, transition tasks to FAILED. 443 Returns list of task_ids that timed out due to heartbeat. 444 """ 445 now = time.time() 446 timed_out = [] 447 with self._lock: 448 for tid, ft in list(self._in_flight.items()): 449 if ft.heartbeat_timeout_ms > 0: 450 timeout_at = ft.heartbeat_at + (ft.heartbeat_timeout_ms / 1000) 451 if now >= timeout_at: 452 try: 453 self._task_graph.transition(tid, TaskStatus.FAILED) 454 except ValueError: 455 pass 456 self._in_flight.pop(tid, None) 457 agent = self._agents.get(ft.agent_id) 458 if agent and tid in agent.current_tasks: 459 agent.current_tasks.remove(tid) 460 timed_out.append(tid) 461 return timed_out 462 463 # ── Timeout Monitor ── 464 465 def start_monitor(self) -> None: 466 self._running = True 467 self._monitor_thread = threading.Thread(target=self._monitor_loop, daemon=True) 468 self._monitor_thread.start() 469 470 def stop_monitor(self) -> None: 471 self._running = False 472 473 def _monitor_loop(self) -> None: 474 while self._running: 475 now = time.time() 476 with self._lock: 477 timed_out = [tid for tid, ft in self._in_flight.items() if now >= ft.timeout_at] 478 for tid in timed_out: 479 try: 480 self._task_graph.transition(tid, TaskStatus.TIMED_OUT) 481 except ValueError: 482 pass 483 self._in_flight.pop(tid, None) 484 485 # v0.8.0: Heartbeat timeout check 486 self._check_heartbeat_timeouts() 487 488 # Agent heartbeat check 489 with self._lock: 490 for agent in list(self._agents.values()): 491 if agent.status == "heartbeating": 492 elapsed = now - agent.last_heartbeat_at 493 if elapsed > agent.heartbeat_interval_ms * 3 / 1000: 494 agent.status = "disconnected" 495 time.sleep(1.0) 496 497 @property 498 def in_flight_count(self) -> int: 499 return len(self._in_flight) 500 501 @property 502 def in_flight_task_ids(self) -> list[str]: 503 return list(self._in_flight.keys())
16@dataclass 17class InFlightTask: 18 task_id: str 19 agent_id: str 20 agent_name: str 21 started_at: float 22 timeout_at: float 23 heartbeat_at: float = 0.0 # v0.8.0: last heartbeat timestamp 24 heartbeat_timeout_ms: int = 30000 # v0.8.0: heartbeat timeout in ms
27@dataclass 28class AgentState: 29 agent_id: str 30 agent_name: str 31 status: str = "registered" # registered → connected → heartbeating → disconnected → shutdown 32 operational_state: str = "idle" 33 last_heartbeat_at: float = 0.0 34 heartbeat_interval_ms: int = 30000 35 endpoint: str | None = None 36 max_concurrent_tasks: int = 5 37 current_tasks: list[str] = field(default_factory=list) 38 capabilities: list[dict] = field(default_factory=list) 39 required_credentials: list[str] = field(default_factory=list) # v1.1.0 40 historical_success_rate: float = 0.0 41 total_completed: int = 0 42 total_failed: int = 0
45class ExecutionEngine: 46 """Kernel component — Task dispatch, lifecycle, timeouts, heartbeat tracking. 47 48 v1.3.0: MPC replan_check hook, incremental verification, diagnosis trigger. 49 """ 50 51 def __init__(self, task_graph: TaskGraphEngine, event_bus: EventBus): 52 self._task_graph = task_graph 53 self._event_bus = event_bus 54 task_graph._event_bus = event_bus # v0.9.0: wire lifecycle events 55 self._in_flight: dict[str, InFlightTask] = {} # task_id → InFlightTask 56 self._agents: dict[str, AgentState] = {} 57 self._agent_dispatch: Callable | None = None # Callback: (agent_id, task) → bool 58 self._agent_cancel: Callable | None = None 59 self._lock = threading.RLock() 60 self._monitor_thread: threading.Thread | None = None 61 self._running = False 62 self._task_inputs: dict[str, dict] = {} # v0.9.0: task input context 63 self._task_start_times: dict[str, float] = {} # v0.9.0: task start timestamps 64 self._credential_injector = None # v1.1.0: set by runtime 65 66 # v1.3.0: MPC Adaptive Loop 67 self._replan_callback: Callable | None = None # Runtime._on_replan 68 self._replan_rules: list = [] # ReplanRule instances 69 self._current_plan = None # PlannerPlan reference 70 self._replan_count: dict[str, int] = {} # goal_id → count 71 self._max_replans: int = 5 72 self._incremental_verifier = None # SchemaVerifier for per-task check 73 self._diagnosis_engine = None # DiagnosisEngine instance (from feature flag) 74 75 # ── Agent Management ── 76 77 def register_agent(self, agent_id: str, agent_name: str, **kwargs) -> AgentState: 78 state = AgentState(agent_id=agent_id, agent_name=agent_name, **kwargs) 79 with self._lock: 80 self._agents[agent_id] = state 81 state.status = "connected" 82 return state 83 84 def heartbeat(self, agent_id: str) -> bool: 85 with self._lock: 86 agent = self._agents.get(agent_id) 87 if not agent: 88 return False 89 agent.last_heartbeat_at = time.time() 90 if agent.status == "connected": 91 agent.status = "heartbeating" 92 elif agent.status == "disconnected": 93 agent.status = "heartbeating" 94 # Update operational state 95 agent.operational_state = "busy" if agent.current_tasks else "idle" 96 return True 97 98 def remove_agent(self, agent_id: str) -> None: 99 with self._lock: 100 self._agents.pop(agent_id, None) 101 # Cancel all in-flight tasks for this agent 102 for tid in list(self._in_flight.keys()): 103 if self._in_flight[tid].agent_id == agent_id: 104 self.cancel_task(tid) 105 106 def get_agent(self, agent_id: str) -> AgentState | None: 107 return self._agents.get(agent_id) 108 109 def list_agents(self) -> list[AgentState]: 110 return list(self._agents.values()) 111 112 # ── Dispatch ── 113 114 def set_task_input_context(self, task_id: str, input_context: dict) -> None: 115 """v0.9.0: Set the input context for a task before dispatch.""" 116 with self._lock: 117 self._task_inputs[task_id] = input_context 118 119 def dispatch(self, task_id: str, agent_id: str) -> bool: 120 """Dispatch a task to an agent. Returns True if agent accepted.""" 121 task = self._task_graph.get_task(task_id) 122 agent = self._agents.get(agent_id) 123 if not task or not agent: 124 return False 125 126 with self._lock: 127 self._task_graph.transition(task_id, TaskStatus.STARTED, agent_id=agent_id) 128 hb_timeout = getattr(task, 'heartbeat_timeout_ms', 0) or agent.heartbeat_interval_ms * 3 129 now = time.time() 130 in_flight = InFlightTask( 131 task_id=task_id, 132 agent_id=agent_id, 133 agent_name=agent.agent_name, 134 started_at=now, 135 timeout_at=now + (task.timeout_ms / 1000), 136 heartbeat_at=now, 137 heartbeat_timeout_ms=hb_timeout, 138 ) 139 self._in_flight[task_id] = in_flight 140 self._task_start_times[task_id] = now # v0.9.0 141 agent.current_tasks.append(task_id) 142 agent.operational_state = "busy" 143 144 # v1.1.0: Inject credentials before dispatch 145 if self._credential_injector and agent.required_credentials: 146 try: 147 self._credential_injector.inject(task, agent_id, agent.required_credentials) 148 except Exception: 149 # Credential failure → reject task for retry 150 self._in_flight.pop(task_id, None) 151 agent.current_tasks.remove(task_id) 152 try: 153 self._task_graph.transition(task_id, TaskStatus.FAILED) 154 except ValueError: 155 pass 156 return False 157 158 # v0.9.0: Publish task.started event with input context 159 input_ctx = self._task_inputs.pop(task_id, None) 160 self._event_bus.publish(Event( 161 event_id=str(uuid.uuid4()), 162 event_type="task.started", 163 source="execution_engine", 164 timestamp=now, 165 correlation_id=task.plan_id, 166 payload={ 167 "task_id": task_id, 168 "agent_id": agent_id, 169 "agent_name": agent.agent_name, 170 "required_capability": task.required_capability, 171 "input_context": input_ctx or {}, 172 }, 173 )) 174 175 # Call the agent dispatch callback (in-process: direct function call) 176 if self._agent_dispatch: 177 self._agent_dispatch(agent_id, task) 178 179 return True 180 181 def reject_task(self, task_id: str) -> None: 182 """Agent rejected the task → re-schedule.""" 183 with self._lock: 184 self._in_flight.pop(task_id, None) 185 try: 186 self._task_graph.transition(task_id, TaskStatus.READY) 187 except ValueError: 188 pass 189 190 # ── Result Handling ── 191 192 def submit_result(self, task_id: str, agent_id: str, result: dict) -> bool: 193 """ 194 Agent returns task result. result = {status: "completed"|"failed", artifact?: ..., error?: ...} 195 196 v0.8.0: If error code matches task.non_retryable_errors, transition to FATAL_FAILED. 197 """ 198 with self._lock: 199 inflight = self._in_flight.pop(task_id, None) 200 if not inflight: 201 return False 202 203 agent = self._agents.get(agent_id) 204 if agent and task_id in agent.current_tasks: 205 agent.current_tasks.remove(task_id) 206 if not agent.current_tasks: 207 agent.operational_state = "idle" 208 209 status = result.get("status") 210 duration_ms = (time.time() - self._task_start_times.pop(task_id, time.time())) * 1000 211 task = self._task_graph.get_task(task_id) 212 213 if status == "completed": 214 try: 215 self._task_graph.transition(task_id, TaskStatus.COMPLETED) 216 except ValueError: 217 return False 218 if agent: 219 agent.total_completed += 1 220 # v0.9.0: Publish task.completed with artifact 221 artifact = result.get("artifact", {}) 222 payload_size = len(str(artifact)) 223 event_payload = { 224 "task_id": task_id, "agent_id": agent_id, 225 "duration_ms": int(duration_ms), 226 } 227 if payload_size > 100000: 228 event_payload["content_ref"] = f"storage:task:{task_id}:artifact" 229 event_payload["output_artifact_summary"] = f"Large artifact ({payload_size} bytes)" 230 else: 231 event_payload["output_artifact"] = artifact 232 self._event_bus.publish(Event( 233 event_id=str(uuid.uuid4()), 234 event_type="task.completed", 235 source="execution_engine", 236 timestamp=time.time(), 237 correlation_id=task.plan_id if task else "", 238 payload=event_payload, 239 )) 240 else: 241 # v0.8.0: Check non_retryable_errors 242 error_code = (result.get("error") or {}).get("code", "") 243 if task and error_code and task.non_retryable_errors: 244 if error_code in task.non_retryable_errors: 245 try: 246 self._task_graph.transition(task_id, TaskStatus.FATAL_FAILED) 247 except ValueError: 248 self._task_graph.transition(task_id, TaskStatus.FAILED) 249 if agent: 250 agent.total_failed += 1 251 return True 252 try: 253 self._task_graph.transition(task_id, TaskStatus.FAILED) 254 except ValueError: 255 return False 256 if agent: 257 agent.total_failed += 1 258 # v0.9.0: Publish task.failed with error 259 self._event_bus.publish(Event( 260 event_id=str(uuid.uuid4()), 261 event_type="task.failed", 262 source="execution_engine", 263 timestamp=time.time(), 264 correlation_id=task.plan_id if task else "", 265 payload={ 266 "task_id": task_id, "agent_id": agent_id, 267 "error": result.get("error", {}), 268 "duration_ms": int(duration_ms), 269 }, 270 )) 271 272 # ── v1.3.0: MPC Replan Check (outside lock to avoid deadlock) ── 273 self._mpc_replan_check(task_id, result) 274 return True 275 276 def _mpc_replan_check(self, task_id: str, result: dict) -> None: 277 """v1.3.0: After task result is handled, check if replan is needed.""" 278 if not self._replan_callback or not self._replan_rules: 279 return 280 if not self._current_plan: 281 return 282 283 # Only trigger replan on failed tasks 284 status = result.get("status") 285 if status == "completed": 286 return 287 288 goal_id = getattr(self._current_plan, 'goal_id', 'unknown') 289 count = self._replan_count.get(goal_id, 0) 290 if count >= self._max_replans: 291 self._event_bus.publish(Event( 292 event_id=str(uuid.uuid4()), 293 event_type="goal.replan_limit_exceeded", 294 source="execution_engine", 295 timestamp=time.time(), 296 correlation_id=goal_id, 297 payload={ 298 "goal_id": goal_id, 299 "replan_count": count, 300 "max_replans": self._max_replans, 301 "reason": f"Exceeded max replans ({self._max_replans})", 302 }, 303 )) 304 return 305 306 artifact = result.get("artifact", {}) 307 verdict = self._run_incremental_verify(task_id, artifact) 308 309 from .replan_rules import ReplanContext 310 ctx = ReplanContext( 311 task_id=task_id, 312 artifact=artifact, 313 verdict=verdict, 314 current_plan=self._current_plan, 315 ) 316 317 diagnosis = self._run_diagnosis_if_applicable(task_id, result) 318 319 for rule in self._replan_rules: 320 if rule.should_replan(ctx): 321 new_plan = self._replan_callback( 322 plan_id=self._current_plan.plan_id, 323 trigger=rule.trigger_reason, 324 context={ 325 "failed_task_id": task_id, 326 "verdict": verdict, 327 "diagnosis": diagnosis, 328 "completed_tasks": self._get_completed_task_ids(), 329 "failed_task": self._task_graph.get_task(task_id), 330 }, 331 ) 332 if new_plan: 333 self._current_plan = new_plan 334 self._replan_count[goal_id] = count + 1 335 self._event_bus.publish(Event( 336 event_id=str(uuid.uuid4()), 337 event_type="execution_plan.modified", 338 source="execution_engine", 339 timestamp=time.time(), 340 correlation_id=new_plan.plan_id, 341 payload={ 342 "plan_id": new_plan.plan_id, 343 "trigger_reason": rule.trigger_reason, 344 "failed_task_id": task_id, 345 "replan_count": count + 1, 346 }, 347 )) 348 break 349 350 # ── v1.3.0: MPC Support Methods ── 351 352 def set_replan_callback(self, callback: Callable) -> None: 353 """v1.3.0: Inject Runtime._on_replan as the replan callback.""" 354 self._replan_callback = callback 355 356 def set_current_plan(self, plan) -> None: 357 """v1.3.0: Set the current ExecutionPlan reference.""" 358 self._current_plan = plan 359 360 def set_diagnosis_engine(self, engine) -> None: 361 """v1.3.0: Inject DiagnosisEngine for failure analysis.""" 362 self._diagnosis_engine = engine 363 364 def _run_incremental_verify(self, task_id: str, artifact: dict | None): 365 """v1.3.0: Lightweight per-task verification after completion.""" 366 verifier = self._incremental_verifier 367 if not verifier: 368 from .verifier import Verdict 369 return Verdict(verdict="passed", score=1.0, verifier_id="incremental") 370 371 if artifact is None or (isinstance(artifact, dict) and not artifact): 372 from .verifier import Verdict 373 return Verdict(verdict="failed", score=0.0, verifier_id="incremental", 374 summary="Empty artifact") 375 376 task = self._task_graph.get_task(task_id) 377 rules = ["non_empty"] 378 expected_schema = getattr(task, 'expected_output_schema', None) if task else None 379 if expected_schema: 380 rules.append("schema") 381 382 from .verifier import VerificationCriteria 383 criteria = VerificationCriteria( 384 expected_output_schema=expected_schema or {}, 385 rules=rules, 386 ) 387 return verifier.verify(artifact, criteria) 388 389 def _run_diagnosis_if_applicable(self, task_id: str, result: dict): 390 """v1.3.0: Run DiagnosisEngine if test_output is present in result.""" 391 if not self._diagnosis_engine: 392 return None 393 test_output = ( 394 result.get("test_output") 395 or (result.get("error") or {}).get("test_output", "") 396 ) 397 if not test_output: 398 return None 399 return self._diagnosis_engine.diagnose(test_output) 400 401 def _get_completed_task_ids(self) -> list[str]: 402 """v1.3.0: Return task_ids of all COMPLETED tasks in current plan.""" 403 if not self._current_plan: 404 return [] 405 completed = [] 406 for task in self._task_graph.list_tasks(): 407 if task.status == TaskStatus.COMPLETED: 408 completed.append(task.task_id) 409 return completed 410 411 # ── Cancellation ── 412 413 def cancel_task(self, task_id: str) -> bool: 414 with self._lock: 415 inflight = self._in_flight.pop(task_id, None) 416 if inflight: 417 agent = self._agents.get(inflight.agent_id) 418 if agent and task_id in agent.current_tasks: 419 agent.current_tasks.remove(task_id) 420 try: 421 self._task_graph.transition(task_id, TaskStatus.CANCELLED) 422 except ValueError: 423 pass 424 if self._agent_cancel: 425 self._agent_cancel(inflight.agent_id, task_id) 426 return True 427 return False 428 429 # ── v0.8.0: Heartbeat ── 430 431 def submit_heartbeat(self, task_id: str, agent_id: str = "") -> bool: 432 """v0.8.0: Update heartbeat timestamp for an in-flight task.""" 433 with self._lock: 434 ft = self._in_flight.get(task_id) 435 if not ft: 436 return False 437 if agent_id and ft.agent_id != agent_id: 438 return False 439 ft.heartbeat_at = time.time() 440 return True 441 442 def _check_heartbeat_timeouts(self) -> list[str]: 443 """v0.8.0: Check for heartbeat timeouts, transition tasks to FAILED. 444 Returns list of task_ids that timed out due to heartbeat. 445 """ 446 now = time.time() 447 timed_out = [] 448 with self._lock: 449 for tid, ft in list(self._in_flight.items()): 450 if ft.heartbeat_timeout_ms > 0: 451 timeout_at = ft.heartbeat_at + (ft.heartbeat_timeout_ms / 1000) 452 if now >= timeout_at: 453 try: 454 self._task_graph.transition(tid, TaskStatus.FAILED) 455 except ValueError: 456 pass 457 self._in_flight.pop(tid, None) 458 agent = self._agents.get(ft.agent_id) 459 if agent and tid in agent.current_tasks: 460 agent.current_tasks.remove(tid) 461 timed_out.append(tid) 462 return timed_out 463 464 # ── Timeout Monitor ── 465 466 def start_monitor(self) -> None: 467 self._running = True 468 self._monitor_thread = threading.Thread(target=self._monitor_loop, daemon=True) 469 self._monitor_thread.start() 470 471 def stop_monitor(self) -> None: 472 self._running = False 473 474 def _monitor_loop(self) -> None: 475 while self._running: 476 now = time.time() 477 with self._lock: 478 timed_out = [tid for tid, ft in self._in_flight.items() if now >= ft.timeout_at] 479 for tid in timed_out: 480 try: 481 self._task_graph.transition(tid, TaskStatus.TIMED_OUT) 482 except ValueError: 483 pass 484 self._in_flight.pop(tid, None) 485 486 # v0.8.0: Heartbeat timeout check 487 self._check_heartbeat_timeouts() 488 489 # Agent heartbeat check 490 with self._lock: 491 for agent in list(self._agents.values()): 492 if agent.status == "heartbeating": 493 elapsed = now - agent.last_heartbeat_at 494 if elapsed > agent.heartbeat_interval_ms * 3 / 1000: 495 agent.status = "disconnected" 496 time.sleep(1.0) 497 498 @property 499 def in_flight_count(self) -> int: 500 return len(self._in_flight) 501 502 @property 503 def in_flight_task_ids(self) -> list[str]: 504 return list(self._in_flight.keys())
Kernel component — Task dispatch, lifecycle, timeouts, heartbeat tracking.
v1.3.0: MPC replan_check hook, incremental verification, diagnosis trigger.
51 def __init__(self, task_graph: TaskGraphEngine, event_bus: EventBus): 52 self._task_graph = task_graph 53 self._event_bus = event_bus 54 task_graph._event_bus = event_bus # v0.9.0: wire lifecycle events 55 self._in_flight: dict[str, InFlightTask] = {} # task_id → InFlightTask 56 self._agents: dict[str, AgentState] = {} 57 self._agent_dispatch: Callable | None = None # Callback: (agent_id, task) → bool 58 self._agent_cancel: Callable | None = None 59 self._lock = threading.RLock() 60 self._monitor_thread: threading.Thread | None = None 61 self._running = False 62 self._task_inputs: dict[str, dict] = {} # v0.9.0: task input context 63 self._task_start_times: dict[str, float] = {} # v0.9.0: task start timestamps 64 self._credential_injector = None # v1.1.0: set by runtime 65 66 # v1.3.0: MPC Adaptive Loop 67 self._replan_callback: Callable | None = None # Runtime._on_replan 68 self._replan_rules: list = [] # ReplanRule instances 69 self._current_plan = None # PlannerPlan reference 70 self._replan_count: dict[str, int] = {} # goal_id → count 71 self._max_replans: int = 5 72 self._incremental_verifier = None # SchemaVerifier for per-task check 73 self._diagnosis_engine = None # DiagnosisEngine instance (from feature flag)
84 def heartbeat(self, agent_id: str) -> bool: 85 with self._lock: 86 agent = self._agents.get(agent_id) 87 if not agent: 88 return False 89 agent.last_heartbeat_at = time.time() 90 if agent.status == "connected": 91 agent.status = "heartbeating" 92 elif agent.status == "disconnected": 93 agent.status = "heartbeating" 94 # Update operational state 95 agent.operational_state = "busy" if agent.current_tasks else "idle" 96 return True
114 def set_task_input_context(self, task_id: str, input_context: dict) -> None: 115 """v0.9.0: Set the input context for a task before dispatch.""" 116 with self._lock: 117 self._task_inputs[task_id] = input_context
v0.9.0: Set the input context for a task before dispatch.
119 def dispatch(self, task_id: str, agent_id: str) -> bool: 120 """Dispatch a task to an agent. Returns True if agent accepted.""" 121 task = self._task_graph.get_task(task_id) 122 agent = self._agents.get(agent_id) 123 if not task or not agent: 124 return False 125 126 with self._lock: 127 self._task_graph.transition(task_id, TaskStatus.STARTED, agent_id=agent_id) 128 hb_timeout = getattr(task, 'heartbeat_timeout_ms', 0) or agent.heartbeat_interval_ms * 3 129 now = time.time() 130 in_flight = InFlightTask( 131 task_id=task_id, 132 agent_id=agent_id, 133 agent_name=agent.agent_name, 134 started_at=now, 135 timeout_at=now + (task.timeout_ms / 1000), 136 heartbeat_at=now, 137 heartbeat_timeout_ms=hb_timeout, 138 ) 139 self._in_flight[task_id] = in_flight 140 self._task_start_times[task_id] = now # v0.9.0 141 agent.current_tasks.append(task_id) 142 agent.operational_state = "busy" 143 144 # v1.1.0: Inject credentials before dispatch 145 if self._credential_injector and agent.required_credentials: 146 try: 147 self._credential_injector.inject(task, agent_id, agent.required_credentials) 148 except Exception: 149 # Credential failure → reject task for retry 150 self._in_flight.pop(task_id, None) 151 agent.current_tasks.remove(task_id) 152 try: 153 self._task_graph.transition(task_id, TaskStatus.FAILED) 154 except ValueError: 155 pass 156 return False 157 158 # v0.9.0: Publish task.started event with input context 159 input_ctx = self._task_inputs.pop(task_id, None) 160 self._event_bus.publish(Event( 161 event_id=str(uuid.uuid4()), 162 event_type="task.started", 163 source="execution_engine", 164 timestamp=now, 165 correlation_id=task.plan_id, 166 payload={ 167 "task_id": task_id, 168 "agent_id": agent_id, 169 "agent_name": agent.agent_name, 170 "required_capability": task.required_capability, 171 "input_context": input_ctx or {}, 172 }, 173 )) 174 175 # Call the agent dispatch callback (in-process: direct function call) 176 if self._agent_dispatch: 177 self._agent_dispatch(agent_id, task) 178 179 return True
Dispatch a task to an agent. Returns True if agent accepted.
181 def reject_task(self, task_id: str) -> None: 182 """Agent rejected the task → re-schedule.""" 183 with self._lock: 184 self._in_flight.pop(task_id, None) 185 try: 186 self._task_graph.transition(task_id, TaskStatus.READY) 187 except ValueError: 188 pass
Agent rejected the task → re-schedule.
192 def submit_result(self, task_id: str, agent_id: str, result: dict) -> bool: 193 """ 194 Agent returns task result. result = {status: "completed"|"failed", artifact?: ..., error?: ...} 195 196 v0.8.0: If error code matches task.non_retryable_errors, transition to FATAL_FAILED. 197 """ 198 with self._lock: 199 inflight = self._in_flight.pop(task_id, None) 200 if not inflight: 201 return False 202 203 agent = self._agents.get(agent_id) 204 if agent and task_id in agent.current_tasks: 205 agent.current_tasks.remove(task_id) 206 if not agent.current_tasks: 207 agent.operational_state = "idle" 208 209 status = result.get("status") 210 duration_ms = (time.time() - self._task_start_times.pop(task_id, time.time())) * 1000 211 task = self._task_graph.get_task(task_id) 212 213 if status == "completed": 214 try: 215 self._task_graph.transition(task_id, TaskStatus.COMPLETED) 216 except ValueError: 217 return False 218 if agent: 219 agent.total_completed += 1 220 # v0.9.0: Publish task.completed with artifact 221 artifact = result.get("artifact", {}) 222 payload_size = len(str(artifact)) 223 event_payload = { 224 "task_id": task_id, "agent_id": agent_id, 225 "duration_ms": int(duration_ms), 226 } 227 if payload_size > 100000: 228 event_payload["content_ref"] = f"storage:task:{task_id}:artifact" 229 event_payload["output_artifact_summary"] = f"Large artifact ({payload_size} bytes)" 230 else: 231 event_payload["output_artifact"] = artifact 232 self._event_bus.publish(Event( 233 event_id=str(uuid.uuid4()), 234 event_type="task.completed", 235 source="execution_engine", 236 timestamp=time.time(), 237 correlation_id=task.plan_id if task else "", 238 payload=event_payload, 239 )) 240 else: 241 # v0.8.0: Check non_retryable_errors 242 error_code = (result.get("error") or {}).get("code", "") 243 if task and error_code and task.non_retryable_errors: 244 if error_code in task.non_retryable_errors: 245 try: 246 self._task_graph.transition(task_id, TaskStatus.FATAL_FAILED) 247 except ValueError: 248 self._task_graph.transition(task_id, TaskStatus.FAILED) 249 if agent: 250 agent.total_failed += 1 251 return True 252 try: 253 self._task_graph.transition(task_id, TaskStatus.FAILED) 254 except ValueError: 255 return False 256 if agent: 257 agent.total_failed += 1 258 # v0.9.0: Publish task.failed with error 259 self._event_bus.publish(Event( 260 event_id=str(uuid.uuid4()), 261 event_type="task.failed", 262 source="execution_engine", 263 timestamp=time.time(), 264 correlation_id=task.plan_id if task else "", 265 payload={ 266 "task_id": task_id, "agent_id": agent_id, 267 "error": result.get("error", {}), 268 "duration_ms": int(duration_ms), 269 }, 270 )) 271 272 # ── v1.3.0: MPC Replan Check (outside lock to avoid deadlock) ── 273 self._mpc_replan_check(task_id, result) 274 return True
Agent returns task result. result = {status: "completed"|"failed", artifact?: ..., error?: ...}
v0.8.0: If error code matches task.non_retryable_errors, transition to FATAL_FAILED.
352 def set_replan_callback(self, callback: Callable) -> None: 353 """v1.3.0: Inject Runtime._on_replan as the replan callback.""" 354 self._replan_callback = callback
v1.3.0: Inject Runtime._on_replan as the replan callback.
356 def set_current_plan(self, plan) -> None: 357 """v1.3.0: Set the current ExecutionPlan reference.""" 358 self._current_plan = plan
v1.3.0: Set the current ExecutionPlan reference.
360 def set_diagnosis_engine(self, engine) -> None: 361 """v1.3.0: Inject DiagnosisEngine for failure analysis.""" 362 self._diagnosis_engine = engine
v1.3.0: Inject DiagnosisEngine for failure analysis.
413 def cancel_task(self, task_id: str) -> bool: 414 with self._lock: 415 inflight = self._in_flight.pop(task_id, None) 416 if inflight: 417 agent = self._agents.get(inflight.agent_id) 418 if agent and task_id in agent.current_tasks: 419 agent.current_tasks.remove(task_id) 420 try: 421 self._task_graph.transition(task_id, TaskStatus.CANCELLED) 422 except ValueError: 423 pass 424 if self._agent_cancel: 425 self._agent_cancel(inflight.agent_id, task_id) 426 return True 427 return False
431 def submit_heartbeat(self, task_id: str, agent_id: str = "") -> bool: 432 """v0.8.0: Update heartbeat timestamp for an in-flight task.""" 433 with self._lock: 434 ft = self._in_flight.get(task_id) 435 if not ft: 436 return False 437 if agent_id and ft.agent_id != agent_id: 438 return False 439 ft.heartbeat_at = time.time() 440 return True
v0.8.0: Update heartbeat timestamp for an in-flight task.