@feng0531/dsh-code-review 0.86.0 → 0.87.0
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 +16 -0
- package/CORE-ALIGNMENT-TASKS.md +19 -4
- package/CORE-SOURCE-INVENTORY.md +6 -6
- package/DEVELOPMENT.md +15 -1
- package/README.md +22 -5
- package/RELEASE-NOTES.md +14 -14
- package/index.mjs +1 -1
- package/package.json +1 -1
- package/src/background-review.mjs +36 -0
- package/src/candidate-verification.mjs +1 -1
- package/src/coverage-plan.mjs +20 -6
- package/src/cpp-migration-bindings.mjs +149 -0
- package/src/evidence-context.mjs +96 -0
- package/src/group-interaction.mjs +48 -0
- package/src/group-primary.mjs +14 -3
- package/src/interaction-risk.mjs +71 -0
- package/src/migration-contracts.mjs +160 -0
- package/src/migration-regression.mjs +103 -0
- package/src/model-contract.mjs +3 -3
- package/src/model-tools.mjs +6 -4
- package/src/primary-seeds.mjs +6 -2
- package/src/project-review-service.mjs +2 -2
- package/src/prompts.mjs +1 -0
- package/src/regression-assessment.mjs +8 -2
- package/src/report-summary.mjs +59 -0
- package/src/retrieval-loop.mjs +17 -4
- 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 +117 -0
- package/src/review-reader-policy.mjs +1 -1
- package/src/review-resume.mjs +67 -2
- package/src/review-stage-state.mjs +143 -0
- package/src/review.mjs +221 -52
- package/src/service.mjs +8 -8
- package/src/snapshot-reader.mjs +62 -22
- package/src/stage-continuation.mjs +74 -0
- package/src/verification-loop.mjs +9 -4
- package/src/verification-seeds.mjs +18 -1
package/src/service.mjs
CHANGED
|
@@ -55,16 +55,16 @@ function buildPreviewNotice(snapshot, options, enableRiskPlanning, changesConcur
|
|
|
55
55
|
return scopeNotice + (snapshot.history?.vcs==='svn'&&options.autoContext===true?'SVN历史autoContext为精确依赖定位读取固定版本父目录有界清单,包含同目录非匹配名字元数据;仅匹配捕获正文发送模型,不扫描全仓,不用工作树替代历史版本。':'') + (options.oldContextPaths !== undefined ? 'oldContextPaths仅为baseline旧侧正文,无目标侧替代;选中变更复用原old身份,旧侧不能证明新侧行为。' : '') + (snapshot.callerDiscovery ? 'callerScopePaths已授权预览读取限定目录内tracked候选正文,包括不匹配文件;非匹配正文不发送模型,匹配捕获上下文会发送。仅字面引用导航,不是函数调用或完整调用覆盖。' : '') + (enableRiskPlanning ? '执行按plan阈值决定风险假设规划,小变更可跳过独立规划但仍主审并复核;计划不是证据,所有阶段共享原预算。' : '') + `执行会向上述模型提供方发送可审查文件两侧内容、预览列出的显式/自动上下文、所选项目规则全文及预览业务需求文本。模型可在该快照范围内多轮只读检索,不读取范围外文件;候选生成后会进行证据与反证复核,两阶段各最多${maxRetrievalRounds}轮检索;独立文件审查链最多${changesConcurrency}条并发,不设插件内部默认时间或总模型调用次数截止,遵循宿主/用户取消;输入字节和检索轮次保护仍保留;复核不能证明缺陷成立。plan.reviewRounds为独立审查工作量,非模型reasoning effort;后续轮不带首轮风险计划,所有轮共享原超时与预算。plan仅预检初始输入,ready不代表已审查;minimumCalls不含后续检索/复核/动态业务综合;多文件可先进行业务分组,分组失败保留原组并记录限制,动态综合数执行前未知,实际预算可能不足。请先向用户展示范围,获得确认后执行。`;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
function createSessionReviewService(llm, { capture = captureSnapshot, now = Date.now, ttlMs = 300000, maxPreviews = 8, allowModelSending = false, enableRiskPlanning = false, skipSmallRiskPlans = true, reviewRounds = 1, enableBusinessGrouping = false, enableAnchorCorrection = false, changesConcurrency = 2, maxRetrievalRounds = 3, riskPlanningMode = 'separate', readerBudgetPolicy = 'fixed', optimizeReviewCalls = false, enablePrimarySeeds = false, focusPrimaryContext = false, enableSearchSeeds = false, nativeOutputTools = false, enableGroupPrimary = false, authorize = async () => false } = {}) {
|
|
58
|
+
function createSessionReviewService(llm, { capture = captureSnapshot, now = Date.now, ttlMs = 300000, maxPreviews = 8, allowModelSending = false, enableRiskPlanning = false, skipSmallRiskPlans = true, reviewRounds = 1, enableBusinessGrouping = false, enableAnchorCorrection = false, changesConcurrency = 2, maxRetrievalRounds = 3, riskPlanningMode = 'separate', readerBudgetPolicy = 'fixed', optimizeReviewCalls = false, enablePrimarySeeds = false, focusPrimaryContext = false, enableSearchSeeds = false, nativeOutputTools = false, enableGroupPrimary = false, enableEvidenceContext = false, inlineInteractionRisks = false, enableMigrationContracts = false, enableMigrationRegression = false, enableGroupInteractions = false, enableStageContinuation = false, authorize = async () => false } = {}) {
|
|
59
59
|
for (const value of [ttlMs, maxPreviews]) if (!Number.isSafeInteger(value) || value < 1) throw new Error('Invalid preview limit');
|
|
60
60
|
changesConcurrency = reviewConcurrency(changesConcurrency);
|
|
61
61
|
maxRetrievalRounds = reviewRetrievalRounds(maxRetrievalRounds);
|
|
62
62
|
riskPlanningMode = validateRiskPlanningMode(riskPlanningMode);
|
|
63
63
|
reviewReaderBudget({ files: [] }, readerBudgetPolicy);
|
|
64
|
-
for (const value of [optimizeReviewCalls, enablePrimarySeeds, focusPrimaryContext, enableSearchSeeds, nativeOutputTools, enableGroupPrimary]) if (typeof value !== 'boolean') throw new Error('Invalid review optimization option');
|
|
64
|
+
for (const value of [optimizeReviewCalls, enablePrimarySeeds, focusPrimaryContext, enableSearchSeeds, nativeOutputTools, enableGroupPrimary, enableEvidenceContext, inlineInteractionRisks, enableMigrationContracts, enableMigrationRegression, enableGroupInteractions, enableStageContinuation]) if (typeof value !== 'boolean') throw new Error('Invalid review optimization option');
|
|
65
65
|
const lifetime = new AbortController();
|
|
66
66
|
const bind = exec => ({ ...exec, signal: exec.signal ? AbortSignal.any([exec.signal, lifetime.signal]) : lifetime.signal });
|
|
67
|
-
const resumeOptions = (options, route, rounds) => ({ captureOptions: options, route, runtime: REVIEW_RUNTIME, instructionsHash: hash(REVIEW_INSTRUCTIONS), execution: { rounds, enableRiskPlanning, skipSmallRiskPlans, enableBusinessGrouping, enableAnchorCorrection, changesConcurrency, maxRetrievalRounds, riskPlanningMode, readerBudgetPolicy, optimizeReviewCalls, enablePrimarySeeds, focusPrimaryContext, enableSearchSeeds, nativeOutputTools, enableGroupPrimary } });
|
|
67
|
+
const resumeOptions = (options, route, rounds) => ({ captureOptions: options, route, runtime: REVIEW_RUNTIME, instructionsHash: hash(REVIEW_INSTRUCTIONS), execution: { rounds, enableRiskPlanning, skipSmallRiskPlans, enableBusinessGrouping, enableAnchorCorrection, changesConcurrency, maxRetrievalRounds, riskPlanningMode, readerBudgetPolicy, optimizeReviewCalls, enablePrimarySeeds, focusPrimaryContext, enableSearchSeeds, nativeOutputTools, enableGroupPrimary, enableEvidenceContext, inlineInteractionRisks, enableMigrationContracts, enableMigrationRegression, enableGroupInteractions, enableStageContinuation } });
|
|
68
68
|
const previews = new Map();
|
|
69
69
|
let busy = false;
|
|
70
70
|
function prune() { for (const [id, p] of previews) if (p.expires <= now()) previews.delete(id); }
|
|
@@ -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, { enableEvidenceContext, inlineInteractionRisks, enableMigrationContracts, enableMigrationRegression, enableGroupInteractions, enableStageContinuation, 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,8 +110,8 @@ 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:
|
|
114
|
-
execution: { nativeOutputTools, enableGroupPrimary, fileConcurrency: changesConcurrency, maxRetrievalRounds, ...(optimizeReviewCalls ? { riskPlanningMode, readerBudget: { policy: readerBudgetPolicy, ...readerBudget }, missingContextSeeds: enablePrimarySeeds, focusPrimaryContext, searchContextSeeds: enableSearchSeeds } : {}) },
|
|
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
|
+
execution: { nativeOutputTools, enableGroupPrimary, enableEvidenceContext, inlineInteractionRisks, enableMigrationContracts, enableMigrationRegression, enableGroupInteractions, enableStageContinuation, fileConcurrency: changesConcurrency, maxRetrievalRounds, ...(optimizeReviewCalls ? { riskPlanningMode, readerBudget: { policy: readerBudgetPolicy, ...readerBudget }, missingContextSeeds: enablePrimarySeeds, focusPrimaryContext, searchContextSeeds: enableSearchSeeds } : {}) },
|
|
115
115
|
repositoryRoot: snapshot.root,
|
|
116
116
|
vcs: snapshot.vcs,
|
|
117
117
|
...(snapshot.changeScope ? { changeScope: snapshot.changeScope } : options.changeScope !== undefined ? { changeScope: options.changeScope } : {}),
|
|
@@ -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) + (enableMigrationContracts ? ' 迁移契约仅由批准快照的明确旧/新入口与调用绑定产生,无法解析的语言结构保持未解决,不以相似名称推断迁移。' : '') + (enableMigrationRegression ? ' 显式跨文件旧新比较仅在host契约限定来源和本次独立回执齐备时成立,因果仍未证明。' : '') + (enableGroupInteractions ? ' 组主审可附带交互结果,只有当前代全组编辑实读且host校验通过时替代匹配专门阶段;缺失/非法回退,候选仍独立复核。' : '') + (enableStageContinuation ? ' 未完成阶段可在其他阶段释放额度后有限继续,不重置总账或原超时;已验收主审生成可精确续用,但复核重新读取、新回执,不继承旧证据。' : '') + (enableEvidenceContext ? ' 可将本次实际交付过的批准原行按相关性经当前阶段reader有限重新投递;缓存仍计交付字节,不继承历史读取覆盖或复核回执。' : '') + (inlineInteractionRisks ? ' 预算适配的精确跨文件单元可在交互主审同次返回风险假设,省去独立规划;缺失或无效风险不补发、不代表已完成风险核查。' : '') + (optimizeReviewCalls ? ` 完整正文或覆盖全部精确编辑的单批窗口可将风险问题合并入主审(非独立规划);仅对批准上下文的确定坐标预取小片段,不代表完整核查。${focusPrimaryContext ? " 主审优先保留主文件全文,仅附带有关联的有界完整上下文;其余批准来源保留目录按需读,不表示不存在或已核查。" : ""}${enableSearchSeeds ? " 可将模型搜索命中的批准原行小片段与搜索结果同轮提供,每阶段最多2次额外读取,共用原预算;不猜测另侧坐标,不增加检索轮次。" : ""}共享检索上限按本次范围计算:${readerBudget.maxCalls}次唯一操作结果生成/${readerBudget.maxOutputBytes}交付字节,所有阶段共用、不逐文件重置;同快照相同请求可复用原始结果,每次交付仍计字节并重新签发复核回执;主审、综合及复核按权重预留最低额度,未用额度释放,额度不足仍如实标记未完成。` : ''),
|
|
145
145
|
};
|
|
146
146
|
} finally { busy = false; }
|
|
147
147
|
},
|
|
@@ -168,7 +168,7 @@ function createSessionReviewService(llm, { capture = captureSnapshot, now = Date
|
|
|
168
168
|
exec.signal?.throwIfAborted();
|
|
169
169
|
if (owner(exec).cwd !== current.cwd || JSON.stringify(owner(exec).route) !== JSON.stringify(preview.route)) throw new Error('Agent changed during approval');
|
|
170
170
|
await exec.reviewCheckpoint?.({stage:'approved-manifest',snapshotId:snapshot.id,files:snapshot.files.map(f=>({fileId:f.id,path:f.path,status:f.eligibility==='reviewable'?'pending':f.eligibility}))});
|
|
171
|
-
const report = await reviewSnapshot(snapshot, createDshModel(llm, preview.route,{correctJson:true,useNativeTools:nativeOutputTools}), { signal: exec.signal, enableRetrieval: true, enableGrouping: true, enableGroupPrimary, nativeOutputTools, enableVerification: true, riskPlanningMode, readerBudgetPolicy, optimizeReviewCalls, enablePrimarySeeds, focusPrimaryContext, enableSearchSeeds, enableRiskPlanning, skipSmallRiskPlans, reviewRounds:preview.rounds, enableBusinessGrouping, enableAnchorCorrection,changesConcurrency,maxRetrievalRounds,correctProtocol:true, onProgress:exec.reviewCheckpoint, resumeState, onModelArtifact: exec.onModelArtifact, onResumeCheckpoint: exec.reviewResumeCheckpoint ? async checkpoint => { await exec.reviewResumeCheckpoint(buildResumeState(snapshot, resumeIdentity, checkpoint)); } : undefined });
|
|
171
|
+
const report = await reviewSnapshot(snapshot, createDshModel(llm, preview.route,{correctJson:true,useNativeTools:nativeOutputTools}), { signal: exec.signal, enableRetrieval: true, enableGrouping: true, enableGroupPrimary, enableEvidenceContext, inlineInteractionRisks, enableMigrationContracts, enableMigrationRegression, enableGroupInteractions, enableStageContinuation, nativeOutputTools, enableVerification: true, riskPlanningMode, readerBudgetPolicy, optimizeReviewCalls, enablePrimarySeeds, focusPrimaryContext, enableSearchSeeds, enableRiskPlanning, skipSmallRiskPlans, reviewRounds:preview.rounds, enableBusinessGrouping, enableAnchorCorrection,changesConcurrency,maxRetrievalRounds,correctProtocol:true, onProgress:exec.reviewCheckpoint, resumeState, onModelArtifact: exec.onModelArtifact, onResumeCheckpoint: exec.reviewResumeCheckpoint ? async checkpoint => { await exec.reviewResumeCheckpoint(buildResumeState(snapshot, resumeIdentity, checkpoint)); } : undefined });
|
|
172
172
|
report.model = preview.route;
|
|
173
173
|
if (exec.reviewResumeCheckpoint) await exec.reviewResumeCheckpoint(buildResumeState(snapshot, resumeIdentity, report));
|
|
174
174
|
// reviewSnapshot preserves cancelled coverage; do not replace it with a freshness error.
|
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
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
const KINDS = new Set(['verification', 'synthesis', 'interaction']);
|
|
3
|
+
const REASONS = new Set(['READER_CALL_BUDGET_EXCEEDED', 'READER_OUTPUT_BUDGET_EXCEEDED', 'STAGE_INTERRUPTED', 'STAGE_CANCELLED', 'STAGE_TIMEOUT', 'STAGE_NO_PROGRESS', 'STAGE_ATTEMPTS_EXHAUSTED', 'STAGE_FAILED']);
|
|
4
|
+
const fail = code => Object.assign(new Error(code), { code });
|
|
5
|
+
const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().filter(key => value[key] !== undefined).map(key => [key, canonical(value[key])])) : value;
|
|
6
|
+
const digest = value => createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex');
|
|
7
|
+
const same = (a,b) => JSON.stringify(a) === JSON.stringify(b);
|
|
8
|
+
|
|
9
|
+
// Host metadata only: accepted generation belongs in the separate owner-bound
|
|
10
|
+
// checkpoint. No read results/receipts are persisted or minted by this machine.
|
|
11
|
+
export function createStageContinuation(snapshot, options, { completedPrimary = [], units = [], checkpoint, maxAttempts = 2 } = {}) {
|
|
12
|
+
if (!snapshot || typeof snapshot.id !== 'string' || !Array.isArray(snapshot.files) || !Number.isSafeInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 4 || !Array.isArray(units) || units.length > 2000) throw fail('INVALID_STAGE_CONTINUATION');
|
|
13
|
+
const fileIds = new Set(snapshot.files.filter(f => f.eligibility === 'reviewable').map(f => f.id));
|
|
14
|
+
if (!Array.isArray(completedPrimary) || new Set(completedPrimary).size !== completedPrimary.length || completedPrimary.some(id => !fileIds.has(id))) throw fail('INVALID_STAGE_PRIMARY');
|
|
15
|
+
const primary = [...completedPrimary].sort(), states = new Map();
|
|
16
|
+
for (const unit of units) {
|
|
17
|
+
if (!unit || typeof unit.id !== 'string' || !unit.id || unit.id.length > 500 || states.has(unit.id) || unit.id.startsWith('primary:') || !KINDS.has(unit.kind) || !Array.isArray(unit.fileIds) || !unit.fileIds.length || new Set(unit.fileIds).size !== unit.fileIds.length || unit.fileIds.some(id => !fileIds.has(id)) || !Array.isArray(unit.dependencies ?? []) || new Set(unit.dependencies ?? []).size !== (unit.dependencies ?? []).length) throw fail('INVALID_STAGE_UNIT');
|
|
18
|
+
states.set(unit.id, { id: unit.id, kind: unit.kind, fileIds: [...unit.fileIds], dependencies: [...(unit.dependencies ?? [])], status: 'pending', attempts: 0, pendingReason: null, stopped: false });
|
|
19
|
+
}
|
|
20
|
+
const visit = (id, visiting = new Set(), visited = new Set()) => {
|
|
21
|
+
if (visited.has(id)) return;
|
|
22
|
+
if (visiting.has(id)) throw fail('INVALID_STAGE_DEPENDENCIES');
|
|
23
|
+
visiting.add(id);
|
|
24
|
+
for (const dep of states.get(id).dependencies) {
|
|
25
|
+
if (typeof dep !== 'string' || dep === id) throw fail('INVALID_STAGE_DEPENDENCIES');
|
|
26
|
+
if (dep.startsWith('primary:')) { if (!fileIds.has(dep.slice(8))) throw fail('INVALID_STAGE_DEPENDENCIES'); }
|
|
27
|
+
else { if (!states.has(dep)) throw fail('INVALID_STAGE_DEPENDENCIES'); visit(dep, visiting, visited); }
|
|
28
|
+
}
|
|
29
|
+
visiting.delete(id); visited.add(id);
|
|
30
|
+
};
|
|
31
|
+
const visited = new Set(); for (const id of states.keys()) visit(id, new Set(), visited);
|
|
32
|
+
const identityHash = digest({ snapshot, options, primary, units: [...states.values()].map(({id,kind,fileIds,dependencies})=>({id,kind,fileIds,dependencies})), maxAttempts });
|
|
33
|
+
if (checkpoint !== undefined) {
|
|
34
|
+
if (!checkpoint || checkpoint.schemaVersion !== 1 || checkpoint.snapshotId !== snapshot.id || checkpoint.identityHash !== identityHash || checkpoint.maxAttempts !== maxAttempts || !same(checkpoint.completedPrimary, primary) || !Array.isArray(checkpoint.units) || checkpoint.units.length !== states.size || Object.keys(checkpoint).sort().join(',') !== 'completedPrimary,identityHash,maxAttempts,schemaVersion,snapshotId,units') throw fail('STAGE_CONTINUATION_IDENTITY_MISMATCH');
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
for (const saved of checkpoint.units) {
|
|
37
|
+
const state = states.get(saved?.id);
|
|
38
|
+
if (!state || seen.has(saved.id) || !same(saved.fileIds,state.fileIds) || !same(saved.dependencies,state.dependencies) || saved.kind !== state.kind || !['pending','running','completed'].includes(saved.status) || !Number.isSafeInteger(saved.attempts) || saved.attempts < 0 || saved.attempts > maxAttempts || typeof saved.stopped !== 'boolean' || saved.pendingReason !== null && !REASONS.has(saved.pendingReason) || Object.keys(saved).sort().join(',') !== 'attempts,dependencies,fileIds,id,kind,pendingReason,status,stopped' || (saved.status !== 'pending' && saved.attempts === 0)) throw fail('INVALID_STAGE_CHECKPOINT');
|
|
39
|
+
seen.add(saved.id); Object.assign(state, { status: saved.status === 'running' ? 'pending' : saved.status, attempts: saved.attempts, pendingReason: saved.status === 'running' ? 'STAGE_INTERRUPTED' : saved.pendingReason, stopped: saved.stopped });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const get = id => { const state=states.get(id); if (!state) throw fail('UNKNOWN_STAGE_UNIT'); return state; };
|
|
43
|
+
const dependenciesReady = state => state.fileIds.every(id => primary.includes(id)) && state.dependencies.every(dep => dep.startsWith('primary:') ? primary.includes(dep.slice(8)) : get(dep).status === 'completed');
|
|
44
|
+
for (const state of states.values()) {
|
|
45
|
+
if (state.status === 'completed' && (!dependenciesReady(state) || state.stopped || state.pendingReason !== null)) throw fail('INVALID_STAGE_CHECKPOINT');
|
|
46
|
+
}
|
|
47
|
+
const copy = state => structuredClone(state);
|
|
48
|
+
const api = {
|
|
49
|
+
ready() { return [...states.values()].filter(s => s.status === 'pending' && !s.stopped && s.attempts < maxAttempts && dependenciesReady(s)).map(copy); },
|
|
50
|
+
start(id, { signal } = {}) {
|
|
51
|
+
signal?.throwIfAborted(); const state=get(id);
|
|
52
|
+
if (state.status !== 'pending' || state.stopped || state.attempts >= maxAttempts || !dependenciesReady(state)) throw fail('STAGE_NOT_READY');
|
|
53
|
+
state.status='running'; state.attempts++; state.pendingReason=null;
|
|
54
|
+
return { id, attempt: state.attempts, kind: state.kind, fileIds: [...state.fileIds], boundary: 'stage-start', requiresFreshEvidence: true };
|
|
55
|
+
},
|
|
56
|
+
complete(id, attempt, { signal } = {}) {
|
|
57
|
+
const state=get(id);
|
|
58
|
+
if (state.status !== 'running' || state.attempts !== attempt) throw fail('STALE_STAGE_ATTEMPT');
|
|
59
|
+
if (signal?.aborted) { state.status='pending'; state.pendingReason='STAGE_CANCELLED'; state.stopped=true; signal.throwIfAborted(); }
|
|
60
|
+
state.status='completed'; state.pendingReason=null;
|
|
61
|
+
},
|
|
62
|
+
yield(id, reason = 'STAGE_NO_PROGRESS') {
|
|
63
|
+
const state=get(id); if (state.status !== 'running' || !REASONS.has(reason)) throw fail('INVALID_STAGE_TRANSITION');
|
|
64
|
+
state.status='pending'; state.pendingReason=state.attempts >= maxAttempts ? 'STAGE_ATTEMPTS_EXHAUSTED' : reason; state.stopped=state.attempts >= maxAttempts;
|
|
65
|
+
},
|
|
66
|
+
stop(id, reason = 'STAGE_NO_PROGRESS') {
|
|
67
|
+
const state=get(id); if (state.status === 'completed' || !REASONS.has(reason)) throw fail('INVALID_STAGE_TRANSITION');
|
|
68
|
+
state.status='pending'; state.pendingReason=reason; state.stopped=true;
|
|
69
|
+
},
|
|
70
|
+
checkpoint() { return { schemaVersion: 1, snapshotId: snapshot.id, identityHash, maxAttempts, completedPrimary: [...primary], units: [...states.values()].map(copy) }; },
|
|
71
|
+
summary() { const values=[...states.values()]; return { units: values.length, completed: values.filter(s=>s.status==='completed').length, pending: values.filter(s=>s.status==='pending').length, running: values.filter(s=>s.status==='running').length, stopped: values.filter(s=>s.stopped).length, attempts: values.reduce((n,s)=>n+s.attempts,0) }; },
|
|
72
|
+
};
|
|
73
|
+
return Object.freeze(api);
|
|
74
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { prepareMigrationRegression, migrationRegressionMetadata } from './migration-regression.mjs';
|
|
2
|
+
import { VERIFICATION_INSTRUCTIONS, MIGRATION_VERIFICATION_INSTRUCTIONS } from './prompts.mjs';
|
|
2
3
|
import { CONTEXT_SIDE_NOTICE } from './context-identity.mjs';
|
|
3
4
|
import { defectGuidance } from './defect-guidance.mjs';
|
|
4
5
|
import { modelWireBudget } from './model-wire-budget.mjs';
|
|
@@ -15,13 +16,17 @@ export { VERIFICATION_INSTRUCTIONS };
|
|
|
15
16
|
export async function verificationLoop(model, request, candidates, scope, options = {}) {
|
|
16
17
|
request.signal?.throwIfAborted();
|
|
17
18
|
if (!Array.isArray(candidates) || candidates.length > 50) throw new Error('Invalid candidates');
|
|
19
|
+
if (options.enableMigrationRegression !== undefined && typeof options.enableMigrationRegression !== 'boolean') throw new Error('Invalid migration regression policy');
|
|
18
20
|
if (!candidates.length) return [];
|
|
19
21
|
const ids = candidates.map((_, i) => 'c' + (i + 1));
|
|
20
22
|
const envelope = request.input ? JSON.parse(request.input) : {};
|
|
21
23
|
const rules = envelope.rules ?? [];
|
|
22
|
-
const
|
|
24
|
+
const project = options.migrationSnapshot?.projectScope || options.migrationSnapshot?.files?.some(file => file.reviewMode === 'project') || envelope.file?.reviewMode === 'project';
|
|
25
|
+
if (options.enableMigrationRegression === true && !project && options.migrationContracts?.length && options.migrationSnapshot?.id !== envelope.snapshotId) throw new Error('Migration verification snapshot mismatch');
|
|
26
|
+
const migrationContext = options.enableMigrationRegression === true && !project && options.migrationContracts?.length ? prepareMigrationRegression(options.migrationSnapshot, scope, options.migrationContracts, candidates) : undefined;
|
|
27
|
+
const input = JSON.stringify({ ...(envelope.snapshotId ? { snapshotId: envelope.snapshotId } : {}), ...(migrationContext ? { migrationContracts: migrationRegressionMetadata(migrationContext) } : {}), contextSideNotice:CONTEXT_SIDE_NOTICE, ...(envelope.businessRequirement!==undefined?{businessRequirement:envelope.businessRequirement,requirementNotice:envelope.requirementNotice}:{}), defectGuidance: [...new Set(candidates.map(f => scope.catalog().find(s => s.fileId === f.fileId)?.path).filter(Boolean))].map(path => envelope.file?.path === path && envelope.defectGuidance ? envelope.defectGuidance : envelope.files?.find(f=>f.path===path)?.defectGuidance ?? defectGuidance(path)), rules, candidates: candidates.map((finding, i) => ({ candidateId: ids[i], finding })) });
|
|
23
28
|
const seedReceipts = new Map();
|
|
24
|
-
const verificationRequest = { ...request, input, instructions:envelope.file?.reviewMode==='project'?VERIFICATION_INSTRUCTIONS+' 本次为当前项目源码审查,空old只是传输占位,不是历史版本。不要要求回归因果;regression.classification用uncertain并说明不适用,仅凭new侧原文核实当前缺陷和反证。':VERIFICATION_INSTRUCTIONS };
|
|
29
|
+
const verificationRequest = { ...request, input, instructions:envelope.file?.reviewMode==='project'?VERIFICATION_INSTRUCTIONS+' 本次为当前项目源码审查,空old只是传输占位,不是历史版本。不要要求回归因果;regression.classification用uncertain并说明不适用,仅凭new侧原文核实当前缺陷和反证。':VERIFICATION_INSTRUCTIONS + (migrationContext ? ' ' + MIGRATION_VERIFICATION_INSTRUCTIONS : '') };
|
|
25
30
|
// Explicit final-only mode has extra finalization metadata/instructions and
|
|
26
31
|
// permits no retrieval rounds. Do not spend its budget on optional prefetch.
|
|
27
32
|
const initialRetrieved = options.maxRounds === 0 ? [] : verificationSeeds(candidates, scope, {
|
|
@@ -39,5 +44,5 @@ export async function verificationLoop(model, request, candidates, scope, option
|
|
|
39
44
|
// read under this invocation's receipt table and the shared approved budget.
|
|
40
45
|
const response = await retrievalLoop(model, verificationRequest, retrievalScope, { ...options, initialRetrieved });
|
|
41
46
|
request.signal?.throwIfAborted();
|
|
42
|
-
return validateCandidateVerification(response, ids, scope.read, resolveEvidence, { catalog: scope.catalog(), fileIds: Object.fromEntries(ids.map((id, i) => [id, candidates[i].fileId])) });
|
|
47
|
+
return validateCandidateVerification(response, ids, scope.read, resolveEvidence, { enableMigrationRegression: options.enableMigrationRegression === true && !project, migrationContext, catalog: scope.catalog(), fileIds: Object.fromEntries(ids.map((id, i) => [id, candidates[i].fileId])) });
|
|
43
48
|
}
|
|
@@ -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.
|