@feng0531/dsh-code-review 0.88.0 → 0.89.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 +7 -0
- package/README.md +5 -1
- package/RELEASE-NOTES.md +9 -9
- package/index.mjs +8 -1
- package/package.json +2 -2
- package/src/child-output-schema.mjs +22 -0
- package/src/host-review-children.mjs +100 -0
- package/src/review-child-broker.mjs +65 -0
- package/src/review-child-plugin.mjs +32 -0
- package/src/service.mjs +10 -6
- package/src/whole-scope-review.mjs +45 -15
- package/src/whole-scope-workers.mjs +88 -0
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.89.0 — 超限分组的真实子智能体并行
|
|
6
|
+
|
|
7
|
+
- 整体路径只有完整输入超限分组时创建真实spawn子会话,默认2组、可配1~4;同组持续上下文补读,主审全部收尾后集中复核。默认策略仍staged。
|
|
8
|
+
- 受限快照工具强绑定child身份,共用唯一读取账本和模型生成尝试额度;能力或身份变化拒绝,不隐藏串行回退。逻辑LLM输入检查不冒充最终HTTP字节。
|
|
9
|
+
- FIFO检查点、取消join和首个致命原因保真,按批准顺序汇总;跨组未核查仍partial。
|
|
10
|
+
- 实际宿主独立离线Context通过两个真实child的并行、持续读取和结构化终态验证;模型、快照和cwd为fixture,不宣称真实提供方质量或业务提速。
|
|
11
|
+
|
|
5
12
|
## 0.88.0 — 整体主审实验路径与自动回归
|
|
6
13
|
|
|
7
14
|
- 新增whole-scope完整文件字节合批与候选集中复核,显式输入容量绑定预览和执行;默认仍staged,fresh单轮changes限定,超大文件及跨批缺口如实保留。
|
package/README.md
CHANGED
|
@@ -280,7 +280,11 @@ Git / SVN 历史审查也能使用这些参数;依赖和调用方按对应版
|
|
|
280
280
|
|
|
281
281
|
插件配置 `executionStrategy: "whole-scope"` 启用完整文件按字节合批的变更审查。能装下时,一次主审同时检查全部成员;超限才分批,不按四个文件强切,不再额外调用逐文件风险规划、业务分组及旧专门综合。候选集中独立复核,缺少上下文时仍可能增加检索调用,因此不承诺总调用仅一次。
|
|
282
282
|
|
|
283
|
-
`
|
|
283
|
+
实际超限分为多组时,整体路径使用宿主真实 `spawn` 子智能体有界并行,`wholeScopeConcurrency` 可设1~4(默认2);只有一组时不创建子智能体。每组保持独立会话和持续上下文,补读不重新建组;全部主审收尾后集中复核。宿主须具备受限预设、结构化子会话和活动父Agent;能力缺失或变化明确拒绝,不暗中退回串行。子智能体共用唯一批准快照读取账本与模型生成尝试总账,取消须等待所有子会话清理。跨组关系未核查仍partial,并行不提高完成标准。
|
|
284
|
+
|
|
285
|
+
内部 `review_snapshot` 工具可能在父会话目录可见,但只有本次注册且身份绑定的审查子会话可执行;父会话和其他子会话读取前拒绝。子会话作为宿主管理的独立Session按宿主规则记录,可能含批准源码和工具响应,不等同于插件私有模型工件留存。
|
|
286
|
+
|
|
287
|
+
`maxInputBytes` 是插件显式输入保护,不是模型上下文容量探测;默认仍为 98304 字节,允许配置为 1~4194304 的整数。主审计划计入完整新旧正文、JSON转义、规则、检索目录、指令与原生结果工具;持续子会话发送前另外检查包含历史消息和工具的逻辑LLM envelope,不是提供方最终HTTP字节。宿主系统提示与历史开销可能使已规划组在发送前受限,届时保留未完成而不截断源码。预览的 `plan.maxInputBytes` 与执行使用同一值。提高前应核实当前模型容量、费用及提供方限制,插件不会自动猜测容量或因失败不断加大输入重试。
|
|
284
288
|
|
|
285
289
|
当前仅支持 fresh 单轮 changes;多轮、旧任务恢复及 project 明确拒绝,预览显示 `resumeSupported:false`。超大完整文件显示 blocked,不截断或把差异窗口冒充全文;多批跨边界未核查保持 partial。身份、原文定位、归因、独立复核回执及来源新鲜度校验不变。默认仍为 `staged`;整体路径尚无真实业务同条件质量与耗时验收,不宜把单测调用减少当作业务问题已解决。配置由宿主插件管理方式加载,发布不会自动安装或重启宿主。
|
|
286
290
|
|
package/RELEASE-NOTES.md
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
# v0.
|
|
1
|
+
# v0.89.0 发布说明
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 真正超限后,分组由子智能体并行处理
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
整体路径保持能装下就整体主审;只有完整输入超限形成多个组时,使用宿主真实spawn子智能体,`wholeScopeConcurrency` 默认2、可设1~4。每组一个独立Session和持续上下文,补读使用原会话,不把普通ctx.llm并发包装称为子智能体。全部主审收尾后仍集中独立复核;默认策略仍staged,整体路径需显式开启。
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
预览绑定并发数及执行方式,执行前重核宿主能力;缺失受限预设或结构化子会话能力明确拒绝,不隐藏回退。子会话只获得批准快照broker与结构化提交工具,不继承任意文件、shell、网络或递归委托能力。内部snapshot工具虽可能在父目录可见,但父会话及未绑定child在读取前拒绝。
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
多个child共享唯一读取账本与同步模型生成尝试总账;每次实际逻辑LLM调用先校验身份、路由、工具集合与输入容量再扣账。这里统计模型生成尝试和逻辑LLM输入,不声称统计adapter内部HTTP重试或最终HTTP字节。宿主提示及历史开销可能使规划组在发送前受限,不截断完整文件掩盖。
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
取消主动停止并等待子会话清理;检查点FIFO入队时冻结,存储错误中断并等待其他工作收尾,保留首个致命原因。统一报告按批准顺序汇总。多组跨边界未核查仍partial,不降低质量门槛。子会话由宿主按独立Session记录,可能含批准源码,不等同插件私有工件存储;不自动读这些会话作为审查证据。
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
## 验证边界
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
使用实际安装宿主的Agent/Session/Inbox/Loop/LLM/原生执行器/预设/子智能体运行时,在独立离线Context中验证file与group结构化路径:两个真实child并行,每个同id两次模型生成,中间读取批准fixture后保留上下文;只暴露snapshot与structured_output,结构化终态完成后释放,未绑定父读取拒绝。工作目录get/set、snapshot和模型提供方使用离线fixture,没有真实提供方调用、没有修改运行宿主。此证据证明执行契约,不证明真实业务质量、r7364闭环或提速。
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
本版不自动安装、改配置或重启宿主。配置见[使用说明](README.md),发布门禁见[开发说明](DEVELOPMENT.md)。
|
package/index.mjs
CHANGED
|
@@ -2,6 +2,9 @@ import { registerBackgroundReview } from './src/background-review.mjs';
|
|
|
2
2
|
import { createReviewService } from './src/service.mjs';
|
|
3
3
|
import { DEFAULT_PROJECT_CONCURRENCY } from './src/project-execution.mjs';
|
|
4
4
|
import { reviewToolOutput } from './src/tool-view.mjs';
|
|
5
|
+
import { createHostReviewChildren } from './src/host-review-children.mjs';
|
|
6
|
+
import { attachReviewChildMiddleware } from './src/review-child-broker.mjs';
|
|
7
|
+
import { registerSnapshotTool } from './src/review-child-plugin.mjs';
|
|
5
8
|
|
|
6
9
|
export const name = 'dsh-code-review';
|
|
7
10
|
export const inject = ['tools', 'llm'];
|
|
@@ -14,7 +17,11 @@ const EXECUTE_PARAMETERS = { type: 'object', additionalProperties: false, proper
|
|
|
14
17
|
export function apply(ctx, config = {}) {
|
|
15
18
|
// This entry runs only when the host invokes the registered tool.
|
|
16
19
|
// Host tool policy owns permission; do not request a second approval here.
|
|
17
|
-
const
|
|
20
|
+
const hostChildren = createHostReviewChildren(ctx);
|
|
21
|
+
registerSnapshotTool(ctx);
|
|
22
|
+
attachReviewChildMiddleware(ctx);
|
|
23
|
+
ctx.on('dispose', () => hostChildren.dispose());
|
|
24
|
+
const service = createReviewService(ctx.llm, { hostChildren, wholeScopeConcurrency: config.wholeScopeConcurrency ?? 2, executionStrategy: config.executionStrategy ?? 'staged', maxInputBytes: config.maxInputBytes ?? 96 * 1024, allowModelSending: true, nativeOutputTools: true, enableGroupPrimary: true, enableEvidenceContext: config.enableEvidenceContext ?? true, inlineInteractionRisks: config.inlineInteractionRisks ?? true, enableMigrationContracts: config.enableMigrationContracts ?? true, enableMigrationRegression: config.enableMigrationRegression ?? true, enableGroupInteractions: config.enableGroupInteractions ?? true, enableStageContinuation: config.enableStageContinuation ?? true, enableRiskPlanning: true, enableBusinessGrouping: true, enableAnchorCorrection: true, projectConcurrency: config.projectConcurrency ?? DEFAULT_PROJECT_CONCURRENCY, riskPlanningMode: config.riskPlanningMode ?? 'auto', readerBudgetPolicy: config.readerBudgetPolicy ?? 'scaled', optimizeReviewCalls: true, enablePrimarySeeds: true, focusPrimaryContext: config.focusPrimaryContext ?? true, enableSearchSeeds: config.enableSearchSeeds ?? true, changesConcurrency: config.changesConcurrency ?? 2, maxRetrievalRounds: config.maxRetrievalRounds ?? 3, authorize: async () => true });
|
|
18
25
|
ctx.tools.register({
|
|
19
26
|
name: 'code_review_preview', description: PREVIEW_DESCRIPTION, parameters: PREVIEW_PARAMETERS, output: reviewToolOutput,
|
|
20
27
|
async execute(args, exec) { const preview = await service.preview(args, exec); return { json: JSON.stringify(preview), markdown: '' }; },
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feng0531/dsh-code-review",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.89.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
|
|
7
7
|
"main": "./index.mjs",
|
|
8
|
-
"exports": { ".": "./index.mjs", "./package.json": "./package.json", "./locale/*.json": "./locale/*.json" },
|
|
8
|
+
"exports": { ".": "./index.mjs", "./package.json": "./package.json", "./child": "./src/review-child-plugin.mjs", "./locale/*.json": "./locale/*.json" },
|
|
9
9
|
"files": ["scripts/review-bench-adapter.mjs", "evaluation/REVIEWBENCH-LOCAL.md", "index.mjs", "cordis.patch.yml", "src/", "locale/", "README.md", "CHANGELOG.md", "RELEASE-NOTES.md", "DEVELOPMENT.md", "CORE-ALIGNMENT-TASKS.md", "CORE-SOURCE-INVENTORY.md", "SVN-SUPPORT-MATRIX.md", "SVN-RELEASE-EVIDENCE.md", "LICENSE", "LICENSES/"],
|
|
10
10
|
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } },
|
|
11
11
|
"repository": { "type": "git", "url": "https://github.com/f-e-n-g-0531/dsh-code-review.git" },
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// The host's structured_output accepts a documented finite JSON Schema subset.
|
|
2
|
+
// Numeric/length bounds remain mandatory in the ordinary review validators.
|
|
3
|
+
const annotations = new Set(['title', 'description', 'default']);
|
|
4
|
+
const scalars = new Set(['type', 'enum', 'const', 'required', 'additionalProperties', ...annotations]);
|
|
5
|
+
export function childOutputSchema(schema) {
|
|
6
|
+
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) throw new Error('Invalid child output schema');
|
|
7
|
+
const result = {};
|
|
8
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
9
|
+
if (key === 'properties') result.properties = Object.fromEntries(Object.entries(value).map(([name, child]) => [name, childOutputSchema(child)]));
|
|
10
|
+
else if (key === 'items') result.items = childOutputSchema(value);
|
|
11
|
+
else if (key === 'oneOf') result.oneOf = value.map(childOutputSchema);
|
|
12
|
+
else if (scalars.has(key)) result[key] = structuredClone(value);
|
|
13
|
+
else if (!['minimum','maximum','minItems','maxItems','minLength','maxLength','pattern'].includes(key)) throw new Error('Unknown child schema constraint: ' + key);
|
|
14
|
+
}
|
|
15
|
+
if (!result.type && !result.oneOf) {
|
|
16
|
+
const values = result.enum ?? (Object.hasOwn(result, 'const') ? [result.const] : []);
|
|
17
|
+
const types = [...new Set(values.map(value => value === null ? 'null' : typeof value))];
|
|
18
|
+
if (types.length === 1 && ['string','number','boolean','null'].includes(types[0])) result.type = types[0];
|
|
19
|
+
else if (values.length) throw new Error('Ambiguous child enum type');
|
|
20
|
+
}
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { modelResultTools } from './model-tools.mjs';
|
|
3
|
+
import { childOutputSchema } from './child-output-schema.mjs';
|
|
4
|
+
import { parseRetrievalResponse, executeRetrievalBatch } from './retrieval-protocol.mjs';
|
|
5
|
+
import { registerReviewChild } from './review-child-broker.mjs';
|
|
6
|
+
|
|
7
|
+
export const REVIEW_CHILD_PRESET = 'dsh-code-review-restricted';
|
|
8
|
+
const failure = (code, message) => Object.assign(new Error(message), { code });
|
|
9
|
+
// One managed spawn activation owns the entire primary loop, including snapshot tools.
|
|
10
|
+
// No credentials or free-form filesystem tools are passed into that activation.
|
|
11
|
+
export function createHostReviewChildren(ctx) {
|
|
12
|
+
let ready, unregisterPreset;
|
|
13
|
+
const service = () => ctx.get?.('subagents');
|
|
14
|
+
const presets = () => ctx.get?.('agentPresets');
|
|
15
|
+
function capability() {
|
|
16
|
+
const runtime = service(), provider = runtime?.getProvider?.('spawn');
|
|
17
|
+
return !!(provider?.capabilities?.outputSchema && provider.capabilities.agentPreset && provider.capabilities.agentOptions && runtime.resolveMaxDepth && presets()?.register && ctx.get?.('agents')?.get);
|
|
18
|
+
}
|
|
19
|
+
async function prepare() {
|
|
20
|
+
if (!capability()) throw failure('REVIEW_SUBAGENTS_UNAVAILABLE', 'Restricted host spawn subagents are unavailable; no hidden direct fallback');
|
|
21
|
+
ready ??= presets().register({ id: REVIEW_CHILD_PRESET, name: 'Read-only snapshot review', plugins: [
|
|
22
|
+
{ name: '@deepseek-ai/dsh-tool-executor-native' },
|
|
23
|
+
{ name: '@feng0531/dsh-code-review/child' },
|
|
24
|
+
] }).then(dispose => { unregisterPreset = dispose; });
|
|
25
|
+
await ready;
|
|
26
|
+
}
|
|
27
|
+
return { capability, prepare,
|
|
28
|
+
async create({ parent, route, cwd, signal }) {
|
|
29
|
+
await prepare(); signal.throwIfAborted();
|
|
30
|
+
const runtime = service(), childId = randomUUID();
|
|
31
|
+
let handle, revoked, stop, started = false;
|
|
32
|
+
const child = { transport: 'host-subagent', childId,
|
|
33
|
+
async runPrimary({ request, scope, maxRounds, maxInputBytes, reserveModelCall, onRetrieved, onBudgetLimited, onTruncated, onProgress }) {
|
|
34
|
+
if (started) throw new Error('A review child cannot be reused for another primary');
|
|
35
|
+
started = true; let rounds = 0, modelCalls = 0, bindingFatal;
|
|
36
|
+
const abortBinding = error => { bindingFatal ??= error; handle?.dispose().catch(() => {}); };
|
|
37
|
+
const assertAgent = agent => {
|
|
38
|
+
if (!agent || agent.id !== childId || agent.session.header.parentSession !== (parent.session.header.id ?? parent.id) || agent.session.header.agentPreset !== REVIEW_CHILD_PRESET) throw failure('REVIEW_CHILD_IDENTITY', 'Review child ownership changed');
|
|
39
|
+
};
|
|
40
|
+
const binding = { parent, parentId: parent.session.header.id ?? parent.id, presetId: REVIEW_CHILD_PRESET, route, assertAgent, onFatal: abortBinding,
|
|
41
|
+
beforeModelCall(options) {
|
|
42
|
+
try {
|
|
43
|
+
signal.throwIfAborted();
|
|
44
|
+
if (bindingFatal) throw bindingFatal;
|
|
45
|
+
if (options.provider !== route.provider || options.model !== route.model || options.reasoningEffort !== route.reasoningEffort) throw failure('REVIEW_CHILD_ROUTE', 'Review child model route changed');
|
|
46
|
+
// This is the complete logical LLM envelope, not provider-specific HTTP bytes.
|
|
47
|
+
const bytes = Buffer.byteLength(JSON.stringify({ system: options.system, messages: options.messages, tools: options.tools }));
|
|
48
|
+
if (bytes > maxInputBytes) throw failure('WHOLE_SCOPE_INPUT_BUDGET', 'Child logical model input exceeds approved capacity');
|
|
49
|
+
if (modelCalls >= maxRounds + 2) throw failure('WHOLE_SCOPE_MODEL_CALL_LIMIT', 'Child primary loop call limit exceeded');
|
|
50
|
+
reserveModelCall(); modelCalls++;
|
|
51
|
+
} catch (error) { abortBinding(error); throw error; }
|
|
52
|
+
},
|
|
53
|
+
executeRequests(args, exec) {
|
|
54
|
+
assertAgent(exec.agent); signal.throwIfAborted();
|
|
55
|
+
if (bindingFatal) throw bindingFatal;
|
|
56
|
+
const requests = parseRetrievalResponse(args);
|
|
57
|
+
if (rounds >= maxRounds) {
|
|
58
|
+
const info = { code: 'RETRIEVAL_ROUND_BUDGET_EXCEEDED', reason: 'Child snapshot tool rounds exhausted' };
|
|
59
|
+
onBudgetLimited?.(info); throw failure(info.code, info.reason);
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
scope.preflight(requests);
|
|
63
|
+
const results = scope.transaction(() => executeRetrievalBatch(args, scope, signal));
|
|
64
|
+
rounds++;
|
|
65
|
+
for (const { request: operation, result } of results) {
|
|
66
|
+
if (result.truncated) onTruncated?.();
|
|
67
|
+
onRetrieved?.([{ kind: operation.kind, snapshotId: result.snapshotId, stage: 'retrieved-locally', sourceId: result.sourceId, hash: result.hash, start: result.start, count: result.count, resultBytes: Buffer.byteLength(JSON.stringify(result)) }]);
|
|
68
|
+
}
|
|
69
|
+
return { snapshotId: JSON.parse(request.input).snapshotId, results, budget: scope.budget() };
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (['READER_CALL_BUDGET_EXCEEDED','READER_OUTPUT_BUDGET_EXCEEDED'].includes(error.code)) onBudgetLimited?.({code:error.code,reason:error.message});
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
revoked = registerReviewChild(childId, binding);
|
|
77
|
+
const finalEnvelope = JSON.parse(request.input); finalEnvelope.retrievalFinalization = { reason: 'Use review_snapshot tool for supplemental reading; structured_output submits final reviews only' };
|
|
78
|
+
const schema = childOutputSchema(modelResultTools(JSON.stringify(finalEnvelope), request.instructions)[0].parameters);
|
|
79
|
+
stop = () => { handle?.dispose().catch(() => {}); }; signal.addEventListener('abort', stop, { once: true });
|
|
80
|
+
try {
|
|
81
|
+
handle = await runtime.startActivation({ provider: 'spawn', childId, label: 'Whole-scope review batch', request: {
|
|
82
|
+
parent, cwd, agentPreset: REVIEW_CHILD_PRESET, maxDepth: runtime.resolveMaxDepth(undefined),
|
|
83
|
+
agentOptions: { ...route, maxTokens: 4096 }, outputSchema: schema,
|
|
84
|
+
prompt: [{ type: 'text', text: request.instructions + '\nUse only review_snapshot for approved catalog sources. Preserve this child context for all supplemental reads. Return final reviews via structured_output, never requests.\n' + JSON.stringify({ ...JSON.parse(request.input), catalog: scope.catalog(), retrievalBudget: scope.budget() }) }],
|
|
85
|
+
}, signal, delivery: 'caller' });
|
|
86
|
+
if (signal.aborted || bindingFatal) await handle.dispose();
|
|
87
|
+
const result = await handle.result;
|
|
88
|
+
signal.throwIfAborted(); if (bindingFatal) throw bindingFatal;
|
|
89
|
+
if (result.stopReason !== 'completed' || !result.structured) throw failure('REVIEW_CHILD_INCOMPLETE', 'Host child did not commit a structured final result');
|
|
90
|
+
await onProgress?.({stage:'host-child-complete',childId,modelCalls});
|
|
91
|
+
return result.structured;
|
|
92
|
+
} catch (error) { throw bindingFatal ?? error; } finally { signal.removeEventListener('abort', stop); }
|
|
93
|
+
},
|
|
94
|
+
async dispose() { revoked?.(); if (handle) await handle.dispose(); },
|
|
95
|
+
};
|
|
96
|
+
return child;
|
|
97
|
+
},
|
|
98
|
+
async dispose() { if (ready) await ready; await unregisterPreset?.(); },
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Process-local ownership broker. Fixtures exercise this contract, not host integration.
|
|
2
|
+
const bindings = new Map();
|
|
3
|
+
const revoked = new Set();
|
|
4
|
+
const attachments = new WeakMap();
|
|
5
|
+
const allowedTools = new Set(['review_snapshot', 'structured_output']);
|
|
6
|
+
const failure = (message) => Object.assign(new Error(message), { code: 'REVIEW_CHILD_IDENTITY' });
|
|
7
|
+
|
|
8
|
+
export function registerReviewChild(childId, binding) {
|
|
9
|
+
if (typeof childId !== 'string' || !childId || bindings.has(childId) || revoked.has(childId)) throw failure('Child id is invalid, already reserved, or revoked');
|
|
10
|
+
if (!binding || typeof binding.parentId !== 'string' || !binding.parentId || typeof binding.presetId !== 'string' || !binding.presetId || typeof binding.assertAgent !== 'function' || typeof binding.executeRequests !== 'function' || typeof binding.beforeModelCall !== 'function') throw failure('Incomplete restricted child binding');
|
|
11
|
+
const entry = { binding };
|
|
12
|
+
bindings.set(childId, entry);
|
|
13
|
+
let active = true;
|
|
14
|
+
return () => {
|
|
15
|
+
if (!active) return;
|
|
16
|
+
active = false;
|
|
17
|
+
bindings.delete(childId);
|
|
18
|
+
// Do not allow a late stream event to become an unrelated, unguarded call.
|
|
19
|
+
revoked.add(childId);
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getReviewChildBinding(childId) {
|
|
24
|
+
if (revoked.has(childId)) throw failure('Review child binding was revoked');
|
|
25
|
+
return bindings.get(childId)?.binding;
|
|
26
|
+
}
|
|
27
|
+
export const getBinding = getReviewChildBinding;
|
|
28
|
+
|
|
29
|
+
export function assertReviewChildAgent(childId, agent) {
|
|
30
|
+
const binding = getReviewChildBinding(childId);
|
|
31
|
+
const header = agent?.session?.header;
|
|
32
|
+
if (!binding || agent?.id !== childId || header?.id !== childId || header.parentSession !== binding.parentId || header.agentPreset !== binding.presetId) throw failure('Review child session, parent, or preset does not match its reservation');
|
|
33
|
+
binding.assertAgent(agent);
|
|
34
|
+
return binding;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function executeReviewChildRequests(args, exec) {
|
|
38
|
+
const childId = exec?.agent?.id;
|
|
39
|
+
const binding = assertReviewChildAgent(childId, exec?.agent);
|
|
40
|
+
return binding.executeRequests(args, exec);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function attachReviewChildMiddleware(ctx) {
|
|
44
|
+
if (attachments.has(ctx)) return attachments.get(ctx);
|
|
45
|
+
const dispose = ctx.on('llm/stream', function (options, next) {
|
|
46
|
+
const childId = options?.sessionId;
|
|
47
|
+
const binding = getReviewChildBinding(childId);
|
|
48
|
+
if (!binding) return next();
|
|
49
|
+
try {
|
|
50
|
+
const agent = ctx.get?.('agents')?.get?.(childId);
|
|
51
|
+
assertReviewChildAgent(childId, agent);
|
|
52
|
+
if (!Array.isArray(options.tools) || !options.tools.some(tool => tool?.name === 'structured_output') || options.tools.some(tool => !allowedTools.has(tool?.name)) || new Set(options.tools.map(tool => tool.name)).size !== options.tools.length) throw failure('Restricted child model tools changed');
|
|
53
|
+
// Reservation/route/budget guard executes synchronously, before next dispatch.
|
|
54
|
+
const result = binding.beforeModelCall(options);
|
|
55
|
+
if (result && typeof result.then === 'function') throw failure('Child model-call guard must be synchronous');
|
|
56
|
+
} catch (error) {
|
|
57
|
+
// Poison the owner's activation even when failure precedes its budget guard.
|
|
58
|
+
binding.onFatal?.(error);
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
return next();
|
|
62
|
+
});
|
|
63
|
+
attachments.set(ctx, dispose);
|
|
64
|
+
return dispose;
|
|
65
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { assertReviewChildAgent, executeReviewChildRequests } from './review-child-broker.mjs';
|
|
2
|
+
|
|
3
|
+
export const name = 'dsh-code-review-restricted-child';
|
|
4
|
+
export const inject = ['tools'];
|
|
5
|
+
|
|
6
|
+
export function registerSnapshotTool(ctx) {
|
|
7
|
+
// Presets are shared generations: never capture a particular child here.
|
|
8
|
+
// Restrict inherited tools before resolution; host-owned structured_output remains.
|
|
9
|
+
ctx.tools.register({
|
|
10
|
+
name: 'review_snapshot',
|
|
11
|
+
description: 'Internal tool for registered review children only; ordinary parent/other agents cannot call it. Read only the approved review snapshot using catalog retrieval requests. No filesystem, shell, network, or additional agents.',
|
|
12
|
+
parameters: { type: 'object', additionalProperties: false, properties: { requests: { type: 'array', items: { type: 'object' } } }, required: ['requests'] },
|
|
13
|
+
output: {
|
|
14
|
+
schema: { type: 'object', additionalProperties: false, properties: { json: { type: 'string' } }, required: ['json'] },
|
|
15
|
+
render: (_args, value) => [{ type: 'text', text: value.json }],
|
|
16
|
+
},
|
|
17
|
+
async execute(args, exec) {
|
|
18
|
+
const result = await executeReviewChildRequests(args, exec);
|
|
19
|
+
// Recheck after asynchronous business logic: revoked work cannot leak a result.
|
|
20
|
+
assertReviewChildAgent(exec?.agent?.id, exec?.agent);
|
|
21
|
+
return { json: JSON.stringify(result) };
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
export function apply(ctx) {
|
|
26
|
+
ctx.tools.restrict({ allow: ['review_snapshot'] });
|
|
27
|
+
// Identity only: installation above must precede the host's tool assembly.
|
|
28
|
+
ctx.on('agent/pre-step', ({ agent }, next) => {
|
|
29
|
+
assertReviewChildAgent(agent?.id, agent);
|
|
30
|
+
return next();
|
|
31
|
+
});
|
|
32
|
+
}
|
package/src/service.mjs
CHANGED
|
@@ -56,10 +56,11 @@ function buildPreviewNotice(snapshot, options, enableRiskPlanning, changesConcur
|
|
|
56
56
|
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不含后续检索/复核/动态业务综合;多文件可先进行业务分组,分组失败保留原组并记录限制,动态综合数执行前未知,实际预算可能不足。请先内部核对预览与当前人类授权。用户已明确要求审核且范围、模型和发送目的地一致时直接执行,不重复询问确认;只要求预览或明确要求等待确认时不得执行,超出授权或边界不明确时才询问。宿主权限和快照/身份校验仍须通过。`;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
function createSessionReviewService(llm, { capture = captureSnapshot, executionStrategy = 'staged', maxInputBytes = 96 * 1024, 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
|
+
function createSessionReviewService(llm, { capture = captureSnapshot, hostChildren, wholeScopeConcurrency = 2, executionStrategy = 'staged', maxInputBytes = 96 * 1024, 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 } = {}) {
|
|
60
60
|
for (const value of [ttlMs, maxPreviews]) if (!Number.isSafeInteger(value) || value < 1) throw new Error('Invalid preview limit');
|
|
61
61
|
if (!Number.isSafeInteger(maxInputBytes) || maxInputBytes < 1 || maxInputBytes > 4 * 1024 * 1024) throw new Error('Invalid configured input capacity');
|
|
62
62
|
if (!['staged', 'whole-scope'].includes(executionStrategy)) throw new Error('Invalid review execution strategy');
|
|
63
|
+
wholeScopeConcurrency = reviewConcurrency(wholeScopeConcurrency);
|
|
63
64
|
changesConcurrency = reviewConcurrency(changesConcurrency);
|
|
64
65
|
maxRetrievalRounds = reviewRetrievalRounds(maxRetrievalRounds);
|
|
65
66
|
riskPlanningMode = validateRiskPlanningMode(riskPlanningMode);
|
|
@@ -67,7 +68,7 @@ function createSessionReviewService(llm, { capture = captureSnapshot, executionS
|
|
|
67
68
|
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');
|
|
68
69
|
const lifetime = new AbortController();
|
|
69
70
|
const bind = exec => ({ ...exec, signal: exec.signal ? AbortSignal.any([exec.signal, lifetime.signal]) : lifetime.signal });
|
|
70
|
-
const resumeOptions = (options, route, rounds) => ({ captureOptions: options, route, runtime: REVIEW_RUNTIME, instructionsHash: hash(REVIEW_INSTRUCTIONS), execution: { executionStrategy, maxInputBytes, rounds, enableRiskPlanning, skipSmallRiskPlans, enableBusinessGrouping, enableAnchorCorrection, changesConcurrency, maxRetrievalRounds, riskPlanningMode, readerBudgetPolicy, optimizeReviewCalls, enablePrimarySeeds, focusPrimaryContext, enableSearchSeeds, nativeOutputTools, enableGroupPrimary, enableEvidenceContext, inlineInteractionRisks, enableMigrationContracts, enableMigrationRegression, enableGroupInteractions, enableStageContinuation } });
|
|
71
|
+
const resumeOptions = (options, route, rounds) => ({ captureOptions: options, route, runtime: REVIEW_RUNTIME, instructionsHash: hash(REVIEW_INSTRUCTIONS), execution: { executionStrategy, maxInputBytes, wholeScopeConcurrency, rounds, enableRiskPlanning, skipSmallRiskPlans, enableBusinessGrouping, enableAnchorCorrection, changesConcurrency, maxRetrievalRounds, riskPlanningMode, readerBudgetPolicy, optimizeReviewCalls, enablePrimarySeeds, focusPrimaryContext, enableSearchSeeds, nativeOutputTools, enableGroupPrimary, enableEvidenceContext, inlineInteractionRisks, enableMigrationContracts, enableMigrationRegression, enableGroupInteractions, enableStageContinuation } });
|
|
71
72
|
const previews = new Map();
|
|
72
73
|
let busy = false;
|
|
73
74
|
function prune() { for (const [id, p] of previews) if (p.expires <= now()) previews.delete(id); }
|
|
@@ -102,11 +103,13 @@ function createSessionReviewService(llm, { capture = captureSnapshot, executionS
|
|
|
102
103
|
const resumeState = savedResume === undefined ? undefined : validateResumeState(savedResume, snapshot, resumeOptions(options, current.route, rounds));
|
|
103
104
|
const readerBudget = reviewReaderBudget(snapshot, readerBudgetPolicy);
|
|
104
105
|
const plan = buildCoveragePlan(snapshot, { executionStrategy, 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, signal: exec.signal });
|
|
106
|
+
const useHostChildren = executionStrategy === 'whole-scope' && plan.primaryGroups.length > 1 && !!hostChildren;
|
|
107
|
+
if (useHostChildren) await hostChildren.prepare();
|
|
105
108
|
prune();
|
|
106
109
|
while (previews.size >= maxPreviews) previews.delete(previews.keys().next().value);
|
|
107
110
|
const previewId = randomUUID();
|
|
108
111
|
const expires = now() + ttlMs;
|
|
109
|
-
previews.set(previewId, { ...current, target, options, rounds, snapshotId: snapshot.id, resumeState, sourceJobId, expires });
|
|
112
|
+
previews.set(previewId, { ...current, target, options, rounds, snapshotId: snapshot.id, resumeState, sourceJobId, useHostChildren, expires });
|
|
110
113
|
return {
|
|
111
114
|
...(options.businessRequirement !== undefined
|
|
112
115
|
? { businessRequirement: { text: options.businessRequirement, hash: hash(options.businessRequirement), bytes: Buffer.byteLength(options.businessRequirement) } }
|
|
@@ -118,7 +121,7 @@ function createSessionReviewService(llm, { capture = captureSnapshot, executionS
|
|
|
118
121
|
plan,
|
|
119
122
|
snapshotId: snapshot.id,
|
|
120
123
|
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.' },
|
|
121
|
-
execution: { executionStrategy, ...(executionStrategy === 'whole-scope' ? { resumeSupported: false, maxInputBytes } : {}), nativeOutputTools, enableGroupPrimary, enableEvidenceContext, inlineInteractionRisks, enableMigrationContracts, enableMigrationRegression, enableGroupInteractions, enableStageContinuation, fileConcurrency: changesConcurrency, maxRetrievalRounds, ...(optimizeReviewCalls ? { riskPlanningMode, readerBudget: { policy: readerBudgetPolicy, ...readerBudget }, missingContextSeeds: enablePrimarySeeds, focusPrimaryContext, searchContextSeeds: enableSearchSeeds } : {}) },
|
|
124
|
+
execution: { executionStrategy, ...(executionStrategy === 'whole-scope' ? { resumeSupported: false, maxInputBytes, batchExecution: useHostChildren ? 'host-subagents' : 'direct', batchConcurrency: useHostChildren ? Math.min(wholeScopeConcurrency, plan.primaryGroups.length) : 1 } : {}), nativeOutputTools, enableGroupPrimary, enableEvidenceContext, inlineInteractionRisks, enableMigrationContracts, enableMigrationRegression, enableGroupInteractions, enableStageContinuation, fileConcurrency: changesConcurrency, maxRetrievalRounds, ...(optimizeReviewCalls ? { riskPlanningMode, readerBudget: { policy: readerBudgetPolicy, ...readerBudget }, missingContextSeeds: enablePrimarySeeds, focusPrimaryContext, searchContextSeeds: enableSearchSeeds } : {}) },
|
|
122
125
|
repositoryRoot: snapshot.root,
|
|
123
126
|
vcs: snapshot.vcs,
|
|
124
127
|
...(snapshot.changeScope ? { changeScope: snapshot.changeScope } : options.changeScope !== undefined ? { changeScope: options.changeScope } : {}),
|
|
@@ -148,7 +151,7 @@ function createSessionReviewService(llm, { capture = captureSnapshot, executionS
|
|
|
148
151
|
...(snapshot.callerDiscovery ? { callerDiscovery: snapshot.callerDiscovery } : {}),
|
|
149
152
|
...(snapshot.autoContext ? { autoContext: snapshot.autoContext } : {}),
|
|
150
153
|
rules: (snapshot.rules ?? []).map(({ path, hash }) => ({ path, hash })),
|
|
151
|
-
notice: executionStrategy === 'whole-scope' ? '整体优先主审:批准完整双侧按实际输入字节合批;不执行逐文件风险规划、业务分组或旧专门综合。候选集中独立复核;补读可增加调用,共享读取账本不重置。超大文件明确阻断,多批跨边界未核查保持部分完成;不承诺一次请求或审查通过。不支持旧阶段恢复或多轮。' : buildPreviewNotice(snapshot, options, enableRiskPlanning, changesConcurrency, maxRetrievalRounds) + (enableMigrationContracts ? ' 迁移契约仅由批准快照的明确旧/新入口与调用绑定产生,无法解析的语言结构保持未解决,不以相似名称推断迁移。' : '') + (enableMigrationRegression ? ' 显式跨文件旧新比较仅在host契约限定来源和本次独立回执齐备时成立,因果仍未证明。' : '') + (enableGroupInteractions ? ' 组主审可附带交互结果,只有当前代全组编辑实读且host校验通过时替代匹配专门阶段;缺失/非法回退,候选仍独立复核。' : '') + (enableStageContinuation ? ' 未完成阶段可在其他阶段释放额度后有限继续,不重置总账或原超时;已验收主审生成可精确续用,但复核重新读取、新回执,不继承旧证据。' : '') + (enableEvidenceContext ? ' 可将本次实际交付过的批准原行按相关性经当前阶段reader有限重新投递;缓存仍计交付字节,不继承历史读取覆盖或复核回执。' : '') + (inlineInteractionRisks ? ' 预算适配的精确跨文件单元可在交互主审同次返回风险假设,省去独立规划;缺失或无效风险不补发、不代表已完成风险核查。' : '') + (optimizeReviewCalls ? ` 完整正文或覆盖全部精确编辑的单批窗口可将风险问题合并入主审(非独立规划);仅对批准上下文的确定坐标预取小片段,不代表完整核查。${focusPrimaryContext ? " 主审优先保留主文件全文,仅附带有关联的有界完整上下文;其余批准来源保留目录按需读,不表示不存在或已核查。" : ""}${enableSearchSeeds ? " 可将模型搜索命中的批准原行小片段与搜索结果同轮提供,每阶段最多2次额外读取,共用原预算;不猜测另侧坐标,不增加检索轮次。" : ""}共享检索上限按本次范围计算:${readerBudget.maxCalls}次唯一操作结果生成/${readerBudget.maxOutputBytes}交付字节,所有阶段共用、不逐文件重置;同快照相同请求可复用原始结果,每次交付仍计字节并重新签发复核回执;主审、综合及复核按权重预留最低额度,未用额度释放,额度不足仍如实标记未完成。` : ''),
|
|
154
|
+
notice: executionStrategy === 'whole-scope' ? '整体优先主审:批准完整双侧按实际输入字节合批;不执行逐文件风险规划、业务分组或旧专门综合。候选集中独立复核;补读可增加调用,共享读取账本不重置。超大文件明确阻断,多批跨边界未核查保持部分完成;不承诺一次请求或审查通过。不支持旧阶段恢复或多轮。' + (useHostChildren ? ` 超限多组使用真实spawn子智能体,最多${wholeScopeConcurrency}组并行,每组保留独立持续上下文,仅批准快照读取工具;逐模型生成尝试共用总账,逻辑LLM输入预算不代表最终HTTP字节。` : '') : buildPreviewNotice(snapshot, options, enableRiskPlanning, changesConcurrency, maxRetrievalRounds) + (enableMigrationContracts ? ' 迁移契约仅由批准快照的明确旧/新入口与调用绑定产生,无法解析的语言结构保持未解决,不以相似名称推断迁移。' : '') + (enableMigrationRegression ? ' 显式跨文件旧新比较仅在host契约限定来源和本次独立回执齐备时成立,因果仍未证明。' : '') + (enableGroupInteractions ? ' 组主审可附带交互结果,只有当前代全组编辑实读且host校验通过时替代匹配专门阶段;缺失/非法回退,候选仍独立复核。' : '') + (enableStageContinuation ? ' 未完成阶段可在其他阶段释放额度后有限继续,不重置总账或原超时;已验收主审生成可精确续用,但复核重新读取、新回执,不继承旧证据。' : '') + (enableEvidenceContext ? ' 可将本次实际交付过的批准原行按相关性经当前阶段reader有限重新投递;缓存仍计交付字节,不继承历史读取覆盖或复核回执。' : '') + (inlineInteractionRisks ? ' 预算适配的精确跨文件单元可在交互主审同次返回风险假设,省去独立规划;缺失或无效风险不补发、不代表已完成风险核查。' : '') + (optimizeReviewCalls ? ` 完整正文或覆盖全部精确编辑的单批窗口可将风险问题合并入主审(非独立规划);仅对批准上下文的确定坐标预取小片段,不代表完整核查。${focusPrimaryContext ? " 主审优先保留主文件全文,仅附带有关联的有界完整上下文;其余批准来源保留目录按需读,不表示不存在或已核查。" : ""}${enableSearchSeeds ? " 可将模型搜索命中的批准原行小片段与搜索结果同轮提供,每阶段最多2次额外读取,共用原预算;不猜测另侧坐标,不增加检索轮次。" : ""}共享检索上限按本次范围计算:${readerBudget.maxCalls}次唯一操作结果生成/${readerBudget.maxOutputBytes}交付字节,所有阶段共用、不逐文件重置;同快照相同请求可复用原始结果,每次交付仍计字节并重新签发复核回执;主审、综合及复核按权重预留最低额度,未用额度释放,额度不足仍如实标记未完成。` : ''),
|
|
152
155
|
};
|
|
153
156
|
} finally { busy = false; }
|
|
154
157
|
},
|
|
@@ -170,13 +173,14 @@ function createSessionReviewService(llm, { capture = captureSnapshot, executionS
|
|
|
170
173
|
const snapshot = await capture(target, { ...preview.options, signal: exec.signal });
|
|
171
174
|
exec.signal?.throwIfAborted();
|
|
172
175
|
if (snapshot.id !== preview.snapshotId || owner(exec).cwd !== current.cwd) throw new Error('Files changed since preview; preview again');
|
|
176
|
+
if (preview.useHostChildren && !hostChildren?.capability()) throw new Error('Approved child execution capability changed; preview again');
|
|
173
177
|
const resumeIdentity = resumeOptions(preview.options, preview.route, preview.rounds);
|
|
174
178
|
const resumeState = preview.resumeState ? validateResumeState(preview.resumeState, snapshot, resumeIdentity) : undefined;
|
|
175
179
|
if (await authorize({ snapshot, route: preview.route, exec }) !== true) throw new Error('Model sending approval denied or unavailable');
|
|
176
180
|
exec.signal?.throwIfAborted();
|
|
177
181
|
if (owner(exec).cwd !== current.cwd || JSON.stringify(owner(exec).route) !== JSON.stringify(preview.route)) throw new Error('Agent changed during approval');
|
|
178
182
|
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}))});
|
|
179
|
-
const report = await reviewSnapshot(snapshot, createDshModel(llm, preview.route,{correctJson:true,useNativeTools:nativeOutputTools}), { executionStrategy, maxInputBytes, 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 });
|
|
183
|
+
const report = await reviewSnapshot(snapshot, createDshModel(llm, preview.route,{correctJson:true,useNativeTools:nativeOutputTools}), { executionStrategy, maxInputBytes, wholeScopeConcurrency, childExecution: preview.useHostChildren ? 'host-subagents' : undefined, createBatchModel: preview.useHostChildren ? args => hostChildren.create({ ...args, parent: exec.agent, route: preview.route, cwd: target }) : undefined, 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 });
|
|
180
184
|
report.model = preview.route;
|
|
181
185
|
if (exec.reviewResumeCheckpoint) await exec.reviewResumeCheckpoint(buildResumeState(snapshot, resumeIdentity, report));
|
|
182
186
|
// reviewSnapshot preserves cancelled coverage; do not replace it with a freshness error.
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { REVIEW_INSTRUCTIONS } from './prompts.mjs';
|
|
2
|
+
import { runWholeScopeWorkers, createCheckpointQueue } from './whole-scope-workers.mjs';
|
|
3
|
+
import { changesConcurrency } from './review-execution-policy.mjs';
|
|
2
4
|
import { createReviewReader } from './review.mjs';
|
|
3
5
|
import { prepareWholeScope } from './whole-scope-plan.mjs';
|
|
4
6
|
import { retrievalLoop } from './retrieval-loop.mjs';
|
|
@@ -21,6 +23,8 @@ export async function reviewWholeScope(snapshot, model, options = {}) {
|
|
|
21
23
|
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 0) throw new Error('Invalid whole-scope timeout');
|
|
22
24
|
if (options.enableAnchorCorrection !== undefined && typeof options.enableAnchorCorrection !== 'boolean') throw new Error('Invalid anchor correction option');
|
|
23
25
|
const maxRounds = retrievalRounds(options.maxRetrievalRounds);
|
|
26
|
+
const concurrency = changesConcurrency(options.wholeScopeConcurrency ?? 2);
|
|
27
|
+
if (options.createBatchModel !== undefined && typeof options.createBatchModel !== 'function') throw new Error('Invalid child agent factory');
|
|
24
28
|
const input = structuredClone(snapshot);
|
|
25
29
|
if (!input || !['git', 'svn'].includes(input.vcs) || !Array.isArray(input.files) || input.files.length > 200 || input.projectScope || input.files.some(file => file.reviewMode === 'project')) throw new Error('Whole-scope review requires a changes snapshot');
|
|
26
30
|
const identities = new Set(), paths = new Set();
|
|
@@ -41,21 +45,23 @@ export async function reviewWholeScope(snapshot, model, options = {}) {
|
|
|
41
45
|
interactions: [], rules: (input.rules ?? []).map(({ path, hash }) => ({ path, hash })),
|
|
42
46
|
};
|
|
43
47
|
const stateById = new Map();
|
|
44
|
-
let reader, checkpointFailed = false;
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
+
let reader, checkpointFailed = false, fatalError;
|
|
49
|
+
const checkpoints = createCheckpointQueue(options.onResumeCheckpoint, error => { checkpointFailed = true; fatalError ??= error; controller.abort(error); });
|
|
50
|
+
const reportOrder = new Map(input.files.map((file, index) => [file.id, index]));
|
|
51
|
+
const checkpoint = () => {
|
|
52
|
+
report.findings.sort((a, b) => reportOrder.get(a.fileId) - reportOrder.get(b.fileId));
|
|
53
|
+
return checkpoints.enqueue(report);
|
|
48
54
|
};
|
|
49
55
|
const gate = () => {
|
|
50
56
|
signal.throwIfAborted();
|
|
51
57
|
if (report.modelCalls >= maxCalls) throw failure('WHOLE_SCOPE_MODEL_CALL_LIMIT', 'Model call budget exceeded');
|
|
52
58
|
if (options.remainingCalls?.() <= 0) throw failure('PROJECT_MODEL_CALL_LIMIT', 'Shared project model call limit exceeded');
|
|
53
59
|
};
|
|
54
|
-
const
|
|
60
|
+
const trackModel = delegate => async request => {
|
|
55
61
|
gate();
|
|
56
62
|
if (!modelWireBudget(request, maxInputBytes).fits) throw failure('WHOLE_SCOPE_INPUT_BUDGET', 'Actual model wire input budget exceeded');
|
|
57
63
|
report.modelCalls++;
|
|
58
|
-
const result = await
|
|
64
|
+
const result = await delegate({ ...request, maxInputBytes,
|
|
59
65
|
onModelArtifact: options.onModelArtifact ? async artifact => {
|
|
60
66
|
try {
|
|
61
67
|
const reference = await options.onModelArtifact({ ...artifact, snapshotId: input.id, phase: request.reviewPhase });
|
|
@@ -83,6 +89,7 @@ export async function reviewWholeScope(snapshot, model, options = {}) {
|
|
|
83
89
|
signal.throwIfAborted();
|
|
84
90
|
return result;
|
|
85
91
|
};
|
|
92
|
+
const trackedModel = trackModel(model);
|
|
86
93
|
const requestFor = batch => ({ input: batch.payload, instructions: batch.instructions, signal,
|
|
87
94
|
reviewPhase: 'group-primary', nativeOutputTools: options.nativeOutputTools === true,
|
|
88
95
|
onProtocolEvent: options.onProtocolEvent });
|
|
@@ -112,12 +119,27 @@ export async function reviewWholeScope(snapshot, model, options = {}) {
|
|
|
112
119
|
if (plan.batches.length > 1) report.limitations.push({ text: '整体范围按字节分为多个批次;跨 batch 边界关系未核查,不声明整体范围完整覆盖。' });
|
|
113
120
|
report.scopeNotices = [{ text: '整体主审结合当前 batch 完整文件;批准关联源须按需检索,不声明专门 interaction 阶段或全部上下文覆盖。' }];
|
|
114
121
|
const generatedBatches = [];
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
122
|
+
const parallelChildren = plan.batches.length > 1 && typeof options.createBatchModel === 'function';
|
|
123
|
+
report.orchestration.batchExecution = parallelChildren ? (options.childExecution ?? 'child-factory') : 'direct';
|
|
124
|
+
report.orchestration.batchConcurrency = parallelChildren ? Math.min(concurrency, plan.batches.length) : 1;
|
|
125
|
+
const accepted = new Array(plan.batches.length);
|
|
126
|
+
report.orchestration.batches = plan.batches.map(batch => ({ id: batch.id, fileIds: [...batch.fileIds], status: 'pending' }));
|
|
127
|
+
const runBatch = async (batch, index) => {
|
|
128
|
+
const stage = report.orchestration.batches[index];
|
|
129
|
+
let child;
|
|
118
130
|
try {
|
|
119
131
|
gate();
|
|
120
|
-
|
|
132
|
+
if (parallelChildren) {
|
|
133
|
+
child = await options.createBatchModel({ batchId: batch.id, fileIds: [...batch.fileIds], signal });
|
|
134
|
+
if (!child || (typeof child.invoke !== 'function' && typeof child.runPrimary !== 'function') || typeof child.dispose !== 'function') throw new Error('Invalid host child model');
|
|
135
|
+
signal.throwIfAborted();
|
|
136
|
+
}
|
|
137
|
+
const batchModel = child ? trackModel(request => child.invoke(request)) : trackedModel;
|
|
138
|
+
const response = child?.runPrimary ? await child.runPrimary({ request: requestFor(batch), scope: reader, maxRounds, maxInputBytes,
|
|
139
|
+
reserveModelCall: () => { gate(); report.modelCalls++; },
|
|
140
|
+
onRetrieved: loopOptions(stage).onRetrieved, onBudgetLimited: loopOptions(stage).onBudgetLimited, onTruncated: loopOptions(stage).onTruncated,
|
|
141
|
+
onProgress: options.onProgress }) : await retrievalLoop(batchModel, requestFor(batch), reader, loopOptions(stage));
|
|
142
|
+
if (child?.transport === 'host-subagent') { stage.childId = child.childId; stage.execution = 'host-subagent'; }
|
|
121
143
|
const members = validateGroupPrimary(response, batch, input, options.validateFindingResponse, reader.catalog(), { enableAnchorCorrection: options.enableAnchorCorrection === true, signal });
|
|
122
144
|
signal.throwIfAborted();
|
|
123
145
|
// Validate the entire member set before publishing any member result.
|
|
@@ -130,18 +152,23 @@ export async function reviewWholeScope(snapshot, model, options = {}) {
|
|
|
130
152
|
report.limitations.push(...generated.limitations.map(text => ({ fileId: id, text })));
|
|
131
153
|
}
|
|
132
154
|
stage.status = stage.retrievalLimited ? 'pending' : 'completed';
|
|
133
|
-
|
|
155
|
+
accepted[index] = batch;
|
|
134
156
|
await checkpoint();
|
|
135
157
|
} catch (error) {
|
|
136
158
|
signal.throwIfAborted();
|
|
137
|
-
if (checkpointFailed || error.code === 'MODEL_ARTIFACT_FAILED') throw error;
|
|
159
|
+
if (checkpointFailed || error.code === 'MODEL_ARTIFACT_FAILED') { fatalError ??= error; controller.abort(error); throw error; }
|
|
138
160
|
stage.status = isBudget(error) ? 'pending' : 'failed';
|
|
139
161
|
stage.failureCode = error.code ?? 'WHOLE_SCOPE_PRIMARY_FAILED'; stage.reason = error.message;
|
|
140
162
|
for (const id of batch.fileIds) Object.assign(stateById.get(id), { status: stage.status, primaryStatus: stage.status, failureCode: stage.failureCode, reason: error.message });
|
|
141
163
|
report.limitations.push({ text: '整体主审未完成:' + error.message });
|
|
142
|
-
|
|
164
|
+
} finally {
|
|
165
|
+
if (child) { try { await child.dispose(); } catch (error) { fatalError ??= error; controller.abort(error); throw error; } }
|
|
143
166
|
}
|
|
144
|
-
}
|
|
167
|
+
};
|
|
168
|
+
await runWholeScopeWorkers(plan.batches, runBatch, { concurrency: report.orchestration.batchConcurrency, signal, onFatal: error => { if (!signal.aborted) { fatalError ??= error; controller.abort(error); } } });
|
|
169
|
+
generatedBatches.push(...accepted.filter(Boolean));
|
|
170
|
+
const order = new Map(input.files.map((file, index) => [file.id, index]));
|
|
171
|
+
report.findings.sort((a, b) => order.get(a.fileId) - order.get(b.fileId));
|
|
145
172
|
// One global verification queue, not a per-file chain. Receipt ownership and
|
|
146
173
|
// primary-file regression checks remain entirely in verificationLoop.
|
|
147
174
|
for (const finding of report.findings) finding.verification = { status: 'pending', causality: 'unverified' };
|
|
@@ -181,9 +208,12 @@ export async function reviewWholeScope(snapshot, model, options = {}) {
|
|
|
181
208
|
await options.onProgress?.({ stage: 'batch-complete', snapshotId: input.id, files: report.files, findings: report.findings, limitations: report.limitations });
|
|
182
209
|
return report;
|
|
183
210
|
} catch (error) {
|
|
211
|
+
if (fatalError) throw fatalError;
|
|
184
212
|
if (!signal.aborted || checkpointFailed || error.code === 'MODEL_ARTIFACT_FAILED') throw error;
|
|
185
213
|
if (!report.files.length) report.files = input.files.map(file => ({ fileId: file.id, path: file.path, status: file.eligibility === 'reviewable' ? 'cancelled' : file.eligibility }));
|
|
186
214
|
for (const state of report.files) if (state.status === 'pending') state.status = 'cancelled';
|
|
215
|
+
const cancelledOrder = new Map(input.files.map((file, index) => [file.id, index]));
|
|
216
|
+
report.findings.sort((a, b) => cancelledOrder.get(a.fileId) - cancelledOrder.get(b.fileId));
|
|
187
217
|
for (const finding of report.findings) if (finding.verification?.status !== 'completed') finding.verification = { status: 'incomplete', causality: 'unverified' };
|
|
188
218
|
report.status = 'cancelled';
|
|
189
219
|
report.limitations.push({ text: '整体审查已取消或超时;已验收成果保留,其他范围未完成。' });
|
|
@@ -193,6 +223,6 @@ export async function reviewWholeScope(snapshot, model, options = {}) {
|
|
|
193
223
|
await checkpoint();
|
|
194
224
|
return report;
|
|
195
225
|
} finally {
|
|
196
|
-
clearTimeout(timer);
|
|
226
|
+
try { await checkpoints.drain(); } catch (error) { throw fatalError ?? error; } finally { clearTimeout(timer); }
|
|
197
227
|
}
|
|
198
228
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** Run a bounded set of workers, always joining admitted work before rejecting.
|
|
2
|
+
* Cancellation rejects with signal.reason after the join; a worker failure takes
|
|
3
|
+
* precedence, including when onFatal synchronously aborts the outer controller.
|
|
4
|
+
*/
|
|
5
|
+
export async function runWholeScopeWorkers(items, run, { concurrency = 2, signal, onFatal } = {}) {
|
|
6
|
+
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 4) {
|
|
7
|
+
throw new RangeError('concurrency must be an integer from 1 to 4');
|
|
8
|
+
}
|
|
9
|
+
const results = new Array(items.length);
|
|
10
|
+
let next = 0, failed = false, failure;
|
|
11
|
+
async function worker() {
|
|
12
|
+
while (!failed && !signal?.aborted && next < items.length) {
|
|
13
|
+
const index = next++;
|
|
14
|
+
try {
|
|
15
|
+
results[index] = await run(items[index], index);
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if (!failed) {
|
|
18
|
+
failed = true;
|
|
19
|
+
failure = error;
|
|
20
|
+
// Invoke before yielding so outer abort reaches other active work.
|
|
21
|
+
// A throwing notification cannot replace the original worker failure.
|
|
22
|
+
onFatal?.(error);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
await Promise.allSettled(Array.from({ length: Math.min(concurrency, items.length) }, worker));
|
|
28
|
+
if (failed) throw failure;
|
|
29
|
+
if (signal?.aborted) throw signal.reason;
|
|
30
|
+
return results;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Serialize immutable checkpoint values. The first failure is permanent.
|
|
34
|
+
* Internal rejection handlers prevent fire-and-forget enqueue calls from causing
|
|
35
|
+
* unhandled rejections; callers still receive the original rejecting promise.
|
|
36
|
+
*/
|
|
37
|
+
export function createCheckpointQueue(save, onFatal) {
|
|
38
|
+
let tail = Promise.resolve(), failed = false, failure;
|
|
39
|
+
function rejectFirst(error) {
|
|
40
|
+
if (!failed) {
|
|
41
|
+
failed = true;
|
|
42
|
+
failure = error;
|
|
43
|
+
onFatal?.(error);
|
|
44
|
+
}
|
|
45
|
+
throw failure;
|
|
46
|
+
}
|
|
47
|
+
function observe(promise) {
|
|
48
|
+
// Normalize notification exceptions back to the first persistence failure.
|
|
49
|
+
tail = promise.catch(error => {
|
|
50
|
+
if (failed) throw failure;
|
|
51
|
+
return rejectFirst(error);
|
|
52
|
+
});
|
|
53
|
+
tail.catch(() => {});
|
|
54
|
+
return tail;
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
enqueue(value) {
|
|
58
|
+
if (save == null) return Promise.resolve();
|
|
59
|
+
if (failed) return observe(Promise.reject(failure));
|
|
60
|
+
let checkpoint;
|
|
61
|
+
try {
|
|
62
|
+
checkpoint = structuredClone(value);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
// Retain FIFO even if cloning an enqueued value fails.
|
|
65
|
+
return observe(tail.then(() => rejectFirst(error)));
|
|
66
|
+
}
|
|
67
|
+
return observe(tail.then(async () => {
|
|
68
|
+
if (failed) throw failure;
|
|
69
|
+
try {
|
|
70
|
+
await save(checkpoint);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
return rejectFirst(error);
|
|
73
|
+
}
|
|
74
|
+
}));
|
|
75
|
+
},
|
|
76
|
+
async drain() {
|
|
77
|
+
// Also join values enqueued while a save is in progress.
|
|
78
|
+
for (;;) {
|
|
79
|
+
const pending = tail;
|
|
80
|
+
await pending.catch(() => {});
|
|
81
|
+
if (pending === tail) {
|
|
82
|
+
if (failed) throw failure;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|