Добавляет 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:
@@ -13,38 +13,49 @@ import kotlin.time.Instant
|
||||
*
|
||||
* Никаких обновлений, никакого удаления (кроме каскадного вместе
|
||||
* с ConversationStore.delete).
|
||||
*
|
||||
* ## Два способа адресации позиции
|
||||
* - **по [Instant] `createdAt`** — legacy, «дай всё после даты»;
|
||||
* - **по [MessageRecord.seq]** (монотонный per-agent offset) — протокол
|
||||
* снапшотов: диапазон `afterSeq < seq <= upToSeq` даёт **конечное и
|
||||
* стабильное** множество строк. Catch-up: `afterSeq = <курсор>, upToSeq = MAX`.
|
||||
* Снапшот с курсором C: `afterSeq = -1, upToSeq = C`.
|
||||
*
|
||||
* [listFlowSeq] использует keyset-пагинацию (`seq > last`), а не `OFFSET` —
|
||||
* иначе конкурентная вставка/удаление сдвигает окно и молча теряет строки.
|
||||
*/
|
||||
interface JournalStore : AutoCloseable {
|
||||
|
||||
/** Legacy-страница по `createdAt > [after]`, `ORDER BY createdAt ASC` + `OFFSET`. */
|
||||
suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int): List<MessageRecord>
|
||||
|
||||
/**
|
||||
* Сколько сообщений в диалоге [conversationId] всего.
|
||||
* Keyset-страница записей `[afterSeq] < seq <= [upToSeq]`, `ORDER BY seq ASC`.
|
||||
*
|
||||
* O(1) на SQL-бэкендах (`SELECT COUNT(*) ... WHERE conversation_id = ?`),
|
||||
* O(N) на in-memory (size простого list'а с фильтром по conversationId).
|
||||
* Не зависит от cursor'а [after] — для total-размера диалога.
|
||||
* @param afterSeq нижняя эксклюзивная граница (для «с начала» — `-1`).
|
||||
* @param upToSeq верхняя **инклюзивная** граница (для «без отсечки» —
|
||||
* `Long.MAX_VALUE`).
|
||||
*/
|
||||
suspend fun list(conversationId: String, afterSeq: Long, upToSeq: Long, limit: Int): List<MessageRecord>
|
||||
|
||||
/** Сколько сообщений в диалоге [conversationId] всего. */
|
||||
suspend fun count(conversationId: String): Long
|
||||
|
||||
/**
|
||||
* Сколько сообщений в диалоге [conversationId] создано **позже** [after]
|
||||
* (строго `createdAt > after`, как и в [list]).
|
||||
*
|
||||
* O(1) на SQL-бэкендах, O(N) на in-memory. Полезно для:
|
||||
* - UI badge "N новых сообщений" — клиент знает последний `lastSeen`,
|
||||
* сервер говорит `count(convId, after=lastSeen)`;
|
||||
* - пагинации без получения самих записей: знаем лимит последней страницы,
|
||||
* надо понять "есть ли ещё";
|
||||
* - compaction-метрик: «сколько turn'ов осталось после cutoff».
|
||||
* Сколько сообщений создано **позже** [after] (строго `createdAt > after`).
|
||||
* Legacy unread-бейдж по времени.
|
||||
*/
|
||||
suspend fun count(conversationId: String, after: Instant): Long
|
||||
|
||||
/**
|
||||
* Cold-flow paging через [list]. Default-реализация делает N+1 round-trip
|
||||
* (по странице через `list()` пока не получит короткую страницу). Для
|
||||
* in-memory backend'ов это OK; remote/SQLite impl'ы могут override'нуть
|
||||
* на `Channel` / cursor-батчинг, чтобы избежать per-page round-trip.
|
||||
* Сколько сообщений имеют `seq > [afterSeq]`. Cursor-версия unread-бейджа:
|
||||
* `count(convId, afterSeq = lastSeenOffset)`.
|
||||
*/
|
||||
suspend fun count(conversationId: String, afterSeq: Long): Long
|
||||
|
||||
/**
|
||||
* Cold-flow paging (legacy, по [Instant]). Default-реализация делает N+1
|
||||
* round-trip.
|
||||
*/
|
||||
fun listFlow(conversationId: String, after: Instant, pageSize: Int = PAGE_SIZE): Flow<MessageRecord> = flow {
|
||||
var offset = 0
|
||||
@@ -57,6 +68,26 @@ interface JournalStore : AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cold-flow paging по `seq` (keyset). `cursor` растёт по мере эмиссии;
|
||||
* `upToSeq` ограничивает сверху (снапшот с курсором).
|
||||
*/
|
||||
fun listFlowSeq(
|
||||
conversationId: String,
|
||||
afterSeq: Long = -1L,
|
||||
upToSeq: Long = Long.MAX_VALUE,
|
||||
pageSize: Int = PAGE_SIZE,
|
||||
): Flow<MessageRecord> = flow {
|
||||
var cursor = afterSeq
|
||||
while (true) {
|
||||
val page = list(conversationId, cursor, upToSeq, pageSize)
|
||||
if (page.isEmpty()) return@flow
|
||||
for (rec in page) emit(rec)
|
||||
cursor = page.last().seq
|
||||
if (page.size < pageSize) return@flow
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PAGE_SIZE = 100
|
||||
}
|
||||
|
||||
@@ -9,12 +9,29 @@ import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Запись в таблице `message` (append-only audit).
|
||||
*
|
||||
* ## [seq] — курсор записи
|
||||
* [seq] — **тот же монотонный per-agent offset**, что и `offset` соответствующего
|
||||
* events-события (`OffsetSequencer.reserve()`). Writer резервирует offset и
|
||||
* пишет строку с `seq = offset` **до** append'а события в outbox (инвариант
|
||||
* «сначала состояние, потом событие»).
|
||||
*
|
||||
* Нужен для снапшота с «курсором»: клиент берёт `currentCursor() = C` и читает
|
||||
* `listUpTo(convId, C)` — конечное, стабильное множество строк, отражающее
|
||||
* состояние на момент C. Всё, что появится позже, имеет `seq > C` и приедет
|
||||
* потоком событий.
|
||||
*
|
||||
* Значение по умолчанию `0L` — для legacy-записей и тестов; production-путь
|
||||
* (`ConversationLoop` / `ToolDispatcher`) всегда выставляет реальный offset.
|
||||
* Запись с `seq = 0` в снапшоте всегда «≤ C», поэтому попадает в снапшот и
|
||||
* (если её событие ещё и в потоке) применяется дважды — идемпотентно, безвредно.
|
||||
*/
|
||||
@Serializable
|
||||
sealed interface MessageRecord {
|
||||
val id: String
|
||||
val conversationId: String
|
||||
val createdAt: Instant
|
||||
val seq: Long
|
||||
|
||||
@Serializable
|
||||
sealed interface Body : MessageRecord {
|
||||
@@ -29,6 +46,7 @@ sealed interface MessageRecord {
|
||||
override val content: List<Content>,
|
||||
override val createdAt: Instant,
|
||||
val context: MessageContext? = null,
|
||||
override val seq: Long = 0L,
|
||||
) : Body
|
||||
|
||||
@Serializable
|
||||
@@ -45,6 +63,7 @@ sealed interface MessageRecord {
|
||||
* их не раскрывает.
|
||||
*/
|
||||
val reasoning: String? = null,
|
||||
override val seq: Long = 0L,
|
||||
) : Body
|
||||
|
||||
@Serializable
|
||||
@@ -56,6 +75,7 @@ sealed interface MessageRecord {
|
||||
val toolTitle: String?,
|
||||
val toolArgsJson: String,
|
||||
override val createdAt: Instant,
|
||||
override val seq: Long = 0L,
|
||||
) : MessageRecord
|
||||
|
||||
@Serializable
|
||||
@@ -74,6 +94,7 @@ sealed interface MessageRecord {
|
||||
val toolName: String? = null,
|
||||
val result: String?,
|
||||
override val createdAt: Instant,
|
||||
override val seq: Long = 0L,
|
||||
) : MessageRecord
|
||||
|
||||
@Serializable
|
||||
@@ -84,5 +105,6 @@ sealed interface MessageRecord {
|
||||
val message: String,
|
||||
val code: String?,
|
||||
override val createdAt: Instant,
|
||||
override val seq: Long = 0L,
|
||||
) : MessageRecord
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user