Initial commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
"""Service layer for the CLI board plugin."""
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Interactive task actions for the CLI board."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from opc.core.models import Task, TaskStatus
|
||||
from opc.layer2_organization.work_item_transition import apply_task_status_transition
|
||||
from opc.presentation.kanban import column_to_task_status
|
||||
from opc.plugins.office_ui.services.factory import OfficeServiceFactory
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .engine_facade import EngineFacade
|
||||
|
||||
|
||||
class BoardActions:
|
||||
"""Mutating operations exposed to the TUI."""
|
||||
|
||||
def __init__(self, facade: "EngineFacade", project_id: str | None = None) -> None:
|
||||
self.facade = facade
|
||||
self.project_id = project_id
|
||||
self._task_locks: dict[str, asyncio.Lock] = {}
|
||||
self._task_bg_map: dict[str, asyncio.Task[Any]] = {}
|
||||
|
||||
@property
|
||||
def _project_id(self) -> str:
|
||||
return self.project_id or self.facade.project_id or "default"
|
||||
|
||||
async def _run_office_service(self, operation):
|
||||
engine = await self.facade.ensure_ready()
|
||||
async with OfficeServiceFactory(
|
||||
config=getattr(engine, "config", None),
|
||||
project_id=self._project_id,
|
||||
on_progress=getattr(self.facade, "_progress_callback", None),
|
||||
on_runtime_event=getattr(self.facade, "_event_callback", None),
|
||||
) as services:
|
||||
return await operation(services)
|
||||
|
||||
async def create_task(
|
||||
self,
|
||||
*,
|
||||
title: str,
|
||||
description: str = "",
|
||||
auto_run: bool = False,
|
||||
initial_message: str | None = None,
|
||||
mode: str = "task",
|
||||
company_profile: str | None = None,
|
||||
) -> Task:
|
||||
engine = await self.facade.ensure_ready()
|
||||
if not engine.store:
|
||||
raise RuntimeError("OPC store is not available.")
|
||||
|
||||
normalized_title = str(title or "").strip() or "New Chat"
|
||||
normalized_description = str(description or "").strip()
|
||||
result = await self._run_office_service(
|
||||
lambda svc: svc.session.create(
|
||||
project_id=self._project_id,
|
||||
title=normalized_title,
|
||||
description=normalized_description,
|
||||
exec_mode=mode,
|
||||
company_profile=company_profile,
|
||||
interface="cli_board",
|
||||
)
|
||||
)
|
||||
task_id = str(result.payload.get("task_id", "") or "")
|
||||
task = await self._get_task(task_id)
|
||||
|
||||
if auto_run:
|
||||
prompt = (initial_message or f"{normalized_title}\n{normalized_description}").strip()
|
||||
if prompt:
|
||||
await self.send_session_message(
|
||||
task.id,
|
||||
prompt,
|
||||
mode=mode,
|
||||
company_profile=company_profile,
|
||||
allow_terminal=True,
|
||||
)
|
||||
return task
|
||||
|
||||
async def run_task(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
mode: str = "task",
|
||||
company_profile: str | None = None,
|
||||
) -> str:
|
||||
task = await self._get_task(task_id)
|
||||
prompt = f"{task.title}\n{task.description}".strip()
|
||||
if not prompt:
|
||||
raise ValueError("Selected task has no title or description to run.")
|
||||
return await self.send_session_message(
|
||||
task_id,
|
||||
prompt,
|
||||
mode=mode,
|
||||
company_profile=company_profile,
|
||||
allow_terminal=True,
|
||||
)
|
||||
|
||||
async def retry_task(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
mode: str = "task",
|
||||
company_profile: str | None = None,
|
||||
) -> str:
|
||||
return await self.run_task(task_id, mode=mode, company_profile=company_profile)
|
||||
|
||||
async def send_session_message(
|
||||
self,
|
||||
task_id: str,
|
||||
content: str,
|
||||
*,
|
||||
mode: str = "task",
|
||||
company_profile: str | None = None,
|
||||
allow_terminal: bool = False,
|
||||
) -> str:
|
||||
engine = await self.facade.ensure_ready()
|
||||
if not engine.store:
|
||||
raise RuntimeError("OPC store is not available.")
|
||||
|
||||
async with self._get_task_lock(task_id):
|
||||
if existing := self._task_bg_map.get(task_id):
|
||||
if not existing.done() and existing is not asyncio.current_task():
|
||||
raise RuntimeError("This task is already running.")
|
||||
|
||||
task = await self._get_task(task_id)
|
||||
if not allow_terminal and task.status in {TaskStatus.CANCELLED, TaskStatus.DONE}:
|
||||
raise ValueError("Cannot send messages to a completed or cancelled task.")
|
||||
|
||||
if not task.session_id:
|
||||
task.session_id = str(uuid.uuid4())
|
||||
if engine.memory:
|
||||
await engine.memory.ensure_session(
|
||||
task.session_id,
|
||||
project_id=self._project_id,
|
||||
title=task.title,
|
||||
mode="primary",
|
||||
metadata={"source": "cli_board"},
|
||||
)
|
||||
|
||||
current_task = asyncio.current_task()
|
||||
if current_task is not None:
|
||||
self._task_bg_map[task_id] = current_task
|
||||
|
||||
try:
|
||||
normalized_mode = str(mode or "task").strip().lower()
|
||||
send_mode = "company" if normalized_mode in {"company", "custom", "org"} else "task"
|
||||
send_profile = "custom" if normalized_mode in {"custom", "org"} else company_profile
|
||||
result = await self._run_office_service(
|
||||
lambda svc: svc.session.send(
|
||||
project_id=self._project_id,
|
||||
task_id=task_id,
|
||||
content=str(content or "").strip(),
|
||||
mode=send_mode,
|
||||
company_profile=send_profile,
|
||||
)
|
||||
)
|
||||
return str(result.payload.get("response", "") or "")
|
||||
except asyncio.CancelledError:
|
||||
await self._mark_related_tasks(task_id, TaskStatus.CANCELLED)
|
||||
await self._resolve_related_checkpoints(task_id, status="cancelled")
|
||||
raise
|
||||
finally:
|
||||
if self._task_bg_map.get(task_id) is current_task:
|
||||
self._task_bg_map.pop(task_id, None)
|
||||
|
||||
async def move_task(self, task_id: str, column_id: str) -> Task:
|
||||
engine = await self.facade.ensure_ready()
|
||||
if not engine.store:
|
||||
raise RuntimeError("OPC store is not available.")
|
||||
|
||||
task = await self._get_task(task_id)
|
||||
target_status = column_to_task_status(column_id)
|
||||
if target_status is None:
|
||||
raise ValueError(f"Unsupported target column: {column_id}")
|
||||
if task.status in {TaskStatus.DONE, TaskStatus.FAILED, TaskStatus.CANCELLED} and target_status not in {
|
||||
TaskStatus.DONE,
|
||||
TaskStatus.FAILED,
|
||||
TaskStatus.CANCELLED,
|
||||
}:
|
||||
raise ValueError(f"Cannot move terminal task {task.status.value} back to {column_id}.")
|
||||
await self._run_office_service(lambda svc: svc.kanban.move_task(project_id=self._project_id, task_id=task_id, column_id=column_id))
|
||||
return await self._get_task(task_id)
|
||||
|
||||
async def complete_task(self, task_id: str) -> Task:
|
||||
engine = await self.facade.ensure_ready()
|
||||
if not engine.store:
|
||||
raise RuntimeError("OPC store is not available.")
|
||||
await self._run_office_service(lambda svc: svc.session.complete(project_id=self._project_id, task_id=task_id))
|
||||
return await self._get_task(task_id)
|
||||
|
||||
async def cancel_task(self, task_id: str) -> None:
|
||||
engine = await self.facade.ensure_ready()
|
||||
if not engine.store:
|
||||
raise RuntimeError("OPC store is not available.")
|
||||
|
||||
background = self._task_bg_map.get(task_id)
|
||||
if background and not background.done() and background is not asyncio.current_task():
|
||||
background.cancel()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
await self._run_office_service(lambda svc: svc.session.stop(project_id=self._project_id, task_id=task_id))
|
||||
await self._resolve_related_checkpoints(task_id, status="cancelled")
|
||||
|
||||
async def approve_checkpoint(self, task_id: str, *, approved: bool = True, reply: str | None = None) -> str:
|
||||
if reply is not None:
|
||||
message = reply
|
||||
else:
|
||||
message = "approve" if approved else "deny"
|
||||
return await self.send_session_message(
|
||||
task_id,
|
||||
message,
|
||||
allow_terminal=True,
|
||||
)
|
||||
|
||||
def is_running(self, task_id: str) -> bool:
|
||||
task = self._task_bg_map.get(task_id)
|
||||
return bool(task and not task.done())
|
||||
|
||||
def _get_task_lock(self, task_id: str) -> asyncio.Lock:
|
||||
lock = self._task_locks.get(task_id)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
self._task_locks[task_id] = lock
|
||||
return lock
|
||||
|
||||
async def _get_task(self, task_id: str) -> Task:
|
||||
engine = await self.facade.ensure_ready()
|
||||
if not engine.store:
|
||||
raise RuntimeError("OPC store is not available.")
|
||||
task = await engine.store.get_task(task_id)
|
||||
if task is None:
|
||||
raise ValueError(f"Unknown task: {task_id}")
|
||||
return task
|
||||
|
||||
async def _collect_related_tasks(self, task_id: str) -> list[Task]:
|
||||
engine = await self.facade.ensure_ready()
|
||||
if not engine.store:
|
||||
return []
|
||||
root_task = await self._get_task(task_id)
|
||||
tasks = await engine.store.get_tasks(project_id=self._project_id)
|
||||
related: list[Task] = []
|
||||
root_session_id = str(root_task.session_id or "").strip()
|
||||
for task in tasks:
|
||||
metadata = task.metadata if isinstance(task.metadata, dict) else {}
|
||||
origin_task_id = str(metadata.get("origin_task_id", "") or "").strip()
|
||||
if task.id == root_task.id:
|
||||
related.append(task)
|
||||
continue
|
||||
if origin_task_id and origin_task_id == root_task.id:
|
||||
related.append(task)
|
||||
continue
|
||||
parent_session_id = str(task.parent_session_id or "").strip()
|
||||
if root_session_id and parent_session_id and parent_session_id == root_session_id:
|
||||
related.append(task)
|
||||
return related
|
||||
|
||||
async def _mark_related_tasks(self, task_id: str, status: TaskStatus) -> None:
|
||||
engine = await self.facade.ensure_ready()
|
||||
if not engine.store:
|
||||
return
|
||||
for task in await self._collect_related_tasks(task_id):
|
||||
await apply_task_status_transition(
|
||||
engine.store,
|
||||
task,
|
||||
target_status_or_phase=status,
|
||||
reason="cli_board_mark_related_tasks",
|
||||
release_claim=status == TaskStatus.CANCELLED,
|
||||
)
|
||||
|
||||
async def _resolve_related_checkpoints(self, task_id: str, *, status: str) -> None:
|
||||
engine = await self.facade.ensure_ready()
|
||||
if not engine.store:
|
||||
return
|
||||
|
||||
related = await self._collect_related_tasks(task_id)
|
||||
if not related:
|
||||
return
|
||||
|
||||
task_ids = {task.id for task in related}
|
||||
session_ids = {task.session_id for task in related if task.session_id}
|
||||
checkpoints = await engine.store.get_pending_checkpoints(project_id=self._project_id)
|
||||
for checkpoint in checkpoints:
|
||||
if checkpoint.task_id in task_ids or checkpoint.session_id in session_ids:
|
||||
await engine.store.resolve_execution_checkpoint(checkpoint.checkpoint_id, status=status)
|
||||
@@ -0,0 +1,885 @@
|
||||
"""Read-model builders for the CLI board."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time as _time
|
||||
from collections import defaultdict
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from opc.layer2_organization.phase import (
|
||||
Phase,
|
||||
coerce_phase,
|
||||
kanban_column,
|
||||
should_hide_work_item_from_company_kanban,
|
||||
)
|
||||
from opc.layer2_organization.work_item_context_view import WorkItemContextView
|
||||
from opc.layer2_organization.work_item_links import task_by_linked_work_item_id
|
||||
from opc.presentation.kanban import (
|
||||
COMPANY_KANBAN_COLUMNS,
|
||||
build_base_task_payload,
|
||||
datetime_to_timestamp,
|
||||
)
|
||||
|
||||
from ..state.models import (
|
||||
BoardAlert,
|
||||
BoardMetrics,
|
||||
BoardSnapshot,
|
||||
BoardTaskView,
|
||||
LinkedExecutionView,
|
||||
OrgEmployeeView,
|
||||
OrgRoleView,
|
||||
OrgSnapshotView,
|
||||
PendingCheckpointView,
|
||||
PipelineSnapshot,
|
||||
PipelineWorkItemView,
|
||||
SessionMessageView,
|
||||
SessionSummaryView,
|
||||
TaskDetailView,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .engine_facade import EngineFacade
|
||||
|
||||
|
||||
class BoardRepository:
|
||||
"""Builds CLI-specific board and detail snapshots from the main OPC store."""
|
||||
|
||||
def __init__(self, facade: "EngineFacade", project_id: str | None = None) -> None:
|
||||
self.facade = facade
|
||||
self.project_id = project_id
|
||||
|
||||
async def load_snapshot(self) -> BoardSnapshot:
|
||||
engine = await self.facade.ensure_ready()
|
||||
if not engine.store:
|
||||
return BoardSnapshot(project_id=self._project_id)
|
||||
|
||||
tasks = await engine.store.get_tasks(project_id=self._project_id)
|
||||
checkpoints = await engine.store.get_pending_checkpoints(project_id=self._project_id)
|
||||
checkpoint_by_session = self._checkpoint_by_session(checkpoints)
|
||||
await self._enrich_checkpoint_payloads(checkpoint_by_session)
|
||||
|
||||
company_snapshot = await self._maybe_build_company_snapshot(
|
||||
engine,
|
||||
tasks=tasks,
|
||||
checkpoint_by_session=checkpoint_by_session,
|
||||
checkpoint_count=len(checkpoints),
|
||||
)
|
||||
if company_snapshot is not None:
|
||||
return company_snapshot
|
||||
|
||||
visible_tasks, hidden_by_origin, hidden_count = self._split_visible_tasks(tasks)
|
||||
|
||||
task_views: list[BoardTaskView] = []
|
||||
for display_num, task in enumerate(visible_tasks, start=1):
|
||||
task_views.append(
|
||||
self._task_to_view(
|
||||
task,
|
||||
checkpoint=checkpoint_by_session.get(getattr(task, "session_id", None)),
|
||||
linked_tasks=hidden_by_origin.get(getattr(task, "id", ""), []),
|
||||
display_num=display_num,
|
||||
)
|
||||
)
|
||||
|
||||
session_summaries = [self._session_summary_view(task) for task in task_views]
|
||||
alerts = self._alerts_from_tasks(task_views)
|
||||
return BoardSnapshot(
|
||||
project_id=self._project_id,
|
||||
tasks=task_views,
|
||||
hidden_task_count=hidden_count,
|
||||
pending_checkpoint_count=len(checkpoints),
|
||||
session_summaries=session_summaries,
|
||||
alerts=alerts,
|
||||
metrics=self._build_metrics(task_views, hidden_count=hidden_count, pending_checkpoint_count=len(checkpoints)),
|
||||
)
|
||||
|
||||
async def _maybe_build_company_snapshot(
|
||||
self,
|
||||
engine: Any,
|
||||
*,
|
||||
tasks: list[Any],
|
||||
checkpoint_by_session: dict[str | None, PendingCheckpointView],
|
||||
checkpoint_count: int,
|
||||
) -> BoardSnapshot | None:
|
||||
"""Return a company-mode snapshot if an active delegation run exists.
|
||||
|
||||
Cards are sourced from `DelegationWorkItem`. Runtime `Task` objects are
|
||||
kept only as audit references (`runtime_task_id` / `session_id`).
|
||||
"""
|
||||
store = engine.store
|
||||
list_runs = getattr(store, "list_open_delegation_runs", None)
|
||||
list_items = getattr(store, "list_delegation_work_items", None)
|
||||
if list_runs is None or list_items is None:
|
||||
return None
|
||||
try:
|
||||
open_runs = await list_runs(project_id=self._project_id)
|
||||
except TypeError:
|
||||
open_runs = await list_runs()
|
||||
if not open_runs:
|
||||
return None
|
||||
active_run = open_runs[0]
|
||||
run_id = str(getattr(active_run, "run_id", "") or "").strip()
|
||||
if not run_id:
|
||||
return None
|
||||
work_items = await list_items(run_id)
|
||||
if not work_items:
|
||||
return None
|
||||
|
||||
hydrate_links = getattr(store, "hydrate_task_work_item_links", None)
|
||||
if callable(hydrate_links):
|
||||
await hydrate_links(tasks)
|
||||
task_by_work_item_id = task_by_linked_work_item_id(tasks)
|
||||
|
||||
visible_items: list[Any] = []
|
||||
hidden_count = 0
|
||||
for item in work_items:
|
||||
metadata = dict(getattr(item, "metadata", {}) or {})
|
||||
if not str(getattr(item, "parent_work_item_id", "") or "").strip():
|
||||
# Skip the synthetic root work item — the kanban shows leaf delegations.
|
||||
hidden_count += 1
|
||||
continue
|
||||
if bool(metadata.get("attention_work_item", False)):
|
||||
hidden_count += 1
|
||||
continue
|
||||
if should_hide_work_item_from_company_kanban(metadata):
|
||||
hidden_count += 1
|
||||
continue
|
||||
visible_items.append(item)
|
||||
|
||||
task_views: list[BoardTaskView] = []
|
||||
for display_num, item in enumerate(visible_items, start=1):
|
||||
linked_task = task_by_work_item_id.get(str(getattr(item, "work_item_id", "") or "").strip())
|
||||
checkpoint = None
|
||||
linked_session_id = getattr(linked_task, "session_id", None) if linked_task is not None else None
|
||||
if linked_session_id:
|
||||
checkpoint = checkpoint_by_session.get(linked_session_id)
|
||||
task_views.append(
|
||||
self._work_item_to_view(
|
||||
item,
|
||||
linked_task=linked_task,
|
||||
checkpoint=checkpoint,
|
||||
display_num=display_num,
|
||||
)
|
||||
)
|
||||
|
||||
session_summaries = [self._session_summary_view(task) for task in task_views]
|
||||
alerts = self._alerts_from_tasks(task_views)
|
||||
column_order = [column.column_id for column in COMPANY_KANBAN_COLUMNS]
|
||||
return BoardSnapshot(
|
||||
project_id=self._project_id,
|
||||
tasks=task_views,
|
||||
hidden_task_count=hidden_count,
|
||||
pending_checkpoint_count=checkpoint_count,
|
||||
session_summaries=session_summaries,
|
||||
alerts=alerts,
|
||||
metrics=self._build_metrics(
|
||||
task_views,
|
||||
hidden_count=hidden_count,
|
||||
pending_checkpoint_count=checkpoint_count,
|
||||
),
|
||||
mode="company",
|
||||
column_order=column_order,
|
||||
)
|
||||
|
||||
async def load_task_detail(self, task_id: str) -> TaskDetailView | None:
|
||||
engine = await self.facade.ensure_ready()
|
||||
if not engine.store:
|
||||
return None
|
||||
|
||||
tasks = await engine.store.get_tasks(project_id=self._project_id)
|
||||
checkpoints = await engine.store.get_pending_checkpoints(project_id=self._project_id)
|
||||
checkpoint_by_session = self._checkpoint_by_session(checkpoints)
|
||||
await self._enrich_checkpoint_payloads(checkpoint_by_session)
|
||||
|
||||
# In company mode the kanban card_id is a DelegationWorkItem.work_item_id,
|
||||
# not a Task.id. Try the work-item path first; fall back to the runtime
|
||||
# Task path so standard mode (and detail links by Task.id) still works.
|
||||
work_item_detail = await self._load_work_item_detail(
|
||||
engine,
|
||||
work_item_id=task_id,
|
||||
tasks=tasks,
|
||||
checkpoint_by_session=checkpoint_by_session,
|
||||
)
|
||||
if work_item_detail is not None:
|
||||
return work_item_detail
|
||||
|
||||
visible_tasks, _, _ = self._split_visible_tasks(tasks)
|
||||
target = next((task for task in tasks if getattr(task, "id", "") == task_id), None)
|
||||
if target is None:
|
||||
return None
|
||||
|
||||
pending = checkpoint_by_session.get(getattr(target, "session_id", None))
|
||||
linked: list[Any] = []
|
||||
seen_ids: set[str] = set()
|
||||
for task in tasks:
|
||||
origin_task_id = self._origin_task_id(task)
|
||||
if origin_task_id == task_id and getattr(task, "id", "") != task_id:
|
||||
if task.id not in seen_ids:
|
||||
linked.append(task)
|
||||
seen_ids.add(task.id)
|
||||
continue
|
||||
if getattr(task, "parent_session_id", None) and getattr(task, "parent_session_id", None) == getattr(target, "session_id", None):
|
||||
if task.id not in seen_ids and task.id != task_id:
|
||||
linked.append(task)
|
||||
seen_ids.add(task.id)
|
||||
|
||||
transcript: list[SessionMessageView] = []
|
||||
if getattr(target, "session_id", None):
|
||||
raw_transcript = await engine.store.get_session_transcript(target.session_id)
|
||||
transcript = [msg for msg in (self._transcript_item_to_view(item) for item in raw_transcript) if msg is not None]
|
||||
|
||||
display_num = next(
|
||||
(index for index, task in enumerate(visible_tasks, start=1) if getattr(task, "id", "") == task_id),
|
||||
0,
|
||||
)
|
||||
task_view = self._task_to_view(target, checkpoint=pending, linked_tasks=linked, display_num=display_num)
|
||||
linked_views = [self._linked_execution_view(task) for task in linked]
|
||||
result = getattr(target, "result", None) or {}
|
||||
result_content = result.get("content") if isinstance(result, dict) else None
|
||||
artifacts = result.get("artifacts", []) if isinstance(result, dict) else []
|
||||
metadata = getattr(target, "metadata", {}) if isinstance(getattr(target, "metadata", {}), dict) else {}
|
||||
context_preview = (
|
||||
metadata.get("handoff_context")
|
||||
or metadata.get("context_preview")
|
||||
or metadata.get("secretary_context")
|
||||
or None
|
||||
)
|
||||
return TaskDetailView(
|
||||
task=task_view,
|
||||
transcript=transcript,
|
||||
linked_executions=linked_views,
|
||||
progress_entries=list(metadata.get("progress_log", []) or []),
|
||||
pending_checkpoint=pending,
|
||||
result_content=result_content,
|
||||
artifacts=artifacts if isinstance(artifacts, list) else [artifacts],
|
||||
context_preview=str(context_preview).strip() if context_preview else None,
|
||||
)
|
||||
|
||||
async def _load_work_item_detail(
|
||||
self,
|
||||
engine: Any,
|
||||
*,
|
||||
work_item_id: str,
|
||||
tasks: list[Any],
|
||||
checkpoint_by_session: dict[str | None, PendingCheckpointView],
|
||||
) -> TaskDetailView | None:
|
||||
"""Build a detail view keyed by DelegationWorkItem.work_item_id.
|
||||
|
||||
Returns None when the id is not a known work item (caller falls back
|
||||
to the Task-id path).
|
||||
"""
|
||||
store = engine.store
|
||||
list_runs = getattr(store, "list_open_delegation_runs", None)
|
||||
list_items = getattr(store, "list_delegation_work_items", None)
|
||||
if list_runs is None or list_items is None or not work_item_id:
|
||||
return None
|
||||
try:
|
||||
open_runs = await list_runs(project_id=self._project_id)
|
||||
except TypeError:
|
||||
open_runs = await list_runs()
|
||||
if not open_runs:
|
||||
return None
|
||||
target_item: Any | None = None
|
||||
for run in open_runs:
|
||||
run_id = str(getattr(run, "run_id", "") or "").strip()
|
||||
if not run_id:
|
||||
continue
|
||||
for item in await list_items(run_id):
|
||||
if str(getattr(item, "work_item_id", "") or "").strip() == work_item_id:
|
||||
target_item = item
|
||||
break
|
||||
if target_item is not None:
|
||||
break
|
||||
if target_item is None:
|
||||
return None
|
||||
|
||||
task_by_work_item_id = task_by_linked_work_item_id(tasks)
|
||||
linked_task = task_by_work_item_id.get(work_item_id)
|
||||
|
||||
linked_session_id = str(getattr(linked_task, "session_id", "") or "").strip() if linked_task is not None else ""
|
||||
checkpoint = checkpoint_by_session.get(linked_session_id) if linked_session_id else None
|
||||
|
||||
transcript: list[SessionMessageView] = []
|
||||
if linked_session_id:
|
||||
raw_transcript = await engine.store.get_session_transcript(linked_session_id)
|
||||
transcript = [
|
||||
msg
|
||||
for msg in (self._transcript_item_to_view(item) for item in raw_transcript)
|
||||
if msg is not None
|
||||
]
|
||||
|
||||
task_view = self._work_item_to_view(
|
||||
target_item,
|
||||
linked_task=linked_task,
|
||||
checkpoint=checkpoint,
|
||||
display_num=0,
|
||||
)
|
||||
result = getattr(linked_task, "result", None) or {} if linked_task is not None else {}
|
||||
result_content = result.get("content") if isinstance(result, dict) else None
|
||||
artifacts = result.get("artifacts", []) if isinstance(result, dict) else []
|
||||
# Prefer work-item handoff context; fall back to runtime Task metadata.
|
||||
linked_metadata = (
|
||||
getattr(linked_task, "metadata", None) or {}
|
||||
if linked_task is not None
|
||||
else {}
|
||||
)
|
||||
view = WorkItemContextView(target_item, linked_task)
|
||||
context_preview = (
|
||||
view.get("handoff_context")
|
||||
or view.get("context_preview")
|
||||
or (linked_metadata.get("secretary_context") if isinstance(linked_metadata, dict) else None)
|
||||
or None
|
||||
)
|
||||
progress_log = view.get_list("progress_log")
|
||||
return TaskDetailView(
|
||||
task=task_view,
|
||||
transcript=transcript,
|
||||
linked_executions=[self._linked_execution_view(linked_task)] if linked_task is not None else [],
|
||||
progress_entries=progress_log,
|
||||
pending_checkpoint=checkpoint,
|
||||
result_content=result_content,
|
||||
artifacts=artifacts if isinstance(artifacts, list) else [artifacts],
|
||||
context_preview=str(context_preview).strip() if context_preview else None,
|
||||
)
|
||||
|
||||
async def load_pipeline_state(
|
||||
self,
|
||||
parent_task_id: str,
|
||||
runtime_lookup: dict[str, Any] | None = None,
|
||||
) -> PipelineSnapshot | None:
|
||||
"""Build the terminal Office-style work-item pipeline for a run."""
|
||||
engine = await self.facade.ensure_ready()
|
||||
store = getattr(engine, "store", None)
|
||||
if not store:
|
||||
return None
|
||||
list_runs = getattr(store, "list_open_delegation_runs", None)
|
||||
list_items = getattr(store, "list_delegation_work_items", None)
|
||||
if list_runs is None or list_items is None:
|
||||
return None
|
||||
try:
|
||||
runs = list(await list_runs(project_id=self._project_id))
|
||||
except TypeError:
|
||||
runs = list(await list_runs())
|
||||
if not runs:
|
||||
return None
|
||||
|
||||
tasks = await store.get_tasks(project_id=self._project_id) if hasattr(store, "get_tasks") else []
|
||||
hydrate_links = getattr(store, "hydrate_task_work_item_links", None)
|
||||
if callable(hydrate_links):
|
||||
await hydrate_links(tasks)
|
||||
linked_tasks = task_by_linked_work_item_id(tasks)
|
||||
|
||||
selected_run = None
|
||||
selected_items: list[Any] = []
|
||||
target = str(parent_task_id or "").strip()
|
||||
for run in runs:
|
||||
run_id = str(getattr(run, "run_id", "") or "").strip()
|
||||
items = list(await list_items(run_id)) if run_id else []
|
||||
run_keys = {
|
||||
run_id,
|
||||
str(getattr(run, "parent_task_id", "") or "").strip(),
|
||||
str(getattr(run, "task_id", "") or "").strip(),
|
||||
str(getattr(run, "parent_session_id", "") or "").strip(),
|
||||
}
|
||||
item_keys = {str(getattr(item, "work_item_id", "") or "").strip() for item in items}
|
||||
if not target or target in run_keys or target in item_keys:
|
||||
selected_run = run
|
||||
selected_items = items
|
||||
break
|
||||
if selected_run is None:
|
||||
selected_run = runs[0]
|
||||
run_id = str(getattr(selected_run, "run_id", "") or "").strip()
|
||||
selected_items = list(await list_items(run_id)) if run_id else []
|
||||
if not selected_items:
|
||||
return None
|
||||
|
||||
work_items: list[PipelineWorkItemView] = []
|
||||
start_ts = 0.0
|
||||
end_ts = 0.0
|
||||
for item in selected_items:
|
||||
work_item_id = str(getattr(item, "work_item_id", "") or "").strip()
|
||||
linked_task = linked_tasks.get(work_item_id)
|
||||
linked_task_id = str(getattr(linked_task, "id", "") or "").strip() if linked_task is not None else ""
|
||||
metadata = dict(getattr(item, "metadata", {}) or {})
|
||||
linked_metadata = dict(getattr(linked_task, "metadata", {}) or {}) if linked_task is not None else {}
|
||||
phase = coerce_phase(getattr(item, "phase", Phase.READY))
|
||||
created_at = datetime_to_timestamp(getattr(item, "created_at", None))
|
||||
updated_at = datetime_to_timestamp(getattr(item, "updated_at", None) or getattr(item, "created_at", None))
|
||||
if created_at and (not start_ts or created_at < start_ts):
|
||||
start_ts = created_at
|
||||
if updated_at and updated_at > end_ts:
|
||||
end_ts = updated_at
|
||||
runtime = None
|
||||
if runtime_lookup:
|
||||
runtime = runtime_lookup.get(linked_task_id) or runtime_lookup.get(work_item_id)
|
||||
work_items.append(PipelineWorkItemView(
|
||||
projection_id=work_item_id,
|
||||
title=str(getattr(item, "title", "") or work_item_id),
|
||||
role_id=str(getattr(item, "role_id", "") or ""),
|
||||
status=phase.value,
|
||||
assigned_to=str(getattr(linked_task, "assigned_to", "") or getattr(item, "role_id", "") or ""),
|
||||
task_id=linked_task_id or None,
|
||||
runtime_task_id=linked_task_id or None,
|
||||
execution_turn_id=linked_task_id or None,
|
||||
session_id=str(getattr(linked_task, "session_id", "") or "") or None,
|
||||
elapsed_sec=max(0.0, (updated_at or _time.time()) - created_at) if created_at else 0.0,
|
||||
current_tool=getattr(runtime, "current_tool", None) if runtime is not None else None,
|
||||
tool_elapsed_ms=int(getattr(runtime, "tool_elapsed_ms", 0) or 0) if runtime is not None else 0,
|
||||
last_tool_summary=str(getattr(runtime, "last_tool_summary", "") or "") if runtime is not None else "",
|
||||
context_remaining_pct=int(getattr(runtime, "context_remaining_pct", 0) or 0) if runtime is not None else 0,
|
||||
turn_cost_usd=float(getattr(runtime, "turn_cost_usd", 0.0) or 0.0) if runtime is not None else 0.0,
|
||||
has_gate=bool(metadata.get("gate_type") or linked_metadata.get("checkpoint_hint") or (getattr(runtime, "pending_permission_count", 0) if runtime is not None else 0)),
|
||||
gate_type=str(metadata.get("gate_type") or linked_metadata.get("checkpoint_type") or "") or None,
|
||||
dependencies=[
|
||||
str(dep).strip()
|
||||
for dep in list(metadata.get("dependency_work_item_ids", []) or [])
|
||||
if str(dep).strip()
|
||||
],
|
||||
parallel_group=str(metadata.get("parallel_group", "") or "") or None,
|
||||
))
|
||||
|
||||
done_count = sum(1 for item in work_items if item.status in {"done", "reviewed", "delivered"})
|
||||
parent_title = (
|
||||
str(getattr(selected_run, "title", "") or "")
|
||||
or str(getattr(selected_run, "summary", "") or "")
|
||||
or str(getattr(selected_run, "run_id", "") or "")
|
||||
)
|
||||
return PipelineSnapshot(
|
||||
parent_task_id=str(getattr(selected_run, "parent_task_id", "") or getattr(selected_run, "task_id", "") or target),
|
||||
parent_title=parent_title,
|
||||
profile=str(getattr(selected_run, "profile", "") or getattr(selected_run, "company_profile", "") or ""),
|
||||
work_items=work_items,
|
||||
done_count=done_count,
|
||||
total_count=len(work_items),
|
||||
elapsed_sec=max(0.0, (end_ts or _time.time()) - start_ts) if start_ts else 0.0,
|
||||
)
|
||||
|
||||
async def load_org_snapshot(self) -> OrgSnapshotView | None:
|
||||
"""Build a read-only view of the current org structure."""
|
||||
engine = await self.facade.ensure_ready()
|
||||
if not engine.org_engine:
|
||||
return None
|
||||
|
||||
org = engine.org_engine
|
||||
|
||||
# Roles as tree
|
||||
all_agents = org.list_agents()
|
||||
all_employees = org.list_employees()
|
||||
emp_count_by_role: dict[str, int] = {}
|
||||
for emp in all_employees:
|
||||
emp_count_by_role[emp.role_id] = emp_count_by_role.get(emp.role_id, 0) + 1
|
||||
|
||||
try:
|
||||
tree_raw = org.get_org_tree()
|
||||
except Exception:
|
||||
tree_raw = []
|
||||
|
||||
def _build_role_tree(nodes: list[dict]) -> list[OrgRoleView]:
|
||||
result: list[OrgRoleView] = []
|
||||
for node in nodes:
|
||||
agent = node.get("agent")
|
||||
if agent is None:
|
||||
continue
|
||||
role_id = getattr(agent, "role_id", "")
|
||||
result.append(OrgRoleView(
|
||||
role_id=role_id,
|
||||
name=getattr(agent, "name", role_id),
|
||||
responsibility=getattr(agent, "responsibility", ""),
|
||||
reports_to=getattr(agent, "reports_to", "owner"),
|
||||
employee_count=emp_count_by_role.get(role_id, 0),
|
||||
children=_build_role_tree(node.get("reports", [])),
|
||||
))
|
||||
return result
|
||||
|
||||
role_tree = _build_role_tree(tree_raw)
|
||||
|
||||
# Employees
|
||||
employee_views = [
|
||||
OrgEmployeeView(
|
||||
employee_id=emp.employee_id,
|
||||
name=emp.name,
|
||||
role_id=emp.role_id,
|
||||
category=emp.category,
|
||||
domains=list(emp.domains),
|
||||
seniority=emp.seniority,
|
||||
)
|
||||
for emp in all_employees
|
||||
]
|
||||
|
||||
profile = org.get_company_profile()
|
||||
|
||||
return OrgSnapshotView(
|
||||
role_tree=role_tree,
|
||||
employees=employee_views,
|
||||
company_profile=profile,
|
||||
role_count=len(all_agents),
|
||||
employee_count=len(all_employees),
|
||||
)
|
||||
|
||||
@property
|
||||
def _project_id(self) -> str:
|
||||
return self.project_id or self.facade.project_id or "default"
|
||||
|
||||
@staticmethod
|
||||
def _origin_task_id(task: Any) -> str | None:
|
||||
metadata = getattr(task, "metadata", None)
|
||||
if not isinstance(metadata, dict):
|
||||
return None
|
||||
origin_task_id = str(metadata.get("origin_task_id", "") or "").strip()
|
||||
return origin_task_id or None
|
||||
|
||||
def _split_visible_tasks(self, tasks: list[Any]) -> tuple[list[Any], dict[str, list[Any]], int]:
|
||||
visible_tasks: list[Any] = []
|
||||
hidden_by_origin: dict[str, list[Any]] = defaultdict(list)
|
||||
hidden_count = 0
|
||||
for task in tasks:
|
||||
origin_task_id = self._origin_task_id(task)
|
||||
if origin_task_id and origin_task_id != getattr(task, "id", ""):
|
||||
hidden_by_origin[origin_task_id].append(task)
|
||||
hidden_count += 1
|
||||
continue
|
||||
visible_tasks.append(task)
|
||||
return visible_tasks, hidden_by_origin, hidden_count
|
||||
|
||||
@staticmethod
|
||||
def _checkpoint_prompt(checkpoint: Any) -> tuple[str, str]:
|
||||
payload = getattr(checkpoint, "payload", {}) or {}
|
||||
prompt = (
|
||||
payload.get("prompt")
|
||||
or payload.get("message")
|
||||
or payload.get("summary")
|
||||
or payload.get("original_message")
|
||||
or ""
|
||||
)
|
||||
prompt_text = str(prompt).strip()
|
||||
if not prompt_text:
|
||||
prompt_text = json.dumps(payload, ensure_ascii=False, indent=2)[:600]
|
||||
|
||||
summary = (
|
||||
payload.get("feedback_scope")
|
||||
or payload.get("work_item_projection_title")
|
||||
or payload.get("summary")
|
||||
or payload.get("title")
|
||||
or getattr(checkpoint, "checkpoint_type", "pending")
|
||||
)
|
||||
return str(summary).strip(), prompt_text
|
||||
|
||||
def _checkpoint_view(self, checkpoint: Any) -> PendingCheckpointView:
|
||||
summary, prompt = self._checkpoint_prompt(checkpoint)
|
||||
return PendingCheckpointView(
|
||||
checkpoint_id=str(getattr(checkpoint, "checkpoint_id", "") or ""),
|
||||
checkpoint_type=str(getattr(checkpoint, "checkpoint_type", "") or ""),
|
||||
status=str(getattr(checkpoint, "status", "") or ""),
|
||||
session_id=getattr(checkpoint, "session_id", None),
|
||||
task_id=getattr(checkpoint, "task_id", None),
|
||||
summary=summary,
|
||||
prompt=prompt,
|
||||
payload=dict(getattr(checkpoint, "payload", {}) or {}),
|
||||
)
|
||||
|
||||
def _checkpoint_by_session(self, checkpoints: list[Any]) -> dict[str | None, PendingCheckpointView]:
|
||||
result: dict[str | None, PendingCheckpointView] = {}
|
||||
for checkpoint in checkpoints:
|
||||
session_id = getattr(checkpoint, "session_id", None)
|
||||
if session_id not in result:
|
||||
result[session_id] = self._checkpoint_view(checkpoint)
|
||||
return result
|
||||
|
||||
async def _enrich_checkpoint_payloads(
|
||||
self,
|
||||
checkpoint_map: dict[str | None, PendingCheckpointView],
|
||||
) -> None:
|
||||
"""Enrich reorg checkpoint payloads with full proposal data from store."""
|
||||
engine = await self.facade.ensure_ready()
|
||||
if not engine.store:
|
||||
return
|
||||
for view in checkpoint_map.values():
|
||||
if view.checkpoint_type != "company_reorg_pending":
|
||||
continue
|
||||
proposal_id = str(view.payload.get("proposal_id", "") or "").strip()
|
||||
if not proposal_id:
|
||||
continue
|
||||
try:
|
||||
proposal = await engine.store.get_reorg_proposal(proposal_id)
|
||||
except Exception:
|
||||
continue
|
||||
if proposal is None:
|
||||
continue
|
||||
view.payload["title"] = proposal.title
|
||||
view.payload["scope"] = proposal.scope.value if hasattr(proposal.scope, "value") else str(proposal.scope)
|
||||
view.payload["risk_level"] = proposal.risk_level.value if hasattr(proposal.risk_level, "value") else str(proposal.risk_level)
|
||||
view.payload["summary"] = proposal.summary
|
||||
view.payload["rationale"] = proposal.rationale
|
||||
view.payload["impact_summary"] = dict(proposal.impact_summary) if proposal.impact_summary else {}
|
||||
changeset = proposal.changeset
|
||||
if changeset:
|
||||
view.payload["role_changes"] = [
|
||||
{"action": rc.action, "role_id": rc.role_id, "replacement_role_id": getattr(rc, "replacement_role_id", ""), "reason": getattr(rc, "reason", "")}
|
||||
for rc in (changeset.role_changes if hasattr(changeset, "role_changes") else [])
|
||||
]
|
||||
|
||||
def _work_item_to_view(
|
||||
self,
|
||||
item: Any,
|
||||
*,
|
||||
linked_task: Any | None,
|
||||
checkpoint: PendingCheckpointView | None,
|
||||
display_num: int = 0,
|
||||
) -> BoardTaskView:
|
||||
"""Build a BoardTaskView from a DelegationWorkItem (company-mode card).
|
||||
|
||||
The card identity is the work_item_id; runtime Task / session are
|
||||
kept only as audit references via runtime_task_id / session_id.
|
||||
"""
|
||||
normalized_metadata = dict(getattr(item, "metadata", {}) or {})
|
||||
phase = coerce_phase(getattr(item, "phase", Phase.READY))
|
||||
column_id = kanban_column(phase).replace("_", "-")
|
||||
canonical_status = phase.value
|
||||
work_item_id = str(getattr(item, "work_item_id", "") or "").strip()
|
||||
role_id = str(getattr(item, "role_id", "") or "").strip()
|
||||
title = str(getattr(item, "title", "") or "").strip()
|
||||
summary = str(getattr(item, "summary", "") or "").strip()
|
||||
kind = str(getattr(item, "kind", "") or "").strip()
|
||||
dependencies = [
|
||||
str(dep).strip()
|
||||
for dep in list(normalized_metadata.get("dependency_work_item_ids", []) or [])
|
||||
if str(dep).strip()
|
||||
]
|
||||
created_at = datetime_to_timestamp(getattr(item, "created_at", None))
|
||||
updated_at = datetime_to_timestamp(getattr(item, "updated_at", None) or getattr(item, "created_at", None))
|
||||
|
||||
# Audit/back-references (NOT used to drive lifecycle on the card).
|
||||
linked_task_id = str(getattr(linked_task, "id", "") or "").strip() if linked_task is not None else ""
|
||||
linked_session_id = str(getattr(linked_task, "session_id", "") or "").strip() if linked_task is not None else ""
|
||||
result = getattr(linked_task, "result", None) or {} if linked_task is not None else {}
|
||||
result_content = result.get("content") if isinstance(result, dict) else None
|
||||
artifacts = result.get("artifacts", []) if isinstance(result, dict) else []
|
||||
# Merge work-item metadata first, then linked-task metadata so the card
|
||||
# surfaces work-item-truth (status/dependencies) plus runtime telemetry.
|
||||
merged_metadata: dict[str, Any] = dict(normalized_metadata)
|
||||
if linked_task is not None:
|
||||
linked_meta = getattr(linked_task, "metadata", None) or {}
|
||||
if isinstance(linked_meta, dict):
|
||||
for key, value in linked_meta.items():
|
||||
merged_metadata.setdefault(key, value)
|
||||
|
||||
return BoardTaskView(
|
||||
task_id=work_item_id,
|
||||
title=title,
|
||||
description=summary,
|
||||
status=canonical_status,
|
||||
column_id=column_id,
|
||||
priority=None,
|
||||
assignee_ids=[role_id] if role_id else [],
|
||||
assigned_to=role_id,
|
||||
tags=[kind] if kind else [],
|
||||
session_id=linked_session_id or None,
|
||||
created_at=created_at,
|
||||
updated_at=updated_at,
|
||||
metadata=merged_metadata,
|
||||
pending_checkpoint=checkpoint,
|
||||
linked_task_count=1 if linked_task is not None else 0,
|
||||
result_content=result_content,
|
||||
artifacts=artifacts if isinstance(artifacts, list) else [artifacts],
|
||||
origin_task_id=None,
|
||||
display_id=f"OPC-{display_num}" if display_num else "",
|
||||
dependencies=dependencies,
|
||||
work_item_id=work_item_id,
|
||||
runtime_task_id=linked_task_id or None,
|
||||
execution_turn_id=linked_task_id or None,
|
||||
)
|
||||
|
||||
def _task_to_view(
|
||||
self,
|
||||
task: Any,
|
||||
*,
|
||||
checkpoint: PendingCheckpointView | None,
|
||||
linked_tasks: list[Any],
|
||||
display_num: int = 0,
|
||||
) -> BoardTaskView:
|
||||
payload = build_base_task_payload(task, display_num)
|
||||
result = getattr(task, "result", None) or {}
|
||||
result_content = result.get("content") if isinstance(result, dict) else None
|
||||
artifacts = result.get("artifacts", []) if isinstance(result, dict) else []
|
||||
metadata = getattr(task, "metadata", {}) if isinstance(getattr(task, "metadata", {}), dict) else {}
|
||||
return BoardTaskView(
|
||||
task_id=payload["task_id"],
|
||||
title=payload["title"],
|
||||
description=payload["description"],
|
||||
status=payload["status"],
|
||||
column_id=payload["column_id"],
|
||||
priority=payload["priority"],
|
||||
assignee_ids=payload["assignee_ids"],
|
||||
assigned_to=str(getattr(task, "assigned_to", "") or ""),
|
||||
tags=payload["tags"],
|
||||
session_id=payload["session_id"],
|
||||
created_at=float(payload["created_at"]),
|
||||
updated_at=float(payload["updated_at"]),
|
||||
metadata=metadata,
|
||||
pending_checkpoint=checkpoint,
|
||||
linked_task_count=len(linked_tasks),
|
||||
result_content=result_content,
|
||||
artifacts=artifacts if isinstance(artifacts, list) else [artifacts],
|
||||
origin_task_id=self._origin_task_id(task),
|
||||
display_id=str(payload.get("display_id", "") or ""),
|
||||
dependencies=list(payload.get("dependencies", []) or []),
|
||||
runtime_task_id=payload["task_id"],
|
||||
execution_turn_id=payload["task_id"],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _session_summary_view(task: BoardTaskView) -> SessionSummaryView:
|
||||
return SessionSummaryView(
|
||||
task_id=task.task_id,
|
||||
title=task.title,
|
||||
status=task.status,
|
||||
column_id=task.column_id,
|
||||
session_id=task.session_id,
|
||||
updated_at=float(task.updated_at),
|
||||
created_at=float(task.created_at),
|
||||
assigned_to=task.assigned_to,
|
||||
priority=task.priority,
|
||||
pending_checkpoint=task.pending_checkpoint is not None,
|
||||
linked_task_count=task.linked_task_count,
|
||||
tags=list(task.tags),
|
||||
runtime_task_id=task.runtime_task_id,
|
||||
execution_turn_id=task.execution_turn_id or task.runtime_task_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _alerts_from_tasks(tasks: list[BoardTaskView]) -> list[BoardAlert]:
|
||||
alerts: list[BoardAlert] = []
|
||||
for task in tasks:
|
||||
item_label = "Work item" if task.work_item_id else "Task"
|
||||
if task.pending_checkpoint:
|
||||
alerts.append(
|
||||
BoardAlert(
|
||||
alert_id=f"checkpoint:{task.task_id}",
|
||||
level="warn",
|
||||
title="Checkpoint pending",
|
||||
message=f"{task.title} is waiting for human feedback.",
|
||||
task_id=task.task_id,
|
||||
created_at=float(task.updated_at or task.created_at),
|
||||
)
|
||||
)
|
||||
if task.status in {"blocked", "awaiting_peer", "awaiting_review"}:
|
||||
alerts.append(
|
||||
BoardAlert(
|
||||
alert_id=f"blocked:{task.task_id}",
|
||||
level="warn",
|
||||
title=f"{item_label} paused",
|
||||
message=f"{task.title} is paused in `{task.status}`.",
|
||||
task_id=task.task_id,
|
||||
created_at=float(task.updated_at or task.created_at),
|
||||
)
|
||||
)
|
||||
if task.status in {"failed", "cancelled"}:
|
||||
alerts.append(
|
||||
BoardAlert(
|
||||
alert_id=f"terminal:{task.task_id}",
|
||||
level="error",
|
||||
title=f"{item_label} ended abnormally",
|
||||
message=f"{task.title} finished with status `{task.status}`.",
|
||||
task_id=task.task_id,
|
||||
created_at=float(task.updated_at or task.created_at),
|
||||
)
|
||||
)
|
||||
level_weight = {"error": 0, "warn": 1, "info": 2}
|
||||
alerts.sort(key=lambda alert: (level_weight.get(alert.level, 99), -float(alert.created_at)))
|
||||
return alerts[:12]
|
||||
|
||||
@staticmethod
|
||||
def _build_metrics(
|
||||
tasks: list[BoardTaskView],
|
||||
*,
|
||||
hidden_count: int,
|
||||
pending_checkpoint_count: int,
|
||||
) -> BoardMetrics:
|
||||
todo_count = sum(1 for task in tasks if task.column_id == "todo")
|
||||
in_progress_count = sum(1 for task in tasks if task.column_id == "in-progress")
|
||||
in_review_count = sum(1 for task in tasks if task.column_id == "in-review")
|
||||
done_count = sum(1 for task in tasks if task.column_id == "done")
|
||||
blocked_count = sum(1 for task in tasks if task.status in {"blocked", "awaiting_peer", "awaiting_review"})
|
||||
failed_count = sum(1 for task in tasks if task.status in {"failed", "cancelled"})
|
||||
active_session_count = sum(1 for task in tasks if task.session_id)
|
||||
running_count = sum(
|
||||
1
|
||||
for task in tasks
|
||||
if task.status in {"running", "idle", "blocked", "awaiting_peer", "awaiting_review"}
|
||||
)
|
||||
return BoardMetrics(
|
||||
total_tasks=len(tasks) + hidden_count,
|
||||
visible_tasks=len(tasks),
|
||||
filtered_tasks=len(tasks),
|
||||
hidden_task_count=hidden_count,
|
||||
todo_count=todo_count,
|
||||
in_progress_count=in_progress_count,
|
||||
in_review_count=in_review_count,
|
||||
done_count=done_count,
|
||||
running_count=running_count,
|
||||
blocked_count=blocked_count,
|
||||
failed_count=failed_count,
|
||||
pending_checkpoint_count=pending_checkpoint_count,
|
||||
active_session_count=active_session_count,
|
||||
alert_count=len(BoardRepository._alerts_from_tasks(tasks)),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _linked_execution_view(task: Any) -> LinkedExecutionView:
|
||||
payload = build_base_task_payload(task, 0)
|
||||
return LinkedExecutionView(
|
||||
task_id=payload["task_id"],
|
||||
title=payload["title"],
|
||||
status=payload["status"],
|
||||
assigned_to=str(getattr(task, "assigned_to", "") or ""),
|
||||
session_id=payload["session_id"],
|
||||
created_at=float(payload["created_at"]),
|
||||
updated_at=float(payload["updated_at"]),
|
||||
runtime_task_id=payload["task_id"],
|
||||
execution_turn_id=payload["task_id"],
|
||||
metadata=dict(getattr(task, "metadata", {}) or {}),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _render_parts(parts: list[Any]) -> str:
|
||||
lines: list[str] = []
|
||||
for part in parts:
|
||||
part_type = getattr(part, "part_type", "")
|
||||
payload = getattr(part, "payload", {}) if isinstance(getattr(part, "payload", {}), dict) else {}
|
||||
if part_type == "text":
|
||||
text = payload.get("text", "")
|
||||
if text:
|
||||
lines.append(str(text))
|
||||
elif part_type in {"subtask_result", "task_result"}:
|
||||
title = payload.get("task_title", "Task")
|
||||
summary = payload.get("summary", "")
|
||||
lines.append(f"{title}: {summary}".strip(": "))
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
def _transcript_item_to_view(self, item: dict[str, Any]) -> SessionMessageView | None:
|
||||
message = item.get("message")
|
||||
if not message or getattr(message, "summary_flag", False):
|
||||
return None
|
||||
|
||||
content = self._render_parts(item.get("parts", []))
|
||||
if not content:
|
||||
return None
|
||||
|
||||
role = str(getattr(message, "role", "") or "").strip().lower()
|
||||
agent_id = str(getattr(message, "agent_id", "") or "").strip()
|
||||
sender_name = {
|
||||
"user": "You",
|
||||
"assistant": "OPC",
|
||||
"system": "System",
|
||||
"subagent": agent_id.replace("_", " ").replace("-", " ").title() if agent_id else "Subagent",
|
||||
}.get(role, agent_id or role.title() or "OPC")
|
||||
created_at = getattr(message, "created_at", None)
|
||||
timestamp = created_at.timestamp() if hasattr(created_at, "timestamp") else 0.0
|
||||
return SessionMessageView(
|
||||
message_id=str(getattr(message, "message_id", "") or ""),
|
||||
role=role or "assistant",
|
||||
sender_name=sender_name,
|
||||
content=content,
|
||||
created_at=timestamp,
|
||||
metadata={"agent_id": agent_id} if agent_id else {},
|
||||
)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Engine lifecycle management for the CLI board."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from opc.core.config import OPCConfig, get_opc_home
|
||||
from opc.engine import OPCEngine
|
||||
|
||||
ProgressCallback = Callable[[str], Awaitable[None] | Awaitable[Any]]
|
||||
EventCallback = Callable[[Any], Awaitable[None]]
|
||||
|
||||
|
||||
class EngineFacade:
|
||||
"""Owns a single lazily-initialized OPCEngine for the CLI board process."""
|
||||
|
||||
def __init__(self, project_id: str | None = None) -> None:
|
||||
self.project_id = project_id
|
||||
self._config: OPCConfig | None = None
|
||||
self._engine: OPCEngine | None = None
|
||||
self._progress_callback: Callable[..., Awaitable[None]] | None = None
|
||||
self._event_callback: EventCallback | None = None
|
||||
self._init_lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def engine(self) -> OPCEngine | None:
|
||||
return self._engine
|
||||
|
||||
@property
|
||||
def store(self): # noqa: ANN201 - preserve simple property for callers
|
||||
return self._engine.store if self._engine else None
|
||||
|
||||
@property
|
||||
def opc_home(self) -> Path:
|
||||
return get_opc_home()
|
||||
|
||||
def configure_callbacks(
|
||||
self,
|
||||
*,
|
||||
progress_callback: Callable[..., Awaitable[None]] | None = None,
|
||||
event_callback: EventCallback | None = None,
|
||||
) -> None:
|
||||
self._progress_callback = progress_callback
|
||||
self._event_callback = event_callback
|
||||
if self._engine is not None and progress_callback is not None:
|
||||
self._engine.on_progress = progress_callback
|
||||
|
||||
async def ensure_ready(self) -> OPCEngine:
|
||||
if self._engine is not None:
|
||||
return self._engine
|
||||
|
||||
async with self._init_lock:
|
||||
if self._engine is not None:
|
||||
return self._engine
|
||||
|
||||
config_dir = get_opc_home() / "config"
|
||||
self._config = OPCConfig.load(config_dir) if config_dir.exists() else OPCConfig()
|
||||
engine = OPCEngine(
|
||||
config=self._config,
|
||||
project_id=self.project_id,
|
||||
on_progress=self._progress_callback,
|
||||
)
|
||||
await engine.initialize()
|
||||
if self._event_callback is not None:
|
||||
engine.event_bus.subscribe_all(self._event_callback)
|
||||
self._engine = engine
|
||||
return engine
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
if self._engine is None:
|
||||
return
|
||||
engine = self._engine
|
||||
self._engine = None
|
||||
await engine.shutdown()
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Translate OPC engine callbacks into board-friendly runtime updates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from opc.presentation.kanban import STATUS_TO_COLUMN
|
||||
|
||||
BoardEventSink = Callable[[dict[str, Any]], Awaitable[None]]
|
||||
|
||||
_TOOL_RE = re.compile(r"^\[Tool:\s*([^\]]+)\]")
|
||||
|
||||
|
||||
class CliBoardEventBridge:
|
||||
"""Bridges in-process engine events to the board state layer."""
|
||||
|
||||
def __init__(self, sink: BoardEventSink) -> None:
|
||||
self._sink = sink
|
||||
|
||||
async def handle_event(self, event: Any) -> None:
|
||||
event_type = str(getattr(event, "event_type", "") or "")
|
||||
payload = getattr(event, "payload", {}) or {}
|
||||
|
||||
if event_type == "task_status_changed":
|
||||
task_id = str(payload.get("task_id", "") or "")
|
||||
status = str(payload.get("status", "") or "")
|
||||
if task_id and status:
|
||||
await self._sink(
|
||||
{
|
||||
"kind": "task_status",
|
||||
"task_id": task_id,
|
||||
"status": status,
|
||||
"column_id": STATUS_TO_COLUMN.get(status, "todo"),
|
||||
}
|
||||
)
|
||||
if status in {"awaiting_review", "awaiting_peer", "blocked", "failed", "cancelled", "done"}:
|
||||
await self._sink({"kind": "refresh", "reason": f"status:{status}"})
|
||||
return
|
||||
|
||||
if event_type in {"task_created", "child_session_created", "escalation_created"}:
|
||||
await self._sink({"kind": "refresh", "reason": event_type})
|
||||
return
|
||||
|
||||
if event_type == "agent_status_changed":
|
||||
task_id = str(payload.get("task_id", "") or "")
|
||||
status = str(payload.get("status", "") or "")
|
||||
if task_id and status:
|
||||
await self._sink(
|
||||
{
|
||||
"kind": "runtime",
|
||||
"task_id": task_id,
|
||||
"status": status,
|
||||
"current_tool": None,
|
||||
"iteration": None,
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
if event_type == "runtime_event":
|
||||
task_id = str(payload.get("task_id", "") or "")
|
||||
runtime_type = str(payload.get("type", "") or "").strip()
|
||||
if not task_id or not runtime_type:
|
||||
return
|
||||
if runtime_type == "status_snapshot":
|
||||
await self._sink(
|
||||
{
|
||||
"kind": "runtime",
|
||||
"task_id": task_id,
|
||||
"status": "tool_active" if payload.get("current_tool") else "reflecting",
|
||||
"current_tool": payload.get("current_tool"),
|
||||
"iteration": payload.get("iteration"),
|
||||
"tool_elapsed_ms": payload.get("tool_elapsed_ms"),
|
||||
"last_tool_summary": payload.get("last_tool_summary"),
|
||||
"context_tokens": payload.get("context_tokens"),
|
||||
"context_window": payload.get("context_window"),
|
||||
"context_remaining_pct": payload.get("context_remaining_pct"),
|
||||
"turn_cost_usd": payload.get("turn_cost_usd"),
|
||||
"session_cost_usd": payload.get("session_cost_usd"),
|
||||
"pending_permission_count": payload.get("pending_permission_count"),
|
||||
"drain_mode": payload.get("drain_mode"),
|
||||
}
|
||||
)
|
||||
return
|
||||
if runtime_type in {"tool_started", "tool_progress", "tool_completed", "tool_skipped", "permission_requested", "permission_resolved"}:
|
||||
await self._sink(
|
||||
{
|
||||
"kind": "runtime",
|
||||
"task_id": task_id,
|
||||
"status": "tool_active" if runtime_type in {"tool_started", "tool_progress"} else "reflecting",
|
||||
"current_tool": payload.get("tool_name"),
|
||||
"iteration": payload.get("iteration"),
|
||||
"tool_elapsed_ms": payload.get("elapsed_ms"),
|
||||
"last_tool_summary": payload.get("result_summary") or payload.get("message"),
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
if event_type == "agent_log":
|
||||
task_id = str(payload.get("task_id", "") or "")
|
||||
if not task_id:
|
||||
return
|
||||
status = str(payload.get("status", "") or "")
|
||||
iteration = payload.get("iteration")
|
||||
current_tool = None
|
||||
runtime_status = status
|
||||
if status == "thinking":
|
||||
runtime_status = "reflecting"
|
||||
current_tool = "Reflect"
|
||||
elif status == "executing":
|
||||
current_tool = str(payload.get("tool", "") or "") or None
|
||||
runtime_status = "tool_active"
|
||||
await self._sink(
|
||||
{
|
||||
"kind": "runtime",
|
||||
"task_id": task_id,
|
||||
"status": runtime_status,
|
||||
"current_tool": current_tool,
|
||||
"iteration": int(iteration) if isinstance(iteration, int) else None,
|
||||
}
|
||||
)
|
||||
|
||||
async def handle_progress(self, text: str, *, task_id: str | None = None, **_: Any) -> None:
|
||||
if not task_id:
|
||||
return
|
||||
current_tool = None
|
||||
match = _TOOL_RE.match(str(text or "").strip())
|
||||
if match:
|
||||
current_tool = match.group(1).strip() or None
|
||||
await self._sink(
|
||||
{
|
||||
"kind": "progress",
|
||||
"task_id": task_id,
|
||||
"text": str(text or ""),
|
||||
"current_tool": current_tool,
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Periodic reconcile loop for cross-process board consistency."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
|
||||
class ReconcileLoop:
|
||||
"""Run a callback periodically until stopped."""
|
||||
|
||||
def __init__(self, interval_seconds: float, callback: Callable[[], Awaitable[None]]) -> None:
|
||||
self.interval_seconds = max(0.5, float(interval_seconds))
|
||||
self._callback = callback
|
||||
self._stop_event = asyncio.Event()
|
||||
|
||||
async def run(self) -> None:
|
||||
while not self._stop_event.is_set():
|
||||
await self._callback()
|
||||
try:
|
||||
await asyncio.wait_for(self._stop_event.wait(), timeout=self.interval_seconds)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop_event.set()
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"""CLI-board-specific company runtime recovery manager.
|
||||
|
||||
Independent from office_ui/recovery_manager.py — same Core Engine APIs,
|
||||
different notification path (TUI event bridge instead of WebSocket broadcast).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from opc.layer2_organization.work_item_identity import work_item_projection_id_from_metadata
|
||||
from opc.layer2_organization.work_item_transition import apply_task_status_transition
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .engine_facade import EngineFacade
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecoverableWorkItem:
|
||||
projection_id: str
|
||||
title: str
|
||||
task_id: str
|
||||
status: str
|
||||
interrupted: bool
|
||||
previous_status: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class InterruptedCompanyRuntime:
|
||||
parent_session_id: str
|
||||
parent_task_id: str
|
||||
project_id: str
|
||||
title: str
|
||||
profile: str
|
||||
interrupted_at: str
|
||||
work_items: list[RecoverableWorkItem] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecoveryStatus:
|
||||
interrupted: list[InterruptedCompanyRuntime] = field(default_factory=list)
|
||||
active_recoveries: list[str] = field(default_factory=list)
|
||||
scanned_at: float = 0.0
|
||||
|
||||
|
||||
def _is_interrupted(task: Any) -> bool:
|
||||
from opc.core.models import TaskStatus
|
||||
if task.status != TaskStatus.FAILED:
|
||||
return False
|
||||
meta = getattr(task, "metadata", {}) or {}
|
||||
if meta.get("interrupted_recovery"):
|
||||
return True
|
||||
result = getattr(task, "result", {}) or {}
|
||||
artifacts = result.get("artifacts", {}) or {}
|
||||
return bool(artifacts.get("interrupted"))
|
||||
|
||||
|
||||
class CliRecoveryManager:
|
||||
"""Scan for interrupted company runtimes and provide resume/cancel."""
|
||||
|
||||
_CACHE_TTL = 10.0
|
||||
|
||||
def __init__(self, facade: EngineFacade) -> None:
|
||||
self._facade = facade
|
||||
self._lock = asyncio.Lock()
|
||||
self._active: dict[str, asyncio.Task[Any]] = {}
|
||||
self._cached: RecoveryStatus | None = None
|
||||
self._cache_until: float = 0.0
|
||||
|
||||
@property
|
||||
def _project_id(self) -> str:
|
||||
return self._facade.project_id or "default"
|
||||
|
||||
async def get_status(self) -> RecoveryStatus:
|
||||
now = time.time()
|
||||
if self._cached is not None and now < self._cache_until:
|
||||
self._cached.active_recoveries = list(self._active.keys())
|
||||
return self._cached
|
||||
status = await self.scan()
|
||||
self._cached = status
|
||||
self._cache_until = now + self._CACHE_TTL
|
||||
return status
|
||||
|
||||
async def scan(self) -> RecoveryStatus:
|
||||
engine = await self._facade.ensure_ready()
|
||||
if not engine.store:
|
||||
return RecoveryStatus()
|
||||
|
||||
try:
|
||||
all_tasks = await engine.store.get_tasks(project_id=self._project_id)
|
||||
except Exception as exc:
|
||||
logger.warning("Recovery scan failed: %s", exc)
|
||||
return RecoveryStatus()
|
||||
|
||||
groups: dict[str, list[Any]] = {}
|
||||
tasks_by_session: dict[str, Any] = {}
|
||||
for task in all_tasks:
|
||||
sid = str(getattr(task, "session_id", "") or "").strip()
|
||||
if sid:
|
||||
tasks_by_session[sid] = task
|
||||
parent_sid = str(getattr(task, "parent_session_id", "") or "").strip()
|
||||
projection_id = work_item_projection_id_from_metadata(getattr(task, "metadata", {}) or {})
|
||||
if parent_sid and projection_id:
|
||||
groups.setdefault(parent_sid, []).append(task)
|
||||
|
||||
from opc.core.models import TaskStatus
|
||||
interrupted: list[InterruptedCompanyRuntime] = []
|
||||
|
||||
for parent_sid, tasks in groups.items():
|
||||
if not any(_is_interrupted(t) for t in tasks):
|
||||
continue
|
||||
non_terminal = [t for t in tasks if t.status not in (TaskStatus.DONE, TaskStatus.CANCELLED)]
|
||||
if not non_terminal:
|
||||
continue
|
||||
|
||||
parent_task = tasks_by_session.get(parent_sid)
|
||||
parent_task_id = parent_task.id if parent_task else parent_sid
|
||||
title = parent_task.title if parent_task else "Unknown company runtime"
|
||||
|
||||
work_items: list[RecoverableWorkItem] = []
|
||||
earliest = ""
|
||||
for t in sorted(tasks, key=lambda x: (x.created_at, x.id)):
|
||||
meta = dict(getattr(t, "metadata", {}) or {})
|
||||
rmeta = meta.get("interrupted_recovery", {})
|
||||
is_int = _is_interrupted(t)
|
||||
if is_int and rmeta.get("detected_at", ""):
|
||||
det = rmeta["detected_at"]
|
||||
if not earliest or det < earliest:
|
||||
earliest = det
|
||||
work_items.append(RecoverableWorkItem(
|
||||
projection_id=work_item_projection_id_from_metadata(meta, fallback=t.id),
|
||||
title=t.title,
|
||||
task_id=t.id,
|
||||
status=t.status.value if hasattr(t.status, "value") else str(t.status),
|
||||
interrupted=is_int,
|
||||
previous_status=rmeta.get("previous_status", ""),
|
||||
))
|
||||
|
||||
profile = ""
|
||||
for t in tasks:
|
||||
p = (getattr(t, "metadata", {}) or {}).get("company_profile", "")
|
||||
if p:
|
||||
profile = p
|
||||
break
|
||||
|
||||
interrupted.append(InterruptedCompanyRuntime(
|
||||
parent_session_id=parent_sid,
|
||||
parent_task_id=parent_task_id,
|
||||
project_id=self._project_id,
|
||||
title=title,
|
||||
profile=profile,
|
||||
interrupted_at=earliest or datetime.now().isoformat(),
|
||||
work_items=work_items,
|
||||
))
|
||||
|
||||
return RecoveryStatus(
|
||||
interrupted=interrupted,
|
||||
active_recoveries=list(self._active.keys()),
|
||||
scanned_at=time.time(),
|
||||
)
|
||||
|
||||
async def resume(self, parent_task_id: str) -> dict[str, Any]:
|
||||
async with self._lock:
|
||||
if parent_task_id in self._active:
|
||||
return {"ok": False, "error": "already_in_progress"}
|
||||
|
||||
status = await self.scan()
|
||||
wf = next((w for w in status.interrupted if w.parent_task_id == parent_task_id), None)
|
||||
if not wf:
|
||||
return {"ok": False, "error": "not_found"}
|
||||
|
||||
engine = await self._facade.ensure_ready()
|
||||
snapshot = await engine._load_company_runtime_snapshot(wf.parent_session_id)
|
||||
if not snapshot:
|
||||
return {"ok": False, "error": "snapshot_unavailable"}
|
||||
|
||||
plan, tasks = snapshot
|
||||
|
||||
await self._clean_checkpoints(wf, tasks)
|
||||
|
||||
from opc.core.models import TaskStatus
|
||||
resumed_ids: list[str] = []
|
||||
for task in tasks:
|
||||
if task.status == TaskStatus.DONE:
|
||||
continue
|
||||
if task.status in (TaskStatus.FAILED, TaskStatus.BLOCKED):
|
||||
task.result = None
|
||||
task.execution_lock = False
|
||||
task.execution_locked_at = None
|
||||
meta = dict(task.metadata)
|
||||
meta.pop("interrupted_recovery", None)
|
||||
progress = list(meta.get("progress_log", []))
|
||||
progress.append(f"[Recovery] Resumed at {datetime.now().isoformat()}")
|
||||
meta["progress_log"] = progress[-20:]
|
||||
task.metadata = meta
|
||||
try:
|
||||
await apply_task_status_transition(
|
||||
engine.store,
|
||||
task,
|
||||
target_status_or_phase=TaskStatus.PENDING,
|
||||
reason="cli_recovery_resume",
|
||||
release_claim=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Recovery resume skipped %s: %s", task.id, exc)
|
||||
continue
|
||||
if task.status != TaskStatus.PENDING:
|
||||
logger.warning("Recovery resume preserved non-runnable phase for %s", task.id)
|
||||
continue
|
||||
await engine.store.save_task(task)
|
||||
resumed_ids.append(work_item_projection_id_from_metadata(meta, fallback=task.id))
|
||||
|
||||
if not resumed_ids:
|
||||
return {"ok": False, "error": "no_work_items_to_resume"}
|
||||
|
||||
self._cache_until = 0.0
|
||||
bg = asyncio.create_task(self._execute(parent_task_id, plan, tasks))
|
||||
self._active[parent_task_id] = bg
|
||||
return {"ok": True, "resumed_work_item_projection_ids": resumed_ids}
|
||||
|
||||
async def cancel(self, parent_task_id: str) -> dict[str, Any]:
|
||||
async with self._lock:
|
||||
bg = self._active.pop(parent_task_id, None)
|
||||
if bg and not bg.done():
|
||||
bg.cancel()
|
||||
|
||||
status = await self.scan()
|
||||
wf = next((w for w in status.interrupted if w.parent_task_id == parent_task_id), None)
|
||||
if not wf:
|
||||
return {"ok": False, "error": "not_found"}
|
||||
|
||||
engine = await self._facade.ensure_ready()
|
||||
snapshot = await engine._load_company_runtime_snapshot(wf.parent_session_id)
|
||||
if not snapshot:
|
||||
return {"ok": False, "error": "snapshot_unavailable"}
|
||||
|
||||
_, tasks = snapshot
|
||||
from opc.core.models import TaskStatus
|
||||
cancelled = 0
|
||||
for task in tasks:
|
||||
if task.status not in (TaskStatus.DONE, TaskStatus.CANCELLED):
|
||||
try:
|
||||
await apply_task_status_transition(
|
||||
engine.store,
|
||||
task,
|
||||
target_status_or_phase=TaskStatus.CANCELLED,
|
||||
reason="cli_recovery_cancel",
|
||||
release_claim=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Recovery cancel skipped %s: %s", task.id, exc)
|
||||
continue
|
||||
if task.status != TaskStatus.CANCELLED:
|
||||
logger.warning("Recovery cancel preserved non-cancelled phase for %s", task.id)
|
||||
continue
|
||||
cancelled += 1
|
||||
|
||||
await self._clean_checkpoints(wf, tasks)
|
||||
self._cache_until = 0.0
|
||||
return {"ok": True, "cancelled_count": cancelled}
|
||||
|
||||
async def _execute(self, parent_task_id: str, plan: Any, tasks: list[Any]) -> None:
|
||||
try:
|
||||
engine = await self._facade.ensure_ready()
|
||||
executor = engine.company_executor
|
||||
if not executor:
|
||||
raise RuntimeError("company_executor not available")
|
||||
await executor.execute(plan, tasks)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.warning("Recovery execution failed for %s: %s", parent_task_id, exc)
|
||||
finally:
|
||||
self._active.pop(parent_task_id, None)
|
||||
self._cache_until = 0.0
|
||||
|
||||
async def _clean_checkpoints(self, wf: InterruptedCompanyRuntime, tasks: list[Any]) -> None:
|
||||
engine = await self._facade.ensure_ready()
|
||||
if not engine.store:
|
||||
return
|
||||
session_ids = {str(getattr(t, "session_id", "") or "").strip() for t in tasks}
|
||||
session_ids.add(wf.parent_session_id)
|
||||
session_ids.discard("")
|
||||
try:
|
||||
pending = await engine.store.get_pending_checkpoints(project_id=wf.project_id)
|
||||
for cp in pending:
|
||||
if str(cp.session_id or "").strip() in session_ids:
|
||||
await engine.store.resolve_execution_checkpoint(cp.checkpoint_id, status="cancelled")
|
||||
except Exception as exc:
|
||||
logger.debug("Checkpoint cleanup error: %s", exc)
|
||||
Reference in New Issue
Block a user