diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 69257fe0..e7118981 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -329,3 +329,19 @@ - 相关记录:BUG-009、BUG-017 - 复发自:无 - 修复版本:待提交 + +## BUG-019 | 原问题交接租约过期后永久显示处理中 + +- 状态:resolved +- 首次发现:2026-07-22 +- 最近更新:2026-07-22 +- 影响面:生时校正范围终态、跨设备/失败重试、durable 原问题 handoff +- 用户现象:一次继续咨询在输出前失败后,刷新仍无法重试,接口返回“原问题状态已经变化”。 +- 触发条件:handoff 处于 `claimed` 或 `executing`,两分钟租约已经过期,客户端重新加载交接。 +- 根因:数据库 projection 只看持久化 state,把过期租约仍投影为 `in_progress`;客户端因此不会发起 claim,而执行 RPC 又正确拒绝过期租约,形成永久卡死。 +- 修复:projection 将已过期的 `claimed`/`executing` 租约投影为 `pending`,复用既有加锁 claim RPC 原子接管;未过期租约仍保持 `in_progress`,双设备隔离不变。 +- 验证:迁移契约测试锁定过期租约恢复语义;生产制造过期 claim 后重新加载、claim、执行模型咨询和 settle。 +- 防复发:所有租约型状态的读取投影必须同时解释 lease deadline,不允许只依赖枚举 state。 +- 相关记录:BUG-017、BUG-018 +- 复发自:无 +- 修复版本:待提交 diff --git a/frontend/supabase/migrations/20260722120000_recover_expired_rectification_handoff.sql b/frontend/supabase/migrations/20260722120000_recover_expired_rectification_handoff.sql new file mode 100644 index 00000000..62b5d02f --- /dev/null +++ b/frontend/supabase/migrations/20260722120000_recover_expired_rectification_handoff.sql @@ -0,0 +1,36 @@ +begin; + +create or replace function public.conversational_rectification_handoff_projection( + p_user_id uuid, + p_case_id uuid +) +returns jsonb +language sql +stable +security definer +set search_path = '' +as $$ + select pg_catalog.jsonb_build_object( + 'caseId', h.case_id, + 'turnVersion', c.turn_version, + 'question', h.question, + 'questionFingerprint', h.question_fingerprint, + 'requestId', h.request_id, + 'status', case + when h.state = 'pending' then 'pending' + when h.state in ('claimed', 'executing') + and h.lease_expires_at <= pg_catalog.now() then 'pending' + when h.state in ('claimed', 'executing') then 'in_progress' + else 'consumed' + end, + 'turn', public.conversational_rectification_case_projection( + p_user_id, p_case_id + ) -> 'latest_turn' + ) + from public.birth_time_rectification_question_handoffs h + join public.birth_time_rectification_cases c + on c.id = h.case_id and c.user_id = h.user_id + where h.case_id = p_case_id and h.user_id = p_user_id; +$$; + +commit; diff --git a/frontend/tests/rectification-handoff-service.test.ts b/frontend/tests/rectification-handoff-service.test.ts index 7f738943..5dea6bbc 100644 --- a/frontend/tests/rectification-handoff-service.test.ts +++ b/frontend/tests/rectification-handoff-service.test.ts @@ -201,3 +201,16 @@ test("migration keeps claim, billing recovery, settlement and ACL inside locked assert.match(sql, new RegExp(`grant execute on function public\\.${functionName}\\([\\s\\S]*?to service_role`, "i")); } }); + +test("expired handoff leases project as pending so another device can reclaim them", () => { + const sql = readFileSync(new URL( + "../supabase/migrations/20260722120000_recover_expired_rectification_handoff.sql", + import.meta.url, + ), "utf8"); + + assert.match( + sql, + /h\.state in \('claimed', 'executing'\)[\s\S]*h\.lease_expires_at <= pg_catalog\.now\(\)[\s\S]*then 'pending'/i, + ); + assert.match(sql, /create or replace function public\.conversational_rectification_handoff_projection/i); +});