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/fix-restore-crash-loop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix repeated server crashes when resuming a session that was interrupted in the middle of a turn.
1 change: 1 addition & 0 deletions packages/agent-core-v2/src/_base/di/instantiation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ export interface IInstantiationService {
provideAll(entries: ReadonlyArray<ProvideAllEntry>): void;
unprovide<T>(id: ServiceIdentifier<T>): void;
dispose(): void;
disposeAsync(): Promise<void>;
}

export const IInstantiationService: ServiceIdentifier<IInstantiationService> =
Expand Down
24 changes: 21 additions & 3 deletions packages/agent-core-v2/src/_base/di/instantiationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,10 @@ export class InstantiationService implements IInstantiationService {
return this._ledger.register(disposer, label);
}

anchorKernelFinalizer(disposer: Disposer, label: string): LedgerEntry {
return this._ledger.registerFinalizer(disposer, label);
}

private _getFiberHost(): FiberHost {
this._fiberHost ??= {
mintUid: () => ++this._root()._nextUnitUid,
Expand Down Expand Up @@ -648,18 +652,31 @@ export class InstantiationService implements IInstantiationService {
return new InstantiationService(services, this._strict, this, this._enableTracing);
}

private _disposePromise: Promise<void> | undefined;

dispose(): void {
void this.disposeAsync();
}

disposeAsync(): Promise<void> {
this._disposePromise ??= this.disposeCore();
return this._disposePromise;
}

private disposeCore(): Promise<void> {
if (this._disposed) {
return;
return Promise.resolve();
Comment on lines +661 to +668

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Return the in-flight promise from repeated disposal

Fresh evidence in the new async-disposal implementation is that a second disposeAsync() call returns an already-resolved promise while the first ledger teardown may still be suspended. If a parent scope starts disposal and another lifecycle path then awaits the same handle, that caller proceeds before contributed units and finalizers finish, reintroducing the teardown race this change is intended to close. Cache and return the first teardown promise for every subsequent call.

AGENTS.md reference: packages/agent-core-v2/AGENTS.md:L24-L27

Useful? React with 👍 / 👎.

}
this._disposed = true;

const childTeardowns: Promise<void>[] = [];
let teardown: void | Promise<void> = undefined;
try {
for (const child of Array.from(this._children)) {
child.dispose();
childTeardowns.push(child.disposeAsync());
}
this._children.clear();
void this._ledger.teardown('scope-close');
teardown = this._ledger.teardown('scope-close');
Comment on lines 675 to +679

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Dispose child scopes before tearing down the parent

Fresh evidence in this revision is that Promise.all only delays resolution: this loop starts each child disposal, but line 679 immediately starts the parent ledger while an asynchronous child can still be suspended—the new child test even observes parent-finalizer before child-finalizer. When a child disposer uses a parent-scoped service, the parent can therefore dispose that resource underneath it; await all child teardowns before starting _ledger.teardown() rather than running both concurrently.

AGENTS.md reference: packages/agent-core-v2/AGENTS.md:L24-L27

Useful? React with 👍 / 👎.

this._services.dispose();
this.cascade.dispose();
for (const view of this._collectionViews.values()) {
Expand All @@ -674,6 +691,7 @@ export class InstantiationService implements IInstantiationService {
this._parent._children.delete(this);
}
}
return Promise.all([...childTeardowns, Promise.resolve(teardown)]).then(() => undefined);
}

private _createInstance<T>(ctor: any, args: unknown[], _trace: Trace, unit?: {
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-core-v2/src/_base/di/scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export interface IScopeHandle<K extends ScopeKind = ScopeKind> {
readonly id: string;
readonly kind: K;
readonly accessor: ServicesAccessor;
dispose(): void;
dispose(): void | Promise<void>;
}

export type IAppScopeHandle = IScopeHandle<'app'>;
Expand Down Expand Up @@ -171,7 +171,7 @@ export function createScopedChildHandle(
get: <T>(serviceId: ServiceIdentifier<T>): T =>
child.invokeFunction((a) => a.get(serviceId)),
};
return { id, kind, accessor, dispose: () => child.dispose() };
return { id, kind, accessor, dispose: () => child.disposeAsync() };
}

export class Scope implements IDisposable {
Expand Down
18 changes: 9 additions & 9 deletions packages/agent-core-v2/src/_base/di/scopeUnits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function watchScopeUnits(container: InstantiationService, kind: ScopeKind
const foldLedger = new Ledger(`scope-units:${kind}`);
container.anchorKernelEntry((reason) => foldLedger.teardown(reason), `scope-units:${kind}`);

const materialized = new Map<number, () => void>();
const materialized = new Map<number, () => void | Promise<void>>();

const materialize = (record: StoredRecord): void => {
const recipe = record.value as ServiceRecipe;
Expand All @@ -32,7 +32,7 @@ export function watchScopeUnits(container: InstantiationService, kind: ScopeKind
if (isClassRecipe(recipe)) {
const instance = host.constructService(recipe, undefined) as Partial<IDisposable>;
unitLedger.register(() => {
instance.dispose?.();
return instance.dispose?.();
}, `unit:${name}`);
} else {
const facade = new FiberRuntime(
Expand All @@ -57,23 +57,23 @@ export function watchScopeUnits(container: InstantiationService, kind: ScopeKind
}

let retracted = false;
const retract = (): void => {
const retract = (): void | Promise<void> => {
if (retracted) {
return;
return undefined;
}
retracted = true;
materialized.delete(record.id);
void unitLedger.teardown('unload');
return unitLedger.teardown('unload');
};
if (!record.providerBook.isActive) {
retract();
void retract();
return;
}
record.providerBook.register(() => {
retract();
void retract();
}, `scope-units:${kind}`);
foldLedger.register(() => {
retract();
return retract();
}, `record:${name}`);
materialized.set(record.id, retract);
};
Expand All @@ -92,7 +92,7 @@ export function watchScopeUnits(container: InstantiationService, kind: ScopeKind
}
for (const [id, retract] of Array.from(materialized)) {
if (!seen.has(id)) {
retract();
void retract();
Comment on lines 93 to +95

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Await teardown triggered by contribution removal

When a Feature is hot-unprovided or replaced, reconcile() now starts the potentially asynchronous retract() but discards its promise. Consequently IFeatureManager.unprovideUnit() can complete and a replacement scope unit can materialize while the old unit's asynchronous disposer is still running, allowing old cleanup to race with the replacement. Preserve and await the retraction through the provider teardown instead of fire-and-forgetting it.

AGENTS.md reference: packages/agent-core-v2/AGENTS.md:L24-L27

Useful? React with 👍 / 👎.

}
}
};
Expand Down
4 changes: 3 additions & 1 deletion packages/agent-core-v2/src/_base/di/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ export function createScopedTestHost(appStubs: ScopeSeed = []): ScopedTestHost {
id: handle.id,
kind: handle.kind,
accessor: handle.accessor,
dispose: () => handle.dispose(),
dispose: () => {
void handle.dispose();
},
} as Scope;
}
return app.createChild(kind, id, { seeds: stubs });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,14 @@ export class TestInstantiationService extends InstantiationService implements ID
super.dispose();
}
}

public override disposeAsync(): Promise<void> {
sinon.restore();
if (this._properDispose) {
return super.disposeAsync();
}
return Promise.resolve();
}
}

interface SinonOptions {
Expand Down
13 changes: 11 additions & 2 deletions packages/agent-core-v2/src/_base/lifecycle/ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ export class Ledger {
return this._push({ label, kind: 'disposer', active: true, run: disposer });
}

registerFinalizer(disposer: Disposer, label: string = 'finalizer'): LedgerEntry {
this._assertActive('registerFinalizer');
return this._push({ label, kind: 'disposer', active: true, run: disposer }, true);
}

effect(body: EffectBody, label: string = 'effect'): LedgerEntry {
this._assertActive('effect');
const out = body();
Expand Down Expand Up @@ -151,11 +156,15 @@ export class Ledger {
return infos;
}

private _push(record: EntryRecord): LedgerEntry {
private _push(record: EntryRecord, front = false): LedgerEntry {
if (Ledger.captureStacks) {
record.stack = new Error('Ledger registration').stack;
}
this._records.push(record);
if (front) {
this._records.unshift(record);
} else {
this._records.push(record);
}
return {
label: record.label,
get disposed() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
eventBus?.activateAgent(agent);
let managed: ManagedAgent | undefined;
let didCreate = false;
let finalizerArmed = false;
try {
const handle = createScopedChildHandle(
this.instantiation,
Expand All @@ -237,13 +238,17 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
}],
],
configureContainer: (container) => {
container.anchorKernelFinalizer(() => {
eventBus?.deactivateAgent(agent);
}, 'agent-event-bus-deactivate');
Comment on lines +241 to +243

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 Let the finalizer own create-failure deactivation

When a post-creation step such as restore() or tool activation rejects while an Agent-scope unit has asynchronous teardown, the catch calls managed.handle.dispose() and then still calls eventBus?.deactivateAgent(agent) at line 283. That immediate call defeats this finalizer on the create-failure path: a unit publishing during the remaining drain again sees no active lifecycle context and can produce the same unhandled rejection this change is meant to prevent. Keep the direct deactivation only for failures before the finalizer is installed, and otherwise let scope teardown perform it.

AGENTS.md reference: packages/agent-core-v2/AGENTS.md:L16-L16

Useful? React with 👍 / 👎.

Comment on lines +241 to +243

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Await contributed-unit teardown before deactivating context

Fresh evidence in the current revision shows that this finalizer still does not cover asynchronous ScopeUnits teardown: although it now runs after the scope-units anchor, scopeUnits.ts's retract() calls void unitLedger.teardown('unload') and returns immediately. When a contributed Agent unit has an async disposer, the container ledger therefore advances to this deactivation while that disposer is suspended, so a later agent-domain publication fails the strict lifecycle-context check and can again become an unhandled rejection in non-kap hosts. Make the fold return and await each unit teardown before this finalizer runs.

AGENTS.md reference: packages/agent-core-v2/AGENTS.md:L24-L26

Useful? React with 👍 / 👎.

Comment on lines +241 to +243

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Await agent-scope teardown before completing removal

Fresh evidence in this revision is that scopeUnits now propagates the contributed unit's promise, but IScopeHandle.dispose() remains synchronous and InstantiationService.dispose() still fire-and-forgets the ledger drain. Consequently this finalizer can execute after remove() has deleted the roster entry and emitted onDidClose; if the caller recreates the same agent ID while an old async disposer is suspended, activateAgent replaces the old context and a later teardown publication from AgentActivityView fails the event bus identity check, becoming an unobserved rejection through its void dispatcher.dispatch(...). Make scope disposal awaitable and keep the ID unavailable until this finalizer completes.

AGENTS.md reference: packages/agent-core-v2/AGENTS.md:L26-L26

Useful? React with 👍 / 👎.

finalizerArmed = true;
this.adopt({
id: agentId,
kind: LifecycleScope.Agent,
accessor: {
get: (id) => container.invokeFunction((accessor) => accessor.get(id)),
},
dispose: () => { container.dispose(); },
dispose: () => container.disposeAsync(),
});
managed = this.roster.get(agentId);
},
Expand Down Expand Up @@ -274,10 +279,10 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
await managed.runtimeSet.close().catch(() => undefined);
managed.killSpace();
try {
managed.handle.dispose();
await managed.handle.dispose();
} catch { }
}
eventBus?.deactivateAgent(agent);
if (!finalizerArmed) eventBus?.deactivateAgent(agent);
if (didCreate) this.onDidCloseEmitter.fire(agent);
throw error;
}
Expand Down Expand Up @@ -446,10 +451,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
await Promise.all([loop.settled(), compactionSettled, prompt.drain(reason)]);
await managed.runtimeSet.close();
managed.killSpace();
handle.dispose();
this.instantiation.invokeFunction((accessor) =>
(accessor.get(ISessionEventBus) as ISessionEventBus | undefined)?.deactivateAgent(agent),
);
await handle.dispose();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Block recreation until the old agent teardown completes

Fresh evidence in the current revision is that retaining the roster entry through this await still does not reserve the ID: create() at lines 181–183 proceeds when the existing entry has closing === true. If a caller recreates the same agent ID while an asynchronous scope disposer is suspended here, doCreate() activates the new context, so a later publication from the old AgentActivityView fails the event-bus identity guard and can become an unhandled rejection. Reject or queue creation while the old removal promise is in flight.

AGENTS.md reference: packages/agent-core-v2/AGENTS.md:L24-L27

Useful? React with 👍 / 👎.

if (this.roster.get(agent.agentId) === managed) this.roster.delete(agent.agentId);
this.onDidCloseEmitter.fire(agent);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
const sessionDir = handle.accessor.get(ISessionContext).sessionDir;
this.sessions.delete(sessionId);
await this.drainAgents(handle).catch(() => {});
handle.dispose();
void handle.dispose();
await this.hostFs.remove(sessionDir).catch(() => {});
throw error;
}
Expand Down Expand Up @@ -282,7 +282,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
this.pluginAgentProfileLoader.ready,
]);
} catch (error) {
handle.dispose();
void handle.dispose();
void this.explicitAgentProfileLoader.reload().catch(() => undefined);
throw error;
}
Expand Down Expand Up @@ -364,7 +364,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
await this.announceCreated({ sessionId, handle, source: 'resume' });
} catch (error) {
this.sessions.delete(sessionId);
handle.dispose();
void handle.dispose();
throw error;
}
return handle;
Expand All @@ -387,7 +387,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
await this.appendLogStore.drainRetirements();
await drainSessionMetadataWrites();
await this.indexMirror.drain();
handle.dispose();
void handle.dispose();
await drainLogCloses();
this._onDidCloseSession.fire({ sessionId });
}
Expand All @@ -408,7 +408,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
this.sessions.delete(sessionId);
await drainSessionMetadataWrites();
await this.indexMirror.drain();
handle.dispose();
void handle.dispose();
await drainLogCloses();
this._onDidArchiveSession.fire({ sessionId });
}
Expand Down Expand Up @@ -589,7 +589,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
}
if (target !== undefined) {
try {
target.dispose();
void target.dispose();
} catch {
}
}
Expand Down
58 changes: 58 additions & 0 deletions packages/agent-core-v2/test/_base/di/child.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,64 @@ describe('InstantiationService.createChild', () => {
expect(events).toEqual(['disposed']);
});

it('repeated disposeAsync returns the in-flight teardown promise', async () => {
const events: string[] = [];
let releaseGate!: () => void;
const ix = new InstantiationService(new ServiceCollection());
ix.anchorKernelEntry(() => {
events.push('finalizer');
}, 'finalizer');
ix.anchorKernelEntry(() => {
events.push('gate-entered');
return new Promise<void>((resolve) => {
releaseGate = resolve;
});
}, 'gate');

const first = ix.disposeAsync();
const second = ix.disposeAsync();
let secondSettled = false;
void second.then(() => {
secondSettled = true;
});
await new Promise((resolve) => setTimeout(resolve, 10));
expect(events).toEqual(['gate-entered']);
expect(secondSettled).toBe(false);
releaseGate();
await Promise.all([first, second]);
expect(events).toEqual(['gate-entered', 'finalizer']);
});

it('disposeAsync awaits asynchronous child container teardown', async () => {
const events: string[] = [];
let releaseChildGate!: () => void;
const parent = new InstantiationService(new ServiceCollection());
const child = parent.createChild(new ServiceCollection()) as InstantiationService;
child.anchorKernelEntry(() => {
events.push('child-finalizer');
}, 'child-finalizer');
child.anchorKernelEntry(() => {
events.push('child-gate-entered');
return new Promise<void>((resolve) => {
releaseChildGate = resolve;
});
}, 'child-gate');
parent.anchorKernelEntry(() => {
events.push('parent-finalizer');
}, 'parent-finalizer');

let settled = false;
const disposal = parent.disposeAsync().then(() => {
settled = true;
});
await new Promise((resolve) => setTimeout(resolve, 10));
expect(events).toEqual(['child-gate-entered', 'parent-finalizer']);
expect(settled).toBe(false);
releaseChildGate();
await disposal;
expect(events).toEqual(['child-gate-entered', 'parent-finalizer', 'child-finalizer']);
});

it('parent dispose propagates to children', () => {
const events: string[] = [];
interface IParentSvc {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ function makeHost(
});
const disposeHost = host.dispose.bind(host);
host.dispose = () => {
workspaceHandle.dispose();
void workspaceHandle.dispose();
disposeHost();
};
return { host, workspace: workspaceHandle, config };
Expand Down
Loading
Loading