Files
2026-07-13 13:39:12 +08:00

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();