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
59 changes: 47 additions & 12 deletions apps/desktop/src/main/adeMcpProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ type RuntimeRoots = {
workspaceRoot: string;
};

const MCP_SOCKET_CONNECT_TIMEOUT_MS = 5_000;
const MCP_SOCKET_CONNECT_RETRY_DELAY_MS = 150;

function resolveCliArg(flag: string): string | null {
const args = process.argv.slice(2);
for (let i = 0; i < args.length; i += 1) {
Expand Down Expand Up @@ -118,6 +121,43 @@ function relayProxyInputWithIdentity(socket: net.Socket): void {
});
}

function isRetriableSocketConnectError(error: NodeJS.ErrnoException): boolean {
return error.code === "ENOENT" || error.code === "ECONNREFUSED";
}

async function connectToSocketWithRetry(socketPath: string): Promise<net.Socket> {
const deadline = Date.now() + MCP_SOCKET_CONNECT_TIMEOUT_MS;

while (true) {
const socket = net.createConnection(socketPath);
try {
await new Promise<void>((resolve, reject) => {
const handleConnect = () => {
socket.off("error", handleError);
resolve();
};
const handleError = (error: NodeJS.ErrnoException) => {
socket.off("connect", handleConnect);
reject(error);
};
socket.once("connect", handleConnect);
socket.once("error", handleError);
});
return socket;
} catch (error) {
socket.destroy();
const nextError = error as NodeJS.ErrnoException;
if (!isRetriableSocketConnectError(nextError) || Date.now() >= deadline) {
throw nextError;
}
await new Promise((resolve) => {
const timer = setTimeout(resolve, MCP_SOCKET_CONNECT_RETRY_DELAY_MS);
timer.unref?.();
});
}
}
}

async function main(): Promise<void> {
const roots = resolveRuntimeRoots();
const socketPath = process.env.ADE_MCP_SOCKET_PATH?.trim() || resolveAdeLayout(roots.projectRoot).socketPath;
Expand All @@ -134,25 +174,20 @@ async function main(): Promise<void> {
process.exit(0);
}

const socket = net.createConnection(socketPath);
let connected = false;
const socket = await connectToSocketWithRetry(socketPath);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Connected-socket initialization is skipped after awaiting the retry helper.

Line 177 returns an already-connected socket, so the connect handler on Lines 186-191 never runs. That means relayProxyInputWithIdentity(socket) and socket.pipe(process.stdout) are not started.

🐛 Proposed fix
   const socket = await connectToSocketWithRetry(socketPath);
-  let connected = false;
+  let connected = true;

   socket.on("error", (err) => {
-    const prefix = connected ? "[ade-mcp-proxy]" : "[ade-mcp-proxy] Failed to connect";
+    const prefix = connected ? "[ade-mcp-proxy]" : "[ade-mcp-proxy] Failed to connect";
     process.stderr.write(`${prefix}: ${err.message}\n`);
     process.exit(1);
   });

-  socket.on("connect", () => {
-    connected = true;
-    process.stdin.resume();
-    relayProxyInputWithIdentity(socket);
-    socket.pipe(process.stdout);
-  });
+  process.stdin.resume();
+  relayProxyInputWithIdentity(socket);
+  socket.pipe(process.stdout);

   socket.on("close", () => {
     process.exit(connected ? 0 : 1);
   });

Also applies to: 186-191

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/adeMcpProxy.ts` at line 177, connectToSocketWithRetry
currently returns an already-connected socket so the 'connect' event handler
never fires and relayProxyInputWithIdentity(socket) and
socket.pipe(process.stdout) aren't started; fix by changing the flow so you
create the socket and attach the 'connect' handler before attempting to connect
(or modify connectToSocketWithRetry to return an unconnected socket or a
connector function), then invoke socket.connect(socketPath) (or call the
returned connector) so the existing 'connect' listener runs and starts
relayProxyInputWithIdentity and socket.pipe; reference connectToSocketWithRetry,
the 'connect' handler, relayProxyInputWithIdentity(socket), and
socket.pipe(process.stdout) when making the change.


socket.on("error", (err) => {
const prefix = connected ? "[ade-mcp-proxy]" : "[ade-mcp-proxy] Failed to connect";
process.stderr.write(`${prefix}: ${err.message}\n`);
process.stderr.write(`[ade-mcp-proxy]: ${err.message}\n`);
process.exit(1);
});

socket.on("connect", () => {
connected = true;
process.stdin.resume();
relayProxyInputWithIdentity(socket);
socket.pipe(process.stdout);
});

socket.on("close", () => {
process.exit(connected ? 0 : 1);
process.exit(0);
});

process.stdin.resume();
relayProxyInputWithIdentity(socket);
socket.pipe(process.stdout);
}

void main().catch((error) => {
Expand Down
58 changes: 25 additions & 33 deletions apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { app, BrowserWindow, nativeImage, protocol, shell } from "electron";
import path from "node:path";
type NodePtyType = typeof import("node-pty");

Check warning on line 3 in apps/desktop/src/main/main.ts

View workflow job for this annotation

GitHub Actions / lint-desktop

`import()` type annotations are forbidden
import { registerIpc } from "./services/ipc/registerIpc";
import { createFileLogger } from "./services/logging/logger";
import { openKvDb } from "./services/state/kvDb";
Expand Down Expand Up @@ -40,6 +40,7 @@
import { createPrPollingService } from "./services/prs/prPollingService";
import { createQueueLandingService } from "./services/prs/queueLandingService";
import { createIssueInventoryService } from "./services/prs/issueInventoryService";
import { createPrSummaryService } from "./services/prs/prSummaryService";
import {
detectDefaultBaseRef,
resolveRepoRoot,
Expand Down Expand Up @@ -76,7 +77,6 @@
import { createAutoRebaseService } from "./services/lanes/autoRebaseService";
import { createMissionService } from "./services/missions/missionService";
import { createMissionPreflightService } from "./services/missions/missionPreflightService";
import { createCompactionFlushService } from "./services/memory/compactionFlushService";
import { createBatchConsolidationService } from "./services/memory/batchConsolidationService";
import { createEmbeddingService } from "./services/memory/embeddingService";
import { createEmbeddingWorkerService } from "./services/memory/embeddingWorkerService";
Expand Down Expand Up @@ -1125,7 +1125,6 @@
});
const reconciledSessions = sessionService.reconcileStaleRunningSessions({
status: "disposed",
excludeToolTypes: ["claude-chat", "codex-chat", "opencode-chat", "cursor"],
});
if (reconciledSessions > 0) {
logger.warn("sessions.reconciled_stale_running", {
Expand Down Expand Up @@ -1378,6 +1377,7 @@
logger,
prService,
projectConfigService,
db,
onEvent: (event) => emitProjectEvent(projectRoot, IPC.prsEvent, event),
onPullRequestsChanged: async ({ changedPrs, changes }) => {
if (changedPrs.length > 0) {
Expand Down Expand Up @@ -1456,6 +1456,14 @@

const issueInventoryService = createIssueInventoryService({ db });

const prSummaryService = createPrSummaryService({
db,
logger,
projectRoot,
prService,
aiIntegrationService,
});

const fileService = createFileService({
laneService,
onLaneWorktreeMutation: ({ laneId, reason }) => {
Expand Down Expand Up @@ -1620,10 +1628,6 @@
memoryService,
});
memoryFilesServiceRef = memoryFilesService;
const compactionFlushService = createCompactionFlushService(undefined, {
logger,
});
aiIntegrationService.setCompactionFlushService(compactionFlushService);
const batchConsolidationService = createBatchConsolidationService({
db,
logger,
Expand Down Expand Up @@ -1905,32 +1909,6 @@
openclawBridgeServiceRef?.onAgentChatEvent(event);
emitProjectEvent(projectRoot, IPC.agentChatEvent, event);

// Compaction flush: when context compaction occurs, trigger a flush steer
// so the agent can save durable discoveries to memory before they are lost.
if (event.event.type === "context_compact") {
const sid = event.sessionId;
const compactEvt = event.event as { preTokens?: number };
void compactionFlushService
.beforeCompaction({
sessionId: sid,
boundaryId: `chat:${sid}:${Date.now()}`,
conversationTokenCount: compactEvt.preTokens ?? 200_000,
maxTokens: 200_000,
flushTurn: async ({ prompt }) => {
try {
await agentChatService.steer({
sessionId: sid,
text: prompt,
});
return { status: "flushed" };
} catch {
return { status: "budget_exceeded" };
}
},
})
.catch(() => {});
}

// Capture agent session errors as failure gotchas for the memory system
if (event.event.type === "error" && event.provenance?.runId) {
const prov = event.provenance;
Expand Down Expand Up @@ -2930,7 +2908,19 @@
});
conn.on("error", () => {}); // ignore connection errors
});
mcpSocketServer.listen(mcpSocketPath);
await new Promise<void>((resolve, reject) => {
const handleListening = () => {
mcpSocketServer.off("error", handleError);
resolve();
};
const handleError = (error: Error) => {
mcpSocketServer.off("listening", handleListening);
reject(error);
};
mcpSocketServer.once("listening", handleListening);
mcpSocketServer.once("error", handleError);
mcpSocketServer.listen(mcpSocketPath);
});
logger.info("mcp.socket_server_started", { socketPath: mcpSocketPath });

return {
Expand Down Expand Up @@ -2970,6 +2960,7 @@
computerUseArtifactBrokerService,
queueLandingService,
issueInventoryService,
prSummaryService,
jobEngine,
automationService,
automationPlannerService,
Expand Down Expand Up @@ -3068,6 +3059,7 @@
prPollingService: null,
queueLandingService: null,
issueInventoryService: null,
prSummaryService: null,
jobEngine: null,
automationService: null,
automationPlannerService: null,
Expand Down
50 changes: 50 additions & 0 deletions apps/desktop/src/main/services/ai/aiIntegrationService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ const mockState = vi.hoisted(() => ({
probeClaudeRuntimeHealth: vi.fn(),
resetClaudeRuntimeProbeCache: vi.fn(),
runProviderTask: vi.fn(),
clearOpenCodeInventoryCache: vi.fn(),
peekOpenCodeInventoryCache: vi.fn(),
probeOpenCodeProviderInventory: vi.fn(),
resolveOpenCodeBinary: vi.fn(),
}));

vi.mock("./authDetector", () => ({
Expand Down Expand Up @@ -53,6 +57,16 @@ vi.mock("./providerTaskRunner", () => ({
runProviderTask: (...args: unknown[]) => mockState.runProviderTask(...args),
}));

vi.mock("../opencode/openCodeInventory", () => ({
clearOpenCodeInventoryCache: (...args: unknown[]) => mockState.clearOpenCodeInventoryCache(...args),
peekOpenCodeInventoryCache: (...args: unknown[]) => mockState.peekOpenCodeInventoryCache(...args),
probeOpenCodeProviderInventory: (...args: unknown[]) => mockState.probeOpenCodeProviderInventory(...args),
}));

vi.mock("../opencode/openCodeBinaryManager", () => ({
resolveOpenCodeBinary: (...args: unknown[]) => mockState.resolveOpenCodeBinary(...args),
}));

import { getLocalProviderDefaultEndpoint } from "../../../shared/modelRegistry";
import { createAiIntegrationService } from "./aiIntegrationService";

Expand Down Expand Up @@ -217,6 +231,18 @@ beforeEach(() => {
});
mockState.initModelsDevService.mockResolvedValue(new Map());
mockState.probeClaudeRuntimeHealth.mockResolvedValue(undefined);
mockState.clearOpenCodeInventoryCache.mockImplementation(() => undefined);
mockState.peekOpenCodeInventoryCache.mockReturnValue(null);
mockState.probeOpenCodeProviderInventory.mockResolvedValue({
modelIds: ["opencode/openai/gpt-5.4-mini"],
providers: [{ id: "openai", name: "OpenAI", connected: true, modelCount: 1 }],
error: null,
descriptors: [],
});
mockState.resolveOpenCodeBinary.mockReturnValue({
path: "/Users/admin/.opencode/bin/opencode",
source: "user-installed",
});
});

describe("aiIntegrationService", () => {
Expand Down Expand Up @@ -364,4 +390,28 @@ describe("aiIntegrationService", () => {
expect(mockState.buildProviderConnections).toHaveBeenCalledTimes(1);
expect(secondStatus).toEqual(firstStatus);
});

it("does not cold-probe OpenCode inventory on default getStatus", async () => {
const { service } = makeService();

const status = await service.getStatus();

expect(status.opencodeBinaryInstalled).toBe(true);
expect(status.opencodeProviders).toEqual([]);
expect(status.availableModelIds).not.toContain("opencode/openai/gpt-5.4-mini");
expect(mockState.peekOpenCodeInventoryCache).toHaveBeenCalledTimes(1);
expect(mockState.probeOpenCodeProviderInventory).not.toHaveBeenCalled();
});

it("probes OpenCode inventory when explicitly refreshed", async () => {
const { service } = makeService();

const status = await service.getStatus({ refreshOpenCodeInventory: true });

expect(mockState.probeOpenCodeProviderInventory).toHaveBeenCalledTimes(1);
expect(status.opencodeProviders).toEqual([
{ id: "openai", name: "OpenAI", connected: true, modelCount: 1 },
]);
expect(status.availableModelIds).toContain("opencode/openai/gpt-5.4-mini");
});
});
16 changes: 0 additions & 16 deletions apps/desktop/src/main/services/ai/aiIntegrationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
peekOpenCodeInventoryCache,
probeOpenCodeProviderInventory,
} from "../opencode/openCodeInventory";
import { resolveOpenCodeExecutablePath, type DiscoveredLocalModelEntry } from "../opencode/openCodeRuntime";

Check warning on line 41 in apps/desktop/src/main/services/ai/aiIntegrationService.ts

View workflow job for this annotation

GitHub Actions / lint-desktop

'resolveOpenCodeExecutablePath' is defined but never used. Allowed unused vars must match /^_/u
import { resolveOpenCodeBinary, type OpenCodeBinarySource } from "../opencode/openCodeBinaryManager";
import { initialize as initModelsDevService } from "./modelsDevService";
import { updateModelPricing } from "../../../shared/modelProfiles";
Expand All @@ -46,7 +46,6 @@
import { parseStructuredOutput } from "./utils";
import { getApiKeyStoreStatus } from "./apiKeyStore";
import type { createMemoryService } from "../memory/memoryService";
import type { CompactionFlushService } from "../memory/compactionFlushService";
import { inspectLocalProvider } from "./localModelDiscovery";
import { discoverCursorCliModelDescriptors, clearCursorCliModelsCache } from "../chat/cursorModelsDiscovery";
import { resolveCursorAgentExecutable } from "./cursorAgentExecutable";
Expand Down Expand Up @@ -655,7 +654,6 @@
projectRoot: string;
}) {
const { db, logger, projectConfigService, projectRoot } = args;
let compactionFlushService: CompactionFlushService | null = null;

// Non-blocking: fetch models.dev data and enrich pricing + registry
initModelsDevService().then((modelData) => {
Expand Down Expand Up @@ -1257,17 +1255,6 @@
opencodeInventoryError = peeked.error;
opencodeModelIds = peeked.modelIds;
opencodeProviders = peeked.providers;
} else {
// No cache yet — auto-probe on first getStatus so free/connected models appear immediately.
const probed = await probeOpenCodeProviderInventory({
projectRoot,
projectConfig: effectiveConfig,
logger,
discoveredLocalModels,
});
opencodeInventoryError = probed.error;
opencodeModelIds = probed.modelIds;
opencodeProviders = probed.providers;
}
}

Expand Down Expand Up @@ -1338,9 +1325,6 @@

getAvailabilityAsync,
resolveModelForTask,
setCompactionFlushService(service: CompactionFlushService | null) {
compactionFlushService = service;
},

// Backward-compatible convenience methods used by migrated services.
async generateNarrative(args: {
Expand Down
Loading
Loading