fix(session-title): harden async provider lifecycle
This commit is contained in:
@@ -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: a2cef52bf88f03eef804e3d7f2bf83288ba49b2e
|
||||
2026-07-21-log-backed-session-titles.zh.md: ea6ecc6e66d1afac772cdd93936c6ed49d5409a2
|
||||
2026-07-21-log-backed-session-titles.md: d536e8623652116c1194c303cd50ea44f27bb34d
|
||||
2026-07-21-log-backed-session-titles.zh.md: b44f7cfaede440291445894ddb73829b1a3404ec
|
||||
|
||||
@@ -16,21 +16,21 @@ The [`session-title` capability family](../../../../packages/session-title/READM
|
||||
|
||||
### Event ownership and folding
|
||||
|
||||
Every accepted revision is a log-only `session/title` event. Its payload contains normalized non-empty text, the exact eligible human `user/message` seqs used to derive it, and either fallback provenance or the registered provider id plus optional provider/model route. `foldSessionTitle()` selects the latest event and adds that event's seq and timestamp as `SessionTitleSnapshot`. Title events never enter `session.surface` or `deriveMessages()`.
|
||||
Every accepted revision is a log-only `session/title` event. Its payload contains normalized non-empty text, the exact eligible human `user/message` seqs used to derive it, and either fallback provenance or the registered provider id plus optional provider/model route. Before an auxiliary title-model dispatch, the shared helper appends a log-only `session/title-llm-request` event containing the title-provider id, exact source seqs, route, system prompt, messages, and output-token cap; a later generation failure leaves the request auditable. Validation failures that never reach dispatch create no request event. `foldSessionTitle()` selects the latest title event and adds that event's seq and timestamp as `SessionTitleSnapshot`. Neither event enters `session.surface` or `deriveMessages()`.
|
||||
|
||||
The core session package exposes `ctx.sessions.appendOutOfBand()` only for plugin event types whose owners also declaration-merge an `OutOfBandSessionEventMap` marker. An open turn receives the log-only event directly and owns its normal checkpoint. A closed log receives `turn/start → event → turn/end` under the plugin's trigger, followed by an awaited flush. Once the synthetic turn opens, target-append failure still attempts to close and flush it; detach is deferred until the sequence settles. Session titles contribute the `session-title` zero-step trigger and opt `session/title` into this seam.
|
||||
The core session package exposes `ctx.sessions.appendOutOfBand()` only for plugin event types whose owners also declaration-merge an `OutOfBandSessionEventMap` marker. An open turn receives the log-only event directly and owns its normal checkpoint. A closed log receives `turn/start → event → turn/end` under the plugin's trigger, followed by an awaited flush. Once the synthetic turn opens, target-append failure still attempts to close and flush it; detach is deferred until the sequence settles. Session titles contribute the `session-title` zero-step trigger and opt both title event types into this seam.
|
||||
|
||||
### Input and asynchronous timing
|
||||
|
||||
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 waits until the corresponding `request/header` records the main request's exact provider/model route, then runs independently of the agent response. 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.
|
||||
Automatic provider work waits until the corresponding `request/header` records the main request's exact provider/model route, then runs independently of the agent response. 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. 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.
|
||||
|
||||
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.
|
||||
|
||||
### Registration, routing, and failure policy
|
||||
|
||||
`register(provider)` validates one branded stable id, cadence, and generation function, then returns an effect disposer. A second live registration throws immediately. Provider disposal and session disposal abort pending and active work. Every session-local generation has a monotonic revision and exact registration identity; acceptance rechecks revision, registration, session liveness, and cancellation, so a provider that ignores its abort signal cannot commit stale output.
|
||||
`register(provider)` validates one branded stable id, cadence, and generation function, then returns an awaitable effect disposer. A second live registration throws immediately. Provider disposal marks the registration closing, aborts its pending and active work, and waits for every call to settle before removing the registration, so replacement cannot overlap a provider that ignores cancellation. Session disposal aborts its active work. Service teardown prevents queued fallback and provider microtasks from starting, aborts active work, and drains tracked promises before unloading completes. Every session-local generation has a monotonic revision and exact registration identity; acceptance rechecks revision, registration, session liveness, service liveness, and cancellation, so stale output cannot commit.
|
||||
|
||||
Model providers require explicit word, CJK-character, input-byte, output-token, and timeout limits. Optional `provider` and `model` overrides are a pair; without them the helper uses the exact route from the logged main request header. Selected messages are framed as JSON under one fixed language-aware instruction. Oversized input is rejected rather than truncated because truncation would make the recorded source seqs falsely imply complete use.
|
||||
|
||||
@@ -55,6 +55,6 @@ A fork inherits seed title events unchanged, like the rest of its source log. Th
|
||||
|
||||
- Titles survive JSONL and SQLite persistence, replay through ACP, and follow fork inheritance without a separate mutable record.
|
||||
- A fallback appears without an auxiliary call; deployments choose whether better titles justify model cost and whether later prompts should retitle a session.
|
||||
- Late accepted titles consume event seqs and may create a balanced zero-step turn, so transcript and persistence fixtures expose the update even though model history and KV-cache identity do not change.
|
||||
- Auxiliary request records and late accepted titles consume event seqs and may create balanced zero-step turns, so persistence exposes both attempted dispatches and accepted updates even though model history and KV-cache identity do not change.
|
||||
- One provider and monotonic per-session revisions make disposal, supersession, and stale-result rejection explicit, at the cost of leaving multi-strategy precedence to a composite provider.
|
||||
- Manual rename, deletion, generated-versus-user precedence, search, and list indexing remain outside the capability.
|
||||
|
||||
@@ -16,21 +16,21 @@ Status: implemented
|
||||
|
||||
### 事件归属与折叠
|
||||
|
||||
每个已接受的修订都是纯日志 `session/title` 事件。其载荷包含规范化后的非空文本、用于派生标题的所有合格且来源为人类的 `user/message` 的准确 seq,以及回退来源信息,或已注册的提供方 id 加可选的提供方和模型路由。`foldSessionTitle()` 选择最新事件,并将该事件的 seq 和时间戳加入 `SessionTitleSnapshot`。标题事件永远不会进入 `session.surface` 或 `deriveMessages()`。
|
||||
每个已接受的修订都是纯日志 `session/title` 事件。其载荷包含规范化后的非空文本、用于派生标题的所有合格且来源为人类的 `user/message` 的准确 seq,以及回退来源信息,或已注册的提供方 id 加可选的提供方和模型路由。辅助标题模型发起调用前,共享辅助组件会追加一个纯日志 `session/title-llm-request` 事件,其载荷包含标题提供方 id、准确的源 seq、路由、系统提示词、消息和输出 token 上限;即使后续生成失败,这次请求仍可审计。未进入调用阶段的验证失败不会创建请求事件。`foldSessionTitle()` 选择最新的标题事件,并将该事件的 seq 和时间戳加入 `SessionTitleSnapshot`。这两类事件都不会进入 `session.surface` 或 `deriveMessages()`。
|
||||
|
||||
核心会话包通过 `ctx.sessions.appendOutOfBand()` 暴露这一接口,但只允许所属插件同时通过声明合并向 `OutOfBandSessionEventMap` 添加标记的插件事件类型使用。开放轮次会直接接收纯日志事件,并负责其常规检查点。已关闭的日志会在该插件的触发器下接收 `turn/start → event → turn/end`,随后等待刷写完成。合成轮次一旦开启,即使目标追加失败,系统仍会尝试将其关闭并刷写;整个序列完成前会延迟 detach。会话标题提供 `session-title` 零步骤触发器,并让 `session/title` 使用这一服务边界。
|
||||
核心会话包通过 `ctx.sessions.appendOutOfBand()` 暴露这一接口,但只允许所属插件同时通过声明合并向 `OutOfBandSessionEventMap` 添加标记的插件事件类型使用。开放轮次会直接接收纯日志事件,并负责其常规检查点。已关闭的日志会在该插件的触发器下接收 `turn/start → event → turn/end`,随后等待刷写完成。合成轮次一旦开启,即使目标追加失败,系统仍会尝试将其关闭并刷写;整个序列完成前会延迟 detach。会话标题提供 `session-title` 零步骤触发器,并让这两类标题事件都使用这一服务边界。
|
||||
|
||||
### 输入与异步时序
|
||||
|
||||
只有人类来源的 `user/message` 事件中的文本块才符合条件。空提示词、仅含控制字符的提示词和非文本提示词会等待下一条合格消息。服务从提示词路径调度首个回退标题而不等待其完成,随后规范化空白和控制序列,应用已配置的单词数和 UTF-8 字节限制且不拆分代码点,并记录第一条消息的 seq。
|
||||
|
||||
自动提供方工作会等待相应的 `request/header` 记录主请求的准确提供方和模型路由,然后独立于 agent 响应运行。完成结果在被接受时加入当时开放的轮次,否则使用零步骤追加路径。显式调用 `refresh(session, signal?)` 会生成尚缺的回退标题并等待已注册的提供方;没有提供方时则返回回退标题。
|
||||
自动提供方工作会等待相应的 `request/header` 记录主请求的准确提供方和模型路由,然后独立于 agent 响应运行。完成结果在被接受时加入当时开放的轮次,否则使用零步骤追加路径。显式调用 `refresh(session, signal?)` 会生成尚缺的回退标题并等待已注册的提供方;没有提供方时则返回回退标题。并发刷新会在等待回退标题持久化完成前预留会话本地修订号,因此在任何调用有机会造成提供方完成顺序倒置之前,较新的调用就会取代较早的调用。
|
||||
|
||||
首消息提供方仅在新会话首次创建回退标题时调度一次。自动执行失败后,后续提示词不会重新调度;`refresh()` 是重试路径。全部消息提供方会在每条合格且由人类发出的提示词后调度,并传入截至该修订的所有合格消息,包括预置历史记录。较新的修订会中止并取代更早的待执行或活跃工作。
|
||||
|
||||
### 注册、路由与失败策略
|
||||
|
||||
`register(provider)` 会验证一个带品牌类型的稳定 id、执行时机和生成函数,然后返回 effect 资源释放函数。第二个活跃注册会立即抛出错误。提供方和会话执行 dispose(资源释放)时,都会中止待执行和活跃工作。每项会话本地生成都有单调递增的修订号和对应的注册身份;接受结果时会重新检查修订号、注册、会话活跃状态和取消状态,因此即使提供方忽略中止信号,也无法提交陈旧输出。
|
||||
`register(provider)` 会验证一个带品牌类型的稳定 id、执行时机和生成函数,然后返回一个可等待完成的 effect 资源释放函数。第二个活跃注册会立即抛出错误。提供方执行资源释放时,会将注册标记为正在关闭,中止其待执行和活跃工作,并等待所有调用结束后才移除注册,因此替代提供方不会与忽略取消的旧提供方重叠运行。会话资源释放会中止其活跃工作。服务卸载时,会阻止排队中的回退和提供方微任务启动,中止活跃工作,并且卸载完成前会等待所有已跟踪的 promise 结算。每项会话本地生成都有单调递增的修订号和对应的注册身份;接受结果时会重新检查修订号、注册、会话活跃状态、服务活跃状态和取消状态,因此陈旧输出无法提交。
|
||||
|
||||
模型提供方必须显式配置单词数、CJK 字符数、输入字节数、输出 token 数和超时限制。可选的 `provider` 和 `model` 覆盖项必须成对提供;两者均未提供时,辅助组件会使用主请求已记录请求头中的准确路由。系统在一条固定且能区分语言的指令下,将选中的消息封装为 JSON。过大输入会被拒绝而不是截断,因为截断会让记录的源消息 seq 错误地表示这些消息已被完整使用。
|
||||
|
||||
@@ -55,6 +55,6 @@ Status: implemented
|
||||
|
||||
- 标题可以在 JSONL 和 SQLite 持久化中存续,通过 ACP 回放,并遵循 fork 继承语义,而无需单独的可变记录。
|
||||
- 回退标题无需辅助调用即可出现;部署方可以自行决定更优标题是否值得模型成本,以及后续提示词是否需要重新生成会话标题。
|
||||
- 延迟接受的标题会占用事件 seq,并可能创建平衡的零步骤轮次,因此 transcript(文本记录)和持久化 fixture(测试前置数据)会呈现该更新,尽管模型历史和 KV 缓存标识保持不变。
|
||||
- 辅助请求记录和延迟接受的标题会占用事件 seq,并可能创建平衡的零步骤轮次,因此持久化会同时呈现尝试发起的调用与已接受的更新,尽管模型历史和 KV 缓存标识保持不变。
|
||||
- 单个提供方和每会话单调递增的修订号让释放、取代和陈旧结果拒绝行为明确可见,但多策略优先级必须由复合提供方负责。
|
||||
- 手动重命名、删除、生成标题与用户标题的优先级、搜索和列表索引不在此功能范围内。
|
||||
|
||||
Reference in New Issue
Block a user