Energy was measured once around the whole agent loop and attributed wholesale to
L3, so you could not see where the joules and time actually went across a run.
This adds a structured, per-step event stream.
- joule_core::journal: StepRecord{kind,label,energy_uj,provenance,duration_ns,ok,
detail,tokens} + RunJournal with the metrics SOTA tools report -- energy split by
kind (codegen vs running vs debugging), Joules/token, and energy-delay product
(u128, penalizes latency as well as energy). Serializes to the OpenTelemetry
GenAI span shape with energy as a first-class span attribute. `measure()` brackets
a host-counter reader + wall clock around a step and captures its outcome (errors
become typed events, not strings folded into the next prompt). Integer uj/ns
throughout, so a journal is byte-reproducible and sealable.
- joule_core::energy: energy_reader() -- a cheap Rc reader over the host counter.
- joule_coded::run_loop_metered: like run_loop but wraps the injected generate/run/
edit closures so each step self-meters into a shared journal. The daemon's run
path uses it; the run total is now the sum of per-step energy, and `state` shows
the energy split. New `journal` control method returns the steps + summary (or the
OTel spans with {"otel":true}).
Signature of run_agent_loop is unchanged, so the GUI call site is untouched.
Verified: journal unit tests (metrics, measure, OTel shape); a headless integration
test drives run_loop_metered with a scripted model + a fake counter and asserts each
step carries its own energy; full joule_core suite (136) green.
131 lines
5.5 KiB
Rust
131 lines
5.5 KiB
Rust
//! The headless keystone, deterministically: drive the worker's real `run_loop`
|
|
//! (which writes edits to disk through the same `FsDocument` path the daemon uses)
|
|
//! and `record_trace` (which seals each edit into a signed session ledger), with a
|
|
//! scripted on-device model standing in for JouleClaw's GGUF runtime. Proves a
|
|
//! real coding op closes headlessly and produces an intact, signed receipt — no
|
|
//! GUI, no window, no model download.
|
|
|
|
use ed25519_dalek::SigningKey;
|
|
use joule_coded::{record_trace, run_loop, run_loop_metered, seed_from};
|
|
use joule_core::journal::EnergyReader;
|
|
use joule_core::sessions::{SessionGrant, SessionManager};
|
|
use std::cell::Cell;
|
|
use std::rc::Rc;
|
|
|
|
#[test]
|
|
fn headless_run_edits_a_file_and_seals_a_signed_op() {
|
|
// A scratch checkout with one file the agent will rewrite. The primary session
|
|
// makes it accountable without needing a git worktree.
|
|
let root = std::env::temp_dir().join(format!("jc-headless-{}", std::process::id()));
|
|
let src = root.join("src");
|
|
std::fs::create_dir_all(&src).unwrap();
|
|
std::fs::write(src.join("lib.rs"), "fn answer() -> i32 { 0 }\n").unwrap();
|
|
|
|
let key = SigningKey::from_bytes(&seed_from(&root.to_string_lossy()));
|
|
let author = key.verifying_key().to_bytes();
|
|
let grant = SessionGrant {
|
|
tiers: vec!["l0".into(), "l1".into(), "l2".into(), "l3".into()],
|
|
joule_ceiling_uj: 1_000_000_000,
|
|
};
|
|
let mut sessions = SessionManager::with_primary(
|
|
root.clone(),
|
|
key,
|
|
root.join(".joule").join("worktrees"),
|
|
grant,
|
|
"test-worker",
|
|
);
|
|
|
|
// The scripted "model": edit the file, then finish. Exactly the wire the
|
|
// on-device runtime would emit, so this exercises the real loop + tool parsing.
|
|
let turns = [
|
|
"<<<TOOL edit_file>>>\nsrc/lib.rs\nfn answer() -> i32 { 42 }\n<<<END>>>",
|
|
"<<<TOOL finish>>>\n",
|
|
];
|
|
let mut step = 0usize;
|
|
let trace = run_loop(&root, "make answer return 42", 8, |_prompt| {
|
|
let turn = turns[step.min(turns.len() - 1)];
|
|
step += 1;
|
|
Ok(turn.to_string())
|
|
});
|
|
|
|
// The loop applied exactly one edit, and it is already on disk (the daemon's
|
|
// apply_edit wrote it through FsDocument).
|
|
assert_eq!(trace.applied.len(), 1, "expected one applied edit");
|
|
assert_eq!(trace.applied[0].0, "src/lib.rs");
|
|
let on_disk = std::fs::read_to_string(src.join("lib.rs")).unwrap();
|
|
assert!(on_disk.contains("{ 42 }"), "edit not on disk: {on_disk:?}");
|
|
|
|
// Recording seals the edit as a content-addressed op into the primary
|
|
// session's signed chain — the headless receipt.
|
|
let before = sessions
|
|
.primary()
|
|
.unwrap()
|
|
.events()
|
|
.iter()
|
|
.filter(|e| e.kind == "op")
|
|
.count();
|
|
let recorded = record_trace(&mut sessions, None, author, "make answer return 42", 12_345, &trace);
|
|
assert_eq!(recorded, 1, "expected one op recorded");
|
|
|
|
let primary = sessions.primary().unwrap();
|
|
let ops_after = primary.events().iter().filter(|e| e.kind == "op").count();
|
|
assert_eq!(ops_after, before + 1, "ledger did not grow by one op");
|
|
assert!(primary.verify_chain(), "the signed ledger chain must verify");
|
|
assert_eq!(primary.energy_uj(), 12_345, "op energy folded into the session");
|
|
|
|
std::fs::remove_dir_all(&root).ok();
|
|
}
|
|
|
|
#[test]
|
|
fn metered_run_records_per_step_energy() {
|
|
let root = std::env::temp_dir().join(format!("jc-metered-{}", std::process::id()));
|
|
let src = root.join("src");
|
|
std::fs::create_dir_all(&src).unwrap();
|
|
std::fs::write(src.join("lib.rs"), "fn answer() -> i32 { 0 }\n").unwrap();
|
|
|
|
// A fake host counter that advances 1000 µJ per read. `measure` reads it twice
|
|
// per step (before + after), so each recorded step's energy is exactly 1000 µJ —
|
|
// a deterministic stand-in for a real RAPL/IOReport counter.
|
|
let reader: EnergyReader = Rc::new({
|
|
let cumulative = Cell::new(0u64);
|
|
move || {
|
|
let value = cumulative.get();
|
|
cumulative.set(value + 1000);
|
|
(value, "hw_shunt")
|
|
}
|
|
});
|
|
|
|
// Scripted model: edit the file, then finish. Each turn is one `generate` step;
|
|
// the edit is one `edit` step — all metered independently.
|
|
let turns = [
|
|
"<<<TOOL edit_file>>>\nsrc/lib.rs\nfn answer() -> i32 { 42 }\n<<<END>>>",
|
|
"<<<TOOL finish>>>\n",
|
|
];
|
|
let mut step = 0usize;
|
|
let (trace, journal) = run_loop_metered(&root, "make answer return 42", 8, reader, |_prompt| {
|
|
let turn = turns[step.min(turns.len() - 1)];
|
|
step += 1;
|
|
Ok(turn.to_string())
|
|
});
|
|
|
|
assert_eq!(trace.applied.len(), 1, "one edit applied");
|
|
assert!(!journal.is_empty(), "journal recorded steps");
|
|
|
|
// Every step carries its own measured energy + provenance — not one lump.
|
|
for record in &journal.steps {
|
|
assert_eq!(record.energy_uj, 1000, "{} step energy", record.kind);
|
|
assert_eq!(record.provenance, "hw_shunt");
|
|
assert!(record.ok, "{} step should succeed", record.kind);
|
|
}
|
|
let kinds: Vec<&str> = journal.steps.iter().map(|record| record.kind.as_str()).collect();
|
|
assert!(kinds.contains(&"generate"), "a generate step was metered: {kinds:?}");
|
|
assert!(kinds.contains(&"edit"), "the edit step was metered: {kinds:?}");
|
|
|
|
// The summary sums the per-step energy and splits it by kind.
|
|
let summary = journal.summary();
|
|
assert_eq!(summary.total_energy_uj, journal.steps.len() as u64 * 1000);
|
|
assert_eq!(summary.errors, 0);
|
|
assert!(summary.energy_by_kind.get("edit").copied().unwrap_or(0) >= 1000);
|
|
|
|
std::fs::remove_dir_all(&root).ok();
|
|
}
|