The panel fully delegates to the cascade engine (submit + L4), verified by a gpui test and the 24 panel tests. Document the remaining superseded inline-tier cluster to remove as one careful pass (auto-strip is unsafe here -- string-brace and lifetime hazards), and two minor engine parity deltas (L3 prompt symbols/ codebase retrieval; cross-session L2 persistence). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
12 KiB
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_joule_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_matchare shared fromjoule_core::cascade(commitf9c9b02). - 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, returnstier: Noneon fall-through), andCascadeEngine::record_external_close(record a GUI-applied L4 op into the ledger + L0 cache). Covered by the testresolve_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:
- Add
cascade: CascadeEngine(built innewfrom the panel's gate/hooks/ sessions/steering/energy/key/model) alongside the existing fields. - Route
submit_promptthroughcascade.resolve_localon a background thread over aStringDocumentsnapshot; on a local close, write the buffer back and layer methodology/attest/review onOutcome.edit; on fall-through, the existingsend_to_l4, recording viacascade.record_external_close. Verify in the editor (checklist below) before deleting anything. - Only then, one subsystem at a time, repoint the other readers
(
state(), render, methodology, release, steering, daemon control) atself.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 innewwith the worktree root, the session key, andhost_energy_counter(); setl3_modelafter 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_joule_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; constL2_THRESHOLD. - Fields (+ their
new()inits):ledger_loaded,l2_loaded(only read by the deadensure_*); thel2_index/L2Entrychain (written byindex_for_l2, which the test-onlyrecord_tier_editcalls — drop that call, then removeindex_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 theL2_THRESHOLDimport in the testuse 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 tojoule_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
-
Documentover the editor buffer — the trait iscx-free but the buffer needscx. Bridge with a snapshot: readactive_singleton_buffertext + path viacxbefore calling the engine, wrap in an in-memoryStringDocument(add this impl tojoule_core::document), call the engine, then writedocument.text()back to the buffer viacxafter. (FsDocumentalready proves the trait;StringDocumentis its in-memory sibling.) -
L4DispatchoverAcpThread— theAcpThreadis 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: ifresolvereturns "no local tier closed" (tierNone), the panel falls back to its existing asyncsend_to_l4on the foreground (the currentAcpThread.send+ theAcpThreadEvent::Stoppedsubscription that builds the L4 op). The L4 op is then handed to the engine for ledger/cache recording via a smallCascadeEngine::record_external_close(op, tier, energy_uj)method (add it — it isappend_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.jsonlstill 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.