Merge main into thedotmack/file-read-timeline-inject
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -646,18 +646,19 @@ export function spawnDaemon(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const escapedRuntimePath = runtimePath.replace(/'/g, "''");
|
||||
const escapedScriptPath = scriptPath.replace(/'/g, "''");
|
||||
const psCommand = `Start-Process -FilePath '${escapedRuntimePath}' -ArgumentList '${escapedScriptPath}','--daemon' -WindowStyle Hidden`;
|
||||
// Use -EncodedCommand to avoid all shell quoting issues with spaces in paths
|
||||
const psScript = `Start-Process -FilePath '${runtimePath.replace(/'/g, "''")}' -ArgumentList @('${scriptPath.replace(/'/g, "''")}','--daemon') -WindowStyle Hidden`;
|
||||
const encodedCommand = Buffer.from(psScript, 'utf16le').toString('base64');
|
||||
|
||||
try {
|
||||
execSync(`powershell -NoProfile -Command "${psCommand}"`, {
|
||||
execSync(`powershell -NoProfile -EncodedCommand ${encodedCommand}`, {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
env
|
||||
});
|
||||
return 0;
|
||||
} catch (error) {
|
||||
// APPROVED OVERRIDE: Windows daemon spawn is best-effort; log and let callers fall back to health checks/retry flow.
|
||||
logger.error('SYSTEM', 'Failed to spawn worker daemon on Windows', { runtimePath }, error as Error);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -578,6 +578,13 @@ export class WorkerService {
|
||||
'ENOENT',
|
||||
'spawn',
|
||||
'Invalid API key',
|
||||
'API_KEY_INVALID',
|
||||
'API key expired',
|
||||
'API key not valid',
|
||||
'PERMISSION_DENIED',
|
||||
'Gemini API error: 400',
|
||||
'Gemini API error: 401',
|
||||
'Gemini API error: 403',
|
||||
'FOREIGN KEY constraint failed',
|
||||
];
|
||||
if (unrecoverablePatterns.some(pattern => errorMessage.includes(pattern))) {
|
||||
@@ -653,30 +660,26 @@ export class WorkerService {
|
||||
|
||||
// Do NOT restart after unrecoverable errors - prevents infinite loops
|
||||
if (hadUnrecoverableError) {
|
||||
logger.warn('SYSTEM', 'Skipping restart due to unrecoverable error', {
|
||||
sessionId: session.sessionDbId
|
||||
});
|
||||
this.broadcastProcessingStatus();
|
||||
this.terminateSession(session.sessionDbId, 'unrecoverable_error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Store for pending-count check below
|
||||
const { PendingMessageStore } = require('./sqlite/PendingMessageStore.js');
|
||||
const pendingStore = new PendingMessageStore(this.dbManager.getSessionStore().db, 3);
|
||||
|
||||
// Idle timeout means no new work arrived for 3 minutes - don't restart
|
||||
// No need to reset stale processing messages here — claimNextMessage() self-heals
|
||||
if (session.idleTimedOut) {
|
||||
logger.info('SYSTEM', 'Generator exited due to idle timeout, not restarting', {
|
||||
sessionId: session.sessionDbId
|
||||
});
|
||||
session.idleTimedOut = false; // Reset flag
|
||||
this.broadcastProcessingStatus();
|
||||
return;
|
||||
}
|
||||
const pendingStore = this.sessionManager.getPendingMessageStore();
|
||||
|
||||
// Check if there's pending work that needs processing with a fresh AbortController
|
||||
const pendingCount = pendingStore.getPendingCount(session.sessionDbId);
|
||||
|
||||
// Idle timeout means no new work arrived for 3 minutes - don't restart
|
||||
// But check pendingCount first: a message may have arrived between idle
|
||||
// abort and .finally(), and we must not abandon it
|
||||
if (session.idleTimedOut) {
|
||||
session.idleTimedOut = false; // Reset flag
|
||||
if (pendingCount === 0) {
|
||||
this.terminateSession(session.sessionDbId, 'idle_timeout');
|
||||
return;
|
||||
}
|
||||
// Fall through to pending-work restart below
|
||||
}
|
||||
const MAX_PENDING_RESTARTS = 3;
|
||||
|
||||
if (pendingCount > 0) {
|
||||
@@ -690,7 +693,7 @@ export class WorkerService {
|
||||
consecutiveRestarts: session.consecutiveRestarts
|
||||
});
|
||||
session.consecutiveRestarts = 0;
|
||||
this.broadcastProcessingStatus();
|
||||
this.terminateSession(session.sessionDbId, 'max_restarts_exceeded');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -703,12 +706,13 @@ export class WorkerService {
|
||||
session.abortController = new AbortController();
|
||||
// Restart processor
|
||||
this.startSessionProcessor(session, 'pending-work-restart');
|
||||
this.broadcastProcessingStatus();
|
||||
} else {
|
||||
// Successful completion with no pending work — reset counter
|
||||
// Successful completion with no pending work — clean up session
|
||||
// removeSessionImmediate fires onSessionDeletedCallback → broadcastProcessingStatus()
|
||||
session.consecutiveRestarts = 0;
|
||||
this.sessionManager.removeSessionImmediate(session.sessionDbId);
|
||||
}
|
||||
|
||||
this.broadcastProcessingStatus();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -784,6 +788,30 @@ export class WorkerService {
|
||||
this.sessionEventBroadcaster.broadcastSessionCompleted(sessionDbId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate a session that will not restart.
|
||||
* Enforces the restart-or-terminate invariant: every generator exit
|
||||
* must either call startSessionProcessor() or terminateSession().
|
||||
* No zombie sessions allowed.
|
||||
*
|
||||
* GENERATOR EXIT INVARIANT:
|
||||
* .finally() → restart? → startSessionProcessor()
|
||||
* no? → terminateSession()
|
||||
*/
|
||||
private terminateSession(sessionDbId: number, reason: string): void {
|
||||
const pendingStore = this.sessionManager.getPendingMessageStore();
|
||||
const abandoned = pendingStore.markAllSessionMessagesAbandoned(sessionDbId);
|
||||
|
||||
logger.info('SYSTEM', 'Session terminated', {
|
||||
sessionId: sessionDbId,
|
||||
reason,
|
||||
abandonedMessages: abandoned
|
||||
});
|
||||
|
||||
// removeSessionImmediate fires onSessionDeletedCallback → broadcastProcessingStatus()
|
||||
this.sessionManager.removeSessionImmediate(sessionDbId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process pending session queues
|
||||
*/
|
||||
@@ -907,8 +935,8 @@ export class WorkerService {
|
||||
* Broadcast processing status change to SSE clients
|
||||
*/
|
||||
broadcastProcessingStatus(): void {
|
||||
const isProcessing = this.sessionManager.isAnySessionProcessing();
|
||||
const queueDepth = this.sessionManager.getTotalActiveWork();
|
||||
const isProcessing = queueDepth > 0;
|
||||
const activeSessions = this.sessionManager.getActiveSessionCount();
|
||||
|
||||
logger.info('WORKER', 'Broadcasting processing status', {
|
||||
@@ -1241,7 +1269,10 @@ async function main() {
|
||||
// Check if running as main module in both ESM and CommonJS
|
||||
const isMainModule = typeof require !== 'undefined' && typeof module !== 'undefined'
|
||||
? require.main === module || !module.parent
|
||||
: import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('worker-service');
|
||||
: import.meta.url === `file://${process.argv[1]}`
|
||||
|| process.argv[1]?.endsWith('worker-service')
|
||||
|| process.argv[1]?.endsWith('worker-service.cjs')
|
||||
|| process.argv[1]?.replaceAll('\\', '/') === __filename?.replaceAll('\\', '/');
|
||||
|
||||
if (isMainModule) {
|
||||
main().catch((error) => {
|
||||
|
||||
@@ -350,7 +350,7 @@ export class SessionManager {
|
||||
this.sessions.delete(sessionDbId);
|
||||
this.sessionQueues.delete(sessionDbId);
|
||||
|
||||
logger.info('SESSION', 'Session removed (orphaned after SDK termination)', {
|
||||
logger.info('SESSION', 'Session removed from active sessions', {
|
||||
sessionId: sessionDbId,
|
||||
project: session.project
|
||||
});
|
||||
@@ -402,10 +402,11 @@ export class SessionManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any session has pending messages (for spinner tracking)
|
||||
* Check if any active session has pending messages (for spinner tracking).
|
||||
* Scoped to in-memory sessions only.
|
||||
*/
|
||||
hasPendingMessages(): boolean {
|
||||
return this.getPendingStore().hasAnyPendingWork();
|
||||
return this.getTotalQueueDepth() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -437,12 +438,12 @@ export class SessionManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any session is actively processing (has pending messages OR active generator)
|
||||
* Used for activity indicator to prevent spinner from stopping while SDK is processing
|
||||
* Check if any active session has pending work.
|
||||
* Scoped to in-memory sessions only — orphaned DB messages from dead
|
||||
* sessions must not keep the spinner spinning forever.
|
||||
*/
|
||||
isAnySessionProcessing(): boolean {
|
||||
// hasAnyPendingWork checks for 'pending' OR 'processing'
|
||||
return this.getPendingStore().hasAnyPendingWork();
|
||||
return this.getTotalQueueDepth() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,12 +33,6 @@ export class SessionEventBroadcaster {
|
||||
prompt
|
||||
});
|
||||
|
||||
// Start activity indicator (work is about to begin)
|
||||
this.sseBroadcaster.broadcast({
|
||||
type: 'processing_status',
|
||||
isProcessing: true
|
||||
});
|
||||
|
||||
// Update processing status based on queue depth
|
||||
this.workerService.broadcastProcessingStatus();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user