@twinsearth/dsh-master 3.3.0 → 3.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,318 @@
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
+ /**
6
+ * 3.3.1 · 瓮城回流出口(设计 §5.6,里程碑 M5)。
7
+ *
8
+ * 3.3.0 里这条通道是硬编码的 `{ target:'fang', available:false,
9
+ * reason:'fang-channel-not-ready' }`:接口位在,但没有实现。3.3.1 把它换成
10
+ * **真实探测 + 真实投递**:
11
+ *
12
+ * 1. `probe()`:用契约 SDK 的 `hello()` 协商,看对端在公共 minor 下是否宣告
13
+ * `fang.relay.publish` / `fang.relay.status`(contract 1.1 才有的方法);
14
+ * 宣告了再读一次 `fang.relay.status` 确认对端**真的在服务**,不是只挂方法名。
15
+ * 2. `enqueue()` / `flush()`:把 `cost:event` / `cost:degraded` /
16
+ * `cost:substitution` 批量投递,按事件 id 幂等,回执里的 `accepted` /
17
+ * `duplicates` / `cursor` 才是“送达”的唯一证据。
18
+ * 3. `status()`:同步返回**最近一次已知状态**(`probe()` 异步更新)。
19
+ *
20
+ * 两条硬约束(与 I1–I8 一致):
21
+ * * 不可用必须显式 `available:false` + 具体原因,**不得**把队列长度当送达;
22
+ * * 投递失败不重试成“假成功”:批次退回队列、`failed` 计数、状态转不可用,
23
+ * 并保留 `lastError`。
24
+ *
25
+ * 对端身份(`peer`)由对端在 `fang.relay.status` 里**自报**(如
26
+ * `dsh-fang-bundle` / `contract-server`),只用于记录“对端是谁”,不参与鉴权,
27
+ * 也不得被当成“已对接真实瓮城”的证据 —— 那需要 `peer === 'dsh-fang-bundle'`。
28
+ */
29
+
30
+ import path from 'node:path'
31
+
32
+ import { METHODS, createContractClient } from '../contract/index.js'
33
+
34
+ /** 允许作为 M5 证据的对端标识(自报值白名单)。 */
35
+ export const RELAY_PEERS = Object.freeze(['dsh-fang-bundle', 'contract-server'])
36
+
37
+ /** 默认值:批量/队列/探测都有界,避免长跑进程无界增长。 */
38
+ export const RELAY_DEFAULTS = Object.freeze({
39
+ maxBatch: 32,
40
+ maxQueue: 500,
41
+ probeTtlMs: 60_000,
42
+ timeoutMs: 15_000,
43
+ })
44
+
45
+ function asString(value) { return String(value ?? '').trim() }
46
+
47
+ function clampInt(value, fallback, min, max) {
48
+ const n = Number(value)
49
+ if (!Number.isFinite(n)) return fallback
50
+ return Math.min(max, Math.max(min, Math.floor(n)))
51
+ }
52
+
53
+ function describeError(error) {
54
+ const code = asString(error?.code) || 'relay-error'
55
+ const message = asString(error?.message ?? error)
56
+ return message ? `${code}:${message.slice(0, 160)}` : code
57
+ }
58
+
59
+ /**
60
+ * 创建回流出口。
61
+ *
62
+ * @param {{
63
+ * client?: object | null, // createContractClient() 的返回值;null ⇒ 明确不可用
64
+ * target?: string,
65
+ * now?: () => number,
66
+ * maxBatch?: number, maxQueue?: number, probeTtlMs?: number, timeoutMs?: number,
67
+ * logger?: object,
68
+ * }} [options]
69
+ */
70
+ export function createFangRelay(options = {}) {
71
+ const client = options.client && typeof options.client === 'object' ? options.client : null
72
+ const target = asString(options.target) || 'fang'
73
+ const now = typeof options.now === 'function' ? options.now : () => Date.now()
74
+ const maxBatch = clampInt(options.maxBatch, RELAY_DEFAULTS.maxBatch, 1, 128)
75
+ const maxQueue = clampInt(options.maxQueue, RELAY_DEFAULTS.maxQueue, 1, 5_000)
76
+ const probeTtlMs = clampInt(options.probeTtlMs, RELAY_DEFAULTS.probeTtlMs, 1_000, 600_000)
77
+ const timeoutMs = clampInt(options.timeoutMs, RELAY_DEFAULTS.timeoutMs, 1_000, 300_000)
78
+ const logger = options.logger ?? null
79
+
80
+ const queue = []
81
+ const counters = { enqueued: 0, delivered: 0, duplicates: 0, failed: 0, dropped: 0, batches: 0, roundTrips: 0 }
82
+ let sequence = 0
83
+ let batchSequence = 0
84
+ let cursor = null
85
+ let lastError = null
86
+ let lastDeliveredAt = 0
87
+ let probing = null
88
+ // 没有客户端时开局即知不可用(不需要“探测中”这种灰色状态去掩盖)。
89
+ let state = Object.freeze({
90
+ available: false,
91
+ reason: client ? 'probe-pending' : 'relay-client-absent',
92
+ peer: null,
93
+ peerContractVersion: null,
94
+ transport: null,
95
+ probedAt: 0,
96
+ })
97
+
98
+ function setState(next) {
99
+ state = Object.freeze({ ...state, probedAt: now(), ...next })
100
+ }
101
+
102
+ function status() {
103
+ return Object.freeze({
104
+ target,
105
+ available: state.available === true,
106
+ reason: state.reason ?? null,
107
+ peer: state.peer ?? null,
108
+ peerContractVersion: state.peerContractVersion ?? null,
109
+ transport: state.transport ?? null,
110
+ delivered: counters.delivered,
111
+ failed: counters.failed,
112
+ queued: queue.length,
113
+ dropped: counters.dropped,
114
+ duplicates: counters.duplicates,
115
+ batches: counters.batches,
116
+ cursor,
117
+ lastError,
118
+ lastDeliveredAt,
119
+ probedAt: state.probedAt,
120
+ limits: { maxBatch, maxQueue, probeTtlMs, timeoutMs },
121
+ })
122
+ }
123
+
124
+ /** 探测对端:hello 协商 → 方法裁剪判定 → 读一次 relay 状态确认真的可用。 */
125
+ async function probe({ force = false } = {}) {
126
+ if (!client) {
127
+ setState({ available: false, reason: 'relay-client-absent', peer: null })
128
+ return status()
129
+ }
130
+ if (!force && state.probedAt > 0 && now() - state.probedAt < probeTtlMs) return status()
131
+ if (probing) return probing
132
+ probing = (async () => {
133
+ try {
134
+ const hello = await client.hello(true)
135
+ if (!hello || hello.ok !== true) {
136
+ setState({ available: false, reason: `hello-failed:${asString(hello?.code) || 'unknown'}`, peer: null })
137
+ return status()
138
+ }
139
+ const methods = Array.isArray(hello.negotiation?.methods) ? hello.negotiation.methods : []
140
+ const peerContractVersion = asString(hello.hello?.contractVersion) || null
141
+ const offersRelay = methods.includes(METHODS.RELAY_PUBLISH) && methods.includes(METHODS.RELAY_STATUS)
142
+ if (!offersRelay) {
143
+ // 1.0 对端不会宣告 1.1 方法;这是“协议层没有回流”,不是传输故障。
144
+ setState({
145
+ available: false,
146
+ reason: `peer-contract-without-relay:${peerContractVersion ?? 'unknown'}`,
147
+ peer: null,
148
+ peerContractVersion,
149
+ transport: asString(hello.transport) || null,
150
+ })
151
+ return status()
152
+ }
153
+ const remote = await client.relayStatus({}, { timeoutMs })
154
+ if (!remote || remote.ok !== true) {
155
+ setState({
156
+ available: false,
157
+ reason: `relay-status-failed:${asString(remote?.code) || 'unknown'}`,
158
+ peer: null,
159
+ peerContractVersion,
160
+ transport: asString(hello.transport) || null,
161
+ })
162
+ return status()
163
+ }
164
+ const result = remote.response?.result ?? {}
165
+ cursor = Number.isFinite(Number(result.cursor)) ? Number(result.cursor) : cursor
166
+ setState({
167
+ available: true,
168
+ reason: null,
169
+ peer: asString(result.peer) || null,
170
+ peerContractVersion: asString(result.contractVersion) || peerContractVersion,
171
+ transport: asString(remote.transport ?? hello.transport) || null,
172
+ })
173
+ return status()
174
+ } catch (error) {
175
+ setState({ available: false, reason: `probe-threw:${describeError(error)}`, peer: null })
176
+ try { logger?.warn?.(`[cost-relay] probe failed: ${describeError(error)}`) } catch { /* ignore */ }
177
+ return status()
178
+ }
179
+ })()
180
+ try { return await probing } finally { probing = null }
181
+ }
182
+
183
+ /** 入队一条总线事件;不可用时不假装收下(记 dropped 并返回原因)。 */
184
+ function enqueue(event = {}) {
185
+ if (state.available !== true) {
186
+ counters.dropped += 1
187
+ return { ok: false, code: 'relay-unavailable', reason: state.reason ?? 'unknown' }
188
+ }
189
+ const type = asString(event.type) || 'cost:event'
190
+ sequence += 1
191
+ const item = Object.freeze({
192
+ // id 稳定且唯一:重投同一批时对端按 id 去重,不会重复记账。
193
+ id: `cost-${type}-${sequence}-${Math.max(0, Math.floor(Number(event.at ?? now()) || 0))}`,
194
+ type,
195
+ at: Math.max(0, Math.floor(Number(event.at ?? now()) || 0)),
196
+ payload: event.payload && typeof event.payload === 'object' ? { ...event.payload } : {},
197
+ })
198
+ if (queue.length >= maxQueue) {
199
+ queue.shift()
200
+ counters.dropped += 1
201
+ }
202
+ queue.push(item)
203
+ counters.enqueued += 1
204
+ return { ok: true, id: item.id, queued: queue.length }
205
+ }
206
+
207
+ /** 投递一个批次;失败时批次退回队列并把状态转不可用(不吞错)。 */
208
+ async function flush() {
209
+ if (!client) return { ok: false, code: 'relay-client-absent', ...status() }
210
+ if (state.available !== true) return { ok: false, code: 'relay-unavailable', reason: state.reason ?? 'unknown', ...status() }
211
+ if (queue.length === 0) 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
+ } catch (error) {
219
+ outcome = { ok: false, code: 'relay-threw', error: describeError(error) }
220
+ }
221
+ counters.roundTrips += 1
222
+ if (outcome && outcome.ok === true) {
223
+ const result = outcome.response?.result ?? {}
224
+ const accepted = Math.max(0, Number(result.accepted ?? 0) || 0)
225
+ const duplicates = Math.max(0, Number(result.duplicates ?? 0) || 0)
226
+ // 只有对端回执才算送达:从队列头移除本次实际发送的批次。
227
+ queue.splice(0, batch.length)
228
+ counters.batches += 1
229
+ counters.delivered += accepted + duplicates
230
+ counters.duplicates += duplicates
231
+ if (Number.isFinite(Number(result.cursor))) cursor = Number(result.cursor)
232
+ lastDeliveredAt = now()
233
+ lastError = null
234
+ return { ok: true, delivered: accepted, duplicates, sent: batch.length, queued: queue.length, cursor, batchId }
235
+ }
236
+ const code = asString(outcome?.code) || 'publish-failed'
237
+ const detail = asString(outcome?.failures?.[0]?.code) || asString(outcome?.error)
238
+ lastError = detail && !detail.startsWith(code) ? `${code}:${detail}`.slice(0, 200) : (detail || code)
239
+ counters.failed += batch.length
240
+ setState({ available: false, reason: `publish-failed:${lastError}` })
241
+ try { logger?.warn?.(`[cost-relay] publish failed: ${lastError}`) } catch { /* ignore */ }
242
+ return { ok: false, code, error: lastError, sent: batch.length, queued: queue.length }
243
+ }
244
+
245
+ /** 挂到事件总线上:订阅者异常与投递异常都不影响主流程。 */
246
+ function attach(bus) {
247
+ if (!bus || typeof bus.subscribe !== 'function') return () => {}
248
+ return bus.subscribe((event) => {
249
+ try { enqueue(event) } catch { /* 回流不得影响主流程 */ }
250
+ })
251
+ }
252
+
253
+ function reset() {
254
+ queue.length = 0
255
+ lastError = null
256
+ setState({ available: false, reason: 'probe-pending', peer: null })
257
+ return status()
258
+ }
259
+
260
+ return Object.freeze({ target, probe, enqueue, flush, attach, status, reset, describe: status })
261
+ }
262
+
263
+ /**
264
+ * 从插件配置/环境装配契约客户端。
265
+ *
266
+ * 未配置任何传输时返回 null(**不**创建空客户端去假装探测过)。
267
+ *
268
+ * @param {object} ctx
269
+ * @param {{relay?: object, dispatch?: object}} [config]
270
+ * @param {{fetchImpl?: typeof fetch, env?: object}} [options]
271
+ */
272
+ export function createRelayClient(ctx, config = {}, options = {}) {
273
+ const raw = config.relay && typeof config.relay === 'object' ? config.relay : {}
274
+ const dispatch = config.dispatch && typeof config.dispatch === 'object' ? config.dispatch : {}
275
+ const env = options.env ?? process.env
276
+ const fetchImpl = options.fetchImpl ?? fetch
277
+ const httpBaseUrl = asString(raw.httpBaseUrl) || asString(dispatch.motherUrl) || asString(env.DSH_FANG_CONTRACT_URL) || asString(env.DSH_MASTER_MOTHER_URL)
278
+ const httpToken = asString(raw.httpToken) || asString(dispatch.motherToken) || asString(env.DSH_FANG_CONTRACT_TOKEN)
279
+ const motherDataDir = asString(env.DSH_FANG_MOTHER)
280
+ const fileDir = asString(raw.fileDir) || asString(dispatch.contractFileDir) || (motherDataDir ? path.join(motherDataDir, 'contract-outbox') : '')
281
+ const localService = (() => {
282
+ try { return ctx && typeof ctx.get === 'function' ? ctx.get('fangContract') : null } catch { return null }
283
+ })()
284
+ const enabled = raw.enabled === true || Boolean(httpBaseUrl) || Boolean(fileDir) || Boolean(localService)
285
+ if (!enabled) return null
286
+ if (raw.enabled === false) return null
287
+ return createContractClient({
288
+ ctx,
289
+ config: {
290
+ httpBaseUrl,
291
+ httpToken,
292
+ httpHeaders: raw.httpHeaders && typeof raw.httpHeaders === 'object' ? raw.httpHeaders : undefined,
293
+ fileDir,
294
+ timeoutMs: clampInt(raw.timeoutMs, RELAY_DEFAULTS.timeoutMs, 1_000, 300_000),
295
+ fileTimeoutMs: clampInt(raw.fileTimeoutMs, 30_000, 1_000, 300_000),
296
+ order: Array.isArray(raw.order) ? raw.order : undefined,
297
+ },
298
+ fetchImpl,
299
+ })
300
+ }
301
+
302
+ /**
303
+ * 一次性装配:配置 → 客户端 → 出口。返回的 `client` 为 null 表示没有任何
304
+ * 可用传输(此时出口状态是 `relay-client-absent`,不是“探测中”)。
305
+ */
306
+ export function createFangRelayFromContext(ctx, config = {}, options = {}) {
307
+ const client = createRelayClient(ctx, config, options)
308
+ const relay = createFangRelay({
309
+ client,
310
+ now: options.now,
311
+ maxBatch: config.relay?.maxBatch,
312
+ maxQueue: config.relay?.maxQueue,
313
+ probeTtlMs: config.relay?.probeTtlMs,
314
+ timeoutMs: config.relay?.timeoutMs,
315
+ logger: options.logger,
316
+ })
317
+ return { client, relay }
318
+ }
package/src/cost/index.ts CHANGED
@@ -17,6 +17,7 @@ import { createAuditChain } from './audit.ts'
17
17
  import { createAttribution } from './attribution.ts'
18
18
  import { createCostBus } from './bus.ts'
19
19
  import { createHostSurfaceAdapter } from './host-surface.ts'
20
+ import { createFangRelay } from './fang-relay.ts'
20
21
  import { buildSavingsReport, classifyEntry } from './report.ts'
21
22
  import { buildMetricsReport, METRIC_KEYS, SUCCESS_REGRESSION_PP } from './metrics.ts'
22
23
  import { MODULE_MANIFESTS, MODULE_IDS, validateManifests } from './modules/manifests.ts'
@@ -24,8 +25,9 @@ import { buildLedgerReport, formatLedgerReport, parseLedgerJsonl } from './ledge
24
25
  import { buildToolFaceReport, planToolFace } from './toolface.ts'
25
26
  import { buildReuseReport, formatReuseReport } from './reuse-report.ts'
26
27
 
27
- export { createLedger, createBudget, createPolicy, createAuditChain, createAttribution, createCostBus, createHostSurfaceAdapter, buildSavingsReport, classifyEntry, buildMetricsReport, METRIC_KEYS, SUCCESS_REGRESSION_PP, MODULE_MANIFESTS, MODULE_IDS, validateManifests, buildLedgerReport, formatLedgerReport, parseLedgerJsonl, buildToolFaceReport, planToolFace, buildReuseReport, formatReuseReport }
28
+ export { createLedger, createBudget, createPolicy, createAuditChain, createAttribution, createCostBus, createHostSurfaceAdapter, createFangRelay, buildSavingsReport, classifyEntry, buildMetricsReport, METRIC_KEYS, SUCCESS_REGRESSION_PP, MODULE_MANIFESTS, MODULE_IDS, validateManifests, buildLedgerReport, formatLedgerReport, parseLedgerJsonl, buildToolFaceReport, planToolFace, buildReuseReport, formatReuseReport }
28
29
  export * from './types.ts'
30
+ export { RELAY_PEERS, RELAY_DEFAULTS, createFangRelayFromContext, createRelayClient } from './fang-relay.ts'
29
31
 
30
32
  /**
31
33
  * @param {{
@@ -34,6 +36,7 @@ export * from './types.ts'
34
36
  * budgetLimits?: Array<{unit:string, limitTokens?:number, limitCost?:number, onExhaust?:string, degradeTo?:string}>,
35
37
  * degradeModels?: string[],
36
38
  * now?: () => number, maxEntries?: number, maxRecords?: number,
39
+ * relay?: object, relayClient?: object|null, relayOptions?: object, relayAutoAttach?: boolean,
37
40
  * }} [options]
38
41
  */
39
42
  export function createCostSystem(options = {}) {
@@ -50,6 +53,18 @@ export function createCostSystem(options = {}) {
50
53
  })
51
54
  const attribution = createAttribution({ ledger, now })
52
55
  const bus = createCostBus({ now, maxEvents: options.maxBusEvents, relays: options.relays })
56
+ /**
57
+ * §5.6 / M5:瓮城回流出口。
58
+ * 传入 `relay`(已有适配器)则复用;否则用 `relayClient` 现造一个。
59
+ * 没有客户端时出口状态是 `relay-client-absent` —— 显式不可用,不是“探测中”。
60
+ */
61
+ const relay = options.relay && typeof options.relay.status === 'function'
62
+ ? options.relay
63
+ : createFangRelay({ client: options.relayClient ?? null, now, ...(options.relayOptions && typeof options.relayOptions === 'object' ? options.relayOptions : {}), logger: options.logger })
64
+ // 默认把出口挂到总线;测试可关掉自动挂载,手动投递。
65
+ if (options.relayAutoAttach !== false && typeof relay.attach === 'function') {
66
+ try { relay.attach(bus) } catch { /* 回流挂载失败不影响记账 */ }
67
+ }
53
68
  /** 宿主会话工具面适配器(可选;默认关闭,不依赖任何第三方插件)。 */
54
69
  const hostSurface = createHostSurfaceAdapter({
55
70
  ledger,
@@ -270,6 +285,21 @@ export function createCostSystem(options = {}) {
270
285
  return bus.subscribe(listener)
271
286
  }
272
287
 
288
+ /** §5.6:探测回流对端(hello 协商 + relay 状态),结果并入 `status().relay`。 */
289
+ async function relayProbe(input = {}) {
290
+ return await relay.probe({ force: input.force === true })
291
+ }
292
+
293
+ /** §5.6:投递当前队列里的回流事件;返回回执摘要(不吞错,不谎报送达)。 */
294
+ async function relayFlush() {
295
+ if (typeof relay.flush !== 'function') return { ok: false, code: 'relay-flush-unsupported' }
296
+ return await relay.flush()
297
+ }
298
+
299
+ function relayStatus() {
300
+ return relay.status()
301
+ }
302
+
273
303
  function status() {
274
304
  const ledgerSnapshot = ledger.snapshot()
275
305
  const auditVerify = audit.verify()
@@ -293,6 +323,8 @@ export function createCostSystem(options = {}) {
293
323
  unattributedProjectedTotal: attributionSnapshot.unattributedProjectedTotal,
294
324
  },
295
325
  bus: { count: busSnapshot.count, dropped: busSnapshot.dropped, subscribers: busSnapshot.subscribers, relays: busSnapshot.relays },
326
+ /** §5.6 / M5:瓮城回流出口的真实状态(可达则 available:true + 对端自报 peer)。 */
327
+ relay: relay.status(),
296
328
  degraded: {
297
329
  modules: [...degradedByModule.values()].map((item) => ({ ...item, phases: { ...item.phases } })),
298
330
  occurrences: [...degradedByModule.values()].reduce((sum, item) => sum + item.occurrences, 0),
@@ -312,5 +344,5 @@ export function createCostSystem(options = {}) {
312
344
  })
313
345
  }
314
346
 
315
- return Object.freeze({ ledger, budget, policy, audit, attribution, bus, hostSurface, recordUsage, authorize, attribute, attributeTotal, substitute, trace, degraded, query, replay, savingsReport, metrics, ledgerReport, subscribe, status })
347
+ return Object.freeze({ ledger, budget, policy, audit, attribution, bus, hostSurface, relay, recordUsage, authorize, attribute, attributeTotal, substitute, trace, degraded, query, replay, savingsReport, metrics, ledgerReport, subscribe, status, relayProbe, relayFlush, relayStatus })
316
348
  }
@@ -28,6 +28,13 @@ export const M3_MIN_SAMPLES = 20
28
28
  /** M4 放行前要求的最小守卫准确率(与 ENFORCE_GATE.minAccuracy 一致)。 */
29
29
  export const M3_MIN_ACCURACY = 0.98
30
30
 
31
+ /**
32
+ * M5 回流放行条件:至少 3 条事件拿到对端回执,且对端自报身份在白名单内。
33
+ * 自环(同一进程里服务端+客户端)与未知 peer 一律不算达标。
34
+ */
35
+ export const M5_MIN_RELAY_EVENTS = 3
36
+ export const M5_RELAY_PEERS = Object.freeze(['dsh-fang-bundle', 'contract-server'])
37
+
31
38
  export const MILESTONES = Object.freeze([
32
39
  Object.freeze({
33
40
  id: 'M1',
@@ -79,13 +86,16 @@ export const MILESTONES = Object.freeze([
79
86
  * files?: string[], // 仓库里实际存在的文件(相对路径)
80
87
  * guard?: {samples?: number, accuracy?: number}, // M3 的守卫准确率数据(来自 shadow 报告)
81
88
  * forkExperiment?: {ok?: boolean}, // M4 的成功率对照(来自 gate 的 G5)
82
- * fangRelay?: {available?: boolean}, // 瓮城回流通道(本会话范围内恒为不可用)
89
+ * fangRelay?: {available?: boolean, peer?: string, events?: number,
90
+ * sameProcess?: boolean, transport?: string, contractVersion?: string,
91
+ * reason?: string}, // M5 的回流对端证据(独立进程实测)
83
92
  * }} evidence
84
93
  */
85
94
  export function evaluateMilestones(evidence = {}) {
86
95
  const files = new Set(Array.isArray(evidence.files) ? evidence.files : [])
87
96
  const rows = []
88
97
  const done = new Set()
98
+ let relayEvidence = null
89
99
 
90
100
  for (const milestone of MILESTONES) {
91
101
  const missingImplementation = milestone.implementation.filter((file) => !files.has(file))
@@ -118,10 +128,30 @@ export function evaluateMilestones(evidence = {}) {
118
128
  if (evidence?.forkExperiment?.ok === true) effect = 'verified'
119
129
  else reasons.push('effect-needs-fork-success-comparison')
120
130
  } else if (milestone.id === 'M5') {
121
- // 面板 + 冻结清单可测;瓮城回流在本会话范围内明确不可用(不伪装)
122
- const relayOk = evidence?.fangRelay?.available === true
123
- effect = relayOk ? 'verified' : 'unmeasured'
124
- if (!relayOk) reasons.push('fang-relay-out-of-scope')
131
+ // 回流必须有**对端回执**才算达标:可用 + 非自环 + 对端自报身份在白名单 + 至少 3 条事件。
132
+ const relay = evidence?.fangRelay && typeof evidence.fangRelay === 'object' ? evidence.fangRelay : null
133
+ const available = relay?.available === true
134
+ const peer = String(relay?.peer ?? '')
135
+ const events = Math.max(0, Number(relay?.events ?? 0) || 0)
136
+ if (!available) {
137
+ reasons.push(`fang-relay-unavailable:${String(relay?.reason ?? 'not-provided')}`)
138
+ } else if (relay?.sameProcess === true) {
139
+ reasons.push('fang-relay-self-loop')
140
+ } else if (!M5_RELAY_PEERS.includes(peer)) {
141
+ reasons.push(`fang-relay-peer-invalid:${peer || 'missing'}`)
142
+ } else if (events < M5_MIN_RELAY_EVENTS) {
143
+ reasons.push(`fang-relay-no-receipts(events=${events})`)
144
+ } else {
145
+ effect = 'verified'
146
+ }
147
+ relayEvidence = Object.freeze({
148
+ available,
149
+ peer: peer || null,
150
+ events,
151
+ sameProcess: relay?.sameProcess === true,
152
+ transport: relay?.transport ?? null,
153
+ contractVersion: relay?.contractVersion ?? relay?.peerContractVersion ?? null,
154
+ })
125
155
  }
126
156
  }
127
157
 
@@ -141,6 +171,12 @@ export function evaluateMilestones(evidence = {}) {
141
171
  effect,
142
172
  status,
143
173
  reasons,
174
+ /** M5:回流对端证据(peer 自报 + 事件数 + 是否自环);其它阶段为 null。 */
175
+ relay: relayEvidence,
176
+ /** 剩余缺口说明(不隐藏)。 */
177
+ notes: milestone.id === 'M5' && relayEvidence?.peer === 'contract-server'
178
+ ? ['peer=contract-server:契约对端独立进程实测,dsh-fang-bundle 采用 contract 1.1 后应变为 peer=dsh-fang-bundle']
179
+ : [],
144
180
  }))
145
181
  if (status === 'done') done.add(milestone.id)
146
182
  }
package/src/kernel.ts CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  import crypto from 'node:crypto'
12
12
 
13
- export const MASTER_VERSION = '3.3.0'
13
+ export const MASTER_VERSION = '3.3.2'
14
14
  export const FANG_CONTRACT_MIN = '3.1.14'
15
15
 
16
16
  export const SERVICE = Object.freeze({
@@ -28,7 +28,7 @@ import {
28
28
  roleAllowed,
29
29
  safeGet,
30
30
  } from '../kernel.ts'
31
- import { createCostSystem, DEFAULT_BUDGET_LIMITS } from '../cost/index.ts'
31
+ import { createCostSystem, createFangRelayFromContext, DEFAULT_BUDGET_LIMITS } from '../cost/index.ts'
32
32
 
33
33
  export const name = 'dsh-master-cost-optimizer'
34
34
  export const inject = ['dshMaster']
@@ -53,6 +53,10 @@ function finiteNonNegative(value) {
53
53
  * enabled?: boolean, mode?: string, targetSavingsRatio?: number,
54
54
  * budget?: Array<{unit: string, limitTokens: number, onExhaust?: string}>,
55
55
  * enforceAllowlist?: string[],
56
+ * relay?: {enabled?: boolean, httpBaseUrl?: string, httpToken?: string, fileDir?: string,
57
+ * timeoutMs?: number, maxBatch?: number, maxQueue?: number, probeTtlMs?: number,
58
+ * autoFlushMs?: number},
59
+ * dispatch?: {motherUrl?: string, motherToken?: string, contractFileDir?: string},
56
60
  * }} [config]
57
61
  */
58
62
  export function apply(ctx, config = {}) {
@@ -70,6 +74,12 @@ export function apply(ctx, config = {}) {
70
74
  }
71
75
 
72
76
  const targetSavingsRatio = finiteNonNegative(config.targetSavingsRatio)
77
+ /**
78
+ * §5.6 / M5:瓮城回流出口。
79
+ * 传输按「插件配置 relay → dispatch 配置 → 环境变量 DSH_FANG_*」装配;
80
+ * 一个都没有时不造客户端,出口状态显式 `relay-client-absent`。
81
+ */
82
+ const relayWiring = createFangRelayFromContext(ctx, config, { logger: ctx?.logger })
73
83
  const system = createCostSystem({
74
84
  mode: config.mode,
75
85
  allowlist: config.enforceAllowlist ?? config.allowlist,
@@ -80,9 +90,21 @@ export function apply(ctx, config = {}) {
80
90
  hostSurface: config.hostSurface && typeof config.hostSurface === 'object'
81
91
  ? { enabled: config.hostSurface.enabled === true, source: config.hostSurface.source }
82
92
  : undefined,
93
+ relay: relayWiring.relay,
94
+ logger: ctx?.logger,
83
95
  })
84
96
  const policy = system.policy.describe()
85
97
 
98
+ // M5:加载后异步探测一次回流对端(不阻塞 apply;没有客户端时立即返回 absent)。
99
+ try { void Promise.resolve(system.relayProbe()).catch(() => {}) } catch { /* 探测失败不影响主流程 */ }
100
+ /** M5:回流自动投递;没有客户端或 autoFlushMs=0 时不开定时器。 */
101
+ const autoFlushMs = Math.max(0, Number(config.relay?.autoFlushMs ?? 2_000) || 0)
102
+ let relayTimer = null
103
+ if (relayWiring.client && autoFlushMs > 0) {
104
+ relayTimer = setInterval(() => { try { void system.relayFlush().catch(() => {}) } catch { /* ignore */ } }, autoFlushMs)
105
+ try { relayTimer.unref?.() } catch { /* ignore */ }
106
+ }
107
+
86
108
  // 3.2.x 兼容计数器(任务级实测汇总;系统层另有账本,两者口径不同、互不覆盖)。
87
109
  const metrics = {
88
110
  tasks: 0,
@@ -95,7 +117,7 @@ export function apply(ctx, config = {}) {
95
117
 
96
118
  const service = {
97
119
  id: SERVICE.cost,
98
- version: '3.3.0',
120
+ version: '3.3.2',
99
121
  role,
100
122
  get mode() {
101
123
  return system.policy.describe().mode
@@ -315,12 +337,24 @@ export function apply(ctx, config = {}) {
315
337
  substitutions(filter = {}) {
316
338
  return system.audit.substitutions(filter)
317
339
  },
340
+ /** M5:瓮城回流出口状态(只读;不可用时必须带具体原因)。 */
341
+ relayStatus() {
342
+ return system.relayStatus()
343
+ },
344
+ /** M5:立即探测回流对端(hello 协商 + relay 状态),返回最新状态。 */
345
+ async relayProbe(input = {}) {
346
+ return await system.relayProbe(input)
347
+ },
348
+ /** M5:立即投递队列中的回流事件;回执里的数字才是送达证据。 */
349
+ async relayFlush() {
350
+ return await system.relayFlush()
351
+ },
318
352
  status() {
319
353
  const current = system.policy.describe()
320
354
  return {
321
355
  ok: true,
322
356
  id: 'cost-optimizer',
323
- version: '3.3.0',
357
+ version: '3.3.2',
324
358
  role,
325
359
  mode: current.mode,
326
360
  requestedMode: current.requestedMode,
@@ -340,8 +374,10 @@ export function apply(ctx, config = {}) {
340
374
  attribution: system.attribution.snapshot(),
341
375
  attributionSummary: system.status().attributionSummary,
342
376
  bus: system.bus.snapshot(1),
343
- /** §5.6:内部事件类型(冒号命名)与对外出口状态(fang 未就绪 ⇒ unavailable)。 */
344
- events: { types: ['cost:event', 'cost:degraded', 'cost:substitution'], relays: system.bus.snapshot(1).relays },
377
+ /** §5.6 / M5:瓮城回流出口(可达 ⇒ available:true + 对端自报 peer;否则带原因)。 */
378
+ relay: system.relayStatus(),
379
+ /** §5.6:内部事件类型(冒号命名)与对外出口状态。 */
380
+ events: { types: ['cost:event', 'cost:degraded', 'cost:substitution'], relays: system.bus.snapshot(1).relays },
345
381
  degraded: system.status().degraded,
346
382
  surfaces: system.status().surfaces,
347
383
  hostSurface: system.hostSurface.status(),
@@ -360,12 +396,18 @@ export function apply(ctx, config = {}) {
360
396
  doubleCountGuard: report.doubleCountGuard,
361
397
  }
362
398
  })(),
363
- note: '3.3.0 M1+M2:系统层记账/预算/灰度/审计 + A1 工具输出紧凑化(默认 shadow);工具面 A2–A3 与决策复用 B1–B4 在 M3–M4 接入。',
399
+ note: '3.3.2:系统层记账/预算/灰度/审计 + A1 工具输出紧凑化(默认 shadow)+ 瓮城回流出口(contract 1.1.1 真实投递,回执计数修复);工具面 A2–A3 与决策复用 B1–B4 已接入(shadow 默认)。',
364
400
  }
365
401
  },
366
402
  }
367
403
 
368
404
  try { ctx.effect(() => ctx.provide(SERVICE.cost, service)) } catch { /* fake ctx */ }
405
+ try {
406
+ ctx.effect(() => () => {
407
+ if (relayTimer) clearInterval(relayTimer)
408
+ relayTimer = null
409
+ })
410
+ } catch { /* fake ctx */ }
369
411
  // 3.3.0 §11:可选子服务(主服务形态不变;消费方必须探测,不能假定存在)。
370
412
  const optionalFaces = {
371
413
  [OPTIONAL_SERVICE.costLedger]: Object.freeze({
@@ -312,6 +312,20 @@ export function apply(ctx, config = {}) {
312
312
  } catch { return null }
313
313
  })()
314
314
  const tickets = id === null ? [...workerEvents.keys()] : [id]
315
+ /**
316
+ * §5.6 / M5:回流出口状态来自成本服务(3.3.1 起是真实探测,不再是硬编码)。
317
+ * 没有成本服务时如实给 `cost-service-unavailable`,不编造“未就绪”原因。
318
+ */
319
+ const relayBlock = (() => {
320
+ try {
321
+ const cost = safeGet(ctx, SERVICE.cost)
322
+ if (cost && typeof cost.relayStatus === 'function') {
323
+ const relay = cost.relayStatus()
324
+ if (relay && typeof relay === 'object') return relay
325
+ }
326
+ } catch { /* 回流状态读取失败不阻塞成本摘要 */ }
327
+ return { target: 'fang', available: false, reason: 'cost-service-unavailable' }
328
+ })()
315
329
  const rows = []
316
330
  const totals = { events: 0, degraded: 0, substitutions: 0, estimated: 0, savedTokens: 0 }
317
331
  for (const key of tickets) {
@@ -345,8 +359,8 @@ export function apply(ctx, config = {}) {
345
359
  totals,
346
360
  /* §11:只读成本块(A 单价项 / B 跳过调用项分列 + 命中率),面板只读消费。 */
347
361
  service: serviceBlock,
348
- /* §5.6:瓮城「产物回流」尚未就绪 —— 显式 unavailable,不假装成功、不阻塞主线。 */
349
- relay: { target: 'fang', available: false, reason: 'fang-channel-not-ready' },
362
+ /* §5.6 / M5:瓮城回流出口真实状态(不可用必须显式带原因)。 */
363
+ relay: relayBlock,
350
364
  }
351
365
  }
352
366