@feng0531/dsh-code-review 0.86.0 → 0.86.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/CORE-ALIGNMENT-TASKS.md +2 -1
- package/README.md +2 -2
- package/RELEASE-NOTES.md +11 -15
- package/package.json +1 -1
- package/src/background-review.mjs +36 -0
- package/src/primary-seeds.mjs +6 -2
- package/src/project-review-service.mjs +2 -2
- package/src/retrieval-loop.mjs +1 -1
- package/src/review-artifacts.mjs +1 -1
- package/src/review-diagnostics.mjs +2 -1
- package/src/review-jobs.mjs +1 -1
- package/src/review-reader-allocation.mjs +75 -0
- package/src/review-reader-policy.mjs +1 -1
- package/src/review.mjs +60 -18
- package/src/service.mjs +3 -3
- package/src/snapshot-reader.mjs +62 -22
- package/src/verification-seeds.mjs +18 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
本文件介绍对使用者有意义的变化。安装与参数见 [README](README.md),本次发布正文见 [发布说明](RELEASE-NOTES.md)。历史提交和安装包保留在 [GitHub Releases](https://github.com/f-e-n-g-0531/dsh-code-review/releases);本文不把开发日志当作功能介绍。
|
|
4
4
|
|
|
5
|
+
## 0.86.1 — 修复取证额度分配、重复读取与工件容量
|
|
6
|
+
|
|
7
|
+
- 同快照精确操作复用,重复交付仍扣字节并重新签发复核回执;根预算内为主审、综合及复核预留最低份额,释放闲置额度。
|
|
8
|
+
- 已校验组成员保留,组受限原因传播不重复计数;后续综合预算失败不改写已完成窗口成果。
|
|
9
|
+
- 工件默认数量上限64改为4096,单件1MiB/任务8MiB不变,超限仍明确标记。
|
|
10
|
+
- 终态摘要显示报告真实partial/覆盖与分页总量,跨文件综合不再硬编码误报超时;增加74文件规模化离线回归。
|
|
11
|
+
|
|
5
12
|
## 0.86.0 — 原生结构化输出、私有留存、断点续审与组主审
|
|
6
13
|
|
|
7
14
|
- 优先使用真实宿主原生结果工具并保留同次文本兼容;只接受未改写的完整 JSON,歧义失败关闭,证据校验不放宽。
|
package/CORE-ALIGNMENT-TASKS.md
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
| 结果传输 | 原生结果工具,同次文本兼容;仅原生模式接受完整 JSON 后明确尾注 | 修补损坏 JSON、猜字段/身份、无歧义解析所有输出 |
|
|
14
14
|
| 显式续审 | 新预览通过完整快照、配置和路由身份核验后复用已完成合格文件链 | 恢复旧授权、变化后无条件复用、未完成阶段已覆盖 |
|
|
15
15
|
| 私有模型工件 | 宿主任务目录外于业务工作区,有界保存文本/工具参数及失败工件,普通报告只给摘要 | 工件成为批准来源、失败草稿成为确认发现、Windows 模式位保证 ACL |
|
|
16
|
-
|
|
|
16
|
+
| 取证资源 | 同快照精确操作复用、每次交付计字节、阶段最低额度及闲置份额释放 | 根预算增加、每阶段一定有新读额度、缓存即本阶段证据 |
|
|
17
|
+
| 问题复核 | 原文位置检查、模型反证与状态记录、每次重新签发回执 | 缺陷必然成立、无漏报或无误报 |
|
|
17
18
|
| 文件指导 | 语言、协议、配置与模板的保守检查提示 | 编译器、类型检查器或真实运行结果 |
|
|
18
19
|
|
|
19
20
|
## 当前限制
|
package/README.md
CHANGED
|
@@ -243,7 +243,7 @@ Git / SVN 历史审查也能使用这些参数;依赖和调用方按对应版
|
|
|
243
243
|
|
|
244
244
|
需要补审时,在**新预览**中传入 `resumeFromJobId`,例如 `{"resumeFromJobId":"原执行任务的真实jobId"}`;仍须展示新范围、重新确认并通过当前宿主权限。只复用同一会话、工作目录、模型路由/思考设置下,完整快照、配置和运行时身份均匹配的已完成且符合复用条件的文件链;项目模式还绑定对应批次。来源或配置变化不做猜测复用。未完成阶段重新执行,旧发现标记 `priorRun`,不伪造本轮读取凭据、模型调用或覆盖。
|
|
245
245
|
|
|
246
|
-
后台审查会在上述宿主任务目录的 `artifacts` 子目录留存有界模型工件,包括失败/取消时已捕获的文本或工具参数;不含 reasoning。它们可能含源码,应按源码保护,不会自动加入提示词、当作已确认发现或在普通状态报告公开。默认单工件 1 MiB、每任务原文总量 8 MiB、最多
|
|
246
|
+
后台审查会在上述宿主任务目录的 `artifacts` 子目录留存有界模型工件,包括失败/取消时已捕获的文本或工具参数;不含 reasoning。它们可能含源码,应按源码保护,不会自动加入提示词、当作已确认发现或在普通状态报告公开。默认单工件 1 MiB、每任务原文总量 8 MiB、最多 4096 条记录;截断由工件标记,存储超限明确返回遗漏标记和无源码诊断。读取绑定原会话、目录、提供方、模型和思考设置。需要查看时,Agent 必须先提示“可能把源码/模型原文带入当前对话”,取得你的明确同意,再调用 `code_review_artifacts({jobId,confirmed:true})`;可用 `offset`/`limit` 分页,默认一次 1 条、最多 100 条。返回内容标为 `sourceBearing:true`、`untrusted:true`,不是批准证据;普通 `status` 不返回工件原文。目录/文件使用 0700/0600 权限模式及原子写入;Windows 模式位不等于额外 ACL 保证。
|
|
247
247
|
|
|
248
248
|
## 多个会话同时审查
|
|
249
249
|
|
|
@@ -336,7 +336,7 @@ Git / SVN 历史审查也能使用这些参数;依赖和调用方按对应版
|
|
|
336
336
|
|
|
337
337
|
模型搜索的精确命中可同轮附带批准来源原文,每阶段最多2次额外读取尝试、每次最多24行;占用原共享额度并预留4次操作/16KiB输出,不猜测另侧坐标,不增加检索轮数。配置 `focusPrimaryContext:false`、`enableSearchSeeds:false` 可分别关闭上下文聚焦和搜索命中附读;无检索能力时不会隐藏正文。
|
|
338
338
|
|
|
339
|
-
共享读取预算按本次可审文件数有界缩放,预览的 `execution.readerBudget`
|
|
339
|
+
共享读取预算按本次可审文件数有界缩放,预览的 `execution.readerBudget` 显示具体值;所有阶段共用、不逐文件刷新。生产编排在根上限内按主审规模及综合/复核单元权重预留最低额度,未用份额释放;最低份额取整可能为零,不保证每个阶段都有新读额度。同一批准快照的相同操作结果可复用,`calls` 计实际结果生成、缓存命中另计;每次交付仍计序列化字节,复核回执重新签发,跨快照不复用。必要证据不足仍保留未完成范围,不以提前返回当作完成。配置 `riskPlanningMode:"separate"`、`readerBudgetPolicy:"fixed"` 可回退原规划和固定读取额度,这些是插件配置,不是preview参数。更多额度不保证更快或完整覆盖;不建议用提高并发掩盖冗余调用。
|
|
340
340
|
|
|
341
341
|
诊断显示实际加载版本、阶段及调用目的计数、读取余量,并区分单阶段检索轮数和共享读取额度。新增无源码的JSON纠正原因、协议/审查格式纠正计数及未合并规划原因;纠正计数以实际纠正调用为准;这些诊断不含原响应、源码片段或提供方错误文本,与下面说明的私有模型工件分开。`primary-initial` 是检索链初始调用,不只包含主审阶段。累计调用耗时可能并发重叠,不能当作总耗时或纯提供方耗时。离线调用数减少不代表实际审核必然同比提速,不能用更快返回partial冒充审核完成。
|
|
342
342
|
|
package/RELEASE-NOTES.md
CHANGED
|
@@ -1,27 +1,23 @@
|
|
|
1
|
-
# v0.86.
|
|
1
|
+
# v0.86.1 发布说明
|
|
2
2
|
|
|
3
|
-
使用方式见 [README](README.md),历史变化见 [变更记录](CHANGELOG.md)
|
|
3
|
+
使用方式见 [README](README.md),历史变化见 [变更记录](CHANGELOG.md),发布门禁见 [开发说明](DEVELOPMENT.md)。
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## 取证预算与成果保留
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
生产编排为主审、跨窗口/跨文件综合及候选复核在同一个根读取预算内按权重预留最低额度,阶段结束释放闲置份额;不提高根调用或输出字节上限。最低份额取整可能为零,额度和检索轮数不足仍如实披露,不能保证所有来源或阶段完成。
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
同一批准快照内,相同读取/搜索/查找请求可复用原始操作结果,身份、版本侧、坐标、查询和上限均参与匹配。返回独立副本;每次交付仍扣序列化输出字节,复核回执重新签发,不继承其他阶段凭据。缓存和分配账本参与原子事务回滚;失败、取消、源身份变化不放宽边界。低层默认保持原语义,仅生产优化编排启用。
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
已经校验的组成员不因后续读取准入失败被丢弃;组级受限原因传播至所有成员,但共享事件只计一次。跨窗口/跨文件阶段预算失败单独记录,不把已完成主审/窗口成果改写成主审失败;复用资格仍拒绝未完成综合、受限阶段或未复核候选。
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
## 工件容量与终态呈现
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
默认工件数量上限从64改为4096,单记录1MiB、单任务8MiB不变;真实数量/字节超限、截断仍明确记录,不宣称无限留存。普通诊断不含原文;取回仍需源码隐私知情同意。
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
业务分组成为文件链调度前的独立阶段,不再先跑完整条首文件链。2至4个能完整装入输入预算且差异精确的变更文件,可以一次 group-primary 生成全部成员的主审结果,再按真实 fileId 与原始行号逐项严格校验、分发;不会仍为每个成员重复发起首轮主审。超预算、窗口/多批次、非精确差异、项目模式和多轮独立审查在发送前保持原路径。
|
|
20
|
-
|
|
21
|
-
组内风险问题可以合并到本次生成,缺失/无效计划和未读来源明确披露,不补发隐藏规划调用。组主审不冒充跨文件证据:独立交互阶段及候选复核保留,不能把不合格 interaction 降级成普通发现绕过校验。调用预算耗尽时保留已经生成并校验的成员结果,把后续未完成阶段标为 partial,不丢弃已付费主审成果。失败组不隐藏补发全体单文件调用。
|
|
17
|
+
任务结束的状态摘要显示真实报告状态、覆盖、调用、读取余量及分页总量,明确后台 completed 不等于报告 complete;一次 terminal wait 仍返回整份报告。跨文件综合不再统一误写为超时,而显示已有原因及失败代码。
|
|
22
18
|
|
|
23
19
|
## 验证与边界
|
|
24
20
|
|
|
25
|
-
|
|
21
|
+
新增离线夹具覆盖74文件共享读取、早期贪婪请求与后续复核额度、组限制传播、已完成窗口保留、零新读额度下缓存交付与新回执、事务回滚及128个小工件全部留存。它们证明机制,不代表已重跑用户业务,也不构成真实模型速度、覆盖或质量基准。
|
|
26
22
|
|
|
27
|
-
|
|
23
|
+
正式发布须通过冻结标签 Ubuntu/Windows × Node.js22/24 四矩阵及 Actions、npm、GitHub Release 原始包逐字节、校验清单和冻结发布说明核验。不会自动安装、升级或重启当前 DSH。
|
package/package.json
CHANGED
|
@@ -15,10 +15,46 @@ const CANCEL_PARAMETERS = { type: 'object', additionalProperties: false, propert
|
|
|
15
15
|
const WAIT_DESCRIPTION = 'Wait for review progress or terminal result without ending the user request. Prefer waitFor=terminal to ignore intermediate checkpoints; progress preserves checkpoint-by-checkpoint compatibility. Reuse the exact returned waitRevision as after; never guess increments or call status after every event. Timeout defaults to 60000ms and is bounded to 60000ms; changed=false means timeout, not completion or cancellation. Call again while queued/running. Preview completion requires confirmation; execute completion returns one final consolidated report.';
|
|
16
16
|
const WAIT_PARAMETERS = { type: 'object', additionalProperties: false, properties: { jobId: { type: 'string' }, after: { type: 'integer', minimum: 0, description: 'Exact waitRevision returned by start/status/wait; do not increment it yourself.' }, waitFor: { type: 'string', enum: ['progress', 'terminal'], default: 'progress', description: 'Use terminal to wait through intermediate saves; progress wakes on each durable checkpoint.' }, timeoutMs: { type: 'integer', minimum: 1, maximum: 60000, default: 60000 } }, required: ['jobId'] };
|
|
17
17
|
|
|
18
|
+
const TERMINAL_STATUSES = ['completed', 'failed', 'cancelled', 'interrupted'];
|
|
19
|
+
const REPORT_STATUSES = ['completed', 'partial', 'failed', 'cancelled'];
|
|
20
|
+
const COVERAGE_FIELDS = ['completed', 'excluded', 'blocked', 'failed', 'cancelled', 'pending'];
|
|
21
|
+
const LIMIT_CODES = ['READER_CALL_BUDGET_EXCEEDED', 'READER_OUTPUT_BUDGET_EXCEEDED', 'RETRIEVAL_ROUND_BUDGET_EXCEEDED'];
|
|
22
|
+
const count = value => Number.isSafeInteger(value) && value >= 0 ? value : '不可用';
|
|
23
|
+
function terminalSummary(value) {
|
|
24
|
+
const report = value.resultSummary;
|
|
25
|
+
const lines = ['审查任务已结束:' + value.status, 'jobId: ' + value.jobId];
|
|
26
|
+
if (value.progress) lines.push('elapsedMs: ' + count(value.progress.elapsedMs));
|
|
27
|
+
if (report && REPORT_STATUSES.includes(report.status)) {
|
|
28
|
+
lines.push('报告状态:' + report.status + ';任务结束不等于所有审查通过。');
|
|
29
|
+
lines.push('文件覆盖:' + COVERAGE_FIELDS.map(key => key + ' ' + count(report.coverage?.[key])).join(';'));
|
|
30
|
+
lines.push('总文件:' + count(report.totalFiles) + ';总发现:' + count(report.totalFindings) + ';限制项:' + count(report.totalLimitations));
|
|
31
|
+
lines.push('模型调用:' + count(report.modelCalls ?? report.diagnostics?.modelCalls));
|
|
32
|
+
const d = report.diagnostics;
|
|
33
|
+
if (d?.readerBudget) {
|
|
34
|
+
const b = d.readerBudget;
|
|
35
|
+
lines.push('共享读取预算:调用 ' + count(b.calls) + '/' + count(b.maxCalls) + ',输出字节 ' + count(b.outputBytes) + '/' + count(b.maxOutputBytes) + ';剩余调用 ' + count(b.remainingCalls) + ',剩余字节 ' + count(b.remainingOutputBytes));
|
|
36
|
+
}
|
|
37
|
+
const reasons = Array.isArray(d?.readerLimitReasons) ? d.readerLimitReasons : [];
|
|
38
|
+
const bounded = LIMIT_CODES.flatMap(code => { const row = reasons.find(item => item?.code === code); return row ? [code + ' ' + count(row.calls)] : []; });
|
|
39
|
+
if (bounded.length) lines.push('读取限制原因:' + bounded.join(';'));
|
|
40
|
+
if (d?.readerLimits) lines.push('每阶段最大检索轮次:' + count(d.readerLimits.maxRounds));
|
|
41
|
+
if (d?.artifacts) lines.push('私有工件计数:留存 ' + count(d.artifacts.retained) + ',未留存 ' + count(d.artifacts.omitted) + '(不包含原始正文)');
|
|
42
|
+
if (value.resultPage) lines.push('当前页:文件 ' + (value.resultPage.files?.length ?? 0) + '/' + count(value.resultPage.totalFiles) + ',发现 ' + (value.resultPage.findings?.length ?? 0) + '/' + count(value.resultPage.totalFindings) + ';code_review_status 的 offset/limit 仅切换结果页。');
|
|
43
|
+
lines.push('完整报告:调用一次 code_review_wait(jobId 同上,waitFor=terminal)立即取得最终报告;无需继续轮询。');
|
|
44
|
+
} else if (report?.previewId) {
|
|
45
|
+
lines.push('预览已完成,尚未执行审查;请展示批准范围并取得用户确认后再执行。');
|
|
46
|
+
lines.push('预览文件总量:' + count(report.filePage?.total ?? report.files?.length) + ';计划最少调用:' + count(report.plan?.minimumCalls));
|
|
47
|
+
} else {
|
|
48
|
+
lines.push('未提供完整报告摘要;终态不会自动恢复或重新发送源码。请检查已保存检查点和未完成范围。');
|
|
49
|
+
}
|
|
50
|
+
return lines.join('\n');
|
|
51
|
+
}
|
|
52
|
+
|
|
18
53
|
function respond(value) {
|
|
19
54
|
if (value.finalMarkdown !== null && value.finalMarkdown !== undefined) {
|
|
20
55
|
return { json: JSON.stringify(value), markdown: value.finalMarkdown };
|
|
21
56
|
}
|
|
57
|
+
if (TERMINAL_STATUSES.includes(value.status)) return { json: JSON.stringify(value), markdown: terminalSummary(value) };
|
|
22
58
|
const p = value.progress;
|
|
23
59
|
if (p) {
|
|
24
60
|
const progress = p.total === null
|
package/src/primary-seeds.mjs
CHANGED
|
@@ -65,13 +65,17 @@ export function primarySeeds(scope, request, { maxInputBytes = 96 * 1024, onRetr
|
|
|
65
65
|
if (attempted.size >= MAX_READS) break;
|
|
66
66
|
if (seeds.some(item => item.request.id === source.id && item.result.start <= coordinate && coordinate < item.result.start + item.result.count)) continue;
|
|
67
67
|
const budget = scope.budget();
|
|
68
|
-
if (budget.
|
|
68
|
+
if (budget.remainingOutputBytes <= RESERVE_BYTES) break;
|
|
69
69
|
const operation = { kind: 'read', id: source.id, start: Math.max(1, coordinate - 4), count: MAX_LINES };
|
|
70
70
|
const key = JSON.stringify(operation);
|
|
71
71
|
if (attempted.has(key)) continue;
|
|
72
72
|
attempted.add(key);
|
|
73
73
|
let item;
|
|
74
74
|
try {
|
|
75
|
+
// A cached raw delivery costs no new materialization call, but still uses
|
|
76
|
+
// output bytes and must pass the exact operation's approved preflight.
|
|
77
|
+
const cached = budget.reusableResults === true && scope.preflight([operation])?.calls === 0 && scope.canServe?.([operation]) === true;
|
|
78
|
+
if (!cached && budget.remainingCalls <= RESERVE_CALLS) continue;
|
|
75
79
|
item = scope.transaction(() => {
|
|
76
80
|
checkSignal();
|
|
77
81
|
scope.preflight([operation]);
|
|
@@ -79,7 +83,7 @@ export function primarySeeds(scope, request, { maxInputBytes = 96 * 1024, onRetr
|
|
|
79
83
|
checkSignal();
|
|
80
84
|
const next = retrievalRequest(request, scope, [...seeds, { request: operation, result }]);
|
|
81
85
|
const remaining = scope.budget();
|
|
82
|
-
if (result.start + result.count <= coordinate || remaining.remainingCalls < RESERVE_CALLS || remaining.remainingOutputBytes < RESERVE_BYTES || (request.nativeOutputTools === true ? !modelWireBudget(next, maxInputBytes).fits : Buffer.byteLength(next.input) + Buffer.byteLength(next.instructions) > maxInputBytes)) throw optionalError();
|
|
86
|
+
if (result.start + result.count <= coordinate || (!cached && remaining.remainingCalls < RESERVE_CALLS) || remaining.remainingOutputBytes < RESERVE_BYTES || (request.nativeOutputTools === true ? !modelWireBudget(next, maxInputBytes).fits : Buffer.byteLength(next.input) + Buffer.byteLength(next.instructions) > maxInputBytes)) throw optionalError();
|
|
83
87
|
return { request: operation, result };
|
|
84
88
|
});
|
|
85
89
|
} catch (error) {
|
|
@@ -123,7 +123,7 @@ export function createProjectReviewService(llm, { allowModelSending = false, aut
|
|
|
123
123
|
model: current.route, modelSendingEnabled: allowModelSending,
|
|
124
124
|
totalTrackedNodes: s.totalTrackedNodes, selectedFiles: s.selectedFiles, selectionComplete: s.selectionComplete,
|
|
125
125
|
inventoryComplete: s.inventoryComplete, inventoryScope: s.inventoryScope, inventoryNodes: s.inventoryNodes, batches: s.batches.length,
|
|
126
|
-
privateArtifacts: { enabled: typeof exec.onModelArtifact === 'function', sourceBearing: true, maxArtifactBytes: 1048576, maxJobBytes: 8388608, maxArtifacts:
|
|
126
|
+
privateArtifacts: { enabled: typeof exec.onModelArtifact === 'function', sourceBearing: true, maxArtifactBytes: 1048576, maxJobBytes: 8388608, maxArtifacts: 4096, notice: 'Bounded private untrusted model outputs; raw access requires explicit consent and is not review evidence.' },
|
|
127
127
|
execution: { nativeOutputTools, enableGroupPrimary, batchConcurrency: projectConcurrency, maxRetrievalRounds },
|
|
128
128
|
files: s.batches.flatMap(b => b.files.map(f => ({ path: f.path, eligibility: f.eligibility, reason: f.reason }))),
|
|
129
129
|
notice: s.notice + PREVIEW_NOTICE_SUFFIX + ' Each stage allows at most ' + maxRetrievalRounds + ' retrieval rounds; exhaustion requires a final-only response and explicit unfinished coverage.',
|
|
@@ -202,7 +202,7 @@ export function createProjectReviewService(llm, { allowModelSending = false, aut
|
|
|
202
202
|
repositoryRoot: s.root, vcs: s.vcs, model: p.route, modelCalls: calls, maxModelCalls: projectMaxCalls,
|
|
203
203
|
totalTrackedNodes: s.totalTrackedNodes, selectionComplete: s.selectionComplete, inventoryComplete: s.inventoryComplete, inventoryScope: s.inventoryScope, inventoryNodes: s.inventoryNodes,
|
|
204
204
|
coverage, files, findings, artifacts: results.flatMap(result => result.artifacts ?? []), batches: results, resume: { reusedFiles: files.filter(file => file.reused).length },
|
|
205
|
-
privateArtifacts: { enabled: typeof exec.onModelArtifact === 'function', sourceBearing: true, maxArtifactBytes: 1048576, maxJobBytes: 8388608, maxArtifacts:
|
|
205
|
+
privateArtifacts: { enabled: typeof exec.onModelArtifact === 'function', sourceBearing: true, maxArtifactBytes: 1048576, maxJobBytes: 8388608, maxArtifacts: 4096, notice: 'Bounded private untrusted model outputs; raw access requires explicit consent and is not review evidence.' },
|
|
206
206
|
execution: { nativeOutputTools, enableGroupPrimary, batchConcurrency: projectConcurrency, maxRetrievalRounds },
|
|
207
207
|
notice: s.notice + REPORT_NOTICE_SUFFIX,
|
|
208
208
|
};
|
package/src/retrieval-loop.mjs
CHANGED
|
@@ -66,7 +66,7 @@ export async function retrievalLoop(model, request, scope, { correctProtocol = f
|
|
|
66
66
|
};
|
|
67
67
|
const checkExhausted = async round => {
|
|
68
68
|
const budget = scope.budget?.();
|
|
69
|
-
if (!finalization && budget && (budget.remainingCalls === 0 || budget.remainingOutputBytes === 0)) {
|
|
69
|
+
if (!finalization && budget && ((budget.remainingCalls === 0 && !budget.reusableResults) || budget.remainingOutputBytes === 0)) {
|
|
70
70
|
const error = new Error(budget.remainingCalls === 0 ? 'Reader call budget exceeded' : 'Reader output budget exceeded');
|
|
71
71
|
error.code = budget.remainingCalls === 0 ? 'READER_CALL_BUDGET_EXCEEDED' : 'READER_OUTPUT_BUDGET_EXCEEDED';
|
|
72
72
|
await finalize(error);
|
package/src/review-artifacts.mjs
CHANGED
|
@@ -40,7 +40,7 @@ async function projectedRealpath(name) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
// Artifacts are untrusted private evidence, never approved source or confirmed findings.
|
|
43
|
-
export function createReviewArtifacts({ directory, maxArtifactBytes = 1048576, maxJobBytes = 8388608, maxArtifacts =
|
|
43
|
+
export function createReviewArtifacts({ directory, maxArtifactBytes = 1048576, maxJobBytes = 8388608, maxArtifacts = 4096 } = {}) {
|
|
44
44
|
if (typeof directory !== 'string' || !path.isAbsolute(directory)) throw fail('INVALID_REVIEW_ARTIFACT_DIRECTORY');
|
|
45
45
|
if (![maxArtifactBytes, maxJobBytes, maxArtifacts].every(n => Number.isSafeInteger(n) && n > 0) || maxArtifactBytes > 16777216 || maxJobBytes > 268435456 || maxArtifacts > 10000) throw fail('INVALID_REVIEW_ARTIFACT_LIMIT');
|
|
46
46
|
let canonical;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { REVIEW_RUNTIME } from './runtime-identity.mjs';
|
|
2
2
|
const PHASES = ['business-grouping', 'risk-planning', 'primary', 'group-primary', 'file-interaction', 'window-synthesis', 'verification'];
|
|
3
3
|
const PURPOSES = ['primary-initial', 'retrieval-followup', 'budget-finalization', 'json-correction', 'business-grouping', 'risk-planning', 'group-primary', 'window-synthesis', 'file-interaction', 'verification'];
|
|
4
|
-
const READER_FIELDS = ['calls', 'outputBytes', 'maxCalls', 'maxOutputBytes', 'remainingCalls', 'remainingOutputBytes', 'maxResultBytes'];
|
|
4
|
+
const READER_FIELDS = ['calls', 'outputBytes', 'maxCalls', 'maxOutputBytes', 'remainingCalls', 'remainingOutputBytes', 'maxResultBytes', 'cacheHits', 'deliveries', 'cacheEntries', 'cacheBytes'];
|
|
5
5
|
const JSON_ISSUES = ['property-name', 'colon', 'separator', 'invalid-escape', 'control-character', 'unterminated-string', 'unexpected-end', 'trailing-content', 'syntax'];
|
|
6
6
|
const CORRECTION_CODES = ['MODEL_INVALID_JSON', 'MODEL_INVALID_REVIEW_RESPONSE', 'MODEL_INVALID_FINDING_IDENTITY', 'MODEL_INVALID_FINDING_SEVERITY', 'MODEL_MISSING_FINDING_FIELD', 'MODEL_INVALID_ANCHOR', 'MODEL_ANCHOR_MISMATCH', 'RETRIEVAL_PROTOCOL_ERROR'];
|
|
7
7
|
const LIMIT_CODES = ['READER_CALL_BUDGET_EXCEEDED', 'READER_OUTPUT_BUDGET_EXCEEDED', 'RETRIEVAL_ROUND_BUDGET_EXCEEDED'];
|
|
@@ -67,6 +67,7 @@ export function diagnosticsMarkdown(d) {
|
|
|
67
67
|
const n = field => number(d.readerBudget[field]) ?? '不可用';
|
|
68
68
|
output.push('- 共享读取预算:调用 ' + n('calls') + '/' + n('maxCalls') + ',序列化输出字节 ' + n('outputBytes') + '/' + n('maxOutputBytes') + ';剩余调用 ' + n('remainingCalls') + ',剩余输出字节 ' + n('remainingOutputBytes') + '。');
|
|
69
69
|
}
|
|
70
|
+
if (number(d.readerBudget?.cacheHits) !== null) output.push('- 快照内取证复用:缓存命中 ' + d.readerBudget.cacheHits + ',实际交付 ' + (number(d.readerBudget.deliveries) ?? '不可用') + ';calls 计唯一操作结果生成,重复交付仍计输出字节,复核回执每次重新签发。');
|
|
70
71
|
if (d.correctionReasons?.length) output.push('- 纠正原因(仅实际纠正调用,不重复计失败/完成通知):' + d.correctionReasons.filter(r => CORRECTION_CODES.includes(r.code)).map(r => r.code + (JSON_ISSUES.includes(r.jsonIssue) ? '/' + r.jsonIssue : '') + ' ' + (number(r.calls) ?? '不可用')).join(';') + '。');
|
|
71
72
|
if (d.readerLimitReasons?.length) output.push('- 读取限制原因:' + d.readerLimitReasons.filter(r => LIMIT_CODES.includes(r.code)).map(r => r.code + ' ' + (number(r.calls) ?? '不可用')).join(';') + '。');
|
|
72
73
|
if (number(d.readerLimits?.maxRounds) !== null) output.push('- 每阶段最大检索轮次:' + number(d.readerLimits.maxRounds) + ';调用数/输出字节耗尽与轮次耗尽分别记录,不能混同。');
|
package/src/review-jobs.mjs
CHANGED
|
@@ -78,7 +78,7 @@ export function createReviewJobs({directory}={}){
|
|
|
78
78
|
},
|
|
79
79
|
async resume(id,exec){const r=await owned(id,exec);if(jobs.get(id)&&!jobs.get(id).settled||['queued','running'].includes(r.status)||!r.resumeState)throw new Error('No terminal completed-chain resume state available');return structuredClone(r.resumeState);},
|
|
80
80
|
async artifacts(id,exec,page){const r=await owned(id,exec);return artifacts.read(id,r.owner,page);},
|
|
81
|
-
async status(id,exec,page){const r=structuredClone(await owned(id,exec));delete r.resumeState;const active=jobs.get(id);if(active&&!active.settled&&!['queued','running'].includes(r.status)){r.status='running';r.stage='finalizing';delete r.result;delete r.finishedAt;}r.recovery=recoveryCoverage(r);r.progress=jobProgress(r);r.waitRevision=jobs.get(id)?.savedRevision??r.checkpoints.length;r.nextAction=['queued','running'].includes(r.status)?'Call code_review_wait with waitFor=terminal; reuse the exact returned waitRevision as after, never guess increments. No code_review_status call is needed after each event. Do not end the user request while task runs.':r.result?.previewId?'Show preview and request user confirmation before execute.':'Collect final report and unfinished scope, then provide one final answer.';if(!page)return r;const {offset=0,limit=20,checkpointIndex}=page;if(!Number.isSafeInteger(offset)||offset<0||!Number.isSafeInteger(limit)||limit<1||limit>100)throw new Error('Invalid result page');if(checkpointIndex!==undefined){if(!Number.isSafeInteger(checkpointIndex)||checkpointIndex<0||checkpointIndex>=r.checkpoints.length)throw new Error('Invalid checkpoint index');return {jobId:r.jobId,status:r.status,waitRevision:r.waitRevision,progress:r.progress,nextAction:r.nextAction,checkpointIndex,checkpoint:pageCheckpoint(r.checkpoints[checkpointIndex],offset,limit),page:{offset,limit}};}const result=r.result;const report=result?.report;return {...r,result:undefined,recovery:{...r.recovery,unfinishedStages:r.recovery.unfinishedStages.slice(offset,offset+limit),files:r.recovery.files.slice(offset,offset+limit),windows:r.recovery.windows.slice(offset,offset+limit),suggestedPaths:r.recovery.suggestedPaths.slice(offset,offset+limit),totalObservedFiles:r.recovery.files.length},checkpoints:r.checkpoints.slice(offset,offset+limit).map(c=>pageCheckpoint(c,0,limit)),checkpointPage:{offset,limit,total:r.checkpoints.length},...(result?{resultSummary:report?{status:report.status,coverage:report.coverage,model:report.model,snapshotId:report.snapshotId,diagnostics:report.diagnostics,execution:report.execution,modelCalls:report.modelCalls}:({...result,files:result.files?.slice(offset,offset+limit),filePage:result.files?{offset,limit,total:result.files.length}:undefined}),resultPage:report?{files:report.files?.slice(offset,offset+limit),findings:report.findings?.slice(offset,offset+limit),totalFiles:report.files?.length,totalFindings:report.findings?.length}:undefined}: {})};},
|
|
81
|
+
async status(id,exec,page){const r=structuredClone(await owned(id,exec));delete r.resumeState;const active=jobs.get(id);if(active&&!active.settled&&!['queued','running'].includes(r.status)){r.status='running';r.stage='finalizing';delete r.result;delete r.finishedAt;}r.recovery=recoveryCoverage(r);r.progress=jobProgress(r);r.waitRevision=jobs.get(id)?.savedRevision??r.checkpoints.length;r.nextAction=['queued','running'].includes(r.status)?'Call code_review_wait with waitFor=terminal; reuse the exact returned waitRevision as after, never guess increments. No code_review_status call is needed after each event. Do not end the user request while task runs.':r.result?.previewId?'Show preview and request user confirmation before execute.':'Collect final report and unfinished scope, then provide one final answer.';if(!page)return r;const {offset=0,limit=20,checkpointIndex}=page;if(!Number.isSafeInteger(offset)||offset<0||!Number.isSafeInteger(limit)||limit<1||limit>100)throw new Error('Invalid result page');if(checkpointIndex!==undefined){if(!Number.isSafeInteger(checkpointIndex)||checkpointIndex<0||checkpointIndex>=r.checkpoints.length)throw new Error('Invalid checkpoint index');return {jobId:r.jobId,status:r.status,waitRevision:r.waitRevision,progress:r.progress,nextAction:r.nextAction,checkpointIndex,checkpoint:pageCheckpoint(r.checkpoints[checkpointIndex],offset,limit),page:{offset,limit}};}const result=r.result;const report=result?.report;return {...r,result:undefined,recovery:{...r.recovery,unfinishedStages:r.recovery.unfinishedStages.slice(offset,offset+limit),files:r.recovery.files.slice(offset,offset+limit),windows:r.recovery.windows.slice(offset,offset+limit),suggestedPaths:r.recovery.suggestedPaths.slice(offset,offset+limit),totalObservedFiles:r.recovery.files.length},checkpoints:r.checkpoints.slice(offset,offset+limit).map(c=>pageCheckpoint(c,0,limit)),checkpointPage:{offset,limit,total:r.checkpoints.length},...(result?{resultSummary:report?{status:report.status,coverage:report.coverage,model:report.model,snapshotId:report.snapshotId,diagnostics:report.diagnostics,execution:report.execution,modelCalls:report.modelCalls,totalFiles:report.files?.length,totalFindings:report.findings?.length,totalLimitations:report.limitations?.length}:({...result,files:result.files?.slice(offset,offset+limit),filePage:result.files?{offset,limit,total:result.files.length}:undefined}),resultPage:report?{files:report.files?.slice(offset,offset+limit),findings:report.findings?.slice(offset,offset+limit),totalFiles:report.files?.length,totalFindings:report.findings?.length}:undefined}: {})};},
|
|
82
82
|
async wait(id,exec,{after,waitFor='progress',timeoutMs=60000,signal}={}){
|
|
83
83
|
const r=await owned(id,exec);
|
|
84
84
|
if(!Number.isSafeInteger(timeoutMs)||timeoutMs<1||timeoutMs>60000)throw new Error('Invalid wait duration');
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Per-stage admission over ONE approved root ledger, not independent readers.
|
|
2
|
+
// Fixed proportional floors protect future stages; released unused floors become
|
|
3
|
+
// shared capacity. No content/source identifiers are emitted by summary().
|
|
4
|
+
export function createReviewReaderAllocation(root, units) {
|
|
5
|
+
if (!root || !['budget', 'usage', 'preflight', 'transaction', 'read', 'find', 'search', 'catalog'].every(key => typeof root[key] === 'function')) throw new Error('Invalid root reader allocation');
|
|
6
|
+
if (!Array.isArray(units) || units.length > 2000) throw new Error('Invalid reader allocation units');
|
|
7
|
+
const budget = root.budget();
|
|
8
|
+
if (![budget.remainingCalls, budget.remainingOutputBytes].every(value => Number.isSafeInteger(value) && value >= 0)) throw new Error('Invalid root reader allocation budget');
|
|
9
|
+
const states = new Map(); let totalWeight = 0;
|
|
10
|
+
for (const unit of units) {
|
|
11
|
+
if (!unit || typeof unit.id !== 'string' || !unit.id || unit.id.length > 500 || states.has(unit.id) || !Number.isSafeInteger(unit.weight) || unit.weight < 1 || unit.weight > 1000000) throw new Error('Invalid reader allocation unit');
|
|
12
|
+
states.set(unit.id, { weight: unit.weight, calls: 0, outputBytes: 0, cacheHits: 0, released: false, inTransaction: false });
|
|
13
|
+
totalWeight += unit.weight;
|
|
14
|
+
}
|
|
15
|
+
for (const state of states.values()) {
|
|
16
|
+
state.callFloor = Math.floor(budget.remainingCalls * state.weight / totalWeight);
|
|
17
|
+
state.byteFloor = Math.floor(budget.remainingOutputBytes * state.weight / totalWeight);
|
|
18
|
+
}
|
|
19
|
+
const fail = kind => { const error = new Error('Reader allocation ' + kind + ' budget exceeded'); error.code = kind === 'call' ? 'READER_CALL_BUDGET_EXCEEDED' : 'READER_OUTPUT_BUDGET_EXCEEDED'; throw error; };
|
|
20
|
+
const live = id => { const state = states.get(id); if (!state || state.released) throw new Error('Reader allocation unit unavailable'); return state; };
|
|
21
|
+
const available = state => {
|
|
22
|
+
const remaining = root.budget(); let protectedCalls = 0, protectedBytes = 0;
|
|
23
|
+
for (const other of states.values()) {
|
|
24
|
+
if (other === state || other.released) continue;
|
|
25
|
+
protectedCalls += Math.max(0, other.callFloor - other.calls);
|
|
26
|
+
protectedBytes += Math.max(0, other.byteFloor - other.outputBytes);
|
|
27
|
+
}
|
|
28
|
+
return { ...remaining, remainingCalls: Math.max(0, remaining.remainingCalls - protectedCalls), remainingOutputBytes: Math.max(0, remaining.remainingOutputBytes - protectedBytes) };
|
|
29
|
+
};
|
|
30
|
+
const check = (state, cost) => { const remaining = available(state); if (cost.calls > remaining.remainingCalls) fail('call'); if (cost.outputBytes > remaining.remainingOutputBytes) fail('output'); };
|
|
31
|
+
const scopes = new Map();
|
|
32
|
+
return Object.freeze({
|
|
33
|
+
scope(id) {
|
|
34
|
+
const state = live(id); if (scopes.has(id)) return scopes.get(id);
|
|
35
|
+
const preflight = requests => { live(id); const cost = root.preflight(requests); if (!cost || !Number.isSafeInteger(cost.calls) || !Number.isSafeInteger(cost.outputBytes)) throw new Error('Reader allocation preflight unavailable'); check(state, cost); return cost; };
|
|
36
|
+
const execute = operation => {
|
|
37
|
+
live(id); const before = root.usage(), remaining = available(state);
|
|
38
|
+
const result = operation(), after = root.usage();
|
|
39
|
+
const calls = after.calls - before.calls, outputBytes = after.outputBytes - before.outputBytes;
|
|
40
|
+
if (calls > remaining.remainingCalls) fail('call');
|
|
41
|
+
if (outputBytes > remaining.remainingOutputBytes) fail('output');
|
|
42
|
+
state.calls += calls; state.outputBytes += outputBytes; state.cacheHits += (after.cacheHits ?? 0) - (before.cacheHits ?? 0);
|
|
43
|
+
return result;
|
|
44
|
+
};
|
|
45
|
+
const operation = (kind, request, decorate) => {
|
|
46
|
+
preflight([{ ...request, kind }]);
|
|
47
|
+
const run = () => execute(() => kind === 'read' ? root.read(request, decorate) : root[kind](request));
|
|
48
|
+
if (state.inTransaction) return run();
|
|
49
|
+
const before = { calls: state.calls, outputBytes: state.outputBytes, cacheHits: state.cacheHits };
|
|
50
|
+
try { return root.transaction(run); } catch (error) { Object.assign(state, before); throw error; }
|
|
51
|
+
};
|
|
52
|
+
const scope = Object.freeze({
|
|
53
|
+
catalog: () => { live(id); return root.catalog(); },
|
|
54
|
+
budget: () => { live(id); return available(state); },
|
|
55
|
+
usage: () => ({ calls: state.calls, outputBytes: state.outputBytes, cacheHits: state.cacheHits }),
|
|
56
|
+
preflight,
|
|
57
|
+
canServe(requests) { try { preflight(requests); return true; } catch { return false; } },
|
|
58
|
+
read: (request, decorate) => operation('read', request, decorate),
|
|
59
|
+
find: request => operation('find', request),
|
|
60
|
+
search: request => operation('search', request),
|
|
61
|
+
transaction(executeBatch) {
|
|
62
|
+
live(id); if (state.inTransaction || typeof executeBatch !== 'function') throw new Error('Invalid reader allocation transaction');
|
|
63
|
+
const before = { calls: state.calls, outputBytes: state.outputBytes, cacheHits: state.cacheHits };
|
|
64
|
+
state.inTransaction = true;
|
|
65
|
+
try { return root.transaction(executeBatch); }
|
|
66
|
+
catch (error) { Object.assign(state, before); throw error; }
|
|
67
|
+
finally { state.inTransaction = false; }
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
scopes.set(id, scope); return scope;
|
|
71
|
+
},
|
|
72
|
+
release(id) { const state = states.get(id); if (!state) return; if (state.inTransaction) throw new Error('Reader allocation transaction active'); state.released = true; },
|
|
73
|
+
summary() { const usage = root.usage(); return { units: states.size, releasedUnits: [...states.values()].filter(state => state.released).length, calls: usage.calls, outputBytes: usage.outputBytes, ...(usage.cacheHits !== undefined ? { cacheHits: usage.cacheHits } : {}) }; },
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -36,7 +36,7 @@ export function readerAdmission(prepared, remaining) {
|
|
|
36
36
|
if (!required) return { permitted: true };
|
|
37
37
|
const calls = remaining?.remainingCalls, bytes = remaining?.remainingOutputBytes;
|
|
38
38
|
if (!Number.isSafeInteger(calls) || !Number.isSafeInteger(bytes)) return { permitted: false, code: 'READER_BUDGET_UNAVAILABLE' };
|
|
39
|
-
if (calls <= 0) return { permitted: false, code: 'READER_CALL_BUDGET_EXCEEDED' };
|
|
39
|
+
if (calls <= 0 && !remaining.reusableResults) return { permitted: false, code: 'READER_CALL_BUDGET_EXCEEDED' };
|
|
40
40
|
if (bytes <= 0) return { permitted: false, code: 'READER_OUTPUT_BUDGET_EXCEEDED' };
|
|
41
41
|
return { permitted: true };
|
|
42
42
|
}
|
package/src/review.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import { changesConcurrency, retrievalRounds } from './review-execution-policy.m
|
|
|
29
29
|
import { scheduleFileChains } from './file-chain-scheduler.mjs';
|
|
30
30
|
import { reviewReaderBudget, readerAdmission } from './review-reader-policy.mjs';
|
|
31
31
|
import { primarySeeds } from './primary-seeds.mjs';
|
|
32
|
+
import { createReviewReaderAllocation } from './review-reader-allocation.mjs';
|
|
32
33
|
import { prepareGroupPrimary, validateGroupPrimary } from './group-primary.mjs';
|
|
33
34
|
import { configureWireBudget, modelWireBudget } from './model-wire-budget.mjs';
|
|
34
35
|
|
|
@@ -124,7 +125,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
124
125
|
if (options.enableBusinessGrouping === true && options.enableRetrieval !== true) throw new Error('Business grouping requires approved retrieval scope');
|
|
125
126
|
if (options.enableVerification === true && options.enableRetrieval !== true) throw new Error('Verification requires approved retrieval scope');
|
|
126
127
|
const readerLimits = reviewReaderBudget(input, options.readerBudgetPolicy ?? 'fixed');
|
|
127
|
-
const scope = options.enableRetrieval === true ? createRetrievalScope({ ...input, context: input.context ?? [] }, { ...readerLimits, signal }) : null;
|
|
128
|
+
const scope = options.enableRetrieval === true ? createRetrievalScope({ ...input, context: input.context ?? [] }, { ...readerLimits, signal, ...(options.optimizeReviewCalls === true ? { reuseResults: true } : {}) }) : null;
|
|
128
129
|
const report = { schemaVersion: 1, snapshotId: input.id, vcs: input.vcs, status: 'completed', files: [], findings: [], limitations: [], modelCalls: 0 };
|
|
129
130
|
validateBusinessRequirement(input.businessRequirement);
|
|
130
131
|
if(input.businessRequirement!==undefined)report.businessRequirement={hash:hash(input.businessRequirement),bytes:Buffer.byteLength(input.businessRequirement),status:'untrusted-constraint'};
|
|
@@ -174,20 +175,24 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
174
175
|
} });
|
|
175
176
|
};
|
|
176
177
|
const groupPrepared = new Map(), groupResults = new Map(), groupStarted = new Map();
|
|
178
|
+
let readerAllocation;
|
|
179
|
+
const stageScope = id => readerAllocation
|
|
180
|
+
? configureWireBudget(readerAllocation.scope(id), { nativeOutputTools: options.nativeOutputTools === true }) : scope;
|
|
181
|
+
const releaseStage = id => readerAllocation?.release(id);
|
|
177
182
|
const groupResult = async group => {
|
|
178
183
|
if (!groupResults.has(group.id)) {
|
|
179
184
|
groupStarted.set(group.id, performance.now());
|
|
180
185
|
groupResults.set(group.id, invoke(async request => {
|
|
181
|
-
const prepared = groupPrepared.get(group.id)
|
|
182
|
-
const response = await retrievalLoop(sharedModel, { ...request, nativeOutputTools: options.nativeOutputTools === true, input: prepared.payload, instructions: prepared.instructions, reviewPhase: 'group-primary' },
|
|
186
|
+
const prepared = groupPrepared.get(group.id), groupScope = stageScope('group:' + group.id); const limits = [], reads = [];
|
|
187
|
+
const response = await retrievalLoop(sharedModel, { ...request, nativeOutputTools: options.nativeOutputTools === true, input: prepared.payload, instructions: prepared.instructions, reviewPhase: 'group-primary' }, groupScope, {
|
|
183
188
|
maxRounds: maxRetrievalRounds, maxInputBytes, beforeCall: reserveShared, correctProtocol: options.correctProtocol === true,
|
|
184
189
|
enableSearchSeeds: options.enableSearchSeeds === true,
|
|
185
190
|
onRetrieved: records => { reads.push(...records); (sharedState.retrievalAudit ??= []).push(...records); },
|
|
186
|
-
onBudgetLimited: info => {
|
|
191
|
+
onBudgetLimited: info => { limits.push(info); (sharedState.retrievalLimits ??= []).push(info); },
|
|
187
192
|
});
|
|
188
193
|
const results = validateGroupPrimary(response, prepared, input, validateResponse, scope.catalog());
|
|
189
194
|
for (const generated of results.values()) {
|
|
190
|
-
generated.
|
|
195
|
+
generated.groupLimits = structuredClone(limits);
|
|
191
196
|
if (generated.groupRisk) {
|
|
192
197
|
generated.groupRisk.sourceReadCoverage = riskReadCoverage(generated.groupRisk.risks, reads, scope.catalog(), input.id);
|
|
193
198
|
if (generated.groupRisk.status !== 'completed') generated.limitations.push('组主审的合并风险计划缺失或无效,未补发规划调用;不表示已完成风险条件核查');
|
|
@@ -197,7 +202,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
197
202
|
return results;
|
|
198
203
|
}, { input: groupPrepared.get(group.id).payload, instructions: groupPrepared.get(group.id).instructions }, timeoutMs, signal));
|
|
199
204
|
}
|
|
200
|
-
return groupResults.get(group.id);
|
|
205
|
+
try { return await groupResults.get(group.id); } finally { releaseStage('group:' + group.id); }
|
|
201
206
|
};
|
|
202
207
|
const runFile = async index => {
|
|
203
208
|
const file = input.files[index];
|
|
@@ -229,14 +234,16 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
229
234
|
if(changes.precision==='envelope')result.limitations.push({fileId:file.id,text:'精确差异不可得,按保守包络分段检查;包络包含可能未变的行,不证明精确编辑或回归因果。'});
|
|
230
235
|
if (changes.status === 'limited') result.limitations.push({ fileId: file.id, text: '精确变更分析受限:' + changes.reason + ';仍按完整两侧内容审查' });
|
|
231
236
|
if (state.groupFallback) result.limitations.push({ fileId: file.id, text: '关联组上下文超过输入预算,回退单文件审查' });
|
|
232
|
-
if (state.initialInputBytes > maxInputBytes) { state.status = 'blocked'; state.reason = '输入超过预算,未截断或提交模型'; return; }
|
|
237
|
+
if (state.initialInputBytes > maxInputBytes) { state.status = 'blocked'; state.reason = '输入超过预算,未截断或提交模型'; releaseStage('primary:' + file.id); releaseStage('synthesis:' + file.id); releaseStage('verification:' + file.id); return; }
|
|
233
238
|
try {
|
|
234
239
|
const beforeCall = ({ reserved = false } = {}) => {
|
|
235
240
|
if (!reserved && options.remainingCalls?.() <= 0) throw Object.assign(new Error('Shared project model call limit exceeded'), { code: 'PROJECT_MODEL_CALL_LIMIT' });
|
|
236
241
|
if (report.modelCalls >= maxCalls) throw new Error('Model call budget exceeded');
|
|
237
242
|
report.modelCalls++;
|
|
238
243
|
};
|
|
244
|
+
let activeReader = scope, activeStage = 'primary';
|
|
239
245
|
const onBudgetLimited = info => {
|
|
246
|
+
if (activeStage !== 'primary') (state.secondaryRetrievalLimits ??= []).push({ phase: activeStage, ...info });
|
|
240
247
|
state.retrievalLimited = true;
|
|
241
248
|
(state.retrievalLimits ??= []).push(info);
|
|
242
249
|
result.limitations.push({ fileId: file.id, text: '检索预算受限,已保留现有结果但不能视为完整审查:' + info.reason });
|
|
@@ -277,7 +284,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
277
284
|
});
|
|
278
285
|
};
|
|
279
286
|
const executor = scope
|
|
280
|
-
? (request, onLimited = () => {}) => retrievalLoop(trackedModel, { ...request, nativeOutputTools: options.nativeOutputTools === true, onProtocolEvent },
|
|
287
|
+
? (request, onLimited = () => {}) => retrievalLoop(trackedModel, { ...request, nativeOutputTools: options.nativeOutputTools === true, onProtocolEvent }, activeReader, { correctProtocol:options.correctProtocol===true,maxRounds:maxRetrievalRounds,maxInputBytes, beforeCall, initialRetrieved: request.initialRetrieved ?? [], allowExhaustedInitial: request.allowExhaustedInitial === true, enableSearchSeeds: options.enableSearchSeeds === true && !request.reviewPhase?.includes('verification'), onBudgetLimited: info => { onBudgetLimited(info); onLimited(info); }, onRetrieved: records => {
|
|
281
288
|
(state.retrievalAudit ??= []).push(...records);
|
|
282
289
|
}, onTruncated: () => {
|
|
283
290
|
if (!state.retrievalTruncated) result.limitations.push({ fileId: file.id, text: '检索结果达到数量上限,部分匹配未提供给模型' });
|
|
@@ -288,7 +295,11 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
288
295
|
const groupedGeneration = grouped ? (await groupResult({ id: grouped[0] })).get(file.id) : null;
|
|
289
296
|
if (grouped) {
|
|
290
297
|
state.primaryGroupId = grouped[0]; state.primaryMethod = 'group-primary';
|
|
291
|
-
if (groupedGeneration.
|
|
298
|
+
if (groupedGeneration.groupLimits?.length) {
|
|
299
|
+
state.retrievalLimited = true;
|
|
300
|
+
state.groupRetrievalLimits = structuredClone(groupedGeneration.groupLimits);
|
|
301
|
+
result.limitations.push(...state.groupRetrievalLimits.map(limit => ({ fileId: file.id, text: '组主审检索受限:' + limit.code + ';已接受的本成员成果保留,不表示完整审查' })));
|
|
302
|
+
}
|
|
292
303
|
if (groupedGeneration.groupRisk) (state.riskPlans ??= []).push({ ...groupedGeneration.groupRisk, method: 'inline', scope: 'group-all-edits', groupId: grouped[0], decision: { required: true, reason: 'group-inline-primary' } });
|
|
293
304
|
}
|
|
294
305
|
const chainTimeout = grouped && timeoutMs > 0 ? Math.max(0, timeoutMs - (performance.now() - groupStarted.get(grouped[0]))) : timeoutMs;
|
|
@@ -306,9 +317,14 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
306
317
|
enclosingRequest.signal.throwIfAborted();
|
|
307
318
|
const phase=batch.interaction?'file-interaction':batch.synthesis?'window-synthesis':'primary';
|
|
308
319
|
try{
|
|
309
|
-
|
|
320
|
+
activeStage = phase;
|
|
321
|
+
activeReader = groupedGeneration && !batch.synthesis && !batch.interaction ? scope : stageScope(batch.interaction ? 'interaction:' + batch.interaction.id : batch.synthesis ? 'synthesis:' + file.id : 'primary:' + file.id);
|
|
322
|
+
// Consuming an already validated group member performs no new source/model work.
|
|
323
|
+
const cachedPrimary = groupedGeneration && !batch.synthesis && !batch.interaction;
|
|
324
|
+
const admission = !cachedPrimary && activeReader && options.optimizeReviewCalls === true
|
|
325
|
+
? readerAdmission({ synthesis: batch.synthesis === true, interaction: !!batch.interaction, metadata: prepared.metadata }, activeReader.budget()) : { permitted: true };
|
|
310
326
|
if (!admission.permitted) {
|
|
311
|
-
state.readerAdmission = { code: admission.code, remaining:
|
|
327
|
+
state.readerAdmission = { code: admission.code, remaining: activeReader.budget() };
|
|
312
328
|
if (batch.synthesis || batch.interaction) throw Object.assign(new Error('Required evidence budget unavailable before stage admission'), { code: admission.code });
|
|
313
329
|
state.reason = '检索预算不足以取得本阶段所需证据,未启动模型;需重新预览补审';
|
|
314
330
|
state.failureCode = admission.code;
|
|
@@ -372,7 +388,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
372
388
|
const generate = async attempt => {
|
|
373
389
|
if (!initialInputBudget(attempt, scope, maxInputBytes).fits) throw new Error('Review correction input budget exceeded');
|
|
374
390
|
const initialRetrieved = scope && options.enablePrimarySeeds === true && maxRetrievalRounds > 0 && !batch.synthesis && !batch.interaction
|
|
375
|
-
? primarySeeds(
|
|
391
|
+
? primarySeeds(activeReader, attempt, { maxInputBytes, signal: attempt.signal }) : [];
|
|
376
392
|
return executor({ ...attempt, initialRetrieved }, () => { finalResponseOnly = true; });
|
|
377
393
|
};
|
|
378
394
|
const validate = response => {
|
|
@@ -430,10 +446,12 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
430
446
|
result.findings.push(...generated.findings);
|
|
431
447
|
if (generated.findings.some(f => f.attribution.status === 'missing')) result.limitations.push({ fileId: file.id, text: '部分发现缺少变更归因,仅校验了定位,尚不能确认属于本次回归' });
|
|
432
448
|
result.limitations.push(...generated.limitations.map(text => ({ fileId: file.id, text })));
|
|
449
|
+
if (!groupedGeneration && !scheduled.slice(batchIndex + 1).some(next => !next.synthesis && !next.interaction)) releaseStage('primary:' + file.id);
|
|
433
450
|
if (options.enableVerification === true && generated.findings.length) {
|
|
434
451
|
for (const finding of generated.findings) finding.verification = { status: 'pending', causality: 'unverified' };
|
|
435
452
|
try {
|
|
436
|
-
|
|
453
|
+
activeStage = 'verification';
|
|
454
|
+
const verdicts = await verificationLoop(trackedModel, { ...request, onProtocolEvent, reviewPhase: 'verification' }, generated.findings, stageScope('verification:' + file.id), {
|
|
437
455
|
correctProtocol:options.correctProtocol===true,maxRounds:maxRetrievalRounds,maxInputBytes, beforeCall, onBudgetLimited,
|
|
438
456
|
onRetrieved: records => { (state.retrievalAudit ??= []).push(...records); },
|
|
439
457
|
onTruncated: () => { result.limitations.push({ fileId: file.id, text: '复核检索结果达到数量上限' }); },
|
|
@@ -463,7 +481,11 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
463
481
|
await options.onProgress?.({stage:'window-complete',snapshotId:input.id,fileId:file.id,path:file.path,windowIds:batch.windowIds,round:batch.round,findings:generated.findings,windowCoverage:state.windowCoverage});
|
|
464
482
|
}catch(error){
|
|
465
483
|
enclosingRequest.signal.throwIfAborted();
|
|
466
|
-
if(phase==='primary'||error.code==='MODEL_ARTIFACT_FAILED'
|
|
484
|
+
if(phase==='primary'||error.code==='MODEL_ARTIFACT_FAILED')throw error;
|
|
485
|
+
if (['Model call budget exceeded', 'Model call budget exceeded before next review pass'].includes(error.message) || error.code === 'PROJECT_MODEL_CALL_LIMIT') {
|
|
486
|
+
result.limitations.push({ fileId: file.id, text: phase + '模型调用额度不足,尚未启动的阶段保留 pending;已完成主审成果保留' });
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
467
489
|
const failure={phase,status:'failed',code:error.code??'REVIEW_EXECUTION_FAILED',reason:error.message,round:batch.round,...(error.attempts?{attempts:error.attempts}:{}),...(error.diagnostic?{diagnostic:error.diagnostic}:{})};
|
|
468
490
|
(state.stageFailures??=[]).push(failure);
|
|
469
491
|
if(batch.synthesis)state.synthesisStatus='failed';
|
|
@@ -478,10 +500,10 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
478
500
|
|
|
479
501
|
state.status = state.retrievalLimited ? 'pending' : 'completed';
|
|
480
502
|
if (state.retrievalLimited) {
|
|
481
|
-
const codes = new Set((state.retrievalLimits ?? []).map(limit => limit.code));
|
|
503
|
+
const codes = new Set([...(state.retrievalLimits ?? []), ...(state.groupRetrievalLimits ?? [])].map(limit => limit.code));
|
|
482
504
|
state.reason = codes.has('RETRIEVAL_ROUND_BUDGET_EXCEEDED')
|
|
483
|
-
? '
|
|
484
|
-
: '共享读取调用或输出预算受限,未完成完整审查;已有发现保留,不表示通过';
|
|
505
|
+
? '单阶段检索轮数上限达到,未完成完整审查;读取额度可能仍有余量,已有发现保留,不表示通过'
|
|
506
|
+
: (readerAllocation ? '阶段取证分配或共享读取预算受限,未完成完整审查;其他阶段保留额度不表示本阶段已完成,已有发现保留' : '共享读取调用或输出预算受限,未完成完整审查;已有发现保留,不表示通过');
|
|
485
507
|
if (codes.size === 1) state.failureCode = [...codes][0];
|
|
486
508
|
}
|
|
487
509
|
|
|
@@ -496,6 +518,12 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
496
518
|
state.failureCode = signal?.aborted ? 'CANCELLED' : ['MODEL_INVALID_JSON','MODEL_INCOMPLETE_OUTPUT','MODEL_TIMEOUT','MODEL_INVALID_READ_REQUEST','MODEL_OUTPUT_LIMIT','MODEL_PROTOCOL_ERROR','MODEL_INVALID_REVIEW_RESPONSE','MODEL_INVALID_FINDING_IDENTITY','MODEL_INVALID_FINDING_SEVERITY','MODEL_MISSING_FINDING_FIELD','MODEL_INVALID_ANCHOR','MODEL_ANCHOR_MISMATCH','READER_CALL_BUDGET_EXCEEDED','READER_OUTPUT_BUDGET_EXCEEDED','RETRIEVAL_ROUND_BUDGET_EXCEEDED','RETRIEVAL_FINAL_RESPONSE_REQUIRED','PROJECT_MODEL_CALL_LIMIT'].includes(error?.code) ? error.code : 'REVIEW_EXECUTION_FAILED';
|
|
497
519
|
}
|
|
498
520
|
} finally {
|
|
521
|
+
if (readerAllocation && file.eligibility === 'reviewable' && !options.resumeState?.completed?.some(row => row.fileId === file.id)) {
|
|
522
|
+
if (![...groupPrepared.values()].some(group => group.fileIds.includes(file.id))) releaseStage('primary:' + file.id);
|
|
523
|
+
releaseStage('verification:' + file.id);
|
|
524
|
+
releaseStage('synthesis:' + file.id);
|
|
525
|
+
for (const plan of interactionPlans.filter(plan => input.files.filter(member => plan.group.fileIds.includes(member.id)).at(-1)?.id === file.id)) releaseStage('interaction:' + plan.group.id);
|
|
526
|
+
}
|
|
499
527
|
state.resumeEligible = state.status === 'completed' && !state.stageFailures?.length && !state.reviewPasses?.some(pass => pass.status !== 'completed') && !state.windowCoverage?.pending?.length && !state.retrievalLimited && result.findings.every(f => f.verification?.status === 'completed') && report.interactions.filter(item => item.fileIds?.includes(file.id)).every(item => item.status === 'completed');
|
|
500
528
|
await options.onProgress?.({stage:'file-complete',snapshotId:input.id,file:state,findings:result.findings});
|
|
501
529
|
await options.onResumeCheckpoint?.({ ...report, files: fileResults.filter(Boolean).map(item => item.state), findings: fileResults.filter(Boolean).flatMap(item => item.findings), limitations: [...report.limitations, ...fileResults.filter(Boolean).flatMap(item => item.limitations)] });
|
|
@@ -540,6 +568,19 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
540
568
|
}
|
|
541
569
|
report.primaryGroups = [...groupPrepared].map(([groupId, p]) => ({ groupId, fileIds: p.fileIds, mode: 'group-primary' }));
|
|
542
570
|
}
|
|
571
|
+
if (scope && options.optimizeReviewCalls === true) {
|
|
572
|
+
const units = [];
|
|
573
|
+
const reusable = new Set((options.resumeState?.completed ?? []).map(row => row.fileId));
|
|
574
|
+
for (const [id, prepared] of groupPrepared) units.push({ id: 'group:' + id, weight: prepared.fileIds.length * 4 });
|
|
575
|
+
for (const file of input.files.filter(file => file.eligibility === 'reviewable' && !reusable.has(file.id))) {
|
|
576
|
+
const prepared = preparePrimaryInput(input, file, { instructions, scope, grouping: report.grouping, maxInputBytes, riskPlanningMode: options.riskPlanningMode, focusPrimaryContext: options.focusPrimaryContext === true, enableRiskPlanning: options.enableRiskPlanning, skipSmallRiskPlans: options.skipSmallRiskPlans });
|
|
577
|
+
if (![...groupPrepared.values()].some(group => group.fileIds.includes(file.id))) units.push({ id: 'primary:' + file.id, weight: 4 * Math.max(1, prepared.batches?.length ?? 1) });
|
|
578
|
+
if (prepared.synthesis?.status === 'ready') units.push({ id: 'synthesis:' + file.id, weight: 2 });
|
|
579
|
+
if (options.enableVerification === true) units.push({ id: 'verification:' + file.id, weight: 2 });
|
|
580
|
+
}
|
|
581
|
+
for (const plan of interactionPlans.filter(plan => plan.prepared.status === 'ready' && !plan.group.fileIds.every(id => reusable.has(id)))) units.push({ id: 'interaction:' + plan.group.id, weight: 2 });
|
|
582
|
+
if (units.length) readerAllocation = createReviewReaderAllocation(scope, units);
|
|
583
|
+
}
|
|
543
584
|
const dependencies = new Map();
|
|
544
585
|
for (const plan of interactionPlans.filter(p => p.prepared.status === 'ready')) {
|
|
545
586
|
const members = input.files.map((file, index) => plan.group.fileIds.includes(file.id) ? index : -1).filter(index => index >= 0);
|
|
@@ -576,6 +617,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
576
617
|
await options.onResumeCheckpoint?.(report);
|
|
577
618
|
report.followup = coverageFollowup(report);
|
|
578
619
|
if (scope) report.readerBudget = { ...scope.usage(), ...scope.budget(), ...readerLimits };
|
|
620
|
+
if (readerAllocation) report.readerAllocation = readerAllocation.summary();
|
|
579
621
|
report.diagnostics = reviewDiagnostics(report, { reviewWallMs: performance.now() - startedAt });
|
|
580
622
|
return report;
|
|
581
623
|
}
|
|
@@ -640,7 +682,7 @@ export function markdownReport(report) {
|
|
|
640
682
|
}
|
|
641
683
|
for (const file of report.files) if (file.windowCoverage) output.push('- ' + safe(file.path) + ':变更窗口已完成 ' + file.windowCoverage.completed.length + ',待审 ' + file.windowCoverage.pending.length + ';仅表示窗口分析完成,不证明全文正确');
|
|
642
684
|
for (const file of report.files) if (file.synthesisStatus) output.push('- ' + safe(file.path) + ':跨窗口综合 ' + safe(file.synthesisStatus) + ';不等于事实正确性证明');
|
|
643
|
-
for (const interaction of report.interactions ?? []) output.push('- 跨文件综合 ' + safe(interaction.groupId) + ':' + safe(interaction.status) + '
|
|
685
|
+
for (const interaction of report.interactions ?? []) output.push('- 跨文件综合 ' + safe(interaction.groupId) + ':' + safe(interaction.status) + (interaction.failureCode ? ' · ' + safe(interaction.failureCode) : '') + (interaction.reason ? ' — ' + safe(interaction.reason) : '') + ';与组末主文件共享生命周期,不证明完整交互覆盖');
|
|
644
686
|
if (report.rules?.length) output.push('', '## 项目规则', '规则仅为已批准约束数据,不扩大权限或证明缺陷。', ...report.rules.map(rule => '- ' + safe(rule.path) + ' · SHA256 ' + safe(rule.hash)));
|
|
645
687
|
if (report.followup?.suggestedPaths.length) output.push('', '## 后续选择建议', '仅为原快照未完成项;不会自动续审。再次执行需新预览、新审批,并显式选择规则和上下文。阻断项需先处理原因;关联上下文不等于主文件已审查。', ...report.followup.items.filter(item => item.followup).map(item => '- ' + safe(item.path) + ':' + item.status + (item.incompleteVerification ? '(候选复核未完成)' : '')));
|
|
646
688
|
output.push('', '## 审查发现');
|
package/src/service.mjs
CHANGED
|
@@ -97,7 +97,7 @@ function createSessionReviewService(llm, { capture = captureSnapshot, now = Date
|
|
|
97
97
|
if (owner(exec).cwd !== current.cwd) throw new Error('Agent directory changed; preview again');
|
|
98
98
|
const resumeState = savedResume === undefined ? undefined : validateResumeState(savedResume, snapshot, resumeOptions(options, current.route, rounds));
|
|
99
99
|
const readerBudget = reviewReaderBudget(snapshot, readerBudgetPolicy);
|
|
100
|
-
const plan = buildCoveragePlan(snapshot, { enableGroupPrimary, resumeState, focusPrimaryContext, riskPlanningMode, enableRiskPlanning, skipSmallRiskPlans, reviewRounds:rounds, enableBusinessGrouping, instructions: REVIEW_INSTRUCTIONS, scope: configureWireBudget(createRetrievalScope(snapshot, { ...readerBudget, signal: exec.signal }), { nativeOutputTools }), grouping: buildReviewGroups(snapshot.files, inferFileRelations(snapshot.files)), maxInputBytes: 96 * 1024, signal: exec.signal });
|
|
100
|
+
const plan = buildCoveragePlan(snapshot, { enableGroupPrimary, resumeState, focusPrimaryContext, riskPlanningMode, enableRiskPlanning, skipSmallRiskPlans, reviewRounds:rounds, enableBusinessGrouping, instructions: REVIEW_INSTRUCTIONS, scope: configureWireBudget(createRetrievalScope(snapshot, { ...readerBudget, signal: exec.signal, ...(optimizeReviewCalls ? { reuseResults: true } : {}) }), { nativeOutputTools }), grouping: buildReviewGroups(snapshot.files, inferFileRelations(snapshot.files)), maxInputBytes: 96 * 1024, signal: exec.signal });
|
|
101
101
|
prune();
|
|
102
102
|
while (previews.size >= maxPreviews) previews.delete(previews.keys().next().value);
|
|
103
103
|
const previewId = randomUUID();
|
|
@@ -110,7 +110,7 @@ function createSessionReviewService(llm, { capture = captureSnapshot, now = Date
|
|
|
110
110
|
...(resumeState ? { resume: resumePreview(resumeState, sourceJobId) } : {}),
|
|
111
111
|
plan,
|
|
112
112
|
snapshotId: snapshot.id,
|
|
113
|
-
privateArtifacts: { enabled: typeof exec.onModelArtifact === 'function', sourceBearing: true, maxArtifactBytes: 1048576, maxJobBytes: 8388608, maxArtifacts:
|
|
113
|
+
privateArtifacts: { enabled: typeof exec.onModelArtifact === 'function', sourceBearing: true, maxArtifactBytes: 1048576, maxJobBytes: 8388608, maxArtifacts: 4096, notice: 'Background jobs retain bounded untrusted model output outside the reviewed workspace; omissions/truncation explicit. Never included in ordinary status or reused as evidence; explicit consent required to read.' },
|
|
114
114
|
execution: { nativeOutputTools, enableGroupPrimary, fileConcurrency: changesConcurrency, maxRetrievalRounds, ...(optimizeReviewCalls ? { riskPlanningMode, readerBudget: { policy: readerBudgetPolicy, ...readerBudget }, missingContextSeeds: enablePrimarySeeds, focusPrimaryContext, searchContextSeeds: enableSearchSeeds } : {}) },
|
|
115
115
|
repositoryRoot: snapshot.root,
|
|
116
116
|
vcs: snapshot.vcs,
|
|
@@ -141,7 +141,7 @@ function createSessionReviewService(llm, { capture = captureSnapshot, now = Date
|
|
|
141
141
|
...(snapshot.callerDiscovery ? { callerDiscovery: snapshot.callerDiscovery } : {}),
|
|
142
142
|
...(snapshot.autoContext ? { autoContext: snapshot.autoContext } : {}),
|
|
143
143
|
rules: (snapshot.rules ?? []).map(({ path, hash }) => ({ path, hash })),
|
|
144
|
-
notice: buildPreviewNotice(snapshot, options, enableRiskPlanning, changesConcurrency, maxRetrievalRounds) + (optimizeReviewCalls ? ` 完整正文或覆盖全部精确编辑的单批窗口可将风险问题合并入主审(非独立规划);仅对批准上下文的确定坐标预取小片段,不代表完整核查。${focusPrimaryContext ? " 主审优先保留主文件全文,仅附带有关联的有界完整上下文;其余批准来源保留目录按需读,不表示不存在或已核查。" : ""}${enableSearchSeeds ? " 可将模型搜索命中的批准原行小片段与搜索结果同轮提供,每阶段最多2次额外读取,共用原预算;不猜测另侧坐标,不增加检索轮次。" : ""}共享检索上限按本次范围计算:${readerBudget.maxCalls}
|
|
144
|
+
notice: buildPreviewNotice(snapshot, options, enableRiskPlanning, changesConcurrency, maxRetrievalRounds) + (optimizeReviewCalls ? ` 完整正文或覆盖全部精确编辑的单批窗口可将风险问题合并入主审(非独立规划);仅对批准上下文的确定坐标预取小片段,不代表完整核查。${focusPrimaryContext ? " 主审优先保留主文件全文,仅附带有关联的有界完整上下文;其余批准来源保留目录按需读,不表示不存在或已核查。" : ""}${enableSearchSeeds ? " 可将模型搜索命中的批准原行小片段与搜索结果同轮提供,每阶段最多2次额外读取,共用原预算;不猜测另侧坐标,不增加检索轮次。" : ""}共享检索上限按本次范围计算:${readerBudget.maxCalls}次唯一操作结果生成/${readerBudget.maxOutputBytes}交付字节,所有阶段共用、不逐文件重置;同快照相同请求可复用原始结果,每次交付仍计字节并重新签发复核回执;主审、综合及复核按权重预留最低额度,未用额度释放,额度不足仍如实标记未完成。` : ''),
|
|
145
145
|
};
|
|
146
146
|
} finally { busy = false; }
|
|
147
147
|
},
|
package/src/snapshot-reader.mjs
CHANGED
|
@@ -21,15 +21,17 @@ function budgetError(kind) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
// This capability reads only caller-approved text entries, never the filesystem.
|
|
24
|
-
export function createSnapshotReader(snapshotId, entries, { maxBytes = 4 * 1024 * 1024, maxCalls = 50, maxOutputBytes = 256 * 1024, signal } = {}) {
|
|
24
|
+
export function createSnapshotReader(snapshotId, entries, { maxBytes = 4 * 1024 * 1024, maxCalls = 50, maxOutputBytes = 256 * 1024, signal, reuseResults = false } = {}) {
|
|
25
25
|
if (typeof snapshotId !== 'string' || !snapshotId || snapshotId.length > 200) throw new Error('Invalid snapshot identity');
|
|
26
26
|
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 16 * 1024 * 1024) throw new Error('Invalid reader budget');
|
|
27
27
|
if (!Array.isArray(entries) || entries.length > 500) throw new Error('Invalid reader entries');
|
|
28
28
|
for (const [value, cap] of [[maxCalls, 1000], [maxOutputBytes, 4 * 1024 * 1024]]) {
|
|
29
29
|
if (!Number.isSafeInteger(value) || value < 1 || value > cap) throw new Error('Invalid reader budget');
|
|
30
30
|
}
|
|
31
|
+
if (typeof reuseResults !== 'boolean') throw new Error('Invalid reader reuse option');
|
|
31
32
|
signal?.throwIfAborted();
|
|
32
33
|
|
|
34
|
+
let cache = new Map(), cacheBytes = 0, cacheHits = 0, deliveries = 0;
|
|
33
35
|
let calls = 0, outputBytes = 0, inTransaction = false;
|
|
34
36
|
const begin = () => {
|
|
35
37
|
signal?.throwIfAborted();
|
|
@@ -103,16 +105,66 @@ export function createSnapshotReader(snapshotId, entries, { maxBytes = 4 * 1024
|
|
|
103
105
|
return { snapshotId, sourceId: id, hash: source.hash, start, count: selectedCount, text, endOfSource: endOffset === source.text.length };
|
|
104
106
|
};
|
|
105
107
|
|
|
108
|
+
const validate = (kind, request) => {
|
|
109
|
+
if (!request || typeof request !== 'object') throw new Error('Invalid reader request');
|
|
110
|
+
if (kind === 'read') {
|
|
111
|
+
if (!sources.has(request.id)) throw new Error('Source not authorized');
|
|
112
|
+
if (!Number.isSafeInteger(request.start) || request.start < 1 || !Number.isSafeInteger(request.count) || request.count < 1 || request.count > 200) throw new Error('Invalid line range');
|
|
113
|
+
} else {
|
|
114
|
+
if (!validQuery(request.query, request.limit === undefined ? 20 : request.limit)) throw new Error('Invalid query request');
|
|
115
|
+
if (kind === 'search' && request.sourceIds !== undefined && (!Array.isArray(request.sourceIds) || !request.sourceIds.length || request.sourceIds.length > 20 || new Set(request.sourceIds).size !== request.sourceIds.length || request.sourceIds.some(id => !sources.has(id)))) throw new Error('Invalid search source filter');
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
const keyOf = (kind, request) => JSON.stringify(kind === 'read' ? [kind, request.id, request.start, request.count] : kind === 'find' ? [kind, request.query, request.limit ?? 20] : [kind, request.query, request.limit ?? 20, request.sourceIds === undefined ? null : [...request.sourceIds].sort()]);
|
|
119
|
+
const materialize = (kind, request) => kind === 'read' ? read(request) : kind === 'find' ? find(request) : search(request);
|
|
120
|
+
const deliver = (kind, request, decorate = value => value) => {
|
|
121
|
+
signal?.throwIfAborted();
|
|
122
|
+
// Validation is never bypassed by canonicalization (e.g. duplicate filters).
|
|
123
|
+
if (reuseResults) validate(kind, request);
|
|
124
|
+
const key = reuseResults ? keyOf(kind, request) : null;
|
|
125
|
+
const cached = reuseResults && cache.get(key);
|
|
126
|
+
if (!cached) begin();
|
|
127
|
+
const raw = cached ? structuredClone(cached.result) : materialize(kind, request);
|
|
128
|
+
const result = finish(decorate(structuredClone(raw)));
|
|
129
|
+
if (reuseResults) {
|
|
130
|
+
deliveries++;
|
|
131
|
+
if (cached) cacheHits++;
|
|
132
|
+
else {
|
|
133
|
+
const size = resultSize(raw);
|
|
134
|
+
if (cache.size < maxCalls && cacheBytes + size <= maxOutputBytes) { cache.set(key, { result: structuredClone(raw), size }); cacheBytes += size; }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return result;
|
|
138
|
+
};
|
|
139
|
+
const preflight = requests => {
|
|
140
|
+
signal?.throwIfAborted();
|
|
141
|
+
if (!Array.isArray(requests) || !requests.length || requests.length > 8) throw new Error('Invalid retrieval batch');
|
|
142
|
+
const staged = new Map(cache); let stagedBytes = cacheBytes, needed = 0, size = 0, hits = 0;
|
|
143
|
+
for (const request of requests) {
|
|
144
|
+
if (!['read', 'find', 'search'].includes(request?.kind)) throw new Error('Unsupported retrieval operation');
|
|
145
|
+
// Dry-run validates against immutable approved sources; it grants no receipts.
|
|
146
|
+
validate(request.kind, request);
|
|
147
|
+
const key = reuseResults ? keyOf(request.kind, request) : null;
|
|
148
|
+
const raw = reuseResults && staged.has(key) ? staged.get(key).result : materialize(request.kind, request), bytes = resultSize(raw);
|
|
149
|
+
if (reuseResults && staged.has(key)) hits++;
|
|
150
|
+
else { needed++; if (reuseResults && staged.size < maxCalls && stagedBytes + bytes <= maxOutputBytes) { staged.set(key, { result: raw, size: bytes }); stagedBytes += bytes; } }
|
|
151
|
+
size += bytes;
|
|
152
|
+
}
|
|
153
|
+
if (calls + needed > maxCalls) throw budgetError('call');
|
|
154
|
+
if (outputBytes + size > maxOutputBytes) throw budgetError('output');
|
|
155
|
+
return { calls: needed, outputBytes: size, cacheHits: hits };
|
|
156
|
+
};
|
|
106
157
|
return Object.freeze({
|
|
107
|
-
usage: () => ({ calls, outputBytes }),
|
|
108
|
-
budget: () => ({ maxCalls, maxOutputBytes, remainingCalls: maxCalls - calls, remainingOutputBytes: maxOutputBytes - outputBytes, maxResultBytes: 64 * 1024 }),
|
|
158
|
+
usage: () => ({ calls, outputBytes, ...(reuseResults ? { cacheHits, deliveries, cacheEntries: cache.size, cacheBytes } : {}) }),
|
|
159
|
+
budget: () => ({ maxCalls, maxOutputBytes, remainingCalls: maxCalls - calls, remainingOutputBytes: maxOutputBytes - outputBytes, maxResultBytes: 64 * 1024, ...(reuseResults ? { reusableResults: cache.size > 0 } : {}) }),
|
|
160
|
+
canServe(requests) { try { preflight(requests); return true; } catch { return false; } },
|
|
109
161
|
// A synchronous host-only batch commits its ledger only as a whole. This
|
|
110
162
|
// also covers decorated response bytes that preflight cannot predict.
|
|
111
163
|
// Callers must not publish results/audit records before this returns.
|
|
112
164
|
transaction(execute) {
|
|
113
165
|
signal?.throwIfAborted();
|
|
114
166
|
if (inTransaction || typeof execute !== 'function') throw new Error('Invalid reader transaction');
|
|
115
|
-
const previousCalls = calls, previousBytes = outputBytes;
|
|
167
|
+
const previousCalls = calls, previousBytes = outputBytes, previousCache = new Map(cache), previousCacheBytes = cacheBytes, previousHits = cacheHits, previousDeliveries = deliveries;
|
|
116
168
|
inTransaction = true;
|
|
117
169
|
try {
|
|
118
170
|
const result = execute();
|
|
@@ -120,29 +172,17 @@ export function createSnapshotReader(snapshotId, entries, { maxBytes = 4 * 1024
|
|
|
120
172
|
signal?.throwIfAborted();
|
|
121
173
|
return result;
|
|
122
174
|
} catch (error) {
|
|
123
|
-
calls = previousCalls; outputBytes = previousBytes;
|
|
175
|
+
calls = previousCalls; outputBytes = previousBytes; cache = previousCache; cacheBytes = previousCacheBytes; cacheHits = previousHits; deliveries = previousDeliveries;
|
|
124
176
|
throw error;
|
|
125
177
|
} finally { inTransaction = false; }
|
|
126
178
|
},
|
|
127
179
|
// Host-only dry run. Validate ALL ranges and sources before charging anything.
|
|
128
180
|
// No evidence or receipts escape this preflight. Decorated reads are still
|
|
129
181
|
// measured by finish; this never reserves or increases the shared budget.
|
|
130
|
-
preflight
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (request?.kind === 'find') return find(request);
|
|
136
|
-
if (request?.kind === 'search') return search(request);
|
|
137
|
-
throw new Error('Unsupported retrieval operation');
|
|
138
|
-
});
|
|
139
|
-
if (calls + requests.length > maxCalls) throw budgetError('call');
|
|
140
|
-
const size = results.reduce((sum, result) => sum + resultSize(result), 0);
|
|
141
|
-
if (outputBytes + size > maxOutputBytes) throw budgetError('output');
|
|
142
|
-
},
|
|
143
|
-
find(request) { begin(); return finish(find(request)); },
|
|
144
|
-
search(request) { begin(); return finish(search(request)); },
|
|
145
|
-
// decorate is a host-only callback; model requests never supply it.
|
|
146
|
-
read(request, decorate = value => value) { begin(); return finish(decorate(read(request))); },
|
|
182
|
+
preflight,
|
|
183
|
+
find(request) { return deliver('find', request); },
|
|
184
|
+
search(request) { return deliver('search', request); },
|
|
185
|
+
// Only immutable raw text is cached; each delivery creates fresh decorations.
|
|
186
|
+
read(request, decorate) { return deliver('read', request, decorate); },
|
|
147
187
|
});
|
|
148
188
|
}
|
|
@@ -35,8 +35,10 @@ export function verificationSeeds(candidates, scope, { signal, fits, onReceipt =
|
|
|
35
35
|
const budget = scope.budget?.();
|
|
36
36
|
// At least one exact final validation read per candidate must remain. This
|
|
37
37
|
// reserves no new authority; additional evidence can still exhaust budgets.
|
|
38
|
-
if (budget &&
|
|
38
|
+
if (budget && budget.remainingOutputBytes <= finalEvidenceBytes) break;
|
|
39
39
|
try {
|
|
40
|
+
const cached = budget?.reusableResults === true && scope.preflight?.([request])?.calls === 0 && scope.canServe?.([request]) === true;
|
|
41
|
+
if (budget && !cached && budget.remainingCalls <= candidates.length) continue;
|
|
40
42
|
// Failed transactions discard their receipt table too, not just the ledger.
|
|
41
43
|
const receipts = createEvidenceReceipts(scope.read, { limit: 1 });
|
|
42
44
|
const item = scope.transaction(() => {
|
|
@@ -48,7 +50,22 @@ export function verificationSeeds(candidates, scope, { signal, fits, onReceipt =
|
|
|
48
50
|
const proposed = [...seeds, { request, result }];
|
|
49
51
|
const nextBudget = scope.budget?.();
|
|
50
52
|
const reserve = finalEvidenceBytes + Buffer.byteLength(JSON.stringify(result)) * references;
|
|
53
|
+
if (cached) {
|
|
54
|
+
// Validation reads the actual returned count, not the seed's requested
|
|
55
|
+
// EOF-clamped count. These are different exact cache keys. Reserve all
|
|
56
|
+
// final reads explicitly before admitting a zero-call cached seed.
|
|
57
|
+
let calls = 0;
|
|
58
|
+
for (const item of proposed) {
|
|
59
|
+
const validation = { kind: 'read', id: item.result.sourceId, start: item.result.start, count: item.result.count };
|
|
60
|
+
const cost = scope.preflight([validation]);
|
|
61
|
+
if (!Number.isSafeInteger(cost?.calls) || cost.calls < 0) throw capacityError();
|
|
62
|
+
const matching = [...ranges.values()].find(range => JSON.stringify(range.request) === JSON.stringify(item.request));
|
|
63
|
+
calls += cost.calls * (matching?.references ?? 1);
|
|
64
|
+
}
|
|
65
|
+
if (nextBudget && calls > nextBudget.remainingCalls) throw capacityError();
|
|
66
|
+
}
|
|
51
67
|
if (Buffer.byteLength(JSON.stringify(proposed)) > MAX_SEED_BYTES || !fits(proposed) || (nextBudget && nextBudget.remainingOutputBytes < reserve)) throw capacityError();
|
|
68
|
+
signal?.throwIfAborted();
|
|
52
69
|
return { request, result };
|
|
53
70
|
});
|
|
54
71
|
// Publish only after the approved reader has committed the transaction.
|