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:
2026-10-05 23:33:35 +03:00
parent 22a167ed03
commit 9e888227a3
115 changed files with 2147 additions and 6025 deletions
+1
View File
@@ -24,6 +24,7 @@ kotlin {
// здесь). Message-события несут общие типы содержимого из
// низкоуровневого :content-api (Content/MessageContext/TurnTokens).
api(project(":content-api"))
api(project(":cursor-api"))
api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.core)
api(libs.kotlinx.serialization.json)
@@ -12,7 +12,9 @@ import kotlinx.serialization.Serializable
* ([AgentEvent] via `/events` и [DurableEvent] via `/conversations/{id}/events`);
* [CommonEvent] — for those who need everything in one place.
*
* Server endpoint: `GET /events/all` (SSE), or replay via `OutboxStore.events(after)`.
* **Live-уведомление, не носитель позиции.** [date] — момент эмиссии (UTC),
* только для отображения/сортировки. Монотонный курсор для удалённого resume
* живёт в sync-слое; outbox его не хранит и не выдаёт.
*
* **История**: до 2026-09-21 жил в `:proto` как `pw.binom.agentik.proto.CommonEvent`;
* при миграции в `:outbox-api` был оставлен typealias в `:proto` для backward-compat,
@@ -23,23 +25,13 @@ import kotlinx.serialization.Serializable
*/
@Serializable
sealed interface CommonEvent {
/** Момент эмиссии в UTC. Только для отображения/сортировки — **не** курсор. */
/** Момент эмиссии в UTC. Только для отображения/сортировки. */
val date: Instant
/**
* Монотонный per-agent offset события — **курсор** (см. [Cursor]).
*
* Присваивается writer'ом через [OffsetSequencer.reserve] в тот же момент,
* что и `seq` соответствующей строки состояния (сначала строка, потом
* событие). Клиенты оперируют [Cursor], а не [date].
*/
val offset: Long
@Serializable
@SerialName("agent")
data class Agent(
override val date: Instant,
override val offset: Long,
val event: AgentEvent,
) : CommonEvent
@@ -47,7 +39,6 @@ sealed interface CommonEvent {
@SerialName("conversation")
data class Conversation(
override val date: Instant,
override val offset: Long,
val conversationId: String,
val event: DurableEvent,
) : CommonEvent
@@ -1,51 +0,0 @@
package pw.binom.agentik.outbox
import kotlin.random.Random
import kotlinx.serialization.Serializable
/**
* Курсор события — **позиция в общем монотонном потоке событий агента**.
*
* Состоит из двух частей:
* - [offset] — per-agent монотонный номер события (0-based). Именно он, а не
* wall-clock [CommonEvent.date], является курсором: несколько событий могут
* иметь одинаковый [date] (например `UserMessage` и `ToolCall` в одну
* миллисекунду), но offset'ы всегда строго возрастают и уникальны. Фильтрация
* `offset > after.offset` не теряет события на «ничьих» по времени.
* - [epoch] — идентификатор «мира» счётчика. Меняется при сбросе/восстановлении
* БД, из-за которого offset'ы теряют монотонность. Клиент хранит epoch в своём
* курсоре; несовпадение epoch → сервер сигналит gap ([OutboxGapException]) →
* клиент делает полный resync. Обычный **рестарт** сервера epoch НЕ меняет
* (счётчик персистентный), поэтому клиент продолжает инкрементально.
*
* **Семантика подписки**: [Cursor.offset] — **эксклюзивная** граница.
* `events(after = cursor)` отдаёт события со строго большим offset. Практически
* клиент кладёт сюда offset последнего применённого события, либо [OutboxStore.currentCursor]
* из снапшота.
*
* **Почему не `Instant` и не «id ASC»**: `Instant` лоссов при совпадении millis,
* а `id` — случайный UUID, который не задаёт порядок записи.
*
* **Переполнение счётчика**: `Long` на агента неисчерпаем (≈4.6·10¹⁷ ходов при
* 20 событиях/ход — это ~1.5·10⁷ лет при 1000 ходов/с). Заворачивать его нельзя
* (сломает монотонность), поэтому при любом сбое, инвалидирующем счётчик
* (сброс/восстановление БД), **ротируется [epoch]** и все клиенты делают
* полный resync — это и есть «обработка переполнения», а не wrap.
*/
@Serializable
data class Cursor(
val epoch: String,
val offset: Long,
) {
override fun toString(): String = "$epoch:$offset"
companion object {
/**
* Новый случайный [epoch] (opaque-строка). Новый epoch = «новый мир»
* счётчика: используется при первичной инициализации персистентного
* счётчика и при инвалидации offset-пространства — клиенты с прежним
* курсором получат [OutboxGapException] и сделают полный resync.
*/
fun newEpoch(): String = Random.nextLong().toString(16).padStart(16, '0')
}
}
@@ -1,25 +0,0 @@
package pw.binom.agentik.outbox
/**
* Персистентное хранилище позиции счётчика — [Cursor] (`epoch` + `offset`).
*
* Единственный мост между [OffsetSequencer] (чистая логика монотонного
* счётчика) и durable-носителем (`outbox-ksqlite`). Секвенсор читает позицию
* один раз при создании и держит `epoch`/`offset` в памяти (см.
* [OffsetSequencer] KDoc), поэтому [load] синхронный; [save] — durable
* запись, вызывается на каждом [OffsetSequencer.reserve].
*
* Вызовы сериализованы самим [PersistentOffsetSequencer] (его `Mutex`),
* так что реализация может не иметь собственной синхронизации.
*/
interface CursorStore {
/**
* Текущая позиция счётчика, или `null` если он ещё не инициализирован
* (пустая БД / первый запуск). Для пустого хранилища [PersistentOffsetSequencer]
* сгенерирует новый `epoch` и стартовый offset.
*/
fun load(): Cursor?
/** Записать позицию durable. */
fun save(cursor: Cursor)
}
@@ -10,14 +10,14 @@ import kotlin.time.Instant
/**
* Элемент **durable**-потока диалога.
*
* Каждое событие несёт [date] — момент эмиссии в UTC. Используется клиентом
* как курсор («где остановился») при обрыве/переподключении и для разрешения
* порядка при равных timestamps.
* Каждое событие несёт [date] — момент эмиссии в UTC (для отображения и
* разрешения порядка при равных timestamps).
*
* Это «целые», сохраняемые события: сообщения ([UserMessage]/[AssistantMessage]),
* вызовы тулов ([ToolCall]/[ToolResult]/[ToolFailed]), терминаторы хода
* ([Interrupted]/[Error]) и lifecycle ([ConversationClosing]/[CompactionTriggered]).
* Их можно перезапросить по курсору (`after`).
* Durable-история для перезапроса живёт в
* [pw.binom.agentik.journal.JournalStore].
*
* **Стриминг ответа и маркеры фаз хода — НЕ здесь.** Дельты текста/картинок
* и маркеры `Working`/`End` живут в [OnlineEvent] (live-only, не сохраняются).
@@ -1,40 +1,25 @@
package pw.binom.agentik.outbox
/**
* Mutable вариант [OutboxStore] — добавляет producer-операции [reserveOffset]
* и [append].
* Mutable вариант [OutboxStore] — добавляет producer-операцию [append].
*
* Предназначен **только для producer'ов** (ChatAgent, ConversationLoop,
* ToolDispatcher, sub-agents, A2A-bridge). Consumer'ы принимают read-only
* [OutboxStore] — тогда невозможно случайно писать в store из observer'а.
*
* ## Контракт записи (порядок важен)
* ## Контракт записи
* ```
* val n = outbox.reserveOffset() // 1. забронировать offset
* journal.append(record.copy(seq = n)) // 2. сначала состояние
* outbox.append(event.copy(offset = n)) // 3. потом событие
* journal.append(record) // 1. сначала durable состояние (audit)
* outbox.append(event) // 2. потом live-уведомление
* ```
* «Сначала состояние, потом событие» — инвариант, на котором держится
* [OutboxStore.currentCursor]: к моменту, когда событие `n` появилось в
* outbox, строка состояния со `seq = n` уже записана.
*
* Offset **обязан** быть выставлен в [CommonEvent.offset]; store проверяет
* строгую монотонность и бросает [IllegalArgumentException] на нарушение.
* Событие — это уведомление о том, что durable-факт уже записан. Позицию в
* потоке (курсор) outbox не ведёт: монотонный порядок для удалённого
* доступа держит sync-слой.
*/
interface MutableOutboxStore : OutboxStore {
/**
* Забронировать следующий монотонный offset (делегирует в
* [OffsetSequencer.reserve]). Вызывается **до** записи состояния.
*/
suspend fun reserveOffset(): Long
/**
* Положить событие в лог. [CommonEvent.offset] должен быть уже выставлен
* (обычно значением из [reserveOffset]).
*
* **Не идемпотентно** — повторный append с тем же offset'ом нарушает
* монотонность и бросит исключение (защита от двойной записи).
* Опубликовать событие в live-поток.
*/
suspend fun append(event: CommonEvent)
}
@@ -1,39 +0,0 @@
package pw.binom.agentik.outbox
/**
* Источник монотонных offset'ов **для одного агента**.
*
* Один счётчик на агента, сквозной по всем сущностям (conversation + message +
* lifecycle): это даёт единый [Cursor] на всё — глобальный курсор (чат
* появился/умер/переименован) и per-chat курсор суть просто закладки в одном
* потоке offset'ов (как offset одного Kafka-topic'а с ключом `conversationId`).
*
* **Кто владеет счётчиком**: writer-сторона. В `standalone` это персистентный
* счётчик в той же SQLite-БД, что и журнал, — иначе рестарт сервера сбросил бы
* offset'ы, а у клиента в локальной БД остался бы старый курсор. Персистентность
* даёт дешёвый инкрементальный resume после рестарта; полная инвалидация
* (сброс/восстановление БД) закрывается ротацией [epoch].
*
* **Порядок записи (инвариант)**: сначала пишется строка состояния с
* `seq = reserve()`, потом событие с `offset = <тот же>`. Тогда «состояние
* с offset ≤ C» гарантированно уже записано в момент чтения снапшота с
* курсором C, и всё, что `> C`, придёт потоком.
*
* **`epoch()`/`current()` — не-`suspend`**: persistent-реализация читает
* `(epoch, counter)` один раз при создании (в конструкторе, где и так идёт
* синхронный I/O открытия БД) и держит в памяти; на диск пишет только
* [reserve]. Это позволяет читать курсор из любого места без корутины.
*/
interface OffsetSequencer {
/**
* Идентификатор текущей эпохи счётчика (см. [Cursor.epoch]). Стабилен между
* рестартами, пока счётчик персистентный.
*/
fun epoch(): String
/** Следующий offset, который будет выдан [reserve] (next offset to assign). */
fun current(): Long
/** Забронировать следующий offset; монотонно возрастает на 1 (durable). */
suspend fun reserve(): Long
}
@@ -1,6 +1,7 @@
package pw.binom.agentik.outbox
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
/**
* Live-канал **онлайн-событий** ([OnlineEvent]) диалога — стриминга ответа
@@ -27,7 +28,8 @@ interface OnlineOutbox : AutoCloseable {
* Подписка на live-поток онлайн-событий диалога [conversationId].
* События, эмитнутые до подписки, не приходят.
*/
fun onlineEvents(conversationId: String): Flow<OnlineEvent>
fun onlineEvents(conversationId: String): Flow<OnlineEvent> =
onlineEvents().filter { it.conversationId == conversationId }
/** Освобождает ресурсы. Idempotent. */
override fun close()
@@ -1,34 +0,0 @@
package pw.binom.agentik.outbox
/**
* Курсор клиента вышел за пределы retention'а outbox'а, **или** принадлежит
* другой [Cursor.epoch].
*
* Это **не ошибка выполнения**, а сигнал протокола: «твой курсор мёртв — я не
* могу отдать непрерывный поток событий, начиная с него». Клиент обязан:
* 1. очистить/пометить свой локальный кэш как устаревший;
* 2. запросить у сервера свежий **snapshot состояния** (он вернёт и состояние,
* и актуальный [Cursor]);
* 3. подписаться на события `after = <cursor из снапшота>` и накатить snapshot,
* затем буферизованные дельты.
*
* Бросается **изнутри** [OutboxStore.events] / [OutboxStore.conversationEvents] /
* [OutboxStore.agentEvents] (то есть из Flow, а не отдельной pre-check'ом) — так
* проверка делается под тем же lock'ом, что и регистрация подписчика, и не
* гоняется с конкурентной эвикцией.
*
* **Retry-политики НЕ должна этому исключению ретраить** (см.
* `ReconnectingOutbox`): повторный connect с тем же курсором даст тот же gap и
* превратится в бесконечный цикл. Обработка — resync, не backoff.
*/
class OutboxGapException(
/** Курсор, с которого клиент просил поток (может быть `null` для live-only). */
val requested: Cursor?,
/** Актуальный курсор сервера (`currentCursor()`). */
val current: Cursor,
/** Минимальный курсор, с которого ещё можно продолжить поток (`oldestCursor()`). */
val oldest: Cursor,
) : RuntimeException(
"Outbox cursor is out of retention: requested=$requested, " +
"oldest=$oldest, current=$current. Re-snapshot the full state."
)
@@ -5,83 +5,49 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
/**
* Bounded-tail лог **durable**-событий агента.
* Live-шина **durable**-событий агента.
*
* Хранит [CommonEvent] — «целые» факты хода ([DurableEvent]) и lifecycle
* диалогов ([AgentEvent]). Высокочастотный стриминг ответа (дельты текста и
* картинок) сюда **не попадает** — он живёт в [OnlineOutbox] (live-only, не
* сохраняется и не реплеится).
* картинок) сюда **не попадает** — он живёт в [OnlineOutbox] (live-only,
* не сохраняется и не реплеится).
*
* ## Два уровня хранения
* 1. **Этот store** — короткий bounded tail (live SSE + недавний replay),
* эвиктится по TTL/cap (implementation-defined).
* 2. **Журнал (`:journal-api`)** — полный audit log, никогда не эвиктится.
* Source of truth для всего прошлого. Он и есть «полное состояние»,
* которое запрашивает клиент при resync'е.
* ## Роль
* outbox — это **уведомления в моменте**: расширения агента (reflection,
* skill mining, sub-agents) и UI подписываются на живой поток. Историю
* («что было раньше») отдаёт [pw.binom.agentik.journal.JournalStore] — полный
* durable audit. Всё, что нужно для *удалённого* доступа (resume с курсора,
* снапшот + дельты, gap detection), — забота sync-слоя поверх journal/outbox,
* а не этого интерфейса.
*
* ## Курсор, а не дата
* Позиция в потоке — монотонный [Cursor] `(epoch, offset)`, а не wall-clock
* [CommonEvent.date]. Offset уникален и упорядочен даже когда два события
* делят одну миллисекунду. `Instant` для этого не годится (лоссов на ничьих),
* случайный `id` — тоже (не задаёт порядок записи).
*
* ## Протокол клиента (гарантия «в итоге корректное состояние»)
* ```
* // 1. Пробуем продолжить с сохранённого курсора.
* try {
* outbox.conversationEvents(after = saved, conversationId = id).collect { apply(it) }
* } catch (e: OutboxGapException) {
* // 2. Курсор мёртв — берём свежий снапшот (состояние + его курсор).
* val snap = agent.chatSnapshot(id) // { state, cursor }
* clearLocal(); applySnapshot(snap.state)
* // 3. Подписка с курсора снапшота; дельты > cursor накатываются поверх.
* outbox.conversationEvents(after = snap.cursor, conversationId = id).collect { apply(it) }
* }
* ```
* Точный порядок на стороне сервера/snapshot'а (subscribe-before-snapshot,
* буферизация дельт, idempotent apply) описан в `:client/README.md`.
*
* ## Gap detection
* `after != null && after.offset < oldestCursor().offset` (или другой
* [Cursor.epoch]) → [OutboxGapException] бросается **изнутри** Flow. Проверка
* идёт под тем же lock'ом, что и регистрация подписчика (одним критическим
* участком), поэтому не гоняется с конкурентной эвикцией и не теряет события
* в окне «snapshot → live».
* ## Курсора и replay здесь нет
* [CommonEvent.date] — только метка времени для отображения/сортировки.
* Подписки отдают события, эмитированные **после** вызова (live-only).
* Клиенту, которому нужен пропущенный хвост, следует читать journal.
*
* ## Read-only
* Интерфейс предоставляет только чтение. Запись — [MutableOutboxStore].
*
* ## Подписки
* Каждый вызов [events] / [conversationEvents] / [agentEvents] — **новая
* независимая подписка** (cold Flow). `after == null` → только live (события
* с момента вызова). Иные consumer'ы видят тот же live-tail; каждая подписка —
* своя проекция.
* независимая подписка** (cold Flow). Иные consumer'ы видят тот же live-tail;
* каждая подписка — своя проекция.
*/
interface OutboxStore : AutoCloseable {
/**
* Подписка на события.
*
* - `after == null` → **только live** (события с момента вызова, replay
* буфера не отдаётся);
* - `after != null` → сначала **catchup** всех буферизованных событий с
* `offset > after.offset` (по возрастанию offset), затем live.
*
* @throws OutboxGapException изнутри Flow, если [after] старше
* [oldestCursor] (retention gap) или принадлежит другой эпохе.
* Живой поток событий. События, эмитнутые до подписки, не приходят.
*/
fun events(after: Cursor?): Flow<CommonEvent>
fun events(): Flow<CommonEvent>
/**
* Подписка только на conversation-события ([CommonEvent.Conversation]).
*
* - `conversationId == null` → все диалоги;
* - `conversationId != null` → только этот диалог.
*
* Семантика [after] и `gap` идентична [events].
*/
fun conversationEvents(after: Cursor?, conversationId: String? = null): Flow<CommonEvent.Conversation> =
events(after)
fun conversationEvents(conversationId: String? = null): Flow<CommonEvent.Conversation> =
events()
.filterIsInstance<CommonEvent.Conversation>()
.let { filtered ->
if (conversationId == null) filtered
@@ -91,32 +57,9 @@ interface OutboxStore : AutoCloseable {
/**
* Подписка только на agent-события ([CommonEvent.Agent] — создание/удаление/
* переименование диалога).
*
* Семантика [after] и `gap` идентична [events].
*/
fun agentEvents(after: Cursor?): Flow<CommonEvent.Agent> =
events(after).filterIsInstance<CommonEvent.Agent>()
/**
* Актуальный курсор: offset последнего **записанного** события
* (`lastOffset`). Это «commit point» снапшота: состояние со `seq <= cursor.offset`
* уже в БД, всё, что `> cursor.offset`, придёт потоком.
*
* Клиент берёт его из снапшота либо напрямую перед подпиской.
*/
suspend fun currentCursor(): Cursor
/**
* Минимальный курсор, с которого ещё можно продолжить поток без разрыва.
*
* - `after.offset >= oldestCursor().offset` → replay возможен;
* - `after.offset < oldestCursor().offset` → [OutboxGapException].
*
* Для никогда не эвиктировавшего буфера равен offset'у последнего события
* (т.е. «истории нет, но резумиться с конца можно»), а не `-1`: клиент,
* догнавший состояние до рестарта, продолжает инкрементально.
*/
suspend fun oldestCursor(): Cursor
fun agentEvents(): Flow<CommonEvent.Agent> =
events().filterIsInstance<CommonEvent.Agent>()
override fun close()
}
@@ -1,62 +0,0 @@
package pw.binom.agentik.outbox
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* [OffsetSequencer] поверх персистентного [CursorStore] — production-счётчик
* событий агента.
*
* На создании читает сохранённый [Cursor] (`epoch` + next-offset) синхронно и
* держит его в памяти. Позиция переживает рестарт процесса, поэтому обычный
* рестарт сервера **не меняет** `epoch` и offset'ы остаются монотонными —
* клиент продолжает инкрементально (см. [Cursor.epoch] KDoc), а не получает
* gap на каждой перезагрузке.
*
* ### Стартовый offset
*
* Если хранилище пустое (первый запуск / апгрейд БД, где `message.seq` уже
* накоплен), новый `epoch` генерируется сразу, а `next` берётся из [initialNext]
* — по умолчанию `0`, но апгрейд должен передать `maxSeq + 1` журнала, иначе
* новые offset'ы столкнутся с уже записанными `seq`. Новый `epoch` при этом
* корректно заставляет клиентов сделать однократный resync.
*
* ### Ротация epoch
*
* Смена «мира» (сброс/восстановление БД) выполняется вызывающим: очисти
* [CursorStore] — следующий старт сгенерирует новый `epoch`.
*/
class PersistentOffsetSequencer(
private val store: CursorStore,
private val initialNext: () -> Long = { 0L },
private val newEpoch: () -> String = { Cursor.newEpoch() },
) : OffsetSequencer {
private val mutex = Mutex()
private val epoch: String
private var next: Long
init {
val saved = store.load()
if (saved == null) {
epoch = newEpoch()
next = initialNext()
// Фиксируем epoch сразу, чтобы он не «прыгал» до первого события.
store.save(Cursor(epoch = epoch, offset = next))
} else {
epoch = saved.epoch
next = saved.offset
}
}
override fun epoch(): String = epoch
override fun current(): Long = next
override suspend fun reserve(): Long = mutex.withLock {
val assigned = next
next = assigned + 1
store.save(Cursor(epoch = epoch, offset = next))
assigned
}
}