fix: unify company runtime recovery lifecycle

This commit is contained in:
LZH-YS1998
2026-07-14 14:35:43 +08:00
parent 5e02364eb4
commit b8202bbe9e
56 changed files with 8753 additions and 3542 deletions
+256 -232
View File
@@ -2118,33 +2118,6 @@ def runtime_run(task_id: str = typer.Argument(...), project: Optional[str] = typ
asyncio.run(_run_service_command(project, lambda svc: svc.runtime.run_task(project_id=project or "default", task_id=task_id), json_output=json_output))
recovery_app = typer.Typer(help="Recover interrupted company runtimes")
app.add_typer(recovery_app, name="recovery")
@recovery_app.command("scan")
def recovery_scan(project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
asyncio.run(_run_service_command(project, lambda svc: svc.runtime.recovery_scan(project_id=project or "default"), json_output=json_output))
@recovery_app.command("resume")
def recovery_resume(parent_task_id: str = typer.Argument(...), project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
asyncio.run(_run_service_command(project, lambda svc: svc.runtime.recovery_action(project_id=project or "default", action="resume", parent_task_id=parent_task_id), json_output=json_output))
@recovery_app.command("cancel")
def recovery_cancel(parent_task_id: str = typer.Argument(...), yes: bool = typer.Option(False, "--yes", "-y"), project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
if not yes:
console.print("[warning]Destructive command requires --yes.[/warning]")
raise typer.Exit(code=1)
asyncio.run(_run_service_command(project, lambda svc: svc.runtime.recovery_action(project_id=project or "default", action="cancel", parent_task_id=parent_task_id), json_output=json_output))
@recovery_app.command("retry")
def recovery_retry(parent_task_id: str = typer.Argument(...), project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
asyncio.run(_run_service_command(project, lambda svc: svc.runtime.recovery_action(project_id=project or "default", action="retry", parent_task_id=parent_task_id), json_output=json_output))
comms_app = typer.Typer(help="Inspect company-mode comms")
app.add_typer(comms_app, name="comms")
@@ -2834,6 +2807,17 @@ class ChatTurnController:
self._closing = True
self.queue.clear()
await self.stop_kanban_watch(silent=True)
prepare = getattr(
self.state.engine,
"prepare_active_company_runtimes_for_shutdown",
None,
)
if callable(prepare):
# Persist company checkpoints and put the engine into
# infrastructure-shutdown mode before cancelling the active turn.
# Engine.shutdown() repeats this idempotently when it closes the
# remaining stores and subsystems.
await prepare()
task = self.active_task
if task is None or task.done():
return
@@ -2994,8 +2978,6 @@ _SLASH_COMMANDS: tuple[_SlashCommandSpec, ...] = (
_SlashCommandSpec("Tasks", "/task rename <task_id> <title>", "Rename a task and its session title."),
_SlashCommandSpec("Tasks", "/task delete <task_id> --yes", "Hard-delete a task and its persisted lifecycle data."),
_SlashCommandSpec("Runtime", "/runtime [--limit N] [--full]", "Show live runtime, active tasks, external sessions, and checkpoints."),
_SlashCommandSpec("Runtime", "/recover [--limit N] [--full]", "Show interrupted runtime and resumable checkpoints.", ("scan", "resume", "cancel", "retry")),
_SlashCommandSpec("Runtime", "/recover resume|cancel|retry <parent_task_id>", "Act on an interrupted company runtime."),
_SlashCommandSpec("Runtime", "/logs <task_id|session_id> [--limit N] [--full]", "Show execution logs, runtime events, tools, and transcript."),
_SlashCommandSpec("Runtime", "/comms <task_id> [--limit N] [--full]", "Show company-mode messages, handoffs, and review notes."),
_SlashCommandSpec("Runtime", "/attachments [--limit N] [--full]", "List current session attachment references."),
@@ -3027,7 +3009,7 @@ _SLASH_COMMANDS: tuple[_SlashCommandSpec, ...] = (
_SlashCommandSpec("Diagnostics", "/checkpoints [--limit N] [--full]", "List pending execution checkpoints."),
)
CommandSpec = _SlashCommandSpec
_SLASH_ALIASES = {"p": "project", "s": "session", "t": "task", "checkpoint": "checkpoints", "recovery": "recover", "work-item": "work-items", "workitems": "work-items"}
_SLASH_ALIASES = {"p": "project", "s": "session", "t": "task", "checkpoint": "checkpoints", "work-item": "work-items", "workitems": "work-items"}
def _initial_company_profile(config: OPCConfig) -> str:
@@ -3862,6 +3844,8 @@ async def _resolve_session_or_task(state: _InteractiveChatState, token: str) ->
async def _resolve_runtime_control_target(state: _InteractiveChatState, target: str = "") -> tuple[str, str]:
from opc.layer2_organization.company_runtime_identity import load_company_runtime_identity_index
store = _require_chat_store(state, label="Session store")
if store is None:
return "", ""
@@ -3869,24 +3853,38 @@ async def _resolve_runtime_control_target(state: _InteractiveChatState, target:
if not raw_target:
console.print("[warning]No current session. Use /session list or /session create first.[/warning]")
return "", ""
project_id = _current_project_id(state.engine)
identity_index = await load_company_runtime_identity_index(store, project_id)
task = await store.get_task(raw_target) if hasattr(store, "get_task") else None
if task is not None:
project_id = str(getattr(task, "project_id", "") or "default")
if project_id != _current_project_id(state.engine):
console.print(f"[warning]Target belongs to project '{project_id}'. Switch project first.[/warning]")
task_project_id = str(getattr(task, "project_id", "") or "default")
if task_project_id != project_id:
console.print(f"[warning]Target belongs to project '{task_project_id}'. Switch project first.[/warning]")
return "", ""
runtime_identity = identity_index.resolve(task_id=raw_target)
if runtime_identity is not None:
return raw_target, runtime_identity.runtime_session_id
return str(getattr(task, "id", "") or ""), str(getattr(task, "session_id", "") or getattr(task, "parent_session_id", "") or "")
runtime_identity = (
identity_index.resolve(runtime_session_id=raw_target)
or identity_index.resolve(task_session_id=raw_target)
)
if runtime_identity is not None:
control_task_id = (
runtime_identity.ui_anchor_task_id
or runtime_identity.config_source_task_id
)
if control_task_id:
return control_task_id, runtime_identity.runtime_session_id
session = await store.get_session(raw_target) if hasattr(store, "get_session") else None
if session is None:
console.print(f"[warning]Task or session not found: {raw_target}[/warning]")
return "", ""
project_id = str(getattr(session, "project_id", "") or "default")
if project_id != _current_project_id(state.engine):
console.print(f"[warning]Target belongs to project '{project_id}'. Switch project first.[/warning]")
session_project_id = str(getattr(session, "project_id", "") or "default")
if session_project_id != project_id:
console.print(f"[warning]Target belongs to project '{session_project_id}'. Switch project first.[/warning]")
return "", ""
if raw_target in state.session_to_task:
return state.session_to_task[raw_target], raw_target
tasks = await store.get_tasks(project_id=project_id) if hasattr(store, "get_tasks") else []
tasks = list(identity_index.tasks)
candidates = [
item for item in tasks
if str(getattr(item, "session_id", "") or "") == raw_target
@@ -3894,8 +3892,15 @@ async def _resolve_runtime_control_target(state: _InteractiveChatState, target:
if not candidates:
console.print(f"[warning]Session is not task-backed: {raw_target}[/warning]")
return "", raw_target
candidates.sort(key=lambda item: bool(str(getattr(item, "parent_session_id", "") or "")))
return str(getattr(candidates[0], "id", "") or ""), raw_target
task_mode_anchor = min(
candidates,
key=lambda item: (
bool(str(getattr(item, "parent_session_id", "") or "")),
str(getattr(item, "created_at", "") or ""),
str(getattr(item, "id", "") or ""),
),
)
return str(getattr(task_mode_anchor, "id", "") or ""), raw_target
def _make_cli_runtime_control_context(state: _InteractiveChatState, controller: ChatTurnController | None = None) -> Any:
@@ -3923,35 +3928,66 @@ def _make_cli_runtime_control_context(state: _InteractiveChatState, controller:
return context
async def _latest_company_suspend_checkpoint(state: _InteractiveChatState) -> Any | None:
session_id = str(state.session_id or "").strip()
if not session_id:
return None
for name in ("get_active_company_runtime_suspend_checkpoint", "get_pending_company_runtime_suspend_checkpoint"):
getter = getattr(state.engine, name, None)
if callable(getter):
try:
checkpoint = await getter(session_id)
except Exception:
checkpoint = None
if checkpoint is not None and str(getattr(checkpoint, "status", "") or "pending") == "pending":
return checkpoint
async def _company_runtime_identity_for_session(
state: _InteractiveChatState,
session_id: str | None = None,
) -> Any | None:
from opc.layer2_organization.company_runtime_identity import (
load_company_runtime_identity_index,
)
runtime_session_id = str(session_id or state.session_id or "").strip()
store = getattr(state.engine, "store", None)
if store is not None and hasattr(store, "get_pending_checkpoints"):
try:
checkpoints = await store.get_pending_checkpoints(
project_id=_current_project_id(state.engine),
session_id=session_id,
checkpoint_types=["company_runtime_suspended", "company_runtime_interrupted"],
)
except TypeError:
checkpoints = await store.get_pending_checkpoints(project_id=_current_project_id(state.engine), session_id=session_id)
except Exception:
checkpoints = []
for checkpoint in list(checkpoints or []):
if str(getattr(checkpoint, "checkpoint_type", "") or "") in {"company_runtime_suspended", "company_runtime_interrupted"}:
return checkpoint
return None
if not runtime_session_id or store is None:
return None
try:
identity_index = await load_company_runtime_identity_index(
store,
_current_project_id(state.engine),
)
# The interactive session may be a role/work-item session. Resolve it
# as a task session after trying the canonical root session so both UI
# roots and child channels converge on the same company runtime.
identity = (
identity_index.resolve(runtime_session_id=runtime_session_id)
or identity_index.resolve(task_session_id=runtime_session_id)
)
except Exception:
return None
return identity
async def _latest_company_suspend_checkpoint(
state: _InteractiveChatState,
session_id: str | None = None,
) -> Any | None:
identity = await _company_runtime_identity_for_session(state, session_id)
return identity.checkpoint if identity is not None and identity.resumable else None
async def _company_runtime_execution_identity(
state: _InteractiveChatState,
runtime_identity: Any,
) -> Any:
"""Read execution configuration from the durable runtime config source."""
from opc.plugins.office_ui.execution_identity import execution_identity_from_task
config_source_task_id = str(
getattr(runtime_identity, "config_source_task_id", "") or ""
).strip()
store = getattr(state.engine, "store", None)
if not config_source_task_id or store is None or not hasattr(store, "get_task"):
raise RuntimeError("Company runtime has no durable configuration source.")
config_task = await store.get_task(config_source_task_id)
if config_task is None:
raise RuntimeError("Company runtime configuration source no longer exists.")
return execution_identity_from_task(
config_task,
default_exec_mode=state.mode,
default_company_profile=state.company_profile,
default_preferred_agent=state.preferred_agent or "native",
default_org_id=state.org_id,
)
async def _handle_stop_slash(state: _InteractiveChatState, args: list[str], controller: ChatTurnController | None = None) -> None:
@@ -3972,22 +4008,35 @@ async def _handle_stop_slash(state: _InteractiveChatState, args: list[str], cont
return
payload = dict(result.payload)
state.runtime_control_state = str(payload.get("runtime_control_state") or payload.get("status") or "stopped")
state.runtime_control_task_id = str(payload.get("resume_parent_task_id") or payload.get("task_id") or task_id)
state.runtime_control_task_id = str(payload.get("task_id") or task_id)
state.runtime_control_session_id = str(payload.get("resume_parent_session_id") or payload.get("session_id") or session_id)
state.runtime_control_checkpoint_id = str(payload.get("checkpoint_id") or "")
console.print("[success]Stopped.[/success] [dim]Send a message to revise, or /continue to resume.[/dim]")
async def _runtime_control_identity_for_task(state: _InteractiveChatState, task_id: str) -> Any:
async def _runtime_control_execution_identity(state: _InteractiveChatState, task_id: str) -> Any:
from opc.layer2_organization.company_runtime_identity import load_company_runtime_identity_index
from opc.plugins.office_ui.execution_identity import execution_identity_from_task
store = getattr(state.engine, "store", None)
task = None
if store is not None and hasattr(store, "get_task") and task_id:
if store is not None and task_id:
try:
task = await store.get_task(task_id)
identity_index = await load_company_runtime_identity_index(
store,
_current_project_id(state.engine),
)
runtime_identity = identity_index.resolve(task_id=task_id)
task = identity_index.task(
runtime_identity.config_source_task_id if runtime_identity is not None else task_id
)
except Exception:
task = None
if task is None and hasattr(store, "get_task"):
try:
task = await store.get_task(task_id)
except Exception:
task = None
return execution_identity_from_task(
task,
default_exec_mode=state.mode,
@@ -4014,13 +4063,24 @@ async def _handle_continue_slash(state: _InteractiveChatState, args: list[str],
task_id, session_id = await _resolve_runtime_control_target(state, target)
if not task_id:
return
identity = await _runtime_control_identity_for_task(state, task_id)
identity = await _runtime_control_execution_identity(state, task_id)
content = " ".join(message_parts).strip() or "Resume the existing runtime."
checkpoint = await _latest_company_suspend_checkpoint(state, session_id)
if identity.exec_mode in {"company", "org", "custom"} and checkpoint is None:
console.print(
"[warning]No suspended or interrupted company runtime is available to continue.[/warning]"
)
return
if controller is not None and controller.is_busy:
checkpoint = await _latest_company_suspend_checkpoint(state)
if checkpoint is None and state.runtime_control_state not in {"suspended", "stopped"}:
console.print("[warning]Busy: wait for the current turn or /stop it before /continue.[/warning]")
return
metadata: dict[str, Any] = {"ui_force_resume": True}
if checkpoint is not None:
metadata["response_to_checkpoint_id"] = str(getattr(checkpoint, "checkpoint_id", "") or "")
metadata["response_to_checkpoint_type"] = str(
getattr(checkpoint, "checkpoint_type", "") or "company_runtime_suspended"
)
item = QueuedChatInput(
text=content,
project_id=_current_project_id(state.engine),
@@ -4030,26 +4090,29 @@ async def _handle_continue_slash(state: _InteractiveChatState, args: list[str],
org_id=identity.org_id,
preferred_agent=identity.preferred_agent,
domains=list(state.domains),
message_metadata={"ui_force_resume": True},
message_metadata=metadata,
)
state.runtime_control_state = "resuming"
state.runtime_control_task_id = task_id
state.runtime_control_session_id = session_id or state.session_id
state.runtime_control_checkpoint_id = ""
state.runtime_control_checkpoint_id = str(getattr(checkpoint, "checkpoint_id", "") or "")
if controller is not None:
await controller.submit_item(item)
else:
previous_session_id = state.session_id
previous_mode = state.mode
previous_profile = state.company_profile
previous_org_id = state.org_id
previous_agent = state.preferred_agent
state.session_id = session_id or state.session_id
state.mode = identity.exec_mode
state.company_profile = identity.company_profile
state.org_id = identity.org_id
state.preferred_agent = identity.preferred_agent
try:
await _process_interactive_chat_message(state, content, message_metadata={"ui_force_resume": True})
await _process_interactive_chat_message(state, content, message_metadata=metadata)
finally:
state.session_id = previous_session_id
state.mode = previous_mode
state.company_profile = previous_profile
state.org_id = previous_org_id
@@ -5630,140 +5693,6 @@ async def _handle_runtime_slash(state: _InteractiveChatState, args: list[str]) -
_render_checkpoint_table(checkpoints[:limit], title="Pending Checkpoints", full=full)
class _ChatRecoveryFacade:
def __init__(self, state: _InteractiveChatState) -> None:
self._state = state
self.project_id = _current_project_id(state.engine)
@property
def store(self): # noqa: ANN201 - preserve recovery facade shape
return getattr(self._state.engine, "store", None)
@property
def opc_home(self) -> Path:
return Path(getattr(self._state.engine, "opc_home", get_opc_home()))
async def ensure_ready(self):
return self._state.engine
def _get_chat_recovery_manager(state: _InteractiveChatState) -> Any:
cached = getattr(state, "_cli_chat_recovery_manager", None)
engine_id = id(state.engine)
if cached and cached[0] == engine_id:
return cached[1]
from opc.plugins.cli_board.services.recovery import CliRecoveryManager
manager = CliRecoveryManager(_ChatRecoveryFacade(state))
setattr(state, "_cli_chat_recovery_manager", (engine_id, manager))
return manager
def _render_recovery_status(status: Any, *, limit: int, full: bool = False) -> None:
workflows = list(getattr(status, "interrupted", []) or [])[:limit]
if workflows:
table = Table(title=f"Interrupted Company Runtimes ({len(workflows)})")
table.add_column("Parent Task")
table.add_column("Parent Session")
table.add_column("Title")
table.add_column("Profile")
table.add_column("Interrupted")
table.add_column("Work Items")
for workflow in workflows:
interrupted_items = [
item for item in list(getattr(workflow, "work_items", []) or [])
if bool(getattr(item, "interrupted", False))
]
table.add_row(
str(getattr(workflow, "parent_task_id", "") or ""),
str(getattr(workflow, "parent_session_id", "") or ""),
_clip_text(getattr(workflow, "title", "") or "", 70, full=full),
str(getattr(workflow, "profile", "") or ""),
str(getattr(workflow, "interrupted_at", "") or ""),
f"{len(interrupted_items)}/{len(list(getattr(workflow, 'work_items', []) or []))}",
)
console.print(table)
else:
console.print("[info]No interrupted company runtimes found.[/info]")
active = list(getattr(status, "active_recoveries", []) or [])
if active:
console.print(f"[info]Active recoveries: {', '.join(active)}[/info]")
async def _find_checkpoint_by_id(store: Any, project_id: str, checkpoint_id: str) -> Any | None:
if hasattr(store, "get_execution_checkpoints"):
checkpoints = await store.get_execution_checkpoints(project_id=project_id)
else:
checkpoints = await store.get_pending_checkpoints(project_id=project_id)
return next((item for item in checkpoints if str(getattr(item, "checkpoint_id", "") or "") == checkpoint_id), None)
async def _handle_recover_slash(state: _InteractiveChatState, args: list[str]) -> None:
store = _require_chat_store(state, label="Recovery store")
if store is None:
return
if args and args[0].lower() == "resume":
if len(args) != 2:
console.print("[warning]Usage: /recover resume <parent_task_id>. Try /recover.[/warning]")
return
manager = _get_chat_recovery_manager(state)
result = await manager.resume(args[1])
if result.get("ok"):
resumed = result.get("resumed_work_item_projection_ids", []) or []
console.print(f"[success]Recovery started for {args[1]}: {', '.join(resumed) or 'runtime queued'}.[/success]")
return
if result.get("error") == "not_found":
checkpoint = await _find_checkpoint_by_id(store, _current_project_id(state.engine), args[1])
if checkpoint:
session_id = str(getattr(checkpoint, "session_id", "") or "")
suffix = f" Try /session resume {session_id}." if session_id else " Try /checkpoints."
console.print(f"[warning]Checkpoint {args[1]} is not resumed directly.{suffix}[/warning]")
return
console.print(f"[warning]Recovery could not start: {result.get('error', 'unknown_error')}[/warning]")
return
if args and args[0].lower() in {"cancel", "retry"}:
action = args[0].lower()
if len(args) < 2:
console.print(f"[warning]Usage: /recover {action} <parent_task_id>{' --yes' if action == 'cancel' else ''}. Try /recover.[/warning]")
return
if action == "cancel":
remaining, ok = _require_yes_arg(args[2:], usage="/recover cancel <parent_task_id> --yes")
if not ok or remaining:
return
elif len(args) > 2:
console.print(f"[warning]Usage: /recover {action} <parent_task_id>[/warning]")
return
payload = await _run_chat_office_service(
state,
lambda svc: svc.runtime.recovery_action(project_id=_current_project_id(state.engine), action=action, parent_task_id=args[1]),
)
if payload:
_emit_payload(payload)
return
try:
if args and args[0].lower() == "scan":
args = args[1:]
args, limit, full = _parse_view_args(args)
except ValueError as exc:
console.print(f"[warning]{exc}. Try /recover --limit 20.[/warning]")
return
if args:
console.print("[warning]Usage: /recover [--limit N] [--full] or /recover resume <parent_task_id>.[/warning]")
return
manager = _get_chat_recovery_manager(state)
status = await manager.get_status()
_render_recovery_status(status, limit=limit, full=full)
if hasattr(store, "get_execution_checkpoints"):
checkpoints = await store.get_execution_checkpoints(
project_id=_current_project_id(state.engine),
statuses=["pending", "resuming"],
)
else:
checkpoints = await store.get_pending_checkpoints(project_id=_current_project_id(state.engine))
_render_checkpoint_table(checkpoints[:limit], title="Recovery Checkpoints", full=full)
async def _resolve_logs_target(
state: _InteractiveChatState,
target: str,
@@ -7487,10 +7416,6 @@ def _busy_slash_policy(command: str, args: list[str]) -> BusyCommandPolicy:
}
if command in readonly_roots:
return BusyCommandPolicy.IMMEDIATE_READONLY
if command == "recover":
if not args or args[0].lower() in {"scan", "status", "list"}:
return BusyCommandPolicy.IMMEDIATE_READONLY
return BusyCommandPolicy.BLOCKED_WHEN_BUSY
if command == "task":
if args and args[0].lower() == "show":
return BusyCommandPolicy.IMMEDIATE_READONLY
@@ -7810,8 +7735,6 @@ async def _handle_chat_slash_command(state: _InteractiveChatState, user_input: s
await _handle_reorg_slash(state, args)
elif command == "runtime":
await _handle_runtime_slash(state, args)
elif command == "recover":
await _handle_recover_slash(state, args)
elif command in {"work-items", "work-item"}:
await _handle_work_items_slash(state, args)
elif command == "logs":
@@ -7835,6 +7758,13 @@ async def _sync_runtime_checkpoint_hint(state: _InteractiveChatState) -> None:
display = state.runtime_display
if not hasattr(display, "set_checkpoint_hint"):
return
runtime_identity = await _company_runtime_identity_for_session(state)
if runtime_identity is not None and runtime_identity.checkpoint is not None:
checkpoint = runtime_identity.checkpoint
checkpoint_type = str(getattr(checkpoint, "checkpoint_type", "") or "pending")
checkpoint_id = str(getattr(checkpoint, "checkpoint_id", "") or "")
display.set_checkpoint_hint(checkpoint_type or checkpoint_id)
return
getter = getattr(state.engine, "get_latest_pending_checkpoint_for_session", None)
if not callable(getter) or not state.session_id:
display.set_checkpoint_hint("")
@@ -7862,24 +7792,110 @@ async def _process_interactive_chat_message(
try:
state.runtime_display.begin_turn()
effective_metadata = message_metadata
if effective_metadata is None:
suspend_checkpoint = await _latest_company_suspend_checkpoint(state)
if suspend_checkpoint is not None:
effective_metadata = {
"response_to_checkpoint_id": str(getattr(suspend_checkpoint, "checkpoint_id", "") or ""),
"response_to_checkpoint_type": str(getattr(suspend_checkpoint, "checkpoint_type", "") or "company_runtime_suspended"),
}
elif state.mode == "company":
execution_session_id = state.session_id
execution_origin_task_id: str | None = None
execution_mode = state.mode
execution_company_profile = state.company_profile
execution_org_id = state.org_id
execution_preferred_agent = state.preferred_agent
handoff_identity = None
runtime_identity = await _company_runtime_identity_for_session(state)
runtime_checkpoint = (
runtime_identity.checkpoint
if runtime_identity is not None
else None
)
explicit_runtime_type = str(
(effective_metadata or {}).get("response_to_checkpoint_type", "") or ""
).strip()
explicit_runtime_id = str(
(effective_metadata or {}).get("response_to_checkpoint_id", "") or ""
).strip()
is_explicit_runtime_handoff = explicit_runtime_type in {
"company_runtime_suspended",
"company_runtime_interrupted",
}
if is_explicit_runtime_handoff:
if runtime_identity is None or runtime_checkpoint is None:
raise RuntimeError(
"Company runtime checkpoint does not match the current session."
)
checkpoint_id = str(
getattr(runtime_checkpoint, "checkpoint_id", "") or ""
).strip()
checkpoint_type = str(
getattr(runtime_checkpoint, "checkpoint_type", "") or ""
).strip()
checkpoint_status = str(
getattr(runtime_checkpoint, "status", "") or ""
).strip().lower()
if (
not explicit_runtime_id
or explicit_runtime_id != checkpoint_id
or explicit_runtime_type != checkpoint_type
):
raise RuntimeError(
"Company runtime checkpoint identity mismatch; refresh before continuing."
)
if checkpoint_status != "pending":
raise RuntimeError(
f"Company runtime checkpoint is {checkpoint_status or 'not pending'}."
)
execution_session_id = runtime_identity.runtime_session_id
execution_origin_task_id = runtime_identity.ui_anchor_task_id or None
handoff_identity = runtime_identity
elif effective_metadata is None and runtime_checkpoint is not None:
checkpoint_status = str(
getattr(runtime_checkpoint, "status", "") or ""
).strip().lower()
if checkpoint_status != "pending":
raise RuntimeError(
f"Company runtime checkpoint is {checkpoint_status or 'not pending'}."
)
if runtime_identity is None:
raise RuntimeError(
"Company runtime checkpoint does not match the current session."
)
effective_metadata = {
"response_to_checkpoint_id": str(getattr(runtime_checkpoint, "checkpoint_id", "") or ""),
"response_to_checkpoint_type": str(getattr(runtime_checkpoint, "checkpoint_type", "") or "company_runtime_suspended"),
}
execution_session_id = runtime_identity.runtime_session_id
execution_origin_task_id = runtime_identity.ui_anchor_task_id or None
handoff_identity = runtime_identity
elif effective_metadata is None:
if runtime_identity is not None and runtime_identity.pending_checkpoint_id:
# An active checkpoint may only be pending or resuming. Never
# let a malformed identity/status fall through as a new turn.
raise RuntimeError(
"Company runtime checkpoint is not available for a new turn."
)
if state.mode == "company":
effective_metadata = {"company_preflight": "manual"}
if handoff_identity is not None:
runtime_execution_identity = await _company_runtime_execution_identity(
state,
handoff_identity,
)
execution_mode = runtime_execution_identity.exec_mode
execution_company_profile = runtime_execution_identity.company_profile
execution_org_id = runtime_execution_identity.org_id
execution_preferred_agent = runtime_execution_identity.preferred_agent
response = await state.engine.process_message(
user_input,
project_id=getattr(state.engine, "project_id", None),
session_id=state.session_id,
mode=state.mode,
org_id=state.org_id or None,
company_profile=state.company_profile if state.mode == "company" else None,
preferred_agent=state.preferred_agent,
session_id=execution_session_id,
mode=execution_mode,
org_id=execution_org_id or None,
company_profile=(
execution_company_profile
if execution_mode == "company"
else None
),
preferred_agent=execution_preferred_agent,
domains=list(state.domains),
origin_task_id=execution_origin_task_id,
message_metadata=effective_metadata,
)
await state.runtime_display.flush()
@@ -7910,7 +7926,15 @@ async def _process_interactive_chat_message(
"company_runtime_interrupted",
}
):
state.runtime_control_state = "running"
remaining_checkpoint = await _latest_company_suspend_checkpoint(state)
if remaining_checkpoint is not None:
state.runtime_control_state = "suspended"
state.runtime_control_checkpoint_id = str(
getattr(remaining_checkpoint, "checkpoint_id", "") or ""
)
else:
state.runtime_control_state = "idle"
state.runtime_control_checkpoint_id = ""
except KeyboardInterrupt:
console.print("\n[warning]Interrupted. Type /quit to exit.[/warning]")
except Exception as e:
+269
View File
@@ -0,0 +1,269 @@
"""Controller-local ownership of active task execution attempts.
Persisted task rows describe durable workflow state; they cannot prove that
the controller which owns an execution coroutine is still alive. This
registry intentionally stays in memory and is shared by all engines owned by
one controller.
"""
from __future__ import annotations
import asyncio
from contextlib import contextmanager
from contextvars import ContextVar
import uuid
from collections.abc import Iterator
_CURRENT_HANDOFF: ContextVar[tuple[object, str] | None] = ContextVar(
"opc_active_task_run_handoff",
default=None,
)
_CURRENT_DRIVER_ATTEMPT: ContextVar[tuple[object, str] | None] = ContextVar(
"opc_active_task_run_driver_attempt",
default=None,
)
class ActiveTaskRunAdmissionClosed(RuntimeError):
"""Raised when execution registration starts after shutdown admission closes."""
class ActiveTaskRunRegistry:
"""Track active execution attempts by ``(project_id, task_id)``.
A task can briefly have overlapping attempts while cancellation and a new
dispatch cross. Each registration therefore receives its own token and a
task remains active until its last token is removed.
"""
def __init__(self) -> None:
self._attempts: dict[tuple[str, str], set[str]] = {}
self._scope_locks: dict[tuple[str, str], asyncio.Lock] = {}
self._handoff_refs: dict[str, int] = {}
self._handoffs_drained = asyncio.Event()
self._handoffs_drained.set()
self._admission_closed = False
@staticmethod
def _key(project_id: str | None, task_id: str | None) -> tuple[str, str]:
project = str(project_id or "default").strip() or "default"
task = str(task_id or "").strip()
if not task:
raise ValueError("task_id is required")
return project, task
def register(self, project_id: str | None, task_id: str | None) -> str:
handoff_token = self._current_pending_handoff_token()
driver_attempt_token = self._current_driver_attempt_token()
if (
self._admission_closed
and handoff_token is None
and driver_attempt_token is None
):
raise ActiveTaskRunAdmissionClosed(
"task execution admission is closed for controller shutdown"
)
key = self._key(project_id, task_id)
attempt_token = uuid.uuid4().hex
self._attempts.setdefault(key, set()).add(attempt_token)
# A pre-shutdown WS request is handed off once its first real execution
# coroutine is registered. The reservation itself is deliberately not
# reported by is_active()/active_task_ids(); only this attempt is.
if handoff_token is not None:
self._settle_handoff(handoff_token)
return attempt_token
@contextmanager
def bind_driver_attempt(self, attempt_token: str) -> Iterator[None]:
"""Allow nested attempts while their live scheduler owns the scope.
Closing controller admission rejects new ingress, but a scheduler that
was already running may be between its atomic WorkItem claim and child
coroutine creation. Its nested registrations remain admissible until
that scheduler attempt ends, so shutdown can snapshot the still-live
scope instead of creating an orphan RUNNING claim.
"""
if not self._attempt_token_is_active(attempt_token):
raise ValueError("driver attempt is not active")
context_token = _CURRENT_DRIVER_ATTEMPT.set((self, attempt_token))
try:
yield
finally:
_CURRENT_DRIVER_ATTEMPT.reset(context_token)
def reserve_handoff(self) -> str:
"""Reserve one accepted ingress request until execution is registered.
Reservations bridge the short scheduling gap between the WS router and
``register``. They are controller-local synchronization only and never
become a second liveness source.
"""
if self._admission_closed:
raise ActiveTaskRunAdmissionClosed(
"task execution admission is closed for controller shutdown"
)
token = uuid.uuid4().hex
self._handoff_refs[token] = 1
self._handoffs_drained.clear()
return token
@contextmanager
def bind_handoff(self, handoff_token: str) -> Iterator[None]:
"""Propagate a reservation through tasks spawned by an ingress handler."""
if handoff_token not in self._handoff_refs:
raise ValueError("handoff reservation is not pending")
context_token = _CURRENT_HANDOFF.set((self, handoff_token))
try:
yield
finally:
_CURRENT_HANDOFF.reset(context_token)
def retain_current_handoff(self) -> str | None:
"""Retain the bound reservation for a newly scheduled coroutine."""
handoff_token = self._current_pending_handoff_token()
if handoff_token is None:
return None
self._handoff_refs[handoff_token] += 1
return handoff_token
def release_current_handoff(self) -> bool:
"""Release an accepted request that will not start an execution."""
handoff_token = self._current_pending_handoff_token()
if handoff_token is None:
return False
return self.release_handoff(handoff_token)
def release_handoff(self, handoff_token: str) -> bool:
"""Release one owner, draining a request that exited before execution."""
refs = self._handoff_refs.get(handoff_token)
if refs is None:
return False
if refs > 1:
self._handoff_refs[handoff_token] = refs - 1
return True
self._settle_handoff(handoff_token)
return True
def revoke_handoff(self, handoff_token: str) -> bool:
"""Invalidate every retained owner of a queued ingress handoff.
Controller shutdown uses this after synchronously cancelling a request
which has not registered its first execution attempt. Revocation is
intentionally stronger than ``release_handoff``: callbacks may be
delayed by cancellation cleanup, but the revoked request must neither
keep the shutdown barrier open nor register work after admission has
closed.
"""
if handoff_token not in self._handoff_refs:
return False
self._settle_handoff(handoff_token)
return True
def _current_pending_handoff_token(self) -> str | None:
binding = _CURRENT_HANDOFF.get()
if binding is None or binding[0] is not self:
return None
token = binding[1]
return token if token in self._handoff_refs else None
def _current_driver_attempt_token(self) -> str | None:
binding = _CURRENT_DRIVER_ATTEMPT.get()
if binding is None or binding[0] is not self:
return None
token = binding[1]
return token if self._attempt_token_is_active(token) else None
def _attempt_token_is_active(self, attempt_token: str) -> bool:
return any(
attempt_token in attempts
for attempts in self._attempts.values()
)
def _settle_handoff(self, handoff_token: str) -> None:
self._handoff_refs.pop(handoff_token, None)
if not self._handoff_refs:
self._handoffs_drained.set()
@property
def admission_closed(self) -> bool:
return self._admission_closed
def close_admission(self) -> None:
"""Reject future attempts without dropping attempts already in flight."""
self._admission_closed = True
async def close_admission_and_wait_for_handoffs(self) -> None:
"""Close ingress and wait until every already-accepted request hands off.
A bound pending reservation may still call ``register`` after admission
closes. That registration atomically drains the reservation and turns
the real coroutine into the sole active fact. This wait therefore ends
at handoff, never at completion of the potentially long execution.
"""
self.close_admission()
while self._handoff_refs:
await self._handoffs_drained.wait()
@property
def pending_handoff_count(self) -> int:
return len(self._handoff_refs)
def is_handoff_pending(self, handoff_token: str | None) -> bool:
return bool(handoff_token and handoff_token in self._handoff_refs)
def scope_lock(
self,
project_id: str | None,
runtime_session_id: str | None,
) -> asyncio.Lock:
"""Return the controller-shared lock for one durable runtime scope."""
project = str(project_id or "default").strip() or "default"
session = str(runtime_session_id or "").strip()
if not session:
raise ValueError("runtime_session_id is required")
key = (project, session)
lock = self._scope_locks.get(key)
if lock is None:
lock = asyncio.Lock()
self._scope_locks[key] = lock
return lock
def unregister(
self,
project_id: str | None,
task_id: str | None,
attempt_token: str,
) -> bool:
key = self._key(project_id, task_id)
attempts = self._attempts.get(key)
if not attempts or attempt_token not in attempts:
return False
attempts.remove(attempt_token)
if not attempts:
self._attempts.pop(key, None)
return True
def is_active(self, project_id: str | None, task_id: str | None) -> bool:
return bool(self._attempts.get(self._key(project_id, task_id)))
def active_task_ids(self, project_id: str | None) -> set[str]:
project = str(project_id or "default").strip() or "default"
return {
task_id
for (candidate_project, task_id), attempts in self._attempts.items()
if candidate_project == project and attempts
}
def attempt_count(self, project_id: str | None, task_id: str | None) -> int:
return len(self._attempts.get(self._key(project_id, task_id), ()))
+358 -57
View File
@@ -2862,32 +2862,6 @@ class OPCStore:
await self._db.commit()
return cursor.rowcount > 0
async def renew_task_lock(self, task_id: str) -> bool:
"""Refresh ``execution_locked_at`` for a live running task.
This is the heartbeat used by the WS handler while it is actively
processing a session message. It only updates rows whose ``status`` is
still ``running`` (anything else has already ended and must not keep a
live timestamp), and deliberately ignores ``execution_lock``: that bit
is set only by delegation checkout, while the office_ui dispatch path
serializes through an in-memory asyncio.Lock and never flips it. The
refreshed ``execution_locked_at`` is what ``reset_orphan_running_tasks``
compares against on startup to distinguish live work from abandoned
``running`` rows.
Returns ``True`` when the row was still ``status=running`` (heartbeat
extended), ``False`` otherwise (task ended or gone caller should
stop heartbeating).
"""
assert self._db
now_iso = datetime.now().isoformat()
async with self._db.execute(
"UPDATE tasks SET execution_locked_at = ? WHERE id = ? AND status = 'running'",
(now_iso, task_id),
) as cursor:
await self._db.commit()
return cursor.rowcount > 0
async def release_task_lock(self, task_id: str) -> None:
assert self._db
await self._db.execute(
@@ -2896,37 +2870,6 @@ class OPCStore:
)
await self._db.commit()
async def reset_orphan_running_tasks(self, *, lease_seconds: int = 300) -> dict[str, int]:
"""Reset ``status=running`` tasks abandoned by a prior process.
Safe single-process assumption: when this runs during server startup,
any task that still claims ``status=running`` must be an orphan its
worker coroutine died with the previous process. We revert it to
``idle`` so the UI can Continue it, and clear any stale execution lock
regardless of status when the lease has expired.
Returns a summary dict with keys ``statuses_reset`` and
``locks_cleared`` giving the affected row counts.
"""
assert self._db
cutoff_iso = (datetime.now() - timedelta(seconds=int(lease_seconds))).isoformat()
async with self._db.execute(
"UPDATE tasks SET status = 'idle', execution_lock = 0, execution_locked_at = NULL "
"WHERE status = 'running' AND ("
"execution_locked_at IS NULL OR execution_locked_at < ?"
")",
(cutoff_iso,),
) as cursor:
statuses_reset = cursor.rowcount or 0
async with self._db.execute(
"UPDATE tasks SET execution_lock = 0, execution_locked_at = NULL "
"WHERE execution_lock = 1 AND execution_locked_at IS NOT NULL AND execution_locked_at < ?",
(cutoff_iso,),
) as cursor:
locks_cleared = cursor.rowcount or 0
await self._db.commit()
return {"statuses_reset": statuses_reset, "locks_cleared": locks_cleared}
def _row_to_task(self, row: Any, description: Any) -> Task:
cols = [d[0] for d in description]
data = dict(zip(cols, row))
@@ -5509,6 +5452,108 @@ class OPCStore:
await self.save_delegation_work_item(item)
return item
async def claim_delegation_work_item_if_dispatchable(
self,
work_item_id: str,
*,
expected_phase: Phase | str,
role_runtime_session_id: str,
seat_id: str,
task_id: str,
work_item_revision: int = 0,
) -> DelegationWorkItem | None:
"""Atomically claim an unheld, unowned dispatchable WorkItem.
The dispatcher may be operating on a snapshot loaded before a Stop or
shutdown transition. Keeping the phase, claim, queue, and durable
hold predicates in the same UPDATE prevents that stale snapshot from
resurrecting a suspended WorkItem.
"""
phase = coerce_phase(expected_phase)
dispatchable_phases = {
Phase.READY,
Phase.READY_FOR_REWORK,
*IN_PROGRESS_PHASES,
}
if phase not in dispatchable_phases:
return None
if phase != Phase.RUNNING:
validate_transition(phase, Phase.RUNNING)
role_session_id = str(role_runtime_session_id or "").strip()
claimed_task_id = str(task_id or "").strip()
if not work_item_id or not role_session_id or not claimed_task_id:
return None
updated_at = datetime.now()
db = self._require_db()
cursor = await db.execute(
"""UPDATE delegation_work_items
SET phase = ?,
role_runtime_session_id = ?,
claimed_by_role_runtime_session_id = ?,
claimed_by_seat_id = ?,
metadata = json_set(
COALESCE(NULLIF(metadata, ''), '{}'),
'$.claimed_by_role_session_id', ?,
'$.claimed_task_id', ?,
'$.claimed_work_item_revision', ?
),
updated_at = ?
WHERE work_item_id = ?
AND phase = ?
AND COALESCE(claimed_by_role_runtime_session_id, '') = ''
AND COALESCE(claimed_by_seat_id, '') = ''
AND COALESCE(json_extract(metadata, '$.claimed_by_role_session_id'), '') = ''
AND COALESCE(json_extract(metadata, '$.claimed_task_id'), '') = ''
AND COALESCE(json_extract(metadata, '$.dispatch_hold'), '') = ''
AND COALESCE(json_extract(metadata, '$.queued_behind_session'), '') = ''
AND COALESCE(
CAST(json_extract(metadata, '$.manager_mutation_revision') AS INTEGER),
0
) = ?""",
(
Phase.RUNNING.value,
role_session_id,
role_session_id,
str(seat_id or "").strip(),
role_session_id,
claimed_task_id,
int(work_item_revision or 0),
updated_at.isoformat(),
str(work_item_id or "").strip(),
phase.value,
int(work_item_revision or 0),
),
)
await db.commit()
if not (getattr(cursor, "rowcount", 0) or 0):
return None
persisted = await self.get_delegation_work_item(work_item_id)
if persisted is None:
return None
persisted_metadata = dict(persisted.metadata or {})
if (
persisted.phase != Phase.RUNNING
or str(persisted.claimed_by_role_runtime_session_id or "").strip()
!= role_session_id
or str(persisted_metadata.get("claimed_by_role_session_id", "") or "").strip()
!= role_session_id
or str(persisted_metadata.get("claimed_task_id", "") or "").strip()
!= claimed_task_id
or str(persisted_metadata.get("dispatch_hold", "") or "").strip()
or str(persisted_metadata.get("queued_behind_session", "") or "").strip()
):
# Stop/shutdown may have won immediately after the CAS commit and
# cleared this claim while adding a durable hold. The committed
# UPDATE is not permission to spawn once that newer state exists.
return None
# Do not fire the generic phase hooks here. The executor's first
# idempotent RUNNING transition performs projection. Deferring it
# avoids a post-commit hook racing after Stop and projecting the Task
# back to RUNNING from an already-held WorkItem.
return persisted
async def apply_delegation_review_resolution(
self,
work_item_id: str,
@@ -7148,6 +7193,262 @@ class OPCStore:
)
await self._db.commit()
async def get_or_create_active_execution_checkpoint(
self,
checkpoint: ExecutionCheckpoint,
*,
checkpoint_types: list[str] | tuple[str, ...] | set[str],
create_if_missing: bool = True,
) -> tuple[ExecutionCheckpoint | None, bool]:
"""Atomically reuse or create one active checkpoint for a session scope.
``BEGIN IMMEDIATE`` serializes checkpoint creation across independent
controller/store connections. It also repairs historical duplicate
active rows in the same transaction, so concurrent startup/shutdown
reconcilers can never supersede each other's newly-created checkpoint
and leave the scope without a durable recovery point.
"""
assert self._db
clean_types = sorted(
{
str(item).strip()
for item in checkpoint_types
if str(item).strip()
}
)
project_id = str(checkpoint.project_id or "default").strip() or "default"
session_id = str(checkpoint.session_id or "").strip()
checkpoint_type = str(checkpoint.checkpoint_type or "").strip()
if not session_id:
raise ValueError("active execution checkpoint requires session_id")
if checkpoint_type not in clean_types:
raise ValueError(
"checkpoint_type must be included in the active checkpoint scope"
)
placeholders = ", ".join("?" for _ in clean_types)
try:
await self._db.execute("BEGIN IMMEDIATE")
async with self._db.execute(
f"""SELECT * FROM execution_checkpoints
WHERE project_id = ?
AND session_id = ?
AND checkpoint_type IN ({placeholders})
AND status IN ('pending', 'resuming')
ORDER BY updated_at DESC, created_at DESC, checkpoint_id DESC""",
(project_id, session_id, *clean_types),
) as cursor:
rows = await cursor.fetchall()
cols = [description[0] for description in cursor.description]
if rows:
decoded = [dict(zip(cols, row)) for row in rows]
winner_data = decoded[0]
winner_id = str(winner_data["checkpoint_id"])
now = datetime.now().isoformat()
for duplicate in decoded[1:]:
duplicate_id = str(duplicate.get("checkpoint_id", "") or "").strip()
if not duplicate_id:
continue
payload = _json_loads(duplicate.get("payload"), {})
payload["superseded_at"] = now
payload["superseded_by_checkpoint_id"] = winner_id
await self._db.execute(
"""UPDATE execution_checkpoints
SET status = 'superseded', payload = ?, updated_at = ?
WHERE checkpoint_id = ? AND status IN ('pending', 'resuming')""",
(_json_dumps(payload), now, duplicate_id),
)
await self._db.commit()
return (
ExecutionCheckpoint(
checkpoint_id=winner_id,
project_id=str(winner_data["project_id"]),
session_id=winner_data.get("session_id"),
checkpoint_type=str(winner_data["checkpoint_type"]),
status=str(winner_data["status"]),
task_id=winner_data.get("task_id"),
payload=_json_loads(winner_data.get("payload"), {}),
created_at=datetime.fromisoformat(str(winner_data["created_at"])),
updated_at=datetime.fromisoformat(str(winner_data["updated_at"])),
),
False,
)
if not create_if_missing:
await self._db.commit()
return None, False
await self._db.execute(
"""INSERT INTO execution_checkpoints
(checkpoint_id, project_id, session_id, checkpoint_type, status,
task_id, payload, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
checkpoint.checkpoint_id,
project_id,
session_id,
checkpoint_type,
checkpoint.status,
checkpoint.task_id,
_json_dumps(checkpoint.payload),
checkpoint.created_at.isoformat(),
checkpoint.updated_at.isoformat(),
),
)
await self._db.commit()
return checkpoint, True
except Exception:
await self._db.rollback()
raise
async def normalize_active_execution_checkpoints(
self,
*,
project_id: str,
session_id: str,
checkpoint_types: list[str] | tuple[str, ...] | set[str],
) -> ExecutionCheckpoint | None:
"""Return one active scope owner while superseding duplicate rows.
Unlike creation, startup reconciliation must not manufacture a
checkpoint merely because none exists. This wrapper reuses the same
serialized transaction and duplicate winner rule with insertion
explicitly disabled.
"""
clean_types = sorted(
{
str(item).strip()
for item in checkpoint_types
if str(item).strip()
}
)
if not clean_types or not str(session_id or "").strip():
return None
candidate = ExecutionCheckpoint(
project_id=str(project_id or "default").strip() or "default",
session_id=str(session_id or "").strip(),
checkpoint_type=clean_types[0],
)
checkpoint, _created = await self.get_or_create_active_execution_checkpoint(
candidate,
checkpoint_types=clean_types,
create_if_missing=False,
)
return checkpoint
async def compare_and_set_execution_checkpoint(
self,
checkpoint_id: str,
*,
expected_statuses: list[str] | tuple[str, ...] | set[str],
status: str,
payload: dict[str, Any],
updated_at: datetime | None = None,
) -> bool:
"""Atomically transition a checkpoint only from an expected state.
The conditional UPDATE is the cross-controller guard for checkpoint
consumption. In-memory scope locks serialize one Office controller;
this CAS prevents a standalone CLI/controller from claiming the same
pending runtime concurrently.
"""
assert self._db
expected = [
str(item).strip()
for item in expected_statuses
if str(item).strip()
]
if not checkpoint_id or not expected:
return False
placeholders = ", ".join("?" for _ in expected)
cursor = await self._db.execute(
f"""UPDATE execution_checkpoints
SET status = ?, payload = ?, updated_at = ?
WHERE checkpoint_id = ? AND status IN ({placeholders})""",
(
str(status or "").strip(),
_json_dumps(dict(payload or {})),
(updated_at or datetime.now()).isoformat(),
checkpoint_id,
*expected,
),
)
await self._db.commit()
return cursor.rowcount == 1
async def complete_execution_checkpoint_and_reopen_ui_anchor(
self,
checkpoint_id: str,
*,
project_id: str,
session_id: str,
expected_status: str,
status: str,
payload: dict[str, Any],
ui_anchor_task_id: str = "",
updated_at: datetime | None = None,
) -> bool:
"""Atomically complete a runtime handoff and reopen its UI anchor.
The anchor must never become chat-runnable if a concurrent Stop already
took checkpoint ownership back. Keeping both writes in one SQLite
transaction makes the checkpoint CAS the gate for the UI projection.
"""
assert self._db
checkpoint_id = str(checkpoint_id or "").strip()
project_id = str(project_id or "default").strip() or "default"
session_id = str(session_id or "").strip()
expected_status = str(expected_status or "").strip()
status = str(status or "").strip()
ui_anchor_task_id = str(ui_anchor_task_id or "").strip()
if not checkpoint_id or not session_id or not expected_status or not status:
return False
now = updated_at or datetime.now()
try:
await self._db.execute("BEGIN IMMEDIATE")
cursor = await self._db.execute(
"""UPDATE execution_checkpoints
SET status = ?, payload = ?, updated_at = ?
WHERE checkpoint_id = ?
AND project_id = ?
AND session_id = ?
AND status = ?""",
(
status,
_json_dumps(dict(payload or {})),
now.isoformat(),
checkpoint_id,
project_id,
session_id,
expected_status,
),
)
if cursor.rowcount != 1:
await self._db.rollback()
return False
if ui_anchor_task_id:
await self._db.execute(
"""UPDATE tasks
SET status = ?, execution_lock = 0, execution_locked_at = NULL
WHERE id = ? AND project_id = ? AND status = ?""",
(
TaskStatus.IDLE.value,
ui_anchor_task_id,
project_id,
TaskStatus.CANCELLED.value,
),
)
await self._db.commit()
return True
except Exception:
await self._db.rollback()
raise
async def get_execution_checkpoints(
self,
project_id: str = "default",
+1470 -550
View File
File diff suppressed because it is too large Load Diff
+195 -142
View File
@@ -17,6 +17,10 @@ from typing import Any, Awaitable, Callable
from loguru import logger
from opc.core.active_task_runs import (
ActiveTaskRunAdmissionClosed,
ActiveTaskRunRegistry,
)
from opc.core.config import DEFAULT_EXTERNAL_AGENT_STARTUP_TIMEOUT_SECONDS, DEFAULT_ORGANIZATION_ID
from opc.core.models import (
AdaptiveRoleProfile,
@@ -456,6 +460,26 @@ class WorkItemOutputBundle:
summary: str = ""
@dataclass(frozen=True)
class CompanyExecutorDriverOwnership:
"""One registry attempt covering a complete company scheduler run."""
registry: ActiveTaskRunRegistry
project_id: str
task_id: str
attempt_token: str
def bind(self):
return self.registry.bind_driver_attempt(self.attempt_token)
def release(self) -> bool:
return self.registry.unregister(
self.project_id,
self.task_id,
self.attempt_token,
)
def serialize_company_runtime_spec(spec: CompanyRuntimeSpec | None) -> dict[str, Any]:
if spec is None:
return {}
@@ -1354,6 +1378,7 @@ class CompanyWorkItemExecutor:
store: Any | None = None,
llm: Any | None = None,
role_prompt_runner: Callable[[Task, str, dict[str, Any], str, bool], Awaitable[str | None]] | None = None,
active_task_run_registry: ActiveTaskRunRegistry | None = None,
) -> None:
self.org_engine = org_engine
self.communication = communication
@@ -1373,6 +1398,7 @@ class CompanyWorkItemExecutor:
self.on_kanban_changed = on_kanban_changed
self.work_item_timeout = work_item_timeout
self.role_prompt_runner = role_prompt_runner
self.active_task_run_registry = active_task_run_registry
self._default_run_state = CompanyExecutorRunState()
self._run_state_var: ContextVar[CompanyExecutorRunState | None] = ContextVar(
f"company-executor-run-state:{id(self)}",
@@ -3368,12 +3394,6 @@ class CompanyWorkItemExecutor:
"cell_id": work_item.cell_id,
"parent_work_item_id": work_item.parent_work_item_id,
}
if work_item.phase == Phase.PAUSED:
task.metadata.setdefault("interrupted_recovery", {
"reason": "work_item_interrupted",
"detected_at": datetime.now().isoformat(),
})
if task.metadata != before_metadata:
changed = True
return changed
@@ -4359,14 +4379,76 @@ class CompanyWorkItemExecutor:
return "dispatch_required"
return "worker_execute"
async def execute(self, plan: CompanyWorkItemRuntimePlan, tasks: list[Task]) -> str:
plan = _coerce_company_work_item_runtime_plan(plan) or CompanyWorkItemRuntimePlan()
plan.metadata = {
**dict(plan.metadata or {}),
"execution_model": "multi_team_org",
"runtime_model": "multi_team_org",
@staticmethod
def _driver_ownership_task(
tasks: list[Task],
*,
preferred_task_ids: set[str] | None = None,
) -> Task | None:
preferred = {
str(task_id or "").strip()
for task_id in set(preferred_task_ids or set())
if str(task_id or "").strip()
}
return await self._execute_multi_team_org(plan, tasks)
candidates = [
task
for task in tasks
if str(getattr(task, "id", "") or "").strip()
and (not preferred or task.id in preferred)
]
for task in candidates:
if linked_work_item_id_for_task(task):
return task
for task in candidates:
if is_work_item_runtime_metadata(dict(task.metadata or {})):
return task
return candidates[0] if candidates else None
def acquire_driver_ownership(
self,
tasks: list[Task],
*,
preferred_task_ids: set[str] | None = None,
) -> CompanyExecutorDriverOwnership | None:
registry = self.active_task_run_registry
task = self._driver_ownership_task(
tasks,
preferred_task_ids=preferred_task_ids,
)
if registry is None or task is None:
return None
project_id = str(task.project_id or "default").strip() or "default"
try:
attempt_token = registry.register(project_id, task.id)
except ActiveTaskRunAdmissionClosed as exc:
raise asyncio.CancelledError(str(exc)) from exc
return CompanyExecutorDriverOwnership(
registry=registry,
project_id=project_id,
task_id=task.id,
attempt_token=attempt_token,
)
async def execute(
self,
plan: CompanyWorkItemRuntimePlan,
tasks: list[Task],
) -> str:
ownership = self.acquire_driver_ownership(tasks)
try:
plan = _coerce_company_work_item_runtime_plan(plan) or CompanyWorkItemRuntimePlan()
plan.metadata = {
**dict(plan.metadata or {}),
"execution_model": "multi_team_org",
"runtime_model": "multi_team_org",
}
if ownership is None:
return await self._execute_multi_team_org(plan, tasks)
with ownership.bind():
return await self._execute_multi_team_org(plan, tasks)
finally:
if ownership is not None:
ownership.release()
async def _execute_multi_team_org(
self,
@@ -4501,11 +4583,11 @@ class CompanyWorkItemExecutor:
# Claim whatever is immediately claimable and spawn each
# work item as an independent asyncio.Task so the loop no
# longer blocks on the slowest sibling.
claims = await self.runtime.claim_runnable_tasks(tasks, work_items=work_items)
for member_session, claimed_task in claims:
work_item_coro = self._run_claimed_work_item(member_session, claimed_task, {})
work_item_task = asyncio.create_task(work_item_coro)
active_work_item_tasks[work_item_task] = (member_session, claimed_task)
claims = await self._claim_and_create_work_item_tasks(
tasks,
work_items,
active_work_item_tasks,
)
# Termination: only when nothing is in-flight AND nothing
# else is runnable. If work items are still running, even an
# "empty runnable" snapshot may become non-empty within
@@ -4608,10 +4690,6 @@ class CompanyWorkItemExecutor:
self._schedule_kanban_notification()
except asyncio.CancelledError:
claimed_pairs = list(active_work_item_tasks.values())
claimed_tasks = [
claimed_task
for _member_session, claimed_task in claimed_pairs
]
for work_item_task in list(active_work_item_tasks.keys()):
if not work_item_task.done():
work_item_task.cancel()
@@ -4660,63 +4738,6 @@ class CompanyWorkItemExecutor:
)
except Exception:
logger.opt(exception=True).debug("company runtime cancellation: failed session idle reset")
for claimed_task in claimed_tasks:
if claimed_task.status in {TaskStatus.DONE, TaskStatus.FAILED, TaskStatus.CANCELLED}:
continue
claimed_task.metadata = dict(claimed_task.metadata or {})
claimed_task.metadata["company_runtime_suspended_at"] = datetime.now().isoformat()
claimed_task.metadata.setdefault("last_stop_reason", "runtime_cancelled")
claimed_task.metadata["company_runtime_stop_state"] = "suspended"
claimed_task.metadata["company_runtime_stop_marked_at"] = (
claimed_task.metadata.get("company_runtime_stop_marked_at") or datetime.now().isoformat()
)
claimed_task.metadata.setdefault(
"suspended_task_status",
claimed_task.status.value if isinstance(claimed_task.status, TaskStatus) else str(claimed_task.status or ""),
)
work_item_id = linked_work_item_id_for_task(claimed_task)
try:
if work_item_id and self._store_is_ready(self.store) and hasattr(self.store, "get_delegation_work_item"):
work_item = await self.store.get_delegation_work_item(work_item_id)
else:
work_item = None
if work_item is not None and getattr(work_item, "phase", None) not in {Phase.APPROVED, Phase.FAILED, Phase.CANCELLED}:
phase = getattr(work_item, "phase", Phase.RUNNING)
phase_value = phase.value if isinstance(phase, Phase) else str(phase or "")
original_claim = {
"claimed_by_role_runtime_session_id": str(getattr(work_item, "claimed_by_role_runtime_session_id", "") or ""),
"claimed_by_seat_id": str(getattr(work_item, "claimed_by_seat_id", "") or ""),
"claimed_by_role_session_id": str((getattr(work_item, "metadata", {}) or {}).get("claimed_by_role_session_id", "") or ""),
"claimed_task_id": str((getattr(work_item, "metadata", {}) or {}).get("claimed_task_id", "") or claimed_task.id),
}
await self.store.update_delegation_work_item(
work_item_id,
metadata_updates={
"dispatch_hold": "company_runtime_suspended",
"suspended_at": datetime.now().isoformat(),
"suspend_reason": claimed_task.metadata.get("last_stop_reason", "runtime_cancelled"),
"suspended_phase": phase_value,
"suspended_task_status": claimed_task.metadata.get("suspended_task_status", ""),
"suspended_claim": original_claim,
"claimed_by_role_session_id": "",
"claimed_task_id": "",
},
claimed_by_role_runtime_session_id="",
claimed_by_seat_id="",
)
claimed_task.metadata["dispatch_hold"] = "company_runtime_suspended"
claimed_task.metadata["suspended_phase"] = phase_value
except Exception:
logger.opt(exception=True).debug(
"company runtime cancellation: failed suspend hold release",
)
if self.save_task and self._store_is_ready(self.store):
try:
await self.save_task(claimed_task)
except Exception:
logger.opt(exception=True).debug(
"company runtime cancellation: failed suspended task save",
)
raise
finally:
# Drain any work items still running (shouldn't happen given the
@@ -4739,6 +4760,97 @@ class CompanyWorkItemExecutor:
active_work_item_tasks.clear()
return self._summarize_multi_team_org_results(tasks)
@staticmethod
def _runtime_scope_for_tasks(tasks: list[Task]) -> tuple[str, str]:
project_id = "default"
runtime_session_id = ""
for task in tasks:
project_id = str(task.project_id or project_id).strip() or "default"
metadata = dict(task.metadata or {})
runtime_session_id = str(
getattr(task, "parent_session_id", "")
or metadata.get("company_runtime_root_session_id")
or metadata.get("parent_session_id")
or ""
).strip()
if runtime_session_id:
return project_id, runtime_session_id
for task in tasks:
runtime_session_id = str(getattr(task, "session_id", "") or "").strip()
if runtime_session_id:
return project_id, runtime_session_id
return project_id, ""
async def _claim_and_create_work_item_tasks(
self,
tasks: list[Task],
work_items: list[DelegationWorkItem],
active_work_item_tasks: dict[
asyncio.Task[TaskResult | None],
tuple[CompanyMemberSession, Task],
],
) -> list[tuple[CompanyMemberSession, Task]]:
"""Keep durable claim and coroutine ownership in one scope boundary."""
async def claim_and_create() -> list[tuple[CompanyMemberSession, Task]]:
claims = await self.runtime.claim_runnable_tasks(
tasks,
work_items=work_items,
)
for member_session, claimed_task in claims:
work_item_task = self._create_claimed_work_item_task(
member_session,
claimed_task,
{},
)
active_work_item_tasks[work_item_task] = (
member_session,
claimed_task,
)
return claims
registry = self.active_task_run_registry
project_id, runtime_session_id = self._runtime_scope_for_tasks(tasks)
if registry is None or not runtime_session_id:
return await claim_and_create()
async with registry.scope_lock(project_id, runtime_session_id):
return await claim_and_create()
def _create_claimed_work_item_task(
self,
member_session: CompanyMemberSession,
task: Task,
task_by_projection_id: dict[str, Task],
) -> asyncio.Task[TaskResult | None]:
"""Register ownership before scheduling the full claimed-item coroutine."""
registry = self.active_task_run_registry
project_id = str(task.project_id or "default").strip() or "default"
attempt_token = ""
if registry is not None:
try:
attempt_token = registry.register(project_id, task.id)
except ActiveTaskRunAdmissionClosed as exc:
raise asyncio.CancelledError(str(exc)) from exc
async def run_owned() -> TaskResult | None:
try:
return await self._run_claimed_work_item(
member_session,
task,
task_by_projection_id,
)
finally:
if registry is not None and attempt_token:
registry.unregister(project_id, task.id, attempt_token)
try:
return asyncio.create_task(run_owned())
except BaseException:
if registry is not None and attempt_token:
registry.unregister(project_id, task.id, attempt_token)
raise
async def _run_claimed_work_item(
self,
member_session: CompanyMemberSession,
@@ -5298,69 +5410,10 @@ class CompanyWorkItemExecutor:
timeout=self.work_item_timeout,
)
except asyncio.CancelledError:
task.metadata = dict(task.metadata or {})
task.metadata["company_runtime_suspended_at"] = datetime.now().isoformat()
task.metadata.setdefault("last_stop_reason", "runtime_cancelled")
task.metadata["company_runtime_stop_state"] = "suspended"
task.metadata["company_runtime_stop_marked_at"] = (
task.metadata.get("company_runtime_stop_marked_at") or datetime.now().isoformat()
)
task.metadata.setdefault(
"suspended_task_status",
task.status.value if isinstance(task.status, TaskStatus) else str(task.status or ""),
)
work_item_id = linked_work_item_id_for_task(task)
store_ready = self._store_is_ready(self.store)
if work_item_id and self.store and store_ready:
task.metadata.pop("progress_log", None)
await append_work_item_progress(
self.store,
work_item_id,
"Work item suspended by runtime cancellation.",
)
else:
progress = list(task.metadata.get("progress_log", []) or [])
progress.append("Work item suspended by runtime cancellation.")
task.metadata["progress_log"] = progress[-20:]
try:
work_item = (
await self.store.get_delegation_work_item(work_item_id)
if work_item_id and store_ready and hasattr(self.store, "get_delegation_work_item")
else None
)
if work_item is not None and getattr(work_item, "phase", None) not in {Phase.APPROVED, Phase.FAILED, Phase.CANCELLED}:
phase = getattr(work_item, "phase", Phase.RUNNING)
phase_value = phase.value if isinstance(phase, Phase) else str(phase or "")
original_claim = {
"claimed_by_role_runtime_session_id": str(getattr(work_item, "claimed_by_role_runtime_session_id", "") or ""),
"claimed_by_seat_id": str(getattr(work_item, "claimed_by_seat_id", "") or ""),
"claimed_by_role_session_id": str((getattr(work_item, "metadata", {}) or {}).get("claimed_by_role_session_id", "") or ""),
"claimed_task_id": str((getattr(work_item, "metadata", {}) or {}).get("claimed_task_id", "") or task.id),
}
await self.store.update_delegation_work_item(
work_item_id,
metadata_updates={
"dispatch_hold": "company_runtime_suspended",
"suspended_at": datetime.now().isoformat(),
"suspend_reason": task.metadata.get("last_stop_reason", "runtime_cancelled"),
"suspended_phase": phase_value,
"suspended_task_status": task.metadata.get("suspended_task_status", ""),
"suspended_claim": original_claim,
"claimed_by_role_session_id": "",
"claimed_task_id": "",
},
claimed_by_role_runtime_session_id="",
claimed_by_seat_id="",
)
task.metadata["dispatch_hold"] = "company_runtime_suspended"
task.metadata["suspended_phase"] = phase_value
task.status = task_status_for_phase(phase) if isinstance(phase, Phase) else task.status
except Exception:
logger.opt(exception=True).debug(
"company runtime cancellation: failed to apply suspend hold",
)
if self.save_task and self._store_is_ready(self.store):
await self.save_task(task)
# Suspension is a checkpoint transition owned by OPCEngine.
# This task object may be stale by the time cancellation is
# observed, so persisting it here can erase the canonical
# checkpoint type, stop intent, or WorkItem hold.
raise
except asyncio.TimeoutError:
logger.error(f"Company work item {projection_id} timed out after {self.work_item_timeout}s")
+73 -35
View File
@@ -1373,7 +1373,6 @@ class CompanyRuntime:
_skip("no task materialized for work_item this tick",
session=session_label, work_item_id=work_item_id)
continue
self._claimed_work_item_ids.add(work_item_id)
else:
task_id = queued_item_id
self._queued_task_ids.discard(task_id)
@@ -1392,6 +1391,30 @@ class CompanyRuntime:
status=getattr(task, "status", None))
continue
self._claimed_task_ids.add(task_id)
if work_item is not None:
claimed = await self._claim_role_session_work_item(
session,
work_item,
task,
)
if not claimed:
fresh_work_item = None
get_work_item = getattr(
self.store,
"get_delegation_work_item",
None,
)
if callable(get_work_item):
fresh_work_item = await get_work_item(work_item_id)
if fresh_work_item is not None:
work_item_map[work_item_id] = fresh_work_item
_skip(
"atomic WorkItem claim lost to a phase/hold/owner update",
session=session_label,
work_item_id=work_item_id,
)
continue
self._claimed_work_item_ids.add(work_item_id)
if can_soft_wake and (
bool((task.metadata or {}).get("review_task", False))
or bool((task.metadata or {}).get("review_execution_work_item", False))
@@ -1412,7 +1435,6 @@ class CompanyRuntime:
self.prepare_task_for_session(session, task)
await self._sync_current_turn_mode_to_work_item(task, session.current_turn_mode)
if work_item is not None:
await self._claim_role_session_work_item(session, work_item, task)
# #7: mirror the claim onto the in-memory work_item so
# subsequent iterations in the same claim pass see it
# and is_dispatchable returns False (race safety after
@@ -1908,21 +1930,65 @@ class CompanyRuntime:
self.role_sessions[role_session_id] = role_session
return role_session
async def _claim_role_session_work_item(self, session: CompanyMemberSession, work_item: Any, task: Task) -> None:
async def _claim_role_session_work_item(
self,
session: CompanyMemberSession,
work_item: Any,
task: Task,
) -> bool:
"""Atomically claim ``work_item`` for the role-instance behind
``session``.
In the role-instance model the claim identity is
``role_runtime_session_id``. Seat / manager-seat columns are
still written for org-chart lookups but they are NOT part of
the claim key — only the role session is.
the claim key — only the role session is. Pure in-memory runtimes have
no durable race to arbitrate and keep the same local claim semantics.
"""
work_item_id = str(getattr(work_item, "work_item_id", "") or "").strip()
if not work_item_id:
return
return False
role_session = self._ensure_role_session(task)
if role_session is None:
return
return False
work_item_revision = 0
try:
work_item_revision = int((getattr(work_item, "metadata", {}) or {}).get("manager_mutation_revision") or 0)
except (TypeError, ValueError):
work_item_revision = 0
store_ready = self.store is not None and bool(
getattr(self.store, "is_ready", False)
)
claim = (
getattr(self.store, "claim_delegation_work_item_if_dispatchable", None)
if store_ready
else None
)
if store_ready:
# A durable runtime must win the store CAS before it mutates any
# in-memory scheduling state. This is the Stop/shutdown race
# boundary: a missing CAS API is a failed claim, not permission to
# fall back to the former best-effort update path.
if not callable(claim):
return False
persisted = await claim(
work_item_id,
expected_phase=getattr(work_item, "phase", Phase.READY),
role_runtime_session_id=role_session.role_session_id,
seat_id=str(getattr(session, "seat_id", "") or "").strip(),
task_id=task.id,
work_item_revision=work_item_revision,
)
if persisted is None:
return False
work_item.phase = persisted.phase
work_item.role_runtime_session_id = persisted.role_runtime_session_id
work_item.claimed_by_role_runtime_session_id = (
persisted.claimed_by_role_runtime_session_id
)
work_item.claimed_by_seat_id = persisted.claimed_by_seat_id
work_item.metadata = dict(persisted.metadata or {})
ready_background_ids = [
item_id
for item_id in list(role_session.background_work_item_ids or [])
@@ -1937,39 +2003,11 @@ class CompanyRuntime:
session.background_work_item_ids = list(role_session.background_work_item_ids)
task.metadata = dict(task.metadata)
task.metadata["delegation_role_session_id"] = role_session.role_session_id
work_item_revision = 0
try:
work_item_revision = int((getattr(work_item, "metadata", {}) or {}).get("manager_mutation_revision") or 0)
except (TypeError, ValueError):
work_item_revision = 0
task.metadata["started_work_item_revision"] = work_item_revision
task.metadata["claimed_work_item_revision"] = work_item_revision
if self.store and bool(getattr(self.store, "is_ready", False)) and hasattr(self.store, "update_delegation_work_item"):
# Do not regress a work item that is already in a review
# phase (AWAITING_MANAGER_REVIEW / AWAITING_HUMAN) back to
# RUNNING: the DB phase validator rejects that transition
# and the error bubbles up to the session loop. This
# happens when the reactivation sweeper wakes a task whose
# work item has already been promoted to review — treat
# the claim as "refresh the task/role-session bindings
# only" and leave the phase alone.
current_phase = getattr(work_item, "phase", None)
phase_to_write: Phase | None = Phase.RUNNING
if current_phase in IN_REVIEW_PHASES:
phase_to_write = None
await self.store.update_delegation_work_item(
work_item_id,
phase=phase_to_write,
role_runtime_session_id=role_session.role_session_id,
claimed_by_role_runtime_session_id=role_session.role_session_id,
metadata_updates={
"claimed_by_role_session_id": role_session.role_session_id,
"claimed_task_id": task.id,
"claimed_work_item_revision": work_item_revision,
},
)
if self.store and bool(getattr(self.store, "is_ready", False)) and hasattr(self.store, "save_delegation_role_session"):
await self.store.save_delegation_role_session(role_session)
return True
def ensure_role_instance_session(
self, task: Task
@@ -0,0 +1,391 @@
"""Canonical company-runtime identity derived from durable records.
Company-mode Tasks are execution envelopes, not the identity of a run. A
runtime is owned by its root session and an active suspend checkpoint. This
module deliberately has no UI dependencies so every surface can resolve the
same scope without relying on process-local task maps.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Iterable
from opc.layer2_organization.work_item_links import linked_work_item_id_for_task
from opc.layer2_organization.work_item_runtime import is_work_item_runtime_metadata
COMPANY_RUNTIME_CHECKPOINT_TYPES: frozenset[str] = frozenset({
"company_runtime_suspended",
"company_runtime_interrupted",
})
ACTIVE_COMPANY_RUNTIME_CHECKPOINT_STATUSES: frozenset[str] = frozenset({
"pending",
"resuming",
})
def _text(value: Any) -> str:
return str(value or "").strip()
def _metadata(task: Any) -> dict[str, Any]:
return dict(getattr(task, "metadata", {}) or {})
def _task_id(task: Any) -> str:
return _text(getattr(task, "id", ""))
def _task_session_id(task: Any) -> str:
return _text(getattr(task, "session_id", ""))
def _task_parent_session_id(task: Any) -> str:
metadata = _metadata(task)
return _text(
getattr(task, "parent_session_id", "")
or metadata.get("company_runtime_root_session_id")
or metadata.get("parent_session_id")
)
def _has_company_runtime_marker(task: Any) -> bool:
metadata = _metadata(task)
exec_mode = _text(metadata.get("exec_mode")).lower()
mode = _text(metadata.get("mode")).lower()
execution_mode = _text(metadata.get("execution_mode")).lower()
if exec_mode in {"company", "org", "custom"} or mode in {"company", "org", "custom"}:
return True
if execution_mode in {"company", "company_mode", "multi_team_org"}:
return True
if is_work_item_runtime_metadata(metadata):
return True
if linked_work_item_id_for_task(task):
return True
if any(
metadata.get(key) not in (None, "", [], {})
for key in (
"company_work_item_plan",
"company_runtime_root_session_id",
"delegation_run_id",
"work_item_projection_id",
"work_item_projection_ref",
"work_item_role_id",
"shared_role_session",
)
):
return True
# Old company records may predate exec_mode. An explicit task-mode marker
# wins over the legacy profile hint.
explicitly_task_mode = (
exec_mode in {"task", "project", "single"}
or mode == "task"
or execution_mode in {"task", "task_mode", "project"}
or _text(metadata.get("task_mode_contract")) == "single_full_capability_main_agent"
)
return not explicitly_task_mode and bool(_text(metadata.get("company_profile")))
def is_company_runtime_task(task: Any) -> bool:
"""Return whether durable Task metadata identifies company-owned work."""
return _has_company_runtime_marker(task)
def is_pure_company_ui_anchor(task: Any, runtime_session_id: str) -> bool:
"""Return whether *task* is the user-facing container for a runtime.
A shared final-decider Task can have the same ``session_id`` as the UI
anchor. Work-item, role, or parent links therefore disqualify a Task even
when its session id is an exact match.
"""
session_id = _text(runtime_session_id)
if not session_id or _task_session_id(task) != session_id:
return False
if _text(getattr(task, "parent_session_id", "")) or _text(getattr(task, "parent_id", "")):
return False
if linked_work_item_id_for_task(task):
return False
metadata = _metadata(task)
return not any(
metadata.get(key) not in (None, "", [], {}, False)
for key in (
"work_item_runtime",
"work_item_projection_id",
"work_item_projection_ref",
"work_item_id",
"work_item_role_id",
"delegation_role_session_id",
"shared_role_session",
"shared_role_id",
"company_runtime_root_session_id",
)
)
def _created_sort_key(value: Any) -> tuple[float, str]:
created_at = getattr(value, "created_at", None)
if isinstance(created_at, datetime):
timestamp = created_at.timestamp()
elif hasattr(created_at, "timestamp"):
try:
timestamp = float(created_at.timestamp())
except Exception:
timestamp = 0.0
else:
timestamp = 0.0
return timestamp, _task_id(value)
def _checkpoint_sort_key(checkpoint: Any) -> tuple[float, float, str]:
def _timestamp(value: Any) -> float:
if isinstance(value, datetime):
return value.timestamp()
if hasattr(value, "timestamp"):
try:
return float(value.timestamp())
except Exception:
return 0.0
return 0.0
return (
_timestamp(getattr(checkpoint, "updated_at", None)),
_timestamp(getattr(checkpoint, "created_at", None)),
_text(getattr(checkpoint, "checkpoint_id", "")),
)
def _checkpoint_runtime_session_id(checkpoint: Any) -> str:
payload = dict(getattr(checkpoint, "payload", {}) or {})
return _text(
getattr(checkpoint, "session_id", "")
or payload.get("parent_session_id")
or payload.get("session_id")
)
@dataclass(frozen=True)
class CompanyRuntimeIdentity:
"""Resolved identity for one company runtime scope."""
project_id: str
runtime_session_id: str
runtime_task_ids: tuple[str, ...]
ui_anchor_task_id: str = ""
config_source_task_id: str = ""
pending_checkpoint_id: str = ""
pending_checkpoint_type: str = ""
pending_checkpoint_status: str = ""
resumable: bool = False
checkpoint: Any | None = field(default=None, repr=False, compare=False)
class CompanyRuntimeIdentityIndex:
"""Session-first index over preloaded Tasks and checkpoints."""
def __init__(self, tasks: Iterable[Any], checkpoints: Iterable[Any] = ()) -> None:
self.tasks = tuple(tasks or ())
self.checkpoints = tuple(checkpoints or ())
self.tasks_by_id = {
_task_id(task): task
for task in self.tasks
if _task_id(task)
}
self.checkpoints_by_id = {
_text(getattr(checkpoint, "checkpoint_id", "")): checkpoint
for checkpoint in self.checkpoints
if _text(getattr(checkpoint, "checkpoint_id", ""))
}
self._identities_by_session = self._build_identities()
self._runtime_session_by_task_id: dict[str, str] = {}
runtime_sessions_by_task_session_id: dict[str, set[str]] = {}
for runtime_session_id, identity in self._identities_by_session.items():
for task_id in identity.runtime_task_ids:
self._runtime_session_by_task_id[task_id] = runtime_session_id
task_session_id = _task_session_id(self.tasks_by_id.get(task_id))
if task_session_id:
runtime_sessions_by_task_session_id.setdefault(
task_session_id,
set(),
).add(runtime_session_id)
self._runtime_session_by_task_session_id = {
task_session_id: next(iter(runtime_session_ids))
for task_session_id, runtime_session_ids in runtime_sessions_by_task_session_id.items()
if len(runtime_session_ids) == 1
}
@property
def identities(self) -> tuple[CompanyRuntimeIdentity, ...]:
return tuple(self._identities_by_session.values())
def task(self, task_id: str) -> Any | None:
return self.tasks_by_id.get(_text(task_id))
def resolve(
self,
*,
task_id: str = "",
task_session_id: str = "",
runtime_session_id: str = "",
checkpoint_id: str = "",
) -> CompanyRuntimeIdentity | None:
requested_task_id = _text(task_id)
requested_task_session_id = _text(task_session_id)
requested_session_id = _text(runtime_session_id)
requested_checkpoint_id = _text(checkpoint_id)
task_scope_id = self._runtime_session_by_task_id.get(requested_task_id, "")
session_scope_id = self._runtime_session_by_task_session_id.get(
requested_task_session_id,
"",
)
checkpoint = self.checkpoints_by_id.get(requested_checkpoint_id) if requested_checkpoint_id else None
checkpoint_session_id = _checkpoint_runtime_session_id(checkpoint) if checkpoint is not None else ""
candidates = {
value
for value in (
requested_session_id,
task_scope_id,
session_scope_id,
checkpoint_session_id,
)
if value
}
if requested_task_session_id and not session_scope_id:
return None
if len(candidates) != 1:
return None
resolved_session_id = next(iter(candidates))
identity = self._identities_by_session.get(resolved_session_id)
if identity is None:
return None
if requested_task_id and requested_task_id not in identity.runtime_task_ids:
return None
if requested_checkpoint_id and requested_checkpoint_id != identity.pending_checkpoint_id:
return None
return identity
def _build_identities(self) -> dict[str, CompanyRuntimeIdentity]:
active_checkpoints_by_session: dict[str, list[Any]] = {}
for checkpoint in self.checkpoints:
checkpoint_type = _text(getattr(checkpoint, "checkpoint_type", ""))
checkpoint_status = _text(getattr(checkpoint, "status", "")).lower()
if (
checkpoint_type not in COMPANY_RUNTIME_CHECKPOINT_TYPES
or checkpoint_status not in ACTIVE_COMPANY_RUNTIME_CHECKPOINT_STATUSES
):
continue
runtime_session_id = _checkpoint_runtime_session_id(checkpoint)
if runtime_session_id:
active_checkpoints_by_session.setdefault(runtime_session_id, []).append(checkpoint)
known_sessions = set(active_checkpoints_by_session)
for task in self.tasks:
if not _has_company_runtime_marker(task):
continue
runtime_session_id = _task_parent_session_id(task) or _task_session_id(task)
if runtime_session_id:
known_sessions.add(runtime_session_id)
tasks_by_session: dict[str, list[Any]] = {session_id: [] for session_id in known_sessions}
for task in self.tasks:
task_id = _task_id(task)
if not task_id:
continue
parent_session_id = _task_parent_session_id(task)
own_session_id = _task_session_id(task)
runtime_session_id = parent_session_id or own_session_id
if runtime_session_id not in known_sessions:
continue
if not (
_has_company_runtime_marker(task)
or runtime_session_id in active_checkpoints_by_session
or is_pure_company_ui_anchor(task, runtime_session_id)
):
continue
tasks_by_session.setdefault(runtime_session_id, []).append(task)
identities: dict[str, CompanyRuntimeIdentity] = {}
for runtime_session_id in sorted(known_sessions):
group = sorted(tasks_by_session.get(runtime_session_id, []), key=_created_sort_key)
anchor = next(
(task for task in group if is_pure_company_ui_anchor(task, runtime_session_id)),
None,
)
def _has_runtime_config(task: Any) -> bool:
metadata = _metadata(task)
return any(
metadata.get(key) not in (None, "", [], {})
for key in (
"exec_mode",
"mode",
"company_profile",
"org_id",
"organization_id",
"preferred_agent",
"selected_execution_agent",
)
)
config_source = (
anchor if anchor is not None and _has_runtime_config(anchor) else None
) or next(
(
task for task in group
if _has_runtime_config(task)
),
anchor or (group[0] if group else None),
)
checkpoint_candidates = active_checkpoints_by_session.get(runtime_session_id, [])
checkpoint = max(checkpoint_candidates, key=_checkpoint_sort_key) if checkpoint_candidates else None
checkpoint_status = _text(getattr(checkpoint, "status", "")).lower() if checkpoint is not None else ""
project_id = _text(
getattr(checkpoint, "project_id", "") if checkpoint is not None else ""
) or _text(getattr(config_source, "project_id", "") if config_source is not None else "") or "default"
identities[runtime_session_id] = CompanyRuntimeIdentity(
project_id=project_id,
runtime_session_id=runtime_session_id,
runtime_task_ids=tuple(_task_id(task) for task in group if _task_id(task)),
ui_anchor_task_id=_task_id(anchor) if anchor is not None else "",
config_source_task_id=_task_id(config_source) if config_source is not None else "",
pending_checkpoint_id=_text(getattr(checkpoint, "checkpoint_id", "")) if checkpoint is not None else "",
pending_checkpoint_type=_text(getattr(checkpoint, "checkpoint_type", "")) if checkpoint is not None else "",
pending_checkpoint_status=checkpoint_status,
resumable=checkpoint_status == "pending",
checkpoint=checkpoint,
)
return identities
def build_company_runtime_identity_index(
tasks: Iterable[Any],
checkpoints: Iterable[Any] = (),
) -> CompanyRuntimeIdentityIndex:
return CompanyRuntimeIdentityIndex(tasks, checkpoints)
async def load_company_runtime_identity_index(
store: Any,
project_id: str,
) -> CompanyRuntimeIdentityIndex:
"""Load durable records once and build the canonical runtime index."""
tasks = await store.get_tasks(project_id=project_id)
checkpoint_getter = getattr(store, "get_execution_checkpoints", None)
if callable(checkpoint_getter):
checkpoints = await checkpoint_getter(
project_id=project_id,
checkpoint_types=sorted(COMPANY_RUNTIME_CHECKPOINT_TYPES),
statuses=sorted(ACTIVE_COMPANY_RUNTIME_CHECKPOINT_STATUSES),
)
else:
checkpoint_getter = getattr(store, "get_pending_checkpoints", None)
checkpoints = await checkpoint_getter(
project_id=project_id,
checkpoint_types=sorted(COMPANY_RUNTIME_CHECKPOINT_TYPES),
) if callable(checkpoint_getter) else []
return build_company_runtime_identity_index(tasks, checkpoints)
@@ -75,6 +75,8 @@ class CustomRuntimeRunner:
store=shared_store,
owns_store=shared_store is None,
run_startup_reconcile=shared_store is None,
active_task_run_registry=getattr(self.parent, "_active_task_run_registry", None),
owns_active_task_run_registry=False,
on_progress=self.parent.on_progress,
on_runtime_event=self.parent.on_runtime_event,
on_escalation=self.parent.on_escalation,
+4 -2
View File
@@ -144,8 +144,10 @@ class EngineSeatExecutor:
member_session: CompanyMemberSession | None = None,
) -> None:
_ = member_session
if hasattr(self.host, "_active_task_runs"):
self.host._active_task_runs.discard(task.id)
# The task coroutine owns its registry attempt token and removes it in
# ``finally``. Interrupt requests must not make a still-running
# coroutine appear inactive.
return None
async def shutdown(
self,
-297
View File
@@ -1,297 +0,0 @@
"""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)
-39
View File
@@ -15,7 +15,6 @@ from opc.plugins.cli_board.state.store import BoardStateStore
from opc.plugins.cli_board.tui.screens.help import HelpScreen
from opc.plugins.cli_board.tui.screens.palette import CommandPaletteScreen, PaletteCommand
from opc.plugins.cli_board.tui.screens.prompt import PromptField, PromptScreen
from opc.plugins.cli_board.tui.screens.recovery import RecoveryAction, RecoveryScreen
from opc.plugins.cli_board.widgets.activity_pane import ActivityPaneWidget
from opc.plugins.cli_board.widgets.context_tabs import ContextTabsWidget
from opc.plugins.cli_board.widgets.detail_pane import DetailPaneWidget
@@ -36,7 +35,6 @@ if TYPE_CHECKING:
from opc.plugins.cli_board.services.engine_facade import EngineFacade
from opc.plugins.cli_board.services.event_bridge import CliBoardEventBridge
from opc.plugins.cli_board.services.reconcile import ReconcileLoop
from opc.plugins.cli_board.services.recovery import CliRecoveryManager
class CliBoardApp(App[None]):
@@ -74,7 +72,6 @@ class CliBoardApp(App[None]):
Binding("x", "cancel_task", "Cancel"),
Binding("t", "retry_selected", "Retry"),
Binding("e", "checkpoint_feedback", "Feedback"),
Binding("w", "recovery_scan", "Recovery"),
Binding("R", "rename_session", "Rename", show=False),
Binding("D", "delete_session", "Delete", show=False),
Binding("E", "switch_mode", "Mode", show=False),
@@ -115,7 +112,6 @@ class CliBoardApp(App[None]):
self.repository: BoardRepository | None = None
self.actions: BoardActions | None = None
self.event_bridge: CliBoardEventBridge | None = None
self.recovery_manager: CliRecoveryManager | None = None
self.reconcile_loop: ReconcileLoop | None = None
self.exec_mode = "task"
self.company_profile = "corporate"
@@ -143,13 +139,10 @@ class CliBoardApp(App[None]):
from opc.plugins.cli_board.services.engine_facade import EngineFacade
from opc.plugins.cli_board.services.event_bridge import CliBoardEventBridge
from opc.plugins.cli_board.services.recovery import CliRecoveryManager
self.facade = EngineFacade(project_id=self.project_id)
self.repository = BoardRepository(self.facade, project_id=self.project_id)
self.actions = BoardActions(self.facade, project_id=self.project_id)
self.event_bridge = CliBoardEventBridge(self._handle_board_event)
self.recovery_manager = CliRecoveryManager(self.facade)
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
@@ -650,36 +643,6 @@ class CliBoardApp(App[None]):
success_message=f"{label} checkpoint for {task.title}{suffix}.",
)
def action_recovery_scan(self) -> None:
if self._readonly_guard():
return
self._action_recovery_scan()
@work(group="modal", exclusive=True)
async def _action_recovery_scan(self) -> None:
if self.recovery_manager is None:
self.status_widget.set_message("Recovery unavailable.")
return
status = await self.recovery_manager.get_status()
result = await self.push_screen_wait(RecoveryScreen(status))
if result is None:
return
if result.action == "resume":
self.status_widget.set_message(f"Resuming {result.parent_task_id}...")
outcome = await self.recovery_manager.resume(result.parent_task_id)
if outcome.get("ok"):
ids = outcome.get("resumed_work_item_projection_ids", [])
self.status_widget.set_message(f"Resumed {len(ids)} work item(s).")
else:
self.status_widget.set_message(f"Resume failed: {outcome.get('error', '?')}.")
elif result.action == "cancel":
outcome = await self.recovery_manager.cancel(result.parent_task_id)
if outcome.get("ok"):
self.status_widget.set_message(f"Cancelled {outcome.get('cancelled_count', 0)} task(s).")
else:
self.status_widget.set_message(f"Cancel failed: {outcome.get('error', '?')}.")
await self._refresh_snapshot(reason="recovery", silent=True)
def action_rename_session(self) -> None:
if self._readonly_guard():
return
@@ -1354,7 +1317,6 @@ class CliBoardApp(App[None]):
PaletteCommand("view_focus", "Switch to Focus", "Zoom into the selected task.", "3"),
PaletteCommand("view_pipeline", "Switch to Projection", "Show the read-only work-item projection for the selected company run.", "4"),
PaletteCommand("view_org", "Switch to Organisation", "Show read-only org structure.", "5"),
PaletteCommand("recovery_scan", "Runtime Recovery", "Scan and resume interrupted company runtimes.", "w"),
PaletteCommand("rename_session", "Rename Session", "Change the title of the selected task.", "R"),
PaletteCommand("delete_session", "Delete Session", "Cancel and remove the selected task.", "D"),
PaletteCommand("purge_cancelled", "Purge Cancelled Tasks", "Permanently delete all cancelled/failed tasks.", ""),
@@ -1382,7 +1344,6 @@ class CliBoardApp(App[None]):
"project_delete",
"session_config",
"org_add_role",
"recovery_scan",
"rename_session",
"delete_session",
"purge_cancelled",
+1 -1
View File
@@ -49,7 +49,7 @@ class HelpScreen(ModalScreen[None]):
" s: reply in session m: move between columns\n"
" a / d: approve / deny checkpoint\n"
" e: checkpoint feedback (approve/deny with message)\n"
" c: done x: cancel t: retry w: runtime recovery\n"
" c: done x: cancel t: retry\n"
"\n"
"Session Management\n"
" R: rename session D: delete session\n"
@@ -1,126 +0,0 @@
"""Recovery modal screen for the CLI board."""
from __future__ import annotations
from dataclasses import dataclass
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.screen import ModalScreen
from textual.widgets import Button, Label, Static
from opc.plugins.cli_board.services.recovery import RecoveryStatus
@dataclass
class RecoveryAction:
action: str # "resume" | "cancel" | "dismiss"
parent_task_id: str = ""
class RecoveryScreen(ModalScreen[RecoveryAction | None]):
"""Show interrupted company runtimes with resume/cancel options."""
DEFAULT_CSS = """
RecoveryScreen {
align: center middle;
}
.recovery-dialog {
width: 88;
max-width: 90%;
height: auto;
max-height: 80%;
border: solid $primary;
background: $surface;
padding: 1 2;
}
.recovery-title {
text-style: bold;
margin-bottom: 1;
}
.recovery-runtime {
margin-bottom: 1;
padding: 1;
border: round $secondary;
}
.recovery-actions {
align-horizontal: right;
height: auto;
margin-top: 1;
}
.recovery-empty {
color: $text-muted;
margin: 1;
}
"""
BINDINGS = [("escape", "dismiss_screen", "Close")]
def __init__(self, status: RecoveryStatus) -> None:
super().__init__()
self.status = status
def compose(self) -> ComposeResult:
with Vertical(classes="recovery-dialog"):
yield Static("Interrupted Runtimes", classes="recovery-title")
if not self.status.interrupted:
yield Static("No interrupted runtimes found.", classes="recovery-empty")
else:
with VerticalScroll():
for wf in self.status.interrupted:
with Vertical(classes="recovery-runtime"):
# Runtime header
active = wf.parent_task_id in set(self.status.active_recoveries)
status_label = " (recovering...)" if active else ""
yield Label(f"{wf.title}{status_label}")
yield Static(
f" Profile: {wf.profile or 'unknown'} "
f"Interrupted: {wf.interrupted_at[:19] if wf.interrupted_at else '?'}"
)
# Work-item summary
done = sum(1 for s in wf.work_items if s.status == "done")
total = len(wf.work_items)
failed = sum(1 for s in wf.work_items if s.interrupted)
yield Static(
f" Work items: {done}/{total} done, {failed} interrupted"
)
if not active:
with Horizontal():
yield Button(
"Resume",
id=f"resume-{wf.parent_task_id}",
variant="primary",
)
yield Button(
"Cancel",
id=f"cancel-{wf.parent_task_id}",
variant="error",
)
with Horizontal(classes="recovery-actions"):
yield Button("Close", id="close-recovery")
def on_button_pressed(self, event: Button.Pressed) -> None:
btn_id = event.button.id or ""
if btn_id == "close-recovery":
self.dismiss(None)
return
if btn_id.startswith("resume-"):
task_id = btn_id[len("resume-"):]
self.dismiss(RecoveryAction(action="resume", parent_task_id=task_id))
return
if btn_id.startswith("cancel-"):
task_id = btn_id[len("cancel-"):]
self.dismiss(RecoveryAction(action="cancel", parent_task_id=task_id))
return
def action_dismiss_screen(self) -> None:
self.dismiss(None)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:," />
<title>OpenOPC Pixel Office</title>
<script type="module" crossorigin src="./assets/index-02tfsorH.js"></script>
<script type="module" crossorigin src="./assets/index-Cson66Y4.js"></script>
<link rel="modulepreload" crossorigin href="./assets/phaser-DFK5Ua9d.js">
<link rel="stylesheet" crossorigin href="./assets/index-BCWwLlJm.css">
<link rel="stylesheet" crossorigin href="./assets/index-DEvLDWDw.css">
</head>
<body>
<div id="root"></div>
+7 -23
View File
@@ -476,7 +476,6 @@ export default function App() {
const [globalCompanyProfile, setGlobalCompanyProfile] = useState<'corporate' | 'custom'>('corporate')
const [globalTaskPreferredAgent, setGlobalTaskPreferredAgent] = useState<TaskPreferredAgent>('native')
const [orgInfoData, setOrgInfoData] = useState<OrgInfoPayload | null>(null)
const [recoveryStatus, setRecoveryStatus] = useState<any>(null)
const [commsState, setCommsState] = useState<import('./lib/wsClient').CommsStatePayload | null>(null)
const [commsMessage, setCommsMessage] = useState<import('./lib/wsClient').CommsMessagePayload | null>(null)
const [talentTemplates, setTalentTemplates] = useState<TalentTemplate[]>([])
@@ -1239,7 +1238,6 @@ export default function App() {
runtimeControlState: String(payload.runtime_control_state ?? payload.runtimeControlState ?? 'idle') as any,
canStop: Boolean(payload.can_stop ?? payload.canStop),
canResume: Boolean(payload.can_resume ?? payload.canResume),
resumeParentTaskId: String(payload.resume_parent_task_id ?? payload.resumeParentTaskId ?? ''),
resumeParentSessionId: String(payload.resume_parent_session_id ?? payload.resumeParentSessionId ?? ''),
pendingRuntimeCheckpointId: String(payload.pending_runtime_checkpoint_id ?? payload.pendingRuntimeCheckpointId ?? ''),
stopIntentId: String(payload.stop_intent_id ?? payload.stopIntentId ?? ''),
@@ -1749,10 +1747,6 @@ export default function App() {
clientRef.current?.collabSync(getActiveProjectId(), undefined, projectViewGenerationRef.current)
}
},
onRecoveryStatus: (payload) => {
if (!payloadMatchesActiveProject(payload as unknown as Record<string, unknown>, false)) return
setRecoveryStatus(payload)
},
onCommsState: (payload) => {
if (!payloadMatchesActiveProject(payload as unknown as Record<string, unknown>, false)) return
setCommsState(payload)
@@ -1761,13 +1755,6 @@ export default function App() {
if (!payloadMatchesActiveProject(payload as unknown as Record<string, unknown>, true)) return
setCommsMessage(payload)
},
onRecoveryResult: (payload) => {
if (!payloadMatchesActiveProject(payload as unknown as Record<string, unknown>, false)) return
if (payload?.status === 'completed' || payload?.status === 'cancelled') {
// Trigger a re-scan
clientRef.current?.recoveryAction(getActiveProjectId(), 'scan')
}
},
onTalentList: (payload) => {
setTalentTemplates(payload.templates ?? [])
if (payload.talent_dir) setDefaultTalentDir(payload.talent_dir)
@@ -2242,18 +2229,13 @@ export default function App() {
) => {
const session = sessionStore.sessions.find(s => s.taskId === taskId)
const parentSessionId = session?.resumeParentSessionId ?? session?.parentSessionId ?? session?.sessionId
const parentTaskId = session?.resumeParentTaskId
?? (parentSessionId ? sessionStore.sessions.find(s => s.sessionId === parentSessionId && !s.parentSessionId)?.taskId : undefined)
?? taskId
for (const candidate of sessionStore.sessions) {
if (
candidate.taskId === taskId
|| candidate.taskId === parentTaskId
|| (!!parentSessionId && (candidate.parentSessionId === parentSessionId || candidate.sessionId === parentSessionId))
) {
sessionStore.updateSession(candidate.taskId, {
...patch,
resumeParentTaskId: parentTaskId,
resumeParentSessionId: parentSessionId,
})
}
@@ -2278,7 +2260,7 @@ export default function App() {
clientRef.current?.sessionStop(getActiveProjectId(), taskId)
}, [sessionStore.sessions, markRuntimeControlForTask, getActiveProjectId])
const handleSessionResume = useCallback((taskId: string) => {
const handleSessionResume = useCallback((taskId: string, runtimeSessionId?: string, checkpointId?: string) => {
const session = sessionStore.sessions.find(s => s.taskId === taskId)
const isCompanyRuntime = session?.execMode === 'company'
|| session?.execMode === 'org'
@@ -2293,7 +2275,12 @@ export default function App() {
canResume: false,
})
}
clientRef.current?.sessionResume(getActiveProjectId(), taskId)
clientRef.current?.sessionResume(
getActiveProjectId(),
taskId,
runtimeSessionId ?? session?.resumeParentSessionId ?? session?.parentSessionId ?? session?.sessionId,
checkpointId ?? session?.pendingRuntimeCheckpointId,
)
}, [sessionStore.sessions, markRuntimeControlForTask, getActiveProjectId])
const handleGlobalModeChange = useCallback((mode: 'task' | 'company' | 'org' | 'custom', profile?: string, orgId?: string) => {
@@ -2426,9 +2413,6 @@ export default function App() {
activeSavedOrg={activeSavedOrg}
onSavedOrgsList={handleSavedOrgsList}
onSavedOrgLoad={handleSavedOrgLoad}
recoveryStatus={recoveryStatus}
onRecoveryResume={(id) => clientRef.current?.recoveryAction(getActiveProjectId(), 'resume', id)}
onRecoveryCancel={(id) => clientRef.current?.recoveryAction(getActiveProjectId(), 'cancel', id)}
commsState={commsState}
commsMessage={commsMessage}
onCommsRefresh={(opts) => {
@@ -676,7 +676,6 @@ export function mapBackendSession(raw: any): Session {
runtimeControlState: raw.runtime_control_state ?? raw.runtimeControlState,
canStop: raw.can_stop ?? raw.canStop,
canResume: raw.can_resume ?? raw.canResume,
resumeParentTaskId: raw.resume_parent_task_id ?? raw.resumeParentTaskId,
resumeParentSessionId: raw.resume_parent_session_id ?? raw.resumeParentSessionId,
pendingRuntimeCheckpointId: raw.pending_runtime_checkpoint_id ?? raw.pendingRuntimeCheckpointId,
stopIntentId: raw.stop_intent_id ?? raw.stopIntentId,
@@ -387,7 +387,6 @@ export function getConversationSessionView(
runtimeControlState: runtimeSource.runtimeControlState ?? normalizedActiveSession.runtimeControlState,
canStop: runtimeSource.canStop ?? normalizedActiveSession.canStop,
canResume: runtimeSource.canResume ?? normalizedActiveSession.canResume,
resumeParentTaskId: runtimeSource.resumeParentTaskId ?? normalizedActiveSession.resumeParentTaskId,
resumeParentSessionId: runtimeSource.resumeParentSessionId ?? normalizedActiveSession.resumeParentSessionId,
pendingRuntimeCheckpointId: runtimeSource.pendingRuntimeCheckpointId ?? normalizedActiveSession.pendingRuntimeCheckpointId,
stopIntentId: runtimeSource.stopIntentId ?? normalizedActiveSession.stopIntentId,
@@ -447,7 +446,6 @@ export function getConversationHeaderSession(
runtimeControlState: runtimeSource.runtimeControlState ?? normalizedActiveSession.runtimeControlState,
canStop: runtimeSource.canStop ?? normalizedActiveSession.canStop,
canResume: runtimeSource.canResume ?? normalizedActiveSession.canResume,
resumeParentTaskId: runtimeSource.resumeParentTaskId ?? normalizedActiveSession.resumeParentTaskId,
resumeParentSessionId: runtimeSource.resumeParentSessionId ?? normalizedActiveSession.resumeParentSessionId,
pendingRuntimeCheckpointId: runtimeSource.pendingRuntimeCheckpointId ?? normalizedActiveSession.pendingRuntimeCheckpointId,
stopIntentId: runtimeSource.stopIntentId ?? normalizedActiveSession.stopIntentId,
@@ -30,6 +30,17 @@ const flushPromises = async () => {
const client = new VisualSocketClient('ws://unit.test', {})
// Company Continue keeps the selected UI channel task separate from the
// durable runtime identity used by the checkpoint handoff.
client.sessionResume('project-a', 'ui-task', 'runtime-session', 'checkpoint-1')
const resumeEnvelope = JSON.parse(
(client as unknown as TestSocketClient).pendingQueue.pop() ?? '{}',
) as Record<string, unknown>
assert.equal(resumeEnvelope.type, 'session_resume')
assert.equal(resumeEnvelope.task_id, 'ui-task')
assert.equal(resumeEnvelope.runtime_session_id, 'runtime-session')
assert.equal(resumeEnvelope.checkpoint_id, 'checkpoint-1')
// A summary and a full request for the same task are distinct correlations.
// Neither Promise may settle merely because the request was queued locally.
const summaryPromise = client.sessionDetail('project-a', 'task-1', { detailLevel: 'summary' })
@@ -41,8 +41,6 @@ interface SocketHandlers {
onProjectSwitched?: (payload: { project_id: string; switch_seq?: string }) => void
onProjectDeleted?: (payload: { project_id: string }) => void
onOrgInfo?: (payload: OrgInfoPayload) => void
onRecoveryStatus?: (payload: any) => void
onRecoveryResult?: (payload: any) => void
onTalentList?: (payload: TalentListPayload) => void
onTalentScanLocal?: (payload: { templates: Array<{ template_id: string; name: string; description: string; category: string; domains: string[]; tags: string[] }> }) => void
onEmployeeDetail?: (payload: EmployeeDetailPayload) => void
@@ -169,7 +167,6 @@ const PROJECT_SCOPED_MESSAGE_TYPES = new Set([
'session_update_title',
'secretary_send',
'project_index',
'recovery_action',
'comms_state',
'comms_read_message',
])
@@ -420,9 +417,22 @@ export class VisualSocketClient {
this.send({ type: 'session_stop', project_id: pid, task_id: taskId })
}
sessionResume(projectId: string, taskId: string, content?: string): void {
sessionResume(
projectId: string,
taskId: string,
runtimeSessionId?: string,
checkpointId?: string,
content?: string,
): void {
const pid = this.requireProjectId(projectId, 'session_resume')
this.send({ type: 'session_resume', project_id: pid, task_id: taskId, content })
this.send({
type: 'session_resume',
project_id: pid,
task_id: taskId,
runtime_session_id: runtimeSessionId,
checkpoint_id: checkpointId,
content,
})
}
sessionComplete(projectId: string, taskId: string): void {
@@ -646,11 +656,6 @@ export class VisualSocketClient {
this.send({ type: 'org_saved_delete', name })
}
recoveryAction(projectId: string, action: 'resume' | 'cancel' | 'scan', parentTaskId?: string): void {
const pid = this.requireProjectId(projectId, 'recovery_action')
this.send({ type: 'recovery_action', project_id: pid, action, parent_task_id: parentTaskId })
}
commsState(projectId: string, opts?: { task_id?: string; session_id?: string }): void {
const pid = this.requireProjectId(projectId, 'comms_state')
this.send({ type: 'comms_state', project_id: pid, ...(opts || {}) })
@@ -801,12 +806,6 @@ export class VisualSocketClient {
if (projectId) this.commsState(projectId)
} catch { /* ignore */ }
break
case 'recovery_status':
this.handlers.onRecoveryStatus?.(parsed.payload)
break
case 'recovery_result':
this.handlers.onRecoveryResult?.(parsed.payload)
break
case 'talent_list':
this.handlers.onTalentList?.(parsed.payload)
break
@@ -244,7 +244,6 @@ function sessionReducer(state: Session[], action: SessionAction): Session[] {
runtimeControlState: guardedRuntimeControl.runtimeControlState ?? existing.runtimeControlState,
canStop: guardedRuntimeControl.canStop ?? existing.canStop,
canResume: guardedRuntimeControl.canResume ?? existing.canResume,
resumeParentTaskId: incoming.resumeParentTaskId ?? existing.resumeParentTaskId,
resumeParentSessionId: incoming.resumeParentSessionId ?? existing.resumeParentSessionId,
pendingRuntimeCheckpointId: guardedRuntimeControl.pendingRuntimeCheckpointId ?? existing.pendingRuntimeCheckpointId,
stopIntentId: guardedRuntimeControl.stopIntentId ?? existing.stopIntentId,
@@ -337,7 +336,6 @@ function sessionReducer(state: Session[], action: SessionAction): Session[] {
runtimeControlState: guarded.runtimeControlState ?? s.runtimeControlState,
canStop: guarded.canStop ?? s.canStop,
canResume: guarded.canResume ?? s.canResume,
resumeParentTaskId: guarded.resumeParentTaskId ?? s.resumeParentTaskId,
resumeParentSessionId: guarded.resumeParentSessionId ?? s.resumeParentSessionId,
pendingRuntimeCheckpointId: guarded.pendingRuntimeCheckpointId ?? s.pendingRuntimeCheckpointId,
stopIntentId: guarded.stopIntentId ?? s.stopIntentId,
@@ -316,10 +316,9 @@ export interface Session {
originChannel?: string
originTaskId?: string
runtimeControlState?: 'running' | 'suspending' | 'suspended' | 'resuming' | 'idle'
canStop?: boolean
canResume?: boolean
resumeParentTaskId?: string
resumeParentSessionId?: string
canStop?: boolean
canResume?: boolean
resumeParentSessionId?: string
pendingRuntimeCheckpointId?: string
stopIntentId?: string
// Handoff context from upstream work item (Company Mode)
@@ -100,8 +100,6 @@ export type SocketEnvelope =
| { type: 'work_item_batch_updated'; payload: { run_id?: string; work_items: RuntimeWorkItemInfo[]; frontier?: RuntimeFrontierSummary } }
| { type: 'project_recovery_updated'; payload: Record<string, unknown> }
| { type: 'project_revision_created'; payload: { run_id?: string; revision_links: SessionLinkInfo[] } }
| { type: 'recovery_status'; payload: Record<string, unknown> }
| { type: 'recovery_result'; payload: Record<string, unknown> }
| { type: 'talent_list'; payload: TalentListPayload }
| { type: 'talent_scan_local'; payload: { templates: Array<{ template_id: string; name: string; description: string; category: string; domains: string[]; tags: string[] }> } }
| { type: 'employee_detail'; payload: EmployeeDetailPayload }
@@ -77,7 +77,6 @@ interface ContextPanelProps {
onCommsRefresh?: () => void
onCommsReadMessage?: (path: string) => void
orgInfoData?: OrgInfoPayload | null
recoveryStatus?: Record<string, unknown> | null
canShowTeamTab?: boolean
onTeamStopRun?: () => void
@@ -497,7 +496,6 @@ export function ContextPanel({
onCommsRefresh,
onCommsReadMessage,
orgInfoData,
recoveryStatus,
canShowTeamTab = false,
onTeamStopRun,
onTitleChange,
@@ -1306,7 +1304,6 @@ export function ContextPanel({
<div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
<ProjectCockpit
orgInfoData={orgInfoData ?? null}
recoveryStatus={recoveryStatus ?? null}
commsState={commsState ?? null}
onStopRun={onTeamStopRun}
embedded
@@ -48,7 +48,6 @@ interface TeamCardInfo {
interface ProjectCockpitProps {
orgInfoData?: OrgInfoPayload | null
recoveryStatus?: Record<string, unknown> | null
commsState?: CommsStatePayload | null
onStopRun?: () => void
embedded?: boolean
@@ -56,7 +55,6 @@ interface ProjectCockpitProps {
export function ProjectCockpit({
orgInfoData,
recoveryStatus,
commsState,
onStopRun,
embedded = false,
@@ -78,7 +76,6 @@ export function ProjectCockpit({
count + asRecordList(asRecord(digest.manager_digest).notification_backlog).length
), 0)
const unreadCount = actionableCount + protocolCount + notificationCount
const interrupted = Array.isArray(recoveryStatus?.interrupted) ? recoveryStatus.interrupted.length : 0
const communicationItems = [
{ label: 'Actionable', value: actionableCount },
@@ -198,7 +195,7 @@ export function ProjectCockpit({
<span>Seats {runtimeView.runtimeSeats.length}</span>
<span>Approvals {pendingDecisionCount}</span>
<span>Unread {unreadCount}</span>
<span>Recovery {interrupted > 0 ? `${interrupted} interrupted` : summarizeText(asRecord(projectRun?.recovery_pointer).status, 'clean')}</span>
<span>Run state {summarizeText(asRecord(projectRun?.recovery_pointer).status, 'clean')}</span>
</div>
</div>
@@ -1,116 +0,0 @@
import { useState } from 'react'
export interface RecoverableWorkItem {
work_item_projection_id: string
title: string
task_id: string
status: string
interrupted: boolean
previous_status: string
}
export interface InterruptedWorkItemRuntime {
parent_session_id: string
parent_task_id: string
project_id: string
title: string
profile: string
interrupted_at: string
work_items: RecoverableWorkItem[]
}
export interface RecoveryStatusPayload {
interrupted: InterruptedWorkItemRuntime[]
active_recoveries: string[]
scanned_at: number
}
interface WorkItemRecoveryPanelProps {
data: RecoveryStatusPayload
onResume: (parentTaskId: string) => void
onCancel: (parentTaskId: string) => void
}
const STATUS_ICON: Record<string, string> = {
done: '\u2713',
failed: '\u2717',
pending: '\u25CB',
blocked: '\u25A0',
cancelled: '\u2014',
running: '\u25B6',
}
const STATUS_COLOR: Record<string, string> = {
done: 'var(--green, #27ae60)',
failed: 'var(--red, #e74c3c)',
pending: 'var(--text-secondary, #888)',
blocked: 'var(--yellow, #f39c12)',
cancelled: 'var(--text-dim, #555)',
running: 'var(--accent, #3498db)',
}
export function WorkItemRecoveryPanel({ data, onResume, onCancel }: WorkItemRecoveryPanelProps) {
const [dismissed, setDismissed] = useState<Set<string>>(new Set())
if (!data.interrupted.length && !data.active_recoveries.length) return null
const visible = data.interrupted.filter(w => !dismissed.has(w.parent_task_id))
if (!visible.length && !data.active_recoveries.length) return null
return (
<div className="wfr-panel">
{visible.map(wf => {
const isRecovering = data.active_recoveries.includes(wf.parent_task_id)
const doneCount = wf.work_items.filter(item => item.status === 'done').length
const failedCount = wf.work_items.filter(item => item.interrupted || item.status === 'failed').length
return (
<div key={wf.parent_task_id} className="wfr-card">
<div className="wfr-header">
<span className="wfr-icon">&#x26A0;</span>
<div className="wfr-header-text">
<span className="wfr-title">Interrupted: {wf.title}</span>
<span className="wfr-subtitle">
{doneCount}/{wf.work_items.length} work items done, {failedCount} interrupted
{wf.profile && <> &middot; {wf.profile}</>}
</span>
</div>
</div>
<div className="wfr-work-items">
{wf.work_items.map(item => (
<div key={item.work_item_projection_id} className={`wfr-work-item wfr-work-item--${item.status}`}>
<span className="wfr-work-item-icon" style={{ color: STATUS_COLOR[item.status] || STATUS_COLOR.pending }}>
{STATUS_ICON[item.status] || STATUS_ICON.pending}
</span>
<span className="wfr-work-item-title">{item.title}</span>
{item.interrupted && <span className="wfr-work-item-badge">interrupted</span>}
</div>
))}
</div>
<div className="wfr-actions">
{isRecovering ? (
<span className="wfr-recovering">
<span className="spinner-inline" /> Recovering...
</span>
) : (
<>
<button className="wfr-btn wfr-btn--resume" onClick={() => onResume(wf.parent_task_id)}>
Resume
</button>
<button className="wfr-btn wfr-btn--cancel" onClick={() => onCancel(wf.parent_task_id)}>
Cancel
</button>
<button className="wfr-btn wfr-btn--dismiss" onClick={() => setDismissed(prev => new Set(prev).add(wf.parent_task_id))}>
Dismiss
</button>
</>
)}
</div>
</div>
)
})}
</div>
)
}
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { AgentInfo, OrgInfoPayload, SavedOrgSummary } from '../types/visual'
import { WorkItemRecoveryPanel } from './WorkItemRecoveryPanel'
import type { ChatMessage, CheckpointReplyMetadata, OutgoingAttachmentPayload } from '../types/chat'
import type { KanbanTask, Session, TaskPreferredAgent } from '../types/kanban'
import type { BoardStoreState } from '../kanban/BoardStore'
@@ -253,7 +252,7 @@ interface WorkspacePageProps {
*/
onContinueInNewChat?: (mode: 'task' | 'company' | 'org' | 'custom', companyProfile?: 'corporate' | 'custom', orgId?: string) => void
onSessionStop?: (taskId: string) => void
onSessionResume?: (taskId: string) => void
onSessionResume?: (taskId: string, runtimeSessionId?: string, checkpointId?: string) => void
onSessionComplete?: (taskId: string) => void
onLoadSessionDetail?: (
taskId: string,
@@ -263,9 +262,6 @@ interface WorkspacePageProps {
onCollabSync?: () => void
orgInfoData?: OrgInfoPayload | null
onNavigateToOrg?: () => void
recoveryStatus?: any
onRecoveryResume?: (parentTaskId: string) => void
onRecoveryCancel?: (parentTaskId: string) => void
commsState?: import('../lib/wsClient').CommsStatePayload | null
commsMessage?: import('../lib/wsClient').CommsMessagePayload | null
onCommsRefresh?: (opts?: { task_id?: string; session_id?: string; project_id?: string }) => void
@@ -305,9 +301,6 @@ export function WorkspacePage({
onCollabSync,
orgInfoData,
onNavigateToOrg,
recoveryStatus,
onRecoveryResume,
onRecoveryCancel,
commsState,
commsMessage,
onCommsRefresh,
@@ -988,13 +981,21 @@ export function WorkspacePage({
const handleResume = useCallback(() => {
const targetSession = activeConversation.runtimeSession ?? activeConversation.displaySession ?? activeSession
const targetTaskId = targetSession?.resumeParentTaskId ?? targetSession?.taskId ?? activeSessionId
if (targetTaskId) onSessionResume?.(targetTaskId)
const uiTaskId = activeSessionId ?? targetSession?.taskId
const runtimeSessionId = targetSession?.resumeParentSessionId
?? targetSession?.parentSessionId
?? targetSession?.sessionId
if (uiTaskId) {
onSessionResume?.(uiTaskId, runtimeSessionId, targetSession?.pendingRuntimeCheckpointId)
}
}, [activeConversation.runtimeSession, activeConversation.displaySession, activeSession, activeSessionId, onSessionResume])
const handleResumeTask = useCallback((taskId: string) => {
const session = sessions.find(s => s.taskId === taskId)
onSessionResume?.(session?.resumeParentTaskId ?? taskId)
const runtimeSessionId = session?.resumeParentSessionId
?? session?.parentSessionId
?? session?.sessionId
onSessionResume?.(taskId, runtimeSessionId, session?.pendingRuntimeCheckpointId)
}, [sessions, onSessionResume])
const handleCompleteTask = useCallback((taskId: string) => {
@@ -1034,8 +1035,11 @@ export function WorkspacePage({
}
const targetTaskId = activeSessionId
if (!targetTaskId) return
const checkpointReplyId = String(latestPendingCheckpointReply?.response_to_checkpoint_id ?? '').trim()
const runtimeSession = activeConversation.runtimeSession ?? activeConversation.displaySession ?? activeSession
const runtimeCheckpointId = String(runtimeSession?.pendingRuntimeCheckpointId ?? '').trim()
let outgoingMetadata = latestPendingCheckpointReply
?? (runtimeCheckpointId ? { response_to_checkpoint_id: runtimeCheckpointId } : undefined)
const checkpointReplyId = String(outgoingMetadata?.response_to_checkpoint_id ?? '').trim()
if (!checkpointReplyId) {
const uiMessageId = makeOptimisticUserMessageId()
outgoingMetadata = { ...(latestPendingCheckpointReply ?? {}), ui_message_id: uiMessageId }
@@ -1050,7 +1054,7 @@ export function WorkspacePage({
}
dispatchSessionSend(targetTaskId, content, attachments, outgoingMetadata)
},
[effectiveView.kind, activeSessionId, activeConversation.displaySession, activeSession, latestPendingCheckpointReply, chatStore, dispatchSessionSend, onSecretarySend],
[effectiveView.kind, activeSessionId, activeConversation.runtimeSession, activeConversation.displaySession, activeSession, latestPendingCheckpointReply, chatStore, dispatchSessionSend, onSecretarySend],
)
// ── MessageList send (checkpoint replies) ──
@@ -1114,13 +1118,6 @@ export function WorkspacePage({
{/* Middle column: Kanban Board (hidden when panel maximized) */}
{panelState !== 'maximized' && (
<div className="workspace-board">
{recoveryStatus && onRecoveryResume && onRecoveryCancel && (
<WorkItemRecoveryPanel
data={recoveryStatus}
onResume={onRecoveryResume}
onCancel={onRecoveryCancel}
/>
)}
{agents.length > 0 && <AgentStatusBar agents={agents} tasks={boardStore.tasks} />}
{isCompanyMode ? (
boardStore.activeBoard && activeSession && (
@@ -1206,7 +1203,6 @@ export function WorkspacePage({
onCommsRefresh={onCommsRefresh ? () => onCommsRefresh({ session_id: activeSession?.sessionId || undefined, project_id: projectId || undefined }) : undefined}
onCommsReadMessage={onCommsReadMessage}
orgInfoData={orgInfoData ?? null}
recoveryStatus={recoveryStatus ?? null}
canShowTeamTab={canShowTeamTab}
onTeamStopRun={activeSessionId ? () => onSessionStop?.(activeSessionId) : undefined}
onTitleChange={onTitleChange}
@@ -1605,132 +1605,6 @@
}
}
/* ── Work Item Recovery Panel ────────────────────────────────────────── */
.wfr-panel {
padding: 0 8px;
}
.wfr-card {
background: color-mix(in srgb, var(--yellow, #f39c12) 8%, var(--bg-secondary));
border: 1px solid color-mix(in srgb, var(--yellow, #f39c12) 25%, transparent);
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 8px;
}
.wfr-header {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 8px;
}
.wfr-icon {
font-size: 16px;
line-height: 1;
flex-shrink: 0;
}
.wfr-header-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.wfr-title {
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.wfr-subtitle {
font-size: 11px;
color: var(--text-secondary);
}
.wfr-work-items {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
.wfr-work-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
}
.wfr-work-item-icon {
font-size: 12px;
width: 14px;
text-align: center;
flex-shrink: 0;
font-weight: 700;
}
.wfr-work-item-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wfr-work-item-badge {
font-size: 9px;
padding: 1px 5px;
border-radius: 3px;
background: color-mix(in srgb, var(--red, #e74c3c) 15%, transparent);
color: var(--red, #e74c3c);
flex-shrink: 0;
}
.wfr-actions {
display: flex;
gap: 8px;
align-items: center;
}
.wfr-btn {
padding: 5px 14px;
font-size: 12px;
font-weight: 500;
border: none;
border-radius: 6px;
cursor: pointer;
}
.wfr-btn--resume {
background: var(--green, #27ae60);
color: #fff;
}
.wfr-btn--resume:hover { opacity: 0.85; }
.wfr-btn--cancel {
background: var(--red, #e74c3c);
color: #fff;
}
.wfr-btn--cancel:hover { opacity: 0.85; }
.wfr-btn--dismiss {
background: transparent;
color: var(--text-secondary);
}
.wfr-btn--dismiss:hover { color: var(--text); }
.wfr-recovering {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--accent);
}
/* ═══════════════════════════════════════════════════════
Kanban empty-state
═══════════════════════════════════════════════════════ */
-552
View File
@@ -1,552 +0,0 @@
"""Pluggable work-item crash-recovery manager.
Detects interrupted company-mode work items after server restart and provides
deterministic Resume / Cancel operations — no LLM needed.
Hooks into existing engine capabilities without modifying core code:
- engine.store → task queries + persistence
- engine._load_company_runtime_snapshot() → reconstruct work-item plan + tasks
- engine.company_executor.execute() → resume execution
- ws_handler.broadcast() → push status to all clients
"""
from __future__ import annotations
import asyncio
import dataclasses
import logging
import time
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Awaitable, Callable
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
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Data types
# ---------------------------------------------------------------------------
@dataclass
class RecoverableWorkItem:
work_item_projection_id: str
title: str
task_id: str
status: str # "done" | "failed" | "pending" | "blocked" | "cancelled"
interrupted: bool # has interrupted_recovery metadata
previous_status: str # what it was before reconciliation
@dataclass
class InterruptedWorkItemRun:
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[InterruptedWorkItemRun] = field(default_factory=list)
active_recoveries: list[str] = field(default_factory=list)
scanned_at: float = 0.0
# ---------------------------------------------------------------------------
# Manager
# ---------------------------------------------------------------------------
class RuntimeRecoveryManager:
"""Scans for interrupted work-item runs and provides deterministic recovery."""
_CACHE_TTL = 10.0 # seconds
def __init__(
self,
engine: Any,
broadcast_fn: Callable[[dict[str, Any]], Awaitable[None]],
) -> None:
self._engine = engine
self._broadcast = broadcast_fn
self._lock = asyncio.Lock()
self._active_recoveries: dict[str, asyncio.Task[Any]] = {}
self._cached: RecoveryStatus | None = None
self._cache_until: float = 0.0
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
async def get_recovery_status(self) -> RecoveryStatus:
"""Return cached scan results, re-scanning if stale."""
now = time.time()
if self._cached is not None and now < self._cache_until:
# Keep active_recoveries up to date even from cache
self._cached.active_recoveries = list(self._active_recoveries.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:
"""Scan the database for recoverable interrupted work-item runs."""
store = self._engine.store
if not store:
return RecoveryStatus()
project_id = self._engine.project_id or "default"
try:
all_tasks = await store.get_tasks(project_id=project_id)
except Exception as exc:
logger.warning(f"Recovery scan failed: {exc}")
return RecoveryStatus()
# Group projected work-item tasks by parent_session_id.
groups: dict[str, list[Any]] = {}
all_tasks_by_session: dict[str, Any] = {}
for task in all_tasks:
session_id = str(getattr(task, "session_id", "") or "").strip()
if session_id:
all_tasks_by_session[session_id] = 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)
interrupted: list[InterruptedWorkItemRun] = []
for parent_sid, tasks in groups.items():
# Check if any task has interrupted_recovery metadata
has_interrupted = any(
_is_interrupted(t) for t in tasks
)
if not has_interrupted:
continue
# Skip if all tasks are terminal (DONE or CANCELLED)
from opc.core.models import TaskStatus
non_terminal = [
t for t in tasks
if t.status not in (TaskStatus.DONE, TaskStatus.CANCELLED)
]
if not non_terminal:
continue
# Find the parent (primary) task
parent_task = all_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 work-item run"
# Build work-item list.
work_items: list[RecoverableWorkItem] = []
earliest_interrupt = ""
for t in sorted(tasks, key=lambda x: (x.created_at, x.id)):
meta = dict(getattr(t, "metadata", {}) or {})
recovery_meta = meta.get("interrupted_recovery", {})
is_int = _is_interrupted(t)
if is_int and recovery_meta.get("detected_at", ""):
detected = recovery_meta["detected_at"]
if not earliest_interrupt or detected < earliest_interrupt:
earliest_interrupt = detected
work_items.append(RecoverableWorkItem(
work_item_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=recovery_meta.get("previous_status", ""),
))
profile = ""
for t in tasks:
p = (getattr(t, "metadata", {}) or {}).get("company_profile", "")
if p:
profile = p
break
interrupted.append(InterruptedWorkItemRun(
parent_session_id=parent_sid,
parent_task_id=parent_task_id,
project_id=project_id,
title=title,
profile=profile,
interrupted_at=earliest_interrupt or datetime.now().isoformat(),
work_items=work_items,
))
return RecoveryStatus(
interrupted=interrupted,
active_recoveries=list(self._active_recoveries.keys()),
scanned_at=time.time(),
)
async def resume(self, parent_task_id: str) -> dict[str, Any]:
"""Deterministically resume an interrupted work-item run."""
async with self._lock:
if parent_task_id in self._active_recoveries:
return {"ok": False, "error": "already_in_progress"}
# Find the interrupted work-item run.
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"}
# Load snapshot
snapshot = await self._engine._load_company_runtime_snapshot(wf.parent_session_id)
if not snapshot:
return {"ok": False, "error": "snapshot_unavailable"}
plan, tasks = snapshot
# Clean orphaned checkpoints
await self._clean_orphaned_checkpoints(wf, tasks)
# Reset interrupted/failed/blocked tasks → PENDING
from opc.core.models import TaskStatus
resumed_ids: list[str] = []
failed_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
projection_id = work_item_projection_id_from_metadata(meta, fallback=task.id)
try:
await apply_task_status_transition(
self._engine.store,
task,
target_status_or_phase=TaskStatus.PENDING,
reason="office_recovery_resume",
release_claim=True,
)
except Exception as exc:
logger.warning("Recovery resume skipped %s: %s", task.id, exc)
failed_ids.append(projection_id)
continue
if task.status != TaskStatus.PENDING:
logger.warning("Recovery resume preserved non-runnable phase for %s", task.id)
failed_ids.append(projection_id)
continue
await self._engine.store.save_task(task)
resumed_ids.append(projection_id)
if not resumed_ids:
return {
"ok": False,
"error": "no_work_items_to_resume",
"failed_work_item_projection_ids": failed_ids,
}
# Invalidate cache
self._cache_until = 0.0
# Launch execution in background
await self._set_run_recovery_state(
wf.parent_session_id,
status="resuming",
lifecycle_status="active",
)
bg_task = asyncio.create_task(
self._execute_recovery(parent_task_id, wf.parent_session_id, plan, tasks)
)
self._active_recoveries[parent_task_id] = bg_task
return {
"ok": True,
"resumed_work_item_projection_ids": resumed_ids,
"failed_work_item_projection_ids": failed_ids,
}
async def cancel(self, parent_task_id: str) -> dict[str, Any]:
"""Cancel an interrupted work-item run and clean up."""
async with self._lock:
# Cancel active recovery if running
bg = self._active_recoveries.pop(parent_task_id, None)
if bg and not bg.done():
bg.cancel()
# Find the interrupted work-item run.
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"}
# Load tasks and cancel non-terminal ones
snapshot = await self._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_count = 0
failed_ids: list[str] = []
for task in tasks:
if task.status not in (TaskStatus.DONE, TaskStatus.CANCELLED):
projection_id = work_item_projection_id_from_metadata(
getattr(task, "metadata", {}) or {},
fallback=task.id,
)
try:
await apply_task_status_transition(
self._engine.store,
task,
target_status_or_phase=TaskStatus.CANCELLED,
reason="office_recovery_cancel",
release_claim=True,
)
except Exception as exc:
logger.warning("Recovery cancel skipped %s: %s", task.id, exc)
failed_ids.append(projection_id)
continue
if task.status != TaskStatus.CANCELLED:
logger.warning("Recovery cancel preserved non-cancelled phase for %s", task.id)
failed_ids.append(projection_id)
continue
cancelled_count += 1
# Clean orphaned checkpoints
await self._clean_orphaned_checkpoints(wf, tasks)
# Invalidate cache
self._cache_until = 0.0
await self._set_run_recovery_state(
wf.parent_session_id,
status="cancelled",
lifecycle_status="cancelled",
)
await self._broadcast_status()
return {
"ok": True,
"cancelled_count": cancelled_count,
"failed_work_item_projection_ids": failed_ids,
}
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
async def _execute_recovery(
self,
parent_task_id: str,
parent_session_id: str,
plan: Any,
tasks: list[Any],
) -> None:
"""Run the work-item executor in the background."""
project_id = self._engine.project_id or "default"
try:
await self._set_run_recovery_state(
parent_session_id,
status="started",
lifecycle_status="active",
)
await self._broadcast({"type": "recovery_result", "payload": {
"project_id": project_id,
"parent_task_id": parent_task_id, "status": "started",
}})
executor = self._engine.company_executor
if not executor:
raise RuntimeError("company_executor not available")
result = await executor.execute(plan, tasks)
await self._broadcast({"type": "recovery_result", "payload": {
"project_id": project_id,
"parent_task_id": parent_task_id, "status": "completed",
"summary": result[:500] if result else "",
}})
await self._set_run_recovery_state(
parent_session_id,
status="completed",
lifecycle_status="active",
)
except asyncio.CancelledError:
await self._broadcast({"type": "recovery_result", "payload": {
"project_id": project_id,
"parent_task_id": parent_task_id, "status": "cancelled",
}})
await self._set_run_recovery_state(
parent_session_id,
status="cancelled",
lifecycle_status="cancelled",
)
except Exception as exc:
logger.warning(f"Recovery execution failed for {parent_task_id}: {exc}")
await self._broadcast({"type": "recovery_result", "payload": {
"project_id": project_id,
"parent_task_id": parent_task_id, "status": "failed",
"error": str(exc),
}})
await self._set_run_recovery_state(
parent_session_id,
status="failed",
lifecycle_status="blocked",
extra={"error": str(exc)},
)
finally:
self._active_recoveries.pop(parent_task_id, None)
self._cache_until = 0.0
await self._broadcast_status()
async def _clean_orphaned_checkpoints(
self,
wf: InterruptedWorkItemRun,
tasks: list[Any],
) -> int:
"""Resolve pending checkpoints whose tasks are no longer active."""
store = self._engine.store
if not store:
return 0
session_ids = {
str(getattr(t, "session_id", "") or "").strip()
for t in tasks
}
session_ids.add(wf.parent_session_id)
session_ids.discard("")
cleaned = 0
try:
pending = await store.get_pending_checkpoints(
project_id=wf.project_id,
)
for cp in pending:
cp_session = str(cp.session_id or "").strip()
if cp_session in session_ids:
await store.resolve_execution_checkpoint(
cp.checkpoint_id, status="cancelled"
)
cleaned += 1
except Exception as exc:
logger.debug(f"Checkpoint cleanup error: {exc}")
return cleaned
async def _broadcast_status(self) -> None:
"""Push updated recovery status to all connected clients."""
try:
status = await self.get_recovery_status()
await self._broadcast({"type": "recovery_status", "payload":
_serialize_status(status, project_id=self._engine.project_id or "default")
})
except Exception as exc:
logger.debug(f"Recovery status broadcast failed: {exc}")
def _invalidate_cache(self) -> None:
"""Force next get_recovery_status to re-scan."""
self._cache_until = 0.0
self._cached = None
async def _set_run_recovery_state(
self,
key: str,
*,
status: str,
lifecycle_status: str | None = None,
match_task_id: bool = False,
extra: dict[str, Any] | None = None,
) -> None:
store = getattr(self._engine, "store", None)
if not store or not hasattr(store, "list_delegation_runs") or not hasattr(store, "save_delegation_run"):
return
runs = await store.list_delegation_runs(project_id=self._engine.project_id or "default")
target = None
if match_task_id:
for run in runs:
metadata = dict(getattr(run, "metadata", {}) or {})
if str(metadata.get("origin_task_id", "") or "").strip() == key:
target = run
break
if target is None:
for run in runs:
if str(run.session_id or "").strip() == key:
target = run
break
if target is None:
return
target.recovery_pointer = {
**dict(getattr(target, "recovery_pointer", {}) or {}),
"status": status,
"updated_at": datetime.now().isoformat(),
**dict(extra or {}),
}
if lifecycle_status:
target.lifecycle_status = lifecycle_status
target.updated_at = datetime.now()
await store.save_delegation_run(target)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _is_interrupted(task: Any) -> bool:
"""Check if a task was interrupted by crash."""
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"))
def _serialize_status(status: RecoveryStatus, *, project_id: str | None = None) -> dict[str, Any]:
"""Convert RecoveryStatus to JSON-safe dict."""
resolved_project_id = str(project_id or "").strip()
if not resolved_project_id:
for item in status.interrupted:
if item.project_id:
resolved_project_id = item.project_id
break
payload = {
"interrupted": [
{
"parent_session_id": w.parent_session_id,
"parent_task_id": w.parent_task_id,
"project_id": w.project_id,
"title": w.title,
"profile": w.profile,
"interrupted_at": w.interrupted_at,
"work_items": [
{
"work_item_projection_id": s.work_item_projection_id,
"title": s.title,
"task_id": s.task_id,
"status": s.status,
"interrupted": s.interrupted,
"previous_status": s.previous_status,
}
for s in w.work_items
],
}
for w in status.interrupted
],
"active_recoveries": status.active_recoveries,
"scanned_at": status.scanned_at,
}
if resolved_project_id:
payload["project_id"] = resolved_project_id
return payload
-11
View File
@@ -144,17 +144,6 @@ async def create_app(
engine.event_bus.subscribe_all(_root_engine_event)
# ── Runtime crash recovery (pluggable, no engine modifications) ──
from opc.plugins.office_ui.recovery_manager import RuntimeRecoveryManager
recovery_manager = RuntimeRecoveryManager(engine, ws_handler.broadcast)
ws_handler.recovery_manager = recovery_manager
# ── Startup self-heal for tasks abandoned by a prior process ─────
# Must run before restoring persisted mode and before any WS client can
# connect, so orphaned running/locked rows do not block new Continue /
# session_send acquisitions.
await ws_handler.heal_orphan_tasks_on_boot()
# ── Restore persisted mode and load matching agents on startup ───
await ws_handler.restore_persisted_mode()
startup_preset = ws_handler._resolve_preset_name()
+10 -1
View File
@@ -32,4 +32,13 @@ class ServiceError(Exception):
self.payload = dict(payload or {})
def to_payload(self) -> dict[str, Any]:
return {"error": self.message, "code": self.code, **self.payload}
# Transport envelope fields are authoritative. Business details may
# add context, but must never turn an error acknowledgement into a
# success or replace the exception's code/message.
payload = {
key: value
for key, value in self.payload.items()
if key not in {"ok", "error", "code"}
}
payload.update({"error": self.message, "code": self.code})
return payload
-45
View File
@@ -200,48 +200,3 @@ class RuntimeService:
payload["display_text"] = " | ".join(display_parts)
payload["event_type"] = event_type
return payload
async def recovery_scan(self, *, project_id: str) -> ServiceResult:
manager = await self._recovery_manager(project_id)
from opc.plugins.office_ui.recovery_manager import _serialize_status
status = await manager.get_recovery_status()
return ServiceResult(_serialize_status(status, project_id=project_id))
async def recovery_action(self, *, project_id: str, action: str, parent_task_id: str) -> ServiceResult:
manager = await self._recovery_manager(project_id)
normalized = str(action or "").strip().lower()
if normalized == "scan":
return await self.recovery_scan(project_id=project_id)
if not str(parent_task_id or "").strip():
raise ServiceError("parent_task_id_required", "parent_task_id required")
if normalized in {"resume", "retry"}:
payload = await manager.resume(parent_task_id)
elif normalized == "cancel":
payload = await manager.cancel(parent_task_id)
else:
raise ServiceError("unknown_recovery_action", f"unknown action: {action}", {"action": action})
payload = {**dict(payload), "project_id": project_id, "parent_task_id": parent_task_id, "action": normalized}
if not payload.get("ok", False):
raise ServiceError(str(payload.get("error") or "recovery_failed"), str(payload.get("error") or "recovery_failed"), payload)
return ServiceResult(payload)
async def _recovery_manager(self, project_id: str) -> Any:
engine = await self.context.engine_for_project(project_id)
async def _noop_broadcast(_event: dict[str, Any]) -> None:
return None
managers = getattr(self.context, "recovery_managers", None)
if managers is None:
managers = {}
setattr(self.context, "recovery_managers", managers)
key = self.context.normalize_project_id(project_id)
existing = managers.get(key)
if existing is not None and getattr(existing, "_engine", None) is engine:
return existing
from opc.plugins.office_ui.recovery_manager import RuntimeRecoveryManager
manager = RuntimeRecoveryManager(engine, _noop_broadcast)
managers[key] = manager
return manager
+305 -182
View File
@@ -12,6 +12,12 @@ from typing import Any
from loguru import logger
from opc.core.models import Task, TaskStatus
from opc.layer2_organization.company_runtime_identity import (
ACTIVE_COMPANY_RUNTIME_CHECKPOINT_STATUSES,
COMPANY_RUNTIME_CHECKPOINT_TYPES,
is_company_runtime_task,
load_company_runtime_identity_index,
)
from opc.plugins.office_ui.execution_identity import (
ExecutionIdentity,
canonicalize_execution_identity,
@@ -156,6 +162,32 @@ class SessionService:
task_project = self.context.normalize_project_id(getattr(task, "project_id", None))
if task_project != pid:
raise ServiceError("target_wrong_project", "Target belongs to a different project", {"project_id": task_project})
# Company control is resolved from durable session/checkpoint scope
# before any generic Task/session fallback. This prevents a shared
# final-decider row from winning merely because it was loaded first.
identity_index = await load_company_runtime_identity_index(store, pid)
if task is not None:
identity = identity_index.resolve(task_id=raw_target)
else:
identity = (
identity_index.resolve(runtime_session_id=raw_target)
or identity_index.resolve(task_session_id=raw_target)
)
if identity is not None:
# A concrete Task id is the caller's UI/chat channel, never the
# company execution identity. Keep that channel stable while the
# shared identity supplies the durable runtime session/checkpoint.
if task is not None:
return task, identity.runtime_session_id
resolved_task = (
identity_index.task(identity.ui_anchor_task_id)
or identity_index.task(identity.config_source_task_id)
)
if resolved_task is not None:
return resolved_task, identity.runtime_session_id
if task is not None:
return task, str(getattr(task, "session_id", "") or getattr(task, "parent_session_id", "") or "")
session = await store.get_session(raw_target) if hasattr(store, "get_session") else None
@@ -165,139 +197,64 @@ class SessionService:
if session_project != pid:
raise ServiceError("target_wrong_project", "Target belongs to a different project", {"project_id": session_project})
tasks = await store.get_tasks(project_id=pid) if hasattr(store, "get_tasks") else []
tasks = list(identity_index.tasks)
session_tasks = [
candidate for candidate in tasks
if str(getattr(candidate, "session_id", "") or "") == raw_target
]
if not session_tasks:
raise ServiceError("session_not_task_backed", "Session is not linked to a task-backed runtime", {"session_id": raw_target})
session_tasks.sort(key=lambda item: bool(str(getattr(item, "parent_session_id", "") or "")))
return session_tasks[0], raw_target
task_mode_anchor = min(
session_tasks,
key=lambda item: (
bool(str(getattr(item, "parent_session_id", "") or "")),
str(getattr(item, "created_at", "") or ""),
str(getattr(item, "id", "") or ""),
),
)
return task_mode_anchor, raw_target
async def _resolve_company_runtime_target(self, engine: Any, task: Any) -> dict[str, Any]:
store = getattr(engine, "store", None)
parent_session_id = str(
getattr(task, "parent_session_id", "")
or getattr(task, "session_id", "")
or ""
).strip()
parent_task_id = str(self.context.session_to_task.get(parent_session_id) or "").strip()
project_id = self.context.normalize_project_id(getattr(task, "project_id", None) or getattr(engine, "project_id", None))
try:
project_tasks = await store.get_tasks(project_id=project_id) if hasattr(store, "get_tasks") else [task]
except Exception:
project_tasks = [task]
for candidate in project_tasks:
candidate_id = str(getattr(candidate, "id", "") or "").strip()
candidate_session_id = str(getattr(candidate, "session_id", "") or "").strip()
candidate_parent_session_id = str(getattr(candidate, "parent_session_id", "") or "").strip()
if candidate_session_id == parent_session_id and not candidate_parent_session_id:
parent_task_id = candidate_id
break
if not parent_task_id:
parent_task_id = str(
self.context.active_runtime_children.get(str(getattr(task, "id", "") or ""))
or getattr(task, "id", "")
or ""
).strip()
affected_task_ids: list[str] = []
for candidate in project_tasks:
candidate_id = str(getattr(candidate, "id", "") or "").strip()
if not candidate_id:
continue
candidate_session_id = str(getattr(candidate, "session_id", "") or "").strip()
candidate_parent_session_id = str(getattr(candidate, "parent_session_id", "") or "").strip()
if (
candidate_id == str(getattr(task, "id", "") or "")
or candidate_id == parent_task_id
or candidate_session_id == parent_session_id
or candidate_parent_session_id == parent_session_id
):
if candidate_id not in affected_task_ids:
affected_task_ids.append(candidate_id)
for child_id, origin_id in list(self.context.active_runtime_children.items()):
if origin_id == parent_task_id or child_id == str(getattr(task, "id", "") or ""):
if child_id not in affected_task_ids:
affected_task_ids.append(child_id)
if parent_task_id and parent_task_id not in affected_task_ids:
affected_task_ids.insert(0, parent_task_id)
return {
"parent_session_id": parent_session_id,
"parent_task_id": parent_task_id or str(getattr(task, "id", "") or ""),
"origin_task_id": parent_task_id or str(getattr(task, "id", "") or ""),
"affected_task_ids": affected_task_ids or [str(getattr(task, "id", "") or "")],
}
async def _mark_company_runtime_stop_state(
async def _resolve_company_runtime_target(
self,
*,
engine: Any,
task_ids: list[str],
state: str,
stop_intent_id: str,
checkpoint_type: str = "",
) -> None:
task: Any,
*,
runtime_session_id: str = "",
checkpoint_id: str = "",
) -> dict[str, Any]:
store = getattr(engine, "store", None)
project_id = self.context.normalize_project_id(getattr(task, "project_id", None) or getattr(engine, "project_id", None))
if not self.context.store_is_ready(store):
return
for task_id in task_ids:
try:
task = await store.get_task(str(task_id))
except Exception:
task = None
if not task or self._is_terminal_status(task):
continue
metadata = dict(getattr(task, "metadata", {}) or {})
metadata["company_runtime_stop_state"] = state
metadata["company_runtime_stop_intent_id"] = stop_intent_id
metadata["company_runtime_stop_marked_at"] = datetime.now().isoformat()
metadata["dispatch_hold"] = "company_runtime_suspended"
metadata["company_runtime_suspended_at"] = datetime.now().isoformat()
if checkpoint_type:
metadata["company_runtime_suspend_checkpoint_type"] = checkpoint_type
metadata.setdefault("suspended_task_status", self._task_status_value(task))
task.metadata = metadata
task.status = TaskStatus.BLOCKED
if hasattr(task, "execution_lock"):
task.execution_lock = False
if hasattr(task, "execution_locked_at"):
task.execution_locked_at = None
try:
await store.save_task(task)
except Exception:
logger.opt(exception=True).debug("failed to mark company runtime stop state")
async def _clear_company_runtime_stop_state(self, *, engine: Any, task_ids: list[str]) -> None:
store = getattr(engine, "store", None)
if not self.context.store_is_ready(store):
return
for task_id in task_ids:
try:
task = await store.get_task(str(task_id))
except Exception:
task = None
if not task:
continue
metadata = dict(getattr(task, "metadata", {}) or {})
for key in (
"dispatch_hold",
"company_runtime_stop_state",
"company_runtime_stop_intent_id",
"company_runtime_stop_marked_at",
"company_runtime_suspend_checkpoint_type",
"company_runtime_suspended_at",
"suspended_task_status",
):
metadata.pop(key, None)
task.metadata = metadata
if self._task_status_value(task) == "blocked":
task.status = TaskStatus.IDLE
try:
await store.save_task(task)
except Exception:
logger.opt(exception=True).debug("failed to clear company runtime stop state")
raise ServiceError("store_not_ready", "store_not_ready", {"project_id": project_id})
index = await load_company_runtime_identity_index(store, project_id)
identity = index.resolve(
task_id=str(getattr(task, "id", "") or ""),
runtime_session_id=runtime_session_id,
checkpoint_id=checkpoint_id,
)
if identity is None:
raise ServiceError(
"company_runtime_identity_mismatch",
"Company runtime identity does not match the requested task/session/checkpoint",
{
"task_id": str(getattr(task, "id", "") or ""),
"runtime_session_id": str(runtime_session_id or ""),
"checkpoint_id": str(checkpoint_id or ""),
},
)
config_task = index.task(identity.config_source_task_id) or task
ui_anchor_task_id = identity.ui_anchor_task_id
return {
"identity": identity,
"runtime_session_id": identity.runtime_session_id,
"ui_channel_task_id": str(getattr(task, "id", "") or ""),
"ui_anchor_task_id": ui_anchor_task_id,
"config_source_task_id": identity.config_source_task_id,
"config_task": config_task,
"origin_task_id": ui_anchor_task_id,
"affected_task_ids": list(identity.runtime_task_ids),
"checkpoint": identity.checkpoint,
}
def _normalize_requested_config(
self,
@@ -743,8 +700,54 @@ class SessionService:
if getattr(engine, "memory", None):
await engine.memory.ensure_session(task.session_id, project_id=project_id, title=task.title, mode="primary", metadata={"source": "service"})
await store.save_task(task)
# A Task selected by the UI/CLI is only the chat channel for company
# mode. Resolve the durable runtime scope before choosing execution
# configuration, session, origin, or checkpoint. In particular, a
# role/work-item Task must never become the parent execution identity.
company_target: dict[str, Any] | None = None
task_is_company_runtime = is_company_runtime_task(task)
try:
company_target = await self._resolve_company_runtime_target(engine, task)
except ServiceError as exc:
if exc.code != "company_runtime_identity_mismatch" or task_is_company_runtime:
raise
if task.status == TaskStatus.CANCELLED:
company_identity = (
company_target.get("identity")
if company_target is not None
else None
)
checkpoint = (
company_target.get("checkpoint")
if company_target is not None
else None
)
active_cancelled_anchor = bool(
company_identity is not None
and str(getattr(company_identity, "ui_anchor_task_id", "") or "").strip()
== str(getattr(task, "id", "") or "").strip()
and checkpoint is not None
and str(getattr(checkpoint, "checkpoint_type", "") or "").strip()
in COMPANY_RUNTIME_CHECKPOINT_TYPES
and str(getattr(checkpoint, "status", "") or "").strip().lower()
in ACTIVE_COMPANY_RUNTIME_CHECKPOINT_STATUSES
)
if not active_cancelled_anchor:
raise ServiceError(
"session_ended",
"session_ended",
{"project_id": project_id, "task_id": task.id},
)
config_task = (
company_target.get("config_task")
if company_target is not None
else task
) or task
identity = self.resolve_task_identity(
task,
config_task,
default_exec_mode=mode,
default_company_profile=company_profile if company_profile is not None else "corporate",
default_preferred_agent=preferred_agent if preferred_agent is not None else "native",
@@ -752,26 +755,85 @@ class SessionService:
)
if identity.is_custom_org and not identity.org_id:
raise ServiceError("org_id_required", "org_id_required", {"project_id": project_id, "task_id": task.id})
await self.persist_session_config(
task,
exec_mode=identity.exec_mode,
company_profile=identity.company_profile,
preferred_agent=identity.preferred_agent,
org_id=identity.org_id,
engine=engine,
)
# Existing company scopes read configuration from the resolver's
# config source. Only persist when that source is the selected Task;
# this preserves normal session configuration while avoiding writes to
# an internal work item merely because it was used as the UI channel.
if (
company_target is None
or str(getattr(config_task, "id", "") or "").strip()
== str(getattr(task, "id", "") or "").strip()
):
await self.persist_session_config(
task,
exec_mode=identity.exec_mode,
company_profile=identity.company_profile,
preferred_agent=identity.preferred_agent,
org_id=identity.org_id,
engine=engine,
)
execution_session_id = str(getattr(task, "session_id", "") or "").strip()
origin_task_id = str(getattr(task, "id", "") or "").strip() or None
message_metadata: dict[str, Any] | None = None
if company_target is not None:
execution_session_id = str(
company_target.get("runtime_session_id", "") or ""
).strip()
origin_task_id = str(
company_target.get("ui_anchor_task_id", "") or ""
).strip() or None
if not execution_session_id:
raise ServiceError(
"company_runtime_identity_mismatch",
"Company runtime has no canonical runtime session",
{"project_id": project_id, "task_id": task.id},
)
checkpoint = company_target.get("checkpoint")
if checkpoint is not None:
checkpoint_id = str(
getattr(checkpoint, "checkpoint_id", "") or ""
).strip()
checkpoint_status = str(
getattr(checkpoint, "status", "") or ""
).strip().lower()
if checkpoint_status != "pending":
raise ServiceError(
"company_runtime_checkpoint_not_pending",
"Company runtime checkpoint is not pending",
{
"project_id": project_id,
"task_id": task.id,
"checkpoint_id": checkpoint_id,
"checkpoint_status": checkpoint_status,
},
)
message_metadata = {
"response_to_checkpoint_id": checkpoint_id,
"response_to_checkpoint_type": str(
getattr(checkpoint, "checkpoint_type", "") or ""
).strip(),
}
response = await engine.process_message(
str(content or "").strip(),
project_id=project_id,
session_id=task.session_id,
session_id=execution_session_id,
mode=identity.exec_mode,
org_id=identity.org_id or None,
company_profile=identity.company_profile if identity.is_company_runtime else None,
preferred_agent=identity.preferred_agent if identity.is_task else None,
domains=list(domains or []),
origin_task_id=task.id,
origin_task_id=origin_task_id,
message_metadata=message_metadata,
)
return ServiceResult({"project_id": project_id, "task_id": task.id, "session_id": task.session_id, "response": response})
return ServiceResult({
"project_id": project_id,
"task_id": task.id,
"session_id": execution_session_id,
"response": response,
})
async def rename(self, *, project_id: str, task_id: str = "", session_id: str = "", title: str) -> ServiceResult:
pid = self.context.normalize_project_id(project_id)
@@ -935,50 +997,44 @@ class SessionService:
default_payload=default_payload,
)
engine = await self.context.engine_for_project(project_id)
exec_mode, _company_profile = self.resolve_task_session_config(task)
if exec_mode in {"company", "org", "custom"}:
try:
target_info = await self._resolve_company_runtime_target(engine, task)
except ServiceError as exc:
if exc.code != "company_runtime_identity_mismatch":
raise
target_info = None
if target_info is None and is_company_runtime_task(task):
raise ServiceError(
"company_runtime_identity_mismatch",
"Company runtime identity could not be resolved; refusing task-mode cancellation",
{"task_id": resolved_task_id, "session_id": resolved_session_id},
)
if target_info is not None:
stop_intent_id = str(uuid.uuid4())
affected_task_ids = list(target_info.get("affected_task_ids", []) or [resolved_task_id])
suspended: dict[str, Any] | None = None
suspend = getattr(engine, "suspend_company_runtime", None)
await self._mark_company_runtime_stop_state(
engine=engine,
task_ids=affected_task_ids,
state="suspending",
stop_intent_id=stop_intent_id,
)
if callable(suspend):
try:
suspended = await suspend(
origin_task_id=str(target_info.get("origin_task_id", "") or resolved_task_id),
session_id=(str(target_info.get("parent_session_id", "") or resolved_session_id).strip() or None),
session_id=(str(target_info.get("runtime_session_id", "") or resolved_session_id).strip() or None),
reason="user_stop",
checkpoint_type="company_runtime_suspended",
stop_intent_id=stop_intent_id,
)
except Exception:
logger.opt(exception=True).warning("suspend_company_runtime failed during service stop")
if suspended is not None:
for candidate in list(suspended.get("task_ids", []) or []):
candidate_id = str(candidate or "").strip()
if candidate_id and candidate_id not in affected_task_ids:
affected_task_ids.append(candidate_id)
await self._mark_company_runtime_stop_state(
engine=engine,
task_ids=affected_task_ids,
state="suspended",
stop_intent_id=stop_intent_id,
checkpoint_type=str(suspended.get("checkpoint_type", "") or "company_runtime_suspended"),
)
else:
await self._mark_company_runtime_stop_state(
engine=engine,
task_ids=affected_task_ids,
state="suspended",
stop_intent_id=stop_intent_id,
checkpoint_type="company_runtime_suspended",
if suspended is None:
raise ServiceError(
"company_runtime_suspend_failed",
"Company runtime could not be suspended",
{"runtime_session_id": target_info.get("runtime_session_id", "")},
)
for candidate in list(suspended.get("task_ids", []) or []):
candidate_id = str(candidate or "").strip()
if candidate_id and candidate_id not in affected_task_ids:
affected_task_ids.append(candidate_id)
self.context.stop_requested_task_ids.update(affected_task_ids)
if self.context.cancel_session_tasks is not None:
for tid in affected_task_ids:
@@ -1009,8 +1065,7 @@ class SessionService:
"stop_intent_id": stop_intent_id,
"checkpoint_id": str((suspended or {}).get("checkpoint_id", "") or ""),
"task_ids": affected_task_ids,
"resume_parent_task_id": str(target_info.get("parent_task_id", "") or resolved_task_id),
"resume_parent_session_id": str(target_info.get("parent_session_id", "") or resolved_session_id),
"resume_parent_session_id": str(target_info.get("runtime_session_id", "") or resolved_session_id),
}
return ServiceResult(payload, [ServiceEvent("session_runtime_control", payload), ServiceEvent("session_updated", payload)])
@@ -1039,6 +1094,8 @@ class SessionService:
project_id: str,
task_id: str = "",
session_id: str = "",
runtime_session_id: str = "",
checkpoint_id: str = "",
target: str = "",
content: str = "",
) -> ServiceResult:
@@ -1066,45 +1123,111 @@ class SessionService:
default_payload=default_payload,
)
engine = await self.context.engine_for_project(project_id)
exec_mode, company_profile = self.resolve_task_session_config(task)
target_info = await self._resolve_company_runtime_target(engine, task) if exec_mode in {"company", "org", "custom"} else {
try:
company_target = await self._resolve_company_runtime_target(
engine,
task,
runtime_session_id=runtime_session_id,
checkpoint_id=checkpoint_id,
)
except ServiceError as exc:
if exc.code != "company_runtime_identity_mismatch" or runtime_session_id or checkpoint_id:
raise
if is_company_runtime_task(task):
raise
company_target = None
target_info = company_target or {
"affected_task_ids": [resolved_task_id],
"parent_task_id": resolved_task_id,
"parent_session_id": resolved_session_id,
"ui_anchor_task_id": resolved_task_id,
"runtime_session_id": resolved_session_id,
"config_task": task,
"checkpoint": None,
}
affected_task_ids = list(target_info.get("affected_task_ids", []) or [resolved_task_id])
await self._clear_company_runtime_stop_state(engine=engine, task_ids=affected_task_ids)
message = str(content or "").strip() or "Resume the existing runtime."
engine_mode = "company" if exec_mode == "company" else ("org" if exec_mode in {"org", "custom"} else "task")
org_id = self.resolve_task_org_id(task) if engine_mode == "org" else ""
config_task = target_info.get("config_task") or task
config_exec_mode, config_company_profile = self.resolve_task_session_config(config_task)
engine_mode = "company" if config_exec_mode == "company" else (
"org" if config_exec_mode in {"org", "custom"} else "task"
)
if engine_mode in {"company", "org"}:
checkpoint = target_info.get("checkpoint")
if checkpoint is None:
raise ServiceError("company_runtime_checkpoint_not_found", "No active company runtime checkpoint")
if str(getattr(checkpoint, "status", "") or "").strip().lower() != "pending":
raise ServiceError(
"company_runtime_checkpoint_not_pending",
"Company runtime checkpoint is not pending",
{"checkpoint_id": str(getattr(checkpoint, "checkpoint_id", "") or "")},
)
else:
checkpoint = None
org_id = self.resolve_task_org_id(config_task) if engine_mode == "org" else ""
message_metadata: dict[str, Any] = {"ui_force_resume": True}
if checkpoint is not None:
message_metadata.update({
"response_to_checkpoint_id": str(getattr(checkpoint, "checkpoint_id", "") or ""),
"response_to_checkpoint_type": str(getattr(checkpoint, "checkpoint_type", "") or ""),
})
response = await engine.process_message(
message,
project_id=self.context.normalize_project_id(project_id),
session_id=str(target_info.get("parent_session_id", "") or resolved_session_id),
session_id=str(target_info.get("runtime_session_id", "") or resolved_session_id),
mode=engine_mode,
org_id=org_id or None,
company_profile=company_profile if engine_mode == "company" else None,
preferred_agent=self.resolve_task_preferred_agent(task) if engine_mode == "task" else None,
origin_task_id=str(target_info.get("parent_task_id", "") or resolved_task_id),
message_metadata={"ui_force_resume": True},
company_profile=config_company_profile if engine_mode == "company" else None,
preferred_agent=self.resolve_task_preferred_agent(config_task) if engine_mode == "task" else None,
origin_task_id=(str(target_info.get("ui_anchor_task_id", "") or "").strip() or None),
message_metadata=message_metadata,
)
runtime_control_state = "idle"
pending_runtime_checkpoint_id = ""
if engine_mode in {"company", "org"}:
refreshed_index = await load_company_runtime_identity_index(
engine.store,
self.context.normalize_project_id(project_id),
)
refreshed_identity = refreshed_index.resolve(
runtime_session_id=str(
target_info.get("runtime_session_id", "") or resolved_session_id
),
)
if refreshed_identity is not None and refreshed_identity.pending_checkpoint_id:
pending_runtime_checkpoint_id = refreshed_identity.pending_checkpoint_id
runtime_control_state = (
"suspended"
if refreshed_identity.pending_checkpoint_status == "pending"
else "resuming"
)
payload = {
**default_payload,
"status": "resuming",
"runtime_control_state": "resuming",
"can_resume": False,
"status": runtime_control_state,
"runtime_control_state": runtime_control_state,
"can_resume": runtime_control_state == "suspended",
"response": response,
"task_ids": affected_task_ids,
"resume_parent_task_id": str(target_info.get("parent_task_id", "") or resolved_task_id),
"resume_parent_session_id": str(target_info.get("parent_session_id", "") or resolved_session_id),
"resume_parent_session_id": str(target_info.get("runtime_session_id", "") or resolved_session_id),
"pending_runtime_checkpoint_id": pending_runtime_checkpoint_id,
}
return ServiceResult(payload, [ServiceEvent("session_runtime_control", payload), ServiceEvent("session_updated", payload)])
async def resume(self, *, project_id: str, task_id: str = "", session_id: str = "", target: str = "", content: str = "") -> ServiceResult:
async def resume(
self,
*,
project_id: str,
task_id: str = "",
session_id: str = "",
runtime_session_id: str = "",
checkpoint_id: str = "",
target: str = "",
content: str = "",
) -> ServiceResult:
return await self.continue_run(
project_id=project_id,
task_id=task_id,
session_id=session_id,
runtime_session_id=runtime_session_id,
checkpoint_id=checkpoint_id,
target=target,
content=content,
)
+71 -80
View File
@@ -39,6 +39,11 @@ from opc.layer2_organization.phase import (
should_hide_work_item_from_company_kanban,
verdict,
)
from opc.layer2_organization.company_runtime_identity import (
ACTIVE_COMPANY_RUNTIME_CHECKPOINT_STATUSES,
COMPANY_RUNTIME_CHECKPOINT_TYPES,
build_company_runtime_identity_index,
)
from opc.layer2_organization.work_item_context_view import WorkItemContextView
from opc.layer2_organization.work_item_identity import (
WORK_ITEM_PROJECTION_ID_KEY,
@@ -1503,6 +1508,11 @@ def _primary_session_tasks_by_session_id(
*,
task_meta_map: dict[str, dict[str, Any]] | None = None,
) -> tuple[dict[str, Any], list[str]]:
identity_index = build_company_runtime_identity_index(tasks)
company_identities = {
identity.runtime_session_id: identity
for identity in identity_index.identities
}
primary_tasks_by_session_id: dict[str, Any] = {}
ordered_session_ids: list[str] = []
for task in tasks:
@@ -1513,6 +1523,19 @@ def _primary_session_tasks_by_session_id(
if bool(task_meta.get("review_task", False)):
continue
session_id = str(getattr(task, "session_id", "") or "").strip()
company_identity = company_identities.get(session_id)
if company_identity is not None:
anchor = identity_index.task(company_identity.ui_anchor_task_id)
if anchor is not None and session_id not in primary_tasks_by_session_id:
primary_tasks_by_session_id[session_id] = anchor
ordered_session_ids.append(session_id)
if anchor is not None:
# A shared final-decider/work-item Task must never replace a
# pure UI anchor that owns the same session id.
continue
# Without a pure anchor this scope has no primary chat container.
# Never synthesize one from a role/work-item Task.
continue
if not session_id or _task_parent_session_link(task, task_meta):
continue
current = primary_tasks_by_session_id.get(session_id)
@@ -1530,31 +1553,16 @@ def _primary_session_tasks_by_session_id(
return primary_tasks_by_session_id, ordered_session_ids
def _shared_role_identity_tasks_by_session_id(
def _company_config_source_tasks_by_session_id(
tasks: list[Any],
*,
task_meta_map: dict[str, dict[str, Any]] | None = None,
) -> dict[str, Any]:
identity_tasks_by_session_id: dict[str, Any] = {}
for task in tasks:
task_id = str(getattr(task, "id", "") or "").strip()
task_meta = (
task_meta_map.get(task_id, {}) if task_meta_map is not None and task_id else _task_metadata(task)
)
session_id = _shared_role_session_key(task, task_meta)
if not session_id:
continue
current = identity_tasks_by_session_id.get(session_id)
if current is None:
identity_tasks_by_session_id[session_id] = task
continue
current_id = str(getattr(current, "id", "") or "").strip()
current_meta = (
task_meta_map.get(current_id, {}) if task_meta_map is not None and current_id else _task_metadata(current)
)
if _session_representative_rank(task, task_meta) > _session_representative_rank(current, current_meta):
identity_tasks_by_session_id[session_id] = task
return identity_tasks_by_session_id
identity_index = build_company_runtime_identity_index(tasks)
return {
identity.runtime_session_id: task
for identity in identity_index.identities
if identity.config_source_task_id
and (task := identity_index.task(identity.config_source_task_id)) is not None
}
async def build_company_kanban_projection(
@@ -2594,31 +2602,7 @@ async def _build_company_runtime_control_by_task(
if not store:
return {}
parent_task_by_session: dict[str, str] = {}
tasks_by_parent_session: dict[str, list[Any]] = {}
for task in tasks:
metadata = dict(getattr(task, "metadata", {}) or {})
mode = str(metadata.get("mode", "") or metadata.get("exec_mode", "") or "").strip().lower()
is_company_runtime_task = bool(
mode in {"company", "org", "custom"}
or str(getattr(task, "parent_session_id", "") or "").strip()
or metadata.get("company_profile")
or metadata.get("company_work_item_plan")
or metadata.get("work_item_runtime")
or metadata.get("work_item_projection_id")
)
if not is_company_runtime_task:
continue
session_id = str(getattr(task, "session_id", "") or "").strip()
parent_session_id = str(getattr(task, "parent_session_id", "") or "").strip()
task_id = str(getattr(task, "id", "") or "").strip()
if session_id and not parent_session_id:
parent_task_by_session[session_id] = task_id
runtime_parent_session_id = parent_session_id or session_id
if runtime_parent_session_id:
tasks_by_parent_session.setdefault(runtime_parent_session_id, []).append(task)
checkpoints_by_session: dict[str, Any] = {}
checkpoints: list[Any] = []
getter = getattr(store, "get_execution_checkpoints", None)
if not callable(getter):
getter = getattr(store, "get_pending_checkpoints", None)
@@ -2626,29 +2610,25 @@ async def _build_company_runtime_control_by_task(
try:
kwargs = {
"project_id": project_id,
"checkpoint_types": ["company_runtime_suspended", "company_runtime_interrupted"],
"checkpoint_types": sorted(COMPANY_RUNTIME_CHECKPOINT_TYPES),
}
if getattr(getter, "__name__", "") == "get_execution_checkpoints":
kwargs["statuses"] = ["pending", "resuming"]
kwargs["statuses"] = sorted(ACTIVE_COMPANY_RUNTIME_CHECKPOINT_STATUSES)
checkpoints = await getter(**kwargs)
for checkpoint in checkpoints:
sid = str(getattr(checkpoint, "session_id", "") or "").strip()
if sid and sid not in checkpoints_by_session:
checkpoints_by_session[sid] = checkpoint
except Exception:
logger.opt(exception=True).debug("snapshot: failed to load company runtime checkpoints")
checkpoints = []
identity_index = build_company_runtime_identity_index(tasks, checkpoints)
result: dict[str, dict[str, Any]] = {}
for parent_session_id, group in tasks_by_parent_session.items():
checkpoint = checkpoints_by_session.get(parent_session_id)
parent_task_id = parent_task_by_session.get(parent_session_id, "")
if not parent_task_id:
for task in group:
if not str(getattr(task, "parent_session_id", "") or "").strip():
parent_task_id = str(getattr(task, "id", "") or "").strip()
break
if not parent_task_id and group:
parent_task_id = str(getattr(group[0], "id", "") or "").strip()
for identity in identity_index.identities:
group = [
task
for task_id in identity.runtime_task_ids
if (task := identity_index.task(task_id)) is not None
]
checkpoint = identity.checkpoint
def _task_status_value(task: Any) -> str:
status = getattr(task, "status", "")
@@ -2660,10 +2640,19 @@ async def _build_company_runtime_control_by_task(
task for task in group
if _task_status_value(task) not in {"done", "failed", "cancelled"}
]
has_running_task = any(
_task_status_value(task) == "running"
for task in non_terminal_group
)
# Persisted RUNNING is only a projection. The controller-local
# execution registry is the sole proof that this process still owns a
# coroutine capable of monitoring and persisting the run.
runtime_is_live = getattr(engine, "_task_runtime_is_live", None)
has_running_task = False
if callable(runtime_is_live):
for task in non_terminal_group:
live_result = runtime_is_live(task)
if inspect.isawaitable(live_result):
live_result = await live_result
if live_result is True:
has_running_task = True
break
any_stop_in_progress = any(
str((getattr(task, "metadata", {}) or {}).get("company_runtime_stop_state", "") or "").strip()
in {"suspending", "suspended", "resuming_after_suspending"}
@@ -2708,8 +2697,7 @@ async def _build_company_runtime_control_by_task(
"runtime_control_state": state,
"can_stop": state == "running",
"can_resume": state == "suspended",
"resume_parent_task_id": parent_task_id,
"resume_parent_session_id": parent_session_id,
"resume_parent_session_id": identity.runtime_session_id,
"pending_runtime_checkpoint_id": pending_checkpoint_id,
"stop_intent_id": str(checkpoint_payload.get("stop_intent_id", "") or ""),
}
@@ -3014,9 +3002,8 @@ async def build_project_index_sync(
session_tasks,
task_meta_map=task_meta_map,
)
shared_identity_tasks_by_session_id = _shared_role_identity_tasks_by_session_id(
company_config_tasks_by_session_id = _company_config_source_tasks_by_session_id(
session_tasks,
task_meta_map=task_meta_map,
)
child_tasks_by_parent: dict[str, list[Any]] = {}
for task in session_tasks:
@@ -3087,11 +3074,12 @@ async def build_project_index_sync(
representative_task = primary_tasks_by_session_id.get(session_id)
representative_task_id = str(getattr(representative_task, "id", "") or "").strip()
shared_session_id = _shared_role_session_key(t, t_meta)
if shared_session_id and representative_task_id and representative_task_id != task_id:
continue
if shared_session_id:
if not representative_task_id or representative_task_id != task_id:
continue
identity_task = t
identity_meta = t_meta
shared_identity_task = shared_identity_tasks_by_session_id.get(session_id)
shared_identity_task = company_config_tasks_by_session_id.get(session_id)
shared_identity_task_id = str(getattr(shared_identity_task, "id", "") or "").strip()
if shared_identity_task_id and shared_identity_task_id != task_id:
identity_task = shared_identity_task
@@ -3569,9 +3557,8 @@ async def build_collab_sync(
session_tasks,
task_meta_map=task_meta_map,
)
shared_identity_tasks_by_session_id = _shared_role_identity_tasks_by_session_id(
company_config_tasks_by_session_id = _company_config_source_tasks_by_session_id(
session_tasks,
task_meta_map=task_meta_map,
)
child_tasks_by_parent: dict[str, list[Any]] = {}
for task in session_tasks:
@@ -3616,11 +3603,15 @@ async def build_collab_sync(
representative_task = primary_tasks_by_session_id.get(session_id)
representative_task_id = str(getattr(representative_task, "id", "") or "").strip()
shared_session_id = _shared_role_session_key(t, t_meta)
if shared_session_id and representative_task_id and representative_task_id != str(getattr(t, "id", "") or "").strip():
continue
if shared_session_id:
if (
not representative_task_id
or representative_task_id != str(getattr(t, "id", "") or "").strip()
):
continue
identity_task = t
identity_meta = t_meta
shared_identity_task = shared_identity_tasks_by_session_id.get(session_id)
shared_identity_task = company_config_tasks_by_session_id.get(session_id)
shared_identity_task_id = str(getattr(shared_identity_task, "id", "") or "").strip()
if shared_identity_task_id and shared_identity_task_id != str(getattr(t, "id", "") or "").strip():
identity_task = shared_identity_task
@@ -51,8 +51,11 @@ class CompanyKanbanProjectionTests(unittest.IsolatedAsyncioTestCase):
},
)
store = MagicMock()
store.get_pending_checkpoints = AsyncMock(return_value=[])
engine = SimpleNamespace(store=store)
store.get_execution_checkpoints = AsyncMock(return_value=[])
engine = SimpleNamespace(
store=store,
_task_runtime_is_live=AsyncMock(return_value=True),
)
control = await _build_company_runtime_control_by_task(
engine,
@@ -95,8 +98,11 @@ class CompanyKanbanProjectionTests(unittest.IsolatedAsyncioTestCase):
},
)
store = MagicMock()
store.get_pending_checkpoints = AsyncMock(return_value=[])
engine = SimpleNamespace(store=store)
store.get_execution_checkpoints = AsyncMock(return_value=[])
engine = SimpleNamespace(
store=store,
_task_runtime_is_live=AsyncMock(return_value=True),
)
control = await _build_company_runtime_control_by_task(
engine,
@@ -106,8 +112,9 @@ class CompanyKanbanProjectionTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(control["child-task"]["runtime_control_state"], "running")
self.assertTrue(control["child-task"]["can_stop"])
engine._task_runtime_is_live.assert_awaited()
async def test_runtime_control_running_status_does_not_require_live_heartbeat(self) -> None:
async def test_runtime_control_running_status_requires_controller_registry_ownership(self) -> None:
created_at = datetime.now(timezone.utc)
parent_task = SimpleNamespace(
id="parent-task",
@@ -122,7 +129,7 @@ class CompanyKanbanProjectionTests(unittest.IsolatedAsyncioTestCase):
},
)
store = MagicMock()
store.get_pending_checkpoints = AsyncMock(return_value=[])
store.get_execution_checkpoints = AsyncMock(return_value=[])
engine = SimpleNamespace(
store=store,
_task_runtime_is_live=AsyncMock(return_value=False),
@@ -134,9 +141,9 @@ class CompanyKanbanProjectionTests(unittest.IsolatedAsyncioTestCase):
"proj1",
)
self.assertEqual(control["parent-task"]["runtime_control_state"], "running")
self.assertTrue(control["parent-task"]["can_stop"])
engine._task_runtime_is_live.assert_not_awaited()
self.assertEqual(control["parent-task"]["runtime_control_state"], "idle")
self.assertFalse(control["parent-task"]["can_stop"])
engine._task_runtime_is_live.assert_awaited_once_with(parent_task)
async def test_runtime_control_treats_dispatch_hold_as_suspending_without_checkpoint(self) -> None:
created_at = datetime.now(timezone.utc)
@@ -1482,7 +1489,7 @@ class CollabSyncCompanyModeTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(session["company_profile"], "custom")
self.assertEqual(session["preferred_agent"], "codex")
async def test_build_collab_sync_deduplicates_shared_role_sessions(self) -> None:
async def test_build_collab_sync_does_not_promote_shared_role_session_without_ui_anchor(self) -> None:
created_at = datetime.now(timezone.utc)
shared_session_id = "root-session:role:cto"
pending_task = SimpleNamespace(
@@ -1519,6 +1526,7 @@ class CollabSyncCompanyModeTests(unittest.IsolatedAsyncioTestCase):
engine = MagicMock()
engine.store = MagicMock()
engine.store.get_tasks = AsyncMock(return_value=[pending_task, running_task])
engine.store.get_execution_checkpoints = AsyncMock(return_value=[])
engine.project_id = "proj-shared"
engine.llm = None
@@ -1586,10 +1594,7 @@ class CollabSyncCompanyModeTests(unittest.IsolatedAsyncioTestCase):
)
sessions = result.get("sessions", [])
self.assertEqual(len(sessions), 1)
self.assertEqual(sessions[0]["project_id"], "proj-shared")
self.assertEqual(sessions[0]["task_id"], "task-running")
self.assertEqual(sessions[0]["session_id"], shared_session_id)
self.assertEqual(sessions, [])
async def test_build_collab_sync_keeps_root_session_visible_when_final_decider_shares_session(self) -> None:
created_at = datetime.now(timezone.utc)
File diff suppressed because it is too large Load Diff