context-vault 3.17.0 → 3.19.0
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/bin/cli.js +157 -0
- package/dist/register-tools.d.ts.map +1 -1
- package/dist/register-tools.js +0 -2
- package/dist/register-tools.js.map +1 -1
- package/dist/server.js +78 -1
- package/dist/server.js.map +1 -1
- package/dist/tools/recall.d.ts +1 -1
- package/dist/tools/recall.d.ts.map +1 -1
- package/dist/tools/recall.js +50 -100
- package/dist/tools/recall.js.map +1 -1
- package/node_modules/@context-vault/core/dist/assemble.d.ts +22 -0
- package/node_modules/@context-vault/core/dist/assemble.d.ts.map +1 -0
- package/node_modules/@context-vault/core/dist/assemble.js +143 -0
- package/node_modules/@context-vault/core/dist/assemble.js.map +1 -0
- package/node_modules/@context-vault/core/dist/capture.d.ts.map +1 -1
- package/node_modules/@context-vault/core/dist/capture.js +10 -5
- package/node_modules/@context-vault/core/dist/capture.js.map +1 -1
- package/node_modules/@context-vault/core/dist/consolidation.d.ts +40 -0
- package/node_modules/@context-vault/core/dist/consolidation.d.ts.map +1 -0
- package/node_modules/@context-vault/core/dist/consolidation.js +229 -0
- package/node_modules/@context-vault/core/dist/consolidation.js.map +1 -0
- package/node_modules/@context-vault/core/dist/db.d.ts +25 -1
- package/node_modules/@context-vault/core/dist/db.d.ts.map +1 -1
- package/node_modules/@context-vault/core/dist/db.js +92 -4
- package/node_modules/@context-vault/core/dist/db.js.map +1 -1
- package/node_modules/@context-vault/core/dist/frontmatter.d.ts.map +1 -1
- package/node_modules/@context-vault/core/dist/frontmatter.js +26 -3
- package/node_modules/@context-vault/core/dist/frontmatter.js.map +1 -1
- package/node_modules/@context-vault/core/dist/index.d.ts.map +1 -1
- package/node_modules/@context-vault/core/dist/index.js +225 -184
- package/node_modules/@context-vault/core/dist/index.js.map +1 -1
- package/node_modules/@context-vault/core/dist/main.d.ts +2 -0
- package/node_modules/@context-vault/core/dist/main.d.ts.map +1 -1
- package/node_modules/@context-vault/core/dist/main.js +2 -0
- package/node_modules/@context-vault/core/dist/main.js.map +1 -1
- package/node_modules/@context-vault/core/dist/search.d.ts +5 -0
- package/node_modules/@context-vault/core/dist/search.d.ts.map +1 -1
- package/node_modules/@context-vault/core/dist/search.js +97 -5
- package/node_modules/@context-vault/core/dist/search.js.map +1 -1
- package/node_modules/@context-vault/core/dist/summarize.d.ts +5 -0
- package/node_modules/@context-vault/core/dist/summarize.d.ts.map +1 -0
- package/node_modules/@context-vault/core/dist/summarize.js +146 -0
- package/node_modules/@context-vault/core/dist/summarize.js.map +1 -0
- package/node_modules/@context-vault/core/dist/types.d.ts +2 -0
- package/node_modules/@context-vault/core/dist/types.d.ts.map +1 -1
- package/node_modules/@context-vault/core/package.json +5 -1
- package/node_modules/@context-vault/core/src/assemble.ts +187 -0
- package/node_modules/@context-vault/core/src/capture.ts +10 -5
- package/node_modules/@context-vault/core/src/consolidation.ts +356 -0
- package/node_modules/@context-vault/core/src/db.ts +95 -4
- package/node_modules/@context-vault/core/src/frontmatter.ts +25 -4
- package/node_modules/@context-vault/core/src/index.ts +127 -88
- package/node_modules/@context-vault/core/src/main.ts +4 -0
- package/node_modules/@context-vault/core/src/search.ts +102 -5
- package/node_modules/@context-vault/core/src/summarize.ts +157 -0
- package/node_modules/@context-vault/core/src/types.ts +2 -0
- package/package.json +2 -2
- package/scripts/validate-epipe-shutdown.mjs +183 -0
- package/scripts/validate-sqlite-busy-retry.mjs +243 -0
- package/src/register-tools.ts +0 -2
- package/src/server.ts +76 -1
- package/src/tools/recall.ts +51 -110
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "context-vault",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.19.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Persistent memory for AI agents — saves and searches knowledge across sessions",
|
|
6
6
|
"bin": {
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
"@context-vault/core"
|
|
68
68
|
],
|
|
69
69
|
"dependencies": {
|
|
70
|
-
"@context-vault/core": "^3.
|
|
70
|
+
"@context-vault/core": "^3.19.0",
|
|
71
71
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
72
72
|
"adm-zip": "^0.5.16",
|
|
73
73
|
"sqlite-vec": "^0.1.0"
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Regression test for mcp-epipe-graceful-shutdown.
|
|
3
|
+
//
|
|
4
|
+
// Spawns the built server, completes the MCP handshake, then simulates a
|
|
5
|
+
// client disconnect by destroying the parent's read of child.stdout and
|
|
6
|
+
// closing child.stdin. Asserts:
|
|
7
|
+
// (a) the child exits cleanly (code 0) within 3s
|
|
8
|
+
// (b) vault.db-wal is 0 bytes (WAL was checkpointed)
|
|
9
|
+
// (c) error.log has NO `write EPIPE` uncaughtException entry for this run
|
|
10
|
+
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import {
|
|
13
|
+
existsSync,
|
|
14
|
+
mkdtempSync,
|
|
15
|
+
readFileSync,
|
|
16
|
+
rmSync,
|
|
17
|
+
statSync,
|
|
18
|
+
} from 'node:fs';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { join, resolve, dirname } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const SERVER_JS = resolve(__dirname, '..', 'dist', 'server.js');
|
|
25
|
+
const EXIT_DEADLINE_MS = 3000;
|
|
26
|
+
const READY_TIMEOUT_MS = 10_000;
|
|
27
|
+
const RESPONSE_TIMEOUT_MS = 5000;
|
|
28
|
+
|
|
29
|
+
const tmpRoot = mkdtempSync(join(tmpdir(), 'cv-epipe-'));
|
|
30
|
+
const dataDir = join(tmpRoot, 'data');
|
|
31
|
+
const vaultDir = join(tmpRoot, 'vault');
|
|
32
|
+
|
|
33
|
+
let child = null;
|
|
34
|
+
let pass = false;
|
|
35
|
+
let failMsg = '';
|
|
36
|
+
let stdoutBuf = '';
|
|
37
|
+
let stderrBuf = '';
|
|
38
|
+
|
|
39
|
+
function cleanup() {
|
|
40
|
+
if (child && child.exitCode === null) {
|
|
41
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
42
|
+
}
|
|
43
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch {}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function waitFor(predicate, timeoutMs, label) {
|
|
47
|
+
const start = Date.now();
|
|
48
|
+
while (Date.now() - start < timeoutMs) {
|
|
49
|
+
if (predicate()) return;
|
|
50
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`timeout: ${label}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function extractJsonResponses(buffer) {
|
|
56
|
+
const results = [];
|
|
57
|
+
for (const line of buffer.split('\n')) {
|
|
58
|
+
const trimmed = line.trim();
|
|
59
|
+
if (!trimmed) continue;
|
|
60
|
+
try { results.push(JSON.parse(trimmed)); } catch {}
|
|
61
|
+
}
|
|
62
|
+
return results;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
if (!existsSync(SERVER_JS)) {
|
|
67
|
+
throw new Error(`server bundle missing: ${SERVER_JS} (run npm run build first)`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
child = spawn(process.execPath, [SERVER_JS], {
|
|
71
|
+
env: {
|
|
72
|
+
...process.env,
|
|
73
|
+
CONTEXT_VAULT_DATA_DIR: dataDir,
|
|
74
|
+
CONTEXT_VAULT_VAULT_DIR: vaultDir,
|
|
75
|
+
CONTEXT_VAULT_DIR: vaultDir,
|
|
76
|
+
CONTEXT_VAULT_TELEMETRY: '0',
|
|
77
|
+
},
|
|
78
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
child.stdout.on('data', (chunk) => { stdoutBuf += chunk.toString('utf-8'); });
|
|
82
|
+
child.stderr.on('data', (chunk) => { stderrBuf += chunk.toString('utf-8'); });
|
|
83
|
+
|
|
84
|
+
const exitPromise = new Promise((resolveExit) => {
|
|
85
|
+
child.once('exit', (code, signal) => resolveExit({ code, signal }));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Wait for the server to finish startup logs before sending JSON-RPC.
|
|
89
|
+
await waitFor(() => /Database:/.test(stderrBuf), READY_TIMEOUT_MS, 'server startup');
|
|
90
|
+
|
|
91
|
+
function send(msg) {
|
|
92
|
+
child.stdin.write(JSON.stringify(msg) + '\n');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
send({
|
|
96
|
+
jsonrpc: '2.0',
|
|
97
|
+
id: 1,
|
|
98
|
+
method: 'initialize',
|
|
99
|
+
params: {
|
|
100
|
+
protocolVersion: '2024-11-05',
|
|
101
|
+
capabilities: {},
|
|
102
|
+
clientInfo: { name: 'epipe-validator', version: '1.0.0' },
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await waitFor(
|
|
107
|
+
() => extractJsonResponses(stdoutBuf).some((r) => r.id === 1),
|
|
108
|
+
RESPONSE_TIMEOUT_MS,
|
|
109
|
+
'initialize response',
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
send({ jsonrpc: '2.0', method: 'notifications/initialized' });
|
|
113
|
+
|
|
114
|
+
send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
115
|
+
await waitFor(
|
|
116
|
+
() => extractJsonResponses(stdoutBuf).some((r) => r.id === 2),
|
|
117
|
+
RESPONSE_TIMEOUT_MS,
|
|
118
|
+
'tools/list response',
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// Simulate client disconnect: destroy the parent's read of child.stdout so
|
|
122
|
+
// the next write from the server raises EPIPE, then send another request
|
|
123
|
+
// to force the server to attempt a write.
|
|
124
|
+
child.stdout.destroy();
|
|
125
|
+
send({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} });
|
|
126
|
+
// Close stdin last so the server detects the full disconnect.
|
|
127
|
+
try { child.stdin.end(); } catch {}
|
|
128
|
+
|
|
129
|
+
const result = await Promise.race([
|
|
130
|
+
exitPromise,
|
|
131
|
+
new Promise((resolveTimeout) =>
|
|
132
|
+
setTimeout(() => resolveTimeout({ code: null, signal: 'deadline' }), EXIT_DEADLINE_MS),
|
|
133
|
+
),
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
if (result.signal === 'deadline') {
|
|
137
|
+
throw new Error(`server did not exit within ${EXIT_DEADLINE_MS}ms after disconnect`);
|
|
138
|
+
}
|
|
139
|
+
if (result.code !== 0) {
|
|
140
|
+
throw new Error(`unclean exit: code=${result.code} signal=${result.signal}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const walPath = join(dataDir, 'vault.db-wal');
|
|
144
|
+
if (existsSync(walPath)) {
|
|
145
|
+
const size = statSync(walPath).size;
|
|
146
|
+
if (size !== 0) {
|
|
147
|
+
throw new Error(`WAL not checkpointed: ${walPath} is ${size} bytes`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const errorLogPath = join(dataDir, 'error.log');
|
|
152
|
+
if (existsSync(errorLogPath)) {
|
|
153
|
+
const content = readFileSync(errorLogPath, 'utf-8');
|
|
154
|
+
for (const line of content.split('\n')) {
|
|
155
|
+
const trimmed = line.trim();
|
|
156
|
+
if (!trimmed) continue;
|
|
157
|
+
let entry;
|
|
158
|
+
try { entry = JSON.parse(trimmed); } catch { continue; }
|
|
159
|
+
const isUncaught = entry.error_type === 'uncaughtException';
|
|
160
|
+
const mentionsEpipe =
|
|
161
|
+
typeof entry.message === 'string' && /\bEPIPE\b/i.test(entry.message);
|
|
162
|
+
if (isUncaught && mentionsEpipe) {
|
|
163
|
+
throw new Error(`error.log contains write EPIPE uncaughtException: ${trimmed}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
pass = true;
|
|
169
|
+
console.log('PASS: EPIPE graceful shutdown (exit 0, WAL checkpointed, no uncaught EPIPE)');
|
|
170
|
+
} catch (err) {
|
|
171
|
+
failMsg = err && err.message ? err.message : String(err);
|
|
172
|
+
console.error(`FAIL: ${failMsg}`);
|
|
173
|
+
if (stderrBuf) {
|
|
174
|
+
const tail = stderrBuf.split('\n').slice(-15).join('\n');
|
|
175
|
+
if (tail.trim()) {
|
|
176
|
+
console.error('--- server stderr tail ---');
|
|
177
|
+
console.error(tail);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
} finally {
|
|
181
|
+
cleanup();
|
|
182
|
+
process.exit(pass ? 0 : 1);
|
|
183
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Regression test for mcp-sqlite-busy-retry.
|
|
3
|
+
//
|
|
4
|
+
// Spawns the built MCP server, completes the MCP handshake, then simulates
|
|
5
|
+
// a competing writer by holding a BEGIN IMMEDIATE transaction on the same
|
|
6
|
+
// vault.db for ~200ms. During that window, fires a save_context tool call
|
|
7
|
+
// and asserts the server retries through SQLITE_BUSY and succeeds — not
|
|
8
|
+
// returning INTERNAL_ERROR.
|
|
9
|
+
//
|
|
10
|
+
// Pass criteria:
|
|
11
|
+
// (a) Single scenario: tool call succeeds while lock is held (returns a
|
|
12
|
+
// JSON-RPC result with no isError flag).
|
|
13
|
+
// (b) Stability: 5 independent scenarios must succeed in at least 4.
|
|
14
|
+
//
|
|
15
|
+
// Exit 0 on pass, non-zero on fail.
|
|
16
|
+
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
19
|
+
import {
|
|
20
|
+
existsSync,
|
|
21
|
+
mkdtempSync,
|
|
22
|
+
rmSync,
|
|
23
|
+
mkdirSync,
|
|
24
|
+
} from 'node:fs';
|
|
25
|
+
import { tmpdir } from 'node:os';
|
|
26
|
+
import { join, resolve, dirname } from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
|
|
29
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const SERVER_JS = resolve(__dirname, '..', 'dist', 'server.js');
|
|
31
|
+
|
|
32
|
+
const READY_TIMEOUT_MS = 15_000;
|
|
33
|
+
const RESPONSE_TIMEOUT_MS = 15_000;
|
|
34
|
+
const LOCK_HOLD_MS = 200;
|
|
35
|
+
const SCENARIOS = 5;
|
|
36
|
+
const MIN_PASSES = 4;
|
|
37
|
+
|
|
38
|
+
if (!existsSync(SERVER_JS)) {
|
|
39
|
+
console.error(`FAIL: server bundle missing at ${SERVER_JS}. Run: cd mcp && npm run build`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function extractJsonResponses(buffer) {
|
|
44
|
+
const results = [];
|
|
45
|
+
for (const line of buffer.split('\n')) {
|
|
46
|
+
const trimmed = line.trim();
|
|
47
|
+
if (!trimmed) continue;
|
|
48
|
+
try { results.push(JSON.parse(trimmed)); } catch {}
|
|
49
|
+
}
|
|
50
|
+
return results;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function waitFor(predicate, timeoutMs, label) {
|
|
54
|
+
const start = Date.now();
|
|
55
|
+
while (Date.now() - start < timeoutMs) {
|
|
56
|
+
if (predicate()) return;
|
|
57
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
58
|
+
}
|
|
59
|
+
throw new Error(`timeout: ${label}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function runScenario(scenarioId) {
|
|
63
|
+
const tmpRoot = mkdtempSync(join(tmpdir(), `cv-busy-${scenarioId}-`));
|
|
64
|
+
const dataDir = join(tmpRoot, 'data');
|
|
65
|
+
const vaultDir = join(tmpRoot, 'vault');
|
|
66
|
+
mkdirSync(dataDir, { recursive: true });
|
|
67
|
+
mkdirSync(vaultDir, { recursive: true });
|
|
68
|
+
|
|
69
|
+
let child = null;
|
|
70
|
+
let stdoutBuf = '';
|
|
71
|
+
let stderrBuf = '';
|
|
72
|
+
let holder = null;
|
|
73
|
+
let holderTxOpen = false;
|
|
74
|
+
|
|
75
|
+
const cleanup = () => {
|
|
76
|
+
if (holder) {
|
|
77
|
+
try {
|
|
78
|
+
if (holderTxOpen) {
|
|
79
|
+
try { holder.exec('ROLLBACK'); } catch {}
|
|
80
|
+
}
|
|
81
|
+
holder.close();
|
|
82
|
+
} catch {}
|
|
83
|
+
}
|
|
84
|
+
if (child && child.exitCode === null) {
|
|
85
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
86
|
+
}
|
|
87
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch {}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
child = spawn(process.execPath, [SERVER_JS], {
|
|
92
|
+
env: {
|
|
93
|
+
...process.env,
|
|
94
|
+
CONTEXT_VAULT_DATA_DIR: dataDir,
|
|
95
|
+
CONTEXT_VAULT_VAULT_DIR: vaultDir,
|
|
96
|
+
CONTEXT_VAULT_DIR: vaultDir,
|
|
97
|
+
CONTEXT_VAULT_TELEMETRY: '0',
|
|
98
|
+
},
|
|
99
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
child.stdout.on('data', (chunk) => { stdoutBuf += chunk.toString('utf-8'); });
|
|
103
|
+
child.stderr.on('data', (chunk) => { stderrBuf += chunk.toString('utf-8'); });
|
|
104
|
+
|
|
105
|
+
// Wait for the server to finish startup (DB initialized)
|
|
106
|
+
await waitFor(() => /Database:/.test(stderrBuf), READY_TIMEOUT_MS, 'server startup');
|
|
107
|
+
|
|
108
|
+
const send = (msg) => { child.stdin.write(JSON.stringify(msg) + '\n'); };
|
|
109
|
+
|
|
110
|
+
send({
|
|
111
|
+
jsonrpc: '2.0', id: 1, method: 'initialize',
|
|
112
|
+
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'busy-validator', version: '1.0' } },
|
|
113
|
+
});
|
|
114
|
+
await waitFor(() => extractJsonResponses(stdoutBuf).some((r) => r.id === 1),
|
|
115
|
+
RESPONSE_TIMEOUT_MS, 'initialize response');
|
|
116
|
+
|
|
117
|
+
send({ jsonrpc: '2.0', method: 'notifications/initialized' });
|
|
118
|
+
|
|
119
|
+
// Server has opened vault.db. Now we open a second connection to the same
|
|
120
|
+
// file and grab an immediate write lock. WAL keeps readers unblocked but
|
|
121
|
+
// a BEGIN IMMEDIATE holds the reserved lock — any writer (including the
|
|
122
|
+
// server) will see SQLITE_BUSY until we release.
|
|
123
|
+
const dbPath = join(dataDir, 'vault.db');
|
|
124
|
+
if (!existsSync(dbPath)) throw new Error(`vault.db not created at ${dbPath}`);
|
|
125
|
+
|
|
126
|
+
holder = new DatabaseSync(dbPath);
|
|
127
|
+
holder.exec('PRAGMA journal_mode = WAL');
|
|
128
|
+
// Do NOT set a long busy_timeout on the holder — we want it to release
|
|
129
|
+
// on demand, not wait.
|
|
130
|
+
holder.exec('BEGIN IMMEDIATE');
|
|
131
|
+
holderTxOpen = true;
|
|
132
|
+
|
|
133
|
+
// Schedule lock release after LOCK_HOLD_MS. The server's retry wrapper
|
|
134
|
+
// (50/150/500ms backoff) plus the PRAGMA busy_timeout=5000ms both give
|
|
135
|
+
// it plenty of time to retry successfully once we release.
|
|
136
|
+
const releaseTimer = setTimeout(() => {
|
|
137
|
+
try {
|
|
138
|
+
if (holderTxOpen) {
|
|
139
|
+
holder.exec('COMMIT');
|
|
140
|
+
holderTxOpen = false;
|
|
141
|
+
}
|
|
142
|
+
} catch (e) {
|
|
143
|
+
// If commit fails because the writer won a race, that's fine — our
|
|
144
|
+
// purpose (holding for LOCK_HOLD_MS) is already done.
|
|
145
|
+
}
|
|
146
|
+
}, LOCK_HOLD_MS);
|
|
147
|
+
|
|
148
|
+
const callId = 100 + scenarioId;
|
|
149
|
+
send({
|
|
150
|
+
jsonrpc: '2.0', id: callId, method: 'tools/call',
|
|
151
|
+
params: {
|
|
152
|
+
name: 'save_context',
|
|
153
|
+
arguments: {
|
|
154
|
+
kind: 'event',
|
|
155
|
+
body: `busy-retry validation scenario ${scenarioId} at ${new Date().toISOString()}`,
|
|
156
|
+
tags: ['bucket:validation', 'busy-retry-test'],
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await waitFor(() => extractJsonResponses(stdoutBuf).some((r) => r.id === callId),
|
|
162
|
+
RESPONSE_TIMEOUT_MS, `tools/call response (scenario ${scenarioId})`);
|
|
163
|
+
clearTimeout(releaseTimer);
|
|
164
|
+
|
|
165
|
+
// Ensure lock is released before asserting (in case timer didn't fire)
|
|
166
|
+
if (holderTxOpen) {
|
|
167
|
+
try { holder.exec('COMMIT'); holderTxOpen = false; } catch {}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const responses = extractJsonResponses(stdoutBuf).filter((r) => r.id === callId);
|
|
171
|
+
if (responses.length === 0) throw new Error('no response received');
|
|
172
|
+
const response = responses[0];
|
|
173
|
+
|
|
174
|
+
if (response.error) {
|
|
175
|
+
throw new Error(`JSON-RPC error: ${JSON.stringify(response.error)}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const result = response.result;
|
|
179
|
+
if (!result) throw new Error(`empty result: ${JSON.stringify(response)}`);
|
|
180
|
+
|
|
181
|
+
// The MCP tool-result shape: { content: [...], isError?: boolean }.
|
|
182
|
+
// INTERNAL_ERROR surfaces as isError: true plus error text.
|
|
183
|
+
if (result.isError) {
|
|
184
|
+
const text = (result.content || []).map((c) => c.text || '').join(' ');
|
|
185
|
+
throw new Error(`tool returned isError: ${text.slice(0, 200)}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { scenarioId, ok: true };
|
|
189
|
+
} catch (err) {
|
|
190
|
+
const msg = err && err.message ? err.message : String(err);
|
|
191
|
+
const stderrTail = stderrBuf.split('\n').slice(-10).join('\n');
|
|
192
|
+
return { scenarioId, ok: false, error: msg, stderrTail };
|
|
193
|
+
} finally {
|
|
194
|
+
cleanup();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function main() {
|
|
199
|
+
console.log(`[busy-retry] Running ${SCENARIOS} scenarios (each holds DB lock for ${LOCK_HOLD_MS}ms during save_context)`);
|
|
200
|
+
|
|
201
|
+
const results = [];
|
|
202
|
+
// Run sequentially — each scenario has its own vault dir, but running in
|
|
203
|
+
// parallel would conflate lock contention signals across scenarios.
|
|
204
|
+
for (let i = 1; i <= SCENARIOS; i++) {
|
|
205
|
+
const r = await runScenario(i);
|
|
206
|
+
results.push(r);
|
|
207
|
+
if (r.ok) {
|
|
208
|
+
console.log(` scenario ${i}: PASS`);
|
|
209
|
+
} else {
|
|
210
|
+
console.log(` scenario ${i}: FAIL — ${r.error}`);
|
|
211
|
+
if (r.stderrTail && r.stderrTail.trim()) {
|
|
212
|
+
console.log(` stderr tail:\n${r.stderrTail.split('\n').map(l => ' ' + l).join('\n')}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const passed = results.filter((r) => r.ok).length;
|
|
218
|
+
const firstPassed = results[0].ok;
|
|
219
|
+
|
|
220
|
+
console.log(`\n[busy-retry] ${passed}/${SCENARIOS} scenarios passed`);
|
|
221
|
+
|
|
222
|
+
// Assertion (a): first scenario must pass (single-shot proof that a held
|
|
223
|
+
// lock does not surface as INTERNAL_ERROR).
|
|
224
|
+
if (!firstPassed) {
|
|
225
|
+
console.error('FAIL: scenario 1 did not succeed — retry path is broken');
|
|
226
|
+
process.exit(1);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Assertion (b): stability — at least MIN_PASSES of SCENARIOS must succeed.
|
|
230
|
+
if (passed < MIN_PASSES) {
|
|
231
|
+
console.error(`FAIL: only ${passed}/${SCENARIOS} succeeded (required ${MIN_PASSES})`);
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
console.log('PASS: SQLITE_BUSY retry wrapper absorbs transient contention');
|
|
236
|
+
process.exit(0);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
main().catch((err) => {
|
|
240
|
+
console.error('FAIL: unhandled error');
|
|
241
|
+
console.error(err);
|
|
242
|
+
process.exit(1);
|
|
243
|
+
});
|
package/src/register-tools.ts
CHANGED
|
@@ -18,7 +18,6 @@ import * as ingestUrl from './tools/ingest-url.js';
|
|
|
18
18
|
import * as contextStatus from './tools/context-status.js';
|
|
19
19
|
import * as clearContext from './tools/clear-context.js';
|
|
20
20
|
import * as createSnapshot from './tools/create-snapshot.js';
|
|
21
|
-
import * as sessionStart from './tools/session-start.js';
|
|
22
21
|
import * as listBuckets from './tools/list-buckets.js';
|
|
23
22
|
import * as ingestProject from './tools/ingest-project.js';
|
|
24
23
|
import * as sessionEnd from './tools/session-end.js';
|
|
@@ -35,7 +34,6 @@ const toolModules = [
|
|
|
35
34
|
contextStatus,
|
|
36
35
|
clearContext,
|
|
37
36
|
createSnapshot,
|
|
38
|
-
sessionStart,
|
|
39
37
|
sessionEnd,
|
|
40
38
|
listBuckets,
|
|
41
39
|
recall,
|
package/src/server.ts
CHANGED
|
@@ -11,6 +11,50 @@ import { fileURLToPath } from 'node:url';
|
|
|
11
11
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
12
|
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
|
|
13
13
|
|
|
14
|
+
// Module-level shutdown coordination so pipe/uncaught handlers (below main())
|
|
15
|
+
// can route through the graceful shutdown wired up inside main().
|
|
16
|
+
let shutdownHandler: ((signal: string) => void) | null = null;
|
|
17
|
+
let shutdownInProgress = false;
|
|
18
|
+
|
|
19
|
+
function isPipeError(err: unknown): boolean {
|
|
20
|
+
if (!err || typeof err !== 'object') return false;
|
|
21
|
+
const e = err as { code?: string; message?: string };
|
|
22
|
+
if (e.code === 'EPIPE' || e.code === 'ERR_STREAM_DESTROYED' || e.code === 'ERR_STREAM_WRITE_AFTER_END') return true;
|
|
23
|
+
return typeof e.message === 'string' && /\bEPIPE\b/.test(e.message);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function handlePipeDisconnect(source: string): void {
|
|
27
|
+
if (shutdownInProgress) return;
|
|
28
|
+
if (shutdownHandler) {
|
|
29
|
+
try {
|
|
30
|
+
shutdownHandler(`EPIPE:${source}`);
|
|
31
|
+
} catch {
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
} else {
|
|
35
|
+
// Shutdown not wired yet (startup phase). Exit clean so WAL isn't dirtied.
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Catch broken-pipe writes to stdout/stderr so they route through graceful
|
|
41
|
+
// shutdown instead of bubbling up as an `uncaughtException` that skips the
|
|
42
|
+
// WAL checkpoint. Node raises EPIPE (not SIGPIPE) on pipe writes with no reader.
|
|
43
|
+
process.stdout.on('error', (err) => {
|
|
44
|
+
if (isPipeError(err)) {
|
|
45
|
+
handlePipeDisconnect('stdout');
|
|
46
|
+
} else {
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
process.stderr.on('error', (err) => {
|
|
51
|
+
if (isPipeError(err)) {
|
|
52
|
+
handlePipeDisconnect('stderr');
|
|
53
|
+
} else {
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
14
58
|
import { resolveConfig } from '@context-vault/core/config';
|
|
15
59
|
import type { LocalCtx } from './types.js';
|
|
16
60
|
import { appendErrorLog } from './error-log.js';
|
|
@@ -206,7 +250,28 @@ async function main(): Promise<void> {
|
|
|
206
250
|
}
|
|
207
251
|
|
|
208
252
|
function shutdown(signal: string): void {
|
|
209
|
-
|
|
253
|
+
// Idempotent: EPIPE from stdout error + uncaughtException can both fire
|
|
254
|
+
// during a single client disconnect. Second call becomes a no-op.
|
|
255
|
+
if (shutdownInProgress) return;
|
|
256
|
+
shutdownInProgress = true;
|
|
257
|
+
|
|
258
|
+
const isEpipe = signal.startsWith('EPIPE');
|
|
259
|
+
if (isEpipe) {
|
|
260
|
+
// Log a clean shutdown entry in place of the EPIPE uncaughtException
|
|
261
|
+
// that would otherwise have fired. Keeps the audit log readable.
|
|
262
|
+
appendErrorLog(config!.dataDir, {
|
|
263
|
+
timestamp: new Date().toISOString(),
|
|
264
|
+
error_type: 'EPIPE_shutdown',
|
|
265
|
+
message: `client pipe closed (${signal}); graceful shutdown`,
|
|
266
|
+
node_version: process.version,
|
|
267
|
+
platform: process.platform,
|
|
268
|
+
arch: process.arch,
|
|
269
|
+
cv_version: pkg.version,
|
|
270
|
+
});
|
|
271
|
+
console.error(`[context-vault] EPIPE shutdown: client disconnected (${signal})`);
|
|
272
|
+
} else {
|
|
273
|
+
console.error(`[context-vault] Received ${signal}, shutting down...`);
|
|
274
|
+
}
|
|
210
275
|
|
|
211
276
|
if (ctx.activeOps.count > 0) {
|
|
212
277
|
console.error(
|
|
@@ -232,6 +297,9 @@ async function main(): Promise<void> {
|
|
|
232
297
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
233
298
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
234
299
|
|
|
300
|
+
// Expose shutdown to module-level handlers (stdout error, uncaughtException).
|
|
301
|
+
shutdownHandler = shutdown;
|
|
302
|
+
|
|
235
303
|
// RSS watchdog: kill the process if memory usage exceeds the cap.
|
|
236
304
|
// Prevents runaway embedding/reindex operations from frying user systems.
|
|
237
305
|
const MAX_RSS_BYTES = parseInt(process.env.CONTEXT_VAULT_MAX_RSS_MB || '1024', 10) * 1024 * 1024;
|
|
@@ -310,6 +378,13 @@ async function main(): Promise<void> {
|
|
|
310
378
|
}
|
|
311
379
|
|
|
312
380
|
process.on('uncaughtException', (err) => {
|
|
381
|
+
// EPIPE from a dead client pipe is not a crash; it's a disconnect.
|
|
382
|
+
// Route through graceful shutdown so the WAL is checkpointed.
|
|
383
|
+
if (isPipeError(err)) {
|
|
384
|
+
handlePipeDisconnect('uncaught');
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
313
388
|
const dataDir = join(homedir(), '.context-mcp');
|
|
314
389
|
const logEntry = {
|
|
315
390
|
timestamp: new Date().toISOString(),
|