← Telemachus

Design — pull-executors

Pull-model executors: inference hosts that come to the work

Decided 15 Sep 2026 (PULL‑1…8 confirmed). Built as slice 66: domain/exec/pull.rkt, migration 0028-executors, the worker protocol under /api/workers/*, cli/telemachus-worker.rkt, test/pull-tests.rkt and test/pull-smoke.sh (a chat routed through a real worker against the scripted model). Requested by a downstream project (issue #15) and the shape TEN‑2e has been waiting for. See As built at the end.

What exists, and where it stops

Layer Today
Executors domain/exec/federation.rkt: a registry of push endpoints — a name, an OpenAI-compatible URL, a model, a key. The server calls them. Instance-scoped, loaded from TELEMACHUS_EXECUTORS at boot (SCHED‑3: transport deferred).
The scheduler domain/sched/scheduler.rkt: a durable jobs table, an atomic in-process claim under a lock, a bounded worker pool, per-team concurrency caps, quota admission at claim, cancel-not-preempt (SCHED‑7). Every workflow step is a job.
Model calls run-chat picks a backend by name and POSTs to it. A tool that needs a model calls run-chat (or the current-doc-chat seam) synchronously inside its job.
Credentials API tokens with scopes, now expiring; S3 keys with scopes. Nothing identifies a machine that runs inference.

So a GPU box behind a NAT, a laptop on a tailnet, a spot instance that comes and goes, or a company's own inference host (TEN‑2e) cannot participate: the server has to be able to reach it. The ask is the inversion — a host that reaches the server, claims work it is capable of, runs it, and posts the result — without a second queue, a second credential model, or a second place jobs live.

The shape: a worker is a client of the jobs table

Nothing new is invented above the scheduler. A pull host is a worker that claims jobs over HTTP exactly as an in-process worker thread claims them from the database: same atomic claim, same per-team cap, same quota admission, same cancel semantics. What changes is where the handler runs and that a job now carries a lease.

in-process today:   claim-next! ──▶ run-claimed! (handler in this process) ──▶ done/error
pull host:          POST /api/workers/claim ──▶ (the host runs it) ──▶ POST /api/workers/jobs/<id>/complete|fail
                                          └─ heartbeat, or the lease expires and the job is re-queued

What a worker looks like

A worker is a loop any language can write; the reference one is cli/telemachus-worker.rkt, and it is short on purpose:

loop:
  job = POST /api/workers/claim {kinds, models, max_wait: 20}     # long-poll; 204 when nothing
  if job:
     thread heartbeat every lease/3:  POST /api/workers/jobs/<id>/heartbeat
     try:    result = run(job.kind, job.payload)                # e.g. call the local ollama
             POST /api/workers/jobs/<id>/complete {result}
     except: POST /api/workers/jobs/<id>/fail {error}

The Bearer is the worker token. Every one of those four endpoints is a declared route with auth: bearer, perm: jobs:execute, and appears in api.md.

Authorization

Data shapes (backend-neutral)

executors  + mode        text not null default 'push'     -- push | pull
           + org_id      text null                        -- TEN-2e: null = instance-wide
           + token_id    text null                        -- the worker token (pull only)
           + capabilities text not null default '{}'      -- {kinds:[…], models:[…]}
           + last_seen_at, status                         -- health from heartbeats
jobs       + executor_id  text null                       -- who holds it
           + lease_until  bigint null                     -- epoch seconds; null for in-process
           + attempt      integer not null default 1
           + requirements text not null default '{}'      -- {model?, kind}

The executor registry moves from a boot-time config file into a table (the push entries from TELEMACHUS_EXECUTORS are imported on boot, as plugin workflows are materialized). Nothing else changes shape.

Contract (any backend implements)

Executors:
  create(principal, {name, mode, org_id?, model?, url?, capabilities}) -> {executor, worker_token?}
  list(principal) -> [executor…]              # keys never shown
  retire(principal, id)                       # revokes the worker token

Workers (bearer = worker token, perm jobs:execute):
  claim({kinds, models, max_wait}) -> job | none          # atomic; leases; per-team cap + quota apply
  heartbeat(job_id) -> lease_until
  complete(job_id, result) -> ok | 409 not-lease-holder
  fail(job_id, error) -> ok | 409

Scheduler:
  register-job-kind! kind handler #:remote? #:validate    # a remote kind has no in-process handler
  reap-leases!                                             # expired -> queued (attempt+1) or error

Model routing:
  run-chat #:executor <pull executor> -> enqueue infer.chat, wait (bounded), return (values reply tokens)

Bootstrapping plan

  1. Migration: the four jobs columns and the executors table; import TELEMACHUS_EXECUTORS on boot. jobs:execute in the catalog, described.
  2. Worker endpoints + leases + the reaper (a scheduler tick). Unit tests: claim is atomic across two workers, an expired lease re-queues, a stale complete is 409, a job with a model requirement waits for a capable worker, an org-bound executor never sees another org's job.
  3. infer.chat as the first remote kind; run-chat #:executor routing to a pull executor by enqueue-and-wait. Smoke: cli/telemachus-worker.rkt against the scripted mock model, a chat routed through it end to end.
  4. TEN‑2e: POST /api/org/executors for an org admin; the org gate on offers.
  5. Admin › Compute lists pull executors with health; a worker that stops heartbeating goes stale.

As built

Decisions (confirmed 15 Sep 2026)

# Decision Recommendation · alternatives Why it matters
PULL‑1 Where pull work lives The existing jobs table, claimed over HTTP · vs a separate work queue One queue means one cap, one quota, one cancel, one audit.
PULL‑2 Worker identity An API token with the single scope jobs:execute, bound to an executor row · vs a new credential type · vs mTLS Scopes, expiry and revocation already exist; a token is the natural key.
PULL‑3 Lost workers Leases with heartbeats; expiry re-queues with a bounded attempt count · vs run-once-and-fail · vs no lease A host that vanishes must not lose the job and must not double-run it.
PULL‑4 Placement Capability match (kinds, models) at claim; no requirement = anywhere · vs named routing only SCHED‑2 finally gets a consumer; a job that needs a model waits for one.
PULL‑5 Routing a synchronous model call Enqueue infer.chat and wait, bounded by the caller's lease · vs make every model-using tool asynchronous Every existing tool keeps working; the wait is inside a job that is already admitted.
PULL‑6 Per-org executors (TEN‑2e) executors.org_id, offered only to that org's teams; null = instance-wide · vs instance-wide only A company's own inference host serves only that company — the org gate, again.
PULL‑7 Transport Plain HTTPS, long-poll claim, JSON · vs WebSocket · vs a message bus Works through NAT and a tailnet; nothing to deploy beside the server (SCHED‑3 stays deferred for anything richer).
PULL‑8 Token formats for federation Opaque worker tokens now; revisit PASETO only if a host must be verifiable without the database · vs signed tokens now The server checks the database on every call anyway; signed tokens buy nothing until there is a second verifier.