Skip to content

fix(server): the turn after a delete lands in the chat it was sent to (#730) - #742

Merged
edspencer merged 1 commit into
mainfrom
fix/730-resume-after-delete
Aug 7, 2026
Merged

fix(server): the turn after a delete lands in the chat it was sent to (#730)#742
edspencer merged 1 commit into
mainfrom
fix/730-resume-after-delete

Conversation

@edspencer

Copy link
Copy Markdown
Owner

Fixes #730.

The actual root cause — not the one in the issue

The issue's original guess (deleteSession clears the agent's current-session slot) was already retracted in a correction comment, and the correction's lead — the directory-wide discovery-cache invalidation — turns out to be a red herring too. Here is what is really happening, confirmed by reproducing it at the integration tier and reading herdctl's own log line:

Session for keeper-<slug> is invalid: CLI session file not found for session B. Clearing stale session.
No valid session for keeper-<slug> (expired or not found), starting fresh

Note B — the deleted chat — on a turn that explicitly asked to resume A.

herdctl keeps one agent-level session pointer per agent at <stateDir>/sessions/<agent>.json, rewritten after every batch turn, so it always names that project's most-recently-active chat. It exists for exactly one purpose: resume that session when a caller asks for a turn without naming one. Paddock's chat sends always name one, so Paddock never reads it.

It still reaches us, through how JobExecutor reacts to it going stale (dist/runner/job-executor.js:131-289):

const hadAgentSession = (await getSessionInfo(sessionsDir, agent.qualifiedName)) !== null;
const existingSession  = await getSessionInfo(sessionsDir, agent.qualifiedName, { timeout, runtime, ... });
// …
let adopt = !hadAgentSession && (currentRuntimeType !== "cli" || dockerEnabled);

Deleting B leaves the pointer naming a file that no longer exists. On the next turn the timeout-aware read judges it file_not_found, clears it, and returns null (dist/state/session.js:92-106). So existingSession is null while hadAgentSession is true, and the adoption branch — the one that would have honored the caller's explicit resume: A — is gated shut. effectiveResume stays undefined and the turn starts fresh.

The gate asks "did this agent have a pointer?" when the question is "was the thing that just got cleared the session the caller asked for?" Those coincide only when the pointer named the caller's chat.

Every rule the reproduction established falls out of this, which is what convinced me it is the whole mechanism and not a partial one:

Observed Why
only the most-recently-active chat's deletion breaks anything the pointer is rewritten every turn, so it only ever names that one
one-shot — the second send works the fresh turn rewrites the pointer, and existingSession.session_id !== options.resume then correctly trusts the caller
archive does not trigger it it removes nothing, so nothing dangles
promote does it deletes the source transcript by the same call
scoped per project one pointer per keeper agent
"it worked after a restart" (the note at herdctl.ts:1525) a restart is just a run where no pointer exists yet, so adopt is free

Where the fix belongs

Upstream. Filed as edspencer/herdctl#448 with the one-line repair: capture the pointer's id, not just its existence, so hadAgentSession means "the pointer named the session the caller asked for". Any consumer whose transcripts vanish by some other route — a manual rm, a retention sweep, a crashed write — hits the same misclassification, and Paddock cannot fix that from here.

What this PR does instead

The thin, well-understood guard the brief allows for, and it is narrower than the upstream fix while being true regardless of it: when Paddock removes a transcript, it drops the agent-level pointer if that pointer names it.

That file is already dangling by the time we reach it, and herdctl would clear it at the very next turn anyway — clearing it at the moment we make it dangle is semantically a no-op that simply moves the clear out of the code path that misreads it. The next turn then sees a clean "this agent owns no session" and adopts the caller's explicit resume, which is the same path a process restart took. A pointer naming a different chat is live state and is left alone; the whole thing is best-effort, so failing to tidy can never fail the delete the user asked for.

Called from all four places Paddock removes a transcript from an agent's working directory: the own delete path, promoteSession, and the two import-undo paths that delete a copy an import placed. Under claude.transcripts: host nothing is removed, so there is nothing to drop.

Scope

driveMode: batch only. The SDK session path (driveMode: session, the default) never consults the pointer when the caller names a session, so it is structurally immune — worth stating plainly, since the issue does not record which mode the reproducing instance ran. If the report came from a session-mode instance then there is a second mechanism still at large, and I would want to know that; everything in the report is reproduced exactly by this one.

Tests

packages/server/test/integration/resume-after-delete.test.ts — server-integration tier (startTestApp + real FleetManager + real CLI runtime + the fake claude), per §5.3 item 0a. No browser: the whole bug is server-side session resolution.

  • a turn sent to A after deleting B lands in A, with A's history behind it (the fake keeper still recalls the codeword), and no stray chat appears
  • same after promoting B away
  • a control: deleting an older chat, and archiving, change nothing

The first two fail on main (expected '907e4670-…' to be '28e6602a-…') and the control passes there — so the control is pinning the boundary, not just passing vacuously. There was previously no test anywhere that sent a turn after a delete; delete-chat-host-transcripts.test.ts covers the deletion and stops.

npm test (2,901 tests) and npm run typecheck green.

🤖 Generated with Claude Code

…#730)

Deleting a chat left herdctl's agent-level session pointer naming a
transcript that was no longer on disk. On the next turn the JobExecutor
found it dangling, cleared it, and then refused the caller's EXPLICIT
resume — reading "a pointer existed a moment ago" as "this agent's
session just expired, start fresh". The pointer named the deleted chat;
the caller asked for a different one, whose transcript was right there.

So the user's next message was silently misfiled into a brand-new
session while the UI kept showing the chat they were in, and the keeper
answered with no memory of it. It cost exactly one turn.

Paddock now clears the pointer itself at the moment it deletes or
promotes the transcript that pointer names — a file herdctl would have
cleared at the next turn anyway, so the next turn instead sees a clean
"this agent owns no session" and adopts the explicit resume (the same
path a process restart took, and why this always worked after a
restart). Another chat's pointer is left alone.

The misreading itself is upstream: edspencer/herdctl#448.

Pinned at the integration tier: a turn after a delete, and after a
promote, must land in the chat it was addressed to. Both fail on main.

Co-Authored-By: Claude <noreply@anthropic.com>
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying paddock with  Cloudflare Pages  Cloudflare Pages

Latest commit: 7e7e927
Status: ✅  Deploy successful!
Preview URL: https://53abc5ec.paddock-7u2.pages.dev
Branch Preview URL: https://fix-730-resume-after-delete.paddock-7u2.pages.dev

View logs

@edspencer

Copy link
Copy Markdown
Owner Author

Answering your scope question directly: the reproduction ran on batch.

I'm the author of the report, so I can settle this rather than leave it hanging.

The QA instance was a pm-run wrapper that explicitly exported PADDOCK_DRIVE_MODE=batch in order to reach the fake claude (the CLI runtime is the only one that spawns from PATH). I verified it at the time in /proc/<pid>/environ, and the audit write-up records it in its method section. Every reproduction in #730 — all 8+ across 5 probes, including the browser one — was on batch.

So your caveat is exactly right, and I'd go further: there is no evidence of a second mechanism, because there is no session-mode observation at all. Your analysis explains 100% of what I saw, and I'd treat "session mode is structurally immune" as unrefuted rather than as something my report contradicts.

What that means for severity, which I think is worth stating on the ticket

session is the default drive mode. If the pointer is genuinely never consulted when the caller names a session, then default-configuration users were never exposed to this, and the real-world blast radius is projects explicitly set to driveMode: batch (plus the sweeper/trigger paths, which are always CLI). That is materially narrower than "critical data loss for everyone", which is how I pitched it — and the correction is yours, not mine.

The fix is still worth having: batch is a supported, documented per-project mode, and the upstream misclassification you filed as edspencer/herdctl#448 bites any consumer whose transcript vanishes by any route (manual rm, retention sweep, crashed write). But I'd rather the ticket carry an accurate scope than an inflated one.

The uncomfortable general point

This is the third time in this workstream that a finding has turned out to be shaped by the harness rather than by the product: the sub-agent bug (#725) only reproduces in a state the fake couldn't previously produce; the mid-turn fork/revert transcripts are invalid in a way only the real Messages API would reject; and now #730's mechanism is batch-only while the default is session. All three are the batch-vs-session blindness #717 was filed about. I'll carry that caveat explicitly into the remaining fix waves rather than re-learn it each time.

Suggested follow-up, not for this PR: a deliberate check of whether the delete→resume sequence is truly clean on session mode with a real credential. If it is, we can say so on the issue and close the question; if it isn't, there's a second mechanism and we'd want to know. That's precisely a test:qa:live item under #717 rather than something this PR should carry.

Nothing blocking here — the root-cause analysis is more complete than what I filed, the upstream/downstream split is the right call, and the control test pinning the boundary is the detail that convinced me this is the whole mechanism. Thank you for not building on my wrong guess.

@edspencer
edspencer merged commit 0182c5c into main Aug 7, 2026
5 checks passed
@edspencer
edspencer deleted the fix/730-resume-after-delete branch August 7, 2026 18:08
@github-actions github-actions Bot mentioned this pull request Aug 7, 2026
edspencer added a commit that referenced this pull request Aug 8, 2026
… releases (#762)

* chore(docs): start the 0.56-0.66 What's New pass

* docs(website): split What's New, archiving 0.52 and older

The page had grown to 1,150 lines and 30 releases, which is past the
point where anyone scrolls it. Everything from 0.52 back to 0.29 moves
verbatim to a new 'What's New — earlier releases' page; the main page
keeps 0.53 and newer.

Entries are moved unchanged — the archive is append-only and its
entries are never rewritten, which is the same promise the page already
makes about describing each release as it shipped. Image paths are
unchanged because both pages sit in the same content directory.

Adds the sidebar entry (Starlight does not auto-discover, so an
unlisted page is invisible), a cross-link in both directions, and a
note in the maintainer footer describing how to trim next time.

* docs(website): What's New for 0.63 through 0.66.1

Five new entries. 0.66.0 leads on the Config screen redesign (#740) —
the largest UX change in the range and the one a user meets every time —
with a screenshot of the rail, live filter, env-override legend and the
dirty dot. Also carries the breaking default-port move 4000 -> 7233
(#741) with the operator action spelled out, schemaVersion (#735), the
import -> adopt rename (#748), the destructive-op interlock (#743) and
the sub-agent bar fixes (#750).

0.66.1 is the queued-message release (#751): three silent-loss paths and
the Stop-hands-it-back decision. 0.65 is promote_project (#668). 0.64
leads on path:/managed replacing repoBacked (#709) plus the Changes tab
finally reporting on the checkout (#597). 0.63 is host plugin
inheritance and MCP fidelity (#705), including the batch-mode argv
credential disclosure (#702).

Two claims deliberately qualified against the changelog's framing: the
delete-then-send fix (#742) is called out as batch-only, since the
default session mode was never affected; and the sub-agent bar is not
described as absolute, because the settle heuristic can hold an
interrupted sub-agent for ten minutes.

Also corrects a live error: the 0.59.1-0.60 entry claimed in bold that
--here leaves your ~/.claude alone. That was false for exactly those
versions — --here linked ~/.claude/projects/<dir> at the workspace, and
one report lost 30 transcripts to it before 0.61.1 stopped it. Replaced
with a caution box pointing forward.

* docs(website): a recorded demo of the 0.66 Config screen

The filter and the rail only read in motion, so the entry gets both a
still (rail counts, env chips, legend, dirty dot) and a 22s clip:
filter by env-var name, Modified-only lens, then a rail jump that
scrolls rather than swapping tabs.

Shot on a v0.66.1 rig with no credentials. Two leaks were caught by
looking at the frames rather than trusting the launcher:
PADDOCK_GIT_AUTHOR_EMAIL was inherited and put a real internal address
in the Git identity section, and the Advanced section printed the rig's
scratch paths. The launcher is now an ALLOW-list — it drops every
inherited PADDOCK_* and sets only what it needs — which is the fix
issue #567 argues for, and the frames now show stock defaults and a
~/.paddock install path.

* docs(website): backfill 0.56, 0.57, 0.58 and 0.61.0

These four were never written up — a gap in the middle of the page, not
a tail. 0.61.0 in particular was only reachable by reading the 0.62
entry backwards, where it appears as the thing being removed.

0.61.0 leads on Paddock taking ownership of its Claude home, with a
caution box narrating the whiplash: 0.61.0 isolates the home, 0.61.1
un-isolates the CLI because an isolated home cannot see a macOS
Keychain login, and 0.62 replaces the mechanism entirely. Without that
sentence the three entries read as contradicting each other.

0.58 carries its own caution: everything in it shipped in a CLI that,
installed through npm, printed nothing and exited zero, and stayed that
way across 0.57 and 0.59.0 until 0.59.1. An entry recommending it
without that note would be recommending a broken install. The cause is
described only as the run-directly guard, not attributed to a specific
PR, because the changelog's own attribution does not line up with the
release it shipped in.

0.57 is the environment system prompt, with the audit numbers that
justified it and a note that three candidate rules were measured and
cut. 0.56 is the npm publish, plus the correction that the claude CLI
was never a prerequisite for chats.

Also corrects 'several hundred boot log lines' in the 0.59.1-0.60 entry
to match the changelog's actual figure (about thirty, down to nine).

* docs(website): cut the new What's New entries roughly in half

The nine entries added this pass ran to 314 lines. They read as
changelog archaeology rather than release notes — explaining how each
bug worked before saying what changed, and carrying detail nobody
scanning a What's New page needs.

Now 180 lines, of which 12 are the image and video markup. Each bullet
is one idea in three or four lines: what a user notices first, then only
the context that makes it land. What survives unchanged is the material
that is genuinely load-bearing — the port change's operator action, the
'tabs partition' argument, the batch-only qualifier on the delete-then-
send fix, and the two caution boxes, since those exist to stop a reader
believing something false.

* docs(website): plainer titles, and another pass on length

Titles were making claims rather than saying what changed — 'Nothing you
typed while it was busy goes missing', 'A Config screen you can
navigate', 'A command-line worth running'. They now name the subject:
Queued messages; Config screen, and a new default port; CLI output and
flags; Environment system prompt; npm package.

Prose down from 168 lines to 137 (originally 302). Mostly by cutting
restatement — 0.61.0's four small turn-level fixes were four bullets
saying the same shape of thing and are now one.

* docs(website): bring the older entries into the same style

Retitles every remaining entry to name its subject rather than make a
claim, and cuts them to the same length as the new ones. 0.62's title
becomes 'Granular host Claude inheritance options' as requested.

  Five levers instead of one            -> Granular host Claude inheritance options
  Your ~/.claude, left alone and ...    -> CLI login, and symlinks into your Claude home
  One command, on your own history      -> npx install, --here, and confirmed adoption
  Bring your terminal history with you  -> Adopting Claude Code CLI chats
  Claude, not "the keeper"              -> The "keeper" rename, and Home's attention feeds
  Home says what it's holding           -> Unread badge on the Home link

Lengths: 0.62 115->51, 0.61.1 57->36, 0.59.1-0.60 45->29, 0.55 81->29,
0.54 70->42, 0.53 62->14. The page is 379 lines, down from 1,150.

Everything load-bearing is kept: the breaking env-var table, the
instructions: own warning, the leftover-symlink check with its shell
command, and both caution boxes. All five media assets verified present
in the built output. Frontmatter description rewritten — it still
described 0.62 as 'five independent levers'.

---------

Co-authored-by: HomeLab Agent <homelab-infra@valfenda.net>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DATA LOSS: after deleting a chat, the next message to another chat is silently misfiled into a brand-new session

1 participant