mirror of
https://github.com/cline/cline.git
synced 2026-09-12 09:14:50 +08:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d82c6abb00 | ||
|
|
ecdcdfa363 | ||
|
|
0bf728eb53 |
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
Reference in New Issue
Block a user