Server-beta: Postgres storage + independent runtime + BullMQ queue (Phases 1–3) (#2351)
* Add server beta runtime foundation * Address server beta review findings * Resolve server beta review comments * Tighten server beta review follow-ups * Harden server beta auth and search * Avoid unnecessary FTS rebuilds * Block scoped keys from creating projects * Release BullMQ claims best effort on close * Address server beta review blockers * Reset BullMQ claims best effort * Add Postgres observation storage foundation * feat(server-beta): add independent runtime service Introduce src/server/runtime/ as a self-contained server-beta runtime that owns its lifecycle, Postgres bootstrap, and HTTP boundary without depending on WorkerService. ServerBetaService wraps the existing Server class, exposes /healthz and /v1/info with runtime="server-beta", and persists state to dedicated paths (.server-beta.pid|.port|.runtime.json). The four boundary managers (queue, generation worker, provider registry, event broadcaster) are intentionally disabled in this phase and report their status through /v1/info; later phases activate them. Adds plans/2026-05-07-finish-bullmq-branch-ship-plan.md to track the remaining work for this branch. Phase 2 of plans/2026-05-07-server-beta-independent-bullmq-observation-runtime.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(server-beta): route CLI lifecycle and bundle separate runtime scripts/build-hooks.js now produces plugin/scripts/server-beta-service.cjs as a separate Node CJS bundle, alongside the existing worker-service bundle. The server-beta runtime is now installable independently. src/npx-cli/commands/server.ts routes start|stop|restart|status to the server-beta lifecycle instead of the legacy worker. The worker keeps its own start|stop|restart|status under the worker namespace; the two runtimes can be operated independently. src/services/worker-service.ts adds a server-* command parser branch that delegates to the sibling server-beta-service.cjs bundle so direct worker-service invocations still route to the right runtime. tests/npx-cli-server-namespace.test.ts updated to expect server-beta lifecycle routing. Includes rebuilt plugin/scripts/*.cjs bundles produced by build-and-sync. Phase 2 of plans/2026-05-07-server-beta-independent-bullmq-observation-runtime.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(server-beta): add BullMQ job queue primitives Introduce src/server/jobs/ as the queue-side primitives that Phase 3 of the server-beta runtime needs to operate. types.ts defines a discriminated union over the four job kinds (event, event-batch, summary, reindex) and maps each to a per-kind BullMQ queue name and deterministic-ID prefix. job-id.ts builds deterministic, colon-free BullMQ jobIds from (kind, team, project, source). The colon ban exists because BullMQ uses ':' as a Redis key separator internally; embedding ':' in jobIds breaks scan and state lookups. ServerJobQueue.ts is a thin wrapper over BullMQ Queue + Worker that enforces autorun:false, default concurrency 1, and an attached error listener — all per BullMQ docs requirements. Test seams accept queue and worker factories so unit tests do not need Redis. outbox.ts publishes through the Postgres ObservationGenerationJob repository as canonical history. enqueueOutbox writes the row first, then publishes to BullMQ; if BullMQ throws, the row is transitioned to failed and a failed event is appended. reconcileOnStartup re-enqueues queued + processing rows after a restart, replacing terminal BullMQ jobs that may still be holding the deterministic ID slot. markCompleted and markFailed wrap transitionStatus and append the matching event row. Includes 20 unit tests covering deterministic ID stability, colon-free output, queue lifecycle, error-listener attachment, double-start refusal, idempotent enqueue, BullMQ failure rollback, startup reconciliation, max-attempts skipping, and completion / failure / retry transitions. Phase 3 commit 1 of plans/2026-05-07-server-beta-independent-bullmq-observation-runtime.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(server-beta): activate queue boundary in runtime service Wire ActiveServerBetaQueueManager into the server-beta runtime graph. The active manager owns one ServerJobQueue per generation kind (event, event-batch, summary, reindex) and surfaces lane metadata through boundary health. Selection is opt-in and fail-fast: if CLAUDE_MEM_QUEUE_ENGINE is set to bullmq the active manager is constructed (and any Redis/config error throws — no silent fallback to SQLite, per Phase 3 anti-pattern guard). For any other engine the disabled boundary remains so worker-era and test setups stay compatible. Widens ServerBetaBoundaryHealth.status to a discriminated union ('disabled' | 'active' | 'errored') with optional details. The disabled adapter still emits status='disabled', which keeps the existing server-beta-service test green. ServerBetaService receives the manager through a new optional queueManager field on CreateServerBetaServiceOptions so test graphs and Phase 4 wiring can inject custom managers. Adds tests/server/runtime/active-queue-manager.test.ts covering bullmq guard, active health shape, per-kind queue access, close behavior, and post-close errored health. Phase 3 commit 2 of plans/2026-05-07-server-beta-independent-bullmq-observation-runtime.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(server-beta): cap /v1/events/batch at 500 events Prevents unbounded array DoS surface flagged in PR review. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -87,10 +87,10 @@ function scanCleanupCounts(dbPath: string): CleanupCounts {
|
||||
+ (db.prepare(`SELECT COUNT(*) AS n FROM session_summaries WHERE memory_session_id IN (SELECT memory_session_id FROM sdk_sessions WHERE project = ? AND memory_session_id IS NOT NULL)`).get(OBSERVER_SESSIONS_PROJECT) as { n: number }).n;
|
||||
counts.stuckPendingMessages = (db.prepare(
|
||||
`SELECT COUNT(*) AS n FROM pending_messages
|
||||
WHERE status IN ('failed', 'processing')
|
||||
WHERE status = 'processing'
|
||||
AND session_db_id IN (
|
||||
SELECT session_db_id FROM pending_messages
|
||||
WHERE status IN ('failed', 'processing')
|
||||
WHERE status = 'processing'
|
||||
GROUP BY session_db_id
|
||||
HAVING COUNT(*) >= ?
|
||||
)`
|
||||
@@ -222,10 +222,10 @@ function runStuckPendingPurge(db: Database, counts: CleanupCounts): void {
|
||||
try {
|
||||
const stuckCount = (db.prepare(
|
||||
`SELECT COUNT(*) AS n FROM pending_messages
|
||||
WHERE status IN ('failed', 'processing')
|
||||
WHERE status = 'processing'
|
||||
AND session_db_id IN (
|
||||
SELECT session_db_id FROM pending_messages
|
||||
WHERE status IN ('failed', 'processing')
|
||||
WHERE status = 'processing'
|
||||
GROUP BY session_db_id
|
||||
HAVING COUNT(*) >= ?
|
||||
)`
|
||||
@@ -233,10 +233,10 @@ function runStuckPendingPurge(db: Database, counts: CleanupCounts): void {
|
||||
|
||||
db.run(
|
||||
`DELETE FROM pending_messages
|
||||
WHERE status IN ('failed', 'processing')
|
||||
WHERE status = 'processing'
|
||||
AND session_db_id IN (
|
||||
SELECT session_db_id FROM pending_messages
|
||||
WHERE status IN ('failed', 'processing')
|
||||
WHERE status = 'processing'
|
||||
GROUP BY session_db_id
|
||||
HAVING COUNT(*) >= ?
|
||||
)`,
|
||||
|
||||
@@ -9,6 +9,9 @@ export interface CreateIteratorOptions {
|
||||
sessionDbId: number;
|
||||
signal: AbortSignal;
|
||||
onIdleTimeout?: () => void;
|
||||
idleTimeoutMs?: number;
|
||||
claimRetryDelayMs?: number;
|
||||
maxClaimFailures?: number;
|
||||
}
|
||||
|
||||
export class SessionQueueProcessor {
|
||||
@@ -18,8 +21,16 @@ export class SessionQueueProcessor {
|
||||
) {}
|
||||
|
||||
async *createIterator(options: CreateIteratorOptions): AsyncIterableIterator<PendingMessageWithId> {
|
||||
const { sessionDbId, signal, onIdleTimeout } = options;
|
||||
const {
|
||||
sessionDbId,
|
||||
signal,
|
||||
onIdleTimeout,
|
||||
idleTimeoutMs = IDLE_TIMEOUT_MS,
|
||||
claimRetryDelayMs = 250,
|
||||
maxClaimFailures = 3
|
||||
} = options;
|
||||
let lastActivityTime = Date.now();
|
||||
let claimFailures = 0;
|
||||
|
||||
while (!signal.aborted) {
|
||||
let persistentMessage: PersistentPendingMessage | null = null;
|
||||
@@ -28,18 +39,25 @@ export class SessionQueueProcessor {
|
||||
} catch (error) {
|
||||
if (signal.aborted) return;
|
||||
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
||||
logger.error('QUEUE', 'Failed to claim next message; ending iterator', { sessionDbId }, normalizedError);
|
||||
return;
|
||||
claimFailures++;
|
||||
logger.error('QUEUE', 'Failed to claim next message', { sessionDbId, claimFailures, maxClaimFailures }, normalizedError);
|
||||
if (claimFailures >= maxClaimFailures) {
|
||||
logger.error('QUEUE', 'Claim failure limit reached; ending iterator', { sessionDbId, claimFailures }, normalizedError);
|
||||
return;
|
||||
}
|
||||
await this.waitForDelay(signal, claimRetryDelayMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (persistentMessage) {
|
||||
claimFailures = 0;
|
||||
lastActivityTime = Date.now();
|
||||
yield this.toPendingMessageWithId(persistentMessage);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const idleTimedOut = await this.handleWaitPhase(signal, lastActivityTime, sessionDbId, onIdleTimeout);
|
||||
const idleTimedOut = await this.handleWaitPhase(signal, lastActivityTime, sessionDbId, idleTimeoutMs, onIdleTimeout);
|
||||
if (idleTimedOut) return;
|
||||
lastActivityTime = Date.now();
|
||||
} catch (error) {
|
||||
@@ -64,17 +82,18 @@ export class SessionQueueProcessor {
|
||||
signal: AbortSignal,
|
||||
lastActivityTime: number,
|
||||
sessionDbId: number,
|
||||
idleTimeoutMs: number,
|
||||
onIdleTimeout?: () => void
|
||||
): Promise<boolean> {
|
||||
const receivedMessage = await this.waitForMessage(signal, IDLE_TIMEOUT_MS);
|
||||
const receivedMessage = await this.waitForMessage(signal, idleTimeoutMs);
|
||||
|
||||
if (!receivedMessage && !signal.aborted) {
|
||||
const idleDuration = Date.now() - lastActivityTime;
|
||||
if (idleDuration >= IDLE_TIMEOUT_MS) {
|
||||
if (idleDuration >= idleTimeoutMs) {
|
||||
logger.info('SESSION', 'Idle timeout reached, triggering abort to kill subprocess', {
|
||||
sessionDbId,
|
||||
idleDurationMs: idleDuration,
|
||||
thresholdMs: IDLE_TIMEOUT_MS
|
||||
thresholdMs: idleTimeoutMs
|
||||
});
|
||||
onIdleTimeout?.();
|
||||
return true;
|
||||
@@ -115,4 +134,25 @@ export class SessionQueueProcessor {
|
||||
timeoutId = setTimeout(onTimeout, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
private waitForDelay(signal: AbortSignal, delayMs: number): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const cleanup = () => {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
};
|
||||
const onAbort = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
timeoutId = setTimeout(() => {
|
||||
cleanup();
|
||||
resolve();
|
||||
}, delayMs);
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
|
||||
export {
|
||||
createCorsMiddleware,
|
||||
createMiddleware,
|
||||
requireLocalhost,
|
||||
summarizeRequestBody
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as fs from 'fs';
|
||||
import path from 'path';
|
||||
import { ALLOWED_OPERATIONS, ALLOWED_TOPICS } from './allowed-constants.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { createMiddleware, summarizeRequestBody, requireLocalhost } from './Middleware.js';
|
||||
import { createCorsMiddleware, createMiddleware, summarizeRequestBody, requireLocalhost } from './Middleware.js';
|
||||
import { errorHandler, notFoundHandler } from './ErrorHandler.js';
|
||||
import { getSupervisor } from '../../supervisor/index.js';
|
||||
import { isPidAlive } from '../../supervisor/process-registry.js';
|
||||
@@ -13,6 +13,7 @@ import { ENV_PREFIXES, ENV_EXACT_MATCHES } from '../../supervisor/env-sanitizer.
|
||||
import { flushResponseThen } from './flushResponseThen.js';
|
||||
import { getUptimeSeconds } from '../../shared/uptime.js';
|
||||
import { globalRateLimitStore } from '../worker/RateLimitStore.js';
|
||||
import type { ObservationQueueHealth } from '../../server/queue/ObservationQueueEngine.js';
|
||||
|
||||
const INSTRUCTIONS_BASE_DIR: string = path.resolve(__dirname, '../skills/mem-search');
|
||||
const INSTRUCTIONS_OPERATIONS_DIR: string = path.join(INSTRUCTIONS_BASE_DIR, 'operations');
|
||||
@@ -82,7 +83,10 @@ export interface ServerOptions {
|
||||
onShutdown: () => Promise<void>;
|
||||
onRestart: () => Promise<void>;
|
||||
workerPath: string;
|
||||
runtime?: string;
|
||||
getAiStatus: () => AiStatus;
|
||||
preBodyParserRoutes?: RouteHandler[];
|
||||
getQueueHealth?: () => ObservationQueueHealth | null | Promise<ObservationQueueHealth | null>;
|
||||
}
|
||||
|
||||
export class Server {
|
||||
@@ -94,6 +98,8 @@ export class Server {
|
||||
constructor(options: ServerOptions) {
|
||||
this.options = options;
|
||||
this.app = express();
|
||||
this.setupCors();
|
||||
this.setupPreBodyParserRoutes();
|
||||
this.setupMiddleware();
|
||||
this.setupCoreRoutes();
|
||||
}
|
||||
@@ -153,14 +159,27 @@ export class Server {
|
||||
}
|
||||
|
||||
private setupMiddleware(): void {
|
||||
const middlewares = createMiddleware(summarizeRequestBody);
|
||||
const middlewares = createMiddleware(summarizeRequestBody, { includeCors: false });
|
||||
middlewares.forEach(mw => this.app.use(mw));
|
||||
}
|
||||
|
||||
private setupCors(): void {
|
||||
this.app.use(createCorsMiddleware());
|
||||
}
|
||||
|
||||
private setupPreBodyParserRoutes(): void {
|
||||
this.options.preBodyParserRoutes?.forEach(handler => handler.setupRoutes(this.app));
|
||||
}
|
||||
|
||||
private setupCoreRoutes(): void {
|
||||
this.app.get('/api/health', (_req: Request, res: Response) => {
|
||||
res.status(200).json({
|
||||
status: 'ok',
|
||||
this.app.get('/api/health', async (_req: Request, res: Response) => {
|
||||
const queueHealth = this.options.getQueueHealth
|
||||
? await this.options.getQueueHealth()
|
||||
: null;
|
||||
const queueDegraded = queueHealth?.engine === 'bullmq' && queueHealth.redis.status === 'error';
|
||||
res.status(queueDegraded ? 503 : 200).json({
|
||||
status: queueDegraded ? 'degraded' : 'ok',
|
||||
...(this.options.runtime ? { runtime: this.options.runtime } : {}),
|
||||
version: BUILT_IN_VERSION,
|
||||
workerPath: this.options.workerPath,
|
||||
uptime: getUptimeSeconds(this.startTime),
|
||||
@@ -172,6 +191,7 @@ export class Server {
|
||||
mcpReady: this.options.getMcpReady(),
|
||||
ai: this.options.getAiStatus(),
|
||||
rateLimits: globalRateLimitStore.getMostRecentByWindow(),
|
||||
...(queueHealth ? { queue: queueHealth } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -57,8 +57,11 @@ export class PendingMessageStore {
|
||||
message.agentId ?? null
|
||||
);
|
||||
|
||||
this.onMutate?.();
|
||||
return result.lastInsertRowid as number;
|
||||
if (result.changes > 0) {
|
||||
this.onMutate?.();
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
claimNextMessage(sessionDbId: number): PersistentPendingMessage | null {
|
||||
@@ -79,7 +82,9 @@ export class PendingMessageStore {
|
||||
sessionId: sessionDbId
|
||||
});
|
||||
}
|
||||
this.onMutate?.();
|
||||
if (claimed) {
|
||||
this.onMutate?.();
|
||||
}
|
||||
return claimed;
|
||||
}
|
||||
|
||||
@@ -122,6 +127,40 @@ export class PendingMessageStore {
|
||||
return result.count;
|
||||
}
|
||||
|
||||
getTotalQueueDepth(): number {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT COUNT(*) as count FROM pending_messages
|
||||
WHERE status IN ('pending', 'processing')
|
||||
`);
|
||||
const result = stmt.get() as { count: number };
|
||||
return result.count;
|
||||
}
|
||||
|
||||
hasAnyPendingWork(): boolean {
|
||||
return this.getTotalQueueDepth() > 0;
|
||||
}
|
||||
|
||||
getSessionsWithPendingMessages(): number[] {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT DISTINCT session_db_id FROM pending_messages
|
||||
WHERE status IN ('pending', 'processing')
|
||||
ORDER BY session_db_id ASC
|
||||
`);
|
||||
return (stmt.all() as Array<{ session_db_id: number }>).map(row => row.session_db_id);
|
||||
}
|
||||
|
||||
confirmProcessed(messageId: number): number {
|
||||
const stmt = this.db.prepare(`
|
||||
DELETE FROM pending_messages
|
||||
WHERE id = ? AND status = 'processing'
|
||||
`);
|
||||
const changes = stmt.run(messageId).changes;
|
||||
if (changes > 0) {
|
||||
this.onMutate?.();
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
peekPendingTypes(sessionDbId: number): Array<{ message_type: string; tool_name: string | null }> {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT message_type, tool_name FROM pending_messages
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
SchemaVersion
|
||||
} from '../../../types/database.js';
|
||||
import { DEFAULT_PLATFORM_SOURCE } from '../../../shared/platform-source.js';
|
||||
import { ensureServerStorageSchema, SERVER_STORAGE_SCHEMA_VERSION } from '../../../storage/sqlite/schema.js';
|
||||
|
||||
export class MigrationRunner {
|
||||
constructor(private db: Database) {}
|
||||
@@ -34,6 +35,9 @@ export class MigrationRunner {
|
||||
this.addObservationsUniqueContentHashIndex();
|
||||
this.addObservationsMetadataColumn();
|
||||
this.dropDeadPendingMessagesColumns();
|
||||
this.dropWorkerPidColumn();
|
||||
this.createServerOwnedTables();
|
||||
this.rebuildPendingMessagesForFinalQueueSchema();
|
||||
}
|
||||
|
||||
private initializeSchema(): void {
|
||||
@@ -422,10 +426,8 @@ export class MigrationRunner {
|
||||
last_user_message TEXT,
|
||||
last_assistant_message TEXT,
|
||||
prompt_number INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'processing', 'processed', 'failed')),
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'processing')),
|
||||
created_at_epoch INTEGER NOT NULL,
|
||||
completed_at_epoch INTEGER,
|
||||
FOREIGN KEY (session_db_id) REFERENCES sdk_sessions(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
@@ -844,12 +846,8 @@ export class MigrationRunner {
|
||||
last_assistant_message TEXT,
|
||||
prompt_number INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK(status IN ('pending', 'processing', 'processed', 'failed')),
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
CHECK(status IN ('pending', 'processing')),
|
||||
created_at_epoch INTEGER NOT NULL,
|
||||
failed_at_epoch INTEGER,
|
||||
completed_at_epoch INTEGER,
|
||||
worker_pid INTEGER,
|
||||
agent_type TEXT,
|
||||
agent_id TEXT,
|
||||
FOREIGN KEY (session_db_id) REFERENCES sdk_sessions(id) ON DELETE CASCADE
|
||||
@@ -860,8 +858,7 @@ export class MigrationRunner {
|
||||
INSERT INTO pending_messages_new (
|
||||
id, session_db_id, content_session_id, tool_use_id, message_type,
|
||||
tool_name, tool_input, tool_response, cwd, last_user_message,
|
||||
last_assistant_message, prompt_number, status, retry_count,
|
||||
created_at_epoch, failed_at_epoch, completed_at_epoch, worker_pid,
|
||||
last_assistant_message, prompt_number, status, created_at_epoch,
|
||||
agent_type, agent_id
|
||||
)
|
||||
SELECT
|
||||
@@ -870,22 +867,19 @@ export class MigrationRunner {
|
||||
content_session_id,
|
||||
${has('tool_use_id') ? 'tool_use_id' : 'NULL'},
|
||||
message_type,
|
||||
tool_name,
|
||||
tool_input,
|
||||
tool_response,
|
||||
cwd,
|
||||
${has('tool_name') ? 'tool_name' : 'NULL'},
|
||||
${has('tool_input') ? 'tool_input' : 'NULL'},
|
||||
${has('tool_response') ? 'tool_response' : 'NULL'},
|
||||
${has('cwd') ? 'cwd' : 'NULL'},
|
||||
${has('last_user_message') ? 'last_user_message' : 'NULL'},
|
||||
${has('last_assistant_message') ? 'last_assistant_message' : 'NULL'},
|
||||
${has('prompt_number') ? 'prompt_number' : 'NULL'},
|
||||
status,
|
||||
retry_count,
|
||||
CASE WHEN status = 'processing' THEN 'processing' ELSE 'pending' END,
|
||||
created_at_epoch,
|
||||
${has('failed_at_epoch') ? 'failed_at_epoch' : 'NULL'},
|
||||
${has('completed_at_epoch') ? 'completed_at_epoch' : 'NULL'},
|
||||
NULL,
|
||||
${has('agent_type') ? 'agent_type' : 'NULL'},
|
||||
${has('agent_id') ? 'agent_id' : 'NULL'}
|
||||
FROM pending_messages
|
||||
WHERE status IN ('pending', 'processing')
|
||||
`);
|
||||
|
||||
this.db.run('DROP TABLE pending_messages');
|
||||
@@ -894,7 +888,6 @@ export class MigrationRunner {
|
||||
this.db.run('CREATE INDEX IF NOT EXISTS idx_pending_messages_session ON pending_messages(session_db_id)');
|
||||
this.db.run('CREATE INDEX IF NOT EXISTS idx_pending_messages_status ON pending_messages(status)');
|
||||
this.db.run('CREATE INDEX IF NOT EXISTS idx_pending_messages_claude_session ON pending_messages(content_session_id)');
|
||||
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
|
||||
@@ -991,6 +984,10 @@ export class MigrationRunner {
|
||||
|
||||
this.db.run(`DELETE FROM pending_messages WHERE status NOT IN ('pending', 'processing')`);
|
||||
|
||||
if (toDrop.includes('worker_pid')) {
|
||||
this.db.run('DROP INDEX IF EXISTS idx_pending_messages_worker_pid');
|
||||
}
|
||||
|
||||
for (const colName of toDrop) {
|
||||
try {
|
||||
this.db.run(`ALTER TABLE pending_messages DROP COLUMN ${colName}`);
|
||||
@@ -1002,4 +999,130 @@ export class MigrationRunner {
|
||||
|
||||
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(31, new Date().toISOString());
|
||||
}
|
||||
|
||||
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 (hasColumn) {
|
||||
try {
|
||||
this.db.run('DROP INDEX IF EXISTS idx_pending_messages_worker_pid');
|
||||
this.db.run('ALTER TABLE pending_messages DROP COLUMN worker_pid');
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
||||
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(32, new Date().toISOString());
|
||||
}
|
||||
|
||||
private createServerOwnedTables(): void {
|
||||
ensureServerStorageSchema(this.db);
|
||||
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(
|
||||
SERVER_STORAGE_SCHEMA_VERSION,
|
||||
new Date().toISOString()
|
||||
);
|
||||
}
|
||||
|
||||
private rebuildPendingMessagesForFinalQueueSchema(): void {
|
||||
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(34) as SchemaVersion | undefined;
|
||||
if (applied) return;
|
||||
|
||||
const table = this.db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='pending_messages'").get() as { sql: string } | null;
|
||||
if (!table) {
|
||||
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(34, new Date().toISOString());
|
||||
return;
|
||||
}
|
||||
|
||||
const hasStaleStatusCheck = table.sql.includes("'processed'") || table.sql.includes("'failed'");
|
||||
const cols = this.db.query('PRAGMA table_info(pending_messages)').all() as TableColumnInfo[];
|
||||
const colNames = new Set(cols.map(c => c.name));
|
||||
const hasDeadColumns = ['retry_count', 'failed_at_epoch', 'completed_at_epoch', 'worker_pid'].some(name => colNames.has(name));
|
||||
if (!hasStaleStatusCheck && !hasDeadColumns) {
|
||||
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(34, new Date().toISOString());
|
||||
return;
|
||||
}
|
||||
|
||||
const has = (name: string) => colNames.has(name);
|
||||
this.db.run('PRAGMA foreign_keys = OFF');
|
||||
this.db.run('BEGIN TRANSACTION');
|
||||
try {
|
||||
this.db.run('DROP TABLE IF EXISTS pending_messages_final');
|
||||
this.db.run(`
|
||||
CREATE TABLE pending_messages_final (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_db_id INTEGER NOT NULL,
|
||||
content_session_id TEXT NOT NULL,
|
||||
tool_use_id TEXT,
|
||||
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
|
||||
)
|
||||
`);
|
||||
|
||||
this.db.run(`
|
||||
INSERT INTO pending_messages_final (
|
||||
id, session_db_id, content_session_id, tool_use_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,
|
||||
${has('tool_use_id') ? 'tool_use_id' : 'NULL'},
|
||||
message_type,
|
||||
${has('tool_name') ? 'tool_name' : 'NULL'},
|
||||
${has('tool_input') ? 'tool_input' : 'NULL'},
|
||||
${has('tool_response') ? 'tool_response' : 'NULL'},
|
||||
${has('cwd') ? 'cwd' : 'NULL'},
|
||||
${has('last_user_message') ? 'last_user_message' : 'NULL'},
|
||||
${has('last_assistant_message') ? 'last_assistant_message' : 'NULL'},
|
||||
${has('prompt_number') ? 'prompt_number' : 'NULL'},
|
||||
CASE WHEN status = 'processing' THEN 'processing' ELSE 'pending' END,
|
||||
created_at_epoch,
|
||||
${has('agent_type') ? 'agent_type' : 'NULL'},
|
||||
${has('agent_id') ? 'agent_id' : 'NULL'}
|
||||
FROM pending_messages
|
||||
WHERE status IN ('pending', 'processing')
|
||||
`);
|
||||
|
||||
this.db.run('DROP TABLE pending_messages');
|
||||
this.db.run('ALTER TABLE pending_messages_final RENAME TO pending_messages');
|
||||
this.db.run('CREATE INDEX IF NOT EXISTS idx_pending_messages_session ON pending_messages(session_db_id)');
|
||||
this.db.run('CREATE INDEX IF NOT EXISTS idx_pending_messages_status ON pending_messages(status)');
|
||||
this.db.run('CREATE INDEX IF NOT EXISTS idx_pending_messages_claude_session ON pending_messages(content_session_id)');
|
||||
this.db.run(`
|
||||
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
|
||||
`);
|
||||
|
||||
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(34, new Date().toISOString());
|
||||
this.db.run('COMMIT');
|
||||
this.db.run('PRAGMA foreign_keys = ON');
|
||||
} catch (error) {
|
||||
this.db.run('ROLLBACK');
|
||||
this.db.run('PRAGMA foreign_keys = ON');
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Migration 34 failed: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
-- claude-mem SQLite schema
|
||||
--
|
||||
-- Authoritative shape of the database after all migrations through
|
||||
-- runner.ts have been applied (current runner tip = migration 31;
|
||||
-- SessionStore boot repair records migration 32). Fresh
|
||||
-- runner.ts have been applied (current tip = migration 34). Fresh
|
||||
-- databases boot directly into this shape; existing databases reach
|
||||
-- it via the migration runner.
|
||||
--
|
||||
|
||||
@@ -244,7 +244,7 @@ export class TranscriptEventProcessor {
|
||||
const toolName = typeof fields.toolName === 'string' ? fields.toolName : undefined;
|
||||
if (!toolName) return;
|
||||
|
||||
const result = ingestObservation({
|
||||
const result = await ingestObservation({
|
||||
contentSessionId: session.sessionId,
|
||||
cwd: session.cwd ?? process.cwd(),
|
||||
toolName,
|
||||
|
||||
+277
-19
@@ -1,9 +1,12 @@
|
||||
|
||||
import path from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { spawn } from 'child_process';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { getWorkerPort, getWorkerHost } from '../shared/worker-utils.js';
|
||||
import { DATA_DIR, DB_PATH, ensureDir } from '../shared/paths.js';
|
||||
import { HOOK_TIMEOUTS } from '../shared/hook-constants.js';
|
||||
import { SettingsDefaultsManager } from '../shared/SettingsDefaultsManager.js';
|
||||
import { getAuthMethodDescription } from '../shared/EnvManager.js';
|
||||
@@ -46,6 +49,13 @@ import { performGracefulShutdown } from './infrastructure/GracefulShutdown.js';
|
||||
import { adoptMergedWorktrees, adoptMergedWorktreesForAllKnownRepos } from './infrastructure/WorktreeAdoption.js';
|
||||
|
||||
import { Server } from './server/Server.js';
|
||||
import { BetterAuthRoutes } from '../server/auth/BetterAuthRoutes.js';
|
||||
import {
|
||||
createServerApiKey,
|
||||
listServerApiKeys,
|
||||
revokeServerApiKey,
|
||||
} from '../server/auth/api-key-service.js';
|
||||
import { ServerV1Routes } from '../server/routes/v1/ServerV1Routes.js';
|
||||
|
||||
import {
|
||||
updateCursorContextForProject,
|
||||
@@ -196,6 +206,12 @@ export class WorkerService implements WorkerRef {
|
||||
: null,
|
||||
};
|
||||
},
|
||||
getQueueHealth: () => this.sessionManager.isBullMqQueueEnabled()
|
||||
? this.sessionManager.getQueueHealth()
|
||||
: null,
|
||||
preBodyParserRoutes: [
|
||||
new BetterAuthRoutes(() => this.dbManager.getConnection()),
|
||||
],
|
||||
});
|
||||
|
||||
this.registerRoutes();
|
||||
@@ -224,7 +240,7 @@ export class WorkerService implements WorkerRef {
|
||||
next();
|
||||
});
|
||||
|
||||
this.server.app.use('/api', async (req, res, next) => {
|
||||
this.server.app.use(['/api', '/v1'], async (req, res, next) => {
|
||||
if (req.path === '/chroma/status' || req.path === '/health' || req.path === '/readiness' || req.path === '/version') {
|
||||
next();
|
||||
return;
|
||||
@@ -253,6 +269,9 @@ export class WorkerService implements WorkerRef {
|
||||
this.server.registerRoutes(new SettingsRoutes(this.settingsManager));
|
||||
this.server.registerRoutes(new LogsRoutes());
|
||||
this.server.registerRoutes(new MemoryRoutes(this.dbManager, 'claude-mem'));
|
||||
this.server.registerRoutes(new ServerV1Routes({
|
||||
getDatabase: () => this.dbManager.getConnection(),
|
||||
}));
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
@@ -260,6 +279,7 @@ export class WorkerService implements WorkerRef {
|
||||
const host = getWorkerHost();
|
||||
|
||||
await startSupervisor();
|
||||
await this.sessionManager.initializeQueueEngine();
|
||||
|
||||
await this.server.listen(port, host);
|
||||
|
||||
@@ -705,14 +725,14 @@ export class WorkerService implements WorkerRef {
|
||||
}
|
||||
}
|
||||
|
||||
this.completionHandler.finalizeSession(sessionDbId);
|
||||
await this.completionHandler.finalizeSession(sessionDbId);
|
||||
this.sessionManager.removeSessionImmediate(sessionDbId);
|
||||
}
|
||||
|
||||
private terminateSession(sessionDbId: number, reason: string): void {
|
||||
private async terminateSession(sessionDbId: number, reason: string): Promise<void> {
|
||||
logger.info('SYSTEM', 'Session terminated', { sessionId: sessionDbId, reason });
|
||||
|
||||
this.completionHandler.finalizeSession(sessionDbId);
|
||||
await this.completionHandler.finalizeSession(sessionDbId);
|
||||
|
||||
this.sessionManager.removeSessionImmediate(sessionDbId);
|
||||
}
|
||||
@@ -734,21 +754,23 @@ export class WorkerService implements WorkerRef {
|
||||
}
|
||||
|
||||
broadcastProcessingStatus(): void {
|
||||
const queueDepth = this.sessionManager.getTotalActiveWork();
|
||||
const isProcessing = queueDepth > 0;
|
||||
const activeSessions = this.sessionManager.getActiveSessionCount();
|
||||
void (async () => {
|
||||
const queueDepth = await this.sessionManager.getTotalActiveWork();
|
||||
const isProcessing = queueDepth > 0;
|
||||
const activeSessions = this.sessionManager.getActiveSessionCount();
|
||||
|
||||
logger.info('WORKER', 'Broadcasting processing status', {
|
||||
isProcessing,
|
||||
queueDepth,
|
||||
activeSessions
|
||||
});
|
||||
logger.info('WORKER', 'Broadcasting processing status', {
|
||||
isProcessing,
|
||||
queueDepth,
|
||||
activeSessions
|
||||
});
|
||||
|
||||
this.sseBroadcaster.broadcast({
|
||||
type: 'processing_status',
|
||||
isProcessing,
|
||||
queueDepth
|
||||
});
|
||||
this.sseBroadcaster.broadcast({
|
||||
type: 'processing_status',
|
||||
isProcessing,
|
||||
queueDepth
|
||||
});
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -756,11 +778,174 @@ export async function ensureWorkerStarted(port: number): Promise<WorkerStartResu
|
||||
return ensureWorkerStartedShared(port, __filename);
|
||||
}
|
||||
|
||||
type ParsedWorkerCommand = {
|
||||
command: string | undefined;
|
||||
args: string[];
|
||||
};
|
||||
|
||||
function parseWorkerServiceCommand(argv: string[]): ParsedWorkerCommand {
|
||||
const [rawCommand, maybeSubCommand, ...rest] = argv;
|
||||
|
||||
if (rawCommand === 'server') {
|
||||
const lifecycleCommands = new Set(['start', 'stop', 'restart', 'status']);
|
||||
if (maybeSubCommand && lifecycleCommands.has(maybeSubCommand)) {
|
||||
return { command: `server-${maybeSubCommand}`, args: rest };
|
||||
}
|
||||
const serverCommands = new Set(['logs', 'doctor', 'migrate', 'export', 'import', 'api-key']);
|
||||
return {
|
||||
command: maybeSubCommand && serverCommands.has(maybeSubCommand) ? `server-${maybeSubCommand}` : 'server-help',
|
||||
args: rest,
|
||||
};
|
||||
}
|
||||
|
||||
if (rawCommand === 'worker') {
|
||||
const workerAliases = new Set(['start', 'stop', 'restart', 'status']);
|
||||
return {
|
||||
command: maybeSubCommand && workerAliases.has(maybeSubCommand) ? maybeSubCommand : 'worker-help',
|
||||
args: rest,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: rawCommand,
|
||||
args: maybeSubCommand === undefined ? [] : [maybeSubCommand, ...rest],
|
||||
};
|
||||
}
|
||||
|
||||
function printServerCommandUnsupported(command: string): never {
|
||||
console.error(`Server command not implemented yet: ${command}`);
|
||||
console.error('This worker bundle accepts the CLI route, but no backend API exists for it yet.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function printServerCommandHelp(): never {
|
||||
console.error('Usage: worker-service server <command>');
|
||||
console.error('Commands: start, stop, restart, status, logs, doctor, migrate, export, import, api-key create|list|revoke');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function printWorkerAliasHelp(): never {
|
||||
console.error('Usage: worker-service worker start|stop|restart|status');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function runServerBetaServiceCli(command: string): void {
|
||||
const serverBetaScript = path.join(__dirname, 'server-beta-service.cjs');
|
||||
if (!existsSync(serverBetaScript)) {
|
||||
console.error(`Server beta script not found at: ${serverBetaScript}`);
|
||||
console.error('Rebuild or reinstall claude-mem so server-beta-service.cjs is available.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const child = spawn(process.execPath, [serverBetaScript, command], {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
console.error(`Failed to start server beta command: ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
child.on('close', (exitCode) => {
|
||||
process.exit(exitCode ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
function parseServerApiKeyOptions(args: string[]): Record<string, string> {
|
||||
const options: Record<string, string> = {};
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const item = args[i];
|
||||
if (!item.startsWith('--')) {
|
||||
continue;
|
||||
}
|
||||
const key = item.slice(2);
|
||||
const next = args[i + 1];
|
||||
if (!next || next.startsWith('--')) {
|
||||
options[key] = 'true';
|
||||
continue;
|
||||
}
|
||||
options[key] = next;
|
||||
i++;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function openServerCommandDatabase(): Database {
|
||||
ensureDir(DATA_DIR);
|
||||
return new Database(DB_PATH, { create: true, readwrite: true });
|
||||
}
|
||||
|
||||
function runServerApiKeyCli(args: string[]): never {
|
||||
const subCommand = args[0];
|
||||
const options = parseServerApiKeyOptions(args.slice(1));
|
||||
const db = openServerCommandDatabase();
|
||||
|
||||
try {
|
||||
if (subCommand === 'create') {
|
||||
const scopes = (options.scope ?? options.scopes ?? 'memories:read')
|
||||
.split(',')
|
||||
.map(scope => scope.trim())
|
||||
.filter(Boolean);
|
||||
const created = createServerApiKey(db, {
|
||||
name: options.name ?? 'server-api-key',
|
||||
teamId: options.team ?? null,
|
||||
projectId: options.project ?? null,
|
||||
scopes,
|
||||
});
|
||||
console.log(JSON.stringify({
|
||||
id: created.record.id,
|
||||
key: created.rawKey,
|
||||
name: created.record.name,
|
||||
teamId: created.record.teamId,
|
||||
projectId: created.record.projectId,
|
||||
scopes: created.record.scopes,
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (subCommand === 'list') {
|
||||
console.log(JSON.stringify(listServerApiKeys(db).map(key => ({
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
prefix: key.prefix,
|
||||
teamId: key.teamId,
|
||||
projectId: key.projectId,
|
||||
scopes: key.scopes,
|
||||
status: key.status,
|
||||
lastUsedAtEpoch: key.lastUsedAtEpoch,
|
||||
expiresAtEpoch: key.expiresAtEpoch,
|
||||
createdAtEpoch: key.createdAtEpoch,
|
||||
})), null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (subCommand === 'revoke') {
|
||||
const id = args[1];
|
||||
if (!id) {
|
||||
console.error('Usage: worker-service server api-key revoke <id>');
|
||||
process.exit(1);
|
||||
}
|
||||
const revoked = revokeServerApiKey(db, id);
|
||||
if (!revoked) {
|
||||
console.error(`API key not found: ${id}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(JSON.stringify({ id: revoked.id, status: revoked.status }, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error(`Unknown server api-key subcommand: ${subCommand ?? '(none)'}`);
|
||||
console.error('Usage: worker-service server api-key create|list|revoke');
|
||||
process.exit(1);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const command = process.argv[2];
|
||||
const { command, args: commandArgs } = parseWorkerServiceCommand(process.argv.slice(2));
|
||||
|
||||
const hookInitiatedCommands = ['start', 'hook', 'restart', '--daemon'];
|
||||
if ((hookInitiatedCommands.includes(command) || command === undefined) && isPluginDisabledInClaudeSettings()) {
|
||||
if ((command === undefined || hookInitiatedCommands.includes(command)) && isPluginDisabledInClaudeSettings()) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -822,6 +1007,7 @@ async function main() {
|
||||
console.log(` PID: ${pidInfo.pid}`);
|
||||
console.log(` Port: ${pidInfo.port}`);
|
||||
console.log(` Started: ${pidInfo.startedAt}`);
|
||||
await printQueueStatusIfBullMq(port);
|
||||
} else {
|
||||
console.log('Worker is not running');
|
||||
}
|
||||
@@ -829,6 +1015,44 @@ async function main() {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'server-start':
|
||||
case 'server-stop':
|
||||
case 'server-restart':
|
||||
case 'server-status': {
|
||||
runServerBetaServiceCli(command.slice('server-'.length));
|
||||
break;
|
||||
}
|
||||
|
||||
case 'server-logs':
|
||||
case 'server-doctor':
|
||||
case 'server-migrate':
|
||||
case 'server-export':
|
||||
case 'server-import': {
|
||||
printServerCommandUnsupported(command.replace('-', ' '));
|
||||
break;
|
||||
}
|
||||
|
||||
case 'server-api-key': {
|
||||
const apiKeyCommand = commandArgs[0];
|
||||
if (apiKeyCommand === 'create' || apiKeyCommand === 'list' || apiKeyCommand === 'revoke') {
|
||||
runServerApiKeyCli(commandArgs);
|
||||
}
|
||||
console.error(`Unknown server api-key subcommand: ${apiKeyCommand ?? '(none)'}`);
|
||||
console.error('Usage: worker-service server api-key create|list|revoke');
|
||||
process.exit(1);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'server-help': {
|
||||
printServerCommandHelp();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'worker-help': {
|
||||
printWorkerAliasHelp();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'cursor': {
|
||||
const subcommand = process.argv[3];
|
||||
const cursorResult = await handleCursorCommand(subcommand, process.argv.slice(4));
|
||||
@@ -978,6 +1202,40 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
async function printQueueStatusIfBullMq(port: number): Promise<void> {
|
||||
if (SettingsDefaultsManager.get('CLAUDE_MEM_QUEUE_ENGINE').trim().toLowerCase() !== 'bullmq') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`http://${getWorkerHost()}:${port}/api/health`);
|
||||
if (!response.ok) {
|
||||
console.log(` Queue: BullMQ health unavailable (HTTP ${response.status})`);
|
||||
return;
|
||||
}
|
||||
const body = await response.json() as {
|
||||
queue?: {
|
||||
redis?: {
|
||||
status?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
mode?: string;
|
||||
prefix?: string;
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
const redis = body.queue?.redis;
|
||||
if (!redis) {
|
||||
return;
|
||||
}
|
||||
const target = `${redis.host ?? 'unknown'}:${redis.port ?? 'unknown'}`;
|
||||
const suffix = redis.status === 'ok' ? '' : ` (${redis.error ?? 'unhealthy'})`;
|
||||
console.log(` Queue: BullMQ Redis ${redis.status ?? 'unknown'} at ${target} [${redis.mode ?? 'external'}, prefix=${redis.prefix ?? 'claude_mem'}]${suffix}`);
|
||||
} catch (error) {
|
||||
console.log(` Queue: BullMQ health unavailable (${error instanceof Error ? error.message : String(error)})`);
|
||||
}
|
||||
}
|
||||
|
||||
const isMainModule = typeof require !== 'undefined' && typeof module !== 'undefined'
|
||||
? require.main === module || !module.parent || process.env.CLAUDE_MEM_MANAGED === 'true'
|
||||
: import.meta.url === `file://${process.argv[1]}`
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface ActiveSession {
|
||||
cumulativeInputTokens: number;
|
||||
cumulativeOutputTokens: number;
|
||||
earliestPendingTimestamp: number | null;
|
||||
claimedMessageIds: number[];
|
||||
conversationHistory: ConversationMessage[];
|
||||
currentProvider: 'claude' | 'gemini' | 'openrouter' | null;
|
||||
consecutiveRestarts: number;
|
||||
|
||||
@@ -65,6 +65,13 @@ export class DatabaseManager {
|
||||
return this.chromaSync;
|
||||
}
|
||||
|
||||
getConnection(): Database {
|
||||
if (!this.db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
return this.db;
|
||||
}
|
||||
|
||||
getSessionById(sessionDbId: number): {
|
||||
id: number;
|
||||
content_session_id: string;
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import { DatabaseManager } from './DatabaseManager.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import type { ActiveSession, PendingMessage, PendingMessageWithId, ObservationData } from '../worker-types.js';
|
||||
import { PendingMessageStore } from '../sqlite/PendingMessageStore.js';
|
||||
import { SessionQueueProcessor } from '../queue/SessionQueueProcessor.js';
|
||||
import {
|
||||
SqliteObservationQueueEngine,
|
||||
type HealthCheckedObservationQueueEngine,
|
||||
type InspectableObservationQueueEngine,
|
||||
type ObservationQueueHealth
|
||||
} from '../../server/queue/ObservationQueueEngine.js';
|
||||
import { BullMqObservationQueueEngine } from '../../server/queue/BullMqObservationQueueEngine.js';
|
||||
import { getObservationQueueEngineName } from '../../server/queue/redis-config.js';
|
||||
import { getSdkProcessForSession, ensureSdkProcessExit } from '../../supervisor/process-registry.js';
|
||||
import { getSupervisor } from '../../supervisor/index.js';
|
||||
import { RestartGuard } from './RestartGuard.js';
|
||||
@@ -12,24 +16,55 @@ import { RestartGuard } from './RestartGuard.js';
|
||||
export class SessionManager {
|
||||
private dbManager: DatabaseManager;
|
||||
private sessions: Map<number, ActiveSession> = new Map();
|
||||
private sessionQueues: Map<number, EventEmitter> = new Map();
|
||||
private onSessionDeletedCallback?: () => void;
|
||||
private pendingStore: PendingMessageStore | null = null;
|
||||
private queueEngine: InspectableObservationQueueEngine | null = null;
|
||||
private queueEngineName: 'sqlite' | 'bullmq' | null = null;
|
||||
private onPendingMutate?: () => void;
|
||||
|
||||
constructor(dbManager: DatabaseManager) {
|
||||
this.dbManager = dbManager;
|
||||
}
|
||||
|
||||
private getPendingStore(): PendingMessageStore {
|
||||
if (!this.pendingStore) {
|
||||
const sessionStore = this.dbManager.getSessionStore();
|
||||
this.pendingStore = new PendingMessageStore(
|
||||
sessionStore.db,
|
||||
() => this.onPendingMutate?.()
|
||||
);
|
||||
private getQueueEngine(): InspectableObservationQueueEngine {
|
||||
if (!this.queueEngine) {
|
||||
this.queueEngineName = getObservationQueueEngineName();
|
||||
if (this.queueEngineName === 'bullmq') {
|
||||
this.queueEngine = new BullMqObservationQueueEngine({
|
||||
onMutate: () => this.onPendingMutate?.()
|
||||
});
|
||||
} else {
|
||||
const sessionStore = this.dbManager.getSessionStore();
|
||||
this.queueEngine = new SqliteObservationQueueEngine(
|
||||
sessionStore.db,
|
||||
() => this.onPendingMutate?.()
|
||||
);
|
||||
}
|
||||
}
|
||||
return this.pendingStore;
|
||||
return this.queueEngine;
|
||||
}
|
||||
|
||||
async initializeQueueEngine(): Promise<void> {
|
||||
this.queueEngineName = getObservationQueueEngineName();
|
||||
if (this.queueEngineName === 'sqlite') {
|
||||
return;
|
||||
}
|
||||
const queue = this.getQueueEngine();
|
||||
if (isHealthCheckedQueue(queue)) {
|
||||
await queue.assertHealthy();
|
||||
await queue.getTotalQueueDepth();
|
||||
}
|
||||
}
|
||||
|
||||
isBullMqQueueEnabled(): boolean {
|
||||
return (this.queueEngineName ?? getObservationQueueEngineName()) === 'bullmq';
|
||||
}
|
||||
|
||||
async getQueueHealth(): Promise<ObservationQueueHealth | null> {
|
||||
const queue = this.getQueueEngine();
|
||||
if (isHealthCheckedQueue(queue)) {
|
||||
return queue.getHealth();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
setOnSessionDeleted(callback: () => void): void {
|
||||
@@ -134,6 +169,7 @@ export class SessionManager {
|
||||
cumulativeInputTokens: 0,
|
||||
cumulativeOutputTokens: 0,
|
||||
earliestPendingTimestamp: null,
|
||||
claimedMessageIds: [],
|
||||
conversationHistory: [], // Initialize empty - will be populated by agents
|
||||
currentProvider: null, // Will be set when generator starts
|
||||
consecutiveRestarts: 0, // DEPRECATED: use restartGuard. Kept for logging compat.
|
||||
@@ -153,9 +189,6 @@ export class SessionManager {
|
||||
|
||||
this.sessions.set(sessionDbId, session);
|
||||
|
||||
const emitter = new EventEmitter();
|
||||
this.sessionQueues.set(sessionDbId, emitter);
|
||||
|
||||
logger.info('SESSION', 'Session initialized', {
|
||||
sessionId: sessionDbId,
|
||||
project: session.project,
|
||||
@@ -171,7 +204,7 @@ export class SessionManager {
|
||||
return this.sessions.get(sessionDbId);
|
||||
}
|
||||
|
||||
queueObservation(sessionDbId: number, data: ObservationData): void {
|
||||
async queueObservation(sessionDbId: number, data: ObservationData): Promise<void> {
|
||||
let session = this.sessions.get(sessionDbId);
|
||||
if (!session) {
|
||||
session = this.initializeSession(sessionDbId);
|
||||
@@ -190,8 +223,9 @@ export class SessionManager {
|
||||
};
|
||||
|
||||
try {
|
||||
const messageId = this.getPendingStore().enqueue(sessionDbId, session.contentSessionId, message);
|
||||
const queueDepth = this.getPendingStore().getPendingCount(sessionDbId);
|
||||
const queue = this.getQueueEngine();
|
||||
const messageId = await queue.enqueue(sessionDbId, session.contentSessionId, message);
|
||||
const queueDepth = await queue.getPendingCount(sessionDbId);
|
||||
const toolSummary = logger.formatTool(data.tool_name, data.tool_input);
|
||||
if (messageId === 0) {
|
||||
logger.debug('QUEUE', `DUP_SUPPRESSED | sessionDbId=${sessionDbId} | type=observation | tool=${toolSummary} | toolUseId=${data.toolUseId ?? 'null'} | depth=${queueDepth}`, {
|
||||
@@ -212,11 +246,9 @@ export class SessionManager {
|
||||
throw normalized;
|
||||
}
|
||||
|
||||
const emitter = this.sessionQueues.get(sessionDbId);
|
||||
emitter?.emit('message');
|
||||
}
|
||||
|
||||
queueSummarize(sessionDbId: number, lastAssistantMessage?: string): void {
|
||||
async queueSummarize(sessionDbId: number, lastAssistantMessage?: string): Promise<void> {
|
||||
let session = this.sessions.get(sessionDbId);
|
||||
if (!session) {
|
||||
session = this.initializeSession(sessionDbId);
|
||||
@@ -228,8 +260,9 @@ export class SessionManager {
|
||||
};
|
||||
|
||||
try {
|
||||
const messageId = this.getPendingStore().enqueue(sessionDbId, session.contentSessionId, message);
|
||||
const queueDepth = this.getPendingStore().getPendingCount(sessionDbId);
|
||||
const queue = this.getQueueEngine();
|
||||
const messageId = await queue.enqueue(sessionDbId, session.contentSessionId, message);
|
||||
const queueDepth = await queue.getPendingCount(sessionDbId);
|
||||
if (messageId === 0) {
|
||||
logger.debug('QUEUE', `DUP_SUPPRESSED | sessionDbId=${sessionDbId} | type=summarize | depth=${queueDepth}`, {
|
||||
sessionId: sessionDbId
|
||||
@@ -252,13 +285,32 @@ export class SessionManager {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const emitter = this.sessionQueues.get(sessionDbId);
|
||||
emitter?.emit('message');
|
||||
}
|
||||
|
||||
clearPendingForSession(sessionDbId: number): void {
|
||||
this.getPendingStore().clearPendingForSession(sessionDbId);
|
||||
this.sessionQueues.get(sessionDbId)?.emit('message');
|
||||
async clearPendingForSession(sessionDbId: number): Promise<number> {
|
||||
return await this.getQueueEngine().clearPendingForSession(sessionDbId);
|
||||
}
|
||||
|
||||
async resetProcessingToPending(sessionDbId: number): Promise<number> {
|
||||
const session = this.sessions.get(sessionDbId);
|
||||
if (session) {
|
||||
session.claimedMessageIds = [];
|
||||
}
|
||||
return await this.getQueueEngine().resetProcessingToPending(sessionDbId);
|
||||
}
|
||||
|
||||
async confirmClaimedMessages(sessionDbId: number): Promise<number> {
|
||||
const session = this.sessions.get(sessionDbId);
|
||||
const claimedIds = session?.claimedMessageIds ?? [];
|
||||
let confirmed = 0;
|
||||
for (const messageId of claimedIds) {
|
||||
confirmed += await this.getQueueEngine().confirmProcessed(messageId);
|
||||
}
|
||||
if (session) {
|
||||
session.claimedMessageIds = [];
|
||||
session.earliestPendingTimestamp = null;
|
||||
}
|
||||
return confirmed;
|
||||
}
|
||||
|
||||
async deleteSession(sessionDbId: number): Promise<void> {
|
||||
@@ -314,8 +366,6 @@ export class SessionManager {
|
||||
}
|
||||
|
||||
this.sessions.delete(sessionDbId);
|
||||
this.sessionQueues.delete(sessionDbId);
|
||||
|
||||
logger.info('SESSION', 'Session deleted', {
|
||||
sessionId: sessionDbId,
|
||||
duration: `${(sessionDuration / 1000).toFixed(1)}s`,
|
||||
@@ -337,8 +387,6 @@ export class SessionManager {
|
||||
}
|
||||
|
||||
this.sessions.delete(sessionDbId);
|
||||
this.sessionQueues.delete(sessionDbId);
|
||||
|
||||
logger.info('SESSION', 'Session removed from active sessions', {
|
||||
sessionId: sessionDbId,
|
||||
project: session.project
|
||||
@@ -352,31 +400,28 @@ export class SessionManager {
|
||||
async shutdownAll(): Promise<void> {
|
||||
const sessionIds = Array.from(this.sessions.keys());
|
||||
await Promise.all(sessionIds.map(id => this.deleteSession(id)));
|
||||
await this.queueEngine?.close();
|
||||
this.queueEngine = null;
|
||||
}
|
||||
|
||||
hasPendingMessages(): boolean {
|
||||
return this.getTotalQueueDepth() > 0;
|
||||
async hasPendingMessages(): Promise<boolean> {
|
||||
return (await this.getTotalQueueDepth()) > 0;
|
||||
}
|
||||
|
||||
getActiveSessionCount(): number {
|
||||
return this.sessions.size;
|
||||
}
|
||||
|
||||
getTotalQueueDepth(): number {
|
||||
const stmt = this.dbManager.getSessionStore().db.prepare(`
|
||||
SELECT COUNT(*) as count FROM pending_messages
|
||||
WHERE status IN ('pending', 'processing')
|
||||
`);
|
||||
const result = stmt.get() as { count: number };
|
||||
return result.count;
|
||||
async getTotalQueueDepth(): Promise<number> {
|
||||
return await this.getQueueEngine().getTotalQueueDepth();
|
||||
}
|
||||
|
||||
getTotalActiveWork(): number {
|
||||
return this.getTotalQueueDepth();
|
||||
async getTotalActiveWork(): Promise<number> {
|
||||
return await this.getTotalQueueDepth();
|
||||
}
|
||||
|
||||
isAnySessionProcessing(): boolean {
|
||||
return this.getTotalQueueDepth() > 0;
|
||||
async isAnySessionProcessing(): Promise<boolean> {
|
||||
return (await this.getTotalQueueDepth()) > 0;
|
||||
}
|
||||
|
||||
async *getMessageIterator(sessionDbId: number): AsyncIterableIterator<PendingMessageWithId> {
|
||||
@@ -385,16 +430,10 @@ export class SessionManager {
|
||||
session = this.initializeSession(sessionDbId);
|
||||
}
|
||||
|
||||
const emitter = this.sessionQueues.get(sessionDbId);
|
||||
if (!emitter) {
|
||||
throw new Error(`No emitter for session ${sessionDbId}`);
|
||||
}
|
||||
const queue = this.getQueueEngine();
|
||||
await this.resetProcessingToPending(sessionDbId);
|
||||
|
||||
this.getPendingStore().resetProcessingToPending(sessionDbId);
|
||||
|
||||
const processor = new SessionQueueProcessor(this.getPendingStore(), emitter);
|
||||
|
||||
for await (const message of processor.createIterator({
|
||||
for await (const message of queue.createIterator({
|
||||
sessionDbId,
|
||||
signal: session.abortController.signal,
|
||||
onIdleTimeout: () => {
|
||||
@@ -404,6 +443,7 @@ export class SessionManager {
|
||||
session.abortController.abort();
|
||||
}
|
||||
})) {
|
||||
session.claimedMessageIds.push(message._persistentId);
|
||||
if (session.earliestPendingTimestamp === null) {
|
||||
session.earliestPendingTimestamp = message._originalTimestamp;
|
||||
} else {
|
||||
@@ -416,7 +456,11 @@ export class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
getPendingMessageStore(): PendingMessageStore {
|
||||
return this.getPendingStore();
|
||||
getPendingMessageStore(): InspectableObservationQueueEngine {
|
||||
return this.getQueueEngine();
|
||||
}
|
||||
}
|
||||
|
||||
function isHealthCheckedQueue(queue: InspectableObservationQueueEngine): queue is HealthCheckedObservationQueueEngine {
|
||||
return 'getHealth' in queue && 'assertHealthy' in queue;
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export async function processAgentResponse(
|
||||
// Plain-text skip responses are intentionally ignored. Re-queueing them
|
||||
// creates an observer loop where the same low-signal batch is retried
|
||||
// until the restart guard fires or the provider quota is exhausted.
|
||||
sessionManager.clearPendingForSession(session.sessionDbId);
|
||||
await sessionManager.confirmClaimedMessages(session.sessionDbId);
|
||||
session.earliestPendingTimestamp = null;
|
||||
return;
|
||||
}
|
||||
@@ -53,7 +53,7 @@ export async function processAgentResponse(
|
||||
// Reset any claimed-but-undelivered messages back to pending so they don't
|
||||
// count as "in progress" and trigger a respawn loop while we wait for the
|
||||
// memory session id to appear. The next generator pass will re-claim them.
|
||||
sessionManager.getPendingMessageStore().resetProcessingToPending(session.sessionDbId);
|
||||
await sessionManager.resetProcessingToPending(session.sessionDbId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ export async function processAgentResponse(
|
||||
session.lastSummaryStored = result.summaryId !== null;
|
||||
|
||||
if (summary && (summary.skipped || session.lastSummaryStored)) {
|
||||
ingestSummary({
|
||||
await ingestSummary({
|
||||
kind: 'parsed',
|
||||
sessionDbId: session.sessionDbId,
|
||||
messageId: -1,
|
||||
@@ -108,9 +108,10 @@ export async function processAgentResponse(
|
||||
});
|
||||
}
|
||||
|
||||
sessionManager.clearPendingForSession(session.sessionDbId);
|
||||
await sessionManager.confirmClaimedMessages(session.sessionDbId);
|
||||
session.earliestPendingTimestamp = null;
|
||||
session.restartGuard?.recordSuccess();
|
||||
worker?.broadcastProcessingStatus?.();
|
||||
|
||||
void notifyTelegram({
|
||||
observations: labeledObservations,
|
||||
@@ -182,6 +183,14 @@ async function syncAndBroadcastObservations(
|
||||
for (const obsId of uniqueObservationIds) {
|
||||
const observationIndex = result.observationIds.indexOf(obsId);
|
||||
const obs = observations[observationIndex];
|
||||
if (!obs) {
|
||||
logger.warn('DB', `${agentName} storage returned observation id without matching parsed observation`, {
|
||||
sessionId: session.sessionDbId,
|
||||
obsId,
|
||||
observationIndex
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const chromaStart = Date.now();
|
||||
|
||||
dbManager.getChromaSync()?.syncObservation(
|
||||
|
||||
@@ -8,4 +8,5 @@ export function cleanupProcessedMessages(
|
||||
worker: WorkerRef | undefined
|
||||
): void {
|
||||
session.earliestPendingTimestamp = null;
|
||||
worker?.broadcastProcessingStatus?.();
|
||||
}
|
||||
|
||||
@@ -6,26 +6,16 @@ import { getPackageRoot } from '../../../shared/paths.js';
|
||||
import { logger } from '../../../utils/logger.js';
|
||||
|
||||
export function createMiddleware(
|
||||
summarizeRequestBody: (method: string, path: string, body: any) => string
|
||||
summarizeRequestBody: (method: string, path: string, body: any) => string,
|
||||
options: { includeCors?: boolean } = {}
|
||||
): RequestHandler[] {
|
||||
const middlewares: RequestHandler[] = [];
|
||||
|
||||
middlewares.push(express.json({ limit: '5mb' }));
|
||||
if (options.includeCors !== false) {
|
||||
middlewares.push(createCorsMiddleware());
|
||||
}
|
||||
|
||||
middlewares.push(cors({
|
||||
origin: (origin, callback) => {
|
||||
if (!origin ||
|
||||
origin.startsWith('http://localhost:') ||
|
||||
origin.startsWith('http://127.0.0.1:')) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
callback(new Error('CORS not allowed'));
|
||||
}
|
||||
},
|
||||
methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
allowedHeaders: ['Content-Type', 'X-Requested-With'],
|
||||
credentials: false
|
||||
}));
|
||||
middlewares.push(express.json({ limit: '5mb' }));
|
||||
|
||||
middlewares.push((req: Request, res: Response, next: NextFunction) => {
|
||||
const staticExtensions = ['.html', '.js', '.css', '.svg', '.png', '.jpg', '.jpeg', '.webp', '.woff', '.woff2', '.ttf', '.eot'];
|
||||
@@ -58,6 +48,23 @@ export function createMiddleware(
|
||||
return middlewares;
|
||||
}
|
||||
|
||||
export function createCorsMiddleware(): RequestHandler {
|
||||
return cors({
|
||||
origin: (origin, callback) => {
|
||||
if (!origin ||
|
||||
origin.startsWith('http://localhost:') ||
|
||||
origin.startsWith('http://127.0.0.1:')) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
callback(new Error('CORS not allowed'));
|
||||
}
|
||||
},
|
||||
methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
|
||||
credentials: false
|
||||
});
|
||||
}
|
||||
|
||||
export function requireLocalhost(req: Request, res: Response, next: NextFunction): void {
|
||||
const clientIp = req.ip || req.connection.remoteAddress || '';
|
||||
const isLocalhost =
|
||||
|
||||
@@ -271,15 +271,15 @@ export class DataRoutes extends BaseRouteHandler {
|
||||
res.json(store.getProjectCatalog());
|
||||
});
|
||||
|
||||
private handleGetProcessingStatus = this.wrapHandler((req: Request, res: Response): void => {
|
||||
const isProcessing = this.sessionManager.isAnySessionProcessing();
|
||||
const queueDepth = this.sessionManager.getTotalActiveWork();
|
||||
private handleGetProcessingStatus = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
|
||||
const isProcessing = await this.sessionManager.isAnySessionProcessing();
|
||||
const queueDepth = await this.sessionManager.getTotalActiveWork();
|
||||
res.json({ isProcessing, queueDepth });
|
||||
});
|
||||
|
||||
private handleSetProcessing = this.wrapHandler((req: Request, res: Response): void => {
|
||||
const isProcessing = this.sessionManager.isAnySessionProcessing();
|
||||
const queueDepth = this.sessionManager.getTotalQueueDepth();
|
||||
private handleSetProcessing = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
|
||||
const isProcessing = await this.sessionManager.isAnySessionProcessing();
|
||||
const queueDepth = await this.sessionManager.getTotalQueueDepth();
|
||||
const activeSessions = this.sessionManager.getActiveSessionCount();
|
||||
|
||||
res.json({ status: 'ok', isProcessing, queueDepth, activeSessions });
|
||||
|
||||
@@ -65,15 +65,15 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
return (isGeminiSelected() && isGeminiAvailable()) ? 'gemini' : 'claude';
|
||||
}
|
||||
|
||||
public ensureGeneratorRunning(sessionDbId: number, source: string): void {
|
||||
public async ensureGeneratorRunning(sessionDbId: number, source: string): Promise<void> {
|
||||
const session = this.sessionManager.getSession(sessionDbId);
|
||||
if (!session) return;
|
||||
|
||||
const selectedProvider = this.getSelectedProvider();
|
||||
|
||||
if (!session.generatorPromise) {
|
||||
this.applyTierRouting(session);
|
||||
this.startGeneratorWithProvider(session, selectedProvider, source);
|
||||
await this.applyTierRouting(session);
|
||||
await this.startGeneratorWithProvider(session, selectedProvider, source);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -89,11 +89,11 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private startGeneratorWithProvider(
|
||||
private async startGeneratorWithProvider(
|
||||
session: ReturnType<typeof this.sessionManager.getSession>,
|
||||
provider: 'claude' | 'gemini' | 'openrouter',
|
||||
source: string
|
||||
): void {
|
||||
): Promise<void> {
|
||||
if (!session) return;
|
||||
|
||||
if (session.abortController.signal.aborted) {
|
||||
@@ -107,7 +107,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
const agentName = provider === 'openrouter' ? 'OpenRouter' : (provider === 'gemini' ? 'Gemini' : 'Claude SDK');
|
||||
|
||||
const pendingStore = this.sessionManager.getPendingMessageStore();
|
||||
const actualQueueDepth = pendingStore.getPendingCount(session.sessionDbId);
|
||||
const actualQueueDepth = await pendingStore.getPendingCount(session.sessionDbId);
|
||||
|
||||
logger.info('SESSION', `Generator auto-starting (${source}) using ${agentName}`, {
|
||||
sessionId: session.sessionDbId,
|
||||
@@ -121,7 +121,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
const myController = session.abortController;
|
||||
|
||||
session.generatorPromise = agent.startSession(session, this.workerService)
|
||||
.catch(error => {
|
||||
.catch(async error => {
|
||||
if (myController.signal.aborted) {
|
||||
logger.debug('HTTP', 'Generator catch: ignoring error after abort', { sessionId: session.sessionDbId });
|
||||
return;
|
||||
@@ -145,9 +145,8 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
error: errorMsg
|
||||
}, error);
|
||||
|
||||
const pendingStore = this.sessionManager.getPendingMessageStore();
|
||||
try {
|
||||
const reset = pendingStore.resetProcessingToPending(session.sessionDbId);
|
||||
const reset = await this.sessionManager.resetProcessingToPending(session.sessionDbId);
|
||||
if (reset > 0) {
|
||||
logger.warn('SESSION', `Reset processing messages after generator error`, {
|
||||
sessionId: session.sessionDbId,
|
||||
@@ -168,8 +167,10 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
sessionManager: this.sessionManager,
|
||||
completionHandler: this.completionHandler,
|
||||
restartGenerator: (s, source) => {
|
||||
this.applyTierRouting(s);
|
||||
this.startGeneratorWithProvider(s, this.getSelectedProvider(), source);
|
||||
void (async () => {
|
||||
await this.applyTierRouting(s);
|
||||
await this.startGeneratorWithProvider(s, this.getSelectedProvider(), source);
|
||||
})();
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -222,7 +223,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
platformSource: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
private handleObservationsByClaudeId = this.wrapHandler((req: Request, res: Response): void => {
|
||||
private handleObservationsByClaudeId = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
|
||||
const {
|
||||
contentSessionId,
|
||||
tool_name,
|
||||
@@ -236,7 +237,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
toolUseId,
|
||||
} = req.body;
|
||||
|
||||
const result = ingestObservation({
|
||||
const result = await ingestObservation({
|
||||
contentSessionId,
|
||||
toolName: tool_name,
|
||||
toolInput: tool_input,
|
||||
@@ -261,7 +262,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
res.json({ status: 'queued' });
|
||||
});
|
||||
|
||||
private handleSummarizeByClaudeId = this.wrapHandler((req: Request, res: Response): void => {
|
||||
private handleSummarizeByClaudeId = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
|
||||
const { contentSessionId, last_assistant_message, agentId } = req.body;
|
||||
const platformSource = normalizePlatformSource(req.body.platformSource);
|
||||
|
||||
@@ -290,16 +291,16 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
const cleanedLastAssistantMessage = last_assistant_message
|
||||
? stripMemoryTagsFromPrompt(String(last_assistant_message))
|
||||
: last_assistant_message;
|
||||
this.sessionManager.queueSummarize(sessionDbId, cleanedLastAssistantMessage);
|
||||
await this.sessionManager.queueSummarize(sessionDbId, cleanedLastAssistantMessage);
|
||||
|
||||
this.ensureGeneratorRunning(sessionDbId, 'summarize');
|
||||
await this.ensureGeneratorRunning(sessionDbId, 'summarize');
|
||||
|
||||
this.eventBroadcaster.broadcastSummarizeQueued();
|
||||
|
||||
res.json({ status: 'queued' });
|
||||
});
|
||||
|
||||
private handleStatusByClaudeId = this.wrapHandler((req: Request, res: Response): void => {
|
||||
private handleStatusByClaudeId = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
|
||||
const contentSessionId = req.query.contentSessionId as string;
|
||||
|
||||
if (!contentSessionId) {
|
||||
@@ -316,7 +317,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
}
|
||||
|
||||
const pendingStore = this.sessionManager.getPendingMessageStore();
|
||||
const queueLength = pendingStore.getPendingCount(sessionDbId);
|
||||
const queueLength = await pendingStore.getPendingCount(sessionDbId);
|
||||
|
||||
res.json({
|
||||
status: 'active',
|
||||
@@ -327,7 +328,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
});
|
||||
});
|
||||
|
||||
private handleSessionInitByClaudeId = this.wrapHandler((req: Request, res: Response): void => {
|
||||
private handleSessionInitByClaudeId = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
|
||||
const { contentSessionId } = req.body;
|
||||
|
||||
const project = req.body.project || 'unknown';
|
||||
@@ -458,7 +459,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
});
|
||||
}
|
||||
|
||||
this.ensureGeneratorRunning(sessionDbId, 'init');
|
||||
await this.ensureGeneratorRunning(sessionDbId, 'init');
|
||||
|
||||
this.eventBroadcaster.broadcastSessionStarted(sessionDbId, session.project);
|
||||
} else {
|
||||
@@ -478,7 +479,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
'Read', 'Glob', 'Grep', 'LS', 'ListMcpResourcesTool'
|
||||
]);
|
||||
|
||||
private applyTierRouting(session: NonNullable<ReturnType<typeof this.sessionManager.getSession>>): void {
|
||||
private async applyTierRouting(session: NonNullable<ReturnType<typeof this.sessionManager.getSession>>): Promise<void> {
|
||||
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
|
||||
if (settings.CLAUDE_MEM_TIER_ROUTING_ENABLED === 'false') {
|
||||
session.modelOverride = undefined;
|
||||
@@ -488,7 +489,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
session.modelOverride = undefined;
|
||||
|
||||
const pendingStore = this.sessionManager.getPendingMessageStore();
|
||||
const pending = pendingStore.peekPendingTypes(session.sessionDbId);
|
||||
const pending = await pendingStore.peekPendingTypes(session.sessionDbId);
|
||||
|
||||
if (pending.length === 0) {
|
||||
session.modelOverride = undefined;
|
||||
|
||||
@@ -97,12 +97,20 @@ export class ViewerRoutes extends BaseRouteHandler {
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
const isProcessing = this.sessionManager.isAnySessionProcessing();
|
||||
const queueDepth = this.sessionManager.getTotalActiveWork();
|
||||
this.sseBroadcaster.broadcast({
|
||||
type: 'processing_status',
|
||||
isProcessing,
|
||||
queueDepth
|
||||
});
|
||||
void (async () => {
|
||||
try {
|
||||
const isProcessing = await this.sessionManager.isAnySessionProcessing();
|
||||
const queueDepth = await this.sessionManager.getTotalActiveWork();
|
||||
this.sseBroadcaster.broadcast({
|
||||
type: 'processing_status',
|
||||
isProcessing,
|
||||
queueDepth
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('HTTP', 'Failed to broadcast initial processing status', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ interface IngestContext {
|
||||
sessionManager: SessionManager;
|
||||
dbManager: DatabaseManager;
|
||||
eventBroadcaster: SessionEventBroadcaster;
|
||||
ensureGeneratorRunning?: (sessionDbId: number, source: string) => void;
|
||||
ensureGeneratorRunning?: (sessionDbId: number, source: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
let ctx: IngestContext | null = null;
|
||||
@@ -65,7 +65,7 @@ export function setIngestContext(next: IngestContext): void {
|
||||
}
|
||||
|
||||
export function attachIngestGeneratorStarter(
|
||||
ensureGeneratorRunning: (sessionDbId: number, source: string) => void,
|
||||
ensureGeneratorRunning: (sessionDbId: number, source: string) => void | Promise<void>,
|
||||
): void {
|
||||
requireContext().ensureGeneratorRunning = ensureGeneratorRunning;
|
||||
}
|
||||
@@ -94,7 +94,7 @@ export interface ObservationPayload {
|
||||
toolUseId?: string;
|
||||
}
|
||||
|
||||
export function ingestObservation(payload: ObservationPayload): IngestResult {
|
||||
export async function ingestObservation(payload: ObservationPayload): Promise<IngestResult> {
|
||||
const { sessionManager, dbManager, eventBroadcaster, ensureGeneratorRunning } = requireContext();
|
||||
|
||||
const platformSource = normalizePlatformSource(payload.platformSource);
|
||||
@@ -158,7 +158,7 @@ export function ingestObservation(payload: ObservationPayload): IngestResult {
|
||||
? stripMemoryTagsFromJson(JSON.stringify(payload.toolResponse))
|
||||
: '{}';
|
||||
|
||||
sessionManager.queueObservation(sessionDbId, {
|
||||
await sessionManager.queueObservation(sessionDbId, {
|
||||
tool_name: payload.toolName,
|
||||
tool_input: cleanedToolInput,
|
||||
tool_response: cleanedToolResponse,
|
||||
@@ -175,7 +175,7 @@ export function ingestObservation(payload: ObservationPayload): IngestResult {
|
||||
toolUseId: typeof payload.toolUseId === 'string' ? payload.toolUseId : undefined,
|
||||
});
|
||||
|
||||
ensureGeneratorRunning?.(sessionDbId, 'observation');
|
||||
await ensureGeneratorRunning?.(sessionDbId, 'observation');
|
||||
eventBroadcaster.broadcastObservationQueued(sessionDbId);
|
||||
|
||||
return { ok: true, sessionDbId };
|
||||
@@ -229,7 +229,7 @@ export type SummaryPayload =
|
||||
parsed: ParsedSummary;
|
||||
};
|
||||
|
||||
export function ingestSummary(payload: SummaryPayload): IngestResult {
|
||||
export async function ingestSummary(payload: SummaryPayload): Promise<IngestResult> {
|
||||
if (payload.kind === 'queue') {
|
||||
const { sessionManager, dbManager, ensureGeneratorRunning } = requireContext();
|
||||
|
||||
@@ -249,8 +249,8 @@ export function ingestSummary(payload: SummaryPayload): IngestResult {
|
||||
return { ok: false, reason: message, status: 500 };
|
||||
}
|
||||
|
||||
sessionManager.queueSummarize(sessionDbId, payload.lastAssistantMessage);
|
||||
ensureGeneratorRunning?.(sessionDbId, 'summarize');
|
||||
await sessionManager.queueSummarize(sessionDbId, payload.lastAssistantMessage);
|
||||
await ensureGeneratorRunning?.(sessionDbId, 'summarize');
|
||||
|
||||
return { ok: true, sessionDbId };
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { RestartGuard } from '../RestartGuard.js';
|
||||
export interface GeneratorExitDependencies {
|
||||
sessionManager: SessionManager;
|
||||
completionHandler: SessionCompletionHandler;
|
||||
restartGenerator: (session: ActiveSession, source: string) => void;
|
||||
restartGenerator: (session: ActiveSession, source: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
function isHardStopReason(reason: ActiveSession['abortReason']): boolean {
|
||||
@@ -50,11 +50,11 @@ export async function handleGeneratorExit(
|
||||
|
||||
const pendingStore = sessionManager.getPendingMessageStore();
|
||||
|
||||
const terminateSession = (logPrefix: string, clearPending: boolean) => {
|
||||
const terminateSession = async (logPrefix: string, clearPending: boolean) => {
|
||||
try {
|
||||
if (clearPending) {
|
||||
try {
|
||||
pendingStore.clearPendingForSession(sessionDbId);
|
||||
await pendingStore.clearPendingForSession(sessionDbId);
|
||||
} catch (e) {
|
||||
const normalized = e instanceof Error ? e : new Error(String(e));
|
||||
logger.error('SESSION', `${logPrefix} pending cleanup failed; continuing finalization`, {
|
||||
@@ -64,7 +64,7 @@ export async function handleGeneratorExit(
|
||||
}
|
||||
}
|
||||
try {
|
||||
completionHandler.finalizeSession(sessionDbId);
|
||||
await 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`, {
|
||||
@@ -82,26 +82,26 @@ export async function handleGeneratorExit(
|
||||
sessionId: sessionDbId,
|
||||
reason
|
||||
});
|
||||
terminateSession('Hard-stop', true);
|
||||
await terminateSession('Hard-stop', true);
|
||||
return;
|
||||
}
|
||||
|
||||
let pendingCount: number;
|
||||
try {
|
||||
pendingCount = pendingStore.getPendingCount(sessionDbId);
|
||||
pendingCount = await pendingStore.getPendingCount(sessionDbId);
|
||||
} catch (e) {
|
||||
const normalized = e instanceof Error ? e : new Error(String(e));
|
||||
logger.error('SESSION', 'Error during recovery pending-count check; aborting to prevent leaks', {
|
||||
sessionId: sessionDbId
|
||||
}, normalized);
|
||||
terminateSession('Recovery abort', true);
|
||||
await terminateSession('Recovery abort', true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingCount === 0) {
|
||||
session.restartGuard?.recordSuccess();
|
||||
session.consecutiveRestarts = 0;
|
||||
terminateSession('Natural completion', false);
|
||||
await terminateSession('Natural completion', false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ export async function handleGeneratorExit(
|
||||
maxConsecutiveFailures: session.restartGuard.maxConsecutiveFailures,
|
||||
});
|
||||
session.consecutiveRestarts = 0;
|
||||
terminateSession('Restart guard', true);
|
||||
await terminateSession('Restart guard', true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ export async function handleGeneratorExit(
|
||||
session.respawnTimer = undefined;
|
||||
const stillExists = deps.sessionManager.getSession(sessionDbId);
|
||||
if (stillExists && !stillExists.generatorPromise) {
|
||||
restartGenerator(stillExists, 'pending-work-restart');
|
||||
void restartGenerator(stillExists, 'pending-work-restart');
|
||||
}
|
||||
}, backoffMs);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export class SessionCompletionHandler {
|
||||
private dbManager: DatabaseManager
|
||||
) {}
|
||||
|
||||
finalizeSession(sessionDbId: number): void {
|
||||
async finalizeSession(sessionDbId: number): Promise<void> {
|
||||
const sessionStore = this.dbManager.getSessionStore();
|
||||
|
||||
const row = sessionStore.getSessionById(sessionDbId);
|
||||
@@ -28,7 +28,7 @@ export class SessionCompletionHandler {
|
||||
|
||||
try {
|
||||
const pendingStore = this.sessionManager.getPendingMessageStore();
|
||||
const cleared = pendingStore.clearPendingForSession(sessionDbId);
|
||||
const cleared = await pendingStore.clearPendingForSession(sessionDbId);
|
||||
if (cleared > 0) {
|
||||
logger.warn('SESSION', `Cleared ${cleared} orphaned pending messages on session finalize`, {
|
||||
sessionId: sessionDbId, cleared
|
||||
@@ -46,7 +46,7 @@ export class SessionCompletionHandler {
|
||||
}
|
||||
|
||||
async completeByDbId(sessionDbId: number): Promise<void> {
|
||||
this.finalizeSession(sessionDbId);
|
||||
await this.finalizeSession(sessionDbId);
|
||||
|
||||
await this.sessionManager.deleteSession(sessionDbId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user