Chat: /clear breaks the session — lab keeps reading the pre-clear transcript file #34
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Cloonar/coding-lab#34
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Using
/clearfrom the embedded chat composer (/runs/:id) breaks thesession: the chat does not clear, and replies sent afterwards produce no
visible response. The only recovery is to stop the instance and start a new
one.
Root cause: lab has no "clear" feature —
/clearis forwarded verbatim intothe Claude session as a normal reply. Claude Code's
/clearstarts a newsession (new
sessionId, new transcript file, old file frozen on disk), butlab pins each run to a write-once
runs.transcript_pathderived from theold sessionId and never re-validates it. lab keeps reading the dead
transcript forever.
Reproduction
/runs/:id./clearinto the composer and send.yield no visible response; the composer can get stuck (wrong Send/Interrupt
state). Stopping the instance and starting a new one is the only recovery.
Root cause (code)
The transcript path is resolved once, persisted to
runs.transcript_path, andthen returned unconditionally for the life of the run:
internal/chat/chat.go:266-282—resolvePathreturns the persisted path ifset; it only calls
LocateTranscriptwhen the path is empty. Neverre-validated after the first hit.
internal/chat/tailer.go:145-147— the tailer cachespathin a goroutinelocal and re-resolves only
if path == "". It then stats the frozen fileforever →
ModTime/Sizenever change → norun.messages.changedeverfires again (
tailer.go:159-172).internal/provider/claudecode/chat.go:53-63(LocateTranscript) /:69-95(sessionIDForDir) — the path is<projects>/<slug(worktree)>/<sessionId>.jsonl, built from the currentregistry sessionId in
~/.claude/sessions/<pid>.json. Correct at firstlocate, but the function is never called again.
internal/store/runs.go:204-212(
UpdateRunTranscriptPath); field atruns.go:51. There is no code anywherethat invalidates or re-locates it.
Evidence (verified)
/clearcallssetConversationId(randomUUID())→ a brand-new
<newSessionId>.jsonl; the old file is left intact/resumable./compact, by contrast, keeps the same sessionId and appends to the samefile — so
/compactis not affected by this bug. Refs:anthropics/claude-code#37451, anthropics/claude-code#3046.
transcripts for one cwd — an older frozen file and a newer active file with a
different sessionId, whose line 4 was the
/clearcommand marker and whichcarried zero back-reference to the old sessionId (a clean rotation, not a
resume).
~/.claude/sessions/<pid>.json'ssessionIdfield equals the currentlyactive transcript filename in that worktree's project dir. So re-running
LocateTranscriptfor a live process resolves to the current session —which is what makes the fix below work.
Symptom → cause (1:1)
tailer sees no change, so no refresh event fires.
addressed by session name, stable across
/clear—internal/provider/claudecode/interact.go:44), and Claude answers into thenew file; lab reads the old file, so the message and its reply are
invisible. Composer state is derived from the frozen file, so the Send /
Interrupt / locked control can also be wrong.
transcript_path, soresolvePathre-locates and picks up the current session. The only recoverytoday.
Fix (chosen approach: make
/clearwork)Backend — re-locate on rotation (
internal/chat)resolvePath, for an active run, re-validate the persisted pathagainst the live session on each read/tick. If
LocateTranscriptreturns adifferent, non-empty path, adopt it:
UpdateRunTranscriptPathand returnthe new path. Rules:
grabbing a successor run's transcript — see the comment at
chat.go:259-266).registry gap / between-states moment).
tailer.go, stop cachingpathwrite-once — re-resolve each tick (or whena lightweight sessionId check changes). On a path change, reset
lastMod/lastSz/lastChatand forcepublishMessagesChangedso the UIrefetches the fresh (cleared) transcript.
(
messagesResponse,internal/httpapi/chat.go:39-49) — e.g.transcript_id= the resolved sessionId (or a stable hash of the path). This is what the
frontend watches to reset.
This is general: it also covers rotation triggered by
/clearrun directly inclaude.ai, or by
/rewind.Frontend — clear the stream on rotation (
web/src/routes/RunChat.tsx)transcript_idfrom the messages response into a signal.params.id,RunChat.tsx:188-203) on(params.id, transcriptId). On change, resetmessages/state/pendingDialogField/hasMore/exhausted/openGroups(same as the route-change reset), then refetch.
seqat 1, so without a resetthe new messages collide with stale ones in the seq-keyed merge
(
web/src/lib/chatStream.ts). It also makes/clearvisibly clear the chatand re-enables the composer.
The reply/answer/interrupt send path needs no change (already addressed by
session name).
Acceptance criteria
/clearin the composer: the visible chat clears within ~1 poll tick,the composer returns to a usable Send state, and a subsequent reply appears
with Claude's response.
(read-only), and transcript-gone.
/compactstill shows continuous history (same file; unaffected).Tests
internal/chat: withprovidertest.Fake.SetTranscriptPath(
internal/provider/providertest/fake.go:402), simulate rotation mid-run(path A → path B with different content). Assert
Readfollows to B, persistsB via
UpdateRunTranscriptPath, and the tailer publishesrun.messages.changed. Assert an ended run does NOT re-locate. Assert anempty locate does not clobber an already-set path.
internal/httpapi: messages response includestranscript_id, and it changesacross a rotation.
web:RunChatresets the accumulated stream whentranscript_idchanges(precedent:
web/src/routes/RunChat.test.tsx).Verify live during implementation
Confirm
~/.claude/sessions/<pid>.json'ssessionIdupdates promptly after aninteractive
/clear(the on-host artifact + live invariant both indicate yes;a ~2-minute manual check with real claude 2.1.198). If it lags, fall back to
"newest actively-growing
.jsonlin the cwd-slug project dir" as the locatestrategy.
Docs
/clear(and/rewind)rotate the sessionId → transcript file, and lab must follow. Document it in
internal/compat/compat.md§5 (transcript location).budget/claim/three-strikes effects.
Design decisions (reviewed with maintainer, 2026-07-08) — refines the Fix section above
Where this comment differs from the original Fix section, this comment wins.
Scope — tolerance only; NO Clear affordance
lab does not grow a "Clear" button or a
ClearChatprovider capability. Therequirement is solely that lab behaves correctly when the operator commands
the agent to clear — e.g. types
/clear, which stays an ordinary free-textpassthrough reply. The "interface" here is the provider seam, not UI.
Detect by effect, not by cause
lab must never parse
/clearor know any agent's clear command. Each tick itre-asks the provider for the current transcript of the live session
(
LocateTranscript, already on the seam) and follows it when the answerchanges. All agent-specificity stays inside
LocateTranscript; lab corestays ignorant of clear commands. Bonus: keying off the observable effect also
handles a clear run out-of-band (inside claude.ai, or
/rewind) for free.View semantics — fresh-only, re-point & forget
runs.transcript_pathstays a single value, re-pointed to the newtranscript. Pre-clear messages vanish from the lab view (the old file remains on
disk,
claude --resume-able). No transcript-history chaining (YAGNI): thetranscript is display-only, so re-pointing has no budget / claim / three-strikes
/ CR-done-signal consequence.
NEW — dismiss a stale pending-dialog spool on rotation
(Not in the original body; must be included.)
PendingDialog(
internal/provider/claudecode/dialogspool.go:193) suppresses a spooled dialogonly when its
tool_use_idappears in the transcript. So after a re-point toa fresh file, a pre-clear pending-dialog spool (keyed by the stable run ID)
does not self-heal — the composer stays locked and sends stay blocked for
this sub-case. Fix: give
PendingDialogthe same mtime staleness checkBlockedStatealready uses (dialogspool.go:208-227), OR'd with the existingtool-id check (tool-id stays primary): a spool older than the current transcript
is stale. Safe for the normal pending case — compat §5 keeps the transcript
byte-frozen during a genuine pending dialog while the spool is written after
it, so the spool is always newer and the dialog still shows.
Frontend visible clear — explicit
transcript_id, no inferenceThe re-point keeps the same run id, so the SPA's route-change reset
(
web/src/routes/RunChat.tsx:188) won't fire, and the new transcript restartsseqat 1 (collides with the stale accumulated messages in the seq-keyed merge,web/src/lib/chatStream.ts). So:transcript_idin the messages response — the locatedtranscript's identity (for Claude, the
sessionId/filename). Provider-neutralin the envelope, provider-derived in value; the same value the backend
compares to detect the rotation.
(runId, transcript_id): on change itclears the stream/state exactly like a route change, then refetches. That is
what makes the chat visibly empty out and the composer come back to life.
Unchanged from the body
transcript_pathwith an empty locate (transientregistry gap / between-states).
pathwrite-once; re-resolves each tick, resets itscached mtime/size on a change, and forces
run.messages.changed./compactis unaffected (same sessionId, same file).name, stable across a clear).
~/.claude/sessions/<pid>.json'ssessionIdupdates on/clear(the linchpin the re-locate relies on).internal/compat/compat.md§5:/clear(and/rewind) rotate the sessionId → transcript file; lab follows by effect.Acceptance additions
reply is delivered and visible.
transcript_idchanges and the SPA stream resets to the freshconversation within ~1 poll tick.