Repository navigation
feat(session): Support concurrent, routable sessions - #177
Conversation
- Session map with optional names; start_session/attach_session accept `name` and `onExisting: "close" | "keep"` (default close, unchanged) - Every session-bound tool accepts an optional `session` target (sessionId first, then name); omitting it uses the default session, and targeting never changes the default - Per-session command queue: same-session calls serialize, different sessions run concurrently - Results carry a `[session: name (id8)]` prefix when targeted or when more than one session is live; single-session output is unchanged - Name collisions are rejected before launching; a failed registration disposes the new browser and tunnel - Tool listing covers the union of live session platforms - Docs: README multiple-sessions section, ROADMAP Phase 2 items, CLAUDE.md; remove the superseded multi-session proposal Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A hung command could block close_session, the teardown of a replaced session, and Electron start_session, because all three waited in the session's queue. close_session is now unqueued (still resolved and validated), replaced-session teardown and the Electron replace call closeSession directly, and overlapping closes of one session share a single teardown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The PR appears safe to merge, with all four previous findings addressed and no new actionable issues found. SummaryAdds named sessions, optional tool targeting, and separate queues so different sessions can run alongside each other.
Diagram%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Tool call] --> B{Session-bound?}
B -->|No| C[Run in a fresh call context]
B -->|Yes| D[Resolve target or default session]
D --> E[Check session platform]
E --> F{Close session?}
F -->|Yes| G[Run outside the queue]
F -->|No| H[Queue for the selected session]
H --> I[Run tool and record its steps]
I --> J[Add session label when needed]
Reviews (5) · Last reviewed commit: "fix(session): Count agent setup toward t..." · Reviewed by Greptile |
| await registerOrDispose(() => registerSession(sessionId, wdioBrowser, sessionMetadata, { | ||
| sessionId, | ||
| type: 'browser', | ||
| startedAt: new Date().toISOString(), | ||
| capabilities: mergedCapabilities, | ||
| steps: [], | ||
| }, { keep: args.onExisting === 'keep', name: args.name }), async () => { | ||
| await wdioBrowser.deleteSession(); | ||
| if (tunnelHandle) await provider.stopTunnel?.(tunnelHandle); | ||
| }); |
There was a problem hiding this comment.
start_session exposes the new session before setup finishes, but never joins that session's queue. Another caller can target its known name while setWindowSize is pending and run navigate immediately. Startup can then navigate to navigationUrl, overwriting that caller's page.
Hold the new session's queue until startup finishes, while still allowing close_session to bypass it.
| }, { keep: args.onExisting === 'keep', name: args.name }), async () => { | ||
| await wdioBrowser.deleteSession(); | ||
| if (tunnelHandle) await provider.stopTunnel?.(tunnelHandle); |
There was a problem hiding this comment.
Cleanup stops after one failure
The new disposal callbacks skip later cleanup when an earlier step throws. Concurrent starts using the same name can fail registration after creating a browser and tunnel. If deleteSession() then rejects, stopTunnel() never runs, and the outer catch hides that cleanup error.
The mobile callback has the same problem. The Electron callback skips browser deletion if runtime cleanup fails. Use independent cleanup attempts or finally so one failure does not leave the remaining resources running.
Knowledge Base Used: Session lifecycle transitions
| const live = [...getState().sessionMetadata.values()].map(platformOf); | ||
| const platforms = live.length ? live : [platform]; | ||
| for (const [name, tool] of tools) { | ||
| const group = groupOf(name); | ||
| const listed = toolAppliesTo(name, platform) && (!group || enabledGroups.has(group)); | ||
| const listed = platforms.some((p) => toolAppliesTo(name, p)) && (!group || enabledGroups.has(group)); |
There was a problem hiding this comment.
The new platform union includes the session being replaced when registration refreshes the tool list. notifySessionRegistered() runs before the old metadata is removed, and removal does not trigger another refresh.
Replacing a browser session with Android therefore leaves browser-only tools listed on a long-running stdio server, even though those calls now fail the target-platform check. Refresh after removing replaced sessions, and when sessions close, so the list matches the live sessions.
Knowledge Base Used: Adaptive tool orchestration
…ool list on close - Browser startup after registration runs in the session queue, so targeted calls cannot race initial navigation; close_session still bypasses it. - A failed startup closes the new session and reports it as failed to the provider. - Disposal after failed registration runs each cleanup step independently and logs failures. - onSessionsChanged replaces onSessionRegistered; it fires after a replaced session is removed and when a session closes, and the platform resets once the last session closes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A page whose main thread hangs could stall a session indefinitely: classic WebDriver commands waited ~120s with 3 retries, and the per-session queue made every later call wait behind them. - Add a shared withTimeout helper (src/utils/with-timeout.ts) used by the 30s action timeout, a new 90s deadline on queued session tool calls, and a 90s limit on deleteSession in close_session. - Local browser sessions: connectionRetryTimeout 45s, connectionRetryCount 0, so hung classic commands abort and are never replayed. - start_session: bound the first navigation by the action timeout; a timeout keeps the session and reports the page as still loading. - Process handlers: log unhandled rejections and keep running; log uncaught exceptions and exit 1. - Exit 1 when the HTTP server fails to listen, and log a failed Chrome spawn in launch_chrome instead of crashing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| const run = () => sessionContext.run({ sessionId: id }, () => call(rest, extra)); | ||
| let result: ToolResult; | ||
| try { | ||
| result = await (unqueued ? run() : runInSessionQueue(id, () => withQueuedCallTimeout(run(), toolName))); |
There was a problem hiding this comment.
Timed-out batches keep running
withQueuedCallTimeout() releases the session queue after 90 seconds without stopping the callback. A valid perform_actions batch can exceed that limit: its action list has no length limit, and each step gets its own 30-second limit.
The batch keeps issuing clicks or fills after the caller receives a timeout. Meanwhile, the next call starts on the same session, so the two calls can change each other's page or form. Keep the queue occupied until the callback ends, or stop the batch before allowing the next call to run.
…180) The step budget started after agentFor(), which has no time limit of its own, so a slow agent setup let the batch run past the 90s queue deadline. Start the clock at the top of the call and run no step when setup has already used the budget.
Implements Phase 1 of #170. Phase 2 (listing sessions, idle timeout, session cap, cloud cleanup policy) is listed in ROADMAP.md and not part of this PR.
What changes
start_sessionandattach_sessionacceptnameandonExisting: "close" | "keep". The default (close) behaves exactly as before. Withkeep, the new session runs alongside and does not become the default unless none exists.attach_sessionwith onlynamelooks up a live session.session(sessionId first, then name). Omitting it uses the default session; targeting never changes the default for other callers.registerToolinjects the parameter unless a tool is markedsessionless.close_sessionand replaced-session teardown deliberately skip the queue so a hung command can still be closed. Overlapping closes of one session share a single teardown.[session: name (id8)]when a target is passed or more than one session is live. Single-session output is unchanged.start_session/attach_sessionsteps are recorded under the session they create (session-less tools run in an empty call context that registration fills in).Names are scoped to one server process.
Docs
README gains a "Multiple sessions" section and updated Session Management rows. ROADMAP keeps only the Phase 2 items, and CLAUDE.md describes the routing. The superseded
docs/architecture/multi-session-proposal.mdis removed.Verification
npm test: 859/859 (54 files);tsc --noEmitclean; eslint 0 errors.registerSession: start-step attribution, the duplicate name never reachingremote(), dispose on failed registration, close while a command hangs, and overlapping closes.Known limitations
start_sessionwithonExisting: "keep"is recorded in the default session's history, since no new session exists to own it.close_session; they rely on the client's timeout.wdio://session/current/*resources always refer to the default session.🤖 Generated with Claude Code