diff --git a/packages/client/ui-schedule/tests/reminder-row.spec.tsx b/packages/client/ui-schedule/tests/reminder-row.spec.tsx
index aec78fa423..7e002f1fbb 100644
--- a/packages/client/ui-schedule/tests/reminder-row.spec.tsx
+++ b/packages/client/ui-schedule/tests/reminder-row.spec.tsx
@@ -9,6 +9,66 @@ import { zh } from '../src/client/locales.ts'
const t: ReminderRowProps['t'] = makeTranslate(zh)
+const invalidSidecars: ReadonlyArray<{ name: string; view: unknown }> = [
+ { name: 'non-object', view: undefined },
+ { name: 'null', view: null },
+ { name: 'array', view: [] },
+ {
+ name: 'missing schedule id',
+ view: {
+ scheduleId: null,
+ prompt: 'not trusted',
+ occurrenceAt: '2026-08-05T08:00:00.000Z',
+ deliveryMode: 'session-local',
+ },
+ },
+ {
+ name: 'empty schedule id',
+ view: {
+ scheduleId: '',
+ prompt: 'not trusted',
+ occurrenceAt: '2026-08-05T08:00:00.000Z',
+ deliveryMode: 'session-local',
+ },
+ },
+ {
+ name: 'non-string prompt',
+ view: {
+ scheduleId: 'schedule-7',
+ prompt: 7,
+ occurrenceAt: '2026-08-05T08:00:00.000Z',
+ deliveryMode: 'session-local',
+ },
+ },
+ {
+ name: 'non-string occurrence',
+ view: {
+ scheduleId: 'schedule-7',
+ prompt: 'not trusted',
+ occurrenceAt: 7,
+ deliveryMode: 'session-local',
+ },
+ },
+ {
+ name: 'empty occurrence',
+ view: {
+ scheduleId: 'schedule-7',
+ prompt: 'not trusted',
+ occurrenceAt: '',
+ deliveryMode: 'session-local',
+ },
+ },
+ {
+ name: 'unsupported delivery mode',
+ view: {
+ scheduleId: 'schedule-7',
+ prompt: 'not trusted',
+ occurrenceAt: '2026-08-05T08:00:00.000Z',
+ deliveryMode: 'external',
+ },
+ },
+]
+
afterEach(cleanup)
function props(view: unknown): ReminderRowProps {
@@ -40,13 +100,8 @@ describe('ReminderRow', () => {
expect(time.getAttribute('datetime')).toBe('2026-08-05T08:00:00.000Z')
})
- it('contains an incompatible sidecar as a visible unavailable receipt', () => {
- render()
+ it.each(invalidSidecars)('contains an incompatible $name sidecar as an unavailable receipt', ({ view }) => {
+ render()
expect(screen.getByText('提醒回执不可用 · schedule/change')).toBeTruthy()
expect(screen.queryByText('not trusted')).toBeNull()
diff --git a/packages/session/session-persistence/src/coordinator.ts b/packages/session/session-persistence/src/coordinator.ts
index e27ff9476a..3e2d448028 100644
--- a/packages/session/session-persistence/src/coordinator.ts
+++ b/packages/session/session-persistence/src/coordinator.ts
@@ -1143,7 +1143,7 @@ export class PersistenceCoordinator {
const seed = session.events.slice(0, live.seedEnd)
await this.onCreated(session, seed)
}).catch((error: unknown) => {
- if (live.init === init) live.init = undefined
+ live.init = undefined
throw error
})
live.init = init
@@ -1254,7 +1254,7 @@ export class PersistenceCoordinator {
if (tracked.materialized || tracked.cursor !== 0) {
throw new Error(`session "${session.header.id}" lost its persisted artifact during live initialization`)
}
- if (seed.length > 0) await this.appendCore(session.header.id, seed)
+ await this.appendCore(session.header.id, seed)
return
}
await this.adoptLivePrefix(session, seed, stored, tracked)
diff --git a/packages/session/session-persistence/tests/persistence.spec.ts b/packages/session/session-persistence/tests/persistence.spec.ts
index 4d46bbe1fc..2629dc44e6 100644
--- a/packages/session/session-persistence/tests/persistence.spec.ts
+++ b/packages/session/session-persistence/tests/persistence.spec.ts
@@ -467,6 +467,87 @@ describe('PersistenceCoordinator retryable live initialization', () => {
}
})
+ it('retries a fork seed when initialization rejects before materialization', async () => {
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ const backend = new ControlledBackend()
+ const appendGate = Promise.withResolvers()
+ backend.beforeAppend = async (attempt) => {
+ if (attempt === 1) {
+ await appendGate.promise
+ throw new Error('pre-commit init write failure')
+ }
+ }
+ const fiber = await ctx.plugin(Object.assign((inner: Context) => {
+ new PersistenceCoordinator(inner, backend)
+ }, { inject: ['sessions'] }))
+
+ try {
+ const seed = oneTurnLog()
+ const session = ctx.sessions.create(SessionId('retry-unmaterialized-fork-seed'), {
+ seed,
+ meta: { cwd: '/w', seedLength: seed.length },
+ })
+ await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) })
+ const first = ctx.sessions.flush(session)
+ appendGate.resolve(undefined)
+ await expect(first).rejects.toThrow('pre-commit init write failure')
+ await expect(ctx.sessions.flush(session)).resolves.toBe(true)
+
+ expect(backend.appendAttempts).toBe(2)
+ expect(backend.store.get(session.id)?.events.map(event => event.seq))
+ .toEqual([0, 1, 2, 3, 4, 5, 6])
+ } finally {
+ appendGate.resolve(undefined)
+ await fiber.dispose()
+ await ctx.fiber.dispose()
+ }
+ })
+
+ it('rejects a retry when its adopted durable prefix disappears', async () => {
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ const backend = new ControlledBackend()
+ const id = SessionId('retry-missing-adopted-prefix')
+ const seed = oneTurnLog()
+ const stored = seed.slice(0, 1)
+ const storedMeta = meta(id, '/w')
+ backend.store.set(id, { meta: storedMeta, events: structuredClone(stored) })
+ const appendGate = Promise.withResolvers()
+ backend.beforeAppend = async (attempt) => {
+ if (attempt === 1) {
+ await appendGate.promise
+ throw new Error('pre-commit adoption write failure')
+ }
+ }
+ const fiber = await ctx.plugin(Object.assign((inner: Context) => {
+ new PersistenceCoordinator(inner, backend)
+ }, { inject: ['sessions'] }))
+
+ try {
+ const session = ctx.sessions.create(id, {
+ seed,
+ meta: { cwd: '/w', seedLength: seed.length },
+ })
+ await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) })
+ const first = ctx.sessions.flush(session)
+ appendGate.resolve(undefined)
+ await expect(first).rejects.toThrow('pre-commit adoption write failure')
+
+ backend.store.delete(id)
+ await expect(ctx.sessions.flush(session))
+ .rejects.toThrow('lost its persisted artifact during live initialization')
+ expect(backend.appendAttempts).toBe(1)
+ } finally {
+ appendGate.resolve(undefined)
+ if (!backend.store.has(id)) {
+ backend.store.set(id, { meta: storedMeta, events: structuredClone(stored) })
+ }
+ await fiber.dispose()
+ await ctx.fiber.dispose()
+ }
+ })
+
it('retries only a missing suffix after stored-session adoption rejects', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)