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
12 changes: 12 additions & 0 deletions apps/code/src/main/services/agent/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,11 +183,17 @@ export const subscribeSessionInput = z.object({
taskRunId: z.string(),
});

// Record activity input — resets the idle timeout for the given session
export const recordActivityInput = z.object({
taskRunId: z.string(),
});

// Agent events
export const AgentServiceEvent = {
SessionEvent: "session-event",
PermissionRequest: "permission-request",
SessionsIdle: "sessions-idle",
SessionIdleKilled: "session-idle-killed",
} as const;

export interface AgentSessionEventPayload {
Expand All @@ -203,10 +209,16 @@ export type PermissionRequestPayload = Omit<
taskRunId: string;
};

export interface SessionIdleKilledPayload {
taskRunId: string;
taskId: string;
}

export interface AgentServiceEvents {
[AgentServiceEvent.SessionEvent]: AgentSessionEventPayload;
[AgentServiceEvent.PermissionRequest]: PermissionRequestPayload;
[AgentServiceEvent.SessionsIdle]: undefined;
[AgentServiceEvent.SessionIdleKilled]: SessionIdleKilledPayload;
}

// Permission response input for tRPC
Expand Down
146 changes: 146 additions & 0 deletions apps/code/src/main/services/agent/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,13 @@ const mockFetch = vi.hoisted(() => vi.fn());

// --- Module mocks ---

const mockPowerMonitor = vi.hoisted(() => ({
on: vi.fn(),
}));

vi.mock("electron", () => ({
app: mockApp,
powerMonitor: mockPowerMonitor,
}));

vi.mock("../../utils/logger.js", () => ({
Expand Down Expand Up @@ -280,4 +285,145 @@ describe("AgentService", () => {
);
});
});

describe("idle timeout", () => {
function injectSession(
svc: AgentService,
taskRunId: string,
overrides: Record<string, unknown> = {},
) {
const sessions = (svc as unknown as { sessions: Map<string, unknown> })
.sessions;
sessions.set(taskRunId, {
taskRunId,
taskId: `task-for-${taskRunId}`,
repoPath: "/mock/repo",
agent: { cleanup: vi.fn().mockResolvedValue(undefined) },
clientSideConnection: {},
channel: `ch-${taskRunId}`,
createdAt: Date.now(),
lastActivityAt: Date.now(),
config: {},
needsRecreation: false,
promptPending: false,
...overrides,
});
}

function getIdleTimeouts(svc: AgentService) {
return (
svc as unknown as {
idleTimeouts: Map<
string,
{ handle: ReturnType<typeof setTimeout>; deadline: number }
>;
}
).idleTimeouts;
}

beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

it("recordActivity is a no-op for unknown sessions", () => {
service.recordActivity("unknown-run");
expect(getIdleTimeouts(service).size).toBe(0);
});

it("recordActivity sets a timeout for a known session", () => {
injectSession(service, "run-1");
service.recordActivity("run-1");
expect(getIdleTimeouts(service).has("run-1")).toBe(true);
});

it("recordActivity resets the timeout on subsequent calls", () => {
injectSession(service, "run-1");
service.recordActivity("run-1");
const firstDeadline = getIdleTimeouts(service).get("run-1")?.deadline;

vi.advanceTimersByTime(5 * 60 * 1000);
service.recordActivity("run-1");
const secondDeadline = getIdleTimeouts(service).get("run-1")?.deadline;

expect(secondDeadline).toBeGreaterThan(firstDeadline!);
});

it("kills idle session after timeout expires", () => {
injectSession(service, "run-1");
service.recordActivity("run-1");

vi.advanceTimersByTime(15 * 60 * 1000);

expect(service.emit).toHaveBeenCalledWith(
"session-idle-killed",
expect.objectContaining({ taskRunId: "run-1" }),
);
});

it("does not kill session if activity is recorded before timeout", () => {
injectSession(service, "run-1");
service.recordActivity("run-1");

vi.advanceTimersByTime(14 * 60 * 1000);
service.recordActivity("run-1");
vi.advanceTimersByTime(14 * 60 * 1000);

expect(service.emit).not.toHaveBeenCalledWith(
"session-idle-killed",
expect.anything(),
);
});

it("reschedules when promptPending is true at timeout", () => {
injectSession(service, "run-1", { promptPending: true });
service.recordActivity("run-1");

vi.advanceTimersByTime(15 * 60 * 1000);

expect(service.emit).not.toHaveBeenCalledWith(
"session-idle-killed",
expect.anything(),
);
expect(getIdleTimeouts(service).has("run-1")).toBe(true);
});

it("checkIdleDeadlines kills expired sessions on resume", () => {
injectSession(service, "run-1");
service.recordActivity("run-1");

const resumeHandler = mockPowerMonitor.on.mock.calls.find(
([event]: string[]) => event === "resume",
)?.[1] as () => void;
expect(resumeHandler).toBeDefined();

vi.advanceTimersByTime(20 * 60 * 1000);
resumeHandler();

expect(service.emit).toHaveBeenCalledWith(
"session-idle-killed",
expect.objectContaining({ taskRunId: "run-1" }),
);
});

it("checkIdleDeadlines does not kill non-expired sessions", () => {
injectSession(service, "run-1");
service.recordActivity("run-1");

const resumeHandler = mockPowerMonitor.on.mock.calls.find(
([event]: string[]) => event === "resume",
)?.[1] as () => void;

vi.advanceTimersByTime(5 * 60 * 1000);
resumeHandler();

expect(service.emit).not.toHaveBeenCalledWith(
"session-idle-killed",
expect.anything(),
);
});
});
});
66 changes: 65 additions & 1 deletion apps/code/src/main/services/agent/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { getLlmGatewayUrl } from "@posthog/agent/posthog-api";
import type { OnLogCallback } from "@posthog/agent/types";
import { isAuthError } from "@shared/errors.js";
import type { AcpMessage } from "@shared/types/session-events.js";
import { app } from "electron";
import { app, powerMonitor } from "electron";
import { inject, injectable, preDestroy } from "inversify";
import { MAIN_TOKENS } from "../../di/tokens.js";
import { isDevBuild } from "../../utils/env.js";
Expand Down Expand Up @@ -252,10 +252,16 @@ interface PendingPermission {

@injectable()
export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
private static readonly IDLE_TIMEOUT_MS = 15 * 60 * 1000;

private sessions = new Map<string, ManagedSession>();
private currentToken: string | null = null;
private pendingPermissions = new Map<string, PendingPermission>();
private mockNodeReady = false;
private idleTimeouts = new Map<
string,
{ handle: ReturnType<typeof setTimeout>; deadline: number }
>();
private processTracking: ProcessTrackingService;
private sleepService: SleepService;
private fsService: FsService;
Expand All @@ -276,6 +282,8 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
this.sleepService = sleepService;
this.fsService = fsService;
this.posthogPluginService = posthogPluginService;

powerMonitor.on("resume", () => this.checkIdleDeadlines());
}

public updateToken(newToken: string): void {
Expand Down Expand Up @@ -349,6 +357,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
});

this.pendingPermissions.delete(key);
this.recordActivity(taskRunId);
}

/**
Expand Down Expand Up @@ -376,6 +385,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
});

this.pendingPermissions.delete(key);
this.recordActivity(taskRunId);
}

/**
Expand All @@ -392,6 +402,48 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
return false;
}

public recordActivity(taskRunId: string): void {
if (!this.sessions.has(taskRunId)) return;

const existing = this.idleTimeouts.get(taskRunId);
if (existing) clearTimeout(existing.handle);

const deadline = Date.now() + AgentService.IDLE_TIMEOUT_MS;
const handle = setTimeout(() => {
this.killIdleSession(taskRunId);
}, AgentService.IDLE_TIMEOUT_MS);

this.idleTimeouts.set(taskRunId, { handle, deadline });
}

private killIdleSession(taskRunId: string): void {
const session = this.sessions.get(taskRunId);
if (!session) return;
if (session.promptPending) {
this.recordActivity(taskRunId);
return;
}
log.info("Killing idle session", { taskRunId, taskId: session.taskId });
this.emit(AgentServiceEvent.SessionIdleKilled, {
taskRunId,
taskId: session.taskId,
});
this.cleanupSession(taskRunId).catch((err) => {
log.error("Failed to cleanup idle session", { taskRunId, err });
});
}

private checkIdleDeadlines(): void {
const now = Date.now();
const expired = [...this.idleTimeouts.entries()].filter(
([, { deadline }]) => now >= deadline,
);
for (const [taskRunId, { handle }] of expired) {
clearTimeout(handle);
this.killIdleSession(taskRunId);
}
}

private getToken(fallback: string): string {
return this.currentToken || fallback;
}
Expand Down Expand Up @@ -786,6 +838,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
};

this.sessions.set(taskRunId, session);
this.recordActivity(taskRunId);
if (isRetry) {
log.info("Session created after auth retry", { taskRunId });
}
Expand Down Expand Up @@ -912,6 +965,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {

session.lastActivityAt = Date.now();
session.promptPending = true;
this.recordActivity(sessionId);
this.sleepService.acquire(sessionId);

const promptJson = JSON.stringify(finalPrompt);
Expand Down Expand Up @@ -947,6 +1001,8 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
throw err;
} finally {
session.promptPending = false;
session.lastActivityAt = Date.now();
this.recordActivity(sessionId);
this.sleepService.release(sessionId);

if (!this.hasActiveSessions()) {
Expand Down Expand Up @@ -1138,6 +1194,8 @@ For git operations while detached:

@preDestroy()
async cleanupAll(): Promise<void> {
for (const { handle } of this.idleTimeouts.values()) clearTimeout(handle);
this.idleTimeouts.clear();
const sessionIds = Array.from(this.sessions.keys());
log.info("Cleaning up all agent sessions", {
sessionCount: sessionIds.length,
Expand Down Expand Up @@ -1224,6 +1282,12 @@ For git operations while detached:
}

this.sessions.delete(taskRunId);

const timeout = this.idleTimeouts.get(taskRunId);
if (timeout) {
clearTimeout(timeout.handle);
this.idleTimeouts.delete(taskRunId);
}
}
}

Expand Down
15 changes: 15 additions & 0 deletions apps/code/src/main/trpc/routers/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
promptInput,
promptOutput,
reconnectSessionInput,
recordActivityInput,
respondToPermissionInput,
sessionResponseSchema,
setConfigOptionInput,
Expand Down Expand Up @@ -183,6 +184,20 @@ export const agentRouter = router({
log.info("All sessions reset successfully");
}),

recordActivity: publicProcedure
.input(recordActivityInput)
.mutation(({ input }) => getService().recordActivity(input.taskRunId)),

onSessionIdleKilled: publicProcedure.subscription(async function* (opts) {
const service = getService();
for await (const event of service.toIterable(
AgentServiceEvent.SessionIdleKilled,
{ signal: opts.signal },
)) {
yield event;
}
}),

getGatewayModels: publicProcedure
.input(getGatewayModelsInput)
.output(getGatewayModelsOutput)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const mockTrpcAgent = vi.hoisted(() => ({
cancelPermission: { mutate: vi.fn() },
onSessionEvent: { subscribe: vi.fn() },
onPermissionRequest: { subscribe: vi.fn() },
onSessionIdleKilled: { subscribe: vi.fn(() => ({ unsubscribe: vi.fn() })) },
resetAll: { mutate: vi.fn().mockResolvedValue(undefined) },
}));

Expand Down
Loading
Loading