Initial commit

This commit is contained in:
LZH-YS1998
2026-07-01 17:56:31 +08:00
commit d78931979d
731 changed files with 311088 additions and 0 deletions
@@ -0,0 +1,2 @@
"""Service layer for the CLI board plugin."""
+287
View File
@@ -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()
+297
View File
@@ -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)