@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.
@@ -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
- const result = { state, findings: [], limitations: [] };
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
- if (report.modelCalls >= maxCalls || options.remainingCalls?.() <= 0) { state.reason = '模型调用预算耗尽'; return; }
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'||/budget exceeded/i.test(error.message))throw error;
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 }, timeoutMs, signal);
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
- // 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++);
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).slice(first), async 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
  }
@@ -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
- 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: 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 });