moeru-ai/airi · warning
[chat-sync] DELETE /api/v1/chats failed for
Error message
[chat-sync] DELETE /api/v1/chats failed for
What it means
Logged when the cloud mapper's DELETE /api/v1/chats/{id} request rejects while deleting a chat server-side. The local session is already removed, and a tombstone row was enqueued just before the call, so reconcile-driven drainTombstones keeps retrying the DELETE until the server confirms and the tombstone can be dropped. Without that retry, the still-existing server row would be re-adopted as a ghost session on the next reconcile round.
Source
Thrown at packages/stage-ui/src/stores/chat/session-store.ts:621
await refreshOutboxPendingCount()
if (isCloudUser) {
const remoteChatId = cloudChatId ?? sessionId
// Tombstone first: even if the cloud DELETE never reaches the server
// (offline, transient 5xx), the next reconcile will see the remote id
// here and skip the adopt branch — preventing the ghost-session bug
// where the server still has the row and re-creates the local mapping.
// The reconcile-driven `drainTombstones` retries failed DELETEs.
await enqueuePersist(() => chatSessionsRepo.addTombstone(currentUserId, remoteChatId))
if (cloudChatId) {
getCloudMapper().deleteChat(cloudChatId).then(
async () => {
// Server confirmed the delete; reconcile will not see this id again,
// so we can drop the tombstone.
await enqueuePersist(() => chatSessionsRepo.removeTombstones(currentUserId, [cloudChatId]))
},
(err) => {
console.warn('[chat-sync] DELETE /api/v1/chats failed for', sessionId, errorMessageFrom(err))
},
)
}
}
const characterIndex = index.value?.characters[characterId]
const fallbackId = characterIndex
? Object.keys(characterIndex.sessions).find(id => sessionMetas.value[id])
: undefined
// Persisted character fallback is shared, but live selection is local to
// the window that was displaying the deleted session.
if (fallbackId && characterIndex) {
characterIndex.activeSessionId = fallbackId
if (wasActive) {
activeSessionId.value = fallbackId
await loadSession(fallbackId)
}View on GitHub (pinned to b6d0809ecb)
Solutions
- Verify the hosted backend is reachable and SERVER_URL is correct (server/docker-compose.yaml stack up)
- Re-login if the failure is 401/403 so the mapper sends a fresh token
- Do nothing for transient failures — the tombstone enqueued before the DELETE is retried by drainTombstones on the next successful connect
- If it persists, inspect server logs for the failing DELETE /api/v1/chats/{cloudChatId} and its status code
- Only clear the tombstone manually after confirming the chat is gone server-side, otherwise a ghost session returns
Example fix
// before
getCloudMapper().deleteChat(cloudChatId).then(
async () => { await enqueuePersist(() => chatSessionsRepo.removeTombstones(currentUserId, [cloudChatId])) },
err => console.warn('[chat-sync] DELETE /api/v1/chats failed for', sessionId, errorMessageFrom(err)),
)
// after: await + explicit tombstone-first ordering so reconcile owns retries
await enqueuePersist(() => chatSessionsRepo.addTombstone(currentUserId, remoteChatId))
try {
await getCloudMapper().deleteChat(cloudChatId)
await enqueuePersist(() => chatSessionsRepo.removeTombstones(currentUserId, [cloudChatId]))
}
catch (err) {
// tombstone stays enqueued; drainTombstones retries on next reconcile
console.warn('[chat-sync] DELETE /api/v1/chats failed for', sessionId, errorMessageFrom(err))
} Defensive patterns
Strategy: retry
Validate before calling
if (!cloudChatId) return // local-only chat: nothing to delete server-side if (!currentUserId) return // anonymous sessions have no server row
Try / catch
// tombstone-first ordering: enqueue addTombstone BEFORE deleteChat, and only
// removeTombstones on success so any rejection is retried by drainTombstones
await enqueuePersist(() => chatSessionsRepo.addTombstone(currentUserId, remoteChatId))
try {
await getCloudMapper().deleteChat(cloudChatId)
await enqueuePersist(() => chatSessionsRepo.removeTombstones(currentUserId, [cloudChatId]))
}
catch (err) {
console.warn('[chat-sync] DELETE /api/v1/chats failed for', sessionId, errorMessageFrom(err))
} Prevention
- Keep auth tokens fresh and surface 401s as a re-login prompt instead of silent warn storms
- Ensure the backend stack is up before bulk operations that delete chats
- Never remove a tombstone without a confirmed server delete
- Correlate the warn with the cloudChatId, not just the local sessionId, when debugging
When it happens
Trigger: Deleting a chat while the WebSocket/HTTP channel to the hosted backend is down; expired or invalid auth token (401); backend restarted or the DELETE route returns 4xx/5xx; misconfigured SERVER_URL so the request never reaches the API app.
Common situations: User deletes a chat right after the machine wakes from sleep, before the client reconnects; long-lived tab with an expired token; self-hosted docker-compose backend stopped; reverse proxy not forwarding DELETE to the API app.
Understand the failure class
Background: 'Something went wrong' / 'Request failed (500)' / 'HTTP error! status: 404' — what failed HTTP requests actually mean and how to find the real cause — this error's family across 28 libraries.
Related errors
- [chat-ws] socket error:
- [chat-ws] ws error event:
- [chat-sync] pullMessages failed for
- [chat-sync] listChats failed; skipping reconcile this round:
- [chat-sync] tombstone drain failed for
AI-assisted analysis of moeru-ai/airi@b6d0809ecb (2026-08-18).
Data as JSON: /api/errors/db04f6510f8996e3.
Report an issue: GitHub.