fix(session-title): deduplicate fallback creation

This commit is contained in:
Tianyi Cui
2026-07-21 15:23:27 +08:00
parent 9b048354b8
commit c622a2881d
7 changed files with 45 additions and 9 deletions
@@ -10,7 +10,7 @@ Only text blocks from human `user/message` events are eligible. The first eligib
- `refresh(session, signal?)` materializes the fallback when needed, then explicitly runs the registered provider over the current eligible messages. Provider errors and caller cancellation reject; cancellation does not roll back a fallback append already entering durability.
- `register(provider)` installs the sole optional provider and returns its awaitable Cordis effect disposer. A second registration throws immediately; disposal aborts pending and active calls, waits for their settlement, and only then permits another provider to register.
Automatic work never delays the main agent response. A provider starts only after a marked loop-built request's exact route matches the current logged `request/header`, including when the unchanged header needs no new snapshot. Its late completion joins an open turn or uses a flushed zero-step `session-title` turn through `ctx.sessions.appendOutOfBand()`. Automatic failures warn and retain the latest title. New all-message revisions, provider disposal, session disposal, and explicit refresh abort older work, and a stale completion cannot append. Concurrent explicit refreshes reserve their order before fallback durability waits. Service and bundled model-provider records use `appendSessionTitleOutOfBand()` to share a per-session settlement queue, so a replacement request record waits for an earlier title write without serializing the superseded model call itself. Service teardown cancels queued work and drains calls that ignore cancellation before unloading completes.
Automatic work never delays the main agent response. A provider starts only after a marked loop-built request's exact route matches the current logged `request/header`, including when the unchanged header needs no new snapshot. Its late completion joins an open turn or uses a flushed zero-step `session-title` turn through `ctx.sessions.appendOutOfBand()`. Automatic failures warn and retain the latest title. New all-message revisions, provider disposal, session disposal, and explicit refresh abort older work, and a stale completion cannot append. Concurrent explicit refreshes reserve their order before fallback durability waits, while overlapping automatic and explicit fallback requests share one session-local in-flight append. Service and bundled model-provider records use `appendSessionTitleOutOfBand()` to share a per-session settlement queue, so a replacement request record waits for an earlier title write without serializing the superseded model call itself. Service teardown cancels queued work and drains calls that ignore cancellation before unloading completes.
Forks inherit title events in their seed unchanged. The first-message cadence does not automatically retitle a child; the all-messages cadence may append a new revision after the child receives a later human prompt.
@@ -266,6 +266,7 @@ interface ActiveProviderWork extends PendingAutomaticWork {
/** Mutable concurrency state scoped to one live session. */
interface SessionTitleWorkState {
revision: number
fallback?: Promise<SessionTitleSnapshot | undefined>
pending?: PendingAutomaticWork
active?: ActiveProviderWork
}
@@ -707,12 +708,19 @@ export class SessionTitleService extends Service {
this.config.fallbackMaxBytes,
)
if (title.length === 0) return undefined
await appendSessionTitleOutOfBand(this.ctx, session, 'session/title', {
const state = this.stateFor(session)
if (state.fallback !== undefined) return state.fallback
const fallback = appendSessionTitleOutOfBand(this.ctx, session, 'session/title', {
title,
messageSeqs: [first.seq],
source: { kind: 'fallback' },
}, this.lifetime.signal)
return this.get(session)
}, this.lifetime.signal).then(() => this.get(session))
state.fallback = fallback
try {
return await fallback
} finally {
delete state.fallback
}
}
}
@@ -204,6 +204,34 @@ describe('SessionTitleService configuration and refresh boundaries', () => {
})
})
it('shares one durable fallback across concurrent refreshes', async () => {
const ctx = await setup()
const seed = new Session(SessionId('fallback-concurrency-seed'))
seed.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const source = appendPrompt(seed, 'Create exactly one fallback title')
seed.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const session = ctx.sessions.create(SessionId('fallback-concurrency'), { seed: seed.events })
let flushes = 0
ctx.on('session/flush', (subject) => {
if (subject === session) flushes += 1
})
const results = await Promise.all([
ctx.sessionTitle.refresh(session),
ctx.sessionTitle.refresh(session),
])
expect(results[0]).toEqual(results[1])
expect(session.events.filter(event => event.type === 'session/title')).toHaveLength(1)
expect(session.events.filter(event => event.type === 'turn/start'
&& event.data.trigger.kind === 'session-title')).toHaveLength(1)
expect(ctx.sessionTitle.get(session)?.messageSeqs).toEqual([source.seq])
expect(flushes).toBe(1)
})
it('reserves overlapping refresh order before fallback durability settles', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)