feat: add background agents with scheduling support

- Add background task scheduling system with cron-based triggers
- Add background-task-detail component for viewing agent status
- Add agent schedule repo and state management
- Update sidebar to show background agents section
- Remove old workflow-authoring and workflow-run-ops skills
- Add IPC handlers for agent schedule operations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Arjun 2026-02-04 23:21:13 +05:30
parent 82db06d724
commit c447a42d07
20 changed files with 1544 additions and 500 deletions

View file

@ -24,6 +24,9 @@ import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
import * as composioHandler from './composio-handler.js';
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js';
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
type InvokeChannels = ipc.InvokeChannels;
type IPCChannels = ipc.IPCChannels;
@ -384,5 +387,38 @@ export function setupIpcHandlers() {
'composio:execute-action': async (_event, args) => {
return composioHandler.executeAction(args.actionSlug, args.toolkitSlug, args.input);
},
// Agent schedule handlers
'agent-schedule:getConfig': async () => {
const repo = container.resolve<IAgentScheduleRepo>('agentScheduleRepo');
try {
return await repo.getConfig();
} catch {
// Return empty config if file doesn't exist
return { agents: {} };
}
},
'agent-schedule:getState': async () => {
const repo = container.resolve<IAgentScheduleStateRepo>('agentScheduleStateRepo');
try {
return await repo.getState();
} catch {
// Return empty state if file doesn't exist
return { agents: {} };
}
},
'agent-schedule:updateAgent': async (_event, args) => {
const repo = container.resolve<IAgentScheduleRepo>('agentScheduleRepo');
await repo.upsert(args.agentName, args.entry);
// Trigger the runner to pick up the change immediately
triggerAgentScheduleRun();
return { success: true };
},
'agent-schedule:deleteAgent': async (_event, args) => {
const repo = container.resolve<IAgentScheduleRepo>('agentScheduleRepo');
const stateRepo = container.resolve<IAgentScheduleStateRepo>('agentScheduleStateRepo');
await repo.delete(args.agentName);
await stateRepo.deleteAgentState(args.agentName);
return { success: true };
},
});
}

View file

@ -10,6 +10,7 @@ import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies
import { init as initGranolaSync } from "@x/core/dist/knowledge/granola/sync.js";
import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js";
import { init as initPreBuiltRunner } from "@x/core/dist/pre_built/runner.js";
import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
const __filename = fileURLToPath(import.meta.url);
@ -156,6 +157,9 @@ app.whenReady().then(async () => {
// start pre-built agent runner
initPreBuiltRunner();
// start background agent runner (scheduled agents)
initAgentRunner();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();

View file

@ -51,6 +51,9 @@ import { Separator } from "@/components/ui/separator"
import { Toaster } from "@/components/ui/sonner"
import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
import { OnboardingModal } from '@/components/onboarding-modal'
import { BackgroundTaskDetail } from '@/components/background-task-detail'
import { AgentScheduleConfig } from '@x/shared/dist/agent-schedule.js'
import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js'
type DirEntry = z.infer<typeof workspace.DirEntry>
type RunEventType = z.infer<typeof RunEvent>
@ -499,6 +502,22 @@ function App() {
// Onboarding state
const [showOnboarding, setShowOnboarding] = useState(false)
// Background tasks state
type BackgroundTaskItem = {
name: string
description?: string
schedule: z.infer<typeof AgentScheduleConfig>["agents"][string]["schedule"]
enabled: boolean
startingMessage?: string
status?: z.infer<typeof AgentScheduleState>["agents"][string]["status"]
nextRunAt?: string | null
lastRunAt?: string | null
lastError?: string | null
runCount?: number
}
const [backgroundTasks, setBackgroundTasks] = useState<BackgroundTaskItem[]>([])
const [selectedBackgroundTask, setSelectedBackgroundTask] = useState<string | null>(null)
// Keep runIdRef in sync with runId state (for use in event handlers to avoid stale closures)
useEffect(() => {
runIdRef.current = runId
@ -528,12 +547,17 @@ function App() {
const cleanup = window.ipc.on('workspace:didChange', async (event) => {
loadDirectory().then(setTree)
// Reload current file if it was changed externally
if (!selectedPath) return
const changedPath = event.type === 'changed' ? event.path : null
const changedPaths = (event.type === 'bulkChanged' ? event.paths : []) ?? []
// Reload background tasks if agent-schedule.json changed
if (changedPath === 'config/agent-schedule.json' || changedPaths.includes('config/agent-schedule.json')) {
loadBackgroundTasks()
}
// Reload current file if it was changed externally
if (!selectedPath) return
const isCurrentFileChanged =
changedPath === selectedPath || changedPaths.includes(selectedPath)
@ -548,6 +572,7 @@ function App() {
}
})
return cleanup
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loadDirectory, selectedPath, editorContent])
// Load file content when selected
@ -663,6 +688,63 @@ function App() {
loadRuns()
}, [loadRuns])
// Load background tasks
const loadBackgroundTasks = useCallback(async () => {
try {
const [configResult, stateResult] = await Promise.all([
window.ipc.invoke('agent-schedule:getConfig', null),
window.ipc.invoke('agent-schedule:getState', null),
])
const tasks: BackgroundTaskItem[] = Object.entries(configResult.agents).map(([name, entry]) => {
const state = stateResult.agents[name]
return {
name,
description: entry.description,
schedule: entry.schedule,
enabled: entry.enabled ?? true,
startingMessage: entry.startingMessage,
status: state?.status,
nextRunAt: state?.nextRunAt,
lastRunAt: state?.lastRunAt,
lastError: state?.lastError,
runCount: state?.runCount ?? 0,
}
})
setBackgroundTasks(tasks)
} catch (err) {
console.error('Failed to load background tasks:', err)
}
}, [])
// Load background tasks on mount
useEffect(() => {
loadBackgroundTasks()
}, [loadBackgroundTasks])
// Handle toggling background task enabled state
const handleToggleBackgroundTask = useCallback(async (taskName: string, enabled: boolean) => {
const task = backgroundTasks.find(t => t.name === taskName)
if (!task) return
try {
await window.ipc.invoke('agent-schedule:updateAgent', {
agentName: taskName,
entry: {
schedule: task.schedule,
enabled,
startingMessage: task.startingMessage,
description: task.description,
},
})
// Reload to get updated state
await loadBackgroundTasks()
} catch (err) {
console.error('Failed to update background task:', err)
}
}, [backgroundTasks, loadBackgroundTasks])
// Load a specific run and populate conversation
const loadRun = useCallback(async (id: string) => {
try {
@ -1169,6 +1251,7 @@ function App() {
setPendingAskHumanRequests(new Map())
setAllPermissionRequests(new Map())
setPermissionResponses(new Map())
setSelectedBackgroundTask(null)
}, [])
const handleChatInputSubmit = (text: string) => {
@ -1193,6 +1276,8 @@ function App() {
// Clear forward history when navigating to a new file
setFileHistoryForward([])
setSelectedPath(path)
// Clear background task selection when navigating to a file
setSelectedBackgroundTask(null)
}, [selectedPath])
const navigateBack = useCallback(() => {
@ -1686,7 +1771,16 @@ function App() {
const conversationContentClassName = hasConversation
? "mx-auto w-full max-w-4xl pb-28"
: "mx-auto w-full max-w-4xl min-h-full items-center justify-center pb-0"
const headerTitle = selectedPath ? selectedPath : (isGraphOpen ? 'Graph View' : 'Chat')
const headerTitle = selectedPath
? selectedPath
: isGraphOpen
? 'Graph View'
: selectedBackgroundTask
? `Background Task: ${selectedBackgroundTask}`
: 'Chat'
const selectedTask = selectedBackgroundTask
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
: null
return (
<TooltipProvider delayDuration={0}>
@ -1716,8 +1810,18 @@ function App() {
currentRunId={runId}
tasksActions={{
onNewChat: handleNewChat,
onSelectRun: loadRun,
onSelectRun: (runIdToLoad) => {
setSelectedBackgroundTask(null)
loadRun(runIdToLoad)
},
onSelectBackgroundTask: (taskName) => {
setSelectedBackgroundTask(taskName)
setSelectedPath(null)
setIsGraphOpen(false)
},
}}
backgroundTasks={backgroundTasks}
selectedBackgroundTask={selectedBackgroundTask}
/>
<SidebarInset className="overflow-hidden! min-h-0">
{/* Header with sidebar triggers */}
@ -1819,6 +1923,21 @@ function App() {
</pre>
</div>
)
) : selectedTask ? (
<div className="flex-1 min-h-0 overflow-hidden">
<BackgroundTaskDetail
name={selectedTask.name}
description={selectedTask.description}
schedule={selectedTask.schedule}
enabled={selectedTask.enabled}
status={selectedTask.status}
nextRunAt={selectedTask.nextRunAt}
lastRunAt={selectedTask.lastRunAt}
lastError={selectedTask.lastError}
runCount={selectedTask.runCount}
onToggleEnabled={(enabled) => handleToggleBackgroundTask(selectedTask.name, enabled)}
/>
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col">
<Conversation className="relative flex-1 overflow-y-auto [scrollbar-gutter:stable]">

View file

@ -0,0 +1,175 @@
import { Bot, Calendar, Clock, AlertCircle, CheckCircle } from "lucide-react"
import { Switch } from "@/components/ui/switch"
interface BackgroundTaskSchedule {
type: "cron" | "window" | "once"
expression?: string
cron?: string
startTime?: string
endTime?: string
runAt?: string
}
interface BackgroundTaskDetailProps {
name: string
description?: string
schedule: BackgroundTaskSchedule
enabled: boolean
status?: "scheduled" | "running" | "finished" | "failed" | "triggered"
nextRunAt?: string | null
lastRunAt?: string | null
lastError?: string | null
runCount?: number
onToggleEnabled: (enabled: boolean) => void
}
function formatScheduleDescription(schedule: BackgroundTaskSchedule): string {
switch (schedule.type) {
case "cron":
return `Runs on cron schedule: ${schedule.expression}`
case "window":
return `Runs once between ${schedule.startTime} and ${schedule.endTime} based on: ${schedule.cron}`
case "once":
return `Runs once at ${schedule.runAt}`
default:
return "Unknown schedule type"
}
}
function formatDateTime(isoString: string | null | undefined): string {
if (!isoString) return "Never"
try {
const date = new Date(isoString)
return date.toLocaleString()
} catch {
return isoString
}
}
export function BackgroundTaskDetail({
name,
description,
schedule,
enabled,
status,
nextRunAt,
lastRunAt,
lastError,
runCount = 0,
onToggleEnabled,
}: BackgroundTaskDetailProps) {
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b border-border px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10">
<Bot className="size-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<h1 className="text-xl font-semibold truncate">{name}</h1>
<p className="text-sm text-muted-foreground">Background Agent</p>
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Description */}
{description && (
<section>
<h2 className="text-sm font-medium text-muted-foreground mb-2">Description</h2>
<p className="text-sm">{description}</p>
</section>
)}
{/* Schedule */}
<section>
<h2 className="text-sm font-medium text-muted-foreground mb-2">Schedule</h2>
<div className="bg-muted/50 rounded-lg p-4 space-y-2">
<div className="flex items-center gap-2">
<Calendar className="size-4 text-muted-foreground" />
<span className="text-sm font-medium capitalize">{schedule.type} Schedule</span>
</div>
<p className="text-sm text-muted-foreground">
{formatScheduleDescription(schedule)}
</p>
</div>
</section>
{/* Enabled Toggle - hide for completed one-time schedules */}
{status === "triggered" ? (
<section>
<h2 className="text-sm font-medium text-muted-foreground mb-2">Status</h2>
<div className="bg-muted/50 rounded-lg p-4">
<div className="flex items-center gap-2">
<CheckCircle className="size-4 text-green-500" />
<p className="text-sm font-medium">Completed</p>
</div>
<p className="text-xs text-muted-foreground mt-1">
This one-time agent has finished running and will not run again.
</p>
</div>
</section>
) : (
<section>
<h2 className="text-sm font-medium text-muted-foreground mb-2">Status</h2>
<div className="flex items-center justify-between bg-muted/50 rounded-lg p-4">
<div>
<p className="text-sm font-medium">{enabled ? "Enabled" : "Disabled"}</p>
<p className="text-xs text-muted-foreground">
{enabled ? "This agent will run according to its schedule" : "This agent is paused and will not run"}
</p>
</div>
<Switch
checked={enabled}
onCheckedChange={onToggleEnabled}
/>
</div>
</section>
)}
{/* Run Statistics */}
<section>
<h2 className="text-sm font-medium text-muted-foreground mb-2">Run History</h2>
<div className="grid grid-cols-2 gap-4">
<div className="bg-muted/50 rounded-lg p-4">
<p className="text-2xl font-semibold">{runCount}</p>
<p className="text-xs text-muted-foreground">Total Runs</p>
</div>
<div className="bg-muted/50 rounded-lg p-4">
<p className="text-sm font-medium">{formatDateTime(lastRunAt)}</p>
<p className="text-xs text-muted-foreground">Last Run</p>
</div>
</div>
</section>
{/* Next Run */}
{nextRunAt && schedule.type !== "once" && (
<section>
<h2 className="text-sm font-medium text-muted-foreground mb-2">Next Scheduled Run</h2>
<div className="bg-muted/50 rounded-lg p-4">
<div className="flex items-center gap-2">
<Clock className="size-4 text-muted-foreground" />
<span className="text-sm">{formatDateTime(nextRunAt)}</span>
</div>
</div>
</section>
)}
{/* Last Error */}
{lastError && (
<section>
<h2 className="text-sm font-medium text-red-500 mb-2">Last Error</h2>
<div className="bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex items-start gap-2">
<AlertCircle className="size-4 text-red-500 mt-0.5 shrink-0" />
<p className="text-sm text-red-700 dark:text-red-400">{lastError}</p>
</div>
</div>
</section>
)}
</div>
</div>
)
}

View file

@ -3,6 +3,7 @@
import * as React from "react"
import { useState } from "react"
import {
Bot,
ChevronRight,
ChevronsDownUp,
ChevronsUpDown,
@ -78,9 +79,27 @@ type RunListItem = {
agentId: string
}
type BackgroundTaskItem = {
name: string
description?: string
schedule: {
type: "cron" | "window" | "once"
expression?: string
cron?: string
startTime?: string
endTime?: string
runAt?: string
}
enabled: boolean
status?: "scheduled" | "running" | "finished" | "failed" | "triggered"
nextRunAt?: string | null
lastRunAt?: string | null
}
type TasksActions = {
onNewChat: () => void
onSelectRun: (runId: string) => void
onSelectBackgroundTask?: (taskName: string) => void
}
type SidebarContentPanelProps = {
@ -93,6 +112,8 @@ type SidebarContentPanelProps = {
runs?: RunListItem[]
currentRunId?: string | null
tasksActions?: TasksActions
backgroundTasks?: BackgroundTaskItem[]
selectedBackgroundTask?: string | null
} & React.ComponentProps<typeof Sidebar>
const sectionTitles = {
@ -110,6 +131,8 @@ export function SidebarContentPanel({
runs = [],
currentRunId,
tasksActions,
backgroundTasks = [],
selectedBackgroundTask,
...props
}: SidebarContentPanelProps) {
const { activeSection } = useSidebarSection()
@ -137,6 +160,8 @@ export function SidebarContentPanel({
runs={runs}
currentRunId={currentRunId}
actions={tasksActions}
backgroundTasks={backgroundTasks}
selectedBackgroundTask={selectedBackgroundTask}
/>
)}
</SidebarContent>
@ -653,15 +678,40 @@ function Tree({
)
}
// Get status indicator color
function getStatusColor(status?: string, enabled?: boolean): string {
// Disabled agents always show gray
if (enabled === false) {
return "bg-gray-400"
}
switch (status) {
case "running":
return "bg-blue-500"
case "finished":
return "bg-green-500"
case "failed":
return "bg-red-500"
case "triggered":
return "bg-gray-400"
case "scheduled":
default:
return "bg-yellow-500"
}
}
// Tasks Section
function TasksSection({
runs,
currentRunId,
actions,
backgroundTasks = [],
selectedBackgroundTask,
}: {
runs: RunListItem[]
currentRunId?: string | null
actions?: TasksActions
backgroundTasks?: BackgroundTaskItem[]
selectedBackgroundTask?: string | null
}) {
return (
<SidebarGroup className="flex-1 flex flex-col overflow-hidden">
@ -677,9 +727,38 @@ function TasksSection({
</SidebarMenu>
</div>
<SidebarGroupContent className="flex-1 overflow-y-auto">
{runs.length > 0 && (
{/* Background Tasks Section */}
{backgroundTasks.length > 0 && (
<>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
Background Tasks
</div>
<SidebarMenu>
{backgroundTasks.map((task) => (
<SidebarMenuItem key={task.name}>
<SidebarMenuButton
isActive={selectedBackgroundTask === task.name}
onClick={() => actions?.onSelectBackgroundTask?.(task.name)}
className="gap-2"
>
<div className="relative">
<Bot className="size-4 shrink-0" />
<span
className={`absolute -bottom-0.5 -right-0.5 size-2 rounded-full ${getStatusColor(task.status, task.enabled)} ${task.status === "running" && task.enabled ? "animate-pulse" : ""}`}
/>
</div>
<span className={`truncate text-sm ${!task.enabled ? "text-muted-foreground" : ""}`}>
{task.name}
</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</>
)}
{runs.length > 0 && (
<>
<div className="px-3 py-1.5 mt-4 text-xs font-medium text-muted-foreground">
Chat history
</div>
<SidebarMenu>