fix(conversations): stop 404ing a session between its first prompt and its transcript - #878
Merged
Merged
Conversation
…d its transcript Sending the first prompt is what flips promptCount to 1, and it is also what makes Claude create <sessionId>.jsonl. The empty-conversation guard keyed on `promptCount === 0`, so it was shut for exactly the interval the file did not exist yet — 0.3s to 1.7s — and GET /api/conversations/:id answered 404 for a session that was working fine. Mobile refetches messages on submit and lands squarely in that window, rendering "Messages failed to load". Measured on 4 sessions, 8 of 203 conversation 404s in a 20.5-day log, every one bracketed by pty.input_write -> 404 -> session.jsonl_wired. The in-flight case is now its own clause: no transcript bound yet, and the session still recently active. Recency is anchored on `lastActivityAt ?? startedAt` rather than `startedAt` alone, because spawn-to-first-prompt is human think time and ran to 405.7s in production. Past the watcher's deadline with nothing on disk it 404s again, so real data loss is still reported as such.
RonenMars
force-pushed
the
fix/conversation-pending-404
branch
from
September 12, 2026 01:50
393f056 to
e06cf45
Compare
Owner
Author
|
🎉 This PR is included in version 1.90.1 🎉 The release is available on: Your semantic-release bot 📦🚀 |
This was referenced Sep 12, 2026
RonenMars
added a commit
that referenced
this pull request
Sep 12, 2026
…rm [skip-ci] The session-lifecycle bullet still described the guard as `promptCount === 0`, which #878 replaced. As written it asserted that any prompted session with no transcript is data loss and 404s — now true only past the watcher's deadline, and the exact reasoning that left the original hole open. Records both disjoint cases, why the second exists, and why recency is anchored on `lastActivityAt ?? startedAt` rather than the spawn.
RonenMars
added a commit
that referenced
this pull request
Sep 12, 2026
…rm [skip-ci] (#880) Follow-up to #878, which changed the behaviour this bullet describes. ## What was stale The session-lifecycle bullet still documented the guard as `promptCount === 0`: > …answers **200 with zero messages** whenever `SessionStore` holds the id and its `promptCount` is 0. The guard stays narrow deliberately: a session that *has* sent prompts and still has no transcript is real data loss and still 404s. Both halves are now wrong in a way that matters. The counter is no longer the whole test, and the closing sentence asserts flatly that a prompted session with no transcript is data loss — true only *past* the watcher's deadline since #878. That sentence is worth more than a routine doc correction, because it is the precise reasoning that left the hole open in the first place: it reads as a deliberate, closed decision, so the next reader has no reason to ask whether "has sent prompts" and "has a transcript" can ever be true at different instants. They can, for 0.3s–1.7s, every single time a session is first prompted. ## What it says now - The two cases that reach the 200 are disjoint and are **not** the same test — `promptCount === 0` qualifies indefinitely (an abandoned session never gets a file); a prompted session qualifies only while the transcript is in flight. - Why the second case exists, with the production evidence: 4 sessions, 8 of 203 conversation 404s in a 20.5-day log, each bracketed by `pty.input_write` → 404 → `session.jsonl_wired`. - Why recency anchors on `lastActivityAt ?? startedAt` and not the spawn — spawn-to-first-prompt ran to 405.7s, so a spawn anchor reopens the hole for anyone slow to type. - Where the guard is still narrow: a bound transcript, or a prompted session quiet past the deadline, still 404s. ## Scope Docs only — one bullet in `CLAUDE.md`, nothing else touched, hence `[skip-ci]`. No version is cut: `docs:` is not a releasing type, so `main` moves once rather than twice.
RonenMars
added a commit
that referenced
this pull request
Sep 12, 2026
…the spawn (#882) Found while reviewing the doc comment on `TRANSCRIPT_WATCH_DEADLINE_MS` after #878. It turned out to describe a design `watchForJsonl` did not implement, and the gap was a real defect rather than a wording slip. ## The defect `watchForJsonl` arms `fs.watch` on the **whole project directory**, not on its one file, and computed `deadline` once as a `const` at spawn: ``` T=0s session A spawns in tb-mobile watcher armed, deadline T=120s T=150s session B writes its own JSONL ← unrelated neighbour, same directory → fires tryWire for A → A's file absent, spawn deadline passed → A's watch CLOSED permanently T=300s A's user finally prompts file created, nobody watching ``` One real project (`-Users-ronenmars-dev-ai-tools-tb-mobile`) holds **406 transcripts**, so a neighbouring write is ordinary, not exotic. The old comment half-saw this — *"the first post-deadline directory event ends the watch either way"* — but read it as harmless while reasoning about where to place the check. **Why it never surfaced as a visible failure:** `sessionFileMap` is never set, so `broadcastConversationLines` and `fileWatcher.watch` never run and the session silently loses live line streaming — while `locateJsonlPath` rung 4 reconstructs `<uuid>.jsonl`, so every REST read keeps working. It degrades in the one place nothing asserts on. ## Why the deadline was wrong, not just the comment The transcript is created by the **turn**, and the turn arrives on human time: **17% of spawns** in a 20.5-day log reached their first prompt later than 120 s (133 s, 276 s, 406 s, 671 s, and one at 4.5 h). Anchoring on the spawn asks about the wrong moment — the same error #878 fixed in `handleGetConversation`. Two independent reasons the pre-turn case must not expire: 1. **Before any prompt the file cannot exist**, so failing to find it is not evidence of anything. 2. **Abandonment was never what the deadline caught.** `ptyManager.hasSession()` already ends either watch when the PTY goes — hold, grace, or the 6 h idle reaper. Past the first turn the deadline is meaningful again (there the file is genuinely overdue) and now re-arms per turn, matching the identical, already-shipped reasoning in `watchForCodexRollout`. ## The change Six lines of behaviour — `deadline` becomes `let`, a `seenPrompts` re-arm, and an early return while `promptCount` is 0. The rest is the rationale comment and docs. ## Tests Both added to the existing `transcript-watch-deadline.test.ts`, and both confirmed to bite: | Test | Fails when | |---|---| | survives a neighbour's post-deadline write, then binds | the fix is reverted (**confirmed red** — the hazard is observed, not inferred) | | does give up once a session that HAS typed goes overdue | `prompts === 0` is widened to "never expire" | The first control needed its event sequence corrected during development: the first callback after a turn *observes* the new `promptCount` and re-arms, so going genuinely overdue takes a second event. Lint 0, `tsc --noEmit` 0, full suite **3083 passed / 11 skipped**. ## Deliberately out of scope Codex still expires a never-prompted session at 120 s. The trade-off differs — Claude's fix costs one idle `fs.watch` handle, while the Codex equivalent means a 250 ms `setInterval` running for up to 6 h — so it deserves its own decision rather than being folded in here. Filing separately as P3.
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.
What was wrong
GET /api/conversations/:idreturned 404 for a live, healthy session — mobile rendered it as "Messages failed to load".Claude creates
<sessionId>.jsonlonly on the user's first turn.Sending that prompt is also what flips
promptCountfrom 0 to 1.The empty-conversation guard keyed on
promptCount === 0, so the one moment a session stopped qualifying for it was the one moment the file did not exist yet.Production, session
fe2e9043:Not a one-off: 4 sessions, 8 of 203 conversation 404s in a 20.5-day log, every one bracketed by
pty.input_write→ 404 →session.jsonl_wired, with windows of 363 ms to 1657 ms.Mobile refetches messages on submit, so it lands in the window every time.
The fix
promptCountwas never the real question — "is the transcript bound yet" is, andfindLiveSessionFilePathalready answers it.The in-flight case becomes its own clause, leaving the existing
promptCount === 0branch untouched so an opened-and-abandoned session still renders as empty rather than as an error.Two things keep the guard narrow:
!bound— a wired transcript means the file exists, so failing to resolve the conversation is a real miss, not a session waiting to speak.TRANSCRIPT_WATCH_DEADLINE_MS— past it with nothing on disk, that is data loss and still 404s.Recency is anchored on
lastActivityAt ?? startedAt, the same readingsession-store.ts:207takes, not onstartedAtalone.Spawn-to-first-prompt is human think time and ran to 405.7s in production, so anchoring on the spawn would have left the hole wide open for anyone slow to type.
Wire impact
Additive in effect: a 404 becomes a 200 carrying the
emptyConversationPayloadshape mobile already handles for thepromptCount === 0case.No field, status vocabulary or event changed, so no mobile check was required.
Tests
Three added to
conversation-404-semantics.test.ts, and the pre-existing negative control renamed to say what now carries it (staleness, not the prompt count).Every clause has a test that goes red without it:
startedAt!boundclause is droppedThe first two were confirmed red against the unfixed source and green after; the third was confirmed red with
!boundremoved.Lint 0,
tsc --noEmit0, full suite 3081 passed / 11 skipped.