Files
muzick/backend/src/services/vibe-session-coordinator.service.ts
T
kami 5a73a6a6f3 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KENqSChfyqWnor6ud2WWH6
2026-08-10 13:59:16 +04:00

419 lines
17 KiB
TypeScript

import { DbService, VibeEvent, VibePlan, VibeSession } from './db.service.js';
import { SessionDirector } from './session-director.service.js';
import { Candidate, VibeCalendarContext } from './generators.service.js';
/**
* This is deliberately a narrow bridge between the durable Vibe ledger and
* the current deterministic director. It writes authoritative revisions and
* lets the playback client replace only its unserved preview after feedback.
*/
export const DEFAULT_VIBE_POLICY_VERSION = 'vibe-v2-initial';
export const VIBE_EVENT_TYPES = [
'session_started', 'session_resumed', 'session_ended',
'plan_published', 'track_served', 'playback_started', 'progress', 'completed',
'skipped', 'disliked', 'kept', 'favourite_added', 'queue_removed',
'manual_search', 'album_opened', 'artist_opened', 'playlist_added',
'track_replayed', 'volume_changed', 'playback_error',
] as const;
export type VibeEventType = (typeof VIBE_EVENT_TYPES)[number];
export interface StartVibeSessionInput {
seedTrackId?: string;
resumeSessionId?: string;
context?: VibeCalendarContext;
}
export interface AppendVibeEventInput {
eventId?: string;
type: VibeEventType;
trackId?: string;
occurredAt?: Date;
positionMs?: number;
durationMs?: number;
payload?: Record<string, unknown>;
}
/**
* An explicit advancement protocol for a plan item that was durably served
* but cannot be played locally. `eventId` is the idempotency key for this
* state transition; it is intentionally separate from a retry of /next.
*/
export interface AdvanceUnplayableVibeItemInput {
expectedPlanVersion: number;
planVersionId: string;
ordinal: number;
trackId: string;
eventId: string;
}
/**
* How much of the plan a client is shown and holds ready. The plan itself is
* PLAN_SIZE long and stays that way; this is only the served window. Every
* preview item costs the client one track fetch on every advance, and a replan
* discards whatever is still unplayed, so a long window buys little beyond a
* longer Up next list.
*/
const PREVIEW_SIZE = 3;
export class VibeSessionNotFoundError extends Error {}
export class VibeSessionLifecycleError extends Error {}
export class VibePlanNotFoundError extends Error {}
export interface VibeSessionResponse {
session: VibeSession;
sessionId: string;
planVersion: number | null;
now: VibePlan['items'][number] | null;
preview: VibePlan['items'];
state: Record<string, unknown>;
replanned: boolean;
replanReason: string | null;
}
export class VibeSessionCoordinator {
constructor(
private readonly db: DbService,
private readonly director: Pick<SessionDirector, 'buildPlan' | 'buildState' | 'replan'>,
) {}
async start(userId: string, input: StartVibeSessionInput): Promise<VibeSessionResponse> {
if (input.resumeSessionId) return this.resume(userId, input.resumeSessionId);
// Calendar context is a weak boot prior, never a substitute for listening
// feedback or long-term preference. It is intentionally coarse and is
// persisted with the session so a resume remains coherent.
const calendar = input.context;
const contextualEnergy = calendar && calendar.localHour < 6 ? 0.32
: calendar && calendar.localHour >= 18 && calendar.localHour < 23 ? 0.58
: 0.5;
const initialState = {
energy: contextualEnergy,
noveltyHunger: 0.3,
explorationCoefficient: 0.3,
discoveryRadius: 0.38,
sessionGoal: { type: 'discovery' as const, target: 1, progress: 0 },
};
const session = await this.db.createVibeSession({
userId,
policyVersion: DEFAULT_VIBE_POLICY_VERSION,
seedTrackId: input.seedTrackId ?? null,
context: calendar ? { ...calendar } : undefined,
profile: {
goals: initialState.sessionGoal,
explorationCoefficient: initialState.explorationCoefficient,
discoveryRadius: initialState.discoveryRadius,
},
});
// session_state is a derived cache used by the current director. Give it
// the durable ID so director state cannot accidentally bleed into another
// session while the durable tables remain the source of truth.
await this.db.createSessionState(
userId,
calendar ? JSON.stringify(calendar) : undefined,
{
energy: initialState.energy,
noveltyHunger: initialState.noveltyHunger,
explorationCoefficient: initialState.explorationCoefficient,
discoveryRadius: initialState.discoveryRadius,
sessionGoal: initialState.sessionGoal,
},
session.id,
);
await this.db.recordVibeEvent({
sessionId: session.id,
userId,
type: 'session_started',
payload: { policyVersion: DEFAULT_VIBE_POLICY_VERSION, ...(calendar ? { calendar } : {}) },
});
const candidates = await this.director.buildPlan(userId, session.id, input.seedTrackId);
const state = await this.director.buildState(userId, session.id);
let plan: VibePlan;
try {
plan = await this.db.publishVibePlan({
sessionId: session.id,
userId,
version: 1,
reason: 'session_started',
stateSnapshot: state,
objectiveSnapshot: {
policyVersion: DEFAULT_VIBE_POLICY_VERSION,
horizonTracks: candidates.length,
...(candidates[0]?.plan?.objective ?? {}),
},
items: candidates.map((candidate, ordinal) => ({
ordinal,
track_id: candidate.trackId,
slot_role: candidate.plan?.slotRole ?? (ordinal === 0 ? 'next' : null),
candidate_source: candidate.generatorId,
score: candidate.plan?.score ?? candidate.relevance,
score_breakdown: candidate.plan?.scoreBreakdown ?? { relevance: candidate.relevance },
explanation: candidate.plan
? { paths: candidate.explanation, planner: candidate.plan.explanation }
: candidate.explanation,
committed: false,
})),
});
} catch (error) {
throw this.mapLifecycleError(error);
}
return this.toResponse(session, plan, state);
}
async getPlan(userId: string, sessionId: string, version?: number): Promise<VibeSessionResponse> {
const session = await this.requireSession(userId, sessionId);
const plan = await this.db.getVibePlan(sessionId, userId, version);
if (!plan) throw new VibePlanNotFoundError(
version === undefined ? 'Vibe session does not have a published plan' : 'Vibe plan revision was not found',
);
return this.toResponse(session, plan, plan?.state_snapshot ?? {});
}
async serveNext(userId: string, sessionId: string, expectedPlanVersion?: number): Promise<VibeSessionResponse> {
try {
const served = expectedPlanVersion === undefined
? await this.db.serveNextVibePlanItem(sessionId, userId)
: await this.db.serveNextVibePlanItem(sessionId, userId, expectedPlanVersion);
const response = await this.getPlan(userId, sessionId);
// A plan may be replaced between the client's preview and this request.
// In that case the database does not commit anything and this is the
// current, revisable preview the client must reconcile to.
if (served.stale) return response;
return { ...response, now: served.item, preview: response.preview };
} catch (error) {
throw this.mapLifecycleError(error);
}
}
async advancePastUnplayable(
userId: string,
sessionId: string,
input: AdvanceUnplayableVibeItemInput,
): Promise<VibeSessionResponse> {
try {
const served = await this.db.advancePastUnplayableVibePlanItem(sessionId, userId, input);
const response = await this.getPlan(userId, sessionId);
if (served.stale) return response;
return { ...response, now: served.item, preview: response.preview };
} catch (error) {
throw this.mapLifecycleError(error);
}
}
async appendEvent(
userId: string,
sessionId: string,
input: AppendVibeEventInput,
): Promise<VibeSessionResponse & { event: VibeEvent; idempotent: boolean }> {
try {
// Do this before the ledger write, rather than after it, because Vibe
// events are immutable. The DB repeats this boundary for non-HTTP
// callers; keeping it here also makes coordinator callers see exactly
// what will be persisted.
const result = await this.db.recordVibeEvent({
sessionId,
userId,
clientEventId: input.eventId,
type: input.type,
trackId: input.trackId,
occurredAt: input.occurredAt,
positionMs: input.positionMs,
durationMs: input.durationMs,
payload: input.payload,
});
// The ledger write is authoritative; this idempotent projection updates
// exploration only after the exact event exists. Keep the compatibility
// guard for old coordinator test doubles during the migration.
const projectSessionFeedback = (this.db as Partial<DbService>).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<DbService>).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 };
}
// A material event is durable before its computed replacement can be
// written. If planning/persistence failed after that event committed, a
// retry must finish the missing replacement instead of permanently
// returning an obsolete preview. Once a replacement exists, a duplicate
// retry returns that canonical revision without doing work again.
if (!result.inserted) {
const existingReplacement = await this.db.getVibePlanForFeedbackEvent(sessionId, userId, result.event.id);
if (existingReplacement) {
const session = await this.requireSession(userId, sessionId);
return {
...this.toResponse(session, existingReplacement, existingReplacement.state_snapshot),
event: result.event,
idempotent: true,
};
}
}
const session = await this.requireSession(userId, sessionId);
const state = await this.director.buildState(userId, sessionId);
// A feedback target is useful as the local replan anchor, but it must
// never displace the durable seed from the exclusion boundary. Unlike
// feedback tracks, the seed is not necessarily present in the event
// ledger, so carry it explicitly into every replacement request.
const seedTrackId = session.seed_track_id ?? undefined;
// Revisions retain the durable, unserved queue tail rather than building
// an unrelated plan after every signal. Besides reducing churn, this
// preserves a valid callback/recovery pair that has already been shown
// to the client while allowing the director to refill under the same
// hard caps and current feedback state.
const current = await this.db.getVibePlan(sessionId, userId);
const retained = current ? this.unservedCandidates(current) : [];
const excludedTrackIds = new Set<string>([
...(seedTrackId ? [seedTrackId] : []),
]);
const candidates = await this.director.replan(
userId,
sessionId,
retained,
input.trackId ? [input.trackId] : [],
input.trackId ?? seedTrackId,
{ excludedTrackIds },
);
const reason = `feedback:${input.type}`;
const plan = await this.db.publishVibePlan({
sessionId,
userId,
reason,
stateSnapshot: state,
objectiveSnapshot: {
policyVersion: session.policy_version,
feedbackEventId: result.event.id,
feedbackType: input.type,
horizonTracks: candidates.length,
...(candidates[0]?.plan?.objective ?? {}),
},
items: candidates.map((candidate, ordinal) => ({
ordinal,
track_id: candidate.trackId,
slot_role: candidate.plan?.slotRole ?? (ordinal === 0 ? 'next' : null),
candidate_source: candidate.generatorId,
score: candidate.plan?.score ?? candidate.relevance,
score_breakdown: candidate.plan?.scoreBreakdown ?? { relevance: candidate.relevance },
explanation: candidate.plan
? { paths: candidate.explanation, planner: candidate.plan.explanation }
: candidate.explanation,
committed: false,
})),
});
return {
...this.toResponse(session, plan, state),
event: result.event,
idempotent: !result.inserted,
replanned: true,
replanReason: reason,
};
} catch (error) {
throw this.mapLifecycleError(error);
}
}
async end(userId: string, sessionId: string): Promise<VibeSessionResponse> {
let ended: VibeSession;
try {
ended = (await this.db.endVibeSessionWithEvent(sessionId, userId)).session;
} catch (error) {
throw this.mapLifecycleError(error);
}
const plan = await this.db.getVibePlan(sessionId, userId);
return this.toResponse(ended, plan, plan?.state_snapshot ?? {});
}
private async resume(userId: string, sessionId: string): Promise<VibeSessionResponse> {
try {
const resumed = await this.db.resumeVibeSession(sessionId, userId);
const plan = await this.db.getVibePlan(sessionId, userId);
return this.toResponse(resumed.session, plan, plan?.state_snapshot ?? {});
} catch (error) {
throw this.mapLifecycleError(error);
}
}
private async requireSession(userId: string, sessionId: string): Promise<VibeSession> {
const session = await this.db.getVibeSession(sessionId, userId);
if (!session) throw new VibeSessionNotFoundError('Vibe session was not found');
return session;
}
private toResponse(
session: VibeSession,
plan: VibePlan | null,
state: Record<string, unknown>,
): VibeSessionResponse {
// A revision is immutable, but clients need a live future: already served
// rows stay in the ledger and are excluded from the replacement preview.
const preview = plan?.items.filter((item) => !item.committed).slice(0, PREVIEW_SIZE) ?? [];
return {
session,
sessionId: session.id,
planVersion: plan?.version ?? null,
now: preview[0] ?? null,
preview,
state,
replanned: false,
replanReason: null,
};
}
/** Reconstruct the planner envelope from the durable revision. Older
* revisions stored only paths, so they remain valid retention inputs. */
private unservedCandidates(plan: VibePlan): Candidate[] {
return plan.items
.filter(item => !item.committed)
.map(item => {
const stored = item.explanation;
const hasPlanner = !!stored && !Array.isArray(stored) && typeof stored === 'object'
&& 'planner' in stored;
const object = hasPlanner ? stored as { paths?: Candidate['explanation']; planner?: Record<string, unknown> } : undefined;
const planner = object?.planner;
return {
trackId: item.track_id,
generatorId: item.candidate_source,
relevance: item.score,
explanation: object?.paths ?? (Array.isArray(stored) ? stored : []),
plan: planner ? {
slotRole: item.slot_role ?? 'retained',
score: item.score,
scoreBreakdown: item.score_breakdown,
explanation: planner,
objective: {
policy: planner.policy,
constraints: planner.constraints,
relaxations: planner.relaxations,
},
} : undefined,
};
});
}
private mapLifecycleError(error: unknown): Error {
if (error instanceof Error && (error.message.includes('Cannot record a new event for') || error.message.includes('Cannot resume ') || error.message.includes('Cannot publish a plan for'))) {
return new VibeSessionLifecycleError(error.message);
}
if (error instanceof Error && error.message.includes('not found or is not owned')) {
return new VibeSessionNotFoundError('Vibe session was not found');
}
return error instanceof Error ? error : new Error(String(error));
}
}
const MATERIAL_FEEDBACK_EVENTS = new Set<VibeEventType>(['skipped', 'disliked', 'completed', 'kept']);
function isMaterialFeedback(type: VibeEventType): boolean {
return MATERIAL_FEEDBACK_EVENTS.has(type);
}