Heartbeat · Jobs
The three-tier job substrate — how heavy work runs without blocking a page, survives restarts, and grades its own outcomes.
---
.. toc::
Rows, not threads
The founding rule of the substrate: *long work runs in another process, observed by reading rows — never thread-locals.* A job is a row with a status machine:
`` pending → running → done | error | cancelled ↘ cancelling ↗ ``
Three tiers:
1. Tier 1 — a thin callback (a "Run now" button) writes one pending row and returns the job id instantly. No heavy work in the web request. 2. Tier 2 — the dispatcher (in the pulse worker) atomically claims the oldest pending row (SELECT … FOR UPDATE SKIP LOCKED on Postgres) and runs its registered body, at most three concurrently. 3. Tier 3 — the body itself, wrapped in hb.task(...) so its lifecycle lands on the heartbeat, beating progress rows as it goes.
Because state lives in rows, a restart is recoverable: boot-time orphan recovery fails anything a dead process left running, and a periodic reaper closes rows whose worker went silent.
Outcome grading
A finished job gets a severity grade from its status, its reason, and its result shape — rendered identically in the cockpit, on /activity/<job id>, and in the recent feed:
.. exec::docs.heartbeat.jobs_examples
Cancellation is cooperative and cross-process
A cancel request flips the row to cancelling; the running body observes it at its own boundaries and finishes as cancelled. The request travels through the store — never an in-memory flag — so a click in the web container stops work in the worker container.
```python
File: lib/pulse/jobs.py
"""The heavy-work job substrate — JSONL-first, PG-mirrored.
Ported from the SailsBaord heartbeat substrate (services/jobs.py). The pattern: *long work running in another process, observed from the web container by reading rows, not thread-locals.*
A user/owner action that takes more than a moment does not block the page. The three tiers:
* Tier-1 — a thin Dash callback validates input, calls :func:create_job (one `pending row), and returns the job_id immediately. No heavy work in the callback. * Tier-2 — the dispatcher in the pulse-worker (lib/pulse/worker) :func:claim_next s the oldest pending row and runs Tier-3. The web container never runs job bodies. * Tier-3 — the work, registered per-kind via :func:register_runner (filled by lib.pulse.network_jobs), wrapped in hb.task(...)` so activity flows to the heartbeat.
Because state lives in rows (not an in-process set), a web or worker restart is recoverable — see :func:recover_orphans.
Depends on `$PULSE_DATA_DIR/jobs/ JSONL files (source of truth, survives a DB outage) and lib.db (optional Postgres mirror). When DATABASE_URL` is unset everything runs off JSONL and the PG paths no-op — dev and prod share one code path. No Dash imports. """ from __future__ import annotations
import json import logging import os import threading import time import uuid from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Callable
logger = logging.getLogger(__name__)
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
def _data_root() -> Path: return Path(os.getenv("PULSE_DATA_DIR") or (PROJECT_ROOT / "data"))
JOBS_DIR = _data_root() / "jobs" JOBS_PATH = JOBS_DIR / "jobs.jsonl" PROGRESS_PATH = JOBS_DIR / "progress.jsonl"
Terminal states never transition. `cancelling` is the transient
"a cancel was requested; the body will observe it and stop" — it
lives here, deliberately NOT on the heartbeat phase enum (which is
idle|working|error). Same vocabulary, separate scope.
ACTIVE_STATES = ("pending", "running", "cancelling") TERMINAL_STATES = ("done", "error", "cancelled")
Kinds whose row is created + lifecycled by an in-web-process worker,
NOT by the dispatcher. Empty on the hub today — every network job is
dispatcher-run. The mechanism stays so a future UI-driven batch can
opt out of dispatch without re-porting the substrate.
UI_OWNED_KINDS: frozenset[str] = frozenset()
_write_lock = threading.Lock()
kind → Tier-3 runner. Filled by lib.pulse.network_jobs.register_runners;
empty here so the data layer ships inert.
_RUNNERS: dict[str, Callable[[str, dict], None]] = {}
def register_runner(kind: str, fn: Callable[[str, dict], None]) -> None: """Register the Tier-3 body for `kind`.
`fn(job_id, params) runs inside the dispatcher (pulse-worker), is expected to wrap its work in hb.task(...) and call :func:progress / :func:finish_job`. Re-registering replaces. """ _RUNNERS[kind] = fn
def runner_for(kind: str) -> Callable[[str, dict], None] | None: return _RUNNERS.get(kind)
---------------------------------------------------------------------
Storage helpers
---------------------------------------------------------------------
def _ensure_dir() -> None: JOBS_DIR.mkdir(parents=True, exist_ok=True)
def _now() -> datetime: return datetime.now(timezone.utc)
def _now_iso() -> str: return _now().isoformat()
def _pg_on() -> bool: try: from lib.db import is_configured
return bool(is_configured()) except Exception: return False
def _append_jsonl(path: Path, record: dict) -> None: """Append one line. JSONL is the source of truth — it runs unconditionally and first, exactly like `heartbeat._append_event`. """ _ensure_dir() line = json.dumps(record, separators=(",", ":"), default=str) + "\n" with _write_lock: with path.open("a", encoding="utf-8") as fh: fh.write(line)
def _read_jsonl(path: Path) -> list[dict]: if not path.exists(): return [] out: list[dict] = [] with path.open("r", encoding="utf-8") as fh: for line in fh: line = line.strip() if not line: continue try: out.append(json.loads(line)) except json.JSONDecodeError: continue return out
def _jsonl_state() -> dict[str, dict]: """Reconstruct latest-per-id from the append-only log.
Low-volume by nature, so a full scan is fine — and it's only the fallback path; PG-configured deploys never hit it for reads. """ state: dict[str, dict] = {} for rec in _read_jsonl(JOBS_PATH): jid = rec.get("id") if jid: state[jid] = rec return state
---------------------------------------------------------------------
Single-job-per-target dedupe
#
A double-submit (duplicate callback registration, impatient
double-click, retry) must not spawn two rows for one action. The
identity of a job is its target: two active jobs for the same
(kind, params["slug"]) are the same job. Kinds without a slug param
dedupe per-kind via an explicit dedupe key of None → no dedupe.
---------------------------------------------------------------------
def _job_dedupe_key(j: dict) -> str | None: slug = (j.get("params") or {}).get("slug") kind = j.get("kind") if not slug or not kind: return None return f"{kind}:{slug}"
def _active_jobs() -> list[dict]: """Every job currently in an ACTIVE state, store-of-record first.""" rows: list[dict] if _pg_on(): try: from lib.db import repos
rows = repos.jobs_for_actor(clerk_id=None, limit=200) except Exception: logger.debug("_active_jobs PG failed; JSONL", exc_info=True) rows = list(_jsonl_state().values()) else: rows = list(_jsonl_state().values()) return [r for r in rows if r.get("status") in ACTIVE_STATES]
def find_active_peer( kind: str | None, slug: str | None, *, exclude_id: str | None = None, ) -> dict | None: """The earliest still-active job targeting the same (kind, slug).
`exclude_id drops the asking job from the set. Earliest by (created_at, id)` so every caller independently agrees on which single job is the winner without a lock. """ if not kind or not slug: return None key = f"{kind}:{slug}" peers = [ j for j in _active_jobs() if _job_dedupe_key(j) == key and j.get("id") != exclude_id ] if not peers: return None peers.sort(key=lambda j: (j.get("created_at") or "", j.get("id") or "")) return peers[0]
def superseded_by(job_id: str) -> str | None: """If another active job targets this job's (kind, slug) and was created first, return that winner's id — this job should yield. """ cur = get_job(job_id) if cur is None: return None kind = cur.get("kind") slug = (cur.get("params") or {}).get("slug") winner = find_active_peer(kind, slug) # includes self if winner is not None and winner.get("id") != job_id: return winner.get("id") return None
---------------------------------------------------------------------
Write path (JSONL always; PG best-effort)
---------------------------------------------------------------------
def _persist(job: dict) -> None: """JSONL append (source of truth) + best-effort PG upsert.""" _append_jsonl(JOBS_PATH, job) if not _pg_on(): return try: from lib.db import repos
if repos.get_job_row(job["id"]) is None: repos.insert_job(job) else: repos.update_job(job["id"], **{ k: v for k, v in job.items() if k != "id" }) except Exception: # A PG glitch must never break the job write path — the JSONL # line already landed and the dispatcher/readers fall back to # it. Mirrors the heartbeat's dual-write posture. logger.debug("job PG mirror failed (id=%s)", job.get("id"), exc_info=True)
def create_job( *, kind: str, actor: str = "system", actor_clerk_id: str | None = None, actor_handle: str | None = None, title: str | None = None, params: dict[str, Any] | None = None, dedupe: bool = False, ) -> str: """Create a `pending` job and return its id immediately.
The id is an app-generated uuid so the Tier-1 callback can return it and start an indicator even before the best-effort PG insert lands.
`dedupe=True collapses a re-submit: if an active job already targets the same (kind, params["slug"])` its id is returned and no second row is written. """ if dedupe: slug = (params or {}).get("slug") existing = find_active_peer(kind, slug) if existing is not None: logger.info( "create_job deduped: active %s already targets %s:%s " "(returning %s, no new row)", existing.get("id"), kind, slug, existing.get("id"), ) return existing["id"]
job_id = uuid.uuid4().hex job = { "id": job_id, "kind": kind, "status": "pending", "actor": actor or "system", "actor_clerk_id": actor_clerk_id, "actor_handle": actor_handle, "title": title or kind.replace("_", " ").title(), "created_at": _now_iso(), "started_at": None, "ended_at": None, "task_id": None, "cost_usd": None, "progress_pct": 0, "message": "Queued", "reason": None, "params": params or {}, "result": None, "extras": None, } _persist(job) logger.info("job created id=%s kind=%s actor=%s", job_id, kind, actor) return job_id
def _patch(job_id: str, **fields: Any) -> dict | None: cur = get_job(job_id) if cur is None: return None cur.update(fields) _persist(cur) return cur
def start_job(job_id: str, *, task_id: str | None = None) -> dict | None: """pending → running. Called by the dispatcher right before Tier-3. (`claim_next` already does this atomically in PG; this is the JSONL-dev equivalent / a no-op resync.)""" return _patch( job_id, status="running", started_at=_now_iso(), task_id=task_id, message="Running", progress_pct=5, )
def progress( job_id: str, *, pct: int | None = None, message: str | None = None, ) -> None: """Append a progress beat + denormalise latest onto the job row.""" _append_jsonl(PROGRESS_PATH, { "job_id": job_id, "ts": _now_iso(), "pct": pct, "message": message, }) if _pg_on(): try: from lib.db import repos
repos.append_job_progress( job_id, pct=pct, message=message, ts=_now_iso(), ) except Exception: logger.debug("job progress PG mirror failed", exc_info=True) # Stamp a beat timestamp on EVERY progress call — the staleness reaper # measures from max(progress_at, started_at, created_at), so a job that # beats keeps itself alive. fields: dict[str, Any] = {"progress_at": _now_iso()} if pct is not None: fields["progress_pct"] = pct if message: fields["message"] = message[:256] _patch(job_id, **fields)
def finish_job( job_id: str, *, status: str = "done", cost_usd: float | None = None, result: dict | None = None, reason: str | None = None, ) -> dict | None: """running → done | error. Denormalises final cost onto the row.""" if status not in ("done", "error", "cancelled"): status = "done" return _patch( job_id, status=status, ended_at=_now_iso(), cost_usd=cost_usd, result=result, reason=reason, progress_pct=100 if status == "done" else None, message={"done": "Complete", "error": "Failed", "cancelled": "Cancelled"}.get(status, status), )
def request_cancel(job_id: str) -> bool: """Cancel a job. Returns True if the request was accepted.
Two cases, because a job cancelled *before* it starts has no body to cooperatively stop:
* `pending → straight to cancelled (terminal). It never ran; just drop it. claim_next will skip it (not pending). * running → cancelling (transient). The Tier-3 body observes :func:should_cancel at its boundaries and finishes via :func:mark_cancelled`.
Cross-process by design: the cancel is requested in the web container and observed in the worker, so it MUST travel through the store (PG row / JSONL line), never an in-memory Event. """ cur = get_job(job_id) if cur is None: return False state = cur.get("status") if state == "pending": mark_cancelled(job_id, reason="cancelled before start") return True if state == "running": _patch(job_id, status="cancelling", message="Cancelling…") return True return False
def should_cancel(job_id: str) -> bool: cur = get_job(job_id) return bool(cur and cur.get("status") == "cancelling")
def mark_cancelled(job_id: str, *, reason: str | None = None) -> dict | None: return finish_job( job_id, status="cancelled", reason=reason or "cancelled by user", )
def attach_task(job_id: str, task_id: str | None) -> dict | None: """Stamp the heartbeat `task_id` onto the job row — the join key between a job and its heartbeat_events.""" if not task_id: return None return _patch(job_id, task_id=task_id)
---------------------------------------------------------------------
Dispatcher claim + crash recovery (Tier-2, called from the worker)
---------------------------------------------------------------------
def claim_next(*, kinds: list[str] | None = None) -> dict | None: """Atomically claim the oldest `pending job → running`.
PG path uses `SELECT … FOR UPDATE SKIP LOCKED so a second worker (or two dispatcher ticks) never double-runs a job. JSONL dev path is single-process and serialised by _write_lock`. """ if _pg_on(): try: from lib.db import repos
# Allowlist derived from the registered runners minus the # UI-owned set (empty on the hub today). allow = kinds if allow is None: allow = [k for k in _RUNNERS.keys() if k not in UI_OWNED_KINDS] claimed = repos.claim_pending_job(kinds=allow) if claimed is not None: # Mirror the claim into JSONL so the audit log + the # JSONL fallback agree with PG. _append_jsonl(JOBS_PATH, claimed) return claimed except Exception: logger.exception("claim_next PG failed; trying JSONL") with _write_lock: state = _jsonl_state() pend = sorted( (j for j in state.values() if j.get("status") == "pending" and (j.get("kind") not in UI_OWNED_KINDS) and (not kinds or j.get("kind") in kinds)), key=lambda j: j.get("created_at") or "", ) if not pend: return None job = pend[0] job["status"] = "running" job["started_at"] = _now_iso() with JOBS_PATH.open("a", encoding="utf-8") as fh: fh.write(json.dumps(job, separators=(",", ":"), default=str) + "\n") return job
def recover_orphans(reason: str = "orphaned by restart") -> list[dict]: """On worker boot: any job stuck `running/cancelling` from a dead process is failed. Jobs are rows; restart-safe. """ if _pg_on(): try: from lib.db import repos
recovered = repos.recover_orphan_jobs(reason=reason) for j in recovered: _append_jsonl(JOBS_PATH, j) return recovered except Exception: logger.exception("recover_orphans PG failed; trying JSONL") recovered = [] for j in _jsonl_state().values(): if j.get("status") in ("running", "cancelling"): j = dict(j) j["status"] = "error" j["reason"] = reason j["ended_at"] = _now_iso() _append_jsonl(JOBS_PATH, j) recovered.append(j) return recovered
Kinds whose Tier-3 body runs IN the web process and beats progress
frequently — a long silence means a dead worker, so the reaper may
close a stale `running` row of these kinds. Empty on the hub today;
dispatcher-run network jobs finish in seconds and are covered by the
stuck-`cancelling` rule.
REAPABLE_RUNNING_KINDS: tuple[str, ...] = ()
How long a non-terminal row may sit without a progress beat before the
periodic reaper force-finalizes it. Override via env.
REAP_AFTER_S = float(os.environ.get("JOBS_REAP_AFTER_S", "300"))
def reap_stale_jobs(older_than_s: float = REAP_AFTER_S) -> list[dict]: """Periodic safety net: close rows whose worker is gone but whose row never reached a terminal state (`recover_orphans only runs on dispatcher BOOT). Targeting is narrow: stuck cancelling of any kind, plus running of :data:REAPABLE_RUNNING_KINDS`. """ reason = f"reaped: no worker progress for over {int(older_than_s)}s" if _pg_on(): try: from lib.db import repos
reaped = repos.reap_stale_jobs( older_than_s=older_than_s, running_kinds=REAPABLE_RUNNING_KINDS, reason=reason, ) for j in reaped: _append_jsonl(JOBS_PATH, j) return reaped except Exception: logger.exception("reap_stale_jobs PG failed; trying JSONL") # JSONL fallback (dev / no DATABASE_URL): staleness from the LATEST # signal of life — a progress beat first, then job start, then creation. cutoff = datetime.now(timezone.utc) - timedelta(seconds=float(older_than_s)) reaped = [] for j in _jsonl_state().values(): st = j.get("status") reapable = st == "cancelling" or ( st == "running" and j.get("kind") in REAPABLE_RUNNING_KINDS ) if not reapable: continue ts_raw = (j.get("progress_at") or j.get("started_at") or j.get("created_at")) try: ts = datetime.fromisoformat(ts_raw) if ts_raw else None except (TypeError, ValueError): ts = None if ts is None: continue if ts.tzinfo is None: ts = ts.replace(tzinfo=timezone.utc) if ts < cutoff: j = dict(j) j["status"] = "cancelled" j["reason"] = reason j["ended_at"] = _now_iso() _append_jsonl(JOBS_PATH, j) reaped.append(j) return reaped
---------------------------------------------------------------------
Tier-2 dispatcher (runs in the pulse-worker — never the web box)
#
Ownership mirrors the heartbeat pulse exactly:
`HEARTBEAT_PULSE_WORKER=1` → defer to the sidecar (no-op here)
unless `force=True`. So the dispatcher runs in the single process
in dev and in the sidecar in prod — and the prod *web* container,
which defers the pulse, also defers the dispatcher. "The web
container never runs job bodies" falls out of the same env flag
that already governs the pulse, not a second mechanism.
---------------------------------------------------------------------
_dispatch_stop = threading.Event() _dispatch_thread: threading.Thread | None = None _active: set[str] = set() _active_lock = threading.Lock() MAX_CONCURRENT = max(1, int(os.environ.get("JOBS_MAX_CONCURRENT", "3")))
def _run_one(job: dict) -> None: jid, kind = job["id"], job.get("kind") try: fn = runner_for(kind) if fn is None: finish_job( jid, status="error", reason=f"no runner registered for kind={kind!r}", ) return fn(jid, job.get("params") or {}) # Safety net: a runner that returned without setting a terminal # state would leave the indicator spinning forever. cur = get_job(jid) if cur and cur.get("status") in ACTIVE_STATES: finish_job(jid, status="done") except Exception as exc: # noqa: BLE001 — never kill the dispatcher logger.exception("job runner crashed id=%s kind=%s", jid, kind) try: finish_job( jid, status="error", reason=f"{type(exc).__name__}: {exc}", ) except Exception: pass finally: with _active_lock: _active.discard(jid)
def _dispatch_loop(interval_s: int) -> None: # Boot recovery first: fail anything a dead process left in flight # so indicators resolve instead of hanging forever. try: orphans = recover_orphans() if orphans: logger.warning( "recovered %d orphaned job(s) on dispatcher boot", len(orphans), ) except Exception: logger.exception("orphan recovery failed at dispatcher boot")
_last_reap = time.monotonic() while not _dispatch_stop.is_set(): try: # Periodic stale-job reaper (every ~60s). The boot-time # recover_orphans above only catches what a *restart* left # behind; this catches what dies while the process stays up. if time.monotonic() - _last_reap >= 60: _last_reap = time.monotonic() try: reaped = reap_stale_jobs() if reaped: logger.warning( "reaped %d stale job(s): %s", len(reaped), ", ".join( f"{r.get('id', '')[:8]}({r.get('kind')})" for r in reaped ), ) except Exception: logger.exception("periodic stale-job reap failed")
with _active_lock: at_cap = len(_active) >= MAX_CONCURRENT if not at_cap: job = claim_next() if job is not None: with _active_lock: _active.add(job["id"]) threading.Thread( target=_run_one, args=(job,), name=f"job-{job.get('kind')}-{job['id'][:8]}", daemon=True, ).start() # Immediately try to claim more so a backlog drains # rather than trickling one per interval. continue except Exception: logger.exception("dispatch tick failed") if _dispatch_stop.wait(timeout=interval_s): return
def start_dispatcher( *, force: bool = False, interval_s: int = 3, ) -> threading.Thread | None: """Spawn the dispatcher thread (idempotent).
Defers (returns None) when `HEARTBEAT_PULSE_WORKER=1 and not force — same rule as heartbeat.start_daemon`. Lazily registers the Tier-3 runners so whatever process owns the dispatcher has them, with no caller having to remember the import. """ global _dispatch_thread if not force and os.environ.get("HEARTBEAT_PULSE_WORKER") == "1": return None if _dispatch_thread is not None and _dispatch_thread.is_alive(): return _dispatch_thread try: from lib.pulse import network_jobs
network_jobs.register_runners() except Exception: logger.exception("job runner registration failed") _dispatch_stop.clear() _dispatch_thread = threading.Thread( target=_dispatch_loop, args=(interval_s,), name="job-dispatcher", daemon=True, ) _dispatch_thread.start() logger.info("job dispatcher online (max_concurrent=%d)", MAX_CONCURRENT) return _dispatch_thread
def stop_dispatcher() -> None: _dispatch_stop.set()
def dispatcher_running() -> bool: t = _dispatch_thread return bool(t and t.is_alive() and not _dispatch_stop.is_set())
---------------------------------------------------------------------
Read path (PG preferred; JSONL fallback — dev == prod code path)
---------------------------------------------------------------------
def get_job(job_id: str) -> dict | None: if _pg_on(): try: from lib.db import repos
row = repos.get_job_row(job_id) if row is not None: return row except Exception: logger.debug("get_job PG failed; trying JSONL", exc_info=True) return _jsonl_state().get(job_id)
def get_job_progress(job_id: str, *, limit: int = 200) -> list[dict]: """The per-job progress trail, oldest → newest.
JSONL-first by design: `progress() appends to PROGRESS_PATH *unconditionally and first* (the source of truth, exactly like the heartbeat event log). Returns [] when there is no trail yet — never raises. Each row: {"job_id", "ts", "pct", "message"}`. """ out = [ r for r in _read_jsonl(PROGRESS_PATH) if r.get("job_id") == job_id ] out.sort(key=lambda r: r.get("ts") or "") return out[-limit:] if limit and len(out) > limit else out
def snapshot( *, clerk_id: str | None = None, active_limit: int = 20, recent_limit: int = 12, ) -> dict[str, Any]: """What is running, plus the recently-finished tail. `clerk_id=None` → all actors (the dashboard overview).
Multi-process safe: the web container reads job state from the store, mirroring `heartbeat.snapshot()`'s PG overlay — never a worker thread-local, because the worker's memory is in a different container. """ rows: list[dict] if _pg_on(): try: from lib.db import repos
rows = repos.jobs_for_actor(clerk_id=clerk_id, limit=80) except Exception: logger.debug("snapshot PG failed; JSONL", exc_info=True) rows = list(_jsonl_state().values()) else: rows = list(_jsonl_state().values())
if clerk_id and not _pg_on(): rows = [r for r in rows if r.get("actor_clerk_id") == clerk_id]
rows.sort(key=lambda r: r.get("created_at") or "", reverse=True) active = [r for r in rows if r.get("status") in ACTIVE_STATES][:active_limit] recent = [r for r in rows if r.get("status") in TERMINAL_STATES][:recent_limit] return { "active": active, "recent": recent, "active_count": len(active), "now": _now_iso(), } ```
:defaultExpanded: false :withExpandedButton: true
.. llms_copy::Heartbeat · Jobs
---
*Source: /docs/heartbeat-jobs*
Note for AI agents: This is the static, prerendered view of an interactive Dash application served because we detected a non-JS user agent. Full prose docs:
- /docs/heartbeat-jobs/llms.txt — LLM-friendly documentation
- /sitemap.xml
- /robots.txt