From 5a73a6a6f3d59045792cf8672a9f411ef2fe2a30 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 10 Aug 2026 13:59:16 +0400 Subject: [PATCH] fix(vibe): let a dislike outlive the session that heard it Disliking a track in a Vibe wrote one row to the session ledger and nothing else. The ledger only excludes a track from the session it was recorded in, so the same track came back the next evening, and the one after that. A dislike in a Vibe is the same verdict as a dislike anywhere else, so it now takes the same path. Two more things undid a dislike that did land. The library scan rewrote every track's state from the file on disk, which restored every HIDDEN track to LIBRARY on every scan; finding a file again says nothing about whether the listener wants to hear it. And hiding only matched tracks in LIBRARY, so a disliked probation recommendation stayed eligible. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KENqSChfyqWnor6ud2WWH6 --- backend/src/services/db.service.ts | 9 ++++-- .../vibe-session-coordinator.service.test.ts | 32 +++++++++++++++++++ .../vibe-session-coordinator.service.ts | 8 +++++ workers/src/scanner.service.ts | 9 +++++- 4 files changed, 55 insertions(+), 3 deletions(-) diff --git a/backend/src/services/db.service.ts b/backend/src/services/db.service.ts index 3ab7a5e..b0e0311 100644 --- a/backend/src/services/db.service.ts +++ b/backend/src/services/db.service.ts @@ -416,9 +416,14 @@ export class DbService { */ async dislikeTrack(userId: string, trackId: string): Promise { await this.withTransaction(async (client) => { - // Phase 1: hide the track in all active views + // Phase 1: hide the track in all active views. A probation recommendation + // is disliked the same way a library track is, and retires on the spot — + // the listener has answered the question probation exists to ask. await client.query( - "UPDATE tracks SET state = 'HIDDEN' WHERE id = $1 AND state = 'LIBRARY'", + `UPDATE tracks + SET state = 'HIDDEN', + probation_status = CASE WHEN state = 'RECOMMENDED' THEN 'retired' ELSE probation_status END + WHERE id = $1 AND state IN ('LIBRARY', 'RECOMMENDED')`, [trackId] ); diff --git a/backend/src/services/vibe-session-coordinator.service.test.ts b/backend/src/services/vibe-session-coordinator.service.test.ts index 070677a..79e8855 100644 --- a/backend/src/services/vibe-session-coordinator.service.test.ts +++ b/backend/src/services/vibe-session-coordinator.service.test.ts @@ -50,6 +50,7 @@ function setup() { advancePastUnplayableVibePlanItem: vi.fn().mockResolvedValue({ item: plan().items[0], stale: false }), persistNextVibePlan: vi.fn().mockResolvedValue({ ...plan(), version: 2, reason: 'feedback:completed' }), getVibePlanForFeedbackEvent: vi.fn().mockResolvedValue(null), + dislikeTrack: vi.fn().mockResolvedValue(undefined), } as unknown as DbService; const director = { buildPlan: vi.fn().mockResolvedValue([{ trackId: TRACK_ID, generatorId: 'comfort', relevance: 0.8, explanation: [] }]), @@ -120,6 +121,37 @@ describe('VibeSessionCoordinator', () => { expect(db.persistNextVibePlan).not.toHaveBeenCalled(); }); + it('records a durable dislike so the track is gone from later sessions too', async () => { + const { db, coordinator } = setup(); + + await coordinator.appendEvent('user-1', SESSION_ID, { + eventId: EVENT_ID, type: 'disliked', trackId: TRACK_ID, + }); + + expect(db.dislikeTrack).toHaveBeenCalledWith('user-1', TRACK_ID); + }); + + it('does not dislike a track twice when its event is delivered again', async () => { + const { db, coordinator } = setup(); + (db.recordVibeEvent as any).mockResolvedValueOnce({ + event: { id: 'event-1', client_event_id: EVENT_ID, type: 'disliked' }, inserted: false, + }); + + await coordinator.appendEvent('user-1', SESSION_ID, { + eventId: EVENT_ID, type: 'disliked', trackId: TRACK_ID, + }); + + expect(db.dislikeTrack).not.toHaveBeenCalled(); + }); + + it('leaves the library alone for feedback that is not a dislike', async () => { + const { db, coordinator } = setup(); + + await coordinator.appendEvent('user-1', SESSION_ID, { type: 'skipped', trackId: TRACK_ID }); + + expect(db.dislikeTrack).not.toHaveBeenCalled(); + }); + it('recovers a material feedback replan when its first persistence attempt failed', async () => { const { db, coordinator } = setup(); (db.publishVibePlan as any).mockRejectedValueOnce(new Error('temporary database failure')); diff --git a/backend/src/services/vibe-session-coordinator.service.ts b/backend/src/services/vibe-session-coordinator.service.ts index 26fa3f4..3c0c80e 100644 --- a/backend/src/services/vibe-session-coordinator.service.ts +++ b/backend/src/services/vibe-session-coordinator.service.ts @@ -229,6 +229,14 @@ export class VibeSessionCoordinator { // guard for old coordinator test doubles during the migration. const projectSessionFeedback = (this.db as Partial).projectVibeSessionFeedback; if (projectSessionFeedback) await projectSessionFeedback.call(this.db, result.event); + // A dislike in a Vibe is the same verdict as a dislike anywhere else. The + // ledger alone only excludes the track from this one session, which is + // why a disliked track kept coming back the next evening. Run it once per + // distinct event so a retried delivery cannot log a second feedback row. + if (input.type === 'disliked' && input.trackId && result.inserted) { + const dislikeTrack = (this.db as Partial).dislikeTrack; + if (dislikeTrack) await dislikeTrack.call(this.db, userId, input.trackId); + } if (!isMaterialFeedback(input.type)) { const response = await this.getPlan(userId, sessionId); return { ...response, event: result.event, idempotent: !result.inserted }; diff --git a/workers/src/scanner.service.ts b/workers/src/scanner.service.ts index a752893..3e26d4e 100644 --- a/workers/src/scanner.service.ts +++ b/workers/src/scanner.service.ts @@ -213,8 +213,15 @@ export class ScannerService { mtime = EXTRACT(EPOCH FROM NOW()), -- Existing recommendation rows stay recommendations during every -- ordinary scan. This is load-bearing for probation and Vibe. + -- A dislike is a listener verdict on a file that is still on disk, + -- so finding that file again says nothing new: HIDDEN and DELETED + -- survive a rescan. Without this every scan restored every disliked + -- track to LIBRARY and the Vibe served it again. state = CASE WHEN tracks.source_type = 'RECOMMENDATION' - THEN tracks.state ELSE EXCLUDED.state END, + THEN tracks.state + WHEN tracks.state IN ('HIDDEN', 'DELETED') + THEN tracks.state + ELSE EXCLUDED.state END, source_type = CASE WHEN tracks.source_type = 'RECOMMENDATION' THEN tracks.source_type ELSE $8::track_source_type END, probation_status = CASE WHEN tracks.source_type = 'RECOMMENDATION'