3f4d885dd7
Security & robustness: command injection, path traversal, approval bypass in tool/market layer
9422 lines
433 KiB
Python
9422 lines
433 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.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.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.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,
|
|
_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",
|
|
})
|
|
|
|
|
|
_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",
|
|
"recovery_status",
|
|
"recovery_result",
|
|
"project_revision_created",
|
|
"comms_state",
|
|
"comms_message",
|
|
"comms_state_dirty",
|
|
})
|
|
|
|
|
|
# ── 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._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._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._progress_flush_task: asyncio.Task[None] | None = None
|
|
self._shutting_down: bool = False
|
|
self._active_message_tasks: set[asyncio.Task[Any]] = set()
|
|
self._recovery_managers: dict[str, Any] = {}
|
|
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 _recovery_manager_for_engine(self, engine: Any, project_id: str) -> Any:
|
|
normalized = self._normalize_project_id(project_id)
|
|
existing = self._recovery_managers.get(normalized)
|
|
if existing is not None and getattr(existing, "_engine", None) is engine:
|
|
return existing
|
|
root_manager = getattr(self, "recovery_manager", None)
|
|
if root_manager is not None and getattr(root_manager, "_engine", None) is engine:
|
|
self._recovery_managers[normalized] = root_manager
|
|
return root_manager
|
|
from opc.plugins.office_ui.recovery_manager import RuntimeRecoveryManager
|
|
|
|
manager = RuntimeRecoveryManager(engine, self.broadcast)
|
|
self._recovery_managers[normalized] = manager
|
|
return manager
|
|
|
|
def _progress_callback_for_engine(self, engine: Any) -> Any:
|
|
async def _progress(text: str, **kw: Any) -> None:
|
|
await self.on_progress(
|
|
text,
|
|
_runtime_engine=engine,
|
|
_project_id=self._normalize_project_id(getattr(engine, "project_id", None)),
|
|
**kw,
|
|
)
|
|
|
|
return _progress
|
|
|
|
def _runtime_event_callback_for_engine(self, engine: Any) -> Any:
|
|
async def _runtime_event(event: Any) -> None:
|
|
await self.on_opc_event(
|
|
event,
|
|
runtime_engine=engine,
|
|
project_id=self._normalize_project_id(getattr(engine, "project_id", None)),
|
|
)
|
|
|
|
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:
|
|
await self.on_kanban_changed(engine=engine)
|
|
|
|
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._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
|
|
|
|
async def heal_orphan_tasks_on_boot(self, *, lease_seconds: int = 300) -> None:
|
|
"""Called once at server startup, before accepting WS connections.
|
|
|
|
Any task still sitting at ``status=running`` must be an orphan from a
|
|
previous (crashed / killed / power-cut) process, because the new server
|
|
has not yet started any work. ``reset_orphan_running_tasks`` reverts
|
|
those rows to ``idle`` and drops stale execution locks so:
|
|
|
|
- The UI shows them as idle (not permanently stuck as "running").
|
|
- Continue / normal session send can acquire the lock freely.
|
|
|
|
The ``lease_seconds`` grace window is defensive for theoretical
|
|
multi-process deployments: if another live worker is still
|
|
heartbeating, its ``execution_locked_at`` will be fresh and we skip it.
|
|
In the single-process default deployment the check is a no-op and all
|
|
stale rows are reset.
|
|
"""
|
|
store = self.engine.store
|
|
if not self._store_is_ready(store):
|
|
return
|
|
if not hasattr(store, "reset_orphan_running_tasks"):
|
|
return
|
|
try:
|
|
summary = await store.reset_orphan_running_tasks(lease_seconds=lease_seconds)
|
|
except Exception:
|
|
logger.opt(exception=True).warning("heal_orphan_tasks_on_boot: reset_orphan_running_tasks failed")
|
|
return
|
|
reset_count = summary.get("statuses_reset", 0)
|
|
locks_cleared = summary.get("locks_cleared", 0)
|
|
if reset_count or locks_cleared:
|
|
logger.info(
|
|
f"Startup self-heal: reset {reset_count} orphan running task(s), "
|
|
f"cleared {locks_cleared} stale execution lock(s) "
|
|
f"(lease={lease_seconds}s)."
|
|
)
|
|
|
|
async def _task_heartbeat_loop(
|
|
self,
|
|
task_id: str,
|
|
*,
|
|
interval_seconds: float = 15.0,
|
|
store: Any | None = None,
|
|
) -> None:
|
|
"""Bump ``tasks.execution_locked_at`` every ``interval_seconds`` while
|
|
the task is being processed by this server.
|
|
|
|
Paired with ``reset_orphan_running_tasks`` on startup: a fresh timestamp
|
|
proves the task is actually alive, so crash recovery can safely reset
|
|
only those whose heartbeat has gone stale.
|
|
|
|
Silently exits when cancelled (normal shutdown of the processing
|
|
coroutine) or when the DB row stops being locked.
|
|
"""
|
|
store = store or self.engine.store
|
|
if not self._store_is_ready(store):
|
|
return
|
|
try:
|
|
# Fire once immediately so an early crash still leaves a fresh
|
|
# timestamp behind (avoids recent work being mistaken for an orphan
|
|
# from a prior run if the task row already had a stale value).
|
|
try:
|
|
still_ours = await store.renew_task_lock(task_id)
|
|
except Exception:
|
|
logger.opt(exception=True).debug(
|
|
f"Heartbeat (initial) renew_task_lock failed for {task_id}",
|
|
)
|
|
still_ours = True
|
|
if not still_ours:
|
|
return
|
|
while True:
|
|
await asyncio.sleep(interval_seconds)
|
|
try:
|
|
still_ours = await store.renew_task_lock(task_id)
|
|
except Exception:
|
|
logger.opt(exception=True).debug(
|
|
f"Heartbeat renew_task_lock failed for {task_id}",
|
|
)
|
|
continue
|
|
if not still_ours:
|
|
# Task is no longer running — stop bumping the timestamp.
|
|
return
|
|
except asyncio.CancelledError:
|
|
return
|
|
|
|
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:
|
|
normalized_detail_level = _normalize_transcript_detail_level(detail_level)
|
|
if normalized_detail_level == "full":
|
|
return True
|
|
metadata = dict(message.get("metadata", {}) or {})
|
|
return str(metadata.get("detail_visibility", "summary")).strip() != "full"
|
|
|
|
@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):
|
|
before_dt = datetime.fromtimestamp(before_timestamp) if before_timestamp is not None else None
|
|
raw_page = page_loader(
|
|
session_id,
|
|
limit=limit,
|
|
before_created_at=before_dt,
|
|
before_message_id=before_message_id,
|
|
detail_level=_normalize_transcript_detail_level(detail_level),
|
|
)
|
|
page = await raw_page if inspect.isawaitable(raw_page) else raw_page
|
|
transcript_page = list((page or {}).get("messages", []) or [])
|
|
formatted_page = build_transcript_ui_messages(
|
|
transcript_page,
|
|
channel_id=channel_id,
|
|
task_id=task_id,
|
|
detail_level=_normalize_transcript_detail_level(detail_level),
|
|
)
|
|
total_count = int((page or {}).get("total_count", len(formatted_page)) or 0)
|
|
has_more = bool((page or {}).get("has_more", False))
|
|
return formatted_page, max(total_count, len(formatted_page)), 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 self._shutting_down:
|
|
await ws.close()
|
|
return ws
|
|
self._clients.add(ws)
|
|
self._ensure_progress_flush_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,
|
|
),
|
|
)
|
|
|
|
# Push work-item recovery status if any interrupted work-item runtimes exist
|
|
initial_recovery_project_id = initial_project_id
|
|
_rm = self._recovery_manager_for_engine(self.engine, initial_recovery_project_id)
|
|
if _rm:
|
|
try:
|
|
from opc.plugins.office_ui.recovery_manager import _serialize_status
|
|
_rs = await _rm.get_recovery_status()
|
|
if _rs.interrupted:
|
|
if not await self._safe_send_json(
|
|
ws,
|
|
{"type": "recovery_status", "payload": _serialize_status(_rs, project_id=initial_recovery_project_id)},
|
|
):
|
|
return ws
|
|
except Exception:
|
|
pass
|
|
|
|
# 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)
|
|
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,
|
|
},
|
|
})
|
|
buf = self._progress_buffer.setdefault(task_id, [])
|
|
buf.append(entry)
|
|
self._progress_project_ids[task_id] = pid
|
|
if len(buf) >= 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
|
|
|
|
parent_task_id = str(self._active_runtime_children.get(source_task_id) or "").strip()
|
|
if parent_task_id:
|
|
return parent_task_id
|
|
|
|
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:
|
|
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
|
|
session_id = str(getattr(task, "session_id", "") or "").strip()
|
|
if session_id:
|
|
mapped_task_id = str(self._session_to_task.get(session_id) or "").strip()
|
|
if mapped_task_id:
|
|
return mapped_task_id
|
|
|
|
return source_task_id
|
|
|
|
@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)
|
|
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,
|
|
}})
|
|
|
|
# ── Accumulate for persistence (flushed at threshold / task end) ──
|
|
buf = self._progress_buffer.setdefault(task_id, [])
|
|
buf.append(entry)
|
|
self._progress_project_ids[task_id] = pid
|
|
if len(buf) >= 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,
|
|
)
|
|
|
|
@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 and runtime_type in _TASK_MODE_HIDDEN_RUNTIME_PROGRESS_TYPES:
|
|
return None
|
|
if is_task_mode and runtime_type not in _TASK_MODE_VISIBLE_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":
|
|
return None
|
|
elif runtime_type == "member_idle":
|
|
return None
|
|
elif runtime_type == "thinking_delta":
|
|
entry_type = "thinking"
|
|
detail = str(payload.get("text", "") or "").strip()
|
|
summary = "Thinking"
|
|
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" if is_task_mode else "status_change"
|
|
summary = str(payload.get("tool_name", "") or "tool")
|
|
if not is_task_mode:
|
|
summary = f"{summary} completed"
|
|
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 is_task_mode and 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 _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)
|
|
if handler:
|
|
current_task = asyncio.current_task()
|
|
if current_task is not None:
|
|
self._active_message_tasks.add(current_task)
|
|
try:
|
|
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 current_task is not None:
|
|
self._active_message_tasks.discard(current_task)
|
|
else:
|
|
logger.debug(f"Unknown WS message type: {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 _track(self, coro: Any) -> asyncio.Task[Any]:
|
|
"""Create a tracked background task that auto-removes itself on completion."""
|
|
task = asyncio.create_task(coro)
|
|
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)
|
|
self._task_bg_context[bg] = {
|
|
"task_id": task_id,
|
|
"project_id": self._normalize_project_id(project_id or getattr(engine, "project_id", None)),
|
|
"engine": engine,
|
|
}
|
|
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 = 2.0) -> None:
|
|
"""Stop accepting new WS work and close clients before DB shutdown."""
|
|
import aiohttp
|
|
|
|
self._shutting_down = True
|
|
if self._progress_flush_task and not self._progress_flush_task.done():
|
|
self._progress_flush_task.cancel()
|
|
try:
|
|
await self._progress_flush_task
|
|
except (asyncio.CancelledError, Exception):
|
|
pass
|
|
self._progress_flush_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}")
|
|
|
|
pending = [
|
|
task
|
|
for task in self._active_message_tasks
|
|
if task is not asyncio.current_task() and not task.done()
|
|
]
|
|
if pending:
|
|
try:
|
|
await asyncio.wait_for(
|
|
asyncio.gather(*pending, return_exceptions=True),
|
|
timeout=timeout,
|
|
)
|
|
except asyncio.TimeoutError:
|
|
logger.debug(f"Timed out waiting for {len(pending)} WS message handler(s) to finish")
|
|
|
|
async def _mark_task_tree_cancelled_if_active(
|
|
self,
|
|
task_id: str,
|
|
*,
|
|
store: Any | None = None,
|
|
) -> list[str]:
|
|
store = store or self.engine.store
|
|
if not self._store_is_ready(store):
|
|
return []
|
|
|
|
from opc.core.models import TaskStatus as TS
|
|
|
|
affected_ids = [task_id]
|
|
affected_ids.extend(
|
|
child_id
|
|
for child_id, origin_id in self._active_runtime_children.items()
|
|
if origin_id == task_id and child_id != task_id
|
|
)
|
|
|
|
updated_ids: list[str] = []
|
|
for current_id in affected_ids:
|
|
if current_id in self._stop_requested_task_ids:
|
|
continue
|
|
task = await store.get_task(current_id)
|
|
if not task or task.status in (TS.CANCELLED, TS.DONE, TS.FAILED):
|
|
continue
|
|
task.status = TS.CANCELLED
|
|
await store.save_task(task)
|
|
updated_ids.append(current_id)
|
|
return updated_ids
|
|
|
|
@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()
|
|
if action:
|
|
payload.setdefault("action", action)
|
|
await self._send_ack(ws, ok=False, **payload)
|
|
|
|
# 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)
|
|
|
|
@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
|
|
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
|
|
identity = self._resolve_task_identity(
|
|
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
|
|
|
|
company_runtime_target: dict[str, Any] | None = None
|
|
heartbeat_task: asyncio.Task[Any] | None = None
|
|
try:
|
|
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")
|
|
if self._store_is_ready(engine.store):
|
|
heartbeat_task = asyncio.create_task(
|
|
self._task_heartbeat_loop(task_id, interval_seconds=15.0, store=engine.store),
|
|
name=f"task-heartbeat:{task_id}",
|
|
)
|
|
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:
|
|
if heartbeat_task is not None and not heartbeat_task.done():
|
|
heartbeat_task.cancel()
|
|
try:
|
|
await heartbeat_task
|
|
except (asyncio.CancelledError, Exception):
|
|
pass
|
|
# 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"),
|
|
}
|
|
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 _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":
|
|
await self._mark_human_escalation_checkpoint_status(
|
|
escalation_id,
|
|
status="timeout",
|
|
project_id=project_id,
|
|
default_action=str(payload.get("default_action", "") or "").strip() or None,
|
|
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
|
|
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:
|
|
messages = await self.chat_store.get_channel_messages_page(
|
|
channel_id,
|
|
limit=request_limit,
|
|
before_timestamp=before_timestamp,
|
|
before_message_id=before_message_id,
|
|
project_id=project_id,
|
|
)
|
|
messages = self._filter_ui_messages_for_detail_level(messages, detail_level)
|
|
messages = [_sanitize_ui_message_dict(message) for message in messages]
|
|
except Exception:
|
|
messages = []
|
|
try:
|
|
if transcript_total_count:
|
|
visible_cache_count = transcript_total_count
|
|
else:
|
|
visible_cache_count = len(self._filter_ui_messages_for_detail_level(
|
|
await self.chat_store.get_channel_messages(
|
|
channel_id,
|
|
limit=max(request_limit * 8, 500),
|
|
project_id=project_id,
|
|
),
|
|
detail_level,
|
|
))
|
|
except Exception as exc:
|
|
if self._is_expected_shutdown_error(exc):
|
|
logger.debug(f"session_detail: visible count skipped during shutdown for {task_id}")
|
|
return
|
|
visible_cache_count = len(messages)
|
|
total_message_count = max(transcript_total_count, visible_cache_count, len(messages))
|
|
has_more = transcript_has_more or (
|
|
before_timestamp is None and total_message_count > len(messages)
|
|
)
|
|
|
|
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:
|
|
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:
|
|
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
|
|
|
|
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:
|
|
if _looks_like_escalation_reply(content):
|
|
stale_checkpoint_id = explicit_escalation_id or explicit_checkpoint_id
|
|
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 = await self.chat_store.insert_message(
|
|
channel_id=channel_id,
|
|
sender="assistant",
|
|
sender_name="OPC",
|
|
content=(
|
|
"That approval request is no longer active. "
|
|
"The approval card has been marked inactive in the session history."
|
|
),
|
|
project_id=pid,
|
|
metadata={"type": "system"},
|
|
)
|
|
await self.broadcast({"type": "session_message", "payload": helper})
|
|
return
|
|
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,
|
|
)
|
|
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,
|
|
) -> dict[str, Any] | None:
|
|
"""Resolve any company parent/child task id to the parent runtime 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
|
|
parent_session_id = str(
|
|
getattr(task, "parent_session_id", "")
|
|
or getattr(task, "session_id", "")
|
|
or ""
|
|
).strip()
|
|
if not parent_session_id:
|
|
return None
|
|
|
|
project_id = str(getattr(task, "project_id", "") or runtime_engine.project_id or "default")
|
|
try:
|
|
project_tasks = await store.get_tasks(project_id=project_id)
|
|
except Exception:
|
|
project_tasks = [task]
|
|
|
|
parent_task_id = str(self._session_to_task.get(parent_session_id) or "").strip()
|
|
for candidate in project_tasks:
|
|
candidate_id = str(getattr(candidate, "id", "") or "").strip()
|
|
candidate_session_id = str(getattr(candidate, "session_id", "") or "").strip()
|
|
candidate_parent_session_id = str(getattr(candidate, "parent_session_id", "") or "").strip()
|
|
if candidate_session_id == parent_session_id and not candidate_parent_session_id:
|
|
parent_task_id = candidate_id
|
|
break
|
|
if not parent_task_id:
|
|
parent_task_id = str(
|
|
self._active_runtime_children.get(task_id)
|
|
or self._session_to_task.get(parent_session_id)
|
|
or task_id
|
|
).strip()
|
|
|
|
affected_task_ids: list[str] = []
|
|
for candidate in project_tasks:
|
|
candidate_id = str(getattr(candidate, "id", "") or "").strip()
|
|
if not candidate_id:
|
|
continue
|
|
candidate_session_id = str(getattr(candidate, "session_id", "") or "").strip()
|
|
candidate_parent_session_id = str(getattr(candidate, "parent_session_id", "") or "").strip()
|
|
if (
|
|
candidate_id == task_id
|
|
or candidate_id == parent_task_id
|
|
or candidate_session_id == parent_session_id
|
|
or candidate_parent_session_id == parent_session_id
|
|
):
|
|
if candidate_id not in affected_task_ids:
|
|
affected_task_ids.append(candidate_id)
|
|
for child_id, origin_id in list(self._active_runtime_children.items()):
|
|
if origin_id == parent_task_id or child_id == task_id:
|
|
if child_id not in affected_task_ids:
|
|
affected_task_ids.append(child_id)
|
|
if parent_task_id and parent_task_id not in affected_task_ids:
|
|
affected_task_ids.insert(0, parent_task_id)
|
|
|
|
return {
|
|
"task": task,
|
|
"engine": runtime_engine,
|
|
"parent_session_id": parent_session_id,
|
|
"parent_task_id": parent_task_id or task_id,
|
|
"origin_task_id": parent_task_id or task_id,
|
|
"affected_task_ids": affected_task_ids or [task_id],
|
|
}
|
|
|
|
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()
|
|
]
|
|
parent_task_id = str(target.get("parent_task_id", "") or "").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_task_id": parent_task_id,
|
|
"resume_parent_session_id": str(target.get("parent_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 _mark_company_runtime_stop_state(
|
|
self,
|
|
target: dict[str, Any],
|
|
*,
|
|
state: str,
|
|
stop_intent_id: str,
|
|
) -> None:
|
|
runtime_engine = target.get("engine") or self.engine
|
|
store = runtime_engine.store
|
|
if not self._store_is_ready(store):
|
|
return
|
|
from opc.core.models import TaskStatus
|
|
|
|
for task_id in list(target.get("affected_task_ids", []) or []):
|
|
try:
|
|
task = await store.get_task(str(task_id))
|
|
except Exception:
|
|
task = None
|
|
if not task or task.status in {TaskStatus.DONE, TaskStatus.FAILED, TaskStatus.CANCELLED}:
|
|
continue
|
|
task.metadata = dict(task.metadata or {})
|
|
task.metadata["company_runtime_stop_state"] = state
|
|
task.metadata["company_runtime_stop_intent_id"] = stop_intent_id
|
|
task.metadata["company_runtime_stop_marked_at"] = datetime.now().isoformat()
|
|
task.metadata.setdefault(
|
|
"suspended_task_status",
|
|
task.status.value if hasattr(task.status, "value") else str(task.status or ""),
|
|
)
|
|
task.execution_lock = False
|
|
task.execution_locked_at = None
|
|
try:
|
|
await store.save_task(task)
|
|
except Exception:
|
|
logger.opt(exception=True).debug("failed to mark company runtime stop state")
|
|
|
|
async def _clear_company_runtime_stop_state(
|
|
self,
|
|
target: dict[str, Any],
|
|
*,
|
|
stop_intent_id: str,
|
|
) -> None:
|
|
runtime_engine = target.get("engine") or self.engine
|
|
store = runtime_engine.store
|
|
if not self._store_is_ready(store):
|
|
return
|
|
for task_id in list(target.get("affected_task_ids", []) or []):
|
|
try:
|
|
task = await store.get_task(str(task_id))
|
|
except Exception:
|
|
task = None
|
|
if not task:
|
|
continue
|
|
metadata = dict(task.metadata or {})
|
|
if str(metadata.get("company_runtime_stop_intent_id", "") or "") != str(stop_intent_id or ""):
|
|
continue
|
|
for key in (
|
|
"dispatch_hold",
|
|
"company_runtime_stop_state",
|
|
"company_runtime_stop_intent_id",
|
|
"company_runtime_stop_marked_at",
|
|
"company_runtime_suspend_checkpoint_type",
|
|
"company_runtime_suspended_at",
|
|
"suspended_task_status",
|
|
):
|
|
metadata.pop(key, None)
|
|
task.metadata = metadata
|
|
task.execution_lock = False
|
|
task.execution_locked_at = None
|
|
try:
|
|
await store.save_task(task)
|
|
except Exception:
|
|
logger.opt(exception=True).debug("failed to clear company runtime stop state")
|
|
|
|
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("parent_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._mark_company_runtime_stop_state(
|
|
target,
|
|
state="suspended",
|
|
stop_intent_id=stop_intent_id,
|
|
)
|
|
except Exception:
|
|
logger.opt(exception=True).debug("failed to mark company runtime fully suspended")
|
|
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:{origin_task_id or target.get('parent_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._clear_company_runtime_stop_state(target, stop_intent_id=stop_intent_id)
|
|
except Exception:
|
|
logger.opt(exception=True).debug("failed to clear company runtime stop state after suspend failure")
|
|
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
|
|
|
|
exec_mode, _company_profile = self._resolve_task_session_config(task)
|
|
if exec_mode in {"company", "org", "custom"} and 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}")
|
|
target = None
|
|
if target is not None:
|
|
parent_session_id = str(target.get("parent_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._mark_company_runtime_stop_state(
|
|
target,
|
|
state="suspending",
|
|
stop_intent_id=stop_intent_id,
|
|
)
|
|
await self._set_company_runtime_control(
|
|
target,
|
|
state="suspending",
|
|
stop_intent_id=stop_intent_id,
|
|
)
|
|
finalizer = asyncio.create_task(
|
|
self._finalize_company_runtime_stop(target, stop_intent_id=stop_intent_id)
|
|
)
|
|
self._company_stop_finalize_tasks[parent_session_id] = finalizer
|
|
self._background_tasks.add(finalizer)
|
|
finalizer.add_done_callback(self._on_bg_task_done)
|
|
await self._send_ack(ws, ok=True, stop_intent_id=stop_intent_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 an existing runtime after disconnect / Stop without re-planning.
|
|
|
|
The engine's `_maybe_resume_existing_company_runtime` normally asks an
|
|
LLM to classify whether a user reply means "resume" or "start new".
|
|
The UI Continue button is unambiguous, so we set ``ui_force_resume`` in
|
|
the message metadata to bypass the classifier and re-awaken any
|
|
blocked/failed/pending tasks for the session (and their downstream
|
|
descendants) via the same path as a natural-language "继续".
|
|
"""
|
|
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)
|
|
|
|
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 self._send_ack(ws, ok=False, error="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)
|
|
resume_task_id = task_id
|
|
if exec_mode in {"company", "org", "custom"} and 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"session_resume: failed to resolve company runtime target for {task_id}")
|
|
target = None
|
|
if target is not None:
|
|
parent_session_id = str(target.get("parent_session_id", "") or "").strip()
|
|
resume_task_id = str(target.get("parent_task_id", "") or task_id).strip()
|
|
session_id = parent_session_id or session_id
|
|
finalizer = self._company_stop_finalize_tasks.get(parent_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 self._send_ack(ws, ok=False, error="stop_finalize_in_progress")
|
|
return
|
|
except Exception:
|
|
logger.opt(exception=True).debug("session_resume: stop finalizer ended with error")
|
|
self._company_stop_intents.pop(parent_session_id, None)
|
|
try:
|
|
await self._set_company_runtime_control(target, state="resuming")
|
|
except Exception:
|
|
logger.opt(exception=True).debug("session_resume: failed to broadcast resuming state")
|
|
if not session_id:
|
|
await self._send_ack(ws, ok=False, error="session_not_found")
|
|
return
|
|
|
|
content = str(data.get("content", "") or "").strip() or "Resume the existing runtime."
|
|
|
|
self._track_session(
|
|
resume_task_id,
|
|
self._process_session_message(
|
|
resume_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,
|
|
project_id: str,
|
|
) -> dict[str, str]:
|
|
parent_session_id = str(
|
|
payload.get("parent_session_id")
|
|
or getattr(waiting_task, "parent_session_id", "")
|
|
or ""
|
|
).strip()
|
|
parent_task_id = ""
|
|
|
|
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
|
|
candidate_parent_session_id = str(target.get("parent_session_id", "") or "").strip()
|
|
candidate_parent_task_id = str(target.get("parent_task_id", "") or "").strip()
|
|
if not parent_session_id and candidate_parent_session_id:
|
|
parent_session_id = candidate_parent_session_id
|
|
if candidate_parent_task_id and (
|
|
not parent_task_id
|
|
or not parent_session_id
|
|
or candidate_parent_session_id == parent_session_id
|
|
):
|
|
parent_task_id = candidate_parent_task_id
|
|
if parent_session_id and parent_task_id:
|
|
break
|
|
|
|
if not parent_session_id:
|
|
raw_session_id = str(
|
|
payload.get("session_id")
|
|
or getattr(checkpoint, "session_id", "")
|
|
or getattr(waiting_task, "session_id", "")
|
|
or ""
|
|
).strip()
|
|
if raw_session_id and ":" in raw_session_id:
|
|
parent_session_id = raw_session_id.split(":", 1)[0]
|
|
else:
|
|
parent_session_id = raw_session_id
|
|
|
|
store = getattr(engine, "store", None)
|
|
if (not parent_task_id or not parent_session_id) and self._store_is_ready(store):
|
|
try:
|
|
tasks = await store.get_tasks(project_id=project_id)
|
|
except Exception:
|
|
tasks = []
|
|
for candidate in tasks:
|
|
candidate_id = str(getattr(candidate, "id", "") or "").strip()
|
|
candidate_session_id = str(getattr(candidate, "session_id", "") or "").strip()
|
|
candidate_parent_session_id = str(getattr(candidate, "parent_session_id", "") or "").strip()
|
|
if parent_session_id and candidate_session_id == parent_session_id and not candidate_parent_session_id:
|
|
parent_task_id = candidate_id
|
|
break
|
|
if not parent_session_id and candidate_id == task_id:
|
|
parent_session_id = candidate_session_id
|
|
parent_task_id = candidate_id
|
|
break
|
|
|
|
if not parent_task_id:
|
|
parent_task_id = str(task_id or waiting_task_id or "").strip()
|
|
if not parent_session_id:
|
|
parent_session_id = str(
|
|
getattr(waiting_task, "session_id", "")
|
|
or payload.get("session_id")
|
|
or getattr(checkpoint, "session_id", "")
|
|
or ""
|
|
).strip()
|
|
return {"parent_task_id": parent_task_id, "parent_session_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",
|
|
})
|
|
waiting_task.status = TaskStatus.DONE
|
|
await store.save_task(waiting_task)
|
|
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,
|
|
project_id=run_project_id,
|
|
)
|
|
parent_task_id = str(target.get("parent_task_id", "") or task_id).strip() or task_id
|
|
parent_session_id = str(target.get("parent_session_id", "") or session_id or "").strip()
|
|
if not parent_session_id:
|
|
return False
|
|
|
|
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")
|
|
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()
|
|
if explicit_checkpoint_type and explicit_checkpoint_type not in {
|
|
"company_runtime_suspended",
|
|
"company_runtime_interrupted",
|
|
}:
|
|
return False
|
|
if task is None:
|
|
return False
|
|
exec_mode, _company_profile = self._resolve_task_session_config(task)
|
|
if exec_mode not in {"company", "org", "custom"}:
|
|
return False
|
|
try:
|
|
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")
|
|
return False
|
|
if target is None:
|
|
return False
|
|
parent_session_id = str(target.get("parent_session_id", "") or session_id or "").strip()
|
|
if not parent_session_id:
|
|
return False
|
|
|
|
finalizer = self._company_stop_finalize_tasks.get(parent_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")
|
|
|
|
checkpoint = None
|
|
get_checkpoint = getattr(run_engine, "get_active_company_runtime_suspend_checkpoint", None)
|
|
if callable(get_checkpoint):
|
|
try:
|
|
checkpoint = await get_checkpoint(parent_session_id)
|
|
except Exception:
|
|
logger.opt(exception=True).debug("failed to load active company suspend checkpoint")
|
|
if checkpoint is None:
|
|
return False
|
|
if str(getattr(checkpoint, "status", "") or "").strip() != "pending":
|
|
return False
|
|
|
|
parent_task_id = str(target.get("parent_task_id", "") or task_id).strip() or task_id
|
|
lock = self._company_suspend_reply_locks.get(parent_session_id)
|
|
if lock is None:
|
|
lock = asyncio.Lock()
|
|
self._company_suspend_reply_locks[parent_session_id] = lock
|
|
|
|
bg = self._track(
|
|
self._process_company_suspend_reply(
|
|
parent_task_id=parent_task_id,
|
|
parent_session_id=parent_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,
|
|
lock=lock,
|
|
)
|
|
)
|
|
self._task_bg_context[bg] = {
|
|
"task_id": parent_task_id,
|
|
"project_id": self._normalize_project_id(run_project_id),
|
|
"engine": run_engine,
|
|
"company_suspend_reply": True,
|
|
}
|
|
return True
|
|
|
|
async def _process_company_suspend_reply(
|
|
self,
|
|
*,
|
|
parent_task_id: str,
|
|
parent_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],
|
|
lock: asyncio.Lock,
|
|
) -> None:
|
|
channel_id = f"session:{parent_task_id}"
|
|
pid = self._normalize_project_id(run_project_id or getattr(run_engine, "project_id", None))
|
|
async with lock:
|
|
try:
|
|
try:
|
|
await self._set_company_runtime_control(target, state="resuming")
|
|
except Exception:
|
|
logger.opt(exception=True).debug("failed to broadcast company suspend reply routing state")
|
|
|
|
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 company suspend reply")
|
|
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
|
|
await run_engine.process_message(
|
|
content,
|
|
project_id=pid,
|
|
session_id=parent_session_id,
|
|
mode=engine_mode,
|
|
org_id=session_org_id or None,
|
|
company_profile=company_profile,
|
|
preferred_agent=None,
|
|
origin_task_id=parent_task_id,
|
|
attachment_refs=attachment_refs,
|
|
message_metadata=engine_message_metadata or None,
|
|
)
|
|
checkpoint_meta = await self._extract_checkpoint_metadata(
|
|
parent_task_id,
|
|
session_id=parent_session_id,
|
|
engine=run_engine,
|
|
)
|
|
await self._sync_task_transcript_messages(
|
|
parent_task_id,
|
|
engine=run_engine,
|
|
latest_assistant_metadata=checkpoint_meta if checkpoint_meta else None,
|
|
)
|
|
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",
|
|
)
|
|
finally:
|
|
if self._chat_store_is_ready(self.chat_store):
|
|
await self._flush_progress(parent_task_id, project_id=pid)
|
|
self._task_bg_context.pop(asyncio.current_task(), None)
|
|
self._company_suspend_reply_locks.pop(parent_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
|
|
|
|
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
|
|
store = engine.store
|
|
if self._store_is_ready(store):
|
|
from opc.core.models import TaskStatus
|
|
task = await store.get_task(task_id)
|
|
if task:
|
|
session_exec_mode, session_company_profile = self._resolve_task_session_config(task)
|
|
session_org_id = self._resolve_task_org_id(task)
|
|
session_preferred_agent = self._resolve_task_preferred_agent(task)
|
|
|
|
# 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:
|
|
session_exec_mode, session_company_profile = self._resolve_task_session_config(task)
|
|
session_org_id = self._resolve_task_org_id(task)
|
|
session_preferred_agent = self._resolve_task_preferred_agent(task)
|
|
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",
|
|
}})
|
|
# Bump execution_locked_at periodically while we own this task, so
|
|
# crash recovery (reset_orphan_running_tasks) can distinguish live
|
|
# work from abandoned locks by timestamp freshness.
|
|
heartbeat_task: asyncio.Task[Any] | None = None
|
|
if self._store_is_ready(engine.store):
|
|
heartbeat_task = asyncio.create_task(
|
|
self._task_heartbeat_loop(task_id, interval_seconds=15.0, store=engine.store),
|
|
name=f"task-heartbeat:{task_id}",
|
|
)
|
|
# 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:
|
|
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
|
|
if task is not None:
|
|
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,
|
|
)
|
|
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,
|
|
)
|
|
|
|
# ── 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,
|
|
}})
|
|
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:
|
|
cancelled_ids = await self._mark_task_tree_cancelled_if_active(task_id, store=engine.store)
|
|
for cancelled_id in cancelled_ids:
|
|
await self.broadcast({"type": "board_task_status_changed", "payload": {
|
|
"project_id": pid, "task_id": cancelled_id, "column_id": "done", "status": "cancelled",
|
|
}})
|
|
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",
|
|
}})
|
|
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:
|
|
# Stop heartbeat before anything else so we don't keep bumping
|
|
# execution_locked_at for a task we've just finished handling.
|
|
if heartbeat_task is not None and not heartbeat_task.done():
|
|
heartbeat_task.cancel()
|
|
try:
|
|
await heartbeat_task
|
|
except (asyncio.CancelledError, Exception):
|
|
pass
|
|
# 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)
|
|
# 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()
|
|
]
|
|
|
|
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": str(pause_request.get("resume_hint", "") or "").strip(),
|
|
"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")
|
|
|
|
async def _handle_recovery_action(self, ws: Any, data: dict) -> None:
|
|
"""Handle work-item recovery actions (resume/cancel/scan)."""
|
|
try:
|
|
_engine, project_id = await self._engine_for_request(data)
|
|
action = str(data.get("action", "") or "").strip()
|
|
result = await self._ensure_office_services().runtime.recovery_action(
|
|
project_id=project_id,
|
|
action=action,
|
|
parent_task_id=str(data.get("parent_task_id", "") or "").strip(),
|
|
)
|
|
if action == "scan":
|
|
await ws.send_json({"type": "recovery_status", "payload": result.payload})
|
|
else:
|
|
await self._send_service_ack(ws, result)
|
|
except ServiceError as exc:
|
|
await self._send_service_error(ws, exc, action="recovery_action")
|
|
|
|
# Register handlers defined after _HANDLERS class-level dict
|
|
_HANDLERS["recovery_action"] = _handle_recovery_action
|
|
_HANDLERS["comms_state"] = _handle_comms_state
|
|
_HANDLERS["comms_read_message"] = _handle_comms_read_message
|