Files
OpenOPC/opc/plugins/office_ui/services/session.py
T
LZH-YS1998 08e48c2f9c fix(logging): replace stdlib-style exc_info kwargs with loguru opt(exception=)
Loguru has no exc_info kwarg: extra kwargs are str.format() arguments, so
logger.error(f"...{e}", exc_info=True) forces .format() on the rendered
message — any error text containing braces (e.g. a JSON error body) raises
KeyError FROM the log call itself, escaping the surrounding except block and
killing the caller (observed: whole agent turns dying in benchmark runs).
The intended traceback was also never logged, since exc_info is not a loguru
feature.

Batch fix of all 143 sites across 11 files:
  logger.X(msg, exc_info=True) -> logger.opt(exception=True).X(msg)
  (one exc_info=exc site -> opt(exception=exc))
Messages are byte-identical; with the kwarg gone loguru never calls
.format(), so brace-containing f-string messages are inert.

Verified: AST post-conditions per file, py_compile, import smoke of all
modules, behavioral equivalence of the 3 patterns, full unit suite (1549
passed) with a failure set identical to the pristine tree (22 pre-existing,
zero regressions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Also add pixel-agents to the README acknowledgements.
2026-07-03 20:07:37 +08:00

1173 lines
56 KiB
Python

"""Session lifecycle service shared by Office UI and CLI."""
from __future__ import annotations
import asyncio
import shutil
import time
import uuid
from datetime import datetime
from pathlib import Path
from typing import Any
from loguru import logger
from opc.core.models import Task, TaskStatus
from opc.plugins.office_ui.execution_identity import (
ExecutionIdentity,
canonicalize_execution_identity,
execution_identity_from_task,
normalize_company_profile,
normalize_exec_mode,
normalize_org_id,
normalize_preferred_agent,
)
from opc.plugins.office_ui.snapshot_builder import build_collab_sync
from .context import OfficeServiceContext
from .models import ServiceEvent, ServiceError, ServiceResult
class SessionService:
def __init__(self, context: OfficeServiceContext) -> None:
self.context = context
@staticmethod
def normalize_exec_mode(value: Any) -> str:
return normalize_exec_mode(value)
def normalize_company_profile(self, value: Any) -> str:
if self.context.mode_state.exec_mode in {"org", "custom"}:
default = "custom"
else:
default = self.context.mode_state.company_profile if self.context.mode_state.company_profile == "corporate" else "corporate"
return normalize_company_profile(value, default=default)
@staticmethod
def normalize_preferred_agent(value: Any, default: str = "native") -> str:
return normalize_preferred_agent(value, default=default)
@staticmethod
def normalize_org_id(value: Any) -> str:
return normalize_org_id(value)
def resolve_task_identity(
self,
task: Any | None,
*,
default_exec_mode: Any = None,
default_company_profile: Any = None,
default_preferred_agent: Any = None,
default_org_id: Any = "",
) -> ExecutionIdentity:
return execution_identity_from_task(
task,
default_exec_mode=default_exec_mode if default_exec_mode is not None else self.context.mode_state.exec_mode,
default_company_profile=(
default_company_profile
if default_company_profile is not None
else self.context.mode_state.company_profile
),
default_preferred_agent=(
default_preferred_agent
if default_preferred_agent is not None
else self.context.mode_state.task_preferred_agent
),
default_org_id=default_org_id,
)
def resolve_task_session_config(self, task: Any | None) -> tuple[str, str]:
identity = self.resolve_task_identity(task)
return identity.exec_mode, identity.company_profile
def resolve_task_org_id(self, task: Any | None) -> str:
identity = self.resolve_task_identity(task)
return identity.org_id if identity.is_custom_org else ""
def resolve_task_preferred_agent(self, task: Any | None) -> str:
return self.resolve_task_identity(task).preferred_agent
def resolve_task_selected_execution_agent(self, task: Any | None) -> str:
metadata = dict(getattr(task, "metadata", {}) or {}) if task is not None else {}
selected = metadata.get("selected_execution_agent")
if selected not in (None, "", [], {}):
return self.normalize_preferred_agent(selected, default="native")
mode = str(metadata.get("mode", "") or "").strip().lower()
exec_mode = str(metadata.get("exec_mode", "") or "").strip().lower()
execution_mode = str(metadata.get("execution_mode", "") or "").strip().lower()
task_mode_contract = str(metadata.get("task_mode_contract", "") or "").strip()
is_task_mode = (
mode == "task"
or exec_mode in {"task", "project", "single"}
or execution_mode in {"task", "task_mode", "project"}
or task_mode_contract == "single_full_capability_main_agent"
)
if is_task_mode:
preferred = metadata.get("preferred_agent")
if preferred not in (None, "", [], {}):
return self.normalize_preferred_agent(preferred, default="native")
assigned = getattr(task, "assigned_external_agent", None) if task is not None else None
if assigned not in (None, "", [], {}):
return self.normalize_preferred_agent(assigned, default="native")
return "native"
@staticmethod
def _task_status_value(task: Any) -> str:
status = getattr(task, "status", "")
return status.value if hasattr(status, "value") else str(status or "")
@staticmethod
def _is_terminal_status(task: Any) -> bool:
return SessionService._task_status_value(task) in {"done", "failed", "cancelled"}
async def _coerce_hook_result(
self,
result: Any,
*,
default_payload: dict[str, Any],
default_event_type: str = "session_updated",
) -> ServiceResult:
if isinstance(result, ServiceResult):
return result
if isinstance(result, dict):
payload = {**default_payload, **result}
else:
payload = dict(default_payload)
return ServiceResult(payload, [ServiceEvent(default_event_type, payload)])
async def _resolve_task_target(
self,
*,
project_id: str,
target: str = "",
task_id: str = "",
session_id: str = "",
) -> tuple[Any, str]:
pid = self.context.normalize_project_id(project_id)
raw_target = str(target or task_id or session_id or "").strip()
if not raw_target:
raise ServiceError("missing_target", "Missing task_id or session_id")
engine = await self.context.engine_for_project(pid)
store = getattr(engine, "store", None)
if not self.context.store_is_ready(store):
raise ServiceError("store_not_ready", "store_not_ready", {"project_id": pid})
task = await store.get_task(raw_target) if hasattr(store, "get_task") else None
if task is not None:
task_project = self.context.normalize_project_id(getattr(task, "project_id", None))
if task_project != pid:
raise ServiceError("target_wrong_project", "Target belongs to a different project", {"project_id": task_project})
return task, str(getattr(task, "session_id", "") or getattr(task, "parent_session_id", "") or "")
session = await store.get_session(raw_target) if hasattr(store, "get_session") else None
if session is None:
raise ServiceError("target_not_found", "Task or session not found", {"target": raw_target})
session_project = self.context.normalize_project_id(getattr(session, "project_id", None))
if session_project != pid:
raise ServiceError("target_wrong_project", "Target belongs to a different project", {"project_id": session_project})
tasks = await store.get_tasks(project_id=pid) if hasattr(store, "get_tasks") else []
session_tasks = [
candidate for candidate in tasks
if str(getattr(candidate, "session_id", "") or "") == raw_target
]
if not session_tasks:
raise ServiceError("session_not_task_backed", "Session is not linked to a task-backed runtime", {"session_id": raw_target})
session_tasks.sort(key=lambda item: bool(str(getattr(item, "parent_session_id", "") or "")))
return session_tasks[0], raw_target
async def _resolve_company_runtime_target(self, engine: Any, task: Any) -> dict[str, Any]:
store = getattr(engine, "store", None)
parent_session_id = str(
getattr(task, "parent_session_id", "")
or getattr(task, "session_id", "")
or ""
).strip()
parent_task_id = str(self.context.session_to_task.get(parent_session_id) or "").strip()
project_id = self.context.normalize_project_id(getattr(task, "project_id", None) or getattr(engine, "project_id", None))
try:
project_tasks = await store.get_tasks(project_id=project_id) if hasattr(store, "get_tasks") else [task]
except Exception:
project_tasks = [task]
for candidate in project_tasks:
candidate_id = str(getattr(candidate, "id", "") or "").strip()
candidate_session_id = str(getattr(candidate, "session_id", "") or "").strip()
candidate_parent_session_id = str(getattr(candidate, "parent_session_id", "") or "").strip()
if candidate_session_id == parent_session_id and not candidate_parent_session_id:
parent_task_id = candidate_id
break
if not parent_task_id:
parent_task_id = str(
self.context.active_runtime_children.get(str(getattr(task, "id", "") or ""))
or getattr(task, "id", "")
or ""
).strip()
affected_task_ids: list[str] = []
for candidate in project_tasks:
candidate_id = str(getattr(candidate, "id", "") or "").strip()
if not candidate_id:
continue
candidate_session_id = str(getattr(candidate, "session_id", "") or "").strip()
candidate_parent_session_id = str(getattr(candidate, "parent_session_id", "") or "").strip()
if (
candidate_id == str(getattr(task, "id", "") or "")
or candidate_id == parent_task_id
or candidate_session_id == parent_session_id
or candidate_parent_session_id == parent_session_id
):
if candidate_id not in affected_task_ids:
affected_task_ids.append(candidate_id)
for child_id, origin_id in list(self.context.active_runtime_children.items()):
if origin_id == parent_task_id or child_id == str(getattr(task, "id", "") or ""):
if child_id not in affected_task_ids:
affected_task_ids.append(child_id)
if parent_task_id and parent_task_id not in affected_task_ids:
affected_task_ids.insert(0, parent_task_id)
return {
"parent_session_id": parent_session_id,
"parent_task_id": parent_task_id or str(getattr(task, "id", "") or ""),
"origin_task_id": parent_task_id or str(getattr(task, "id", "") or ""),
"affected_task_ids": affected_task_ids or [str(getattr(task, "id", "") or "")],
}
async def _mark_company_runtime_stop_state(
self,
*,
engine: Any,
task_ids: list[str],
state: str,
stop_intent_id: str,
checkpoint_type: str = "",
) -> None:
store = getattr(engine, "store", None)
if not self.context.store_is_ready(store):
return
for task_id in task_ids:
try:
task = await store.get_task(str(task_id))
except Exception:
task = None
if not task or self._is_terminal_status(task):
continue
metadata = dict(getattr(task, "metadata", {}) or {})
metadata["company_runtime_stop_state"] = state
metadata["company_runtime_stop_intent_id"] = stop_intent_id
metadata["company_runtime_stop_marked_at"] = datetime.now().isoformat()
metadata["dispatch_hold"] = "company_runtime_suspended"
metadata["company_runtime_suspended_at"] = datetime.now().isoformat()
if checkpoint_type:
metadata["company_runtime_suspend_checkpoint_type"] = checkpoint_type
metadata.setdefault("suspended_task_status", self._task_status_value(task))
task.metadata = metadata
task.status = TaskStatus.BLOCKED
if hasattr(task, "execution_lock"):
task.execution_lock = False
if hasattr(task, "execution_locked_at"):
task.execution_locked_at = None
try:
await store.save_task(task)
except Exception:
logger.opt(exception=True).debug("failed to mark company runtime stop state")
async def _clear_company_runtime_stop_state(self, *, engine: Any, task_ids: list[str]) -> None:
store = getattr(engine, "store", None)
if not self.context.store_is_ready(store):
return
for task_id in task_ids:
try:
task = await store.get_task(str(task_id))
except Exception:
task = None
if not task:
continue
metadata = dict(getattr(task, "metadata", {}) or {})
for key in (
"dispatch_hold",
"company_runtime_stop_state",
"company_runtime_stop_intent_id",
"company_runtime_stop_marked_at",
"company_runtime_suspend_checkpoint_type",
"company_runtime_suspended_at",
"suspended_task_status",
):
metadata.pop(key, None)
task.metadata = metadata
if self._task_status_value(task) == "blocked":
task.status = TaskStatus.IDLE
try:
await store.save_task(task)
except Exception:
logger.opt(exception=True).debug("failed to clear company runtime stop state")
def _normalize_requested_config(
self,
*,
exec_mode: Any,
company_profile: Any = None,
preferred_agent: Any = None,
org_id: Any = None,
current_company_profile: str = "corporate",
current_preferred_agent: str = "native",
) -> tuple[str, str, str, str]:
default_profile = current_company_profile if current_company_profile != "custom" else "corporate"
identity = canonicalize_execution_identity(
exec_mode=exec_mode,
company_profile=company_profile if company_profile not in (None, "", [], {}) else default_profile,
preferred_agent=preferred_agent,
org_id=org_id,
default_company_profile=default_profile,
default_preferred_agent=current_preferred_agent,
explicit_exec_mode=exec_mode is not None,
)
return identity.exec_mode, identity.company_profile, identity.preferred_agent, identity.org_id
async def _ensure_org_loaded(self, org_id: str) -> None:
if not org_id:
return
loader = self.context.load_active_org_config
if callable(loader) and not loader(org_id):
raise ServiceError("org_not_found", "org_not_found", {"org_id": org_id})
setter = self.context.set_active_saved_org_name
if callable(setter):
await setter(org_id)
async def create(
self,
*,
project_id: str,
title: str = "New Chat",
exec_mode: Any = None,
company_profile: Any = None,
preferred_agent: Any = None,
org_id: Any = None,
task_id: str | None = None,
description: str = "",
interface: str = "office_ui",
emit_board_event: bool = True,
assignee_ids: list[str] | None = None,
board_id: str | None = None,
) -> ServiceResult:
pid = self.context.normalize_project_id(project_id)
engine = await self.context.engine_for_project(pid)
current_agent = self.context.mode_state.task_preferred_agent
requested_exec = exec_mode if exec_mode is not None else self.context.mode_state.exec_mode
requested_profile = company_profile if company_profile is not None else self.context.mode_state.company_profile
normalized_exec, normalized_profile, normalized_agent, normalized_org = self._normalize_requested_config(
exec_mode=requested_exec,
company_profile=requested_profile,
preferred_agent=preferred_agent if preferred_agent is not None else current_agent,
org_id=org_id,
current_company_profile=self.context.mode_state.company_profile,
current_preferred_agent=current_agent,
)
if normalized_exec == "org":
if not normalized_org and self.context.get_active_saved_org_name is not None:
normalized_org = await self.context.get_active_saved_org_name()
if not normalized_org:
raise ServiceError("org_id_required", "org_id_required", {"project_id": pid})
await self._ensure_org_loaded(normalized_org)
else:
normalized_org = ""
tid = str(task_id or uuid.uuid4())
session_id = str(uuid.uuid4())
channel_id = f"session:{tid}"
self.context.session_to_task[session_id] = tid
normalized_title = str(title or "New Chat").strip() or "New Chat"
metadata = self._session_metadata(
task_id=tid,
exec_mode=normalized_exec,
company_profile=normalized_profile,
preferred_agent=normalized_agent,
org_id=normalized_org,
interface=interface,
)
if getattr(engine, "memory", None):
await engine.memory.ensure_session(
session_id=session_id,
project_id=pid,
title=normalized_title,
mode="primary",
metadata=metadata,
)
ch = await self.context.chat_store.create_session_channel(tid, normalized_title, project_id=pid)
if getattr(engine, "store", None):
await engine.store.save_task(Task(
id=tid,
title=normalized_title,
description=str(description or ""),
project_id=pid,
session_id=session_id,
metadata={key: value for key, value in metadata.items() if key != "interface"},
org_id=normalized_org or None,
))
events: list[ServiceEvent] = []
self.context.event_adapter._task_display_counter += 1
self.context.event_adapter._task_display_map[tid] = self.context.event_adapter._task_display_counter
display_id = f"OPC-{self.context.event_adapter.task_display_counter}"
if emit_board_event and normalized_exec == "task":
events.append(ServiceEvent("board_task_created", {
"project_id": pid,
"task_id": tid,
"display_id": display_id,
"board_id": board_id or pid,
"title": normalized_title,
"assignee_ids": assignee_ids or [],
}))
created_at = ch.get("created_at", time.time()) if isinstance(ch, dict) else time.time()
session_payload = {
"project_id": pid,
"task_id": tid,
"channel_id": channel_id,
"session_id": session_id,
"origin_task_id": tid,
"exec_mode": normalized_exec,
"company_profile": normalized_profile,
"org_id": normalized_org,
"preferred_agent": normalized_agent,
"selected_execution_agent": normalized_agent,
"title": normalized_title,
"status": "pending",
"created_at": created_at,
}
events.append(ServiceEvent("session_created", session_payload))
if normalized_exec in {"company", "org", "custom"}:
try:
collab = await build_collab_sync(
engine,
self.context.agent_store,
self.context.chat_store,
self.context.event_adapter,
exec_mode=normalized_exec,
)
events.append(ServiceEvent("collab_sync_push", collab))
except Exception:
logger.opt(exception=True).warning("create_session collab_sync build failed")
return ServiceResult(session_payload, events)
def _session_metadata(
self,
*,
task_id: str,
exec_mode: str,
company_profile: str,
preferred_agent: str,
org_id: str = "",
interface: str = "office_ui",
) -> dict[str, Any]:
identity = canonicalize_execution_identity(
exec_mode=exec_mode,
company_profile=company_profile,
preferred_agent=preferred_agent,
org_id=org_id,
default_preferred_agent=self.context.mode_state.task_preferred_agent,
explicit_exec_mode=True,
)
metadata: dict[str, Any] = {
"exec_mode": identity.exec_mode,
"company_profile": identity.company_profile,
"preferred_agent": identity.preferred_agent,
"interface": interface,
}
if identity.is_custom_org and identity.org_id:
metadata.update({"org_id": identity.org_id, "organization_id": identity.org_id})
if identity.is_task:
metadata.update({
"mode": "task",
"execution_mode": "task_mode",
"origin_task_id": task_id,
"task_mode_contract": "single_full_capability_main_agent",
"selected_execution_agent": identity.preferred_agent,
"force_native_execution": identity.preferred_agent == "native",
})
else:
metadata.update({
"mode": identity.exec_mode,
"execution_mode": "company_mode",
"origin_task_id": "",
"task_mode_contract": "",
"selected_execution_agent": "",
"force_native_execution": False,
"preferred_external_agent": "",
"agent_selection": {},
})
return metadata
async def list(self, *, project_id: str, limit: int = 50) -> ServiceResult:
engine = await self.context.engine_for_project(project_id)
if not getattr(engine, "store", None):
raise ServiceError("store_not_ready", "store_not_ready", {"project_id": project_id})
sessions = await engine.store.list_sessions(project_id=self.context.normalize_project_id(project_id), parent_session_id=None, limit=limit)
return ServiceResult({"project_id": self.context.normalize_project_id(project_id), "sessions": [self._record_to_dict(item) for item in sessions]})
async def detail(self, *, project_id: str, task_id: str = "", session_id: str = "", limit: int = 200) -> ServiceResult:
engine = await self.context.engine_for_project(project_id)
store = getattr(engine, "store", None)
if not self.context.store_is_ready(store):
raise ServiceError("store_not_ready", "store_not_ready", {"project_id": project_id})
task = await store.get_task(task_id) if task_id else None
if not task and session_id and hasattr(store, "get_session"):
session = await store.get_session(session_id)
elif task and getattr(task, "session_id", None):
session_id = str(task.session_id)
session = await store.get_session(session_id) if hasattr(store, "get_session") else None
else:
session = None
if not task and not session:
raise ServiceError("session_not_found", "session_not_found", {"task_id": task_id, "session_id": session_id})
transcript = await store.get_session_transcript(session_id) if session_id and hasattr(store, "get_session_transcript") else []
return ServiceResult({
"project_id": self.context.normalize_project_id(project_id),
"task": self._task_to_dict(task) if task else None,
"session": self._record_to_dict(session) if session else None,
"messages": transcript[-max(1, min(limit, 500)):],
})
async def update_config(
self,
*,
project_id: str,
task_id: str,
exec_mode: Any = None,
company_profile: Any = None,
preferred_agent: Any = None,
org_id: Any = None,
) -> ServiceResult:
engine = await self.context.engine_for_project(project_id)
store = getattr(engine, "store", None)
if not self.context.store_is_ready(store):
raise ServiceError("store_not_ready", "store_not_ready")
task = await store.get_task(str(task_id or ""))
if not task:
raise ServiceError("task_not_found", "task_not_found", {"project_id": project_id, "task_id": task_id})
current_exec, current_profile = self.resolve_task_session_config(task)
current_agent = self.resolve_task_preferred_agent(task)
lock_reason = await self.session_config_lock_reason(task, self.context.normalize_project_id(project_id))
if lock_reason:
raise ServiceError("session_config_locked", "session_config_locked", {
"reason": lock_reason,
"project_id": project_id,
"task_id": task_id,
"exec_mode": current_exec,
"company_profile": current_profile,
"org_id": self.resolve_task_org_id(task),
"preferred_agent": current_agent,
"selected_execution_agent": self.resolve_task_selected_execution_agent(task),
})
normalized_exec, normalized_profile, normalized_agent, normalized_org = self._normalize_requested_config(
exec_mode=exec_mode if exec_mode is not None else current_exec,
company_profile=company_profile,
preferred_agent=preferred_agent if preferred_agent is not None else current_agent,
org_id=org_id if org_id is not None else self.resolve_task_org_id(task),
current_company_profile=current_profile,
current_preferred_agent=current_agent,
)
if normalized_exec == "org" and normalized_org:
await self._ensure_org_loaded(normalized_org)
elif normalized_exec == "org":
if self.context.get_active_saved_org_name is not None:
normalized_org = await self.context.get_active_saved_org_name()
if not normalized_org:
raise ServiceError("org_id_required", "org_id_required", {"project_id": project_id, "task_id": task_id})
await self._ensure_org_loaded(normalized_org)
await self.persist_session_config(
task,
exec_mode=normalized_exec,
company_profile=normalized_profile,
preferred_agent=normalized_agent,
org_id=normalized_org,
engine=engine,
)
payload = {
"project_id": self.context.normalize_project_id(project_id),
"task_id": task_id,
"exec_mode": normalized_exec,
"company_profile": normalized_profile,
"org_id": normalized_org,
"preferred_agent": normalized_agent,
"selected_execution_agent": self.resolve_task_selected_execution_agent(task),
}
return ServiceResult(payload, [ServiceEvent("session_updated", payload)])
async def session_config_lock_reason(self, task: Any, project_id: str) -> str:
task_id = str(getattr(task, "id", "") or "").strip()
if task_id and self.context.chat_store:
channel_id = f"session:{task_id}"
try:
count_fn = getattr(self.context.chat_store, "get_channel_visible_message_count", None)
if callable(count_fn):
message_count = await count_fn(channel_id, project_id=project_id)
else:
count_fn = getattr(self.context.chat_store, "get_channel_message_count", None)
message_count = await count_fn(channel_id, project_id=project_id) if callable(count_fn) else 0
if int(message_count or 0) > 0:
return "message_history"
except Exception:
logger.opt(exception=True).debug("Failed to inspect session message count for config lock")
status = getattr(getattr(task, "status", None), "value", getattr(task, "status", None))
status_value = str(status or "").strip().lower()
if status_value and status_value != "pending":
return f"status:{status_value}"
if getattr(task, "execution_lock", False) or getattr(task, "execution_locked_at", None):
return "execution_lock"
if str(getattr(task, "parent_session_id", "") or "").strip():
return "child_session"
if getattr(task, "result", None) is not None:
return "result"
if str(getattr(task, "linked_work_item_id", "") or "").strip():
return "linked_work_item"
metadata = dict(getattr(task, "metadata", {}) or {})
for key in {
"runtime_session_id",
"external_session_id",
"delegation_run_id",
"company_run_id",
"company_runtime_run_id",
"company_runtime_started_at",
"company_runtime_suspended_at",
"work_item_id",
"work_item_projection_id",
"work_item_role_id",
"work_item_turn_type",
"active_work_item_id",
"current_work_item_id",
"runtime_context",
}:
value = metadata.get(key)
if value not in (None, "", [], {}):
return f"metadata:{key}"
return ""
async def persist_session_config(
self,
task: Any,
*,
exec_mode: str,
company_profile: str,
preferred_agent: str,
org_id: str = "",
engine: Any | None = None,
) -> None:
runtime_engine = engine or self.context.engine
metadata = dict(getattr(task, "metadata", {}) or {})
identity = canonicalize_execution_identity(
exec_mode=exec_mode,
company_profile=company_profile,
preferred_agent=preferred_agent,
org_id=org_id,
default_preferred_agent=self.context.mode_state.task_preferred_agent,
explicit_exec_mode=True,
)
if identity.is_custom_org and not identity.org_id:
raise ServiceError("org_id_required", "org_id_required", {
"task_id": str(getattr(task, "id", "") or ""),
})
metadata["exec_mode"] = identity.exec_mode
metadata["company_profile"] = identity.company_profile
metadata["preferred_agent"] = identity.preferred_agent
normalized_org_id = identity.org_id
if identity.is_custom_org and normalized_org_id:
metadata["org_id"] = normalized_org_id
metadata["organization_id"] = normalized_org_id
else:
metadata.pop("org_id", None)
metadata.pop("organization_id", None)
if identity.is_task:
task_id = str(getattr(task, "id", "") or "").strip()
metadata.setdefault("mode", "task")
metadata["execution_mode"] = "task_mode"
metadata.setdefault("origin_task_id", task_id)
metadata.setdefault("task_mode_contract", "single_full_capability_main_agent")
metadata["selected_execution_agent"] = identity.preferred_agent
metadata["force_native_execution"] = identity.preferred_agent == "native"
metadata["preferred_external_agent"] = None if identity.preferred_agent == "native" else identity.preferred_agent
metadata["agent_selection"] = {
**dict(metadata.get("agent_selection", {}) or {}),
"selected": identity.preferred_agent,
"strategy": "native" if identity.preferred_agent == "native" else "external",
"decision_reason": "task_mode_session_preference",
"selection_source": "session_config",
}
else:
for key in ("mode", "execution_mode", "task_mode_contract", "force_native_execution", "preferred_external_agent", "agent_selection"):
metadata.pop(key, None)
task.metadata = metadata
task.org_id = normalized_org_id or None
if getattr(runtime_engine, "store", None):
await runtime_engine.store.save_task(task)
session_id = str(getattr(task, "session_id", "") or "").strip()
if getattr(runtime_engine, "memory", None) and session_id:
session_metadata = self._session_metadata(
task_id=str(getattr(task, "id", "") or ""),
exec_mode=identity.exec_mode,
company_profile=identity.company_profile,
preferred_agent=identity.preferred_agent,
org_id=normalized_org_id,
)
if not identity.is_custom_org:
session_metadata["org_id"] = ""
session_metadata["organization_id"] = ""
await runtime_engine.memory.ensure_session(
session_id=session_id,
project_id=getattr(task, "project_id", None) or runtime_engine.project_id or "default",
title=getattr(task, "title", "") or "",
mode="primary" if not getattr(task, "parent_session_id", None) else "child",
parent_session_id=getattr(task, "parent_session_id", None),
metadata=session_metadata,
)
async def send(
self,
*,
project_id: str,
task_id: str,
content: str,
mode: str = "task",
company_profile: str | None = None,
preferred_agent: str | None = None,
org_id: str | None = None,
domains: list[str] | None = None,
) -> ServiceResult:
engine = await self.context.engine_for_project(project_id)
store = getattr(engine, "store", None)
if not self.context.store_is_ready(store):
raise ServiceError("store_not_ready", "store_not_ready", {"project_id": project_id})
task = await store.get_task(task_id)
if not task:
raise ServiceError("task_not_found", "task_not_found", {"project_id": project_id, "task_id": task_id})
if not getattr(task, "session_id", None):
task.session_id = str(uuid.uuid4())
if getattr(engine, "memory", None):
await engine.memory.ensure_session(task.session_id, project_id=project_id, title=task.title, mode="primary", metadata={"source": "service"})
await store.save_task(task)
identity = self.resolve_task_identity(
task,
default_exec_mode=mode,
default_company_profile=company_profile if company_profile is not None else "corporate",
default_preferred_agent=preferred_agent if preferred_agent is not None else "native",
default_org_id=org_id or "",
)
if identity.is_custom_org and not identity.org_id:
raise ServiceError("org_id_required", "org_id_required", {"project_id": project_id, "task_id": task.id})
await self.persist_session_config(
task,
exec_mode=identity.exec_mode,
company_profile=identity.company_profile,
preferred_agent=identity.preferred_agent,
org_id=identity.org_id,
engine=engine,
)
response = await engine.process_message(
str(content or "").strip(),
project_id=project_id,
session_id=task.session_id,
mode=identity.exec_mode,
org_id=identity.org_id or None,
company_profile=identity.company_profile if identity.is_company_runtime else None,
preferred_agent=identity.preferred_agent if identity.is_task else None,
domains=list(domains or []),
origin_task_id=task.id,
)
return ServiceResult({"project_id": project_id, "task_id": task.id, "session_id": task.session_id, "response": response})
async def rename(self, *, project_id: str, task_id: str = "", session_id: str = "", title: str) -> ServiceResult:
pid = self.context.normalize_project_id(project_id)
target = str(task_id or session_id or "").strip()
new_title = str(title or "").strip()
if not target:
raise ServiceError("missing_session", "Missing task_id or session_id")
if not new_title:
raise ServiceError("missing_title", "Missing title")
engine = await self.context.engine_for_project(pid)
store = getattr(engine, "store", None)
if not store:
raise ServiceError("store_not_ready", "store_not_ready", {"project_id": pid})
task = await store.get_task(target) if hasattr(store, "get_task") else None
task_project_id = str(getattr(task, "project_id", "") or "").strip() if task is not None else ""
if (
task is not None
and task_project_id not in {"", "default"}
and self.context.normalize_project_id(task_project_id) != pid
):
task = None
session = None
resolved_session_id = ""
if task is not None:
resolved_session_id = str(getattr(task, "session_id", "") or "")
if resolved_session_id and hasattr(store, "get_session"):
session = await store.get_session(resolved_session_id)
elif hasattr(store, "get_session"):
session = await store.get_session(target)
session_project_id = str(getattr(session, "project_id", "") or "").strip() if session is not None else ""
if (
session is not None
and session_project_id not in {"", "default"}
and self.context.normalize_project_id(session_project_id) != pid
):
session = None
if session is not None:
resolved_session_id = str(getattr(session, "session_id", "") or target)
if hasattr(store, "get_tasks"):
tasks = await store.get_tasks(project_id=pid)
task = next((item for item in tasks if str(getattr(item, "session_id", "") or "") == resolved_session_id), None)
if task is None and session is None:
raise ServiceError("session_not_found", "session_not_found", {"target": target})
resolved_task_id = str(getattr(task, "id", "") or "") if task is not None else ""
if task is not None:
task.title = new_title
await store.save_task(task)
resolved_session_id = str(getattr(task, "session_id", "") or resolved_session_id)
if resolved_session_id:
if getattr(engine, "memory", None):
update_title = getattr(engine.memory, "update_session_title", None)
if callable(update_title):
await update_title(resolved_session_id, new_title)
elif session is not None and hasattr(store, "save_session"):
session.title = new_title
await store.save_session(session)
if resolved_task_id:
await self.context.chat_store.update_channel_name(f"session:{resolved_task_id}", new_title, project_id=pid)
payload = {
"project_id": pid,
"task_id": resolved_task_id,
"session_id": resolved_session_id,
"title": new_title,
}
return ServiceResult(payload, [ServiceEvent("session_title_updated", payload)])
async def delete(self, *, project_id: str, task_id: str) -> ServiceResult:
engine = await self.context.engine_for_project(project_id)
task = await engine.store.get_task(task_id) if getattr(engine, "store", None) else None
if not task:
# Older UI/session rows can outlive their runtime task rows after
# project migrations or startup reconciliation. Delete should stay
# idempotent for those orphaned sidebar chats instead of surfacing a
# task_not_found error to the frontend.
tid = str(task_id or "").strip()
if not tid:
raise ServiceError("task_not_found", "task_not_found", {"task_id": task_id})
await self.context.chat_store.delete_channel(f"session:{tid}", project_id=project_id)
delete_progress = getattr(self.context.chat_store, "delete_progress", None)
if callable(delete_progress):
await delete_progress(tid, project_id=project_id)
await self.context.chat_store.delete_activity_messages_for_task(project_id, tid)
payload = {"project_id": project_id, "task_id": tid, "task_ids": [tid], "orphaned": True}
return ServiceResult(payload, [ServiceEvent("session_deleted", {"project_id": project_id, "task_id": tid})])
session_ids_to_runtime_clean: set[str] = {str(getattr(task, "session_id", "") or "").strip()}
comms_dirs_to_remove: set[Path] = set()
tasks_to_scan = [task]
parent_sid = str(getattr(task, "session_id", "") or task_id)
try:
siblings = await engine.store.get_tasks(project_id=project_id)
for sibling in siblings:
if getattr(sibling, "id", "") != task_id and getattr(sibling, "parent_session_id", None) == parent_sid:
tasks_to_scan.append(sibling)
if getattr(sibling, "session_id", None):
session_ids_to_runtime_clean.add(str(sibling.session_id))
except Exception:
pass
for candidate in tasks_to_scan:
comms_dir = self._resolve_task_comms_dir(candidate, engine=engine)
if comms_dir is not None:
comms_dirs_to_remove.add(comms_dir)
if self.context.cancel_task_tree is not None:
all_task_ids = await self.context.cancel_task_tree(task_id, hard=True, store=engine.store)
else:
all_task_ids = [str(getattr(candidate, "id", "") or "") for candidate in tasks_to_scan if str(getattr(candidate, "id", "") or "")]
hard_delete = getattr(engine.store, "hard_delete_task", None)
if callable(hard_delete):
for candidate in reversed(tasks_to_scan):
await hard_delete(getattr(candidate, "id", ""), getattr(candidate, "session_id", None))
cleanup = getattr(engine.store, "delete_company_runtime_artifacts_for_session", None)
if callable(cleanup):
for session_id in session_ids_to_runtime_clean:
if session_id:
try:
await cleanup(session_id)
except Exception:
pass
for tid in all_task_ids:
try:
await self.context.chat_store.delete_channel(f"session:{tid}", project_id=project_id)
except Exception:
pass
try:
await self.context.chat_store.delete_activity_messages_for_task(project_id, tid)
except Exception:
pass
for path in comms_dirs_to_remove:
try:
if path.is_dir():
shutil.rmtree(str(path), ignore_errors=True)
except Exception:
pass
events = [
ServiceEvent("session_deleted", {"project_id": project_id, "task_id": tid})
for tid in all_task_ids
]
return ServiceResult({"project_id": project_id, "task_id": task_id, "task_ids": all_task_ids}, events)
async def stop(self, *, project_id: str, task_id: str = "", session_id: str = "", target: str = "") -> ServiceResult:
from opc.layer2_organization.work_item_transition import apply_task_status_transition
task, resolved_session_id = await self._resolve_task_target(
project_id=project_id,
target=target,
task_id=task_id,
session_id=session_id,
)
resolved_task_id = str(getattr(task, "id", "") or "")
default_payload = {
"project_id": self.context.normalize_project_id(project_id),
"task_id": resolved_task_id,
"session_id": resolved_session_id,
}
if self.context.runtime_stop_hook is not None:
return await self._coerce_hook_result(
await self.context.runtime_stop_hook(project_id=project_id, task_id=resolved_task_id, session_id=resolved_session_id),
default_payload=default_payload,
)
engine = await self.context.engine_for_project(project_id)
exec_mode, _company_profile = self.resolve_task_session_config(task)
if exec_mode in {"company", "org", "custom"}:
target_info = await self._resolve_company_runtime_target(engine, task)
stop_intent_id = str(uuid.uuid4())
affected_task_ids = list(target_info.get("affected_task_ids", []) or [resolved_task_id])
suspended: dict[str, Any] | None = None
suspend = getattr(engine, "suspend_company_runtime", None)
await self._mark_company_runtime_stop_state(
engine=engine,
task_ids=affected_task_ids,
state="suspending",
stop_intent_id=stop_intent_id,
)
if callable(suspend):
try:
suspended = await suspend(
origin_task_id=str(target_info.get("origin_task_id", "") or resolved_task_id),
session_id=(str(target_info.get("parent_session_id", "") or resolved_session_id).strip() or None),
reason="user_stop",
checkpoint_type="company_runtime_suspended",
stop_intent_id=stop_intent_id,
)
except Exception:
logger.opt(exception=True).warning("suspend_company_runtime failed during service stop")
if suspended is not None:
for candidate in list(suspended.get("task_ids", []) or []):
candidate_id = str(candidate or "").strip()
if candidate_id and candidate_id not in affected_task_ids:
affected_task_ids.append(candidate_id)
await self._mark_company_runtime_stop_state(
engine=engine,
task_ids=affected_task_ids,
state="suspended",
stop_intent_id=stop_intent_id,
checkpoint_type=str(suspended.get("checkpoint_type", "") or "company_runtime_suspended"),
)
else:
await self._mark_company_runtime_stop_state(
engine=engine,
task_ids=affected_task_ids,
state="suspended",
stop_intent_id=stop_intent_id,
checkpoint_type="company_runtime_suspended",
)
self.context.stop_requested_task_ids.update(affected_task_ids)
if self.context.cancel_session_tasks is not None:
for tid in affected_task_ids:
self.context.cancel_session_tasks(tid)
if self.context.chat_store is not None:
try:
await self.context.chat_store.insert_message(
channel_id=f"session:{str(target_info.get('origin_task_id', '') or resolved_task_id)}",
sender="system",
sender_name="System",
content="Company runtime suspended by user",
project_id=self.context.normalize_project_id(project_id),
metadata={
"type": "system",
"stop_reason": "user_stop",
"checkpoint_type": "company_runtime_suspended",
"checkpoint_id": str((suspended or {}).get("checkpoint_id", "") or ""),
"stop_intent_id": stop_intent_id,
},
)
except Exception:
logger.opt(exception=True).debug("failed to insert company runtime stop system message")
payload = {
**default_payload,
"status": "suspended",
"runtime_control_state": "suspended",
"can_resume": True,
"stop_intent_id": stop_intent_id,
"checkpoint_id": str((suspended or {}).get("checkpoint_id", "") or ""),
"task_ids": affected_task_ids,
"resume_parent_task_id": str(target_info.get("parent_task_id", "") or resolved_task_id),
"resume_parent_session_id": str(target_info.get("parent_session_id", "") or resolved_session_id),
}
return ServiceResult(payload, [ServiceEvent("session_runtime_control", payload), ServiceEvent("session_updated", payload)])
await apply_task_status_transition(engine.store, task, target_status_or_phase=TaskStatus.CANCELLED, reason="service_session_stop")
if self.context.cancel_session_tasks is not None:
self.context.cancel_session_tasks(resolved_task_id)
payload = {**default_payload, "status": "cancelled", "runtime_control_state": "stopped", "can_resume": False}
return ServiceResult(payload, [ServiceEvent("board_task_status_changed", payload), ServiceEvent("session_updated", payload)])
async def complete(self, *, project_id: str, task_id: str) -> ServiceResult:
from opc.layer2_organization.work_item_transition import apply_task_status_transition
engine = await self.context.engine_for_project(project_id)
task = await engine.store.get_task(task_id) if getattr(engine, "store", None) else None
if not task:
raise ServiceError("task_not_found", "task_not_found", {"task_id": task_id})
if self.context.cancel_session_tasks is not None:
self.context.cancel_session_tasks(task_id)
await apply_task_status_transition(engine.store, task, target_status_or_phase=TaskStatus.DONE, reason="service_session_complete")
payload = {"project_id": project_id, "task_id": task_id, "status": "done"}
return ServiceResult(payload, [ServiceEvent("board_task_status_changed", payload), ServiceEvent("session_updated", payload)])
async def continue_run(
self,
*,
project_id: str,
task_id: str = "",
session_id: str = "",
target: str = "",
content: str = "",
) -> ServiceResult:
task, resolved_session_id = await self._resolve_task_target(
project_id=project_id,
target=target,
task_id=task_id,
session_id=session_id,
)
resolved_task_id = str(getattr(task, "id", "") or "")
default_payload = {
"project_id": self.context.normalize_project_id(project_id),
"task_id": resolved_task_id,
"session_id": resolved_session_id,
"runtime_control_state": "resuming",
}
if self.context.runtime_continue_hook is not None:
return await self._coerce_hook_result(
await self.context.runtime_continue_hook(
project_id=project_id,
task_id=resolved_task_id,
session_id=resolved_session_id,
content=content,
),
default_payload=default_payload,
)
engine = await self.context.engine_for_project(project_id)
exec_mode, company_profile = self.resolve_task_session_config(task)
target_info = await self._resolve_company_runtime_target(engine, task) if exec_mode in {"company", "org", "custom"} else {
"affected_task_ids": [resolved_task_id],
"parent_task_id": resolved_task_id,
"parent_session_id": resolved_session_id,
}
affected_task_ids = list(target_info.get("affected_task_ids", []) or [resolved_task_id])
await self._clear_company_runtime_stop_state(engine=engine, task_ids=affected_task_ids)
message = str(content or "").strip() or "Resume the existing runtime."
engine_mode = "company" if exec_mode == "company" else ("org" if exec_mode in {"org", "custom"} else "task")
org_id = self.resolve_task_org_id(task) if engine_mode == "org" else ""
response = await engine.process_message(
message,
project_id=self.context.normalize_project_id(project_id),
session_id=str(target_info.get("parent_session_id", "") or resolved_session_id),
mode=engine_mode,
org_id=org_id or None,
company_profile=company_profile if engine_mode == "company" else None,
preferred_agent=self.resolve_task_preferred_agent(task) if engine_mode == "task" else None,
origin_task_id=str(target_info.get("parent_task_id", "") or resolved_task_id),
message_metadata={"ui_force_resume": True},
)
payload = {
**default_payload,
"status": "resuming",
"runtime_control_state": "resuming",
"can_resume": False,
"response": response,
"task_ids": affected_task_ids,
"resume_parent_task_id": str(target_info.get("parent_task_id", "") or resolved_task_id),
"resume_parent_session_id": str(target_info.get("parent_session_id", "") or resolved_session_id),
}
return ServiceResult(payload, [ServiceEvent("session_runtime_control", payload), ServiceEvent("session_updated", payload)])
async def resume(self, *, project_id: str, task_id: str = "", session_id: str = "", target: str = "", content: str = "") -> ServiceResult:
return await self.continue_run(
project_id=project_id,
task_id=task_id,
session_id=session_id,
target=target,
content=content,
)
async def resolve_starting_session(self, *, project_id: str) -> ServiceResult:
engine = await self.context.engine_for_project(project_id)
store = getattr(engine, "store", None)
if not self.context.store_is_ready(store):
return await self.create(project_id=project_id, title="CLI Session", interface="cli")
sessions = await store.list_sessions(project_id=self.context.normalize_project_id(project_id), parent_session_id=None, limit=1)
if sessions:
session = sessions[0]
return ServiceResult({"session_id": session.session_id, "restored": True, "title": session.title, "project_id": project_id})
created = await self.create(project_id=project_id, title="CLI Session", interface="cli")
created.payload["restored"] = False
return created
@staticmethod
def _record_to_dict(record: Any) -> dict[str, Any]:
if record is None:
return {}
data = dict(getattr(record, "__dict__", {}) or {})
for key, value in list(data.items()):
if hasattr(value, "isoformat"):
data[key] = value.isoformat()
return data
@staticmethod
def _task_to_dict(task: Any) -> dict[str, Any]:
if task is None:
return {}
data = dict(getattr(task, "__dict__", {}) or {})
status = data.get("status")
if hasattr(status, "value"):
data["status"] = status.value
for key, value in list(data.items()):
if hasattr(value, "isoformat"):
data[key] = value.isoformat()
return data
def _resolve_task_comms_dir(self, task: Any, *, engine: Any) -> Path | None:
metadata = dict(getattr(task, "metadata", {}) or {})
workspace_root = (
str(metadata.get("comms_workspace_root") or "").strip()
or str(metadata.get("target_output_dir") or "").strip()
or str(metadata.get("setup_workspace_prepared") or "").strip()
)
comms_root = str(metadata.get("comms_root") or "").strip()
session_id = (
str(getattr(task, "parent_session_id", "") or "").strip()
or str(getattr(task, "session_id", "") or "").strip()
)
pid = str(getattr(task, "project_id", "") or getattr(engine, "project_id", "") or "default")
if not session_id:
return None
try:
from opc.layer2_organization import comms as file_comms
if workspace_root:
return file_comms.resolve_layout(workspace_root, pid, session_id).root
if comms_root:
return file_comms.resolve_layout(str(Path(comms_root).parent), pid, session_id).root
except Exception:
return None
return None