@feng0531/dsh-code-review 0.85.13 → 0.86.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 +8 -0
- package/CORE-ALIGNMENT-TASKS.md +6 -1
- package/CORE-SOURCE-INVENTORY.md +4 -2
- package/README.md +16 -6
- package/RELEASE-NOTES.md +15 -16
- package/index.mjs +2 -2
- package/package.json +1 -1
- package/src/background-review.mjs +15 -1
- package/src/coverage-plan.mjs +16 -2
- package/src/dsh-model.mjs +80 -15
- package/src/group-primary.mjs +40 -0
- package/src/input-budget.mjs +2 -0
- package/src/model-contract.mjs +1 -0
- package/src/model-json.mjs +23 -1
- package/src/model-tools.mjs +58 -0
- package/src/model-wire-budget.mjs +32 -0
- package/src/primary-seeds.mjs +2 -1
- package/src/project-execution.mjs +3 -2
- package/src/project-review-service.mjs +47 -10
- package/src/retrieval-loop.mjs +11 -9
- package/src/review-artifacts.mjs +117 -0
- package/src/review-diagnostics.mjs +19 -8
- package/src/review-jobs.mjs +6 -2
- package/src/review-resume.mjs +53 -0
- package/src/review.mjs +133 -44
- package/src/search-seeds.mjs +2 -1
- package/src/service.mjs +24 -7
- package/src/verification-loop.mjs +2 -1
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { hash } from './content.mjs';
|
|
2
|
+
import { validateBusinessGroups } from './business-groups.mjs';
|
|
3
|
+
|
|
4
|
+
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;
|
|
5
|
+
const digest = value => hash(JSON.stringify(canonical(value)));
|
|
6
|
+
const safeFile = file => !file?.stageFailures?.length && !file?.reviewPasses?.some(pass => pass.status !== 'completed') && !file?.windowCoverage?.pending?.length && file?.status === 'completed' && file.resumeEligible === true && !file.retrievalLimited && (!file.synthesisStatus || file.synthesisStatus === 'completed') && (!file.verificationStatus || !['pending', 'failed'].includes(file.verificationStatus));
|
|
7
|
+
|
|
8
|
+
// Host-private, schema-versioned completed-chain state. A previous result grants
|
|
9
|
+
// neither source authority nor model approval. Exact whole-snapshot binding is
|
|
10
|
+
// deliberately conservative: any changed context/rule/source invalidates reuse.
|
|
11
|
+
export function buildResumeState(snapshot, options, report) {
|
|
12
|
+
if (typeof snapshot?.id !== 'string' || !Array.isArray(snapshot.files) || !report || !Array.isArray(report.files) || !Array.isArray(report.findings)) throw new Error('Invalid resume checkpoint');
|
|
13
|
+
const approved = new Map(snapshot.files.filter(file => file.eligibility === 'reviewable').map(file => [file.id, file]));
|
|
14
|
+
const completed = [];
|
|
15
|
+
for (const state of report.files) {
|
|
16
|
+
const file = approved.get(state.fileId);
|
|
17
|
+
if (!file || file.path !== state.path || !safeFile(state)) continue;
|
|
18
|
+
if (completed.some(item => item.fileId === file.id)) throw new Error('Duplicate resume file');
|
|
19
|
+
const findings = report.findings.filter(finding => finding.fileId === file.id);
|
|
20
|
+
if (findings.some(finding => finding.verification?.status !== 'completed') || (report.interactions ?? []).some(item => item.fileIds?.includes(file.id) && item.status !== 'completed')) continue;
|
|
21
|
+
completed.push({ fileId: file.id, path: file.path, state: structuredClone(state), findings: structuredClone(findings), limitations: structuredClone((report.limitations ?? []).filter(item => item.fileId === file.id)) });
|
|
22
|
+
}
|
|
23
|
+
let businessGrouping;
|
|
24
|
+
if (report.businessGrouping?.groups) {
|
|
25
|
+
validateBusinessGroups({ groups: report.businessGrouping.groups.map(group => ({ fileIds: group.fileIds, reason: group.reason })) }, snapshot.files);
|
|
26
|
+
businessGrouping = structuredClone(report.businessGrouping);
|
|
27
|
+
}
|
|
28
|
+
const interactions = structuredClone((report.interactions ?? []).filter(item => Array.isArray(item.fileIds) && item.fileIds.length && item.status === 'completed' && item.fileIds.every(id => completed.some(row => row.fileId === id))));
|
|
29
|
+
const identity = digest({ snapshot, options });
|
|
30
|
+
const result = { schemaVersion: 1, snapshotId: snapshot.id, identityHash: identity, completed, ...(businessGrouping ? { businessGrouping } : {}), interactions };
|
|
31
|
+
if (Buffer.byteLength(JSON.stringify(result)) > 8 * 1024 * 1024) throw new Error('Resume checkpoint exceeds storage limit');
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function validateResumeState(value, snapshot, options) {
|
|
36
|
+
if (!value || value.schemaVersion !== 1 || value.snapshotId !== snapshot?.id || value.identityHash !== digest({ snapshot, options }) || !Array.isArray(value.completed) || value.completed.length > snapshot.files.length) throw new Error('Resume identity mismatch; capture a fresh independent review');
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
for (const item of value.completed) {
|
|
39
|
+
const source = snapshot.files.find(file => file.id === item?.fileId && file.path === item.path && file.eligibility === 'reviewable');
|
|
40
|
+
if (!source || seen.has(item.fileId) || !safeFile(item.state) || item.state.fileId !== item.fileId || item.state.path !== item.path || !Array.isArray(item.findings) || item.findings.some(finding => finding?.fileId !== item.fileId || finding.verification?.status !== 'completed') || !Array.isArray(item.limitations) || item.limitations.some(item => item?.fileId !== source.id)) throw new Error('Invalid completed resume chain');
|
|
41
|
+
seen.add(item.fileId);
|
|
42
|
+
}
|
|
43
|
+
if (value.businessGrouping !== undefined) {
|
|
44
|
+
if (!Array.isArray(value.businessGrouping?.groups)) throw new Error('Invalid resume business partition');
|
|
45
|
+
validateBusinessGroups({ groups: value.businessGrouping.groups.map(group => ({ fileIds: group.fileIds, reason: group.reason })) }, snapshot.files);
|
|
46
|
+
}
|
|
47
|
+
if (!Array.isArray(value.interactions) || value.interactions.some(item => !Array.isArray(item?.fileIds) || !item.fileIds.length || item.status !== 'completed' || item.fileIds.some(id => !seen.has(id)))) throw new Error('Invalid resume interactions');
|
|
48
|
+
return structuredClone(value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function resumePreview(state, sourceJobId) {
|
|
52
|
+
return { sourceJobId, reusableFiles: state.completed.length, fileIds: state.completed.map(item => item.fileId), notice: 'Only exact-identity completed file chains are reusable. Pending/interrupted stages run again; old findings retain prior-run provenance, not fresh model evidence. Fresh preview confirmation and model authorization are still required.' };
|
|
53
|
+
}
|
package/src/review.mjs
CHANGED
|
@@ -29,6 +29,8 @@ 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 { prepareGroupPrimary, validateGroupPrimary } from './group-primary.mjs';
|
|
33
|
+
import { configureWireBudget, modelWireBudget } from './model-wire-budget.mjs';
|
|
32
34
|
|
|
33
35
|
export { REVIEW_INSTRUCTIONS };
|
|
34
36
|
export { PROJECT_REVIEW_INSTRUCTIONS };
|
|
@@ -99,6 +101,8 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
99
101
|
const maxRetrievalRounds = retrievalRounds(options.maxRetrievalRounds);
|
|
100
102
|
let checkpointFailed = false, checkpointFailure;
|
|
101
103
|
const progress = options.onProgress;
|
|
104
|
+
const resumeCheckpoint = options.onResumeCheckpoint;
|
|
105
|
+
if (resumeCheckpoint) options = { ...options, onResumeCheckpoint: async report => { try { await resumeCheckpoint(report); } catch (error) { checkpointFailed = true; checkpointFailure = error; controller.abort(error); throw error; } } };
|
|
102
106
|
options = { ...options, onProgress: progress ? async event => {
|
|
103
107
|
if (checkpointFailed) throw checkpointFailure;
|
|
104
108
|
try { await progress(structuredClone(event)); }
|
|
@@ -140,21 +144,81 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
140
144
|
if(input.workingScope)report.workingScope=structuredClone(input.workingScope);
|
|
141
145
|
if(input.effectiveMergeSources)report.effectiveMergeSources=structuredClone(input.effectiveMergeSources);
|
|
142
146
|
report.rules = (input.rules ?? []).map(({ path, hash }) => ({ path, hash }));
|
|
147
|
+
if (scope) configureWireBudget(scope, { nativeOutputTools: options.nativeOutputTools === true });
|
|
143
148
|
if (options.enableGrouping === true) report.grouping = buildReviewGroups(input.files, inferFileRelations(input.files));
|
|
144
149
|
report.interactions = [];
|
|
145
150
|
const interactionPlans = scope && report.grouping ? report.grouping.groups.filter(g => g.fileIds.length > 1).map(group => ({ group, prepared: prepareInteractionInput(input, group, payload => initialInputBudget({ instructions, input: payload }, scope, maxInputBytes)) })) : [];
|
|
146
151
|
for (const plan of interactionPlans.filter(p => p.prepared.status !== 'not-applicable')) report.interactions.push({ groupId: plan.group.id, fileIds: plan.prepared.fileIds, status: plan.prepared.status === 'ready' ? 'pending' : 'blocked', reason: plan.prepared.reason, lifecycle: 'shares-last-primary-timeout' });
|
|
147
152
|
const fileResults = new Array(input.files.length);
|
|
153
|
+
const sharedState = report.orchestration = { modelMetrics: {}, modelCallPurposes: {} };
|
|
154
|
+
const retainArtifact = options.onModelArtifact ? async artifact => { const reference = await options.onModelArtifact(artifact); if (reference) (report.artifacts ??= []).push(reference); } : undefined;
|
|
155
|
+
report.resume = { reusedFiles: options.resumeState?.completed?.length ?? 0 };
|
|
156
|
+
const reserveShared = ({ correction = false } = {}) => {
|
|
157
|
+
if (!correction && options.remainingCalls?.() <= 0) throw Object.assign(new Error('Shared project model call limit exceeded'), { code: 'PROJECT_MODEL_CALL_LIMIT' });
|
|
158
|
+
if (report.modelCalls >= maxCalls) throw new Error('Model call budget exceeded');
|
|
159
|
+
report.modelCalls++;
|
|
160
|
+
};
|
|
161
|
+
const sharedModel = async request => {
|
|
162
|
+
let correction;
|
|
163
|
+
return model({ ...request, nativeOutputTools: options.nativeOutputTools === true, maxInputBytes: options.nativeOutputTools === true ? maxInputBytes : request.maxInputBytes, onModelArtifact: retainArtifact ? artifact => retainArtifact({ ...artifact, snapshotId: input.id, phase: request.reviewPhase }) : undefined,
|
|
164
|
+
onProtocolEvent: async info => { if (info.action === 'json-transport-recovery') (sharedState.modelRecoveries ??= []).push(info); await request.onProtocolEvent?.(info); },
|
|
165
|
+
onProtocolCorrection: async info => { reserveShared({ correction: true }); correction = info; await request.onProtocolCorrection?.(info); },
|
|
166
|
+
onModelEvent: async info => {
|
|
167
|
+
if (info.action === 'model-end') {
|
|
168
|
+
const phase = request.reviewPhase, metrics = sharedState.modelMetrics[phase] ??= { calls: 0, durationMs: 0, inputBytes: 0, totalOutputBytes: 0 };
|
|
169
|
+
metrics.calls++; metrics.durationMs += info.durationMs; metrics.inputBytes += info.inputBytes; metrics.totalOutputBytes += info.totalOutputBytes;
|
|
170
|
+
if (info.callPurpose) sharedState.modelCallPurposes[info.callPurpose] = (sharedState.modelCallPurposes[info.callPurpose] ?? 0) + 1;
|
|
171
|
+
if (info.callPurpose === 'json-correction' && correction) { (sharedState.modelCorrections ??= []).push({ action: 'explicit-json-correction', code: 'MODEL_INVALID_JSON', jsonIssue: correction.jsonIssue }); correction = undefined; }
|
|
172
|
+
}
|
|
173
|
+
await options.onProgress?.({ stage: info.action, snapshotId: input.id, phase: request.reviewPhase, ...info });
|
|
174
|
+
} });
|
|
175
|
+
};
|
|
176
|
+
const groupPrepared = new Map(), groupResults = new Map(), groupStarted = new Map();
|
|
177
|
+
const groupResult = async group => {
|
|
178
|
+
if (!groupResults.has(group.id)) {
|
|
179
|
+
groupStarted.set(group.id, performance.now());
|
|
180
|
+
groupResults.set(group.id, invoke(async request => {
|
|
181
|
+
const prepared = groupPrepared.get(group.id); let limited = false; const reads = [];
|
|
182
|
+
const response = await retrievalLoop(sharedModel, { ...request, nativeOutputTools: options.nativeOutputTools === true, input: prepared.payload, instructions: prepared.instructions, reviewPhase: 'group-primary' }, scope, {
|
|
183
|
+
maxRounds: maxRetrievalRounds, maxInputBytes, beforeCall: reserveShared, correctProtocol: options.correctProtocol === true,
|
|
184
|
+
enableSearchSeeds: options.enableSearchSeeds === true,
|
|
185
|
+
onRetrieved: records => { reads.push(...records); (sharedState.retrievalAudit ??= []).push(...records); },
|
|
186
|
+
onBudgetLimited: info => { limited = true; (sharedState.retrievalLimits ??= []).push(info); },
|
|
187
|
+
});
|
|
188
|
+
const results = validateGroupPrimary(response, prepared, input, validateResponse, scope.catalog());
|
|
189
|
+
for (const generated of results.values()) {
|
|
190
|
+
generated.groupLimited = limited;
|
|
191
|
+
if (generated.groupRisk) {
|
|
192
|
+
generated.groupRisk.sourceReadCoverage = riskReadCoverage(generated.groupRisk.risks, reads, scope.catalog(), input.id);
|
|
193
|
+
if (generated.groupRisk.status !== 'completed') generated.limitations.push('组主审的合并风险计划缺失或无效,未补发规划调用;不表示已完成风险条件核查');
|
|
194
|
+
if (generated.groupRisk.sourceReadCoverage.some(item => item.unreadSourceIds.length)) generated.limitations.push('组风险计划请求的部分来源未在本次组生成读取;不表示已核查或排除风险条件');
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return results;
|
|
198
|
+
}, { input: groupPrepared.get(group.id).payload, instructions: groupPrepared.get(group.id).instructions }, timeoutMs, signal));
|
|
199
|
+
}
|
|
200
|
+
return groupResults.get(group.id);
|
|
201
|
+
};
|
|
148
202
|
const runFile = async index => {
|
|
149
203
|
const file = input.files[index];
|
|
150
204
|
const state = { fileId: file.id, path: file.path, status: file.eligibility === 'reviewable' ? 'pending' : file.eligibility, reason: file.reason, ...(file.blockCode ? {blockCode:file.blockCode} : {}) };
|
|
151
|
-
|
|
205
|
+
let result = { state, findings: [], limitations: [] };
|
|
152
206
|
fileResults[index] = result;
|
|
153
207
|
try {
|
|
154
208
|
await options.onProgress?.({stage:'file-started',snapshotId:input.id,fileId:file.id,path:file.path,status:state.status});
|
|
155
209
|
if (state.status !== 'pending') return;
|
|
210
|
+
const reusable = options.resumeState?.completed?.find(row => row.fileId === file.id && row.path === file.path);
|
|
211
|
+
if (reusable) {
|
|
212
|
+
const prior = structuredClone(reusable.state);
|
|
213
|
+
for (const key of ['modelMetrics', 'modelCallPurposes', 'modelCorrections', 'modelRecoveries', 'retrievalAudit', 'protocolAttempts', 'protocolDiagnostic', 'responseAttempts', 'responseCorrections', 'anchorCorrections', 'retrievalLimits']) delete prior[key];
|
|
214
|
+
Object.assign(state, prior, { reused: true, reuseNotice: 'Prior completed evidence retained for an identical approved snapshot/configuration; no new model or evidence receipt claimed.' });
|
|
215
|
+
result.findings = structuredClone(reusable.findings).map(finding => ({ ...finding, reused: true, priorRun: { snapshotId: options.resumeState.snapshotId, evidenceStatus: 'historical-verified-evidence-not-current-run-receipts' } }));
|
|
216
|
+
result.limitations = structuredClone(reusable.limitations ?? []);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
156
219
|
if (signal?.aborted) { state.status = 'cancelled'; state.reason = '用户取消'; return; }
|
|
157
|
-
|
|
220
|
+
const assignedGroup = [...groupPrepared].find(([, value]) => value.fileIds.includes(file.id));
|
|
221
|
+
if (!assignedGroup && (report.modelCalls >= maxCalls || options.remainingCalls?.() <= 0)) { state.reason = '模型调用预算耗尽'; return; }
|
|
158
222
|
const prepared = preparePrimaryInput(input, file, { instructions, scope, grouping: report.grouping, maxInputBytes, riskPlanningMode: options.riskPlanningMode, focusPrimaryContext: options.focusPrimaryContext === true, enableRiskPlanning: options.enableRiskPlanning, skipSmallRiskPlans: options.skipSmallRiskPlans });
|
|
159
223
|
const { changes, payload } = prepared;
|
|
160
224
|
Object.assign(state, prepared.metadata);
|
|
@@ -179,6 +243,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
179
243
|
};
|
|
180
244
|
let pendingStageCorrection;
|
|
181
245
|
const onProtocolEvent = async info => {
|
|
246
|
+
if (info.action === 'json-transport-recovery') (state.modelRecoveries ??= []).push(info);
|
|
182
247
|
if (info.action === 'retrieval-protocol-correction') pendingStageCorrection = { action: info.action, code: 'RETRIEVAL_PROTOCOL_ERROR' };
|
|
183
248
|
await options.onProgress?.({ stage: 'protocol-correction', fileId: file.id, path: file.path, ...info });
|
|
184
249
|
};
|
|
@@ -187,7 +252,8 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
187
252
|
// The executor has admitted this call; budget-vetoed retries never reach here.
|
|
188
253
|
if (pendingStageCorrection) { (state.modelCorrections ??= []).push(pendingStageCorrection); pendingStageCorrection = undefined; }
|
|
189
254
|
let pendingJsonCorrection;
|
|
190
|
-
return model({ ...request, callPurpose: request.callPurpose ?? request.reviewPhase ?? 'primary-initial', onProtocolEvent,
|
|
255
|
+
return model({ ...request, nativeOutputTools: options.nativeOutputTools === true, maxInputBytes: options.nativeOutputTools === true ? maxInputBytes : request.maxInputBytes, callPurpose: request.callPurpose ?? request.reviewPhase ?? 'primary-initial', onProtocolEvent,
|
|
256
|
+
onModelArtifact: retainArtifact ? artifact => retainArtifact({ ...artifact, snapshotId: input.id, fileId: file.id, path: file.path, phase: request.reviewPhase ?? 'primary' }) : undefined,
|
|
191
257
|
onModelEvent: async info => {
|
|
192
258
|
if (info.action === 'model-end') {
|
|
193
259
|
const phase = request.reviewPhase ?? 'primary';
|
|
@@ -211,40 +277,23 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
211
277
|
});
|
|
212
278
|
};
|
|
213
279
|
const executor = scope
|
|
214
|
-
? (request, onLimited = () => {}) => retrievalLoop(trackedModel, { ...request, onProtocolEvent }, scope, { 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 => {
|
|
280
|
+
? (request, onLimited = () => {}) => retrievalLoop(trackedModel, { ...request, nativeOutputTools: options.nativeOutputTools === true, onProtocolEvent }, scope, { 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 => {
|
|
215
281
|
(state.retrievalAudit ??= []).push(...records);
|
|
216
282
|
}, onTruncated: () => {
|
|
217
283
|
if (!state.retrievalTruncated) result.limitations.push({ fileId: file.id, text: '检索结果达到数量上限,部分匹配未提供给模型' });
|
|
218
284
|
state.retrievalTruncated = true;
|
|
219
285
|
} })
|
|
220
286
|
: request => { beforeCall(); return trackedModel(request); };
|
|
287
|
+
const grouped = assignedGroup;
|
|
288
|
+
const groupedGeneration = grouped ? (await groupResult({ id: grouped[0] })).get(file.id) : null;
|
|
289
|
+
if (grouped) {
|
|
290
|
+
state.primaryGroupId = grouped[0]; state.primaryMethod = 'group-primary';
|
|
291
|
+
if (groupedGeneration.groupLimited) state.retrievalLimited = true;
|
|
292
|
+
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
|
+
}
|
|
294
|
+
const chainTimeout = grouped && timeoutMs > 0 ? Math.max(0, timeoutMs - (performance.now() - groupStarted.get(grouped[0]))) : timeoutMs;
|
|
295
|
+
if (grouped && timeoutMs > 0 && chainTimeout <= 0) throw Object.assign(new Error('Model timeout'), { code: 'MODEL_TIMEOUT' });
|
|
221
296
|
await invoke(async enclosingRequest => {
|
|
222
|
-
if (options.enableBusinessGrouping === true && !report.businessGrouping && prepareBusinessGroups(input.files)) {
|
|
223
|
-
report.businessGrouping = { status: 'pending' };
|
|
224
|
-
try {
|
|
225
|
-
const local = localBusinessGroups(input.files);
|
|
226
|
-
let grouped = local;
|
|
227
|
-
if (!local) {
|
|
228
|
-
const groupInput = prepareBusinessGroups(input.files);
|
|
229
|
-
if (Buffer.byteLength(groupInput) + Buffer.byteLength(BUSINESS_GROUP_INSTRUCTIONS) > maxInputBytes) throw new Error('Business grouping input budget exceeded');
|
|
230
|
-
beforeCall();
|
|
231
|
-
const response = await trackedModel({ ...enclosingRequest, instructions: BUSINESS_GROUP_INSTRUCTIONS, input: groupInput, reviewPhase: 'business-grouping' });
|
|
232
|
-
enclosingRequest.signal.throwIfAborted();
|
|
233
|
-
grouped = validateBusinessGroups(response, input.files);
|
|
234
|
-
}
|
|
235
|
-
report.businessGrouping = { status: 'completed', ...grouped };
|
|
236
|
-
for (const group of grouped.groups.filter(g => g.fileIds.length > 1)) {
|
|
237
|
-
if (interactionPlans.some(p => [...p.group.fileIds].sort().join(',') === [...group.fileIds].sort().join(','))) continue;
|
|
238
|
-
const prepared = prepareInteractionInput(input, group, payload => initialInputBudget({ instructions, input: payload }, scope, maxInputBytes));
|
|
239
|
-
interactionPlans.push({ group, prepared });
|
|
240
|
-
if (prepared.status !== 'not-applicable') report.interactions.push({ groupId: group.id, fileIds: prepared.fileIds, status: prepared.status === 'ready' ? 'pending' : 'blocked', reason: prepared.reason, lifecycle: 'shares-last-primary-timeout' });
|
|
241
|
-
}
|
|
242
|
-
} catch (error) {
|
|
243
|
-
report.businessGrouping = { status: 'fallback', reason: error.message };
|
|
244
|
-
result.limitations.push({ text: '业务分组未完成,保留原分组:' + error.message });
|
|
245
|
-
enclosingRequest.signal.throwIfAborted();
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
297
|
const batches = [...(prepared.batches ?? [{ payload, windowIds: state.windowIds ?? [] }])];
|
|
249
298
|
if (prepared.synthesis?.status === 'ready') batches.push({ payload: prepared.synthesis.payload, windowIds: [], synthesis: true });
|
|
250
299
|
const matchingInteractionPlans = interactionPlans.filter(p => input.files.filter(f => p.group.fileIds.includes(f.id)).at(-1)?.id === file.id);
|
|
@@ -267,14 +316,14 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
267
316
|
continue;
|
|
268
317
|
}
|
|
269
318
|
let batchPayload = batch.payload;
|
|
270
|
-
const inlineRisk = prepared.inlineRisk === true && batch.round === 1 && !batch.synthesis && !batch.interaction;
|
|
319
|
+
const inlineRisk = !groupedGeneration && prepared.inlineRisk === true && batch.round === 1 && !batch.synthesis && !batch.interaction;
|
|
271
320
|
const batchInstructions = inlineRisk ? prepared.instructions : instructions;
|
|
272
321
|
let riskEntry, activeRisks = [];
|
|
273
322
|
if (inlineRisk) {
|
|
274
323
|
riskEntry = { status: 'pending', method: 'inline', windowIds: batch.windowIds, scope: state.sourceMode === 'change-windows' ? 'all-edits' : 'full-file', risks: [], decision: { required: true, reason: 'inline-primary' } };
|
|
275
324
|
(state.riskPlans ??= []).push(riskEntry);
|
|
276
325
|
}
|
|
277
|
-
if (options.enableRiskPlanning === true && !inlineRisk && !batch.synthesis && batch.round === 1) {
|
|
326
|
+
if (options.enableRiskPlanning === true && (!groupedGeneration || batch.interaction) && !inlineRisk && !batch.synthesis && batch.round === 1) {
|
|
278
327
|
const reusable = !batch.interaction && riskInput.cacheable;
|
|
279
328
|
riskEntry = reusable ? primaryRiskPlan : undefined;
|
|
280
329
|
if (!riskEntry) {
|
|
@@ -315,9 +364,9 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
315
364
|
}
|
|
316
365
|
}
|
|
317
366
|
}
|
|
318
|
-
if(report.modelCalls>=maxCalls)throw new Error('Model call budget exceeded before next review pass');
|
|
367
|
+
if(report.modelCalls>=maxCalls && !(groupedGeneration && !batch.synthesis && !batch.interaction))throw new Error('Model call budget exceeded before next review pass');
|
|
319
368
|
if(rounds>1) state.reviewPasses[batchIndex].status='running';
|
|
320
|
-
const request = { ...enclosingRequest, instructions: batchInstructions, input: batchPayload, reviewPhase: phase, allowExhaustedInitial: options.optimizeReviewCalls === true && !batch.synthesis && !batch.interaction && prepared.metadata.contextMode !== 'approved-retrieval' };
|
|
369
|
+
const request = { ...enclosingRequest, nativeOutputTools: options.nativeOutputTools === true, instructions: batchInstructions, input: batchPayload, reviewPhase: phase, allowExhaustedInitial: options.optimizeReviewCalls === true && !batch.synthesis && !batch.interaction && prepared.metadata.contextMode !== 'approved-retrieval' };
|
|
321
370
|
let auditStart = state.retrievalAudit?.length ?? 0;
|
|
322
371
|
let finalResponseOnly = false;
|
|
323
372
|
const generate = async attempt => {
|
|
@@ -347,7 +396,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
347
396
|
if (!batch.interaction && state.sourceMode === 'change-windows') validateWindowFindings(generated.findings, batch.synthesis ? prepared.synthesis.windows : JSON.parse(batch.payload).windows, batch.synthesis === true);
|
|
348
397
|
return generated;
|
|
349
398
|
};
|
|
350
|
-
const generated = await correctedResponse(generate, validate, request, {
|
|
399
|
+
const generated = groupedGeneration && !batch.synthesis && !batch.interaction ? structuredClone(groupedGeneration) : await correctedResponse(generate, validate, request, {
|
|
351
400
|
enabled: options.correctProtocol === true,
|
|
352
401
|
canCorrect: () => !finalResponseOnly,
|
|
353
402
|
onCorrection: async info => {
|
|
@@ -368,7 +417,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
368
417
|
}
|
|
369
418
|
delete generated.inlineRisks;
|
|
370
419
|
}
|
|
371
|
-
if (options.enableRiskPlanning === true && !batch.synthesis && batch.round === 1) {
|
|
420
|
+
if (options.enableRiskPlanning === true && (!groupedGeneration || batch.interaction) && !batch.synthesis && batch.round === 1) {
|
|
372
421
|
const coverage = riskReadCoverage(activeRisks,(state.retrievalAudit??[]).slice(auditStart),scope?.catalog()??[],input.id);
|
|
373
422
|
(riskEntry.generations ??= []).push({ round: batch.round, windowIds: batch.windowIds, coverage });
|
|
374
423
|
// Preserve the single-generation field without unioning evidence across windows.
|
|
@@ -394,6 +443,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
394
443
|
if (verdicts.some(v => v.verdict === 'supported' && v.regression?.classification === 'uncertain')) result.limitations.push({ fileId: file.id, text: '部分模型支持候选缺少充分前后对照,不能确认由本次变更引入' });
|
|
395
444
|
if (verdicts.some(v => v.verdict === 'uncertain')) result.limitations.push({ fileId: file.id, text: '部分候选复核仍不确定,不能视为已确认或已排除' });
|
|
396
445
|
} catch (error) {
|
|
446
|
+
if (error?.code === 'MODEL_ARTIFACT_FAILED') throw error;
|
|
397
447
|
for (const finding of generated.findings) finding.verification = { status: 'incomplete', causality: 'unverified' };
|
|
398
448
|
result.limitations.push({ fileId: file.id, text: '候选复核未完成:' + (error?.message ?? String(error)) });
|
|
399
449
|
request.signal.throwIfAborted();
|
|
@@ -413,7 +463,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
413
463
|
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});
|
|
414
464
|
}catch(error){
|
|
415
465
|
enclosingRequest.signal.throwIfAborted();
|
|
416
|
-
if(phase==='primary'
|
|
466
|
+
if(phase==='primary'||error.code==='MODEL_ARTIFACT_FAILED'||(/budget exceeded/i.test(error.message)&&!groupedGeneration))throw error;
|
|
417
467
|
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}:{})};
|
|
418
468
|
(state.stageFailures??=[]).push(failure);
|
|
419
469
|
if(batch.synthesis)state.synthesisStatus='failed';
|
|
@@ -423,7 +473,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
423
473
|
await options.onProgress?.({stage:'synthesis-failed',snapshotId:input.id,fileId:file.id,path:file.path,...failure,windowCoverage:state.windowCoverage});
|
|
424
474
|
}
|
|
425
475
|
}
|
|
426
|
-
}, { instructions, input: payload },
|
|
476
|
+
}, { instructions, input: payload }, chainTimeout, signal);
|
|
427
477
|
if (signal?.aborted) throw signal.reason;
|
|
428
478
|
|
|
429
479
|
state.status = state.retrievalLimited ? 'pending' : 'completed';
|
|
@@ -436,6 +486,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
436
486
|
}
|
|
437
487
|
|
|
438
488
|
} catch (error) {
|
|
489
|
+
if (error?.code === 'MODEL_ARTIFACT_FAILED') { checkpointFailed = true; checkpointFailure = error; controller.abort(error); throw error; }
|
|
439
490
|
state.status = signal?.aborted ? 'cancelled' : error?.code === 'PROJECT_MODEL_CALL_LIMIT' ? 'pending' : 'failed';
|
|
440
491
|
state.reason = error?.message ?? String(error);
|
|
441
492
|
if(error?.attempts)state.protocolAttempts=error.attempts;
|
|
@@ -445,13 +496,49 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
445
496
|
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';
|
|
446
497
|
}
|
|
447
498
|
} finally {
|
|
499
|
+
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');
|
|
448
500
|
await options.onProgress?.({stage:'file-complete',snapshotId:input.id,file:state,findings:result.findings});
|
|
501
|
+
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)] });
|
|
449
502
|
}
|
|
450
503
|
};
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
504
|
+
if (options.resumeState?.businessGrouping) report.businessGrouping = structuredClone(options.resumeState.businessGrouping);
|
|
505
|
+
for (const interaction of report.interactions) {
|
|
506
|
+
if (interaction.fileIds.every(id => options.resumeState?.completed?.some(row => row.fileId === id))) Object.assign(interaction, { status: 'completed', reused: true });
|
|
507
|
+
}
|
|
508
|
+
// Freeze grouping before admitting file chains, without serializing a whole first file.
|
|
509
|
+
if (options.enableBusinessGrouping === true && prepareBusinessGroups(input.files) && !signal.aborted) {
|
|
510
|
+
try {
|
|
511
|
+
const local = report.businessGrouping?.groups ? report.businessGrouping : localBusinessGroups(input.files);
|
|
512
|
+
const grouped = local ?? await invoke(async request => {
|
|
513
|
+
const groupInput = prepareBusinessGroups(input.files);
|
|
514
|
+
if ((options.nativeOutputTools === true ? !modelWireBudget({ input: groupInput, instructions: BUSINESS_GROUP_INSTRUCTIONS, nativeOutputTools: true }, maxInputBytes).fits : Buffer.byteLength(groupInput) + Buffer.byteLength(BUSINESS_GROUP_INSTRUCTIONS) > maxInputBytes)) throw new Error('Business grouping input budget exceeded');
|
|
515
|
+
reserveShared();
|
|
516
|
+
return validateBusinessGroups(await sharedModel({ ...request, reviewPhase: 'business-grouping', callPurpose: 'business-grouping' }), input.files);
|
|
517
|
+
}, { input: prepareBusinessGroups(input.files), instructions: BUSINESS_GROUP_INSTRUCTIONS }, timeoutMs, signal);
|
|
518
|
+
report.businessGrouping = { status: 'completed', ...grouped };
|
|
519
|
+
for (const group of grouped.groups.filter(g => g.fileIds.length > 1)) {
|
|
520
|
+
if (interactionPlans.some(p => [...p.group.fileIds].sort().join(',') === [...group.fileIds].sort().join(','))) continue;
|
|
521
|
+
const prepared = prepareInteractionInput(input, group, payload => initialInputBudget({ instructions, input: payload }, scope, maxInputBytes));
|
|
522
|
+
interactionPlans.push({ group, prepared });
|
|
523
|
+
if (prepared.status !== 'not-applicable') report.interactions.push({ groupId: group.id, fileIds: prepared.fileIds, status: group.fileIds.every(id => options.resumeState?.completed?.some(row => row.fileId === id)) ? 'completed' : prepared.status === 'ready' ? 'pending' : 'blocked', reason: prepared.reason, lifecycle: 'shares-last-primary-timeout' });
|
|
524
|
+
}
|
|
525
|
+
} catch (error) {
|
|
526
|
+
if (signal.aborted || checkpointFailed || error.code === 'MODEL_ARTIFACT_FAILED') throw error;
|
|
527
|
+
report.businessGrouping = { status: 'fallback', reason: error.message };
|
|
528
|
+
report.limitations.push({ text: '业务分组未完成,保留原分组:' + error.message });
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
if (options.enableGroupPrimary === true && scope && rounds === 1) {
|
|
532
|
+
const seen = new Set();
|
|
533
|
+
const groups = report.businessGrouping?.groups ?? report.grouping?.groups ?? [];
|
|
534
|
+
for (const group of groups) {
|
|
535
|
+
if (group.fileIds.some(id => seen.has(id) || options.resumeState?.completed?.some(row => row.fileId === id))) continue;
|
|
536
|
+
const prepared = prepareGroupPrimary(input, group, { instructions, scope, maxInputBytes, focusPrimaryContext: options.focusPrimaryContext === true, enableRiskPlanning: options.enableRiskPlanning === true });
|
|
537
|
+
if (!prepared) continue;
|
|
538
|
+
groupPrepared.set(group.id, prepared);
|
|
539
|
+
for (const id of group.fileIds) seen.add(id);
|
|
540
|
+
}
|
|
541
|
+
report.primaryGroups = [...groupPrepared].map(([groupId, p]) => ({ groupId, fileIds: p.fileIds, mode: 'group-primary' }));
|
|
455
542
|
}
|
|
456
543
|
const dependencies = new Map();
|
|
457
544
|
for (const plan of interactionPlans.filter(p => p.prepared.status === 'ready')) {
|
|
@@ -459,7 +546,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
459
546
|
const owner = members.at(-1);
|
|
460
547
|
if (owner !== undefined) dependencies.set(owner, [...new Set([...(dependencies.get(owner) ?? []), ...members.slice(0, -1)])]);
|
|
461
548
|
}
|
|
462
|
-
await scheduleFileChains(input.files.map((_, index) => index)
|
|
549
|
+
await scheduleFileChains(input.files.map((_, index) => index), async index => {
|
|
463
550
|
try { await runFile(index); }
|
|
464
551
|
catch (error) { controller.abort(error); throw error; }
|
|
465
552
|
}, dependencies, fileConcurrency);
|
|
@@ -485,6 +572,8 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
485
572
|
}
|
|
486
573
|
report.regressionCoverage = Object.fromEntries(['introduced', 'preexisting', 'uncertain', 'unassessed'].map(classification => [classification, report.findings.filter(f => (f.verification?.regression?.classification ?? 'unassessed') === classification).length]));
|
|
487
574
|
report.findingGroups = groupDuplicateFindings(report.findings);
|
|
575
|
+
for (const state of report.files) state.resumeEligible = state.status === 'completed' && !state.stageFailures?.length && !state.reviewPasses?.some(pass => pass.status !== 'completed') && !state.windowCoverage?.pending?.length && !state.retrievalLimited && report.findings.filter(f => f.fileId === state.fileId).every(f => f.verification?.status === 'completed') && report.interactions.filter(item => item.fileIds?.includes(state.fileId)).every(item => item.status === 'completed');
|
|
576
|
+
await options.onResumeCheckpoint?.(report);
|
|
488
577
|
report.followup = coverageFollowup(report);
|
|
489
578
|
if (scope) report.readerBudget = { ...scope.usage(), ...scope.budget(), ...readerLimits };
|
|
490
579
|
report.diagnostics = reviewDiagnostics(report, { reviewWallMs: performance.now() - startedAt });
|
|
@@ -518,7 +607,7 @@ function renderFinding(output, finding, index, duplicates) {
|
|
|
518
607
|
const verification = finding.verification;
|
|
519
608
|
if (verification) {
|
|
520
609
|
const label = verification.status !== 'completed' ? '未完成' : ({ supported: '模型复核支持', refuted: '模型复核反驳(保留候选供追溯)', uncertain: '不确定' }[verification.verdict] ?? '未知');
|
|
521
|
-
output.push('- 复核状态:' + label + ';不是事实或因果证明');
|
|
610
|
+
output.push('- 复核状态:' + label + (finding.reused ? '(相同快照的既往轮次结果,本次未重新取证)' : '') + ';不是事实或因果证明');
|
|
522
611
|
if (verification.reason) output.push('- 复核理由:' + safe(verification.reason));
|
|
523
612
|
for (const ref of verification.evidence ?? []) output.push('- 复核证据:' + safe(ref.sourceId) + ':' + ref.start + '(' + ref.count + '行) · SHA256 ' + safe(ref.hash) + ' · ' + safe(ref.text));
|
|
524
613
|
}
|
package/src/search-seeds.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { retrievalRequest } from './retrieval-loop.mjs';
|
|
2
|
+
import { modelWireBudget } from './model-wire-budget.mjs';
|
|
2
3
|
|
|
3
4
|
const validLine = value => Number.isSafeInteger(value) && value > 0;
|
|
4
5
|
const capacity = () => Object.assign(new Error('Optional search seed unavailable'), { code: 'SEARCH_SEED_CAPACITY' });
|
|
@@ -59,7 +60,7 @@ export function searchSeeds(scope, request, records, { existing = [], maxReads =
|
|
|
59
60
|
check();
|
|
60
61
|
const remaining = scope.budget();
|
|
61
62
|
if (typeof next?.input !== 'string' || typeof next.instructions !== 'string') throw new Error('Invalid search seed request builder');
|
|
62
|
-
if (Buffer.byteLength(next.input) + Buffer.byteLength(next.instructions) > maxInputBytes || remaining.remainingCalls < 4 || remaining.remainingOutputBytes < 16 * 1024) throw capacity();
|
|
63
|
+
if ((request.nativeOutputTools === true ? !modelWireBudget(next, maxInputBytes).fits : Buffer.byteLength(next.input) + Buffer.byteLength(next.instructions) > maxInputBytes) || remaining.remainingCalls < 4 || remaining.remainingOutputBytes < 16 * 1024) throw capacity();
|
|
63
64
|
return { request: operation, result };
|
|
64
65
|
});
|
|
65
66
|
seeds.push(item);
|
package/src/service.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { configureWireBudget } from './model-wire-budget.mjs';
|
|
1
2
|
import {REVIEW_RUNTIME} from './runtime-identity.mjs';
|
|
3
|
+
import { buildResumeState, validateResumeState, resumePreview } from './review-resume.mjs';
|
|
2
4
|
import { reviewDiagnostics } from './review-diagnostics.mjs';
|
|
3
5
|
import {createProjectReviewService} from './project-review-service.mjs';
|
|
4
6
|
import {svnDiagnostic} from './svn-diagnostics.mjs';
|
|
@@ -53,15 +55,16 @@ function buildPreviewNotice(snapshot, options, enableRiskPlanning, changesConcur
|
|
|
53
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不含后续检索/复核/动态业务综合;多文件可先进行业务分组,分组失败保留原组并记录限制,动态综合数执行前未知,实际预算可能不足。请先向用户展示范围,获得确认后执行。`;
|
|
54
56
|
}
|
|
55
57
|
|
|
56
|
-
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, 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, authorize = async () => false } = {}) {
|
|
57
59
|
for (const value of [ttlMs, maxPreviews]) if (!Number.isSafeInteger(value) || value < 1) throw new Error('Invalid preview limit');
|
|
58
60
|
changesConcurrency = reviewConcurrency(changesConcurrency);
|
|
59
61
|
maxRetrievalRounds = reviewRetrievalRounds(maxRetrievalRounds);
|
|
60
62
|
riskPlanningMode = validateRiskPlanningMode(riskPlanningMode);
|
|
61
63
|
reviewReaderBudget({ files: [] }, readerBudgetPolicy);
|
|
62
|
-
for (const value of [optimizeReviewCalls, enablePrimarySeeds, focusPrimaryContext, enableSearchSeeds]) if (typeof value !== 'boolean') throw new Error('Invalid review optimization option');
|
|
64
|
+
for (const value of [optimizeReviewCalls, enablePrimarySeeds, focusPrimaryContext, enableSearchSeeds, nativeOutputTools, enableGroupPrimary]) if (typeof value !== 'boolean') throw new Error('Invalid review optimization option');
|
|
63
65
|
const lifetime = new AbortController();
|
|
64
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 } });
|
|
65
68
|
const previews = new Map();
|
|
66
69
|
let busy = false;
|
|
67
70
|
function prune() { for (const [id, p] of previews) if (p.expires <= now()) previews.delete(id); }
|
|
@@ -70,7 +73,8 @@ function createSessionReviewService(llm, { capture = captureSnapshot, now = Date
|
|
|
70
73
|
exec = bind(exec);
|
|
71
74
|
exec.signal.throwIfAborted();
|
|
72
75
|
if (busy) throw new Error('Review service busy');
|
|
73
|
-
if (!args || Object.keys(args).some(k => !['repositoryPath', 'selectedPaths', 'contextPaths', 'rulePaths', 'autoContext', 'commit', 'baseRevision', 'targetRevision','businessRequirement','callerScopePaths','oldContextPaths','reviewRounds','svnRevision','svnBaseRevision','svnTargetRevision','svnAllowNetwork','svnEffectiveMergeinfo','svnBasePath','svnTargetPath','svnReplacementBaseRevision','svnReplacementAllowNetwork','changeScope'].includes(k))) throw new Error('Unknown preview argument');
|
|
76
|
+
if (!args || Object.keys(args).some(k => !['repositoryPath', 'selectedPaths', 'contextPaths', 'rulePaths', 'autoContext', 'commit', 'baseRevision', 'targetRevision','businessRequirement','callerScopePaths','oldContextPaths','reviewRounds','svnRevision','svnBaseRevision','svnTargetRevision','svnAllowNetwork','svnEffectiveMergeinfo','svnBasePath','svnTargetPath','svnReplacementBaseRevision','svnReplacementAllowNetwork','changeScope','resumeFromJobId'].includes(k))) throw new Error('Unknown preview argument');
|
|
77
|
+
if (args.resumeFromJobId !== undefined && (typeof args.resumeFromJobId !== 'string' || !/^[a-f0-9-]{36}$/.test(args.resumeFromJobId))) throw new Error('Invalid resume job id');
|
|
74
78
|
if (args.autoContext !== undefined && typeof args.autoContext !== 'boolean') throw new Error('Invalid autoContext option');
|
|
75
79
|
for (const key of ['selectedPaths', 'contextPaths', 'rulePaths', 'oldContextPaths']) if (args[key] !== undefined && (!Array.isArray(args[key]) || args[key].length > (key === 'rulePaths' ? 4 : key === 'oldContextPaths' ? 20 : 200) || args[key].some(p => typeof p !== 'string'))) throw new Error('Invalid path list');
|
|
76
80
|
const rounds=validateReviewRounds(args.reviewRounds === undefined ? reviewRounds : args.reviewRounds);
|
|
@@ -80,24 +84,34 @@ function createSessionReviewService(llm, { capture = captureSnapshot, now = Date
|
|
|
80
84
|
busy = true;
|
|
81
85
|
try {
|
|
82
86
|
const options = structuredClone(args);
|
|
87
|
+
const sourceJobId = options.resumeFromJobId;
|
|
88
|
+
delete options.resumeFromJobId;
|
|
89
|
+
let savedResume;
|
|
90
|
+
if (sourceJobId !== undefined) {
|
|
91
|
+
if (typeof exec.resolveReviewResume !== 'function') throw new Error('Durable job resume is unavailable; use a new background preview');
|
|
92
|
+
savedResume = await exec.resolveReviewResume(sourceJobId);
|
|
93
|
+
}
|
|
83
94
|
const target = await resolveWorkspaceRepository(current.cwd, options.repositoryPath);
|
|
84
95
|
const snapshot = await capture(target, { ...options, signal: exec.signal });
|
|
85
96
|
exec.signal?.throwIfAborted();
|
|
86
97
|
if (owner(exec).cwd !== current.cwd) throw new Error('Agent directory changed; preview again');
|
|
98
|
+
const resumeState = savedResume === undefined ? undefined : validateResumeState(savedResume, snapshot, resumeOptions(options, current.route, rounds));
|
|
87
99
|
const readerBudget = reviewReaderBudget(snapshot, readerBudgetPolicy);
|
|
88
|
-
const plan = buildCoveragePlan(snapshot, { focusPrimaryContext, riskPlanningMode, enableRiskPlanning, skipSmallRiskPlans, reviewRounds:rounds, enableBusinessGrouping, instructions: REVIEW_INSTRUCTIONS, scope: createRetrievalScope(snapshot, { ...readerBudget, signal: exec.signal }), 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 }), { nativeOutputTools }), grouping: buildReviewGroups(snapshot.files, inferFileRelations(snapshot.files)), maxInputBytes: 96 * 1024, signal: exec.signal });
|
|
89
101
|
prune();
|
|
90
102
|
while (previews.size >= maxPreviews) previews.delete(previews.keys().next().value);
|
|
91
103
|
const previewId = randomUUID();
|
|
92
|
-
previews.set(previewId, { ...current, target, options, rounds, snapshotId: snapshot.id, expires: now() + ttlMs });
|
|
104
|
+
previews.set(previewId, { ...current, target, options, rounds, snapshotId: snapshot.id, resumeState, sourceJobId, expires: now() + ttlMs });
|
|
93
105
|
return {
|
|
94
106
|
...(options.businessRequirement !== undefined
|
|
95
107
|
? { businessRequirement: { text: options.businessRequirement, hash: hash(options.businessRequirement), bytes: Buffer.byteLength(options.businessRequirement) } }
|
|
96
108
|
: {}),
|
|
97
109
|
previewId,
|
|
110
|
+
...(resumeState ? { resume: resumePreview(resumeState, sourceJobId) } : {}),
|
|
98
111
|
plan,
|
|
99
112
|
snapshotId: snapshot.id,
|
|
100
|
-
|
|
113
|
+
privateArtifacts: { enabled: typeof exec.onModelArtifact === 'function', sourceBearing: true, maxArtifactBytes: 1048576, maxJobBytes: 8388608, maxArtifacts: 64, 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, fileConcurrency: changesConcurrency, maxRetrievalRounds, ...(optimizeReviewCalls ? { riskPlanningMode, readerBudget: { policy: readerBudgetPolicy, ...readerBudget }, missingContextSeeds: enablePrimarySeeds, focusPrimaryContext, searchContextSeeds: enableSearchSeeds } : {}) },
|
|
101
115
|
repositoryRoot: snapshot.root,
|
|
102
116
|
vcs: snapshot.vcs,
|
|
103
117
|
...(snapshot.changeScope ? { changeScope: snapshot.changeScope } : options.changeScope !== undefined ? { changeScope: options.changeScope } : {}),
|
|
@@ -148,12 +162,15 @@ function createSessionReviewService(llm, { capture = captureSnapshot, now = Date
|
|
|
148
162
|
const snapshot = await capture(target, { ...preview.options, signal: exec.signal });
|
|
149
163
|
exec.signal?.throwIfAborted();
|
|
150
164
|
if (snapshot.id !== preview.snapshotId || owner(exec).cwd !== current.cwd) throw new Error('Files changed since preview; preview again');
|
|
165
|
+
const resumeIdentity = resumeOptions(preview.options, preview.route, preview.rounds);
|
|
166
|
+
const resumeState = preview.resumeState ? validateResumeState(preview.resumeState, snapshot, resumeIdentity) : undefined;
|
|
151
167
|
if (await authorize({ snapshot, route: preview.route, exec }) !== true) throw new Error('Model sending approval denied or unavailable');
|
|
152
168
|
exec.signal?.throwIfAborted();
|
|
153
169
|
if (owner(exec).cwd !== current.cwd || JSON.stringify(owner(exec).route) !== JSON.stringify(preview.route)) throw new Error('Agent changed during approval');
|
|
154
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}))});
|
|
155
|
-
const report = await reviewSnapshot(snapshot, createDshModel(llm, preview.route,{correctJson:true}), { signal: exec.signal, enableRetrieval: true, enableGrouping: true, enableVerification: true, riskPlanningMode, readerBudgetPolicy, optimizeReviewCalls, enablePrimarySeeds, focusPrimaryContext, enableSearchSeeds, enableRiskPlanning, skipSmallRiskPlans, reviewRounds:preview.rounds, enableBusinessGrouping, enableAnchorCorrection,changesConcurrency,maxRetrievalRounds,correctProtocol:true, onProgress:exec.reviewCheckpoint });
|
|
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 });
|
|
156
172
|
report.model = preview.route;
|
|
173
|
+
if (exec.reviewResumeCheckpoint) await exec.reviewResumeCheckpoint(buildResumeState(snapshot, resumeIdentity, report));
|
|
157
174
|
// reviewSnapshot preserves cancelled coverage; do not replace it with a freshness error.
|
|
158
175
|
if (report.status === 'cancelled') {
|
|
159
176
|
report.outdated = true;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { VERIFICATION_INSTRUCTIONS } from './prompts.mjs';
|
|
2
2
|
import { CONTEXT_SIDE_NOTICE } from './context-identity.mjs';
|
|
3
3
|
import { defectGuidance } from './defect-guidance.mjs';
|
|
4
|
+
import { modelWireBudget } from './model-wire-budget.mjs';
|
|
4
5
|
import { createEvidenceReceipts } from './evidence-receipts.mjs';
|
|
5
6
|
import { retrievalLoop, retrievalRequest } from './retrieval-loop.mjs';
|
|
6
7
|
import { verificationSeeds } from './verification-seeds.mjs';
|
|
@@ -28,7 +29,7 @@ export async function verificationLoop(model, request, candidates, scope, option
|
|
|
28
29
|
onReceipt: (id, resolve) => seedReceipts.set(id, resolve),
|
|
29
30
|
fits: seeds => {
|
|
30
31
|
const next = retrievalRequest(verificationRequest, scope, seeds);
|
|
31
|
-
return Buffer.byteLength(next.input) + Buffer.byteLength(next.instructions) <= (options.maxInputBytes ?? 96 * 1024);
|
|
32
|
+
return request.nativeOutputTools === true ? modelWireBudget(next, options.maxInputBytes ?? 96 * 1024).fits : Buffer.byteLength(next.input) + Buffer.byteLength(next.instructions) <= (options.maxInputBytes ?? 96 * 1024);
|
|
32
33
|
},
|
|
33
34
|
});
|
|
34
35
|
const receipts = createEvidenceReceipts(scope.read, { limit: 50 - seedReceipts.size });
|