Defect and feature in one change
The board is discovered by polling instead of being announced, and its frames carry no ordering key.
broadcastBoards() walks every project, reloads all of its issues from SQLite, diffs them, then sleeps two seconds — for the server's lifetime, whether or not anyone is connected (Server.php:1061-1076). Work happens when nothing changed; a change waits up to two seconds to cross a connection that is already open; the cost scales with projects rather than with activity. The SSE path carries its own copy of the same poll-and-diff (Server.php:894ff), which is why the bootstrap defect fixed in #97 existed on one path and not the other.
A run trace has none of this: TraceBus pushes and TraceWire::row() fixes the wire form, so a replayed row and a live one are identical on both transports and every row carries a seq the client de-dupes by. The board has no equivalent, and that is the residual race left by #97 — a late subscriber's bootstrap and a concurrent live change on the same socket have nothing to order them by, so a stale frame can overwrite a fresh one.
The design is written
dev/design/board-cursor.md in this repository: one wire form {producerId, seq, tsMs, kind, data} shared by every producer and both transports, a BoardFeed value object holding one producer's counter and last-sent map, tsMs ordering across producers and seq within one, the poll deleted in favour of the writing worker announcing its own change, and coalescing plus a debounce so a run touching its ticket ten times a second yields one frame.
Three decisions in it are still open: how the dirty signal is wired (a TraceBus subscription against explicit marks at the row-edit sites), the debounce value, and what to do with a tsMs tie.
Depends on
The multi-worker change, because the numbering scheme exists to survive it: with one worker a single in-process counter would do, and with several there is no shared memory to hold one.
Acceptance
broadcastBoards() and the server-global $sent cursor are gone.
- A change reaches an open board in milliseconds, and a burst of changes to one ticket arrives as one frame.
- A client that connects while a run is writing ends with the same board as one that was connected all along, whichever order the frames arrive in.
Defect and feature in one change
The board is discovered by polling instead of being announced, and its frames carry no ordering key.
broadcastBoards()walks every project, reloads all of its issues from SQLite, diffs them, then sleeps two seconds — for the server's lifetime, whether or not anyone is connected (Server.php:1061-1076). Work happens when nothing changed; a change waits up to two seconds to cross a connection that is already open; the cost scales with projects rather than with activity. The SSE path carries its own copy of the same poll-and-diff (Server.php:894ff), which is why the bootstrap defect fixed in #97 existed on one path and not the other.A run trace has none of this:
TraceBuspushes andTraceWire::row()fixes the wire form, so a replayed row and a live one are identical on both transports and every row carries aseqthe client de-dupes by. The board has no equivalent, and that is the residual race left by #97 — a late subscriber's bootstrap and a concurrent live change on the same socket have nothing to order them by, so a stale frame can overwrite a fresh one.The design is written
dev/design/board-cursor.mdin this repository: one wire form{producerId, seq, tsMs, kind, data}shared by every producer and both transports, aBoardFeedvalue object holding one producer's counter and last-sent map,tsMsordering across producers andseqwithin one, the poll deleted in favour of the writing worker announcing its own change, and coalescing plus a debounce so a run touching its ticket ten times a second yields one frame.Three decisions in it are still open: how the dirty signal is wired (a
TraceBussubscription against explicit marks at the row-edit sites), the debounce value, and what to do with atsMstie.Depends on
The multi-worker change, because the numbering scheme exists to survive it: with one worker a single in-process counter would do, and with several there is no shared memory to hold one.
Acceptance
broadcastBoards()and the server-global$sentcursor are gone.