209 lines
7.2 KiB
TypeScript
209 lines
7.2 KiB
TypeScript
/**
|
|
* CursorSessionManager — keeps cursor's h2 streams alive across OpenAI calls
|
|
* so a tool-using turn can complete inline.
|
|
*
|
|
* cursor's `agent.v1.AgentService/Run` is bidirectional. When the model
|
|
* invokes an MCP tool it pauses and waits for a `ExecClientMessage.McpResult`
|
|
* on the SAME stream. Closing the stream between the OpenAI tool_calls
|
|
* response and the role:"tool" follow-up loses the exec_id mapping cursor
|
|
* needs to resume.
|
|
*
|
|
* This manager solves that by retaining the open h2 stream (keyed by
|
|
* conversation_id) when the executor reports endReason="tool_calls". The
|
|
* next OpenAI call with role:"tool" reacquires the session, replies via
|
|
* encodeExecMcpResult on the live stream, and continues driving until
|
|
* turn_ended.
|
|
*
|
|
* Multi-instance considerations: sessions live in process memory. If a
|
|
* follow-up call lands on a different OmniRoute instance, acquire() returns
|
|
* undefined and the executor falls back to cold-resume (fresh RunRequest
|
|
* with all history flattened into UserText). Cold-resume is correctness-
|
|
* preserving but loses the inline efficiency.
|
|
*
|
|
* Concurrency: one in-flight call per session. The acquire/release pattern
|
|
* keeps a session in "awaiting_tool_result" between calls; if a second call
|
|
* arrives while the first is still running, acquire() returns undefined and
|
|
* the second falls back to cold-resume.
|
|
*
|
|
* TTL: sessions evict after CURSOR_SESSION_IDLE_TTL_MS (default 5min). The
|
|
* sweep runs lazily on every acquire/release rather than via setInterval to
|
|
* keep this module test-friendly.
|
|
*/
|
|
|
|
import type { ClientHttp2Session, ClientHttp2Stream } from "node:http2";
|
|
import { encodeExecMcpResult } from "../utils/cursorAgentProtobuf.ts";
|
|
|
|
const DEFAULT_IDLE_TTL_MS = 5 * 60 * 1000;
|
|
|
|
export type CursorSession = {
|
|
conversationId: string;
|
|
h2Client: ClientHttp2Session;
|
|
h2Req: ClientHttp2Stream;
|
|
blobStore: Map<string, Buffer>;
|
|
pendingToolCalls: Map<string, { execMsgId: number; execId: string; toolName: string }>;
|
|
state: "running" | "awaiting_tool_result" | "closed";
|
|
lastActivityTs: number;
|
|
idleTimer?: ReturnType<typeof setTimeout>;
|
|
};
|
|
|
|
export class CursorSessionManager {
|
|
private sessions = new Map<string, CursorSession>();
|
|
private idleTtlMs: number;
|
|
private maxSessions: number;
|
|
|
|
constructor(opts: { idleTtlMs?: number; maxSessions?: number } = {}) {
|
|
this.idleTtlMs = opts.idleTtlMs ?? DEFAULT_IDLE_TTL_MS;
|
|
this.maxSessions = opts.maxSessions ?? 100;
|
|
}
|
|
|
|
/**
|
|
* Try to reacquire an existing session for this conversation. Returns
|
|
* undefined if there isn't one, if it's still running, or if it's idle
|
|
* past the TTL (in which case it's closed as a side-effect).
|
|
*/
|
|
acquire(conversationId: string): CursorSession | undefined {
|
|
this.evictExpired();
|
|
const session = this.sessions.get(conversationId);
|
|
if (!session) return undefined;
|
|
if (session.state !== "awaiting_tool_result") return undefined;
|
|
this.clearIdleTimer(session);
|
|
session.state = "running";
|
|
session.lastActivityTs = Date.now();
|
|
return session;
|
|
}
|
|
|
|
/**
|
|
* Register a freshly-opened h2 stream as the session for this conversation.
|
|
* Any pre-existing session for the same conversation is closed first.
|
|
*/
|
|
open(
|
|
conversationId: string,
|
|
h2Client: ClientHttp2Session,
|
|
h2Req: ClientHttp2Stream,
|
|
blobStore: Map<string, Buffer>
|
|
): CursorSession {
|
|
const existing = this.sessions.get(conversationId);
|
|
if (existing) this.close(existing);
|
|
const session: CursorSession = {
|
|
conversationId,
|
|
h2Client,
|
|
h2Req,
|
|
blobStore,
|
|
pendingToolCalls: new Map(),
|
|
state: "running",
|
|
lastActivityTs: Date.now(),
|
|
};
|
|
this.sessions.set(conversationId, session);
|
|
this.attachCloseHandlers(session);
|
|
this.enforceMaxSessions();
|
|
return session;
|
|
}
|
|
|
|
/**
|
|
* Mark a session as no longer in-flight. If finalState is
|
|
* "awaiting_tool_result" the h2 stream stays open and the next acquire()
|
|
* for this conversation_id can reuse it. If "idle" or "closed" the
|
|
* h2 is torn down here.
|
|
*/
|
|
release(session: CursorSession, finalState: "awaiting_tool_result" | "idle" | "closed"): void {
|
|
session.lastActivityTs = Date.now();
|
|
if (finalState === "awaiting_tool_result") {
|
|
session.state = "awaiting_tool_result";
|
|
this.armIdleTimer(session);
|
|
return;
|
|
}
|
|
this.close(session);
|
|
}
|
|
|
|
close(session: CursorSession): void {
|
|
if (session.state === "closed") return;
|
|
session.state = "closed";
|
|
this.clearIdleTimer(session);
|
|
try {
|
|
session.h2Req.close();
|
|
} catch {}
|
|
try {
|
|
session.h2Client.close();
|
|
} catch {}
|
|
// Drop any unanswered tool-call mappings so a closed session doesn't pin
|
|
// their (small) entries for the lifetime of the lingering object.
|
|
session.pendingToolCalls.clear();
|
|
this.sessions.delete(session.conversationId);
|
|
}
|
|
|
|
/**
|
|
* Send an MCP tool result on this session's open h2 stream. Returns true
|
|
* if the openAIToolCallId matched a pending call we'd previously seen
|
|
* mcp_args for; false otherwise (caller should fall back to cold-resume).
|
|
*/
|
|
sendToolResult(
|
|
session: CursorSession,
|
|
openAIToolCallId: string,
|
|
content: string,
|
|
isError: boolean
|
|
): boolean {
|
|
const pending = session.pendingToolCalls.get(openAIToolCallId);
|
|
if (!pending) return false;
|
|
try {
|
|
session.h2Req.write(encodeExecMcpResult(pending.execMsgId, pending.execId, content, isError));
|
|
session.pendingToolCalls.delete(openAIToolCallId);
|
|
session.lastActivityTs = Date.now();
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private evictExpired(): void {
|
|
const now = Date.now();
|
|
for (const session of this.sessions.values()) {
|
|
if (now - session.lastActivityTs > this.idleTtlMs) {
|
|
this.close(session);
|
|
}
|
|
}
|
|
}
|
|
|
|
private armIdleTimer(session: CursorSession): void {
|
|
this.clearIdleTimer(session);
|
|
session.idleTimer = setTimeout(() => this.close(session), this.idleTtlMs);
|
|
session.idleTimer.unref?.();
|
|
}
|
|
|
|
private clearIdleTimer(session: CursorSession): void {
|
|
if (session.idleTimer) {
|
|
clearTimeout(session.idleTimer);
|
|
session.idleTimer = undefined;
|
|
}
|
|
}
|
|
|
|
private attachCloseHandlers(session: CursorSession): void {
|
|
const closeSession = () => this.close(session);
|
|
session.h2Req.once?.("close", closeSession);
|
|
session.h2Req.once?.("error", closeSession);
|
|
session.h2Client.once?.("close", closeSession);
|
|
session.h2Client.once?.("error", closeSession);
|
|
}
|
|
|
|
private enforceMaxSessions(): void {
|
|
if (this.sessions.size <= this.maxSessions) return;
|
|
const oldest = Array.from(this.sessions.values()).sort(
|
|
(a, b) => a.lastActivityTs - b.lastActivityTs
|
|
)[0];
|
|
if (oldest) this.close(oldest);
|
|
}
|
|
|
|
// ─── Test / introspection helpers ────────────────────────────────────────
|
|
|
|
size(): number {
|
|
return this.sessions.size;
|
|
}
|
|
|
|
has(conversationId: string): boolean {
|
|
return this.sessions.has(conversationId);
|
|
}
|
|
}
|
|
|
|
// Module-level singleton — one manager per OmniRoute process. The executor
|
|
// imports this directly. For testing, construct a fresh CursorSessionManager.
|
|
export const cursorSessionManager = new CursorSessionManager();
|