From a0f356899a3d0ecda69143fc3a11bdb1b2cbd946 Mon Sep 17 00:00:00 2001 From: guazi04 Date: Thu, 5 Mar 2026 21:01:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(session):=20add=20lifecycle=20management?= =?UTF-8?q?=20=E2=80=94=20storage=20reclamation,=20CLI=20commands,=20VACUU?= =?UTF-8?q?M=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clear tool output/attachments after compaction to reclaim storage (conservative mode, configurable via compaction.reclaim) - Add retroactive reclamation pass for previously-pruned parts - Expose session archive/unarchive in CLI - Add `session stats` command showing DB size and session/message/part counts - Add `session prune` command with --older-than, --children, --vacuum, --dry-run - Add Database.vacuum() and Database.checkpoint() for disk space reclamation - Fix cross-project child session cascade deletion via project-agnostic childrenAll() Closes #16101 --- packages/opencode/src/cli/cmd/session.ts | 239 +++++++++++++++++++- packages/opencode/src/config/config.ts | 1 + packages/opencode/src/session/compaction.ts | 24 +- packages/opencode/src/session/index.ts | 13 +- packages/opencode/src/storage/db.ts | 13 ++ 5 files changed, 284 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/cli/cmd/session.ts b/packages/opencode/src/cli/cmd/session.ts index 7fb5fda97b9..5778f2cf866 100644 --- a/packages/opencode/src/cli/cmd/session.ts +++ b/packages/opencode/src/cli/cmd/session.ts @@ -9,6 +9,8 @@ import { Filesystem } from "../../util/filesystem" import { Process } from "../../util/process" import { EOL } from "os" import path from "path" +import { Database, sql, eq, or, lt, isNull, not, and, desc } from "../../storage/db" +import { SessionTable } from "../../session/session.sql" function pagerCmd(): string[] { const lessOptions = ["-R", "-S"] @@ -40,7 +42,15 @@ function pagerCmd(): string[] { export const SessionCommand = cmd({ command: "session", describe: "manage sessions", - builder: (yargs: Argv) => yargs.command(SessionListCommand).command(SessionDeleteCommand).demandCommand(), + builder: (yargs: Argv) => + yargs + .command(SessionListCommand) + .command(SessionDeleteCommand) + .command(SessionArchiveCommand) + .command(SessionUnarchiveCommand) + .command(SessionStatsCommand) + .command(SessionPruneCommand) + .demandCommand(), async handler() {}, }) @@ -154,3 +164,230 @@ function formatSessionJSON(sessions: Session.Info[]): string { })) return JSON.stringify(jsonData, null, 2) } + +export const SessionArchiveCommand = cmd({ + command: "archive ", + describe: "archive a session", + builder: (yargs: Argv) => { + return yargs.positional("sessionID", { + describe: "session ID to archive", + type: "string", + demandOption: true, + }) + }, + handler: async (args) => { + await bootstrap(process.cwd(), async () => { + try { + await Session.get(args.sessionID) + } catch { + UI.error(`Session not found: ${args.sessionID}`) + process.exit(1) + } + await Session.setArchived({ sessionID: args.sessionID, time: Date.now() }) + UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} archived` + UI.Style.TEXT_NORMAL) + }) + }, +}) + +export const SessionUnarchiveCommand = cmd({ + command: "unarchive ", + describe: "unarchive a session", + builder: (yargs: Argv) => { + return yargs.positional("sessionID", { + describe: "session ID to unarchive", + type: "string", + demandOption: true, + }) + }, + handler: async (args) => { + await bootstrap(process.cwd(), async () => { + try { + await Session.get(args.sessionID) + } catch { + UI.error(`Session not found: ${args.sessionID}`) + process.exit(1) + } + await Session.setArchived({ sessionID: args.sessionID, time: undefined }) + UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} unarchived` + UI.Style.TEXT_NORMAL) + }) + }, +}) + +function formatSize(bytes: number): string { + if (bytes >= 1_073_741_824) return (bytes / 1_073_741_824).toFixed(1) + " GB" + if (bytes >= 1_048_576) return (bytes / 1_048_576).toFixed(1) + " MB" + if (bytes >= 1024) return (bytes / 1024).toFixed(1) + " KB" + return bytes + " B" +} + +export const SessionStatsCommand = cmd({ + command: "stats", + describe: "show session storage statistics", + builder: (yargs: Argv) => yargs, + handler: async () => { + await bootstrap(process.cwd(), async () => { + const size = Number(Filesystem.stat(Database.Path)?.size ?? 0) + const wal = Number(Filesystem.stat(Database.Path + "-wal")?.size ?? 0) + + const counts = Database.use((db) => + db + .get<{ + roots: number + children: number + archived: number + messages: number + parts: number + }>( + sql`SELECT + (SELECT COUNT(*) FROM session WHERE parent_id IS NULL) as roots, + (SELECT COUNT(*) FROM session WHERE parent_id IS NOT NULL) as children, + (SELECT COUNT(*) FROM session WHERE time_archived IS NOT NULL) as archived, + (SELECT COUNT(*) FROM message) as messages, + (SELECT COUNT(*) FROM part) as parts`, + ) + ) + + const r = counts?.roots ?? 0 + const c = counts?.children ?? 0 + + console.log("Session Storage Statistics") + console.log("─".repeat(40)) + console.log(`Database size: ${formatSize(size)}`) + if (wal > 0) console.log(`WAL file size: ${formatSize(wal)}`) + console.log(`Total sessions: ${r + c} (${r} root, ${c} child)`) + console.log(`Archived sessions: ${counts?.archived ?? 0}`) + console.log(`Total messages: ${(counts?.messages ?? 0).toLocaleString()}`) + console.log(`Total parts: ${(counts?.parts ?? 0).toLocaleString()}`) + }) + }, +}) + +export const SessionPruneCommand = cmd({ + command: "prune", + describe: "delete old and archived sessions to reclaim storage", + builder: (yargs: Argv) => + yargs + .option("older-than", { + describe: "prune sessions inactive for N days (default: 30)", + type: "number", + default: 30, + }) + .option("children", { + describe: "also prune child sessions independently", + type: "boolean", + default: false, + }) + .option("vacuum", { + describe: "run VACUUM after pruning", + type: "boolean", + default: false, + }) + .option("dry-run", { + describe: "show what would be pruned without deleting", + type: "boolean", + default: false, + }), + handler: async (args) => { + await bootstrap(process.cwd(), async () => { + const cutoff = Date.now() - args.olderThan * 86_400_000 + const BATCH = 100 + const candidates: { id: string; title: string; archived: boolean; parent: boolean }[] = [] + + // paginate through all prunable sessions in batches + let offset = 0 + while (true) { + const rows = Database.use((db) => { + const conditions = [ + or( + not(isNull(SessionTable.time_archived)), + lt(SessionTable.time_updated, cutoff), + ), + ] + if (!args.children) { + conditions.push(isNull(SessionTable.parent_id)) + } + return db + .select({ + id: SessionTable.id, + title: SessionTable.title, + time_archived: SessionTable.time_archived, + parent_id: SessionTable.parent_id, + }) + .from(SessionTable) + .where(and(...conditions)) + .orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)) + .limit(BATCH) + .offset(offset) + .all() + }) + if (rows.length === 0) break + for (const row of rows) { + candidates.push({ + id: row.id, + title: row.title, + archived: row.time_archived !== null, + parent: row.parent_id === null, + }) + } + if (rows.length < BATCH) break + offset += BATCH + } + + if (candidates.length === 0) { + UI.println("No sessions to prune") + return + } + + // sort roots before children to avoid double-delete + candidates.sort((a, b) => (a.parent === b.parent ? 0 : a.parent ? -1 : 1)) + + if (args.dryRun) { + UI.println(`Would prune ${candidates.length} session(s):`) + for (const s of candidates) { + const tag = s.archived ? " [archived]" : "" + UI.println(` ${s.id} ${Locale.truncate(s.title, 40)}${tag}`) + } + return + } + + const before = Number(Filesystem.stat(Database.Path)?.size ?? 0) + const deleted = new Set() + for (const s of candidates) { + if (deleted.has(s.id)) continue + const descendants = collectDescendants(s.id) + await Session.remove(s.id) + deleted.add(s.id) + for (const d of descendants) deleted.add(d) + } + UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Pruned ${deleted.size} session(s)` + UI.Style.TEXT_NORMAL) + + if (args.vacuum) { + try { + Database.vacuum() + const after = Number(Filesystem.stat(Database.Path)?.size ?? 0) + const freed = before - after + if (freed > 0) UI.println(`Reclaimed ${formatSize(freed)}`) + else UI.println("Database vacuumed") + } catch { + UI.error("Database is busy or locked — try again when no sessions are active") + } + } + }) + }, +}) + +function collectDescendants(id: string): string[] { + const rows = Database.use((db) => + db + .select({ id: SessionTable.id }) + .from(SessionTable) + .where(eq(SessionTable.parent_id, id)) + .all(), + ) + const result: string[] = [] + for (const row of rows) { + result.push(row.id) + result.push(...collectDescendants(row.id)) + } + return result +} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 28c5b239a41..326ee4d3257 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1139,6 +1139,7 @@ export namespace Config { .object({ auto: z.boolean().optional().describe("Enable automatic compaction when context is full (default: true)"), prune: z.boolean().optional().describe("Enable pruning of old tool outputs (default: true)"), + reclaim: z.boolean().optional().describe("Clear old tool outputs from storage after compaction (default: true)"), reserved: z .number() .int() diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 79884d641ea..a7a528ccf5b 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -62,7 +62,7 @@ export namespace SessionCompaction { const msgs = await Session.messages({ sessionID: input.sessionID }) let total = 0 let pruned = 0 - const toPrune = [] + const toPrune: MessageV2.ToolPart[] = [] let turns = 0 loop: for (let msgIndex = msgs.length - 1; msgIndex >= 0; msgIndex--) { @@ -88,13 +88,18 @@ export namespace SessionCompaction { } log.info("found", { pruned, total }) if (pruned > PRUNE_MINIMUM) { + const reclaim = config.compaction?.reclaim !== false for (const part of toPrune) { if (part.state.status === "completed") { part.state.time.compacted = Date.now() + if (reclaim) { + part.state.output = "[reclaimed]" + part.state.attachments = [] + } await Session.updatePart(part) } } - log.info("pruned", { count: toPrune.length }) + log.info("pruned", { count: toPrune.length, reclaim }) } } @@ -290,6 +295,21 @@ When constructing the summary, try to stick to this template: } if (processor.message.error) return "stop" Bus.publish(Event.Compacted, { sessionID: input.sessionID }) + // retroactive reclamation: clear output/attachments from previously compacted parts + const cfg = await Config.get() + if (cfg.compaction?.reclaim !== false) { + for (const msg of input.messages) { + for (const part of msg.parts) { + if (part.type !== "tool") continue + if (part.state.status !== "completed") continue + if (!part.state.time.compacted) continue + if (part.state.output === "[reclaimed]") continue + part.state.output = "[reclaimed]" + part.state.attachments = [] + await Session.updatePart(part) + } + } + } return "continue" } diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index b117632051f..7465df900fa 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -10,7 +10,7 @@ import { Flag } from "../flag/flag" import { Identifier } from "../id/id" import { Installation } from "../installation" -import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like, inArray, lt } from "../storage/db" +import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage/db" import type { SQL } from "../storage/db" import { SessionTable, MessageTable, PartTable } from "./session.sql" import { ProjectTable } from "../project/project.sql" @@ -654,11 +654,18 @@ export namespace Session { return rows.map(fromRow) }) + // finds ALL children regardless of project — used by remove() for safe cascading + function childrenAll(parentID: string) { + const rows = Database.use((db) => + db.select().from(SessionTable).where(eq(SessionTable.parent_id, parentID)).all(), + ) + return rows.map(fromRow) + } + export const remove = fn(Identifier.schema("session"), async (sessionID) => { - const project = Instance.project try { const session = await get(sessionID) - for (const child of await children(sessionID)) { + for (const child of childrenAll(sessionID)) { await remove(child.id) } await unshare(sessionID).catch(() => {}) diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index f29aac18d16..c6184a684fc 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -108,6 +108,19 @@ export namespace Database { Client.reset() } + export function vacuum() { + const sqlite = state.sqlite + if (!sqlite) return + sqlite.run("PRAGMA wal_checkpoint(TRUNCATE)") + sqlite.run("VACUUM") + } + + export function checkpoint() { + const sqlite = state.sqlite + if (!sqlite) return + sqlite.run("PRAGMA wal_checkpoint(TRUNCATE)") + } + export type TxOrDb = Transaction | Client const ctx = Context.create<{