fix(web): open a saved session in milliseconds; New session dialog with new workspace + worktree - #947
Merged
Conversation
…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>
Test Results 5 files 1 020 suites 29m 52s ⏱️ For more details on these failures, see this check. Results for commit f4ae5c0. ♻️ This comment has been updated with latest results. |
…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>
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):
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
session.resume)projects.tree(sidebar refresh, after every navigation)Root cause.
session.resumespawns a runtime before replying, and the runtime's system prompt counts files withPath.rglobover the whole workspace —node_modulesincluded (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.treere-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.resumewithomit_messages). The web client now does the same.What changed
Server
workspace_snapshot.py: one bounded, breadth-firstscandirwalk that prunes vendored/generated directories before entering them, never follows symlinks, and stops at a directory/entry budget; capped counts render asN+ (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). Reportsinfo.runningfor 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 (attachedevent) instead of spawning twice. Messages come from the live runtime's own record when there is one;info.runningsays whether it is mid-turn. A refused replay is not reused.session.create:create_dir(mkdir -p a new workspace; absolute paths only,~expands) andworktree(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 plaincwdpasses through as before. Worktree on a folder that does not exist yet is refused before anything is created.session.close:if_idlerefuses (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 ownget_activitycontrol (new:/goalcontinuation,/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 getssession.closed.session.closedto 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).session.historyandprojects.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 (ProbeCacheonDesktopServeState, 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
session.historyat 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 withoutsession.historyget the one-call resume as before.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.closeddrops the runtime id so the next prompt reconnects.Verification
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.session.closed, overlapping opens, used-across-reload, dialog, sidebar);tscclean.clawcodex serve+ builtui-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 sessionssession.active_listshows one live runtime.Follow-ups (not in this PR)
send()retry after "unknown session" goes text-only: images attached to the first attempt lived in the old runtime.session.resumeon reconnect would restore the hold.agent_server.py, after the loop rather than in afinally), leaving a zombie with a stuck turn.Notes
session.resume/projects.treecalls 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 onmain's recent PR runs (test_nano_bash,test_nano_editCRLF,test_gateway_questions::test_walk_survives_an_unreadable_directory,test_config_dir_override) — none touched here.🤖 Generated with Claude Code