agentworkshop 0.7.48 → 0.7.49

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.
Files changed (31) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/_nuxt/builds/latest.json +1 -1
  3. package/.output/public/_nuxt/builds/meta/0530cee1-fff5-4025-a6f4-5708b6534608.json +1 -0
  4. package/.output/server/chunks/_/nitro.mjs +957 -955
  5. package/.output/server/package.json +1 -1
  6. package/README-zh.md +813 -811
  7. package/README.md +823 -821
  8. package/config.yml +176 -176
  9. package/docs/cli.en.md +445 -445
  10. package/docs/cli.md +410 -410
  11. package/docs/full-test-plan.md +191 -191
  12. package/docs/plugins.en.md +567 -567
  13. package/docs/plugins.md +560 -560
  14. package/docs/sdk.en.md +922 -922
  15. package/docs/sdk.md +861 -861
  16. package/package.json +172 -172
  17. package/scripts/_aw0746-stage5.mjs +159 -144
  18. package/scripts/_aw0746-stage7-twin.mjs +301 -267
  19. package/scripts/_aw0748-failclosed-probe.mjs +117 -90
  20. package/scripts/_aw0748-fresh-channel.mjs +21 -0
  21. package/scripts/_aw0748-mock-closedloop.mjs +89 -0
  22. package/scripts/_aw0748-wake-channel.mjs +46 -0
  23. package/scripts/_aw0749-readme-rows.mjs +23 -0
  24. package/scripts/_probe-lease.cjs +13 -0
  25. package/scripts/_probe-task-queue.cjs +9 -0
  26. package/server/services/workshop/aml/twin/provider-contracts.ts +34 -0
  27. package/server/services/workshop/aml/twin/provider-health.ts +8 -0
  28. package/server/services/workshop/aml/twin/provider-loader.ts +67 -0
  29. package/server/services/workshop/aml/twin/provider-registry.ts +50 -0
  30. package/server/services/workshop/runtime/manager/state.ts +21 -4
  31. package/.output/public/_nuxt/builds/meta/6f524f3b-53e1-4df9-84e6-84d8058b9f16.json +0 -1
@@ -1,144 +1,159 @@
1
- /**
2
- * 0.7.46/0.7.47 打包系统验收 · 阶段 5:真实场景 prompt 闭环控制优化(hybrid twin channel)
3
- * - 用 PLC 模拟注塑产线的**真实场景事实**(克重窗口/守卫/受控量/活动批次)下发 goal 任务
4
- * - 断言:真实 omp 团队走完 twin_scene_read → twin_snapshot_create → mpc_optimize → twin_gate_evaluate
5
- * - 断言:recommendation-only 生效 —— 全程**零真实 DCW 写入**
6
- * 用法:AW_BASE=http://127.0.0.1:3001 [AW_HOME=<隔离实例目录>] node scripts/_aw0746-stage5.mjs
7
- */
8
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
9
- import { join } from 'node:path'
10
-
11
- const BASE = process.env.AW_BASE ?? 'http://127.0.0.1:3001'
12
- const HOME = process.env.AW_HOME ?? `${process.cwd()}/.aw0746-home`
13
- const ADMIN = { email: process.env.AW_ADMIN_EMAIL ?? 'admin@awshop.local', password: process.env.AW_ADMIN_PASSWORD ?? 'Awshop@123!' }
14
-
15
- let pass = 0
16
- const fails = []
17
- const check = (name, ok, detail = '') => {
18
- console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`)
19
- if (ok) pass += 1
20
- else fails.push(name)
21
- }
22
- const sleep = ms => new Promise(r => setTimeout(r, ms))
23
- const j = async (method, path, body, token) => {
24
- const r = await fetch(BASE + path, {
25
- method,
26
- headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
27
- body: body === undefined ? undefined : JSON.stringify(body),
28
- signal: AbortSignal.timeout(180_000),
29
- })
30
- return { status: r.status, ...(await r.json().catch(() => ({}))) }
31
- }
32
-
33
- const token = (await j('POST', '/api/users/login', { email: ADMIN.email, password: ADMIN.password })).data?.token
34
- const chans = (await j('GET', '/api/workshop/channels', undefined, token)).data ?? []
35
- const twin = chans.find(c => /闭环优化-hybridtwin/.test(c.name))
36
- check('找到闭环优化 Channel(hybrid twin 模板实例)', Boolean(twin), twin?.name)
37
- const channelId = twin.id
38
- const members = (await j('GET', `/api/workshop/channels/${channelId}/agents`, undefined, token)).data ?? []
39
- const lead = members.find(m => m.role === 'lead')
40
-
41
- const dcw = (await j('GET', '/api/workshop/dcw', undefined, token)).data
42
- const line = (dcw.lines ?? []).find(l => /injection-aw0746/i.test(String(l.name)))
43
- const daq = (await j('GET', '/api/workshop/daq', undefined, token)).data
44
- const nodes = (daq.nodes ?? []).filter(n => n.lineId === line.id)
45
- const byName = re => nodes.find(n => re.test(n.name))
46
- const controls = (dcw.nodes ?? []).filter(n => n.lineId === line.id && /hold-pressure|hold-time|mold-temp/.test(n.name))
47
- const writesBefore = Number(dcw.controller?.writesTotal ?? 0)
48
-
49
- // 真实场景 prompt(取自 PLC 模拟器注塑场景的工程事实)
50
- const SCENARIO = [
51
- '场景:PLC 模拟器注塑成型产线(真实协议栈,非抽象任务)。',
52
- `产线 ${line.name}(${line.id}),活动批次配方三件套由 line_context 读取,不要臆造 id。`,
53
- '质量目标:制品克重 part-weight 命中 32.5 ± 0.35 g;飞边 flash-rate ≤ 0.4%;缩痕 sink-mark ≤ 1.5%;',
54
- '守卫:熔体温度 melt-temp-pv 235~262℃;注射压力 inj-pressure-pv 40~70 bar。',
55
- `受控量(不可越界,单变量小步):${controls.map(c => `${c.name}(${c.id},${c.min}~${c.max}${c.unit})`).join('; ')}。`,
56
- `观测节点:${[byName(/part-weight/), byName(/flash-rate/), byName(/sink-mark/), byName(/melt-temp/), byName(/inj-pressure/)].filter(Boolean).map(n => `${n.name}(${n.id})`).join('; ')}。`,
57
- ].join('\n')
58
-
59
- const TASK = [
60
- '目标:完成一次 recommendation-only 的混合孪生 MPC 闭环优化推演,并给出可审计的推荐结论。',
61
- '硬性要求(按序执行,每步都有据可查):',
62
- '1) twin_scene_read 读取场景契约;若场景未就绪,先用 scene/objective 参数把当前产线与活动批次登记进去(允许使用工具自带参数)。',
63
- '2) twin_snapshot_create 生成工况快照(必须带最新 DAQ 值)。',
64
- '3) twin_trial_run 跑至少 1 个候选的虚拟试验(硬约束拒绝不安全候选;candidateExecuted 必须为 false)。',
65
- '4) mpc_optimize 在受控量小步范围内搜索推荐设定;',
66
- '5) twin_gate_evaluate 做门禁与收益评估 —— 若门禁未通过,报告 safe_small_step 结论(这是合法结果,不要伪造通过)。',
67
- '禁止事项:任何成员不得调用 dcw_control / param_control 写真实设定(本次为 recommendation-only,写控需另行授权)。',
68
- '交付物(中文,≤ 12 行):① 场景与批次三元组;② 快照关键值;③ 推荐设定(或 safe_small_step 原因);④ 门禁/证书结论;⑤ 一句话说明"未写真实 PLC"这一事实。',
69
- ].join('\n')
70
-
71
- const task = await j('POST', `/api/workshop/channels/${channelId}/tasks`, {
72
- title: `闭环优化:注塑克重窗口 recommendation-only ${Date.now().toString(36).slice(-4)}`,
73
- description: `${SCENARIO}\n\n${TASK}`,
74
- assigneeId: lead?.id,
75
- }, token)
76
- const taskId = task.data?.id
77
- const taskStartMs = Date.now() - 60_000
78
- check('闭环优化任务下发到 lead(真实 omp 团队)', Boolean(taskId), `task=${String(taskId).slice(0, 8)} ${task.message ?? ''}`)
79
-
80
- console.log(' … 等待真实 omp 团队执行(最长 12 分钟)…')
81
- let final = null
82
- const deadline = Date.now() + 12 * 60_000
83
- while (Date.now() < deadline) {
84
- await sleep(15_000)
85
- const ts = (await j('GET', `/api/workshop/channels/${channelId}/tasks`, undefined, token)).data ?? []
86
- const t = ts.find(x => x.id === taskId)
87
- process.stdout.write(`\r state=${t?.state ?? '?'} progress=${t?.progress ?? 0}% children=${ts.filter(x => x.parentId === taskId).length} `)
88
- if (t && ['COMPLETED', 'FAILED', 'CANCELED'].includes(t.state)) {
89
- final = t
90
- break
91
- }
92
- }
93
- console.log('')
94
- check('任务到达终态', Boolean(final), `state=${final?.state ?? 'timeout'}`)
95
- check('任务 COMPLETED(闭环推演完成)', final?.state === 'COMPLETED', `state=${final?.state}`)
96
-
97
- const arts = (final?.artifacts ?? []).map(a => (a.parts ?? []).map(p => p.text ?? '').join('\n')).join('\n---\n')
98
- console.log('\n交付物:\n' + (arts || '(空)').slice(0, 1400))
99
-
100
- // 工具证据必须落在**磁盘工件**上,而不是频道事件里 grep 工具名:
101
- // ① 事件流不记 Agent 的工具调用(实测命中为空,旧断言属于误判);
102
- // ② 交付物里的 snapshot_id/trial_id 可能是模型幻觉,只有工件文件才是硬证据。
103
- const twinsRoot = `${HOME}/aml/twins`
104
- const since = taskStartMs
105
- const newer = (dir) => {
106
- const p = join(twinsRoot, dir)
107
- if (!existsSync(p)) return []
108
- return readdirSync(p, { withFileTypes: true })
109
- .filter(e => e.isDirectory() && statSync(join(p, e.name)).mtimeMs >= since)
110
- .map(e => join(p, e.name))
111
- }
112
- const snapDirs = newer('snapshots')
113
- const trialDirs = newer('trials')
114
- const mpcDirs = newer('mpc')
115
- check('孪生/MPC 工具链被真实调用(磁盘工件留痕)', snapDirs.length >= 1 && trialDirs.length >= 1, `快照=${snapDirs.length} 试验=${trialDirs.length} MPC=${mpcDirs.length}(${twinsRoot})`)
116
-
117
- const trialArtifacts = trialDirs.map((d) => {
118
- try {
119
- return JSON.parse(readFileSync(join(d, 'trials.json'), 'utf8'))
120
- }
121
- catch {
122
- return null
123
- }
124
- }).filter(Boolean)
125
- const trialsReal = trialArtifacts.map(a => a.trial).filter(Boolean)
126
- check('虚拟试验恒为 candidateExecuted=false(recommendation-only)', trialsReal.length >= 1 && trialsReal.every(t => t.candidateExecuted === false), `试验数=${trialsReal.length} candidateExecuted=${[...new Set(trialsReal.map(t => t.candidateExecuted))].join(',')}`)
127
- const snapIds = snapDirs.map((d) => {
128
- try {
129
- return JSON.parse(readFileSync(join(d, 'snapshots.json'), 'utf8'))
130
- }
131
- catch {
132
- return null
133
- }
134
- }).filter(Boolean)
135
- check('TwinSnapshot 为真实数据(fresh=true 且水位>0)', snapIds.length >= 1 && snapIds.every(s2 => s2.dataQuality?.fresh === true && s2.daqWatermark > 0), snapIds.map(s2 => `${s2.snapshotId} fresh=${s2.dataQuality?.fresh}`).join('; '))
136
- const certs = trialArtifacts.filter(a => a.certificate).length
137
- console.log(` · 工件明细:快照 ${snapIds.length} 个 / 试验 ${trialsReal.length} 个 / 推荐证书 ${certs} 张`)
138
-
139
- const dcwAfter = (await j('GET', '/api/workshop/dcw', undefined, token)).data
140
- const writesAfter = Number(dcwAfter.controller?.writesTotal ?? 0)
141
- check('全程零真实 DCW 写入(recommendation-only 生效)', writesAfter === writesBefore, `writes ${writesBefore} → ${writesAfter}`)
142
-
143
- console.log(`\n★ 阶段 5:${pass} 通过 / ${fails.length} 失败${fails.length ? ` (${fails.join('; ')})` : ''}`)
144
- console.log(JSON.stringify({ channelId, taskId, state: final?.state }, null, 1))
1
+ /**
2
+ * 0.7.46/0.7.47 打包系统验收 · 阶段 5:真实场景 prompt 闭环控制优化(hybrid twin channel)
3
+ * - 用 PLC 模拟注塑产线的**真实场景事实**(克重窗口/守卫/受控量/活动批次)下发 goal 任务
4
+ * - 断言:真实 omp 团队走完 twin_scene_read → twin_snapshot_create → mpc_optimize → twin_gate_evaluate
5
+ * - 断言:recommendation-only 生效 —— 全程**零真实 DCW 写入**
6
+ * 用法:AW_BASE=http://127.0.0.1:3001 [AW_HOME=<隔离实例目录>] node scripts/_aw0746-stage5.mjs
7
+ */
8
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+
11
+ const BASE = process.env.AW_BASE ?? 'http://127.0.0.1:3001'
12
+ const HOME = process.env.AW_HOME ?? `${process.cwd()}/.aw0746-home`
13
+ const ADMIN = { email: process.env.AW_ADMIN_EMAIL ?? 'admin@awshop.local', password: process.env.AW_ADMIN_PASSWORD ?? 'Awshop@123!' }
14
+
15
+ let pass = 0
16
+ const fails = []
17
+ const check = (name, ok, detail = '') => {
18
+ console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`)
19
+ if (ok) pass += 1
20
+ else fails.push(name)
21
+ }
22
+ const sleep = ms => new Promise(r => setTimeout(r, ms))
23
+ const j = async (method, path, body, token) => {
24
+ const r = await fetch(BASE + path, {
25
+ method,
26
+ headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
27
+ body: body === undefined ? undefined : JSON.stringify(body),
28
+ signal: AbortSignal.timeout(180_000),
29
+ })
30
+ return { status: r.status, ...(await r.json().catch(() => ({}))) }
31
+ }
32
+
33
+ const token = (await j('POST', '/api/users/login', { email: ADMIN.email, password: ADMIN.password })).data?.token
34
+ const chans = (await j('GET', '/api/workshop/channels', undefined, token)).data ?? []
35
+ // AW_CHANNEL_ID 可指定专用 Channel:FIFO 根队列是串行的,上一个根任务 FAILED 后
36
+ // 该 Channel 可能长时间不再派发新根任务(实测 55017fd8 悬挂 15 分钟),
37
+ // 验收用新实例化 Channel 可避免被历史队列拖住。
38
+ const twin = process.env.AW_CHANNEL_ID
39
+ ? chans.find(c => c.id === process.env.AW_CHANNEL_ID)
40
+ : chans.find(c => /闭环优化-hybridtwin/.test(c.name))
41
+ check('找到闭环优化 Channel(hybrid twin 模板实例)', Boolean(twin), `${twin?.name} ${String(twin?.id).slice(0, 8)}`)
42
+ const channelId = twin.id
43
+ const members = (await j('GET', `/api/workshop/channels/${channelId}/agents`, undefined, token)).data ?? []
44
+ const lead = members.find(m => m.role === 'lead')
45
+
46
+ const dcw = (await j('GET', '/api/workshop/dcw', undefined, token)).data
47
+ const line = (dcw.lines ?? []).find(l => /injection-aw0746/i.test(String(l.name)))
48
+ const daq = (await j('GET', '/api/workshop/daq', undefined, token)).data
49
+ const nodes = (daq.nodes ?? []).filter(n => n.lineId === line.id)
50
+ const byName = re => nodes.find(n => re.test(n.name))
51
+ // auto_daq 走 Agent 真实绑定:给 Channel 全体成员补绑定(模板实例化不自带)
52
+ const requiredIds = [byName(/part-weight/), byName(/flash-rate/), byName(/sink-mark/), byName(/melt-temp-pv/), byName(/melt-pressure-pv/)].filter(Boolean).map(n => n.id)
53
+ let bindCount = 0
54
+ for (const m of members) {
55
+ for (const nodeId of requiredIds) {
56
+ const r = await j('POST', '/api/workshop/agent-tools/bindings', { agentId: m.id, nodeId, kind: 'daq', mode: 'auto' }, token)
57
+ if (r.code === 0) bindCount += 1
58
+ }
59
+ }
60
+ console.log(` · 场景绑定注入:${bindCount} 条(成员 ${members.length} × 节点 ${requiredIds.length})`)
61
+ const controls = (dcw.nodes ?? []).filter(n => n.lineId === line.id && /hold-pressure|hold-time|mold-temp/.test(n.name))
62
+ const writesBefore = Number(dcw.controller?.writesTotal ?? 0)
63
+
64
+ // 真实场景 prompt(取自 PLC 模拟器注塑场景的工程事实)
65
+ const SCENARIO = [
66
+ '场景:PLC 模拟器注塑成型产线(真实协议栈,非抽象任务)。',
67
+ `产线 ${line.name}(${line.id}),活动批次配方三件套由 line_context 读取,不要臆造 id。`,
68
+ '质量目标:制品克重 part-weight 命中 32.5 ± 0.35 g;飞边 flash-rate ≤ 0.4%;缩痕 sink-mark ≤ 1.5%;',
69
+ '守卫:熔体温度 melt-temp-pv 235~262℃;注射压力 inj-pressure-pv 40~70 bar。',
70
+ `受控量(不可越界,单变量小步):${controls.map(c => `${c.name}(${c.id},${c.min}~${c.max}${c.unit})`).join('; ')}。`,
71
+ `观测节点:${[byName(/part-weight/), byName(/flash-rate/), byName(/sink-mark/), byName(/melt-temp/), byName(/inj-pressure/)].filter(Boolean).map(n => `${n.name}(${n.id})`).join('; ')}。`,
72
+ ].join('\n')
73
+
74
+ const TASK = [
75
+ '目标:完成一次 recommendation-only 的混合孪生 MPC 闭环优化推演,并给出可审计的推荐结论。',
76
+ '硬性要求(按序执行,每步都有据可查):',
77
+ '1) twin_scene_read 读取场景契约;若场景未就绪,先用 scene/objective 参数把当前产线与活动批次登记进去(允许使用工具自带参数)。',
78
+ '2) twin_snapshot_create 生成工况快照(必须带最新 DAQ 值)。',
79
+ '3) twin_trial_run 跑至少 1 个候选的虚拟试验(硬约束拒绝不安全候选;candidateExecuted 必须为 false)。',
80
+ '4) mpc_optimize 在受控量小步范围内搜索推荐设定;',
81
+ '5) twin_gate_evaluate 做门禁与收益评估 —— 若门禁未通过,报告 safe_small_step 结论(这是合法结果,不要伪造通过)。',
82
+ '禁止事项:任何成员不得调用 dcw_control / param_control 写真实设定(本次为 recommendation-only,写控需另行授权)。',
83
+ '交付物(中文,≤ 12 行):① 场景与批次三元组;② 快照关键值;③ 推荐设定(或 safe_small_step 原因);④ 门禁/证书结论;⑤ 一句话说明"未写真实 PLC"这一事实。',
84
+ ].join('\n')
85
+
86
+ const task = await j('POST', `/api/workshop/channels/${channelId}/tasks`, {
87
+ title: `闭环优化:注塑克重窗口 recommendation-only ${Date.now().toString(36).slice(-4)}`,
88
+ description: `${SCENARIO}\n\n${TASK}`,
89
+ assigneeId: lead?.id,
90
+ }, token)
91
+ const taskId = task.data?.id
92
+ const taskStartMs = Date.now() - 60_000
93
+ check('闭环优化任务下发到 lead(真实 omp 团队)', Boolean(taskId), `task=${String(taskId).slice(0, 8)} ${task.message ?? ''}`)
94
+
95
+ console.log(' … 等待真实 omp 团队执行(最长 20 分钟)…')
96
+ let final = null
97
+ const deadline = Date.now() + 20 * 60_000
98
+ while (Date.now() < deadline) {
99
+ await sleep(15_000)
100
+ const ts = (await j('GET', `/api/workshop/channels/${channelId}/tasks`, undefined, token)).data ?? []
101
+ const t = ts.find(x => x.id === taskId)
102
+ process.stdout.write(`\r state=${t?.state ?? '?'} progress=${t?.progress ?? 0}% children=${ts.filter(x => x.parentId === taskId).length} `)
103
+ if (t && ['COMPLETED', 'FAILED', 'CANCELED'].includes(t.state)) {
104
+ final = t
105
+ break
106
+ }
107
+ }
108
+ console.log('')
109
+ check('任务到达终态', Boolean(final), `state=${final?.state ?? 'timeout'}`)
110
+ check('任务 COMPLETED(闭环推演完成)', final?.state === 'COMPLETED', `state=${final?.state}`)
111
+
112
+ const arts = (final?.artifacts ?? []).map(a => (a.parts ?? []).map(p => p.text ?? '').join('\n')).join('\n---\n')
113
+ console.log('\n交付物:\n' + (arts || '(空)').slice(0, 1400))
114
+
115
+ // 工具证据必须落在**磁盘工件**上,而不是频道事件里 grep 工具名:
116
+ // ① 事件流不记 Agent 的工具调用(实测命中为空,旧断言属于误判);
117
+ // ② 交付物里的 snapshot_id/trial_id 可能是模型幻觉,只有工件文件才是硬证据。
118
+ const twinsRoot = `${HOME}/aml/twins`
119
+ const since = taskStartMs
120
+ const newer = (dir) => {
121
+ const p = join(twinsRoot, dir)
122
+ if (!existsSync(p)) return []
123
+ return readdirSync(p, { withFileTypes: true })
124
+ .filter(e => e.isDirectory() && statSync(join(p, e.name)).mtimeMs >= since)
125
+ .map(e => join(p, e.name))
126
+ }
127
+ const snapDirs = newer('snapshots')
128
+ const trialDirs = newer('trials')
129
+ const mpcDirs = newer('mpc')
130
+ check('孪生/MPC 工具链被真实调用(磁盘工件留痕)', snapDirs.length >= 1 && trialDirs.length >= 1, `快照=${snapDirs.length} 试验=${trialDirs.length} MPC=${mpcDirs.length}(${twinsRoot})`)
131
+
132
+ const trialArtifacts = trialDirs.map((d) => {
133
+ try {
134
+ return JSON.parse(readFileSync(join(d, 'trials.json'), 'utf8'))
135
+ }
136
+ catch {
137
+ return null
138
+ }
139
+ }).filter(Boolean)
140
+ const trialsReal = trialArtifacts.map(a => a.trial).filter(Boolean)
141
+ check('虚拟试验恒为 candidateExecuted=false(recommendation-only)', trialsReal.length >= 1 && trialsReal.every(t => t.candidateExecuted === false), `试验数=${trialsReal.length} candidateExecuted=${[...new Set(trialsReal.map(t => t.candidateExecuted))].join(',')}`)
142
+ const snapIds = snapDirs.map((d) => {
143
+ try {
144
+ return JSON.parse(readFileSync(join(d, 'snapshots.json'), 'utf8'))
145
+ }
146
+ catch {
147
+ return null
148
+ }
149
+ }).filter(Boolean)
150
+ check('TwinSnapshot 为真实数据(fresh=true 且水位>0)', snapIds.length >= 1 && snapIds.every(s2 => s2.dataQuality?.fresh === true && s2.daqWatermark > 0), snapIds.map(s2 => `${s2.snapshotId} fresh=${s2.dataQuality?.fresh}`).join('; '))
151
+ const certs = trialArtifacts.filter(a => a.certificate).length
152
+ console.log(` · 工件明细:快照 ${snapIds.length} 个 / 试验 ${trialsReal.length} 个 / 推荐证书 ${certs} 张`)
153
+
154
+ const dcwAfter = (await j('GET', '/api/workshop/dcw', undefined, token)).data
155
+ const writesAfter = Number(dcwAfter.controller?.writesTotal ?? 0)
156
+ check('全程零真实 DCW 写入(recommendation-only 生效)', writesAfter === writesBefore, `writes ${writesBefore} → ${writesAfter}`)
157
+
158
+ console.log(`\n★ 阶段 5:${pass} 通过 / ${fails.length} 失败${fails.length ? ` (${fails.join('; ')})` : ''}`)
159
+ console.log(JSON.stringify({ channelId, taskId, state: final?.state }, null, 1))