Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/file-write-staleness-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Edit and Write now require reading an existing file before modifying it, and reject the write when the file changed on disk since it was last read.
6 changes: 5 additions & 1 deletion packages/agent-core-v2/docs/state-manifest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
// references become '(circular)', and class instances collapse to a '(ClassName)'
// marker — the wire shape of an entry is the JSON projection of the type here.
//
// Index (App: 0 keys · Workspace: 6 keys · Session: 17 keys · Agent: 98 keys)
// Index (App: 0 keys · Workspace: 6 keys · Session: 17 keys · Agent: 99 keys)
// App
// Workspace
// workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts
Expand Down Expand Up @@ -125,6 +125,7 @@
// runtimeBinding src/agent/runtimeBinding/runtimeBindingOps.ts
// shellCommand.tasks src/agent/shellCommand/shellCommandService.ts
// skill src/agent/skill/skillOps.ts
// staleGuard src/features/staleGuard/staleGuardOps.ts
// stepRetry.failedAttempts src/agent/stepRetry/stepRetryService.ts
// stepRetry.lastFailedDriverId src/agent/stepRetry/stepRetryService.ts
// swarm src/features/swarm/swarmOps.ts
Expand Down Expand Up @@ -1574,6 +1575,9 @@ export interface AgentStateSnapshot {
readonly id?: string;
readonly revisionCount?: Readonly<Record<string, number>>;
};
// src/features/staleGuard/staleGuardOps.ts
// replayable · durable — folds: StaleGuardRecorded, StaleGuardCleared
'staleGuard': /* StaleGuardModelState — packages/agent-core-v2/src/features/staleGuard/staleGuardOps.ts */ Map<string, number>;
// src/features/swarm/swarmOps.ts
// replayable · durable — folds: SwarmModeEnter, SwarmModeExit
'swarm': 'task' | 'tool' | 'manual' | null;
Expand Down
24 changes: 23 additions & 1 deletion packages/agent-core-v2/docs/wire-manifest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
// cross-reducers), blobs (the folding states whose blob codec offloads inline
// media to blob storage), owner (the source file declaring the class).

// Index (53 record types)
// Index (55 record types)
// config.update profile src/agent/profile/profileOps.ts
// context.append_loop_event contextMemory, turn src/agent/contextMemory/contextEvents.ts
// context.append_message contextMemory, goalForkNotice, plan, task.notificationDelivery, todo src/agent/contextMemory/contextEvents.ts
Expand Down Expand Up @@ -57,6 +57,8 @@
// profile.bind profile, profile.activeTools src/agent/profile/profileOps.ts
// prompt.accepted promptAdmission src/agent/prompt/promptOps.ts
// runtime.set_binding runtimeBinding src/agent/runtimeBinding/runtimeBindingOps.ts
// staleGuard.cleared staleGuard src/features/staleGuard/staleGuardOps.ts
// staleGuard.recorded staleGuard src/features/staleGuard/staleGuardOps.ts
// swarm_mode.enter swarm src/features/swarm/swarmOps.ts
// swarm_mode.exit contextMemory, swarm src/features/swarm/swarmOps.ts
// task.started task src/agent/task/taskOps.ts
Expand Down Expand Up @@ -497,6 +499,24 @@ interface RuntimeSetBindingPayload {
runtimeId: string;
}

/**
* states: staleGuard
* owner: src/features/staleGuard/staleGuardOps.ts
*/
interface StaleGuardClearedPayload {
_name: 'staleGuard.cleared';
}

/**
* states: staleGuard
* owner: src/features/staleGuard/staleGuardOps.ts
*/
interface StaleGuardRecordedPayload {
_name: 'staleGuard.recorded';
path: string;
mtimeMs: number;
}

/**
* states: swarm
* owner: src/features/swarm/swarmOps.ts
Expand Down Expand Up @@ -792,6 +812,8 @@ interface WirePayloadMap {
"profile.bind": ProfileBindPayload;
"prompt.accepted": PromptAcceptedPayload;
"runtime.set_binding": RuntimeSetBindingPayload;
"staleGuard.cleared": StaleGuardClearedPayload;
"staleGuard.recorded": StaleGuardRecordedPayload;
"swarm_mode.enter": SwarmModeEnterPayload;
"swarm_mode.exit": SwarmModeExitPayload;
"task.started": TaskStartedPayload;
Expand Down
10 changes: 10 additions & 0 deletions packages/agent-core-v2/src/features/staleGuard/staleGuard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';

export interface IStaleGuardService {
readonly _serviceBrand: undefined;

recordedMtimeMs(path: string): number | undefined;
}

export const IStaleGuardService: ServiceIdentifier<IStaleGuardService> =
createDecorator<IStaleGuardService>('staleGuardService');
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { ScopeActivation } from '#/_base/di/instantiation';
import { Feature } from '#/features/feature';
import { registerFeature } from '#/features/featureRegistry';

import { IStaleGuardService } from './staleGuard';
import { StaleGuardService } from './staleGuardService';

export class StaleGuardFeature extends Feature {
static override readonly name = 'staleGuard';

constructor() {
super();
this.contributeAgentService(IStaleGuardService, StaleGuardService, {
activation: ScopeActivation.OnScopeCreated,
});
}
}

registerFeature(StaleGuardFeature);
41 changes: 41 additions & 0 deletions packages/agent-core-v2/src/features/staleGuard/staleGuardOps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */
import { z } from 'zod';

import { Event2 } from '#/app/event/event2';
import { defineState } from '#/state/state';

export type StaleGuardModelState = Map<string, number>;

const staleGuardRecordedSchema = z.object({
path: z.string(),
mtimeMs: z.number(),
});

export class StaleGuardRecorded extends Event2<z.infer<typeof staleGuardRecordedSchema>> {
static override readonly type = 'staleGuard.recorded';
static override readonly durable = true;
static override readonly schema = staleGuardRecordedSchema;
}
export interface StaleGuardRecorded extends z.infer<typeof staleGuardRecordedSchema> {}

const staleGuardClearedSchema = z.object({});

export class StaleGuardCleared extends Event2<z.infer<typeof staleGuardClearedSchema>> {
static override readonly type = 'staleGuard.cleared';
static override readonly durable = true;
static override readonly schema = staleGuardClearedSchema;
}
export interface StaleGuardCleared extends z.infer<typeof staleGuardClearedSchema> {}

export const staleGuardKey = defineState(
'staleGuard',
(): StaleGuardModelState => new Map(),
).replayable({
schema: z.custom<StaleGuardModelState>(),
})
.on(StaleGuardRecorded, (s, e) => {
s.set(e.path, e.mtimeMs);
})
.on(StaleGuardCleared, (s) => {
s.clear();
});
146 changes: 146 additions & 0 deletions packages/agent-core-v2/src/features/staleGuard/staleGuardService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { Disposable } from '#/_base/di/lifecycle';
import { IAgentStateService } from '#/agent/state/agentState';
import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime';
import { denyToolExecution } from '#/agent/toolExecutor/beforeToolExecuteEvent';
import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
import type {
BeforeToolExecuteEvent,
ToolDidExecuteContext,
} from '#/agent/toolExecutor/toolHooks';
import type { ToolCall } from '#/kosong/contract/message';
import type { HostFileStat } from '#/os/interface/hostFileSystem';
import { IEventDispatcher } from '#/state/eventDispatcher';
import type { ToolAccesses, ToolFileAccessOperation } from '#/tool/toolContract';

import { IStaleGuardService } from './staleGuard';
import { StaleGuardCleared, StaleGuardRecorded, staleGuardKey } from './staleGuardOps';

const WRITE_OPERATIONS: readonly ToolFileAccessOperation[] = ['write', 'readwrite'];
const READ_OPERATIONS: readonly ToolFileAccessOperation[] = ['read'];

function accessedFilePath(
accesses: ToolAccesses | undefined,
operations: readonly ToolFileAccessOperation[],
): string | undefined {
for (const access of accesses ?? []) {
if (access.kind === 'file' && operations.includes(access.operation)) return access.path;
}
return undefined;
}

function stringArg(args: unknown, key: string): string | undefined {
if (typeof args !== 'object' || args === null) return undefined;
const value = (args as Record<string, unknown>)[key];
return typeof value === 'string' ? value : undefined;
}

function callPathArg(call: ToolCall): string | undefined {
if (typeof call.arguments !== 'string') return undefined;
try {
return stringArg(JSON.parse(call.arguments), 'path');
} catch {
return undefined;
}
}

export class StaleGuardService extends Disposable implements IStaleGuardService {
declare readonly _serviceBrand: undefined;

constructor(
@IAgentStateService private readonly states: IAgentStateService,
@IEventDispatcher private readonly dispatcher: IEventDispatcher,
@IAgentRuntimeService private readonly runtime: IAgentRuntimeService,
@IAgentToolExecutorService toolExecutor: IAgentToolExecutorService,
) {
super();
this.states.contributeState(staleGuardKey);
this._register(toolExecutor.onBeforeExecuteTool((event) => this.guardWrite(event)));
this._register(
toolExecutor.hooks.onDidExecuteTool.register('staleGuard', async (ctx, next) => {
await this.observeExecution(ctx);
await next();
}),
);
this._register(
this.runtime.onDidChange(() => {
void this.dispatcher.dispatch(new StaleGuardCleared({}));
}),
);
}

recordedMtimeMs(path: string): number | undefined {
return this.states.get(staleGuardKey).get(path);
}

private guardWrite(event: BeforeToolExecuteEvent): void {
const name = event.toolCall.name;
if (name !== 'Edit' && name !== 'Write') return;
const path = accessedFilePath(event.execution.accesses, WRITE_OPERATIONS);
if (path === undefined) return;
const displayPath = stringArg(event.args, 'path') ?? path;
if (coveredByEarlierRead(event, displayPath)) return;
event.waitUntil(async () => {
const error = await this.checkWritable(path, displayPath);
return error === undefined ? undefined : { veto: denyToolExecution(error) };
Comment on lines +82 to +84

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Revalidate freshness when the scheduled write starts

The check runs during tool-call preparation rather than immediately before the write. AgentToolExecutorService.execute prepares every call before executeBatch starts any task, so a same-response Read followed by Edit is incorrectly rejected as unread; conversely, a file changed after this check while the write waits behind another conflicting call or an approval is overwritten without detection. Run the freshness check inside the scheduler's execution task, after preceding file accesses have completed and directly before execution.execute.

Useful? React with 👍 / 👎.

});
}

private async observeExecution(ctx: ToolDidExecuteContext): Promise<void> {
if (ctx.outcome !== 'executed' || ctx.result.isError === true) return;
const name = ctx.toolCall.name;
if (name === 'Read') {
const path = accessedFilePath(ctx.accesses, READ_OPERATIONS);
if (path !== undefined) await this.recordCurrentMtime(path);
return;
}
if (name === 'Edit' || name === 'Write') {
const path = accessedFilePath(ctx.accesses, WRITE_OPERATIONS);
if (path !== undefined) await this.recordCurrentMtime(path);
}
}

private async checkWritable(path: string, displayPath: string): Promise<string | undefined> {
const stat = await this.statFile(path);
if (stat === undefined || stat.mtimeMs === undefined) return undefined;
const recorded = this.recordedMtimeMs(path);
if (recorded === undefined) {
return (
`"${displayPath}" has not been read by this agent yet. ` +
'Read the file before writing to it.'
);
}
if (recorded !== stat.mtimeMs) {
return (
`"${displayPath}" has been modified on disk since this agent last read it. ` +
'Read the file again before writing to it.'
);
}
return undefined;
}

private async recordCurrentMtime(path: string): Promise<void> {
const stat = await this.statFile(path);
if (stat?.mtimeMs === undefined) return;
await this.dispatcher.dispatch(new StaleGuardRecorded({ path, mtimeMs: stat.mtimeMs }));
}

private async statFile(path: string): Promise<HostFileStat | undefined> {
const lease = this.runtime.acquire(['fs']);
try {
const stat = await lease.runtime.fs!.stat(path);
return stat.isFile ? stat : undefined;
} catch {
return undefined;
} finally {
lease.dispose();
}
}
}

function coveredByEarlierRead(event: BeforeToolExecuteEvent, rawPath: string): boolean {
for (const call of event.toolCalls) {
if (call.id === event.toolCall.id) return false;
if (call.name === 'Read' && callPathArg(call) === rawPath) return true;
}
return false;
}
1 change: 1 addition & 0 deletions packages/agent-core-v2/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,7 @@ import '#/agent/goal/goalDeadlineSchedulerService';
export * from '#/agent/goal/goal';
export * from '#/agent/goal/goalService';
export * from '#/agent/goal/types';
import '#/features/staleGuard/staleGuardFeature';
export * from '#/features/tower/tower';
export * from '#/features/tower/towerService';
export * from '#/features/tower/towerRateLimit';
Expand Down
Loading
Loading