…start (#4093)
POST /api/chat/slots/{slot}/note replies 200 with visibleDeferred: true for
a note held during a running turn, but both halves of the hold lived only
in _ChatSlot._deferred_notes — a gateway restart between the 200 and the
next turn silently voided the delivery promise.
The hold now persists through the slot's own metadata line under one
invariant: retirement is ROW-DERIVED. Every note carries an id; the flush
stamps each delivered inject row with it (meta.noteId) and records
rebind-dropped ids on the slot; the full save — reading the on-disk and
live holds UNDER the history lock — retires exactly the entries whose
rows are in the window it writes (or whose ids were dropped) and keeps
everything else. Row and retirement land in one atomic file replace; the
drop records are consumed only AFTER that write commits, since a dropped
note's row never exists and the record is its only retirement path.
- enqueue (durable-before-200): asyncio.to_thread + update_metadata_if,
everything read inside the lock-time guard; the write MERGES by note id
(union_deferred_notes), pins the posted note (ensure=) so a racing
turn-end flush cannot yield a 200 with no durable copy, and pins the
TARGET to the history key authorized at enqueue, re-verified under the
store lock — a cron/workflow rebind in the persist window is refused
(uniform not-found shape) instead of writing app content into a foreign
transcript's metadata. The union never evicts: at the 2x ceiling the
NEW note is refused (429). On EVERY failure branch, a rollback that
finds the note already drained means a flush DELIVERED it — the 200
stands, because any error would make the caller re-post a line the
user already saw. Other failures roll back BY IDENTITY and answer a
retryable 503 (including an UNREADABLE record)
- deferred notes are bounded at the ENQUEUE boundary (413 over 4000
chars); the durable copy is persisted VERBATIM, never truncated
- both restore paths replay the hold up to the durable CEILING (2x the
live cap — every durable entry is a 200-acknowledged note); restored
notes are sanitized fail-closed (no session stamp -> dropped;
over-bound content -> dropped; malformed context half dropped alone)
- the flush never writes metadata; a crash between flush and save
re-delivers on restore (at-least-once)
- docs updated in the same commit (App Kit api-reference, session.md,
endpoint docstring): do not re-post after a restart; 503 = retry,
413/429 = boundary refusals, 404 = ownership/rebind refusal
_pending_context (the /context queue) stays memory-only: only the context
halves embedded in held notes ride the same metadata shape naturally.
Closes #4093
Problem / Motivation
POST /api/chat/slots/{slot}/noteaccepts a note while a turn is running and replies200withvisibleDeferred: true— a delivery promise for a transcript line. Both halves of the held note live in memory only: the visible line in_ChatSlot._deferred_notesand its queued context entry embedded in it. The persistence layer references neither (the cleanup-path comment inchat_handlers.pysaid so explicitly), so a gateway restart between the 200 and the next turn silently drops the note. The only mitigation was documentation: "a 200 means accepted for this gateway lifetime".Why it matters
Background actors (crons, apps) are exactly the callers that post notes into running turns, and a gateway restart happens on every upgrade and every crash. A caller that got a 200 has no signal the note evaporated; the user never sees the line, and the "re-post it yourself" advice in the docs is unactionable for a caller that no longer exists. The response field reads as a delivery promise and the system did not keep it.
What changed (motivation → approach → change)
Symptom: an acknowledged held note vanishes across a restart. Root cause: the hold has no durable representation. Fix: persist the hold with the slot, under one invariant — retirement is row-derived. Every note carries an id; the flush stamps each delivered inject row with it (
meta.noteId) and records rebind-dropped ids; the full save retires exactly the entries whose rows are in the window it writes (or whose ids were dropped) and keeps everything else:chat_handlers.py): the/notedeferred branch persists the hold viaasyncio.to_thread+update_metadata_if, with everything read inside the guard the store evaluates under the cross-process history lock (the establishedSLOT_OWNED_META_KEYSread-modify-write pattern; never an outside-lock snapshot). The write is a merge (union_deferred_notes): live entries plus disk entries whose noteidis absent from memory — absent-from-memory can mean "delivered into the still-unsaved window", and erasing such an entry would lose an acknowledged note on crash. The posted note is pinned into the write (ensure=) so a turn-end flush draining it mid-persist cannot yield a 200 with no durable copy anywhere — and the pin covers exactly one state (not held, not durable, not dropped, not committed): under the lock it is skipped when the note's delivered row is already in the committed transcript, so a late worker cannot resurrect a retired hold into a duplicate replay. The union never evicts: at the 2× ceiling the new note is refused (429 deferred_notes_full) rather than a retained entry dropped. A failed write — including an unreadable metadata record, whichupdate_metadata_ifreports identically to an absent one — rolls the note back by identity (equality could evict a byte-identical sibling holding a durable 200) and answers a retryable503 deferred_note_persist_failed. A slot with no metadata line at all keeps its 200 (it has no durable identity for the hold to outlive).chat_persistence.py,history.py,slot_buffers.py):deferred_notesjoinsSLOT_OWNED_META_KEYS, and the full save — reading the on-disk and live holds under the same history lock the merge writers commit under — retires exactly the entries whose delivered rows (stampedmeta.noteIdby the flush) are in the window this save writes, plus recorded rebind-drops, and unions everything else forward. Row and retirement land in one atomic file replace, so no flush/save/enqueue interleaving can clear an entry whose row is unsaved, and a/notecommit that wins the lock during the save's patient acquire is kept rather than overwritten by a stale snapshot. The flush itself writes no metadata at all: a crash between flush and save re-delivers on restore — at-least-once, the correct failure direction for a delivery promise.chat_persistence.py): both slot-restore paths read the persisted hold back into_deferred_notes, so the existingflush_deferred_notes()call sites deliver it on the first turn after the restart — the flush sites themselves are unchanged. The restore is bounded by_MAX_DURABLE_HOLD_ENTRIES(2× the live cap), the same ceiling the persist path admits: every durable entry is a 200-acknowledged note whose caller was told not to re-post, so a live-cap restore would silently discard acknowledged content. The live cap still binds new enqueues, and the first flush drains the surplus. Restored notes are sanitized fail-closed: a note without an authorizationsessionstamp is dropped (the cross-session leak the stamp prevents), a context half that fails the pending-context schema is dropped alone so a corrupted entry cannot poison the flush or the next turn's drain.slot_buffers.py,chat_handlers.py): a note posted during a running turn is capped at 4,000 chars with an actionable413 deferred_note_too_largebefore any 200 — and the durable copy is then persisted verbatim, never truncated, so a restart replays exactly what the 200 accepted. Restore drops (never alters) an over-bound entry as tamper evidence. The boundary rejection is also what keeps the metadata line small (it is read and rewritten whole under the history lock on every/notePOST).session.mdhandover rationale, and the endpoint docstring now state the new contract — do not re-post after a restart (the restored hold delivers, and a re-post would double the line);503= retry the same request,413/429= boundary refusals._pending_context(the/contextendpoint's queue) deliberately stays memory-only, as the issue allows: only the context halves embedded in held notes ride the metadata shape naturally. (Sibling PR #6813 addresses the context queue across a close; no overlap with this diff.)The rows-only handover save defers this key to the on-disk value (clearing it there could erase a live replacement slot's own hold); the restore closes the resulting window by dropping any restored entry whose delivered row is already committed in the transcript (
drop_committed_restored_notes), failing toward a duplicate rather than a loss when the transcript is unreadable.Tests
test/test_deferred_note_persistence.py(38 tests), pinning the issue's regression gates:ensure=pins a note a racing flush already drained; the hold-full ceiling refuses the new note without evicting a retained one.MAX_DEFERRED_NOTES, mints ids for legacy entries; the serializer is verbatim.Existing coverage unchanged: the deferred-note endpoint suites, flush-exception-safety, slot facade contract, restore/rehydrate, history/atomic-rewrite, and slot-close recreation-race suites all pass (968 tests in the touched neighborhood); the full backend suite delta vs pristine main is zero.
Manual verification
N/A — unit coverage exercises the real
ConversationLogon-disk round trip end to end (endpoint → metadata line → fresh-state restore → flush → save), which is the integration surface this change touches.Related Issues
Closes #4093
Pattern harvest
Rule candidate: review-prompt
Pattern: an acknowledgement's backing state cleared by a non-committing writer — any "clear on consume" of a durability record must ride the same atomic write that commits the consumed data, never an independent earlier write.
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)