Files
ss-tools/backend/src/core/task_manager/lifecycle.py
busya f872e610a9 refactor: decompose oversized contracts to satisfy INV_7 fractal limit
Break monolithic modules >400 lines into focused sub-modules while
preserving backward-compatible imports and all test coverage:

Backend (Python):
- TranslationExecutor: 1974→241 lines, split into 9 sub-modules
- Translate plugin: orchestrator (1137→148), preview (1303→244),
  service (1052→275), dictionary (1007→68)
- ProfileService: 857→172 with 4 extracted sub-modules
- TaskManager: 708→322 with graph/event_bus/lifecycle extracted
- Test dictionary: 1199→split into 6 focused test files

Frontend (Svelte):
- SettingsPage: 1451→291 with 6 extracted tab components
- GitManager: 1220→228 with 5 extracted panels
- DatasetReviewWorkspace: 1202→314
- translate.js API: 664→28 barrel with 6 domain modules

Protocol:
- Remove single-contract 150-line limit from INV_7 (keep CC≤10)
- Fix unclosed #endregion tags across 11 files
- Fix 19 test regressions from stale mock paths
- All 294 tests passing
2026-05-17 19:18:32 +03:00

314 lines
15 KiB
Python

# #region JobLifecycleModule [C:5] [TYPE Module] [SEMANTICS task,lifecycle,state,machine,execution]
# @BRIEF Task creation, execution, pause/resume, and completion transitions for plugin-backed
# jobs — extracted from the monolithic TaskManager.
# @LAYER Core
# @RELATION DEPENDS_ON -> [TaskGraph]
# @RELATION DEPENDS_ON -> [EventBus]
# @RELATION DEPENDS_ON -> [TaskContext]
# @RELATION DEPENDS_ON -> [PluginLoader]
# @RELATION DEPENDS_ON -> [TaskPersistenceService]
# @RATIONALE Extracted from TaskManager to satisfy INV_7. JobLifecycle encodes the task state
# machine with plugin execution, context injection, backward-compatible plugin
# dispatch, and input/mapping pause/resume transitions.
# @REJECTED Keeping lifecycle/execution logic inside TaskManager was rejected — it mixes
# async execution orchestration with passive registry and log buffering concerns,
# violating single-responsibility and INV_7.
# @PRE Requested plugin ids resolve to executable plugins; task state transitions target known
# TaskStatus values.
# @POST Every scheduled task transitions through a valid lifecycle path ending in persisted
# terminal or waiting state.
# @SIDE_EFFECT Schedules async execution, pauses on input/mapping requests, mutates persisted
# task lifecycle markers.
# @INVARIANT A task cannot be resumed from a waiting state unless a matching future exists or
# a new wait future is created.
import asyncio
import inspect
from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, datetime
from typing import Any
from ..cot_logger import seed_trace_id
from ..logger import belief_scope, logger
from .context import TaskContext
from .graph import TaskGraph
from .models import Task, TaskStatus
# #region JobLifecycle [C:5] [TYPE Class] [SEMANTICS task,lifecycle,execution,state,machine]
# @BRIEF Encodes task creation, execution, pause/resume, and completion transitions for
# plugin-backed jobs.
# @RELATION DEPENDS_ON -> [TaskGraph]
# @RELATION DEPENDS_ON -> [TaskContext]
# @RELATION DEPENDS_ON -> [PluginLoader]
# @RELATION DEPENDS_ON -> [TaskPersistenceService]
# @PRE plugin_loader resolves plugin ids; graph and event_bus are initialized.
# @POST Every scheduled task transitions through a valid lifecycle path ending in persisted
# terminal or waiting state.
# @RATIONALE Extracted as standalone lifecycle state machine to isolate async execution
# orchestration (plugin dispatch, context injection, pause/resume futures) from
# passive registry and log buffering concerns.
# @REJECTED Keeping execution logic inside TaskManager was rejected — it mixed async task
# orchestration with synchronous registry operations and background threading,
# violating single-responsibility and making the 708-line module unmaintainable.
class JobLifecycle:
"""Task state machine: creation, execution, pause/resume, completion."""
# #region __init__ [TYPE Function]
# @BRIEF Initialize with dependencies.
def __init__(
self,
plugin_loader: Any,
graph: TaskGraph,
event_bus: Any,
persistence_service: Any,
executor: ThreadPoolExecutor,
loop: asyncio.AbstractEventLoop,
):
self.plugin_loader = plugin_loader
self.graph = graph
self.event_bus = event_bus
self.persistence_service = persistence_service
self.executor = executor
self.loop = loop
# #endregion __init__
# #region create_task [C:4] [TYPE Function] [SEMANTICS task,create,persist,schedule]
# @BRIEF Creates and queues a new task for execution.
# @PRE Plugin with plugin_id exists. Params are valid dict.
# @POST Task is created, added to registry, and scheduled for execution.
# @RAISES ValueError if plugin not found or params invalid.
async def create_task(
self, plugin_id: str, params: dict[str, Any], user_id: str | None = None,
add_log_callback=None,
) -> Task:
with belief_scope("JobLifecycle.create_task", f"plugin_id={plugin_id}"):
if not self.plugin_loader.has_plugin(plugin_id):
logger.error(f"Plugin with ID '{plugin_id}' not found.")
raise ValueError(f"Plugin with ID '{plugin_id}' not found.")
self.plugin_loader.get_plugin(plugin_id)
if not isinstance(params, dict):
logger.error("Task parameters must be a dictionary.")
raise ValueError("Task parameters must be a dictionary.")
logger.reason("Creating task node and scheduling execution")
task = Task(plugin_id=plugin_id, params=params, user_id=user_id)
self.graph.add_task(task)
self.persistence_service.persist_task(task)
logger.info(f"Task {task.id} created and scheduled for execution")
self.loop.create_task(
self._run_task(task.id, add_log_callback=add_log_callback)
)
logger.reflect(
"Task creation persisted and execution scheduled",
extra={"task_id": task.id, "plugin_id": plugin_id},
)
return task
# #endregion create_task
# #region _run_task [C:4] [TYPE Function] [SEMANTICS task,execute,context,dispatch]
# @BRIEF Internal method to execute a task with TaskContext support.
# @PRE Task exists in registry.
# @POST Task is executed, status updated to SUCCESS or FAILED.
# @SIDE_EFFECT Executes plugin code, updates task status and persistence, emits logs.
async def _run_task(self, task_id: str, add_log_callback=None):
seed_trace_id()
with belief_scope("JobLifecycle._run_task", f"task_id={task_id}"):
task = self.graph.get_task(task_id)
if task is None:
logger.error(f"Task {task_id} not found in registry")
return
plugin = self.plugin_loader.get_plugin(task.plugin_id)
logger.reason(
"Transitioning task to running state",
extra={"task_id": task_id, "plugin_id": task.plugin_id},
)
logger.info(
f"Starting execution of task {task_id} for plugin '{plugin.name}'"
)
task.status = TaskStatus.RUNNING
task.started_at = datetime.utcnow()
self.persistence_service.persist_task(task)
if add_log_callback:
add_log_callback(
task_id, "INFO",
f"Task started for plugin '{plugin.name}'",
source="system",
)
try:
params = {**task.params, "_task_id": task_id}
sig = inspect.signature(plugin.execute)
accepts_context = "context" in sig.parameters
if accepts_context:
# Create TaskContext for new-style plugins
context = TaskContext(
task_id=task_id,
add_log_fn=add_log_callback,
params=params,
default_source="plugin",
background_tasks=None,
)
if asyncio.iscoroutinefunction(plugin.execute):
task.result = await plugin.execute(params, context=context)
else:
task.result = await self.loop.run_in_executor(
self.executor,
lambda: plugin.execute(params, context=context),
)
else:
if asyncio.iscoroutinefunction(plugin.execute):
task.result = await plugin.execute(params)
else:
task.result = await self.loop.run_in_executor(
self.executor, plugin.execute, params
)
logger.info(f"Task {task_id} completed successfully")
task.status = TaskStatus.SUCCESS
if add_log_callback:
add_log_callback(
task_id, "INFO",
f"Task completed successfully for plugin '{plugin.name}'",
source="system",
)
except Exception as e:
logger.error(f"Task {task_id} failed: {e}")
task.status = TaskStatus.FAILED
if add_log_callback:
add_log_callback(
task_id, "ERROR",
f"Task failed: {e}",
source="system",
metadata={"error_type": type(e).__name__},
)
finally:
task.finished_at = datetime.utcnow()
self.event_bus.flush_task_logs(task_id)
self.persistence_service.persist_task(task)
logger.info(
f"Task {task_id} execution finished with status: {task.status}"
)
logger.reflect(
"Task lifecycle reached persisted terminal state",
extra={"task_id": task_id, "status": str(task.status)},
)
# #endregion _run_task
# #region resolve_task [C:3] [TYPE Function] [SEMANTICS task,resolve,mapping,resume]
# @BRIEF Resumes a task that is awaiting mapping.
# @PRE Task exists and is in AWAITING_MAPPING state.
# @POST Task status updated to RUNNING, params updated, execution resumed.
# @RAISES ValueError if task not found or not awaiting mapping.
async def resolve_task(
self, task_id: str, resolution_params: dict[str, Any],
) -> None:
with belief_scope("JobLifecycle.resolve_task", f"task_id={task_id}"):
task = self.graph.get_task(task_id)
if not task or task.status != TaskStatus.AWAITING_MAPPING:
raise ValueError("Task is not awaiting mapping.")
task.params.update(resolution_params)
task.status = TaskStatus.RUNNING
self.persistence_service.persist_task(task)
self.graph.resolve_future(task_id, True)
# #endregion resolve_task
# #region wait_for_resolution [C:3] [TYPE Function] [SEMANTICS task,wait,mapping,future]
# @BRIEF Pauses execution and waits for a resolution signal.
# @PRE Task exists.
# @POST Execution pauses until future is set.
async def wait_for_resolution(self, task_id: str) -> None:
with belief_scope("JobLifecycle.wait_for_resolution", f"task_id={task_id}"):
task = self.graph.get_task(task_id)
if not task:
return
task.status = TaskStatus.AWAITING_MAPPING
self.persistence_service.persist_task(task)
future = self.loop.create_future()
self.graph.create_future(task_id, future)
try:
await future
finally:
self.graph.remove_future(task_id)
# #endregion wait_for_resolution
# #region wait_for_input [C:3] [TYPE Function] [SEMANTICS task,wait,input,future]
# @BRIEF Pauses execution and waits for user input.
# @PRE Task exists.
# @POST Execution pauses until future is set via resume_task_with_password.
async def wait_for_input(self, task_id: str) -> None:
with belief_scope("JobLifecycle.wait_for_input", f"task_id={task_id}"):
task = self.graph.get_task(task_id)
if not task:
return
future = self.loop.create_future()
self.graph.create_future(task_id, future)
try:
await future
finally:
self.graph.remove_future(task_id)
# #endregion wait_for_input
# #region await_input [C:3] [TYPE Function] [SEMANTICS task,input,pause,state]
# @BRIEF Transition a task to AWAITING_INPUT state with input request.
# @PRE Task exists and is in RUNNING state.
# @POST Task status changed to AWAITING_INPUT, input_request set, persisted.
# @RAISES ValueError if task not found or not RUNNING.
def await_input(
self, task_id: str, input_request: dict[str, Any],
add_log_callback=None,
) -> None:
with belief_scope("JobLifecycle.await_input", f"task_id={task_id}"):
task = self.graph.get_task(task_id)
if not task:
raise ValueError(f"Task {task_id} not found")
if task.status != TaskStatus.RUNNING:
raise ValueError(
f"Task {task_id} is not RUNNING (current: {task.status})"
)
task.status = TaskStatus.AWAITING_INPUT
task.input_required = True
task.input_request = input_request
self.persistence_service.persist_task(task)
if add_log_callback:
add_log_callback(
task_id, "INFO",
"Task paused for user input",
metadata={"input_request": input_request},
)
# #endregion await_input
# #region resume_task_with_password [C:3] [TYPE Function] [SEMANTICS task,resume,password,input]
# @BRIEF Resume a task that is awaiting input with provided passwords.
# @PRE Task exists and is in AWAITING_INPUT state.
# @POST Task status changed to RUNNING, passwords injected, task resumed.
# @RAISES ValueError if task not found, not awaiting input, or passwords invalid.
def resume_task_with_password(
self, task_id: str, passwords: dict[str, str],
add_log_callback=None,
) -> None:
with belief_scope(
"JobLifecycle.resume_task_with_password", f"task_id={task_id}"
):
task = self.graph.get_task(task_id)
if not task:
raise ValueError(f"Task {task_id} not found")
if task.status != TaskStatus.AWAITING_INPUT:
raise ValueError(
f"Task {task_id} is not AWAITING_INPUT (current: {task.status})"
)
if not isinstance(passwords, dict) or not passwords:
raise ValueError("Passwords must be a non-empty dictionary")
task.params["passwords"] = passwords
task.input_required = False
task.input_request = None
task.status = TaskStatus.RUNNING
self.persistence_service.persist_task(task)
if add_log_callback:
add_log_callback(
task_id, "INFO",
"Task resumed with passwords",
metadata={"databases": list(passwords.keys())},
)
self.graph.resolve_future(task_id, True)
# #endregion resume_task_with_password
# #endregion JobLifecycle
# #endregion JobLifecycleModule