fix: harden startup and schema repair contracts
Reliability patch covering startup path resolution, install marker compatibility, export CLI request contracts, schema repair safety, hard-stop retry-loop handling, and the PR babysit status helper.
This commit is contained in:
@@ -1,5 +1,32 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { isWorkerUnavailableError } from '../src/cli/hook-command.js';
|
||||
import { isNonBlockingHookInputError, isWorkerUnavailableError } from '../src/cli/hook-command.js';
|
||||
|
||||
describe('isNonBlockingHookInputError', () => {
|
||||
it('classifies missing transcript paths as non-blocking hook input errors', () => {
|
||||
const error = new Error(
|
||||
'Transcript path missing or file does not exist: /tmp/missing-session.jsonl'
|
||||
);
|
||||
|
||||
expect(isNonBlockingHookInputError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('classifies missing transcript-path errors without file-existence text', () => {
|
||||
expect(
|
||||
isNonBlockingHookInputError(new Error('Transcript path missing: /tmp/missing-session.jsonl'))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('classifies nonexistent transcript-path errors without missing text', () => {
|
||||
expect(
|
||||
isNonBlockingHookInputError(new Error('Transcript path does not exist: /tmp/missing-session.jsonl'))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not classify unrelated hook errors as non-blocking input errors', () => {
|
||||
expect(isNonBlockingHookInputError(new Error('Cannot read properties of undefined'))).toBe(false);
|
||||
expect(isNonBlockingHookInputError(new Error('Request failed: 400'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isWorkerUnavailableError', () => {
|
||||
describe('transport failures → true (graceful)', () => {
|
||||
|
||||
@@ -6,6 +6,26 @@ import { fileURLToPath } from 'url';
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, '../..');
|
||||
|
||||
function readJson(relativePath: string): any {
|
||||
return JSON.parse(readFileSync(path.join(projectRoot, relativePath), 'utf-8'));
|
||||
}
|
||||
|
||||
function commandHooksFrom(relativePath: string): string[] {
|
||||
const parsed = readJson(relativePath);
|
||||
return Object.values(parsed.hooks ?? {}).flatMap((matchers: any) =>
|
||||
matchers.flatMap((matcher: any) =>
|
||||
(matcher.hooks ?? [])
|
||||
.filter((hook: any) => hook.type === 'command')
|
||||
.map((hook: any) => String(hook.command ?? ''))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function mcpStartupCommandFrom(relativePath: string): string {
|
||||
const parsed = readJson(relativePath);
|
||||
return parsed.mcpServers['mcp-search'].args[1];
|
||||
}
|
||||
|
||||
describe('Plugin Distribution - Skills', () => {
|
||||
const skillPath = path.join(projectRoot, 'plugin/skills/mem-search/SKILL.md');
|
||||
|
||||
@@ -58,61 +78,83 @@ describe('Plugin Distribution - hooks.json Integrity', () => {
|
||||
});
|
||||
|
||||
it('should reference CLAUDE_PLUGIN_ROOT in all hook commands', () => {
|
||||
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
|
||||
const parsed = JSON.parse(readFileSync(hooksPath, 'utf-8'));
|
||||
|
||||
for (const [eventName, matchers] of Object.entries(parsed.hooks)) {
|
||||
for (const matcher of matchers as any[]) {
|
||||
for (const hook of matcher.hooks) {
|
||||
if (hook.type === 'command') {
|
||||
expect(hook.command).toContain('${CLAUDE_PLUGIN_ROOT}');
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const command of commandHooksFrom('plugin/hooks/hooks.json')) {
|
||||
expect(command).toContain('CLAUDE_PLUGIN_ROOT');
|
||||
}
|
||||
});
|
||||
|
||||
it('should include CLAUDE_PLUGIN_ROOT fallback in all hook commands (#1215)', () => {
|
||||
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
|
||||
const parsed = JSON.parse(readFileSync(hooksPath, 'utf-8'));
|
||||
const expectedFallbackPath = '$HOME/.claude/plugins/marketplaces/thedotmack/plugin';
|
||||
const expectedFallbackPath = '$_C/plugins/marketplaces/thedotmack/plugin';
|
||||
|
||||
for (const [eventName, matchers] of Object.entries(parsed.hooks)) {
|
||||
for (const matcher of matchers as any[]) {
|
||||
for (const hook of matcher.hooks) {
|
||||
if (hook.type === 'command') {
|
||||
expect(hook.command).toContain(expectedFallbackPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const command of commandHooksFrom('plugin/hooks/hooks.json')) {
|
||||
expect(command).toContain(expectedFallbackPath);
|
||||
}
|
||||
});
|
||||
|
||||
it('should try cache path before marketplaces fallback in all hook commands (#1533)', () => {
|
||||
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
|
||||
const parsed = JSON.parse(readFileSync(hooksPath, 'utf-8'));
|
||||
const cachePath = '$HOME/.claude/plugins/cache/thedotmack/claude-mem';
|
||||
const marketplacesPath = '$HOME/.claude/plugins/marketplaces/thedotmack/plugin';
|
||||
const cachePath = '$_C/plugins/cache/thedotmack/claude-mem';
|
||||
const marketplacesPath = '$_C/plugins/marketplaces/thedotmack/plugin';
|
||||
|
||||
for (const [eventName, matchers] of Object.entries(parsed.hooks)) {
|
||||
for (const matcher of matchers as any[]) {
|
||||
for (const hook of matcher.hooks) {
|
||||
if (hook.type === 'command') {
|
||||
expect(hook.command).toContain(cachePath);
|
||||
expect(hook.command.indexOf(cachePath)).toBeLessThan(hook.command.indexOf(marketplacesPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const command of commandHooksFrom('plugin/hooks/hooks.json')) {
|
||||
expect(command).toContain(cachePath);
|
||||
expect(command.indexOf(cachePath)).toBeLessThan(command.indexOf(marketplacesPath));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Plugin Distribution - Startup Root Resolution', () => {
|
||||
it('MCP startup commands should have config-dir based non-empty fallbacks', () => {
|
||||
for (const relativePath of ['.mcp.json', 'plugin/.mcp.json']) {
|
||||
const command = mcpStartupCommandFrom(relativePath);
|
||||
|
||||
expect(command).toContain('${CLAUDE_CONFIG_DIR:-$HOME/.claude}');
|
||||
expect(command).toContain('_E="${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-}}"');
|
||||
expect(command).toContain('while IFS= read -r _R');
|
||||
expect(command).toContain('$_C/plugins/marketplaces/thedotmack/plugin');
|
||||
expect(command).toContain('$_C/plugins/cache/thedotmack/claude-mem');
|
||||
expect(command).toContain('[ -f "$_Q/scripts/mcp-server.cjs" ]');
|
||||
expect(command).not.toContain('"/scripts/mcp-server.cjs"');
|
||||
expect(command.indexOf('$_C/plugins/cache/thedotmack/claude-mem')).toBeLessThan(
|
||||
command.indexOf('$_C/plugins/marketplaces/thedotmack/plugin')
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('Codex hook commands should have config-dir based non-empty fallbacks', () => {
|
||||
for (const command of commandHooksFrom('plugin/hooks/codex-hooks.json')) {
|
||||
expect(command).toContain('${CLAUDE_CONFIG_DIR:-$HOME/.claude}');
|
||||
expect(command).toContain('export PATH=');
|
||||
expect(command).toContain('while IFS= read -r _R');
|
||||
expect(command).toContain('$_C/plugins/marketplaces/thedotmack/plugin');
|
||||
expect(command).toContain('$_C/plugins/cache/thedotmack/claude-mem');
|
||||
expect(command).toContain('[ -f "$_Q/scripts/');
|
||||
expect(command).toContain('command -v cygpath');
|
||||
expect(command.indexOf('$_C/plugins/cache/thedotmack/claude-mem')).toBeLessThan(
|
||||
command.indexOf('$_C/plugins/marketplaces/thedotmack/plugin')
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('Claude hook commands should have config-dir based non-empty fallbacks', () => {
|
||||
for (const command of commandHooksFrom('plugin/hooks/hooks.json')) {
|
||||
expect(command).toContain('${CLAUDE_CONFIG_DIR:-$HOME/.claude}');
|
||||
expect(command).toContain('while IFS= read -r _R');
|
||||
expect(command).toContain('$_C/plugins/marketplaces/thedotmack/plugin');
|
||||
expect(command).toContain('$_C/plugins/cache/thedotmack/claude-mem');
|
||||
expect(command).toContain('[ -f "$_Q/scripts/');
|
||||
expect(command).not.toContain('$HOME/.claude/plugins/');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Plugin Distribution - package.json Files Field', () => {
|
||||
it('should include "plugin" in root package.json files field', () => {
|
||||
it('should include plugin distribution files in root package.json files field', () => {
|
||||
const packageJsonPath = path.join(projectRoot, 'package.json');
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
||||
expect(packageJson.files).toBeDefined();
|
||||
expect(packageJson.files).toContain('plugin');
|
||||
expect(packageJson.files).toContain('plugin/hooks');
|
||||
expect(packageJson.files).toContain('plugin/.mcp.json');
|
||||
expect(packageJson.files).toContain('plugin/skills');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdirSync, writeFileSync, rmSync } from 'fs';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
const VERSION_CHECK_SCRIPT = join(import.meta.dir, '..', 'plugin', 'scripts', 'version-check.js');
|
||||
|
||||
function runVersionCheck(root: string) {
|
||||
const env = { ...process.env, CLAUDE_PLUGIN_ROOT: root };
|
||||
delete env.CLAUDE_MEM_CODEX_HOOK;
|
||||
|
||||
return spawnSync('node', [VERSION_CHECK_SCRIPT], {
|
||||
encoding: 'utf-8',
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
describe('plugin/scripts/version-check.js install marker compatibility', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(
|
||||
tmpdir(),
|
||||
`version-check-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
writeFileSync(join(tempDir, 'package.json'), JSON.stringify({ version: '12.4.4' }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('accepts a matching legacy plain-text marker without an upgrade hint', () => {
|
||||
writeFileSync(join(tempDir, '.install-version'), '12.4.4\n');
|
||||
|
||||
const result = runVersionCheck(tempDir);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toBe('');
|
||||
expect(result.stderr).toBe('');
|
||||
});
|
||||
|
||||
it('accepts a matching legacy plain-text marker with a leading v', () => {
|
||||
writeFileSync(join(tempDir, '.install-version'), 'v12.4.4\n');
|
||||
|
||||
const result = runVersionCheck(tempDir);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toBe('');
|
||||
expect(result.stderr).toBe('');
|
||||
});
|
||||
|
||||
it('emits an upgrade hint for a mismatched legacy plain-text marker', () => {
|
||||
writeFileSync(join(tempDir, '.install-version'), '12.4.3\n');
|
||||
|
||||
const result = runVersionCheck(tempDir);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toContain(
|
||||
'claude-mem: upgraded to v12.4.4 - run: npx claude-mem@latest install',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalDataDir = process.env.CLAUDE_MEM_DATA_DIR;
|
||||
const originalNoMain = process.env.CLAUDE_MEM_EXPORT_MEMORIES_NO_MAIN;
|
||||
|
||||
describe('export-memories script', () => {
|
||||
let tempDir: string | undefined;
|
||||
const consoleSpies: ReturnType<typeof spyOn>[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
|
||||
if (originalDataDir === undefined) {
|
||||
delete process.env.CLAUDE_MEM_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_MEM_DATA_DIR = originalDataDir;
|
||||
}
|
||||
|
||||
if (originalNoMain === undefined) {
|
||||
delete process.env.CLAUDE_MEM_EXPORT_MEMORIES_NO_MAIN;
|
||||
} else {
|
||||
process.env.CLAUDE_MEM_EXPORT_MEMORIES_NO_MAIN = originalNoMain;
|
||||
}
|
||||
|
||||
consoleSpies.splice(0).forEach(spy => spy.mockRestore());
|
||||
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
tempDir = undefined;
|
||||
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('loads settings from CLAUDE_MEM_DATA_DIR and sends canonical memorySessionIds', async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'claude-mem-export-'));
|
||||
process.env.CLAUDE_MEM_DATA_DIR = tempDir;
|
||||
process.env.CLAUDE_MEM_EXPORT_MEMORIES_NO_MAIN = '1';
|
||||
writeFileSync(join(tempDir, 'settings.json'), JSON.stringify({
|
||||
CLAUDE_MEM_WORKER_PORT: '45678',
|
||||
}));
|
||||
|
||||
consoleSpies.push(
|
||||
spyOn(console, 'log').mockImplementation(() => {}),
|
||||
spyOn(console, 'warn').mockImplementation(() => {}),
|
||||
spyOn(console, 'error').mockImplementation(() => {}),
|
||||
);
|
||||
|
||||
let batchBody: unknown;
|
||||
let searchSignal: unknown;
|
||||
let batchSignal: unknown;
|
||||
const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.startsWith('http://localhost:45678/api/search?')) {
|
||||
searchSignal = init?.signal;
|
||||
return new Response(JSON.stringify({
|
||||
observations: [
|
||||
{ memory_session_id: 'memory-a' },
|
||||
{ memory_session_id: 'memory-b' },
|
||||
],
|
||||
sessions: [
|
||||
{ memory_session_id: 'memory-a' },
|
||||
],
|
||||
prompts: [],
|
||||
}), { status: 200 });
|
||||
}
|
||||
|
||||
if (url === 'http://localhost:45678/api/sdk-sessions/batch') {
|
||||
batchSignal = init?.signal;
|
||||
batchBody = JSON.parse(String(init?.body));
|
||||
return new Response(JSON.stringify([
|
||||
{ memory_session_id: 'memory-a' },
|
||||
{ memory_session_id: 'memory-b' },
|
||||
]), { status: 200 });
|
||||
}
|
||||
|
||||
return new Response('unexpected url', { status: 500 });
|
||||
});
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
const { exportMemories } = await import('../../scripts/export-memories.ts');
|
||||
const outputFile = join(tempDir, 'export.json');
|
||||
|
||||
await exportMemories('needle', outputFile, 'project-a');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(searchSignal).toBeInstanceOf(AbortSignal);
|
||||
expect(batchSignal).toBeInstanceOf(AbortSignal);
|
||||
expect(batchBody).toEqual({ memorySessionIds: ['memory-a', 'memory-b'] });
|
||||
expect(batchBody).not.toHaveProperty('sdkSessionIds');
|
||||
|
||||
const exported = JSON.parse(readFileSync(outputFile, 'utf-8'));
|
||||
expect(exported.query).toBe('needle');
|
||||
expect(exported.project).toBe('project-a');
|
||||
expect(exported.totalSessions).toBe(2);
|
||||
});
|
||||
|
||||
it('rejects an invalid worker port before fetching', async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'claude-mem-export-'));
|
||||
process.env.CLAUDE_MEM_DATA_DIR = tempDir;
|
||||
process.env.CLAUDE_MEM_EXPORT_MEMORIES_NO_MAIN = '1';
|
||||
writeFileSync(join(tempDir, 'settings.json'), JSON.stringify({
|
||||
CLAUDE_MEM_WORKER_PORT: '45678abc',
|
||||
}));
|
||||
|
||||
const fetchMock = mock(async () => new Response('{}', { status: 200 }));
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
const { exportMemories } = await import('../../scripts/export-memories.ts');
|
||||
|
||||
await expect(exportMemories('needle', join(tempDir, 'export.json'))).rejects.toThrow(
|
||||
'Invalid CLAUDE_MEM_WORKER_PORT',
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an empty worker port with a clear configuration error', async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'claude-mem-export-'));
|
||||
process.env.CLAUDE_MEM_DATA_DIR = tempDir;
|
||||
process.env.CLAUDE_MEM_EXPORT_MEMORIES_NO_MAIN = '1';
|
||||
writeFileSync(join(tempDir, 'settings.json'), JSON.stringify({
|
||||
CLAUDE_MEM_WORKER_PORT: '',
|
||||
}));
|
||||
|
||||
const fetchMock = mock(async () => new Response('{}', { status: 200 }));
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
const { exportMemories } = await import('../../scripts/export-memories.ts');
|
||||
|
||||
await expect(exportMemories('needle', join(tempDir, 'export.json'))).rejects.toThrow(
|
||||
'Invalid CLAUDE_MEM_WORKER_PORT in settings.json: missing',
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a non-string worker port with a clear configuration error', async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'claude-mem-export-'));
|
||||
process.env.CLAUDE_MEM_DATA_DIR = tempDir;
|
||||
process.env.CLAUDE_MEM_EXPORT_MEMORIES_NO_MAIN = '1';
|
||||
writeFileSync(join(tempDir, 'settings.json'), JSON.stringify({
|
||||
CLAUDE_MEM_WORKER_PORT: 45678,
|
||||
}));
|
||||
|
||||
const fetchMock = mock(async () => new Response('{}', { status: 200 }));
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
const { exportMemories } = await import('../../scripts/export-memories.ts');
|
||||
|
||||
await expect(exportMemories('needle', join(tempDir, 'export.json'))).rejects.toThrow(
|
||||
'Invalid CLAUDE_MEM_WORKER_PORT in settings.json: missing',
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails the export when SDK session metadata cannot be fetched', async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'claude-mem-export-'));
|
||||
process.env.CLAUDE_MEM_DATA_DIR = tempDir;
|
||||
process.env.CLAUDE_MEM_EXPORT_MEMORIES_NO_MAIN = '1';
|
||||
writeFileSync(join(tempDir, 'settings.json'), JSON.stringify({
|
||||
CLAUDE_MEM_WORKER_PORT: '45678',
|
||||
}));
|
||||
|
||||
consoleSpies.push(
|
||||
spyOn(console, 'log').mockImplementation(() => {}),
|
||||
spyOn(console, 'warn').mockImplementation(() => {}),
|
||||
spyOn(console, 'error').mockImplementation(() => {}),
|
||||
);
|
||||
|
||||
const fetchMock = mock(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.startsWith('http://localhost:45678/api/search?')) {
|
||||
return new Response(JSON.stringify({
|
||||
observations: [{ memory_session_id: 'memory-a' }],
|
||||
sessions: [],
|
||||
prompts: [],
|
||||
}), { status: 200 });
|
||||
}
|
||||
|
||||
if (url === 'http://localhost:45678/api/sdk-sessions/batch') {
|
||||
return new Response('worker unavailable', {
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
});
|
||||
}
|
||||
|
||||
return new Response('unexpected url', { status: 500 });
|
||||
});
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
const { exportMemories } = await import('../../scripts/export-memories.ts');
|
||||
const outputFile = join(tempDir, 'export.json');
|
||||
|
||||
await expect(exportMemories('needle', outputFile)).rejects.toThrow(
|
||||
'Failed to fetch SDK sessions: 503 Service Unavailable worker unavailable',
|
||||
);
|
||||
expect(existsSync(outputFile)).toBe(false);
|
||||
});
|
||||
|
||||
it('fails deterministically when a worker request times out', async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'claude-mem-export-'));
|
||||
process.env.CLAUDE_MEM_DATA_DIR = tempDir;
|
||||
process.env.CLAUDE_MEM_EXPORT_MEMORIES_NO_MAIN = '1';
|
||||
writeFileSync(join(tempDir, 'settings.json'), JSON.stringify({
|
||||
CLAUDE_MEM_WORKER_PORT: '45678',
|
||||
}));
|
||||
|
||||
consoleSpies.push(
|
||||
spyOn(console, 'log').mockImplementation(() => {}),
|
||||
spyOn(console, 'warn').mockImplementation(() => {}),
|
||||
spyOn(console, 'error').mockImplementation(() => {}),
|
||||
);
|
||||
|
||||
const fetchMock = mock(async () => {
|
||||
throw new DOMException('The operation was aborted.', 'AbortError');
|
||||
});
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
const { exportMemories } = await import('../../scripts/export-memories.ts');
|
||||
|
||||
await expect(exportMemories('needle', join(tempDir, 'export.json'))).rejects.toThrow(
|
||||
'Worker request timed out after 30000ms',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
process.env.PR_BABYSIT_STATUS_NO_MAIN = '1';
|
||||
|
||||
describe('pr-babysit-status helpers', () => {
|
||||
it('extracts concise actionable hints from bot review bodies', async () => {
|
||||
const { extractActionableHints } = await import('../../scripts/pr-babysit-status.ts');
|
||||
|
||||
const hints = extractActionableHints(`
|
||||
**Actionable comments posted: 2**
|
||||
|
||||
<details>
|
||||
<summary>Prompt for all review comments with AI agents</summary>
|
||||
|
||||
Inline comments:
|
||||
In \`@src/file.ts\`:
|
||||
- Line 10: Replace the unsafe fallback with a checked path.
|
||||
- Around line 22: Treat a missing binary as stale.
|
||||
</details>
|
||||
`);
|
||||
|
||||
expect(hints).toContain('Actionable comments posted: 2');
|
||||
expect(hints).toContain('10: Replace the unsafe fallback with a checked path.');
|
||||
expect(hints).toContain('22: Treat a missing binary as stale.');
|
||||
expect(hints.join('\n')).not.toContain('Prompt for all review comments');
|
||||
});
|
||||
|
||||
it('extracts review comment headings without dumping full markdown', async () => {
|
||||
const { extractActionableHints } = await import('../../scripts/pr-babysit-status.ts');
|
||||
|
||||
const hints = extractActionableHints(`
|
||||
_Potential issue_ | _Major_ | _Quick win_
|
||||
|
||||
**Treat a missing current Bun binary as stale too.**
|
||||
|
||||
If the marker says this install was created with Bun but getBunVersion now
|
||||
returns null, this still reports the install as current and skips repair.
|
||||
`);
|
||||
|
||||
expect(hints).toContain('Treat a missing current Bun binary as stale too.');
|
||||
expect(hints.some(hint => hint.includes('skips repair'))).toBe(false);
|
||||
});
|
||||
|
||||
it('summarizes branch protection without requiring unavailable fields', async () => {
|
||||
const { summarizeProtection } = await import('../../scripts/pr-babysit-status.ts');
|
||||
|
||||
expect(summarizeProtection({
|
||||
required_pull_request_reviews: {
|
||||
dismiss_stale_reviews: true,
|
||||
require_code_owner_reviews: false,
|
||||
require_last_push_approval: false,
|
||||
required_approving_review_count: 1,
|
||||
},
|
||||
enforce_admins: { enabled: false },
|
||||
allow_force_pushes: { enabled: true },
|
||||
})).toEqual([
|
||||
'Required checks: none',
|
||||
'Required reviews: 1 approval',
|
||||
'Dismiss stale reviews: yes',
|
||||
'Code owner reviews: no',
|
||||
'Last-push approval: no',
|
||||
'Conversation resolution: no',
|
||||
'Signed commits: no',
|
||||
'Enforce admins: no',
|
||||
'Allow force pushes: yes',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,124 +1,315 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { ClaudeMemDatabase } from '../../../src/services/sqlite/Database.js';
|
||||
import { PendingMessageStore } from '../../../src/services/sqlite/PendingMessageStore.js';
|
||||
import { createSDKSession } from '../../../src/services/sqlite/Sessions.js';
|
||||
import type { PendingMessage } from '../../../src/services/worker-types.js';
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import type { Database } from 'bun:sqlite';
|
||||
import { SessionStore } from '../../../src/services/sqlite/SessionStore.js';
|
||||
import { PendingMessageStore } from '../../../src/services/sqlite/PendingMessageStore.js';
|
||||
import type { PendingMessage } from '../../../src/services/worker-types.js';
|
||||
|
||||
describe('PendingMessageStore - Self-Healing claimNextMessage', () => {
|
||||
let db: Database;
|
||||
let store: PendingMessageStore;
|
||||
let sessionDbId: number;
|
||||
const CONTENT_SESSION_ID = 'test-self-heal';
|
||||
function getColumnNames(db: Database, table: string): string[] {
|
||||
const quotedTable = `"${table.replace(/"/g, '""')}"`;
|
||||
return (db.prepare(`PRAGMA table_info(${quotedTable})`).all() as { name: string }[])
|
||||
.map(column => column.name);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db = new ClaudeMemDatabase(':memory:').db;
|
||||
store = new PendingMessageStore(db, 3);
|
||||
sessionDbId = createSDKSession(db, CONTENT_SESSION_ID, 'test-project', 'Test prompt');
|
||||
function getIndexNames(db: Database, table: string): string[] {
|
||||
const quotedTable = `"${table.replace(/"/g, '""')}"`;
|
||||
return (db.prepare(`PRAGMA index_list(${quotedTable})`).all() as { name: string }[])
|
||||
.map(index => index.name);
|
||||
}
|
||||
|
||||
function rebuildPendingMessagesWithoutToolUseId(db: Database): void {
|
||||
db.run('DROP INDEX IF EXISTS ux_pending_session_tool');
|
||||
db.run('DROP INDEX IF EXISTS idx_pending_messages_worker_pid');
|
||||
db.run('DROP TABLE IF EXISTS pending_messages_without_tool_use_id');
|
||||
db.run(`
|
||||
CREATE TABLE pending_messages_without_tool_use_id (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_db_id INTEGER NOT NULL,
|
||||
content_session_id TEXT NOT NULL,
|
||||
message_type TEXT NOT NULL CHECK(message_type IN ('observation', 'summarize')),
|
||||
tool_name TEXT,
|
||||
tool_input TEXT,
|
||||
tool_response TEXT,
|
||||
cwd TEXT,
|
||||
last_user_message TEXT,
|
||||
last_assistant_message TEXT,
|
||||
prompt_number INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'processing')),
|
||||
created_at_epoch INTEGER NOT NULL,
|
||||
agent_type TEXT,
|
||||
agent_id TEXT,
|
||||
FOREIGN KEY (session_db_id) REFERENCES sdk_sessions(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
db.run(`
|
||||
INSERT INTO pending_messages_without_tool_use_id (
|
||||
id, session_db_id, content_session_id, message_type, tool_name,
|
||||
tool_input, tool_response, cwd, last_user_message,
|
||||
last_assistant_message, prompt_number, status, created_at_epoch,
|
||||
agent_type, agent_id
|
||||
)
|
||||
SELECT
|
||||
id, session_db_id, content_session_id, message_type, tool_name,
|
||||
tool_input, tool_response, cwd, last_user_message,
|
||||
last_assistant_message, prompt_number, status, created_at_epoch,
|
||||
agent_type, agent_id
|
||||
FROM pending_messages
|
||||
`);
|
||||
db.run('DROP TABLE pending_messages');
|
||||
db.run('ALTER TABLE pending_messages_without_tool_use_id RENAME TO pending_messages');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_pending_messages_session ON pending_messages(session_db_id)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_pending_messages_status ON pending_messages(status)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_pending_messages_claude_session ON pending_messages(content_session_id)');
|
||||
}
|
||||
|
||||
function rebuildLegacyPendingMessagesWithDeadColumns(db: Database): void {
|
||||
db.run('DROP INDEX IF EXISTS ux_pending_session_tool');
|
||||
db.run('DROP INDEX IF EXISTS idx_pending_messages_worker_pid');
|
||||
db.run('DROP TABLE pending_messages');
|
||||
db.run(`
|
||||
CREATE TABLE pending_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_db_id INTEGER NOT NULL,
|
||||
content_session_id TEXT NOT NULL,
|
||||
message_type TEXT NOT NULL,
|
||||
tool_name TEXT,
|
||||
tool_input TEXT,
|
||||
tool_response TEXT,
|
||||
cwd TEXT,
|
||||
last_user_message TEXT,
|
||||
last_assistant_message TEXT,
|
||||
prompt_number INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
failed_at_epoch INTEGER,
|
||||
completed_at_epoch INTEGER,
|
||||
created_at_epoch INTEGER NOT NULL,
|
||||
agent_type TEXT,
|
||||
agent_id TEXT,
|
||||
tool_use_id TEXT,
|
||||
worker_pid INTEGER,
|
||||
FOREIGN KEY (session_db_id) REFERENCES sdk_sessions(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_pending_messages_session ON pending_messages(session_db_id)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_pending_messages_status ON pending_messages(status)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_pending_messages_claude_session ON pending_messages(content_session_id)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_pending_messages_worker_pid ON pending_messages(worker_pid)');
|
||||
}
|
||||
|
||||
function createPendingMessage(overrides: Partial<PendingMessage> = {}): PendingMessage {
|
||||
return {
|
||||
type: 'observation',
|
||||
tool_name: 'TestTool',
|
||||
tool_input: { test: 'input' },
|
||||
tool_response: { test: 'response' },
|
||||
prompt_number: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('PendingMessageStore current schema guardrails', () => {
|
||||
test('SessionStore repairs missing tool_use_id even when schema_versions says pending migrations already ran', () => {
|
||||
const initialStore = new SessionStore(':memory:');
|
||||
const db = initialStore.db;
|
||||
rebuildPendingMessagesWithoutToolUseId(db);
|
||||
|
||||
const repairedStore = new SessionStore(db);
|
||||
try {
|
||||
const columns = getColumnNames(db, 'pending_messages');
|
||||
expect(columns).toContain('tool_use_id');
|
||||
expect(columns).not.toContain('worker_pid');
|
||||
|
||||
const sessionDbId = repairedStore.createSDKSession('content-shape-repair', 'test-project', 'initial prompt');
|
||||
const pendingStore = new PendingMessageStore(db, () => {});
|
||||
|
||||
pendingStore.enqueue(sessionDbId, 'content-shape-repair', createPendingMessage({ toolUseId: 'tool-1' }));
|
||||
pendingStore.enqueue(sessionDbId, 'content-shape-repair', createPendingMessage({ toolUseId: 'tool-1' }));
|
||||
|
||||
const count = db.prepare(`
|
||||
SELECT COUNT(*) AS count
|
||||
FROM pending_messages
|
||||
WHERE content_session_id = ?
|
||||
`).get('content-shape-repair') as { count: number };
|
||||
expect(count.count).toBe(1);
|
||||
} finally {
|
||||
repairedStore.close();
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
test('SessionStore removes stale duplicate rows before creating the tool_use_id unique index', () => {
|
||||
const initialStore = new SessionStore(':memory:');
|
||||
const db = initialStore.db;
|
||||
const sessionDbId = initialStore.createSDKSession('content-stale-dedupe', 'test-project', 'initial prompt');
|
||||
rebuildLegacyPendingMessagesWithDeadColumns(db);
|
||||
db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(31, new Date().toISOString());
|
||||
db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(32, new Date().toISOString());
|
||||
db.prepare(`
|
||||
INSERT INTO pending_messages (
|
||||
id, session_db_id, content_session_id, message_type, status,
|
||||
created_at_epoch, tool_use_id, completed_at_epoch
|
||||
)
|
||||
VALUES (?, ?, ?, 'observation', ?, ?, ?, ?)
|
||||
`).run(1, sessionDbId, 'content-stale-dedupe', 'completed', 1000, 'tool-stale', 1100);
|
||||
db.prepare(`
|
||||
INSERT INTO pending_messages (
|
||||
id, session_db_id, content_session_id, message_type, status,
|
||||
created_at_epoch, tool_use_id
|
||||
)
|
||||
VALUES (?, ?, ?, 'observation', ?, ?, ?)
|
||||
`).run(2, sessionDbId, 'content-stale-dedupe', 'pending', 1200, 'tool-stale');
|
||||
|
||||
const repairedStore = new SessionStore(db);
|
||||
try {
|
||||
const rows = db.prepare(`
|
||||
SELECT id, status, tool_use_id
|
||||
FROM pending_messages
|
||||
WHERE content_session_id = ?
|
||||
`).all('content-stale-dedupe') as { id: number; status: string; tool_use_id: string }[];
|
||||
|
||||
expect(rows).toEqual([{ id: 2, status: 'pending', tool_use_id: 'tool-stale' }]);
|
||||
expect(getColumnNames(db, 'pending_messages')).not.toContain('completed_at_epoch');
|
||||
expect(getColumnNames(db, 'pending_messages')).not.toContain('worker_pid');
|
||||
expect(getIndexNames(db, 'pending_messages')).toContain('ux_pending_session_tool');
|
||||
} finally {
|
||||
repairedStore.close();
|
||||
}
|
||||
});
|
||||
|
||||
function enqueueMessage(overrides: Partial<PendingMessage> = {}): number {
|
||||
const message: PendingMessage = {
|
||||
type: 'observation',
|
||||
tool_name: 'TestTool',
|
||||
tool_input: { test: 'input' },
|
||||
tool_response: { test: 'response' },
|
||||
prompt_number: 1,
|
||||
...overrides,
|
||||
test('SessionStore preserves processing duplicate rows during tool_use_id dedupe', () => {
|
||||
const initialStore = new SessionStore(':memory:');
|
||||
const db = initialStore.db;
|
||||
const sessionDbId = initialStore.createSDKSession('content-processing-dedupe', 'test-project', 'initial prompt');
|
||||
rebuildLegacyPendingMessagesWithDeadColumns(db);
|
||||
db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(31, new Date().toISOString());
|
||||
db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(32, new Date().toISOString());
|
||||
db.prepare(`
|
||||
INSERT INTO pending_messages (
|
||||
id, session_db_id, content_session_id, message_type, status,
|
||||
created_at_epoch, tool_use_id
|
||||
)
|
||||
VALUES (?, ?, ?, 'observation', ?, ?, ?)
|
||||
`).run(1, sessionDbId, 'content-processing-dedupe', 'pending', 1000, 'tool-in-flight');
|
||||
db.prepare(`
|
||||
INSERT INTO pending_messages (
|
||||
id, session_db_id, content_session_id, message_type, status,
|
||||
created_at_epoch, tool_use_id
|
||||
)
|
||||
VALUES (?, ?, ?, 'observation', ?, ?, ?)
|
||||
`).run(2, sessionDbId, 'content-processing-dedupe', 'processing', 1100, 'tool-in-flight');
|
||||
|
||||
const repairedStore = new SessionStore(db);
|
||||
try {
|
||||
const rows = db.prepare(`
|
||||
SELECT id, status, tool_use_id
|
||||
FROM pending_messages
|
||||
WHERE content_session_id = ?
|
||||
`).all('content-processing-dedupe') as { id: number; status: string; tool_use_id: string }[];
|
||||
|
||||
expect(rows).toEqual([{ id: 2, status: 'processing', tool_use_id: 'tool-in-flight' }]);
|
||||
} finally {
|
||||
repairedStore.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('SessionStore does not stamp dead-column cleanup when a drop fails', () => {
|
||||
const initialStore = new SessionStore(':memory:');
|
||||
const db = initialStore.db;
|
||||
const sessionDbId = initialStore.createSDKSession('content-drop-failure', 'test-project', 'initial prompt');
|
||||
rebuildLegacyPendingMessagesWithDeadColumns(db);
|
||||
db.prepare('DELETE FROM schema_versions WHERE version IN (31, 32)').run();
|
||||
db.prepare(`
|
||||
INSERT INTO pending_messages (
|
||||
id, session_db_id, content_session_id, message_type, status,
|
||||
created_at_epoch, tool_use_id, completed_at_epoch
|
||||
)
|
||||
VALUES (?, ?, ?, 'observation', 'completed', ?, ?, ?)
|
||||
`).run(1, sessionDbId, 'content-drop-failure', 1000, 'tool-completed', 1100);
|
||||
|
||||
const originalRun = db.run.bind(db);
|
||||
(db as any).run = (query: string, ...bindings: unknown[]) => {
|
||||
if (query.includes('ALTER TABLE pending_messages DROP COLUMN completed_at_epoch')) {
|
||||
throw new Error('simulated drop failure');
|
||||
}
|
||||
return originalRun(query, ...bindings);
|
||||
};
|
||||
return store.enqueue(sessionDbId, CONTENT_SESSION_ID, message);
|
||||
}
|
||||
|
||||
function makeMessageStaleProcessing(messageId: number): void {
|
||||
const staleTimestamp = Date.now() - 120_000;
|
||||
db.run(
|
||||
`UPDATE pending_messages SET status = 'processing', started_processing_at_epoch = ? WHERE id = ?`,
|
||||
[staleTimestamp, messageId]
|
||||
);
|
||||
}
|
||||
const repairedStore = new SessionStore(db);
|
||||
try {
|
||||
const version31 = db
|
||||
.prepare('SELECT version FROM schema_versions WHERE version = ?')
|
||||
.get(31);
|
||||
|
||||
test('stuck processing messages are recovered on next claim', () => {
|
||||
const msgId = enqueueMessage();
|
||||
makeMessageStaleProcessing(msgId);
|
||||
|
||||
const beforeClaim = db.query('SELECT status FROM pending_messages WHERE id = ?').get(msgId) as { status: string };
|
||||
expect(beforeClaim.status).toBe('processing');
|
||||
|
||||
const claimed = store.claimNextMessage(sessionDbId);
|
||||
|
||||
expect(claimed).not.toBeNull();
|
||||
expect(claimed!.id).toBe(msgId);
|
||||
const afterClaim = db.query('SELECT status FROM pending_messages WHERE id = ?').get(msgId) as { status: string };
|
||||
expect(afterClaim.status).toBe('processing');
|
||||
expect(version31).toBeNull();
|
||||
expect(getColumnNames(db, 'pending_messages')).toContain('completed_at_epoch');
|
||||
const rowCount = db.prepare(`
|
||||
SELECT COUNT(*) AS count
|
||||
FROM pending_messages
|
||||
WHERE content_session_id = ? AND status = 'completed'
|
||||
`).get('content-drop-failure') as { count: number };
|
||||
expect(rowCount.count).toBe(1);
|
||||
} finally {
|
||||
(db as any).run = originalRun;
|
||||
repairedStore.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('actively processing messages are NOT recovered', () => {
|
||||
const activeId = enqueueMessage();
|
||||
const pendingId = enqueueMessage();
|
||||
test('SessionStore keeps null tool_use_id rows because summaries and legacy rows may not have tool ids', () => {
|
||||
const store = new SessionStore(':memory:');
|
||||
const db = store.db;
|
||||
const sessionDbId = store.createSDKSession('content-null-tool', 'test-project', 'initial prompt');
|
||||
|
||||
const recentTimestamp = Date.now() - 5_000;
|
||||
db.run(
|
||||
`UPDATE pending_messages SET status = 'processing', started_processing_at_epoch = ? WHERE id = ?`,
|
||||
[recentTimestamp, activeId]
|
||||
);
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO pending_messages (
|
||||
session_db_id, content_session_id, message_type, status, created_at_epoch, tool_use_id
|
||||
)
|
||||
VALUES (?, ?, 'summarize', 'pending', ?, NULL)
|
||||
`).run(sessionDbId, 'content-null-tool', 1000);
|
||||
|
||||
const claimed = store.claimNextMessage(sessionDbId);
|
||||
db.prepare(`
|
||||
INSERT INTO pending_messages (
|
||||
session_db_id, content_session_id, message_type, status, created_at_epoch, tool_use_id
|
||||
)
|
||||
VALUES (?, ?, 'summarize', 'pending', ?, NULL)
|
||||
`).run(sessionDbId, 'content-null-tool', 1001);
|
||||
|
||||
expect(claimed).not.toBeNull();
|
||||
expect(claimed!.id).toBe(pendingId);
|
||||
const rows = db.prepare(`
|
||||
SELECT COUNT(*) AS count
|
||||
FROM pending_messages
|
||||
WHERE content_session_id = ? AND tool_use_id IS NULL
|
||||
`).get('content-null-tool') as { count: number };
|
||||
|
||||
const activeMsg = db.query('SELECT status FROM pending_messages WHERE id = ?').get(activeId) as { status: string };
|
||||
expect(activeMsg.status).toBe('processing');
|
||||
expect(rows.count).toBe(2);
|
||||
} finally {
|
||||
store.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('recovery and claim is atomic within single call', () => {
|
||||
const stuckId = enqueueMessage();
|
||||
const pendingId1 = enqueueMessage();
|
||||
const pendingId2 = enqueueMessage();
|
||||
test('fresh SessionStore pending_messages shape does not require worker_pid for enqueue and claim', () => {
|
||||
const store = new SessionStore(':memory:');
|
||||
try {
|
||||
const db = store.db;
|
||||
const columns = getColumnNames(db, 'pending_messages');
|
||||
const indexes = getIndexNames(db, 'pending_messages');
|
||||
|
||||
makeMessageStaleProcessing(stuckId);
|
||||
expect(columns).toContain('tool_use_id');
|
||||
expect(columns).not.toContain('worker_pid');
|
||||
expect(indexes).not.toContain('idx_pending_messages_worker_pid');
|
||||
|
||||
const claimed = store.claimNextMessage(sessionDbId);
|
||||
const sessionDbId = store.createSDKSession('content-claim-current', 'test-project', 'initial prompt');
|
||||
const pendingStore = new PendingMessageStore(db, () => {});
|
||||
const messageId = pendingStore.enqueue(
|
||||
sessionDbId,
|
||||
'content-claim-current',
|
||||
createPendingMessage({ toolUseId: 'tool-claim' })
|
||||
);
|
||||
|
||||
expect(claimed).not.toBeNull();
|
||||
expect(claimed!.id).toBe(stuckId);
|
||||
|
||||
const msg1 = db.query('SELECT status FROM pending_messages WHERE id = ?').get(pendingId1) as { status: string };
|
||||
const msg2 = db.query('SELECT status FROM pending_messages WHERE id = ?').get(pendingId2) as { status: string };
|
||||
expect(msg1.status).toBe('pending');
|
||||
expect(msg2.status).toBe('pending');
|
||||
});
|
||||
|
||||
test('no messages returns null without error', () => {
|
||||
const claimed = store.claimNextMessage(sessionDbId);
|
||||
expect(claimed).toBeNull();
|
||||
});
|
||||
|
||||
test('self-healing only affects the specified session', () => {
|
||||
const session2Id = createSDKSession(db, 'other-session', 'test-project', 'Test');
|
||||
|
||||
const stuckInSession1 = enqueueMessage();
|
||||
makeMessageStaleProcessing(stuckInSession1);
|
||||
|
||||
const msg: PendingMessage = {
|
||||
type: 'observation',
|
||||
tool_name: 'TestTool',
|
||||
tool_input: { test: 'input' },
|
||||
tool_response: { test: 'response' },
|
||||
prompt_number: 1,
|
||||
};
|
||||
const session2MsgId = store.enqueue(session2Id, 'other-session', msg);
|
||||
makeMessageStaleProcessing(session2MsgId);
|
||||
|
||||
const claimed = store.claimNextMessage(session2Id);
|
||||
expect(claimed).not.toBeNull();
|
||||
expect(claimed!.id).toBe(session2MsgId);
|
||||
|
||||
const session1Msg = db.query('SELECT status FROM pending_messages WHERE id = ?').get(stuckInSession1) as { status: string };
|
||||
expect(session1Msg.status).toBe('processing');
|
||||
const claimed = pendingStore.claimNextMessage(sessionDbId) as ({ id: number; tool_use_id: string | null } | null);
|
||||
expect(claimed).not.toBeNull();
|
||||
expect(claimed!.id).toBe(messageId);
|
||||
expect(claimed!.tool_use_id).toBe('tool-claim');
|
||||
} finally {
|
||||
store.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it, mock } from 'bun:test';
|
||||
import type { ActiveSession } from '../../../src/services/worker-types.js';
|
||||
import { handleGeneratorExit } from '../../../src/services/worker/session/GeneratorExitHandler.js';
|
||||
|
||||
function createSession(): ActiveSession {
|
||||
return {
|
||||
sessionDbId: 42,
|
||||
contentSessionId: 'content-42',
|
||||
memorySessionId: 'memory-42',
|
||||
project: 'test-project',
|
||||
platformSource: 'claude-code',
|
||||
userPrompt: 'test',
|
||||
pendingMessages: [],
|
||||
abortController: new AbortController(),
|
||||
generatorPromise: Promise.resolve(),
|
||||
lastPromptNumber: 1,
|
||||
startTime: Date.now(),
|
||||
cumulativeInputTokens: 0,
|
||||
cumulativeOutputTokens: 0,
|
||||
earliestPendingTimestamp: null,
|
||||
conversationHistory: [],
|
||||
currentProvider: 'claude',
|
||||
consecutiveRestarts: 0,
|
||||
lastGeneratorActivity: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function createDeps(pendingCount = 3) {
|
||||
const pendingStore = {
|
||||
clearPendingForSession: mock(() => undefined),
|
||||
getPendingCount: mock(() => pendingCount),
|
||||
};
|
||||
const sessionManager = {
|
||||
getPendingMessageStore: mock(() => pendingStore),
|
||||
removeSessionImmediate: mock(() => undefined),
|
||||
};
|
||||
const completionHandler = {
|
||||
finalizeSession: mock(() => undefined),
|
||||
};
|
||||
const restartGenerator = mock(() => undefined);
|
||||
|
||||
return {
|
||||
pendingStore,
|
||||
sessionManager,
|
||||
completionHandler,
|
||||
restartGenerator,
|
||||
deps: {
|
||||
sessionManager: sessionManager as any,
|
||||
completionHandler: completionHandler as any,
|
||||
restartGenerator,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('handleGeneratorExit hard-stop reasons', () => {
|
||||
it('does not restart pending work after context overflow', async () => {
|
||||
const session = createSession();
|
||||
const { deps, pendingStore, completionHandler, sessionManager, restartGenerator } = createDeps();
|
||||
|
||||
await handleGeneratorExit(session, 'overflow', deps);
|
||||
|
||||
expect(pendingStore.clearPendingForSession).toHaveBeenCalledWith(42);
|
||||
expect(completionHandler.finalizeSession).toHaveBeenCalledWith(42);
|
||||
expect(sessionManager.removeSessionImmediate).toHaveBeenCalledWith(42);
|
||||
expect(pendingStore.getPendingCount).not.toHaveBeenCalled();
|
||||
expect(restartGenerator).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not restart pending work while quota guard is active', async () => {
|
||||
const session = createSession();
|
||||
const { deps, pendingStore, completionHandler, sessionManager, restartGenerator } = createDeps();
|
||||
|
||||
await handleGeneratorExit(session, 'quota:hourly', deps);
|
||||
|
||||
expect(pendingStore.clearPendingForSession).toHaveBeenCalledWith(42);
|
||||
expect(completionHandler.finalizeSession).toHaveBeenCalledWith(42);
|
||||
expect(sessionManager.removeSessionImmediate).toHaveBeenCalledWith(42);
|
||||
expect(pendingStore.getPendingCount).not.toHaveBeenCalled();
|
||||
expect(restartGenerator).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes hard-stopped sessions even when pending cleanup fails', async () => {
|
||||
const session = createSession();
|
||||
const { deps, pendingStore, completionHandler, sessionManager, restartGenerator } = createDeps();
|
||||
pendingStore.clearPendingForSession.mockImplementation(() => {
|
||||
throw new Error('simulated pending cleanup failure');
|
||||
});
|
||||
|
||||
await handleGeneratorExit(session, 'overflow', deps);
|
||||
|
||||
expect(pendingStore.clearPendingForSession).toHaveBeenCalledWith(42);
|
||||
expect(completionHandler.finalizeSession).toHaveBeenCalledWith(42);
|
||||
expect(sessionManager.removeSessionImmediate).toHaveBeenCalledWith(42);
|
||||
expect(pendingStore.getPendingCount).not.toHaveBeenCalled();
|
||||
expect(restartGenerator).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes hard-stopped sessions even when finalization fails', async () => {
|
||||
const session = createSession();
|
||||
const { deps, pendingStore, completionHandler, sessionManager, restartGenerator } = createDeps();
|
||||
completionHandler.finalizeSession.mockImplementation(() => {
|
||||
throw new Error('simulated finalization failure');
|
||||
});
|
||||
|
||||
await handleGeneratorExit(session, 'quota', deps);
|
||||
|
||||
expect(pendingStore.clearPendingForSession).toHaveBeenCalledWith(42);
|
||||
expect(completionHandler.finalizeSession).toHaveBeenCalledWith(42);
|
||||
expect(sessionManager.removeSessionImmediate).toHaveBeenCalledWith(42);
|
||||
expect(pendingStore.getPendingCount).not.toHaveBeenCalled();
|
||||
expect(restartGenerator).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes naturally completed sessions even when finalization fails', async () => {
|
||||
const session = createSession();
|
||||
const { deps, pendingStore, completionHandler, sessionManager, restartGenerator } = createDeps(0);
|
||||
completionHandler.finalizeSession.mockImplementation(() => {
|
||||
throw new Error('simulated finalization failure');
|
||||
});
|
||||
|
||||
await handleGeneratorExit(session, 'idle', deps);
|
||||
|
||||
expect(pendingStore.clearPendingForSession).not.toHaveBeenCalled();
|
||||
expect(completionHandler.finalizeSession).toHaveBeenCalledWith(42);
|
||||
expect(sessionManager.removeSessionImmediate).toHaveBeenCalledWith(42);
|
||||
expect(restartGenerator).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,18 @@ describe('setup-runtime install marker', () => {
|
||||
expect(marker?.bun).toBe('1.0.0');
|
||||
expect(marker?.uv).toBe('0.5.0');
|
||||
});
|
||||
|
||||
it('returns parsed marker when file is a legacy plain-text version', () => {
|
||||
writeFileSync(join(tempDir, '.install-version'), '12.4.4\n');
|
||||
const marker = readInstallMarker(tempDir);
|
||||
expect(marker).toEqual({ version: '12.4.4' });
|
||||
});
|
||||
|
||||
it('normalizes a leading v in legacy plain-text versions', () => {
|
||||
writeFileSync(join(tempDir, '.install-version'), 'v12.4.4\n');
|
||||
const marker = readInstallMarker(tempDir);
|
||||
expect(marker).toEqual({ version: '12.4.4' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeInstallMarker', () => {
|
||||
@@ -109,5 +121,15 @@ describe('setup-runtime install marker', () => {
|
||||
writeInstallMarker(tempDir, '1.0.0', bunVersion, '0.1.0');
|
||||
expect(isInstallCurrent(tempDir, '1.0.0')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a matching legacy plain-text marker when bun is available', () => {
|
||||
const bunVersion = probeBunVersion();
|
||||
if (!bunVersion) {
|
||||
return;
|
||||
}
|
||||
mkdirSync(join(tempDir, 'node_modules'));
|
||||
writeFileSync(join(tempDir, '.install-version'), '1.0.0\n');
|
||||
expect(isInstallCurrent(tempDir, '1.0.0')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -215,9 +215,9 @@ describe('Transactions Module', () => {
|
||||
expect(result.observationIds).toHaveLength(1);
|
||||
expect(result.summaryId).not.toBeNull();
|
||||
|
||||
const msgStmt = db.prepare('SELECT status FROM pending_messages WHERE id = ?');
|
||||
const msg = msgStmt.get(messageId) as { status: string } | undefined;
|
||||
expect(msg?.status).toBe('processed');
|
||||
const msgStmt = db.prepare('SELECT id FROM pending_messages WHERE id = ?');
|
||||
const msg = msgStmt.get(messageId) as { id: number } | null;
|
||||
expect(msg).toBeNull();
|
||||
});
|
||||
|
||||
it('should maintain atomicity - all operations share same timestamp', () => {
|
||||
@@ -284,5 +284,26 @@ describe('Transactions Module', () => {
|
||||
expect(result.observationIds).toHaveLength(1);
|
||||
expect(result.summaryId).toBeNull();
|
||||
});
|
||||
|
||||
it('should roll back stored observations when the pending message is not completed', () => {
|
||||
const { memorySessionId } = createSessionWithMemoryId('content-missing-pending', 'missing-pending-session');
|
||||
const observations = [createObservationInput({ title: 'Rollback Obs' })];
|
||||
|
||||
expect(() => storeObservationsAndMarkComplete(
|
||||
db,
|
||||
memorySessionId,
|
||||
'test-project',
|
||||
observations,
|
||||
null,
|
||||
99999
|
||||
)).toThrow('storeObservationsAndMarkComplete: failed to complete pending message 99999');
|
||||
|
||||
const count = db.prepare(`
|
||||
SELECT COUNT(*) AS count
|
||||
FROM observations
|
||||
WHERE title = ?
|
||||
`).get('Rollback Obs') as { count: number };
|
||||
expect(count.count).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -193,6 +193,25 @@ describe('DataRoutes Type Coercion', () => {
|
||||
expect(jsonSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept legacy sdkSessionIds as a compatibility alias', () => {
|
||||
const { req, res, jsonSpy } = createMockReqRes({ sdkSessionIds: ['abc', 'def'] });
|
||||
handler(req as Request, res as Response);
|
||||
|
||||
expect(mockGetSdkSessionsBySessionIds).toHaveBeenCalledWith(['abc', 'def']);
|
||||
expect(jsonSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prefer canonical memorySessionIds when both fields are provided', () => {
|
||||
const { req, res, jsonSpy } = createMockReqRes({
|
||||
memorySessionIds: ['canonical'],
|
||||
sdkSessionIds: ['legacy'],
|
||||
});
|
||||
handler(req as Request, res as Response);
|
||||
|
||||
expect(mockGetSdkSessionsBySessionIds).toHaveBeenCalledWith(['canonical']);
|
||||
expect(jsonSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject non-array, non-string values', () => {
|
||||
const { req, res, statusSpy } = createMockReqRes({ memorySessionIds: 42 });
|
||||
handler(req as Request, res as Response);
|
||||
|
||||
Reference in New Issue
Block a user