Skip to content

fix(conversations): stop 404ing a session between its first prompt and its transcript - #878

Merged
RonenMars merged 1 commit into
mainfrom
fix/conversation-pending-404
Sep 12, 2026
Merged

fix(conversations): stop 404ing a session between its first prompt and its transcript#878
RonenMars merged 1 commit into
mainfrom
fix/conversation-pending-404

Conversation

@RonenMars

Copy link
Copy Markdown
Owner

What was wrong

GET /api/conversations/:id returned 404 for a live, healthy session — mobile rendered it as "Messages failed to load".

Claude creates <sessionId>.jsonl only on the user's first turn.
Sending that prompt is also what flips promptCount from 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:

01:35:03.675  GET /api/conversations/fe2e9043… → 200   (promptCount=0, guard open)
01:35:35.130  pty.input_write  promptCount=1          ← prompt sent
01:35:35.396  GET /api/conversations/fe2e9043… → 404  ← the error the user saw
01:35:35.418  GET /api/conversations/fe2e9043… → 404
01:35:35.546  session.jsonl_wired                     ← transcript lands, 150ms too late

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

promptCount was never the real question — "is the transcript bound yet" is, and findLiveSessionFilePath already answers it.
The in-flight case becomes its own clause, leaving the existing promptCount === 0 branch 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.
  • the watcher's own 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 reading session-store.ts:207 takes, not on startedAt alone.
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 emptyConversationPayload shape mobile already handles for the promptCount === 0 case.
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:

Test Fails when
200 in the prompt→JSONL window the fix is reverted
covers a user slow to prompt recency anchors on startedAt
404s when the transcript IS bound the !bound clause is dropped

The first two were confirmed red against the unfixed source and green after; the third was confirmed red with !bound removed.

Lint 0, tsc --noEmit 0, full suite 3081 passed / 11 skipped.

…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
RonenMars force-pushed the fix/conversation-pending-404 branch from 393f056 to e06cf45 Compare September 12, 2026 01:50
@RonenMars
RonenMars merged commit d7b7175 into main Sep 12, 2026
10 checks passed
@RonenMars
RonenMars deleted the fix/conversation-pending-404 branch September 12, 2026 02:02
@RonenMars

Copy link
Copy Markdown
Owner Author

🎉 This PR is included in version 1.90.1 🎉

The release is available on:

Your semantic-release bot 📦🚀

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant