agent-joule/dev-docs/cascade-gui-dedup.md
David Charlot 7a59f0ffc5 agent-mei: rename agent_joule panel/theme/workflows to agent-mei, MEI fleet wiring and honest-energy receipts
Brings over the uncommitted agent-mei work (Mac checkout, Sep 22) plus box
follow-ups (Oct 17: agent_mei action namespace in keymap/zed.rs, MEI_BRIDGE.md,
RENAME_AGENT_MEI.md, unmetered receipts report 0 uJ). joule_core, JOULE_CODE_*
and JouleDB names unchanged. *.bak.* scratch files excluded.
2026-10-27 12:59:12 -04:00

12 KiB
Raw Permalink Blame History

Cascade GUI dedup — finishing Phase C

The full L0→L4 cascade now lives in joule_core::cascade::CascadeEngine and is already used by the headless worker (joule-coded serve + the cascade subcommand) and joule-bench. The GUI panel (agent_mei_panel) still carries its own inline copy of the tier orchestration. This doc is the plan to delete that copy and route the panel through the shared engine.

Why this step is staged separately: it changes the editor's async interaction path, and GPUI failure modes (UI freeze, borrow-panics, a missed cx.notify()) do not show at compile time. It must be verified with the editor running.

Already done (compile + test verified, no editor needed):

  • The pure-helper slice — parse_substitution, unquote, best_l2_match are shared from joule_core::cascade (commit f9c9b02).
  • The engine-side adapter pieces this plan calls for: joule_core::document::StringDocument (in-memory snapshot Document), CascadeEngine::resolve_local (L0–L3, never consults the cloud, returns tier: None on fall-through), and CascadeEngine::record_external_close (record a GUI-applied L4 op into the ledger + L0 cache). Covered by the test resolve_local_falls_through_then_external_l4_caches_for_l0.

Engine parity is now COMPLETE (commits d7d73c2, 7261e29): the engine does all the portable governance — tier gating (authorize_tier: PreTool hooks + session grant/ceiling + JCP cascade gate, incl. the L4 no-phone-home gate), PostTool hooks and steering in note_close — and emits Outcome.edit (AppliedEdit { op, path, old_text, new_text }) so a GUI host can layer the genuinely cx-bound steps (methodology citation/export, sandbox attestation, the review surface, session-manager op accounting) on top of a local close. Builders: with_gate / with_hooks / with_sessions / with_steering. 5 engine tests.

The remaining work is the panel rewrite — and it is panel-WIDE, not localized. The panel's cascade state (tier_closes, ledger_*, energy_counter, session_key, l0_cache, l2_index, l3_model, codebase_index, cascade_gate, session_mgr, run_steering, local_energy_uj, last_intent, pending_prior) is read across the whole crate — the state() snapshot, the render methods, the methodology/release/ship surfaces, the steering control, and the daemon control-dispatch. Migrating to self.cascade: CascadeEngine means rewiring every reader to self.cascade.<accessor>() (add accessors as needed), not just submit_prompt. Do it incrementally with the editor running:

  1. Add cascade: CascadeEngine (built in new from the panel's gate/hooks/ sessions/steering/energy/key/model) alongside the existing fields.
  2. Route submit_prompt through cascade.resolve_local on a background thread over a StringDocument snapshot; on a local close, write the buffer back and layer methodology/attest/review on Outcome.edit; on fall-through, the existing send_to_l4, recording via cascade.record_external_close. Verify in the editor (checklist below) before deleting anything.
  3. Only then, one subsystem at a time, repoint the other readers (state(), render, methodology, release, steering, daemon control) at self.cascade, deleting each now-duplicated panel field/method as its last reader moves. Re-verify in the editor after each.

Target shape

The panel holds one CascadeEngine instead of its own cascade state:

  • Delete the panel fields the engine already owns: l0_cache, l2_index, l3_model, codebase_index, energy_counter, energy_baseline, session_key (move into the engine), and the ledger fields (ledger_head, ledger_seq, ledger_ops, ledger_energy_uj, ledger_intact, ledger_loaded, l2_loaded), tier_closes, local_energy_uj, last_intent, pending_prior.
  • Add cascade: CascadeEngine (constructed in new with the worktree root, the session key, and host_energy_counter(); set l3_model after load).

Delete these panel methods (now in the engine): attempt_l1_deterministic, attempt_l2_then_l3, attempt_l3_then_l4, record_tier_edit, record_l3_edit, bracket_energy, note_close, append_to_ledger, ensure_ledger_loaded, seal_op_receipt, ledger_receipt, verify_entry_seal, energy_uj_now, and the L0/L1/L2 portion of submit_prompt. Keep host_energy (session aggregate for the status line) and the L4/ACP machinery (see below).

STATUS: migration DONE + verified; dead-code cleanup remains

The panel now fully delegates to joule_core::cascade::CascadeEngine: submit_prompt runs L0–L3 on a background thread over a buffer snapshot and the L4 Stopped handler records via record_external_close — one engine, one ledger chain. Verified by cascade_submit_drives_engine_and_closes_at_l1 (a #[gpui::test] driving control_dispatch("submit")) and all 24 panel tests. Commits: agent_mei_panel: Route submit_prompt through the shared cascade engine and … Route the L4 close through the engine ledger.

Residual (≈11 dead-code warnings, no errors): the superseded inline-tier code. It is an interconnected, partly test-entangled cluster — remove it as one careful pass (NOT with a naive brace-counting script: this file has {/} inside string literals like "fn main() {" and '{' char literals plus 'a lifetimes, which fooled an auto-strip into deleting 835 lines. Use rust-analyzer's remove-unused, or delete by verified line ranges with a Rust-aware lexer, then re-run the panel tests).

Cluster to delete together:

  • Methods: attempt_l1_deterministic, attempt_l2_then_l3, attempt_l3_then_l4, ensure_ledger_loaded, ensure_l2_loaded, verify_entry_seal.
  • Free fns: harness_prompt, parse_edit, format_symbol_context, symbol_kind_label, repo_relative, format_codebase_context, l2_threshold; const L2_THRESHOLD.
  • Fields (+ their new() inits): ledger_loaded, l2_loaded (only read by the dead ensure_*); the l2_index / L2Entry chain (written by index_for_l2, which the test-only record_tier_edit calls — drop that call, then remove index_for_l2 + l2_index + L2Entry).
  • Tests that reference the above (delete with the code they cover; the engine has equivalent coverage): l3_probe_simple_vs_harness, harness_probe_one, embeds_separate_paraphrase_from_unrelated, and the L2_THRESHOLD import in the test use super::{…} block.
  • Then record_tier_edit / record_l3_edit / record_multi_edit / note_close / append_to_ledger / seal_op_receipt / ledger_receipt / bracket_energy / energy_uj_now / l0_cache / ledger_* become candidates too if their only remaining callers are the panel's own cascade tests — migrate those tests to joule_core::cascade (which already tests L0/L1/ledger) and delete the panel copies for the full dedup.

Known minor behavior delta to revisit during cleanup: the engine's L3 prompt uses the @mention context but not LSP symbols or codebase-retrieval augmentation (the panel's old L3 did); and L2 retrieval is in-session only (the engine does not yet persist/rehydrate l2_index.jsonl across restarts as the panel's ensure_l2_loaded did). Add these to the engine if parity matters.

Gating: use the per-request snapshot (NOT engine-held governance)

A GUI host's gating reads live, shared, mutable state — session_mgr energy, the hooks registry, cascade_gate — that other panel features mutate, and that is not Send. So the engine must NOT hold it to gate on a background thread (stale gating = a security bug). Instead: the panel keeps authorize_tier (reading its live state + self.cascade.life_spent_uj()), computes allow = [authorize_tier(L0)..authorize_tier(L4)] on the foreground before spawning, and calls cascade.set_request_authorization(Some(allow)). The engine honors that snapshot (commit adding set_request_authorization). Same pattern for the other governance side-effects (PostTool hooks, steering, methodology, attestation, session-mgr op recording): the engine does the portable half (tier_closes/energy/ledger/cache, and its own empty hooks/steering no-op for the GUI); the panel fires its live-state half on the foreground after the close, driven by Outcome.edit (the op + path + old/new text).

No safe compile-only milestone — do this live at the editor

There is no additive first step: a lazily-built cascade field is dead until submit_prompt routes through it, and the instant it does, the panel's own l0_cache/ledger_*/tier_closes/local_energy_uj go stale and every state()/render reader is wrong until repointed at self.cascade.<accessor>(). It is one coordinated change whose correctness — gating freshness, no double-fired hooks, no UI freeze on L3, no missed cx.notify() — is a runtime property cargo check cannot see. Execute it with the editor running, verifying after each step (route submit_prompt first; then repoint state()/render/methodology/ release/steering/daemon-control one subsystem at a time, deleting each duplicated panel field/method as its last reader moves).

The two seams

  1. Document over the editor buffer — the trait is cx-free but the buffer needs cx. Bridge with a snapshot: read active_singleton_buffer text + path via cx before calling the engine, wrap in an in-memory StringDocument (add this impl to joule_core::document), call the engine, then write document.text() back to the buffer via cx after. (FsDocument already proves the trait; StringDocument is its in-memory sibling.)

  2. L4Dispatch over AcpThread — the AcpThread is GPUI/non-Send, so it cannot run on a background thread. Therefore the engine runs L0–L3 on a background thread over the snapshot document, and L4 is not dispatched from the engine in the GUI. Instead: if resolve returns "no local tier closed" (tier None), the panel falls back to its existing async send_to_l4 on the foreground (the current AcpThread.send + the AcpThreadEvent::Stopped subscription that builds the L4 op). The L4 op is then handed to the engine for ledger/cache recording via a small CascadeEngine::record_external_close(op, tier, energy_uj) method (add it — it is append_to_ledger + l0_cache.insert + note_close, already private).

    Net: the engine owns L0–L3 and all recording; the GUI keeps only the GPUI-bound L4 transport, calling back into the engine to record.

Async flow for submit_prompt

read prompt + buffer snapshot (cx)        // foreground, instant
spawn background:
    outcome = engine.resolve_local(req, &mut snapshot_doc)   // L0..L3, no L4
foreground (on completion):
    if outcome closed:  write snapshot_doc back to buffer (cx); status; notify
    else:               send_to_l4(...)  // existing path; records via engine

Add CascadeEngine::resolve_local = resolve with the L4 branch replaced by "return tier: None" (so the engine never needs an L4Dispatch in the GUI). The existing resolve(.., l4) stays for the headless worker.

engine.resolve_local must be callable on a background thread: confirm CascadeEngine: Send (its fields — L0Cache Mutex, Arc<L3Model>, Box<dyn EnergyCounter + Send + Sync>, SigningKey, plain data — are Send; the snapshot StringDocument is Send). The engine is owned by the panel, so either move it into the background closure and back, or wrap it Arc<Mutex<CascadeEngine>> and lock for the resolve (one request at a time).

Verification checklist (editor running)

  • cmd-alt-1 → connect agent; prompt a deterministic edit ("rename X to Y") → closes at L1, buffer updates, status shows ⚡ closed at L1.
  • Repeat the identical prompt on the same buffer state → L0 cache hit.
  • A retrieval paraphrase over the same buffer → L2 replay.
  • A novel prompt with a model loaded → L3 generation, no UI freeze during generation (it runs on the background thread).
  • A novel prompt with no local close → L4 ACP round-trip renders, op recorded (ledger grows, energy/status line correct).
  • .joule/ledger.jsonl still hash-chains + seals verify across a restart.

Risk register

  • UI freeze if any tier accidentally runs on the foreground (L3 is the danger).
  • Borrow-panic from updating the buffer entity while already in an update.
  • Missed cx.notify() after the background close → stale UI.
  • The L4 handoff: ensure exactly one of (engine L0–L3) or (panel L4) records the op — no double-record, no missed record.