Compare commits

...
Author SHA1 Message Date
abeatrix d82c6abb00 patch 2026-06-02 12:30:44 -07:00
abeatrix ecdcdfa363 patches 2026-06-01 19:29:22 -07:00
abeatrix 0bf728eb53 fix(core): Prune deleted session artifacts from persisted session history
## Summary

- Filter sessions whose artifact files were manually removed from `~/.cline/data/sessions` so `listSessions` no longer returns deleted sessions
- Add background cleanup for stale persisted session rows and wire it into hub daemon-owned session services
- Cascade cleanup to child/subagent rows and align file-backed delete behavior with SQLite-backed behavior

## Validation

- `bunx vitest run sdk/packages/core/src/session/services/persistence-service.test.ts`
- `bun biome check --diagnostic-level=error ...`
- `bun -F @cline/core typecheck`

## Notes

- Missing-artifact cleanup is scheduled in the background and errors are caught/logged.
- `listSessions` filters stale rows immediately without waiting for persisted row cleanup.
2026-06-01 19:12:06 -07:00
8 changed files with 524 additions and 29 deletions
@@ -56,6 +56,7 @@ export interface HubScheduleRuntimeHandlers {
}>;
abortSession(sessionId: string): Promise<{ applied: boolean }>;
stopSession(sessionId: string): Promise<{ applied: boolean }>;
dispose?: () => Promise<void> | void;
}
export interface ActiveScheduledExecution {
@@ -72,11 +72,16 @@ export interface CreateLocalHubScheduleRuntimeHandlersOptions
export function createLocalHubScheduleRuntimeHandlers(
options: CreateLocalHubScheduleRuntimeHandlersOptions = {},
): HubScheduleRuntimeHandlers {
const sessionService = new CoreSessionService(new SqliteSessionStore(), {
logger: options.logger,
});
const stopSessionCleanup = sessionService.startBackgroundSessionCleanup();
const sessionHost = new LocalRuntimeHost({
sessionService: new CoreSessionService(new SqliteSessionStore()),
sessionService,
fetch: options.fetch,
telemetry: options.telemetry,
});
let disposed = false;
return {
async startSession(request) {
@@ -140,5 +145,13 @@ export function createLocalHubScheduleRuntimeHandlers(
await sessionHost.stopSession(sessionId);
return { applied: true };
},
async dispose() {
if (disposed) {
return;
}
disposed = true;
stopSessionCleanup();
await sessionHost.dispose("hub_schedule_runtime_stop");
},
};
}
@@ -176,17 +176,22 @@ export class HubServerTransport implements NativeHubTransport {
private readonly cronService?: CronService;
private readonly sessionHost: RuntimeHost &
Partial<PendingPromptsRuntimeService>;
private readonly stopSessionCleanup?: () => void;
private readonly hubId = createSessionId("hub_");
private readonly ctx: HubTransportContext;
constructor(readonly options: HubWebSocketServerOptions) {
this.sessionHost =
options.sessionHost ??
new LocalRuntimeHost({
sessionService: new CoreSessionService(new SqliteSessionStore()),
if (options.sessionHost) {
this.sessionHost = options.sessionHost;
} else {
const sessionService = new CoreSessionService(new SqliteSessionStore());
this.stopSessionCleanup = sessionService.startBackgroundSessionCleanup();
this.sessionHost = new LocalRuntimeHost({
sessionService,
fetch: options.fetch,
telemetry: options.telemetry,
});
}
this.ctx = {
clients: this.clients,
sessionState: this.sessionState,
@@ -294,6 +299,7 @@ export class HubServerTransport implements NativeHubTransport {
() => true,
"Hub shutting down before capability request was resolved.",
);
this.stopSessionCleanup?.();
await this.sessionHost.dispose("hub_server_stop");
await this.schedules.dispose();
if (this.cronService) {
@@ -303,6 +309,11 @@ export class HubServerTransport implements NativeHubTransport {
console.error("[hub] cron service stop failed", err);
}
}
try {
await this.options.runtimeHandlers.dispose?.();
} catch (err) {
console.error("[hub] schedule runtime stop failed", err);
}
}
async handleCommand(envelope: HubCommandEnvelope): Promise<HubReplyEnvelope> {
@@ -123,6 +123,7 @@ class FileSessionPersistenceAdapter implements SessionPersistenceAdapter {
async listSessions(options: {
limit: number;
offset?: number;
parentSessionId?: string;
status?: string;
}): Promise<SessionRow[]> {
@@ -136,7 +137,10 @@ class FileSessionPersistenceAdapter implements SessionPersistenceAdapter {
options.status !== undefined ? row.status === options.status : true,
)
.sort((a, b) => b.startedAt.localeCompare(a.startedAt))
.slice(0, options.limit);
.slice(
Math.max(0, Math.floor(options.offset ?? 0)),
Math.max(0, Math.floor(options.offset ?? 0)) + options.limit,
);
}
async updateSession(
@@ -213,10 +217,9 @@ class FileSessionPersistenceAdapter implements SessionPersistenceAdapter {
async deleteSession(sessionId: string, cascade: boolean): Promise<boolean> {
const index = this.readIndex();
const existing = index.sessions[sessionId];
if (!existing) {
return false;
if (existing) {
delete index.sessions[sessionId];
}
delete index.sessions[sessionId];
if (cascade) {
for (const row of Object.values(index.sessions)) {
if (row.parentSessionId === sessionId) {
@@ -225,7 +228,7 @@ class FileSessionPersistenceAdapter implements SessionPersistenceAdapter {
}
}
this.writeIndex(index);
return true;
return !!existing;
}
async enqueueSpawnRequest(input: {
@@ -1,4 +1,10 @@
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import {
existsSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -102,6 +108,246 @@ describe("UnifiedSessionPersistenceService", () => {
15_000,
);
sqliteIt(
"prunes database rows when a root session artifact directory is removed",
async () => {
const dbDir = mkdtempSync(join(tmpdir(), "removed-session-prune-db-"));
const sessionsDir = mkdtempSync(
join(tmpdir(), "removed-session-prune-sessions-"),
);
tempDirs.push(dbDir, sessionsDir);
const store = new SqliteSessionStore({ sessionsDir: dbDir });
stores.push(store);
const service = new CoreSessionService(store, {
sessionArtifactsDir: sessionsDir,
});
const sessionId = "removed-root-session";
await service.createRootSessionWithArtifacts({
sessionId,
source: SessionSource.CLI,
pid: process.pid,
interactive: false,
provider: "mock-provider",
model: "mock-model",
cwd: "/tmp/project",
workspaceRoot: "/tmp/project",
enableTools: true,
enableSpawn: true,
enableTeams: true,
prompt: "hello",
startedAt: "2026-01-01T00:00:00.000Z",
});
await service.onTeamTaskStart(
sessionId,
"java-haiku-agent",
"Write a haiku about Java",
);
await expect(
service.updateSessionStatus(sessionId, "completed", 0),
).resolves.toMatchObject({ updated: true });
rmSync(join(sessionsDir, sessionId), { recursive: true, force: true });
await expect(service.listSessions(10)).resolves.toEqual([]);
await expect(
service.reconcileMissingArtifactSessions(10),
).resolves.toBeGreaterThanOrEqual(0);
expect(
store.queryOne(`SELECT session_id FROM sessions WHERE session_id = ?`, [
sessionId,
]),
).toBeUndefined();
expect(
store.queryAll(
`SELECT session_id FROM sessions WHERE parent_session_id = ?`,
[sessionId],
),
).toEqual([]);
},
);
it("prunes indexed rows when a root session artifact directory is removed", async () => {
const sessionsDir = mkdtempSync(
join(tmpdir(), "removed-session-prune-file-"),
);
tempDirs.push(sessionsDir);
const service = new FileSessionService(sessionsDir);
const sessionId = "removed-file-root-session";
await service.createRootSessionWithArtifacts({
sessionId,
source: SessionSource.CLI,
pid: process.pid,
interactive: false,
provider: "mock-provider",
model: "mock-model",
cwd: "/tmp/project",
workspaceRoot: "/tmp/project",
enableTools: true,
enableSpawn: true,
enableTeams: true,
prompt: "hello",
startedAt: "2026-01-01T00:00:00.000Z",
});
await service.onTeamTaskStart(
sessionId,
"java-haiku-agent",
"Write a haiku about Java",
);
await expect(
service.updateSessionStatus(sessionId, "completed", 0),
).resolves.toMatchObject({ updated: true });
rmSync(join(sessionsDir, sessionId), { recursive: true, force: true });
await expect(service.listSessions(10)).resolves.toEqual([]);
await expect(
service.reconcileMissingArtifactSessions(10),
).resolves.toBeGreaterThanOrEqual(0);
const index = JSON.parse(
readFileSync(join(sessionsDir, "sessions.index.json"), "utf8"),
) as { sessions: Record<string, unknown> };
expect(index.sessions[sessionId]).toBeUndefined();
expect(Object.values(index.sessions)).toEqual([]);
});
it("does not prune a live non-terminal session with missing artifacts", async () => {
const sessionsDir = mkdtempSync(join(tmpdir(), "live-session-prune-file-"));
tempDirs.push(sessionsDir);
const service = new FileSessionService(sessionsDir);
const sessionId = "live-file-root-session";
await service.createRootSessionWithArtifacts({
sessionId,
source: SessionSource.CLI,
pid: process.pid,
interactive: false,
provider: "mock-provider",
model: "mock-model",
cwd: "/tmp/project",
workspaceRoot: "/tmp/project",
enableTools: true,
enableSpawn: false,
enableTeams: false,
prompt: "still running",
startedAt: "2026-01-01T00:00:00.000Z",
});
rmSync(join(sessionsDir, sessionId), { recursive: true, force: true });
await expect(service.reconcileMissingArtifactSessions(10)).resolves.toBe(0);
const index = JSON.parse(
readFileSync(join(sessionsDir, "sessions.index.json"), "utf8"),
) as { sessions: Record<string, unknown> };
expect(index.sessions[sessionId]).toBeTruthy();
});
it("does not prune a child row with null messagesPath while its parent artifacts exist", async () => {
const sessionsDir = mkdtempSync(
join(tmpdir(), "null-child-messages-prune-file-"),
);
tempDirs.push(sessionsDir);
const service = new FileSessionService(sessionsDir);
const rootSessionId = "healthy-parent-session";
await service.createRootSessionWithArtifacts({
sessionId: rootSessionId,
source: SessionSource.CLI,
pid: process.pid,
interactive: false,
provider: "mock-provider",
model: "mock-model",
cwd: "/tmp/project",
workspaceRoot: "/tmp/project",
enableTools: true,
enableSpawn: true,
enableTeams: true,
prompt: "parent",
startedAt: "2026-01-01T00:00:00.000Z",
});
await service.onTeamTaskStart(
rootSessionId,
"java-haiku-agent",
"Write a haiku about Java",
);
const indexPath = join(sessionsDir, "sessions.index.json");
const index = JSON.parse(readFileSync(indexPath, "utf8")) as {
sessions: Record<
string,
{ messagesPath?: string | null; status?: string }
>;
};
const childSessionId = Object.keys(index.sessions).find(
(sessionId) => sessionId !== rootSessionId,
);
expect(childSessionId).toBeTruthy();
const child = index.sessions[childSessionId as string];
child.messagesPath = null;
child.status = "completed";
writeFileSync(indexPath, `${JSON.stringify(index, null, 2)}\n`, "utf8");
await expect(service.reconcileMissingArtifactSessions(10)).resolves.toBe(0);
const nextIndex = JSON.parse(readFileSync(indexPath, "utf8")) as {
sessions: Record<string, unknown>;
};
expect(nextIndex.sessions[childSessionId as string]).toBeTruthy();
});
it("continues scanning after stale rows to return older valid sessions", async () => {
const sessionsDir = mkdtempSync(
join(tmpdir(), "stale-session-window-file-"),
);
tempDirs.push(sessionsDir);
const service = new FileSessionService(sessionsDir);
for (let index = 0; index < 11; index += 1) {
const sessionId = `removed-window-session-${index}`;
await service.createRootSessionWithArtifacts({
sessionId,
source: SessionSource.CLI,
pid: process.pid,
interactive: false,
provider: "mock-provider",
model: "mock-model",
cwd: "/tmp/project",
workspaceRoot: "/tmp/project",
enableTools: true,
enableSpawn: false,
enableTeams: false,
prompt: `removed ${index}`,
startedAt: `2026-01-02T00:00:${String(index).padStart(2, "0")}.000Z`,
});
rmSync(join(sessionsDir, sessionId), { recursive: true, force: true });
}
for (let index = 0; index < 2; index += 1) {
await service.createRootSessionWithArtifacts({
sessionId: `valid-window-session-${index}`,
source: SessionSource.CLI,
pid: process.pid,
interactive: false,
provider: "mock-provider",
model: "mock-model",
cwd: "/tmp/project",
workspaceRoot: "/tmp/project",
enableTools: true,
enableSpawn: false,
enableTeams: false,
prompt: `valid ${index}`,
startedAt: `2026-01-01T00:00:${String(index).padStart(2, "0")}.000Z`,
});
}
const rows = await service.listSessions(2);
expect(rows.map((row) => row.sessionId)).toEqual([
"valid-window-session-1",
"valid-window-session-0",
]);
});
sqliteIt(
"persists teammate task metadata in the file envelope and usage on messages",
async () => {
@@ -1,3 +1,4 @@
import { existsSync } from "node:fs";
import { dirname } from "node:path";
import type * as LlmsProviders from "@cline/llms";
import type { AgentResult, BasicLogger } from "@cline/shared";
@@ -37,10 +38,23 @@ import { TeamChildSessionManager } from "../team";
export type { PersistedSessionUpdateInput, SessionPersistenceAdapter };
const OCC_MAX_RETRIES = 4;
const DEFAULT_SESSION_CLEANUP_INTERVAL_MS = 60_000;
const DEFAULT_SESSION_CLEANUP_LIMIT = 2000;
const SESSION_LIST_PAGE_SIZE = 500;
const MAX_SESSION_LIST_SCAN_ROWS = 10_000;
export interface BackgroundSessionCleanupOptions {
intervalMs?: number;
limit?: number;
}
export class UnifiedSessionPersistenceService {
private readonly manifestStore: SessionManifestStore;
private readonly teamChildren: TeamChildSessionManager;
private readonly logger: BasicLogger | undefined;
private missingArtifactCleanupPromise: Promise<number> | undefined;
private missingArtifactCleanupLimit = 0;
private pendingMissingArtifactCleanupLimit = 0;
private static readonly STALE_REASON = "failed_external_process_exit";
private static readonly STALE_SOURCE = "stale_session_reconciler";
private static readonly TEAM_HEARTBEAT_LOG_INTERVAL_MS = 30_000;
@@ -64,6 +78,7 @@ export class UnifiedSessionPersistenceService {
this.toPersistedMessages(messages, result, previousMessages),
UnifiedSessionPersistenceService.TEAM_HEARTBEAT_LOG_INTERVAL_MS,
);
this.logger = options.logger;
}
private toPersistedMessages(
@@ -421,6 +436,7 @@ export class UnifiedSessionPersistenceService {
): Promise<SessionRow | undefined> {
if (
isNonTerminalSessionStatus(row.status) === false ||
!this.hasPersistedArtifacts(row) ||
this.isPidAlive(row.pid)
) {
return row;
@@ -487,25 +503,224 @@ export class UnifiedSessionPersistenceService {
return await this.adapter.getSession(row.sessionId);
}
private normalizeArtifactPath(
path: string | null | undefined,
): string | undefined {
return typeof path === "string" && path.trim().length > 0
? path
: undefined;
}
private hasRootArtifacts(sessionId: string, messagesPath?: string): boolean {
const sessionDir =
this.manifestStore.artifacts.sessionArtifactsDir(sessionId);
if (!existsSync(sessionDir)) {
return false;
}
const manifestPath = this.manifestStore.artifacts.sessionManifestPath(
sessionId,
false,
);
return (
existsSync(manifestPath) || !!(messagesPath && existsSync(messagesPath))
);
}
private hasPersistedArtifacts(row: SessionRow): boolean {
const messagesPath = this.normalizeArtifactPath(row.messagesPath);
if (row.isSubagent) {
if (messagesPath) {
return existsSync(messagesPath);
}
const parentSessionId = this.normalizeArtifactPath(row.parentSessionId);
return parentSessionId ? this.hasRootArtifacts(parentSessionId) : false;
}
return this.hasRootArtifacts(row.sessionId, messagesPath);
}
private isLiveNonTerminalSession(row: SessionRow): boolean {
return isNonTerminalSessionStatus(row.status) && this.isPidAlive(row.pid);
}
private async pruneMissingArtifactSessions(limit = 2000): Promise<number> {
const requestedLimit = Math.max(1, Math.floor(limit));
const rows = await this.adapter.listSessions({ limit: requestedLimit });
let pruned = 0;
const prunedRootSessionIds = new Set<string>();
for (const row of rows) {
if (
row.isSubagent &&
row.parentSessionId &&
prunedRootSessionIds.has(row.parentSessionId)
) {
continue;
}
if (this.isLiveNonTerminalSession(row)) {
continue;
}
if (
row.isSubagent &&
!this.normalizeArtifactPath(row.messagesPath) &&
row.parentSessionId
) {
const parent = await this.adapter.getSession(row.parentSessionId);
if (parent && this.isLiveNonTerminalSession(parent)) {
continue;
}
}
if (this.hasPersistedArtifacts(row)) {
continue;
}
const result = await this.deleteSession(row.sessionId);
if (result.deleted) {
pruned++;
if (!row.isSubagent) {
prunedRootSessionIds.add(row.sessionId);
}
}
}
return pruned;
}
async reconcileMissingArtifactSessions(limit = 2000): Promise<number> {
const requestedLimit = Math.max(1, Math.floor(limit));
if (this.missingArtifactCleanupPromise) {
if (requestedLimit <= this.missingArtifactCleanupLimit) {
return await this.missingArtifactCleanupPromise;
}
this.pendingMissingArtifactCleanupLimit = Math.max(
this.pendingMissingArtifactCleanupLimit,
requestedLimit,
);
await this.missingArtifactCleanupPromise;
return await this.reconcileMissingArtifactSessions(requestedLimit);
}
this.pendingMissingArtifactCleanupLimit = 0;
const cleanup = this.pruneMissingArtifactSessions(requestedLimit);
this.missingArtifactCleanupPromise = cleanup;
this.missingArtifactCleanupLimit = requestedLimit;
try {
return await this.missingArtifactCleanupPromise;
} finally {
if (this.missingArtifactCleanupPromise === cleanup) {
this.missingArtifactCleanupPromise = undefined;
this.missingArtifactCleanupLimit = 0;
const pendingLimit = this.pendingMissingArtifactCleanupLimit;
this.pendingMissingArtifactCleanupLimit = 0;
if (pendingLimit > requestedLimit) {
this.scheduleMissingArtifactCleanup(pendingLimit);
}
}
}
}
private scheduleMissingArtifactCleanup(limit = 2000): void {
const timer = setTimeout(() => {
void this.reconcileMissingArtifactSessions(limit).catch((error) => {
this.logger?.log("Session artifact cleanup failed", {
severity: "warn",
error,
});
});
}, 0);
timer.unref?.();
}
private runScheduledMissingArtifactCleanup(limit = 2000): void {
void this.reconcileMissingArtifactSessions(limit).catch((error) => {
this.logger?.log("Session artifact cleanup failed", {
severity: "warn",
error,
});
});
}
startBackgroundSessionCleanup(
options: BackgroundSessionCleanupOptions = {},
): () => void {
const limit = Math.max(
1,
Math.floor(options.limit ?? DEFAULT_SESSION_CLEANUP_LIMIT),
);
const intervalMs = Math.max(
1_000,
Math.floor(options.intervalMs ?? DEFAULT_SESSION_CLEANUP_INTERVAL_MS),
);
this.scheduleMissingArtifactCleanup(limit);
const interval = setInterval(() => {
this.runScheduledMissingArtifactCleanup(limit);
}, intervalMs);
interval.unref?.();
return () => clearInterval(interval);
}
private withResolvedHistoryMetadata(row: SessionRow): SessionRow {
const meta = sanitizeMetadata(row.metadata ?? undefined);
const manifest = this.manifestStore.readSessionManifest(row.sessionId);
const manifestTitle = normalizeTitle(
typeof manifest?.metadata?.title === "string"
? (manifest.metadata.title as string)
: undefined,
);
const resolved = manifestTitle
? { ...(meta ?? {}), title: manifestTitle }
: meta;
return { ...row, metadata: resolved };
}
private async listRowsWithPersistedArtifacts(
requestedLimit: number,
): Promise<SessionRow[]> {
const rows: SessionRow[] = [];
const pageSize = Math.max(
requestedLimit,
Math.min(SESSION_LIST_PAGE_SIZE, MAX_SESSION_LIST_SCAN_ROWS),
);
const maxScanRows = Math.max(
DEFAULT_SESSION_CLEANUP_LIMIT,
Math.min(MAX_SESSION_LIST_SCAN_ROWS, requestedLimit * 10),
);
let offset = 0;
while (rows.length < requestedLimit && offset < maxScanRows) {
const batchLimit = Math.min(pageSize, maxScanRows - offset);
const batch = await this.adapter.listSessions({
limit: batchLimit,
offset,
});
if (batch.length === 0) {
break;
}
for (const row of batch) {
if (this.hasPersistedArtifacts(row)) {
rows.push(row);
if (rows.length >= requestedLimit) {
break;
}
}
}
if (batch.length < batchLimit) {
break;
}
offset += batch.length;
}
return rows;
}
async listSessions(limit = 200): Promise<SessionRow[]> {
const requestedLimit = Math.max(1, Math.floor(limit));
const scanLimit = Math.min(requestedLimit * 5, 2000);
await this.reconcileDeadSessions(scanLimit);
const cleanupLimit = Math.max(
DEFAULT_SESSION_CLEANUP_LIMIT,
Math.min(MAX_SESSION_LIST_SCAN_ROWS, requestedLimit * 10),
);
const deadSessionScanLimit = Math.min(requestedLimit * 5, 2000);
this.scheduleMissingArtifactCleanup(cleanupLimit);
await this.reconcileDeadSessions(deadSessionScanLimit);
const rows = await this.adapter.listSessions({ limit: scanLimit });
return rows.slice(0, requestedLimit).map((row) => {
const meta = sanitizeMetadata(row.metadata ?? undefined);
const manifest = this.manifestStore.readSessionManifest(row.sessionId);
const manifestTitle = normalizeTitle(
typeof manifest?.metadata?.title === "string"
? (manifest.metadata.title as string)
: undefined,
);
const resolved = manifestTitle
? { ...(meta ?? {}), title: manifestTitle }
: meta;
return { ...row, metadata: resolved };
});
const rows = await this.listRowsWithPersistedArtifacts(requestedLimit);
return rows.map((row) => this.withResolvedHistoryMetadata(row));
}
async reconcileDeadSessions(limit = 2000): Promise<number> {
@@ -81,6 +81,7 @@ class LocalSessionPersistenceAdapter implements SessionPersistenceAdapter {
async listSessions(options: {
limit: number;
offset?: number;
parentSessionId?: string;
status?: string;
}): Promise<SessionRow[]> {
@@ -102,8 +103,12 @@ class LocalSessionPersistenceAdapter implements SessionPersistenceAdapter {
FROM sessions
${where}
ORDER BY started_at DESC
LIMIT ?`,
[...params, options.limit],
LIMIT ? OFFSET ?`,
[
...params,
options.limit,
Math.max(0, Math.floor(options.offset ?? 0)),
],
)
.map(patchSqliteRow);
}
+1
View File
@@ -105,6 +105,7 @@ export interface SessionPersistenceAdapter {
getSession(sessionId: string): Promise<SessionRow | undefined>;
listSessions(options: {
limit: number;
offset?: number;
parentSessionId?: string;
status?: string;
}): Promise<SessionRow[]>;