@twinsearth/dsh-master 3.2.10 → 3.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/client.js +50 -0
- package/dist-host/contract/client.js +7 -0
- package/dist-host/contract/constants.js +16 -2
- package/dist-host/contract/index.js +1 -0
- package/dist-host/contract/relay.js +239 -0
- package/dist-host/cost/attribution.js +183 -0
- package/dist-host/cost/audit.js +163 -0
- package/dist-host/cost/budget.js +211 -0
- package/dist-host/cost/bus.js +95 -0
- package/dist-host/cost/cert-chain.js +100 -0
- package/dist-host/cost/experiment.js +132 -0
- package/dist-host/cost/fang-relay.js +329 -0
- package/dist-host/cost/gate.js +189 -0
- package/dist-host/cost/host-surface.js +80 -0
- package/dist-host/cost/index.js +345 -0
- package/dist-host/cost/ledger-report.js +193 -0
- package/dist-host/cost/ledger.js +401 -0
- package/dist-host/cost/metrics.js +198 -0
- package/dist-host/cost/milestones.js +209 -0
- package/dist-host/cost/modules/index.js +5 -0
- package/dist-host/cost/modules/manifests.js +130 -0
- package/dist-host/cost/policy.js +276 -0
- package/dist-host/cost/report.js +156 -0
- package/dist-host/cost/reuse-report.js +128 -0
- package/dist-host/cost/rsi.js +367 -0
- package/dist-host/cost/toolface.js +147 -0
- package/dist-host/cost/types.js +31 -0
- package/dist-host/kernel.js +28 -2
- package/dist-host/plugins/cost-optimizer.js +329 -27
- package/dist-host/plugins/superagent-router.js +104 -1
- package/dist-host/superagent/types.js +8 -0
- package/dist-worker/agent-loop.js +449 -7
- package/dist-worker/baseline-fit.js +66 -0
- package/dist-worker/budget-guard.js +93 -0
- package/dist-worker/ccs.js +99 -0
- package/dist-worker/cost-hooks.js +115 -0
- package/dist-worker/cost-modules.js +330 -0
- package/dist-worker/cost-spi.js +3 -0
- package/dist-worker/edg.js +372 -0
- package/dist-worker/gdr.js +159 -0
- package/dist-worker/index.js +80 -2
- package/dist-worker/llm-http.js +20 -0
- package/dist-worker/sink-http.js +1 -0
- package/dist-worker/spill.js +115 -0
- package/dist-worker/tool-batch.js +66 -0
- package/dist-worker/tool-compact.js +275 -0
- package/dist-worker/tool-disclosure.js +90 -0
- package/dist-worker/tools.js +16 -0
- package/docs/ARCHITECTURE.md +24 -0
- package/docs/CHANGELOG.md +66 -0
- package/docs/DESIGN-v3.3.0-cost-optimization.md +1058 -0
- package/docs/EXPERIMENT-M5-fang-relay.md +117 -0
- package/docs/EXPERIMENT-M5-real-ab.md +87 -0
- package/docs/THIRD-PARTY.md +87 -0
- package/package.json +2 -2
- package/src/contract/client.js +7 -0
- package/src/contract/constants.js +17 -2
- package/src/contract/index.js +1 -0
- package/src/contract/relay.js +223 -0
- package/src/cost/attribution.ts +186 -0
- package/src/cost/audit.ts +170 -0
- package/src/cost/budget.ts +226 -0
- package/src/cost/bus.ts +97 -0
- package/src/cost/cert-chain.ts +102 -0
- package/src/cost/experiment.ts +136 -0
- package/src/cost/fang-relay.ts +318 -0
- package/src/cost/gate.ts +191 -0
- package/src/cost/host-surface.ts +97 -0
- package/src/cost/index.ts +348 -0
- package/src/cost/ledger-report.ts +186 -0
- package/src/cost/ledger.ts +385 -0
- package/src/cost/metrics.ts +193 -0
- package/src/cost/milestones.ts +206 -0
- package/src/cost/modules/index.ts +13 -0
- package/src/cost/modules/manifests.ts +129 -0
- package/src/cost/policy.ts +276 -0
- package/src/cost/report.ts +158 -0
- package/src/cost/reuse-report.ts +131 -0
- package/src/cost/rsi.ts +364 -0
- package/src/cost/toolface.ts +151 -0
- package/src/cost/types.ts +298 -0
- package/src/kernel.ts +27 -2
- package/src/plugins/cost-optimizer.ts +313 -26
- package/src/plugins/superagent-router.ts +96 -1
- package/src/superagent/types.ts +70 -0
- package/worker/agent-loop.ts +444 -7
- package/worker/baseline-fit.ts +80 -0
- package/worker/budget-guard.ts +156 -0
- package/worker/ccs.ts +202 -0
- package/worker/cost-hooks.ts +133 -0
- package/worker/cost-modules.ts +356 -0
- package/worker/cost-spi.ts +283 -0
- package/worker/edg.ts +424 -0
- package/worker/gdr.ts +233 -0
- package/worker/index.ts +84 -3
- package/worker/llm-http.ts +18 -0
- package/worker/sink-http.ts +5 -2
- package/worker/spill.ts +133 -0
- package/worker/tool-batch.ts +124 -0
- package/worker/tool-compact.ts +317 -0
- package/worker/tool-disclosure.ts +131 -0
- package/worker/tools.ts +553 -531
- package/worker/types.ts +191 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
// @ts-nocheck — host JS→TS 迁移阶段:运行时行为优先,类型注解逐步补齐(worker/ 仍严格检查)。
|
|
4
|
+
/**
|
|
5
|
+
* M5 实验台:**A/B 对照执行器**(control = ccs off / enforce = ccs enforce)。
|
|
6
|
+
*
|
|
7
|
+
* 设计原则(决定它能不能当证据):
|
|
8
|
+
* * 走**真实 Worker 代码路径**(工具注册表、A1 紧凑、A3 批量、B1/B2/B3 全都在);
|
|
9
|
+
* * 每组 ≥10 任务,**同一 corpus 同一顺序**,control 与 enforce 只差一个开关;
|
|
10
|
+
* * 输出**同表**:tokens/task 与成功率并列,绝不只报实验组;
|
|
11
|
+
* * **自报口径**:`harness:'local-dry-run'` 与 `runner` 字段写进结果 ——
|
|
12
|
+
* 用脚本化 LLM 跑的 dry run 不能冒充「真实 fork 实机」证据(G5 会因此继续被挡住)。
|
|
13
|
+
*/
|
|
14
|
+
export const EXPERIMENT_HARNESS = Object.freeze({
|
|
15
|
+
dryRun: 'local-dry-run',
|
|
16
|
+
/** 真实 provider + 真实 Worker 代码路径(票据可以是合成的,但模型与工具是真的)。 */
|
|
17
|
+
realProvider: 'real-provider-real-worker',
|
|
18
|
+
realFork: 'real-fork',
|
|
19
|
+
});
|
|
20
|
+
const num = (value) => {
|
|
21
|
+
const n = Number(value);
|
|
22
|
+
return Number.isFinite(n) ? n : 0;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* 单组执行:把 corpus 逐个跑完,收集指标。
|
|
26
|
+
*
|
|
27
|
+
* @param {{
|
|
28
|
+
* tasks: Array<{taskId: string, run: (task: object) => Promise<object>}>,
|
|
29
|
+
* group: 'control' | 'enforce',
|
|
30
|
+
* }} input
|
|
31
|
+
*/
|
|
32
|
+
export async function runGroup(input = {}) {
|
|
33
|
+
const tasks = Array.isArray(input.tasks) ? input.tasks : [];
|
|
34
|
+
const rows = [];
|
|
35
|
+
for (const task of tasks) {
|
|
36
|
+
const startedAt = Date.now();
|
|
37
|
+
let result;
|
|
38
|
+
try {
|
|
39
|
+
result = await task.run(task);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
result = { ok: false, error: { code: 'harness-error', message: error instanceof Error ? error.message : String(error) } };
|
|
43
|
+
}
|
|
44
|
+
const elapsedMs = Date.now() - startedAt;
|
|
45
|
+
const cost = result?.cost ?? {};
|
|
46
|
+
rows.push({
|
|
47
|
+
taskId: String(task.taskId),
|
|
48
|
+
ok: result?.ok === true,
|
|
49
|
+
tokens: num(result?.tokenUsage),
|
|
50
|
+
elapsedMs: num(result?.elapsedMs) || elapsedMs,
|
|
51
|
+
steps: Array.isArray(result?.steps) ? result.steps.length : 0,
|
|
52
|
+
a1: cost.compaction
|
|
53
|
+
? {
|
|
54
|
+
mode: cost.compaction.mode,
|
|
55
|
+
applied: cost.compaction.applied === true,
|
|
56
|
+
savedChars: num(cost.compaction.savedChars),
|
|
57
|
+
estimatedTokens: num(cost.compaction.estimatedTokens),
|
|
58
|
+
}
|
|
59
|
+
: null,
|
|
60
|
+
a3: cost.batch ? { taught: cost.batch.taught === true, corrections: num(cost.batch.corrections) } : null,
|
|
61
|
+
reuse: cost.ccs
|
|
62
|
+
? {
|
|
63
|
+
mode: cost.ccs.mode,
|
|
64
|
+
attempts: Array.isArray(cost.ccs.attempts) ? cost.ccs.attempts.length : 0,
|
|
65
|
+
hits: Array.isArray(cost.ccs.attempts) ? cost.ccs.attempts.filter((item) => item.hit === true).length : 0,
|
|
66
|
+
wouldSaveTokens: Array.isArray(cost.ccs.attempts) ? cost.ccs.attempts.reduce((sum, item) => sum + num(item.wouldSaveTokens), 0) : 0,
|
|
67
|
+
skippedModelCalls: num(cost.ccs.skippedModelCalls),
|
|
68
|
+
substitutions: Array.isArray(cost.ccs.substitutions) ? cost.ccs.substitutions.length : 0,
|
|
69
|
+
dispatched: Array.isArray(cost.ccs.substitutions) ? cost.ccs.substitutions.filter((cert) => cert.dispatched === true).length : 0,
|
|
70
|
+
failures: Array.isArray(cost.ccs.substitutions) ? cost.ccs.substitutions.filter((cert) => cert.dispatched === false).length : 0,
|
|
71
|
+
shadowCertificates: Array.isArray(cost.ccs.shadowCertificates) ? cost.ccs.shadowCertificates.length : 0,
|
|
72
|
+
}
|
|
73
|
+
: null,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return { group: String(input.group ?? 'unknown'), tasks: rows };
|
|
77
|
+
}
|
|
78
|
+
/** 两组同表对比(成功率差值按 §10.1 的 2pp 门禁)。 */
|
|
79
|
+
export function compareGroups({ control, enforce, harness = EXPERIMENT_HARNESS.dryRun, runner = 'scripted-llm' } = {}) {
|
|
80
|
+
const avg = (rows) => (rows.length > 0 ? rows.reduce((sum, row) => sum + num(row.tokens), 0) / rows.length : null);
|
|
81
|
+
const rate = (rows) => (rows.length > 0 ? rows.filter((row) => row.ok === true).length / rows.length : null);
|
|
82
|
+
const ctl = Array.isArray(control?.tasks) ? control.tasks : [];
|
|
83
|
+
const trt = Array.isArray(enforce?.tasks) ? enforce.tasks : [];
|
|
84
|
+
const controlTokens = avg(ctl);
|
|
85
|
+
const enforceTokens = avg(trt);
|
|
86
|
+
const controlSuccess = rate(ctl);
|
|
87
|
+
const enforceSuccess = rate(trt);
|
|
88
|
+
const tokenDeltaPct = controlTokens !== null && enforceTokens !== null && controlTokens > 0
|
|
89
|
+
? (enforceTokens - controlTokens) / controlTokens
|
|
90
|
+
: null;
|
|
91
|
+
const successDelta = controlSuccess !== null && enforceSuccess !== null ? enforceSuccess - controlSuccess : null;
|
|
92
|
+
const reuse = trt.map((row) => row.reuse).filter(Boolean);
|
|
93
|
+
const a1 = trt.map((row) => row.a1).filter(Boolean);
|
|
94
|
+
return Object.freeze({
|
|
95
|
+
harness,
|
|
96
|
+
runner,
|
|
97
|
+
/** 这条是给 G5 用的:dry-run 永远不足以当证据;真实 provider + 真实 worker 才算测量有效。 */
|
|
98
|
+
qualifiesForGate: harness === EXPERIMENT_HARNESS.realFork || harness === EXPERIMENT_HARNESS.realProvider,
|
|
99
|
+
table: [
|
|
100
|
+
{ group: 'control', samples: ctl.length, tokensPerTask: controlTokens, successRate: controlSuccess, elapsedMsMedian: median(ctl.map((row) => num(row.elapsedMs))) },
|
|
101
|
+
{ group: 'enforce', samples: trt.length, tokensPerTask: enforceTokens, successRate: enforceSuccess, elapsedMsMedian: median(trt.map((row) => num(row.elapsedMs))) },
|
|
102
|
+
],
|
|
103
|
+
delta: { tokensPerTask: controlTokens !== null && enforceTokens !== null ? enforceTokens - controlTokens : null, tokensPerTaskPct: tokenDeltaPct, successRate: successDelta },
|
|
104
|
+
a1: {
|
|
105
|
+
tasks: a1.length,
|
|
106
|
+
/** pplied 只反映**最后一次**压缩事件;真正判断贡献要看是否产生了估算节省。 */
|
|
107
|
+
tasksWithSavings: a1.filter((row) => num(row.estimatedTokens) > 0).length,
|
|
108
|
+
applied: a1.filter((row) => row.applied === true).length,
|
|
109
|
+
estimatedTokens: a1.reduce((sum, row) => sum + num(row.estimatedTokens), 0),
|
|
110
|
+
},
|
|
111
|
+
reuse: {
|
|
112
|
+
attempts: reuse.reduce((sum, row) => sum + num(row.attempts), 0),
|
|
113
|
+
hits: reuse.reduce((sum, row) => sum + num(row.hits), 0),
|
|
114
|
+
wouldSaveTokens: reuse.reduce((sum, row) => sum + num(row.wouldSaveTokens), 0),
|
|
115
|
+
skippedModelCalls: reuse.reduce((sum, row) => sum + num(row.skippedModelCalls), 0),
|
|
116
|
+
dispatched: reuse.reduce((sum, row) => sum + num(row.dispatched), 0),
|
|
117
|
+
failures: reuse.reduce((sum, row) => sum + num(row.failures), 0),
|
|
118
|
+
shadowCertificates: reuse.reduce((sum, row) => sum + num(row.shadowCertificates), 0),
|
|
119
|
+
},
|
|
120
|
+
/** 与 gate G5 同形,便于直接喂 `evaluateForkExperiment`。 */
|
|
121
|
+
gateShape: Object.freeze({
|
|
122
|
+
control: { tasks: ctl.map((row) => ({ taskId: row.taskId, tokens: row.tokens, success: row.ok })) },
|
|
123
|
+
enforce: { tasks: trt.map((row) => ({ taskId: row.taskId, tokens: row.tokens, success: row.ok })) },
|
|
124
|
+
}),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
function median(values) {
|
|
128
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
129
|
+
if (sorted.length === 0)
|
|
130
|
+
return null;
|
|
131
|
+
return sorted[Math.min(sorted.length - 1, Math.ceil(0.5 * sorted.length) - 1)];
|
|
132
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
// @ts-nocheck — host JS→TS 迁移阶段:运行时行为优先,类型注解逐步补齐(worker/ 仍严格检查)。
|
|
4
|
+
/**
|
|
5
|
+
* 3.3.1 · 瓮城回流出口(设计 §5.6,里程碑 M5)。
|
|
6
|
+
*
|
|
7
|
+
* 3.3.0 里这条通道是硬编码的 `{ target:'fang', available:false,
|
|
8
|
+
* reason:'fang-channel-not-ready' }`:接口位在,但没有实现。3.3.1 把它换成
|
|
9
|
+
* **真实探测 + 真实投递**:
|
|
10
|
+
*
|
|
11
|
+
* 1. `probe()`:用契约 SDK 的 `hello()` 协商,看对端在公共 minor 下是否宣告
|
|
12
|
+
* `fang.relay.publish` / `fang.relay.status`(contract 1.1 才有的方法);
|
|
13
|
+
* 宣告了再读一次 `fang.relay.status` 确认对端**真的在服务**,不是只挂方法名。
|
|
14
|
+
* 2. `enqueue()` / `flush()`:把 `cost:event` / `cost:degraded` /
|
|
15
|
+
* `cost:substitution` 批量投递,按事件 id 幂等,回执里的 `accepted` /
|
|
16
|
+
* `duplicates` / `cursor` 才是“送达”的唯一证据。
|
|
17
|
+
* 3. `status()`:同步返回**最近一次已知状态**(`probe()` 异步更新)。
|
|
18
|
+
*
|
|
19
|
+
* 两条硬约束(与 I1–I8 一致):
|
|
20
|
+
* * 不可用必须显式 `available:false` + 具体原因,**不得**把队列长度当送达;
|
|
21
|
+
* * 投递失败不重试成“假成功”:批次退回队列、`failed` 计数、状态转不可用,
|
|
22
|
+
* 并保留 `lastError`。
|
|
23
|
+
*
|
|
24
|
+
* 对端身份(`peer`)由对端在 `fang.relay.status` 里**自报**(如
|
|
25
|
+
* `dsh-fang-bundle` / `contract-server`),只用于记录“对端是谁”,不参与鉴权,
|
|
26
|
+
* 也不得被当成“已对接真实瓮城”的证据 —— 那需要 `peer === 'dsh-fang-bundle'`。
|
|
27
|
+
*/
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { METHODS, createContractClient } from '../contract/index.js';
|
|
30
|
+
/** 允许作为 M5 证据的对端标识(自报值白名单)。 */
|
|
31
|
+
export const RELAY_PEERS = Object.freeze(['dsh-fang-bundle', 'contract-server']);
|
|
32
|
+
/** 默认值:批量/队列/探测都有界,避免长跑进程无界增长。 */
|
|
33
|
+
export const RELAY_DEFAULTS = Object.freeze({
|
|
34
|
+
maxBatch: 32,
|
|
35
|
+
maxQueue: 500,
|
|
36
|
+
probeTtlMs: 60_000,
|
|
37
|
+
timeoutMs: 15_000,
|
|
38
|
+
});
|
|
39
|
+
function asString(value) { return String(value ?? '').trim(); }
|
|
40
|
+
function clampInt(value, fallback, min, max) {
|
|
41
|
+
const n = Number(value);
|
|
42
|
+
if (!Number.isFinite(n))
|
|
43
|
+
return fallback;
|
|
44
|
+
return Math.min(max, Math.max(min, Math.floor(n)));
|
|
45
|
+
}
|
|
46
|
+
function describeError(error) {
|
|
47
|
+
const code = asString(error?.code) || 'relay-error';
|
|
48
|
+
const message = asString(error?.message ?? error);
|
|
49
|
+
return message ? `${code}:${message.slice(0, 160)}` : code;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* 创建回流出口。
|
|
53
|
+
*
|
|
54
|
+
* @param {{
|
|
55
|
+
* client?: object | null, // createContractClient() 的返回值;null ⇒ 明确不可用
|
|
56
|
+
* target?: string,
|
|
57
|
+
* now?: () => number,
|
|
58
|
+
* maxBatch?: number, maxQueue?: number, probeTtlMs?: number, timeoutMs?: number,
|
|
59
|
+
* logger?: object,
|
|
60
|
+
* }} [options]
|
|
61
|
+
*/
|
|
62
|
+
export function createFangRelay(options = {}) {
|
|
63
|
+
const client = options.client && typeof options.client === 'object' ? options.client : null;
|
|
64
|
+
const target = asString(options.target) || 'fang';
|
|
65
|
+
const now = typeof options.now === 'function' ? options.now : () => Date.now();
|
|
66
|
+
const maxBatch = clampInt(options.maxBatch, RELAY_DEFAULTS.maxBatch, 1, 128);
|
|
67
|
+
const maxQueue = clampInt(options.maxQueue, RELAY_DEFAULTS.maxQueue, 1, 5_000);
|
|
68
|
+
const probeTtlMs = clampInt(options.probeTtlMs, RELAY_DEFAULTS.probeTtlMs, 1_000, 600_000);
|
|
69
|
+
const timeoutMs = clampInt(options.timeoutMs, RELAY_DEFAULTS.timeoutMs, 1_000, 300_000);
|
|
70
|
+
const logger = options.logger ?? null;
|
|
71
|
+
const queue = [];
|
|
72
|
+
const counters = { enqueued: 0, delivered: 0, duplicates: 0, failed: 0, dropped: 0, batches: 0, roundTrips: 0 };
|
|
73
|
+
let sequence = 0;
|
|
74
|
+
let batchSequence = 0;
|
|
75
|
+
let cursor = null;
|
|
76
|
+
let lastError = null;
|
|
77
|
+
let lastDeliveredAt = 0;
|
|
78
|
+
let probing = null;
|
|
79
|
+
// 没有客户端时开局即知不可用(不需要“探测中”这种灰色状态去掩盖)。
|
|
80
|
+
let state = Object.freeze({
|
|
81
|
+
available: false,
|
|
82
|
+
reason: client ? 'probe-pending' : 'relay-client-absent',
|
|
83
|
+
peer: null,
|
|
84
|
+
peerContractVersion: null,
|
|
85
|
+
transport: null,
|
|
86
|
+
probedAt: 0,
|
|
87
|
+
});
|
|
88
|
+
function setState(next) {
|
|
89
|
+
state = Object.freeze({ ...state, probedAt: now(), ...next });
|
|
90
|
+
}
|
|
91
|
+
function status() {
|
|
92
|
+
return Object.freeze({
|
|
93
|
+
target,
|
|
94
|
+
available: state.available === true,
|
|
95
|
+
reason: state.reason ?? null,
|
|
96
|
+
peer: state.peer ?? null,
|
|
97
|
+
peerContractVersion: state.peerContractVersion ?? null,
|
|
98
|
+
transport: state.transport ?? null,
|
|
99
|
+
delivered: counters.delivered,
|
|
100
|
+
failed: counters.failed,
|
|
101
|
+
queued: queue.length,
|
|
102
|
+
dropped: counters.dropped,
|
|
103
|
+
duplicates: counters.duplicates,
|
|
104
|
+
batches: counters.batches,
|
|
105
|
+
cursor,
|
|
106
|
+
lastError,
|
|
107
|
+
lastDeliveredAt,
|
|
108
|
+
probedAt: state.probedAt,
|
|
109
|
+
limits: { maxBatch, maxQueue, probeTtlMs, timeoutMs },
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
/** 探测对端:hello 协商 → 方法裁剪判定 → 读一次 relay 状态确认真的可用。 */
|
|
113
|
+
async function probe({ force = false } = {}) {
|
|
114
|
+
if (!client) {
|
|
115
|
+
setState({ available: false, reason: 'relay-client-absent', peer: null });
|
|
116
|
+
return status();
|
|
117
|
+
}
|
|
118
|
+
if (!force && state.probedAt > 0 && now() - state.probedAt < probeTtlMs)
|
|
119
|
+
return status();
|
|
120
|
+
if (probing)
|
|
121
|
+
return probing;
|
|
122
|
+
probing = (async () => {
|
|
123
|
+
try {
|
|
124
|
+
const hello = await client.hello(true);
|
|
125
|
+
if (!hello || hello.ok !== true) {
|
|
126
|
+
setState({ available: false, reason: `hello-failed:${asString(hello?.code) || 'unknown'}`, peer: null });
|
|
127
|
+
return status();
|
|
128
|
+
}
|
|
129
|
+
const methods = Array.isArray(hello.negotiation?.methods) ? hello.negotiation.methods : [];
|
|
130
|
+
const peerContractVersion = asString(hello.hello?.contractVersion) || null;
|
|
131
|
+
const offersRelay = methods.includes(METHODS.RELAY_PUBLISH) && methods.includes(METHODS.RELAY_STATUS);
|
|
132
|
+
if (!offersRelay) {
|
|
133
|
+
// 1.0 对端不会宣告 1.1 方法;这是“协议层没有回流”,不是传输故障。
|
|
134
|
+
setState({
|
|
135
|
+
available: false,
|
|
136
|
+
reason: `peer-contract-without-relay:${peerContractVersion ?? 'unknown'}`,
|
|
137
|
+
peer: null,
|
|
138
|
+
peerContractVersion,
|
|
139
|
+
transport: asString(hello.transport) || null,
|
|
140
|
+
});
|
|
141
|
+
return status();
|
|
142
|
+
}
|
|
143
|
+
const remote = await client.relayStatus({}, { timeoutMs });
|
|
144
|
+
if (!remote || remote.ok !== true) {
|
|
145
|
+
setState({
|
|
146
|
+
available: false,
|
|
147
|
+
reason: `relay-status-failed:${asString(remote?.code) || 'unknown'}`,
|
|
148
|
+
peer: null,
|
|
149
|
+
peerContractVersion,
|
|
150
|
+
transport: asString(hello.transport) || null,
|
|
151
|
+
});
|
|
152
|
+
return status();
|
|
153
|
+
}
|
|
154
|
+
const result = remote.response?.result ?? {};
|
|
155
|
+
cursor = Number.isFinite(Number(result.cursor)) ? Number(result.cursor) : cursor;
|
|
156
|
+
setState({
|
|
157
|
+
available: true,
|
|
158
|
+
reason: null,
|
|
159
|
+
peer: asString(result.peer) || null,
|
|
160
|
+
peerContractVersion: asString(result.contractVersion) || peerContractVersion,
|
|
161
|
+
transport: asString(remote.transport ?? hello.transport) || null,
|
|
162
|
+
});
|
|
163
|
+
return status();
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
setState({ available: false, reason: `probe-threw:${describeError(error)}`, peer: null });
|
|
167
|
+
try {
|
|
168
|
+
logger?.warn?.(`[cost-relay] probe failed: ${describeError(error)}`);
|
|
169
|
+
}
|
|
170
|
+
catch { /* ignore */ }
|
|
171
|
+
return status();
|
|
172
|
+
}
|
|
173
|
+
})();
|
|
174
|
+
try {
|
|
175
|
+
return await probing;
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
probing = null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** 入队一条总线事件;不可用时不假装收下(记 dropped 并返回原因)。 */
|
|
182
|
+
function enqueue(event = {}) {
|
|
183
|
+
if (state.available !== true) {
|
|
184
|
+
counters.dropped += 1;
|
|
185
|
+
return { ok: false, code: 'relay-unavailable', reason: state.reason ?? 'unknown' };
|
|
186
|
+
}
|
|
187
|
+
const type = asString(event.type) || 'cost:event';
|
|
188
|
+
sequence += 1;
|
|
189
|
+
const item = Object.freeze({
|
|
190
|
+
// id 稳定且唯一:重投同一批时对端按 id 去重,不会重复记账。
|
|
191
|
+
id: `cost-${type}-${sequence}-${Math.max(0, Math.floor(Number(event.at ?? now()) || 0))}`,
|
|
192
|
+
type,
|
|
193
|
+
at: Math.max(0, Math.floor(Number(event.at ?? now()) || 0)),
|
|
194
|
+
payload: event.payload && typeof event.payload === 'object' ? { ...event.payload } : {},
|
|
195
|
+
});
|
|
196
|
+
if (queue.length >= maxQueue) {
|
|
197
|
+
queue.shift();
|
|
198
|
+
counters.dropped += 1;
|
|
199
|
+
}
|
|
200
|
+
queue.push(item);
|
|
201
|
+
counters.enqueued += 1;
|
|
202
|
+
return { ok: true, id: item.id, queued: queue.length };
|
|
203
|
+
}
|
|
204
|
+
/** 投递一个批次;失败时批次退回队列并把状态转不可用(不吞错)。 */
|
|
205
|
+
async function flush() {
|
|
206
|
+
if (!client)
|
|
207
|
+
return { ok: false, code: 'relay-client-absent', ...status() };
|
|
208
|
+
if (state.available !== true)
|
|
209
|
+
return { ok: false, code: 'relay-unavailable', reason: state.reason ?? 'unknown', ...status() };
|
|
210
|
+
if (queue.length === 0)
|
|
211
|
+
return { ok: true, delivered: 0, sent: 0, queued: 0 };
|
|
212
|
+
const batch = queue.slice(0, maxBatch);
|
|
213
|
+
batchSequence += 1;
|
|
214
|
+
const batchId = `relay-${batchSequence}`;
|
|
215
|
+
let outcome = null;
|
|
216
|
+
try {
|
|
217
|
+
outcome = await client.publishRelay({ batchId, events: batch }, { timeoutMs });
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
outcome = { ok: false, code: 'relay-threw', error: describeError(error) };
|
|
221
|
+
}
|
|
222
|
+
counters.roundTrips += 1;
|
|
223
|
+
if (outcome && outcome.ok === true) {
|
|
224
|
+
const result = outcome.response?.result ?? {};
|
|
225
|
+
const accepted = Math.max(0, Number(result.accepted ?? 0) || 0);
|
|
226
|
+
const duplicates = Math.max(0, Number(result.duplicates ?? 0) || 0);
|
|
227
|
+
// 只有对端回执才算送达:从队列头移除本次实际发送的批次。
|
|
228
|
+
queue.splice(0, batch.length);
|
|
229
|
+
counters.batches += 1;
|
|
230
|
+
counters.delivered += accepted + duplicates;
|
|
231
|
+
counters.duplicates += duplicates;
|
|
232
|
+
if (Number.isFinite(Number(result.cursor)))
|
|
233
|
+
cursor = Number(result.cursor);
|
|
234
|
+
lastDeliveredAt = now();
|
|
235
|
+
lastError = null;
|
|
236
|
+
return { ok: true, delivered: accepted, duplicates, sent: batch.length, queued: queue.length, cursor, batchId };
|
|
237
|
+
}
|
|
238
|
+
const code = asString(outcome?.code) || 'publish-failed';
|
|
239
|
+
const detail = asString(outcome?.failures?.[0]?.code) || asString(outcome?.error);
|
|
240
|
+
lastError = detail && !detail.startsWith(code) ? `${code}:${detail}`.slice(0, 200) : (detail || code);
|
|
241
|
+
counters.failed += batch.length;
|
|
242
|
+
setState({ available: false, reason: `publish-failed:${lastError}` });
|
|
243
|
+
try {
|
|
244
|
+
logger?.warn?.(`[cost-relay] publish failed: ${lastError}`);
|
|
245
|
+
}
|
|
246
|
+
catch { /* ignore */ }
|
|
247
|
+
return { ok: false, code, error: lastError, sent: batch.length, queued: queue.length };
|
|
248
|
+
}
|
|
249
|
+
/** 挂到事件总线上:订阅者异常与投递异常都不影响主流程。 */
|
|
250
|
+
function attach(bus) {
|
|
251
|
+
if (!bus || typeof bus.subscribe !== 'function')
|
|
252
|
+
return () => { };
|
|
253
|
+
return bus.subscribe((event) => {
|
|
254
|
+
try {
|
|
255
|
+
enqueue(event);
|
|
256
|
+
}
|
|
257
|
+
catch { /* 回流不得影响主流程 */ }
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
function reset() {
|
|
261
|
+
queue.length = 0;
|
|
262
|
+
lastError = null;
|
|
263
|
+
setState({ available: false, reason: 'probe-pending', peer: null });
|
|
264
|
+
return status();
|
|
265
|
+
}
|
|
266
|
+
return Object.freeze({ target, probe, enqueue, flush, attach, status, reset, describe: status });
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* 从插件配置/环境装配契约客户端。
|
|
270
|
+
*
|
|
271
|
+
* 未配置任何传输时返回 null(**不**创建空客户端去假装探测过)。
|
|
272
|
+
*
|
|
273
|
+
* @param {object} ctx
|
|
274
|
+
* @param {{relay?: object, dispatch?: object}} [config]
|
|
275
|
+
* @param {{fetchImpl?: typeof fetch, env?: object}} [options]
|
|
276
|
+
*/
|
|
277
|
+
export function createRelayClient(ctx, config = {}, options = {}) {
|
|
278
|
+
const raw = config.relay && typeof config.relay === 'object' ? config.relay : {};
|
|
279
|
+
const dispatch = config.dispatch && typeof config.dispatch === 'object' ? config.dispatch : {};
|
|
280
|
+
const env = options.env ?? process.env;
|
|
281
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
282
|
+
const httpBaseUrl = asString(raw.httpBaseUrl) || asString(dispatch.motherUrl) || asString(env.DSH_FANG_CONTRACT_URL) || asString(env.DSH_MASTER_MOTHER_URL);
|
|
283
|
+
const httpToken = asString(raw.httpToken) || asString(dispatch.motherToken) || asString(env.DSH_FANG_CONTRACT_TOKEN);
|
|
284
|
+
const motherDataDir = asString(env.DSH_FANG_MOTHER);
|
|
285
|
+
const fileDir = asString(raw.fileDir) || asString(dispatch.contractFileDir) || (motherDataDir ? path.join(motherDataDir, 'contract-outbox') : '');
|
|
286
|
+
const localService = (() => {
|
|
287
|
+
try {
|
|
288
|
+
return ctx && typeof ctx.get === 'function' ? ctx.get('fangContract') : null;
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
})();
|
|
294
|
+
const enabled = raw.enabled === true || Boolean(httpBaseUrl) || Boolean(fileDir) || Boolean(localService);
|
|
295
|
+
if (!enabled)
|
|
296
|
+
return null;
|
|
297
|
+
if (raw.enabled === false)
|
|
298
|
+
return null;
|
|
299
|
+
return createContractClient({
|
|
300
|
+
ctx,
|
|
301
|
+
config: {
|
|
302
|
+
httpBaseUrl,
|
|
303
|
+
httpToken,
|
|
304
|
+
httpHeaders: raw.httpHeaders && typeof raw.httpHeaders === 'object' ? raw.httpHeaders : undefined,
|
|
305
|
+
fileDir,
|
|
306
|
+
timeoutMs: clampInt(raw.timeoutMs, RELAY_DEFAULTS.timeoutMs, 1_000, 300_000),
|
|
307
|
+
fileTimeoutMs: clampInt(raw.fileTimeoutMs, 30_000, 1_000, 300_000),
|
|
308
|
+
order: Array.isArray(raw.order) ? raw.order : undefined,
|
|
309
|
+
},
|
|
310
|
+
fetchImpl,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* 一次性装配:配置 → 客户端 → 出口。返回的 `client` 为 null 表示没有任何
|
|
315
|
+
* 可用传输(此时出口状态是 `relay-client-absent`,不是“探测中”)。
|
|
316
|
+
*/
|
|
317
|
+
export function createFangRelayFromContext(ctx, config = {}, options = {}) {
|
|
318
|
+
const client = createRelayClient(ctx, config, options);
|
|
319
|
+
const relay = createFangRelay({
|
|
320
|
+
client,
|
|
321
|
+
now: options.now,
|
|
322
|
+
maxBatch: config.relay?.maxBatch,
|
|
323
|
+
maxQueue: config.relay?.maxQueue,
|
|
324
|
+
probeTtlMs: config.relay?.probeTtlMs,
|
|
325
|
+
timeoutMs: config.relay?.timeoutMs,
|
|
326
|
+
logger: options.logger,
|
|
327
|
+
});
|
|
328
|
+
return { client, relay };
|
|
329
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
// @ts-nocheck — host JS→TS 迁移阶段:运行时行为优先,类型注解逐步补齐(worker/ 仍严格检查)。
|
|
4
|
+
/**
|
|
5
|
+
* 3.3.0 · gate(发布门禁,设计 §10.2)。
|
|
6
|
+
*
|
|
7
|
+
* **3.3.0 不通过不发布**。六道门:
|
|
8
|
+
*
|
|
9
|
+
* | # | 门禁 | 判定 |
|
|
10
|
+
* |---|---|---|
|
|
11
|
+
* | G1 | `npm run check` 0 error | 退出码 |
|
|
12
|
+
* | G2 | `npm run build` 0 error | 退出码 |
|
|
13
|
+
* | G3 | `npm test` 全绿(含新增模块测试) | 退出码 + 用例数 |
|
|
14
|
+
* | G4 | 新增测试至少覆盖:I1–I8 各一条、守卫命中/未命中、schema 漂移、证书回放、归因不重复计 | 覆盖清单逐项核验 |
|
|
15
|
+
* | G5 | 真实 fork 实机:≥10 任务的对照组 vs enforce 组,tokens/task 与成功率同表 | 样本量 + 同表 + 成功率不退化 |
|
|
16
|
+
* | G6 | 零残留:临时 profile / 工作区清理证据 | 路径扫描 |
|
|
17
|
+
* | G7 | 发布需用户明确授权 | 默认 **未授权**(设计不含发布动作) |
|
|
18
|
+
*
|
|
19
|
+
* 门禁的价值在于**敢说不**:任一门不满足即 `ok:false` 并列出 `blockers`,
|
|
20
|
+
* 不允许"部分通过"。
|
|
21
|
+
*/
|
|
22
|
+
/** 必须被测试覆盖的能力(G4),每项给出识别标记。 */
|
|
23
|
+
export const REQUIRED_COVERAGE = Object.freeze([
|
|
24
|
+
{ id: 'I1', label: '预算不足不启动模型调用', patterns: [/I1:.*budget/u] },
|
|
25
|
+
{ id: 'I2', label: 'CCS 只经原生派发器 / 无派发器拒绝', patterns: [/I2:.*native dispatcher/u, /I2:.*non-native/u] },
|
|
26
|
+
{ id: 'I3', label: '破坏性工具默认不入池', patterns: [/I3:.*destructive/u] },
|
|
27
|
+
{ id: 'I4', label: 'schema 漂移整图作废', patterns: [/I4:.*schema drift/u] },
|
|
28
|
+
{ id: 'I5', label: '守卫不匹配回落慢模型', patterns: [/I5:.*guard miss/u] },
|
|
29
|
+
{ id: 'I6', label: '模块异常 degraded 且任务继续', patterns: [/I6:.*throwing module/u] },
|
|
30
|
+
{ id: 'I7', label: '压缩回执可回读原文(spill)', patterns: [/I7:.*read back/u] },
|
|
31
|
+
{ id: 'I8', label: '取消信号阻断替换', patterns: [/I8:.*aborted signal/u] },
|
|
32
|
+
{ id: 'guard-hit', label: '守卫命中', patterns: [/all five gates must pass/u] },
|
|
33
|
+
{ id: 'guard-miss', label: '守卫未命中', patterns: [/each gate has its own miss reason/u] },
|
|
34
|
+
{ id: 'schema-drift', label: 'schema 漂移(B2 层)', patterns: [/schema drift archives the whole graph/u, /schemaHash/u] },
|
|
35
|
+
{ id: 'cert-replay', label: '证书回放', patterns: [/100% replayable/u] },
|
|
36
|
+
{ id: 'no-double-count', label: '归因不重复计', patterns: [/doubleCountGuard/u, /mutually exclusive/u] },
|
|
37
|
+
]);
|
|
38
|
+
/** G5:对照组 vs enforce 组的最小样本量。 */
|
|
39
|
+
export const MIN_FORK_TASKS = 10;
|
|
40
|
+
/** G5:成功率允许的最大下降(与 §10.1 的 2pp 门禁一致)。 */
|
|
41
|
+
export const MAX_SUCCESS_DROP_PP = 0.02;
|
|
42
|
+
const num = (value) => {
|
|
43
|
+
const n = Number(value);
|
|
44
|
+
return Number.isFinite(n) ? n : 0;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* G4:扫描测试源码,逐项确认覆盖(找不到标记即报缺)。
|
|
48
|
+
* @param {Record<string, string>} sources 文件名 → 源码
|
|
49
|
+
*/
|
|
50
|
+
export function checkCoverage(sources = {}) {
|
|
51
|
+
const haystack = Object.values(sources).join('\n');
|
|
52
|
+
const missing = [];
|
|
53
|
+
const covered = [];
|
|
54
|
+
for (const item of REQUIRED_COVERAGE) {
|
|
55
|
+
const found = item.patterns.some((pattern) => pattern.test(haystack));
|
|
56
|
+
if (found)
|
|
57
|
+
covered.push(item.id);
|
|
58
|
+
else
|
|
59
|
+
missing.push({ id: item.id, label: item.label });
|
|
60
|
+
}
|
|
61
|
+
return { ok: missing.length === 0, covered, missing };
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* G5:评估真实 fork 实机对照实验。
|
|
65
|
+
* @param {{control?: {tasks: Array<{taskId: string, tokens: number, success: boolean}>}, enforce?: {tasks: Array<{taskId: string, tokens: number, success: boolean}>}}} input
|
|
66
|
+
*/
|
|
67
|
+
export function evaluateForkExperiment(input = {}) {
|
|
68
|
+
const control = Array.isArray(input?.control?.tasks) ? input.control.tasks : [];
|
|
69
|
+
const enforce = Array.isArray(input?.enforce?.tasks) ? input.enforce.tasks : [];
|
|
70
|
+
const problems = [];
|
|
71
|
+
// 证据来源必须真实:dry-run(合成语料 + 脚本化模型)不能冒充 G5。
|
|
72
|
+
const harness = input?.harness === undefined ? 'real-fork' : String(input.harness);
|
|
73
|
+
if (harness === 'local-dry-run')
|
|
74
|
+
problems.push('harness-not-real');
|
|
75
|
+
if (control.length < MIN_FORK_TASKS)
|
|
76
|
+
problems.push(`control-tasks<${MIN_FORK_TASKS}`);
|
|
77
|
+
if (enforce.length < MIN_FORK_TASKS)
|
|
78
|
+
problems.push(`enforce-tasks<${MIN_FORK_TASKS}`);
|
|
79
|
+
const avg = (rows) => (rows.length > 0 ? rows.reduce((sum, row) => sum + num(row?.tokens), 0) / rows.length : null);
|
|
80
|
+
const rate = (rows) => (rows.length > 0 ? rows.filter((row) => row?.success === true).length / rows.length : null);
|
|
81
|
+
const controlTokensPerTask = avg(control);
|
|
82
|
+
const enforceTokensPerTask = avg(enforce);
|
|
83
|
+
const controlSuccess = rate(control);
|
|
84
|
+
const enforceSuccess = rate(enforce);
|
|
85
|
+
const tokenDelta = controlTokensPerTask !== null && enforceTokensPerTask !== null
|
|
86
|
+
? Math.round((enforceTokensPerTask - controlTokensPerTask) * 100) / 100
|
|
87
|
+
: null;
|
|
88
|
+
const successDelta = controlSuccess !== null && enforceSuccess !== null
|
|
89
|
+
? Math.round((enforceSuccess - controlSuccess) * 10000) / 10000
|
|
90
|
+
: null;
|
|
91
|
+
if (successDelta !== null && successDelta <= -MAX_SUCCESS_DROP_PP)
|
|
92
|
+
problems.push('success-regression>=2pp');
|
|
93
|
+
// 同表:两组必须都在同一份报告里给出 tokens/task 与成功率
|
|
94
|
+
const sameTable = controlTokensPerTask !== null && enforceTokensPerTask !== null && controlSuccess !== null && enforceSuccess !== null;
|
|
95
|
+
if (!sameTable)
|
|
96
|
+
problems.push('groups-not-in-same-table');
|
|
97
|
+
return {
|
|
98
|
+
ok: problems.length === 0,
|
|
99
|
+
problems,
|
|
100
|
+
samples: { control: control.length, enforce: enforce.length },
|
|
101
|
+
table: [
|
|
102
|
+
{ group: 'control', samples: control.length, tokensPerTask: controlTokensPerTask, successRate: controlSuccess },
|
|
103
|
+
{ group: 'enforce', samples: enforce.length, tokensPerTask: enforceTokensPerTask, successRate: enforceSuccess },
|
|
104
|
+
],
|
|
105
|
+
delta: { tokensPerTask: tokenDelta, successRate: successDelta },
|
|
106
|
+
/** token 变好且成功率不退化,才算"通过且有效"。 */
|
|
107
|
+
improvement: tokenDelta !== null && tokenDelta < 0 && (successDelta === null || successDelta > -MAX_SUCCESS_DROP_PP),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* G6:零残留——给出的路径里不允许还有临时 profile / 工作区。
|
|
112
|
+
* @param {Array<{path: string, exists: boolean, kind?: string}>} checks
|
|
113
|
+
*/
|
|
114
|
+
export function checkResidue(checks = []) {
|
|
115
|
+
const leftovers = checks.filter((entry) => entry?.exists === true).map((entry) => ({ path: String(entry.path), kind: String(entry.kind ?? 'unknown') }));
|
|
116
|
+
return {
|
|
117
|
+
ok: leftovers.length === 0,
|
|
118
|
+
checked: checks.length,
|
|
119
|
+
leftovers,
|
|
120
|
+
/** 没有检查项等于没有证据,不算通过。 */
|
|
121
|
+
evidence: checks.length > 0 ? '<results>' : null,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* 汇总门禁。
|
|
126
|
+
* @param {{
|
|
127
|
+
* commands?: Array<{id: string, exitCode: number, note?: string}>,
|
|
128
|
+
* coverage?: ReturnType<typeof checkCoverage>,
|
|
129
|
+
* forkExperiment?: ReturnType<typeof evaluateForkExperiment>,
|
|
130
|
+
* residue?: ReturnType<typeof checkResidue>,
|
|
131
|
+
* releaseAuthorized?: boolean,
|
|
132
|
+
* }} input
|
|
133
|
+
*/
|
|
134
|
+
export function evaluateGate(input = {}) {
|
|
135
|
+
const items = [];
|
|
136
|
+
const blockers = [];
|
|
137
|
+
for (const command of Array.isArray(input.commands) ? input.commands : []) {
|
|
138
|
+
const id = String(command?.id ?? 'unknown');
|
|
139
|
+
const passed = num(command?.exitCode) === 0;
|
|
140
|
+
items.push({ gate: id, ok: passed, detail: `exit=${num(command?.exitCode)}${command?.note ? ` ${command.note}` : ''}` });
|
|
141
|
+
if (!passed)
|
|
142
|
+
blockers.push(`${id}:exit-code`);
|
|
143
|
+
}
|
|
144
|
+
const coverage = input.coverage;
|
|
145
|
+
if (!coverage) {
|
|
146
|
+
blockers.push('G4:coverage-not-checked');
|
|
147
|
+
items.push({ gate: 'G4', ok: false, detail: '未执行覆盖核验' });
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
items.push({ gate: 'G4', ok: coverage.ok === true, detail: coverage.ok ? `${coverage.covered.length} 项全中` : `缺 ${coverage.missing.map((item) => item.id).join(',')}` });
|
|
151
|
+
if (coverage.ok !== true)
|
|
152
|
+
blockers.push(`G4:missing:${coverage.missing.map((item) => item.id).join(',')}`);
|
|
153
|
+
}
|
|
154
|
+
const fork = input.forkExperiment;
|
|
155
|
+
if (!fork) {
|
|
156
|
+
blockers.push('G5:fork-experiment-missing');
|
|
157
|
+
items.push({ gate: 'G5', ok: false, detail: '尚无真实 fork 对照数据(≥10 任务/组)' });
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
items.push({ gate: 'G5', ok: fork.ok === true, detail: fork.ok ? `control ${fork.table[0].tokensPerTask} → enforce ${fork.table[1].tokensPerTask} tokens/task` : fork.problems.join(',') });
|
|
161
|
+
if (fork.ok !== true)
|
|
162
|
+
blockers.push(`G5:${fork.problems.join(',')}`);
|
|
163
|
+
}
|
|
164
|
+
const residue = input.residue;
|
|
165
|
+
if (!residue) {
|
|
166
|
+
blockers.push('G6:residue-not-checked');
|
|
167
|
+
items.push({ gate: 'G6', ok: false, detail: '未提供清理证据' });
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
items.push({ gate: 'G6', ok: residue.ok === true, detail: residue.ok ? `${residue.checked} 个路径均无残留` : `残留: ${residue.leftovers.map((item) => item.path).join(',')}` });
|
|
171
|
+
if (residue.ok !== true)
|
|
172
|
+
blockers.push('G6:residue');
|
|
173
|
+
}
|
|
174
|
+
// G7:默认未授权。设计本身不含发布动作;授权是人工显式行为。
|
|
175
|
+
const authorized = input.releaseAuthorized === true;
|
|
176
|
+
items.push({ gate: 'G7', ok: authorized, detail: authorized ? '用户已明确授权' : '未授权(发布需用户明确授权)' });
|
|
177
|
+
if (!authorized)
|
|
178
|
+
blockers.push('G7:release-not-authorized');
|
|
179
|
+
const hardGates = ['G1', 'G2', 'G3', 'G4', 'G5', 'G6'];
|
|
180
|
+
const hardOk = items.filter((item) => hardGates.includes(item.gate)).every((item) => item.ok);
|
|
181
|
+
return {
|
|
182
|
+
ok: hardOk && authorized,
|
|
183
|
+
/** 不发布 ≠ 代码不可用:G1–G4 + G6 全绿时,代码质量门禁是过了的。 */
|
|
184
|
+
bootstrapOk: hardOk,
|
|
185
|
+
blockers,
|
|
186
|
+
items,
|
|
187
|
+
releaseAuthorized: authorized,
|
|
188
|
+
};
|
|
189
|
+
}
|