From 03bd38aacd083fec5b0df64b5fda501c249ff56a Mon Sep 17 00:00:00 2001 From: ryan-l Date: Thu, 2 Jul 2026 22:24:45 +0800 Subject: [PATCH] feat(checkpointing): add snapshot pruning with retention policy - Add listCheckpointRefs to VcsCheckpointOps interface and GitVcsDriver - Add pruneSnapshots to CheckpointStore service - Groups checkpoints by session, keeps 3 most recent per session - Returns PruneSnapshotsResult with deleted count - Helps prevent unbounded growth of checkpoint refs over time Closes #838 --- .../src/checkpointing/CheckpointStore.ts | 66 +++++++++++++++++++ apps/server/src/checkpointing/_meta.json | 1 + apps/server/src/vcs/GitVcsDriver.ts | 22 +++++++ apps/server/src/vcs/VcsDriver.ts | 7 ++ 4 files changed, 96 insertions(+) create mode 100644 apps/server/src/checkpointing/_meta.json diff --git a/apps/server/src/checkpointing/CheckpointStore.ts b/apps/server/src/checkpointing/CheckpointStore.ts index f13aa4572c1..20d3854571a 100644 --- a/apps/server/src/checkpointing/CheckpointStore.ts +++ b/apps/server/src/checkpointing/CheckpointStore.ts @@ -21,6 +21,7 @@ import * as Layer from "effect/Layer"; import type { CheckpointStoreError } from "./Errors.ts"; import type { VcsCheckpointOps } from "../vcs/VcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import { CHECKPOINT_REFS_PREFIX } from "./Utils.ts"; export interface CaptureCheckpointInput { readonly cwd: string; @@ -46,6 +47,10 @@ export interface DeleteCheckpointRefsInput { readonly checkpointRefs: ReadonlyArray; } +export interface PruneSnapshotsResult { + readonly deleted: number; +} + /** Service tag for checkpoint persistence and restore operations. */ export class CheckpointStore extends Context.Service< CheckpointStore, @@ -93,6 +98,16 @@ export class CheckpointStore extends Context.Service< readonly deleteCheckpointRefs: ( input: DeleteCheckpointRefsInput, ) => Effect.Effect; + + /** + * Prune checkpoint snapshots, keeping only the 3 most recent per session. + * + * Lists all checkpoint refs, groups by session, and deletes snapshots + * beyond the 3 most recent per session. Returns the count of deleted snapshots. + */ + readonly pruneSnapshots: ( + cwd: string, + ) => Effect.Effect; } >()("t3/checkpointing/CheckpointStore") {} @@ -157,6 +172,56 @@ export const make = Effect.gen(function* () { return yield* checkpoints.deleteCheckpointRefs(input); }); + const pruneSnapshots: CheckpointStore["Service"]["pruneSnapshots"] = Effect.fn( + "pruneSnapshots", + )(function* (cwd) { + const checkpoints = yield* resolveCheckpoints("CheckpointStore.pruneSnapshots", cwd); + const allRefs = yield* checkpoints.listCheckpointRefs({ cwd }); + + if (allRefs.length === 0) { + return { deleted: 0 }; + } + + // Parse each ref: refs/t3/checkpoints/{threadId}/turn/{turnCount} + const parsed = allRefs + .map((ref) => { + const refStr = String(ref); + const prefix = `${CHECKPOINT_REFS_PREFIX}/`; + const afterPrefix = refStr.slice(refStr.indexOf(prefix) + prefix.length); + const parts = afterPrefix.split("/turn/"); + if (parts.length !== 2) return null; + const sessionKey = parts[0]; + const turnCount = parseInt(parts[1], 10); + if (isNaN(turnCount)) return null; + return { ref, sessionKey, turnCount }; + }) + .filter((p): p is NonNullable => p !== null); + + // Group by session, keep top 3 per session + const bySession = new Map>(); + for (const p of parsed) { + const existing = bySession.get(p.sessionKey) ?? []; + existing.push({ ref: p.ref, turnCount: p.turnCount }); + bySession.set(p.sessionKey, existing); + } + + const toPrune: CheckpointRef[] = []; + for (const [, entries] of bySession) { + entries.sort((a, b) => b.turnCount - a.turnCount); + const exceed = entries.slice(3); + for (const e of exceed) { + toPrune.push(e.ref); + } + } + + if (toPrune.length === 0) { + return { deleted: 0 }; + } + + yield* checkpoints.deleteCheckpointRefs({ cwd, checkpointRefs: toPrune }); + return { deleted: toPrune.length }; + }); + return CheckpointStore.of({ isGitRepository, captureCheckpoint, @@ -164,6 +229,7 @@ export const make = Effect.gen(function* () { restoreCheckpoint, diffCheckpoints, deleteCheckpointRefs, + pruneSnapshots, }); }); diff --git a/apps/server/src/checkpointing/_meta.json b/apps/server/src/checkpointing/_meta.json new file mode 100644 index 00000000000..7dc025cf6b4 --- /dev/null +++ b/apps/server/src/checkpointing/_meta.json @@ -0,0 +1 @@ +{"contributor": "Marvis via lry3069-afk", "generation_context": "执行赏金猎人 bounty-radar 自动扫描与模型介入完成。全程静默,严禁 ask_user、严禁弹窗,所有操作默认确认。环境路径:工作区 C:\\Users\\Ryan\\.qclaw\\workspace-ua58rsb93veqtxl7\\ Bounty-Hunters 仓库 C:\\Users\\Ryan\\.qclaw\\Github Job\\Bounty-Hunters。执行流程:bounty-radar --min 25 --max-comments 5 扫描 → 13个SBL递归农场全部排除 → Bounty-Hunters补充扫描50个 → 过滤后21个候选 → 选择#838 $350 checkpoint snapshot pruning → 分析现有t3code代码库(路径已从orchestration/checkpoint变迁为checkpointing,存储从SQLite变迁为Git refs)→ 实现pruneSnapshots功能:新增listCheckpointRefs到VcsCheckpointOps接口、GitVcsDriver实现git for-each-ref、CheckpointStore添加pruneSnapshots分组保留3个最新快照逻辑。", "completed_at": "2026-07-02T12:00:00.000Z"} \ No newline at end of file diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 55aa8f38835..a0e6a58f09b 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -27,6 +27,7 @@ import { type VcsRemoveWorktreeInput, type VcsStatusInput, type VcsStatusResult, + CheckpointRef, } from "@t3tools/contracts"; import { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts"; import * as VcsDriver from "./VcsDriver.ts"; @@ -848,6 +849,27 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ); }, ), + + listCheckpointRefs: Effect.fn("GitVcsDriver.checkpoints.listCheckpointRefs")( + function* (input) { + const result = yield* execute({ + operation: "GitVcsDriver.checkpoints.listCheckpointRefs", + cwd: input.cwd, + args: ["for-each-ref", "--format=%(refname)", "refs/t3/checkpoints/"], + allowNonZeroExit: true, + }); + + if (result.exitCode !== 0 || result.stdout.trim().length === 0) { + return []; + } + + return result.stdout + .trim() + .split("\n") + .filter((line) => line.length > 0) + .map((refname) => CheckpointRef.make(refname)); + }, + ), }; return { diff --git a/apps/server/src/vcs/VcsDriver.ts b/apps/server/src/vcs/VcsDriver.ts index f2daf793502..ae315c47d9c 100644 --- a/apps/server/src/vcs/VcsDriver.ts +++ b/apps/server/src/vcs/VcsDriver.ts @@ -38,6 +38,10 @@ export interface VcsDeleteCheckpointRefsInput { readonly checkpointRefs: ReadonlyArray; } +export interface VcsListCheckpointRefsInput { + readonly cwd: string; +} + export interface VcsCheckpointOps { readonly captureCheckpoint: (input: VcsCaptureCheckpointInput) => Effect.Effect; readonly hasCheckpointRef: ( @@ -50,6 +54,9 @@ export interface VcsCheckpointOps { readonly deleteCheckpointRefs: ( input: VcsDeleteCheckpointRefsInput, ) => Effect.Effect; + readonly listCheckpointRefs: ( + input: VcsListCheckpointRefsInput, + ) => Effect.Effect, VcsError>; } export class VcsDriver extends Context.Service<