fix(plan): clean up session directories and plans on deletion (#20914)

This commit is contained in:
Jerop Kipruto
2026-03-03 09:11:25 -05:00
committed by GitHub
parent 1e2afbb514
commit fca29b0bd8
6 changed files with 105 additions and 15 deletions

View File

@@ -28,6 +28,7 @@ implementation. It allows you to:
- [Example: Enable research subagents in Plan Mode](#example-enable-research-subagents-in-plan-mode) - [Example: Enable research subagents in Plan Mode](#example-enable-research-subagents-in-plan-mode)
- [Custom Plan Directory and Policies](#custom-plan-directory-and-policies) - [Custom Plan Directory and Policies](#custom-plan-directory-and-policies)
- [Automatic Model Routing](#automatic-model-routing) - [Automatic Model Routing](#automatic-model-routing)
- [Cleanup](#cleanup)
## Enabling Plan Mode ## Enabling Plan Mode
@@ -290,6 +291,24 @@ performance. You can disable this automatic switching in your settings:
} }
``` ```
## Cleanup
By default, Gemini CLI automatically cleans up old session data, including all
associated plan files and task trackers.
- **Default behavior:** Sessions (and their plans) are retained for **30 days**.
- **Configuration:** You can customize this behavior via the `/settings` command
(search for **Session Retention**) or in your `settings.json` file. See
[session retention] for more details.
Manual deletion also removes all associated artifacts:
- **Command Line:** Use `gemini --delete-session <index|id>`.
- **Session Browser:** Press `/resume`, navigate to a session, and press `x`.
If you use a [custom plans directory](#custom-plan-directory-and-policies),
those files are not automatically deleted and must be managed manually.
[`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder [`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder
[`read_file`]: /docs/tools/file-system.md#2-read_file-readfile [`read_file`]: /docs/tools/file-system.md#2-read_file-readfile
[`grep_search`]: /docs/tools/file-system.md#5-grep_search-searchtext [`grep_search`]: /docs/tools/file-system.md#5-grep_search-searchtext
@@ -311,3 +330,4 @@ performance. You can disable this automatic switching in your settings:
[auto model]: /docs/reference/configuration.md#model-settings [auto model]: /docs/reference/configuration.md#model-settings
[model routing]: /docs/cli/telemetry.md#model-routing [model routing]: /docs/cli/telemetry.md#model-routing
[preferred external editor]: /docs/reference/configuration.md#general [preferred external editor]: /docs/reference/configuration.md#general
[session retention]: /docs/cli/session-management.md#session-retention

View File

@@ -121,27 +121,36 @@ session lengths.
### Session retention ### Session retention
To prevent your history from growing indefinitely, enable automatic cleanup By default, Gemini CLI automatically cleans up old session data to prevent your
policies in your settings. history from growing indefinitely. When a session is deleted, Gemini CLI also
removes all associated data, including implementation plans, task trackers, tool
outputs, and activity logs.
The default policy is to **retain sessions for 30 days**.
#### Configuration
You can customize these policies using the `/settings` command or by manually
editing your `settings.json` file:
```json ```json
{ {
"general": { "general": {
"sessionRetention": { "sessionRetention": {
"enabled": true, "enabled": true,
"maxAge": "30d", // Keep sessions for 30 days "maxAge": "30d",
"maxCount": 50 // Keep the 50 most recent sessions "maxCount": 50
} }
} }
} }
``` ```
- **`enabled`**: (boolean) Master switch for session cleanup. Defaults to - **`enabled`**: (boolean) Master switch for session cleanup. Defaults to
`false`. `true`.
- **`maxAge`**: (string) Duration to keep sessions (for example, "24h", "7d", - **`maxAge`**: (string) Duration to keep sessions (for example, "24h", "7d",
"4w"). Sessions older than this are deleted. "4w"). Sessions older than this are deleted. Defaults to `"30d"`.
- **`maxCount`**: (number) Maximum number of sessions to retain. The oldest - **`maxCount`**: (number) Maximum number of sessions to retain. The oldest
sessions exceeding this count are deleted. sessions exceeding this count are deleted. Defaults to undefined (unlimited).
- **`minRetention`**: (string) Minimum retention period (safety limit). Defaults - **`minRetention`**: (string) Minimum retention period (safety limit). Defaults
to `"1d"`. Sessions newer than this period are never deleted by automatic to `"1d"`. Sessions newer than this period are never deleted by automatic
cleanup. cleanup.

View File

@@ -919,6 +919,32 @@ describe('Session Cleanup', () => {
), ),
); );
}); });
it('should delete the session-specific directory', async () => {
const config = createMockConfig();
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '1d', // Very short retention to trigger deletion of all but current
},
},
};
// Mock successful file operations
mockFs.access.mockResolvedValue(undefined);
mockFs.unlink.mockResolvedValue(undefined);
mockFs.rm.mockResolvedValue(undefined);
await cleanupExpiredSessions(config, settings);
// Verify that fs.rm was called with the session directory for the deleted session that has sessionInfo
// recent456 should be deleted and its directory removed
expect(mockFs.rm).toHaveBeenCalledWith(
path.join('/tmp/test-project', 'recent456'),
expect.objectContaining({ recursive: true, force: true }),
);
});
}); });
describe('parseRetentionPeriod format validation', () => { describe('parseRetentionPeriod format validation', () => {

View File

@@ -115,6 +115,17 @@ export async function cleanupExpiredSessions(
} catch { } catch {
/* ignore if doesn't exist */ /* ignore if doesn't exist */
} }
// ALSO cleanup the session-specific directory (contains plans, tasks, etc.)
const sessionDir = path.join(
config.storage.getProjectTempDir(),
sessionId,
);
try {
await fs.rm(sessionDir, { recursive: true, force: true });
} catch {
/* ignore if doesn't exist */
}
} }
if (config.getDebugMode()) { if (config.getDebugMode()) {

View File

@@ -309,23 +309,33 @@ describe('ChatRecordingService', () => {
}); });
describe('deleteSession', () => { describe('deleteSession', () => {
it('should delete the session file and tool outputs if they exist', () => { it('should delete the session file, tool outputs, session directory, and logs if they exist', () => {
const sessionId = 'test-session-id';
const chatsDir = path.join(testTempDir, 'chats'); const chatsDir = path.join(testTempDir, 'chats');
const logsDir = path.join(testTempDir, 'logs');
const toolOutputsDir = path.join(testTempDir, 'tool-outputs');
const sessionDir = path.join(testTempDir, sessionId);
fs.mkdirSync(chatsDir, { recursive: true }); fs.mkdirSync(chatsDir, { recursive: true });
const sessionFile = path.join(chatsDir, 'test-session-id.json'); fs.mkdirSync(logsDir, { recursive: true });
fs.mkdirSync(toolOutputsDir, { recursive: true });
fs.mkdirSync(sessionDir, { recursive: true });
const sessionFile = path.join(chatsDir, `${sessionId}.json`);
fs.writeFileSync(sessionFile, '{}'); fs.writeFileSync(sessionFile, '{}');
const toolOutputDir = path.join( const logFile = path.join(logsDir, `session-${sessionId}.jsonl`);
testTempDir, fs.writeFileSync(logFile, '{}');
'tool-outputs',
'session-test-session-id', const toolOutputDir = path.join(toolOutputsDir, `session-${sessionId}`);
);
fs.mkdirSync(toolOutputDir, { recursive: true }); fs.mkdirSync(toolOutputDir, { recursive: true });
chatRecordingService.deleteSession('test-session-id'); chatRecordingService.deleteSession(sessionId);
expect(fs.existsSync(sessionFile)).toBe(false); expect(fs.existsSync(sessionFile)).toBe(false);
expect(fs.existsSync(logFile)).toBe(false);
expect(fs.existsSync(toolOutputDir)).toBe(false); expect(fs.existsSync(toolOutputDir)).toBe(false);
expect(fs.existsSync(sessionDir)).toBe(false);
}); });
it('should not throw if session file does not exist', () => { it('should not throw if session file does not exist', () => {

View File

@@ -569,6 +569,13 @@ export class ChatRecordingService {
fs.unlinkSync(sessionPath); fs.unlinkSync(sessionPath);
} }
// Cleanup Activity logs in the project logs directory
const logsDir = path.join(tempDir, 'logs');
const logPath = path.join(logsDir, `session-${sessionId}.jsonl`);
if (fs.existsSync(logPath)) {
fs.unlinkSync(logPath);
}
// Cleanup tool outputs for this session // Cleanup tool outputs for this session
const safeSessionId = sanitizeFilenamePart(sessionId); const safeSessionId = sanitizeFilenamePart(sessionId);
const toolOutputDir = path.join( const toolOutputDir = path.join(
@@ -585,6 +592,13 @@ export class ChatRecordingService {
) { ) {
fs.rmSync(toolOutputDir, { recursive: true, force: true }); fs.rmSync(toolOutputDir, { recursive: true, force: true });
} }
// ALSO cleanup the session-specific directory (contains plans, tasks, etc.)
const sessionDir = path.join(tempDir, safeSessionId);
// Robustness: Ensure the path is strictly within the temp root
if (fs.existsSync(sessionDir) && sessionDir.startsWith(tempDir)) {
fs.rmSync(sessionDir, { recursive: true, force: true });
}
} catch (error) { } catch (error) {
debugLogger.error('Error deleting session file.', error); debugLogger.error('Error deleting session file.', error);
throw error; throw error;