diff --git a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md index fe9b64244e..5f55876e8c 100644 --- a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md +++ b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md index 0c00a457d6..1e29e1d8fd 100644 --- a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md @@ -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。 diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index f259eb6f6c..366c25e617 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -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) { diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index d9e5a5f470..658f6a3669 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -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>>() + const page = deferred>>() + 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) diff --git a/packages/schedule/tool-schedule/README.md b/packages/schedule/tool-schedule/README.md index 06587ba08f..07ce03587d 100644 --- a/packages/schedule/tool-schedule/README.md +++ b/packages/schedule/tool-schedule/README.md @@ -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. diff --git a/packages/schedule/tool-schedule/README.zh.md b/packages/schedule/tool-schedule/README.zh.md index 7ed23d3664..44098aa625 100644 --- a/packages/schedule/tool-schedule/README.zh.md +++ b/packages/schedule/tool-schedule/README.zh.md @@ -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: ## 已知限制与暂缓事项 - **仅限会话本地交付**:提醒只有在原会话 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。 diff --git a/packages/schedule/tool-schedule/src/runtime.ts b/packages/schedule/tool-schedule/src/runtime.ts index ebbe46d6f5..e91ff255f1 100644 --- a/packages/schedule/tool-schedule/src/runtime.ts +++ b/packages/schedule/tool-schedule/src/runtime.ts @@ -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 { while (this.requested && !this.stopping && !this.faulted) { this.requested = false - await this.driveOnce() + await runScheduleTransaction(this.agent, () => this.driveOnce()) } } diff --git a/packages/schedule/tool-schedule/src/tools.ts b/packages/schedule/tool-schedule/src/tools.ts index abcc7db1e7..df4ce30d5d 100644 --- a/packages/schedule/tool-schedule/src/tools.ts +++ b/packages/schedule/tool-schedule/src/tools.ts @@ -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 { 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), }))) diff --git a/packages/schedule/tool-schedule/src/transaction.ts b/packages/schedule/tool-schedule/src/transaction.ts new file mode 100644 index 0000000000..2435d6535e --- /dev/null +++ b/packages/schedule/tool-schedule/src/transaction.ts @@ -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>() + +/** + * 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(agent: Agent, operation: () => Promise): Promise { + 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) + } +} diff --git a/packages/schedule/tool-schedule/tests/tools.spec.ts b/packages/schedule/tool-schedule/tests/tools.spec.ts index 6071fa1f18..cb211990a9 100644 --- a/packages/schedule/tool-schedule/tests/tools.spec.ts +++ b/packages/schedule/tool-schedule/tests/tools.spec.ts @@ -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 { 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') diff --git a/packages/session/session-persistence/src/coordinator.ts b/packages/session/session-persistence/src/coordinator.ts index 3e2d448028..750763f0be 100644 --- a/packages/session/session-persistence/src/coordinator.ts +++ b/packages/session/session-persistence/src/coordinator.ts @@ -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 | undefined writes: SessionWriteBehind @@ -1129,18 +1127,17 @@ export class PersistenceCoordinator { 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 { 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 diff --git a/packages/session/session-persistence/tests/persistence.spec.ts b/packages/session/session-persistence/tests/persistence.spec.ts index 2629dc44e6..e7966f59c8 100644 --- a/packages/session/session-persistence/tests/persistence.spec.ts +++ b/packages/session/session-persistence/tests/persistence.spec.ts @@ -55,7 +55,6 @@ interface MemoryConfig { store?: MemoryStore } interface CoordinatorInternals { states: Map live: Map | undefined writes: { pending: unknown[]; active: Promise | 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)