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

2842 lines
112 KiB
Python

from __future__ import annotations
import asyncio
import os
import sys
from collections.abc import Callable
from contextlib import nullcontext
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from .event_bus import RuntimeEventBus
from .execution_lifecycle import ExecutionLifecycle
from .execution_request import ExecutionRequest, ExecutionResult
from .session_runtime import SessionRuntime
from .turn_runner import TurnRunner
from .workspace_runtime import WorkspaceRuntime
from openspace.llm.types import TokenUsage
from openspace.persistence import FileHistory, SessionStorage
from openspace.services.runtime_support.cost import CostTracker
from openspace.services.lsp import shutdown_lsp_server_manager
from openspace.utils.logging import Logger
logger = Logger.get_logger(__name__)
_CLEANUP_MEMORY_DRAIN_TIMEOUT_S = 10.0
_QUALITY_SIGNAL_CUTOVER_CHECKPOINT = "quality_signal_cutover:last_watermark"
_GENERAL_EVOLUTION_TRIGGER_TYPES = ("ANALYSIS", "MANUAL", "QUALITY_SIGNAL")
def _default_evolution_replay_command(*, docker_image: str | None = None) -> list[str]:
executable = "python" if docker_image else (sys.executable or "python")
return [executable, "-m", "openspace.skill_engine.evolution.eval_worker"]
def _runtime_source_root() -> Path:
return Path(__file__).resolve().parents[2]
def _evolution_replay_sandbox_manager(workspace_dir: str | Path | None) -> Any | None:
try:
from openspace.services.sandbox import get_process_sandbox_manager
return get_process_sandbox_manager(cwd=workspace_dir)
except Exception:
logger.debug("Evolution replay sandbox manager unavailable", exc_info=True)
return None
def _new_set_event() -> asyncio.Event:
event = asyncio.Event()
event.set()
return event
def _derive_session_title(messages: list[dict[str, Any]]) -> str:
for message in messages:
if not isinstance(message, dict):
continue
if message.get("role") != "user":
continue
content = message.get("content")
if isinstance(content, str) and content.strip():
return content.strip()[:80]
text = message.get("text")
if isinstance(text, str) and text.strip():
return text.strip()[:80]
return ""
def _runtime_skill_store_db_path(config: Any, storage_root: Path | None) -> Path | None:
from openspace.skill_engine.evidence import resolve_skill_store_db_path
explicit = (
getattr(config, "skill_store_db_path", None)
or os.environ.get("OPENSPACE_SKILL_STORE_DB_PATH")
)
return resolve_skill_store_db_path(
explicit_db_path=explicit,
storage_root=storage_root,
workspace_dir=getattr(config, "workspace_dir", None),
)
def _env_evolution_allowed_read_roots() -> list[Path]:
roots: list[Path] = []
raw = os.environ.get("OPENSPACE_EVOLUTION_ALLOWED_READ_ROOTS", "")
for item in raw.split(os.pathsep):
text = item.strip()
if not text:
continue
try:
path = Path(text).expanduser()
except (TypeError, ValueError):
continue
roots.append(path)
return roots
@dataclass(slots=True)
class OpenSpaceRuntimeState:
"""Mutable state for a single OpenSpace runtime/session."""
llm_client: Any | None = None
grounding_client: Any | None = None
grounding_config: Any | None = None
grounding_agent: Any | None = None
multi_agent: Any | None = None
recording_manager: Any | None = None
skill_registry: Any | None = None
skill_store: Any | None = None
execution_analyzer: Any | None = None
skill_evolver: Any | None = None
evidence_store: Any | None = None
evidence_runtime_adapter: Any | None = None
trigger_engine: Any | None = None
packet_builder: Any | None = None
decision_engine: Any | None = None
evolution_engine: Any | None = None
behavior_evaluator: Any | None = None
candidate_store: Any | None = None
evolution_storage_root: Path | None = None
diagnostic_tracker: Any | None = None
reasoning_effort: str | None = None
event_proxy: Any | None = None
tui_bridge: Any | None = None
warm_core: Any | None = None
cost_tracker: CostTracker = field(default_factory=CostTracker)
session_storage: SessionStorage | None = None
file_history: FileHistory | None = None
scheduler: Any | None = None
current_session_id: str | None = None
current_session_metadata: dict[str, Any] | None = None
memory_cleanup_context: dict[str, Any] | None = None
event_sinks: list[Callable[[str, dict[str, Any]], Any]] = field(default_factory=list)
post_execution_tasks: set[asyncio.Task[Any]] = field(default_factory=set)
execution_count: int = 0
last_evolved_skills: list[dict[str, Any]] = field(default_factory=list)
capture_skill_dir: str | None = None
initialized: bool = False
running: bool = False
task_done: asyncio.Event = field(default_factory=_new_set_event)
class OpenSpaceRuntime:
"""Runtime orchestration for one OpenSpace execution request.
This class owns mutable runtime state and the execution lifecycle:
initialization status, session prepare, workspace resolution, turn
execution, post-turn draining, and persistence. The top-level ``OpenSpace``
object remains the public API shell; mutable execution services should be
injected into ``OpenSpaceRuntimeState`` instead of read back from private
facade fields.
"""
def __init__(
self,
*,
config: Any,
event_bus: RuntimeEventBus | None = None,
state: OpenSpaceRuntimeState | None = None,
session_runtime: SessionRuntime | None = None,
workspace_runtime: WorkspaceRuntime | None = None,
turn_runner: TurnRunner | None = None,
execution_lifecycle: ExecutionLifecycle | None = None,
bridge_dispatch_suppressed: Callable[[], bool] | None = None,
) -> None:
self.config = config
self.state = state or OpenSpaceRuntimeState()
self.event_bus = event_bus or RuntimeEventBus()
self._bridge_dispatch_suppressed = bridge_dispatch_suppressed or (lambda: False)
self.session_runtime = session_runtime or SessionRuntime.from_runtime(self)
self.workspace_runtime = (
workspace_runtime or WorkspaceRuntime.from_config(config)
)
self.turn_runner = turn_runner or TurnRunner.from_runtime(self)
self.execution_lifecycle = execution_lifecycle or ExecutionLifecycle(self)
@property
def cost_tracker(self) -> CostTracker:
return self.state.cost_tracker
@cost_tracker.setter
def cost_tracker(self, value: CostTracker) -> None:
self.state.cost_tracker = value
@property
def session_storage(self) -> SessionStorage | None:
return self.state.session_storage
@session_storage.setter
def session_storage(self, value: SessionStorage | None) -> None:
self.state.session_storage = value
if value is None:
return
adapter = self.state.evidence_runtime_adapter
set_entry_sink = getattr(value, "set_entry_sink", None)
if adapter is not None and callable(set_entry_sink):
set_entry_sink(getattr(adapter, "on_session_entry", None))
@property
def file_history(self) -> FileHistory | None:
return self.state.file_history
@file_history.setter
def file_history(self, value: FileHistory | None) -> None:
self.state.file_history = value
@property
def scheduler(self) -> Any | None:
return self.state.scheduler
@scheduler.setter
def scheduler(self, value: Any | None) -> None:
self.state.scheduler = value
@property
def current_session_id(self) -> str | None:
return self.state.current_session_id
@current_session_id.setter
def current_session_id(self, value: str | None) -> None:
self.state.current_session_id = value
@property
def current_session_metadata(self) -> dict[str, Any] | None:
return self.state.current_session_metadata
@current_session_metadata.setter
def current_session_metadata(self, value: dict[str, Any] | None) -> None:
self.state.current_session_metadata = value
session_dir = (value or {}).get("session_dir")
self.register_evidence_read_roots(session_dir)
def register_evidence_read_roots(self, *roots: Any) -> None:
store = self.state.evidence_store
add_many = getattr(store, "add_allowed_read_roots", None)
if store is None or not callable(add_many):
return
cleaned: list[Path] = []
for root in roots:
if not root:
continue
try:
path = Path(root).expanduser()
except (TypeError, ValueError):
continue
if path.is_file():
path = path.parent
cleaned.append(path)
if cleaned:
add_many(cleaned)
def register_skill_evidence_read_roots(self) -> None:
roots: list[Path] = []
registry = self.state.skill_registry
if registry is not None:
try:
skills = registry.list_skills()
except Exception:
skills = []
for skill in skills or []:
path = getattr(skill, "path", None)
if path:
roots.append(Path(path).expanduser().parent)
store = self.state.skill_store
load_active = getattr(store, "load_active", None)
if callable(load_active):
try:
records = load_active()
except Exception:
records = {}
for record in (records or {}).values():
path = getattr(record, "path", None)
if path:
roots.append(Path(path).expanduser().parent)
self.register_evidence_read_roots(*roots)
@property
def llm_client(self) -> Any | None:
return self.state.llm_client
@property
def grounding_client(self) -> Any | None:
return self.state.grounding_client
@property
def grounding_config(self) -> Any | None:
return self.state.grounding_config
@property
def grounding_agent(self) -> Any | None:
return self.state.grounding_agent
@property
def multi_agent(self) -> Any | None:
return self.state.multi_agent
@property
def recording_manager(self) -> Any | None:
return self.state.recording_manager
@property
def skill_registry(self) -> Any | None:
return self.state.skill_registry
@property
def skill_store(self) -> Any | None:
return self.state.skill_store
@property
def execution_analyzer(self) -> Any | None:
return self.state.execution_analyzer
@property
def diagnostic_tracker(self) -> Any | None:
return self.state.diagnostic_tracker
@property
def reasoning_effort(self) -> str | None:
return self.state.reasoning_effort
async def initialize_services(
self,
*,
low_latency_profiler: Any | None = None,
) -> None:
"""Initialize runtime-owned services for the public OpenSpace facade."""
config = self.config
if self.is_initialized:
logger.warning("OpenSpace already initialized")
return
def _startup_span(name: str, **metadata: Any):
if low_latency_profiler is None:
return nullcontext()
span = getattr(low_latency_profiler, "span", None)
if not callable(span):
return nullcontext()
return span(name, **metadata)
def _startup_mark(name: str, **metadata: Any) -> None:
if low_latency_profiler is None:
return
marker = getattr(low_latency_profiler, "mark", None)
if callable(marker):
marker(name, **metadata)
logger.info("Initializing OpenSpace...")
try:
from openspace.agents.grounding_agent import GroundingAgent
from openspace.agents.multi_agent_orchestrator import MultiAgentOrchestrator
from openspace.config import get_config, load_config
from openspace.config.constants import CONFIG_GROUNDING, CONFIG_SECURITY
from openspace.config.loader import CONFIG_DIR, get_agent_config
from openspace.grounding.core.grounding_client import GroundingClient
from openspace.llm import LLMClient
from openspace.recording import RecordingManager
from openspace.services.runtime_support.background import (
run_startup_evolution_recovery,
start_background_housekeeping,
)
from openspace.services.lsp import initialize_lsp_server_manager
from openspace.skill_engine import ExecutionAnalyzer, SkillStore
from openspace.skill_engine.evidence import (
EvidenceStore,
PacketBuilder,
RuntimeEvidenceAdapter,
resolve_evidence_db_path,
resolve_evolution_storage_root,
)
from openspace.skill_engine.evolution import (
CaptureContractSemanticReviewer,
EvolutionAdmission,
EvolutionCandidateStore,
EvolutionCommitter,
EvolutionEngine,
EvolutionRecovery,
EvolutionValidator,
SkillBehaviorEvaluator,
SkillEvolverAuthoringBackend,
SubprocessSkillReplayRunner,
)
from openspace.skill_engine.decision import DecisionEngine
from openspace.skill_engine.evolver import SkillEvolver
from openspace.skill_engine.triggers import TriggerEngine
self.state.llm_client = LLMClient(
model=config.llm_model,
enable_thinking=config.llm_enable_thinking,
rate_limit_delay=config.llm_rate_limit_delay,
max_retries=config.llm_max_retries,
timeout=config.llm_timeout,
**config.llm_kwargs,
)
logger.info("✓ LLM Client: %s", config.llm_model)
if config.grounding_config_path:
grounding_config = load_config(
CONFIG_DIR / CONFIG_GROUNDING,
CONFIG_DIR / CONFIG_SECURITY,
config.grounding_config_path,
)
logger.info(
"Merged custom grounding config: %s",
config.grounding_config_path,
)
else:
grounding_config = get_config()
if getattr(config, "use_clawwork_productivity", False):
shell_cfg = grounding_config.shell.model_copy(
update={
"use_clawwork_productivity": True,
"working_dir": config.workspace_dir
or grounding_config.shell.working_dir,
}
)
grounding_config = grounding_config.model_copy(
update={"shell": shell_cfg}
)
logger.info(
"ClawWork productivity tools enabled "
"(shell.working_dir used as sandbox root)"
)
agent_config = get_agent_config("GroundingAgent")
cli_max_iter = config.grounding_max_iterations
default_max_iter = type(config)().grounding_max_iterations
if agent_config:
cfg_max_iter = agent_config.get("max_iterations", default_max_iter)
if cli_max_iter != default_max_iter:
max_iterations = cli_max_iter
else:
max_iterations = cfg_max_iter
backend_scope = (
config.backend_scope
or agent_config.get("backend_scope")
or ["gui", "shell", "mcp", "web", "meta"]
)
config.grounding_max_iterations = max_iterations
logger.info(
"Loaded GroundingAgent config from config_agents.json "
"(max_iterations=%s)",
max_iterations,
)
else:
max_iterations = config.grounding_max_iterations
backend_scope = (
config.backend_scope or ["gui", "shell", "mcp", "web", "meta"]
)
logger.warning(
"config_agents.json not found, using default config "
"(max_iterations=%s)",
max_iterations,
)
if grounding_config.enabled_backends:
scope_set = set(backend_scope)
filtered = [
entry
for entry in grounding_config.enabled_backends
if entry.get("name", "").lower() in scope_set
]
if len(filtered) != len(grounding_config.enabled_backends):
skipped = [
entry.get("name")
for entry in grounding_config.enabled_backends
if entry.get("name", "").lower() not in scope_set
]
logger.info("Skipping backends not in scope: %s", skipped)
grounding_config = grounding_config.model_copy(
update={"enabled_backends": filtered}
)
self.state.grounding_config = grounding_config
evolution_storage_root = resolve_evolution_storage_root(
explicit_root=getattr(config, "evolution_storage_root", None),
explicit_db_path=(
getattr(config, "evidence_db_path", None)
or getattr(config, "skill_store_db_path", None)
),
session_storage=self.state.session_storage,
skill_store=self.state.skill_store,
workspace_dir=config.workspace_dir,
)
self.state.evolution_storage_root = evolution_storage_root
quality_cfg = getattr(grounding_config, "tool_quality", None)
quality_db_path = _runtime_skill_store_db_path(
config,
evolution_storage_root,
)
if (
quality_cfg is not None
and quality_db_path is not None
and not getattr(quality_cfg, "db_path", None)
):
try:
grounding_config = grounding_config.model_copy(
update={
"tool_quality": quality_cfg.model_copy(
update={"db_path": str(quality_db_path)}
)
}
)
self.state.grounding_config = grounding_config
except Exception:
logger.debug("Failed to align tool quality DB path", exc_info=True)
with _startup_span(
"provider.register",
backend_scope=tuple(str(item) for item in backend_scope),
):
self.state.grounding_client = GroundingClient(config=grounding_config)
with _startup_span(
"provider.initialize",
backend_scope=tuple(str(item) for item in backend_scope),
):
await self.state.grounding_client.initialize_all_providers()
backends = list(self.state.grounding_client.list_providers().keys())
logger.info("✓ Grounding Client: %s backends", len(backends))
logger.debug(" Available backends: %s", [b.value for b in backends])
if config.enable_recording:
self.state.recording_manager = RecordingManager(
enabled=True,
task_id="",
log_dir=config.recording_log_dir,
backends=config.recording_backends,
enable_screenshot=config.enable_screenshot,
enable_video=config.enable_video,
enable_conversation_log=config.enable_conversation_log,
agent_name="OpenSpace",
)
self.state.grounding_client.recording_manager = (
self.state.recording_manager
)
logger.info(
"✓ Recording enabled: %s backends",
len(self.state.recording_manager.backends or []),
)
if getattr(config, "evolution_evidence_enabled", True):
try:
evidence_db_path = resolve_evidence_db_path(
explicit_db_path=getattr(config, "evidence_db_path", None),
storage_root=evolution_storage_root,
session_storage=self.state.session_storage,
skill_store=self.state.skill_store,
workspace_dir=config.workspace_dir,
)
self.state.evidence_store = EvidenceStore(
db_path=evidence_db_path,
allowed_read_roots=(evolution_storage_root,),
)
session_dir = (self.current_session_metadata or {}).get("session_dir")
self.register_evidence_read_roots(
config.workspace_dir,
session_dir,
getattr(config, "recording_log_dir", None),
*_env_evolution_allowed_read_roots(),
)
self.state.packet_builder = PacketBuilder(self.state.evidence_store)
self.state.trigger_engine = None
if getattr(config, "evolution_triggers_enabled", True):
self.state.trigger_engine = TriggerEngine(
self.state.evidence_store
)
self.state.candidate_store = EvolutionCandidateStore(
evidence_store=self.state.evidence_store,
)
replay_command = getattr(config, "evolution_replay_command", None)
replay_docker_image = getattr(
config,
"evolution_replay_docker_image",
None,
)
replay_runner = SubprocessSkillReplayRunner(
replay_command
or _default_evolution_replay_command(
docker_image=replay_docker_image,
),
docker_image=replay_docker_image,
timeout_s=getattr(
config,
"evolution_replay_timeout_s",
600.0,
),
cwd=getattr(config, "workspace_dir", None),
sandbox_manager=_evolution_replay_sandbox_manager(
getattr(config, "workspace_dir", None),
),
pythonpath_roots=(_runtime_source_root(),),
)
self.state.behavior_evaluator = SkillBehaviorEvaluator(
evidence_store=self.state.evidence_store,
llm_client=self.state.llm_client,
replay_runner=replay_runner,
enable_routing_eval=getattr(
config,
"evolution_routing_eval_enabled",
True,
),
require_routing_eval=getattr(
config,
"evolution_routing_eval_required",
False,
),
require_replay_runner=getattr(
config,
"evolution_behavior_eval_require_replay_runner",
True,
),
)
self.state.evidence_runtime_adapter = RuntimeEvidenceAdapter(
self.state.evidence_store,
trigger_engine=self.state.trigger_engine,
)
storage = self.state.session_storage
set_entry_sink = getattr(storage, "set_entry_sink", None)
if storage is not None and callable(set_entry_sink):
set_entry_sink(
getattr(
self.state.evidence_runtime_adapter,
"on_session_entry",
None,
)
)
self.event_bus.register_sink(
self.state.evidence_runtime_adapter.on_runtime_event
)
logger.info("✓ Evolution evidence store: %s", evidence_db_path)
logger.info("✓ Evolution candidate store initialized")
if self.state.trigger_engine is not None:
logger.info("✓ Evolution trigger engine initialized")
except Exception as exc:
logger.warning(
"Evolution evidence store init failed (non-fatal): %s",
exc,
)
if getattr(config, "evolution_engine_enabled", False):
self.state.evolution_engine = EvolutionEngine(
packet_builder=self.state.packet_builder,
decision_engine=self.state.decision_engine,
admission_policy=EvolutionAdmission(
evidence_store=self.state.evidence_store,
skill_store=self.state.skill_store,
registry=self.state.skill_registry,
allow_single_observation_capture=getattr(
config,
"evolution_allow_single_observation_capture",
True,
),
),
candidate_store=self.state.candidate_store,
validator=EvolutionValidator(
evidence_store=self.state.evidence_store,
skill_store=self.state.skill_store,
registry=self.state.skill_registry,
semantic_validator=CaptureContractSemanticReviewer(
self.state.llm_client,
model=(
getattr(
config,
"evolution_capture_semantic_validation_model",
None,
)
or config.execution_analyzer_model
or config.llm_model
),
max_tokens=getattr(
config,
"evolution_capture_semantic_validation_max_tokens",
2048,
),
),
semantic_enabled=getattr(
config,
"evolution_capture_semantic_validation_enabled",
True,
),
),
behavior_evaluator=self.state.behavior_evaluator,
behavior_eval_max_revisions=getattr(
config,
"evolution_behavior_eval_max_revisions",
2,
),
evolution_mode=getattr(config, "evolution_mode", "autonomous"),
)
logger.info(
"✓ Evolution engine enabled (mode=%s)",
getattr(config, "evolution_mode", "autonomous"),
)
tool_retrieval_llm = None
if config.tool_retrieval_model:
tool_retrieval_llm = LLMClient(
model=config.tool_retrieval_model,
timeout=config.llm_timeout,
max_retries=config.llm_max_retries,
**config.llm_kwargs,
)
logger.info("✓ Tool retrieval LLM: %s", config.tool_retrieval_model)
skill_selection_llm = None
if config.skill_registry_model:
skill_selection_llm = LLMClient(
model=config.skill_registry_model,
timeout=30.0,
max_retries=2,
**config.llm_kwargs,
)
logger.info("✓ Skill selection LLM: %s", config.skill_registry_model)
self.state.grounding_agent = GroundingAgent(
name="OpenSpace-GroundingAgent",
backend_scope=backend_scope,
llm_client=self.state.llm_client,
grounding_client=self.state.grounding_client,
recording_manager=self.state.recording_manager,
system_prompt=config.grounding_system_prompt,
max_iterations=max_iterations,
tool_retrieval_llm=tool_retrieval_llm,
skill_selection_llm=skill_selection_llm,
enable_turn0_llm_skill_selector=not bool(
config.disable_turn0_llm_skill_selector
),
)
logger.info("✓ GroundingAgent: %s", ", ".join(backend_scope))
self.state.multi_agent = MultiAgentOrchestrator(
grounding_client=self.state.grounding_client,
llm_client=self.state.llm_client,
event_sink=self.emit_runtime_event,
workspace_dir=config.workspace_dir or Path.cwd(),
)
self.state.multi_agent.initialize()
self.state.multi_agent.bind_agent(self.state.grounding_agent)
logger.info("✓ Multi-agent orchestrator initialized")
scheduler_workspace = str(config.workspace_dir or Path.cwd())
should_start_scheduler = (
config.scheduler_sync_start
or self.workspace_has_enabled_schedules(scheduler_workspace)
)
if should_start_scheduler:
with _startup_span("scheduler.ensure", phase="initialize"):
await self.ensure_scheduler(scheduler_workspace)
_startup_mark("scheduler.initialize_started", phase="initialize")
logger.info("✓ Schedule cron scheduler initialized")
else:
_startup_mark(
"scheduler.initialize_skipped_by_profile",
phase="initialize",
profile=config.capability_profile,
)
logger.info("Schedule cron scheduler initialization skipped by profile")
if config.lsp_sync_start:
try:
with _startup_span("lsp.initialize"):
initialize_lsp_server_manager(
cwd=str(config.workspace_dir or Path.cwd()),
bare=False,
)
logger.info("✓ LSP manager initialized (optional, lazy-start)")
except Exception:
logger.debug("LSP manager init failed", exc_info=True)
else:
logger.info("LSP manager initialization skipped by profile")
if self.state.grounding_config and self.state.grounding_config.skills.enabled:
with _startup_span("skill.registry.discover"):
self.state.skill_registry = self.init_skill_registry()
if self.state.skill_registry:
skills = self.state.skill_registry.list_skills()
logger.info("✓ Skills: %s discovered", len(skills))
admission_policy = None
if self.state.evolution_engine is not None:
admission_policy = getattr(
self.state.evolution_engine,
"admission_policy",
None,
)
if admission_policy is not None and hasattr(
admission_policy,
"registry",
):
admission_policy.registry = self.state.skill_registry
validator = None
if self.state.evolution_engine is not None:
validator = getattr(
self.state.evolution_engine,
"validator",
None,
)
if validator is not None and hasattr(validator, "registry"):
validator.registry = self.state.skill_registry
behavior_evaluator = self.state.behavior_evaluator
if behavior_evaluator is not None and hasattr(
behavior_evaluator,
"registry",
):
behavior_evaluator.registry = self.state.skill_registry
self.state.grounding_agent.set_skill_registry(
self.state.skill_registry
)
skill_cfg = self.state.grounding_config.skills
self.state.grounding_agent.set_skill_protocol_settings(
listing_enabled=skill_cfg.listing_enabled,
discovery_enabled=skill_cfg.discovery_enabled,
discovery_max_results=skill_cfg.discovery_max_results,
listing_budget_context_percent=skill_cfg.listing_budget_context_percent,
listing_max_description_chars=skill_cfg.listing_max_description_chars,
post_tool_query_builder_enabled=skill_cfg.post_tool_query_builder_enabled,
post_tool_query_builder_model=skill_cfg.post_tool_query_builder_model,
post_tool_query_builder_max_chars=skill_cfg.post_tool_query_builder_max_chars,
)
if self.state.skill_registry and config.skill_store_sync_start:
try:
with _startup_span("skill.store.sync"):
skill_store_db_path = _runtime_skill_store_db_path(
config,
self.state.evolution_storage_root,
)
skill_store = SkillStore(
skill_store_db_path,
trust_promotion_min_independent_successes=getattr(
config,
"skill_trust_promotion_min_independent_successes",
2,
),
)
self.state.skill_store = skill_store
admission_policy = None
if self.state.evolution_engine is not None:
admission_policy = getattr(
self.state.evolution_engine,
"admission_policy",
None,
)
if admission_policy is not None and hasattr(
admission_policy,
"skill_store",
):
admission_policy.skill_store = skill_store
validator = None
if self.state.evolution_engine is not None:
validator = getattr(
self.state.evolution_engine,
"validator",
None,
)
if validator is not None and hasattr(validator, "skill_store"):
validator.skill_store = skill_store
behavior_evaluator = self.state.behavior_evaluator
if behavior_evaluator is not None and hasattr(
behavior_evaluator,
"skill_store",
):
behavior_evaluator.skill_store = skill_store
evidence_adapter = self.state.evidence_runtime_adapter
set_evidence_sink = getattr(skill_store, "set_evidence_sink", None)
if evidence_adapter is not None and callable(set_evidence_sink):
set_evidence_sink(
getattr(evidence_adapter, "on_skill_store_event", None)
)
await skill_store.sync_from_registry(
self.state.skill_registry.list_skills()
)
self.register_skill_evidence_read_roots()
self.state.grounding_agent._skill_store = skill_store
logger.info("✓ Skill quality store enabled")
if config.execution_analysis_sync_start:
quality_mgr = (
self.state.grounding_client.quality_manager
if self.state.grounding_client
else None
)
self.state.execution_analyzer = ExecutionAnalyzer(
store=skill_store,
llm_client=self.state.llm_client,
model=config.execution_analyzer_model,
max_tokens=config.execution_analyzer_max_tokens,
skill_registry=self.state.skill_registry,
quality_manager=quality_mgr,
)
logger.info("✓ Execution analysis enabled")
if self.state.evidence_store is not None:
self.state.decision_engine = DecisionEngine(
analyzer=self.state.execution_analyzer,
evidence_store=self.state.evidence_store,
)
if self.state.evolution_engine is not None:
self.state.evolution_engine.decision_engine = (
self.state.decision_engine
)
logger.info("✓ Evolution decision engine enabled")
self.state.skill_evolver = SkillEvolver(
store=skill_store,
registry=self.state.skill_registry,
llm_client=self.state.llm_client,
model=config.skill_evolver_model,
max_tokens=config.skill_evolver_max_tokens,
max_concurrent=config.evolution_max_concurrent,
)
if (
self.state.evolution_engine is not None
and self.state.evidence_store is not None
):
evolution_storage_root = resolve_evolution_storage_root(
explicit_root=getattr(
config,
"evolution_storage_root",
None,
),
explicit_db_path=getattr(
self.state.evidence_store,
"db_path",
getattr(config, "evidence_db_path", None),
),
session_storage=self.state.session_storage,
skill_store=skill_store,
workspace_dir=config.workspace_dir,
)
self.state.evolution_engine.authoring_backend = (
SkillEvolverAuthoringBackend(
self.state.skill_evolver,
evolution_storage_root
/ ".openspace"
/ "evolution"
/ "staging",
self.state.evidence_store,
)
)
self.state.evolution_engine.committer = EvolutionCommitter(
evidence_store=self.state.evidence_store,
skill_store=skill_store,
registry=self.state.skill_registry,
trigger_engine=self.state.trigger_engine,
backup_root=(
evolution_storage_root
/ ".openspace"
/ "evolution"
/ "backups"
),
)
self.register_evidence_read_roots(
evolution_storage_root / ".openspace" / "evolution",
)
logger.info(
"✓ Skill evolution enabled (concurrent=%s)",
config.evolution_max_concurrent,
)
elif config.enable_recording:
logger.info(
"Execution analysis and skill evolution skipped by profile"
)
except Exception as exc:
logger.warning("Skill quality init failed (non-fatal): %s", exc)
elif self.state.skill_registry:
logger.info("Skill quality store sync skipped by profile")
if self.state.evidence_store is not None:
recovery = EvolutionRecovery(
evidence_store=self.state.evidence_store,
skill_store=self.state.skill_store,
registry=self.state.skill_registry,
trigger_engine=self.state.trigger_engine,
stale_job_timeout_s=getattr(
config,
"evolution_recovery_stale_job_timeout_s",
30 * 60,
),
staging_retention_s=getattr(
config,
"evolution_staging_retention_s",
7 * 24 * 60 * 60,
),
)
result = await run_startup_evolution_recovery(
recovery,
event_sink=self.emit_runtime_event,
)
if result is not None:
logger.info("✓ Evolution startup recovery: %s", result.to_dict())
await self.maybe_drain_startup_retryable_evolution_jobs()
self.propagate_service_hooks()
try:
start_background_housekeeping(
{
"cwd": config.workspace_dir or str(Path.cwd()),
"event_sink": self.emit_runtime_event,
},
event_sink=self.emit_runtime_event,
)
logger.info("✓ Background housekeeping initialized")
except Exception:
logger.debug("Background housekeeping init failed", exc_info=True)
self.mark_initialized()
logger.info("=" * 60)
logger.info("OpenSpace ready to use!")
logger.info("=" * 60)
except Exception as exc:
logger.error("Failed to initialize OpenSpace: %s", exc)
await self.cleanup_resources()
raise
async def cleanup_resources(self) -> None:
"""Close runtime-owned services and release process resources."""
logger.info("Cleaning up OpenSpace resources...")
try:
cleanup_context = (
self.memory_cleanup_context or self.build_memory_cleanup_context()
)
try:
from openspace.services.runtime_support.background import stop_background_housekeeping
async def _timeout_event_sink(
event_type: str,
data: dict[str, Any],
) -> None:
payload = dict(data)
payload["reason"] = "cleanup"
await self.event_bus.emit(event_type, payload)
await stop_background_housekeeping(
cleanup_context,
timeout_s=_CLEANUP_MEMORY_DRAIN_TIMEOUT_S,
event_sink=_timeout_event_sink,
cancel_pending=True,
)
except Exception:
logger.debug("Background housekeeping stop failed", exc_info=True)
await self.drain_post_execution_tasks()
if self.state.skill_evolver:
await self.state.skill_evolver.wait_background()
if self.state.multi_agent:
await self.state.multi_agent.shutdown()
if self.scheduler:
await self.scheduler.stop()
self.scheduler = None
await shutdown_lsp_server_manager()
if self.state.grounding_client:
await self.state.grounding_client.close_all_sessions()
logger.debug("All grounding sessions closed")
recording_manager = self.state.recording_manager
if recording_manager and recording_manager.recording_status:
try:
await recording_manager.stop()
logger.debug("Recording manager stopped")
except Exception as exc:
logger.warning("Failed to stop recording: %s", exc)
if self.state.execution_analyzer:
try:
self.state.execution_analyzer.close()
logger.debug("Execution analyzer closed")
except Exception as exc:
logger.debug("Failed to close execution analyzer: %s", exc)
if self.state.trigger_engine:
try:
close = getattr(self.state.trigger_engine, "close", None)
if callable(close):
close()
logger.debug("Trigger engine closed")
except Exception as exc:
logger.debug("Failed to close trigger engine: %s", exc)
if self.state.candidate_store:
try:
close = getattr(self.state.candidate_store, "close", None)
if callable(close):
close()
logger.debug("Evolution candidate store closed")
except Exception as exc:
logger.debug("Failed to close candidate store: %s", exc)
if self.state.evidence_store:
try:
self.state.evidence_store.close()
logger.debug("Evidence store closed")
except Exception as exc:
logger.debug("Failed to close evidence store: %s", exc)
self.mark_uninitialized()
logger.info("OpenSpace cleanup complete")
except Exception as exc:
logger.error("Error during cleanup: %s", exc, exc_info=True)
def propagate_service_hooks(self) -> None:
"""Pass runtime event hooks to initialized sub-components."""
if self.state.grounding_agent:
self.state.grounding_agent.set_tui_bridge(
self.state.event_proxy
if self.state.tui_bridge is not None
else None
)
self.state.grounding_agent.set_runtime_event_sink(self.emit_runtime_event)
if self.state.llm_client:
self.state.llm_client.set_event_callback(self.emit)
self.state.llm_client.set_usage_callback(self.record_llm_usage)
if self.state.multi_agent:
self.state.multi_agent.set_event_sink(self.emit_runtime_event)
if self.scheduler:
self.scheduler.event_sink = self.emit_runtime_event
self.scheduler.notification_service.event_sink = self.emit_runtime_event
self.scheduler.approval_service.event_sink = self.emit_runtime_event
def init_skill_registry(self) -> Any | None:
"""Build and populate the runtime SkillRegistry from configured roots."""
from openspace.runtime.skill_registry import build_skill_registry
config = self.config
skill_cfg = (
self.state.grounding_config.skills
if self.state.grounding_config
else None
)
return build_skill_registry(
workspace_dir=config.workspace_dir,
configured_skill_dirs=getattr(skill_cfg, "skill_dirs", None),
metadata_only_discovery=config.skill_metadata_only_discovery,
)
def session_storage_config_home(self) -> Path | None:
configured = getattr(self.config, "session_storage_dir", None)
if not configured:
return None
return Path(configured).expanduser().resolve()
def session_cwd_from_metadata(
self,
metadata: dict[str, Any] | None,
) -> str:
data = metadata or {}
worktree = data.get("worktree")
if not isinstance(worktree, dict):
worktree = {}
return str(
data.get("cwd")
or data.get("project_path")
or data.get("workspace_dir")
or worktree.get("workspace_dir")
or worktree.get("worktree_path")
or self.config.workspace_dir
or os.getcwd()
)
def ensure_session_storage(
self,
session_id: str | None = None,
*,
metadata: dict[str, Any] | None = None,
create: bool = True,
) -> SessionStorage:
sid = session_id or self.current_session_id
if not sid:
storage = SessionStorage.create_new(
cwd=self.session_cwd_from_metadata(metadata),
model=self.config.llm_model,
config_home=self.session_storage_config_home(),
metadata=metadata,
)
self.session_storage = storage
self.current_session_id = storage.session_id
metadata_dict = storage.metadata.to_dict()
metadata_dict["session_dir"] = str(storage.session_dir)
metadata_dict["transcript_path"] = str(storage.transcript_path)
self.current_session_metadata = metadata_dict
self.configure_file_history()
return storage
if (
self.session_storage is not None
and self.session_storage.session_id == str(sid)
):
return self.session_storage
storage = SessionStorage.for_session(
str(sid),
cwd=self.session_cwd_from_metadata(metadata),
config_home=self.session_storage_config_home(),
create=create,
)
self.session_storage = storage
return storage
async def restore_canonical_session(self, session_id: str) -> dict[str, Any]:
from openspace.services.session.restore import restore_session as restore_runtime_session
restored_session = await restore_runtime_session(
session_id,
cwd=os.getcwd(),
allow_cross_project=True,
config_home=self.session_storage_config_home(),
)
return restored_session.to_dict()
async def save_canonical_session_messages(
self,
session_id: str,
metadata: dict[str, Any],
messages: list[dict[str, Any]],
*,
replace: bool = False,
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
storage = self.ensure_session_storage(session_id, metadata=metadata)
metadata_with_cost = dict(metadata)
metadata_with_cost["cost"] = self.build_cost_snapshot()
if replace:
await storage.replace_messages(
messages,
model=self.config.llm_model,
metadata_patch=metadata_with_cost,
)
else:
await storage.save_turn(
messages,
model=self.config.llm_model,
metadata_patch=metadata_with_cost,
)
loaded = storage.load()
self.current_session_id = storage.session_id
self.current_session_metadata = dict(loaded.metadata)
return dict(loaded.metadata), list(loaded.messages)
async def prepare_session(self, execution_context: dict[str, Any]) -> str:
"""Create or restore the runtime session for this execution."""
requested_session = execution_context.get("session_id")
self.cost_tracker = CostTracker()
if requested_session:
try:
restored = await self.restore_session(str(requested_session))
if restored["messages"] and not execution_context.get(
"conversation_history"
):
execution_context["conversation_history"] = restored["messages"]
self.apply_restored_runtime(restored, execution_context)
self.remember_memory_cleanup_context(execution_context)
return str(requested_session)
except FileNotFoundError:
logger.warning(
"Requested session %s not found, creating a new session",
requested_session,
)
self.session_storage = SessionStorage.create_new(
cwd=os.getcwd(),
model=self.config.llm_model,
config_home=self.session_storage_config_home(),
metadata={
"mode": "default",
"runtime": {"model": self.config.llm_model},
"persistence": {"source": "openspace.persistence.SessionStorage"},
},
)
metadata = self.session_storage.metadata.to_dict()
metadata["session_dir"] = str(self.session_storage.session_dir)
metadata["transcript_path"] = str(self.session_storage.transcript_path)
self.current_session_id = self.session_storage.session_id
self.current_session_metadata = metadata
self.configure_file_history()
self.remember_memory_cleanup_context()
return self.session_storage.session_id
async def persist_session(
self,
final_result: dict[str, Any],
execution_context: dict[str, Any],
) -> None:
"""Persist the latest session snapshot after an execution."""
if not self.current_session_id or not self.current_session_metadata:
return
messages = final_result.get("messages")
if not isinstance(messages, list):
messages = execution_context.get("conversation_history", [])
if not isinstance(messages, list):
messages = []
metadata = self.build_session_record(
dict(self.current_session_metadata),
[message for message in messages if isinstance(message, dict)],
execution_context=execution_context,
final_result=final_result,
)
self.current_session_metadata = metadata
await self.save_canonical_session_messages(
self.current_session_id,
metadata,
[message for message in messages if isinstance(message, dict)],
)
async def restore_session(self, session_id: str) -> dict[str, Any]:
"""Restore persisted session state into the active runtime."""
restored = await self.restore_canonical_session(session_id)
restored_record = restored.get("session_record")
restored_metadata = (
dict(restored_record) if isinstance(restored_record, dict) else {}
)
self.session_storage = SessionStorage.for_session(
str(restored.get("session_id") or session_id),
cwd=restored_metadata.get("cwd")
or restored_metadata.get("project_path")
or os.getcwd(),
config_home=self.session_storage_config_home(),
create=True,
)
self.current_session_id = str(restored.get("session_id") or session_id)
self.current_session_metadata = restored_metadata
self.cost_tracker = CostTracker()
if restored.get("cost"):
self.cost_tracker.restore(restored.get("cost"))
self.configure_file_history(restored.get("file_history_snapshots"))
self.restore_workspace_from_restored_session(restored)
self.apply_restored_runtime(restored, None)
self.remember_memory_cleanup_context()
return restored
async def load_session_snapshot(self, session_id: str) -> dict[str, Any]:
"""Load persisted session data without mutating runtime or workspace."""
return await self.restore_canonical_session(session_id)
def configure_file_history(self, snapshots: Any | None = None) -> None:
"""Attach the per-file history service to the active SessionStorage."""
if self.session_storage is None:
self.file_history = None
return
history = FileHistory(session_storage=self.session_storage)
if snapshots is None:
try:
snapshots = self.session_storage.load().file_history_snapshots
except Exception:
snapshots = None
if isinstance(snapshots, list):
history.restore_state(snapshots)
self.file_history = history
async def fork_session(self, session_id: str) -> dict[str, Any]:
"""Fork a session while preserving OpenSpace SessionStorage transcripts."""
from openspace.services.session.restore import restore_session as restore_runtime_session
forked = await restore_runtime_session(
session_id,
cwd=os.getcwd(),
fork=True,
allow_cross_project=True,
config_home=self.session_storage_config_home(),
)
restored = forked.to_dict()
self.session_storage = SessionStorage.for_session(
forked.session_id,
cwd=os.getcwd(),
config_home=self.session_storage_config_home(),
create=True,
)
self.current_session_id = forked.session_id
metadata = restored.get("session_record")
self.current_session_metadata = (
dict(metadata) if isinstance(metadata, dict) else {}
)
self.cost_tracker = CostTracker()
if restored.get("cost"):
self.cost_tracker.restore(restored.get("cost"))
self.configure_file_history(restored.get("file_history_snapshots"))
self.apply_restored_runtime(restored, None)
self.remember_memory_cleanup_context()
return restored
async def rewind_session(
self,
session_id: str,
messages: list[dict[str, Any]],
) -> dict[str, Any]:
"""Replace a session transcript with a rewound message list."""
if session_id != self.current_session_id or not self.current_session_metadata:
await self.restore_session(session_id)
normalized_messages = [
message for message in messages if isinstance(message, dict)
]
session_record = self.build_session_record(
dict(self.current_session_metadata or {}),
normalized_messages,
execution_context=None,
)
session_record["last_task_id"] = None
session_record["last_status"] = "rewound"
runtime = dict(session_record.get("runtime", {}))
runtime.pop("active_task_id", None)
runtime["phase"] = "rewound"
session_record["runtime"] = runtime
from openspace.services.session.restore import rewind_session as rewind_canonical_session
restored_session = await rewind_canonical_session(
session_id,
normalized_messages,
cwd=(
session_record.get("cwd")
or session_record.get("project_path")
or os.getcwd()
),
config_home=self.session_storage_config_home(),
model=self.config.llm_model,
metadata_patch=session_record,
cost=self.build_cost_snapshot(),
allow_cross_project=True,
)
restored = restored_session.to_dict()
self.session_storage = SessionStorage.for_session(
restored_session.session_id,
cwd=restored_session.session_record.get("cwd")
or restored_session.session_record.get("project_path")
or os.getcwd(),
config_home=self.session_storage_config_home(),
create=True,
)
self.current_session_metadata = dict(restored_session.session_record)
self.apply_restored_runtime(restored, None)
return restored
async def discover_sessions(self, **kwargs: Any) -> dict[str, Any]:
"""Discover resumable canonical sessions."""
from openspace.services.session.restore import discover_sessions as discover_runtime_sessions
normalized_page = max(0, int(kwargs.get("page") or 0))
page_size = max(1, int(kwargs.get("page_size") or 20))
discovered = await discover_runtime_sessions(
os.getcwd(),
page=normalized_page,
page_size=page_size,
limit=kwargs.get("limit", 50),
all_projects=bool(kwargs.get("all_projects", False)),
config_home=self.session_storage_config_home(),
)
return discovered.to_dict()
async def save_current_session(
self,
session_name: str | None = None,
) -> dict[str, Any]:
"""Persist the currently active session snapshot."""
if not self.current_session_id or not self.current_session_metadata:
storage = self.ensure_session_storage(
metadata={
"mode": "default",
"runtime": {"model": self.config.llm_model},
}
)
metadata = storage.metadata.to_dict()
self.current_session_metadata = dict(metadata)
else:
storage = self.ensure_session_storage(
self.current_session_id,
metadata=dict(self.current_session_metadata or {}),
)
metadata = dict(self.current_session_metadata or {})
try:
loaded = storage.load()
loaded_metadata = dict(loaded.metadata)
loaded_metadata.update(metadata)
metadata = loaded_metadata
messages = list(loaded.messages)
except FileNotFoundError:
messages = []
if not isinstance(messages, list):
messages = []
if session_name:
metadata["title"] = session_name
session_record = self.build_session_record(
metadata,
[message for message in messages if isinstance(message, dict)],
execution_context=None,
)
if self.current_session_id is None:
self.current_session_id = storage.session_id
await self.save_canonical_session_messages(
self.current_session_id,
session_record,
[message for message in messages if isinstance(message, dict)],
)
return {
"session_id": self.current_session_id,
"name": session_record.get("title"),
"message_count": len(messages),
"cost_usd": self.cost_tracker.get_total(),
}
def remember_memory_cleanup_context(
self,
context: dict[str, Any] | None = None,
) -> None:
cleanup_context = self.build_memory_cleanup_context(context)
if cleanup_context is not None:
self.state.memory_cleanup_context = cleanup_context
def build_memory_cleanup_context(
self,
context: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
source = context or {}
metadata = self.current_session_metadata or {}
session_id_value = source.get("session_id") or self.current_session_id
session_id = (
str(session_id_value).strip()
if session_id_value is not None
else None
)
if not session_id:
session_id = None
session_dir_value = source.get("session_dir")
session_dir = (
str(session_dir_value).strip()
if session_dir_value is not None
else None
)
if not session_dir and self.session_storage is not None:
session_dir = str(self.session_storage.session_dir)
cwd_value = source.get("cwd") or source.get("workspace_dir")
if cwd_value is None:
worktree = metadata.get("worktree")
if not isinstance(worktree, dict):
worktree = {}
custom_metadata = metadata.get("metadata")
if not isinstance(custom_metadata, dict):
custom_metadata = {}
cwd_value = (
metadata.get("workspace_dir")
or worktree.get("workspace_dir")
or custom_metadata.get("workspace_dir")
or self.config.workspace_dir
)
cwd = str(cwd_value).strip() if cwd_value is not None else None
if not cwd:
cwd = None
cleanup_context: dict[str, Any] = {}
if session_id is not None:
cleanup_context["session_id"] = session_id
if session_dir is not None:
cleanup_context["session_dir"] = session_dir
if cwd is not None:
cleanup_context["cwd"] = cwd
return cleanup_context or None
def build_cost_snapshot(self) -> dict[str, Any]:
return self.cost_tracker.snapshot()
def build_session_record(
self,
metadata: dict[str, Any],
messages: list[dict[str, Any]],
execution_context: dict[str, Any] | None,
final_result: dict[str, Any] | None = None,
) -> dict[str, Any]:
record = dict(metadata)
custom_metadata = record.get("metadata")
if not isinstance(custom_metadata, dict):
custom_metadata = {}
runtime = record.get("runtime")
if not isinstance(runtime, dict):
runtime = {}
worktree = record.get("worktree")
if not isinstance(worktree, dict):
worktree = {}
context = execution_context or {}
result = final_result or {}
workspace_dir = context.get("workspace_dir")
project_path = str(record.get("project_path") or Path.cwd())
worktree_path = str(record.get("worktree_path") or workspace_dir or project_path)
record["turn_count"] = int(record.get("turn_count", 0)) + (
1 if final_result else 0
)
record["message_count"] = len(messages)
record["last_task_id"] = result.get("task_id", record.get("last_task_id"))
record["last_status"] = result.get("status", record.get("last_status"))
record["model"] = self.config.llm_model
record["title"] = (
record.get("title")
or record.get("name")
or _derive_session_title(messages)
)
record["project_path"] = project_path
record["worktree_path"] = worktree_path
if workspace_dir:
record["workspace_dir"] = workspace_dir
record["mode"] = record.get("mode") or custom_metadata.get("mode") or "default"
custom_metadata.update(
{
"workspace_dir": workspace_dir or custom_metadata.get("workspace_dir"),
"skills_used": result.get("skills_used", []),
}
)
record["metadata"] = custom_metadata
breakdown = self.cost_tracker.get_breakdown()
total_input = sum(item["input_tokens"] for item in breakdown.values())
total_output = sum(item["output_tokens"] for item in breakdown.values())
total_cache_read = sum(
item.get("cache_read_input_tokens", 0)
for item in breakdown.values()
)
total_cache_creation = sum(
item.get("cache_creation_input_tokens", 0)
for item in breakdown.values()
)
total_reasoning = sum(
item.get("reasoning_tokens", 0)
for item in breakdown.values()
)
runtime.update(
{
"session_id": self.current_session_id,
"model": self.config.llm_model,
"cost_usd": self.cost_tracker.get_total(),
"input_tokens": total_input,
"output_tokens": total_output,
"cache_read_input_tokens": total_cache_read,
"cache_creation_input_tokens": total_cache_creation,
"reasoning_tokens": total_reasoning,
"unknown_model_cost": self.cost_tracker.has_unknown_model_cost(),
}
)
if result.get("task_id"):
runtime["active_task_id"] = result["task_id"]
if context.get("max_iterations") is not None:
runtime["max_iterations"] = context.get("max_iterations")
if result.get("status"):
runtime["phase"] = result["status"]
record["runtime"] = runtime
worktree.update(
{
"project_path": project_path,
"worktree_path": worktree_path,
"workspace_dir": (
workspace_dir or worktree.get("workspace_dir") or worktree_path
),
}
)
record["worktree"] = worktree
record.setdefault("file_history_snapshots", [])
record.setdefault("content_replacements", [])
return record
def apply_restored_runtime(
self,
restored: dict[str, Any],
execution_context: dict[str, Any] | None,
) -> None:
session_record = restored.get("session_record")
if not isinstance(session_record, dict):
return
runtime = restored.get("runtime")
if not isinstance(runtime, dict):
runtime = {}
model = runtime.get("model") or session_record.get("model")
if isinstance(model, str) and model:
self.config.llm_model = model
if self.state.llm_client is not None:
self.state.llm_client.model = model
workspace_dir = (
session_record.get("workspace_dir")
or (session_record.get("worktree") or {}).get("workspace_dir")
or (session_record.get("metadata") or {}).get("workspace_dir")
)
if execution_context is not None and workspace_dir:
execution_context.setdefault("workspace_dir", workspace_dir)
agent_type = session_record.get("agent_type")
if not agent_type and isinstance(session_record.get("agent"), dict):
agent_type = session_record["agent"].get("type")
if execution_context is not None and isinstance(agent_type, str) and agent_type:
execution_context.setdefault("agent_type", agent_type)
todo_state = runtime.get("todo_state")
if execution_context is not None and isinstance(todo_state, dict):
execution_context["todo_state"] = {
str(key): list(value) if isinstance(value, list) else []
for key, value in todo_state.items()
}
def restore_workspace_from_restored_session(
self,
restored: dict[str, Any],
) -> None:
"""Canonical recovery uses per-file history; no workspace snapshot path."""
del restored
return
async def record_llm_usage(
self,
model: str,
usage: TokenUsage,
) -> None:
"""Track cumulative usage and surface it as status updates."""
await self.cost_tracker.add_usage(model, usage)
breakdown = self.cost_tracker.get_breakdown()
total_input = sum(item["input_tokens"] for item in breakdown.values())
total_output = sum(item["output_tokens"] for item in breakdown.values())
total_cache_read = sum(
item.get("cache_read_input_tokens", 0)
for item in breakdown.values()
)
total_cache_creation = sum(
item.get("cache_creation_input_tokens", 0)
for item in breakdown.values()
)
total_reasoning = sum(
item.get("reasoning_tokens", 0)
for item in breakdown.values()
)
await self.emit(
"status_update",
{
"phase": "llm_usage",
"session_id": self.current_session_id,
"model": model,
"input_tokens": total_input,
"output_tokens": total_output,
"cache_read_input_tokens": total_cache_read,
"cache_creation_input_tokens": total_cache_creation,
"reasoning_tokens": total_reasoning,
"cost_usd": self.cost_tracker.get_total(),
"unknown_model_cost": self.cost_tracker.has_unknown_model_cost(),
},
)
await self.emit_runtime_event(
"background_session_update",
{
"session_id": self.current_session_id,
"status": "running" if self.state.running else "idle",
"active_agent_id": "primary",
"metadata": {
"model": model,
"input_tokens": total_input,
"output_tokens": total_output,
"cache_read_input_tokens": total_cache_read,
"cache_creation_input_tokens": total_cache_creation,
"reasoning_tokens": total_reasoning,
"cost_usd": self.cost_tracker.get_total(),
"unknown_model_cost": self.cost_tracker.has_unknown_model_cost(),
},
},
)
def get_runtime_status(self) -> dict[str, Any]:
"""Return the current runtime snapshot for TUI status sync."""
breakdown = self.cost_tracker.get_breakdown()
total_input = sum(item["input_tokens"] for item in breakdown.values())
total_output = sum(item["output_tokens"] for item in breakdown.values())
total_cache_read = sum(
item.get("cache_read_input_tokens", 0)
for item in breakdown.values()
)
total_cache_creation = sum(
item.get("cache_creation_input_tokens", 0)
for item in breakdown.values()
)
total_reasoning = sum(
item.get("reasoning_tokens", 0)
for item in breakdown.values()
)
status = {
"model": self.config.llm_model,
"session_id": self.current_session_id,
"cost_usd": self.cost_tracker.get_total(),
"input_tokens": total_input,
"output_tokens": total_output,
"cache_read_input_tokens": total_cache_read,
"cache_creation_input_tokens": total_cache_creation,
"reasoning_tokens": total_reasoning,
"unknown_model_cost": self.cost_tracker.has_unknown_model_cost(),
"reasoning_effort": self.reasoning_effort or "auto",
}
sandbox = self.get_sandbox_runtime_status()
if sandbox is not None:
status["sandbox"] = sandbox
return status
def get_sandbox_runtime_status(self) -> dict[str, Any] | None:
try:
from openspace.services.sandbox import (
build_sandbox_status,
get_process_sandbox_manager,
)
cwd = self.config.workspace_dir
metadata = self.current_session_metadata
if not cwd and isinstance(metadata, dict):
worktree = metadata.get("worktree")
if isinstance(worktree, dict):
workspace_dir = worktree.get("workspace_dir")
if isinstance(workspace_dir, str) and workspace_dir.strip():
cwd = workspace_dir
if not cwd:
for key in ("workspace_dir", "project_path", "worktree_path"):
value = metadata.get(key)
if isinstance(value, str) and value.strip():
cwd = value
break
cwd = cwd or os.getcwd()
manager = get_process_sandbox_manager(cwd=cwd)
return build_sandbox_status(manager)
except Exception as exc:
logger.debug(f"Unable to build sandbox runtime status: {exc}")
return None
def add_post_execution_task(self, task: asyncio.Task[Any]) -> None:
self.state.post_execution_tasks.add(task)
task.add_done_callback(self.state.post_execution_tasks.discard)
async def drain_post_execution_tasks(self) -> None:
if not self.state.post_execution_tasks:
return
tasks = list(self.state.post_execution_tasks)
drain = asyncio.gather(*tasks, return_exceptions=True)
timeout_s = self.post_execution_timeout_s()
if timeout_s > 0:
try:
await asyncio.wait_for(drain, timeout=timeout_s)
except asyncio.TimeoutError:
logger.warning(
"Post-execution background drain timed out after %.2fs; "
"cancelling unfinished tasks",
timeout_s,
)
for task in tasks:
if not task.done():
task.cancel()
await asyncio.wait(tasks, timeout=1.0)
else:
await drain
self.state.post_execution_tasks.clear()
async def drain_memory_background_tasks(
self,
*,
timeout_s: float,
reason: str,
context: dict[str, Any] | None = None,
) -> None:
"""Drain already-submitted memory background tasks before persistence."""
if timeout_s <= 0:
return
async def _timeout_event_sink(
event_type: str,
data: dict[str, Any],
) -> None:
payload = dict(data)
payload["reason"] = reason
if "session_id" not in payload and context is not None:
session_id = context.get("session_id")
if session_id:
payload["session_id"] = session_id
await self.event_bus.emit(event_type, payload)
try:
from openspace.services.runtime_support.background import drain_background_tasks
drain_result = await drain_background_tasks(
timeout_s=timeout_s,
event_sink=_timeout_event_sink,
context=context,
)
payload = drain_result.as_event_payload()
payload["reason"] = reason
payload["timed_out"] = bool(drain_result.timed_out)
if context is not None:
if "session_id" not in payload and context.get("session_id"):
payload["session_id"] = context.get("session_id")
if context.get("task_id"):
payload["task_id"] = context.get("task_id")
await self.event_bus.emit("background_drain", payload)
except Exception:
logger.debug(
"Memory background drain failed during %s",
reason,
exc_info=True,
)
def post_execution_mode(self) -> str:
mode = str(self.config.post_execution_mode or "inline").strip().lower()
if mode not in {"inline", "background", "disabled"}:
logger.warning(
"Unknown post_execution_mode=%r; falling back to inline",
self.config.post_execution_mode,
)
return "inline"
return mode
def post_execution_timeout_s(self) -> float:
try:
return max(
0.0,
float(getattr(self.config, "post_execution_timeout_s", 0.0) or 0.0),
)
except (TypeError, ValueError):
logger.warning(
"Invalid post_execution_timeout_s=%r; disabling timeout",
getattr(self.config, "post_execution_timeout_s", None),
)
return 0.0
async def run_post_execution_tasks(
self,
task_id: str,
recording_dir: str | None,
result: dict[str, Any],
*,
evolved_skills: list[dict[str, Any]] | None = None,
capture_skill_dir: str | None = None,
session_id: str | None = None,
) -> list[dict[str, Any]]:
task_evolved_skills = evolved_skills if evolved_skills is not None else []
del capture_skill_dir
if self.state.evolution_engine is not None and self.state.trigger_engine is not None:
jobs = self._ensure_analysis_trigger_jobs(task_id, session_id=session_id)
outcomes = []
if jobs:
outcomes = await self.drain_evolution_jobs(
job_ids=[job.job_id for job in jobs],
limit=len(jobs),
)
for outcome in outcomes:
for record in getattr(outcome, "evolved_skill_records", []) or []:
task_evolved_skills.append(
self.evolved_skill_record_from_evolution(record)
)
else:
logger.debug(
"Post-execution skill evolution skipped: evolution engine unavailable"
)
await self._run_legacy_execution_analysis(
task_id,
recording_dir=recording_dir,
result=result,
)
quality_outcomes = await self.maybe_evolve_quality()
for outcome in quality_outcomes:
for record in getattr(outcome, "evolved_skill_records", []) or []:
task_evolved_skills.append(
self.evolved_skill_record_from_evolution(record)
)
final_outcomes = await self.maybe_drain_final_evolution_jobs(
task_id=task_id,
session_id=session_id,
)
for outcome in final_outcomes:
for record in getattr(outcome, "evolved_skill_records", []) or []:
task_evolved_skills.append(
self.evolved_skill_record_from_evolution(record)
)
return task_evolved_skills
async def _run_legacy_execution_analysis(
self,
task_id: str,
*,
recording_dir: str | None,
result: dict[str, Any],
) -> None:
analyzer = self.state.execution_analyzer
analyze = getattr(analyzer, "analyze_execution", None)
if analyzer is None or not callable(analyze):
return
if not recording_dir:
logger.debug(
"Legacy post-execution analysis skipped: recording_dir unavailable"
)
return
try:
await analyze(task_id, recording_dir, result)
except Exception:
logger.debug(
"Legacy post-execution analysis failed for %s",
task_id,
exc_info=True,
)
def _ensure_analysis_trigger_jobs(
self,
task_id: str,
*,
session_id: str | None = None,
) -> list[Any]:
trigger_engine = self.state.trigger_engine
if trigger_engine is None:
return []
try:
from openspace.skill_engine.evidence import EvidenceScope
return list(
trigger_engine.evaluate_checkpoint(
"task_session_persisted",
EvidenceScope(
session_id=session_id or self.current_session_id,
task_id=task_id,
),
)
or []
)
except Exception:
logger.debug("Analysis trigger job creation skipped", exc_info=True)
return []
async def drain_evolution_jobs(
self,
*,
job_ids: list[str] | None = None,
trigger_types: tuple[str, ...] | None = None,
scope: Any | None = None,
claim_statuses: tuple[str, ...] | None = None,
limit: int = 1,
) -> list[Any]:
trigger_engine = self.state.trigger_engine
evolution_engine = self.state.evolution_engine
if trigger_engine is None or evolution_engine is None:
return []
worker_id = f"runtime:{self.current_session_id or 'session'}"
try:
if job_ids:
claim_jobs = getattr(trigger_engine, "claim_jobs", None)
if not callable(claim_jobs):
return []
jobs = list(claim_jobs(job_ids, worker_id=worker_id) or [])
else:
try:
jobs = list(
trigger_engine.claim_next(
limit=limit,
worker_id=worker_id,
trigger_types=trigger_types,
scope=scope,
claim_statuses=claim_statuses,
)
or []
)
except TypeError:
if (
trigger_types is not None
or scope is not None
or claim_statuses is not None
):
return []
jobs = list(
trigger_engine.claim_next(
limit=limit,
worker_id=worker_id,
)
or []
)
except Exception:
logger.debug("Evolution trigger job claim failed", exc_info=True)
return []
outcomes: list[Any] = []
for job in jobs:
outcome = None
try:
outcome = await evolution_engine.process_job(job)
except asyncio.CancelledError:
self._complete_cancelled_evolution_job(
trigger_engine=trigger_engine,
job=job,
outcome=outcome,
)
raise
except Exception as exc:
logger.debug(
"Evolution job processing failed for %s",
getattr(job, "job_id", ""),
exc_info=True,
)
from openspace.skill_engine.evolution import EvolutionRunResult
outcome = EvolutionRunResult(
job_id=str(getattr(job, "job_id", "") or ""),
status="failed",
decisions=[],
admissions=[],
candidates=[],
actions=[],
evolved_skill_records=[],
errors=[str(exc)],
)
outcomes.append(outcome)
from openspace.skill_engine.evolution import (
completion_after_recovery,
completion_from_outcome,
)
completion = completion_from_outcome(outcome)
try:
recovered_actions: list[Any] = []
if completion.needs_recovery:
recovered_actions = await self._recover_committing_actions(
reason=f"trigger_job:{getattr(job, 'job_id', '')}",
)
completion = completion_after_recovery(outcome, recovered_actions)
if not self._trigger_job_already_terminal(job):
trigger_engine.complete(
job.job_id,
status=completion.status,
result_ref=completion.result_ref,
error=completion.error,
)
except asyncio.CancelledError:
self._complete_cancelled_evolution_job(
trigger_engine=trigger_engine,
job=job,
outcome=outcome,
)
raise
except Exception:
logger.debug(
"Evolution trigger job completion failed for %s",
getattr(job, "job_id", ""),
exc_info=True,
)
return outcomes
def _complete_cancelled_evolution_job(
self,
*,
trigger_engine: Any,
job: Any,
outcome: Any | None,
) -> None:
job_id = str(getattr(job, "job_id", "") or "")
if not job_id or self._trigger_job_already_terminal(job):
return
status = "failed_retryable"
result_ref = None
error = "evolution job cancelled before completion"
if outcome is not None:
try:
from openspace.skill_engine.evolution import completion_from_outcome
completion = completion_from_outcome(outcome)
status = completion.status
result_ref = completion.result_ref
error = completion.error or error
except Exception:
logger.debug(
"Evolution cancellation completion mapping failed for %s",
job_id,
exc_info=True,
)
try:
trigger_engine.complete(
job_id,
status=status,
result_ref=result_ref,
error=error,
)
except Exception:
logger.debug(
"Evolution trigger job cancellation completion failed for %s",
job_id,
exc_info=True,
)
def _trigger_job_already_terminal(self, job: Any) -> bool:
job_id = str(getattr(job, "job_id", "") or "")
if not job_id:
return False
trigger_engine = self.state.trigger_engine
store = getattr(trigger_engine, "store", None)
get_job = getattr(store, "get_job", None)
if not callable(get_job):
return False
try:
loaded = get_job(job_id)
except Exception:
return False
status = str(getattr(loaded, "status", "") or "").lower()
return status in {"completed", "failed", "superseded", "rejected"}
async def _recover_committing_actions(self, *, reason: str) -> list[Any]:
evolution_engine = self.state.evolution_engine
recover = getattr(evolution_engine, "recover_committing_actions", None)
if not callable(recover):
return []
try:
recovered = await recover()
if recovered:
logger.info(
"Evolution committing action recovery after %s: %s action(s)",
reason,
len(recovered),
)
return list(recovered or [])
except Exception:
logger.debug(
"Evolution committing action recovery failed after %s",
reason,
exc_info=True,
)
return []
def schedule_post_execution_tasks(
self,
task_id: str,
recording_dir: str | None,
result: dict[str, Any],
*,
evolved_skills: list[dict[str, Any]] | None = None,
capture_skill_dir: str | None = None,
) -> None:
if (
not self.state.evolution_engine
and not self.state.trigger_engine
and not self.state.grounding_client
):
return
task_result = dict(result)
task_evolved_skills = evolved_skills if evolved_skills is not None else []
task_capture_skill_dir = capture_skill_dir
session_id = self.current_session_id
workspace_dir = self.config.workspace_dir
async def _runner() -> None:
await self.run_post_execution_tasks(
task_id,
recording_dir,
task_result,
evolved_skills=task_evolved_skills,
capture_skill_dir=task_capture_skill_dir,
session_id=session_id,
)
from openspace.services.runtime_support.background import get_background_supervisor
supervisor = (
getattr(self.state.warm_core, "background_supervisor", None)
if self.state.warm_core is not None
else None
) or get_background_supervisor()
task = supervisor.submit(
source="post_execution",
name="Post Execution",
description="Background execution analysis and skill evolution",
task_type="post_execution",
task_id=f"post-{task_id}",
context={
"event_sink": self.event_bus.emit,
"session_id": session_id,
"cwd": workspace_dir,
},
coro_factory=_runner,
)
self.add_post_execution_task(task)
async def maybe_evolve_quality(self) -> list[Any]:
"""Trigger quality evolution based on global execution count."""
self.increment_execution_count()
if not self._quality_cutover_enabled():
return []
created_jobs, _signal_path_failed = self._create_quality_signal_trigger_jobs()
if created_jobs:
return await self._drain_created_evolution_jobs(created_jobs)
return []
def _create_quality_signal_trigger_jobs(self) -> tuple[list[Any], bool]:
"""Create QUALITY_SIGNAL jobs for the runtime cutover path.
Finalization only records quality_signal_ref evidence; this method is
the runtime entry that creates jobs which maybe_evolve_quality drains.
"""
evidence_store = getattr(self.state, "evidence_store", None)
trigger_engine = getattr(self.state, "trigger_engine", None)
if evidence_store is None:
logger.warning("Quality signal cutover path unavailable: missing evidence store")
return [], True
if trigger_engine is None:
logger.warning("Quality signal cutover path unavailable: missing trigger engine")
return [], True
latest_watermark = getattr(evidence_store, "latest_manifest_watermark", None)
if not callable(latest_watermark):
logger.warning(
"Quality signal cutover path unavailable: evidence store has no "
"latest_manifest_watermark API"
)
return [], True
signal_store = None
try:
from openspace.skill_engine.signals import (
QualitySignalReconciler,
QualitySignalStore,
)
since = self._load_quality_signal_cutover_checkpoint()
until = int(latest_watermark())
signal_store = QualitySignalStore(evidence_store)
if bool(getattr(self.config, "quality_signal_reconciliation_enabled", True)):
reconciler = QualitySignalReconciler(
evidence_store,
signal_store=signal_store,
trigger_engine=None,
enabled=True,
)
reconciliation = reconciler.scan_window(
since_watermark=since,
until_watermark=until,
)
if self._quality_signal_reconciliation_failed(
evidence_store,
reconciliation,
):
return [], True
if not bool(getattr(self.config, "quality_signal_trigger_enabled", True)):
logger.warning(
"Quality signal cutover path unavailable: signal trigger disabled"
)
return [], True
from_quality_signals = getattr(trigger_engine, "from_quality_signals", None)
if not callable(from_quality_signals):
logger.warning(
"Quality signal cutover path unavailable: trigger engine has no "
"from_quality_signals API"
)
return [], True
trigger_refs = signal_store.list_triggerable_since(since)
if not trigger_refs:
self._mark_quality_signal_cutover_checkpoint(
int(latest_watermark())
)
return [], False
manifest_watermark = int(latest_watermark())
jobs = list(
from_quality_signals(
trigger_refs,
manifest_watermark=manifest_watermark,
)
or []
)
self._mark_quality_signal_cutover_checkpoint(
int(latest_watermark())
)
return jobs, False
except Exception:
logger.debug("Quality signal cutover path failed", exc_info=True)
return [], True
finally:
close = getattr(signal_store, "close", None)
if callable(close):
try:
close()
except Exception:
logger.debug("Quality signal cutover store close failed", exc_info=True)
@staticmethod
def _quality_signal_reconciliation_failed(
evidence_store: Any,
reconciliation: Any,
) -> bool:
metric_ref_id = str(getattr(reconciliation, "metric_window_ref", "") or "")
if not metric_ref_id:
logger.warning(
"Quality signal cutover path unavailable: reconciliation did not "
"write a metric_window_ref"
)
return True
get_ref = getattr(evidence_store, "get_ref", None)
if not callable(get_ref):
return False
try:
metric_ref = get_ref(metric_ref_id)
except Exception:
logger.warning(
"Quality signal cutover path unavailable: failed to read "
"reconciliation metric_window_ref",
exc_info=True,
)
return True
status = str(getattr(metric_ref, "metadata", {}).get("status") or "")
if status == "failed":
logger.warning(
"Quality signal cutover path unavailable: reconciliation failed"
)
return True
return False
async def _drain_created_evolution_jobs(self, jobs: list[Any]) -> list[Any]:
job_ids = [str(getattr(job, "job_id", "") or "") for job in jobs]
job_ids = [job_id for job_id in dict.fromkeys(job_ids) if job_id]
if not job_ids:
return []
trigger_engine = self.state.trigger_engine
claim_jobs = getattr(trigger_engine, "claim_jobs", None)
if callable(claim_jobs):
return await self.drain_evolution_jobs(
job_ids=job_ids,
limit=len(job_ids),
)
trigger_types = tuple(
sorted(
{
str(getattr(job, "trigger_type", "") or "").upper()
for job in jobs
if str(getattr(job, "trigger_type", "") or "").strip()
}
)
)
return await self.drain_evolution_jobs(
trigger_types=trigger_types or None,
limit=len(job_ids),
)
async def maybe_drain_startup_retryable_evolution_jobs(self) -> list[Any]:
"""Optionally retry persisted failed_retryable jobs during startup."""
limit = int(
getattr(
self.config,
"evolution_startup_retryable_drain_limit",
0,
)
or 0
)
rounds = int(
getattr(
self.config,
"evolution_startup_retryable_drain_rounds",
1,
)
or 0
)
timeout_s = float(
getattr(
self.config,
"evolution_startup_retryable_drain_timeout_s",
0.0,
)
or 0.0
)
raw_statuses = getattr(
self.config,
"evolution_startup_retryable_drain_statuses",
"failed_retryable",
)
if isinstance(raw_statuses, str):
claim_statuses = tuple(
item.strip() for item in raw_statuses.split(",") if item.strip()
)
else:
claim_statuses = tuple(
str(item).strip() for item in raw_statuses if str(item).strip()
)
if not claim_statuses:
claim_statuses = ("failed_retryable",)
if limit <= 0 or rounds <= 0:
return []
if self.state.trigger_engine is None or self.state.evolution_engine is None:
return []
outcomes: list[Any] = []
for _ in range(rounds):
try:
drain_coro = self.drain_evolution_jobs(
claim_statuses=claim_statuses,
trigger_types=_GENERAL_EVOLUTION_TRIGGER_TYPES,
limit=limit,
)
if timeout_s > 0:
batch = await asyncio.wait_for(drain_coro, timeout=timeout_s)
else:
batch = await drain_coro
except asyncio.TimeoutError:
logger.warning(
"Startup retryable evolution drain timed out after %.2fs",
timeout_s,
)
break
except Exception:
logger.debug("Startup retryable evolution drain failed", exc_info=True)
break
if not batch:
break
outcomes.extend(batch)
if len(batch) < limit:
break
if outcomes:
logger.info(
"Startup retryable evolution drain processed %s job(s)",
len(outcomes),
)
return outcomes
async def maybe_drain_final_evolution_jobs(
self,
*,
task_id: str,
session_id: str | None = None,
) -> list[Any]:
"""Optionally retry open evolution jobs before short-lived runtimes exit."""
limit = int(getattr(self.config, "evolution_final_drain_limit", 0) or 0)
rounds = int(getattr(self.config, "evolution_final_drain_rounds", 1) or 0)
timeout_s = float(
getattr(self.config, "evolution_final_drain_timeout_s", 0.0) or 0.0
)
if limit <= 0 or rounds <= 0:
return []
if self.state.trigger_engine is None or self.state.evolution_engine is None:
return []
try:
from openspace.skill_engine.evidence import EvidenceScope
scope = EvidenceScope(
session_id=session_id or self.current_session_id,
task_id=task_id,
)
except Exception:
logger.debug("Final evolution drain scope unavailable", exc_info=True)
scope = None
outcomes: list[Any] = []
for _ in range(rounds):
try:
drain_coro = self.drain_evolution_jobs(
scope=scope,
trigger_types=_GENERAL_EVOLUTION_TRIGGER_TYPES,
limit=limit,
)
if timeout_s > 0:
batch = await asyncio.wait_for(drain_coro, timeout=timeout_s)
else:
batch = await drain_coro
except asyncio.TimeoutError:
logger.warning(
"Final evolution drain timed out after %.2fs", timeout_s
)
break
except Exception:
logger.debug("Final evolution drain failed", exc_info=True)
break
if not batch:
break
outcomes.extend(batch)
if len(batch) < limit:
break
if outcomes:
logger.info("Final evolution drain processed %s job(s)", len(outcomes))
return outcomes
def _load_quality_signal_cutover_checkpoint(self) -> int:
trigger_engine = self.state.trigger_engine
for owner in (
trigger_engine,
getattr(trigger_engine, "store", None),
):
loader = getattr(owner, "load_checkpoint", None)
if not callable(loader):
continue
try:
value = loader(_QUALITY_SIGNAL_CUTOVER_CHECKPOINT)
if value is not None:
return max(0, int(value))
except (TypeError, ValueError):
return 0
except Exception:
logger.debug("Quality signal cutover checkpoint lookup failed", exc_info=True)
return 0
return 0
def _mark_quality_signal_cutover_checkpoint(self, watermark: int) -> None:
value = max(0, int(watermark))
trigger_engine = self.state.trigger_engine
for owner in (
trigger_engine,
getattr(trigger_engine, "store", None),
):
saver = getattr(owner, "save_checkpoint", None)
if not callable(saver):
continue
try:
saver(_QUALITY_SIGNAL_CUTOVER_CHECKPOINT, value)
return
except Exception:
logger.debug("Quality signal cutover checkpoint save failed", exc_info=True)
return
def _quality_cutover_enabled(self) -> bool:
trigger_engine = self.state.trigger_engine
evolution_engine = self.state.evolution_engine
if trigger_engine is None or evolution_engine is None:
return False
mode = self._quality_evolution_mode()
if mode not in {"audit_only", "fix_only", "autonomous"}:
return False
if self._evolution_component(evolution_engine, "packet_builder", self.state.packet_builder) is None:
return False
if self._evolution_component(evolution_engine, "decision_engine", self.state.decision_engine) is None:
return False
if self._evolution_component(evolution_engine, "admission_policy", None) is None:
return False
if mode == "audit_only":
return True
for name in ("authoring_backend", "validator", "behavior_evaluator", "committer"):
if self._evolution_component(evolution_engine, name, None) is None:
return False
return True
@staticmethod
def _evolution_component(engine: Any, name: str, fallback: Any = None) -> Any:
value = getattr(engine, name, None)
return value if value is not None else fallback
def _quality_evolution_mode(self) -> str:
evolution_engine = self.state.evolution_engine
mode = str(
getattr(evolution_engine, "evolution_mode", None)
or getattr(self.config, "evolution_mode", "autonomous")
or "autonomous"
).strip().lower()
return mode if mode in {"audit_only", "fix_only", "autonomous"} else "autonomous"
async def ensure_scheduler(
self,
workspace_dir: str,
*,
task_manager: Any | None = None,
) -> Any:
from openspace.services.scheduler import (
create_scheduler_for_workspace,
get_default_schedule_path,
)
target_path = get_default_schedule_path(workspace_dir)
current_path = None
scheduler = self.scheduler
if scheduler is not None:
current_path = getattr(getattr(scheduler, "store", None), "path", None)
if scheduler is None or current_path != target_path:
if scheduler is not None:
await scheduler.stop()
scheduler = create_scheduler_for_workspace(
workspace_dir,
event_sink=self.emit_runtime_event,
task_manager=task_manager,
)
self.scheduler = scheduler
await scheduler.start()
elif task_manager is not None:
scheduler.task_manager = task_manager
return scheduler
def should_start_scheduler_for_execute(
self,
task: str,
context: dict[str, Any],
) -> bool:
del task
config = self.config
if getattr(config, "scheduler_execute_sync_start", False):
return True
if context.get("force_scheduler") or context.get("scheduler_intent"):
return True
if self.scheduler is not None:
return True
return context.get("scheduler") is not None
def workspace_has_enabled_schedules(self, workspace_dir: str) -> bool:
try:
from openspace.services.scheduler import has_enabled_schedules
return has_enabled_schedules(workspace_dir)
except Exception:
logger.debug("Failed to inspect scheduled tasks", exc_info=True)
return False
def append_evolved_skill(self, record: dict[str, Any]) -> None:
self.state.last_evolved_skills.append(record)
@staticmethod
def evolved_skill_record_from_evolution(rec: Any) -> dict[str, Any]:
return {
"skill_id": rec.skill_id,
"name": rec.name,
"description": rec.description,
"path": str(rec.path) if rec.path else "",
"origin": rec.lineage.origin.value,
"trust_state": rec.trust_state.value,
"enabled": bool(rec.enabled),
"trust_successes": rec.trust_successes,
"trust_failures": rec.trust_failures,
"generation": rec.lineage.generation,
"parent_local_skill_ids": rec.lineage.parent_skill_ids,
"change_summary": rec.lineage.change_summary,
}
def increment_execution_count(self) -> int:
self.state.execution_count += 1
return self.state.execution_count
def mark_idle(self) -> None:
self.state.running = False
self.state.task_done.set()
def mark_initialized(self) -> None:
self.state.initialized = True
def mark_uninitialized(self) -> None:
self.state.initialized = False
self.mark_idle()
def is_bridge_dispatch_suppressed(self) -> bool:
try:
return bool(self._bridge_dispatch_suppressed())
except Exception:
return False
async def emit(self, event_type: str, data: dict[str, Any]) -> None:
"""Emit an event through the runtime event bus."""
if event_type == "status_update" and "sandbox" not in data:
sandbox = self.get_sandbox_runtime_status()
if sandbox is not None:
data = {**data, "sandbox": sandbox}
await self.event_bus.emit(event_type, data)
async def emit_runtime_event(
self,
event_type: str,
data: dict[str, Any],
) -> None:
await self.event_bus.emit(event_type, data)
def register_event_sink(
self,
sink: Callable[[str, dict[str, Any]], Any],
) -> None:
self.state.event_sinks.append(sink)
def unregister_event_sink(
self,
sink: Callable[[str, dict[str, Any]], Any],
) -> None:
if sink in self.state.event_sinks:
self.state.event_sinks.remove(sink)
def iter_event_sinks(self) -> list[Callable[[str, dict[str, Any]], Any]]:
return list(self.state.event_sinks)
@property
def capture_skill_dir(self) -> str | None:
return self.state.capture_skill_dir
@property
def memory_cleanup_context(self) -> dict[str, Any] | None:
return self.state.memory_cleanup_context
@property
def is_initialized(self) -> bool:
return self.state.initialized
@property
def is_running(self) -> bool:
return self.state.running
async def execute(self, request: ExecutionRequest) -> ExecutionResult:
return await self.execution_lifecycle.execute(request)