Files
GMW/.hermes/plans/2026-08-30_voice-autoreconnect-persistent-spec.md
T

3.7 KiB

Spec: Voice auto-reconnect (persistent state + rejoin on drop)

Goal

Setelah VoiceController.connect() berhasil, state "sedang merekam di /" disimpan di Postgres. Kalau gateway restart/reboot, atau koneksi voice drop tidak disengaja (dikeluarkan/moved/server restart), gateway otomatis join ulang ke channel yang sama.

Requirement mapping (user's ask)

  • "autoreconnect ke channel yg sama jika server restart atau reboot" → reconnect on startup (ready handler) + keep DB record across graceful shutdown.
  • "state nya persistent di db" → voice_auto_reconnect table.
  • "rejoin jika tidak sengaja dikeluarkan" → watchdog on Disconnected/Destroyed (kick / moved / voice server restart) → full rejoin with backoff.
  • Manual leave (/voice disconnect, dashboard disconnect) MUST NOT rejoin.

Design decisions

  1. New table voice_auto_reconnect (dedicated, not ui_state):
    • guild_id text PK
    • channel_id text NOT NULL
    • channel_name text
    • connected_at bigint epoch-ms
    • updated_at bigint epoch-ms DAO: voiceAutoReconnectRepo.ts — upsert(record), list(), delete(guildId).
  2. Write on connect: VoiceController.connect() → after startRecording success → upsert. IDEMPOTENT (upsert per guild).
  3. Clear on manual leave: handleVoiceDisconnect (all) + handleVoiceDisconnectGuild pass clearPersisted: true. Graceful shutdown disconnect() keeps the record.
  4. Rejoin on startup: bootstrap ready → await voiceController.autoReconnect() (list persisted → connect each, non-fatal on failure).
  5. Rejoin on unexpected drop: monitor per-connection; on Disconnected/Destroyed with !intentional → schedule rejoin connect(guildId, persistedChannelId) with backoff (min 2s, max 30s, max 5 attempts). Track rejoinAttempts, reset on success.
  6. Intentional flag: disconnectGuild(guildId, { clearPersisted?, intentional? }).
    • shutdown disconnect() → { intentional: true, clearPersisted: false }.
    • manual disconnect() (dashboard) → { clearPersisted: true }, sets intentional.
    • manual disconnectGuild → { clearPersisted: true }, sets intentional. The monitor checks intentional before rejoin; clearPersisted only deletes the row.

Files touched

  • services/discord-gateway/src/shared/database/schema.ts — add pgVoiceAutoReconnectTable
    • types.
  • services/discord-gateway/src/shared/database/voiceAutoReconnectRepo.ts (NEW) — DAO.
  • services/discord-gateway/src/modules/voice-recording/voiceController.ts — upsert on connect; monitor + rejoin; autoReconnect(); disconnect/disconnectGuild opts.
  • services/discord-gateway/src/modules/command-handler/voice.handler.ts — manual disconnect/disconnectGuild pass clearPersisted: true.
  • services/discord-gateway/src/app/bootstrap.ts — call voiceController.autoReconnect() in ready.
  • services/discord-gateway/drizzle/migrations/0020_add_voice_auto_reconnect.sql + meta/_journal.json entry (apply manually per gmw-ops).

Edge cases

  • Channel deleted / guild lost while persisted → connect() throws (channel not found) → log + delete persisted row (don't retry forever).
  • Rejoin attempts exhausted → keep row (so next restart retries) + log.
  • Multiple guilds: per-guild monitor, per-guild persisted row.
  • Graceful shutdown order: shutdown sets intentional=true (so no rejoin during teardown) but keeps row.

Verification

  • pnpm typecheck && pnpm build && pnpm lint in services/discord-gateway.
  • Apply migration 0020 manually; verify table exists.
  • CI Build & Deploy (Nix) green; gateway deploy lands.
  • Manual: connect via dashboard → check voice_auto_reconnect row; simulate drop → confirm rejoin; manual disconnect → row cleared.