10562 lines
484 KiB
Python
10562 lines
484 KiB
Python
"""WebSocket Handler — routes all messages between frontend and OPC.
|
||
|
||
Routes inbound UI messages and outbound event envelopes.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import inspect
|
||
import json
|
||
import math
|
||
import re
|
||
import time
|
||
import uuid
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any, TYPE_CHECKING
|
||
|
||
from loguru import logger
|
||
from opc.core.active_task_runs import (
|
||
ActiveTaskRunAdmissionClosed,
|
||
ActiveTaskRunRegistry,
|
||
)
|
||
from opc.core.config import (
|
||
OPCConfig,
|
||
get_project_workplace,
|
||
slugify_organization_name,
|
||
validate_organization_id,
|
||
)
|
||
from opc.core.org_config import (
|
||
allocate_org_config_id,
|
||
apply_org_config_payload_to_config,
|
||
build_org_config_payload_from_config,
|
||
list_org_config_paths,
|
||
load_org_config_payload,
|
||
org_config_filename,
|
||
org_config_path,
|
||
org_config_relative_path,
|
||
org_configs_dir,
|
||
read_org_index,
|
||
validate_runnable_org_config,
|
||
validate_saved_org_id,
|
||
write_org_config_payload,
|
||
write_org_index,
|
||
)
|
||
from opc.core.models import normalize_role_runtime_status
|
||
from opc.core.transcript_visibility import rendered_transcript_metadata_visible
|
||
from opc.presentation.kanban import build_company_board_columns
|
||
from opc.layer2_organization.phase import (
|
||
kanban_column,
|
||
should_hide_work_item_from_company_kanban,
|
||
)
|
||
from opc.layer2_organization.company_runtime_identity import (
|
||
ACTIVE_COMPANY_RUNTIME_CHECKPOINT_STATUSES,
|
||
COMPANY_RUNTIME_CHECKPOINT_TYPES,
|
||
is_company_runtime_task,
|
||
load_company_runtime_identity_index,
|
||
)
|
||
from opc.layer2_organization.work_item_identity import (
|
||
work_item_identity_payload,
|
||
work_item_identity_payload_for_task,
|
||
work_item_projection_id_from_metadata,
|
||
work_item_turn_type_from_metadata,
|
||
)
|
||
from opc.layer2_organization.work_item_links import linked_work_item_id_for_task
|
||
from opc.layer2_organization.work_item_transition import (
|
||
apply_task_status_transition,
|
||
)
|
||
from opc.layer2_organization.org_work_item_planner import build_custom_org_work_item_blueprint
|
||
from opc.layer4_tools.output_budget import clip_text
|
||
|
||
if TYPE_CHECKING:
|
||
import aiohttp.web
|
||
from opc.engine import OPCEngine
|
||
from opc.plugins.office_ui.agent_store import AgentStore
|
||
from opc.plugins.office_ui.chat_store import ChatStore
|
||
from opc.plugins.office_ui.event_adapter import EventAdapter
|
||
|
||
from opc.plugins.office_ui.dispatcher import Dispatcher
|
||
from opc.plugins.office_ui.services import (
|
||
ModeState,
|
||
OfficeServiceContext,
|
||
OfficeServices,
|
||
ServiceError,
|
||
ServiceResult,
|
||
SessionService,
|
||
)
|
||
from opc.plugins.office_ui.snapshot_builder import (
|
||
STATUS_TO_COLUMN,
|
||
_build_company_runtime_control_by_task,
|
||
collapse_adjacent_transcript_duplicates,
|
||
_build_session_context_preview,
|
||
_extract_markdown_text,
|
||
_sanitize_ui_message_dict,
|
||
_normalize_transcript_detail_level,
|
||
_task_parent_session_link,
|
||
build_collab_sync,
|
||
build_project_index_sync,
|
||
build_transcript_ui_messages,
|
||
build_snapshot,
|
||
)
|
||
from opc.plugins.office_ui.org_architecture_snapshot import (
|
||
apply_org_architecture_snapshot,
|
||
build_org_architecture_snapshot,
|
||
dump_org_architecture_snapshot,
|
||
parse_org_architecture_snapshot,
|
||
)
|
||
|
||
|
||
def _add_execution_turn_aliases(
|
||
payload: dict[str, Any],
|
||
runtime_task_id: Any | None = None,
|
||
) -> dict[str, Any]:
|
||
"""Add canonical UI aliases for runtime Task / execution turn identity."""
|
||
task_id = str(
|
||
runtime_task_id
|
||
or payload.get("runtime_task_id")
|
||
or payload.get("execution_turn_id")
|
||
or payload.get("task_id")
|
||
or ""
|
||
).strip()
|
||
if task_id:
|
||
payload.setdefault("runtime_task_id", task_id)
|
||
payload.setdefault("execution_turn_id", task_id)
|
||
return payload
|
||
|
||
|
||
def _is_cjk_title_char(ch: str) -> bool:
|
||
cp = ord(ch)
|
||
return (
|
||
0x3400 <= cp <= 0x4DBF
|
||
or 0x4E00 <= cp <= 0x9FFF
|
||
or 0xF900 <= cp <= 0xFAFF
|
||
or 0x3040 <= cp <= 0x30FF
|
||
or 0xAC00 <= cp <= 0xD7AF
|
||
)
|
||
|
||
|
||
def _compact_session_title(content: str, *, max_units: int = 10, fallback: str = "New Chat") -> str:
|
||
text = re.sub(r"\s+", " ", str(content or "")).strip()
|
||
if not text:
|
||
return fallback
|
||
if max_units <= 0:
|
||
return fallback
|
||
|
||
units = 0
|
||
index = 0
|
||
cut_index = len(text)
|
||
while index < len(text):
|
||
ch = text[index]
|
||
if ch.isspace():
|
||
index += 1
|
||
continue
|
||
if _is_cjk_title_char(ch):
|
||
units += 1
|
||
index += 1
|
||
if units == max_units:
|
||
cut_index = index
|
||
break
|
||
continue
|
||
if (ch.isalnum() or ch == "_") and not _is_cjk_title_char(ch):
|
||
while (
|
||
index < len(text)
|
||
and (text[index].isalnum() or text[index] == "_")
|
||
and not _is_cjk_title_char(text[index])
|
||
):
|
||
index += 1
|
||
units += 1
|
||
if units == max_units:
|
||
cut_index = index
|
||
break
|
||
continue
|
||
index += 1
|
||
|
||
compact = text[:cut_index].strip() or fallback
|
||
has_more_units = any(
|
||
_is_cjk_title_char(ch) or ((ch.isalnum() or ch == "_") and not _is_cjk_title_char(ch))
|
||
for ch in text[cut_index:]
|
||
)
|
||
return f"{compact}..." if has_more_units else compact
|
||
|
||
|
||
_TASK_MODE_HIDDEN_RUNTIME_PROGRESS_TYPES: frozenset[str] = frozenset({
|
||
"message_start",
|
||
"message_stop",
|
||
"tool_call_delta",
|
||
"status_snapshot",
|
||
"context_usage",
|
||
"cost_update",
|
||
"task_ledger_updated",
|
||
"prompt_prefix_state",
|
||
"prompt_prefix_cache_fingerprint",
|
||
"prefetch_started",
|
||
"prefetch_completed",
|
||
"prefetch_consumed",
|
||
"durable_memory_extracted",
|
||
"durable_memory_extraction_failed",
|
||
"tool_hook",
|
||
"turn_started",
|
||
"turn_completed",
|
||
"member_idle",
|
||
})
|
||
|
||
|
||
_TASK_MODE_DEBUG_ONLY_PROGRESS_TYPES: frozenset[str] = frozenset({
|
||
"compaction_applied",
|
||
})
|
||
|
||
|
||
# Company mode shares the task-mode noise list; runtime bookkeeping events
|
||
# (turns, status snapshots, cost ticks) carry no reviewable content and drown
|
||
# out thinking/tool entries in the per-role activity feed.
|
||
_COMPANY_MODE_HIDDEN_RUNTIME_PROGRESS_TYPES: frozenset[str] = (
|
||
_TASK_MODE_HIDDEN_RUNTIME_PROGRESS_TYPES | frozenset({"member_inbox_updated"})
|
||
)
|
||
|
||
|
||
# These handlers can hand an accepted UI request to engine execution. Reserve
|
||
# at the router boundary (before their first await), because the persisted task
|
||
# mode may not be known until the handler loads the task from the project store.
|
||
_EXECUTION_HANDOFF_MESSAGE_TYPES: frozenset[str] = frozenset({
|
||
"run_task",
|
||
"session_send",
|
||
"session_resume",
|
||
})
|
||
|
||
|
||
_TASK_MODE_VISIBLE_RUNTIME_PROGRESS_TYPES: frozenset[str] = frozenset({
|
||
"thinking_delta",
|
||
"tool_started",
|
||
"tool_progress",
|
||
"tool_completed",
|
||
"permission_requested",
|
||
"permission_resolved",
|
||
"checkpoint_saved",
|
||
"turn_failed",
|
||
})
|
||
|
||
|
||
def _normalize_escalation_key(value: str) -> str:
|
||
return re.sub(r"[\s\-]+", "_", value.strip()).strip("_").casefold()
|
||
|
||
|
||
def _normalize_escalation_reply(reply: str, options: list[dict[str, Any]]) -> str | None:
|
||
raw_reply = str(reply or "").strip()
|
||
if not raw_reply:
|
||
return None
|
||
|
||
normalized_map: dict[str, str] = {}
|
||
for idx, option in enumerate(options, start=1):
|
||
option_id = str(option.get("id", "")).strip()
|
||
label = str(option.get("label", option_id)).strip()
|
||
if not option_id:
|
||
continue
|
||
normalized_map[option_id.casefold()] = option_id
|
||
normalized_map[_normalize_escalation_key(option_id)] = option_id
|
||
if label:
|
||
normalized_map[label.casefold()] = option_id
|
||
normalized_map[_normalize_escalation_key(label)] = option_id
|
||
normalized_map[str(idx)] = option_id
|
||
|
||
alias_map = {
|
||
"y": "approve_once",
|
||
"yes": "approve_once",
|
||
"approve": "approve_once",
|
||
"allow": "approve_once",
|
||
"session": "approve_session",
|
||
"n": "deny",
|
||
"no": "deny",
|
||
"deny": "deny",
|
||
"reject": "deny",
|
||
"project": "always_project",
|
||
"global": "always_global",
|
||
}
|
||
alias = alias_map.get(_normalize_escalation_key(raw_reply))
|
||
if alias and alias in normalized_map.values():
|
||
return alias
|
||
return normalized_map.get(raw_reply.casefold()) or normalized_map.get(_normalize_escalation_key(raw_reply))
|
||
|
||
|
||
def _ui_message_identity_metadata(
|
||
*,
|
||
kind: str | None = None,
|
||
message_id: str | None = None,
|
||
conversation_turn_id: str | None = None,
|
||
created_at: float | None = None,
|
||
) -> dict[str, Any]:
|
||
metadata: dict[str, Any] = {}
|
||
if kind:
|
||
metadata["kind"] = kind
|
||
normalized_id = str(message_id or "").strip()
|
||
if normalized_id:
|
||
metadata["ui_message_id"] = normalized_id
|
||
normalized_turn_id = str(conversation_turn_id or "").strip()
|
||
if normalized_turn_id:
|
||
metadata["conversation_turn_id"] = normalized_turn_id
|
||
metadata["canonical_turn_id"] = normalized_turn_id
|
||
metadata["turn_id"] = normalized_turn_id
|
||
if created_at is not None:
|
||
metadata["ui_created_at"] = float(created_at)
|
||
return metadata
|
||
|
||
|
||
def _ui_conversation_turn_id(message_id: str | None) -> str:
|
||
normalized_id = str(message_id or "").strip()
|
||
if not normalized_id:
|
||
return ""
|
||
return f"ui-turn:{normalized_id}"
|
||
|
||
|
||
_GENERIC_ESCALATION_OPTIONS: list[dict[str, str]] = [
|
||
{"id": "approve_once", "label": "Approve once"},
|
||
{"id": "approve_session", "label": "Allow for this session"},
|
||
{"id": "deny", "label": "Deny"},
|
||
{"id": "always_project", "label": "Always allow for this project"},
|
||
{"id": "always_global", "label": "Always allow globally"},
|
||
{"id": "proceed", "label": "Proceed"},
|
||
{"id": "abort", "label": "Abort"},
|
||
]
|
||
|
||
|
||
def _looks_like_escalation_reply(content: str) -> bool:
|
||
return _normalize_escalation_reply(content, _GENERIC_ESCALATION_OPTIONS) is not None
|
||
|
||
|
||
_TASK_MODE_PREFERRED_AGENTS = frozenset({
|
||
"native",
|
||
"codex",
|
||
"claude_code",
|
||
"cursor",
|
||
"opencode",
|
||
})
|
||
|
||
_PERSISTED_WORKER_NOTIFICATION_KINDS = frozenset({
|
||
"idle",
|
||
"task_complete",
|
||
"blocked",
|
||
"handoff_ready",
|
||
"error",
|
||
"permission_needed",
|
||
})
|
||
|
||
_RUNTIME_TASK_VISIBILITY_EVENT_TYPES = frozenset({
|
||
"member_session_started",
|
||
"member_claimed_work_item",
|
||
"member_idle",
|
||
"member_inbox_updated",
|
||
"worker_notification",
|
||
})
|
||
|
||
_PROJECT_SCOPED_ENVELOPE_TYPES = frozenset({
|
||
"snapshot",
|
||
"event",
|
||
"channel_created",
|
||
"board_task_created",
|
||
"board_task_moved",
|
||
"board_task_status_changed",
|
||
"session_runtime_control",
|
||
"chat_new_message",
|
||
"chat_channel_created",
|
||
"kanban_updated",
|
||
"kanban_board_created",
|
||
"agent_runtime_update",
|
||
"worker_notification",
|
||
"execution_mode_resolved",
|
||
"collab_sync_push",
|
||
"project_index_push",
|
||
"kanban_view_data",
|
||
"session_created",
|
||
"session_updated",
|
||
"session_message",
|
||
"session_title_updated",
|
||
"session_deleted",
|
||
"child_session_created",
|
||
"session_progress",
|
||
"work_item_progress",
|
||
"project_run_updated",
|
||
"seat_digest_updated",
|
||
"work_item_batch_updated",
|
||
"project_recovery_updated",
|
||
"project_revision_created",
|
||
"comms_state",
|
||
"comms_message",
|
||
"comms_state_dirty",
|
||
"runtime_status_sync",
|
||
})
|
||
|
||
|
||
# ── Saved org architectures helpers ──────────────────────────────
|
||
# A saved org is a complete user organization config under
|
||
# .opc/config/company_orgs/org_<organization_id>_config.yaml.
|
||
_SAVED_ORG_NAME_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
|
||
_ACTIVE_SAVED_ORG_STATE_KEY = "active_saved_org"
|
||
_SAVED_ORG_NAME_LAX_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
||
|
||
|
||
class ProjectScopeError(ValueError):
|
||
"""Raised when a project-scoped WS request is missing its explicit scope."""
|
||
|
||
|
||
def _saved_orgs_dir() -> Path:
|
||
from opc.core.config import get_opc_home
|
||
return org_configs_dir(get_opc_home() / "config")
|
||
|
||
|
||
def _saved_org_path(name: str, *, strict: bool = True) -> Path:
|
||
raw = str(name or "").strip()
|
||
pattern = _SAVED_ORG_NAME_RE if strict else _SAVED_ORG_NAME_LAX_RE
|
||
if pattern.match(raw):
|
||
org_id = raw.lower()
|
||
elif not strict and raw:
|
||
org_id = slugify_organization_name(raw)
|
||
else:
|
||
raise ValueError(f"Invalid saved-org name: {name!r}")
|
||
return _saved_orgs_dir() / org_config_filename(org_id)
|
||
|
||
|
||
class WSHandler:
|
||
"""Routes all WebSocket messages between frontend and OPC."""
|
||
|
||
def __init__(
|
||
self,
|
||
engine: OPCEngine,
|
||
agent_store: AgentStore,
|
||
chat_store: ChatStore,
|
||
event_adapter: EventAdapter,
|
||
) -> None:
|
||
self.engine = engine
|
||
self._root_engine = engine
|
||
self._active_project_id = str(engine.project_id or "default").strip() or "default"
|
||
self._project_switch_lock = asyncio.Lock()
|
||
self.agent_store = agent_store
|
||
self.chat_store = chat_store
|
||
self.event_adapter = event_adapter
|
||
self._clients: set[aiohttp.web.WebSocketResponse] = set()
|
||
self._client_project_ids: dict[Any, str] = {}
|
||
self._client_switch_seq: dict[Any, str] = {}
|
||
self._client_project_index_tasks: dict[Any, asyncio.Task[Any]] = {}
|
||
self._client_initial_state_tasks: dict[Any, asyncio.Task[Any]] = {}
|
||
self._exec_mode: str = "task" # restored from DB in restore_persisted_mode()
|
||
self._company_profile: str = "corporate"
|
||
self._task_preferred_agent: str = "native"
|
||
self._local_talent_cache: list[Any] | None = None # invalidated on import/hire
|
||
self._background_tasks: set[asyncio.Task[Any]] = set()
|
||
self._task_bg_map: dict[str, set[asyncio.Task[Any]]] = {}
|
||
self._task_bg_context: dict[asyncio.Task[Any], dict[str, Any]] = {}
|
||
self._handoff_route_tasks: dict[asyncio.Task[Any], str] = {}
|
||
self._broadcast_seq: int = 0
|
||
self._broadcast_lock = asyncio.Lock()
|
||
self._task_locks: dict[str, asyncio.Lock] = {}
|
||
# Tracks which asyncio.Task currently holds the per-task lock. If the
|
||
# prior holder finishes/crashes without reaching the finally block that
|
||
# pops the lock, the next acquirer would block forever. We detect that
|
||
# case by checking ``holder.done()`` in ``_get_task_lock`` and replace
|
||
# the stale lock before the new acquirer blocks.
|
||
self._task_lock_holders: dict[str, asyncio.Task[Any]] = {}
|
||
self._config_lock = asyncio.Lock()
|
||
self._active_runtime_children: dict[str, str] = {}
|
||
self._secretary_session_id: str | None = None
|
||
self._secretary_session_ids: dict[str, str] = {}
|
||
self._session_to_task: dict[str, str] = {}
|
||
self._ui_task_aliases: dict[str, str] = {}
|
||
self._pending_escalations: dict[str, dict[str, Any]] = {}
|
||
self._pending_escalation_order: list[str] = []
|
||
self._progress_buffer: dict[str, list[dict[str, Any]]] = {}
|
||
self._progress_project_ids: dict[str, str] = {}
|
||
self._runtime_status_sync_task: asyncio.Task[Any] | None = None
|
||
self._runtime_status_sync_prev: dict[str, str] = {}
|
||
self._assistant_delta_buffers: dict[tuple[str, str, str], dict[str, Any]] = {}
|
||
self._assistant_delta_flush_tasks: dict[tuple[str, str, str], asyncio.Task[None]] = {}
|
||
self._assistant_delta_seq: int = 0
|
||
self._ASSISTANT_DELTA_FLUSH_INTERVAL_SEC = 0.05
|
||
self._stop_requested_task_ids: set[str] = set()
|
||
self._company_stop_intents: dict[str, dict[str, Any]] = {}
|
||
self._company_stop_finalize_tasks: dict[str, asyncio.Task[Any]] = {}
|
||
self._company_suspend_reply_locks: dict[str, asyncio.Lock] = {}
|
||
self._company_delivery_feedback_reply_locks: dict[str, asyncio.Lock] = {}
|
||
# Buffer progress entries before UPSERTing to SQLite. Raised from 1
|
||
# so bursts (codex streaming thinking chunks, multi-line tool output)
|
||
# don't hammer the DB once per entry, but kept small enough that a
|
||
# task sitting idle between work items still shows up on the Activity
|
||
# panel without waiting for 10 entries to accumulate. A periodic
|
||
# flush in ``_periodic_flush_loop`` catches tasks that emit sparsely
|
||
# so at most ``_PROGRESS_FLUSH_INTERVAL_SEC`` of entries are held
|
||
# in RAM before they're persisted and visible on page refresh.
|
||
self._PROGRESS_FLUSH_THRESHOLD = 2
|
||
self._PROGRESS_FLUSH_INTERVAL_SEC = 3.0
|
||
self._RUNTIME_STATUS_SYNC_INTERVAL_SEC = 12.0
|
||
self._progress_flush_task: asyncio.Task[None] | None = None
|
||
self._shutting_down: bool = False
|
||
self._active_message_tasks: set[asyncio.Task[Any]] = set()
|
||
self.dispatcher = Dispatcher(engine, chat_store)
|
||
self.services_context = OfficeServiceContext(
|
||
engine=engine,
|
||
agent_store=agent_store,
|
||
chat_store=chat_store,
|
||
event_adapter=event_adapter,
|
||
mode_state=ModeState(
|
||
exec_mode=self._exec_mode,
|
||
company_profile=self._company_profile,
|
||
task_preferred_agent=self._task_preferred_agent,
|
||
),
|
||
)
|
||
self.services_context.config_lock = self._config_lock
|
||
self.services_context.background_tasks = self._background_tasks
|
||
self.services_context.task_bg_map = self._task_bg_map
|
||
self.services_context.task_bg_context = self._task_bg_context
|
||
self.services_context.session_to_task = self._session_to_task
|
||
self.services_context.active_runtime_children = self._active_runtime_children
|
||
self.services_context.stop_requested_task_ids = self._stop_requested_task_ids
|
||
self.services_context.task_locks = self._task_locks
|
||
self.services_context.task_lock_holders = self._task_lock_holders
|
||
self.services_context.wire_engine_callbacks = self._wire_engine_callbacks # type: ignore[attr-defined]
|
||
self.services_context.load_active_org_config = lambda org_id: self._load_active_org_config_into_engine(org_id)
|
||
self.services_context.set_active_saved_org_name = self._service_set_active_saved_org_name
|
||
self.services_context.get_active_saved_org_name = self._service_get_active_saved_org_name
|
||
self.services_context.project_workplace_hook = lambda project_id: get_project_workplace(project_id) # type: ignore[attr-defined]
|
||
self.services_context.on_engine_activated = self._on_service_engine_activated
|
||
self.services_context.persist_runtime_config = self._persist_runtime_config
|
||
self.services_context.rebind_engine_config = self._rebind_engine_config
|
||
self.services_context.sync_role_map = self._sync_role_map
|
||
self.services_context.ensure_custom_role_agents = self._ensure_custom_role_agents
|
||
self.services_context.broadcast_snapshot = self._broadcast_snapshot
|
||
self.services_context.cancel_session_tasks = self._cancel_session_tasks
|
||
self.services_context.cancel_task_tree = self._cancel_task_tree
|
||
self.services = OfficeServices(self.services_context)
|
||
self._wire_engine_callbacks(engine)
|
||
|
||
def _on_service_engine_activated(self, engine: Any, project_id: str) -> None:
|
||
self.engine = engine
|
||
self.dispatcher = Dispatcher(engine, self.chat_store)
|
||
self._active_project_id = self._normalize_project_id(project_id)
|
||
self._refresh_engine_attachment_store()
|
||
|
||
def _ensure_office_services(self) -> OfficeServices:
|
||
"""Create service wiring for tests that instantiate WSHandler via __new__."""
|
||
if hasattr(self, "services") and hasattr(self, "services_context"):
|
||
self.services_context.mode_state.exec_mode = getattr(self, "_exec_mode", "task")
|
||
self.services_context.mode_state.company_profile = getattr(self, "_company_profile", "corporate")
|
||
self.services_context.mode_state.task_preferred_agent = getattr(self, "_task_preferred_agent", "native")
|
||
return self.services
|
||
context = OfficeServiceContext(
|
||
engine=self.engine,
|
||
agent_store=getattr(self, "agent_store", None),
|
||
chat_store=getattr(self, "chat_store", None),
|
||
event_adapter=getattr(self, "event_adapter", None),
|
||
mode_state=ModeState(
|
||
exec_mode=getattr(self, "_exec_mode", "task"),
|
||
company_profile=getattr(self, "_company_profile", "corporate"),
|
||
task_preferred_agent=getattr(self, "_task_preferred_agent", "native"),
|
||
),
|
||
)
|
||
if hasattr(self, "_config_lock"):
|
||
context.config_lock = self._config_lock
|
||
for attr in ("_background_tasks", "_task_bg_map", "_task_bg_context", "_session_to_task", "_task_locks", "_task_lock_holders"):
|
||
if hasattr(self, attr):
|
||
setattr(context, attr.removeprefix("_"), getattr(self, attr))
|
||
if hasattr(self, "_wire_engine_callbacks"):
|
||
context.wire_engine_callbacks = self._wire_engine_callbacks # type: ignore[attr-defined]
|
||
if hasattr(self, "_load_active_org_config_into_engine"):
|
||
context.load_active_org_config = lambda org_id: self._load_active_org_config_into_engine(org_id)
|
||
if hasattr(self, "_service_set_active_saved_org_name"):
|
||
context.set_active_saved_org_name = self._service_set_active_saved_org_name
|
||
if hasattr(self, "_service_get_active_saved_org_name"):
|
||
context.get_active_saved_org_name = self._service_get_active_saved_org_name
|
||
if hasattr(self, "_on_service_engine_activated"):
|
||
context.on_engine_activated = self._on_service_engine_activated
|
||
if hasattr(self, "_persist_runtime_config"):
|
||
context.persist_runtime_config = self._persist_runtime_config
|
||
if hasattr(self, "_rebind_engine_config"):
|
||
context.rebind_engine_config = self._rebind_engine_config
|
||
if hasattr(self, "_sync_role_map"):
|
||
context.sync_role_map = self._sync_role_map
|
||
if hasattr(self, "_ensure_custom_role_agents"):
|
||
context.ensure_custom_role_agents = self._ensure_custom_role_agents
|
||
if hasattr(self, "_broadcast_snapshot"):
|
||
context.broadcast_snapshot = self._broadcast_snapshot
|
||
if hasattr(self, "_cancel_session_tasks"):
|
||
context.cancel_session_tasks = self._cancel_session_tasks
|
||
if hasattr(self, "_cancel_task_tree"):
|
||
context.cancel_task_tree = self._cancel_task_tree
|
||
self.services_context = context
|
||
self.services = OfficeServices(context)
|
||
return self.services
|
||
|
||
async def _service_set_active_saved_org_name(self, org_id: str) -> None:
|
||
await self._set_active_saved_org_name(org_id)
|
||
|
||
async def _service_get_active_saved_org_name(self) -> str:
|
||
return await self._get_active_saved_org_name()
|
||
|
||
@staticmethod
|
||
def _normalize_project_id(project_id: str | None) -> str:
|
||
return str(project_id or "default").strip() or "default"
|
||
|
||
def _active_engine_project_id(self) -> str:
|
||
return self._normalize_project_id(getattr(self.engine, "project_id", None) or self._active_project_id)
|
||
|
||
def _client_active_project_id(self, ws: Any | None = None) -> str:
|
||
if ws is not None:
|
||
project_id = str(self._client_project_ids.get(ws, "") or "").strip()
|
||
if project_id:
|
||
return self._normalize_project_id(project_id)
|
||
return self._active_engine_project_id()
|
||
|
||
def _request_project_id(self, data: dict[str, Any] | None) -> str:
|
||
raw = (data or {}).get("project_id") or (data or {}).get("projectId")
|
||
project_id = str(raw or "").strip()
|
||
if not project_id:
|
||
raise ProjectScopeError("project_id required for project-scoped request")
|
||
return self._normalize_project_id(project_id)
|
||
|
||
@staticmethod
|
||
def _payload_project_id(payload: Any) -> str:
|
||
if not isinstance(payload, dict):
|
||
return ""
|
||
for key in ("project_id", "projectId", "active_project_id", "activeProjectId"):
|
||
value = payload.get(key)
|
||
normalized = str(value or "").strip()
|
||
if normalized:
|
||
return normalized
|
||
data = payload.get("data")
|
||
if isinstance(data, dict):
|
||
for key in ("project_id", "projectId"):
|
||
value = data.get(key)
|
||
normalized = str(value or "").strip()
|
||
if normalized:
|
||
return normalized
|
||
return ""
|
||
|
||
async def _engine_for_request(self, data: dict[str, Any] | None) -> tuple[Any, str]:
|
||
project_id = self._request_project_id(data)
|
||
engine = await self._engine_for_project(project_id)
|
||
return engine, project_id
|
||
|
||
def _progress_callback_for_engine(self, engine: Any) -> Any:
|
||
async def _progress(text: str, **kw: Any) -> None:
|
||
# UI progress is a best-effort display copy: a failure here (e.g.
|
||
# a locked ui_state.db) must never crash the agent execution that
|
||
# emitted the progress line.
|
||
try:
|
||
await self.on_progress(
|
||
text,
|
||
_runtime_engine=engine,
|
||
_project_id=self._normalize_project_id(getattr(engine, "project_id", None)),
|
||
**kw,
|
||
)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception:
|
||
logger.opt(exception=True).warning(
|
||
"UI progress handling failed; agent execution continues"
|
||
)
|
||
|
||
return _progress
|
||
|
||
def _runtime_event_callback_for_engine(self, engine: Any) -> Any:
|
||
async def _runtime_event(event: Any) -> None:
|
||
try:
|
||
await self.on_opc_event(
|
||
event,
|
||
runtime_engine=engine,
|
||
project_id=self._normalize_project_id(getattr(engine, "project_id", None)),
|
||
)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception:
|
||
logger.opt(exception=True).warning(
|
||
"UI runtime-event handling failed; agent execution continues"
|
||
)
|
||
|
||
setattr(_runtime_event, "_opc_ui_handler_id", id(self))
|
||
setattr(_runtime_event, "_opc_ui_project_id", self._normalize_project_id(getattr(engine, "project_id", None)))
|
||
return _runtime_event
|
||
|
||
def _escalation_callback_for_engine(self, engine: Any) -> Any:
|
||
async def _escalation(message: str, options: list[dict]) -> str | None:
|
||
return await self._handle_ui_escalation(
|
||
message,
|
||
options,
|
||
project_id=self._normalize_project_id(getattr(engine, "project_id", None)),
|
||
)
|
||
|
||
setattr(_escalation, "_opc_ui_handler_id", id(self))
|
||
setattr(_escalation, "_opc_ui_project_id", self._normalize_project_id(getattr(engine, "project_id", None)))
|
||
return _escalation
|
||
|
||
def _kanban_callback_for_engine(self, engine: Any) -> Any:
|
||
async def _kanban_changed() -> None:
|
||
try:
|
||
await self.on_kanban_changed(engine=engine)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception:
|
||
logger.opt(exception=True).warning(
|
||
"UI kanban refresh failed; agent execution continues"
|
||
)
|
||
|
||
return _kanban_changed
|
||
|
||
def _wire_engine_callbacks(self, engine: Any) -> None:
|
||
try:
|
||
progress_callback = self._progress_callback_for_engine(engine)
|
||
runtime_event_callback = self._runtime_event_callback_for_engine(engine)
|
||
escalation_callback = self._escalation_callback_for_engine(engine)
|
||
engine.on_company_runtime_children = self._register_company_runtime_children
|
||
engine.on_company_kanban_callback_factory = self._kanban_callback_for_engine
|
||
engine.on_escalation = escalation_callback
|
||
engine.on_progress = progress_callback
|
||
engine.on_runtime_event = runtime_event_callback
|
||
if getattr(engine, "escalation", None):
|
||
engine.escalation.user_reply_callback = escalation_callback
|
||
company_executor = getattr(engine, "company_executor", None)
|
||
if company_executor is not None:
|
||
company_executor.progress_callback = progress_callback
|
||
company_executor.on_kanban_changed = self._kanban_callback_for_engine(engine)
|
||
reorg_manager = getattr(engine, "reorg_manager", None)
|
||
if reorg_manager is not None:
|
||
reorg_manager.progress_callback = progress_callback
|
||
event_bus = getattr(engine, "event_bus", None)
|
||
forward_runtime_event = getattr(engine, "_forward_runtime_event", None)
|
||
if (
|
||
engine is not self._root_engine
|
||
and event_bus is not None
|
||
and callable(getattr(event_bus, "subscribe_all", None))
|
||
):
|
||
# Delegates need all events, not only runtime_event. They may
|
||
# have inherited a typed runtime forwarder during initialize();
|
||
# remove it so runtime_event is not delivered twice.
|
||
listeners_by_type = getattr(event_bus, "_listeners", {})
|
||
runtime_list = listeners_by_type.get("runtime_event", []) if listeners_by_type is not None else []
|
||
if callable(forward_runtime_event) and isinstance(runtime_list, list):
|
||
runtime_list[:] = [
|
||
listener for listener in runtime_list
|
||
if listener != forward_runtime_event
|
||
]
|
||
project_marker = self._normalize_project_id(getattr(engine, "project_id", None))
|
||
global_listeners = getattr(event_bus, "_global_listeners", [])
|
||
already_subscribed = any(
|
||
getattr(listener, "_opc_ui_handler_id", None) == id(self)
|
||
and getattr(listener, "_opc_ui_project_id", None) == project_marker
|
||
for listener in list(global_listeners or [])
|
||
)
|
||
if not already_subscribed:
|
||
event_bus.subscribe_all(runtime_event_callback)
|
||
except Exception:
|
||
logger.opt(exception=True).debug(
|
||
f"Failed to wire UI callbacks for project engine {getattr(engine, 'project_id', None)!r}",
|
||
)
|
||
|
||
@staticmethod
|
||
def _is_real_opc_engine(engine: Any) -> bool:
|
||
try:
|
||
from opc.engine import OPCEngine as _OPCEngine
|
||
except Exception:
|
||
return False
|
||
return isinstance(engine, _OPCEngine)
|
||
|
||
async def _engine_for_project(self, project_id: str) -> Any:
|
||
normalized = self._normalize_project_id(project_id)
|
||
root = self._root_engine
|
||
current_root_project = self._normalize_project_id(getattr(root, "project_id", None))
|
||
if normalized == current_root_project:
|
||
engine = root
|
||
else:
|
||
delegate_getter = getattr(root, "_get_project_delegate", None)
|
||
explicit_delegate = "_get_project_delegate" in getattr(root, "__dict__", {})
|
||
if not callable(delegate_getter) or not (self._is_real_opc_engine(root) or explicit_delegate):
|
||
raise RuntimeError(
|
||
"Project switching requires OPCEngine project delegates or an explicit "
|
||
"_get_project_delegate test double."
|
||
)
|
||
maybe_engine = delegate_getter(normalized)
|
||
engine = await maybe_engine if inspect.isawaitable(maybe_engine) else maybe_engine
|
||
# Self-heal a closed store (project deleted then re-created while this
|
||
# engine instance stayed bound to it — e.g. the root engine, which can
|
||
# never be evicted from its own delegate cache).
|
||
store = getattr(engine, "store", None)
|
||
if store is not None and not getattr(store, "is_ready", True):
|
||
ensure_ready = getattr(store, "ensure_ready", None)
|
||
if callable(ensure_ready):
|
||
logger.warning(f"Reopening closed store for project '{normalized}'")
|
||
await ensure_ready()
|
||
self._wire_engine_callbacks(engine)
|
||
return engine
|
||
|
||
async def _activate_project(self, project_id: str) -> Any:
|
||
engine = await self._engine_for_project(project_id)
|
||
self.engine = engine
|
||
self.dispatcher = Dispatcher(engine, self.chat_store)
|
||
self._active_project_id = self._normalize_project_id(getattr(engine, "project_id", None) or project_id)
|
||
self._refresh_engine_attachment_store()
|
||
return engine
|
||
|
||
def _refresh_engine_attachment_store(self) -> None:
|
||
"""Ensure the engine attachment store matches the active project."""
|
||
ensure_attachment_store = getattr(self.engine, "_ensure_attachment_store", None)
|
||
if not callable(ensure_attachment_store):
|
||
return
|
||
try:
|
||
ensure_attachment_store()
|
||
except Exception as exc:
|
||
logger.warning(f"Failed to refresh attachment store for project {self.engine.project_id!r}: {exc}")
|
||
|
||
def _register_company_runtime_children(self, parent_session_id: str, child_task_ids: list[str]) -> None:
|
||
"""Called by engine when company mode creates child work-item tasks.
|
||
|
||
Maps each child task_id to the primary task_id (the one the user
|
||
initiated) so that ``on_progress`` can dual-route runtime events.
|
||
"""
|
||
origin_task_id = self._session_to_task.get(parent_session_id)
|
||
if not origin_task_id:
|
||
# Fallback: try to find by iterating known mappings
|
||
for sid, tid in self._session_to_task.items():
|
||
if sid == parent_session_id or tid == parent_session_id:
|
||
origin_task_id = tid
|
||
break
|
||
if not origin_task_id:
|
||
logger.warning(f"Cannot map runtime children: parent_session_id={parent_session_id} not in _session_to_task")
|
||
return
|
||
for child_id in child_task_ids:
|
||
self._active_runtime_children[child_id] = origin_task_id
|
||
# Also register child task_id in _session_to_task for progress routing
|
||
self._session_to_task[child_id] = child_id
|
||
|
||
def _get_task_lock(self, task_id: str) -> asyncio.Lock:
|
||
"""Get or create a per-task lock for serializing messages within one session.
|
||
|
||
Self-heals stale locks: if the previous holder coroutine is ``.done()``
|
||
but the lock was never released (process interrupt, silent cancellation,
|
||
unhandled exception swallowed upstream), replace it with a fresh lock so
|
||
the next acquirer can proceed instead of blocking forever. This is what
|
||
lets Continue / new messages work after a disconnect.
|
||
"""
|
||
prev_holder = self._task_lock_holders.get(task_id)
|
||
if prev_holder is not None and prev_holder.done():
|
||
logger.warning(
|
||
f"Replacing stale task lock for {task_id} "
|
||
f"(prior holder done: cancelled={prev_holder.cancelled()}). "
|
||
"Lock was not released via finally — likely disconnect or crash."
|
||
)
|
||
self._task_locks.pop(task_id, None)
|
||
self._task_lock_holders.pop(task_id, None)
|
||
lock = self._task_locks.get(task_id)
|
||
if lock is None:
|
||
lock = asyncio.Lock()
|
||
self._task_locks[task_id] = lock
|
||
return lock
|
||
|
||
def _resolve_agent_for_idle(self, task_id: str, task: Any = None) -> str | None:
|
||
"""Resolve agent_id for a task, trying event_adapter map first, then task.assigned_to."""
|
||
agent_id = self.event_adapter._resolve_agent_from_task(task_id)
|
||
if agent_id:
|
||
return agent_id
|
||
# Fallback: resolve from task's assigned_to (opc_role_id → UI agent_id)
|
||
assigned_to = str(getattr(task, "assigned_to", "") or "").strip() if task else ""
|
||
if assigned_to:
|
||
return self.event_adapter._resolve_role_to_agent(assigned_to)
|
||
return None
|
||
|
||
@staticmethod
|
||
def _normalize_session_detail_timestamp(value: Any) -> float | None:
|
||
try:
|
||
numeric = float(value)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
if not numeric:
|
||
return None
|
||
if numeric > 1_000_000_000_000:
|
||
numeric /= 1000.0
|
||
return numeric
|
||
|
||
@staticmethod
|
||
def _message_visible_in_detail_level(message: dict[str, Any], detail_level: str) -> bool:
|
||
metadata = dict(message.get("metadata", {}) or {})
|
||
return rendered_transcript_metadata_visible(metadata, detail_level=detail_level)
|
||
|
||
@classmethod
|
||
def _filter_ui_messages_for_detail_level(
|
||
cls,
|
||
messages: list[dict[str, Any]],
|
||
detail_level: str,
|
||
) -> list[dict[str, Any]]:
|
||
return [
|
||
message
|
||
for message in messages
|
||
if cls._message_visible_in_detail_level(message, detail_level)
|
||
]
|
||
|
||
async def _load_session_transcript_page(
|
||
self,
|
||
task: Any,
|
||
*,
|
||
limit: int,
|
||
detail_level: str = "summary",
|
||
before_timestamp: float | None = None,
|
||
before_message_id: str | None = None,
|
||
engine: Any | None = None,
|
||
) -> tuple[list[dict[str, Any]], int, bool]:
|
||
"""Load a transcript page for the requested detail level."""
|
||
runtime_engine = engine or self.engine
|
||
store = runtime_engine.store
|
||
if not self._store_is_ready(store):
|
||
return [], 0, False
|
||
|
||
task_id = str(getattr(task, "id", "") or "").strip()
|
||
session_id = str(getattr(task, "session_id", "") or "").strip()
|
||
if not task_id or not session_id:
|
||
return [], 0, False
|
||
|
||
channel_id = f"session:{task_id}"
|
||
page_loader = getattr(store, "get_session_transcript_page", None)
|
||
if callable(page_loader):
|
||
normalized_limit = max(1, min(int(limit), 500))
|
||
normalized_detail_level = _normalize_transcript_detail_level(detail_level)
|
||
# A database-visible transcript row can still disappear when the
|
||
# renderer finds no content, and adjacent result surfaces can
|
||
# collapse to one UI row. Page raw rows in bounded chunks until
|
||
# we have one *rendered* look-ahead row or exhaust the transcript.
|
||
# This makes has_more describe the UI timeline rather than the SQL
|
||
# row set and prevents an empty raw page from stalling history.
|
||
chunk_limit = normalized_limit
|
||
raw_before_dt = (
|
||
datetime.fromtimestamp(before_timestamp)
|
||
if before_timestamp is not None
|
||
else None
|
||
)
|
||
raw_before_id = before_message_id
|
||
seen_raw_cursors: set[tuple[datetime, str]] = set()
|
||
formatted_messages: list[dict[str, Any]] = []
|
||
total_count = 0
|
||
raw_has_more = False
|
||
|
||
while True:
|
||
raw_page = page_loader(
|
||
session_id,
|
||
limit=chunk_limit,
|
||
before_created_at=raw_before_dt,
|
||
before_message_id=raw_before_id,
|
||
detail_level=normalized_detail_level,
|
||
)
|
||
page = await raw_page if inspect.isawaitable(raw_page) else raw_page
|
||
transcript_chunk = list((page or {}).get("messages", []) or [])
|
||
total_count = max(
|
||
total_count,
|
||
int((page or {}).get("total_count", 0) or 0),
|
||
)
|
||
raw_has_more = bool((page or {}).get("has_more", False))
|
||
if not transcript_chunk:
|
||
break
|
||
|
||
formatted_chunk = build_transcript_ui_messages(
|
||
transcript_chunk,
|
||
channel_id=channel_id,
|
||
task_id=task_id,
|
||
detail_level=normalized_detail_level,
|
||
)
|
||
formatted_messages = collapse_adjacent_transcript_duplicates([
|
||
*formatted_chunk,
|
||
*formatted_messages,
|
||
])
|
||
if len(formatted_messages) > normalized_limit:
|
||
return (
|
||
formatted_messages[-normalized_limit:],
|
||
max(total_count, len(formatted_messages)),
|
||
True,
|
||
)
|
||
if not raw_has_more:
|
||
break
|
||
|
||
oldest_message = transcript_chunk[0].get("message")
|
||
oldest_created_at = getattr(oldest_message, "created_at", None)
|
||
oldest_message_id = str(
|
||
getattr(oldest_message, "message_id", "") or ""
|
||
).strip()
|
||
if not isinstance(oldest_created_at, datetime) or not oldest_message_id:
|
||
break
|
||
next_cursor = (oldest_created_at, oldest_message_id)
|
||
if next_cursor in seen_raw_cursors:
|
||
break
|
||
seen_raw_cursors.add(next_cursor)
|
||
raw_before_dt, raw_before_id = next_cursor
|
||
# Small client pages should not require one SQL round-trip per
|
||
# empty row. Start at the requested size so duplicate/empty
|
||
# boundaries are exact, then grow only while looking through
|
||
# rows which did not fill the rendered page.
|
||
chunk_limit = min(500, max(chunk_limit + 1, chunk_limit * 2))
|
||
|
||
return (
|
||
formatted_messages[-normalized_limit:],
|
||
max(total_count, len(formatted_messages)),
|
||
raw_has_more,
|
||
)
|
||
|
||
transcript_loader = getattr(store, "get_session_transcript", None)
|
||
if not callable(transcript_loader):
|
||
return [], 0, False
|
||
|
||
raw_transcript = transcript_loader(session_id)
|
||
transcript = list(await raw_transcript if inspect.isawaitable(raw_transcript) else raw_transcript)
|
||
formatted_messages = build_transcript_ui_messages(
|
||
transcript,
|
||
channel_id=channel_id,
|
||
task_id=task_id,
|
||
detail_level=_normalize_transcript_detail_level(detail_level),
|
||
)
|
||
|
||
total_count = len(formatted_messages)
|
||
if before_timestamp is None:
|
||
has_more = total_count > limit
|
||
return formatted_messages[-limit:], total_count, has_more
|
||
|
||
filtered_messages: list[dict[str, Any]] = []
|
||
normalized_before_id = str(before_message_id or "").strip()
|
||
for message in formatted_messages:
|
||
created_at = float(message.get("timestamp") or 0)
|
||
message_id = str(message.get("message_id", "") or "").strip()
|
||
if created_at < before_timestamp:
|
||
filtered_messages.append(message)
|
||
continue
|
||
if (
|
||
normalized_before_id
|
||
and created_at == before_timestamp
|
||
and message_id < normalized_before_id
|
||
):
|
||
filtered_messages.append(message)
|
||
has_more = len(filtered_messages) > limit
|
||
return filtered_messages[-limit:], total_count, has_more
|
||
|
||
async def _sync_role_map(self) -> None:
|
||
"""Sync opc_role_id → agent_id mapping to EventAdapter."""
|
||
role_map = await self.agent_store.get_role_agent_map()
|
||
self.event_adapter.update_role_map(role_map)
|
||
|
||
async def restore_persisted_mode(self) -> None:
|
||
"""Restore exec_mode, company_profile, and task preferred agent from DB on startup."""
|
||
self._exec_mode = self._normalize_session_exec_mode(
|
||
await self.agent_store.get_server_state("exec_mode", "task")
|
||
)
|
||
self._company_profile = self._normalize_session_company_profile(
|
||
await self.agent_store.get_server_state("company_profile", "corporate")
|
||
)
|
||
if self._exec_mode == "org":
|
||
self._company_profile = "custom"
|
||
self._task_preferred_agent = self._normalize_session_preferred_agent(
|
||
await self.agent_store.get_server_state("task_preferred_agent", "native"),
|
||
)
|
||
|
||
# Sync in-memory org architecture to match restored exec_mode.
|
||
# Do not save during startup restore: disk files are the source of truth.
|
||
if self.engine.org_engine:
|
||
async with self._config_lock:
|
||
if self._exec_mode in {"org", "custom"}:
|
||
self.engine.config.org.company_profile = "custom"
|
||
self.engine.org_engine.config = self.engine.config
|
||
self.engine.org_engine.reload_from_config()
|
||
elif self._exec_mode == "company":
|
||
self._restore_company_config_into_engine(self._company_profile)
|
||
else:
|
||
self._restore_company_config_into_engine("")
|
||
if self._exec_mode in {"org", "custom"}:
|
||
await self._restore_active_saved_org_if_needed()
|
||
if hasattr(self, "services_context"):
|
||
self.services_context.mode_state.exec_mode = self._exec_mode
|
||
self.services_context.mode_state.company_profile = self._company_profile
|
||
self.services_context.mode_state.task_preferred_agent = self._task_preferred_agent
|
||
|
||
async def _persist_mode(self) -> None:
|
||
"""Save current exec_mode, company_profile, and task preferred agent to DB."""
|
||
await self.agent_store.set_server_state("exec_mode", self._exec_mode)
|
||
await self.agent_store.set_server_state("company_profile", self._company_profile)
|
||
await self.agent_store.set_server_state("task_preferred_agent", self._task_preferred_agent)
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# Connection lifecycle
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
async def handle_ws(self, request: aiohttp.web.Request) -> aiohttp.web.WebSocketResponse:
|
||
"""Handle a WebSocket connection."""
|
||
import aiohttp.web as web
|
||
ws = web.WebSocketResponse()
|
||
await ws.prepare(request)
|
||
if bool(getattr(self, "_shutting_down", False)):
|
||
await ws.close()
|
||
return ws
|
||
self._clients.add(ws)
|
||
self._ensure_progress_flush_loop()
|
||
self._ensure_runtime_status_sync_loop()
|
||
logger.info(f"WS client connected ({len(self._clients)} total)")
|
||
|
||
try:
|
||
# Ensure role→agent mapping is current
|
||
await self._sync_role_map()
|
||
|
||
# Send initial snapshot
|
||
initial_project_id = self._active_engine_project_id()
|
||
self._client_project_ids[ws] = initial_project_id
|
||
snapshot = await build_snapshot(
|
||
self.engine, self.agent_store, self.chat_store, self.event_adapter
|
||
)
|
||
snapshot["project_id"] = initial_project_id
|
||
snapshot["exec_mode"] = self._exec_mode
|
||
snapshot["company_profile"] = self._company_profile
|
||
snapshot["task_preferred_agent"] = self._task_preferred_agent
|
||
if not await self._safe_send_json(ws, {"type": "snapshot", "payload": snapshot}):
|
||
return ws
|
||
self._track_client_initial_state(
|
||
ws,
|
||
self._send_initial_project_state_for_client(
|
||
ws,
|
||
self.engine,
|
||
initial_project_id,
|
||
),
|
||
)
|
||
|
||
# Process messages
|
||
async for msg in ws:
|
||
if msg.type == 1: # aiohttp.WSMsgType.TEXT
|
||
await self._route_message(ws, msg.data)
|
||
elif msg.type == 2: # BINARY
|
||
pass
|
||
elif msg.type == 8: # ERROR
|
||
logger.warning(f"WS error: {ws.exception()}")
|
||
except Exception as e:
|
||
if self._is_expected_shutdown_error(e) or self._is_ws_disconnect_error(e):
|
||
logger.debug(f"WS handler closed during disconnect/shutdown: {type(e).__name__}: {e!r}")
|
||
else:
|
||
logger.error(f"WS handler error: {e}")
|
||
finally:
|
||
self._clients.discard(ws)
|
||
try:
|
||
self._client_project_ids.pop(ws, None)
|
||
self._client_switch_seq.pop(ws, None)
|
||
index_task = self._client_project_index_tasks.pop(ws, None)
|
||
if index_task is not None and not index_task.done():
|
||
index_task.cancel()
|
||
initial_task = self._client_initial_state_tasks.pop(ws, None)
|
||
if initial_task is not None and not initial_task.done():
|
||
initial_task.cancel()
|
||
except TypeError:
|
||
pass
|
||
logger.info(f"WS client disconnected ({len(self._clients)} total)")
|
||
|
||
return ws
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# Outbound: OPC → Frontend
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
async def broadcast(self, envelope: dict[str, Any]) -> None:
|
||
"""Broadcast an envelope to all connected clients."""
|
||
if not self._clients:
|
||
return
|
||
async with self._broadcast_lock:
|
||
self._broadcast_seq += 1
|
||
prepared = self._prepare_outbound_envelope(envelope)
|
||
if prepared is None:
|
||
return
|
||
envelope = prepared
|
||
envelope["_seq"] = self._broadcast_seq
|
||
data = json.dumps(envelope, default=str)
|
||
envelope_type = str(envelope.get("type", "") or "")
|
||
envelope_project_id = self._payload_project_id(envelope.get("payload"))
|
||
if not envelope_project_id:
|
||
envelope_project_id = str(envelope.get("project_id", "") or "").strip()
|
||
# Snapshot client set to avoid iteration-during-mutation race
|
||
clients = set(self._clients)
|
||
disconnected = set()
|
||
for ws in clients:
|
||
if (
|
||
envelope_type in _PROJECT_SCOPED_ENVELOPE_TYPES
|
||
and envelope_project_id
|
||
and self._client_active_project_id(ws) != self._normalize_project_id(envelope_project_id)
|
||
):
|
||
continue
|
||
try:
|
||
await ws.send_str(data)
|
||
except Exception:
|
||
disconnected.add(ws)
|
||
self._clients -= disconnected
|
||
|
||
def _prepare_outbound_envelope(self, envelope: dict[str, Any]) -> dict[str, Any] | None:
|
||
envelope_type = str(envelope.get("type", "") or "")
|
||
explicit_project_id = str(envelope.get("project_id", "") or "").strip()
|
||
payload = envelope.get("payload")
|
||
if isinstance(payload, dict):
|
||
payload_project_id = self._payload_project_id(payload)
|
||
final_project_id = explicit_project_id or payload_project_id
|
||
if envelope_type in _PROJECT_SCOPED_ENVELOPE_TYPES and not final_project_id:
|
||
logger.warning(
|
||
"Dropping project-scoped UI envelope without project_id: type={}",
|
||
envelope_type,
|
||
)
|
||
return None
|
||
if final_project_id and payload_project_id != final_project_id:
|
||
payload = {**payload, "project_id": final_project_id}
|
||
envelope = {**envelope, "payload": payload}
|
||
elif envelope_type in _PROJECT_SCOPED_ENVELOPE_TYPES:
|
||
final_project_id = explicit_project_id
|
||
if not final_project_id:
|
||
logger.warning(
|
||
"Dropping project-scoped UI envelope without payload/project_id: type={}",
|
||
envelope_type,
|
||
)
|
||
return None
|
||
if envelope.get("type") in {"session_message", "chat_new_message"}:
|
||
payload = envelope.get("payload")
|
||
if isinstance(payload, dict):
|
||
envelope = {
|
||
**envelope,
|
||
"payload": _sanitize_ui_message_dict(payload),
|
||
}
|
||
return envelope
|
||
|
||
async def _send_envelope_to_client(self, ws: Any, envelope: dict[str, Any]) -> bool:
|
||
async with self._broadcast_lock:
|
||
self._broadcast_seq += 1
|
||
prepared = self._prepare_outbound_envelope(envelope)
|
||
if prepared is None:
|
||
return False
|
||
prepared["_seq"] = self._broadcast_seq
|
||
return await self._safe_send_json(ws, prepared)
|
||
|
||
async def _canonicalize_runtime_visual_event(
|
||
self,
|
||
visual_event: dict[str, Any],
|
||
*,
|
||
engine: Any | None = None,
|
||
) -> dict[str, Any]:
|
||
payload = dict(visual_event.get("data", {}) or {})
|
||
raw_runtime_task_id = str(payload.get("task_id", "") or "").strip()
|
||
mapped_task_id = await self._ui_task_id_for_runtime_task_id(raw_runtime_task_id, engine=engine)
|
||
if mapped_task_id:
|
||
payload["task_id"] = mapped_task_id
|
||
if raw_runtime_task_id and mapped_task_id != raw_runtime_task_id:
|
||
payload.setdefault("runtime_task_id", raw_runtime_task_id)
|
||
_add_execution_turn_aliases(payload, raw_runtime_task_id or mapped_task_id)
|
||
if not str(payload.get("turn_id", "") or "").strip():
|
||
runtime_session_id = str(payload.get("runtime_session_id", "") or "").strip()
|
||
iteration = payload.get("iteration")
|
||
if runtime_session_id and iteration not in (None, "", [], {}):
|
||
payload["turn_id"] = f"{runtime_session_id}:{iteration}"
|
||
return {
|
||
**visual_event,
|
||
"data": payload,
|
||
}
|
||
|
||
@staticmethod
|
||
def _assistant_delta_key(payload: dict[str, Any], *, delta_type: str = "assistant_delta") -> tuple[str, str, str] | None:
|
||
task_id = str(payload.get("task_id", "") or "").strip()
|
||
if not task_id:
|
||
return None
|
||
turn_id = str(
|
||
payload.get("turn_id")
|
||
or payload.get("canonical_turn_id")
|
||
or payload.get("execution_turn_id")
|
||
or payload.get("runtime_task_id")
|
||
or "active"
|
||
).strip() or "active"
|
||
item_id = str(
|
||
payload.get("item_id")
|
||
or payload.get("stream_id")
|
||
or delta_type
|
||
).strip() or delta_type
|
||
return task_id, turn_id, item_id
|
||
|
||
async def _delayed_flush_assistant_delta(self, key: tuple[str, str, str]) -> None:
|
||
try:
|
||
await asyncio.sleep(self._ASSISTANT_DELTA_FLUSH_INTERVAL_SEC)
|
||
await self._flush_assistant_delta(key)
|
||
finally:
|
||
current = asyncio.current_task()
|
||
if self._assistant_delta_flush_tasks.get(key) is current:
|
||
self._assistant_delta_flush_tasks.pop(key, None)
|
||
|
||
async def _flush_assistant_delta(self, key: tuple[str, str, str]) -> None:
|
||
bucket = self._assistant_delta_buffers.pop(key, None)
|
||
pending = self._assistant_delta_flush_tasks.pop(key, None)
|
||
current = asyncio.current_task()
|
||
if pending is not None and pending is not current and not pending.done():
|
||
pending.cancel()
|
||
if not bucket:
|
||
return
|
||
text = str(bucket.get("text", "") or "")
|
||
if not text:
|
||
return
|
||
visual_event = dict(bucket.get("event", {}) or {})
|
||
payload = dict(visual_event.get("data", {}) or {})
|
||
self._assistant_delta_seq += 1
|
||
payload["text"] = text
|
||
payload.setdefault("seq", self._assistant_delta_seq)
|
||
visual_event["data"] = payload
|
||
await self.broadcast({"type": "event", "payload": visual_event})
|
||
|
||
async def _flush_assistant_delta_for_payload(self, payload: dict[str, Any]) -> None:
|
||
task_id = str(payload.get("task_id", "") or "").strip()
|
||
if not task_id:
|
||
return
|
||
turn_id = str(
|
||
payload.get("turn_id")
|
||
or payload.get("canonical_turn_id")
|
||
or payload.get("execution_turn_id")
|
||
or payload.get("runtime_task_id")
|
||
or ""
|
||
).strip()
|
||
keys = [
|
||
key for key in list(self._assistant_delta_buffers)
|
||
if key[0] == task_id and (not turn_id or key[1] == turn_id)
|
||
]
|
||
for key in keys:
|
||
await self._flush_assistant_delta(key)
|
||
|
||
async def _queue_assistant_delta_visual_event(self, visual_event: dict[str, Any]) -> None:
|
||
payload = dict(visual_event.get("data", {}) or {})
|
||
text = str(payload.get("text", "") or "")
|
||
if not text:
|
||
return
|
||
delta_type = str(visual_event.get("type", "") or "assistant_delta").strip() or "assistant_delta"
|
||
key = self._assistant_delta_key(payload, delta_type=delta_type)
|
||
if key is None:
|
||
await self.broadcast({"type": "event", "payload": visual_event})
|
||
return
|
||
bucket = self._assistant_delta_buffers.setdefault(
|
||
key,
|
||
{
|
||
"event": {
|
||
**visual_event,
|
||
"data": {
|
||
**payload,
|
||
"text": "",
|
||
},
|
||
},
|
||
"text": "",
|
||
},
|
||
)
|
||
bucket["event"] = {
|
||
**visual_event,
|
||
"data": {
|
||
**payload,
|
||
"text": "",
|
||
},
|
||
}
|
||
bucket["text"] = f"{bucket.get('text', '')}{text}"
|
||
if "\n" in text:
|
||
await self._flush_assistant_delta(key)
|
||
return
|
||
pending = self._assistant_delta_flush_tasks.get(key)
|
||
if pending is None or pending.done():
|
||
self._assistant_delta_flush_tasks[key] = asyncio.create_task(
|
||
self._delayed_flush_assistant_delta(key),
|
||
name=f"assistant-delta-flush:{key[0]}:{key[1]}:{key[2]}",
|
||
)
|
||
|
||
async def _broadcast_runtime_visual_event(self, visual_event: dict[str, Any]) -> None:
|
||
payload = dict(visual_event.get("data", {}) or {})
|
||
runtime_type = str(visual_event.get("type", "") or "").strip()
|
||
if runtime_type in {"assistant_delta", "thinking_delta"}:
|
||
await self._queue_assistant_delta_visual_event(visual_event)
|
||
return
|
||
if runtime_type in {"turn_completed", "turn_failed", "checkpoint_saved"}:
|
||
await self._flush_assistant_delta_for_payload(payload)
|
||
await self.broadcast({"type": "event", "payload": visual_event})
|
||
|
||
async def on_opc_event(
|
||
self,
|
||
event: Any,
|
||
*,
|
||
runtime_engine: Any | None = None,
|
||
project_id: str | None = None,
|
||
) -> None:
|
||
"""EventBus subscriber. Translates OPC events and broadcasts."""
|
||
engine = runtime_engine or self.engine
|
||
pid = self._normalize_project_id(project_id or getattr(engine, "project_id", None))
|
||
if event.event_type == "runtime_event":
|
||
runtime_payload = dict(event.payload or {})
|
||
runtime_type = str(runtime_payload.get("type", "") or "").strip()
|
||
if runtime_type in _RUNTIME_TASK_VISIBILITY_EVENT_TYPES:
|
||
await self._materialize_runtime_task_visibility(
|
||
runtime_payload,
|
||
engine=engine,
|
||
project_id=pid,
|
||
)
|
||
|
||
visual_events = self.event_adapter.translate(event)
|
||
for ve in visual_events:
|
||
ve = dict(ve)
|
||
ve["project_id"] = pid
|
||
ve_data = dict(ve.get("data", {}) or {})
|
||
ve_data.setdefault("project_id", pid)
|
||
ve["data"] = ve_data
|
||
if ve.get("type") == "board_task_status_changed":
|
||
# Kanban status update: broadcast as dedicated message type
|
||
payload = dict(ve.get("data", {}) or {})
|
||
raw_runtime_task_id = str(payload.get("task_id", "") or "").strip()
|
||
mapped_task_id = await self._ui_task_id_for_runtime_task_id(raw_runtime_task_id, engine=engine)
|
||
if mapped_task_id:
|
||
payload["task_id"] = mapped_task_id
|
||
if mapped_task_id != raw_runtime_task_id:
|
||
payload.setdefault("work_item_id", mapped_task_id)
|
||
_add_execution_turn_aliases(payload, raw_runtime_task_id)
|
||
await self.broadcast({
|
||
"type": "board_task_status_changed",
|
||
"payload": payload,
|
||
})
|
||
elif ve.get("type") == "execution_mode_resolved":
|
||
await self.broadcast({
|
||
"type": "execution_mode_resolved",
|
||
"payload": ve.get("data", {}),
|
||
})
|
||
elif ve.get("type") == "agent_runtime_update":
|
||
payload = dict(ve.get("data", {}) or {})
|
||
raw_runtime_task_id = str(payload.get("task_id", "") or "").strip()
|
||
mapped_task_id = await self._ui_task_id_for_runtime_task_id(raw_runtime_task_id, engine=engine)
|
||
if mapped_task_id:
|
||
payload["task_id"] = mapped_task_id
|
||
if mapped_task_id != raw_runtime_task_id:
|
||
payload.setdefault("work_item_id", mapped_task_id)
|
||
_add_execution_turn_aliases(payload, raw_runtime_task_id)
|
||
await self.broadcast({
|
||
"type": "agent_runtime_update",
|
||
"payload": payload,
|
||
})
|
||
elif ve.get("type") == "child_session_created":
|
||
payload = dict(ve.get("data", {}) or {})
|
||
if event.event_type == "child_session_created" and self._store_is_ready(engine.store):
|
||
task_id = str(payload.get("task_id", "") or "").strip()
|
||
if task_id:
|
||
try:
|
||
created_task = await engine.store.get_task(task_id)
|
||
except Exception:
|
||
created_task = None
|
||
if created_task is not None:
|
||
payload.setdefault(
|
||
"selected_execution_agent",
|
||
self._resolve_task_selected_execution_agent(created_task),
|
||
)
|
||
# Enrich with role/employee metadata from task
|
||
meta = created_task.metadata or {}
|
||
identity_payload = (
|
||
{}
|
||
if self._runtime_payload_is_task_mode(dict(meta or {}))
|
||
else work_item_identity_payload(
|
||
projection_id=str(meta.get("work_item_projection_id", "") or "").strip(),
|
||
turn_type=str(meta.get("work_item_turn_type", "") or "").strip(),
|
||
)
|
||
)
|
||
for _key, _value in identity_payload.items():
|
||
if _key not in payload and _value:
|
||
payload[_key] = _value
|
||
for _key in (
|
||
"work_item_role_id", "work_item_role_name",
|
||
"employee_assignment", "origin_task_id",
|
||
):
|
||
if _key not in payload and meta.get(_key):
|
||
payload[_key] = meta[_key]
|
||
role_id = str(
|
||
payload.get("work_item_role_id")
|
||
or getattr(created_task, "assigned_to", "")
|
||
or ""
|
||
).strip()
|
||
if role_id and not str(payload.get("work_item_role_name", "") or "").strip():
|
||
payload["work_item_role_name"] = self._resolve_work_item_role_name(
|
||
role_id,
|
||
meta,
|
||
engine=engine,
|
||
)
|
||
_add_execution_turn_aliases(payload, task_id)
|
||
await self.broadcast({
|
||
"type": "child_session_created",
|
||
"payload": payload,
|
||
})
|
||
else:
|
||
if event.event_type == "runtime_event":
|
||
ve = await self._canonicalize_runtime_visual_event(ve, engine=engine)
|
||
await self._broadcast_runtime_visual_event(ve)
|
||
else:
|
||
await self.broadcast({"type": "event", "payload": ve})
|
||
|
||
if event.event_type == "runtime_event":
|
||
runtime_payload = dict(event.payload or {})
|
||
if str(runtime_payload.get("type", "") or "").strip() == "worker_notification":
|
||
await self._handle_worker_notification(runtime_payload, engine=engine, project_id=pid)
|
||
await self._handle_runtime_event_progress(event.payload or {}, engine=engine, project_id=pid)
|
||
|
||
# Create chat_store channel for child sessions (so messages can render)
|
||
if event.event_type == "child_session_created":
|
||
p = event.payload or {}
|
||
task_id = p.get("task_id", "")
|
||
title = p.get("title", "Sub-task")
|
||
if task_id:
|
||
channel_id = f"session:{task_id}"
|
||
exec_mode = self._normalize_session_exec_mode(self._exec_mode)
|
||
company_profile = self._normalize_session_company_profile(self._company_profile)
|
||
org_id = ""
|
||
preferred_agent = self._task_preferred_agent
|
||
parent_session_id = str(p.get("parent_session_id") or "").strip()
|
||
parent_task_id = (
|
||
str(self._session_to_task.get(parent_session_id) or "").strip()
|
||
or parent_session_id
|
||
)
|
||
if parent_task_id and self._store_is_ready(engine.store):
|
||
try:
|
||
parent_task = await engine.store.get_task(parent_task_id)
|
||
except Exception:
|
||
parent_task = None
|
||
exec_mode, company_profile = self._resolve_task_session_config(parent_task)
|
||
org_id = self._resolve_task_org_id(parent_task)
|
||
preferred_agent = self._resolve_task_preferred_agent(parent_task)
|
||
work_item_identity: dict[str, Any] = {}
|
||
if self._store_is_ready(engine.store):
|
||
try:
|
||
created_task = await engine.store.get_task(task_id)
|
||
except Exception:
|
||
created_task = None
|
||
if created_task is not None:
|
||
created_meta = dict(getattr(created_task, "metadata", {}) or {})
|
||
role_id = str(created_meta.get("work_item_role_id", "") or getattr(created_task, "assigned_to", "") or "").strip()
|
||
role_name = str(created_meta.get("work_item_role_name", "") or "").strip()
|
||
if not role_name and role_id:
|
||
role_name = self._resolve_work_item_role_name(
|
||
role_id,
|
||
created_meta,
|
||
engine=engine,
|
||
)
|
||
if self._runtime_payload_is_task_mode(created_meta):
|
||
work_item_identity = {
|
||
"employee_assignment": created_meta.get("employee_assignment"),
|
||
"origin_task_id": created_meta.get("origin_task_id") or parent_task_id or task_id,
|
||
"selected_execution_agent": self._resolve_task_selected_execution_agent(created_task),
|
||
}
|
||
else:
|
||
projection_id = work_item_projection_id_from_metadata(created_meta)
|
||
turn_type = work_item_turn_type_from_metadata(created_meta, fallback="")
|
||
work_item_identity = {
|
||
**work_item_identity_payload(projection_id=projection_id, turn_type=turn_type),
|
||
"work_item_role_id": role_id,
|
||
"work_item_role_name": role_name,
|
||
"employee_assignment": created_meta.get("employee_assignment"),
|
||
"origin_task_id": created_meta.get("origin_task_id") or parent_task_id or task_id,
|
||
"selected_execution_agent": self._resolve_task_selected_execution_agent(created_task),
|
||
}
|
||
preferred_agent = self._resolve_task_preferred_agent(created_task)
|
||
org_id = self._resolve_task_org_id(created_task) or org_id
|
||
await self.chat_store.create_session_channel(task_id, title, project_id=pid)
|
||
# Display counter already incremented by task_created event — use map lookup
|
||
display_num = self.event_adapter.get_task_display_num(task_id)
|
||
display_id = f"OPC-{display_num}"
|
||
execution_aliases = _add_execution_turn_aliases({}, task_id)
|
||
# Child company runtime sessions remain in the session sidebar but do not
|
||
# become company-mode kanban cards.
|
||
if not parent_session_id:
|
||
await self.broadcast({"type": "board_task_created", "payload": {
|
||
"project_id": pid,
|
||
"task_id": task_id,
|
||
**execution_aliases,
|
||
"display_id": display_id,
|
||
"board_id": pid,
|
||
"title": title,
|
||
# Engine event agent_id is opc_role_id; resolve to UI agent_id
|
||
"assignee_ids": [self.event_adapter._resolve_role_to_agent(p["agent_id"])] if p.get("agent_id") else [],
|
||
**work_item_identity,
|
||
}})
|
||
await self.broadcast({"type": "session_created", "payload": {
|
||
"project_id": pid,
|
||
"task_id": task_id,
|
||
**execution_aliases,
|
||
"channel_id": channel_id,
|
||
"session_id": p.get("session_id"),
|
||
"parent_session_id": p.get("parent_session_id"),
|
||
"origin_task_id": work_item_identity.get("origin_task_id") or parent_task_id or task_id,
|
||
"exec_mode": exec_mode,
|
||
"company_profile": company_profile,
|
||
"org_id": org_id,
|
||
"preferred_agent": preferred_agent,
|
||
"title": title,
|
||
"status": "pending",
|
||
"created_at": time.time(),
|
||
**work_item_identity,
|
||
}})
|
||
|
||
# Update agent_store status for persistence (match by opc_role_id)
|
||
if event.event_type == "agent_status_changed":
|
||
await self.agent_store.update_status_by_role(
|
||
event.payload.get("role_id", ""),
|
||
event.payload.get("status", ""),
|
||
)
|
||
|
||
# Mirror agent messages to chat
|
||
if event.event_type == "agent_message_sent":
|
||
await self._mirror_agent_message(event, engine=engine, project_id=pid)
|
||
# Tell every connected client that the comms tree just changed
|
||
# so the CommsPanel can refetch immediately instead of waiting
|
||
# for its 5s polling tick. Cheap fire-and-forget broadcast.
|
||
try:
|
||
await self.broadcast({
|
||
"type": "comms_state_dirty",
|
||
"payload": {
|
||
"project_id": pid,
|
||
"from": (event.payload or {}).get("from"),
|
||
"to": (event.payload or {}).get("to"),
|
||
},
|
||
})
|
||
except Exception:
|
||
pass
|
||
|
||
# Mirror escalations to chat
|
||
if event.event_type == "escalation_created":
|
||
await self._mirror_escalation(event, engine=engine, project_id=pid)
|
||
if event.event_type in {"escalation_resolved", "escalation_timeout"}:
|
||
await self._mark_escalation_event_checkpoint_terminal(event, project_id=pid)
|
||
|
||
if event.event_type == "task_status_changed":
|
||
payload = event.payload or {}
|
||
task_id = str(payload.get("task_id", "") or "").strip()
|
||
status = str(payload.get("status", "") or "").strip().lower()
|
||
if task_id and status in {
|
||
"done",
|
||
"failed",
|
||
"cancelled",
|
||
"blocked",
|
||
"awaiting_manager_review",
|
||
"awaiting_human",
|
||
"awaiting_review",
|
||
"awaiting_peer",
|
||
}:
|
||
await self._sync_task_transcript_messages(task_id, engine=engine)
|
||
if self._store_is_ready(engine.store):
|
||
task = await engine.store.get_task(task_id)
|
||
if task is not None:
|
||
for parent_task_id in self._related_parent_task_ids(task):
|
||
await self._sync_task_transcript_messages(parent_task_id, engine=engine)
|
||
|
||
async def _handle_runtime_event_progress(
|
||
self,
|
||
payload: dict[str, Any],
|
||
*,
|
||
engine: Any | None = None,
|
||
project_id: str | None = None,
|
||
) -> None:
|
||
runtime_engine = engine or self.engine
|
||
pid = self._normalize_project_id(project_id or getattr(runtime_engine, "project_id", None))
|
||
payload = self._enrich_runtime_progress_payload(payload, engine=runtime_engine)
|
||
raw_task_id = str(payload.get("task_id", "") or "").strip()
|
||
if not raw_task_id:
|
||
return
|
||
task_id = await self._ui_task_id_for_runtime_task_id(raw_task_id, engine=runtime_engine)
|
||
if not task_id:
|
||
return
|
||
runtime_type = str(payload.get("type", "") or "").strip()
|
||
entry = self._runtime_event_to_progress_entry(payload)
|
||
if not entry:
|
||
if runtime_type in {"turn_completed", "turn_failed", "checkpoint_saved"}:
|
||
await self._sync_task_transcript_messages(task_id, engine=runtime_engine)
|
||
if self._store_is_ready(runtime_engine.store):
|
||
task = await runtime_engine.store.get_task(raw_task_id)
|
||
if task is not None:
|
||
for parent_task_id in self._related_parent_task_ids(task):
|
||
await self._sync_task_transcript_messages(parent_task_id, engine=runtime_engine)
|
||
return
|
||
entry["timestamp"] = time.time()
|
||
_add_execution_turn_aliases(entry, raw_task_id)
|
||
# Buffer BEFORE broadcasting: broadcast awaits can interleave a
|
||
# session_detail read, and any entry a client has already seen must be
|
||
# visible in buffer∪DB or the snapshot will erase it from the live log.
|
||
buf = self._progress_buffer.setdefault(task_id, [])
|
||
buf.append(entry)
|
||
self._progress_project_ids[task_id] = pid
|
||
await self.broadcast({
|
||
"type": "session_progress",
|
||
"payload": {
|
||
"project_id": pid,
|
||
"task_id": task_id,
|
||
**_add_execution_turn_aliases({}, raw_task_id),
|
||
"entry": entry,
|
||
},
|
||
})
|
||
origin = self._active_runtime_children.get(raw_task_id) or (task_id if task_id != raw_task_id else None)
|
||
if entry.get("is_company_runtime") and origin:
|
||
await self.broadcast({
|
||
"type": "work_item_progress",
|
||
"payload": {
|
||
"project_id": pid,
|
||
"task_id": origin,
|
||
**_add_execution_turn_aliases({}, raw_task_id),
|
||
"entry": entry,
|
||
},
|
||
})
|
||
# Re-read the buffer: a concurrent flush during the broadcast awaits
|
||
# may have popped it, leaving `buf` as a stale detached list.
|
||
if len(self._progress_buffer.get(task_id, [])) >= self._PROGRESS_FLUSH_THRESHOLD:
|
||
await self._flush_progress(task_id, project_id=pid)
|
||
if runtime_type in {"turn_completed", "turn_failed", "checkpoint_saved"}:
|
||
await self._sync_task_transcript_messages(task_id, engine=runtime_engine)
|
||
if self._store_is_ready(runtime_engine.store):
|
||
task = await runtime_engine.store.get_task(raw_task_id)
|
||
if task is not None:
|
||
for parent_task_id in self._related_parent_task_ids(task):
|
||
await self._sync_task_transcript_messages(parent_task_id, engine=runtime_engine)
|
||
|
||
async def _handle_worker_notification(
|
||
self,
|
||
payload: dict[str, Any],
|
||
*,
|
||
engine: Any | None = None,
|
||
project_id: str | None = None,
|
||
) -> None:
|
||
runtime_engine = engine or self.engine
|
||
remapped = dict(payload or {})
|
||
mapped_task_id = await self._ui_task_id_for_runtime_task_id(remapped.get("task_id"), engine=runtime_engine)
|
||
if mapped_task_id:
|
||
remapped["task_id"] = mapped_task_id
|
||
remapped["project_id"] = self._normalize_project_id(project_id or getattr(runtime_engine, "project_id", None))
|
||
await self.broadcast({
|
||
"type": "worker_notification",
|
||
"payload": remapped,
|
||
})
|
||
await self._persist_worker_notification_message(payload, engine=runtime_engine, project_id=project_id)
|
||
|
||
async def _persist_worker_notification_message(
|
||
self,
|
||
payload: dict[str, Any],
|
||
*,
|
||
engine: Any | None = None,
|
||
project_id: str | None = None,
|
||
) -> None:
|
||
runtime_engine = engine or self.engine
|
||
notification_kind = str(payload.get("notification_kind", "") or "").strip()
|
||
raw_task_id = str(payload.get("task_id", "") or "").strip()
|
||
if notification_kind not in _PERSISTED_WORKER_NOTIFICATION_KINDS or not raw_task_id:
|
||
return
|
||
task_id = await self._ui_task_id_for_runtime_task_id(raw_task_id, engine=runtime_engine)
|
||
if not task_id:
|
||
return
|
||
project_id = self._normalize_project_id(project_id or getattr(runtime_engine, "project_id", None))
|
||
title = "Task Update"
|
||
if self._store_is_ready(runtime_engine.store):
|
||
try:
|
||
task = await runtime_engine.store.get_task(raw_task_id)
|
||
except Exception:
|
||
task = None
|
||
if task is not None:
|
||
title = str(getattr(task, "title", "") or title).strip() or title
|
||
await self.chat_store.create_session_channel(task_id, title, project_id=project_id)
|
||
timestamp = float(payload.get("timestamp") or time.time())
|
||
worker_id = str(payload.get("worker_id", "") or "").strip() or "worker"
|
||
worker_type = str(payload.get("worker_type", "") or "").strip() or "worker"
|
||
message_id = (
|
||
f"worker-note:{task_id}:{worker_id}:{notification_kind}:{int(timestamp * 1000)}"
|
||
)
|
||
summary = str(payload.get("summary", "") or "").strip()
|
||
worker_name = str(payload.get("name", "") or "").strip()
|
||
sender_name = worker_name or worker_type.replace("_", " ").title()
|
||
content = summary or f"{sender_name}: {notification_kind.replace('_', ' ')}"
|
||
metadata = {
|
||
**_ui_message_identity_metadata(
|
||
kind="worker_notification",
|
||
message_id=message_id,
|
||
created_at=timestamp,
|
||
),
|
||
"source": "runtime_event",
|
||
"role": "system",
|
||
"worker_id": worker_id,
|
||
"worker_type": worker_type,
|
||
"notification_kind": notification_kind,
|
||
"resident_status": payload.get("resident_status"),
|
||
}
|
||
message = await self.chat_store.insert_message(
|
||
channel_id=f"session:{task_id}",
|
||
sender="system",
|
||
sender_name=sender_name,
|
||
content=content,
|
||
metadata=metadata,
|
||
message_id=message_id,
|
||
project_id=project_id,
|
||
)
|
||
await self.broadcast({
|
||
"type": "session_message",
|
||
"payload": message,
|
||
})
|
||
|
||
def _remember_pending_escalation(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||
escalation_id = str(payload.get("escalation_id") or f"esc_{uuid.uuid4()}")
|
||
raw_project_id = str(payload.get("project_id") or "").strip()
|
||
project_id = self._normalize_project_id(raw_project_id) if raw_project_id else ""
|
||
approval_group_key = str(payload.get("approval_group_key") or "").strip() or self._approval_group_key(
|
||
str(payload.get("message") or "")
|
||
)
|
||
existing = self._pending_escalations.get(escalation_id)
|
||
if existing is not None:
|
||
future = existing.get("future")
|
||
if future is None or future.done():
|
||
future = asyncio.get_running_loop().create_future()
|
||
record = {
|
||
**existing,
|
||
**payload,
|
||
"future": future,
|
||
"escalation_id": escalation_id,
|
||
"approval_group_key": approval_group_key,
|
||
}
|
||
if project_id:
|
||
record["project_id"] = project_id
|
||
self._pending_escalations[escalation_id] = record
|
||
if escalation_id in self._pending_escalation_order:
|
||
self._pending_escalation_order = [
|
||
item for item in self._pending_escalation_order
|
||
if item != escalation_id
|
||
]
|
||
self._pending_escalation_order.append(escalation_id)
|
||
return record
|
||
|
||
future: asyncio.Future[str | None] = asyncio.get_running_loop().create_future()
|
||
record = {
|
||
**payload,
|
||
"future": future,
|
||
"escalation_id": escalation_id,
|
||
"approval_group_key": approval_group_key,
|
||
}
|
||
if project_id:
|
||
record["project_id"] = project_id
|
||
self._pending_escalations[escalation_id] = record
|
||
self._pending_escalation_order.append(escalation_id)
|
||
return record
|
||
|
||
@staticmethod
|
||
def _approval_group_key(message: str) -> str:
|
||
raw = str(message or "").strip()
|
||
if not raw:
|
||
return ""
|
||
normalized = WSHandler._semantic_permission_group_key(raw)
|
||
if normalized:
|
||
return normalized
|
||
for line in raw.splitlines():
|
||
stripped = line.strip()
|
||
if stripped.lower().startswith("allowlist target:"):
|
||
return stripped.split(":", 1)[1].strip()
|
||
match = re.search(r"Approve\s+([a-z_]+)\s+'([^']+)'\?", raw, re.IGNORECASE)
|
||
if match:
|
||
return f"{match.group(1).lower()}:{match.group(2).strip()}"
|
||
return ""
|
||
|
||
@staticmethod
|
||
def _semantic_permission_group_key(message: str) -> str:
|
||
raw = str(message or "")
|
||
tool_match = re.search(r"Approve\s+tool\s+'([^']+)'\?", raw, re.IGNORECASE)
|
||
tool_name = tool_match.group(1).strip().casefold() if tool_match else ""
|
||
if tool_name != "shell_exec":
|
||
return ""
|
||
|
||
command_match = re.search(
|
||
r"command=(.*?)(?:\nAllowlist target:|\Z)",
|
||
raw,
|
||
re.IGNORECASE | re.DOTALL,
|
||
)
|
||
command = (command_match.group(1) if command_match else raw).strip()
|
||
command_family = ""
|
||
if re.match(r"^(?:python|python3)\b", command, re.IGNORECASE):
|
||
command_family = "python"
|
||
elif re.match(r"^node\b", command, re.IGNORECASE):
|
||
command_family = "node"
|
||
if not command_family:
|
||
return ""
|
||
|
||
domains = sorted({
|
||
match.group(1).casefold()
|
||
for match in re.finditer(r"https?://([^/\s'\"<>]+)", command)
|
||
})
|
||
domain_key = ",".join(domains) if domains else "no-domain"
|
||
return f"tool:shell_exec/{command_family}:domain:{domain_key}"
|
||
|
||
def _resolve_related_pending_escalations(
|
||
self,
|
||
record: dict[str, Any],
|
||
reply: str,
|
||
) -> list[str]:
|
||
normalized_reply = str(reply or "").strip().lower()
|
||
if normalized_reply not in {"approve_session", "always_project", "always_global"}:
|
||
return []
|
||
group_key = str(record.get("approval_group_key") or "").strip()
|
||
if not group_key:
|
||
return []
|
||
current_escalation_id = str(record.get("escalation_id") or "").strip()
|
||
task_id = str(record.get("task_id") or "").strip()
|
||
project_id = str(record.get("project_id") or "").strip()
|
||
resolved_ids: list[str] = []
|
||
for escalation_id in list(self._pending_escalation_order):
|
||
if escalation_id == current_escalation_id:
|
||
continue
|
||
candidate = self._pending_escalations.get(escalation_id)
|
||
if not candidate:
|
||
continue
|
||
future = candidate.get("future")
|
||
if future is None or future.done():
|
||
continue
|
||
candidate_project_id = str(candidate.get("project_id") or "").strip()
|
||
if project_id and candidate_project_id and candidate_project_id != project_id:
|
||
continue
|
||
if str(candidate.get("approval_group_key") or "").strip() != group_key:
|
||
continue
|
||
if normalized_reply == "approve_session" and str(candidate.get("task_id") or "").strip() != task_id:
|
||
continue
|
||
future.set_result(normalized_reply)
|
||
resolved_ids.append(escalation_id)
|
||
return resolved_ids
|
||
|
||
@staticmethod
|
||
def _task_mode_permission_prompt(message: str, current_turn_title: str = "") -> str:
|
||
raw = str(message or "").strip()
|
||
lines = [line.strip() for line in raw.splitlines() if line.strip()]
|
||
cleaned: list[str] = []
|
||
for line in lines:
|
||
if line.startswith("[") and "]" in line:
|
||
line = line.split("]", 1)[1].strip()
|
||
if line.lower().startswith("task:"):
|
||
continue
|
||
cleaned.append(line)
|
||
title = "Permission required"
|
||
if current_turn_title:
|
||
title = f"Permission required: {current_turn_title[:80]}"
|
||
return "\n".join([title, *cleaned]).strip()
|
||
|
||
async def _resolve_escalation_session_task_id(
|
||
self,
|
||
task_id: str | None,
|
||
*,
|
||
engine: Any | None = None,
|
||
) -> str | None:
|
||
source_task_id = str(task_id or "").strip()
|
||
if not source_task_id:
|
||
return None
|
||
|
||
runtime_engine = engine or self.engine
|
||
store = getattr(runtime_engine, "store", None)
|
||
get_task = getattr(store, "get_task", None)
|
||
if callable(get_task):
|
||
try:
|
||
task = await get_task(source_task_id)
|
||
except Exception as e:
|
||
logger.warning(f"Failed to resolve escalation task mapping for {source_task_id}: {e}")
|
||
task = None
|
||
if task is not None:
|
||
try:
|
||
identity_index = await load_company_runtime_identity_index(
|
||
store,
|
||
self._normalize_project_id(
|
||
getattr(task, "project_id", None)
|
||
or getattr(runtime_engine, "project_id", None)
|
||
),
|
||
)
|
||
runtime_identity = identity_index.resolve(task_id=source_task_id)
|
||
except Exception:
|
||
logger.opt(exception=True).debug(
|
||
"failed to resolve durable company identity for escalation"
|
||
)
|
||
runtime_identity = None
|
||
if runtime_identity is not None:
|
||
# Escalations are a control decision. Route only through
|
||
# the durable runtime identity; process-local progress maps
|
||
# are not evidence that a work-item Task is the UI parent.
|
||
return runtime_identity.ui_anchor_task_id or None
|
||
if is_company_runtime_task(task):
|
||
return None
|
||
internal_turn_target = self._company_internal_turn_escalation_target(task)
|
||
if internal_turn_target is not None:
|
||
return internal_turn_target or None
|
||
ui_task_id = self._ui_task_id_for_task(task)
|
||
if ui_task_id:
|
||
return ui_task_id
|
||
metadata = dict(getattr(task, "metadata", {}) or {})
|
||
origin_task_id = str(metadata.get("origin_task_id") or "").strip()
|
||
if origin_task_id:
|
||
return origin_task_id
|
||
|
||
return source_task_id
|
||
|
||
def _company_internal_turn_escalation_target(self, task: Any | None) -> str | None:
|
||
"""Visible routing target for escalations raised by internal
|
||
company-mode scheduling turns.
|
||
|
||
Review/report turn work items get composite ids (``review::<wid>::vN``),
|
||
so their runtime tasks carry session ids shaped like
|
||
``<root_session>:review::<wid>::vN``. The UI deliberately hides those
|
||
session channels, so an approval card posted to the turn's own channel
|
||
can never be seen or answered — it silently times out and the work item
|
||
parks on AWAITING_HUMAN.
|
||
|
||
Returns None when ``task`` is not such an internal turn (caller keeps
|
||
its normal resolution), the durable origin task when present, or ""
|
||
when no visible channel is known. Company runtime control routing is
|
||
resolved before this helper; process-local session maps are deliberately
|
||
not consulted here.
|
||
"""
|
||
if task is None:
|
||
return None
|
||
session_id = str(getattr(task, "session_id", "") or "").strip()
|
||
root_session_id, sep, suffix = session_id.partition(":")
|
||
if not sep or "::" not in suffix:
|
||
return None
|
||
metadata = dict(getattr(task, "metadata", {}) or {})
|
||
origin_task_id = str(metadata.get("origin_task_id") or "").strip()
|
||
task_id = str(getattr(task, "id", "") or "").strip()
|
||
if origin_task_id and origin_task_id != task_id:
|
||
return origin_task_id
|
||
return ""
|
||
|
||
@staticmethod
|
||
def _pending_escalation_matches_task(record: dict[str, Any], task_id: str | None) -> bool:
|
||
task_key = str(task_id or "").strip()
|
||
if not task_key:
|
||
return True
|
||
record_task_id = str(record.get("task_id") or "").strip()
|
||
source_task_id = str(record.get("source_task_id") or "").strip()
|
||
return task_key in {record_task_id, source_task_id}
|
||
|
||
@staticmethod
|
||
def _pending_escalation_matches_project(record: dict[str, Any], project_id: str | None) -> bool:
|
||
project_key = str(project_id or "").strip()
|
||
if not project_key:
|
||
return True
|
||
record_project_id = str(record.get("project_id") or "").strip()
|
||
if not record_project_id:
|
||
return True
|
||
return record_project_id == project_key
|
||
|
||
def _find_pending_escalation(
|
||
self,
|
||
*,
|
||
task_id: str | None = None,
|
||
escalation_id: str | None = None,
|
||
project_id: str | None = None,
|
||
) -> dict[str, Any] | None:
|
||
explicit_escalation_id = str(escalation_id or "").strip()
|
||
if explicit_escalation_id:
|
||
record = self._pending_escalations.get(explicit_escalation_id)
|
||
if not record:
|
||
return None
|
||
future = record.get("future")
|
||
if future is None or future.done():
|
||
return None
|
||
if not self._pending_escalation_matches_project(record, project_id):
|
||
return None
|
||
if not self._pending_escalation_matches_task(record, task_id):
|
||
return None
|
||
return record
|
||
|
||
for escalation_id in reversed(self._pending_escalation_order):
|
||
record = self._pending_escalations.get(escalation_id)
|
||
if not record:
|
||
continue
|
||
future = record.get("future")
|
||
if future is None or future.done():
|
||
continue
|
||
if not self._pending_escalation_matches_project(record, project_id):
|
||
continue
|
||
if not self._pending_escalation_matches_task(record, task_id):
|
||
continue
|
||
return record
|
||
return None
|
||
|
||
async def _handle_ui_escalation(
|
||
self,
|
||
message: str,
|
||
options: list[dict],
|
||
*,
|
||
project_id: str | None = None,
|
||
) -> str | None:
|
||
project_key = self._normalize_project_id(project_id)
|
||
option_ids = tuple(str(opt.get("id", "")).strip() for opt in options)
|
||
record = None
|
||
for escalation_id in reversed(self._pending_escalation_order):
|
||
candidate = self._pending_escalations.get(escalation_id)
|
||
if not candidate:
|
||
continue
|
||
future = candidate.get("future")
|
||
if future is None or future.done():
|
||
continue
|
||
if not self._pending_escalation_matches_project(candidate, project_key):
|
||
continue
|
||
candidate_ids = tuple(str(opt.get("id", "")).strip() for opt in candidate.get("options", []))
|
||
if candidate_ids == option_ids and str(candidate.get("message", "")) == message:
|
||
record = candidate
|
||
break
|
||
if record is None:
|
||
record = self._find_pending_escalation(project_id=project_key)
|
||
if record is None:
|
||
return None
|
||
|
||
future = record["future"]
|
||
try:
|
||
return await future
|
||
finally:
|
||
escalation_id = str(record.get("escalation_id", ""))
|
||
self._pending_escalations.pop(escalation_id, None)
|
||
self._pending_escalation_order = [
|
||
item for item in self._pending_escalation_order
|
||
if item != escalation_id
|
||
]
|
||
|
||
async def on_kanban_changed(self, *, engine: Any | None = None) -> None:
|
||
"""Callback fired by the company-mode lifecycle loop after each work item
|
||
batch completes. Broadcasts a full collab_sync so the frontend kanban
|
||
reflects newly delegated / updated work items in real-time."""
|
||
runtime_engine = engine or self.engine
|
||
if (
|
||
self._shutting_down
|
||
or not self._store_is_ready(getattr(runtime_engine, "store", None))
|
||
or not self._chat_store_is_ready(self.chat_store)
|
||
):
|
||
return
|
||
try:
|
||
collab = await build_collab_sync(
|
||
runtime_engine, self.agent_store, self.chat_store,
|
||
self.event_adapter,
|
||
exec_mode=self._exec_mode,
|
||
)
|
||
await self.broadcast({"type": "collab_sync_push", "payload": collab})
|
||
except Exception as exc:
|
||
if self._is_expected_shutdown_error(exc) or self._is_closed_database_error(exc):
|
||
logger.debug(
|
||
"on_kanban_changed skipped during shutdown/closed store: {}: {}",
|
||
type(exc).__name__,
|
||
exc,
|
||
)
|
||
return
|
||
# Surface the exception type + message inline AND force the full
|
||
# traceback into the sink. loguru silently drops ``exc_info=True``
|
||
# unless the sink was configured with ``backtrace=True``; using
|
||
# ``.opt(exception=True)`` reliably prints the stack regardless
|
||
# of sink configuration, which is what we want when diagnosing a
|
||
# transient race between the company-mode loop and the UI push.
|
||
logger.opt(exception=True).warning(
|
||
"on_kanban_changed collab_sync broadcast failed: {}: {}",
|
||
type(exc).__name__,
|
||
exc,
|
||
)
|
||
|
||
async def on_progress(self, text: str, **kw: Any) -> None:
|
||
"""engine.on_progress callback. Routes progress to session channel.
|
||
|
||
``task_id`` is now supplied explicitly by the caller (NativeRuntimeV2,
|
||
company_mode, or the engine scoped wrapper). No global fallback.
|
||
Optional ``agent_role_id`` / ``agent_name`` carry agent identity for
|
||
dual-routed messages so the parent chat can display per-agent identity.
|
||
"""
|
||
import time as _time
|
||
|
||
runtime_engine = kw.pop("_runtime_engine", None) or self.engine
|
||
pid = self._normalize_project_id(kw.pop("_project_id", None) or getattr(runtime_engine, "project_id", None))
|
||
raw_task_id = str(kw.get("task_id", "") or "").strip() or None
|
||
task_id = await self._ui_task_id_for_runtime_task_id(raw_task_id, engine=runtime_engine)
|
||
task_id = task_id or None
|
||
agent_role_id: str = kw.get("agent_role_id", "")
|
||
agent_name: str = kw.get("agent_name", "")
|
||
visual_events = self.event_adapter.parse_progress(
|
||
text,
|
||
task_id=task_id or raw_task_id,
|
||
agent_role_id=agent_role_id,
|
||
)
|
||
for ve in visual_events:
|
||
ve = dict(ve)
|
||
ve_data = dict(ve.get("data", {}) or {})
|
||
ve_data.setdefault("project_id", pid)
|
||
ve["data"] = ve_data
|
||
ve["project_id"] = pid
|
||
await self.broadcast({"type": "event", "payload": ve})
|
||
|
||
# Route to session channel if task_id is known, else activity
|
||
target_channel = f"session:{task_id}" if task_id else f"activity:{pid}"
|
||
|
||
# ── Resolve role label for cleaner display ─────────────────
|
||
_role_label = (agent_name or agent_role_id or "").strip()
|
||
if _role_label:
|
||
_role_label = _role_label.replace("_", " ").title()
|
||
|
||
# ── Broadcast progress entry for tool call history ──────────
|
||
if task_id:
|
||
entry = self._parse_progress_entry(text)
|
||
if entry:
|
||
entry["timestamp"] = _time.time()
|
||
# Enrich with role name for frontend display
|
||
if _role_label and not entry.get("role_name"):
|
||
entry["role_name"] = _role_label
|
||
if entry.get("is_company_runtime"):
|
||
entry.update(
|
||
work_item_identity_payload(
|
||
projection_id=entry.get("work_item_projection_id") or "",
|
||
turn_type=entry.get("work_item_turn_type") or "",
|
||
)
|
||
)
|
||
if not entry.get("work_item_projection_title"):
|
||
entry["work_item_projection_title"] = _role_label or None
|
||
_add_execution_turn_aliases(entry, raw_task_id or task_id)
|
||
# Buffer BEFORE broadcasting: broadcast awaits can interleave a
|
||
# session_detail read, and any entry a client has already seen
|
||
# must be visible in buffer∪DB or the snapshot will erase it.
|
||
self._progress_buffer.setdefault(task_id, []).append(entry)
|
||
self._progress_project_ids[task_id] = pid
|
||
await self.broadcast({"type": "session_progress", "payload": {
|
||
"project_id": pid,
|
||
"task_id": task_id,
|
||
**_add_execution_turn_aliases({}, raw_task_id or task_id),
|
||
"entry": entry,
|
||
}})
|
||
# ── Company runtime dual-route: broadcast to primary session ──
|
||
origin = self._active_runtime_children.get(task_id)
|
||
if entry.get("is_company_runtime") and origin:
|
||
await self.broadcast({"type": "work_item_progress", "payload": {
|
||
"project_id": pid,
|
||
"task_id": origin,
|
||
**_add_execution_turn_aliases({}, raw_task_id or task_id),
|
||
"entry": entry,
|
||
}})
|
||
|
||
# ── Persist at threshold (re-read: a concurrent flush during the
|
||
# broadcast awaits may have popped the buffer) ──
|
||
if len(self._progress_buffer.get(task_id, [])) >= self._PROGRESS_FLUSH_THRESHOLD:
|
||
await self._flush_progress(task_id, project_id=pid)
|
||
|
||
is_work_item_event = text.startswith("[Company:")
|
||
|
||
# Clean up [Company:UUID] prefix for display — replace with role name
|
||
display_text = text
|
||
if is_work_item_event and _role_label:
|
||
bracket_end = text.find("]")
|
||
if bracket_end > 9:
|
||
raw_projection = text[9:bracket_end]
|
||
# If the projection looks like a UUID, replace it with the role label.
|
||
if len(raw_projection) > 12 and raw_projection.replace("-", "").replace("_", "").isalnum():
|
||
display_text = f"[{_role_label}] {text[bracket_end + 1:].strip()}"
|
||
|
||
if self._should_store_progress_message(text):
|
||
msg_meta: dict[str, Any] = {}
|
||
if task_id:
|
||
msg_meta["task_id"] = task_id
|
||
if is_work_item_event:
|
||
msg_meta["is_work_item_event"] = True
|
||
msg = await self.chat_store.insert_message(
|
||
channel_id=target_channel,
|
||
sender="system",
|
||
sender_name="OPC",
|
||
content=display_text,
|
||
metadata=msg_meta or None,
|
||
project_id=pid,
|
||
)
|
||
await self.broadcast({"type": "session_message", "payload": msg})
|
||
|
||
# Dual-route: also insert into parent session channel so the
|
||
# primary chat view shows agent output from child tasks.
|
||
if task_id:
|
||
parent_task_id = self._active_runtime_children.get(task_id)
|
||
if parent_task_id and parent_task_id != task_id:
|
||
parent_channel = f"session:{parent_task_id}"
|
||
# Resolve agent identity for the forwarded message
|
||
fwd_sender = "system"
|
||
fwd_sender_name = "OPC"
|
||
if agent_role_id:
|
||
fwd_sender = self.event_adapter._resolve_role_to_agent(agent_role_id)
|
||
fwd_sender_name = agent_name or agent_role_id.replace("_", " ").title()
|
||
fwd_meta: dict[str, Any] = {
|
||
"task_id": task_id,
|
||
"forwarded_from": task_id,
|
||
# _should_store_progress_message already filters out noisy
|
||
# external streams/heartbeats. Forwarded stored messages are
|
||
# high-signal role launch/status records and should remain
|
||
# visible in the primary session summary.
|
||
"detail_visibility": "summary",
|
||
}
|
||
if is_work_item_event:
|
||
fwd_meta["is_work_item_event"] = True
|
||
if _role_label:
|
||
fwd_meta["role_name"] = _role_label
|
||
parent_msg = await self.chat_store.insert_message(
|
||
channel_id=parent_channel,
|
||
sender=fwd_sender,
|
||
sender_name=fwd_sender_name,
|
||
content=display_text,
|
||
metadata=fwd_meta,
|
||
project_id=pid,
|
||
)
|
||
await self.broadcast({"type": "session_message", "payload": parent_msg})
|
||
|
||
async def _flush_progress(self, task_id: str, *, project_id: str | None = None) -> None:
|
||
"""Write buffered progress entries to ChatStore for a single task.
|
||
|
||
Atomically pops the buffer so concurrent on_progress calls for the same
|
||
task_id will create a fresh buffer. Safe to call multiple times — if
|
||
the buffer is empty (already flushed), returns immediately.
|
||
"""
|
||
entries = self._progress_buffer.pop(task_id, [])
|
||
if not entries:
|
||
self._progress_project_ids.pop(task_id, None)
|
||
return
|
||
pid = self._normalize_project_id(project_id or self._progress_project_ids.pop(task_id, None) or self.engine.project_id)
|
||
try:
|
||
await self.chat_store.append_progress(task_id, entries, project_id=pid)
|
||
except Exception:
|
||
logger.debug(f"Failed to flush progress for task {task_id}")
|
||
|
||
async def flush_all_progress(self) -> None:
|
||
"""Flush all buffered progress to DB. Called on graceful server shutdown."""
|
||
task_ids = list(self._progress_buffer.keys())
|
||
for tid in task_ids:
|
||
await self._flush_progress(tid)
|
||
|
||
def _ensure_progress_flush_loop(self) -> None:
|
||
"""Start the background periodic-flush coroutine if not already running.
|
||
|
||
Called lazily on the first WS client connect so the loop exists for
|
||
as long as the UI is in use. Flushes any task whose buffer has had
|
||
entries sitting in RAM longer than ``_PROGRESS_FLUSH_INTERVAL_SEC``,
|
||
so even sparsely-emitting tasks show complete Activity timelines
|
||
without a 10-entry wait.
|
||
"""
|
||
if self._progress_flush_task and not self._progress_flush_task.done():
|
||
return
|
||
self._progress_flush_task = asyncio.create_task(self._periodic_flush_loop())
|
||
|
||
async def _periodic_flush_loop(self) -> None:
|
||
while not self._shutting_down:
|
||
try:
|
||
await asyncio.sleep(self._PROGRESS_FLUSH_INTERVAL_SEC)
|
||
except asyncio.CancelledError:
|
||
break
|
||
if self._shutting_down:
|
||
break
|
||
# Snapshot buffer keys under no-lock read; _flush_progress handles
|
||
# the pop atomically so racing appends don't lose entries.
|
||
pending = [tid for tid, buf in self._progress_buffer.items() if buf]
|
||
for tid in pending:
|
||
try:
|
||
await self._flush_progress(tid)
|
||
except Exception:
|
||
logger.debug(
|
||
"Periodic progress flush error for task %s", tid,
|
||
)
|
||
|
||
def _ensure_runtime_status_sync_loop(self) -> None:
|
||
"""Start the runtime-status reconciliation coroutine if not running.
|
||
|
||
Live status deltas are one-shot, best-effort broadcasts: a delta lost
|
||
to a disconnect window, a project-scope drop, or a not-ready store
|
||
leaves the UI stuck on a stale status until a full refresh. This loop
|
||
periodically re-broadcasts the authoritative status of every task with
|
||
a live runtime (plus one final tick for tasks that just ended) so any
|
||
missed delta converges within one interval.
|
||
"""
|
||
if self._runtime_status_sync_task and not self._runtime_status_sync_task.done():
|
||
return
|
||
self._runtime_status_sync_task = asyncio.create_task(self._runtime_status_sync_loop())
|
||
|
||
async def _runtime_status_sync_loop(self) -> None:
|
||
while not self._shutting_down:
|
||
try:
|
||
await asyncio.sleep(self._RUNTIME_STATUS_SYNC_INTERVAL_SEC)
|
||
except asyncio.CancelledError:
|
||
break
|
||
if self._shutting_down:
|
||
break
|
||
try:
|
||
await self._runtime_status_sync_tick()
|
||
except Exception:
|
||
logger.opt(exception=True).debug("runtime status sync tick failed")
|
||
|
||
def _collect_runtime_status_candidates(self) -> dict[str, str]:
|
||
"""Map task_id → project_id for every task with a live runtime.
|
||
|
||
Sources are purely in-memory (no store scans): the background-task
|
||
context registry covers every session run across modes; the company
|
||
runtime-children map adds work-item child tasks of a live tree.
|
||
"""
|
||
candidates: dict[str, str] = {}
|
||
for bg, ctx in list(self._task_bg_context.items()):
|
||
if bg.done():
|
||
continue
|
||
t_id = str(ctx.get("task_id") or "").strip()
|
||
if t_id:
|
||
candidates[t_id] = self._normalize_project_id(str(ctx.get("project_id") or ""))
|
||
for child_id, root_id in list(self._active_runtime_children.items()):
|
||
if child_id in candidates:
|
||
continue
|
||
pid = (
|
||
candidates.get(root_id)
|
||
or self._progress_project_ids.get(child_id)
|
||
or self._progress_project_ids.get(root_id)
|
||
)
|
||
if pid:
|
||
candidates[child_id] = self._normalize_project_id(pid)
|
||
return candidates
|
||
|
||
async def _runtime_status_sync_tick(self) -> None:
|
||
current = self._collect_runtime_status_candidates()
|
||
if not self._clients:
|
||
# Nobody to correct; a connecting client gets a full snapshot.
|
||
self._runtime_status_sync_prev = current
|
||
return
|
||
departed = {
|
||
tid: pid for tid, pid in self._runtime_status_sync_prev.items()
|
||
if tid not in current
|
||
}
|
||
self._runtime_status_sync_prev = current
|
||
to_sync = {**departed, **current}
|
||
if not to_sync:
|
||
return
|
||
tracker_by_task: dict[str, tuple[str, str | None]] = {}
|
||
for tracker in getattr(self.event_adapter, "_trackers", {}).values():
|
||
t_id = str(getattr(tracker, "task_id", "") or "").strip()
|
||
if t_id:
|
||
tracker_by_task[t_id] = (tracker.state.value, tracker.current_tool)
|
||
by_project: dict[str, list[str]] = {}
|
||
for tid, pid in to_sync.items():
|
||
by_project.setdefault(pid, []).append(tid)
|
||
for pid, task_ids in by_project.items():
|
||
try:
|
||
engine = await self._engine_for_project(pid)
|
||
except Exception:
|
||
continue
|
||
store = getattr(engine, "store", None)
|
||
if store is None or not self._store_is_ready(store):
|
||
continue
|
||
sessions: list[dict[str, Any]] = []
|
||
for task_id in sorted(task_ids):
|
||
try:
|
||
t = await store.get_task(task_id)
|
||
except Exception:
|
||
continue
|
||
if t is None:
|
||
continue
|
||
status_val = t.status.value if hasattr(t.status, "value") else str(t.status)
|
||
entry: dict[str, Any] = {"task_id": task_id, "status": status_val}
|
||
tracked = tracker_by_task.get(task_id)
|
||
if tracked is not None:
|
||
entry["agent_status"], entry["current_tool"] = tracked
|
||
sessions.append(entry)
|
||
if sessions:
|
||
await self.broadcast({
|
||
"type": "runtime_status_sync",
|
||
"payload": {"project_id": pid, "sessions": sessions},
|
||
})
|
||
|
||
@staticmethod
|
||
def _parse_progress_entry(text: str) -> dict[str, Any] | None:
|
||
"""Parse on_progress text into a ProgressEntry dict, or None to skip."""
|
||
# [Tool: file_read] {"path": "src/api.ts"}
|
||
if text.startswith("[Tool:"):
|
||
bracket_end = text.find("]")
|
||
if bracket_end > 0:
|
||
tool_name = text[7:bracket_end].strip()
|
||
detail = text[bracket_end + 1:].lstrip()
|
||
return {"type": "tool_call", "summary": tool_name, "detail": detail}
|
||
return {"type": "tool_call", "summary": text[:60]}
|
||
|
||
# [Autonomy] tool:web_search -> auto_approve (risk=low)
|
||
if text.startswith("[Autonomy]"):
|
||
return {"type": "autonomy", "summary": text[11:].strip()[:100]}
|
||
|
||
# [Delegating to claude-code] task=...
|
||
if text.startswith("[Delegating"):
|
||
bracket_end = text.find("]")
|
||
target = text[15:bracket_end] if bracket_end > 15 else "agent"
|
||
return {
|
||
"type": "handoff",
|
||
"summary": f"Delegating to {target}",
|
||
"detail": text,
|
||
}
|
||
|
||
# [External:agent:stdout] ... / [External status] ... / failures
|
||
if text.startswith("[External"):
|
||
bracket_end = text.find("]")
|
||
header = text[1:bracket_end] if bracket_end > 1 else "External"
|
||
detail = text[bracket_end + 1:].strip() if bracket_end > 0 else text
|
||
summary = detail[:80] if detail else header[:80]
|
||
|
||
if header.startswith("External:"):
|
||
parts = header.split(":")
|
||
agent = parts[1] if len(parts) > 1 else "external"
|
||
stream = parts[2] if len(parts) > 2 else ""
|
||
if stream == "thinking":
|
||
thinking_summary = detail[:120] if detail else f"{agent} thinking"
|
||
if len(detail) > 120:
|
||
thinking_summary = thinking_summary.rstrip() + "..."
|
||
return {
|
||
"type": "thinking",
|
||
"summary": thinking_summary,
|
||
"detail": detail or None,
|
||
}
|
||
if stream == "tool":
|
||
first_line = next((line.strip() for line in detail.splitlines() if line.strip()), "")
|
||
if first_line.startswith("$ "):
|
||
first_line = first_line[2:]
|
||
tool_summary = first_line[:120] if first_line else f"{agent} tool"
|
||
return {
|
||
"type": "tool_call",
|
||
"summary": tool_summary,
|
||
"detail": detail or None,
|
||
}
|
||
label = f"{agent} {stream}".strip()
|
||
if label:
|
||
summary = label
|
||
elif header == "External status" and detail:
|
||
summary = detail[:80]
|
||
|
||
return {
|
||
"type": "status_change",
|
||
"summary": summary,
|
||
"detail": detail or text,
|
||
}
|
||
|
||
# [CapabilityRecovery]
|
||
if text.startswith("[CapabilityRecovery]"):
|
||
return {"type": "status_change", "summary": "Capability recovery"}
|
||
|
||
# [Company] (no projection) — global company runtime event (e.g. deadlock)
|
||
if text.startswith("[Company]"):
|
||
action = text[10:].strip()
|
||
action_lower = action.lower()
|
||
entry_type = "gate_result"
|
||
if "deadlock" in action_lower:
|
||
entry_type = "deadlock"
|
||
elif "failed" in action_lower:
|
||
entry_type = "work_item_failed"
|
||
return {
|
||
"type": entry_type,
|
||
"summary": f"Company runtime: {action[:80]}",
|
||
"detail": action[:200] if action else None,
|
||
**work_item_identity_payload(projection_id="company_runtime", turn_type=""),
|
||
"work_item_projection_title": "Company Runtime",
|
||
"is_company_runtime": True,
|
||
}
|
||
|
||
# [Company:projection] ... — classify by specific action
|
||
if text.startswith("[Company:"):
|
||
bracket_end = text.find("]")
|
||
projection_id = text[9:bracket_end] if bracket_end > 9 else "work_item"
|
||
action = text[bracket_end + 2:].strip() if bracket_end > 0 else ""
|
||
action_lower = action.lower()
|
||
|
||
entry_type = "gate_result"
|
||
if "starting" in action_lower or "started" in action_lower:
|
||
entry_type = "work_item_started"
|
||
elif "gate passed" in action_lower or "approved" in action_lower or "completed" in action_lower:
|
||
entry_type = "gate_approved"
|
||
elif "rejected" in action_lower or "reworking" in action_lower:
|
||
entry_type = "gate_rejected"
|
||
elif "awaiting manager review" in action_lower:
|
||
entry_type = "awaiting_manager_review"
|
||
elif "awaiting user" in action_lower or "awaiting human review" in action_lower or "awaiting review" in action_lower:
|
||
entry_type = "awaiting_human"
|
||
elif "awaiting peer" in action_lower:
|
||
entry_type = "awaiting_peer"
|
||
elif "failed" in action_lower:
|
||
entry_type = "work_item_failed"
|
||
elif "deadlock" in action_lower:
|
||
entry_type = "deadlock"
|
||
|
||
# Use projection name as title, action text as detail.
|
||
is_uuid_like = len(projection_id) > 12 and projection_id.replace("-", "").replace("_", "").isalnum()
|
||
projection_title = projection_id if is_uuid_like else projection_id.replace("_", " ").replace("-", " ").title()
|
||
return {
|
||
"type": entry_type,
|
||
"summary": f"{projection_title}: {action[:80]}",
|
||
"detail": action[:200] if action else None,
|
||
**work_item_identity_payload(projection_id=projection_id, turn_type=""),
|
||
"work_item_projection_title": projection_title,
|
||
"is_company_runtime": True,
|
||
}
|
||
|
||
return None
|
||
|
||
@staticmethod
|
||
def _coerce_int(value: Any) -> int | None:
|
||
if value in (None, ""):
|
||
return None
|
||
if isinstance(value, bool):
|
||
return int(value)
|
||
if isinstance(value, (int, float)):
|
||
numeric = float(value)
|
||
if not math.isfinite(numeric):
|
||
return None
|
||
return int(numeric)
|
||
try:
|
||
numeric = float(str(value))
|
||
except (TypeError, ValueError):
|
||
return None
|
||
if not math.isfinite(numeric):
|
||
return None
|
||
return int(numeric)
|
||
|
||
@staticmethod
|
||
def _context_usage_metrics(payload: dict[str, Any]) -> tuple[int | None, int | None, int | None]:
|
||
context_tokens = WSHandler._coerce_int(payload.get("context_tokens", payload.get("token_count")))
|
||
context_window = WSHandler._coerce_int(payload.get("context_window"))
|
||
remaining_pct = WSHandler._coerce_int(payload.get("context_remaining_pct"))
|
||
|
||
if remaining_pct is not None:
|
||
remaining_pct = max(0, min(remaining_pct, 100))
|
||
|
||
if context_window is not None and context_window > 0:
|
||
if context_tokens is not None:
|
||
used_tokens = max(0, min(context_tokens, context_window))
|
||
used_pct = int(round((used_tokens / context_window) * 100))
|
||
return used_tokens, max(0, min(used_pct, 100)), context_window
|
||
if remaining_pct is not None:
|
||
used_pct = 100 - remaining_pct
|
||
used_tokens = int(round((used_pct / 100) * context_window))
|
||
return used_tokens, used_pct, context_window
|
||
|
||
if remaining_pct is not None:
|
||
return context_tokens, 100 - remaining_pct, context_window
|
||
|
||
return context_tokens, None, context_window
|
||
|
||
@staticmethod
|
||
def _context_usage_compact_label(payload: dict[str, Any]) -> str | None:
|
||
used_tokens, used_pct, context_window = WSHandler._context_usage_metrics(payload)
|
||
if used_pct is not None:
|
||
return f"{used_pct}% used"
|
||
if used_tokens is not None and context_window is not None and context_window > 0:
|
||
return f"{used_tokens}/{context_window} tokens"
|
||
if used_tokens is not None:
|
||
return f"{used_tokens} tokens"
|
||
return None
|
||
|
||
@staticmethod
|
||
def _context_usage_detail(payload: dict[str, Any]) -> str | None:
|
||
used_tokens, used_pct, context_window = WSHandler._context_usage_metrics(payload)
|
||
parts: list[str] = []
|
||
if used_tokens is not None:
|
||
if context_window is not None and context_window > 0:
|
||
parts.append(f"{used_tokens}/{context_window} tokens")
|
||
else:
|
||
parts.append(f"{used_tokens} tokens")
|
||
if used_pct is not None:
|
||
parts.append(f"{used_pct}% used")
|
||
return " | ".join(parts) or None
|
||
|
||
@staticmethod
|
||
def _humanize_role_label(role_id: str) -> str:
|
||
normalized = str(role_id or "").strip()
|
||
if not normalized:
|
||
return ""
|
||
if "_" not in normalized and "-" not in normalized and normalized.isalpha() and len(normalized) <= 4:
|
||
return normalized.upper()
|
||
return normalized.replace("_", " ").replace("-", " ").title()
|
||
|
||
def _resolve_work_item_role_name(
|
||
self,
|
||
role_id: str,
|
||
metadata: dict[str, Any] | None = None,
|
||
*,
|
||
engine: Any | None = None,
|
||
) -> str:
|
||
explicit = str((metadata or {}).get("work_item_role_name", "") or "").strip()
|
||
if explicit:
|
||
return explicit
|
||
|
||
rid = str(role_id or "").strip()
|
||
if not rid:
|
||
return ""
|
||
|
||
runtime_engine = engine or self.engine
|
||
org_engine = getattr(runtime_engine, "org_engine", None)
|
||
get_agent = getattr(org_engine, "get_agent", None)
|
||
if callable(get_agent):
|
||
try:
|
||
agent = get_agent(rid)
|
||
except Exception:
|
||
agent = None
|
||
if isinstance(agent, dict):
|
||
name = agent.get("name")
|
||
else:
|
||
name = getattr(agent, "name", "")
|
||
if isinstance(name, str) and name.strip():
|
||
return name.strip()
|
||
|
||
return self._humanize_role_label(rid)
|
||
|
||
def _enrich_runtime_progress_payload(self, payload: dict[str, Any], *, engine: Any | None = None) -> dict[str, Any]:
|
||
enriched = dict(payload or {})
|
||
if str(enriched.get("work_item_role_name", "") or "").strip():
|
||
return enriched
|
||
|
||
role_id = str(
|
||
enriched.get("role_id")
|
||
or enriched.get("agent_role_id")
|
||
or enriched.get("work_item_role_id")
|
||
or ""
|
||
).strip()
|
||
role_name = self._resolve_work_item_role_name(role_id, engine=engine)
|
||
if role_name:
|
||
enriched["work_item_role_name"] = role_name
|
||
return enriched
|
||
|
||
@staticmethod
|
||
def _runtime_payload_is_task_mode(payload: dict[str, Any]) -> bool:
|
||
execution_mode = str(payload.get("execution_mode", "") or "").strip().lower()
|
||
if execution_mode == "company_mode":
|
||
return False
|
||
if execution_mode in {"task_mode", "task", "project_mode", "project"}:
|
||
return True
|
||
projection_id = str(payload.get("work_item_projection_id", "") or "").strip()
|
||
if projection_id and projection_id != "task_mode_execution":
|
||
return False
|
||
if str(payload.get("company_profile", "") or "").strip():
|
||
return False
|
||
mode = str(payload.get("mode", "") or "").strip().lower()
|
||
runtime_kind = str(payload.get("runtime_kind", "") or "").strip()
|
||
task_mode_contract = str(payload.get("task_mode_contract", "") or "").strip()
|
||
return (
|
||
mode == "task"
|
||
or runtime_kind == "task_mode_agent_turn"
|
||
or task_mode_contract == "single_full_capability_main_agent"
|
||
or projection_id == "task_mode_execution"
|
||
)
|
||
|
||
@staticmethod
|
||
def _runtime_event_to_progress_entry(payload: dict[str, Any]) -> dict[str, Any] | None:
|
||
runtime_type = str(payload.get("type", "") or "").strip()
|
||
if not runtime_type:
|
||
return None
|
||
is_task_mode = WSHandler._runtime_payload_is_task_mode(payload)
|
||
if is_task_mode:
|
||
if runtime_type in _TASK_MODE_HIDDEN_RUNTIME_PROGRESS_TYPES:
|
||
return None
|
||
if runtime_type not in _TASK_MODE_VISIBLE_RUNTIME_PROGRESS_TYPES:
|
||
return None
|
||
elif runtime_type in _COMPANY_MODE_HIDDEN_RUNTIME_PROGRESS_TYPES:
|
||
return None
|
||
|
||
summary = runtime_type.replace("_", " ").title()
|
||
detail = ""
|
||
entry_type = "status_change"
|
||
|
||
if runtime_type == "turn_started":
|
||
entry_type = "work_item_started"
|
||
summary = f"Turn {payload.get('iteration', '?')} started"
|
||
elif runtime_type == "assistant_delta":
|
||
# Company mode only (task mode is filtered out above and already
|
||
# streams assistant text as the draft reply): surface the role's
|
||
# narration and final reply in its progress transcript, matching
|
||
# what external agents get via [External:*:result] parsing.
|
||
entry_type = "assistant"
|
||
detail = str(payload.get("text", "") or "")
|
||
if not detail.strip():
|
||
return None
|
||
preview = " ".join(detail.split())
|
||
summary = preview[:120].rstrip() + ("..." if len(preview) > 120 else "")
|
||
elif runtime_type == "member_idle":
|
||
return None
|
||
elif runtime_type == "thinking_delta":
|
||
entry_type = "thinking"
|
||
# Keep the raw fragment: streaming deltas are token-sized, so
|
||
# stripping them destroys the whitespace between tokens once the
|
||
# fragments are merged back into one entry.
|
||
detail = str(payload.get("text", "") or "")
|
||
if not detail.strip():
|
||
return None
|
||
preview = " ".join(detail.split())
|
||
summary = preview[:120].rstrip() + ("..." if len(preview) > 120 else "")
|
||
elif runtime_type == "member_claimed_work_item":
|
||
entry_type = "work_item_started"
|
||
priority = str(payload.get("message_priority", "") or "").strip().lower()
|
||
if priority == "manager":
|
||
summary = "Work item resumed"
|
||
detail = "Claimed from manager queue."
|
||
else:
|
||
summary = "Work item started"
|
||
if priority:
|
||
detail = f"Claimed from {priority.replace('_', ' ')} queue."
|
||
elif runtime_type == "tool_started":
|
||
entry_type = "tool_call"
|
||
summary = str(payload.get("tool_name", "") or "tool")
|
||
if payload.get("arguments"):
|
||
try:
|
||
detail = json.dumps(payload.get("arguments", {}), ensure_ascii=False, default=str)
|
||
except TypeError:
|
||
detail = str(payload.get("arguments"))
|
||
elif runtime_type == "tool_progress":
|
||
entry_type = "tool_call"
|
||
summary = str(payload.get("tool_name", "") or "tool")
|
||
detail = str(payload.get("text", "") or payload.get("message", "") or "").strip()
|
||
elif runtime_type == "tool_completed":
|
||
entry_type = "tool_call"
|
||
summary = str(payload.get("tool_name", "") or "tool")
|
||
detail = str(payload.get("result_summary", "") or payload.get("result_preview", "") or "").strip()
|
||
elif runtime_type == "status_snapshot":
|
||
entry_type = "status_change"
|
||
current_tool = str(payload.get("current_tool", "") or "").strip()
|
||
turn_cost = payload.get("turn_cost_usd")
|
||
pieces: list[str] = []
|
||
if current_tool:
|
||
pieces.append(f"tool={current_tool}")
|
||
context_label = WSHandler._context_usage_compact_label(payload)
|
||
if context_label:
|
||
pieces.append(f"context={context_label}")
|
||
if turn_cost not in (None, ""):
|
||
pieces.append(f"turn=${float(turn_cost):.4f}")
|
||
summary = "Runtime status"
|
||
detail = " | ".join(pieces)
|
||
elif runtime_type in {"permission_requested", "permission_resolved"}:
|
||
entry_type = "autonomy"
|
||
target = str(payload.get("tool_name", "") or "tool").strip()
|
||
resolution = str(payload.get("resolution", payload.get("predicted_permission", "")) or "").strip()
|
||
summary = f"{target}: {resolution or runtime_type.replace('_', ' ')}".strip(": ")
|
||
detail = str(payload.get("rationale", "") or "").strip()
|
||
elif runtime_type == "cost_update":
|
||
entry_type = "status_change"
|
||
summary = "Cost update"
|
||
detail = (
|
||
f"turn=${float(payload.get('turn_cost_usd', 0.0) or 0.0):.4f} "
|
||
f"session=${float(payload.get('session_cost_usd', 0.0) or 0.0):.4f}"
|
||
)
|
||
elif runtime_type == "context_usage":
|
||
entry_type = "status_change"
|
||
summary = "Context usage"
|
||
detail = WSHandler._context_usage_detail(payload) or "Context usage updated"
|
||
elif runtime_type == "context_warning":
|
||
entry_type = "status_change"
|
||
summary = "Context usage high"
|
||
detail = WSHandler._context_usage_detail(payload) or "Context window nearly full"
|
||
elif runtime_type in {"subagent_started", "subagent_updated", "subagent_completed"}:
|
||
if is_task_mode and str(payload.get("profile", "") or "").strip() == "verify":
|
||
entry_type = "verification"
|
||
profile = str(payload.get("profile", "") or "verify").strip()
|
||
summary = f"{profile}: {runtime_type.replace('_', ' ')}"
|
||
detail = (
|
||
str(payload.get("content_preview", "") or "").strip()
|
||
or str(payload.get("message", "") or "").strip()
|
||
or str(payload.get("status", "") or "").strip()
|
||
)
|
||
else:
|
||
entry_type = "handoff"
|
||
profile = str(payload.get("profile", "") or "subagent").strip()
|
||
summary = f"{profile}: {runtime_type.replace('_', ' ')}"
|
||
detail = (
|
||
str(payload.get("content_preview", "") or "").strip()
|
||
or str(payload.get("message", "") or "").strip()
|
||
or str(payload.get("status", "") or "").strip()
|
||
)
|
||
elif runtime_type == "member_inbox_updated":
|
||
entry_type = "status_change"
|
||
summary = "Resident inbox updated"
|
||
pieces = [
|
||
f"chat={int(payload.get('actionable_inbox_count', 0) or 0)}",
|
||
f"protocol={int(payload.get('protocol_backlog_count', 0) or 0)}",
|
||
f"notifications={int(payload.get('notification_backlog_count', 0) or 0)}",
|
||
]
|
||
resident_status = str(payload.get("resident_status", "") or "").strip()
|
||
if resident_status:
|
||
pieces.append(f"status={resident_status}")
|
||
detail = " | ".join(pieces)
|
||
elif runtime_type == "worker_notification":
|
||
notification_kind = str(payload.get("notification_kind", "") or "update").strip() or "update"
|
||
if notification_kind == "blocked":
|
||
entry_type = "awaiting_peer"
|
||
elif notification_kind == "error":
|
||
entry_type = "work_item_failed"
|
||
elif notification_kind in {"task_complete", "handoff_ready"}:
|
||
entry_type = "handoff"
|
||
else:
|
||
entry_type = "status_change"
|
||
worker_label = (
|
||
str(payload.get("name", "") or "").strip()
|
||
or str(payload.get("worker_type", "") or "worker").strip().replace("_", " ")
|
||
)
|
||
summary = f"{worker_label}: {notification_kind.replace('_', ' ')}".strip(": ")
|
||
detail = str(payload.get("summary", "") or "").strip()
|
||
elif runtime_type == "compaction_applied":
|
||
if is_task_mode and runtime_type in _TASK_MODE_DEBUG_ONLY_PROGRESS_TYPES:
|
||
return None
|
||
entry_type = "status_change"
|
||
summary = "Context compacted"
|
||
detail = f"message_count={payload.get('message_count', '')}".strip()
|
||
elif runtime_type in {"verification_started", "verification_repair_requested", "verification_completed"}:
|
||
entry_type = "verification" if is_task_mode else "status_change"
|
||
summary = runtime_type.replace("_", " ").title()
|
||
detail = (
|
||
str(payload.get("verdict", "") or "").strip()
|
||
or str(payload.get("reason", "") or "").strip()
|
||
or str(payload.get("profile", "") or "").strip()
|
||
)
|
||
elif runtime_type == "checkpoint_saved":
|
||
if is_task_mode:
|
||
entry_type = "needs_input"
|
||
summary = "Needs input"
|
||
detail = str(payload.get("checkpoint_type", "") or "").strip()
|
||
else:
|
||
review_level = str(payload.get("review_level", "") or "").strip().lower()
|
||
entry_type = "awaiting_manager_review" if review_level == "manager" else "awaiting_human"
|
||
review_target = str(payload.get("review_target_role_id", "") or "").strip()
|
||
summary = (
|
||
f"Awaiting {review_target or 'manager'} review"
|
||
if review_level == "manager"
|
||
else "Awaiting human review"
|
||
)
|
||
detail = str(payload.get("checkpoint_type", "") or "").strip()
|
||
elif runtime_type == "turn_completed":
|
||
entry_type = "gate_approved"
|
||
summary = f"Turn {payload.get('iteration', '?')} completed"
|
||
detail = str(payload.get("content_preview", "") or "").strip()
|
||
elif runtime_type == "turn_failed":
|
||
entry_type = "work_item_failed"
|
||
summary = f"Turn {payload.get('iteration', '?')} failed"
|
||
detail = str(payload.get("message", "") or "").strip()
|
||
|
||
entry: dict[str, Any] = {
|
||
"type": entry_type,
|
||
"summary": summary[:160] if summary else runtime_type,
|
||
"detail": detail[:4000] if detail else None,
|
||
}
|
||
tool_call_id = str(payload.get("tool_call_id", "") or "").strip()
|
||
if tool_call_id and entry_type in {"tool_call", "autonomy"}:
|
||
turn_id = str(payload.get("turn_id", "") or "").strip()
|
||
prefix = "permission" if entry_type == "autonomy" else "tool"
|
||
entry.setdefault("item_id", f"{turn_id}:{prefix}:{tool_call_id}" if turn_id else f"{prefix}:{tool_call_id}")
|
||
entry.setdefault("stream_id", entry["item_id"])
|
||
entry["tool_call_id"] = tool_call_id
|
||
permission_group_key = str(payload.get("permission_group_key", "") or "").strip()
|
||
if permission_group_key:
|
||
entry["permission_group_key"] = permission_group_key
|
||
for alias_key in ("turn_id", "item_id", "stream_id", "seq", "execution_mode"):
|
||
if alias_key in payload and payload.get(alias_key) not in (None, ""):
|
||
entry[alias_key] = payload.get(alias_key)
|
||
work_item_projection_id = str(payload.get("work_item_projection_id") or "").strip()
|
||
if is_task_mode and work_item_projection_id == "task_mode_execution":
|
||
work_item_projection_id = ""
|
||
work_item_turn_type = str(
|
||
payload.get("work_item_turn_type")
|
||
or payload.get("turn_type")
|
||
or ""
|
||
).strip()
|
||
work_item_projection_title = str(payload.get("work_item_projection_title", "") or "").strip()
|
||
work_item_role_name = str(payload.get("work_item_role_name") or payload.get("role_name") or "").strip()
|
||
if not work_item_role_name:
|
||
role_id = str(
|
||
payload.get("role_id")
|
||
or payload.get("agent_role_id")
|
||
or payload.get("work_item_role_id")
|
||
or ""
|
||
).strip()
|
||
work_item_role_name = WSHandler._humanize_role_label(role_id)
|
||
if not is_task_mode and (work_item_projection_id or work_item_projection_title):
|
||
entry.update(
|
||
work_item_identity_payload(
|
||
projection_id=work_item_projection_id or work_item_projection_title,
|
||
turn_type=work_item_turn_type,
|
||
)
|
||
)
|
||
entry["work_item_projection_title"] = (
|
||
work_item_role_name
|
||
or work_item_projection_title
|
||
or work_item_projection_id.replace("_", " ").title()
|
||
)
|
||
entry["is_company_runtime"] = True
|
||
if work_item_role_name:
|
||
entry["role_name"] = work_item_role_name
|
||
return entry
|
||
|
||
@staticmethod
|
||
def _should_store_progress_message(text: str) -> bool:
|
||
"""Route high-signal progress into chat/activity without flooding it."""
|
||
if len(text) <= 10:
|
||
return False
|
||
# Plain-text progress is used for native agent final replies; the
|
||
# authoritative assistant message comes from transcript sync. Storing
|
||
# it here creates a duplicate system-colored chat bubble.
|
||
if not text.startswith("["):
|
||
return False
|
||
if text.startswith(("[Tool:", "[Autonomy]", "[Cost:", "[Token", "[CapabilityRecovery]")):
|
||
return False
|
||
if text.startswith("[External:"):
|
||
return False
|
||
if text.startswith("[External status]"):
|
||
lowered = text.lower()
|
||
return (
|
||
"started pid=" in lowered
|
||
or "timed out" in lowered
|
||
or "cancelled" in lowered
|
||
)
|
||
return True
|
||
|
||
def _related_parent_task_ids(self, task: Any) -> list[str]:
|
||
task_id = str(getattr(task, "id", "") or "").strip()
|
||
metadata = dict(getattr(task, "metadata", {}) or {})
|
||
related: list[str] = []
|
||
|
||
for candidate in (
|
||
self._active_runtime_children.get(task_id),
|
||
metadata.get("origin_task_id"),
|
||
self._session_to_task.get(str(getattr(task, "parent_session_id", "") or "").strip()),
|
||
):
|
||
resolved = str(candidate or "").strip()
|
||
if resolved and resolved != task_id and resolved not in related:
|
||
related.append(resolved)
|
||
return related
|
||
|
||
def _ensure_task_display_num(self, task_id: str) -> int:
|
||
existing = self.event_adapter._task_display_map.get(task_id)
|
||
if existing is not None:
|
||
return existing
|
||
self.event_adapter._task_display_counter += 1
|
||
self.event_adapter._task_display_map[task_id] = self.event_adapter._task_display_counter
|
||
return self.event_adapter._task_display_counter
|
||
|
||
async def _materialize_runtime_task_visibility(
|
||
self,
|
||
payload: dict[str, Any],
|
||
*,
|
||
engine: Any | None = None,
|
||
project_id: str | None = None,
|
||
) -> None:
|
||
"""Ensure runtime-only company events still materialize a live UI session.
|
||
|
||
Some company-mode tasks are first surfaced through runtime events such as
|
||
``member_session_started`` instead of the dedicated ``child_session_created``
|
||
path. Without an explicit session/board broadcast, the frontend receives
|
||
progress updates for an unknown task and cannot render the execution tree
|
||
until a later full ``collab_sync`` rebuild.
|
||
"""
|
||
runtime_engine = engine or self.engine
|
||
task_id = str(payload.get("task_id", "") or "").strip()
|
||
if not task_id or not self._store_is_ready(runtime_engine.store):
|
||
return
|
||
|
||
try:
|
||
task = await runtime_engine.store.get_task(task_id)
|
||
except Exception:
|
||
task = None
|
||
if task is None:
|
||
return
|
||
|
||
session_id = str(getattr(task, "session_id", "") or "").strip()
|
||
if not session_id:
|
||
return
|
||
|
||
metadata = dict(getattr(task, "metadata", {}) or {})
|
||
project_id = self._normalize_project_id(
|
||
getattr(task, "project_id", "") or project_id or getattr(runtime_engine, "project_id", None)
|
||
)
|
||
ui_task_id = self._ui_task_id_for_task(task) or task_id
|
||
ui_task = task
|
||
if ui_task_id != task_id and self._store_is_ready(runtime_engine.store):
|
||
try:
|
||
resolved = await runtime_engine.store.get_task(ui_task_id)
|
||
except Exception:
|
||
resolved = None
|
||
if resolved is not None:
|
||
ui_task = resolved
|
||
title = str(getattr(ui_task, "title", "") or getattr(task, "title", "") or payload.get("title") or "Session").strip() or "Session"
|
||
channel_id = f"session:{ui_task_id}"
|
||
try:
|
||
existing_channels = await self.chat_store.get_session_channels(project_id)
|
||
except Exception:
|
||
existing_channels = []
|
||
channel = next(
|
||
(
|
||
channel
|
||
for channel in existing_channels
|
||
if str(channel.get("channel_id", "") or "").strip() == channel_id
|
||
),
|
||
None,
|
||
)
|
||
channel_already_materialized = channel is not None
|
||
if channel is None:
|
||
channel = await self.chat_store.create_session_channel(ui_task_id, title, project_id=project_id)
|
||
self._session_to_task[session_id] = ui_task_id
|
||
if channel_already_materialized:
|
||
return
|
||
|
||
role_id = str(getattr(task, "assigned_to", "") or metadata.get("work_item_role_id", "") or "").strip()
|
||
assignee_ids = [self.event_adapter._resolve_role_to_agent(role_id)] if role_id else []
|
||
work_item_role_name = str(metadata.get("work_item_role_name", "") or "").strip()
|
||
if not work_item_role_name and role_id:
|
||
work_item_role_name = self._resolve_work_item_role_name(role_id, metadata, engine=runtime_engine)
|
||
shared_role_session = bool(metadata.get("shared_role_session", False))
|
||
|
||
parent_session_id = str(
|
||
getattr(task, "parent_session_id", "")
|
||
or metadata.get("parent_session_id", "")
|
||
or ""
|
||
).strip() or None
|
||
if shared_role_session:
|
||
parent_session_id = None
|
||
origin_task_id = str(
|
||
metadata.get("origin_task_id", "")
|
||
or self._session_to_task.get(str(parent_session_id or ""))
|
||
or ui_task_id
|
||
).strip() or ui_task_id
|
||
|
||
display_num = self._ensure_task_display_num(ui_task_id)
|
||
created_at = channel.get("created_at") if isinstance(channel, dict) else None
|
||
if not isinstance(created_at, (int, float)):
|
||
raw_created_at = getattr(ui_task, "created_at", None) or getattr(task, "created_at", None)
|
||
created_at = raw_created_at.timestamp() if hasattr(raw_created_at, "timestamp") else time.time()
|
||
|
||
status = getattr(getattr(task, "status", None), "value", str(getattr(task, "status", "pending")))
|
||
exec_mode, company_profile = self._resolve_task_session_config(task)
|
||
preferred_agent = self._resolve_task_preferred_agent(task)
|
||
selected_execution_agent = self._resolve_task_selected_execution_agent(task)
|
||
work_item_projection_id = work_item_projection_id_from_metadata(metadata)
|
||
work_item_turn_type = work_item_turn_type_from_metadata(metadata, fallback="")
|
||
work_item_identity = {
|
||
**work_item_identity_payload(projection_id=work_item_projection_id, turn_type=work_item_turn_type),
|
||
"work_item_role_id": role_id or None,
|
||
"work_item_role_name": work_item_role_name or None,
|
||
"employee_assignment": metadata.get("employee_assignment"),
|
||
"origin_task_id": origin_task_id,
|
||
"selected_execution_agent": selected_execution_agent,
|
||
}
|
||
execution_aliases = _add_execution_turn_aliases({}, task_id)
|
||
|
||
if parent_session_id:
|
||
await self.broadcast({
|
||
"type": "child_session_created",
|
||
"payload": {
|
||
"project_id": project_id,
|
||
"session_id": session_id,
|
||
"parent_session_id": parent_session_id,
|
||
"task_id": ui_task_id,
|
||
**execution_aliases,
|
||
"origin_task_id": origin_task_id,
|
||
"title": title,
|
||
"agent_id": assignee_ids[0] if assignee_ids else None,
|
||
**work_item_identity,
|
||
},
|
||
})
|
||
|
||
if not parent_session_id:
|
||
await self.broadcast({
|
||
"type": "board_task_created",
|
||
"payload": {
|
||
"project_id": project_id,
|
||
"task_id": ui_task_id,
|
||
**execution_aliases,
|
||
"display_id": f"OPC-{display_num}",
|
||
"board_id": project_id,
|
||
"title": title,
|
||
"assignee_ids": assignee_ids,
|
||
**work_item_identity,
|
||
},
|
||
})
|
||
await self.broadcast({
|
||
"type": "session_created",
|
||
"payload": {
|
||
"project_id": project_id,
|
||
"task_id": ui_task_id,
|
||
**execution_aliases,
|
||
"channel_id": channel_id,
|
||
"session_id": session_id,
|
||
"parent_session_id": parent_session_id,
|
||
"origin_task_id": origin_task_id,
|
||
"exec_mode": exec_mode,
|
||
"company_profile": company_profile,
|
||
"preferred_agent": preferred_agent,
|
||
"selected_execution_agent": selected_execution_agent,
|
||
"title": title,
|
||
"status": status,
|
||
"created_at": created_at,
|
||
"assignee_ids": assignee_ids,
|
||
**work_item_identity,
|
||
},
|
||
})
|
||
|
||
async def _ensure_reply_projected(
|
||
self,
|
||
*,
|
||
channel_id: str,
|
||
project_id: str,
|
||
session_id: str | None,
|
||
engine: Any | None = None,
|
||
) -> None:
|
||
"""Last-resort invariant: the session's newest persisted top-level reply
|
||
must exist in the UI channel once the turn has unwound.
|
||
|
||
The transcript sync is the normal projection path; when it is starved,
|
||
cancelled, or misses the row (project 000, 2026-07-07 19:21/20:27), the
|
||
engine has replied but the user sees an empty conversation forever.
|
||
Detection is by the transcript message id, so an already-projected reply
|
||
(any channel) is never duplicated.
|
||
"""
|
||
if not session_id:
|
||
return
|
||
runtime_engine = engine or self.engine
|
||
store = getattr(runtime_engine, "store", None)
|
||
if not self._store_is_ready(store):
|
||
return
|
||
lister = getattr(store, "list_session_messages", None)
|
||
parts_loader = getattr(store, "list_session_parts", None)
|
||
if not callable(lister) or not callable(parts_loader):
|
||
return
|
||
try:
|
||
records = await lister(session_id)
|
||
except Exception:
|
||
logger.opt(exception=True).debug("reply projection: failed to list session messages")
|
||
return
|
||
latest = None
|
||
for record in reversed(records or []):
|
||
if str(getattr(record, "role", "") or "").strip().lower() != "assistant":
|
||
continue
|
||
metadata = dict(getattr(record, "metadata", {}) or {})
|
||
if str(metadata.get("kind", "") or "").strip() != "top_level_reply":
|
||
continue
|
||
latest = record
|
||
break
|
||
if latest is None:
|
||
return
|
||
message_id = str(getattr(latest, "message_id", "") or "").strip()
|
||
if not message_id:
|
||
return
|
||
try:
|
||
if await self.chat_store.message_scope(message_id) is not None:
|
||
return
|
||
except Exception:
|
||
return
|
||
try:
|
||
parts = await parts_loader(session_id, message_id)
|
||
except Exception:
|
||
logger.opt(exception=True).debug("reply projection: failed to load reply parts")
|
||
return
|
||
text = "\n".join(
|
||
chunk
|
||
for part in parts or []
|
||
if str(getattr(part, "part_type", "") or "") == "text"
|
||
for chunk in [str(dict(getattr(part, "payload", {}) or {}).get("text", "") or "")]
|
||
if chunk
|
||
).strip()
|
||
if not text:
|
||
return
|
||
logger.warning(
|
||
f"Top-level reply {message_id} missing from UI channel {channel_id} after "
|
||
"transcript sync; projecting it directly"
|
||
)
|
||
reply_metadata = dict(getattr(latest, "metadata", {}) or {})
|
||
reply_metadata.setdefault("kind", "top_level_reply")
|
||
reply_metadata.setdefault("source", "engine")
|
||
reply_metadata.setdefault("ui_message_id", message_id)
|
||
reply_metadata["reply_projection_fallback"] = True
|
||
msg = await self.chat_store.insert_message(
|
||
channel_id=channel_id,
|
||
sender="assistant",
|
||
sender_name="OPC",
|
||
content=text,
|
||
project_id=project_id,
|
||
metadata=reply_metadata,
|
||
message_id=message_id,
|
||
)
|
||
await self.broadcast({"type": "session_message", "payload": msg})
|
||
|
||
async def _sync_task_transcript_messages(
|
||
self,
|
||
task_id: str,
|
||
*,
|
||
engine: Any | None = None,
|
||
broadcast: bool = True,
|
||
detail_level: str = "summary",
|
||
latest_assistant_metadata: dict[str, Any] | None = None,
|
||
) -> int:
|
||
"""Backfill persisted transcript messages into chat_store and UI."""
|
||
runtime_engine = engine or self.engine
|
||
store = runtime_engine.store
|
||
if not self._store_is_ready(store):
|
||
return 0
|
||
|
||
task = await store.get_task(task_id)
|
||
if not task:
|
||
return 0
|
||
|
||
transcript_loader = getattr(store, "get_session_transcript", None)
|
||
session_id = str(getattr(task, "session_id", "") or "").strip()
|
||
if not callable(transcript_loader) or not session_id:
|
||
return 0
|
||
|
||
project_id = getattr(task, "project_id", None) or runtime_engine.project_id or "default"
|
||
ui_task_id = self._ui_task_id_for_task(task) or task_id
|
||
ui_task = task
|
||
if ui_task_id != task_id:
|
||
try:
|
||
resolved = await store.get_task(ui_task_id)
|
||
except Exception:
|
||
resolved = None
|
||
if resolved is not None:
|
||
ui_task = resolved
|
||
channel_id = f"session:{ui_task_id}"
|
||
await self.chat_store.create_session_channel(
|
||
ui_task_id,
|
||
getattr(ui_task, "title", "") or getattr(task, "title", "") or "Session",
|
||
project_id=project_id,
|
||
)
|
||
|
||
try:
|
||
transcript = await transcript_loader(session_id)
|
||
except Exception:
|
||
transcript = []
|
||
|
||
formatted_messages = build_transcript_ui_messages(
|
||
transcript,
|
||
channel_id=channel_id,
|
||
task_id=ui_task_id,
|
||
detail_level=_normalize_transcript_detail_level(detail_level),
|
||
)
|
||
|
||
if latest_assistant_metadata:
|
||
latest_assistant_metadata = (
|
||
latest_assistant_metadata
|
||
if self._checkpoint_metadata_targets_task(latest_assistant_metadata, task)
|
||
else None
|
||
)
|
||
|
||
attached_latest_metadata = False
|
||
if latest_assistant_metadata:
|
||
for message in reversed(formatted_messages):
|
||
if str(message.get("sender", "") or "").strip().lower() == "user":
|
||
continue
|
||
if not self._message_can_host_checkpoint_metadata(message, latest_assistant_metadata):
|
||
continue
|
||
metadata = dict(message.get("metadata", {}) or {})
|
||
metadata.update(latest_assistant_metadata)
|
||
message["metadata"] = metadata
|
||
attached_latest_metadata = True
|
||
break
|
||
if not attached_latest_metadata:
|
||
checkpoint_id = str(latest_assistant_metadata.get("checkpoint_id", "") or "").strip()
|
||
synthetic_message_id = f"checkpoint::{checkpoint_id}" if checkpoint_id else str(uuid.uuid4())
|
||
formatted_messages.append({
|
||
"message_id": synthetic_message_id,
|
||
"sender": "assistant",
|
||
"sender_name": str(
|
||
latest_assistant_metadata.get("work_item_projection_title")
|
||
or latest_assistant_metadata.get("requesting_role_id")
|
||
or "Company Member"
|
||
),
|
||
"content": str(
|
||
latest_assistant_metadata.get("prompt")
|
||
or latest_assistant_metadata.get("summary")
|
||
or "Human review requested."
|
||
),
|
||
"timestamp": time.time(),
|
||
"reply_to_id": None,
|
||
"mentions": [],
|
||
"metadata": dict(latest_assistant_metadata),
|
||
})
|
||
|
||
inserted_messages = await self.chat_store.backfill_messages(
|
||
channel_id,
|
||
formatted_messages,
|
||
project_id=project_id,
|
||
)
|
||
if broadcast and inserted_messages:
|
||
for message in inserted_messages:
|
||
await self.broadcast({"type": "session_message", "payload": {
|
||
"project_id": project_id,
|
||
"message_id": message["message_id"],
|
||
"channel_id": channel_id,
|
||
"sender": message["sender"],
|
||
"sender_name": message["sender_name"],
|
||
"content": message["content"],
|
||
"created_at": message["timestamp"],
|
||
"reply_to_id": message.get("reply_to_id"),
|
||
"mentions": message.get("mentions", []),
|
||
"metadata": message.get("metadata", {}),
|
||
}})
|
||
return len(inserted_messages)
|
||
|
||
@staticmethod
|
||
def _checkpoint_metadata_targets_task(metadata: dict[str, Any], task: Any) -> bool:
|
||
if str((metadata or {}).get("checkpoint_type", "") or "").strip() != "company_delivery_feedback":
|
||
return True
|
||
target_task_id = str(
|
||
(metadata or {}).get("waiting_task_id")
|
||
or (metadata or {}).get("task_id")
|
||
or ""
|
||
).strip()
|
||
task_id = str(getattr(task, "id", "") or "").strip()
|
||
return not target_task_id or not task_id or target_task_id == task_id
|
||
|
||
@staticmethod
|
||
def _message_can_host_checkpoint_metadata(
|
||
message: dict[str, Any],
|
||
checkpoint_metadata: dict[str, Any],
|
||
) -> bool:
|
||
if str((checkpoint_metadata or {}).get("checkpoint_type", "") or "").strip() != "company_delivery_feedback":
|
||
return True
|
||
metadata = dict(message.get("metadata", {}) or {})
|
||
if str(message.get("sender", "") or "").strip().lower() == "system":
|
||
return False
|
||
if str(metadata.get("kind", "") or "").strip() == "worker_notification":
|
||
return False
|
||
if str(metadata.get("transcript_kind", "") or "").strip() == "child_result":
|
||
return False
|
||
return True
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# Inbound routing
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
async def _route_message(self, ws: Any, raw: str) -> None:
|
||
"""Parse and route an incoming WS message."""
|
||
try:
|
||
data = json.loads(raw)
|
||
except json.JSONDecodeError:
|
||
return
|
||
# ``json.loads`` succeeds for non-object frames (null/number/array/string);
|
||
# ``data.get`` would then raise AttributeError, escape this method, and drop the
|
||
# whole WS connection. Ignore anything that is not a JSON object.
|
||
if not isinstance(data, dict):
|
||
return
|
||
|
||
msg_type = data.get("type", "")
|
||
if self._shutting_down:
|
||
logger.debug(f"Ignoring WS message during shutdown: {msg_type}")
|
||
return
|
||
handler = self._HANDLERS.get(msg_type)
|
||
handoff_registry = None
|
||
handoff_token: str | None = None
|
||
if handler and msg_type in _EXECUTION_HANDOFF_MESSAGE_TYPES:
|
||
handoff_registry = self._controller_active_task_run_registry()
|
||
if handoff_registry is not None:
|
||
try:
|
||
handoff_token = handoff_registry.reserve_handoff()
|
||
except ActiveTaskRunAdmissionClosed:
|
||
await self._send_ack(
|
||
ws,
|
||
ok=False,
|
||
error="service_shutting_down",
|
||
action=msg_type,
|
||
)
|
||
return
|
||
if handler:
|
||
current_task = asyncio.current_task()
|
||
if current_task is not None:
|
||
self._active_message_tasks.add(current_task)
|
||
if handoff_token is not None:
|
||
handoff_routes = getattr(self, "_handoff_route_tasks", None)
|
||
if not isinstance(handoff_routes, dict):
|
||
handoff_routes = {}
|
||
self._handoff_route_tasks = handoff_routes
|
||
handoff_routes[current_task] = handoff_token
|
||
try:
|
||
if handoff_registry is not None and handoff_token is not None:
|
||
with handoff_registry.bind_handoff(handoff_token):
|
||
await handler(self, ws, data)
|
||
else:
|
||
await handler(self, ws, data)
|
||
except Exception as e:
|
||
if self._is_ws_disconnect_error(e) or self._is_expected_shutdown_error(e):
|
||
logger.debug(
|
||
f"WS handler closed for {msg_type} during disconnect/shutdown: "
|
||
f"{type(e).__name__}: {e!r}"
|
||
)
|
||
elif isinstance(e, ProjectScopeError):
|
||
logger.warning(
|
||
"Rejected project-scoped WS request without project_id: type={} keys={} project_id={!r} projectId={!r}",
|
||
msg_type,
|
||
sorted(str(key) for key in data.keys()),
|
||
data.get("project_id"),
|
||
data.get("projectId"),
|
||
)
|
||
try:
|
||
await self._send_ack(ws, ok=False, error=str(e), action=msg_type)
|
||
except Exception:
|
||
pass
|
||
else:
|
||
logger.opt(exception=True).error(f"WS handler error for {msg_type}: {type(e).__name__}: {e!r}")
|
||
try:
|
||
await self._send_ack(ws, ok=False, error=str(e) or type(e).__name__, action=msg_type)
|
||
except Exception:
|
||
pass # WS may already be closed
|
||
finally:
|
||
if handoff_registry is not None and handoff_token is not None:
|
||
handoff_registry.release_handoff(handoff_token)
|
||
if current_task is not None:
|
||
self._active_message_tasks.discard(current_task)
|
||
handoff_routes = getattr(self, "_handoff_route_tasks", None)
|
||
if isinstance(handoff_routes, dict):
|
||
handoff_routes.pop(current_task, None)
|
||
else:
|
||
logger.debug(f"Unknown WS message type: {msg_type}")
|
||
await self._send_ack(
|
||
ws,
|
||
ok=False,
|
||
error="unknown_message_type",
|
||
action=msg_type,
|
||
)
|
||
|
||
# ── Sync ──────────────────────────────────────────────────────────
|
||
|
||
async def _handle_ping(self, ws: Any, data: dict) -> None:
|
||
await ws.send_json({"type": "pong"})
|
||
|
||
async def _send_project_index_for_client(
|
||
self,
|
||
ws: Any,
|
||
engine: Any,
|
||
project_id: str,
|
||
*,
|
||
switch_seq: str = "",
|
||
view_generation: Any = None,
|
||
include_snapshot: bool = False,
|
||
send_error_ack: bool = True,
|
||
) -> None:
|
||
try:
|
||
index_payload = await build_project_index_sync(
|
||
engine,
|
||
self.agent_store,
|
||
self.chat_store,
|
||
self.event_adapter,
|
||
exec_mode=self._exec_mode,
|
||
)
|
||
index_payload["project_id"] = project_id
|
||
index_payload["switch_seq"] = switch_seq
|
||
if view_generation is not None:
|
||
index_payload["view_generation"] = view_generation
|
||
|
||
if self._client_active_project_id(ws) != project_id:
|
||
return
|
||
if switch_seq and self._client_switch_seq.get(ws, "") != switch_seq:
|
||
return
|
||
await self._send_envelope_to_client(
|
||
ws,
|
||
{"type": "project_index_push", "payload": index_payload},
|
||
)
|
||
|
||
if not include_snapshot:
|
||
return
|
||
try:
|
||
snapshot = await build_snapshot(
|
||
engine,
|
||
self.agent_store,
|
||
self.chat_store,
|
||
self.event_adapter,
|
||
)
|
||
snapshot["project_id"] = project_id
|
||
snapshot["exec_mode"] = self._exec_mode
|
||
snapshot["company_profile"] = self._company_profile
|
||
snapshot["task_preferred_agent"] = self._task_preferred_agent
|
||
snapshot["switch_seq"] = switch_seq
|
||
if view_generation is not None:
|
||
snapshot["view_generation"] = view_generation
|
||
if self._client_active_project_id(ws) != project_id:
|
||
return
|
||
if switch_seq and self._client_switch_seq.get(ws, "") != switch_seq:
|
||
return
|
||
await self._send_envelope_to_client(ws, {"type": "snapshot", "payload": snapshot})
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as exc:
|
||
logger.opt(exception=True).warning(
|
||
f"Project index sent, but snapshot refresh failed for {project_id}: {type(exc).__name__}: {exc!r}",
|
||
)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as exc:
|
||
logger.opt(exception=True).error(
|
||
f"Failed to build project index for {project_id}: {type(exc).__name__}: {exc!r}",
|
||
)
|
||
if send_error_ack and self._client_active_project_id(ws) == project_id:
|
||
await self._send_ack(
|
||
ws,
|
||
ok=False,
|
||
action="project_index",
|
||
project_id=project_id,
|
||
switch_seq=switch_seq,
|
||
error=f"Project index failed: {exc}",
|
||
)
|
||
|
||
async def _send_initial_project_state_for_client(
|
||
self,
|
||
ws: Any,
|
||
engine: Any,
|
||
project_id: str,
|
||
) -> None:
|
||
"""Send the full reconnect baseline for a newly opened websocket."""
|
||
await self._send_project_index_for_client(
|
||
ws,
|
||
engine,
|
||
project_id,
|
||
send_error_ack=False,
|
||
)
|
||
if self._client_active_project_id(ws) != project_id:
|
||
return
|
||
try:
|
||
collab = await build_collab_sync(
|
||
engine,
|
||
self.agent_store,
|
||
self.chat_store,
|
||
self.event_adapter,
|
||
exec_mode=self._exec_mode,
|
||
)
|
||
collab["project_id"] = project_id
|
||
if self._client_active_project_id(ws) == project_id:
|
||
await self._send_envelope_to_client(ws, {"type": "collab_sync_push", "payload": collab})
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception:
|
||
logger.opt(exception=True).warning("Initial websocket collab_sync push failed")
|
||
try:
|
||
org_info = await self._build_org_info_payload()
|
||
if self._client_active_project_id(ws) == project_id:
|
||
await self._send_envelope_to_client(ws, {"type": "org_info", "payload": org_info})
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception:
|
||
logger.opt(exception=True).warning("Initial websocket org_info push failed")
|
||
|
||
async def _handle_collab_sync(self, ws: Any, data: dict) -> None:
|
||
engine, project_id = await self._engine_for_request(data)
|
||
result = await build_collab_sync(
|
||
engine,
|
||
self.agent_store,
|
||
self.chat_store,
|
||
self.event_adapter,
|
||
exec_mode=self._exec_mode,
|
||
)
|
||
result["ok"] = True
|
||
result["project_id"] = project_id
|
||
if data.get("switch_seq") or data.get("switchSeq"):
|
||
result["switch_seq"] = str(data.get("switch_seq") or data.get("switchSeq") or "")
|
||
if data.get("view_generation") is not None:
|
||
result["view_generation"] = data.get("view_generation")
|
||
await ws.send_json({"type": "ack", "payload": result})
|
||
|
||
async def _handle_project_index(self, ws: Any, data: dict) -> None:
|
||
engine, project_id = await self._engine_for_request(data)
|
||
switch_seq = str(data.get("switch_seq") or data.get("switchSeq") or "").strip()
|
||
view_generation = data.get("view_generation")
|
||
self._track_client_project_index(
|
||
ws,
|
||
self._send_project_index_for_client(
|
||
ws,
|
||
engine,
|
||
project_id,
|
||
switch_seq=switch_seq,
|
||
view_generation=view_generation,
|
||
include_snapshot=bool(data.get("include_snapshot") or data.get("includeSnapshot")),
|
||
),
|
||
)
|
||
await self._send_ack(
|
||
ws,
|
||
ok=True,
|
||
action="project_index",
|
||
project_id=project_id,
|
||
switch_seq=switch_seq,
|
||
)
|
||
|
||
# ── Chat ──────────────────────────────────────────────────────────
|
||
|
||
# ── Kanban ────────────────────────────────────────────────────────
|
||
|
||
async def _handle_kanban_create_board(self, ws: Any, data: dict) -> None:
|
||
# We use one board per project; accept for protocol compatibility
|
||
_engine, project_id = await self._engine_for_request(data)
|
||
data = {**data, "project_id": project_id}
|
||
await self._send_ack(ws, ok=True)
|
||
await self.broadcast({"type": "kanban_board_created", "payload": data})
|
||
|
||
async def _handle_kanban_create_task(self, ws: Any, data: dict) -> None:
|
||
try:
|
||
_engine, pid = await self._engine_for_request(data)
|
||
result = await self.services.kanban.create_task(
|
||
project_id=pid,
|
||
title=data.get("title", "Untitled"),
|
||
description=data.get("description", ""),
|
||
task_id=data.get("task_id"),
|
||
board_id=data.get("board_id", pid),
|
||
assignee_ids=data.get("assignee_ids", []),
|
||
)
|
||
self._session_to_task.update(self.services_context.session_to_task)
|
||
await self._publish_service_result(result)
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="kanban_create_task")
|
||
|
||
async def _handle_kanban_update_task(self, ws: Any, data: dict) -> None:
|
||
try:
|
||
_engine, project_id = await self._engine_for_request(data)
|
||
result = await self.services.kanban.update_task(
|
||
project_id=project_id,
|
||
task_id=data.get("task_id", ""),
|
||
updates=data.get("updates", {}),
|
||
)
|
||
await self._publish_service_result(result)
|
||
await self._send_ack(ws, ok=True, **result.payload)
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="kanban_update_task")
|
||
|
||
async def _handle_kanban_move_task(self, ws: Any, data: dict) -> None:
|
||
try:
|
||
_engine, project_id = await self._engine_for_request(data)
|
||
result = await self.services.kanban.move_task(
|
||
project_id=project_id,
|
||
task_id=data.get("task_id", ""),
|
||
column_id=data.get("column_id", ""),
|
||
)
|
||
await self._publish_service_result(result)
|
||
await self._send_ack(ws, ok=True, **result.payload)
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="kanban_move_task")
|
||
|
||
async def _handle_kanban_delete_task(self, ws: Any, data: dict) -> None:
|
||
try:
|
||
_engine, project_id = await self._engine_for_request(data)
|
||
result = await self.services.kanban.delete_task(project_id=project_id, task_id=data.get("task_id", ""))
|
||
await self._publish_service_result(result)
|
||
await self._send_ack(ws, ok=True, **result.payload)
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="kanban_delete_task")
|
||
|
||
async def _handle_kanban_delete_board(self, ws: Any, data: dict) -> None:
|
||
# No-op for OPC's project-based boards
|
||
_engine, project_id = await self._engine_for_request(data)
|
||
await self._send_ack(ws, ok=True, project_id=project_id)
|
||
|
||
async def _handle_kanban_assign(self, ws: Any, data: dict) -> None:
|
||
try:
|
||
_engine, project_id = await self._engine_for_request(data)
|
||
result = await self.services.kanban.assign(
|
||
project_id=project_id,
|
||
task_id=data.get("task_id", ""),
|
||
agent_id=data.get("agent_id", ""),
|
||
)
|
||
await self._send_ack(ws, ok=True, **result.payload)
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="kanban_assign")
|
||
|
||
async def _handle_kanban_status(self, ws: Any, data: dict) -> None:
|
||
# Alias: convert status to column_id and delegate
|
||
try:
|
||
_engine, project_id = await self._engine_for_request(data)
|
||
result = await self.services.kanban.status(
|
||
project_id=project_id,
|
||
task_id=data.get("task_id", ""),
|
||
status=data.get("status", data.get("column_id", "")),
|
||
)
|
||
await self._publish_service_result(result)
|
||
await self._send_ack(ws, ok=True, **result.payload)
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="kanban_status")
|
||
|
||
async def _handle_kanban_switch_view(self, ws: Any, data: dict) -> None:
|
||
"""Return filtered kanban data for global/office/agent view levels."""
|
||
from opc.plugins.office_ui.snapshot_builder import build_company_kanban_projection, task_to_kanban
|
||
level = data.get("level", "global")
|
||
target_id = data.get("target_id")
|
||
run_engine, project_id = await self._engine_for_request(data)
|
||
|
||
tasks: list[Any] = []
|
||
if run_engine.store:
|
||
try:
|
||
tasks = await run_engine.store.get_tasks(project_id=project_id)
|
||
except Exception:
|
||
logger.warning("Failed to load tasks for kanban_switch_view")
|
||
|
||
company_projection_tasks: list[dict[str, Any]] = []
|
||
company_columns: list[dict[str, Any]] = []
|
||
company_boards: list[dict[str, Any]] = []
|
||
if run_engine.store:
|
||
try:
|
||
company_projection_tasks, company_columns, company_boards, _ = await build_company_kanban_projection(
|
||
run_engine,
|
||
project_id=project_id,
|
||
tasks=tasks,
|
||
event_adapter=self.event_adapter,
|
||
)
|
||
except Exception:
|
||
logger.opt(exception=True).warning("Failed to load company kanban projection for kanban_switch_view")
|
||
if self._exec_mode in {"company", "org", "custom"} and not company_columns:
|
||
company_boards = [{
|
||
"board_id": project_id,
|
||
"name": project_id if project_id != "default" else "Main Board",
|
||
"prefix": "OPC",
|
||
"color": "#4f46e5",
|
||
"next_task_num": 1,
|
||
"created_at": time.time(),
|
||
"updated_at": time.time(),
|
||
}]
|
||
company_columns = build_company_board_columns(project_id)
|
||
|
||
if company_columns:
|
||
filtered_tasks = list(company_projection_tasks)
|
||
if level == "agent" and target_id:
|
||
agent = await self.agent_store._get_one(target_id)
|
||
role_id = agent.get("opc_role_id", target_id) if agent else target_id
|
||
filtered_tasks = [
|
||
task for task in filtered_tasks
|
||
if str(task.get("work_item_role_id", "") or "").strip() == str(role_id or "").strip()
|
||
]
|
||
elif level == "office" and target_id and getattr(run_engine, "org_engine", None) is not None:
|
||
office_role_ids = {
|
||
str(agent.get("opc_role_id", agent.get("agent_id", "")) or "").strip()
|
||
for agent in await self.agent_store.get_all()
|
||
if str(agent.get("office_id", "") or "").strip() == str(target_id or "").strip()
|
||
}
|
||
filtered_tasks = [
|
||
task for task in filtered_tasks
|
||
if str(task.get("work_item_role_id", "") or "").strip() in office_role_ids
|
||
]
|
||
boards = list(company_boards)
|
||
if boards:
|
||
counts_by_board: dict[str, int] = {}
|
||
for task in filtered_tasks:
|
||
board_id = str(task.get("board_id", "") or "").strip()
|
||
if not board_id:
|
||
continue
|
||
counts_by_board[board_id] = counts_by_board.get(board_id, 0) + 1
|
||
for board in boards:
|
||
board_id = str(board.get("board_id", "") or "").strip()
|
||
board["next_task_num"] = int(counts_by_board.get(board_id, 0) or 0) + 1
|
||
await ws.send_json({"type": "kanban_view_data", "payload": {
|
||
"project_id": project_id,
|
||
"boards": boards,
|
||
"columns": company_columns,
|
||
"tasks": filtered_tasks,
|
||
"work_item_projections": [],
|
||
}})
|
||
return
|
||
|
||
# Filter tasks by view level (task.assigned_to stores opc_role_id)
|
||
if level == "agent" and target_id:
|
||
agent = await self.agent_store._get_one(target_id)
|
||
role_id = agent.get("opc_role_id", target_id) if agent else target_id
|
||
tasks = [t for t in tasks if t.assigned_to == role_id]
|
||
elif level == "office" and target_id:
|
||
agents = await self.agent_store.get_all()
|
||
office_role_ids = {a.get("opc_role_id", a["agent_id"]) for a in agents if a.get("office_id") == target_id}
|
||
tasks = [t for t in tasks if t.assigned_to in office_role_ids]
|
||
|
||
formatted_tasks = [task_to_kanban(t, i + 1, self.event_adapter) for i, t in enumerate(tasks)]
|
||
|
||
# Board and columns (same structure as collab_sync)
|
||
now = time.time()
|
||
boards = [{
|
||
"board_id": project_id,
|
||
"name": project_id if project_id != "default" else "Main Board",
|
||
"prefix": "OPC",
|
||
"color": "#4f46e5",
|
||
"next_task_num": len(tasks) + 1,
|
||
"created_at": now,
|
||
"updated_at": now,
|
||
}]
|
||
columns = [
|
||
{"column_id": "todo", "board_id": project_id, "name": "Todo",
|
||
"color": "#6b7280", "sort_order": 0, "is_terminal": False},
|
||
{"column_id": "in-progress", "board_id": project_id, "name": "In Progress",
|
||
"color": "#eab308", "sort_order": 1, "is_terminal": False},
|
||
{"column_id": "done", "board_id": project_id, "name": "Done",
|
||
"color": "#22c55e", "sort_order": 2, "is_terminal": True},
|
||
]
|
||
await ws.send_json({"type": "kanban_view_data", "payload": {
|
||
"project_id": project_id,
|
||
"boards": boards,
|
||
"columns": columns,
|
||
"tasks": formatted_tasks,
|
||
"work_item_projections": [],
|
||
}})
|
||
|
||
# ── Agent Management ──────────────────────────────────────────────
|
||
|
||
async def _handle_create_agent(self, ws: Any, data: dict) -> None:
|
||
role = data.get("role", {})
|
||
role_id = role.get("id", "executor")
|
||
# Resolve name: explicit name → org_engine role name → role_id
|
||
name = role.get("name")
|
||
if not name or name == role_id:
|
||
if self.engine.org_engine:
|
||
org_role = self.engine.org_engine.get_agent(role_id)
|
||
if org_role:
|
||
name = org_role.name
|
||
if not name:
|
||
name = role_id.replace("_", " ").replace("-", " ").title()
|
||
office_id = role.get("office_id", "office-0")
|
||
# Collect optional custom fields from frontend
|
||
description = role.get("description", "")
|
||
specialties = role.get("specialties", [])
|
||
if isinstance(specialties, str):
|
||
specialties = [s.strip() for s in specialties.split(",") if s.strip()]
|
||
tools = role.get("tools", [])
|
||
system_prompt = role.get("system_prompt", "")
|
||
appearance = role.get("appearance", {})
|
||
palette = appearance.get("palette") if isinstance(appearance, dict) else None
|
||
seat_zone = appearance.get("seat_zone", "workspace") if isinstance(appearance, dict) else "workspace"
|
||
|
||
# Custom mode: create full three-layer data (RoleConfig + EmployeeConfig + Agent)
|
||
employee_id = None
|
||
if self._exec_mode in {"org", "custom"} and self.engine.org_engine:
|
||
async with self._config_lock:
|
||
from opc.core.config import RoleConfig, EmployeeConfig
|
||
|
||
org = self.engine.org_engine
|
||
role_created = False
|
||
|
||
# 1. Create RoleConfig if role doesn't exist yet
|
||
if not org.get_agent(role_id):
|
||
org.add_role(RoleConfig(
|
||
id=role_id,
|
||
name=name,
|
||
responsibility=description,
|
||
tools=tools or list(specialties or []),
|
||
))
|
||
role_created = True
|
||
|
||
# 2. Generate unique employee_id (uuid suffix prevents collisions)
|
||
slug = f"{role_id}-{name.lower().replace(' ', '-')}"
|
||
if any(e.employee_id == slug for e in self.engine.config.org.employees):
|
||
slug = f"{slug}-{uuid.uuid4().hex[:8]}"
|
||
employee_id = slug
|
||
|
||
# 3. Write custom prompt file if system_prompt provided
|
||
prompt_refs: list[str] = []
|
||
if system_prompt:
|
||
prompt_ref = self._write_custom_prompt(employee_id, name, system_prompt)
|
||
prompt_refs.append(prompt_ref)
|
||
|
||
# 4. Create EmployeeConfig and append to config
|
||
all_tools = tools or list(specialties or [])
|
||
emp = EmployeeConfig(
|
||
employee_id=employee_id,
|
||
name=name,
|
||
role_id=role_id,
|
||
description=description,
|
||
category=description[:60] if description else "",
|
||
domains=all_tools,
|
||
tags=list(specialties or []),
|
||
prompt_refs=prompt_refs,
|
||
)
|
||
self.engine.config.org.employees = [
|
||
*self.engine.config.org.employees,
|
||
emp,
|
||
]
|
||
|
||
# 5. Persist under lock to prevent concurrent save races
|
||
try:
|
||
self._persist_runtime_config()
|
||
except Exception:
|
||
# Rollback in-memory state
|
||
self.engine.config.org.employees = [
|
||
e for e in self.engine.config.org.employees
|
||
if e.employee_id != employee_id
|
||
]
|
||
if role_created:
|
||
org.remove_role(role_id)
|
||
# Clean up prompt file
|
||
if prompt_refs:
|
||
prompt_path = Path(self.engine.opc_home) / prompt_refs[0]
|
||
prompt_path.unlink(missing_ok=True)
|
||
employee_id = None
|
||
logger.warning("Failed to persist config after create_agent, rolled back")
|
||
|
||
agent = await self.agent_store.create_agent(
|
||
name=name, opc_role_id=role_id, office_id=office_id,
|
||
org_engine=self.engine.org_engine,
|
||
description=description,
|
||
specialties=specialties,
|
||
tools=tools,
|
||
palette=palette,
|
||
seat_zone=seat_zone,
|
||
employee_id=employee_id,
|
||
)
|
||
|
||
# Custom mode: broadcast org panel refresh to ALL clients
|
||
if self._exec_mode in {"org", "custom"}:
|
||
await self._broadcast_org_info()
|
||
await self.agent_store.sync_custom_shadow()
|
||
|
||
# Sync role map + broadcast agent_spawned event
|
||
await self._sync_role_map()
|
||
await self.broadcast({"type": "event", "payload": {
|
||
"event_id": str(uuid.uuid4()),
|
||
"type": "agent_spawned",
|
||
"agent_id": agent["agent_id"],
|
||
"data": {"role_name": agent["name"]},
|
||
"timestamp": time.time(),
|
||
}})
|
||
agents = await self.agent_store.get_all()
|
||
await self._send_ack(ws, ok=True, agents=agents)
|
||
|
||
def _write_custom_prompt(self, employee_id: str, name: str, prompt_text: str) -> str:
|
||
"""Write a custom prompt file and return its relative path as a prompt_ref."""
|
||
prompts_dir = Path(self.engine.opc_home) / "prompts" / "custom"
|
||
prompts_dir.mkdir(parents=True, exist_ok=True)
|
||
# ``employee_id`` is derived from user-supplied role id/name and previously flowed
|
||
# unchecked into the path, enabling traversal (e.g. "../../tmp/pwn"). Reduce it to
|
||
# a single safe path component and confirm containment before writing.
|
||
safe_id = Path(str(employee_id or "")).name.replace("..", "")
|
||
safe_id = safe_id.replace("/", "").replace("\\", "").strip() or "agent"
|
||
filename = f"{safe_id}.md"
|
||
filepath = (prompts_dir / filename).resolve()
|
||
base = prompts_dir.resolve()
|
||
if base not in filepath.parents and filepath != base:
|
||
raise ValueError(f"Custom prompt filename escapes prompts directory: {employee_id!r}")
|
||
filepath.write_text(f"# {name}\n\n{prompt_text}\n", encoding="utf-8")
|
||
return f"prompts/custom/{filename}"
|
||
|
||
async def _build_org_info_payload(self) -> dict[str, Any]:
|
||
"""Build the full org_info payload via OrgService."""
|
||
result = await self._ensure_office_services().org.info()
|
||
return result.payload
|
||
|
||
async def _broadcast_org_info(self) -> None:
|
||
"""Build org_info payload and broadcast to ALL connected clients."""
|
||
result = await self._ensure_office_services().org.info(include_events=True)
|
||
await self._publish_service_result(result)
|
||
|
||
async def _broadcast_snapshot(self) -> None:
|
||
snapshot = await build_snapshot(
|
||
self.engine, self.agent_store, self.chat_store, self.event_adapter
|
||
)
|
||
snapshot["exec_mode"] = self._exec_mode
|
||
snapshot["company_profile"] = self._company_profile
|
||
snapshot["task_preferred_agent"] = self._task_preferred_agent
|
||
await self.broadcast({"type": "snapshot", "payload": snapshot})
|
||
|
||
async def _ensure_custom_role_agents(self) -> list[dict[str, Any]]:
|
||
if self._exec_mode not in {"org", "custom"} or not self.engine.org_engine:
|
||
return await self.agent_store.get_all()
|
||
agents = await self.agent_store.ensure_custom_role_agents(self.engine.org_engine)
|
||
await self._sync_role_map()
|
||
return agents
|
||
|
||
async def _handle_delete_agent(self, ws: Any, data: dict) -> None:
|
||
try:
|
||
result = await self._ensure_office_services().agent.delete(data.get("agent_id", ""))
|
||
await self._publish_service_result(result)
|
||
if self._exec_mode in {"org", "custom"} and data.get("agent_id"):
|
||
if hasattr(self, "chat_store") and hasattr(self, "event_adapter"):
|
||
await self._broadcast_snapshot()
|
||
await self._broadcast_org_info()
|
||
await self._send_service_ack(ws, result)
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="delete_agent")
|
||
|
||
async def _handle_list_agents(self, ws: Any, data: dict) -> None:
|
||
result = await self._ensure_office_services().agent.list()
|
||
await self._send_service_ack(ws, result)
|
||
|
||
async def _handle_move_agent(self, ws: Any, data: dict) -> None:
|
||
try:
|
||
await self._ensure_office_services().agent.move(
|
||
agent_id=data.get("agent_id", ""),
|
||
office_id=data.get("office_id", "office-0"),
|
||
seat_zone=data.get("seat_zone"),
|
||
desk_id=data.get("desk_id"),
|
||
)
|
||
await self._broadcast_snapshot()
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="move_agent")
|
||
|
||
async def _handle_get_agent_detail(self, ws: Any, data: dict) -> None:
|
||
"""Return detailed info for a single agent (agent-level kanban view)."""
|
||
try:
|
||
_engine, project_id = await self._engine_for_request(data)
|
||
result = await self._ensure_office_services().agent.detail(
|
||
project_id=project_id,
|
||
agent_id=data.get("agent_id", ""),
|
||
)
|
||
await self._send_service_ack(ws, result)
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="get_agent_detail")
|
||
|
||
# ── Company Runtime Mode ─────────────────────────────────────────────────
|
||
|
||
async def _handle_set_mode(self, ws: Any, data: dict) -> None:
|
||
new_mode = data.get("mode", "task")
|
||
new_profile = data.get("profile", "corporate")
|
||
org_id = self._normalize_session_org_id(
|
||
data.get("org_id") or data.get("organization_id")
|
||
)
|
||
new_preferred_agent = self._normalize_session_preferred_agent(
|
||
data.get("preferred_agent", self._task_preferred_agent),
|
||
default=self._task_preferred_agent,
|
||
)
|
||
ok = await self._apply_mode_switch(new_mode, new_profile, new_preferred_agent, org_id=org_id)
|
||
if not ok:
|
||
await self._send_ack(
|
||
ws,
|
||
ok=False,
|
||
error=getattr(self, "_last_org_load_error", "") or "org_not_found",
|
||
org_id=org_id,
|
||
)
|
||
return
|
||
active_org_id = (org_id or await self._get_active_saved_org_name()) if self._exec_mode == "org" else ""
|
||
await self._send_ack(
|
||
ws,
|
||
ok=True,
|
||
mode=self._exec_mode,
|
||
profile=self._company_profile,
|
||
org_id=active_org_id,
|
||
preferred_agent=self._task_preferred_agent,
|
||
)
|
||
|
||
async def _apply_mode_switch(
|
||
self,
|
||
new_mode: str,
|
||
new_profile: str,
|
||
new_preferred_agent: str,
|
||
*,
|
||
sync_config: bool = True,
|
||
org_id: str | None = None,
|
||
) -> bool:
|
||
# Mode is a default for new turns. Existing sessions carry their own
|
||
# persisted mode/profile, so switching the toolbar must not interrupt or
|
||
# rewrite running task state.
|
||
previous_mode = getattr(self, "_exec_mode", "task")
|
||
previous_profile = getattr(self, "_company_profile", "corporate")
|
||
previous_preferred_agent = getattr(self, "_task_preferred_agent", "native")
|
||
new_mode = self._normalize_session_exec_mode(new_mode)
|
||
self._last_org_load_error = ""
|
||
if sync_config and new_mode == "org" and org_id:
|
||
config_dir = Path(getattr(self.engine, "opc_home", None) or Path.cwd() / ".opc") / "config"
|
||
if not org_config_path(config_dir, org_id).exists():
|
||
self._last_org_load_error = "org_not_found"
|
||
return False
|
||
new_profile = "custom" if new_mode == "org" else self._normalize_session_company_profile(new_profile)
|
||
self._exec_mode = new_mode
|
||
self._company_profile = new_profile
|
||
self._task_preferred_agent = new_preferred_agent
|
||
if hasattr(self, "services_context"):
|
||
self.services_context.mode_state.exec_mode = self._exec_mode
|
||
self.services_context.mode_state.company_profile = self._company_profile
|
||
self.services_context.mode_state.task_preferred_agent = self._task_preferred_agent
|
||
await self._persist_mode()
|
||
|
||
# Sync company_profile in config so _effective_roles() uses the right mode
|
||
if sync_config and self.engine.org_engine:
|
||
async with self._config_lock:
|
||
if new_mode == "org":
|
||
loaded = self._load_active_org_config_into_engine(org_id)
|
||
if not loaded:
|
||
self._exec_mode = previous_mode
|
||
self._company_profile = previous_profile
|
||
self._task_preferred_agent = previous_preferred_agent
|
||
if hasattr(self, "services_context"):
|
||
self.services_context.mode_state.exec_mode = self._exec_mode
|
||
self.services_context.mode_state.company_profile = self._company_profile
|
||
self.services_context.mode_state.task_preferred_agent = self._task_preferred_agent
|
||
await self._persist_mode()
|
||
return False
|
||
elif org_id:
|
||
await self._set_active_saved_org_name(org_id)
|
||
elif new_mode == "company":
|
||
self._restore_company_config_into_engine(new_profile)
|
||
else:
|
||
self._restore_company_config_into_engine("")
|
||
# Mode switching persists only the selected UI mode. It must not
|
||
# rewrite company/org architecture files.
|
||
|
||
# Reload agents for the new mode
|
||
if self.engine.org_engine:
|
||
preset = self._resolve_preset_name()
|
||
await self.agent_store.load_preset(preset, self.engine.org_engine)
|
||
|
||
await self._prune_stale_agent_store_entries()
|
||
if self._exec_mode in {"org", "custom"}:
|
||
await self._ensure_custom_role_agents()
|
||
|
||
# Sync role→agent mapping for EventAdapter
|
||
await self._sync_role_map()
|
||
|
||
# B4: Clean orphan references — clear assigned_to on pending tasks
|
||
# whose role no longer exists, and prune stale DM channels
|
||
new_agents = await self.agent_store.get_all()
|
||
valid_agent_ids = {a["agent_id"] for a in new_agents}
|
||
valid_role_ids = {a.get("opc_role_id", a["agent_id"]) for a in new_agents}
|
||
if self.engine.store:
|
||
pending_tasks = await self.engine.store.get_tasks(project_id=self.engine.project_id or "default")
|
||
for task in pending_tasks:
|
||
if task.assigned_to and task.assigned_to not in valid_role_ids:
|
||
task.assigned_to = ""
|
||
await self.engine.store.save_task(task)
|
||
await self.chat_store.prune_stale_channels(valid_agent_ids, project_id=self.engine.project_id or "default")
|
||
# Ensure activity channel exists (session channels are on-demand)
|
||
await self.chat_store.ensure_activity_channel(project_id=self.engine.project_id or "default")
|
||
|
||
# Broadcast snapshot + full collab_sync so frontend refreshes everything
|
||
snapshot = await build_snapshot(
|
||
self.engine, self.agent_store, self.chat_store, self.event_adapter
|
||
)
|
||
snapshot["exec_mode"] = self._exec_mode
|
||
snapshot["company_profile"] = self._company_profile
|
||
snapshot["task_preferred_agent"] = self._task_preferred_agent
|
||
await self.broadcast({"type": "snapshot", "payload": snapshot})
|
||
|
||
collab = await build_collab_sync(
|
||
self.engine,
|
||
self.agent_store,
|
||
self.chat_store,
|
||
self.event_adapter,
|
||
exec_mode=self._exec_mode,
|
||
)
|
||
await self.broadcast({"type": "collab_sync_push", "payload": collab})
|
||
await self._broadcast_org_info()
|
||
return True
|
||
|
||
async def _prune_stale_agent_store_entries(self) -> None:
|
||
if not self.engine.org_engine or not hasattr(self, "agent_store"):
|
||
return
|
||
|
||
effective_role_ids = {agent.role_id for agent in self.engine.org_engine.list_agents()}
|
||
try:
|
||
effective_employee_ids = {employee.employee_id for employee in self.engine.config.org.employees}
|
||
except Exception:
|
||
effective_employee_ids = set()
|
||
|
||
for stale in await self.agent_store.get_all():
|
||
emp_id = str(stale.get("employee_id") or "").strip()
|
||
role_id = str(stale.get("opc_role_id") or stale.get("agent_id") or "").strip()
|
||
is_stale = (
|
||
(emp_id and emp_id not in effective_employee_ids)
|
||
or (not emp_id and role_id and role_id not in effective_role_ids)
|
||
)
|
||
if is_stale:
|
||
try:
|
||
await self.agent_store.remove_agent(stale["agent_id"])
|
||
except Exception:
|
||
logger.debug(f"Failed to prune stale agent {stale.get('agent_id')}")
|
||
|
||
if self._exec_mode in {"org", "custom"}:
|
||
await self.agent_store.sync_custom_shadow()
|
||
|
||
def _target_mode_for_profile(self, profile: str | None) -> tuple[str | None, str | None]:
|
||
if profile == "custom":
|
||
return "org", "custom"
|
||
if profile == "corporate":
|
||
return "company", "corporate"
|
||
return None, None
|
||
|
||
def _rebind_engine_config(self, config: Any) -> None:
|
||
self.engine.config = config
|
||
org_engine = getattr(self.engine, "org_engine", None)
|
||
if org_engine is not None:
|
||
org_engine.config = config
|
||
talent_market = getattr(self.engine, "talent_market", None)
|
||
if talent_market is not None:
|
||
talent_market.config = config
|
||
if hasattr(self.engine, "_runtime_config_signature"):
|
||
self.engine._runtime_config_signature = None
|
||
|
||
def _restore_company_config_into_engine(self, company_profile: str) -> None:
|
||
config_dir = Path(getattr(self.engine, "opc_home", None) or Path.cwd() / ".opc") / "config"
|
||
try:
|
||
loaded_config = OPCConfig.load(config_dir) if config_dir.exists() else OPCConfig()
|
||
except Exception as exc:
|
||
logger.warning(f"Failed to reload company architecture after leaving org mode: {exc}")
|
||
loaded_config = self.engine.config
|
||
loaded_config.org.company_profile = company_profile
|
||
self._rebind_engine_config(loaded_config)
|
||
if self.engine.org_engine:
|
||
self.engine.org_engine.reload_from_config()
|
||
configure_tools = getattr(self.engine.org_engine, "configure_task_mode_tools", None)
|
||
task_tools = getattr(self.engine, "_task_mode_tool_names", None)
|
||
if callable(configure_tools) and callable(task_tools):
|
||
configure_tools(task_tools())
|
||
|
||
def _load_active_org_config_into_engine(self, organization_id: str | None = None) -> bool:
|
||
config_dir = Path(getattr(self.engine, "opc_home", None) or Path.cwd() / ".opc") / "config"
|
||
self._last_org_load_error = ""
|
||
try:
|
||
payload, source_path = load_org_config_payload(config_dir, organization_id)
|
||
loaded_config = apply_org_config_payload_to_config(
|
||
self.engine.config,
|
||
payload,
|
||
source_path=source_path,
|
||
)
|
||
validate_runnable_org_config(loaded_config, organization_id=organization_id or "")
|
||
except FileNotFoundError:
|
||
self._last_org_load_error = "org_not_found"
|
||
return False
|
||
except Exception as exc:
|
||
self._last_org_load_error = str(exc)
|
||
logger.warning(f"Failed to load org architecture for org mode: {exc}")
|
||
return False
|
||
self._rebind_engine_config(loaded_config)
|
||
if self.engine.org_engine:
|
||
self.engine.org_engine.reload_from_config()
|
||
return True
|
||
|
||
def _resolve_preset_name(self) -> str:
|
||
"""Map current mode to agent_store preset name.
|
||
|
||
task → "single" (1 executor)
|
||
company → profile name ("corporate")
|
||
org → "custom" (user-managed agents)
|
||
"""
|
||
if self._exec_mode in {"org", "custom"}:
|
||
return "custom"
|
||
if self._exec_mode == "company":
|
||
return self._company_profile # "corporate"
|
||
return "single" # task mode uses single-agent preset
|
||
|
||
async def _handle_run_task(self, ws: Any, data: dict) -> None:
|
||
title = data.get("title", "")
|
||
description = data.get("description", "")
|
||
mode = data.get("mode", self._exec_mode)
|
||
profile = data.get("profile", self._company_profile)
|
||
org_id = self._normalize_session_org_id(data.get("org_id") or data.get("organization_id"))
|
||
task_id = data.get("task_id")
|
||
run_engine, run_project_id = await self._engine_for_request(data)
|
||
self._track(self._run_task(
|
||
title,
|
||
description,
|
||
mode,
|
||
profile,
|
||
task_id=task_id,
|
||
run_engine=run_engine,
|
||
run_project_id=run_project_id,
|
||
org_id=org_id,
|
||
))
|
||
await self._send_ack(ws, ok=True)
|
||
|
||
# ── Cross-Office ──────────────────────────────────────────────────
|
||
|
||
async def _handle_cross_office(self, ws: Any, data: dict) -> None:
|
||
"""Visual-only: broadcasts collab event for frontend animation. No engine dispatch."""
|
||
await self.broadcast({"type": "cross_office_collab", "payload": {
|
||
"agent_ids": data.get("agent_ids", []),
|
||
"task_id": data.get("task_id", ""),
|
||
"action": data.get("action", ""),
|
||
}})
|
||
|
||
# ── Workload ─────────────────────────────────────────────────────
|
||
|
||
async def _handle_agent_workload(self, ws: Any, data: dict) -> None:
|
||
"""Return per-agent task counts (active, pending, done, failed)."""
|
||
if not self.engine.store:
|
||
await self._send_ack(ws, ok=True, workload={})
|
||
return
|
||
agents = await self.agent_store.get_all()
|
||
# Fetch all tasks once, then group by assigned_to (opc_role_id)
|
||
all_tasks = await self.engine.store.get_tasks(
|
||
project_id=self.engine.project_id or "default",
|
||
)
|
||
# Build role_id → task list mapping
|
||
role_tasks: dict[str, list] = {}
|
||
for t in all_tasks:
|
||
role_tasks.setdefault(t.assigned_to, []).append(t)
|
||
|
||
workload: dict[str, dict[str, int]] = {}
|
||
for agent in agents:
|
||
agent_id = agent.get("agent_id", "")
|
||
if not agent_id:
|
||
continue
|
||
# Tasks are assigned by opc_role_id, not agent_id
|
||
role_id = agent.get("opc_role_id", agent_id)
|
||
counts = {"active": 0, "pending": 0, "done": 0, "failed": 0}
|
||
for t in role_tasks.get(role_id, []):
|
||
sv = t.status.value if hasattr(t.status, "value") else str(t.status)
|
||
if sv == "running":
|
||
counts["active"] += 1
|
||
elif sv == "pending":
|
||
counts["pending"] += 1
|
||
elif sv == "done":
|
||
counts["done"] += 1
|
||
elif sv == "failed":
|
||
counts["failed"] += 1
|
||
workload[agent_id] = counts
|
||
await self._send_ack(ws, ok=True, workload=workload)
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# Private helpers
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
def _controller_active_task_run_registry(self) -> Any | None:
|
||
root_engine = getattr(self, "_root_engine", None) or getattr(self, "engine", None)
|
||
registry = getattr(root_engine, "_active_task_run_registry", None)
|
||
return registry if isinstance(registry, ActiveTaskRunRegistry) else None
|
||
|
||
def _release_current_execution_handoff(self) -> None:
|
||
registry = self._controller_active_task_run_registry()
|
||
release = getattr(registry, "release_current_handoff", None)
|
||
if callable(release):
|
||
release()
|
||
|
||
def _track(self, coro: Any) -> asyncio.Task[Any]:
|
||
"""Create a tracked background task that auto-removes itself on completion."""
|
||
handoff_registry = self._controller_active_task_run_registry()
|
||
handoff_token = (
|
||
handoff_registry.retain_current_handoff()
|
||
if handoff_registry is not None
|
||
else None
|
||
)
|
||
try:
|
||
task = asyncio.create_task(coro)
|
||
except BaseException:
|
||
if handoff_registry is not None and handoff_token is not None:
|
||
handoff_registry.release_handoff(handoff_token)
|
||
if inspect.iscoroutine(coro):
|
||
coro.close()
|
||
raise
|
||
if handoff_registry is not None and handoff_token is not None:
|
||
def _release_execution_handoff(
|
||
done_task: asyncio.Task[Any],
|
||
*,
|
||
registry: ActiveTaskRunRegistry = handoff_registry,
|
||
token: str = handoff_token,
|
||
) -> None:
|
||
registry.release_handoff(token)
|
||
task_context = getattr(self, "_task_bg_context", None)
|
||
if isinstance(task_context, dict):
|
||
context = task_context.get(done_task)
|
||
if isinstance(context, dict):
|
||
context.pop("execution_handoff_token", None)
|
||
if not context:
|
||
task_context.pop(done_task, None)
|
||
|
||
task.add_done_callback(
|
||
_release_execution_handoff
|
||
)
|
||
# This task owns the accepted engine handoff even before callers
|
||
# such as _track_session add their richer session context. Keep it
|
||
# in the existing execution-owned task index so shutdown cannot
|
||
# close the store while its cancellation cleanup is still running.
|
||
task_context = getattr(self, "_task_bg_context", None)
|
||
if isinstance(task_context, dict):
|
||
context = task_context.setdefault(task, {})
|
||
context["execution_handoff_token"] = handoff_token
|
||
# Work scheduled after the gate closes is rejected. A coroutine
|
||
# retained by a pre-shutdown reservation is the one exception: it must
|
||
# reach registry.register (or exit) before checkpoint snapshotting.
|
||
if self._shutting_down and handoff_token is None:
|
||
task.cancel()
|
||
self._background_tasks.add(task)
|
||
task.add_done_callback(self._on_bg_task_done)
|
||
return task
|
||
|
||
def _cancel_client_project_index(self, ws: Any) -> None:
|
||
task = self._client_project_index_tasks.pop(ws, None)
|
||
if task is not None and not task.done():
|
||
task.cancel()
|
||
|
||
def _track_client_project_index(
|
||
self,
|
||
ws: Any,
|
||
coro: Any,
|
||
) -> asyncio.Task[Any]:
|
||
self._cancel_client_project_index(ws)
|
||
task = self._track(coro)
|
||
self._client_project_index_tasks[ws] = task
|
||
|
||
def _cleanup(done: asyncio.Task[Any]) -> None:
|
||
if self._client_project_index_tasks.get(ws) is done:
|
||
self._client_project_index_tasks.pop(ws, None)
|
||
|
||
task.add_done_callback(_cleanup)
|
||
return task
|
||
|
||
def _track_client_initial_state(
|
||
self,
|
||
ws: Any,
|
||
coro: Any,
|
||
) -> asyncio.Task[Any]:
|
||
prior = self._client_initial_state_tasks.pop(ws, None)
|
||
if prior is not None and not prior.done():
|
||
prior.cancel()
|
||
task = self._track(coro)
|
||
self._client_initial_state_tasks[ws] = task
|
||
|
||
def _cleanup(done: asyncio.Task[Any]) -> None:
|
||
if self._client_initial_state_tasks.get(ws) is done:
|
||
self._client_initial_state_tasks.pop(ws, None)
|
||
|
||
task.add_done_callback(_cleanup)
|
||
return task
|
||
|
||
def _track_session(
|
||
self,
|
||
task_id: str,
|
||
coro: Any,
|
||
*,
|
||
project_id: str | None = None,
|
||
engine: Any | None = None,
|
||
) -> asyncio.Task[Any]:
|
||
"""Like _track but keeps all live tasks for explicit cancellation."""
|
||
bg = self._track(coro)
|
||
context = dict(self._task_bg_context.get(bg) or {})
|
||
context.update({
|
||
"task_id": task_id,
|
||
"project_id": self._normalize_project_id(project_id or getattr(engine, "project_id", None)),
|
||
"engine": engine,
|
||
})
|
||
self._task_bg_context[bg] = context
|
||
task_group = self._task_bg_map.setdefault(task_id, set())
|
||
task_group.add(bg)
|
||
bg.add_done_callback(lambda t: self._discard_session_bg_task(task_id, t))
|
||
return bg
|
||
|
||
def _discard_session_bg_task(self, task_id: str, task: asyncio.Task[Any]) -> None:
|
||
self._task_bg_context.pop(task, None)
|
||
task_group = self._task_bg_map.get(task_id)
|
||
if task_group is None:
|
||
return
|
||
task_group.discard(task)
|
||
if not task_group:
|
||
self._task_bg_map.pop(task_id, None)
|
||
|
||
def _cancel_session_tasks(self, task_id: str) -> None:
|
||
task_group = self._task_bg_map.pop(task_id, set())
|
||
for bg_task in list(task_group):
|
||
try:
|
||
if not bg_task.done():
|
||
bg_task.cancel()
|
||
except Exception:
|
||
logger.opt(exception=True).debug(f"Failed to cancel background task for {task_id}")
|
||
|
||
@staticmethod
|
||
def _is_ws_disconnect_error(exc: BaseException) -> bool:
|
||
message = str(exc or "").strip().lower()
|
||
if type(exc).__name__ in {"ClientConnectionResetError", "ConnectionResetError"}:
|
||
return True
|
||
return any(
|
||
token in message
|
||
for token in (
|
||
"cannot write to closing transport",
|
||
"closing transport",
|
||
"websocket connection is closed",
|
||
"connection reset by peer",
|
||
"broken pipe",
|
||
)
|
||
)
|
||
|
||
@staticmethod
|
||
def _is_closed_database_error(exc: BaseException) -> bool:
|
||
message = str(exc or "").strip().lower()
|
||
return any(
|
||
token in message
|
||
for token in (
|
||
"cannot operate on a closed database",
|
||
"closed database",
|
||
"no active connection",
|
||
)
|
||
)
|
||
|
||
def _is_expected_shutdown_error(self, exc: BaseException) -> bool:
|
||
return self._shutting_down and (
|
||
self._is_ws_disconnect_error(exc) or self._is_closed_database_error(exc)
|
||
)
|
||
|
||
@staticmethod
|
||
def _ws_flag_is_set(value: Any) -> bool:
|
||
return isinstance(value, bool) and value
|
||
|
||
@classmethod
|
||
def _ws_is_open(cls, ws: Any) -> bool:
|
||
return not (
|
||
cls._ws_flag_is_set(getattr(ws, "closed", False))
|
||
or cls._ws_flag_is_set(getattr(ws, "closing", False))
|
||
)
|
||
|
||
async def _safe_send_json(self, ws: Any, payload: dict[str, Any]) -> bool:
|
||
if not self._ws_is_open(ws):
|
||
self._clients.discard(ws)
|
||
return False
|
||
try:
|
||
result = ws.send_json(payload)
|
||
if inspect.isawaitable(result):
|
||
await result
|
||
return True
|
||
except Exception as exc:
|
||
if self._is_ws_disconnect_error(exc) or self._shutting_down:
|
||
self._clients.discard(ws)
|
||
logger.debug(f"Skipped WS send during disconnect/shutdown: {type(exc).__name__}: {exc!r}")
|
||
return False
|
||
raise
|
||
|
||
async def shutdown(self, timeout: float = 15.0) -> None:
|
||
"""Checkpoint owned runtimes, then stop all WS work before DB shutdown."""
|
||
import aiohttp
|
||
|
||
self._shutting_down = True
|
||
pending_handlers = [
|
||
task
|
||
for task in self._active_message_tasks
|
||
if task is not asyncio.current_task() and not task.done()
|
||
]
|
||
|
||
def execution_owned_tasks() -> set[asyncio.Task[Any]]:
|
||
owned = set(self._task_bg_context)
|
||
for task_group in self._task_bg_map.values():
|
||
owned.update(task_group)
|
||
return {
|
||
task
|
||
for task in owned
|
||
if task is not asyncio.current_task() and not task.done()
|
||
}
|
||
|
||
# A preaccepted handler can replace its handoff reservation with the
|
||
# real execution task while the admission barrier drains. Union this
|
||
# pre-barrier view with the post-checkpoint view so neither owner can
|
||
# disappear from the cancellation set during that handoff.
|
||
execution_tasks_before_prepare = execution_owned_tasks()
|
||
|
||
# Atomically close execution admission, then wait only for requests
|
||
# accepted before that close to either register their first real engine
|
||
# coroutine or exit. There is intentionally no timeout window here:
|
||
# registration drains the reservation, so long execution is not part
|
||
# of this wait.
|
||
active_run_registry = self._controller_active_task_run_registry()
|
||
close_and_wait = getattr(
|
||
active_run_registry,
|
||
"close_admission_and_wait_for_handoffs",
|
||
None,
|
||
)
|
||
if callable(close_and_wait):
|
||
# Close synchronously, then cancel accepted requests which are
|
||
# still queued before their first engine registration. In
|
||
# particular, a duplicate Continue can be waiting behind the
|
||
# per-runtime reply lock for the entire first execution; waiting
|
||
# for that reservation here would deadlock shutdown before it can
|
||
# checkpoint and cancel the live owner.
|
||
active_run_registry.close_admission()
|
||
is_handoff_pending = getattr(
|
||
active_run_registry,
|
||
"is_handoff_pending",
|
||
None,
|
||
)
|
||
pending_handoff_tasks: set[asyncio.Task[Any]] = set()
|
||
pending_handoff_tokens: set[str] = set()
|
||
if callable(is_handoff_pending):
|
||
for task, context in list(self._task_bg_context.items()):
|
||
token = (
|
||
context.get("execution_handoff_token")
|
||
if isinstance(context, dict)
|
||
else None
|
||
)
|
||
if (
|
||
task is not asyncio.current_task()
|
||
and not task.done()
|
||
and is_handoff_pending(token)
|
||
):
|
||
pending_handoff_tasks.add(task)
|
||
pending_handoff_tokens.add(str(token))
|
||
handoff_routes = getattr(self, "_handoff_route_tasks", {})
|
||
if isinstance(handoff_routes, dict):
|
||
for task, token in list(handoff_routes.items()):
|
||
if (
|
||
task is not asyncio.current_task()
|
||
and not task.done()
|
||
and is_handoff_pending(token)
|
||
):
|
||
pending_handoff_tasks.add(task)
|
||
pending_handoff_tokens.add(token)
|
||
for task in pending_handoff_tasks:
|
||
task.cancel()
|
||
revoke_handoff = getattr(active_run_registry, "revoke_handoff", None)
|
||
if callable(revoke_handoff):
|
||
for token in pending_handoff_tokens:
|
||
revoke_handoff(token)
|
||
await close_and_wait()
|
||
|
||
# The controller registry is the only source of truth for executions
|
||
# owned by this process. Persist one checkpoint per active company
|
||
# scope while every execution coroutine and its store are still alive.
|
||
root_engine = getattr(self, "_root_engine", None) or self.engine
|
||
try:
|
||
await root_engine.prepare_active_company_runtimes_for_shutdown()
|
||
except Exception:
|
||
logger.opt(exception=True).error(
|
||
"Refusing to cancel WS execution because company runtime checkpointing failed"
|
||
)
|
||
raise
|
||
|
||
# No execution coroutine may get another scheduling turn after its
|
||
# checkpoint/holds are durable. Build the cancellation set and set
|
||
# every cancellation flag synchronously, before awaiting progress
|
||
# flushing, client close handshakes, or any other UI cleanup.
|
||
execution_tasks = execution_tasks_before_prepare | execution_owned_tasks()
|
||
tracked_tasks = set(self._background_tasks)
|
||
tracked_tasks.update(execution_tasks)
|
||
tracked_tasks.update(pending_handlers)
|
||
tracked_tasks = {
|
||
task
|
||
for task in tracked_tasks
|
||
if task is not asyncio.current_task() and not task.done()
|
||
}
|
||
for task in tracked_tasks:
|
||
task.cancel()
|
||
if self._progress_flush_task and not self._progress_flush_task.done():
|
||
self._progress_flush_task.cancel()
|
||
|
||
# Await execution cleanup first so broker finally blocks reap process
|
||
# groups while the engine/store are guaranteed to remain available.
|
||
if execution_tasks:
|
||
_done, still_cleaning = await asyncio.wait(
|
||
execution_tasks,
|
||
timeout=max(0.0, float(timeout)),
|
||
)
|
||
if still_cleaning:
|
||
message = (
|
||
"Timed out waiting for "
|
||
f"{len(still_cleaning)} execution task(s) to finish cancellation cleanup"
|
||
)
|
||
logger.error(message)
|
||
raise RuntimeError(message)
|
||
|
||
if self._progress_flush_task:
|
||
try:
|
||
await self._progress_flush_task
|
||
except (asyncio.CancelledError, Exception):
|
||
pass
|
||
self._progress_flush_task = None
|
||
|
||
if self._runtime_status_sync_task:
|
||
# Cancel rather than await: the sync interval is long enough that
|
||
# waiting for a natural wake-up would stall shutdown.
|
||
self._runtime_status_sync_task.cancel()
|
||
try:
|
||
await self._runtime_status_sync_task
|
||
except (asyncio.CancelledError, Exception):
|
||
pass
|
||
self._runtime_status_sync_task = None
|
||
|
||
clients = list(self._clients)
|
||
for ws in clients:
|
||
try:
|
||
await ws.close(
|
||
code=aiohttp.WSCloseCode.GOING_AWAY,
|
||
message=b"server shutting down",
|
||
)
|
||
except Exception as exc:
|
||
if not self._is_ws_disconnect_error(exc):
|
||
logger.debug(f"Failed to close WS client cleanly: {type(exc).__name__}: {exc!r}")
|
||
|
||
ui_background_tasks = tracked_tasks - execution_tasks
|
||
if ui_background_tasks:
|
||
_done, still_running = await asyncio.wait(
|
||
ui_background_tasks,
|
||
timeout=max(0.0, float(timeout)),
|
||
)
|
||
if still_running:
|
||
logger.warning(
|
||
"Timed out waiting for {} non-execution WS background task(s) to stop",
|
||
len(still_running),
|
||
)
|
||
|
||
@staticmethod
|
||
def _store_is_ready(store: Any | None) -> bool:
|
||
"""Treat initialized stores as ready, even without an explicit flag."""
|
||
if not store:
|
||
return False
|
||
ready = getattr(store, "is_ready", None)
|
||
if callable(ready):
|
||
return bool(ready())
|
||
if ready is None:
|
||
return True
|
||
return bool(ready)
|
||
|
||
@staticmethod
|
||
def _chat_store_is_ready(chat_store: Any | None) -> bool:
|
||
if chat_store is None:
|
||
return False
|
||
ready = getattr(chat_store, "is_ready", None)
|
||
if callable(ready):
|
||
try:
|
||
if not bool(ready()):
|
||
return False
|
||
except Exception:
|
||
return False
|
||
elif ready is not None and not bool(ready):
|
||
return False
|
||
db = getattr(chat_store, "_db", None)
|
||
if db is None:
|
||
return False
|
||
connection = getattr(db, "_connection", True)
|
||
return connection is not None
|
||
|
||
@staticmethod
|
||
def _task_has_comms_workspace(task: Any | None) -> bool:
|
||
if task is None:
|
||
return False
|
||
metadata = dict(getattr(task, "metadata", {}) or {})
|
||
return bool(
|
||
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()
|
||
)
|
||
|
||
@staticmethod
|
||
def _shared_root_ui_task_id(task: Any | None) -> str:
|
||
if task is None:
|
||
return ""
|
||
metadata = dict(getattr(task, "metadata", {}) or {})
|
||
if not bool(metadata.get("shared_role_session", False)):
|
||
return ""
|
||
session_id = str(getattr(task, "session_id", "") or "").strip()
|
||
root_session_id = str(metadata.get("company_runtime_root_session_id", "") or "").strip()
|
||
origin_task_id = str(metadata.get("origin_task_id", "") or "").strip()
|
||
if origin_task_id and root_session_id and session_id == root_session_id:
|
||
return origin_task_id
|
||
return ""
|
||
|
||
@staticmethod
|
||
def _task_mode_origin_ui_task_id(task: Any | None) -> str:
|
||
if task is None:
|
||
return ""
|
||
metadata = dict(getattr(task, "metadata", {}) or {})
|
||
task_id = str(getattr(task, "id", "") or "").strip()
|
||
origin_task_id = str(metadata.get("origin_task_id", "") or "").strip()
|
||
if not origin_task_id or origin_task_id == task_id:
|
||
return ""
|
||
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()
|
||
if (
|
||
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"
|
||
):
|
||
return origin_task_id
|
||
return ""
|
||
|
||
def _ui_task_id_for_task(self, task: Any | None) -> str:
|
||
if task is None:
|
||
return ""
|
||
task_id = str(getattr(task, "id", "") or "").strip()
|
||
ui_task_id = self._shared_root_ui_task_id(task) or self._task_mode_origin_ui_task_id(task) or task_id
|
||
if task_id and ui_task_id and ui_task_id != task_id:
|
||
self._ui_task_aliases[task_id] = ui_task_id
|
||
return ui_task_id
|
||
|
||
async def _ui_task_id_for_runtime_task_id(self, task_id: str | None, *, engine: Any | None = None) -> str:
|
||
raw_task_id = str(task_id or "").strip()
|
||
if not raw_task_id:
|
||
return ""
|
||
mapped = str(self._ui_task_aliases.get(raw_task_id) or "").strip()
|
||
if mapped:
|
||
return mapped
|
||
runtime_engine = engine or self.engine
|
||
store = getattr(runtime_engine, "store", None)
|
||
get_task = getattr(store, "get_task", None)
|
||
if callable(get_task):
|
||
try:
|
||
task = await get_task(raw_task_id)
|
||
except Exception:
|
||
task = None
|
||
mapped = self._ui_task_id_for_task(task)
|
||
if mapped:
|
||
return mapped
|
||
return raw_task_id
|
||
|
||
def _on_bg_task_done(self, task: asyncio.Task[Any]) -> None:
|
||
"""Callback for tracked background tasks: cleanup + log unhandled errors.
|
||
|
||
For session tasks, also broadcasts an error message and idle status
|
||
so the frontend doesn't stay stuck on "thinking" forever.
|
||
"""
|
||
self._background_tasks.discard(task)
|
||
if task.cancelled():
|
||
return
|
||
exc = task.exception()
|
||
if exc is not None:
|
||
logger.opt(exception=exc).error(f"Background task failed: {exc}")
|
||
# Notify frontend for session tasks so UI doesn't stay stuck on "thinking"
|
||
context = self._task_bg_context.get(task) or {}
|
||
task_id = self._find_task_id_for_bg_task(task)
|
||
if task_id:
|
||
asyncio.ensure_future(self._notify_session_bg_failure(
|
||
task_id,
|
||
exc,
|
||
project_id=str(context.get("project_id") or "").strip() or None,
|
||
))
|
||
|
||
def _find_task_id_for_bg_task(self, bg_task: asyncio.Task[Any]) -> str | None:
|
||
"""Find the task_id associated with a background task."""
|
||
context = self._task_bg_context.get(bg_task)
|
||
if context:
|
||
task_id = str(context.get("task_id", "") or "").strip()
|
||
if task_id:
|
||
return task_id
|
||
for tid, task_group in self._task_bg_map.items():
|
||
if bg_task in task_group:
|
||
return tid
|
||
return None
|
||
|
||
async def _notify_session_bg_failure(
|
||
self,
|
||
task_id: str,
|
||
exc: BaseException,
|
||
*,
|
||
project_id: str | None = None,
|
||
) -> None:
|
||
"""Broadcast error message + idle status for a failed session background task."""
|
||
try:
|
||
channel_id = f"session:{task_id}"
|
||
pid = self._normalize_project_id(project_id or self.engine.project_id)
|
||
msg = await self.chat_store.insert_message(
|
||
channel_id=channel_id,
|
||
sender="system",
|
||
sender_name="OPC",
|
||
content=f"Error: {exc}",
|
||
project_id=pid,
|
||
)
|
||
await self.broadcast({"type": "session_message", "payload": msg})
|
||
await self.broadcast({"type": "board_task_status_changed", "payload": {
|
||
"project_id": pid, "task_id": task_id, "column_id": "in-progress", "status": "failed",
|
||
}})
|
||
idle_payload: dict[str, Any] = {
|
||
"project_id": pid,
|
||
"task_id": task_id, "status": "idle", "current_tool": None, "iteration": 0,
|
||
}
|
||
resolved_agent = self._resolve_agent_for_idle(task_id)
|
||
if resolved_agent:
|
||
idle_payload["agent_id"] = resolved_agent
|
||
await self.broadcast({"type": "agent_runtime_update", "payload": idle_payload})
|
||
except Exception as notify_exc:
|
||
logger.debug(f"Failed to notify frontend of session bg failure: {notify_exc}")
|
||
|
||
async def _send_ack(self, ws: Any, ok: bool = True, **extra: Any) -> None:
|
||
payload: dict[str, Any] = {"ok": ok, **extra}
|
||
await self._safe_send_json(ws, {"type": "ack", "payload": payload})
|
||
|
||
async def _publish_service_result(self, result: ServiceResult) -> None:
|
||
for event in result.events:
|
||
await self.broadcast({"type": event.type, "payload": event.payload})
|
||
|
||
async def _send_service_ack(self, ws: Any, result: ServiceResult, **extra: Any) -> None:
|
||
payload = dict(result.payload or {})
|
||
payload.update(extra)
|
||
ok = bool(payload.pop("ok", True))
|
||
await self._send_ack(ws, ok=ok, **payload)
|
||
|
||
async def _send_service_error(self, ws: Any, exc: ServiceError, *, action: str | None = None) -> None:
|
||
payload = exc.to_payload()
|
||
payload.pop("ok", None)
|
||
if action:
|
||
payload["action"] = action
|
||
await self._send_ack(ws, ok=False, **payload)
|
||
|
||
async def _refresh_runtime_control_for_client(
|
||
self,
|
||
ws: Any,
|
||
*,
|
||
engine: Any,
|
||
project_id: str,
|
||
) -> None:
|
||
"""Replace optimistic Continue state with a durable snapshot."""
|
||
try:
|
||
collab = await build_collab_sync(
|
||
engine,
|
||
self.agent_store,
|
||
self.chat_store,
|
||
self.event_adapter,
|
||
exec_mode=self._exec_mode,
|
||
)
|
||
collab["project_id"] = self._normalize_project_id(project_id)
|
||
await self._safe_send_json(ws, {"type": "collab_sync_push", "payload": collab})
|
||
except Exception:
|
||
logger.opt(exception=True).debug("failed to refresh runtime control after rejected Continue")
|
||
|
||
# UI profile name → engine CompanyProfile name
|
||
_PROFILE_TO_ENGINE: dict[str, str] = {"classic": "corporate"}
|
||
|
||
def _normalize_session_exec_mode(self, value: Any) -> str:
|
||
return SessionService.normalize_exec_mode(value)
|
||
|
||
def _normalize_session_company_profile(self, value: Any) -> str:
|
||
return self._ensure_office_services().session.normalize_company_profile(value)
|
||
|
||
def _normalize_session_preferred_agent(self, value: Any, default: str = "native") -> str:
|
||
return SessionService.normalize_preferred_agent(value, default=default)
|
||
|
||
def _normalize_session_org_id(self, value: Any) -> str:
|
||
return SessionService.normalize_org_id(value)
|
||
|
||
def _resolve_task_session_config(self, task: Any | None) -> tuple[str, str]:
|
||
return self._ensure_office_services().session.resolve_task_session_config(task)
|
||
|
||
def _resolve_task_identity(self, task: Any | None, **defaults: Any) -> Any:
|
||
return self._ensure_office_services().session.resolve_task_identity(task, **defaults)
|
||
|
||
def _resolve_task_org_id(self, task: Any | None) -> str:
|
||
return self._ensure_office_services().session.resolve_task_org_id(task)
|
||
|
||
async def _resolve_session_runtime_config_task(
|
||
self,
|
||
task_id: str,
|
||
task: Any | None,
|
||
*,
|
||
engine: Any,
|
||
) -> Any | None:
|
||
"""Resolve company-session config from the durable runtime identity."""
|
||
if task is None:
|
||
return None
|
||
exec_mode, _ = self._resolve_task_session_config(task)
|
||
runtime_bound = is_company_runtime_task(task)
|
||
if not runtime_bound and not self._is_company_session_exec_mode(exec_mode):
|
||
return task
|
||
try:
|
||
target = await self._resolve_company_runtime_target(task_id, engine=engine)
|
||
except ServiceError:
|
||
if runtime_bound:
|
||
raise
|
||
logger.opt(exception=True).debug(
|
||
"failed to resolve durable session config task"
|
||
)
|
||
return task
|
||
except Exception as exc:
|
||
if runtime_bound:
|
||
raise ServiceError(
|
||
"company_runtime_identity_mismatch",
|
||
"Company runtime identity could not be resolved",
|
||
{"task_id": str(task_id or "").strip()},
|
||
) from exc
|
||
logger.opt(exception=True).debug(
|
||
"failed to resolve durable session config task"
|
||
)
|
||
return task
|
||
if target is not None:
|
||
return target.get("config_task") or task
|
||
if runtime_bound:
|
||
raise ServiceError(
|
||
"company_runtime_identity_mismatch",
|
||
"Company runtime identity could not be resolved",
|
||
{"task_id": str(task_id or "").strip()},
|
||
)
|
||
return task
|
||
|
||
@staticmethod
|
||
def _is_company_session_exec_mode(exec_mode: Any) -> bool:
|
||
return str(exec_mode or "").strip().lower() in {"company", "org", "custom"}
|
||
|
||
def _resolve_task_preferred_agent(self, task: Any | None) -> str:
|
||
return self._ensure_office_services().session.resolve_task_preferred_agent(task)
|
||
|
||
def _resolve_task_selected_execution_agent(self, task: Any | None) -> str:
|
||
return self._ensure_office_services().session.resolve_task_selected_execution_agent(task)
|
||
|
||
async def _session_config_lock_reason(self, task: Any, project_id: str) -> str:
|
||
"""Return a reason when a session's execution config can no longer change."""
|
||
return await self._ensure_office_services().session.session_config_lock_reason(task, project_id)
|
||
|
||
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:
|
||
await self._ensure_office_services().session.persist_session_config(
|
||
task,
|
||
exec_mode=exec_mode,
|
||
company_profile=company_profile,
|
||
preferred_agent=preferred_agent,
|
||
org_id=org_id,
|
||
engine=engine,
|
||
)
|
||
def _resolve_engine_mode(self, mode: str | None = None, profile: str | None = None) -> tuple[str, str | None]:
|
||
"""Resolve UI execution mode into (engine_mode, company_profile).
|
||
|
||
UI modes → engine API:
|
||
"task" → mode="project", company_profile=None
|
||
"company" → mode="company", company_profile=profile
|
||
"org" → mode="org", company_profile="custom"
|
||
UI profile "classic" maps to engine CompanyProfile "corporate".
|
||
"""
|
||
mode = mode or self._exec_mode
|
||
profile = profile or self._company_profile
|
||
if mode == "company":
|
||
engine_profile = self._PROFILE_TO_ENGINE.get(profile, profile) if profile else None
|
||
return "company", engine_profile
|
||
if mode in {"org", "custom"}:
|
||
return "org", "custom"
|
||
return "project", None
|
||
|
||
async def _run_task(
|
||
self,
|
||
title: str,
|
||
description: str,
|
||
mode: str,
|
||
profile: str,
|
||
task_id: str | None = None,
|
||
*,
|
||
run_engine: Any | None = None,
|
||
run_project_id: str | None = None,
|
||
org_id: str | None = None,
|
||
) -> None:
|
||
"""Execute a task with the selected mode."""
|
||
engine = run_engine or self.engine
|
||
pid = self._normalize_project_id(run_project_id or getattr(engine, "project_id", None))
|
||
error_channel = f"session:{task_id}" if task_id else f"activity:{pid}"
|
||
|
||
# Look up session_id from task
|
||
session_id: str | None = None
|
||
task = None
|
||
config_task = None
|
||
preferred_agent = self._task_preferred_agent
|
||
session_org_id = self._normalize_session_org_id(org_id)
|
||
if task_id and getattr(engine, "store", None):
|
||
task = await engine.store.get_task(task_id)
|
||
if task:
|
||
session_id = task.session_id
|
||
|
||
company_runtime_target: dict[str, Any] | None = None
|
||
try:
|
||
if task is not None:
|
||
config_task = await self._resolve_session_runtime_config_task(
|
||
task_id,
|
||
task,
|
||
engine=engine,
|
||
)
|
||
identity = self._resolve_task_identity(
|
||
config_task,
|
||
default_exec_mode=mode,
|
||
default_company_profile=profile,
|
||
default_preferred_agent=preferred_agent,
|
||
default_org_id=session_org_id,
|
||
)
|
||
mode = identity.exec_mode
|
||
profile = identity.company_profile
|
||
session_org_id = identity.org_id
|
||
preferred_agent = identity.preferred_agent
|
||
if identity.is_custom_org and not identity.org_id:
|
||
raise ServiceError(
|
||
"org_id_required",
|
||
"org_id_required",
|
||
{"project_id": pid, "task_id": task_id},
|
||
)
|
||
content = f"{title}\n{description}".strip()
|
||
engine_mode, company_profile = self._resolve_engine_mode(mode, profile)
|
||
engine_preferred_agent = preferred_agent if engine_mode == "project" else None
|
||
response = None
|
||
|
||
if task_id:
|
||
# Per-task lock: same session serialized, different sessions concurrent
|
||
async with self._get_task_lock(task_id):
|
||
if self._is_company_session_exec_mode(mode) and task is not None and self._store_is_ready(engine.store):
|
||
from opc.core.models import TaskStatus
|
||
await apply_task_status_transition(
|
||
engine.store,
|
||
task,
|
||
target_status_or_phase=TaskStatus.RUNNING,
|
||
reason="run_task_started",
|
||
)
|
||
await self.broadcast({"type": "board_task_status_changed", "payload": {
|
||
"project_id": pid, "task_id": task_id, "column_id": "in-progress", "status": "running",
|
||
}})
|
||
if task is not None:
|
||
await self._persist_session_config(
|
||
task,
|
||
exec_mode=mode,
|
||
company_profile=profile,
|
||
preferred_agent=preferred_agent,
|
||
org_id=session_org_id,
|
||
engine=engine,
|
||
)
|
||
if self._is_company_session_exec_mode(mode):
|
||
try:
|
||
company_runtime_target = await self._resolve_company_runtime_target(task_id, engine=engine)
|
||
await self._set_company_runtime_control(company_runtime_target, state="running")
|
||
except Exception:
|
||
logger.opt(exception=True).debug("failed to mark run_task company runtime running")
|
||
response = await engine.process_message(
|
||
content,
|
||
project_id=pid,
|
||
session_id=session_id,
|
||
mode=engine_mode,
|
||
org_id=session_org_id or None,
|
||
company_profile=company_profile,
|
||
preferred_agent=engine_preferred_agent,
|
||
origin_task_id=task_id,
|
||
)
|
||
await self._sync_task_transcript_messages(task_id, engine=engine)
|
||
else:
|
||
response = await engine.process_message(
|
||
content,
|
||
project_id=pid,
|
||
mode=engine_mode,
|
||
org_id=session_org_id or None,
|
||
company_profile=company_profile,
|
||
preferred_agent=engine_preferred_agent,
|
||
)
|
||
|
||
# Broadcast: task idle (agent responded, waiting for user)
|
||
if task_id:
|
||
await self.broadcast({"type": "board_task_status_changed", "payload": {
|
||
"project_id": pid, "task_id": task_id, "column_id": "in-progress", "status": "idle",
|
||
}})
|
||
if getattr(engine, "store", None):
|
||
from opc.core.models import TaskStatus as TS
|
||
t = await engine.store.get_task(task_id)
|
||
if t:
|
||
t.status = TS.IDLE
|
||
await engine.store.save_task(t)
|
||
if self._is_company_session_exec_mode(mode):
|
||
try:
|
||
idle_target = await self._resolve_company_runtime_target(task_id, engine=engine)
|
||
await self._set_company_runtime_control(idle_target or company_runtime_target, state="idle")
|
||
except Exception:
|
||
logger.opt(exception=True).debug("failed to mark run_task company runtime idle")
|
||
except Exception as e:
|
||
logger.opt(exception=True).error(f"Task execution error: {e}")
|
||
# Broadcast: task failed (stays in in-progress, user can retry)
|
||
if task_id:
|
||
await self.broadcast({"type": "board_task_status_changed", "payload": {
|
||
"project_id": pid, "task_id": task_id, "column_id": "in-progress", "status": "failed",
|
||
}})
|
||
if self._is_company_session_exec_mode(mode):
|
||
try:
|
||
failed_target = await self._resolve_company_runtime_target(task_id, engine=engine)
|
||
await self._set_company_runtime_control(failed_target or company_runtime_target, state="idle")
|
||
except Exception:
|
||
logger.opt(exception=True).debug("failed to clear run_task company runtime after error")
|
||
err_meta: dict[str, Any] = {}
|
||
if task_id:
|
||
err_meta["task_id"] = task_id
|
||
msg = await self.chat_store.insert_message(
|
||
channel_id=error_channel,
|
||
sender="system",
|
||
sender_name="OPC",
|
||
content=f"Task error: {e}",
|
||
metadata=err_meta or None,
|
||
project_id=pid,
|
||
)
|
||
await self.broadcast({"type": "session_message", "payload": msg})
|
||
finally:
|
||
# Always refresh the frontend after a task run so new delegation
|
||
# runs / work items are reflected on the kanban immediately.
|
||
try:
|
||
collab = await build_collab_sync(
|
||
engine, self.agent_store, self.chat_store,
|
||
self.event_adapter,
|
||
exec_mode=mode,
|
||
)
|
||
await self.broadcast({"type": "collab_sync_push", "payload": collab})
|
||
except Exception:
|
||
logger.opt(exception=True).warning("Post-run collab_sync broadcast failed (non-fatal)")
|
||
|
||
async def _mirror_agent_message(
|
||
self,
|
||
event: Any,
|
||
*,
|
||
engine: Any | None = None,
|
||
project_id: str | None = None,
|
||
) -> None:
|
||
"""Mirror agent_message_sent events into session channel or activity."""
|
||
runtime_engine = engine or self.engine
|
||
pid = self._normalize_project_id(project_id or getattr(runtime_engine, "project_id", None))
|
||
p = event.payload or {}
|
||
from_role = p.get("from", "")
|
||
content = p.get("subject", "") or p.get("body", "") or "Message"
|
||
task_id = p.get("task_id")
|
||
|
||
# Resolve opc_role_id → agent_id for consistent sender identity
|
||
from_agent = self.event_adapter._resolve_role_to_agent(from_role) if from_role else ""
|
||
|
||
# Resolve human-readable name: agent store name → org role name → raw ID
|
||
display_name = from_role or from_agent
|
||
if from_agent:
|
||
agents = await self.agent_store.get_all()
|
||
match = next((a for a in agents if a["agent_id"] == from_agent), None)
|
||
if match:
|
||
display_name = match["name"]
|
||
org_engine = getattr(runtime_engine, "org_engine", None)
|
||
if display_name == from_role and org_engine:
|
||
org_role = org_engine.get_agent(from_role)
|
||
if org_role:
|
||
display_name = org_role.name
|
||
|
||
# Route to session channel if task_id is known, else activity
|
||
target_channel = f"session:{task_id}" if task_id else f"activity:{pid}"
|
||
mirror_meta: dict[str, Any] = {}
|
||
if task_id:
|
||
mirror_meta["task_id"] = task_id
|
||
msg = await self.chat_store.insert_message(
|
||
channel_id=target_channel,
|
||
sender=from_agent,
|
||
sender_name=display_name,
|
||
content=content,
|
||
metadata=mirror_meta or None,
|
||
project_id=pid,
|
||
)
|
||
await self.broadcast({"type": "session_message", "payload": msg})
|
||
|
||
async def _mirror_escalation(
|
||
self,
|
||
event: Any,
|
||
*,
|
||
engine: Any | None = None,
|
||
project_id: str | None = None,
|
||
) -> None:
|
||
"""Mirror escalation_created events into session channel or activity."""
|
||
runtime_engine = engine or self.engine
|
||
pid = self._normalize_project_id(project_id or getattr(runtime_engine, "project_id", None))
|
||
p = event.payload or {}
|
||
message = p.get("message", "Escalation required")
|
||
source_task_id = str(p.get("task_id") or "").strip() or None
|
||
source_task = None
|
||
if source_task_id and getattr(runtime_engine, "store", None):
|
||
getter = getattr(runtime_engine.store, "get_task", None)
|
||
if callable(getter):
|
||
try:
|
||
source_task = await getter(source_task_id)
|
||
except Exception:
|
||
source_task = None
|
||
source_metadata = dict(getattr(source_task, "metadata", {}) or {}) if source_task is not None else {}
|
||
is_task_mode = self._runtime_payload_is_task_mode(source_metadata)
|
||
current_turn_title = str(
|
||
source_metadata.get("original_message")
|
||
or getattr(source_task, "description", "")
|
||
or ""
|
||
).strip()
|
||
display_message = self._task_mode_permission_prompt(message, current_turn_title) if is_task_mode else message
|
||
session_task_id = await self._resolve_escalation_session_task_id(source_task_id, engine=runtime_engine)
|
||
target_channel = f"session:{session_task_id}" if session_task_id else f"activity:{pid}"
|
||
options = p.get("options", []) or []
|
||
esc_record = self._remember_pending_escalation({
|
||
"escalation_id": str(p.get("escalation_id") or ""),
|
||
"project_id": pid,
|
||
"task_id": session_task_id,
|
||
"source_task_id": source_task_id,
|
||
"message": message,
|
||
"display_message": display_message,
|
||
"options": options,
|
||
"default_action": p.get("default_action"),
|
||
"escalation_type": p.get("type", "decision_needed"),
|
||
"approval_group_key": p.get("approval_group_key") or self._approval_group_key(message),
|
||
})
|
||
esc_meta: dict[str, Any] = {
|
||
"checkpoint_type": "human_escalation",
|
||
"checkpoint_id": esc_record.get("escalation_id"),
|
||
"escalation_id": esc_record.get("escalation_id"),
|
||
"escalation_type": esc_record.get("escalation_type"),
|
||
"prompt": display_message,
|
||
"summary": display_message,
|
||
"options": options,
|
||
"default_action": esc_record.get("default_action"),
|
||
"source": "engine",
|
||
"ui_message_id": f"escalation::{esc_record.get('escalation_id')}",
|
||
"project_id": pid,
|
||
"approval_group_key": esc_record.get("approval_group_key"),
|
||
}
|
||
approval_context = dict(p.get("approval_context") or {})
|
||
if approval_context:
|
||
# Persisted with the card so a click AFTER the inline wait expired
|
||
# (or after a restart) can still apply the same allowlist grant and
|
||
# resume the parked task.
|
||
esc_meta["approval_context"] = approval_context
|
||
if is_task_mode:
|
||
esc_meta["execution_mode"] = "task_mode"
|
||
esc_meta["permission_group_key"] = esc_record.get("approval_group_key")
|
||
esc_meta["current_turn_title"] = current_turn_title
|
||
if session_task_id:
|
||
esc_meta["task_id"] = session_task_id
|
||
if source_task_id and source_task_id != session_task_id:
|
||
esc_meta["source_task_id"] = source_task_id
|
||
msg = await self.chat_store.insert_message(
|
||
channel_id=target_channel,
|
||
sender="assistant",
|
||
sender_name="OPC",
|
||
content=display_message,
|
||
metadata=esc_meta or None,
|
||
message_id=f"escalation::{esc_record.get('escalation_id')}",
|
||
project_id=pid,
|
||
)
|
||
await self.broadcast({"type": "session_message", "payload": msg})
|
||
|
||
async def _recent_identical_helper_exists(
|
||
self,
|
||
channel_id: str,
|
||
content: str,
|
||
*,
|
||
project_id: str,
|
||
window_seconds: float = 120.0,
|
||
scan_limit: int = 10,
|
||
) -> bool:
|
||
"""True when an identical assistant helper was posted very recently.
|
||
|
||
Used to collapse rapid duplicate user clicks into a single helper
|
||
reply instead of one warning per click.
|
||
"""
|
||
try:
|
||
recent = await self.chat_store.get_channel_messages(
|
||
channel_id, limit=scan_limit, project_id=project_id,
|
||
)
|
||
except Exception:
|
||
return False
|
||
now = time.time()
|
||
for item in reversed(recent):
|
||
if str(item.get("sender", "")) != "assistant":
|
||
continue
|
||
if str(item.get("content", "")) != content:
|
||
continue
|
||
try:
|
||
created_at = float(item.get("created_at", 0) or 0)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if now - created_at <= window_seconds:
|
||
return True
|
||
return False
|
||
|
||
async def _find_pending_approval_park_checkpoint(
|
||
self,
|
||
engine: Any,
|
||
task_id: str,
|
||
project_id: str,
|
||
) -> Any | None:
|
||
"""Locate the pending checkpoint a tool-approval timeout parked on.
|
||
|
||
When an approval card's inline wait expires, the blocked runtime task
|
||
returns AWAITING_HUMAN and the engine saves a durable pause checkpoint
|
||
(task mode: ``task_user_input``; company mode: ``company_work_item_gate``).
|
||
A later click on the card resumes execution through that checkpoint.
|
||
"""
|
||
source_task_id = str(task_id or "").strip()
|
||
if not source_task_id:
|
||
return None
|
||
store = getattr(engine, "store", None)
|
||
getter = getattr(store, "get_pending_checkpoints", None)
|
||
if not callable(getter):
|
||
return None
|
||
try:
|
||
pending = await getter(project_id=project_id)
|
||
except Exception:
|
||
logger.opt(exception=True).debug(
|
||
"Failed to load pending checkpoints for deferred escalation resume"
|
||
)
|
||
return None
|
||
candidates = []
|
||
for checkpoint in pending or []:
|
||
if str(getattr(checkpoint, "checkpoint_type", "") or "") not in {
|
||
"task_user_input",
|
||
"company_work_item_gate",
|
||
}:
|
||
continue
|
||
payload = dict(getattr(checkpoint, "payload", {}) or {})
|
||
linked_ids = {
|
||
str(payload.get("task_id") or "").strip(),
|
||
str(payload.get("waiting_task_id") or "").strip(),
|
||
str(getattr(checkpoint, "task_id", "") or "").strip(),
|
||
}
|
||
linked_ids.update(str(item or "").strip() for item in list(payload.get("task_ids", []) or []))
|
||
if source_task_id in linked_ids:
|
||
candidates.append(checkpoint)
|
||
if not candidates:
|
||
return None
|
||
|
||
def _checkpoint_timestamp(checkpoint: Any) -> float:
|
||
created = getattr(checkpoint, "created_at", None)
|
||
try:
|
||
return float(created.timestamp())
|
||
except (AttributeError, TypeError, ValueError, OSError):
|
||
return 0.0
|
||
|
||
return max(candidates, key=_checkpoint_timestamp)
|
||
|
||
async def _resolve_deferred_escalation_click(
|
||
self,
|
||
*,
|
||
engine: Any,
|
||
project_id: str,
|
||
channel_id: str,
|
||
checkpoint_id: str,
|
||
card_meta: dict[str, Any],
|
||
option_id: str,
|
||
) -> dict[str, Any]:
|
||
"""Apply a decision clicked on an approval card whose inline wait has
|
||
expired: persist the allowlist grant, resolve the card, and hand back
|
||
either a flow-through rewrite (resume the parked task through the
|
||
normal message pipeline) or a helper reply when nothing is parked."""
|
||
approval_context = dict(card_meta.get("approval_context") or {})
|
||
summary: dict[str, Any] = {
|
||
"approved": option_id in {"approve_once", "approve_session", "always_project", "always_global"},
|
||
"scope": None,
|
||
}
|
||
approval_engine = getattr(engine, "approval_engine", None)
|
||
apply_decision = getattr(approval_engine, "apply_deferred_escalation_decision", None)
|
||
if callable(apply_decision):
|
||
try:
|
||
summary = apply_decision(option_id, approval_context)
|
||
except Exception:
|
||
logger.opt(exception=True).warning(
|
||
"Deferred approval grant failed; resuming the parked task without a new allowlist entry"
|
||
)
|
||
await self._mark_human_escalation_checkpoint_status(
|
||
checkpoint_id,
|
||
status="resolved",
|
||
project_id=project_id,
|
||
channel_id=channel_id,
|
||
reply=option_id,
|
||
reason="deferred_decision",
|
||
)
|
||
|
||
source_task_id = str(
|
||
card_meta.get("source_task_id") or card_meta.get("task_id") or ""
|
||
).strip()
|
||
park_checkpoint = await self._find_pending_approval_park_checkpoint(
|
||
engine, source_task_id, project_id
|
||
)
|
||
action_name = str(approval_context.get("action_name", "") or "").strip() or "action"
|
||
approved = bool(summary.get("approved"))
|
||
scope = str(summary.get("scope") or "").strip()
|
||
if park_checkpoint is None:
|
||
return {
|
||
"action": "reply",
|
||
"text": (
|
||
f"Decision `{option_id}` recorded"
|
||
+ (f"; allowlist updated ({scope})" if approved and scope else "")
|
||
+ ". No parked task is currently waiting on this approval — if the runtime "
|
||
"is still transitioning, the grant applies on its next attempt."
|
||
),
|
||
}
|
||
if approved:
|
||
scope_note = f" (allowlisted: {scope})" if scope else ""
|
||
crafted = (
|
||
f"Approval decision: {option_id}. The previously blocked `{action_name}` action "
|
||
f"is now permitted{scope_note}. Re-run it and continue the task."
|
||
)
|
||
else:
|
||
crafted = (
|
||
f"Approval decision: deny. Do not run the blocked `{action_name}` action; "
|
||
"choose an alternative approach or report the limitation to your manager."
|
||
)
|
||
return {
|
||
"action": "flow_through",
|
||
"content": crafted,
|
||
"reply_metadata": {
|
||
"response_to_checkpoint_id": str(getattr(park_checkpoint, "checkpoint_id", "") or ""),
|
||
"response_to_checkpoint_type": str(getattr(park_checkpoint, "checkpoint_type", "") or ""),
|
||
},
|
||
}
|
||
|
||
async def _mark_human_escalation_checkpoint_status(
|
||
self,
|
||
escalation_id: str,
|
||
*,
|
||
status: str,
|
||
project_id: str,
|
||
channel_id: str | None = None,
|
||
reply: str | None = None,
|
||
default_action: str | None = None,
|
||
reason: str | None = None,
|
||
) -> dict[str, Any] | None:
|
||
normalized_escalation_id = str(escalation_id or "").strip()
|
||
if not normalized_escalation_id:
|
||
return None
|
||
update_status = getattr(self.chat_store, "update_checkpoint_status", None)
|
||
if not callable(update_status):
|
||
return None
|
||
status_metadata: dict[str, Any] = {
|
||
"checkpoint_resolution_source": "escalation_lifecycle",
|
||
}
|
||
if reply is not None:
|
||
status_metadata["checkpoint_resolution_reply"] = reply
|
||
if default_action is not None:
|
||
status_metadata["checkpoint_timeout_default_action"] = default_action
|
||
if reason:
|
||
status_metadata["checkpoint_resolution_reason"] = reason
|
||
try:
|
||
updated = await update_status(
|
||
normalized_escalation_id,
|
||
channel_id=channel_id,
|
||
checkpoint_type="human_escalation",
|
||
status=status,
|
||
status_metadata=status_metadata,
|
||
project_id=project_id,
|
||
)
|
||
except Exception:
|
||
logger.opt(exception=True).debug(
|
||
f"Failed to update human escalation checkpoint status for {normalized_escalation_id}",
|
||
)
|
||
return None
|
||
if updated is not None:
|
||
await self.broadcast({"type": "session_message", "payload": updated})
|
||
return updated
|
||
|
||
async def _mark_escalation_event_checkpoint_terminal(
|
||
self,
|
||
event: Any,
|
||
*,
|
||
project_id: str,
|
||
) -> None:
|
||
payload = dict(getattr(event, "payload", {}) or {})
|
||
escalation_id = str(payload.get("escalation_id", "") or "").strip()
|
||
if not escalation_id:
|
||
return
|
||
if event.event_type == "escalation_timeout":
|
||
default_action = str(payload.get("default_action", "") or "").strip() or None
|
||
if default_action is None:
|
||
# No default was applied on timeout — the decision is still the
|
||
# user's to make. The task parks on AWAITING_HUMAN and the card
|
||
# stays pending; clicking it later applies the decision and
|
||
# resumes the parked task (deferred approval path).
|
||
return
|
||
await self._mark_human_escalation_checkpoint_status(
|
||
escalation_id,
|
||
status="timeout",
|
||
project_id=project_id,
|
||
default_action=default_action,
|
||
reason="timeout",
|
||
)
|
||
return
|
||
if event.event_type == "escalation_resolved":
|
||
await self._mark_human_escalation_checkpoint_status(
|
||
escalation_id,
|
||
status="resolved",
|
||
project_id=project_id,
|
||
reply=str(payload.get("reply", "") or "").strip() or None,
|
||
reason="resolved",
|
||
)
|
||
|
||
async def _reconcile_inactive_human_escalation_cards(
|
||
self,
|
||
channel_id: str,
|
||
*,
|
||
task_id: str,
|
||
project_id: str,
|
||
) -> list[dict[str, Any]]:
|
||
"""Mark legacy human escalation cards stale when no live approval exists.
|
||
|
||
Older Office UI builds persisted approval cards without a terminal
|
||
``checkpoint_status`` when the runtime timed out or auto-approved. On a
|
||
later reload there is no in-memory escalation future for those cards, so
|
||
session detail is the authoritative place to reconcile persisted UI
|
||
state with runtime state.
|
||
"""
|
||
getter = getattr(self.chat_store, "get_unresolved_checkpoint_messages", None)
|
||
if not callable(getter):
|
||
return []
|
||
try:
|
||
cards = await getter(
|
||
channel_id,
|
||
checkpoint_type="human_escalation",
|
||
project_id=project_id,
|
||
)
|
||
except Exception:
|
||
logger.opt(exception=True).debug(
|
||
f"Failed to load unresolved human escalation cards for {channel_id}",
|
||
)
|
||
return []
|
||
|
||
updated_cards: list[dict[str, Any]] = []
|
||
for card in cards:
|
||
metadata = dict(card.get("metadata", {}) or {})
|
||
escalation_id = str(
|
||
metadata.get("escalation_id")
|
||
or metadata.get("checkpoint_id")
|
||
or ""
|
||
).strip()
|
||
if not escalation_id:
|
||
continue
|
||
if self._find_pending_escalation(
|
||
task_id=task_id,
|
||
escalation_id=escalation_id,
|
||
project_id=project_id,
|
||
):
|
||
continue
|
||
if isinstance(metadata.get("approval_context"), dict) and metadata.get("approval_context"):
|
||
# Deferred-capable approval card: it stays answerable after the
|
||
# inline wait expired or across restarts, so a missing pending
|
||
# future does NOT make it stale.
|
||
continue
|
||
updated = await self._mark_human_escalation_checkpoint_status(
|
||
escalation_id,
|
||
status="stale",
|
||
project_id=project_id,
|
||
channel_id=channel_id,
|
||
reason="session_detail_reconcile_inactive_escalation",
|
||
)
|
||
if updated is not None:
|
||
updated_cards.append(updated)
|
||
return updated_cards
|
||
|
||
async def _reconcile_execution_checkpoint_cards(
|
||
self,
|
||
channel_id: str,
|
||
*,
|
||
project_id: str,
|
||
engine: Any,
|
||
) -> list[dict[str, Any]]:
|
||
"""Mark non-human checkpoint cards terminal once the engine checkpoint is terminal."""
|
||
getter = getattr(self.chat_store, "get_unresolved_checkpoint_messages", None)
|
||
if not callable(getter):
|
||
return []
|
||
try:
|
||
cards = await getter(channel_id, project_id=project_id)
|
||
except Exception:
|
||
logger.opt(exception=True).debug(
|
||
f"Failed to load unresolved execution checkpoint cards for {channel_id}",
|
||
)
|
||
return []
|
||
|
||
updated_cards: list[dict[str, Any]] = []
|
||
for card in cards:
|
||
metadata = dict(card.get("metadata", {}) or {})
|
||
checkpoint_type = str(metadata.get("checkpoint_type", "") or "").strip()
|
||
if checkpoint_type in {"", "human_escalation"}:
|
||
continue
|
||
checkpoint_id = str(metadata.get("checkpoint_id", "") or "").strip()
|
||
if not checkpoint_id:
|
||
continue
|
||
status = await self._execution_checkpoint_status(
|
||
engine=engine,
|
||
project_id=project_id,
|
||
checkpoint_id=checkpoint_id,
|
||
checkpoint_type=checkpoint_type,
|
||
)
|
||
if not status or status == "pending":
|
||
continue
|
||
try:
|
||
updated = await self.chat_store.update_checkpoint_status(
|
||
checkpoint_id,
|
||
channel_id=channel_id,
|
||
checkpoint_type=checkpoint_type,
|
||
status=status,
|
||
status_metadata={
|
||
"checkpoint_resolution_source": "execution_checkpoint_lifecycle",
|
||
},
|
||
project_id=project_id,
|
||
)
|
||
except Exception:
|
||
logger.opt(exception=True).debug(
|
||
f"Failed to reconcile execution checkpoint card {checkpoint_id}",
|
||
)
|
||
continue
|
||
if updated is not None:
|
||
updated_cards.append(updated)
|
||
await self.broadcast({"type": "session_message", "payload": updated})
|
||
return updated_cards
|
||
|
||
# ── Session Handlers ──────────────────────────────────────────────
|
||
|
||
async def _handle_create_session(self, ws: Any, data: dict) -> None:
|
||
"""Create a new Task (PENDING) + engine session. Broadcasts session_created."""
|
||
try:
|
||
run_engine, pid = await self._engine_for_request(data)
|
||
result = await self.services.session.create(
|
||
project_id=pid,
|
||
title=data.get("title", "New Chat"),
|
||
exec_mode=data.get("exec_mode", self._exec_mode),
|
||
company_profile=data.get("company_profile"),
|
||
preferred_agent=data.get("preferred_agent", self._task_preferred_agent),
|
||
org_id=data.get("org_id") or data.get("organization_id"),
|
||
interface="office_ui",
|
||
)
|
||
self._session_to_task.update(self.services_context.session_to_task)
|
||
await self._publish_service_result(result)
|
||
await self._send_ack(ws, ok=True, action="create_session", **result.payload)
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="create_session")
|
||
|
||
async def _handle_session_update_config(self, ws: Any, data: dict) -> None:
|
||
"""Update a session's persisted execution configuration."""
|
||
try:
|
||
_engine, project_id = await self._engine_for_request(data)
|
||
result = await self.services.session.update_config(
|
||
project_id=project_id,
|
||
task_id=str(data.get("task_id", "") or ""),
|
||
exec_mode=data.get("exec_mode"),
|
||
company_profile=data.get("company_profile"),
|
||
preferred_agent=data.get("preferred_agent"),
|
||
org_id=data.get("org_id") or data.get("organization_id"),
|
||
)
|
||
await self._publish_service_result(result)
|
||
await self._send_ack(ws, ok=True, **result.payload)
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="session_update_config")
|
||
|
||
async def _handle_session_detail(self, ws: Any, data: dict) -> None:
|
||
"""Return a paginated persisted transcript/context for a single session."""
|
||
if self._shutting_down:
|
||
return
|
||
|
||
task_id = str(data.get("task_id", "") or "").strip()
|
||
if not task_id:
|
||
await self._send_ack(ws, ok=False, error="task_id required")
|
||
return
|
||
|
||
run_engine, request_project_id = await self._engine_for_request(data)
|
||
view_generation = data.get("view_generation")
|
||
store = run_engine.store
|
||
if not self._store_is_ready(store):
|
||
await self._send_ack(ws, ok=False, error="store_not_ready", project_id=request_project_id, view_generation=view_generation)
|
||
return
|
||
|
||
task = await store.get_task(task_id)
|
||
if not task:
|
||
await self._send_ack(ws, ok=False, error="task_not_found", project_id=request_project_id, task_id=task_id, view_generation=view_generation, action="session_detail")
|
||
return
|
||
|
||
channel_id = f"session:{task_id}"
|
||
session_id = getattr(task, "session_id", None)
|
||
project_id = getattr(task, "project_id", None) or run_engine.project_id or request_project_id
|
||
request_limit = max(1, min(int(data.get("limit", 200) or 200), 500))
|
||
detail_level = _normalize_transcript_detail_level(data.get("detail_level", "summary"))
|
||
raw_include = data.get("include")
|
||
if isinstance(raw_include, list):
|
||
include_set = {str(item or "").strip() for item in raw_include if str(item or "").strip()}
|
||
else:
|
||
include_set = {"messages", "session_state"}
|
||
if detail_level == "full":
|
||
include_set.update({"progress", "work_items", "runtime_context"})
|
||
before_timestamp = self._normalize_session_detail_timestamp(data.get("before_created_at"))
|
||
before_message_id = str(data.get("before_message_id", "") or "").strip() or None
|
||
|
||
try:
|
||
await self.chat_store.create_session_channel(
|
||
task_id,
|
||
getattr(task, "title", "") or "Session",
|
||
project_id=project_id,
|
||
)
|
||
except Exception as exc:
|
||
if self._is_expected_shutdown_error(exc):
|
||
logger.debug(f"session_detail: channel bootstrap skipped during shutdown for {task_id}")
|
||
return
|
||
raise
|
||
try:
|
||
transcript_page, transcript_total_count, transcript_has_more = await self._load_session_transcript_page(
|
||
task,
|
||
limit=request_limit,
|
||
detail_level=detail_level,
|
||
before_timestamp=before_timestamp,
|
||
before_message_id=before_message_id,
|
||
engine=run_engine,
|
||
)
|
||
if transcript_page:
|
||
await self.chat_store.backfill_messages(
|
||
channel_id,
|
||
transcript_page,
|
||
project_id=project_id,
|
||
)
|
||
except Exception:
|
||
logger.opt(exception=True).debug(f"session_detail: transcript page load failed for {task_id}")
|
||
transcript_total_count = 0
|
||
transcript_has_more = False
|
||
|
||
await self._reconcile_inactive_human_escalation_cards(
|
||
channel_id,
|
||
task_id=task_id,
|
||
project_id=project_id,
|
||
)
|
||
await self._reconcile_execution_checkpoint_cards(
|
||
channel_id,
|
||
project_id=project_id,
|
||
engine=run_engine,
|
||
)
|
||
|
||
try:
|
||
cache_page = await self.chat_store.get_channel_messages_page_info(
|
||
channel_id,
|
||
limit=request_limit,
|
||
before_timestamp=before_timestamp,
|
||
before_message_id=before_message_id,
|
||
detail_level=detail_level,
|
||
project_id=project_id,
|
||
)
|
||
messages = list(cache_page.get("messages", []) or [])
|
||
visible_cache_count = int(cache_page.get("total_count", len(messages)) or 0)
|
||
cache_has_more = bool(cache_page.get("has_more", False))
|
||
messages = self._filter_ui_messages_for_detail_level(messages, detail_level)
|
||
messages = [_sanitize_ui_message_dict(message) for message in messages]
|
||
except Exception as exc:
|
||
if self._is_expected_shutdown_error(exc):
|
||
logger.debug(f"session_detail: cache page skipped during shutdown for {task_id}")
|
||
return
|
||
messages = []
|
||
visible_cache_count = len(messages)
|
||
cache_has_more = False
|
||
total_message_count = max(transcript_total_count, visible_cache_count, len(messages))
|
||
has_more = transcript_has_more or cache_has_more
|
||
|
||
task_meta = task.metadata if isinstance(getattr(task, "metadata", None), dict) else {}
|
||
handoff_context = _extract_markdown_text(task_meta.get("handoff_context"), max_chars=None)
|
||
task_description_context = _extract_markdown_text(getattr(task, "description", ""), max_chars=None)
|
||
parent_session_link = _task_parent_session_link(task, task_meta)
|
||
if not handoff_context and parent_session_link:
|
||
handoff_context = task_description_context or await _build_session_context_preview(
|
||
run_engine,
|
||
session_id,
|
||
max_chars=None,
|
||
)
|
||
handoff_to = _extract_markdown_text(task_meta.get("handoff_to"), max_chars=None)
|
||
status_val = task.status.value if hasattr(task.status, "value") else str(task.status)
|
||
created_at = getattr(task, "created_at", None)
|
||
created_ts = created_at.timestamp() if hasattr(created_at, "timestamp") else time.time()
|
||
updated_at = getattr(task, "updated_at", None)
|
||
updated_ts = updated_at.timestamp() if hasattr(updated_at, "timestamp") else created_ts
|
||
assigned_to = str(getattr(task, "assigned_to", "") or "").strip()
|
||
assignee_ids: list[str] = []
|
||
if assigned_to:
|
||
try:
|
||
assignee_ids = [self.event_adapter._resolve_role_to_agent(assigned_to)] if self.event_adapter else [assigned_to]
|
||
except Exception:
|
||
assignee_ids = [assigned_to]
|
||
identity = self._resolve_task_identity(task)
|
||
exec_mode_val = identity.exec_mode
|
||
company_profile_val = identity.company_profile
|
||
org_id_val = identity.org_id
|
||
project_tasks = [task]
|
||
if self._is_company_session_exec_mode(exec_mode_val):
|
||
try:
|
||
project_tasks = await store.get_tasks(project_id=project_id)
|
||
except Exception:
|
||
logger.opt(exception=True).debug("session_detail: failed to load project tasks for runtime control")
|
||
project_tasks = [task]
|
||
try:
|
||
runtime_control_meta = (
|
||
await _build_company_runtime_control_by_task(run_engine, project_tasks, project_id)
|
||
).get(task_id, {})
|
||
except Exception:
|
||
logger.opt(exception=True).debug("session_detail: failed to build runtime control payload")
|
||
runtime_control_meta = {}
|
||
runtime_meta = dict(task_meta.get("runtime_v2", {}) or {})
|
||
member_session_meta = dict(task_meta.get("member_session_state", {}) or {})
|
||
session_state: dict[str, Any] = {
|
||
"project_id": project_id,
|
||
"task_id": task_id,
|
||
"runtime_task_id": task_id,
|
||
"execution_turn_id": task_id,
|
||
"session_id": session_id,
|
||
"parent_session_id": parent_session_link or None,
|
||
"mode": "child" if parent_session_link else "primary",
|
||
"exec_mode": exec_mode_val,
|
||
"company_profile": company_profile_val,
|
||
"org_id": org_id_val,
|
||
"channel_id": channel_id,
|
||
"title": getattr(task, "title", "") or "Session",
|
||
"status": status_val,
|
||
"column_id": STATUS_TO_COLUMN.get(status_val, "todo"),
|
||
"assignee_ids": assignee_ids,
|
||
"priority": None,
|
||
"tags": list(getattr(task, "tags", []) or []),
|
||
"created_at": created_ts,
|
||
"updated_at": updated_ts,
|
||
"message_count": total_message_count,
|
||
"handoff_context": handoff_context,
|
||
"handoff_to": handoff_to,
|
||
"origin_task_id": task_meta.get("origin_task_id") or task_id,
|
||
"artifacts": task_meta.get("artifacts"),
|
||
"runtime_session_id": runtime_meta.get("runtime_session_id"),
|
||
"resume_cursor": runtime_meta.get("resume_cursor"),
|
||
"active_subagents": list(runtime_meta.get("active_subagents", []) or []),
|
||
"permission_requests": list(runtime_meta.get("permission_requests", []) or []),
|
||
"worktree_path": runtime_meta.get("worktree_path"),
|
||
"context_tokens": runtime_meta.get("context_tokens"),
|
||
"context_window": runtime_meta.get("context_window"),
|
||
"context_remaining_pct": runtime_meta.get("context_remaining_pct"),
|
||
"input_tokens": runtime_meta.get("input_tokens"),
|
||
"output_tokens": runtime_meta.get("output_tokens"),
|
||
"total_tokens": runtime_meta.get("total_tokens"),
|
||
"turn_cost_usd": runtime_meta.get("turn_cost_usd"),
|
||
"session_cost_usd": runtime_meta.get("session_cost_usd"),
|
||
"pending_permission_count": runtime_meta.get("pending_permission_count"),
|
||
"drain_mode": runtime_meta.get("drain_mode"),
|
||
"resident_status": normalize_role_runtime_status(
|
||
member_session_meta.get("status") or member_session_meta.get("resident_status"),
|
||
member_session_meta.get("focused_work_item_id"),
|
||
),
|
||
"actionable_inbox_count": member_session_meta.get("actionable_inbox_count"),
|
||
"protocol_backlog_count": member_session_meta.get("protocol_backlog_count"),
|
||
"notification_backlog_count": member_session_meta.get("notification_backlog_count"),
|
||
"latest_notification": member_session_meta.get("latest_notification"),
|
||
"detail_loaded": True,
|
||
"full_loaded": detail_level == "full" and not has_more,
|
||
"has_more": has_more,
|
||
"detail_loading": False,
|
||
"view_generation": view_generation,
|
||
**runtime_control_meta,
|
||
}
|
||
if "progress" in include_set or "work_items" in include_set or detail_level == "full":
|
||
try:
|
||
# Flush the in-memory progress buffer first: entries are
|
||
# broadcast to clients before they reach the DB, so a snapshot
|
||
# built from the DB alone would erase freshly streamed entries
|
||
# from the client's live log (visible as flickering rows).
|
||
await self._flush_progress(task_id, project_id=project_id)
|
||
progress_log = await self.chat_store.get_progress(task_id, project_id=project_id)
|
||
except Exception:
|
||
progress_log = []
|
||
session_state["progress_log"] = progress_log
|
||
if "work_items" in include_set or detail_level == "full":
|
||
session_state["work_item_log"] = list(task_meta.get("work_item_log", []) or [])
|
||
if task_meta.get("role_work_items"):
|
||
session_state["role_work_items"] = task_meta.get("role_work_items")
|
||
if task_meta.get("executor_role_work_items"):
|
||
session_state["executor_role_work_items"] = task_meta.get("executor_role_work_items")
|
||
|
||
await self._send_ack(
|
||
ws,
|
||
ok=True,
|
||
action="session_detail",
|
||
project_id=project_id,
|
||
view_generation=view_generation,
|
||
task_id=task_id,
|
||
channel_id=channel_id,
|
||
session_id=session_id,
|
||
detail_level=detail_level,
|
||
include=sorted(include_set),
|
||
message_count=total_message_count,
|
||
loaded_count=len(messages),
|
||
has_more=has_more,
|
||
messages=messages,
|
||
session_state=session_state,
|
||
handoff_context=handoff_context,
|
||
handoff_to=handoff_to,
|
||
)
|
||
|
||
async def _handle_session_send(self, ws: Any, data: dict) -> None:
|
||
"""Handle user message in a session. Auto-titles from first message."""
|
||
task_id = data.get("task_id", "")
|
||
content = data.get("content", "")
|
||
if not content or not task_id:
|
||
return
|
||
run_engine, run_project_id = await self._engine_for_request(data)
|
||
|
||
# Process file attachments via AttachmentStore (disk storage, lightweight refs)
|
||
raw_attachments = data.get("attachments", [])
|
||
attachment_refs: list[dict] = []
|
||
attachment_errors: list[str] = []
|
||
if raw_attachments:
|
||
ensure_attachment_store = getattr(run_engine, "_ensure_attachment_store", None)
|
||
if callable(ensure_attachment_store):
|
||
try:
|
||
ensure_attachment_store()
|
||
except Exception as exc:
|
||
logger.warning(f"Failed to refresh attachment store for project {run_project_id!r}: {exc}")
|
||
att_store = getattr(run_engine, "attachment_store", None)
|
||
if raw_attachments and att_store:
|
||
for att in raw_attachments:
|
||
filename = str(att.get("filename", "upload") or "upload").strip() or "upload"
|
||
mime_type = str(att.get("mime_type", "") or "").strip() or None
|
||
try:
|
||
ref = await att_store.save_from_base64(
|
||
filename,
|
||
att.get("data", ""),
|
||
mime_type=mime_type,
|
||
)
|
||
attachment_refs.append(ref.to_dict())
|
||
except Exception as exc:
|
||
logger.warning(f"Attachment save failed: {exc}")
|
||
attachment_errors.append(f"{filename}: {exc}")
|
||
elif raw_attachments and not att_store:
|
||
attachment_errors.append("Attachment store is not available for the active project.")
|
||
|
||
channel_id = f"session:{task_id}"
|
||
pid = run_project_id
|
||
|
||
# Look up session_id from task
|
||
session_id: str | None = None
|
||
task = None
|
||
task_session_exec_mode = self._normalize_session_exec_mode(self._exec_mode)
|
||
store = run_engine.store
|
||
if self._store_is_ready(store):
|
||
task = await store.get_task(task_id)
|
||
if task:
|
||
session_id = task.session_id
|
||
task_session_exec_mode, _ = self._resolve_task_session_config(task)
|
||
else:
|
||
await self._send_ack(ws, ok=False, error="task_not_found", project_id=run_project_id, task_id=task_id)
|
||
return
|
||
|
||
# Only cancelled/deleted sessions are terminal — their session data has
|
||
# been torn down, so no further input can be accepted. A DONE session is
|
||
# NOT terminal: task-mode reuses the same primary task across follow-up
|
||
# turns (engine `_find_reusable_task_mode_task` reopens a DONE task in the
|
||
# same session), and company sessions accept follow-up text after final
|
||
# delivery. Blocking DONE here made task mode one-shot — the second user
|
||
# message was rejected before it ever reached the engine.
|
||
if task:
|
||
from opc.core.models import TaskStatus
|
||
if task.status == TaskStatus.CANCELLED:
|
||
try:
|
||
cancelled_target = await self._resolve_company_runtime_target(
|
||
task_id,
|
||
engine=run_engine,
|
||
)
|
||
except Exception:
|
||
logger.opt(exception=True).debug(
|
||
"failed to resolve cancelled company UI anchor"
|
||
)
|
||
cancelled_target = None
|
||
identity = (cancelled_target or {}).get("identity")
|
||
active_cancelled_anchor = bool(
|
||
identity is not None
|
||
and str(getattr(identity, "ui_anchor_task_id", "") or "") == task_id
|
||
and str(getattr(identity, "pending_checkpoint_id", "") or "").strip()
|
||
and str(getattr(identity, "pending_checkpoint_type", "") or "").strip()
|
||
in COMPANY_RUNTIME_CHECKPOINT_TYPES
|
||
and str(getattr(identity, "pending_checkpoint_status", "") or "").strip().lower()
|
||
in ACTIVE_COMPANY_RUNTIME_CHECKPOINT_STATUSES
|
||
)
|
||
if not active_cancelled_anchor:
|
||
await self._send_ack(ws, ok=False, error="session_ended")
|
||
return
|
||
|
||
if attachment_errors:
|
||
helper_lines = [
|
||
"Some attachments could not be included, so they were not sent to the model:",
|
||
*[f"- {item}" for item in attachment_errors],
|
||
]
|
||
helper = await self.chat_store.insert_message(
|
||
channel_id=channel_id,
|
||
sender="assistant",
|
||
sender_name="OPC",
|
||
content="\n".join(helper_lines),
|
||
project_id=pid,
|
||
metadata={"type": "system"},
|
||
)
|
||
await self.broadcast({"type": "session_message", "payload": helper})
|
||
if raw_attachments and not attachment_refs and content.strip() == "Sent with attachments":
|
||
return
|
||
|
||
pending_escalation: dict[str, Any] | None = None
|
||
background_pending_escalation: dict[str, Any] | None = None
|
||
normalized_pending_reply: str | None = None
|
||
|
||
# Insert user message to chat_store (UI rendering layer)
|
||
reply_metadata: dict[str, Any] = {}
|
||
raw_metadata = data.get("metadata")
|
||
if isinstance(raw_metadata, dict):
|
||
for key in (
|
||
"response_to_checkpoint_id",
|
||
"response_to_checkpoint_type",
|
||
"response_to_escalation_id",
|
||
):
|
||
value = raw_metadata.get(key)
|
||
if value is None:
|
||
continue
|
||
normalized_value = str(value).strip()
|
||
if normalized_value:
|
||
reply_metadata[key] = normalized_value
|
||
raw_checkpoint_reply_kind = str(raw_metadata.get("checkpoint_reply_kind", "") or "").strip().lower()
|
||
if raw_checkpoint_reply_kind in {"approve", "deny", "feedback", "ignore"}:
|
||
reply_metadata["checkpoint_reply_kind"] = raw_checkpoint_reply_kind
|
||
raw_ui_message_id = str(raw_metadata.get("ui_message_id", "") or "").strip()
|
||
if raw_ui_message_id:
|
||
reply_metadata["ui_message_id"] = raw_ui_message_id
|
||
raw_role_agents = raw_metadata.get("recruitment_role_agents")
|
||
if isinstance(raw_role_agents, dict):
|
||
normalized_role_agents: dict[str, str] = {}
|
||
for raw_role_id, raw_agent in raw_role_agents.items():
|
||
role_id = str(raw_role_id or "").strip()
|
||
agent_name = str(raw_agent or "").strip().lower()
|
||
if role_id and agent_name in _TASK_MODE_PREFERRED_AGENTS:
|
||
normalized_role_agents[role_id] = agent_name
|
||
if normalized_role_agents:
|
||
reply_metadata["recruitment_role_agents"] = normalized_role_agents
|
||
raw_recruitment_agent = str(raw_metadata.get("recruitment_agent", "") or "").strip().lower().replace("-", "_")
|
||
if raw_recruitment_agent in _TASK_MODE_PREFERRED_AGENTS:
|
||
reply_metadata["recruitment_agent"] = raw_recruitment_agent
|
||
raw_staffing_action = str(raw_metadata.get("staffing_action", "") or "").strip().lower()
|
||
if raw_staffing_action in {"manual_approve", "approve", "auto_recruit", "deny"}:
|
||
reply_metadata["staffing_action"] = (
|
||
"manual_approve" if raw_staffing_action == "approve" else raw_staffing_action
|
||
)
|
||
raw_staffing_selections = raw_metadata.get("staffing_selections")
|
||
if isinstance(raw_staffing_selections, dict):
|
||
normalized_selections: dict[str, dict[str, str]] = {}
|
||
for raw_role_id, raw_selection in raw_staffing_selections.items():
|
||
role_id = str(raw_role_id or "").strip()
|
||
if not role_id or not isinstance(raw_selection, dict):
|
||
continue
|
||
kind = str(raw_selection.get("kind", "") or "").strip().lower()
|
||
selected_id = str(
|
||
raw_selection.get("id")
|
||
or raw_selection.get("employee_id")
|
||
or raw_selection.get("template_id")
|
||
or ""
|
||
).strip()
|
||
if kind in {"employee", "template"} and selected_id:
|
||
normalized_selections[role_id] = {"kind": kind, "id": selected_id}
|
||
elif kind == "fallback":
|
||
normalized_selections[role_id] = {"kind": "fallback", "id": ""}
|
||
if normalized_selections:
|
||
reply_metadata["staffing_selections"] = normalized_selections
|
||
raw_user_input_answers = raw_metadata.get("user_input_answers")
|
||
if isinstance(raw_user_input_answers, dict):
|
||
normalized_answers: dict[str, dict[str, Any]] = {}
|
||
for raw_question_id, raw_answer in raw_user_input_answers.items():
|
||
question_id = str(raw_question_id or "").strip()
|
||
if not question_id:
|
||
continue
|
||
if isinstance(raw_answer, dict):
|
||
answer: dict[str, Any] = {}
|
||
for field in (
|
||
"question_id",
|
||
"question",
|
||
"selected_option_id",
|
||
"selected_label",
|
||
"freeform_text",
|
||
"answer_text",
|
||
):
|
||
value = raw_answer.get(field)
|
||
if value is None:
|
||
continue
|
||
normalized_value = str(value).strip()
|
||
if normalized_value:
|
||
answer[field] = normalized_value
|
||
if answer:
|
||
answer.setdefault("question_id", question_id)
|
||
normalized_answers[question_id] = answer
|
||
else:
|
||
normalized_value = str(raw_answer or "").strip()
|
||
if normalized_value:
|
||
normalized_answers[question_id] = {
|
||
"question_id": question_id,
|
||
"answer_text": normalized_value,
|
||
}
|
||
if normalized_answers:
|
||
reply_metadata["user_input_answers"] = normalized_answers
|
||
|
||
# Idempotency on the client-generated message id: the WS client queues
|
||
# sends while disconnected and flushes the queue after a reconnect, so
|
||
# one typed message can be delivered more than once. The first delivery
|
||
# persisted a row under this id in this channel; later copies are
|
||
# acknowledged and dropped instead of dispatching a duplicate turn.
|
||
client_message_id = str(reply_metadata.get("ui_message_id", "") or "").strip()
|
||
if client_message_id:
|
||
existing_scope = await self.chat_store.message_scope(client_message_id)
|
||
if existing_scope == (channel_id, pid):
|
||
logger.info(
|
||
f"session_send deduplicated re-delivered client message {client_message_id} "
|
||
f"for task {task_id}"
|
||
)
|
||
await self._send_ack(
|
||
ws,
|
||
ok=True,
|
||
action="session_send",
|
||
task_id=task_id,
|
||
project_id=pid,
|
||
deduplicated=True,
|
||
message_id=client_message_id,
|
||
)
|
||
return
|
||
if existing_scope is not None:
|
||
# Same id already used in another channel/project: never reuse it
|
||
# as a row id there (insert_message REPLACEs by primary key).
|
||
client_message_id = ""
|
||
|
||
explicit_checkpoint_id = str(reply_metadata.get("response_to_checkpoint_id", "")).strip()
|
||
explicit_checkpoint_type = str(reply_metadata.get("response_to_checkpoint_type", "")).strip()
|
||
explicit_escalation_id = str(reply_metadata.get("response_to_escalation_id", "")).strip()
|
||
explicit_human_escalation = (
|
||
explicit_checkpoint_type == "human_escalation"
|
||
or bool(explicit_escalation_id)
|
||
)
|
||
if explicit_human_escalation:
|
||
pending_escalation_id = explicit_escalation_id
|
||
if (
|
||
not pending_escalation_id
|
||
and reply_metadata.get("response_to_checkpoint_type") == "human_escalation"
|
||
):
|
||
pending_escalation_id = reply_metadata.get("response_to_checkpoint_id")
|
||
|
||
pending_escalation = self._find_pending_escalation(
|
||
task_id=task_id,
|
||
escalation_id=pending_escalation_id,
|
||
project_id=pid,
|
||
)
|
||
elif not explicit_checkpoint_id and not explicit_checkpoint_type:
|
||
background_pending_escalation = self._find_pending_escalation(
|
||
task_id=task_id,
|
||
project_id=pid,
|
||
)
|
||
|
||
stale_human_escalation = (
|
||
explicit_human_escalation
|
||
and not pending_escalation
|
||
and bool(explicit_checkpoint_id or explicit_escalation_id)
|
||
)
|
||
if stale_human_escalation:
|
||
handled_as_deferred = False
|
||
if _looks_like_escalation_reply(content):
|
||
stale_checkpoint_id = explicit_escalation_id or explicit_checkpoint_id
|
||
# Duplicate clicks on an approval card that was JUST resolved
|
||
# (e.g. the user's own first click) are a normal occurrence
|
||
# when the server is slow: answer idempotently instead of
|
||
# flipping the card to "stale" and spamming inactive warnings.
|
||
card = None
|
||
try:
|
||
card = await self.chat_store.get_checkpoint_message(
|
||
stale_checkpoint_id,
|
||
channel_id=channel_id,
|
||
checkpoint_type="human_escalation",
|
||
project_id=pid,
|
||
)
|
||
except Exception:
|
||
logger.opt(exception=True).debug(
|
||
"Failed to load checkpoint card for stale escalation reply"
|
||
)
|
||
card_meta = dict((card or {}).get("metadata", {}) or {})
|
||
card_status = str(card_meta.get("checkpoint_status", "") or "").strip().lower()
|
||
helper_text: str | None = None
|
||
if card_status in {"resolved", "responded"}:
|
||
resolution_reply = str(
|
||
card_meta.get("checkpoint_resolution_reply", "") or ""
|
||
).strip()
|
||
helper_text = (
|
||
"This approval was already handled"
|
||
+ (f" (decision: {resolution_reply})" if resolution_reply else "")
|
||
+ ". No further action is needed."
|
||
)
|
||
else:
|
||
approval_context = card_meta.get("approval_context")
|
||
deferred_option = (
|
||
_normalize_escalation_reply(content, list(card_meta.get("options") or []))
|
||
if isinstance(approval_context, dict) and approval_context
|
||
else None
|
||
)
|
||
if deferred_option:
|
||
# The inline wait expired (or the server restarted), but
|
||
# the decision is still the user's to make: apply the
|
||
# grant, resolve the card, and resume the parked task.
|
||
outcome = await self._resolve_deferred_escalation_click(
|
||
engine=run_engine,
|
||
project_id=pid,
|
||
channel_id=channel_id,
|
||
checkpoint_id=stale_checkpoint_id,
|
||
card_meta=card_meta,
|
||
option_id=deferred_option,
|
||
)
|
||
if outcome.get("action") == "flow_through":
|
||
content = str(outcome.get("content") or content)
|
||
for key in (
|
||
"response_to_checkpoint_id",
|
||
"response_to_checkpoint_type",
|
||
"response_to_escalation_id",
|
||
):
|
||
reply_metadata.pop(key, None)
|
||
reply_metadata.update(dict(outcome.get("reply_metadata") or {}))
|
||
handled_as_deferred = True
|
||
else:
|
||
helper_text = str(outcome.get("text") or "Decision recorded.")
|
||
else:
|
||
await self._mark_human_escalation_checkpoint_status(
|
||
stale_checkpoint_id,
|
||
status="stale",
|
||
project_id=pid,
|
||
channel_id=channel_id,
|
||
reason="reply_to_inactive_escalation",
|
||
)
|
||
helper_text = (
|
||
"That approval request is no longer active. "
|
||
"The approval card has been marked inactive in the session history."
|
||
)
|
||
if helper_text is not None:
|
||
if await self._recent_identical_helper_exists(
|
||
channel_id, helper_text, project_id=pid
|
||
):
|
||
return
|
||
helper = await self.chat_store.insert_message(
|
||
channel_id=channel_id,
|
||
sender="assistant",
|
||
sender_name="OPC",
|
||
content=helper_text,
|
||
project_id=pid,
|
||
metadata={"type": "system"},
|
||
)
|
||
await self.broadcast({"type": "session_message", "payload": helper})
|
||
return
|
||
if not handled_as_deferred:
|
||
for key in (
|
||
"response_to_checkpoint_id",
|
||
"response_to_checkpoint_type",
|
||
"response_to_escalation_id",
|
||
):
|
||
reply_metadata.pop(key, None)
|
||
|
||
if (
|
||
explicit_checkpoint_type == "company_delivery_feedback"
|
||
and str(reply_metadata.get("checkpoint_reply_kind", "") or "").strip().lower() == "ignore"
|
||
):
|
||
if await self._route_company_delivery_feedback_reply_if_pending(
|
||
task_id=task_id,
|
||
content=content,
|
||
session_id=session_id,
|
||
task=task,
|
||
attachment_refs=attachment_refs or None,
|
||
message_metadata=reply_metadata if reply_metadata else None,
|
||
user_message_id=None,
|
||
user_message_created_at=None,
|
||
run_engine=run_engine,
|
||
run_project_id=run_project_id,
|
||
reply_channel_id=channel_id,
|
||
):
|
||
return
|
||
|
||
msg_metadata: dict = dict(reply_metadata)
|
||
if attachment_refs:
|
||
msg_metadata["attachment_refs"] = attachment_refs
|
||
msg = await self.chat_store.insert_message(
|
||
channel_id=channel_id,
|
||
sender="user",
|
||
sender_name="You",
|
||
content=content,
|
||
project_id=pid,
|
||
metadata=msg_metadata if msg_metadata else None,
|
||
# Persist under the client-generated id so re-deliveries of the same
|
||
# send are detectable and the optimistic bubble merges with the echo.
|
||
message_id=client_message_id or None,
|
||
)
|
||
await self.broadcast({"type": "session_message", "payload": msg})
|
||
|
||
if (
|
||
explicit_checkpoint_id
|
||
and explicit_checkpoint_type
|
||
and explicit_checkpoint_type not in {"human_escalation", "company_delivery_feedback"}
|
||
):
|
||
updated_checkpoint_msg = await self.chat_store.mark_checkpoint_responded(
|
||
channel_id,
|
||
explicit_checkpoint_id,
|
||
checkpoint_type=explicit_checkpoint_type,
|
||
response_message_id=str(msg.get("message_id") or "").strip() or None,
|
||
response_metadata=reply_metadata if reply_metadata else None,
|
||
project_id=pid,
|
||
)
|
||
if updated_checkpoint_msg is not None:
|
||
await self.broadcast({"type": "session_message", "payload": updated_checkpoint_msg})
|
||
|
||
# Auto-generate title from first message if task title is still default
|
||
store = run_engine.store
|
||
if self._store_is_ready(store) and task:
|
||
if task.title in ("New Chat", ""):
|
||
auto_title = _compact_session_title(content)
|
||
task.title = auto_title
|
||
await store.save_task(task)
|
||
await self.chat_store.update_channel_name(channel_id, auto_title, project_id=pid)
|
||
# Also update engine session title
|
||
if run_engine.memory and session_id:
|
||
await run_engine.memory.update_session_title(session_id, auto_title)
|
||
await self.broadcast({"type": "session_title_updated", "payload": {
|
||
"project_id": pid,
|
||
"task_id": task_id,
|
||
"title": auto_title,
|
||
}})
|
||
|
||
if background_pending_escalation:
|
||
escalation_key = str(background_pending_escalation.get("escalation_id") or "").strip()
|
||
helper_text = (
|
||
"This approval is waiting for a card action. "
|
||
"Please use the approval card buttons to approve or deny."
|
||
)
|
||
if not _looks_like_escalation_reply(content):
|
||
allowed = [
|
||
str(opt.get("label") or opt.get("id") or "").strip()
|
||
for opt in background_pending_escalation.get("options", [])
|
||
if str(opt.get("id", "")).strip()
|
||
]
|
||
if allowed:
|
||
helper_text = (
|
||
"This task is waiting for an approval decision. "
|
||
f"Use the approval card buttons: {', '.join(allowed)}."
|
||
)
|
||
helper = await self.chat_store.insert_message(
|
||
channel_id=channel_id,
|
||
sender="assistant",
|
||
sender_name="OPC",
|
||
content=helper_text,
|
||
project_id=pid,
|
||
metadata={
|
||
"type": "system",
|
||
"pending_checkpoint_type": "human_escalation",
|
||
"pending_escalation_id": escalation_key,
|
||
},
|
||
)
|
||
await self.broadcast({"type": "session_message", "payload": helper})
|
||
return
|
||
|
||
if pending_escalation:
|
||
normalized = normalized_pending_reply
|
||
if normalized is None:
|
||
normalized = _normalize_escalation_reply(content, pending_escalation.get("options", []))
|
||
if normalized is None:
|
||
allowed = [
|
||
str(opt.get("label") or opt.get("id") or "").strip()
|
||
for opt in pending_escalation.get("options", [])
|
||
if str(opt.get("id", "")).strip()
|
||
]
|
||
helper = await self.chat_store.insert_message(
|
||
channel_id=channel_id,
|
||
sender="assistant",
|
||
sender_name="OPC",
|
||
content=(
|
||
"This task is waiting for your escalation decision. "
|
||
f"Choose one of: {', '.join(allowed)}."
|
||
),
|
||
metadata={
|
||
"checkpoint_type": "human_escalation",
|
||
"checkpoint_id": pending_escalation.get("escalation_id"),
|
||
"escalation_id": pending_escalation.get("escalation_id"),
|
||
"escalation_type": pending_escalation.get("escalation_type"),
|
||
"prompt": pending_escalation.get("message", ""),
|
||
"summary": pending_escalation.get("message", ""),
|
||
"options": pending_escalation.get("options", []),
|
||
"default_action": pending_escalation.get("default_action"),
|
||
},
|
||
project_id=pid,
|
||
)
|
||
await self.broadcast({"type": "session_message", "payload": helper})
|
||
return
|
||
future = pending_escalation.get("future")
|
||
if future and not future.done():
|
||
future.set_result(normalized)
|
||
auto_resolved_ids = self._resolve_related_pending_escalations(pending_escalation, normalized)
|
||
for escalation_id in auto_resolved_ids:
|
||
updated = await self.chat_store.mark_checkpoint_responded(
|
||
channel_id,
|
||
escalation_id,
|
||
checkpoint_type="human_escalation",
|
||
response_message_id=msg.get("message_id"),
|
||
response_metadata=reply_metadata if reply_metadata else None,
|
||
project_id=pid,
|
||
)
|
||
if updated is not None:
|
||
await self.broadcast({"type": "session_message", "payload": updated})
|
||
return
|
||
|
||
if await self._route_company_delivery_feedback_reply_if_pending(
|
||
task_id=task_id,
|
||
content=content,
|
||
session_id=session_id,
|
||
task=task,
|
||
attachment_refs=attachment_refs or None,
|
||
message_metadata=reply_metadata if reply_metadata else None,
|
||
user_message_id=str(msg.get("message_id") or "").strip() or None,
|
||
user_message_created_at=float(msg.get("created_at")) if msg.get("created_at") is not None else None,
|
||
run_engine=run_engine,
|
||
run_project_id=run_project_id,
|
||
reply_channel_id=channel_id,
|
||
):
|
||
return
|
||
|
||
if (
|
||
task
|
||
and self._is_company_session_exec_mode(task_session_exec_mode)
|
||
and not explicit_checkpoint_id
|
||
and not explicit_checkpoint_type
|
||
and not explicit_escalation_id
|
||
):
|
||
await self._supersede_pending_delivery_feedback_for_new_company_turn(
|
||
task_id=task_id,
|
||
session_id=session_id,
|
||
run_engine=run_engine,
|
||
run_project_id=run_project_id,
|
||
)
|
||
|
||
if await self._route_company_suspend_reply_if_pending(
|
||
task_id=task_id,
|
||
content=content,
|
||
session_id=session_id,
|
||
task=task,
|
||
attachment_refs=attachment_refs or None,
|
||
message_metadata=reply_metadata if reply_metadata else None,
|
||
user_message_id=str(msg.get("message_id") or "").strip() or None,
|
||
user_message_created_at=float(msg.get("created_at")) if msg.get("created_at") is not None else None,
|
||
run_engine=run_engine,
|
||
run_project_id=run_project_id,
|
||
):
|
||
return
|
||
|
||
if task and self._is_company_session_exec_mode(task_session_exec_mode):
|
||
self._track_session(
|
||
task_id,
|
||
self._process_session_message(
|
||
task_id,
|
||
content,
|
||
session_id=session_id,
|
||
attachment_refs=attachment_refs or None,
|
||
message_metadata=reply_metadata if reply_metadata else None,
|
||
user_message_id=str(msg.get("message_id") or "").strip() or None,
|
||
user_message_created_at=float(msg.get("created_at")) if msg.get("created_at") is not None else None,
|
||
run_engine=run_engine,
|
||
run_project_id=run_project_id,
|
||
),
|
||
project_id=run_project_id,
|
||
engine=run_engine,
|
||
)
|
||
return
|
||
|
||
# Route task-mode sessions through Dispatcher: classify intent, then either engine or direct reply.
|
||
self._track_session(
|
||
task_id,
|
||
self._dispatch_session_message(
|
||
task_id,
|
||
content,
|
||
session_id=session_id,
|
||
attachment_refs=attachment_refs or None,
|
||
message_metadata=reply_metadata if reply_metadata else None,
|
||
user_message_id=str(msg.get("message_id") or "").strip() or None,
|
||
user_message_created_at=float(msg.get("created_at")) if msg.get("created_at") is not None else None,
|
||
run_engine=run_engine,
|
||
run_project_id=run_project_id,
|
||
),
|
||
project_id=run_project_id,
|
||
engine=run_engine,
|
||
)
|
||
|
||
async def _cancel_task_tree(
|
||
self,
|
||
task_id: str,
|
||
*,
|
||
hard: bool = False,
|
||
preserve_history: bool = False,
|
||
store: Any | None = None,
|
||
) -> list[str]:
|
||
"""Cancel a task and its children, clean up engine store data.
|
||
|
||
Args:
|
||
hard: If True, hard-delete task rows and all lifecycle data
|
||
(used by session_delete). If False, soft-cancel and
|
||
partial cleanup (used by session_stop).
|
||
|
||
Returns all affected task_ids (parent + children).
|
||
"""
|
||
all_task_ids: list[str] = [task_id]
|
||
store = store or self.engine.store
|
||
if not store:
|
||
return all_task_ids
|
||
from opc.core.models import TaskStatus
|
||
task = await store.get_task(task_id)
|
||
if not task:
|
||
return all_task_ids
|
||
|
||
# Collect child tasks first (before any deletion)
|
||
child_tasks: list[tuple[str, str | None]] = []
|
||
parent_sid = task.session_id or task_id
|
||
project_id = task.project_id or getattr(store, "project_id", None) or self.engine.project_id or "default"
|
||
try:
|
||
siblings = await store.get_tasks(project_id=project_id)
|
||
for sib in siblings:
|
||
if sib.id == task_id:
|
||
continue
|
||
if getattr(sib, "parent_session_id", None) == parent_sid:
|
||
child_tasks.append((sib.id, sib.session_id))
|
||
all_task_ids.append(sib.id)
|
||
except Exception:
|
||
logger.debug(f"Failed to find children of {task_id}")
|
||
|
||
async def _cancel_child_via_phase(child_task: Any) -> None:
|
||
"""Cascade CANCELLED to the child's work_item.phase so the UI
|
||
column + all projection layers update. Plain tasks keep legacy
|
||
Task.status behavior through the shared transition fallback.
|
||
"""
|
||
child_task.metadata = dict(getattr(child_task, "metadata", {}) or {})
|
||
child_task.metadata["last_stop_reason"] = "user_stop"
|
||
await apply_task_status_transition(
|
||
store,
|
||
child_task,
|
||
target_status_or_phase=TaskStatus.CANCELLED,
|
||
reason="user_stop",
|
||
metadata_updates={"last_stop_reason": "user_stop"},
|
||
release_claim=True,
|
||
)
|
||
|
||
if hard:
|
||
# Hard-delete: children first, then parent (preserves session ref-count accuracy)
|
||
for child_id, child_sid in child_tasks:
|
||
try:
|
||
await store.hard_delete_task(child_id, child_sid)
|
||
except Exception:
|
||
logger.debug(f"hard_delete_task failed for child {child_id}")
|
||
try:
|
||
await store.hard_delete_task(task_id, task.session_id)
|
||
except Exception:
|
||
logger.debug(f"hard_delete_task failed for {task_id}")
|
||
elif preserve_history:
|
||
# Parent stays IDLE (suspend-for-resume — user hit Stop, may resume
|
||
# later). We do NOT transition parent's work_item here; resuming
|
||
# should pick it back up in whatever phase it was parked in.
|
||
task.metadata["last_stop_reason"] = "user_stop"
|
||
task.status = TaskStatus.IDLE
|
||
await store.save_task(task)
|
||
for child_id, _child_sid in child_tasks:
|
||
try:
|
||
child_task = await store.get_task(child_id)
|
||
if child_task:
|
||
await _cancel_child_via_phase(child_task)
|
||
except Exception:
|
||
logger.debug(f"Failed to preserve stop state for child task {child_id}")
|
||
else:
|
||
# Soft-cancel: cascade CANCELLED phase to parent + children
|
||
await apply_task_status_transition(
|
||
store,
|
||
task,
|
||
target_status_or_phase=TaskStatus.CANCELLED,
|
||
reason="user_cancel",
|
||
release_claim=True,
|
||
)
|
||
try:
|
||
await store.delete_session_data(task_id, task.session_id)
|
||
except Exception:
|
||
logger.debug(f"Failed to clean session data for {task_id}")
|
||
for child_id, child_sid in child_tasks:
|
||
try:
|
||
child_task = await store.get_task(child_id)
|
||
if child_task:
|
||
await _cancel_child_via_phase(child_task)
|
||
await store.delete_session_data(child_id, child_sid)
|
||
except Exception:
|
||
pass
|
||
|
||
# Clean progress buffers + cancel background tasks for all affected
|
||
for tid in all_task_ids:
|
||
self._progress_buffer.pop(tid, None)
|
||
self._progress_project_ids.pop(tid, None)
|
||
if hard:
|
||
try:
|
||
await self.chat_store.delete_progress(tid, project_id=project_id)
|
||
except Exception:
|
||
pass
|
||
self._cancel_session_tasks(tid)
|
||
|
||
return all_task_ids
|
||
|
||
async def _resolve_company_runtime_target(
|
||
self,
|
||
task_id: str,
|
||
*,
|
||
engine: Any | None = None,
|
||
runtime_session_id: str = "",
|
||
checkpoint_id: str = "",
|
||
) -> dict[str, Any] | None:
|
||
"""Resolve a Task/UI channel to the canonical session-first scope."""
|
||
runtime_engine = engine or self.engine
|
||
store = runtime_engine.store
|
||
if not task_id or not self._store_is_ready(store):
|
||
return None
|
||
task = await store.get_task(task_id)
|
||
if task is None:
|
||
return None
|
||
project_id = self._normalize_project_id(
|
||
getattr(task, "project_id", None) or getattr(runtime_engine, "project_id", None)
|
||
)
|
||
identity_index = await load_company_runtime_identity_index(store, project_id)
|
||
identity = identity_index.resolve(
|
||
task_id=task_id,
|
||
runtime_session_id=runtime_session_id,
|
||
checkpoint_id=checkpoint_id,
|
||
)
|
||
if identity is None:
|
||
return None
|
||
ui_anchor_task_id = identity.ui_anchor_task_id
|
||
config_task = identity_index.task(identity.config_source_task_id) or task
|
||
|
||
return {
|
||
"task": task,
|
||
"engine": runtime_engine,
|
||
"identity": identity,
|
||
"runtime_session_id": identity.runtime_session_id,
|
||
"ui_channel_task_id": task_id,
|
||
"ui_anchor_task_id": ui_anchor_task_id,
|
||
"config_source_task_id": identity.config_source_task_id,
|
||
"config_task": config_task,
|
||
"checkpoint": identity.checkpoint,
|
||
"origin_task_id": ui_anchor_task_id,
|
||
"affected_task_ids": list(identity.runtime_task_ids),
|
||
}
|
||
|
||
async def _broadcast_company_runtime_control(
|
||
self,
|
||
target: dict[str, Any],
|
||
*,
|
||
state: str,
|
||
checkpoint_id: str = "",
|
||
stop_intent_id: str = "",
|
||
) -> None:
|
||
affected_task_ids = [
|
||
str(item).strip()
|
||
for item in list(target.get("affected_task_ids", []) or [])
|
||
if str(item).strip()
|
||
]
|
||
runtime_engine = target.get("engine") or self.engine
|
||
project_id = self._normalize_project_id(getattr(runtime_engine, "project_id", None))
|
||
payload = {
|
||
"project_id": project_id,
|
||
"runtime_control_state": state,
|
||
"can_stop": state == "running",
|
||
"can_resume": state == "suspended",
|
||
"resume_parent_session_id": str(target.get("runtime_session_id", "") or "").strip(),
|
||
"pending_runtime_checkpoint_id": checkpoint_id,
|
||
"stop_intent_id": stop_intent_id,
|
||
"task_ids": affected_task_ids,
|
||
}
|
||
await self.broadcast({"type": "session_runtime_control", "payload": payload})
|
||
|
||
async def _set_company_runtime_control(
|
||
self,
|
||
target: dict[str, Any] | None,
|
||
*,
|
||
state: str,
|
||
checkpoint_id: str = "",
|
||
stop_intent_id: str = "",
|
||
) -> None:
|
||
if target is None:
|
||
return
|
||
await self._broadcast_company_runtime_control(
|
||
target,
|
||
state=state,
|
||
checkpoint_id=checkpoint_id,
|
||
stop_intent_id=stop_intent_id,
|
||
)
|
||
|
||
async def _finalize_company_runtime_stop(self, target: dict[str, Any], *, stop_intent_id: str) -> None:
|
||
runtime_engine = target.get("engine") or self.engine
|
||
parent_session_id = str(target.get("runtime_session_id", "") or "").strip()
|
||
origin_task_id = str(target.get("origin_task_id", "") or "").strip()
|
||
affected_task_ids = [
|
||
str(item).strip()
|
||
for item in list(target.get("affected_task_ids", []) or [])
|
||
if str(item).strip()
|
||
]
|
||
suspended: dict[str, Any] | None = None
|
||
try:
|
||
suspended = await runtime_engine.suspend_company_runtime(
|
||
origin_task_id=origin_task_id,
|
||
session_id=parent_session_id or None,
|
||
reason="user_stop",
|
||
checkpoint_type="company_runtime_suspended",
|
||
stop_intent_id=stop_intent_id,
|
||
)
|
||
except Exception:
|
||
logger.opt(exception=True).warning(f"suspend_company_runtime failed for {origin_task_id}")
|
||
|
||
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)
|
||
target["affected_task_ids"] = affected_task_ids
|
||
self._stop_requested_task_ids.update(affected_task_ids)
|
||
for tid in affected_task_ids:
|
||
self._progress_buffer.pop(tid, None)
|
||
self._progress_project_ids.pop(tid, None)
|
||
self._cancel_session_tasks(tid)
|
||
try:
|
||
await self._set_company_runtime_control(
|
||
target,
|
||
state="suspended",
|
||
checkpoint_id=str(suspended.get("checkpoint_id", "") or ""),
|
||
stop_intent_id=stop_intent_id,
|
||
)
|
||
except Exception:
|
||
logger.opt(exception=True).debug("failed to broadcast company runtime suspended state")
|
||
channel_id = f"session:{target.get('ui_channel_task_id', '')}"
|
||
pid = self._normalize_project_id(getattr(runtime_engine, "project_id", None))
|
||
try:
|
||
msg = await self.chat_store.insert_message(
|
||
channel_id=channel_id,
|
||
sender="system",
|
||
sender_name="System",
|
||
content="Company runtime suspended by user",
|
||
project_id=pid,
|
||
metadata={
|
||
"type": "system",
|
||
"stop_reason": "user_stop",
|
||
"checkpoint_type": suspended.get("checkpoint_type"),
|
||
"checkpoint_id": suspended.get("checkpoint_id"),
|
||
"stop_intent_id": stop_intent_id,
|
||
},
|
||
)
|
||
await self.broadcast({"type": "session_message", "payload": msg})
|
||
except Exception:
|
||
logger.opt(exception=True).warning(f"Failed to insert suspend message for {origin_task_id}")
|
||
else:
|
||
try:
|
||
await self._set_company_runtime_control(
|
||
target,
|
||
state="running" if affected_task_ids else "idle",
|
||
stop_intent_id=stop_intent_id,
|
||
)
|
||
except Exception:
|
||
pass
|
||
self._company_stop_intents.pop(parent_session_id, None)
|
||
self._company_stop_finalize_tasks.pop(parent_session_id, None)
|
||
|
||
async def _handle_session_stop(self, ws: Any, data: dict) -> None:
|
||
"""Stop a running task: suspend company runtime, cancel legacy task mode."""
|
||
task_id = data.get("task_id", "")
|
||
if not task_id:
|
||
return
|
||
|
||
run_engine, run_project_id = await self._engine_for_request(data)
|
||
store = run_engine.store
|
||
task = None
|
||
if self._store_is_ready(store):
|
||
try:
|
||
task = await store.get_task(task_id)
|
||
except Exception:
|
||
logger.opt(exception=True).debug(f"Failed to load task for stop: {task_id}")
|
||
if task is None:
|
||
await self._send_ack(ws, ok=False, error="task_not_found", project_id=run_project_id, task_id=task_id)
|
||
return
|
||
|
||
target = None
|
||
if task is not None:
|
||
try:
|
||
target = await self._resolve_company_runtime_target(task_id, engine=run_engine)
|
||
except Exception:
|
||
logger.opt(exception=True).warning(
|
||
f"failed to resolve company runtime stop target for {task_id}"
|
||
)
|
||
if target is not None:
|
||
parent_session_id = str(target.get("runtime_session_id", "") or "").strip()
|
||
existing_checkpoint = None
|
||
try:
|
||
existing_checkpoint = await run_engine.get_pending_company_runtime_suspend_checkpoint(parent_session_id)
|
||
except Exception:
|
||
logger.opt(exception=True).debug("failed to check existing company suspend checkpoint")
|
||
existing_intent = self._company_stop_intents.get(parent_session_id)
|
||
existing_finalizer = self._company_stop_finalize_tasks.get(parent_session_id)
|
||
if existing_checkpoint is not None:
|
||
await self._set_company_runtime_control(
|
||
target,
|
||
state="suspended",
|
||
checkpoint_id=existing_checkpoint.checkpoint_id,
|
||
stop_intent_id=str((existing_checkpoint.payload or {}).get("stop_intent_id", "") or ""),
|
||
)
|
||
await self._send_ack(ws, ok=True, idempotent=True)
|
||
return
|
||
if existing_intent or (existing_finalizer is not None and not existing_finalizer.done()):
|
||
await self._set_company_runtime_control(
|
||
target,
|
||
state="suspending",
|
||
stop_intent_id=str((existing_intent or {}).get("stop_intent_id", "") or ""),
|
||
)
|
||
await self._send_ack(ws, ok=True, idempotent=True)
|
||
return
|
||
|
||
stop_intent_id = str(uuid.uuid4())
|
||
self._company_stop_intents[parent_session_id] = {
|
||
"stop_intent_id": stop_intent_id,
|
||
"requested_at": datetime.now().isoformat(),
|
||
"origin_task_id": target.get("origin_task_id", task_id),
|
||
}
|
||
await self._set_company_runtime_control(
|
||
target,
|
||
state="suspending",
|
||
stop_intent_id=stop_intent_id,
|
||
)
|
||
finalizer = self._track(
|
||
self._finalize_company_runtime_stop(target, stop_intent_id=stop_intent_id)
|
||
)
|
||
self._company_stop_finalize_tasks[parent_session_id] = finalizer
|
||
await self._send_ack(ws, ok=True, stop_intent_id=stop_intent_id)
|
||
return
|
||
|
||
if is_company_runtime_task(task):
|
||
await self._send_ack(
|
||
ws,
|
||
ok=False,
|
||
error="company_runtime_identity_mismatch",
|
||
project_id=run_project_id,
|
||
task_id=task_id,
|
||
)
|
||
return
|
||
|
||
try:
|
||
all_task_ids = await self._cancel_task_tree(task_id, preserve_history=True, store=store)
|
||
except Exception:
|
||
logger.opt(exception=True).warning(f"_cancel_task_tree failed for {task_id}")
|
||
all_task_ids = [task_id]
|
||
self._stop_requested_task_ids.update(all_task_ids)
|
||
|
||
# Broadcast status updates for all affected tasks
|
||
for tid in all_task_ids:
|
||
is_primary_task = tid == task_id
|
||
try:
|
||
await self.broadcast({"type": "board_task_status_changed", "payload": {
|
||
"project_id": run_project_id,
|
||
"task_id": tid,
|
||
"column_id": "in-progress" if is_primary_task else "done",
|
||
"status": "idle" if is_primary_task else "cancelled",
|
||
}})
|
||
stop_payload: dict[str, Any] = {
|
||
"project_id": run_project_id,
|
||
"task_id": tid, "status": "idle", "current_tool": None, "iteration": 0,
|
||
}
|
||
# Resolve agent_id from event_adapter map or task.assigned_to
|
||
stop_task = None
|
||
if self._store_is_ready(store):
|
||
try:
|
||
stop_task = await store.get_task(tid)
|
||
except Exception:
|
||
pass
|
||
resolved_agent = self._resolve_agent_for_idle(tid, stop_task)
|
||
if resolved_agent:
|
||
stop_payload["agent_id"] = resolved_agent
|
||
await self.broadcast({"type": "agent_runtime_update", "payload": stop_payload})
|
||
except Exception:
|
||
logger.opt(exception=True).warning(f"Failed to broadcast stop status for {tid}")
|
||
|
||
# Insert system message (only for the primary task — children are internal)
|
||
channel_id = f"session:{task_id}"
|
||
pid = run_project_id
|
||
try:
|
||
msg = await self.chat_store.insert_message(
|
||
channel_id=channel_id,
|
||
sender="system",
|
||
sender_name="System",
|
||
content="Task stopped by user",
|
||
project_id=pid,
|
||
metadata={"type": "system", "stop_reason": "user_stop"},
|
||
)
|
||
await self.broadcast({"type": "session_message", "payload": msg})
|
||
except Exception:
|
||
logger.opt(exception=True).warning(f"Failed to insert stop message for {task_id}")
|
||
await self._send_ack(ws, ok=True)
|
||
|
||
async def _handle_session_resume(self, ws: Any, data: dict) -> None:
|
||
"""Resume by durable runtime-session/checkpoint identity."""
|
||
task_id = str(data.get("task_id", "") or "").strip()
|
||
if not task_id:
|
||
await self._send_ack(ws, ok=False, error="missing_task_id")
|
||
return
|
||
run_engine, run_project_id = await self._engine_for_request(data)
|
||
|
||
async def reject(error: str, **extra: Any) -> None:
|
||
await self._send_ack(ws, ok=False, error=error, **extra)
|
||
await self._refresh_runtime_control_for_client(
|
||
ws,
|
||
engine=run_engine,
|
||
project_id=run_project_id,
|
||
)
|
||
|
||
task = None
|
||
session_id_override = str(data.get("session_id", "") or "").strip() or None
|
||
if self._store_is_ready(run_engine.store):
|
||
try:
|
||
task = await run_engine.store.get_task(task_id)
|
||
except Exception:
|
||
logger.opt(exception=True).warning(f"session_resume: get_task failed for {task_id}")
|
||
if task is None:
|
||
await reject("task_not_found", project_id=run_project_id, task_id=task_id)
|
||
return
|
||
session_id = session_id_override
|
||
if not session_id and task is not None:
|
||
session_id = str(task.session_id or task.parent_session_id or "").strip() or None
|
||
exec_mode, _company_profile = self._resolve_task_session_config(task)
|
||
runtime_session_id = str(data.get("runtime_session_id", "") or "").strip()
|
||
checkpoint_id = str(data.get("checkpoint_id", "") or "").strip()
|
||
is_company_resume = bool(runtime_session_id or checkpoint_id) or exec_mode in {
|
||
"company",
|
||
"org",
|
||
"custom",
|
||
}
|
||
if is_company_resume and task is not None:
|
||
if not runtime_session_id:
|
||
await reject("missing_runtime_session_id")
|
||
return
|
||
if not checkpoint_id:
|
||
await reject("missing_checkpoint_id")
|
||
return
|
||
try:
|
||
target = await self._resolve_company_runtime_target(
|
||
task_id,
|
||
engine=run_engine,
|
||
runtime_session_id=runtime_session_id,
|
||
checkpoint_id=checkpoint_id,
|
||
)
|
||
except Exception:
|
||
logger.opt(exception=True).warning(f"session_resume: failed to resolve company runtime target for {task_id}")
|
||
target = None
|
||
if target is None:
|
||
await reject(
|
||
"company_runtime_identity_mismatch",
|
||
project_id=run_project_id,
|
||
task_id=task_id,
|
||
)
|
||
return
|
||
checkpoint = target.get("checkpoint")
|
||
if checkpoint is None or str(getattr(checkpoint, "status", "") or "").strip().lower() != "pending":
|
||
await reject("checkpoint_not_pending", checkpoint_id=checkpoint_id)
|
||
return
|
||
session_id = runtime_session_id
|
||
finalizer = self._company_stop_finalize_tasks.get(runtime_session_id)
|
||
if finalizer is not None and not finalizer.done():
|
||
try:
|
||
await asyncio.wait_for(asyncio.shield(finalizer), timeout=10.0)
|
||
except asyncio.TimeoutError:
|
||
await reject("stop_finalize_in_progress")
|
||
return
|
||
except Exception:
|
||
logger.opt(exception=True).debug("session_resume: stop finalizer ended with error")
|
||
self._company_stop_intents.pop(runtime_session_id, None)
|
||
content = str(data.get("content", "") or "").strip() or "Resume the existing runtime."
|
||
lock = self._company_suspend_reply_locks.get(runtime_session_id)
|
||
if lock is not None:
|
||
self._release_current_execution_handoff()
|
||
await reject(
|
||
"checkpoint_handoff_in_progress",
|
||
checkpoint_id=checkpoint_id,
|
||
)
|
||
return
|
||
lock = asyncio.Lock()
|
||
self._company_suspend_reply_locks[runtime_session_id] = lock
|
||
try:
|
||
bg = self._track_session(
|
||
task_id,
|
||
self._process_company_suspend_reply(
|
||
ui_task_id=task_id,
|
||
runtime_session_id=runtime_session_id,
|
||
content=content,
|
||
attachment_refs=None,
|
||
message_metadata={
|
||
"ui_force_resume": True,
|
||
"response_to_checkpoint_id": checkpoint_id,
|
||
"response_to_checkpoint_type": str(getattr(checkpoint, "checkpoint_type", "") or ""),
|
||
},
|
||
user_message_id=None,
|
||
user_message_created_at=None,
|
||
run_engine=run_engine,
|
||
run_project_id=run_project_id,
|
||
target=target,
|
||
checkpoint=checkpoint,
|
||
lock=lock,
|
||
),
|
||
project_id=run_project_id,
|
||
engine=run_engine,
|
||
)
|
||
except BaseException:
|
||
self._company_suspend_reply_locks.pop(runtime_session_id, None)
|
||
raise
|
||
self._task_bg_context[bg]["company_suspend_reply"] = True
|
||
await self._send_ack(
|
||
ws,
|
||
ok=True,
|
||
runtime_session_id=runtime_session_id,
|
||
checkpoint_id=checkpoint_id,
|
||
)
|
||
return
|
||
if not session_id:
|
||
await reject("session_not_found")
|
||
return
|
||
|
||
content = str(data.get("content", "") or "").strip() or "Resume the existing runtime."
|
||
|
||
self._track_session(
|
||
task_id,
|
||
self._process_session_message(
|
||
task_id,
|
||
content,
|
||
session_id=session_id,
|
||
message_metadata={"ui_force_resume": True},
|
||
run_engine=run_engine,
|
||
run_project_id=run_project_id,
|
||
),
|
||
project_id=run_project_id,
|
||
engine=run_engine,
|
||
)
|
||
await self._send_ack(ws, ok=True)
|
||
|
||
async def _load_execution_checkpoint_for_reply(
|
||
self,
|
||
*,
|
||
engine: Any,
|
||
project_id: str,
|
||
checkpoint_id: str,
|
||
checkpoint_type: str,
|
||
) -> Any | None:
|
||
normalized_checkpoint_id = str(checkpoint_id or "").strip()
|
||
normalized_checkpoint_type = str(checkpoint_type or "").strip()
|
||
if not normalized_checkpoint_id:
|
||
return None
|
||
|
||
store = getattr(engine, "store", None)
|
||
getter = getattr(store, "get_execution_checkpoints", None)
|
||
if callable(getter):
|
||
try:
|
||
checkpoints = await getter(project_id=project_id)
|
||
except TypeError:
|
||
checkpoints = await getter(project_id)
|
||
except Exception:
|
||
logger.opt(exception=True).debug("failed to list checkpoints for explicit reply routing")
|
||
checkpoints = []
|
||
for checkpoint in list(checkpoints or []):
|
||
if str(getattr(checkpoint, "checkpoint_id", "") or "").strip() != normalized_checkpoint_id:
|
||
continue
|
||
if (
|
||
normalized_checkpoint_type
|
||
and str(getattr(checkpoint, "checkpoint_type", "") or "").strip() != normalized_checkpoint_type
|
||
):
|
||
return None
|
||
return checkpoint
|
||
|
||
direct_lookup = getattr(engine, "_load_execution_checkpoint_by_id", None)
|
||
if callable(direct_lookup):
|
||
try:
|
||
maybe_checkpoint = direct_lookup(normalized_checkpoint_id)
|
||
checkpoint = await maybe_checkpoint if inspect.isawaitable(maybe_checkpoint) else maybe_checkpoint
|
||
except Exception:
|
||
logger.opt(exception=True).debug("failed to load checkpoint by id for explicit reply routing")
|
||
checkpoint = None
|
||
if checkpoint is not None:
|
||
if str(getattr(checkpoint, "checkpoint_id", "") or "").strip() != normalized_checkpoint_id:
|
||
return None
|
||
if (
|
||
normalized_checkpoint_type
|
||
and str(getattr(checkpoint, "checkpoint_type", "") or "").strip() != normalized_checkpoint_type
|
||
):
|
||
return None
|
||
return checkpoint
|
||
return None
|
||
|
||
async def _company_delivery_feedback_parent_target(
|
||
self,
|
||
*,
|
||
task_id: str,
|
||
waiting_task_id: str,
|
||
waiting_task: Any | None,
|
||
checkpoint: Any,
|
||
payload: dict[str, Any],
|
||
engine: Any,
|
||
) -> dict[str, str]:
|
||
expected_runtime_session_id = str(
|
||
payload.get("parent_session_id")
|
||
or getattr(waiting_task, "parent_session_id", "")
|
||
or getattr(checkpoint, "session_id", "")
|
||
or ""
|
||
).strip()
|
||
|
||
for candidate_task_id in (waiting_task_id, task_id):
|
||
if not candidate_task_id:
|
||
continue
|
||
try:
|
||
target = await self._resolve_company_runtime_target(candidate_task_id, engine=engine)
|
||
except Exception:
|
||
logger.opt(exception=True).debug("failed to resolve company runtime target for delivery feedback")
|
||
target = None
|
||
if not target:
|
||
continue
|
||
runtime_session_id = str(target.get("runtime_session_id", "") or "").strip()
|
||
if expected_runtime_session_id and runtime_session_id != expected_runtime_session_id:
|
||
continue
|
||
ui_anchor_task_id = str(target.get("ui_anchor_task_id", "") or "").strip()
|
||
if runtime_session_id and ui_anchor_task_id:
|
||
return {
|
||
"parent_task_id": ui_anchor_task_id,
|
||
"parent_session_id": runtime_session_id,
|
||
}
|
||
|
||
# Delivery feedback is a company checkpoint action, not a generic
|
||
# chat operation. Ambiguous/missing durable identity must be rejected
|
||
# instead of selecting the first Task sharing the root session.
|
||
return {"parent_task_id": "", "parent_session_id": ""}
|
||
|
||
async def _delivery_feedback_checkpoint_visible_to_session(
|
||
self,
|
||
checkpoint: Any,
|
||
*,
|
||
task_id: str,
|
||
session_id: str | None,
|
||
engine: Any,
|
||
) -> bool:
|
||
requested_session_id = str(session_id or "").strip()
|
||
if not requested_session_id:
|
||
return False
|
||
|
||
payload = dict(getattr(checkpoint, "payload", {}) or {})
|
||
review_level = str(payload.get("review_level", "") or "").strip().lower()
|
||
if review_level == "manager":
|
||
return False
|
||
|
||
checker = getattr(engine, "_checkpoint_visible_to_reply_session", None)
|
||
if callable(checker):
|
||
try:
|
||
maybe_visible = checker(checkpoint, requested_session_id)
|
||
visible = await maybe_visible if inspect.isawaitable(maybe_visible) else maybe_visible
|
||
if visible is True:
|
||
return True
|
||
except Exception:
|
||
logger.opt(exception=True).debug("failed to evaluate delivery feedback checkpoint visibility")
|
||
|
||
checkpoint_session_id = str(getattr(checkpoint, "session_id", "") or "").strip()
|
||
if checkpoint_session_id == requested_session_id:
|
||
return True
|
||
|
||
for key in ("parent_session_id", "runtime_session_id", "origin_session_id"):
|
||
if str(payload.get(key, "") or "").strip() == requested_session_id:
|
||
return True
|
||
|
||
normalized_task_id = str(task_id or "").strip()
|
||
raw_task_ids = payload.get("task_ids", [])
|
||
task_ids = {
|
||
str(item or "").strip()
|
||
for item in (raw_task_ids if isinstance(raw_task_ids, list) else [])
|
||
if str(item or "").strip()
|
||
}
|
||
payload_task_id = str(payload.get("task_id", "") or "").strip()
|
||
waiting_task_id = str(payload.get("waiting_task_id", "") or payload_task_id or "").strip()
|
||
if normalized_task_id and normalized_task_id in ({payload_task_id, waiting_task_id} | task_ids):
|
||
return True
|
||
|
||
store = getattr(engine, "store", None)
|
||
get_task = getattr(store, "get_task", None) if self._store_is_ready(store) else None
|
||
if callable(get_task) and waiting_task_id:
|
||
try:
|
||
waiting_task = await get_task(waiting_task_id)
|
||
except Exception:
|
||
logger.opt(exception=True).debug("failed to load waiting task for delivery feedback visibility")
|
||
waiting_task = None
|
||
if waiting_task is not None:
|
||
waiting_session_id = str(getattr(waiting_task, "session_id", "") or "").strip()
|
||
parent_session_id = str(getattr(waiting_task, "parent_session_id", "") or "").strip()
|
||
if requested_session_id in {waiting_session_id, parent_session_id}:
|
||
return True
|
||
if normalized_task_id and normalized_task_id == str(getattr(waiting_task, "id", "") or "").strip():
|
||
return True
|
||
|
||
return False
|
||
|
||
@staticmethod
|
||
def _checkpoint_created_timestamp(checkpoint: Any | None) -> float | None:
|
||
raw_value = getattr(checkpoint, "created_at", None) if checkpoint is not None else None
|
||
if raw_value is None:
|
||
return None
|
||
if isinstance(raw_value, (int, float)):
|
||
return float(raw_value)
|
||
if isinstance(raw_value, datetime):
|
||
return raw_value.timestamp()
|
||
if isinstance(raw_value, str):
|
||
try:
|
||
return datetime.fromisoformat(raw_value.replace("Z", "+00:00")).timestamp()
|
||
except ValueError:
|
||
return None
|
||
return None
|
||
|
||
def _delivery_feedback_checkpoint_channel_id(
|
||
self,
|
||
checkpoint: Any | None,
|
||
*,
|
||
fallback_channel_id: str | None = None,
|
||
) -> str:
|
||
normalized_fallback = str(fallback_channel_id or "").strip()
|
||
if normalized_fallback:
|
||
return normalized_fallback
|
||
payload = dict(getattr(checkpoint, "payload", {}) or {}) if checkpoint is not None else {}
|
||
for key in ("parent_task_id", "ui_task_id", "origin_task_id", "task_id", "waiting_task_id"):
|
||
task_id = str(payload.get(key, "") or "").strip()
|
||
if task_id:
|
||
return f"session:{task_id}"
|
||
checkpoint_task_id = str(getattr(checkpoint, "task_id", "") or "").strip() if checkpoint is not None else ""
|
||
return f"session:{checkpoint_task_id}" if checkpoint_task_id else ""
|
||
|
||
async def _update_or_emit_checkpoint_card_status(
|
||
self,
|
||
checkpoint_id: str,
|
||
*,
|
||
checkpoint_type: str,
|
||
status: str,
|
||
project_id: str,
|
||
channel_id: str | None = None,
|
||
checkpoint: Any | None = None,
|
||
response_message_id: str | None = None,
|
||
response_metadata: dict[str, Any] | None = None,
|
||
status_metadata: dict[str, Any] | None = None,
|
||
broadcast_update: bool = True,
|
||
) -> dict[str, Any] | None:
|
||
"""Update a persisted checkpoint card, or persist a terminal synthetic card.
|
||
|
||
Snapshot-built delivery review cards can be synthetic-only. In that case
|
||
there is no chat_store row for update_checkpoint_status() to mutate, so
|
||
the client keeps the old pending card in the floating Pending Actions
|
||
section. Persisting the terminal synthetic card with the same checkpoint
|
||
identity lets the frontend merge it and move it back into the timeline.
|
||
"""
|
||
normalized_checkpoint_id = str(checkpoint_id or "").strip()
|
||
normalized_checkpoint_type = str(checkpoint_type or "").strip()
|
||
normalized_status = str(status or "resolved").strip().lower() or "resolved"
|
||
normalized_project_id = self._normalize_project_id(project_id)
|
||
if not normalized_checkpoint_id or not normalized_checkpoint_type:
|
||
return None
|
||
|
||
updated = await self.chat_store.update_checkpoint_status(
|
||
normalized_checkpoint_id,
|
||
channel_id=channel_id,
|
||
checkpoint_type=normalized_checkpoint_type,
|
||
status=normalized_status,
|
||
response_message_id=response_message_id,
|
||
response_metadata=response_metadata,
|
||
status_metadata=status_metadata,
|
||
project_id=normalized_project_id,
|
||
)
|
||
if updated is not None:
|
||
if broadcast_update:
|
||
await self.broadcast({"type": "session_message", "payload": updated})
|
||
return updated
|
||
|
||
if normalized_checkpoint_type != "company_delivery_feedback":
|
||
return None
|
||
|
||
fallback_channel_id = self._delivery_feedback_checkpoint_channel_id(
|
||
checkpoint,
|
||
fallback_channel_id=channel_id,
|
||
)
|
||
if not fallback_channel_id:
|
||
return None
|
||
|
||
if checkpoint is not None:
|
||
base_meta = self._build_delivery_feedback_meta(checkpoint)
|
||
else:
|
||
base_meta = {
|
||
"checkpoint_type": normalized_checkpoint_type,
|
||
"checkpoint_id": normalized_checkpoint_id,
|
||
"summary": "Human review requested.",
|
||
"prompt": "Human review requested.",
|
||
}
|
||
|
||
now = time.time()
|
||
metadata = dict(base_meta)
|
||
metadata["checkpoint_type"] = normalized_checkpoint_type
|
||
metadata["checkpoint_id"] = normalized_checkpoint_id
|
||
metadata["checkpoint_status"] = normalized_status
|
||
if normalized_status == "responded":
|
||
metadata["checkpoint_responded_at"] = now
|
||
else:
|
||
metadata["checkpoint_resolved_at"] = now
|
||
if response_message_id:
|
||
metadata["checkpoint_response_message_id"] = response_message_id
|
||
if isinstance(status_metadata, dict):
|
||
for key, value in status_metadata.items():
|
||
metadata[str(key)] = value
|
||
if isinstance(response_metadata, dict):
|
||
raw_reply_kind = str(response_metadata.get("checkpoint_reply_kind", "") or "").strip().lower()
|
||
if raw_reply_kind in {"approve", "deny", "feedback", "ignore"}:
|
||
metadata["checkpoint_reply_kind"] = raw_reply_kind
|
||
|
||
content = str(
|
||
metadata.get("prompt")
|
||
or metadata.get("summary")
|
||
or "Human review requested."
|
||
).strip() or "Human review requested."
|
||
sender_name = str(
|
||
metadata.get("work_item_projection_title")
|
||
or metadata.get("requesting_role_id")
|
||
or "Company Member"
|
||
).strip() or "Company Member"
|
||
message_id = f"checkpoint::{normalized_checkpoint_id}"
|
||
try:
|
||
created = await self.chat_store.insert_message(
|
||
channel_id=fallback_channel_id,
|
||
sender="assistant",
|
||
sender_name=sender_name,
|
||
content=content,
|
||
metadata=metadata,
|
||
message_id=message_id,
|
||
project_id=normalized_project_id,
|
||
created_at=self._checkpoint_created_timestamp(checkpoint),
|
||
)
|
||
except Exception:
|
||
logger.opt(exception=True).debug(
|
||
"failed to insert terminal synthetic checkpoint card; retrying status update: checkpoint_id={}",
|
||
normalized_checkpoint_id,
|
||
)
|
||
updated = await self.chat_store.update_checkpoint_status(
|
||
normalized_checkpoint_id,
|
||
channel_id=None,
|
||
checkpoint_type=normalized_checkpoint_type,
|
||
status=normalized_status,
|
||
response_message_id=response_message_id,
|
||
response_metadata=response_metadata,
|
||
status_metadata=status_metadata,
|
||
project_id=normalized_project_id,
|
||
)
|
||
if updated is not None:
|
||
if broadcast_update:
|
||
await self.broadcast({"type": "session_message", "payload": updated})
|
||
return updated
|
||
return None
|
||
if broadcast_update:
|
||
await self.broadcast({"type": "session_message", "payload": created})
|
||
return created
|
||
|
||
async def _supersede_pending_delivery_feedback_for_new_company_turn(
|
||
self,
|
||
*,
|
||
task_id: str,
|
||
session_id: str | None,
|
||
run_engine: Any,
|
||
run_project_id: str,
|
||
) -> list[str]:
|
||
project_id = self._normalize_project_id(run_project_id or getattr(run_engine, "project_id", None))
|
||
store = getattr(run_engine, "store", None)
|
||
if not session_id or not self._store_is_ready(store):
|
||
return []
|
||
getter = getattr(store, "get_pending_checkpoints", None)
|
||
resolver = getattr(store, "resolve_execution_checkpoint", None)
|
||
if not callable(getter) or not callable(resolver):
|
||
return []
|
||
try:
|
||
checkpoints = await getter(
|
||
project_id=project_id,
|
||
checkpoint_types=["company_delivery_feedback"],
|
||
)
|
||
except Exception:
|
||
logger.opt(exception=True).debug("failed to list pending delivery feedback checkpoints")
|
||
return []
|
||
|
||
superseded_ids: list[str] = []
|
||
for checkpoint in list(checkpoints or []):
|
||
checkpoint_id = str(getattr(checkpoint, "checkpoint_id", "") or "").strip()
|
||
if (
|
||
not checkpoint_id
|
||
or str(getattr(checkpoint, "checkpoint_type", "") or "").strip() != "company_delivery_feedback"
|
||
or str(getattr(checkpoint, "status", "") or "").strip().lower() != "pending"
|
||
):
|
||
continue
|
||
visible = await self._delivery_feedback_checkpoint_visible_to_session(
|
||
checkpoint,
|
||
task_id=task_id,
|
||
session_id=session_id,
|
||
engine=run_engine,
|
||
)
|
||
if not visible:
|
||
continue
|
||
|
||
try:
|
||
terminalizer = getattr(run_engine, "_terminalize_company_delivery_feedback_checkpoint", None)
|
||
if inspect.iscoroutinefunction(terminalizer):
|
||
superseded_at = datetime.now().isoformat()
|
||
await terminalizer(
|
||
checkpoint,
|
||
status="superseded",
|
||
resolution="superseded_by_new_company_turn",
|
||
payload_updates={
|
||
"feedback_superseded": True,
|
||
"feedback_superseded_at": superseded_at,
|
||
"feedback_resolution": "superseded_by_new_company_turn",
|
||
},
|
||
task_metadata_updates={
|
||
"feedback_superseded": True,
|
||
"feedback_superseded_at": superseded_at,
|
||
},
|
||
)
|
||
else:
|
||
await resolver(checkpoint_id, status="superseded")
|
||
waiting_task_id = str(
|
||
dict(getattr(checkpoint, "payload", {}) or {}).get("waiting_task_id")
|
||
or getattr(checkpoint, "task_id", "")
|
||
or ""
|
||
).strip()
|
||
if waiting_task_id and hasattr(store, "get_task") and hasattr(store, "save_task"):
|
||
from opc.core.models import TaskStatus
|
||
waiting_task = await store.get_task(waiting_task_id)
|
||
if waiting_task is not None:
|
||
superseded_at = datetime.now().isoformat()
|
||
waiting_task.metadata = dict(getattr(waiting_task, "metadata", {}) or {})
|
||
waiting_task.metadata.update({
|
||
"requires_user_feedback": False,
|
||
"human_review_closed": True,
|
||
"human_review_closed_at": superseded_at,
|
||
"human_review_resolution": "superseded_by_new_company_turn",
|
||
"feedback_closed": True,
|
||
"feedback_resolved": True,
|
||
"feedback_superseded": True,
|
||
"feedback_superseded_at": superseded_at,
|
||
"feedback_resolution": "superseded_by_new_company_turn",
|
||
})
|
||
await apply_task_status_transition(
|
||
store,
|
||
waiting_task,
|
||
target_status_or_phase=TaskStatus.DONE,
|
||
reason="superseded_by_new_company_turn",
|
||
)
|
||
except Exception:
|
||
logger.opt(exception=True).debug("failed to mark delivery feedback checkpoint superseded")
|
||
continue
|
||
|
||
superseded_ids.append(checkpoint_id)
|
||
await self._update_or_emit_checkpoint_card_status(
|
||
checkpoint_id,
|
||
checkpoint_type="company_delivery_feedback",
|
||
status="superseded",
|
||
checkpoint=checkpoint,
|
||
channel_id=f"session:{task_id}",
|
||
status_metadata={"checkpoint_resolution_reason": "new_company_turn_started"},
|
||
project_id=project_id,
|
||
)
|
||
return superseded_ids
|
||
|
||
async def _route_company_delivery_feedback_reply_if_pending(
|
||
self,
|
||
*,
|
||
task_id: str,
|
||
content: str,
|
||
session_id: str | None,
|
||
task: Any | None,
|
||
attachment_refs: list[dict] | None,
|
||
message_metadata: dict[str, Any] | None,
|
||
user_message_id: str | None,
|
||
user_message_created_at: float | None,
|
||
run_engine: Any,
|
||
run_project_id: str,
|
||
reply_channel_id: str,
|
||
) -> bool:
|
||
metadata = dict(message_metadata or {})
|
||
checkpoint_id = str(metadata.get("response_to_checkpoint_id", "") or "").strip()
|
||
checkpoint_type = str(metadata.get("response_to_checkpoint_type", "") or "").strip()
|
||
reply_kind = str(metadata.get("checkpoint_reply_kind", "") or "").strip().lower()
|
||
if checkpoint_type != "company_delivery_feedback" or not checkpoint_id:
|
||
return False
|
||
|
||
checkpoint = await self._load_execution_checkpoint_for_reply(
|
||
engine=run_engine,
|
||
project_id=run_project_id,
|
||
checkpoint_id=checkpoint_id,
|
||
checkpoint_type=checkpoint_type,
|
||
)
|
||
if checkpoint is None:
|
||
await self._update_or_emit_checkpoint_card_status(
|
||
checkpoint_id,
|
||
channel_id=reply_channel_id,
|
||
checkpoint_type=checkpoint_type,
|
||
status="stale",
|
||
response_message_id=user_message_id,
|
||
response_metadata=metadata,
|
||
project_id=run_project_id,
|
||
)
|
||
if reply_kind == "ignore":
|
||
return True
|
||
helper = await self.chat_store.insert_message(
|
||
channel_id=reply_channel_id,
|
||
sender="assistant",
|
||
sender_name="OPC",
|
||
content=(
|
||
"This delivery self-evolution review is no longer active. "
|
||
"The review card has been marked inactive in the session history."
|
||
),
|
||
project_id=run_project_id,
|
||
metadata={"type": "system"},
|
||
)
|
||
await self.broadcast({"type": "session_message", "payload": helper})
|
||
return True
|
||
|
||
status = str(getattr(checkpoint, "status", "") or "").strip().lower()
|
||
if status and status != "pending":
|
||
await self._update_or_emit_checkpoint_card_status(
|
||
checkpoint_id,
|
||
channel_id=reply_channel_id,
|
||
checkpoint_type=checkpoint_type,
|
||
status=status,
|
||
checkpoint=checkpoint,
|
||
response_message_id=user_message_id,
|
||
response_metadata=metadata,
|
||
project_id=run_project_id,
|
||
)
|
||
if reply_kind == "ignore":
|
||
return True
|
||
if status == "superseded":
|
||
helper_text = (
|
||
"This delivery self-evolution review was superseded by a newer company turn. "
|
||
"The review card has been marked inactive in the session history."
|
||
)
|
||
else:
|
||
helper_text = (
|
||
"This delivery self-evolution review is no longer active. "
|
||
"The review card has been updated in the session history."
|
||
)
|
||
helper = await self.chat_store.insert_message(
|
||
channel_id=reply_channel_id,
|
||
sender="assistant",
|
||
sender_name="OPC",
|
||
content=helper_text,
|
||
project_id=run_project_id,
|
||
metadata={"type": "system"},
|
||
)
|
||
await self.broadcast({"type": "session_message", "payload": helper})
|
||
return True
|
||
|
||
payload = dict(getattr(checkpoint, "payload", {}) or {})
|
||
waiting_task_id = str(payload.get("waiting_task_id", "") or payload.get("task_id", "") or "").strip()
|
||
if reply_kind == "ignore":
|
||
lock_key = checkpoint_id
|
||
lock = self._company_delivery_feedback_reply_locks.get(lock_key)
|
||
if lock is None:
|
||
lock = asyncio.Lock()
|
||
self._company_delivery_feedback_reply_locks[lock_key] = lock
|
||
|
||
async with lock:
|
||
checkpoint = await self._load_execution_checkpoint_for_reply(
|
||
engine=run_engine,
|
||
project_id=run_project_id,
|
||
checkpoint_id=checkpoint_id,
|
||
checkpoint_type=checkpoint_type,
|
||
)
|
||
if checkpoint is None:
|
||
await self._update_or_emit_checkpoint_card_status(
|
||
checkpoint_id,
|
||
channel_id=reply_channel_id,
|
||
checkpoint_type=checkpoint_type,
|
||
status="stale",
|
||
response_message_id=user_message_id,
|
||
response_metadata=metadata,
|
||
project_id=run_project_id,
|
||
)
|
||
return True
|
||
|
||
status = str(getattr(checkpoint, "status", "") or "").strip().lower()
|
||
if status and status != "pending":
|
||
await self._update_or_emit_checkpoint_card_status(
|
||
checkpoint_id,
|
||
channel_id=reply_channel_id,
|
||
checkpoint_type=checkpoint_type,
|
||
status=status,
|
||
checkpoint=checkpoint,
|
||
response_message_id=user_message_id,
|
||
response_metadata=metadata,
|
||
project_id=run_project_id,
|
||
)
|
||
return True
|
||
|
||
started = time.monotonic()
|
||
await self._update_or_emit_checkpoint_card_status(
|
||
checkpoint_id,
|
||
channel_id=reply_channel_id,
|
||
checkpoint_type="company_delivery_feedback",
|
||
status="ignored",
|
||
checkpoint=checkpoint,
|
||
response_message_id=user_message_id,
|
||
response_metadata=metadata,
|
||
status_metadata={"checkpoint_resolution_reason": "ignored_by_user"},
|
||
project_id=run_project_id,
|
||
)
|
||
|
||
payload = dict(getattr(checkpoint, "payload", {}) or {})
|
||
ignored_at = datetime.now().isoformat()
|
||
try:
|
||
runner = getattr(run_engine, "ignore_company_delivery_feedback_checkpoint", None)
|
||
if callable(runner):
|
||
result = runner(checkpoint, reply_metadata=metadata or None)
|
||
if inspect.isawaitable(result):
|
||
await result
|
||
else:
|
||
terminalizer = getattr(run_engine, "_terminalize_company_delivery_feedback_checkpoint", None)
|
||
if callable(terminalizer):
|
||
result = terminalizer(
|
||
checkpoint,
|
||
status="ignored",
|
||
resolution="self_evolution_review_ignored",
|
||
payload_updates={
|
||
**payload,
|
||
"feedback_ignored": True,
|
||
"feedback_ignored_at": ignored_at,
|
||
"feedback_resolution": "self_evolution_review_ignored",
|
||
},
|
||
task_metadata_updates={
|
||
"self_evolution_review_ignored": True,
|
||
"self_evolution_review_ignored_at": ignored_at,
|
||
"feedback_ignored": True,
|
||
"feedback_ignored_at": ignored_at,
|
||
},
|
||
)
|
||
if inspect.isawaitable(result):
|
||
await result
|
||
else:
|
||
resolver = getattr(getattr(run_engine, "store", None), "resolve_execution_checkpoint", None)
|
||
if callable(resolver):
|
||
result = resolver(checkpoint_id, status="ignored")
|
||
if inspect.isawaitable(result):
|
||
await result
|
||
except Exception:
|
||
logger.exception(
|
||
"failed to terminalize ignored delivery feedback checkpoint: checkpoint_id={}",
|
||
checkpoint_id,
|
||
)
|
||
logger.info(
|
||
"delivery feedback ignore handled: checkpoint_id={} elapsed_ms={:.1f}",
|
||
checkpoint_id,
|
||
(time.monotonic() - started) * 1000,
|
||
)
|
||
return True
|
||
waiting_task = None
|
||
store = getattr(run_engine, "store", None)
|
||
if waiting_task_id and self._store_is_ready(store):
|
||
try:
|
||
waiting_task = await store.get_task(waiting_task_id)
|
||
except Exception:
|
||
logger.opt(exception=True).debug("failed to load delivery feedback waiting task")
|
||
target = await self._company_delivery_feedback_parent_target(
|
||
task_id=task_id,
|
||
waiting_task_id=waiting_task_id,
|
||
waiting_task=waiting_task,
|
||
checkpoint=checkpoint,
|
||
payload=payload,
|
||
engine=run_engine,
|
||
)
|
||
parent_task_id = str(target.get("parent_task_id", "") or "").strip()
|
||
parent_session_id = str(target.get("parent_session_id", "") or "").strip()
|
||
if not parent_task_id or not parent_session_id:
|
||
# This is already known to be an explicit delivery-feedback reply.
|
||
# Consuming it here prevents a missing/ambiguous durable identity
|
||
# from falling through as an ordinary turn on the work-item Task.
|
||
if self._chat_store_is_ready(self.chat_store):
|
||
try:
|
||
helper = await self.chat_store.insert_message(
|
||
channel_id=reply_channel_id,
|
||
sender="assistant",
|
||
sender_name="OPC",
|
||
content=(
|
||
"This review no longer matches an active company runtime. "
|
||
"Refresh the session before replying again."
|
||
),
|
||
project_id=run_project_id,
|
||
metadata={
|
||
"type": "system",
|
||
"reason": "company_runtime_identity_mismatch",
|
||
},
|
||
)
|
||
await self.broadcast({"type": "session_message", "payload": helper})
|
||
except Exception:
|
||
logger.opt(exception=True).debug(
|
||
"failed to emit delivery feedback identity rejection"
|
||
)
|
||
return True
|
||
|
||
lock_key = checkpoint_id or parent_session_id
|
||
lock = self._company_delivery_feedback_reply_locks.get(lock_key)
|
||
if lock is None:
|
||
lock = asyncio.Lock()
|
||
self._company_delivery_feedback_reply_locks[lock_key] = lock
|
||
|
||
self._track_session(
|
||
parent_task_id,
|
||
self._process_company_delivery_feedback_reply(
|
||
parent_task_id=parent_task_id,
|
||
parent_session_id=parent_session_id,
|
||
reply_channel_id=reply_channel_id,
|
||
content=content,
|
||
attachment_refs=attachment_refs,
|
||
message_metadata=metadata,
|
||
user_message_id=user_message_id,
|
||
user_message_created_at=user_message_created_at,
|
||
run_engine=run_engine,
|
||
run_project_id=run_project_id,
|
||
checkpoint=checkpoint,
|
||
waiting_task_id=waiting_task_id,
|
||
lock=lock,
|
||
),
|
||
project_id=run_project_id,
|
||
engine=run_engine,
|
||
)
|
||
return True
|
||
|
||
async def _process_company_delivery_feedback_reply(
|
||
self,
|
||
*,
|
||
parent_task_id: str,
|
||
parent_session_id: str,
|
||
reply_channel_id: str,
|
||
content: str,
|
||
attachment_refs: list[dict] | None,
|
||
message_metadata: dict[str, Any] | None,
|
||
user_message_id: str | None,
|
||
user_message_created_at: float | None,
|
||
run_engine: Any,
|
||
run_project_id: str,
|
||
checkpoint: Any,
|
||
waiting_task_id: str,
|
||
lock: asyncio.Lock,
|
||
) -> None:
|
||
pid = self._normalize_project_id(run_project_id or getattr(run_engine, "project_id", None))
|
||
async with lock:
|
||
try:
|
||
parent_task = None
|
||
if self._store_is_ready(run_engine.store):
|
||
try:
|
||
parent_task = await run_engine.store.get_task(parent_task_id)
|
||
except Exception:
|
||
logger.opt(exception=True).debug("failed to load parent task for delivery feedback reply")
|
||
if parent_task is None:
|
||
raise ServiceError(
|
||
"org_id_required",
|
||
"org_id_required",
|
||
{
|
||
"project_id": pid,
|
||
"task_id": parent_task_id,
|
||
"reason": "delivery_feedback_requires_durable_parent_task",
|
||
},
|
||
)
|
||
session_exec_mode = self._normalize_session_exec_mode(self._exec_mode)
|
||
session_company_profile = self._normalize_session_company_profile(self._company_profile)
|
||
session_org_id = ""
|
||
if parent_task is not None:
|
||
session_exec_mode, session_company_profile = self._resolve_task_session_config(parent_task)
|
||
session_org_id = self._resolve_task_org_id(parent_task)
|
||
engine_mode, company_profile = self._resolve_engine_mode(
|
||
session_exec_mode,
|
||
session_company_profile,
|
||
)
|
||
engine_message_metadata = dict(message_metadata or {})
|
||
engine_message_metadata.update(_ui_message_identity_metadata(
|
||
message_id=user_message_id,
|
||
conversation_turn_id=_ui_conversation_turn_id(user_message_id),
|
||
created_at=user_message_created_at,
|
||
))
|
||
self._active_runtime_children[parent_task_id] = parent_task_id
|
||
self._session_to_task[parent_session_id] = parent_task_id
|
||
payload = dict(getattr(checkpoint, "payload", {}) or {})
|
||
for child_task_id in list(payload.get("task_ids", []) or []):
|
||
child_id = str(child_task_id or "").strip()
|
||
if child_id:
|
||
self._active_runtime_children[child_id] = parent_task_id
|
||
if waiting_task_id:
|
||
self._active_runtime_children[waiting_task_id] = parent_task_id
|
||
|
||
reply_kind = str(engine_message_metadata.get("checkpoint_reply_kind", "") or "").strip().lower()
|
||
if reply_kind not in {"approve", "feedback"}:
|
||
normalized_content = str(content or "").strip().lower()
|
||
reply_kind = "approve" if normalized_content in {"approve", "approved", "i approve this delivery."} else "feedback"
|
||
feedback_text = str(content or "").strip() if reply_kind == "feedback" else ""
|
||
runner = getattr(run_engine, "run_company_delivery_self_evolution_checkpoint", None)
|
||
if not callable(runner):
|
||
raise RuntimeError("Company delivery self-evolution is not available in this runtime.")
|
||
result_text = await runner(
|
||
checkpoint,
|
||
action=reply_kind,
|
||
feedback=feedback_text,
|
||
reply_metadata=engine_message_metadata or None,
|
||
)
|
||
assistant_msg = await self.chat_store.insert_message(
|
||
channel_id=reply_channel_id or f"session:{parent_task_id}",
|
||
sender="assistant",
|
||
sender_name="OPC",
|
||
content=str(result_text or "Self-evolution completed.").strip(),
|
||
project_id=pid,
|
||
metadata={
|
||
"type": "system",
|
||
"kind": "company_self_evolution_result",
|
||
"response_to_checkpoint_type": "company_delivery_feedback",
|
||
"response_to_checkpoint_id": str(getattr(checkpoint, "checkpoint_id", "") or "").strip(),
|
||
"checkpoint_reply_kind": reply_kind,
|
||
"self_evolution_completed": True,
|
||
},
|
||
)
|
||
await self.broadcast({"type": "session_message", "payload": assistant_msg})
|
||
updated_checkpoint_msg = await self._mark_checkpoint_card_after_engine_response(
|
||
channel_id=reply_channel_id,
|
||
project_id=pid,
|
||
engine=run_engine,
|
||
message_metadata=engine_message_metadata,
|
||
response_message_id=user_message_id,
|
||
)
|
||
if updated_checkpoint_msg is not None:
|
||
await self.broadcast({"type": "session_message", "payload": updated_checkpoint_msg})
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as exc:
|
||
logger.exception("Company delivery feedback reply processing error: {}", exc)
|
||
if self._chat_store_is_ready(self.chat_store):
|
||
try:
|
||
msg = await self.chat_store.insert_message(
|
||
channel_id=reply_channel_id or f"session:{parent_task_id}",
|
||
sender="system",
|
||
sender_name="OPC",
|
||
content=f"Error: {exc}",
|
||
project_id=pid,
|
||
)
|
||
await self.broadcast({"type": "session_message", "payload": msg})
|
||
except Exception:
|
||
logger.opt(exception=True).debug("failed to write delivery feedback reply error")
|
||
finally:
|
||
if self._chat_store_is_ready(self.chat_store):
|
||
await self._flush_progress(parent_task_id, project_id=pid)
|
||
if waiting_task_id:
|
||
await self._flush_progress(waiting_task_id, project_id=pid)
|
||
|
||
async def _route_company_suspend_reply_if_pending(
|
||
self,
|
||
*,
|
||
task_id: str,
|
||
content: str,
|
||
session_id: str | None,
|
||
task: Any | None,
|
||
attachment_refs: list[dict] | None,
|
||
message_metadata: dict[str, Any] | None,
|
||
user_message_id: str | None,
|
||
user_message_created_at: float | None,
|
||
run_engine: Any,
|
||
run_project_id: str,
|
||
) -> bool:
|
||
"""Route text after company Stop without waiting on the parent task lock.
|
||
|
||
A live company run can still hold the parent session lock while Stop is
|
||
finalizing or already suspended. Plain text after Stop must reach the
|
||
engine's company-runtime suspend checkpoint immediately so the
|
||
CEO/final-decider can arbitrate with edit/delete/delegate tools. The
|
||
Continue button uses ``session_resume`` and keeps the original forced
|
||
resume path.
|
||
"""
|
||
explicit_checkpoint_type = str((message_metadata or {}).get("response_to_checkpoint_type", "") or "").strip()
|
||
explicit_runtime_handoff = (
|
||
explicit_checkpoint_type in COMPANY_RUNTIME_CHECKPOINT_TYPES
|
||
)
|
||
if explicit_checkpoint_type and not explicit_runtime_handoff:
|
||
return False
|
||
if task is None:
|
||
return False
|
||
explicit_checkpoint_id = str(
|
||
(message_metadata or {}).get("response_to_checkpoint_id", "") or ""
|
||
).strip()
|
||
|
||
async def reject(reason: str, content: str) -> bool:
|
||
if self._chat_store_is_ready(self.chat_store):
|
||
try:
|
||
helper = await self.chat_store.insert_message(
|
||
channel_id=f"session:{task_id}",
|
||
sender="assistant",
|
||
sender_name="OPC",
|
||
content=content,
|
||
project_id=run_project_id,
|
||
metadata={"type": "system", "reason": reason},
|
||
)
|
||
await self.broadcast({"type": "session_message", "payload": helper})
|
||
except Exception:
|
||
logger.opt(exception=True).debug(
|
||
"failed to emit company runtime handoff rejection"
|
||
)
|
||
return True
|
||
|
||
if explicit_runtime_handoff and not explicit_checkpoint_id:
|
||
return await reject(
|
||
"missing_runtime_checkpoint_id",
|
||
"This Continue request has no runtime checkpoint identity. Refresh the session and try again.",
|
||
)
|
||
|
||
try:
|
||
base_target = await self._resolve_company_runtime_target(
|
||
task_id,
|
||
engine=run_engine,
|
||
)
|
||
except Exception:
|
||
logger.opt(exception=True).debug("failed to resolve company suspend reply target")
|
||
base_target = None
|
||
if base_target is None:
|
||
if explicit_runtime_handoff or is_company_runtime_task(task):
|
||
return await reject(
|
||
"company_runtime_identity_mismatch",
|
||
"This message no longer matches an active company runtime. Refresh the session before retrying.",
|
||
)
|
||
return False
|
||
|
||
target = base_target
|
||
if explicit_checkpoint_id:
|
||
try:
|
||
target = await self._resolve_company_runtime_target(
|
||
task_id,
|
||
engine=run_engine,
|
||
checkpoint_id=explicit_checkpoint_id,
|
||
)
|
||
except Exception:
|
||
logger.opt(exception=True).debug(
|
||
"failed to resolve explicit company runtime checkpoint"
|
||
)
|
||
target = None
|
||
if target is None:
|
||
if explicit_runtime_handoff or base_target.get("checkpoint") is not None:
|
||
return await reject(
|
||
"company_runtime_identity_mismatch",
|
||
"This runtime checkpoint does not match the current company session. Refresh before retrying.",
|
||
)
|
||
return False
|
||
|
||
runtime_session_id = str(target.get("runtime_session_id", "") or "").strip()
|
||
if not runtime_session_id:
|
||
return await reject(
|
||
"company_runtime_identity_mismatch",
|
||
"The active company runtime has no canonical session identity. Refresh before retrying.",
|
||
)
|
||
|
||
finalizer = self._company_stop_finalize_tasks.get(runtime_session_id)
|
||
if finalizer is not None and not finalizer.done():
|
||
try:
|
||
await asyncio.wait_for(asyncio.shield(finalizer), timeout=10.0)
|
||
except asyncio.TimeoutError:
|
||
helper = await self.chat_store.insert_message(
|
||
channel_id=f"session:{task_id}",
|
||
sender="assistant",
|
||
sender_name="OPC",
|
||
content="Stop is still finalizing. Send your update again after the runtime reaches Suspended.",
|
||
project_id=run_project_id,
|
||
metadata={"type": "system", "reason": "company_stop_finalize_in_progress"},
|
||
)
|
||
await self.broadcast({"type": "session_message", "payload": helper})
|
||
return True
|
||
except Exception:
|
||
logger.opt(exception=True).debug("company stop finalizer failed before follow-up routing")
|
||
|
||
# Stop finalization may have created the checkpoint after the first
|
||
# index load. Reload durable identity before deciding whether this
|
||
# message is an ordinary turn.
|
||
try:
|
||
target = await self._resolve_company_runtime_target(
|
||
task_id,
|
||
engine=run_engine,
|
||
checkpoint_id=explicit_checkpoint_id,
|
||
)
|
||
except Exception:
|
||
logger.opt(exception=True).debug(
|
||
"failed to reload company runtime identity after stop finalization"
|
||
)
|
||
target = None
|
||
if target is None:
|
||
return await reject(
|
||
"company_runtime_identity_mismatch",
|
||
"The stopped runtime could not be matched to this session. Refresh before retrying.",
|
||
)
|
||
|
||
checkpoint = target.get("checkpoint")
|
||
if checkpoint is None:
|
||
if explicit_runtime_handoff:
|
||
return await reject(
|
||
"company_runtime_checkpoint_not_found",
|
||
"This company runtime checkpoint is no longer active. Refresh before retrying.",
|
||
)
|
||
return False
|
||
checkpoint_status = str(
|
||
getattr(checkpoint, "status", "") or ""
|
||
).strip().lower()
|
||
if checkpoint_status != "pending":
|
||
return await reject(
|
||
"company_runtime_checkpoint_not_pending",
|
||
(
|
||
"This company runtime is already being resumed. Wait for the current handoff to finish."
|
||
if checkpoint_status == "resuming"
|
||
else "This company runtime checkpoint is no longer pending. Refresh before retrying."
|
||
),
|
||
)
|
||
|
||
lock = self._company_suspend_reply_locks.get(runtime_session_id)
|
||
if lock is not None:
|
||
self._release_current_execution_handoff()
|
||
return await reject(
|
||
"checkpoint_handoff_in_progress",
|
||
"This company runtime is already being resumed by another request.",
|
||
)
|
||
lock = asyncio.Lock()
|
||
self._company_suspend_reply_locks[runtime_session_id] = lock
|
||
|
||
try:
|
||
bg = self._track_session(
|
||
task_id,
|
||
self._process_company_suspend_reply(
|
||
ui_task_id=task_id,
|
||
runtime_session_id=runtime_session_id,
|
||
content=content,
|
||
attachment_refs=attachment_refs,
|
||
message_metadata=message_metadata,
|
||
user_message_id=user_message_id,
|
||
user_message_created_at=user_message_created_at,
|
||
run_engine=run_engine,
|
||
run_project_id=run_project_id,
|
||
target=target,
|
||
checkpoint=checkpoint,
|
||
lock=lock,
|
||
),
|
||
project_id=run_project_id,
|
||
engine=run_engine,
|
||
)
|
||
except BaseException:
|
||
self._company_suspend_reply_locks.pop(runtime_session_id, None)
|
||
raise
|
||
self._task_bg_context[bg]["company_suspend_reply"] = True
|
||
return True
|
||
|
||
async def _process_company_suspend_reply(
|
||
self,
|
||
*,
|
||
ui_task_id: str,
|
||
runtime_session_id: str,
|
||
content: str,
|
||
attachment_refs: list[dict] | None,
|
||
message_metadata: dict[str, Any] | None,
|
||
user_message_id: str | None,
|
||
user_message_created_at: float | None,
|
||
run_engine: Any,
|
||
run_project_id: str,
|
||
target: dict[str, Any],
|
||
checkpoint: Any,
|
||
lock: asyncio.Lock,
|
||
) -> None:
|
||
channel_id = f"session:{ui_task_id}"
|
||
pid = self._normalize_project_id(run_project_id or getattr(run_engine, "project_id", None))
|
||
async with lock:
|
||
try:
|
||
config_task = target.get("config_task")
|
||
session_exec_mode = self._normalize_session_exec_mode(self._exec_mode)
|
||
session_company_profile = self._normalize_session_company_profile(self._company_profile)
|
||
session_org_id = ""
|
||
if config_task is not None:
|
||
session_exec_mode, session_company_profile = self._resolve_task_session_config(config_task)
|
||
session_org_id = self._resolve_task_org_id(config_task)
|
||
engine_mode, company_profile = self._resolve_engine_mode(
|
||
session_exec_mode,
|
||
session_company_profile,
|
||
)
|
||
if engine_mode == "org" and not session_org_id:
|
||
raise ServiceError(
|
||
"org_id_required",
|
||
"org_id_required",
|
||
{
|
||
"project_id": pid,
|
||
"task_id": str(target.get("config_source_task_id", "") or "").strip(),
|
||
},
|
||
)
|
||
|
||
try:
|
||
await self._set_company_runtime_control(
|
||
target,
|
||
state="resuming",
|
||
checkpoint_id=str(
|
||
getattr(checkpoint, "checkpoint_id", "") or ""
|
||
).strip(),
|
||
)
|
||
except Exception:
|
||
logger.opt(exception=True).debug("failed to broadcast company suspend reply routing state")
|
||
|
||
engine_message_metadata = dict(message_metadata or {})
|
||
engine_message_metadata.update({
|
||
"response_to_checkpoint_id": str(getattr(checkpoint, "checkpoint_id", "") or ""),
|
||
"response_to_checkpoint_type": str(getattr(checkpoint, "checkpoint_type", "") or ""),
|
||
})
|
||
engine_message_metadata.update(_ui_message_identity_metadata(
|
||
message_id=user_message_id,
|
||
conversation_turn_id=_ui_conversation_turn_id(user_message_id),
|
||
created_at=user_message_created_at,
|
||
))
|
||
execution_anchor_task_id = str(
|
||
target.get("ui_anchor_task_id", "") or ""
|
||
).strip()
|
||
if execution_anchor_task_id:
|
||
self._active_runtime_children[execution_anchor_task_id] = execution_anchor_task_id
|
||
self._session_to_task[runtime_session_id] = (
|
||
execution_anchor_task_id or ui_task_id
|
||
)
|
||
await run_engine.process_message(
|
||
content,
|
||
project_id=pid,
|
||
session_id=runtime_session_id,
|
||
mode=engine_mode,
|
||
org_id=session_org_id or None,
|
||
company_profile=company_profile,
|
||
preferred_agent=None,
|
||
origin_task_id=execution_anchor_task_id or None,
|
||
attachment_refs=attachment_refs,
|
||
message_metadata=engine_message_metadata or None,
|
||
)
|
||
checkpoint_meta = await self._extract_checkpoint_metadata(
|
||
ui_task_id,
|
||
session_id=runtime_session_id,
|
||
engine=run_engine,
|
||
)
|
||
await self._sync_task_transcript_messages(
|
||
ui_task_id,
|
||
engine=run_engine,
|
||
latest_assistant_metadata=checkpoint_meta if checkpoint_meta else None,
|
||
)
|
||
# Replace the optimistic ``resuming`` projection with the
|
||
# checkpoint state committed by the engine. Successful and
|
||
# already-consumed handoffs need the same durable refresh as
|
||
# failed handoffs so every client converges on snapshot state.
|
||
try:
|
||
await self.on_kanban_changed(engine=run_engine)
|
||
except Exception:
|
||
logger.opt(exception=True).debug(
|
||
"failed to refresh company runtime control after handoff"
|
||
)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as exc:
|
||
if self._is_expected_shutdown_error(exc) or self._is_closed_database_error(exc):
|
||
logger.debug(
|
||
"Company suspend reply skipped during shutdown/closed store: {}: {}",
|
||
type(exc).__name__,
|
||
exc,
|
||
)
|
||
else:
|
||
logger.exception("Company suspend reply processing error: {}", exc)
|
||
if self._chat_store_is_ready(self.chat_store):
|
||
try:
|
||
msg = await self.chat_store.insert_message(
|
||
channel_id=channel_id,
|
||
sender="system",
|
||
sender_name="OPC",
|
||
content=f"Error: {exc}",
|
||
project_id=pid,
|
||
)
|
||
await self.broadcast({"type": "session_message", "payload": msg})
|
||
except Exception as chat_exc:
|
||
if self._is_expected_shutdown_error(chat_exc) or self._is_closed_database_error(chat_exc):
|
||
logger.debug(
|
||
"Skipped company suspend reply error message during shutdown/closed store: {}: {}",
|
||
type(chat_exc).__name__,
|
||
chat_exc,
|
||
)
|
||
else:
|
||
logger.opt(exception=True).debug(
|
||
"Failed to write company suspend reply error message",
|
||
)
|
||
# The engine restores a failed handoff checkpoint to
|
||
# pending. Replace the optimistic ``resuming`` projection
|
||
# for every client with that durable state immediately.
|
||
await self.on_kanban_changed(engine=run_engine)
|
||
finally:
|
||
if self._chat_store_is_ready(self.chat_store):
|
||
await self._flush_progress(ui_task_id, project_id=pid)
|
||
self._task_bg_context.pop(asyncio.current_task(), None)
|
||
self._company_suspend_reply_locks.pop(runtime_session_id, None)
|
||
|
||
def _resolve_task_comms_dir(self, task: Any) -> Path | None:
|
||
"""Resolve `<workspace>/.opc-comms/<project>/<session>` for a task.
|
||
|
||
Returns None when the task lacks enough metadata to locate its
|
||
on-disk comms tree (e.g. workspace never resolved, no session id).
|
||
"""
|
||
md = task.metadata or {}
|
||
workspace_root = (
|
||
str(md.get("comms_workspace_root") or "").strip()
|
||
or str(md.get("target_output_dir") or "").strip()
|
||
or str(md.get("setup_workspace_prepared") or "").strip()
|
||
)
|
||
comms_root = str(md.get("comms_root") or "").strip()
|
||
session_id = (
|
||
str(getattr(task, "parent_session_id", "") or "").strip()
|
||
or str(getattr(task, "session_id", "") or "").strip()
|
||
)
|
||
project_id = (
|
||
str(getattr(task, "project_id", "") or "").strip()
|
||
or self.engine.project_id
|
||
or "default"
|
||
)
|
||
if not session_id:
|
||
return None
|
||
try:
|
||
from opc.layer2_organization import comms as _comms
|
||
if workspace_root:
|
||
return _comms.resolve_layout(workspace_root, project_id, session_id).root
|
||
if comms_root:
|
||
# comms_root is `<ws>/.opc-comms`; its parent is workspace_root.
|
||
inferred_ws = str(Path(comms_root).parent)
|
||
return _comms.resolve_layout(inferred_ws, project_id, session_id).root
|
||
except Exception:
|
||
logger.opt(exception=True).debug(f"_resolve_task_comms_dir failed for task {getattr(task, 'id', '?')}")
|
||
return None
|
||
|
||
async def _handle_session_delete(self, ws: Any, data: dict) -> None:
|
||
"""Delete task/session data and notify frontend."""
|
||
task_id = data.get("task_id", "")
|
||
if not task_id:
|
||
return
|
||
try:
|
||
_engine, project_id = await self._engine_for_request(data)
|
||
result = await self._ensure_office_services().session.delete(project_id=project_id, task_id=task_id)
|
||
self._session_to_task.update(self.services_context.session_to_task)
|
||
await self._publish_service_result(result)
|
||
await self._send_ack(ws, ok=True, project_id=project_id)
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="session_delete")
|
||
|
||
async def _handle_session_complete(self, ws: Any, data: dict) -> None:
|
||
"""Mark a session's task as DONE (user explicitly completes it)."""
|
||
task_id = data.get("task_id", "")
|
||
if not task_id:
|
||
return
|
||
try:
|
||
_engine, project_id = await self._engine_for_request(data)
|
||
result = await self._ensure_office_services().session.complete(project_id=project_id, task_id=task_id)
|
||
await self._publish_service_result(result)
|
||
await self._send_ack(ws, ok=True, project_id=project_id)
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="session_complete")
|
||
|
||
async def _handle_session_update_title(self, ws: Any, data: dict) -> None:
|
||
"""Update session/task title."""
|
||
task_id = data.get("task_id", "")
|
||
title = data.get("title", "")
|
||
if not task_id or not title:
|
||
return
|
||
try:
|
||
_engine, project_id = await self._engine_for_request(data)
|
||
result = await self._ensure_office_services().session.rename(
|
||
project_id=project_id,
|
||
task_id=task_id,
|
||
title=title,
|
||
)
|
||
await self._publish_service_result(result)
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="session_update_title")
|
||
|
||
async def _dispatch_session_message(
|
||
self, task_id: str, content: str, *, session_id: str | None = None,
|
||
attachment_refs: list[dict] | None = None,
|
||
message_metadata: dict[str, Any] | None = None,
|
||
user_message_id: str | None = None,
|
||
user_message_created_at: float | None = None,
|
||
run_engine: Any | None = None,
|
||
run_project_id: str | None = None,
|
||
) -> None:
|
||
"""Route through Dispatcher: classify → engine pipeline or direct reply."""
|
||
captured_context = run_engine is not None or run_project_id is not None
|
||
engine = run_engine or self.engine
|
||
pid = self._normalize_project_id(run_project_id or getattr(engine, "project_id", None))
|
||
channel_id = f"session:{task_id}"
|
||
dispatcher = self.dispatcher if engine is self.engine else Dispatcher(engine, self.chat_store)
|
||
try:
|
||
result = await dispatcher.handle(
|
||
task_id,
|
||
content,
|
||
session_id=session_id,
|
||
has_attachments=bool(attachment_refs),
|
||
)
|
||
if result.route == "engine":
|
||
process_kwargs: dict[str, Any] = {
|
||
"session_id": session_id,
|
||
"attachment_refs": attachment_refs,
|
||
}
|
||
if message_metadata:
|
||
process_kwargs["message_metadata"] = message_metadata
|
||
if user_message_id:
|
||
process_kwargs["user_message_id"] = user_message_id
|
||
if user_message_created_at is not None:
|
||
process_kwargs["user_message_created_at"] = user_message_created_at
|
||
if captured_context:
|
||
process_kwargs["run_engine"] = engine
|
||
process_kwargs["run_project_id"] = pid
|
||
await self._process_session_message(task_id, content, **process_kwargs)
|
||
else:
|
||
# Direct reply from Dispatcher (status query, conversation, session control)
|
||
msg = await self.chat_store.insert_message(
|
||
channel_id=channel_id,
|
||
sender="assistant",
|
||
sender_name="OPC",
|
||
content=result.response,
|
||
project_id=pid,
|
||
)
|
||
await self.broadcast({"type": "session_message", "payload": msg})
|
||
# Record exchange in engine memory for context continuity
|
||
if engine.memory and session_id:
|
||
user_turn_meta = _ui_message_identity_metadata(
|
||
kind="top_level_user_turn",
|
||
message_id=user_message_id,
|
||
conversation_turn_id=_ui_conversation_turn_id(user_message_id),
|
||
created_at=user_message_created_at,
|
||
)
|
||
await engine.memory.record_user_turn(
|
||
session_id, content,
|
||
project_id=pid,
|
||
metadata=user_turn_meta or None,
|
||
)
|
||
assistant_turn_meta = _ui_message_identity_metadata(
|
||
kind="top_level_reply",
|
||
message_id=str(msg.get("message_id") or "").strip() or None,
|
||
created_at=float(msg.get("created_at")) if msg.get("created_at") is not None else None,
|
||
)
|
||
await engine.memory.record_assistant_turn(
|
||
session_id, result.response,
|
||
project_id=pid,
|
||
metadata=assistant_turn_meta or None,
|
||
)
|
||
except Exception as e:
|
||
logger.opt(exception=True).error(f"Dispatcher error, falling back to engine: {e}")
|
||
process_kwargs = {
|
||
"session_id": session_id,
|
||
"attachment_refs": attachment_refs,
|
||
}
|
||
if message_metadata:
|
||
process_kwargs["message_metadata"] = message_metadata
|
||
if user_message_id:
|
||
process_kwargs["user_message_id"] = user_message_id
|
||
if user_message_created_at is not None:
|
||
process_kwargs["user_message_created_at"] = user_message_created_at
|
||
if captured_context:
|
||
process_kwargs["run_engine"] = engine
|
||
process_kwargs["run_project_id"] = pid
|
||
await self._process_session_message(task_id, content, **process_kwargs)
|
||
|
||
async def _execution_checkpoint_status(
|
||
self,
|
||
*,
|
||
engine: Any,
|
||
project_id: str,
|
||
checkpoint_id: str,
|
||
checkpoint_type: str | None = None,
|
||
) -> str:
|
||
normalized_checkpoint_id = str(checkpoint_id or "").strip()
|
||
normalized_checkpoint_type = str(checkpoint_type or "").strip()
|
||
if not normalized_checkpoint_id:
|
||
return ""
|
||
direct_lookup = getattr(engine, "_load_execution_checkpoint_by_id", None)
|
||
checkpoint = None
|
||
if callable(direct_lookup):
|
||
try:
|
||
checkpoint = await direct_lookup(normalized_checkpoint_id)
|
||
except Exception:
|
||
logger.opt(exception=True).debug("failed to load checkpoint by id from engine")
|
||
if checkpoint is None:
|
||
store = getattr(engine, "store", None)
|
||
getter = getattr(store, "get_execution_checkpoints", None)
|
||
if callable(getter):
|
||
try:
|
||
checkpoints = await getter(project_id=project_id)
|
||
except TypeError:
|
||
checkpoints = await getter(project_id)
|
||
for item in checkpoints:
|
||
if str(getattr(item, "checkpoint_id", "") or "").strip() == normalized_checkpoint_id:
|
||
checkpoint = item
|
||
break
|
||
if checkpoint is None:
|
||
return ""
|
||
if normalized_checkpoint_type and str(getattr(checkpoint, "checkpoint_type", "") or "").strip() != normalized_checkpoint_type:
|
||
return ""
|
||
return str(getattr(checkpoint, "status", "") or "").strip().lower()
|
||
|
||
async def _mark_checkpoint_card_after_engine_response(
|
||
self,
|
||
*,
|
||
channel_id: str,
|
||
project_id: str,
|
||
engine: Any,
|
||
message_metadata: dict[str, Any] | None,
|
||
response_message_id: str | None,
|
||
) -> dict[str, Any] | None:
|
||
metadata = dict(message_metadata or {})
|
||
checkpoint_id = str(metadata.get("response_to_checkpoint_id", "") or "").strip()
|
||
checkpoint_type = str(metadata.get("response_to_checkpoint_type", "") or "").strip()
|
||
if not checkpoint_id or checkpoint_type == "human_escalation":
|
||
return None
|
||
status = await self._execution_checkpoint_status(
|
||
engine=engine,
|
||
project_id=project_id,
|
||
checkpoint_id=checkpoint_id,
|
||
checkpoint_type=checkpoint_type,
|
||
)
|
||
if not status or status == "pending":
|
||
return None
|
||
try:
|
||
if status == "resolved":
|
||
updated = await self.chat_store.mark_checkpoint_responded(
|
||
channel_id,
|
||
checkpoint_id,
|
||
checkpoint_type=checkpoint_type,
|
||
response_message_id=response_message_id,
|
||
response_metadata=metadata,
|
||
project_id=project_id,
|
||
)
|
||
if updated is not None:
|
||
return updated
|
||
updated = await self.chat_store.update_checkpoint_status(
|
||
checkpoint_id,
|
||
channel_id=None,
|
||
checkpoint_type=checkpoint_type,
|
||
status="responded",
|
||
response_message_id=response_message_id,
|
||
response_metadata=metadata,
|
||
project_id=project_id,
|
||
)
|
||
if updated is not None:
|
||
return updated
|
||
checkpoint = await self._load_execution_checkpoint_for_reply(
|
||
engine=engine,
|
||
project_id=project_id,
|
||
checkpoint_id=checkpoint_id,
|
||
checkpoint_type=checkpoint_type,
|
||
)
|
||
return await self._update_or_emit_checkpoint_card_status(
|
||
checkpoint_id,
|
||
channel_id=channel_id,
|
||
checkpoint_type=checkpoint_type,
|
||
status="responded",
|
||
checkpoint=checkpoint,
|
||
response_message_id=response_message_id,
|
||
response_metadata=metadata,
|
||
project_id=project_id,
|
||
broadcast_update=False,
|
||
)
|
||
updated = await self.chat_store.update_checkpoint_status(
|
||
checkpoint_id,
|
||
channel_id=channel_id,
|
||
checkpoint_type=checkpoint_type,
|
||
status=status,
|
||
response_message_id=response_message_id,
|
||
response_metadata=metadata,
|
||
project_id=project_id,
|
||
)
|
||
if updated is not None:
|
||
return updated
|
||
updated = await self.chat_store.update_checkpoint_status(
|
||
checkpoint_id,
|
||
channel_id=None,
|
||
checkpoint_type=checkpoint_type,
|
||
status=status,
|
||
response_message_id=response_message_id,
|
||
response_metadata=metadata,
|
||
project_id=project_id,
|
||
)
|
||
if updated is not None:
|
||
return updated
|
||
checkpoint = await self._load_execution_checkpoint_for_reply(
|
||
engine=engine,
|
||
project_id=project_id,
|
||
checkpoint_id=checkpoint_id,
|
||
checkpoint_type=checkpoint_type,
|
||
)
|
||
return await self._update_or_emit_checkpoint_card_status(
|
||
checkpoint_id,
|
||
channel_id=channel_id,
|
||
checkpoint_type=checkpoint_type,
|
||
status=status,
|
||
checkpoint=checkpoint,
|
||
response_message_id=response_message_id,
|
||
response_metadata=metadata,
|
||
project_id=project_id,
|
||
broadcast_update=False,
|
||
)
|
||
except Exception:
|
||
logger.opt(exception=True).debug("failed to persist checkpoint card terminal state")
|
||
return None
|
||
|
||
_LOCK_FREE_CHECKPOINT_ANSWER_TYPES = frozenset({
|
||
"task_user_input",
|
||
"company_work_item_gate",
|
||
})
|
||
|
||
async def _try_lock_free_parked_checkpoint_answer(
|
||
self,
|
||
*,
|
||
task_id: str,
|
||
content: str,
|
||
session_id: str | None,
|
||
message_metadata: dict[str, Any] | None,
|
||
user_message_id: str | None,
|
||
user_message_created_at: float | None,
|
||
engine: Any,
|
||
pid: str,
|
||
channel_id: str,
|
||
session_exec_mode: str,
|
||
session_company_profile: str | None,
|
||
session_org_id: str,
|
||
attachment_refs: list[dict] | None,
|
||
) -> bool:
|
||
"""Answer a pending park checkpoint without taking the per-task turn lock.
|
||
|
||
A company goal turn can hold the per-task lock for hours while its live
|
||
dispatcher waits on AWAITING_HUMAN approval cards. The card answers are
|
||
themselves session messages, so they queue behind that same lock — a
|
||
circular wait: dispatcher waits for the answer, the answer waits for the
|
||
lock, the lock waits for the dispatcher (project-0012 late-approval
|
||
wedge). When the reply explicitly targets a pending park checkpoint and
|
||
the lock is currently held, deliver it straight through the engine's
|
||
checkpoint-resume channel instead: with a live dispatcher the engine
|
||
only persists the input, applies the approval decision, releases the
|
||
human wait, and wakes the loop — no second dispatcher, no re-entry.
|
||
|
||
Returns True when the reply was fully handled here.
|
||
"""
|
||
metadata = dict(message_metadata or {})
|
||
checkpoint_id = str(metadata.get("response_to_checkpoint_id", "") or "").strip()
|
||
checkpoint_type = str(metadata.get("response_to_checkpoint_type", "") or "").strip()
|
||
if not checkpoint_id or checkpoint_type not in self._LOCK_FREE_CHECKPOINT_ANSWER_TYPES:
|
||
return False
|
||
lock = self._get_task_lock(task_id)
|
||
if not lock.locked():
|
||
# No turn in flight: the serialized path works and preserves
|
||
# ordering, so keep the existing behavior.
|
||
return False
|
||
store = getattr(engine, "store", None)
|
||
if not self._store_is_ready(store):
|
||
return False
|
||
getter = getattr(store, "get_pending_checkpoints", None)
|
||
if not callable(getter):
|
||
return False
|
||
try:
|
||
pending = await getter(project_id=pid)
|
||
except Exception:
|
||
logger.opt(exception=True).debug(
|
||
"Lock-free checkpoint answer: failed to load pending checkpoints"
|
||
)
|
||
return False
|
||
checkpoint = next(
|
||
(
|
||
item
|
||
for item in pending or []
|
||
if str(getattr(item, "checkpoint_id", "") or "").strip() == checkpoint_id
|
||
and str(getattr(item, "checkpoint_type", "") or "").strip() == checkpoint_type
|
||
),
|
||
None,
|
||
)
|
||
if checkpoint is None:
|
||
return False
|
||
checkpoint_payload = dict(getattr(checkpoint, "payload", {}) or {})
|
||
checkpoint_task_id = str(
|
||
getattr(checkpoint, "task_id", "")
|
||
or checkpoint_payload.get("waiting_task_id")
|
||
or checkpoint_payload.get("task_id")
|
||
or ""
|
||
).strip()
|
||
checkpoint_session_id = str(
|
||
getattr(checkpoint, "session_id", "")
|
||
or checkpoint_payload.get("session_id")
|
||
or ""
|
||
).strip()
|
||
payload_task_ids = {
|
||
str(item).strip()
|
||
for item in list(checkpoint_payload.get("task_ids", []) or [])
|
||
if str(item).strip()
|
||
}
|
||
if (
|
||
not checkpoint_task_id
|
||
or checkpoint_task_id != str(task_id or "").strip()
|
||
or not checkpoint_session_id
|
||
or checkpoint_session_id != str(session_id or "").strip()
|
||
or (payload_task_ids and str(task_id or "").strip() not in payload_task_ids)
|
||
):
|
||
return False
|
||
logger.info(
|
||
f"Lock-free checkpoint answer: task lock for {task_id} is held by a "
|
||
f"live turn; delivering reply to pending checkpoint {checkpoint_id} "
|
||
"through the engine resume channel"
|
||
)
|
||
try:
|
||
engine_mode, company_profile = self._resolve_engine_mode(
|
||
session_exec_mode,
|
||
session_company_profile,
|
||
)
|
||
engine_message_metadata = dict(metadata)
|
||
engine_message_metadata.update(_ui_message_identity_metadata(
|
||
message_id=user_message_id,
|
||
conversation_turn_id=_ui_conversation_turn_id(user_message_id),
|
||
created_at=user_message_created_at,
|
||
))
|
||
response = await engine.process_message(
|
||
content,
|
||
project_id=pid,
|
||
session_id=session_id,
|
||
mode=engine_mode,
|
||
org_id=session_org_id or None,
|
||
company_profile=company_profile,
|
||
origin_task_id=task_id,
|
||
attachment_refs=attachment_refs,
|
||
message_metadata=engine_message_metadata or None,
|
||
)
|
||
updated_checkpoint_msg = await self._mark_checkpoint_card_after_engine_response(
|
||
channel_id=channel_id,
|
||
project_id=pid,
|
||
engine=engine,
|
||
message_metadata=engine_message_metadata,
|
||
response_message_id=user_message_id,
|
||
)
|
||
if updated_checkpoint_msg is not None:
|
||
await self.broadcast({"type": "session_message", "payload": updated_checkpoint_msg})
|
||
reply_text = str(response or "").strip() or "Input received."
|
||
reply_msg = await self.chat_store.insert_message(
|
||
channel_id=channel_id,
|
||
sender="assistant",
|
||
sender_name="OPC",
|
||
content=reply_text,
|
||
project_id=pid,
|
||
metadata={"type": "system", "checkpoint_answer_lock_free": True},
|
||
)
|
||
await self.broadcast({"type": "session_message", "payload": reply_msg})
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as exc:
|
||
# Do NOT fall back to the locked path: it would silently queue
|
||
# behind the in-flight turn — exactly the wedge this path exists
|
||
# to break. Surface the failure so the user can retry.
|
||
logger.opt(exception=True).warning(
|
||
f"Lock-free checkpoint answer failed for {checkpoint_id}"
|
||
)
|
||
helper = await self.chat_store.insert_message(
|
||
channel_id=channel_id,
|
||
sender="system",
|
||
sender_name="OPC",
|
||
content=f"Failed to deliver the approval reply: {exc}. Please click the card again.",
|
||
project_id=pid,
|
||
)
|
||
await self.broadcast({"type": "session_message", "payload": helper})
|
||
return True
|
||
|
||
async def _process_session_message(
|
||
self, task_id: str, content: str, *,
|
||
session_id: str | None = None,
|
||
attachment_refs: list[dict] | None = None,
|
||
message_metadata: dict[str, Any] | None = None,
|
||
user_message_id: str | None = None,
|
||
user_message_created_at: float | None = None,
|
||
run_engine: Any | None = None,
|
||
run_project_id: str | None = None,
|
||
) -> None:
|
||
"""Process user message in session context via engine.
|
||
|
||
Passes session_id to engine.process_message so the engine can:
|
||
- Record user/assistant turns to session memory
|
||
- Build session-aware context for agent execution
|
||
|
||
Temporarily overrides engine.on_progress so that all progress
|
||
during this call routes to session:{task_id}.
|
||
"""
|
||
engine = run_engine or self.engine
|
||
pid = self._normalize_project_id(run_project_id or getattr(engine, "project_id", None))
|
||
channel_id = f"session:{task_id}"
|
||
|
||
# Register session→task mapping for company runtime child resolution
|
||
if session_id:
|
||
self._session_to_task[session_id] = task_id
|
||
|
||
session_exec_mode = self._normalize_session_exec_mode(self._exec_mode)
|
||
session_company_profile = self._normalize_session_company_profile(self._company_profile)
|
||
session_preferred_agent = self._task_preferred_agent
|
||
session_org_id = ""
|
||
task = None
|
||
config_task = None
|
||
store = engine.store
|
||
if self._store_is_ready(store):
|
||
from opc.core.models import TaskStatus
|
||
task = await store.get_task(task_id)
|
||
if task:
|
||
config_task = await self._resolve_session_runtime_config_task(
|
||
task_id,
|
||
task,
|
||
engine=engine,
|
||
)
|
||
session_exec_mode, session_company_profile = self._resolve_task_session_config(
|
||
config_task
|
||
)
|
||
session_org_id = self._resolve_task_org_id(config_task)
|
||
session_preferred_agent = self._resolve_task_preferred_agent(config_task)
|
||
if (
|
||
session_exec_mode in {"org", "custom"}
|
||
and not session_org_id
|
||
and is_company_runtime_task(task)
|
||
):
|
||
raise ServiceError(
|
||
"org_id_required",
|
||
"org_id_required",
|
||
{"project_id": pid, "task_id": task_id},
|
||
)
|
||
|
||
if await self._try_lock_free_parked_checkpoint_answer(
|
||
task_id=task_id,
|
||
content=content,
|
||
session_id=session_id,
|
||
message_metadata=message_metadata,
|
||
user_message_id=user_message_id,
|
||
user_message_created_at=user_message_created_at,
|
||
engine=engine,
|
||
pid=pid,
|
||
channel_id=channel_id,
|
||
session_exec_mode=session_exec_mode,
|
||
session_company_profile=session_company_profile,
|
||
session_org_id=session_org_id,
|
||
attachment_refs=attachment_refs,
|
||
):
|
||
return
|
||
|
||
# Per-task lock: same session serialized, different sessions concurrent
|
||
async with self._get_task_lock(task_id):
|
||
current_task = asyncio.current_task()
|
||
if current_task is not None:
|
||
self._task_lock_holders[task_id] = current_task
|
||
if self._store_is_ready(store):
|
||
from opc.core.models import TaskStatus
|
||
task = await store.get_task(task_id)
|
||
if task:
|
||
config_task = await self._resolve_session_runtime_config_task(
|
||
task_id,
|
||
task,
|
||
engine=engine,
|
||
)
|
||
session_exec_mode, session_company_profile = self._resolve_task_session_config(
|
||
config_task
|
||
)
|
||
session_org_id = self._resolve_task_org_id(config_task)
|
||
session_preferred_agent = self._resolve_task_preferred_agent(config_task)
|
||
if (
|
||
session_exec_mode in {"org", "custom"}
|
||
and not session_org_id
|
||
and is_company_runtime_task(task)
|
||
):
|
||
raise ServiceError(
|
||
"org_id_required",
|
||
"org_id_required",
|
||
{"project_id": pid, "task_id": task_id},
|
||
)
|
||
if task.status == TaskStatus.DONE and self._is_company_session_exec_mode(session_exec_mode):
|
||
task.status = TaskStatus.IDLE
|
||
task.metadata = dict(getattr(task, "metadata", {}) or {})
|
||
task.metadata["company_session_reopened_at"] = datetime.now().isoformat()
|
||
await store.save_task(task)
|
||
await apply_task_status_transition(
|
||
store,
|
||
task,
|
||
target_status_or_phase=TaskStatus.RUNNING,
|
||
reason="session_message_started",
|
||
)
|
||
await self.broadcast({"type": "board_task_status_changed", "payload": {
|
||
"project_id": pid, "task_id": task_id, "column_id": "in-progress", "status": "running",
|
||
}})
|
||
terminal_board_broadcast_sent = False
|
||
# Register company runtime origin so child-task progress can dual-route
|
||
company_runtime_target: dict[str, Any] | None = None
|
||
if session_exec_mode in ("company", "org", "custom"):
|
||
self._active_runtime_children[task_id] = task_id # primary maps to self
|
||
try:
|
||
company_runtime_target = await self._resolve_company_runtime_target(task_id, engine=engine)
|
||
await self._set_company_runtime_control(company_runtime_target, state="running")
|
||
except Exception:
|
||
logger.opt(exception=True).debug("failed to mark company session runtime running")
|
||
try:
|
||
config_task_id = str(getattr(config_task, "id", "") or "").strip()
|
||
selected_task_id = str(getattr(task, "id", "") or "").strip()
|
||
should_persist_selected_config = bool(
|
||
task is not None
|
||
and (
|
||
not config_task_id
|
||
or config_task_id == selected_task_id
|
||
or (session_exec_mode == "org" and not session_org_id)
|
||
)
|
||
)
|
||
if should_persist_selected_config:
|
||
await self._persist_session_config(
|
||
task,
|
||
exec_mode=session_exec_mode,
|
||
company_profile=session_company_profile,
|
||
preferred_agent=session_preferred_agent,
|
||
org_id=session_org_id,
|
||
engine=engine,
|
||
)
|
||
persisted_identity = self._resolve_task_identity(
|
||
task,
|
||
default_exec_mode=session_exec_mode,
|
||
default_company_profile=session_company_profile,
|
||
default_preferred_agent=session_preferred_agent,
|
||
default_org_id=session_org_id,
|
||
)
|
||
session_exec_mode = persisted_identity.exec_mode
|
||
session_company_profile = persisted_identity.company_profile
|
||
session_preferred_agent = persisted_identity.preferred_agent
|
||
session_org_id = persisted_identity.org_id
|
||
engine_mode, company_profile = self._resolve_engine_mode(
|
||
session_exec_mode,
|
||
session_company_profile,
|
||
)
|
||
engine_preferred_agent = session_preferred_agent if session_exec_mode == "task" else None
|
||
engine_message_metadata = dict(message_metadata or {})
|
||
engine_message_metadata.update(_ui_message_identity_metadata(
|
||
message_id=user_message_id,
|
||
conversation_turn_id=_ui_conversation_turn_id(user_message_id),
|
||
created_at=user_message_created_at,
|
||
))
|
||
response = await engine.process_message(
|
||
content,
|
||
project_id=pid,
|
||
session_id=session_id,
|
||
mode=engine_mode,
|
||
org_id=session_org_id or None,
|
||
company_profile=company_profile,
|
||
preferred_agent=engine_preferred_agent,
|
||
origin_task_id=task_id,
|
||
attachment_refs=attachment_refs,
|
||
message_metadata=engine_message_metadata or None,
|
||
)
|
||
updated_checkpoint_msg = await self._mark_checkpoint_card_after_engine_response(
|
||
channel_id=channel_id,
|
||
project_id=pid,
|
||
engine=engine,
|
||
message_metadata=engine_message_metadata,
|
||
response_message_id=user_message_id,
|
||
)
|
||
if updated_checkpoint_msg is not None:
|
||
await self.broadcast({"type": "session_message", "payload": updated_checkpoint_msg})
|
||
# ── Check for pending checkpoint → attach structured metadata ──
|
||
checkpoint_meta = await self._extract_checkpoint_metadata(
|
||
task_id, session_id=session_id, engine=engine,
|
||
)
|
||
await self._sync_task_transcript_messages(
|
||
task_id,
|
||
engine=engine,
|
||
latest_assistant_metadata=checkpoint_meta if checkpoint_meta else None,
|
||
)
|
||
await self._ensure_reply_projected(
|
||
channel_id=channel_id,
|
||
project_id=pid,
|
||
session_id=session_id or (str(getattr(task, "session_id", "") or "").strip() if task else None),
|
||
engine=engine,
|
||
)
|
||
|
||
# ── Status: idle only while the engine left the task active ──
|
||
store = engine.store
|
||
final_status = "idle"
|
||
final_column_id = "in-progress"
|
||
if self._store_is_ready(store):
|
||
from opc.core.models import TaskStatus as TS
|
||
t = await store.get_task(task_id)
|
||
if t:
|
||
try:
|
||
current_status = t.status if isinstance(t.status, TS) else TS(str(t.status))
|
||
except ValueError:
|
||
current_status = TS.IDLE
|
||
if current_status in {TS.PENDING, TS.RUNNING}:
|
||
t.status = TS.IDLE
|
||
await store.save_task(t)
|
||
current_status = TS.IDLE
|
||
final_status = current_status.value
|
||
if current_status in {TS.DONE, TS.CANCELLED}:
|
||
final_column_id = "done"
|
||
await self.broadcast({"type": "board_task_status_changed", "payload": {
|
||
"project_id": pid, "task_id": task_id, "column_id": final_column_id, "status": final_status,
|
||
}})
|
||
terminal_board_broadcast_sent = True
|
||
if session_exec_mode in ("company", "org", "custom"):
|
||
try:
|
||
idle_target = await self._resolve_company_runtime_target(task_id, engine=engine)
|
||
await self._set_company_runtime_control(idle_target or company_runtime_target, state="idle")
|
||
except Exception:
|
||
logger.opt(exception=True).debug("failed to mark company session runtime idle")
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as e:
|
||
logger.exception(f"Session processing error: {e}")
|
||
msg = await self.chat_store.insert_message(
|
||
channel_id=channel_id,
|
||
sender="system",
|
||
sender_name="OPC",
|
||
content=f"Error: {e}",
|
||
project_id=pid,
|
||
)
|
||
await self.broadcast({"type": "session_message", "payload": msg})
|
||
|
||
# ── Status: failed ─────────────────────────────────────
|
||
store = engine.store
|
||
if self._store_is_ready(store):
|
||
from opc.core.models import TaskStatus as TS
|
||
t = await store.get_task(task_id)
|
||
if t:
|
||
t.status = TS.FAILED
|
||
await store.save_task(t)
|
||
await self.broadcast({"type": "board_task_status_changed", "payload": {
|
||
"project_id": pid, "task_id": task_id, "column_id": "in-progress", "status": "failed",
|
||
}})
|
||
terminal_board_broadcast_sent = True
|
||
if session_exec_mode in ("company", "org", "custom"):
|
||
try:
|
||
failed_target = await self._resolve_company_runtime_target(task_id, engine=engine)
|
||
await self._set_company_runtime_control(failed_target or company_runtime_target, state="idle")
|
||
except Exception:
|
||
logger.opt(exception=True).debug("failed to clear company session runtime after error")
|
||
finally:
|
||
# Flush progress buffers before clearing company runtime mappings
|
||
child_ids = [k for k, v in self._active_runtime_children.items() if v == task_id and k != task_id]
|
||
for cid in child_ids:
|
||
await self._flush_progress(cid, project_id=pid)
|
||
await self._flush_progress(task_id, project_id=pid)
|
||
# Clean up per-task state
|
||
self._active_runtime_children.pop(task_id, None)
|
||
for k in child_ids:
|
||
self._active_runtime_children.pop(k, None)
|
||
self._stop_requested_task_ids.discard(k)
|
||
self._task_locks.pop(task_id, None)
|
||
self._task_lock_holders.pop(task_id, None)
|
||
self._stop_requested_task_ids.discard(task_id)
|
||
if session_id:
|
||
self._session_to_task.pop(session_id, None)
|
||
if not terminal_board_broadcast_sent:
|
||
# A cancelled run (or a failure inside the failure handler)
|
||
# never reaches the terminal broadcasts above, leaving the
|
||
# board stuck on "running" until a full refresh. Mirror the
|
||
# persisted status best-effort; never write engine state here.
|
||
try:
|
||
store = engine.store
|
||
if self._store_is_ready(store):
|
||
t = await store.get_task(task_id)
|
||
if t is not None:
|
||
status_val = t.status.value if hasattr(t.status, "value") else str(t.status)
|
||
await self.broadcast({"type": "board_task_status_changed", "payload": {
|
||
"project_id": pid,
|
||
"task_id": task_id,
|
||
"column_id": "done" if status_val in ("done", "cancelled") else "in-progress",
|
||
"status": status_val,
|
||
}})
|
||
except Exception:
|
||
logger.opt(exception=True).debug("failed to mirror terminal board status")
|
||
# Clear agent runtime indicator — include agent_id so the
|
||
# frontend can also clear the swarm agent's reflecting/tool_active state.
|
||
idle_payload: dict[str, Any] = {
|
||
"project_id": pid,
|
||
"task_id": task_id, "status": "idle", "current_tool": None, "iteration": 0,
|
||
}
|
||
resolved_agent = self._resolve_agent_for_idle(task_id, task)
|
||
if resolved_agent:
|
||
idle_payload["agent_id"] = resolved_agent
|
||
await self.broadcast({"type": "agent_runtime_update", "payload": idle_payload})
|
||
|
||
# ── Checkpoint metadata extraction ──────────────────────────────────
|
||
|
||
async def _extract_checkpoint_metadata(
|
||
self,
|
||
task_id: str,
|
||
*,
|
||
session_id: str | None = None,
|
||
engine: Any | None = None,
|
||
) -> dict[str, Any] | None:
|
||
"""Query pending checkpoint from engine store and build structured metadata.
|
||
|
||
Called after engine.process_message() returns. The engine saves the
|
||
checkpoint *before* returning its summary string, so a pending
|
||
checkpoint is guaranteed to exist here if the response represents an
|
||
interactive confirmation prompt.
|
||
|
||
Returns a dict suitable for ChatStore message ``metadata``, or None.
|
||
"""
|
||
runtime_engine = engine or self.engine
|
||
checkpoint = await runtime_engine.get_latest_pending_checkpoint_for_session(session_id)
|
||
if not checkpoint:
|
||
return None
|
||
|
||
if checkpoint.checkpoint_type == "task_user_input":
|
||
return await self._build_task_user_input_meta(checkpoint, engine=runtime_engine)
|
||
if checkpoint.checkpoint_type == "company_work_item_gate":
|
||
return self._build_company_work_item_gate_meta(checkpoint)
|
||
if checkpoint.checkpoint_type == "company_staffing_selection":
|
||
return self._build_staffing_selection_meta(checkpoint)
|
||
if checkpoint.checkpoint_type == "company_recruitment_confirmation":
|
||
return self._build_recruitment_meta(checkpoint, engine=runtime_engine)
|
||
if checkpoint.checkpoint_type == "company_reorg_pending":
|
||
return await self._build_reorg_meta(checkpoint, engine=runtime_engine)
|
||
if checkpoint.checkpoint_type == "company_delivery_feedback":
|
||
return self._build_delivery_feedback_meta(checkpoint)
|
||
return None
|
||
|
||
async def _build_task_user_input_meta(
|
||
self,
|
||
cp: Any,
|
||
*,
|
||
engine: Any | None = None,
|
||
) -> dict[str, Any] | None:
|
||
payload = dict(cp.payload or {})
|
||
pause_request = dict(payload.get("pause_request", {}) or {})
|
||
task_id = str(payload.get("task_id", "")).strip()
|
||
work_item_projection_id = ""
|
||
work_item_projection_title = ""
|
||
runtime_engine = engine or self.engine
|
||
store = runtime_engine.store
|
||
work_item_turn_type = ""
|
||
if store and task_id:
|
||
task = await store.get_task(task_id)
|
||
if task:
|
||
task_metadata = dict(getattr(task, "metadata", {}) or {})
|
||
work_item_projection_id = str(task_metadata.get("work_item_projection_id", "") or "").strip()
|
||
work_item_turn_type = str(task_metadata.get("work_item_turn_type", "") or "").strip()
|
||
work_item_projection_title = str(task.title or "").strip()
|
||
|
||
prompt = str(payload.get("prompt", "") or "").strip()
|
||
summary = str(pause_request.get("reason", "") or prompt).strip()
|
||
questions = [str(item).strip() for item in list(pause_request.get("questions", []) or []) if str(item).strip()]
|
||
input_questions = [
|
||
dict(item)
|
||
for item in list(pause_request.get("input_questions", []) or [])
|
||
if isinstance(item, dict) and str(item.get("question", "") or item.get("header", "") or "").strip()
|
||
]
|
||
if not input_questions:
|
||
input_questions = [
|
||
{
|
||
"id": f"question_{index + 1}",
|
||
"header": "",
|
||
"question": question,
|
||
"options": [],
|
||
"allow_freeform": True,
|
||
"required": True,
|
||
}
|
||
for index, question in enumerate(questions)
|
||
]
|
||
required_fields = [
|
||
str(item).strip()
|
||
for item in list(pause_request.get("required_fields", []) or [])
|
||
if str(item).strip()
|
||
]
|
||
resume_hint = str(pause_request.get("resume_hint", "") or "").strip()
|
||
if not resume_hint and "blocked by autonomy policy" in f"{prompt} {summary}".lower():
|
||
# This park came from a tool-approval timeout. The approval card
|
||
# posted earlier stays pending and clickable indefinitely, so point
|
||
# the user at it instead of leaving typed input as the only path.
|
||
resume_hint = (
|
||
"Tip: the tool-approval card above is still active — choose an option "
|
||
"there (e.g. Approve) to grant the permission and resume this task "
|
||
"automatically. Reply here only to give different instructions."
|
||
)
|
||
|
||
return {
|
||
"checkpoint_type": "task_user_input",
|
||
"checkpoint_id": cp.checkpoint_id,
|
||
"task_id": task_id,
|
||
**work_item_identity_payload(projection_id=work_item_projection_id, turn_type=work_item_turn_type),
|
||
"work_item_projection_title": work_item_projection_title,
|
||
"summary": summary,
|
||
"prompt": prompt,
|
||
"questions": questions,
|
||
"input_questions": input_questions,
|
||
"required_fields": required_fields,
|
||
"context_note": str(pause_request.get("context_note", "") or "").strip(),
|
||
"resume_hint": resume_hint,
|
||
"requesting_role_id": str(
|
||
payload.get("requesting_role_id") or pause_request.get("requesting_role_id") or ""
|
||
).strip(),
|
||
"requesting_task_id": str(
|
||
payload.get("requesting_task_id") or pause_request.get("requesting_task_id") or ""
|
||
).strip(),
|
||
"requesting_work_item_id": str(
|
||
payload.get("requesting_work_item_id") or pause_request.get("requesting_work_item_id") or ""
|
||
).strip(),
|
||
"seat_id": str(payload.get("seat_id") or pause_request.get("seat_id") or "").strip(),
|
||
"runtime_session_id": str(payload.get("runtime_session_id", "") or "").strip(),
|
||
"resume_cursor": payload.get("resume_cursor"),
|
||
"active_subagents": list(payload.get("active_subagents", []) or []),
|
||
"permission_requests": list(payload.get("permission_requests", []) or []),
|
||
"worktree_path": str(payload.get("worktree_path", "") or "").strip(),
|
||
}
|
||
|
||
def _build_company_work_item_gate_meta(self, cp: Any) -> dict[str, Any]:
|
||
payload = dict(cp.payload or {})
|
||
gate = dict(payload.get("gate", {}) or {})
|
||
projection_id = str(payload.get("work_item_projection_id") or "").strip()
|
||
turn_type = str(payload.get("work_item_turn_type") or "").strip()
|
||
projection_title = str(
|
||
payload.get("work_item_projection_title") or projection_id or "Work item gate"
|
||
).strip()
|
||
gate_type = str(gate.get("type", "") or "review").strip()
|
||
prompt_lines = [
|
||
f"{projection_title} requires confirmation.",
|
||
f"Gate type: {gate_type}",
|
||
]
|
||
instructions = str(gate.get("instructions", "") or "").strip()
|
||
if instructions:
|
||
prompt_lines.append(f"Instructions: {instructions}")
|
||
return {
|
||
"checkpoint_type": "company_work_item_gate",
|
||
"checkpoint_id": cp.checkpoint_id,
|
||
**work_item_identity_payload(projection_id=projection_id, turn_type=turn_type),
|
||
"work_item_projection_title": projection_title,
|
||
"company_profile": payload.get("company_profile", ""),
|
||
"summary": instructions or f"Pending {gate_type} confirmation",
|
||
"prompt": "\n".join(prompt_lines),
|
||
"options": [
|
||
{"id": "approve", "label": "Approve"},
|
||
{"id": "deny", "label": "Deny"},
|
||
],
|
||
"default_action": "deny",
|
||
"runtime_session_id": str(payload.get("runtime_session_id", "") or "").strip(),
|
||
"resume_cursor": payload.get("resume_cursor"),
|
||
"active_subagents": list(payload.get("active_subagents", []) or []),
|
||
"permission_requests": list(payload.get("permission_requests", []) or []),
|
||
"worktree_path": str(payload.get("worktree_path", "") or "").strip(),
|
||
}
|
||
|
||
def _build_recruitment_meta(self, cp: Any, *, engine: Any | None = None) -> dict[str, Any]:
|
||
"""Extract recruitment plan data into frontend-friendly metadata."""
|
||
runtime_engine = engine or self.engine
|
||
payload = cp.payload
|
||
rp = payload.get("recruitment_plan", {})
|
||
plan_metadata = dict(rp.get("metadata", {}) or {})
|
||
recruitment_agent = self._normalize_session_preferred_agent(
|
||
payload.get("recruitment_agent") or plan_metadata.get("recruitment_agent") or "native",
|
||
default="native",
|
||
)
|
||
proposals_raw = rp.get("proposals", [])
|
||
proposals = []
|
||
payload_role_agents: dict[str, str] = {}
|
||
raw_payload_role_agents = payload.get("recruitment_role_agents")
|
||
if isinstance(raw_payload_role_agents, dict):
|
||
payload_role_agents = {
|
||
str(raw_role_id or "").strip(): self._normalize_session_preferred_agent(raw_agent, default="codex")
|
||
for raw_role_id, raw_agent in raw_payload_role_agents.items()
|
||
if str(raw_role_id or "").strip()
|
||
}
|
||
recruitment_role_agents: dict[str, str] = {}
|
||
employee_payloads: list[dict[str, Any]] = []
|
||
template_payloads: list[dict[str, Any]] = []
|
||
org_engine = getattr(runtime_engine, "org_engine", None)
|
||
talent_market = getattr(runtime_engine, "talent_market", None)
|
||
is_placeholder = getattr(runtime_engine, "_is_placeholder_staffing_employee", lambda _employee: False)
|
||
employee_payload = getattr(runtime_engine, "_staffing_employee_payload", None)
|
||
template_payload = getattr(runtime_engine, "_staffing_template_payload", None)
|
||
if org_engine and callable(employee_payload):
|
||
try:
|
||
employee_payloads = [
|
||
employee_payload(employee)
|
||
for employee in org_engine.list_employees()
|
||
if not is_placeholder(employee)
|
||
]
|
||
except Exception:
|
||
employee_payloads = []
|
||
if talent_market and callable(template_payload):
|
||
try:
|
||
template_payloads = [
|
||
template_payload(template)
|
||
for template in talent_market.list_available_templates()
|
||
if str(getattr(template, "id", "") or "").strip()
|
||
]
|
||
except Exception:
|
||
template_payloads = []
|
||
employees_by_id = {
|
||
str(item.get("employee_id", "") or "").strip(): item
|
||
for item in employee_payloads
|
||
if str(item.get("employee_id", "") or "").strip()
|
||
}
|
||
templates_by_id = {
|
||
str(item.get("template_id", "") or "").strip(): item
|
||
for item in template_payloads
|
||
if str(item.get("template_id", "") or "").strip()
|
||
}
|
||
employees_by_role: dict[str, list[dict[str, Any]]] = {}
|
||
for item in employee_payloads:
|
||
role_id = str(item.get("role_id", "") or "").strip()
|
||
if role_id:
|
||
employees_by_role.setdefault(role_id, []).append(item)
|
||
staffing_roles: list[dict[str, Any]] = []
|
||
staffing_selections: dict[str, dict[str, str]] = {}
|
||
recruitment_rationales: list[dict[str, Any]] = []
|
||
for p in proposals_raw:
|
||
role_id = str(p.get("role_id", "") or "").strip()
|
||
proposal_metadata = dict(p.get("metadata", {}) or {})
|
||
entry: dict[str, Any] = {
|
||
"role_id": role_id,
|
||
"status": p.get("status", ""),
|
||
"rationale": p.get("rationale", ""),
|
||
"role_labels": p.get("role_labels", []),
|
||
}
|
||
cand = p.get("candidate")
|
||
if cand:
|
||
entry["candidate"] = {
|
||
"template_id": cand.get("template_id", ""),
|
||
"template_name": cand.get("template_name", ""),
|
||
"category": cand.get("category", ""),
|
||
"domains": cand.get("domains", []),
|
||
"proposed_name": cand.get("proposed_employee_name", ""),
|
||
"rationale": cand.get("rationale", ""),
|
||
}
|
||
template_id = str(cand.get("template_id", "") or "").strip()
|
||
if template_id and template_id not in templates_by_id:
|
||
templates_by_id[template_id] = {
|
||
"kind": "template",
|
||
"template_id": template_id,
|
||
"template_name": cand.get("template_name", "") or template_id,
|
||
"category": cand.get("category", ""),
|
||
"domains": cand.get("domains", []),
|
||
"tags": [],
|
||
"description": "",
|
||
"preferred_external_agent": cand.get("preferred_external_agent"),
|
||
"source_path": cand.get("source_path", ""),
|
||
}
|
||
emp = p.get("existing_employee")
|
||
if emp:
|
||
entry["existing_employee"] = {
|
||
"employee_id": emp.get("employee_id", ""),
|
||
"employee_name": emp.get("employee_name", ""),
|
||
"role_id": emp.get("role_id", ""),
|
||
"domains": emp.get("domains", []),
|
||
"experience_score": emp.get("experience_score", 0),
|
||
"rationale": emp.get("rationale", ""),
|
||
}
|
||
employee_id = str(emp.get("employee_id", "") or "").strip()
|
||
if employee_id and employee_id not in employees_by_id:
|
||
employee_payload_item = {
|
||
"kind": "employee",
|
||
"employee_id": employee_id,
|
||
"employee_name": emp.get("employee_name", "") or employee_id,
|
||
"role_id": emp.get("role_id", "") or role_id,
|
||
"category": emp.get("category", ""),
|
||
"domains": emp.get("domains", []),
|
||
"tags": [],
|
||
"description": "",
|
||
"preferred_external_agent": None,
|
||
"experience_score": emp.get("experience_score", 0),
|
||
}
|
||
employees_by_id[employee_id] = employee_payload_item
|
||
if role_id:
|
||
employees_by_role.setdefault(role_id, []).append(employee_payload_item)
|
||
default_agent = self._normalize_session_preferred_agent("codex", default="codex")
|
||
entry["default_agent"] = default_agent
|
||
entry["selected_agent"] = self._normalize_session_preferred_agent(
|
||
payload_role_agents.get(role_id) or proposal_metadata.get("selected_execution_agent"),
|
||
default=default_agent,
|
||
)
|
||
if role_id:
|
||
recruitment_role_agents[role_id] = entry["selected_agent"]
|
||
proposals.append(entry)
|
||
role_label = str((p.get("role_labels", []) or [role_id])[0] or role_id)
|
||
default_selection: dict[str, str] = {"kind": "fallback", "id": ""}
|
||
selection_label = "Fallback role-only"
|
||
cand = p.get("candidate")
|
||
emp = p.get("existing_employee")
|
||
if emp and str(emp.get("employee_id", "") or "").strip():
|
||
employee_id = str(emp.get("employee_id", "") or "").strip()
|
||
default_selection = {"kind": "employee", "id": employee_id, "employee_id": employee_id}
|
||
selection_label = str(emp.get("employee_name", "") or employee_id)
|
||
elif cand and str(cand.get("template_id", "") or "").strip():
|
||
template_id = str(cand.get("template_id", "") or "").strip()
|
||
default_selection = {"kind": "template", "id": template_id, "template_id": template_id}
|
||
selection_label = str(cand.get("proposed_employee_name") or cand.get("template_name") or template_id)
|
||
if role_id:
|
||
staffing_selections[role_id] = dict(default_selection)
|
||
same_role_ids = {
|
||
str(item.get("employee_id", "") or "").strip()
|
||
for item in employees_by_role.get(role_id, [])
|
||
if str(item.get("employee_id", "") or "").strip()
|
||
}
|
||
same_role_ids.update(
|
||
str(item or "").strip()
|
||
for item in list(p.get("existing_employee_ids", []) or [])
|
||
if str(item or "").strip()
|
||
)
|
||
staffing_roles.append(
|
||
{
|
||
"role_id": role_id,
|
||
"role_label": role_label,
|
||
"role_responsibility": "",
|
||
"default_selection": default_selection,
|
||
"same_role_employee_ids": sorted(same_role_ids),
|
||
"fallback_available": True,
|
||
"default_agent": default_agent,
|
||
"selected_agent": entry["selected_agent"],
|
||
"default_source": "recruitment",
|
||
}
|
||
)
|
||
reason_parts = [
|
||
str((cand or {}).get("rationale", "") or "").strip(),
|
||
str((emp or {}).get("rationale", "") or "").strip(),
|
||
str(p.get("rationale", "") or "").strip(),
|
||
]
|
||
rationale = next((item for item in reason_parts if item), "")
|
||
recruitment_rationales.append(
|
||
{
|
||
"role_id": role_id,
|
||
"role_label": role_label,
|
||
"status": p.get("status", ""),
|
||
"selection_label": selection_label,
|
||
"rationale": rationale,
|
||
}
|
||
)
|
||
employee_payloads = list(employees_by_id.values())
|
||
template_payloads = list(templates_by_id.values())
|
||
return {
|
||
"checkpoint_type": "company_recruitment_confirmation",
|
||
"checkpoint_id": cp.checkpoint_id,
|
||
"company_profile": rp.get("company_profile", "corporate"),
|
||
"previous_checkpoint_id": payload.get("previous_checkpoint_id", ""),
|
||
"recruitment_revision": payload.get("recruitment_revision") or dict(rp.get("metadata", {}) or {}).get("recruitment_revision"),
|
||
"recruiter_feedback": list(rp.get("recruiter_feedback", []) or []),
|
||
"recruitment_agent": recruitment_agent,
|
||
"recruitment_role_agents": recruitment_role_agents,
|
||
"proposals": proposals,
|
||
"summary": rp.get("summary", ""),
|
||
"recruitment_rationales": recruitment_rationales,
|
||
"staffing_roles": staffing_roles,
|
||
"staffing_pool": {
|
||
"employees": employee_payloads,
|
||
"templates": template_payloads,
|
||
},
|
||
"staffing_selections": staffing_selections,
|
||
}
|
||
|
||
def _build_staffing_selection_meta(self, cp: Any) -> dict[str, Any]:
|
||
"""Extract manual staffing data into frontend-friendly metadata."""
|
||
payload = dict(cp.payload or {})
|
||
raw_role_agents = payload.get("recruitment_role_agents")
|
||
payload_role_agents = raw_role_agents if isinstance(raw_role_agents, dict) else {}
|
||
recruitment_agent = self._normalize_session_preferred_agent(
|
||
payload.get("recruitment_agent") or "native",
|
||
default="native",
|
||
)
|
||
staffing_roles: list[dict[str, Any]] = []
|
||
recruitment_role_agents: dict[str, str] = {}
|
||
for raw_role in list(payload.get("staffing_roles", []) or []):
|
||
if not isinstance(raw_role, dict):
|
||
continue
|
||
role = dict(raw_role)
|
||
role_id = str(role.get("role_id", "") or "").strip()
|
||
selected_agent = self._normalize_session_preferred_agent(
|
||
payload_role_agents.get(role_id) or role.get("selected_agent") or role.get("default_agent") or "codex",
|
||
default="codex",
|
||
)
|
||
if role_id:
|
||
role["selected_agent"] = selected_agent
|
||
recruitment_role_agents[role_id] = selected_agent
|
||
staffing_roles.append(role)
|
||
return {
|
||
"checkpoint_type": "company_staffing_selection",
|
||
"checkpoint_id": cp.checkpoint_id,
|
||
"company_profile": payload.get("company_profile", "corporate"),
|
||
"summary": payload.get("summary") or "Select staff manually, or run automatic recruitment.",
|
||
"staffing_strategy": payload.get("staffing_strategy", ""),
|
||
"recommended_action": payload.get("recommended_action", ""),
|
||
"staffing_defaults": dict(payload.get("staffing_defaults", {}) or {}),
|
||
"staffing_roles": staffing_roles,
|
||
"recruitment_agent": recruitment_agent,
|
||
"recruitment_role_agents": recruitment_role_agents,
|
||
"staffing_pool": dict(payload.get("staffing_pool", {}) or {}),
|
||
}
|
||
|
||
async def _build_reorg_meta(
|
||
self,
|
||
cp: Any,
|
||
*,
|
||
engine: Any | None = None,
|
||
) -> dict[str, Any] | None:
|
||
"""Extract reorg proposal data into frontend-friendly metadata."""
|
||
runtime_engine = engine or self.engine
|
||
store = runtime_engine.store
|
||
if not store:
|
||
return None
|
||
proposal_id = cp.payload.get("proposal_id", "")
|
||
if not proposal_id:
|
||
return None
|
||
proposal = await store.get_reorg_proposal(proposal_id)
|
||
if not proposal:
|
||
return None
|
||
changeset = proposal.changeset
|
||
role_changes = []
|
||
if hasattr(changeset, "role_changes"):
|
||
for rc in changeset.role_changes:
|
||
role_changes.append({
|
||
"action": rc.action,
|
||
"role_id": rc.role_id,
|
||
"replacement_role_id": rc.replacement_role_id,
|
||
"reason": rc.reason,
|
||
})
|
||
return {
|
||
"checkpoint_type": "company_reorg_pending",
|
||
"checkpoint_id": cp.checkpoint_id,
|
||
"proposal_id": proposal.proposal_id,
|
||
"scope": proposal.scope.value,
|
||
"risk_level": proposal.risk_level.value,
|
||
"status": proposal.status.value,
|
||
"title": proposal.title,
|
||
"summary": proposal.summary,
|
||
"rationale": proposal.rationale,
|
||
"role_changes": role_changes,
|
||
"impact_summary": proposal.impact_summary,
|
||
"user_confirmation_required": proposal.user_confirmation_required,
|
||
}
|
||
|
||
def _build_delivery_feedback_meta(self, cp: Any) -> dict[str, Any]:
|
||
payload = dict(cp.payload or {})
|
||
prompt = str(payload.get("prompt", "") or "").strip()
|
||
projection_title = str(payload.get("work_item_projection_title", "") or "").strip()
|
||
feedback_scope = str(payload.get("feedback_scope", "work_item") or "work_item").strip()
|
||
delivery_package = payload.get("delivery_package")
|
||
if not isinstance(delivery_package, dict):
|
||
delivery_package = {}
|
||
result_content = str(payload.get("result_content", "") or "").strip()
|
||
summary = str(
|
||
delivery_package.get("executive_summary")
|
||
or delivery_package.get("summary")
|
||
or payload.get("work_item_summary")
|
||
or payload.get("work_item_summary_for_downstream")
|
||
or result_content
|
||
or projection_title
|
||
or ("Final delivery review" if feedback_scope == "final" else "Work item review")
|
||
).strip()
|
||
waiting_task_id = str(
|
||
payload.get("waiting_task_id")
|
||
or payload.get("task_id")
|
||
or getattr(cp, "task_id", "")
|
||
or ""
|
||
).strip()
|
||
return {
|
||
"checkpoint_type": "company_delivery_feedback",
|
||
"checkpoint_id": cp.checkpoint_id,
|
||
"waiting_task_id": waiting_task_id,
|
||
"task_id": waiting_task_id,
|
||
**work_item_identity_payload(
|
||
projection_id=str(payload.get("work_item_projection_id", "") or "").strip(),
|
||
turn_type=str(payload.get("work_item_turn_type", "") or "").strip(),
|
||
),
|
||
"work_item_projection_title": projection_title,
|
||
"company_profile": payload.get("company_profile", ""),
|
||
"feedback_scope": feedback_scope,
|
||
"summary": summary,
|
||
"prompt": prompt,
|
||
"options": [
|
||
{"id": "approve", "label": "Fully Agree / 完全同意"},
|
||
{"id": "ignore", "label": "Ignore / 忽略"},
|
||
{"id": "feedback", "label": "Feedback / 反馈"},
|
||
],
|
||
"delivery_package": delivery_package,
|
||
"result_content": result_content,
|
||
"delivery_revision": payload.get("delivery_revision", ""),
|
||
"owner_directive_revision": payload.get("owner_directive_revision", ""),
|
||
"latest_user_directive": str(payload.get("latest_user_directive", "") or "").strip(),
|
||
"waiting_work_item_id": str(payload.get("waiting_work_item_id", "") or "").strip(),
|
||
"runtime_session_id": str(payload.get("runtime_session_id", "") or "").strip(),
|
||
"resume_cursor": payload.get("resume_cursor"),
|
||
"active_subagents": list(payload.get("active_subagents", []) or []),
|
||
"permission_requests": list(payload.get("permission_requests", []) or []),
|
||
"worktree_path": str(payload.get("worktree_path", "") or "").strip(),
|
||
}
|
||
|
||
# ── Secretary ──────────────────────────────────────────────────────
|
||
|
||
async def _handle_secretary_send(self, ws: Any, data: dict) -> None:
|
||
"""Handle user message in the secretary channel (no kanban task linkage)."""
|
||
content = data.get("content", "")
|
||
if not content:
|
||
return
|
||
|
||
run_engine, pid = await self._engine_for_request(data)
|
||
|
||
# Ensure secretary channel exists
|
||
await self.chat_store.ensure_secretary_channel(project_id=pid)
|
||
secretary_channel = f"secretary:{pid}"
|
||
|
||
# Lazily resolve or create a persistent secretary session
|
||
secretary_session_id = self._secretary_session_ids.get(pid)
|
||
if secretary_session_id is None:
|
||
if getattr(run_engine, "secretary", None):
|
||
sessions = await run_engine.secretary.list_sessions(
|
||
pid, limit=1,
|
||
)
|
||
if sessions:
|
||
secretary_session_id = sessions[0].session_id
|
||
if secretary_session_id is None:
|
||
import uuid as _uuid
|
||
secretary_session_id = str(_uuid.uuid4())
|
||
self._secretary_session_ids[pid] = secretary_session_id
|
||
if self._active_engine_project_id() == pid:
|
||
self._secretary_session_id = secretary_session_id
|
||
|
||
# Insert user message to chat_store for UI rendering
|
||
msg = await self.chat_store.insert_message(
|
||
channel_id=secretary_channel,
|
||
sender="user",
|
||
sender_name="You",
|
||
content=content,
|
||
project_id=pid,
|
||
)
|
||
await self.broadcast({"type": "session_message", "payload": msg})
|
||
|
||
# Process via SecretaryService (shared with CLI `opc secretary`)
|
||
self._track(self._process_secretary_message(content, engine=run_engine, project_id=pid, session_id=secretary_session_id))
|
||
|
||
async def _process_secretary_message(
|
||
self,
|
||
content: str,
|
||
*,
|
||
engine: Any,
|
||
project_id: str,
|
||
session_id: str,
|
||
) -> None:
|
||
"""Call engine.process_secretary_message and write reply to secretary channel."""
|
||
pid = project_id
|
||
secretary_channel = f"secretary:{pid}"
|
||
try:
|
||
result = await engine.process_secretary_message(
|
||
content,
|
||
project_id=pid,
|
||
session_id=session_id,
|
||
)
|
||
reply_text = result.get("response", "") if isinstance(result, dict) else str(result)
|
||
|
||
# Show applied updates if any
|
||
applied = result.get("applied_updates", []) if isinstance(result, dict) else []
|
||
if applied:
|
||
reply_text += "\n\n**Applied updates:**\n" + "\n".join(f"- {u}" for u in applied)
|
||
|
||
msg = await self.chat_store.insert_message(
|
||
channel_id=secretary_channel,
|
||
sender="assistant",
|
||
sender_name="Secretary",
|
||
content=(reply_text or "No response."),
|
||
project_id=pid,
|
||
)
|
||
await self.broadcast({"type": "session_message", "payload": msg})
|
||
except Exception as e:
|
||
logger.opt(exception=True).error(f"Secretary processing error: {e}")
|
||
msg = await self.chat_store.insert_message(
|
||
channel_id=secretary_channel,
|
||
sender="system",
|
||
sender_name="Secretary",
|
||
content=f"Error: {e}",
|
||
project_id=pid,
|
||
)
|
||
await self.broadcast({"type": "session_message", "payload": msg})
|
||
|
||
# ── Project Management ──────────────────────────────────────────────
|
||
|
||
async def _handle_list_projects(self, ws: Any, data: dict) -> None:
|
||
"""List available projects by scanning the projects directory."""
|
||
result = await self.services.project.list(active_project_id=self._client_active_project_id(ws))
|
||
await self._send_ack(ws, ok=True, **result.payload)
|
||
|
||
async def _handle_create_project(self, ws: Any, data: dict) -> None:
|
||
"""Create a new project directory."""
|
||
try:
|
||
result = await self.services.project.create(
|
||
data.get("project_id", ""),
|
||
active_project_id=self._client_active_project_id(ws),
|
||
)
|
||
await self._send_ack(ws, ok=True, **result.payload)
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="create_project")
|
||
|
||
async def _handle_delete_project(self, ws: Any, data: dict) -> None:
|
||
"""Delete a project and all its data. Switches to 'default' if active."""
|
||
try:
|
||
result = await self.services.project.delete(data.get("project_id", ""))
|
||
self.engine = self.services_context.engine
|
||
if result.payload.get("active_project_id") == "default":
|
||
self._secretary_session_id = None
|
||
await self._publish_service_result(result)
|
||
await self._send_ack(ws, ok=True, project_id=result.payload.get("project_id"))
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="delete_project")
|
||
|
||
async def _handle_switch_project(self, ws: Any, data: dict) -> None:
|
||
"""Switch the active project view without rebinding in-flight runtimes."""
|
||
new_id = data.get("project_id", "").strip()
|
||
switch_seq = str(data.get("switch_seq") or data.get("switchSeq") or "").strip()
|
||
try:
|
||
await self.services.project.switch(new_id, switch_seq=switch_seq, include_snapshot=False)
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="switch_project")
|
||
return
|
||
except Exception as exc:
|
||
logger.opt(exception=True).error(
|
||
f"Failed to prepare project switch for {new_id}: {type(exc).__name__}: {exc!r}",
|
||
)
|
||
await self._send_ack(
|
||
ws,
|
||
ok=False,
|
||
project_id=new_id,
|
||
switch_seq=switch_seq,
|
||
error=f"Failed to switch project `{new_id}`: {exc}",
|
||
)
|
||
return
|
||
|
||
project_engine = self.services_context.engine
|
||
self._client_project_ids[ws] = new_id
|
||
self._client_switch_seq[ws] = switch_seq
|
||
self._secretary_session_id = None
|
||
await self._send_envelope_to_client(
|
||
ws,
|
||
{"type": "project_switched", "payload": {"project_id": new_id, "switch_seq": switch_seq}},
|
||
)
|
||
self._track_client_project_index(
|
||
ws,
|
||
self._send_project_index_for_client(
|
||
ws,
|
||
project_engine,
|
||
new_id,
|
||
switch_seq=switch_seq,
|
||
include_snapshot=True,
|
||
),
|
||
)
|
||
await self._send_ack(ws, ok=True, project_id=new_id, switch_seq=switch_seq)
|
||
|
||
# ── Org Info handler ─────────────────────────────────────────────
|
||
|
||
async def _handle_org_info(self, ws: Any, data: dict) -> None:
|
||
"""Return org structure, employees, runtime topology, and channel statuses."""
|
||
result = await self._ensure_office_services().org.info()
|
||
await ws.send_json({"type": "org_info", "payload": result.payload})
|
||
|
||
# ── Phase 4: Talent Market ──────────────────────────────────────────
|
||
|
||
async def _handle_talent_import(self, ws: Any, data: dict) -> None:
|
||
"""Import talent templates from a local repo directory."""
|
||
try:
|
||
result = await self._ensure_office_services().talent.import_repo(data.get("repo_path", ""))
|
||
self._local_talent_cache = None
|
||
await self._send_service_ack(ws, result)
|
||
await self._send_talent_list(ws)
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="talent_import")
|
||
except Exception as exc:
|
||
logger.warning(f"Failed to import talent templates: {exc}")
|
||
await self._send_ack(ws, ok=False, error=str(exc))
|
||
|
||
async def _handle_talent_scan_local(self, ws: Any, data: dict) -> None:
|
||
"""Scan local talent directory and return unregistered templates for selection."""
|
||
try:
|
||
result = await self._ensure_office_services().talent.scan()
|
||
await ws.send_json({"type": "talent_scan_local", "payload": result.payload})
|
||
except Exception:
|
||
await ws.send_json({"type": "talent_scan_local", "payload": {"templates": []}})
|
||
|
||
async def _handle_talent_import_selected(self, ws: Any, data: dict) -> None:
|
||
"""Import user-selected templates from the local talent directory."""
|
||
try:
|
||
result = await self._ensure_office_services().talent.import_selected(list(data.get("template_ids", []) or []))
|
||
self._local_talent_cache = None
|
||
await self._send_service_ack(ws, result)
|
||
await self._send_talent_list(ws)
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="talent_import_selected")
|
||
except Exception as exc:
|
||
logger.warning(f"Failed to import selected templates: {exc}")
|
||
await self._send_ack(ws, ok=False, error=str(exc))
|
||
|
||
async def _send_talent_list(self, ws: Any) -> None:
|
||
result = await self._ensure_office_services().talent.list()
|
||
await ws.send_json({"type": "talent_list", "payload": result.payload})
|
||
|
||
async def _handle_talent_list(self, ws: Any, data: dict) -> None:
|
||
"""List all talent templates."""
|
||
try:
|
||
await self._send_talent_list(ws)
|
||
except Exception:
|
||
logger.debug("Failed to list talent templates")
|
||
await ws.send_json({"type": "talent_list", "payload": {"templates": []}})
|
||
|
||
async def _handle_talent_hire(self, ws: Any, data: dict) -> None:
|
||
"""Hire a talent template into an existing role."""
|
||
try:
|
||
result = await self._ensure_office_services().talent.hire(
|
||
template_id=data.get("template_id", ""),
|
||
role_id=data.get("role_id", ""),
|
||
employee_name=data.get("employee_name"),
|
||
employee_id=data.get("employee_id"),
|
||
organization_id=data.get("org_id") or data.get("organization_id"),
|
||
)
|
||
self._local_talent_cache = None
|
||
await self._publish_service_result(result)
|
||
await self._send_service_ack(ws, result)
|
||
await self._broadcast_org_info()
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="talent_hire")
|
||
except Exception as exc:
|
||
logger.warning(f"Unexpected error hiring template: {exc}")
|
||
await self._send_ack(ws, ok=False, error=str(exc))
|
||
|
||
async def _handle_import_employee_as_agent(self, ws: Any, data: dict) -> None:
|
||
"""Import an existing org employee as a visual office agent."""
|
||
try:
|
||
result = await self._ensure_office_services().talent.import_employee_as_agent(
|
||
employee_id=data.get("employee_id", ""),
|
||
office_id=data.get("office_id", "office-0"),
|
||
)
|
||
await self._publish_service_result(result)
|
||
await self._send_ack(ws, ok=True, action="employee_imported", imported_employee_id=result.payload.get("imported_employee_id"))
|
||
await self._handle_org_info(ws, {})
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="import_employee_as_agent")
|
||
except Exception as exc:
|
||
logger.warning(f"Failed to import employee: {exc}")
|
||
await self._send_ack(ws, ok=False, error=str(exc))
|
||
|
||
# ── Phase 4: Employee Detail ────────────────────────────────────────
|
||
|
||
async def _handle_employee_detail(self, ws: Any, data: dict) -> None:
|
||
"""Return detailed evolution profile for an employee."""
|
||
employee_id = data.get("employee_id", "")
|
||
if not employee_id:
|
||
await ws.send_json({"type": "employee_detail", "payload": {"employee_id": "", "error": "employee_id required"}})
|
||
return
|
||
try:
|
||
result = await self._ensure_office_services().talent.employee_detail(employee_id)
|
||
payload = dict(result.payload.get("employee", {}) or {})
|
||
await ws.send_json({"type": "employee_detail", "payload": payload})
|
||
except ServiceError as exc:
|
||
await ws.send_json({"type": "employee_detail", "payload": {"employee_id": employee_id, "error": exc.message}})
|
||
|
||
# ── Phase 4: Reorg Management ───────────────────────────────────────
|
||
|
||
async def _handle_reorg_list(self, ws: Any, data: dict) -> None:
|
||
"""List recent reorg proposals for the current project."""
|
||
store = self.engine.store
|
||
if not store:
|
||
await ws.send_json({"type": "reorg_list", "payload": {"proposals": []}})
|
||
return
|
||
project_id = self.engine.project_id or "default"
|
||
try:
|
||
proposals = await store.list_reorg_proposals(project_id, limit=20)
|
||
result = []
|
||
for p in proposals:
|
||
changeset_summary: dict[str, Any] = {
|
||
"role_changes": [],
|
||
"task_adjustments_count": 0,
|
||
}
|
||
if p.changeset:
|
||
changeset_summary = {
|
||
"role_changes": [
|
||
{"action": rc.action, "role_id": rc.role_id, "reason": rc.reason}
|
||
for rc in (p.changeset.role_changes or [])
|
||
],
|
||
"task_adjustments_count": len(p.changeset.task_adjustments or []),
|
||
}
|
||
result.append({
|
||
"proposal_id": p.proposal_id,
|
||
"title": p.title,
|
||
"summary": p.summary,
|
||
"rationale": p.rationale,
|
||
"scope": p.scope.value if hasattr(p.scope, "value") else str(p.scope),
|
||
"risk_level": p.risk_level.value if hasattr(p.risk_level, "value") else str(p.risk_level),
|
||
"status": p.status.value if hasattr(p.status, "value") else str(p.status),
|
||
"initiated_by": p.initiated_by,
|
||
"changeset": changeset_summary,
|
||
"impact_summary": p.impact_summary or {},
|
||
"created_at": p.created_at.timestamp() if hasattr(p.created_at, "timestamp") else 0,
|
||
"updated_at": p.updated_at.timestamp() if hasattr(p.updated_at, "timestamp") else 0,
|
||
})
|
||
await ws.send_json({"type": "reorg_list", "payload": {"proposals": result}})
|
||
except Exception:
|
||
logger.debug("Failed to list reorg proposals")
|
||
await ws.send_json({"type": "reorg_list", "payload": {"proposals": []}})
|
||
|
||
async def _handle_reorg_decide(self, ws: Any, data: dict) -> None:
|
||
"""Approve or deny a reorg proposal from the UI."""
|
||
proposal_id = data.get("proposal_id", "")
|
||
approved = data.get("approved", False)
|
||
notes = data.get("notes", "")
|
||
rm = getattr(self.engine, "reorg_manager", None)
|
||
if not rm or not proposal_id:
|
||
await ws.send_json({"type": "ack", "payload": {"ok": False, "error": "reorg_manager not available or missing proposal_id"}})
|
||
return
|
||
try:
|
||
await rm.set_reorg_approval(proposal_id, approved=approved, notes=notes)
|
||
if approved:
|
||
result = await rm.apply_reorg(proposal_id)
|
||
await ws.send_json({"type": "ack", "payload": {"ok": True, "action": "reorg_applied", "result": result}})
|
||
# Refresh org info for all clients
|
||
await self._handle_org_info(ws, {})
|
||
else:
|
||
await ws.send_json({"type": "ack", "payload": {"ok": True, "action": "reorg_denied"}})
|
||
except Exception as exc:
|
||
logger.warning(f"Failed to decide reorg {proposal_id}: {exc}")
|
||
await ws.send_json({"type": "ack", "payload": {"ok": False, "error": str(exc)}})
|
||
|
||
# ------------------------------------------------------------------
|
||
# Org config import / export
|
||
# ------------------------------------------------------------------
|
||
|
||
async def _ack_saved_err(self, ws: Any, msg_type: str, name: str, error: str) -> None:
|
||
"""Shared error-ack helper for the saved-org handlers."""
|
||
await ws.send_json({"type": msg_type, "payload": {
|
||
"ok": False, "name": name, "error": error,
|
||
}})
|
||
|
||
async def _get_active_saved_org_name(self) -> str:
|
||
config_dir = Path(getattr(self.engine, "opc_home", None) or Path.cwd() / ".opc") / "config"
|
||
try:
|
||
active_id = read_org_index(config_dir)
|
||
if active_id and org_config_path(config_dir, active_id).exists():
|
||
return active_id
|
||
except Exception:
|
||
pass
|
||
if not hasattr(self, "agent_store"):
|
||
return ""
|
||
try:
|
||
name = await self.agent_store.get_server_state(_ACTIVE_SAVED_ORG_STATE_KEY, "")
|
||
except Exception:
|
||
return ""
|
||
try:
|
||
path = _saved_org_path(name, strict=False)
|
||
except ValueError:
|
||
return ""
|
||
org_id = path.stem.removeprefix("org_").removesuffix("_config")
|
||
if path.exists():
|
||
try:
|
||
write_org_index(config_dir, validate_saved_org_id(org_id))
|
||
except Exception:
|
||
pass
|
||
return org_id
|
||
return ""
|
||
|
||
async def _set_active_saved_org_name(self, name: str | None) -> None:
|
||
value = str(name or "").strip()
|
||
config_dir = Path(getattr(self.engine, "opc_home", None) or Path.cwd() / ".opc") / "config"
|
||
if value:
|
||
org_id = validate_saved_org_id(value)
|
||
write_org_index(config_dir, org_id)
|
||
if not hasattr(self, "agent_store"):
|
||
return
|
||
try:
|
||
await self.agent_store.set_server_state(_ACTIVE_SAVED_ORG_STATE_KEY, value)
|
||
except Exception:
|
||
logger.debug("Failed to persist active saved org name")
|
||
|
||
def _write_active_org_config(self, config: Any) -> None:
|
||
config_dir = Path(getattr(self.engine, "opc_home", None) or Path.cwd() / ".opc") / "config"
|
||
org = getattr(config, "org", None)
|
||
raw_org_id = str(getattr(org, "organization_id", "") or "").strip()
|
||
raw_name = str(
|
||
getattr(org, "organization_name", "")
|
||
or getattr(org, "company_name", "")
|
||
or raw_org_id
|
||
or "org"
|
||
).strip()
|
||
try:
|
||
organization_id = validate_saved_org_id(raw_org_id)
|
||
except ValueError:
|
||
active_id = read_org_index(config_dir)
|
||
organization_id = active_id or allocate_org_config_id(config_dir, raw_name)
|
||
if org is not None:
|
||
org.organization_id = organization_id
|
||
org.organization_name = raw_name
|
||
org.organization_config_file = org_config_relative_path(organization_id)
|
||
org.company_profile = "custom"
|
||
try:
|
||
from opc.core.employee_registry import write_employee_registry
|
||
|
||
org.employees, _ = write_employee_registry(
|
||
Path(config_dir).parent,
|
||
organization_id,
|
||
list(getattr(org, "employees", []) or []),
|
||
)
|
||
except Exception:
|
||
logger.opt(exception=True).debug("Failed to write employee registry for active org")
|
||
payload = build_org_config_payload_from_config(
|
||
config,
|
||
organization_id=organization_id,
|
||
organization_name=raw_name,
|
||
)
|
||
write_org_config_payload(config_dir, organization_id, payload)
|
||
write_org_index(config_dir, organization_id)
|
||
|
||
def _persist_runtime_config(self) -> None:
|
||
if str(getattr(self, "_exec_mode", "") or "").strip().lower() in {"org", "custom"}:
|
||
self._write_active_org_config(self.engine.config)
|
||
return
|
||
self.engine.config.save()
|
||
|
||
async def _restore_active_saved_org_if_needed(self) -> None:
|
||
"""Recover org mode from the last loaded saved architecture.
|
||
|
||
Org mode owns its active index, so startup restore must not consult or
|
||
mutate company_index.yaml.
|
||
"""
|
||
if str(getattr(self, "_exec_mode", "") or "").strip().lower() not in {"org", "custom"}:
|
||
return
|
||
|
||
name = await self._get_active_saved_org_name()
|
||
if not name:
|
||
return
|
||
try:
|
||
config_dir = Path(getattr(self.engine, "opc_home", None) or Path.cwd() / ".opc") / "config"
|
||
payload, path = load_org_config_payload(config_dir, name)
|
||
validated_config = apply_org_config_payload_to_config(
|
||
self.engine.config,
|
||
payload,
|
||
source_path=path,
|
||
)
|
||
except Exception as exc:
|
||
logger.warning(f"Failed to restore saved org '{name}' during startup: {exc}")
|
||
return
|
||
|
||
async with self._config_lock:
|
||
self._rebind_engine_config(validated_config)
|
||
if self.engine.org_engine:
|
||
self.engine.org_engine.reload_from_config()
|
||
logger.info(f"Restored org architecture from saved org '{name}'")
|
||
try:
|
||
await self._broadcast_org_info()
|
||
except Exception:
|
||
pass
|
||
|
||
async def _handle_org_saved_list(self, ws: Any, data: dict) -> None:
|
||
"""Enumerate saved organization configs."""
|
||
result = await self._ensure_office_services().org.saved_list()
|
||
await ws.send_json({"type": "org_saved_list", "payload": result.payload})
|
||
|
||
async def _handle_org_saved_save_as(self, ws: Any, data: dict) -> None:
|
||
"""Snapshot current engine config.org as a saved organization."""
|
||
try:
|
||
result = await self._ensure_office_services().org.saved_save_as(
|
||
str(data.get("organization_name") or data.get("name") or "").strip(),
|
||
overwrite=bool(data.get("overwrite", False)),
|
||
)
|
||
await ws.send_json({"type": "org_saved_save_as", "payload": result.payload})
|
||
except ServiceError as exc:
|
||
await self._ack_saved_err(ws, "org_saved_save_as", str(data.get("name") or ""), exc.message)
|
||
|
||
async def _handle_org_saved_create(self, ws: Any, data: dict) -> None:
|
||
"""Create, save, and activate a new custom organization."""
|
||
name = str(data.get("organization_name") or data.get("name") or "").strip()
|
||
try:
|
||
members = data.get("members")
|
||
result = await self._ensure_office_services().org.saved_create(
|
||
organization_name=name,
|
||
members=members if isinstance(members, list) else [],
|
||
)
|
||
org_id = str(result.payload.get("organization_id") or result.payload.get("name") or "").strip()
|
||
ok = await self._apply_mode_switch(
|
||
"org",
|
||
"custom",
|
||
getattr(self, "_task_preferred_agent", "native"),
|
||
org_id=org_id,
|
||
)
|
||
if not ok:
|
||
await ws.send_json({"type": "org_saved_create", "payload": {
|
||
"ok": False,
|
||
"name": org_id or name,
|
||
"error": getattr(self, "_last_org_load_error", "") or "org_activation_failed",
|
||
}})
|
||
return
|
||
await ws.send_json({"type": "org_saved_create", "payload": result.payload})
|
||
except ServiceError as exc:
|
||
await self._ack_saved_err(ws, "org_saved_create", name, exc.message)
|
||
|
||
async def _handle_org_saved_load(self, ws: Any, data: dict) -> None:
|
||
"""Activate a saved org. Uses _apply_org_config so errors surface
|
||
correctly (the previous delegation to _handle_org_config_import
|
||
sent a spurious org_config_import response and unconditionally
|
||
acked ok=True even on failure)."""
|
||
name = str(data.get("organization_id") or data.get("name") or "")
|
||
try:
|
||
organization_id = validate_saved_org_id(name)
|
||
path = org_config_path(Path(getattr(self.engine, "opc_home", None) or Path.cwd() / ".opc") / "config", organization_id)
|
||
except ValueError as exc:
|
||
return await self._ack_saved_err(ws, "org_saved_load", name, str(exc))
|
||
if not path.exists():
|
||
return await self._ack_saved_err(ws, "org_saved_load", name, "not_found")
|
||
try:
|
||
raw = path.read_text(encoding="utf-8")
|
||
except OSError as exc:
|
||
return await self._ack_saved_err(ws, "org_saved_load", name, f"read_failed: {exc}")
|
||
ok, payload = await self._apply_org_config(raw, dry_run=False, allow_mode_transition=True)
|
||
if ok:
|
||
await self._set_active_saved_org_name(organization_id)
|
||
await ws.send_json({"type": "org_saved_load", "payload": {
|
||
"ok": True,
|
||
"name": organization_id,
|
||
"organization_id": organization_id,
|
||
"filename": path.name,
|
||
**payload,
|
||
}})
|
||
else:
|
||
await ws.send_json({"type": "org_saved_load", "payload": {
|
||
"ok": False, "name": name, **payload,
|
||
}})
|
||
|
||
async def _handle_org_saved_delete(self, ws: Any, data: dict) -> None:
|
||
"""Remove a saved-org file. Never touches the active config file."""
|
||
name = str(data.get("organization_id") or data.get("name") or "")
|
||
try:
|
||
result = await self._ensure_office_services().org.saved_delete(name)
|
||
await ws.send_json({"type": "org_saved_delete", "payload": result.payload})
|
||
except ServiceError as exc:
|
||
await self._ack_saved_err(ws, "org_saved_delete", name, exc.message)
|
||
|
||
async def _handle_org_config_export(self, ws: Any, data: dict) -> None:
|
||
"""Build and return current org config as a YAML string from live engine state."""
|
||
result = await self._ensure_office_services().org.export_config()
|
||
await ws.send_json({"type": "org_config_export", "payload": {"yaml": result.payload.get("yaml", "")}})
|
||
|
||
async def _apply_org_config(
|
||
self,
|
||
raw_yaml: str,
|
||
*,
|
||
dry_run: bool,
|
||
allow_mode_transition: bool = False,
|
||
) -> tuple[bool, dict]:
|
||
"""Parse, validate and (optionally) apply an org architecture YAML string.
|
||
|
||
Pure function with no WS I/O — callers format their own response.
|
||
The apply path takes self._config_lock and broadcasts org_info on
|
||
success; dry-run and error paths do neither.
|
||
|
||
Returns (ok, payload_dict):
|
||
Success: (True, {"dry_run": bool, "preview": {roles_added, roles_removed, employees_changed}})
|
||
Error: (False, {"error": str, "validation_errors": list[str]})
|
||
"""
|
||
try:
|
||
snapshot = parse_org_architecture_snapshot(raw_yaml)
|
||
existing = self.engine.config
|
||
validated_config = apply_org_architecture_snapshot(existing, snapshot)
|
||
try:
|
||
validate_saved_org_id(getattr(validated_config.org, "organization_id", ""))
|
||
except ValueError:
|
||
if "organization_id" in snapshot:
|
||
raise
|
||
raw_name = str(
|
||
getattr(validated_config.org, "organization_name", "")
|
||
or getattr(validated_config.org, "company_name", "")
|
||
or "org"
|
||
).strip()
|
||
config_dir = Path(getattr(self.engine, "opc_home", None) or Path.cwd() / ".opc") / "config"
|
||
organization_id = allocate_org_config_id(config_dir, raw_name)
|
||
validated_config.org.organization_id = organization_id
|
||
validated_config.org.organization_name = raw_name
|
||
validated_config.org.organization_config_file = org_config_relative_path(organization_id)
|
||
validated_config.org.company_profile = "custom"
|
||
try:
|
||
from opc.core.employee_registry import load_company_employees
|
||
|
||
config_dir = Path(getattr(self.engine, "opc_home", None) or Path.cwd() / ".opc") / "config"
|
||
validated_config.org.employees = load_company_employees(
|
||
Path(config_dir).parent,
|
||
validated_config.org.organization_id,
|
||
list(validated_config.org.employees),
|
||
)
|
||
except Exception:
|
||
logger.opt(exception=True).debug("Failed to load employee registry for applied org config")
|
||
roles_before = {r.id for r in existing.org.roles}
|
||
roles_after = {r.id for r in validated_config.org.roles}
|
||
employees_before = len(existing.org.employees)
|
||
preview = {
|
||
"roles_added": len(roles_after - roles_before),
|
||
"roles_removed": len(roles_before - roles_after),
|
||
"employees_changed": abs(
|
||
len(validated_config.org.employees) - employees_before
|
||
),
|
||
}
|
||
if dry_run:
|
||
return True, {"dry_run": True, "preview": preview}
|
||
validate_runnable_org_config(validated_config)
|
||
current_mode = getattr(self, "_exec_mode", None)
|
||
if not allow_mode_transition and current_mode is not None and str(current_mode or "").strip().lower() not in {"org", "custom"}:
|
||
return False, {
|
||
"error": "Corporate organization is read-only. Select or create a saved custom org before editing.",
|
||
"code": "org_read_only",
|
||
"validation_errors": [],
|
||
}
|
||
async with self._config_lock:
|
||
self._write_active_org_config(validated_config)
|
||
self._rebind_engine_config(validated_config)
|
||
self.engine.org_engine.reload_from_config()
|
||
|
||
target_mode, target_profile = self._target_mode_for_profile(
|
||
validated_config.org.company_profile
|
||
)
|
||
current_mode = getattr(self, "_exec_mode", None)
|
||
current_profile = getattr(self, "_company_profile", "corporate")
|
||
mode_changed = target_mode is not None and (
|
||
target_mode != current_mode
|
||
or (target_mode == "company" and target_profile != current_profile)
|
||
)
|
||
runtime_ready = all(
|
||
hasattr(self, attr)
|
||
for attr in ("agent_store", "chat_store", "event_adapter")
|
||
)
|
||
if mode_changed and runtime_ready:
|
||
await self._apply_mode_switch(
|
||
target_mode,
|
||
target_profile or current_profile,
|
||
getattr(self, "_task_preferred_agent", "native"),
|
||
sync_config=False,
|
||
)
|
||
else:
|
||
if hasattr(self, "agent_store"):
|
||
await self._prune_stale_agent_store_entries()
|
||
if getattr(self, "_exec_mode", "") in {"org", "custom"}:
|
||
await self._ensure_custom_role_agents()
|
||
if runtime_ready:
|
||
await self._broadcast_snapshot()
|
||
await self._broadcast_org_info()
|
||
return True, {"dry_run": False, "preview": preview}
|
||
except Exception as exc:
|
||
validation_errors: list[str] = []
|
||
try:
|
||
from pydantic import ValidationError
|
||
if isinstance(exc, ValidationError):
|
||
validation_errors = [str(e) for e in exc.errors()]
|
||
except ImportError:
|
||
pass
|
||
return False, {"error": str(exc), "validation_errors": validation_errors}
|
||
|
||
async def _handle_org_config_import(self, ws: Any, data: dict) -> None:
|
||
"""WS endpoint: user-initiated YAML import. Thin adapter around
|
||
_apply_org_config. Saved-org Load uses _handle_org_saved_load
|
||
which also calls _apply_org_config directly (not this handler),
|
||
so the two flows don't cross-pollute their WS response types."""
|
||
raw_yaml: str = data.get("yaml", "")
|
||
dry_run: bool = data.get("dry_run", True)
|
||
ok, payload = await self._apply_org_config(raw_yaml, dry_run=dry_run)
|
||
await ws.send_json({"type": "org_config_import", "payload": {"ok": ok, **payload}})
|
||
|
||
# ------------------------------------------------------------------
|
||
# OPC Market handlers
|
||
# ------------------------------------------------------------------
|
||
|
||
async def _handle_market_browse(self, ws: Any, data: dict) -> None:
|
||
"""Return all available architecture presets for browsing."""
|
||
result = await self._ensure_office_services().market.browse()
|
||
await ws.send_json({"type": "market_browse", "payload": result.payload})
|
||
|
||
async def _handle_market_preview(self, ws: Any, data: dict) -> None:
|
||
"""Return full details of a single architecture preset."""
|
||
try:
|
||
result = await self._ensure_office_services().market.preview(data.get("preset_id", ""))
|
||
await ws.send_json({"type": "market_preview", "payload": result.payload})
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="market_preview")
|
||
|
||
async def _handle_market_apply_preset(self, ws: Any, data: dict) -> None:
|
||
"""Apply a built-in architecture preset to the current org."""
|
||
try:
|
||
result = await self._ensure_office_services().market.apply_preset(
|
||
preset_id=data.get("preset_id", ""),
|
||
strategy=data.get("strategy", "namespace"),
|
||
)
|
||
await self._publish_service_result(result)
|
||
await self._send_service_ack(ws, result)
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="market_apply_preset")
|
||
except Exception as exc:
|
||
logger.warning(f"Market apply preset failed: {exc}")
|
||
await self._send_ack(ws, ok=False, error=str(exc))
|
||
|
||
async def _handle_market_list_installed(self, ws: Any, data: dict) -> None:
|
||
"""Return list of installed market packages."""
|
||
result = await self._ensure_office_services().market.list_installed()
|
||
await ws.send_json({"type": "market_list_installed", "payload": result.payload})
|
||
|
||
async def _handle_market_export(self, ws: Any, data: dict) -> None:
|
||
"""Export current org as an .opcpkg package."""
|
||
try:
|
||
result = await self._ensure_office_services().market.export(
|
||
package_id=data.get("package_id", ""),
|
||
name=data.get("name", ""),
|
||
description=data.get("description", ""),
|
||
version=data.get("version", "1.0.0"),
|
||
output_dir=str(Path(getattr(self.engine, "opc_home", Path.cwd() / ".opc")) / "exports"),
|
||
)
|
||
await self._send_service_ack(ws, result)
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="market_export")
|
||
except Exception as exc:
|
||
logger.warning(f"Market export failed: {exc}")
|
||
await self._send_ack(ws, ok=False, error=str(exc))
|
||
|
||
async def _handle_market_install(self, ws: Any, data: dict) -> None:
|
||
"""Install an .opcpkg package from a local path."""
|
||
try:
|
||
result = await self._ensure_office_services().market.install(
|
||
path=data.get("path", ""),
|
||
strategy=data.get("strategy", "namespace"),
|
||
)
|
||
await self._publish_service_result(result)
|
||
await self._send_service_ack(ws, result)
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="market_install")
|
||
except Exception as exc:
|
||
logger.warning(f"Market install failed: {exc}")
|
||
await self._send_ack(ws, ok=False, error=str(exc))
|
||
|
||
async def _handle_market_uninstall(self, ws: Any, data: dict) -> None:
|
||
"""Uninstall a market package and clean related role/agent state."""
|
||
try:
|
||
result = await self._ensure_office_services().market.uninstall(data.get("package_id", ""))
|
||
await self._publish_service_result(result)
|
||
await self._send_service_ack(ws, result)
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="market_uninstall")
|
||
except Exception as exc:
|
||
logger.warning(f"Market uninstall failed: {exc}")
|
||
await self._send_ack(ws, ok=False, error=str(exc))
|
||
|
||
# ------------------------------------------------------------------
|
||
# Org editing handlers (custom mode)
|
||
# ------------------------------------------------------------------
|
||
|
||
async def _handle_bulk_add_roles(self, ws: Any, data: dict) -> None:
|
||
"""Add multiple roles atomically in a single transaction."""
|
||
try:
|
||
roles_data = data.get("roles", [])
|
||
if not roles_data or not isinstance(roles_data, list):
|
||
await self._send_ack(ws, ok=False, error="roles list required")
|
||
return
|
||
result = await self._ensure_office_services().org.bulk_add_roles(roles_data)
|
||
await self._publish_service_result(result)
|
||
await self._send_ack(
|
||
ws,
|
||
ok=True,
|
||
action="roles_added",
|
||
role_ids=result.payload.get("role_ids", []),
|
||
count=result.payload.get("count", 0),
|
||
)
|
||
await self._broadcast_org_info()
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="bulk_add_roles")
|
||
except Exception as exc:
|
||
logger.warning(f"Failed to bulk add roles: {exc}")
|
||
await self._send_ack(ws, ok=False, error=str(exc))
|
||
|
||
async def _handle_add_role(self, ws: Any, data: dict) -> None:
|
||
"""Add a new role to the organisation."""
|
||
try:
|
||
result = await self._ensure_office_services().org.add_role(data)
|
||
await self._publish_service_result(result)
|
||
role = dict(result.payload.get("role", {}) or {})
|
||
await self._send_ack(ws, ok=True, action="role_added", role_id=role.get("id") or role.get("role_id"))
|
||
await self._broadcast_org_info()
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="add_role")
|
||
except Exception as exc:
|
||
logger.warning(f"Failed to add role: {exc}")
|
||
await self._send_ack(ws, ok=False, error=str(exc))
|
||
|
||
async def _handle_update_role(self, ws: Any, data: dict) -> None:
|
||
"""Update a role's relationships and runtime fields."""
|
||
try:
|
||
role_id = str(data.get("role_id", "") or "").strip()
|
||
if not role_id:
|
||
await self._send_ack(ws, ok=False, error="role_id required")
|
||
return
|
||
result = await self._ensure_office_services().org.update_role(role_id, data)
|
||
await self._publish_service_result(result)
|
||
await self._send_ack(ws, ok=True, action="role_updated", role_id=role_id)
|
||
await self._broadcast_org_info()
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="update_role")
|
||
except Exception as exc:
|
||
logger.warning(f"Failed to update role: {exc}")
|
||
await self._send_ack(ws, ok=False, error=str(exc))
|
||
|
||
async def _handle_update_org_strategy(self, ws: Any, data: dict) -> None:
|
||
try:
|
||
result = await self._ensure_office_services().org.update_org_strategy(
|
||
final_decider_role_id=str(data.get("final_decider_role_id", "") or "").strip() or None,
|
||
)
|
||
await self._publish_service_result(result)
|
||
await self._send_ack(ws, ok=True, action="org_strategy_updated")
|
||
await self._broadcast_org_info()
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="update_org_strategy")
|
||
except Exception as exc:
|
||
logger.warning(f"Failed to update org strategy: {exc}")
|
||
await self._send_ack(ws, ok=False, error=str(exc))
|
||
|
||
async def _handle_delete_role(self, ws: Any, data: dict) -> None:
|
||
"""Delete a role and its employees from the organisation."""
|
||
try:
|
||
role_id = str(data.get("role_id", "") or "").strip()
|
||
if not role_id:
|
||
await self._send_ack(ws, ok=False, error="role_id required")
|
||
return
|
||
result = await self._ensure_office_services().org.delete_role(role_id)
|
||
await self._publish_service_result(result)
|
||
await self._send_ack(ws, ok=True, action="role_deleted", role_id=role_id)
|
||
await self._broadcast_org_info()
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="delete_role")
|
||
except Exception as exc:
|
||
logger.warning(f"Failed to delete role: {exc}")
|
||
await self._send_ack(ws, ok=False, error=str(exc))
|
||
|
||
async def _handle_update_runtime_policy(self, ws: Any, data: dict) -> None:
|
||
"""Update the runtime policy for custom mode."""
|
||
try:
|
||
result = await self._ensure_office_services().org.update_runtime_policy(data.get("policy", {}) or {})
|
||
await self._publish_service_result(result)
|
||
await self._send_ack(ws, ok=True, action="runtime_policy_updated")
|
||
await self._broadcast_org_info()
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="update_runtime_policy")
|
||
except Exception as exc:
|
||
logger.warning(f"Failed to update runtime policy: {exc}")
|
||
await self._send_ack(ws, ok=False, error=str(exc))
|
||
|
||
async def _handle_reset_architecture(self, ws: Any, data: dict) -> None:
|
||
"""Clear all custom roles, employees, runtime, and installed packages."""
|
||
try:
|
||
result = await self._ensure_office_services().org.reset_architecture()
|
||
await self._publish_service_result(result)
|
||
await self._send_ack(ws, ok=True, action="architecture_reset")
|
||
await self._broadcast_org_info()
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="reset_architecture")
|
||
except Exception as exc:
|
||
logger.warning(f"Failed to reset architecture: {exc}")
|
||
await self._send_ack(ws, ok=False, error=str(exc))
|
||
|
||
# Handler routing table
|
||
_HANDLERS: dict[str, Any] = {
|
||
"ping": _handle_ping,
|
||
"collab_sync": _handle_collab_sync,
|
||
"project_index": _handle_project_index,
|
||
"kanban_create_board": _handle_kanban_create_board,
|
||
"kanban_create_task": _handle_kanban_create_task,
|
||
"kanban_update_task": _handle_kanban_update_task,
|
||
"kanban_move_task": _handle_kanban_move_task,
|
||
"kanban_delete_task": _handle_kanban_delete_task,
|
||
"kanban_delete_board": _handle_kanban_delete_board,
|
||
"kanban_assign": _handle_kanban_assign,
|
||
"kanban_status": _handle_kanban_status,
|
||
"create_agent": _handle_create_agent,
|
||
"delete_agent": _handle_delete_agent,
|
||
"list_agents": _handle_list_agents,
|
||
"move_agent": _handle_move_agent,
|
||
"set_execution_mode": _handle_set_mode,
|
||
"run_task": _handle_run_task,
|
||
"cross_office_collab": _handle_cross_office,
|
||
"agent_workload": _handle_agent_workload,
|
||
"kanban_switch_view": _handle_kanban_switch_view,
|
||
"get_agent_detail": _handle_get_agent_detail,
|
||
# Session handlers
|
||
"create_session": _handle_create_session,
|
||
"session_update_config": _handle_session_update_config,
|
||
"session_detail": _handle_session_detail,
|
||
"session_send": _handle_session_send,
|
||
"session_stop": _handle_session_stop,
|
||
"session_resume": _handle_session_resume,
|
||
"session_delete": _handle_session_delete,
|
||
"session_complete": _handle_session_complete,
|
||
"session_update_title": _handle_session_update_title,
|
||
# Secretary handler
|
||
"secretary_send": _handle_secretary_send,
|
||
# Project management
|
||
"list_projects": _handle_list_projects,
|
||
"create_project": _handle_create_project,
|
||
"delete_project": _handle_delete_project,
|
||
"switch_project": _handle_switch_project,
|
||
# Org info
|
||
"org_info": _handle_org_info,
|
||
# Phase 4: Talent Market, Employee Detail, Reorg
|
||
"talent_import": _handle_talent_import,
|
||
"talent_list": _handle_talent_list,
|
||
"talent_scan_local": _handle_talent_scan_local,
|
||
"talent_import_selected": _handle_talent_import_selected,
|
||
"talent_hire": _handle_talent_hire,
|
||
"import_employee_as_agent": _handle_import_employee_as_agent,
|
||
"employee_detail": _handle_employee_detail,
|
||
"reorg_list": _handle_reorg_list,
|
||
"reorg_decide": _handle_reorg_decide,
|
||
# OPC Market
|
||
"market_browse": _handle_market_browse,
|
||
"market_preview": _handle_market_preview,
|
||
"market_apply_preset": _handle_market_apply_preset,
|
||
"market_list_installed": _handle_market_list_installed,
|
||
"market_export": _handle_market_export,
|
||
"market_install": _handle_market_install,
|
||
"market_uninstall": _handle_market_uninstall,
|
||
# Org config import/export
|
||
"org_config_export": _handle_org_config_export,
|
||
"org_config_import": _handle_org_config_import,
|
||
# Saved org architectures (named snapshots)
|
||
"org_saved_list": _handle_org_saved_list,
|
||
"org_saved_save_as": _handle_org_saved_save_as,
|
||
"org_saved_create": _handle_org_saved_create,
|
||
"org_saved_load": _handle_org_saved_load,
|
||
"org_saved_delete": _handle_org_saved_delete,
|
||
# Org editing (custom mode)
|
||
"bulk_add_roles": _handle_bulk_add_roles,
|
||
"add_role": _handle_add_role,
|
||
"update_role": _handle_update_role,
|
||
"update_org_strategy": _handle_update_org_strategy,
|
||
"delete_role": _handle_delete_role,
|
||
"update_runtime_policy": _handle_update_runtime_policy,
|
||
"reset_architecture": _handle_reset_architecture,
|
||
}
|
||
|
||
async def _handle_comms_state(self, ws: Any, data: dict) -> None:
|
||
"""Return a snapshot of the file-based comms layout for a session."""
|
||
if self._shutting_down:
|
||
return
|
||
try:
|
||
_engine, project_id = await self._engine_for_request(data)
|
||
result = await self._ensure_office_services().comms.state(
|
||
project_id=project_id,
|
||
task_id=str(data.get("task_id", "") or ""),
|
||
session_id=str(data.get("session_id", "") or ""),
|
||
)
|
||
await ws.send_json({"type": "comms_state", "payload": result.payload})
|
||
except ServiceError as exc:
|
||
await ws.send_json({"type": "comms_state", "payload": {"available": False, "reason": exc.message, **exc.payload}})
|
||
except Exception as exc:
|
||
await ws.send_json({"type": "comms_state", "payload": {"available": False, "reason": str(exc)}})
|
||
|
||
async def _handle_comms_read_message(self, ws: Any, data: dict) -> None:
|
||
"""Read the body of a single comms message file for the UI viewer."""
|
||
if self._shutting_down:
|
||
return
|
||
try:
|
||
result = await self._ensure_office_services().comms.read(
|
||
project_id=self._request_project_id(data),
|
||
task_id=str(data.get("task_id", "") or ""),
|
||
path=str(data.get("path", "") or ""),
|
||
)
|
||
await ws.send_json({"type": "comms_message", "payload": result.payload})
|
||
except ServiceError as exc:
|
||
await self._send_service_error(ws, exc, action="comms_read_message")
|
||
|
||
# Register handlers defined after _HANDLERS class-level dict
|
||
_HANDLERS["comms_state"] = _handle_comms_state
|
||
_HANDLERS["comms_read_message"] = _handle_comms_read_message
|