Skip to content

Commit 7a0f366

Browse files
authored
agentHost: use vacuum into for safer sqlite migration during forking (#311477)
* agentHost: use vacuum into for safer sqlite migration during forking * comments
1 parent dd50d09 commit 7a0f366

File tree

5 files changed

+60
-6
lines changed

5 files changed

+60
-6
lines changed

src/vs/platform/agentHost/common/sessionDataService.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,12 @@ export interface ISessionDatabase extends IDisposable {
173173
*/
174174
remapTurnIds(mapping: ReadonlyMap<string, string>): Promise<void>;
175175

176+
/**
177+
* Creates a safe, consistent copy of the database at the given path
178+
* using SQLite's `VACUUM INTO` command.
179+
*/
180+
vacuumInto(targetPath: string): Promise<void>;
181+
176182
/**
177183
* Resolves once all in-flight write operations on this database have
178184
* settled. Used by graceful shutdown to flush fire-and-forget writes

src/vs/platform/agentHost/node/copilot/copilotAgent.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -464,15 +464,21 @@ export class CopilotAgent extends Disposable implements IAgent {
464464
});
465465
const newSessionId = forkResult.sessionId;
466466

467-
// Copy the source session's database file so the forked session
468-
// inherits turn event IDs and file-edit snapshots.
469-
const sourceDbDir = this._sessionDataService.getSessionDataDir(config.fork!.session);
467+
// Copy the source session's database using VACUUM INTO so the
468+
// forked session inherits turn event IDs and file-edit snapshots.
469+
// VACUUM INTO is safe even while the source DB is open.
470470
const targetDbDir = this._sessionDataService.getSessionDataDirById(newSessionId);
471-
const sourceDbPath = URI.joinPath(sourceDbDir, SESSION_DB_FILENAME);
472471
const targetDbPath = URI.joinPath(targetDbDir, SESSION_DB_FILENAME);
473472
try {
474-
await fs.mkdir(targetDbDir.fsPath, { recursive: true });
475-
await fs.copyFile(sourceDbPath.fsPath, targetDbPath.fsPath);
473+
const sourceDbRef = await this._sessionDataService.tryOpenDatabase(config.fork!.session);
474+
if (sourceDbRef) {
475+
try {
476+
await fs.mkdir(targetDbDir.fsPath, { recursive: true });
477+
await sourceDbRef.object.vacuumInto(targetDbPath.fsPath);
478+
} finally {
479+
sourceDbRef.dispose();
480+
}
481+
}
476482
} catch (err) {
477483
this._logService.warn(`[Copilot] Failed to copy session database for fork: ${err instanceof Error ? err.message : String(err)}`);
478484
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,11 @@ export class SessionDatabase implements ISessionDatabase {
535535
}
536536
}
537537

538+
async vacuumInto(targetPath: string) {
539+
const db = await this._ensureDb();
540+
await dbRun(db, 'VACUUM INTO ?', [targetPath]);
541+
}
542+
538543
/**
539544
* Wrap a mutating operation's promise so {@link whenIdle} can await it.
540545
* Invoke at the **outermost** layer of every public mutating method so

src/vs/platform/agentHost/test/common/sessionTestHelpers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ export class TestSessionDatabase implements ISessionDatabase {
7272

7373
async close(): Promise<void> { }
7474

75+
async vacuumInto(_targetPath: string): Promise<void> { }
76+
7577
dispose(): void { }
7678

7779
async setTurnEventId(_turnId: string, _eventId: string): Promise<void> { }

src/vs/platform/agentHost/test/node/sessionDatabase.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,15 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import assert from 'assert';
7+
import { tmpdir } from 'os';
8+
import * as fs from 'fs/promises';
79
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
810
import { DisposableStore } from '../../../../base/common/lifecycle.js';
911
import { SessionDatabase, runMigrations, sessionDatabaseMigrations, type ISessionDatabaseMigration } from '../../node/sessionDatabase.js';
1012
import { FileEditKind } from '../../common/state/sessionState.js';
1113
import type { Database } from '@vscode/sqlite3';
14+
import { generateUuid } from '../../../../base/common/uuid.js';
15+
import { join } from '../../../../base/common/path.js';
1216

1317
suite('SessionDatabase', () => {
1418

@@ -489,4 +493,35 @@ suite('SessionDatabase', () => {
489493
assert.ok(tables.includes('session_metadata'));
490494
});
491495
});
496+
497+
// ---- vacuumInto -----------------------------------------------------
498+
499+
suite('vacuumInto', () => {
500+
501+
let tmpDir: string;
502+
503+
setup(async () => {
504+
tmpDir = await fs.mkdtemp(join(tmpdir(), 'session-db-test-' + generateUuid()));
505+
});
506+
507+
teardown(async () => {
508+
await Promise.all([db?.close(), db2?.close()]);
509+
db = db2 = undefined;
510+
await fs.rm(tmpDir, { recursive: true, force: true });
511+
});
512+
513+
test('produces a copy with the same data', async () => {
514+
db = disposables.add(await SessionDatabase.open(':memory:'));
515+
await db.createTurn('turn-1');
516+
await db.setTurnEventId('turn-1', 'evt-1');
517+
await db.setMetadata('key', 'value');
518+
519+
const targetPath = join(tmpDir, 'copy.db');
520+
await db.vacuumInto(targetPath);
521+
522+
db2 = disposables.add(await SessionDatabase.open(targetPath));
523+
assert.strictEqual(await db2.getTurnEventId('turn-1'), 'evt-1');
524+
assert.strictEqual(await db2.getMetadata('key'), 'value');
525+
});
526+
});
492527
});

0 commit comments

Comments
 (0)