From ba448d50c5099c85e48478e49c1f3d7f0a427607 Mon Sep 17 00:00:00 2001 From: chengluyu <2239547+chengluyu@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:06:37 +0800 Subject: [PATCH 1/2] fix(agent-core-v2): serialize agent startup --- .changeset/fix-v2-goal-mode-startup-crash.md | 5 + .../src/agent/prompt/promptService.ts | 10 ++ .../session/agentLifecycle/agentLifecycle.ts | 18 +++- .../agentLifecycle/agentLifecycleService.ts | 94 +++++++++++++++---- .../src/session/agentLifecycle/mainAgent.ts | 2 +- .../test/agent/prompt/promptService.test.ts | 19 +++- .../test/app/gateway/gateway.test.ts | 1 + .../app/messageLegacy/messageLegacy.test.ts | 5 +- .../app/sessionExport/sessionExport.test.ts | 1 + .../sessionLifecycle/sessionLifecycle.test.ts | 3 + .../agentLifecycle/agentLifecycle.test.ts | 86 ++++++++++++++++- .../sessionActivity/sessionActivity.test.ts | 1 + .../test/session/swarm/sessionSwarm.test.ts | 1 + .../test/session/todo/sessionTodo.test.ts | 1 + .../workspaceCommand/workspaceCommand.test.ts | 1 + packages/agent-core-v2/test/tool/tool.test.ts | 1 + 16 files changed, 219 insertions(+), 30 deletions(-) create mode 100644 .changeset/fix-v2-goal-mode-startup-crash.md diff --git a/.changeset/fix-v2-goal-mode-startup-crash.md b/.changeset/fix-v2-goal-mode-startup-crash.md new file mode 100644 index 0000000000..ab79ecc386 --- /dev/null +++ b/.changeset/fix-v2-goal-mode-startup-crash.md @@ -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. diff --git a/packages/agent-core-v2/src/agent/prompt/promptService.ts b/packages/agent-core-v2/src/agent/prompt/promptService.ts index bfe7e9fddf..62ced993e3 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptService.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptService.ts @@ -187,6 +187,16 @@ 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 { + // Every caller fires `void this.startNext()`, so a launch failure (a + // throwing hook, or the loop rejecting the turn — e.g. the activity + // lane still `initializing` or already `disposed`) must never escape + // as an unhandled rejection. Settle the prompt as failed so + // `enqueue`'s waiters resolve and the queue keeps draining. + 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(); diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts index a20594136f..b2b8776517 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts @@ -148,6 +148,16 @@ export interface IAgentLifecycleService { readonly onDidDispose: Event; /** Create an agent from zero (empty context). */ create(opts?: CreateAgentOptions): Promise; + /** + * Await any in-flight `create(agentId)` bootstrap and return the settled + * handle (`undefined` when the agent does not exist, or its creation + * failed). `create` registers the handle before its async bootstrap + * finishes, so a concurrent `getHandle` can observe a half-initialized + * agent whose activity lane is still `initializing`; callers that + * auto-materialize an agent (e.g. `ensureMainAgent`) must resolve through + * here instead. + */ + whenReady(agentId: string): Promise; /** * Resolve the session/plugin MCP config and wait for the initial connection * attempt to finish. Per-server failures are reflected in MCP status entries @@ -155,10 +165,10 @@ export interface IAgentLifecycleService { */ ensureMcpReady(): Promise; /** - * 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. + * Idempotently fire {@link onDidCreateMain} for the given handle after + * main-only wirings are attached, so main-only capabilities can subscribe + * without filtering every {@link onDidCreate}. No caller other than the + * main-agent bootstrapper (`ensureMainAgent`) should invoke it. */ notifyMainCreated(handle: IAgentScopeHandle): void; /** diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index ffc108af2e..6a09549fac 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -104,6 +104,8 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle private mcpManager: McpConnectionManager | undefined; private mcpInitialLoad: Promise | undefined; private readonly interactionBusDisposables = new Map(); + private readonly creating = new Map>(); + private readonly mainCreatedHandles = new WeakSet(); get onDidCreate() { return this.onDidCreateEmitter.event; @@ -170,8 +172,10 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle } async create(opts: CreateAgentOptions = {}): Promise { - 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)`). @@ -194,25 +198,75 @@ 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 { + 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; + }, + ): Promise { + 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) { + // A failed bootstrap must not strand a half-initialized agent in the + // registry: its activity lane never leaves `initializing`, so every + // later lookup would hand out an agent that rejects all turns. Drop the + // broken handle (and dispose its scope best-effort) so the next + // creation attempt starts clean. + if (this.handles.get(agentId) === handle) this.handles.delete(agentId); + try { + handle.dispose(); + } catch { + // Disposal of a partially constructed scope must not mask the + // bootstrap failure. + } + this.onDidDisposeEmitter.fire(agentId); + throw error; + } } private assertCanCreate(): void { @@ -355,6 +409,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); } diff --git a/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts b/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts index b8cb2da31e..8be16f182c 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts @@ -47,7 +47,7 @@ export async function ensureMainAgent( ): Promise { 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 ?? diff --git a/packages/agent-core-v2/test/agent/prompt/promptService.test.ts b/packages/agent-core-v2/test/agent/prompt/promptService.test.ts index 387c9a7795..3ce70eb262 100644 --- a/packages/agent-core-v2/test/agent/prompt/promptService.test.ts +++ b/packages/agent-core-v2/test/agent/prompt/promptService.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, onTestFinished } from 'vitest'; +import { describe, expect, it, onTestFinished, vi } from 'vitest'; import { DisposableStore } from '#/_base/di/lifecycle'; import { createServices } from '#/_base/di/test'; @@ -14,6 +14,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'; @@ -106,4 +107,20 @@ 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(); + // The loop rejects a launch synchronously when the activity lane is not + // idle (e.g. `initializing` before bootstrap finished, or `disposed`). + // The failure must settle the prompt instead of escaping the + // fire-and-forget `startNext` as an unhandled rejection. + 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: [] }); + }); }); diff --git a/packages/agent-core-v2/test/app/gateway/gateway.test.ts b/packages/agent-core-v2/test/app/gateway/gateway.test.ts index 7907296455..da4dae342f 100644 --- a/packages/agent-core-v2/test/app/gateway/gateway.test.ts +++ b/packages/agent-core-v2/test/app/gateway/gateway.test.ts @@ -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(), }; diff --git a/packages/agent-core-v2/test/app/messageLegacy/messageLegacy.test.ts b/packages/agent-core-v2/test/app/messageLegacy/messageLegacy.test.ts index d99230ab63..557dc90bf6 100644 --- a/packages/agent-core-v2/test/app/messageLegacy/messageLegacy.test.ts +++ b/packages/agent-core-v2/test/app/messageLegacy/messageLegacy.test.ts @@ -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'); diff --git a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts index 5d14bb8fd1..c55a773e63 100644 --- a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts +++ b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts @@ -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 () => {}, }; diff --git a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts index 5fc2d411a9..ce949c95b9 100644 --- a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts @@ -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(), @@ -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), }; } @@ -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 }; diff --git a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts index fa850256b8..ea868c4021 100644 --- a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts +++ b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts @@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; +import { type ISessionScopeHandle, LifecycleScope } from '#/_base/di/scope'; import { TestInstantiationService } from '#/_base/di/test'; import { Event } from '#/_base/event'; import { type McpServerConfig } from '#/agent/mcp/config-schema'; @@ -19,8 +20,10 @@ import { McpConnectionManager } from '#/agent/mcp/connection-manager'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { AgentLifecycleService } from '#/session/agentLifecycle/agentLifecycleService'; +import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent'; +import { ISessionCronService } from '#/session/cron/sessionCronService'; import '#/activity/agentActivityService'; -import { ISessionActivityKernel } from '#/activity/activity'; +import { IAgentActivityService, ISessionActivityKernel } from '#/activity/activity'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; import { IAgentBlobService } from '#/agent/blob/agentBlobService'; @@ -125,7 +128,7 @@ function stubBlobPassThrough(ix: TestInstantiationService): void { describe('AgentLifecycleService', () => { let disposables: DisposableStore; let ix: TestInstantiationService; - let registerAgent: ReturnType; + let registerAgent: ReturnType>; let atomicDocs: Map; let permissionModeSetMode: ReturnType; @@ -139,7 +142,7 @@ describe('AgentLifecycleService', () => { ix.stub(IAppendLogStore, recordingAppendLog().store); ix.stub(ISessionActivityKernel, stubSessionActivityKernel()); stubBlobPassThrough(ix); - registerAgent = vi.fn(() => Promise.resolve()); + registerAgent = vi.fn().mockResolvedValue(undefined); atomicDocs = new Map(); ix.stub(ISessionContext, { _serviceBrand: undefined, @@ -156,7 +159,7 @@ describe('AgentLifecycleService', () => { update: () => Promise.resolve(), setTitle: () => Promise.resolve(), setArchived: () => Promise.resolve(), - registerAgent: registerAgent as ISessionMetadata['registerAgent'], + registerAgent, }); ix.stub(IBootstrapService, { _serviceBrand: undefined, @@ -420,6 +423,81 @@ describe('AgentLifecycleService', () => { expect(settled).toBe(true); }); + it('whenReady waits for an in-flight creation to finish bootstrap', async () => { + let releaseRegister!: () => void; + registerAgent.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseRegister = resolve; + }), + ); + const svc = ix.get(IAgentLifecycleService); + const create = svc.create({ agentId: 'main' }); + + const early = svc.getHandle('main'); + expect(early).toBeDefined(); + expect(early!.accessor.get(IAgentActivityService).lane()).toBe('initializing'); + + const ready = svc.whenReady('main'); + await expect( + Promise.race([ready.then(() => 'ready'), Promise.resolve('pending')]), + ).resolves.toBe('pending'); + + releaseRegister(); + const handle = await ready; + await create; + expect(handle).toBe(early); + expect(handle!.accessor.get(IAgentActivityService).lane()).toBe('idle'); + }); + + it('ensureMainAgent returns one handle when calls start concurrently', async () => { + ix.stub(ISessionCronService, { _serviceBrand: undefined }); + const session: ISessionScopeHandle = { + id: 'sess_test', + kind: LifecycleScope.Session, + accessor: ix, + dispose: () => {}, + }; + + const [first, second] = await Promise.all([ + ensureMainAgent(session), + ensureMainAgent(session), + ]); + + expect(first).toBe(second); + expect(registerAgent).toHaveBeenCalledTimes(1); + expect(ix.get(IAgentLifecycleService).list()).toEqual([first]); + }); + + it('notifyMainCreated emits once when the same handle is announced repeatedly', async () => { + const svc = ix.get(IAgentLifecycleService); + const main = await svc.create({ agentId: 'main' }); + const announced: string[] = []; + disposables.add(svc.onDidCreateMain((handle) => announced.push(handle.id))); + + svc.notifyMainCreated(main); + svc.notifyMainCreated(main); + + expect(announced).toEqual(['main']); + }); + + it('whenReady resolves undefined for an unknown agent', async () => { + const svc = ix.get(IAgentLifecycleService); + await expect(svc.whenReady('missing')).resolves.toBeUndefined(); + }); + + it('drops the handle when creation bootstrap fails so the next create starts clean', async () => { + registerAgent.mockRejectedValueOnce(new Error('bootstrap boom')); + const svc = ix.get(IAgentLifecycleService); + + await expect(svc.create({ agentId: 'main' })).rejects.toThrow('bootstrap boom'); + expect(svc.getHandle('main')).toBeUndefined(); + await expect(svc.whenReady('main')).resolves.toBeUndefined(); + + const main = await svc.create({ agentId: 'main' }); + expect(main.id).toBe('main'); + }); + it('fork throws when the source agent does not exist', async () => { const svc = ix.get(IAgentLifecycleService); await expect(svc.fork('missing')).rejects.toThrow('Source agent "missing" does not exist'); diff --git a/packages/agent-core-v2/test/session/sessionActivity/sessionActivity.test.ts b/packages/agent-core-v2/test/session/sessionActivity/sessionActivity.test.ts index e6ccabaab9..f8063d287c 100644 --- a/packages/agent-core-v2/test/session/sessionActivity/sessionActivity.test.ts +++ b/packages/agent-core-v2/test/session/sessionActivity/sessionActivity.test.ts @@ -62,6 +62,7 @@ function lifecycle(handles: readonly IAgentScopeHandle[]): IAgentLifecycleServic throw new Error('not implemented in test'); }, getHandle: () => undefined, + whenReady: () => Promise.resolve(undefined), list: () => handles, remove: () => Promise.resolve(), }; diff --git a/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts b/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts index 90405d32ec..43c6488f23 100644 --- a/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts +++ b/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts @@ -1253,6 +1253,7 @@ function lifecycleStub( completion: Promise.resolve({ summary: 'child summary' }), })), getHandle: (agentId: string) => handles.get(agentId), + whenReady: (agentId: string) => Promise.resolve(handles.get(agentId)), list: () => [...handles.values()], remove: async (agentId: string) => { handles.delete(agentId); diff --git a/packages/agent-core-v2/test/session/todo/sessionTodo.test.ts b/packages/agent-core-v2/test/session/todo/sessionTodo.test.ts index 36aa24a6db..d5dcf143da 100644 --- a/packages/agent-core-v2/test/session/todo/sessionTodo.test.ts +++ b/packages/agent-core-v2/test/session/todo/sessionTodo.test.ts @@ -180,6 +180,7 @@ function makeLifecycleStub(handles: readonly IAgentScopeHandle[] = []): Lifecycl onDidCreateMain: onDidCreateMain.event, onDidDispose: onDidDispose.event, getHandle: (id: string) => byId.get(id), + whenReady: (id: string) => Promise.resolve(byId.get(id)), list: () => [...byId.values()], create: async () => { throw new Error('not implemented'); diff --git a/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts b/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts index 3b7f062747..ae3e7bb830 100644 --- a/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts +++ b/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts @@ -183,6 +183,7 @@ function agentsStub(): AgentsStub { throw new Error('not implemented'); }, getHandle: (id) => (id === MAIN_AGENT_ID && mainPresent ? mainHandle : undefined), + whenReady: (id) => Promise.resolve(id === MAIN_AGENT_ID && mainPresent ? mainHandle : undefined), list: () => [], remove: () => Promise.resolve(), setMain: (present) => { diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index cb9ac223ad..7f6c4d959c 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -273,6 +273,7 @@ function createAgentLifecycleStub(options: AgentLifecycleStubOptions = {}): Agen }; }), getHandle: vi.fn((agentId) => handles.get(agentId)), + whenReady: vi.fn((agentId: string) => Promise.resolve(handles.get(agentId))), list: vi.fn(() => [...handles.values()]), remove: vi.fn(async (agentId) => { handles.delete(agentId); From fe02ac6b5d2fc914a8626389c1d9ba363c6a5584 Mon Sep 17 00:00:00 2001 From: chengluyu <2239547+chengluyu@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:09:57 +0800 Subject: [PATCH 2/2] style(agent-core-v2): align startup comments --- .../src/agent/prompt/promptService.ts | 5 ---- .../session/agentLifecycle/agentLifecycle.ts | 23 ++++--------------- .../agentLifecycle/agentLifecycleService.ts | 19 +++++---------- .../test/agent/prompt/promptService.test.ts | 12 ++++++---- 4 files changed, 19 insertions(+), 40 deletions(-) diff --git a/packages/agent-core-v2/src/agent/prompt/promptService.ts b/packages/agent-core-v2/src/agent/prompt/promptService.ts index 62ced993e3..778d9059a5 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptService.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptService.ts @@ -188,11 +188,6 @@ export class AgentPromptService implements IAgentPromptService { item.state = 'running'; item.launchedDeferred.resolve(turn); this.active = Object.assign(item, { turn }); void turn.result.then((result) => this.settle(item, result)); } catch { - // Every caller fires `void this.startNext()`, so a launch failure (a - // throwing hook, or the loop rejecting the turn — e.g. the activity - // lane still `initializing` or already `disposed`) must never escape - // as an unhandled rejection. Settle the prompt as failed so - // `enqueue`'s waiters resolve and the queue keeps draining. item.state = 'failed'; item.launchedDeferred.resolve(undefined); item.completionDeferred.resolve({ promptId: item.id, result: undefined, state: 'failed' }); diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts index b2b8776517..7b01995680 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts @@ -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. */ @@ -148,15 +150,6 @@ export interface IAgentLifecycleService { readonly onDidDispose: Event; /** Create an agent from zero (empty context). */ create(opts?: CreateAgentOptions): Promise; - /** - * Await any in-flight `create(agentId)` bootstrap and return the settled - * handle (`undefined` when the agent does not exist, or its creation - * failed). `create` registers the handle before its async bootstrap - * finishes, so a concurrent `getHandle` can observe a half-initialized - * agent whose activity lane is still `initializing`; callers that - * auto-materialize an agent (e.g. `ensureMainAgent`) must resolve through - * here instead. - */ whenReady(agentId: string): Promise; /** * Resolve the session/plugin MCP config and wait for the initial connection @@ -164,12 +157,6 @@ export interface IAgentLifecycleService { * rather than rejecting this promise. */ ensureMcpReady(): Promise; - /** - * Idempotently fire {@link onDidCreateMain} for the given handle after - * main-only wirings are attached, so main-only capabilities can subscribe - * without filtering every {@link onDidCreate}. No caller other than the - * main-agent bootstrapper (`ensureMainAgent`) should invoke it. - */ notifyMainCreated(handle: IAgentScopeHandle): void; /** * Fire {@link onDidStopAgentTask} for a mirrored run that has stopped. diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index 6a09549fac..e6fecbbbe5 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -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 @@ -252,18 +253,10 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle handle.accessor.get(IAgentActivityService).markReady(); return handle; } catch (error) { - // A failed bootstrap must not strand a half-initialized agent in the - // registry: its activity lane never leaves `initializing`, so every - // later lookup would hand out an agent that rejects all turns. Drop the - // broken handle (and dispose its scope best-effort) so the next - // creation attempt starts clean. if (this.handles.get(agentId) === handle) this.handles.delete(agentId); try { handle.dispose(); - } catch { - // Disposal of a partially constructed scope must not mask the - // bootstrap failure. - } + } catch {} this.onDidDisposeEmitter.fire(agentId); throw error; } diff --git a/packages/agent-core-v2/test/agent/prompt/promptService.test.ts b/packages/agent-core-v2/test/agent/prompt/promptService.test.ts index 3ce70eb262..5901dcda9e 100644 --- a/packages/agent-core-v2/test/agent/prompt/promptService.test.ts +++ b/packages/agent-core-v2/test/agent/prompt/promptService.test.ts @@ -1,3 +1,11 @@ +/** + * 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'; @@ -110,10 +118,6 @@ describe('AgentPromptService', () => { it('settles the prompt as failed when the loop throws on launch', async () => { const { prompt, loop } = harness(); - // The loop rejects a launch synchronously when the activity lane is not - // idle (e.g. `initializing` before bootstrap finished, or `disposed`). - // The failure must settle the prompt instead of escaping the - // fire-and-forget `startNext` as an unhandled rejection. vi.spyOn(loop, 'enqueue').mockImplementation(() => { throw new Error2(ErrorCodes.ACTIVITY_INITIALIZING, 'Agent is still restoring'); });