Repository navigation
Sessions: archived and deleted sessions stay closed - #676
Conversation
An archived session leaves `archived` only through unarchive, and a run never creates a row for a deleted session id. - Engine: run() raises SessionClosedError for an archived or deleted session. It checks after the run lock and again under the client lock while it builds the client. The queue entry leaves as `refused`, and the error event says why. Routes answer 409. - mark_active/idle/stopped/error write nothing to an archived session. - Archive: refused while a turn holds the run lock (also while it waits for a concurrency slot). The check and the status write hold the client lock. After the archive, pending wakeups are deleted and pending questions expire with the normal reports. Approvals stay. - Channels: an archived session is not sticky. The router sends a message refused by an archive again, once, to a new session. - Delete: 409 while a turn runs or waits; a run cannot start during the delete. The session's questions expire. The web chat shows an alert. - An answer to an archived or deleted session is stored, no turn. - A person's own message (web chat, POST /api/chat, plan revision) unarchives the session in their name, then runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Archive: refused while anything holds the session's run lock (a queued message, a background continuation, an idle drain) or a registered task runs. After the checks, with no await, the session is marked as archiving; a run checks the mark right after it takes the run lock, so it is refused before it stores anything. This replaces the hold of the client lock. - Delete: also 409 while the run lock is held (the queue handoff). - POST /api/chat with the id of a deleted session creates nothing and answers 409. A plan revision for a deleted proposer session goes to cron:task-planner. - Run later into an archived session unarchives it first. - Unstarring an archived session starts no star_project_hook turn. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Nerve PR review · ✅ Ready to merge Updated for 9b787f6: three refactor commits with no behaviour change. Archive and delete now share one The PR closes the five audited paths (#671) that revived archived or deleted sessions without context. One Completeness: 26 tests on the real engine and SQLite;
Checked: full suite in an offline sandbox at |
- SessionManager.closing(session_id, reason) marks a session while an archive or a delete is in progress; closing_reason() reports it. The engine's own delete set, begin_delete and end_delete are gone. - SessionManager.has_turn_work() is the one turn-work check for archive and the delete route. - Database.closed_session_reason() is the one "archived or deleted" lookup for the engine, POST /api/chat and the plan revision. - The race explanation is in one docstring (SessionManager.closing). - The delete test goes through the delete route, so it also covers the route's mark. A unit test covers nested marks and their cleanup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
run() checks the session right after it holds the run lock. From there the run lock stays held for the whole turn, and archive and delete refuse a session whose run lock is held, so the second check at the client build and its except branch in _run_inner had no caller that could reach them. The test that wrote status='archived' straight to the database to reach them goes too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_refuse_turn takes the error to raise (default NotAcceptingTurnsError), so a run in a closed session uses it with SessionClosedError, and the copy in _refuse_closed_session goes. Every caller that gets a SessionClosedError already logs it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixes #671.
What
Archived and deleted sessions stay closed:
archivedleaves that state only through unarchive (with anunarchivedevent).How
One guard in the engine.
run()raises the newSessionClosedError(reason=archivedordeleted) for a closed session. It checks once, right after the run holds the session's run lock. The run lock then stays held for the whole turn, and archive and delete refuse a session whose run lock is held. The user message is not stored. A queued entry leaves the queue asrefused(the web chat then gives the text back), and the session's clients get an error event that says why. A route that runs a turn answers409.Status writes.
mark_active,mark_idle,mark_stoppedandmark_errorwrite nothing to an archived or missing session (WHERE status != 'archived'), and log their event only when they wrote.Archive (
SessionManager.archive_session):SessionBusyError("session is running")while the session has turn work: a turn that runs or waits for a concurrency slot, a registered task (a workflow leg between turns), or anything that holds the run lock (a queued message, a background continuation of the runtime, an idle drain).SessionManager.closing) until the status is written. A run checks the mark right after it takes the run lock, also with no await in between. So a run either makes the archive fail, or is refused before it stores anything.Channels.
_is_within_sticky_periodis False for an archived session, so the next channel message starts a new session. If the session is archived after the router resolved it, the router sends the message again once, to a new session.Delete (
DELETE /api/sessions/{id}): answers409while the session has turn work, as above. The web chat shows an alert. The delete uses the same check and the same closing mark, so a run cannot start during the delete. After the delete, the session's pending questions expire. The rows stay as history.POST /api/chatwith the id of a deleted session creates nothing and answers409. A plan revision for a deleted proposer session goes tocron:task-planner, as its docstring says.Answers.
handle_answerfor an archived or missing session stores the answer and broadcasts it, but starts no turn.A person's own message reopens. The WebSocket
messagehandler,POST /api/chatwith an explicitsession_id, "Run later", and a plan revision request for an archived proposer session callengine.reopen_archived. That unarchives the session in that person's name, then the turn runs. Every other caller is refused. Unstarring an archived session keeps it archived and starts nostar_project_hookturn.Why
The state machine audit found five paths that bring an archived or deleted session back as
activewith no conversation history (SES-1, SES-2, SES-3, NTF-3, NTF-9 in #671). The agent then acted on a wakeup, an answer or a channel message without the context that the person expects, and the chat came back in the sidebar with nounarchivedevent.Tests
tests/test_closed_sessions.py(26 tests) drives the real engine, run lock, client build and SQLite store with a scripted backend. It includes the three audit repros, changed to assert the expected result: a due wakeup on an archived session fires nothing; a delete with a queued turn answers 409 and a later run creates no row; an answer to a deleted session creates no row.reopen_archived, and thetest_drain.pydatabase double returns a session row.npm run build, andnpm test(141 files, 1911 tests) pass locally.Not in this PR
🤖 Generated with Claude Code