Skip to content
Open
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/fix-stale-runtime-binding-restore.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix restored sessions permanently losing tool and subagent access when their previous runtime no longer exists.
29 changes: 26 additions & 3 deletions packages/agent-core-v2/src/agent/runtimeBinding/agentRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Emitter, type Event } from '#/_base/event';
import type { IDisposable } from '#/_base/di/lifecycle';
import { LifecycleScope } from '#/app/scopes';
import type { Runtime, RuntimeBinding, RuntimeCapability, RuntimeLease } from '#/runtime/runtime';
import { runtimeStatusAllows, type RuntimeGenerationSnapshot } from '#/runtime/runtimeRegistry';
import { RuntimeError, runtimeStatusAllows, type RuntimeGenerationSnapshot } from '#/runtime/runtimeRegistry';
import {
IRuntimeResolver,
IWorkspaceInstanceManager,
Expand Down Expand Up @@ -76,7 +76,13 @@ export class AgentRuntimeService implements IAgentRuntimeService {
}

inspect(): Runtime {
return this.resolver.inspect(this.binding.current);
try {
return this.resolver.inspect(this.binding.current);
} catch (error) {
if (!(error instanceof RuntimeError) || error.code !== 'runtime.not_found') throw error;
this.heal(error);
return this.resolver.inspect(this.binding.current);
}
}

isAvailable(required: readonly RuntimeCapability[] = []): boolean {
Expand All @@ -89,7 +95,13 @@ export class AgentRuntimeService implements IAgentRuntimeService {
}

acquire(required: readonly RuntimeCapability[] = []): RuntimeLease {
return this.resolver.acquire(this.binding.current, required);
try {
return this.resolver.acquire(this.binding.current, required);
} catch (error) {
if (!(error instanceof RuntimeError) || error.code !== 'runtime.not_found') throw error;
this.heal(error);
return this.resolver.acquire(this.binding.current, required);
}
}

dispose(): void {
Expand All @@ -104,6 +116,17 @@ export class AgentRuntimeService implements IAgentRuntimeService {
this.changeEmitter.fire();
}

private heal(error: RuntimeError): void {
const current = this.binding.current;
if (current.runtimeId === 'local') throw error;
try {
this.binding.set({ workspaceId: current.workspaceId, runtimeId: 'local' });
} catch (fallbackError) {
error.cause = fallbackError;
throw error;
}
}

private bindRegistry(): void {
this.registrySubscription?.dispose();
const binding = this.binding.current;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest';

import { Emitter } from '#/_base/event';
import type { Event2 } from '#/app/event/event2';
import { AgentRuntimeService, snapshotAgentRuntimeBinding } from '#/agent/runtimeBinding/agentRuntime';
import { RuntimeSetBinding, runtimeBindingKey } from '#/agent/runtimeBinding/runtimeBindingOps';
import { AgentRuntimeBindingService, agentRuntimeBindingKey } from '#/agent/runtimeBinding/runtimeBindingService';
import { AgentStateService } from '#/agent/state/agentStateService';
import { FakeRuntime } from '#/runtime/fakeRuntime';
Expand Down Expand Up @@ -53,10 +55,25 @@ function setup() {
sessionScope: 'sessions/session',
cwd: '/workspace',
});
let restoreHook: ((context: undefined, next: () => Promise<void>) => Promise<void>) | undefined;
const dispatched: Event2[] = [];
const dispatcher = {
_serviceBrand: undefined,
dispatch: () => Promise.resolve(),
hooks: { onDidRestore: { register: () => ({ dispose: () => {} }) } },
dispatch: (event: Event2) => {
dispatched.push(event);
return Promise.resolve();
},
hooks: {
onDidRestore: {
register: (
_id: string,
hook: (context: undefined, next: () => Promise<void>) => Promise<void>,
) => {
restoreHook = hook;
return { dispose: () => {} };
},
},
},
} as unknown as IEventDispatcher;
const binding = new AgentRuntimeBindingService(
{
Expand All @@ -77,6 +94,11 @@ function setup() {
onDidChange: workspaceChanges.event,
get: () => ({ runtimes: registry }),
} as unknown as IWorkspaceInstanceManager;
const restore = async (replayed: RuntimeBinding): Promise<void> => {
state.set(runtimeBindingKey, replayed);
if (restoreHook === undefined) throw new Error('restore hook was not registered');
await restoreHook(undefined, async () => {});
};
return {
registry,
resolver,
Expand All @@ -85,6 +107,8 @@ function setup() {
local,
remote,
localRegistration,
dispatched,
restore,
workspaceChanges,
agentRuntime: new AgentRuntimeService(binding, resolver, workspaces),
};
Expand Down Expand Up @@ -230,4 +254,74 @@ describe('AgentRuntimeBindingService', () => {
local.setStatus('ready');
expect(changes).toHaveLength(1);
});

it('heals a stale restored binding through acquire and persists the fallback', async () => {
const { binding, dispatched, restore, agentRuntime } = setup();
await restore({ workspaceId: 'workspace', runtimeId: 'acp:session_gone' });

const firstLease = agentRuntime.acquire();
expect(firstLease.runtime.identity.runtimeId).toBe('local');
expect(binding.current).toEqual({ workspaceId: 'workspace', runtimeId: 'local' });
const secondLease = agentRuntime.acquire();
expect(secondLease.runtime.identity.runtimeId).toBe('local');
expect(dispatched).toEqual([
expect.objectContaining({
type: RuntimeSetBinding.type,
workspaceId: 'workspace',
runtimeId: 'local',
}),
]);
firstLease.dispose();
secondLease.dispose();
});

it('heals a stale restored binding through inspect', async () => {
const { binding, dispatched, restore, agentRuntime } = setup();
await restore({ workspaceId: 'workspace', runtimeId: 'acp:session_gone' });

expect(agentRuntime.inspect().identity.runtimeId).toBe('local');
expect(binding.current).toEqual({ workspaceId: 'workspace', runtimeId: 'local' });
expect(dispatched).toEqual([
expect.objectContaining({ type: RuntimeSetBinding.type, runtimeId: 'local' }),
]);
});

it('heals a stale restored binding through availability and persists the fallback', async () => {
const { binding, dispatched, restore, agentRuntime } = setup();
await restore({ workspaceId: 'workspace', runtimeId: 'acp:session_gone' });

expect(agentRuntime.isAvailable(['fs'])).toBe(true);
expect(binding.current).toEqual({ workspaceId: 'workspace', runtimeId: 'local' });
expect(dispatched).toEqual([
expect.objectContaining({ type: RuntimeSetBinding.type, runtimeId: 'local' }),
]);
const lease = agentRuntime.acquire();
expect(lease.runtime.identity.runtimeId).toBe('local');
lease.dispose();
});

it('preserves the stale binding and original error when the local fallback is missing', async () => {
const { binding, localRegistration, restore, agentRuntime } = setup();
const stale = { workspaceId: 'workspace', runtimeId: 'acp:session_gone' };
await localRegistration.remove();
await restore(stale);

let thrown: unknown;
try {
agentRuntime.acquire();
} catch (error) {
thrown = error;
}
expect(thrown).toEqual(
expect.objectContaining<Partial<RuntimeError>>({
code: 'runtime.not_found',
message: 'runtime acp:session_gone does not exist in workspace workspace',
cause: expect.objectContaining<Partial<RuntimeError>>({
code: 'runtime.not_found',
message: 'runtime local does not exist in workspace workspace',
}),
}),
);
expect(binding.current).toEqual(stale);
});
});