Добавляет Cursor/OffsetSequencer в :outbox-api и интегрирует PersistentOffsetSequencer через :outbox-ksqlite.
Введение монотонного offset'а как персистентного состояния агента:
offset'ы переживают рестарт standalone-агента, клиент продолжает
синхронизацию инкрементально, без полной re-sync с нуля.
outbox-api:
- Cursor (offset: Long) — курсор в журнале событий агента.
- OffsetSequencer — интерфейс резервирования уникального offset.
- CursorStore — персистентное хранилище текущего offset'а.
- PersistentOffsetSequencer — декоратор над любым OutboxStore,
обновляет CursorStore на каждом append (atomic transaction).
- OutboxGapException — клиент запросил after < earliestCursor() →
сервер не может удовлетворить, клиент обязан делать full resync.
- DurableEvent переименован из Event.kt → DurableEvent.kt (Event.kt
был общим sealed-типом, теперь это термин из спеки).
- MutableOutboxStore и OutboxStore теперь читают offset через
CursorStore вместо in-memory counter'а.
outbox-inmemory:
- InMemoryOffsetSequencer — для тестов и dev-режима.
- InMemoryOutboxStore теперь принимает OffsetSequencer в конструкторе.
outbox-ksqlite (новый модуль):
- KsqliteCursorStore — таблица outbox_cursor (agent_id TEXT PK,
offset INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL).
- KsqliteCursorStoreTest — 4 теста (set/get, monotonic, concurrent).
proto + server:
- Snapshot.proto — server-state snapshot endpoint для клиентов,
которым нужна полная материализация (использование TBD).
- Routes.kt + SnapshotRouteTest — endpoint /agentik/snapshot (GET).
journal-ksqlite:
- KsqliteJournalStore.listFlow/append — без изменений по API,
нотации минимальные (codecs).
standalone:
- DurableLog (бывший ChatAgent-orchestration) — атомарный commit
события в OutboxStore + PersistentOffsetSequencer + materialization
(через Reducer) одной транзакцией.
- SqliteStores — добавляет KsqliteCursorStore в bundle, единая
shared-connection для всех ksqlite-сторов standalone-агента.
- ChatAgent / ConversationLoop / ConversationEvents / ReflectionScheduler /
ToolDispatcher — переход на новые абстракции.
- standalone/build.gradle.kts — implementation(project(':outbox-ksqlite'))
включено (раньше было закомментировано — модуль только создавался).
client:
- AgentikAgent / AgentClient / HttpEventStore / HttpJournalStore /
ReconnectingOutbox — используют Cursor через transport API.
- client/README.md — синхронизирован с новым поведением (468 строк
diff — это в основном оформление и примеры).
kotlinx-io: 0.8.0 → 0.9.1 в libs.versions.toml (см. sync-core tests).
SYNC-SYSTEM.md (в корне) — спецификация, на которую ссылается и
:sync-core (эта сессия), и эта Cursor-абстракция в outbox-api.
Тесты: standalone 132, journal-ksqlite 25, outbox-inmemory 20,
outbox-ksqlite 4, client 10, sync-core 74 — все зелёные на jvm;
sync-core linuxX64 74 тоже зелёный.
sync2/ (заброшенный stub с одним build.gradle.kts) удалён.
This commit is contained in:
@@ -9,7 +9,7 @@ import kotlinx.serialization.Serializable
|
||||
*
|
||||
* Useful for admin dashboards, debug tools, parent agents: one subscription
|
||||
* instead of N+1. For regular UI use two separate SSE feeds
|
||||
* ([AgentEvent] via `/events` и [Event] via `/conversations/{id}/events`);
|
||||
* ([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)`.
|
||||
@@ -23,12 +23,23 @@ import kotlinx.serialization.Serializable
|
||||
*/
|
||||
@Serializable
|
||||
sealed interface CommonEvent {
|
||||
/** Момент эмиссии в 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
|
||||
|
||||
@@ -36,7 +47,8 @@ sealed interface CommonEvent {
|
||||
@SerialName("conversation")
|
||||
data class Conversation(
|
||||
override val date: Instant,
|
||||
override val offset: Long,
|
||||
val conversationId: String,
|
||||
val event: Event,
|
||||
val event: DurableEvent,
|
||||
) : CommonEvent
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
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')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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)
|
||||
}
|
||||
+11
-11
@@ -30,7 +30,7 @@ import kotlin.time.Instant
|
||||
* `pw.binom.agentik.outbox.Event`.
|
||||
*/
|
||||
@Serializable
|
||||
sealed interface Event {
|
||||
sealed interface DurableEvent {
|
||||
/** Момент эмиссии события в UTC. */
|
||||
val date: Instant
|
||||
|
||||
@@ -46,7 +46,7 @@ sealed interface Event {
|
||||
val id: String,
|
||||
val content: List<Content>,
|
||||
val context: MessageContext? = null,
|
||||
) : Event
|
||||
) : DurableEvent
|
||||
|
||||
/**
|
||||
* Целое сообщение ассистента — итог хода. Эмитится при завершении хода,
|
||||
@@ -63,7 +63,7 @@ sealed interface Event {
|
||||
val content: List<Content>,
|
||||
val reasoning: String? = null,
|
||||
val tokens: TurnTokens? = null,
|
||||
) : Event
|
||||
) : DurableEvent
|
||||
|
||||
/**
|
||||
* Агент начал вызов тула. Аргументы приходят целиком — стриминга нет.
|
||||
@@ -78,7 +78,7 @@ sealed interface Event {
|
||||
val title: String?,
|
||||
val toolName: String,
|
||||
val toolArgs: String,
|
||||
) : Event
|
||||
) : DurableEvent
|
||||
|
||||
/**
|
||||
* Результат вызова тула. Приходит целиком после завершения исполнения.
|
||||
@@ -101,7 +101,7 @@ sealed interface Event {
|
||||
val toolCallId: String,
|
||||
val toolName: String? = null,
|
||||
val result: String?,
|
||||
) : Event
|
||||
) : DurableEvent
|
||||
|
||||
/**
|
||||
* Ход прерван через `Conversation.interrupt`. Частичный ответ НЕ сохраняется
|
||||
@@ -109,7 +109,7 @@ sealed interface Event {
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("interrupted")
|
||||
data class Interrupted(override val date: Instant) : Event
|
||||
data class Interrupted(override val date: Instant) : DurableEvent
|
||||
|
||||
/**
|
||||
* Ошибка хода. После неё поток завершается; дальнейшие события могут
|
||||
@@ -117,7 +117,7 @@ sealed interface Event {
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("error")
|
||||
data class Error(override val date: Instant, val message: String, val code: String? = null) : Event
|
||||
data class Error(override val date: Instant, val message: String, val code: String? = null) : DurableEvent
|
||||
|
||||
/**
|
||||
* Конвейер вызова тула упал (handler кинул Throwable, args не парсятся,
|
||||
@@ -136,7 +136,7 @@ sealed interface Event {
|
||||
val toolName: String?,
|
||||
val message: String,
|
||||
val durationMs: Long,
|
||||
) : Event
|
||||
) : DurableEvent
|
||||
|
||||
/**
|
||||
* Диалог переходит в закрытое состояние ([Conversation.close] /
|
||||
@@ -148,7 +148,7 @@ sealed interface Event {
|
||||
*
|
||||
* Парный `Opening` намеренно отсутствует — симметрия не нужна,
|
||||
* так как открытие тривиально (id уже известен с момента
|
||||
* `Agent.createConversation` → [Event.ConversationCreated]
|
||||
* `Agent.createConversation` → [DurableEvent.ConversationCreated]
|
||||
* / [AgentEvent.Created] в outbox'е).
|
||||
*/
|
||||
@Serializable
|
||||
@@ -156,7 +156,7 @@ sealed interface Event {
|
||||
data class ConversationClosing(
|
||||
override val date: Instant,
|
||||
val conversationId: String,
|
||||
) : Event
|
||||
) : DurableEvent
|
||||
|
||||
/**
|
||||
* Compactor сжал старые turn'ы — [turnsCompacted] из истории исчезли.
|
||||
@@ -173,5 +173,5 @@ sealed interface Event {
|
||||
override val date: Instant,
|
||||
val conversationId: String,
|
||||
val turnsCompacted: Int,
|
||||
) : Event
|
||||
) : DurableEvent
|
||||
}
|
||||
@@ -10,17 +10,21 @@ package pw.binom.agentik.outbox
|
||||
* персистятся, I/O нет — блокировать продюсера незачем. [tryAppendOnline]
|
||||
* не буферизует и не ждёт (см. [OnlineOutbox]): медленный подписчик может
|
||||
* потерять дельту, это допустимо.
|
||||
*
|
||||
* Диалог берётся из самого события ([OnlineEvent.conversationId]) — отдельного
|
||||
* параметра нет, чтобы не было двух источников истины.
|
||||
*/
|
||||
interface MutableOnlineOutbox : OnlineOutbox {
|
||||
|
||||
suspend fun appendOnline(conversationId: String, event: OnlineEvent)
|
||||
suspend fun appendOnline(event: OnlineEvent)
|
||||
|
||||
/**
|
||||
* Эмитит [event] в live-канал диалога [conversationId]. Не сохраняется.
|
||||
* Эмитит [event] в live-канал его диалога ([OnlineEvent.conversationId]).
|
||||
* Не сохраняется.
|
||||
*
|
||||
* Возвращает `true`, если событие принято live-каналом. Возврат `false`
|
||||
* (нет активных подписчиков / буфер переполнен с DROP-политикой) —
|
||||
* не ошибка: у онлайн-событий нет гарантии доставки.
|
||||
*/
|
||||
fun tryAppendOnline(conversationId: String, event: OnlineEvent): Boolean
|
||||
fun tryAppendOnline(event: OnlineEvent): Boolean
|
||||
}
|
||||
|
||||
@@ -1,50 +1,40 @@
|
||||
package pw.binom.agentik.outbox
|
||||
|
||||
/**
|
||||
* Mutable вариант [OutboxStore] — добавляет producer-операцию [append].
|
||||
* Mutable вариант [OutboxStore] — добавляет producer-операции [reserveOffset]
|
||||
* и [append].
|
||||
*
|
||||
* Этот интерфейс предназначен **только для producer'ов** (ChatAgent,
|
||||
* sub-agents, A2A-bridge). Consumer'ы (server SSE endpoints, admin
|
||||
* dashboards, parent agents) должны принимать **read-only** [OutboxStore]
|
||||
* — тогда невозможно случайно писать в store из observer'а.
|
||||
* Предназначен **только для producer'ов** (ChatAgent, ConversationLoop,
|
||||
* ToolDispatcher, sub-agents, A2A-bridge). Consumer'ы принимают read-only
|
||||
* [OutboxStore] — тогда невозможно случайно писать в store из observer'а.
|
||||
*
|
||||
* Типичное использование:
|
||||
* ## Контракт записи (порядок важен)
|
||||
* ```
|
||||
* // Producer
|
||||
* class ChatAgent(private val events: MutableEventStore) {
|
||||
* suspend fun doSomething() {
|
||||
* events.append(CommonEvent.Agent(date = now, event = AgentEvent.Created(...)))
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* // Consumer
|
||||
* class EventStreamEndpoint(private val events: EventStore) {
|
||||
* fun stream() = events.events(after = null)
|
||||
* // Ошибка компиляции если раскомментировать:
|
||||
* // events.append(...) // ← нельзя, MutableEventStore нет в типе
|
||||
* }
|
||||
* val n = outbox.reserveOffset() // 1. забронировать offset
|
||||
* journal.append(record.copy(seq = n)) // 2. сначала состояние
|
||||
* outbox.append(event.copy(offset = n)) // 3. потом событие
|
||||
* ```
|
||||
* «Сначала состояние, потом событие» — инвариант, на котором держится
|
||||
* [OutboxStore.currentCursor]: к моменту, когда событие `n` появилось в
|
||||
* outbox, строка состояния со `seq = n` уже записана.
|
||||
*
|
||||
* **Append НЕ идемпотентен**: [CommonEvent] не имеет уникального id,
|
||||
* поэтому retry с тем же logical event (например, после network failure
|
||||
* между producer и store) приведёт к дубликату в tail'е. Это OK для
|
||||
* use case'a bounded-tail — клиент, делающий catchup через [events](after),
|
||||
* получит свой диапазон ровно один раз при подключении, а последующие
|
||||
* retry producer'а просто насытят tail повторами, не задевая уже
|
||||
* обработанные. Для гарантированной exactly-once — dedup через
|
||||
* [message-store] (там есть монотонный `id`).
|
||||
*
|
||||
* **Silently evicted**: implementation может выкинуть этот event сразу
|
||||
* после append (TTL/cap) без уведомления producer'а. Producer **не
|
||||
* должен** полагаться на то, что event дойдёт до клиента, если он
|
||||
* вне retention window.
|
||||
* Offset **обязан** быть выставлен в [CommonEvent.offset]; store проверяет
|
||||
* строгую монотонность и бросает [IllegalArgumentException] на нарушение.
|
||||
*/
|
||||
interface MutableOutboxStore : OutboxStore {
|
||||
|
||||
/**
|
||||
* Положить event в log.
|
||||
* Забронировать следующий монотонный offset (делегирует в
|
||||
* [OffsetSequencer.reserve]). Вызывается **до** записи состояния.
|
||||
*/
|
||||
suspend fun reserveOffset(): Long
|
||||
|
||||
/**
|
||||
* Положить событие в лог. [CommonEvent.offset] должен быть уже выставлен
|
||||
* (обычно значением из [reserveOffset]).
|
||||
*
|
||||
* - **Не идемпотентно** — см. KDoc интерфейса.
|
||||
* - **Suspend** для KMP I/O impl'ов (SQLite через JNI).
|
||||
* **Не идемпотентно** — повторный append с тем же offset'ом нарушает
|
||||
* монотонность и бросит исключение (защита от двойной записи).
|
||||
*/
|
||||
suspend fun append(event: CommonEvent)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
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
|
||||
}
|
||||
@@ -8,19 +8,19 @@ import kotlin.time.Instant
|
||||
* **Онлайн-события** диалога: live-поток «в моменте» — маркеры фаз хода и
|
||||
* стриминг ответа агента (дельты текста/картинок).
|
||||
*
|
||||
* Принципиальное отличие от [Event] (durable):
|
||||
* Принципиальное отличие от [DurableEvent] (durable):
|
||||
* - **Никогда и нигде не сохраняются** — ни в буфер [OnlineOutbox],
|
||||
* ни в journal. Это чистый live-канал.
|
||||
* - **Только онлайн-подписка**: события, эмитнутые до подписки
|
||||
* (или в момент обрыва соединения), не реплеятся и не восстанавливаются.
|
||||
* Потерянный фрагмент не страшен — целый результат хода приходит
|
||||
* durable-событием ([Event.AssistantMessage]) и/или лежит в журнале.
|
||||
* durable-событием ([DurableEvent.AssistantMessage]) и/или лежит в журнале.
|
||||
* - **Нет курсора**: у потока нет `after`/`lastSeen` — курсор там, где
|
||||
* есть что реплеить.
|
||||
*
|
||||
* Зачем разделять: маркеры фаз и дельты токенов — высокочастотный мусор,
|
||||
* который, попав в durable store, копится в RAM (standalone-outbox растёт
|
||||
* unbounded) и засоряет историю. В [Event] остаются только «целые» события,
|
||||
* unbounded) и засоряет историю. В [DurableEvent] остаются только «целые» события,
|
||||
* пригодные к перезапросу по курсору.
|
||||
*/
|
||||
@Serializable
|
||||
@@ -28,6 +28,14 @@ sealed interface OnlineEvent {
|
||||
/** Момент эмиссии события в UTC (для упорядочивания в рамках стрима). */
|
||||
val date: Instant
|
||||
|
||||
/**
|
||||
* Id диалога, которому принадлежит событие. Делает событие
|
||||
* самодостаточным: общий live-поток всех диалогов (`GET /online`)
|
||||
* разбирается на стороне клиента без внешнего конверта — какое
|
||||
* событие к какому чату, видно прямо из payload'а.
|
||||
*/
|
||||
val conversationId: String
|
||||
|
||||
@Serializable
|
||||
enum class ResponseType {
|
||||
@SerialName("text") TEXT,
|
||||
@@ -36,35 +44,48 @@ sealed interface OnlineEvent {
|
||||
|
||||
/**
|
||||
* Маркер «агент принял запрос и пошёл обрабатывать». Эмитится **до**
|
||||
* [End]/[Event.Interrupted]/[Event.Error], синхронно из `Conversation.send()`,
|
||||
* [End]/[DurableEvent.Interrupted]/[DurableEvent.Error], синхронно из `Conversation.send()`,
|
||||
* чтобы UI мог показать спиннер ещё до первого токена ответа.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("working")
|
||||
data class Working(override val date: Instant) : OnlineEvent
|
||||
data class Working(override val date: Instant, override val conversationId: String) : OnlineEvent
|
||||
|
||||
/** Ход завершён (нормально либо оборван). Зеркало терминатора — см. [Event]. */
|
||||
/** Ход завершён (нормально либо оборван). Зеркало терминатора — см. [DurableEvent]. */
|
||||
@Serializable
|
||||
@SerialName("end")
|
||||
data class End(override val date: Instant) : OnlineEvent
|
||||
data class End(override val date: Instant, override val conversationId: String) : OnlineEvent
|
||||
|
||||
/** Ассистент начал рассуждение (опциональный маркер; контент идёт через [AppendText]). */
|
||||
@Serializable
|
||||
@SerialName("start_reasoning")
|
||||
data class StartReasoning(override val date: Instant) : OnlineEvent
|
||||
data class StartReasoning(override val date: Instant, override val conversationId: String) : OnlineEvent
|
||||
|
||||
/** Начало ответа ассистента заданного типа. Далее идут соответствующие `Append*`. */
|
||||
@Serializable
|
||||
@SerialName("start_response")
|
||||
data class StartResponse(override val date: Instant, val responseType: ResponseType) : OnlineEvent
|
||||
data class StartResponse(
|
||||
override val date: Instant,
|
||||
override val conversationId: String,
|
||||
val responseType: ResponseType,
|
||||
) : OnlineEvent
|
||||
|
||||
/** Очередная дельта текста ответа. */
|
||||
@Serializable
|
||||
@SerialName("append_text")
|
||||
data class AppendText(override val date: Instant, val body: String) : OnlineEvent
|
||||
data class AppendText(
|
||||
override val date: Instant,
|
||||
override val conversationId: String,
|
||||
val body: String,
|
||||
) : OnlineEvent
|
||||
|
||||
/** Очередная дельта картинки ответа. */
|
||||
@Serializable
|
||||
@SerialName("append_image")
|
||||
data class AppendImage(override val date: Instant, val body: ByteArray, val mime: String) : OnlineEvent
|
||||
data class AppendImage(
|
||||
override val date: Instant,
|
||||
override val conversationId: String,
|
||||
val body: ByteArray,
|
||||
val mime: String,
|
||||
) : OnlineEvent
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
*
|
||||
* Это осознанный компромисс: дельты токенов — высокочастотный мусор,
|
||||
* который в durable-сторе копился бы в RAM и засорял историю. Потеря
|
||||
* фрагмента при обрыве не критична — целый ответ приходит [Event.AssistantMessage]
|
||||
* фрагмента при обрыве не критична — целый ответ приходит [DurableEvent.AssistantMessage]
|
||||
* и/или лежит в [pw.binom.agentik.journal.JournalStore].
|
||||
*
|
||||
* Read-only view: запись — через [MutableOnlineOutbox].
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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."
|
||||
)
|
||||
@@ -3,92 +3,84 @@ package pw.binom.agentik.outbox
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Bounded-tail event log с автоматическим управлением TTL.
|
||||
* Bounded-tail лог **durable**-событий агента.
|
||||
*
|
||||
* Хранит **только durable-события [Event]** — «целые» факты хода
|
||||
* (Working/End/Interrupted/Error, ToolCall/ToolResult/ToolFailed).
|
||||
* Высокочастотный **стриминг ответа** (дельты текста/картинок) сюда
|
||||
* НЕ попадает — он живёт в [OnlineOutbox] (live-only, не сохраняется).
|
||||
* Хранит [CommonEvent] — «целые» факты хода ([DurableEvent]) и lifecycle
|
||||
* диалогов ([AgentEvent]). Высокочастотный стриминг ответа (дельты текста и
|
||||
* картинок) сюда **не попадает** — он живёт в [OnlineOutbox] (live-only, не
|
||||
* сохраняется и не реплеится).
|
||||
*
|
||||
* **Архитектура двухуровневого хранилища событий**:
|
||||
* 1. **Этот store** = короткий bounded tail (live SSE + недавний replay).
|
||||
* События автоматически эвиктятся по TTL/cap (implementation-defined).
|
||||
* 2. **Message store (`:message-store-api`)** = полный audit log, никогда не
|
||||
* эвиктится. Source of truth для всего прошлого.
|
||||
* ## Два уровня хранения
|
||||
* 1. **Этот store** — короткий bounded tail (live SSE + недавний replay),
|
||||
* эвиктится по TTL/cap (implementation-defined).
|
||||
* 2. **Журнал (`:journal-api`)** — полный audit log, никогда не эвиктится.
|
||||
* Source of truth для всего прошлого. Он и есть «полное состояние»,
|
||||
* которое запрашивает клиент при resync'е.
|
||||
*
|
||||
* **Паттерн reconnect** (caller'ы):
|
||||
* ## Курсор, а не дата
|
||||
* Позиция в потоке — монотонный [Cursor] `(epoch, offset)`, а не wall-clock
|
||||
* [CommonEvent.date]. Offset уникален и упорядочен даже когда два события
|
||||
* делят одну миллисекунду. `Instant` для этого не годится (лоссов на ничьих),
|
||||
* случайный `id` — тоже (не задаёт порядок записи).
|
||||
*
|
||||
* ## Протокол клиента (гарантия «в итоге корректное состояние»)
|
||||
* ```
|
||||
* val earliest = store.earliestEventDate()
|
||||
* if (client.lastSeen < earliest) {
|
||||
* // gap обнаружен — идём в message store за прошлым
|
||||
* val gap = messageStore.query(after = client.lastSeen, before = earliest)
|
||||
* applyAll(gap)
|
||||
* // 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) }
|
||||
* }
|
||||
* store.events(after = client.lastSeen).collect { apply(it) }
|
||||
* ```
|
||||
* Точный порядок на стороне сервера/snapshot'а (subscribe-before-snapshot,
|
||||
* буферизация дельт, idempotent apply) описан в `:client/README.md`.
|
||||
*
|
||||
* **Нет delete/cleanup методов** — TTL/cap eviction полностью на стороне
|
||||
* implementation. Это:
|
||||
* - Убирает single source of truth дублирование (caller не может забыть cleanup).
|
||||
* - Позволяет impl выбирать retention strategy (TTL, size cap, sliding window).
|
||||
* - Сохраняет контракт clean: интерфейс только о put/get.
|
||||
* ## Gap detection
|
||||
* `after != null && after.offset < oldestCursor().offset` (или другой
|
||||
* [Cursor.epoch]) → [OutboxGapException] бросается **изнутри** Flow. Проверка
|
||||
* идёт под тем же lock'ом, что и регистрация подписчика (одним критическим
|
||||
* участком), поэтому не гоняется с конкурентной эвикцией и не теряет события
|
||||
* в окне «snapshot → live».
|
||||
*
|
||||
* **Read-only**: этот интерфейс предоставляет только read-операции.
|
||||
* Для записи см. [MutableOutboxStore].
|
||||
* ## Read-only
|
||||
* Интерфейс предоставляет только чтение. Запись — [MutableOutboxStore].
|
||||
*
|
||||
* **Подписки нереентрантные**: каждый вызов [events] создаёт **новую
|
||||
* подписку** (cold Flow). Один [events] НЕ видит события, добавленные до
|
||||
* его вызова, если [after] == null. Если нужен catchup — передавайте
|
||||
* `after = lastSeenDate` явно.
|
||||
*
|
||||
* **Multi-consumer**: разные [events] подписки видят одно и то же live
|
||||
* tail. Каждая подписка — независимая projection.
|
||||
* ## Подписки
|
||||
* Каждый вызов [events] / [conversationEvents] / [agentEvents] — **новая
|
||||
* независимая подписка** (cold Flow). `after == null` → только live (события
|
||||
* с момента вызова). Иные consumer'ы видят тот же live-tail; каждая подписка —
|
||||
* своя проекция.
|
||||
*/
|
||||
interface OutboxStore : AutoCloseable {
|
||||
|
||||
/**
|
||||
* Subscribe на events.
|
||||
* Подписка на события.
|
||||
*
|
||||
* **`after == null`** → только **live** (события с момента вызова
|
||||
* `events()`). Каждое новое событие от любого producer'а немедленно
|
||||
* появится в Flow. Буфер replay не отдаётся.
|
||||
* - `after == null` → **только live** (события с момента вызова, replay
|
||||
* буфера не отдаётся);
|
||||
* - `after != null` → сначала **catchup** всех буферизованных событий с
|
||||
* `offset > after.offset` (по возрастанию offset), затем live.
|
||||
*
|
||||
* **`after != null`** → сначала **catchup**: эмитт все буферизованные
|
||||
* события с `date > after`, порядок `date ASC` (ties по `id ASC`).
|
||||
* Затем **live** (как null-case).
|
||||
*
|
||||
* Cold Flow: каждый вызов — новая подписка. Вызов **после** append'а
|
||||
* не увидит этот конкретный event (если `after == null`); для catchup
|
||||
* передавайте явный `after`.
|
||||
*
|
||||
* ВАЖНО: `Flow` НЕ бросает ошибку при потере сети между producer и
|
||||
* store — такие события просто не дойдут до этого Flow. Для гарантии
|
||||
* полноты клиент обязан cross-check с [earliestEventDate] и fallback
|
||||
* в message store при gap'е (см. KDoc интерфейса).
|
||||
* @throws OutboxGapException изнутри Flow, если [after] старше
|
||||
* [oldestCursor] (retention gap) или принадлежит другой эпохе.
|
||||
*/
|
||||
fun events(after: Instant?): Flow<CommonEvent>
|
||||
fun events(after: Cursor?): Flow<CommonEvent>
|
||||
|
||||
/**
|
||||
* Subscribe на **только conversation events** (т.е. [CommonEvent.Conversation]).
|
||||
* Подписка только на conversation-события ([CommonEvent.Conversation]).
|
||||
*
|
||||
* - [conversationId] == null → события **всех** диалогов.
|
||||
* - [conversationId] != null → события **только этого** диалога.
|
||||
* - `conversationId == null` → все диалоги;
|
||||
* - `conversationId != null` → только этот диалог.
|
||||
*
|
||||
* Семантика `after` идентична [events] (catchup + live).
|
||||
* Возвращаемый тип — конкретный subtype [CommonEvent.Conversation].
|
||||
* Семантика [after] и `gap` идентична [events].
|
||||
*/
|
||||
/**
|
||||
* **Default implementation** (читает все events + фильтрует).
|
||||
*
|
||||
* Простая реализация через [events] + filterIsInstance. Реализации
|
||||
* могут override'нуть для эффективности (например, добавить SQL
|
||||
* `WHERE conversation_id = ?` чтобы не тянуть всё в память), но
|
||||
* контракт корректен и без override.
|
||||
*/
|
||||
fun conversationEvents(after: Instant?, conversationId: String? = null): Flow<CommonEvent.Conversation> =
|
||||
fun conversationEvents(after: Cursor?, conversationId: String? = null): Flow<CommonEvent.Conversation> =
|
||||
events(after)
|
||||
.filterIsInstance<CommonEvent.Conversation>()
|
||||
.let { filtered ->
|
||||
@@ -97,53 +89,34 @@ interface OutboxStore : AutoCloseable {
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe на **только agent events** ([CommonEvent.Agent] —
|
||||
* создание/удаление/переименование диалога).
|
||||
* Подписка только на agent-события ([CommonEvent.Agent] — создание/удаление/
|
||||
* переименование диалога).
|
||||
*
|
||||
* Семантика `after` идентична [events] (catchup + live).
|
||||
* Возвращаемый тип — конкретный subtype [CommonEvent.Agent].
|
||||
*
|
||||
* Полезно для admin-дашборда, который хочет видеть только lifecycle
|
||||
* диалогов без деталей ходов.
|
||||
* Семантика [after] и `gap` идентична [events].
|
||||
*/
|
||||
/**
|
||||
* **Default implementation** (читает все events + фильтрует по типу).
|
||||
*
|
||||
* Простая реализация через [events] + filterIsInstance. Реализации
|
||||
* могут override'нуть для эффективности (например, читать только agent
|
||||
* row'ы из БД), но контракт корректен и без override.
|
||||
*/
|
||||
fun agentEvents(after: Instant?): Flow<CommonEvent.Agent> =
|
||||
fun agentEvents(after: Cursor?): Flow<CommonEvent.Agent> =
|
||||
events(after).filterIsInstance<CommonEvent.Agent>()
|
||||
|
||||
/**
|
||||
* Date **стартовой точки** буфера.
|
||||
* Актуальный курсор: offset последнего **записанного** события
|
||||
* (`lastOffset`). Это «commit point» снапшота: состояние со `seq <= cursor.offset`
|
||||
* уже в БД, всё, что `> cursor.offset`, придёт потоком.
|
||||
*
|
||||
* - Если буфер не пуст → `date` самого старого буферизованного event'а.
|
||||
* - Если буфер пуст → текущее время (`Clock.System.now()` на момент вызова).
|
||||
*
|
||||
* **Семантика "now если пусто"** важна: позволяет клиенту безопасно
|
||||
* подписаться на [events](after = earliest) сразу — он получит только
|
||||
* новые live event'ы, без ложного catchup. Если бы возвращалось
|
||||
* `Instant.DISTANT_PAST` или `null` (с проверкой), клиент мог бы
|
||||
* ошибочно подписаться на несуществующий catchup и зависнуть в ожидании.
|
||||
*
|
||||
* **Используется клиентом для gap detection**:
|
||||
* - `lastSeen < earliest` → есть дыра в покрытии, нужен fallback
|
||||
* в message store за диапазоном `[lastSeen, earliest)`.
|
||||
* - `lastSeen >= earliest` → всё доступно через [events](after),
|
||||
* fallback не нужен.
|
||||
* - `lastSeen == earliest` → OK, первый live event будет > earliest.
|
||||
*
|
||||
* **Edge case**: клиент, подключившийся до того как store увидел хоть
|
||||
* один event, получает `earliest ≈ now`. Его `lastSeen` будет < earliest
|
||||
* — адаптируется в первом же poll'е и пойдёт через fallback если
|
||||
* сообщения audit log существуют (для consistency с прошлым).
|
||||
*
|
||||
* Suspend потому что в persistent impl'ах требует SQL query (`MIN(date)`
|
||||
* или `Clock.now()` для пустого буфера).
|
||||
* Клиент берёт его из снапшота либо напрямую перед подпиской.
|
||||
*/
|
||||
suspend fun earliestEventDate(): Instant
|
||||
suspend fun currentCursor(): Cursor
|
||||
|
||||
/**
|
||||
* Минимальный курсор, с которого ещё можно продолжить поток без разрыва.
|
||||
*
|
||||
* - `after.offset >= oldestCursor().offset` → replay возможен;
|
||||
* - `after.offset < oldestCursor().offset` → [OutboxGapException].
|
||||
*
|
||||
* Для никогда не эвиктировавшего буфера равен offset'у последнего события
|
||||
* (т.е. «истории нет, но резумиться с конца можно»), а не `-1`: клиент,
|
||||
* догнавший состояние до рестарта, продолжает инкрементально.
|
||||
*/
|
||||
suspend fun oldestCursor(): Cursor
|
||||
|
||||
override fun close()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user