RFC-0016 — Horizontal & Concurrent Execution
Status: Core implemented & verified live (epic #188; 8-concurrent Job-per-task proof + KEDA 1→3 scale proven 2026-06-21; remaining default flips + multi-node workspace consumption tracked in RFC-0017) · Created: 2026-06-20 · Updated: 2026-06-21 · Extends: RFC-0005 (per-task ephemeral sandbox / k8s Job — Tier A proven), RFC-0002 (contract is the job unit), RFC-0006 (VAL), RFC-0014 (cost/budget → concurrency budget) · Affects: PFactory, AIFactory, TFactory (execution model), Factory (shared-state + Job-dispatch conventions), platform (Postgres, object store, Redis/KEDA)
1. Motivation
The fleet is single-instance by construction. Live cluster + code audit
(2026-06-20) confirm: every service runs 1 replica, HPA disabled, no queue
or worker tier, an in-memory task/session store, and a ReadWriteOnce
local-path PVC that cannot mount on a second pod. Deploys use
maxSurge:0/maxUnavailable:1 — a brief full outage that also kills in-flight
jobs.
An enterprise needs 5–10+ concurrent tasks (one team can submit that many at
once). Today that is impossible to do safely. This RFC defines how PFactory,
AIFactory, and TFactory scale to many concurrent jobs with isolation, backpressure,
and control-plane high-availability — reusing the RFC-0005 per-task k8s-Job
substrate already proven (and TFactory’s existing kube_sandbox.py).
2. Current state (audited)
| Execution today | Concurrent in one pod | Primary blocker | |
|---|---|---|---|
| PFactory | process() runs synchronously inside the async handler, single uvicorn process |
~1 (blocks the event loop, incl. /health) |
sync-in-async single process + in-memory _sessions singleton |
| AIFactory | OS subprocess per build (run.py) |
unbounded (uncapped) | MAX_CONCURRENT_TASKS is dead code; shared base-repo .git (no cross-process lock); one shared Claude token |
| TFactory | OS subprocess per task | unbounded (uncapped) | shared host resources: fixed ports, fixed container name tfactory-run-{target}, compose without per-task namespace; no backpressure |
Two assets to build on: (1) AIFactory + TFactory already isolate execution as a
subprocess per task — the hard part; (2) per-job data is largely isolated
(unique mkdtemp, per-spec workspaces, anonymous --rm containers). The blockers
are shared singletons, shared host resources, admission control, and the single
RWO/1-replica deployment — not deep rewrites.
3. Principles
- Stateless control plane, isolated execution. The web-server accepts requests and serves status from shared storage; the actual job runs in an isolated unit (a k8s Job pod), never in the request’s event loop.
- One job = one isolated unit. Each task gets its own CPU/mem, network, and
fresh workspace — no shared ports, container names, or
.gitindex. - Shared state is the enabler. No service scales past one pod until per-pod in-memory state moves to a shared store (Postgres) and artifacts move off RWO local-path to object storage / RWX.
- Backpressure, not oversubscription. A global admission cap + per-task resource requests; the scheduler bin-packs across nodes; a queue absorbs bursts. Never silently oversubscribe a pod into OOM.
- Reuse, don’t reinvent. Build on RFC-0005’s k8s-Job sandbox and TFactory’s
kube_sandbox.py; fold concurrency cost into RFC-0014’s budget.
4. Target architecture
flowchart TD
U["client / fleet (5-10+ tasks)"] --> CP["control plane (stateless web-server, N replicas)"]
CP -->|"persist job + status"| PG[("Postgres (shared state)")]
CP -->|"enqueue"| Q[["Redis queue"]]
Q --> KEDA["KEDA: scale dispatch on queue depth"]
KEDA --> J1["k8s Job: task A (run.py)"]
KEDA --> J2["k8s Job: task B"]
KEDA --> J3["k8s Job: task C (... N)"]
J1 --> OBJ[("object store (S3/MinIO): workspaces + artifacts")]
J2 --> OBJ
J3 --> OBJ
J1 -->|"status + verdict"| PG
J2 --> PG
J3 --> PG
CP -->|"read status/verdict"| PG
- Control plane (per service): a thin, horizontally-scalable Deployment. Accepts API calls, writes the job + status to Postgres, enqueues work. Serves status/results by reading shared state — never blocks on execution. Multiple replicas safe because no in-memory job state.
- Execution = k8s Job per task. A dispatcher creates one Job per contract;
the Job pod runs the existing
run.py(AIFactory/TFactory) or PFactoryprocess(), gets its own resources + network (no port/name/.gitcollisions), clones fresh, writes results to Postgres + object store, and exits. Reuses RFC-0005 substrate + the livekube_sandbox(AIFactorycore/kube_sandbox.py, TFactorytools/runners/kube_sandbox.py). The Job image is the thin nix-base image, not a toolchain-bundled image — see §4.1. - State: Postgres for job/session/status (replaces
_sessions/running_tasksdicts and the SQLite/emptyDir stores); object storage (S3/MinIO) or an RWX volume for workspaces + artifacts (replaces RWO local-path). - Autoscaling: Redis queue + KEDA scales Job dispatch on queue depth; a global concurrency cap and per-task resource requests govern fan-out.
4.1 Job pods are Nix-provisioned per task (RFC-0005 Tier A) — NORMATIVE
Phase-2 Job pods MUST get their toolchain from Nix per task, not from a toolchain-bundled image. This converges RFC-0016 Phase 2 with RFC-0005 Tier A — they are the same Job substrate, so we build it once. The mechanism already exists, is wired default-off, and is proven live in TFactory (2026-06-17); this RFC makes it the Phase-2 default rather than re-bundling toolchains.
Mechanism (the chosen path, not a proposal):
- Thin nix-base Job image. The Job runs
tfactory-runner-nix(FROM nixos/nix+ flakes +filter-syscalls = false, nothing else — noapk add go/rust/maven/jdk). The control-plane image shrinks to no bundled toolchains; every toolchain comes from the per-task flake. - Per-task flake from the contract.
nix_provisioner.generate_flake()derives aflake.nix(pinned to a full nixpkgs commit rev, never a branch) purely from the contract’s RFC-0005environmentblock, written into the co-mounted worktree. Build and verify share the same generatedflake.lock, so the build env and the verify env cannot drift. - Materialize at Job start. The Job executes the task’s build/verify commands
via
nix develop path:/work#default --command …(thepath:ref is mandatory — a bare ref triggers Nix’s git fetcher and breaks on the Job-root vs worktree-uid mismatch). Toolchains realise from substituters; only those tools land onPATH. - Warm shared
/nix/store(the one real gap to close). Today each Job’s/nix/storeis ephemeral, so every Job cold-fetches the closure. Phase 2 MUST mount a warm RWX/nix/store(+/nix/var) PVC across Jobs, backed bycache.nixos.org+ a fleet cachix substituter, with a size-cappednix store gcCronJob. This is what makes “second run is instant” real. - Materialize-or-HALT. Run
environment.proof.verify(cargo --version, …) first; a missing toolchain surfaces as a structuredenvironment_unavailableHALT (RFC-0005 §3.4 / RFC-0001a), never a silent fail.
Why this is the design law: it (a) kills the Trivy-CVE-on-fat-base problem —
the apk add toolchain block + its CVE-patch lines leave the control-plane image
entirely; (b) ends missing-toolchain build failures and UnsupportedLanguageError
dead-ends — any declared toolchain materializes on demand; (c) guarantees
build/verify reproducibility via one pinned flake; (d) GCs cleanly. Fallbacks
remain (RFC-0005 Tier B catalog image → Tier C on-demand build → Tier D
setup-script) behind the same factory-sandbox interface for the long tail.
5. Per-service plan
- PFactory (furthest behind): (a) move
process()off the event loop — immediately viaasyncio.to_thread/process pool + admission cap; ultimately a Job. (b) Externalize_sessions→ Postgres soingest/process/emitcan hit any replica. (c) Note: default planning is deterministic (no LLM) and light, so PFactory may run a thread/process-pool worker model rather than full Job-per-task if Jobs prove heavy for sub-second planning. - AIFactory (closest): (a) wire the dead
MAX_CONCURRENT_TASKSinto real admission control. (b) Eliminate shared base-repo.gitcontention — clone or worktree-per-Job in an isolated checkout, or a cross-process git lock. (c) Claude token pool so concurrent builds don’t collide on one OAuth token / rate limit. (d)running_tasks→ Postgres. (e)run.pyas a Nix-provisioned k8s Job (§4.1) — live-validate the already-wirednixjobpath, then shrink the coder Dockerfile to the thin control-plane image. - TFactory: (a) dynamic/auto-free ports (the
KubernetesRuntime local_port=0path already exists — make it the default) + per-task compose project namespace + per-task runtime container names (replace fixedtfactory-run-{target}). (b) Admission control + per-task resource requests so N test containers don’t exhaust the pod. (c)run.pyas a Nix-provisioned k8s Job (§4.1) — thenixjobpath is already proven live here (2026-06-17); make it the default. (d) State → Postgres (replace SQLite emptyDir). This also mitigates #464: resource starvation under naive in-pod concurrency is a cause of lanes never reaching a verdict.
6. Phasing
- Phase 1 — Shared state + collision fixes + admission control. Unblocks ~5
concurrent now without the full Job model: Postgres-backed state, object/RWX
artifacts, admission caps, PFactory event-loop offload, AIFactory
.git/token fixes, TFactory ports/namespacing. - Phase 2 — Control/execution split → Nix-provisioned Job-per-task. Dispatcher
- RBAC + result-callback convention;
run.py/process()wrapped as k8s Jobs on the thin nix-base image with per-tasknix develop(§4.1, RFC-0005 Tier A — reusekube_sandbox+nix_provisioner). Control plane scales to N replicas; deploys stop killing running jobs. Concrete enabling work (mostly consolidate + flip-on, since the path is wired default-off and live-proven in TFactory): publishtfactory-runner-nixto ghcr + add the fleet cachix substituter; provision the sandbox ServiceAccount/Role + a warm RWX/nix/storePVC in gitops; live-validate the AIFactorynixjobpath (flipAIFACTORY_SANDBOX_GATES=1 / BACKEND=nixjob); then shrink the coder Dockerfile to the thin control-plane image (drop the six bundled toolchains + CVE patches) and makenixjobthe default backend.
- RBAC + result-callback convention;
- Phase 3 — Autoscaling + concurrency budget. Redis + KEDA scale on queue depth; global cap + per-task resources; concurrency cost folded into RFC-0014.
7. Verification
- Concurrency proof: submit 5–10 tasks simultaneously across the fleet; assert
each runs in its own Job pod, no port/container/
.gitcollisions, all reach a terminal state, and control-plane/healthstays responsive throughout. - HA proof: roll the control-plane Deployment mid-run; in-flight Jobs survive (detached) and results still land in Postgres/object store.
- Backpressure proof: submit beyond the cap; excess queues (no OOM), KEDA scales out, queue drains.
- Nix-per-task proof (§4.1): a task declaring a non-default toolchain (e.g. Go,
Rust) builds + verifies green in a thin nix-base Job with NO toolchain baked into
the image; the control-plane image carries no
apk-added toolchains; a warm/nix/storemakes the second same-toolchain Job materially faster than the first; a missing/undeclared toolchain HALTs withenvironment_unavailable(never a silent fail).
8. Adoption (tracked by the epic)
Factory: shared-state + Job-dispatch conventions, object-store interface, RBAC,
concurrency-budget tie-in. PFactory/AIFactory/TFactory: state externalization,
collision fixes, admission control, run.py/process() as Jobs. Platform:
Postgres, object storage, Redis, KEDA. Plus the concurrency/HA/backpressure E2E
proofs.