circuitforge-core/docs/modules/tasks.md
pyr0ball 43f9e9a54c
Some checks failed
CI / test (pull_request) Has been cancelled
feat(tasks): implement dispatch_task/get_task_status generic caller/args dispatch
Pagepiper (and possibly other products copying its pattern) imported
dispatch_task(caller, args) -> task_id / get_task_status(task_id) -> dict
from circuitforge_core.tasks, expecting a "product/task_name" + kwargs-dict
interface. Neither function existed, so every call silently hit an
except-Exception fallback with no visible error.

This is a new module, not a TaskScheduler wrapper — TaskScheduler is keyed
by task_id/job_id/params against a specific SQLite background_tasks table
(VRAM-budgeted LLM queue), a different shape from the generic named-runnable
dispatch pagepiper actually needed.

Scope: free-tier, in-process, single-node only. Routing through the
circuitforge-orch coordinator would need a new generic task-dispatch
endpoint on that separate BSL package (CFOrchClient only exposes model/
service allocation today) — tracked as follow-up, not attempted here.
Consuming products additionally need to call register_task_runner() at
startup to benefit; that's product-side work in a separate repo.

Bump to 0.22.0.

Closes: #67
2026-07-10 18:08:48 -07:00

3.5 KiB

tasks

VRAM-aware background task scheduler. Manages a queue of LLM inference jobs and coordinates VRAM allocation with the cf-orch coordinator before executing each task.

from circuitforge_core.tasks import TaskScheduler, get_scheduler, reset_scheduler

Why VRAM-aware scheduling

Running multiple LLM inference jobs concurrently on a single GPU causes OOM errors and corrupted outputs. The scheduler serializes LLM work per service and negotiates with the cf-orch coordinator so tasks across multiple products don't compete for the same VRAM budget.

Core API

get_scheduler() -> TaskScheduler

Returns the singleton scheduler for the current process. Creates it on first call.

reset_scheduler()

Tears down the scheduler (releases VRAM leases, cancels pending tasks). Called during FastAPI lifespan teardown.

# In FastAPI lifespan
from circuitforge_core.tasks import get_scheduler, reset_scheduler

@asynccontextmanager
async def lifespan(app: FastAPI):
    scheduler = get_scheduler()
    yield
    reset_scheduler()

scheduler.submit(task_type, payload, vram_gb) -> str

Enqueues a task. Returns the task ID. The scheduler acquires a VRAM lease from the coordinator before executing.

task_id = await scheduler.submit(
    task_type="recipe_llm",
    payload={"pantry_ids": [1, 2, 3]},
    vram_gb=4.0,
)

scheduler.result(task_id) -> TaskResult | None

Polls for a completed result. Returns None if still running.

VRAM budgets

Each product defines its VRAM budgets in compose.yml / compose.override.yml:

environment:
  VRAM_BUDGET_RECIPE_LLM: "4.0"
  VRAM_BUDGET_EXPIRY_LLM: "2.0"

These map to task types in the scheduler. If the coordinator is unavailable (local dev without cf-orch), the scheduler falls back to sequential local execution.

Shim pattern

Products that need to re-export scheduler functions for backward compatibility use a shim:

# myproduct/app/tasks/scheduler.py
from circuitforge_core.tasks.scheduler import (
    get_scheduler as _base_get_scheduler,
    reset_scheduler,          # re-export for lifespan teardown
)

def get_scheduler():
    """Product-specific scheduler with service name injected."""
    return _base_get_scheduler(service_name="myproduct")

Always re-export reset_scheduler from the shim so the FastAPI lifespan can import it from one place.

Generic caller/args dispatch

Separate from the VRAM-budgeted scheduler above: dispatch_task()/get_task_status() are a generic, product-agnostic pair for running a named callable in the background and polling its status, keyed by a "product/task_name" string rather than the scheduler's task_id/job_id/SQLite-table shape.

from circuitforge_core.tasks import register_task_runner, dispatch_task, get_task_status

# Once, at product startup:
register_task_runner("pagepiper/ingest_pdf", run_ingest_pdf)

# Anywhere a task needs dispatching:
task_id = dispatch_task("pagepiper/ingest_pdf", {"doc_id": "...", "file_path": "..."})
get_task_status(task_id)  # {"status": "running", "progress": 0, "error": None}

Free-tier, in-process, single-node — runs on a background thread pool, no cross-node distribution. dispatch_task() raises LookupError for an unregistered caller, so products with an except Exception: ... fallback to local execution keep working unchanged if they forget to register a runner.

register_task_runner/dispatch_task/get_task_status route through circuitforge_core.tasks.dispatch internally — nothing shared with scheduler.py's VRAM-aware queue.