fix(chat): follow transcript rotation on /clear so the chat keeps working (#34) #42

Merged
dominik.polakovics merged 2 commits from afk/34 into main 2026-07-08 09:24:42 +02:00

Summary

Fixes issue #34: using /clear from the chat composer broke the session — the chat kept reading the pre-clear transcript and replies produced no visible response, and the only recovery was to stop and restart the instance.

Claude Code's /clear (and /rewind) rotates the sessionId → a new transcript file, freezing the old one on disk. lab pinned runs.transcript_path write-once to the old file. This change makes lab follow the rotation by its observable effect (the located transcript changing), so lab core never parses any agent's clear command — which also handles a clear run out-of-band (in claude.ai, or /rewind) for free.

Changes

  • internal/chat — an ACTIVE run re-asks LocateTranscript each read/tick and adopts a different, non-empty path (locateActive), re-pointing runs.transcript_path (single value — re-point & forget). Ended runs never re-locate (successor-safety); an empty locate never clobbers a set path; the persist is gated on a fresh liveness re-check so a since-ended run's tailer can't adopt a successor's transcript in a reused worktree. The tailer re-resolves each tick against its own cached path and resets its file-change bookkeeping on a rotation.
  • transcript_id — an opaque hash-of-path token in the messages response (provider-neutral in the envelope, provider-derived in value); the SPA keys a stream reset on it, since the fresh transcript restarts seq at 1 and would otherwise collide with the accumulated messages in the seq-keyed merge.
  • claudecode.PendingDialog — a dialog spool older than the current transcript is treated as stale (OR'd with the tool-id check), so a pre-clear spool can't keep the composer locked against the new session. Safe for a genuine pending dialog: the transcript is byte-frozen then (compat §5) and the spool is written after it, so the spool is always newer.
  • web/RunChat — resets the accumulated stream + composer on a transcript_id change; treats the "" locating/gone sentinel as "no rotation".
  • compat.md §5/§9 — documents the new coupling.

/compact is unaffected (same sessionId, same file). Reply/interrupt is unchanged (the tmux session name is stable across /clear). No budget/claim/three-strikes effects; no new provider capability (issue #7 decision 12 / ADR-0016).

Tests

  • internal/chat: re-locate-on-rotation, empty-locate-never-clobbers, ended-run-ignores-rotation, locateActive never persists a successor onto an ended run, and the tailer follows a rotation + republishes.
  • internal/httpapi: transcript_id is present and changes across a rotation.
  • internal/provider/claudecode: a spool goes stale after a rotation; a byte-frozen (older) transcript still shows the dialog.
  • web: RunChat resets the stream + unlocks the composer on a transcript_id change, and the locating→available first-locate does not spuriously reset.

Verification

Full Go suite (untagged + ui-tagged) green; frontend eslint + prettier + 380 vitest tests + tsc + vite build green. An adversarial multi-agent review of the diff surfaced 4 issues — a successor-adoption regression in the tailer, a ""-vs-undefined guard gap in the SPA rotation effect, and a filesystem-timestamp-granularity flake in a pre-existing spool test — all fixed and re-verified.

Closes #34

🤖 Generated with Claude Code

## Summary Fixes issue #34: using `/clear` from the chat composer broke the session — the chat kept reading the pre-clear transcript and replies produced no visible response, and the only recovery was to stop and restart the instance. Claude Code's `/clear` (and `/rewind`) rotates the sessionId → a new transcript file, freezing the old one on disk. lab pinned `runs.transcript_path` write-once to the old file. This change makes lab **follow the rotation by its observable effect** (the located transcript changing), so lab core never parses any agent's clear command — which also handles a clear run out-of-band (in claude.ai, or `/rewind`) for free. ## Changes - **`internal/chat`** — an ACTIVE run re-asks `LocateTranscript` each read/tick and adopts a different, non-empty path (`locateActive`), re-pointing `runs.transcript_path` (single value — re-point & forget). Ended runs never re-locate (successor-safety); an empty locate never clobbers a set path; the persist is gated on a fresh liveness re-check so a since-ended run's tailer can't adopt a successor's transcript in a reused worktree. The tailer re-resolves each tick against its own cached path and resets its file-change bookkeeping on a rotation. - **`transcript_id`** — an opaque hash-of-path token in the messages response (provider-neutral in the envelope, provider-derived in value); the SPA keys a stream reset on it, since the fresh transcript restarts `seq` at 1 and would otherwise collide with the accumulated messages in the seq-keyed merge. - **`claudecode.PendingDialog`** — a dialog spool older than the current transcript is treated as stale (OR'd with the tool-id check), so a pre-clear spool can't keep the composer locked against the new session. Safe for a genuine pending dialog: the transcript is byte-frozen then (compat §5) and the spool is written after it, so the spool is always newer. - **`web/RunChat`** — resets the accumulated stream + composer on a `transcript_id` change; treats the `""` locating/gone sentinel as "no rotation". - **`compat.md §5/§9`** — documents the new coupling. `/compact` is unaffected (same sessionId, same file). Reply/interrupt is unchanged (the tmux session name is stable across `/clear`). No budget/claim/three-strikes effects; no new provider capability (issue #7 decision 12 / ADR-0016). ## Tests - `internal/chat`: re-locate-on-rotation, empty-locate-never-clobbers, ended-run-ignores-rotation, `locateActive` never persists a successor onto an ended run, and the tailer follows a rotation + republishes. - `internal/httpapi`: `transcript_id` is present and changes across a rotation. - `internal/provider/claudecode`: a spool goes stale after a rotation; a byte-frozen (older) transcript still shows the dialog. - `web`: `RunChat` resets the stream + unlocks the composer on a `transcript_id` change, and the locating→available first-locate does not spuriously reset. ## Verification Full Go suite (untagged + `ui`-tagged) green; frontend eslint + prettier + 380 vitest tests + `tsc` + `vite build` green. An adversarial multi-agent review of the diff surfaced 4 issues — a successor-adoption regression in the tailer, a `""`-vs-`undefined` guard gap in the SPA rotation effect, and a filesystem-timestamp-granularity flake in a pre-existing spool test — all fixed and re-verified. Closes #34 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(chat): follow transcript rotation on /clear so the chat keeps working (#34)
All checks were successful
ci / native (pull_request) Successful in 6m43s
b0e3946923
Claude Code's /clear (and /rewind) starts a new sessionId -> a new transcript
file, freezing the old one on disk. lab pinned runs.transcript_path write-once
to the old file, so after /clear the chat stopped updating and the composer
could stay locked; the only recovery was to stop and restart the instance.
Follow the rotation by its observable effect -- never by parsing any agent's
clear command:

- internal/chat: for an ACTIVE run, re-ask LocateTranscript each read/tick and
  adopt a different, non-empty path (locateActive), re-pointing
  runs.transcript_path (single value -- re-point & forget). Ended runs never
  re-locate (successor-safety); an empty locate never clobbers a set path; the
  persist is gated on a fresh liveness re-check so a since-ended run's tailer
  cannot adopt a successor's transcript in a reused worktree. The tailer
  re-resolves each tick against its own cached path and resets its file-change
  bookkeeping on a rotation so the fresh (cleared) transcript is re-read and
  republished.
- Surface an opaque transcript_id (a hash of the resolved path) in the messages
  response; the SPA keys a stream reset on it, since the fresh transcript
  restarts seq at 1 and would otherwise collide with the accumulated messages in
  the seq-keyed merge.
- claudecode: PendingDialog treats a dialog spool older than the current
  transcript as stale (OR'd with the tool-id check), so a pre-clear spool cannot
  keep the composer locked against the new session. Safe for a genuine pending
  dialog -- the transcript is byte-frozen then and the spool is written after it.
- web/RunChat: reset the accumulated stream + composer on a transcript_id
  change; treat the "" locating/gone sentinel as "no rotation".
- Document the coupling in compat.md §5/§9.

/compact is unaffected (same sessionId, same file). Reply/interrupt is unchanged
(tmux session name is stable across /clear). No budget/claim/three-strikes
effects; no new provider capability (issue #7 decision 12 / ADR-0016).

Closes #34

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012bAFELumrq1A8WJSZ9v1rG
The transcript-rotation reset (issue #34) runs synchronously inside
setTranscriptId — Solid flushes user effects on a signal write — so it
resets the accumulated stream and starts a superseding refetch mid-function.
The outer refetch then ran past its only fetchToken check and merged its
pre-rotation tail/latest back over the reset: a transcript-A tail line whose
seq exceeds the fresh transcript B's max seq survived the seq-keyed merge and
persisted (until B's seq caught up, a route change, or another rotation).

Re-check fetchToken right after the transcript_id write so a rotation-started
refetch owns the stream and the stale outer one bails. Adds a regression test
that forces the straddle (the after= tail GET sees A and returns a new
high-seq line; the latest GET sees rotated B) and asserts only B's fresh
content survives — it fails without the guard.

Refs #34

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WNFHeDbVuqGkQqBXnVoXNK
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/coding-lab!42
No description provided.