Добавляет 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:
2026-10-02 01:16:15 +03:00
parent 92b76c4e3c
commit 5bdc517988
63 changed files with 2953 additions and 1017 deletions
+30
View File
@@ -0,0 +1,30 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
// KMP-реализация :outbox-api `CursorStore` поверх ksqlite.
// Минимальная — только таблица `outbox_cursor` (одна строка: epoch + next offset).
// Даёт production-агенту персистентный `OffsetSequencer`: обычный рестарт сервера
// не ротирует epoch, клиент продолжает инкрементально.
//
// Цели сборки — jvm() + linuxX64() + mingwX64() (как у остальных ksqlite-модулей).
kotlin {
jvmToolchain(21)
jvm()
linuxX64()
mingwX64()
sourceSets {
commonMain.dependencies {
implementation(libs.ksqlite)
api(project(":outbox-api"))
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -0,0 +1,78 @@
package pw.binom.agentik.outbox.ksqlite
import pw.binom.agentik.outbox.Cursor
import pw.binom.agentik.outbox.CursorStore
import pw.binom.db.ksqlite.SQLiteConnection
import pw.binom.db.ksqlite.SQLitePreparedStatement
/**
* ksqlite-реализация [CursorStore] — таблица `outbox_cursor` (одна строка,
* `id = 1`).
*
* ## Lifecycle соединения
*
* Две формы, как у остальных ksqlite-store'ов:
* - `KsqliteCursorStore(connection)` — внешнее соединение, store НЕ закрывает
* его в [close]. Для shared-connection bundle'а (`SqliteStores.assemble`).
* - `KsqliteCursorStore(path)` — открывает файловое соединение и закрывает
* его в [close].
*
* ## Синхронизация
*
* `load()` вызывается один раз при создании `PersistentOffsetSequencer`,
* `save()` — сериализован его `Mutex`. Поэтому собственный mutex не нужен;
* prepared statements закрываются в [close] ДО owned-connection (иначе
* финалайзеры stmt'ов дёргают уже закрытый parent → SIGSEGV).
*/
class KsqliteCursorStore private constructor(
private val connection: SQLiteConnection,
private val ownsConnection: Boolean,
) : CursorStore, AutoCloseable {
/** Внешнее соединение — store НЕ закрывает его в [close]. */
constructor(connection: SQLiteConnection) : this(connection, ownsConnection = false)
/** Файловое соединение — store закрывает его в [close]. */
constructor(path: String) : this(
connection = SQLiteConnection.open(path = path),
ownsConnection = true,
)
init {
Schema.migrate(connection)
}
private val getStmt: SQLitePreparedStatement = connection.prepare(
"SELECT ${Schema.COL_EPOCH}, ${Schema.COL_OFFSET} " +
"FROM ${Schema.TABLE} WHERE ${Schema.COL_ID} = 1"
)
private val setStmt: SQLitePreparedStatement = connection.prepare(
"INSERT INTO ${Schema.TABLE}(${Schema.COL_ID}, ${Schema.COL_EPOCH}, ${Schema.COL_OFFSET}) " +
"VALUES(1, ?, ?) ON CONFLICT(${Schema.COL_ID}) DO UPDATE SET " +
"${Schema.COL_EPOCH}=excluded.${Schema.COL_EPOCH}, " +
"${Schema.COL_OFFSET}=excluded.${Schema.COL_OFFSET}"
)
override fun load(): Cursor? {
getStmt.reset()
getStmt.clearBindings()
getStmt.executeQuery().use { rs ->
if (!rs.next()) return null
return Cursor(epoch = rs.getText(0)!!, offset = rs.getLong(1)!!)
}
}
override fun save(cursor: Cursor) {
setStmt.reset()
setStmt.clearBindings()
setStmt.bindText(1, cursor.epoch)
setStmt.bindLong(2, cursor.offset)
setStmt.executeUpdate()
}
override fun close() {
getStmt.close()
setStmt.close()
if (ownsConnection) connection.close()
}
}
@@ -0,0 +1,44 @@
package pw.binom.agentik.outbox.ksqlite
import pw.binom.db.ksqlite.SQLiteConnection
/**
* Имена таблиц/колонок для ksqlite-бэкенда `:outbox-api`.
*
* Владеет одной таблицей `outbox_cursor` — ровно одна строка (`id = 1`) с
* персистентной позицией счётчика событий агента (`epoch` + next offset).
*
* Как и остальные ksqlite-модули, `user_version` как gate не используется
* (split-world: несколько модулей ставят его независимо) — [migrate] просто
* идемпотентно прогоняет DDL.
*/
object Schema {
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
const val CURRENT_VERSION: Int = 1
const val TABLE = "outbox_cursor"
const val COL_ID = "id"
const val COL_EPOCH = "epoch"
/** Хранит **next** offset (эксклюзивную границу), а не последний выданный. */
const val COL_OFFSET = "next_offset"
private val ddl = """
CREATE TABLE IF NOT EXISTS $TABLE (
$COL_ID INTEGER PRIMARY KEY CHECK ($COL_ID = 1),
$COL_EPOCH TEXT NOT NULL,
$COL_OFFSET INTEGER NOT NULL
);
"""
fun migrate(conn: SQLiteConnection) {
conn.exec("BEGIN")
try {
conn.exec(ddl)
conn.exec("COMMIT")
} catch (t: Throwable) {
runCatching { conn.exec("ROLLBACK") }
throw t
}
}
}
@@ -0,0 +1,80 @@
package pw.binom.agentik.outbox.ksqlite
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.outbox.Cursor
import pw.binom.agentik.outbox.CursorStore
import pw.binom.agentik.outbox.PersistentOffsetSequencer
import pw.binom.db.ksqlite.SQLiteConnection
/** In-memory [CursorStore] для проверки логики секвенсора без БД. */
private class FakeCursorStore : CursorStore {
var value: Cursor? = null
override fun load(): Cursor? = value
override fun save(cursor: Cursor) {
value = cursor
}
}
class PersistentOffsetSequencerTest {
@Test
fun reservesMonotonically() = runTest {
val seq = PersistentOffsetSequencer(FakeCursorStore(), newEpoch = { "e1" })
assertEquals("e1", seq.epoch())
assertEquals(0L, seq.current())
assertEquals(0L, seq.reserve())
assertEquals(1L, seq.reserve())
assertEquals(2L, seq.current())
}
@Test
fun seedsInitialNextOnFirstRun() = runTest {
val seq = PersistentOffsetSequencer(FakeCursorStore(), initialNext = { 42L }, newEpoch = { "e1" })
assertEquals(42L, seq.current())
assertEquals(42L, seq.reserve())
assertEquals(43L, seq.current())
}
@Test
fun survivesRestartKeepingEpochAndOffset() = runTest {
val store = FakeCursorStore()
val before = PersistentOffsetSequencer(store, newEpoch = { "e1" })
before.reserve()
before.reserve()
// «Рестарт»: новый секвенсор над тем же persisted-хранилищем.
val after = PersistentOffsetSequencer(store, newEpoch = { "MUST-NOT-BE-USED" })
assertEquals("e1", after.epoch())
assertEquals(2L, after.current())
assertEquals(2L, after.reserve())
assertEquals(3L, after.current())
}
}
class KsqliteCursorStoreTest {
@Test
fun roundTripsAndSurvivesRestart() {
val conn = SQLiteConnection.memory("outbox-cursor-test")
try {
val first = KsqliteCursorStore(conn)
assertNull(first.load())
first.save(Cursor(epoch = "e1", offset = 7))
assertEquals(Cursor(epoch = "e1", offset = 7), first.load())
// «Рестарт»: новый store над той же БД.
val second = KsqliteCursorStore(conn)
assertEquals(Cursor(epoch = "e1", offset = 7), second.load())
second.save(Cursor(epoch = "e1", offset = 8))
assertEquals(Cursor(epoch = "e1", offset = 8), first.load())
first.close()
second.close()
} finally {
conn.close()
}
}
}