@feng0531/dsh-code-review 0.86.1 → 0.87.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/CORE-ALIGNMENT-TASKS.md +17 -3
- package/CORE-SOURCE-INVENTORY.md +6 -6
- package/DEVELOPMENT.md +15 -1
- package/README.md +20 -3
- package/RELEASE-NOTES.md +15 -11
- package/index.mjs +1 -1
- package/package.json +1 -1
- package/src/candidate-verification.mjs +1 -1
- package/src/coverage-plan.mjs +20 -6
- package/src/cpp-migration-bindings.mjs +149 -0
- package/src/evidence-context.mjs +96 -0
- package/src/group-interaction.mjs +48 -0
- package/src/group-primary.mjs +14 -3
- package/src/interaction-risk.mjs +71 -0
- package/src/migration-contracts.mjs +160 -0
- package/src/migration-regression.mjs +103 -0
- package/src/model-contract.mjs +3 -3
- package/src/model-tools.mjs +6 -4
- package/src/prompts.mjs +1 -0
- package/src/regression-assessment.mjs +8 -2
- package/src/report-summary.mjs +59 -0
- package/src/retrieval-loop.mjs +16 -3
- package/src/review-reader-allocation.mjs +46 -4
- package/src/review-resume.mjs +67 -2
- package/src/review-stage-state.mjs +143 -0
- package/src/review.mjs +169 -42
- package/src/service.mjs +7 -7
- package/src/stage-continuation.mjs +74 -0
- package/src/verification-loop.mjs +9 -4
package/src/review.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import { inferFileRelations } from './file-relations.mjs';
|
|
|
19
19
|
import { buildReviewGroups } from './review-groups.mjs';
|
|
20
20
|
import { changeMap } from './change-map.mjs';
|
|
21
21
|
import { createRetrievalScope } from './retrieval-scope.mjs';
|
|
22
|
-
import { retrievalLoop } from './retrieval-loop.mjs';
|
|
22
|
+
import { retrievalLoop, retrievalRequest } from './retrieval-loop.mjs';
|
|
23
23
|
import { reviewDiagnostics, diagnosticsMarkdown } from './review-diagnostics.mjs';
|
|
24
24
|
import { preparePrimaryInput } from './primary-input.mjs';
|
|
25
25
|
import { validateAttribution } from './attribution.mjs';
|
|
@@ -32,6 +32,13 @@ import { primarySeeds } from './primary-seeds.mjs';
|
|
|
32
32
|
import { createReviewReaderAllocation } from './review-reader-allocation.mjs';
|
|
33
33
|
import { prepareGroupPrimary, validateGroupPrimary } from './group-primary.mjs';
|
|
34
34
|
import { configureWireBudget, modelWireBudget } from './model-wire-budget.mjs';
|
|
35
|
+
import { createEvidenceContext } from './evidence-context.mjs';
|
|
36
|
+
import { prepareInteractionRisk, consumeInteractionRisks } from './interaction-risk.mjs';
|
|
37
|
+
import { reportSummaryMarkdown } from './report-summary.mjs';
|
|
38
|
+
import { validateGroupInteraction } from './group-interaction.mjs';
|
|
39
|
+
import { stageKey, prepareStageGenerations } from './review-stage-state.mjs';
|
|
40
|
+
import { createStageContinuation } from './stage-continuation.mjs';
|
|
41
|
+
import { buildMigrationContracts } from './migration-contracts.mjs';
|
|
35
42
|
|
|
36
43
|
export { REVIEW_INSTRUCTIONS };
|
|
37
44
|
export { PROJECT_REVIEW_INSTRUCTIONS };
|
|
@@ -124,6 +131,8 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
124
131
|
}
|
|
125
132
|
if (options.enableBusinessGrouping === true && options.enableRetrieval !== true) throw new Error('Business grouping requires approved retrieval scope');
|
|
126
133
|
if (options.enableVerification === true && options.enableRetrieval !== true) throw new Error('Verification requires approved retrieval scope');
|
|
134
|
+
for (const key of ['enableEvidenceContext', 'inlineInteractionRisks', 'enableMigrationContracts', 'enableMigrationRegression', 'enableGroupInteractions', 'enableStageContinuation']) if (options[key] !== undefined && typeof options[key] !== 'boolean') throw new Error('Invalid review optimization option');
|
|
135
|
+
const evidenceContext = options.enableEvidenceContext === true ? createEvidenceContext(input.id) : null;
|
|
127
136
|
const readerLimits = reviewReaderBudget(input, options.readerBudgetPolicy ?? 'fixed');
|
|
128
137
|
const scope = options.enableRetrieval === true ? createRetrievalScope({ ...input, context: input.context ?? [] }, { ...readerLimits, signal, ...(options.optimizeReviewCalls === true ? { reuseResults: true } : {}) }) : null;
|
|
129
138
|
const report = { schemaVersion: 1, snapshotId: input.id, vcs: input.vcs, status: 'completed', files: [], findings: [], limitations: [], modelCalls: 0 };
|
|
@@ -146,9 +155,14 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
146
155
|
if(input.effectiveMergeSources)report.effectiveMergeSources=structuredClone(input.effectiveMergeSources);
|
|
147
156
|
report.rules = (input.rules ?? []).map(({ path, hash }) => ({ path, hash }));
|
|
148
157
|
if (scope) configureWireBudget(scope, { nativeOutputTools: options.nativeOutputTools === true });
|
|
149
|
-
if (options.
|
|
158
|
+
if (options.enableMigrationContracts === true && scope && !input.projectScope) {
|
|
159
|
+
report.migrationContracts = buildMigrationContracts(input);
|
|
160
|
+
}
|
|
161
|
+
if (options.enableGrouping === true || report.migrationContracts?.contracts.length) report.grouping = buildReviewGroups(input.files, [...inferFileRelations(input.files), ...(report.migrationContracts?.links ?? []).map(link => ({ from: link.from, to: link.to, reason: link.reasons?.join(',') ?? 'migration-contract' }))]);
|
|
150
162
|
report.interactions = [];
|
|
151
|
-
const
|
|
163
|
+
const selectedGroups = (report.grouping?.groups ?? []).filter(group => group.fileIds.length > 1);
|
|
164
|
+
if (report.migrationContracts) report.migrationContracts.groups = selectedGroups.filter(group => report.migrationContracts.groups.some(other => [...other.fileIds].sort().join(',') === [...group.fileIds].sort().join(',')));
|
|
165
|
+
const interactionPlans = scope ? selectedGroups.map(group => ({ group, prepared: prepareInteractionInput(input, group, payload => initialInputBudget({ instructions, input: payload }, scope, maxInputBytes)) })) : [];
|
|
152
166
|
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' });
|
|
153
167
|
const fileResults = new Array(input.files.length);
|
|
154
168
|
const sharedState = report.orchestration = { modelMetrics: {}, modelCallPurposes: {} };
|
|
@@ -174,23 +188,45 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
174
188
|
await options.onProgress?.({ stage: info.action, snapshotId: input.id, phase: request.reviewPhase, ...info });
|
|
175
189
|
} });
|
|
176
190
|
};
|
|
177
|
-
const groupPrepared = new Map(), groupResults = new Map(), groupStarted = new Map();
|
|
191
|
+
const groupPrepared = new Map(), groupResults = new Map(), groupStarted = new Map(), groupInteractions = new Map();
|
|
192
|
+
const acceptedStages = new Map(prepareStageGenerations(input, options.resumeState?.stageGenerations ?? []).filter(record => record.phase === 'primary').map(record => [record.key, { generation: record.generation, record, restored: true }]));
|
|
193
|
+
const deferredStages = [], yieldedUnits = new Set(), fileDeadlines = new Map(), primaryStageKeys = new Map();
|
|
194
|
+
const checkpointStages = () => {
|
|
195
|
+
if (!options.enableStageContinuation) return;
|
|
196
|
+
report.stageGenerations = prepareStageGenerations(input, [...acceptedStages.values()].map(entry => entry.record).filter(record => record?.phase === 'primary'));
|
|
197
|
+
};
|
|
178
198
|
let readerAllocation;
|
|
179
199
|
const stageScope = id => readerAllocation
|
|
180
200
|
? configureWireBudget(readerAllocation.scope(id), { nativeOutputTools: options.nativeOutputTools === true }) : scope;
|
|
181
|
-
const releaseStage = id => readerAllocation?.release(id);
|
|
201
|
+
const releaseStage = id => { if (!yieldedUnits.has(id)) readerAllocation?.release(id); };
|
|
202
|
+
const contextSeeds = (reader, request, existing = [], buildRequest = records => retrievalRequest(request, reader, records)) => {
|
|
203
|
+
if (!evidenceContext || !reader || maxRetrievalRounds === 0) return [];
|
|
204
|
+
const payload = JSON.parse(request.input), catalog = reader.catalog();
|
|
205
|
+
const members = [payload, ...(payload.reviews ?? [])];
|
|
206
|
+
const fileIds = new Set(members.flatMap(member => [member.file?.id, ...(member.files ?? []).map(item => item.fileId), ...(member.relatedFiles ?? []).map(item => item.id)]).filter(Boolean));
|
|
207
|
+
const paths = new Set(members.flatMap(member => (member.contextRelations ?? []).flatMap(item => [item.fromPath, item.toPath])).filter(Boolean));
|
|
208
|
+
for (const caller of members.flatMap(member => member.callerBindings ?? [])) if (caller.path) paths.add(caller.path);
|
|
209
|
+
const riskIds = new Set(members.flatMap(member => (member.riskPlan ?? []).flatMap(item => item.sourceIds ?? [])));
|
|
210
|
+
const sourceIds = catalog.filter(source => fileIds.has(source.fileId) || paths.has(source.path) || riskIds.has(source.id)).map(source => source.id).slice(0, 200);
|
|
211
|
+
return evidenceContext.seed(reader, request, { sourceIds, existing, maxInputBytes, buildRequest });
|
|
212
|
+
};
|
|
182
213
|
const groupResult = async group => {
|
|
183
214
|
if (!groupResults.has(group.id)) {
|
|
184
215
|
groupStarted.set(group.id, performance.now());
|
|
185
216
|
groupResults.set(group.id, invoke(async request => {
|
|
186
217
|
const prepared = groupPrepared.get(group.id), groupScope = stageScope('group:' + group.id); const limits = [], reads = [];
|
|
187
|
-
const
|
|
218
|
+
const groupRequest = { ...request, nativeOutputTools: options.nativeOutputTools === true, input: prepared.payload, instructions: prepared.instructions, reviewPhase: 'group-primary' };
|
|
219
|
+
const response = await retrievalLoop(sharedModel, groupRequest, groupScope, {
|
|
220
|
+
initialContext: (existing, build) => contextSeeds(groupScope, groupRequest, existing, build),
|
|
188
221
|
maxRounds: maxRetrievalRounds, maxInputBytes, beforeCall: reserveShared, correctProtocol: options.correctProtocol === true,
|
|
189
222
|
enableSearchSeeds: options.enableSearchSeeds === true,
|
|
190
|
-
onRetrieved: records => { reads.push(...records); (sharedState.retrievalAudit ??= []).push(...records); },
|
|
223
|
+
onRetrieved: records => { reads.push(...records); (sharedState.retrievalAudit ??= []).push(...records); evidenceContext?.remember(records); },
|
|
191
224
|
onBudgetLimited: info => { limits.push(info); (sharedState.retrievalLimits ??= []).push(info); },
|
|
192
225
|
});
|
|
193
226
|
const results = validateGroupPrimary(response, prepared, input, validateResponse, scope.catalog());
|
|
227
|
+
const interaction = validateGroupInteraction(response, prepared, input, reads, scope.catalog());
|
|
228
|
+
groupInteractions.set(group.id, interaction);
|
|
229
|
+
if (interaction.status !== 'disabled') (report.groupInteractionChannels ??= []).push({ groupId: group.id, status: interaction.status, reason: interaction.reason });
|
|
194
230
|
for (const generated of results.values()) {
|
|
195
231
|
generated.groupLimits = structuredClone(limits);
|
|
196
232
|
if (generated.groupRisk) {
|
|
@@ -284,8 +320,9 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
284
320
|
});
|
|
285
321
|
};
|
|
286
322
|
const executor = scope
|
|
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 => {
|
|
323
|
+
? (request, onLimited = () => {}) => retrievalLoop(trackedModel, { ...request, nativeOutputTools: options.nativeOutputTools === true, onProtocolEvent }, activeReader, { correctProtocol:options.correctProtocol===true,maxRounds:maxRetrievalRounds,maxInputBytes, beforeCall, initialRetrieved: request.initialRetrieved ?? [], yieldOnBudget: options.enableStageContinuation === true && activeStage !== 'primary', initialContext: (existing, build) => contextSeeds(activeReader, request, existing, build), allowExhaustedInitial: request.allowExhaustedInitial === true, enableSearchSeeds: options.enableSearchSeeds === true && !request.reviewPhase?.includes('verification'), onBudgetLimited: info => { onBudgetLimited(info); onLimited(info); }, onRetrieved: records => {
|
|
288
324
|
(state.retrievalAudit ??= []).push(...records);
|
|
325
|
+
evidenceContext?.remember(records);
|
|
289
326
|
}, onTruncated: () => {
|
|
290
327
|
if (!state.retrievalTruncated) result.limitations.push({ fileId: file.id, text: '检索结果达到数量上限,部分匹配未提供给模型' });
|
|
291
328
|
state.retrievalTruncated = true;
|
|
@@ -304,42 +341,74 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
304
341
|
}
|
|
305
342
|
const chainTimeout = grouped && timeoutMs > 0 ? Math.max(0, timeoutMs - (performance.now() - groupStarted.get(grouped[0]))) : timeoutMs;
|
|
306
343
|
if (grouped && timeoutMs > 0 && chainTimeout <= 0) throw Object.assign(new Error('Model timeout'), { code: 'MODEL_TIMEOUT' });
|
|
344
|
+
if (!fileDeadlines.has(file.id)) fileDeadlines.set(file.id, timeoutMs > 0 ? performance.now() + chainTimeout : Infinity);
|
|
307
345
|
await invoke(async enclosingRequest => {
|
|
308
346
|
const batches = [...(prepared.batches ?? [{ payload, windowIds: state.windowIds ?? [] }])];
|
|
309
347
|
if (prepared.synthesis?.status === 'ready') batches.push({ payload: prepared.synthesis.payload, windowIds: [], synthesis: true });
|
|
310
348
|
const matchingInteractionPlans = interactionPlans.filter(p => input.files.filter(f => p.group.fileIds.includes(f.id)).at(-1)?.id === file.id);
|
|
311
349
|
for (const interactionPlan of matchingInteractionPlans.filter(p => p.prepared.status === 'ready')) batches.push({ payload: interactionPlan.prepared.payload, windowIds: [], interaction: interactionPlan.group });
|
|
312
350
|
const scheduled = Array.from({length:rounds},(_,i)=>batches.map(b=>({...b,round:i+1}))).flat();
|
|
351
|
+
primaryStageKeys.set(file.id, scheduled.filter(batch => !batch.synthesis && !batch.interaction).map(batch => stageKey(file.id, { phase: 'primary', round: batch.round, windowIds: batch.windowIds ?? [] })));
|
|
313
352
|
if(rounds>1) state.reviewPasses=scheduled.map(b=>({round:b.round,windowIds:b.windowIds,...(b.interaction?{groupId:b.interaction.id}:{}),...(b.synthesis?{synthesis:true}:{}),status:'pending'}));
|
|
314
353
|
const riskInput = canonicalRiskInput(input, file, prepared, { instructions: RISK_PLAN_INSTRUCTIONS, scope, maxInputBytes });
|
|
315
354
|
let primaryRiskPlan;
|
|
316
|
-
|
|
355
|
+
const executeStage = async (batch, batchIndex, enclosingRequest, retry = false) => {
|
|
317
356
|
enclosingRequest.signal.throwIfAborted();
|
|
357
|
+
const key = stageKey(file.id, { phase: batch.interaction ? 'file-interaction' : batch.synthesis ? 'window-synthesis' : 'primary', round: batch.round, windowIds: batch.windowIds ?? [], ...(batch.interaction ? { groupId: batch.interaction.id } : {}) });
|
|
358
|
+
const priorStage = acceptedStages.get(key);
|
|
359
|
+
if (retry && priorStage?.completed) return;
|
|
360
|
+
const allocationId = batch.interaction ? 'interaction:' + batch.interaction.id : batch.synthesis ? 'synthesis:' + file.id : 'primary:' + file.id;
|
|
361
|
+
const defer = (error, unitId = allocationId) => {
|
|
362
|
+
if (!options.enableStageContinuation || !readerAllocation || !['READER_CALL_BUDGET_EXCEEDED', 'READER_OUTPUT_BUDGET_EXCEEDED'].includes(error?.code)) return false;
|
|
363
|
+
if (!yieldedUnits.has(unitId)) { readerAllocation.yield(unitId, { reason: error.code }); yieldedUnits.add(unitId); }
|
|
364
|
+
let pending = deferredStages.find(item => item.key === key);
|
|
365
|
+
if (pending && pending.allocationId !== unitId) { const previousUnit = pending.allocationId; pending.allocationId = unitId; if (!deferredStages.some(other => other !== pending && !other.done && other.allocationId === previousUnit)) { yieldedUnits.delete(previousUnit); readerAllocation.release(previousUnit); } }
|
|
366
|
+
if (!pending) {
|
|
367
|
+
pending = { key, allocationId: unitId, fileId: file.id, phase: unitId.startsWith('verification:') ? 'verification' : phase === 'window-synthesis' ? 'synthesis' : 'interaction', fileIds: batch.interaction?.fileIds ?? [file.id], attempts: 0, run: () => invoke(next => executeStage(batch, batchIndex, next, true), { instructions, input: payload }, timeoutMs > 0 ? Math.max(1, fileDeadlines.get(file.id) - performance.now()) : 0, signal) };
|
|
368
|
+
deferredStages.push(pending);
|
|
369
|
+
}
|
|
370
|
+
pending.requests = error.pendingRequests ?? []; pending.reason = error.code; pending.done = false;
|
|
371
|
+
state.continuationPending = true;
|
|
372
|
+
if (batch.interaction) Object.assign(report.interactions.find(item => item.groupId === batch.interaction.id), { status: 'pending', reason: error.code });
|
|
373
|
+
if (batch.synthesis) state.synthesisStatus = 'pending';
|
|
374
|
+
return true;
|
|
375
|
+
};
|
|
318
376
|
const phase=batch.interaction?'file-interaction':batch.synthesis?'window-synthesis':'primary';
|
|
319
377
|
try{
|
|
320
378
|
activeStage = phase;
|
|
321
|
-
|
|
379
|
+
if (!priorStage && yieldedUnits.has(allocationId)) { defer(Object.assign(new Error('Stage is waiting for released reader capacity'), { code: 'READER_CALL_BUDGET_EXCEEDED' })); return; }
|
|
380
|
+
activeReader = priorStage || groupedGeneration && !batch.synthesis && !batch.interaction ? scope : stageScope(allocationId);
|
|
322
381
|
// Consuming an already validated group member performs no new source/model work.
|
|
323
|
-
const
|
|
324
|
-
const
|
|
382
|
+
const combinedInteraction = batch.interaction && batch.round === 1 ? [...groupInteractions].find(([id, channel]) => channel.status === 'accepted' && JSON.stringify([...groupPrepared.get(id).fileIds].sort()) === JSON.stringify([...batch.interaction.fileIds].sort()))?.[1] : null;
|
|
383
|
+
const cachedPrimary = !!priorStage || groupedGeneration && !batch.synthesis && !batch.interaction;
|
|
384
|
+
const admission = !cachedPrimary && !combinedInteraction && activeReader && options.optimizeReviewCalls === true
|
|
325
385
|
? readerAdmission({ synthesis: batch.synthesis === true, interaction: !!batch.interaction, metadata: prepared.metadata }, activeReader.budget()) : { permitted: true };
|
|
326
386
|
if (!admission.permitted) {
|
|
387
|
+
if ((batch.synthesis || batch.interaction) && defer(Object.assign(new Error('Stage reader allocation unavailable'), { code: admission.code }))) return;
|
|
327
388
|
state.readerAdmission = { code: admission.code, remaining: activeReader.budget() };
|
|
328
389
|
if (batch.synthesis || batch.interaction) throw Object.assign(new Error('Required evidence budget unavailable before stage admission'), { code: admission.code });
|
|
329
390
|
state.reason = '检索预算不足以取得本阶段所需证据,未启动模型;需重新预览补审';
|
|
330
391
|
state.failureCode = admission.code;
|
|
331
392
|
state.retrievalLimited = true;
|
|
332
|
-
|
|
393
|
+
return;
|
|
333
394
|
}
|
|
334
395
|
let batchPayload = batch.payload;
|
|
335
|
-
const
|
|
336
|
-
|
|
396
|
+
const relevantContracts = report.migrationContracts?.contracts.filter(contract => contract.candidateFileIds.includes(file.id) || batch.interaction?.fileIds.some(id => contract.candidateFileIds.includes(id))) ?? [];
|
|
397
|
+
if (relevantContracts.length) {
|
|
398
|
+
const enriched = JSON.stringify({ ...JSON.parse(batchPayload), migrationContracts: relevantContracts, migrationNotice: 'Host navigation hypotheses only; old/new entries and callers are side-qualified. Follow the complete old public gate → new entry → caller chain under the SAME trigger. Contracts do not prove semantic migration, grant sources, or replace current independent verification.' });
|
|
399
|
+
if (initialInputBudget({ input: enriched, instructions }, activeReader, maxInputBytes).fits) batchPayload = enriched;
|
|
400
|
+
else (state.migrationContextLimitations ??= []).push('Migration navigation did not fit this stage; no coverage claim.');
|
|
401
|
+
}
|
|
402
|
+
const interactionStrategy = batch.interaction && !combinedInteraction ? prepareInteractionRisk(input, batch.interaction, matchingInteractionPlans.find(plan => plan.group.id === batch.interaction.id).prepared, { ...options, instructions, scope: activeReader, maxInputBytes, round: batch.round }) : null;
|
|
403
|
+
const inlineInteraction = !priorStage && interactionStrategy?.mode === 'inline';
|
|
404
|
+
const inlineRisk = !priorStage && (inlineInteraction || (!groupedGeneration && prepared.inlineRisk === true && batch.round === 1 && !batch.synthesis && !batch.interaction));
|
|
405
|
+
const batchInstructions = inlineInteraction ? interactionStrategy.instructions : inlineRisk ? prepared.instructions : instructions;
|
|
337
406
|
let riskEntry, activeRisks = [];
|
|
338
407
|
if (inlineRisk) {
|
|
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' } };
|
|
408
|
+
riskEntry = { status: 'pending', method: 'inline', windowIds: batch.windowIds, scope: inlineInteraction ? 'group-all-edits' : state.sourceMode === 'change-windows' ? 'all-edits' : 'full-file', ...(inlineInteraction ? { groupId: batch.interaction.id, sourceMode: 'file-interaction' } : {}), risks: [], decision: { required: true, reason: inlineInteraction ? 'inline-interaction' : 'inline-primary' } };
|
|
340
409
|
(state.riskPlans ??= []).push(riskEntry);
|
|
341
410
|
}
|
|
342
|
-
if (options.enableRiskPlanning === true && (!groupedGeneration || batch.interaction) && !inlineRisk && !batch.synthesis && batch.round === 1) {
|
|
411
|
+
if (options.enableRiskPlanning === true && (!groupedGeneration || batch.interaction) && !inlineRisk && !combinedInteraction && !priorStage && !batch.synthesis && batch.round === 1) {
|
|
343
412
|
const reusable = !batch.interaction && riskInput.cacheable;
|
|
344
413
|
riskEntry = reusable ? primaryRiskPlan : undefined;
|
|
345
414
|
if (!riskEntry) {
|
|
@@ -380,7 +449,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
380
449
|
}
|
|
381
450
|
}
|
|
382
451
|
}
|
|
383
|
-
if(report.modelCalls>=maxCalls && !(groupedGeneration && !batch.synthesis && !batch.interaction))throw new Error('Model call budget exceeded before next review pass');
|
|
452
|
+
if(report.modelCalls>=maxCalls && !priorStage && !combinedInteraction && !(groupedGeneration && !batch.synthesis && !batch.interaction))throw new Error('Model call budget exceeded before next review pass');
|
|
384
453
|
if(rounds>1) state.reviewPasses[batchIndex].status='running';
|
|
385
454
|
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' };
|
|
386
455
|
let auditStart = state.retrievalAudit?.length ?? 0;
|
|
@@ -406,13 +475,15 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
406
475
|
response = decoded;
|
|
407
476
|
}
|
|
408
477
|
}
|
|
409
|
-
const
|
|
410
|
-
|
|
478
|
+
const interactionResult = inlineInteraction ? consumeInteractionRisks(response, input, batch.interaction, scope.catalog()) : null;
|
|
479
|
+
const generated = batch.interaction ? validateInteractionFindings(interactionResult?.response ?? response, input, batch.interaction, (state.retrievalAudit ?? []).slice(auditStart), scope.catalog()) : validateResponse(response, file, changes);
|
|
480
|
+
if (inlineInteraction) generated.interactionRisk = interactionResult.risk;
|
|
481
|
+
else if (inlineRisk) { const value = typeof response === 'string' ? JSON.parse(response) : response; if (Object.hasOwn(value, 'risks')) generated.inlineRisks = value.risks; }
|
|
411
482
|
if (batch.synthesis) validateSynthesisReads(generated.findings, changes, (state.retrievalAudit ?? []).slice(auditStart), scope.catalog(), file.id, input.id);
|
|
412
483
|
if (!batch.interaction && state.sourceMode === 'change-windows') validateWindowFindings(generated.findings, batch.synthesis ? prepared.synthesis.windows : JSON.parse(batch.payload).windows, batch.synthesis === true);
|
|
413
484
|
return generated;
|
|
414
485
|
};
|
|
415
|
-
const generated = groupedGeneration && !batch.synthesis && !batch.interaction ? structuredClone(groupedGeneration) : await correctedResponse(generate, validate, request, {
|
|
486
|
+
const generated = priorStage ? priorStage.generation : combinedInteraction ? { findings: structuredClone(combinedInteraction.findings), limitations: structuredClone(combinedInteraction.limitations) } : groupedGeneration && !batch.synthesis && !batch.interaction ? structuredClone(groupedGeneration) : await correctedResponse(generate, validate, request, {
|
|
416
487
|
enabled: options.correctProtocol === true,
|
|
417
488
|
canCorrect: () => !finalResponseOnly,
|
|
418
489
|
onCorrection: async info => {
|
|
@@ -424,7 +495,12 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
424
495
|
await options.onProgress?.({ stage: 'protocol-correction', fileId: file.id, path: file.path, ...info });
|
|
425
496
|
},
|
|
426
497
|
});
|
|
427
|
-
if (
|
|
498
|
+
if (inlineInteraction) {
|
|
499
|
+
Object.assign(riskEntry, generated.interactionRisk);
|
|
500
|
+
activeRisks = riskEntry.risks;
|
|
501
|
+
if (riskEntry.limitation) result.limitations.push({ fileId: file.id, text: riskEntry.limitation });
|
|
502
|
+
delete generated.interactionRisk;
|
|
503
|
+
} else if (inlineRisk) {
|
|
428
504
|
const response = generated.inlineRisks;
|
|
429
505
|
if (response === undefined) { riskEntry.status = 'absent'; result.limitations.push({ fileId: file.id, text: '合并主审未返回风险问题,保留有效发现但不声明独立风险规划完成' }); }
|
|
430
506
|
else {
|
|
@@ -433,7 +509,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
433
509
|
}
|
|
434
510
|
delete generated.inlineRisks;
|
|
435
511
|
}
|
|
436
|
-
if (options.enableRiskPlanning === true && (!groupedGeneration || batch.interaction) && !batch.synthesis && batch.round === 1) {
|
|
512
|
+
if (options.enableRiskPlanning === true && !priorStage && !combinedInteraction && (!groupedGeneration || batch.interaction) && !batch.synthesis && batch.round === 1) {
|
|
437
513
|
const coverage = riskReadCoverage(activeRisks,(state.retrievalAudit??[]).slice(auditStart),scope?.catalog()??[],input.id);
|
|
438
514
|
(riskEntry.generations ??= []).push({ round: batch.round, windowIds: batch.windowIds, coverage });
|
|
439
515
|
// Preserve the single-generation field without unioning evidence across windows.
|
|
@@ -443,16 +519,24 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
443
519
|
}
|
|
444
520
|
request.signal.throwIfAborted();
|
|
445
521
|
if(rounds>1)generated.findings.forEach(f=>{f.reviewRound=batch.round;});
|
|
446
|
-
|
|
522
|
+
if (options.enableStageContinuation && !priorStage && !state.retrievalLimited) {
|
|
523
|
+
const record = { key, fileId: file.id, phase, round: batch.round, windowIds: batch.windowIds ?? [], ...(batch.interaction ? { groupId: batch.interaction.id } : {}), generation: { findings: structuredClone(generated.findings), limitations: structuredClone(generated.limitations) } };
|
|
524
|
+
acceptedStages.set(key, { generation: generated, record, completed: false });
|
|
525
|
+
checkpointStages();
|
|
526
|
+
}
|
|
527
|
+
for (const finding of generated.findings) { const index = priorStage ? result.findings.findIndex(existing => existing.id === finding.id) : -1; if (index < 0) result.findings.push(finding); else result.findings[index] = finding; }
|
|
447
528
|
if (generated.findings.some(f => f.attribution.status === 'missing')) result.limitations.push({ fileId: file.id, text: '部分发现缺少变更归因,仅校验了定位,尚不能确认属于本次回归' });
|
|
448
529
|
result.limitations.push(...generated.limitations.map(text => ({ fileId: file.id, text })));
|
|
449
530
|
if (!groupedGeneration && !scheduled.slice(batchIndex + 1).some(next => !next.synthesis && !next.interaction)) releaseStage('primary:' + file.id);
|
|
450
|
-
if (options.
|
|
531
|
+
if (options.enableStageContinuation && !batch.interaction && !batch.synthesis && state.windowCoverage && batch.round === 1) { state.windowCoverage.completed = [...new Set([...state.windowCoverage.completed, ...batch.windowIds])]; state.windowCoverage.pending = state.windowCoverage.pending.filter(id => !batch.windowIds.includes(id)); }
|
|
532
|
+
if (options.enableVerification === true && generated.findings.length && !priorStage?.completed) {
|
|
533
|
+
if (yieldedUnits.has('verification:' + file.id)) { defer(Object.assign(new Error('Verification allocation is waiting for released capacity'), { code: 'READER_CALL_BUDGET_EXCEEDED' }), 'verification:' + file.id); return; }
|
|
451
534
|
for (const finding of generated.findings) finding.verification = { status: 'pending', causality: 'unverified' };
|
|
452
535
|
try {
|
|
453
536
|
activeStage = 'verification';
|
|
454
537
|
const verdicts = await verificationLoop(trackedModel, { ...request, onProtocolEvent, reviewPhase: 'verification' }, generated.findings, stageScope('verification:' + file.id), {
|
|
455
|
-
correctProtocol:options.correctProtocol===true,maxRounds:maxRetrievalRounds,maxInputBytes, beforeCall, onBudgetLimited,
|
|
538
|
+
correctProtocol:options.correctProtocol===true,maxRounds:maxRetrievalRounds,maxInputBytes, beforeCall, onBudgetLimited, yieldOnBudget: options.enableStageContinuation === true,
|
|
539
|
+
enableMigrationRegression: options.enableMigrationRegression === true, migrationContracts: report.migrationContracts?.contracts ?? [], migrationSnapshot: input,
|
|
456
540
|
onRetrieved: records => { (state.retrievalAudit ??= []).push(...records); },
|
|
457
541
|
onTruncated: () => { result.limitations.push({ fileId: file.id, text: '复核检索结果达到数量上限' }); },
|
|
458
542
|
});
|
|
@@ -465,11 +549,14 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
465
549
|
for (const finding of generated.findings) finding.verification = { status: 'incomplete', causality: 'unverified' };
|
|
466
550
|
result.limitations.push({ fileId: file.id, text: '候选复核未完成:' + (error?.message ?? String(error)) });
|
|
467
551
|
request.signal.throwIfAborted();
|
|
552
|
+
if (defer(error, 'verification:' + file.id)) return;
|
|
468
553
|
}
|
|
469
554
|
}
|
|
555
|
+
if (acceptedStages.has(key)) acceptedStages.get(key).completed = !options.enableVerification || generated.findings.every(finding => finding.verification?.status === 'completed');
|
|
470
556
|
if (batch.synthesis) state.synthesisStatus = 'completed';
|
|
471
557
|
if (batch.interaction) {
|
|
472
558
|
const interaction=report.interactions.find(p => p.groupId === batch.interaction.id);
|
|
559
|
+
if (combinedInteraction) Object.assign(interaction, { method: 'group-primary-validated-channel', channelGroupId: combinedInteraction.groupId });
|
|
473
560
|
if(rounds>1){interaction.completedRounds??=[];interaction.completedRounds.push(batch.round);interaction.reviewRounds=rounds;}
|
|
474
561
|
if(batch.round===rounds)interaction.status='completed';
|
|
475
562
|
}
|
|
@@ -478,13 +565,15 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
478
565
|
state.windowCoverage.pending = state.windowCoverage.pending.filter(id => !batch.windowIds.includes(id));
|
|
479
566
|
}
|
|
480
567
|
if(rounds>1)state.reviewPasses[batchIndex].status='completed';
|
|
568
|
+
if (retry) { const pending = deferredStages.find(stage => stage.key === key); if (pending) pending.succeeded = !options.enableVerification || generated.findings.every(finding => finding.verification?.status === 'completed'); }
|
|
481
569
|
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});
|
|
482
570
|
}catch(error){
|
|
483
571
|
enclosingRequest.signal.throwIfAborted();
|
|
572
|
+
if (defer(error)) return;
|
|
484
573
|
if(phase==='primary'||error.code==='MODEL_ARTIFACT_FAILED')throw error;
|
|
485
574
|
if (['Model call budget exceeded', 'Model call budget exceeded before next review pass'].includes(error.message) || error.code === 'PROJECT_MODEL_CALL_LIMIT') {
|
|
486
575
|
result.limitations.push({ fileId: file.id, text: phase + '模型调用额度不足,尚未启动的阶段保留 pending;已完成主审成果保留' });
|
|
487
|
-
|
|
576
|
+
return false;
|
|
488
577
|
}
|
|
489
578
|
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}:{})};
|
|
490
579
|
(state.stageFailures??=[]).push(failure);
|
|
@@ -494,11 +583,12 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
494
583
|
result.limitations.push({fileId:file.id,text:phase+'未完成:'+error.message+';已完成主文件/窗口成果保留,不表示综合已通过'});
|
|
495
584
|
await options.onProgress?.({stage:'synthesis-failed',snapshotId:input.id,fileId:file.id,path:file.path,...failure,windowCoverage:state.windowCoverage});
|
|
496
585
|
}
|
|
497
|
-
}
|
|
586
|
+
};
|
|
587
|
+
for (const [batchIndex, batch] of scheduled.entries()) if (await executeStage(batch, batchIndex, enclosingRequest) === false) break;
|
|
498
588
|
}, { instructions, input: payload }, chainTimeout, signal);
|
|
499
589
|
if (signal?.aborted) throw signal.reason;
|
|
500
590
|
|
|
501
|
-
state.status = state.retrievalLimited ? 'pending' : 'completed';
|
|
591
|
+
state.status = state.retrievalLimited || state.continuationPending ? 'pending' : 'completed';
|
|
502
592
|
if (state.retrievalLimited) {
|
|
503
593
|
const codes = new Set([...(state.retrievalLimits ?? []), ...(state.groupRetrievalLimits ?? [])].map(limit => limit.code));
|
|
504
594
|
state.reason = codes.has('RETRIEVAL_ROUND_BUDGET_EXCEEDED')
|
|
@@ -526,6 +616,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
526
616
|
}
|
|
527
617
|
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');
|
|
528
618
|
await options.onProgress?.({stage:'file-complete',snapshotId:input.id,file:state,findings:result.findings});
|
|
619
|
+
checkpointStages();
|
|
529
620
|
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)] });
|
|
530
621
|
}
|
|
531
622
|
};
|
|
@@ -558,17 +649,17 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
558
649
|
}
|
|
559
650
|
if (options.enableGroupPrimary === true && scope && rounds === 1) {
|
|
560
651
|
const seen = new Set();
|
|
561
|
-
const groups = report.businessGrouping?.groups ?? report.grouping?.groups ?? [];
|
|
652
|
+
const groups = [...(report.migrationContracts?.groups ?? []), ...(report.businessGrouping?.groups ?? report.grouping?.groups ?? [])];
|
|
562
653
|
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 });
|
|
654
|
+
if (group.fileIds.some(id => seen.has(id) || options.resumeState?.completed?.some(row => row.fileId === id) || options.resumeState?.stageGenerations?.some(row => row.fileId === id))) continue;
|
|
655
|
+
const prepared = prepareGroupPrimary(input, group, { instructions, scope, maxInputBytes, focusPrimaryContext: options.focusPrimaryContext === true, enableRiskPlanning: options.enableRiskPlanning === true, enableGroupInteractions: options.enableGroupInteractions === true, migrationContracts: report.migrationContracts?.contracts ?? [] });
|
|
565
656
|
if (!prepared) continue;
|
|
566
657
|
groupPrepared.set(group.id, prepared);
|
|
567
658
|
for (const id of group.fileIds) seen.add(id);
|
|
568
659
|
}
|
|
569
660
|
report.primaryGroups = [...groupPrepared].map(([groupId, p]) => ({ groupId, fileIds: p.fileIds, mode: 'group-primary' }));
|
|
570
661
|
}
|
|
571
|
-
if (scope && options.optimizeReviewCalls === true) {
|
|
662
|
+
if (scope && (options.optimizeReviewCalls === true || options.enableStageContinuation === true)) {
|
|
572
663
|
const units = [];
|
|
573
664
|
const reusable = new Set((options.resumeState?.completed ?? []).map(row => row.fileId));
|
|
574
665
|
for (const [id, prepared] of groupPrepared) units.push({ id: 'group:' + id, weight: prepared.fileIds.length * 4 });
|
|
@@ -591,7 +682,46 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
591
682
|
try { await runFile(index); }
|
|
592
683
|
catch (error) { controller.abort(error); throw error; }
|
|
593
684
|
}, dependencies, fileConcurrency);
|
|
685
|
+
if (options.enableStageContinuation === true && deferredStages.length && !signal.aborted) {
|
|
686
|
+
const primaryDone = input.files.filter(file => file.eligibility === 'reviewable' && (options.resumeState?.completed?.some(row => row.fileId === file.id) || primaryStageKeys.get(file.id)?.length && primaryStageKeys.get(file.id).every(key => acceptedStages.has(key)))).map(file => file.id);
|
|
687
|
+
const units = deferredStages.map(stage => ({ id: hash(stage.key), kind: stage.phase, fileIds: stage.fileIds, dependencies: stage.fileIds.map(id => 'primary:' + id) }));
|
|
688
|
+
const continuationOptions = { rounds, maxInputBytes, enableMigrationContracts: options.enableMigrationContracts === true, enableMigrationRegression: options.enableMigrationRegression === true };
|
|
689
|
+
let continuation = createStageContinuation(input, continuationOptions, { completedPrimary: primaryDone, units, maxAttempts: 2 });
|
|
690
|
+
if (options.resumeState?.continuation?.state) {
|
|
691
|
+
const fresh = continuation.checkpoint(), prior = options.resumeState.continuation.state;
|
|
692
|
+
for (const unit of fresh.units) { const old = prior.units.find(item => item.id === unit.id && item.kind === unit.kind && JSON.stringify(item.fileIds) === JSON.stringify(unit.fileIds)); if (old && old.status !== 'completed') { unit.attempts = Math.min(fresh.maxAttempts, old.attempts); unit.stopped = unit.attempts >= fresh.maxAttempts; unit.pendingReason = unit.stopped ? 'STAGE_ATTEMPTS_EXHAUSTED' : 'STAGE_INTERRUPTED'; } }
|
|
693
|
+
continuation = createStageContinuation(input, continuationOptions, { completedPrimary: primaryDone, units, checkpoint: fresh, maxAttempts: 2 });
|
|
694
|
+
}
|
|
695
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
696
|
+
let progressed = false;
|
|
697
|
+
for (const stage of deferredStages) {
|
|
698
|
+
if (stage.done || !continuation.ready().some(unit => unit.id === hash(stage.key))) continue;
|
|
699
|
+
if (performance.now() >= fileDeadlines.get(stage.fileId) || signal.aborted || report.modelCalls >= maxCalls) { continuation.stop(hash(stage.key), 'STAGE_NO_PROGRESS'); continue; }
|
|
700
|
+
const admitted = !yieldedUnits.has(stage.allocationId) ? { resumed: true } : stage.requests.length ? readerAllocation.resume(stage.allocationId, stage.requests) : readerAllocation.resumeBudget(stage.allocationId);
|
|
701
|
+
if (!admitted.resumed) continue;
|
|
702
|
+
yieldedUnits.delete(stage.allocationId);
|
|
703
|
+
const token = continuation.start(hash(stage.key), { signal });
|
|
704
|
+
stage.attempts++; await stage.run();
|
|
705
|
+
progressed = true;
|
|
706
|
+
if (stage.succeeded && !yieldedUnits.has(stage.allocationId)) { stage.done = true; continuation.complete(hash(stage.key), token.attempt, { signal }); if (!deferredStages.some(other => other !== stage && !other.done && other.allocationId === stage.allocationId)) readerAllocation.release(stage.allocationId); }
|
|
707
|
+
else { continuation.yield(hash(stage.key), stage.reason); if (!yieldedUnits.has(stage.allocationId)) continuation.stop(hash(stage.key), 'STAGE_FAILED'); }
|
|
708
|
+
}
|
|
709
|
+
if (!progressed) break;
|
|
710
|
+
}
|
|
711
|
+
report.stageContinuation = continuation.summary();
|
|
712
|
+
report.continuation = continuation.checkpoint();
|
|
713
|
+
report.continuationOptions = continuationOptions;
|
|
714
|
+
for (const file of input.files) {
|
|
715
|
+
const result = fileResults[input.files.indexOf(file)];
|
|
716
|
+
const pending = deferredStages.filter(stage => stage.fileId === file.id && !stage.done);
|
|
717
|
+
if (result?.state.continuationPending && !pending.length) { result.state.continuationPending = false; if (result.state.status === 'pending' && primaryDone.includes(file.id) && !result.state.stageFailures?.length && !result.state.reviewPasses?.some(pass => pass.status !== 'completed')) result.state.status = result.state.retrievalLimited ? 'pending' : 'completed'; }
|
|
718
|
+
}
|
|
719
|
+
checkpointStages();
|
|
720
|
+
await options.onResumeCheckpoint?.({ ...report, files: fileResults.map(result => result.state), findings: fileResults.flatMap(result => result.findings), limitations: [...report.limitations, ...fileResults.flatMap(result => result.limitations)] });
|
|
721
|
+
}
|
|
722
|
+
for (const id of yieldedUnits) readerAllocation?.release(id);
|
|
594
723
|
if (checkpointFailed) throw checkpointFailure;
|
|
724
|
+
for (const result of fileResults) if (result?.state.continuationPending === false) { const state = result.state; state.resumeEligible = state.status === 'completed' && !state.stageFailures?.length && !state.reviewPasses?.some(pass => pass.status !== 'completed') && !state.windowCoverage?.pending?.length && !state.retrievalLimited && result.findings.every(finding => finding.verification?.status === 'completed') && report.interactions.filter(item => item.fileIds?.includes(state.fileId)).every(item => item.status === 'completed'); }
|
|
595
725
|
report.files = fileResults.map(result => result.state);
|
|
596
726
|
report.findings = fileResults.flatMap(result => result.findings);
|
|
597
727
|
report.limitations.push(...fileResults.flatMap(result => result.limitations));
|
|
@@ -663,7 +793,12 @@ function renderFinding(output, finding, index, duplicates) {
|
|
|
663
793
|
}
|
|
664
794
|
|
|
665
795
|
export function markdownReport(report) {
|
|
666
|
-
const output = ['# Code Review 报告', '', '状态:' + report.status, '快照:' + report.snapshotId, '', '##
|
|
796
|
+
const output = ['# Code Review 报告', '', '状态:' + report.status, '快照:' + report.snapshotId, ...reportSummaryMarkdown(report), '', '## 审查发现'];
|
|
797
|
+
if (!report.findings.length) output.push(report.status === 'completed' ? '在已审查范围内未发现具体问题;不代表代码已被证明正确。' : '当前没有有效问题记录,但审查存在未完成项或限制,不能视为通过。');
|
|
798
|
+
const duplicates = new Map();
|
|
799
|
+
for (const group of groupDuplicateFindings(report.findings)) for (const index of group.findingIndices.slice(1)) duplicates.set(index, group.findingIndices[0]);
|
|
800
|
+
for (const [index, finding] of report.findings.entries()) renderFinding(output, finding, index, duplicates);
|
|
801
|
+
output.push('', '## 覆盖情况');
|
|
667
802
|
if (report.callerDiscovery) output.push('- 引用方发现:扫描 '+report.callerDiscovery.scanned.length+' 个文件,捕获 '+report.callerDiscovery.capturedPaths.length+' 个上下文;仅批准目录字面引用导航,不证明函数调用或完整覆盖。');
|
|
668
803
|
if(report.businessRequirement)output.push('- 业务需求:SHA256 '+safe(report.businessRequirement.hash)+' · '+report.businessRequirement.bytes+' bytes;不可信约束,不是证据或权限,全文不在报告重复。');
|
|
669
804
|
for (const file of report.files) for (const correction of file.anchorCorrections ?? []) output.push('- 定位纠正:' + safe(correction.originalCandidate.fileId) + ' · ' + safe(correction.status) + ' · ' + safe(correction.reason) + ';原完整候选保留JSON,仅定位不证明因果');
|
|
@@ -685,15 +820,7 @@ export function markdownReport(report) {
|
|
|
685
820
|
for (const interaction of report.interactions ?? []) output.push('- 跨文件综合 ' + safe(interaction.groupId) + ':' + safe(interaction.status) + (interaction.failureCode ? ' · ' + safe(interaction.failureCode) : '') + (interaction.reason ? ' — ' + safe(interaction.reason) : '') + ';与组末主文件共享生命周期,不证明完整交互覆盖');
|
|
686
821
|
if (report.rules?.length) output.push('', '## 项目规则', '规则仅为已批准约束数据,不扩大权限或证明缺陷。', ...report.rules.map(rule => '- ' + safe(rule.path) + ' · SHA256 ' + safe(rule.hash)));
|
|
687
822
|
if (report.followup?.suggestedPaths.length) output.push('', '## 后续选择建议', '仅为原快照未完成项;不会自动续审。再次执行需新预览、新审批,并显式选择规则和上下文。阻断项需先处理原因;关联上下文不等于主文件已审查。', ...report.followup.items.filter(item => item.followup).map(item => '- ' + safe(item.path) + ':' + item.status + (item.incompleteVerification ? '(候选复核未完成)' : '')));
|
|
688
|
-
output.push('', '## 审查发现');
|
|
689
|
-
if (!report.findings.length) output.push(report.status === 'completed' ? '在已审查范围内未发现具体问题;不代表代码已被证明正确。' : '当前没有有效问题记录,但审查存在未完成项或限制,不能视为通过。');
|
|
690
823
|
output.push(...diagnosticsMarkdown(report.diagnostics));
|
|
691
|
-
// Recompute from exact data rather than trusting potentially stale group metadata.
|
|
692
|
-
const duplicates = new Map();
|
|
693
|
-
for (const group of groupDuplicateFindings(report.findings)) {
|
|
694
|
-
for (const index of group.findingIndices.slice(1)) duplicates.set(index, group.findingIndices[0]);
|
|
695
|
-
}
|
|
696
|
-
for (const [index, finding] of report.findings.entries()) renderFinding(output, finding, index, duplicates);
|
|
697
824
|
if (report.limitations.length) output.push('', '## 限制', ...report.limitations.map(l => '- ' + safe(l.text)));
|
|
698
825
|
return output.join('\n') + '\n';
|
|
699
826
|
}
|
package/src/service.mjs
CHANGED
|
@@ -55,16 +55,16 @@ function buildPreviewNotice(snapshot, options, enableRiskPlanning, changesConcur
|
|
|
55
55
|
return scopeNotice + (snapshot.history?.vcs==='svn'&&options.autoContext===true?'SVN历史autoContext为精确依赖定位读取固定版本父目录有界清单,包含同目录非匹配名字元数据;仅匹配捕获正文发送模型,不扫描全仓,不用工作树替代历史版本。':'') + (options.oldContextPaths !== undefined ? 'oldContextPaths仅为baseline旧侧正文,无目标侧替代;选中变更复用原old身份,旧侧不能证明新侧行为。' : '') + (snapshot.callerDiscovery ? 'callerScopePaths已授权预览读取限定目录内tracked候选正文,包括不匹配文件;非匹配正文不发送模型,匹配捕获上下文会发送。仅字面引用导航,不是函数调用或完整调用覆盖。' : '') + (enableRiskPlanning ? '执行按plan阈值决定风险假设规划,小变更可跳过独立规划但仍主审并复核;计划不是证据,所有阶段共享原预算。' : '') + `执行会向上述模型提供方发送可审查文件两侧内容、预览列出的显式/自动上下文、所选项目规则全文及预览业务需求文本。模型可在该快照范围内多轮只读检索,不读取范围外文件;候选生成后会进行证据与反证复核,两阶段各最多${maxRetrievalRounds}轮检索;独立文件审查链最多${changesConcurrency}条并发,不设插件内部默认时间或总模型调用次数截止,遵循宿主/用户取消;输入字节和检索轮次保护仍保留;复核不能证明缺陷成立。plan.reviewRounds为独立审查工作量,非模型reasoning effort;后续轮不带首轮风险计划,所有轮共享原超时与预算。plan仅预检初始输入,ready不代表已审查;minimumCalls不含后续检索/复核/动态业务综合;多文件可先进行业务分组,分组失败保留原组并记录限制,动态综合数执行前未知,实际预算可能不足。请先向用户展示范围,获得确认后执行。`;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
function createSessionReviewService(llm, { capture = captureSnapshot, now = Date.now, ttlMs = 300000, maxPreviews = 8, allowModelSending = false, enableRiskPlanning = false, skipSmallRiskPlans = true, reviewRounds = 1, enableBusinessGrouping = false, enableAnchorCorrection = false, changesConcurrency = 2, maxRetrievalRounds = 3, riskPlanningMode = 'separate', readerBudgetPolicy = 'fixed', optimizeReviewCalls = false, enablePrimarySeeds = false, focusPrimaryContext = false, enableSearchSeeds = false, nativeOutputTools = false, enableGroupPrimary = false, authorize = async () => false } = {}) {
|
|
58
|
+
function createSessionReviewService(llm, { capture = captureSnapshot, now = Date.now, ttlMs = 300000, maxPreviews = 8, allowModelSending = false, enableRiskPlanning = false, skipSmallRiskPlans = true, reviewRounds = 1, enableBusinessGrouping = false, enableAnchorCorrection = false, changesConcurrency = 2, maxRetrievalRounds = 3, riskPlanningMode = 'separate', readerBudgetPolicy = 'fixed', optimizeReviewCalls = false, enablePrimarySeeds = false, focusPrimaryContext = false, enableSearchSeeds = false, nativeOutputTools = false, enableGroupPrimary = false, enableEvidenceContext = false, inlineInteractionRisks = false, enableMigrationContracts = false, enableMigrationRegression = false, enableGroupInteractions = false, enableStageContinuation = false, authorize = async () => false } = {}) {
|
|
59
59
|
for (const value of [ttlMs, maxPreviews]) if (!Number.isSafeInteger(value) || value < 1) throw new Error('Invalid preview limit');
|
|
60
60
|
changesConcurrency = reviewConcurrency(changesConcurrency);
|
|
61
61
|
maxRetrievalRounds = reviewRetrievalRounds(maxRetrievalRounds);
|
|
62
62
|
riskPlanningMode = validateRiskPlanningMode(riskPlanningMode);
|
|
63
63
|
reviewReaderBudget({ files: [] }, readerBudgetPolicy);
|
|
64
|
-
for (const value of [optimizeReviewCalls, enablePrimarySeeds, focusPrimaryContext, enableSearchSeeds, nativeOutputTools, enableGroupPrimary]) if (typeof value !== 'boolean') throw new Error('Invalid review optimization option');
|
|
64
|
+
for (const value of [optimizeReviewCalls, enablePrimarySeeds, focusPrimaryContext, enableSearchSeeds, nativeOutputTools, enableGroupPrimary, enableEvidenceContext, inlineInteractionRisks, enableMigrationContracts, enableMigrationRegression, enableGroupInteractions, enableStageContinuation]) if (typeof value !== 'boolean') throw new Error('Invalid review optimization option');
|
|
65
65
|
const lifetime = new AbortController();
|
|
66
66
|
const bind = exec => ({ ...exec, signal: exec.signal ? AbortSignal.any([exec.signal, lifetime.signal]) : lifetime.signal });
|
|
67
|
-
const resumeOptions = (options, route, rounds) => ({ captureOptions: options, route, runtime: REVIEW_RUNTIME, instructionsHash: hash(REVIEW_INSTRUCTIONS), execution: { rounds, enableRiskPlanning, skipSmallRiskPlans, enableBusinessGrouping, enableAnchorCorrection, changesConcurrency, maxRetrievalRounds, riskPlanningMode, readerBudgetPolicy, optimizeReviewCalls, enablePrimarySeeds, focusPrimaryContext, enableSearchSeeds, nativeOutputTools, enableGroupPrimary } });
|
|
67
|
+
const resumeOptions = (options, route, rounds) => ({ captureOptions: options, route, runtime: REVIEW_RUNTIME, instructionsHash: hash(REVIEW_INSTRUCTIONS), execution: { rounds, enableRiskPlanning, skipSmallRiskPlans, enableBusinessGrouping, enableAnchorCorrection, changesConcurrency, maxRetrievalRounds, riskPlanningMode, readerBudgetPolicy, optimizeReviewCalls, enablePrimarySeeds, focusPrimaryContext, enableSearchSeeds, nativeOutputTools, enableGroupPrimary, enableEvidenceContext, inlineInteractionRisks, enableMigrationContracts, enableMigrationRegression, enableGroupInteractions, enableStageContinuation } });
|
|
68
68
|
const previews = new Map();
|
|
69
69
|
let busy = false;
|
|
70
70
|
function prune() { for (const [id, p] of previews) if (p.expires <= now()) previews.delete(id); }
|
|
@@ -97,7 +97,7 @@ function createSessionReviewService(llm, { capture = captureSnapshot, now = Date
|
|
|
97
97
|
if (owner(exec).cwd !== current.cwd) throw new Error('Agent directory changed; preview again');
|
|
98
98
|
const resumeState = savedResume === undefined ? undefined : validateResumeState(savedResume, snapshot, resumeOptions(options, current.route, rounds));
|
|
99
99
|
const readerBudget = reviewReaderBudget(snapshot, readerBudgetPolicy);
|
|
100
|
-
const plan = buildCoveragePlan(snapshot, { enableGroupPrimary, resumeState, focusPrimaryContext, riskPlanningMode, enableRiskPlanning, skipSmallRiskPlans, reviewRounds:rounds, enableBusinessGrouping, instructions: REVIEW_INSTRUCTIONS, scope: configureWireBudget(createRetrievalScope(snapshot, { ...readerBudget, signal: exec.signal, ...(optimizeReviewCalls ? { reuseResults: true } : {}) }), { nativeOutputTools }), grouping: buildReviewGroups(snapshot.files, inferFileRelations(snapshot.files)), maxInputBytes: 96 * 1024, signal: exec.signal });
|
|
100
|
+
const plan = buildCoveragePlan(snapshot, { enableEvidenceContext, inlineInteractionRisks, enableMigrationContracts, enableMigrationRegression, enableGroupInteractions, enableStageContinuation, enableGroupPrimary, resumeState, focusPrimaryContext, riskPlanningMode, enableRiskPlanning, skipSmallRiskPlans, reviewRounds:rounds, enableBusinessGrouping, instructions: REVIEW_INSTRUCTIONS, scope: configureWireBudget(createRetrievalScope(snapshot, { ...readerBudget, signal: exec.signal, ...(optimizeReviewCalls ? { reuseResults: true } : {}) }), { nativeOutputTools }), grouping: buildReviewGroups(snapshot.files, inferFileRelations(snapshot.files)), maxInputBytes: 96 * 1024, signal: exec.signal });
|
|
101
101
|
prune();
|
|
102
102
|
while (previews.size >= maxPreviews) previews.delete(previews.keys().next().value);
|
|
103
103
|
const previewId = randomUUID();
|
|
@@ -111,7 +111,7 @@ function createSessionReviewService(llm, { capture = captureSnapshot, now = Date
|
|
|
111
111
|
plan,
|
|
112
112
|
snapshotId: snapshot.id,
|
|
113
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 } : {}) },
|
|
114
|
+
execution: { nativeOutputTools, enableGroupPrimary, enableEvidenceContext, inlineInteractionRisks, enableMigrationContracts, enableMigrationRegression, enableGroupInteractions, enableStageContinuation, fileConcurrency: changesConcurrency, maxRetrievalRounds, ...(optimizeReviewCalls ? { riskPlanningMode, readerBudget: { policy: readerBudgetPolicy, ...readerBudget }, missingContextSeeds: enablePrimarySeeds, focusPrimaryContext, searchContextSeeds: enableSearchSeeds } : {}) },
|
|
115
115
|
repositoryRoot: snapshot.root,
|
|
116
116
|
vcs: snapshot.vcs,
|
|
117
117
|
...(snapshot.changeScope ? { changeScope: snapshot.changeScope } : options.changeScope !== undefined ? { changeScope: options.changeScope } : {}),
|
|
@@ -141,7 +141,7 @@ function createSessionReviewService(llm, { capture = captureSnapshot, now = Date
|
|
|
141
141
|
...(snapshot.callerDiscovery ? { callerDiscovery: snapshot.callerDiscovery } : {}),
|
|
142
142
|
...(snapshot.autoContext ? { autoContext: snapshot.autoContext } : {}),
|
|
143
143
|
rules: (snapshot.rules ?? []).map(({ path, hash }) => ({ path, hash })),
|
|
144
|
-
notice: buildPreviewNotice(snapshot, options, enableRiskPlanning, changesConcurrency, maxRetrievalRounds) + (optimizeReviewCalls ? ` 完整正文或覆盖全部精确编辑的单批窗口可将风险问题合并入主审(非独立规划);仅对批准上下文的确定坐标预取小片段,不代表完整核查。${focusPrimaryContext ? " 主审优先保留主文件全文,仅附带有关联的有界完整上下文;其余批准来源保留目录按需读,不表示不存在或已核查。" : ""}${enableSearchSeeds ? " 可将模型搜索命中的批准原行小片段与搜索结果同轮提供,每阶段最多2次额外读取,共用原预算;不猜测另侧坐标,不增加检索轮次。" : ""}共享检索上限按本次范围计算:${readerBudget.maxCalls}次唯一操作结果生成/${readerBudget.maxOutputBytes}交付字节,所有阶段共用、不逐文件重置;同快照相同请求可复用原始结果,每次交付仍计字节并重新签发复核回执;主审、综合及复核按权重预留最低额度,未用额度释放,额度不足仍如实标记未完成。` : ''),
|
|
144
|
+
notice: buildPreviewNotice(snapshot, options, enableRiskPlanning, changesConcurrency, maxRetrievalRounds) + (enableMigrationContracts ? ' 迁移契约仅由批准快照的明确旧/新入口与调用绑定产生,无法解析的语言结构保持未解决,不以相似名称推断迁移。' : '') + (enableMigrationRegression ? ' 显式跨文件旧新比较仅在host契约限定来源和本次独立回执齐备时成立,因果仍未证明。' : '') + (enableGroupInteractions ? ' 组主审可附带交互结果,只有当前代全组编辑实读且host校验通过时替代匹配专门阶段;缺失/非法回退,候选仍独立复核。' : '') + (enableStageContinuation ? ' 未完成阶段可在其他阶段释放额度后有限继续,不重置总账或原超时;已验收主审生成可精确续用,但复核重新读取、新回执,不继承旧证据。' : '') + (enableEvidenceContext ? ' 可将本次实际交付过的批准原行按相关性经当前阶段reader有限重新投递;缓存仍计交付字节,不继承历史读取覆盖或复核回执。' : '') + (inlineInteractionRisks ? ' 预算适配的精确跨文件单元可在交互主审同次返回风险假设,省去独立规划;缺失或无效风险不补发、不代表已完成风险核查。' : '') + (optimizeReviewCalls ? ` 完整正文或覆盖全部精确编辑的单批窗口可将风险问题合并入主审(非独立规划);仅对批准上下文的确定坐标预取小片段,不代表完整核查。${focusPrimaryContext ? " 主审优先保留主文件全文,仅附带有关联的有界完整上下文;其余批准来源保留目录按需读,不表示不存在或已核查。" : ""}${enableSearchSeeds ? " 可将模型搜索命中的批准原行小片段与搜索结果同轮提供,每阶段最多2次额外读取,共用原预算;不猜测另侧坐标,不增加检索轮次。" : ""}共享检索上限按本次范围计算:${readerBudget.maxCalls}次唯一操作结果生成/${readerBudget.maxOutputBytes}交付字节,所有阶段共用、不逐文件重置;同快照相同请求可复用原始结果,每次交付仍计字节并重新签发复核回执;主审、综合及复核按权重预留最低额度,未用额度释放,额度不足仍如实标记未完成。` : ''),
|
|
145
145
|
};
|
|
146
146
|
} finally { busy = false; }
|
|
147
147
|
},
|
|
@@ -168,7 +168,7 @@ function createSessionReviewService(llm, { capture = captureSnapshot, now = Date
|
|
|
168
168
|
exec.signal?.throwIfAborted();
|
|
169
169
|
if (owner(exec).cwd !== current.cwd || JSON.stringify(owner(exec).route) !== JSON.stringify(preview.route)) throw new Error('Agent changed during approval');
|
|
170
170
|
await exec.reviewCheckpoint?.({stage:'approved-manifest',snapshotId:snapshot.id,files:snapshot.files.map(f=>({fileId:f.id,path:f.path,status:f.eligibility==='reviewable'?'pending':f.eligibility}))});
|
|
171
|
-
const report = await reviewSnapshot(snapshot, createDshModel(llm, preview.route,{correctJson:true,useNativeTools:nativeOutputTools}), { signal: exec.signal, enableRetrieval: true, enableGrouping: true, enableGroupPrimary, nativeOutputTools, enableVerification: true, riskPlanningMode, readerBudgetPolicy, optimizeReviewCalls, enablePrimarySeeds, focusPrimaryContext, enableSearchSeeds, enableRiskPlanning, skipSmallRiskPlans, reviewRounds:preview.rounds, enableBusinessGrouping, enableAnchorCorrection,changesConcurrency,maxRetrievalRounds,correctProtocol:true, onProgress:exec.reviewCheckpoint, resumeState, onModelArtifact: exec.onModelArtifact, onResumeCheckpoint: exec.reviewResumeCheckpoint ? async checkpoint => { await exec.reviewResumeCheckpoint(buildResumeState(snapshot, resumeIdentity, checkpoint)); } : undefined });
|
|
171
|
+
const report = await reviewSnapshot(snapshot, createDshModel(llm, preview.route,{correctJson:true,useNativeTools:nativeOutputTools}), { signal: exec.signal, enableRetrieval: true, enableGrouping: true, enableGroupPrimary, enableEvidenceContext, inlineInteractionRisks, enableMigrationContracts, enableMigrationRegression, enableGroupInteractions, enableStageContinuation, nativeOutputTools, enableVerification: true, riskPlanningMode, readerBudgetPolicy, optimizeReviewCalls, enablePrimarySeeds, focusPrimaryContext, enableSearchSeeds, enableRiskPlanning, skipSmallRiskPlans, reviewRounds:preview.rounds, enableBusinessGrouping, enableAnchorCorrection,changesConcurrency,maxRetrievalRounds,correctProtocol:true, onProgress:exec.reviewCheckpoint, resumeState, onModelArtifact: exec.onModelArtifact, onResumeCheckpoint: exec.reviewResumeCheckpoint ? async checkpoint => { await exec.reviewResumeCheckpoint(buildResumeState(snapshot, resumeIdentity, checkpoint)); } : undefined });
|
|
172
172
|
report.model = preview.route;
|
|
173
173
|
if (exec.reviewResumeCheckpoint) await exec.reviewResumeCheckpoint(buildResumeState(snapshot, resumeIdentity, report));
|
|
174
174
|
// reviewSnapshot preserves cancelled coverage; do not replace it with a freshness error.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
const KINDS = new Set(['verification', 'synthesis', 'interaction']);
|
|
3
|
+
const REASONS = new Set(['READER_CALL_BUDGET_EXCEEDED', 'READER_OUTPUT_BUDGET_EXCEEDED', 'STAGE_INTERRUPTED', 'STAGE_CANCELLED', 'STAGE_TIMEOUT', 'STAGE_NO_PROGRESS', 'STAGE_ATTEMPTS_EXHAUSTED', 'STAGE_FAILED']);
|
|
4
|
+
const fail = code => Object.assign(new Error(code), { code });
|
|
5
|
+
const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().filter(key => value[key] !== undefined).map(key => [key, canonical(value[key])])) : value;
|
|
6
|
+
const digest = value => createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex');
|
|
7
|
+
const same = (a,b) => JSON.stringify(a) === JSON.stringify(b);
|
|
8
|
+
|
|
9
|
+
// Host metadata only: accepted generation belongs in the separate owner-bound
|
|
10
|
+
// checkpoint. No read results/receipts are persisted or minted by this machine.
|
|
11
|
+
export function createStageContinuation(snapshot, options, { completedPrimary = [], units = [], checkpoint, maxAttempts = 2 } = {}) {
|
|
12
|
+
if (!snapshot || typeof snapshot.id !== 'string' || !Array.isArray(snapshot.files) || !Number.isSafeInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 4 || !Array.isArray(units) || units.length > 2000) throw fail('INVALID_STAGE_CONTINUATION');
|
|
13
|
+
const fileIds = new Set(snapshot.files.filter(f => f.eligibility === 'reviewable').map(f => f.id));
|
|
14
|
+
if (!Array.isArray(completedPrimary) || new Set(completedPrimary).size !== completedPrimary.length || completedPrimary.some(id => !fileIds.has(id))) throw fail('INVALID_STAGE_PRIMARY');
|
|
15
|
+
const primary = [...completedPrimary].sort(), states = new Map();
|
|
16
|
+
for (const unit of units) {
|
|
17
|
+
if (!unit || typeof unit.id !== 'string' || !unit.id || unit.id.length > 500 || states.has(unit.id) || unit.id.startsWith('primary:') || !KINDS.has(unit.kind) || !Array.isArray(unit.fileIds) || !unit.fileIds.length || new Set(unit.fileIds).size !== unit.fileIds.length || unit.fileIds.some(id => !fileIds.has(id)) || !Array.isArray(unit.dependencies ?? []) || new Set(unit.dependencies ?? []).size !== (unit.dependencies ?? []).length) throw fail('INVALID_STAGE_UNIT');
|
|
18
|
+
states.set(unit.id, { id: unit.id, kind: unit.kind, fileIds: [...unit.fileIds], dependencies: [...(unit.dependencies ?? [])], status: 'pending', attempts: 0, pendingReason: null, stopped: false });
|
|
19
|
+
}
|
|
20
|
+
const visit = (id, visiting = new Set(), visited = new Set()) => {
|
|
21
|
+
if (visited.has(id)) return;
|
|
22
|
+
if (visiting.has(id)) throw fail('INVALID_STAGE_DEPENDENCIES');
|
|
23
|
+
visiting.add(id);
|
|
24
|
+
for (const dep of states.get(id).dependencies) {
|
|
25
|
+
if (typeof dep !== 'string' || dep === id) throw fail('INVALID_STAGE_DEPENDENCIES');
|
|
26
|
+
if (dep.startsWith('primary:')) { if (!fileIds.has(dep.slice(8))) throw fail('INVALID_STAGE_DEPENDENCIES'); }
|
|
27
|
+
else { if (!states.has(dep)) throw fail('INVALID_STAGE_DEPENDENCIES'); visit(dep, visiting, visited); }
|
|
28
|
+
}
|
|
29
|
+
visiting.delete(id); visited.add(id);
|
|
30
|
+
};
|
|
31
|
+
const visited = new Set(); for (const id of states.keys()) visit(id, new Set(), visited);
|
|
32
|
+
const identityHash = digest({ snapshot, options, primary, units: [...states.values()].map(({id,kind,fileIds,dependencies})=>({id,kind,fileIds,dependencies})), maxAttempts });
|
|
33
|
+
if (checkpoint !== undefined) {
|
|
34
|
+
if (!checkpoint || checkpoint.schemaVersion !== 1 || checkpoint.snapshotId !== snapshot.id || checkpoint.identityHash !== identityHash || checkpoint.maxAttempts !== maxAttempts || !same(checkpoint.completedPrimary, primary) || !Array.isArray(checkpoint.units) || checkpoint.units.length !== states.size || Object.keys(checkpoint).sort().join(',') !== 'completedPrimary,identityHash,maxAttempts,schemaVersion,snapshotId,units') throw fail('STAGE_CONTINUATION_IDENTITY_MISMATCH');
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
for (const saved of checkpoint.units) {
|
|
37
|
+
const state = states.get(saved?.id);
|
|
38
|
+
if (!state || seen.has(saved.id) || !same(saved.fileIds,state.fileIds) || !same(saved.dependencies,state.dependencies) || saved.kind !== state.kind || !['pending','running','completed'].includes(saved.status) || !Number.isSafeInteger(saved.attempts) || saved.attempts < 0 || saved.attempts > maxAttempts || typeof saved.stopped !== 'boolean' || saved.pendingReason !== null && !REASONS.has(saved.pendingReason) || Object.keys(saved).sort().join(',') !== 'attempts,dependencies,fileIds,id,kind,pendingReason,status,stopped' || (saved.status !== 'pending' && saved.attempts === 0)) throw fail('INVALID_STAGE_CHECKPOINT');
|
|
39
|
+
seen.add(saved.id); Object.assign(state, { status: saved.status === 'running' ? 'pending' : saved.status, attempts: saved.attempts, pendingReason: saved.status === 'running' ? 'STAGE_INTERRUPTED' : saved.pendingReason, stopped: saved.stopped });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const get = id => { const state=states.get(id); if (!state) throw fail('UNKNOWN_STAGE_UNIT'); return state; };
|
|
43
|
+
const dependenciesReady = state => state.fileIds.every(id => primary.includes(id)) && state.dependencies.every(dep => dep.startsWith('primary:') ? primary.includes(dep.slice(8)) : get(dep).status === 'completed');
|
|
44
|
+
for (const state of states.values()) {
|
|
45
|
+
if (state.status === 'completed' && (!dependenciesReady(state) || state.stopped || state.pendingReason !== null)) throw fail('INVALID_STAGE_CHECKPOINT');
|
|
46
|
+
}
|
|
47
|
+
const copy = state => structuredClone(state);
|
|
48
|
+
const api = {
|
|
49
|
+
ready() { return [...states.values()].filter(s => s.status === 'pending' && !s.stopped && s.attempts < maxAttempts && dependenciesReady(s)).map(copy); },
|
|
50
|
+
start(id, { signal } = {}) {
|
|
51
|
+
signal?.throwIfAborted(); const state=get(id);
|
|
52
|
+
if (state.status !== 'pending' || state.stopped || state.attempts >= maxAttempts || !dependenciesReady(state)) throw fail('STAGE_NOT_READY');
|
|
53
|
+
state.status='running'; state.attempts++; state.pendingReason=null;
|
|
54
|
+
return { id, attempt: state.attempts, kind: state.kind, fileIds: [...state.fileIds], boundary: 'stage-start', requiresFreshEvidence: true };
|
|
55
|
+
},
|
|
56
|
+
complete(id, attempt, { signal } = {}) {
|
|
57
|
+
const state=get(id);
|
|
58
|
+
if (state.status !== 'running' || state.attempts !== attempt) throw fail('STALE_STAGE_ATTEMPT');
|
|
59
|
+
if (signal?.aborted) { state.status='pending'; state.pendingReason='STAGE_CANCELLED'; state.stopped=true; signal.throwIfAborted(); }
|
|
60
|
+
state.status='completed'; state.pendingReason=null;
|
|
61
|
+
},
|
|
62
|
+
yield(id, reason = 'STAGE_NO_PROGRESS') {
|
|
63
|
+
const state=get(id); if (state.status !== 'running' || !REASONS.has(reason)) throw fail('INVALID_STAGE_TRANSITION');
|
|
64
|
+
state.status='pending'; state.pendingReason=state.attempts >= maxAttempts ? 'STAGE_ATTEMPTS_EXHAUSTED' : reason; state.stopped=state.attempts >= maxAttempts;
|
|
65
|
+
},
|
|
66
|
+
stop(id, reason = 'STAGE_NO_PROGRESS') {
|
|
67
|
+
const state=get(id); if (state.status === 'completed' || !REASONS.has(reason)) throw fail('INVALID_STAGE_TRANSITION');
|
|
68
|
+
state.status='pending'; state.pendingReason=reason; state.stopped=true;
|
|
69
|
+
},
|
|
70
|
+
checkpoint() { return { schemaVersion: 1, snapshotId: snapshot.id, identityHash, maxAttempts, completedPrimary: [...primary], units: [...states.values()].map(copy) }; },
|
|
71
|
+
summary() { const values=[...states.values()]; return { units: values.length, completed: values.filter(s=>s.status==='completed').length, pending: values.filter(s=>s.status==='pending').length, running: values.filter(s=>s.status==='running').length, stopped: values.filter(s=>s.stopped).length, attempts: values.reduce((n,s)=>n+s.attempts,0) }; },
|
|
72
|
+
};
|
|
73
|
+
return Object.freeze(api);
|
|
74
|
+
}
|