mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-11-01 14:30:37 +00:00
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:
parent
60136588a4
commit
eba23edb93
11 changed files with 48 additions and 90 deletions
5
.changeset/file-history-always-on.md
Normal file
5
.changeset/file-history-always-on.md
Normal 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.
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { Feature } from '#/features/feature';
|
||||
import { registerFeature } from '#/features/featureRegistry';
|
||||
|
||||
import './flag';
|
||||
import { IAgentFileHistoryService } from './fileHistory';
|
||||
import { AgentFileHistoryService } from './fileHistoryService';
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue