Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 38 additions & 4 deletions apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"],
Expand All @@ -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
Expand Down Expand Up @@ -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"],
Expand All @@ -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", {
Expand Down
55 changes: 55 additions & 0 deletions apps/desktop/src/main/services/lanes/laneLaunchContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
refreshVmLaneLaunchCache,
resolveLaneLaunchContext,
setMacosVmLaunchProvider,
syncMacosVmLaunchCacheFromEvent,
VmNotReadyError,
type MacosVmLaunchProvider,
} from "./laneLaunchContext";
Expand Down Expand Up @@ -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");
});
});
});
58 changes: 57 additions & 1 deletion apps/desktop/src/main/services/lanes/laneLaunchContext.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string, unknown>) => 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),
});
});
}
126 changes: 75 additions & 51 deletions apps/desktop/src/main/services/lanes/laneService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
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<string, LaneRow> =>
new Map(getAllLaneRows(includeArchived).map((row) => [row.id, row] as const));

Expand Down Expand Up @@ -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",
});
},

Expand Down