dsh-vibe-math 2.3.11 → 2.3.13
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.md +17 -40
- package/{AUDIT-CHECKLIST.md → docs/AUDIT-CHECKLIST.md} +323 -304
- package/docs/COMPAT-AUDIT-ROUND2.md +325 -0
- package/docs/generate_framework_diagram_v2.py +114 -0
- package/docs/generate_framework_diagram_v3.py +127 -0
- package/{RELEASE-NOTES-2.1.0.md → docs/release-notes/RELEASE-NOTES-2.1.0.md} +143 -143
- package/{RELEASE-NOTES-2.2.0.md → docs/release-notes/RELEASE-NOTES-2.2.0.md} +266 -266
- package/{RELEASE-NOTES-2.2.1.md → docs/release-notes/RELEASE-NOTES-2.2.1.md} +43 -43
- package/{RELEASE-NOTES-2.2.2.md → docs/release-notes/RELEASE-NOTES-2.2.2.md} +88 -88
- package/{RELEASE-NOTES-2.3.0.md → docs/release-notes/RELEASE-NOTES-2.3.0.md} +207 -207
- package/{RELEASE-NOTES-2.3.1.md → docs/release-notes/RELEASE-NOTES-2.3.1.md} +134 -134
- package/{RELEASE-NOTES-2.3.10.md → docs/release-notes/RELEASE-NOTES-2.3.10.md} +105 -105
- package/{RELEASE-NOTES-2.3.11.md → docs/release-notes/RELEASE-NOTES-2.3.11.md} +57 -57
- package/docs/release-notes/RELEASE-NOTES-2.3.12.md +80 -0
- package/docs/release-notes/RELEASE-NOTES-2.3.13.md +137 -0
- package/{RELEASE-NOTES-2.3.2.md → docs/release-notes/RELEASE-NOTES-2.3.2.md} +145 -145
- package/{RELEASE-NOTES-2.3.3.md → docs/release-notes/RELEASE-NOTES-2.3.3.md} +115 -115
- package/{RELEASE-NOTES-2.3.4.md → docs/release-notes/RELEASE-NOTES-2.3.4.md} +69 -69
- package/{RELEASE-NOTES-2.3.5.md → docs/release-notes/RELEASE-NOTES-2.3.5.md} +63 -63
- package/{RELEASE-NOTES-2.3.6.md → docs/release-notes/RELEASE-NOTES-2.3.6.md} +66 -66
- package/{RELEASE-NOTES-2.3.7.md → docs/release-notes/RELEASE-NOTES-2.3.7.md} +59 -59
- package/{RELEASE-NOTES-2.3.8.md → docs/release-notes/RELEASE-NOTES-2.3.8.md} +45 -45
- package/{RELEASE-NOTES-2.3.9.md → docs/release-notes/RELEASE-NOTES-2.3.9.md} +70 -70
- package/docs/test-timing.md +18 -17
- package/installer.js +258 -53
- package/package.json +50 -40
- package/{audit-formal-sensitivity.mjs → tests/audit-formal-sensitivity.mjs} +342 -342
- package/tests/audit-installer-compat.test.mjs +136 -0
- package/tests/audit-installer-policy.test.mjs +245 -0
- package/{audit-persona-sensitivity.mjs → tests/audit-persona-sensitivity.mjs} +249 -249
- package/{audit-persona-surface.test.mjs → tests/audit-persona-surface.test.mjs} +349 -349
- package/{audit-prompt-invariants.mjs → tests/audit-prompt-invariants.mjs} +508 -508
- package/{audit-spec-traceability.mjs → tests/audit-spec-traceability.mjs} +193 -193
- package/{audit-v5-integrity.mjs → tests/audit-v5-integrity.mjs} +448 -448
- package/{audit-v5-sensitivity.mjs → tests/audit-v5-sensitivity.mjs} +384 -384
- package/{e2e-v5-round2.test.mjs → tests/e2e-v5-round2.test.mjs} +521 -521
- package/{formal-verify-v2.test.mjs → tests/formal-verify-v2.test.mjs} +1315 -1315
- package/{formal-verify-v3.test.mjs → tests/formal-verify-v3.test.mjs} +1257 -1257
- package/{formal-verify-v4.test.mjs → tests/formal-verify-v4.test.mjs} +1082 -1082
- package/{formal-verify-v5.test.mjs → tests/formal-verify-v5.test.mjs} +708 -708
- package/{prompt-v5-integrity.test.mjs → tests/prompt-v5-integrity.test.mjs} +3 -3
- package/{run-tests.mjs → tests/run-tests.mjs} +121 -118
- package/{selfdrive-v5.mjs → tests/selfdrive-v5.mjs} +470 -470
- package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +1 -1
- package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +1 -1
- package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +1 -1
- package//347/244/272/344/276/213/345/233/276//345/256/236/351/231/205/344/275/277/347/224/250/347/244/272/344/276/213-/351/225/277/346/210/252/345/233/276.png +0 -0
- package//347/244/272/344/276/213/345/233/276//346/241/206/346/236/266/345/233/276-v2.png +0 -0
- package//347/244/272/344/276/213/345/233/276//346/241/206/346/236/266/345/233/276-v3.png +0 -0
- /package/{RELEASE-NOTES-2.0.22.md → docs/release-notes/RELEASE-NOTES-2.0.22.md} +0 -0
|
@@ -1,521 +1,521 @@
|
|
|
1
|
-
// ============================================================
|
|
2
|
-
// V5 ROUND-2 E2E — the behaviour paths the first suite never exercised:
|
|
3
|
-
// · the hardened JSON FALLBACK backend (host without sessionProjections)
|
|
4
|
-
// · a simulated PROCESS RESTART (fresh agent + fresh backend over the same workspace)
|
|
5
|
-
// · duplicate subagent/end idempotence
|
|
6
|
-
// · hire quotas (per-member and institute-wide)
|
|
7
|
-
// · quorumMode 'all-unanimous'
|
|
8
|
-
// · m recomputed when the roster shrinks (a voter is dismissed)
|
|
9
|
-
// · vibe_v5_wait (validation, no-progress shortcut, and real activity wake)
|
|
10
|
-
// · vibe_v5_read_library
|
|
11
|
-
// · id sanitisation against path traversal
|
|
12
|
-
// · configure guard while running
|
|
13
|
-
// · the verification watchdog abandoning a stuck round
|
|
14
|
-
// · meeting PARKED behind an in-flight verification, then resumed
|
|
15
|
-
// · compaction/keepalive directives appear only when they should
|
|
16
|
-
// · session-log REPLAY reproduces the institute state in a new session
|
|
17
|
-
// Run: node e2e-v5-round2.test.mjs
|
|
18
|
-
// ============================================================
|
|
19
|
-
import { mkdtempSync, existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
20
|
-
import { tmpdir } from 'node:os'
|
|
21
|
-
import { join, dirname, isAbsolute } from 'node:path'
|
|
22
|
-
|
|
23
|
-
// V5_PLUGIN lets a sensitivity probe point this suite at a deliberately broken copy.
|
|
24
|
-
// Without it every probe against this suite silently tested the UNMUTATED plugin and was
|
|
25
|
-
// reported as a "detection" only because the probe's own spawn failed — i.e. the whole
|
|
26
|
-
// e2e block of the sensitivity audit was vacuous.
|
|
27
|
-
const PLUGIN = process.env.V5_PLUGIN
|
|
28
|
-
? new URL('file:///' + String(process.env.V5_PLUGIN).replace(/\\/g, '/'))
|
|
29
|
-
: new URL('
|
|
30
|
-
let passed = 0, failed = 0
|
|
31
|
-
const failures = []
|
|
32
|
-
const assert = (c, m) => { if (c) { passed++; console.log(' ok - ' + m) } else { failed++; failures.push(m); console.error(' FAIL - ' + m) } }
|
|
33
|
-
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
|
34
|
-
|
|
35
|
-
function makeProjectionRegistry() {
|
|
36
|
-
const units = new Map()
|
|
37
|
-
const cells = new Map()
|
|
38
|
-
const cellMap = (sess) => { const id = String(sess.id); let m = cells.get(id); if (!m) { m = new Map(); cells.set(id, m) } return m }
|
|
39
|
-
return {
|
|
40
|
-
register(def) { units.set(def.key, def); return () => { units.delete(def.key) } },
|
|
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
|
-
|
|
59
|
-
function makeSession(id, parentSession) {
|
|
60
|
-
const events = []
|
|
61
|
-
const s = {
|
|
62
|
-
id,
|
|
63
|
-
header: { version: 1, id, createdAt: Date.now(), cwd: null, parentSession, isSeeded: false },
|
|
64
|
-
inheritedEventCount: 0,
|
|
65
|
-
get seq() { return events.length },
|
|
66
|
-
append(type, data) { const ev = { type, data, seq: events.length, time: Date.now() }; events.push(ev); this._drive(ev); return ev },
|
|
67
|
-
deriveMessages() { return [] },
|
|
68
|
-
snapshotEvents(from) { return events.slice(from || 0) },
|
|
69
|
-
ownEvents() { return events.slice() },
|
|
70
|
-
_events: events,
|
|
71
|
-
_drive: null,
|
|
72
|
-
}
|
|
73
|
-
return s
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** A fresh host+plugin instance. `withProjections:false` exercises the JSON fallback. */
|
|
77
|
-
function makeHost(opts) {
|
|
78
|
-
const o = opts || {}
|
|
79
|
-
const WS = o.ws || mkdtempSync(join(tmpdir(), 'vibe-v5r2-'))
|
|
80
|
-
const projections = o.withProjections === false ? undefined : makeProjectionRegistry()
|
|
81
|
-
const listeners = {}, toolRegs = [], commandRegs = [], spawns = [], wakes = [], interrupts = [], drains = []
|
|
82
|
-
const liveAgents = new Map()
|
|
83
|
-
|
|
84
|
-
const ctx = {
|
|
85
|
-
get(name) {
|
|
86
|
-
if (name === 'sessionProjections') return projections
|
|
87
|
-
if (name === 'sandboxPolicy') return undefined
|
|
88
|
-
if (name === 'compaction') return o.compaction
|
|
89
|
-
if (name === 'subprocess') {
|
|
90
|
-
return {
|
|
91
|
-
async spawn({ argv }) {
|
|
92
|
-
const script = argv[argv.length - 1] || ''
|
|
93
|
-
if (/New-Item/.test(script)) {
|
|
94
|
-
const re = /'((?:[^']|'')*)'/g; let m
|
|
95
|
-
while ((m = re.exec(script)) !== null) { const p = m[1].replace(/''/g, "'"); if (p && !/^-/.test(p)) mkdirSync(p, { recursive: true }) }
|
|
96
|
-
}
|
|
97
|
-
return { done: Promise.resolve({ exitCode: 0 }) }
|
|
98
|
-
},
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
return undefined
|
|
102
|
-
},
|
|
103
|
-
on(e, fn) { (listeners[e] = listeners[e] || []).push(fn) },
|
|
104
|
-
effect(fn) { const d = fn(); return () => { if (typeof d === 'function') d() } },
|
|
105
|
-
logger: { info() {}, warn() {}, error() {} },
|
|
106
|
-
timeout(cb, ms) { const h = setTimeout(cb, ms); return () => clearTimeout(h) },
|
|
107
|
-
tools: { register(spec) { toolRegs.push(spec); return () => {} } },
|
|
108
|
-
commands: { register(spec) { commandRegs.push(spec); return () => {} } },
|
|
109
|
-
sessions: { async flush() { return true } },
|
|
110
|
-
subagents: {
|
|
111
|
-
list() { return ['spawn'] },
|
|
112
|
-
async startContinuable({ label, request }) {
|
|
113
|
-
const id = 'c' + (spawns.length + 1)
|
|
114
|
-
const childSession = makeSession(id, 'sess-A'); childSession.header.cwd = WS
|
|
115
|
-
liveAgents.set(id, { id, session: childSession, options: request && request.agentOptions })
|
|
116
|
-
spawns.push({ label, request, childId: id, persona: request && request.persona, ended: false })
|
|
117
|
-
if (projections) childSession._drive = (ev) => projections._drive(childSession, ev)
|
|
118
|
-
return { childId: id, messageId: 'm' + spawns.length }
|
|
119
|
-
},
|
|
120
|
-
async sendMessage(parent, childId, blocks) { wakes.push({ childId, blocks }); return 'w' + wakes.length },
|
|
121
|
-
interrupt(childId) { interrupts.push(childId) },
|
|
122
|
-
async drainContinuableChildren(parent, ids) { drains.push(...ids); for (const i of ids) liveAgents.delete(i) },
|
|
123
|
-
},
|
|
124
|
-
agents: { roots() { return [ROOT] }, get(id) { return id === ROOT.id ? ROOT : liveAgents.get(id) }, list() { return [ROOT, ...liveAgents.values()] } },
|
|
125
|
-
fs: {
|
|
126
|
-
async resolve(rel, o2) {
|
|
127
|
-
const b = (o2 && o2.cwd) || WS
|
|
128
|
-
const p = (typeof rel === 'string' && isAbsolute(rel)) ? rel.replace(/\//g, '\\') : join(b, ...String(rel).split('/'))
|
|
129
|
-
return { targetKey: p, displayPath: p }
|
|
130
|
-
},
|
|
131
|
-
async stat(t) { return existsSync(t.targetKey) ? { version: 'v1', type: 'file', size: 1 } : undefined },
|
|
132
|
-
async readText(t) { return readFileSync(t.targetKey, 'utf8') },
|
|
133
|
-
async writeText(t, c) { mkdirSync(dirname(t.targetKey), { recursive: true }); writeFileSync(t.targetKey, c, 'utf8') },
|
|
134
|
-
async listDir(t) { if (!existsSync(t.targetKey)) return []; return readdirSync(t.targetKey, { withFileTypes: true }).map(e => ({ name: e.name, type: e.isDirectory() ? 'directory' : 'file' })) },
|
|
135
|
-
},
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const ROOT_SESSION = makeSession('sess-A', undefined)
|
|
139
|
-
ROOT_SESSION.header.cwd = WS
|
|
140
|
-
const ROOT = { id: 'sess-A', options: {}, session: ROOT_SESSION, ctx: undefined }
|
|
141
|
-
if (projections) ROOT_SESSION._drive = (ev) => projections._drive(ROOT_SESSION, ev)
|
|
142
|
-
|
|
143
|
-
const mod = o.pluginModule
|
|
144
|
-
mod.apply(ctx)
|
|
145
|
-
|
|
146
|
-
async function callTool(name, args, agent) {
|
|
147
|
-
const spec = toolRegs.find(x => x.name === name)
|
|
148
|
-
if (!spec) throw new Error('no tool ' + name)
|
|
149
|
-
return JSON.parse(await spec.execute(args || {}, { agent: agent || ROOT }))
|
|
150
|
-
}
|
|
151
|
-
const childAgent = (childId) => liveAgents.get(childId) || { id: childId, session: { header: { parentSession: ROOT.id } } }
|
|
152
|
-
function fireEnd(childId, reply, stopReason) {
|
|
153
|
-
const blocks = reply === undefined ? [] : [{ type: 'text', text: '```json\n' + JSON.stringify(reply) + '\n```' }]
|
|
154
|
-
for (const h of (listeners['subagent/end'] || [])) h({ id: childId, runId: 'r', provider: 'spawn', local: true, stopReason: stopReason || 'completed', lastAssistantMessage: blocks })
|
|
155
|
-
}
|
|
156
|
-
const spawnOf = (m) => spawns.find(s => s.label.indexOf('vibe5 ' + m + ' ') !== -1)
|
|
157
|
-
const childOf = (m) => { const s = spawnOf(m); return s ? s.childId : m }
|
|
158
|
-
const labelOf = (cid) => { const s = spawns.find(x => x.childId === cid); const m = s ? /vibe5 (\S+) /.exec(s.label) : null; return m ? m[1] : '' }
|
|
159
|
-
const kindOf = (p) => /【入职首轮/.test(p) ? 'initial' : /【研究所会议/.test(p) ? 'meeting' : /【求真表决/.test(p) ? 'verify' : /【心跳检查/.test(p) ? 'checkpoint' : 'normal'
|
|
160
|
-
|
|
161
|
-
async function settleSpawns() {
|
|
162
|
-
for (const sp of spawns.slice()) {
|
|
163
|
-
if (sp.ended) continue
|
|
164
|
-
sp.ended = true
|
|
165
|
-
fireEnd(sp.childId, { progress: labelOf(sp.childId) + ':初始见解已记录。', solved: false, contextPct: 10 })
|
|
166
|
-
await sleep(15)
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
let plannedVotes = null
|
|
170
|
-
let solvePlan = null
|
|
171
|
-
async function drain(budget = 60) {
|
|
172
|
-
let n = 0
|
|
173
|
-
while (wakes.length && n < budget) {
|
|
174
|
-
const w = wakes.shift()
|
|
175
|
-
const prompt = (w.blocks && w.blocks[0] && w.blocks[0].text) || ''
|
|
176
|
-
const kind = kindOf(prompt)
|
|
177
|
-
const who = labelOf(w.childId)
|
|
178
|
-
let reply
|
|
179
|
-
if (kind === 'verify') {
|
|
180
|
-
const tm = /"target"\s*:\s*"([^"]+)"/.exec(prompt)
|
|
181
|
-
const t = tm ? tm[1] : 'p-r-1'
|
|
182
|
-
const v = plannedVotes && plannedVotes.has(who) ? plannedVotes.get(who) : 0.5
|
|
183
|
-
reply = { verdict: { target: t, verdict: v, reason: who + ' 判断' }, contextPct: 20 }
|
|
184
|
-
} else if (kind === 'meeting') {
|
|
185
|
-
reply = { input: who + ':意见已述。', vote_solved: solvePlan === true, solved: solvePlan === true, contextPct: 20 }
|
|
186
|
-
} else {
|
|
187
|
-
reply = { progress: who + ':继续推进。', solved: false, contextPct: 20 }
|
|
188
|
-
}
|
|
189
|
-
fireEnd(w.childId, reply)
|
|
190
|
-
n++
|
|
191
|
-
await sleep(15)
|
|
192
|
-
}
|
|
193
|
-
return n
|
|
194
|
-
}
|
|
195
|
-
/** Pull the next queued wake for one member and return its prompt text.
|
|
196
|
-
* Wakes for OTHER members are answered benignly so the scheduler keeps cycling —
|
|
197
|
-
* otherwise a single unanswered heartbeat would stop all further passes. */
|
|
198
|
-
async function peekWakeOf(member, maxWaitMs) {
|
|
199
|
-
const t0 = Date.now()
|
|
200
|
-
while (Date.now() - t0 < (maxWaitMs || 3000)) {
|
|
201
|
-
const i = wakes.findIndex(w => labelOf(w.childId) === member)
|
|
202
|
-
if (i !== -1) {
|
|
203
|
-
const w = wakes.splice(i, 1)[0]
|
|
204
|
-
return { childId: w.childId, text: (w.blocks && w.blocks[0] && w.blocks[0].text) || '' }
|
|
205
|
-
}
|
|
206
|
-
if (wakes.length) {
|
|
207
|
-
const w = wakes.shift()
|
|
208
|
-
fireEnd(w.childId, { progress: '其他成员推进中', solved: false, contextPct: 10 })
|
|
209
|
-
}
|
|
210
|
-
await sleep(20)
|
|
211
|
-
}
|
|
212
|
-
return null
|
|
213
|
-
}
|
|
214
|
-
return { WS, ctx, ROOT, ROOT_SESSION, projections, 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
|
-
}
|
|
216
|
-
|
|
217
|
-
const pluginModule = await import(PLUGIN.href + '?t=' + Date.now())
|
|
218
|
-
const PROBLEM = '证明素数有无穷多个'
|
|
219
|
-
|
|
220
|
-
// ============================================================
|
|
221
|
-
console.log('-- V5 round-2 e2e --')
|
|
222
|
-
|
|
223
|
-
// ---------- 1. the hardened JSON FALLBACK backend ----------
|
|
224
|
-
console.log('\n[1] fallback backend (host without sessionProjections)')
|
|
225
|
-
{
|
|
226
|
-
const h = makeHost({ pluginModule, withProjections: false })
|
|
227
|
-
const st = await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
|
|
228
|
-
assert(st.ok === true, 'start works without sessionProjections (fallback path)')
|
|
229
|
-
const s0 = await h.callTool('vibe_v5_status', {})
|
|
230
|
-
assert(s0.backend === 'file', 'status reports the file backend (got ' + s0.backend + ')')
|
|
231
|
-
await h.settleSpawns()
|
|
232
|
-
const stFile = join(h.WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'State', 'institute.v5state.json')
|
|
233
|
-
// the state file is written lazily (deferred stringify chain) — give it a tick
|
|
234
|
-
for (let i = 0; i < 40 && !existsSync(stFile); i++) await sleep(10)
|
|
235
|
-
assert(existsSync(stFile), 'the fallback persisted the institute state to State/institute.v5state.json')
|
|
236
|
-
if (existsSync(stFile)) {
|
|
237
|
-
const parsed = JSON.parse(readFileSync(stFile, 'utf8'))
|
|
238
|
-
const inst = parsed.institutes['default::institute']
|
|
239
|
-
assert(!!inst && inst.members.length === 3, 'the persisted fallback state holds 1 academician + 2 researchers (got ' + (inst ? inst.members.length : 'none') + ')')
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// ---------- 2. simulated PROCESS RESTART ----------
|
|
244
|
-
console.log('\n[2] simulated process restart (fresh host, fresh backend, same workspace)')
|
|
245
|
-
{
|
|
246
|
-
const WS = mkdtempSync(join(tmpdir(), 'vibe-v5r2-restart-'))
|
|
247
|
-
const h1 = makeHost({ pluginModule, withProjections: false, ws: WS })
|
|
248
|
-
await h1.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
|
|
249
|
-
await h1.settleSpawns()
|
|
250
|
-
const stFile = join(WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'State', 'institute.v5state.json')
|
|
251
|
-
for (let i = 0; i < 40 && !existsSync(stFile); i++) await sleep(10)
|
|
252
|
-
assert(existsSync(stFile), 'first process persisted its state')
|
|
253
|
-
|
|
254
|
-
// a genuinely fresh host = new backend instance, empty in-memory state
|
|
255
|
-
const h2 = makeHost({ pluginModule, withProjections: false, ws: WS })
|
|
256
|
-
const s2 = await h2.callTool('vibe_v5_status', {})
|
|
257
|
-
assert(s2.members.length === 3, 'a FRESH host reads the persisted roster back (got ' + s2.members.length + ' members)')
|
|
258
|
-
const res = await h2.callTool('vibe_v5_resume', {})
|
|
259
|
-
assert(res.ok === true, 'resume works on a fresh host over persisted state (got ' + JSON.stringify(res).slice(0, 120) + ')')
|
|
260
|
-
assert(h2.spawns.length === 3, 'resume re-spawned the 3 members whose child sessions no longer exist (got ' + h2.spawns.length + ')')
|
|
261
|
-
assert(s2.quorum && s2.quorum.m === 3, 'the recovered roster recomputes m correctly (m=' + (s2.quorum ? s2.quorum.m : '?') + ')')
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// ---------- 3. duplicate subagent/end is idempotent ----------
|
|
265
|
-
console.log('\n[3] duplicate subagent/end idempotence')
|
|
266
|
-
{
|
|
267
|
-
const h = makeHost({ pluginModule })
|
|
268
|
-
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
|
|
269
|
-
const cid = h.childOf('r-1')
|
|
270
|
-
// settle the founding turn normally, then REPLAY the exact same end event
|
|
271
|
-
const before = h.ROOT_SESSION._events.length
|
|
272
|
-
h.fireEnd(cid, { progress: '唯一一次', record: [{ kind: 'proposition', id: 'dup-x', statement: 's', value: 0.5, motive: 'm', p: 0.5 }] })
|
|
273
|
-
await sleep(40)
|
|
274
|
-
const afterFirst = h.ROOT_SESSION._events.length
|
|
275
|
-
h.fireEnd(cid, { progress: '重复投递', record: [{ kind: 'proposition', id: 'dup-x', statement: 's', value: 0.5, motive: 'm', p: 0.5 }] })
|
|
276
|
-
await sleep(40)
|
|
277
|
-
const afterSecond = h.ROOT_SESSION._events.length
|
|
278
|
-
assert(afterSecond === afterFirst, 'a replayed end commits NOTHING new (' + afterFirst + ' -> ' + afterSecond + ' events)')
|
|
279
|
-
assert(afterFirst > before, 'the first end did commit state')
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// ---------- 4. hire quotas ----------
|
|
283
|
-
console.log('\n[4] hire quotas')
|
|
284
|
-
{
|
|
285
|
-
const h = makeHost({ pluginModule })
|
|
286
|
-
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
|
|
287
|
-
await h.settleSpawns()
|
|
288
|
-
await h.callTool('vibe_v5_set', { maxTempPerMember: 2, maxTempTotal: 3 })
|
|
289
|
-
const a = await h.callTool('vibe_v5_hire', { purpose: 'p1', initial_task: 't1' }, h.childAgent(h.childOf('r-1')))
|
|
290
|
-
const b = await h.callTool('vibe_v5_hire', { purpose: 'p2', initial_task: 't2' }, h.childAgent(h.childOf('r-1')))
|
|
291
|
-
const c = await h.callTool('vibe_v5_hire', { purpose: 'p3', initial_task: 't3' }, h.childAgent(h.childOf('r-1')))
|
|
292
|
-
await h.settleSpawns()
|
|
293
|
-
assert(a.ok && b.ok, 'the first two hires succeed (cap 2 per member)')
|
|
294
|
-
assert(c.ok === false && c.code === 'V5_MEMBER_LIMIT', 'the third hire is refused by maxTempPerMember (' + (c.code || 'no code') + ')')
|
|
295
|
-
const d = await h.callTool('vibe_v5_hire', { purpose: 'p4', initial_task: 't4' }, h.childAgent(h.childOf('acad')))
|
|
296
|
-
assert(d.ok === true, 'the academician can still hire (its own per-member budget)')
|
|
297
|
-
const e = await h.callTool('vibe_v5_hire', { purpose: 'p5', initial_task: 't5' }, h.childAgent(h.childOf('acad')))
|
|
298
|
-
assert(e.ok === false && e.code === 'V5_MEMBER_LIMIT', 'the institute-wide cap maxTempTotal=3 then refuses (' + (e.code || 'no code') + ')')
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// ---------- 5. quorumMode all-unanimous ----------
|
|
302
|
-
console.log('\n[5] quorumMode all-unanimous')
|
|
303
|
-
{
|
|
304
|
-
const h = makeHost({ pluginModule })
|
|
305
|
-
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
|
|
306
|
-
await h.settleSpawns()
|
|
307
|
-
await h.callTool('vibe_v5_set', { quorumMode: 'all-unanimous' })
|
|
308
|
-
await h.callTool('vibe_v5_propose_verify', { target: 'p-r-1', kind: 'proposition', reason: 'r' }, h.childAgent(h.childOf('r-1')))
|
|
309
|
-
const s = await h.callTool('vibe_v5_status', {})
|
|
310
|
-
assert(!!s.verify, 'verification started in all-unanimous mode')
|
|
311
|
-
// only 2 of 3 voters assert true; the third abstains -> must NOT verify
|
|
312
|
-
h.plannedVotes = new Map([['acad', 1], ['r-1', 1], ['r-2', 0.5]])
|
|
313
|
-
await h.drain(6)
|
|
314
|
-
let s2 = await h.callTool('vibe_v5_status', {})
|
|
315
|
-
assert(s2.verified.indexOf('p-r-1') === -1, 'all-unanimous: 2/3 true with one abstention does NOT verify')
|
|
316
|
-
// now everyone asserts true -> verifies
|
|
317
|
-
h.plannedVotes = new Map([['acad', 1], ['r-1', 1], ['r-2', 1]])
|
|
318
|
-
await h.drain(8)
|
|
319
|
-
let s3 = await h.callTool('vibe_v5_status', {})
|
|
320
|
-
if (!s3.verified.includes('p-r-1')) { h.plannedVotes = new Map([['acad', 1], ['r-1', 1], ['r-2', 1]]); await h.drain(8); s3 = await h.callTool('vibe_v5_status', {}) }
|
|
321
|
-
assert(s3.verified.indexOf('p-r-1') !== -1, 'all-unanimous: every voter true DOES verify')
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// ---------- 6. m recomputed when the roster shrinks ----------
|
|
325
|
-
console.log('\n[6] quorum recomputed when a voter is dismissed')
|
|
326
|
-
{
|
|
327
|
-
const h = makeHost({ pluginModule })
|
|
328
|
-
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 3 })
|
|
329
|
-
await h.settleSpawns()
|
|
330
|
-
const s0 = await h.callTool('vibe_v5_status', {})
|
|
331
|
-
assert(s0.quorum.m === 3 && s0.quorum.voterCount === 4, 'baseline m=3 over 4 voters')
|
|
332
|
-
await h.callTool('vibe_v5_remove_researcher', { id: 'r-3' })
|
|
333
|
-
await h.callTool('vibe_v5_remove_researcher', { id: 'r-2' })
|
|
334
|
-
const s1 = await h.callTool('vibe_v5_status', {})
|
|
335
|
-
assert(s1.quorum.voterCount === 2, 'two researchers dismissed -> 2 voters (got ' + s1.quorum.voterCount + ')')
|
|
336
|
-
assert(s1.quorum.m === 2, 'm tracks the roster down to min(cap=3, P=2) = 2 (got ' + s1.quorum.m + ')')
|
|
337
|
-
assert(h.drains.length === 2, 'both dismissed researchers had their resident child released')
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// ---------- 7. vibe_v5_wait ----------
|
|
341
|
-
console.log('\n[7] vibe_v5_wait')
|
|
342
|
-
{
|
|
343
|
-
const h = makeHost({ pluginModule })
|
|
344
|
-
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
|
|
345
|
-
await h.settleSpawns()
|
|
346
|
-
const bad = await h.callTool('vibe_v5_wait', { timeout_ms: 5000 }, h.childAgent(h.childOf('r-1')))
|
|
347
|
-
assert(bad.ok === false || bad.code === 'V5_INVALID_TIMEOUT' || String(bad.error || '').indexOf('10000') !== -1,
|
|
348
|
-
'a timeout below 10000ms is rejected (' + JSON.stringify(bad).slice(0, 90) + ')')
|
|
349
|
-
// a real wait must be woken by genuine institute activity, not by polling
|
|
350
|
-
const waitP = h.callTool('vibe_v5_wait', { timeout_ms: 10000 }, h.childAgent(h.childOf('r-1')))
|
|
351
|
-
await sleep(30)
|
|
352
|
-
await h.callTool('vibe_v5_say', { text: '有人说话就会唤醒等待者' }, h.childAgent(h.childOf('acad')))
|
|
353
|
-
const woke = await Promise.race([waitP, sleep(1500).then(() => ({ timedOut: 'NEVER-RESOLVED' }))])
|
|
354
|
-
assert(woke.timedOut === false, 'wait is woken by real activity rather than timing out (' + JSON.stringify(woke).slice(0, 80) + ')')
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// ---------- 8. read_library ----------
|
|
358
|
-
console.log('\n[8] vibe_v5_read_library')
|
|
359
|
-
{
|
|
360
|
-
const h = makeHost({ pluginModule })
|
|
361
|
-
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
|
|
362
|
-
await h.callTool('vibe_v5_record_proposition', { id: 'p-x', statement: 'S', value: 0.7, motive: 'M', p: 0.9 }, h.childAgent(h.childOf('r-1')))
|
|
363
|
-
h.fireEnd(h.childOf('r-1'), { progress: '进展正文', solved: false })
|
|
364
|
-
await sleep(30)
|
|
365
|
-
const lib = await h.callTool('vibe_v5_read_library', { member: 'r-1' }, h.childAgent(h.childOf('acad')))
|
|
366
|
-
assert(lib.ok === true && lib.count > 0, 'read_library returns entries (got ' + lib.count + ')')
|
|
367
|
-
const hasProp = (lib.items || []).some(i => i.kind === 'proposition' && i.id === 'p-x')
|
|
368
|
-
const hasProg = (lib.items || []).some(i => i.kind === 'progress' && /进展正文/.test(i.text))
|
|
369
|
-
assert(hasProp, 'read_library surfaces another member\'s recorded card (read-only cross-read)')
|
|
370
|
-
assert(hasProg, 'read_library surfaces another member\'s progress log')
|
|
371
|
-
const one = await h.callTool('vibe_v5_read_library', { member: 'r-1', kind: 'proposition', id: 'p-x' })
|
|
372
|
-
assert(one.count === 1 && /- ID: p-x/.test(one.items[0].text), 'read_library can fetch exactly one card by id')
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
// ---------- 9. id sanitisation (path traversal) ----------
|
|
376
|
-
console.log('\n[9] id sanitisation against path traversal')
|
|
377
|
-
{
|
|
378
|
-
const h = makeHost({ pluginModule })
|
|
379
|
-
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
|
|
380
|
-
await h.settleSpawns()
|
|
381
|
-
const r = await h.callTool('vibe_v5_propose_verify', { target: '../../../../evil', kind: 'proposition', reason: 'x' }, h.childAgent(h.childOf('r-1')))
|
|
382
|
-
assert(r.ok === true, 'a traversal-shaped target is accepted but sanitised')
|
|
383
|
-
const s = await h.callTool('vibe_v5_status', {})
|
|
384
|
-
const t = s.verify ? s.verify.target : (s.verifyQueue[0] || '')
|
|
385
|
-
assert(!!t && t.indexOf('/') === -1 && t.indexOf('..') === -1, 'the stored target id contains no separators or ".." (got "' + t + '")')
|
|
386
|
-
h.plannedVotes = new Map([['acad', 1], ['r-1', 1]])
|
|
387
|
-
await h.drain(8)
|
|
388
|
-
const base = join(h.WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute')
|
|
389
|
-
assert(!existsSync(join(h.WS, 'evil')) && !existsSync(join(h.WS, 'VibeMath', 'evil')), 'nothing was written outside the institute tree')
|
|
390
|
-
assert(existsSync(base), 'the institute tree itself is intact')
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// ---------- 10. configure guard while running ----------
|
|
394
|
-
console.log('\n[10] configure guard')
|
|
395
|
-
{
|
|
396
|
-
const h = makeHost({ pluginModule })
|
|
397
|
-
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
|
|
398
|
-
const c = await h.callTool('vibe_v5_configure', { institute: 'other' })
|
|
399
|
-
assert(c.ok === false && c.code === 'V5_INSTITUTE_STATE', 'reconfiguring while running is refused (' + (c.code || '') + ')')
|
|
400
|
-
const after = await h.callTool('vibe_v5_status', {})
|
|
401
|
-
assert(after.institute === 'institute', 'the institute was not split onto a second tree')
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// ---------- 11. verification watchdog ----------
|
|
405
|
-
console.log('\n[11] verification watchdog abandons a stuck round')
|
|
406
|
-
{
|
|
407
|
-
const h = makeHost({ pluginModule })
|
|
408
|
-
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
|
|
409
|
-
await h.settleSpawns()
|
|
410
|
-
await h.callTool('vibe_v5_set', { activityTimeoutMs: 40 }) // recoverStallMs = 80ms
|
|
411
|
-
await h.callTool('vibe_v5_propose_verify', { target: 'p-r-1', kind: 'proposition', reason: 'x' }, h.childAgent(h.childOf('r-1')))
|
|
412
|
-
const s0 = await h.callTool('vibe_v5_status', {})
|
|
413
|
-
assert(!!s0.verify, 'verification is in flight')
|
|
414
|
-
h.wakes.length = 0 // deliberately never answer the voters
|
|
415
|
-
await sleep(200) // past recoverStallMs
|
|
416
|
-
// any scheduling pass re-runs the watchdog
|
|
417
|
-
await h.callTool('vibe_v5_say', { text: 'ping' }, h.childAgent(h.childOf('r-1')))
|
|
418
|
-
h.fireEnd(h.childOf('r-1'), { solved: false })
|
|
419
|
-
await sleep(80)
|
|
420
|
-
let cleared = false
|
|
421
|
-
for (let i = 0; i < 20 && !cleared; i++) {
|
|
422
|
-
const s = await h.callTool('vibe_v5_status', {})
|
|
423
|
-
if (!s.verify) cleared = true
|
|
424
|
-
else { h.fireEnd(h.childOf('r-1'), { solved: false }); await sleep(40) }
|
|
425
|
-
}
|
|
426
|
-
const s1 = await h.callTool('vibe_v5_status', {})
|
|
427
|
-
assert(!s1.verify, 'the stuck verification was abandoned instead of blocking forever')
|
|
428
|
-
assert(s1.undecided.length >= 1 || s1.verified.indexOf('p-r-1') === -1, 'the abandoned object stayed 未定论 rather than being forced true/false')
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
// ---------- 12. meeting parked behind a verification ----------
|
|
432
|
-
console.log('\n[12] meeting parked behind an in-flight verification')
|
|
433
|
-
{
|
|
434
|
-
const h = makeHost({ pluginModule })
|
|
435
|
-
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
|
|
436
|
-
await h.settleSpawns()
|
|
437
|
-
await h.callTool('vibe_v5_propose_verify', { target: 'p-r-1', kind: 'proposition', reason: 'x' }, h.childAgent(h.childOf('r-1')))
|
|
438
|
-
const s0 = await h.callTool('vibe_v5_status', {})
|
|
439
|
-
assert(!!s0.verify, 'a verification is in flight')
|
|
440
|
-
const m = await h.callTool('vibe_v5_meeting', { agenda: '协调下一步', kind: 'sync' }, h.childAgent(h.childOf('acad')))
|
|
441
|
-
assert(m.ok === true && m.parked === true, 'the meeting is PARKED rather than preempting verification (' + JSON.stringify(m).slice(0, 90) + ')')
|
|
442
|
-
const s1 = await h.callTool('vibe_v5_status', {})
|
|
443
|
-
assert(!!s1.parkedMeeting, 'status exposes the parked meeting')
|
|
444
|
-
// settle the verification, then the parked meeting must actually convene. Drain only
|
|
445
|
-
// the verification's own asks: a greedy drain would also consume the meeting's asks
|
|
446
|
-
// and finalize the meeting before we can observe it.
|
|
447
|
-
h.plannedVotes = new Map([['acad', 1], ['r-1', 1], ['r-2', 1]])
|
|
448
|
-
await h.drain(3)
|
|
449
|
-
await sleep(80)
|
|
450
|
-
let met = null
|
|
451
|
-
for (let i = 0; i < 20 && !met; i++) {
|
|
452
|
-
const s = await h.callTool('vibe_v5_status', {})
|
|
453
|
-
if (s.meeting) met = s.meeting
|
|
454
|
-
else if (s.verify) { await h.drain(3) }
|
|
455
|
-
await sleep(40)
|
|
456
|
-
}
|
|
457
|
-
assert(!!met, 'the parked meeting convened once verification cleared (meeting=' + (met ? met.id : 'none') + ')')
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
// ---------- 13. compaction directives only when warranted ----------
|
|
461
|
-
console.log('\n[13] compaction directive appears only when warranted')
|
|
462
|
-
{
|
|
463
|
-
const h = makeHost({ pluginModule })
|
|
464
|
-
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
|
|
465
|
-
await h.settleSpawns()
|
|
466
|
-
await h.callTool('vibe_v5_set', { activityTimeoutMs: 30, compactAfterRounds: 50, compactThreshold: 99 })
|
|
467
|
-
// NOTE: never clear `h.wakes` here. Discarding a queued wake would leave that member
|
|
468
|
-
// marked busy forever (a real host always delivers its message), which would starve
|
|
469
|
-
// the heartbeat of candidates. `peekWakeOf` instead answers every OTHER member's wake.
|
|
470
|
-
const plain = await h.peekWakeOf('r-1', 4000)
|
|
471
|
-
assert(!!plain, 'a heartbeat/round wake reached r-1')
|
|
472
|
-
assert(!!plain && plain.text.indexOf('[CONTEXT COMPACT') === -1, 'no compaction directive on an ordinary round')
|
|
473
|
-
if (plain) h.fireEnd(plain.childId, { progress: 'x', contextPct: 10 })
|
|
474
|
-
|
|
475
|
-
// Let r-1 REPORT a full context window (the documented channel); the very next
|
|
476
|
-
// research round must then carry the directive.
|
|
477
|
-
const r1w = await h.peekWakeOf('r-1', 4000)
|
|
478
|
-
assert(!!r1w, 'r-1 got the next round')
|
|
479
|
-
if (r1w) h.fireEnd(r1w.childId, { progress: 'x', contextPct: 100 })
|
|
480
|
-
|
|
481
|
-
const forced = await h.peekWakeOf('r-1', 4000)
|
|
482
|
-
assert(!!forced && forced.text.indexOf('[CONTEXT COMPACT') !== -1,
|
|
483
|
-
'a member reporting 100% context DOES receive the compaction directive')
|
|
484
|
-
assert(!!forced && forced.text.indexOf('[核心规则]') !== -1,
|
|
485
|
-
'the compaction round also re-anchors the short core rules')
|
|
486
|
-
if (forced) h.fireEnd(forced.childId, { progress: '浓缩后的自述', compacted: true, contextPct: 15 })
|
|
487
|
-
|
|
488
|
-
// The directive must NOT come back on the following round (v4 §24.1-③ regression).
|
|
489
|
-
const later = await h.peekWakeOf('r-1', 4000)
|
|
490
|
-
assert(!!later, 'r-1 got a further round')
|
|
491
|
-
assert(!!later && later.text.indexOf('[CONTEXT COMPACT') === -1, 'the directive does NOT repeat on the next round (v4 §24.1-③ regression)')
|
|
492
|
-
if (later) h.fireEnd(later.childId, { progress: 'y', contextPct: 15 })
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
// ---------- 14. session-log REPLAY reproduces state ----------
|
|
496
|
-
console.log('\n[14] session-log replay reproduces the institute in a new session')
|
|
497
|
-
{
|
|
498
|
-
const h = makeHost({ pluginModule })
|
|
499
|
-
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
|
|
500
|
-
await h.settleSpawns()
|
|
501
|
-
const s0 = await h.callTool('vibe_v5_status', {})
|
|
502
|
-
const events = h.ROOT_SESSION._events.filter(e => String(e.type).indexOf('vibe5/') === 0)
|
|
503
|
-
assert(events.length > 0, 'the session log holds institute events (' + events.length + ')')
|
|
504
|
-
// replay those events into a brand-new session and read the state through a new root
|
|
505
|
-
const replayed = makeSession('sess-REPLAY', undefined)
|
|
506
|
-
replayed.header.cwd = h.WS
|
|
507
|
-
replayed._drive = (ev) => h.projections._drive(replayed, ev)
|
|
508
|
-
for (const e of events) replayed.append(e.type, e.data)
|
|
509
|
-
const ROOT2 = { id: 'sess-REPLAY', options: {}, session: replayed }
|
|
510
|
-
const s1 = await h.callTool('vibe_v5_status', {}, ROOT2)
|
|
511
|
-
assert(s1.members.length === s0.members.length, 'replay reproduced the roster (' + s1.members.length + ' vs ' + s0.members.length + ')')
|
|
512
|
-
assert(s1.quorum.m === s0.quorum.m, 'replay reproduced the quorum')
|
|
513
|
-
assert(s1.tasks.length === s0.tasks.length, 'replay reproduced the task board')
|
|
514
|
-
assert(s1.institute === s0.institute && s1.project === s0.project, 'replay reproduced the institute identity')
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
console.log('')
|
|
518
|
-
console.log('passed=' + passed + ' failed=' + failed)
|
|
519
|
-
if (failed) { console.error('FAILURES:'); for (const f of failures) console.error(' - ' + f); process.exit(1) }
|
|
520
|
-
console.log('ALL GREEN')
|
|
521
|
-
process.exit(0)
|
|
1
|
+
// ============================================================
|
|
2
|
+
// V5 ROUND-2 E2E — the behaviour paths the first suite never exercised:
|
|
3
|
+
// · the hardened JSON FALLBACK backend (host without sessionProjections)
|
|
4
|
+
// · a simulated PROCESS RESTART (fresh agent + fresh backend over the same workspace)
|
|
5
|
+
// · duplicate subagent/end idempotence
|
|
6
|
+
// · hire quotas (per-member and institute-wide)
|
|
7
|
+
// · quorumMode 'all-unanimous'
|
|
8
|
+
// · m recomputed when the roster shrinks (a voter is dismissed)
|
|
9
|
+
// · vibe_v5_wait (validation, no-progress shortcut, and real activity wake)
|
|
10
|
+
// · vibe_v5_read_library
|
|
11
|
+
// · id sanitisation against path traversal
|
|
12
|
+
// · configure guard while running
|
|
13
|
+
// · the verification watchdog abandoning a stuck round
|
|
14
|
+
// · meeting PARKED behind an in-flight verification, then resumed
|
|
15
|
+
// · compaction/keepalive directives appear only when they should
|
|
16
|
+
// · session-log REPLAY reproduces the institute state in a new session
|
|
17
|
+
// Run: node tests/e2e-v5-round2.test.mjs
|
|
18
|
+
// ============================================================
|
|
19
|
+
import { mkdtempSync, existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
20
|
+
import { tmpdir } from 'node:os'
|
|
21
|
+
import { join, dirname, isAbsolute } from 'node:path'
|
|
22
|
+
|
|
23
|
+
// V5_PLUGIN lets a sensitivity probe point this suite at a deliberately broken copy.
|
|
24
|
+
// Without it every probe against this suite silently tested the UNMUTATED plugin and was
|
|
25
|
+
// reported as a "detection" only because the probe's own spawn failed — i.e. the whole
|
|
26
|
+
// e2e block of the sensitivity audit was vacuous.
|
|
27
|
+
const PLUGIN = process.env.V5_PLUGIN
|
|
28
|
+
? new URL('file:///' + String(process.env.V5_PLUGIN).replace(/\\/g, '/'))
|
|
29
|
+
: new URL('../vibe-math-v5/vibe-math-v5.js', import.meta.url)
|
|
30
|
+
let passed = 0, failed = 0
|
|
31
|
+
const failures = []
|
|
32
|
+
const assert = (c, m) => { if (c) { passed++; console.log(' ok - ' + m) } else { failed++; failures.push(m); console.error(' FAIL - ' + m) } }
|
|
33
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
|
34
|
+
|
|
35
|
+
function makeProjectionRegistry() {
|
|
36
|
+
const units = new Map()
|
|
37
|
+
const cells = new Map()
|
|
38
|
+
const cellMap = (sess) => { const id = String(sess.id); let m = cells.get(id); if (!m) { m = new Map(); cells.set(id, m) } return m }
|
|
39
|
+
return {
|
|
40
|
+
register(def) { units.set(def.key, def); return () => { units.delete(def.key) } },
|
|
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
|
+
|
|
59
|
+
function makeSession(id, parentSession) {
|
|
60
|
+
const events = []
|
|
61
|
+
const s = {
|
|
62
|
+
id,
|
|
63
|
+
header: { version: 1, id, createdAt: Date.now(), cwd: null, parentSession, isSeeded: false },
|
|
64
|
+
inheritedEventCount: 0,
|
|
65
|
+
get seq() { return events.length },
|
|
66
|
+
append(type, data) { const ev = { type, data, seq: events.length, time: Date.now() }; events.push(ev); this._drive(ev); return ev },
|
|
67
|
+
deriveMessages() { return [] },
|
|
68
|
+
snapshotEvents(from) { return events.slice(from || 0) },
|
|
69
|
+
ownEvents() { return events.slice() },
|
|
70
|
+
_events: events,
|
|
71
|
+
_drive: null,
|
|
72
|
+
}
|
|
73
|
+
return s
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A fresh host+plugin instance. `withProjections:false` exercises the JSON fallback. */
|
|
77
|
+
function makeHost(opts) {
|
|
78
|
+
const o = opts || {}
|
|
79
|
+
const WS = o.ws || mkdtempSync(join(tmpdir(), 'vibe-v5r2-'))
|
|
80
|
+
const projections = o.withProjections === false ? undefined : makeProjectionRegistry()
|
|
81
|
+
const listeners = {}, toolRegs = [], commandRegs = [], spawns = [], wakes = [], interrupts = [], drains = []
|
|
82
|
+
const liveAgents = new Map()
|
|
83
|
+
|
|
84
|
+
const ctx = {
|
|
85
|
+
get(name) {
|
|
86
|
+
if (name === 'sessionProjections') return projections
|
|
87
|
+
if (name === 'sandboxPolicy') return undefined
|
|
88
|
+
if (name === 'compaction') return o.compaction
|
|
89
|
+
if (name === 'subprocess') {
|
|
90
|
+
return {
|
|
91
|
+
async spawn({ argv }) {
|
|
92
|
+
const script = argv[argv.length - 1] || ''
|
|
93
|
+
if (/New-Item/.test(script)) {
|
|
94
|
+
const re = /'((?:[^']|'')*)'/g; let m
|
|
95
|
+
while ((m = re.exec(script)) !== null) { const p = m[1].replace(/''/g, "'"); if (p && !/^-/.test(p)) mkdirSync(p, { recursive: true }) }
|
|
96
|
+
}
|
|
97
|
+
return { done: Promise.resolve({ exitCode: 0 }) }
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return undefined
|
|
102
|
+
},
|
|
103
|
+
on(e, fn) { (listeners[e] = listeners[e] || []).push(fn) },
|
|
104
|
+
effect(fn) { const d = fn(); return () => { if (typeof d === 'function') d() } },
|
|
105
|
+
logger: { info() {}, warn() {}, error() {} },
|
|
106
|
+
timeout(cb, ms) { const h = setTimeout(cb, ms); return () => clearTimeout(h) },
|
|
107
|
+
tools: { register(spec) { toolRegs.push(spec); return () => {} } },
|
|
108
|
+
commands: { register(spec) { commandRegs.push(spec); return () => {} } },
|
|
109
|
+
sessions: { async flush() { return true } },
|
|
110
|
+
subagents: {
|
|
111
|
+
list() { return ['spawn'] },
|
|
112
|
+
async startContinuable({ label, request }) {
|
|
113
|
+
const id = 'c' + (spawns.length + 1)
|
|
114
|
+
const childSession = makeSession(id, 'sess-A'); childSession.header.cwd = WS
|
|
115
|
+
liveAgents.set(id, { id, session: childSession, options: request && request.agentOptions })
|
|
116
|
+
spawns.push({ label, request, childId: id, persona: request && request.persona, ended: false })
|
|
117
|
+
if (projections) childSession._drive = (ev) => projections._drive(childSession, ev)
|
|
118
|
+
return { childId: id, messageId: 'm' + spawns.length }
|
|
119
|
+
},
|
|
120
|
+
async sendMessage(parent, childId, blocks) { wakes.push({ childId, blocks }); return 'w' + wakes.length },
|
|
121
|
+
interrupt(childId) { interrupts.push(childId) },
|
|
122
|
+
async drainContinuableChildren(parent, ids) { drains.push(...ids); for (const i of ids) liveAgents.delete(i) },
|
|
123
|
+
},
|
|
124
|
+
agents: { roots() { return [ROOT] }, get(id) { return id === ROOT.id ? ROOT : liveAgents.get(id) }, list() { return [ROOT, ...liveAgents.values()] } },
|
|
125
|
+
fs: {
|
|
126
|
+
async resolve(rel, o2) {
|
|
127
|
+
const b = (o2 && o2.cwd) || WS
|
|
128
|
+
const p = (typeof rel === 'string' && isAbsolute(rel)) ? rel.replace(/\//g, '\\') : join(b, ...String(rel).split('/'))
|
|
129
|
+
return { targetKey: p, displayPath: p }
|
|
130
|
+
},
|
|
131
|
+
async stat(t) { return existsSync(t.targetKey) ? { version: 'v1', type: 'file', size: 1 } : undefined },
|
|
132
|
+
async readText(t) { return readFileSync(t.targetKey, 'utf8') },
|
|
133
|
+
async writeText(t, c) { mkdirSync(dirname(t.targetKey), { recursive: true }); writeFileSync(t.targetKey, c, 'utf8') },
|
|
134
|
+
async listDir(t) { if (!existsSync(t.targetKey)) return []; return readdirSync(t.targetKey, { withFileTypes: true }).map(e => ({ name: e.name, type: e.isDirectory() ? 'directory' : 'file' })) },
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const ROOT_SESSION = makeSession('sess-A', undefined)
|
|
139
|
+
ROOT_SESSION.header.cwd = WS
|
|
140
|
+
const ROOT = { id: 'sess-A', options: {}, session: ROOT_SESSION, ctx: undefined }
|
|
141
|
+
if (projections) ROOT_SESSION._drive = (ev) => projections._drive(ROOT_SESSION, ev)
|
|
142
|
+
|
|
143
|
+
const mod = o.pluginModule
|
|
144
|
+
mod.apply(ctx)
|
|
145
|
+
|
|
146
|
+
async function callTool(name, args, agent) {
|
|
147
|
+
const spec = toolRegs.find(x => x.name === name)
|
|
148
|
+
if (!spec) throw new Error('no tool ' + name)
|
|
149
|
+
return JSON.parse(await spec.execute(args || {}, { agent: agent || ROOT }))
|
|
150
|
+
}
|
|
151
|
+
const childAgent = (childId) => liveAgents.get(childId) || { id: childId, session: { header: { parentSession: ROOT.id } } }
|
|
152
|
+
function fireEnd(childId, reply, stopReason) {
|
|
153
|
+
const blocks = reply === undefined ? [] : [{ type: 'text', text: '```json\n' + JSON.stringify(reply) + '\n```' }]
|
|
154
|
+
for (const h of (listeners['subagent/end'] || [])) h({ id: childId, runId: 'r', provider: 'spawn', local: true, stopReason: stopReason || 'completed', lastAssistantMessage: blocks })
|
|
155
|
+
}
|
|
156
|
+
const spawnOf = (m) => spawns.find(s => s.label.indexOf('vibe5 ' + m + ' ') !== -1)
|
|
157
|
+
const childOf = (m) => { const s = spawnOf(m); return s ? s.childId : m }
|
|
158
|
+
const labelOf = (cid) => { const s = spawns.find(x => x.childId === cid); const m = s ? /vibe5 (\S+) /.exec(s.label) : null; return m ? m[1] : '' }
|
|
159
|
+
const kindOf = (p) => /【入职首轮/.test(p) ? 'initial' : /【研究所会议/.test(p) ? 'meeting' : /【求真表决/.test(p) ? 'verify' : /【心跳检查/.test(p) ? 'checkpoint' : 'normal'
|
|
160
|
+
|
|
161
|
+
async function settleSpawns() {
|
|
162
|
+
for (const sp of spawns.slice()) {
|
|
163
|
+
if (sp.ended) continue
|
|
164
|
+
sp.ended = true
|
|
165
|
+
fireEnd(sp.childId, { progress: labelOf(sp.childId) + ':初始见解已记录。', solved: false, contextPct: 10 })
|
|
166
|
+
await sleep(15)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
let plannedVotes = null
|
|
170
|
+
let solvePlan = null
|
|
171
|
+
async function drain(budget = 60) {
|
|
172
|
+
let n = 0
|
|
173
|
+
while (wakes.length && n < budget) {
|
|
174
|
+
const w = wakes.shift()
|
|
175
|
+
const prompt = (w.blocks && w.blocks[0] && w.blocks[0].text) || ''
|
|
176
|
+
const kind = kindOf(prompt)
|
|
177
|
+
const who = labelOf(w.childId)
|
|
178
|
+
let reply
|
|
179
|
+
if (kind === 'verify') {
|
|
180
|
+
const tm = /"target"\s*:\s*"([^"]+)"/.exec(prompt)
|
|
181
|
+
const t = tm ? tm[1] : 'p-r-1'
|
|
182
|
+
const v = plannedVotes && plannedVotes.has(who) ? plannedVotes.get(who) : 0.5
|
|
183
|
+
reply = { verdict: { target: t, verdict: v, reason: who + ' 判断' }, contextPct: 20 }
|
|
184
|
+
} else if (kind === 'meeting') {
|
|
185
|
+
reply = { input: who + ':意见已述。', vote_solved: solvePlan === true, solved: solvePlan === true, contextPct: 20 }
|
|
186
|
+
} else {
|
|
187
|
+
reply = { progress: who + ':继续推进。', solved: false, contextPct: 20 }
|
|
188
|
+
}
|
|
189
|
+
fireEnd(w.childId, reply)
|
|
190
|
+
n++
|
|
191
|
+
await sleep(15)
|
|
192
|
+
}
|
|
193
|
+
return n
|
|
194
|
+
}
|
|
195
|
+
/** Pull the next queued wake for one member and return its prompt text.
|
|
196
|
+
* Wakes for OTHER members are answered benignly so the scheduler keeps cycling —
|
|
197
|
+
* otherwise a single unanswered heartbeat would stop all further passes. */
|
|
198
|
+
async function peekWakeOf(member, maxWaitMs) {
|
|
199
|
+
const t0 = Date.now()
|
|
200
|
+
while (Date.now() - t0 < (maxWaitMs || 3000)) {
|
|
201
|
+
const i = wakes.findIndex(w => labelOf(w.childId) === member)
|
|
202
|
+
if (i !== -1) {
|
|
203
|
+
const w = wakes.splice(i, 1)[0]
|
|
204
|
+
return { childId: w.childId, text: (w.blocks && w.blocks[0] && w.blocks[0].text) || '' }
|
|
205
|
+
}
|
|
206
|
+
if (wakes.length) {
|
|
207
|
+
const w = wakes.shift()
|
|
208
|
+
fireEnd(w.childId, { progress: '其他成员推进中', solved: false, contextPct: 10 })
|
|
209
|
+
}
|
|
210
|
+
await sleep(20)
|
|
211
|
+
}
|
|
212
|
+
return null
|
|
213
|
+
}
|
|
214
|
+
return { WS, ctx, ROOT, ROOT_SESSION, projections, 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
|
+
}
|
|
216
|
+
|
|
217
|
+
const pluginModule = await import(PLUGIN.href + '?t=' + Date.now())
|
|
218
|
+
const PROBLEM = '证明素数有无穷多个'
|
|
219
|
+
|
|
220
|
+
// ============================================================
|
|
221
|
+
console.log('-- V5 round-2 e2e --')
|
|
222
|
+
|
|
223
|
+
// ---------- 1. the hardened JSON FALLBACK backend ----------
|
|
224
|
+
console.log('\n[1] fallback backend (host without sessionProjections)')
|
|
225
|
+
{
|
|
226
|
+
const h = makeHost({ pluginModule, withProjections: false })
|
|
227
|
+
const st = await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
|
|
228
|
+
assert(st.ok === true, 'start works without sessionProjections (fallback path)')
|
|
229
|
+
const s0 = await h.callTool('vibe_v5_status', {})
|
|
230
|
+
assert(s0.backend === 'file', 'status reports the file backend (got ' + s0.backend + ')')
|
|
231
|
+
await h.settleSpawns()
|
|
232
|
+
const stFile = join(h.WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'State', 'institute.v5state.json')
|
|
233
|
+
// the state file is written lazily (deferred stringify chain) — give it a tick
|
|
234
|
+
for (let i = 0; i < 40 && !existsSync(stFile); i++) await sleep(10)
|
|
235
|
+
assert(existsSync(stFile), 'the fallback persisted the institute state to State/institute.v5state.json')
|
|
236
|
+
if (existsSync(stFile)) {
|
|
237
|
+
const parsed = JSON.parse(readFileSync(stFile, 'utf8'))
|
|
238
|
+
const inst = parsed.institutes['default::institute']
|
|
239
|
+
assert(!!inst && inst.members.length === 3, 'the persisted fallback state holds 1 academician + 2 researchers (got ' + (inst ? inst.members.length : 'none') + ')')
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---------- 2. simulated PROCESS RESTART ----------
|
|
244
|
+
console.log('\n[2] simulated process restart (fresh host, fresh backend, same workspace)')
|
|
245
|
+
{
|
|
246
|
+
const WS = mkdtempSync(join(tmpdir(), 'vibe-v5r2-restart-'))
|
|
247
|
+
const h1 = makeHost({ pluginModule, withProjections: false, ws: WS })
|
|
248
|
+
await h1.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
|
|
249
|
+
await h1.settleSpawns()
|
|
250
|
+
const stFile = join(WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'State', 'institute.v5state.json')
|
|
251
|
+
for (let i = 0; i < 40 && !existsSync(stFile); i++) await sleep(10)
|
|
252
|
+
assert(existsSync(stFile), 'first process persisted its state')
|
|
253
|
+
|
|
254
|
+
// a genuinely fresh host = new backend instance, empty in-memory state
|
|
255
|
+
const h2 = makeHost({ pluginModule, withProjections: false, ws: WS })
|
|
256
|
+
const s2 = await h2.callTool('vibe_v5_status', {})
|
|
257
|
+
assert(s2.members.length === 3, 'a FRESH host reads the persisted roster back (got ' + s2.members.length + ' members)')
|
|
258
|
+
const res = await h2.callTool('vibe_v5_resume', {})
|
|
259
|
+
assert(res.ok === true, 'resume works on a fresh host over persisted state (got ' + JSON.stringify(res).slice(0, 120) + ')')
|
|
260
|
+
assert(h2.spawns.length === 3, 'resume re-spawned the 3 members whose child sessions no longer exist (got ' + h2.spawns.length + ')')
|
|
261
|
+
assert(s2.quorum && s2.quorum.m === 3, 'the recovered roster recomputes m correctly (m=' + (s2.quorum ? s2.quorum.m : '?') + ')')
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---------- 3. duplicate subagent/end is idempotent ----------
|
|
265
|
+
console.log('\n[3] duplicate subagent/end idempotence')
|
|
266
|
+
{
|
|
267
|
+
const h = makeHost({ pluginModule })
|
|
268
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
|
|
269
|
+
const cid = h.childOf('r-1')
|
|
270
|
+
// settle the founding turn normally, then REPLAY the exact same end event
|
|
271
|
+
const before = h.ROOT_SESSION._events.length
|
|
272
|
+
h.fireEnd(cid, { progress: '唯一一次', record: [{ kind: 'proposition', id: 'dup-x', statement: 's', value: 0.5, motive: 'm', p: 0.5 }] })
|
|
273
|
+
await sleep(40)
|
|
274
|
+
const afterFirst = h.ROOT_SESSION._events.length
|
|
275
|
+
h.fireEnd(cid, { progress: '重复投递', record: [{ kind: 'proposition', id: 'dup-x', statement: 's', value: 0.5, motive: 'm', p: 0.5 }] })
|
|
276
|
+
await sleep(40)
|
|
277
|
+
const afterSecond = h.ROOT_SESSION._events.length
|
|
278
|
+
assert(afterSecond === afterFirst, 'a replayed end commits NOTHING new (' + afterFirst + ' -> ' + afterSecond + ' events)')
|
|
279
|
+
assert(afterFirst > before, 'the first end did commit state')
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---------- 4. hire quotas ----------
|
|
283
|
+
console.log('\n[4] hire quotas')
|
|
284
|
+
{
|
|
285
|
+
const h = makeHost({ pluginModule })
|
|
286
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
|
|
287
|
+
await h.settleSpawns()
|
|
288
|
+
await h.callTool('vibe_v5_set', { maxTempPerMember: 2, maxTempTotal: 3 })
|
|
289
|
+
const a = await h.callTool('vibe_v5_hire', { purpose: 'p1', initial_task: 't1' }, h.childAgent(h.childOf('r-1')))
|
|
290
|
+
const b = await h.callTool('vibe_v5_hire', { purpose: 'p2', initial_task: 't2' }, h.childAgent(h.childOf('r-1')))
|
|
291
|
+
const c = await h.callTool('vibe_v5_hire', { purpose: 'p3', initial_task: 't3' }, h.childAgent(h.childOf('r-1')))
|
|
292
|
+
await h.settleSpawns()
|
|
293
|
+
assert(a.ok && b.ok, 'the first two hires succeed (cap 2 per member)')
|
|
294
|
+
assert(c.ok === false && c.code === 'V5_MEMBER_LIMIT', 'the third hire is refused by maxTempPerMember (' + (c.code || 'no code') + ')')
|
|
295
|
+
const d = await h.callTool('vibe_v5_hire', { purpose: 'p4', initial_task: 't4' }, h.childAgent(h.childOf('acad')))
|
|
296
|
+
assert(d.ok === true, 'the academician can still hire (its own per-member budget)')
|
|
297
|
+
const e = await h.callTool('vibe_v5_hire', { purpose: 'p5', initial_task: 't5' }, h.childAgent(h.childOf('acad')))
|
|
298
|
+
assert(e.ok === false && e.code === 'V5_MEMBER_LIMIT', 'the institute-wide cap maxTempTotal=3 then refuses (' + (e.code || 'no code') + ')')
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ---------- 5. quorumMode all-unanimous ----------
|
|
302
|
+
console.log('\n[5] quorumMode all-unanimous')
|
|
303
|
+
{
|
|
304
|
+
const h = makeHost({ pluginModule })
|
|
305
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
|
|
306
|
+
await h.settleSpawns()
|
|
307
|
+
await h.callTool('vibe_v5_set', { quorumMode: 'all-unanimous' })
|
|
308
|
+
await h.callTool('vibe_v5_propose_verify', { target: 'p-r-1', kind: 'proposition', reason: 'r' }, h.childAgent(h.childOf('r-1')))
|
|
309
|
+
const s = await h.callTool('vibe_v5_status', {})
|
|
310
|
+
assert(!!s.verify, 'verification started in all-unanimous mode')
|
|
311
|
+
// only 2 of 3 voters assert true; the third abstains -> must NOT verify
|
|
312
|
+
h.plannedVotes = new Map([['acad', 1], ['r-1', 1], ['r-2', 0.5]])
|
|
313
|
+
await h.drain(6)
|
|
314
|
+
let s2 = await h.callTool('vibe_v5_status', {})
|
|
315
|
+
assert(s2.verified.indexOf('p-r-1') === -1, 'all-unanimous: 2/3 true with one abstention does NOT verify')
|
|
316
|
+
// now everyone asserts true -> verifies
|
|
317
|
+
h.plannedVotes = new Map([['acad', 1], ['r-1', 1], ['r-2', 1]])
|
|
318
|
+
await h.drain(8)
|
|
319
|
+
let s3 = await h.callTool('vibe_v5_status', {})
|
|
320
|
+
if (!s3.verified.includes('p-r-1')) { h.plannedVotes = new Map([['acad', 1], ['r-1', 1], ['r-2', 1]]); await h.drain(8); s3 = await h.callTool('vibe_v5_status', {}) }
|
|
321
|
+
assert(s3.verified.indexOf('p-r-1') !== -1, 'all-unanimous: every voter true DOES verify')
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ---------- 6. m recomputed when the roster shrinks ----------
|
|
325
|
+
console.log('\n[6] quorum recomputed when a voter is dismissed')
|
|
326
|
+
{
|
|
327
|
+
const h = makeHost({ pluginModule })
|
|
328
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 3 })
|
|
329
|
+
await h.settleSpawns()
|
|
330
|
+
const s0 = await h.callTool('vibe_v5_status', {})
|
|
331
|
+
assert(s0.quorum.m === 3 && s0.quorum.voterCount === 4, 'baseline m=3 over 4 voters')
|
|
332
|
+
await h.callTool('vibe_v5_remove_researcher', { id: 'r-3' })
|
|
333
|
+
await h.callTool('vibe_v5_remove_researcher', { id: 'r-2' })
|
|
334
|
+
const s1 = await h.callTool('vibe_v5_status', {})
|
|
335
|
+
assert(s1.quorum.voterCount === 2, 'two researchers dismissed -> 2 voters (got ' + s1.quorum.voterCount + ')')
|
|
336
|
+
assert(s1.quorum.m === 2, 'm tracks the roster down to min(cap=3, P=2) = 2 (got ' + s1.quorum.m + ')')
|
|
337
|
+
assert(h.drains.length === 2, 'both dismissed researchers had their resident child released')
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ---------- 7. vibe_v5_wait ----------
|
|
341
|
+
console.log('\n[7] vibe_v5_wait')
|
|
342
|
+
{
|
|
343
|
+
const h = makeHost({ pluginModule })
|
|
344
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
|
|
345
|
+
await h.settleSpawns()
|
|
346
|
+
const bad = await h.callTool('vibe_v5_wait', { timeout_ms: 5000 }, h.childAgent(h.childOf('r-1')))
|
|
347
|
+
assert(bad.ok === false || bad.code === 'V5_INVALID_TIMEOUT' || String(bad.error || '').indexOf('10000') !== -1,
|
|
348
|
+
'a timeout below 10000ms is rejected (' + JSON.stringify(bad).slice(0, 90) + ')')
|
|
349
|
+
// a real wait must be woken by genuine institute activity, not by polling
|
|
350
|
+
const waitP = h.callTool('vibe_v5_wait', { timeout_ms: 10000 }, h.childAgent(h.childOf('r-1')))
|
|
351
|
+
await sleep(30)
|
|
352
|
+
await h.callTool('vibe_v5_say', { text: '有人说话就会唤醒等待者' }, h.childAgent(h.childOf('acad')))
|
|
353
|
+
const woke = await Promise.race([waitP, sleep(1500).then(() => ({ timedOut: 'NEVER-RESOLVED' }))])
|
|
354
|
+
assert(woke.timedOut === false, 'wait is woken by real activity rather than timing out (' + JSON.stringify(woke).slice(0, 80) + ')')
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---------- 8. read_library ----------
|
|
358
|
+
console.log('\n[8] vibe_v5_read_library')
|
|
359
|
+
{
|
|
360
|
+
const h = makeHost({ pluginModule })
|
|
361
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
|
|
362
|
+
await h.callTool('vibe_v5_record_proposition', { id: 'p-x', statement: 'S', value: 0.7, motive: 'M', p: 0.9 }, h.childAgent(h.childOf('r-1')))
|
|
363
|
+
h.fireEnd(h.childOf('r-1'), { progress: '进展正文', solved: false })
|
|
364
|
+
await sleep(30)
|
|
365
|
+
const lib = await h.callTool('vibe_v5_read_library', { member: 'r-1' }, h.childAgent(h.childOf('acad')))
|
|
366
|
+
assert(lib.ok === true && lib.count > 0, 'read_library returns entries (got ' + lib.count + ')')
|
|
367
|
+
const hasProp = (lib.items || []).some(i => i.kind === 'proposition' && i.id === 'p-x')
|
|
368
|
+
const hasProg = (lib.items || []).some(i => i.kind === 'progress' && /进展正文/.test(i.text))
|
|
369
|
+
assert(hasProp, 'read_library surfaces another member\'s recorded card (read-only cross-read)')
|
|
370
|
+
assert(hasProg, 'read_library surfaces another member\'s progress log')
|
|
371
|
+
const one = await h.callTool('vibe_v5_read_library', { member: 'r-1', kind: 'proposition', id: 'p-x' })
|
|
372
|
+
assert(one.count === 1 && /- ID: p-x/.test(one.items[0].text), 'read_library can fetch exactly one card by id')
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ---------- 9. id sanitisation (path traversal) ----------
|
|
376
|
+
console.log('\n[9] id sanitisation against path traversal')
|
|
377
|
+
{
|
|
378
|
+
const h = makeHost({ pluginModule })
|
|
379
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
|
|
380
|
+
await h.settleSpawns()
|
|
381
|
+
const r = await h.callTool('vibe_v5_propose_verify', { target: '../../../../evil', kind: 'proposition', reason: 'x' }, h.childAgent(h.childOf('r-1')))
|
|
382
|
+
assert(r.ok === true, 'a traversal-shaped target is accepted but sanitised')
|
|
383
|
+
const s = await h.callTool('vibe_v5_status', {})
|
|
384
|
+
const t = s.verify ? s.verify.target : (s.verifyQueue[0] || '')
|
|
385
|
+
assert(!!t && t.indexOf('/') === -1 && t.indexOf('..') === -1, 'the stored target id contains no separators or ".." (got "' + t + '")')
|
|
386
|
+
h.plannedVotes = new Map([['acad', 1], ['r-1', 1]])
|
|
387
|
+
await h.drain(8)
|
|
388
|
+
const base = join(h.WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute')
|
|
389
|
+
assert(!existsSync(join(h.WS, 'evil')) && !existsSync(join(h.WS, 'VibeMath', 'evil')), 'nothing was written outside the institute tree')
|
|
390
|
+
assert(existsSync(base), 'the institute tree itself is intact')
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ---------- 10. configure guard while running ----------
|
|
394
|
+
console.log('\n[10] configure guard')
|
|
395
|
+
{
|
|
396
|
+
const h = makeHost({ pluginModule })
|
|
397
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
|
|
398
|
+
const c = await h.callTool('vibe_v5_configure', { institute: 'other' })
|
|
399
|
+
assert(c.ok === false && c.code === 'V5_INSTITUTE_STATE', 'reconfiguring while running is refused (' + (c.code || '') + ')')
|
|
400
|
+
const after = await h.callTool('vibe_v5_status', {})
|
|
401
|
+
assert(after.institute === 'institute', 'the institute was not split onto a second tree')
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ---------- 11. verification watchdog ----------
|
|
405
|
+
console.log('\n[11] verification watchdog abandons a stuck round')
|
|
406
|
+
{
|
|
407
|
+
const h = makeHost({ pluginModule })
|
|
408
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
|
|
409
|
+
await h.settleSpawns()
|
|
410
|
+
await h.callTool('vibe_v5_set', { activityTimeoutMs: 40 }) // recoverStallMs = 80ms
|
|
411
|
+
await h.callTool('vibe_v5_propose_verify', { target: 'p-r-1', kind: 'proposition', reason: 'x' }, h.childAgent(h.childOf('r-1')))
|
|
412
|
+
const s0 = await h.callTool('vibe_v5_status', {})
|
|
413
|
+
assert(!!s0.verify, 'verification is in flight')
|
|
414
|
+
h.wakes.length = 0 // deliberately never answer the voters
|
|
415
|
+
await sleep(200) // past recoverStallMs
|
|
416
|
+
// any scheduling pass re-runs the watchdog
|
|
417
|
+
await h.callTool('vibe_v5_say', { text: 'ping' }, h.childAgent(h.childOf('r-1')))
|
|
418
|
+
h.fireEnd(h.childOf('r-1'), { solved: false })
|
|
419
|
+
await sleep(80)
|
|
420
|
+
let cleared = false
|
|
421
|
+
for (let i = 0; i < 20 && !cleared; i++) {
|
|
422
|
+
const s = await h.callTool('vibe_v5_status', {})
|
|
423
|
+
if (!s.verify) cleared = true
|
|
424
|
+
else { h.fireEnd(h.childOf('r-1'), { solved: false }); await sleep(40) }
|
|
425
|
+
}
|
|
426
|
+
const s1 = await h.callTool('vibe_v5_status', {})
|
|
427
|
+
assert(!s1.verify, 'the stuck verification was abandoned instead of blocking forever')
|
|
428
|
+
assert(s1.undecided.length >= 1 || s1.verified.indexOf('p-r-1') === -1, 'the abandoned object stayed 未定论 rather than being forced true/false')
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ---------- 12. meeting parked behind a verification ----------
|
|
432
|
+
console.log('\n[12] meeting parked behind an in-flight verification')
|
|
433
|
+
{
|
|
434
|
+
const h = makeHost({ pluginModule })
|
|
435
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
|
|
436
|
+
await h.settleSpawns()
|
|
437
|
+
await h.callTool('vibe_v5_propose_verify', { target: 'p-r-1', kind: 'proposition', reason: 'x' }, h.childAgent(h.childOf('r-1')))
|
|
438
|
+
const s0 = await h.callTool('vibe_v5_status', {})
|
|
439
|
+
assert(!!s0.verify, 'a verification is in flight')
|
|
440
|
+
const m = await h.callTool('vibe_v5_meeting', { agenda: '协调下一步', kind: 'sync' }, h.childAgent(h.childOf('acad')))
|
|
441
|
+
assert(m.ok === true && m.parked === true, 'the meeting is PARKED rather than preempting verification (' + JSON.stringify(m).slice(0, 90) + ')')
|
|
442
|
+
const s1 = await h.callTool('vibe_v5_status', {})
|
|
443
|
+
assert(!!s1.parkedMeeting, 'status exposes the parked meeting')
|
|
444
|
+
// settle the verification, then the parked meeting must actually convene. Drain only
|
|
445
|
+
// the verification's own asks: a greedy drain would also consume the meeting's asks
|
|
446
|
+
// and finalize the meeting before we can observe it.
|
|
447
|
+
h.plannedVotes = new Map([['acad', 1], ['r-1', 1], ['r-2', 1]])
|
|
448
|
+
await h.drain(3)
|
|
449
|
+
await sleep(80)
|
|
450
|
+
let met = null
|
|
451
|
+
for (let i = 0; i < 20 && !met; i++) {
|
|
452
|
+
const s = await h.callTool('vibe_v5_status', {})
|
|
453
|
+
if (s.meeting) met = s.meeting
|
|
454
|
+
else if (s.verify) { await h.drain(3) }
|
|
455
|
+
await sleep(40)
|
|
456
|
+
}
|
|
457
|
+
assert(!!met, 'the parked meeting convened once verification cleared (meeting=' + (met ? met.id : 'none') + ')')
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ---------- 13. compaction directives only when warranted ----------
|
|
461
|
+
console.log('\n[13] compaction directive appears only when warranted')
|
|
462
|
+
{
|
|
463
|
+
const h = makeHost({ pluginModule })
|
|
464
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
|
|
465
|
+
await h.settleSpawns()
|
|
466
|
+
await h.callTool('vibe_v5_set', { activityTimeoutMs: 30, compactAfterRounds: 50, compactThreshold: 99 })
|
|
467
|
+
// NOTE: never clear `h.wakes` here. Discarding a queued wake would leave that member
|
|
468
|
+
// marked busy forever (a real host always delivers its message), which would starve
|
|
469
|
+
// the heartbeat of candidates. `peekWakeOf` instead answers every OTHER member's wake.
|
|
470
|
+
const plain = await h.peekWakeOf('r-1', 4000)
|
|
471
|
+
assert(!!plain, 'a heartbeat/round wake reached r-1')
|
|
472
|
+
assert(!!plain && plain.text.indexOf('[CONTEXT COMPACT') === -1, 'no compaction directive on an ordinary round')
|
|
473
|
+
if (plain) h.fireEnd(plain.childId, { progress: 'x', contextPct: 10 })
|
|
474
|
+
|
|
475
|
+
// Let r-1 REPORT a full context window (the documented channel); the very next
|
|
476
|
+
// research round must then carry the directive.
|
|
477
|
+
const r1w = await h.peekWakeOf('r-1', 4000)
|
|
478
|
+
assert(!!r1w, 'r-1 got the next round')
|
|
479
|
+
if (r1w) h.fireEnd(r1w.childId, { progress: 'x', contextPct: 100 })
|
|
480
|
+
|
|
481
|
+
const forced = await h.peekWakeOf('r-1', 4000)
|
|
482
|
+
assert(!!forced && forced.text.indexOf('[CONTEXT COMPACT') !== -1,
|
|
483
|
+
'a member reporting 100% context DOES receive the compaction directive')
|
|
484
|
+
assert(!!forced && forced.text.indexOf('[核心规则]') !== -1,
|
|
485
|
+
'the compaction round also re-anchors the short core rules')
|
|
486
|
+
if (forced) h.fireEnd(forced.childId, { progress: '浓缩后的自述', compacted: true, contextPct: 15 })
|
|
487
|
+
|
|
488
|
+
// The directive must NOT come back on the following round (v4 §24.1-③ regression).
|
|
489
|
+
const later = await h.peekWakeOf('r-1', 4000)
|
|
490
|
+
assert(!!later, 'r-1 got a further round')
|
|
491
|
+
assert(!!later && later.text.indexOf('[CONTEXT COMPACT') === -1, 'the directive does NOT repeat on the next round (v4 §24.1-③ regression)')
|
|
492
|
+
if (later) h.fireEnd(later.childId, { progress: 'y', contextPct: 15 })
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ---------- 14. session-log REPLAY reproduces state ----------
|
|
496
|
+
console.log('\n[14] session-log replay reproduces the institute in a new session')
|
|
497
|
+
{
|
|
498
|
+
const h = makeHost({ pluginModule })
|
|
499
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
|
|
500
|
+
await h.settleSpawns()
|
|
501
|
+
const s0 = await h.callTool('vibe_v5_status', {})
|
|
502
|
+
const events = h.ROOT_SESSION._events.filter(e => String(e.type).indexOf('vibe5/') === 0)
|
|
503
|
+
assert(events.length > 0, 'the session log holds institute events (' + events.length + ')')
|
|
504
|
+
// replay those events into a brand-new session and read the state through a new root
|
|
505
|
+
const replayed = makeSession('sess-REPLAY', undefined)
|
|
506
|
+
replayed.header.cwd = h.WS
|
|
507
|
+
replayed._drive = (ev) => h.projections._drive(replayed, ev)
|
|
508
|
+
for (const e of events) replayed.append(e.type, e.data)
|
|
509
|
+
const ROOT2 = { id: 'sess-REPLAY', options: {}, session: replayed }
|
|
510
|
+
const s1 = await h.callTool('vibe_v5_status', {}, ROOT2)
|
|
511
|
+
assert(s1.members.length === s0.members.length, 'replay reproduced the roster (' + s1.members.length + ' vs ' + s0.members.length + ')')
|
|
512
|
+
assert(s1.quorum.m === s0.quorum.m, 'replay reproduced the quorum')
|
|
513
|
+
assert(s1.tasks.length === s0.tasks.length, 'replay reproduced the task board')
|
|
514
|
+
assert(s1.institute === s0.institute && s1.project === s0.project, 'replay reproduced the institute identity')
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
console.log('')
|
|
518
|
+
console.log('passed=' + passed + ' failed=' + failed)
|
|
519
|
+
if (failed) { console.error('FAILURES:'); for (const f of failures) console.error(' - ' + f); process.exit(1) }
|
|
520
|
+
console.log('ALL GREEN')
|
|
521
|
+
process.exit(0)
|