refactor(schedule): keep reminder delivery conversational
This commit is contained in:
@@ -55,7 +55,6 @@ interface MemoryConfig { store?: MemoryStore }
|
||||
interface CoordinatorInternals {
|
||||
states: Map<unknown, unknown>
|
||||
live: Map<unknown, {
|
||||
init: Promise<void> | undefined
|
||||
writes: { pending: unknown[]; active: Promise<void> | undefined; hasWork: boolean }
|
||||
}>
|
||||
chains: Map<unknown, unknown>
|
||||
@@ -183,7 +182,6 @@ class ControlledBackend implements PersistenceBackend<never> {
|
||||
loadAttempts = 0
|
||||
repairAttempts = 0
|
||||
beforeAppend?: (attempt: number) => Promise<void>
|
||||
afterAppend?: (attempt: number) => Promise<void>
|
||||
beforeLoadStored?: (attempt: number, signal?: AbortSignal) => Promise<void>
|
||||
/** When set, the declared seek hook delegates here so readFrom exercises it; unset throws (tests set it first). */
|
||||
seekHook?: (id: SessionId, fromSeq: number, signal?: AbortSignal) => Promise<StoredSuffix | undefined>
|
||||
@@ -220,7 +218,6 @@ class ControlledBackend implements PersistenceBackend<never> {
|
||||
} else {
|
||||
entry.events.push(...structuredClone(events) as SessionEvent[])
|
||||
}
|
||||
await this.afterAppend?.(attempt)
|
||||
}
|
||||
|
||||
async commitRepair(m: SessionHeader, _tornMarker: undefined, closers: readonly SessionEvent[]): Promise<void> {
|
||||
@@ -376,213 +373,6 @@ describe('PersistenceCoordinator bounded writes', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('PersistenceCoordinator retryable live initialization', () => {
|
||||
it('retries a rejected first storage read for a new empty session', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const backend = new ControlledBackend()
|
||||
const loadGate = Promise.withResolvers<undefined>()
|
||||
const retryGate = Promise.withResolvers<undefined>()
|
||||
backend.beforeLoadStored = async (attempt) => {
|
||||
if (attempt === 1) {
|
||||
await loadGate.promise
|
||||
throw new Error('transient init read failure')
|
||||
}
|
||||
if (attempt === 2) await retryGate.promise
|
||||
}
|
||||
let coordinator!: PersistenceCoordinator<never>
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
coordinator = new PersistenceCoordinator(inner, backend)
|
||||
}, { inject: ['sessions'] }))
|
||||
|
||||
try {
|
||||
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 })
|
||||
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)]
|
||||
await vi.waitFor(() => { expect(backend.loadAttempts).toBe(2) })
|
||||
retryGate.resolve(undefined)
|
||||
await expect(Promise.all(retries)).resolves.toEqual([true, true])
|
||||
// The one shared retry performs the normal new-session probe and
|
||||
// createCore's collision recheck; a second initialization would add two
|
||||
// more reads.
|
||||
expect(backend.loadAttempts).toBe(3)
|
||||
|
||||
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.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)
|
||||
await fiber.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the backend cursor when a fork seed committed before initialization rejected', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const backend = new ControlledBackend()
|
||||
const appendGate = Promise.withResolvers<undefined>()
|
||||
backend.afterAppend = async (attempt) => {
|
||||
if (attempt === 1) {
|
||||
await appendGate.promise
|
||||
throw new Error('uncertain init write')
|
||||
}
|
||||
}
|
||||
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-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('uncertain init write')
|
||||
await expect(ctx.sessions.flush(session)).resolves.toBe(true)
|
||||
|
||||
expect(backend.appendAttempts).toBe(1)
|
||||
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('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<undefined>()
|
||||
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<undefined>()
|
||||
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)
|
||||
const backend = new ControlledBackend()
|
||||
const id = SessionId('retry-resume-adoption')
|
||||
const stored = oneTurnLog()
|
||||
backend.store.set(id, { meta: meta(id, '/w'), events: structuredClone(stored) })
|
||||
const appendGate = Promise.withResolvers<undefined>()
|
||||
backend.beforeAppend = async (attempt) => {
|
||||
if (attempt === 1) {
|
||||
await appendGate.promise
|
||||
throw new Error('transient 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: stored, meta: { cwd: '/w' } })
|
||||
await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) })
|
||||
const first = ctx.sessions.flush(session)
|
||||
appendGate.resolve(undefined)
|
||||
await expect(first).rejects.toThrow('transient adoption write failure')
|
||||
await expect(ctx.sessions.flush(session)).resolves.toBe(true)
|
||||
|
||||
expect(backend.appendAttempts).toBe(2)
|
||||
expect(backend.store.get(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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('PersistenceCoordinator stored identity', () => {
|
||||
it('rejects a mismatched backend header before repair or state publication', async () => {
|
||||
const ctx = new Context()
|
||||
|
||||
Reference in New Issue
Block a user