fix(rectification): 请求内 Case 档案只读投影写即失效缓存
同一请求里 dossier/compute 按 (fn, userId, caseId) 合并重复读,其它 RPC 与 .from 立即失效。路由入口各包一处,零调用点改动。
This commit is contained in:
@@ -11285,3 +11285,19 @@
|
||||
- 相关记录:BUG-473、BUG-249、BUG-250、BUG-260、BUG-617
|
||||
- 复发自:BUG-473(影响面含本文件,但两条防复发只约束正在流的那一行)
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-726 | 一轮校正把同一份 Case 档案从数据库取多次
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-15
|
||||
- 最近更新:2026-09-16
|
||||
- 影响面:`loadV9CaseDossier` / `loadV9CaseCompute`、`POST /api/rectification/agent`、regenerate、Case GET/POST
|
||||
- 用户现象:同一轮对话里档案被重复取回。用户看不到这条,但 2 vCPU 主机上这份 jsonb 投影与 Next.js、Python 引擎抢同一批核。
|
||||
- 触发条件:一次 HTTP 请求内多处调用 `get_agentic_rectification_case_dossier` 或 `get_agentic_rectification_case_compute`,中间没有写操作。
|
||||
- 根因:只读投影没有任何请求作用域缓存。每个调用点独立打 Postgres RPC。这不是回归,是 V9 运行时引入以来的分层遗漏。
|
||||
- 修复:新增 `withRectificationRequestCache`,挂在客户端对象上。两个只读投影按 `(fn, p_user_id, p_case_id)` 缓存 in-flight promise;其它 RPC 与 `.from(...)` 一律先清空再转发。生命周期等于一次请求。路由入口各包一处,零调用点改动,不跨请求缓存。
|
||||
- 验证:`frontend/tests/rectification-request-dossier-cache.test.ts`:连续两次读只打一次底层;中间其它 RPC 后必须重读;不同 caseId 互不命中;并发读合并;`.from` 透传后缓存清空;五条路由源码合同。既有 stream 50 / answer-choice 34 一条不改仍全绿。路由预取 + 证据轮 `case_dossier` 5→4。
|
||||
- 防复发:Case 只读投影必须经请求作用域缓存读取;新增只读投影要么进缓存白名单,要么在记录里写明为什么不能缓存。不得把档案挂在模块作用域或 `globalThis`。
|
||||
- 相关记录:BUG-176
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# PROGRESS · 一轮对话把整份 Case 档案从数据库取 3.4 次(2026-09-15 / 2026-09-16)
|
||||
|
||||
工作树:`.worktrees/rectification-request-dossier-cache-20260915`
|
||||
分支:`codex/rectification-request-dossier-cache-20260915`
|
||||
任务书基线:`6b3248bf`;代码基线 `origin/staging` @ `e4788dfc`(含 failure-attribution `8b982baf`、engine-memoization BUG-721、settled-render BUG-725)
|
||||
本机 Windows。
|
||||
|
||||
Skill **未 bump**。未改 `frontend/src/app/page.tsx`、SQL、轮询间隔、GET 投影形状、计费口径、`docs/tasks/README.md` 状态列。未 push `HEAD:staging`。
|
||||
|
||||
## 开工基线
|
||||
|
||||
| 项 | 数字 |
|
||||
| --- | --- |
|
||||
| `frontend/src/app/page.tsx` | **1879** 行(未改) |
|
||||
| 最大 BUG 号 | **BUG-725**(本单占用 **BUG-726**;任务书预占 726,开工时 722–724 已落库,随后 721/725 也合入 staging) |
|
||||
| `tsc --noEmit` | 收尾 **0 错** |
|
||||
| `npm run lint` | **0 error**(全仓既有 warning;本单文件 0 warning) |
|
||||
|
||||
`frontend/node_modules` 已是指向主仓的 junction,未新建。
|
||||
|
||||
## 任务状态
|
||||
|
||||
| 任务 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 5.1 请求作用域缓存包装器 | 完成 | `withRectificationRequestCache`;写即失效;缓存 in-flight promise |
|
||||
| 5.2 路由入口接线 | 完成 | agent / regenerate / Case GET / repair-exit POST / accept POST 各一处 |
|
||||
| 5.3 前后计数 | 完成 | 见下表;预算断言钉在路由预取 + 证据轮 `≤ 4` |
|
||||
| 5.4 Bug 历史 | 完成 | BUG-726 |
|
||||
|
||||
## 实现要点
|
||||
|
||||
- 新模块 `frontend/src/lib/rectification-agentic/v9/request-cache.ts`。Map 在每次 `withRectificationRequestCache` 的闭包里,不挂模块作用域、不挂 `globalThis`。
|
||||
- 白名单:`get_agentic_rectification_case_dossier`、`get_agentic_rectification_case_compute`。键是 `(fn, p_user_id, p_case_id)`。
|
||||
- 其它 `rpc` 先 `clear` 再转发。Proxy 透传 `.from(...)` 等属性,访问时同样清空。类型用 `as T` 保留 Supabase 客户端形状,**没有为过类型去改业务代码**,也没有收缩成只包 `rpc`。
|
||||
- 调用点零改动。`fakeAccounting` 默认不包缓存(`requestCache` / `RECTIFICATION_REQUEST_CACHE=1` 显式打开);计数导出默认关闭(`countRpc` / `RECTIFICATION_RPC_COUNT=1`)。
|
||||
|
||||
## 改前 / 改后 dossier RPC 计数
|
||||
|
||||
测法:`fakeAccounting` 按实例聚合,只计打过 dossier 或 compute 的场景。改后用 `RECTIFICATION_REQUEST_CACHE=1` 包上同一批测试客户端。既有断言一条未改。
|
||||
|
||||
| 路径 | 场景数 | 改前 dossier(均值) | 改后 dossier(均值) | 改前 compute(均值) | 改后 compute(均值) |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||
| Agent 轮(`rectification-v9-stream.test.ts`) | 41 | 143(**3.49**) | 143(**3.49**) | 27(0.66) | 27(0.66) |
|
||||
| 点选题(`rectification-answer-choice.test.ts`) | 15 | 31(**2.07**) | 30(**2.00**) | 17(1.13) | 15(1.00) |
|
||||
| 路由预取 + 证据轮(新预算断言) | 1 | 5 | **4** | — | — |
|
||||
|
||||
Agent 轮套件数字没降:这些测试的 fake stream 不执行真实工具,每次 `loadV9CaseDossier` 之间都夹着 skill identity / append / run phase 等非白名单 RPC,按「写即失效」必须重读。生产路径上路由先读档案、再进 `runV9AgentTurn` 再读一次,中间无写,预算断言覆盖这一跳(5→4)。点选题套件 dossier 31→30、compute 17→15。
|
||||
|
||||
## 既有断言改动
|
||||
|
||||
无。stream 50 / answer-choice 34 一条未改仍全绿。
|
||||
|
||||
## 测试
|
||||
|
||||
| 命令 | 结果 |
|
||||
| --- | --- |
|
||||
| `tsc --noEmit` | **0 错** |
|
||||
| 本单文件 eslint | **0 error / 0 warning** |
|
||||
| `npm run lint` | **0 error**(全仓既有 warning 同量级) |
|
||||
| `tests/rectification-request-dossier-cache.test.ts` | **8 / 8 绿** |
|
||||
| `rectification-v9-stream.test.ts` | **50 / 50 绿** |
|
||||
| `rectification-answer-choice.test.ts` | **34 / 34 绿** |
|
||||
| `tests/rectification-*.test.ts` + `agentic-rectification-*.test.ts` | **1263 pass / 1 fail / 9 skipped**。唯一失败是既有 `rectification-v9-agent.test.ts` Windows `symlinkSync` EPERM,与本单无关 |
|
||||
| `page.tsx` | 仍 1879 行,未改 |
|
||||
| 全量 `npm test` | `# tests 3197 / # pass 3114 / # fail 69 / # skipped 14`。比 failure-attribution 进度记录的 3189/3104/71/14 多本单 8 条绿;失败仍是无 Docker 的 database-*、部署/Caddy/shell、Windows symlink EPERM。不含本单定向套件 |
|
||||
| `npx next build --webpack` | **Compiled successfully** + TypeScript 37s 过。Collecting page data 死在既有 `/api/consult` skill runtime symlink EPERM。不伪装成 `/` ○ Static 已核过 |
|
||||
|
||||
## 环境缺口
|
||||
|
||||
- 无 Docker:数据库套件红,不伪装成通过。
|
||||
- 无登录态、无 Chrome:浏览器级验收不在本单。
|
||||
- Windows 无 symlink 权限:`rectification-v9-agent.test.ts` 与部分 skill runtime 路径 EPERM。本机 Node 只能建 junction。
|
||||
- `next build`:webpack 编译在本机可过;收集页面数据时 skill runtime symlink 仍可能 EPERM(既有缺口)。不伪装成 `/` ○ Static 已核过。
|
||||
@@ -30,6 +30,7 @@ import { isProductEnabled } from "@/lib/product-access";
|
||||
import { resolveSessionLanguageModel } from "@/lib/model-catalog";
|
||||
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { withRectificationRequestCache } from "@/lib/rectification-agentic/v9/request-cache";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { defaultMessageOrigin, isRectificationMessageOrigin } from "@/lib/rectification-agentic/v9/message-origin";
|
||||
import { previousInferenceFromReceipt } from "@/lib/rectification-agentic/v9/inference-adapter";
|
||||
@@ -150,7 +151,7 @@ export async function POST(request: Request) {
|
||||
let accounting;
|
||||
try {
|
||||
supabase = await createServerSupabaseClient();
|
||||
accounting = createAdminSupabaseClient();
|
||||
accounting = withRectificationRequestCache(createAdminSupabaseClient());
|
||||
} catch (error) {
|
||||
return jsonForSupabaseSetupFailure(error, "POST /api/rectification/agent");
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { withRectificationRequestCache } from "@/lib/rectification-agentic/v9/request-cache";
|
||||
import { isProductEnabled } from "@/lib/product-access";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { RectificationToolServiceError } from "@/lib/rectification-agentic/v9/tool-service";
|
||||
@@ -59,7 +60,7 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
let accounting;
|
||||
try {
|
||||
supabase = await createServerSupabaseClient();
|
||||
accounting = createAdminSupabaseClient();
|
||||
accounting = withRectificationRequestCache(createAdminSupabaseClient());
|
||||
} catch (error) {
|
||||
return jsonForSupabaseSetupFailure(error, "POST /api/rectification/cases/[caseId]/candidates/accept");
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { withRectificationRequestCache } from "@/lib/rectification-agentic/v9/request-cache";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import {
|
||||
loadV9CaseDossier,
|
||||
@@ -32,7 +33,7 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
let accounting;
|
||||
try {
|
||||
supabase = await createServerSupabaseClient();
|
||||
accounting = createAdminSupabaseClient();
|
||||
accounting = withRectificationRequestCache(createAdminSupabaseClient());
|
||||
} catch (error) {
|
||||
return jsonForSupabaseSetupFailure(error, "POST /api/rectification/cases/[caseId]/repair-exit");
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { withRectificationRequestCache } from "@/lib/rectification-agentic/v9/request-cache";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import {
|
||||
loadV9CaseDossier,
|
||||
@@ -29,7 +30,7 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
let accounting;
|
||||
try {
|
||||
supabase = await createServerSupabaseClient();
|
||||
accounting = createAdminSupabaseClient();
|
||||
accounting = withRectificationRequestCache(createAdminSupabaseClient());
|
||||
} catch (error) {
|
||||
return jsonForSupabaseSetupFailure(error, "GET /api/rectification/cases/[caseId]");
|
||||
}
|
||||
|
||||
+2
-1
@@ -9,6 +9,7 @@ import { resolveExactSkillPackage } from "@/lib/skill-package-registry";
|
||||
import { resolveSessionLanguageModel } from "@/lib/model-catalog";
|
||||
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { withRectificationRequestCache } from "@/lib/rectification-agentic/v9/request-cache";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { getRectificationV9RegenerationAgent } from "@/mastra/agentic-rectification";
|
||||
import { logRectificationDeliveryTurn } from "@/lib/rectification-agentic/v9/delivery-turn-guard";
|
||||
@@ -64,7 +65,7 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
let accounting;
|
||||
try {
|
||||
supabase = await createServerSupabaseClient();
|
||||
accounting = createAdminSupabaseClient();
|
||||
accounting = withRectificationRequestCache(createAdminSupabaseClient());
|
||||
} catch (error) {
|
||||
return jsonForSupabaseSetupFailure(error, "POST /api/rectification/cases/[caseId]/turns/[turnId]/regenerate");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Request-scoped cache for Case read projections.
|
||||
*
|
||||
* `get_agentic_rectification_case_dossier` and
|
||||
* `get_agentic_rectification_case_compute` are unchanged for the life of a
|
||||
* request until this client performs any other RPC or table access. Call sites
|
||||
* keep calling `loadV9CaseDossier` as before; the wrapper sits on the client
|
||||
* object so direct `accounting.rpc(...)` paths are covered too.
|
||||
*
|
||||
* The cache is a closure over a Map created by each `withRectificationRequestCache`
|
||||
* call. It is not stored on `globalThis` or at module scope.
|
||||
*
|
||||
* Other properties are forwarded through a Proxy so `.from(table).update(...)`
|
||||
* (used via `as never` in a few call sites) still works, and accessing them
|
||||
* invalidates the same way a non-projection RPC does.
|
||||
*/
|
||||
import type { RectificationRpcClient } from "./tool-service";
|
||||
|
||||
export const RECTIFICATION_CACHED_PROJECTION_RPCS = [
|
||||
"get_agentic_rectification_case_dossier",
|
||||
"get_agentic_rectification_case_compute",
|
||||
] as const;
|
||||
|
||||
type CachedProjectionRpc = (typeof RECTIFICATION_CACHED_PROJECTION_RPCS)[number];
|
||||
|
||||
const cachedProjectionRpcSet = new Set<string>(RECTIFICATION_CACHED_PROJECTION_RPCS);
|
||||
|
||||
function isCachedProjectionRpc(fn: string): fn is CachedProjectionRpc {
|
||||
return cachedProjectionRpcSet.has(fn);
|
||||
}
|
||||
|
||||
function projectionCacheKey(fn: string, args: Record<string, unknown>): string | null {
|
||||
const userId = args.p_user_id;
|
||||
const caseId = args.p_case_id;
|
||||
if (typeof userId !== "string" || typeof caseId !== "string" || !userId || !caseId) {
|
||||
return null;
|
||||
}
|
||||
return `${fn}\0${userId}\0${caseId}`;
|
||||
}
|
||||
|
||||
type RpcResult = Awaited<ReturnType<RectificationRpcClient["rpc"]>>;
|
||||
|
||||
export function withRectificationRequestCache<T extends RectificationRpcClient>(accounting: T): T {
|
||||
const cache = new Map<string, PromiseLike<RpcResult>>();
|
||||
const originalRpc = accounting.rpc.bind(accounting);
|
||||
|
||||
function invalidate(): void {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
function cachedRpc(fn: string, args: Record<string, unknown>): PromiseLike<RpcResult> {
|
||||
if (!isCachedProjectionRpc(fn)) {
|
||||
invalidate();
|
||||
return originalRpc(fn, args);
|
||||
}
|
||||
const key = projectionCacheKey(fn, args);
|
||||
if (!key) return originalRpc(fn, args);
|
||||
const hit = cache.get(key);
|
||||
if (hit) return hit;
|
||||
const pending = originalRpc(fn, args);
|
||||
cache.set(key, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
return new Proxy(accounting, {
|
||||
get(target, property) {
|
||||
if (property === "rpc") return cachedRpc;
|
||||
invalidate();
|
||||
const value = Reflect.get(target, property);
|
||||
if (typeof value === "function") {
|
||||
return value.bind(target);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
}) as T;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { withRectificationRequestCache } from "../src/lib/rectification-agentic/v9/request-cache.ts";
|
||||
import { loadV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
import { runV9AgentTurn } from "../src/lib/rectification-agentic/v9/agent-run.ts";
|
||||
import { RECTIFICATION_SKILL_NAME } from "../src/lib/rectification-agentic/v9/case-status.ts";
|
||||
import {
|
||||
CASE_COMPUTE_RPC,
|
||||
CASE_DOSSIER_RPC,
|
||||
CASE_ID,
|
||||
SESSION_ID,
|
||||
TURN_ID,
|
||||
USER_ID,
|
||||
countCachedProjectionRpcs,
|
||||
dossierFixture,
|
||||
fakeAccounting,
|
||||
receiptHandlers,
|
||||
} from "./rectification-v9-test-support.ts";
|
||||
|
||||
/** Measured after wrapping: route-style prefetch + one evidence turn. */
|
||||
const CACHED_AGENT_TURN_DOSSIER_BUDGET = 4;
|
||||
|
||||
const WRAPPED_ADMIN_CLIENT = /accounting = withRectificationRequestCache\(createAdminSupabaseClient\(\)\)/;
|
||||
const BARE_ADMIN_CLIENT = /accounting = createAdminSupabaseClient\(\)/;
|
||||
|
||||
const CACHED_ROUTES = [
|
||||
"../src/app/api/rectification/agent/route.ts",
|
||||
"../src/app/api/rectification/cases/[caseId]/turns/[turnId]/regenerate/route.ts",
|
||||
"../src/app/api/rectification/cases/[caseId]/route.ts",
|
||||
"../src/app/api/rectification/cases/[caseId]/repair-exit/route.ts",
|
||||
"../src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts",
|
||||
] as const;
|
||||
|
||||
function projectionArgs(caseId = CASE_ID) {
|
||||
return { p_user_id: USER_ID, p_case_id: caseId };
|
||||
}
|
||||
|
||||
function delayedClient(innerCalls: string[], delayMs = 20) {
|
||||
return {
|
||||
rpc(fn: string, args: Record<string, unknown>) {
|
||||
void args;
|
||||
innerCalls.push(fn);
|
||||
return new Promise<{ data: unknown; error: null }>((resolve) => {
|
||||
setTimeout(() => resolve({ data: { ok: fn }, error: null }), delayMs);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("repeated case_dossier reads of the same user and case hit the in-flight cache once", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
[CASE_DOSSIER_RPC]: () => dossierFixture(),
|
||||
}, { requestCache: true });
|
||||
const first = await loadV9CaseDossier(accounting.client, USER_ID, CASE_ID);
|
||||
const second = await loadV9CaseDossier(accounting.client, USER_ID, CASE_ID);
|
||||
assert.equal(first.case.caseId, CASE_ID);
|
||||
assert.equal(second.case.caseId, CASE_ID);
|
||||
assert.deepEqual(countCachedProjectionRpcs(accounting.calls), { dossier: 1, compute: 0 });
|
||||
});
|
||||
|
||||
test("a non-projection rpc invalidates so the next dossier read hits the backing client", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
[CASE_DOSSIER_RPC]: () => dossierFixture(),
|
||||
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
|
||||
}, { requestCache: true });
|
||||
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs());
|
||||
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs());
|
||||
await accounting.client.rpc("append_agentic_rectification_turn", projectionArgs());
|
||||
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs());
|
||||
assert.equal(countCachedProjectionRpcs(accounting.calls).dossier, 2);
|
||||
});
|
||||
|
||||
test("two caseIds do not share a cached projection", async () => {
|
||||
const otherCaseId = "99999999-9999-4999-8999-999999999999";
|
||||
const accounting = fakeAccounting({
|
||||
[CASE_DOSSIER_RPC]: () => dossierFixture(),
|
||||
}, { requestCache: true });
|
||||
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs(CASE_ID));
|
||||
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs(otherCaseId));
|
||||
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs(CASE_ID));
|
||||
assert.equal(
|
||||
accounting.calls.filter((call) => call.fn === CASE_DOSSIER_RPC).length,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test("concurrent duplicate reads share one backing rpc", async () => {
|
||||
const innerCalls: string[] = [];
|
||||
const wrapped = withRectificationRequestCache(delayedClient(innerCalls));
|
||||
const args = projectionArgs();
|
||||
const [first, second] = await Promise.all([
|
||||
wrapped.rpc(CASE_DOSSIER_RPC, args),
|
||||
wrapped.rpc(CASE_DOSSIER_RPC, args),
|
||||
]);
|
||||
assert.deepEqual(first, { data: { ok: CASE_DOSSIER_RPC }, error: null });
|
||||
assert.deepEqual(second, { data: { ok: CASE_DOSSIER_RPC }, error: null });
|
||||
assert.deepEqual(innerCalls, [CASE_DOSSIER_RPC]);
|
||||
});
|
||||
|
||||
test("dossier and compute projections cache independently", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
[CASE_DOSSIER_RPC]: () => dossierFixture(),
|
||||
[CASE_COMPUTE_RPC]: () => ({ case_id: CASE_ID }),
|
||||
}, { requestCache: true });
|
||||
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs());
|
||||
await accounting.client.rpc(CASE_COMPUTE_RPC, projectionArgs());
|
||||
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs());
|
||||
await accounting.client.rpc(CASE_COMPUTE_RPC, projectionArgs());
|
||||
assert.deepEqual(countCachedProjectionRpcs(accounting.calls), { dossier: 1, compute: 1 });
|
||||
});
|
||||
|
||||
test(".from passthrough invalidates the projection cache", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
[CASE_DOSSIER_RPC]: () => dossierFixture(),
|
||||
}, { requestCache: true });
|
||||
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs());
|
||||
await accounting.client.from("profiles").update({ birth_time_source: "approximate" }).eq("id", USER_ID);
|
||||
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs());
|
||||
assert.equal(countCachedProjectionRpcs(accounting.calls).dossier, 2);
|
||||
assert.equal(accounting.profilePatches.length, 1);
|
||||
});
|
||||
|
||||
test("rectification routes wrap createAdminSupabaseClient once and never use the bare client", () => {
|
||||
for (const relative of CACHED_ROUTES) {
|
||||
const source = readFileSync(new URL(relative, import.meta.url), "utf8");
|
||||
const wraps = source.match(new RegExp(WRAPPED_ADMIN_CLIENT.source, "g")) ?? [];
|
||||
assert.equal(wraps.length, 1, relative);
|
||||
assert.equal(BARE_ADMIN_CLIENT.test(source), false, relative);
|
||||
}
|
||||
});
|
||||
|
||||
test("a cached evidence turn stays within the measured case_dossier budget", async () => {
|
||||
const handlers = {
|
||||
...receiptHandlers,
|
||||
[CASE_DOSSIER_RPC]: () => dossierFixture(),
|
||||
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
|
||||
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
|
||||
};
|
||||
const unwrapped = fakeAccounting(handlers);
|
||||
const wrapped = fakeAccounting(handlers, { requestCache: true });
|
||||
const stream = {
|
||||
stream: async () => ({
|
||||
fullStream: (async function* () {
|
||||
yield { type: "start" };
|
||||
yield { type: "tool-call", payload: { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } } };
|
||||
yield { type: "tool-result", payload: { toolName: "skill" } };
|
||||
yield { type: "tool-call", payload: { toolName: "rectification-read-case", args: { caseId: CASE_ID } } };
|
||||
yield { type: "tool-result", payload: { toolName: "rectification-read-case" } };
|
||||
yield { type: "text-delta", payload: { text: "记下了。" } };
|
||||
yield { type: "finish" };
|
||||
})(),
|
||||
totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20 }),
|
||||
}),
|
||||
getSkill: async () => ({ name: RECTIFICATION_SKILL_NAME, instructions: "skill" }),
|
||||
};
|
||||
const base = {
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
sessionId: SESSION_ID,
|
||||
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
||||
action: "evidence" as const,
|
||||
message: "2016年9月离开家去北京工作",
|
||||
modelName: "gpt-4o-mini",
|
||||
billing: {
|
||||
reserve: async () => ({ success: true, status: 200 }),
|
||||
complete: async () => true,
|
||||
release: async () => true,
|
||||
},
|
||||
emit: () => {},
|
||||
buildAgent: async () => stream as never,
|
||||
};
|
||||
await loadV9CaseDossier(unwrapped.client, USER_ID, CASE_ID);
|
||||
await runV9AgentTurn({ ...base, accounting: unwrapped.client });
|
||||
await loadV9CaseDossier(wrapped.client, USER_ID, CASE_ID);
|
||||
await runV9AgentTurn({ ...base, accounting: wrapped.client });
|
||||
const before = countCachedProjectionRpcs(unwrapped.calls);
|
||||
const after = countCachedProjectionRpcs(wrapped.calls);
|
||||
assert.ok(after.dossier < before.dossier);
|
||||
assert.ok(
|
||||
after.dossier <= CACHED_AGENT_TURN_DOSSIER_BUDGET,
|
||||
`cached dossier rpc ${after.dossier} exceeded budget ${CACHED_AGENT_TURN_DOSSIER_BUDGET} (uncached ${before.dossier})`,
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { RectificationRpcClient } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
import { withRectificationRequestCache } from "../src/lib/rectification-agentic/v9/request-cache.ts";
|
||||
|
||||
export const CASE_DOSSIER_RPC = "get_agentic_rectification_case_dossier";
|
||||
export const CASE_COMPUTE_RPC = "get_agentic_rectification_case_compute";
|
||||
|
||||
export type FakeRpcHandler = (
|
||||
fn: string,
|
||||
@@ -17,13 +21,81 @@ export type FakeAccounting = {
|
||||
};
|
||||
};
|
||||
|
||||
export type FakeAccountingOptions = {
|
||||
fallback?: FakeRpcHandler;
|
||||
/**
|
||||
* Default off. When true, wrap `client` with the request-scoped projection
|
||||
* cache. Also enabled by RECTIFICATION_REQUEST_CACHE=1 for before/after counts.
|
||||
*/
|
||||
requestCache?: boolean;
|
||||
/**
|
||||
* Default off. When true, this instance is included in takeFakeAccountingRpcCounts().
|
||||
* Also enabled by RECTIFICATION_RPC_COUNT=1.
|
||||
*/
|
||||
countRpc?: boolean;
|
||||
};
|
||||
|
||||
export type CachedProjectionRpcCounts = {
|
||||
dossier: number;
|
||||
compute: number;
|
||||
};
|
||||
|
||||
export type FakeAccountingRpcCountSummary = {
|
||||
scenes: number;
|
||||
dossier: number;
|
||||
compute: number;
|
||||
dossierMean: number;
|
||||
computeMean: number;
|
||||
};
|
||||
|
||||
const countedAccounting: FakeAccounting[] = [];
|
||||
let rpcCountEnabled = process.env.RECTIFICATION_RPC_COUNT === "1";
|
||||
|
||||
export function countCachedProjectionRpcs(
|
||||
calls: Array<{ fn: string }>,
|
||||
): CachedProjectionRpcCounts {
|
||||
let dossier = 0;
|
||||
let compute = 0;
|
||||
for (const call of calls) {
|
||||
if (call.fn === CASE_DOSSIER_RPC) dossier += 1;
|
||||
else if (call.fn === CASE_COMPUTE_RPC) compute += 1;
|
||||
}
|
||||
return { dossier, compute };
|
||||
}
|
||||
|
||||
export function enableFakeAccountingRpcCount(enabled = true): void {
|
||||
rpcCountEnabled = enabled;
|
||||
if (!enabled) countedAccounting.length = 0;
|
||||
}
|
||||
|
||||
export function takeFakeAccountingRpcCounts(): FakeAccountingRpcCountSummary {
|
||||
const scenes = countedAccounting
|
||||
.map((item) => countCachedProjectionRpcs(item.calls))
|
||||
.filter((row) => row.dossier > 0 || row.compute > 0);
|
||||
const dossier = scenes.reduce((sum, row) => sum + row.dossier, 0);
|
||||
const compute = scenes.reduce((sum, row) => sum + row.compute, 0);
|
||||
return {
|
||||
scenes: scenes.length,
|
||||
dossier,
|
||||
compute,
|
||||
dossierMean: scenes.length === 0 ? 0 : dossier / scenes.length,
|
||||
computeMean: scenes.length === 0 ? 0 : compute / scenes.length,
|
||||
};
|
||||
}
|
||||
|
||||
if (process.env.RECTIFICATION_RPC_COUNT === "1") {
|
||||
process.on("beforeExit", () => {
|
||||
process.stderr.write(`RECTIFICATION_RPC_COUNT ${JSON.stringify(takeFakeAccountingRpcCounts())}\n`);
|
||||
});
|
||||
}
|
||||
|
||||
export function fakeAccounting(
|
||||
handlers: Partial<Record<string, FakeRpcHandler>>,
|
||||
options: { fallback?: FakeRpcHandler } = {},
|
||||
options: FakeAccountingOptions = {},
|
||||
): FakeAccounting {
|
||||
const calls: Array<{ fn: string; args: Record<string, unknown> }> = [];
|
||||
const profilePatches: Array<Record<string, unknown>> = [];
|
||||
const client: FakeAccounting["client"] = {
|
||||
const rawClient: FakeAccounting["client"] = {
|
||||
rpc(fn, args) {
|
||||
calls.push({ fn, args });
|
||||
const handler = handlers[fn] ?? options.fallback;
|
||||
@@ -57,7 +129,12 @@ export function fakeAccounting(
|
||||
};
|
||||
},
|
||||
};
|
||||
return { calls, profilePatches, client };
|
||||
const requestCache = options.requestCache === true
|
||||
|| process.env.RECTIFICATION_REQUEST_CACHE === "1";
|
||||
const client = requestCache ? withRectificationRequestCache(rawClient) : rawClient;
|
||||
const accounting: FakeAccounting = { calls, profilePatches, client };
|
||||
if (options.countRpc === true || rpcCountEnabled) countedAccounting.push(accounting);
|
||||
return accounting;
|
||||
}
|
||||
|
||||
export const CASE_ID = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
Reference in New Issue
Block a user