fix(rectification): adopt/gate turns use uuid request ids; post-adopt checks carry the state probe id (BUG-1149, BUG-1150)
Independent Staging Quality Gate / validate (push) Failing after 13m1s
Independent Staging Quality Gate / publish (push) Skipped

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-10-01 19:38:10 +08:00
co-authored by Claude Opus 5.5
parent dc45fde613
commit 28f4df0e07
8 changed files with 126 additions and 9 deletions
+6
View File
@@ -1,5 +1,11 @@
# 印度占星 Skill 更新日志
## 2026-10-01 — 采用后的核对题可以作答了;交付收尾那句话不再丢
- 修复:采用后的核对题点任何选项都提示「这次没提交上」。现在能正常作答(BUG-1150)。
- 修复:采用后的单独消息、交付时补写的那句收尾话之前都没能存进数据库(编号格式不对,被拒后没有报错)。现在都能存下,刷新也在(BUG-1149)。
- 修复前已经出现的核对题仍答不了,可点「这题跳过」或新开一次校正。Skill 版本不变;不改数据库结构。
## 2026-10-01 — 首页开场语换成知心姐姐口吻;今日星语说清为什么
- 开场语:深夜不再说「夜猫子」「还没睡呀」,改成「这会儿很安静」「夜里好,{名字}」「慢慢说,{名字}」;其余时段同样改成关心的口吻,例如「中午好,记得吃饭」「今天辛苦了」「周五了,松口气」。
+32 -1
View File
@@ -15394,7 +15394,7 @@
## BUG-1146 | 采用后的核对题只有题干、没有选项按钮,刷新也不出;题挂在上一题的旧回复下
- 状态:resolved(2026-10-01 Claude 直接执行;单元与客户端回归测试;部署与真机复核以部署后 health 与 `docs/testing/rectification-post-adopt-card-20261001.md` 为准)
- 状态:resolved(2026-10-01 Claude 直接执行;单元与客户端回归测试。**第一版 `282455cc` 真机仍挂错位置**:采用轮次的 requestId 不是 uuid,真库拒写、被吞(BUG-1149),补修后真库重放通过;真机复核以 `docs/testing/rectification-post-adopt-card-20261001.md` 为准)
- 首次发现 / 最近更新:2026-10-01 / 2026-10-01
- 影响面:`v9/method-followup.ts`(已采用时承接活焦点的 keep 分支)、`candidates/accept/route.ts`、`v9/answer-choice.ts::writeAdoptFollowupTurn`(新)、`rectification-chat-accept-run.ts`。所有点「采用」后出现核对题的校正,盘型口径之前也会。
- 用户现象:真机点「采用」后出现「2016 年 9 月那次升学…」题干,下面没有四个按钮;刷新后仍然没有。题干挂在答上一题那条消息(「已记录,范围没变;…」)下面。
@@ -15436,3 +15436,34 @@
- 相关记录:BUG-1132(人话自检)。
- 复发自:无
- 修复版本:`codex/consult-term-glossary-20261001`。
## BUG-1149 | 采用轮次与交付收尾轮次的请求号不是 uuid,真库拒写且被吞
- 状态:resolved(2026-10-01 Claude 直接执行;本机真 PostgreSQL 17 重放:修前报 uuid 语法错误,修后采用轮次写入成功)
- 首次发现 / 最近更新:2026-10-01 / 2026-10-01
- 影响面:`v9/answer-choice.ts::writeAdoptFollowupTurn`(BUG-1146 新增)、`exhaustionGateRequestId` → `persistExhaustionGateTurn`(`turn-exit.ts`、`agent-run-finish.ts`、`ensureNonTerminalTurnExit` 三处调用)。
- 用户现象:BUG-1146 部署后真机点「采用」,核对题仍挂在上一题「已记录,范围收到…」下,没有单独的采用消息;交付收尾那句(无轮次时补写的旁白)在库里从来没有,刷新或重挂后就不见了。
- 触发条件:每次采用;每次走交付收尾补写。
- 根因:`append_agentic_rectification_turn` 现行(V10)重载的 `p_request_id` 是 uuid;`<请求号>:adopt` 与 `<轮次号或案例号>:gate` 都不是 uuid,PostgreSQL 报 `invalid input syntax for type uuid`。两处调用方都在 try/catch 里只打一行 warn,交付标记却已经打上,所以只读续跑也不写轮次。假数据库测试不校验类型(BUG-1146 的单元测试断言的正是错误的 `req-1:adopt`)。
- 修复:`deterministicTurnRequestId(seed)` 用 sha256 派生 v5 形 uuid(同一种子同一 uuid,重试仍幂等);`adoptTurnRequestId`、`exhaustionGateRequestId` 改用它。
- 验证:`frontend/tests/rectification-post-adopt-card-20261001.test.ts`(两种请求号符合 uuid 格式且同种子稳定;采用轮次断言改为 `adoptTurnRequestId("req-1")`——原值 `req-1:adopt` / 新值派生 uuid / 原因:原值在真库必然失败)。本机 PG17 + 真引擎重放虚构案例:修前 `invalid input syntax for type uuid: "<uuid>:adopt"`,修后 `adopt_turn` 写入。
- 防复发:写轮次的请求号一律 uuid;新增确定性轮次必须在真库(PG 替身)至少跑一次。
- 相关记录:BUG-1146、BUG-596、BUG-1150。
- 复发自:无(`:gate` 自 BUG-596 一侧引入后一直未在真库写成功过)
- 修复版本:`codex/rectification-post-adopt-verify-20261001`。
## BUG-1150 | 采用后核对题点任何选项都报「这次没提交上」
- 状态:resolved(2026-10-01 Claude 直接执行;本机真 PostgreSQL 17 重放:修前 `stale_probe`,修后作答成功并收尾)
- 首次发现 / 最近更新:2026-10-01 / 2026-10-01
- 影响面:`v9/server-focus.ts::expectedAnswerSchemaFor`(reverse_verify / out_of_sample_check 的探针身份)。所有计分的采用后核对题。
- 用户现象:采用后核对题四个按钮出现了,点任一个都弹「这次没提交上,请再点一次。」,再点也一样。
- 触发条件:采用后核对题计分(`scoring` 为真),且推断态里该探针的 id 带拆分哈希。
- 根因:核对题盖章时不看推断态(BUG-912 防串题),`probe_id` 写成 `probe:<语义键>`;推断态里同一探针的 id 是 `probe:<语义键>:<拆分哈希>`。作答时前端层按语义键找到探针、用带哈希的 id 写转移,数据库 `persist_agentic_rectification_inference_transition` 发现它与活焦点 schema 的 `probe_id` 不同,拒为 `agentic_rectification_stale_probe`,接口回非 200。
- 修复:核对题盖章时,推断态里若有**同语义键、同拆分哈希**的那一道,就写它的 id;找不到时保持原样(仍不盖无关的高增益探针,BUG-912 不放宽)。
- 验证:`frontend/tests/rectification-post-adopt-card-20261001.test.ts`「a scoring post-adopt check carries the state's probe id」(修前红:实际 `probe:career.2018.dasha_activation`)。本机 PG17 重放虚构案例:修前 `stale_probe`,修后 `applied: true`、收尾句「前事核对到这里…」。
- 防复发:计分题的 schema `probe_id` 必须与推断态 id 同形;采用后流程的回归需在真库走到「作答」一步。
- 已存在的卡:修复前已落库的核对题仍是旧 id,作答仍会被拒;可点「这题跳过」,或新开一次校正。
- 相关记录:BUG-912、BUG-498、BUG-1146、BUG-1149。
- 复发自:无
- 修复版本:`codex/rectification-post-adopt-verify-20261001`。
@@ -42,3 +42,16 @@
| `tests/test_repo_privacy_markers.py` | — | 通过 |
Python 快速门:本单不改 `scripts/**`、`tests/**`,未跑。`test:db`:不改表与 SQL,所用 RPC 采用后已有调用路径,未跑;部署后真机清单覆盖。
## 第二轮(真机回报后,BUG-1149~1150)
真机:采用后核对题仍挂在上一题下面;点任何选项都「这次没提交上」。本机真 PostgreSQL 17 + 真引擎重放虚构案例复现两处:
| 步骤 | 修前 | 修后 |
| --- | --- | --- |
| 写采用轮次 | `invalid input syntax for type uuid: "<uuid>:adopt"`(被吞) | 写入 |
| 作答核对题 | `agentic_rectification_stale_probe`(schema `probe:<键>` ≠ 推断态 `probe:<键>:<哈希>`) | `applied: true`,收尾句 |
同根问题:交付收尾补写轮次的 `<id>:gate` 请求号也不是 uuid,一并修。真机反映的「选中后旁白和卡片消失一会儿」本机无浏览器未复现;采用路径现在不再发只读续跑、收尾句能落库,待真机复测,复现则请录屏。
第二轮门禁(`52495df7`,基线 staging `fa6d026f`):tsc 0、lint 0 error、用例 4866(失败 24 条与 f1a169cb 基线逐条一致、cancelled 0;唯一「缺失」名为 c0f05912 改名)、`/` ○ Static、首屏 gzip JS 555,642、镜像 COPY 布局构建通过;真库重放 8 条中可采用的 1 条:采用轮次写入、作答成功。
+1 -1
View File
@@ -265,7 +265,7 @@
| `TASK-rectification-chart-tier-caveats-20261001.md` | — | **报告与聊天标注判断不了的分盘**:采用后读 `active_birth_provenance`(segment-v1)→ 结果 `segment_summary` 档位,blocked/indistinct 的 D9/D10 在聊天 answer_policy 与一句说明、报告章节顶部提示 + 主题降级;旧资料逐字节不变;不改库、不改 consult/route.ts | 待领取 | BUG-1138~1139 |
| `TASK-rectification-angle-timing-research-20261001.md` | `PROGRESS-rectification-angle-timing-research-20261001.md` | **角度类时间技法研究(离线)**:行运土木罗压本命上升 / 天顶度数、次限推运角、太阳弧角(1° ≈ 4 分钟出生时间),77 例 v5;乱序日期对照 ≥ 50 次必做、留一法、不得挤出真值;显著才叠加到六题后验比头段命中与区间宽度 | 已实现待验收(0/9 预登记检验显著,BUG-1141 closed_by_design;分支 codex/rectification-angle-timing-research-20261001,未推送) | BUG-1141 |
| (直接执行,无任务书) | `PROGRESS-rectification-segment-order-20261001.md` | **健康区分题写入失败(BUG-1143)+ 按盘型挑题默认开启(BUG-1144)**:区分类焦点把 `health_pressure` 原样写入、违反表约束、写入被吞后提前交付(BUG-672 同族);修后 77 例真库回放平均问题数 ±10 2.8→4.6、±30 3.6→5.4,D10 头段 ±30 39→52;按段选题 ≤61 分钟默认开,±30 D9 46→51、D10 52→55,真值段 76/76 | 已验收(Claude 10-01 直接执行) | 分支 codex/rectification-segment-order-20261001 |
| (直接执行,无任务书) | `PROGRESS-rectification-post-adopt-card-20261001.md` | **采用后核对题无按钮(BUG-1146)+ 盘型口径回复念分钟排名(BUG-1147)**:keep 分支丢探针身份致 GET 空卡(BUG-498 复发);采用接口不写轮次、只读运行被 60 秒交付窗口挡掉,题挂到上一题回复下;改为采用接口写「已采用 HH:MM。再核对一件过去的事。」轮次并挂题。盘型口径下去掉「领先 / 落后」、交付旁白不再说「分不开」、边界句只说一次 | 已验收(Claude 10-01 直接执行:门禁对基线 f1a169cb 一致);真机清单 `docs/testing/rectification-post-adopt-card-20261001.md` | 分支 codex/rectification-post-adopt-verify-20261001 |
| (直接执行,无任务书) | `PROGRESS-rectification-post-adopt-card-20261001.md` | **采用后核对题无按钮(BUG-1146)+ 盘型口径回复念分钟排名(BUG-1147)**:keep 分支丢探针身份致 GET 空卡(BUG-498 复发);采用接口不写轮次、只读运行被 60 秒交付窗口挡掉,题挂到上一题回复下;改为采用接口写「已采用 HH:MM。再核对一件过去的事。」轮次并挂题。盘型口径下去掉「领先 / 落后」、交付旁白不再说「分不开」、边界句只说一次 | 已验收(Claude 10-01 直接执行);真机回报后补修 BUG-1149(请求号非 uuid 被真库拒写)、BUG-1150(核对题作答 stale_probe),本机真库重放通过;真机清单 `docs/testing/rectification-post-adopt-card-20261001.md` | 分支 codex/rectification-post-adopt-verify-20261001 |
| `TASK-rectification-typed-event-scoring-research-20260929.md` | `PROGRESS-rectification-typed-event-research-20260929.md` | **打字经历按选择题规则计分(离线研究,不上线)**:计分通道不对称 + 已入账年份挡题;R0 学业质量题措辞 / 年精度显示成 1 月(冻结文件,需重新冻结) | 已验收关单(Claude 10-01:三类技法 9 项检验 0 显著,真值平均排位 0.49~0.59;Claude 补正对照——埋入信号排位 0.077、挪年 0.502——流程有效;BUG-1141 closed_by_design) | BUG-1088、1089 |
| `TASK-report-reader-polish-20260929.md` | `PROGRESS-report-reader-polish-20260929.md` | **报告页打磨**:生成入口挪进页面主体(删标题栏按钮)、详情页到底部按钮(懒渲染一次到底)、导出按钮带文字、目录一级/二级分层、去掉「字段」与 RL/NL 缩写表头、状态列同义重复去重、报告表格淡底色、分块导出显示真实文件大小 | Claude 直接执行并自验(tsc 0、lint 0 error、全量 fail 与基线同 24 条、`/` Static、gzip +7 B、快速门 Python 1000 passed),已部署 staging `d7772011`,真机欠 | BUG-1092~1094 |
| `TASK-report-english-edition-20260929.md` | `PROGRESS-report-english-edition-20260929.md` | **报告中英两版**:同一次引擎计算渲染 zh/en 两遍(不用模型翻译),瑜伽库 477 条补英文、模板与前端表头英文化;中文逐字节不变、英文零汉字、两版数字序列一致;阅读页 `?lang=en` 切换,导出当前语言 + 「问 AI 建议导出英文版」提示;旧报告不补英文 | Claude 直接执行(含两个 fork 子代理)并自验(tsc 0、lint 0 error、全量 fail 与基线同 24 条、`/` Static、gzip +0.06%、Python 定向全绿),已部署 staging `d7772011`,真机欠 | — |
@@ -1,6 +1,6 @@
# 真机清单:采用后的核对题(2026-10-01,BUG-1146~1147)
> staging 部署后,用自己的账号在手机(375px 宽)走一遍。用虚构经历即可。
> staging 部署后,用自己的账号在手机(375px 宽)走一遍。用虚构经历即可。**请新开一次校正**——修复前已出现的核对题仍答不了(可点「这题跳过」)。
1. **走到交付**:新开一次生时校正,答到出现交付卡(每张盘的上升 + 档位)。
- 交付卡旁边的话里**没有**「这几个候选按现有信息分不开」;
@@ -10,7 +10,8 @@
- 出现一条新的助手消息「已采用 HH:MM。再核对一件过去的事。」,不是挂在上一题「已记录…」下面;
- 如果有核对题,题目下有**四个按钮**,可以点。
4. **刷新**:刷新页面,核对题还在、四个按钮还在、仍挂在那条采用消息下面。
5. **答核对题**:点一个按钮,能正常进入下一题或收尾,不报错。
5. **答核对题**:点一个按钮,不出现「这次没提交上」,能进入下一题或看到「前事核对到这里…」。
5a. **选中时**:点「更像这个」后,上面的旁白和卡片不应消失;如果仍消失一下,请录屏发给 Claude。
6. **旧校正会话**:从会话列表打开一个以前的校正会话,能打开、不报错。
有任何一项不符,截图发给 Claude。
@@ -6,6 +6,7 @@
* returns a fallback narration in one receipt.
*/
import { createHash } from "node:crypto";
import { posteriorMap, scoreDeltas } from "../core/decision-fingerprint";
import { productDeliveryForDomain } from "./product-delivery.ts";
import { nextProbe } from "../core/build-state";
@@ -188,7 +189,7 @@ function exhaustionCarrierNarration(range: string | null | undefined, gate: stri
}
export function exhaustionGateRequestId(askedTurnId: string | null | undefined, caseId: string): string {
return `${askedTurnId ?? caseId}:gate`;
return deterministicTurnRequestId(`gate:${askedTurnId ?? caseId}`);
}
export async function persistExhaustionGateTurn(input: {
@@ -1621,6 +1622,21 @@ export async function persistCollectDenialTurn(input: {
return { streamText, turnId: turn.turnId };
}
/**
* `append_agentic_rectification_turn` keys idempotency on a uuid `p_request_id`; a suffixed
* id (「<uuid>:adopt」「<uuid>:gate」) is rejected by PostgreSQL (BUG-1149). Same seed → same uuid,
* so a retried request still lands on the same turn.
*/
export function adoptTurnRequestId(requestId: string): string {
return deterministicTurnRequestId(`adopt:${requestId}`);
}
export function deterministicTurnRequestId(seed: string): string {
const hex = createHash("sha256").update(`rectification-turn:${seed}`).digest("hex");
const variant = ((parseInt(hex[16]!, 16) & 0x3) | 0x8).toString(16);
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-${variant}${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
}
/**
* BUG-1146: after 采用 the first post-adopt check is persisted by persistNextInterviewIfIdle, but the
* accept route wrote no turn. The read_only agent call that follows short-circuits as
@@ -1645,7 +1661,7 @@ export async function writeAdoptFollowupTurn(input: {
&& (focus.intent === "reverse_verify" || focus.intent === "out_of_sample_check"));
// The stem leaves the body once the card attaches; the lead is what the message says.
const turn = await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
requestId: `${input.requestId}:adopt`,
requestId: adoptTurnRequestId(input.requestId),
userMessage: null,
assistantMessage: check ? `${postAdoptCheckLead(input.savedTime)}${narration}` : narration,
});
@@ -149,6 +149,14 @@ export function expectedAnswerSchemaFor(
if (typeof followup.probe_year === "number" && followup.probe_year > 0) {
stamped.probe_year = followup.probe_year;
}
// BUG-1150: a scoring post-adopt check names its own probe. When the state holds that probe
// (same semantic key and split), carry the state's id — split-hashed ids are
// 「probe:<key>:<split>」 and the transition RPC rejects the bare 「probe:<key>」 as stale.
if (verifyOnly && state && followup.semantic_key) {
const own = state.probes.find((probe) => probe.semantic_key === followup.semantic_key
&& (!followup.candidate_split_hash || probe.candidate_split_hash === followup.candidate_split_hash));
if (own) stamped.probe_id = own.id;
}
if (verifyOnly || targetedCollect) return stamped;
return state && stamped.scoring !== false && !schemaProbeId(stamped) ? null : stamped;
}
@@ -2,8 +2,9 @@ import assert from "node:assert/strict";
import test from "node:test";
import { buildMethodFollowupPlan, projectRectificationChoiceCard } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
import { persistServerOwnedFocus } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { writeAdoptFollowupTurn } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { expectedAnswerSchemaFor, persistServerOwnedFocus } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts";
import { adoptTurnRequestId, exhaustionGateRequestId, inferenceForPersistedAnswer, writeAdoptFollowupTurn } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { composeChoiceNarration } from "../src/lib/rectification-agentic/v9/choice-action.ts";
import { alreadyDelivered, resetDeliveryTurnGuardForTests } from "../src/lib/rectification-agentic/v9/delivery-turn-guard.ts";
import { deliveryTurnNarration, REPRESENTATIVE_MINUTE_DISCLAIMER } from "../src/lib/rectification-agentic/user-copy.ts";
@@ -104,7 +105,7 @@ test("采用 writes its own turn, hangs the post-adopt check on it and quiets th
});
assert.deepEqual(out, { turnId: "44444444-4444-4444-8444-444444444444" });
const turn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
assert.equal(turn?.args.p_request_id, "req-1:adopt");
assert.equal(turn?.args.p_request_id, adoptTurnRequestId("req-1"));
assert.equal(turn?.args.p_user_message, null);
assert.equal(turn?.args.p_assistant_message, "已采用 05:00。再核对一件过去的事。2018 年前后,有没有入职或职责明显加重?");
const link = accounting.calls.find((call) => call.fn === "set_agentic_rectification_conversation_focus");
@@ -184,3 +185,44 @@ test("client: no adopt turn (idempotent accept) keeps the read_only continuation
assert.equal(recorder.sends.length, 1);
assert.equal(recorder.sends[0]!.action, "read_only");
});
// BUG-1149: real PostgreSQL types p_request_id as uuid; 「<id>:adopt」/「<id>:gate」 raised 22P02,
// the accept route swallowed it, and the gate narration never persisted.
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
test("deterministic turn request ids are uuids and stable per seed", () => {
const adopt = adoptTurnRequestId("66666666-6666-4666-8666-666666666666");
assert.match(adopt, UUID);
assert.equal(adopt, adoptTurnRequestId("66666666-6666-4666-8666-666666666666"));
assert.notEqual(adopt, adoptTurnRequestId("77777777-7777-4777-8777-777777777777"));
const gate = exhaustionGateRequestId("55555555-5555-4555-8555-555555555555", CASE);
assert.match(gate, UUID);
assert.equal(gate, exhaustionGateRequestId("55555555-5555-4555-8555-555555555555", CASE));
assert.match(exhaustionGateRequestId(null, CASE), UUID);
assert.notEqual(adopt, adoptTurnRequestId("66666666-6666-4666-8666-666666666666:x"));
});
// BUG-1150: post-adopt checks were stamped 「probe:<key>」 while the state holds the split-hashed
// 「probe:<key>:<split>」; the transition RPC rejected every answer as stale_probe.
test("a scoring post-adopt check carries the state's probe id", () => {
const plan = buildMethodFollowupPlan({
evidence: EVIDENCE as never, accepted: true, sessionOutcome: "adopt_representative",
selectionAllowed: true, proposeAllowed: true, eventProbes: [DATED_CHECK],
} as never);
const followup = plan.next_followup!;
assert.equal(followup.intent, "reverse_verify");
const stateId = "probe:career.2018.dasha_activation:career.2018.dasha_activation";
const inference = {
algorithm_version: INFERENCE_ALGORITHM_VERSION, candidate_set_id: "04:58-05:06:05:00,05:04", revision: 6,
phase: "discrimination", result_status: "discriminating", range_start: "04:58", range_end: "05:06",
candidates: ["05:00", "05:04"].map((time, index) => ({ id: time, time, cluster_range: ["04:58", "05:06"], prior_score: 10,
posterior_score: 10, probability: 0.5, status: "active", rank: index + 1, strong_conflict_count: 0 })),
events: [], answered_probes: [], rounds: [], entropy: 1, representative_time: "05:00", credible_range: ["04:58", "05:06"],
probes: [{ ...DATED_CHECK, id: stateId, question: "2018", information_gain: 0.5, year: 2018 }],
};
const receipt = { inference_state: inference };
assert.ok(inferenceForPersistedAnswer(receipt)?.probes.some((probe) => probe.id === stateId), "fixture state parses");
const schema = expectedAnswerSchemaFor(followup.choice_frame!, followup.choice_frame!.question_id, receipt, followup);
assert.equal(schema?.probe_id, stateId);
const bare = expectedAnswerSchemaFor(followup.choice_frame!, followup.choice_frame!.question_id, null, followup);
assert.equal(bare?.probe_id, "probe:career.2018.dasha_activation", "no state: the named key, as before");
});