|
1 | | -# Serverless Harness (Pi track) |
| 1 | +# Serverless Harness |
2 | 2 |
|
3 | | -## Packages |
4 | | -- `packages/session-backend` — generic append-only `LogStore` + Redis Streams impl. |
5 | | -- `harness` — Pi `SessionStorageBackend` adapter (write-behind) + headless smoke. |
6 | | -- `pi-fork` — pinned Pi (base commit `406a2214`, branch `feat/session-storage-backend`) with the injectable `SessionStorageBackend` seam. |
| 3 | +**Run stateful AI coding agents serverless — scale to zero between turns, resume exactly where they left off.** |
7 | 4 |
|
8 | | -## Milestones |
| 5 | +-success) |
| 6 | + |
| 7 | + |
| 8 | + |
| 9 | + |
9 | 10 |
|
10 | | -- **M1 — Redis SessionStorageBackend** (`packages/session-backend`, `harness`): append-only |
11 | | - `LogStore` backed by Redis Streams; write-behind adapter wired into Pi's `SessionStorageBackend` |
12 | | - seam. Design: `docs/specs/2026-06-16-m1-redis-session-backend-design.md`. |
13 | | -- **M2 — K8sSandboxClient** (`packages/k8s-sandbox`): routes Pi tool execution to |
14 | | - a remote Kubernetes pod via `kubectl exec`. Env-gated (`KAGENTI_SANDBOX_POD`); |
15 | | - off by default. Design: `docs/specs/2026-06-17-m2-k8s-sandbox-client-design.md`. |
| 11 | +Serverless Harness turns a long-lived AI agent into a **scale-to-zero workload** on Kubernetes. |
| 12 | +An agent process normally has to stay resident — holding its conversation, tool state, and working |
| 13 | +directory in memory — even while it sits idle waiting for the next turn or for a human to approve a |
| 14 | +step. That idle time is pure cost. The harness decouples the agent's **state** (durable in Redis) |
| 15 | +and its **tool execution** (an isolated sandbox pod) from the **agent process** itself, so the agent |
| 16 | +runs as a Knative service that drops to zero pods when idle and cold-starts with full session |
| 17 | +continuity on the next request. |
| 18 | + |
| 19 | +The result is a **leaf-session backend**: an external orchestrator dispatches isolated units of agent |
| 20 | +work ("leaves") over a simple HTTP + shared-volume contract, and the harness runs each one |
| 21 | +sync, async (queued), scheduled, or paused-for-approval — all on infrastructure that costs nothing at |
| 22 | +rest. |
| 23 | + |
| 24 | +## Table of Contents |
| 25 | + |
| 26 | +- [Why](#why) |
| 27 | +- [Architecture](#architecture) |
| 28 | +- [Features](#features) |
| 29 | +- [Quick Start](#quick-start) |
| 30 | +- [How It Works](#how-it-works) |
| 31 | +- [Dispatch Archetypes](#dispatch-archetypes) |
| 32 | +- [Repository Layout](#repository-layout) |
| 33 | +- [Evidence](#evidence) |
| 34 | +- [Roadmap](#roadmap) |
| 35 | +- [Documentation](#documentation) |
| 36 | +- [Status & License](#status--license) |
| 37 | + |
| 38 | +--- |
| 39 | + |
| 40 | +## Why |
| 41 | + |
| 42 | +| Persistent agent | Serverless Harness | |
| 43 | +|------------------|--------------------| |
| 44 | +| Process stays resident between turns | Scales to **zero** when idle, cold-starts in sub-second | |
| 45 | +| State lives in process memory — lost on crash/evict | State lives in **Redis** — survives eviction, restart, and cold start | |
| 46 | +| Tools execute in the agent process | Tools execute in an **isolated sandbox pod** (brain/hands split) | |
| 47 | +| Idle compute billed continuously | **Only Redis + sandbox** stay resident (2 pods at rest) | |
| 48 | +| One invocation model | **Four**: sync, async fan-out, scheduled, human-gated | |
| 49 | + |
| 50 | +In an idle-heavy workload [experiment](deploy/knative/EXPERIMENTS.md), the serverless path consumed |
| 51 | +roughly **a quarter** of the pod-seconds of an equivalent always-on agent — because the expensive |
| 52 | +part (the agent process) exists only while a turn is actively running. |
| 53 | + |
| 54 | +--- |
| 55 | + |
| 56 | +## Architecture |
| 57 | + |
| 58 | +```mermaid |
| 59 | +flowchart LR |
| 60 | + O[External Orchestrator] -->|POST /run-leaf| K[Knative Service<br/>scale-to-zero] |
| 61 | + C[CronJob<br/>cron-dispatch] -->|schedule| K |
| 62 | + K -->|sync| R[runLeaf] |
| 63 | + K -->|async: true| Q[(Redis Streams<br/>queue + session state)] |
| 64 | + Q --> W[KEDA ScaledJob<br/>leaf-worker 0..N] |
| 65 | + W --> R |
| 66 | + R -->|kubectl exec| S[sandbox-0<br/>tool / code execution] |
| 67 | + R <-->|session state| Q |
| 68 | +``` |
| 69 | + |
| 70 | +| Component | Role | |
| 71 | +|-----------|------| |
| 72 | +| **Knative Service** | Scale-to-zero HTTP endpoint; runs a turn inline (sync) or enqueues it (async) | |
| 73 | +| **Redis** | Durable session state (resume by `sessionId`), work queue (Streams), gate state | |
| 74 | +| **KEDA ScaledJob** | Autoscales `leaf-worker` pods 0→N on queue depth (`lagCount` + `pendingEntriesCount`) | |
| 75 | +| **sandbox-0** | Persistent pod where all tool/code execution runs; reached via `kubectl exec` | |
| 76 | +| **Shared PVC** | Volume-envelope contract — inputs, results, and markers travel as files | |
| 77 | +| **CronJob** | Scheduled dispatch (`cron-dispatch`) for periodic batch work | |
| 78 | + |
| 79 | +> **Note:** The Knative Service and the `leaf-worker` are the **same container image** with two entry |
| 80 | +> points (`server.ts` vs `leaf-job.ts`). Both converge on `runLeaf()`, which routes execution into |
| 81 | +> `sandbox-0`. The "brain" (model inference + session logic) runs in whichever pod called `runLeaf()`; |
| 82 | +> the "hands" (actual command/tool execution) always run in the sandbox. |
| 83 | +
|
| 84 | +--- |
| 85 | + |
| 86 | +## Features |
| 87 | + |
| 88 | +- **Scale-to-zero turns** — Knative drops the agent to zero pods between turns; the activator |
| 89 | + cold-starts a fresh pod on the next request. |
| 90 | +- **Durable resume** — sessions are append-only logs in Redis; a cold-started pod recalls full |
| 91 | + conversation and state by `sessionId`, surviving pod eviction. |
| 92 | +- **Brain/hands isolation** — the agent never executes tools in its own process; everything runs in a |
| 93 | + separate hardened `sandbox-0` pod via a persistent in-pod channel. |
| 94 | +- **Four dispatch modes** — one `/run-leaf` endpoint serves sync, async-queued, cron-scheduled, and |
| 95 | + human-gated execution (see [Dispatch Archetypes](#dispatch-archetypes)). |
| 96 | +- **Human-in-the-loop gates** — a leaf can pause mid-run, report `awaiting_approval`, and resume on an |
| 97 | + external approve/reject/abort verdict — scaling to zero while it waits. |
| 98 | +- **Volume-envelope contract** — orchestrators pass inputs and collect results as files on a shared |
| 99 | + PVC, decoupling result size from HTTP limits. |
| 100 | +- **Hardened by default** — non-root UID, read-only root filesystem, all capabilities dropped, |
| 101 | + `RuntimeDefault` seccomp, no service-account token automount. |
| 102 | +- **Built on Pi** — wraps a pinned [`kagenti/pi`](https://github.com/kagenti/pi) coding agent through |
| 103 | + an injectable `SessionStorageBackend` seam; the agent itself is unmodified. |
| 104 | + |
| 105 | +--- |
| 106 | + |
| 107 | +## Quick Start |
| 108 | + |
| 109 | +Bring up the full stack on a local [Kind](https://kind.sigs.k8s.io/) cluster and drive an agent that |
| 110 | +scales to zero and resumes from cold. |
| 111 | + |
| 112 | +> **Prerequisites:** `kind`, `kubectl`, `docker`, and an Anthropic-compatible model credential. |
| 113 | +
|
| 114 | +```bash |
| 115 | +# 1. Clone (the Pi agent is a submodule) |
| 116 | +git clone --recurse-submodules https://github.com/kagenti/serverless-harness.git |
| 117 | +cd serverless-harness |
| 118 | + |
| 119 | +# 2. Provide a model credential — either a direct key... |
| 120 | +export ANTHROPIC_API_KEY=sk-... |
| 121 | +# ...or a Bearer-token gateway (e.g. LiteLLM): |
| 122 | +# export ANTHROPIC_BASE_URL=https://your-gateway |
| 123 | +# export ANTHROPIC_AUTH_TOKEN=... |
| 124 | + |
| 125 | +# 3. One-shot: create cluster, install Knative + Kourier, deploy Redis + sandbox + harness |
| 126 | +./deploy/knative/setup-kind.sh |
| 127 | +``` |
| 128 | + |
| 129 | +In a second terminal, expose the gateway and watch pods: |
| 130 | + |
| 131 | +```bash |
| 132 | +kubectl port-forward -n kourier-system svc/kourier 8080:80 # leave running |
| 133 | +watch -n5 'kubectl get pods' # in another pane |
| 134 | +``` |
| 135 | + |
| 136 | +**Send the first turn** — a pod cold-starts to handle it, then scales back to zero: |
| 137 | + |
| 138 | +```bash |
| 139 | +curl -s -H "Host: serverless-harness.default.example.com" \ |
| 140 | + -H "Content-Type: application/json" \ |
| 141 | + -d '{"prompt":"Remember the secret word: pineapple. Reply only with OK."}' \ |
| 142 | + http://localhost:8080/turn | jq . |
| 143 | +# => { "sessionId": "019ed8e8-...", "response": "OK" } |
| 144 | +``` |
| 145 | + |
| 146 | +**Resume across a cold start** — wait ~90s for the pod to terminate, then ask on the *same* session. |
| 147 | +A fresh pod spins up from zero and still remembers the state from Redis: |
| 148 | + |
| 149 | +```bash |
| 150 | +export SID="<sessionId from above>" |
| 151 | +curl -s -H "Host: serverless-harness.default.example.com" \ |
| 152 | + -H "Content-Type: application/json" \ |
| 153 | + -d "{\"sessionId\":\"$SID\",\"prompt\":\"What was the secret word?\"}" \ |
| 154 | + http://localhost:8080/turn | jq . |
| 155 | +# => response contains "pineapple" |
| 156 | +``` |
| 157 | + |
| 158 | +See [`serverless-harness-demo.md`](serverless-harness-demo.md) for the full guided walkthrough |
| 159 | +(including sandbox command execution) and [`deploy/knative/SMOKE.md`](deploy/knative/SMOKE.md) for the |
| 160 | +verified smoke-test claims. |
| 161 | + |
| 162 | +--- |
| 163 | + |
| 164 | +## How It Works |
| 165 | + |
| 166 | +1. **An orchestrator POSTs a leaf** to `/run-leaf` (or `/turn` for a single interactive turn). |
| 167 | +2. The **Knative Service** wakes from zero, and either runs the leaf inline (`sync`) or pushes the |
| 168 | + envelope onto **Redis Streams** and returns `202` (`async: true`), then idles back to zero. |
| 169 | +3. For async work, a **KEDA ScaledJob** scales `leaf-worker` pods up on queue depth and drains items. |
| 170 | +4. Both paths call **`runLeaf()`**, which executes all tools inside **`sandbox-0`** via `kubectl exec`. |
| 171 | +5. **Session state streams to Redis** as it goes, so the leaf is resumable by `sessionId` even if its |
| 172 | + pod dies mid-run. |
| 173 | +6. **Results land as files** on a shared PVC, and a **done-marker** signals completion to the |
| 174 | + orchestrator. |
| 175 | + |
| 176 | +--- |
| 177 | + |
| 178 | +## Dispatch Archetypes |
| 179 | + |
| 180 | +The same backend serves three orchestration patterns, all validated end-to-end on Kind: |
| 181 | + |
| 182 | +| Archetype | Pattern | Example use case | |
| 183 | +|-----------|---------|------------------| |
| 184 | +| **A — Async fan-out** | `{async:true}` → Redis Streams → KEDA scales workers 0→N → done-markers | "Research 10 topics concurrently" | |
| 185 | +| **B — Human gate** | Leaf pauses → `awaiting_approval` → external verdict → resume/terminate | "Draft a clause, pause for legal sign-off, finalize" | |
| 186 | +| **C — Scheduled** | CronJob → `cron-dispatch` reads a config list → posts each as async | "Summarize yesterday's tickets at 02:00 daily" | |
| 187 | + |
| 188 | +--- |
| 189 | + |
| 190 | +## Repository Layout |
| 191 | + |
| 192 | +```text |
| 193 | +serverless-harness/ |
| 194 | +├── packages/ |
| 195 | +│ ├── session-backend/ # Generic append-only LogStore + Redis Streams impl |
| 196 | +│ ├── k8s-sandbox/ # Routes Pi tool execution to a remote pod (kubectl exec) |
| 197 | +│ ├── knative-server/ # HTTP server (server.ts) + leaf-worker (leaf-job.ts) entry points |
| 198 | +│ └── work-queue/ # Redis Streams work queue (async dispatch) |
| 199 | +├── harness/ # Pi SessionStorageBackend adapter (write-behind) + headless smoke |
| 200 | +├── pi-fork/ # Pinned Pi coding agent (submodule) with the injectable backend seam |
| 201 | +├── deploy/knative/ # Kind setup, manifests, smoke + experiment drivers |
| 202 | +├── experiments/ # @sh/experiments — reproducible cost/behaviour experiments |
| 203 | +└── docs/specs/ # Design specs (per-milestone) + milestone registry |
| 204 | +``` |
| 205 | + |
| 206 | +--- |
| 207 | + |
| 208 | +## Evidence |
| 209 | + |
| 210 | +Behaviour and economics are backed by reproducible experiments rather than claims: |
| 211 | + |
| 212 | +- **[`deploy/knative/EXPERIMENTS.md`](deploy/knative/EXPERIMENTS.md)** — cluster experiments E1 |
| 213 | + (economics), E3 (mobility), E4 (recovery), run live on Kind. |
| 214 | +- **[`docs/experiment-results.md`](docs/experiment-results.md)** — E2 (reconstruction cost) and E5 |
| 215 | + (budget enforcement) from the `@sh/experiments` workspace. |
| 216 | +- **[`deploy/knative/SMOKE.md`](deploy/knative/SMOKE.md)** — the 6/6 cold-start + resume smoke claims. |
| 217 | + |
| 218 | +--- |
| 219 | + |
| 220 | +## Roadmap |
| 221 | + |
| 222 | +**Phase 1 — Decoupled Harness (built):** Redis session backend, remote sandbox client, persistent |
| 223 | +in-pod channel, Knative wrapper, compaction-checkpoint fast path, experiments, and the leaf-session |
| 224 | +backend with all three dispatch archetypes. See the |
| 225 | +[milestone registry](docs/specs/README.md) for the source-of-truth status of every milestone. |
| 226 | + |
| 227 | +**Phase 2 — Zero-Trust Credential Plane (design complete, deferred):** a credential plane where |
| 228 | +*no component influenced by model output ever holds a raw secret.* |
| 229 | + |
| 230 | +| ID | Adds | |
| 231 | +|----|------| |
| 232 | +| Z1 | Per-session SPIFFE identity (SPIRE) | |
| 233 | +| Z2 | Secret-free, default-deny harness lock-down | |
| 234 | +| Z3 | Inference injector — provider-key chokepoint, mTLS to the LLM gateway | |
| 235 | +| Z4 | MCP code-mode in the sandbox | |
| 236 | +| Z5 | Generalized credentialed egress (sandbox forward proxy) | |
| 237 | +| Z6 | Subagents as isolated child sessions | |
| 238 | +| Z7 | Red-team + formal validation of the credential plane | |
| 239 | + |
| 240 | +Today the harness uses a trust-the-operator model: the model credential is a pre-provisioned |
| 241 | +Kubernetes Secret, there is no egress policy, and all leaves share one service-account identity. Those |
| 242 | +gaps are exactly what Phase 2 closes. |
| 243 | + |
| 244 | +--- |
| 245 | + |
| 246 | +## Documentation |
| 247 | + |
| 248 | +- [Executive overview — leaf-session backend](docs/executive-overview-leaf-session.md) |
| 249 | +- [Milestone registry](docs/specs/README.md) — authoritative milestone numbering and status |
| 250 | +- [Design specs](docs/specs/) — one dated design doc per milestone |
| 251 | +- [`harness/README.md`](harness/README.md) — local dev build (Pi workspace build order, headless smoke) |
| 252 | + |
| 253 | +--- |
| 254 | + |
| 255 | +## Status & License |
| 256 | + |
| 257 | +This is a **private research repository** (`kagenti/serverless-harness`). It is an MVP — the |
| 258 | +scale-to-zero, durable-resume, sandbox-isolation, and dispatch features above are built and |
| 259 | +smoke-verified; the zero-trust credential plane is designed but not yet implemented. Interfaces may |
| 260 | +change. |
| 261 | + |
| 262 | +Licensed under the [Apache License 2.0](LICENSE). |
| 263 | + |
| 264 | +--- |
| 265 | + |
| 266 | +*Assisted-By: Claude Code* |
0 commit comments