Initial segregate: proxy source, deploy manifests, ImagePolicy Flux CRs.
build-and-push / build (push) Failing after 2m45s

Move workload out of infra with cursor-devbox-style GitRepository +
run_number-sha image automation.
This commit is contained in:
Vsevolod Sauta
2026-09-21 10:31:36 +03:00
commit 57a036ba5e
22 changed files with 2444 additions and 0 deletions
+464
View File
@@ -0,0 +1,464 @@
/**
* Cursor-agent session manager (ACP transport).
*
* Each HTTP chat request creates an ACP session (`agent acp`):
* initialize → session/new (MCP stdio bridge) → session/prompt
*
* Client tools are advertised via in-process ProxyMcpServer. MCP tools/call
* parks until the HTTP client returns role:"tool" with the result.
*
* Built-in cursor-agent tools are denied via session/request_permission.
* Images are sent as native ACP image ContentBlocks (not via Read).
*/
import { spawn } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import { AcpClient } from "./acp-client.mjs";
import { ProxyMcpServer } from "./mcp-server.mjs";
export const SESSION_TTL_MS = parseInt(process.env.PROXY_SESSION_TTL_MS || "120000", 10);
export const MAX_CONCURRENT_SESSIONS = parseInt(process.env.PROXY_MAX_CONCURRENT_SESSIONS || "4", 10);
export const TOOL_CALL_BATCH_MS = parseInt(process.env.PROXY_TOOL_CALL_BATCH_MS || "50", 10);
const REAL_AGENT = process.env.CURSOR_AGENT_BIN || "/usr/local/bin/agent.real";
const MCP_BRIDGE = process.env.MCP_BRIDGE_BIN || "/app/scripts/mcp-stdio-bridge.sh";
/** @type {Map<string, CursorSession>} */
const SESSIONS = new Map();
/**
* Resolve ACP model id from session/new response and requested OpenAI model id.
* @param {object} sessionResult session/new result
* @param {string} requested
*/
export function resolveModelId(sessionResult, requested) {
const available = sessionResult.models?.availableModels || [];
if (available.length > 0) {
if (!requested || requested === "auto") {
return sessionResult.models.currentModelId || available[0].modelId;
}
const exact = available.find((m) => m.modelId === requested || m.name === requested);
if (exact) return exact.modelId;
const byName = available.find((m) => m.name?.toLowerCase() === requested.toLowerCase());
if (byName) return byName.modelId;
const prefix = available.find(
(m) => m.modelId.startsWith(`${requested}[`) || m.modelId.startsWith(requested)
);
if (prefix) return prefix.modelId;
const contains = available.find((m) => m.modelId.includes(requested));
if (contains) return contains.modelId;
return sessionResult.models.currentModelId || available[0].modelId;
}
const modelOption = (sessionResult.configOptions || []).find(
(o) => o.id === "model" || o.category === "model"
);
return pickModelValueId(modelOption, requested);
}
/**
* Pick an ACP model config value for the requested OpenAI model id.
* @param {object} modelOption session/new config option with id "model"
* @param {string} requested e.g. "auto", "gpt-5.2"
*/
export function pickModelValueId(modelOption, requested) {
const options = modelOption?.options || [];
if (!options.length) return null;
if (!requested || requested === "auto") {
const auto = options.find((o) => o.value === "default[]" || o.name === "Auto");
return auto?.value || modelOption.currentValue || options[0].value;
}
const exact = options.find((o) => o.value === requested);
if (exact) return exact.value;
const byName = options.find((o) => o.name?.toLowerCase() === requested.toLowerCase());
if (byName) return byName.value;
const prefix = options.find(
(o) => o.value.startsWith(`${requested}[`) || o.value.startsWith(requested)
);
if (prefix) return prefix.value;
const contains = options.find((o) => o.value.includes(requested));
if (contains) return contains.value;
return null;
}
function pickPermissionOption(options, allow) {
if (!Array.isArray(options) || options.length === 0) return null;
const kindMatch = allow
? (o) => o.kind === "allow_once" || o.kind === "allow_always"
: (o) => o.kind === "reject_once" || o.kind === "reject_always";
return options.find(kindMatch) || (allow ? options[0] : options[options.length - 1]);
}
export function extractToolNameFromPermission(params) {
const tool = params?.toolCall;
if (!tool) return "";
if (tool.rawInput && typeof tool.rawInput === "object" && typeof tool.rawInput.name === "string") {
let name = tool.rawInput.name;
if (name.startsWith("mcp_client_")) {
name = name.slice("mcp_client_".length);
}
return name;
}
if (typeof tool.title === "string") {
const clientMcp = tool.title.match(/^client-([^:]+):\s*(\S+)/);
if (clientMcp) {
return clientMcp[2];
}
const m = tool.title.match(/^(\S+)/);
if (m) return m[1].replace(/:$/, "");
}
return "";
}
function mapStopReason(stopReason) {
if (stopReason === "end_turn") return "stop";
if (stopReason === "cancelled") return "error";
return "stop";
}
export class CursorSession {
constructor({ sessionId, tools, model, log }) {
this.sessionId = sessionId;
this.tools = tools || [];
this.clientToolNames = new Set(this.tools.map((t) => t.function?.name).filter(Boolean));
this.model = model || "auto";
this.log = log || console.error;
this.detectedModel = model || "auto";
this.createdAt = Date.now();
this.lastActivityAt = Date.now();
this.workDir = null;
this.cursorProc = null;
this.acp = null;
this.mcp = null;
this.acpSessionId = null;
this.timeoutHandle = null;
this._finishToolCallsTimer = null;
this.terminated = false;
this._handlers = null;
this._hasToolCallInTurn = false;
this._promptInFlight = false;
}
async start() {
this.workDir = mkdtempSync(join(tmpdir(), `cursor-${this.sessionId}-`));
const mcpTools = this.tools.map((t) => ({
name: t.function.name,
description: t.function.description,
inputSchema: t.function.parameters,
}));
const socketPath = join(this.workDir, "mcp.sock");
this.mcp = new ProxyMcpServer({
socketPath,
tools: mcpTools,
onToolCall: ({ toolCallId, name, arguments: args }) => {
if (!this._handlers) return;
this._hasToolCallInTurn = true;
this._handlers.onToolCall({
id: toolCallId,
name,
arguments: JSON.stringify(args || {}),
});
this._scheduleFinishToolCallsTurn();
},
log: (...args) => this.log(`[mcp:${this.sessionId}]`, ...args),
});
await this.mcp.start();
const args = ["acp"];
const env = { ...process.env };
if (process.env.CURSOR_API_KEY) env.CURSOR_API_KEY = process.env.CURSOR_API_KEY;
this.cursorProc = spawn(REAL_AGENT, args, {
cwd: this.workDir,
env,
stdio: ["pipe", "pipe", "pipe"],
});
this.acp = new AcpClient({
stdin: this.cursorProc.stdin,
stdout: this.cursorProc.stdout,
log: (...a) => this.log(`[acp:${this.sessionId}]`, ...a),
});
this.acp.onSessionUpdate = (params) => this._onSessionUpdate(params);
this.acp.onIncomingRequest = (method, params) => this._onIncomingRequest(method, params);
this.cursorProc.stderr.on("data", (d) =>
this.log(`[cursor:${this.sessionId} stderr] ${d.toString().trim()}`)
);
this.cursorProc.on("close", (code) => {
this.log(`[cursor:${this.sessionId}] process exited code=${code}`);
if (this._handlers) this._finishTurn("error", null);
});
this.cursorProc.on("error", (e) => {
this.log(`[cursor:${this.sessionId}] spawn error: ${e.message}`);
if (this._handlers) this._finishTurn("error", null);
});
await this.acp.initialize();
await this.acp.authenticateIfNeeded();
const mcpServers = [
{
name: "client",
command: MCP_BRIDGE,
args: [socketPath],
env: [],
},
];
let newSessionResult;
try {
newSessionResult = await this.acp.request("session/new", {
cwd: this.workDir,
mcpServers,
});
} catch (e) {
if (e.code === -32000) {
await this.acp.request("authenticate", { methodId: "cursor_login" });
newSessionResult = await this.acp.request("session/new", {
cwd: this.workDir,
mcpServers,
});
} else {
throw e;
}
}
this.acpSessionId = newSessionResult.sessionId;
const modelId = resolveModelId(newSessionResult, this.model);
const currentModelId = newSessionResult.models?.currentModelId;
if (modelId && modelId !== currentModelId) {
await this.acp.request("session/set_config_option", {
sessionId: this.acpSessionId,
configId: "model",
value: modelId,
});
}
this.detectedModel = modelId || currentModelId || this.model;
this._resetTimer();
return this;
}
async _onIncomingRequest(method, params) {
if (method === "session/request_permission") {
const toolName = extractToolNameFromPermission(params);
const allow = this.clientToolNames.has(toolName);
const option = pickPermissionOption(params.options, allow);
if (!option) {
return { outcome: { outcome: "cancelled" } };
}
const optionId = option.optionId || option.id;
return { outcome: { outcome: "selected", optionId } };
}
if (method === "cursor/ask_question") {
const first = params?.questions?.[0]?.options?.[0]?.id;
return { answers: first ? { [params.questions[0].id]: first } : {} };
}
if (method === "cursor/create_plan") {
return { approved: true };
}
if (
method === "cursor/update_todos" ||
method === "cursor/task" ||
method === "cursor/generate_image"
) {
return {};
}
this.log(`[acp] unhandled incoming method: ${method}`);
return {};
}
_onSessionUpdate(params) {
if (!this._handlers || params.sessionId !== this.acpSessionId) return;
const update = params.update;
if (!update) return;
if (update.sessionUpdate === "agent_message_chunk") {
const content = update.content;
if (content?.type === "text" && content.text) {
this._handlers.onAssistantDelta(content.text);
}
return;
}
// agent_thought_chunk and other updates are ignored for OpenAI output.
}
/**
* @param {Array<object>|null} blocks ACP ContentBlocks, or null for follow-up only
*/
runTurn(blocks, handlers) {
this._handlers = handlers;
this.lastActivityAt = Date.now();
this._resetTimer();
if (blocks === null) {
// Follow-up after tool_calls: same session/prompt is still in flight.
this._hasToolCallInTurn = false;
return;
}
this._hasToolCallInTurn = false;
this._promptInFlight = true;
this.acp
.request("session/prompt", {
sessionId: this.acpSessionId,
prompt: blocks,
})
.then((result) => {
this._promptInFlight = false;
if (!this._handlers) return;
if (this.mcp.pending.size > 0 || this._hasToolCallInTurn) {
this._scheduleFinishToolCallsTurn();
return;
}
this._finishTurn(mapStopReason(result.stopReason), null);
})
.catch((e) => {
this._promptInFlight = false;
this.log(`[acp] session/prompt error: ${e.message}`);
if (this._handlers) this._finishTurn("error", null);
});
}
resolveToolCall(toolCallId, result) {
const ok = this.mcp.resolveToolCall(toolCallId, result);
if (ok) {
this.lastActivityAt = Date.now();
this._resetTimer();
}
return ok;
}
_scheduleFinishToolCallsTurn() {
if (this._finishToolCallsTimer) clearTimeout(this._finishToolCallsTimer);
this._finishToolCallsTimer = setTimeout(() => {
this._finishToolCallsTimer = null;
if (!this._handlers || !this._hasToolCallInTurn) return;
if (this.mcp.pending.size === 0) return;
this._finishTurn("tool_calls", null);
}, TOOL_CALL_BATCH_MS);
}
async destroy() {
if (this.terminated) return;
this.terminated = true;
if (this._finishToolCallsTimer) clearTimeout(this._finishToolCallsTimer);
if (this.timeoutHandle) clearTimeout(this.timeoutHandle);
try {
this.acp?.close();
if (this.cursorProc && this.cursorProc.exitCode === null) {
this.cursorProc.kill("SIGTERM");
await new Promise((r) => {
const kill = setTimeout(() => {
try {
this.cursorProc?.kill("SIGKILL");
} catch {
/* ignore */
}
r();
}, 5000);
this.cursorProc?.once("close", () => {
clearTimeout(kill);
r();
});
});
}
} catch {
/* ignore */
}
try {
await this.mcp?.stop();
} catch {
/* ignore */
}
try {
if (this.workDir) rmSync(this.workDir, { recursive: true, force: true });
} catch {
/* ignore */
}
SESSIONS.delete(this.sessionId);
}
_resetTimer() {
if (this.timeoutHandle) clearTimeout(this.timeoutHandle);
this.timeoutHandle = setTimeout(async () => {
this.log(`[cursor:${this.sessionId}] TTL expired (idle ${SESSION_TTL_MS}ms)`);
await this.destroy();
}, SESSION_TTL_MS);
}
_finishTurn(reason, usage) {
if (!this._handlers) return;
const handlers = this._handlers;
this._handlers = null;
void Promise.resolve(handlers.onFinish(reason, usage));
}
}
export function getActiveSessionCount() {
return SESSIONS.size;
}
export async function createSession({ tools, model, log }) {
if (SESSIONS.size >= MAX_CONCURRENT_SESSIONS) {
const err = new Error(`too many concurrent sessions (max ${MAX_CONCURRENT_SESSIONS})`);
err.code = "too_many_sessions";
throw err;
}
const sessionId = `s_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
const session = new CursorSession({ sessionId, tools, model, log });
SESSIONS.set(sessionId, session);
try {
await session.start();
return session;
} catch (e) {
await session.destroy();
SESSIONS.delete(sessionId);
throw e;
}
}
export function findSessionForFollowUp(body) {
const toolResults = (body.messages || []).filter((m) => m.role === "tool");
if (toolResults.length === 0) return null;
for (const session of SESSIONS.values()) {
if (!session.mcp) continue;
for (const tr of toolResults) {
if (session.mcp.pending.has(tr.tool_call_id)) {
return { session, toolResults };
}
}
}
return null;
}
export function getSession(sessionId) {
return SESSIONS.get(sessionId);
}
export async function destroyAllSessions() {
const all = [...SESSIONS.values()];
SESSIONS.clear();
await Promise.all(all.map((s) => s.destroy()));
}