src/vs/platform/agentHost/node/agentHostCheckpointService.ts

272 LOC · 62 covered · 210 uncovered · 13 ranges · 917 concepts · 1 introducers · 483 tests

File neighbourhood

The centred file is linked to every concept that introduces one of its ranges, every test that runs code from the file, and the gray connector concepts standing between those tests and the file's own introducer concepts. Undirected links join concepts to every file where they introduce source and concepts to the tests they introduce; arrows show specialization between the displayed concepts and bridge only concepts omitted from this view. Concept colors match the source ranges below; connector concepts have no source color and are shown in gray.

Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the file

In the embedded map, ordinary wheel input scrolls the page; use the visible controls to zoom and drag to pan. Open the full-screen map for canvas navigation: wheel pans, Ctrl/Command plus wheel zooms, and arrow keys pan when this region is focused. On touch screens, open the full-screen map to pan or pinch. If JavaScript or WebGL is unavailable, use the related-file, concept, and source links on this page.

Graph controls are ready.

Interactive rendering requires JavaScript and WebGL. Use the related-file, concept, and source links on this page while the interactive map is unavailable.

1 > /*--------------------------------------------------------------------------------------------- agentService.ts ×122
2 > * Copyright (c) Microsoft Corporation. All rights reserved.
3 > * Licensed under the MIT License. See License.txt in the project root for license information.
4 > *--------------------------------------------------------------------------------------------*/
5 >
6 > import { SequencerByKey } from '../../../base/common/async.js';
7 > import { Disposable } from '../../../base/common/lifecycle.js';
8 > import { URI } from '../../../base/common/uri.js';
9 > import { ILogService } from '../../log/common/log.js';
10 > import { IAgentHostCheckpointService, META_CHECKPOINT_BASE_REF, buildCheckpointRefName } from '../common/agentHostCheckpointService.js';
11 > import { AgentSession } from '../common/agentService.js';
12 > import { ISessionDatabase, ISessionDataService } from '../common/sessionDataService.js';
13 > import { IAgentHostGitService } from '../common/agentHostGitService.js';
14 >
15 > /**
16 > * `session_metadata` key under which the working directory used for
17 > * checkpoint capture is persisted (set when the baseline is created).
18 > * Stored as `URI.toString()`. Read by `captureTurnCheckpoint` /
19 > * `disposeSessionData` so they can resolve the repo without per-call
20 > * working-directory plumbing.
21 > */
22 > export const META_CHECKPOINT_WORKING_DIR = 'checkpoint.workingDir';
23 >
24 > export class AgentHostCheckpointService extends Disposable implements IAgentHostCheckpointService {
25 > declare readonly _serviceBrand: undefined;
26 >
27 > /**
28 > * Serializes capture/dispose per session so back-to-back end-of-turn
29 > * captures don't race on the temp-index files or the `setTurnCheckpointRef`
30 > * write, and a dispose can't run concurrently with an in-flight capture.
31 > * Keyed by session URI string.
32 > */
33 > private readonly _sequencer = new SequencerByKey<string>();
34 >
35 > constructor(
36 @ISessionDataService private readonly _sessionDataService: ISessionDataService,
37 @IAgentHostGitService private readonly _gitService: IAgentHostGitService,
38 @ILogService private readonly _logService: ILogService,
39 ) {
40 super();
41 // Cleanup hook: when a session's data directory is about to be
42 // deleted, enumerate and delete every checkpoint ref we created
43 // for that session BEFORE the database file disappears. The
44 // `waitUntil` API blocks `deleteSessionData` until our promise
45 // settles, so the deletion can't race the ref read.
46 this._register(this._sessionDataService.onWillDeleteSessionData(e => {
47 e.waitUntil(this.disposeSessionData(e.session));
48 }));
49 }
51 > captureBaseline(sessionUri: URI, workingDirectory: URI | undefined): Promise<string | undefined> {
52 return this._sequencer.queue(sessionUri.toString(), () => this._captureBaseline(sessionUri, workingDirectory));
53 }
55 > private async _captureBaseline(sessionUri: URI, workingDirectory: URI | undefined): Promise<string | undefined> {
56 if (!workingDirectory) {
57 return undefined;
58 }
59 const ref = this._sessionDataService.openDatabase(sessionUri);
60 try {
61 const existing = await ref.object.getMetadata(META_CHECKPOINT_BASE_REF);
62 if (existing) {
63 return existing;
64 }
65 const sanitized = this._sanitizedSessionId(sessionUri);
66 const refName = buildCheckpointRefName(sanitized, 0);
67 const commit = await this._writeCheckpointCommit(workingDirectory, undefined, `Agent host session ${sanitized} - baseline checkpoint`);
68 if (!commit) {
69 return undefined;
70 }
71 const repoRoot = await this._gitService.getRepositoryRoot(workingDirectory);
72 if (!repoRoot) {
73 return undefined;
74 }
75 await this._gitService.updateRef(repoRoot, refName, commit.commitOid);
76 await ref.object.setMetadata(META_CHECKPOINT_BASE_REF, refName);
77 await ref.object.setMetadata(META_CHECKPOINT_WORKING_DIR, workingDirectory.toString());
78 this._logService.trace(`[AgentHostCheckpoint] Captured baseline for ${sessionUri.toString()} at ${refName}`);
79 return refName;
80 } catch (err) {
81 this._logService.warn(`[AgentHostCheckpoint] Failed to capture baseline for ${sessionUri.toString()}`, err);
82 return undefined;
83 } finally {
84 ref.dispose();
85 }
86 }
88 > captureTurnCheckpoint(sessionUri: URI, turnId: string): Promise<string | undefined> {
89 return this._sequencer.queue(sessionUri.toString(), () => this._captureTurnCheckpoint(sessionUri, turnId));
90 }
92 > private async _captureTurnCheckpoint(sessionUri: URI, turnId: string): Promise<string | undefined> {
93 const ref = this._sessionDataService.openDatabase(sessionUri);
94 try {
95 const [baseRef, workingDirRaw, existing, prevTurnRef] = await Promise.all([
96 ref.object.getMetadata(META_CHECKPOINT_BASE_REF),
97 ref.object.getMetadata(META_CHECKPOINT_WORKING_DIR),
98 ref.object.getTurnCheckpointRef(turnId),
99 ref.object.getPreviousCheckpointRef(turnId),
100 ]);
101 if (existing) {
102 return existing;
103 }
104 if (!baseRef || !workingDirRaw) {
105 // Baseline never captured — session is not git-backed or
106 // baseline failed. Nothing to chain from.
107 return undefined;
108 }
109 const workingDirectory = URI.parse(workingDirRaw);
110 const repoRoot = await this._gitService.getRepositoryRoot(workingDirectory);
111 if (!repoRoot) {
112 return undefined;
113 }
114 const parentRef = prevTurnRef ?? baseRef;
115 const parentCommitOid = await this._gitService.revParse(repoRoot, parentRef);
116 if (!parentCommitOid) {
117 this._logService.warn(`[AgentHostCheckpoint] Parent ref ${parentRef} missing for session ${sessionUri.toString()}`);
118 return undefined;
119 }
120
121 const tree = await this._gitService.captureWorkingTreeAsTree(workingDirectory);
122 if (!tree) {
123 return undefined;
124 }
125
126 // No-op turn: if the tree is identical to the parent's tree,
127 // don't create a redundant commit/ref — point the turn at the
128 // parent ref so per-turn diffs against it are empty by
129 // construction.
130 const parentTree = await this._gitService.revParse(repoRoot, `${parentCommitOid}^{tree}`);
131 if (parentTree && parentTree === tree) {
132 await ref.object.setTurnCheckpointRef(turnId, parentRef);
133 this._logService.trace(`[AgentHostCheckpoint] No-op turn ${turnId} for ${sessionUri.toString()}; reusing ${parentRef}`);
134 return parentRef;
135 }
136
137 const sanitized = this._sanitizedSessionId(sessionUri);
138 const turnNumber = await this._nextTurnNumber(ref.object);
139 const refName = buildCheckpointRefName(sanitized, turnNumber);
140 const commitOid = await this._gitService.commitTree(repoRoot, tree, parentCommitOid, `Agent host session ${sanitized} - turn ${turnNumber}`);
141 if (!commitOid) {
142 return undefined;
143 }
144 await this._gitService.updateRef(repoRoot, refName, commitOid);
145 await ref.object.setTurnCheckpointRef(turnId, refName);
146 this._logService.trace(`[AgentHostCheckpoint] Captured turn ${turnNumber} for ${sessionUri.toString()} at ${refName}`);
147 return refName;
148 } catch (err) {
149 this._logService.warn(`[AgentHostCheckpoint] Failed to capture turn checkpoint for ${sessionUri.toString()}/${turnId}`, err);
150 return undefined;
151 } finally {
152 ref.dispose();
153 }
154 }
156 > async getTurnCheckpointPair(sessionUri: URI, turnId: string): Promise<{ parent: string; current: string } | undefined> {
157 const ref = this._sessionDataService.openDatabase(sessionUri);
158 try {
159 const [current, prev, baseRef] = await Promise.all([
160 ref.object.getTurnCheckpointRef(turnId),
161 ref.object.getPreviousCheckpointRef(turnId),
162 ref.object.getMetadata(META_CHECKPOINT_BASE_REF),
163 ]);
164 if (!current) {
165 return undefined;
166 }
167 const parent = prev ?? baseRef;
168 if (!parent) {
169 return undefined;
170 }
171 return { parent, current };
172 } finally {
173 ref.dispose();
174 }
175 }
177 > async getBaselineCheckpointRef(sessionUri: URI): Promise<string | undefined> {
178 const ref = this._sessionDataService.openDatabase(sessionUri);
179 try {
180 return await ref.object.getMetadata(META_CHECKPOINT_BASE_REF);
181 } finally {
182 ref.dispose();
183 }
184 }
186 > async disposeSessionData(sessionUri: URI): Promise<void> {
187 await this._sequencer.queue(sessionUri.toString(), () => this._disposeSessionData(sessionUri));
188 }
190 > private async _disposeSessionData(sessionUri: URI): Promise<void> {
191 const refHandle = await this._sessionDataService.tryOpenDatabase(sessionUri);
192 if (!refHandle) {
193 return;
194 }
195 try {
196 const [workingDirRaw, baseRef, turnRefs] = await Promise.all([
197 refHandle.object.getMetadata(META_CHECKPOINT_WORKING_DIR),
198 refHandle.object.getMetadata(META_CHECKPOINT_BASE_REF),
199 refHandle.object.getAllCheckpointRefs(),
200 ]);
201 if (!workingDirRaw) {
202 return;
203 }
204 const workingDirectory = URI.parse(workingDirRaw);
205 const repoRoot = await this._gitService.getRepositoryRoot(workingDirectory);
206 if (!repoRoot) {
207 return;
208 }
209 // Dedup baseRef and turnRefs (a no-op turn may reuse its
210 // parent's ref). Deleting the same ref twice is harmless but
211 // noisy, and the batch API takes a list.
212 const all = new Set<string>();
213 if (baseRef) {
214 all.add(baseRef);
215 }
216 for (const r of turnRefs) {
217 all.add(r);
218 }
219 if (all.size === 0) {
220 return;
221 }
222 await this._gitService.deleteRefs(repoRoot, [...all]);
223 this._logService.trace(`[AgentHostCheckpoint] Deleted ${all.size} checkpoint refs for ${sessionUri.toString()}`);
224 } catch (err) {
225 this._logService.warn(`[AgentHostCheckpoint] Failed to dispose checkpoint refs for ${sessionUri.toString()}`, err);
226 } finally {
227 refHandle.dispose();
228 }
229 }
231 > private async _writeCheckpointCommit(
232 workingDirectory: URI,
233 parentOid: string | undefined,
234 message: string,
235 ): Promise<{ commitOid: string } | undefined> {
236 const tree = await this._gitService.captureWorkingTreeAsTree(workingDirectory);
237 if (!tree) {
238 return undefined;
239 }
240 const repoRoot = await this._gitService.getRepositoryRoot(workingDirectory);
241 if (!repoRoot) {
242 return undefined;
243 }
244 const commitOid = await this._gitService.commitTree(repoRoot, tree, parentOid, message);
245 if (!commitOid) {
246 return undefined;
247 }
248 return { commitOid };
249 }
251 > /**
252 > * Parses the highest turn number from the existing refs and returns
253 > * the next one. Falls back to 1 (baseline is always 0).
254 > */
255 > private async _nextTurnNumber(db: ISessionDatabase): Promise<number> {
256 const refs = await db.getAllCheckpointRefs();
257 let max = 0;
258 for (const ref of refs) {
259 const idx = ref.lastIndexOf('/');
260 const tail = idx >= 0 ? ref.substring(idx + 1) : ref;
261 const n = parseInt(tail, 10);
262 if (Number.isFinite(n) && n > max) {
263 max = n;
264 }
265 }
266 return max + 1;
267 }
269 > private _sanitizedSessionId(sessionUri: URI): string {
270 return AgentSession.id(sessionUri).replace(/[^a-zA-Z0-9_.-]/g, '-');
271 }