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

Fix a server crash when the first goal-mode prompt is submitted while the v2 agent is still starting.
5 changes: 5 additions & 0 deletions packages/agent-core-v2/src/agent/prompt/promptService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,11 @@ export class AgentPromptService implements IAgentPromptService {
if (turn === undefined) { this.pending.unshift(item); return; }
item.state = 'running'; item.launchedDeferred.resolve(turn); this.active = Object.assign(item, { turn });
void turn.result.then((result) => this.settle(item, result));
} catch {
item.state = 'failed';
item.launchedDeferred.resolve(undefined);
item.completionDeferred.resolve({ promptId: item.id, result: undefined, state: 'failed' });
this.publishCompleted(item.id, 'failed');
} finally {
this.launching = false;
if (this.active === undefined) void this.startNext();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@
* - The main agent is an ordinary agent whose only distinction is
* `agentId === 'main'`. Business operations (create / fork / run / lookup)
* treat it uniformly; the only main-specific surface is the
* `onDidCreateMain` event, fired via `notifyMainCreated` by the main
* bootstrapper so main-only capabilities subscribe without filtering every
* `onDidCreate`.
* `onDidCreateMain` event, fired idempotently via `notifyMainCreated` by the
* main bootstrapper so main-only capabilities subscribe without filtering
* every `onDidCreate`.
* - Creation is single-flight per explicit agent id, and readiness lookups
* return only settled handles.
* - `forkedFrom` is provenance only (a recorded value); business logic must
* not branch on it.
*/
Expand Down Expand Up @@ -148,18 +150,13 @@ export interface IAgentLifecycleService {
readonly onDidDispose: Event<string>;
/** Create an agent from zero (empty context). */
create(opts?: CreateAgentOptions): Promise<IAgentScopeHandle>;
whenReady(agentId: string): Promise<IAgentScopeHandle | undefined>;
/**
* Resolve the session/plugin MCP config and wait for the initial connection
* attempt to finish. Per-server failures are reflected in MCP status entries
* rather than rejecting this promise.
*/
ensureMcpReady(): Promise<void>;
/**
* Fire {@link onDidCreateMain} for the given handle. Called exactly once by
* the main-agent bootstrapper (`ensureMainAgent`) after main-only wirings
* are attached, so main-only capabilities can subscribe without filtering
* every {@link onDidCreate}. No other caller should invoke it.
*/
notifyMainCreated(handle: IAgentScopeHandle): void;
/**
* Fire {@link onDidStopAgentTask} for a mirrored run that has stopped.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
/**
* `agentLifecycle` domain (L6) — `IAgentLifecycleService` implementation.
*
* Creates and tracks the session's agents as child scopes in a flat registry.
* Seeds each agent's identity through `agent` scopeContext, wires per-agent
* wire records and the wire state machine, the blob store, and MCP, and
* registers the agent in the session registry. Bound at Session scope.
* Creates and tracks the session's agents as child scopes in a flat registry,
* serializing same-id bootstrap and dropping incomplete handles after startup
* failure. Seeds each agent's identity through `agent` scopeContext, wires
* per-agent wire records and the wire state machine, the blob store, and MCP,
* and registers the agent in the session registry. Bound at Session scope.
*
* No agent id is special here: the main agent is created by its bootstrappers
* as `create({ agentId: 'main' })` (see `mainAgent.ts`), and `fork` requires
Expand Down Expand Up @@ -104,6 +105,8 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
private mcpManager: McpConnectionManager | undefined;
private mcpInitialLoad: Promise<void> | undefined;
private readonly interactionBusDisposables = new Map<string, IDisposable>();
private readonly creating = new Map<string, Promise<IAgentScopeHandle>>();
private readonly mainCreatedHandles = new WeakSet<IAgentScopeHandle>();

get onDidCreate() {
return this.onDidCreateEmitter.event;
Expand Down Expand Up @@ -170,8 +173,10 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
}

async create(opts: CreateAgentOptions = {}): Promise<IAgentScopeHandle> {
this.assertCanCreate();
const agentId = opts.agentId ?? `agent-${nextAgentId++}`;
const creating = this.creating.get(agentId);
if (creating !== undefined) return creating;
this.assertCanCreate();
const mcpManager = this.getMcpManager();
const mcpReady = this.ensureMcpReady();
// Per-agent homedir → the wire-record persistence key (`hashKey(homedir)`).
Expand All @@ -194,25 +199,67 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
{ extra: this.buildAgentScopeExtras({ agentId, agentHomedir, agentScope, mcpManager }) },
) as IAgentScopeHandle;
this.handles.set(agentId, handle);
// Record the agent in the session registry so a closed-session fork can
// enumerate every agent and relocate its wire log.
await this.sessionMetadata.registerAgent(agentId, {
homedir: agentHomedir,
type: agentId === 'main' ? 'main' : 'sub',
parentAgentId: agentId === 'main' ? undefined : 'main',
forkedFrom: opts.forkedFrom,
labels: opts.labels,
const created = this.bootstrapAgent(handle, agentId, opts, {
agentHomedir,
agentScope,
mcpReady,
});
this.onDidCreateEmitter.fire(handle);
this.igniteEagerServices(handle);
await mcpReady;
await this.ensureWireMetadata(handle, agentScope);
await this.bindBootstrap(handle, opts);
// Bootstrap (eager tool / hook / MCP setup, wire metadata, profile binding)
// is complete: drive the activity kernel `initializing → idle` so the agent
// can admit turns. Until this point `begin` rejects with `activity.initializing`.
handle.accessor.get(IAgentActivityService).markReady();
return handle;
this.creating.set(agentId, created);
try {
return await created;
} finally {
if (this.creating.get(agentId) === created) this.creating.delete(agentId);
}
}

async whenReady(agentId: string): Promise<IAgentScopeHandle | undefined> {
const creating = this.creating.get(agentId);
if (creating === undefined) return this.handles.get(agentId);
try {
return await creating;
} catch {
return undefined;
}
}

private async bootstrapAgent(
handle: IAgentScopeHandle,
agentId: string,
opts: CreateAgentOptions,
bootstrap: {
readonly agentHomedir: string;
readonly agentScope: string;
readonly mcpReady: Promise<void>;
},
): Promise<IAgentScopeHandle> {
try {
// Record the agent in the session registry so a closed-session fork can
// enumerate every agent and relocate its wire log.
await this.sessionMetadata.registerAgent(agentId, {
homedir: bootstrap.agentHomedir,
type: agentId === 'main' ? 'main' : 'sub',
parentAgentId: agentId === 'main' ? undefined : 'main',
forkedFrom: opts.forkedFrom,
labels: opts.labels,
});
this.onDidCreateEmitter.fire(handle);
this.igniteEagerServices(handle);
await bootstrap.mcpReady;
await this.ensureWireMetadata(handle, bootstrap.agentScope);
await this.bindBootstrap(handle, opts);
// Bootstrap (eager tool / hook / MCP setup, wire metadata, profile binding)
// is complete: drive the activity kernel `initializing → idle` so the agent
// can admit turns. Until this point `begin` rejects with `activity.initializing`.
handle.accessor.get(IAgentActivityService).markReady();
return handle;
} catch (error) {
if (this.handles.get(agentId) === handle) this.handles.delete(agentId);
try {
handle.dispose();
} catch {}
this.onDidDisposeEmitter.fire(agentId);
throw error;
}
}

private assertCanCreate(): void {
Expand Down Expand Up @@ -355,6 +402,8 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
}

notifyMainCreated(handle: IAgentScopeHandle): void {
if (this.mainCreatedHandles.has(handle)) return;
this.mainCreatedHandles.add(handle);
this.onDidCreateMainEmitter.fire(handle);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export async function ensureMainAgent(
): Promise<IAgentScopeHandle> {
const agents = session.accessor.get(IAgentLifecycleService);
session.accessor.get(ISessionCronService);
const existing = agents.getHandle(MAIN_AGENT_ID);
const existing = await agents.whenReady(MAIN_AGENT_ID);
if (existing !== undefined) return existing;
const permissionMode =
opts?.permissionMode ??
Expand Down
23 changes: 22 additions & 1 deletion packages/agent-core-v2/test/agent/prompt/promptService.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { describe, expect, it, onTestFinished } from 'vitest';
/**
* Scenario: per-agent prompt scheduling and launch-failure settlement.
*
* Exercises `IAgentPromptService` through DI with controlled context, loop,
* wire, compaction, and tool-execution collaborators.
* Run: `pnpm exec vitest run packages/agent-core-v2/test/agent/prompt/promptService.test.ts`.
*/

import { describe, expect, it, onTestFinished, vi } from 'vitest';

import { DisposableStore } from '#/_base/di/lifecycle';
import { createServices } from '#/_base/di/test';
Expand All @@ -14,6 +22,7 @@ import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminde
import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
import { IEventBus } from '#/app/event/eventBus';
import { EventBusService } from '#/app/event/eventBusService';
import { ErrorCodes, Error2 } from '#/errors';
import { createHooks } from '#/hooks';
import { IAgentWireService } from '#/wire/tokens';

Expand Down Expand Up @@ -106,4 +115,16 @@ describe('AgentPromptService', () => {
const handle = await prompt.enqueue({ message: message('blocked') });
await expect(handle.completion).resolves.toMatchObject({ state: 'blocked' });
});

it('settles the prompt as failed when the loop throws on launch', async () => {
const { prompt, loop } = harness();
vi.spyOn(loop, 'enqueue').mockImplementation(() => {
throw new Error2(ErrorCodes.ACTIVITY_INITIALIZING, 'Agent is still restoring');
});
const handle = await prompt.enqueue({ id: 'prompt-x', message: message('hello') });
expect(handle.state).toBe('failed');
await expect(handle.launched).resolves.toBeUndefined();
await expect(handle.completion).resolves.toMatchObject({ state: 'failed', result: undefined });
expect(prompt.list()).toEqual({ active: undefined, pending: [] });
});
});
1 change: 1 addition & 0 deletions packages/agent-core-v2/test/app/gateway/gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ describe('RestGateway', () => {
throw new Error('not implemented in test');
},
getHandle: (id) => (id === 'main' ? agentHandle : undefined),
whenReady: (id) => Promise.resolve(id === 'main' ? agentHandle : undefined),
list: () => [agentHandle],
remove: () => Promise.resolve(),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ function buildService(opts: {
accessor: {
get: (token: unknown): unknown => {
if (token === IAgentLifecycleService) {
return { getHandle: (id: string) => (id === MAIN_AGENT_ID ? mainHandle : undefined) };
return {
getHandle: (id: string) => (id === MAIN_AGENT_ID ? mainHandle : undefined),
whenReady: (id: string) => Promise.resolve(id === MAIN_AGENT_ID ? mainHandle : undefined),
};
}
if (token === ISessionCronService) return {};
throw new Error('unexpected session service access');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,7 @@ function stubAgentLifecycle(agents: readonly IAgentScopeHandle[]): IAgentLifecyc
throw new Error('run should not be called by session export');
},
getHandle: (agentId) => agents.find((agent) => agent.id === agentId),
whenReady: (agentId) => Promise.resolve(agents.find((agent) => agent.id === agentId)),
list: () => agents,
remove: async () => {},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ function agentLifecycleStub(): IAgentLifecycleService {
onDidCreateMain: () => ({ dispose: () => {} }),
onDidDispose: () => ({ dispose: () => {} }),
create: () => Promise.reject(new Error('not implemented')),
whenReady: () => Promise.resolve(undefined),
notifyMainCreated: () => {},
notifyAgentTaskStopped: () => {},
ensureMcpReady: () => Promise.resolve(),
Expand Down Expand Up @@ -267,6 +268,7 @@ function agentLifecycleWithMainStub(): IAgentLifecycleService {
return {
...agentLifecycleStub(),
getHandle: (id) => (id === MAIN_AGENT_ID ? main : undefined),
whenReady: (id) => Promise.resolve(id === MAIN_AGENT_ID ? main : undefined),
};
}

Expand Down Expand Up @@ -311,6 +313,7 @@ function agentLifecycleCapturingPlanSpy(opts: { mainPreexists?: boolean } = {}):
const lifecycle: IAgentLifecycleService = {
...agentLifecycleStub(),
getHandle: (id: string) => (id === MAIN_AGENT_ID ? mainHandle : undefined),
whenReady: (id: string) => Promise.resolve(id === MAIN_AGENT_ID ? mainHandle : undefined),
create,
};
return { lifecycle, enter, create };
Expand Down
Loading
Loading