backup: Phase 1 agent work (security, persistence, batch endpoint)
This is a backup of all work done by the 3 Phase 1 agents: Agent A - Command Injection Fix (Issue #354): - Fixed command injection in BranchManager.ts - Fixed unnecessary shell usage in bun-path.ts - Added comprehensive security test suite - Created SECURITY.md and SECURITY_AUDIT_REPORT.md Agent B - Observation Persistence Fix (Issue #353): - Added PendingMessageStore from PR #335 - Integrated persistent queue into SessionManager - Modified SDKAgent to mark messages complete - Updated SessionStore with pending_messages migration - Updated worker-types.ts with new interfaces Agent C - Batch Endpoint Verification (Issue #348): - Created batch-observations.test.ts - Updated worker-service.mdx documentation Also includes: - Documentation context files (biomimetic, windows struggles) - Build artifacts from agent testing This work will be re-evaluated after v7.3.0 release.
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
import { Database } from './sqlite-compat.js';
|
||||
import type { PendingMessage } from '../worker-types.js';
|
||||
|
||||
/**
|
||||
* Persistent pending message record from database
|
||||
*/
|
||||
export interface PersistentPendingMessage {
|
||||
id: number;
|
||||
session_db_id: number;
|
||||
claude_session_id: string;
|
||||
message_type: 'observation' | 'summarize';
|
||||
tool_name: string | null;
|
||||
tool_input: string | null;
|
||||
tool_response: string | null;
|
||||
cwd: string | null;
|
||||
last_user_message: string | null;
|
||||
last_assistant_message: string | null;
|
||||
prompt_number: number | null;
|
||||
status: 'pending' | 'processing' | 'processed' | 'failed';
|
||||
retry_count: number;
|
||||
created_at_epoch: number;
|
||||
started_processing_at_epoch: number | null;
|
||||
completed_at_epoch: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* PendingMessageStore - Persistent work queue for SDK messages
|
||||
*
|
||||
* Messages are persisted before processing and marked complete after success.
|
||||
* This enables recovery from SDK hangs and worker crashes.
|
||||
*
|
||||
* Lifecycle:
|
||||
* 1. enqueue() - Message persisted with status 'pending'
|
||||
* 2. markProcessing() - Status changes to 'processing' when yielded to SDK
|
||||
* 3. markProcessed() - Status changes to 'processed' after successful SDK response
|
||||
* 4. markFailed() - Status changes to 'failed' if max retries exceeded
|
||||
*
|
||||
* Recovery:
|
||||
* - resetStuckMessages() - Moves 'processing' messages back to 'pending' if stuck
|
||||
* - getSessionsWithPendingMessages() - Find sessions that need recovery on startup
|
||||
*/
|
||||
export class PendingMessageStore {
|
||||
private db: Database;
|
||||
private maxRetries: number;
|
||||
|
||||
constructor(db: Database, maxRetries: number = 3) {
|
||||
this.db = db;
|
||||
this.maxRetries = maxRetries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a new message (persist before processing)
|
||||
* @returns The database ID of the persisted message
|
||||
*/
|
||||
enqueue(sessionDbId: number, claudeSessionId: string, message: PendingMessage): number {
|
||||
const now = Date.now();
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO pending_messages (
|
||||
session_db_id, claude_session_id, message_type,
|
||||
tool_name, tool_input, tool_response, cwd,
|
||||
last_user_message, last_assistant_message,
|
||||
prompt_number, status, retry_count, created_at_epoch
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?)
|
||||
`);
|
||||
|
||||
const result = stmt.run(
|
||||
sessionDbId,
|
||||
claudeSessionId,
|
||||
message.type,
|
||||
message.tool_name || null,
|
||||
message.tool_input ? JSON.stringify(message.tool_input) : null,
|
||||
message.tool_response ? JSON.stringify(message.tool_response) : null,
|
||||
message.cwd || null,
|
||||
message.last_user_message || null,
|
||||
message.last_assistant_message || null,
|
||||
message.prompt_number || null,
|
||||
now
|
||||
);
|
||||
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Peek at oldest pending message for session (does NOT change status)
|
||||
* @returns The oldest pending message or null if none
|
||||
*/
|
||||
peekPending(sessionDbId: number): PersistentPendingMessage | null {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT * FROM pending_messages
|
||||
WHERE session_db_id = ? AND status = 'pending'
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
`);
|
||||
return stmt.get(sessionDbId) as PersistentPendingMessage | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pending messages for session (ordered by creation time)
|
||||
*/
|
||||
getAllPending(sessionDbId: number): PersistentPendingMessage[] {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT * FROM pending_messages
|
||||
WHERE session_db_id = ? AND status = 'pending'
|
||||
ORDER BY id ASC
|
||||
`);
|
||||
return stmt.all(sessionDbId) as PersistentPendingMessage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all queue messages (for UI display)
|
||||
* Returns pending, processing, and failed messages (not processed - they're deleted)
|
||||
* Joins with sdk_sessions to get project name
|
||||
*/
|
||||
getQueueMessages(): (PersistentPendingMessage & { project: string | null })[] {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT pm.*, ss.project
|
||||
FROM pending_messages pm
|
||||
LEFT JOIN sdk_sessions ss ON pm.claude_session_id = ss.claude_session_id
|
||||
WHERE pm.status IN ('pending', 'processing', 'failed')
|
||||
ORDER BY
|
||||
CASE pm.status
|
||||
WHEN 'failed' THEN 0
|
||||
WHEN 'processing' THEN 1
|
||||
WHEN 'pending' THEN 2
|
||||
END,
|
||||
pm.created_at_epoch ASC
|
||||
`);
|
||||
return stmt.all() as (PersistentPendingMessage & { project: string | null })[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of stuck messages (processing longer than threshold)
|
||||
*/
|
||||
getStuckCount(thresholdMs: number): number {
|
||||
const cutoff = Date.now() - thresholdMs;
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT COUNT(*) as count FROM pending_messages
|
||||
WHERE status = 'processing' AND started_processing_at_epoch < ?
|
||||
`);
|
||||
const result = stmt.get(cutoff) as { count: number };
|
||||
return result.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a specific message (reset to pending)
|
||||
* Works for pending (re-queue), processing (reset stuck), and failed messages
|
||||
*/
|
||||
retryMessage(messageId: number): boolean {
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE pending_messages
|
||||
SET status = 'pending', started_processing_at_epoch = NULL
|
||||
WHERE id = ? AND status IN ('pending', 'processing', 'failed')
|
||||
`);
|
||||
const result = stmt.run(messageId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all processing messages for a session to pending
|
||||
* Used when force-restarting a stuck session
|
||||
*/
|
||||
resetProcessingToPending(sessionDbId: number): number {
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE pending_messages
|
||||
SET status = 'pending', started_processing_at_epoch = NULL
|
||||
WHERE session_db_id = ? AND status = 'processing'
|
||||
`);
|
||||
const result = stmt.run(sessionDbId);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort a specific message (delete from queue)
|
||||
*/
|
||||
abortMessage(messageId: number): boolean {
|
||||
const stmt = this.db.prepare('DELETE FROM pending_messages WHERE id = ?');
|
||||
const result = stmt.run(messageId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry all stuck messages at once
|
||||
*/
|
||||
retryAllStuck(thresholdMs: number): number {
|
||||
const cutoff = Date.now() - thresholdMs;
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE pending_messages
|
||||
SET status = 'pending', started_processing_at_epoch = NULL
|
||||
WHERE status = 'processing' AND started_processing_at_epoch < ?
|
||||
`);
|
||||
const result = stmt.run(cutoff);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recently processed messages (for UI feedback)
|
||||
* Shows messages completed in the last N minutes so users can see their stuck items were processed
|
||||
*/
|
||||
getRecentlyProcessed(limit: number = 10, withinMinutes: number = 30): (PersistentPendingMessage & { project: string | null })[] {
|
||||
const cutoff = Date.now() - (withinMinutes * 60 * 1000);
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT pm.*, ss.project
|
||||
FROM pending_messages pm
|
||||
LEFT JOIN sdk_sessions ss ON pm.claude_session_id = ss.claude_session_id
|
||||
WHERE pm.status = 'processed' AND pm.completed_at_epoch > ?
|
||||
ORDER BY pm.completed_at_epoch DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
return stmt.all(cutoff, limit) as (PersistentPendingMessage & { project: string | null })[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark message as being processed (status: pending -> processing)
|
||||
*/
|
||||
markProcessing(messageId: number): void {
|
||||
const now = Date.now();
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE pending_messages
|
||||
SET status = 'processing', started_processing_at_epoch = ?
|
||||
WHERE id = ? AND status = 'pending'
|
||||
`);
|
||||
stmt.run(now, messageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark message as successfully processed (status: processing -> processed)
|
||||
*/
|
||||
markProcessed(messageId: number): void {
|
||||
const now = Date.now();
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE pending_messages
|
||||
SET status = 'processed', completed_at_epoch = ?
|
||||
WHERE id = ? AND status = 'processing'
|
||||
`);
|
||||
stmt.run(now, messageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark message as failed (status: processing -> failed or back to pending for retry)
|
||||
* If retry_count < maxRetries, moves back to 'pending' for retry
|
||||
* Otherwise marks as 'failed' permanently
|
||||
*/
|
||||
markFailed(messageId: number): void {
|
||||
const now = Date.now();
|
||||
|
||||
// Get current retry count
|
||||
const msg = this.db.prepare('SELECT retry_count FROM pending_messages WHERE id = ?').get(messageId) as { retry_count: number } | undefined;
|
||||
|
||||
if (!msg) return;
|
||||
|
||||
if (msg.retry_count < this.maxRetries) {
|
||||
// Move back to pending for retry
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE pending_messages
|
||||
SET status = 'pending', retry_count = retry_count + 1, started_processing_at_epoch = NULL
|
||||
WHERE id = ?
|
||||
`);
|
||||
stmt.run(messageId);
|
||||
} else {
|
||||
// Max retries exceeded, mark as permanently failed
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE pending_messages
|
||||
SET status = 'failed', completed_at_epoch = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
stmt.run(now, messageId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset stuck messages (processing -> pending if stuck longer than threshold)
|
||||
* @param thresholdMs Messages processing longer than this are considered stuck (0 = reset all)
|
||||
* @returns Number of messages reset
|
||||
*/
|
||||
resetStuckMessages(thresholdMs: number): number {
|
||||
const cutoff = thresholdMs === 0 ? Date.now() : Date.now() - thresholdMs;
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE pending_messages
|
||||
SET status = 'pending', started_processing_at_epoch = NULL
|
||||
WHERE status = 'processing' AND started_processing_at_epoch < ?
|
||||
`);
|
||||
|
||||
const result = stmt.run(cutoff);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of pending messages for a session
|
||||
*/
|
||||
getPendingCount(sessionDbId: number): number {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT COUNT(*) as count FROM pending_messages
|
||||
WHERE session_db_id = ? AND status IN ('pending', 'processing')
|
||||
`);
|
||||
const result = stmt.get(sessionDbId) as { count: number };
|
||||
return result.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any session has pending work
|
||||
*/
|
||||
hasAnyPendingWork(): boolean {
|
||||
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 > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all session IDs that have pending messages (for recovery on startup)
|
||||
*/
|
||||
getSessionsWithPendingMessages(): number[] {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT DISTINCT session_db_id FROM pending_messages
|
||||
WHERE status IN ('pending', 'processing')
|
||||
`);
|
||||
const results = stmt.all() as { session_db_id: number }[];
|
||||
return results.map(r => r.session_db_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session info for a pending message (for recovery)
|
||||
*/
|
||||
getSessionInfoForMessage(messageId: number): { sessionDbId: number; claudeSessionId: string } | null {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT session_db_id, claude_session_id FROM pending_messages WHERE id = ?
|
||||
`);
|
||||
const result = stmt.get(messageId) as { session_db_id: number; claude_session_id: string } | undefined;
|
||||
return result ? { sessionDbId: result.session_db_id, claudeSessionId: result.claude_session_id } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup old processed messages (retention policy)
|
||||
* @param retentionMs Delete processed messages older than this (0 = delete all processed)
|
||||
* @returns Number of messages deleted
|
||||
*/
|
||||
cleanupProcessed(retentionMs: number): number {
|
||||
const cutoff = retentionMs === 0 ? Date.now() : Date.now() - retentionMs;
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
DELETE FROM pending_messages
|
||||
WHERE status = 'processed' AND completed_at_epoch < ?
|
||||
`);
|
||||
|
||||
const result = stmt.run(cutoff);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a PersistentPendingMessage back to PendingMessage format
|
||||
*/
|
||||
toPendingMessage(persistent: PersistentPendingMessage): PendingMessage {
|
||||
return {
|
||||
type: persistent.message_type,
|
||||
tool_name: persistent.tool_name || undefined,
|
||||
tool_input: persistent.tool_input ? JSON.parse(persistent.tool_input) : undefined,
|
||||
tool_response: persistent.tool_response ? JSON.parse(persistent.tool_response) : undefined,
|
||||
prompt_number: persistent.prompt_number || undefined,
|
||||
cwd: persistent.cwd || undefined,
|
||||
last_user_message: persistent.last_user_message || undefined,
|
||||
last_assistant_message: persistent.last_assistant_message || undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user