Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
230a493fcd | ||
|
|
bc4a6c2b49 | ||
|
|
87765d602c | ||
|
|
c881dfb4ae | ||
|
|
6721d320d8 | ||
|
|
4ab81821cd |
@@ -1,5 +1,34 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-11-02 — 报告生成中途进程重启,失败后点数会退回
|
||||
|
||||
- 报告任务最后一次尝试时,如果进程重启或卡住、租约过期,报告会被标为失败。以前这种情况不退点,只有删掉这份报告才退。现在回收时同时退点,个人报告、合盘、年运都一样。
|
||||
- 退点只退一次:回收、报告任务自己的失败处理、删除报告三处用的是同一个退点记录,重复调用不会多退。已经交付的报告不退。
|
||||
- 退点请求本身失败时不再忽略:
|
||||
- 报告:任务先不标失败,写一条 `errorCode=report_refund_failed` 的日志,等租约过期后由回收重试;最后一次尝试时,回收在同一个事务里退点并标失败,退点不成功就留到下一轮。
|
||||
- 生时校正:同一个退点请求再试一次,还不行就写 `errorCode=rectification_refund_failed` 的日志。点数留在这个校正案例上,下一轮继续用,不会再扣一次。
|
||||
- 数据库迁移 `20261102010000_report_lease_expiry_release.sql`,只替换回收函数,不改表。
|
||||
- Skill 版本不变(BUG-1505)。
|
||||
|
||||
## 2026-11-02 — 改出生资料时,时区没查到不再清掉出生地
|
||||
|
||||
- 修改已存人物的出生日期、时间或地点后,如果时区这会儿查不到,原来的出生地会留着,地点下面写「时区这会儿没查到,原来的出生地还在,查到后才能保存。」,旁边有「重试」。查到之前「保存」不能点,所以不会把旧日期的时区存进新日期;改回原来的日期和地点也可以直接保存。
|
||||
- 新建人物时查不到时区,仍是「时区服务暂时不可用,稍后重试即可。」加「重试」,「确定」不能点。
|
||||
- 顺带修了同一个原因的两处:编辑时改省份、城市或国家,选到一半不会再跳回空白要重选;打开海外人物的编辑,不会再被清空并跳回中国。
|
||||
- 改日期查到新时区后又改回原日期,会重新查一次,不再沿用刚才那个日期的时区。
|
||||
- 记为 BUG-1506。Skill 版本不变。
|
||||
|
||||
## 2026-11-02 — VedAstro 卡住或断线时,报告不再失败
|
||||
|
||||
- 生成报告时,VedAstro 最多等 25 秒(从开始计算时算起,和本地计算同时进行),前端的等待是 90 秒。等不到就照常出报告,报告里的 VedAstro 一栏标「官方外部引擎本次未验证或不可用」,不会写成已验证。
|
||||
- 这次失败会记到当天:同一张盘当天再生成报告、或者同一份报告的下一个主题,直接用这条记录,不再等 VedAstro。之后 VedAstro 恢复并返回结果,就改用新结果。
|
||||
- 报告不再先跑一遍 VedAstro 主入口概览(按主题的区间扫描)。官方 VedAstro 证据仍然有,来自官方快照,按时返回就照常写进报告。
|
||||
- VedAstro 返回到一半连接断开(读响应时连接被重置或截断)时,按网络错误处理:报告照常出、VedAstro 标未验证;`/api/chart` 不带跳过概览的参数时也返回 200,不再报 500。
|
||||
- 对话、星盘页的行为不变。
|
||||
- 可调参数:`JYOTISH_REPORT_VEDASTRO_DEADLINE_SECONDS`(默认 25,最多 45)。
|
||||
- Skill 版本不变(Gitea #119 第 42 项缺口 2、3;BUG-1508、BUG-1504)。
|
||||
|
||||
|
||||
## 2026-11-01 — 年运报告:从「我的报告」直接打开,写好一节出一节
|
||||
|
||||
- 「我的报告」多了一张「年运」卡片,点进去就是年运报告页,没有单独的入口页。个人报告卡片位置不变。
|
||||
|
||||
+65
-29
@@ -20573,51 +20573,65 @@
|
||||
|
||||
## BUG-1504 | VedAstro 响应读到一半连接断开时,报告生成与不带 skip 的排盘返回 500
|
||||
|
||||
- 状态:investigating
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-11-01(Gitea #119 第 42 项,外部依赖故障审计)
|
||||
- 最近更新:2026-11-01
|
||||
- 最近更新:2026-11-02
|
||||
- 影响面:同步调 VedAstro 官方快照的路径,包括个人报告生成(排盘主入口概览、生时门禁)和不带 `skip_vedastro_main_entry_overview` 的 `/api/chart`。星盘页、对话不受影响:前者总是跳过,后者在后台线程里调用。
|
||||
- 用户现象:报告以 `workflow_server_error` 失败(会退点);接口返回 `ERR_INTERNAL`。
|
||||
- 触发条件:VedAstro 已经返回响应头,但响应体读到一半连接被重置或截断(`http.client.IncompleteRead`、`ConnectionResetError`、`ssl.SSLError`)。
|
||||
- 根因(读码 + 本机 stub 确认 IncompleteRead):`scripts/vedastro_service_adapter.py` 的快照分发(约 2782–2808 行)和区间扫描(约 3010–3046 行)只接住 `HTTPError`、`URLError`、`RemoteDisconnected`、超时和 `JSONDecodeError`;`_attach_vedastro_main_entry_overview` 外面也没有 try,所以异常一直冒到请求总入口。
|
||||
- 修复:未修。本轮只审计,修法待产品决定是否排期(把这几类读错误归为 `network_error`)。
|
||||
- 验证:本机把 `urlopen` 换成读响应时抛 `IncompleteRead` 的 stub,调用排盘得到 500。脚本是临时的,没有入库;做法见 `docs/operations/outage-behaviour-2026-11-01.md` 第一节。
|
||||
- 防复发:待修复单补「读错误降级、不 500」的测试。
|
||||
- 相关记录:Gitea #119 第 42 项;`docs/operations/outage-behaviour-2026-11-01.md`。
|
||||
- 触发条件:VedAstro 已经返回响应头,但响应体读到一半连接被重置或截断(`http.client.IncompleteRead`、`ConnectionResetError`、`ssl.SSLError`)。读响应头时连接被重置也一样。
|
||||
- 根因(读码 + 本机 stub 确认 IncompleteRead):`scripts/vedastro_service_adapter.py` 的快照分发(约 2782–2808 行)和区间扫描(约 3010–3046 行)只接住 `HTTPError`、`URLError`、`RemoteDisconnected`、超时和 `JSONDecodeError`;`urllib` 只包装发请求时的错误,读响应头和响应体时的错误原样抛出。`_attach_vedastro_main_entry_overview` 外面也没有 try,所以异常一直冒到请求总入口。
|
||||
- 修复:
|
||||
- 适配器的 `_post_json` 把读响应时的 `http.client.HTTPException`(含 `IncompleteRead`、`RemoteDisconnected`)、`ConnectionError`(含 `ConnectionResetError`)和 `ssl.SSLError` 转成 `URLError("response_read_failed:<类型名>")`。所有调用点本来就把 `URLError` 记成 `network_error`,所以快照分节、区间扫描、外部技法证据都按网络错误处理,官方快照不再是 ok,网关结果是 `official_blocked`。前台和报告会话照原样把这个失败记进当天的失败缓存。读超时仍是 `timeout`,5xx 仍是 `http_error:<code>`。`RemoteDisconnected` 的标签从 `network_error:<原文>` 变成 `network_error:response_read_failed:RemoteDisconnected`。
|
||||
- `_attach_vedastro_main_entry_overview` 外面包一层 try:任何异常都记成 `status=blocked`、`reason=vedastro_main_entry_overview_error`、`error_type=<类型名>`,排盘照常返回。
|
||||
- `vedastro_rest_bridge.probe_calculate_health` 也接住 `http.client.HTTPException`,健康探测读到一半断开时记 `official_blocked`。
|
||||
- 验证:`tests/test_vedastro_report_deadline_20261102.py`:把 `urlopen` 换成 stub,分别在读响应体时抛 `IncompleteRead`、`ConnectionResetError`、`ssl.SSLError`,在打开连接时抛 `ConnectionResetError`、`RemoteDisconnected`。`_post_json` 抛 `URLError`;官方快照分节是 `network_error:response_read_failed:<类型名>`,没有 ok;区间扫描是 `network_error`。`/api/chart`(不带 skip)走真实 `do_POST` 返回 200,`vedastro_range_scan_result` 不是 ok;编排器直接抛 `IncompleteRead` 时也返回 200,概览为 blocked。503 仍是 `http_error:503`,读超时仍是 `timeout`。把适配器和 REST 桥换回修复前版本,这几条测试失败。
|
||||
- 防复发:该测试文件已加进 `scripts/run_quality_gate.py` 的 quick 清单。新增的 VedAstro 网络调用应当走 `_post_json`,不要自己调 `urlopen`。
|
||||
- 相关记录:BUG-1508(同一轮修复,报告等 VedAstro 的总时限);Gitea #119 第 42 项;`docs/operations/outage-behaviour-2026-11-01.md`。
|
||||
- 复发自:无
|
||||
- 修复版本:未修。
|
||||
- 修复版本:分支 `codex/vedastro-deadline-1504`。
|
||||
|
||||
## BUG-1505 | 报告任务最后一次尝试租约过期时,报告标为失败但点数不退
|
||||
|
||||
- 状态:investigating
|
||||
- 首次发现:2026-11-01(Gitea #119 第 42 项,外部依赖故障审计)
|
||||
- 最近更新:2026-11-01
|
||||
- 影响面:个人报告、合盘、年运等走 `personal_report_jobs` 的报告任务。
|
||||
- 状态:resolved(迁移 + 回归测试;真库测试待 staging 门禁复核)
|
||||
- 首次发现 / 最近更新:2026-11-01 / 2026-11-02(Gitea #119 第 42 项,外部依赖故障审计;产品负责人 2026-11-02 同意修复)
|
||||
- 影响面:个人报告、合盘、年运等走 `personal_report_jobs` 的报告任务;生时校正的退点。
|
||||
- 用户现象:报告显示失败(`calculation_unavailable`),点数已经扣掉,没有退回。用户重新生成要再付一次,只有删除这份失败报告时,删除触发器才会退点。
|
||||
- 触发条件:任务已经是最后一次尝试(`max_attempts` 为 3),这时 worker 进程退出、重启或卡死,租约(600 秒)过期,下一轮由回收函数收尾。发版重启时最容易遇到。另一种情况:worker 正常走到失败分支,但 `billing.release` 返回 false 或抛错,返回值被忽略。生时校正的 `release()` 也一样(`frontend/src/lib/rectification-agentic/v9/agent-run-finish.ts:49`)。
|
||||
- 根因(读码 + 本机 stub 确认):`frontend/supabase/migrations/20260814040000_personal_report_jobs_v2.sql` 的 `recover_expired_personal_report_jobs` 只把任务和报告改成 failed,不调 `release_usage`;worker 的 tick 也不退点。`frontend/src/lib/personal-report-worker-core.ts:240-243` 不检查 `release` 的返回值。
|
||||
- 修复:未修。本轮只审计,待产品决定排期。
|
||||
- 验证:本机 stub 调用回收,`failedCount=1`,没有任何退点调用;`release` 返回 false 时报告照样标失败。脚本是临时的,没有入库。
|
||||
- 防复发:待修复单补「租约过期终态失败必须退点」「退点失败要重试或告警」的测试。
|
||||
- 触发条件:任务已经是最后一次尝试(`max_attempts` 为 3),这时 worker 进程退出、重启或卡死,租约(600 秒)过期,下一轮由回收函数收尾。发版重启时最容易遇到。另一种情况:worker 正常走到失败分支,但 `billing.release` 返回 false 或抛错,返回值被忽略。生时校正的 `release()` 也一样(`frontend/src/lib/rectification-agentic/v9/agent-run-finish.ts`)。
|
||||
- 根因(读码 + 本机 stub 确认):`frontend/supabase/migrations/20260814040000_personal_report_jobs_v2.sql` 的 `recover_expired_personal_report_jobs`(此前唯一的定义)只把任务和报告改成 failed,不调 `release_usage`;worker 的 tick 也不退点。`frontend/src/lib/personal-report-worker-core.ts` 的失败分支不检查 `release` 的结果,退点失败照样把任务关成 failed,之后再没有任何流程会去退。三种报告预扣时都用报告自己的 `request_id`,所以缺的只是回收这一步。
|
||||
- 修复:
|
||||
- 新迁移 `20261102010000_report_lease_expiry_release.sql` 替换回收函数(签名、返回值、invoker 模式和只授权 service_role 都不变,`search_path` 改为空)。要标失败的行(报告已失败,或次数用完),先按报告的 `request_id` 调 `release_usage`,成功(或 `request_completed` 已扣、`profile_missing` 钱包已不在)才标失败;退点抛错或返回其他错误时,这一行保持 running,写一条 `errorCode=report_refund_failed` 的 warning,下一轮回收再试,不影响同一批的其他行。`release_usage` 按 `request_id` 幂等,和 worker 自己的失败处理、删除触发器之间最多退一次。
|
||||
- worker:失败时先退点,退点失败(返回 false 或抛错)就不关任务,记 `[agent-observability]` 日志 `errorCode=report_refund_failed`,结果为 `refund_deferred`,留给租约过期后的回收(还有次数就重试,最后一次就退点并标失败)。报告已在别处标失败时也先退点再关任务;重试次数用完的分支走同一条退点路径。生产端口把 `request_completed`、`profile_missing` 视为已结清,和删除触发器一致。
|
||||
- 生时校正:失败或不结算时的退点,用同一个案例请求号再试一次,还失败就记 `errorCode=rectification_refund_failed`。预扣留在案例上,下一轮校正复用,不会重复扣。
|
||||
- 验证:
|
||||
- 新增 `frontend/tests/database-report-lease-expiry-release-20261102.test.ts`(真库,本机没有 Docker,跳过,由 staging 门禁跑):个人报告和年运报告最后一次租约过期后各退 2 点、只有一条退款记录;还有次数的任务只转重试、不退;再回收一次、晚到的 worker 退点、删除报告都不会多退;某一行退点被模拟故障时它保持 running,同批另一行照常退点,故障解除后下一轮补退;已交付的报告不退。
|
||||
- 新增 `frontend/tests/report-lease-expiry-release-20261102.test.ts` 锁住迁移源文本(invoker、空 `search_path`、只授权 service_role、先退后改、逐行捕获、`release_usage` 的 EXECUTE 没被收回)和两条日志能通过观测 schema。
|
||||
- `frontend/tests/personal-report-worker.test.ts` 新增 5 项(退点返回 false / 抛错时保留租约并记日志、最后一次尝试交给回收、已失败报告先退点、生产端口的结清口径),`frontend/tests/rectification-v9-agent.test.ts` 新增 3 项(重试后成功不记日志、一直失败和抛错各记一条)。
|
||||
- 迁移用 pglast 解析过全部 5 条语句和 1 个 PL/pgSQL 函数体,`tsc` 无错误。
|
||||
- 防复发:上述测试。规则:任何把报告任务关成失败的路径,都要先退点成功再关;退点失败只能留给回收,不能吞掉。
|
||||
- 相关记录:BUG-1461(解读失败不让报告失败);`20260925040000_deleted_report_release.sql`;`docs/operations/outage-behaviour-2026-11-01.md`。
|
||||
- 复发自:无
|
||||
- 修复版本:未修。
|
||||
- 修复版本:分支 `codex/report-refund-1505`(基于 staging `ba1b4be7`)。
|
||||
|
||||
## BUG-1506 | 修改已存人物的出生资料时,时区服务失败会默默清空出生地
|
||||
|
||||
- 状态:investigating
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-11-01(Gitea #119 第 42 项,外部依赖故障审计)
|
||||
- 最近更新:2026-11-01
|
||||
- 影响面:用 `BirthPlacePicker` 编辑已存人物的地方(本人资料、人物列表)。新建人物不受影响:新建时会显示错误和「重试」。
|
||||
- 最近更新:2026-11-02
|
||||
- 影响面:所有用 `BirthPlacePicker` 的表单:星盘档案和星盘页的「编辑人物 / 编辑我的资料 / 添加一个人」(共用 `ChartProfileForm`),以及建档的出生地点卡。修复时又确认了同一根因的两条触发路径:编辑时改省份、改市或改国家(还没选完那一刻就清空并跳回空选择器,要再选一遍),以及打开海外人物的编辑(地区列表还在加载时就被清空、跳回中国)。
|
||||
- 用户现象:修改出生日期、时间或地点后,如果 `/api/locations/timezone` 返回 503、连不上或报错,原来显示的「省 · 市 · 区县 … UTC+8」变回「按省、市、区县逐级选择即可。」。没有错误提示,也没有「重试」按钮,用户要重新选地点,而且可能以为已经存好了。
|
||||
- 触发条件:编辑已有地点的人物时,时区解析失败。
|
||||
- 根因(读码 + 本机渲染确认):失败时 `frontend/src/components/birth-place-picker.tsx` 调 `onChange(null)`(约 274 行)。`frontend/src/components/birth-location-fields.tsx:21-35` 把地点代码全部清空,选择器的 `externalKey` 因此变化,草稿按空值重置(`birth-place-picker.tsx:129-132`),刚设置的错误状态也一起被丢掉。
|
||||
- 修复:未修。本轮只审计,待产品决定排期。
|
||||
- 验证:本机用真实 React 渲染 `BirthPlacePicker`,把 fetch 换成返回 503 的 stub,复现上述现象。脚本是临时的,没有入库。
|
||||
- 防复发:待修复单补「编辑时时区失败保留原地点并显示重试」的行为测试;现有 `frontend/tests/birth-place-picker.test.ts` 只断言源码文本。
|
||||
- 相关记录:`docs/operations/outage-behaviour-2026-11-01.md` 第二节。
|
||||
- 触发条件:编辑已有地点的人物时,时区解析失败;或改到一半的省 / 市 / 国家;或打开海外人物。
|
||||
- 根因(读码 + 真实渲染确认):选择器在两处向上报 `null`:时区查询失败(`frontend/src/components/birth-place-picker.tsx` 原 274 行)和选项没选完(原 228-233 行)。`frontend/src/components/birth-location-fields.tsx` 收到 `null` 就把地点代码、坐标和时区全部清空,选择器的 `externalKey` 因此变化,草稿按空值重置(原 129-132 行),刚设置的错误状态也一起被丢掉。另外改日期查到新时区后再改回原日期,旧代码按「挂载时的出生时刻」判断已存时区有效,会把新日期的时区当原日期的存下去。
|
||||
- 修复:
|
||||
- 选择器不再上报 `null`,`onChange` 只在查到新时区时调用;失败和没选完都保留资料里原来的地点和时区。
|
||||
- 新增 `onPendingChange`:已选地点或出生时刻需要新时区、但资料里还没有(没选完、正在查、查失败)时为 true。`frontend/src/components/birth-place-pending.tsx` 的 `BirthPlacePendingScope` 收这个状态,`BirthPlaceSubmitButton` 据此禁用保存。`ChartProfileForm` 和建档出生地点卡都包了这一层,所以「保存」/「确定」在时区查到或用户改回原值之前不能点。
|
||||
- 查失败时,有原地点的写「时区这会儿没查到,原来的出生地还在,查到后才能保存。」,后面是「重试」;新建(没有原地点)仍是「时区服务暂时不可用,稍后重试即可。」加「重试」。不加别的提示行,状态行位置和高度不变。
|
||||
- 资料里时区对应的出生时刻单独记(`valueMoment`),查到新时区时更新;改回原日期时会重新查,不再把别的日期的时区存下去。查询被取消后迟到的成功结果不再写回。
|
||||
- 验证:新增 `frontend/tests/birth-place-timezone-failure-20261102.test.tsx`,用真实 React 渲染 `ChartProfileForm` + fetch stub:改日期后时区 503 → 地点、坐标、时区原样保留,显示上面的错误句和「重试」,保存按钮禁用;重试成功 → 错误消失、时区改为新值、保存可点;改回原时间 → 不再请求,恢复原时区;改省份没选完 → 草稿留在新省份、资料不变、保存禁用;新建人物查失败 → 新建文案 + 重试,资料里没有可用地点;海外人物打开时(地区列表成功或失败)资料都不被清空。把 `frontend/src` 换回修复前版本,这 6 条行为测试全部失败。`birth-place-picker.test.ts` 的源码断言按新写法改,带原值 / 新值 / 原因注释。
|
||||
- 防复发:上面的行为测试。表单里放出生地选择器时要包 `BirthPlacePendingScope` 并用 `BirthPlaceSubmitButton`,同一测试文件检查两处表单的接线。选择器的 `onChange` 类型不再接受 `null`,以后谁再想用「上报空值」来表示失败会过不了类型检查。
|
||||
- 遗留:海外人物地区列表加载失败时,再改日期会保存不了,但状态行只说「该国家的地区列表暂时不可用」,没有重试(`docs/operations/outage-behaviour-2026-11-01.md` 第二节另一行,未改)。时区接口卡住时仍没有超时(同节另一行,未改)。
|
||||
- 相关记录:`docs/operations/outage-behaviour-2026-11-01.md` 第二节;BUG-1507。
|
||||
- 复发自:无
|
||||
- 修复版本:未修。
|
||||
- 修复版本:分支 `codex/profile-birthplace-1506`(基于 `origin/staging` ba1b4be7)。
|
||||
|
||||
## BUG-1507 | 早期中国出生资料没有时区 ID,被默默按 +8 计算(1986–1991 夏令时出生差一小时)
|
||||
|
||||
@@ -20634,3 +20648,25 @@
|
||||
- 相关记录:BUG-1506;`docs/operations/outage-behaviour-2026-11-01.md` 第二节。新疆 `Asia/Urumqi`(+6)对报北京时间的用户差两小时,另待产品决定,没有单独记 Bug。
|
||||
- 复发自:无
|
||||
- 修复版本:未修。
|
||||
|
||||
## BUG-1508 | 报告生成同步等 VedAstro,没有总时限,VedAstro 卡住时报告超时失败
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-11-01(Gitea #119 第 42 项,外部依赖故障审计,缺口 2)
|
||||
- 最近更新:2026-11-02
|
||||
- 影响面:个人报告生成(`/api/reports` 创建和报告 worker 调 `/api/consultation_workflow`)。对话、星盘页不受影响。
|
||||
- 用户现象:VedAstro 卡住时报告以 `workflow_timeout` 失败(会退点)。期间一直占着进程级两个重计算名额中的一个,并发两份报告会让对话和校正拿到 429。下一份报告还要再等一遍。
|
||||
- 触发条件:VedAstro 不响应(每次请求都等满 `VEDASTRO_TIMEOUT_SECONDS`,生产 20 秒)时生成报告。
|
||||
- 根因(读码 + 本机 stub 确认):报告调用没有传 `foreground`,所以 `defer_optional_external_evidence=false`,Python 端同步跑两段 VedAstro:排盘里的主入口概览(约 4 × 20 秒)和生时门禁里的官方快照(含 8 秒健康探测,约 48 秒)。合计约 128 秒,超过前端 90 秒(`frontend/src/mastra/consultation-workflow.ts:149`)。这条路径的结果直接取自门禁,不经过前台会话,也就不写失败缓存。
|
||||
- 修复:复用对话已有的前台会话、后台线程和当天失败缓存(#130),不另起一套。
|
||||
- 前端:报告的两处调用(`frontend/src/app/api/reports/route.ts`、`frontend/src/lib/personal-report-worker.ts`)传 `vedastroEvidenceBudget: "report"`,请求体带 `vedastro_evidence_budget: "report"`。报告仍不传 `foreground`。
|
||||
- Python:`scripts/vedastro_foreground.py` 新增报告档。带这个字段的请求按 defer 处理,和对话一样:不跑同步的主入口概览;生时门禁只读缓存;官方网关在后台线程里跑,和本地计算并行。不同的是等待时长:对话从收尾时起最多等 1.5 秒;报告从会话开始起算,最多等 25 秒(`JYOTISH_REPORT_VEDASTRO_DEADLINE_SECONDS`,上限 45 秒)。单次请求超时沿用适配器的配置值,报告不会把它调高,也不超过 2 倍时限。
|
||||
- 到时限网关还在跑:报告照常继续,VedAstro 记 `official_blocked` / `report_vedastro_deadline_exceeded`,并写进这张盘当天(UTC)的失败缓存,下一份报告和下一个主题直接用它,不再等。后台线程继续跑:之后成功就写进正常快照缓存(查缓存时成功优先),失败就用真实失败覆盖这条记录。还没开始就被取消的任务不记。
|
||||
- 5xx、连接截断等很快返回的失败照旧记进失败缓存。
|
||||
- 报告里的证据如实标出:`machine_evidence_packet` 的 `external_oracle_status` 是 `official_blocked`,`vedastro_official_raw_response` 是 `missing`,报告的技法审计表按原有规则显示 blocked(「官方外部引擎本次未验证或不可用」)。对话技法审计的说明文字加了 `report_vedastro_deadline_exceeded` 的中文。
|
||||
- 取舍:报告不再跑主入口概览(按主题的区间扫描),引导话题里的 VedAstro 区间事件显示为没有;官方 VedAstro 证据改由网关的官方快照提供,按时返回就照常进报告。
|
||||
- 验证:`tests/test_vedastro_report_deadline_20261102.py`,VedAstro 全部是 stub:网关卡住、时限 0.4 秒时,会话 0.4 秒左右返回 blocked,失败写进缓存,第二次 0.15 秒内返回、网关只调过一次;时限从会话开始起算,本地计算占掉的时间不再额外等;到时限后网关成功,下一次读到成功快照;很快返回的 503 类失败和抛 `IncompleteRead`、`ConnectionResetError` 的网关都记成 blocked 并写缓存;健康时照常用网关结果;对话档的 1.5 秒超时仍不记;单次请求超时沿用配置值。整条 `consultation_workflow` 带报告字段时不调主入口概览,证据段落为 `official_blocked` / `missing`,第二次不再调网关。本机脚本把时限设为 6 秒、单次超时 20 秒、VedAstro 全部卡住:第一份 6.23 秒,第二份 0.11 秒(修复前同样条件约 128 秒)。脚本是临时的,没有入库。
|
||||
- 防复发:该测试文件已加进 `scripts/run_quality_gate.py` 的 quick 清单;`frontend/tests/consultation-workflow-contract.test.ts` 锁住报告两处调用都带 `vedastroEvidenceBudget: "report"`、都不带 `foreground: true`。
|
||||
- 相关记录:BUG-1504;BUG-161、BUG-727、#130(前台会话和失败缓存);`docs/operations/outage-behaviour-2026-11-01.md` 第一节、第五节第 2 条。
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/vedastro-deadline-1504`。
|
||||
|
||||
@@ -57,7 +57,7 @@ Gitea #119 第 42 项:「VedAstro、地点服务、模型供应商挂掉时网
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 新建人物,时区接口 503 或连不上 | 「时区服务暂时不可用,稍后重试即可。」加「重试」按钮,草稿保留,「确定」按钮不可点。不能手填经纬度或时区 | 不涉及 | 路由和服务都不写日志 | `birth-place-picker.tsx:272-276`、`:490-493`;`home-onboarding-shell.tsx:137` [读码];[实测] stub 503 / 连接拒绝 / 坏 JSON 都返回 `unavailable` |
|
||||
| 新建人物,时区接口卡住 | 「正在确认该地点的时区…」一直转,我们的代码没有超时,要等 Node 默认约 300 秒 | 不涉及 | 无 | 选择器到 Next 再到 Python 三段都没有 AbortSignal 或超时 [读码];[实测] stub 卡住时转圈不结束 |
|
||||
| **修改已存人物的出生日期、时间或地点,时区接口失败** | **已存的出生地被清空,没有报错,也没有「重试」按钮**,提示回到「按省、市、区县逐级选择即可。」用户可能以为已经存好 | 不涉及 | 无 | 失败时 `onChange(null)`(`birth-place-picker.tsx:274-275`),父组件把地点代码清空(`birth-location-fields.tsx:21-35`),选择器按新键重置草稿(`:129-132`)。[实测] 真实渲染 + fetch stub。记为 BUG-1506 |
|
||||
| **修改已存人物的出生日期、时间或地点,时区接口失败** | **已存的出生地被清空,没有报错,也没有「重试」按钮**,提示回到「按省、市、区县逐级选择即可。」用户可能以为已经存好 | 不涉及 | 无 | 失败时 `onChange(null)`(`birth-place-picker.tsx:274-275`),父组件把地点代码清空(`birth-location-fields.tsx:21-35`),选择器按新键重置草稿(`:129-132`)。[实测] 真实渲染 + fetch stub。记为 BUG-1506。**2026-11-02 已修**:保留原地点,显示「时区这会儿没查到,原来的出生地还在,查到后才能保存。」加「重试」,保存按钮在查到前不能点 |
|
||||
| 海外国家地区列表加载失败 | 「该国家的地区列表暂时不可用,请稍后重试。」没有重试按钮,要换国家再换回来 | 不涉及 | 无 | `birth-place-picker.tsx:149-171`、`:336` [读码] |
|
||||
| 星盘页:本人资料没存时区,查询时时区失败 | 「出生地的时区没有对上,这张盘先不能算。我们已经记录下来了。」引导去改资料。这是临时故障,文案却让用户以为资料有错;「已经记录下来」实际只是一行 `console.warn`。卡住时 25 秒后显示「这张盘等了太久没有回来…」 | `billed:false` | `console.warn("chart_view_failure", {reason:"timezone_resolver_failure"})`,不告警 | `frontend/src/lib/chart-view-service.ts:190-199`、`chart-view-labels.ts:184`、`:205` [读码];测试 `chart-profile-update-consistency.test.ts`「classifies query, timezone…」 |
|
||||
| 对话:本人资料没存时区,时区失败 | 503「暂时无法核对完整出生资料…请重新保存后再试,本次不会扣点。」同样把临时故障说成资料问题 | **不扣点**:时区在 `reserve()` 之前解析 | 不写日志 | `consultation-route-service.ts:567-574` 对比 `:622` [读码];测试 `consultation-route-service.test.ts`「historical timezone failure remains pre-billing」[实测通过] |
|
||||
@@ -113,10 +113,10 @@ Gitea #119 第 42 项:「VedAstro、地点服务、模型供应商挂掉时网
|
||||
|
||||
## 五、缺口(按风险从高到低)
|
||||
|
||||
1. **报告任务租约过期不退点(BUG-1505)。** 进程在最后一次尝试时重启就会触发,发版时尤其容易遇到。点数扣掉了、报告失败,用户要么删报告,要么重新付一次。退点 RPC 失败也被忽略。
|
||||
2. **报告生成同步等 VedAstro,没有总时限。** VedAstro 卡住时约 128 秒,超过前端 90 秒,报告失败;同时占着两个重计算名额中的一个,并发两份报告会让对话和校正拿到 429。这条路径的失败也不写失败缓存,下一份报告会重来一遍。可选做法:报告也走 `defer_optional_external_evidence`,或者跳过主入口概览,或者复用对话的前台预算和失败缓存。
|
||||
3. **VedAstro 连接被截断会变成 500(BUG-1504)。** 报告失败(会退点),不带 skip 的 `/api/chart` 也会 500。
|
||||
4. **出生资料编辑时,时区服务失败会默默清掉出生地(BUG-1506)。**
|
||||
1. **报告任务租约过期不退点(BUG-1505)。** 进程在最后一次尝试时重启就会触发,发版时尤其容易遇到。点数扣掉了、报告失败,用户要么删报告,要么重新付一次。退点 RPC 失败也被忽略。**2026-11-02 已修**:租约回收在判失败前先退点(与删除触发器、worker 同一个请求号,不会重复退);退点失败的任务留到下次回收重试,并记 `errorCode=report_refund_failed`。
|
||||
2. **报告生成同步等 VedAstro,没有总时限。** VedAstro 卡住时约 128 秒,超过前端 90 秒,报告失败;同时占着两个重计算名额中的一个,并发两份报告会让对话和校正拿到 429。这条路径的失败也不写失败缓存,下一份报告会重来一遍。可选做法:报告也走 `defer_optional_external_evidence`,或者跳过主入口概览,或者复用对话的前台预算和失败缓存。**2026-11-02 已修(BUG-1508)**:报告复用对话的前台会话和失败缓存,VedAstro 最多等 25 秒,到时限标 blocked 并记当天。
|
||||
3. **VedAstro 连接被截断会变成 500(BUG-1504)。** 报告失败(会退点),不带 skip 的 `/api/chart` 也会 500。**2026-11-02 已修**:读响应时的截断和重置按网络错误处理,主入口概览出错时排盘照常返回。
|
||||
4. **出生资料编辑时,时区服务失败会默默清掉出生地(BUG-1506)。**2026-11-02 已修。
|
||||
5. **早期中国资料默默按 +8 计算(BUG-1507)。** 这是正确性问题,与故障无关:1986–1991 年夏季出生的差一小时。新疆 +6 要产品决定。
|
||||
6. **模型调用不重试、没有备用供应商。** 一次 429 或 503 就让付费用户这一轮失败。会退点,但体验差;目录里配了多个模型,失败时也不切换。
|
||||
7. **外部依赖挂了没有任何告警或日志。** 健康检查不探测,VedAstro 模块不写日志,报告侧模型失败不写 `errorCode`。凌晨低流量时,对话也达不到 5 分钟 10 次的阈值。
|
||||
|
||||
@@ -386,6 +386,7 @@ The birth-time rectification session is the consultation transcript plus a house
|
||||
- **Value:** administrative codes, the node centre coordinate, and the IANA zone are all persisted. Coordinates come from the dataset; the zone is resolved once per chosen place through the chart engine rather than per keystroke.
|
||||
- **States:** nothing chosen, partially chosen, resolving the zone, resolved, and zone service unavailable with an explicit retry. An unresolved zone is never reported upward as a usable birth place.
|
||||
- **Saved profiles:** mounting an already-saved place neither refetches nor reports a change, so opening the profile dialog cannot look like a location edit. Changing the birth date does invalidate the stored offset and resolves the zone again.
|
||||
- **Never clears (2026-11-02, BUG-1506):** the picker reports a place only when a fresh zone comes back. A half-chosen level or a failed lookup keeps the profile's previous place and offset; the picker reports *pending* instead, and the form's submit (`BirthPlaceSubmitButton` inside `BirthPlacePendingScope`: the edit dialogs' 保存, onboarding's 确定) stays disabled until the lookup succeeds or the reader goes back to the saved place and moment. A failed lookup on a person who already has a place reads 「时区这会儿没查到,原来的出生地还在,查到后才能保存。」 with 重试; with no place yet it stays 「时区服务暂时不可用,稍后重试即可。」 with 重试. The error replaces the status line in place: no extra hint row, no height change. An offset resolved for an edited date is not reused when the date goes back.
|
||||
- **Accessibility:** every level keeps a visible label, an explicit trigger name, and the 44px target from the select recipe; status text uses a polite live region.
|
||||
|
||||
### Birth date picker
|
||||
|
||||
@@ -381,7 +381,7 @@ export async function POST(request: Request) {
|
||||
return released.success;
|
||||
},
|
||||
},
|
||||
runWorkflow: (input) => runConsultationWorkflow(input),
|
||||
runWorkflow: (input) => runConsultationWorkflow(input, { vedastroEvidenceBudget: "report" }),
|
||||
createAgent: (model) => createPersonalReportAgent(model as Parameters<typeof createPersonalReportAgent>[0]),
|
||||
skillSnapshot: resolveSkillSnapshot(),
|
||||
jobs: createSupabasePersonalReportJobService(admin),
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useBirthPlacePendingReporter } from "@/components/birth-place-pending";
|
||||
import { BirthPlacePicker } from "@/components/birth-place-picker";
|
||||
import type { Profile } from "@/lib/home-types";
|
||||
|
||||
export function BirthLocationFields({ value, onChange }: { value: Profile; onChange: (profile: Profile) => void }) {
|
||||
// The enclosing form holds its submit while the picker still needs a fresh timezone (BUG-1506).
|
||||
const reportPending = useBirthPlacePendingReporter();
|
||||
return (
|
||||
<fieldset className="location-fieldset">
|
||||
<legend>出生地点</legend>
|
||||
@@ -18,21 +21,22 @@ export function BirthLocationFields({ value, onChange }: { value: Profile; onCha
|
||||
}}
|
||||
birthDate={value.date}
|
||||
birthTime={value.reportedTime || value.time}
|
||||
onPendingChange={reportPending}
|
||||
onChange={(location) => onChange({
|
||||
...value,
|
||||
countryCode: location?.countryCode || "CN",
|
||||
provinceCode: location?.provinceCode || "",
|
||||
cityCode: location?.cityCode || "",
|
||||
districtCode: location?.districtCode || "",
|
||||
birthPlaceLabel: location?.label || "",
|
||||
birthPlaceType: location?.placeType || "",
|
||||
birthPlaceProvider: location?.provider || "",
|
||||
birthPlaceProviderId: location?.providerPlaceId || "",
|
||||
timezoneId: location?.timezoneId || "",
|
||||
timezoneSource: location ? "iana_historical" : "",
|
||||
latitude: location?.latitude ?? null,
|
||||
longitude: location?.longitude ?? null,
|
||||
timezoneOffset: location?.timezoneOffset ?? null,
|
||||
countryCode: location.countryCode,
|
||||
provinceCode: location.provinceCode,
|
||||
cityCode: location.cityCode,
|
||||
districtCode: location.districtCode,
|
||||
birthPlaceLabel: location.label,
|
||||
birthPlaceType: location.placeType,
|
||||
birthPlaceProvider: location.provider,
|
||||
birthPlaceProviderId: location.providerPlaceId,
|
||||
timezoneId: location.timezoneId,
|
||||
timezoneSource: "iana_historical",
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
timezoneOffset: location.timezoneOffset,
|
||||
})}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useMemo, useState, type ComponentProps, type ReactNode } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type BirthPlacePending = { readonly pending: boolean; readonly report: (pending: boolean) => void };
|
||||
|
||||
const BirthPlacePendingContext = createContext<BirthPlacePending | null>(null);
|
||||
|
||||
/**
|
||||
* Wraps a form that holds a birth place picker. While the picker still needs a fresh
|
||||
* timezone (unfinished levels, a lookup in flight or a failed lookup), the profile keeps
|
||||
* the previous place and offset, so the form's submit button stays disabled (BUG-1506).
|
||||
*/
|
||||
export function BirthPlacePendingScope({ children }: { readonly children: ReactNode }) {
|
||||
const [pending, report] = useState(false);
|
||||
const value = useMemo(() => ({ pending, report }), [pending]);
|
||||
return <BirthPlacePendingContext.Provider value={value}>{children}</BirthPlacePendingContext.Provider>;
|
||||
}
|
||||
|
||||
/** The picker inside a scope reports here; outside a scope there is nothing to hold. */
|
||||
export function useBirthPlacePendingReporter() {
|
||||
return useContext(BirthPlacePendingContext)?.report;
|
||||
}
|
||||
|
||||
/** The form's submit button, disabled while the scope's picker is pending. */
|
||||
export function BirthPlaceSubmitButton({ disabled, ...props }: Omit<ComponentProps<typeof Button>, "type">) {
|
||||
const pending = useContext(BirthPlacePendingContext)?.pending ?? false;
|
||||
return <Button {...props} type="submit" disabled={Boolean(disabled) || pending} />;
|
||||
}
|
||||
@@ -50,7 +50,14 @@ type BirthPlacePickerProps = {
|
||||
birthDate?: string;
|
||||
birthTime?: string;
|
||||
disabled?: boolean;
|
||||
onChange: (selection: BirthPlaceSelection | null) => void;
|
||||
/** Reports a freshly resolved place only. A failed or unfinished choice never clears the value. */
|
||||
onChange: (selection: BirthPlaceSelection) => void;
|
||||
/**
|
||||
* True while the shown choice or birth moment needs a timezone the value does not carry yet
|
||||
* (unfinished levels, a lookup in flight, or a failed lookup). The form must not save then:
|
||||
* the value still holds the previous place and offset (BUG-1506).
|
||||
*/
|
||||
onPendingChange?: (pending: boolean) => void;
|
||||
};
|
||||
|
||||
type TimezoneState =
|
||||
@@ -97,6 +104,10 @@ function draftFromValue(value: SavedBirthPlace): BirthPlaceDraft {
|
||||
};
|
||||
}
|
||||
|
||||
function draftKey(draft: BirthPlaceDraft) {
|
||||
return `${draft.countryCode}/${draft.provinceCode}/${draft.cityCode}/${draft.districtCode}/${draft.cityCentre ? "centre" : ""}`;
|
||||
}
|
||||
|
||||
function countryLabel(countryCode: string) {
|
||||
return worldCountries.find((item) => item.code === countryCode)?.name ?? countryCode;
|
||||
}
|
||||
@@ -107,14 +118,15 @@ export function BirthPlacePicker({
|
||||
birthTime = "",
|
||||
disabled = false,
|
||||
onChange,
|
||||
onPendingChange,
|
||||
}: BirthPlacePickerProps) {
|
||||
const countryCode = value.countryCode?.trim().toUpperCase() || "CN";
|
||||
const fieldPrefix = useId();
|
||||
const onChangeRef = useRef(onChange);
|
||||
const savedRef = useRef(value);
|
||||
const onPendingChangeRef = useRef(onPendingChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
savedRef.current = value;
|
||||
onPendingChangeRef.current = onPendingChange;
|
||||
});
|
||||
|
||||
const externalKey = `${countryCode}/${value.provinceCode}/${value.cityCode}/${value.districtCode}`;
|
||||
@@ -124,11 +136,15 @@ export function BirthPlacePicker({
|
||||
const [globalLoadErrorCountry, setGlobalLoadErrorCountry] = useState("");
|
||||
const [outcome, setOutcome] = useState<TimezoneOutcome | null>(null);
|
||||
const [retryToken, setRetryToken] = useState(0);
|
||||
// The birth moment the value's offset was resolved for. A historical offset depends on it.
|
||||
const birthMoment = `${birthDate}/${birthTime}`;
|
||||
const [valueMoment, setValueMoment] = useState(birthMoment);
|
||||
|
||||
// Adopt a profile that was loaded or replaced outside this picker.
|
||||
// Adopt a profile that was loaded or replaced outside this picker, or the place this picker just resolved.
|
||||
if (syncedKey !== externalKey) {
|
||||
setSyncedKey(externalKey);
|
||||
setDraft(draftFromValue(value));
|
||||
setValueMoment(birthMoment);
|
||||
}
|
||||
|
||||
const isChina = draft.countryCode === "CN";
|
||||
@@ -208,12 +224,15 @@ export function BirthPlacePicker({
|
||||
}
|
||||
: globalResolved;
|
||||
|
||||
// The offset the profile already carries is only authoritative while the birth moment is unchanged.
|
||||
const birthMoment = `${birthDate}/${birthTime}`;
|
||||
const [mountedMoment] = useState(birthMoment);
|
||||
// The offset the profile already carries is only authoritative for the birth moment it was resolved for.
|
||||
const valueHasZone = value.timezoneId.trim() !== "";
|
||||
const valueHasPlace = valueHasZone || countryCode !== "CN" || Boolean(value.provinceCode);
|
||||
const valueIsCurrent = valueHasZone
|
||||
&& valueMoment === birthMoment
|
||||
&& draftKey(draft) === draftKey(draftFromValue(value));
|
||||
const savedIsCurrent = resolved !== null
|
||||
&& mountedMoment === birthMoment
|
||||
&& value.timezoneId.trim() !== ""
|
||||
&& valueMoment === birthMoment
|
||||
&& valueHasZone
|
||||
&& countryCode === resolved.countryCode
|
||||
&& value.provinceCode === resolved.provinceCode
|
||||
&& value.cityCode === resolved.cityCode
|
||||
@@ -226,14 +245,11 @@ export function BirthPlacePicker({
|
||||
: "empty";
|
||||
|
||||
useEffect(() => {
|
||||
if (syncKey === "saved") return;
|
||||
if (syncKey === "empty") {
|
||||
const saved = savedRef.current;
|
||||
if (saved.countryCode !== "CN" || saved.provinceCode || saved.timezoneId) onChangeRef.current(null);
|
||||
return;
|
||||
}
|
||||
// An unfinished choice keeps the previous value; the pending flag holds the save instead.
|
||||
if (syncKey === "saved" || syncKey === "empty") return;
|
||||
|
||||
const target = resolved as NonNullable<typeof resolved>;
|
||||
const lookupMoment = birthMoment;
|
||||
const controller = new AbortController();
|
||||
(async () => {
|
||||
try {
|
||||
@@ -254,7 +270,9 @@ export function BirthPlacePicker({
|
||||
const timezoneId = typeof payload.timezoneId === "string" ? payload.timezoneId.trim() : "";
|
||||
if (!timezoneId) throw new Error("timezone_unavailable");
|
||||
const timezoneOffset = typeof payload.timezoneOffset === "number" ? payload.timezoneOffset : null;
|
||||
if (controller.signal.aborted) return;
|
||||
setOutcome({ key: syncKey, state: { status: "ready", timezoneId, timezoneOffset } });
|
||||
setValueMoment(lookupMoment);
|
||||
onChangeRef.current({
|
||||
countryCode: target.countryCode,
|
||||
provinceCode: target.provinceCode,
|
||||
@@ -271,8 +289,8 @@ export function BirthPlacePicker({
|
||||
});
|
||||
} catch {
|
||||
if (controller.signal.aborted) return;
|
||||
// Keep the value: a failed lookup must not erase the saved place (BUG-1506).
|
||||
setOutcome({ key: syncKey, state: { status: "error" } });
|
||||
onChangeRef.current(null);
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
@@ -320,6 +338,14 @@ export function BirthPlacePicker({
|
||||
: outcome?.key === syncKey
|
||||
? outcome.state
|
||||
: { status: "loading" };
|
||||
const pending = syncKey === "saved"
|
||||
? false
|
||||
: syncKey === "empty"
|
||||
? valueHasPlace && !valueIsCurrent
|
||||
: settled.status !== "ready";
|
||||
useEffect(() => { onPendingChangeRef.current?.(pending); }, [pending]);
|
||||
useEffect(() => () => onPendingChangeRef.current?.(false), []);
|
||||
|
||||
const globalIsLoading = !isChina
|
||||
&& globalData?.code !== draft.countryCode
|
||||
&& globalLoadErrorCountry !== draft.countryCode;
|
||||
@@ -329,7 +355,9 @@ export function BirthPlacePicker({
|
||||
: settled.status === "ready" && resolved
|
||||
? `${resolved.label} · ${formatBirthPlaceCoordinate(resolved.latitude, resolved.longitude)} · ${formatBirthPlaceTimezone(settled.timezoneId, settled.timezoneOffset)}`
|
||||
: settled.status === "error"
|
||||
? "时区服务暂时不可用,稍后重试即可。"
|
||||
? valueHasPlace
|
||||
? "时区这会儿没查到,原来的出生地还在,查到后才能保存。"
|
||||
: "时区服务暂时不可用,稍后重试即可。"
|
||||
: globalIsLoading
|
||||
? "正在加载该国家的地区列表…"
|
||||
: globalHasError
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { Dispatch, FormEvent, ReactNode, SetStateAction } from "react";
|
||||
import { AyanamsaPreferenceField } from "@/components/ayanamsa-preference-field";
|
||||
import { BirthPlacePendingScope, BirthPlaceSubmitButton } from "@/components/birth-place-pending";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ProfileFields } from "@/components/profile-fields";
|
||||
import { PersonRelationField } from "@/components/person-relation-field";
|
||||
@@ -48,39 +49,41 @@ export function ChartProfileForm({
|
||||
notice,
|
||||
}: ChartProfileFormProps) {
|
||||
return (
|
||||
<form className="profile-form chart-library-form" onSubmit={onSubmit}>
|
||||
<div className="section-heading"><b>{title}</b><small>{description}</small></div>
|
||||
<ProfileFields
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
nameInputId={nameInputId}
|
||||
lockReportedBirthTime={lockReportedBirthTime}
|
||||
/>
|
||||
{showRelation ? (
|
||||
<PersonRelationField
|
||||
idPrefix={nameInputId}
|
||||
value={value.relation}
|
||||
onChange={(relation) => onChange((current) => ({ ...current, relation }))}
|
||||
<BirthPlacePendingScope>
|
||||
<form className="profile-form chart-library-form" onSubmit={onSubmit}>
|
||||
<div className="section-heading"><b>{title}</b><small>{description}</small></div>
|
||||
<ProfileFields
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
nameInputId={nameInputId}
|
||||
lockReportedBirthTime={lockReportedBirthTime}
|
||||
/>
|
||||
) : null}
|
||||
{showGender ? (
|
||||
<ProfileGenderField
|
||||
idPrefix={nameInputId}
|
||||
value={value.gender}
|
||||
onChange={(gender) => onChange((current) => ({ ...current, gender }))}
|
||||
/>
|
||||
) : null}
|
||||
{showAyanamsa ? (
|
||||
<AyanamsaPreferenceField
|
||||
value={resolveAyanamsa(value)}
|
||||
onChange={(ayanamsa) => onChange((current) => ({ ...current, ayanamsa }))}
|
||||
/>
|
||||
) : null}
|
||||
{notice ? <p className="chart-birth-edit-notice" role="status">{notice}</p> : null}
|
||||
<div className="dialog-actions">
|
||||
{onCancel ? <Button className="button-secondary" type="button" variant="outline" onClick={onCancel}>{cancelLabel}</Button> : null}
|
||||
<Button className="button-primary save-profile" type="submit" disabled={submitDisabled}>{submitLabel}</Button>
|
||||
</div>
|
||||
</form>
|
||||
{showRelation ? (
|
||||
<PersonRelationField
|
||||
idPrefix={nameInputId}
|
||||
value={value.relation}
|
||||
onChange={(relation) => onChange((current) => ({ ...current, relation }))}
|
||||
/>
|
||||
) : null}
|
||||
{showGender ? (
|
||||
<ProfileGenderField
|
||||
idPrefix={nameInputId}
|
||||
value={value.gender}
|
||||
onChange={(gender) => onChange((current) => ({ ...current, gender }))}
|
||||
/>
|
||||
) : null}
|
||||
{showAyanamsa ? (
|
||||
<AyanamsaPreferenceField
|
||||
value={resolveAyanamsa(value)}
|
||||
onChange={(ayanamsa) => onChange((current) => ({ ...current, ayanamsa }))}
|
||||
/>
|
||||
) : null}
|
||||
{notice ? <p className="chart-birth-edit-notice" role="status">{notice}</p> : null}
|
||||
<div className="dialog-actions">
|
||||
{onCancel ? <Button className="button-secondary" type="button" variant="outline" onClick={onCancel}>{cancelLabel}</Button> : null}
|
||||
<BirthPlaceSubmitButton className="button-primary save-profile" disabled={submitDisabled}>{submitLabel}</BirthPlaceSubmitButton>
|
||||
</div>
|
||||
</form>
|
||||
</BirthPlacePendingScope>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { FormEvent } from "react";
|
||||
import { BirthTimeAssessmentOverlay, type BirthTimeAssessmentPhase } from "@/components/birth-time-assessment-overlay";
|
||||
import { BirthLocationFields } from "@/components/birth-location-fields";
|
||||
import { BirthPlacePendingScope, BirthPlaceSubmitButton } from "@/components/birth-place-pending";
|
||||
import { BirthTimeIntakeFields } from "@/components/birth-time-intake";
|
||||
import { rememberSkyOrigin } from "@/components/birth-sky/sky-origin";
|
||||
import { OnboardingChatMessage } from "@/components/onboarding-chat-message";
|
||||
@@ -130,14 +131,16 @@ export function HomeOnboardingShell({
|
||||
{!profileComplete && onboardingStep === "place" && onboardingCardReady && (
|
||||
<div className="onboarding-card-reveal" data-onboarding-enter="place-card" data-onboarding-index={4}>
|
||||
<div className="onboarding-card-reveal-inner">
|
||||
<form className="profile-form onboarding-card onboarding-step-card birth-time-transition-card" onSubmit={saveOnboardingPlace} aria-busy={birthTimeAssessmentPhase !== null}>
|
||||
<div className="onboarding-card-heading"><b>出生地点</b><small>搜索全球城市或区县,系统会匹配出生当日的时区。</small></div>
|
||||
<fieldset className="birth-time-transition-fields" disabled={birthTimeAssessmentPhase !== null}>
|
||||
<BirthLocationFields value={profileDraft} onChange={setProfileDraft} />
|
||||
<div className="onboarding-card-actions"><Button className="button-primary" type="submit" data-onboarding-press="" onClick={(event) => rememberSkyOrigin(event.clientX, event.clientY)} disabled={profileSaving || !selectedBirthPlace(profileDraft)}>{profileSaving ? "保存中" : "确定"}</Button></div>
|
||||
</fieldset>
|
||||
<BirthTimeAssessmentOverlay phase={birthTimeAssessmentPhase} />
|
||||
</form>
|
||||
<BirthPlacePendingScope>
|
||||
<form className="profile-form onboarding-card onboarding-step-card birth-time-transition-card" onSubmit={saveOnboardingPlace} aria-busy={birthTimeAssessmentPhase !== null}>
|
||||
<div className="onboarding-card-heading"><b>出生地点</b><small>搜索全球城市或区县,系统会匹配出生当日的时区。</small></div>
|
||||
<fieldset className="birth-time-transition-fields" disabled={birthTimeAssessmentPhase !== null}>
|
||||
<BirthLocationFields value={profileDraft} onChange={setProfileDraft} />
|
||||
<div className="onboarding-card-actions"><BirthPlaceSubmitButton className="button-primary" data-onboarding-press="" onClick={(event) => rememberSkyOrigin(event.clientX, event.clientY)} disabled={profileSaving || !selectedBirthPlace(profileDraft)}>{profileSaving ? "保存中" : "确定"}</BirthPlaceSubmitButton></div>
|
||||
</fieldset>
|
||||
<BirthTimeAssessmentOverlay phase={birthTimeAssessmentPhase} />
|
||||
</form>
|
||||
</BirthPlacePendingScope>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -71,6 +71,7 @@ const NOTE_LABELS: Readonly<Record<string, string>> = {
|
||||
official_raw_archive_is_auditable: "官方原始档案可审计",
|
||||
official_cloud_evidence_available: "官方云证据可用",
|
||||
foreground_optional_evidence_timeout: "前台官方云证据超时,本轮未赶上",
|
||||
report_vedastro_deadline_exceeded: "报告等待官方云证据超过时限,本次未采用",
|
||||
gateway_invocation_error: "官方云网关调用失败",
|
||||
claims_capped_until_pyjhora_jhora_jyotishganit_are_invoked_for_this_run: "结论上限受外部引擎调用限制",
|
||||
three_engine_runtime_closure_available: "三引擎运行时已闭环",
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { GeneratePersonalReportResult } from "./personal-report-generation.
|
||||
import type { PromptCacheUsage } from "./agent-generation-settings.ts";
|
||||
import type { PersonalReportSectionService } from "./personal-report-section-service-core.ts";
|
||||
import type { ReportBillingPort } from "./personal-report-billing.ts";
|
||||
import { logAgentObservability, type AgentObservabilityLogger } from "./agent-observability.ts";
|
||||
|
||||
/**
|
||||
* Durable personal-report worker orchestration.
|
||||
@@ -106,6 +107,8 @@ export type PersonalReportWorkerDeps = Readonly<{
|
||||
) => Promise<void>;
|
||||
sectionService?: PersonalReportSectionService;
|
||||
billing?: ReportBillingPort;
|
||||
/** Records refund failures (`errorCode=report_refund_failed`); defaults to the observability log. */
|
||||
logObservability?: AgentObservabilityLogger;
|
||||
leaseSeconds?: number;
|
||||
heartbeatIntervalMs?: number;
|
||||
recoveryLimit?: number;
|
||||
@@ -123,6 +126,7 @@ export type PersonalReportWorkerTickResult = Readonly<{
|
||||
| "failed"
|
||||
| "retry_scheduled"
|
||||
| "reconcile_deferred"
|
||||
| "refund_deferred"
|
||||
| "lease_lost";
|
||||
jobId: string | null;
|
||||
}>;
|
||||
@@ -223,7 +227,36 @@ export function createPersonalReportWorker(deps: PersonalReportWorkerDeps) {
|
||||
const clearIntervalFn = deps.clearInterval ?? globalThis.clearInterval.bind(globalThis);
|
||||
const now = deps.now ?? (() => new Date());
|
||||
const retryDelayMs = deps.retryDelayMs ?? defaultRetryDelayMs;
|
||||
const logObservability = deps.logObservability ?? logAgentObservability;
|
||||
|
||||
/**
|
||||
* Release the reservation before a job is closed as failed. The billing RPC
|
||||
* already retries with the same request id; if it still fails (throws, or
|
||||
* returns false), the failure is logged with `report_refund_failed` and the
|
||||
* caller leaves the live lease alone. The job is then never closed with the
|
||||
* credits still held: after the lease expires the recovery sweep either
|
||||
* retries the attempt or, on the last attempt, fails the report and releases
|
||||
* in one transaction (BUG-1505).
|
||||
*/
|
||||
async function releaseBeforeFailure(job: PersonalReportJobRecord, reason: string): Promise<boolean> {
|
||||
if (!deps.billing) return true;
|
||||
try {
|
||||
if (await deps.billing.release({ userId: job.userId, requestId: job.requestId, reason })) return true;
|
||||
} catch {
|
||||
// Recorded below; the error text is not logged.
|
||||
}
|
||||
try {
|
||||
logObservability({
|
||||
requestId: job.requestId,
|
||||
runId: job.id,
|
||||
errorCode: "report_refund_failed",
|
||||
billingSettlementResult: "failed",
|
||||
});
|
||||
} catch {
|
||||
// Logging must not change the job outcome.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function settleFailure(
|
||||
job: PersonalReportJobRecord,
|
||||
@@ -237,46 +270,35 @@ export function createPersonalReportWorker(deps: PersonalReportWorkerDeps) {
|
||||
const terminal = !isRetryable(error) || job.attemptCount >= job.maxAttempts;
|
||||
|
||||
try {
|
||||
if (terminal) {
|
||||
if (deps.billing) {
|
||||
await deps.billing.release({ userId: job.userId, requestId: job.requestId, reason: code });
|
||||
}
|
||||
|
||||
if (!report || report.status !== "generating") {
|
||||
await deps.jobs.markFailed({
|
||||
jobId: job.id,
|
||||
leaseToken: job.leaseToken,
|
||||
errorCode: code,
|
||||
});
|
||||
} else {
|
||||
await deps.jobs.completeReportFailed({
|
||||
jobId: job.id,
|
||||
leaseToken: job.leaseToken,
|
||||
errorCode: code,
|
||||
report,
|
||||
failureCode,
|
||||
});
|
||||
}
|
||||
return "failed";
|
||||
if (!terminal) {
|
||||
const retryAt = new Date(now().getTime() + retryDelayMs(job, code));
|
||||
const scheduled = await deps.jobs.scheduleRetry({
|
||||
jobId: job.id,
|
||||
leaseToken: job.leaseToken,
|
||||
errorCode: code,
|
||||
nextAttemptAt: retryAt,
|
||||
});
|
||||
if (scheduled.kind === "scheduled") return "retry_scheduled";
|
||||
// Retry budget exhausted: close it like any other terminal failure.
|
||||
}
|
||||
|
||||
const retryAt = new Date(now().getTime() + retryDelayMs(job, code));
|
||||
const scheduled = await deps.jobs.scheduleRetry({
|
||||
jobId: job.id,
|
||||
leaseToken: job.leaseToken,
|
||||
errorCode: code,
|
||||
nextAttemptAt: retryAt,
|
||||
});
|
||||
if (scheduled.kind === "scheduled") return "retry_scheduled";
|
||||
if (!(await releaseBeforeFailure(job, code))) return "refund_deferred";
|
||||
|
||||
if (!report || report.status !== "generating") return "failed";
|
||||
await deps.jobs.completeReportFailed({
|
||||
jobId: job.id,
|
||||
leaseToken: job.leaseToken,
|
||||
errorCode: code,
|
||||
report,
|
||||
failureCode,
|
||||
});
|
||||
if (!report || report.status !== "generating") {
|
||||
await deps.jobs.markFailed({
|
||||
jobId: job.id,
|
||||
leaseToken: job.leaseToken,
|
||||
errorCode: code,
|
||||
});
|
||||
} else {
|
||||
await deps.jobs.completeReportFailed({
|
||||
jobId: job.id,
|
||||
leaseToken: job.leaseToken,
|
||||
errorCode: code,
|
||||
report,
|
||||
failureCode,
|
||||
});
|
||||
}
|
||||
return "failed";
|
||||
} catch (transitionError) {
|
||||
if (isLeaseLoss(transitionError)) return "lease_lost";
|
||||
@@ -328,6 +350,9 @@ export function createPersonalReportWorker(deps: PersonalReportWorkerDeps) {
|
||||
return "ready";
|
||||
}
|
||||
if (report.status === "failed") {
|
||||
if (!(await releaseBeforeFailure(job, report.failureCode ?? "report_generation_failed"))) {
|
||||
return "refund_deferred";
|
||||
}
|
||||
await deps.jobs.markFailed({
|
||||
jobId: job.id,
|
||||
leaseToken: job.leaseToken,
|
||||
|
||||
@@ -134,7 +134,7 @@ async function generateWriterReport(
|
||||
try {
|
||||
workflows.push({
|
||||
theme: rawTheme,
|
||||
workflow: await runConsultationWorkflow(input, { signal: context.signal }),
|
||||
workflow: await runConsultationWorkflow(input, { signal: context.signal, vedastroEvidenceBudget: "report" }),
|
||||
});
|
||||
} catch (error) {
|
||||
if (context.signal.aborted) throw error;
|
||||
@@ -303,7 +303,15 @@ function createProductionWorker(workerId: string) {
|
||||
});
|
||||
return settled.success;
|
||||
},
|
||||
release: async ({ userId, requestId, reason }) => (await releaseUsage(admin, userId, requestId, reason)).success,
|
||||
release: async ({ userId, requestId, reason }) => {
|
||||
const released = await releaseUsage(admin, userId, requestId, reason);
|
||||
// Final outcomes with nothing left to give back, as in the delete
|
||||
// trigger: already charged, or the wallet is gone. Anything else is a
|
||||
// refund failure the worker must not paper over (BUG-1505).
|
||||
return released.success
|
||||
|| released.error_code === "request_completed"
|
||||
|| released.error_code === "profile_missing";
|
||||
},
|
||||
reserve: async () => { throw new Error("report worker does not reserve usage"); },
|
||||
},
|
||||
loadProfile: (userId, subjectId) => loadReportWorkerProfile(admin, userId, subjectId),
|
||||
|
||||
@@ -24,9 +24,39 @@ import { dropUngroundedFactSentences } from "./spoken-grounding";
|
||||
import { evidenceTurnRecap, RECTIFICATION_USER_COPY } from "../user-copy";
|
||||
import { moderate, MODERATION_OUTPUT_REPLACED_COPY } from "@/lib/moderation";
|
||||
import { recordModerationEvent } from "@/lib/moderation/log";
|
||||
import type { V9AgentRunResult } from "./agent-run";
|
||||
import { logAgentObservability } from "@/lib/agent-observability";
|
||||
import type { V9AgentRunResult, V9RunBilling } from "./agent-run";
|
||||
import type { V9TimedTurn } from "./agent-run-prepare";
|
||||
|
||||
/**
|
||||
* Release the turn's reservation. `billing.release()` reports failure as
|
||||
* `false` (the RPC underneath already retries); one more try with the same
|
||||
* case-bound request id covers a failed request-id lookup, and a release that
|
||||
* still fails is logged with `rectification_refund_failed` instead of being
|
||||
* dropped. The reservation stays reserved under the case's request id, so the
|
||||
* next run of this case reuses it rather than charging again (BUG-1505).
|
||||
*/
|
||||
async function releaseTurnBilling(billing: V9RunBilling, caseId: string, turnId: string): Promise<boolean> {
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
if (await billing.release()) return true;
|
||||
} catch {
|
||||
// Treated like `false`; the error text is not logged.
|
||||
}
|
||||
}
|
||||
try {
|
||||
logAgentObservability({
|
||||
caseId,
|
||||
...(turnId ? { requestId: turnId } : {}),
|
||||
errorCode: "rectification_refund_failed",
|
||||
billingSettlementResult: "failed",
|
||||
});
|
||||
} catch {
|
||||
// Logging must not change the turn outcome.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function finishV9AgentTurn(
|
||||
turn: V9TimedTurn,
|
||||
outcome: AttemptOutcome,
|
||||
@@ -46,7 +76,7 @@ export async function finishV9AgentTurn(
|
||||
const { persistCommittedPhase, finalizeTurn } = v9TurnReceipts(turn);
|
||||
|
||||
if (!outcome.ok) {
|
||||
await billing.release();
|
||||
await releaseTurnBilling(billing, caseId, turnId);
|
||||
await finalizeTurn(outcome.status, null, outcome.attemptId, null);
|
||||
await emit({
|
||||
type: "run.failed",
|
||||
@@ -121,7 +151,7 @@ export async function finishV9AgentTurn(
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
await billing.release();
|
||||
await releaseTurnBilling(billing, caseId, turnId);
|
||||
}
|
||||
|
||||
await persistCommittedPhase(
|
||||
|
||||
@@ -137,6 +137,12 @@ export async function runConsultationWorkflow(
|
||||
input: ConsultationInput,
|
||||
options?: {
|
||||
foreground?: boolean;
|
||||
/**
|
||||
* "report": the API waits for VedAstro only up to its report deadline
|
||||
* (well inside the 90 s below), then marks it blocked and remembers the
|
||||
* failure for the day. BUG-1508.
|
||||
*/
|
||||
vedastroEvidenceBudget?: "report";
|
||||
signal?: AbortSignal;
|
||||
plan?: ConsultationPlan;
|
||||
requestId?: string;
|
||||
@@ -161,6 +167,7 @@ export async function runConsultationWorkflow(
|
||||
question_text: workflowRequest.question,
|
||||
theme: workflowRequest.themes,
|
||||
defer_optional_external_evidence: options?.foreground === true,
|
||||
...(options?.vedastroEvidenceBudget ? { vedastro_evidence_budget: options.vedastroEvidenceBudget } : {}),
|
||||
plan_version: workflowRequest.plan_version,
|
||||
strict_workflow_route: workflowRequest.strictWorkflowRoute,
|
||||
required_layers: workflowRequest.requiredLayers,
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
begin;
|
||||
|
||||
-- BUG-1505: a report job whose lease expired on its last attempt was failed by
|
||||
-- recover_expired_personal_report_jobs, but its reserved credits were never
|
||||
-- released. Only deleting the report (20260925040000) gave them back.
|
||||
--
|
||||
-- Every report type (personal_full / personal_thematic, synastry_full,
|
||||
-- annual_full) reserves under the report's own request_id, the same key the
|
||||
-- worker and the delete trigger release with. release_usage is idempotent on
|
||||
-- that key, so the worker's failure path, this sweep and the delete trigger
|
||||
-- refund at most once between them.
|
||||
--
|
||||
-- A job ends failed here only after its release has succeeded (or there is
|
||||
-- nothing left to release: already charged, or the wallet is gone). If the
|
||||
-- release raises or returns another error, that row is left running with its
|
||||
-- expired lease and the next sweep tries again. One bad row never blocks the
|
||||
-- rest of the batch.
|
||||
--
|
||||
-- Signature, return type and security mode (invoker, service_role only) are
|
||||
-- unchanged. service_role owns the table rights on personal_report_jobs and
|
||||
-- personal_reports (20260814040000, 20260806010000) and EXECUTE on
|
||||
-- release_usage (20260806030000); release_usage is not among the functions
|
||||
-- whose EXECUTE 20260814010000 withholds.
|
||||
|
||||
create or replace function public.recover_expired_personal_report_jobs(
|
||||
p_limit integer default 100
|
||||
)
|
||||
returns table (retrying_count integer, failed_count integer)
|
||||
language plpgsql
|
||||
security invoker
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_recovered_at timestamptz := pg_catalog.clock_timestamp();
|
||||
v_candidate record;
|
||||
v_job_ids uuid[] := '{}'::uuid[];
|
||||
v_release_success boolean;
|
||||
v_release_error text;
|
||||
begin
|
||||
if p_limit not between 1 and 1000 then
|
||||
raise exception using errcode = '22023', message = 'personal_report_job_recovery_limit_invalid';
|
||||
end if;
|
||||
|
||||
-- Lock order matches the worker and delete_chart_subject: job, then report,
|
||||
-- then (inside release_usage) the reservation and wallet.
|
||||
for v_candidate in
|
||||
select
|
||||
job.id,
|
||||
job.user_id,
|
||||
job.request_id,
|
||||
job.attempt_count,
|
||||
job.max_attempts,
|
||||
report.status as report_status
|
||||
from public.personal_report_jobs as job
|
||||
join public.personal_reports as report
|
||||
on report.user_id = job.user_id
|
||||
and report.request_id = job.request_id
|
||||
where job.status = 'running'
|
||||
and job.lease_expires_at <= v_recovered_at
|
||||
order by job.lease_expires_at, job.id
|
||||
for update skip locked
|
||||
limit p_limit
|
||||
loop
|
||||
-- Same rule as the status case below: not ready, and either already failed
|
||||
-- or out of attempts, means this row ends failed.
|
||||
if v_candidate.report_status <> 'ready'
|
||||
and (
|
||||
v_candidate.report_status = 'failed'
|
||||
or v_candidate.attempt_count >= v_candidate.max_attempts
|
||||
) then
|
||||
begin
|
||||
select released.success, released.error_code
|
||||
into v_release_success, v_release_error
|
||||
from public.release_usage(
|
||||
v_candidate.user_id,
|
||||
v_candidate.request_id::text,
|
||||
case
|
||||
when v_candidate.report_status = 'failed' then 'report_failed_lease_expired'
|
||||
else 'lease_expired_attempts_exhausted'
|
||||
end
|
||||
) as released;
|
||||
exception when others then
|
||||
v_release_success := false;
|
||||
v_release_error := 'release_raised:' || sqlstate;
|
||||
end;
|
||||
|
||||
if not coalesce(v_release_success, false)
|
||||
and v_release_error is distinct from 'request_completed'
|
||||
and v_release_error is distinct from 'profile_missing' then
|
||||
raise warning using
|
||||
message = 'personal_report_job_recovery_release_deferred',
|
||||
detail = pg_catalog.format(
|
||||
'errorCode=report_refund_failed job=%s reason=%s',
|
||||
v_candidate.id,
|
||||
coalesce(v_release_error, 'unknown')
|
||||
);
|
||||
continue;
|
||||
end if;
|
||||
end if;
|
||||
|
||||
v_job_ids := pg_catalog.array_append(v_job_ids, v_candidate.id);
|
||||
end loop;
|
||||
|
||||
return query
|
||||
with candidates as (
|
||||
select
|
||||
job.id,
|
||||
job.attempt_count,
|
||||
job.max_attempts,
|
||||
report.id as report_id,
|
||||
report.status as report_status,
|
||||
report.failure_code as report_failure_code
|
||||
from public.personal_report_jobs as job
|
||||
join public.personal_reports as report
|
||||
on report.user_id = job.user_id
|
||||
and report.request_id = job.request_id
|
||||
where job.id = any(v_job_ids)
|
||||
), exhausted_reports as (
|
||||
update public.personal_reports as report
|
||||
set status = 'failed',
|
||||
failure_code = 'calculation_unavailable',
|
||||
updated_at = v_recovered_at
|
||||
from candidates
|
||||
where report.id = candidates.report_id
|
||||
and candidates.report_status = 'generating'
|
||||
and candidates.attempt_count >= candidates.max_attempts
|
||||
returning report.id
|
||||
), recovered as (
|
||||
update public.personal_report_jobs as job
|
||||
set status = case
|
||||
when candidates.report_status = 'ready' then 'ready'
|
||||
when candidates.report_status = 'failed' then 'failed'
|
||||
when job.attempt_count < job.max_attempts then 'retrying'
|
||||
else 'failed'
|
||||
end,
|
||||
lease_token = null,
|
||||
lease_owner = null,
|
||||
lease_acquired_at = null,
|
||||
lease_expires_at = null,
|
||||
heartbeat_at = case
|
||||
when candidates.report_status = 'ready'
|
||||
or exhausted_reports.id is not null then v_recovered_at
|
||||
else job.heartbeat_at
|
||||
end,
|
||||
next_attempt_at = case
|
||||
when candidates.report_status = 'generating'
|
||||
and job.attempt_count < job.max_attempts then v_recovered_at
|
||||
else null
|
||||
end,
|
||||
progress_phase = case
|
||||
when candidates.report_status = 'ready' then 'ready'
|
||||
when candidates.report_status = 'generating'
|
||||
and job.attempt_count < job.max_attempts then 'retry_wait'
|
||||
else 'failed'
|
||||
end,
|
||||
progress_percent = case
|
||||
when candidates.report_status = 'ready' then 100
|
||||
else job.progress_percent
|
||||
end,
|
||||
last_error_code = case
|
||||
when candidates.report_status = 'ready' then null
|
||||
when candidates.report_status = 'failed' then coalesce(candidates.report_failure_code, 'report_generation_failed')
|
||||
when job.attempt_count < job.max_attempts then 'lease_expired'
|
||||
else 'lease_expired_attempts_exhausted'
|
||||
end,
|
||||
last_error_at = case
|
||||
when candidates.report_status = 'ready' then null
|
||||
else v_recovered_at
|
||||
end,
|
||||
finished_at = case
|
||||
when candidates.report_status = 'generating'
|
||||
and job.attempt_count < job.max_attempts then null
|
||||
else v_recovered_at
|
||||
end
|
||||
from candidates
|
||||
left join exhausted_reports on exhausted_reports.id = candidates.report_id
|
||||
where job.id = candidates.id
|
||||
returning job.status
|
||||
)
|
||||
select
|
||||
(count(*) filter (where recovered.status = 'retrying'))::integer,
|
||||
(count(*) filter (where recovered.status = 'failed'))::integer
|
||||
from recovered;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.recover_expired_personal_report_jobs(integer) from public, anon, authenticated;
|
||||
grant execute on function public.recover_expired_personal_report_jobs(integer) to service_role;
|
||||
|
||||
commit;
|
||||
@@ -111,10 +111,18 @@ test("timezone is resolved once for the chosen place rather than during browsing
|
||||
assert.equal(existsSync(new URL("../src/app/api/locations/timezone/route.ts", import.meta.url)), true);
|
||||
assert.match(pickerSource, /fetch\("\/api\/locations\/timezone"/);
|
||||
// Browsing the levels cannot spend a lookup: only a resolved place gets a sync key.
|
||||
assert.match(pickerSource, /if \(syncKey === "empty"\)/);
|
||||
// 原值:`if (syncKey === "empty")`(单独一个分支,里面上报 null)。
|
||||
// 新值:`if (syncKey === "saved" || syncKey === "empty") return;`,没选完不发请求也不上报。
|
||||
// 原因:BUG-1506,见下一条测试。
|
||||
assert.match(pickerSource, /syncKey === "empty"\) return;/);
|
||||
assert.match(pickerSource, /: \{ status: "loading" \};/);
|
||||
// An unresolved zone must not be reported upward as a usable birth place.
|
||||
assert.match(pickerSource, /setOutcome\(\{ key: syncKey, state: \{ status: "error" \} \}\);\s*onChangeRef\.current\(null\);/);
|
||||
// 原值:断言失败分支 `setOutcome(error)` 之后紧跟 `onChangeRef.current(null)`。
|
||||
// 新值:失败分支只记错误,不再上报 null;选择器的 onChange 也不再接受 null。
|
||||
// 原因:BUG-1506,上报 null 会把已存的出生地清空。现在靠 onPendingChange 拦住保存,行为测试在 birth-place-timezone-failure-20261102.test.tsx。
|
||||
assert.match(pickerSource, /setOutcome\(\{ key: syncKey, state: \{ status: "error" \} \}\);\s*\}/);
|
||||
assert.doesNotMatch(pickerSource, /onChangeRef\.current\(null\)/);
|
||||
assert.match(pickerSource, /onChange: \(selection: BirthPlaceSelection\) => void;/);
|
||||
|
||||
const service = readFileSync(new URL("../src/lib/birth-location-timezone-service.ts", import.meta.url), "utf8");
|
||||
assert.match(service, /\/api\/location\/timezone/);
|
||||
@@ -125,19 +133,27 @@ test("mounting a saved birth place neither refetches nor clears the stored locat
|
||||
// Reporting null on mount would look like a location edit and would drop a
|
||||
// rectified birth-time candidate just because the profile dialog was opened.
|
||||
assert.match(pickerSource, /const savedIsCurrent = resolved !== null/);
|
||||
assert.match(pickerSource, /if \(syncKey === "saved"\) return;/);
|
||||
assert.match(pickerSource, /if \(saved\.countryCode !== "CN" \|\| saved\.provinceCode \|\| saved\.timezoneId\) onChangeRef\.current\(null\);/);
|
||||
// 原值:断言 `if (syncKey === "saved") return;`,以及选空时 `if (saved.countryCode !== "CN" || saved.provinceCode || saved.timezoneId) onChangeRef.current(null);`。
|
||||
// 新值:已存和没选完都直接返回,不上报。
|
||||
// 原因:BUG-1506,没选完时上报 null 会清空已存地点,草稿也跟着按空值重置(改省份、改国家、打开海外人物都会中招)。
|
||||
assert.match(pickerSource, /if \(syncKey === "saved" \|\| syncKey === "empty"\) return;/);
|
||||
// A changed birth date invalidates the stored offset, so the zone is resolved again.
|
||||
assert.match(pickerSource, /mountedMoment === birthMoment/);
|
||||
// 原值:`mountedMoment === birthMoment`。
|
||||
// 新值:`valueMoment === birthMoment`,valueMoment 是资料里这份时区对应的出生时刻。
|
||||
// 原因:BUG-1506,改日期查到新时区后再改回原日期,旧写法会把新日期的时区当成原日期的存下去。
|
||||
assert.match(pickerSource, /valueMoment === birthMoment/);
|
||||
});
|
||||
|
||||
test("the profile stores administrative codes alongside coordinates and the IANA zone", () => {
|
||||
assert.match(pageSource, /<BirthPlacePicker/);
|
||||
assert.match(pageSource, /provinceCode: location\?\.provinceCode \|\| ""/);
|
||||
assert.match(pageSource, /cityCode: location\?\.cityCode \|\| ""/);
|
||||
assert.match(pageSource, /districtCode: location\?\.districtCode \|\| ""/);
|
||||
assert.match(pageSource, /birthPlaceProvider: location\?\.provider \|\| ""/);
|
||||
assert.match(pageSource, /timezoneSource: location \? "iana_historical" : ""/);
|
||||
// 原值:`provinceCode: location?.provinceCode || ""` 等可空写法,以及 `timezoneSource: location ? "iana_historical" : ""`。
|
||||
// 新值:`provinceCode: location.provinceCode` 等,`timezoneSource: "iana_historical"`。
|
||||
// 原因:BUG-1506,选择器不再上报 null,父组件不再有把地点清空的分支。
|
||||
assert.match(pageSource, /provinceCode: location\.provinceCode,/);
|
||||
assert.match(pageSource, /cityCode: location\.cityCode,/);
|
||||
assert.match(pageSource, /districtCode: location\.districtCode,/);
|
||||
assert.match(pageSource, /birthPlaceProvider: location\.provider,/);
|
||||
assert.match(pageSource, /timezoneSource: "iana_historical",/);
|
||||
assert.match(pageSource, /birth_place_provider_id:\s*nextProfile\.birthPlaceProviderId/);
|
||||
assert.match(pageSource, /timezone_id:\s*nextProfile\.timezoneId/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import "./install-test-document";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support";
|
||||
import { ChartProfileForm } from "../src/components/chart-profile-form";
|
||||
import { emptyProfile, type Profile } from "../src/lib/home-types";
|
||||
import { selectedBirthPlace } from "../src/lib/home-profile";
|
||||
import usRegions from "../public/data/world-locations/us.json";
|
||||
|
||||
// BUG-1506 (2026-11-02): editing the birth date, time or place of a saved person
|
||||
// while /api/locations/timezone fails used to erase the saved place silently.
|
||||
// These tests mount the real edit form with a fetch stub.
|
||||
Object.assign(globalThis, { React });
|
||||
|
||||
const EDIT_FAILURE = "时区这会儿没查到,原来的出生地还在,查到后才能保存。";
|
||||
const NEW_FAILURE = "时区服务暂时不可用,稍后重试即可。";
|
||||
|
||||
const savedHandan: Profile = {
|
||||
...emptyProfile,
|
||||
name: "甲",
|
||||
date: "1990-05-01",
|
||||
time: "10:00",
|
||||
reportedTime: "10:00",
|
||||
birthTimeSource: "hospital_record",
|
||||
countryCode: "CN",
|
||||
provinceCode: "130000",
|
||||
cityCode: "130400",
|
||||
districtCode: "130402",
|
||||
birthPlaceLabel: "中国 · 河北省 · 邯郸市 · 邯山区",
|
||||
birthPlaceType: "district",
|
||||
birthPlaceProvider: "china_locations",
|
||||
birthPlaceProviderId: "130402",
|
||||
timezoneId: "Asia/Shanghai",
|
||||
timezoneSource: "iana_historical",
|
||||
latitude: 36.603196,
|
||||
longitude: 114.484989,
|
||||
timezoneOffset: 8,
|
||||
};
|
||||
|
||||
type Reply = "fail" | { timezoneId: string; timezoneOffset: number };
|
||||
|
||||
async function mountForm(initial: Profile, options: { regions?: "ok" | "fail" } = {}) {
|
||||
const harness = createClientLifecycleHarness();
|
||||
const lookups: unknown[] = [];
|
||||
let reply: Reply = "fail";
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/data/world-locations/")) {
|
||||
return options.regions === "fail"
|
||||
? new Response("", { status: 503 })
|
||||
: new Response(JSON.stringify(usRegions), { status: 200 });
|
||||
}
|
||||
lookups.push(JSON.parse(String(init?.body ?? "{}")));
|
||||
if (reply === "fail") return new Response("{}", { status: 503 });
|
||||
return new Response(JSON.stringify(reply), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const state: { profile: Profile; set: (next: Profile) => void } = { profile: initial, set: () => {} };
|
||||
function Host() {
|
||||
const [profile, setProfile] = useState(initial);
|
||||
state.profile = profile;
|
||||
state.set = setProfile;
|
||||
return (
|
||||
<ChartProfileForm
|
||||
title="编辑人物"
|
||||
description="称呼和出生资料。"
|
||||
value={profile}
|
||||
onChange={setProfile}
|
||||
nameInputId="bug-1506"
|
||||
onSubmit={(event) => event.preventDefault()}
|
||||
submitLabel="保存"
|
||||
submitDisabled={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
await harness.render(<Host />);
|
||||
await harness.idle();
|
||||
|
||||
const byClass = (name: string) => harness.elements().find((node) => (node.getAttribute("class") ?? "").split(/\s+/).includes(name));
|
||||
return {
|
||||
lookups,
|
||||
state,
|
||||
reply(next: Reply) { reply = next; },
|
||||
status: () => byClass("birth-place-status")?.text ?? "",
|
||||
retry: () => byClass("birth-place-retry"),
|
||||
saveDisabled: () => Boolean(byClass("save-profile")?.hasAttribute("disabled")),
|
||||
async edit(patch: Partial<Profile>) {
|
||||
await harness.update(() => state.set({ ...state.profile, ...patch }));
|
||||
await harness.idle();
|
||||
},
|
||||
async choose(levelLabel: string, code: string) {
|
||||
const nodes = harness.elements();
|
||||
const trigger = nodes.findIndex((node) => node.getAttribute("aria-label") === levelLabel);
|
||||
assert.notEqual(trigger, -1, `${levelLabel} is rendered`);
|
||||
const input = nodes.slice(trigger).find((node) => node.tagName === "INPUT");
|
||||
assert.ok(input, `${levelLabel} has its hidden input`);
|
||||
await harness.event(input, "onChange", { target: Object.assign(input, { value: code }) });
|
||||
await harness.idle();
|
||||
},
|
||||
async clickRetry() {
|
||||
await harness.event(byClass("birth-place-retry")!);
|
||||
await harness.idle();
|
||||
},
|
||||
async close() {
|
||||
try { await harness.close(); } finally { globalThis.fetch = originalFetch; }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assertStillHandan(profile: Profile) {
|
||||
assert.equal(profile.provinceCode, "130000");
|
||||
assert.equal(profile.cityCode, "130400");
|
||||
assert.equal(profile.districtCode, "130402");
|
||||
assert.equal(profile.birthPlaceLabel, "中国 · 河北省 · 邯郸市 · 邯山区");
|
||||
assert.equal(profile.latitude, 36.603196);
|
||||
assert.equal(profile.timezoneId, "Asia/Shanghai");
|
||||
assert.ok(selectedBirthPlace(profile), "the saved place is still a usable birth place");
|
||||
}
|
||||
|
||||
test("a failed lookup after a date edit keeps the saved place, shows the error with a retry and holds the save", async () => {
|
||||
const form = await mountForm(savedHandan);
|
||||
try {
|
||||
// Opening the saved person neither looks the zone up nor holds the save.
|
||||
assert.equal(form.lookups.length, 0);
|
||||
assert.match(form.status(), /邯山区.*Asia\/Shanghai · UTC\+8/);
|
||||
assert.equal(form.saveDisabled(), false);
|
||||
|
||||
await form.edit({ date: "1990-05-02" });
|
||||
assert.equal(form.lookups.length, 1);
|
||||
assert.deepEqual(form.lookups[0], { latitude: 36.603196, longitude: 114.484989, birthDate: "1990-05-02", birthTime: "10:00" });
|
||||
assert.ok(form.status().includes(EDIT_FAILURE), form.status());
|
||||
assert.doesNotMatch(form.status(), /按省、市、区县逐级选择即可/);
|
||||
assert.ok(form.retry(), "the error carries a retry");
|
||||
assertStillHandan(form.state.profile);
|
||||
// The stored offset belongs to the old date: it must not be saved for the new one.
|
||||
assert.equal(form.state.profile.timezoneOffset, 8);
|
||||
assert.equal(form.saveDisabled(), true);
|
||||
|
||||
form.reply({ timezoneId: "Asia/Shanghai", timezoneOffset: 9 });
|
||||
await form.clickRetry();
|
||||
assert.equal(form.lookups.length, 2);
|
||||
assert.ok(!form.status().includes(EDIT_FAILURE));
|
||||
assert.match(form.status(), /Asia\/Shanghai · UTC\+9/);
|
||||
assert.equal(form.retry(), undefined);
|
||||
assert.equal(form.state.profile.timezoneOffset, 9);
|
||||
assert.equal(form.state.profile.date, "1990-05-02");
|
||||
assert.equal(form.saveDisabled(), false);
|
||||
} finally {
|
||||
await form.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("reverting the edit after a failed lookup restores the saved offset without another lookup", async () => {
|
||||
const form = await mountForm(savedHandan);
|
||||
try {
|
||||
await form.edit({ reportedTime: "11:00", time: "11:00" });
|
||||
assert.equal(form.lookups.length, 1);
|
||||
assert.equal(form.saveDisabled(), true);
|
||||
|
||||
await form.edit({ reportedTime: "10:00", time: "10:00" });
|
||||
assert.equal(form.lookups.length, 1);
|
||||
assert.match(form.status(), /Asia\/Shanghai · UTC\+8/);
|
||||
assert.equal(form.saveDisabled(), false);
|
||||
} finally {
|
||||
await form.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("an offset resolved for an edited date is looked up again when the date goes back", async () => {
|
||||
const form = await mountForm(savedHandan);
|
||||
try {
|
||||
form.reply({ timezoneId: "Asia/Shanghai", timezoneOffset: 9 });
|
||||
await form.edit({ date: "1990-05-02" });
|
||||
assert.equal(form.state.profile.timezoneOffset, 9);
|
||||
|
||||
form.reply("fail");
|
||||
await form.edit({ date: "1990-05-01" });
|
||||
// 9 was resolved for 05-02; saving it silently for 05-01 would be the stale offset.
|
||||
assert.equal(form.lookups.length, 2);
|
||||
assert.ok(form.status().includes(EDIT_FAILURE));
|
||||
assert.equal(form.saveDisabled(), true);
|
||||
} finally {
|
||||
await form.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("changing the place keeps the saved place while choosing and after a failed lookup", async () => {
|
||||
const form = await mountForm(savedHandan);
|
||||
try {
|
||||
await form.choose("出生省份", "440000");
|
||||
// The half-chosen province stays on screen instead of snapping back to an empty picker.
|
||||
assert.match(form.status(), /按省、市、区县逐级选择即可/);
|
||||
assert.ok(form.state.profile.provinceCode === "130000");
|
||||
assertStillHandan(form.state.profile);
|
||||
assert.equal(form.saveDisabled(), true);
|
||||
assert.equal(form.lookups.length, 0);
|
||||
|
||||
await form.choose("出生城市", "441900");
|
||||
assert.equal(form.lookups.length, 1);
|
||||
assert.ok(form.status().includes(EDIT_FAILURE));
|
||||
assertStillHandan(form.state.profile);
|
||||
assert.equal(form.saveDisabled(), true);
|
||||
|
||||
form.reply({ timezoneId: "Asia/Shanghai", timezoneOffset: 8 });
|
||||
await form.clickRetry();
|
||||
assert.equal(form.state.profile.provinceCode, "440000");
|
||||
assert.equal(form.state.profile.cityCode, "441900");
|
||||
assert.match(form.state.profile.birthPlaceLabel, /东莞市/);
|
||||
assert.match(form.status(), /东莞市/);
|
||||
assert.equal(form.saveDisabled(), false);
|
||||
} finally {
|
||||
await form.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a new person with no saved place gets the retry and no usable place on failure", async () => {
|
||||
const form = await mountForm({ ...emptyProfile, name: "乙", date: "1992-03-04", reportedTime: "08:00", time: "08:00" });
|
||||
try {
|
||||
assert.equal(form.lookups.length, 0);
|
||||
assert.match(form.status(), /按省、市、区县逐级选择即可/);
|
||||
assert.equal(form.saveDisabled(), false, "the picker alone holds nothing while nothing is chosen");
|
||||
|
||||
await form.choose("出生省份", "440000");
|
||||
await form.choose("出生城市", "441900");
|
||||
assert.equal(form.lookups.length, 1);
|
||||
assert.ok(form.status().includes(NEW_FAILURE), form.status());
|
||||
assert.ok(form.retry());
|
||||
assert.equal(form.state.profile.provinceCode, "");
|
||||
assert.equal(selectedBirthPlace(form.state.profile), null);
|
||||
assert.equal(form.saveDisabled(), true);
|
||||
|
||||
form.reply({ timezoneId: "Asia/Shanghai", timezoneOffset: 8 });
|
||||
await form.clickRetry();
|
||||
assert.equal(form.state.profile.cityCode, "441900");
|
||||
assert.equal(form.state.profile.timezoneOffset, 8);
|
||||
assert.ok(selectedBirthPlace(form.state.profile));
|
||||
assert.equal(form.saveDisabled(), false);
|
||||
} finally {
|
||||
await form.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("opening a saved overseas person keeps the place while the region list loads or fails", async () => {
|
||||
const savedAbbeville: Profile = {
|
||||
...savedHandan,
|
||||
countryCode: "US",
|
||||
provinceCode: "US-AL",
|
||||
cityCode: "110968",
|
||||
districtCode: "",
|
||||
birthPlaceLabel: "美国 · Alabama · Abbeville",
|
||||
birthPlaceType: "city",
|
||||
birthPlaceProvider: "world_location_table",
|
||||
birthPlaceProviderId: "world:US:US-AL:110968",
|
||||
timezoneId: "America/Chicago",
|
||||
latitude: 31.57184,
|
||||
longitude: -85.25049,
|
||||
timezoneOffset: -5,
|
||||
};
|
||||
for (const regions of ["ok", "fail"] as const) {
|
||||
const form = await mountForm(savedAbbeville, { regions });
|
||||
try {
|
||||
// Before the fix the picker reported null while the list loaded: the place was cleared and the picker fell back to China.
|
||||
assert.equal(form.lookups.length, 0, regions);
|
||||
assert.equal(form.state.profile.countryCode, "US", regions);
|
||||
assert.equal(form.state.profile.cityCode, "110968", regions);
|
||||
assert.equal(form.state.profile.timezoneId, "America/Chicago", regions);
|
||||
assert.equal(form.saveDisabled(), false, regions);
|
||||
if (regions === "ok") assert.match(form.status(), /Abbeville.*America\/Chicago · UTC[−-]5/);
|
||||
} finally {
|
||||
await form.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("every form that holds the picker holds its submit through the pending scope", () => {
|
||||
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
|
||||
const fields = read("../src/components/birth-location-fields.tsx");
|
||||
assert.match(fields, /onPendingChange=\{reportPending\}/);
|
||||
// The edit dialogs (people page, chart page) share ChartProfileForm; the onboarding place card has its own form.
|
||||
for (const path of ["../src/components/chart-profile-form.tsx", "../src/components/home-onboarding-shell.tsx"]) {
|
||||
const source = read(path);
|
||||
const scope = source.slice(source.indexOf("<BirthPlacePendingScope>"), source.indexOf("</BirthPlacePendingScope>"));
|
||||
assert.ok(scope.includes("<BirthPlaceSubmitButton"), path);
|
||||
assert.ok(scope.includes(path.includes("chart-profile-form") ? "<ProfileFields" : "<BirthLocationFields"), path);
|
||||
}
|
||||
});
|
||||
@@ -12,6 +12,7 @@ const mastra = readFileSync(new URL("../src/mastra/index.ts", import.meta.url),
|
||||
const tools = readFileSync(new URL("../src/mastra/consultation-tools.ts", import.meta.url), "utf8");
|
||||
const stream = readFileSync(new URL("../src/lib/stream-agent-response.ts", import.meta.url), "utf8");
|
||||
const workflow = readFileSync(new URL("../src/mastra/consultation-workflow.ts", import.meta.url), "utf8");
|
||||
const reportWorker = readFileSync(new URL("../src/lib/personal-report-worker.ts", import.meta.url), "utf8");
|
||||
const plan = readFileSync(new URL("../src/lib/consultation-plan.ts", import.meta.url), "utf8");
|
||||
const routeService = readFileSync(new URL("../src/lib/consultation-route-service.ts", import.meta.url), "utf8");
|
||||
const pythonPlanContract = readFileSync(new URL("../../scripts/consultation_plan_contract.py", import.meta.url), "utf8");
|
||||
@@ -339,8 +340,14 @@ test("personal consultation lets the Agent invoke the server-bound workflow tool
|
||||
|
||||
test("defers optional external evidence only for foreground chat", () => {
|
||||
assert.match(workflow, /defer_optional_external_evidence: options\?\.foreground === true/);
|
||||
assert.match(reportsRoute, /runWorkflow: \(input\) => runConsultationWorkflow\(input\)/);
|
||||
// 原值: /runWorkflow: \(input\) => runConsultationWorkflow\(input\)/
|
||||
// 新值: 报告带 vedastroEvidenceBudget: "report",仍不是前台对话(不带 foreground)
|
||||
// 原因: BUG-1508,报告等 VedAstro 要有总时限,超时标 blocked 并记当天失败
|
||||
assert.match(reportsRoute, /runWorkflow: \(input\) => runConsultationWorkflow\(input, \{ vedastroEvidenceBudget: "report" \}\)/);
|
||||
assert.doesNotMatch(reportsRoute, /foreground:\s*true/);
|
||||
assert.match(workflow, /vedastro_evidence_budget: options\.vedastroEvidenceBudget/);
|
||||
assert.match(reportWorker, /runConsultationWorkflow\(input, \{ signal: context\.signal, vedastroEvidenceBudget: "report" \}\)/);
|
||||
assert.doesNotMatch(reportWorker, /foreground:\s*true/);
|
||||
});
|
||||
|
||||
test("personal Agent owns the Skill and context-bound calculation tool", () => {
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
import { startPostgresFixture, type PostgresFixture } from "./helpers/postgres-fixture.ts";
|
||||
|
||||
// BUG-1505: a report job whose lease expires on its last attempt is failed by
|
||||
// the recovery sweep, which must also release the reserved credits exactly
|
||||
// once, for every report type, and must not lose a refund that fails.
|
||||
|
||||
const runner = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url));
|
||||
const docker = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { stdio: "ignore" }).status === 0;
|
||||
const schemaPassword = "schema-owner-test-password";
|
||||
const serviceRole = ["service_runtime", "service-runtime-test-password"] as const;
|
||||
const HASH = "a".repeat(64);
|
||||
|
||||
function windowsMigrationDirectory(): string {
|
||||
const directory = mkdtempSync(join(tmpdir(), "jyotisha-1505-migrations-"));
|
||||
const seen = new Map<string, Buffer>();
|
||||
for (const relative of ["../db/migrations", "../supabase/migrations"]) {
|
||||
const source = fileURLToPath(new URL(relative, import.meta.url));
|
||||
for (const name of readdirSync(source)) {
|
||||
if (!/^\d{14}_[a-z0-9_]+\.sql$/.test(name)) continue;
|
||||
const bytes = readFileSync(join(source, name));
|
||||
if (/^\.\.\/.+\.sql$/.test(bytes.toString("utf8").trim())) continue;
|
||||
const previous = seen.get(name);
|
||||
if (previous) {
|
||||
assert.equal(bytes.equals(previous), true, `${name} mirrors must match`);
|
||||
continue;
|
||||
}
|
||||
seen.set(name, bytes);
|
||||
writeFileSync(join(directory, name), bytes);
|
||||
}
|
||||
}
|
||||
assert.ok(seen.has("20261102010000_report_lease_expiry_release.sql"));
|
||||
return directory;
|
||||
}
|
||||
|
||||
function applyMigrations(fixture: PostgresFixture): void {
|
||||
const env = {
|
||||
...process.env,
|
||||
SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", schemaPassword),
|
||||
};
|
||||
const first = spawnSync(process.execPath, [runner], { encoding: "utf8", env });
|
||||
if (first.status === 0) {
|
||||
assert.match(first.stdout, /20261102010000_report_lease_expiry_release\.sql/);
|
||||
return;
|
||||
}
|
||||
const detail = `${first.stderr ?? ""}\n${first.stdout ?? ""}`;
|
||||
assert.match(detail, /duplicate migration filename/, detail);
|
||||
const directory = windowsMigrationDirectory();
|
||||
try {
|
||||
const second = spawnSync(process.execPath, [runner], {
|
||||
encoding: "utf8",
|
||||
env: { ...env, MIGRATIONS_DIRECTORY: directory },
|
||||
});
|
||||
assert.equal(second.status, 0, second.stderr || second.stdout);
|
||||
} finally {
|
||||
rmSync(directory, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
type Seeded = Readonly<{ owner: string; request: string; report: string; job: string }>;
|
||||
|
||||
/**
|
||||
* One user with 10 credits, a 2-credit reservation under the report's request
|
||||
* id, and a generating report whose job is running with an expired lease on
|
||||
* attempt `attempt` of 3.
|
||||
*/
|
||||
function seedExpiredJob(
|
||||
fixture: PostgresFixture,
|
||||
input: Readonly<{ reportType: "personal_full" | "annual_full"; attempt: 1 | 3; featureKey: string }>,
|
||||
): Seeded {
|
||||
const owner = randomUUID();
|
||||
const request = randomUUID();
|
||||
const report = randomUUID();
|
||||
const schemaVersion = input.reportType === "annual_full" ? "annual_report.v1" : "report_document.v2";
|
||||
fixture.psql(`insert into identity.users(id,name,email,email_verified,email_verified_at)
|
||||
values('${owner}','Fictional lease','${owner}@example.invalid',true,now());
|
||||
update public.profiles set credits=10 where id='${owner}';`);
|
||||
assert.equal(
|
||||
fixture.psql(`select success::text || '|' || credits::text from public.authorize_usage('${owner}','${input.featureKey}',null,'${request}',2)`),
|
||||
"true|8",
|
||||
);
|
||||
fixture.psql(`insert into public.personal_reports(id,user_id,request_id,request_fingerprint,report_type,status,
|
||||
schema_version,presentation_mode,requested_themes,depth,skill_name,skill_version,skill_source_commit,skill_snapshot_sha256)
|
||||
values('${report}','${owner}','${request}','${HASH}','${input.reportType}','generating',
|
||||
'${schemaVersion}','default',array['career']::text[],'standard','jyotish-personal-report','1.0.0','${"b".repeat(40)}','${HASH}');`);
|
||||
const job = fixture.psql(`select id from public.personal_report_jobs where user_id='${owner}' and request_id='${request}'`);
|
||||
assert.match(job, /^[0-9a-f-]{36}$/);
|
||||
if (input.attempt === 3) {
|
||||
// Two attempts already spent: the next claim is the last one.
|
||||
fixture.psql(`delete from public.personal_report_jobs where id='${job}';
|
||||
insert into public.personal_report_jobs (
|
||||
id, user_id, request_id, request_fingerprint, status,
|
||||
attempt_count, max_attempts, next_attempt_at, progress_phase,
|
||||
last_error_code, last_error_at
|
||||
) values (
|
||||
'${job}', '${owner}', '${request}', '${HASH}',
|
||||
'retrying', 2, 3, clock_timestamp() - interval '1 second', 'retry_wait',
|
||||
'lease_expired', clock_timestamp()
|
||||
);`);
|
||||
}
|
||||
const claimed = fixture.psqlAs(...serviceRole, `set role service_role;
|
||||
select attempt_count from public.claim_personal_report_job('fictional-worker', 60, '${job}');`);
|
||||
assert.equal(claimed, `SET\n${input.attempt}`);
|
||||
// The worker process died: nobody heartbeats and the lease runs out.
|
||||
fixture.psql(`update public.personal_report_jobs
|
||||
set lease_acquired_at = clock_timestamp() - interval '3 seconds',
|
||||
heartbeat_at = clock_timestamp() - interval '2 seconds',
|
||||
lease_expires_at = clock_timestamp() - interval '1 second'
|
||||
where id='${job}';`);
|
||||
return { owner, request, report, job };
|
||||
}
|
||||
|
||||
function recover(fixture: PostgresFixture): string {
|
||||
return fixture.psqlAs(...serviceRole, "set role service_role; select * from public.recover_expired_personal_report_jobs(10);");
|
||||
}
|
||||
|
||||
function credits(fixture: PostgresFixture, seeded: Seeded): string {
|
||||
return fixture.psql(`select credits from public.profiles where id='${seeded.owner}'`);
|
||||
}
|
||||
|
||||
function refunds(fixture: PostgresFixture, seeded: Seeded): string {
|
||||
return fixture.psql(`select count(*) from public.credit_transactions
|
||||
where user_id='${seeded.owner}' and request_id='${seeded.request}' and transaction_type='refund'`);
|
||||
}
|
||||
|
||||
function reservation(fixture: PostgresFixture, seeded: Seeded): string {
|
||||
return fixture.psql(`select status from public.usage_reservations where user_id='${seeded.owner}' and request_id='${seeded.request}'`);
|
||||
}
|
||||
|
||||
function states(fixture: PostgresFixture, seeded: Seeded): string {
|
||||
return fixture.psql(`select report.status || ':' || coalesce(report.failure_code, '-') || ':' || job.status
|
||||
from public.personal_reports report
|
||||
join public.personal_report_jobs job on job.user_id = report.user_id and job.request_id = report.request_id
|
||||
where report.id='${seeded.report}'`);
|
||||
}
|
||||
|
||||
test("lease-expiry recovery releases a last-attempt report once and never double-refunds", {
|
||||
skip: docker ? false : "docker unavailable",
|
||||
}, () => {
|
||||
const fixture = startPostgresFixture();
|
||||
try {
|
||||
applyMigrations(fixture);
|
||||
|
||||
// Personal and annual reports on their last attempt; a personal report
|
||||
// with attempts left must keep its reservation for the retry.
|
||||
const personal = seedExpiredJob(fixture, { reportType: "personal_full", attempt: 3, featureKey: "report.full" });
|
||||
const annual = seedExpiredJob(fixture, { reportType: "annual_full", attempt: 3, featureKey: "annual.full" });
|
||||
const retrying = seedExpiredJob(fixture, { reportType: "personal_full", attempt: 1, featureKey: "report.full" });
|
||||
|
||||
assert.equal(recover(fixture), "SET\n1|2");
|
||||
for (const seeded of [personal, annual]) {
|
||||
assert.equal(states(fixture, seeded), "failed:calculation_unavailable:failed");
|
||||
assert.equal(credits(fixture, seeded), "10");
|
||||
assert.equal(refunds(fixture, seeded), "1");
|
||||
assert.equal(reservation(fixture, seeded), "released");
|
||||
}
|
||||
assert.equal(states(fixture, retrying), "generating:-:retrying");
|
||||
assert.equal(credits(fixture, retrying), "8");
|
||||
assert.equal(reservation(fixture, retrying), "reserved");
|
||||
|
||||
// A second sweep, a late worker release and deleting the failed report all
|
||||
// hit the same idempotency key: no second refund.
|
||||
assert.equal(recover(fixture), "SET\n0|0");
|
||||
fixture.psqlAs(...serviceRole, `set role service_role;
|
||||
select * from public.release_usage('${personal.owner}','${personal.request}','late_worker');`);
|
||||
fixture.psql(`delete from public.personal_reports where id='${personal.report}'`);
|
||||
assert.equal(credits(fixture, personal), "10");
|
||||
assert.equal(refunds(fixture, personal), "1");
|
||||
} finally {
|
||||
fixture.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("a refund that fails during recovery leaves that job running and does not block the batch", {
|
||||
skip: docker ? false : "docker unavailable",
|
||||
}, () => {
|
||||
const fixture = startPostgresFixture();
|
||||
try {
|
||||
applyMigrations(fixture);
|
||||
const blocked = seedExpiredJob(fixture, { reportType: "personal_full", attempt: 3, featureKey: "report.full" });
|
||||
const healthy = seedExpiredJob(fixture, { reportType: "personal_full", attempt: 3, featureKey: "report.full" });
|
||||
|
||||
// Simulate the billing write failing for one reservation only.
|
||||
fixture.psql(`create function public.test_1505_block_release() returns trigger language plpgsql as $$
|
||||
begin
|
||||
if new.request_id = '${blocked.request}' then raise exception 'fictional_billing_outage'; end if;
|
||||
return new;
|
||||
end; $$;
|
||||
create trigger test_1505_block_release before update on public.usage_reservations
|
||||
for each row execute function public.test_1505_block_release();`);
|
||||
|
||||
assert.equal(recover(fixture), "SET\n0|1");
|
||||
assert.equal(states(fixture, healthy), "failed:calculation_unavailable:failed");
|
||||
assert.equal(credits(fixture, healthy), "10");
|
||||
// The blocked job is not closed with its credits held: still running,
|
||||
// still generating, still reserved, and picked up by the next sweep.
|
||||
assert.equal(states(fixture, blocked), "generating:-:running");
|
||||
assert.equal(credits(fixture, blocked), "8");
|
||||
assert.equal(reservation(fixture, blocked), "reserved");
|
||||
assert.equal(recover(fixture), "SET\n0|0");
|
||||
|
||||
fixture.psql(`drop trigger test_1505_block_release on public.usage_reservations;
|
||||
drop function public.test_1505_block_release();`);
|
||||
assert.equal(recover(fixture), "SET\n0|1");
|
||||
assert.equal(states(fixture, blocked), "failed:calculation_unavailable:failed");
|
||||
assert.equal(credits(fixture, blocked), "10");
|
||||
assert.equal(refunds(fixture, blocked), "1");
|
||||
} finally {
|
||||
fixture.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("a delivered report found by recovery is never refunded", {
|
||||
skip: docker ? false : "docker unavailable",
|
||||
}, () => {
|
||||
const fixture = startPostgresFixture();
|
||||
try {
|
||||
applyMigrations(fixture);
|
||||
const delivered = seedExpiredJob(fixture, { reportType: "personal_full", attempt: 3, featureKey: "report.full" });
|
||||
fixture.psql(`update public.personal_reports set status='ready',completed_at=now(),report_document='{}'::jsonb,
|
||||
calculation_hash='${HASH}',evidence_hash='${HASH}' where id='${delivered.report}';`);
|
||||
|
||||
assert.equal(recover(fixture), "SET\n0|0");
|
||||
assert.equal(states(fixture, delivered), "ready:-:ready");
|
||||
assert.equal(credits(fixture, delivered), "8");
|
||||
assert.equal(refunds(fixture, delivered), "0");
|
||||
assert.equal(reservation(fixture, delivered), "reserved");
|
||||
} finally {
|
||||
fixture.stop();
|
||||
}
|
||||
});
|
||||
@@ -98,6 +98,7 @@ function createHarness(input: Readonly<{
|
||||
loseLeaseBeforeCompletion?: boolean;
|
||||
markReadyError?: PersonalReportJobServiceError;
|
||||
billing?: ReportBillingPort;
|
||||
logObservability?: (event: unknown) => never;
|
||||
}>) {
|
||||
let job = input.job ?? jobRecord();
|
||||
let report = input.report === undefined ? reportRecord() : input.report;
|
||||
@@ -351,6 +352,7 @@ function createHarness(input: Readonly<{
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
...(input.logObservability ? { logObservability: input.logObservability } : {}),
|
||||
now: () => NOW,
|
||||
retryDelayMs: () => 1_000,
|
||||
heartbeatIntervalMs: 60_000,
|
||||
@@ -755,6 +757,109 @@ test("a synastry report rejected by the reader gate releases the reservation", a
|
||||
assert.equal(harness.billingCompleteCount, 0);
|
||||
});
|
||||
|
||||
function refundLogSink() {
|
||||
const logged: Array<Record<string, unknown>> = [];
|
||||
const sink = ((event: unknown) => {
|
||||
logged.push(event as Record<string, unknown>);
|
||||
return event as never;
|
||||
}) as (event: unknown) => never;
|
||||
return { logged, sink };
|
||||
}
|
||||
|
||||
// BUG-1505: a refund that fails must not be dropped. The job keeps its lease,
|
||||
// so the expiry sweep owns the outcome and releases on the last attempt.
|
||||
for (const [label, release] of [
|
||||
["returns false", async () => false],
|
||||
["throws", async () => { throw new Error("release rpc down"); }],
|
||||
] as const) {
|
||||
test(`terminal failure whose refund ${label} keeps the lease and logs report_refund_failed`, async () => {
|
||||
const { logged, sink } = refundLogSink();
|
||||
const billing: ReportBillingPort = {
|
||||
async reserve() { throw new Error("not used"); },
|
||||
async complete() { throw new Error("complete must not run"); },
|
||||
release,
|
||||
};
|
||||
const harness = createHarness({
|
||||
billing,
|
||||
logObservability: sink,
|
||||
generate: async () => ({
|
||||
status: "failed",
|
||||
failureCode: "report_schema_invalid",
|
||||
innerReason: "agent_output_invalid",
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await harness.worker.tick();
|
||||
|
||||
assert.equal(result.outcome, "refund_deferred");
|
||||
assert.deepEqual(harness.billingReleaseReasons, ["report_schema_invalid"]);
|
||||
assert.equal(harness.job.status, "running");
|
||||
assert.ok(harness.job.leaseToken);
|
||||
assert.equal(harness.report?.status, "generating");
|
||||
assert.equal(harness.reportFailureCount, 0);
|
||||
assert.equal(harness.events.some((event) => event.startsWith("report+job:failed:")), false);
|
||||
assert.deepEqual(logged, [{
|
||||
requestId: REQUEST_ID,
|
||||
runId: JOB_ID,
|
||||
errorCode: "report_refund_failed",
|
||||
billingSettlementResult: "failed",
|
||||
}]);
|
||||
});
|
||||
}
|
||||
|
||||
test("a refund failure on the last attempt leaves the job to the expiry sweep", async () => {
|
||||
const { logged, sink } = refundLogSink();
|
||||
const billing: ReportBillingPort = {
|
||||
async reserve() { throw new Error("not used"); },
|
||||
async complete() { throw new Error("complete must not run"); },
|
||||
async release() { return false; },
|
||||
};
|
||||
const harness = createHarness({
|
||||
billing,
|
||||
logObservability: sink,
|
||||
job: jobRecord({ status: "retrying", attemptCount: 2, maxAttempts: 3, nextAttemptAt: NOW.toISOString() }),
|
||||
generate: async () => {
|
||||
throw new PersonalReportWorkerError("calculation_unavailable", true);
|
||||
},
|
||||
});
|
||||
|
||||
const first = await harness.worker.tick();
|
||||
assert.equal(first.outcome, "refund_deferred");
|
||||
assert.equal(harness.job.attemptCount, 3);
|
||||
assert.equal(harness.job.status, "running");
|
||||
assert.equal(logged.length, 1);
|
||||
// Nothing closed the job, so after the lease expires the database sweep
|
||||
// fails it and releases in one transaction
|
||||
// (database-report-lease-expiry-release-20261102.test.ts).
|
||||
});
|
||||
|
||||
test("a report already failed elsewhere releases before its job is closed", async () => {
|
||||
const billing: ReportBillingPort = {
|
||||
async reserve() { throw new Error("not used"); },
|
||||
async complete() { throw new Error("complete must not run"); },
|
||||
async release() { return true; },
|
||||
};
|
||||
const harness = createHarness({
|
||||
billing,
|
||||
report: reportRecord({ status: "failed", failureCode: "calculation_unavailable" }),
|
||||
});
|
||||
|
||||
const result = await harness.worker.tick();
|
||||
|
||||
assert.equal(result.outcome, "failed");
|
||||
assert.deepEqual(harness.billingReleaseReasons, ["calculation_unavailable"]);
|
||||
assert.equal(harness.job.status, "failed");
|
||||
assert.equal(harness.generateCount, 0);
|
||||
});
|
||||
|
||||
test("production report release treats already-charged and missing-wallet as settled, anything else as failure", () => {
|
||||
const source = readFileSync(new URL("../src/lib/personal-report-worker.ts", import.meta.url), "utf8");
|
||||
const release = source.slice(source.indexOf("release: async ({ userId, requestId, reason })"));
|
||||
const body = release.slice(0, release.indexOf("reserve:"));
|
||||
assert.match(body, /releaseUsage\(admin, userId, requestId, reason\)/);
|
||||
assert.match(body, /released\.success\s*\|\| released\.error_code === "request_completed"\s*\|\| released\.error_code === "profile_missing"/);
|
||||
});
|
||||
|
||||
test("retryable durable generation failure does not release before retry exhaustion", async () => {
|
||||
const billing: ReportBillingPort = {
|
||||
async reserve() { throw new Error("not used"); },
|
||||
|
||||
@@ -526,6 +526,54 @@ test("framework getSkill failure is a controlled retry and then a failed turn",
|
||||
assert.equal(billing.released, 1);
|
||||
});
|
||||
|
||||
// BUG-1505: a failed refund is retried once with the same case-bound request
|
||||
// and then logged with an errorCode, never silently dropped.
|
||||
for (const [label, outcomes, expectedReleases, expectLog] of [
|
||||
["recovers on the retry", [false, true], 2, false],
|
||||
["keeps failing", [false, false], 2, true],
|
||||
["throws", ["throw", "throw"], 2, true],
|
||||
] as const) {
|
||||
test(`failed turn whose refund ${label} is retried and recorded`, async () => {
|
||||
const lines: string[] = [];
|
||||
const originalInfo = console.info;
|
||||
console.info = (...args: unknown[]) => { lines.push(args.map(String).join(" ")); };
|
||||
try {
|
||||
let calls = 0;
|
||||
const { options } = runOptions({
|
||||
billing: {
|
||||
reserve: async () => ({ success: true, status: 200 }),
|
||||
complete: async () => true,
|
||||
release: async () => {
|
||||
const next = outcomes[calls] ?? true;
|
||||
calls += 1;
|
||||
if (next === "throw") throw new Error("release rpc down");
|
||||
return next;
|
||||
},
|
||||
},
|
||||
buildAgent: async () => ({
|
||||
stream: async () => ({ fullStream: (async function* () {})() }),
|
||||
getSkill: async () => null,
|
||||
}) as never,
|
||||
});
|
||||
const result = await runV9AgentTurn(options);
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.errorCode, "skill_not_loaded");
|
||||
assert.equal(calls, expectedReleases);
|
||||
const refundLogs = lines.filter((line) => line.includes("rectification_refund_failed"));
|
||||
assert.equal(refundLogs.length, expectLog ? 1 : 0);
|
||||
if (expectLog) {
|
||||
const payload = JSON.parse(refundLogs[0].slice(refundLogs[0].indexOf("{")));
|
||||
assert.equal(payload.caseId, CASE_ID);
|
||||
assert.equal(payload.errorCode, "rectification_refund_failed");
|
||||
assert.equal(payload.billingSettlementResult, "failed");
|
||||
assert.match(refundLogs[0], /^\[agent-observability\] /);
|
||||
}
|
||||
} finally {
|
||||
console.info = originalInfo;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("distinct evidence calls in one natural turn are not mistaken for a repeated tool loop", async () => {
|
||||
const firstEvidenceId = EVIDENCE_ID;
|
||||
const secondEvidenceId = "88888888-8888-4888-8888-888888888888";
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { agentObservabilityEventSchema } from "../src/lib/agent-observability.ts";
|
||||
|
||||
// BUG-1505 source pins: the lease-expiry sweep releases the reservation before
|
||||
// it fails a job, stays service_role-only and invoker, and never closes a job
|
||||
// whose release failed. The behaviour runs against Postgres in
|
||||
// database-report-lease-expiry-release-20261102.test.ts (staging gate).
|
||||
|
||||
const migrationsUrl = new URL("../supabase/migrations/", import.meta.url);
|
||||
const migrationName = "20261102010000_report_lease_expiry_release.sql";
|
||||
const migration = readFileSync(new URL(migrationName, migrationsUrl), "utf8");
|
||||
|
||||
function functionBody(): string {
|
||||
const start = migration.indexOf("create or replace function public.recover_expired_personal_report_jobs(");
|
||||
assert.ok(start >= 0, "recovery function missing");
|
||||
const end = migration.indexOf("$$;", start);
|
||||
return migration.slice(start, end);
|
||||
}
|
||||
|
||||
test("the newest definition of the recovery sweep is this migration", () => {
|
||||
const definers = readdirSync(migrationsUrl)
|
||||
.filter((name) => name.endsWith(".sql"))
|
||||
.filter((name) => readFileSync(new URL(name, migrationsUrl), "utf8")
|
||||
.includes("function public.recover_expired_personal_report_jobs("))
|
||||
.sort();
|
||||
assert.equal(definers.at(-1), migrationName);
|
||||
assert.match(migration, /^begin;/);
|
||||
assert.match(migration.trimEnd(), /commit;$/);
|
||||
assert.doesNotMatch(migration, /\bdrop\s+(function|table|trigger|policy)\b/i);
|
||||
});
|
||||
|
||||
test("the sweep keeps its signature, invoker mode, empty search_path and service_role-only grant", () => {
|
||||
const body = functionBody();
|
||||
assert.match(body, /\(\s*p_limit integer default 100\s*\)\s*returns table \(retrying_count integer, failed_count integer\)/);
|
||||
assert.match(body, /language plpgsql\s+security invoker\s+set search_path = ''/);
|
||||
assert.doesNotMatch(migration, /security definer/);
|
||||
assert.match(
|
||||
migration,
|
||||
/revoke all on function public\.recover_expired_personal_report_jobs\(integer\) from public, anon, authenticated;/,
|
||||
);
|
||||
assert.match(migration, /grant execute on function public\.recover_expired_personal_report_jobs\(integer\) to service_role;/);
|
||||
assert.doesNotMatch(migration, /grant[^;]*\bto (public|anon|authenticated)\b/);
|
||||
});
|
||||
|
||||
test("every row that ends failed releases under the report request id before the update", () => {
|
||||
const body = functionBody();
|
||||
const release = body.indexOf("from public.release_usage(");
|
||||
const update = body.indexOf("return query");
|
||||
assert.ok(release > 0 && update > release, "release must run before the status update");
|
||||
assert.match(body, /public\.release_usage\(\s*v_candidate\.user_id,\s*v_candidate\.request_id::text,/);
|
||||
// Same rule as the status case: not ready, and failed or out of attempts.
|
||||
assert.match(
|
||||
body,
|
||||
/v_candidate\.report_status <> 'ready'\s+and \(\s*v_candidate\.report_status = 'failed'\s+or v_candidate\.attempt_count >= v_candidate\.max_attempts\s*\)/,
|
||||
);
|
||||
assert.match(body, /when job\.attempt_count < job\.max_attempts then 'retrying'\s+else 'failed'/);
|
||||
// Only rows whose release settled reach the update.
|
||||
assert.match(body, /v_job_ids := pg_catalog\.array_append\(v_job_ids, v_candidate\.id\)/);
|
||||
assert.match(body, /where job\.id = any\(v_job_ids\)/);
|
||||
});
|
||||
|
||||
test("a failed release is caught per row, logged with an errorCode and retried by the next sweep", () => {
|
||||
const body = functionBody();
|
||||
assert.match(body, /exception when others then\s+v_release_success := false;/);
|
||||
assert.match(
|
||||
body,
|
||||
/and v_release_error is distinct from 'request_completed'\s+and v_release_error is distinct from 'profile_missing'/,
|
||||
);
|
||||
assert.match(body, /errorCode=report_refund_failed/);
|
||||
assert.match(body, /raise warning[\s\S]*?continue;/);
|
||||
// Candidate rows are locked job-then-report and skipped when busy, as before.
|
||||
assert.match(body, /order by job\.lease_expires_at, job\.id\s+for update skip locked\s+limit p_limit/);
|
||||
});
|
||||
|
||||
test("release_usage is callable from the sweep: service_role holds EXECUTE and the registry does not withhold it", () => {
|
||||
const billing = readFileSync(new URL("20260806030000_settle_order_usage_authorization.sql", migrationsUrl), "utf8");
|
||||
assert.match(billing, /grant execute on function[^;]*public\.release_usage\(uuid,text,text\)[^;]*to service_role;/);
|
||||
const registry = readFileSync(new URL("20260814010000_immutable_skill_registry.sql", migrationsUrl), "utf8");
|
||||
assert.doesNotMatch(registry, /release_usage/);
|
||||
const later = readdirSync(migrationsUrl)
|
||||
.filter((name) => name.endsWith(".sql") && name > "20260806030000_settle_order_usage_authorization.sql")
|
||||
.map((name) => readFileSync(new URL(name, migrationsUrl), "utf8"));
|
||||
for (const text of later) {
|
||||
assert.doesNotMatch(text, /revoke[^;]*release_usage/i);
|
||||
}
|
||||
});
|
||||
|
||||
test("both refund-failure log events pass the closed observability schema", () => {
|
||||
const ids = {
|
||||
requestId: "22222222-2222-4222-8222-222222222222",
|
||||
runId: "44444444-4444-4444-8444-444444444444",
|
||||
caseId: "66666666-6666-4666-8666-666666666666",
|
||||
};
|
||||
agentObservabilityEventSchema.parse({
|
||||
requestId: ids.requestId,
|
||||
runId: ids.runId,
|
||||
errorCode: "report_refund_failed",
|
||||
billingSettlementResult: "failed",
|
||||
});
|
||||
agentObservabilityEventSchema.parse({
|
||||
caseId: ids.caseId,
|
||||
requestId: ids.requestId,
|
||||
errorCode: "rectification_refund_failed",
|
||||
billingSettlementResult: "failed",
|
||||
});
|
||||
const worker = readFileSync(new URL("../src/lib/personal-report-worker-core.ts", import.meta.url), "utf8");
|
||||
assert.match(worker, /errorCode: "report_refund_failed"/);
|
||||
assert.match(worker, /if \(!\(await releaseBeforeFailure\(job, code\)\)\) return "refund_deferred";/);
|
||||
const finish = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run-finish.ts", import.meta.url), "utf8");
|
||||
assert.match(finish, /errorCode: "rectification_refund_failed"/);
|
||||
assert.doesNotMatch(finish, /await billing\.release\(\);/);
|
||||
});
|
||||
@@ -60,6 +60,7 @@ try:
|
||||
_foreground_vedastro_join_seconds,
|
||||
_join_foreground_vedastro,
|
||||
_run_foreground_vedastro_gateway,
|
||||
defers_optional_vedastro,
|
||||
finish_foreground_vedastro,
|
||||
should_include_western_evidence_packet,
|
||||
start_foreground_vedastro,
|
||||
@@ -83,6 +84,7 @@ except ModuleNotFoundError: # pragma: no cover - script execution path
|
||||
_foreground_vedastro_join_seconds,
|
||||
_join_foreground_vedastro,
|
||||
_run_foreground_vedastro_gateway,
|
||||
defers_optional_vedastro,
|
||||
finish_foreground_vedastro,
|
||||
should_include_western_evidence_packet,
|
||||
start_foreground_vedastro,
|
||||
@@ -2151,7 +2153,8 @@ def execute_consultation_workflow(
|
||||
question = body.get('question') or ''
|
||||
entry_mode = body.get('entry_mode', 'direct_chart')
|
||||
high_rigor = bool(body.get('return_high_rigor_shape'))
|
||||
defer_optional_external_evidence = bool(body.get('defer_optional_external_evidence'))
|
||||
# A report's VedAstro budget defers like consult, with a longer deadline (BUG-1508).
|
||||
defer_optional_external_evidence = defers_optional_vedastro(body)
|
||||
try:
|
||||
from scripts.three_engine_parity_replay_validator import validate_manifest
|
||||
except ModuleNotFoundError: # pragma: no cover - direct script execution
|
||||
@@ -2901,6 +2904,21 @@ def _attach_vedastro_main_entry_overview(chart_result, birth_payload):
|
||||
or birth_payload.get('today')
|
||||
or datetime.utcnow().strftime('%Y-%m-%d')
|
||||
)[:10]
|
||||
try:
|
||||
return _attach_vedastro_main_entry_overview_from(orchestrator, priority, chart_result, modules, birth_payload, reference_date)
|
||||
except Exception as exc: # BUG-1504: the overview is optional evidence; never a 500.
|
||||
modules['vedastro_range_scan_result'] = {
|
||||
'backend': 'vedastro_service_adapter_candidate',
|
||||
'available': False,
|
||||
'status': 'blocked',
|
||||
'reason': 'vedastro_main_entry_overview_error',
|
||||
'error_type': type(exc).__name__,
|
||||
'source_metadata': {'ingestion_profile': 'main_entry_overview', 'reference_date': reference_date},
|
||||
}
|
||||
return chart_result
|
||||
|
||||
|
||||
def _attach_vedastro_main_entry_overview_from(orchestrator, priority, chart_result, modules, birth_payload, reference_date):
|
||||
vedastro_evidence = orchestrator.orchestrate_vedastro_evidence({
|
||||
'year': birth_payload.get('year'),
|
||||
'month': birth_payload.get('month'),
|
||||
|
||||
@@ -139,6 +139,9 @@ CORE_PYTEST_TARGETS = [
|
||||
"tests/test_report_english_sweep_smoke.py",
|
||||
# /api/shadbala with the birth moment over real HTTP, plus ruff F821 on the API server (BUG-1463).
|
||||
"tests/test_shadbala_service_api.py",
|
||||
# Report waits on VedAstro only to its deadline, then blocked + remembered (BUG-1508);
|
||||
# a cut VedAstro response is a network error, /api/chart stays 200 (BUG-1504).
|
||||
"tests/test_vedastro_report_deadline_20261102.py",
|
||||
]
|
||||
|
||||
RUNTIME_TRUTH_PYTEST_TARGETS = [
|
||||
|
||||
@@ -19,11 +19,31 @@ budget <= k * join (k=2) keeps leftover occupancy of an already-running
|
||||
worker from stretching later requests past the join cap. The previous
|
||||
default budget of 8s with a 1.5s join left cancelled-too-late workers
|
||||
holding the two-thread pool for the rest of the 8s.
|
||||
|
||||
Report profile (body ``vedastro_evidence_budget="report"``, gap 2 of
|
||||
docs/operations/outage-behaviour-2026-11-01.md, BUG-1508). A report used to
|
||||
wait synchronously on the main-entry overview and the rectification gate,
|
||||
about 128s when VedAstro hangs, past the frontend's 90s. A report now runs
|
||||
the same deferred session as consultation, with a longer wait so a healthy
|
||||
VedAstro still lands in the report:
|
||||
|
||||
deadline: measured from session start, so it overlaps local compute
|
||||
(default 25s, cap 45s).
|
||||
budget: the per-request timeout already configured for the adapter, never
|
||||
more than k * deadline.
|
||||
|
||||
When the deadline passes while the gateway is still running, the report
|
||||
continues with VedAstro marked ``official_blocked`` /
|
||||
``report_vedastro_deadline_exceeded`` and that is remembered for the chart
|
||||
and UTC day, so the next report answers at once. The worker keeps running;
|
||||
if it later succeeds the positive snapshot wins, if it fails its own failure
|
||||
replaces the deadline entry.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
|
||||
from typing import Any
|
||||
|
||||
@@ -65,6 +85,13 @@ FOREGROUND_VEDASTRO_BUDGET_JOIN_RATIO = 2.0
|
||||
FOREGROUND_VEDASTRO_WORKERS_DEFAULT = 2
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
# --- report profile: deadline / budget (change together, see docstring) ---
|
||||
REPORT_VEDASTRO_EVIDENCE_BUDGET = "report"
|
||||
REPORT_VEDASTRO_DEADLINE_SECONDS_DEFAULT = 25.0
|
||||
REPORT_VEDASTRO_DEADLINE_SECONDS_MAX = 45.0
|
||||
REPORT_VEDASTRO_DEADLINE_EXCEEDED = "report_vedastro_deadline_exceeded"
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
_FOREGROUND_VEDASTRO_WORKERS = max(
|
||||
int(os.environ.get("JYOTISH_FOREGROUND_VEDASTRO_WORKERS", str(FOREGROUND_VEDASTRO_WORKERS_DEFAULT))),
|
||||
1,
|
||||
@@ -98,6 +125,35 @@ def _foreground_vedastro_budget_seconds() -> float:
|
||||
return min(budget, cap)
|
||||
|
||||
|
||||
def is_report_vedastro_budget(body: dict | None) -> bool:
|
||||
payload = body if isinstance(body, dict) else {}
|
||||
return str(payload.get("vedastro_evidence_budget") or "").strip().lower() == REPORT_VEDASTRO_EVIDENCE_BUDGET
|
||||
|
||||
|
||||
def defers_optional_vedastro(body: dict | None) -> bool:
|
||||
"""Consult asks with the flag; a report budget implies it (BUG-1508)."""
|
||||
payload = body if isinstance(body, dict) else {}
|
||||
return bool(payload.get("defer_optional_external_evidence")) or is_report_vedastro_budget(payload)
|
||||
|
||||
|
||||
def _report_vedastro_deadline_seconds() -> float:
|
||||
raw = str(os.environ.get("JYOTISH_REPORT_VEDASTRO_DEADLINE_SECONDS", str(REPORT_VEDASTRO_DEADLINE_SECONDS_DEFAULT))).strip()
|
||||
try:
|
||||
return min(max(float(raw), 0.0), REPORT_VEDASTRO_DEADLINE_SECONDS_MAX)
|
||||
except ValueError:
|
||||
return REPORT_VEDASTRO_DEADLINE_SECONDS_DEFAULT
|
||||
|
||||
|
||||
def _report_vedastro_budget_seconds() -> float:
|
||||
"""The adapter's own per-request timeout; a report never raises it."""
|
||||
try:
|
||||
from scripts.vedastro_service_adapter import _timeout_seconds as configured_timeout
|
||||
except ModuleNotFoundError: # pragma: no cover - script execution path
|
||||
from vedastro_service_adapter import _timeout_seconds as configured_timeout
|
||||
cap = FOREGROUND_VEDASTRO_BUDGET_JOIN_RATIO * _report_vedastro_deadline_seconds()
|
||||
return min(float(configured_timeout()), cap)
|
||||
|
||||
|
||||
def _blocked_foreground_vedastro(*, reason: str, error_type: str | None = None) -> dict:
|
||||
packet = {
|
||||
"scope": "vedastro_gateway_run",
|
||||
@@ -110,11 +166,18 @@ def _blocked_foreground_vedastro(*, reason: str, error_type: str | None = None)
|
||||
return packet
|
||||
|
||||
|
||||
def _run_foreground_vedastro_gateway(handler, body: dict, cancel_event: threading.Event | None = None) -> dict:
|
||||
def _run_foreground_vedastro_gateway(
|
||||
handler,
|
||||
body: dict,
|
||||
cancel_event: threading.Event | None = None,
|
||||
*,
|
||||
budget_seconds: float | None = None,
|
||||
) -> dict:
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
return _blocked_foreground_vedastro(reason="foreground_optional_evidence_timeout")
|
||||
budget = _foreground_vedastro_budget_seconds() if budget_seconds is None else budget_seconds
|
||||
try:
|
||||
with temporary_timeout_seconds(_foreground_vedastro_budget_seconds()):
|
||||
with temporary_timeout_seconds(budget):
|
||||
result = handler._compute_vedastro_gateway_run(body)
|
||||
except Exception as exc:
|
||||
return _blocked_foreground_vedastro(
|
||||
@@ -150,8 +213,9 @@ def _run_foreground_vedastro_gateway_and_store(
|
||||
handler,
|
||||
body: dict,
|
||||
cancel_event: threading.Event | None = None,
|
||||
budget_seconds: float | None = None,
|
||||
) -> dict:
|
||||
result = _run_foreground_vedastro_gateway(handler, body, cancel_event)
|
||||
result = _run_foreground_vedastro_gateway(handler, body, cancel_event, budget_seconds=budget_seconds)
|
||||
# A run cancelled before it started never asked VedAstro: nothing to remember.
|
||||
if not (cancel_event is not None and cancel_event.is_set() and result.get("official_closure_reason") == "foreground_optional_evidence_timeout"):
|
||||
_remember_gateway_outcome(body, result)
|
||||
@@ -198,8 +262,38 @@ class ForegroundVedastroSession:
|
||||
self.future = None
|
||||
self.cancel_event = threading.Event()
|
||||
self.meta: dict[str, Any] = {}
|
||||
self.report = is_report_vedastro_budget(self.body)
|
||||
self.started_at = time.monotonic()
|
||||
|
||||
def _budget_seconds(self) -> float | None:
|
||||
return _report_vedastro_budget_seconds() if self.report else None
|
||||
|
||||
def _join_seconds(self) -> float:
|
||||
if not self.report:
|
||||
return _foreground_vedastro_join_seconds()
|
||||
return max(self.started_at + _report_vedastro_deadline_seconds() - time.monotonic(), 0.0)
|
||||
|
||||
def _report_deadline_exceeded(self, result: dict) -> dict:
|
||||
"""A report gave up on a gateway that is still running: remember it for today."""
|
||||
future = self.future
|
||||
if (
|
||||
not self.report
|
||||
or result.get("official_closure_reason") != "foreground_optional_evidence_timeout"
|
||||
or future is None
|
||||
or future.cancelled()
|
||||
or future.done()
|
||||
):
|
||||
return result
|
||||
packet = _blocked_foreground_vedastro(reason=REPORT_VEDASTRO_DEADLINE_EXCEEDED)
|
||||
packet["deadline_seconds"] = _report_vedastro_deadline_seconds()
|
||||
try:
|
||||
store_negative_snapshot(self.body, packet)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return packet
|
||||
|
||||
def start(self) -> "ForegroundVedastroSession":
|
||||
self.started_at = time.monotonic()
|
||||
today = official_snapshot_reference_date(self.body)
|
||||
hit = lookup_snapshot(self.body, today=today)
|
||||
if hit is not None:
|
||||
@@ -221,6 +315,7 @@ class ForegroundVedastroSession:
|
||||
self.handler,
|
||||
refresh_body,
|
||||
None,
|
||||
self._budget_seconds(),
|
||||
)
|
||||
self.cached_gateway = gateway
|
||||
self.meta = {
|
||||
@@ -253,6 +348,7 @@ class ForegroundVedastroSession:
|
||||
self.handler,
|
||||
dict(self.body),
|
||||
self.cancel_event,
|
||||
self._budget_seconds(),
|
||||
)
|
||||
return self
|
||||
|
||||
@@ -260,11 +356,12 @@ class ForegroundVedastroSession:
|
||||
if self.cached_gateway is not None:
|
||||
return self.cached_gateway
|
||||
if self.defer:
|
||||
return _join_foreground_vedastro(
|
||||
result = _join_foreground_vedastro(
|
||||
self.future,
|
||||
timeout=_foreground_vedastro_join_seconds(),
|
||||
timeout=self._join_seconds(),
|
||||
cancel_event=self.cancel_event,
|
||||
)
|
||||
return self._report_deadline_exceeded(result)
|
||||
try:
|
||||
result = self.handler._compute_vedastro_gateway_run(self.body)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -13,6 +13,7 @@ and remains ``official_blocked``.
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
@@ -155,7 +156,8 @@ def probe_calculate_health(*, opener: Any | None = None, timeout: float = 8) ->
|
||||
"endpoint": API,
|
||||
"transport": "rest",
|
||||
}
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as error:
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError, http.client.HTTPException) as error:
|
||||
# HTTPException: IncompleteRead and friends when the body is cut (BUG-1504).
|
||||
return {
|
||||
"status": "official_blocked",
|
||||
"reason": type(error).__name__,
|
||||
|
||||
@@ -14,6 +14,7 @@ import hashlib
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
@@ -2016,6 +2017,12 @@ def _acquire_free_tier_slot(request_url: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
# A connection cut while reading headers or the body. urllib only wraps errors
|
||||
# raised while sending, so these reach the caller raw (BUG-1504). RemoteDisconnected
|
||||
# is both an HTTPException and a ConnectionResetError.
|
||||
_TRANSPORT_READ_ERRORS = (http.client.HTTPException, ConnectionError, ssl.SSLError)
|
||||
|
||||
|
||||
def _post_json(endpoint: str, request_preview: dict[str, Any]) -> dict[str, Any] | str:
|
||||
request_url, headers, vedastro_payload = _build_live_request(endpoint, request_preview)
|
||||
req = request.Request(
|
||||
@@ -2024,9 +2031,15 @@ def _post_json(endpoint: str, request_preview: dict[str, Any]) -> dict[str, Any]
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
with request.urlopen(req, timeout=_timeout_seconds()) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
return json.loads(raw)
|
||||
try:
|
||||
with request.urlopen(req, timeout=_timeout_seconds()) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
except error.URLError:
|
||||
raise
|
||||
except _TRANSPORT_READ_ERRORS as exc:
|
||||
# Every caller already turns URLError into network_error.
|
||||
raise error.URLError(f"response_read_failed:{type(exc).__name__}") from exc
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def _retry_status_codes() -> set[int]:
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
"""BUG-1508 and BUG-1504: VedAstro can no longer stall or break a report.
|
||||
|
||||
BUG-1508 (outage audit gap 2): a report now waits for VedAstro only up to its
|
||||
deadline, then goes on with VedAstro marked blocked and remembers that for the
|
||||
day, so the next report does not wait again.
|
||||
|
||||
BUG-1504: a response cut while it is being read is a network error, like the
|
||||
other transport failures, and the main-entry overview never turns into a 500.
|
||||
|
||||
VedAstro is always a stub here; nothing leaves the machine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import io
|
||||
import socket
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
from urllib import error
|
||||
|
||||
import pytest
|
||||
|
||||
import scripts.vedastro_foreground as foreground
|
||||
import scripts.vedastro_service_adapter as adapter
|
||||
from scripts.jyotish_api_server import JyotishComputeService
|
||||
from scripts.vedastro_consultation_snapshot import lookup_negative_snapshot
|
||||
from scripts.vedastro_rest_bridge import probe_calculate_health
|
||||
from scripts.vedastro_runtime_context import timeout_override_seconds
|
||||
from tests.test_api_heavy_compute_gate import _PostCaptureHandler
|
||||
|
||||
TODAY = "2026-11-02"
|
||||
BIRTH = {"year": 1990, "month": 6, "day": 15, "hour": 10, "minute": 20, "lat": 31.2, "lon": 121.5, "tz": 8,
|
||||
"ayanamsa": "lahiri", "today": TODAY}
|
||||
REPORT = {**BIRTH, "vedastro_evidence_budget": "report"}
|
||||
BLOCKED = {"scope": "vedastro_gateway_run", "status": "official_blocked", "official_closure_state": "official_blocked",
|
||||
"official_closure_reason": "official_raw_response_missing"}
|
||||
VERIFIED = {"scope": "vedastro_gateway_run", "status": "ok", "official_closure_state": "official_verified",
|
||||
"official_closure_reason": "official_raw_response_present", "official_raw_response": {"ok": True}}
|
||||
|
||||
|
||||
class Gateway:
|
||||
"""Stands in for the handler's gateway call; `hang` blocks until released."""
|
||||
|
||||
def __init__(self, result=None, *, hang=False, raises=None, delay=0.0):
|
||||
self.result, self.hang, self.raises, self.delay = result, hang, raises, delay
|
||||
self.calls = 0
|
||||
self.release = threading.Event()
|
||||
self.timeouts: list[float | None] = []
|
||||
|
||||
def _compute_vedastro_gateway_run(self, body):
|
||||
self.calls += 1
|
||||
self.timeouts.append(timeout_override_seconds())
|
||||
if self.hang:
|
||||
self.release.wait(5)
|
||||
time.sleep(self.delay)
|
||||
if self.raises is not None:
|
||||
raise self.raises
|
||||
return dict(self.result or BLOCKED)
|
||||
|
||||
|
||||
def _report(handler, body=REPORT, *, pause=0.0):
|
||||
started = time.perf_counter()
|
||||
session = foreground.start_foreground_vedastro(
|
||||
handler, body, defer_optional_external_evidence=foreground.defers_optional_vedastro(body),
|
||||
)
|
||||
time.sleep(pause) # local compute runs here in the real workflow
|
||||
gateway = foreground.finish_foreground_vedastro(session)
|
||||
return gateway, time.perf_counter() - started, session.meta
|
||||
|
||||
|
||||
def _drain():
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline and not foreground._FOREGROUND_VEDASTRO_EXECUTOR._work_queue.empty():
|
||||
time.sleep(0.01)
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def short_deadline(monkeypatch):
|
||||
monkeypatch.setenv("JYOTISH_REPORT_VEDASTRO_DEADLINE_SECONDS", "0.4")
|
||||
|
||||
|
||||
# ---- BUG-1508: deadline, blocked, remembered --------------------------------
|
||||
|
||||
|
||||
def test_a_report_budget_defers_and_a_plain_request_still_does_not():
|
||||
assert foreground.defers_optional_vedastro(REPORT)
|
||||
assert foreground.defers_optional_vedastro({**BIRTH, "defer_optional_external_evidence": True})
|
||||
assert not foreground.defers_optional_vedastro(BIRTH)
|
||||
|
||||
|
||||
def test_a_hung_vedastro_stops_the_report_at_its_deadline_and_the_next_report_does_not_wait(short_deadline):
|
||||
hung = Gateway(hang=True)
|
||||
try:
|
||||
gateway, seconds, meta = _report(hung)
|
||||
assert meta["freshness"] == "miss"
|
||||
assert 0.3 <= seconds < 0.9, seconds
|
||||
assert gateway["official_closure_state"] == "official_blocked"
|
||||
assert gateway["official_closure_reason"] == foreground.REPORT_VEDASTRO_DEADLINE_EXCEEDED
|
||||
remembered = lookup_negative_snapshot(REPORT, today=TODAY)
|
||||
assert remembered["official_closure_reason"] == foreground.REPORT_VEDASTRO_DEADLINE_EXCEEDED
|
||||
|
||||
again, again_seconds, again_meta = _report(hung)
|
||||
assert again_seconds < 0.15, again_seconds
|
||||
assert again_meta["freshness"] == "negative"
|
||||
assert again == gateway
|
||||
assert hung.calls == 1, "the second report did not ask VedAstro again"
|
||||
finally:
|
||||
hung.release.set()
|
||||
_drain()
|
||||
|
||||
|
||||
def test_the_deadline_counts_from_the_start_so_local_compute_overlaps_it(short_deadline):
|
||||
hung = Gateway(hang=True)
|
||||
try:
|
||||
_, seconds, _ = _report(hung, pause=0.3)
|
||||
assert seconds < 0.7, f"waited the whole deadline again after local compute: {seconds}"
|
||||
finally:
|
||||
hung.release.set()
|
||||
_drain()
|
||||
|
||||
|
||||
def test_a_gateway_that_finishes_after_the_deadline_replaces_the_deadline_entry(short_deadline):
|
||||
late = Gateway(VERIFIED, hang=True)
|
||||
gateway, _, _ = _report(late)
|
||||
assert gateway["official_closure_reason"] == foreground.REPORT_VEDASTRO_DEADLINE_EXCEEDED
|
||||
late.release.set()
|
||||
_drain()
|
||||
again, _, meta = _report(Gateway(BLOCKED))
|
||||
assert meta["freshness"] == "fresh", "a later success wins over the deadline entry"
|
||||
assert again["official_closure_state"] == "official_verified"
|
||||
|
||||
|
||||
def test_a_fast_5xx_failure_returns_at_once_and_is_remembered(short_deadline):
|
||||
failing = Gateway({**BLOCKED, "official_closure_reason": "http_error:503"})
|
||||
gateway, seconds, _ = _report(failing)
|
||||
assert seconds < 0.3 and gateway["official_closure_reason"] == "http_error:503"
|
||||
_drain()
|
||||
again, _, meta = _report(failing)
|
||||
assert failing.calls == 1 and meta["freshness"] == "negative" and again == gateway
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exc", [http.client.IncompleteRead(b"{", 10), ConnectionResetError(104, "reset")])
|
||||
def test_a_gateway_that_raises_a_read_error_is_blocked_and_remembered(short_deadline, exc):
|
||||
broken = Gateway(raises=exc)
|
||||
gateway, _, _ = _report(broken)
|
||||
assert gateway["official_closure_state"] == "official_blocked"
|
||||
assert gateway["error_type"] == type(exc).__name__
|
||||
_drain()
|
||||
assert lookup_negative_snapshot(REPORT, today=TODAY)["error_type"] == type(exc).__name__
|
||||
|
||||
|
||||
def test_a_healthy_vedastro_inside_the_deadline_is_used(short_deadline):
|
||||
healthy = Gateway(VERIFIED, delay=0.05)
|
||||
gateway, _, _ = _report(healthy)
|
||||
assert gateway == VERIFIED
|
||||
assert lookup_negative_snapshot(REPORT, today=TODAY) is None
|
||||
|
||||
|
||||
def test_a_report_keeps_the_configured_request_timeout_and_never_raises_it(monkeypatch):
|
||||
monkeypatch.setenv("VEDASTRO_TIMEOUT_SECONDS", "20")
|
||||
seen = Gateway(VERIFIED)
|
||||
_report(seen)
|
||||
monkeypatch.setenv("VEDASTRO_TIMEOUT_SECONDS", "120")
|
||||
monkeypatch.setenv("JYOTISH_REPORT_VEDASTRO_DEADLINE_SECONDS", "25")
|
||||
_report(Gateway(VERIFIED), body={**REPORT, "today": "2026-11-03"})
|
||||
_drain()
|
||||
assert seen.timeouts == [20.0]
|
||||
assert foreground._report_vedastro_budget_seconds() == 50.0, "capped at k * deadline"
|
||||
|
||||
|
||||
def test_the_deadline_has_a_cap_well_inside_the_frontend_wait(monkeypatch):
|
||||
monkeypatch.setenv("JYOTISH_REPORT_VEDASTRO_DEADLINE_SECONDS", "600")
|
||||
assert foreground._report_vedastro_deadline_seconds() == foreground.REPORT_VEDASTRO_DEADLINE_SECONDS_MAX
|
||||
assert foreground.REPORT_VEDASTRO_DEADLINE_SECONDS_MAX <= 45, "frontend waits 90 s per workflow call"
|
||||
|
||||
|
||||
def test_a_consultation_join_timeout_is_still_not_remembered(monkeypatch):
|
||||
monkeypatch.setenv("JYOTISH_FOREGROUND_VEDASTRO_JOIN_SECONDS", "0.05")
|
||||
hung = Gateway(hang=True)
|
||||
try:
|
||||
gateway, _, _ = _report(hung, body={**BIRTH, "defer_optional_external_evidence": True})
|
||||
assert gateway["official_closure_reason"] == "foreground_optional_evidence_timeout"
|
||||
assert lookup_negative_snapshot(BIRTH, today=TODAY) is None
|
||||
finally:
|
||||
hung.release.set()
|
||||
_drain()
|
||||
|
||||
|
||||
def test_the_report_workflow_skips_the_overview_and_marks_vedastro_unavailable(short_deadline, monkeypatch):
|
||||
monkeypatch.setenv("JYOTISH_API_CHART_CACHE_TTL_SECONDS", "0")
|
||||
hung = Gateway(hang=True)
|
||||
service = JyotishComputeService()
|
||||
monkeypatch.setattr(JyotishComputeService, "_compute_vedastro_gateway_run",
|
||||
lambda self, body: hung._compute_vedastro_gateway_run(body))
|
||||
|
||||
def no_overview(*args, **kwargs):
|
||||
raise AssertionError("a report must not run the synchronous main-entry overview")
|
||||
|
||||
monkeypatch.setattr("scripts.jyotish_api_server._attach_vedastro_main_entry_overview", no_overview)
|
||||
body = {**REPORT, "question": "请为个人报告计算 career 主题证据", "theme": ["career"], "entry_mode": "direct_chart"}
|
||||
try:
|
||||
result = service._compute_consultation_workflow(dict(body))
|
||||
sections = result["machine_evidence_packet"]["sections"]
|
||||
assert sections["external_oracle_status"]["status"] == "official_blocked"
|
||||
assert sections["vedastro_official_raw_response"]["status"] == "missing"
|
||||
cross_check = result["consumer_context"]["vedastro_cross_check"]
|
||||
assert cross_check["official_closure_reason"] == foreground.REPORT_VEDASTRO_DEADLINE_EXCEEDED
|
||||
|
||||
started = time.perf_counter()
|
||||
service._compute_consultation_workflow(dict(body))
|
||||
assert hung.calls == 1, "the second report answered from the remembered failure"
|
||||
assert time.perf_counter() - started < 30
|
||||
finally:
|
||||
hung.release.set()
|
||||
_drain()
|
||||
|
||||
|
||||
# ---- BUG-1504: truncated responses are network errors -----------------------
|
||||
|
||||
|
||||
class _CutResponse:
|
||||
def __init__(self, exc):
|
||||
self.exc = exc
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc_info):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
raise self.exc
|
||||
|
||||
|
||||
def _urlopen_raising(exc, *, on_read):
|
||||
def fake(req, timeout=None, *args, **kwargs):
|
||||
if on_read:
|
||||
return _CutResponse(exc)
|
||||
raise exc
|
||||
|
||||
return fake
|
||||
|
||||
|
||||
READ_ERRORS = [
|
||||
pytest.param(http.client.IncompleteRead(b'{"Status":', 200), True, id="incomplete-read"),
|
||||
pytest.param(ConnectionResetError(104, "Connection reset by peer"), True, id="reset-on-read"),
|
||||
pytest.param(ConnectionResetError(104, "Connection reset by peer"), False, id="reset-on-headers"),
|
||||
pytest.param(http.client.RemoteDisconnected("closed"), False, id="remote-disconnected"),
|
||||
pytest.param(ssl.SSLError("bad record mac"), True, id="ssl"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stub_network(monkeypatch):
|
||||
monkeypatch.setenv("VEDASTRO_API_ENDPOINT", "https://oracle.stub.invalid/api")
|
||||
monkeypatch.setenv("VEDASTRO_ENABLE_NETWORK", "1")
|
||||
monkeypatch.setenv("VEDASTRO_CACHE_TTL_SECONDS", "0")
|
||||
monkeypatch.setenv("VEDASTRO_RETRY_BACKOFF_SECONDS", "0")
|
||||
monkeypatch.setenv("JYOTISH_API_CHART_CACHE_TTL_SECONDS", "0")
|
||||
empty = {"available": False, "status": "stubbed", "snapshot_sections": {}, "section_statuses": {}}
|
||||
monkeypatch.setattr(adapter, "_try_official_capability_runner_snapshot_bundle", lambda case: dict(empty))
|
||||
monkeypatch.setattr(adapter, "_try_official_python_bridge_snapshot_bundle", lambda case: dict(empty))
|
||||
monkeypatch.setattr(adapter, "_try_official_full_capability_catalog_bundle", lambda case: dict(empty))
|
||||
monkeypatch.setattr(adapter, "_load_cached_payload", lambda key: (None, None))
|
||||
monkeypatch.setattr(adapter, "_write_artifact", lambda result: None)
|
||||
|
||||
|
||||
def _case():
|
||||
return {**BIRTH, "case_id": "stub", "reference_date": TODAY}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exc,on_read", READ_ERRORS)
|
||||
def test_a_cut_response_is_a_network_error_in_the_snapshot_and_the_range_scan(stub_network, monkeypatch, exc, on_read):
|
||||
monkeypatch.setattr(adapter.request, "urlopen", _urlopen_raising(exc, on_read=on_read))
|
||||
with pytest.raises(error.URLError):
|
||||
adapter._post_json("https://oracle.stub.invalid/api", {"operation": "official_full_snapshot",
|
||||
"section": "x", "official_request_profile": {}})
|
||||
snapshot = adapter._run_official_full_snapshot_case(_case(), case_id="stub")
|
||||
statuses = [value for value in snapshot["section_statuses"].values() if isinstance(value, str)]
|
||||
expected = f"network_error:response_read_failed:{type(exc).__name__}"
|
||||
assert expected in statuses, statuses
|
||||
assert "ok" not in statuses and snapshot["status"] != "ok"
|
||||
scan = adapter._run_range_scan_case(_case(), "career", "2026-11-01", "2026-11-30")
|
||||
assert scan["status"] == "network_error" and scan["available"] is False
|
||||
|
||||
|
||||
def test_a_5xx_is_still_an_http_error(stub_network, monkeypatch):
|
||||
def fake(req, timeout=None, *args, **kwargs):
|
||||
raise error.HTTPError(req.full_url, 503, "Service Unavailable", {}, io.BytesIO(b""))
|
||||
|
||||
monkeypatch.setattr(adapter.request, "urlopen", fake)
|
||||
snapshot = adapter._run_official_full_snapshot_case(_case(), case_id="stub")
|
||||
assert any(value == "http_error:503" for value in snapshot["section_statuses"].values())
|
||||
|
||||
|
||||
def test_a_read_timeout_is_still_a_timeout(stub_network, monkeypatch):
|
||||
monkeypatch.setattr(adapter.request, "urlopen", _urlopen_raising(socket.timeout("timed out"), on_read=True))
|
||||
scan = adapter._run_range_scan_case(_case(), "career", "2026-11-01", "2026-11-30")
|
||||
assert scan["status"] == "timeout"
|
||||
|
||||
|
||||
def _chart_request():
|
||||
return _PostCaptureHandler("/api/chart", {**BIRTH, "date": "1990-06-15", "time": "10:20"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exc,on_read", READ_ERRORS[:2])
|
||||
def test_api_chart_with_the_overview_returns_200_when_vedastro_cuts_the_response(stub_network, monkeypatch, exc, on_read):
|
||||
monkeypatch.setattr(adapter.request, "urlopen", _urlopen_raising(exc, on_read=on_read))
|
||||
handler = _chart_request()
|
||||
handler.do_POST()
|
||||
assert handler.status_code == 200, handler.wfile.getvalue()[:300]
|
||||
overview = handler.payload()["modules"]["vedastro_range_scan_result"]
|
||||
assert overview["status"] != "ok"
|
||||
assert overview["official_full_snapshot"]["status"] != "ok"
|
||||
|
||||
|
||||
def test_api_chart_overview_attach_fails_soft_on_any_error(monkeypatch):
|
||||
monkeypatch.setenv("JYOTISH_API_CHART_CACHE_TTL_SECONDS", "0")
|
||||
from scripts.jyotish_api_server import _load_local_module
|
||||
|
||||
orchestrator = _load_local_module("vedastro_evidence_orchestrator")
|
||||
|
||||
def cut(*args, **kwargs):
|
||||
raise http.client.IncompleteRead(b"", 5)
|
||||
|
||||
monkeypatch.setattr(orchestrator, "orchestrate_vedastro_evidence", cut)
|
||||
handler = _chart_request()
|
||||
handler.do_POST()
|
||||
assert handler.status_code == 200, handler.wfile.getvalue()[:300]
|
||||
overview = handler.payload()["modules"]["vedastro_range_scan_result"]
|
||||
assert overview["status"] == "blocked"
|
||||
assert overview["reason"] == "vedastro_main_entry_overview_error"
|
||||
assert overview["error_type"] == "IncompleteRead"
|
||||
|
||||
|
||||
def test_the_rest_health_probe_blocks_on_a_cut_body():
|
||||
def opener(req, timeout=None):
|
||||
return _CutResponse(http.client.IncompleteRead(b"{", 9))
|
||||
|
||||
result = probe_calculate_health(opener=opener)
|
||||
assert result["status"] == "official_blocked" and result["reason"] == "IncompleteRead"
|
||||
Reference in New Issue
Block a user