Skip to content

feat(session): Support concurrent, routable sessions - #177

Merged
Winify merged 9 commits into
mainfrom
feature/concurrent-sessions
Oct 29, 2026
Merged

Winify merged 9 commits into
mainfrom
feature/concurrent-sessions

Conversation

@Winify

@Winify Winify commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator

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

  • Named sessions, opt-in. start_session and attach_session accept name and onExisting: "close" | "keep". The default (close) behaves exactly as before. With keep, the new session runs alongside and does not become the default unless none exists. attach_session with only name looks up a live session.
  • Targeting. Every session-bound tool accepts an optional session (sessionId first, then name). Omitting it uses the default session; targeting never changes the default for other callers. registerTool injects the parameter unless a tool is marked sessionless.
  • Concurrency. Calls to the same session queue; calls to different sessions run concurrently. close_session and replaced-session teardown deliberately skip the queue so a hung command can still be closed. Overlapping closes of one session share a single teardown.
  • Results carry [session: name (id8)] when a target is passed or more than one session is live. Single-session output is unchanged.
  • Errors. An unknown target, or an untargeted call while no default exists, returns an error listing live sessions. A tool that does not apply to the target's platform is rejected.
  • Safety. A duplicate name is rejected before a browser is launched. If registration still fails, the new browser and tunnel are disposed. Attached sessions are never deleted.
  • Tool listing covers the union of the platforms of live sessions.
  • Attribution. start_session/attach_session steps 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.md is removed.

Verification

  • npm test: 859/859 (54 files); tsc --noEmit clean; eslint 0 errors.
  • New real-state tests do not mock registerSession: start-step attribution, the duplicate name never reaching remote(), dispose on failed registration, close while a command hangs, and overlapping closes.
  • Manual run against the real server over stdio with two headless Chrome sessions:
    • two 1.5 s calls to the same session took 3017 ms (serialized); to different sessions, 1511 ms (concurrent);
    • targeted and untargeted titles stayed separate;
    • a duplicate name left no browser process behind;
    • each session's steps were recorded under the right session.

Known limitations

  • A failed start_session with onExisting: "keep" is recorded in the default session's history, since no new session exists to own it.
  • Calls already queued behind a hung command do not settle after close_session; they rely on the client's timeout.
  • wdio://session/current/* resources always refer to the default session.

🤖 Generated with Claude Code

Winify and others added 2 commits October 27, 2026 13:10
- 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>
@greptile-apps

greptile-apps Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge, with all four previous findings addressed and no new actionable issues found.

Summary

Adds named sessions, optional tool targeting, and separate queues so different sessions can run alongside each other.

  • Callers can keep named sessions live beside a default session.
  • Session-bound tools can target one session without changing the default.
  • Session closes can proceed while commands are stuck.
  • The superseded multi-session proposal is no longer in the docs.

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]
Loading

Reviews (5) · Last reviewed commit: "fix(session): Count agent setup toward t..." · Reviewed by Greptile

Comment thread src/tools/session.tool.ts Outdated
Comment on lines 327 to 336
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);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Startup races other callers

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.

Comment thread src/tools/session.tool.ts Outdated
Comment on lines +333 to +335
}, { keep: args.onExisting === 'keep', name: args.name }), async () => {
await wdioBrowser.deleteSession();
if (tunnelHandle) await provider.stopTunnel?.(tunnelHandle);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 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

Comment thread src/tool-visibility.ts
Comment on lines +33 to +37
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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Removed platforms stay listed

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>
Comment thread src/session/routing.ts
const run = () => sessionContext.run({ sessionId: id }, () => call(rest, extra));
let result: ToolResult;
try {
result = await (unqueued ? run() : runInSessionQueue(id, () => withQueuedCallTimeout(run(), toolName)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 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.
@Winify
Winify merged commit 1345a7d into main Oct 29, 2026
7 checks passed
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