diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml index fac3a1d2fa..eab17eea2b 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-21-log-backed-session-titles.md: c78f4f16d12cdfacbf7bf5d494e93276d6ac9769 -2026-07-21-log-backed-session-titles.zh.md: 845ea9fbc1c50574d61086b9c744d2efe65cf433 +2026-07-21-log-backed-session-titles.md: 0690b09965df516b180652182c15e34c8faf06cc +2026-07-21-log-backed-session-titles.zh.md: f973fdca4897bd57590fb0bc53a20a345144089f diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md index c78f4f16d1..0690b09965 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md @@ -24,7 +24,7 @@ The core session package exposes `ctx.sessions.appendOutOfBand()` only for plugi Only text blocks from human-source `user/message` events are eligible. Empty, control-only, and non-text prompts wait for the next eligible message. The service schedules the first fallback without awaiting it from the prompt path, normalizes whitespace and control sequences, applies the configured word and UTF-8 byte limits without splitting a code point, and records the first message seq. -Automatic provider work starts only after the main loop has a current logged provider/model route. A newly appended `request/header` starts pending work directly; when the header is unchanged, the marked loop-built `llm/stream` request starts it after matching the folded route. Generation then runs independently of the agent response, and a completion joins whichever turn is open at acceptance time or uses the zero-step append path. Explicit `refresh(session, signal?)` materializes any missing fallback and awaits the registered provider; without a provider it returns the fallback. Caller cancellation during fallback flush does not roll back the durable append, but `refresh()` rechecks the signal and rejects instead of returning success. Concurrent refreshes reserve their session-local revision before waiting for fallback durability, so a newer call supersedes an older call before either can invert provider completion order. All title-capability out-of-band writes share a per-session settlement queue; a replacement model request waits for any earlier title write, while the superseded model call itself remains independently abortable and cannot commit stale output. +Automatic provider work starts only after the main loop has a current logged provider/model route. A newly appended `request/header` starts pending work directly; when the header is unchanged, the marked loop-built `llm/stream` request starts it after matching the folded route. Generation then runs independently of the agent response, and a completion joins whichever turn is open at acceptance time or uses the zero-step append path. Explicit `refresh(session, signal?)` materializes any missing fallback and awaits the registered provider; without a provider it returns the fallback. Caller cancellation during fallback flush does not roll back the durable append, but `refresh()` rechecks the signal and rejects instead of returning success. Concurrent refreshes reserve their session-local revision before waiting for fallback durability, so a newer call supersedes an older call before either can invert provider completion order. Automatic work and concurrent refreshes share one session-local in-flight fallback promise, so the first fallback creates only one title event and zero-step turn. All title-capability out-of-band writes share a per-session settlement queue; a replacement model request waits for any earlier title write, while the superseded model call itself remains independently abortable and cannot commit stale output. The first-message provider schedules once when a fresh session first creates its fallback. An automatic failure does not reschedule on later prompts; `refresh()` is the retry path. The all-messages provider schedules after every eligible human prompt and passes all eligible messages through that revision, including seeded history. Its newer revision aborts and supersedes older pending or active work. diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md index 845ea9fbc1..f973fdca48 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md @@ -24,7 +24,7 @@ Status: implemented 只有人类来源的 `user/message` 事件中的文本块才符合条件。空提示词、仅含控制字符的提示词和非文本提示词会等待下一条合格消息。服务从提示词路径调度首个回退标题而不等待其完成,随后规范化空白和控制序列,应用已配置的单词数和 UTF-8 字节限制且不拆分代码点,并记录第一条消息的 seq。 -仅当主循环存在已记录在日志中的当前提供方/模型路由时,自动提供方工作才会启动。`request/header` 新追加到日志时,会直接启动待执行工作;如果请求头没有变化,则由循环构建并带有标记的 `llm/stream` 请求会先与折叠所得的路由匹配,再启动该工作。随后,生成工作独立于 agent 响应运行;完成结果在被接受时加入当时开放的轮次,否则使用零步骤追加路径。显式调用 `refresh(session, signal?)` 会生成尚缺的回退标题并等待已注册的提供方;没有提供方时则返回回退标题。调用方在回退标题刷写期间取消调用不会回滚这次持久化追加,但 `refresh()` 会重新检查取消信号,并让调用以拒绝结束,而非返回成功。并发刷新会在等待回退标题持久化完成前预留会话本地修订号,因此在任何调用有机会造成提供方完成顺序倒置之前,较新的调用就会取代较早的调用。会话标题功能产生的所有带外写入在每个会话内共用一个结算队列;接替执行的模型请求会等待任何更早的标题写入完成,而被取代的模型调用本身仍可独立中止,且无法提交陈旧输出。 +仅当主循环存在已记录在日志中的当前提供方/模型路由时,自动提供方工作才会启动。`request/header` 新追加到日志时,会直接启动待执行工作;如果请求头没有变化,则由循环构建并带有标记的 `llm/stream` 请求会先与折叠所得的路由匹配,再启动该工作。随后,生成工作独立于 agent 响应运行;完成结果在被接受时加入当时开放的轮次,否则使用零步骤追加路径。显式调用 `refresh(session, signal?)` 会生成尚缺的回退标题并等待已注册的提供方;没有提供方时则返回回退标题。调用方在回退标题刷写期间取消调用不会回滚这次持久化追加,但 `refresh()` 会重新检查取消信号,并让调用以拒绝结束,而非返回成功。并发刷新会在等待回退标题持久化完成前预留会话本地修订号,因此在任何调用有机会造成提供方完成顺序倒置之前,较新的调用就会取代较早的调用。自动工作与并发刷新在每个会话内共用同一个进行中的回退 promise,因此首次回退只会创建一个标题事件和一个零步骤轮次。会话标题功能产生的所有带外写入在每个会话内共用一个结算队列;接替执行的模型请求会等待任何更早的标题写入完成,而被取代的模型调用本身仍可独立中止,且无法提交陈旧输出。 首消息提供方仅在新会话首次创建回退标题时调度一次。自动执行失败后,后续提示词不会重新调度;`refresh()` 是重试路径。全部消息提供方会在每条合格且由人类发出的提示词后调度,并传入截至该修订的所有合格消息,包括预置历史记录。较新的修订会中止并取代更早的待执行或活跃工作。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index c5aedf9dbe..b1cb864e7c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1023,7 +1023,7 @@ register(provider: SessionTitleProvider): () => Promise Types: [Session](../core-data-structures/session.md) · [SessionTitleProvider](../core-data-structures/session-title.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) -Source: [`packages/session-title/session-title/src/index.ts:281`](../../packages/session-title/session-title/src/index.ts) +Source: [`packages/session-title/session-title/src/index.ts:282`](../../packages/session-title/session-title/src/index.ts) ## `ctx.skills` — `SkillService` diff --git a/packages/session-title/session-title/README.md b/packages/session-title/session-title/README.md index e8538a1d0c..e9fadff0aa 100644 --- a/packages/session-title/session-title/README.md +++ b/packages/session-title/session-title/README.md @@ -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. diff --git a/packages/session-title/session-title/src/index.ts b/packages/session-title/session-title/src/index.ts index 4059b7cd47..a4a516b68e 100644 --- a/packages/session-title/session-title/src/index.ts +++ b/packages/session-title/session-title/src/index.ts @@ -266,6 +266,7 @@ interface ActiveProviderWork extends PendingAutomaticWork { /** Mutable concurrency state scoped to one live session. */ interface SessionTitleWorkState { revision: number + fallback?: Promise 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 + } } } diff --git a/packages/session-title/session-title/tests/service-contracts.spec.ts b/packages/session-title/session-title/tests/service-contracts.spec.ts index b46f4637d7..1e5f0c8b26 100644 --- a/packages/session-title/session-title/tests/service-contracts.spec.ts +++ b/packages/session-title/session-title/tests/service-contracts.spec.ts @@ -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)