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