diff --git a/jcp/jcp-rs/crates/jcp-orchestrate/MAPPING.md b/jcp/jcp-rs/crates/jcp-orchestrate/MAPPING.md new file mode 100644 index 000000000..0a6437de4 --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-orchestrate/MAPPING.md @@ -0,0 +1,139 @@ +# The background-agent orchestrator pattern → `jcp-orchestrate` + +`jcp-orchestrate` does not invent an orchestration shape. It **ingests** the one +the agent-work ecosystem converged on — a background-agent orchestrator that runs +coding agents in the cloud — and supplies the three things every deployed +instance of that pattern lacks: a **capability gate**, a **joule budget**, and a +**sealed audit**. + +This document maps the pattern — as built by the production background-agent +orchestrators — onto (1) this crate's reconcile loop, (2) the Sandbox S0–S7 +isolation ladder its container model sits low on, (3) the Joule Table seams its UX +surfaces never carried, and (4) the JCP grant its credential model stops short of +— then scopes the frontier the whole category leaves empty. + +## 0. Why map a commodity pattern + +The background-agent orchestrator is a well-worn shape: a tracker of work items; +each item run by a coding agent in an isolated container on its own repo copy, +auto-branched, checkpointed to a forge as machine-authored commits and pull +requests; a CLI for local handoff that doubles as an MCP server; bring-your-own +subscription or API keys; recurring and event-triggered automations; chat and +forge @-mention bindings. Several products shipped exactly this. The shape is not +in dispute and not a moat — it is commodity. + +What none of them produced is the durable artifact: a **signed, energy-accounted, +verifiable receipt** of what ran, under whose authority, for how many joules. +Everyone can build the orchestrator; the accountability is the open lane. This +crate keeps the loop and makes each dispatch a JCP decision made **outside** the +agent. + +## 1. The reconcile loop → `Orchestrator` (ingest, not compete) + +The pattern's control plane is a tracker → route → dispatch → retry loop over a +*heterogeneous* fleet, where a worker is one coding-agent backend on one item and +a different backend on the next. `Orchestrator` keeps that loop and gates every +dispatch. + +| Background-agent orchestrator concept | `jcp-orchestrate` symbol | What TX adds | +|---|---|---| +| Work tracker, one branch per task | `Task { id, kind, payload }` + the sealed decision ledger (`receipts()`) | The run is an **auditable ledger of sealed decisions**, not just a branch list | +| A coding-agent backend (any CLI/framework) | `Harness` — any backend that quotes a joule cost and reports an outcome | The orchestrator is **blind to what runs inside**; backends are `register`ed, not hardcoded | +| Assign a task to a backend | `dispatch` **decided against a grant, outside the backend** (`route` → gate) | Who-runs-what is a runtime authorization, never the agent's own choice | +| Retry on failure | bounded retry **+ failover through a sealed denial** to a backend still under budget | An exhausted backend is denied *on the record*, then fails over; a flaky one retries to `max_attempts` | +| Bring-your-own subscription / API key | `Capability` (task kinds) **+ `budget_uj`** per backend | Authority, not just authentication — see §4 | +| Unbounded agent loop (the documented cost failure) | joule budget **debited + enforced before the side effect** | A hard, pre-side-effect energy ceiling per grant | +| Machine-authored commit / PR as the record | JCR-1 receipt sealed per decision, allow **and** deny | The **non-repudiable audit** a git history cannot be | + +Net: the pattern is *what the loop looks like in production*; `Orchestrator` is +*the same loop with the gate, the budget, and the sealed audit the meta-harness +leaves to the platform*. The `background_agents` example drives it end to end. + +**What is and isn't sealed.** Every decision made *against a grant* is sealed — +the allow, and the deny when a backend is out of budget or out of scope. Budget +enforcement in particular must leave an artifact: a joule ceiling that fires +silently is an enforcement nobody can audit, so a backend that cannot pay is +denied through the runtime and that denial is on the record *before* the loop +fails over to the next. The one refusal that seals nothing is a task whose kind +**no** registered backend holds: there is no grant to decide against, and JCP +seals decisions about a grant, not the absence of one. Such an item is recorded +denied in the `RunReport` and never reaches a backend. + +## 2. Container isolation → Sandbox S0–S7 (attested execution) + +The pattern isolates each task in a container with its own repo copy. On the +Sandbox ladder that is roughly **S2–S3**: namespace/cgroup isolation, no attested +boot, no measured execution, no energy-counter provenance. The container keeps +tasks from colliding; it proves nothing to a third party about what ran. + +| Pattern isolation | Sandbox rung | The step up | +|---|---|---| +| Container, own repo copy | ~S2/S3 (namespace isolation) | Integrity asserted by the host, attested to no one | +| — | **S5** — microVM (proven on real bare-metal KVM + RAPL) | A hardware-isolation domain with a measured boot; a receipt can name the image that ran | +| — | **S6** — TEE attestation (`sandbox-tee::energy`) | An `EnergyStatement` bound into a TEE report's `report_data`; the honest `IntegrityAttested` vs `PhysicallyRooted` trust ladder | + +The move is not a better container. It is running the *same* task inside an S5 +microVM under an attested boot, and binding the energy reading's provenance into +the receipt. The pattern's isolation stops at "tasks don't collide"; the ladder +continues to "a third party can verify what ran and what it cost." + +## 3. The UX surfaces → Joule Table seams (gate / meter / receipt) + +The surfaces the pattern converged on are the right surfaces — they are simply +unmetered, ungated, and unsealed. Each rebinds through an existing binding, and +each gains the three seams the bare orchestrator never had. + +| Pattern surface | Joule Table binding | The three seams it gains | +|---|---|---| +| Local-handoff CLI + MCP task server | **Joule Hook** (editor/host binding) | gate = grant on task creation; meter = joules per task; receipt = MCP receipt server | +| Chat / forge @-mention kicks off a task | **Joule Desk** (teammate binding) | signed-grant authority for the mention; a per-receipt-verifiable ledger of what the teammate did | +| Recurring + event-triggered automations | **Durable agent execution** (Sandbox + JCP) | durable resumable runs, env-manifest identity, standing grants with a joule ceiling, mid-run steering | +| Browser streaming / notifications | mid-run steering over an attested run | monitoring becomes attested control | + +The pattern proved the surfaces convert. It never made any of those actions +accountable. The seams are exactly that missing accountability. + +## 4. Credentials → JCP grant (authentication → authority) + +Bring-your-own subscription answers **who is paying** — an identity with a quota +at a vendor. A JCP grant answers **what this principal may do, for how many +joules, decided where the model can't reach**: a capability and an energy budget +are the same signed, bounded, debited object. A vendor key lets an agent run +until the bill stops it; a grant denies the action itself — an out-of-scope task +kind, or one over the joule ceiling — *before* the side effect, and seals both the +allow and the deny. Auth says "the card works"; the grant says "this action, this +budget, this receipt." + +## 5. The frontier the pattern leaves open + +The background-agent orchestrators shipped every convenience and none of the +trust layer. The empty lane is the one no instance of the pattern occupies: a +**per-run, per-task, energy-accounted, attested, third-party-verifiable receipt** +of agentic work — `efficiency/1` rolled up from JCR-1 decisions, with the energy +counter's provenance attested through `sandbox-tee`. No competitor issues it; +regulation mandates nothing per-task; the category publishes rates, not +outcome-anchored receipts. + +## 6. A conformant flow (the profile in one paragraph) + +Register each coding-agent backend as a `Harness` with a **capability** (the task +kinds it may handle) and a **joule budget**. Track work items as the pattern does; +run the reconcile loop, but make every dispatch a JCP decision against that grant, +**sealed as JCR-1 before the task runs**. Run the task inside a **Sandbox S5 +microVM** under an attested boot, with the energy reading's provenance bound via +`sandbox-tee::energy`. Roll the per-task receipts up with `aggregate()` into an +`efficiency/1` figure (`uj_per_accepted_token`, waste share, outcome anchor). +Rebind the surfaces — local-handoff/MCP through **Joule Hook**, chat/forge through +**Joule Desk**, automations through **Durable agent execution** — each carrying +gate / meter / receipt. The result is the pattern's exact UX, with the one thing +it structurally could not produce: a signed, energy-accounted, verifiable record +of every action. + +## Status + +- Reconcile loop + capability/budget gate + sealed ledger — shipped (`src/lib.rs`). +- End-to-end demonstration — shipped (`examples/background_agents.rs`). +- Sandbox rung integration (run the harness inside an S5 microVM) and the + `efficiency/1` roll-up — the composition this document scopes; the primitives + (`sandbox-tee::energy`, `jcp-efficiency::aggregate`) are already shipped in their + own crates. diff --git a/jcp/jcp-rs/crates/jcp-orchestrate/examples/background_agents.rs b/jcp/jcp-rs/crates/jcp-orchestrate/examples/background_agents.rs new file mode 100644 index 000000000..f1cf4e9df --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-orchestrate/examples/background_agents.rs @@ -0,0 +1,133 @@ +//! A background-agent orchestrator, gated: run a batch of coding tasks across a +//! heterogeneous fleet of coding-agent backends — each in its own isolated +//! sandbox, on its own branch — where every dispatch is capability-gated, +//! joule-budgeted, and sealed **before** the agent runs. +//! +//! This is the shape the cloud background-agent products converged on (tracker → +//! branch → dispatch → PR). The orchestrator is blind to which backend runs a +//! task; it only knows each backend quotes a joule cost and reports an outcome. +//! What it adds over a bare meta-harness is the one artifact none of them +//! produce: an ordered, third-party-verifiable ledger of every decision — who was +//! authorised to run what, for how many joules, and how it turned out. +//! +//! ```text +//! cargo run -p jcp-orchestrate --example background_agents +//! ``` + +use std::cell::Cell; + +use ed25519_dalek::SigningKey; +use jcp_orchestrate::{Harness, HarnessOutcome, Orchestrator, Task}; + +/// A coding-agent backend that completes at a fixed quoted cost. Stands in for +/// any agent CLI/framework running in its own sandbox container — the +/// orchestrator neither knows nor cares which. +struct Backend { + id: String, + kinds: Vec, + cost_uj: u64, +} +impl Harness for Backend { + fn id(&self) -> &str { + &self.id + } + fn handles(&self, kind: &str) -> bool { + self.kinds.iter().any(|k| k == kind) + } + fn estimate(&self, _task: &Task) -> u64 { + self.cost_uj + } + fn run(&self, _task: &Task) -> HarnessOutcome { + HarnessOutcome::done(self.cost_uj) + } +} + +/// A backend whose sandbox flakes `fails` times (transient container/network +/// failure) before the task lands — the bounded-retry path. +struct Flaky { + id: String, + kind: String, + cost_uj: u64, + fails: Cell, +} +impl Harness for Flaky { + fn id(&self) -> &str { + &self.id + } + fn handles(&self, kind: &str) -> bool { + kind == self.kind + } + fn estimate(&self, _task: &Task) -> u64 { + self.cost_uj + } + fn run(&self, _task: &Task) -> HarnessOutcome { + let left = self.fails.get(); + if left > 0 { + self.fails.set(left - 1); + HarnessOutcome::retry(self.cost_uj, "sandbox cold-start timeout") + } else { + HarnessOutcome::done(self.cost_uj) + } + } +} + +fn main() { + // The principal that authorises the fleet, and the steward that seals every + // decision. A third party trusts the steward key, not the orchestrator. + let mut fleet = Orchestrator::new( + SigningKey::from_bytes(&[1u8; 32]), + SigningKey::from_bytes(&[7u8; 32]), + "did:example:background-agents", + 3, // max attempts per item + ); + + // Two backends can `implement`; the first has budget for two tasks, then the + // fleet fails over to the second. A `review` backend flakes twice before it + // lands. Nothing is granted `deploy` — a capability no backend holds. + fleet + .register(Box::new(Backend { id: "coder-alpha".into(), kinds: vec!["implement".into()], cost_uj: 120 }), &["implement"], 300) + .unwrap(); + fleet + .register(Box::new(Backend { id: "coder-beta".into(), kinds: vec!["implement".into()], cost_uj: 120 }), &["implement"], 1_000) + .unwrap(); + fleet + .register(Box::new(Flaky { id: "reviewer".into(), kind: "review".into(), cost_uj: 90, fails: Cell::new(2) }), &["review"], 1_000) + .unwrap(); + + // A batch of work items — each is a branch a backend will take up. + fleet.submit(Task::new("feat/parser-tokenize", "implement", b"add tokenizer to the parser".to_vec())); + fleet.submit(Task::new("feat/parser-ast", "implement", b"build the AST layer".to_vec())); + fleet.submit(Task::new("feat/parser-errors", "implement", b"structured parse errors".to_vec())); + fleet.submit(Task::new("review/pr-482", "review", b"review the tokenizer PR".to_vec())); + fleet.submit(Task::new("ops/ship-prod", "deploy", b"push to production".to_vec())); // ungranted capability + + let report = fleet.run(); + + println!("── run report ──"); + println!(" completed : {}", report.done); + println!(" denied : {} (no backend holds that capability)", report.denied); + println!(" failed : {}", report.failed); + println!(" authorised: {} µJ across the fleet", report.total_uj); + println!(" sealed : {} decision receipts", report.receipts); + + println!("\n── per branch ──"); + for id in ["feat/parser-tokenize", "feat/parser-ast", "feat/parser-errors", "review/pr-482", "ops/ship-prod"] { + println!(" {id:22} → {:?}", fleet.state_of(id).unwrap()); + } + + println!("\n── joules per backend ──"); + for b in ["coder-alpha", "coder-beta", "reviewer"] { + println!(" {b:12} {} µJ", fleet.spent_uj(b)); + } + + // The whole run is one ordered, verifiable ledger — the audit a bare + // background-agent orchestrator has no way to produce. Every allow was sealed + // before its backend ran, and so was every deny: `coder-alpha` refusing the + // third branch for want of joules is on the record, not silently skipped. + // Only `ops/ship-prod` seals nothing — no backend holds `deploy`, so there was + // no grant to decide against. + let steward = fleet.steward().to_string(); + let pk = fleet.steward_pubkey(); + let verified = fleet.receipts().iter().filter(|r| jcp_receipt::extract(r, &steward, &pk).is_ok()).count(); + println!("\naudit: {verified}/{} sealed decisions verify under the steward key", fleet.receipts().len()); +} diff --git a/jcp/jcp-rs/crates/jcp-orchestrate/src/lib.rs b/jcp/jcp-rs/crates/jcp-orchestrate/src/lib.rs index 62318d131..696417201 100644 --- a/jcp/jcp-rs/crates/jcp-orchestrate/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-orchestrate/src/lib.rs @@ -15,11 +15,16 @@ //! orchestrator is blind to what runs inside it. Each harness is registered with //! a **capability** (the task kinds it may handle) and a **joule budget**; every //! dispatch is framed, **decided against that grant outside the harness**, sealed -//! as a JCR-1 receipt, and only then run. A task whose kind no harness is granted -//! is denied; a harness past its joule budget is denied and the loop **fails over** -//! to another harness that still has capacity; a flaky harness retries up to a -//! bound. The whole run is an ordered ledger of sealed decisions — the audit a -//! bare meta-harness has no way to produce. +//! as a JCR-1 receipt — **allow and deny alike** — and only then run. A harness +//! past its joule budget is denied, that denial is sealed, and the loop **fails +//! over** to another harness that still has capacity; a flaky harness retries up +//! to a bound. The whole run is an ordered ledger of sealed decisions — the audit +//! a bare meta-harness has no way to produce. +//! +//! One refusal is deliberately *not* sealed: a task whose kind **no** registered +//! harness is granted. There is no grant to decide against, and JCP seals +//! decisions about a grant, not the absence of one — so the item is recorded as +//! denied in the [`RunReport`] and never reaches a harness. //! //! Ingest, not compete: the compose-many-harnesses shape is the surface; JCP //! contributes the capability, the energy budget, and the sealed audit. @@ -248,71 +253,81 @@ impl Orchestrator { self.report() } - /// Route a task to the first registered harness that both handles its kind - /// and still has budget for its quote — capability-and-budget-aware routing, - /// so an exhausted harness fails over to the next. - fn route(&self, task: &Task) -> Option { - self.order.iter().find_map(|id| { - let harness = self.harnesses.get(id)?; - if !harness.handles(&task.kind) { - return None; - } - let grant = self.grants.get(id)?; - let remaining = grant.grant.budget_uj.saturating_sub(self.runtime.spent(&grant.grant.id())); - (remaining >= harness.estimate(task)).then(|| id.clone()) - }) + /// The harnesses granted this task's kind, in registration order. + /// + /// Budget is deliberately **not** filtered here. A harness that cannot pay + /// for the task is denied by the runtime — and that denial is sealed — before + /// the loop fails over to the next candidate. Pre-filtering on budget would + /// enforce the joule ceiling but leave no receipt of having done so. + fn candidates(&self, task: &Task) -> Vec { + self.order + .iter() + .filter(|id| self.harnesses[*id].handles(&task.kind)) + .cloned() + .collect() } fn dispatch(&mut self, i: usize) { self.items[i].attempts += 1; let task = self.items[i].task.clone(); - let id = match self.route(&task) { - Some(id) => id, - None => { - self.items[i].state = - WorkState::Denied("no harness is granted this kind with budget to spare".into()); - return; - } - }; - - let grant = self.grants[&id].clone(); - let cost = self.harnesses[&id].estimate(&task); - let frame = Frame { - kind: FrameKind::AgentTask, - trust: Trust::Control, - grant_id: grant.grant.id(), - action: task.kind.clone(), - resource: harness_resource(&id), - payload_hash: ContentId::of_bytes(&task.payload), - }; - let decision = self.runtime.decide(&frame, &grant, self.caller, Energy::estimator(cost), self.now); - let receipt = seal(&frame, &grant.grant, &decision, &self.steward_sk, &self.steward) - .expect("seal dispatch decision"); - self.receipts.push(receipt); - self.now += 1; - - if !decision.is_allow() { - let reason = match &decision.verdict { - Verdict::Deny(r) => r.label().to_string(), - Verdict::Allow => "allow".to_string(), - }; - self.items[i].state = WorkState::Denied(format!("gate: {reason}")); + let candidates = self.candidates(&task); + if candidates.is_empty() { + // The fleet holds no grant for this kind, so there is no grant to + // decide against: the item is refused before any harness sees it. + // This seals no receipt — JCP seals decisions about a grant, not the + // absence of one. + self.items[i].state = WorkState::Denied("no harness is granted this kind".into()); return; } - let outcome = self.harnesses[&id].run(&task); - self.items[i].state = match outcome.status { - Status::Done => WorkState::Done, - Status::Retry => { - if self.items[i].attempts < self.max_attempts { - WorkState::Pending - } else { - WorkState::Failed(format!("exhausted {} attempts: {}", self.max_attempts, outcome.output)) - } + // Offer the task to each granted harness in turn. Every decision — allow + // and deny alike — is sealed before anything runs; a denied harness (out + // of budget, out of scope) fails over to the next with its denial on the + // record. + let mut last_reason = String::new(); + for id in candidates { + let grant = self.grants[&id].clone(); + let cost = self.harnesses[&id].estimate(&task); + let frame = Frame { + kind: FrameKind::AgentTask, + trust: Trust::Control, + grant_id: grant.grant.id(), + action: task.kind.clone(), + resource: harness_resource(&id), + payload_hash: ContentId::of_bytes(&task.payload), + }; + let decision = self.runtime.decide(&frame, &grant, self.caller, Energy::estimator(cost), self.now); + let receipt = seal(&frame, &grant.grant, &decision, &self.steward_sk, &self.steward) + .expect("seal dispatch decision"); + self.receipts.push(receipt); + self.now += 1; + + if !decision.is_allow() { + last_reason = match &decision.verdict { + Verdict::Deny(r) => r.label().to_string(), + Verdict::Allow => "allow".to_string(), + }; + continue; } - Status::Failed => WorkState::Failed(outcome.output), - }; + + let outcome = self.harnesses[&id].run(&task); + self.items[i].state = match outcome.status { + Status::Done => WorkState::Done, + Status::Retry => { + if self.items[i].attempts < self.max_attempts { + WorkState::Pending + } else { + WorkState::Failed(format!("exhausted {} attempts: {}", self.max_attempts, outcome.output)) + } + } + Status::Failed => WorkState::Failed(outcome.output), + }; + return; + } + + // Every granted harness refused — each refusal is sealed in the ledger. + self.items[i].state = WorkState::Denied(format!("gate: {last_reason}")); } fn report(&self) -> RunReport { @@ -485,6 +500,43 @@ mod tests { assert!(matches!(o.state_of("t1"), Some(WorkState::Failed(_)))); } + #[test] + fn budget_denials_are_sealed_not_silently_skipped() { + // The joule ceiling is JCP's flagship enforcement, so firing it must leave + // an artifact. Primary funds one task; the second forces a denial on it, + // which is sealed, and the loop fails over to the backup. + let mut o = orch(); + o.register(Box::new(Fixed { id: "primary".into(), kinds: vec!["work".into()], cost: 100 }), &["work"], 100).unwrap(); + o.register(Box::new(Fixed { id: "backup".into(), kinds: vec!["work".into()], cost: 100 }), &["work"], 100).unwrap(); + o.submit(Task::new("t0", "work", b"a".to_vec())); + o.submit(Task::new("t1", "work", b"b".to_vec())); + let r = o.run(); + + assert_eq!(r.done, 2); // t0 on primary, t1 failed over to backup + // t0: 1 allow. t1: primary DENY (sealed) + backup allow → 3 in total. + assert_eq!(r.receipts, 3); + assert_eq!(o.spent_uj("primary"), 100); // the denied dispatch was not debited + assert_eq!(o.spent_uj("backup"), 100); + + // Every sealed decision — the deny included — verifies under the steward key. + for rcpt in o.receipts() { + assert!(jcp_receipt::extract(rcpt, o.steward(), &o.steward_pubkey()).is_ok()); + } + } + + #[test] + fn ungranted_kind_seals_nothing() { + // No harness holds the capability → no grant to decide against → the item + // is refused, and correctly leaves no receipt behind. + let mut o = orch(); + o.register(Box::new(Fixed { id: "summarizer".into(), kinds: vec!["summarize".into()], cost: 10 }), &["summarize"], 1_000) + .unwrap(); + o.submit(Task::new("t1", "deploy", b"x".to_vec())); + let r = o.run(); + assert_eq!(r.denied, 1); + assert_eq!(r.receipts, 0); + } + #[test] fn duplicate_harness_rejected() { let mut o = orch();