Record each message once, under one id, as the API received it - #506
Open
shellicar wants to merge 15 commits into
Open
Record each message once, under one id, as the API received it#506shellicar wants to merge 15 commits into
shellicar wants to merge 15 commits into
Conversation
shellicar
marked this pull request as ready for review
August 10, 2026 17:53
The conversation file rebuilds the API request, and it was also carrying an _identity sidecar, which made the ids message state. Two things followed. A query cancelled mid-round leaves a user-role tip, so the next message merges into that row and inherits the dead query's ids. And the audit recorded the assistant under the API's msg.id while the wire announced it under a separate uuid, so one message read as two. The query, the turn and each message now mint and hold their own id, and a message is recorded in the audit and announced on the wire from a single decision, so the two records cannot hold different sets.
The audit wrote a round at a time, at final_message, so a query cancelled during its tools committed a tool_result message the audit never held. That message is on the wire, so the two records disagreed and the tip could not be read back out of the audit on resume. Each message is now written at its own commit moment, from the same decision that announces it. The assistant deliberately keeps its old moment: model output cannot be regenerated, so it is recorded the instant the response completes rather than behind a disk write. The request delta no longer rides final_message, since the user half is recorded before the request goes out. The v2 message schema drops `from` from its required set, matching the 19 July correction that a tool_result has no sender to name.
It had grown four members serving four different callers, and no caller used more than two of them. Adopting a conversation, committing a message, closing a query and reading the tip are four seams behind one implementation, so each caller now declares only the one it uses and cannot reach the rest. The class is named for what it does: it commits a message to both records rather than only announcing it. It also caught a real gap. The boot sequence declared the dependency but never called adopt, so a resumed conversation started with no tip.
The record was taken from the conversation array before the request went out. That array is shaped for the API and still mutable at that point: consecutive user messages merge into one row, and a clock stamp and a heal are applied to the tip on the way out. So the audit and the wire held something the model never saw, and content merged in afterwards reached neither. A message is a message when the model receives it. The turn runner reads the delta after every shaping and the stream processor announces it as the request goes out, so what was sent is what is recorded, under one id, in both records. That also settles the case this started from. A query cancelled during its tools creates a tool_result message that is only sent on the next request, so it is not recorded yet, and it is recorded the moment anything is sent after it.
…e it is attempted The announcement hung off the stream processor, which is called inside the retry loop, so a reconnect or a backed-off resend announced the same message again. It was there because that was the event that existed, not because the flow put it there. It is now a lifecycle listener the turn runner calls once, above the loop, immediately before the first attempt. The retry is the transport recovering from its own failure and carries the same message.
… a call carries it Both records were driven off the send. Consecutive user messages merge into one row, so each send announced that row again with everything merged into it so far, and one message that grew read as four messages each repeating the ones before it. The two answer different questions and belong on different triggers. The audit holds what an API call carried, so it is written per request. The wire holds what the conversation contains, so it is announced when the conversation is persisted, once per row. One id per row keeps them naming the same message.
A row that grew because consecutive user messages merged was skipped on the grounds it had already been announced. Skipping it is how what you said after the first announcement never reached the wire. It is announced every time the conversation is persisted, under the id that row already has. The state of a message is its latest announcement, last-write-wins per id, so fuller content replaces the earlier one rather than adding a second message.
The committer mints the user row's id, so the scope only ever held the assistant's. It is named for what it holds.
A message is announced on the conversation stream each time the conversation is persisted, and the announcement at the end of a turn was made without a sender. A consumer keeps the latest announcement of a message, so a message you typed ended up on the wire with nobody named as having said it. The sender belongs to the message rather than to the moment it is announced, so it is held with the row and reused when a later announcement is not told one.
A message arriving over the bus carries an id and the identity of whoever sent it, and two fields existed to carry those into the turn. The id and the sender are now established when the request is accepted, before the turn starts, so both fields were being filled in on every turn and read nowhere. The sender one is the misleading half: it looks like the thing that tells the record who spoke.
Startup reads the conversation's audit file to find where it left off, parsing every line. The audit is appended without waiting for the write to finish, so a process killed mid-append leaves a line that will not parse, and parsing it threw with nothing to catch it: the CLI failed to launch on that conversation. Such a line is skipped, the same as a line that carries no id.
When the CLI opens or moves to a conversation it reads the durable record to find the last message, and states that as the conversation's position. Another program has to name that position to be allowed to speak, so getting it wrong means nothing can talk to the conversation. Nothing asserted it: every test replaced the reader with a stub returning nothing, and the reader itself had no test at all.
shellicar
force-pushed
the
fix/remove-identity-field
branch
from
September 12, 2026 10:57
dc6f81b to
96f1db7
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary