Добавляет 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
+3 -3
View File
@@ -8,7 +8,7 @@
сообщения, а не всю историю (в отличие от AG-UI, где клиент обязан
повторять `messages[]` каждый раз).
- **declarative история vs. события** — `Message` это то, что уже легло
в БД, `Event` это live-стрим от агента во время `send()` или `events()`.
в БД, `DurableEvent` это live-стрим от агента во время `send()` или `events()`.
- **чистые интерфейсы** — никаких сетевых и storage зависимостей внутри
`:proto`; это контракт.
@@ -25,7 +25,7 @@
- `:agentik-cli` — работает поверх `:client`, а следовательно поверх `:proto`.
*(`:agentik-tui` был исключён из сборки 2026-09-17.)*
- `:standalone` — реализует `Agent` (через `ChatAgent`) и пишет/читает
`Message`/`Event` напрямую через storage.
`Message`/`DurableEvent` напрямую через storage.
## Как подключить
@@ -62,7 +62,7 @@ target-specific артефакты + общий `kotlinMultiplatform`.
`:proto` — **тонкий** контракт: сами интерфейсы `Agent`/`Conversation`/`Message`,
а общие типы содержимого и события живут в нижележащих модулях:
`Content`/`MessageContext`/`MessageOrigin`/`TurnTokens` — в `:content-api`,
`Event`/`OnlineEvent`/`OutboxStore`/`OnlineOutbox` — в `:outbox-api`.
`DurableEvent`/`OnlineEvent`/`OutboxStore`/`OnlineOutbox` — в `:outbox-api`.
```kotlin
interface Agent : AutoCloseable {
@@ -135,6 +135,27 @@ interface Agent : AutoCloseable {
*/
suspend fun renameConversation(id: String, title: String?): Instant?
/**
* Снапшот **всего состояния агента** (список диалогов) + курсор, на котором
* он валиден. Точка входа resync'а: клиент после [OutboxGapException] чистит
* локальный кэш, берёт этот снапшот, применяет его, затем подписывается
* `outbox.agentEvents(after = snapshot.cursor)` и накатывает дельты.
*
* Курсор читается **до** состояния (cursor-first) — это не гонка: любое
* изменение, случившееся во время чтения, имеет offset `> cursor` и придёт
* потоком. См. README `:client` (протокол синхронизации).
*/
suspend fun conversationsSnapshot(): ConversationsSnapshot
/**
* Снапшот сообщений диалога [conversationId] + курсор, на котором он валиден.
* Сообщения отсечены `seq <= cursor.offset` (см. [ChatSnapshot]).
*
* Парный к [conversationsSnapshot] для per-chat resync'а: подписка —
* `outbox.conversationEvents(after = snapshot.cursor, conversationId)`.
*/
suspend fun chatSnapshot(conversationId: String): ChatSnapshot
companion object {
const val PAGE_SIZE: Int = 100
@@ -13,7 +13,7 @@ import kotlin.time.Instant
*
* **Live-события** диалога НЕ часть этого интерфейса. Их два независимых
* потока:
* - **durable** ([pw.binom.agentik.outbox.Event]: UserMessage / AssistantMessage /
* - **durable** ([pw.binom.agentik.outbox.DurableEvent]: UserMessage / AssistantMessage /
* Interrupted / Error / ToolCall / ToolResult / ToolFailed) — из
* [pw.binom.agentik.outbox.OutboxStore], перезапрашивается по курсору:
* ```
@@ -0,0 +1,40 @@
package pw.binom.agentik.proto
import kotlinx.serialization.Serializable
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.outbox.Cursor
/**
* Снапшот списка диалогов + его [cursor].
*
* Курсор — **commit point** на момент чтения (см. `OutboxStore.currentCursor`):
* состояние со `seq <= cursor.offset` отражено в [conversations], всё, что
* появится позже, придёт потоком outbox-событий. Клиент после применения
* снапшота подписывается `agentEvents(after = cursor)` (или
* `conversationEvents(after = cursor)`) и накатывает дельты поверх.
*
* Таблица `conversation` мала (реестр диалогов), поэтому отсечки по `seq`
* внутри снапшота нет — читается целиком; протокол от этого не страдает
* (absolute-события + идемпотентный apply).
*/
@Serializable
data class ConversationsSnapshot(
val conversations: List<ConversationRecord>,
val cursor: Cursor,
)
/**
* Снапшот сообщений одного диалога + его [cursor].
*
* Сообщения прочитаны с отсечкой `seq <= cursor.offset` — конечное и
* стабильное множество, отражающее состояние диалога на момент [cursor].
* Всё, что появится позже (`seq > cursor.offset`), придёт потоком
* `conversationEvents(after = cursor, conversationId)`.
*/
@Serializable
data class ChatSnapshot(
val conversationId: String,
val messages: List<MessageRecord>,
val cursor: Cursor,
)