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:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user