refactor(agent-core-v2): drop session close-awareness from title generation

Auto title is best-effort: a generation racing session close no longer
cancels its fetch or guards its write-back, so the per-session
sessionLifetime AbortSignal seed, the onWillCloseSession drain, and the
close-time invalidation go away. The newest-request-wins write-back
predicate stays.
This commit is contained in:
7Sageer 2026-07-31 15:28:05 +08:00
parent 7292ccc018
commit 1a23797226
7 changed files with 7 additions and 214 deletions

View file

@ -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`.

View file

@ -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';

View file

@ -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<ISessionLifetime> =
createDecorator<ISessionLifetime>('sessionLifetime');
export function sessionLifetimeSeed(lifetime: ISessionLifetime): ScopeSeed {
return [[ISessionLifetime as ServiceIdentifier<unknown>, lifetime]];
}

View file

@ -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<string | undefined> | undefined;
private readonly _active = new Set<Promise<string | undefined>>();
private _generationSeq = 0;
constructor(
@ISessionContext private readonly ctx: ISessionContext,
@ISessionMetadata private readonly metadata: ISessionMetadata,
@ISessionLifetime private readonly lifetime: ISessionLifetime,
@ISessionLifecycleHooks
lifecycleHooks: Hooks<SessionLifecycleHookSlots>,
@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<string | undefined> {
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({

View file

@ -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<SessionForkedEvent> = this._onDidForkSession.event;
private readonly resuming = new Map<string, Promise<ISessionScopeHandle | undefined>>();
private readonly closing = new Map<string, ClosingEntry>();
private readonly sessionLifetimes = new Map<string, AbortController>();
private readonly reservedTargets = new Set<string>();
private readonly pendingArchive = new Set<string>();
@ -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<void> {
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<void> {
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<void> {
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 {

View file

@ -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());

View file

@ -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<OAuthRef | undefined>;
let titlePrompts: readonly string[];
let promptSourceImpl: (limit: number) => Promise<readonly string[]>;
let lifetimeController: AbortController;
let lifecycleHooks: Hooks<SessionLifecycleHookSlots>;
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<SessionLifecycleHookSlots, keyof SessionLifecycleHookSlots>([
'onDidCreateSession',
'onWillCloseSession',
]);
events = new FakeEventService();
fetchMock = vi.fn<(url: string, init?: RequestInit) => Promise<Response>>(
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<Response>((_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<readonly string[]>((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'];