Skip to content

Commit 83bb341

Browse files
authored
feat: Add idle timeout to kill inactive sessions after 15 minutes (#1207)
1. Add 15-minute idle timeout that auto-kills sessions with no user activity 2. Track activity on permission responses and prompt cycles in AgentService 3. Send heartbeat pings every 5 minutes from TaskLogsPanel when viewing a task 4. Clean up idle-killed sessions in renderer SessionService (unsubscribe, remove state) 5. Clear all timeout handles on service teardown and session cleanup
1 parent 07bdacc commit 83bb341

File tree

8 files changed

+278
-5
lines changed

8 files changed

+278
-5
lines changed

apps/code/src/main/services/agent/schemas.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,11 +183,17 @@ export const subscribeSessionInput = z.object({
183183
taskRunId: z.string(),
184184
});
185185

186+
// Record activity input — resets the idle timeout for the given session
187+
export const recordActivityInput = z.object({
188+
taskRunId: z.string(),
189+
});
190+
186191
// Agent events
187192
export const AgentServiceEvent = {
188193
SessionEvent: "session-event",
189194
PermissionRequest: "permission-request",
190195
SessionsIdle: "sessions-idle",
196+
SessionIdleKilled: "session-idle-killed",
191197
} as const;
192198

193199
export interface AgentSessionEventPayload {
@@ -203,10 +209,16 @@ export type PermissionRequestPayload = Omit<
203209
taskRunId: string;
204210
};
205211

212+
export interface SessionIdleKilledPayload {
213+
taskRunId: string;
214+
taskId: string;
215+
}
216+
206217
export interface AgentServiceEvents {
207218
[AgentServiceEvent.SessionEvent]: AgentSessionEventPayload;
208219
[AgentServiceEvent.PermissionRequest]: PermissionRequestPayload;
209220
[AgentServiceEvent.SessionsIdle]: undefined;
221+
[AgentServiceEvent.SessionIdleKilled]: SessionIdleKilledPayload;
210222
}
211223

212224
// Permission response input for tRPC

apps/code/src/main/services/agent/service.test.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,13 @@ const mockFetch = vi.hoisted(() => vi.fn());
5151

5252
// --- Module mocks ---
5353

54+
const mockPowerMonitor = vi.hoisted(() => ({
55+
on: vi.fn(),
56+
}));
57+
5458
vi.mock("electron", () => ({
5559
app: mockApp,
60+
powerMonitor: mockPowerMonitor,
5661
}));
5762

5863
vi.mock("../../utils/logger.js", () => ({
@@ -280,4 +285,145 @@ describe("AgentService", () => {
280285
);
281286
});
282287
});
288+
289+
describe("idle timeout", () => {
290+
function injectSession(
291+
svc: AgentService,
292+
taskRunId: string,
293+
overrides: Record<string, unknown> = {},
294+
) {
295+
const sessions = (svc as unknown as { sessions: Map<string, unknown> })
296+
.sessions;
297+
sessions.set(taskRunId, {
298+
taskRunId,
299+
taskId: `task-for-${taskRunId}`,
300+
repoPath: "/mock/repo",
301+
agent: { cleanup: vi.fn().mockResolvedValue(undefined) },
302+
clientSideConnection: {},
303+
channel: `ch-${taskRunId}`,
304+
createdAt: Date.now(),
305+
lastActivityAt: Date.now(),
306+
config: {},
307+
needsRecreation: false,
308+
promptPending: false,
309+
...overrides,
310+
});
311+
}
312+
313+
function getIdleTimeouts(svc: AgentService) {
314+
return (
315+
svc as unknown as {
316+
idleTimeouts: Map<
317+
string,
318+
{ handle: ReturnType<typeof setTimeout>; deadline: number }
319+
>;
320+
}
321+
).idleTimeouts;
322+
}
323+
324+
beforeEach(() => {
325+
vi.useFakeTimers();
326+
});
327+
328+
afterEach(() => {
329+
vi.useRealTimers();
330+
});
331+
332+
it("recordActivity is a no-op for unknown sessions", () => {
333+
service.recordActivity("unknown-run");
334+
expect(getIdleTimeouts(service).size).toBe(0);
335+
});
336+
337+
it("recordActivity sets a timeout for a known session", () => {
338+
injectSession(service, "run-1");
339+
service.recordActivity("run-1");
340+
expect(getIdleTimeouts(service).has("run-1")).toBe(true);
341+
});
342+
343+
it("recordActivity resets the timeout on subsequent calls", () => {
344+
injectSession(service, "run-1");
345+
service.recordActivity("run-1");
346+
const firstDeadline = getIdleTimeouts(service).get("run-1")?.deadline;
347+
348+
vi.advanceTimersByTime(5 * 60 * 1000);
349+
service.recordActivity("run-1");
350+
const secondDeadline = getIdleTimeouts(service).get("run-1")?.deadline;
351+
352+
expect(secondDeadline).toBeGreaterThan(firstDeadline!);
353+
});
354+
355+
it("kills idle session after timeout expires", () => {
356+
injectSession(service, "run-1");
357+
service.recordActivity("run-1");
358+
359+
vi.advanceTimersByTime(15 * 60 * 1000);
360+
361+
expect(service.emit).toHaveBeenCalledWith(
362+
"session-idle-killed",
363+
expect.objectContaining({ taskRunId: "run-1" }),
364+
);
365+
});
366+
367+
it("does not kill session if activity is recorded before timeout", () => {
368+
injectSession(service, "run-1");
369+
service.recordActivity("run-1");
370+
371+
vi.advanceTimersByTime(14 * 60 * 1000);
372+
service.recordActivity("run-1");
373+
vi.advanceTimersByTime(14 * 60 * 1000);
374+
375+
expect(service.emit).not.toHaveBeenCalledWith(
376+
"session-idle-killed",
377+
expect.anything(),
378+
);
379+
});
380+
381+
it("reschedules when promptPending is true at timeout", () => {
382+
injectSession(service, "run-1", { promptPending: true });
383+
service.recordActivity("run-1");
384+
385+
vi.advanceTimersByTime(15 * 60 * 1000);
386+
387+
expect(service.emit).not.toHaveBeenCalledWith(
388+
"session-idle-killed",
389+
expect.anything(),
390+
);
391+
expect(getIdleTimeouts(service).has("run-1")).toBe(true);
392+
});
393+
394+
it("checkIdleDeadlines kills expired sessions on resume", () => {
395+
injectSession(service, "run-1");
396+
service.recordActivity("run-1");
397+
398+
const resumeHandler = mockPowerMonitor.on.mock.calls.find(
399+
([event]: string[]) => event === "resume",
400+
)?.[1] as () => void;
401+
expect(resumeHandler).toBeDefined();
402+
403+
vi.advanceTimersByTime(20 * 60 * 1000);
404+
resumeHandler();
405+
406+
expect(service.emit).toHaveBeenCalledWith(
407+
"session-idle-killed",
408+
expect.objectContaining({ taskRunId: "run-1" }),
409+
);
410+
});
411+
412+
it("checkIdleDeadlines does not kill non-expired sessions", () => {
413+
injectSession(service, "run-1");
414+
service.recordActivity("run-1");
415+
416+
const resumeHandler = mockPowerMonitor.on.mock.calls.find(
417+
([event]: string[]) => event === "resume",
418+
)?.[1] as () => void;
419+
420+
vi.advanceTimersByTime(5 * 60 * 1000);
421+
resumeHandler();
422+
423+
expect(service.emit).not.toHaveBeenCalledWith(
424+
"session-idle-killed",
425+
expect.anything(),
426+
);
427+
});
428+
});
283429
});

apps/code/src/main/services/agent/service.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { getLlmGatewayUrl } from "@posthog/agent/posthog-api";
2323
import type { OnLogCallback } from "@posthog/agent/types";
2424
import { isAuthError } from "@shared/errors.js";
2525
import type { AcpMessage } from "@shared/types/session-events.js";
26-
import { app } from "electron";
26+
import { app, powerMonitor } from "electron";
2727
import { inject, injectable, preDestroy } from "inversify";
2828
import { MAIN_TOKENS } from "../../di/tokens.js";
2929
import { isDevBuild } from "../../utils/env.js";
@@ -252,10 +252,16 @@ interface PendingPermission {
252252

253253
@injectable()
254254
export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
255+
private static readonly IDLE_TIMEOUT_MS = 15 * 60 * 1000;
256+
255257
private sessions = new Map<string, ManagedSession>();
256258
private currentToken: string | null = null;
257259
private pendingPermissions = new Map<string, PendingPermission>();
258260
private mockNodeReady = false;
261+
private idleTimeouts = new Map<
262+
string,
263+
{ handle: ReturnType<typeof setTimeout>; deadline: number }
264+
>();
259265
private processTracking: ProcessTrackingService;
260266
private sleepService: SleepService;
261267
private fsService: FsService;
@@ -276,6 +282,8 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
276282
this.sleepService = sleepService;
277283
this.fsService = fsService;
278284
this.posthogPluginService = posthogPluginService;
285+
286+
powerMonitor.on("resume", () => this.checkIdleDeadlines());
279287
}
280288

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

351359
this.pendingPermissions.delete(key);
360+
this.recordActivity(taskRunId);
352361
}
353362

354363
/**
@@ -376,6 +385,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
376385
});
377386

378387
this.pendingPermissions.delete(key);
388+
this.recordActivity(taskRunId);
379389
}
380390

381391
/**
@@ -392,6 +402,48 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
392402
return false;
393403
}
394404

405+
public recordActivity(taskRunId: string): void {
406+
if (!this.sessions.has(taskRunId)) return;
407+
408+
const existing = this.idleTimeouts.get(taskRunId);
409+
if (existing) clearTimeout(existing.handle);
410+
411+
const deadline = Date.now() + AgentService.IDLE_TIMEOUT_MS;
412+
const handle = setTimeout(() => {
413+
this.killIdleSession(taskRunId);
414+
}, AgentService.IDLE_TIMEOUT_MS);
415+
416+
this.idleTimeouts.set(taskRunId, { handle, deadline });
417+
}
418+
419+
private killIdleSession(taskRunId: string): void {
420+
const session = this.sessions.get(taskRunId);
421+
if (!session) return;
422+
if (session.promptPending) {
423+
this.recordActivity(taskRunId);
424+
return;
425+
}
426+
log.info("Killing idle session", { taskRunId, taskId: session.taskId });
427+
this.emit(AgentServiceEvent.SessionIdleKilled, {
428+
taskRunId,
429+
taskId: session.taskId,
430+
});
431+
this.cleanupSession(taskRunId).catch((err) => {
432+
log.error("Failed to cleanup idle session", { taskRunId, err });
433+
});
434+
}
435+
436+
private checkIdleDeadlines(): void {
437+
const now = Date.now();
438+
const expired = [...this.idleTimeouts.entries()].filter(
439+
([, { deadline }]) => now >= deadline,
440+
);
441+
for (const [taskRunId, { handle }] of expired) {
442+
clearTimeout(handle);
443+
this.killIdleSession(taskRunId);
444+
}
445+
}
446+
395447
private getToken(fallback: string): string {
396448
return this.currentToken || fallback;
397449
}
@@ -786,6 +838,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
786838
};
787839

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

913966
session.lastActivityAt = Date.now();
914967
session.promptPending = true;
968+
this.recordActivity(sessionId);
915969
this.sleepService.acquire(sessionId);
916970

917971
const promptJson = JSON.stringify(finalPrompt);
@@ -947,6 +1001,8 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
9471001
throw err;
9481002
} finally {
9491003
session.promptPending = false;
1004+
session.lastActivityAt = Date.now();
1005+
this.recordActivity(sessionId);
9501006
this.sleepService.release(sessionId);
9511007

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

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

12261284
this.sessions.delete(taskRunId);
1285+
1286+
const timeout = this.idleTimeouts.get(taskRunId);
1287+
if (timeout) {
1288+
clearTimeout(timeout.handle);
1289+
this.idleTimeouts.delete(taskRunId);
1290+
}
12271291
}
12281292
}
12291293

apps/code/src/main/trpc/routers/agent.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
promptInput,
1515
promptOutput,
1616
reconnectSessionInput,
17+
recordActivityInput,
1718
respondToPermissionInput,
1819
sessionResponseSchema,
1920
setConfigOptionInput,
@@ -183,6 +184,20 @@ export const agentRouter = router({
183184
log.info("All sessions reset successfully");
184185
}),
185186

187+
recordActivity: publicProcedure
188+
.input(recordActivityInput)
189+
.mutation(({ input }) => getService().recordActivity(input.taskRunId)),
190+
191+
onSessionIdleKilled: publicProcedure.subscription(async function* (opts) {
192+
const service = getService();
193+
for await (const event of service.toIterable(
194+
AgentServiceEvent.SessionIdleKilled,
195+
{ signal: opts.signal },
196+
)) {
197+
yield event;
198+
}
199+
}),
200+
186201
getGatewayModels: publicProcedure
187202
.input(getGatewayModelsInput)
188203
.output(getGatewayModelsOutput)

apps/code/src/renderer/features/sessions/service/service.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const mockTrpcAgent = vi.hoisted(() => ({
1515
cancelPermission: { mutate: vi.fn() },
1616
onSessionEvent: { subscribe: vi.fn() },
1717
onPermissionRequest: { subscribe: vi.fn() },
18+
onSessionIdleKilled: { subscribe: vi.fn(() => ({ unsubscribe: vi.fn() })) },
1819
resetAll: { mutate: vi.fn().mockResolvedValue(undefined) },
1920
}));
2021

0 commit comments

Comments
 (0)