Rifqi Hafizuddin
[NOTICKET] feat(slow-path): persist AnalysisRecord via PostgresAnalysisStore
09f8451 | """TaskRunner — deterministic execution of a static `TaskList`. Zero LLM. | |
| Executes tasks in dependency order, parallelizing each ready "wave" with | |
| `asyncio.gather`. For each task it resolves `${t<id>}` placeholders from upstream | |
| results, does an internal `validate_args`, invokes each tool via the `ToolInvoker` | |
| seam, and records a `TaskResult`. On failure it **degrades and continues**: the | |
| task is marked failed, its dependents are skipped, independent branches keep | |
| running. There is no replanning and no mid-run LLM (INV-6). | |
| `success_criteria` is *not* machine-evaluated here (it is free text); task status | |
| is derived from tool execution outcomes and carried to the Assembler to report. | |
| See AGENT_ARCHITECTURE_CONTEXT_new.md §7.4. | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| from typing import Any | |
| from src.middlewares.logging import get_logger | |
| from ..planner.contracts import ToolOutput, ToolRegistry | |
| from ..planner.schemas import PLACEHOLDER_RE, Task | |
| from ..planner.schemas import TaskList as PlanTaskList | |
| from .invoker import ToolInvoker | |
| from .schemas import RunState, TaskResult, TaskStatus | |
| logger = get_logger("task_runner") | |
| class TaskRunner: | |
| """Runs a `TaskList` against a `ToolInvoker`, producing a `RunState`.""" | |
| def __init__(self, invoker: ToolInvoker, registry: ToolRegistry) -> None: | |
| self._invoker = invoker | |
| self._registry = registry | |
| async def run(self, task_list: PlanTaskList, business_context_id: str) -> RunState: | |
| tasks_by_id: dict[str, Task] = {t.id: t for t in task_list.tasks} | |
| results: dict[str, TaskResult] = {} | |
| remaining: set[str] = set(tasks_by_id) | |
| while remaining: | |
| ready = [ | |
| tid | |
| for tid in remaining | |
| if all(dep in results for dep in tasks_by_id[tid].depends_on) | |
| ] | |
| if not ready: | |
| # A dependency points outside the plan (or a cycle slipped past the | |
| # planner validator): nothing more can run. Fail the rest honestly. | |
| for tid in list(remaining): | |
| results[tid] = TaskResult( | |
| task_id=tid, | |
| stage=tasks_by_id[tid].stage, | |
| status="failure", | |
| objective=tasks_by_id[tid].objective, | |
| error="unresolved dependency; task could not run", | |
| ) | |
| remaining.discard(tid) | |
| break | |
| # Skip any ready task whose dependency failed (degrade-and-continue). | |
| to_run: list[Task] = [] | |
| for tid in ready: | |
| task = tasks_by_id[tid] | |
| failed = [d for d in task.depends_on if results[d].status == "failure"] | |
| if failed: | |
| results[tid] = TaskResult( | |
| task_id=tid, | |
| stage=task.stage, | |
| status="failure", | |
| objective=task.objective, | |
| error=f"skipped: upstream {failed} did not succeed", | |
| ) | |
| remaining.discard(tid) | |
| else: | |
| to_run.append(task) | |
| if not to_run: | |
| continue # remaining dependents will be re-evaluated (and skipped) | |
| wave = await asyncio.gather( | |
| *(self._run_task(task, results) for task in to_run) | |
| ) | |
| for tr in wave: | |
| results[tr.task_id] = tr | |
| remaining.discard(tr.task_id) | |
| return RunState( | |
| plan_id=task_list.plan_id, | |
| business_context_id=business_context_id, | |
| results=results, | |
| open_questions=list(task_list.open_questions), | |
| ) | |
| async def _run_task(self, task: Task, results: dict[str, TaskResult]) -> TaskResult: | |
| outputs: list[ToolOutput] = [] | |
| for call in task.tool_calls: | |
| resolved = self._resolve_args(call.args, results) | |
| arg_error = self._validate_args(call.tool, resolved) | |
| if arg_error is not None: | |
| outputs.append(ToolOutput(tool=call.tool, kind="error", error=arg_error)) | |
| continue | |
| outputs.append(await self._safe_invoke(call.tool, resolved)) | |
| status = _label(outputs) | |
| error: str | None = None | |
| if status == "failure": | |
| errs = [o.error for o in outputs if o.kind == "error" and o.error] | |
| error = errs[0] if errs else "all tool calls failed" | |
| return TaskResult( | |
| task_id=task.id, | |
| stage=task.stage, | |
| status=status, | |
| objective=task.objective, | |
| outputs=outputs, | |
| error=error, | |
| ) | |
| def _resolve_args( | |
| self, args: dict[str, Any], results: dict[str, TaskResult] | |
| ) -> dict[str, Any]: | |
| return {k: self._resolve_value(v, results) for k, v in args.items()} | |
| def _resolve_value(value: Any, results: dict[str, TaskResult]) -> Any: | |
| # A data arg is exactly a "${t<id>}" placeholder (Pattern A); resolve it to | |
| # the referenced task's representative output (its last ToolOutput). | |
| # Materializing that envelope into a DataFrame is the invoker's job. | |
| if isinstance(value, str): | |
| match = PLACEHOLDER_RE.fullmatch(value.strip()) | |
| if match: | |
| upstream = results.get(match.group(1)) | |
| if upstream is None or not upstream.outputs: | |
| return None | |
| return upstream.outputs[-1] | |
| return value | |
| def _validate_args(self, tool: str, resolved: dict[str, Any]) -> str | None: | |
| spec = self._registry.get(tool) | |
| if spec is None: | |
| return f"tool {tool!r} not in registry" | |
| required = spec.input_schema.get("required", []) | |
| missing = [r for r in required if resolved.get(r) is None] | |
| if missing: | |
| return f"missing required arg(s): {sorted(missing)}" | |
| return None | |
| async def _safe_invoke(self, tool: str, args: dict[str, Any]) -> ToolOutput: | |
| try: | |
| return await self._invoker.invoke(tool, args) | |
| except Exception as exc: # noqa: BLE001 — backstop; the invoker is never-throw (§8.4) | |
| logger.warning("tool invoker raised", tool=tool, error=str(exc)) | |
| return ToolOutput(tool=tool, kind="error", error=f"invoker raised: {exc}") | |
| def _label(outputs: list[ToolOutput]) -> TaskStatus: | |
| if not outputs: | |
| return "failure" | |
| errors = sum(1 for o in outputs if o.kind == "error") | |
| if errors == 0: | |
| return "success" | |
| if errors == len(outputs): | |
| return "failure" | |
| return "partial" | |