Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions apps/server/src/checkpointing/CheckpointStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -46,6 +47,10 @@ export interface DeleteCheckpointRefsInput {
readonly checkpointRefs: ReadonlyArray<CheckpointRef>;
}

export interface PruneSnapshotsResult {
readonly deleted: number;
}

/** Service tag for checkpoint persistence and restore operations. */
export class CheckpointStore extends Context.Service<
CheckpointStore,
Expand Down Expand Up @@ -93,6 +98,16 @@ export class CheckpointStore extends Context.Service<
readonly deleteCheckpointRefs: (
input: DeleteCheckpointRefsInput,
) => Effect.Effect<void, CheckpointStoreError>;

/**
* 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<PruneSnapshotsResult, CheckpointStoreError>;
}
>()("t3/checkpointing/CheckpointStore") {}

Expand Down Expand Up @@ -157,13 +172,64 @@ 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<typeof p> => p !== null);

// Group by session, keep top 3 per session
const bySession = new Map<string, Array<{ ref: CheckpointRef; turnCount: number }>>();
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High checkpointing/CheckpointStore.ts:210

pruneSnapshots deletes every checkpoint ref beyond the newest 3 per session, which removes older refs that diff and revert flows still depend on. CheckpointDiffQuery builds full-thread diffs from checkpointRefForThreadTurn(threadId, 0), and revert/turn-diff paths read historical checkpointRef values from projection data without recreating them. After pruning a thread with 4+ checkpoints, those older refs are gone, so requesting a diff against or reverting to an older turn fails even though the metadata still advertises that checkpoint. Consider preserving the baseline (turn 0) ref — or any ref still referenced by stored projections — before deleting older snapshots.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/checkpointing/CheckpointStore.ts around line 210:

`pruneSnapshots` deletes every checkpoint ref beyond the newest 3 per session, which removes older refs that diff and revert flows still depend on. `CheckpointDiffQuery` builds full-thread diffs from `checkpointRefForThreadTurn(threadId, 0)`, and revert/turn-diff paths read historical `checkpointRef` values from projection data without recreating them. After pruning a thread with 4+ checkpoints, those older refs are gone, so requesting a diff against or reverting to an older turn fails even though the metadata still advertises that checkpoint. Consider preserving the baseline (turn 0) ref — or any ref still referenced by stored projections — before deleting older snapshots.

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 };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deleted count not verified

Medium Severity

The pruneSnapshots function reports a deleted count based on the number of refs it attempts to delete. As deleteCheckpointRefs is best-effort and tolerates individual failures, the reported count may exceed the actual number of successfully removed checkpoints.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 03bd38a. Configure here.

});

return CheckpointStore.of({
isGitRepository,
captureCheckpoint,
hasCheckpointRef,
restoreCheckpoint,
diffCheckpoints,
deleteCheckpointRefs,
pruneSnapshots,
});
});

Expand Down
1 change: 1 addition & 0 deletions apps/server/src/checkpointing/_meta.json
Original file line number Diff line number Diff line change
@@ -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"}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bounty metadata committed accidentally

Medium Severity

The _meta.json file in apps/server/src/checkpointing/ contains internal automation metadata. This artifact is not product code and shouldn't ship with the server package.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 03bd38a. Configure here.

22 changes: 22 additions & 0 deletions apps/server/src/vcs/GitVcsDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium vcs/GitVcsDriver.ts:862

listCheckpointRefs treats any non-zero exit code from git for-each-ref as "no checkpoints" and returns [], so real Git failures (e.g. a broken or concurrently removed repository/worktree) are silently masked as an empty checkpoint list instead of surfacing a VcsProcessExitError. Callers like CheckpointStore.pruneSnapshots will then skip pruning and return { deleted: 0 }, reporting a successful empty-state result instead of propagating the failure. Consider throwing VcsProcessExitError when the exit code is non-zero, and only returning [] when the command succeeds with empty output.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/vcs/GitVcsDriver.ts around line 862:

`listCheckpointRefs` treats any non-zero exit code from `git for-each-ref` as "no checkpoints" and returns `[]`, so real Git failures (e.g. a broken or concurrently removed repository/worktree) are silently masked as an empty checkpoint list instead of surfacing a `VcsProcessExitError`. Callers like `CheckpointStore.pruneSnapshots` will then skip pruning and return `{ deleted: 0 }`, reporting a successful empty-state result instead of propagating the failure. Consider throwing `VcsProcessExitError` when the exit code is non-zero, and only returning `[]` when the command succeeds with empty output.

return [];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Git list errors look empty

Medium Severity

The listCheckpointRefs function conflates git for-each-ref errors (like non-zero exit codes) and empty output with a healthy state of no checkpoint refs. This causes checkpoint retention pruning to silently no-op, allowing checkpoint refs to accumulate.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 03bd38a. Configure here.

}

return result.stdout
.trim()
.split("\n")
.filter((line) => line.length > 0)
.map((refname) => CheckpointRef.make(refname));
},
),
};

return {
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/vcs/VcsDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ export interface VcsDeleteCheckpointRefsInput {
readonly checkpointRefs: ReadonlyArray<CheckpointRef>;
}

export interface VcsListCheckpointRefsInput {
readonly cwd: string;
}

export interface VcsCheckpointOps {
readonly captureCheckpoint: (input: VcsCaptureCheckpointInput) => Effect.Effect<void, VcsError>;
readonly hasCheckpointRef: (
Expand All @@ -50,6 +54,9 @@ export interface VcsCheckpointOps {
readonly deleteCheckpointRefs: (
input: VcsDeleteCheckpointRefsInput,
) => Effect.Effect<void, VcsError>;
readonly listCheckpointRefs: (
input: VcsListCheckpointRefsInput,
) => Effect.Effect<ReadonlyArray<CheckpointRef>, VcsError>;
}

export class VcsDriver extends Context.Service<
Expand Down
Loading