Split out of #895 as Stage 2, so Stage 1 (taking the restore disk reads off the event loop) could land on its own.
Stage 1 recap — what already landed
#895's Stage 1 moved every startup-restore disk read into asyncio.to_thread (open_slots.json, the agent→model map, list_sessions(), and per item get_metadata_status / get_metadata + read_messages_chained), leaving only the loop-affine slot mutation on the event loop. That stops the LoopStallWatchdog from killing the gateway mid-boot.
It did not change how much gets built.
Stage 2 — the remaining ask
Startup still materializes a full _ChatSlot for every restored row, including replaying up to 500 messages into memory, purely so the row can appear in the Sessions sidebar. A sidebar row needs a small projection — title, agent, model, folder, pin, colour, timestamps, unread state — not a hydrated transcript.
So the work is still O(all restored sessions × transcript size) at boot; Stage 1 only moved it off the loop thread. On a large home that is real wall-clock boot time and real resident memory for tabs the user may never click.
Shape
- Restore sidebar rows as a lightweight projection built from the metadata line alone (no
read_messages_chained, no slot.append replay).
- Hydrate the full slot lazily, on first open, through the seam that already exists for exactly this:
rehydrate_slot_from_history_async.
- Keep the invariants Stage 1 pinned: the
restoring_open_slots flush guard, the get_metadata_status readability flag (an unreadable row stays in the reopen seed rather than being dropped), _restricted_keys seeding by restore, and the post-hop _slots + close-tombstone revalidation now that read windows are wide.
Why it is separate
Stage 1 is a contained placement change with behaviour parity. Stage 2 changes what a restored slot is at boot, which touches every consumer that assumes a restored slot carries its messages (to_dict() sidebar payloads, _flush_dirty_slots and the _disk_window_len / _disk_older_count save model, the ACP history prefix). That deserves its own change and its own review.
Related
Split out of #895 as Stage 2, so Stage 1 (taking the restore disk reads off the event loop) could land on its own.
Stage 1 recap — what already landed
#895's Stage 1 moved every startup-restore disk read into
asyncio.to_thread(open_slots.json, the agent→model map,list_sessions(), and per itemget_metadata_status/get_metadata+read_messages_chained), leaving only the loop-affine slot mutation on the event loop. That stops theLoopStallWatchdogfrom killing the gateway mid-boot.It did not change how much gets built.
Stage 2 — the remaining ask
Startup still materializes a full
_ChatSlotfor every restored row, including replaying up to 500 messages into memory, purely so the row can appear in the Sessions sidebar. A sidebar row needs a small projection — title, agent, model, folder, pin, colour, timestamps, unread state — not a hydrated transcript.So the work is still O(all restored sessions × transcript size) at boot; Stage 1 only moved it off the loop thread. On a large home that is real wall-clock boot time and real resident memory for tabs the user may never click.
Shape
read_messages_chained, noslot.appendreplay).rehydrate_slot_from_history_async.restoring_open_slotsflush guard, theget_metadata_statusreadability flag (an unreadable row stays in the reopen seed rather than being dropped),_restricted_keysseeding by restore, and the post-hop_slots+ close-tombstone revalidation now that read windows are wide.Why it is separate
Stage 1 is a contained placement change with behaviour parity. Stage 2 changes what a restored slot is at boot, which touches every consumer that assumes a restored slot carries its messages (
to_dict()sidebar payloads,_flush_dirty_slotsand the_disk_window_len/_disk_older_countsave model, the ACP history prefix). That deserves its own change and its own review.Related
list_sessions()in thedashboard/handlers/sessions.pyAPI handler.