Never let one unreadable line take a transcript down

Removing `Event::TaskNote` hours after adding it made every transcript that
had recorded one unreadable. `Transcript::open` parses every line, so `launch`
failed for those sessions and `SessionManager::new` logged
"couldn't relaunch session <id>" and skipped them -- and a skipped session has
no pump and no driver. On the phone that is no status, no history and nothing
sendable, for every live session that had run a background task. One
unfamiliar word took down every conversation it appeared in.

A transcript is append-only and permanent, so the set of kinds one can hold
only ever grows: what this build writes is not what it may have to read. A
line can come from a newer server, or from an older one that wrote a kind
since dropped, and neither may be able to end the file.

`Indexed::parse_at` degrades a line it cannot make sense of to
`Event::Unreadable { kind }` instead of failing the whole read. It keeps the
line's seq -- the cursors, the page bisection and the next-seq counter are all
addressed by it, and dropping the line would hand out a seq the file already
contains -- and carries the word the line called itself, so the phone can say
what is missing rather than that something is. A line with no readable seq is
still an error: that one cannot be placed at all.

`Event::TaskNote` comes back retired rather than deleted: deserializable,
never constructed, dated, with the reason on it. The phone folds it to no row,
which is the point -- an unreadable line correctly draws a placeholder, and
one per background task is the wall the row was removed for in the first
place.

Found while diagnosing a report that live sessions had lost their status and
could not be sent to. 173 server tests pass, including the new one, which
fails on the old code within a second.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-06 22:23:45 -04:00
1 parent 9cc52beb09
commit fd71d876e1
6 files changed
+215 -7

No files matched your search

+42
View File
@@ -208,6 +208,48 @@ pub enum Event {
#[serde(default, skip_serializing_if = "Option::is_none")]
turn_start: Option<u64>,
},
/// **Retired on 2026-09-06, hours after it was added.** Kept only so the
/// transcripts written while it existed still read: a session that ran a
/// background task in that window has these lines for ever, and a
/// transcript is append-only, so there is no pass that could remove them.
///
/// Never constructed. It reported a background task finishing, and drawing
/// a row per one turned out to be a screen of notices about work the
/// reader was not asking after -- see PLAN.md's "Two turns must never be
/// drawn as one". The phone folds it to no row at all, which is what makes
/// keeping it cheap.
///
/// Deleting the variant instead is what broke every live session, and
/// [`Event::Unreadable`] is the reason that cannot happen again. This is
/// still here rather than left to that: an unreadable line draws a
/// placeholder, correctly, and one per background task is the same wall
/// the row was removed for.
TaskNote {
about: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
title: Option<String>,
status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
summary: Option<String>,
},
/// A line in a transcript that this build cannot read: a kind a newer
/// server wrote, a kind an older one wrote that has since been dropped, or
/// a line whose contents do not fit the kind it names.
///
/// **Only ever made when reading, never sent by a driver**, and it is the
/// reason a transcript can outlive a change to this enum. See
/// `Indexed::parse_at` for the incident: removing a variant after
/// transcripts had recorded it made every read of those files fail, so
/// every session in them lost its status, its history and its ability to
/// be sent to.
///
/// It carries the word the line called itself so a reader is told what
/// they are missing rather than that something is missing. `kind` is not
/// an enum for the obvious reason: the whole point of this variant is the
/// words that are not in one.
Unreadable {
kind: String,
},
/// The manager's record of a question being answered, so a rendered
/// question card resolves on every device rather than only the one that
/// answered.