fix(schedule): close concurrent durability gaps

This commit is contained in:
pku-xht
2026-08-07 19:06:34 +08:00
committed by Tianyi Cui
parent 76da9e3f9f
commit 9d73b527de
12 changed files with 160 additions and 71 deletions

View File

@@ -29,7 +29,7 @@ The version-1 `schedule/change` stream is the only durable Schedule authority. A
The current rule accepts a non-empty prompt and exactly one positive safe-integer `after_seconds`. Its record is `{ id, kind: 'after', prompt, afterSeconds, scheduledAt }`; dispatch stores only the id because the record already fixes its occurrence. `at`, `every_seconds`, `cron`, and `time_zone` are rejected rather than hidden in unused fields. Tool values derive `scheduled` or `overdue` and always include `deliveryMode: 'session-local'`.
Every tool operation that reads or decides from the fold first awaits `ctx.sessions.flush(session)`. Create may reject input-shape failures before this preflight; after a successful preflight it allocates an id, appends create, and waits for a second barrier. Delete preflights before deciding whether an id is active and waits for a second barrier only when it appends. List and unknown or finished delete never answer from an unconfirmed live suffix. A failed barrier returns `persistence_uncertain` rather than guessing whether an eager write committed.
An Agent-scoped FIFO serializes each accepted management transaction and the live owner's due transaction from preflight through any post-append barrier. Every tool operation that reads or decides from the fold first awaits `ctx.sessions.flush(session)`. Create may reject input-shape failures before entering the FIFO; after a successful preflight it allocates an id, appends create, and waits for a second barrier. Delete validates its id before the FIFO, then preflights before deciding whether the id is active and waits for a second barrier only when it appends. List and unknown or finished delete never answer from an unconfirmed live suffix or observe a dispatch before its own barrier. A failed barrier returns `persistence_uncertain` rather than guessing whether an eager write committed.
Every successful management preflight also asks the live owner to recompute. This closes the recovery path where create appended successfully but its post-append barrier rejected: a later list can confirm the coordinator's retained batch, return the active record, and arm its timer without a Schedule-specific retry loop.
@@ -41,7 +41,7 @@ The persistence coordinator supplies that acknowledgement only after its write p
### Live delivery lifecycle
The Agent-scoped owner derives its earliest target from the durable fold. Long targets use bounded timer segments, and every wake reads the wall clock again, so a rollback cannot fire early and a forward jump becomes overdue. If a turn or another maintenance task already owns the Agent, `runMaintenance()` rejects the claim; the record stays active and one `whenIdle()` wait triggers a later retry. A rejected persistence preflight also leaves the record active, but no private retry timer runs; later Agent activity reaching idle or a successful Schedule management preflight asks the owner to try again.
The Agent-scoped owner derives its earliest target from the durable fold. Long targets use bounded timer segments, and every wake reads the wall clock again, so a rollback cannot fire early and a forward jump becomes overdue. If a turn or another maintenance task already owns the Agent, `runMaintenance()` rejects the claim; the record stays active and one `whenIdle()` wait triggers a later retry. A rejected persistence preflight or contained framing/synchronous-enqueue failure also leaves the record active, but no private retry timer runs; later Agent activity reaching idle or a successful Schedule management preflight asks the owner to try again.
The accepted path first clears pending persistence and claims the true idle phase through `runMaintenance()`. Inside that task it refolds the exact Session suffix so a direct management mutation that won the claim race cannot be followed by a stale dispatch, samples the decision clock once, constructs the complete fixed reminder frame with JSON-escaped id and prompt, synchronously queues one `followup()`, and appends the id-only dispatch. Waking input remains parked until maintenance settles, so the driver cannot claim the message before dispatch enters the log; only after the task releases the phase does the owner wait for the dispatch barrier. A framing or synchronous enqueue failure is contained and appends no dispatch. An append failure faults that owner because the message may already be queued. A later prompt-admission, request-checkpoint, or model failure cannot retract a dispatch.

View File

@@ -29,7 +29,7 @@ Status: implemented
当前规则接受非空 prompt 与恰好一个正 safe-integer `after_seconds`。record 形状是 `{ id, kind: 'after', prompt, afterSeconds, scheduledAt }`dispatch 只保存 id因为 record 已经唯一确定 occurrence。`at``every_seconds``cron``time_zone` 会被拒绝,不会作为未使用字段隐藏在协议中。工具 value 派生 `scheduled``overdue`,并始终包含 `deliveryMode: 'session-local'`
每项从 fold 读取或作出判断的工具操作都会先等待 `ctx.sessions.flush(session)`。create 可以在这次 preflight 前拒绝只依赖输入 shape 的失败preflight 成功后才分配 id、追加 create并等待第二个 barrier。delete 在判断 id 是否活动前先 preflight只有实际追加时才等待第二个 barrier。list 与未知或已终结 delete 绝不会从未确认的 live 后缀作答。barrier 失败会返回 `persistence_uncertain`,而不是猜测 eager write 是否已经提交。
一个 Agent-scoped FIFO 会将每项已接纳的管理事务与 live owner 的到期事务从 preflight 到任何 post-append barrier 全程串行化。每项从 fold 读取或作出判断的工具操作都会先等待 `ctx.sessions.flush(session)`。create 可以在进入 FIFO 前拒绝只依赖输入 shape 的失败preflight 成功后才分配 id、追加 create并等待第二个 barrier。delete 在进入 FIFO 前验证其 id随后在判断 id 是否活动前先 preflight只有实际追加时才等待第二个 barrier。list 与未知或已终结 delete 绝不会从未确认的 live 后缀作答,也不会在自身的 barrier 前观察到 dispatch。barrier 失败会返回 `persistence_uncertain`,而不是猜测 eager write 是否已经提交。
每次成功的管理 preflight 也会要求 live owner 重新计算。这闭合了 create 已成功追加、但 post-append barrier 拒绝时的恢复路径:后续 list 可以确认 coordinator 保留的 batch、返回活动 record并在没有 Schedule 私有重试循环的情况下 arm timer。
@@ -41,7 +41,7 @@ persistence coordinator 只有在写路径完全停稳后才给出该确认。li
### Live 交付生命周期
Agent-scoped owner 从持久 fold 派生最早目标。超长目标使用有界 timer 分段,每次 wake 都重新读取墙钟,因此回拨不会提前触发,前跳则会形成 overdue。如果 agent 已被某个轮次或另一项 maintenance task 占用,`runMaintenance()` 会拒绝此次认领record 保持活动,并由一个 `whenIdle()` wait 触发稍后的重试。被拒绝的 persistence preflight 同样会让 record 保持活动,但不会运行私有重试 timer后续 agent 活动进入 idle或成功的 Schedule 管理 preflight 会要求 owner 再次尝试。
Agent-scoped owner 从持久 fold 派生最早目标。超长目标使用有界 timer 分段,每次 wake 都重新读取墙钟,因此回拨不会提前触发,前跳则会形成 overdue。如果 agent 已被某个轮次或另一项 maintenance task 占用,`runMaintenance()` 会拒绝此次认领record 保持活动,并由一个 `whenIdle()` wait 触发稍后的重试。被拒绝的 persistence preflight 或被收容的 framing同步入队失败同样会让 record 保持活动,但不会运行私有重试 timer后续 agent 活动进入 idle或成功的 Schedule 管理 preflight 会要求 owner 再次尝试。
获得准入的路径会先清空 pending persistence并通过 `runMaintenance()` 认领真正的 idle phase。该任务会重新折叠确切的 Session 后缀,从而确保在认领竞态中胜出的直接管理变更之后不会跟随陈旧 dispatch随后只采样一次 decision clock使用 JSON-escaped id 与 prompt 构造完整固定 reminder frame同步排入一次 `followup()`,再追加只含 id 的 dispatch。触发唤醒的 input 会保持 parked直到 maintenance 结束,因此 driver 无法在 dispatch 进入 log 前认领消息;只有该任务释放 phase 后owner 才会等待 dispatch barrier。framing 或同步入队失败会被收容,且不会追加 dispatch。append 失败会使该 owner fault因为消息可能已经入队。后续 prompt admission、request checkpoint 或模型失败都不能撤回 dispatch。

View File

@@ -403,6 +403,9 @@ export class Session implements SessionFace {
try {
const { result } = await this.history({ beforeSeq: loading.beforeSeq, maxMessages: PAGE_MESSAGES })
if (this.loadingOlder !== loading) return
// A concurrent gap repair may replace the window with a newer tail page.
// The captured older page no longer adjoins that window and must be dropped.
if (this.baseSeq !== loading.beforeSeq) return
if (!result.ok) return // keep the window as-is; do not overwrite openError (open already succeeded)
const older = result.value.events
if (older.length === 0) {

View File

@@ -286,6 +286,40 @@ describe('late event views', () => {
])
})
it('drops an older page after a concurrent gap repair advances the window base', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(logRange(50, 100), true)
await session.open()
const repair = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
const page = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = payload => payload.beforeSeq === undefined ? repair.promise : page.promise
const gapTail = ev.user(200, '修复后的新窗口')
session.handleMuxEnvelope('gap' as never, {
type: 'session/event', sessionId: SID, event: gapTail,
})
await vi.waitFor(() => {
expect(api.callsOf('session.history')).toHaveLength(2)
})
const loading = session.loadOlder()
repair.resolve(ok({
events: entries([...logRange(150, 200), gapTail]) as never[],
hasMore: true,
}))
await vi.waitFor(() => {
expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([200])
})
page.resolve(ok({
events: entries([...logRange(0, 44), ...plainTurn(44, 0, '陈旧问题', '陈旧回答')]) as never[],
hasMore: false,
}))
await loading
expect(session.getSnapshot()).toMatchObject({ hasMore: true })
expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([200])
})
it('resyncs when repeated older-page late views disagree on event identity', async () => {
const { api, session } = makeSession()
const newer = logRange(6, 12)

View File

@@ -22,7 +22,7 @@ Replay rejects unknown versions, extra fields, reused ids, and delete or dispatc
The generated [tool catalog](../../../docs/tool-catalog.md) owns the argument and output schemas for `schedule_create`, `schedule_list`, and `schedule_delete`. Their canonical values use camelCase record fields even though model input uses `after_seconds`.
`schedule_create` validates shape-only failures before persistence, then checkpoints, allocates a never-reused id, appends the create, and checkpoints again. `schedule_list` returns every active record in create order with `state: "scheduled" | "overdue"` and `deliveryMode: "session-local"`. `schedule_delete` rejects an empty or whitespace-padded id before persistence and appends only for an active id; an unknown or terminal id returns `{ id, deleted: false, code: "schedule_not_found" }` after its preflight.
One Agent-scoped queue serializes each accepted management transaction and the live owner's due transaction from preflight through any post-append barrier. Direct callers therefore cannot interleave a fold with another Schedule mutation or observe a dispatch before its own barrier. `schedule_create` validates shape-only failures before entering that queue, then checkpoints, allocates a never-reused id, appends the create, and checkpoints again. `schedule_list` returns every active record in create order with `state: "scheduled" | "overdue"` and `deliveryMode: "session-local"`. `schedule_delete` rejects an empty or whitespace-padded id before entering the queue and appends only for an active id; an unknown or terminal id returns `{ id, deleted: false, code: "schedule_not_found" }` after its preflight.
Every successful management preflight also asks the live owner to recompute. This matters after a create or delete barrier returned `persistence_uncertain`: a later list or mutation can confirm the retained batch and immediately arm or retire the now-durable record without a private persistence-retry timer.
@@ -79,7 +79,7 @@ The reminder appends after existing history and preserves its reusable prefix. I
## Known Limitations and Deferred Work
- **Session-local delivery only** — a reminder runs on time only while its original session is live; a cold session receives no external notification and processes an overdue record only after resume.
- **Activity-driven persistence retry** — a rejected due preflight leaves the overdue record active but starts no private retry timer; the owner retries after later Agent activity reaches idle or a successful Schedule management preflight asks it to recompute.
- **Activity-driven retry** — a rejected due preflight or contained framing/enqueue failure leaves the overdue record active but starts no private retry timer; the owner retries after later Agent activity reaches idle or a successful Schedule management preflight asks it to recompute.
- **After-only protocol** — version 1 rejects `at`, `every_seconds`, `cron`, and `time_zone`; those rules require later protocol variants rather than hidden compatibility fields.
- **Narrow crash duplicate window** — a crash after synchronous followup admission but before the dispatch checkpoint can repeat the reminder after recovery; the package does not claim model completion, user acknowledgement, or exactly-once external effects.
- **Load-order boundary** — the plugin does not scan or adopt agents that were already live when it loaded.

View File

@@ -22,7 +22,7 @@
生成的[工具目录](../../../docs/tool-catalog.md)负责 `schedule_create``schedule_list``schedule_delete` 的参数与输出 schema。虽然模型输入使用 `after_seconds`,但其规范值中的记录字段使用 camelCase。
`schedule_create` 会在持久化前验证只依赖输入形状的失败,随后执行检查点、分配永不复用的 id、追加 create再次执行检查点。`schedule_list` 按创建顺序返回所有活动记录,其中包含 `state: "scheduled" | "overdue"``deliveryMode: "session-local"``schedule_delete` 会在持久化前拒绝空 id 或前后带空白的 id并只为活动 id 追加事件;未知或已终结的 id 会在 preflight预检后返回 `{ id, deleted: false, code: "schedule_not_found" }`
一条 Agent-scoped 队列会将每项已接纳的管理事务与 live owner 的到期事务从 preflight 到任何 post-append barrier 全程串行化。因此,直接调用方无法让一次 fold 与另一项 Schedule 变更交错,也无法在自身的 barrier 前观察到 dispatch。`schedule_create` 会在进入该队列前验证只依赖输入形状的失败,随后执行检查点、分配永不复用的 id、追加 create再次执行检查点。`schedule_list` 按创建顺序返回所有活动记录,其中包含 `state: "scheduled" | "overdue"``deliveryMode: "session-local"``schedule_delete` 会在进入该队列前拒绝空 id 或前后带空白的 id并只为活动 id 追加事件;未知或已终结的 id 会在 preflight预检后返回 `{ id, deleted: false, code: "schedule_not_found" }`
每次成功的管理 preflight 还会要求 live owner 重新计算。这对 create 或 delete barrier 返回 `persistence_uncertain` 的情况很重要:后续 list 或 mutation 可以确认保留的 batch并立即 arm 或退役此时已持久化的 record而无需私有 persistence retry timer。
@@ -79,7 +79,7 @@ reminder_prompt_json: <JSON.stringify(prompt)>
## 已知限制与暂缓事项
- **仅限会话本地交付**:提醒只有在原会话 live 时才能准时运行cold 会话不会收到外部通知,只有恢复后才会处理 overdue 记录。
- **活动驱动的持久化重试**:到期 preflight 被拒绝后overdue 记录仍保持活动,但不会启动私有重试 timer后续 agent 活动进入 idle或成功的 Schedule 管理 preflight 要求 owner 重新计算后owner 会重试。
- **活动驱动的重试**:到期 preflight 被拒绝或 framing入队失败被收容overdue 记录仍保持活动,但不会启动私有重试 timer后续 agent 活动进入 idle或成功的 Schedule 管理 preflight 要求 owner 重新计算后owner 会重试。
- **仅支持 after 协议**:版本 1 拒绝 `at``every_seconds``cron``time_zone`;这些规则需要后续协议变体,而不是隐藏的兼容字段。
- **存在狭窄的崩溃重复窗口**:同步 `followup` 获得准入后、dispatch 检查点完成前发生崩溃,可能使提醒在恢复后重复;此包不承诺模型完成、用户确认或外部副作用恰好一次。
- **加载顺序边界**:插件不会扫描或接管加载时已经 live 的 agent。

View File

@@ -9,6 +9,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { AfterScheduleRecord } from './types.ts'
import { foldScheduleEvents, renderReminderFraming, ScheduleLogError } from './domain.ts'
import { flushSchedulePersistence } from './persistence.ts'
import { runScheduleTransaction } from './transaction.ts'
/** Largest delay that Node timers represent without clamping. */
export const MAX_TIMER_DELAY_MS = 2_147_483_647
@@ -102,7 +103,7 @@ export class ScheduleOwner {
private async runRequested(): Promise<void> {
while (this.requested && !this.stopping && !this.faulted) {
this.requested = false
await this.driveOnce()
await runScheduleTransaction(this.agent, () => this.driveOnce())
}
}

View File

@@ -18,6 +18,7 @@ import {
scheduleView,
} from './domain.ts'
import { flushSchedulePersistence } from './persistence.ts'
import { runScheduleTransaction } from './transaction.ts'
import type {
AfterScheduleRecord,
PersistenceUncertainError,
@@ -255,31 +256,33 @@ export function registerScheduleTools(
if (exec.agent !== agent) return internalError()
const invalid = validateCreateArgs(args)
if (invalid !== undefined) return invalid
const uncertain = await preflight(rootCtx, agent, 'create')
if (uncertain !== undefined) return uncertain
notifyDurableChange()
const folded = foldForTool(agent)
if (isToolError(folded)) return folded
const id = allocateScheduleId(folded)
let record: AfterScheduleRecord
try {
record = createAfterScheduleRecord(id, args.prompt, args.after_seconds, Date.now())
} catch (error: unknown) {
return error instanceof ScheduleInputError ? inputError(error) : internalError()
}
try {
agent.session.append('schedule/change', {
version: 1,
operation: 'create',
schedule: record,
})
} catch {
return internalError()
}
const barrier = await preflight(rootCtx, agent, 'create', id)
if (barrier !== undefined) return barrier
notifyDurableChange()
return scheduleView(record, Date.now())
return runScheduleTransaction(agent, async () => {
const uncertain = await preflight(rootCtx, agent, 'create')
if (uncertain !== undefined) return uncertain
notifyDurableChange()
const folded = foldForTool(agent)
if (isToolError(folded)) return folded
const id = allocateScheduleId(folded)
let record: AfterScheduleRecord
try {
record = createAfterScheduleRecord(id, args.prompt, args.after_seconds, Date.now())
} catch (error: unknown) {
return error instanceof ScheduleInputError ? inputError(error) : internalError()
}
try {
agent.session.append('schedule/change', {
version: 1,
operation: 'create',
schedule: record,
})
} catch {
return internalError()
}
const barrier = await preflight(rootCtx, agent, 'create', id)
if (barrier !== undefined) return barrier
notifyDurableChange()
return scheduleView(record, Date.now())
})
},
presentCall: args => present('Create reminder', 'other', args.prompt),
})))
@@ -291,13 +294,15 @@ export function registerScheduleTools(
output: { schema: LIST_OUTPUT_SCHEMA, render: renderValue },
async execute(_args, exec): Promise<ScheduleListValue> {
if (exec.agent !== agent) return internalError()
const uncertain = await preflight(rootCtx, agent, 'list')
if (uncertain !== undefined) return uncertain
notifyDurableChange()
const folded = foldForTool(agent)
if (isToolError(folded)) return folded
const now = Date.now()
return folded.active.map(record => scheduleView(record, now))
return runScheduleTransaction(agent, async () => {
const uncertain = await preflight(rootCtx, agent, 'list')
if (uncertain !== undefined) return uncertain
notifyDurableChange()
const folded = foldForTool(agent)
if (isToolError(folded)) return folded
const now = Date.now()
return folded.active.map(record => scheduleView(record, now))
})
},
presentCall: () => present('List reminders', 'read'),
})))
@@ -315,23 +320,25 @@ export function registerScheduleTools(
}
const id = ScheduleId(args.id)
if (exec.agent !== agent) return internalError()
const uncertain = await preflight(rootCtx, agent, 'delete', id)
if (uncertain !== undefined) return uncertain
notifyDurableChange()
const folded = foldForTool(agent)
if (isToolError(folded)) return folded
if (!folded.active.some(record => record.id === id)) {
return { id, deleted: false, code: 'schedule_not_found' }
}
try {
agent.session.append('schedule/change', { version: 1, operation: 'delete', id })
} catch {
return internalError()
}
const barrier = await preflight(rootCtx, agent, 'delete', id)
if (barrier !== undefined) return barrier
notifyDurableChange()
return { id, deleted: true }
return runScheduleTransaction(agent, async () => {
const uncertain = await preflight(rootCtx, agent, 'delete', id)
if (uncertain !== undefined) return uncertain
notifyDurableChange()
const folded = foldForTool(agent)
if (isToolError(folded)) return folded
if (!folded.active.some(record => record.id === id)) {
return { id, deleted: false, code: 'schedule_not_found' }
}
try {
agent.session.append('schedule/change', { version: 1, operation: 'delete', id })
} catch {
return internalError()
}
const barrier = await preflight(rootCtx, agent, 'delete', id)
if (barrier !== undefined) return barrier
notifyDurableChange()
return { id, deleted: true }
})
},
presentCall: args => present('Delete reminder', 'other', args.id),
})))

View File

@@ -0,0 +1,23 @@
/** Agent-scoped serialization for Schedule reads and durable mutations. */
import type { Agent } from '@deepseek-ai/dsh-agent'
const tails = new WeakMap<Agent, Promise<void>>()
/**
* Run one complete Schedule transaction after its exact Agent's prior transaction.
* @param agent - Exact Schedule owner and serialization key.
* @param operation - Complete preflight, fold, mutation, and postflight operation.
* @returns The operation result after exclusive execution.
*/
export async function runScheduleTransaction<T>(agent: Agent, operation: () => Promise<T>): Promise<T> {
const prior = tails.get(agent) ?? Promise.resolve()
const run = prior.then(operation)
const tail = run.then(() => undefined, () => undefined)
tails.set(agent, tail)
try {
return await run
} finally {
if (tails.get(agent) === tail) tails.delete(agent)
}
}

View File

@@ -16,7 +16,7 @@ const contexts: Context[] = []
interface ToolHarness {
readonly ctx: Context
readonly agent: Agent
readonly flushes: { count: number; outcomes: Array<'resolve' | 'reject'> }
readonly flushes: { count: number; outcomes: Array<'resolve' | 'reject' | Promise<'resolve' | 'reject'>> }
readonly changes: { count: number }
readonly disposeTools: () => void
}
@@ -50,11 +50,12 @@ async function harness(withPersistence = true): Promise<ToolHarness> {
await ctx.plugin(ToolRegistry)
const agent = stubAgent(ctx, `schedule-tools-${Math.random()}`)
ctx.agents.register(agent)
const flushes = { count: 0, outcomes: [] as Array<'resolve' | 'reject'> }
const flushes = { count: 0, outcomes: [] as Array<'resolve' | 'reject' | Promise<'resolve' | 'reject'>> }
if (withPersistence) {
ctx.on('session/flush', async () => {
flushes.count += 1
if (flushes.outcomes.shift() === 'reject') return Promise.reject(new Error('disk unavailable'))
const outcome = await (flushes.outcomes.shift() ?? 'resolve')
if (outcome === 'reject') return Promise.reject(new Error('disk unavailable'))
return true as const
})
}
@@ -278,6 +279,31 @@ describe('Schedule persistence failure boundaries', () => {
expect(test.changes.count).toBe(2)
})
it('serializes concurrent management transactions across both persistence barriers', async () => {
const test = await harness()
let releaseCreatePreflight: (() => void) | undefined
const createPreflight = new Promise<'resolve'>((resolve) => {
releaseCreatePreflight = () => { resolve('resolve') }
})
test.flushes.outcomes.push(createPreflight, 'reject', 'resolve')
const creating = execute(test, 'schedule_create', { prompt: 'persist me', after_seconds: 10 })
await vi.waitFor(() => { expect(test.flushes.count).toBe(1) })
const listing = execute(test, 'schedule_list', {})
await Promise.resolve()
expect(test.flushes.count).toBe(1)
if (releaseCreatePreflight === undefined) throw new Error('missing create preflight release')
releaseCreatePreflight()
expect(value(await creating)).toMatchObject({
code: 'persistence_uncertain', operation: 'create', id: 'schedule-1',
})
expect(value(await listing)).toEqual([
expect.objectContaining({ id: 'schedule-1', prompt: 'persist me' }),
])
expect(test.flushes.count).toBe(3)
})
it('returns uncertainty before create or delete reads when their preflight rejects', async () => {
const createTest = await harness()
createTest.flushes.outcomes.push('reject')

View File

@@ -186,8 +186,6 @@ interface SessionState {
/** One live session's initialization and bounded write-behind controller. */
interface LiveSessionState {
/** Exclusive end of the immutable Session prefix present when this lifecycle was first seen. */
seedEnd: number
/** Initialization settlement; retained after success and cleared only after rejection. */
init: Promise<void> | undefined
writes: SessionWriteBehind
@@ -1129,18 +1127,17 @@ export class PersistenceCoordinator<TornMarker = unknown> {
private createLiveState(session: Session): LiveSessionState {
let live: LiveSessionState
live = {
seedEnd: session.events.length,
init: undefined,
writes: this.createWriteBehind(session, () => this.ensureInitialized(session, live)),
}
return live
}
/** Start or join one initialization attempt, rebuilding the immutable seed prefix on retry. */
/** Start or join one initialization attempt; a retry borrows current Session events and reconciles the durable cursor. */
private ensureInitialized(session: Session, live: LiveSessionState): Promise<void> {
if (live.init !== undefined) return live.init
const init = this.serialize(session.header.id, async () => {
const seed = session.events.slice(0, live.seedEnd)
const seed = session.events
await this.onCreated(session, seed)
}).catch((error: unknown) => {
live.init = undefined

View File

@@ -55,7 +55,6 @@ interface MemoryConfig { store?: MemoryStore }
interface CoordinatorInternals {
states: Map<unknown, unknown>
live: Map<unknown, {
seedEnd: number
init: Promise<void> | undefined
writes: { pending: unknown[]; active: Promise<void> | undefined; hasWork: boolean }
}>
@@ -400,6 +399,8 @@ describe('PersistenceCoordinator retryable live initialization', () => {
const session = ctx.sessions.create(SessionId('retry-new-empty'))
await vi.waitFor(() => { expect(backend.loadAttempts).toBe(1) })
const first = ctx.sessions.flush(session)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
loadGate.resolve(undefined)
await expect(first).rejects.toThrow('transient init read failure')
const retries = [ctx.sessions.flush(session), ctx.sessions.flush(session)]
@@ -411,17 +412,14 @@ describe('PersistenceCoordinator retryable live initialization', () => {
// more reads.
expect(backend.loadAttempts).toBe(3)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.sessions.flush(session)
expect(backend.store.get(session.id)?.events.map(event => event.seq)).toEqual([0, 1])
const live = [...(coordinator as unknown as CoordinatorInternals).live.values()][0]
if (live === undefined) throw new Error('live controller was not retained')
expect(live.seedEnd).toBe(0)
expect(live.init).toBeInstanceOf(Promise)
expect(live).not.toHaveProperty('initialized')
expect(live).not.toHaveProperty('seed')
expect(live).not.toHaveProperty('seedEnd')
} finally {
loadGate.resolve(undefined)
retryGate.resolve(undefined)