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.
- package/.output/nitro.json +1 -1
- package/.output/public/_nuxt/builds/latest.json +1 -1
- package/.output/public/_nuxt/builds/meta/44b66249-54d0-4c8a-9057-9755a32329e2.json +1 -0
- package/.output/server/chunks/_/nitro.mjs +798 -799
- package/.output/server/chunks/_/nitro.mjs.map +1 -1
- package/.output/server/package.json +1 -1
- package/README-zh.md +2 -2
- package/README.md +2 -2
- package/docs/cli.en.md +2 -2
- package/docs/cli.md +2 -2
- package/docs/full-test-plan.md +1 -1
- package/docs/plugins.en.md +1 -1
- package/docs/plugins.md +1 -1
- package/docs/sdk.en.md +1 -1
- package/docs/sdk.md +1 -1
- package/package.json +1 -1
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.continue.json +1 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.json +1 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/054bb4bc4e4217a2ab0b0c6b663747214a9af464f5568ea60e784704e340bb0f.source +153 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/0554197c3d2bac203a1f5c48a5d52495d1336db1bd7aa4f4c19875810363e684.source +153 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/05c203d12a954fffba942e1d626e3f5fec05b18a5aef2bac003b7884a4b77406.source +431 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/145a6e6ba811af35aed31e0cc3211f53706a14980fa069ee9e3c1591139f96f0.source +180 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/1ae0fb281f97899c025e368ca73f41349b01fb5f85b19e08d7ebccff49095ed5.source +112 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/20ee9967aa0541c516db7575bdd4a1a745939bc7016bd10d7635903113edb8ec.source +227 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/233751843aeba9d4c5263f275b8102de00443c06f286918cbd23e0b6729e01aa.source +131 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/263c2e05b425e5c322e60c6475279f963bab3a873eb521b21df7fec935a46597.source +160 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/2ac5eaac28eaaee2178c4b32014df8e732f9dd432a285a872f5b7f31c1395fa4.source +157 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/33a0db0d2d95d2c11d0720090458e5fba1425376f5ceb5b422d1d3471be3226b.source +348 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/359e474ae83db35e4615d3728de6bf694b64d4dce9b536730a3b6518f9c0494a.source +296 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/37a4c3c31c4b70f6b719c53d1e9c4fcba603f31adfdd11bb6ef9f0d094923c56.source +285 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/461feca1c14a695eb8b0f17a6f95fe97eeceabc0510faaea4ba6d25bde147d8d.source +217 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/4cfadbda8591f29d5e1e230dc000e701b756e850441b3900fb596a4d2f6e98c2.source +301 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/50ca2810cf0b248c96948994c0c404531bbad6e553166b6816b3b03bfa0f75d2.source +151 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/52d9e8cb591a9ae86e238c3620a610cd386717bee222c0f37b25b2ac7e523dc4.source +135 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/6c87daafa4c006560a954636a97333f6123153c9d34d96203f7ee3a14e556357.source +156 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/77bc47854b577900621cacf1697f81fa2bc005af129d37614a97a3441c08f99f.source +72 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/7bbb789d3a527817118cf8399ed1861d6a962ade10878fd07223435d333a7089.source +469 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/86b7c1daafc8b60ba9982dde5bf1bcc74265709483c176e71f23ec6fc267451f.source +259 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/8df66d62bd41984d0970e8236882080c5085ac937d07a29779463d6a03b37c76.source +97 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/9962f2d1b3a85137bd40cc835725600b39f89ee02a27c06b2e0402e1eafde115.source +277 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/a1090ea066b792cb201739f4994e37bf7c12f52e805239a1eab87f18a537f266.source +171 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/a5241091650134d0919897e086a606a3846c8b2b54f8b4c84a8696c145ac3e9e.source +425 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/af19fa4c6e34d5540028be4b17600161283af3a2a2dfc61a3fdc0834436a0c14.source +99 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/b3713d78e2b2f93905ff6be8d58a8d0a2f42dea322eb67c987fda22ff6a084d5.source +325 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/b3849943c3aff102d1af815631fc843a2a52c17646a4b04696ece323e483bbec.source +332 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/bac735a3803a6650b7f1eb8f587e6dc5c68ea055000c3c99a11789438b78e837.source +43 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/d32c24d61eb8b33c73e5a8fc7e4dc7ef437aed238ee4371c2b00617e6104090a.source +154 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/d5170dc9a9879fda04fdf91d8a110d4efb6787ee38dda596d4da899883d36a72.source +531 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/dc59bcd7bb397be63ba2f2c3fec23f3782dcb17d2d75a9981eeced761f33eb32.source +439 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/eb5ff8cf66b92aec7bf6726781d8df74faa2b030c3aaac17d6b00c1fc04c9b32.source +323 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/f10615a6fae18436e70d1201b2d44fdae3174a3aa7bb4db97dea1b3ed291d721.source +433 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/f36d3e271668bc1260c2823542d22dbf1d0f7041ff16b1ac175bce2256e582ca.source +280 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/f94449380af09f31ac352152007146a75cc80bae8d6411ffe57b2c56f933b0ff.source +117 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/fe4dfd81178fd6b648335f6405d9ede71319aebde796008e50db21f8adf44c99.source +160 -0
- package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/ffb4e3aa548cfdadabaa44ab599f9b6a3ca6288176a2cc7a4b2fc2a12b5fb848.source +101 -0
- package/scripts/test-plugin-hardening.mjs +32 -0
- package/server/plugins/dev-stability-guard.ts +40 -63
- package/server/utils/stability-severity.ts +66 -0
- 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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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":[]}
|