agentworkshop 0.7.37 → 0.7.38

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 (59) 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/44b66249-54d0-4c8a-9057-9755a32329e2.json +1 -0
  4. package/.output/server/chunks/_/nitro.mjs +798 -799
  5. package/.output/server/chunks/_/nitro.mjs.map +1 -1
  6. package/.output/server/package.json +1 -1
  7. package/README-zh.md +2 -2
  8. package/README.md +2 -2
  9. package/docs/cli.en.md +2 -2
  10. package/docs/cli.md +2 -2
  11. package/docs/full-test-plan.md +1 -1
  12. package/docs/plugins.en.md +1 -1
  13. package/docs/plugins.md +1 -1
  14. package/docs/sdk.en.md +1 -1
  15. package/docs/sdk.md +1 -1
  16. package/package.json +1 -1
  17. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.continue.json +1 -0
  18. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.json +1 -0
  19. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/054bb4bc4e4217a2ab0b0c6b663747214a9af464f5568ea60e784704e340bb0f.source +153 -0
  20. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/0554197c3d2bac203a1f5c48a5d52495d1336db1bd7aa4f4c19875810363e684.source +153 -0
  21. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/05c203d12a954fffba942e1d626e3f5fec05b18a5aef2bac003b7884a4b77406.source +431 -0
  22. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/145a6e6ba811af35aed31e0cc3211f53706a14980fa069ee9e3c1591139f96f0.source +180 -0
  23. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/1ae0fb281f97899c025e368ca73f41349b01fb5f85b19e08d7ebccff49095ed5.source +112 -0
  24. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/20ee9967aa0541c516db7575bdd4a1a745939bc7016bd10d7635903113edb8ec.source +227 -0
  25. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/233751843aeba9d4c5263f275b8102de00443c06f286918cbd23e0b6729e01aa.source +131 -0
  26. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/263c2e05b425e5c322e60c6475279f963bab3a873eb521b21df7fec935a46597.source +160 -0
  27. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/2ac5eaac28eaaee2178c4b32014df8e732f9dd432a285a872f5b7f31c1395fa4.source +157 -0
  28. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/33a0db0d2d95d2c11d0720090458e5fba1425376f5ceb5b422d1d3471be3226b.source +348 -0
  29. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/359e474ae83db35e4615d3728de6bf694b64d4dce9b536730a3b6518f9c0494a.source +296 -0
  30. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/37a4c3c31c4b70f6b719c53d1e9c4fcba603f31adfdd11bb6ef9f0d094923c56.source +285 -0
  31. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/461feca1c14a695eb8b0f17a6f95fe97eeceabc0510faaea4ba6d25bde147d8d.source +217 -0
  32. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/4cfadbda8591f29d5e1e230dc000e701b756e850441b3900fb596a4d2f6e98c2.source +301 -0
  33. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/50ca2810cf0b248c96948994c0c404531bbad6e553166b6816b3b03bfa0f75d2.source +151 -0
  34. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/52d9e8cb591a9ae86e238c3620a610cd386717bee222c0f37b25b2ac7e523dc4.source +135 -0
  35. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/6c87daafa4c006560a954636a97333f6123153c9d34d96203f7ee3a14e556357.source +156 -0
  36. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/77bc47854b577900621cacf1697f81fa2bc005af129d37614a97a3441c08f99f.source +72 -0
  37. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/7bbb789d3a527817118cf8399ed1861d6a962ade10878fd07223435d333a7089.source +469 -0
  38. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/86b7c1daafc8b60ba9982dde5bf1bcc74265709483c176e71f23ec6fc267451f.source +259 -0
  39. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/8df66d62bd41984d0970e8236882080c5085ac937d07a29779463d6a03b37c76.source +97 -0
  40. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/9962f2d1b3a85137bd40cc835725600b39f89ee02a27c06b2e0402e1eafde115.source +277 -0
  41. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/a1090ea066b792cb201739f4994e37bf7c12f52e805239a1eab87f18a537f266.source +171 -0
  42. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/a5241091650134d0919897e086a606a3846c8b2b54f8b4c84a8696c145ac3e9e.source +425 -0
  43. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/af19fa4c6e34d5540028be4b17600161283af3a2a2dfc61a3fdc0834436a0c14.source +99 -0
  44. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/b3713d78e2b2f93905ff6be8d58a8d0a2f42dea322eb67c987fda22ff6a084d5.source +325 -0
  45. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/b3849943c3aff102d1af815631fc843a2a52c17646a4b04696ece323e483bbec.source +332 -0
  46. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/bac735a3803a6650b7f1eb8f587e6dc5c68ea055000c3c99a11789438b78e837.source +43 -0
  47. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/d32c24d61eb8b33c73e5a8fc7e4dc7ef437aed238ee4371c2b00617e6104090a.source +154 -0
  48. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/d5170dc9a9879fda04fdf91d8a110d4efb6787ee38dda596d4da899883d36a72.source +531 -0
  49. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/dc59bcd7bb397be63ba2f2c3fec23f3782dcb17d2d75a9981eeced761f33eb32.source +439 -0
  50. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/eb5ff8cf66b92aec7bf6726781d8df74faa2b030c3aaac17d6b00c1fc04c9b32.source +323 -0
  51. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/f10615a6fae18436e70d1201b2d44fdae3174a3aa7bb4db97dea1b3ed291d721.source +433 -0
  52. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/f36d3e271668bc1260c2823542d22dbf1d0f7041ff16b1ac175bce2256e582ca.source +280 -0
  53. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/f94449380af09f31ac352152007146a75cc80bae8d6411ffe57b2c56f933b0ff.source +117 -0
  54. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/fe4dfd81178fd6b648335f6405d9ede71319aebde796008e50db21f8adf44c99.source +160 -0
  55. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/ffb4e3aa548cfdadabaa44ab599f9b6a3ca6288176a2cc7a4b2fc2a12b5fb848.source +101 -0
  56. package/scripts/test-plugin-hardening.mjs +32 -0
  57. package/server/plugins/dev-stability-guard.ts +40 -63
  58. package/server/utils/stability-severity.ts +66 -0
  59. package/.output/public/_nuxt/builds/meta/a66fdef7-dcfd-453b-b1f6-543c7dfd98d5.json +0 -1
@@ -0,0 +1,101 @@
1
+ /**
2
+ * 动态记忆感知测试:按需抓取(recallRows scope 过滤/原文还原/touch)、
3
+ * 主动沉淀(save 私有/共享分流 + 共享域 dedupKey 命名空间)、
4
+ * 记忆引子(小预算 primer + 工具提示行)。
5
+ * 运行: npx tsx scripts/test-memory-dynamic.ts
6
+ */
7
+ import type { DatabaseSync } from 'node:sqlite'
8
+ import { openWorkshopDb } from '../server/services/workshop/db/database'
9
+ import { createMemoryRepo, TEAM_AGENT_ID } from '../server/services/workshop/db/memory.repo'
10
+ import { AgentMemory } from '../server/services/workshop/runtime/memory'
11
+
12
+ let failures = 0
13
+ function check(name: string, ok: boolean, detail = ''): void {
14
+ console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`)
15
+ if (!ok) failures += 1
16
+ }
17
+
18
+ const db: DatabaseSync = openWorkshopDb(':memory:')
19
+ const repo = createMemoryRepo(db)
20
+ const seg = (s: string): string => s.replace(/([\u4e00-\u9fff])/g, ' $1 ')
21
+
22
+ // 播种:a1 私有 × 2、a2 私有 × 1、team 公共 × 1、他 channel 的 team 行 × 1(隔离守卫)
23
+ repo.upsert({ channelId: 'ch1', agentId: 'a1', kind: 'episodic-task', title: '网关限流方案', titleFts: seg('网关限流方案'), content: seg('最终采用令牌桶算法限流,阈值100qps'), importance: 0.8, taskId: 't1', dedupKey: 'task:t1' })
24
+ repo.upsert({ channelId: 'ch1', agentId: 'a1', kind: 'episodic-task', title: '部署脚本草稿', titleFts: seg('部署脚本草稿'), content: seg('deploy.sh 需要先source env'), importance: 0.4, taskId: 't2', dedupKey: 'task:t2' })
25
+ repo.upsert({ channelId: 'ch1', agentId: 'a2', kind: 'episodic-task', title: '他人限流笔记', titleFts: seg('他人限流笔记'), content: seg('a2自己的限流杂记'), importance: 0.8, taskId: 't3', dedupKey: 'task:t3' })
26
+ repo.upsert({ channelId: 'ch1', agentId: TEAM_AGENT_ID, kind: 'semantic', title: 'channel 限流约定', titleFts: seg('channel 限流约定'), content: seg('全channel统一用令牌桶,禁用计数器'), importance: 0.9, taskId: null, dedupKey: 'conv:ratelimit' })
27
+ repo.upsert({ channelId: 'ch2', agentId: TEAM_AGENT_ID, kind: 'semantic', title: '他channel限流约定', titleFts: seg('他channel限流约定'), content: seg('ch2专属公共记忆不应被ch1看到'), importance: 0.9, taskId: null, dedupKey: 'conv:other' })
28
+
29
+ const memA1 = new AgentMemory(repo, { channelId: 'ch1', agentId: 'a1' })
30
+
31
+ console.log('--- 按需抓取 recallRows ---')
32
+ {
33
+ const auto = await memA1.recallRows('限流 令牌桶')
34
+ check('auto 域命中本人 + 公共记忆', auto.some(s => s.title === '网关限流方案') && auto.some(s => s.title === 'channel 限流约定'), JSON.stringify(auto.map(s => s.title)))
35
+ check('auto 域不含他人私有记忆', auto.every(s => s.title !== '他人限流笔记'))
36
+ check('auto 域不含他 channel 公共记忆', auto.every(s => s.title !== '他channel限流约定'))
37
+ check('source 标注 private/shared', auto.some(s => s.source === 'private') && auto.some(s => s.source === 'shared'))
38
+ const own = auto.find(s => s.title === '网关限流方案')
39
+ check('content 还原为未切分原文', own !== undefined && !/\s/.test(own.content.slice(0, 4)) && own.content.includes('令牌桶'), own?.content.slice(0, 20))
40
+ check('返回综合分 score', auto.every(s => typeof s.score === 'number' && s.score > 0))
41
+
42
+ const sharedOnly = await memA1.recallRows('限流', { scope: 'shared' })
43
+ check('shared 域仅公共记忆', sharedOnly.length > 0 && sharedOnly.every(s => s.source === 'shared'), JSON.stringify(sharedOnly.map(s => s.title)))
44
+ check('shared 域严格本 channel', sharedOnly.every(s => s.title !== '他channel限流约定'))
45
+
46
+ const privOnly = await memA1.recallRows('限流', { scope: 'private' })
47
+ check('private 域仅本人记忆', privOnly.length > 0 && privOnly.every(s => s.source === 'private'), JSON.stringify(privOnly.map(s => s.title)))
48
+
49
+ const before = repo.listByAgent('a1', 10).find(r => r.title === '网关限流方案')!.accessCount
50
+ await memA1.recallRows('网关 限流')
51
+ const after = repo.listByAgent('a1', 10).find(r => r.title === '网关限流方案')!.accessCount
52
+ check('recallRows 触发 touch(强化后续排序)', after === before + 1, `before=${before} after=${after}`)
53
+
54
+ // scope 域内完全无记忆才真正返回空(全新 channel;有记忆时弱命中/时近兜底会返回行,by design)
55
+ const none = await new AgentMemory(repo, { channelId: 'ch-empty', agentId: 'nobody' }).recallRows('完全不存在的关键词xyzq')
56
+ check('无命中返回空数组(无记忆 agent)', Array.isArray(none) && none.length === 0)
57
+ const fallback = await memA1.recallRows('完全不存在的关键词xyzq')
58
+ check('FTS 无命中走时近兜底(有记忆 agent 返回最近行)', fallback.length > 0, JSON.stringify(fallback.map(s => s.title)))
59
+ }
60
+
61
+ console.log('\n--- 主动沉淀 save(私有/共享分流)---')
62
+ {
63
+ const priv = await memA1.save({ title: 'SQLite 并发写坑', content: '并发写会 SQLITE_BUSY,须串行化写入', scope: 'private', dedupKey: 'note:sqlite' })
64
+ check('私有沉淀落入本人域', repo.findByAgentDedup('ch1', 'a1', 'note:sqlite') !== null && priv.scope === 'private')
65
+
66
+ const shared = await memA1.save({ title: '限流阈值结论', content: '全队统一令牌桶阈值100qps', scope: 'shared', dedupKey: 'conv:threshold' })
67
+ const teamRow = repo.findByAgentDedup('ch1', TEAM_AGENT_ID, `agent:a1:conv:threshold`)
68
+ check('共享沉淀落入 team 公共域(来源命名空间 dedupKey)', teamRow !== null && shared.dedupKey === 'agent:a1:conv:threshold')
69
+
70
+ // 同 raw key 不同 agent:命名空间隔离互不覆盖
71
+ const memA2 = new AgentMemory(repo, { channelId: 'ch1', agentId: 'a2' })
72
+ await memA2.save({ title: 'a2 的阈值结论', content: 'a2认为阈值应按服务分级', scope: 'shared', dedupKey: 'conv:threshold' })
73
+ const teamRows = repo.listByAgentChannel('ch1', TEAM_AGENT_ID, 100).filter(r => r.content.includes('阈值') || r.title.includes('阈值'))
74
+ check('多 agent 同 raw key 共享沉淀互不覆盖', repo.findByAgentDedup('ch1', TEAM_AGENT_ID, 'agent:a1:conv:threshold') !== null && repo.findByAgentDedup('ch1', TEAM_AGENT_ID, 'agent:a2:conv:threshold') !== null, `rows=${teamRows.length}`)
75
+
76
+ // 共享沉淀即时对全员可检索
77
+ const a2sees = await memA2.recallRows('阈值 令牌桶', { scope: 'shared' })
78
+ check('共享沉淀对其他 agent 即时可检索', a2sees.some(s => s.title === '限流阈值结论'), JSON.stringify(a2sees.map(s => s.title)))
79
+
80
+ // 稳定 dedupKey 幂等刷新不重复
81
+ await memA1.save({ title: '限流阈值结论', content: '修订:阈值升到200qps', scope: 'shared', dedupKey: 'conv:threshold' })
82
+ const count = repo.listByAgentChannel('ch1', TEAM_AGENT_ID, 100).filter(r => r.dedupKey === undefined && r.title === '限流阈值结论').length
83
+ const refreshed = repo.listByAgentChannel('ch1', TEAM_AGENT_ID, 100).some(r => r.title === '限流阈值结论')
84
+ check('稳定 dedupKey 幂等刷新', refreshed && count <= 1)
85
+ }
86
+
87
+ console.log('\n--- 记忆引子 primer(小预算注入)---')
88
+ {
89
+ const block = await memA1.recall('限流 令牌桶 部署', { touch: false })
90
+ check('primer 返回记忆块', block !== null)
91
+ check('primer 含按需抓取工具提示', block !== null && block.includes('search_memory'), block?.slice(-80))
92
+ // v2 三层注入:L0 简报(save(shared) 后自动存在)+ L1 引子 + 提示,同在 300 tok 引子预算 + 500 tok 总预算内
93
+ check('primer 含 L0 会话简报', block !== null && block.includes('会话简报'))
94
+ check('primer 小预算(行数受限)', block !== null && block.split('\n').length < 16, `lines=${block?.split('\n').length}`)
95
+
96
+ const memTiny = new AgentMemory(repo, { channelId: 'ch1', agentId: 'a1', budgetTokens: 5 })
97
+ check('极小预算 primer 无行则 null', (await memTiny.recall('限流')) === null)
98
+ }
99
+
100
+ console.log(failures === 0 ? '\nALL PASS' : `\n${failures} FAILED`)
101
+ process.exit(failures === 0 ? 0 : 1)
@@ -46,5 +46,37 @@ console.log('\n━━━ 2. 路由表按插件卸下(P0-1 回滚所需) ━━
46
46
  check('unregisterPlugin 幂等(再调返回 0)', t.unregisterPlugin('alpha') === 0)
47
47
  }
48
48
 
49
+ console.log('\n━━━ 3. 稳定性护栏的严重性判定(哪类异常不该退进程) ━━━')
50
+ {
51
+ const { classifyFatalCandidate } = await import('../server/utils/stability-severity.ts')
52
+ const err = (code, message = 'boom') => Object.assign(new Error(message), { code })
53
+
54
+ // 外部设备不可达 → 不退进程。实测事故:一台 Modbus TCP 设备未启动
55
+ // (connect ECONNREFUSED 127.0.0.1:1502)使整个生产实例 exit 1。
56
+ check('ECONNREFUSED(设备未启动)→ upstream', classifyFatalCandidate(err('ECONNREFUSED')) === 'upstream')
57
+ check('ENOTFOUND(DNS 解析失败)→ upstream', classifyFatalCandidate(err('ENOTFOUND')) === 'upstream')
58
+ check('EHOSTUNREACH → upstream', classifyFatalCandidate(err('EHOSTUNREACH')) === 'upstream')
59
+ check('ENETUNREACH → upstream', classifyFatalCandidate(err('ENETUNREACH')) === 'upstream')
60
+ check('undici UND_ERR_CONNECT_TIMEOUT → upstream', classifyFatalCandidate(err('UND_ERR_CONNECT_TIMEOUT')) === 'upstream')
61
+ check('无 code 但 message 带 ECONNREFUSED → upstream',
62
+ classifyFatalCandidate(new Error('connect ECONNREFUSED 127.0.0.1:1502')) === 'upstream')
63
+
64
+ // 其余既有分级不得回退
65
+ check('ECONNRESET(客户端硬断)→ socket', classifyFatalCandidate(err('ECONNRESET')) === 'socket')
66
+ check('EPIPE → socket', classifyFatalCandidate(err('EPIPE')) === 'socket')
67
+ check('SQLITE_BUSY → db-busy', classifyFatalCandidate(err('SQLITE_BUSY')) === 'db-busy')
68
+ check('database is locked(无 code)→ db-busy', classifyFatalCandidate(new Error('database is locked')) === 'db-busy')
69
+ check('omp API 4xx → engine', classifyFatalCandidate(new Error('omp API 500: upstream failed')) === 'engine')
70
+
71
+ // fail-fast 语义必须保住:真实缺陷仍要退进程
72
+ check('密钥校验失败 → fatal(安全插件 fail-fast 依赖)', classifyFatalCandidate(new Error('生产环境必须设置 NUXT_SESSION_PASSWORD')) === 'fatal')
73
+ check('任意未分类异常 → fatal', classifyFatalCandidate(new Error('unexpected token in JSON at position 0')) === 'fatal')
74
+ check('null → fatal(不放过未知形态)', classifyFatalCandidate(null) === 'fatal')
75
+ check('非 Error 值(字符串)→ fatal', classifyFatalCandidate('something odd') === 'fatal')
76
+ // 引擎名只允许出现在首行:stack 里偶然含引擎名不得把真实错误降级
77
+ check('引擎名仅在 stack 中 → 不降级',
78
+ classifyFatalCandidate(Object.assign(new Error('TypeError: x is not a function'), { stack: 'TypeError: x is not a function\n at omp API (x.ts:1)' })) === 'fatal')
79
+ }
80
+
49
81
  console.log(`\n━━━ 结果:${pass} passed, ${fail} failed ━━━\n`)
50
82
  process.exit(fail === 0 ? 0 : 1)
@@ -1,63 +1,40 @@
1
- /**
2
- * Server 稳定性护栏:浏览器断开(WS/keep-alive socket 硬断)产生 ECONNRESET/EPIPE
3
- * 类异步异常 —— Node 默认策略直接击穿进程,拖垮所有并发调试/运行会话。
4
- *
5
- * 严重性过滤(关键设计):只抑制 socket 级断连噪声;其余异常保留致命语义
6
- * (记日志后按非零码退出)—— 安全插件的 fail-fast(如生产密钥校验失败拒绝启动)
7
- * 依赖异常中止进程,绝不能被护栏吞掉。
8
- */
9
- const SOCKET_NOISE = /ECONNRESET|EPIPE|ECONNABORTED|ETIMEDOUT|ERR_STREAM_PREMATURE_CLOSE|ERR_STREAM_WRITE_AFTER_END/
10
- // SQLite 忙/锁(hardening ST-1):多写竞争的瞬态错误,记录并继续;
11
- // 真正的库损坏/磁盘错误不经此路径,仍走致命语义。
12
- const SQLITE_BUSY = /SQLITE_BUSY|SQLITE_LOCKED|database is locked/i
13
- // Agent 引擎边界错误(harness 容错):opencode/omp/codex/dsh 等外部引擎 RPC 失败
14
- // (HTTP 4xx/5xx、连接拒绝)只影响对应 agent 会话,应降级重试而非退进程 ——
15
- // 服务端同时承载 DAQ 数采/DCW 写控,引擎故障带走监控平台属于可用性事故。
16
- const ENGINE_BOUNDARY = /\b(opencode|omp|codex|dsh) API\b/i
17
-
18
- function isSocketNoise(reason: unknown): boolean {
19
- if (reason == null) return false
20
- const err = reason as NodeJS.ErrnoException
21
- // code 命中即噪声(经典 errno 形态)
22
- if (typeof err.code === 'string' && SOCKET_NOISE.test(err.code)) return true
23
- // undici 等包装错误:code 可能是 UND_ERR_*/undefined,但 message/stack 仍带
24
- // "read ECONNRESET" 文本 —— 全文兜底再测一次(四引擎并发下 keep-alive socket
25
- // 复位是高频瞬态,不可判死进程)
26
- return SOCKET_NOISE.test(describe(reason))
27
- }
28
-
29
- function describe(reason: unknown): string {
30
- return reason instanceof Error
31
- ? `${reason.message}\n${reason.stack?.slice(0, 600) ?? ''}`
32
- : String(reason)
33
- }
34
-
35
- export default defineNitroPlugin(() => {
36
- const isTransientDbBusy = (reason: unknown): boolean => SQLITE_BUSY.test(describe(reason))
37
- const isEngineBoundary = (reason: unknown): boolean => ENGINE_BOUNDARY.test(describe(reason).split('\n')[0] ?? '')
38
- let engineFaults = 0
39
- const onFatalCandidate = (kind: string, reason: unknown): void => {
40
- if (isSocketNoise(reason)) {
41
- console.warn(`[stability-guard] socket noise suppressed: ${describe(reason).slice(0, 120)}`)
42
- return
43
- }
44
- if (isTransientDbBusy(reason)) {
45
- console.warn(`[stability-guard] sqlite busy(瞬态,不退出): ${describe(reason).slice(0, 120)}`)
46
- return
47
- }
48
- if (isEngineBoundary(reason)) {
49
- engineFaults += 1
50
- console.error(`[stability-guard] 引擎边界错误(累计 ${engineFaults},不退出): ${describe(reason).slice(0, 400)}`)
51
- return
52
- }
53
- console.error(`[stability-guard] fatal ${kind}, exiting:\n${describe(reason)}`)
54
- process.exit(1)
55
- }
56
- process.on('unhandledRejection', (reason) => {
57
- onFatalCandidate('unhandledRejection', reason)
58
- })
59
- process.on('uncaughtException', (err) => {
60
- onFatalCandidate('uncaughtException', err)
61
- })
62
- console.warn('[stability-guard] armed (socket 断连/sqlite 瞬态忙/引擎边界错误 → 记录继续;其余真实错误 → exit 1)')
63
- })
1
+ /**
2
+ * Server 稳定性护栏:浏览器断开(WS/keep-alive socket 硬断)产生 ECONNRESET/EPIPE
3
+ * 类异步异常 —— Node 默认策略直接击穿进程,拖垮所有并发调试/运行会话。
4
+ *
5
+ * 严重性判定集中在 `server/utils/stability-severity.ts`(可被纯 node 回归测试直接断言):
6
+ * socket 断连 / SQLite 瞬态忙 / 外部设备不可达 / Agent 引擎边界 → 记录并继续;
7
+ * 其余判为 fatal,记日志后按非零码退出 —— 安全插件的 fail-fast(如生产密钥校验
8
+ * 失败拒绝启动)依赖异常中止进程,绝不能被护栏吞掉。
9
+ *
10
+ * "外部设备不可达"是**运维常态**(设备下电、模拟器未启、容器重启):
11
+ * 曾实测到一台 Modbus TCP 设备未启动(`connect ECONNREFUSED 127.0.0.1:1502`)
12
+ * 使 unhandledRejection 走到 fatal 分支,整个生产实例 exit 1 ——
13
+ * 数采/数控/数字孪生全平台被一台设备带走,属可用性事故。
14
+ */
15
+ import { classifyFatalCandidate, describeReason } from '../utils/stability-severity'
16
+
17
+ export default defineNitroPlugin(() => {
18
+ // 'db-busy' 必须引号 → 按 @stylistic/quote-props 的 consistent 口径,同对象内全部加引号
19
+ const counts: Record<string, number> = { 'socket': 0, 'db-busy': 0, 'upstream': 0, 'engine': 0 }
20
+ const onFatalCandidate = (kind: string, reason: unknown): void => {
21
+ const severity = classifyFatalCandidate(reason)
22
+ if (severity === 'fatal') {
23
+ console.error(`[stability-guard] fatal ${kind}, exiting:\n${describeReason(reason)}`)
24
+ process.exit(1)
25
+ }
26
+ counts[severity] += 1
27
+ const line = `[stability-guard] ${severity}(累计 ${counts[severity]},不退出): ${describeReason(reason).slice(0, 200).replace(/\n/g, ' ')}`
28
+ // 外部设备不可达与引擎边界属"要有人知道"的信号 → error;其余瞬态噪声 → warn
29
+ if (severity === 'upstream' || severity === 'engine') console.error(line)
30
+ else console.warn(line)
31
+ }
32
+
33
+ process.on('unhandledRejection', (reason) => {
34
+ onFatalCandidate('unhandledRejection', reason)
35
+ })
36
+ process.on('uncaughtException', (err) => {
37
+ onFatalCandidate('uncaughtException', err)
38
+ })
39
+ console.warn('[stability-guard] armed (socket 断连/sqlite 瞬态忙/外部设备不可达/引擎边界错误 → 记录继续;其余真实错误 → exit 1)')
40
+ })
@@ -0,0 +1,66 @@
1
+ /**
2
+ * 致命异常严重性判定(稳定性护栏的唯一判据来源)。
3
+ *
4
+ * 为什么独立成文件:判定逻辑必须可被 **纯 node 回归测试** 直接加载断言。
5
+ * 放在 `server/plugins/dev-stability-guard.ts` 里就只能靠起真实服务观察,
6
+ * 而"哪类异常该退进程"恰恰是最不该靠观察来确认的一类逻辑。
7
+ *
8
+ * 四级严重性,只有 `fatal` 会退进程:
9
+ *
10
+ * 1. `socket` —— 浏览器/客户端硬断(WS、keep-alive)。最高频的瞬态噪声,
11
+ * Node 默认策略会直接击穿进程,从而拖垮所有并发会话。
12
+ * 2. `db-busy` —— SQLite 忙/锁。多写竞争的瞬态错误,应重试而非退出。
13
+ * 3. `upstream` —— **外部设备/上游服务不可达**:ECONNREFUSED / ENOTFOUND /
14
+ * EHOSTUNREACH / ENETUNREACH / 连接超时。
15
+ * 这是运维常态(设备下电、模拟器未启、容器重启),
16
+ * 绝不能让"某一台设备没开"带走整个监控平台 ——
17
+ * DAQ 驱动本身把这类失败记为节点 lastError 并继续轮询,
18
+ * 护栏再补一刀 exit 1 就构成可用性事故。
19
+ * ⚠️ 与"连上了但业务失败"区分:后者不在此列,仍走 fatal。
20
+ * 4. `engine` —— Agent 引擎边界错误(opencode/omp/codex/dsh 的 RPC 失败)。
21
+ * 只影响对应 agent 会话,服务端同时承载数采/数控,不可退进程。
22
+ *
23
+ * 其余一律 `fatal`:安全插件的 fail-fast(如生产密钥校验失败拒绝启动)依赖异常
24
+ * 中止进程,绝不能被护栏吞掉。
25
+ */
26
+
27
+ /** socket 级断连噪声 */
28
+ export const SOCKET_NOISE = /ECONNRESET|EPIPE|ECONNABORTED|ETIMEDOUT|ERR_STREAM_PREMATURE_CLOSE|ERR_STREAM_WRITE_AFTER_END/
29
+
30
+ /** 外部设备/上游不可达(运维常态,非程序缺陷) */
31
+ export const UPSTREAM_UNREACHABLE = /ECONNREFUSED|ENOTFOUND|EHOSTUNREACH|ENETUNREACH|ERR_SOCKET_CONNECTION_TIMEOUT|UND_ERR_CONNECT_TIMEOUT/
32
+
33
+ /** SQLite 忙/锁(hardening ST-1) */
34
+ export const SQLITE_BUSY = /SQLITE_BUSY|SQLITE_LOCKED|database is locked/i
35
+
36
+ /** Agent 引擎边界错误(harness 容错) */
37
+ export const ENGINE_BOUNDARY = /\b(opencode|omp|codex|dsh) API\b/i
38
+
39
+ export type FatalSeverity = 'socket' | 'db-busy' | 'upstream' | 'engine' | 'fatal'
40
+
41
+ /** 错误→可读文本;Error 取 message + 截断 stack,其余 String() */
42
+ export function describeReason(reason: unknown): string {
43
+ return reason instanceof Error
44
+ ? `${reason.message}\n${reason.stack?.slice(0, 600) ?? ''}`
45
+ : String(reason)
46
+ }
47
+
48
+ /**
49
+ * 判定一次"致命候选"的真实严重性。
50
+ * 命中顺序即优先级:code 字段优先(经典 errno),再全文兜底
51
+ * (undici 等包装错误 code 可能是 UND_ERR_*,但 message/stack 仍带原文)。
52
+ */
53
+ export function classifyFatalCandidate(reason: unknown): FatalSeverity {
54
+ if (reason == null) return 'fatal'
55
+ const err = reason as NodeJS.ErrnoException
56
+ const code = typeof err.code === 'string' ? err.code : ''
57
+ const text = `${code}\n${describeReason(reason)}`
58
+
59
+ if (SOCKET_NOISE.test(text)) return 'socket'
60
+ if (SQLITE_BUSY.test(text)) return 'db-busy'
61
+ if (UPSTREAM_UNREACHABLE.test(text)) return 'upstream'
62
+ // 引擎边界只看首行:避免 stack 里偶然出现的引擎名把真实错误降级
63
+ const head = describeReason(reason).split('\n')[0] ?? ''
64
+ if (ENGINE_BOUNDARY.test(head)) return 'engine'
65
+ return 'fatal'
66
+ }
@@ -1 +0,0 @@
1
- {"id":"a66fdef7-dcfd-453b-b1f6-543c7dfd98d5","timestamp":1789185561795,"prerendered":[]}