open-standards/jcp/jcp-rs/crates/jcp-orchestrate/examples/background_agents.rs
Transaction Science b77b9d70f6
All checks were successful
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Successful in 11m52s
jcp-orchestrate: steps wait for steps, and a retry needs a name for the work
The loop was a flat queue of independent tasks routed by kind. A flat queue is
a work pool, not automation: nothing could express "transform after fetch, and
never publish because fetch failed". `Task::after` names the steps a task waits
on, and only an item whose every dependency is `Done` is dispatched.

An edge is one task id naming another in this run, resolved against the run's
own journal — not an endpoint in someone else's system. That is the whole point
of putting the graph here rather than in an integration fabric: the fabrics put
their edges across trust boundaries they do not own, which is where their
failures live, and an edge that does not cross a boundary cannot fail at one.

A step whose dependency did not complete is settled `WorkState::Blocked`, naming
what it waited on and why that did not happen. Blocked is deliberately not
Denied: a denial is a decision about a grant and carries a sealed receipt, while
a block is a missing precondition — no harness was offered the task, no grant was
consulted, nothing was spent, so there is nothing to seal. The blocking pass
runs to a fixed point, because settling one step is what determines why the next
can never run; a single pass would have to call a long chain behind one failure a
cycle, which it is not. A real cycle lands there too, so the loop still
terminates on any graph a caller can submit.

`Status::Retry` was taken at its word: the harness said transient, so the loop
ran the task again. But transient describes why the attempt failed, not whether
it left anything behind — a harness can fail after its effect has landed, and the
second run then pays for and performs that effect twice. The crate already
refuses that bet for `Interrupted`; it was taking it here. A retry is now
honoured only for a task carrying `Task::idempotency_key`, and refused otherwise
with that reason recorded. An attempt bound was never a substitute: it limits how
many times an effect may be duplicated, it does not make duplicating it safe.

SPEC §4.11 gains the rule. It already governed a decision recorded with no
outcome; this is the neighbouring case its own reasoning implies — an outcome
recorded as "run it again" — and it requires the key to be part of the durable
record, so a restarted enforcement point can tell a replay-safe action from one
that merely has attempts left.

Both the graph edges and the replay identity are journalled with the task, so a
resumed run rebuilds the graph and not a flat queue. The example is now a
five-step pipeline, submitted in reverse so the ordering cannot be an artefact of
the queue: the ship is denied for want of a capability and the announcement
behind it is never started. jouledesk gains the matching `TagOutcome::Blocked`,
which seals no efficiency receipt — there were no joules to account for.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-11-12 07:43:11 -05:00

166 lines
7.2 KiB
Rust

//! 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.
//!
//! The batch is a **pipeline, not a pool**: the AST work waits for the tokenizer,
//! the review waits for both, the ship waits for the review, and the announcement
//! waits for the ship. Every edge is one task id naming another in this run — no
//! endpoint in anyone else's system, so there is no boundary for a renamed field
//! or a stale credential to break. The ship is refused for want of a capability,
//! and the announcement behind it is therefore never started at all.
//!
//! ```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<String>,
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<u32>,
}
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();
// The pipeline. Submitted in no particular order — the edges, not the queue,
// decide what runs when.
//
// tokenize ──┬──► ast ────┐
// └──► errors ─┴──► review ──► ship ──► announce
// (denied) (blocked)
//
// `review` is the one task declared replay-safe: its backend flakes on a cold
// sandbox, and a retry is honoured only for a task that names what makes the
// second run the same work as the first. Reviewing a PR twice under the same
// key leaves one review, so it can say so.
fleet.submit(Task::new("release/announce", "notify", b"post the release note".to_vec()).after(["ops/ship-prod"]));
fleet.submit(Task::new("ops/ship-prod", "deploy", b"push to production".to_vec()).after(["review/pr-482"]));
fleet.submit(
Task::new("review/pr-482", "review", b"review the tokenizer PR".to_vec())
.after(["feat/parser-ast", "feat/parser-errors"])
.idempotent("review/pr-482@head"),
);
fleet.submit(Task::new("feat/parser-ast", "implement", b"build the AST layer".to_vec()).after(["feat/parser-tokenize"]));
fleet.submit(Task::new("feat/parser-errors", "implement", b"structured parse errors".to_vec()).after(["feat/parser-tokenize"]));
fleet.submit(Task::new("feat/parser-tokenize", "implement", b"add tokenizer to the parser".to_vec()));
let report = fleet.run();
println!("── run report ──");
println!(" completed : {}", report.done);
println!(" denied : {} (no backend holds that capability)", report.denied);
println!(" blocked : {} (a step it waited on never completed)", report.blocked);
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",
"release/announce",
] {
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.
// Two items seal nothing, for two different reasons. `ops/ship-prod` was
// refused because no backend holds `deploy`: there was no grant to decide
// against. `release/announce` was never offered to anyone — the step it
// waited on did not complete, so no grant was consulted and no joules were
// spent. A pool would have run the announcement anyway.
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());
}