refactor: outbox становится live-only стримом, cursor вынесен в :cursor-api
Семантика outbox'а — теперь чистый live-канал:
* OutboxStore.conversationEvents(id) — больше не принимает after-cursor.
Catchup (replay) делает клиент: journal.listFlow(afterSeq) + подписка
на live. Outbox ответственен только за уведомления «что-то произошло».
* OutboxStore.oldestCursor()/currentCursor()/OutboxGapException — удалены.
Эпоха и offset живут ТОЛЬКО в CursorHolder/CursorStore, переживают
рестарт и инкремент для каждого commit.
* DurableEvent: commit принимает блок { cursor -> MessageRecord }
(cursor выдаёт CursorStore; клиент не вычисляет offset сам).
* MessageRecord больше не несёт cursor — это не его ответственность.
Новые модули:
* :cursor-api — Cursor(epoch, offset) + CursorHolder / MutableCursorHolder
* :cursor-ksqlite — KsqliteCursorHolder (персистентный)
* :cursor-inmemory — для тестов
* :client-sync — LocalSyncAgent (мини-агент поверх :client для десктопа)
Удалены:
* :sync-core — старая референсная реализация, заменена
cursor-разделением и :client.
* outbox-ksqlite — CursorStore/Schema уехали в :cursor-ksqlite.
* OffsetSequencer / PersistentOffsetSequencer / InMemoryOffsetSequencer.
standalone:
* ChatAgent/ConversationLoop/DurableLog/ToolDispatcher/ReflectionScheduler/
ConversationEvents — подписка через push-паттерн (collect событий).
* A2aBridge — currentCursor() и conversationEvents(after=) убраны.
* SqliteStores — cursor_offset удалён из schema v4; seedNextFromJournal
читает MAX(created_at).
* Main.kt — outboxSequencer → outboxCursorHolder; user→agent (:server)
transport удалён; debug-routes удалены; A2A остался.
* Тесты ChatAgentTest/PersistenceTest переписаны на push-паттерн
(subscribe-before-act, snapshot∪live = итоговое состояние). 25/25 + 19/19 ✅
server / client:
* Routes эпоху читают из CursorHolder; снимки несут Cursor? для catchup.
* AgentikAgent и HttpEventStore — те же подписки, без after-параметра.
* ReconnectingOutbox / ReconnectingOutboxTest — без изменений API.
* JournalStore API расширен count(after=Instant?) для unread-badge.
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
// Транспорт-агностичный слой синхронизации: только `:proto` (Agent +
|
||||
// OutboxStore + snapshots), `:journal-api` (локальный стор бесед) и
|
||||
// `:cursor-api` (закладка курсора). Никакого ktor — поэтому набор целей
|
||||
// как у `:proto`, а не как у `:client` (который desktop-only).
|
||||
jvm()
|
||||
macosX64()
|
||||
macosArm64()
|
||||
iosX64()
|
||||
iosArm64()
|
||||
iosSimulatorArm64()
|
||||
linuxX64()
|
||||
linuxArm64()
|
||||
mingwX64()
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(project(":proto"))
|
||||
api(project(":journal-api"))
|
||||
api(project(":cursor-api"))
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(libs.kotlin.test)
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
implementation(project(":outbox-inmemory"))
|
||||
implementation(project(":journal-inmemory"))
|
||||
implementation(project(":cursor-inmemory"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package pw.binom.agentik.client.sync
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.agentik.cursor.MutableCursorHolder
|
||||
import pw.binom.agentik.journal.ConversationRecord
|
||||
import pw.binom.agentik.journal.ConversationStore
|
||||
import pw.binom.agentik.journal.MutableConversationStore
|
||||
import pw.binom.agentik.outbox.AgentEvent
|
||||
import pw.binom.agentik.outbox.OutboxGapException
|
||||
import pw.binom.agentik.proto.Agent
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Оборачивает [Agent] так, что [Agent.conversationStore] становится **локальным**
|
||||
* стором [localStore], синхронизированным с удалённым агентом по курсор-протоколу.
|
||||
*
|
||||
* Механика (см. `:cursor-api` [MutableCursorHolder]):
|
||||
* 1. **Обычный старт** — подписка на `outbox.agentEvents(after = cursor.current())`
|
||||
* с сохранённого курсора. Сервер отдаёт события (курсор живой) → применяем,
|
||||
* после каждого применённого события зовём `cursor.set(event.offset)`.
|
||||
* 2. **Курсор мёртв / ещё нет** (`OutboxGapException`, `cursor.current() == null`)
|
||||
* → **полный resync**: `conversationsSnapshot()` (состояние + его курсор `C`),
|
||||
* `reconcile` локального стора к снапшоту, `cursor.set(C)`, затем подписка
|
||||
* с того же `C`.
|
||||
*
|
||||
* Курсор можно «отставать» от состояния (повторный apply идемпотентен), но нельзя
|
||||
* «убегать вперёд» — поэтому [localStore] и [cursor] должны быть **durable парой**:
|
||||
* курсор без сохранённого состояния = потеря данных после рестарта.
|
||||
*
|
||||
* ## Владение
|
||||
* - [localStore], [cursor] и [scope] — **на стороне вызывающего**, обёртка их не
|
||||
* закрывает;
|
||||
* - обёртка владеет только фоновой корутиной синхронизации и самим [Agent]
|
||||
* (её [Agent.close] закрывает делегат).
|
||||
*/
|
||||
fun Agent.withLocalSync(
|
||||
localStore: MutableConversationStore,
|
||||
cursor: MutableCursorHolder,
|
||||
scope: CoroutineScope,
|
||||
): Agent = LocalSyncAgent(
|
||||
delegate = this,
|
||||
localStore = localStore,
|
||||
cursor = cursor,
|
||||
scope = scope,
|
||||
)
|
||||
|
||||
/** Пауза перед повтором при сетевой ошибке (не при gap — там resync сразу). */
|
||||
private val RESYNC_RETRY_DELAY = 2.seconds
|
||||
|
||||
private class LocalSyncAgent(
|
||||
private val delegate: Agent,
|
||||
private val localStore: MutableConversationStore,
|
||||
private val cursor: MutableCursorHolder,
|
||||
scope: CoroutineScope,
|
||||
) : Agent by delegate {
|
||||
|
||||
private val syncJob: Job = scope.launch { syncLoop() }
|
||||
|
||||
private suspend fun syncLoop() {
|
||||
while (currentCoroutineContext().isActive) {
|
||||
try {
|
||||
syncOnce()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Throwable) {
|
||||
// Сеть/5xx — пауза и повтор, локальный кэш сохраняем.
|
||||
delay(RESYNC_RETRY_DELAY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncOnce() {
|
||||
if (cursor.current() == null) fullResync()
|
||||
try {
|
||||
delegate.outbox.agentEvents(after = cursor.current()).collect { ce ->
|
||||
apply(ce.event)
|
||||
cursor.set(ce.offset)
|
||||
}
|
||||
} catch (_: OutboxGapException) {
|
||||
// Курсор мёртв (retention / смена epoch) — немедленный полный resync.
|
||||
fullResync()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fullResync() {
|
||||
val snap = delegate.conversationsSnapshot()
|
||||
reconcile(snap.conversations)
|
||||
cursor.set(snap.cursor)
|
||||
}
|
||||
|
||||
/**
|
||||
* Приводит локальный кэш к снапшоту: чего нет в снапшоте — удаляем,
|
||||
* всё из снапшота — upsert. Делает полный resync корректным (в т.ч.
|
||||
* «пропавшие» беседы = удалённые).
|
||||
*/
|
||||
private suspend fun reconcile(records: List<ConversationRecord>) {
|
||||
val fresh = records.mapTo(HashSet()) { it.id }
|
||||
val stale = ArrayList<String>()
|
||||
var offset = 0
|
||||
while (true) {
|
||||
val page = localStore.list(offset, ConversationStore.PAGE_SIZE)
|
||||
if (page.isEmpty()) break
|
||||
page.forEach { if (it.id !in fresh) stale += it.id }
|
||||
offset += page.size
|
||||
}
|
||||
stale.forEach { localStore.delete(it) }
|
||||
records.forEach { localStore.upsert(it) }
|
||||
}
|
||||
|
||||
private suspend fun apply(ev: AgentEvent) {
|
||||
when (ev) {
|
||||
is AgentEvent.Created -> {
|
||||
// Created не несёт title/timestamps — нужно сходить в remote.
|
||||
val rec = delegate.conversationStore.get(ev.conversationId)
|
||||
if (rec != null) localStore.upsert(rec)
|
||||
}
|
||||
is AgentEvent.Deleted -> localStore.delete(ev.id)
|
||||
is AgentEvent.Renamed -> localStore.rename(ev.id, ev.title)
|
||||
is AgentEvent.Touched -> localStore.touch(ev.id, ev.updatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only projection локального кэша — клиент через него только
|
||||
* читает (`get` / `list` / `listFlow`).
|
||||
*/
|
||||
override val conversationStore: ConversationStore = object : ConversationStore {
|
||||
override suspend fun get(id: String): ConversationRecord? = localStore.get(id)
|
||||
override suspend fun list(offset: Int, limit: Int): List<ConversationRecord> = localStore.list(offset, limit)
|
||||
override fun close() {} // owned by outer close
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
syncJob.cancel()
|
||||
delegate.close()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user