Files
Jyotisha/docs/tasks/TASK-node-runtime-resources-20261013.md
T
Jesse_ChenandClaude Opus 5.5 2994588ad4 docs(tasks): fold product decisions into audit briefs (BUG-1344..1346)
- dasha-unify: fix Ashtottari birth balance (BUG-1344)
- rect-scoring-perf: server-side candidate cap 241 (BUG-1345)
- node-runtime-resources: enable user-side session cookie cache, 300 s (BUG-1346)
- consult-prep-parallel: classifier/reserve concurrency declined
- answer-effort-ab: model key supplied by product at acceptance
- drop stricter-than-test line-count red lines (AGENTS.md section 6)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v
2026-10-14 00:41:02 +08:00

28 KiB
Raw Blame History

TASK · Node 进程资源:报告 worker 监听泄漏、本地 Postgres 往返、无上限内存表(issue #待定)

  • 日期:2026-10-13
  • 来源:「2026-10-13 全仓屎山与性能排查」——P0「报告 worker 空转泄漏」、P1「数据库适配层往返」、小项「服务端无上限 Map」
  • 执行方:Grok(Windows + Docker),分支 codex/node-runtime-resources-20261013,只推这个分支
  • 验收:Claude(Linux)
  • BUG 编号:BUG-1326~1328、BUG-1346(开工时核对 docs/BUG_HISTORY.md 最大号;写作时最大号 BUG-1317;号段由主会话分配)(另:TASK-upstream-sync7-20261014 占 BUG-1318~1322,本批号段整体从 BUG-1323 起)
    • BUG-1326:报告 worker 空转每 2 秒挂一个 abort 监听,永不释放(P0)
    • BUG-1327:本地 Postgres 适配层每条查询 6 次往返、元数据每次现查(P1,需 A/B)
    • BUG-1328:三张服务端内存表只增不减(小)
    • BUG-1346:用户端开启 Better Auth 会话读缓存(产品 10-14 决定,maxAge 300 s,敏感操作绕过)
  • 开工前置:无(第一批立即派发)

1. 基线

  • 基线 commit:origin/staging 77057a819
  • 涉及文件:
    • frontend/src/lib/personal-report-worker-core.ts(defaultSleep、runPersonalReportWorkerLoop)
    • frontend/src/lib/personal-report-worker.ts(startPersonalReportWorker,由 frontend/src/instrumentation.ts::register 在 Node 运行时启动)
    • frontend/src/lib/birth-time-guided-polling.ts(scoringPollDelay,同型写法,见 T1)
    • frontend/src/lib/db/local-postgres-client-core.ts(inBusinessTransaction、columnTypes、primaryKeyColumns、functionMetadata、LocalPostgresQueryBuilder.execute、LocalPostgresDataClient.rpc)
    • frontend/src/lib/rectification-agentic/v9/block-scan-answer.ts(rescoreAttempts)
    • frontend/src/lib/rectification-agentic/v9/delivery-turn-guard.ts(delivered)
    • frontend/src/lib/request-rate-limit.ts(consumeRequestRateLimit)

2. 事故实证(77057a819)

2.1 报告 worker 监听泄漏(BUG-1326)

personal-report-worker-core.ts::defaultSleep 每次 sleep 都对传入的 signal 执行 addEventListener("abort", …, { once: true })。{ once: true } 只在 abort 触发时摘除;计时器正常到点 resolve 后,监听留在 signal 上。

runPersonalReportWorkerLoop 空闲(tick() 返回 idle)或 tick() 抛错时都会 await sleep(pollIntervalMs=2000, options.signal)。options.signal 来自 personal-report-worker.ts::startPersonalReportWorker 里进程级的 new AbortController(),只有 stop() 才 abort,生产里从不调用。于是每个 Next.js Node 进程每 2 秒多挂一个闭包(闭包还引用已过期的 timer),一天 43,200 个。

复现(虚构负载,与源码同形;Node 22.14.0,--expose-gc):

node --expose-gc leak-fixed.mjs         # 基线写法(脚本见文末附录)
node --expose-gc leak-fixed.mjs fixed   # 到点即摘监听
写法 sleep 次数 残留 abort 监听 GC 后堆增长
基线 43,200(= 空闲一天) 43,200 +22.25 MB
到点 removeEventListener 43,200 0 +0.19 MB

(abort 之后基线堆回落到 +0.36 MB,证明增长全部挂在这个 signal 上。)即每个 Node 进程约 22 MB/天单调上涨,直到容器重启;生产 4 GB 主机上与 Python API、Postgres 共用内存。

全仓 frontend/src 同类写法排查(addEventListener("abort" 全部 18 处逐一读过):

位置 是否在循环里对长寿命 signal 挂监听不摘 结论
personal-report-worker-core.ts::defaultSleep 是,signal 进程级 泄漏,T1 修
birth-time-guided-polling.ts::scoringPollDelay 是,同型写法;但 signal 是 use-birth-time-automatic-journey-effects.ts 里每次 effect 新建的 controller,maxAttempts: 7,最多残留 6 个,effect 卸载即随 controller 回收 有界、不泄漏;同一提交顺手改成到点摘除(同型写法不留第二份,T1b)
session-title-agent.ts、session-context-summary.ts、adopt-narration-agent.ts、turn-intent-classifier.ts::classifyWithinTimeout、agent-run-attempt.ts、consultation-smalltalk.ts、home-bootstrap-run.ts::runHomeBootstrap、home-cloud-sync.ts::waitForUndoWindow 否,均在 finally / dispose / 到点时 removeEventListener 干净
consultation-recovery-poll.ts::runConsultationRecoveryPoll 每次调用挂一个、不摘;signal 是 page.tsx 中每个 recovering effect 新建的 controller 有界、不改
mastra/consultation-tools.ts::createConsultationRunClock 对本次请求新建的 AbortSignal.timeout 挂一次 有界、不改

另两个进程级 worker(account-deletion-worker.ts、reply-quality-retention-worker.ts)用 setInterval,不碰 signal,干净。

2.2 本地 Postgres 适配层往返(BUG-1327)

自托管模式下所有业务读写走 LocalPostgresDataClient(lib/supabase/server.ts::createServerSupabaseClient 用 authenticated + 会话身份;lib/supabase/admin-client-core.ts::createAdminSupabaseClient 用 service_role + 无身份)。按源码逐条数,一次调用的往返:

调用 往返序列 次数
.from(t).select()/update()/delete()/insert() begin → set local role <role> → select set_config(sub), set_config(email) → columnTypes(查 information_schema.columns)→ 业务 SQL → commit 6
.upsert() 未传 onConflict 上述 + primaryKeyColumns(查 pg_index) 7
.rpc(fn, args) begin → set local role → set_config → functionMetadata(查 pg_proc)→ 调用 → commit 6

真正干活的只有 1 次;表结构 / 函数签名每次现查,进程内无任何缓存。information_schema.columns 是带权限过滤的视图,单次比业务主键查询还重。一个首页启动(账户、人物、会话列表)或一轮咨询落库会串行打十几次这种调用。

注意(影响缓存设计):columnTypes 在 set local role 之后执行,information_schema.columns 只列当前角色有权限的列,所以 authenticated 与 service_role 看到的列集合可以不同——缓存键必须带角色。pg_proc 不按权限过滤。

2.3 无上限内存表(BUG-1328)

位置 键 何时删 实际有效期
block-scan-answer.ts 模块级 rescoreAttempts: Map<string, true> caseId:askedTurnId:证据指纹 从不(只有测试用 resetStaleMinuteRescoreAttemptsForTests) 语义是「同一证据指纹只重算一次」(BUG-578),每道题 / 每次证据变化一条,永久累积
delivery-turn-guard.ts 模块级 delivered: Map<caseId, DeliveryMark> caseId 从不(只有 resetDeliveryTurnGuardForTests) 所有读取方都先判 now - at <= 60_000,超过 60 s 的条目已无任何作用(BUG-596)
request-rate-limit.ts 全局 jyotishaRequestRateLimits: Map<string, number[]> bucket:userId 等 从不;只在同一个 key 再次被访问时过滤旧时间戳 某用户不再访问,该 key 及其旧时间戳数组永久留存;key 数 ≈ 历史活跃用户数 × 桶数(六个具名桶 + chartView、birthSky)

均为进程级,随进程寿命线性增长;单条小,但与 2.1 同属「长寿命进程只增不减」。

3. 根因

  • BUG-1326:误以为 { once: true } 会在 Promise 结束时自动摘监听;只有 abort 事件触发才摘。worker 自上线起即如此,从未设计回收,不是回归。docs/BUG_HISTORY.md 检索 defaultSleep、runPersonalReportWorkerLoop、addEventListener、MaxListeners、「内存泄漏」无记录。同一提交里已有正确示范(home-cloud-sync.ts::waitForUndoWindow 到点时 removeEventListener)。
  • BUG-1327:适配层是 Supabase → 自托管 Postgres 迁移时按「每次调用一个完整事务 + 现查元数据」写的兼容层,追求正确、未做缓存。不是回归。相关记录:BUG-166(同文件连接池按 URL 关闭)、BUG-926(order())、local-postgres-not / local-postgres-or 两条算子修复——这些防复发(按 URL 关池、算子语义)本单不得动。
  • BUG-1328:三张表都只为「短时间内去重 / 限流」而建,写的人没考虑进程寿命。BUG-578 的「同一证据指纹只重算一次」、BUG-596 的「交付后 60 s 内无用户消息不再开计费轮」是语义来源,本单不得改变其在有效期内的行为。

4. 决策记录

  • 2026-10-13 产品负责人同意按排查结论全部立单交 Grok 执行(本单含 P0 + P1 + 小项)。
  • 产品追加要求:P1 改动必须 A/B 并做成开关,默认值按 A/B 结论定——本单 T2 照做;T1、T3 是缺陷修复且行为等价(只回收已无作用的条目 / 监听),不做开关。
  • 只读无身份调用省掉事务:评估结论为不做(理由见 T2「评估」),不是让步项。
  • Better Auth cookieCache(会话读缓存):产品负责人 2026-10-14 决定开启(T6,BUG-1346)。代价已向产品说明:改密码 / 封号后,其它设备上的旧登录最多再有效 maxAge 这么久。主会话评估取 maxAge = 300 秒(Better Auth 默认值,与本仓 twoFactorCookieMaxAge: 300 同量级),且敏感操作强制绕过缓存(见 T6)。
  • 不推翻任何既有红线。

5. 硬红线

  1. 不改数据库结构、不加迁移、不动 frontend/db/**、supabase/**;不改 RLS 策略、角色与授权。
  2. 事务语义不变:每次 .from() / .rpc() 调用仍在自己的事务里、仍以同一角色(set local 等价物,事务结束即失效)和同一组 request.jwt.claim.* 执行;连接归还池子时不得带着角色或 claim。不得改成会话级 set role。
  3. 缓存只缓存元数据(列类型、主键列、函数签名),绝不缓存业务数据;缓存键必须含连接串与角色;不缓存「查不到」(空列表、database function not found)。
  4. BUG-578「同一证据指纹只重算一次」与 BUG-596「60 s 窗口内 already_delivered」在有效期内行为逐条不变;request-rate-limit 在窗口内的放行 / 拒绝与 retryAfterSeconds 逐条不变。
  5. closeLocalPostgresDataPool(s) 的按 URL 关池行为(BUG-166)不变,并顺带清掉该 URL 的元数据缓存。
  6. 冻结约束:scripts/jyotish_api_server.py 不碰(tests/test_api_server_growth_contract.py);不碰 frontend/src/app/(app)/page.tsx(home-shell-growth-contract);不改 .gitea/workflows/**;不升级依赖、不顺手修不在本单里的 warning。
  7. 改既有断言写「原值 / 新值 / 原因」三栏;开工先 grep:grep -rn "set local role\|information_schema\|resetDeliveryTurnGuardForTests\|resetStaleMinuteRescoreAttemptsForTests\|RequestRateLimitStore" frontend/tests(已知:admin-contracts.test.ts 215 行匹配迁移文本里的 set local role service_role,那是迁移 SQL 不是适配层,不受影响)。
  8. 开关只读服务端环境变量,不进 NEXT_PUBLIC_*,不入前端包。

6. 任务分解

T1 · 报告 worker sleep 到点摘监听(BUG-1326,P0)

  • personal-report-worker-core.ts::defaultSleep 改为:命名 onAbort;计时器到点时先 signal?.removeEventListener("abort", onAbort) 再 resolve;abort 时清计时器并 resolve。保持 unref、保持「已 abort 直接返回」。
  • 不改 runPersonalReportWorkerLoop 的轮询间隔、错误处理与退出条件;不改 startPersonalReportWorker。
  • T1b:birth-time-guided-polling.ts::scoringPollDelay 同样到点摘监听(有界,不是泄漏,只为全仓不留第二份错误写法)。
  • 验收(新测试 frontend/tests/personal-report-worker-sleep-listener.test.ts):
    • 用 runPersonalReportWorkerLoop + pollIntervalMs: 1 + 一个连续返回 idle 的假 worker,第 200 次 tick 时用 node:events 的 getEventListeners(controller.signal, "abort").length 记录监听数,然后 abort。断言 = 0(基线为 199);另一条:tick 抛错路径同样 = 0。
    • abort 后 loop 在一个轮询间隔内结束(done resolve)。
    • scoringPollDelay:到点 resolve 后监听数 = 0;abort 时立即 resolve。
    • 写进 PROGRESS:在基线代码上跑新测试的失败输出(证明能抓回归)。
  • 命令:npx tsx --test tests/personal-report-worker-sleep-listener.test.ts tests/personal-report-worker.test.ts。Grok 自跑;Claude 复验,并另用 leak-fixed.mjs 同形脚本复测(目标:43,200 次 sleep 后监听 0、堆增长 < 1 MB)。

T2 · 本地 Postgres 元数据缓存 + 合并会话设置(BUG-1327,P1,开关 + A/B)

T2a 进程级元数据缓存(开关 JYOTISHA_PG_METADATA_CACHE,取值 on / off)

  • 在 local-postgres-client-core.ts 内新增模块级缓存(挂 globalThis,与 jyotishaLocalDataPools 同法,避免模块重复求值时分裂):
    • 列类型:键 连接串 | 角色(null→"owner") | 表名 → Map<column, udt_name>;
    • 主键列:键 连接串 | 表名;
    • 函数签名:键 连接串 | 角色 | 函数名 | 排序后的参数名(functionMetadata 的结果依赖传入的参数名集合,必须进键)。
  • 失效:
    1. TTL 300 秒(迁移在旧容器仍运行时执行,再由部署重启换新进程;TTL 兜住「迁移后、重启前」的窗口)。
    2. 业务 SQL 抛出 SQLSTATE 42703(undefined_column)、42P01(undefined_table)、42883(undefined_function)、42804(datatype_mismatch)时,清掉该连接串的全部元数据缓存;本次照旧返回错误(不自动重试——事务已中止,重试会改变调用方看到的语义),下一次调用重新查。
    3. closeLocalPostgresDataPool(url) 清该 URL;closeLocalPostgresDataPools() 全清;导出 clearLocalPostgresMetadataCacheForTests()。
    4. 不缓存空结果与「function not found」。
  • 元数据查询仍在同一事务、同一角色下执行(缓存未命中时),保证与现状看到的列集合一致。
  • 开关 off 时代码路径与基线逐条一致(每次现查)。

T2b 合并会话设置往返(开关 JYOTISHA_PG_MERGED_SESSION_SETUP,on / off)

  • inBusinessTransaction 把 set local role <role> 与两条 set_config 合成一条参数化语句: select set_config('role', $1, true), set_config('request.jwt.claim.sub', $2, true), set_config('request.jwt.claim.email', $3, true);role === null 时省掉第一项。 set_config('role', x, true) 与 SET LOCAL ROLE x 等价(PostgREST 用同一做法),角色仍只能是构造函数白名单里的 authenticated / service_role。
  • begin、commit 保持独立语句;不得用拼字符串的多语句简单查询把 begin 也合进去。
  • 结果:select / insert / update / delete / rpc 往返 6 → 4(缓存命中时);未传 onConflict 的 upsert 7 → 4。

T2c 评估:只读、无身份时省事务——不做

  • set local / set_config(..., true) 只在事务块内生效;不开事务就只能用会话级 set role,归还连接前必须再 reset role,往返数不降(begin/commit 换成 set/reset),且一旦 reset 失败,连接会带着 service_role 回到池子给下一个请求——违反红线 2。无身份的 authenticated 读同理。结论:不做,写进 PROGRESS。

T2 验收

  1. 新单测(不需 Docker)frontend/tests/local-postgres-roundtrips.test.ts:用假 Pool/PoolClient 记录每条 client.query 的 SQL 文本,断言:
    • 两开关 off:select 序列 = begin, set local role, set_config, information_schema, 业务, commit(6)——锁住基线;
    • 两开关 on、同一表第二次调用:begin, set_config(含 role), 业务, commit(4);rpc 同理 4;
    • 角色不同(authenticated vs service_role)不共享列缓存;参数名集合不同不共享函数缓存;
    • 业务 SQL 抛 42703 后下一次调用重新查 information_schema;TTL 过期(注入时钟)后重新查;closeLocalPostgresDataPool(url) 后重新查;
    • 空列结果与 function-not-found 不入缓存。
  2. npm run test:db(Grok 必跑,以 Grok 为准):两开关 on 跑一遍、off 跑一遍。失败清单与 tools/agent-loop/windows-known-failures.txt 按测试名逐条比对,多出一条即未通过;Docker 未开则回报「未跑」,不得写通过。另加一条真库测试(放进现有 database-local-business.test.ts 或新建 database-local-postgres-session-setup.test.ts):开关 on 时,在事务内 select current_user, current_setting('request.jwt.claim.sub', true) 分别等于角色与身份;同一连接归还后再取出,current_user 回到连接用户、claim 为空——证明没有泄漏到池子。
  3. 现有 local-postgres-not / or / order / query-value / pool-close 单测全绿、无断言改动。

T3 · 三张内存表加上限 / 过期(BUG-1328)

  • delivery-turn-guard.ts:markDeliveryTurn 写入前清掉 now - at > WINDOW_MS 的条目(所有读取方本就把它们视为不存在,行为逐条等价)。为防极端情况,再加硬上限 10,000 条(超出删最旧插入)。
  • block-scan-answer.ts::rescoreAttempts:值改为写入时间戳;写入时清掉超过 6 小时的条目,并设硬上限 5,000 条(超出删最旧插入)。语义变化仅限:同一 (case, 题, 证据指纹) 在 6 小时后或被挤出后可能再尝试重算一次——这比现状「进程重启后会再算」更收敛,写进 PROGRESS。
  • request-rate-limit.ts::consumeRequestRateLimit:模块内记录本进程见过的最大 windowMs;每 1,000 次调用(或 store.size > 5,000 时)扫一遍 store,删除「最新时间戳 ≤ now − 最大窗口」的 key(这些 key 无论属于哪个桶都已出窗,删除不改变任何判定);recent 为空时不写回空数组。RequestRateLimitStore 类型与导出不变(测试会传自定义 store)。
  • 验收(扩展 frontend/tests/request-rate-limit.test.ts,新建 frontend/tests/delivery-turn-guard-expiry.test.ts、frontend/tests/rescore-attempts-bound.test.ts 或并入现有对应测试):
    • 窗口内判定与基线逐条相同(复用现有用例,不改断言);
    • 注入 now 推进超过窗口后触发清理,store.size 回落;不同窗口的桶混用时,1 小时窗口的 synastry key 在 61 秒后不被删;
    • delivered 超过 60 s 的条目在下一次 markDeliveryTurn 后不在表里;alreadyDelivered / deliveryCarrierNarration 在 60 s 内结果不变;
    • rescoreAttempts 写入 5,001 条后 size ≤ 5,000;同一 key 6 小时内第二次调用仍不重算(沿用 rectification-stale-compare-fix-20260907.test.ts 等现有用例,全绿)。
  • 现有 rectification-delivery-ui-simplify-20260908.test.ts、rectification-answer-choice.test.ts、rectification-post-adopt-card-20261001.test.ts、ephemeris-route.test.ts、subject-route-birth.test.ts 全绿、无断言改动。

T4 · A/B 验证(T2 必做;T1 以 2.1 的数字复测代替)

  • 脚本 frontend/scripts/bench-local-pg-roundtrips.ts(需 Docker 测试库,复用 test:db 的 Postgres fixture 建库与种子,只用虚构用户)。固定查询序列,模拟一次首页启动 + 一轮咨询落库:
    1. authenticated + 虚构身份:profiles 按 id 读 1 行、chart_profiles 列表、chat_sessions 列表(limit 40, order updated_at)、chat_sessions update 一行、一次带 onConflict 的 upsert、一次不带 onConflict 的 upsert;
    2. service_role:一次常用 rpc(从现有 database-*.test.ts 已覆盖的 rpc 里挑一个只读或可回滚的)、一次 select。
    • 每次调用包一层计数 client.query 的代理,记往返次数与端到端耗时;整串跑 50 遍为一组。
  • A = 两开关 off;B = 两开关 on;另跑 B1 = 只开 T2a、B2 = 只开 T2b 以便归因。每组重复 3 次取中位数,先各热身 1 遍丢弃。
  • 度量:每串往返总数、每串耗时中位数与 p95、单调用平均耗时;Postgres 侧 pg_stat_statements(若 fixture 已启用)或 pg_stat_database.xact_commit 差值作旁证。
  • 正确性:A 与 B 每一步返回的 { data, error, count } 做 JSON 序列化后逐字节一致(同一进程内先 A 后 B,库在两组之间按 fixture 重置);写进脚本输出。
  • 判定门槛:往返数 B ≤ A 的 70%,且耗时中位数降 ≥ 15%,且逐字节一致,且 test:db 两种开关下失败清单都只含已知 13 条 → 两开关默认 on;任何一项不满足 → 对应开关默认 off,在 PROGRESS 写原因。
  • 记录写 docs/testing/node-runtime-resources-ab-20261013.md:环境(Windows + Docker Desktop 版本、Node 版本)、三组原始数字、中位数表、差分结果、默认值结论。
  • Claude 验收:读 A/B 记录、复跑不需 Docker 的往返计数单测;真库数字以 Grok 为准(本机无 Docker,记环境缺口)。

T6 · 用户端开启会话读缓存(BUG-1346,产品 10-14 决定,开关 + A/B)

  • 现状:frontend/src/modules/identity/auth-factory.ts 用户端配置没有 session.cookieCache,每个带登录的请求 getSession 都查一次库(readIdentitySession / createServerSupabaseClient 路径)。
  • 改法:
    • 只对用户端(cookiePrefix: "jyotisha-user" 那份配置)加 session.cookieCache = { enabled, maxAge: 300 };后台(admin 端)配置不加。
    • 开关:服务端环境变量(例如 IDENTITY_SESSION_COOKIE_CACHE,名字执行方定,登记进 deploy 示例),A/B 后定默认值。
    • 敏感操作必须绕过缓存(每次真查库):改密码、注销账户 / 撤销注销、改邮箱、两步验证设置、任何后台接口、account-deletion 相关路由。开工 grep getSession(、readIdentitySession、requireIdentityServerSession 的全部调用点,列表进 PROGRESS;敏感点用 Better Auth 的 disableCookieCache 查询参数(以当前锁定版本的文档 / 类型为准,先确认参数名)。
    • 账户删除检查(consult 等路由里的注销 RPC)与后台 banned 授权检查(auth.ts 查 banned / ban_expires)保持现状,不得因缓存跳过。
  • 不改:会话有效期、revokeSessionsOnPasswordReset、Cookie 属性(secure / httpOnly / sameSite)、表结构。
  • 验收:
    • 新测试:开关开时,同一会话连续 3 次普通请求只查库 1 次;敏感路由每次都查库;开关关时与现在完全一致。
    • 新测试:撤销会话(模拟改密码)后,敏感路由立刻 401;普通路由在 maxAge 内仍可用、超过后 401(用假时钟)。
    • A/B 并入 T4:同一组带登录的请求序列,比较「每请求查库次数」与耗时中位数;门槛:查库次数下降且耗时不升 → 默认开;否则默认关并在 PROGRESS 写原因。
    • Grok 跑 test:db 两种开关各一次,按已知失败清单比对。
    • docs/testing/node-runtime-resources-20261013.md 写真机步骤:两台设备登录 → 一台改密码 → 另一台 5 分钟内仍可浏览、5 分钟后被要求重新登录、期间无法执行改密码 / 注销等敏感操作。

T5 · 记录

  • docs/BUG_HISTORY.md 新增 BUG-1326、1327、1328、1346(1346 写明产品 10-14 决定与「最多 300 秒」的取舍;1327 状态按 A/B 结论写;部署后的内存曲线未观测前 1326 写 resolved(代码+回归测试),生产内存曲线待部署后观察)。
  • docs/tasks/PROGRESS-node-runtime-resources-20261013.md:含 T2c 评估、T3 语义变化说明、基线跑新测试的失败输出、A/B 结论。
  • deploy/README.md 或 .env 示例里登记两个开关(只写名字与默认值,不写任何密钥);若 compose 文件需要透传环境变量,改 deploy/docker-compose.server.yml 前先在回报里说明(不改 workflow)。
  • CHANGELOG.md:一句「服务器长期运行不再逐日涨内存;数据库访问少走几趟」。Skill 版本不 bump。frontend/DESIGN.md 不改。

7. 让步顺序

T1(P0,必须交)→ T3 → T6 → T2a → T2b → T4。T6 是产品决定,不得砍。时间不够时:先砍 T2b(保留开关骨架与单测也可以不交),再砍 T2a;T2 一旦交付就必须带 T4 的 A/B 与 test:db 比对,否则开关默认 off。T1 不得因任何原因延后。

8. 验收命令分工

谁 命令
Grok 自跑 ./node_modules/.bin/tsc --noEmit;npm run lint(0 error);npx tsx --test 跑本单新增 / 改到的测试单文件(personal-report-worker-sleep-listener、personal-report-worker、local-postgres-roundtrips、local-postgres-not/or/order/query-value/pool-close、request-rate-limit、delivery-turn-guard-expiry、rescore-attempts-bound、rectification-delivery-ui-simplify-20260908、rectification-stale-compare-fix-20260907、rectification-answer-choice、ephemeris-route、subject-route-birth),看 fail 与 cancelled 都为 0;npm run test:db 两种开关各一次,按 tools/agent-loop/windows-known-failures.txt 逐名比对;T4 A/B 脚本。前端全量 npm test、Python 快速门、next build 不跑
Claude 验收 上述单文件全部复验 + 前端全量 npm test 与基线逐名比对 + next build(/ 仍 Static,首屏 gzip ±2%,本单不应有前端包变化)+ 泄漏脚本复测;test:db 本机无 Docker,记环境缺口,以 Grok 结果为准

9. 开工前置命令

git fetch origin --prune
git worktree add -b codex/node-runtime-resources-20261013 .worktrees/node-runtime-resources-20261013 origin/staging
cd .worktrees/node-runtime-resources-20261013
git log -1 --format=%h                      # 应为 77057a819 或其后代
grep -n "^## BUG-" docs/BUG_HISTORY.md | tail -3   # 核对最大号;1326~1328 若被占用,顺延并在 PROGRESS 写明
grep -rn 'addEventListener("abort"' frontend/src | grep -v test   # 应与 2.1 表一致(18 处)
grep -rn "set local role\|information_schema\|resetDeliveryTurnGuardForTests\|resetStaleMinuteRescoreAttemptsForTests\|RequestRateLimitStore" frontend/tests
cat tools/agent-loop/windows-known-failures.txt

10. 交付物清单

  • 代码:personal-report-worker-core.ts、birth-time-guided-polling.ts、db/local-postgres-client-core.ts、delivery-turn-guard.ts、block-scan-answer.ts、request-rate-limit.ts
  • 测试:§6 列出的新增 / 扩展测试
  • 脚本:frontend/scripts/bench-local-pg-roundtrips.ts
  • 文档:docs/tasks/PROGRESS-node-runtime-resources-20261013.md、docs/testing/node-runtime-resources-ab-20261013.md、docs/BUG_HISTORY.md(BUG-1326~1328)、CHANGELOG.md;frontend/DESIGN.md 不改

11. 本单不做

  • 只读无身份调用省事务(T2c 已论证不安全)。
  • 把 begin 拼进多语句简单查询(需要手工转义字面量,收益一次往返,不值得冒注入风险)。
  • createServerSupabaseClient 每次调用都读一次身份会话的往返:T6 开缓存后自然减少,不另做。

附录 · 2.1 复现脚本(leak-fixed.mjs,与源码同形,可放任意临时目录)

import { getEventListeners } from "node:events";
const keep = setInterval(() => {}, 100000);
const FIXED = process.argv[2] === "fixed";
const N = Number(process.argv[3] ?? 43200);
async function sleepOld(ms, signal) { if (signal?.aborted) return; await new Promise((resolve) => { const t = setTimeout(resolve, ms); t.unref?.(); signal?.addEventListener("abort", () => { clearTimeout(t); resolve(); }, { once: true }); }); }
async function sleepNew(ms, signal) { if (signal?.aborted) return; await new Promise((resolve) => { const onAbort = () => { clearTimeout(t); resolve(); }; const t = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, ms); t.unref?.(); signal?.addEventListener("abort", onAbort, { once: true }); }); }
const sleep = FIXED ? sleepNew : sleepOld;
const c = new AbortController();
global.gc(); const b = process.memoryUsage().heapUsed;
for (let i = 0; i < N; i++) await sleep(0, c.signal);
global.gc(); const a = process.memoryUsage().heapUsed;
console.log(FIXED ? "fixed" : "baseline", process.version, "sleeps", N, "listeners", getEventListeners(c.signal, "abort").length, "heapMB", ((a - b) / 1048576).toFixed(2));
clearInterval(keep);

运行:node --expose-gc leak-fixed.mjs [fixed] [次数](43,200 次约 45 秒,setTimeout(0) 每次约 1 ms)。