﻿# Expired-conversation cleanup deletes a conversation whose turn is still running

- **Status**: Done
- **Kind**: Bug
- **Authors**: jp
- **Date**: 2026-10-01
- **Label**: domain=plugins
- **Label**: domain=storage
- **Label**: package=jp_storage
- **Label**: package=jp_workspace

`Workspace::remove_ephemeral_conversations`
(`crates/jp_workspace/src/lib.rs:498`) removes every conversation whose
`expires_at` has passed, except the ids it is told to skip.
Its only caller (`crates/jp_cli/src/lib.rs:794-795`) skips the conversations
some session has active, from `all_active_conversation_ids`.
Nothing checks whether the conversation is locked: `PersistBackend::remove` goes
to `Storage::remove_conversation` (`crates/jp_storage/src/lib.rs:420`), which
deletes the directories outright.

`jp query --tmp` is not exposed, because its conversation is activated in the
session before the turn runs.
A conversation created by a plugin's `query` with `new` and `expires_in`
(T-0vmm72z) is never session-active.
While its turn runs, any other `jp` process that reaches teardown deletes it
once it has expired.
With `expires_in: "0s"` that is immediately; with `5m`, any turn that outlives
five minutes.
The turn then writes into a removed directory, or recreates a partial one, and
the plugin's `read_events` fails.

## Fix

Skip conversations whose lock is held, the way `sanitize` already does through
`Storage::is_conversation_locked`
(`crates/jp_storage/src/backend/fs.rs:294-296`).
The check belongs below the CLI, so every caller of the cleanup gets it: in
`remove_ephemeral_conversations`, or in `load_expired_conversation_ids` itself.

## Test

Lock an expired conversation through the workspace, run
`remove_ephemeral_conversations` with an empty skip list, and assert the
conversation is still on disk.
The existing `test_remove_ephemeral_conversations`
(`crates/jp_workspace/src/lib_tests.rs:704`) has the fixture.

## Comments

-----

- **From**: jp
- **Date**: 2026-10-01T10:45:24Z

Fixed in `remove_ephemeral_conversations`: each expired conversation's lock is
taken through the workspace's `LockBackend` for the removal itself, and a
conversation whose lock is held is kept.
Taking the lock rather than checking `is_conversation_locked` closes the window
between the check and the delete, and covers every backend rather than only the
filesystem one.

Exposed by T-0vmm72z on the same branch: `expires_in` on a plugin `query`
creates an expiring conversation that no session has active.
Before it, only `jp conversation edit --tmp` on a conversation a plugin turn was
running on could reach this.

Test: `an_expired_conversation_is_kept_while_its_lock_is_held` keeps a locked
expired conversation, then removes it once released, so the lock is shown to be
what kept it.
