Skip to content

fix(web): open a saved session in milliseconds; New session dialog with new workspace + worktree - #947

Merged
ericleepi314 merged 11 commits into
mainfrom
fix/web-session-load-and-new-workspace
Sep 21, 2026
Merged

ericleepi314 merged 11 commits into
mainfrom
fix/web-session-load-and-new-workspace

Conversation

@ericleepi314

@ericleepi314 ericleepi314 commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Why

Clicking a saved session in the web sidebar took ~45 s on this repo (vs. instant in deepseek-harness). Measured with timing wrappers around the gateway:

step before after
transcript visible after click (1.8 MB session) 48 s 85–150 ms
runtime attach (session.resume) 44 s 0.3 s warm / 1.1 s cold
repeat click on the same row 44 s (new runtime each time) 1 ms (reused)
projects.tree (sidebar refresh, after every navigation) 3.7 s 0.1 s (1.2 s cold)
system-prompt workspace walk 23 s 0.1 s

Root cause. session.resume spawns a runtime before replying, and the runtime's system prompt counts files with Path.rglob over the whole workspace — node_modules included (366k directories here) — filtering only afterwards. The prompt is built twice per resume (spawn, then _do_resume → _rebuild_system_prompt, the second on the event loop), so every click paid ~2 × 23 s. Secondary: projects.tree re-parsed all ~4,200 saved session files and ran a git probe per distinct cwd (~2,700) on every rebuild, i.e. after each navigation and each turn end. And the "already live" check keyed on runtime id, so a row the server had already replayed spawned another runtime on every click and left the previous one running.

deepseek-harness opens a session by reading a cold history page without activating an agent; the desktop client here already does the equivalent (REST prefetch + session.resume with omit_messages). The web client now does the same.

What changed

Server

  • workspace_snapshot.py: one bounded, breadth-first scandir walk that prunes vendored/generated directories before entering them, never follows symlinks, and stops at a directory/entry budget; capped counts render as N+ (partial scan) (WorkspaceSnapshot.counts_partial).
  • session.history (new): a saved transcript read cold — no runtime spawned or asked. Prefers the record of a runtime already replaying the row (it holds the later turns). Reports info.running for a live runtime.
  • session.resume: reuses the runtime already replaying a row (DesktopSession.stored_id), subscribes the resuming socket to it, and a concurrent resume of the same row waits on the in-flight attach (attached event) instead of spawning twice. Messages come from the live runtime's own record when there is one; info.running says whether it is mid-turn. A refused replay is not reused.
  • session.create: create_dir (mkdir -p a new workspace; absolute paths only, ~ expands) and worktree (run in a fresh .clawcodex/worktrees/<name> checkout — the CLI's --worktree; left in place when the session ends). Validation applies only with those flags; a plain cwd passes through as before. Worktree on a folder that does not exist yet is refused before anything is created.
  • session.close: if_idle refuses (closed: false, with a reason) while another window or desktop tile still holds the runtime (each socket that opened it holds it until it lets go or disconnects), while a turn runs or an approval/question is pending, or while the agent's own get_activity control (new: /goal continuation, /loop/cron job or wakeup, queued prompt, running background shell) says busy — re-checked after the agent answers. The reply lands before the teardown (SessionEnd hooks, worker join) and every window on the runtime gets session.closed.
  • A runtime whose agent stream ends retires itself (unregistered, session.closed to every window, teardown scheduled) instead of being handed back by the reuse lookup; an attach whose runtime died is refused rather than handing out a dead id. A dying runtime answers its waiting control queries with "no reply" instead of cancelling them (a cancelled future used to unwind the RPC handler and drop that window's socket).
  • Gateway dispatch: session.history and projects.tree (pure reads) are served beside the socket's other calls instead of queueing behind an attach or a teardown.
  • projects.tree: sidebar rows cached on (mtime_ns, size, inode) per file; git probes memoized across rebuilds (ProbeCache on DesktopServeState, TTL'd with a shorter negative TTL, invalidated on worktree creation) and run in a small pool when cold; non-existent cwds skip git.

Web client

  • A row click renders session.history at once (title, workspace, model chip, nodes, trajectory timings), highlights the row immediately, then attaches the runtime behind it; the composer shows Connecting the agent… and a prompt sent meanwhile waits for the attach rather than starting a session of its own. A runtime handed back mid-turn is adopted as running. Navigation epochs guard late replies. Backends without session.history get the one-call resume as before.
  • Leaving a session that was only looked at releases its runtime (session.close + if_idle); a session a prompt was sent to (or an ask answered in) stays up, and "used" survives a reload with the remembered session. A prompt to a runtime the backend no longer has (closed elsewhere, backend restart) reconnects the conversation and sends again; session.closed drops the runtime id so the next prompt reconnects.
  • New session (sidebar button, brand mark, ⌘⇧N) opens a dialog: pick a known workspace (repos and their worktree lanes) or Create new workspace… with an absolute folder path (created if missing), plus a Worktree switch (Isolate this session in its own git worktree). Refusals stay in the dialog.

Verification

  • pytest: full suite green locally (10.3k tests); tests/server/ + tests/test_workspace_snapshot.py (new) cover history, reuse (incl. two sockets, concurrent, refused replay), if_idle (mid-turn, pending approval, agent-reported activity), non-blocking close, running flags, create options, probe/row caches.
  • vitest: 683 tests green (two-step open, attach wait, legacy fallback, release rules, lost-runtime recovery, session.closed, overlapping opens, used-across-reload, dialog, sidebar); tsc clean.
  • Browser (gstack browse against clawcodex serve + built ui-web/dist): 1.8 MB session visible in 150 ms with the attaching hint, no console errors; dialog creates a worktree in a scratch repo and lands the session in it; a non-git folder with Worktree on shows the git refusal inside the dialog, off creates the folder; after browsing two sessions session.active_list shows one live runtime.
  • Five critic review rounds applied (socket subscription on reuse, lost-runtime recovery, mid-turn adoption, agent-aware idle test, non-blocking close, concurrent cold reads, stale-attach release that spares the row on screen, holders per window, dead-runtime retirement, no socket drop on shutdown).

Follow-ups (not in this PR)

  • A send() retry after "unknown session" goes text-only: images attached to the first attempt lived in the old runtime.
  • A socket reconnect without a reload leaves the window subscribed but not a holder; re-issuing session.resume on reconnect would restore the hold.
  • Pre-existing: a worker crash never reaches the agent's stream-closing sentinel (agent_server.py, after the loop rather than in a finally), leaving a zombie with a stuck turn.

Notes

  • The desktop client's session.resume / projects.tree calls are unchanged in shape; it benefits from the caches and the runtime reuse.
  • test (windows-latest) fails on this PR for the same four pre-existing tests it fails on main's recent PR runs (test_nano_bash, test_nano_edit CRLF, test_gateway_questions::test_walk_survives_an_unreadable_directory, test_config_dir_override) — none touched here.

🤖 Generated with Claude Code

ericleepi314 and others added 3 commits September 21, 2026 01:06
…w workspace or worktree

Clicking a sidebar row took ~45 s on this repo. The click waited on
session.resume, which spawns a runtime, and the runtime's system prompt
walked the whole workspace with Path.rglob (node_modules included: 366k
directories, ~23 s) — twice per resume, once at spawn and once when the
stored conversation was loaded. The sidebar tree then re-read every saved
session file and re-probed git for every distinct cwd (~3.7 s) after each
navigation.

Server
- workspace_snapshot: one bounded scandir walk that prunes vendored and
  generated directories before entering them and stops at a directory and
  entry budget; partial counts render as "N+ (partial scan)". 23 s → 0.1 s.
- session.history: a saved transcript read cold, no runtime touched.
- session.resume reuses the runtime already replaying a row (state.sessions
  is keyed by runtime id, so a row's id never matched) and a concurrent
  resume of the same row waits on the in-flight attach instead of spawning.
- session.create takes create_dir (mkdir -p a new workspace) and worktree
  (run in a fresh .clawcodex/worktrees/<name> checkout, the CLI's --worktree).
- projects.tree: sidebar rows cached on (mtime, size); git probes memoized
  across rebuilds with a TTL and run in parallel when cold. 3.7 s → 0.1 s.

Web client
- A row click renders session.history at once (title, workspace, model,
  nodes, trajectory), then attaches the runtime behind it; the composer says
  "Connecting the agent…" and a prompt sent meanwhile waits for the attach.
- Leaving a session releases its runtime when idle (no turn, no pending
  approval or question, nothing queued), so browsing does not accumulate
  agents. Older backends without session.history get the one-call resume.
- New session opens a dialog: pick a known workspace or "Create new
  workspace…" with an absolute folder path, and a Worktree switch to isolate
  the session in its own git worktree. Refusals stay in the dialog.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…he backend refuse a busy close

- session.close takes if_idle: refused (closed: false, reason: busy) while a
  turn runs or an approval/question is pending — decided on the server's own
  knowledge, since a client attaching to a runtime another window drives
  cannot see that it is busy.
- The web client marks a session as used once a prompt is sent or an ask is
  answered; leaving a used session never closes it (loops and scheduled work
  stay up). Leaving one that was only opened releases it, conditionally.
- A new navigation drops the previous attach handle so a prompt never waits
  on an attach that no longer matters; worktree creation invalidates every
  cached worktree list; the snapshot walk is also bounded by entries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…lost one, and keep the idle test honest

Review round: a reused runtime is now subscribed to the resuming socket (a
second window opening the same row saw none of its own turn); a prompt to
a runtime the backend no longer has reconnects the conversation and sends
again, and session.close tells every window (session.closed) so the next
prompt reconnects instead of failing; a runtime handed back mid-turn is
reported and adopted as running.

The idle test for a conditional close now asks the agent too (get_activity:
a /goal continuation, a /loop or cron job, a queued prompt, a background
shell) and counts agent-started turns from their frames; "used" survives a
reload with the remembered session. session.close answers before its
teardown, and session.history / projects.tree are dispatched beside the
socket's other calls, so an open never queues behind an attach or a
teardown. A refused replay is not reused; the attach wait is uncapped; a
worktree on a folder that does not exist yet is refused before the folder
is made; the probe cache reads one clock sample and forgets a negative
answer sooner; the row cache stamp includes the inode; the dialog offers
worktree lanes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Test Results

     5 files   1 020 suites   29m 52s ⏱️
15 939 tests 15 913 ✅ 22 💤 4 ❌
31 881 runs  31 806 ✅ 71 💤 4 ❌

For more details on these failures, see this check.

Results for commit f4ae5c0.

♻️ This comment has been updated with latest results.

ericleepi314 and others added 8 commits September 21, 2026 01:33
…or a worktree

A plain cwd passes through untouched, as before: the runtime is the judge
of a workspace it is merely pointed at, and existing clients (and the
gateway tests, which send /tmp) rely on that — on Windows the new check
refused every one of them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…turn_active

session.resume now reports whether the runtime is mid-turn; the stub that
stands in for a runtime there declares the flag like the real one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ck idle after asking the agent, keep the conversation on a refused create

Second review round:
- A row click that lands while another row's attach (or a slow worktree
  create) is in flight now lets go of the runtime that reply produced, on the
  backend's idle check, instead of leaking it until the server exits.
- session.close re-takes the idle verdict after the agent answers
  get_activity: a prompt from another socket that lands meanwhile keeps the
  runtime. A dead pump clears the busy markers; a runtime that refused to
  start still answers get_activity, so both can be released.
- createSession sends the request first and resets the conversation only
  once the session exists: a refused create (bad path, not a repository)
  leaves the reader where they were, with the reason in the dialog.
- "used" is written explicitly across a reload (never carried onto a fresh
  replay); a reconnect-and-resend marks the session used; session.closed
  also drops a stale approval or question; teardowns are held on the serve
  state and awaited at shutdown; the tree iterates a copy of the live
  sessions; the dialog scrim closes only on a click that started on it.
- Tests made Windows-safe: USERPROFILE beside HOME, git gets the real
  environment, paths compared as Paths.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t asking it

The dead agent cannot answer get_activity; waiting on it read as busy, so
the runtime the pump lost was never releasable by a conditional close.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d; retire a dead runtime; hold a runtime per window

Third review round:
- A stale attach or create lets go of its runtime only when the reply is
  not for the conversation on screen — by runtime id OR by the row it
  replays. The backend hands a later open of the same row the same runtime
  and serves a socket's calls in order, so a close sent for the stale reply
  would have landed after that later open adopted it (A, B, A).
- A runtime whose agent stream ends leaves the registry at once (every
  window told, teardown scheduled) instead of being handed back by the reuse
  lookup; the turn's error end is sent before the retirement so the
  teardown's cancel cannot swallow it. The reuse lookup skips a dead entry.
- Each socket that opens a runtime holds it until it lets go or disconnects;
  a conditional close from one holder is refused (`held`) while another —
  a desktop tile, a second window — still has it. A close of a runtime that
  is already gone says `gone`.
- The session.info republish reports the real turn state.
- A prompt on a saved row whose reconnect fails stays with the reader (no
  blank session is created for it); a slash command marks the session used;
  the create-failure notice fires only for the conversation that asked; a
  failed boot restore leaves no stored row behind.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… instead of dropping the window's socket

shutdown() cancelled every pending control future, and a cancelled future
raises CancelledError inside the RPC handler awaiting it — which unwinds
the gateway's socket loop and closes that window's socket. The shape was
pre-existing through the unconditional close; this branch added two new
triggers (retirement on a dead stream, the conditional close sent on every
navigation), so a second window mid `session.usage`, or the opener itself
mid-attach, lost its socket when a runtime died. Pending queries are now
resolved with None, the answer every caller already degrades on; a query to
a retired runtime answers None at once instead of waiting 30 s; an attach
whose runtime died is refused ("ended while starting") rather than handing
out a dead id; and a resume reply echoes the row the runtime actually
replays, so a refused replay is never kept as the conversation on screen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…harden death during the attach tail

- The stale-reply guard also matches the reply's runtime id against the
  row on screen: runtime X replayed row R and then ran a turn, so rows R
  and X both exist and an open of X answers `stored_session_id: R`; a
  stale reply from X, S, X was judged off-screen and closed the runtime
  the third click had just adopted.
- Adopting keeps the clicked row when it names the runtime's own record,
  so the sidebar highlight and the remembered row stay on the fuller one.
- A runtime whose stream ends during the resume's get_settings round trip
  is refused rather than handed out; a stream that dies before system/init
  fails the attach at once instead of after the control timeout; shutdown
  resolves waiting queries before the agent's own shutdown and marks the
  session dead afterwards.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…; a resume refuses a runtime closed outright meanwhile

Follow-ups from the approving review: the click on the row a used runtime
replayed gets the same runtime back and must not re-adopt it as "only
looked at"; a resume whose runtime another window closed outright during
its tail is refused instead of handing out an unregistered id; a session
is marked dead before its agent's own shutdown so chained queries answer at
once; the attach skips its control queries on a stream that already died;
the pre-init-death test bounds its wait by the control timeout it guards.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ericleepi314
ericleepi314 merged commit 9d6451a into main Sep 21, 2026
6 of 8 checks passed
@ericleepi314
ericleepi314 deleted the fix/web-session-load-and-new-workspace branch September 21, 2026 11:05
@ericleepi314

Copy link
Copy Markdown
Collaborator Author

Deferred follow-ups from the final review rounds (none affect a prompt's routing, a socket, or a working runtime; recorded here so they are not lost):

  • Click row X, then New session, then row X again within one round trip: the third click is dropped by the same-row guard and the window lands on the new session. createSession does not bump the navigation epoch at send time, so a row click during a create's flight cannot be ordered against it.
  • restoreSession's second attempt (the stored-row fallback) runs the idle release with the "used" flag reset, so a used runtime whose turn ended without saving can be released on that path.
  • A socket reconnect without a reload leaves the window subscribed but not a holder; re-issuing session.resume for the current runtime on reconnect would restore the hold.
  • A send() retry after "unknown session" goes text-only: images attached to the first attempt lived in the old runtime.
  • _retire pops the registry entry before broadcasting session.closed and schedules the teardown after it; a server shutdown landing in that await skips that session's SessionEnd hooks.
  • The worktree test's refusal case assumes the temp dir is not inside a git checkout (GIT_CEILING_DIRECTORIES would pin it).
  • Pre-existing: a worker crash never reaches the agent's stream-closing sentinel in agent_server.py (it sits after the loop, not in a finally), leaving a zombie with a stuck turn.

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.

1 participant