@feng0531/dsh-code-review 0.85.13 → 0.86.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/review.mjs CHANGED
@@ -29,6 +29,9 @@ import { changesConcurrency, retrievalRounds } from './review-execution-policy.m
29
29
  import { scheduleFileChains } from './file-chain-scheduler.mjs';
30
30
  import { reviewReaderBudget, readerAdmission } from './review-reader-policy.mjs';
31
31
  import { primarySeeds } from './primary-seeds.mjs';
32
+ import { createReviewReaderAllocation } from './review-reader-allocation.mjs';
33
+ import { prepareGroupPrimary, validateGroupPrimary } from './group-primary.mjs';
34
+ import { configureWireBudget, modelWireBudget } from './model-wire-budget.mjs';
32
35
 
33
36
  export { REVIEW_INSTRUCTIONS };
34
37
  export { PROJECT_REVIEW_INSTRUCTIONS };
@@ -99,6 +102,8 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
99
102
  const maxRetrievalRounds = retrievalRounds(options.maxRetrievalRounds);
100
103
  let checkpointFailed = false, checkpointFailure;
101
104
  const progress = options.onProgress;
105
+ const resumeCheckpoint = options.onResumeCheckpoint;
106
+ if (resumeCheckpoint) options = { ...options, onResumeCheckpoint: async report => { try { await resumeCheckpoint(report); } catch (error) { checkpointFailed = true; checkpointFailure = error; controller.abort(error); throw error; } } };
102
107
  options = { ...options, onProgress: progress ? async event => {
103
108
  if (checkpointFailed) throw checkpointFailure;
104
109
  try { await progress(structuredClone(event)); }
@@ -120,7 +125,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
120
125
  if (options.enableBusinessGrouping === true && options.enableRetrieval !== true) throw new Error('Business grouping requires approved retrieval scope');
121
126
  if (options.enableVerification === true && options.enableRetrieval !== true) throw new Error('Verification requires approved retrieval scope');
122
127
  const readerLimits = reviewReaderBudget(input, options.readerBudgetPolicy ?? 'fixed');
123
- const scope = options.enableRetrieval === true ? createRetrievalScope({ ...input, context: input.context ?? [] }, { ...readerLimits, signal }) : null;
128
+ const scope = options.enableRetrieval === true ? createRetrievalScope({ ...input, context: input.context ?? [] }, { ...readerLimits, signal, ...(options.optimizeReviewCalls === true ? { reuseResults: true } : {}) }) : null;
124
129
  const report = { schemaVersion: 1, snapshotId: input.id, vcs: input.vcs, status: 'completed', files: [], findings: [], limitations: [], modelCalls: 0 };
125
130
  validateBusinessRequirement(input.businessRequirement);
126
131
  if(input.businessRequirement!==undefined)report.businessRequirement={hash:hash(input.businessRequirement),bytes:Buffer.byteLength(input.businessRequirement),status:'untrusted-constraint'};
@@ -140,21 +145,85 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
140
145
  if(input.workingScope)report.workingScope=structuredClone(input.workingScope);
141
146
  if(input.effectiveMergeSources)report.effectiveMergeSources=structuredClone(input.effectiveMergeSources);
142
147
  report.rules = (input.rules ?? []).map(({ path, hash }) => ({ path, hash }));
148
+ if (scope) configureWireBudget(scope, { nativeOutputTools: options.nativeOutputTools === true });
143
149
  if (options.enableGrouping === true) report.grouping = buildReviewGroups(input.files, inferFileRelations(input.files));
144
150
  report.interactions = [];
145
151
  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
152
  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
153
  const fileResults = new Array(input.files.length);
154
+ const sharedState = report.orchestration = { modelMetrics: {}, modelCallPurposes: {} };
155
+ const retainArtifact = options.onModelArtifact ? async artifact => { const reference = await options.onModelArtifact(artifact); if (reference) (report.artifacts ??= []).push(reference); } : undefined;
156
+ report.resume = { reusedFiles: options.resumeState?.completed?.length ?? 0 };
157
+ const reserveShared = ({ correction = false } = {}) => {
158
+ if (!correction && options.remainingCalls?.() <= 0) throw Object.assign(new Error('Shared project model call limit exceeded'), { code: 'PROJECT_MODEL_CALL_LIMIT' });
159
+ if (report.modelCalls >= maxCalls) throw new Error('Model call budget exceeded');
160
+ report.modelCalls++;
161
+ };
162
+ const sharedModel = async request => {
163
+ let correction;
164
+ 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,
165
+ onProtocolEvent: async info => { if (info.action === 'json-transport-recovery') (sharedState.modelRecoveries ??= []).push(info); await request.onProtocolEvent?.(info); },
166
+ onProtocolCorrection: async info => { reserveShared({ correction: true }); correction = info; await request.onProtocolCorrection?.(info); },
167
+ onModelEvent: async info => {
168
+ if (info.action === 'model-end') {
169
+ const phase = request.reviewPhase, metrics = sharedState.modelMetrics[phase] ??= { calls: 0, durationMs: 0, inputBytes: 0, totalOutputBytes: 0 };
170
+ metrics.calls++; metrics.durationMs += info.durationMs; metrics.inputBytes += info.inputBytes; metrics.totalOutputBytes += info.totalOutputBytes;
171
+ if (info.callPurpose) sharedState.modelCallPurposes[info.callPurpose] = (sharedState.modelCallPurposes[info.callPurpose] ?? 0) + 1;
172
+ if (info.callPurpose === 'json-correction' && correction) { (sharedState.modelCorrections ??= []).push({ action: 'explicit-json-correction', code: 'MODEL_INVALID_JSON', jsonIssue: correction.jsonIssue }); correction = undefined; }
173
+ }
174
+ await options.onProgress?.({ stage: info.action, snapshotId: input.id, phase: request.reviewPhase, ...info });
175
+ } });
176
+ };
177
+ const groupPrepared = new Map(), groupResults = new Map(), groupStarted = new Map();
178
+ let readerAllocation;
179
+ const stageScope = id => readerAllocation
180
+ ? configureWireBudget(readerAllocation.scope(id), { nativeOutputTools: options.nativeOutputTools === true }) : scope;
181
+ const releaseStage = id => readerAllocation?.release(id);
182
+ const groupResult = async group => {
183
+ if (!groupResults.has(group.id)) {
184
+ groupStarted.set(group.id, performance.now());
185
+ groupResults.set(group.id, invoke(async request => {
186
+ const prepared = groupPrepared.get(group.id), groupScope = stageScope('group:' + group.id); const limits = [], reads = [];
187
+ const response = await retrievalLoop(sharedModel, { ...request, nativeOutputTools: options.nativeOutputTools === true, input: prepared.payload, instructions: prepared.instructions, reviewPhase: 'group-primary' }, groupScope, {
188
+ maxRounds: maxRetrievalRounds, maxInputBytes, beforeCall: reserveShared, correctProtocol: options.correctProtocol === true,
189
+ enableSearchSeeds: options.enableSearchSeeds === true,
190
+ onRetrieved: records => { reads.push(...records); (sharedState.retrievalAudit ??= []).push(...records); },
191
+ onBudgetLimited: info => { limits.push(info); (sharedState.retrievalLimits ??= []).push(info); },
192
+ });
193
+ const results = validateGroupPrimary(response, prepared, input, validateResponse, scope.catalog());
194
+ for (const generated of results.values()) {
195
+ generated.groupLimits = structuredClone(limits);
196
+ if (generated.groupRisk) {
197
+ generated.groupRisk.sourceReadCoverage = riskReadCoverage(generated.groupRisk.risks, reads, scope.catalog(), input.id);
198
+ if (generated.groupRisk.status !== 'completed') generated.limitations.push('组主审的合并风险计划缺失或无效,未补发规划调用;不表示已完成风险条件核查');
199
+ if (generated.groupRisk.sourceReadCoverage.some(item => item.unreadSourceIds.length)) generated.limitations.push('组风险计划请求的部分来源未在本次组生成读取;不表示已核查或排除风险条件');
200
+ }
201
+ }
202
+ return results;
203
+ }, { input: groupPrepared.get(group.id).payload, instructions: groupPrepared.get(group.id).instructions }, timeoutMs, signal));
204
+ }
205
+ try { return await groupResults.get(group.id); } finally { releaseStage('group:' + group.id); }
206
+ };
148
207
  const runFile = async index => {
149
208
  const file = input.files[index];
150
209
  const state = { fileId: file.id, path: file.path, status: file.eligibility === 'reviewable' ? 'pending' : file.eligibility, reason: file.reason, ...(file.blockCode ? {blockCode:file.blockCode} : {}) };
151
- const result = { state, findings: [], limitations: [] };
210
+ let result = { state, findings: [], limitations: [] };
152
211
  fileResults[index] = result;
153
212
  try {
154
213
  await options.onProgress?.({stage:'file-started',snapshotId:input.id,fileId:file.id,path:file.path,status:state.status});
155
214
  if (state.status !== 'pending') return;
215
+ const reusable = options.resumeState?.completed?.find(row => row.fileId === file.id && row.path === file.path);
216
+ if (reusable) {
217
+ const prior = structuredClone(reusable.state);
218
+ for (const key of ['modelMetrics', 'modelCallPurposes', 'modelCorrections', 'modelRecoveries', 'retrievalAudit', 'protocolAttempts', 'protocolDiagnostic', 'responseAttempts', 'responseCorrections', 'anchorCorrections', 'retrievalLimits']) delete prior[key];
219
+ Object.assign(state, prior, { reused: true, reuseNotice: 'Prior completed evidence retained for an identical approved snapshot/configuration; no new model or evidence receipt claimed.' });
220
+ result.findings = structuredClone(reusable.findings).map(finding => ({ ...finding, reused: true, priorRun: { snapshotId: options.resumeState.snapshotId, evidenceStatus: 'historical-verified-evidence-not-current-run-receipts' } }));
221
+ result.limitations = structuredClone(reusable.limitations ?? []);
222
+ return;
223
+ }
156
224
  if (signal?.aborted) { state.status = 'cancelled'; state.reason = '用户取消'; return; }
157
- if (report.modelCalls >= maxCalls || options.remainingCalls?.() <= 0) { state.reason = '模型调用预算耗尽'; return; }
225
+ const assignedGroup = [...groupPrepared].find(([, value]) => value.fileIds.includes(file.id));
226
+ if (!assignedGroup && (report.modelCalls >= maxCalls || options.remainingCalls?.() <= 0)) { state.reason = '模型调用预算耗尽'; return; }
158
227
  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
228
  const { changes, payload } = prepared;
160
229
  Object.assign(state, prepared.metadata);
@@ -165,20 +234,23 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
165
234
  if(changes.precision==='envelope')result.limitations.push({fileId:file.id,text:'精确差异不可得,按保守包络分段检查;包络包含可能未变的行,不证明精确编辑或回归因果。'});
166
235
  if (changes.status === 'limited') result.limitations.push({ fileId: file.id, text: '精确变更分析受限:' + changes.reason + ';仍按完整两侧内容审查' });
167
236
  if (state.groupFallback) result.limitations.push({ fileId: file.id, text: '关联组上下文超过输入预算,回退单文件审查' });
168
- if (state.initialInputBytes > maxInputBytes) { state.status = 'blocked'; state.reason = '输入超过预算,未截断或提交模型'; return; }
237
+ if (state.initialInputBytes > maxInputBytes) { state.status = 'blocked'; state.reason = '输入超过预算,未截断或提交模型'; releaseStage('primary:' + file.id); releaseStage('synthesis:' + file.id); releaseStage('verification:' + file.id); return; }
169
238
  try {
170
239
  const beforeCall = ({ reserved = false } = {}) => {
171
240
  if (!reserved && options.remainingCalls?.() <= 0) throw Object.assign(new Error('Shared project model call limit exceeded'), { code: 'PROJECT_MODEL_CALL_LIMIT' });
172
241
  if (report.modelCalls >= maxCalls) throw new Error('Model call budget exceeded');
173
242
  report.modelCalls++;
174
243
  };
244
+ let activeReader = scope, activeStage = 'primary';
175
245
  const onBudgetLimited = info => {
246
+ if (activeStage !== 'primary') (state.secondaryRetrievalLimits ??= []).push({ phase: activeStage, ...info });
176
247
  state.retrievalLimited = true;
177
248
  (state.retrievalLimits ??= []).push(info);
178
249
  result.limitations.push({ fileId: file.id, text: '检索预算受限,已保留现有结果但不能视为完整审查:' + info.reason });
179
250
  };
180
251
  let pendingStageCorrection;
181
252
  const onProtocolEvent = async info => {
253
+ if (info.action === 'json-transport-recovery') (state.modelRecoveries ??= []).push(info);
182
254
  if (info.action === 'retrieval-protocol-correction') pendingStageCorrection = { action: info.action, code: 'RETRIEVAL_PROTOCOL_ERROR' };
183
255
  await options.onProgress?.({ stage: 'protocol-correction', fileId: file.id, path: file.path, ...info });
184
256
  };
@@ -187,7 +259,8 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
187
259
  // The executor has admitted this call; budget-vetoed retries never reach here.
188
260
  if (pendingStageCorrection) { (state.modelCorrections ??= []).push(pendingStageCorrection); pendingStageCorrection = undefined; }
189
261
  let pendingJsonCorrection;
190
- return model({ ...request, callPurpose: request.callPurpose ?? request.reviewPhase ?? 'primary-initial', onProtocolEvent,
262
+ return model({ ...request, nativeOutputTools: options.nativeOutputTools === true, maxInputBytes: options.nativeOutputTools === true ? maxInputBytes : request.maxInputBytes, callPurpose: request.callPurpose ?? request.reviewPhase ?? 'primary-initial', onProtocolEvent,
263
+ onModelArtifact: retainArtifact ? artifact => retainArtifact({ ...artifact, snapshotId: input.id, fileId: file.id, path: file.path, phase: request.reviewPhase ?? 'primary' }) : undefined,
191
264
  onModelEvent: async info => {
192
265
  if (info.action === 'model-end') {
193
266
  const phase = request.reviewPhase ?? 'primary';
@@ -211,40 +284,27 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
211
284
  });
212
285
  };
213
286
  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 => {
287
+ ? (request, onLimited = () => {}) => retrievalLoop(trackedModel, { ...request, nativeOutputTools: options.nativeOutputTools === true, onProtocolEvent }, activeReader, { correctProtocol:options.correctProtocol===true,maxRounds:maxRetrievalRounds,maxInputBytes, beforeCall, initialRetrieved: request.initialRetrieved ?? [], allowExhaustedInitial: request.allowExhaustedInitial === true, enableSearchSeeds: options.enableSearchSeeds === true && !request.reviewPhase?.includes('verification'), onBudgetLimited: info => { onBudgetLimited(info); onLimited(info); }, onRetrieved: records => {
215
288
  (state.retrievalAudit ??= []).push(...records);
216
289
  }, onTruncated: () => {
217
290
  if (!state.retrievalTruncated) result.limitations.push({ fileId: file.id, text: '检索结果达到数量上限,部分匹配未提供给模型' });
218
291
  state.retrievalTruncated = true;
219
292
  } })
220
293
  : request => { beforeCall(); return trackedModel(request); };
221
- 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
- }
294
+ const grouped = assignedGroup;
295
+ const groupedGeneration = grouped ? (await groupResult({ id: grouped[0] })).get(file.id) : null;
296
+ if (grouped) {
297
+ state.primaryGroupId = grouped[0]; state.primaryMethod = 'group-primary';
298
+ if (groupedGeneration.groupLimits?.length) {
299
+ state.retrievalLimited = true;
300
+ state.groupRetrievalLimits = structuredClone(groupedGeneration.groupLimits);
301
+ result.limitations.push(...state.groupRetrievalLimits.map(limit => ({ fileId: file.id, text: '组主审检索受限:' + limit.code + ';已接受的本成员成果保留,不表示完整审查' })));
247
302
  }
303
+ if (groupedGeneration.groupRisk) (state.riskPlans ??= []).push({ ...groupedGeneration.groupRisk, method: 'inline', scope: 'group-all-edits', groupId: grouped[0], decision: { required: true, reason: 'group-inline-primary' } });
304
+ }
305
+ const chainTimeout = grouped && timeoutMs > 0 ? Math.max(0, timeoutMs - (performance.now() - groupStarted.get(grouped[0]))) : timeoutMs;
306
+ if (grouped && timeoutMs > 0 && chainTimeout <= 0) throw Object.assign(new Error('Model timeout'), { code: 'MODEL_TIMEOUT' });
307
+ await invoke(async enclosingRequest => {
248
308
  const batches = [...(prepared.batches ?? [{ payload, windowIds: state.windowIds ?? [] }])];
249
309
  if (prepared.synthesis?.status === 'ready') batches.push({ payload: prepared.synthesis.payload, windowIds: [], synthesis: true });
250
310
  const matchingInteractionPlans = interactionPlans.filter(p => input.files.filter(f => p.group.fileIds.includes(f.id)).at(-1)?.id === file.id);
@@ -257,9 +317,14 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
257
317
  enclosingRequest.signal.throwIfAborted();
258
318
  const phase=batch.interaction?'file-interaction':batch.synthesis?'window-synthesis':'primary';
259
319
  try{
260
- const admission = scope && options.optimizeReviewCalls === true ? readerAdmission({ synthesis: batch.synthesis === true, interaction: !!batch.interaction, metadata: prepared.metadata }, scope.budget()) : { permitted: true };
320
+ activeStage = phase;
321
+ activeReader = groupedGeneration && !batch.synthesis && !batch.interaction ? scope : stageScope(batch.interaction ? 'interaction:' + batch.interaction.id : batch.synthesis ? 'synthesis:' + file.id : 'primary:' + file.id);
322
+ // Consuming an already validated group member performs no new source/model work.
323
+ const cachedPrimary = groupedGeneration && !batch.synthesis && !batch.interaction;
324
+ const admission = !cachedPrimary && activeReader && options.optimizeReviewCalls === true
325
+ ? readerAdmission({ synthesis: batch.synthesis === true, interaction: !!batch.interaction, metadata: prepared.metadata }, activeReader.budget()) : { permitted: true };
261
326
  if (!admission.permitted) {
262
- state.readerAdmission = { code: admission.code, remaining: scope.budget() };
327
+ state.readerAdmission = { code: admission.code, remaining: activeReader.budget() };
263
328
  if (batch.synthesis || batch.interaction) throw Object.assign(new Error('Required evidence budget unavailable before stage admission'), { code: admission.code });
264
329
  state.reason = '检索预算不足以取得本阶段所需证据,未启动模型;需重新预览补审';
265
330
  state.failureCode = admission.code;
@@ -267,14 +332,14 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
267
332
  continue;
268
333
  }
269
334
  let batchPayload = batch.payload;
270
- const inlineRisk = prepared.inlineRisk === true && batch.round === 1 && !batch.synthesis && !batch.interaction;
335
+ const inlineRisk = !groupedGeneration && prepared.inlineRisk === true && batch.round === 1 && !batch.synthesis && !batch.interaction;
271
336
  const batchInstructions = inlineRisk ? prepared.instructions : instructions;
272
337
  let riskEntry, activeRisks = [];
273
338
  if (inlineRisk) {
274
339
  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
340
  (state.riskPlans ??= []).push(riskEntry);
276
341
  }
277
- if (options.enableRiskPlanning === true && !inlineRisk && !batch.synthesis && batch.round === 1) {
342
+ if (options.enableRiskPlanning === true && (!groupedGeneration || batch.interaction) && !inlineRisk && !batch.synthesis && batch.round === 1) {
278
343
  const reusable = !batch.interaction && riskInput.cacheable;
279
344
  riskEntry = reusable ? primaryRiskPlan : undefined;
280
345
  if (!riskEntry) {
@@ -315,15 +380,15 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
315
380
  }
316
381
  }
317
382
  }
318
- if(report.modelCalls>=maxCalls)throw new Error('Model call budget exceeded before next review pass');
383
+ if(report.modelCalls>=maxCalls && !(groupedGeneration && !batch.synthesis && !batch.interaction))throw new Error('Model call budget exceeded before next review pass');
319
384
  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' };
385
+ 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
386
  let auditStart = state.retrievalAudit?.length ?? 0;
322
387
  let finalResponseOnly = false;
323
388
  const generate = async attempt => {
324
389
  if (!initialInputBudget(attempt, scope, maxInputBytes).fits) throw new Error('Review correction input budget exceeded');
325
390
  const initialRetrieved = scope && options.enablePrimarySeeds === true && maxRetrievalRounds > 0 && !batch.synthesis && !batch.interaction
326
- ? primarySeeds(scope, attempt, { maxInputBytes, signal: attempt.signal }) : [];
391
+ ? primarySeeds(activeReader, attempt, { maxInputBytes, signal: attempt.signal }) : [];
327
392
  return executor({ ...attempt, initialRetrieved }, () => { finalResponseOnly = true; });
328
393
  };
329
394
  const validate = response => {
@@ -347,7 +412,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
347
412
  if (!batch.interaction && state.sourceMode === 'change-windows') validateWindowFindings(generated.findings, batch.synthesis ? prepared.synthesis.windows : JSON.parse(batch.payload).windows, batch.synthesis === true);
348
413
  return generated;
349
414
  };
350
- const generated = await correctedResponse(generate, validate, request, {
415
+ const generated = groupedGeneration && !batch.synthesis && !batch.interaction ? structuredClone(groupedGeneration) : await correctedResponse(generate, validate, request, {
351
416
  enabled: options.correctProtocol === true,
352
417
  canCorrect: () => !finalResponseOnly,
353
418
  onCorrection: async info => {
@@ -368,7 +433,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
368
433
  }
369
434
  delete generated.inlineRisks;
370
435
  }
371
- if (options.enableRiskPlanning === true && !batch.synthesis && batch.round === 1) {
436
+ if (options.enableRiskPlanning === true && (!groupedGeneration || batch.interaction) && !batch.synthesis && batch.round === 1) {
372
437
  const coverage = riskReadCoverage(activeRisks,(state.retrievalAudit??[]).slice(auditStart),scope?.catalog()??[],input.id);
373
438
  (riskEntry.generations ??= []).push({ round: batch.round, windowIds: batch.windowIds, coverage });
374
439
  // Preserve the single-generation field without unioning evidence across windows.
@@ -381,10 +446,12 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
381
446
  result.findings.push(...generated.findings);
382
447
  if (generated.findings.some(f => f.attribution.status === 'missing')) result.limitations.push({ fileId: file.id, text: '部分发现缺少变更归因,仅校验了定位,尚不能确认属于本次回归' });
383
448
  result.limitations.push(...generated.limitations.map(text => ({ fileId: file.id, text })));
449
+ if (!groupedGeneration && !scheduled.slice(batchIndex + 1).some(next => !next.synthesis && !next.interaction)) releaseStage('primary:' + file.id);
384
450
  if (options.enableVerification === true && generated.findings.length) {
385
451
  for (const finding of generated.findings) finding.verification = { status: 'pending', causality: 'unverified' };
386
452
  try {
387
- const verdicts = await verificationLoop(trackedModel, { ...request, onProtocolEvent, reviewPhase: 'verification' }, generated.findings, scope, {
453
+ activeStage = 'verification';
454
+ const verdicts = await verificationLoop(trackedModel, { ...request, onProtocolEvent, reviewPhase: 'verification' }, generated.findings, stageScope('verification:' + file.id), {
388
455
  correctProtocol:options.correctProtocol===true,maxRounds:maxRetrievalRounds,maxInputBytes, beforeCall, onBudgetLimited,
389
456
  onRetrieved: records => { (state.retrievalAudit ??= []).push(...records); },
390
457
  onTruncated: () => { result.limitations.push({ fileId: file.id, text: '复核检索结果达到数量上限' }); },
@@ -394,6 +461,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
394
461
  if (verdicts.some(v => v.verdict === 'supported' && v.regression?.classification === 'uncertain')) result.limitations.push({ fileId: file.id, text: '部分模型支持候选缺少充分前后对照,不能确认由本次变更引入' });
395
462
  if (verdicts.some(v => v.verdict === 'uncertain')) result.limitations.push({ fileId: file.id, text: '部分候选复核仍不确定,不能视为已确认或已排除' });
396
463
  } catch (error) {
464
+ if (error?.code === 'MODEL_ARTIFACT_FAILED') throw error;
397
465
  for (const finding of generated.findings) finding.verification = { status: 'incomplete', causality: 'unverified' };
398
466
  result.limitations.push({ fileId: file.id, text: '候选复核未完成:' + (error?.message ?? String(error)) });
399
467
  request.signal.throwIfAborted();
@@ -413,7 +481,11 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
413
481
  await options.onProgress?.({stage:'window-complete',snapshotId:input.id,fileId:file.id,path:file.path,windowIds:batch.windowIds,round:batch.round,findings:generated.findings,windowCoverage:state.windowCoverage});
414
482
  }catch(error){
415
483
  enclosingRequest.signal.throwIfAborted();
416
- if(phase==='primary'||/budget exceeded/i.test(error.message))throw error;
484
+ if(phase==='primary'||error.code==='MODEL_ARTIFACT_FAILED')throw error;
485
+ if (['Model call budget exceeded', 'Model call budget exceeded before next review pass'].includes(error.message) || error.code === 'PROJECT_MODEL_CALL_LIMIT') {
486
+ result.limitations.push({ fileId: file.id, text: phase + '模型调用额度不足,尚未启动的阶段保留 pending;已完成主审成果保留' });
487
+ break;
488
+ }
417
489
  const failure={phase,status:'failed',code:error.code??'REVIEW_EXECUTION_FAILED',reason:error.message,round:batch.round,...(error.attempts?{attempts:error.attempts}:{}),...(error.diagnostic?{diagnostic:error.diagnostic}:{})};
418
490
  (state.stageFailures??=[]).push(failure);
419
491
  if(batch.synthesis)state.synthesisStatus='failed';
@@ -423,19 +495,20 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
423
495
  await options.onProgress?.({stage:'synthesis-failed',snapshotId:input.id,fileId:file.id,path:file.path,...failure,windowCoverage:state.windowCoverage});
424
496
  }
425
497
  }
426
- }, { instructions, input: payload }, timeoutMs, signal);
498
+ }, { instructions, input: payload }, chainTimeout, signal);
427
499
  if (signal?.aborted) throw signal.reason;
428
500
 
429
501
  state.status = state.retrievalLimited ? 'pending' : 'completed';
430
502
  if (state.retrievalLimited) {
431
- const codes = new Set((state.retrievalLimits ?? []).map(limit => limit.code));
503
+ const codes = new Set([...(state.retrievalLimits ?? []), ...(state.groupRetrievalLimits ?? [])].map(limit => limit.code));
432
504
  state.reason = codes.has('RETRIEVAL_ROUND_BUDGET_EXCEEDED')
433
- ? '单阶段检索轮数上限达到,未完成完整审查;共享读取额度可能仍有余量,已有发现保留,不表示通过'
434
- : '共享读取调用或输出预算受限,未完成完整审查;已有发现保留,不表示通过';
505
+ ? '单阶段检索轮数上限达到,未完成完整审查;读取额度可能仍有余量,已有发现保留,不表示通过'
506
+ : (readerAllocation ? '阶段取证分配或共享读取预算受限,未完成完整审查;其他阶段保留额度不表示本阶段已完成,已有发现保留' : '共享读取调用或输出预算受限,未完成完整审查;已有发现保留,不表示通过');
435
507
  if (codes.size === 1) state.failureCode = [...codes][0];
436
508
  }
437
509
 
438
510
  } catch (error) {
511
+ if (error?.code === 'MODEL_ARTIFACT_FAILED') { checkpointFailed = true; checkpointFailure = error; controller.abort(error); throw error; }
439
512
  state.status = signal?.aborted ? 'cancelled' : error?.code === 'PROJECT_MODEL_CALL_LIMIT' ? 'pending' : 'failed';
440
513
  state.reason = error?.message ?? String(error);
441
514
  if(error?.attempts)state.protocolAttempts=error.attempts;
@@ -445,13 +518,68 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
445
518
  state.failureCode = signal?.aborted ? 'CANCELLED' : ['MODEL_INVALID_JSON','MODEL_INCOMPLETE_OUTPUT','MODEL_TIMEOUT','MODEL_INVALID_READ_REQUEST','MODEL_OUTPUT_LIMIT','MODEL_PROTOCOL_ERROR','MODEL_INVALID_REVIEW_RESPONSE','MODEL_INVALID_FINDING_IDENTITY','MODEL_INVALID_FINDING_SEVERITY','MODEL_MISSING_FINDING_FIELD','MODEL_INVALID_ANCHOR','MODEL_ANCHOR_MISMATCH','READER_CALL_BUDGET_EXCEEDED','READER_OUTPUT_BUDGET_EXCEEDED','RETRIEVAL_ROUND_BUDGET_EXCEEDED','RETRIEVAL_FINAL_RESPONSE_REQUIRED','PROJECT_MODEL_CALL_LIMIT'].includes(error?.code) ? error.code : 'REVIEW_EXECUTION_FAILED';
446
519
  }
447
520
  } finally {
521
+ if (readerAllocation && file.eligibility === 'reviewable' && !options.resumeState?.completed?.some(row => row.fileId === file.id)) {
522
+ if (![...groupPrepared.values()].some(group => group.fileIds.includes(file.id))) releaseStage('primary:' + file.id);
523
+ releaseStage('verification:' + file.id);
524
+ releaseStage('synthesis:' + file.id);
525
+ for (const plan of interactionPlans.filter(plan => input.files.filter(member => plan.group.fileIds.includes(member.id)).at(-1)?.id === file.id)) releaseStage('interaction:' + plan.group.id);
526
+ }
527
+ state.resumeEligible = state.status === 'completed' && !state.stageFailures?.length && !state.reviewPasses?.some(pass => pass.status !== 'completed') && !state.windowCoverage?.pending?.length && !state.retrievalLimited && result.findings.every(f => f.verification?.status === 'completed') && report.interactions.filter(item => item.fileIds?.includes(file.id)).every(item => item.status === 'completed');
448
528
  await options.onProgress?.({stage:'file-complete',snapshotId:input.id,file:state,findings:result.findings});
529
+ await options.onResumeCheckpoint?.({ ...report, files: fileResults.filter(Boolean).map(item => item.state), findings: fileResults.filter(Boolean).flatMap(item => item.findings), limitations: [...report.limitations, ...fileResults.filter(Boolean).flatMap(item => item.limitations)] });
449
530
  }
450
531
  };
451
- // Bootstrap lazy business grouping once before freezing dependency admission.
452
- let first = 0;
453
- if (options.enableBusinessGrouping === true && prepareBusinessGroups(input.files)) {
454
- while (first < input.files.length && !report.businessGrouping && !signal.aborted) await runFile(first++);
532
+ if (options.resumeState?.businessGrouping) report.businessGrouping = structuredClone(options.resumeState.businessGrouping);
533
+ for (const interaction of report.interactions) {
534
+ if (interaction.fileIds.every(id => options.resumeState?.completed?.some(row => row.fileId === id))) Object.assign(interaction, { status: 'completed', reused: true });
535
+ }
536
+ // Freeze grouping before admitting file chains, without serializing a whole first file.
537
+ if (options.enableBusinessGrouping === true && prepareBusinessGroups(input.files) && !signal.aborted) {
538
+ try {
539
+ const local = report.businessGrouping?.groups ? report.businessGrouping : localBusinessGroups(input.files);
540
+ const grouped = local ?? await invoke(async request => {
541
+ const groupInput = prepareBusinessGroups(input.files);
542
+ 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');
543
+ reserveShared();
544
+ return validateBusinessGroups(await sharedModel({ ...request, reviewPhase: 'business-grouping', callPurpose: 'business-grouping' }), input.files);
545
+ }, { input: prepareBusinessGroups(input.files), instructions: BUSINESS_GROUP_INSTRUCTIONS }, timeoutMs, signal);
546
+ report.businessGrouping = { status: 'completed', ...grouped };
547
+ for (const group of grouped.groups.filter(g => g.fileIds.length > 1)) {
548
+ if (interactionPlans.some(p => [...p.group.fileIds].sort().join(',') === [...group.fileIds].sort().join(','))) continue;
549
+ const prepared = prepareInteractionInput(input, group, payload => initialInputBudget({ instructions, input: payload }, scope, maxInputBytes));
550
+ interactionPlans.push({ group, prepared });
551
+ 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' });
552
+ }
553
+ } catch (error) {
554
+ if (signal.aborted || checkpointFailed || error.code === 'MODEL_ARTIFACT_FAILED') throw error;
555
+ report.businessGrouping = { status: 'fallback', reason: error.message };
556
+ report.limitations.push({ text: '业务分组未完成,保留原分组:' + error.message });
557
+ }
558
+ }
559
+ if (options.enableGroupPrimary === true && scope && rounds === 1) {
560
+ const seen = new Set();
561
+ const groups = report.businessGrouping?.groups ?? report.grouping?.groups ?? [];
562
+ for (const group of groups) {
563
+ if (group.fileIds.some(id => seen.has(id) || options.resumeState?.completed?.some(row => row.fileId === id))) continue;
564
+ const prepared = prepareGroupPrimary(input, group, { instructions, scope, maxInputBytes, focusPrimaryContext: options.focusPrimaryContext === true, enableRiskPlanning: options.enableRiskPlanning === true });
565
+ if (!prepared) continue;
566
+ groupPrepared.set(group.id, prepared);
567
+ for (const id of group.fileIds) seen.add(id);
568
+ }
569
+ report.primaryGroups = [...groupPrepared].map(([groupId, p]) => ({ groupId, fileIds: p.fileIds, mode: 'group-primary' }));
570
+ }
571
+ if (scope && options.optimizeReviewCalls === true) {
572
+ const units = [];
573
+ const reusable = new Set((options.resumeState?.completed ?? []).map(row => row.fileId));
574
+ for (const [id, prepared] of groupPrepared) units.push({ id: 'group:' + id, weight: prepared.fileIds.length * 4 });
575
+ for (const file of input.files.filter(file => file.eligibility === 'reviewable' && !reusable.has(file.id))) {
576
+ const prepared = preparePrimaryInput(input, file, { instructions, scope, grouping: report.grouping, maxInputBytes, riskPlanningMode: options.riskPlanningMode, focusPrimaryContext: options.focusPrimaryContext === true, enableRiskPlanning: options.enableRiskPlanning, skipSmallRiskPlans: options.skipSmallRiskPlans });
577
+ if (![...groupPrepared.values()].some(group => group.fileIds.includes(file.id))) units.push({ id: 'primary:' + file.id, weight: 4 * Math.max(1, prepared.batches?.length ?? 1) });
578
+ if (prepared.synthesis?.status === 'ready') units.push({ id: 'synthesis:' + file.id, weight: 2 });
579
+ if (options.enableVerification === true) units.push({ id: 'verification:' + file.id, weight: 2 });
580
+ }
581
+ for (const plan of interactionPlans.filter(plan => plan.prepared.status === 'ready' && !plan.group.fileIds.every(id => reusable.has(id)))) units.push({ id: 'interaction:' + plan.group.id, weight: 2 });
582
+ if (units.length) readerAllocation = createReviewReaderAllocation(scope, units);
455
583
  }
456
584
  const dependencies = new Map();
457
585
  for (const plan of interactionPlans.filter(p => p.prepared.status === 'ready')) {
@@ -459,7 +587,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
459
587
  const owner = members.at(-1);
460
588
  if (owner !== undefined) dependencies.set(owner, [...new Set([...(dependencies.get(owner) ?? []), ...members.slice(0, -1)])]);
461
589
  }
462
- await scheduleFileChains(input.files.map((_, index) => index).slice(first), async index => {
590
+ await scheduleFileChains(input.files.map((_, index) => index), async index => {
463
591
  try { await runFile(index); }
464
592
  catch (error) { controller.abort(error); throw error; }
465
593
  }, dependencies, fileConcurrency);
@@ -485,8 +613,11 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
485
613
  }
486
614
  report.regressionCoverage = Object.fromEntries(['introduced', 'preexisting', 'uncertain', 'unassessed'].map(classification => [classification, report.findings.filter(f => (f.verification?.regression?.classification ?? 'unassessed') === classification).length]));
487
615
  report.findingGroups = groupDuplicateFindings(report.findings);
616
+ 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');
617
+ await options.onResumeCheckpoint?.(report);
488
618
  report.followup = coverageFollowup(report);
489
619
  if (scope) report.readerBudget = { ...scope.usage(), ...scope.budget(), ...readerLimits };
620
+ if (readerAllocation) report.readerAllocation = readerAllocation.summary();
490
621
  report.diagnostics = reviewDiagnostics(report, { reviewWallMs: performance.now() - startedAt });
491
622
  return report;
492
623
  }
@@ -518,7 +649,7 @@ function renderFinding(output, finding, index, duplicates) {
518
649
  const verification = finding.verification;
519
650
  if (verification) {
520
651
  const label = verification.status !== 'completed' ? '未完成' : ({ supported: '模型复核支持', refuted: '模型复核反驳(保留候选供追溯)', uncertain: '不确定' }[verification.verdict] ?? '未知');
521
- output.push('- 复核状态:' + label + ';不是事实或因果证明');
652
+ output.push('- 复核状态:' + label + (finding.reused ? '(相同快照的既往轮次结果,本次未重新取证)' : '') + ';不是事实或因果证明');
522
653
  if (verification.reason) output.push('- 复核理由:' + safe(verification.reason));
523
654
  for (const ref of verification.evidence ?? []) output.push('- 复核证据:' + safe(ref.sourceId) + ':' + ref.start + '(' + ref.count + '行) · SHA256 ' + safe(ref.hash) + ' · ' + safe(ref.text));
524
655
  }
@@ -551,7 +682,7 @@ export function markdownReport(report) {
551
682
  }
552
683
  for (const file of report.files) if (file.windowCoverage) output.push('- ' + safe(file.path) + ':变更窗口已完成 ' + file.windowCoverage.completed.length + ',待审 ' + file.windowCoverage.pending.length + ';仅表示窗口分析完成,不证明全文正确');
553
684
  for (const file of report.files) if (file.synthesisStatus) output.push('- ' + safe(file.path) + ':跨窗口综合 ' + safe(file.synthesisStatus) + ';不等于事实正确性证明');
554
- for (const interaction of report.interactions ?? []) output.push('- 跨文件综合 ' + safe(interaction.groupId) + ':' + safe(interaction.status) + ';共享组末主文件超时,不证明完整交互覆盖');
685
+ for (const interaction of report.interactions ?? []) output.push('- 跨文件综合 ' + safe(interaction.groupId) + ':' + safe(interaction.status) + (interaction.failureCode ? ' · ' + safe(interaction.failureCode) : '') + (interaction.reason ? ' — ' + safe(interaction.reason) : '') + ';与组末主文件共享生命周期,不证明完整交互覆盖');
555
686
  if (report.rules?.length) output.push('', '## 项目规则', '规则仅为已批准约束数据,不扩大权限或证明缺陷。', ...report.rules.map(rule => '- ' + safe(rule.path) + ' · SHA256 ' + safe(rule.hash)));
556
687
  if (report.followup?.suggestedPaths.length) output.push('', '## 后续选择建议', '仅为原快照未完成项;不会自动续审。再次执行需新预览、新审批,并显式选择规则和上下文。阻断项需先处理原因;关联上下文不等于主文件已审查。', ...report.followup.items.filter(item => item.followup).map(item => '- ' + safe(item.path) + ':' + item.status + (item.incompleteVerification ? '(候选复核未完成)' : '')));
557
688
  output.push('', '## 审查发现');
@@ -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, ...(optimizeReviewCalls ? { reuseResults: true } : {}) }), { 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
- execution: { 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, 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 } : {}),
@@ -127,7 +141,7 @@ function createSessionReviewService(llm, { capture = captureSnapshot, now = Date
127
141
  ...(snapshot.callerDiscovery ? { callerDiscovery: snapshot.callerDiscovery } : {}),
128
142
  ...(snapshot.autoContext ? { autoContext: snapshot.autoContext } : {}),
129
143
  rules: (snapshot.rules ?? []).map(({ path, hash }) => ({ path, hash })),
130
- notice: buildPreviewNotice(snapshot, options, enableRiskPlanning, changesConcurrency, maxRetrievalRounds) + (optimizeReviewCalls ? ` 完整正文或覆盖全部精确编辑的单批窗口可将风险问题合并入主审(非独立规划);仅对批准上下文的确定坐标预取小片段,不代表完整核查。${focusPrimaryContext ? " 主审优先保留主文件全文,仅附带有关联的有界完整上下文;其余批准来源保留目录按需读,不表示不存在或已核查。" : ""}${enableSearchSeeds ? " 可将模型搜索命中的批准原行小片段与搜索结果同轮提供,每阶段最多2次额外读取,共用原预算;不猜测另侧坐标,不增加检索轮次。" : ""}共享检索上限按本次范围计算:${readerBudget.maxCalls}次操作/${readerBudget.maxOutputBytes}字节,所有阶段共用、不逐文件重置;无法取得必要证据的阶段不再无效启动。` : ''),
144
+ notice: buildPreviewNotice(snapshot, options, enableRiskPlanning, changesConcurrency, maxRetrievalRounds) + (optimizeReviewCalls ? ` 完整正文或覆盖全部精确编辑的单批窗口可将风险问题合并入主审(非独立规划);仅对批准上下文的确定坐标预取小片段,不代表完整核查。${focusPrimaryContext ? " 主审优先保留主文件全文,仅附带有关联的有界完整上下文;其余批准来源保留目录按需读,不表示不存在或已核查。" : ""}${enableSearchSeeds ? " 可将模型搜索命中的批准原行小片段与搜索结果同轮提供,每阶段最多2次额外读取,共用原预算;不猜测另侧坐标,不增加检索轮次。" : ""}共享检索上限按本次范围计算:${readerBudget.maxCalls}次唯一操作结果生成/${readerBudget.maxOutputBytes}交付字节,所有阶段共用、不逐文件重置;同快照相同请求可复用原始结果,每次交付仍计字节并重新签发复核回执;主审、综合及复核按权重预留最低额度,未用额度释放,额度不足仍如实标记未完成。` : ''),
131
145
  };
132
146
  } finally { busy = false; }
133
147
  },
@@ -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;