@feng0531/dsh-code-review 0.87.4 → 0.87.5

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 CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  本文件介绍对使用者有意义的变化。安装与参数见 [README](README.md),本次发布正文见 [发布说明](RELEASE-NOTES.md)。历史提交和安装包保留在 [GitHub Releases](https://github.com/f-e-n-g-0531/dsh-code-review/releases);本文不把开发日志当作功能介绍。
4
4
 
5
+ ## 0.87.5 — 主审优先与依赖感知续审
6
+
7
+ - 先完成所有文件的主审扫描,再执行跨窗口/跨文件综合,避免后续分析在尚有文件未主审时抢先消耗模型调用;继续保留有限续跑、原截止时间和完整覆盖分母。
8
+ - 读取恢复按当前真实就绪阶段排队,跳过依赖未满足的队头;首次精确请求可利用自身保留额度或缓存恢复,不释放其他阶段保留额、不重置总账或检索轮数。
9
+ - 跟进取证输入超限时只移除重复初始源码正文,保留精确编辑、风险/调用线索与已交付证据回执;避免大文件重复正文挤掉新读证据。
10
+ - 未完成原因从阶段关联回文件和报告,保留依赖、分配等待、超时、调用上限与尝试状态;一个阶段受限不再阻止其他健康阶段保留生成。仍受限时明确 partial,不把评论、候选或报告收尾当作审核完成。
11
+
5
12
  ## 0.87.4 — 默认交付人能读懂的审核报告
6
13
 
7
14
  - 默认主文先给是否查完、问题卡、未完成原因和下一步;完整证据与执行台账移到同一报告折叠附录,不要求使用者手动拼接批次。
package/DEVELOPMENT.md CHANGED
@@ -32,6 +32,14 @@
32
32
 
33
33
  运行 `node scripts/changes-speed-benchmark.mjs [baseline-directory]` 比较真实 review 入口上的 8 文件 SVN 受控候选/反驳夹具,包含 receipt 复核与最终精确原文校验;每次模型响应模拟 20ms。可先将冻结版本的 src 解包到独立目录作为 baseline。比较调用数、峰值并发和原文证据,不把离线计时或 mock 结论一致当作真实质量、token 成本或 45→12 分钟保证。`model-start/model-end` 检查点只记录计时、字节、状态及阶段,不写入源码或推理;各文件 `modelMetrics` 是提供方实际调用(含纠错)累计时间,并行时不可相加当作墙钟耗时。
34
34
 
35
+ ### 主审优先与依赖感知恢复
36
+
37
+ 启用 `enableStageContinuation` 时,跨窗口/跨文件综合先登记为依赖阶段,待全体文件主审扫描后由 continuation 运行;独立复核可在主审链内进行,受限后进入同一有界队列。读取恢复传入完整当前 ready allocation IDs,跳过依赖未就绪队头但不释放其 floor;首次精确请求可无 release 尝试自身可负担成本,实际 admission 计次,同 revision 无进展不忙重试。未知成本的 scalar admission 仍要求容量释放,不声称它足以支付任意 verifier 预取。
38
+
39
+ 跟进输入超限时只瘦身重复的原始 source text,保留完整编辑/元数据和已读取证据;既有窗口/综合的元数据也不再为压缩而删除。原始输入、当前接收的证据、独立复核与历史复用保持不同信任层。阶段限制按当前生成判定,不用文件曾经受限的 sticky 标记阻止健康阶段缓存;文件总体受限和不完整窗口仍阻止完成。
40
+
41
+ `continuation.units` 保存真实 pendingReason、依赖与尝试;报告按 fileIds 关联并在附录保留完整身份。模型额度、超时、依赖阻断和读取分配等待不互相冒充。预算仍有余额不保证所有保护中的阶段可运行,全体大请求超过各自 floor 时不偷取其他阶段保留额,仍保留 partial。
42
+
35
43
  ### 大上下文回归与纠正记账
36
44
 
37
45
  正式变更入口默认 `focusPrimaryContext:true`,纯准备阶段只附带直接关系、C#和调用方线索对应的完整批准上下文对象,序列化最多16KiB/输入四分之一,稳定原顺序保留新旧版本对象。其余来源保留批准目录按需读;不会因缺少检索能力隐藏正文。底层API默认false,设置false可回退。主文件、规则与需求不删减;即使旧输入能容纳整批上下文,也应用该附带上限。
package/README.md CHANGED
@@ -239,9 +239,9 @@ Git / SVN 历史审查也能使用这些参数;依赖和调用方按对应版
239
239
 
240
240
  重启后可按原jobId读回保存记录,原运行标记interrupted,不自动继续外发。recovery给出已观察文件/窗口与未完成路径;它不是完整分母或新授权;宿主重启及自动恢复本身不授权外发,补审必须有当前明确授权并重新预览核对。新预览的runtime包含实际加载版本和模块位置,排查不同工作区时分别核对,不根据另一台机器推断。
241
241
 
242
- 变更审查的独立文件链默认最多 2 条并行;配置 `changesConcurrency` 为 1~4(1 回退串行),跨文件综合等待相关前置主审结束。配置 `maxRetrievalRounds` 为 0~50,默认为 3,限制每阶段检索请求回合数(不是成功读取次数;非法请求也会消耗回合)(0 只允许最终答复)。配置不是 preview 参数,不改变模型或来源授权;并发仍共享同一份读取/调用预算,额度不足时完成顺序可能影响哪些任务未完成,不能保证与串行逐项结果相同。提供方限流时应降低并发。复核可预取批准快照中候选锚点附近的同侧原文,仍允许追加反证读取并逐字校验;不复用主审结论或其他会话凭据。完整变更计划可容纳时仅生成一次再分发到窗口,否则保留原分窗口计划并在预览估算中体现。
242
+ 变更审查的独立文件链默认最多 2 条并行;配置 `changesConcurrency` 为 1~4(1 回退串行),启用阶段续审时先扫描全体文件主审,再调度跨窗口/跨文件综合;恢复队列按真实依赖就绪情况排队,保留其他阶段的读取最低份额。配置 `maxRetrievalRounds` 为 0~50,默认为 3,限制每阶段检索请求回合数(不是成功读取次数;非法请求也会消耗回合)(0 只允许最终答复)。配置不是 preview 参数,不改变模型或来源授权;并发仍共享同一份读取/调用预算,额度不足时完成顺序可能影响哪些任务未完成,不能保证与串行逐项结果相同。提供方限流时应降低并发。复核可预取批准快照中候选锚点附近的同侧原文,仍允许追加反证读取并逐字校验;不复用主审结论或其他会话凭据。完整变更计划可容纳时仅生成一次再分发到窗口,否则保留原分窗口计划并在预览估算中体现。
243
243
 
244
- 大文件超出单次输入时按原行号分段。精确差异算不出时使用明确标注的包络,可能包含未变行,不声称精确归因。完整结束但 JSON 语法无效时会按当前阶段 schema 最多请求两次明确纠正,记录额外调用;检索请求非法时整批不执行并明确纠正,回填采用可追溯的滚动工作集、必要时延后重复窗口正文,不虚构证据。主审与复核默认各最多 3 轮检索;到限后只允许最终答复,保留未完成范围,不把已用完预算当成审核通过。不会给错误输出补括号、猜字段或当成无缺陷。
244
+ 大文件超出单次输入时按原行号分段。精确差异算不出时使用明确标注的包络,可能包含未变行,不声称精确归因。完整结束但 JSON 语法无效时会按当前阶段 schema 最多请求两次明确纠正,记录额外调用;检索请求非法时整批不执行并明确纠正,回填采用可追溯的滚动工作集、必要时延后重复初始源码正文,保留精确编辑、风险/调用线索和已读证据回执,不虚构证据。主审与复核默认各最多 3 轮检索;到限后只允许最终答复,保留未完成范围,不把已用完预算当成审核通过。不会给错误输出补括号、猜字段或当成无缺陷。
245
245
 
246
246
  ### 显式续审与私有模型工件
247
247
 
package/RELEASE-NOTES.md CHANGED
@@ -1,17 +1,19 @@
1
- # v0.87.4 发布说明
1
+ # v0.87.5 发布说明
2
2
 
3
- ## 默认报告先给人能读懂的结论
3
+ ## 修复审核执行链路,而不只是整理报告
4
4
 
5
- 默认输出不再把执行台账平铺为报告。仍只交付一份统一 Markdown:先说明是否查完、形成了什么问题记录,再按复核支持本次新增/当前源码问题、待核实、原有问题、已反驳分区,先讲触发条件、影响和建议。没有问题记录不等于没有缺陷,主审完成不等于可以放行。
5
+ 先完成全体文件的主审扫描,再运行跨窗口、跨文件综合,减少后续分析挤占尚未主审文件的模型调用。续审队列按真实依赖就绪集合排队,不再让依赖未满足的 verifier 队头阻挡可运行的 primary;精确已知请求可使用自身保留容量或缓存首次恢复,不扩大共享预算。
6
6
 
7
- 未完成范围按原因和受影响条目聚合,区分单阶段检索轮数、读取分配或共享额度、输入预算和前置依赖。完整证据、逐项覆盖、计划、执行诊断和保留归属的限制放在同一报告的折叠技术附录;重复限制只集中显示一次原文,保留次数、差异字段及可重建的原记录。
7
+ 跟进取证超出输入上限时,移除重复的初始源码正文,保留身份、精确编辑、调用/风险线索、已读取证据正文及回执;初始输入和可容纳的小请求不变。
8
8
 
9
- ## 保留真实性与证据边界
9
+ 阶段停止原因按 fileIds 关联回报告,明确区分前置依赖、读取分配等待、调用预算、超时和尝试耗尽。一个阶段的检索限制不再阻止其他健康阶段缓存已接受生成;原文件仍如实保留受限状态。
10
10
 
11
- 此修复仅改变本地呈现,不额外调用模型、不重新审核业务源码、不改变读取和执行预算、不放宽证据或新增归因校验。原始报告 JSON 保持完整。分页不足、取消、失败、过期、复用、窗口/复核/跨文件未完成仍明确披露;项目报告保留结论冲突的候选,SVN 历史不再误标为 Git。
11
+ ## 参考与边界
12
12
 
13
- ## 验证
13
+ 参考用户指定的 [Open Code Review](https://github.com/alibaba/open-code-review) 的冻结覆盖清单、结构化停止和预算耗尽后保全发现机制;核查本地 a758d9cb 与远端 5c039e815023a05a169b1240ae6b4b24c28270b6 的相关变更。采用独立实现,未复制其源码或模板。不照搬“已有评论即完成”的覆盖推断,也不将 Git merge-base 流程移植为 SVN revision 语义。
14
14
 
15
- 新增报告端到端、问题卡、限制聚合与安全转义测试,并使用保留归档中74条目、290限制、34跨文件未完成这一计数模式的合成呈现夹具回归。公开夹具不含原始路径、源码或限制正文;另在本地验证真实归档呈现,不是新的源码审核结论。发布以全量测试及四矩阵标签 CI 为门禁。
15
+ 本次不增加读取总额、不重置检索轮数或重试次数、不放宽证据与新增归因校验。Git/SVN 固定批准范围和独立复核回执仍有效;预算、超时或证据不足时仍可能 partial/uncertain。离线受控回归不是原业务 r7364 的重跑,不承诺真实模型必定查完或固定提速比例。
16
16
 
17
- 安装与使用见 [README](README.md),历史变化见 [变更记录](CHANGELOG.md),发布门禁见 [开发说明](DEVELOPMENT.md)。发布不自动安装或重启当前宿主。
17
+ ## 验证与升级
18
+
19
+ 新增 Git/SVN 主审优先、依赖队头阻塞、精确恢复公平性、完整阶段取证输入与停止原因关联回归,并执行全量测试和冻结标签四矩阵 CI。发布不自动安装或重启当前宿主。安装见 [README](README.md),门禁见 [开发说明](DEVELOPMENT.md)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feng0531/dsh-code-review",
3
- "version": "0.87.4",
3
+ "version": "0.87.5",
4
4
  "private": false,
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
@@ -5,12 +5,23 @@ const brief = value => { const text = String(value ?? ''); return escape(text.le
5
5
  const html = value => String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
6
6
  const stages = { primary: '主审', verification: '独立复核', synthesis: '综合', 'window-synthesis': '跨窗口综合', interaction: '跨文件综合' };
7
7
  const stage = value => Object.hasOwn(stages, value) ? stages[value] : String(value ?? '未标明阶段');
8
+ const reasonKeys = ['failureCode', 'blockCode', 'code', 'reason', 'pendingReason'];
9
+ const hasReason = record => typeof record === 'string' ? record !== '' : reasonKeys.some(key => record?.[key] != null && record[key] !== '');
10
+ const unknownReason = label => /未知|原因未记录/.test(label);
11
+ const unitKeys = ['id', 'kind', 'fileIds', 'dependencies', 'status', 'attempts', 'pendingReason', 'stopped'];
12
+ const unitMetadata = unit => Object.fromEntries(unitKeys.filter(key => Object.hasOwn(unit ?? {}, key)).map(key => [key, unit[key]]));
8
13
 
9
14
  function reason(record = {}) {
10
- const value = typeof record === 'string' ? record : record?.failureCode ?? record?.blockCode ?? record?.code ?? record?.reason;
15
+ const value = typeof record === 'string' ? record : record?.failureCode ?? record?.blockCode ?? record?.code ?? record?.reason ?? record?.pendingReason;
11
16
  const text = String(value ?? '');
12
17
  // Dependency state must not be relabelled as an input-budget failure.
13
18
  if (/dependenc|primary.*incomplete|member.*incomplete|prerequisite|依赖.*未完成|主审.*未完成/i.test(text)) return '依赖的主审或前置阶段未完成';
19
+ if (/STAGE_ATTEMPTS_EXHAUSTED/i.test(text)) return '阶段尝试次数达到上限(不等同于模型调用或读取额度耗尽)';
20
+ if (/STAGE_ALLOCATION_WAITING/i.test(text)) return '阶段等待读取分配(不代表总额度已耗尽)';
21
+ if (/STAGE_NO_PROGRESS/i.test(text)) return '阶段未取得进展而停止(未记录具体预算耗尽原因)';
22
+ if (/STAGE_INTERRUPTED/i.test(text)) return '阶段执行中断,尚待续审';
23
+ if (/STAGE_FAILED/i.test(text)) return '阶段执行失败';
24
+ if (/STAGE_TIMEOUT/i.test(text)) return '阶段执行超时';
14
25
  if (/RETRIEVAL_ROUND_BUDGET_EXCEEDED|检索轮数|retrieval.*round.*(budget|limit)/i.test(text)) return '单阶段检索轮数达到上限(读取额度可能仍有余量)';
15
26
  if (/READER_CALL_BUDGET_EXCEEDED|reader.*call.*(budget|limit)|读取调用.*(不足|耗尽|上限)/i.test(text)) return '阶段分配或共享读取调用次数不足';
16
27
  if (/READER_OUTPUT_BUDGET_EXCEEDED|reader.*output.*(budget|limit)|读取.*字节|输出字节/i.test(text)) return '阶段分配或共享读取输出字节预算不足';
@@ -23,19 +34,25 @@ function reason(record = {}) {
23
34
  return text ? '未知/其他原因(完整原文见附录)' : '原因未记录(未知;不推测为预算不足)';
24
35
  }
25
36
 
26
- function fileReasons(file, findings) {
37
+ function fileReasons(file, findings, units = []) {
27
38
  const result = [];
28
39
  const limits = [...rows(file?.retrievalLimits), ...rows(file?.groupRetrievalLimits)];
29
40
  const failures = rows(file?.stageFailures);
30
- const hasReason = record => record != null && ['failureCode', 'blockCode', 'code', 'reason'].some(key => record[key] != null && record[key] !== '');
41
+ // Compare phase-local evidence before rendering: a legacy unknown must not
42
+ // obscure a concrete continuation reason for that same stage. Raw values stay in the appendix.
43
+ const evidence = [...failures, ...limits, ...units.map(item => item.evidence)];
44
+ const phase = record => record?.phase ?? record?.stage ?? record?.kind;
45
+ const explained = record => unknownReason(reason(record)) && evidence.some(other => phase(record) != null && phase(other) === phase(record) && !unknownReason(reason(other)));
31
46
  if (['pending', 'blocked', 'failed', 'cancelled', 'running'].includes(file?.status)) {
32
47
  // A missing top-level code does not erase concrete phase/group evidence.
33
- const evidence = hasReason(file) ? [file] : [...failures, ...limits].filter(hasReason);
34
- result.push(...(evidence.length ? evidence : [file]).map(reason));
48
+ const causes = hasReason(file) && !explained(file) ? [file] : [...failures, ...limits].filter(record => hasReason(record) && !explained(record));
49
+ if (causes.length) result.push(...causes.map(reason));
50
+ else if (!units.length) result.push(reason(file));
35
51
  }
36
- for (const failure of failures) result.push(stage(failure?.phase ?? failure?.stage) + '未完成;' + reason(failure));
52
+ for (const failure of failures.filter(record => !explained(record))) result.push(stage(phase(failure)) + '未完成;' + reason(failure));
53
+ for (const item of units.filter(item => !explained(item.evidence))) result.push(stage(item.record.kind) + '未完成;' + reason(item.evidence));
37
54
  if (file?.retrievalLimited || limits.length) {
38
- result.push(...(limits.length ? limits.map(reason) : [reason(file)]));
55
+ result.push(...(limits.length ? limits.filter(record => !explained(record)).map(reason) : units.length ? [] : [reason(file)]));
39
56
  }
40
57
  if (rows(file?.windowCoverage?.pending).length) result.push('变更窗口尚未完成');
41
58
  if (file?.continuationPending) result.push('阶段续审尚未完成');
@@ -53,9 +70,32 @@ function collect(report) {
53
70
  if (record == null || typeof record !== 'object') return [];
54
71
  return [...new Set([...(record.path != null ? [String(record.path)] : []), ...(record.fileId != null ? [identity(record.fileId)] : []), ...rows(record.fileIds).map(identity)])];
55
72
  };
56
- const unfinished = files.map(record => ({ record, reasons: fileReasons(record, findings), paths: record?.path != null ? [String(record.path)] : paths(record) })).filter(item => item.reasons.length);
73
+ const allUnits = rows(report?.continuation?.units);
74
+ const byId = new Map(allUnits.filter(unit => unit?.id != null).map(unit => [unit.id, unit]));
75
+ const completedPrimary = rows(report?.continuation?.completedPrimary);
76
+ const dependency = id => {
77
+ const unit = byId.get(id);
78
+ if (unit) return { ...unitMetadata(unit), paths: paths(unit) };
79
+ if (typeof id === 'string' && id.startsWith('primary:')) {
80
+ const fileId = id.slice(8);
81
+ return { id, kind: 'primary', fileIds: [fileId], paths: [identity(fileId)], status: completedPrimary.includes(fileId) ? 'completed' : 'pending' };
82
+ }
83
+ return { id, status: 'unknown' };
84
+ };
85
+ const units = allUnits.flatMap((record, position) => {
86
+ if (!record || record.status === 'completed') return [];
87
+ const dependencies = rows(record.dependencies).map(dependency);
88
+ const blocked = dependencies.some(dep => dep.status !== 'completed');
89
+ // An explicit reason always wins. Dependency readiness is not a call-budget signal.
90
+ const evidence = !hasReason(record) && blocked ? { ...record, pendingReason: 'DEPENDENCY' } : record;
91
+ return [{ record, position, dependencies, evidence, paths: paths(record) }];
92
+ });
93
+ const unfinished = files.map(record => {
94
+ const associated = record?.fileId == null ? [] : units.filter(item => rows(item.record.fileIds).includes(record.fileId));
95
+ return { record, reasons: fileReasons(record, findings, associated), paths: record?.path != null ? [String(record.path)] : paths(record) };
96
+ }).filter(item => item.reasons.length);
57
97
  const interactions = rows(report?.interactions).filter(record => record?.status !== 'completed').map(record => ({ record, reasons: [reason(record)], paths: paths(record) }));
58
- return { unfinished, interactions, limitations: rows(report?.limitations), paths };
98
+ return { unfinished, interactions, units, limitations: rows(report?.limitations), paths };
59
99
  }
60
100
 
61
101
  function grouped(items) {
@@ -83,7 +123,7 @@ export function readabilityMarkdown(report = {}) {
83
123
  const output = ['', '## 未完成范围与原因', '以下为处理状态与限制记录数,非缺陷数;同一文件可能涉及多个阶段,限制不自动表示待审或缺陷。'];
84
124
  summary(output, '未完成条目', data.unfinished);
85
125
  summary(output, '跨文件综合未完成', data.interactions);
86
- if (!data.unfinished.length && !data.interactions.length) output.push(report?.partial || data.limitations.length ? '- 报告仍有部分完成标记或限制;未逐项记录待续审范围,不表示全部查完。' : '- 未记录未完成条目或跨文件综合;这不是正确性证明。');
126
+ if (!data.unfinished.length && !data.interactions.length) output.push(report?.status === 'partial' || report?.partial || data.limitations.length ? '- 报告仍有部分完成标记或限制;未逐项记录待续审范围,不表示全部查完。' : '- 未记录未完成条目或跨文件综合;这不是正确性证明。');
87
127
  if (data.limitations.length) output.push('- 另有 ' + data.limitations.length + ' 条限制记录,按原文聚合保留在本报告限制附录;不将这些限制全部计入未完成范围。');
88
128
  if (data.unfinished.length || data.interactions.length) output.push('- 以上仅列有界摘要;全部路径、身份、阶段及原始记录见本报告限制附录。');
89
129
  output.push('', '## 下一步');
@@ -139,9 +179,13 @@ export function limitationDetailsMarkdown(report = {}) {
139
179
  for (const item of items) output.push('- ' + (item.paths.length ? item.paths.map(escape).join('、') : '未标明文件身份') + ':' + item.reasons.map(escape).join(';'));
140
180
  // State/reason metadata includes window IDs and pass statuses; full source and candidates already
141
181
  // exist in the complete report JSON and must not be duplicated into this appendix.
142
- const keys = ['fileId', 'path', 'groupId', 'fileIds', 'status', 'failureCode', 'blockCode', 'code', 'reason', 'stage', 'phase', 'stageFailures', 'retrievalLimited', 'retrievalLimits', 'groupRetrievalLimits', 'continuationPending', 'synthesisStatus', 'verificationStatus', 'windowCoverage', 'reviewPasses'];
182
+ const keys = ['fileId', 'path', 'groupId', 'fileIds', 'status', 'failureCode', 'blockCode', 'code', 'reason', 'pendingReason', 'stage', 'phase', 'stageFailures', 'retrievalLimited', 'retrievalLimits', 'groupRetrievalLimits', 'continuationPending', 'synthesisStatus', 'verificationStatus', 'windowCoverage', 'reviewPasses'];
143
183
  output.push('以下仅为状态与原因元数据;源码、候选及其他完整字段见完整源报告 JSON。');
144
184
  originalDetails(output, packed(items.map(item => Object.fromEntries(keys.filter(key => Object.hasOwn(item.record ?? {}, key)).map(key => [key, item.record[key]])))));
145
185
  }
186
+ if (data.units.length) {
187
+ output.push('', '### 未完成阶段续审元数据', '按 continuation.units 原始索引保留对应阶段及依赖身份;record 为原始状态元数据,resolvedReason 仅为展示解释,不覆盖原始 pendingReason。');
188
+ originalDetails(output, data.units.map(item => ({ position: item.position, record: unitMetadata(item.record), paths: item.paths, resolvedReason: reason(item.evidence), dependencies: item.dependencies })));
189
+ }
146
190
  return output;
147
191
  }
@@ -7,16 +7,33 @@ export { RETRIEVAL_INSTRUCTIONS };
7
7
 
8
8
  function compactFollowup(input) {
9
9
  const p = JSON.parse(input);
10
- if (p.windows) {
11
- p.windows = p.windows.map(w => ({ ...w, old: { ...w.old, text: undefined }, new: { ...w.new, text: undefined } }));
12
- p.windowBodyNotice = 'Window bodies deferred; use approved catalog read. Omitted results are not evidence.';
13
- }
14
- if (p.sourceMode === 'window-synthesis') {
15
- for (const key of ['contextRelations', 'contextRelationNotice', 'cppCallSites', 'cppCallNotice', 'callerBindings', 'callerBindingNotice', 'windowNotice', 'defectGuidance', 'contextNotice', 'contextMode']) delete p[key];
16
- if (p.changes) p.changes = { status: p.changes.status, precision: p.changes.precision, edits: p.changes.edits };
17
- p.followupNotice = 'Synthesis metadata compacted; windowIndex retains original coordinates. Read approved sides for evidence.';
18
- }
19
- return JSON.stringify(p);
10
+ let removed = false;
11
+ const strip = (source, keys = ['text']) => {
12
+ if (!source || typeof source !== 'object') return;
13
+ for (const key of keys) if (typeof source[key] === 'string' && source[key].length) {
14
+ delete source[key];
15
+ removed = true;
16
+ }
17
+ };
18
+ const file = value => {
19
+ if (!value || typeof value !== 'object') return;
20
+ // Full snapshot files use left/right; windows use old/new. Never traverse
21
+ // arbitrary claims, exact edits, or retrieved results looking for text.
22
+ for (const side of ['left', 'right', 'old', 'new']) strip(value[side]);
23
+ };
24
+ const envelope = value => {
25
+ file(value.file);
26
+ for (const key of ['relatedFiles', 'files', 'windows']) {
27
+ if (Array.isArray(value[key])) value[key].forEach(file);
28
+ }
29
+ if (Array.isArray(value.context)) value.context.forEach(item => strip(item, ['text', 'oldText']));
30
+ };
31
+ envelope(p);
32
+ if (Array.isArray(p.reviews)) p.reviews.forEach(envelope);
33
+ if (!removed) return input;
34
+ p.followupSourceNotice = 'Repeated initial source bodies deferred; identity, sides and original coordinates remain. Use approved catalog reader operations for source evidence. Previously supplied bodies are not independent reads or complete coverage; deferred/omitted bodies are not evidence. Retrieved results and receipts are unchanged.';
35
+ const compact = JSON.stringify(p);
36
+ return Buffer.byteLength(compact) < Buffer.byteLength(input) ? compact : input;
20
37
  }
21
38
 
22
39
  export function retrievalRequest(request, scope, retrieved = []) {
@@ -105,7 +122,7 @@ export async function retrievalLoop(model, request, scope, { correctProtocol = f
105
122
  let parsed = pendingResponse;
106
123
  if (!parsed) {
107
124
  let { input, instructions } = nextRequest(retrieved);
108
- if (correctProtocol && retrieved.length && !fits({ ...request, input, instructions })) input = compactFollowup(input);
125
+ if (correctProtocol && round > 0 && !fits({ ...request, input, instructions })) input = compactFollowup(input);
109
126
  if (!fits({ ...request, input, instructions })) throw new Error('Retrieval input budget exceeded');
110
127
  request.signal?.throwIfAborted();
111
128
  beforeCall();
@@ -174,7 +191,7 @@ export async function retrievalLoop(model, request, scope, { correctProtocol = f
174
191
  next.instructions = request.instructions + '\n' + RETRIEVAL_INSTRUCTIONS + FINAL_SUFFIX;
175
192
  }
176
193
  next.input = JSON.stringify(value);
177
- if (correctProtocol) next.input = compactFollowup(next.input);
194
+ if (correctProtocol && !fits(next)) next.input = compactFollowup(next.input);
178
195
  return next;
179
196
  }, signal: request.signal,
180
197
  });
@@ -186,7 +203,7 @@ export async function retrievalLoop(model, request, scope, { correctProtocol = f
186
203
  let candidate = [...retrieved, item];
187
204
  let next = nextRequest(candidate);
188
205
  if (correctProtocol) {
189
- while (retrieved.length && !fits({ ...next, input: compactFollowup(next.input) })) {
206
+ while (retrieved.length && !fits(next) && !fits({ ...next, input: compactFollowup(next.input) })) {
190
207
  retrieved.shift();
191
208
  await request.onProtocolEvent?.({ action: 'prior-retrieval-deferred', notice: 'Prior returned fragment no longer in current prompt; reread approved source for evidence.' });
192
209
  candidate = [...retrieved, item];
@@ -30,24 +30,39 @@ export function createReviewReaderAllocation(root, units, { maxResumeAttempts =
30
30
  return { ...remaining, remainingCalls: Math.max(0, remaining.remainingCalls - protectedCalls), remainingOutputBytes: Math.max(0, remaining.remainingOutputBytes - protectedBytes) };
31
31
  };
32
32
  const check = (state, cost) => { const remaining = available(state); if (cost.calls > remaining.remainingCalls) fail('call'); if (cost.outputBytes > remaining.remainingOutputBytes) fail('output'); };
33
- const resumeWithCost = (id, costForAttempt) => {
34
- const state = states.get(id);
33
+ // Only the host knows dependencies. An explicit COMPLETE current ready-ID set
34
+ // filters FIFO opportunities, never the protected floors in available().
35
+ const readyIds = eligibleIds => {
36
+ if (eligibleIds === undefined) return null;
37
+ if (!(Array.isArray(eligibleIds) || eligibleIds instanceof Set) || eligibleIds.size > states.size || eligibleIds.length > states.size) throw new Error('Invalid reader resume eligibility');
38
+ const ready = new Set(eligibleIds);
39
+ if ([...ready].some(id => !states.has(id))) throw new Error('Invalid reader resume eligibility');
40
+ return ready;
41
+ };
42
+ const resumeWithCost = (id, costForAttempt, { eligibleIds, exact = false } = {}) => {
43
+ const state = states.get(id), ready = readyIds(eligibleIds);
35
44
  if (!state || state.released || !state.yielded) throw new Error('Reader allocation unit unavailable');
36
45
  if (state.resumeAttempts >= maxResumeAttempts) return { resumed: false, reason: 'READER_RESUME_ATTEMPTS_EXHAUSTED', attempts: state.resumeAttempts };
37
- const head = yielded.find(key => { const row = states.get(key); return row && !row.released && row.yielded && row.resumeAttempts < maxResumeAttempts && row.yieldRevision < releaseRevision; });
38
- if (head !== id || state.yieldRevision >= releaseRevision) return { resumed: false, reason: 'READER_RESUME_WAITING', attempts: state.resumeAttempts };
39
- state.resumeAttempts++; state.yieldRevision = releaseRevision;
40
- yielded.splice(yielded.indexOf(id), 1); yielded.push(id);
46
+ const waiting = () => ({ resumed: false, reason: 'READER_RESUME_WAITING', attempts: state.resumeAttempts });
47
+ // One first exact admission may use existing own-floor/cache capacity. Later
48
+ // opportunities require a release; readiness changes cannot reset this gate.
49
+ const opportunity = row => row.yieldRevision < releaseRevision || (exact && row.resumeAttempts === 0);
50
+ const head = yielded.find(key => { const row = states.get(key); return (!ready || ready.has(key)) && row && !row.released && row.yielded && row.resumeAttempts < maxResumeAttempts && opportunity(row); });
51
+ if (head !== id || !opportunity(state)) return waiting();
52
+ let failure = null;
41
53
  try {
42
54
  const cost = costForAttempt();
43
55
  if (!cost || !Number.isSafeInteger(cost.calls) || cost.calls < 0 || !Number.isSafeInteger(cost.outputBytes) || cost.outputBytes < 0) throw new Error('Reader allocation preflight unavailable');
44
56
  check(state, cost);
45
- state.yielded = false; state.pendingReason = null; yielded.splice(yielded.indexOf(id), 1);
46
- return { resumed: true, reason: null, attempts: state.resumeAttempts };
47
57
  } catch (error) {
48
58
  if (!['READER_CALL_BUDGET_EXCEEDED', 'READER_OUTPUT_BUDGET_EXCEEDED'].includes(error?.code)) throw error;
49
- return { resumed: false, reason: error.code, attempts: state.resumeAttempts };
59
+ failure = error.code;
50
60
  }
61
+ state.resumeAttempts++; state.yieldRevision = releaseRevision;
62
+ yielded.splice(yielded.indexOf(id), 1);
63
+ if (failure) { yielded.push(id); return { resumed: false, reason: failure, attempts: state.resumeAttempts }; }
64
+ state.yielded = false; state.pendingReason = null;
65
+ return { resumed: true, reason: null, attempts: state.resumeAttempts };
51
66
  };
52
67
  const scopes = new Map();
53
68
  return Object.freeze({
@@ -99,17 +114,17 @@ export function createReviewReaderAllocation(root, units, { maxResumeAttempts =
99
114
  state.yielded = true; state.yieldRevision = releaseRevision; state.pendingReason = reason; yielded.push(id);
100
115
  return { yielded: true, attempts: state.resumeAttempts };
101
116
  },
102
- resume(id, requests = []) {
117
+ resume(id, requests = [], { eligibleIds } = {}) {
103
118
  const state = states.get(id);
104
119
  if (!state || state.released || !state.yielded) throw new Error('Reader allocation unit unavailable');
105
120
  if (!Array.isArray(requests) || !requests.length) return { resumed: false, reason: 'READER_RESUME_REQUESTS_REQUIRED', attempts: state.resumeAttempts };
106
- return resumeWithCost(id, () => root.preflight(requests));
121
+ return resumeWithCost(id, () => root.preflight(requests), { eligibleIds, exact: true });
107
122
  },
108
123
  // Scalar admission is only a retry opportunity, never authority to read.
109
124
  // Every actual read still goes through exact preflight and root accounting.
110
- resumeBudget(id, { requiredCalls = 1, requiredOutputBytes = 1 } = {}) {
125
+ resumeBudget(id, { requiredCalls = 1, requiredOutputBytes = 1, eligibleIds } = {}) {
111
126
  if (![requiredCalls, requiredOutputBytes].every(value => Number.isSafeInteger(value) && value >= 0) || requiredCalls + requiredOutputBytes === 0) throw new Error('Invalid reader resume budget');
112
- return resumeWithCost(id, () => ({ calls: requiredCalls, outputBytes: requiredOutputBytes }));
127
+ return resumeWithCost(id, () => ({ calls: requiredCalls, outputBytes: requiredOutputBytes }), { eligibleIds });
113
128
  },
114
129
  release(id) { const state = states.get(id); if (!state) return; if (state.inTransaction) throw new Error('Reader allocation transaction active'); if (!state.released) { state.released = true; releaseRevision++; } },
115
130
  summary() { const usage = root.usage(); return { units: states.size, releasedUnits: [...states.values()].filter(state => state.released).length, calls: usage.calls, outputBytes: usage.outputBytes, ...(usage.cacheHits !== undefined ? { cacheHits: usage.cacheHits } : {}) }; },
package/src/review.mjs CHANGED
@@ -196,7 +196,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
196
196
  const generationCache = createStageGenerationCache(input, options.resumeState?.stageGenerations ?? []);
197
197
  const acceptedStages = new Map(generationCache.records().filter(record => record.phase === 'primary').map(record => [record.key, { generation: record.generation, record, restored: true }]));
198
198
  const primarySuspensions = new Map(), correctedStages = new Set();
199
- const deferredStages = [], yieldedUnits = new Set(), fileDeadlines = new Map(), primaryStageKeys = new Map();
199
+ const deferredStages = [], yieldedUnits = new Set(), queuedStageUnits = new Set(), fileDeadlines = new Map(), primaryStageKeys = new Map();
200
200
  const checkpointStages = () => {
201
201
  if (!options.enableStageContinuation) return;
202
202
  report.stageGenerations = generationCache.records().filter(record => record.phase === 'primary');
@@ -204,7 +204,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
204
204
  let readerAllocation;
205
205
  const stageScope = id => readerAllocation
206
206
  ? configureWireBudget(readerAllocation.scope(id), { nativeOutputTools: options.nativeOutputTools === true }) : scope;
207
- const releaseStage = id => { if (!yieldedUnits.has(id)) readerAllocation?.release(id); };
207
+ const releaseStage = id => { if (!yieldedUnits.has(id) && !queuedStageUnits.has(id) && !deferredStages.some(stage => !stage.done && stage.allocationId === id)) readerAllocation?.release(id); };
208
208
  const contextSeeds = (reader, request, existing = [], buildRequest = records => retrievalRequest(request, reader, records)) => {
209
209
  if (!evidenceContext || !reader || maxRetrievalRounds === 0) return [];
210
210
  const payload = JSON.parse(request.input), catalog = reader.catalog();
@@ -282,7 +282,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
282
282
  }
283
283
  if (signal?.aborted) { state.status = 'cancelled'; state.reason = '用户取消'; return; }
284
284
  const assignedGroup = [...groupPrepared].find(([, value]) => value.fileIds.includes(file.id));
285
- if (!assignedGroup && (report.modelCalls >= maxCalls || options.remainingCalls?.() <= 0)) { state.reason = '模型调用预算耗尽'; return; }
285
+ if (!assignedGroup && (report.modelCalls >= maxCalls || options.remainingCalls?.() <= 0)) { state.reason = '模型调用预算耗尽'; state.failureCode = 'PROJECT_MODEL_CALL_LIMIT'; return; }
286
286
  const prepared = preparePrimaryInput(input, file, { instructions, scope, grouping: report.grouping, maxInputBytes, riskPlanningMode: options.riskPlanningMode, focusPrimaryContext: options.focusPrimaryContext === true, enableRiskPlanning: options.enableRiskPlanning, skipSmallRiskPlans: options.skipSmallRiskPlans });
287
287
  const { changes, payload } = prepared;
288
288
  Object.assign(state, prepared.metadata);
@@ -300,8 +300,9 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
300
300
  if (report.modelCalls >= maxCalls) throw new Error('Model call budget exceeded');
301
301
  report.modelCalls++;
302
302
  };
303
- let activeReader = scope, activeStage = 'primary';
303
+ let activeReader = scope, activeStage = 'primary', activeStageLimited = false;
304
304
  const onBudgetLimited = info => {
305
+ activeStageLimited = true;
305
306
  if (activeStage !== 'primary') (state.secondaryRetrievalLimits ??= []).push({ phase: activeStage, ...info });
306
307
  state.retrievalLimited = true;
307
308
  (state.retrievalLimits ??= []).push(info);
@@ -404,6 +405,22 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
404
405
  const phase=batch.interaction?'file-interaction':batch.synthesis?'window-synthesis':'primary';
405
406
  try{
406
407
  activeStage = phase;
408
+ activeStageLimited = phase === 'primary' && !!groupedGeneration?.groupLimits?.length;
409
+ // Seal the complete primary sweep before spending reads on secondary synthesis.
410
+ // A scheduled dependency is not a reader failure and does not consume a retry.
411
+ if (options.enableStageContinuation && !retry && (batch.synthesis || batch.interaction)) {
412
+ if (!deferredStages.some(stage => stage.key === key)) deferredStages.push({
413
+ key, allocationId, fileId: file.id, phase: batch.synthesis ? 'synthesis' : 'interaction',
414
+ fileIds: batch.interaction?.fileIds ?? [file.id], attempts: 0, requests: [], reason: 'STAGE_DEPENDENCY_PENDING',
415
+ run: () => invoke(next => executeStage(batch, batchIndex, next, true), { instructions, input: payload }, timeoutMs > 0 ? Math.max(1, fileDeadlines.get(file.id) - performance.now()) : 0, signal),
416
+ });
417
+ queuedStageUnits.add(allocationId);
418
+ if (options.enableVerification) queuedStageUnits.add('verification:' + file.id);
419
+ if (batch.synthesis) state.continuationPending = true;
420
+ if (batch.synthesis) state.synthesisStatus = 'pending';
421
+ if (batch.interaction) Object.assign(report.interactions.find(item => item.groupId === batch.interaction.id), { status: 'pending', reason: 'STAGE_DEPENDENCY_PENDING' });
422
+ return;
423
+ }
407
424
  if (options.enableStageContinuation && (batch.synthesis || batch.interaction) && (batch.interaction?.fileIds ?? [file.id]).some(id => deferredStages.some(stage => stage.fileId === id && stage.phase === 'primary' && !acceptedStages.has(stage.key)))) {
408
425
  defer(Object.assign(new Error('Stage is waiting for accepted primary generations'), { code: 'READER_CALL_BUDGET_EXCEEDED' })); return;
409
426
  }
@@ -560,7 +577,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
560
577
  }
561
578
  request.signal.throwIfAborted();
562
579
  if(rounds>1)generated.findings.forEach(f=>{f.reviewRound=batch.round;});
563
- if (options.enableStageContinuation && !priorStage && !state.retrievalLimited) {
580
+ if (options.enableStageContinuation && !priorStage && !activeStageLimited) {
564
581
  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) } };
565
582
  const admission = generationCache.admit(record);
566
583
  if (admission.accepted) {
@@ -607,19 +624,19 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
607
624
  }
608
625
  }
609
626
  if (acceptedStages.has(key)) acceptedStages.get(key).completed = !options.enableVerification || generated.findings.every(finding => finding.verification?.status === 'completed');
610
- if (batch.synthesis) state.synthesisStatus = 'completed';
627
+ if (batch.synthesis) state.synthesisStatus = activeStageLimited ? 'pending' : 'completed';
611
628
  if (batch.interaction) {
612
629
  const interaction=report.interactions.find(p => p.groupId === batch.interaction.id);
613
630
  if (combinedInteraction) Object.assign(interaction, { method: 'group-primary-validated-channel', channelGroupId: combinedInteraction.groupId });
614
- if(rounds>1){interaction.completedRounds??=[];interaction.completedRounds.push(batch.round);interaction.reviewRounds=rounds;}
615
- if(batch.round===rounds)interaction.status='completed';
631
+ if(rounds>1){interaction.completedRounds??=[];if(!activeStageLimited)interaction.completedRounds.push(batch.round);interaction.reviewRounds=rounds;}
632
+ if(batch.round===rounds) { interaction.status = activeStageLimited || rounds > 1 && new Set(interaction.completedRounds).size !== rounds ? 'pending' : 'completed'; if (activeStageLimited) interaction.reason = state.secondaryRetrievalLimits?.at(-1)?.code ?? 'STAGE_FAILED'; }
616
633
  }
617
634
  if (state.windowCoverage && batch.round === 1) {
618
635
  state.windowCoverage.completed.push(...batch.windowIds);
619
636
  state.windowCoverage.pending = state.windowCoverage.pending.filter(id => !batch.windowIds.includes(id));
620
637
  }
621
638
  if(rounds>1)state.reviewPasses[batchIndex].status='completed';
622
- if (retry) { const pending = deferredStages.find(stage => stage.key === key); if (pending) pending.succeeded = !options.enableVerification || generated.findings.every(finding => finding.verification?.status === 'completed'); }
639
+ if (retry) { const pending = deferredStages.find(stage => stage.key === key); if (pending) { pending.succeeded = !activeStageLimited && (!options.enableVerification || generated.findings.every(finding => finding.verification?.status === 'completed')); if (activeStageLimited) pending.reason = state.retrievalLimits?.at(-1)?.code ?? 'STAGE_FAILED'; } }
623
640
  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});
624
641
  }catch(error){
625
642
  enclosingRequest.signal.throwIfAborted();
@@ -740,7 +757,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
740
757
  }, dependencies, fileConcurrency);
741
758
  if (options.enableStageContinuation === true && deferredStages.length && !signal.aborted) {
742
759
  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);
743
- const units = deferredStages.map((stage, index) => ({ id: hash(stage.key), kind: stage.phase, fileIds: stage.fileIds,
760
+ const units = deferredStages.map((stage, index) => ({ id: hash(stage.key), kind: stage.phase, fileIds: stage.fileIds, pendingReason: stage.reason,
744
761
  dependencies: stage.phase === 'primary'
745
762
  ? deferredStages.slice(0, index).filter(other => other.phase === 'primary' && other.fileId === stage.fileId).map(other => hash(other.key))
746
763
  : stage.fileIds.flatMap(id => { const pending = deferredStages.filter(other => other.phase === 'primary' && other.fileId === id); return pending.length ? pending.map(other => hash(other.key)) : ['primary:' + id]; }) }));
@@ -753,17 +770,26 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
753
770
  }
754
771
  for (let pass = 0; pass < 2; pass++) {
755
772
  let progressed = false;
756
- for (const stage of deferredStages) {
773
+ for (const stage of [...deferredStages].sort((a, b) => (a.phase === 'primary' ? 0 : a.phase === 'verification' ? 1 : 2) - (b.phase === 'primary' ? 0 : b.phase === 'verification' ? 1 : 2))) {
757
774
  if (stage.done || !continuation.ready().some(unit => unit.id === hash(stage.key))) continue;
758
- if (performance.now() >= fileDeadlines.get(stage.fileId) || signal.aborted || report.modelCalls >= maxCalls) { continuation.stop(hash(stage.key), 'STAGE_NO_PROGRESS'); continue; }
759
- const admitted = !yieldedUnits.has(stage.allocationId) ? { resumed: true } : stage.requests.length ? readerAllocation.resume(stage.allocationId, stage.requests) : readerAllocation.resumeBudget(stage.allocationId);
760
- if (!admitted.resumed) continue;
775
+ if (performance.now() >= fileDeadlines.get(stage.fileId) || signal.aborted || report.modelCalls >= maxCalls || options.remainingCalls?.() <= 0) {
776
+ stage.reason = signal.aborted ? 'STAGE_CANCELLED' : performance.now() >= fileDeadlines.get(stage.fileId) ? 'STAGE_TIMEOUT' : 'PROJECT_MODEL_CALL_LIMIT';
777
+ continuation.stop(hash(stage.key), stage.reason); continue;
778
+ }
779
+ const eligibleIds = [...new Set(deferredStages.filter(candidate => !candidate.done && continuation.ready().some(unit => unit.id === hash(candidate.key))).map(candidate => candidate.allocationId))];
780
+ const admitted = !yieldedUnits.has(stage.allocationId) ? { resumed: true } : stage.requests.length ? readerAllocation.resume(stage.allocationId, stage.requests, { eligibleIds }) : readerAllocation.resumeBudget(stage.allocationId, { eligibleIds });
781
+ if (!admitted.resumed) { stage.reason = ['READER_CALL_BUDGET_EXCEEDED', 'READER_OUTPUT_BUDGET_EXCEEDED'].includes(admitted.reason) ? admitted.reason : 'STAGE_ALLOCATION_WAITING'; continue; }
761
782
  yieldedUnits.delete(stage.allocationId);
783
+ if (!stage.allocationId.startsWith('verification:') || !deferredStages.some(other => other !== stage && !other.done && other.fileId === stage.fileId)) queuedStageUnits.delete(stage.allocationId);
762
784
  const token = continuation.start(hash(stage.key), { signal });
763
785
  stage.attempts++; await stage.run();
764
786
  progressed = true;
765
- 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); }
766
- else { continuation.yield(hash(stage.key), stage.reason); if (!yieldedUnits.has(stage.allocationId)) continuation.stop(hash(stage.key), 'STAGE_FAILED'); }
787
+ 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) && !queuedStageUnits.has(stage.allocationId)) readerAllocation?.release(stage.allocationId); }
788
+ else { const cause = stage.reason === 'STAGE_DEPENDENCY_PENDING' ? 'STAGE_FAILED' : stage.reason ?? 'STAGE_FAILED'; continuation.yield(hash(stage.key), cause); if (!yieldedUnits.has(stage.allocationId)) continuation.stop(hash(stage.key), cause); }
789
+ if (!deferredStages.some(other => !other.done && other.fileId === stage.fileId)) {
790
+ queuedStageUnits.delete('verification:' + stage.fileId);
791
+ releaseStage('verification:' + stage.fileId);
792
+ }
767
793
  }
768
794
  if (!progressed) break;
769
795
  }
@@ -771,6 +797,10 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
771
797
  for (const [id, keys] of primaryStageKeys) if (!primaryDone.includes(id) && keys.length && keys.every(key => acceptedStages.has(key))) primaryDone.push(id);
772
798
  report.stageContinuation = continuation.summary();
773
799
  report.continuation = continuation.checkpoint();
800
+ for (const unit of report.continuation.units) if (unit.status !== 'completed') {
801
+ const stage = deferredStages.find(item => hash(item.key) === unit.id);
802
+ if (stage?.reason && !unit.stopped) unit.pendingReason = stage.reason;
803
+ }
774
804
  report.continuationOptions = continuationOptions;
775
805
  for (const file of input.files) {
776
806
  const result = fileResults[input.files.indexOf(file)];
@@ -780,7 +810,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
780
810
  checkpointStages();
781
811
  await options.onResumeCheckpoint?.({ ...report, files: fileResults.map(result => result.state), findings: fileResults.flatMap(result => result.findings), limitations: [...report.limitations, ...fileResults.flatMap(result => result.limitations)] });
782
812
  }
783
- for (const id of yieldedUnits) readerAllocation?.release(id);
813
+ for (const id of new Set([...yieldedUnits, ...queuedStageUnits])) readerAllocation?.release(id);
784
814
  if (checkpointFailed) throw checkpointFailure;
785
815
  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'); }
786
816
  report.files = fileResults.map(result => result.state);
@@ -795,7 +825,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
795
825
  report.selection = { total: input.files.length, reviewable: reviewableCount, excluded: input.files.filter(f => f.eligibility === 'excluded').length, blocked: input.files.filter(f => f.eligibility === 'blocked').length };
796
826
  if (!reviewableCount) report.limitations.push({ text: input.files.length ? '没有可审查文件;排除或阻塞不表示代码审核通过。' : '未选择变更文件,没有执行代码审核。' });
797
827
  const unfinished = report.files.some(f => !['completed', 'excluded'].includes(f.status));
798
- report.status = signal?.aborted ? 'cancelled' : unfinished || report.limitations.length ? 'partial' : 'completed';
828
+ report.status = signal?.aborted ? 'cancelled' : unfinished || report.limitations.length || report.stageContinuation?.pending || report.files.some(file => file.synthesisStatus && file.synthesisStatus !== 'completed') ? 'partial' : 'completed';
799
829
  if (report.files.some(f => f.status === 'failed') && !report.files.some(f => f.status === 'completed')) report.status = signal?.aborted ? 'cancelled' : 'failed';
800
830
  report.coverage = Object.fromEntries(['completed', 'excluded', 'blocked', 'failed', 'cancelled', 'pending'].map(s => [s, report.files.filter(f => f.status === s).length]));
801
831
  if (scope) {
@@ -804,7 +834,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
804
834
  }
805
835
  report.regressionCoverage = Object.fromEntries(['introduced', 'preexisting', 'uncertain', 'unassessed'].map(classification => [classification, report.findings.filter(f => (f.verification?.regression?.classification ?? 'unassessed') === classification).length]));
806
836
  report.findingGroups = groupDuplicateFindings(report.findings);
807
- 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');
837
+ for (const state of report.files) state.resumeEligible = state.status === 'completed' && !state.continuationPending && (!state.synthesisStatus || state.synthesisStatus === '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');
808
838
  await options.onResumeCheckpoint?.(report);
809
839
  report.followup = coverageFollowup(report);
810
840
  if (scope) report.readerBudget = { ...scope.usage(), ...scope.budget(), ...readerLimits };
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  const KINDS = new Set(['primary', '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']);
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', 'STAGE_DEPENDENCY_PENDING', 'STAGE_ALLOCATION_WAITING', 'PROJECT_MODEL_CALL_LIMIT', 'RETRIEVAL_ROUND_BUDGET_EXCEEDED', 'RETRIEVAL_FINAL_RESPONSE_REQUIRED']);
4
4
  const fail = code => Object.assign(new Error(code), { code });
5
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
6
  const digest = value => createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex');
@@ -15,7 +15,7 @@ export function createStageContinuation(snapshot, options, { completedPrimary =
15
15
  const primary = [...completedPrimary].sort(), states = new Map();
16
16
  for (const unit of units) {
17
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 });
18
+ states.set(unit.id, { id: unit.id, kind: unit.kind, fileIds: [...unit.fileIds], dependencies: [...(unit.dependencies ?? [])], status: 'pending', attempts: 0, pendingReason: REASONS.has(unit.pendingReason) ? unit.pendingReason : null, stopped: false });
19
19
  }
20
20
  const visit = (id, visiting = new Set(), visited = new Set()) => {
21
21
  if (visited.has(id)) return;