mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 14:10:37 -07:00
fix(plan): clean up session directories and plans on deletion (#20914)
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user