diff --git a/frontend/src/lib/rectification-v4/case-service.ts b/frontend/src/lib/rectification-v4/case-service.ts index db4fc5fc..20f6df7e 100644 --- a/frontend/src/lib/rectification-v4/case-service.ts +++ b/frontend/src/lib/rectification-v4/case-service.ts @@ -13,8 +13,17 @@ import { calculationSpecHash, evidenceSetHash } from "./fingerprints.ts"; import { openingQuestion } from "./opening-question.ts"; import type { RectificationV4Store } from "./store.ts"; -export function createRectificationV4CaseService(store: RectificationV4Store, options: { readonly now?: () => Date } = {}) { +const regenerationInFlight = new Map>(); + +export function createRectificationV4CaseService( + store: RectificationV4Store, + options: { + readonly now?: () => Date; + readonly regenerateQuestion?: typeof regenerateQuestionRealization; + } = {}, +) { const now = options.now ?? (() => new Date()); + const realizeQuestion = options.regenerateQuestion ?? regenerateQuestionRealization; async function response(userId: string, caseValue: RectificationV4Case, jobId?: string): Promise { const [events, turns] = await Promise.all([ @@ -95,32 +104,45 @@ export function createRectificationV4CaseService(store: RectificationV4Store, op readonly actionId: string; readonly expectedCaseVersion: number; }) { - const current = await store.loadCase(input.userId, input.caseId); - if (!current?.currentQuestion || current.deploymentMode !== "v5_agent") return null; - const validated = await store.loadLatestValidatedDecision(input.userId, input.caseId); - const opportunity = validated?.selectedOpportunity; - if (!opportunity) return null; - const [events, turns] = await Promise.all([ - store.loadEvents(input.userId, input.caseId), - store.loadTurns(input.userId, input.caseId), - ]); - const prompt = await regenerateQuestionRealization({ - caseValue: current, - currentPrompt: current.currentQuestion.prompt, - latestAnswer: turns.at(-1)?.answer ?? "", - acceptedEvents: events, - opportunity, - }); - const nextQuestion = { - ...current.currentQuestion, - id: randomUUID(), - prompt, - }; - return response(input.userId, await store.replaceCurrentQuestion({ - ...input, - question: nextQuestion, - now: now().toISOString(), - })); + const replay = await store.loadActionCase(input.userId, input.actionId); + if (replay) return response(input.userId, replay); + + const key = `${input.userId}:${input.actionId}`; + let pending = regenerationInFlight.get(key); + if (!pending) { + pending = (async () => { + const secondReplay = await store.loadActionCase(input.userId, input.actionId); + if (secondReplay) return secondReplay; + const current = await store.loadCase(input.userId, input.caseId); + if (!current?.currentQuestion || current.deploymentMode !== "v5_agent") return null; + const validated = await store.loadLatestValidatedDecision(input.userId, input.caseId); + const opportunity = validated?.selectedOpportunity; + if (!opportunity) return null; + const [events, turns] = await Promise.all([ + store.loadEvents(input.userId, input.caseId), + store.loadTurns(input.userId, input.caseId), + ]); + const prompt = await realizeQuestion({ + caseValue: current, + currentPrompt: current.currentQuestion.prompt, + latestAnswer: turns.at(-1)?.answer ?? "", + acceptedEvents: events, + opportunity, + }); + return store.replaceCurrentQuestion({ + ...input, + question: { ...current.currentQuestion, id: randomUUID(), prompt }, + now: now().toISOString(), + }); + })(); + regenerationInFlight.set(key, pending); + } + try { + const saved = await pending; + return saved ? response(input.userId, saved) : null; + } finally { + if (regenerationInFlight.get(key) === pending) regenerationInFlight.delete(key); + } }, async reviseEvent(input: { diff --git a/frontend/src/lib/rectification-v4/memory-store.ts b/frontend/src/lib/rectification-v4/memory-store.ts index 37f6522c..154787c5 100644 --- a/frontend/src/lib/rectification-v4/memory-store.ts +++ b/frontend/src/lib/rectification-v4/memory-store.ts @@ -77,6 +77,10 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & { .sort((left, right) => right.caseVersion - left.caseVersion || right.createdAt.localeCompare(left.createdAt))[0]; return latest ? validatedDecisions.get(latest.jobId) ?? latest.validatedDecision : null; }, + async loadActionCase(userId, actionId) { + const replay = actionResults.get(`${userId}:${actionId}`); + return replay ? owned(userId, replay.caseId) : null; + }, async createCase(input) { const replay = actionResults.get(`${input.case.userId}:${input.actionId}`); if (replay) return owned(input.case.userId, replay.caseId); diff --git a/frontend/src/lib/rectification-v4/store.ts b/frontend/src/lib/rectification-v4/store.ts index 1601d21c..39d59707 100644 --- a/frontend/src/lib/rectification-v4/store.ts +++ b/frontend/src/lib/rectification-v4/store.ts @@ -46,6 +46,7 @@ export interface RectificationV4Store { loadEvents(userId: string, caseId: string): Promise; loadTurns(userId: string, caseId: string): Promise; loadLatestValidatedDecision(userId: string, caseId: string): Promise; + loadActionCase(userId: string, actionId: string): Promise; createCase(input: { readonly case: RectificationV4Case; readonly actionId: string }): Promise; replaceCurrentQuestion(input: { readonly userId: string; diff --git a/frontend/src/lib/rectification-v4/supabase-store.ts b/frontend/src/lib/rectification-v4/supabase-store.ts index 3ae3e1d9..8b1c8b28 100644 --- a/frontend/src/lib/rectification-v4/supabase-store.ts +++ b/frontend/src/lib/rectification-v4/supabase-store.ts @@ -208,6 +208,12 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re if (error) throw storeError(error); return data ? validatedDecisionSchema.parse((data as Row).validated_decision_json) : null; }, + async loadActionCase(userId, actionId) { + const { data, error } = await supabase.from("birth_time_rectification_v4_actions") + .select("case_id").eq("user_id", userId).eq("action_id", actionId).maybeSingle(); + if (error) throw storeError(error); + return data ? loadCaseById(userId, String((data as Row).case_id)) : null; + }, async createCase(input) { const id = String(await rpc("create_birth_time_rectification_v5_case", { p_user_id: input.case.userId, diff --git a/frontend/tests/rectification-v4-service.test.ts b/frontend/tests/rectification-v4-service.test.ts index 40c5296a..9aa8068a 100644 --- a/frontend/tests/rectification-v4-service.test.ts +++ b/frontend/tests/rectification-v4-service.test.ts @@ -151,7 +151,15 @@ test("legacy cases are not hard-switched to V5 even when flags change later", as test("V5 Agent regenerate rewrites only the current semantic question and replays the same action once", async () => withMode("v5_agent", async () => { const store = createRectificationV4MemoryStore(); - const service = createRectificationV4CaseService(store, { now: fixedNow }); + let realizationCalls = 0; + const service = createRectificationV4CaseService(store, { + now: fixedNow, + regenerateQuestion: async ({ opportunity }) => { + realizationCalls += 1; + await new Promise((resolve) => setTimeout(resolve, 5)); + return opportunity.fallbackPrompt; + }, + }); const userId = randomUUID(); const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); const queued = await service.answer({ @@ -172,13 +180,19 @@ test("V5 Agent regenerate rewrites only the current semantic question and replay const before = await service.loadCase(userId, created.case.id); assert.ok(before?.case.currentQuestion); const actionId = randomUUID(); - const regenerated = await service.regenerateQuestion({ + const regenerationInput = { userId, caseId: created.case.id, actionId, expectedCaseVersion: before.case.version, - }); + }; + const [regenerated, concurrentReplay] = await Promise.all([ + service.regenerateQuestion(regenerationInput), + service.regenerateQuestion(regenerationInput), + ]); assert.ok(regenerated?.case.currentQuestion); + assert.equal(concurrentReplay?.case.currentQuestion?.id, regenerated.case.currentQuestion.id); + assert.equal(realizationCalls, 1); assert.equal(regenerated.case.version, before.case.version + 1); assert.notEqual(regenerated.case.currentQuestion.id, before.case.currentQuestion.id); assert.equal(regenerated.case.currentQuestion.domain, before.case.currentQuestion.domain); @@ -197,6 +211,7 @@ test("V5 Agent regenerate rewrites only the current semantic question and replay }); assert.equal(replayed?.case.version, regenerated.case.version); assert.equal(replayed?.case.currentQuestion?.id, regenerated.case.currentQuestion.id); + assert.equal(realizationCalls, 1); assert.equal(store.jobs.size, 1); assert.equal(regenerated.case.latestSnapshot?.canConfirmExactMinute ?? false, false); }));