Compare commits

..
Author SHA1 Message Date
Claude 230a493fcd Merge BUG-1504/BUG-1508 VedAstro report deadline
Independent Staging Quality Gate / validate (push) Successful in 19m22s
Independent Staging Quality Gate / publish (push) Successful in 3m53s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y

# Conflicts:
#	CHANGELOG.md
#	docs/operations/outage-behaviour-2026-11-01.md
2026-11-02 07:50:47 +08:00
ClaudeandClaude Opus 5.5 bc4a6c2b49 fix(report): bound VedAstro wait in report generation (BUG-1508, BUG-1504)
Reports send vedastro_evidence_budget=report. The API defers like consult
(no sync main-entry overview, cache-only gate, gateway in the foreground
pool) with a 25 s deadline from session start (cap 45 s). On overrun the
report continues with VedAstro official_blocked /
report_vedastro_deadline_exceeded and the failure is stored in the day's
negative snapshot cache. The main-entry overview attach now fails soft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y
2026-11-02 07:50:03 +08:00
ClaudeandClaude Opus 5.5 87765d602c fix(vedastro): cut responses are network errors, not 500 (BUG-1504)
_post_json turns IncompleteRead, ConnectionResetError, RemoteDisconnected and
ssl.SSLError raised while reading into URLError, so snapshot dispatch, range
scan and external technique evidence record network_error. The REST health
probe also catches http.client.HTTPException.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y
2026-11-02 07:50:02 +08:00
Claude c881dfb4ae Merge BUG-1506 birthplace fix
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y

# Conflicts:
#	CHANGELOG.md
2026-11-02 01:08:26 +08:00
ClaudeandClaude Opus 5.5 6721d320d8 fix(reports): release credits when lease recovery fails a report (BUG-1505)
recover_expired_personal_report_jobs now releases the reservation (by the
report's request_id, idempotent with the worker and the delete trigger)
before it fails a job; a release that raises or errors leaves that row
running for the next sweep and logs errorCode=report_refund_failed.
Migration 20261102010000_report_lease_expiry_release.sql replaces only the
function (invoker, empty search_path, service_role only).

The report worker no longer closes a failed job when its refund fails: it
logs report_refund_failed and leaves the lease to the recovery sweep.
Rectification retries a failed release once with the same case request id
and logs rectification_refund_failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y
2026-11-02 01:06:19 +08:00
ClaudeandClaude Opus 5.5 4ab81821cd fix(profile): keep the saved birth place when the timezone lookup fails (BUG-1506)
The birth place picker reported null on a failed timezone lookup and on a
half-chosen level; BirthLocationFields then cleared the place and the picker
reset its draft. Editing a saved person's date, time or place during a
timezone outage silently erased the birthplace with no error or retry.
The same path reset the picker when changing province/city/country on edit
and when opening an overseas person while the region list loaded.

- The picker only reports a freshly resolved place; failures and unfinished
  choices keep the previous value and report "pending" instead.
- BirthPlacePendingScope + BirthPlaceSubmitButton disable 保存 (edit dialogs)
  and 确定 (onboarding) while pending.
- Failure copy for a person with a place: 「时区这会儿没查到,原来的出生地还在,
  查到后才能保存。」 with 重试; new profiles keep the existing copy.
- Track the moment the stored offset was resolved for, so going back to the
  original date after a successful lookup re-resolves instead of saving a
  stale offset; ignore late results from aborted lookups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y
2026-11-02 00:58:09 +08:00
29 changed files with 1862 additions and 169 deletions
+29
View File
@@ -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
View File
@@ -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 次的阈值。
+1
View File
@@ -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
+1 -1
View File
@@ -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} />;
}
+45 -17
View File
@@ -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
+36 -33
View File
@@ -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: "三引擎运行时已闭环",
+62 -37
View File
@@ -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,
+10 -2
View File
@@ -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;
+26 -10
View File
@@ -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\(\);/);
});
+19 -1
View File
@@ -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'),
+3
View File
@@ -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 = [
+102 -5
View File
@@ -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:
+3 -1
View File
@@ -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__,
+16 -3
View File
@@ -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"