dsh-vibe-math 2.3.16 → 2.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +64 -61
- package/README.md +64 -61
- package/SECURITY.md +56 -0
- package/cordis.patch.yml +1123 -10
- package/docs/AUDIT-CHECKLIST.md +499 -384
- package/docs/COMPAT-AUDIT-ROUND2.md +3 -0
- package/docs/COMPAT-AUDIT-ROUND3.md +139 -0
- package/docs/generate_framework_diagram_v5.mjs +27 -27
- package/docs/release-notes/RELEASE-NOTES-2.4.0.md +172 -0
- package/docs/release-notes/RELEASE-NOTES-2.4.1.md +129 -0
- package/docs/test-timing.md +2 -1
- package/installer.js +89 -21
- package/package.json +36 -10
- package/preset-declaration.js +62 -0
- package/prompt-corpus-persona/persona-corpus.json +2 -2
- package/prompt-corpus-persona/persona-corpus.md +2 -2
- package/prompt-corpus-v5/prompt-corpus-v5.json +7 -7
- package/prompt-corpus-v5/prompt-corpus-v5.md +8 -129
- package/scripts/build-preset-rows.mjs +146 -0
- package/tests/audit-installer-compat.test.mjs +9 -3
- package/tests/audit-preset-rows.test.mjs +185 -0
- package/tests/audit-v5-integrity.mjs +49 -3
- package/tests/e2e-v5-round2.test.mjs +137 -67
- package/tests/formal-verify-v5.test.mjs +43 -35
- package/tests/prompt-v5-integrity.test.mjs +49 -47
- package/tests/selfdrive-v5.mjs +42 -55
- package/vibe-math-v2/agent.cordis.yml +5 -5
- package/vibe-math-v2/preset.yml +1 -1
- package/vibe-math-v2/vibe-math-v2.js +111 -64
- package/vibe-math-v3/vibe-math-v3.js +213 -150
- package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +2 -2
- package/vibe-math-v4/vibe-math-v4.js +257 -34
- package/vibe-math-v5/agent.cordis.yml +8 -8
- package/vibe-math-v5/preset.yml +1 -1
- package/vibe-math-v5/vibe-math-v5.js +292 -129
- package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +56 -44
- package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +24 -22
- package//347/244/272/344/276/213/345/233/276//346/241/206/346/236/266/345/233/276-v5-en.svg +14 -14
- package//347/244/272/344/276/213/345/233/276//346/241/206/346/236/266/345/233/276-v5.svg +14 -14
|
@@ -68,11 +68,17 @@ for (const [range, version, pre, expected] of CASES) {
|
|
|
68
68
|
'matcher: ' + JSON.stringify(range) + ' vs ' + version + (pre ? ' (includePrerelease)' : ''))
|
|
69
69
|
}
|
|
70
70
|
// our own declaration, both semantics (the exact chain the market and the installer share)
|
|
71
|
-
|
|
71
|
+
// The window is per-tuple: every supported line has its own prerelease branch, because npm's
|
|
72
|
+
// prerelease rule only admits a version when a comparator in the SAME [major,minor,patch] tuple also
|
|
73
|
+
// carries a prerelease. 0.2.0 joined the window with the DSH 0.2.0 adaptation.
|
|
74
|
+
for (const v of ['0.1.2-alpha.4', '0.1.2-rc.1', '0.1.3-alpha.2', '0.1.5-rc.1', '0.1.5-rc.2', '0.1.6-alpha.2', '0.1.7-rc.2', '0.2.0-rc.1', '0.2.0-rc.2']) {
|
|
72
75
|
eq(satisfiesDshRange(v, pkg.engines.dsh), true, 'own range admits ' + v + ' (default semantics)')
|
|
73
76
|
eq(satisfiesDshRange(v, pkg.engines.dsh, { includePrerelease: true }), true, 'own range admits ' + v + ' (includePrerelease)')
|
|
74
77
|
}
|
|
75
|
-
for (const v of ['0.
|
|
78
|
+
for (const v of ['0.2.0']) {
|
|
79
|
+
eq(satisfiesDshRange(v, pkg.engines.dsh, { includePrerelease: true }), true, 'own range admits the released ' + v)
|
|
80
|
+
}
|
|
81
|
+
for (const v of ['0.1.1-rc.1', '0.3.0-rc.1', '0.3.0', '1.0.0']) {
|
|
76
82
|
eq(satisfiesDshRange(v, pkg.engines.dsh, { includePrerelease: true }), false, 'own range rejects ' + v)
|
|
77
83
|
}
|
|
78
84
|
// unsupported forms are UNKNOWN (null) — reported, never asserted incompatible
|
|
@@ -88,7 +94,7 @@ eq(satisfiesDshRange('not-a-version', pkg.engines.dsh), null, 'an unparseable ho
|
|
|
88
94
|
const a = dshVersionVerdict('0.1.5-rc.2', pkg)
|
|
89
95
|
ok(a.status === 'compatible' && a.basis === 'engines', 'our own host line is compatible via the declared range', JSON.stringify(a))
|
|
90
96
|
eq(a.requirement, pkg.engines.dsh, 'the verdict surfaces the declared range (so the log and the market card say the same thing)')
|
|
91
|
-
const b = dshVersionVerdict('0.
|
|
97
|
+
const b = dshVersionVerdict('0.3.0', pkg)
|
|
92
98
|
ok(b.status === 'incompatible' && b.basis === 'engines', 'a host outside the declared range is incompatible via the range', JSON.stringify(b))
|
|
93
99
|
const c = dshVersionVerdict('0.1.0-rc.6', pkg)
|
|
94
100
|
ok(c.status === 'incompatible', 'a host below the declared floor is incompatible', JSON.stringify(c))
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// ============================================================================================
|
|
2
|
+
// PRESET DECLARATIONS — the bundle patch that delivers the four presets on the CURRENT DSH line.
|
|
3
|
+
//
|
|
4
|
+
// The defect class this guards (2.4.0 adaptation to DSH 0.2.0): a preset that is present in the
|
|
5
|
+
// package but invisible to the host. Three concrete ways that happened while adapting:
|
|
6
|
+
//
|
|
7
|
+
// 1. `dsh.bundle.patch` as an ARRAY. DSH >= 0.1.7 accepts a list of patch files, but 0.1.5/0.1.6
|
|
8
|
+
// pass the value straight into `path.join`, so an array aborts the whole profile boot with
|
|
9
|
+
// ERR_INVALID_ARG_TYPE. One patch file is valid on every line.
|
|
10
|
+
// 2. A declaration row naming `@deepseek-ai/dsh-agent-preset`. That package exists only from 0.1.7,
|
|
11
|
+
// so on the older line the row fails to import and is reported as an activation failure on every
|
|
12
|
+
// boot. The rows name this package's own `preset-declaration` module instead, which resolves on
|
|
13
|
+
// both lines and registers only where the `agentPresets` service exists.
|
|
14
|
+
// 3. A `disabled: !!js` gate on the declaration row. Measured on DSH 0.2.0-rc.2: an expression that
|
|
15
|
+
// CALLS `ctx.get(...)` throws inside the loader's patch evaluation, the entry never initialises,
|
|
16
|
+
// and the preset silently never registers (`dsh: warning: N entries did not activate`). Constants
|
|
17
|
+
// and `typeof ctx` work; service lookups do not — so there is no such gate anywhere.
|
|
18
|
+
//
|
|
19
|
+
// It also pins the SHARED SOURCE OF TRUTH: cordis.patch.yml is generated from the frozen 0.1.x
|
|
20
|
+
// compositions, so a hand edit to either side fails here instead of drifting.
|
|
21
|
+
//
|
|
22
|
+
// Usage: node tests/audit-preset-rows.test.mjs
|
|
23
|
+
// ============================================================================================
|
|
24
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
25
|
+
import { fileURLToPath } from 'node:url'
|
|
26
|
+
import { join, dirname, resolve } from 'node:path'
|
|
27
|
+
import { buildPatch } from '../scripts/build-preset-rows.mjs'
|
|
28
|
+
|
|
29
|
+
const HERE = fileURLToPath(new URL('./', import.meta.url))
|
|
30
|
+
const REPO = resolve(HERE, '..')
|
|
31
|
+
const pkg = JSON.parse(readFileSync(join(REPO, 'package.json'), 'utf8'))
|
|
32
|
+
const patch = readFileSync(join(REPO, 'cordis.patch.yml'), 'utf8')
|
|
33
|
+
const VERSIONS = ['v2', 'v3', 'v4', 'v5']
|
|
34
|
+
|
|
35
|
+
let passed = 0, failed = 0
|
|
36
|
+
const failures = []
|
|
37
|
+
function ok(cond, label, detail) {
|
|
38
|
+
if (cond) { passed++; console.log(' ok ' + label); return true }
|
|
39
|
+
failed++; failures.push(label + (detail ? ' — ' + detail : ''))
|
|
40
|
+
console.error(' FAIL ' + label + (detail ? ' — ' + detail : ''))
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
const at = (rel) => join(REPO, rel)
|
|
44
|
+
|
|
45
|
+
// ---- 1. the generated patch is in sync with its generator ------------------------------------
|
|
46
|
+
ok(buildPatch() === patch, 'cordis.patch.yml is exactly what scripts/build-preset-rows.mjs generates',
|
|
47
|
+
'hand-edited? regenerate with: node scripts/build-preset-rows.mjs')
|
|
48
|
+
|
|
49
|
+
// ---- 2. bundle patch declaration --------------------------------------------------------------
|
|
50
|
+
ok(typeof pkg.dsh.bundle.patch === 'string' && pkg.dsh.bundle.patch === './cordis.patch.yml',
|
|
51
|
+
'dsh.bundle.patch is the single patch file as a STRING (an array breaks the 0.1.5/0.1.6 boot)',
|
|
52
|
+
JSON.stringify(pkg.dsh.bundle.patch))
|
|
53
|
+
ok(pkg.files.includes('cordis.patch.yml') && pkg.files.includes('scripts/build-preset-rows.mjs')
|
|
54
|
+
&& pkg.files.includes('preset-declaration.js'),
|
|
55
|
+
'the patch, its generator and the declaration module all ship in package.json files')
|
|
56
|
+
|
|
57
|
+
// ---- 3. rows ----------------------------------------------------------------------------------
|
|
58
|
+
ok(/^ {4}- id: vibe-math-preset-installer$/m.test(patch)
|
|
59
|
+
&& /^ {6}name: dsh-vibe-math\/installer$/m.test(patch),
|
|
60
|
+
'the installer row is inserted first')
|
|
61
|
+
const declarationRows = [...patch.matchAll(/^ {4}- id: preset-vibe-math-(v\d)$/gm)].map((m) => m[1])
|
|
62
|
+
ok(declarationRows.join(',') === VERSIONS.join(','),
|
|
63
|
+
'one declaration row per preset, in order', declarationRows.join(','))
|
|
64
|
+
ok((patch.match(/name: dsh-vibe-math\/preset-declaration/g) || []).length === 4,
|
|
65
|
+
'every declaration names this package\'s own preset-declaration module')
|
|
66
|
+
ok(!patch.includes("name: '@deepseek-ai/dsh-agent-preset'"),
|
|
67
|
+
'no row names @deepseek-ai/dsh-agent-preset (that package does not exist on DSH <= 0.1.6)')
|
|
68
|
+
ok(!/^ {4,6}disabled:/m.test(patch),
|
|
69
|
+
'no `disabled: !!js` gate on a DECLARATION row (an expression calling ctx.get() throws in patch evaluation and the row never loads; the nested platform gates on tool-bash/tool-pwsh inside a composition are constants and fine)')
|
|
70
|
+
|
|
71
|
+
// ---- 4. every declaration's composition -------------------------------------------------------
|
|
72
|
+
const IDS = { v2: '20', v3: '21', v4: '22', v5: '23' }
|
|
73
|
+
for (const v of VERSIONS) {
|
|
74
|
+
const start = patch.indexOf('- id: preset-vibe-math-' + v)
|
|
75
|
+
const next = patch.indexOf('\n- insert:', start)
|
|
76
|
+
const body = patch.slice(start, next === -1 ? undefined : next)
|
|
77
|
+
const need = [
|
|
78
|
+
['declared preset id', new RegExp('^ {8}id: vibe-math-' + v + '$', 'm')],
|
|
79
|
+
['roster order', new RegExp('^ {8}order: ' + IDS[v] + '$', 'm')],
|
|
80
|
+
['persona prefix', /^ {14}prefix: \|-/m],
|
|
81
|
+
['no legacy persona.text key', /^(?![\s\S]*^ {14}text:)/m],
|
|
82
|
+
['agent-instructions row', /name: '@deepseek-ai\/dsh-agent-instructions'/],
|
|
83
|
+
['plan-mode inside an isolate realm', /isolate:\n\s+planMode: true/],
|
|
84
|
+
['compaction isolate realm', /isolate:\n\s+compaction: true\n\s+toolResultPruner: true/],
|
|
85
|
+
['delegation isolate realm', /isolate:\n\s+workflowEngine: true/],
|
|
86
|
+
['the 0.1.7+ workflow engine', /name: '@deepseek-ai\/dsh-workflow-ptc'/],
|
|
87
|
+
['present row (deliverable cards)', /id: present\n\s+name: '@deepseek-ai\/dsh-tool-present'/],
|
|
88
|
+
['command-goal row', /id: command-goal\n\s+name: '@deepseek-ai\/dsh-command-goal'/],
|
|
89
|
+
['framework row by package subpath', new RegExp("name: 'dsh-vibe-math/vibe-math-" + v + "/vibe-math-" + v + "\\.js'")],
|
|
90
|
+
['framework row NOT a relative path', new RegExp("(?!name: '\\./vibe-math-" + v + "\\.js')")],
|
|
91
|
+
]
|
|
92
|
+
const missing = need.filter(([, re]) => !re.test(body)).map(([label]) => label)
|
|
93
|
+
ok(missing.length === 0, v + ': the composition carries every required row', 'missing: ' + missing.join(', '))
|
|
94
|
+
ok(!body.includes('workflow-worker-thread'), v + ': the removed worker-thread engine is not referenced')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---- 4b. the declaration module itself ---------------------------------------------------------
|
|
98
|
+
// It is the only piece of code in this mechanism, so its two behaviours are pinned directly:
|
|
99
|
+
// register exactly the row's declaration where the service exists, and do NOTHING (no throw, no call)
|
|
100
|
+
// where it does not — the older DSH line reads the directory instead and must boot silently.
|
|
101
|
+
{
|
|
102
|
+
const declaration = await import('../preset-declaration.js')
|
|
103
|
+
const CONFIG = { id: 'x', name: 'X', description: 'd', order: 7, plugins: [{ id: 'p', name: '@deepseek-ai/dsh-persona' }] }
|
|
104
|
+
const makeCtx = (service) => {
|
|
105
|
+
const calls = []
|
|
106
|
+
const warnings = []
|
|
107
|
+
const ctx = {
|
|
108
|
+
calls, warnings,
|
|
109
|
+
logger: { warn: (m) => warnings.push(String(m)) },
|
|
110
|
+
get: (n) => (n === 'agentPresets' ? service : undefined),
|
|
111
|
+
effect: (fn) => { const d = fn(); ctx.disposers.push(d); return () => { if (typeof d === 'function') d() } },
|
|
112
|
+
disposers: [],
|
|
113
|
+
inject: (deps, cb) => cb(ctx),
|
|
114
|
+
}
|
|
115
|
+
return ctx
|
|
116
|
+
}
|
|
117
|
+
const settle = () => new Promise((r) => setTimeout(r, 0))
|
|
118
|
+
|
|
119
|
+
// (a) service present → the row's declaration reaches register()
|
|
120
|
+
{
|
|
121
|
+
let seen
|
|
122
|
+
const ctx = makeCtx({ register: async (def) => { seen = def; return async () => {} } })
|
|
123
|
+
declaration.apply(ctx, CONFIG)
|
|
124
|
+
await settle()
|
|
125
|
+
ok(seen !== undefined && seen.id === 'x' && seen.order === 7 && Array.isArray(seen.plugins) && seen.plugins.length === 1,
|
|
126
|
+
'preset-declaration: the row config is handed to agentPresets.register() unchanged')
|
|
127
|
+
}
|
|
128
|
+
// (b) service absent (DSH <= 0.1.6) → no call, no throw (the directory form is the mechanism there)
|
|
129
|
+
{
|
|
130
|
+
const ctx = makeCtx(undefined)
|
|
131
|
+
let threw = false
|
|
132
|
+
try { declaration.apply(ctx, CONFIG); await settle() } catch (e) { threw = true }
|
|
133
|
+
ok(!threw && ctx.warnings.length === 0, 'preset-declaration: on a host without the service it registers nothing and stays silent')
|
|
134
|
+
}
|
|
135
|
+
// (c) a duplicate id (the user saved their own declaration) → reported once, never thrown
|
|
136
|
+
{
|
|
137
|
+
const ctx = makeCtx({ register: async () => { throw new Error('Duplicate agent preset: x') } })
|
|
138
|
+
let threw = false
|
|
139
|
+
try { declaration.apply(ctx, CONFIG); await settle() } catch (e) { threw = true }
|
|
140
|
+
ok(!threw && ctx.warnings.length === 1 && /Duplicate agent preset/.test(ctx.warnings[0]),
|
|
141
|
+
'preset-declaration: a duplicate id is logged once (the profile declaration wins) and never breaks the boot',
|
|
142
|
+
ctx.warnings.join(' | '))
|
|
143
|
+
}
|
|
144
|
+
// (d) no ctx.inject (an older Cordis) → the direct path still registers
|
|
145
|
+
{
|
|
146
|
+
let seen
|
|
147
|
+
const ctx = makeCtx({ register: async (def) => { seen = def; return async () => {} } })
|
|
148
|
+
delete ctx.inject
|
|
149
|
+
declaration.apply(ctx, CONFIG)
|
|
150
|
+
await settle()
|
|
151
|
+
ok(seen !== undefined, 'preset-declaration: without ctx.inject it registers directly')
|
|
152
|
+
}
|
|
153
|
+
// (e) a row with no config must not throw
|
|
154
|
+
{
|
|
155
|
+
let threw = false
|
|
156
|
+
try { declaration.apply(makeCtx(undefined), undefined); declaration.apply(makeCtx(undefined), null) } catch (e) { threw = true }
|
|
157
|
+
ok(!threw, 'preset-declaration: a missing config is ignored instead of throwing')
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---- 5. module resolution ---------------------------------------------------------------------
|
|
162
|
+
for (const v of VERSIONS) {
|
|
163
|
+
const sub = './vibe-math-' + v + '/vibe-math-' + v + '.js'
|
|
164
|
+
ok(pkg.exports[sub] === sub && existsSync(at(sub)),
|
|
165
|
+
v + ': the framework plugin is exported as ' + sub)
|
|
166
|
+
ok(pkg.files.includes('vibe-math-' + v + '/vibe-math-' + v + '.js'),
|
|
167
|
+
v + ': the framework plugin ships')
|
|
168
|
+
}
|
|
169
|
+
ok(pkg.exports['./preset-declaration'] === './preset-declaration.js' && existsSync(at('preset-declaration.js')),
|
|
170
|
+
'preset-declaration.js is exported and present')
|
|
171
|
+
ok(pkg.files.includes('preset-declaration.js'), 'preset-declaration.js ships')
|
|
172
|
+
|
|
173
|
+
// ---- 6. the frozen 0.1.x compositions stay the field-tested shape -----------------------------
|
|
174
|
+
for (const v of VERSIONS) {
|
|
175
|
+
const src = readFileSync(at('vibe-math-' + v + '/agent.cordis.yml'), 'utf8')
|
|
176
|
+
ok(src.includes("name: '@deepseek-ai/dsh-workflow-worker-thread'"),
|
|
177
|
+
v + ': the frozen composition still uses the worker-thread engine (what <= 0.1.6 ships)')
|
|
178
|
+
ok(/^ {4}text: \|-/m.test(src) || /^ {2}text: \|-/m.test(src),
|
|
179
|
+
v + ': the frozen composition still carries the legacy persona.text key (DSH <= 0.1.2 needs it)')
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
console.log('')
|
|
183
|
+
console.log('=== PRESET DECLARATIONS: ' + passed + ' passed, ' + failed + ' failed ===')
|
|
184
|
+
if (failed) { for (const f of failures) console.error(' - ' + f); process.exit(1) }
|
|
185
|
+
console.log('ALL GREEN')
|
|
@@ -142,6 +142,34 @@ else {
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
// ---- 3. session-API surface used by tool handlers -----------------------
|
|
145
|
+
// `s` is not always the session API object. The toolFilter sanitiser added for the v2/v3/v4
|
|
146
|
+
// parity guard contains `.map(function(s){ return s.trim() })` — there `s` is a LOCAL string
|
|
147
|
+
// parameter, and a flat `s.NAME(` scan reported the phantom finding "calls s.trim() but the
|
|
148
|
+
// session API does not export it". A false FINDING is as corrosive as a blind spot (it trains
|
|
149
|
+
// the reader to ignore the audit), so the scan now respects the ONE shadowing form the plugin
|
|
150
|
+
// actually uses: a plain `function (...)` whose parameter list binds `s`.
|
|
151
|
+
//
|
|
152
|
+
// This is scope analysis, not an allowlist: any `s.X()` inside such a body CANNOT be a call on
|
|
153
|
+
// the enclosing session object — the parameter shadows it. The tool handlers themselves are
|
|
154
|
+
// ARROW functions (`(s, a, x) => s.…)`), so their `s` IS the session API and stays scanned; the
|
|
155
|
+
// self-check at the bottom of this file proves the skip does not blind those.
|
|
156
|
+
function localSParamBodySpans(text) {
|
|
157
|
+
const spans = []
|
|
158
|
+
const re = /function\s*(?:[A-Za-z_$][\w$]*)?\s*\(([^)]*)\)\s*\{/g
|
|
159
|
+
let m
|
|
160
|
+
while ((m = re.exec(text)) !== null) {
|
|
161
|
+
const params = m[1].split(',').map((p) => p.trim().replace(/[={].*$/s, '').trim())
|
|
162
|
+
if (params.indexOf('s') === -1) continue
|
|
163
|
+
let i = m.index + m[0].length - 1 // sitting on the opening '{'
|
|
164
|
+
let depth = 0
|
|
165
|
+
for (; i < text.length; i++) {
|
|
166
|
+
if (text[i] === '{') depth++
|
|
167
|
+
else if (text[i] === '}') { depth--; if (depth === 0) break }
|
|
168
|
+
}
|
|
169
|
+
spans.push([m.index, i])
|
|
170
|
+
}
|
|
171
|
+
return spans
|
|
172
|
+
}
|
|
145
173
|
// The returned API object is the last `return { ... }` inside makeSession.
|
|
146
174
|
const apiStart = src.lastIndexOf(' return {\n sessionId,')
|
|
147
175
|
if (apiStart === -1) findings.push('could not locate the session API return object')
|
|
@@ -151,14 +179,19 @@ else {
|
|
|
151
179
|
const apiKeys = new Set()
|
|
152
180
|
for (const m of apiText.matchAll(/(?:^|[\s{,])([A-Za-z_$][\w$]*)\s*:/g)) apiKeys.add(m[1])
|
|
153
181
|
for (const m of apiText.matchAll(/(?:^|[\s{,])([A-Za-z_$][\w$]*)\s*,/g)) apiKeys.add(m[1])
|
|
182
|
+
const localScopes = localSParamBodySpans(src)
|
|
183
|
+
const inLocalScope = (idx) => localScopes.some(([a, b]) => idx >= a && idx <= b)
|
|
154
184
|
const usedOnS = new Map()
|
|
185
|
+
let shadowed = 0
|
|
155
186
|
for (const m of src.matchAll(/(?<![\w$.])s\.([A-Za-z_$][\w$]*)\s*\(/g)) {
|
|
187
|
+
if (inLocalScope(m.index)) { shadowed++; continue }
|
|
156
188
|
if (!usedOnS.has(m[1])) usedOnS.set(m[1], lineOf(m.index))
|
|
157
189
|
}
|
|
158
190
|
for (const [k, line] of usedOnS) {
|
|
159
191
|
if (!apiKeys.has(k)) findings.push('line ' + line + ': tool handler calls s.' + k + '() but the session API does not export it')
|
|
160
192
|
}
|
|
161
|
-
notes.push('session API keys: ' + apiKeys.size + '; s.*() called: ' + usedOnS.size
|
|
193
|
+
notes.push('session API keys: ' + apiKeys.size + '; s.*() called: ' + usedOnS.size +
|
|
194
|
+
(shadowed ? '; skipped ' + shadowed + ' inside a local `function(s)` scope (a shadowing parameter, not the session API)' : ''))
|
|
162
195
|
}
|
|
163
196
|
|
|
164
197
|
// ---- 4. error codes ----------------------------------------------------
|
|
@@ -333,7 +366,7 @@ notes.push('composition rows: ' + v5rows.length + '; non-v4 package rows: ' + v5
|
|
|
333
366
|
['reusable definitions live in the GLOBAL cross-project library', 'const okWrite = await writeTextAbs(instRootless(rel), body)'],
|
|
334
367
|
['the Lean path guard normalises .. (no string-prefix traversal hole)', 'const norm = normalizeAbsPath(abs)'],
|
|
335
368
|
['a missing toolchain degrades to a readable result, not a crash', "code: 'LEAN_NOT_FOUND'"],
|
|
336
|
-
['the formal record is durable
|
|
369
|
+
['the formal record is durable state (folded into State/<institute>.v5state.json)', "formal: 'vibe5/formal',"],
|
|
337
370
|
['the state block advertises the formalization counts', "b.push('[形式化] ' + (formalMode()"],
|
|
338
371
|
['the reply contract carries the difficulty judgement', "L.push(' \"formal\": {"],
|
|
339
372
|
]
|
|
@@ -428,7 +461,20 @@ notes.push('composition rows: ' + v5rows.length + '; non-v4 package rows: ' + v5
|
|
|
428
461
|
const stripped = stripNoise(fixture)
|
|
429
462
|
if (stripped.includes('phantom_call')) findings.push('stripNoise() left a comment in the code stream (a phantom call site would be reported)')
|
|
430
463
|
if (!/'/.test(stripped)) findings.push('stripNoise() dropped a value placeholder')
|
|
431
|
-
|
|
464
|
+
// The session-API scan must ignore a SHADOWING `function(s)` parameter while still seeing the
|
|
465
|
+
// real arrow handlers. Without this, either the false `s.trim()` finding comes back or the
|
|
466
|
+
// scope skip silently blinds every tool-handler call — both are failures worth failing on.
|
|
467
|
+
const scopeFixture = [
|
|
468
|
+
'const names = m[1].split(",").map(function(s){ return s.trim() })', // local string param
|
|
469
|
+
'registerTool("t", "d", {}, (s, a) => s.resume())', // the session API
|
|
470
|
+
].join('\n')
|
|
471
|
+
const spans = localSParamBodySpans(scopeFixture)
|
|
472
|
+
const idxShadow = scopeFixture.indexOf('s.trim()')
|
|
473
|
+
const idxApi = scopeFixture.indexOf('s.resume()')
|
|
474
|
+
if (!spans.some(([a, b]) => idxShadow >= a && idxShadow <= b)) findings.push('the session-API scan would report a PHANTOM finding for a shadowing `function(s)` parameter (s.trim())')
|
|
475
|
+
if (spans.some(([a, b]) => idxApi >= a && idxApi <= b)) findings.push('the session-API scan would SKIP a real arrow tool handler (s.resume() was classified as shadowed)')
|
|
476
|
+
notes.push('scanner self-check: stripped output parses=' + (r.status === 0) + '; quoted-class fixture=' + (!stripped.includes('phantom_call')) +
|
|
477
|
+
'; shadowed-param skip=' + spans.length)
|
|
432
478
|
} finally {
|
|
433
479
|
rmSync(tmp, { recursive: true, force: true })
|
|
434
480
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// ============================================================
|
|
2
2
|
// V5 ROUND-2 E2E — the behaviour paths the first suite never exercised:
|
|
3
|
-
// · the hardened JSON
|
|
3
|
+
// · the hardened JSON backend (the ONLY backend: `makeFileBackend`/`installBackend`)
|
|
4
4
|
// · a simulated PROCESS RESTART (fresh agent + fresh backend over the same workspace)
|
|
5
5
|
// · duplicate subagent/end idempotence
|
|
6
6
|
// · hire quotas (per-member and institute-wide)
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// · the verification watchdog abandoning a stuck round
|
|
14
14
|
// · meeting PARKED behind an in-flight verification, then resumed
|
|
15
15
|
// · compaction/keepalive directives appear only when they should
|
|
16
|
-
// · session
|
|
16
|
+
// · the host session log is NEVER written, and the state reloads through the JSON file
|
|
17
17
|
// Run: node tests/e2e-v5-round2.test.mjs
|
|
18
18
|
// ============================================================
|
|
19
19
|
import { mkdtempSync, existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
@@ -32,30 +32,12 @@ const failures = []
|
|
|
32
32
|
const assert = (c, m) => { if (c) { passed++; console.log(' ok - ' + m) } else { failed++; failures.push(m); console.error(' FAIL - ' + m) } }
|
|
33
33
|
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
stateOf(session, key) {
|
|
42
|
-
const def = units.get(key); if (!def) return undefined
|
|
43
|
-
const m = cellMap(session)
|
|
44
|
-
if (!m.has(key)) m.set(key, def.init(session.header, 0))
|
|
45
|
-
return m.get(key)
|
|
46
|
-
},
|
|
47
|
-
checkpoint(session) { const o = {}; const m = cellMap(session); for (const [k, d] of units) o[k] = { ver: d.stateVersion, seq: session.seq, val: m.get(k) }; return o },
|
|
48
|
-
_drive(session, event) {
|
|
49
|
-
const m = cellMap(session)
|
|
50
|
-
for (const [k, def] of units) {
|
|
51
|
-
const cur = m.has(k) ? m.get(k) : def.init(session.header, 0)
|
|
52
|
-
let next; try { next = def.apply(cur, event) } catch (e) { next = cur }
|
|
53
|
-
m.set(k, next)
|
|
54
|
-
}
|
|
55
|
-
},
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
35
|
+
// The host contract v5 needs has NO session services at all: the plugin declares
|
|
36
|
+
// `inject = ['subagents','agents','fs','tools','commands','timer']` and keeps its state in a
|
|
37
|
+
// hardened JSON file. `Session.append` cannot mark an event `ignorable`, and DSH's session
|
|
38
|
+
// persistence refuses to load a log carrying an unknown event type — so a host that offers
|
|
39
|
+
// `sessionProjections` must still never receive an institute event. Every session created
|
|
40
|
+
// here records whatever it is given, so the suites can assert that NOTHING was appended.
|
|
59
41
|
function makeSession(id, parentSession) {
|
|
60
42
|
const events = []
|
|
61
43
|
const s = {
|
|
@@ -63,27 +45,28 @@ function makeSession(id, parentSession) {
|
|
|
63
45
|
header: { version: 1, id, createdAt: Date.now(), cwd: null, parentSession, isSeeded: false },
|
|
64
46
|
inheritedEventCount: 0,
|
|
65
47
|
get seq() { return events.length },
|
|
66
|
-
append(type, data) { const ev = { type, data, seq: events.length, time: Date.now() }; events.push(ev);
|
|
48
|
+
append(type, data) { const ev = { type, data, seq: events.length, time: Date.now() }; events.push(ev); return ev },
|
|
67
49
|
deriveMessages() { return [] },
|
|
68
50
|
snapshotEvents(from) { return events.slice(from || 0) },
|
|
69
51
|
ownEvents() { return events.slice() },
|
|
70
52
|
_events: events,
|
|
71
|
-
_drive: null,
|
|
72
53
|
}
|
|
73
54
|
return s
|
|
74
55
|
}
|
|
75
56
|
|
|
76
|
-
/** A fresh host+plugin instance
|
|
57
|
+
/** A fresh host+plugin instance over a workspace (a fresh temp dir by default). */
|
|
77
58
|
function makeHost(opts) {
|
|
78
59
|
const o = opts || {}
|
|
79
60
|
const WS = o.ws || mkdtempSync(join(tmpdir(), 'vibe-v5r2-'))
|
|
80
|
-
const projections = o.withProjections === false ? undefined : makeProjectionRegistry()
|
|
81
61
|
const listeners = {}, toolRegs = [], commandRegs = [], spawns = [], wakes = [], interrupts = [], drains = []
|
|
82
62
|
const liveAgents = new Map()
|
|
63
|
+
// The two services the fix removed. This host does not provide them, and records every
|
|
64
|
+
// request so the suite can assert the plugin never even LOOKS for them any more.
|
|
65
|
+
const removedServiceQueries = []
|
|
83
66
|
|
|
84
67
|
const ctx = {
|
|
85
68
|
get(name) {
|
|
86
|
-
if (name === 'sessionProjections')
|
|
69
|
+
if (name === 'sessions' || name === 'sessionProjections') removedServiceQueries.push(name)
|
|
87
70
|
if (name === 'sandboxPolicy') return undefined
|
|
88
71
|
if (name === 'compaction') return o.compaction
|
|
89
72
|
if (name === 'subprocess') {
|
|
@@ -106,7 +89,6 @@ function makeHost(opts) {
|
|
|
106
89
|
timeout(cb, ms) { const h = setTimeout(cb, ms); return () => clearTimeout(h) },
|
|
107
90
|
tools: { register(spec) { toolRegs.push(spec); return () => {} } },
|
|
108
91
|
commands: { register(spec) { commandRegs.push(spec); return () => {} } },
|
|
109
|
-
sessions: { async flush() { return true } },
|
|
110
92
|
subagents: {
|
|
111
93
|
list() { return ['spawn'] },
|
|
112
94
|
async startContinuable({ label, request }) {
|
|
@@ -114,7 +96,6 @@ function makeHost(opts) {
|
|
|
114
96
|
const childSession = makeSession(id, 'sess-A'); childSession.header.cwd = WS
|
|
115
97
|
liveAgents.set(id, { id, session: childSession, options: request && request.agentOptions })
|
|
116
98
|
spawns.push({ label, request, childId: id, persona: request && request.persona, ended: false })
|
|
117
|
-
if (projections) childSession._drive = (ev) => projections._drive(childSession, ev)
|
|
118
99
|
return { childId: id, messageId: 'm' + spawns.length }
|
|
119
100
|
},
|
|
120
101
|
async sendMessage(parent, childId, blocks) { wakes.push({ childId, blocks }); return 'w' + wakes.length },
|
|
@@ -138,7 +119,6 @@ function makeHost(opts) {
|
|
|
138
119
|
const ROOT_SESSION = makeSession('sess-A', undefined)
|
|
139
120
|
ROOT_SESSION.header.cwd = WS
|
|
140
121
|
const ROOT = { id: 'sess-A', options: {}, session: ROOT_SESSION, ctx: undefined }
|
|
141
|
-
if (projections) ROOT_SESSION._drive = (ev) => projections._drive(ROOT_SESSION, ev)
|
|
142
122
|
|
|
143
123
|
const mod = o.pluginModule
|
|
144
124
|
mod.apply(ctx)
|
|
@@ -211,48 +191,70 @@ function makeHost(opts) {
|
|
|
211
191
|
}
|
|
212
192
|
return null
|
|
213
193
|
}
|
|
214
|
-
return { WS, ctx, ROOT, ROOT_SESSION,
|
|
194
|
+
return { WS, ctx, ROOT, ROOT_SESSION, removedServiceQueries, spawns, wakes, interrupts, drains, toolRegs, commandRegs, listeners, callTool, childAgent, fireEnd, spawnOf, childOf, labelOf, kindOf, settleSpawns, drain, peekWakeOf, set plannedVotes(v) { plannedVotes = v }, get plannedVotes() { return plannedVotes }, set solvePlan(v) { solvePlan = v } }
|
|
215
195
|
}
|
|
216
196
|
|
|
217
197
|
const pluginModule = await import(PLUGIN.href + '?t=' + Date.now())
|
|
218
198
|
const PROBLEM = '证明素数有无穷多个'
|
|
219
199
|
|
|
200
|
+
// The durable authority is the hardened JSON file. A commit is applied to the in-memory
|
|
201
|
+
// snapshot immediately but written through a deferred per-file chain, so a test that wants to
|
|
202
|
+
// observe "this was committed" must poll the FILE — reading the session log is no longer possible
|
|
203
|
+
// (and is exactly what the fix removed).
|
|
204
|
+
const statePathOf = (ws) => join(ws, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'State', 'institute.v5state.json')
|
|
205
|
+
const readStateAt = (ws) => { try { return JSON.parse(readFileSync(statePathOf(ws), 'utf8')) } catch (e) { return null } }
|
|
206
|
+
const instOfState = (s) => (s && s.institutes) ? s.institutes['default::institute'] : undefined
|
|
207
|
+
async function waitInst(ws, pred) {
|
|
208
|
+
let inst = instOfState(readStateAt(ws))
|
|
209
|
+
for (let i = 0; i < 60; i++) {
|
|
210
|
+
if (inst && (!pred || pred(inst))) return inst
|
|
211
|
+
await sleep(10)
|
|
212
|
+
inst = instOfState(readStateAt(ws))
|
|
213
|
+
}
|
|
214
|
+
return inst
|
|
215
|
+
}
|
|
216
|
+
|
|
220
217
|
// ============================================================
|
|
221
218
|
console.log('-- V5 round-2 e2e --')
|
|
222
219
|
|
|
223
|
-
// ---------- 1. the hardened JSON
|
|
224
|
-
console.log('\n[1]
|
|
220
|
+
// ---------- 1. the hardened JSON backend (the only one) ----------
|
|
221
|
+
console.log('\n[1] the hardened JSON backend (host with no session services at all)')
|
|
225
222
|
{
|
|
226
|
-
const h = makeHost({ pluginModule
|
|
223
|
+
const h = makeHost({ pluginModule })
|
|
227
224
|
const st = await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
|
|
228
|
-
assert(st.ok === true, 'start works
|
|
225
|
+
assert(st.ok === true, 'start works on a host with no sessions/sessionProjections service (got ' + JSON.stringify(st).slice(0, 90) + ')')
|
|
226
|
+
assert(Array.isArray(pluginModule.inject) && pluginModule.inject.indexOf('sessions') === -1 && pluginModule.inject.indexOf('sessionProjections') === -1,
|
|
227
|
+
'★ the plugin declares no sessions/sessionProjections dependency (inject=' + JSON.stringify(pluginModule.inject) + ')')
|
|
228
|
+
assert(h.removedServiceQueries.length === 0,
|
|
229
|
+
'★ the plugin never even resolves the removed services (queries=' + JSON.stringify(h.removedServiceQueries) + ')')
|
|
229
230
|
const s0 = await h.callTool('vibe_v5_status', {})
|
|
230
231
|
assert(s0.backend === 'file', 'status reports the file backend (got ' + s0.backend + ')')
|
|
231
232
|
await h.settleSpawns()
|
|
232
233
|
const stFile = join(h.WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'State', 'institute.v5state.json')
|
|
233
234
|
// the state file is written lazily (deferred stringify chain) — give it a tick
|
|
234
235
|
for (let i = 0; i < 40 && !existsSync(stFile); i++) await sleep(10)
|
|
235
|
-
assert(existsSync(stFile), 'the
|
|
236
|
+
assert(existsSync(stFile), 'the backend persisted the institute state to State/institute.v5state.json')
|
|
236
237
|
if (existsSync(stFile)) {
|
|
237
238
|
const parsed = JSON.parse(readFileSync(stFile, 'utf8'))
|
|
238
239
|
const inst = parsed.institutes['default::institute']
|
|
239
|
-
assert(!!inst && inst.members.length === 3, 'the persisted
|
|
240
|
+
assert(!!inst && inst.members.length === 3, 'the persisted state holds 1 academician + 2 researchers (got ' + (inst ? inst.members.length : 'none') + ')')
|
|
240
241
|
}
|
|
242
|
+
assert(h.ROOT_SESSION._events.length === 0, 'the whole founding wrote NOTHING to the host session log (' + h.ROOT_SESSION._events.length + ' events)')
|
|
241
243
|
}
|
|
242
244
|
|
|
243
245
|
// ---------- 2. simulated PROCESS RESTART ----------
|
|
244
246
|
console.log('\n[2] simulated process restart (fresh host, fresh backend, same workspace)')
|
|
245
247
|
{
|
|
246
248
|
const WS = mkdtempSync(join(tmpdir(), 'vibe-v5r2-restart-'))
|
|
247
|
-
const h1 = makeHost({ pluginModule,
|
|
249
|
+
const h1 = makeHost({ pluginModule, ws: WS })
|
|
248
250
|
await h1.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
|
|
249
251
|
await h1.settleSpawns()
|
|
250
|
-
const stFile =
|
|
252
|
+
const stFile = statePathOf(WS)
|
|
251
253
|
for (let i = 0; i < 40 && !existsSync(stFile); i++) await sleep(10)
|
|
252
254
|
assert(existsSync(stFile), 'first process persisted its state')
|
|
253
255
|
|
|
254
256
|
// a genuinely fresh host = new backend instance, empty in-memory state
|
|
255
|
-
const h2 = makeHost({ pluginModule,
|
|
257
|
+
const h2 = makeHost({ pluginModule, ws: WS })
|
|
256
258
|
const s2 = await h2.callTool('vibe_v5_status', {})
|
|
257
259
|
assert(s2.members.length === 3, 'a FRESH host reads the persisted roster back (got ' + s2.members.length + ' members)')
|
|
258
260
|
const res = await h2.callTool('vibe_v5_resume', {})
|
|
@@ -267,16 +269,24 @@ console.log('\n[3] duplicate subagent/end idempotence')
|
|
|
267
269
|
const h = makeHost({ pluginModule })
|
|
268
270
|
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
|
|
269
271
|
const cid = h.childOf('r-1')
|
|
270
|
-
// settle the founding turn normally, then REPLAY the exact same end event
|
|
271
|
-
|
|
272
|
+
// settle the founding turn normally, then REPLAY the exact same end event. "Committed" is
|
|
273
|
+
// observed where the state now lives — State/institute.v5state.json (`artifactCount` is what
|
|
274
|
+
// recording a card bumps) — and the host session log must stay untouched either way.
|
|
275
|
+
const logBefore = h.ROOT_SESSION._events.length
|
|
272
276
|
h.fireEnd(cid, { progress: '唯一一次', record: [{ kind: 'proposition', id: 'dup-x', statement: 's', value: 0.5, motive: 'm', p: 0.5 }] })
|
|
273
|
-
await
|
|
274
|
-
const afterFirst =
|
|
277
|
+
const inst1 = await waitInst(h.WS, (x) => Number(x.artifactCount) >= 1)
|
|
278
|
+
const afterFirst = inst1 ? Number(inst1.artifactCount) : 0
|
|
275
279
|
h.fireEnd(cid, { progress: '重复投递', record: [{ kind: 'proposition', id: 'dup-x', statement: 's', value: 0.5, motive: 'm', p: 0.5 }] })
|
|
276
|
-
await sleep(
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
assert(
|
|
280
|
+
await sleep(60)
|
|
281
|
+
const inst2 = await waitInst(h.WS)
|
|
282
|
+
const afterSecond = inst2 ? Number(inst2.artifactCount) : 0
|
|
283
|
+
assert(afterSecond === afterFirst, 'a replayed end commits NOTHING new (' + afterFirst + ' -> ' + afterSecond + ' artifacts in the v5state JSON)')
|
|
284
|
+
assert(afterFirst >= 1, 'the first end did commit state (artifactCount=' + afterFirst + ' in State/institute.v5state.json)')
|
|
285
|
+
assert(h.ROOT_SESSION._events.length === logBefore, 'neither end appended anything to the host session log (' + logBefore + ' -> ' + h.ROOT_SESSION._events.length + ' events)')
|
|
286
|
+
// the card itself is durable too, and the replay did not append a second progress entry
|
|
287
|
+
const prog = join(h.WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'Members', 'r-1', 'Progress', 'progress.md')
|
|
288
|
+
const progText = existsSync(prog) ? readFileSync(prog, 'utf8') : ''
|
|
289
|
+
assert(/唯一一次/.test(progText) && !/重复投递/.test(progText), 'only the FIRST end reached the member\'s progress log')
|
|
280
290
|
}
|
|
281
291
|
|
|
282
292
|
// ---------- 4. hire quotas ----------
|
|
@@ -492,26 +502,86 @@ console.log('\n[13] compaction directive appears only when warranted')
|
|
|
492
502
|
if (later) h.fireEnd(later.childId, { progress: 'y', contextPct: 15 })
|
|
493
503
|
}
|
|
494
504
|
|
|
495
|
-
// ---------- 14. session
|
|
496
|
-
console.log('\n[14]
|
|
505
|
+
// ---------- 14. the host session log is never written; the JSON file is the reload path ----------
|
|
506
|
+
console.log('\n[14] nothing is appended to the host session log; state survives a reload through the JSON file')
|
|
497
507
|
{
|
|
498
508
|
const h = makeHost({ pluginModule })
|
|
499
509
|
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
|
|
500
510
|
await h.settleSpawns()
|
|
501
511
|
const s0 = await h.callTool('vibe_v5_status', {})
|
|
502
|
-
const
|
|
503
|
-
assert(
|
|
504
|
-
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
const
|
|
510
|
-
const s1 = await
|
|
511
|
-
assert(s1.members.length === s0.members.length, '
|
|
512
|
-
assert(s1.quorum.m === s0.quorum.m, '
|
|
513
|
-
assert(s1.tasks.length === s0.tasks.length, '
|
|
514
|
-
assert(s1.institute === s0.institute && s1.project === s0.project, '
|
|
512
|
+
const appended = h.ROOT_SESSION._events.filter(e => String(e.type).indexOf('vibe5/') === 0)
|
|
513
|
+
assert(h.ROOT_SESSION._events.length === 0 && appended.length === 0,
|
|
514
|
+
'★ the plugin appends NOTHING to the host session log (' + h.ROOT_SESSION._events.length + ' events): an institute event in the log would make the user session unresumable')
|
|
515
|
+
const stFile = statePathOf(h.WS)
|
|
516
|
+
for (let i = 0; i < 40 && !existsSync(stFile); i++) await sleep(10)
|
|
517
|
+
assert(existsSync(stFile), 'the institute state is on disk instead (State/institute.v5state.json)')
|
|
518
|
+
// A brand-new host over the same workspace is a genuine reload: fresh backend, empty memory.
|
|
519
|
+
const h2 = makeHost({ pluginModule, ws: h.WS })
|
|
520
|
+
const s1 = await h2.callTool('vibe_v5_status', {})
|
|
521
|
+
assert(s1.members.length === s0.members.length, 'the reload reproduced the roster (' + s1.members.length + ' vs ' + s0.members.length + ')')
|
|
522
|
+
assert(s1.quorum.m === s0.quorum.m, 'the reload reproduced the quorum')
|
|
523
|
+
assert(s1.tasks.length === s0.tasks.length, 'the reload reproduced the task board')
|
|
524
|
+
assert(s1.institute === s0.institute && s1.project === s0.project, 'the reload reproduced the institute identity')
|
|
525
|
+
assert(h2.ROOT_SESSION._events.length === 0, 'the reloading host also appended nothing to its session log')
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ---------- 15. the host's live-child cap is survived, named and not re-asked ----------
|
|
529
|
+
// DSH ≥0.2 caps the number of LIVE continuable children per ROOT agent (ActivationPool.reserve,
|
|
530
|
+
// capacity `maxActiveSubagents` on the `subagent` row, default 8; the throw is NOT in the .d.ts).
|
|
531
|
+
// The mock below refuses exactly like the host once the cap is full and frees a slot when a child is
|
|
532
|
+
// interrupted — the two host facts the preset has to survive. NOTE: the preset remembers the ceiling
|
|
533
|
+
// at MODULE scope (a host fact), and every host in this file shares that module, so this case MUST
|
|
534
|
+
// stay the LAST one.
|
|
535
|
+
console.log('\n[15] the host live-child cap (ACTIVATION_LIMIT_REACHED) is named, remembered and not re-asked')
|
|
536
|
+
{
|
|
537
|
+
const h = makeHost({ pluginModule })
|
|
538
|
+
const CAP = 2
|
|
539
|
+
let live = 0
|
|
540
|
+
let hostCalls = 0
|
|
541
|
+
const capError = () => {
|
|
542
|
+
const e = new Error('subagent limit reached (active child limit: ' + CAP + '); wait for an existing child to finish or complete this work with the current agents')
|
|
543
|
+
e.code = 'ACTIVATION_LIMIT_REACHED'
|
|
544
|
+
return e
|
|
545
|
+
}
|
|
546
|
+
const origStart = h.ctx.subagents.startContinuable
|
|
547
|
+
h.ctx.subagents.startContinuable = async function (spec) {
|
|
548
|
+
hostCalls += 1
|
|
549
|
+
if (live >= CAP) throw capError()
|
|
550
|
+
live += 1
|
|
551
|
+
return await origStart.call(this, spec)
|
|
552
|
+
}
|
|
553
|
+
const origInterrupt = h.ctx.subagents.interrupt
|
|
554
|
+
h.ctx.subagents.interrupt = function (childId) { live = Math.max(0, live - 1); return origInterrupt.call(this, childId) }
|
|
555
|
+
const errs = []
|
|
556
|
+
const origErr = console.error
|
|
557
|
+
console.error = function () { errs.push(Array.prototype.join.call(arguments, ' ')) }
|
|
558
|
+
let started
|
|
559
|
+
try {
|
|
560
|
+
started = await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 4, academician: false })
|
|
561
|
+
} finally { console.error = origErr }
|
|
562
|
+
assert(started.ok === true, 'the institute still founds a partial team when the host refuses members (' + JSON.stringify(started).slice(0, 140) + ')')
|
|
563
|
+
assert(h.spawns.length === CAP, 'only the members the host accepted were created (spawns=' + h.spawns.length + ')')
|
|
564
|
+
const capLines = errs.filter(l => /maxActiveSubagents/.test(l))
|
|
565
|
+
assert(capLines.length === 1, 'ONE actionable line for the whole founding round, not one per refused member (got ' + capLines.length + ')')
|
|
566
|
+
assert(capLines.length === 1 && /ACTIVATION_LIMIT_REACHED/.test(capLines[0]),
|
|
567
|
+
'the line names the host ceiling and the parameter that raises it: ' + String(capLines[0]).slice(0, 160))
|
|
568
|
+
const st = await h.callTool('vibe_v5_status', {})
|
|
569
|
+
const refused = st.members.filter(m => m.phase === 'failed')
|
|
570
|
+
assert(refused.length === 2, 'the refused members are recorded as failed, not left active (got ' + refused.length + ')')
|
|
571
|
+
assert(refused.every(m => /maxActiveSubagents/.test(m.error)), 'each failed record carries the cap, not the opaque host string: ' + JSON.stringify(refused.map(m => m.error)).slice(0, 200))
|
|
572
|
+
assert(refused.every(m => m.busy !== true), 'no refused member is left marked busy')
|
|
573
|
+
// Once the ceiling is KNOWN, a refused provisioning must not even reach the host.
|
|
574
|
+
const callsBefore = hostCalls
|
|
575
|
+
const hired = await h.callTool('vibe_v5_hire', { purpose: '被上限拒绝', initial_task: 'x' }, h.childAgent(h.childOf('r-1')))
|
|
576
|
+
assert(hired.ok === false && hired.code === 'ACTIVATION_LIMIT_REACHED', 'hire reports the host cap by its typed code (' + JSON.stringify(hired).slice(0, 160) + ')')
|
|
577
|
+
assert(/maxActiveSubagents/.test(hired.message || ''), 'the hire failure names maxActiveSubagents, not the opaque host string')
|
|
578
|
+
assert(hostCalls === callsBefore, 'the host was NOT asked again once the ceiling was known (skip-before-spawn; hostCalls=' + hostCalls + ')')
|
|
579
|
+
// The refused work is QUEUED, not lost: freeing a slot (fire a member) lets the next round retry.
|
|
580
|
+
await h.callTool('vibe_v5_fire', { id: 'r-1', reason: 'cap test' })
|
|
581
|
+
const resumed = await h.callTool('vibe_v5_resume', {})
|
|
582
|
+
assert(resumed.ok === true && resumed.respawned >= 1, 'a slot freed by firing a member lets resume rebuild a capped member (respawned=' + (resumed && resumed.respawned) + ')')
|
|
583
|
+
const st2 = await h.callTool('vibe_v5_status', {})
|
|
584
|
+
assert(st2.members.filter(m => m.phase === 'active' && m.childId).length === CAP, 'the institute is back at the host ceiling (active=' + st2.members.filter(m => m.phase === 'active').map(m => m.id).join(',') + ')')
|
|
515
585
|
}
|
|
516
586
|
|
|
517
587
|
console.log('')
|