Skip to content

Commit b86bc8a

Browse files
committed
Add scheduled tasks - the deferred "automation" item, scoped honestly
Run a saved prompt against a chosen model on a repeating interval while the app is open (Settings -> Automation). Each task gets a dedicated chat session it appends results to, so you get a running log instead of a new sidebar entry every run. Deliberately scoped down from a full "scheduled agent": this only runs while Modelforge is open (setInterval timers in the main process, no OS-level task registration - closing the app stops it), and it's plain chat completions, not Agent mode - tool-calling would need porting the renderer's tool loop into the main process, a materially bigger change than the rest of this feature. Implementation: - scheduled-tasks-store.ts: CRUD persistence, same pattern as the other *-store.ts modules. - scheduler.ts: owns the interval timers, reschedules on any create/update/delete, and runs a task by calling back into main.ts's existing provider-dispatch logic (extracted into a shared dispatchChat/completePrompt pair also used by chat:send, rather than duplicating the ollama/llamacpp/openai/anthropic branching a second time). - Settings -> Automation tab: create/enable/disable/run-now/delete, reusing the same model picker as chat defaults. Found and fixed a real race while verifying in a live browser session: creating a task optimistically appended it to local state, but the initial mount fetch (a separate window.api.scheduledTasks.list() call) could resolve *after* that append and already include the same task, producing a visible duplicate. Confirmed via React fiber inspection that the state array itself held two entries, not just a rendering artifact, then fixed by re-fetching the list after create instead of appending optimistically, and confirmed the fix holds even with an artificially delayed mount fetch designed to force the race.
1 parent d9cb6e3 commit b86bc8a

10 files changed

Lines changed: 606 additions & 16 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ Beyond chat, Modelforge includes an **agentic mode** — the model can read/writ
5454

5555
**Agent mode** — see the [dedicated section](#agent-mode) below.
5656

57+
**Automation**
58+
- **Scheduled tasks** (Settings → Automation) — run a saved prompt against a chosen model on a repeating interval while the app is open, with results appended to a dedicated chat for that task so you get a running log rather than a new chat every time. This only runs while Modelforge is open (there's no OS-level background service), and doesn't use Agent mode tools — it's plain scheduled chat completions, not a scheduled agent run.
59+
5760
**Models & hardware**
5861
- Model recommendations based on your actual hardware — RAM and VRAM are detected and summed **across all GPUs**, not just the first one, so multi-GPU machines get accurate suggestions.
5962
- **GPU offload control** — set how many model layers Ollama offloads to GPU (`num_gpu`) per chat, per project, or as a global default; leave it blank to let Ollama decide automatically.

‎app/src/main.ts‎

Lines changed: 95 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@ import * as figma from "./figma";
1818
import * as ocr from "./ocr";
1919
import * as huggingface from "./huggingface";
2020
import * as llamacpp from "./llamacpp-manager";
21+
import * as scheduledTasksStore from "./scheduled-tasks-store";
22+
import * as scheduler from "./scheduler";
2123
import type { McpServerConfig } from "./mcp-client";
2224
import type { AttachedFile } from "./file-reader";
2325
import * as openaiProvider from "./providers/openai";
2426
import * as anthropicProvider from "./providers/anthropic";
2527
import { setupMenu } from "./menu";
2628
import { setupAutoUpdater, checkForUpdatesManually } from "./updater";
27-
import type { ChatMessage, ChatChunk, ChatOptions, ProviderId } from "./providers/types";
29+
import type { ChatMessage, ChatChunk, ChatOptions, ProviderId, ToolDefinition } from "./providers/types";
2830

2931
const PROVIDER_SECRET_KEYS: Record<Exclude<ProviderId, "ollama" | "llamacpp">, string> = {
3032
openai: "openai_api_key",
@@ -158,6 +160,43 @@ function createWindow(): void {
158160
}
159161
}
160162

163+
// Shared by chat:send (renderer-driven, streams tokens back over IPC) and
164+
// the scheduled-task runner (background, wants the full text once done) —
165+
// same provider dispatch and error handling either way.
166+
async function dispatchChat(
167+
provider: ProviderId,
168+
model: string,
169+
messages: ChatMessage[],
170+
options: ChatOptions | undefined,
171+
onToken: (chunk: ChatChunk) => void,
172+
signal?: AbortSignal,
173+
tools?: ToolDefinition[]
174+
): Promise<void> {
175+
if (provider === "ollama") {
176+
await ollama.chat(model, messages, options, onToken, signal, tools);
177+
} else if (provider === "llamacpp") {
178+
const modelPath = path.join(getLlamaCppModelsDir(), model);
179+
await llamacpp.chat(modelPath, messages, options, onToken, signal, tools);
180+
} else {
181+
const secretKey = PROVIDER_SECRET_KEYS[provider];
182+
const apiKey = secretsStore.getSecret(secretKey);
183+
if (!apiKey) throw new Error(`No API key set for ${provider}. Add one in Settings.`);
184+
const providerFn = provider === "openai" ? openaiProvider.chat : anthropicProvider.chat;
185+
await providerFn(apiKey, model, messages, options, onToken, signal, tools);
186+
}
187+
}
188+
189+
// Runs a single-turn prompt to completion and returns the full text —
190+
// what the scheduled-task runner needs, as opposed to chat:send's
191+
// token-by-token streaming back to the renderer.
192+
async function completePrompt(provider: ProviderId, model: string, prompt: string): Promise<string> {
193+
let text = "";
194+
await dispatchChat(provider, model, [{ role: "user", content: prompt }], undefined, (chunk) => {
195+
text += chunk.message?.content ?? "";
196+
});
197+
return text;
198+
}
199+
161200
function registerIpcHandlers(): void {
162201
ipcMain.handle("ollama:status", () => ollama.isRunning());
163202
ipcMain.handle("ollama:start", () => ollama.start());
@@ -253,20 +292,7 @@ function registerIpcHandlers(): void {
253292
activeChatRequests.set(requestId, controller);
254293
const tools = agentMode ? [...agentTools.AGENT_TOOLS, ...mcpClient.getConnectedTools()] : undefined;
255294
try {
256-
if (provider === "ollama") {
257-
await ollama.chat(model, messages, options, onToken, controller.signal, tools);
258-
} else if (provider === "llamacpp") {
259-
const modelPath = path.join(getLlamaCppModelsDir(), model);
260-
await llamacpp.chat(modelPath, messages, options, onToken, controller.signal, tools);
261-
} else {
262-
const secretKey = PROVIDER_SECRET_KEYS[provider];
263-
const apiKey = secretsStore.getSecret(secretKey);
264-
if (!apiKey) {
265-
throw new Error(`No API key set for ${provider}. Add one in Settings.`);
266-
}
267-
const providerFn = provider === "openai" ? openaiProvider.chat : anthropicProvider.chat;
268-
await providerFn(apiKey, model, messages, options, onToken, controller.signal, tools);
269-
}
295+
await dispatchChat(provider, model, messages, options, onToken, controller.signal, tools);
270296
return { done: true };
271297
} catch (err) {
272298
const error = err as Error;
@@ -315,6 +341,59 @@ function registerIpcHandlers(): void {
315341
);
316342
ipcMain.handle("sessions:clearAll", () => sessionsStore.clearAll());
317343

344+
ipcMain.handle("scheduledTasks:list", () => scheduledTasksStore.listTasks());
345+
346+
ipcMain.handle(
347+
"scheduledTasks:create",
348+
(
349+
_event: IpcMainInvokeEvent,
350+
{
351+
name,
352+
prompt,
353+
model,
354+
intervalMinutes,
355+
}: { name: string; prompt: string; model: string; intervalMinutes: number }
356+
) => {
357+
requireString(name, "task name");
358+
requireString(prompt, "task prompt");
359+
requireString(model, "task model");
360+
// Each task gets a dedicated chat session it appends results to —
361+
// created here so the task always has somewhere to write to.
362+
const session = sessionsStore.createSession(model);
363+
sessionsStore.updateSession(session.id, { title: name });
364+
const task = scheduledTasksStore.createTask({
365+
name,
366+
prompt,
367+
model,
368+
targetSessionId: session.id,
369+
intervalMinutes: Math.max(1, intervalMinutes || 60),
370+
});
371+
scheduler.rescheduleAll();
372+
return task;
373+
}
374+
);
375+
376+
ipcMain.handle(
377+
"scheduledTasks:update",
378+
(_event: IpcMainInvokeEvent, { id, partial }: { id: string; partial: Record<string, unknown> }) => {
379+
requireString(id, "task id");
380+
const updated = scheduledTasksStore.updateTask(id, partial);
381+
scheduler.rescheduleAll();
382+
return updated;
383+
}
384+
);
385+
386+
ipcMain.handle("scheduledTasks:delete", (_event: IpcMainInvokeEvent, id: string) => {
387+
requireString(id, "task id");
388+
scheduledTasksStore.deleteTask(id);
389+
scheduler.rescheduleAll();
390+
});
391+
392+
ipcMain.handle("scheduledTasks:runNow", (_event: IpcMainInvokeEvent, id: string) => {
393+
requireString(id, "task id");
394+
return scheduler.runTask(id);
395+
});
396+
318397
ipcMain.handle("projects:list", () => projectsStore.listProjects());
319398
ipcMain.handle("projects:create", (_event: IpcMainInvokeEvent, name: string) =>
320399
projectsStore.createProject(requireString(name, "project name"))
@@ -580,6 +659,7 @@ app.whenReady().then(async () => {
580659
llamacpp.setGpuBackend(settingsStore.getSettings().llamaCppGpuBackend ?? "auto");
581660
setupAutoUpdater(() => mainWindow);
582661
void connectEnabledMcpServers();
662+
scheduler.init((provider, model, prompt) => completePrompt(provider as ProviderId, model, prompt));
583663

584664
app.on("activate", () => {
585665
if (BrowserWindow.getAllWindows().length === 0) createWindow();

‎app/src/preload.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { McpServerConfig, McpServerStatus } from "./mcp-client";
66
import type { RollbackResult, ProjectScripts } from "./agent-tools";
77
import type { PromptPreset } from "./settings-store";
88
import type { LocalGgufModel, GpuBackend } from "./llamacpp-manager";
9+
import type { ScheduledTask } from "./scheduled-tasks-store";
910

1011
interface ToolExecuteResult {
1112
result?: unknown;
@@ -135,6 +136,16 @@ contextBridge.exposeInMainWorld("api", {
135136
clearAll: () => ipcRenderer.invoke("sessions:clearAll"),
136137
},
137138

139+
scheduledTasks: {
140+
list: (): Promise<ScheduledTask[]> => ipcRenderer.invoke("scheduledTasks:list"),
141+
create: (name: string, prompt: string, model: string, intervalMinutes: number): Promise<ScheduledTask> =>
142+
ipcRenderer.invoke("scheduledTasks:create", { name, prompt, model, intervalMinutes }),
143+
update: (id: string, partial: Record<string, unknown>): Promise<ScheduledTask | null> =>
144+
ipcRenderer.invoke("scheduledTasks:update", { id, partial }),
145+
delete: (id: string): Promise<void> => ipcRenderer.invoke("scheduledTasks:delete", id),
146+
runNow: (id: string): Promise<void> => ipcRenderer.invoke("scheduledTasks:runNow", id),
147+
},
148+
138149
files: {
139150
openAndRead: () => ipcRenderer.invoke("files:openAndRead"),
140151
openFolderAndRead: () => ipcRenderer.invoke("files:openFolderAndRead"),
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { describe, it, expect } from "vitest";
2+
import { listTasks, getTask, createTask, updateTask, deleteTask } from "./scheduled-tasks-store";
3+
4+
describe("scheduled-tasks-store", () => {
5+
it("starts with no tasks", () => {
6+
expect(listTasks()).toEqual([]);
7+
});
8+
9+
it("creates a task with sensible defaults", () => {
10+
const task = createTask({
11+
name: "Check something",
12+
prompt: "Summarize the latest changes.",
13+
model: "ollama:llama3.1:8b",
14+
targetSessionId: "session-1",
15+
intervalMinutes: 30,
16+
});
17+
expect(task.enabled).toBe(true);
18+
expect(task.lastRunAt).toBeNull();
19+
expect(task.lastError).toBeNull();
20+
expect(getTask(task.id)).toEqual(task);
21+
});
22+
23+
it("updates a task in place", () => {
24+
const task = createTask({
25+
name: "A",
26+
prompt: "p",
27+
model: "ollama:m",
28+
targetSessionId: "s",
29+
intervalMinutes: 10,
30+
});
31+
const updated = updateTask(task.id, { enabled: false, lastError: "boom" });
32+
expect(updated?.enabled).toBe(false);
33+
expect(updated?.lastError).toBe("boom");
34+
expect(getTask(task.id)?.enabled).toBe(false);
35+
});
36+
37+
it("returns null when updating a task that doesn't exist", () => {
38+
expect(updateTask("nonexistent", { enabled: false })).toBeNull();
39+
});
40+
41+
it("deletes a task", () => {
42+
const task = createTask({
43+
name: "B",
44+
prompt: "p",
45+
model: "ollama:m",
46+
targetSessionId: "s",
47+
intervalMinutes: 10,
48+
});
49+
deleteTask(task.id);
50+
expect(getTask(task.id)).toBeNull();
51+
});
52+
});

‎app/src/scheduled-tasks-store.ts‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import * as path from "node:path";
2+
import { randomUUID } from "node:crypto";
3+
import { app } from "electron";
4+
import { readJson, writeJson } from "./json-store";
5+
6+
export interface ScheduledTask {
7+
id: string;
8+
name: string;
9+
prompt: string;
10+
// "provider:modelId", same format used everywhere else in the app.
11+
model: string;
12+
// Every run appends to this one chat session (created alongside the
13+
// task) rather than starting a fresh chat each time — a running log of
14+
// results is far more useful than a new sidebar entry every interval.
15+
targetSessionId: string;
16+
intervalMinutes: number;
17+
enabled: boolean;
18+
lastRunAt: string | null;
19+
lastError: string | null;
20+
createdAt: string;
21+
}
22+
23+
function filePath(): string {
24+
return path.join(app.getPath("userData"), "scheduled-tasks.json");
25+
}
26+
27+
export function listTasks(): ScheduledTask[] {
28+
return readJson<ScheduledTask[]>(filePath(), []);
29+
}
30+
31+
export function getTask(id: string): ScheduledTask | null {
32+
return listTasks().find((t) => t.id === id) ?? null;
33+
}
34+
35+
export function createTask(
36+
partial: Pick<ScheduledTask, "name" | "prompt" | "model" | "targetSessionId" | "intervalMinutes">
37+
): ScheduledTask {
38+
const task: ScheduledTask = {
39+
id: randomUUID(),
40+
enabled: true,
41+
lastRunAt: null,
42+
lastError: null,
43+
createdAt: new Date().toISOString(),
44+
...partial,
45+
};
46+
const all = listTasks();
47+
all.push(task);
48+
writeJson(filePath(), all);
49+
return task;
50+
}
51+
52+
export function updateTask(
53+
id: string,
54+
partial: Partial<
55+
Pick<ScheduledTask, "name" | "prompt" | "model" | "intervalMinutes" | "enabled" | "lastRunAt" | "lastError">
56+
>
57+
): ScheduledTask | null {
58+
const all = listTasks();
59+
const idx = all.findIndex((t) => t.id === id);
60+
if (idx === -1) return null;
61+
all[idx] = { ...all[idx], ...partial };
62+
writeJson(filePath(), all);
63+
return all[idx];
64+
}
65+
66+
export function deleteTask(id: string): void {
67+
writeJson(filePath(), listTasks().filter((t) => t.id !== id));
68+
}

‎app/src/scheduler.test.ts‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import * as scheduler from "./scheduler";
3+
import * as scheduledTasksStore from "./scheduled-tasks-store";
4+
import * as sessionsStore from "./sessions-store";
5+
6+
describe("scheduler.runTask", () => {
7+
it("appends the prompt and response to the task's target session on success", async () => {
8+
const session = sessionsStore.createSession("ollama:llama3.1:8b");
9+
const task = scheduledTasksStore.createTask({
10+
name: "Test task",
11+
prompt: "How's it going?",
12+
model: "ollama:llama3.1:8b",
13+
targetSessionId: session.id,
14+
intervalMinutes: 30,
15+
});
16+
17+
scheduler.init(async () => "All good.");
18+
await scheduler.runTask(task.id);
19+
20+
const updatedSession = sessionsStore.getSession(session.id);
21+
expect(updatedSession?.messages).toEqual([
22+
{ role: "user", content: "How's it going?" },
23+
{ role: "assistant", content: "All good." },
24+
]);
25+
const updatedTask = scheduledTasksStore.getTask(task.id);
26+
expect(updatedTask?.lastRunAt).not.toBeNull();
27+
expect(updatedTask?.lastError).toBeNull();
28+
});
29+
30+
it("records the error and leaves the session untouched when the prompt fails", async () => {
31+
const session = sessionsStore.createSession("ollama:llama3.1:8b");
32+
const task = scheduledTasksStore.createTask({
33+
name: "Failing task",
34+
prompt: "Do the thing.",
35+
model: "ollama:llama3.1:8b",
36+
targetSessionId: session.id,
37+
intervalMinutes: 30,
38+
});
39+
40+
scheduler.init(async () => {
41+
throw new Error("model unavailable");
42+
});
43+
await scheduler.runTask(task.id);
44+
45+
const updatedSession = sessionsStore.getSession(session.id);
46+
expect(updatedSession?.messages).toEqual([]);
47+
const updatedTask = scheduledTasksStore.getTask(task.id);
48+
expect(updatedTask?.lastError).toBe("model unavailable");
49+
expect(updatedTask?.lastRunAt).not.toBeNull();
50+
});
51+
52+
it("does nothing for an unknown task id", async () => {
53+
const runPrompt = vi.fn(async () => "unused");
54+
scheduler.init(runPrompt);
55+
await scheduler.runTask("nonexistent");
56+
expect(runPrompt).not.toHaveBeenCalled();
57+
});
58+
59+
it("records an error for a malformed model reference instead of throwing", async () => {
60+
const session = sessionsStore.createSession("bad-model-ref");
61+
const task = scheduledTasksStore.createTask({
62+
name: "Bad model",
63+
prompt: "p",
64+
model: "no-colon-here",
65+
targetSessionId: session.id,
66+
intervalMinutes: 30,
67+
});
68+
scheduler.init(async () => "unused");
69+
await scheduler.runTask(task.id);
70+
expect(scheduledTasksStore.getTask(task.id)?.lastError).toMatch(/Invalid model reference/);
71+
});
72+
});

0 commit comments

Comments
 (0)