Skip to content

Fix/clean 102 chat message reliability - #95

Merged
maksymhryzodub-prog merged 10 commits into
mainfrom
fix/CLEAN-102-chat-message-reliability
Sep 18, 2026
Merged

maksymhryzodub-prog merged 10 commits into
mainfrom
fix/CLEAN-102-chat-message-reliability

Conversation

@maksymhryzodub-prog

Copy link
Copy Markdown
Contributor

No description provided.

maksymhryzodub-prog and others added 10 commits September 18, 2026 19:49
Spec 015: lost, duplicated and misordered chat messages, delivery state,
timestamps, scroll on send, agent status consistency. Research records the
causes found in code and the four defects reproduced against the local hub
with the scripted socket probe.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…issed events (CLEAN-102)

The hub kept one socket per identity and every Owner/Admin chats as `admin`,
so the last view to connect took every event: the tab that asked got nothing
and another tab showed the answer. A conversation now holds all its sockets
and fans out to them; the sender's other views get a `user_message` echo.

Sends are acknowledged through the socket.io ack when the browser mints a
`clientMessageId`: accepted / rejected (AGENT_OFFLINE, ATTACHMENT_FAILED,
SHARE_REJECTED, EMPTY), the id travels to the agent as the message id, and a
resend of an id already handed over is not forwarded twice. Callers that send
no id (embed widget) keep the synthetic "Agent is not connected" reply.

Routed events carry a per-conversation `seq` and are kept in a bounded buffer,
also while no socket is connected; a reconnect with `lastSeq` gets what it
missed before live traffic. Verified against the local stack with
specs/015-chat-message-reliability/probe.mjs (steal, gap).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…N-102)

The runtime stores one assistant event per turn; its text is every bubble of
the turn concatenated, so a reload showed "Проверю:Ха! Работает!" in a single
bubble. When the event carries the bubbles (`data.messages`, written by the
runtime from CLEAN-102 on), the reader replays those under their wire ids.
Older events and any malformed `messages` array fall back to the whole text.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…102)

The drift sweep moves agents to `failed` / `unreachable` with no pod or hub
event behind the change (the 5-minute startup timeout), and GET /agents/:id
reconciles on read. Neither reached `/agents/status/stream`, so an open agents
list kept showing `deploying` until somebody opened that agent. Every status
write in AgentStatusService now goes through one method that also emits a
`modified` frame; the controller's sync-on-read notifies the same way.

docs/state.md records the client-side rule this belongs to: one record per
entity in its store, fetches upsert, pushes patch, components render by id.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The agents list, the agent page and the chat page each held their own copy of
an agent, so a status could differ between them. The store is now the single
source: fetchById/create/update/restart upsert into `agents`, components
render `byId(id)`, and the optimistic restart flip goes through `patch()` with
a rollback. A failed poll no longer wipes the record the open page renders.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…cts (CLEAN-102)

Order: the timeline was sorted by `ts`, mixing the browser's clock (own
messages) with the agent's (replies), so with skew a reply rendered above its
question. Items now get a per-conversation `seq` on arrival and the flow is
built by buildChatFlow; `ts` is display only.

Delivery: a send is acknowledged by the hub. Messages carry
sending / slow (5 s) / delivered / failed (30 s, rejected, offline) and the
state persists with the conversation, so a reload shows an undelivered message
as such, with Resend (same id) and Discard. A follow-up can be sent while the
agent is still answering.

Reconnects: the channel is reference-counted with a grace close, so moving
from the landing chat to the agent page keeps the socket and the running turn.
`lastSeq` rides the handshake and replayed events are applied once; a transient
disconnect no longer ends the turn; if the watchdog still expires the transcript
tail is consulted and the person is told when the turn really did not finish.

Also: time under every message, day separators, scroll to bottom on send while
incoming content only follows a reader already at the bottom, and the question
asked in another tab shows up here too. Strings via en.json + i18n:sync.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ivery state (CLEAN-102)

The bridle store was one global message list and socket shared by every chat,
so the Rancher panel and an agent's Chat tab wrote into the same state and a
mount-time clear wiped whatever the other one held. State now lives in a
record per "<agentId>:<channel>" with its own socket, acquired and released by
the provider; loading the transcript merges instead of replacing.

On top of that, the same fixes as the console: order by `seq` instead of
mixed-clock `ts`, acknowledged sends with sending / slow / delivered / failed,
a per-device outbox for messages that never arrived (merged under the
transcript on load, Resend with the same id, Discard), `lastSeq` replay after
a reconnect, turns that survive a transient disconnect, time under every
message, day separators, and scroll to bottom on send.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s the UI (CLEAN-102)

The agents list showed "Deploying" next to a header saying "Failed". Three
causes: the same agent lived in several independent copies (list array, a
fetchById copy per component, the status store's own map, a local optimistic
ref); the status stream mapper read `type` off the API's response envelope and
decoded every frame to null, so no live update ever applied; and the REST
mapper turned `unreachable` into `pending`.

An agent now lives once in agentStore: fetches upsert, the stream's snapshot
and events write into the same collection, components render byId(id), and
optimistic status flips go through patch() with a rollback. The list row shows
the failure reason. Rule recorded in docs/state.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
research.md gains the after-the-fix results (scripted hub probe, headless
Chrome checks under a +2 min page clock) and an explicit list of what is still
unverified. The browser checks live in specs/015-chat-message-reliability/e2e
and need no dependency in the repo; message bubbles get data-message-id /
data-role / data-delivery as hooks for them. The contract records where the
implementation refines it, tasks.md is ticked, and CLAUDE.md points at
docs/state.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@maksymhryzodub-prog
maksymhryzodub-prog merged commit a4ab5ae into main Sep 18, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant