Merge remote-tracking branch 'origin/main' into fix-and-ship-codex-mem-search-access

# Conflicts:
#	.mcp.json
#	plugin/.mcp.json
#	plugin/scripts/mcp-server.cjs
#	plugin/scripts/worker-service.cjs
#	tests/infrastructure/plugin-distribution.test.ts
This commit is contained in:
Alex Newman
2026-05-06 19:10:49 -07:00
31 changed files with 2178 additions and 579 deletions
+16
View File
@@ -43,6 +43,14 @@ export function isWorkerUnavailableError(error: unknown): boolean {
return false;
}
export function isNonBlockingHookInputError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
const lower = message.toLowerCase();
return lower.includes('transcript path') &&
(lower.includes('missing') || lower.includes('does not exist'));
}
async function executeHookPipeline(
adapter: ReturnType<typeof getPlatformAdapter>,
handler: ReturnType<typeof getEventHandler>,
@@ -81,6 +89,14 @@ export async function hookCommand(platform: string, event: string, options: Hook
}
return HOOK_EXIT_CODES.SUCCESS;
}
if (isNonBlockingHookInputError(error)) {
logger.warn('HOOK', `Hook input unavailable, skipping hook: ${error instanceof Error ? error.message : error}`);
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
if (!options.skipExit) {
process.exit(HOOK_EXIT_CODES.SUCCESS);
}
return HOOK_EXIT_CODES.SUCCESS;
}
if (isWorkerUnavailableError(error)) {
logger.warn('HOOK', `Worker unavailable, skipping hook: ${error instanceof Error ? error.message : error}`);
if (!options.skipExit) {
+18 -2
View File
@@ -20,6 +20,9 @@ interface MarkerSchema {
installedAt?: string;
}
const LEGACY_VERSION_MARKER_RE =
/^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
function markerPath(targetDir: string): string {
return join(targetDir, '.install-version');
}
@@ -238,11 +241,22 @@ export async function installPluginDependencies(targetDir: string, bunPath: stri
export function readInstallMarker(targetDir: string): MarkerSchema | null {
const path = markerPath(targetDir);
if (!existsSync(path)) return null;
const content = readFileSync(path, 'utf-8');
try {
return JSON.parse(readFileSync(path, 'utf-8')) as MarkerSchema;
const marker = JSON.parse(content);
if (marker && typeof marker === 'object' && typeof marker.version === 'string') {
return marker as MarkerSchema;
}
} catch {
return null;
// Legacy installs wrote only the version string as plain text.
}
const legacyVersion = content.trim();
if (LEGACY_VERSION_MARKER_RE.test(legacyVersion)) {
return { version: legacyVersion.replace(/^v/i, '') };
}
return null;
}
export function writeInstallMarker(
@@ -266,6 +280,8 @@ export function isInstallCurrent(targetDir: string, expectedVersion: string): bo
if (!marker) return false;
if (marker.version !== expectedVersion) return false;
const currentBun = getBunVersion();
if (currentBun && !marker.bun) return false;
if (!currentBun && marker.bun) return false;
if (currentBun && marker.bun && currentBun !== marker.bun) return false;
return true;
}
+50 -32
View File
@@ -66,19 +66,19 @@ export class SessionStore {
this.addObservationModelColumns();
this.ensureMergedIntoProjectColumns();
this.addObservationSubagentColumns();
this.addPendingMessagesToolUseIdAndWorkerPidColumns();
this.addObservationsUniqueContentHashIndex();
this.addObservationsMetadataColumn();
this.dropDeadPendingMessagesColumns();
this.ensurePendingMessagesToolUseIdColumn();
this.dropWorkerPidColumn();
}
private dropWorkerPidColumn(): void {
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(32) as SchemaVersion | undefined;
if (applied) return;
const cols = this.db.query('PRAGMA table_info(pending_messages)').all() as TableColumnInfo[];
const hasColumn = cols.some(c => c.name === 'worker_pid');
if (applied && !hasColumn) return;
if (hasColumn) {
try {
@@ -87,35 +87,47 @@ export class SessionStore {
logger.debug('DB', 'Dropped worker_pid column and its index from pending_messages');
} catch (error) {
logger.warn('DB', 'Failed to drop worker_pid column from pending_messages', {}, error instanceof Error ? error : new Error(String(error)));
return;
}
}
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(32, new Date().toISOString());
if (!applied) {
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(32, new Date().toISOString());
}
}
private dropDeadPendingMessagesColumns(): void {
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(31) as SchemaVersion | undefined;
if (applied) return;
const cols = this.db.query('PRAGMA table_info(pending_messages)').all() as TableColumnInfo[];
const colNames = new Set(cols.map(c => c.name));
const deadColumns = ['retry_count', 'failed_at_epoch', 'completed_at_epoch', 'worker_pid'];
const deadColumns = ['retry_count', 'failed_at_epoch', 'completed_at_epoch'];
const toDrop = deadColumns.filter(name => colNames.has(name));
if (applied && toDrop.length === 0) return;
if (toDrop.length > 0) {
this.db.run(`DELETE FROM pending_messages WHERE status NOT IN ('pending', 'processing')`);
for (const colName of toDrop) {
try {
this.db.run('BEGIN TRANSACTION');
try {
this.db.run(`DELETE FROM pending_messages WHERE status NOT IN ('pending', 'processing')`);
for (const colName of toDrop) {
this.db.run(`ALTER TABLE pending_messages DROP COLUMN ${colName}`);
logger.debug('DB', `Dropped dead column ${colName} from pending_messages`);
} catch (error) {
logger.warn('DB', `Failed to drop column ${colName} from pending_messages`, {}, error instanceof Error ? error : new Error(String(error)));
}
if (!applied) {
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(31, new Date().toISOString());
}
this.db.run('COMMIT');
} catch (error) {
this.db.run('ROLLBACK');
logger.warn('DB', 'Failed to drop dead columns from pending_messages', {}, error instanceof Error ? error : new Error(String(error)));
return;
}
return;
}
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(31, new Date().toISOString());
if (!applied) {
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(31, new Date().toISOString());
}
}
private initializeSchema(): void {
@@ -899,7 +911,7 @@ export class SessionStore {
}
}
private addPendingMessagesToolUseIdAndWorkerPidColumns(): void {
private ensurePendingMessagesToolUseIdColumn(): void {
const tables = this.db.query(
"SELECT name FROM sqlite_master WHERE type='table' AND name='pending_messages'"
).all() as TableNameRow[];
@@ -910,29 +922,36 @@ export class SessionStore {
const cols = this.db.query('PRAGMA table_info(pending_messages)').all() as TableColumnInfo[];
const hasToolUseId = cols.some(c => c.name === 'tool_use_id');
const hasWorkerPid = cols.some(c => c.name === 'worker_pid');
if (!hasToolUseId) {
this.db.run('ALTER TABLE pending_messages ADD COLUMN tool_use_id TEXT');
}
if (!hasWorkerPid) {
this.db.run('ALTER TABLE pending_messages ADD COLUMN worker_pid INTEGER');
}
this.db.run('BEGIN TRANSACTION');
try {
this.db.run('CREATE INDEX IF NOT EXISTS idx_pending_messages_worker_pid ON pending_messages(worker_pid)');
this.db.run(`
DELETE FROM pending_messages
WHERE tool_use_id IS NOT NULL
AND id NOT IN (
SELECT MIN(id) FROM pending_messages
WHERE tool_use_id IS NOT NULL
GROUP BY content_session_id, tool_use_id
WHERE id IN (
SELECT id
FROM (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY content_session_id, tool_use_id
ORDER BY CASE status
WHEN 'processing' THEN 0
WHEN 'pending' THEN 1
ELSE 2
END, id
) AS duplicate_rank
FROM pending_messages
WHERE tool_use_id IS NOT NULL
)
WHERE duplicate_rank > 1
)
`);
this.db.run(`
-- tool_use_id is optional for summaries and legacy rows; enforce de-dupe
-- only for rows that came from a concrete tool-use event.
CREATE UNIQUE INDEX IF NOT EXISTS ux_pending_session_tool
ON pending_messages(content_session_id, tool_use_id)
WHERE tool_use_id IS NOT NULL
@@ -2061,16 +2080,15 @@ export class SessionStore {
summaryId = Number(result.lastInsertRowid);
}
const updateStmt = this.db.prepare(`
UPDATE pending_messages
SET
status = 'processed',
completed_at_epoch = ?,
tool_input = NULL,
tool_response = NULL
// Current queue rows are live work only; completed work is removed, not retained as processed.
const deleteStmt = this.db.prepare(`
DELETE FROM pending_messages
WHERE id = ? AND status = 'processing'
`);
updateStmt.run(timestampEpoch, messageId);
const deleteResult = deleteStmt.run(messageId);
if (deleteResult.changes !== 1) {
throw new Error(`storeObservationsAndMarkComplete: failed to complete pending message ${messageId}`);
}
return { observationIds, summaryId, createdAtEpoch: timestampEpoch };
});
+6 -6
View File
@@ -1,7 +1,8 @@
-- claude-mem SQLite schema
--
-- Authoritative shape of the database after all migrations through
-- runner.ts have been applied (current tip = migration 29). Fresh
-- runner.ts have been applied (current runner tip = migration 31;
-- SessionStore boot repair records migration 32). Fresh
-- databases boot directly into this shape; existing databases reach
-- it via the migration runner.
--
@@ -11,8 +12,8 @@
-- Invariants enforced here (Plan 01):
-- * pending_messages.UNIQUE(content_session_id, tool_use_id) — replaces
-- in-memory pendingTools Map for ingestion pairing (Plan 03 also depends).
-- * pending_messages.worker_pid INTEGER — populated by self-healing
-- claim query; replaces the legacy stale-reset epoch column.
-- * pending_messages only needs pending/processing status for current
-- claim handling; worker_pid and stale-reset epoch columns are legacy.
-- * observations.UNIQUE(memory_session_id, content_hash) — replaces the
-- legacy dedup window; ON CONFLICT DO NOTHING absorbs duplicates.
@@ -120,8 +121,8 @@ CREATE INDEX IF NOT EXISTS idx_summaries_merged_into ON session_summari
-- ─────────────────────────────────────────────────────────────────────
-- pending_messages: persistent work queue for SDK messages.
-- worker_pid + UNIQUE(content_session_id, tool_use_id) make the claim
-- query self-healing without any legacy stale-reset epoch column.
-- UNIQUE(content_session_id, tool_use_id) preserves ingestion pairing without
-- any legacy worker_pid or stale-reset epoch column.
-- ─────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pending_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -147,7 +148,6 @@ CREATE TABLE IF NOT EXISTS pending_messages (
CREATE INDEX IF NOT EXISTS idx_pending_messages_session ON pending_messages(session_db_id);
CREATE INDEX IF NOT EXISTS idx_pending_messages_status ON pending_messages(status);
CREATE INDEX IF NOT EXISTS idx_pending_messages_claude_session ON pending_messages(content_session_id);
CREATE INDEX IF NOT EXISTS idx_pending_messages_worker_pid ON pending_messages(worker_pid);
CREATE UNIQUE INDEX IF NOT EXISTS ux_pending_session_tool
ON pending_messages(content_session_id, tool_use_id)
WHERE tool_use_id IS NOT NULL;
+7 -8
View File
@@ -104,16 +104,15 @@ export function storeObservationsAndMarkComplete(
summaryId = Number(result.lastInsertRowid);
}
const updateStmt = db.prepare(`
UPDATE pending_messages
SET
status = 'processed',
completed_at_epoch = ?,
tool_input = NULL,
tool_response = NULL
// Current queue rows are live work only; completed work is removed, not retained as processed.
const deleteStmt = db.prepare(`
DELETE FROM pending_messages
WHERE id = ? AND status = 'processing'
`);
updateStmt.run(timestampEpoch, messageId);
const deleteResult = deleteStmt.run(messageId);
if (deleteResult.changes !== 1) {
throw new Error(`storeObservationsAndMarkComplete: failed to complete pending message ${messageId}`);
}
return { observationIds, summaryId, createdAtEpoch: timestampEpoch };
});
+11 -2
View File
@@ -53,9 +53,18 @@ const observationsBatchSchema = z.object({
project: z.string().optional(),
}).passthrough();
const sdkSessionsBatchSchema = z.object({
const sdkSessionsBatchSchema = z.preprocess((value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
const body = value as Record<string, unknown>;
if (body.memorySessionIds === undefined && body.sdkSessionIds !== undefined) {
return { ...body, memorySessionIds: body.sdkSessionIds };
}
return value;
}, z.object({
memorySessionIds: stringArrayLike,
}).passthrough();
}).passthrough());
const setProcessingSchema = z.object({}).passthrough();
@@ -11,6 +11,14 @@ export interface GeneratorExitDependencies {
restartGenerator: (session: ActiveSession, source: string) => void;
}
function isHardStopReason(reason: ActiveSession['abortReason']): boolean {
return reason === 'shutdown' ||
reason === 'restart-guard' ||
reason === 'overflow' ||
reason === 'quota' ||
(typeof reason === 'string' && reason.startsWith('quota:'));
}
/**
* Post-generator-exit handler. Under the new model:
* - 'processing' rows reset to 'pending' on next generator start (handled by SessionManager.getMessageIterator).
@@ -18,8 +26,8 @@ export interface GeneratorExitDependencies {
*
* Behavior:
* 1. Always: ensure SDK subprocess is dead.
* 2. Hard-stop reasons (shutdown / restart-guard): clear pending rows for the session and finalize.
* 3. Otherwise (idle / overflow / natural completion):
* 2. Hard-stop reasons (shutdown / restart-guard / overflow / quota): clear pending rows for the session and finalize.
* 3. Otherwise (idle / natural completion):
* - If 0 pending finalize.
* - If pending > 0 and restart guard allows respawn with backoff.
* - If guard tripped clear pending and finalize.
@@ -42,14 +50,39 @@ export async function handleGeneratorExit(
const pendingStore = sessionManager.getPendingMessageStore();
if (reason === 'shutdown' || reason === 'restart-guard') {
const terminateSession = (logPrefix: string, clearPending: boolean) => {
try {
if (clearPending) {
try {
pendingStore.clearPendingForSession(sessionDbId);
} catch (e) {
const normalized = e instanceof Error ? e : new Error(String(e));
logger.error('SESSION', `${logPrefix} pending cleanup failed; continuing finalization`, {
sessionId: sessionDbId,
reason
}, normalized);
}
}
try {
completionHandler.finalizeSession(sessionDbId);
} catch (e) {
const normalized = e instanceof Error ? e : new Error(String(e));
logger.error('SESSION', `${logPrefix} finalization failed; forcing in-memory session removal`, {
sessionId: sessionDbId,
reason
}, normalized);
}
} finally {
sessionManager.removeSessionImmediate(sessionDbId);
}
};
if (isHardStopReason(reason)) {
logger.info('SESSION', `Generator exited with hard-stop reason — clearing pending and finalizing`, {
sessionId: sessionDbId,
reason
});
pendingStore.clearPendingForSession(sessionDbId);
completionHandler.finalizeSession(sessionDbId);
sessionManager.removeSessionImmediate(sessionDbId);
terminateSession('Hard-stop', true);
return;
}
@@ -61,17 +94,14 @@ export async function handleGeneratorExit(
logger.error('SESSION', 'Error during recovery pending-count check; aborting to prevent leaks', {
sessionId: sessionDbId
}, normalized);
pendingStore.clearPendingForSession(sessionDbId);
completionHandler.finalizeSession(sessionDbId);
sessionManager.removeSessionImmediate(sessionDbId);
terminateSession('Recovery abort', true);
return;
}
if (pendingCount === 0) {
session.restartGuard?.recordSuccess();
session.consecutiveRestarts = 0;
completionHandler.finalizeSession(sessionDbId);
sessionManager.removeSessionImmediate(sessionDbId);
terminateSession('Natural completion', false);
return;
}
@@ -90,9 +120,7 @@ export async function handleGeneratorExit(
maxConsecutiveFailures: session.restartGuard.maxConsecutiveFailures,
});
session.consecutiveRestarts = 0;
pendingStore.clearPendingForSession(sessionDbId);
completionHandler.finalizeSession(sessionDbId);
sessionManager.removeSessionImmediate(sessionDbId);
terminateSession('Restart guard', true);
return;
}
+1 -1
View File
@@ -15,7 +15,7 @@ function getDirname(): string {
const _dirname = getDirname();
function resolveDataDir(): string {
export function resolveDataDir(): string {
if (process.env.CLAUDE_MEM_DATA_DIR) {
return process.env.CLAUDE_MEM_DATA_DIR;
}