Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 2b4d14672c fix(server): deduplicate concurrent agent creation
Concurrent create requests for one client message each ran the creation lifecycle. Share the in-flight operation and reply to every requester with the resulting agent so retries cannot start duplicate provider sessions.
2026-08-12 09:09:52 +00:00
17 changed files with 159 additions and 384 deletions
+3 -5
View File
@@ -131,11 +131,9 @@ desktop-only environment inherited by terminals opened inside Paseo from couplin
a new worktree instance to the parent desktop instance's profile or single-instance a new worktree instance to the parent desktop instance's profile or single-instance
lock. lock.
The desktop workspace script `exec`s the dev runner so the terminal owns the runner Electron remains in the desktop dev runner's process group. Closing or stopping the
PID. Terminal shutdown reaches the runner as `SIGHUP`; the runner stops Metro and workspace-script terminal must terminate Electron with Metro; detaching Electron
asks Electron to quit through its normal app lifecycle. Do not add an npm wrapper or leaves an orphan holding the worktree's single-instance lock and broken output pipes.
detach Electron: either change leaves an orphan holding the worktree's single-instance
lock and broken output pipes.
With desktop dev running, verify the real BrowserWindow, titlebar clearance, fullscreen With desktop dev running, verify the real BrowserWindow, titlebar clearance, fullscreen
transition, and 751-pixel settings split with: transition, and 751-pixel settings split with:
@@ -88,43 +88,6 @@ describe("useDraftAgentCreateFlow", () => {
expect(onCreateSuccess).toHaveBeenCalledTimes(1); expect(onCreateSuccess).toHaveBeenCalledTimes(1);
}); });
it("starts one create request when submit is invoked twice before React rerenders", async () => {
let resolveCreate: ((value: { agentId: string; result: { id: string } }) => void) | undefined;
const createRequest = vi.fn(
() =>
new Promise<{ agentId: string; result: { id: string } }>((resolve) => {
resolveCreate = resolve;
}),
);
const onCreateSuccess = vi.fn();
const { result } = renderHook(() =>
useDraftAgentCreateFlow({
draftId: "draft-1",
getPendingServerId: () => "server-1",
buildDraftAgent: (attempt) => ({ attempt }),
createRequest,
onCreateSuccess,
}),
);
const submission = { text: "build this", attachments: [], cwd: "/repo" };
let first: Promise<void>;
let duplicate: Promise<void>;
act(() => {
first = result.current.handleCreateFromInput(submission);
duplicate = result.current.handleCreateFromInput(submission);
});
await expect(duplicate!).rejects.toThrow();
expect(createRequest).toHaveBeenCalledTimes(1);
await act(async () => {
resolveCreate?.({ agentId: "agent-1", result: { id: "agent-1" } });
await first!;
});
expect(onCreateSuccess).toHaveBeenCalledTimes(1);
});
it("allows retrying an empty prompt when the draft still has context attachments", async () => { it("allows retrying an empty prompt when the draft still has context attachments", async () => {
const attachment = { const attachment = {
kind: "chat_history", kind: "chat_history",
+3 -16
View File
@@ -84,17 +84,6 @@ interface CreateRequestContext {
cwd: string; cwd: string;
} }
function assertCreateAvailable(input: {
draftId: string;
isSubmitting: boolean;
alreadyLoadingMessage: string;
}): void {
const pending = useCreateFlowStore.getState().pendingByDraftId[input.draftId];
if (input.isSubmitting || pending?.lifecycle === "active") {
throw new Error(input.alreadyLoadingMessage);
}
}
interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> { interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
draftId: string; draftId: string;
getPendingServerId: () => string | null; getPendingServerId: () => string | null;
@@ -250,11 +239,9 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
const handleCreateFromInput = useCallback( const handleCreateFromInput = useCallback(
async ({ text, attachments, cwd }: SubmitContext) => { async ({ text, attachments, cwd }: SubmitContext) => {
assertCreateAvailable({ if (isSubmitting) {
draftId, throw new Error(t("composer.errors.alreadyLoading"));
isSubmitting, }
alreadyLoadingMessage: t("composer.errors.alreadyLoading"),
});
dispatch({ type: "DRAFT_SET_ERROR", message: "" }); dispatch({ type: "DRAFT_SET_ERROR", message: "" });
const trimmedPrompt = text.trim(); const trimmedPrompt = text.trim();
@@ -15,9 +15,3 @@ export function createElectronSpawnOptions({ env, colorEnv, expoDevUrl }) {
export function resolveChildKillTarget(pid, detached) { export function resolveChildKillTarget(pid, detached) {
return detached ? -pid : pid; return detached ? -pid : pid;
} }
export function registerDevRunnerShutdownSignals({ signalSource, stop }) {
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
signalSource.on(signal, () => stop("SIGTERM"));
}
}
@@ -1,23 +1,7 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { readFileSync } from "node:fs"; import { createElectronSpawnOptions, resolveChildKillTarget } from "./dev-runner-config.mjs";
import {
createElectronSpawnOptions,
registerDevRunnerShutdownSignals,
resolveChildKillTarget,
} from "./dev-runner-config.mjs";
describe("desktop dev process ownership", () => { describe("desktop dev process ownership", () => {
test("makes the signal-handling runner the workspace terminal process", () => {
const paseoConfig = JSON.parse(
readFileSync(new URL("../../../paseo.json", import.meta.url), "utf8"),
);
const devScript = readFileSync(new URL("./dev.sh", import.meta.url), "utf8");
expect(paseoConfig.scripts.desktop.command).toContain("exec ./packages/desktop/scripts/dev.sh");
expect(devScript).toContain('npm --prefix "$DESKTOP_DIR" run build:main');
expect(devScript).toContain('exec node "$SCRIPT_DIR/dev-runner.mjs"');
});
test("keeps Electron in the runner process group", () => { test("keeps Electron in the runner process group", () => {
const options = createElectronSpawnOptions({ const options = createElectronSpawnOptions({
env: { PATH: "/usr/bin" }, env: { PATH: "/usr/bin" },
@@ -39,24 +23,4 @@ describe("desktop dev process ownership", () => {
expect(resolveChildKillTarget(42, true)).toBe(-42); expect(resolveChildKillTarget(42, true)).toBe(-42);
expect(resolveChildKillTarget(42, false)).toBe(42); expect(resolveChildKillTarget(42, false)).toBe(42);
}); });
test("stops children when the owning terminal hangs up", () => {
const listeners = new Map();
const receivedSignals = [];
registerDevRunnerShutdownSignals({
signalSource: {
on(signal, listener) {
listeners.set(signal, listener);
},
},
stop(signal) {
receivedSignals.push(signal);
},
});
expect(Array.from(listeners.keys())).toEqual(["SIGHUP", "SIGINT", "SIGTERM"]);
listeners.get("SIGHUP")();
expect(receivedSignals).toEqual(["SIGTERM"]);
});
}); });
+3 -6
View File
@@ -4,11 +4,7 @@ import path from "node:path";
import { execFileSync, spawn } from "node:child_process"; import { execFileSync, spawn } from "node:child_process";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { import { createElectronSpawnOptions, resolveChildKillTarget } from "./dev-runner-config.mjs";
createElectronSpawnOptions,
registerDevRunnerShutdownSignals,
resolveChildKillTarget,
} from "./dev-runner-config.mjs";
import { resolveDevElectronArgs } from "./dev-runner-args.mjs"; import { resolveDevElectronArgs } from "./dev-runner-args.mjs";
@@ -163,7 +159,8 @@ function canConnect(port, host) {
}); });
} }
registerDevRunnerShutdownSignals({ signalSource: process, stop: stopAll }); process.on("SIGINT", () => stopAll("SIGTERM"));
process.on("SIGTERM", () => stopAll("SIGTERM"));
spawnChild("metro", "npx", ["expo", "start", "--port", String(expoPort)], { spawnChild("metro", "npx", ["expo", "start", "--port", String(expoPort)], {
cwd: appDir, cwd: appDir,
+1 -1
View File
@@ -31,7 +31,7 @@ export PASEO_DAEMON_ENDPOINT="$DAEMON_ENDPOINT"
export PASEO_CORS_ORIGINS="${PASEO_CORS_ORIGINS:-*}" export PASEO_CORS_ORIGINS="${PASEO_CORS_ORIGINS:-*}"
npm --prefix "$DESKTOP_DIR" run build:main npm run build:main
echo "══════════════════════════════════════════════════════" echo "══════════════════════════════════════════════════════"
echo " Paseo Desktop Dev" echo " Paseo Desktop Dev"
@@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest";
import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings"; import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings";
import { import {
createQuitLifecycle, createQuitLifecycle,
registerExternalQuitSignals,
shouldStopDesktopManagedDaemonOnQuit, shouldStopDesktopManagedDaemonOnQuit,
stopDesktopManagedDaemonOnQuitIfNeeded, stopDesktopManagedDaemonOnQuitIfNeeded,
} from "./quit-lifecycle"; } from "./quit-lifecycle";
@@ -30,25 +29,6 @@ function waitForQuitLifecycle(): Promise<void> {
} }
describe("quit-lifecycle", () => { describe("quit-lifecycle", () => {
it("turns external termination signals into one Electron quit", () => {
const listeners = new Map<NodeJS.Signals, () => void>();
const quits: string[] = [];
registerExternalQuitSignals({
signals: {
on: (signal, listener) => {
listeners.set(signal, listener);
},
},
quit: () => quits.push("quit"),
});
expect(Array.from(listeners.keys())).toEqual(["SIGHUP", "SIGINT", "SIGTERM"]);
listeners.get("SIGTERM")?.();
listeners.get("SIGHUP")?.();
expect(quits).toEqual(["quit"]);
});
it("stops by default and only keeps running when keepRunningAfterQuit is enabled", () => { it("stops by default and only keeps running when keepRunningAfterQuit is enabled", () => {
expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_STOP_ON_QUIT)).toBe(true); expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_STOP_ON_QUIT)).toBe(true);
expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_KEEP_RUNNING)).toBe(false); expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_KEEP_RUNNING)).toBe(false);
@@ -14,10 +14,6 @@ interface BeforeQuitApp {
exit(code: number): void; exit(code: number): void;
} }
interface ExternalQuitSignalSource {
on(signal: NodeJS.Signals, listener: () => void): unknown;
}
interface QuitLifecycle { interface QuitLifecycle {
handleBeforeQuit(event: BeforeQuitEvent): void; handleBeforeQuit(event: BeforeQuitEvent): void;
handleBeforeQuitForUpdate(): void; handleBeforeQuitForUpdate(): void;
@@ -35,23 +31,6 @@ export interface StopOnQuitDeps {
showShutdownFeedback: () => void; showShutdownFeedback: () => void;
} }
export function registerExternalQuitSignals({
signals,
quit,
}: {
signals: ExternalQuitSignalSource;
quit: () => void;
}): void {
let quitRequested = false;
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"] satisfies NodeJS.Signals[]) {
signals.on(signal, () => {
if (quitRequested) return;
quitRequested = true;
quit();
});
}
}
export function shouldStopDesktopManagedDaemonOnQuit(settings: QuitLifecycleSettings): boolean { export function shouldStopDesktopManagedDaemonOnQuit(settings: QuitLifecycleSettings): boolean {
return !settings.daemon.keepRunningAfterQuit; return !settings.daemon.keepRunningAfterQuit;
} }
-2
View File
@@ -83,7 +83,6 @@ import {
} from "./daemon/daemon-manager.js"; } from "./daemon/daemon-manager.js";
import { import {
createQuitLifecycle, createQuitLifecycle,
registerExternalQuitSignals,
stopDesktopManagedDaemonOnQuitIfNeeded, stopDesktopManagedDaemonOnQuitIfNeeded,
} from "./daemon/quit-lifecycle.js"; } from "./daemon/quit-lifecycle.js";
import { runDesktopStartup } from "./desktop-startup.js"; import { runDesktopStartup } from "./desktop-startup.js";
@@ -1082,7 +1081,6 @@ const quitLifecycle = createQuitLifecycle({
// electron-updater forwards this event through Electron's built-in autoUpdater. // electron-updater forwards this event through Electron's built-in autoUpdater.
electronAutoUpdater.on("before-quit-for-update", quitLifecycle.handleBeforeQuitForUpdate); electronAutoUpdater.on("before-quit-for-update", quitLifecycle.handleBeforeQuitForUpdate);
app.on("before-quit", quitLifecycle.handleBeforeQuit); app.on("before-quit", quitLifecycle.handleBeforeQuit);
registerExternalQuitSignals({ signals: process, quit: () => app.quit() });
app.on("window-all-closed", () => { app.on("window-all-closed", () => {
if (process.platform !== "darwin") { if (process.platform !== "darwin") {
+88 -35
View File
@@ -366,6 +366,10 @@ type FetchAgentsResponsePageInfo = FetchAgentsResponsePayload["pageInfo"];
type AgentUpdatesFilter = FetchAgentsRequestFilter; type AgentUpdatesFilter = FetchAgentsRequestFilter;
type CreateAgentRequestMessage = Extract<SessionInboundMessage, { type: "create_agent_request" }>; type CreateAgentRequestMessage = Extract<SessionInboundMessage, { type: "create_agent_request" }>;
interface CreatedAgentRequest {
agent: AgentSnapshotPayload;
}
interface ResolvedSessionCreateAgentIntent { interface ResolvedSessionCreateAgentIntent {
config: AgentSessionConfig; config: AgentSessionConfig;
intent: CreateAgentIntent; intent: CreateAgentIntent;
@@ -676,6 +680,10 @@ export class Session {
private readonly hubExecutionController: HubExecutionController | null; private readonly hubExecutionController: HubExecutionController | null;
private readonly workspaceScripts: WorkspaceScriptsService; private readonly workspaceScripts: WorkspaceScriptsService;
private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch; private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch;
private readonly inFlightAgentCreationsByClientMessageId = new Map<
string,
Promise<CreatedAgentRequest>
>();
constructor(options: SessionOptions) { constructor(options: SessionOptions) {
const { const {
@@ -3102,10 +3110,86 @@ export class Session {
* Handle create agent request * Handle create agent request
*/ */
private async handleCreateAgentRequest(msg: CreateAgentRequestMessage): Promise<void> { private async handleCreateAgentRequest(msg: CreateAgentRequestMessage): Promise<void> {
const clientMessageId = msg.clientMessageId?.trim();
const existingCreation = clientMessageId
? this.inFlightAgentCreationsByClientMessageId.get(clientMessageId)
: undefined;
if (existingCreation) {
try {
const created = await existingCreation;
this.emitCreatedAgentResponse(msg.requestId, created.agent);
} catch (error) {
this.emitFailedAgentCreateResponse(msg.requestId, error);
}
return;
}
const creation = this.createAgentForRequest(msg);
if (clientMessageId) {
this.inFlightAgentCreationsByClientMessageId.set(clientMessageId, creation);
}
try {
const created = await creation;
this.emitCreatedAgentResponse(msg.requestId, created.agent);
} catch (error) {
const wireError = toWorktreeWireError(error);
this.sessionLogger.error({ err: error }, "Failed to create agent");
this.emitFailedAgentCreateResponse(msg.requestId, wireError);
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to create agent: ${wireError.message}`,
},
});
} finally {
if (
clientMessageId &&
this.inFlightAgentCreationsByClientMessageId.get(clientMessageId) === creation
) {
this.inFlightAgentCreationsByClientMessageId.delete(clientMessageId);
}
}
}
private emitCreatedAgentResponse(
requestId: string | undefined,
agent: AgentSnapshotPayload,
): void {
if (!requestId) return;
this.emit({
type: "status",
payload: {
status: "agent_created",
agentId: agent.id,
requestId,
agent,
},
});
}
private emitFailedAgentCreateResponse(requestId: string | undefined, error: unknown): void {
if (!requestId) return;
const wireError = toWorktreeWireError(error);
this.emit({
type: "status",
payload: {
status: "agent_create_failed",
requestId,
error: wireError.message,
errorCode: wireError.code,
},
});
}
private async createAgentForRequest(
msg: CreateAgentRequestMessage,
): Promise<CreatedAgentRequest> {
const { const {
config, config,
worktreeName, worktreeName,
requestId,
initialPrompt, initialPrompt,
clientMessageId, clientMessageId,
outputSchema, outputSchema,
@@ -3205,50 +3289,19 @@ export class Session {
agentId: snapshot.id, agentId: snapshot.id,
createdWorktree, createdWorktree,
}); });
if (requestId) { const agentPayload = await this.buildAgentPayload(liveSnapshot);
const agentPayload = await this.buildAgentPayload(liveSnapshot);
this.emit({
type: "status",
payload: {
status: "agent_created",
agentId: liveSnapshot.id,
requestId,
agent: agentPayload,
},
});
}
this.sessionLogger.info( this.sessionLogger.info(
{ agentId: snapshot.id, provider: snapshot.provider }, { agentId: snapshot.id, provider: snapshot.provider },
`Created agent ${snapshot.id} (${snapshot.provider})`, `Created agent ${snapshot.id} (${snapshot.provider})`,
); );
return { agent: agentPayload };
} catch (error) { } catch (error) {
await this.createAgentLifecycleDispatch.cleanupCreatedWorktreeAfterFailedAgentCreate({ await this.createAgentLifecycleDispatch.cleanupCreatedWorktreeAfterFailedAgentCreate({
createdWorktree: createdWorktreeForCleanup, createdWorktree: createdWorktreeForCleanup,
createdAgentId, createdAgentId,
}); });
const wireError = toWorktreeWireError(error); throw error;
this.sessionLogger.error({ err: error }, "Failed to create agent");
if (requestId) {
this.emit({
type: "status",
payload: {
status: "agent_create_failed",
requestId,
error: wireError.message,
errorCode: wireError.code,
},
});
}
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to create agent: ${wireError.message}`,
},
});
} }
} }
@@ -872,7 +872,7 @@ test("client heartbeat clears attention for the focused terminal", async () => {
}); });
}); });
test("create_agent_request keeps requested child cwd when grouped under an existing parent workspace", async () => { test("create_agent_request deduplicates concurrent draft submission retries", async () => {
const workdir = mkdtempSync(path.join(tmpdir(), "paseo-create-agent-cwd-")); const workdir = mkdtempSync(path.join(tmpdir(), "paseo-create-agent-cwd-"));
try { try {
const parent = path.join(workdir, "parent"); const parent = path.join(workdir, "parent");
@@ -981,14 +981,26 @@ test("create_agent_request keeps requested child cwd when grouped under an exist
terminalManager: null, terminalManager: null,
}), }),
); );
await session.handleMessage({ await Promise.all([
type: "create_agent_request", session.handleMessage({
requestId: "req-create-child", type: "create_agent_request",
config: { provider: "codex", cwd: child }, requestId: "req-create-child-1",
attachments: [], clientMessageId: "draft-submission-1",
}); config: { provider: "codex", cwd: child },
attachments: [],
}),
session.handleMessage({
type: "create_agent_request",
requestId: "req-create-child-2",
clientMessageId: "draft-submission-1",
config: { provider: "codex", cwd: child },
attachments: [],
}),
]);
const [createdAgent] = agentManager.listAgents(); const createdAgents = agentManager.listAgents();
expect(createdAgents).toHaveLength(1);
const [createdAgent] = createdAgents;
expect(createdAgent?.cwd).toBe(child); expect(createdAgent?.cwd).toBe(child);
const createdWorkspace = await workspaceRegistry.get(createdAgent!.workspaceId!); const createdWorkspace = await workspaceRegistry.get(createdAgent!.workspaceId!);
expect(createdWorkspace).not.toBeNull(); expect(createdWorkspace).not.toBeNull();
@@ -1007,10 +1019,22 @@ test("create_agent_request keeps requested child cwd when grouped under an exist
projectKey: createdWorkspace!.projectId, projectKey: createdWorkspace!.projectId,
checkout: { cwd: child }, checkout: { cwd: child },
}); });
expect(findByType(emitted, "status")?.payload).toMatchObject({ expect(
status: "agent_created", emitted.filter((message) => message.type === "status").map((message) => message.payload),
agent: { cwd: child }, ).toEqual(
}); expect.arrayContaining([
expect.objectContaining({
status: "agent_created",
requestId: "req-create-child-1",
agent: expect.objectContaining({ cwd: child }),
}),
expect.objectContaining({
status: "agent_created",
requestId: "req-create-child-2",
agent: expect.objectContaining({ cwd: child }),
}),
]),
);
} finally { } finally {
rmSync(workdir, { recursive: true, force: true }); rmSync(workdir, { recursive: true, force: true });
} }
@@ -1,6 +1,8 @@
import { execFile } from "node:child_process";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { promisify } from "node:util";
import type { Logger } from "pino"; import type { Logger } from "pino";
import { z } from "zod"; import { z } from "zod";
import type { ProviderUsage, ProviderUsageBalance } from "../../../server/messages.js"; import type { ProviderUsage, ProviderUsageBalance } from "../../../server/messages.js";
@@ -14,25 +16,8 @@ import {
unavailableUsage, unavailableUsage,
} from "../usage.js"; } from "../usage.js";
// Cursor stores auth in VS Code's ItemTable (state.vscdb). Modern builds keep the const execFileAsync = promisify(execFile);
// access token as a plain JWT string under `cursorAuth/accessToken`; older builds const CURSOR_SQLITE_TIMEOUT_MS = 2_000;
// kept a JSON blob under `cursorAuthStatus`. Read it with node:sqlite so we don't
// depend on a `sqlite3` CLI, which isn't installed by default on Windows (or on many
// Linux hosts) — a missing binary silently rendered Cursor usage unavailable.
const CURSOR_ACCESS_TOKEN_KEY = "cursorAuth/accessToken";
const CURSOR_LEGACY_AUTH_KEY = "cursorAuthStatus";
// @types/node@20 predates the node:sqlite typings; declare the slice we use.
interface CursorStateStatement {
get(...params: unknown[]): Record<string, unknown> | undefined;
}
interface CursorStateDatabase {
prepare(sql: string): CursorStateStatement;
close(): void;
}
interface NodeSqliteModule {
DatabaseSync: new (path: string, options?: { readOnly?: boolean }) => CursorStateDatabase;
}
const CursorBillingCycleTimestampSchema = z.preprocess( const CursorBillingCycleTimestampSchema = z.preprocess(
(value) => (typeof value === "string" || typeof value === "number" ? value : null), (value) => (typeof value === "string" || typeof value === "number" ? value : null),
@@ -62,7 +47,6 @@ type CursorUsageResponse = z.infer<typeof CursorUsageResponseSchema>;
interface CursorQuotaProviderOptions { interface CursorQuotaProviderOptions {
logger: Logger; logger: Logger;
fetch?: ProviderApiFetch; fetch?: ProviderApiFetch;
homeDir?: string;
} }
function parseCursorBillingCycleTimestamp( function parseCursorBillingCycleTimestamp(
@@ -86,38 +70,14 @@ function centsToDollars(value: number | null): number | null {
return value === null ? null : value / 100; return value === null ? null : value / 100;
} }
function readItemTableValue(db: CursorStateDatabase, key: string): string | null { async function readCursorTokenFromSqlite(): Promise<string | null> {
const row = db.prepare("SELECT value FROM ItemTable WHERE key = ?").get(key);
const value = row?.["value"];
if (typeof value === "string") return value;
if (value instanceof Uint8Array) return Buffer.from(value).toString("utf8");
return null;
}
function cursorTokenFromDb(db: CursorStateDatabase): string | null {
const modern = readItemTableValue(db, CURSOR_ACCESS_TOKEN_KEY)?.trim();
if (modern) return modern;
const legacy = readItemTableValue(db, CURSOR_LEGACY_AUTH_KEY);
if (legacy) {
try {
const parsed = CursorAuthStatusSchema.parse(JSON.parse(legacy));
if (parsed.accessToken) return parsed.accessToken;
} catch {
// ignore a malformed legacy blob
}
}
return null;
}
async function readCursorTokenFromSqlite(homeDir: string, logger: Logger): Promise<string | null> {
const dbPaths: string[] = []; const dbPaths: string[] = [];
if (process.env["APPDATA"]) { if (process.env["APPDATA"]) {
dbPaths.push(join(process.env["APPDATA"], "Cursor", "User", "globalStorage", "state.vscdb")); dbPaths.push(join(process.env["APPDATA"], "Cursor", "User", "globalStorage", "state.vscdb"));
} }
dbPaths.push( dbPaths.push(
join( join(
homeDir, homedir(),
"Library", "Library",
"Application Support", "Application Support",
"Cursor", "Cursor",
@@ -126,32 +86,22 @@ async function readCursorTokenFromSqlite(homeDir: string, logger: Logger): Promi
"state.vscdb", "state.vscdb",
), ),
); );
dbPaths.push(join(homeDir, ".config", "Cursor", "User", "globalStorage", "state.vscdb")); dbPaths.push(join(homedir(), ".config", "Cursor", "User", "globalStorage", "state.vscdb"));
// Held in a variable so TypeScript skips module resolution: @types/node@20 has no
// node:sqlite typings yet, while the runtime (Node 22+ / Electron) provides it.
const sqliteSpecifier: string = "node:sqlite";
let sqlite: NodeSqliteModule;
try {
sqlite = (await import(sqliteSpecifier)) as unknown as NodeSqliteModule;
} catch (err) {
logger.debug({ err }, "node:sqlite unavailable; cannot read Cursor state.vscdb");
return null; // runtime without node:sqlite
}
for (const path of dbPaths) { for (const path of dbPaths) {
if (!existsSync(path)) continue; if (!existsSync(path)) continue;
let db: CursorStateDatabase | undefined;
try { try {
db = new sqlite.DatabaseSync(path, { readOnly: true }); const { stdout } = await execFileAsync(
const token = cursorTokenFromDb(db); "sqlite3",
if (token) return token; [path, "SELECT value FROM ItemTable WHERE key = 'cursorAuthStatus'"],
} catch (err) { { timeout: CURSOR_SQLITE_TIMEOUT_MS },
// Locked/permission/corrupt/schema failures all land here; log so an );
// unavailable Cursor card is diagnosable, then try the next candidate. if (stdout) {
logger.debug({ err, path }, "Failed to read Cursor token from state.vscdb"); const parsed = CursorAuthStatusSchema.parse(JSON.parse(stdout.trim()));
} finally { if (parsed.accessToken) return parsed.accessToken;
db?.close(); }
} catch {
continue;
} }
} }
return null; return null;
@@ -163,19 +113,17 @@ export class CursorQuotaProvider implements ProviderUsageFetcher {
private readonly logger: Logger; private readonly logger: Logger;
private readonly fetchApi: ProviderApiFetch; private readonly fetchApi: ProviderApiFetch;
private readonly homeDir: string;
constructor(options: CursorQuotaProviderOptions) { constructor(options: CursorQuotaProviderOptions) {
this.logger = options.logger; this.logger = options.logger;
this.fetchApi = options.fetch ?? fetch; this.fetchApi = options.fetch ?? fetch;
this.homeDir = options.homeDir ?? homedir();
} }
async fetchUsage(): Promise<ProviderUsage> { async fetchUsage(): Promise<ProviderUsage> {
const token = const token =
process.env["CURSOR_ACCESS_TOKEN"] || process.env["CURSOR_ACCESS_TOKEN"] ||
process.env["CURSOR_TOKEN"] || process.env["CURSOR_TOKEN"] ||
(await readCursorTokenFromSqlite(this.homeDir, this.logger)); (await readCursorTokenFromSqlite());
if (!token) return unavailableUsage(this); if (!token) return unavailableUsage(this);
@@ -1,7 +1,6 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { mkdtempSync, rmSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs";
import { promises as fsPromises } from "node:fs"; import { promises as fsPromises } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -59,31 +58,6 @@ function writeKimiCredentials(dir: string, accessToken: string, overrides: objec
); );
} }
// node:sqlite has no @types/node@20 typings; require it with a narrow local type.
const testRequire = createRequire(import.meta.url);
interface TestSqliteDb {
exec(sql: string): void;
prepare(sql: string): { run(...params: unknown[]): void };
close(): void;
}
// Cursor builds have stored ItemTable values as both TEXT and BLOB. Keys map to the
// real layouts: a plain modern token or the legacy JSON object.
function writeCursorStateDb(homeDir: string, rows: Record<string, string | Uint8Array>): void {
const dir = join(homeDir, ".config", "Cursor", "User", "globalStorage");
mkdirSync(dir, { recursive: true });
const { DatabaseSync } = testRequire("node:sqlite") as {
DatabaseSync: new (path: string) => TestSqliteDb;
};
const db = new DatabaseSync(join(dir, "state.vscdb"));
db.exec("CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)");
const insert = db.prepare("INSERT INTO ItemTable (key, value) VALUES (?, ?)");
for (const [key, value] of Object.entries(rows)) {
insert.run(key, value);
}
db.close();
}
function writeGrokAuth(home: string, auth: Record<string, unknown>): void { function writeGrokAuth(home: string, auth: Record<string, unknown>): void {
mkdirSync(join(home, ".grok"), { recursive: true }); mkdirSync(join(home, ".grok"), { recursive: true });
writeFileSync(join(home, ".grok", "auth.json"), JSON.stringify(auth)); writeFileSync(join(home, ".grok", "auth.json"), JSON.stringify(auth));
@@ -398,7 +372,6 @@ describe("real provider usage fetchers", () => {
platform?: typeof process.platform; platform?: typeof process.platform;
keychain?: () => Promise<unknown | null>; keychain?: () => Promise<unknown | null>;
kimiHomeDir?: string; kimiHomeDir?: string;
cursorHomeDir?: string;
miniMaxConfigPath?: string; miniMaxConfigPath?: string;
miniMaxCredentialsPath?: string; miniMaxCredentialsPath?: string;
} = {}, } = {},
@@ -419,11 +392,7 @@ describe("real provider usage fetchers", () => {
}), }),
new CodexQuotaProvider({ logger, codexHome, fetch: fetchThroughTestDouble }), new CodexQuotaProvider({ logger, codexHome, fetch: fetchThroughTestDouble }),
new CopilotQuotaProvider({ logger, fetch: fetchThroughTestDouble }), new CopilotQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
new CursorQuotaProvider({ new CursorQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
logger,
fetch: fetchThroughTestDouble,
homeDir: options.cursorHomeDir,
}),
new ZaiQuotaProvider({ logger, fetch: fetchThroughTestDouble }), new ZaiQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
new GrokQuotaProvider({ new GrokQuotaProvider({
logger, logger,
@@ -733,75 +702,6 @@ describe("real provider usage fetchers", () => {
}); });
}); });
it("reads the Cursor token from the modern cursorAuth/accessToken key in state.vscdb", async () => {
writeCursorStateDb(homeDir, { "cursorAuth/accessToken": "cursor_state_jwt" });
let authorization: string | null = null;
fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => {
authorization = (init?.headers as Record<string, string> | undefined)?.Authorization ?? null;
return jsonResponse({
planUsage: {
totalSpend: "1500",
includedSpend: "1000",
bonusSpend: "500",
remaining: "2500",
limit: "4000",
},
billingCycleStart: "2026-01-14T12:42:14.000Z",
billingCycleEnd: "2026-02-14T12:42:14.000Z",
});
}) as unknown as typeof fetch;
const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor");
expect(authorization).toBe("Bearer cursor_state_jwt");
expect(cursor).toMatchObject({
status: "available",
balances: [expect.objectContaining({ id: "plan_usage", used: 15, remaining: 25, limit: 40 })],
});
});
it("falls back to the legacy cursorAuthStatus JSON blob when the modern key is absent", async () => {
writeCursorStateDb(homeDir, {
cursorAuthStatus: Buffer.from(JSON.stringify({ accessToken: "cursor_legacy_jwt" }), "utf8"),
});
let authorization: string | null = null;
fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => {
authorization = (init?.headers as Record<string, string> | undefined)?.Authorization ?? null;
return jsonResponse({
planUsage: { totalSpend: "0", remaining: "100", limit: "100" },
billingCycleStart: null,
billingCycleEnd: null,
});
}) as unknown as typeof fetch;
const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor");
expect(authorization).toBe("Bearer cursor_legacy_jwt");
expect(cursor.status).toBe("available");
});
it("logs a debug diagnostic and stays unavailable when state.vscdb is unreadable", async () => {
const dir = join(homeDir, ".config", "Cursor", "User", "globalStorage");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "state.vscdb"), "not a sqlite database");
const logger = createLogger();
const provider = new CursorQuotaProvider({
logger,
fetch: (() => {
throw new Error("usage API should not be called without a token");
}) as unknown as typeof fetch,
homeDir,
});
const usage = await provider.fetchUsage();
expect(usage.status).toBe("unavailable");
expect((logger as unknown as { debug: ReturnType<typeof vi.fn> }).debug).toHaveBeenCalledWith(
expect.objectContaining({ path: expect.stringContaining("state.vscdb") }),
expect.stringContaining("Failed to read Cursor token"),
);
});
it("fetches Z.ai usage from ZAI_API_KEY", async () => { it("fetches Z.ai usage from ZAI_API_KEY", async () => {
process.env["ZAI_API_KEY"] = "zai_test_token"; process.env["ZAI_API_KEY"] = "zai_test_token";
fetchApi = mockFetch( fetchApi = mockFetch(
+1 -1
View File
@@ -19,7 +19,7 @@
}, },
"desktop": { "desktop": {
"type": "service", "type": "service",
"command": "PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"${PASEO_WORKTREE_PATH:-$PWD}\" PASEO_HOME=\"${PASEO_WORKTREE_PATH:-$PWD}/.dev/paseo-home\" PASEO_LISTEN=0.0.0.0:${PASEO_SERVICE_DAEMON_PORT:-6768} PASEO_DEV_DAEMON_ENDPOINT=localhost:${PASEO_SERVICE_DAEMON_PORT:-6768} EXPO_PORT=${PASEO_PORT:-} exec ./packages/desktop/scripts/dev.sh" "command": "PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"${PASEO_WORKTREE_PATH:-$PWD}\" PASEO_HOME=\"${PASEO_WORKTREE_PATH:-$PWD}/.dev/paseo-home\" PASEO_LISTEN=0.0.0.0:${PASEO_SERVICE_DAEMON_PORT:-6768} PASEO_DEV_DAEMON_ENDPOINT=localhost:${PASEO_SERVICE_DAEMON_PORT:-6768} EXPO_PORT=${PASEO_PORT:-} npm run dev --workspace=@getpaseo/desktop"
}, },
"ios-simulator": { "ios-simulator": {
"type": "service", "type": "service",
+1 -9
View File
@@ -72,18 +72,10 @@ environments:
cwd: /workspace/project cwd: /workspace/project
worktree: worktree:
mode: branch-off mode: branch-off
newBranch: trigger-${{ paseo.execution.id }} newBranch: paseo/review
base: origin/main base: origin/main
``` ```
`newBranch` is a branch-name string. Embed `${{ paseo.execution.id }}`, which renders the execution's UUID, so every execution branches off `base` on its own branch and keeps it when Hub retries or recovers that execution.
One execution is one step run, so two steps selecting the same environment get separate branches.
`${{ paseo.execution.id }}` is the only expression `newBranch` accepts. `paseo.prompt`, `paseo.context`, `paseo.inputs.*`, `values.*`, `steps.<id>.outputs.*`, and provider event fields are unavailable here, and each one fails bundle activation at the authored field, such as `.paseo/hub.yml.environments.review.worktree.newBranch`.
`${{ paseo.execution.id }}` fails activation the same way anywhere else in a bundle. `branch` and `prNumber` take literal values.
An environment is a complete named object. A step selects its name; objects are not inherited, merged, or partially overridden. An environment is a complete named object. A step selects its name; objects are not inherited, merged, or partially overridden.
### Named agents ### Named agents
+2 -4
View File
@@ -80,13 +80,11 @@ To keep executions off your working tree, add a worktree:
```yaml ```yaml
worktree: worktree:
mode: branch-off mode: branch-off
newBranch: trigger-${{ paseo.execution.id }} newBranch: hub/investigation
base: origin/main base: origin/main
``` ```
`${{ paseo.execution.id }}` renders the execution's UUID, so every execution gets its own branch off `origin/main`. See [Git worktrees](/docs/worktrees) for setup hooks and scripts.
[Environment fields](/docs/hub/configuration/hub-yml#environments) lists what `newBranch` accepts. See [Git worktrees](/docs/worktrees) for setup hooks and scripts.
## What Hub owns ## What Hub owns