Skip to content
Merged
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
46 changes: 33 additions & 13 deletions apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env node
import { Buffer } from "node:buffer";
import { spawn, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs";
import net from "node:net";
import path from "node:path";
Expand Down Expand Up @@ -7839,15 +7840,17 @@ function automationsExampleText(): string {
laneMode: "create",
laneNamePreset: "issue-num-title",
session: {
prompt: "Investigate and propose a fix for {{trigger.issue.title}}.",
title: "Issue fix",
codexFastMode: true,
},
},
actions: [
{
type: "agent-session",
modelId: "claude-opus-4-7",
prompt: "Investigate and propose a fix for {{trigger.issue.title}}.",
modelConfig: {
orchestratorModel: {
modelId: "openai/gpt-5.5",
thinkingLevel: "xhigh",
},
],
},
},
null,
2,
Expand Down Expand Up @@ -10256,6 +10259,14 @@ function shouldReplaceMachineRuntimeVersion(runtimeVersion: string | null): bool
return runtimeVersion == null || runtimeVersion !== VERSION;
}

function computeRuntimeBuildHash(filePath: string): string | null {
try {
return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
} catch {
return null;
}
}

async function initializeMachineRuntimeDaemon(
client: SocketJsonRpcClient,
options: GlobalOptions,
Expand Down Expand Up @@ -10291,26 +10302,35 @@ async function spawnMachineRuntimeDaemon(
const { resolveAdeServeCommand } = await import("./serviceManager/common");
const serviceCommand = resolveAdeServeCommand();
const args = [...serviceCommand.args];
let runtimeBuildHash: string | null = null;
if (
serviceCommand.command === process.execPath &&
args.length === 1 &&
args[0] === "serve" &&
fs.existsSync(CLI_DIST_PATH)
) {
args.splice(0, 1, CLI_DIST_PATH, "serve");
runtimeBuildHash = computeRuntimeBuildHash(CLI_DIST_PATH);
} else if (serviceCommand.command === process.execPath && args[0]) {
runtimeBuildHash = computeRuntimeBuildHash(path.resolve(args[0]));
}
args.push("--socket", socketPath);

const env: NodeJS.ProcessEnv = {
...process.env,
...(serviceCommand.env ?? {}),
ADE_DEFAULT_ROLE: options.role,
ADE_RPC_SOCKET_PATH: socketPath,
ADE_RUNTIME_SOCKET_PATH: socketPath,
};
if (runtimeBuildHash) {
env.ADE_RUNTIME_BUILD_HASH = runtimeBuildHash;
}

const child = spawn(serviceCommand.command, args, {
detached: true,
stdio: "ignore",
env: {
...process.env,
...(serviceCommand.env ?? {}),
ADE_DEFAULT_ROLE: options.role,
ADE_RPC_SOCKET_PATH: socketPath,
ADE_RUNTIME_SOCKET_PATH: socketPath,
},
env,
});
child.once("error", () => {});
child.unref();
Expand Down
75 changes: 70 additions & 5 deletions apps/ade-cli/src/services/projects/projectScope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,14 +140,24 @@ describe("ProjectScopeRegistry", () => {
await scopeRegistry.disposeAll();
});

it("can switch the daemon sync host to a requested project", async () => {
it("switches the daemon sync host without disposing active project scopes", async () => {
const { registry, first, second } = createRegistry();
const firstDispose = vi.fn();
const secondDispose = vi.fn();
const onDisposeProject = vi.fn();
const firstSyncService = {
initialize: vi.fn(async () => undefined),
setHostDiscoveryEnabled: vi.fn(),
setHostStartupEnabled: vi.fn(async () => undefined),
};
const secondSyncService = {
initialize: vi.fn(async () => undefined),
setHostDiscoveryEnabled: vi.fn(),
setHostStartupEnabled: vi.fn(async () => undefined),
};
createAdeRuntimeMock
.mockResolvedValueOnce({ dispose: firstDispose })
.mockResolvedValueOnce({ dispose: secondDispose });
.mockResolvedValueOnce({ dispose: firstDispose, syncService: firstSyncService })
.mockResolvedValueOnce({ dispose: secondDispose, syncService: secondSyncService });
const scopeRegistry = new ProjectScopeRegistry(registry, {
onDisposeProject,
syncRuntime: {
Expand All @@ -162,8 +172,14 @@ describe("ProjectScopeRegistry", () => {
await scopeRegistry.ensureSyncHost(first.projectId);
await scopeRegistry.ensureSyncHost(second.projectId);

expect(firstDispose).toHaveBeenCalledTimes(1);
expect(onDisposeProject).toHaveBeenCalledWith(first.projectId);
expect(firstDispose).not.toHaveBeenCalled();
expect(secondDispose).not.toHaveBeenCalled();
expect(onDisposeProject).not.toHaveBeenCalled();
expect(firstSyncService.setHostDiscoveryEnabled).toHaveBeenCalledWith(false);
expect(firstSyncService.setHostStartupEnabled).toHaveBeenCalledWith(false);
expect(secondSyncService.setHostDiscoveryEnabled).toHaveBeenCalledWith(true);
expect(secondSyncService.setHostStartupEnabled).toHaveBeenCalledWith(true);
expect(secondSyncService.initialize).toHaveBeenCalled();
expect(createAdeRuntimeMock).toHaveBeenCalledTimes(2);
expect(createAdeRuntimeMock.mock.calls[1]?.[0]).toMatchObject({
projectRoot: second.rootPath,
Expand All @@ -176,9 +192,58 @@ describe("ProjectScopeRegistry", () => {
});

await scopeRegistry.disposeAll();
expect(firstDispose).toHaveBeenCalledTimes(1);
expect(secondDispose).toHaveBeenCalledTimes(1);
});

it("promotes an existing warm project when selecting the default sync host", async () => {
const { registry, first, second } = createRegistry();
const firstSyncService = {
initialize: vi.fn(async () => undefined),
setHostDiscoveryEnabled: vi.fn(),
setHostStartupEnabled: vi.fn(async () => undefined),
};
const secondSyncService = {
initialize: vi.fn(async () => undefined),
setHostDiscoveryEnabled: vi.fn(),
setHostStartupEnabled: vi.fn(async () => undefined),
};
createAdeRuntimeMock
.mockResolvedValueOnce({ dispose: vi.fn(), syncService: firstSyncService })
.mockResolvedValueOnce({ dispose: vi.fn(), syncService: secondSyncService });
const scopeRegistry = new ProjectScopeRegistry(registry, {
syncRuntime: {
enabled: true,
hostStartupEnabled: true,
hostDiscoveryEnabled: true,
forceHostRole: true,
runtimeKind: "daemon",
},
});

await scopeRegistry.ensureSyncHost(first.projectId);
await scopeRegistry.get(second.projectId);
const file = JSON.parse(fs.readFileSync(registry.path, "utf8")) as {
projects: Array<{ projectId: string; lastOpenedAt: number; addedAt: number }>;
};
file.projects = file.projects.map((project) => ({
...project,
lastOpenedAt: project.projectId === second.projectId ? 2_000 : 1_000,
addedAt: project.projectId === second.projectId ? 2_000 : 1_000,
}));
fs.writeFileSync(registry.path, JSON.stringify(file, null, 2));
await scopeRegistry.dispose(first.projectId);
const promoted = await scopeRegistry.ensureSyncHost();

expect(promoted?.registryProjectId).toBe(second.projectId);
expect(createAdeRuntimeMock).toHaveBeenCalledTimes(2);
expect(secondSyncService.setHostDiscoveryEnabled).toHaveBeenCalledWith(true);
expect(secondSyncService.setHostStartupEnabled).toHaveBeenCalledWith(true);
expect(secondSyncService.initialize).toHaveBeenCalled();

await scopeRegistry.disposeAll();
});

it("passes runtime capability options into project runtimes", async () => {
const { registry, first } = createRegistry();
const scopeRegistry = new ProjectScopeRegistry(registry, {
Expand Down
39 changes: 33 additions & 6 deletions apps/ade-cli/src/services/projects/projectScope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,15 +112,21 @@ export class ProjectScopeRegistry {
async ensureSyncHost(projectId?: ProjectId): Promise<ProjectScope | null> {
if (!this.options.syncRuntime?.enabled) return null;
if (projectId) {
if (this.scopes.has(projectId) && this.syncHostProjectId !== projectId) {
await this.dispose(projectId);
}
const existingHostId = this.syncHostProjectId;
if (existingHostId && existingHostId !== projectId) {
await this.dispose(existingHostId);
await this.configureCachedSyncHost(existingHostId, false);
}
this.syncHostProjectId = projectId;
return await this.get(projectId);
try {
const scope = await this.get(projectId);
await this.configureSyncHost(scope, true);
return scope;
} catch (error) {
if (this.syncHostProjectId === projectId) {
this.syncHostProjectId = null;
}
throw error;
}
}

const existingHostId = this.syncHostProjectId;
Expand All @@ -139,7 +145,28 @@ export class ProjectScopeRegistry {
const openedDelta = right.lastOpenedAt - left.lastOpenedAt;
return openedDelta !== 0 ? openedDelta : right.addedAt - left.addedAt;
})[0];
return record ? this.get(record.projectId) : null;
return record ? this.ensureSyncHost(record.projectId) : null;
}

private async configureCachedSyncHost(
projectId: ProjectId,
enabled: boolean,
): Promise<void> {
const cached = this.scopes.get(projectId);
if (!cached) return;
const scope = await cached.catch(() => null);
if (scope) await this.configureSyncHost(scope, enabled);
}

private async configureSyncHost(
scope: ProjectScope,
enabled: boolean,
): Promise<void> {
const syncService = scope.runtime.syncService;
if (!syncService) return;
syncService.setHostDiscoveryEnabled?.(enabled);
await syncService.setHostStartupEnabled?.(enabled);
if (enabled) await syncService.initialize();
}

private buildSyncRuntimeOptions(projectId: ProjectId): AdeRuntimeSyncOptions | null {
Expand Down
Loading
Loading