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

  1. Verify the hosted backend is reachable and SERVER_URL is correct (server/docker-compose.yaml stack up)
  2. Re-login if the failure is 401/403 so the mapper sends a fresh token
  3. Do nothing for transient failures — the tombstone enqueued before the DELETE is retried by drainTombstones on the next successful connect
  4. If it persists, inspect server logs for the failing DELETE /api/v1/chats/{cloudChatId} and its status code
  5. 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

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


AI-assisted analysis of moeru-ai/airi@b6d0809ecb (2026-08-18). Data as JSON: /api/errors/db04f6510f8996e3. Report an issue: GitHub.