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)