All checks were successful
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Successful in 11m52s
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]>
166 lines
7.2 KiB
Rust
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());
|
|
}
|