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.
211 lines
12 KiB
Markdown
211 lines
12 KiB
Markdown
# 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.
|