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:
1 parent
9cc52beb09
commit
fd71d876e1
6 files changed
+215
-7
No files matched your search
@@ -309,18 +309,70 @@ impl<'a> Indexed<'a> {
|
||||
}
|
||||
|
||||
fn parse(&self, range: Range<usize>) -> Result<Vec<SeqEvent>> {
|
||||
self.lines[range]
|
||||
self.lines[range.clone()]
|
||||
.iter()
|
||||
.map(|at| {
|
||||
serde_json::from_str(&self.text[at.clone()])
|
||||
.with_context(|| format!("bad transcript line in {}", self.path.display()))
|
||||
})
|
||||
.enumerate()
|
||||
.map(|(offset, at)| self.parse_at(range.start + offset, at.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_one(&self, index: usize) -> Result<SeqEvent> {
|
||||
serde_json::from_str(&self.text[self.lines[index].clone()])
|
||||
.with_context(|| format!("bad transcript line in {}", self.path.display()))
|
||||
self.parse_at(index, self.lines[index].clone())
|
||||
}
|
||||
|
||||
/// One line, degrading to [`Event::Unreadable`] rather than failing when
|
||||
/// this build cannot make sense of the event on it.
|
||||
///
|
||||
/// **A transcript is append-only and permanent, so the set of kinds that
|
||||
/// can appear in one only ever grows.** What this build writes is not what
|
||||
/// it may have to read: a line may come from a newer server, or from an
|
||||
/// older one that wrote a kind since dropped. Refusing the whole file for
|
||||
/// one such line is what happened on 2026-09-06 -- a kind was removed after
|
||||
/// transcripts had recorded it, every read of those files failed, and the
|
||||
/// sessions in them could not be opened, listed, paged or sent to. One
|
||||
/// unfamiliar word took down every conversation it appeared in.
|
||||
///
|
||||
/// So the line survives as a line. It keeps its seq, which is the part
|
||||
/// everything downstream is addressed by, and says what it was rather than
|
||||
/// pretending to be something -- there is a row for that on the phone
|
||||
/// already.
|
||||
///
|
||||
/// The seq itself is still required, and this still fails without one: a
|
||||
/// line that cannot say where it sits in the sequence is not a line this
|
||||
/// file can hold, and quietly dropping it would hand out a seq the file
|
||||
/// already contains.
|
||||
fn parse_at(&self, index: usize, at: Range<usize>) -> Result<SeqEvent> {
|
||||
let line = &self.text[at];
|
||||
match serde_json::from_str(line) {
|
||||
Ok(entry) => Ok(entry),
|
||||
Err(err) => {
|
||||
#[derive(Deserialize)]
|
||||
struct JustPlace {
|
||||
seq: u64,
|
||||
ts: f64,
|
||||
#[serde(rename = "type")]
|
||||
kind: Option<String>,
|
||||
}
|
||||
let place: JustPlace = serde_json::from_str(line).with_context(|| {
|
||||
format!("bad transcript line in {}", self.path.display())
|
||||
})?;
|
||||
// Debug rather than a warning: a transcript written against a
|
||||
// newer build has one of these per line it wrote, and the row
|
||||
// on the phone is where this is actually reported.
|
||||
tracing::debug!(
|
||||
"transcript line {index} of {} (seq {}) is not one this build can read: {err}",
|
||||
self.path.display(),
|
||||
place.seq,
|
||||
);
|
||||
Ok(SeqEvent {
|
||||
seq: place.seq,
|
||||
ts: place.ts,
|
||||
event: Event::Unreadable {
|
||||
kind: place.kind.unwrap_or_else(|| "no kind".to_string()),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The newest `limit` *rows* ending at line `end`, with each run of
|
||||
@@ -396,6 +448,51 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The failure that took every live session down on 2026-09-06: an event
|
||||
/// kind was removed from the enum after transcripts had already recorded
|
||||
/// it, so every read of those files failed and the sessions in them could
|
||||
/// not be opened, listed, paged or sent to.
|
||||
///
|
||||
/// A transcript is append-only and permanent, so **the set of kinds that
|
||||
/// can appear in one only ever grows** -- what this build writes is not
|
||||
/// what it may have to read. A line it cannot make sense of has to be a
|
||||
/// line, not the end of the file.
|
||||
#[test]
|
||||
fn a_line_of_a_kind_this_build_does_not_know_does_not_break_the_file() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("transcript.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
r#"{"seq":1,"ts":1.0,"type":"status","state":"running"}"#,
|
||||
"\n",
|
||||
r#"{"seq":2,"ts":2.0,"type":"assistantText","delta":"hi"}"#,
|
||||
"\n",
|
||||
r#"{"seq":3,"ts":3.0,"type":"taskNote","about":"t","title":"h","status":"completed"}"#,
|
||||
"\n",
|
||||
r#"{"seq":4,"ts":4.0,"type":"somethingFromTheFuture","whatever":[1,2]}"#,
|
||||
"\n",
|
||||
r#"{"seq":5,"ts":5.0,"type":"status","state":"idle"}"#,
|
||||
"\n",
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let entries = read_after(&path, 0).expect("a strange line is not a broken file");
|
||||
assert_eq!(entries.len(), 5, "every line is still a line: {entries:?}");
|
||||
assert_eq!(entries[4].seq, 5);
|
||||
|
||||
let transcript = Transcript::open(&path).expect("open");
|
||||
assert_eq!(transcript.last_status(), Some(SessionStatus::Idle));
|
||||
// The next seq is counted from the newest *line*, whatever kind it is.
|
||||
// Skipping the ones this build cannot read would hand out a seq the
|
||||
// file already contains.
|
||||
assert_eq!(transcript.next_seq, 6);
|
||||
|
||||
let window = read_window(&path, None, None, 80, false).expect("window");
|
||||
assert_eq!(window.len(), 5, "{window:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assigns_increasing_seqs_and_replays_after_a_cursor() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
Reference in new issue
Block a user