Введение монотонного 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) удалён.
:client — Ktor-клиент к :server (KMP, jvm + native)
Тонкий HTTP-клиент к :server-фасаду + локальные примитивы, чтобы
собирать свои клиенты (UI, CLI, parent-агенты, A2A-bridge) без бойлерплейта
про HTTP, JSON, SSE и lifecycle Conversation.
Что есть
AgentikAgent(id, baseUrl, engineFactory, token?)— entry-point. ВозвращаетAgent(тот же интерфейс, что в:proto). HttpClient создаётся внутри из переданнойengineFactory(CIO,OkHttp,Darwin).Agent:createConversation/getConversation/getConversations/deleteConversation/journal/outbox/close.Conversation:send(content, context?)/events(after)(SSEFlow<Event>) /getMessages(after, offset, limit)/rename/interrupt/close.HttpJournalStore—list(convId, afterSeq, upToSeq, limit)/count(convId, afterSeq)→List<MessageRecord>со всеми типами записей (User/Assistant/ToolCall/ToolResult/Error + tokens). Адресация — поseq(см. «Курсорный протокол»), не по датам.HttpEventStore—events(after: Cursor?)/agentEvents/conversationEvents(SSE),currentCursor()/oldestCursor().Agent.conversationsSnapshot()/Agent.chatSnapshot(convId)— состояние +Cursor, на котором оно валидно. Точка входа resync'а.ReconnectingOutbox(outbox, scope, policy)— обёртка надOutboxStoreс авто-reconnect при обрыве стрима (exponential backoff). Два независимых потока:events(after: Cursor?)(те жеCommonEvent) иconnectionStatus()(Connecting/Connected/Disconnected/Failed/Gap) — статус НЕ мешается с основным потоком событий. Мёртвый курсор даётGap(не ретраится). См. ниже.
Agent — AutoCloseable; agent.close() закрывает HttpClient. Не нужно
вручную создавать HttpClient и накатывать на него JSON/Bearer-плагины.
Подключение
// build.gradle.kts
dependencies {
api("pw.binom.agentik:client:0.1.0")
// Движок — на твой выбор (один из):
implementation("io.ktor:ktor-client-cio:3.x") // JVM/Native
implementation("io.ktor:ktor-client-okhttp:3.x") // JVM
implementation("io.ktor:ktor-client-darwin:3.x") // iOS/macOS
// Опционально — только если будешь использовать `InMemoryJournalStore`
// как клиентский кэш. Свой `MutableJournalStore` — не нужен.
api("pw.binom.agentik:journal-inmemory:0.1.0")
}
Что клиент хранит локально (persistence)
Либа не имеет SettingsRepository / Config — это намеренно: UI-фреймворки хранят настройки по-разному (JSON-файл, Keychain, Android DataStore, NSUserDefaults, ...). Либа не навязывает формат, но клиент должен сериализовать у себя минимум:
| Поле | Что это | Где взять |
|---|---|---|
clientId (параметр id в AgentikAgent) |
Идентичность клиента в логах сервера (X-Client-Id header). Не user-id в агенте, не device-id — это произвольная строка клиента, обычно <app-name>-<installation-uuid>. Сервер использует для log multiplexing и не интерпретирует. |
Генерируется один раз при первом запуске (UUID.randomUUID().toString()) и сохраняется. Никогда не меняется. |
baseUrl |
URL сервера (http://host:8080/agentik). Должен включать path-prefix фасада, не только хост. |
Из настроек пользователя / дефолт |
token |
Bearer-токен. null = анонимный доступ (если сервер разрешает). |
Из настроек пользователя / secure-storage |
Опционально (для UX): engineFactory — обычно compile-time выбор по платформе (CIO JVM/Native, OkHttp JVM, Darwin iOS/macOS).
Минимальный JSON для UI, который хранит в файле:
{
"clientId": "my-android-app-550e8400-e29b-41d4-a716-446655440000",
"baseUrl": "https://agent.example.com/agentik",
"token": "s3cret"
}
⚠️ clientId генерируется один раз при установке и больше не меняется — иначе сломается log multiplexing на сервере.
Курсорный протокол (как получить гарантированно актуальное состояние)
Всё серьёзное в :client крутится вокруг одного понятия — курсор события
(Cursor(epoch, offset)), аналога Kafka-offset. Он монотонный, сквозной на
все события агента (одна общая нумерация для AgentEvent и Conversation
-событий) и лежит над двумя хранилищами:
OutboxStore— короткий bounded-tail live-потокCommonEvent(уведомления/дельты). Хранится ограниченно (cap/TTL), события вытесняются.journal+conversationStore— персистентный источник истины (полное состояние). У каждой записи есть свойseqиз того же счётчика.
Ключевое свойство: CommonEvent.offset == MessageRecord.seq ==
ConversationRecord.seq. Событие с offset = N — это ровно «строка состояния
с seq = N изменилась (или появилась/удалилась)». События абсолютные: в
UserMessage/AssistantMessage лежит целая запись, Renamed несёт новый
заголовок, Deleted — «строки больше нет». Поэтому накатывать их на состояние
можно повторно (идемпотентно по id) и в любом порядке относительно снапшота.
Инвариант, на котором стоит гарантия
- Писатель (сервер) сначала пишет строку состояния с
seq = N, потом кладёт событие сoffset = Nв outbox.seqиoffset— один счётчик. - Читатель (клиент) читает сначала курсор, потом состояние:
C = currentCursor()→state = listUpTo(C). Всё, что≤ C, уже в снапшоте; всё, что> C, придёт потоком. - Применение идемпотентно (upsert/delete/rename по
id), поэтому перекрытие снапшота и дельт безвредно.
Ничего не блокируется. Снапшот — это не «заморозка таблицы на время
выгрузки»: это baseline на курсоре C плюс накат всех дельт > C.
Правильная последовательность синхронизации
1. lastSeen = локально сохранённый курсор (или null при первом запуске)
2. попытка: outbox.events(after = lastSeen) ← если сервер ответил
OutboxGapException / ConnectionStatus.Gap → курсор мёртв, иди в п.3
3. ПОЛНЫЙ RESYNC:
a. очистить локальную БД (строки + курсор), пометить «resyncing»
b. C = agent.conversationsSnapshot().cursor (или .chatSnapshot(convId))
c. подписаться events(after = C) и СКОПИРОВАТЬ события в буфер (не применять!)
d. прочитать полное состояние: listUpTo(C) / snapshot.messages
e. применить снапшот целиком
f. применить буфер дельт в порядке offset
4. дальше: применение каждого события из потока (upsert by id)
5. сохранить последний offset как lastSeen
Порядок из шага 3 критичен: сначала подписка, потом снапшот. Если сделать
наоборот (снапшот, потом подписка) — события, пришедшие в промежуток, потеряются.
Буферизация (а не «применять на лету») закрывает delete-resurrection: событие
Deleted(offset > C) для строки, которая ещё лежит в необработанной странице
снапшота, при применении «на лету» было бы стёрто, а потом снапшот вставил бы
строку обратно.
Курсор мёртв: OutboxGapException
Клиент давно не заходил, outbox вытеснил его события (after.offset < oldestCursor().offset), либо сменилась epoch (БД сервера откатили/
восстановили/скопировали — счётчик начал считаться заново). Сервер отвечает
410 Gone. Клиент не ретраит — это сигнал «сделай полный resync»
(шаг 3 выше). С ReconnectingOutbox это приходит как
ConnectionStatus.Gap, поток закрывается, background-loop встаёт.
Никогда не ретрай OutboxGapException — ретрай никогда не пройдёт.
Простой вариант: пересоздать outbox на resync
Если своя реализация шага 3 кажется тяжёлой — минимальный корректный путь
через ReconnectingOutbox:
var recon = ReconnectingOutbox(agent.outbox, scope)
scope.launch { recon.events(after = lastSeen).collect { applyEvent(it) } }
scope.launch {
recon.connectionStatus().collect { s ->
if (s is ConnectionStatus.Gap) {
recon.close()
val snap = agent.chatSnapshot(convId) // state + cursor
applySnapshot(snap.messages) // upsert by id
lastSeen = snap.cursor
recon = ReconnectingOutbox(agent.outbox, scope)
scope.launch { recon.events(after = lastSeen).collect { applyEvent(it) } }
}
}
}
Быстрый старт: свой клиент за 5 минут
Один self-contained пример: создаём агента, открываем диалог, отправляем сообщение, печатаем streaming-ответ.
import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.content.Content
import pw.binom.agentik.outbox.DurableEvent
import pw.binom.agentik.outbox.OnlineEvent
import io.ktor.client.engine.cio.CIO
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
// 1. Agent — обёртка над :server фасадом. HttpClient создаётся внутри.
val agent = AgentikAgent(
id = "my-client",
baseUrl = "http://localhost:8080/agentik",
engineFactory = CIO,
token = "s3cret", // или null, если не нужен
)
val conv = agent.createConversation(temp = false)
// Подписка «после текущего курсора» — событий строго после этой точки.
val cursor = agent.outbox.currentCursor()
// 2. Два независимых потока событий диалога:
// durable (outbox) — целые события, с курсором после переподключения;
// online (OnlineOutbox) — стриминг ответа, только live (без курсора).
launch {
agent.outbox.conversationEvents(after = cursor, conversationId = conv.id)
.collect { ce ->
when (val ev = ce.event) {
is DurableEvent.AssistantMessage -> println("[answer ready: ${ev.content}]")
is DurableEvent.Interrupted -> println("[interrupted]")
is DurableEvent.Error -> println("[error: ${ev.message}]")
else -> Unit
}
}
}
launch {
agent.onlineOutbox.onlineEvents(conv.id).collect { ev ->
when (ev) {
is OnlineEvent.Working -> println("[working]")
is OnlineEvent.AppendText -> print(ev.body)
is OnlineEvent.StartResponse -> println("[start]")
is OnlineEvent.End -> println("\n[end]")
else -> Unit
}
}
}
// 3. Отправить ход (fire-and-forget — ответ придёт по подпискам выше).
conv.send(listOf(Content.Text("Привет")))
// 4. Чистый shutdown.
conv.close()
agent.close()
}
Это весь клиент. :server сам хранит историю, контекст, события.
Ты только получаешь два типизированных Flow и рендеришь как хочешь.
Durable vs online.
DurableEvent(вagent.outbox) — «целые» события, их можно перезапросить по курсоруafter.OnlineEvent(вagent.onlineOutbox) — поток стриминга (Working/End/AppendText/AppendImage), никогда не сохраняется и не реплеится: потерянный при обрыве фрагмент невосстановим, но целый ответ всегда придёт durable-DurableEvent.AssistantMessageи/или ляжет в journal.
HttpClient, applyAgentikDefaults, выбор engine'а — всё скрыто
внутри AgentikAgent. Один вызов — один готовый Agent.
Добавить локальный кэш истории (ещё 4 строки)
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
// Свой кэш. Хочешь SQLite/JSON/etc. — реализуй MutableJournalStore сам.
val cache = InMemoryJournalStore()
// Снапшот на курсоре + подписка ПОСЛЕ него — без потерь (см. «Курсорный протокол»).
val snap = agent.chatSnapshot(conv.id)
cache.appendAll(snap.messages)
launch {
agent.outbox.conversationEvents(after = snap.cursor, conversationId = conv.id)
.collect { ce -> applyDurable(ce.event, cache) } // upsert by id
}
// История — теперь из кэша, без HTTP:
val history = cache.list(conv.id, afterSeq = 0L, upToSeq = Long.MAX_VALUE, limit = Int.MAX_VALUE)
history.forEach { rec ->
when (rec) {
is pw.binom.agentik.journal.MessageRecord.UserMessage -> print("user> ${rec.content.text()}")
is pw.binom.agentik.journal.MessageRecord.AssistantMessage -> print("agent> ${rec.content.text()}")
is pw.binom.agentik.journal.MessageRecord.ToolCall -> print("[tool: ${rec.toolName}]")
is pw.binom.agentik.journal.MessageRecord.ToolResult -> print("[result]")
is pw.binom.agentik.journal.MessageRecord.Error -> print("[error: ${rec.message}]")
}
}
Шаблон «snapshot(курсор) → local.apply → live-дельты после курсора» работает с
любым MutableJournalStore (см. :journal-api). Это и есть кэширование
«без геморроя» с гарантией актуальности.
Что вообще не нужно писать самому
- HTTP-сериализация
DurableEvent/Message—agentikHttpClientрегистрируетagentikJsonиInstantSerializer. - SSE-парсер —
readSse()внутри:client. - Cursor-менеджмент — сервер ведёт единый монотонный
offset/seq, клиент лишь хранитCursor(epoch, offset). НикакихInstant-сравнений и pagination-циклов вручную. - Lifecycle подписок на
events()—Conversation.close()отменяет SSE-job. - HTTP-клиент и Bearer —
AgentikAgentсоздаётHttpClient(engineFactory)с Bearer'ом изtoken=под капотом;agent.close()его закрывает. - Движковые настройки (requestTimeout и пр.) —
HttpClient(engineFactory) { ... }создаётся здесь; для нестандартных движковых настроек используйagentikHttpClient(engineFactory, token)напрямую (он экспортирован).
Что нужно написать самому
- UI-рендеринг
DurableEvent'ов — это твоё (Compose/HTML/CLI). - Диалог с пользователем — ввод текста, отображение кнопок и т.п.
- Persist кэша между запусками (если нужно) — замени
InMemoryJournalStoreна свойMutableJournalStore(см.:journal-ksqliteкак пример).
Базовый пример: send + collect events
import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.content.Content
import pw.binom.agentik.outbox.DurableEvent
import pw.binom.agentik.outbox.OnlineEvent
import io.ktor.client.engine.cio.CIO
import kotlinx.coroutines.launch
val agent = AgentikAgent(
id = "agentik",
baseUrl = "http://localhost:8080/agentik",
engineFactory = CIO,
)
val conv = agent.createConversation(temp = false)
// durable-поток (с курсором): terminal-события хода.
val cursor = agent.outbox.currentCursor()
launch {
agent.outbox.conversationEvents(after = cursor, conversationId = conv.id)
.collect { ce ->
when (ce.event) {
is DurableEvent.AssistantMessage -> println("\n--- answer ready ---")
is DurableEvent.Error -> error("agent error: ${(ce.event as DurableEvent.Error).message}")
else -> Unit
}
}
}
// online-поток (live-only): стриминг ответа.
launch {
agent.onlineOutbox.onlineEvents(conv.id).collect { ev ->
when (ev) {
is OnlineEvent.AppendText -> print(ev.body) // streaming чанки
is OnlineEvent.End -> println("\n--- end ---")
else -> Unit
}
}
}
conv.send(listOf(Content.Text("Привет, расскажи про себя")))
Локальный кэш истории (правильный паттерн)
Клиент держит свой MutableJournalStore и наполняет его снапшотом на
курсоре + дельтами после курсора (см. «Курсорный протокол»). Чтение истории —
из локального кэша, без HTTP.
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.outbox.DurableEvent
// Кэш. Для диска — свой MutableJournalStore (KsqliteJournalStore в :journal-ksqlite).
val cache = InMemoryJournalStore()
class ChatSession(
private val agent: pw.binom.agentik.proto.Agent,
val conversationId: String,
) : AutoCloseable {
private val scope = kotlinx.coroutines.CoroutineScope(
kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.Default,
)
var lastSeen: pw.binom.agentik.outbox.Cursor? = null
init {
scope.launch {
// 1. Снапшот: состояние + курсор, на котором оно валидно.
val snap = agent.chatSnapshot(conversationId)
snap.messages.forEach { cache.append(it) }
lastSeen = snap.cursor
// 2. Дельты строго после курсора снапшота.
agent.outbox.conversationEvents(after = snap.cursor, conversationId = conversationId)
.collect { ce ->
applyToCache(ce.event)
lastSeen = ce.cursor
}
}
}
private suspend fun applyToCache(e: DurableEvent) {
when (e) {
is DurableEvent.UserMessage -> cache.append(e.toRecord())
is DurableEvent.AssistantMessage -> cache.append(e.toRecord())
is DurableEvent.ToolCall -> cache.append(e.toRecord())
is DurableEvent.ToolResult -> cache.append(e.toRecord())
is DurableEvent.Error -> cache.append(e.toRecord())
is DurableEvent.Interrupted -> Unit
}
}
fun history() = kotlinx.coroutines.runBlocking {
cache.list(conversationId, afterSeq = 0L, upToSeq = Long.MAX_VALUE, limit = Int.MAX_VALUE)
}
override fun close() { scope.cancel() }
}
applyToCacheчерезcache.appendдаёт upsert поid(append-only store отбрасывает дубликатыid), поэтому перекрытие снапшота и дельт безвредно. ЗаменитеInMemoryJournalStoreнаKsqliteJournalStore— код не меняется.
Когда курсор мёртв
Если conversationEvents(after = ...) бросает OutboxGapException (или
ReconnectingOutbox эмитит ConnectionStatus.Gap) — клиент был оффлайн дольше
retention'а. Повторите всю последовательность с шага 1 (снапшот), предварительно
очистив локальную БД (cache.clear(conversationId)), иначе воскреснут
удалённые строки. Полный алгоритм — в «Курсорный протокол» выше.
Кэш списка бесед
agent.conversationStore — read-only projection поверх таблицы conversation
на сервере (ConversationRecord = id / title / isTemporal / createdAt /
updatedAt). AgentikAgent оборачивает его в локальный кэш
(wrapWithLocalConversationCache) по тому же протоколу, что и историю:
снапшот на курсоре + live-дельты.
import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.outbox.AgentEvent
import io.ktor.client.engine.cio.CIO
val agent = AgentikAgent(id = "agentik", baseUrl = "http://localhost:8080/agentik", engineFactory = CIO)
// Снапшот списка бесед + его курсор (глобальный для агента).
val snap = agent.conversationsSnapshot()
snap.conversations.forEach { println("${it.id} ${it.title ?: "(no title)"} ${it.updatedAt}") }
// Дельты после курсора снапшота: Created / Deleted / Renamed / Touched.
agent.outbox.agentEvents(after = snap.cursor).collect { ce ->
when (val ev = ce.event) {
is AgentEvent.Created -> println("+ ${ev.conversationId}")
is AgentEvent.Renamed -> println("~ ${ev.id} -> ${ev.title}")
is AgentEvent.Touched -> println("~ ${ev.id} (${ev.updatedAt})")
is AgentEvent.Deleted -> println("- ${ev.id}")
}
}
Команды (создать / переименовать / удалить) идут через agent; сервер сам
эмитит соответствующее AgentEvent в outbox, клиент применяет его к кэшу:
val conv = agent.createConversation(temp = false) // POST /conversations -> Created
agent.renameConversation(conv.id, "Новый заголовок") // PATCH /conversations/{id} -> Renamed
agent.deleteConversation(conv.id) // DELETE /conversations/{id} -> Deleted
Никогда не пиши в conversationStore напрямую. Для активной работы
(send / interrupt) — handle через agent.getConversation(id).
Если хочется своего cache-импла
InMemoryMutableConversationStore подходит для большинства случаев. Для диска —
свой MutableConversationStore (см. KsqliteMutableConversationStore в
:journal-ksqlite). Методы rename/touch принимают seq из общего счётчика:
override suspend fun list(offset: Int, limit: Int): List { /* SELECT ORDER BY updatedAt DESC / }
override suspend fun delete(id: String): Boolean { / DELETE / }
override suspend fun rename(id: String, title: String?): Instant? { / UPDATE + bump updatedAt / }
override suspend fun touch(id: String, now: Instant) { / UPDATE updatedAt */ }
override fun close() {}
}
## Тесты
./gradlew :client:jvmTest
Покрывают: JSON-парсинг `DurableEvent`-ов, SSE-стрим, recovery после разрыва,
401/404, reconnect-cycle `ReconnectingOutbox` (5 кейсов: успех / обрыв +
reconnect / exhausted attempts → Failed / мёртвый курсор → Gap (без ретрая) /
close → cancel).
## Auto-reconnect для живого outbox
Базовый `OutboxStore.events(after: Cursor?)` — cold SSE-стрим; при обрыве
(мобильная сеть, рестарт сервера) клиент должен сам реконнектиться с курсором
последнего увиденного события. Это повторяется в каждом клиенте, поэтому
`ReconnectingOutbox` берёт это на себя:
```kotlin
val recon = ReconnectingOutbox(
outbox = agent.outbox, // или HttpEventStore
scope = myScreenScope,
policy = BackoffPolicy.Default, // 1s → 2s → ... → 30s, ±20% jitter
)
// lastSeen — курсор из последнего снапшота / последнего события.
scope.launch { recon.events(after = lastSeen).collect { handle(it) } }
scope.launch {
recon.connectionStatus().collect { status ->
when (status) {
is Connecting -> ui.showBanner("connecting...")
is Connected -> ui.hideBanner()
is Disconnected -> ui.showBanner("reconnecting in ${status.willRetryIn}…")
is Failed -> ui.showError(status.cause)
is Gap -> resync(status.cause) // курсор мёртв — полный resync
}
}
}
// На выходе (например, navigation back):
recon.close() // отменяет background-loop, потоки терминируются
Gap — единственный статус, который не ретраится: курсор старше
retention'а или чужая эпоха. Обработка — полный resync (см. «Курсорный
протокол»). Если не передать after, при старте берётся
outbox.currentCursor() (live-only семантика).
Два потока независимы — events() содержит только CommonEvent,
connectionStatus() содержит только ConnectionStatus. Никакого
"мешающего" Connecting/Disconnected в потоке событий.
Параметры backoff (см. BackoffPolicy):
initial/max— границы задержкиmultiplier— множитель на каждом шагеjitter— рандом-разброс (по умолчанию 20%)maxAttempts— лимит попыток; после —Failed+ закрытие потока
Если нужен фиксированный delay для тестов — BackoffPolicy.Fixed(10.milliseconds, attempts = 3).
Известное ограничение
SSE event-stream в не-TTY ssh-сессии (без -tt) закрывается на
default-таймауте Ktor. Используйте либо ssh -tt, либо нативный
terminal (TTY). Это upstream-особенность Ktor SSE.