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:
Alex Newman
2026-05-06 18:29:26 -07:00
committed by GitHub
parent bb3dbfdb5a
commit 65f2fd8cdd
29 changed files with 2167 additions and 578 deletions
+28 -1
View File
@@ -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');
});
});
+65
View File
@@ -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',
);
});
});
+228
View File
@@ -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',
);
});
});
+68
View File
@@ -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',
]);
});
});
+292 -101
View File
@@ -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();
});
});
+22
View File
@@ -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);
});
});
});
+24 -3
View File
@@ -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);