@mindrian_os/install 1.13.0-beta.16 → 1.13.0-beta.17
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +10 -0
- package/commands/file-meeting.md +2 -0
- package/commands/grade.md +2 -0
- package/commands/mva-brief.md +56 -0
- package/commands/mva-option.md +89 -0
- package/commands/new-project.md +2 -0
- package/commands/onboard.md +2 -0
- package/hooks/hooks.json +9 -0
- package/lib/agents/mva/brain-classic-traps.cjs +77 -0
- package/lib/agents/mva/brain-cross-domain.cjs +79 -0
- package/lib/agents/mva/brain-similar-ventures.cjs +93 -0
- package/lib/agents/mva/dashboard-graph-neighborhood.cjs +72 -0
- package/lib/agents/mva/index.cjs +42 -0
- package/lib/agents/mva/six-hats-red-black.cjs +137 -0
- package/lib/agents/mva/tavily-funding-scan.cjs +147 -0
- package/lib/agents/mva/test-all-six-agents.cjs +467 -0
- package/lib/conversation/operator.cjs +64 -0
- package/lib/conversation/operator.test.cjs +160 -0
- package/lib/core/mva-agent-contract.cjs +170 -0
- package/lib/core/mva-agent-contract.test.cjs +169 -0
- package/lib/core/mva-budget.cjs +75 -0
- package/lib/core/mva-budget.test.cjs +68 -0
- package/lib/core/mva-classifier.cjs +370 -0
- package/lib/core/mva-classifier.test.cjs +248 -0
- package/lib/core/mva-deck-builder.cjs +452 -0
- package/lib/core/mva-deck-builder.test.cjs +287 -0
- package/lib/core/mva-detect.smoke.test.cjs +197 -0
- package/lib/core/mva-dispatcher.cjs +110 -0
- package/lib/core/mva-dispatcher.test.cjs +216 -0
- package/lib/core/mva-option-router.cjs +292 -0
- package/lib/core/mva-option-router.test.cjs +483 -0
- package/lib/core/mva-orchestrator.cjs +324 -0
- package/lib/core/mva-orchestrator.test.cjs +908 -0
- package/lib/core/mva-progressive-renderer.cjs +194 -0
- package/lib/core/mva-progressive-renderer.test.cjs +157 -0
- package/lib/core/mva-rule-linter.cjs +213 -0
- package/lib/core/mva-rule-linter.test.cjs +336 -0
- package/lib/core/mva-state.cjs +159 -0
- package/lib/core/mva-telemetry.cjs +170 -0
- package/lib/core/mva-telemetry.test.cjs +196 -0
- package/lib/core/mva-vercel-deploy.cjs +168 -0
- package/lib/core/mva-vercel-deploy.test.cjs +239 -0
- package/lib/core/navigation/dashboard-helpers.cjs +145 -0
- package/lib/core/navigation.cjs +11 -0
- package/lib/core/resolve-vercel-key.cjs +107 -0
- package/lib/core/resolve-vercel-key.test.cjs +137 -0
- package/lib/memory/run-feynman-tests.cjs +27 -0
- package/package.json +1 -1
- package/skills/mva-pipeline/SKILL.md +129 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2026 Mindrian. BSL 1.1.
|
|
3
|
+
*
|
|
4
|
+
* Phase 118-05 Plan 05 -- operator helper tests for transitionViaMVAOption.
|
|
5
|
+
*
|
|
6
|
+
* This is the FIRST test file for lib/conversation/operator.cjs. Plan 118-05
|
|
7
|
+
* Task 1 adds the transitionViaMVAOption(roomDir, optionId) helper -- a thin
|
|
8
|
+
* additive wrapper around the existing transition() function. The helper is
|
|
9
|
+
* called by lib/core/mva-option-router.cjs when the user picks 1, 2, or 3
|
|
10
|
+
* from the 30-second MVA brief footer.
|
|
11
|
+
*
|
|
12
|
+
* The 9 existing transition rules are NOT modified. The OPERATORS array is
|
|
13
|
+
* NOT modified. This is a small additive surface.
|
|
14
|
+
*
|
|
15
|
+
* Option 1 -> JUST_TALK (via 'manual_reset' trigger; existing ANY rule)
|
|
16
|
+
* Option 2 -> NO transition (stub per binding decision B6 OPTION A; Phase 119
|
|
17
|
+
* will wire the BUILD_ROOM path)
|
|
18
|
+
* Option 3 -> METHODOLOGY (via 'mos_command' trigger; existing ANY rule)
|
|
19
|
+
*
|
|
20
|
+
* Pure CJS, node built-ins only.
|
|
21
|
+
*/
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
const test = require('node:test');
|
|
25
|
+
const assert = require('node:assert/strict');
|
|
26
|
+
const fs = require('node:fs');
|
|
27
|
+
const path = require('node:path');
|
|
28
|
+
const os = require('node:os');
|
|
29
|
+
|
|
30
|
+
const operator = require('./operator.cjs');
|
|
31
|
+
|
|
32
|
+
// Hermetic temp roomDir per test (so we never pollute real rooms).
|
|
33
|
+
function makeTempRoom() {
|
|
34
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'op-mva-option-'));
|
|
35
|
+
return dir;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function cleanupTempRoom(dir) {
|
|
39
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) {}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------- Task 1: transitionViaMVAOption helper ----------
|
|
43
|
+
|
|
44
|
+
test('Test 1: transitionViaMVAOption(roomDir, 1) -> JUST_TALK', () => {
|
|
45
|
+
const roomDir = makeTempRoom();
|
|
46
|
+
try {
|
|
47
|
+
// Move to a non-JUST_TALK state first so the transition is meaningful.
|
|
48
|
+
// From the default JUST_TALK, step to EXPLORE_CAPTURE.
|
|
49
|
+
const pre = operator.transition(roomDir, 'EXPLORE_CAPTURE', 'user_message');
|
|
50
|
+
assert.equal(pre.success, true);
|
|
51
|
+
assert.equal(operator.getCurrent(roomDir).current, 'EXPLORE_CAPTURE');
|
|
52
|
+
|
|
53
|
+
const result = operator.transitionViaMVAOption(roomDir, 1);
|
|
54
|
+
assert.equal(result.ok, true, 'option 1 should succeed');
|
|
55
|
+
assert.equal(result.new_state, 'JUST_TALK', 'option 1 must end in JUST_TALK');
|
|
56
|
+
assert.equal(result.from, 'EXPLORE_CAPTURE', 'from must be the previous state');
|
|
57
|
+
assert.equal(operator.getCurrent(roomDir).current, 'JUST_TALK');
|
|
58
|
+
} finally {
|
|
59
|
+
cleanupTempRoom(roomDir);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('Test 2: transitionViaMVAOption(roomDir, 2) -> no transition (stub)', () => {
|
|
64
|
+
const roomDir = makeTempRoom();
|
|
65
|
+
try {
|
|
66
|
+
// Move to METHODOLOGY first so we can verify "no transition" preserves it.
|
|
67
|
+
const pre = operator.transition(roomDir, 'METHODOLOGY', 'mos_command');
|
|
68
|
+
assert.equal(pre.success, true);
|
|
69
|
+
assert.equal(operator.getCurrent(roomDir).current, 'METHODOLOGY');
|
|
70
|
+
|
|
71
|
+
const result = operator.transitionViaMVAOption(roomDir, 2);
|
|
72
|
+
assert.equal(result.ok, true, 'option 2 returns ok:true (stub but valid)');
|
|
73
|
+
assert.equal(result.no_transition, true, 'option 2 must flag no_transition');
|
|
74
|
+
assert.equal(result.reason, 'option_2_stub', 'reason must be option_2_stub');
|
|
75
|
+
assert.equal(result.new_state, 'METHODOLOGY', 'state unchanged after stub option');
|
|
76
|
+
assert.equal(operator.getCurrent(roomDir).current, 'METHODOLOGY');
|
|
77
|
+
} finally {
|
|
78
|
+
cleanupTempRoom(roomDir);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('Test 3: transitionViaMVAOption(roomDir, 3) -> METHODOLOGY', () => {
|
|
83
|
+
const roomDir = makeTempRoom();
|
|
84
|
+
try {
|
|
85
|
+
// From JUST_TALK default, option 3 should jump straight to METHODOLOGY
|
|
86
|
+
// via the ANY -> METHODOLOGY 'mos_command' rule.
|
|
87
|
+
assert.equal(operator.getCurrent(roomDir).current, 'JUST_TALK');
|
|
88
|
+
|
|
89
|
+
const result = operator.transitionViaMVAOption(roomDir, 3);
|
|
90
|
+
assert.equal(result.ok, true, 'option 3 should succeed');
|
|
91
|
+
assert.equal(result.new_state, 'METHODOLOGY', 'option 3 must end in METHODOLOGY');
|
|
92
|
+
assert.equal(result.from, 'JUST_TALK', 'from must be the previous state');
|
|
93
|
+
assert.equal(operator.getCurrent(roomDir).current, 'METHODOLOGY');
|
|
94
|
+
} finally {
|
|
95
|
+
cleanupTempRoom(roomDir);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('Test 4: transitionViaMVAOption(roomDir, 99) -> invalid_option error', () => {
|
|
100
|
+
const roomDir = makeTempRoom();
|
|
101
|
+
try {
|
|
102
|
+
const before = operator.getCurrent(roomDir).current;
|
|
103
|
+
const result = operator.transitionViaMVAOption(roomDir, 99);
|
|
104
|
+
assert.equal(result.ok, false);
|
|
105
|
+
assert.equal(result.error, 'invalid_option');
|
|
106
|
+
assert.deepEqual(result.valid_options, [1, 2, 3]);
|
|
107
|
+
// State unchanged
|
|
108
|
+
assert.equal(operator.getCurrent(roomDir).current, before);
|
|
109
|
+
} finally {
|
|
110
|
+
cleanupTempRoom(roomDir);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('Test 4b: transitionViaMVAOption rejects non-integer optionId', () => {
|
|
115
|
+
const roomDir = makeTempRoom();
|
|
116
|
+
try {
|
|
117
|
+
const r1 = operator.transitionViaMVAOption(roomDir, '1');
|
|
118
|
+
assert.equal(r1.ok, false, 'string "1" is rejected (strict int check)');
|
|
119
|
+
assert.equal(r1.error, 'invalid_option');
|
|
120
|
+
|
|
121
|
+
const r2 = operator.transitionViaMVAOption(roomDir, 0);
|
|
122
|
+
assert.equal(r2.ok, false, '0 is rejected');
|
|
123
|
+
|
|
124
|
+
const r3 = operator.transitionViaMVAOption(roomDir, null);
|
|
125
|
+
assert.equal(r3.ok, false, 'null is rejected');
|
|
126
|
+
} finally {
|
|
127
|
+
cleanupTempRoom(roomDir);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ---------- Task 1 Test 5: no regression on OPERATORS or rules ----------
|
|
132
|
+
|
|
133
|
+
test('Test 5a: OPERATORS array is unchanged (5 entries; canonical order)', () => {
|
|
134
|
+
assert.deepEqual(operator.OPERATORS, [
|
|
135
|
+
'JUST_TALK',
|
|
136
|
+
'EXPLORE_CAPTURE',
|
|
137
|
+
'BUILD_ROOM',
|
|
138
|
+
'METHODOLOGY',
|
|
139
|
+
'DECISION_GATE',
|
|
140
|
+
]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('Test 5b: TRANSITION_RULES has 9 entries (Phase 99 D-08 invariant preserved)', () => {
|
|
144
|
+
assert.equal(operator.TRANSITION_RULES.length, 9);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('Test 5c: existing transition rules still work (JUST_TALK -> EXPLORE_CAPTURE on user_message)', () => {
|
|
148
|
+
const roomDir = makeTempRoom();
|
|
149
|
+
try {
|
|
150
|
+
const r = operator.transition(roomDir, 'EXPLORE_CAPTURE', 'user_message');
|
|
151
|
+
assert.equal(r.success, true);
|
|
152
|
+
assert.equal(r.current, 'EXPLORE_CAPTURE');
|
|
153
|
+
} finally {
|
|
154
|
+
cleanupTempRoom(roomDir);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('Test 5d: transitionViaMVAOption exported as top-level function', () => {
|
|
159
|
+
assert.equal(typeof operator.transitionViaMVAOption, 'function');
|
|
160
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2026 Mindrian. BSL 1.1.
|
|
3
|
+
*
|
|
4
|
+
* Phase 118-01 Plan 01 Task 1 -- mva-agent-contract.
|
|
5
|
+
*
|
|
6
|
+
* The AgentResult shape + the runAgent wrapper used by mva-dispatcher.cjs
|
|
7
|
+
* (Task 3 of this plan). Plan 118-02 implements 6 specific agents against
|
|
8
|
+
* the Agent contract documented below.
|
|
9
|
+
*
|
|
10
|
+
* AgentContext interface
|
|
11
|
+
* ----------------------
|
|
12
|
+
* {
|
|
13
|
+
* sentence_sha256: string, // dispatcher-provided -- ONLY identifier passed to agents
|
|
14
|
+
* remaining_budget_ms: number, // dispatcher-provided
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* raw_sentence is NEVER exposed to agents (Canon Part 8 hard invariant).
|
|
18
|
+
* Agents that need linguistic features must derive them from neighborhood-graph
|
|
19
|
+
* queries against room.db (sentence-sha8 only), NOT from the raw sentence string.
|
|
20
|
+
*
|
|
21
|
+
* process.env.MVA_SENTENCE is NEVER set. There is no escape hatch.
|
|
22
|
+
*
|
|
23
|
+
* Agent function signature
|
|
24
|
+
* ------------------------
|
|
25
|
+
* async function agent(context, signal) -> { status: 'ok'|'empty', payload } | null
|
|
26
|
+
*
|
|
27
|
+
* If the agent throws, runAgent wraps with status='error'.
|
|
28
|
+
* If the AbortSignal fires before resolve, runAgent wraps with status='timeout'.
|
|
29
|
+
* If the agent returns null, runAgent wraps with status='empty'.
|
|
30
|
+
*
|
|
31
|
+
* Pure CJS, node built-ins only, zero new runtime dependencies.
|
|
32
|
+
* Canon Part 8 invariants:
|
|
33
|
+
* - NEVER serialize stack traces (stack traces can contain user content).
|
|
34
|
+
* - Error messages capped at 200 chars (sliced to prevent user-content blow-through).
|
|
35
|
+
* - The AgentContext object passed to the agent contains ONLY documented keys.
|
|
36
|
+
*/
|
|
37
|
+
'use strict';
|
|
38
|
+
|
|
39
|
+
const ALLOWED_STATUSES = new Set(['ok', 'empty', 'error', 'timeout']);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* AGENT_RESULT_SHAPE -- a frozen object documenting the AgentResult shape.
|
|
43
|
+
*
|
|
44
|
+
* AgentResult = {
|
|
45
|
+
* agent_id: string, // 'brain_similar' | ... | 'dashboard_graph'
|
|
46
|
+
* status: 'ok' | 'empty' | 'error' | 'timeout',
|
|
47
|
+
* duration_ms: number, // wall-clock from runAgent invocation
|
|
48
|
+
* payload?: any, // agent-specific; opaque to dispatcher
|
|
49
|
+
* error?: string, // sanitized message (<= 200 chars)
|
|
50
|
+
* };
|
|
51
|
+
*/
|
|
52
|
+
const AGENT_RESULT_SHAPE = Object.freeze({
|
|
53
|
+
agent_id: 'string',
|
|
54
|
+
status: "'ok' | 'empty' | 'error' | 'timeout'",
|
|
55
|
+
duration_ms: 'number >= 0',
|
|
56
|
+
payload: 'optional any',
|
|
57
|
+
error: 'optional string'
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* validateAgentResult -- returns true if the result conforms to AgentResult shape.
|
|
62
|
+
*
|
|
63
|
+
* @param {unknown} result
|
|
64
|
+
* @returns {boolean}
|
|
65
|
+
*/
|
|
66
|
+
function validateAgentResult(result) {
|
|
67
|
+
if (!result || typeof result !== 'object') return false;
|
|
68
|
+
if (typeof result.agent_id !== 'string' || result.agent_id.length === 0) return false;
|
|
69
|
+
if (typeof result.status !== 'string' || !ALLOWED_STATUSES.has(result.status)) return false;
|
|
70
|
+
if (typeof result.duration_ms !== 'number' || result.duration_ms < 0) return false;
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Sanitize an error into a short message. Drops stack traces, drops object
|
|
76
|
+
* properties, slices to 200 chars to prevent any user-content blow-through.
|
|
77
|
+
*
|
|
78
|
+
* @param {unknown} err
|
|
79
|
+
* @returns {string}
|
|
80
|
+
*/
|
|
81
|
+
function sanitizeError(err) {
|
|
82
|
+
let msg;
|
|
83
|
+
if (err && typeof err === 'object' && typeof err.message === 'string') {
|
|
84
|
+
msg = err.message;
|
|
85
|
+
} else {
|
|
86
|
+
msg = String(err);
|
|
87
|
+
}
|
|
88
|
+
return msg.slice(0, 200);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* runAgent -- invoke an agent function under an AbortController timeout. Catches
|
|
93
|
+
* any throw and converts to a structured AgentResult.
|
|
94
|
+
*
|
|
95
|
+
* @param {{ id: string, fn: Function }} agentDef
|
|
96
|
+
* @param {{ sentence_sha256: string }} context
|
|
97
|
+
* @param {{ timeoutMs?: number }} opts
|
|
98
|
+
* @returns {Promise<{ agent_id: string, status: string, duration_ms: number, payload?: any, error?: string }>}
|
|
99
|
+
*/
|
|
100
|
+
async function runAgent(agentDef, context, opts) {
|
|
101
|
+
const timeoutMs = (opts && typeof opts.timeoutMs === 'number') ? opts.timeoutMs : 35000;
|
|
102
|
+
const t0 = Date.now();
|
|
103
|
+
const controller = new AbortController();
|
|
104
|
+
|
|
105
|
+
// Build an AgentContext containing ONLY documented keys. We do not spread
|
|
106
|
+
// arbitrary caller props (defense-in-depth against accidental raw_sentence
|
|
107
|
+
// leakage). Canon Part 8 invariant.
|
|
108
|
+
const agentContext = {
|
|
109
|
+
sentence_sha256: context && context.sentence_sha256,
|
|
110
|
+
remaining_budget_ms: timeoutMs
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
let timer;
|
|
114
|
+
const timeoutPromise = new Promise((_resolve, reject) => {
|
|
115
|
+
timer = setTimeout(() => {
|
|
116
|
+
controller.abort();
|
|
117
|
+
reject(new Error('__mva_dispatcher_timeout__'));
|
|
118
|
+
}, timeoutMs);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const result = await Promise.race([
|
|
123
|
+
Promise.resolve().then(() => agentDef.fn(agentContext, controller.signal)),
|
|
124
|
+
timeoutPromise
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
if (timer) clearTimeout(timer);
|
|
128
|
+
|
|
129
|
+
const duration_ms = Date.now() - t0;
|
|
130
|
+
|
|
131
|
+
if (result === null || result === undefined) {
|
|
132
|
+
return { agent_id: agentDef.id, status: 'empty', duration_ms };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Result is { status: 'ok' | 'empty', payload }.
|
|
136
|
+
if (result && typeof result === 'object' && typeof result.status === 'string') {
|
|
137
|
+
if (result.status === 'ok' || result.status === 'empty') {
|
|
138
|
+
return {
|
|
139
|
+
agent_id: agentDef.id,
|
|
140
|
+
status: result.status,
|
|
141
|
+
duration_ms,
|
|
142
|
+
payload: result.payload
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Unknown shape -- treat as empty (defense-in-depth; agent contract violation).
|
|
148
|
+
return { agent_id: agentDef.id, status: 'empty', duration_ms };
|
|
149
|
+
} catch (err) {
|
|
150
|
+
if (timer) clearTimeout(timer);
|
|
151
|
+
const duration_ms = Date.now() - t0;
|
|
152
|
+
|
|
153
|
+
if (controller.signal.aborted) {
|
|
154
|
+
return { agent_id: agentDef.id, status: 'timeout', duration_ms };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
agent_id: agentDef.id,
|
|
159
|
+
status: 'error',
|
|
160
|
+
duration_ms,
|
|
161
|
+
error: sanitizeError(err)
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = {
|
|
167
|
+
runAgent,
|
|
168
|
+
validateAgentResult,
|
|
169
|
+
AGENT_RESULT_SHAPE
|
|
170
|
+
};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2026 Mindrian. BSL 1.1.
|
|
3
|
+
*
|
|
4
|
+
* Phase 118-01 Plan 01 Task 1 -- mva-agent-contract tests.
|
|
5
|
+
*
|
|
6
|
+
* Tests the AgentResult shape, validateAgentResult, and runAgent wrapper.
|
|
7
|
+
* Per Canon Part 8 (Graph Boundary): AgentContext NEVER carries raw sentence;
|
|
8
|
+
* the only sentence-derived field is sentence_sha256. There is no escape hatch
|
|
9
|
+
* via process.env.MVA_SENTENCE.
|
|
10
|
+
*
|
|
11
|
+
* Pure CJS, node built-ins only. Run via `node --test`.
|
|
12
|
+
*/
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const test = require('node:test');
|
|
16
|
+
const assert = require('node:assert');
|
|
17
|
+
|
|
18
|
+
const {
|
|
19
|
+
runAgent,
|
|
20
|
+
validateAgentResult,
|
|
21
|
+
AGENT_RESULT_SHAPE
|
|
22
|
+
} = require('./mva-agent-contract.cjs');
|
|
23
|
+
|
|
24
|
+
test('mva-agent-contract: Test 1 -- agent returns ok wraps result', async () => {
|
|
25
|
+
const agentDef = {
|
|
26
|
+
id: 'test',
|
|
27
|
+
fn: async (_ctx, _signal) => ({ status: 'ok', payload: { x: 1 } })
|
|
28
|
+
};
|
|
29
|
+
const result = await runAgent(
|
|
30
|
+
agentDef,
|
|
31
|
+
{ sentence_sha256: 'abc123' },
|
|
32
|
+
{ timeoutMs: 1000 }
|
|
33
|
+
);
|
|
34
|
+
assert.strictEqual(result.agent_id, 'test');
|
|
35
|
+
assert.strictEqual(result.status, 'ok');
|
|
36
|
+
assert.deepStrictEqual(result.payload, { x: 1 });
|
|
37
|
+
assert.strictEqual(typeof result.duration_ms, 'number');
|
|
38
|
+
assert.ok(result.duration_ms >= 0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('mva-agent-contract: Test 2 -- agent throws becomes error result', async () => {
|
|
42
|
+
const agentDef = {
|
|
43
|
+
id: 'test',
|
|
44
|
+
fn: async (_ctx, _signal) => { throw new Error('boom'); }
|
|
45
|
+
};
|
|
46
|
+
const result = await runAgent(
|
|
47
|
+
agentDef,
|
|
48
|
+
{ sentence_sha256: 'abc123' },
|
|
49
|
+
{ timeoutMs: 1000 }
|
|
50
|
+
);
|
|
51
|
+
assert.strictEqual(result.agent_id, 'test');
|
|
52
|
+
assert.strictEqual(result.status, 'error');
|
|
53
|
+
assert.strictEqual(result.error, 'boom');
|
|
54
|
+
assert.ok(!('stack' in result), 'result must never carry stack');
|
|
55
|
+
assert.strictEqual(typeof result.duration_ms, 'number');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('mva-agent-contract: Test 3 -- agent returns null becomes empty', async () => {
|
|
59
|
+
const agentDef = {
|
|
60
|
+
id: 'test',
|
|
61
|
+
fn: async (_ctx, _signal) => null
|
|
62
|
+
};
|
|
63
|
+
const result = await runAgent(
|
|
64
|
+
agentDef,
|
|
65
|
+
{ sentence_sha256: 'abc123' },
|
|
66
|
+
{ timeoutMs: 1000 }
|
|
67
|
+
);
|
|
68
|
+
assert.strictEqual(result.agent_id, 'test');
|
|
69
|
+
assert.strictEqual(result.status, 'empty');
|
|
70
|
+
assert.strictEqual(typeof result.duration_ms, 'number');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('mva-agent-contract: Test 4 -- agent exceeds timeout returns timeout', async () => {
|
|
74
|
+
const agentDef = {
|
|
75
|
+
id: 'test',
|
|
76
|
+
fn: async (_ctx, signal) => {
|
|
77
|
+
await new Promise((resolve, reject) => {
|
|
78
|
+
const t = setTimeout(resolve, 500);
|
|
79
|
+
signal.addEventListener('abort', () => {
|
|
80
|
+
clearTimeout(t);
|
|
81
|
+
reject(new Error('aborted'));
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
return { status: 'ok', payload: {} };
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const t0 = Date.now();
|
|
88
|
+
const result = await runAgent(
|
|
89
|
+
agentDef,
|
|
90
|
+
{ sentence_sha256: 'abc123' },
|
|
91
|
+
{ timeoutMs: 100 }
|
|
92
|
+
);
|
|
93
|
+
const elapsed = Date.now() - t0;
|
|
94
|
+
assert.strictEqual(result.agent_id, 'test');
|
|
95
|
+
assert.strictEqual(result.status, 'timeout');
|
|
96
|
+
assert.ok(elapsed < 300, `elapsed ${elapsed} must be under 300ms`);
|
|
97
|
+
assert.ok(result.duration_ms >= 90, `duration_ms ${result.duration_ms} should be ~100`);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('mva-agent-contract: Test 5 -- agent receives both args, signal observed', async () => {
|
|
101
|
+
let observedSignalAbortedAtStart = null;
|
|
102
|
+
let observedSignalAbortedAtEnd = null;
|
|
103
|
+
let observedSha = null;
|
|
104
|
+
let observedRemaining = null;
|
|
105
|
+
const agentDef = {
|
|
106
|
+
id: 'test',
|
|
107
|
+
fn: async (ctx, signal) => {
|
|
108
|
+
observedSignalAbortedAtStart = signal.aborted;
|
|
109
|
+
observedSha = ctx.sentence_sha256;
|
|
110
|
+
observedRemaining = ctx.remaining_budget_ms;
|
|
111
|
+
// Wait long enough to be cancelled
|
|
112
|
+
await new Promise((resolve, reject) => {
|
|
113
|
+
const t = setTimeout(resolve, 500);
|
|
114
|
+
signal.addEventListener('abort', () => {
|
|
115
|
+
clearTimeout(t);
|
|
116
|
+
observedSignalAbortedAtEnd = signal.aborted;
|
|
117
|
+
reject(new Error('aborted'));
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
return { status: 'ok', payload: {} };
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
const result = await runAgent(
|
|
124
|
+
agentDef,
|
|
125
|
+
{ sentence_sha256: 'def456' },
|
|
126
|
+
{ timeoutMs: 100 }
|
|
127
|
+
);
|
|
128
|
+
assert.strictEqual(observedSignalAbortedAtStart, false, 'signal must start non-aborted');
|
|
129
|
+
assert.strictEqual(observedSignalAbortedAtEnd, true, 'signal must be aborted on timeout');
|
|
130
|
+
assert.strictEqual(observedSha, 'def456');
|
|
131
|
+
assert.strictEqual(observedRemaining, 100);
|
|
132
|
+
assert.strictEqual(result.status, 'timeout');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('mva-agent-contract: Test 6 -- validateAgentResult', () => {
|
|
136
|
+
assert.strictEqual(
|
|
137
|
+
validateAgentResult({ agent_id: 'x', status: 'ok', duration_ms: 10 }),
|
|
138
|
+
true
|
|
139
|
+
);
|
|
140
|
+
assert.strictEqual(
|
|
141
|
+
validateAgentResult({ agent_id: 'x', status: 'empty', duration_ms: 10 }),
|
|
142
|
+
true
|
|
143
|
+
);
|
|
144
|
+
assert.strictEqual(
|
|
145
|
+
validateAgentResult({ agent_id: 'x', status: 'error', duration_ms: 10, error: 'e' }),
|
|
146
|
+
true
|
|
147
|
+
);
|
|
148
|
+
assert.strictEqual(
|
|
149
|
+
validateAgentResult({ agent_id: 'x', status: 'timeout', duration_ms: 10 }),
|
|
150
|
+
true
|
|
151
|
+
);
|
|
152
|
+
assert.strictEqual(validateAgentResult(null), false);
|
|
153
|
+
assert.strictEqual(validateAgentResult({}), false);
|
|
154
|
+
assert.strictEqual(validateAgentResult({ status: 'ok', duration_ms: 10 }), false);
|
|
155
|
+
assert.strictEqual(validateAgentResult({ agent_id: 'x', duration_ms: 10 }), false);
|
|
156
|
+
assert.strictEqual(
|
|
157
|
+
validateAgentResult({ agent_id: 'x', status: 'invalid', duration_ms: 10 }),
|
|
158
|
+
false
|
|
159
|
+
);
|
|
160
|
+
assert.strictEqual(
|
|
161
|
+
validateAgentResult({ agent_id: 'x', status: 'ok', duration_ms: -1 }),
|
|
162
|
+
false
|
|
163
|
+
);
|
|
164
|
+
assert.strictEqual(
|
|
165
|
+
validateAgentResult({ agent_id: 'x', status: 'ok' }),
|
|
166
|
+
false
|
|
167
|
+
);
|
|
168
|
+
assert.ok(AGENT_RESULT_SHAPE, 'shape doc must be exported');
|
|
169
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2026 Mindrian. BSL 1.1.
|
|
3
|
+
*
|
|
4
|
+
* Phase 118-01 Plan 01 Task 2 -- mva-budget.
|
|
5
|
+
*
|
|
6
|
+
* Global wall-clock budget tracker used by mva-dispatcher.cjs. Per binding
|
|
7
|
+
* decision B2 (HARD):
|
|
8
|
+
* GLOBAL_BUDGET_MS = 45000 (the hard 30-Second-MVA cap; named for the
|
|
9
|
+
* sharp-question fallback window per source spec
|
|
10
|
+
* line 113 -- "Hard budget: 45 seconds maximum")
|
|
11
|
+
* PER_AGENT_CAP_MS = 35000 (each agent gets at most 35s OR remaining
|
|
12
|
+
* global, whichever is less -- source spec
|
|
13
|
+
* line 123 -- "Agents return structured JSON
|
|
14
|
+
* within 35s budget each")
|
|
15
|
+
*
|
|
16
|
+
* The dispatcher creates ONE budget per dispatch call. Each agent's per-agent
|
|
17
|
+
* abort signal feeds from this budget via perAgentMs(): min(35000, remainingMs()).
|
|
18
|
+
*
|
|
19
|
+
* Pure CJS, leaf module -- no dependencies on any other lib/core file.
|
|
20
|
+
* Canon Part 8: this module touches NO user content; it is wall-clock math only.
|
|
21
|
+
*/
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
const GLOBAL_BUDGET_MS = 45000;
|
|
25
|
+
const PER_AGENT_CAP_MS = 35000;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Create a wall-clock budget tracker.
|
|
29
|
+
*
|
|
30
|
+
* @param {number} [globalBudgetMs=45000]
|
|
31
|
+
* @returns {{
|
|
32
|
+
* startedAt: number,
|
|
33
|
+
* globalBudgetMs: number,
|
|
34
|
+
* remainingMs: () => number,
|
|
35
|
+
* perAgentMs: (cap?: number) => number,
|
|
36
|
+
* isExpired: () => boolean,
|
|
37
|
+
* elapsedMs: () => number,
|
|
38
|
+
* }}
|
|
39
|
+
*/
|
|
40
|
+
function createBudget(globalBudgetMs) {
|
|
41
|
+
const cap = (typeof globalBudgetMs === 'number') ? globalBudgetMs : GLOBAL_BUDGET_MS;
|
|
42
|
+
const startedAt = Date.now();
|
|
43
|
+
|
|
44
|
+
function remainingMs() {
|
|
45
|
+
return Math.max(0, cap - (Date.now() - startedAt));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function perAgentMs(perAgentCapMs) {
|
|
49
|
+
const requested = (typeof perAgentCapMs === 'number') ? perAgentCapMs : PER_AGENT_CAP_MS;
|
|
50
|
+
return Math.max(0, Math.min(requested, remainingMs()));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isExpired() {
|
|
54
|
+
return remainingMs() === 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function elapsedMs() {
|
|
58
|
+
return Date.now() - startedAt;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
startedAt,
|
|
63
|
+
globalBudgetMs: cap,
|
|
64
|
+
remainingMs,
|
|
65
|
+
perAgentMs,
|
|
66
|
+
isExpired,
|
|
67
|
+
elapsedMs
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = {
|
|
72
|
+
createBudget,
|
|
73
|
+
GLOBAL_BUDGET_MS,
|
|
74
|
+
PER_AGENT_CAP_MS
|
|
75
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2026 Mindrian. BSL 1.1.
|
|
3
|
+
*
|
|
4
|
+
* Phase 118-01 Plan 01 Task 2 -- mva-budget tests.
|
|
5
|
+
*
|
|
6
|
+
* Per binding decision B2 (HARD):
|
|
7
|
+
* 45-second global wall-clock budget + 35-second per-agent budget.
|
|
8
|
+
* Per-agent abort signal feeds from the global deadline: each agent gets
|
|
9
|
+
* min(35s, remaining_global_budget).
|
|
10
|
+
*
|
|
11
|
+
* Pure CJS, node built-ins only. Run via `node --test`.
|
|
12
|
+
*/
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const test = require('node:test');
|
|
16
|
+
const assert = require('node:assert');
|
|
17
|
+
|
|
18
|
+
const {
|
|
19
|
+
createBudget,
|
|
20
|
+
GLOBAL_BUDGET_MS,
|
|
21
|
+
PER_AGENT_CAP_MS
|
|
22
|
+
} = require('./mva-budget.cjs');
|
|
23
|
+
|
|
24
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
25
|
+
|
|
26
|
+
test('mva-budget: Test 1 -- remaining decreases over time', async () => {
|
|
27
|
+
const budget = createBudget(45000);
|
|
28
|
+
const r0 = budget.remainingMs();
|
|
29
|
+
assert.ok(r0 >= 44990 && r0 <= 45000, `expected ~45000, got ${r0}`);
|
|
30
|
+
await sleep(100);
|
|
31
|
+
const r1 = budget.remainingMs();
|
|
32
|
+
// Allow generous slop for slow CI / GC pauses.
|
|
33
|
+
assert.ok(r1 >= 44800 && r1 <= 44910, `expected ~44900, got ${r1}`);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('mva-budget: Test 2 -- default globalBudgetMs is 45000', () => {
|
|
37
|
+
const budget = createBudget();
|
|
38
|
+
const r = budget.remainingMs();
|
|
39
|
+
assert.ok(r >= 44990 && r <= 45000, `expected ~45000, got ${r}`);
|
|
40
|
+
assert.strictEqual(budget.globalBudgetMs, 45000);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('mva-budget: Test 3 -- perAgentMs returns 35000 when remaining is 45000', () => {
|
|
44
|
+
const budget = createBudget(45000);
|
|
45
|
+
assert.strictEqual(budget.perAgentMs(35000), 35000);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('mva-budget: Test 4 -- perAgentMs caps at remaining', async () => {
|
|
49
|
+
const budget = createBudget(200);
|
|
50
|
+
await sleep(150);
|
|
51
|
+
const cap = budget.perAgentMs(180);
|
|
52
|
+
// remaining is ~50; perAgentCap 180 -> min(180, 50) = ~50
|
|
53
|
+
assert.ok(cap >= 0 && cap <= 80, `expected ~50, got ${cap}`);
|
|
54
|
+
assert.ok(cap < 180, 'must NOT return 180 when remaining is lower');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('mva-budget: Test 5 -- isExpired flips true after globalBudgetMs', async () => {
|
|
58
|
+
const budget = createBudget(80);
|
|
59
|
+
assert.strictEqual(budget.isExpired(), false);
|
|
60
|
+
await sleep(120);
|
|
61
|
+
assert.strictEqual(budget.isExpired(), true);
|
|
62
|
+
assert.strictEqual(budget.remainingMs(), 0);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('mva-budget: Test 6 -- exported constants', () => {
|
|
66
|
+
assert.strictEqual(GLOBAL_BUDGET_MS, 45000);
|
|
67
|
+
assert.strictEqual(PER_AGENT_CAP_MS, 35000);
|
|
68
|
+
});
|