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

211 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.