From a05e1e1c58de2cda7948b38218b2c97f16348640 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 22 May 2026 19:23:00 +0000 Subject: [PATCH] Fix Mac VM lane launch context cache priming and invalidation VM-backed agent and shell launches read SSH targets from a synchronous cache that was only refreshed on lane-placement-changed. Creating a lane with runtimePlacement macos-vm skipped that path, and VM restart/stop did not invalidate stale IPs. - Wire VM link, mirror sync, and placement-changed when creating macos-vm lanes - Reuse the same wiring for attachLaneToVm (including idempotent re-attach) - Refresh/invalidate cache on vm-updated and completed lifecycle operations - Prime cache on runtime_ready for the active VM lane Co-authored-by: Arul Sharma --- apps/desktop/src/main/main.ts | 42 +++++- .../services/lanes/laneLaunchContext.test.ts | 55 ++++++++ .../main/services/lanes/laneLaunchContext.ts | 58 +++++++- .../src/main/services/lanes/laneService.ts | 126 +++++++++++------- 4 files changed, 225 insertions(+), 56 deletions(-) diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index c4d53603ae..baa2dbf01b 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -27,6 +27,7 @@ import { invalidateVmLaneLaunchCache, refreshVmLaneLaunchCache, setMacosVmLaunchProvider, + syncMacosVmLaunchCacheFromEvent, } from "./services/lanes/laneLaunchContext"; import { createLaneEnvironmentService } from "./services/lanes/laneEnvironmentService"; import { createLaneTemplateService } from "./services/lanes/laneTemplateService"; @@ -3187,8 +3188,12 @@ app.whenReady().then(async () => { projectRoot, logger, resolveLanes: async () => laneService.list({ includeArchived: false }), - onEvent: (payload) => - emitProjectEvent(projectRoot, IPC.macosVmEvent, payload), + onEvent: (payload) => { + syncMacosVmLaunchCacheFromEvent(payload, (event, fields) => { + logger.warn(event, fields); + }); + emitProjectEvent(projectRoot, IPC.macosVmEvent, payload); + }, captureWindowSources: async () => { const sources = await desktopCapturer.getSources({ types: ["window"], @@ -3206,6 +3211,15 @@ app.whenReady().then(async () => { // wire-up needs the pool/registry to be lifted into a shared scope. onRuntimeReady: ({ vmName, ipAddress, username }) => { logger.info("macos_vm.runtime_ready", { vmName, ipAddress, username }); + const vmLane = laneService.findExistingVmLane(); + if (!vmLane) return; + invalidateVmLaneLaunchCache(vmLane.id); + void refreshVmLaneLaunchCache({ laneId: vmLane.id }).catch((error) => { + logger.warn("macos_vm.runtime_ready_cache_refresh_failed", { + laneId: vmLane.id, + error: error instanceof Error ? error.message : String(error), + }); + }); }, }); // Wire macosVmService into laneService now that both exist. The hooks let @@ -4204,8 +4218,12 @@ app.whenReady().then(async () => { worktreePath: lane.worktreePath, })); }, - onEvent: (payload) => - emitProjectEvent(projectRoot, IPC.macosVmEvent, payload), + onEvent: (payload) => { + syncMacosVmLaunchCacheFromEvent(payload, (event, fields) => { + logger.warn(event, fields); + }); + emitProjectEvent(projectRoot, IPC.macosVmEvent, payload); + }, captureWindowSources: async () => { const sources = await desktopCapturer.getSources({ types: ["window"], @@ -4222,6 +4240,22 @@ app.whenReady().then(async () => { // bootstrap script only writes a marker file). onRuntimeReady: ({ vmName, ipAddress, username }) => { logger.info("macos_vm.runtime_ready", { vmName, ipAddress, username }); + void (async () => { + const response = await localRuntimePool.callActionForRoot(projectRoot, { + domain: "lane", + action: "list", + args: { includeArchived: false, includeStatus: false }, + }); + const lanes = Array.isArray(response.result) ? response.result as LaneSummary[] : []; + const vmLane = lanes.find((lane) => lane.runtimePlacement === "macos-vm") ?? null; + if (!vmLane) return; + invalidateVmLaneLaunchCache(vmLane.id); + await refreshVmLaneLaunchCache({ laneId: vmLane.id }); + })().catch((error) => { + logger.warn("macos_vm.runtime_ready_cache_refresh_failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); }, }); logger.info("project.runtime_bound", { diff --git a/apps/desktop/src/main/services/lanes/laneLaunchContext.test.ts b/apps/desktop/src/main/services/lanes/laneLaunchContext.test.ts index 7887717569..6357730a8a 100644 --- a/apps/desktop/src/main/services/lanes/laneLaunchContext.test.ts +++ b/apps/desktop/src/main/services/lanes/laneLaunchContext.test.ts @@ -32,6 +32,7 @@ import { refreshVmLaneLaunchCache, resolveLaneLaunchContext, setMacosVmLaunchProvider, + syncMacosVmLaunchCacheFromEvent, VmNotReadyError, type MacosVmLaunchProvider, } from "./laneLaunchContext"; @@ -546,4 +547,58 @@ describe("resolveLaneLaunchContext", () => { expect(result.cwd).toBe("/real/lane/root"); }); }); + + describe("syncMacosVmLaunchCacheFromEvent", () => { + it("refreshes cache after vm-updated with a new IP", async () => { + const provider = makeVmProvider({ + status: makeVmStatus({ laneId: "lane-vm", ipAddress: "192.168.64.11" }), + }); + setMacosVmLaunchProvider(provider); + await refreshVmLaneLaunchCache({ laneId: "lane-vm", provider }); + + const updatedStatus = makeVmStatus({ laneId: "lane-vm", ipAddress: "192.168.64.22" }); + provider.getStatus = vi.fn(async () => updatedStatus); + syncMacosVmLaunchCacheFromEvent({ + type: "vm-updated", + vm: updatedStatus.laneVm!, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const result = resolveLaneLaunchContext({ + laneService: makeLaneService("/projects/my-lane", "macos-vm"), + laneId: "lane-vm", + purpose: "start agent", + }); + expect(result.sshTarget?.ip).toBe("192.168.64.22"); + }); + + it("ignores unrelated macos VM operation events", async () => { + const provider = makeVmProvider({ + status: makeVmStatus({ laneId: "lane-vm", ipAddress: "192.168.64.11" }), + }); + setMacosVmLaunchProvider(provider); + await refreshVmLaneLaunchCache({ laneId: "lane-vm", provider }); + provider.getStatus = vi.fn(async () => { + throw new Error("should not refresh"); + }); + + syncMacosVmLaunchCacheFromEvent({ + type: "operation", + operation: "screenshot", + state: "completed", + laneId: "lane-vm", + vmName: "ade-vm", + message: "done", + occurredAt: new Date().toISOString(), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const result = resolveLaneLaunchContext({ + laneService: makeLaneService("/projects/my-lane", "macos-vm"), + laneId: "lane-vm", + purpose: "start agent", + }); + expect(result.sshTarget?.ip).toBe("192.168.64.11"); + }); + }); }); diff --git a/apps/desktop/src/main/services/lanes/laneLaunchContext.ts b/apps/desktop/src/main/services/lanes/laneLaunchContext.ts index 6d57cd3848..cc0a79e099 100644 --- a/apps/desktop/src/main/services/lanes/laneLaunchContext.ts +++ b/apps/desktop/src/main/services/lanes/laneLaunchContext.ts @@ -1,7 +1,11 @@ import fs from "node:fs"; import path from "node:path"; import type { createLaneService } from "./laneService"; -import type { MacosVmPhaseNumber, MacosVmStatus } from "../../../shared/types/macosVm"; +import type { + MacosVmEventPayload, + MacosVmPhaseNumber, + MacosVmStatus, +} from "../../../shared/types/macosVm"; import { resolvePathWithinRoot } from "../shared/utils"; export type LaneLaunchExecStrategy = "local" | "ssh"; @@ -310,3 +314,55 @@ export function invalidateVmLaneLaunchCache(laneId?: string): void { } vmLaunchContextCache.clear(); } + +const MACOS_VM_LAUNCH_CACHE_REFRESH_OPERATIONS = new Set([ + "provision", + "start", + "stop", + "restart", + "set-credentials", + "delete", + "wipe", +]); + +/** + * Keep the synchronous VM launch cache aligned with macosVmService lifecycle + * events. Without this, agent/PTY launches can keep a stale SSH target for up + * to VM_LAUNCH_CONTEXT_TTL_MS after restart/stop. + */ +export function syncMacosVmLaunchCacheFromEvent( + payload: MacosVmEventPayload, + log?: (event: string, fields: Record) => void, +): void { + const laneId = (() => { + if (payload.type === "vm-updated") { + return payload.vm.laneId?.trim() ?? ""; + } + if (payload.type === "operation") { + return payload.laneId?.trim() ?? ""; + } + return ""; + })(); + if (!laneId) return; + + const shouldRefresh = (() => { + if (payload.type === "vm-updated") return true; + if (payload.type === "operation") { + return ( + payload.state === "completed" + && MACOS_VM_LAUNCH_CACHE_REFRESH_OPERATIONS.has(payload.operation) + ); + } + return false; + })(); + if (!shouldRefresh) return; + + invalidateVmLaneLaunchCache(laneId); + void refreshVmLaneLaunchCache({ laneId }).catch((error) => { + log?.("lane.vm_launch_cache_refresh_failed", { + laneId, + eventType: payload.type, + error: error instanceof Error ? error.message : String(error), + }); + }); +} diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index bd62c1ae49..b84034d086 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -2196,10 +2196,71 @@ export function createLaneService({ linearIssue, linearIssueLinks: getLaneLinearIssueLinks(laneId), }); + if (runtimePlacement === "macos-vm") { + await wireMacosVmLanePlacement({ + laneId, + previousPlacement: "local", + rollbackPlacementOnLinkFailure: true, + }); + } + if (linearIssue) notifyLinearIssueLinked(summary, linearIssue); return summary; }; + const wireMacosVmLanePlacement = async (args: { + laneId: string; + previousPlacement: LaneRuntimePlacement; + rollbackPlacementOnLinkFailure: boolean; + }): Promise => { + const laneId = String(args.laneId ?? "").trim(); + if (!laneId.length) return; + const hooks = activeMacosVmHooks; + if (hooks?.linkLaneToCurrentVm) { + try { + await hooks.linkLaneToCurrentVm({ laneId }); + } catch (error) { + logger.warn("laneService.wire_vm_link_failed", { + laneId, + error: error instanceof Error ? error.message : String(error), + }); + if (args.rollbackPlacementOnLinkFailure) { + db.run( + ` + update lanes + set runtime_placement = 'local' + where id = ? + and project_id = ? + `, + [laneId, projectId], + ); + invalidateLaneListCache(); + } + throw error; + } + } + if (hooks?.startMirrorSyncForLane) { + try { + await hooks.startMirrorSyncForLane({ laneId }); + } catch (error) { + logger.warn("laneService.wire_vm_mirror_sync_failed", { + laneId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + const fromPlacement = + args.previousPlacement === "macos-vm" ? "local" : args.previousPlacement; + emitPlacementChanged({ + type: "lane-placement-changed", + laneId, + from: fromPlacement, + to: "macos-vm", + changedAt: new Date().toISOString(), + }); + }; + const getRowsById = (includeArchived = true): Map => new Map(getAllLaneRows(includeArchived).map((row) => [row.id, row] as const)); @@ -4597,61 +4658,24 @@ export function createLaneService({ const row = getLaneRow(laneId); if (!row) throw new Error(`Lane not found: ${laneId}`); const previous = normalizeRuntimePlacement(row.runtime_placement); - if (previous === "macos-vm") return; - - db.run( - ` - update lanes - set runtime_placement = 'macos-vm' - where id = ? - and project_id = ? - `, - [row.id, projectId], - ); - invalidateLaneListCache(); - const hooks = activeMacosVmHooks; - // Treat the link step as the critical one — if it fails we cannot host - // the lane in the VM and need to roll the placement flip back. Mirror - // sync failure is recoverable (retry on next restart) so we only warn. - if (hooks?.linkLaneToCurrentVm) { - try { - await hooks.linkLaneToCurrentVm({ laneId: row.id }); - } catch (error) { - logger.warn("laneService.attach_link_to_vm_failed_rolling_back", { - laneId: row.id, - error: error instanceof Error ? error.message : String(error), - }); - db.run( - ` - update lanes - set runtime_placement = 'local' - where id = ? - and project_id = ? - `, - [row.id, projectId], - ); - invalidateLaneListCache(); - throw error; - } - } - if (hooks?.startMirrorSyncForLane) { - try { - await hooks.startMirrorSyncForLane({ laneId: row.id }); - } catch (error) { - logger.warn("laneService.attach_start_mirror_sync_failed", { - laneId: row.id, - error: error instanceof Error ? error.message : String(error), - }); - } + if (previous !== "macos-vm") { + db.run( + ` + update lanes + set runtime_placement = 'macos-vm' + where id = ? + and project_id = ? + `, + [row.id, projectId], + ); + invalidateLaneListCache(); } - emitPlacementChanged({ - type: "lane-placement-changed", + await wireMacosVmLanePlacement({ laneId: row.id, - from: "local", - to: "macos-vm", - changedAt: new Date().toISOString(), + previousPlacement: previous, + rollbackPlacementOnLinkFailure: previous !== "macos-vm", }); },