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
32 changes: 32 additions & 0 deletions src/renderer/state/chatRuntimePersister.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
compactRuntimeItemsForHydration,
hydrateThreadRuntimeItems,
loadOlderThreadRuntimeItems,
releaseThreadRuntimeItems,
retainThreadRuntimeItems,
seedOlderThreadRuntimeItemsCursor,
} from "./chatRuntimePersister";

Expand Down Expand Up @@ -285,4 +287,34 @@ describe("paged runtime hydration", () => {
targetTimelineEntryCount: 40,
});
});

it("rehydrates a transcript after the inactive cache evicts it", async () => {
const threadIds = Array.from({ length: 11 }, (_, index) => `cached-thread-${index}`);
bridge.dbGetThreadRuntimeItemsPage.mockImplementation(async ({ threadId }) => ({
items: [makeItem({ id: `${threadId}-item`, type: "assistant_message" })],
nextCursor: null,
}));

for (const threadId of threadIds) {
await hydrateThreadRuntimeItems(threadId);
retainThreadRuntimeItems(threadId);
releaseThreadRuntimeItems(threadId);
}

expect(useAppStore.getState().runtimeItemIdsByThread[threadIds[0]!]).toBeUndefined();
for (const threadId of threadIds.slice(1)) {
expect(useAppStore.getState().runtimeItemIdsByThread[threadId]).toBeDefined();
}

bridge.dbGetThreadRuntimeItemsPage.mockClear();
await hydrateThreadRuntimeItems(threadIds[0]!);
expect(bridge.dbGetThreadRuntimeItemsPage).toHaveBeenCalledWith({
threadId: threadIds[0],
limit: 500,
targetTimelineEntryCount: 40,
});
expect(useAppStore.getState().runtimeItemIdsByThread[threadIds[0]!]).toEqual([
`${threadIds[0]}-item`,
]);
});
});
2 changes: 1 addition & 1 deletion src/renderer/state/chatRuntimePersister.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {

const RUNTIME_PAGE_SCAN_SIZE = 500;
const RUNTIME_TIMELINE_PAGE_SIZE = 40;
const MAX_CACHED_THREAD_TRANSCRIPTS = 40;
const MAX_CACHED_THREAD_TRANSCRIPTS = 10;
const hydratedThreadRuntimeIds = new Set<string>();
const pendingThreadRuntimeHydrations = new Map<string, Promise<boolean>>();
const olderRuntimePageCursorByThread = new Map<string, number | null>();
Expand Down
14 changes: 14 additions & 0 deletions src/shared/processTree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,18 @@ describe("processTree", () => {
expect(taskkillSpawnSyncMock).not.toHaveBeenCalled();
expect(processKillSpy).toHaveBeenCalledWith(31337);
});

it("kills an owned POSIX process group", () => {
const processKillSpy = vi.spyOn(process, "kill").mockImplementation(() => true);

Object.defineProperty(process, "platform", {
configurable: true,
value: "linux",
});

terminateChildProcessTree({ pid: 31337 }, { ownedProcessGroup: true });

expect(taskkillSpawnSyncMock).not.toHaveBeenCalled();
expect(processKillSpy).toHaveBeenCalledExactlyOnceWith(-31337, "SIGKILL");
});
});
29 changes: 25 additions & 4 deletions src/shared/processTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ function isRunnablePid(pid: number): boolean {
}
}

export function terminateProcessTree(pid: number): void {
export interface TerminateProcessTreeOptions {
/** The child was launched detached and owns its POSIX process group. */
ownedProcessGroup?: boolean;
}

export function terminateProcessTree(pid: number, options?: TerminateProcessTreeOptions): void {
if (!Number.isInteger(pid) || pid <= 0) {
return;
}
Expand All @@ -30,17 +35,33 @@ export function terminateProcessTree(pid: number): void {
}
}

if (options?.ownedProcessGroup) {
try {
process.kill(-pid, "SIGKILL");
return;
} catch {
// The group may already be gone; fall back to the immediate process.
}
}

try {
process.kill(pid);
if (options?.ownedProcessGroup) {
process.kill(pid, "SIGKILL");
} else {
process.kill(pid);
}
} catch {
// Best effort; the process may already be gone.
}
}

export function terminateChildProcessTree(child: Pick<ChildProcess, "pid">): void {
export function terminateChildProcessTree(
child: Pick<ChildProcess, "pid">,
options?: TerminateProcessTreeOptions,
): void {
if (typeof child.pid !== "number") {
return;
}

terminateProcessTree(child.pid);
terminateProcessTree(child.pid, options);
}
1 change: 1 addition & 0 deletions src/supervisor/agents/acp-generic/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ async function probeGenericCapabilities(
...(command.env ? { env: command.env } : {}),
label,
...(timeoutMs !== undefined ? { timeoutMs } : {}),
...(ctx?.signal ? { signal: ctx.signal } : {}),
});
}

Expand Down
15 changes: 15 additions & 0 deletions src/supervisor/agents/acp/probe.stress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,4 +133,19 @@ describe("probeAcpCapabilities live-process paths", () => {

expect(elapsed).toBeLessThan(1_000);
});

it("aborts and reaps the probe process when its caller is cancelled", async () => {
const abort = new AbortController();
const started = Date.now();
const pending = probeAcpCapabilities(process.execPath, [FIXTURE], process.cwd(), {
timeoutMs: 5_000,
label: "cancelled",
signal: abort.signal,
});
setTimeout(() => abort.abort(), 100);

await pending;

expect(Date.now() - started).toBeLessThan(1_000);
});
});
19 changes: 18 additions & 1 deletion src/supervisor/agents/acp/probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,7 @@ export async function probeAcpCapabilities(
timeoutMs?: number;
label?: string;
env?: Record<string, string>;
signal?: AbortSignal;
/**
* Auth method IDs to call `authenticate` with (in order) after `initialize`
* but before `newSession`. Stops at the first one advertised by the agent.
Expand All @@ -414,8 +415,12 @@ export async function probeAcpCapabilities(
const deadline = Date.now() + timeoutMs;
const tag = options?.label ? `[acp-probe:${options.label}]` : "[acp-probe]";
let child: ReturnType<typeof spawn> | undefined;
let abortProbe: (() => void) | undefined;
const ownedProcessGroup = process.platform !== "win32";
const probeResult: AcpProbeResult = {};

if (options?.signal?.aborted) return undefined;

try {
const configOptionsWaiters: Array<(configOptions: unknown[] | undefined) => void> = [];
let latestSlashCommands: AgentSlashCommand[] | undefined;
Expand All @@ -430,6 +435,7 @@ export async function probeAcpCapabilities(
env: options?.env ? { ...process.env, ...options.env } : process.env,
shell: false,
windowsHide: true,
detached: ownedProcessGroup,
});

let childExited = false;
Expand All @@ -441,6 +447,16 @@ export async function probeAcpCapabilities(
child!.once("error", markClosed);
child!.once("exit", markClosed);
});
abortProbe = () => {
try {
child?.stdin?.destroy();
} catch {
// Ignore cleanup races.
}
if (child) terminateChildProcessTree(child, { ownedProcessGroup });
};
options?.signal?.addEventListener("abort", abortProbe, { once: true });
if (options?.signal?.aborted) abortProbe();
const remainingBudgetMs = () => Math.max(0, deadline - Date.now());
const waitForProbeWindow = async (maxMs: number): Promise<void> => {
const waitMs = Math.min(maxMs, remainingBudgetMs());
Expand Down Expand Up @@ -717,6 +733,7 @@ export async function probeAcpCapabilities(
}
return undefined;
} finally {
if (abortProbe) options?.signal?.removeEventListener("abort", abortProbe);
if (child && !child.killed) {
// Destroy stdin before killing to prevent the ACP SDK from writing
// to a dead pipe (which causes noisy "ACP write error" logs).
Expand All @@ -725,7 +742,7 @@ export async function probeAcpCapabilities(
} catch {
/* ignore */
}
terminateChildProcessTree(child);
terminateChildProcessTree(child, { ownedProcessGroup });
}
}
}
Expand Down
8 changes: 5 additions & 3 deletions src/supervisor/agents/antigravity/detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,11 @@ const configDirAuthProbe: AuthProbe = async (ctx) => {
// reports "unknown" (→ "Login required") on a distro that is actually signed
// in. A direct `test -d` is cache-independent and reliable (mirrors grok).
if (ctx.location.kind === "wsl") {
const [result] = await batchWslCommandsAsync(ctx.location.distro, [
`test -d ~/${ANTIGRAVITY_CONFIG_SUBPATH} && echo yes || echo no`,
]);
const [result] = await batchWslCommandsAsync(
ctx.location.distro,
[`test -d ~/${ANTIGRAVITY_CONFIG_SUBPATH} && echo yes || echo no`],
ctx.signal,
);
return result?.ok && result.stdout.trim() === "yes" ? "authenticated" : "unknown";
}
return antigravityConfigDirExists(ctx.location) ? "authenticated" : "unknown";
Expand Down
2 changes: 1 addition & 1 deletion src/supervisor/agents/antigravity/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@ export async function probeAntigravityRuntime(
try {
return {
ok: true,
output: await spawnAgentPty(spec, "", 10_000),
output: await spawnAgentPty(spec, "", 10_000, ctx.signal),
};
} catch {
return { ok: false, output: "" };
Expand Down
34 changes: 34 additions & 0 deletions src/supervisor/agents/base.detect-version.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import type { AgentCapability } from "@/shared/contracts";

const execFileAsyncMock = vi.hoisted(() =>
vi.fn<(...args: unknown[]) => Promise<{ stdout: string; stderr?: string }>>(),
);
const spawnMock = vi.hoisted(() =>
vi.fn<
(
command: string,
args: string[],
options: Record<string, unknown>,
) => import("node:child_process").ChildProcess
>(),
);

vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
const { promisify } = require("node:util") as typeof import("node:util");
return {
...actual,
spawn: spawnMock,
execFile: Object.assign(vi.fn(), {
[promisify.custom]: execFileAsyncMock,
}),
Expand Down Expand Up @@ -53,6 +65,28 @@ describe("detectAgentInstall version probe", () => {
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
clearExecutablePathCache();
execFileAsyncMock.mockReset();
spawnMock.mockReset();
spawnMock.mockImplementation((command, args, options) => {
const stdout = new PassThrough();
const stderr = new PassThrough();
const child = Object.assign(new EventEmitter(), {
stdout,
stderr,
pid: 12_345,
killed: false,
}) as unknown as import("node:child_process").ChildProcess;
queueMicrotask(() => {
void execFileAsyncMock(command, args, options).then(
(result) => {
stdout.end(result.stdout);
stderr.end(result.stderr ?? "");
child.emit("close", 0);
},
(error: unknown) => child.emit("error", error),
);
});
return child;
});
});

afterEach(() => {
Expand Down
37 changes: 32 additions & 5 deletions src/supervisor/agents/base.posix-login-shell.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
import { homedir } from "node:os";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ProjectLocation } from "@/shared/contracts";

const execFileAsyncMock = vi.hoisted(() =>
vi.fn<(...args: unknown[]) => Promise<{ stdout: string; stderr?: string }>>(),
);
const spawnMock = vi.hoisted(() =>
vi.fn<
(
command: string,
args: string[],
options: Record<string, unknown>,
) => import("node:child_process").ChildProcess
>(),
);

vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
const { promisify } = require("node:util") as typeof import("node:util");
return {
...actual,
spawn: spawnMock,
execFile: Object.assign(vi.fn(), {
[promisify.custom]: execFileAsyncMock,
}),
Expand Down Expand Up @@ -40,6 +52,7 @@ describe.skipIf(process.platform === "win32")("POSIX login shell wrappers", () =

beforeEach(() => {
vi.clearAllMocks();
spawnMock.mockReset();
clearExecutablePathCache();
process.env.SHELL = "/bin/zsh";
});
Expand Down Expand Up @@ -213,9 +226,21 @@ describe.skipIf(process.platform === "win32")("POSIX login shell wrappers", () =
});

it("runs CLI auth probes via direct spawn", async () => {
execFileAsyncMock.mockResolvedValueOnce({
stdout: "Authenticated\n",
stderr: "",
spawnMock.mockImplementationOnce(() => {
const stdout = new PassThrough();
const stderr = new PassThrough();
const child = Object.assign(new EventEmitter(), {
stdout,
stderr,
pid: undefined,
killed: false,
}) as unknown as import("node:child_process").ChildProcess;
queueMicrotask(() => {
stdout.end("Authenticated\n");
stderr.end();
child.emit("close", 0);
});
return child;
});

const probe = cliSubcommandAuthProbe(["auth", "status"]);
Expand All @@ -227,12 +252,14 @@ describe.skipIf(process.platform === "win32")("POSIX login shell wrappers", () =
}),
).resolves.toBe("authenticated");

expect(execFileAsyncMock).toHaveBeenCalledWith(
expect(spawnMock).toHaveBeenCalledWith(
"/Users/demo/.nvm/versions/node/v24/bin/claude",
["auth", "status"],
expect.objectContaining({
cwd: "/Users/demo/project",
timeout: 30_000,
detached: true,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
}),
);
Expand Down
Loading