Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef7b0d1224 | ||
|
|
7a1987d572 | ||
|
|
a56bf39143 | ||
|
|
770b141e7f | ||
|
|
92ef755c6e | ||
|
|
e12d877b36 | ||
|
|
1a5d25e51d |
@@ -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 对照在门禁内波动,分支与基线单独重跑均通过。目录预检超时,定向预检复现既有历史镜像路径断言失败。
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-11-13 — 个人报告解读可等待较慢的模型返回
|
||||
|
||||
- 单次解读等待上限从 120 秒增至 240 秒。剩余时间不足一次完整重试时,报告保留第一稿中通过检查的内容。沿用当前 Skill 版本(BUG-1874)。
|
||||
|
||||
## 2026-11-13 — 选盘可分轮核对并保存到秒
|
||||
|
||||
- 新版选盘接口按关键换盘线逐处确认,最多三轮。仍会换座的分盘列为未定。短区间可保存段内秒级时刻,历史记录可读回。沿用当前 Skill 版本(BUG-1872)。
|
||||
|
||||
@@ -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`,待发布。
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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 校验。
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user