Skip to content

(restore): add Restore on click startup mode - #441

Merged
devsuitup merged 20 commits into
devsuitup:mainfrom
abate:feat/lazy-restore
Oct 15, 2026
Merged

devsuitup merged 20 commits into
devsuitup:mainfrom
abate:feat/lazy-restore

Conversation

@abate

@abate abate commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator

Based on main 421f77f, which carries #539 (the in-flight open guard), #477, #479, #547/#551's merged web-session rows and #558's restore of a merged member; this PR has only its own commits.

Why

Restoring the working set at startup resumes one claude process per session, which costs a lot of memory when many sessions were open.

What

New Restore Sessions on Startup option: Restore on click (restoreOnStartup: 'lazy').

  • No PTY is spawned at startup, and a renderer reload only reattaches to a PTY that still runs (reattachOnly; main answers notLive instead of spawning).
  • Last run's sessions are marked in the sidebar (outlined status dot) and stay visible regardless of the visible-count and max-age limits; the groups holding them open.
  • Opening one (a click, or the previous/next session keys outside the grid) resumes it through the normal openSession path; the marker clears only once the open succeeds. A held key never resumes marked sessions one after another. A marked session whose conversation continued under another id is resumed there, and the new id replaces it in the saved set; if that continuation is declined or fails to start, it stays marked and saved under the new id, also when it was opened before the marking reached it. A merged web-session row is marked when any of its sessions is; a click on it resumes its marked member (or shows the member already open) and clears the row's marks, and the session keys resume a marked member under its own id.
  • Don't restore in a marked session's context menu drops it from the saved set without starting it (every marked member of a merged row); so does Delete.
  • Unopened sessions stay in the persisted openWorkingSet, so they survive further restarts.

Default stays "Ask on startup".

Tests

  • test/restore-lazy.test.js loads the shipped functions and sidebar.js.
  • Lint 0 errors; the full suite fails only on the tests that fail on main in my sandboxed environment (the CLI-state canary, 12 test-pr isolation tests).
  • Last round, tests first: 41bb577's six new merged-row tests fail on the code before the fix (Test run 38217152161, all four test jobs); green on 638c9be (Test run 38218054511, every job). The held-entries and continuation-settle pins pass before the fix and fail under the reviewer's mutations.

🤖 Generated with Claude Code

@devsuitup devsuitup left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this — lazy restore is a real memory win when many sessions were open. A few things need to change before it can land:

Blocking

  1. Base is 180 commits behind main (forked at 9ae4b9a, v0.0.58); the diff no longer applies (public/app.js, public/sidebar.js). Restore on main now goes through createRestorePlanner / tickRestorePlanner(), which waits for indexing and shows the "not in the index" notice (#376). The lazy branch belongs inside tickRestorePlanner, after plan.action is resolved; otherwise sessions not yet indexed at startup never become dormant, and the next persistWorkingSet() drops them from openWorkingSet.
  2. The dormant marker is cleared at the top of openSession. On main, openSession first calls guardResume (a session live in another process asks before resuming). If the user declines, or openTerminal fails, the entry has already left the dormant set and falls out of the persisted working set. Clear it only after a successful open, where skippedWorkingSetEntries is cleared (or reuse that map).
  3. The tests re-implement app.js instead of loading it. With the lazy branch disabled, the persist loop removed, the delete neutered and the sidebar filter removed all at once, the suite stays green. Please test the shipped code with loadAppFunctions from test/app-source.js (see .ai/shared-guidelines.md, Testing), and add a case where the click is refused because the session is live elsewhere.

Also

  • A dormant entry can only be dismissed by opening it (which spawns claude) and then closing it, and it escapes the visible-count / max-age limits across restarts. A way to dismiss it, or an age cap, would help.
  • A CHANGELOG.md entry under ## Unreleased (see docs/changelog.md).
  • The rationale comment at public/app.js:123 belongs in .ai/contexts/session-state.md (a short "lazy restore" section): code keeps at most a one-line pointer.
  • Worth stating in the docs: in lazy mode the grid starts empty and the last active session is not selected.

Checked and fine: the eslint.config.js global, the settings wording, SETTING_DEFAULTS for the default, and nothing spawns from persisted data without a click.

CI has not run on this head yet; it will once the branch is rebased.

@abate
abate force-pushed the feat/lazy-restore branch 2 times, most recently from 93bec92 to 4b8d98e Compare October 3, 2026 20:12
@abate
abate requested a review from devsuitup October 3, 2026 20:40

@devsuitup devsuitup left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the rebase. The lazy branch now sits in tickRestorePlanner, after the plan resolves, and dormant entries are spliced back in persistWorkingSet. Point 1 is resolved. Two things still block:

Blocking

  1. The dormant marker is still cleared before the open succeeds (public/app.js:1303-1305, at the top of openSession, before guardResume and the result.ok check). Scenario:

    • a dormant session is live in another process;
    • the user clicks it and cancels the confirm;
    • the entry is now neither dormant nor open, and the next persist drops it from the working set for good.

    The same loss happens when openTerminal returns ok:false, because the entry becomes closed. Fix: move dormantWorkingSet.delete(sessionId) and the class removal next to skippedWorkingSetEntries.delete(sessionId), after result.ok.

  2. The refusal case is not tested. In test/restore-lazy.test.js, getSessionLiveElsewhere always returns null, so moving the delete either way leaves the suite green. Please add a case where it returns a live session and confirm returns false. Then assert that the session is still dormant and still in the persisted set.

Non-blocking

  • Function extraction. The test slices functions with its own brace matcher. extractFunction / loadAppFunctions from test/app-source.js skip braces inside strings and regexes; please use them.
  • No way to get rid of a dormant entry. It still bypasses the visible-count and max-age limits (public/sidebar.js:729), and dismissing it means opening it, which spawns claude. A dismiss action or an age cap would help.
  • Sidebar untested. No test loads the sidebar changes (the .dormant class, the visibility bypass).
  • Docs. Please mention that in lazy mode the grid starts empty and the last active session is not selected.

CI is green on 4b8d98e. The diff against current main merges without textual conflicts. The tests were not run on the merged result.

abate added a commit to abate/switchboard that referenced this pull request Oct 6, 2026
Review of devsuitup#441:
- openSession clears the dormant marker only after openTerminal succeeds,
  so a resume refused by guardResume or a failed open keeps the entry
  dormant; persistWorkingSet keeps a dormant or skipped entry whose open
  entry is closed.
- "Don't restore" in a dormant session's context menu removes it from
  the saved set without spawning claude.
- restore-lazy tests load the shipped functions with loadAppFunctions
  next to the real sidebar.js, and cover the refused resume, the failed
  open, the .dormant class, the visibility bypass and the dismiss.
- docs/session-restore.md and docs/settings.md describe Restore on click,
  including the empty grid and no selected session at launch.
@abate
abate force-pushed the feat/lazy-restore branch from 4b8d98e to 7c64d17 Compare October 6, 2026 20:35
@abate

abate commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the second pass. Rebased on current main (b03875b) and addressed everything in 7c64d17:

Blocking

  1. Dormant marker cleared before the open succeeds — dormantWorkingSet.delete and the class removal now sit next to skippedWorkingSetEntries.delete(sessionId), after result.ok. For the ok:false path the open entry stays closed in openSessions, which persistWorkingSet used to filter out with !openSessions.has(...); the filter now keeps a dormant or skipped entry whose open entry is closed, so a failed open does not drop it either.
  2. Refusal case — test/restore-lazy.test.js now has a case where getSessionLiveElsewhere returns a live session and confirm returns false: the session is still dormant, its row still has .dormant, and it is still in the persisted set. A second case covers openTerminal returning ok:false.

Non-blocking

  • Function extraction — the test loads persistWorkingSet, tickRestorePlanner, openSession and dismissDormantSession with loadAppFunctions, next to the real sidebar.js, restore-plan.js and resume-guard.js; only the terminal/PTY edges are stubbed.
  • Getting rid of a dormant entry — a Don't restore item in a dormant session's context menu (dismissDormantSession) removes it from the set and persists, without spawning claude. Shown only on dormant rows.
  • Sidebar — tests render through the real renderProjects and check the .dormant class, that dormant rows are not folded under "older" past the visible count and age limit (while a non-dormant one is), and the dismiss item.
  • Docs — docs/session-restore.md has a "Restore on click" section (empty grid, no session selected, marker kept on a refused or failed open, Don't restore); docs/settings.md lists lazy; the context note and changelog entry are updated.

Each of these mutations turns the suite red: delete back at the top of openSession, the old openSessions.has filter, dormant entries left out of the persist, the lazy branch disabled, the sidebar visibility bypass removed, the .dormant class removed, the dismiss neutered.

@abate
abate requested a review from devsuitup October 6, 2026 20:35
abate added a commit to abate/switchboard that referenced this pull request Oct 6, 2026
Review of devsuitup#441:
- openSession clears the dormant marker only after openTerminal succeeds,
  so a resume refused by guardResume or a failed open keeps the entry
  dormant; persistWorkingSet keeps a dormant or skipped entry whose open
  entry is closed.
- "Don't restore" in a dormant session's context menu removes it from
  the saved set without spawning claude.
- restore-lazy tests load the shipped functions with loadAppFunctions
  next to the real sidebar.js, and cover the refused resume, the failed
  open, the .dormant class, the visibility bypass and the dismiss.
- docs/session-restore.md and docs/settings.md describe Restore on click,
  including the empty grid and no selected session at launch.

@devsuitup devsuitup left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review at 7c64d17, which is now rebased on current main. Thanks for that, and for the earlier changes: the marker is cleared only after a successful open, the refusal case is tested through the shipped code, there is a way to dismiss a dormant entry, the docs and the CHANGELOG are in. Two points still block. Both were reproduced against the shipped code by the reviewer, and I re-checked them by reading.

Blocking

  1. Sessions awaiting the index are lost from the saved set (public/app.js, persistWorkingSet). The loop adds the open sessions plus the skipped and dormant entries. A saved session that the planner has not marked yet is in neither list. If anything persists during the cold index (a click, a close), openWorkingSet is overwritten without it. When the planner later marks it dormant, nothing persists again, so the next restart no longer has it. Fix: keep the saved entries that are not yet resolved when persisting, or persist again after the planner marks them. Test: persist while a saved entry is still unindexed, then check the stored set.
  2. A renderer reload opens the remembered active session before the lazy check (public/app.js, the loadProjects().then block). openSession(session, undefined, { automatic: true }) runs for activeSessionId regardless of the mode, so a reload starts a session without a click, including one that had exited.

Non-blocking

  • Deleting a dormant session does not clear its dormant entry, so its saved record stays: only dismissDormantSession and openSession remove it from dormantWorkingSet.
  • The age-based project collapse still hides dormant rows (public/sidebar.js).
  • Dormant rows cannot be selected or opened with the existing keyboard navigation (public/sidebar.js, public/grid-view.js).

87 existing tests pass locally on Node 24. Not verified: Node 20/22 with c8.

@jbr-sekoia

Copy link
Copy Markdown
Collaborator

Tested on Linux (Ubuntu, GNOME on Wayland), PR head 7c64d17.

Setup. An isolated instance, with a throwaway HOME and its own data directory. The fixture is one git project with four synthetic transcripts. A stub claude on PATH logs its argv and stays alive.

Working

  • Restore on click, then quit and relaunch. Sessions 1–3 were open; the saved set was [1,2,3]. After the relaunch the three rows are dormant (outlined dot), claude is started 0 times, and nothing is selected.
  • A click opens one session. A click on session 2 runs exactly one claude --resume <2>. Sessions 1 and 3 stay dormant, and the set stays [1,2*,3].
  • Don't restore. In the context menu, "Don't restore" on session 3 removes it from the set ([1,2*]) without starting it. After the next relaunch, 1 and 2 are dormant and 3 is not.
  • Tests. test/restore-lazy.test.js: 8/8.

Blocking: "sessions awaiting the index are lost from the saved set." Reproduces.

  • The settings were kept and the session cache wiped. The scan worker was held cold by a FIFO standing in for a transcript, so the saved sessions were not indexed yet.
  • A new session started from "+" during the cold index changed the stored set from [1,2*] to [<new>*].
  • Once indexing finished, 1 and 2 showed as dormant, but the stored set stayed [<new>*], both while running and after quit. At the next launch, 1 and 2 were gone.

Blocking: "a renderer reload opens the remembered active session." Reproduces. Session 1 was opened from its dormant row, then ended with Ctrl-C. location.reload() then ran claude --resume <1> with no click. Session 2 stayed dormant.

Seen alongside, not caused by this PR. While the scan worker is blocked, quitting hangs in main. The test FIFO is a forced condition, not a real large history.

abate added a commit to abate/switchboard that referenced this pull request Oct 8, 2026
Review of devsuitup#441:
- openSession clears the dormant marker only after openTerminal succeeds,
  so a resume refused by guardResume or a failed open keeps the entry
  dormant; persistWorkingSet keeps a dormant or skipped entry whose open
  entry is closed.
- "Don't restore" in a dormant session's context menu removes it from
  the saved set without spawning claude.
- restore-lazy tests load the shipped functions with loadAppFunctions
  next to the real sidebar.js, and cover the refused resume, the failed
  open, the .dormant class, the visibility bypass and the dismiss.
- docs/session-restore.md and docs/settings.md describe Restore on click,
  including the empty grid and no selected session at launch.
@abate
abate force-pushed the feat/lazy-restore branch from 7c64d17 to f4bd11b Compare October 8, 2026 09:26
@abate

abate commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks, and thanks @jbr-sekoia for reproducing both points. Rebased on current main (eaf59ff) and addressed in f4bd11b.

Blocking

  1. Sessions awaiting the index lost from the saved set.
    • Main now has pendingRestoreEntries(), which keeps the planner's unresolved entries when persisting. The rebase merges it with the dormant entries: persistWorkingSet holds skipped, dormant and pending entries. In lazy mode the planner keeps a saved session in remaining until it marks it, so a persist during the cold index writes it back at its saved place.
    • New test in test/restore-lazy.test.js, through the shipped code. During a cold index with only a indexed, a new session is opened and the set persisted; the stored set is [a, x, b, n1]. Once x and b are marked it is the same. Without the pending entries it is [a, n1].
  2. A renderer reload opens the remembered active session.
    • The reload step is now reopenActiveSessionAfterReload(). In lazy mode it reopens activeSessionId only when getActiveSessions lists its PTY, so a reload can reattach but never spawns. That covers an exited session, and one never clicked. The other modes are unchanged.
    • Tested in lazy mode with no live PTY (nothing opened), in lazy mode with a live PTY (reattached), and in auto mode (opened as before).

Non-blocking

  • Deleting a dormant session: the delete handler now calls dismissDormantSession, so the entry leaves the saved set. Tested.
  • Age-based collapse: after marking, revealDormantSessions (sidebar.js) un-collapses the project, worktree and slug groups holding a dormant row. It does this after the render, because the first render already applied the age collapse and morphdom keeps a header's collapsed state from then on. A test renders the old project, collapsed, then marks it and checks it stays open across a further render.
  • Keyboard navigation: outside the grid, navigateSession (grid-view.js) includes dormant rows and opens the one it lands on, a deliberate key press like a click. The grid has no card for a dormant session, so it still skips them. Both are tested.

Changed upstream tests. These read the reload path and persistWorkingSet:

  • test/resume-guard.test.js now checks the call to reopenActiveSessionAfterReload and its { automatic: true } open.
  • test/restore-pending-persist.test.js declares dormantWorkingSet.

Docs: docs/session-restore.md ("Restore on click") and .ai/contexts/session-cache.md cover reload, pending entries, delete, collapse and the keys.

Each fix was removed in turn, and each removal fails a test. test/restore-lazy.test.js is 13/13; the restore and resume suites are 63/63. On the full suite, the only failures are the CLI-state canary, the test-pr tests and four sandbox-wrapper tests. All of these fail the same way on main in my sandboxed environment; the sandbox-wrapper ones are fixed in #483.

@abate
abate requested a review from devsuitup October 8, 2026 09:27

@devsuitup devsuitup left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review at f4bd11b. Thanks: pending-index persistence, the already-exited reload, deleting a dormant session, the initial age collapse and keyboard navigation are fixed, and the changelog ends with (#441). The branch is level with main and its interplay with #485 and #486 holds. 160 tests in 13 related files pass locally on Node 24. One point still blocks.

Blocking

  1. A PTY that exits after the live snapshot can be spawned again without a click (public/app.js, reopenActiveSessionAfterReload ~277-280). In lazy mode the function reads getActiveSessions() once, finds the session live, then calls openSession(session, undefined, { automatic: true }). If the PTY exits between the snapshot and the open, openSession starts a new claude --resume on its own, which is exactly what the lazy mode promises not to do. The test checks the snapshot, not the race. Enforce it where the spawn happens: make the automatic open reattach-only in the main process (refuse to spawn when there is no live PTY), and return a result the renderer handles. Test: snapshot says live, the PTY is gone at open time, and nothing is spawned.

Non-blocking

  • After the planner settles, a recreated project or worktree group can age-collapse its dormant rows again (public/app.js ~327, public/sidebar.js ~981).
  • Design comments in test/restore-lazy.test.js (~4) and public/grid-view.js (~555) go beyond the one-line pointer allowed in code.

Not run: Linux Node 20/22 with c8, lint, Electron and live-process scenarios, fresh CI. CI on this head is not approved yet.

@jbr-sekoia jbr-sekoia left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review at f4bd11b, rebased on current main (eaf59ff). Both blocking points of the last review and both issues reproduced on Linux are resolved:

  • Sessions awaiting the index. persistWorkingSet now holds pendingRestoreEntries() next to the dormant ones, and in lazy mode the planner ignores sessionOpenedOutsideRestore, so a session opened from "+" during the cold index no longer settles the planner and drops the rest. Removing either of these fails test/restore-lazy.test.js ("saved sessions not indexed yet…"; the first removal also fails four tests in test/restore-pending-persist.test.js).
  • Renderer reload. reopenActiveSessionAfterReload reopens activeSessionId in lazy mode only when get-active-sessions lists it, and that list excludes exited PTYs, so the Ctrl-C-then-reload case no longer spawns. Dropping the lazy check, or never reattaching, fails the reload test.
  • The three non-blocking points (delete, age collapse, keyboard navigation) are addressed, and each change fails a test when it is reverted.

Mutation check: 20 single inversions run against the restore, resume and open-session suites (108 tests). 15 turn them red. The 5 that stay green are listed under Non-blocking. CI is green on f4bd11b.

Blocking

  1. The previous/next session keys can resume the same dormant session several times (public/grid-view.js, navigateSession). navigateSession calls openSession on a dormant row without awaiting it, and the handler does not filter e.repeat. activeSessionId changes only when showSession runs, after guardResume and openTerminal return. Until then, each further press computes the same next and calls openSession for it again. openSession checks openSessions.has before its first await, so every call goes through. Scenario:

    • x is open and b, the next row, is dormant;
    • the user presses Ctrl+Shift+] twice quickly, or holds it down;
    • openTerminal is called for b twice. Observed in the restore-lazy harness: openTerminal calls ["x","b","b"].

    In main, open-terminal awaits startMcpServer before activeSessions.set(sessionId, …) (main.js, on by default). The second request can therefore pass the activeSessions.has reattach check, so two claude --resume b run against one transcript and the first PTY is no longer tracked. This main-side part was traced by reading, not run. A double click on a sidebar row could already do this on main; the held key makes it a one-key action, and it lands on a feature whose promise is that nothing starts without a deliberate action. Fix: keep a set of sessions being opened, checked and filled at the top of openSession and cleared in a finally, or at least ignore navigateSession while the target has an open in flight. Test: two navigateSession(1) without awaiting, then one openTerminal call.

Non-blocking

  • A reload marks sessions that are still running as dormant (public/app.js, tickRestorePlanner). After a reload openSessions is empty apart from the reattached active session. The planner therefore marks every other saved session dormant, even when its PTY still runs, and the row shows the "not started" outline. A click reattaches, so nothing spawns. But Don't restore on such a row removes the session from the set while its claude keeps running. Skipping the ids that get-active-sessions lists (or reattaching them) would match the docs.
  • Untested branches. Each of these mutations leaves the suite green:
    • hasDormant = false for slug groups (public/sidebar.js), so a dormant session inside an old slug group is folded under "+ N older";
    • dropping the slug-group branch of revealDormantSessions;
    • dropping the worktree-sessions branch of revealDormantSessions;
    • dropping the .dormant class removal in openSession.
  • Vacuous assertion (test/restore-lazy.test.js, "Don't restore"). The final check that a non-dormant row has no Don't restore item passes even when the item is shown on every row (if (true)). After the re-render, n1 has no oncontextmenu, so no menu opens at all. Assert that the menu exists before checking its items.

Lint: 0 errors. Not verified: a live run of the navigation race, and Node 22 with c8.

abate added a commit to abate/switchboard that referenced this pull request Oct 9, 2026
Review of devsuitup#441:
- openSession clears the dormant marker only after openTerminal succeeds,
  so a resume refused by guardResume or a failed open keeps the entry
  dormant; persistWorkingSet keeps a dormant or skipped entry whose open
  entry is closed.
- "Don't restore" in a dormant session's context menu removes it from
  the saved set without spawning claude.
- restore-lazy tests load the shipped functions with loadAppFunctions
  next to the real sidebar.js, and cover the refused resume, the failed
  open, the .dormant class, the visibility bypass and the dismiss.
- docs/session-restore.md and docs/settings.md describe Restore on click,
  including the empty grid and no selected session at launch.
@abate
abate force-pushed the feat/lazy-restore branch from f4bd11b to d8b210b Compare October 9, 2026 14:07
@abate

abate commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks to both of you. Rebased on 805d064, the parent of #499: my token cannot push a branch that contains its workflow change. The branch merges cleanly with current main (75ab0ad), including #501's scheduled slug groups in sidebar.js. The fixes are in d8b210b.

Blocking

  1. A PTY that exits after the live snapshot (devsuitup).
    • The refusal is now in main. open-terminal takes reattachOnly in the session options. When no live PTY is registered for the id, it answers { ok: false, notLive: true } right after the reattach branch, before the remote attach and before anything is spawned. A PTY's exit removes it from activeSessions in the same tick as exited is set, so a PTY that is gone by the time of the open is refused.
    • In lazy mode, reopenActiveSessionAfterReload opens with reattachOnly. openSession handles notLive by dropping the terminal entry and returning false, and the session is then marked dormant like the others.
    • Tests:
      • the snapshot lists the PTY but the open answers notLive: the request carries reattachOnly, no terminal is left, and the next planner tick marks the session dormant;
      • main is pinned in source: the refusal sits after the live-PTY reattach and before the remote attach and the spawn. main.js cannot be loaded under node, which is why the main side is a source check, as in open-terminal-attach.test.js.
  2. The previous/next keys resume the same dormant session twice (jbr-sekoia).
    • openSession keeps openingSessions. It is checked and filled at the top, before the first await, and cleared in a finally. A second call for a session already being opened returns without doing anything. That covers a held key, a double click on a row, and a click during a restore.
    • Test: two navigateSession(1) calls without awaiting give one openTerminal for the target. Removing the check, or the finally, fails a test.

Non-blocking

  • A reload marked running sessions dormant. In lazy mode, tickRestorePlanner reads getActiveSessions once. A candidate whose PTY is listed is opened with reattachOnly and not marked; if the reattach is refused because the PTY has just exited, it is marked as before. The selected session stays selected. So Don't restore is no longer offered on a session that is still running. Tests: a running saved session is reattached, not marked, and the selection is kept; one that exits before the reattach is marked.
  • A group created after the marking folded its dormant rows again. revealDormantSessions now walks the .dormant rows of a given root. renderProjects runs it on the tree it builds, before morphdom, so a project, worktree or slug group created after the marking is open. The run on the live sidebar after marking stays, because morphdom keeps an existing header's collapsed state. Test: render without the project, then with it again; the project header, the worktree header and the slug group are open.
  • The four surviving mutants now fail tests:
    • hasDormant = false for slug groups: the slug group is put under "+ N older";
    • the slug-group branch and the worktree branch of revealDormantSessions: a dormant row in a folded slug group and in a worktree collapsed for its age;
    • the .dormant removal in openSession: the opening test asserts the row loses its mark before any render.
  • The "Don't restore" assertion. The row that is not dormant is now right-clicked first. The test asserts that its menu opens and has no Don't restore item. In the harness, a row binds its handlers only when the session is in sessionMap, which is why no menu opened before. With if (true) the test now fails.
  • Comments. The header of test/restore-lazy.test.js and the comments in grid-view.js are one-line pointers now.

Other changes

  • Four harnesses that load openSession declare openingSessions: open-session-terminal, process-exit-status, restore-live-elsewhere and schedule-live-resume. The last one also declares dormantWorkingSet.
  • docs/session-restore.md (the reload bullet) and session-cache.md ("Restore on click") describe the reattach-only open, the in-flight guard and the two reveal passes.

Testing

  • Mutation check: each fix was removed in turn, 15 mutants in all, and each removal fails at least one test. They include:
    • the main refusal;
    • passing reattachOnly;
    • the notLive handling;
    • the lazy reload path;
    • the in-flight check and its finally;
    • the planner's reattach and its reattachOnly;
    • keeping the selection;
    • the build-time reveal.
  • test/restore-lazy.test.js has 20 tests.
  • Full suite: 4865 pass and 17 fail. The failures are the CLI-state canary, four sandbox-wrapper tests that need (sandbox): opt-in writable skills and agents #483, and twelve test-pr tests. The twelve fail the same way on upstream/main in my sandboxed environment. Lint: 0 errors.
  • Not run: Electron, a live reload with exiting PTYs, Node 20 or 24, c8.

@abate
abate requested a review from devsuitup October 9, 2026 14:07

@devsuitup devsuitup left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review at d8b210b. Thanks for this round: both blocking points from the last reviews and all the non-blocking ones are addressed, and CI is green on every job. Merged into current main (75ab0ad) in a scratch copy there is no textual conflict, and 255 tests across the restore, resume, open-session, sidebar, grid and restore-plan files pass on the result. Removing any of 11 of the PR's lines one at a time turns a test red.

Previous findings

  • PTY exiting after the live snapshot: resolved. main.js:2559 refuses a reattachOnly open after the live-PTY reattach branch, before the remote attach and any spawn; get-active-sessions and the exit handler (~2402-2425) read the same activeSessions; the renderer passes the flag (public/app.js:279, :338, :1412) and handles notLive (:1424).
  • Same dormant session resumed twice by prev/next: resolved, openingSessions (public/app.js:1380-1447) checked before the first await and cleared in finally.
  • Reload marking running sessions dormant: resolved (public/app.js:333-342).
  • Group recreated after marking: resolved, revealDormantSessions(newSidebar) before morphdom (public/sidebar.js:1031).
  • The four surviving mutants: resolved. Vacuous "Don't restore" assertion: resolved (test/restore-lazy.test.js:230-231). Design comments: resolved.

Major

  1. The CHANGELOG entry lands in the v0.0.90 section once merged. Based on 805d064, the (#441) line ends up under ## v0.0.90 — 2026-10-09 → ### New in the merge, and the changelog check stays green. Since the rebase is blocked by #499's workflow change on your side, we can move it under ## Unreleased by a maintainer edit on the branch or at merge — tell us which you prefer.
  2. Holding the prev/next key resumes one dormant session after another (public/grid-view.js:567-568, handler :642). Once an open finishes, activeSessionId has moved, so the next auto-repeat keydown resumes the following dormant row, and the list wraps. Holding Ctrl+Shift+] for a couple of seconds with 15 saved sessions in Restore on click starts several claude --resume, the cost this mode exists to avoid. Pass e.repeat from handleSessionNavKey to navigateSession and do not resume a dormant target on a repeat; test: two navigateSession(1, { repeat: true }) across dormant rows, zero openTerminal calls.
  3. The in-flight guard also fixes a bug on main, which needs its own regression. Today a double click on a sidebar row calls openSession twice (public/sidebar.js:1433-1439) and both pass the openSessions.has check before the first await, so two open-terminal requests go out, in every restore mode. The new test covers it only through navigateSession. Please add a test through the row's click handler (two clicks, one openTerminal). For bug fixes we ask for the regression test pushed alone first and seen failing in CI, then the fix; splitting the guard and its click test into a small PR would satisfy that and make the fix easy to find later.

Minor
4. public/app.js:1385: a duplicate call returns undefined, which runRestore and the lazy loop (:338, !== false) read as success. In auto mode, a click on a saved session still awaiting getSessionLiveElsewhere, then runRestore reaching it, then a declined "live elsewhere" confirm leaves it neither open nor skipped, so the next persist drops it. Keep openingSessions as a Map of id → promise and return the in-flight promise.
5. public/app.js:333-341: the lazy branch does not hold its candidates while it awaits reattaches (tick() already removed them, public/restore-plan.js:61-62), so a persist during one of the awaits (Don't restore, delete, or #479's exit flush) writes a set without the unprocessed candidates, and the branch never persists at the end. Put them in restoreInFlight for the loop, or persist at the end.
6. public/grid-view.js:568: navigateSession drops the openSession promise, so a throwing openTerminal is an unhandled rejection; a logging .catch is enough.

Suggestion
7. The CHANGELOG says each session "is resumed only when you click it", but prev/next also resume; the docs already say so.

Note: #479 conflicts with this branch in persistWorkingSet (the held line), test/restore-pending-persist.test.js and test/schedule-live-resume.test.js, and the two treat a session closed during a restore differently; whichever lands second reconciles.

Not verified: Electron and live-process runs, the held-key scenario live.

Changes requested: 3 major, 3 minor, 1 suggestion.

@devsuitup devsuitup left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of 85ebb24 (rebased on 33e8e10). Verdict: changes requested: 0 blocker, 0 major, 2 minor.

CI on 85ebb24: all green (Test 38550846930, Build & Release 38550846911). Locally (Node 22): 19 related files, 289/289 pass; restore-lazy.test.js 51/51 on 85ebb24, and the 4 new tests fail on assertions with 9d9c462's app.js.

Previous points: both closed

  • The tick no longer counts its own in-flight candidate as open (public/app.js:486). The new tests fail on 9d9c462 and pass on 85ebb24.
  • A second click or the session keys no longer clear the row before the member's open settles (public/app.js:1728-1740). Same red/green evidence.

Findings

1. MINOR, confirmed by probe: a saved, still-running member is dropped while the row head's open is in flight. public/app.js:484-485 with :1734.

  • The tick treats the reattach as done whenever openSession(...) does not return false. Two other outcomes return undefined:
    • the early return at :1734 when the awaited open fails;
    • a reattach that ends with ok: false without notLive.
  • Production rows carry headSessionId (session-cache.js:656,683), so rowOpenId on a member also looks at the head. setupMerged in the tests does not set it, so the suite cannot see this.
  • Scenario:
    1. Row with members [B, C], head B, saved [B, C, x]. Only C's terminal is still running after a reload.
    2. The user clicks the row during getActiveSessions, which starts resuming B.
    3. The tick's reattach of C awaits B's open.
    4. B fails and the call returns undefined.
    • Result: saved becomes [B, x] and C is lost.
    • If B opens instead, C's terminal is never reattached. That behaviour predates this round.
  • Suggested fix:
    • reattached = result !== false && openSessions.get(id) && !openSessions.get(id).closed, so that C falls through to the existing branches: clear it when the row is open, mark it otherwise.
    • Add headSessionId to setupMerged, or a case that sets it.
    • Add a test with a running non-head member while the head is opening.

2. MINOR, confirmed by probe: an open the user starts during the marking is undone at the end of the tick. public/app.js:477, :501.

  • The lazy branch saves shown = activeSessionId before its awaits. It calls showSession(shown) afterwards whenever the active session changed. It cannot tell its own reattach-only showSession calls from the user's click.
  • Since 85ebb24 also waits for the user's open in finding 1's scenario, the user's session opens before the tick ends and is then hidden again.
  • Probe, with n1 open and active before the tick:
    • Plain [a, x, b] with b running, and the user opens a during the marking: calls ["a","b"], active session afterwards n1.
    • Finding 1's row with the head opening and succeeding: active session afterwards n1.
  • Suggested fix:
    • Do not let automatic reattaches change the active session, instead of restoring it afterwards: pass activate: false to the tick's reattach-only openSession, and make the early return at :1741-1743 respect activate.
    • Or skip the restore when a user open ran during the tick.

Checked, no finding

  • A click that ends without opening: this happens only when the awaited open was the user's own open of the same row and it failed, and that open has already shown its error.
  • Double claude --resume: none found. The exception branch registers in openingSessions before resuming (:1737) and goes through guardResume with automatic: false.
  • Rebase: git diff 33e8e10 prh/441 -- public/app.js removes no main behaviour.
    • #600's keepAttached/bg held rule is kept.
    • #608's openingActive and restoreSessionId code is untouched.
    • The context menu keeps Send to background.
    • The row click still passes the head first (#564).
  • Hygiene: no t.waitFor. The CHANGELOG entry is under ## Unreleased.

abate added 18 commits October 15, 2026 08:48
Restoring the working set resumes a claude process per session at
startup, which costs a lot of memory. The new lazy mode marks last
run's sessions in the sidebar (outlined dot, always visible) and resumes
each one only when it is opened. Unopened sessions stay in the saved
working set across restarts; "Don't restore" drops one without starting
it. A reload reattaches only to a PTY that still runs and never spawns.
…while reattaching

A held previous/next key resumed one dormant session after another: an
auto-repeat now cycles the open sessions only. The lazy branch keeps its
candidates in restoreInFlight while it awaits the reattaches, so a
persist in that window keeps them, and a failed open from the keyboard
is logged instead of left as an unhandled rejection. The changelog
entry moves under Unreleased and names the keys.
…rking, held keys

A dormant session whose conversation continues under another id must not stay
marked under the old id; a session opened while the marking awaits a reattach
must not be marked; a click during a reattach-only open of the same id must
resume it; the session keys must pass e.repeat; a reattach-only open must not
follow a continuation.
…ions opened during the marking

A click resolving a dormant session to another id moved the saved index and
the skipped entry but not the dormant one, so the old id came back marked on
every start. The marking now skips a candidate the user opened while it
awaited a reattach, and reattach-only opens no longer follow a continuation.
…now on main

A fresh entry (a session cleared by /clear, no transcript) has no row to
click, so the marking drops it. Test harnesses declare the state the merged
exit flush and /clear restore read.
…, and skip a candidate opened under its continuation

The remap moved the dormant entry onto the continuation id, and the
already-open branch returned before deleting it, so a closed continuation
came back at every start. The remap also leaves restoreInFlight, so the
marking no longer marks a session the user opened under a continuation.
… and a save during the marking writes each session once
…t until that open settles

A click that resolves a candidate the marking has not reached to a
continuation used to drop it from restoreInFlight at once, so a refused or
failed continuation left it neither dormant nor saved. In Restore on click
mode the entry now moves to the continuation id and, when the open settles,
becomes dormant there unless the continuation opened. The marking also takes
a candidate out of restoreInFlight once it is dormant, so a save during a
later reattach writes it once. Other modes keep main's handling.
… the held entries and the continuation settle

A saved member of a merged row has no sessionMap entry of its own: the
marking, the row click, the session keys, "Don't restore", Delete and the
slug-group fold must all find it through its row.
…ession row

The marking, the reattach and the session keys resolve a member id through
its row (indexedSessionById). A row click resumes the row's dormant member,
and opening or showing any member clears every dormant id of the row, since
the row is then open. Row marks go through sessionItemEl and follow every id
of the row. "Don't restore", Delete and the slug-group fold use rowSessionIds.
The changelog entry moves to Unreleased.
… member, whatever the saved order

After a reload, a later saved member of a row whose other member was
reattached is still marked, and the session keys then resume it beside
the running one. The result also depends on the saved order.
…ked, and the session keys show that member

The marking now clears a candidate whose row already has an open member,
so the result no longer depends on the saved order, and navigateSession
opens the whole row for such a member instead of resuming it beside the
running one. indexedRowOf maps a member to its row through
localMergedMemberRows instead of scanning sessionMap.
…s in flight, and a later member clears the members marked earlier
… still in flight

A member opened during the marking counted as open before its openTerminal
settled, so a failed open lost the other saved members of its row.
…d open of its row, keeps the saved sessions when it fails
… or by a second open of its row

The marking now marks a candidate whose open is still in flight, so a failed
open leaves it saved; the open's own clearDormantRow drops it on success.
openSessionNow waits for an in-flight open of the row's member before clearing
the row, and resumes that member when the open it waited for was an automatic
reattach that found no PTY.
@abate
abate force-pushed the feat/lazy-restore branch from 85ebb24 to e52abdf Compare October 15, 2026 06:55
@devsuitup

Copy link
Copy Markdown
Owner

Checked e52abdf (rebase onto 1168203). Thanks for keeping up with main.

Rebase: clean. git range-diff 33e8e10...85ebb24 1168203...e52abdf maps one to one (b87f268 = 9d9c462, c499bc4 = bccf05c, e52abdf = 85ebb24 plus a currentOpenId() wrapper keeping main's customOptions?.type === 'attach' branch). Nothing merged on main is dropped: the #600/#546 keepAttached rule, restoreInFlight, openingActive and restoreSessionId (#608) are intact. 41 restore-related test files pass locally on Node 22 (732 tests); CI is green on all jobs.

Still open — the two MINORs from the 85ebb24 review, both reproduced again at e52abdf:

  1. A saved member that is still running is dropped while the row head's open is in flight. public/app.js:484-485 counts openSession(...) !== false as reattached, and openSessionNow's early return (:1804-1808) returns undefined. Probe: saved [B,C,x], members [B,C], headSessionId: 'B', only C has a live PTY; the row is clicked during getActiveSessions, B's open is held then fails → dormant and saved ["B","x"], C neither reattached, dormant nor saved. setupMerged builds rows without headSessionId, so the suite cannot see it. Suggested: reattached = result !== false && openSessions.get(id) && !openSessions.get(id).closed, plus a case with headSessionId set.
  2. An open the user starts during the marking is undone at the end of the tick: :477 saves shown and :501 shows it again. Probe: n1 open and active, the user opens a during getActiveSessions, b live → openTerminal for ["n1","a","b"] and the active session ends on n1, hiding the user's tab. Suggested: activate: false on the tick's reattach-only openSession with the early return honouring it, or skip the restore when a user open happened during the tick.

Verdict: changes requested (0 blocker, 0 major, 2 minor).

abate added 2 commits October 15, 2026 09:41
…open is kept when it fails, and a session opened during the marking stays active

Production rows carry headSessionId, so a member's reattach looks at the head's open; setupMerged can now set it.
…, and count only a session left open

A reattach that returned early after another member's open failed was taken as done, dropping a running member from the saved set; and restoring the active session afterwards hid a session the user opened during the marking.

@devsuitup devsuitup left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked 9ede0f2. Both MINORs from the 85ebb24 review are fixed.

  • Saved running member while the head's open is in flight: row [B,C] with headSessionId: 'B', saved [B,C,x], only C live, row clicked during getActiveSessions. B fails: e52abdf saved [B,x], 9ede0f2 keeps [B,C,x]. Test 50 is red with e52abdf's app.js and green here; removing the open check at public/app.js:485 turns it red again.
  • User open during the marking: the active session ends on the user's a (was n1). Test 51 is red on e52abdf, green here; removing activate: false fails tests 16 and 51.
  • restore-lazy.test.js 53/53; the 41 restore-related test files pass locally on Node 22 (734 tests); CI green on all jobs. No t.waitFor; the changelog diff is only the (#441) line.

Nit, not blocking: the mayShow() guard on openSessionNow's two early returns (:1817, :1861) has no test. Reverting both to a plain showSession(...) keeps every test green, yet the case is reachable: during the marking the user opens b (PTY live) then switches to n1; the tick's reattach of b hits the already-open return. Here the active session stays n1; with the guard reverted it becomes b. A test for it would lock that in.

Verdict: approve.

@abate

abate commented Oct 15, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased on main 1168203 (backup of the reviewed head: backup-441-85ebb24). Two conflicts, both kept from each side: sidebar.js buildSessionItem (main's dataset.headSessionId and this PR's .dormant line); openSessionNow's first openId (main's attach-only rule from #641 now feeds both the initial and the settled check). Rebased head e52abdf: Test 38695338164, Build & Release 38695338092 green.

Tests 665059b pushed alone: Test 38701105223 red on assertions, only the two new tests (Build & Release 38701105143 cancelled). Fix 9ede0f2: Test 38702167436, Build & Release 38702167380 green.

1. Running member dropped while the head's open is in flight. The tick counts a reattach only when openSessions then has that id not closed; any other result falls through to the clear/mark branches. setupMerged takes head (sets headSessionId on the row and the index); new test: row [B, C], head B, saved [B, C, x], C running, row clicked during getActiveSessions. B fails: C marked, saved [B, C, x]. B opens: C cleared (row open), saved [B, x]; C's own PTY is still not reattached (as before this round).

2. User's open undone at the end of the tick. The tick's reattaches pass activate: false; openSessionNow's two early returns (open row member, already-open session) now apply the same activate/activeFrom rule as its end, so no tick open changes the active session, and the shown save/restore is removed (its only purpose was undoing those reattaches' showSession). Tests: plain [a, x, b] with b running, n1 active, user opens a during the marking: calls [a, b], active a. Row case above: active B both ways.

Docs: docs/session-restore.md (reload bullet), .ai/contexts/session-cache.md (Restore on click).

@abate
abate requested a review from devsuitup October 15, 2026 09:00
abate added a commit to abate/switchboard that referenced this pull request Oct 15, 2026
@devsuitup
devsuitup dismissed jbr-sekoia’s stale review October 15, 2026 09:27

Superseded: the findings from f4bd11b were addressed in later heads; 9ede0f2 approved after review.

@devsuitup
devsuitup merged commit 76edc5c into devsuitup:main Oct 15, 2026
12 checks passed
@abate
abate deleted the feat/lazy-restore branch October 15, 2026 21:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants