From 3b0f9b0fbc90a79e654e83a95280e0847f11bfab Mon Sep 17 00:00:00 2001 From: vkeerthivikram Date: Thu, 24 Sep 2026 16:48:03 +0530 Subject: [PATCH 1/3] feat: reclaim database space after session cleanup and add a stop button Session cleanup deleted rows but never shrank kilo.db, and a pass could not be stopped once started. A pass now vacuums the database when freed pages clear absolute and relative floors, and a new retention cancel endpoint lets clients stop the active pass; already-deleted sessions stay deleted and the partial result is recorded. The VS Code settings screen gains a Stop button plus cancelling progress and interrupted last-run states. docs-tmp regenerated visual-regression baselines (not part of the cleanup branch) --- .changeset/session-cleanup-stop.md | 6 + .changeset/session-cleanup-vacuum.md | 5 + .../code-with-ai/features/checkpoints.md | 2 + .../code-with-ai/platforms/cli-reference.md | 4 - packages/kilo-vscode/src/KiloProvider.ts | 17 +++ .../src/services/task-cleanup/retention.ts | 12 +- .../tests/unit/cleanup-poll.test.ts | 25 ++++ .../components/settings/CheckpointsTab.tsx | 49 +++++--- .../src/components/settings/cleanup.ts | 10 +- .../kilo-vscode/webview-ui/src/i18n/ar.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/br.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/bs.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/da.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/de.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/en.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/es.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/fa.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/fr.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/it.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/ja.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/ko.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/nl.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/no.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/pl.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/ru.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/th.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/tr.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/uk.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/zh.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/zht.ts | 3 + .../src/types/messages/extension-messages.ts | 4 +- .../src/types/messages/webview-messages.ts | 6 + .../server/httpapi/groups/kilocode.ts | 22 +++- .../server/httpapi/handlers/kilocode.ts | 5 + .../src/kilocode/session/retention.ts | 90 ++++++++++++- .../server/httpapi-exercise-scenarios.ts | 11 ++ .../session/retention-reclaim.test.ts | 57 +++++++++ .../test/kilocode/session/retention.test.ts | 111 ++++++++++++++++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 36 ++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 38 +++++- packages/sdk/openapi.json | 118 +++++++++++++++++- 41 files changed, 656 insertions(+), 35 deletions(-) create mode 100644 .changeset/session-cleanup-stop.md create mode 100644 .changeset/session-cleanup-vacuum.md create mode 100644 packages/opencode/test/kilocode/session/retention-reclaim.test.ts diff --git a/.changeset/session-cleanup-stop.md b/.changeset/session-cleanup-stop.md new file mode 100644 index 000000000000..83073d989f29 --- /dev/null +++ b/.changeset/session-cleanup-stop.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": minor +"kilo-code": minor +--- + +Add a stop button to session cleanup: a running cleanup pass can now be stopped from the settings screen. Already-deleted sessions stay deleted, the interrupted pass records its partial result, and the session database reclaims the freed disk space. diff --git a/.changeset/session-cleanup-vacuum.md b/.changeset/session-cleanup-vacuum.md new file mode 100644 index 000000000000..e0b0d41d2d0e --- /dev/null +++ b/.changeset/session-cleanup-vacuum.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Reclaim disk space after session cleanup: the session database now shrinks on disk once a cleanup pass frees enough pages, instead of keeping the old file size forever. diff --git a/packages/kilo-docs/pages/code-with-ai/features/checkpoints.md b/packages/kilo-docs/pages/code-with-ai/features/checkpoints.md index 065ea0d2c267..6e78be5f3e90 100644 --- a/packages/kilo-docs/pages/code-with-ai/features/checkpoints.md +++ b/packages/kilo-docs/pages/code-with-ai/features/checkpoints.md @@ -71,6 +71,8 @@ The policy lives in `kilo.json` under the `retention` key, so it applies no matt Once enabled, the VS Code extension triggers cleanup about once a day while it is running. You can also run it immediately with the **Run Cleanup Now** button, which asks for confirmation first because deletion is permanent. A spinner and live status show the scanning or deleting phase and the number of sessions processed out of the total. During deletion, the status also shows deleted and failed counts. Reopening Settings shows the current progress. After each run, the **Last cleanup** line shows how many sessions were deleted, how many were skipped, and whether anything failed. +A long pass can be halted with the **Stop cleanup** button while it runs. Stopping keeps what the pass already deleted and skips the rest; the **Last cleanup** line marks an interrupted run. After a pass frees a large share of the session database, Kilo also reclaims the disk space, so the storage file shrinks instead of keeping its old size. + ### What Is Protected - **Active sessions**: Cleanup skips sessions reported as busy by the current backend and sessions with message or part writes in the last hour, including writes from another window or terminal. Merely keeping an idle session open does not protect it. diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md b/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md index cf1fd640ea09..bd16fa0fb9da 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md @@ -1041,8 +1041,6 @@ Options: --stream connect to the WebSocket stream and print events as JSONL [boolean] ``` -Provide exactly one of `--prompt` or `--prompt-stdin`. For example, read a multiline prompt from a file with `kilo cloud start --repo owner/repo --prompt-stdin < prompt.txt`. - ### kilo cloud send ``` @@ -1056,8 +1054,6 @@ Options: --session-id Cloud Agent session ID [string] [required] ``` -Provide exactly one of `--prompt` or `--prompt-stdin`, as with `kilo cloud start`. - ### kilo cloud status ``` diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 20ed149abbce..099167f3cc2e 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -3553,6 +3553,23 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper }) return true } + if (message.type === "stopAutoCleanupNow") { + const service = this.autoCleanup() + const requested = await service?.cancel().then( + () => true, + () => false, + ) + const status = await service?.status().catch(() => null) + this.postMessage({ + type: "autoCleanupStateLoaded", + requestID, + last: status?.last ?? service?.lastResult() ?? null, + progress: status?.progress, + pending: service?.running, + ...(!requested && !status ? { error: "run" } : {}), + }) + return true + } return false } diff --git a/packages/kilo-vscode/src/services/task-cleanup/retention.ts b/packages/kilo-vscode/src/services/task-cleanup/retention.ts index 70cb1471b15c..35342e592e5c 100644 --- a/packages/kilo-vscode/src/services/task-cleanup/retention.ts +++ b/packages/kilo-vscode/src/services/task-cleanup/retention.ts @@ -8,13 +8,15 @@ export interface RetentionResult { skippedActive: number failed: number durationMs: number + cancelled?: boolean + reclaimedBytes?: number } export interface RetentionStatus { policy: { enabled: boolean; maxAgeDays: number } last: RetentionResult | null progress?: { - phase: "scanning" | "deleting" + phase: "scanning" | "deleting" | "cancelling" total: number processed: number deleted: number @@ -125,6 +127,14 @@ export class RetentionService { return this.normalize(response.data) } + /** Asks the backend to stop the active pass; false when none is running. */ + async cancel(): Promise { + const client = this.connection.getClient() + const directory = this.directory() + const response = await client.kilocode.retention.cancel(directory ? { directory } : {}, { throwOnError: true }) + return response.data.requested + } + /** The SDK types JSON numbers as a NaN/Infinity union; the wire format is a plain number. */ private normalize(data: unknown): RetentionStatus { const status = data as RetentionStatus diff --git a/packages/kilo-vscode/tests/unit/cleanup-poll.test.ts b/packages/kilo-vscode/tests/unit/cleanup-poll.test.ts index c5726defb20a..b868fad4cd64 100644 --- a/packages/kilo-vscode/tests/unit/cleanup-poll.test.ts +++ b/packages/kilo-vscode/tests/unit/cleanup-poll.test.ts @@ -151,4 +151,29 @@ describe("session cleanup polling", () => { f.poll.execute() expect(f.sent).toHaveLength(2) }) + + test("stop posts once per running pass and never while idle", () => { + const f = fixture() + f.poll.stop() + expect(f.sent).toHaveLength(0) + f.poll.start() + f.reply(0, { pending: true, progress }) + f.poll.stop() + f.poll.stop() + expect(f.sent.filter((message) => message.type === "stopAutoCleanupNow")).toHaveLength(1) + const halt = f.sent.at(1) + if (!halt || !("requestID" in halt)) throw new Error("Missing stop request") + f.poll.receive({ + type: "autoCleanupStateLoaded", + requestID: halt.requestID, + last: null, + progress: { ...progress, phase: "cancelling" }, + }) + expect(f.states.at(-1)?.message.progress?.phase).toBe("cancelling") + // A later pass can be stopped again. + f.poll.start() + f.reply(2, { pending: true, progress }) + f.poll.stop() + expect(f.sent.filter((message) => message.type === "stopAutoCleanupNow")).toHaveLength(2) + }) }) diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/CheckpointsTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/CheckpointsTab.tsx index c8be69fc6876..27f4a73e7ead 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/CheckpointsTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/CheckpointsTab.tsx @@ -50,14 +50,17 @@ const CheckpointsTab: Component = () => { const lastText = () => { const run = state().last if (!run) return language.t("settings.autoCleanup.lastRun.never") - return language.t("settings.autoCleanup.result", { - date: new Date(run.at).toLocaleString(), - deleted: String(run.deleted), - scanned: String(run.scanned), - failed: String(run.failed), - active: String(run.skippedActive), - seconds: String(Math.max(0.1, Math.round(run.durationMs / 100) / 10)), - }) + const cancelled = run.cancelled ? ` — ${language.t("settings.autoCleanup.lastRun.cancelled")}` : "" + return ( + language.t("settings.autoCleanup.result", { + date: new Date(run.at).toLocaleString(), + deleted: String(run.deleted), + scanned: String(run.scanned), + failed: String(run.failed), + active: String(run.skippedActive), + seconds: String(Math.max(0.1, Math.round(run.durationMs / 100) / 10)), + }) + cancelled + ) } const progressText = () => { @@ -157,17 +160,29 @@ const CheckpointsTab: Component = () => { )} - - + - {language.t("settings.autoCleanup.runNow")} - +
{progressText()} diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/cleanup.ts b/packages/kilo-vscode/webview-ui/src/components/settings/cleanup.ts index 19a733be7b64..3220ca70b322 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/cleanup.ts +++ b/packages/kilo-vscode/webview-ui/src/components/settings/cleanup.ts @@ -5,6 +5,7 @@ export class CleanupPoll { private timer?: ReturnType private request?: string private run?: string + private halt?: string private disposed = false private stale = false private failure?: AutoCleanupStateLoadedMessage["error"] @@ -29,6 +30,12 @@ export class CleanupPoll { this.post({ type: "runAutoCleanupNow", requestID: this.run }) } + stop(): void { + if (this.disposed || this.halt || !this.state?.progress) return + this.halt = crypto.randomUUID() + this.post({ type: "stopAutoCleanupNow", requestID: this.halt }) + } + receive(message: AutoCleanupStateLoadedMessage): void { if (this.disposed || !message.requestID) return if (message.requestID === this.run) { @@ -43,7 +50,8 @@ export class CleanupPoll { this.start() return } - } else return + } else if (message.requestID !== this.halt) return + if (message.requestID === this.halt) this.halt = undefined const unavailable = message.error === "status" || message.error === "timeout" this.state = { ...message, diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 8077a9d626c7..62ae64bd3331 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -1110,6 +1110,9 @@ export const dict = { "settings.autoCleanup.runNow": "شغّل التنظيف الآن", "settings.autoCleanup.runNow.confirm": "حذف الجلسات المنتهية الصلاحية نهائيًا عبر جميع المشاريع وكل عميل Kilo على هذا الجهاز؟", + "settings.autoCleanup.stop": "إيقاف التنظيف", + "settings.autoCleanup.progress.cancelling": "جارٍ إيقاف تنظيف الجلسات...", + "settings.autoCleanup.lastRun.cancelled": "تمت المقاطعة", "settings.context.autoCompaction.title": "ضغط تلقائي", "settings.context.autoCompaction.description": "ضغط السياق تلقائياً قبل أن يصل إلى الحد", "settings.context.compaction.title": "الضغط", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index c865789c56ac..6db1ca16cee9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -1154,6 +1154,9 @@ export const dict = { "settings.autoCleanup.runNow": "Executar limpeza agora", "settings.autoCleanup.runNow.confirm": "Excluir permanentemente as sessões expiradas em todos os projetos e em todos os clientes Kilo desta máquina?", + "settings.autoCleanup.stop": "Parar a limpeza", + "settings.autoCleanup.progress.cancelling": "Parando a limpeza de sessões...", + "settings.autoCleanup.lastRun.cancelled": "interrompida", "settings.context.autoCompaction.title": "Compactação automática", "settings.context.autoCompaction.description": "Compactar automaticamente o contexto antes que atinja o limite", "settings.context.compaction.title": "Compactação", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index a76dd93c61f8..c8f508374b53 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -1145,6 +1145,9 @@ export const dict = { "settings.autoCleanup.runNow": "Pokreni čišćenje sada", "settings.autoCleanup.runNow.confirm": "Trajno obrisati istekle sesije u svim projektima i svim Kilo klijentima na ovom računaru?", + "settings.autoCleanup.stop": "Zaustavi čišćenje", + "settings.autoCleanup.progress.cancelling": "Zaustavljanje čišćenja sesija...", + "settings.autoCleanup.lastRun.cancelled": "prekinuto", "settings.context.autoCompaction.title": "Automatska kompresija", "settings.context.autoCompaction.description": "Automatski komprimiraj kontekst prije nego dostigne limit", "settings.context.compaction.title": "Kompresija", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index 9c3a40cf3fc9..d7d4d9525986 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -1140,6 +1140,9 @@ export const dict = { "settings.autoCleanup.runNow": "Kør oprydning nu", "settings.autoCleanup.runNow.confirm": "Slet udløbne sessioner permanent på tværs af alle projekter og alle Kilo-klienter på denne maskine?", + "settings.autoCleanup.stop": "Stop oprydning", + "settings.autoCleanup.progress.cancelling": "Stopper oprydning af sessioner...", + "settings.autoCleanup.lastRun.cancelled": "afbrudt", "settings.context.autoCompaction.title": "Automatisk komprimering", "settings.context.autoCompaction.description": "Komprimér automatisk kontekst, før den når grænsen", "settings.context.compaction.title": "Komprimering", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index c75fd2153c98..c1328921892a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -1165,6 +1165,9 @@ export const dict = { "settings.autoCleanup.runNow": "Bereinigung jetzt ausführen", "settings.autoCleanup.runNow.confirm": "Abgelaufene Sitzungen endgültig in allen Projekten und allen Kilo-Clients auf diesem Rechner löschen?", + "settings.autoCleanup.stop": "Bereinigung stoppen", + "settings.autoCleanup.progress.cancelling": "Sitzungsbereinigung wird gestoppt...", + "settings.autoCleanup.lastRun.cancelled": "abgebrochen", "settings.context.autoCompaction.title": "Automatische Komprimierung", "settings.context.autoCompaction.description": "Kontext automatisch komprimieren, bevor er das Limit erreicht", "settings.context.compaction.title": "Komprimierung", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index fb339ced6b3f..3709246cb264 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -1123,6 +1123,9 @@ export const dict = { "settings.autoCleanup.runNow": "Run Cleanup Now", "settings.autoCleanup.runNow.confirm": "Permanently delete expired sessions across all projects and every Kilo client on this machine?", + "settings.autoCleanup.stop": "Stop cleanup", + "settings.autoCleanup.progress.cancelling": "Stopping session cleanup...", + "settings.autoCleanup.lastRun.cancelled": "interrupted", "settings.context.autoCompaction.title": "Auto Compaction", "settings.context.autoCompaction.description": "Automatically compact context before it reaches the limit", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 4f3e135d9464..e996ec63901d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -1157,6 +1157,9 @@ export const dict = { "settings.autoCleanup.runNow": "Ejecutar limpieza ahora", "settings.autoCleanup.runNow.confirm": "¿Eliminar permanentemente las sesiones caducadas en todos los proyectos y en todos los clientes de Kilo de esta máquina?", + "settings.autoCleanup.stop": "Detener limpieza", + "settings.autoCleanup.progress.cancelling": "Deteniendo la limpieza de sesiones...", + "settings.autoCleanup.lastRun.cancelled": "interrumpida", "settings.context.autoCompaction.title": "Compactación automática", "settings.context.autoCompaction.description": "Compactar automáticamente el contexto antes de que alcance el límite", "settings.context.compaction.title": "Compactación", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts index c3b3da46f089..8a6c97cb0ba9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts @@ -1125,6 +1125,9 @@ export const dict = { "settings.autoCleanup.runNow": "اجرای پاکسازی الآن", "settings.autoCleanup.runNow.confirm": "حذف دائمی جلسه‌های منقضی‌شده در همهٔ پروژه‌ها و همهٔ سرویس‌گیرنده‌های Kilo روی این رایانه؟", + "settings.autoCleanup.stop": "توقف پاکسازی", + "settings.autoCleanup.progress.cancelling": "در حال توقف پاکسازی نشست‌ها...", + "settings.autoCleanup.lastRun.cancelled": "قطع‌شده", "settings.context.autoCompaction.title": "فشرده‌سازی خودکار", "settings.context.autoCompaction.description": "قبل از رسیدن به محدودیت، زمینه را به‌طور خودکار فشرده کنید", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index b4d9fe576c7e..e90f8b9387c2 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -1171,6 +1171,9 @@ export const dict = { "settings.autoCleanup.runNow": "Lancer le nettoyage maintenant", "settings.autoCleanup.runNow.confirm": "Supprimer définitivement les sessions expirées dans tous les projets et tous les clients Kilo de cette machine ?", + "settings.autoCleanup.stop": "Arrêter le nettoyage", + "settings.autoCleanup.progress.cancelling": "Arrêt du nettoyage des sessions...", + "settings.autoCleanup.lastRun.cancelled": "interrompu", "settings.context.autoCompaction.title": "Compaction automatique", "settings.context.autoCompaction.description": "Compacter automatiquement le contexte avant qu'il n'atteigne la limite", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index 35524325364c..bcf495960a99 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -1007,6 +1007,9 @@ export const dict = { "settings.autoCleanup.runNow": "Esegui pulizia ora", "settings.autoCleanup.runNow.confirm": "Eliminare definitivamente le sessioni scadute in tutti i progetti e in tutti i client Kilo di questo computer?", + "settings.autoCleanup.stop": "Interrompi pulizia", + "settings.autoCleanup.progress.cancelling": "Interruzione della pulizia delle sessioni...", + "settings.autoCleanup.lastRun.cancelled": "interrotta", "settings.context.autoCompaction.title": "Compattazione automatica", "settings.context.autoCompaction.description": "Compatta automaticamente il contesto quando è pieno", "settings.context.compaction.title": "Compattazione", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 91d918ec95b3..e15c35c7b537 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -1133,6 +1133,9 @@ export const dict = { "settings.autoCleanup.runNow": "今すぐクリーンアップを実行", "settings.autoCleanup.runNow.confirm": "このマシン上のすべてのプロジェクトとすべての Kilo クライアントにわたる、削除対象の古いセッションを完全に削除しますか?", + "settings.autoCleanup.stop": "クリーンアップを停止", + "settings.autoCleanup.progress.cancelling": "セッションのクリーンアップを停止しています...", + "settings.autoCleanup.lastRun.cancelled": "中断されました", "settings.context.autoCompaction.title": "自動圧縮", "settings.context.autoCompaction.description": "コンテキストが上限に達する前に自動的に圧縮", "settings.context.compaction.title": "圧縮", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 1ff96ea340d0..06483148d147 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -1121,6 +1121,9 @@ export const dict = { "settings.autoCleanup.runNow": "지금 정리 실행", "settings.autoCleanup.runNow.confirm": "이 컴퓨터의 모든 프로젝트와 모든 Kilo 클라이언트에 걸쳐 만료된 세션을 영구적으로 삭제할까요?", + "settings.autoCleanup.stop": "정리 중지", + "settings.autoCleanup.progress.cancelling": "세션 정리를 중지하는 중...", + "settings.autoCleanup.lastRun.cancelled": "중단됨", "settings.context.autoCompaction.title": "자동 압축", "settings.context.autoCompaction.description": "컨텍스트가 한도에 도달하기 전에 자동으로 압축", "settings.context.compaction.title": "압축", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index a6f51c75c42e..eec321240498 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -1111,6 +1111,9 @@ export const dict = { "settings.autoCleanup.runNow": "Opschoning nu uitvoeren", "settings.autoCleanup.runNow.confirm": "Verlopen sessies definitief verwijderen in alle projecten en alle Kilo-clients op deze machine?", + "settings.autoCleanup.stop": "Opschonen stoppen", + "settings.autoCleanup.progress.cancelling": "Sessie-opschoning wordt gestopt...", + "settings.autoCleanup.lastRun.cancelled": "afgebroken", "settings.context.autoCompaction.title": "Automatische Compactie", "settings.context.autoCompaction.description": "Context automatisch compacteren voordat deze de limiet bereikt", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index ce6453e17541..8f349f0359b0 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -1139,6 +1139,9 @@ export const dict = { "settings.autoCleanup.runNow": "Kjør opprydding nå", "settings.autoCleanup.runNow.confirm": "Slett utløpte økter permanent på tvers av alle prosjekter og alle Kilo-klienter på denne maskinen?", + "settings.autoCleanup.stop": "Stopp opprydding", + "settings.autoCleanup.progress.cancelling": "Stopper opprydding av økter...", + "settings.autoCleanup.lastRun.cancelled": "avbrutt", "settings.context.autoCompaction.title": "Automatisk komprimering", "settings.context.autoCompaction.description": "Komprimer automatisk kontekst før den når grensen", "settings.context.compaction.title": "Komprimering", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 7e30fab37fe9..fad5ac934611 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -1145,6 +1145,9 @@ export const dict = { "settings.autoCleanup.runNow": "Uruchom czyszczenie teraz", "settings.autoCleanup.runNow.confirm": "Trwale usunąć wygasłe sesje we wszystkich projektach i we wszystkich klientach Kilo na tym komputerze?", + "settings.autoCleanup.stop": "Zatrzymaj czyszczenie", + "settings.autoCleanup.progress.cancelling": "Zatrzymywanie czyszczenia sesji...", + "settings.autoCleanup.lastRun.cancelled": "przerwane", "settings.context.autoCompaction.title": "Automatyczna kompakcja", "settings.context.autoCompaction.description": "Automatycznie kompaktuj kontekst, zanim osiągnie limit", "settings.context.compaction.title": "Kompaktowanie", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index dba78b4b69e8..8e73de65ff25 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -1139,6 +1139,9 @@ export const dict = { "settings.autoCleanup.runNow": "Запустить очистку сейчас", "settings.autoCleanup.runNow.confirm": "Безвозвратно удалить устаревшие сессии во всех проектах и во всех клиентах Kilo на этом компьютере?", + "settings.autoCleanup.stop": "Остановить очистку", + "settings.autoCleanup.progress.cancelling": "Остановка очистки сессий...", + "settings.autoCleanup.lastRun.cancelled": "прервана", "settings.context.autoCompaction.title": "Автоматическое сжатие", "settings.context.autoCompaction.description": "Автоматически сжимать контекст до достижения лимита", "settings.context.compaction.title": "Сжатие", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 620684098753..4ccd1ca30297 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -1118,6 +1118,9 @@ export const dict = { "settings.autoCleanup.runNow": "รันการล้างเดี๋ยวนี้", "settings.autoCleanup.runNow.confirm": "ลบเซสชันที่หมดอายุอย่างถาวรทั่วทุกโปรเจกต์และไคลเอนต์ Kilo ทุกตัวบนเครื่องนี้หรือไม่?", + "settings.autoCleanup.stop": "หยุดการล้างข้อมูล", + "settings.autoCleanup.progress.cancelling": "กำลังหยุดการล้างเซสชัน...", + "settings.autoCleanup.lastRun.cancelled": "ถูกยกเลิก", "settings.context.autoCompaction.title": "การบีบอัดอัตโนมัติ", "settings.context.autoCompaction.description": "บีบอัดบริบทอัตโนมัติก่อนถึงขีดจำกัด", "settings.context.compaction.title": "การบีบอัด", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index da4f540c9f78..059ec5653360 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -1099,6 +1099,9 @@ export const dict = { "settings.autoCleanup.runNow": "Temizliği şimdi çalıştır", "settings.autoCleanup.runNow.confirm": "Süresi dolmuş oturumlar bu bilgisayardaki tüm projelerde ve tüm Kilo istemcilerinde kalıcı olarak silinsin mi?", + "settings.autoCleanup.stop": "Temizliği durdur", + "settings.autoCleanup.progress.cancelling": "Oturum temizliği durduruluyor...", + "settings.autoCleanup.lastRun.cancelled": "kesintiye uğradı", "settings.context.autoCompaction.title": "Otomatik Sıkıştırma", "settings.context.autoCompaction.description": "Bağlam sınıra ulaşmadan önce otomatik olarak sıkıştır", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index cab3a42d0fc2..60ea283d6d33 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -1099,6 +1099,9 @@ export const dict = { "settings.autoCleanup.runNow": "Запустити очищення зараз", "settings.autoCleanup.runNow.confirm": "Безповоротно видалити застарілі сесії в усіх проєктах і в усіх клієнтах Kilo на цьому комп'ютері?", + "settings.autoCleanup.stop": "Зупинити очищення", + "settings.autoCleanup.progress.cancelling": "Зупинка очищення сеансів...", + "settings.autoCleanup.lastRun.cancelled": "перервано", "settings.context.autoCompaction.title": "Автоматичне стиснення", "settings.context.autoCompaction.description": "Автоматично стискати контекст до досягнення ліміту", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index d24f7915165b..4774592c2508 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -1075,6 +1075,9 @@ export const dict = { "正在删除会话:已处理 {{processed}}/{{total}}(已删除 {{deleted}} 个,失败 {{failed}} 个)", "settings.autoCleanup.runNow": "立即运行清理", "settings.autoCleanup.runNow.confirm": "永久删除这台机器上所有项目和所有 Kilo 客户端中已过期的会话?", + "settings.autoCleanup.stop": "停止清理", + "settings.autoCleanup.progress.cancelling": "正在停止会话清理...", + "settings.autoCleanup.lastRun.cancelled": "已中断", "settings.context.autoCompaction.title": "自动压缩", "settings.context.autoCompaction.description": "在上下文达到限制前自动压缩", "settings.context.compaction.title": "压缩", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index 8473f71e29f7..1115ad66229f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -1037,6 +1037,9 @@ export const dict = { "正在刪除會話:已處理 {{processed}}/{{total}}(已刪除 {{deleted}} 個,失敗 {{failed}} 個)", "settings.autoCleanup.runNow": "立即執行清理", "settings.autoCleanup.runNow.confirm": "永久刪除這台機器上所有專案和所有 Kilo 用戶端中已過期的會話?", + "settings.autoCleanup.stop": "停止清理", + "settings.autoCleanup.progress.cancelling": "正在停止工作階段清理...", + "settings.autoCleanup.lastRun.cancelled": "已中斷", "settings.context.autoCompaction.title": "自動壓縮", "settings.context.autoCompaction.description": "在上下文達到限制前自動壓縮", "settings.context.compaction.title": "壓縮", diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index 48bf6e67ea75..9cc43e4138a4 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -812,6 +812,8 @@ export interface AutoCleanupLastResult { skippedActive: number failed: number durationMs: number + cancelled?: boolean + reclaimedBytes?: number } export interface AutoCleanupStateLoadedMessage { @@ -821,7 +823,7 @@ export interface AutoCleanupStateLoadedMessage { pending?: boolean error?: "status" | "timeout" | "run" progress?: { - phase: "scanning" | "deleting" + phase: "scanning" | "deleting" | "cancelling" total: number processed: number deleted: number diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index 26e25ec0c714..7a8e9bf9d1fa 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -532,6 +532,11 @@ export interface RunAutoCleanupNowMessage { requestID: string } +export interface StopAutoCleanupNowMessage { + type: "stopAutoCleanupNow" + requestID: string +} + export interface RequestThroughputSettingMessage { type: "requestThroughputSetting" } @@ -1712,6 +1717,7 @@ export type WebviewMessage = | RequestTimelineSettingMessage | RequestAutoCleanupStateMessage | RunAutoCleanupNowMessage + | StopAutoCleanupNowMessage | RequestThroughputSettingMessage | RequestAutoApprovalReasonSettingMessage | RequestWorkStyleMessage diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts b/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts index 50b7b691e371..9da73d10a67c 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts @@ -120,6 +120,8 @@ export const RetentionState = Schema.Struct({ skippedActive: Schema.Number, failed: Schema.Number, durationMs: Schema.Number, + cancelled: Schema.optional(Schema.Boolean), + reclaimedBytes: Schema.optional(Schema.Number), }) export const RetentionStatus = Schema.Struct({ @@ -130,7 +132,7 @@ export const RetentionStatus = Schema.Struct({ last: Schema.optional(RetentionState), progress: Schema.optional( Schema.Struct({ - phase: Schema.Literals(["scanning", "deleting"]), + phase: Schema.Literals(["scanning", "deleting", "cancelling"]), total: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), processed: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), deleted: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), @@ -170,6 +172,7 @@ export const KilocodePaths = { backgroundJobPromote: `${root}/background-jobs/:jobID/promote`, retentionStatus: `${root}/retention`, retentionRun: `${root}/retention/run`, + retentionCancel: `${root}/retention/cancel`, wakeups: `${root}/wakeups`, } as const @@ -306,7 +309,8 @@ export const KilocodeApi = HttpApi.make("kilocode") OpenApi.annotations({ identifier: "kilocode.marketplace.install", summary: "Install a marketplace item", - description: "Install a marketplace MCP server, agent, skill, or plugin into project or global Kilo config.", + description: + "Install a marketplace MCP server, agent, skill, or plugin into project or global Kilo config.", }), ), HttpApiEndpoint.post("marketplaceRemove", KilocodePaths.marketplaceRemove, { @@ -533,6 +537,20 @@ export const KilocodeApi = HttpApi.make("kilocode") "Run one machine-wide session retention pass. Does nothing unless the retention policy is enabled in kilo.json; `force` bypasses the minimum spacing between scheduled passes, never the enable check.", }), ), + HttpApiEndpoint.post("retentionCancel", KilocodePaths.retentionCancel, { + query: WorkspaceRoutingQuery, + success: described( + Schema.Struct({ requested: Schema.Boolean }), + "Retention cancel request outcome; false when no pass was running", + ), + }).annotateMerge( + OpenApi.annotations({ + identifier: "kilocode.retention.cancel", + summary: "Stop the active session retention pass", + description: + "Ask the machine-wide retention pass to stop before deleting more sessions. Already-deleted sessions stay deleted; the interrupted pass still records its partial result and honors the spacing window.", + }), + ), ) .annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts index c2d4f3f5d9de..092674796786 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts @@ -570,6 +570,10 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode" return { ...(yield* retentionActive()), last: outcome.result } }) + const retentionCancel = Effect.fn("KilocodeHttpApi.retentionCancel")(function* () { + return { requested: KiloSessionRetention.cancel() } + }) + return handlers .handle("resumeSession", resumeSession) .handle("drainSession", drainSession) @@ -607,5 +611,6 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode" .handle("wakeups", wakeups) .handle("retentionStatus", retentionStatus) .handle("retentionRun", retentionRun) + .handle("retentionCancel", retentionCancel) }), ) diff --git a/packages/opencode/src/kilocode/session/retention.ts b/packages/opencode/src/kilocode/session/retention.ts index c564c5a22e44..c37c6fe4008f 100644 --- a/packages/opencode/src/kilocode/session/retention.ts +++ b/packages/opencode/src/kilocode/session/retention.ts @@ -49,10 +49,16 @@ export namespace KiloSessionRetention { skippedActive: number failed: number durationMs: number + cancelled?: boolean + reclaimedBytes?: number } + /** Free pages must clear both floors before a pass pays for a full database rebuild. */ + export const VACUUM_MIN_FREE_BYTES = 16 * 1024 * 1024 + export const VACUUM_MIN_FREE_RATIO = 0.2 + export interface Progress { - phase: "scanning" | "deleting" + phase: "scanning" | "deleting" | "cancelling" total: number processed: number deleted: number @@ -71,6 +77,21 @@ export namespace KiloSessionRetention { } | undefined + let halting = false + + /** + * Ask the active pass to stop before removing more sessions. Cooperative: + * the pass checks between removals, keeps what it already deleted, and + * still records its partial result so the spacing gate holds. Returns false + * when no pass is running. + */ + export function cancel(): boolean { + if (!current) return false + halting = true + current.phase = "cancelling" + return true + } + // Sample only outstanding candidate IDs when polled, not the entire database // after each removal. This also observes children during a long root cascade. export const readProgress = Effect.fn("KiloSessionRetention.readProgress")(function* () { @@ -129,6 +150,12 @@ export namespace KiloSessionRetention { return typeof value === "number" && Number.isInteger(value) && value >= 1 ? value : fallback } + export function worthVacuuming(pageCount: number, freePages: number, pageSize: number): boolean { + if (!pageCount || !pageSize || freePages <= 0) return false + const free = freePages * pageSize + return free >= VACUUM_MIN_FREE_BYTES && free / (pageCount * pageSize) >= VACUUM_MIN_FREE_RATIO + } + export function policy(info: { retention?: { enabled?: boolean; maxAgeDays?: number } } | undefined): Policy { return { enabled: info?.retention?.enabled === true, @@ -217,6 +244,7 @@ export namespace KiloSessionRetention { const busy = new Set() let count = 0 for (const id of ids) { + if (halting) break // SQLite is synchronous. Let status/health requests run between batches. if (count++ % 32 === 0) yield* Effect.sleep("1 millis") const session = SessionID.make(id) @@ -243,6 +271,37 @@ export namespace KiloSessionRetention { return busy }) + /** + * Return freed pages to the OS after a pass. SQLite keeps deleted pages + * allocated for reuse, so a pass that empties most of the file still leaves + * its size on disk unchanged until a rebuild. Only worth the rebuild cost + * when the free tail is large in absolute terms and relative to the whole + * file, and never for in-memory databases. Best effort: a busy database + * only skips or delays the rebuild, it must never fail the pass. + */ + export const reclaim = Effect.fn("KiloSessionRetention.reclaim")(function* () { + const { db } = yield* Database.Service + const page = yield* db + .get<{ + page_size: number + page_count: number + freelist_count: number + file: string + }>("SELECT page_size, page_count, freelist_count, (SELECT file FROM pragma_database_list WHERE name = 'main') AS file FROM pragma_page_size, pragma_page_count, pragma_freelist_count") + .pipe(Effect.orDie) + if (!page?.file) return { vacuumed: false as const, reclaimedBytes: 0 } + if (!worthVacuuming(page.page_count, page.freelist_count, page.page_size)) + return { vacuumed: false as const, reclaimedBytes: 0 } + yield* db.run("VACUUM").pipe(Effect.orDie) + // The rebuild lands in the WAL first; truncate it so both files shrink. + yield* db.run("PRAGMA wal_checkpoint(TRUNCATE)").pipe(Effect.orDie) + const after = yield* db.get<{ page_count: number }>("PRAGMA page_count").pipe(Effect.orDie) + const reclaimedBytes = Math.max(0, page.page_count - (after?.page_count ?? page.page_count)) * page.page_size + if (reclaimedBytes > 0) + log.info("retention vacuum reclaimed database space", { reclaimedBytes, freePagesBefore: page.freelist_count }) + return { vacuumed: true as const, reclaimedBytes } + }) + const statePath = path.join(Global.Path.data, "retention", "state.json") export const readState = Effect.fn("KiloSessionRetention.readState")(function* () { @@ -263,6 +322,7 @@ export namespace KiloSessionRetention { export const run = Effect.fn("KiloSessionRetention.run")( function* (input: { force?: boolean } = {}) { const started = Date.now() + halting = false const progress = (current = { phase: "scanning" as Progress["phase"], total: 0, @@ -330,14 +390,17 @@ export namespace KiloSessionRetention { } if (final && progress.pending.has(id)) progress.failed.add(id) }) - for (const id of roots) yield* remove(SessionID.make(id), false) + for (const id of roots) { + if (halting) break + yield* remove(SessionID.make(id), false) + } // Children stored in another project are not covered by the parent's // cascade — sweep whatever expired rows are still present. NotFound here // means an earlier cascade already removed the row. Chunked because a // machine with retention off for a while can expire thousands at once. const expiredIds = [...progress.pending] const chunkSize = 500 - for (let start = 0; start < expiredIds.length; start += chunkSize) { + for (let start = 0; start < expiredIds.length && !halting; start += chunkSize) { const chunk = expiredIds.slice(start, start + chunkSize) const leftover = yield* db .select({ id: SessionTable.id }) @@ -346,21 +409,37 @@ export namespace KiloSessionRetention { .all() .pipe(Effect.orDie) for (const row of leftover) { + if (halting) break yield* remove(row.id, true) } } yield* readProgress() + const cancelled = halting + const back = yield* cancelled + ? Effect.succeed({ vacuumed: false as const, reclaimedBytes: 0 }) + : reclaim().pipe( + Effect.catchCause((cause) => + Effect.sync(() => { + log.error("retention reclaim failed", { cause }) + return { vacuumed: false as const, reclaimedBytes: 0 } + }), + ), + ) const result: State = { at: started, scanned: mapped.length, deleted: progress.total - progress.pending.size, skippedActive: skipped.length, - failed: progress.pending.size, + // A swept pass counts every outstanding row as failed; a cancelled + // pass only counts rows it actually tried and lost. + failed: cancelled ? progress.failed.size : progress.pending.size, durationMs: Date.now() - started, + ...(cancelled ? { cancelled: true } : {}), + ...(back.reclaimedBytes > 0 ? { reclaimedBytes: back.reclaimedBytes } : {}), } yield* writeState(result) - log.info("retention pass complete", { ...result }) + log.info(cancelled ? "retention pass cancelled" : "retention pass complete", { ...result }) return { ran: true as const, result } }, (effect, _input: { force?: boolean } = {}) => @@ -368,6 +447,7 @@ export namespace KiloSessionRetention { effect.pipe( Effect.ensuring( Effect.sync(() => { + halting = false current = undefined }), ), diff --git a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts index f273087cfac2..3eb900dab8ec 100644 --- a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts +++ b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts @@ -781,6 +781,17 @@ export const kiloScenarios: Scenario[] = [ check(typeof body.last.durationMs === "number", "run result should include a duration") } }), + http.protected + .post("/kilocode/retention/cancel", "kilocode.retention.cancel") + .mutating() + .at((ctx) => ({ + path: "/kilocode/retention/cancel", + headers: ctx.headers(), + })) + .json(200, (body) => { + object(body) + check(typeof body.requested === "boolean", "retention cancel should report whether a pass was running") + }), http.protected .post("/kilocode/background-jobs/{jobID}/cancel", "kilocode.backgroundJob.cancel") .at((ctx) => ({ diff --git a/packages/opencode/test/kilocode/session/retention-reclaim.test.ts b/packages/opencode/test/kilocode/session/retention-reclaim.test.ts new file mode 100644 index 000000000000..fb2948d91e83 --- /dev/null +++ b/packages/opencode/test/kilocode/session/retention-reclaim.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { statSync } from "node:fs" +import { Database } from "@opencode-ai/core/database/database" +import { KiloSessionRetention } from "../../../src/kilocode/session/retention" +import { tmpdir } from "../../fixture/fixture" + +const use = (effect: Effect.Effect, file: string) => + Effect.runPromise(Effect.provide(Database.layerFromPath(file))(Effect.scoped(effect))) + +describe("worthVacuuming", () => { + test("requires free space large enough in bytes and relative to the file", () => { + expect(KiloSessionRetention.worthVacuuming(93_861, 0, 4096)).toBe(false) + // 8MB free: below the absolute floor + expect(KiloSessionRetention.worthVacuuming(10_000, 2_048, 4096)).toBe(false) + // 25MB free of a 2GB file: below the ratio floor + expect(KiloSessionRetention.worthVacuuming(512_000, 6_144, 4096)).toBe(false) + // 346MB free of a 367MB file: worth the rebuild + expect(KiloSessionRetention.worthVacuuming(93_861, 84_639, 4096)).toBe(true) + }) +}) + +describe("reclaim", () => { + test("skips while nothing is free, vacuums once freed pages cross the thresholds", async () => { + await using tmp = await tmpdir() + const file = `${tmp.path}/reclaim.db` + await use( + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db.run("CREATE TABLE junk (payload BLOB)").pipe(Effect.orDie) + yield* db.run("INSERT INTO junk (payload) VALUES (zeroblob(24 * 1024 * 1024))").pipe(Effect.orDie) + const before = statSync(file).size + expect(yield* KiloSessionRetention.reclaim()).toEqual({ vacuumed: false, reclaimedBytes: 0 }) + yield* db.run("DROP TABLE junk").pipe(Effect.orDie) + const freed = yield* KiloSessionRetention.reclaim() + expect(freed.vacuumed).toBe(true) + expect(freed.reclaimedBytes).toBeGreaterThan(KiloSessionRetention.VACUUM_MIN_FREE_BYTES) + expect(statSync(file).size).toBeLessThan(before - 16 * 1024 * 1024) + expect(yield* KiloSessionRetention.reclaim()).toEqual({ vacuumed: false, reclaimedBytes: 0 }) + }), + file, + ) + }) + + test("never vacuums an in-memory database", async () => { + await use( + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db.run("CREATE TABLE junk (payload BLOB)").pipe(Effect.orDie) + yield* db.run("INSERT INTO junk (payload) VALUES (zeroblob(24 * 1024 * 1024))").pipe(Effect.orDie) + yield* db.run("DROP TABLE junk").pipe(Effect.orDie) + expect(yield* KiloSessionRetention.reclaim()).toEqual({ vacuumed: false, reclaimedBytes: 0 }) + }), + ":memory:", + ) + }) +}) diff --git a/packages/opencode/test/kilocode/session/retention.test.ts b/packages/opencode/test/kilocode/session/retention.test.ts index c9517dd768df..deb131d8bc94 100644 --- a/packages/opencode/test/kilocode/session/retention.test.ts +++ b/packages/opencode/test/kilocode/session/retention.test.ts @@ -42,6 +42,32 @@ it("retention HTTP status accepts idle and individual-session progress", () => { expect(decode({ policy })).toEqual({ policy }) const progress = { phase: "deleting" as const, total: 10, processed: 4, deleted: 3, failed: 1, skippedActive: 2 } expect(decode({ policy, progress })).toEqual({ policy, progress }) + const halting = { ...progress, phase: "cancelling" as const } + expect(decode({ policy, progress: halting })).toEqual({ policy, progress: halting }) + expect( + decode({ + policy, + last: { at: 1, scanned: 2, deleted: 1, skippedActive: 0, failed: 0, durationMs: 3, reclaimedBytes: 4 }, + }), + ).toEqual({ + policy, + last: { at: 1, scanned: 2, deleted: 1, skippedActive: 0, failed: 0, durationMs: 3, reclaimedBytes: 4 }, + }) + expect( + decode({ + policy, + last: { at: 1, scanned: 2, deleted: 1, skippedActive: 0, failed: 0, durationMs: 3, cancelled: true }, + }), + ).toEqual({ + policy, + last: { at: 1, scanned: 2, deleted: 1, skippedActive: 0, failed: 0, durationMs: 3, cancelled: true }, + }) + expect( + decode({ policy, last: { at: 1, scanned: 2, deleted: 1, skippedActive: 0, failed: 0, durationMs: 3 } }), + ).toEqual({ + policy, + last: { at: 1, scanned: 2, deleted: 1, skippedActive: 0, failed: 0, durationMs: 3 }, + }) expect(() => decode({ policy, progress: { ...progress, deleted: -1 } })).toThrow() }) @@ -557,3 +583,88 @@ dbIt.live("run clears deleting progress after interruption without replacing the expect(yield* KiloSessionRetention.readState()).toEqual(previous) }), ) + +dbIt.live("cancel with no active pass reports false", () => + Effect.gen(function* () { + expect(KiloSessionRetention.cancel()).toBe(false) + }), +) + +dbIt.live("cancel during deleting keeps completed removals, stops the rest, records a partial result", () => + Effect.gen(function* () { + const { db } = yield* Database.Service + const first = `ses_retention_stop_a_${crypto.randomUUID()}` + const second = `ses_retention_stop_b_${crypto.randomUUID()}` + const updated = Date.now() - 40 * KiloSessionRetention.DAY_MS + yield* seed({ + directory: "/tmp/retention-stop", + rows: [ + { id: first, updated }, + { id: second, updated }, + ], + }) + const reached = yield* Deferred.make() + const resume = yield* Deferred.make() + let removed = 0 + const sessions = Layer.mock(Session.Service, { + remove: (id) => + Effect.gen(function* () { + if (++removed === 1) { + yield* Deferred.succeed(reached, undefined) + yield* Deferred.await(resume) + } + yield* db.delete(SessionTable).where(eq(SessionTable.id, id)).run().pipe(Effect.orDie) + }), + }) + const fiber = yield* KiloSessionRetention.run({ force: true }).pipe( + Effect.provide(Layer.merge(enabled, sessions)), + Effect.forkChild, + ) + yield* awaitWithTimeout(Deferred.await(reached), "first removal did not start") + expect(KiloSessionRetention.cancel()).toBe(true) + expect((yield* KiloSessionRetention.readProgress())?.phase).toBe("cancelling") + yield* Deferred.succeed(resume, undefined) + const outcome = yield* Fiber.join(fiber) + expect(outcome.ran && outcome.result).toMatchObject({ scanned: 2, deleted: 1, failed: 0, cancelled: true }) + expect(yield* KiloSessionRetention.readState()).toMatchObject({ deleted: 1, cancelled: true }) + expect(yield* KiloSessionRetention.readProgress()).toBeUndefined() + const rows = yield* db + .select({ id: SessionTable.id }) + .from(SessionTable) + .where(inArray(SessionTable.id, [SessionID.make(first), SessionID.make(second)])) + .all() + .pipe(Effect.orDie) + expect(rows.map((row) => row.id)).toEqual([SessionID.make(second)]) + expect(KiloSessionRetention.cancel()).toBe(false) + }), +) + +dbIt.live("cancel during scanning aborts before any deletion", () => + Effect.gen(function* () { + const id = `ses_retention_stop_scan_${crypto.randomUUID()}` + yield* seed({ + directory: "/tmp/retention-stop-scan", + rows: [{ id, updated: Date.now() - 40 * KiloSessionRetention.DAY_MS }], + }) + const gate = yield* Deferred.make() + const entered = yield* Deferred.make() + const config = Layer.mock(Config.Service, { + get: () => + Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(gate)), + Effect.as({ retention: { enabled: true, maxAgeDays: 30 } }), + ), + }) + const sessions = Layer.mock(Session.Service, { remove: () => Effect.die("no session should be removed") }) + const fiber = yield* KiloSessionRetention.run({ force: true }).pipe( + Effect.provide(Layer.merge(config, sessions)), + Effect.forkChild, + ) + yield* awaitWithTimeout(Deferred.await(entered), "scan did not start") + expect(KiloSessionRetention.cancel()).toBe(true) + yield* Deferred.succeed(gate, undefined) + const outcome = yield* Fiber.join(fiber) + expect(outcome.ran && outcome.result).toMatchObject({ deleted: 0, failed: 0, cancelled: true }) + expect(yield* KiloSessionRetention.readProgress()).toBeUndefined() + }), +) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 148efb92764c..628bf3521c37 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -211,6 +211,8 @@ import type { KilocodeResetSessionBoardResponses, KilocodeResumeSessionErrors, KilocodeResumeSessionResponses, + KilocodeRetentionCancelErrors, + KilocodeRetentionCancelResponses, KilocodeRetentionRunErrors, KilocodeRetentionRunResponses, KilocodeRetentionStatusErrors, @@ -7924,6 +7926,40 @@ export class Retention extends HeyApiClient { }, }) } + + /** + * Stop the active session retention pass + * + * Ask the machine-wide retention pass to stop before deleting more sessions. Already-deleted sessions stay deleted; the interrupted pass still records its partial result and honors the spacing window. + */ + public cancel( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + KilocodeRetentionCancelResponses, + KilocodeRetentionCancelErrors, + ThrowOnError + >({ + url: "/kilocode/retention/cancel", + ...options, + ...params, + }) + } } export class Migrate extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index a69cd87e9bb8..d227979a7d31 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -17734,9 +17734,11 @@ export type KilocodeRetentionStatusResponses = { skippedActive: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" failed: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" durationMs: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + cancelled?: boolean + reclaimedBytes?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } progress?: { - phase: "scanning" | "deleting" + phase: "scanning" | "deleting" | "cancelling" total: number processed: number deleted: number @@ -17785,9 +17787,11 @@ export type KilocodeRetentionRunResponses = { skippedActive: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" failed: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" durationMs: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + cancelled?: boolean + reclaimedBytes?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } progress?: { - phase: "scanning" | "deleting" + phase: "scanning" | "deleting" | "cancelling" total: number processed: number deleted: number @@ -17799,6 +17803,36 @@ export type KilocodeRetentionRunResponses = { export type KilocodeRetentionRunResponse = KilocodeRetentionRunResponses[keyof KilocodeRetentionRunResponses] +export type KilocodeRetentionCancelData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/kilocode/retention/cancel" +} + +export type KilocodeRetentionCancelErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type KilocodeRetentionCancelError = KilocodeRetentionCancelErrors[keyof KilocodeRetentionCancelErrors] + +export type KilocodeRetentionCancelResponses = { + /** + * Retention cancel request outcome; false when no pass was running + */ + 200: { + requested: boolean + } +} + +export type KilocodeRetentionCancelResponse = KilocodeRetentionCancelResponses[keyof KilocodeRetentionCancelResponses] + export type AnacondaDesktopStatusData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 343a30d65408..23b184eac9c3 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -17112,6 +17112,32 @@ "enum": ["Infinity", "-Infinity", "NaN"] } ] + }, + "cancelled": { + "type": "boolean" + }, + "reclaimedBytes": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] } }, "required": ["at", "scanned", "deleted", "skippedActive", "failed", "durationMs"], @@ -17122,7 +17148,7 @@ "properties": { "phase": { "type": "string", - "enum": ["scanning", "deleting"] + "enum": ["scanning", "deleting", "cancelling"] }, "total": { "type": "integer", @@ -17380,6 +17406,32 @@ "enum": ["Infinity", "-Infinity", "NaN"] } ] + }, + "cancelled": { + "type": "boolean" + }, + "reclaimedBytes": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] } }, "required": ["at", "scanned", "deleted", "skippedActive", "failed", "durationMs"], @@ -17390,7 +17442,7 @@ "properties": { "phase": { "type": "string", - "enum": ["scanning", "deleting"] + "enum": ["scanning", "deleting", "cancelling"] }, "total": { "type": "integer", @@ -17460,6 +17512,68 @@ ] } }, + "/kilocode/retention/cancel": { + "post": { + "tags": ["kilocode"], + "operationId": "kilocode.retention.cancel", + "parameters": [ + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "responses": { + "200": { + "description": "Retention cancel request outcome; false when no pass was running", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "requested": { + "type": "boolean" + } + }, + "required": ["requested"], + "additionalProperties": false, + "description": "Retention cancel request outcome; false when no pass was running" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Ask the machine-wide retention pass to stop before deleting more sessions. Already-deleted sessions stay deleted; the interrupted pass still records its partial result and honors the spacing window.", + "summary": "Stop the active session retention pass", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.kilocode.retention.cancel({\n ...\n})" + } + ] + } + }, "/kilocode/anaconda-desktop/status": { "get": { "tags": ["anaconda-desktop"], From 9f58f3a77bb29785602b2bc3aa7b0fd3da22d42e Mon Sep 17 00:00:00 2001 From: vkeerthivikram Date: Thu, 24 Sep 2026 18:48:43 +0530 Subject: [PATCH 2/3] fix: address review feedback on cleanup cancel A cancelled pass now counts children removed by earlier root cascades (progress sampling also runs while cancelling), counts root removals that failed before the cancel, and keeps its cancelling phase instead of flipping back to deleting. Cancel is sealed once the deletion sweep finishes so it can not report success during the vacuum, and the vacuum no longer swallows interrupts. The settings poll can stop a pass while its run reply is still outstanding and forgets a stop once no progress remains. --- .../tests/unit/cleanup-poll.test.ts | 16 ++++ .../src/components/settings/cleanup.ts | 4 +- .../src/kilocode/session/retention.ts | 42 +++++++---- .../test/kilocode/session/retention.test.ts | 75 +++++++++++++++++++ 4 files changed, 123 insertions(+), 14 deletions(-) diff --git a/packages/kilo-vscode/tests/unit/cleanup-poll.test.ts b/packages/kilo-vscode/tests/unit/cleanup-poll.test.ts index b868fad4cd64..9bd07a9f821a 100644 --- a/packages/kilo-vscode/tests/unit/cleanup-poll.test.ts +++ b/packages/kilo-vscode/tests/unit/cleanup-poll.test.ts @@ -170,6 +170,22 @@ describe("session cleanup polling", () => { progress: { ...progress, phase: "cancelling" }, }) expect(f.states.at(-1)?.message.progress?.phase).toBe("cancelling") + }) + + test("stop works while the run reply is still outstanding and resets once a pass ends", () => { + const f = fixture() + f.poll.execute() + f.poll.stop() + f.poll.stop() + expect(f.sent.filter((message) => message.type === "stopAutoCleanupNow")).toHaveLength(1) + const halt = f.sent.at(1) + if (!halt || !("requestID" in halt)) throw new Error("Missing stop request") + // The stop reply arrives without progress: nothing is stoppable anymore. + f.poll.receive({ type: "autoCleanupStateLoaded", requestID: halt.requestID, last: null }) + // The run completes afterwards without progress either. + const run = f.sent.at(0) + if (!run || !("requestID" in run)) throw new Error("Missing run request") + f.poll.receive({ type: "autoCleanupStateLoaded", requestID: run.requestID, last: null }) // A later pass can be stopped again. f.poll.start() f.reply(2, { pending: true, progress }) diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/cleanup.ts b/packages/kilo-vscode/webview-ui/src/components/settings/cleanup.ts index 3220ca70b322..e7df25995f5f 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/cleanup.ts +++ b/packages/kilo-vscode/webview-ui/src/components/settings/cleanup.ts @@ -31,7 +31,7 @@ export class CleanupPoll { } stop(): void { - if (this.disposed || this.halt || !this.state?.progress) return + if (this.disposed || this.halt || !(this.run || this.state?.progress)) return this.halt = crypto.randomUUID() this.post({ type: "stopAutoCleanupNow", requestID: this.halt }) } @@ -52,6 +52,8 @@ export class CleanupPoll { } } else if (message.requestID !== this.halt) return if (message.requestID === this.halt) this.halt = undefined + // A pass that no longer reports progress cannot be stopped anymore. + if (!message.progress) this.halt = undefined const unavailable = message.error === "status" || message.error === "timeout" this.state = { ...message, diff --git a/packages/opencode/src/kilocode/session/retention.ts b/packages/opencode/src/kilocode/session/retention.ts index c37c6fe4008f..449a6e7b7a3f 100644 --- a/packages/opencode/src/kilocode/session/retention.ts +++ b/packages/opencode/src/kilocode/session/retention.ts @@ -78,15 +78,17 @@ export namespace KiloSessionRetention { | undefined let halting = false + // Set once the deletion sweep is over; cancel only makes sense before that. + let sealed = false /** * Ask the active pass to stop before removing more sessions. Cooperative: * the pass checks between removals, keeps what it already deleted, and * still records its partial result so the spacing gate holds. Returns false - * when no pass is running. + * when no pass is running or its deletions already finished. */ export function cancel(): boolean { - if (!current) return false + if (!current || sealed) return false halting = true current.phase = "cancelling" return true @@ -97,7 +99,9 @@ export namespace KiloSessionRetention { export const readProgress = Effect.fn("KiloSessionRetention.readProgress")(function* () { const state = current if (!state) return undefined - if (state.phase === "deleting") { + // Refresh during cancelling too, so a cancelled pass's final counts + // include children removed by earlier root cascades. + if (state.phase === "deleting" || state.phase === "cancelling") { const { db } = yield* Database.Service const ids = [...state.pending] for (let start = 0; start < ids.length; start += 500) { @@ -287,7 +291,9 @@ export namespace KiloSessionRetention { page_count: number freelist_count: number file: string - }>("SELECT page_size, page_count, freelist_count, (SELECT file FROM pragma_database_list WHERE name = 'main') AS file FROM pragma_page_size, pragma_page_count, pragma_freelist_count") + }>( + "SELECT page_size, page_count, freelist_count, (SELECT file FROM pragma_database_list WHERE name = 'main') AS file FROM pragma_page_size, pragma_page_count, pragma_freelist_count", + ) .pipe(Effect.orDie) if (!page?.file) return { vacuumed: false as const, reclaimedBytes: 0 } if (!worthVacuuming(page.page_count, page.freelist_count, page.page_size)) @@ -323,6 +329,7 @@ export namespace KiloSessionRetention { function* (input: { force?: boolean } = {}) { const started = Date.now() halting = false + sealed = false const progress = (current = { phase: "scanning" as Progress["phase"], total: 0, @@ -364,10 +371,12 @@ export namespace KiloSessionRetention { progress.total = expired.size progress.pending = new Set([...expired].map((id) => SessionID.make(id))) progress.skippedActive = skipped.length - progress.phase = "deleting" + // A cancel that landed during scanning keeps its phase; the loops + // below break before removing anything. + if (!halting) progress.phase = "deleting" const sessions = yield* Session.Service - const remove = Effect.fn("KiloSessionRetention.remove")(function* (id: SessionID, final: boolean) { + const remove = Effect.fn("KiloSessionRetention.remove")(function* (id: SessionID) { yield* sessions.remove(id).pipe( Effect.catchCause((cause) => { if (Cause.hasInterrupts(cause)) return Effect.interrupt @@ -388,11 +397,14 @@ export namespace KiloSessionRetention { progress.failed.delete(id) return } - if (final && progress.pending.has(id)) progress.failed.add(id) + // Count the loss even on a non-final attempt: the leftover sweep + // retries it and clears the mark on success, but a cancelled pass + // never retries, so the root failure must already be recorded. + if (progress.pending.has(id)) progress.failed.add(id) }) for (const id of roots) { if (halting) break - yield* remove(SessionID.make(id), false) + yield* remove(SessionID.make(id)) } // Children stored in another project are not covered by the parent's // cascade — sweep whatever expired rows are still present. NotFound here @@ -410,9 +422,10 @@ export namespace KiloSessionRetention { .pipe(Effect.orDie) for (const row of leftover) { if (halting) break - yield* remove(row.id, true) + yield* remove(row.id) } } + sealed = true yield* readProgress() const cancelled = halting @@ -420,10 +433,12 @@ export namespace KiloSessionRetention { ? Effect.succeed({ vacuumed: false as const, reclaimedBytes: 0 }) : reclaim().pipe( Effect.catchCause((cause) => - Effect.sync(() => { - log.error("retention reclaim failed", { cause }) - return { vacuumed: false as const, reclaimedBytes: 0 } - }), + Cause.hasInterrupts(cause) + ? Effect.interrupt + : Effect.sync(() => { + log.error("retention reclaim failed", { cause }) + return { vacuumed: false as const, reclaimedBytes: 0 } + }), ), ) const result: State = { @@ -448,6 +463,7 @@ export namespace KiloSessionRetention { Effect.ensuring( Effect.sync(() => { halting = false + sealed = false current = undefined }), ), diff --git a/packages/opencode/test/kilocode/session/retention.test.ts b/packages/opencode/test/kilocode/session/retention.test.ts index deb131d8bc94..c9b08daeb47d 100644 --- a/packages/opencode/test/kilocode/session/retention.test.ts +++ b/packages/opencode/test/kilocode/session/retention.test.ts @@ -639,6 +639,81 @@ dbIt.live("cancel during deleting keeps completed removals, stops the rest, reco }), ) +dbIt.live("cancelled pass still counts children removed by earlier root cascades", () => + Effect.gen(function* () { + const { db } = yield* Database.Service + const parent = `ses_retention_cascade_p_${crypto.randomUUID()}` + const child = `ses_retention_cascade_c_${crypto.randomUUID()}` + const updated = Date.now() - 40 * KiloSessionRetention.DAY_MS + yield* seed({ + directory: "/tmp/retention-cascade", + rows: [ + { id: parent, updated }, + { id: child, updated, parent }, + ], + }) + const reached = yield* Deferred.make() + const resume = yield* Deferred.make() + let removed = 0 + const sessions = Layer.mock(Session.Service, { + remove: (id) => + Effect.gen(function* () { + if (++removed === 1) { + yield* Deferred.succeed(reached, undefined) + yield* Deferred.await(resume) + } + // Removing the parent cascades the child, as the real service does. + yield* db + .delete(SessionTable) + .where(inArray(SessionTable.id, [id, SessionID.make(child)])) + .run() + .pipe(Effect.orDie) + }), + }) + const fiber = yield* KiloSessionRetention.run({ force: true }).pipe( + Effect.provide(Layer.merge(enabled, sessions)), + Effect.forkChild, + ) + yield* awaitWithTimeout(Deferred.await(reached), "root removal did not start") + expect(KiloSessionRetention.cancel()).toBe(true) + yield* Deferred.succeed(resume, undefined) + const outcome = yield* Fiber.join(fiber) + expect(outcome.ran && outcome.result).toMatchObject({ scanned: 2, deleted: 2, failed: 0, cancelled: true }) + }), +) + +dbIt.live("cancelled pass counts a root removal that failed before the cancel", () => + Effect.gen(function* () { + const id = `ses_retention_rootfail_${crypto.randomUUID()}` + yield* seed({ + directory: "/tmp/retention-rootfail", + rows: [{ id, updated: Date.now() - 40 * KiloSessionRetention.DAY_MS }], + }) + const reached = yield* Deferred.make() + const resume = yield* Deferred.make() + let removed = 0 + const sessions = Layer.mock(Session.Service, { + remove: () => + Effect.gen(function* () { + if (++removed === 1) { + yield* Deferred.succeed(reached, undefined) + yield* Deferred.await(resume) + } + // Failed removal: the row stays in place. + }), + }) + const fiber = yield* KiloSessionRetention.run({ force: true }).pipe( + Effect.provide(Layer.merge(enabled, sessions)), + Effect.forkChild, + ) + yield* awaitWithTimeout(Deferred.await(reached), "root removal did not start") + expect(KiloSessionRetention.cancel()).toBe(true) + yield* Deferred.succeed(resume, undefined) + const outcome = yield* Fiber.join(fiber) + expect(outcome.ran && outcome.result).toMatchObject({ deleted: 0, failed: 1, cancelled: true }) + }), +) + dbIt.live("cancel during scanning aborts before any deletion", () => Effect.gen(function* () { const id = `ses_retention_stop_scan_${crypto.randomUUID()}` From 3401413069b7b859175274a7580e07fa188d653e Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 25 Sep 2026 09:40:32 +0200 Subject: [PATCH 3/3] fix(vscode): preserve session cleanup cancellation result --- packages/kilo-vscode/src/KiloProvider.ts | 5 +-- .../tests/unit/cleanup-stop.test.ts | 44 +++++++++++++++++++ .../test/kilocode/session/retention.test.ts | 35 +++++++++++++++ 3 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 packages/kilo-vscode/tests/unit/cleanup-stop.test.ts diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 099167f3cc2e..9b4895d0a2ac 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -3555,10 +3555,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } if (message.type === "stopAutoCleanupNow") { const service = this.autoCleanup() - const requested = await service?.cancel().then( - () => true, - () => false, - ) + const requested = await service?.cancel().catch(() => false) const status = await service?.status().catch(() => null) this.postMessage({ type: "autoCleanupStateLoaded", diff --git a/packages/kilo-vscode/tests/unit/cleanup-stop.test.ts b/packages/kilo-vscode/tests/unit/cleanup-stop.test.ts new file mode 100644 index 000000000000..d990a9a991cb --- /dev/null +++ b/packages/kilo-vscode/tests/unit/cleanup-stop.test.ts @@ -0,0 +1,44 @@ +import { expect, test } from "bun:test" +import path from "node:path" +import { Project } from "ts-morph" +import type { AutoCleanupStateLoadedMessage } from "../../webview-ui/src/types/messages" + +// Exercise the actual handler without loading unrelated VS Code provider dependencies. +const source = new Project().addSourceFileAtPath(path.join(import.meta.dir, "../../src/KiloProvider.ts")) +const method = source.getClassOrThrow("KiloProvider").getMethodOrThrow("handleAutoCleanupMessage").getText() +const code = new Bun.Transpiler({ loader: "ts" }).transformSync(`class Provider { ${method} }`) +const handler = new Function(`${code}; return Provider.prototype.handleAutoCleanupMessage`)() + +test.each([ + { requested: false, available: false, error: "run" }, + { requested: true, available: false, error: undefined }, + { requested: false, available: true, error: undefined }, + { requested: true, available: true, error: undefined }, +])("stop result $requested with status available $available", async ({ requested, available, error }) => { + const replies: AutoCleanupStateLoadedMessage[] = [] + const service = { + cancel: async () => requested, + status: async () => { + if (!available) throw new Error("Status unavailable") + return { last: null } + }, + lastResult: () => null, + running: false, + } + expect( + await handler.call( + { autoCleanup: () => service, postMessage: (message: AutoCleanupStateLoadedMessage) => replies.push(message) }, + { type: "stopAutoCleanupNow", requestID: "stop" }, + ), + ).toBe(true) + expect(replies).toEqual([ + { + type: "autoCleanupStateLoaded", + requestID: "stop", + last: null, + progress: undefined, + pending: false, + ...(error ? { error } : {}), + }, + ]) +}) diff --git a/packages/opencode/test/kilocode/session/retention.test.ts b/packages/opencode/test/kilocode/session/retention.test.ts index c9b08daeb47d..f898077acdd4 100644 --- a/packages/opencode/test/kilocode/session/retention.test.ts +++ b/packages/opencode/test/kilocode/session/retention.test.ts @@ -338,6 +338,41 @@ dbIt.live("run skips history queries without age candidates and includes scannin }), ) +runIt.live("90-day retention preserves younger sessions and parents with fresh or busy descendants", () => + Effect.gen(function* () { + expect(Database.path()).toBe(":memory:") + const { db } = yield* Database.Service + const now = Date.now() + const ids = Array.from({ length: 7 }, () => SessionID.make(`ses_retention_${crypto.randomUUID()}`)) + const [young, recent, parent, child, ancestor, busy, expired] = ids + yield* seed({ + directory: "/tmp/retention-90-days", + rows: [ + { id: young, updated: now - 60 * KiloSessionRetention.DAY_MS }, + { id: recent, updated: now - 89 * KiloSessionRetention.DAY_MS }, + { id: parent, updated: now - 120 * KiloSessionRetention.DAY_MS }, + { id: child, parent, updated: now - 89 * KiloSessionRetention.DAY_MS }, + { id: ancestor, updated: now - 120 * KiloSessionRetention.DAY_MS }, + { + id: busy, + parent: ancestor, + updated: now - 100 * KiloSessionRetention.DAY_MS, + message: now - 100 * KiloSessionRetention.DAY_MS, + part: now, + }, + { id: expired, updated: now - 91 * KiloSessionRetention.DAY_MS }, + ], + }) + const config = Layer.mock(Config.Service, { + get: () => Effect.succeed({ retention: { enabled: true, maxAgeDays: 90 } }), + }) + const outcome = yield* KiloSessionRetention.run({ force: true }).pipe(Effect.provide(config)) + expect(outcome.ran && outcome.result).toMatchObject({ scanned: 7, deleted: 1, failed: 0, skippedActive: 2 }) + const rows = yield* db.select({ id: SessionTable.id }).from(SessionTable).all().pipe(Effect.orDie) + expect(rows.map((row) => row.id).sort()).toEqual(ids.filter((id) => id !== expired).sort()) + }), +) + runIt.live("candidate probes preserve cross-project descendant and busy-parent selection", () => Effect.gen(function* () { const { db } = yield* Database.Service