diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index 12a6909d9..c66ae8b4a 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -91,11 +91,6 @@ const DOMAIN_LAYER = new Map([ // create-source/close-reason vocabulary; a pure contract with no IO, so it // sits in L1 beside `sessionContext`. ['sessionLifecycleHooks', 1], - // `sessionLifetime` is the per-session liveness seed (`AbortSignal` - // aborted by the Workspace-scope `workspaceHandler` when the session's - // close begins); a pure contract with no IO, so it sits in L1 beside - // `sessionLifecycleHooks`. - ['sessionLifetime', 1], // `scopeContext` is the Agent-scope seeded immutable facts value // (`agentId` plus a persistence scope helper); a pure seed with no IO, so it // sits in L1 beside `sessionContext`. diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 3e1591a2a..8746eebad 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -366,7 +366,6 @@ export * from '#/workspace/workspaceHandler/workspaceHandler'; export * from '#/workspace/workspaceHandler/workspaceHandlerService'; export * from '#/workspace/workspaceHandler/addressing'; export * from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; -export * from '#/session/sessionLifetime/sessionLifetime'; export * from '#/session/externalHooks/externalHooks'; export * from '#/session/externalHooks/externalHooksService'; import '#/app/sessionExport/errors'; diff --git a/packages/agent-core-v2/src/session/sessionLifetime/sessionLifetime.ts b/packages/agent-core-v2/src/session/sessionLifetime/sessionLifetime.ts deleted file mode 100644 index b209420b2..000000000 --- a/packages/agent-core-v2/src/session/sessionLifetime/sessionLifetime.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * `sessionLifetime` domain (L1) — per-session liveness seed. - * - * Defines the `ISessionLifetime` carrying the session scope's liveness - * `AbortSignal`: the Workspace-scope `workspaceHandler` creates one - * `AbortController` per materialized session, seeds the signal into the - * Session scope, and aborts it synchronously when the session's close - * begins — before any async `onWillCloseSession` hook runs. Session-scope - * consumers with in-flight async work (e.g. `sessionTitle`) treat an - * aborted signal as "this scope is being torn down": cancel the work and - * drop its write-back. Pure contract — no IO, no store. Session-scoped. - */ - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import type { ScopeSeed } from '#/_base/di/scope'; - -export interface ISessionLifetime { - readonly _serviceBrand: undefined; - - readonly signal: AbortSignal; -} - -export const ISessionLifetime: ServiceIdentifier = - createDecorator('sessionLifetime'); - -export function sessionLifetimeSeed(lifetime: ISessionLifetime): ScopeSeed { - return [[ISessionLifetime as ServiceIdentifier, lifetime]]; -} diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts index a4cf12c7b..d443cb7f5 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts @@ -11,11 +11,7 @@ * the user is never overwritten. An already-generated title is not * regenerated unless forced. Plain calls coalesce onto one shared * in-flight generation while a forced regeneration always runs on its - * own; the newest request always wins the write-back, which the - * serialized metadata update drops once the scope's `sessionLifetime` - * signal fires or a newer request supersedes it. Every active generation - * is drained through the `sessionLifecycleHooks` `onWillCloseSession` - * slot before the scope is disposed. + * own; the newest request always wins the write-back. * Provider config comes * from `provider`, the bearer token from `auth`, host identity headers from * `model`, prompt history from `agentLifecycle`/`sessionTitle`, and logs @@ -35,17 +31,11 @@ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/ import { ILogService } from '#/_base/log/log'; import { IOAuthService } from '#/app/auth/auth'; import { IEventService } from '#/app/event/event'; -import type { Hooks } from '#/hooks'; import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; import { IProviderService } from '#/kosong/provider/provider'; import { isOAuthCatalogVendor } from '#/kosong/provider/providerDefinition'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; -import { - ISessionLifecycleHooks, - type SessionLifecycleHookSlots, -} from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; -import { ISessionLifetime } from '#/session/sessionLifetime/sessionLifetime'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { IAgentTitlePromptSource } from './agentTitlePromptSource'; @@ -61,36 +51,24 @@ export class SessionTitleService implements ISessionTitleService { declare readonly _serviceBrand: undefined; private _shared: Promise | undefined; - private readonly _active = new Set>(); private _generationSeq = 0; constructor( @ISessionContext private readonly ctx: ISessionContext, @ISessionMetadata private readonly metadata: ISessionMetadata, - @ISessionLifetime private readonly lifetime: ISessionLifetime, - @ISessionLifecycleHooks - lifecycleHooks: Hooks, @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, @IEventService private readonly eventService: IEventService, @IProviderService private readonly providers: IProviderService, @IOAuthService private readonly oauth: IOAuthService, @IHostRequestHeaders private readonly hostHeaders: IHostRequestHeaders, @ILogService private readonly log: ILogService, - ) { - lifecycleHooks.onWillCloseSession.register('sessionTitle', async (_event, next) => { - await Promise.allSettled([...this._active]); - await next(); - }); - } + ) {} async generateTitle(options?: { readonly force?: boolean }): Promise { - if (this.lifetime.signal.aborted) return undefined; if (options?.force !== true && this._shared !== undefined) return this._shared; const tracked = this.generateTitleOnce(options).finally(() => { - this._active.delete(tracked); if (this._shared === tracked) this._shared = undefined; }); - this._active.add(tracked); if (options?.force !== true) this._shared = tracked; return tracked; } @@ -147,10 +125,9 @@ export class SessionTitleService implements ISessionTitleService { ...this.hostHeaders.headers, ...provider.customHeaders, }, - signal: this.lifetime.signal, }); let result = await requestTitle(token); - if (result.kind === 'error' && result.status === 401 && !this.lifetime.signal.aborted) { + if (result.kind === 'error' && result.status === 401) { try { token = await tokenProvider.getAccessToken({ force: true }); } catch (error) { @@ -164,11 +141,10 @@ export class SessionTitleService implements ISessionTitleService { this.log.debug(`chat_title request failed: ${result.message}`); return undefined; } - if (this.lifetime.signal.aborted) return undefined; const title = result.title.slice(0, MAX_GENERATED_TITLE_LENGTH); const applied = await this.metadata.setGeneratedTitleIfUncustomized( title, - () => seq === this._generationSeq && !this.lifetime.signal.aborted, + () => seq === this._generationSeq, ); if (!applied) return undefined; this.eventService.publish({ diff --git a/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts b/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts index c21d04785..2062917c3 100644 --- a/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts @@ -4,11 +4,9 @@ * Owns the registry of THIS handler's open Session child scopes, creating * them through the DI scope tree (children of the handler's Workspace * scope) and seeding each with its identity, storage addressing derived - * from the handler's `workspaceContext.persistenceScope`, a per-session - * `sessionLifecycleHooks` slots instance it runs around create/close, and - * a per-session `sessionLifetime` abort signal it fires synchronously when - * a close/archive begins — before any async close hook — so in-flight - * session work can cancel itself and drop its write-back. A close/archive + * from the handler's `workspaceContext.persistenceScope`, and a + * per-session `sessionLifecycleHooks` slots instance it runs around + * create/close. A close/archive * is tracked in a closing registry from that first synchronous step until * the scope is disposed: `get`/`list` hide the session while it is * closing, `resume` waits the close out and re-materializes instead of @@ -122,7 +120,6 @@ import { sessionLifecycleHooksSeed, type SessionLifecycleHookSlots, } from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; -import { sessionLifetimeSeed } from '#/session/sessionLifetime/sessionLifetime'; import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; import { ISessionProcessRunner } from '#/session/process/processRunner'; import { sessionSkillCatalogDataSeed } from '#/session/sessionSkillCatalog/skillCatalogData'; @@ -188,7 +185,6 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan readonly onDidForkSession: Event = this._onDidForkSession.event; private readonly resuming = new Map>(); private readonly closing = new Map(); - private readonly sessionLifetimes = new Map(); private readonly reservedTargets = new Set(); private readonly pendingArchive = new Set(); @@ -275,7 +271,6 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan const sessionDir = handle.accessor.get(ISessionContext).sessionDir; if (this.sessions.get(sessionId) === handle) { this.sessions.delete(sessionId); - this.invalidateSessionLifetime(sessionId); } await this.drainAgents(handle).catch(() => {}); handle.dispose(); @@ -313,8 +308,6 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan 'onDidCreateSession', 'onWillCloseSession', ]); - const lifetime = new AbortController(); - this.sessionLifetimes.set(opts.sessionId, lifetime); await this.hostEnv.ready; const handle = createScopedChildHandle( this.instantiation, @@ -324,7 +317,6 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan extra: [ ...sessionContextSeed(ctx), ...sessionLifecycleHooksSeed(hooks), - ...sessionLifetimeSeed({ _serviceBrand: undefined, signal: lifetime.signal }), [ITelemetryService, this.telemetry.withContext({ sessionId: opts.sessionId })], // Workspace resource seeds (the §3.5 injection contracts): the // handler's shared skill / agent-profile catalogs, AGENTS.md @@ -359,7 +351,6 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan ]); await this.mcp.ready; } catch (error) { - this.invalidateSessionLifetime(opts.sessionId); handle.dispose(); // Re-arm the explicit agent-profile loader after a fatal rejection: // its `ready` tracks the latest load pass, so without a reload the @@ -473,7 +464,6 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan } private async doClose(sessionId: string, handle: ISessionScopeHandle): Promise { - this.invalidateSessionLifetime(sessionId); try { await this.announceWillClose({ sessionId, handle, reason: 'exit' }); } finally { @@ -525,7 +515,6 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan } private async doArchive(sessionId: string, handle: ISessionScopeHandle): Promise { - this.invalidateSessionLifetime(sessionId); const meta = handle.accessor.get(ISessionMetadata); await meta.setArchived(true); this.event.publish({ @@ -580,13 +569,6 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan .onWillCloseSession.run({ reason: event.reason }); } - private invalidateSessionLifetime(sessionId: string): void { - const lifetime = this.sessionLifetimes.get(sessionId); - if (lifetime === undefined) return; - this.sessionLifetimes.delete(sessionId); - lifetime.abort(); - } - private async drainAgents(handle: ISessionScopeHandle): Promise { const agentLifecycle = handle.accessor.get(IAgentLifecycleService); for (const agent of agentLifecycle.list()) { @@ -701,7 +683,6 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan this.sessions.get(targetId) === target ) { this.sessions.delete(targetId); - this.invalidateSessionLifetime(targetId); } if (target !== undefined) { try { diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index dc364a6b6..1faa14ee2 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -153,7 +153,6 @@ import { ISessionLifecycleHooks, type SessionLifecycleHookSlots, } from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; -import { ISessionLifetime } from '#/session/sessionLifetime/sessionLifetime'; import { IEventBus } from '#/app/event/eventBus'; import { IWireService } from '#/wire/wire'; import { WireService } from '#/wire/wireService'; @@ -1178,10 +1177,6 @@ export class AgentTestContext { 'onWillCloseSession', ]), ); - reg.defineInstance(ISessionLifetime, { - _serviceBrand: undefined, - signal: new AbortController().signal, - }); reg.defineInstance(ISessionInteractionService, this.createInteractionService()); reg.defineInstance(ISessionApprovalService, this.createApprovalService()); reg.defineInstance(ISessionQuestionService, this.createQuestionService()); diff --git a/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts index a5fe45d2d..1edee7b8c 100644 --- a/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts +++ b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts @@ -20,7 +20,6 @@ import { createServices, type TestInstantiationService } from '#/_base/di/test'; import { Emitter } from '#/_base/event'; import { IOAuthService } from '#/app/auth/auth'; import { type DomainEvent, IEventService } from '#/app/event/event'; -import { createHooks, type Hooks } from '#/hooks'; import { HostRequestHeaders, IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; import { IProviderService, @@ -32,11 +31,6 @@ import { IAgentLifecycleService, MAIN_AGENT_ID, } from '#/session/agentLifecycle/agentLifecycle'; -import { - ISessionLifecycleHooks, - type SessionLifecycleHookSlots, -} from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; -import { ISessionLifetime } from '#/session/sessionLifetime/sessionLifetime'; import { IAgentTitlePromptSource, } from '#/session/sessionTitle/agentTitlePromptSource'; @@ -153,8 +147,6 @@ describe('SessionTitleService', () => { let resolvedOAuthRefs: Array; let titlePrompts: readonly string[]; let promptSourceImpl: (limit: number) => Promise; - let lifetimeController: AbortController; - let lifecycleHooks: Hooks; let tokenCalls: boolean[]; beforeEach(() => { @@ -166,11 +158,6 @@ describe('SessionTitleService', () => { tokenCalls = []; providers = { 'managed:kimi-code': MANAGED_PROVIDER }; metadata = new FakeSessionMetadata(); - lifetimeController = new AbortController(); - lifecycleHooks = createHooks([ - 'onDidCreateSession', - 'onWillCloseSession', - ]); events = new FakeEventService(); fetchMock = vi.fn<(url: string, init?: RequestInit) => Promise>( async () => @@ -210,11 +197,6 @@ describe('SessionTitleService', () => { get: () => mainAgent, }); reg.defineInstance(IEventService, events); - reg.defineInstance(ISessionLifetime, { - _serviceBrand: undefined, - signal: lifetimeController.signal, - }); - reg.defineInstance(ISessionLifecycleHooks, lifecycleHooks); reg.defineInstance(IProviderService, stubProviderService(providers)); reg.definePartialInstance(IOAuthService, { resolveTokenProvider: (_provider, oauthRef) => { @@ -361,113 +343,6 @@ describe('SessionTitleService', () => { expect(metadata.meta.title).toBe('user 取的标题'); }); - it('drops the write-back when the lifetime signal aborts after the response arrived', async () => { - const pendingFetch = createPendingFetch(); - fetchMock.mockImplementationOnce(pendingFetch.fetch); - const applySpy = vi.spyOn(metadata, 'setGeneratedTitleIfUncustomized'); - titlePrompts = ['hello']; - - const generation = ix.get(ISessionTitleService).generateTitle(); - await pendingFetch.started; - lifetimeController.abort(); - pendingFetch.resolve( - new Response(JSON.stringify({ title: '生成的标题' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); - - await expect(generation).resolves.toBeUndefined(); - expect(applySpy).not.toHaveBeenCalled(); - expect(metadata.meta.title).toBeUndefined(); - }); - - it('cancels the in-flight request when the lifetime signal aborts', async () => { - fetchMock.mockImplementationOnce( - async (_url, init) => - new Promise((_resolve, reject) => { - init?.signal?.addEventListener('abort', () => { - reject(new DOMException('The operation was aborted.', 'AbortError')); - }); - }), - ); - const applySpy = vi.spyOn(metadata, 'setGeneratedTitleIfUncustomized'); - titlePrompts = ['hello']; - - const generation = ix.get(ISessionTitleService).generateTitle(); - await vi.waitFor(() => { - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - lifetimeController.abort(); - - await expect(generation).resolves.toBeUndefined(); - expect(applySpy).not.toHaveBeenCalled(); - expect(metadata.meta.title).toBeUndefined(); - }); - - it('returns unavailable without fetching when the scope is already closing', async () => { - lifetimeController.abort(); - titlePrompts = ['hello']; - - await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('drains an in-flight generation through onWillCloseSession', async () => { - const pendingFetch = createPendingFetch(); - fetchMock.mockImplementationOnce(pendingFetch.fetch); - titlePrompts = ['hello']; - - const generation = ix.get(ISessionTitleService).generateTitle(); - await pendingFetch.started; - - let reachedTerminal = false; - const closeRun = lifecycleHooks.onWillCloseSession - .run({ reason: 'exit' }, async () => { - reachedTerminal = true; - }) - .then(() => reachedTerminal); - // The hook chain must be parked on the pending generation, microtask- - // deterministically: the terminal callback cannot run while the fetch - // is unresolved. - await Promise.resolve(); - await Promise.resolve(); - expect(reachedTerminal).toBe(false); - - pendingFetch.resolve( - new Response(JSON.stringify({ title: '生成的标题' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); - await expect(closeRun).resolves.toBe(true); - await expect(generation).resolves.toBe('生成的标题'); - expect(metadata.meta.title).toBe('生成的标题'); - }); - - it('drains a generation that is still collecting prompts through onWillCloseSession', async () => { - let resolvePrompts!: (prompts: readonly string[]) => void; - promptSourceImpl = async (limit) => - new Promise((resolve) => { - resolvePrompts = (prompts) => resolve(prompts.slice(0, limit)); - }); - - const generation = ix.get(ISessionTitleService).generateTitle(); - let reachedTerminal = false; - const closeRun = lifecycleHooks.onWillCloseSession - .run({ reason: 'exit' }, async () => { - reachedTerminal = true; - }) - .then(() => reachedTerminal); - await Promise.resolve(); - await Promise.resolve(); - expect(reachedTerminal).toBe(false); - - resolvePrompts(['hello']); - await expect(closeRun).resolves.toBe(true); - await expect(generation).resolves.toBe('生成的标题'); - }); - it('keeps the current title when the backend request fails', async () => { fetchMock.mockImplementationOnce(async () => new Response('', { status: 500 })); titlePrompts = ['hello'];