feat(agent-core-v2): drop the experimental gate from turn-level file history (#3525)

* feat(agent-core-v2): drop the experimental gate from turn-level file history

The file_history flag and its KIMI_CODE_EXPERIMENTAL_FILE_HISTORY env
var are removed; first-touch baselines, turn checkpoints, and the
changes/turnRecorded/contentAt reads now run unconditionally, so the
per-turn file diffs served by kap-server no longer depend on opt-in
configuration.

The changes REST response drops its enabled field — recorded alone
tells an authoritative empty result apart from a turn whose records
were never made, lost to a crash, or displaced by retention.

* fix(agent-core-v2): probe blob existence with a stat, not a directory listing

turnRecorded always answered false for turns whose snapshots lived under
the file-history/ prefix directory: BlobStoreService.has listed the
scope directory (a shallow readdir) and exact-matched the nested key
against top-level entries, so keyed blobs were never found and the
changes endpoint reported recorded=false for exactly the turns that had
edits. Stat the key through storage.size instead, and pin the
turn-recorded assertions against a real node-fs home so the harness
covers the production layout.

* fix(agent-core-v2): probe every keyed blob before calling a turn recorded

Merging the start and end entries let an end entry with key null (a
deleted or oversize-at-end file) shadow the start baseline, and probing
only one surviving key missed partial loss in the other phase: a
session displaced from the thirty-session window could still claim
authority for turns whose snapshots were gone. turnRecorded now stats
every distinct keyed blob across both checkpoints and reports the turn
unrecorded when any is missing.
This commit is contained in:
qer 2026-09-04 15:57:59 +08:00 • committed by GitHub
parent 60136588a4
commit eba23edb93
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 48 additions and 90 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---
Turn-level file history is now always on; the experimental file-history flag has been removed.

View file

@ -42,7 +42,6 @@ export interface FileHistoryContent {
export interface IAgentFileHistoryService {
readonly _serviceBrand: undefined;
enabled(): boolean;
history(): FileHistoryState;
settled(): Promise<void>;
captureForActiveTurn(path: string): Promise<void>;

View file

@ -1,7 +1,6 @@
import { Feature } from '#/features/feature';
import { registerFeature } from '#/features/featureRegistry';
import './flag';
import { IAgentFileHistoryService } from './fileHistory';
import { AgentFileHistoryService } from './fileHistoryService';

View file

@ -15,7 +15,6 @@ import { IHostFileSystem } from '#/os/interface/hostFileSystem';
import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore';
import { TurnEnded } from '#/agent/loop/turnOps';
import { IEventBus } from '#/app/event/eventBus';
import { IFlagService } from '#/app/flag/flag';
import { IBlobStore } from '#/persistence/interface/blobStore';
import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle';
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
@ -40,7 +39,6 @@ import {
fileHistoryKey,
} from './fileHistoryOps';
import { touchFileHistorySession } from './fileHistoryRetention';
import { FILE_HISTORY_FLAG_ID } from './flag';
export const FILE_HISTORY_MAX_FILE_BYTES = 4 * 1024 * 1024;
export { FILE_HISTORY_BLOB_PREFIX } from './fileHistory';
@ -58,7 +56,6 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor
@IAgentToolExecutorService toolExecutor: IAgentToolExecutorService,
@IEventBus eventBus: IEventBus,
@IEventDispatcher private readonly dispatcher: IEventDispatcher,
@IFlagService private readonly flags: IFlagService,
@IAgentRuntimeService private readonly runtime: IAgentRuntimeService,
@IBlobStore private readonly blobs: IBlobStore,
@ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext,
@ -89,17 +86,12 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor
eventBus.subscribe(TurnEnded, (event) => {
if (event.agentId !== this.agentCtx.agentId) return;
if (this.activeTurnId === event.turnId) this.activeTurnId = undefined;
if (!this.enabled()) return;
void this.enqueue(() => this.endCheckpoint(event.turnId));
}),
);
this.effect(() => () => this.queue, 'fileHistory:drain');
}
enabled(): boolean {
return this.flags.enabled(FILE_HISTORY_FLAG_ID);
}
history(): FileHistoryState {
return this.agentState.get(fileHistoryKey);
}
@ -109,12 +101,10 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor
}
changes(turnId: number): Promise<FileHistoryChange[]> {
if (!this.enabled()) return Promise.resolve([]);
return this.enqueueValue(() => this.readChanges(turnId));
}
turnRecorded(turnId: number): Promise<boolean> {
if (!this.enabled()) return Promise.resolve(false);
return this.enqueueValue(async () => {
const state = this.history();
const index = state.checkpoints.findIndex(
@ -129,10 +119,17 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor
index === state.checkpoints.length - 1 &&
this.activeTurnId === turnId;
if (end === undefined && !live) return false;
const entries = { ...state.checkpoints[index]!.entries, ...end?.entries };
const keyed = Object.values(entries).find((entry) => entry.key !== null);
if (keyed?.key === null || keyed?.key === undefined) return true;
return this.blobs.has(this.agentCtx.scope(), keyed.key);
const keys = new Set<string>();
for (const entry of [
...Object.values(state.checkpoints[index]!.entries),
...Object.values(end?.entries ?? {}),
]) {
if (entry.key !== null) keys.add(entry.key);
}
for (const key of keys) {
if (!(await this.blobs.has(this.agentCtx.scope(), key))) return false;
}
return true;
});
}
@ -219,7 +216,6 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor
path: string,
phase: FileHistoryCheckpointPhase = 'start',
): Promise<FileHistoryContent | undefined> {
if (!this.enabled()) return Promise.resolve(undefined);
return this.enqueueValue(() => this.readContentAt(turnId, path, phase));
}
@ -255,14 +251,12 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor
}
private onWillExecuteTool(event: WillExecuteToolEvent): void {
if (!this.enabled()) return;
const path = editTargetPath(event.execution.display);
if (path === undefined) return;
event.waitUntil(this.enqueue(() => this.capture(path, event.turnId)));
}
private onSubagentWillExecuteTool(event: WillExecuteToolEvent): void {
if (!this.enabled()) return;
const path = editTargetPath(event.execution.display);
if (path === undefined) return;
const main = this.agentLifecycle.handleOf(MAIN_AGENT_ID);
@ -272,7 +266,7 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor
captureForActiveTurn(path: string): Promise<void> {
const turnId = this.activeTurnId;
if (!this.enabled() || turnId === undefined) return Promise.resolve();
if (turnId === undefined) return Promise.resolve();
return this.enqueue(() => this.capture(path, turnId));
}

View file

@ -1,16 +0,0 @@
import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry';
export const FILE_HISTORY_FLAG_ID = 'file_history';
export const FILE_HISTORY_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_FILE_HISTORY';
export const fileHistoryFlag: FlagDefinitionInput = {
id: FILE_HISTORY_FLAG_ID,
title: 'Turn-level file history',
description:
'Record each turn\'s edited files — their content from before the first edit and after the turn ends, kept for the last five editing turns of the thirty most recently active sessions per workspace — so per-turn file diffs come from real whole-file snapshots instead of tool-argument reconstruction. Enabling this stores plain copies of edited file content inside the session data directory.',
env: FILE_HISTORY_FLAG_ENV,
default: false,
surface: 'core',
};
registerFlagDefinition(fileHistoryFlag);

View file

@ -330,7 +330,6 @@ import '#/features/plan/planFeature';
export * from '#/features/fileHistory/fileHistory';
export * from '#/features/fileHistory/fileHistoryOps';
export * from '#/features/fileHistory/fileHistoryService';
export * from '#/features/fileHistory/flag';
import '#/features/fileHistory/fileHistoryFeature';
export * from '#/features/externalHooks/configSection';
export * from '#/features/externalHooks/app/externalHooksRunner';

View file

@ -26,8 +26,7 @@ export class BlobStoreService implements IBlobStore {
}
async has(scope: string, key: string): Promise<boolean> {
const keys = await this.storage.list(scope, key);
return keys.includes(key);
return (await this.storage.size(scope, key)) !== undefined;
}
async delete(scope: string, key: string): Promise<void> {

View file

@ -15,12 +15,10 @@ import { TurnEnded } from '#/agent/loop/turnOps';
import { USER_PROMPT_ORIGIN } from '#/agent/contextMemory/types';
import { IEventBus, type ISessionEventBus } from '#/app/event/eventBus';
import { EventBusService } from '#/app/event/eventBusService';
import type { IFlagService } from '#/app/flag/flag';
import { IAgentFileHistoryService } from '#/features/fileHistory/fileHistory';
import { AgentFileHistoryService, countLineDiff } from '#/features/fileHistory/fileHistoryService';
import { displacedCheckpoints } from '#/features/fileHistory/fileHistoryOps';
import type { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
import { FILE_HISTORY_FLAG_ENV } from '#/features/fileHistory/flag';
import type { ToolCall } from '#/kosong/contract/message';
import type { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime';
import type { IHostFileSystem } from '#/os/interface/hostFileSystem';
@ -39,7 +37,7 @@ import type { RunnableToolExecution } from '#/tool/toolContract';
import { createFakeHostFs } from '../../tools/fixtures/fake-exec';
import { stubToolExecutorEvents, type ToolExecutorEventStubs } from '../../agent/toolExecutor/stubs';
import { registerTestAgentWire, registerTestEventDispatcher, testWireScope } from '../../wire/stubs';
import { createTestAgent } from '../../harness';
import { createTestAgent, homeDirServices } from '../../harness';
const SCOPE = 'wire';
const KEY = 'file-history-test';
@ -56,7 +54,6 @@ describe('AgentFileHistoryService', () => {
let blobs: IBlobStore;
let scopeCtx: IAgentScopeContext;
let files: Map<string, Uint8Array>;
let flagEnabled: boolean;
beforeEach(() => {
disposables = new DisposableStore();
@ -79,7 +76,6 @@ describe('AgentFileHistoryService', () => {
executorEvents = stubToolExecutorEvents();
blobs = new BlobStoreService(new InMemoryStorageService());
files = new Map();
flagEnabled = true;
});
afterEach(() => {
@ -119,7 +115,6 @@ describe('AgentFileHistoryService', () => {
agentId === scopeCtx.agentId
? scopeCtx
: makeAgentScopeContext({ agentId, agentScope: testWireScope(SCOPE, KEY) });
const flags = { enabled: () => flagEnabled } as unknown as IFlagService;
const workspace = {
workDir: WORK_DIR,
additionalDirs: [],
@ -138,7 +133,6 @@ describe('AgentFileHistoryService', () => {
executorEvents.executor,
eventBus,
ix.get(IEventDispatcher),
flags,
stubRuntime(),
blobs,
workspace,
@ -312,19 +306,6 @@ describe('AgentFileHistoryService', () => {
]);
});
it('does nothing while the flag is off', async () => {
flagEnabled = false;
const service = createService();
setFile('/ws/a.txt', 'content\n');
startTurn(1);
await fireEdit(service, '/ws/a.txt', 1);
await service.settled();
const state = service.history();
expect(state.checkpoints).toEqual([]);
expect(state.tracked).toEqual([]);
});
it('excludes user edits between turns via the end-of-turn checkpoint', async () => {
const service = createService();
@ -349,25 +330,6 @@ describe('AgentFileHistoryService', () => {
expect(await service.contentAt(2, 'a.txt')).toBeUndefined();
});
it('guards reads once the flag is turned off after data was recorded', async () => {
const service = createService();
setFile('/ws/a.txt', 'content\n');
startTurn(1);
await fireEdit(service, '/ws/a.txt', 1);
setFile('/ws/a.txt', 'changed\n');
endTurn(1);
startTurn(2);
await service.settled();
expect(await service.changes(1)).toEqual([
{ path: 'a.txt', status: 'modified', additions: 1, deletions: 1 },
]);
expect((await service.contentAt(1, 'a.txt'))?.content).toBe('content\n');
flagEnabled = false;
expect(await service.changes(1)).toEqual([]);
expect(await service.contentAt(1, 'a.txt')).toBeUndefined();
});
it('reports an over-budget modified file as oversize with no counts', async () => {
const service = createService();
@ -472,6 +434,28 @@ describe('AgentFileHistoryService', () => {
expect(await service.turnRecorded(3)).toBe(false);
});
it('reports a deletion turn unrecorded once its baseline blob is gone', async () => {
const service = createService();
setFile('/ws/d.txt', 'gone\n');
startTurn(1);
await fireEdit(service, '/ws/d.txt', 1);
files.delete('/ws/d.txt');
endTurn(1);
await service.settled();
expect(await service.changes(1)).toEqual([
{ path: 'd.txt', status: 'deleted', additions: 0, deletions: 1 },
]);
expect(await service.turnRecorded(1)).toBe(true);
const keyed = Object.values(
service.history().checkpoints.find((c) => c.turnId === 1 && c.phase !== 'end')!.entries,
).find((entry) => entry.key !== null);
await blobs.delete(scopeCtx.scope(), keyed!.key!);
expect(await service.turnRecorded(1)).toBe(false);
});
it('keeps a shared baseline blob alive until its last window reference leaves', async () => {
const service = createService();
setFile('/ws/s.txt', 'base\n');
@ -522,19 +506,12 @@ describe('AgentFileHistoryService', () => {
});
describe('file history through real scripted turns', () => {
beforeEach(() => {
process.env[FILE_HISTORY_FLAG_ENV] = '1';
});
afterEach(() => {
delete process.env[FILE_HISTORY_FLAG_ENV];
});
it('checkpoints edits across turns and serves exact per-turn changes', async () => {
const dir = await mkdtemp(join(tmpdir(), 'file-history-e2e-'));
const home = await mkdtemp(join(tmpdir(), 'file-history-home-'));
const file = join(dir, 'notes.txt');
await writeFile(file, 'alpha\nbeta\n');
const ctx = createTestAgent();
const ctx = createTestAgent(homeDirServices(home));
try {
await ctx.rpc.setPermission({ mode: 'yolo' });
@ -582,9 +559,14 @@ describe('file history through real scripted turns', () => {
{ path: file, status: 'modified', additions: 1, deletions: 1 },
]);
expect(await service.changes(1)).toEqual([]);
expect(await service.turnRecorded(0)).toBe(true);
expect(await service.turnRecorded(1)).toBe(false);
expect(await service.turnRecorded(99)).toBe(false);
} finally {
await ctx.dispose();
await rm(dir, { recursive: true, force: true });
await rm(home, { recursive: true, force: true });
}
});

View file

@ -24,7 +24,6 @@ export type WireFileHistoryChange = z.infer<typeof fileHistoryChangeSchema>;
export const fileHistoryChangesResponseSchema = z.object({
changes: z.array(fileHistoryChangeSchema),
enabled: z.boolean(),
recorded: z.boolean(),
});
export type FileHistoryChangesResponse = z.infer<typeof fileHistoryChangesResponseSchema>;

View file

@ -60,7 +60,6 @@ export function registerFileHistoryRoutes(app: FileHistoryRouteHost, core: Scope
okEnvelope(
{
changes: await history.changes(req.query.turn_id),
enabled: history.enabled(),
recorded: await history.turnRecorded(req.query.turn_id),
},
req.id,

View file

@ -92,7 +92,6 @@ describe('file history routes', () => {
expect(changes.statusCode).toBe(200);
expect((changes.json() as Envelope<{ changes: unknown[] }>).data).toEqual({
changes: [],
enabled: false,
recorded: false,
});