Skip to content

Commit 9ffddf2

Browse files
committed
Add idle timeout to kill inactive sessions after 15 minutes
1 parent 4f2e034 commit 9ffddf2

File tree

6 files changed

+104
-0
lines changed

6 files changed

+104
-0
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+
// Report activity input — keeps the idle timeout debounce alive for the given task
187+
export const reportActivityInput = z.object({
188+
taskId: z.string().nullable(),
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.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,10 +252,13 @@ 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 idleTimeoutHandles = new Map<string, ReturnType<typeof setTimeout>>();
259262
private processTracking: ProcessTrackingService;
260263
private sleepService: SleepService;
261264
private fsService: FsService;
@@ -349,6 +352,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
349352
});
350353

351354
this.pendingPermissions.delete(key);
355+
this.recordActivity(taskRunId);
352356
}
353357

354358
/**
@@ -376,6 +380,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
376380
});
377381

378382
this.pendingPermissions.delete(key);
383+
this.recordActivity(taskRunId);
379384
}
380385

381386
/**
@@ -392,6 +397,36 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
392397
return false;
393398
}
394399

400+
public reportActivity(taskId: string | null): void {
401+
if (!taskId) return;
402+
for (const session of this.sessions.values()) {
403+
if (session.taskId === taskId) {
404+
this.recordActivity(session.taskRunId);
405+
}
406+
}
407+
}
408+
409+
private recordActivity(taskRunId: string): void {
410+
const existing = this.idleTimeoutHandles.get(taskRunId);
411+
if (existing) clearTimeout(existing);
412+
413+
const handle = setTimeout(() => {
414+
this.idleTimeoutHandles.delete(taskRunId);
415+
const session = this.sessions.get(taskRunId);
416+
if (!session || session.promptPending) return;
417+
log.info("Killing idle session", { taskRunId, taskId: session.taskId });
418+
this.emit(AgentServiceEvent.SessionIdleKilled, {
419+
taskRunId,
420+
taskId: session.taskId,
421+
});
422+
this.cleanupSession(taskRunId).catch((err) => {
423+
log.error("Failed to cleanup idle session", { taskRunId, err });
424+
});
425+
}, AgentService.IDLE_TIMEOUT_MS);
426+
427+
this.idleTimeoutHandles.set(taskRunId, handle);
428+
}
429+
395430
private getToken(fallback: string): string {
396431
return this.currentToken || fallback;
397432
}
@@ -912,6 +947,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
912947

913948
session.lastActivityAt = Date.now();
914949
session.promptPending = true;
950+
this.recordActivity(sessionId);
915951
this.sleepService.acquire(sessionId);
916952

917953
const promptJson = JSON.stringify(finalPrompt);
@@ -947,6 +983,8 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
947983
throw err;
948984
} finally {
949985
session.promptPending = false;
986+
session.lastActivityAt = Date.now();
987+
this.recordActivity(sessionId);
950988
this.sleepService.release(sessionId);
951989

952990
if (!this.hasActiveSessions()) {
@@ -1138,6 +1176,8 @@ For git operations while detached:
11381176

11391177
@preDestroy()
11401178
async cleanupAll(): Promise<void> {
1179+
for (const handle of this.idleTimeoutHandles.values()) clearTimeout(handle);
1180+
this.idleTimeoutHandles.clear();
11411181
const sessionIds = Array.from(this.sessions.keys());
11421182
log.info("Cleaning up all agent sessions", {
11431183
sessionCount: sessionIds.length,
@@ -1224,6 +1264,12 @@ For git operations while detached:
12241264
}
12251265

12261266
this.sessions.delete(taskRunId);
1267+
1268+
const handle = this.idleTimeoutHandles.get(taskRunId);
1269+
if (handle) {
1270+
clearTimeout(handle);
1271+
this.idleTimeoutHandles.delete(taskRunId);
1272+
}
12271273
}
12281274
}
12291275

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+
reportActivityInput,
1718
respondToPermissionInput,
1819
sessionResponseSchema,
1920
setConfigOptionInput,
@@ -183,6 +184,20 @@ export const agentRouter = router({
183184
log.info("All sessions reset successfully");
184185
}),
185186

187+
reportActivity: publicProcedure
188+
.input(reportActivityInput)
189+
.mutation(({ input }) => getService().reportActivity(input.taskId)),
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

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,23 @@ export class SessionService {
121121
onStatusChange?: () => void;
122122
}
123123
>();
124+
private idleKilledSubscription: { unsubscribe: () => void } | null = null;
125+
126+
constructor() {
127+
this.idleKilledSubscription =
128+
trpcVanilla.agent.onSessionIdleKilled.subscribe(undefined, {
129+
onData: (event) => {
130+
const { taskRunId } = event as { taskRunId: string; taskId: string };
131+
log.info("Session idle-killed by main process", { taskRunId });
132+
this.unsubscribeFromChannel(taskRunId);
133+
sessionStoreSetters.removeSession(taskRunId);
134+
removePersistedConfigOptions(taskRunId);
135+
},
136+
onError: (err) => {
137+
log.debug("Idle-killed subscription error", { error: err });
138+
},
139+
});
140+
}
124141

125142
/**
126143
* Connect to a task session.
@@ -767,6 +784,8 @@ export class SessionService {
767784
this.connectingTasks.clear();
768785
this.previewAbort?.abort();
769786
this.previewAbort = null;
787+
this.idleKilledSubscription?.unsubscribe();
788+
this.idleKilledSubscription = null;
770789
}
771790

772791
private updatePromptStateFromEvents(

apps/code/src/renderer/features/task-detail/components/TaskLogsPanel.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,17 @@ export function TaskLogsPanel({ taskId, task }: TaskLogsPanelProps) {
132132
requestFocus(taskId);
133133
}, [taskId, requestFocus]);
134134

135+
useEffect(() => {
136+
trpcVanilla.agent.reportActivity.mutate({ taskId }).catch(() => {});
137+
const heartbeat = setInterval(
138+
() => {
139+
trpcVanilla.agent.reportActivity.mutate({ taskId }).catch(() => {});
140+
},
141+
5 * 60 * 1000,
142+
);
143+
return () => clearInterval(heartbeat);
144+
}, [taskId]);
145+
135146
// Keep cloud session title aligned with latest task metadata.
136147
useEffect(() => {
137148
if (!isCloud) return;

0 commit comments

Comments
 (0)