Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1de05a7f1 | ||
|
|
05c57be98f | ||
|
|
222c80e544 | ||
|
|
8dd6dd09d6 |
No files matched your search
@@ -60,3 +60,4 @@ CLAUDE.md
|
|||||||
build/release/AgentCompany-0.4.61-arm64.dmg
|
build/release/AgentCompany-0.4.61-arm64.dmg
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
build/release/AgentCompany-0.4.90-arm64.dmg
|
||||||
@@ -6,7 +6,9 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>Launch autonomous AI companies from your desktop.</strong><br/>
|
<strong>Launch autonomous AI companies from your desktop.</strong><br/>
|
||||||
Set a goal. Watch AI agents self-organize, delegate, and deliver.
|
Set a goal. Watch AI agents self-organize, delegate, and deliver.<br/>
|
||||||
|
<strong>PLEASE USE A CODING PLAN, IT COMSUMES A LOT OF TOKENS</strong><br/>
|
||||||
|
I will add support for Ollama and Github Copilot in the near future.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -28,7 +30,7 @@
|
|||||||
|
|
||||||
## What is AgentCompany?
|
## What is AgentCompany?
|
||||||
|
|
||||||
AgentCompany is a cross-platform desktop app that turns a single goal into a fully autonomous AI company. You provide a company name, description, and mission. A CEO agent self-organizes departments, hires specialists, delegates tasks, and delivers real results — code, content, research, business plans, and more — while you observe through a Slack-style command hub.
|
AgentCompany is a cross-platform desktop app that turns a single goal into a fully autonomous AI company. You provide a company name, description, and mission. A CEO agent self-organizes departments, hires specialists, delegates tasks, and delivers real results — code, content, research, business plans, and more — while you observe through a Slack-style command hub. Inspired by **[Paperclip](https://github.com/paperclipai/paperclip)**
|
||||||
|
|
||||||
### Supported AI Runtimes
|
### Supported AI Runtimes
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"output": "/Users/zacharyzhang/Documents/Github/AgentCompany/build/release"
|
"output": "/Users/zacharyzhang/Documents/Github/AgentCompany/build/release"
|
||||||
},
|
},
|
||||||
"extraMetadata": {
|
"extraMetadata": {
|
||||||
"version": "0.4.80"
|
"version": "0.4.95"
|
||||||
},
|
},
|
||||||
"mac": {
|
"mac": {
|
||||||
"hardenedRuntime": false,
|
"hardenedRuntime": false,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ arm64:
|
|||||||
- '!build/release{,/**/*}'
|
- '!build/release{,/**/*}'
|
||||||
- out/**
|
- out/**
|
||||||
- package.json
|
- package.json
|
||||||
|
- assets/icon.png
|
||||||
|
- assets/tray-icon*.png
|
||||||
- '!**/*.{iml,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,suo,xproj,cc,d.ts,mk,a,o,obj,forge-meta,pdb}'
|
- '!**/*.{iml,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,suo,xproj,cc,d.ts,mk,a,o,obj,forge-meta,pdb}'
|
||||||
- '!**/._*'
|
- '!**/._*'
|
||||||
- '!**/electron-builder.{yaml,yml,json,json5,toml,ts}'
|
- '!**/electron-builder.{yaml,yml,json,json5,toml,ts}'
|
||||||
@@ -16,3 +18,5 @@ arm64:
|
|||||||
- '**/*'
|
- '**/*'
|
||||||
- out/**
|
- out/**
|
||||||
- package.json
|
- package.json
|
||||||
|
- assets/icon.png
|
||||||
|
- assets/tray-icon*.png
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
version: 0.4.80
|
version: 0.4.95
|
||||||
files:
|
files:
|
||||||
- url: AgentCompany-0.4.80-arm64-mac.zip
|
- url: AgentCompany-0.4.95-arm64-mac.zip
|
||||||
sha512: 5gKuP0v6BeMrgiEre/WXXERVAyjh7imWgleoV18ZU5fly+/wq8witKgg8o+E5kid7VUSqTYlvePRtkT8NrYYhg==
|
sha512: /Ew0ls0nISOD/65MHxVbc23TRmgP/bbCWR89j/FhoAwKkE0Vmexjsp6IxY22X8tXnwuMgP+A/DJgqenNlKslzQ==
|
||||||
size: 113614651
|
size: 115753870
|
||||||
path: AgentCompany-0.4.80-arm64-mac.zip
|
path: AgentCompany-0.4.95-arm64-mac.zip
|
||||||
sha512: 5gKuP0v6BeMrgiEre/WXXERVAyjh7imWgleoV18ZU5fly+/wq8witKgg8o+E5kid7VUSqTYlvePRtkT8NrYYhg==
|
sha512: /Ew0ls0nISOD/65MHxVbc23TRmgP/bbCWR89j/FhoAwKkE0Vmexjsp6IxY22X8tXnwuMgP+A/DJgqenNlKslzQ==
|
||||||
releaseDate: '2026-03-23T20:47:41.499Z'
|
releaseDate: '2026-03-24T20:37:33.571Z'
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agent-company",
|
"name": "agent-company",
|
||||||
"version": "0.4.95",
|
"version": "0.4.97",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Cross-platform Electron desktop control plane for local-first AI agent companies.",
|
"description": "Cross-platform Electron desktop control plane for local-first AI agent companies.",
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ interface AutomationDependencies {
|
|||||||
notify: (options: NotificationOptions) => void;
|
notify: (options: NotificationOptions) => void;
|
||||||
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
|
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
|
||||||
dispatchBrowserAction: (actionId: string, companyId: string) => Promise<void>;
|
dispatchBrowserAction: (actionId: string, companyId: string) => Promise<void>;
|
||||||
|
isQuitting: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RunFinishedEvent {
|
interface RunFinishedEvent {
|
||||||
@@ -86,9 +87,11 @@ export function createAutomationService({
|
|||||||
notify,
|
notify,
|
||||||
wakeAgentIfPossible,
|
wakeAgentIfPossible,
|
||||||
dispatchBrowserAction,
|
dispatchBrowserAction,
|
||||||
|
isQuitting,
|
||||||
}: AutomationDependencies) {
|
}: AutomationDependencies) {
|
||||||
const activeAutomationExecutions = new Set<string>();
|
const activeAutomationExecutions = new Set<string>();
|
||||||
const activeWorkflowTriggerExecutions = new Set<string>();
|
const activeWorkflowTriggerExecutions = new Set<string>();
|
||||||
|
const pendingScheduledTimers = new Set<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
function normalizeWorkflowSteps(raw: unknown): WorkflowStepState[] {
|
function normalizeWorkflowSteps(raw: unknown): WorkflowStepState[] {
|
||||||
if (!Array.isArray(raw)) {
|
if (!Array.isArray(raw)) {
|
||||||
@@ -642,6 +645,7 @@ export function createAutomationService({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function dispatchAutomation(trigger: string, ctx: AutomationEventContext) {
|
function dispatchAutomation(trigger: string, ctx: AutomationEventContext) {
|
||||||
|
if (isQuitting()) return;
|
||||||
evaluateAutomationRules(trigger, ctx);
|
evaluateAutomationRules(trigger, ctx);
|
||||||
evaluateWorkflowTriggers(trigger, ctx);
|
evaluateWorkflowTriggers(trigger, ctx);
|
||||||
}
|
}
|
||||||
@@ -859,6 +863,7 @@ export function createAutomationService({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function processRunCompletion(run: RunRecord, event: RunFinishedEvent) {
|
function processRunCompletion(run: RunRecord, event: RunFinishedEvent) {
|
||||||
|
if (isQuitting()) return;
|
||||||
let task: ReturnType<typeof db.getTask> | null = null;
|
let task: ReturnType<typeof db.getTask> | null = null;
|
||||||
try {
|
try {
|
||||||
task = db.getTask(run.taskId);
|
task = db.getTask(run.taskId);
|
||||||
@@ -1032,6 +1037,7 @@ export function createAutomationService({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleApprovalResolved(approvalId: string, companyId: string, decision: string) {
|
function handleApprovalResolved(approvalId: string, companyId: string, decision: string) {
|
||||||
|
if (isQuitting()) return;
|
||||||
const snapshot = db.listSnapshot();
|
const snapshot = db.listSnapshot();
|
||||||
const approval = snapshot.approvals.find((entry) => entry.id === approvalId);
|
const approval = snapshot.approvals.find((entry) => entry.id === approvalId);
|
||||||
if (!approval) return;
|
if (!approval) return;
|
||||||
@@ -1168,7 +1174,12 @@ export function createAutomationService({
|
|||||||
if (draft.scheduledAt) {
|
if (draft.scheduledAt) {
|
||||||
const delay = new Date(draft.scheduledAt).getTime() - Date.now();
|
const delay = new Date(draft.scheduledAt).getTime() - Date.now();
|
||||||
if (delay > 0) {
|
if (delay > 0) {
|
||||||
setTimeout(dispatchSocialAction, delay);
|
const timer = setTimeout(() => {
|
||||||
|
pendingScheduledTimers.delete(timer);
|
||||||
|
if (isQuitting()) return;
|
||||||
|
dispatchSocialAction();
|
||||||
|
}, delay);
|
||||||
|
pendingScheduledTimers.add(timer);
|
||||||
} else {
|
} else {
|
||||||
dispatchSocialAction();
|
dispatchSocialAction();
|
||||||
}
|
}
|
||||||
@@ -1229,6 +1240,7 @@ export function createAutomationService({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleTaskStatusChange(taskId: string, companyId: string, previousStatus: string, newStatus: string) {
|
function handleTaskStatusChange(taskId: string, companyId: string, previousStatus: string, newStatus: string) {
|
||||||
|
if (isQuitting()) return;
|
||||||
if (previousStatus === newStatus) return;
|
if (previousStatus === newStatus) return;
|
||||||
|
|
||||||
let task: ReturnType<typeof db.getTask> | null = null;
|
let task: ReturnType<typeof db.getTask> | null = null;
|
||||||
@@ -1298,6 +1310,7 @@ export function createAutomationService({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleTaskCreated(taskId: string, companyId: string) {
|
function handleTaskCreated(taskId: string, companyId: string) {
|
||||||
|
if (isQuitting()) return;
|
||||||
let task: ReturnType<typeof db.getTask> | null = null;
|
let task: ReturnType<typeof db.getTask> | null = null;
|
||||||
try {
|
try {
|
||||||
task = db.getTask(taskId);
|
task = db.getTask(taskId);
|
||||||
@@ -1322,6 +1335,13 @@ export function createAutomationService({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
for (const timer of pendingScheduledTimers) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
pendingScheduledTimers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
runWorkflowPipeline,
|
runWorkflowPipeline,
|
||||||
dispatchAutomation,
|
dispatchAutomation,
|
||||||
@@ -1329,5 +1349,6 @@ export function createAutomationService({
|
|||||||
handleApprovalResolved,
|
handleApprovalResolved,
|
||||||
handleTaskStatusChange,
|
handleTaskStatusChange,
|
||||||
handleTaskCreated,
|
handleTaskCreated,
|
||||||
|
destroy,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -192,6 +192,7 @@ export async function bootstrapApplication({
|
|||||||
// Re-register timers for scheduled social posts (survives app restart).
|
// Re-register timers for scheduled social posts (survives app restart).
|
||||||
// Scan all companies for queued browser actions with a future scheduledAt time
|
// Scan all companies for queued browser actions with a future scheduledAt time
|
||||||
// and set up setTimeout to dispatch them at the right moment.
|
// and set up setTimeout to dispatch them at the right moment.
|
||||||
|
const scheduledPostTimers = new Set<ReturnType<typeof setTimeout>>();
|
||||||
try {
|
try {
|
||||||
const snapshot = db.listSnapshot();
|
const snapshot = db.listSnapshot();
|
||||||
for (const company of snapshot.companies) {
|
for (const company of snapshot.companies) {
|
||||||
@@ -203,12 +204,15 @@ export async function bootstrapApplication({
|
|||||||
if (payload.scheduledAt) {
|
if (payload.scheduledAt) {
|
||||||
const delay = new Date(payload.scheduledAt).getTime() - Date.now();
|
const delay = new Date(payload.scheduledAt).getTime() - Date.now();
|
||||||
if (delay > 0) {
|
if (delay > 0) {
|
||||||
setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
const browserActions = serviceGraph.getBrowserActionService();
|
scheduledPostTimers.delete(timer);
|
||||||
|
if (isQuittingRef.current) return;
|
||||||
|
const browserActions = serviceGraph?.getBrowserActionService();
|
||||||
if (browserActions) {
|
if (browserActions) {
|
||||||
void browserActions.dispatchBrowserAction(action.id, company.id);
|
void browserActions.dispatchBrowserAction(action.id, company.id);
|
||||||
}
|
}
|
||||||
}, delay);
|
}, delay);
|
||||||
|
scheduledPostTimers.add(timer);
|
||||||
log.info(`[bootstrap] Re-registered scheduled post timer: action ${action.id} in ${Math.round(delay / 1000)}s`);
|
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
|
// 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)}`);
|
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.
|
// Show the window immediately so the user sees the app launch without delay.
|
||||||
await serviceGraph.getDesktopShellService()?.ensureWindow();
|
await serviceGraph.getDesktopShellService()?.ensureWindow();
|
||||||
serviceGraph.getAppLifecycleService()?.register(hasSingleInstanceLock);
|
serviceGraph.getAppLifecycleService()?.register(hasSingleInstanceLock);
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ interface DesktopShellDependencies {
|
|||||||
onBackupProfile: () => Promise<void>;
|
onBackupProfile: () => Promise<void>;
|
||||||
onShortcut: (action: "toggle-command-palette" | "toggle-console" | "refresh") => void;
|
onShortcut: (action: "toggle-command-palette" | "toggle-console" | "refresh") => void;
|
||||||
notify: (options: NotificationOptions) => void;
|
notify: (options: NotificationOptions) => void;
|
||||||
|
getActiveRunCount: () => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSocialBrowserSession(contents: Electron.WebContents) {
|
function isSocialBrowserSession(contents: Electron.WebContents) {
|
||||||
@@ -55,11 +56,91 @@ export function createDesktopShellService({
|
|||||||
onBackupProfile,
|
onBackupProfile,
|
||||||
onShortcut,
|
onShortcut,
|
||||||
notify,
|
notify,
|
||||||
|
getActiveRunCount,
|
||||||
}: DesktopShellDependencies) {
|
}: DesktopShellDependencies) {
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
let appTray: Tray | null = null;
|
let appTray: Tray | null = null;
|
||||||
let backgroundCloseNoticeShown = false;
|
let backgroundCloseNoticeShown = false;
|
||||||
let windowCreationPromise: Promise<BrowserWindow | null> | null = null;
|
let windowCreationPromise: Promise<BrowserWindow | null> | 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() {
|
function getMainWindow() {
|
||||||
return mainWindow;
|
return mainWindow;
|
||||||
@@ -195,7 +276,11 @@ export function createDesktopShellService({
|
|||||||
{ role: "hideOthers" },
|
{ role: "hideOthers" },
|
||||||
{ role: "unhide" },
|
{ role: "unhide" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "quit" },
|
{
|
||||||
|
label: `Quit ${app.name}`,
|
||||||
|
accelerator: "CmdOrCtrl+Q",
|
||||||
|
click: () => void requestQuitWithConfirmation(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const template: Electron.MenuItemConstructorOptions[] = [
|
const template: Electron.MenuItemConstructorOptions[] = [
|
||||||
@@ -222,7 +307,14 @@ export function createDesktopShellService({
|
|||||||
await onBackupProfile();
|
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" },
|
{ type: "separator" },
|
||||||
{
|
{
|
||||||
label: "Quit",
|
label: "Quit",
|
||||||
click: () => {
|
click: () => void requestQuitWithConfirmation(),
|
||||||
onRequestQuit();
|
|
||||||
app.quit();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ interface OrchestrationDependencies {
|
|||||||
dispatchAutomation: (trigger: string, ctx: AutomationDispatch) => void;
|
dispatchAutomation: (trigger: string, ctx: AutomationDispatch) => void;
|
||||||
hasWorkerReady: () => boolean;
|
hasWorkerReady: () => boolean;
|
||||||
enqueueWorkerRun: (payload: EnqueueRunPayload) => void;
|
enqueueWorkerRun: (payload: EnqueueRunPayload) => void;
|
||||||
|
isQuitting: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createOrchestrationService({
|
export function createOrchestrationService({
|
||||||
@@ -72,6 +73,7 @@ export function createOrchestrationService({
|
|||||||
dispatchAutomation,
|
dispatchAutomation,
|
||||||
hasWorkerReady,
|
hasWorkerReady,
|
||||||
enqueueWorkerRun,
|
enqueueWorkerRun,
|
||||||
|
isQuitting,
|
||||||
}: OrchestrationDependencies) {
|
}: OrchestrationDependencies) {
|
||||||
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
const scheduleLastFiredMap = new Map<string, number>();
|
const scheduleLastFiredMap = new Map<string, number>();
|
||||||
@@ -182,6 +184,9 @@ export function createOrchestrationService({
|
|||||||
trigger: string;
|
trigger: string;
|
||||||
}) {
|
}) {
|
||||||
const { runId, companyId, agent, task, connector, workspace, workspaceMode, companyName, companyDescription, snapshot, trigger } = params;
|
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()) {
|
if (!hasWorkerReady()) {
|
||||||
throw new Error("Orchestration worker is not ready.");
|
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 {
|
function startAgentHeartbeatRun(agentId: string, companyId: string, trigger: string): string | null {
|
||||||
|
if (isQuitting()) return null;
|
||||||
if (!hasWorkerReady()) return null;
|
if (!hasWorkerReady()) return null;
|
||||||
const companyStatus = db.getCompanyStatus(companyId);
|
const companyStatus = db.getCompanyStatus(companyId);
|
||||||
if (companyStatus !== "active") {
|
if (companyStatus !== "active") {
|
||||||
@@ -448,7 +454,7 @@ export function createOrchestrationService({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function wakeAgentIfPossible(agentId: string | null | undefined, companyId: string, trigger: string) {
|
function wakeAgentIfPossible(agentId: string | null | undefined, companyId: string, trigger: string) {
|
||||||
if (!agentId) return null;
|
if (!agentId || isQuitting()) return null;
|
||||||
try {
|
try {
|
||||||
const agent = db.getAgent(agentId);
|
const agent = db.getAgent(agentId);
|
||||||
if (agent.companyId !== companyId) {
|
if (agent.companyId !== companyId) {
|
||||||
@@ -473,6 +479,9 @@ export function createOrchestrationService({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function queueTaskRun(taskId: string, trigger: string, actor: string) {
|
function queueTaskRun(taskId: string, trigger: string, actor: string) {
|
||||||
|
if (isQuitting()) {
|
||||||
|
return fail("APP_SHUTTING_DOWN", "Application is shutting down.");
|
||||||
|
}
|
||||||
if (!hasWorkerReady()) {
|
if (!hasWorkerReady()) {
|
||||||
logger.warn(`[run] Worker not ready — cannot queue run for task ${taskId} (trigger: ${trigger})`);
|
logger.warn(`[run] Worker not ready — cannot queue run for task ${taskId} (trigger: ${trigger})`);
|
||||||
return fail("WORKER_UNAVAILABLE", "Orchestration worker is not ready.");
|
return fail("WORKER_UNAVAILABLE", "Orchestration worker is not ready.");
|
||||||
@@ -556,7 +565,7 @@ export function createOrchestrationService({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resumeQueuedRuns() {
|
function resumeQueuedRuns() {
|
||||||
if (!hasWorkerReady()) {
|
if (isQuitting() || !hasWorkerReady()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const snapshot = db.listSnapshot();
|
const snapshot = db.listSnapshot();
|
||||||
@@ -610,7 +619,7 @@ export function createOrchestrationService({
|
|||||||
|
|
||||||
heartbeatTimer = setInterval(() => {
|
heartbeatTimer = setInterval(() => {
|
||||||
try {
|
try {
|
||||||
if (!hasWorkerReady()) return;
|
if (isQuitting() || !hasWorkerReady()) return;
|
||||||
const activeCompanies = db.listActiveCompanyIds();
|
const activeCompanies = db.listActiveCompanyIds();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,14 @@ export function createServiceGraph({
|
|||||||
},
|
},
|
||||||
onShortcut: (action) => emitEvent({ type: "shortcut", action }),
|
onShortcut: (action) => emitEvent({ type: "shortcut", action }),
|
||||||
notify,
|
notify,
|
||||||
|
getActiveRunCount: () => {
|
||||||
|
try {
|
||||||
|
const snapshot = db.listSnapshot();
|
||||||
|
return snapshot.runs.filter((r) => r.status === "running" || r.status === "queued").length;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
desktopShellService.initialize();
|
desktopShellService.initialize();
|
||||||
|
|
||||||
@@ -169,6 +177,7 @@ export function createServiceGraph({
|
|||||||
enqueueWorkerRun: (payload) => {
|
enqueueWorkerRun: (payload) => {
|
||||||
workerService?.getWorker()?.postMessage({ type: "enqueue-run", payload });
|
workerService?.getWorker()?.postMessage({ type: "enqueue-run", payload });
|
||||||
},
|
},
|
||||||
|
isQuitting: () => isQuittingRef.current,
|
||||||
});
|
});
|
||||||
workerService.createWorker();
|
workerService.createWorker();
|
||||||
|
|
||||||
@@ -180,6 +189,7 @@ export function createServiceGraph({
|
|||||||
notify,
|
notify,
|
||||||
wakeAgentIfPossible,
|
wakeAgentIfPossible,
|
||||||
dispatchBrowserAction,
|
dispatchBrowserAction,
|
||||||
|
isQuitting: () => isQuittingRef.current,
|
||||||
});
|
});
|
||||||
|
|
||||||
onboardingService = createOnboardingService({
|
onboardingService = createOnboardingService({
|
||||||
@@ -210,6 +220,7 @@ export function createServiceGraph({
|
|||||||
onBeforeQuit: () => {
|
onBeforeQuit: () => {
|
||||||
isQuittingRef.current = true;
|
isQuittingRef.current = true;
|
||||||
orchestrationService?.stopHeartbeatScheduler();
|
orchestrationService?.stopHeartbeatScheduler();
|
||||||
|
automationService?.destroy();
|
||||||
apiServerRef.current?.stop();
|
apiServerRef.current?.stop();
|
||||||
apiServerRef.current = null;
|
apiServerRef.current = null;
|
||||||
browserManagerRef.current?.shutdown();
|
browserManagerRef.current?.shutdown();
|
||||||
|
|||||||
@@ -83,8 +83,11 @@ export function createWorkerService({
|
|||||||
const MAX_CONSECUTIVE_WAKES = 5;
|
const MAX_CONSECUTIVE_WAKES = 5;
|
||||||
|
|
||||||
let worker: ReturnType<typeof utilityProcess.fork> | null = null;
|
let worker: ReturnType<typeof utilityProcess.fork> | null = null;
|
||||||
|
let destroyed = false;
|
||||||
|
const pendingRetryTimers = new Set<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
function getWorker() {
|
function getWorker() {
|
||||||
|
if (destroyed) return null;
|
||||||
return worker;
|
return worker;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +101,7 @@ export function createWorkerService({
|
|||||||
worker.stderr?.on("data", (chunk) => logger.error(`[worker] ${String(chunk).trim()}`));
|
worker.stderr?.on("data", (chunk) => logger.error(`[worker] ${String(chunk).trim()}`));
|
||||||
|
|
||||||
worker.on("exit", (code) => {
|
worker.on("exit", (code) => {
|
||||||
if (isQuitting()) return;
|
if (isQuitting() || destroyed) return;
|
||||||
|
|
||||||
logger.warn(`[worker] Worker exited with code ${code}, recovering...`);
|
logger.warn(`[worker] Worker exited with code ${code}, recovering...`);
|
||||||
|
|
||||||
@@ -320,7 +323,7 @@ export function createWorkerService({
|
|||||||
publishDomainChanged();
|
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);
|
const retryCount = db.getTaskRetryCount(run.taskId);
|
||||||
|
|
||||||
if (retryCount < MAX_RETRY_ATTEMPTS) {
|
if (retryCount < MAX_RETRY_ATTEMPTS) {
|
||||||
@@ -329,9 +332,12 @@ export function createWorkerService({
|
|||||||
db.advanceTaskStatus(run.taskId, "todo",
|
db.advanceTaskStatus(run.taskId, "todo",
|
||||||
`Run ${event.status} (attempt ${retryCount + 1}/${MAX_RETRY_ATTEMPTS}). Auto-retrying after ${RETRY_COOLDOWN_MS / 1000}s cooldown.`);
|
`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}`);
|
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");
|
wakeAgentIfPossible(run.agentId, run.companyId, "retry_failed");
|
||||||
}, RETRY_COOLDOWN_MS);
|
}, RETRY_COOLDOWN_MS);
|
||||||
|
pendingRetryTimers.add(retryTimer);
|
||||||
} else {
|
} else {
|
||||||
// Retry limit reached -- escalate up the chain
|
// Retry limit reached -- escalate up the chain
|
||||||
db.resetTaskRetryCount(run.taskId);
|
db.resetTaskRetryCount(run.taskId);
|
||||||
@@ -376,13 +382,15 @@ export function createWorkerService({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pendingWakes = db.consumePendingWakes(run.agentId);
|
const pendingWakes = db.consumePendingWakes(run.agentId);
|
||||||
if (pendingWakes.length > 0) {
|
if (pendingWakes.length > 0 && !isQuitting() && !destroyed) {
|
||||||
for (const wake of pendingWakes) {
|
for (const wake of pendingWakes) {
|
||||||
wakeAgentIfPossible(run.agentId, wake.companyId, wake.trigger);
|
wakeAgentIfPossible(run.agentId, wake.companyId, wake.trigger);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
processRunCompletion(run, event as z.infer<typeof workerMessageSchema> & { type: "run-finished" });
|
if (!isQuitting() && !destroyed) {
|
||||||
|
processRunCompletion(run, event as z.infer<typeof workerMessageSchema> & { type: "run-finished" });
|
||||||
|
}
|
||||||
|
|
||||||
// Reset retry count on successful completion
|
// Reset retry count on successful completion
|
||||||
if (event.status === "succeeded") {
|
if (event.status === "succeeded") {
|
||||||
@@ -396,7 +404,7 @@ export function createWorkerService({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Self-wake with circuit breaker to prevent infinite loops
|
// Self-wake with circuit breaker to prevent infinite loops
|
||||||
if (pendingWakes.length === 0) {
|
if (pendingWakes.length === 0 && !isQuitting() && !destroyed) {
|
||||||
try {
|
try {
|
||||||
const actionableTasks = db.listAgentTasks(run.agentId, run.companyId, ["todo", "in_progress"]);
|
const actionableTasks = db.listAgentTasks(run.agentId, run.companyId, ["todo", "in_progress"]);
|
||||||
if (actionableTasks.length > 0) {
|
if (actionableTasks.length > 0) {
|
||||||
@@ -426,7 +434,26 @@ export function createWorkerService({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function destroy() {
|
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) {
|
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();
|
worker.kill();
|
||||||
// Force-kill after 2s if SIGTERM didn't work (child CLI processes may keep it alive)
|
// Force-kill after 2s if SIGTERM didn't work (child CLI processes may keep it alive)
|
||||||
const ref = worker;
|
const ref = worker;
|
||||||
|
|||||||
Reference in new issue
Block a user