Compare commits

...
Author SHA1 Message Date
jesse-ux ef7b0d1224 Keep the first report draft when the retry is skipped
Independent Staging Quality Gate / validate-python (pull_request) Successful in 6m55s
Independent Staging Quality Gate / validate-frontend (pull_request) Successful in 7m12s
Independent Staging Quality Gate / publish (pull_request) Skipped
Refs #240, BUG-1874
2026-11-13 14:12:07 +08:00
ClaudeandClaude Opus 5.5 7a1987d572 docs(tasks): #240 fix brief 1 — keep first draft when retry is skipped
Independent Staging Quality Gate / validate-python (pull_request) Successful in 7m34s
Independent Staging Quality Gate / validate-frontend (pull_request) Successful in 7m38s
Independent Staging Quality Gate / publish (pull_request) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXozsiu43ob7B8UZCmDM
2026-11-13 13:36:48 +08:00
jesse-ux a56bf39143 Merge remote-tracking branch 'origin/staging' into codex/reading-timeout-20261113
Independent Staging Quality Gate / validate-python (pull_request) Canceled after 0s
Independent Staging Quality Gate / validate-frontend (pull_request) Canceled after 0s
Independent Staging Quality Gate / publish (pull_request) Canceled after 0s
# Conflicts:
#	CHANGELOG.md
#	docs/BUG_HISTORY.md
#	docs/tasks/README.md
2026-11-13 13:32:07 +08:00
jesse-ux 770b141e7f Allow report readings to finish within the worker budget
Refs #240, BUG-1874
2026-11-13 13:31:42 +08:00
ClaudeandClaude Opus 5.5 92ef755c6e Merge #238 chart-choice rounds engine, D6/D8, seconds adoption (BUG-1872)
Independent Staging Quality Gate / validate-python (push) Successful in 8m34s
Independent Staging Quality Gate / validate-frontend (push) Successful in 5m47s
Independent Staging Quality Gate / publish (push) Successful in 3m3s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXozsiu43ob7B8UZCmDM
2026-11-13 13:14:53 +08:00
jesse-ux e12d877b36 Merge remote-tracking branch 'origin/codex/reading-timeout-20261113' into codex/reading-timeout-20261113 2026-11-13 13:05:44 +08:00
ClaudeandClaude Opus 5.5 1a5d25e51d docs(tasks): brief for #240 reading-timeout
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXozsiu43ob7B8UZCmDM
2026-11-13 09:59:38 +08:00
16 changed files with 272 additions and 13 deletions
+5
View File
@@ -1,5 +1,10 @@
# BLOCKED
## #240 · Windows 门禁与公开盘复核
- Docker Desktop 的 Linux 引擎返回 HTTP 500,容器用例无法在本机验证。目录预检扫描超时。生产模型对两张公开盘的实跑留给独立验收。
- 替代证据:假时钟、报告快照和离线回放通过,前端用同配置与 staging 对比。详见 [#240 进度](docs/tasks/PROGRESS-reading-timeout-20261113.md)。
## #237 · Windows 全量门禁与预检
- 快速门的三项 PyJHora 检查在同机基线复现。咨询 VedAstro 对照在门禁内波动,分支与基线单独重跑均通过。目录预检超时,定向预检复现既有历史镜像路径断言失败。
+4
View File
@@ -1,5 +1,9 @@
# 印度占星 Skill 更新日志
## 2026-11-13 — 个人报告解读可等待较慢的模型返回
- 单次解读等待上限从 120 秒增至 240 秒。剩余时间不足一次完整重试时,报告保留第一稿中通过检查的内容。沿用当前 Skill 版本(BUG-1874)。
## 2026-11-13 — 选盘可分轮核对并保存到秒
- 新版选盘接口按关键换盘线逐处确认,最多三轮。仍会换座的分盘列为未定。短区间可保存段内秒级时刻,历史记录可读回。沿用当前 Skill 版本(BUG-1872)。
+14
View File
@@ -25255,3 +25255,17 @@
- 验证与防复发:合成短段和顺序回归、实际引擎 D6/D8 扫描、隔离 PostgreSQL 秒级档案与历史读回,见 [#238 进度](tasks/PROGRESS-chart-choice-rounds-engine-20261113.md)。
- 关联记录:#238、#239、BUG-1654、BUG-1865。
- 修复版本:`codex/chart-choice-rounds-engine-20261113`,待发布。
## BUG-1874 | 报告解读在模型完成前超时
- 状态:fixed-pending-verify(待独立验收)。
- 首次发现 / 最近更新:2026-11-13 / 2026-11-13。
- 影响面:个人报告解读、worker 重试与离线验收工具。
- 现象:公开盘验收时,解读模型完成一次写作所需时间超过 120 秒。两次尝试均超时,报告只展示计算正文。
- 触发条件:模型写作持续超过旧上限,或者 worker 完成报告前剩余的初始租期不足以安排第二次完整写作。
- 根因:单次上限短于实际生成时间,旧重试条件未考虑报告前序计算与收尾的时间。
- 修复:单次上限提高到 240 秒,验收工具沿用同一常量。距 worker 开始后的租期预算终点不足 300 秒时跳过第二次写作。首次验收发现跳过时清除了通过检查的第一稿。修复后保留该稿,仍隐藏被拦的章节。第一稿不可用时保留其失败原因。超时和跳过分别记录原因与耗时。
- 验证:假时钟覆盖 200 秒完成、250 秒超时及 300 秒重试边界。报告集成回归验证跳过后仍持久化计算正文、可用第一稿及该稿的跳过记录。线上模型复核待独立验收。
- 防复发:锁定两次尝试上限、起算点、跳过原因、快照结果及脱敏日志字段。
- 关联记录:#240、#220、BUG-1863。详见 [执行进度](tasks/PROGRESS-reading-timeout-20261113.md)。
- 修复版本:`codex/reading-timeout-20261113`,待发布。
+4
View File
@@ -2,6 +2,10 @@
Purpose: read this file before substantial project work. It exists to stop repeat mistakes caused by multiple Codex windows, WorkBuddy mirrors, local drafts, backup folders, and partial cloud-git visibility.
## 2026-11-13 · #240 Windows 报告预检
- 本轮复现目录扫描 90 秒超时。延长预检外层时限后,预检测试中的独立目录扫描仍在 120 秒超时。Python 和适配器诊断通过,Gitea 分支用官方 API 与 Git 核对。构建遇到指向工作树外的依赖联接时,改用从同锁文件安装结果复制到本工作树的依赖。见 [#240 进度](../tasks/PROGRESS-reading-timeout-20261113.md)。
## 2026-11-13 · #237 Windows 报告预检
- 远端可见性、Python 和适配器检查通过。首次目录扫描及定向测试超时。外层命令时限延长至 180 秒后,目录扫描仍在 90 秒超时,定向测试仅余既有 `.workbuddy` 镜像路径断言失败。见 [#237 进度](../tasks/PROGRESS-reading-window-edge-20261113.md)。
+21
View File
@@ -0,0 +1,21 @@
# #240 验收退回:跳过重试时不能把第一稿丢掉(2026-11-13,Claude)
验收对象 `a56bf391`。其余部分都通过,不要改动:240 秒超时、租期预算、日志、验收工具共用同一个常量。
## 问题
`writeReportReading` 判断剩余时间不够、跳过第二次时,执行了 `best = null; break;`。如果第一次已经写出一稿,只是有节被拦,这一稿也会被扔掉,报告最后没有解读。原意只是「不再重试」,不是「放弃已有的一稿」。
## 改法
- 跳过重试时**保留 `best`**。只有第一次本身失败(超时、模型失败),才按无解读流程走。
- `reading_retry_skipped_deadline` 照旧写进日志和 attempts 记录,作为「为什么没有第二次」的说明。如果最终有一稿可用,它不能覆盖 `unavailable` 的含义;在 PROGRESS 里写明最终返回的字段。
## 验收
新增单测(假时钟):
- 第一次写出一稿,有 1 节被拦,剩余时间不够 → 返回第一稿,attempts 里有 skip 记录;
- 第一次超时,剩余时间不够 → 无解读,原因为 `model_timeout`,同时记录 skip。
原有测试全部通过。
@@ -0,0 +1,29 @@
# #240 报告解读超时与重试进度
- Codex 按规划评论 42797 执行。基线为 `origin/staging` 的 `efdb67906c5b76413396d7e41304ce685c4fccf1`,已包含 #220。合入分支上的任务书提交以保留规划记录。
- 发布前同步 staging `92ef755c6e3ea1a70d89f834b0f27f4f3c013586`,纳入独立验收通过的 #238。两单交集为共享文档,合并后复核两单的记录与报告定向用例。
- 第二次写作前检查 `generationStartedAt + leaseSeconds * 1_000 - Date.now()`。起算点复用 worker 开始处理已认领任务时的时间,包含加载档案和计算报告所用时间。生产租期配置为 600 秒,心跳照常续租,重试预算始终以起算点计算。
- 单次写作上限为 240 秒。剩余时间至少 300 秒才可重试一次,预留 60 秒用于报告收尾。预算不足时,保存原有计算正文并记录 `reading_retry_skipped_deadline`。脱敏日志分别输出 `model_timeout` 和跳过原因及相应耗时。验收工具直接使用生产写作的超时常量。
- 新增回归覆盖假时钟下的 200 秒成功、250 秒超时、低于及等于 300 秒、两次尝试上限、worker 预算起算点、跳过重试后的无解读快照。既有断言沿用原值。
- 生产模型的两张公开盘实跑由独立验收完成。
## 验证
- 报告定向 86 项通过,包括离线验收工具的原稿回放和工件哈希核对。TypeScript 通过,ESLint 0 error、62 条既有 warning。生产构建首页仍为静态。逐一压缩首页 HTML 引用的资源后,JS 为 494476 B、CSS 为 90906 B,与相同前端源码基线一致。
- 前端全量同配置跑出 3698 项、3617 通过、45 失败、36 跳过、0 取消。基线为 3692 项、3611 通过、45 失败、36 跳过、0 取消。六条新用例全部通过,失败名称与基线逐项一致。默认并发的 `npm test` 另有一条咨询时钟用例失败,单独复跑通过。
- Docker Desktop 返回 HTTP 500。两侧用相同的进程 PATH 排除不可用的 Docker CLI,容器测试按原条件跳过。Python 与适配器预检通过,目录扫描两次超时。Git 和官方 API 均核对了远端任务分支。
- Python 快速门在实施基线运行至 pytest,1463 项通过、2 项跳过、29 项子测试通过、3 项失败。失败名称与 Windows 基线一致,均为 `test_upstream_technique_layers.py` 的 Kaala / PyJHora 校验。#238 在其验收分支的本机快速门也只有这三项失败。
## 第一次验收返修
- Claude 在评论 42937 退回 `a56bf39143ad842bf8c64ecebc6fb52821358918`。按 [修复单](FIX-reading-timeout-1.md) 保留通过检查的第一稿,继续隐藏被拦的章节。日志仍记录跳过原因和耗时。
- 跳过时新增 `attempts[1]`,字段为 `attempt: 2`、`status: "skipped"`、`reason: "reading_retry_skipped_deadline"`、`sections: []`。`retries` 为 0,因为只调用过一次。已有解读时返回 `reading` 和 `selected_attempt: 1`,省略 `unavailable`。第一稿超时或模型失败时,返回空解读,`unavailable` 分别为 `model_timeout` 或 `model_failed`,`selected_attempt` 为空。
| 原断言 | 新断言 | 原因 |
| --- | --- | --- |
| 预算不足丢弃有一节被拦的第一稿 | 保留第一稿及其用量,维持该节拦截,记录未执行的第二次 | 修复单要求保存已通过检查的部分 |
| 超时或失败后跳过重试,结果原因改成跳过 | 结果和无解读快照保留第一稿失败原因,另在 attempts 记录跳过 | 区分解读未生成的原因与重试未执行的原因 |
- 返修后 87 项报告定向通过。TypeScript、ESLint 和生产构建通过,ESLint 为 0 error、62 条既有 warning。首页仍为静态,首屏 gzip 与首次交接一致。
- 同配置前端全量 3700 项、3618 通过、45 失败、37 跳过、0 取消。失败名称与首次交接的 Windows 基线逐项一致。与首次本机测试相比,已合入的 #238 增加一项容器测试,因 Docker 不可用而跳过;本轮新增一项首稿快照测试。默认并发的 `npm test` 再现咨询时钟用例的波动,单独复跑通过。
- 返修后的 Python 快速门运行至 pytest,1471 项通过、2 项跳过、29 项子测试通过、3 项失败。三个失败名称与第一次执行及 Windows staging 基线一致,均为 Kaala / PyJHora 校验。
+1
View File
@@ -33,6 +33,7 @@
| 任务书 | 进度 | 主题 | 状态 | 落点 |
| --- | --- | --- | --- | --- |
| [#240 任务书](TASK-reading-timeout-20261113.md) | [执行进度](PROGRESS-reading-timeout-20261113.md) | 报告解读超时与租期内重试(BUG-1874) | 待验收 | `codex/reading-timeout-20261113` |
| [#238 任务书](TASK-chart-choice-rounds-engine-20261113.md) | [执行进度](PROGRESS-chart-choice-rounds-engine-20261113.md) | 多轮选盘与秒级采纳(BUG-1872) | 待验收 | `codex/chart-choice-rounds-engine-20261113` |
| [#237 任务书](TASK-reading-window-edge-20261113.md) | [执行进度](PROGRESS-reading-window-edge-20261113.md) | 个人报告区间端点换座判定(BUG-1871) | 待验收 | `codex/reading-window-edge-20261113` |
| [#220 修订任务书](FIX-reading-snapshot-binding-1.md) | [执行进度](PROGRESS-reading-snapshot-binding-20261112.md) | 个人报告生成快照、逐节拦截原因和离线验收包(BUG-1863) | 待验收 | `codex/reading-snapshot-binding-20261112` |
@@ -0,0 +1,40 @@
# TASK:报告解读超时由 120 秒放宽,重试按剩余租期决定(2026-11-13)
- Gitea:#240
- 分支:`codex/reading-timeout-20261113`,基于 staging `3e6077f752b606fee69a9eb15853fe328801901b`
- Bug 编号:BUG-1874
- 验收:Claude(jyotisha-e6)
不得自行写「产品决定/裁定」;任务书没写的取舍一律选需拍板。不得修改或删除既有断言来让测试通过;确需修改的,在 PROGRESS 里写明原值、新值、原因。
## 现象(验收主机实测,2026-11-13)
- 生产模型 `deepseek-flash` 用现行提示(#219 之后)写一份个人报告解读,实测两次分别用了 **126 秒和 141 秒**。
- `REPORT_READING_TIMEOUT_MS = 120_000`(`frontend/src/lib/personal-report-reading.ts`),所以按生产配置跑,两张公开盘各两次尝试**全部超时**(#220 验收工具 `model_timeout`)。
- 报告解读是可选项。超时之后报告照常生成,但用户看不到解读。
- worker 租期是 `leaseSeconds: 600`(`personal-report-worker.ts`)。
## 要做的
1. 单次解读超时改为 240 秒。
2. 解读最多写两次(`writeReportReading` 已有这个上限)。第二次之前先看剩余时间:从 worker 开始算起,剩下的不到「240 秒 + 60 秒安全余量」,就不再重试,直接按现在的「无解读」流程收尾,并记下原因 `reading_retry_skipped_deadline`。
- 剩余时间要从现有的租期或开始时间推算,在 PROGRESS 写明用的是哪个量。
- 不得延长租期。
3. 超时和跳过重试都要在现有日志里留一行,只记耗时和原因,不记正文。
4. #220 的验收工具 `tools/report-reading-acceptance/run.mts` 使用同一个常量,不另设超时。
## 验收
1. 单元测试:
- 用假写作器,第一次耗时 200 秒(用假时钟模拟,不真等)→ 不超时;
- 第一次耗时 250 秒 → 超时;
- 剩余时间不足时 → 不重试,原因写对。
2. 原有测试全部通过;tsc 无错误,eslint 0 个错误,Python 快速门通过。
3. 验收时我会在验收主机用 `deepseek-flash` 对两张公开盘各跑一次。
## 不要碰
- 提示内容
- 复查规则
- 租期
- 冻结文件
@@ -72,6 +72,7 @@ export type LongformGenerateDeps = Readonly<{
signal?: AbortSignal;
/** Writes the reading in front of the engine report (issue #113 R1). Absent: no reading is requested. */
writeReading?: ReportReadingWriter;
readingRetryDeadlineMs?: number;
}>;
function text(value: unknown): string | null {
@@ -249,6 +250,7 @@ export async function generatePersonalReportLongform(
birthTimeConfirmed: readingMinuteConfirmed,
write: deps.writeReading,
signal: deps.signal,
retryDeadlineMs: deps.readingRetryDeadlineMs,
})
: null;
if (reading) {
@@ -50,7 +50,7 @@ export const readingSectionTransformSchema = z.strictObject({
export const readingAttemptSchema = z.strictObject({
attempt: z.number().int().positive(),
raw_response_sha256: sha256.optional(),
status: z.enum(["accepted", "rejected", "failed"]),
status: z.enum(["accepted", "rejected", "failed", "skipped"]),
reason: reason.optional(),
sections: z.array(readingSectionTransformSchema),
});
+22 -6
View File
@@ -38,7 +38,8 @@ export {
reportReadingSchema,
type ReportReading,
} from "./personal-report-reading-contract";
export const REPORT_READING_TIMEOUT_MS = 120_000;
export const REPORT_READING_TIMEOUT_MS = 240_000;
const REPORT_READING_RETRY_RESERVE_MS = 60_000;
/** Never in the reading: deterministic fate, medical, investment (AGENTS Part B4). */
const BANNED_PHRASES = [
@@ -643,8 +644,8 @@ function timedSignal(parent: AbortSignal | undefined, timeoutMs: number) {
}
/**
* One attempt plus one retry. A draft with withheld sections is kept, and the
* retry is used only if it withholds fewer. Never throws, except when the job
* One attempt plus one retry when the budget allows it. Keep the accepted
* draft with the fewest withheld sections. Never throws, except when the job
* itself is aborted.
*/
export async function writeReportReading(input: Readonly<{
@@ -656,6 +657,7 @@ export async function writeReportReading(input: Readonly<{
signal?: AbortSignal;
timeoutMs?: number;
attempts?: number;
retryDeadlineMs?: number;
}>): Promise<ReportReadingOutcome> {
const facts = acceptReadingFacts(input.facts);
if (!facts) return { reading: null, unavailable: "facts_unavailable", inputTokens: 0, outputTokens: 0 };
@@ -666,9 +668,19 @@ export async function writeReportReading(input: Readonly<{
let unavailable = "model_failed";
const attempts: ReadingAttempt[] = [];
let best: { reading: CheckedReportReading; withheld: number; rawSha256: string; attempt: number } | null = null;
for (let attempt = 0; attempt < (input.attempts ?? 2); attempt += 1) {
const timeoutMs = input.timeoutMs ?? REPORT_READING_TIMEOUT_MS;
const startedAt = Date.now();
for (let attempt = 0; attempt < Math.min(input.attempts ?? 2, 2); attempt += 1) {
input.signal?.throwIfAborted();
const timed = timedSignal(input.signal, input.timeoutMs ?? REPORT_READING_TIMEOUT_MS);
const attemptStartedAt = Date.now();
if (attempt > 0 && input.retryDeadlineMs !== undefined
&& input.retryDeadlineMs - attemptStartedAt < timeoutMs + REPORT_READING_RETRY_RESERVE_MS) {
const reason = "reading_retry_skipped_deadline";
console.info(`[personal-report] reason=${reason} duration_ms=${attemptStartedAt - startedAt}`);
attempts.push({ attempt: attempt + 1, status: "skipped", reason, sections: [] });
break;
}
const timed = timedSignal(input.signal, timeoutMs);
try {
const result = await Promise.race([
input.write({ system, user, signal: timed.signal }),
@@ -691,12 +703,16 @@ export async function writeReportReading(input: Readonly<{
} catch {
if (input.signal?.aborted) throw input.signal.reason ?? new Error("aborted");
unavailable = timed.signal.aborted ? "model_timeout" : "model_failed";
if (unavailable === "model_timeout") {
console.info(`[personal-report] reason=${unavailable} duration_ms=${Date.now() - attemptStartedAt}`);
}
attempts.push({ attempt: attempt + 1, status: "failed", reason: unavailable, sections: [] });
} finally {
timed.dispose();
}
}
const transformLog = { selected_attempt: best?.attempt ?? null, retries: Math.max(0, attempts.length - 1), attempts };
const completedAttempts = attempts.filter(({ status }) => status !== "skipped").length;
const transformLog = { selected_attempt: best?.attempt ?? null, retries: Math.max(0, completedAttempts - 1), attempts };
const reading = best ? bindReportReadingSnapshot({
reading: best.reading, rawSha256: best.rawSha256, transformLog,
reportInput: input.reportInput, facts, system, gitCommit: input.gitCommit,
@@ -69,6 +69,8 @@ export type PersonalReportWorkerGenerationContext = Readonly<{
report: PersonalReportRecord;
profile: unknown;
signal: AbortSignal;
/** End of the initial generation budget, regardless of lease heartbeats. */
readingRetryDeadlineMs: number;
sectionService?: PersonalReportSectionService;
billing?: ReportBillingPort;
onProgress?: (progress: Readonly<{ phase: string; completed: number; total: number }>) => Promise<void> | void;
@@ -393,6 +395,7 @@ export function createPersonalReportWorker(deps: PersonalReportWorkerDeps) {
report,
profile,
signal: controller.signal,
readingRetryDeadlineMs: generationStartedAt + leaseSeconds * 1_000,
sectionService: deps.sectionService,
onProgress: async (progress) => {
try {
@@ -229,6 +229,7 @@ async function generateLongformReport(
birthTimeStatus: usableBirth.status,
signal: context.signal,
writeReading: createReportReadingWriter(model),
readingRetryDeadlineMs: context.readingRetryDeadlineMs,
});
if (generated.status !== "ready") return generated;
return {
+23 -1
View File
@@ -93,12 +93,15 @@ function createHarness(input: Readonly<{
generate?: (context: {
profile: unknown;
report: PersonalReportRecord;
readingRetryDeadlineMs: number;
onProgress?: (progress: Readonly<{ phase: string; completed: number; total: number }>) => Promise<void> | void;
}) => Promise<GeneratePersonalReportResult>;
loseLeaseBeforeCompletion?: boolean;
markReadyError?: PersonalReportJobServiceError;
billing?: ReportBillingPort;
logObservability?: (event: unknown) => never;
leaseSeconds?: number;
now?: () => Date;
}>) {
let job = input.job ?? jobRecord();
let report = input.report === undefined ? reportRecord() : input.report;
@@ -353,7 +356,8 @@ function createHarness(input: Readonly<{
}
: undefined,
...(input.logObservability ? { logObservability: input.logObservability } : {}),
now: () => NOW,
leaseSeconds: input.leaseSeconds,
now: input.now ?? (() => NOW),
retryDelayMs: () => 1_000,
heartbeatIntervalMs: 60_000,
});
@@ -396,6 +400,24 @@ function subjectClient(row: unknown) {
} };
}
test("report loading and generation share the initial reading retry budget", async () => {
let now = NOW;
const harness = createHarness({
leaseSeconds: 600,
now: () => now,
loadProfile: async () => {
now = new Date(NOW.getTime() + 310_000);
return { birth_time_status: "confirmed" };
},
generate: async ({ readingRetryDeadlineMs }) => {
assert.equal(readingRetryDeadlineMs, NOW.getTime() + 600_000);
assert.equal(readingRetryDeadlineMs - now.getTime(), 290_000);
return { status: "ready", document: READY_DOCUMENT, evidenceHash: "c".repeat(64) };
},
});
assert.equal((await harness.worker.tick()).outcome, "ready");
});
test("queued report worker passes only persisted other birth data to the fake engine", async () => {
const harness = createHarness({
report: reportRecord({ chartProfileId: OTHER_ID }),
+100 -4
View File
@@ -295,6 +295,85 @@ test("a cancelled job still cancels; the reading never swallows the abort", asyn
await assert.rejects(pending, /job_cancelled/);
});
test("a report reading that takes 200 seconds finishes before the default timeout", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "Date"] });
const write = Object.assign(async () => {
await new Promise<void>((resolve) => setTimeout(resolve, 200_000));
return { body: draft(), inputTokens: 0, outputTokens: 0 };
}, { prompt: PROMPT });
const pending = writeReportReading({ reportInput: {}, facts: FACTS, birthTimeConfirmed: true, write });
t.mock.timers.tick(200_000);
assert.ok((await pending).reading);
});
test("a 250-second reading times out and skips the retry with less than 300 seconds left", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "Date"] });
const log = t.mock.method(console, "info", () => {});
const run = t.mock.fn(async () => {
await new Promise<void>((resolve) => setTimeout(resolve, 250_000));
return { body: draft(), inputTokens: 0, outputTokens: 0 };
});
const pending = writeReportReading({
reportInput: {}, facts: FACTS, birthTimeConfirmed: true,
write: Object.assign(run, { prompt: PROMPT }),
retryDeadlineMs: Date.now() + 539_999,
});
t.mock.timers.tick(240_000);
const outcome = await pending;
assert.equal(outcome.reading, null);
assert.equal(outcome.unavailable, "model_timeout");
assert.equal(outcome.transformLog?.attempts[0]?.reason, "model_timeout");
assert.deepEqual(outcome.transformLog?.attempts[1], {
attempt: 2, status: "skipped", reason: "reading_retry_skipped_deadline", sections: [],
});
assert.equal(outcome.transformLog?.retries, 0);
assert.equal(run.mock.callCount(), 1);
assert.deepEqual(log.mock.calls.map((call) => call.arguments), [
["[personal-report] reason=model_timeout duration_ms=240000"],
["[personal-report] reason=reading_retry_skipped_deadline duration_ms=240000"],
]);
});
test("exactly 300 seconds left allows one retry even if more attempts were requested", async (t) => {
t.mock.timers.enable({ apis: ["Date"] });
const calls: string[] = [];
const outcome = await writeReportReading({
reportInput: {}, facts: FACTS, birthTimeConfirmed: true,
write: writer([new Error("500"), new Error("500"), draft()], calls),
attempts: 3, retryDeadlineMs: Date.now() + 300_000,
});
assert.equal(calls.length, 2);
assert.equal(outcome.reading, null);
assert.equal(outcome.unavailable, "model_failed");
assert.equal(outcome.transformLog?.retries, 1);
});
test("skipping the retry keeps the accepted first draft with one withheld section", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "Date"] });
const write = t.mock.fn(async () => {
await new Promise<void>((resolve) => setTimeout(resolve, 200_000));
return { body: draft({ health: ["要小心糖尿病。"] }), inputTokens: 1000, outputTokens: 500 };
});
const pending = writeReportReading({
reportInput: {}, facts: FACTS, birthTimeConfirmed: true,
write: Object.assign(write, { prompt: PROMPT }),
retryDeadlineMs: Date.now() + 499_999,
});
t.mock.timers.tick(200_000);
const outcome = await pending;
assert.ok(outcome.reading);
assert.deepEqual(outcome.reading.sections.filter(({ status }) => status === "withheld").map(({ id }) => id), ["health"]);
assert.equal(outcome.unavailable, undefined);
assert.equal(outcome.transformLog?.selected_attempt, 1);
assert.equal(outcome.transformLog?.retries, 0);
assert.deepEqual(outcome.reading.transform_log.attempts[1], {
attempt: 2, status: "skipped", reason: "reading_retry_skipped_deadline", sections: [],
});
assert.equal(outcome.inputTokens, 1000);
assert.equal(outcome.outputTokens, 500);
assert.equal(write.mock.callCount(), 1);
});
const identity = {
reportId: "11111111-1111-4111-8111-111111111111",
userId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
@@ -425,7 +504,7 @@ test("the worker writes the reading in the report job and bills its tokens", ()
assert.match(worker, /outputTokens: generated\.usage\?\.outputTokens \?\? 0/);
});
for (const mode of ["reading", "model-down", "no-writer"]) {
for (const mode of ["reading", "model-down", "no-writer", "retry-skipped", "partial-retry-skipped"]) {
test(`report job with the reading: ${mode}`, () => {
const script = `
import assert from "node:assert/strict";
@@ -439,7 +518,9 @@ for (const mode of ["reading", "model-down", "no-writer"]) {
const golden = JSON.parse(readFileSync("tests/fixtures/report-density-fictional-reader.json", "utf8"));
const mode = ${JSON.stringify(mode)};
const draft = ${JSON.stringify(draft())};
const partialDraft = ${JSON.stringify(draft({ health: ["要小心糖尿病。"] }))};
const facts = ${JSON.stringify(FACTS)};
let writes = 0;
const rows = new Map(); const bodies = [];
const admin = { async rpc(name, args) {
const table = "personal_report_sections"; const current = rows.get(table);
@@ -453,13 +534,15 @@ for (const mode of ["reading", "model-down", "no-writer"]) {
}; }};
const report = { id: "123e4567-e89b-12d3-a456-426614174000", userId: "123e4567-e89b-12d3-a456-426614174001", requestId: "123e4567-e89b-12d3-a456-426614174002", reportType: "personal_full", presentationMode: "default", depth: "standard", requestedThemes: ["career"], skillName: "jyotish-vedic-astrology", skillVersion: "6.9.14", skillSourceCommit: null, skillSnapshotSha256: "ab".repeat(32) };
const writeReading = mode === "no-writer" ? undefined : Object.assign(async () => {
if (mode === "model-down") throw new Error("model down");
return { body: draft, inputTokens: 1200, outputTokens: 800 };
writes += 1;
if (mode === "model-down" || mode === "retry-skipped") throw new Error("model down");
return { body: mode === "partial-retry-skipped" ? partialDraft : draft, inputTokens: 1200, outputTokens: 800 };
}, { prompt: { system: "system", skill: ${JSON.stringify(SKILL)} } });
const gender = mode === "reading" ? "female" : mode === "model-down" ? "unknown" : null;
const result = await generatePersonalReportLongform({ report, admin, profile: { birth_date: "2000-01-01", reported_birth_time: "12:00", birth_time_status: "reported", latitude: 0, longitude: 0, timezone_offset: 0, ayanamsa: "lahiri", gender }, candidateRange: null, displayName: "虚构测试", birthTimeStatus: "reported", now: () => new Date("2026-10-27T12:00:00Z"),
fetchImpl: async (_url, init) => { bodies.push(JSON.parse(init.body)); return Response.json({ format: "markdown", markdown: golden.markdown, fact_table_packet: packet, reader_dasha_applicability: golden.reader_dasha_applicability, ...(mode === "no-writer" ? {} : { reading_facts: facts }) }); },
...(writeReading ? { writeReading } : {}) });
...(writeReading ? { writeReading } : {}),
...(mode.endsWith("retry-skipped") ? { readingRetryDeadlineMs: Date.now() + 290_000 } : {}) });
assert.equal(result.status, "ready");
assert.equal(bodies.length, 1);
assert.equal(bodies[0].include_reading_facts, mode === "no-writer" ? undefined : true);
@@ -478,6 +561,19 @@ for (const mode of ["reading", "model-down", "no-writer"]) {
assert.equal(stored.snapshot.reading, undefined);
assert.equal(stored.snapshot.readingUnavailable, "model_failed");
assert.deepEqual(result.usage, { inputTokens: 0, outputTokens: 0 });
} else if (mode === "retry-skipped") {
assert.equal(writes, 1);
assert.equal(stored.snapshot.reading, undefined);
assert.equal(stored.snapshot.readingUnavailable, "model_failed");
assert.deepEqual(result.usage, { inputTokens: 0, outputTokens: 0 });
} else if (mode === "partial-retry-skipped") {
assert.equal(writes, 1);
assert.equal(stored.snapshot.readingUnavailable, undefined);
assert.deepEqual(stored.snapshot.reading.sections.filter(({ status }) => status === "withheld").map(({ id }) => id), ["health"]);
assert.equal(stored.snapshot.reading.transform_log.selected_attempt, 1);
assert.deepEqual(stored.snapshot.reading.transform_log.attempts[1],
{ attempt: 2, status: "skipped", reason: "reading_retry_skipped_deadline", sections: [] });
assert.deepEqual(result.usage, { inputTokens: 1200, outputTokens: 800 });
} else {
assert.equal(stored.snapshot.reading, undefined);
assert.equal(stored.snapshot.readingUnavailable, undefined);
+2 -1
View File
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
import { reportReadingRules } from "../../frontend/src/lib/personal-report-reading-audit.ts";
import { acceptReadingFacts, writeReportReading, type ReportReadingWriter } from "../../frontend/src/lib/personal-report-reading.ts";
import { acceptReadingFacts, REPORT_READING_TIMEOUT_MS, writeReportReading, type ReportReadingWriter } from "../../frontend/src/lib/personal-report-reading.ts";
import { canonicalReportReadingJson, canonicalReportReadingSha256, reportReadingSha256 } from "../../frontend/src/lib/report-reading-canonical-json.ts";
import { buildReportExportBlocks } from "../../frontend/src/lib/report-export-blocks.ts";
import { reportReadingPrompt } from "../../frontend/src/mastra/report-reading-skill.ts";
@@ -80,6 +80,7 @@ async function main() {
}, { prompt: provider?.prompt ?? reportReadingPrompt() });
const outcome = await writeReportReading({
facts, reportInput: input, birthTimeConfirmed: true, write, gitCommit: commit,
timeoutMs: REPORT_READING_TIMEOUT_MS,
...(values.replay ? { attempts: 1 } : {}),
});
const { reading, transformLog } = outcome;