diff --git a/package.json b/package.json index efb3f356..f1a04eff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-company", - "version": "0.4.95", + "version": "0.4.96", "private": true, "type": "module", "description": "Cross-platform Electron desktop control plane for local-first AI agent companies.", diff --git a/src/main/automation-service.ts b/src/main/automation-service.ts index 7ebea51c..1638e33b 100644 --- a/src/main/automation-service.ts +++ b/src/main/automation-service.ts @@ -69,6 +69,7 @@ interface AutomationDependencies { notify: (options: NotificationOptions) => void; wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null; dispatchBrowserAction: (actionId: string, companyId: string) => Promise; + isQuitting: () => boolean; } interface RunFinishedEvent { @@ -86,9 +87,11 @@ export function createAutomationService({ notify, wakeAgentIfPossible, dispatchBrowserAction, + isQuitting, }: AutomationDependencies) { const activeAutomationExecutions = new Set(); const activeWorkflowTriggerExecutions = new Set(); + const pendingScheduledTimers = new Set>(); function normalizeWorkflowSteps(raw: unknown): WorkflowStepState[] { if (!Array.isArray(raw)) { @@ -642,6 +645,7 @@ export function createAutomationService({ } function dispatchAutomation(trigger: string, ctx: AutomationEventContext) { + if (isQuitting()) return; evaluateAutomationRules(trigger, ctx); evaluateWorkflowTriggers(trigger, ctx); } @@ -859,6 +863,7 @@ export function createAutomationService({ } function processRunCompletion(run: RunRecord, event: RunFinishedEvent) { + if (isQuitting()) return; let task: ReturnType | null = null; try { task = db.getTask(run.taskId); @@ -1032,6 +1037,7 @@ export function createAutomationService({ } function handleApprovalResolved(approvalId: string, companyId: string, decision: string) { + if (isQuitting()) return; const snapshot = db.listSnapshot(); const approval = snapshot.approvals.find((entry) => entry.id === approvalId); if (!approval) return; @@ -1168,7 +1174,12 @@ export function createAutomationService({ if (draft.scheduledAt) { const delay = new Date(draft.scheduledAt).getTime() - Date.now(); if (delay > 0) { - setTimeout(dispatchSocialAction, delay); + const timer = setTimeout(() => { + pendingScheduledTimers.delete(timer); + if (isQuitting()) return; + dispatchSocialAction(); + }, delay); + pendingScheduledTimers.add(timer); } else { dispatchSocialAction(); } @@ -1229,6 +1240,7 @@ export function createAutomationService({ } function handleTaskStatusChange(taskId: string, companyId: string, previousStatus: string, newStatus: string) { + if (isQuitting()) return; if (previousStatus === newStatus) return; let task: ReturnType | null = null; @@ -1298,6 +1310,7 @@ export function createAutomationService({ } function handleTaskCreated(taskId: string, companyId: string) { + if (isQuitting()) return; let task: ReturnType | null = null; try { task = db.getTask(taskId); @@ -1322,6 +1335,13 @@ export function createAutomationService({ } } + function destroy() { + for (const timer of pendingScheduledTimers) { + clearTimeout(timer); + } + pendingScheduledTimers.clear(); + } + return { runWorkflowPipeline, dispatchAutomation, @@ -1329,5 +1349,6 @@ export function createAutomationService({ handleApprovalResolved, handleTaskStatusChange, handleTaskCreated, + destroy, }; } diff --git a/src/main/bootstrap-application.ts b/src/main/bootstrap-application.ts index 6c084acd..d9c24865 100644 --- a/src/main/bootstrap-application.ts +++ b/src/main/bootstrap-application.ts @@ -192,6 +192,7 @@ export async function bootstrapApplication({ // Re-register timers for scheduled social posts (survives app restart). // Scan all companies for queued browser actions with a future scheduledAt time // and set up setTimeout to dispatch them at the right moment. + const scheduledPostTimers = new Set>(); try { const snapshot = db.listSnapshot(); for (const company of snapshot.companies) { @@ -203,12 +204,15 @@ export async function bootstrapApplication({ if (payload.scheduledAt) { const delay = new Date(payload.scheduledAt).getTime() - Date.now(); if (delay > 0) { - setTimeout(() => { - const browserActions = serviceGraph.getBrowserActionService(); + const timer = setTimeout(() => { + scheduledPostTimers.delete(timer); + if (isQuittingRef.current) return; + const browserActions = serviceGraph?.getBrowserActionService(); if (browserActions) { void browserActions.dispatchBrowserAction(action.id, company.id); } }, delay); + scheduledPostTimers.add(timer); log.info(`[bootstrap] Re-registered scheduled post timer: action ${action.id} in ${Math.round(delay / 1000)}s`); } // If delay <= 0, the action is overdue -- normal dispatch cycle will pick it up @@ -221,6 +225,16 @@ export async function bootstrapApplication({ log.warn(`[bootstrap] Failed to re-register scheduled post timers: ${error instanceof Error ? error.message : String(error)}`); } + // Register cleanup for bootstrap-owned resources during quit. + // This runs BEFORE service-graph's onBeforeQuit (which is registered later). + app.on("before-quit", () => { + for (const timer of scheduledPostTimers) { + clearTimeout(timer); + } + scheduledPostTimers.clear(); + notificationService.destroy(); + }); + // Show the window immediately so the user sees the app launch without delay. await serviceGraph.getDesktopShellService()?.ensureWindow(); serviceGraph.getAppLifecycleService()?.register(hasSingleInstanceLock); diff --git a/src/main/desktop-shell-service.ts b/src/main/desktop-shell-service.ts index 53e4306a..8385d01b 100644 --- a/src/main/desktop-shell-service.ts +++ b/src/main/desktop-shell-service.ts @@ -30,6 +30,7 @@ interface DesktopShellDependencies { onBackupProfile: () => Promise; onShortcut: (action: "toggle-command-palette" | "toggle-console" | "refresh") => void; notify: (options: NotificationOptions) => void; + getActiveRunCount: () => number; } function isSocialBrowserSession(contents: Electron.WebContents) { @@ -55,11 +56,91 @@ export function createDesktopShellService({ onBackupProfile, onShortcut, notify, + getActiveRunCount, }: DesktopShellDependencies) { let mainWindow: BrowserWindow | null = null; let appTray: Tray | null = null; let backgroundCloseNoticeShown = false; let windowCreationPromise: Promise | null = null; + let quitDialogShowing = false; + + /** + * Quit confirmation gate — shown when agents are actively running. + * Gives the user three choices: + * 0 = "Stop All & Quit" → terminate all agents and exit + * 1 = "Minimize to Background" → hide window, agents keep running + * 2 = "Cancel" → do nothing + * + * When no agents are active the app quits immediately without a dialog. + */ + async function requestQuitWithConfirmation() { + if (quitDialogShowing) return; + + let activeRunCount = 0; + try { + activeRunCount = getActiveRunCount(); + } catch { + // Cannot determine active runs — proceed with immediate quit as fail-safe + onRequestQuit(); + app.quit(); + return; + } + + if (activeRunCount === 0) { + onRequestQuit(); + app.quit(); + return; + } + + quitDialogShowing = true; + try { + const message = activeRunCount === 1 + ? "1 agent run is currently active." + : `${activeRunCount} agent runs are currently active.`; + + const options: Electron.MessageBoxOptions = { + type: "question", + buttons: ["Stop All & Quit", "Minimize to Background", "Cancel"], + defaultId: 2, + cancelId: 2, + title: "AgentCompany", + message, + detail: "Quitting will stop all running agents and their tasks. You can also minimize to keep agents running in the background.", + }; + + const parentWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null; + const result = parentWindow + ? await dialog.showMessageBox(parentWindow, options) + : await dialog.showMessageBox(options); + + if (result.response === 0) { + // Stop All & Quit + onRequestQuit(); + app.quit(); + } else if (result.response === 1) { + // Minimize to Background + if (parentWindow) { + parentWindow.hide(); + } + if (!backgroundCloseNoticeShown) { + backgroundCloseNoticeShown = true; + notify({ + title: "Running in Background", + body: "AgentCompany is still running in the system tray", + urgency: "informational", + navigation: { section: "overview" }, + }); + } + } + // Cancel: do nothing + } catch { + // Dialog failed (e.g. window destroyed mid-flight) — quit as fail-safe + onRequestQuit(); + app.quit(); + } finally { + quitDialogShowing = false; + } + } function getMainWindow() { return mainWindow; @@ -195,7 +276,11 @@ export function createDesktopShellService({ { role: "hideOthers" }, { role: "unhide" }, { type: "separator" }, - { role: "quit" }, + { + label: `Quit ${app.name}`, + accelerator: "CmdOrCtrl+Q", + click: () => void requestQuitWithConfirmation(), + }, ], }; const template: Electron.MenuItemConstructorOptions[] = [ @@ -222,7 +307,14 @@ export function createDesktopShellService({ await onBackupProfile(); }, }, - ...(process.platform === "darwin" ? [] : [{ type: "separator" as const }, { role: "quit" as const }]), + ...(process.platform === "darwin" ? [] : [ + { type: "separator" as const }, + { + label: "Quit", + accelerator: "Alt+F4", + click: () => void requestQuitWithConfirmation(), + }, + ]), ], }, { @@ -333,10 +425,7 @@ export function createDesktopShellService({ { type: "separator" }, { label: "Quit", - click: () => { - onRequestQuit(); - app.quit(); - }, + click: () => void requestQuitWithConfirmation(), }, ]), ); diff --git a/src/main/orchestration-service.ts b/src/main/orchestration-service.ts index 8677fc58..e6e40a36 100644 --- a/src/main/orchestration-service.ts +++ b/src/main/orchestration-service.ts @@ -60,6 +60,7 @@ interface OrchestrationDependencies { dispatchAutomation: (trigger: string, ctx: AutomationDispatch) => void; hasWorkerReady: () => boolean; enqueueWorkerRun: (payload: EnqueueRunPayload) => void; + isQuitting: () => boolean; } export function createOrchestrationService({ @@ -72,6 +73,7 @@ export function createOrchestrationService({ dispatchAutomation, hasWorkerReady, enqueueWorkerRun, + isQuitting, }: OrchestrationDependencies) { let heartbeatTimer: ReturnType | null = null; const scheduleLastFiredMap = new Map(); @@ -182,6 +184,9 @@ export function createOrchestrationService({ trigger: string; }) { const { runId, companyId, agent, task, connector, workspace, workspaceMode, companyName, companyDescription, snapshot, trigger } = params; + if (isQuitting()) { + throw new Error("Application is shutting down — refusing to dispatch new run."); + } if (!hasWorkerReady()) { throw new Error("Orchestration worker is not ready."); } @@ -311,6 +316,7 @@ export function createOrchestrationService({ } function startAgentHeartbeatRun(agentId: string, companyId: string, trigger: string): string | null { + if (isQuitting()) return null; if (!hasWorkerReady()) return null; const companyStatus = db.getCompanyStatus(companyId); if (companyStatus !== "active") { @@ -448,7 +454,7 @@ export function createOrchestrationService({ } function wakeAgentIfPossible(agentId: string | null | undefined, companyId: string, trigger: string) { - if (!agentId) return null; + if (!agentId || isQuitting()) return null; try { const agent = db.getAgent(agentId); if (agent.companyId !== companyId) { @@ -473,6 +479,9 @@ export function createOrchestrationService({ } function queueTaskRun(taskId: string, trigger: string, actor: string) { + if (isQuitting()) { + return fail("APP_SHUTTING_DOWN", "Application is shutting down."); + } if (!hasWorkerReady()) { logger.warn(`[run] Worker not ready — cannot queue run for task ${taskId} (trigger: ${trigger})`); return fail("WORKER_UNAVAILABLE", "Orchestration worker is not ready."); @@ -556,7 +565,7 @@ export function createOrchestrationService({ } function resumeQueuedRuns() { - if (!hasWorkerReady()) { + if (isQuitting() || !hasWorkerReady()) { return; } const snapshot = db.listSnapshot(); @@ -610,7 +619,7 @@ export function createOrchestrationService({ heartbeatTimer = setInterval(() => { try { - if (!hasWorkerReady()) return; + if (isQuitting() || !hasWorkerReady()) return; const activeCompanies = db.listActiveCompanyIds(); const now = Date.now(); diff --git a/src/main/service-graph.ts b/src/main/service-graph.ts index f8170575..aae90abd 100644 --- a/src/main/service-graph.ts +++ b/src/main/service-graph.ts @@ -135,6 +135,14 @@ export function createServiceGraph({ }, onShortcut: (action) => emitEvent({ type: "shortcut", action }), notify, + getActiveRunCount: () => { + try { + const snapshot = db.listSnapshot(); + return snapshot.runs.filter((r) => r.status === "running" || r.status === "queued").length; + } catch { + return 0; + } + }, }); desktopShellService.initialize(); @@ -169,6 +177,7 @@ export function createServiceGraph({ enqueueWorkerRun: (payload) => { workerService?.getWorker()?.postMessage({ type: "enqueue-run", payload }); }, + isQuitting: () => isQuittingRef.current, }); workerService.createWorker(); @@ -180,6 +189,7 @@ export function createServiceGraph({ notify, wakeAgentIfPossible, dispatchBrowserAction, + isQuitting: () => isQuittingRef.current, }); onboardingService = createOnboardingService({ @@ -210,6 +220,7 @@ export function createServiceGraph({ onBeforeQuit: () => { isQuittingRef.current = true; orchestrationService?.stopHeartbeatScheduler(); + automationService?.destroy(); apiServerRef.current?.stop(); apiServerRef.current = null; browserManagerRef.current?.shutdown(); diff --git a/src/main/worker-service.ts b/src/main/worker-service.ts index f5976b22..a29496a1 100644 --- a/src/main/worker-service.ts +++ b/src/main/worker-service.ts @@ -83,8 +83,11 @@ export function createWorkerService({ const MAX_CONSECUTIVE_WAKES = 5; let worker: ReturnType | null = null; + let destroyed = false; + const pendingRetryTimers = new Set>(); function getWorker() { + if (destroyed) return null; return worker; } @@ -98,7 +101,7 @@ export function createWorkerService({ worker.stderr?.on("data", (chunk) => logger.error(`[worker] ${String(chunk).trim()}`)); worker.on("exit", (code) => { - if (isQuitting()) return; + if (isQuitting() || destroyed) return; logger.warn(`[worker] Worker exited with code ${code}, recovering...`); @@ -320,7 +323,7 @@ export function createWorkerService({ publishDomainChanged(); } - if (event.status === "failed" || event.status === "timed_out") { + if ((event.status === "failed" || event.status === "timed_out") && !isQuitting() && !destroyed) { const retryCount = db.getTaskRetryCount(run.taskId); if (retryCount < MAX_RETRY_ATTEMPTS) { @@ -329,9 +332,12 @@ export function createWorkerService({ db.advanceTaskStatus(run.taskId, "todo", `Run ${event.status} (attempt ${retryCount + 1}/${MAX_RETRY_ATTEMPTS}). Auto-retrying after ${RETRY_COOLDOWN_MS / 1000}s cooldown.`); logger.info(`[escalation] Retrying task ${run.taskId} (attempt ${retryCount + 1}/${MAX_RETRY_ATTEMPTS}) for agent ${run.agentId}`); - setTimeout(() => { + const retryTimer = setTimeout(() => { + pendingRetryTimers.delete(retryTimer); + if (isQuitting() || destroyed) return; wakeAgentIfPossible(run.agentId, run.companyId, "retry_failed"); }, RETRY_COOLDOWN_MS); + pendingRetryTimers.add(retryTimer); } else { // Retry limit reached -- escalate up the chain db.resetTaskRetryCount(run.taskId); @@ -376,13 +382,15 @@ export function createWorkerService({ } const pendingWakes = db.consumePendingWakes(run.agentId); - if (pendingWakes.length > 0) { + if (pendingWakes.length > 0 && !isQuitting() && !destroyed) { for (const wake of pendingWakes) { wakeAgentIfPossible(run.agentId, wake.companyId, wake.trigger); } } - processRunCompletion(run, event as z.infer & { type: "run-finished" }); + if (!isQuitting() && !destroyed) { + processRunCompletion(run, event as z.infer & { type: "run-finished" }); + } // Reset retry count on successful completion if (event.status === "succeeded") { @@ -396,7 +404,7 @@ export function createWorkerService({ } // Self-wake with circuit breaker to prevent infinite loops - if (pendingWakes.length === 0) { + if (pendingWakes.length === 0 && !isQuitting() && !destroyed) { try { const actionableTasks = db.listAgentTasks(run.agentId, run.companyId, ["todo", "in_progress"]); if (actionableTasks.length > 0) { @@ -426,7 +434,26 @@ export function createWorkerService({ } function destroy() { + destroyed = true; + + // Clear all pending retry timers so they never fire after quit + for (const timer of pendingRetryTimers) { + clearTimeout(timer); + } + pendingRetryTimers.clear(); + if (worker) { + // Cancel all running/queued runs in the worker before killing it. + // This tells the worker to SIGTERM all child CLI processes (claude, codex, etc.) + const snapshot = db.listSnapshot(); + for (const run of snapshot.runs) { + if (run.status === "running" || run.status === "queued") { + try { + worker.postMessage({ type: "cancel-run", runId: run.id }); + } catch { /* worker may already be dead */ } + } + } + worker.kill(); // Force-kill after 2s if SIGTERM didn't work (child CLI processes may keep it alive) const ref = worker;