OpenSpace/openspace/runtime/execution_finalizer.py
2026-07-17 11:43:42 +08:00

399 lines
15 KiB
Python

from __future__ import annotations
import asyncio
from typing import Any
from openspace.utils.logging import Logger
logger = Logger.get_logger(__name__)
class ExecutionFinalizer:
"""Stops recording, runs post-execution work, and persists the session."""
def __init__(self, runtime: Any) -> None:
self._runtime = runtime
def __getattr__(self, name: str) -> Any:
return getattr(self._runtime, name)
async def finalize(
self,
*,
task_id: str,
start_time: float,
execution_time: float,
result: dict[str, Any],
execution_context: dict[str, Any],
memory_drain_timeout: float,
evolved_skills: list[dict[str, Any]],
capture_skill_dir: str | None,
cancelled_exc: asyncio.CancelledError | None,
) -> dict[str, Any]:
recording_dir = None
recording_manager = self.state.recording_manager
if recording_manager and recording_manager.recording_status:
recording_dir = recording_manager.trajectory_dir
final_result = {
**result,
"task_id": task_id,
"session_id": self.current_session_id,
"execution_time": execution_time,
"skills_used": result.get("active_skills", []),
"evolved_skills": list(evolved_skills),
}
if capture_skill_dir:
final_result["capture_skill_dir"] = capture_skill_dir
if "session_capability_state" not in final_result:
capability_state = execution_context.get("session_capability_state")
if capability_state is not None:
final_result["session_capability_state"] = capability_state
try:
await self._emit_task_finish_evidence(
"task_finished_pre_persist",
task_id=task_id,
recording_dir=recording_dir,
final_result=final_result,
execution_context=execution_context,
capture_skill_dir=capture_skill_dir,
)
await self.drain_memory_background_tasks(
timeout_s=memory_drain_timeout,
reason="pre_persist",
context=execution_context,
)
await self.session_runtime.persist(final_result, execution_context)
await self._scan_session_evidence_checkpoint(
task_id=task_id,
execution_context=execution_context,
)
await self._scan_skill_evidence_checkpoint(
task_id=task_id,
execution_context=execution_context,
)
await self._scan_tool_quality_evidence_checkpoint()
await self._emit_task_finish_evidence(
"task_session_persisted",
task_id=task_id,
recording_dir=recording_dir,
final_result=final_result,
execution_context=execution_context,
capture_skill_dir=capture_skill_dir,
)
await self._scan_quality_signal_checkpoint(
task_id=task_id,
execution_context=execution_context,
)
post_execution_mode = self.post_execution_mode()
if cancelled_exc is None and post_execution_mode == "inline":
post_execution = self.run_post_execution_tasks(
task_id,
recording_dir,
result,
evolved_skills=evolved_skills,
capture_skill_dir=capture_skill_dir,
)
post_execution_timeout_s = self.post_execution_timeout_s()
try:
if post_execution_timeout_s > 0:
await asyncio.wait_for(
post_execution,
timeout=post_execution_timeout_s,
)
else:
await post_execution
except asyncio.TimeoutError:
logger.warning(
"Inline post-execution tasks timed out after %.2fs; "
"returning task result without waiting for more evolution",
post_execution_timeout_s,
)
final_result["post_execution_timed_out"] = True
else:
final_result["evolved_skills"] = list(evolved_skills)
if cancelled_exc is None and post_execution_mode == "background":
self.schedule_post_execution_tasks(
task_id,
recording_dir,
result,
evolved_skills=evolved_skills,
capture_skill_dir=capture_skill_dir,
)
if cancelled_exc is None:
await self._maybe_report_cloud_task_trace(
task_id=task_id,
final_result=final_result,
execution_context=execution_context,
)
return final_result
finally:
await self._stop_recording(
recording_manager=recording_manager,
task_id=task_id,
start_time=start_time,
result=result,
)
async def _stop_recording(
self,
*,
recording_manager: Any,
task_id: str,
start_time: float,
result: dict[str, Any],
) -> None:
if not recording_manager or not recording_manager.recording_status:
return
try:
exec_time = asyncio.get_event_loop().time() - start_time
await recording_manager.save_execution_outcome(
status=result.get("status", "unknown"),
iterations=result.get("iterations", 0),
execution_time=exec_time,
)
except Exception:
pass
try:
await recording_manager.stop()
logger.debug(f"Recording stopped: {task_id}")
except Exception as exc:
logger.warning(f"Failed to stop recording: {exc}")
async def _maybe_report_cloud_task_trace(
self,
*,
task_id: str,
final_result: dict[str, Any],
execution_context: dict[str, Any],
) -> None:
try:
from openspace.cloud.task_trace_reporter import CloudTaskTraceReporter
workspace_root = (
execution_context.get("cwd")
or execution_context.get("original_cwd")
or getattr(self.config, "project_root", None)
)
reporter = CloudTaskTraceReporter(workspace_root=workspace_root)
outcome = await reporter.maybe_report_execution(
final_result,
task_id=task_id,
session_id=str(self.current_session_id or ""),
runtime=self._runtime,
)
if outcome.get("status") not in {"skipped", "reported"}:
logger.debug("Cloud task trace reporter outcome: %s", outcome)
except Exception:
logger.debug("Cloud task trace reporting skipped", exc_info=True)
async def _emit_task_finish_evidence(
self,
event_type: str,
*,
task_id: str,
recording_dir: str | None,
final_result: dict[str, Any],
execution_context: dict[str, Any],
capture_skill_dir: str | None = None,
) -> None:
storage = self.session_storage
messages = final_result.get("messages")
if not isinstance(messages, list):
messages = execution_context.get("conversation_history")
message_count = len(messages) if isinstance(messages, list) else None
response = final_result.get("response")
payload = {
"task_id": task_id,
"parent_task_id": execution_context.get("parent_task_id"),
"session_id": self.current_session_id,
"execution_time": final_result.get("execution_time"),
"status": final_result.get("status"),
"stop_reason": final_result.get("stop_reason"),
"iterations": final_result.get("iterations"),
"tool_execution_count": len(final_result.get("tool_executions") or []),
"tool_executions": final_result.get("tool_executions") or [],
"active_skills": (
final_result.get("active_skills")
or final_result.get("skills_used")
or []
),
"retrieved_tools_list": final_result.get("retrieved_tools_list") or [],
"preselection_debug_info": final_result.get("preselection_debug_info"),
"permission_mode": final_result.get("permission_mode"),
"session_capability_state": final_result.get("session_capability_state"),
"recording_dir": recording_dir,
"capture_skill_dir": capture_skill_dir or final_result.get("capture_skill_dir"),
"message_count": message_count,
"final_response_preview": str(response or "")[:500],
}
if storage is not None:
payload.update(
{
"session_dir": str(storage.session_dir),
"transcript_path": str(storage.transcript_path),
"tool_results_dir": str(storage.tool_results_dir),
"file_history_dir": str(storage.file_history_dir),
"transcript_generation": getattr(
storage,
"current_generation",
None,
),
}
)
try:
await self.emit_runtime_event(event_type, payload)
except Exception:
logger.debug("Failed to emit evidence event %s", event_type, exc_info=True)
async def _scan_session_evidence_checkpoint(
self,
*,
task_id: str,
execution_context: dict[str, Any],
) -> None:
storage = self.session_storage
adapter = getattr(self.state, "evidence_runtime_adapter", None)
scan = getattr(adapter, "ingest_session_storage_delta", None)
if storage is None or scan is None:
return
try:
result = scan(
storage,
task_id=task_id,
parent_task_id=execution_context.get("parent_task_id"),
agent_id=execution_context.get("agent_id") or "primary",
)
if asyncio.iscoroutine(result):
await result
except Exception:
logger.debug("Session evidence checkpoint scan failed", exc_info=True)
async def _scan_skill_evidence_checkpoint(
self,
*,
task_id: str,
execution_context: dict[str, Any],
) -> None:
skill_store = getattr(self.state, "skill_store", None)
adapter = getattr(self.state, "evidence_runtime_adapter", None)
scan = getattr(adapter, "ingest_skill_store_delta", None)
if skill_store is None or scan is None:
return
try:
result = scan(
skill_store,
task_id=task_id,
)
if asyncio.iscoroutine(result):
await result
except Exception:
logger.debug("Skill evidence checkpoint scan failed", exc_info=True)
async def _scan_tool_quality_evidence_checkpoint(self) -> None:
adapter = getattr(self.state, "evidence_runtime_adapter", None)
scan = getattr(adapter, "ingest_tool_quality_delta", None)
if scan is None:
return
quality_manager = None
grounding_client = getattr(self.state, "grounding_client", None)
if grounding_client is not None:
quality_manager = getattr(grounding_client, "quality_manager", None)
if quality_manager is None:
return
try:
result = scan(quality_manager)
if asyncio.iscoroutine(result):
await result
except Exception:
logger.debug("Tool quality evidence checkpoint scan failed", exc_info=True)
async def _scan_quality_signal_checkpoint(
self,
*,
task_id: str,
execution_context: dict[str, Any],
) -> None:
if not self._quality_signal_detector_enabled():
return
evidence_store = getattr(self.state, "evidence_store", None)
if evidence_store is None:
return
signal_store = None
try:
from openspace.skill_engine.evidence import EvidenceScope
from openspace.skill_engine.signals import (
CHECKPOINT_TASK_SESSION_PERSISTED,
QualitySignalDetector,
QualitySignalStore,
)
latest_watermark = getattr(evidence_store, "latest_manifest_watermark", None)
if not callable(latest_watermark):
await self._emit_quality_signal_warning(
task_id,
"evidence_store_missing_latest_manifest_watermark",
)
return
scan_watermark = int(latest_watermark())
detector = QualitySignalDetector(evidence_store)
signals = detector.scan_checkpoint(
checkpoint_name=CHECKPOINT_TASK_SESSION_PERSISTED,
scope=EvidenceScope(
session_id=self.current_session_id,
task_id=task_id,
source_task_ids=tuple(
item
for item in (
task_id,
execution_context.get("parent_task_id"),
)
if item
),
),
manifest_watermark=scan_watermark,
)
signal_store = QualitySignalStore(evidence_store)
# Trigger job creation/drain is owned by the runtime cutover path.
signal_store.upsert_many(signals)
except Exception as exc:
logger.debug("Quality signal checkpoint scan failed", exc_info=True)
await self._emit_quality_signal_warning(task_id, str(exc))
finally:
close = getattr(signal_store, "close", None)
if callable(close):
try:
close()
except Exception:
logger.debug("Quality signal store close failed", exc_info=True)
def _quality_signal_detector_enabled(self) -> bool:
return bool(
getattr(self.config, "quality_signal_detector_enabled", True)
)
async def _emit_quality_signal_warning(
self,
task_id: str,
error: str,
) -> None:
try:
await self.emit_runtime_event(
"quality_signal_checkpoint_warning",
{
"session_id": self.current_session_id,
"task_id": task_id,
"error": error,
},
)
except Exception:
logger.debug("Failed to emit quality signal warning", exc_info=True)