Поток сообщений

TUI — «глупый терминал»: отправил в шину, ждёт от агента. Агент решает: начать обработку сразу или поставить в очередь.

Издатель: TUI (через onSend)

Подписчик: SendMessage Handler в app_runner.go

TUI публикует это для каждого пользовательского сообщения (текст, команда, скилл, ответ ask_user). Не рисует ничего — ждёт ответа от агента.

Издатель: SendMessage Handler (когда TryStart failed — сессия занята)

Подписчик: TUI → handleMessageQueued → рисует в pending-очереди

Издатель: Agent (Run для прямого сообщения, drainPending для pending)

Подписчик: TUI → handleUserMessageReceived → рисует в чате + убирает из pending по messageID

sequenceDiagram
    participant U as Пользователь
    participant TUI
    participant H as Handler
    participant M as Manager
    participant A as Agent.Run
    participant DB as SQLite

    U->>TUI: печатает "привет"
    TUI->>H: SendMessage{msg-1, "привет"}
    Note over TUI: НЕ рисует!
    H->>M: TryStart(sessionID)
    M-->>H: true (свободна)
    H->>A: go Run(ctx, sid, "msg-1", msg)
    Note over H,A: НЕ пишем в DB!
    A->>A: validateUserMessage → append
    A->>TUI: MessageReceived{msg-1, "привет"}
    TUI->>TUI: рисует в чате
    TUI->>TUI: проверяет pending по msg-1 (нет)
    A->>TUI: TurnStart → StreamingToken → ...
sequenceDiagram
    participant U as Пользователь
    participant TUI
    participant H as Handler
    participant M as Manager
    participant A as Agent.Run (работающий)
    participant DB as SQLite

    U->>TUI: печатает "ещё вопрос"
    TUI->>H: SendMessage{msg-2, "ещё вопрос"}
    Note over TUI: НЕ рисует!
    H->>M: TryStart(sessionID)
    M-->>H: false (занята)
    H->>DB: AddPendingMessage(sid, msg-2)
    H->>TUI: MessageQueued{msg-2, "ещё вопрос"}
    TUI->>TUI: рисует в pending-очереди

    Note over A: ...следующая итерация цикла...
    A->>DB: drainPending
    DB-->>A: [msg-2]
    A->>TUI: MessageReceived{msg-2, "ещё вопрос"}
    TUI->>TUI: рисует в чате
    TUI->>TUI: убирает msg-2 из pending
sequenceDiagram
    participant TUI
    participant H as Handler
    participant M as Manager
    participant A as Agent.Run
    participant DB as SQLite

    Note over TUI: Sync tick каждые 2 сек
    TUI->>DB: HasPendingMessages(sid)?
    DB-->>TUI: true
    TUI->>H: SendMessage{nil, ""}
    H->>M: TryStart(sessionID)
    M-->>H: true (свободна)
    H->>A: go Run(ctx, sid, "", nil)
    Note over A: nil message → только drainPending
    A->>DB: drainPending
    DB-->>A: [сообщения]
    A->>TUI: MessageReceived{...} (для каждого)
  • Нет дублей: если TryStart успешен — сообщение НЕ пишется в DB, передаётся напрямую в Run. Если занят — пишется в DB, не передаётся напрямую.
  • Нет зависаний: каждое сообщение в DB гарантированно дренажится через drainPending (в начале Run и в каждой итерации цикла).
  • Race-free: TryStart — атомарная проверка-and-set под mutex.