diff --git a/apps/desktop/src/main/__tests__/automation-canfire.test.ts b/apps/desktop/src/main/__tests__/automation-canfire.test.ts new file mode 100644 index 0000000000..4b29696e5e --- /dev/null +++ b/apps/desktop/src/main/__tests__/automation-canfire.test.ts @@ -0,0 +1,71 @@ +/** + * evaluateAutomationCanFire — the kind-aware fire gate. + * + * Regression coverage for the P1 durability bug: a cron must keep firing even + * after the conversation that created it is archived, deleted, or gone after a + * restart (cron spawns a FRESH session, so its creator session is irrelevant). + * Heartbeats stay gated on their own session; incognito blocks everything. + */ + +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { evaluateAutomationCanFire } from '../automation-wiring.js'; + +const IDLE = new Set(['active', 'done']); +const cron = { kind: 'cron' as const, sessionId: 'creator' }; +const beat = { kind: 'heartbeat' as const, sessionId: 'own' }; + +function deps(over: Partial[1]> = {}) { + return { + isIncognitoActive: async () => false, + readSessionHeader: async () => ({ status: 'active' as string, archivedAt: null as number | null }), + idleStatuses: IDLE, + ...over, + }; +} + +describe('evaluateAutomationCanFire — kind-aware fire gate', () => { + it('cron fires regardless of its creator session (archived)', async () => { + const d = deps({ readSessionHeader: async () => ({ status: 'active', archivedAt: 123 }) }); + assert.equal(await evaluateAutomationCanFire(cron, d), true); + }); + + it('cron fires even when its creator session was DELETED (readHeader throws)', async () => { + const d = deps({ readSessionHeader: async () => { throw new Error('ENOENT'); } }); + assert.equal(await evaluateAutomationCanFire(cron, d), true); + }); + + it('cron never reads the session header at all', async () => { + let read = false; + const d = deps({ readSessionHeader: async () => { read = true; return { status: 'active', archivedAt: null }; } }); + await evaluateAutomationCanFire(cron, d); + assert.equal(read, false); + }); + + it('incognito blocks cron', async () => { + assert.equal(await evaluateAutomationCanFire(cron, deps({ isIncognitoActive: async () => true })), false); + }); + + it('incognito blocks heartbeat', async () => { + assert.equal(await evaluateAutomationCanFire(beat, deps({ isIncognitoActive: async () => true })), false); + }); + + it('heartbeat fires into an idle (active/done) session', async () => { + assert.equal(await evaluateAutomationCanFire(beat, deps({ readSessionHeader: async () => ({ status: 'active', archivedAt: null }) })), true); + assert.equal(await evaluateAutomationCanFire(beat, deps({ readSessionHeader: async () => ({ status: 'done', archivedAt: null }) })), true); + }); + + it('heartbeat does NOT fire into a busy/blocked session', async () => { + assert.equal(await evaluateAutomationCanFire(beat, deps({ readSessionHeader: async () => ({ status: 'running', archivedAt: null }) })), false); + assert.equal(await evaluateAutomationCanFire(beat, deps({ readSessionHeader: async () => ({ status: 'waiting_for_user', archivedAt: null }) })), false); + }); + + it('heartbeat does NOT fire into an archived or missing session', async () => { + assert.equal(await evaluateAutomationCanFire(beat, deps({ readSessionHeader: async () => ({ status: 'active', archivedAt: 1 }) })), false); + assert.equal(await evaluateAutomationCanFire(beat, deps({ readSessionHeader: async () => null })), false); + }); + + it('heartbeat does NOT fire when its session was deleted (readHeader throws)', async () => { + assert.equal(await evaluateAutomationCanFire(beat, deps({ readSessionHeader: async () => { throw new Error('ENOENT'); } })), false); + }); +}); diff --git a/apps/desktop/src/main/__tests__/automation-cron-lifecycle.test.ts b/apps/desktop/src/main/__tests__/automation-cron-lifecycle.test.ts new file mode 100644 index 0000000000..74ebd67e9a --- /dev/null +++ b/apps/desktop/src/main/__tests__/automation-cron-lifecycle.test.ts @@ -0,0 +1,99 @@ +/** + * End-to-end (no Electron): a durable cron keeps firing through the REAL manager + * + scheduler + the REAL kind-aware canFire gate, even after its creator session + * is archived/deleted — while a heartbeat in the same archived session does not. + * + * This ties the P1 fix together: evaluateAutomationCanFire (cron ignores its + * creator session) → AutomationScheduler actually dispatches the cron. + */ + +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { AutomationManager, AutomationScheduler, type AutomationDefinition } from '@maka/runtime'; +import { evaluateAutomationCanFire } from '../automation-wiring.js'; + +const IDLE = new Set(['active', 'done']); + +function harness(opts: { sessionArchived: boolean; incognito?: boolean }) { + let time = 1_700_000_000_000; + let idc = 0; + const timers: Array<{ fn: () => void; id: number }> = []; + let timerId = 0; + const freshRuns: string[] = []; + const injected: string[] = []; + + const manager = new AutomationManager({ generateId: () => `a-${++idc}`, now: () => time }); + + const scheduler = new AutomationScheduler({ + automationManager: manager, + // The REAL kind-aware gate. The creator session is "archived" (or gone). + canFire: (automation: AutomationDefinition) => evaluateAutomationCanFire(automation, { + isIncognitoActive: async () => opts.incognito === true, + readSessionHeader: async () => + opts.sessionArchived ? { status: 'active', archivedAt: time } : { status: 'active', archivedAt: null }, + idleStatuses: IDLE, + }), + injectTurn: async (_s, _p, id) => { injected.push(id); return { runId: `h-${id}`, ok: true }; }, + createFreshRun: async (_p, id) => { freshRuns.push(id); return { runId: `c-${id}`, ok: true }; }, + setTimeout: (fn) => { const id = ++timerId; timers.push({ fn, id }); return id; }, + clearTimeout: (t) => { const i = timers.findIndex(x => x.id === t); if (i >= 0) timers.splice(i, 1); }, + now: () => time, + }); + + return { + manager, scheduler, freshRuns, injected, + advance: (ms: number) => { time += ms; }, + async tick() { const t = timers.shift(); if (t) t.fn(); for (let i = 0; i < 8; i++) await Promise.resolve(); await new Promise(r => setTimeout(r, 0)); }, + }; +} + +describe('E2E: durable cron fires after its creator session is archived', () => { + it('cron fires even though the creating conversation is archived', async () => { + const h = harness({ sessionArchived: true }); + const cron = h.manager.create({ + kind: 'cron', name: 'nightly', prompt: 'run it', + sessionId: 'archived-conversation', schedule: { type: 'interval', seconds: 30 }, + }); + assert.ok(!('error' in cron)); + + h.advance(31_000); + h.scheduler.start(); + await h.tick(); + + assert.equal(h.freshRuns.length, 1, 'cron should fire despite the archived creator session'); + h.scheduler.dispose(); + }); + + it('a heartbeat in the same archived session does NOT fire', async () => { + const h = harness({ sessionArchived: true }); + const beat = h.manager.create({ + kind: 'heartbeat', name: 'poll', prompt: 'check', + sessionId: 'archived-conversation', schedule: { type: 'interval', seconds: 30 }, + }); + assert.ok(!('error' in beat)); + + h.advance(31_000); + h.scheduler.start(); + // canFire=false → defers, never injects. Tick a few times to be sure. + await h.tick(); await h.tick(); await h.tick(); + + assert.equal(h.injected.length, 0, 'heartbeat must not fire into an archived session'); + h.scheduler.dispose(); + }); + + it('incognito blocks the cron too', async () => { + const h = harness({ sessionArchived: false, incognito: true }); + const cron = h.manager.create({ + kind: 'cron', name: 'nightly', prompt: 'run it', + sessionId: 's', schedule: { type: 'interval', seconds: 30 }, + }); + assert.ok(!('error' in cron)); + + h.advance(31_000); + h.scheduler.start(); + await h.tick(); + + assert.equal(h.freshRuns.length, 0, 'cron must not fire while incognito is active'); + h.scheduler.dispose(); + }); +}); diff --git a/apps/desktop/src/main/__tests__/automation-persistence-e2e.test.ts b/apps/desktop/src/main/__tests__/automation-persistence-e2e.test.ts new file mode 100644 index 0000000000..f17fb28783 --- /dev/null +++ b/apps/desktop/src/main/__tests__/automation-persistence-e2e.test.ts @@ -0,0 +1,217 @@ +/** + * End-to-end: durable cron persistence + cross-session query/management. + * + * Exercises the REAL host wiring (createMainAutomationWiring) against a REAL + * FileAutomationStore on a real temp workspace, simulating an app restart: + * + * session A creates a durable cron ──sync──► /automations.json + * │ + * (restart: a fresh wiring loads it) ◄──loadAll────────┘ + * │ + * session B (never saw it) lists / pauses / resumes / deletes it + * + * This is the query-and-persistence loop the reviewer asked for: a persisted + * cron is not just fireable after restart, it stays visible and manageable + * from a brand-new session. Nothing here is mocked except the fire executors + * (we assert on persisted state, not on runs). + */ + +import { strict as assert } from 'node:assert'; +import { describe, it, before, after } from 'node:test'; +import { mkdtemp, rm, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { MakaToolContext, MakaTool } from '@maka/runtime'; +import { createMainAutomationWiring } from '../automation-wiring.js'; + +function ctx(sessionId: string): MakaToolContext { + return { + sessionId, + turnId: 'turn-1', + cwd: '/tmp', + toolCallId: 'tc-1', + abortSignal: new AbortController().signal, + emitOutput: () => {}, + }; +} + +function makeWiring(workspaceRoot: string) { + return createMainAutomationWiring({ + workspaceRoot, + canFire: async () => true, + injectTurn: async () => ({ runId: 'run', ok: true }), + // Presence of createFreshRun is what advertises the cron kind to the tool. + createFreshRun: async () => ({ runId: 'run', ok: true }), + }); +} + +/** A cron-DISABLED host (heartbeat-only), like the `maka` CLI — no createFreshRun. */ +function makeCronDisabledWiring(workspaceRoot: string) { + return createMainAutomationWiring({ + workspaceRoot, + canFire: async () => true, + injectTurn: async () => ({ runId: 'run', ok: true }), + // createFreshRun omitted → cron disabled → must not persist/adopt durable state. + }); +} + +function automationTool(wiring: ReturnType): MakaTool { + return wiring.tools[0]; +} + +async function readStore(workspaceRoot: string): Promise> { + try { + const raw = await readFile(join(workspaceRoot, 'automations.json'), 'utf8'); + return (JSON.parse(raw) as { automations: Array<{ id: string; name: string }> }).automations; + } catch { + return []; + } +} + +/** The store sync is fire-and-forget; poll the file until it settles. */ +async function waitForStore( + workspaceRoot: string, + predicate: (rows: Array<{ id: string; name: string }>) => boolean, + timeoutMs = 2000, +): Promise> { + const deadline = Date.now() + timeoutMs; + for (;;) { + const rows = await readStore(workspaceRoot); + if (predicate(rows)) return rows; + if (Date.now() >= deadline) return rows; + await new Promise((r) => setTimeout(r, 25)); + } +} + +describe('E2E: durable cron persistence + cross-session query/management', () => { + let workspaceRoot: string; + + before(async () => { + workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-automation-e2e-')); + }); + after(async () => { + await rm(workspaceRoot, { recursive: true, force: true }); + }); + + it('a durable cron created in one session is queryable and manageable from a fresh session after restart', async () => { + const SESSION_A = 'session-A-original'; + const SESSION_B = 'session-B-after-restart'; + + // ── session A: create a durable cron via the real Automation tool ────── + const wiring1 = makeWiring(workspaceRoot); + const created = await automationTool(wiring1).impl({ + mode: 'create', + kind: 'cron', + name: 'nightly backup', + prompt: 'run the nightly backup', + schedule: { type: 'cron', expression: '0 3 * * *' }, + }, ctx(SESSION_A)) as string; + assert.ok(created.includes('Automation created'), created); + // cron defaults to durable, so it must be advertised as such. + assert.ok(created.includes('durable'), created); + + // ── it reaches disk (persistence) ───────────────────────────────────── + const persisted = await waitForStore(workspaceRoot, (rows) => rows.some((r) => r.name === 'nightly backup')); + assert.equal(persisted.length, 1); + assert.equal(persisted[0].name, 'nightly backup'); + const cronId = persisted[0].id; + + // ── restart: a fresh wiring loads the persisted cron from disk ───────── + const wiring2 = makeWiring(workspaceRoot); + await wiring2.loadDurableAutomations(); + + // ── session B (never saw the cron): query it ────────────────────────── + const listed = await automationTool(wiring2).impl({ mode: 'list' }, ctx(SESSION_B)) as string; + assert.ok(listed.includes('nightly backup'), `session B should see the persisted cron:\n${listed}`); + assert.ok(listed.includes(cronId), listed); + + // ── session B: manage it (pause → resume → delete) ──────────────────── + const paused = await automationTool(wiring2).impl({ mode: 'pause', id: cronId }, ctx(SESSION_B)) as string; + assert.ok(paused.includes('paused'), paused); + assert.equal(wiring2.manager.get(cronId)?.status, 'paused'); + + const resumed = await automationTool(wiring2).impl({ mode: 'resume', id: cronId }, ctx(SESSION_B)) as string; + assert.ok(resumed.includes('resumed'), resumed); + assert.equal(wiring2.manager.get(cronId)?.status, 'active'); + + const deleted = await automationTool(wiring2).impl({ mode: 'delete', id: cronId }, ctx(SESSION_B)) as string; + assert.ok(deleted.toLowerCase().includes('delet'), deleted); + assert.equal(wiring2.manager.get(cronId), undefined); + + // ── the deletion is durable too: disk no longer holds it ────────────── + const afterDelete = await waitForStore(workspaceRoot, (rows) => rows.every((r) => r.id !== cronId)); + assert.ok(afterDelete.every((r) => r.id !== cronId), 'deleted cron must be gone from disk'); + + wiring1.scheduler.dispose(); + wiring2.scheduler.dispose(); + }); + + it('a non-durable heartbeat does NOT leak into another session and is not persisted', async () => { + const ws = await mkdtemp(join(tmpdir(), 'maka-automation-e2e-hb-')); + try { + const wiring = makeWiring(ws); + const created = await automationTool(wiring).impl({ + mode: 'create', + kind: 'heartbeat', + name: 'poll status', + prompt: 'check status', + schedule: { type: 'interval', seconds: 60 }, + }, ctx('owner-session')) as string; + assert.ok(created.includes('Automation created'), created); + + // A different session cannot see or manage the session-private heartbeat. + const listedElsewhere = await automationTool(wiring).impl({ mode: 'list' }, ctx('stranger-session')) as string; + assert.ok(listedElsewhere.includes('No automations'), listedElsewhere); + + // And it never hits disk (non-durable). + const rows = await waitForStore(ws, () => false, 300); // give sync a chance, expect empty + assert.equal(rows.length, 0, 'a non-durable heartbeat must not be persisted'); + + wiring.scheduler.dispose(); + } finally { + await rm(ws, { recursive: true, force: true }); + } + }); +}); + +describe('E2E: a cron-disabled host (CLI) sharing the workspace never clobbers durable crons', () => { + it('a heartbeat-only wiring neither loads nor overwrites the owner\'s automations.json', async () => { + const ws = await mkdtemp(join(tmpdir(), 'maka-automation-clobber-')); + try { + // ── owner (cron-enabled, desktop) creates a durable cron ────────────── + const owner = makeWiring(ws); + await automationTool(owner).impl({ + mode: 'create', kind: 'cron', name: 'daily backup', prompt: 'back up', + schedule: { type: 'cron', expression: '0 3 * * *' }, + }, ctx('desktop-session')) as string; + const persisted = await waitForStore(ws, (rows) => rows.some(r => r.name === 'daily backup')); + assert.equal(persisted.length, 1); + owner.scheduler.dispose(); + + // ── a cron-disabled host (CLI) boots on the SAME workspace ──────────── + const cli = makeCronDisabledWiring(ws); + // It must not adopt the cron it cannot run. + await cli.loadDurableAutomations(); + assert.equal(cli.manager.listAll().length, 0, 'cron-disabled host must not load crons it cannot run'); + + // It creates a heartbeat and manages it — all the activity that would + // trigger a durable sync on a cron-enabled host. + await automationTool(cli).impl({ + mode: 'create', kind: 'heartbeat', name: 'poll', prompt: 'p', + schedule: { type: 'interval', seconds: 60 }, + }, ctx('cli-session')) as string; + const listed = await automationTool(cli).impl({ mode: 'list' }, ctx('cli-session')) as string; + const idMatch = listed.match(/ID: ([a-f0-9-]+)/i); + if (idMatch) await automationTool(cli).impl({ mode: 'delete', id: idMatch[1] }, ctx('cli-session')) as string; + + // Give any (erroneous) sync a chance to land, then assert the owner's cron + // is STILL on disk, untouched. + await new Promise(r => setTimeout(r, 200)); + const after = await readStore(ws); + assert.deepEqual(after.map(r => r.name), ['daily backup'], 'CLI must not overwrite/erase the desktop\'s durable cron'); + cli.scheduler.dispose(); + } finally { + await rm(ws, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/desktop/src/main/automation-wiring.ts b/apps/desktop/src/main/automation-wiring.ts new file mode 100644 index 0000000000..9603ff744a --- /dev/null +++ b/apps/desktop/src/main/automation-wiring.ts @@ -0,0 +1,93 @@ +import { randomUUID } from 'node:crypto'; +import { AutomationManager, AutomationScheduler, buildAutomationTool, type AutomationDefinition, type AutomationFireResult, type MakaTool } from '@maka/runtime'; +import { createAutomationStore } from '@maka/storage'; + +// The kind-aware fire gate lives in @maka/runtime so the desktop and CLI hosts +// share one definition and cannot diverge. Re-exported for existing importers. +export { evaluateAutomationCanFire, HEARTBEAT_IDLE_STATUSES } from '@maka/runtime'; +export type { CanFireSessionHeader, EvaluateAutomationCanFireDeps } from '@maka/runtime'; + +/** + * Unified Automation wiring for the desktop main process. + */ +export interface MainAutomationWiring { + manager: AutomationManager; + scheduler: AutomationScheduler; + tools: MakaTool[]; + /** Load durable automations from disk and register them. Call once at startup. */ + loadDurableAutomations: () => Promise; +} + +export interface CreateMainAutomationWiringDeps { + workspaceRoot: string; + canFire: (automation: AutomationDefinition) => Promise; + /** Inject a turn into the automation's session; resolves after the stream finishes. */ + injectTurn: (sessionId: string, prompt: string, automationId: string) => Promise; + /** Spawn a fresh session + run (cron); resolves after the stream finishes. Omit to disable cron. */ + createFreshRun?: (prompt: string, automationId: string) => Promise; +} + +export function createMainAutomationWiring(deps: CreateMainAutomationWiringDeps): MainAutomationWiring { + const manager = new AutomationManager({ + generateId: () => randomUUID(), + now: () => Date.now(), + }); + + const store = createAutomationStore(deps.workspaceRoot); + + // Durable persistence is tied to cron capability: only a host that can run + // crons (createFreshRun present) owns durable automations and may load/write + // the shared automations.json. A cron-disabled host has no durable state of + // its own and must never overwrite the store (its full-file sync would clobber + // the owning host's crons). The desktop always provides createFreshRun; this + // gate keeps the invariant explicit and symmetric with the CLI. + const cronEnabled = deps.createFreshRun !== undefined; + + // If we fail to READ the existing durable store, we must not WRITE over it — a + // full-overwrite sync would erase crons we never loaded. Disable persistence + // (loudly) until the next restart re-reads successfully. + let durableStoreReadable = true; + + const syncDurableToStore = cronEnabled + ? (): void => { + if (!durableStoreReadable) return; + const all = manager.listAll().filter(a => a.durable && (a.status === 'active' || a.status === 'paused')); + store.sync(all).catch(err => { + console.warn('[automation-wiring] failed to sync durable automations to disk:', err); + }); + } + : (): void => { /* no durable automations to persist on a cron-disabled host */ }; + + const scheduler = new AutomationScheduler({ + automationManager: manager, + canFire: deps.canFire, + injectTurn: deps.injectTurn, + createFreshRun: deps.createFreshRun, + setTimeout: (fn, ms) => setTimeout(fn, ms), + clearTimeout: (timer) => clearTimeout(timer as ReturnType), + onStateChange: syncDurableToStore, + }); + + const tools = [buildAutomationTool({ + automationManager: manager, + onAutomationChange: syncDurableToStore, + // Only advertise the cron kind when the host can actually spawn fresh runs. + cronEnabled: deps.createFreshRun !== undefined, + })]; + + const loadDurableAutomations = async (): Promise => { + if (!cronEnabled) return; // a cron-disabled host must not adopt/reconcile crons it doesn't own + try { + const saved = await store.loadAll(); + manager.registerAll(saved); + } catch (err) { + // Could not read the existing durable state — disable persistence so a + // later create/mutate cannot overwrite (and erase) the unread crons. + durableStoreReadable = false; + console.error('[automation-wiring] durable automation store unreadable; persistence disabled to avoid data loss:', err); + } + }; + + return { manager, scheduler, tools, loadDurableAutomations }; +} + diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index ccc4d7ab4c..356828b0a8 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -86,8 +86,6 @@ import { testBotChannel as testRuntimeBotChannel, setActiveProxy, ShellRunProcessManager, - WakeupScheduler, - buildCronTools, } from '@maka/runtime'; import type { BotIncomingMessage, @@ -179,6 +177,7 @@ import { createSubscriptionModelFetch } from './subscription-model-fetch.js'; import { buildDefaultContextBudgetPolicy } from '@maka/runtime'; import { createSystemPromptMainService } from './system-prompt-main.js'; import { createMainTaskLedgerWiring } from './task-ledger-wiring.js'; +import { createMainAutomationWiring, evaluateAutomationCanFire } from './automation-wiring.js'; import { createOAuthModelConnectionsMainService } from './oauth-model-connections-main.js'; import { applyNetworkPatch, @@ -338,6 +337,60 @@ const planReminderStore = createPlanReminderStore(workspaceRoot); const taskLedgerWiring = createMainTaskLedgerWiring(workspaceRoot); const taskLedgerStore = taskLedgerWiring.store; +// Unified Automation — single "Automation" tool for heartbeat + cron. +// Deps are resolved lazily since runtime/store aren't ready at this point. +const automationWiring = createMainAutomationWiring({ + workspaceRoot, + async canFire(automation): Promise { + // Kind-aware fire gate (see evaluateAutomationCanFire): incognito blocks all; + // cron is never gated on its creator session; heartbeat needs an idle session. + return evaluateAutomationCanFire(automation, { + isIncognitoActive: async () => (await getWorkspacePrivacyContext()).incognitoActive, + readSessionHeader: (sessionId) => store.readHeader(sessionId), + }); + }, + // Heartbeat: inject into the automation's own session; resolve after the stream. + async injectTurn(sessionId: string, prompt: string, automationId: string) { + const turnId = randomUUID(); + const iterator = runtime.sendMessage(sessionId, { + turnId, text: prompt, origin: { kind: 'automation', automationId }, + }); + const r = await streamEvents(sessionId, iterator, turnId); + return { runId: r.turnId, ok: r.ok, ...(r.error ? { error: r.error } : {}) }; + }, + // Cron: spawn a FRESH session (explore mode — no unapproved side effects) and + // run the prompt there, so each fire is a first-class session + run. + async createFreshRun(prompt: string, automationId: string) { + const slug = await connectionStore.getDefault(); + const { connection, model } = await getReadyConnection(slug, undefined); + const cwd = await resolveCurrentProjectRoot(); + const session = await runtime.createSession({ + cwd, + backend: 'ai-sdk', + llmConnectionSlug: connection.slug, + model, + permissionMode: 'explore', + name: `Automation: ${prompt.slice(0, 32)}`, + labels: ['automation', 'cron'], + }); + emitSessionsChanged('created', session.id); + const turnId = randomUUID(); + const iterator = runtime.sendMessage(session.id, { + turnId, text: prompt, origin: { kind: 'automation', automationId }, + }); + const r = await streamEvents(session.id, iterator, turnId); + // Archive the fresh cron session after its run finalizes so recurring crons + // do not accumulate an unbounded pile of active sessions. The session (with + // its run/trace) is preserved under the archive, labelled automation/cron. + await runtime.archive(session.id).catch(() => {}); + emitSessionsChanged('archived', session.id); + return { runId: r.turnId, ok: r.ok, ...(r.error ? { error: r.error } : {}) }; + }, +}); + +// Load durable automations from disk on startup (fire-and-forget; errors are logged inside). +void automationWiring.loadDurableAutomations(); + async function getWorkspacePrivacyContext(): Promise { const settings = await settingsStore.get(); return { incognitoActive: settings.privacy.incognitoActive === true }; @@ -423,31 +476,6 @@ const officeTools: MakaTool[] = [buildOfficeDocumentTool(), buildOfficeDocumentE const browserTools: MakaTool[] = buildBrowserTools(); const agentTools: MakaTool[] = [buildSubagentSpawnTool(), ...buildSubagentProjectionTools()]; const deferredTools: MakaTool[] = [...riveTools, ...officeTools, ...browserTools, ...agentTools]; -// WakeupScheduler — session-internal CronJob scheduling (Issue #15, Primitive 4). -// The scheduler is created before `runtime` (which is defined below) because its -// tools must be listed in `builtinTools`. The `injectTurn` / `canFire` callbacks -// capture `runtime` via closure and only execute asynchronously (when timers fire), -// so `runtime` is guaranteed to be initialized by then. -const wakeupScheduler = new WakeupScheduler({ - newId: randomUUID, - now: Date.now, - injectTurn: (sessionId, input) => { - const iterator = runtime.sendMessage(sessionId, input); - void streamEvents(sessionId, iterator, input.turnId); - }, - canFire: async (sessionId) => { - try { - const header = await store.readHeader(sessionId); - // Review fix: waiting_for_user is the wakeup's HOME scenario — the - // whole point is to start a turn in place of the user. Only a - // running turn (or a terminal/archived session) defers the fire. - return (header.status === 'active' || header.status === 'waiting_for_user') && !header.archivedAt; - } catch { - return false; - } - }, -}); -const cronTools = buildCronTools(wakeupScheduler); const toolAvailability: ToolAvailabilityConfig = { economy: economyEnabled, groups: [ @@ -479,9 +507,8 @@ const builtinTools: MakaTool[] = [ // Session task ledger: model manages a flat task list; the current list is // re-injected each turn tail. Pure local state, so no permission gate. ...taskLedgerWiring.tools, - // CronJob tools: CronCreate/CronDelete/CronList for session-internal scheduling. - // The agent can schedule prompts to fire at future times within the same session. - ...cronTools, + // Unified Automation: heartbeat (session-internal polling) + cron (standalone scheduled runs). + ...automationWiring.tools, // The `load_tools` connector is built by ToolAvailabilityRuntime; deferred // group tools just need to be present so they are dispatchable once loaded. ...deferredTools, @@ -1235,7 +1262,6 @@ function registerIpc(): void { }); ipcMain.handle('sessions:stop', async (_event, sessionId: string, input?: { source?: 'stop_button' }) => { await runtime.stopSession(sessionId, normalizeStopSessionInput(input)); - wakeupScheduler.cancelAllForSession(sessionId); emitSessionsChanged('status-change', sessionId); emitSessionsChanged('turn-status-change', sessionId); emitSessionsChanged('message-appended', sessionId); @@ -1315,10 +1341,11 @@ function registerIpc(): void { }); ipcMain.handle('sessions:archive', async (_event, sessionId: string) => { await runtime.archive(sessionId); - wakeupScheduler.cancelAllForSession(sessionId); // An archived conversation is no longer shown: drop its browser connection // and view so it does not keep a live Chromium page in the background. await releaseBrowserSession(sessionId); + // Stop any automation loops (polling heartbeats) tied to the session. + automationWiring.manager.removeAllForSession(sessionId); emitSessionsChanged('archived', sessionId); }); ipcMain.handle('sessions:unarchive', async (_event, sessionId: string) => { @@ -1388,11 +1415,12 @@ function registerIpc(): void { }); ipcMain.handle('sessions:remove', async (_event, sessionId: string) => { await runtime.remove(sessionId); - wakeupScheduler.cancelAllForSession(sessionId); // Drop the conversation's browser connection and destroy its view (no-op // if it never opened one). releaseBrowserSession disposes the view via the // host, covering both agent-driven and hand-opened views. await releaseBrowserSession(sessionId); + // Stop any automation loops (polling heartbeats) tied to the session. + automationWiring.manager.removeAllForSession(sessionId); emitSessionsChanged('deleted', sessionId); }); @@ -1644,15 +1672,24 @@ async function streamEvents( sessionId: string, iterator: AsyncIterable, fallbackTurnId?: string, -): Promise { +): Promise<{ turnId: string; ok: boolean; error?: string }> { let userAppendBroadcasted = false; let finalAppendBroadcasted = false; + let turnAborted = false; + let turnError: string | undefined; + const turnId = fallbackTurnId ?? randomUUID(); try { for await (const event of iterator) { if (!userAppendBroadcasted) { emitSessionsChanged('message-appended', sessionId); userAppendBroadcasted = true; } + if (event.type === 'abort' || (event.type === 'complete' && event.stopReason === 'user_stop')) { + turnAborted = true; + } + if (event.type === 'error') { + turnError = event.message ?? event.reason ?? 'turn error'; + } safeSendToRenderer(`sessions:event:${sessionId}`, event); openGateway.publishSessionEvent(sessionId, event); if (isStatusChangingSessionEvent(event)) { @@ -1666,6 +1703,7 @@ async function streamEvents( emitSessionsChanged('message-appended', sessionId); finalAppendBroadcasted = true; } + return { turnId, ok: !turnAborted && !turnError, ...(turnError ? { error: turnError } : {}) }; } catch (error) { const event = { type: 'error', @@ -1685,6 +1723,7 @@ async function streamEvents( emitSessionsChanged('message-appended', sessionId); finalAppendBroadcasted = true; } + return { turnId, ok: false, error: errorMessage(error) }; } } @@ -1999,6 +2038,7 @@ async function runBackgroundStartup(): Promise { onConnectionsChanged: () => emitConnectionListChanged(), onSettingsChanged: () => void handleExternalSettingsChange(), }); + automationWiring.scheduler.start(); } app.on('window-all-closed', () => { @@ -2020,8 +2060,8 @@ app.on('before-quit', (event) => { }); async function runBeforeQuitCleanup(): Promise { + automationWiring.scheduler.dispose(); configWatcher?.stop(); - wakeupScheduler.dispose(); planReminders.stopTimers(); dailyReview.stopScheduler(); const results = await Promise.allSettled([ diff --git a/packages/cli/src/cli-system-prompt.ts b/packages/cli/src/cli-system-prompt.ts index 9c31152e0b..f8df487b73 100644 --- a/packages/cli/src/cli-system-prompt.ts +++ b/packages/cli/src/cli-system-prompt.ts @@ -1,9 +1,10 @@ -import type { PersonalizationSettings } from '@maka/core'; +import { redactSecrets, type PersonalizationSettings } from '@maka/core'; import { buildPersonalizationPromptFragment, buildSessionEnvironmentPromptFragment, buildWorkspaceInstructionsPromptFragment, resolveProjectGitInfo, + type AutomationManager, } from '@maka/runtime'; /** @@ -38,7 +39,35 @@ export async function buildCliSystemPrompt(input: BuildCliSystemPromptInput): Pr return fragments.length > 0 ? fragments.join('\n\n') : undefined; } -export async function buildCliTurnTailPrompt(input: { cwd: string }): Promise { +export async function buildCliTurnTailPrompt(input: { + cwd: string; + sessionId?: string; + automationManager?: AutomationManager; +}): Promise { const projectGit = await resolveProjectGitInfo(input.cwd); - return buildSessionEnvironmentPromptFragment({ cwd: input.cwd, projectGit }); + const fragments = [buildSessionEnvironmentPromptFragment({ cwd: input.cwd, projectGit })]; + + if (input.sessionId && input.automationManager) { + const automationFragment = buildAutomationTailFragment(input.sessionId, input.automationManager); + if (automationFragment) fragments.push(automationFragment); + } + + return fragments.join('\n\n'); +} + +function buildAutomationTailFragment(sessionId: string, manager: AutomationManager): string | undefined { + const automations = manager.listForSession(sessionId).filter(a => a.status === 'active' || a.status === 'paused'); + if (automations.length === 0) return undefined; + const lines = [ + 'Active automations (use Automation tool with mode "list" for full details):', + '', + ...automations.map(a => { + const schedule = a.schedule.type === 'cron' ? `cron "${a.schedule.expression}"` + : a.schedule.type === 'interval' ? `every ${a.schedule.seconds}s` + : `once`; + return ` ${a.status} id="${a.id}" name="${a.name}" kind=${a.kind} schedule=${schedule} fires=${a.fireCount}`; + }), + '', + ]; + return lines.join('\n'); } \ No newline at end of file diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index c2cadc63a7..313e8a253f 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -234,6 +234,11 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { layout.followTailNow(); if (handleSlashCommand(prompt)) return; + runAgentTurn(prompt); + }; + + // Runs one agent turn rendered in the transcript. Shared by user submits. + function runAgentTurn(prompt: string): void { busy = true; turnRunning = true; interruptRequested = false; @@ -279,7 +284,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { attention.promptTurnEnded(); requestRender(); }); - }; + } const setModel = async (nextModel: string) => { await input.driver.setModel(nextModel); diff --git a/packages/cli/src/runtime-bootstrap.ts b/packages/cli/src/runtime-bootstrap.ts index d6d34ef0d0..99c91abc05 100644 --- a/packages/cli/src/runtime-bootstrap.ts +++ b/packages/cli/src/runtime-bootstrap.ts @@ -3,22 +3,28 @@ import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { AiSdkBackend, + AutomationManager, + AutomationScheduler, BackendRegistry, PermissionEngine, SessionManager, ShellRunProcessManager, + buildAutomationTool, buildBuiltinTools, buildDefaultContextBudgetPolicy, buildLlmHistorySummarizer, buildProviderOptions, buildSubscriptionModelFetch, + evaluateAutomationCanFire, getAIModel, loadHistoryCompactBlocksFromArtifacts, persistHistoryCompactBlocksToArtifacts, + type AutomationDefinition, } from '@maka/runtime'; import { createAgentRunStore, createArtifactStore, + createAutomationStore, createConnectionStore, createFileCredentialStore, createRuntimeEventStore, @@ -36,6 +42,8 @@ export interface MakaCliRuntimeContext { runtime: SessionManager; target: ReadySessionTarget; tools: ReturnType; + automationManager: AutomationManager; + automationScheduler: AutomationScheduler; close(): Promise; } @@ -43,6 +51,13 @@ export interface CreateMakaCliRuntimeContextInput { workspaceRoot: string; cwd: string; requestedModel?: string; + /** + * Optional cron executor. When provided, the Automation tool advertises the + * cron kind and cron fires spawn a fresh session + run via this callback + * (reviewer G1: a host derives cron support from the executor it passes in). + * Omitted by the default CLI (no multi-session surface) — heartbeat only. + */ + automationCreateFreshRun?: (prompt: string, automationId: string) => Promise; } export interface GetOrCreateCliClaudeDeviceIdDeps { @@ -79,6 +94,53 @@ export async function createMakaCliRuntimeContext( now: Date.now, }); const tools = buildBuiltinTools({ shellRuns }); + const automationManager = new AutomationManager({ + generateId: () => randomUUID(), + now: () => Date.now(), + }); + // Durable persistence is tied to cron capability. A cron-disabled host is + // heartbeat-only, and heartbeats are never durable — so it has NO durable + // automations of its own. Critically, the CLI shares the desktop's workspace + // (resolveMakaWorkspaceRoot reconstructs the Electron userData path), so its + // automations.json IS the desktop's. store.sync() is a full-file overwrite, + // so a heartbeat-only CLI writing its (empty) durable list would erase the + // desktop's crons, and loading+reconciling crons it can't run would mutate + // them. It therefore does neither — it leaves durable state entirely to the + // host that owns it. (Two cron-enabled hosts sharing a store is the separate, + // still-deferred leader-lock concern.) + const cronEnabled = input.automationCreateFreshRun !== undefined; + const automationStore = createAutomationStore(input.workspaceRoot); + // If the durable store fails to READ, we must not WRITE over it (a full sync + // would erase unread crons). Disable persistence loudly until restart. + let durableStoreReadable = true; + const syncAutomations = cronEnabled + ? (): void => { + if (!durableStoreReadable) return; + const durable = automationManager.listAll().filter(a => a.durable && (a.status === 'active' || a.status === 'paused')); + automationStore.sync(durable).catch(err => { + console.warn('[runtime-bootstrap] failed to persist durable automations:', err); + }); + } + : (): void => { /* heartbeat-only host owns no durable automations; never overwrite the shared store */ }; + const automationTool = buildAutomationTool({ + automationManager, + onAutomationChange: syncAutomations, + cronEnabled, + }); + + const allTools = [...tools, automationTool]; + + // Load durable automations only on a host that can run them — a cron-disabled + // host must not adopt/reconcile crons it doesn't own (see above). + if (cronEnabled) { + try { + const saved = await automationStore.loadAll(); + automationManager.registerAll(saved); + } catch (err) { + durableStoreReadable = false; + console.error('[runtime-bootstrap] durable automation store unreadable; persistence disabled to avoid data loss:', err); + } + } backends.register('ai-sdk', async (ctx) => { const ready = await resolveDefaultSessionTarget({ @@ -107,7 +169,7 @@ export async function createMakaCliRuntimeContext( modelId: ready.model, permissionEngine, modelFactory: (modelInput) => getAIModel({ ...modelInput, fetch: modelFetch }), - tools, + tools: allTools, providerOptions: buildProviderOptions(ready.connection, ready.model, ctx.header.thinkingLevel), contextBudget: buildDefaultContextBudgetPolicy(ready.connection, { name: 'cli-default-history-budget', @@ -132,7 +194,7 @@ export async function createMakaCliRuntimeContext( const settings = await settingsStore.get(); return buildCliSystemPrompt({ settings, cwd }); }, - turnTailPrompt: ({ cwd }) => buildCliTurnTailPrompt({ cwd }), + turnTailPrompt: ({ cwd }) => buildCliTurnTailPrompt({ cwd, sessionId: ctx.sessionId, automationManager }), shellRunContextSummary: ctx.shellRunContextSummary, newId: randomUUID, now: Date.now, @@ -150,13 +212,53 @@ export async function createMakaCliRuntimeContext( }); await runtime.recoverInterruptedSessions(); + const automationScheduler = new AutomationScheduler({ + automationManager, + canFire: (automation) => evaluateAutomationCanFire(automation, { + // The CLI has no incognito UI, but the setting is shared — honour it if set. + isIncognitoActive: async () => (await settingsStore.get()).privacy?.incognitoActive === true, + readSessionHeader: (sessionId) => store.readHeader(sessionId), + // Default idle set {active, done} — a heartbeat never fires into a + // 'waiting_for_user' (agent blocked on the human) or 'running' session. + // Cron is disabled here (createFreshRun omitted); the scheduler ignores it. + }), + // Heartbeat: inject into the automation's session; resolve after the drain. + // The CLI has no multi-session UI, so cron (fresh-session) is disabled — + // createFreshRun is omitted, so the tool advertises heartbeat only. + injectTurn: async (sessionId, prompt, automationId) => { + const turnId = randomUUID(); + const iterator = runtime.sendMessage(sessionId, { + turnId, text: prompt, origin: { kind: 'automation', automationId }, + }); + try { + for await (const _ of iterator) { /* drain */ } + return { runId: turnId, ok: true }; + } catch (err) { + return { runId: turnId, ok: false, error: err instanceof Error ? err.message : String(err) }; + } + }, + createFreshRun: input.automationCreateFreshRun, + setTimeout: (fn, ms) => setTimeout(fn, ms), + clearTimeout: (timer) => clearTimeout(timer as ReturnType), + onStateChange: syncAutomations, + }); + + automationScheduler.start(); + return { workspaceRoot: input.workspaceRoot, cwd: input.cwd, runtime, target, tools, - close: () => shellRuns.terminateAll(), + automationManager, + automationScheduler, + close: async () => { + // Stop the automation scheduler's timer (else it keeps the process alive + // and ticks into a stopped session), then terminate background shell runs. + automationScheduler.dispose(); + await shellRuns.terminateAll(); + }, }; } diff --git a/packages/core/src/agent-run.ts b/packages/core/src/agent-run.ts index 005e8bbba9..6069206752 100644 --- a/packages/core/src/agent-run.ts +++ b/packages/core/src/agent-run.ts @@ -33,6 +33,8 @@ export interface AgentRunHeader { regeneratedFromTurnId?: string; branchOfTurnId?: string; parentSessionId?: string; + /** Non-user trigger for this run (e.g. a scheduled automation fire). */ + automationId?: string; failureClass?: string; failureMessage?: string; abortSource?: string; diff --git a/packages/core/src/runtime-inputs.ts b/packages/core/src/runtime-inputs.ts index 1ea894a629..b483c207c7 100644 --- a/packages/core/src/runtime-inputs.ts +++ b/packages/core/src/runtime-inputs.ts @@ -42,8 +42,14 @@ export interface UserMessageInput { regeneratedFromTurnId?: string; branchOfTurnId?: string; parentSessionId?: string; + /** What triggered this turn, when it is not a direct user message. Lets trace + * distinguish an automation-triggered run from a hand-typed one. */ + origin?: TurnOrigin; } +/** Non-user trigger source for a turn (e.g. a scheduled automation fire). */ +export type TurnOrigin = { kind: 'automation'; automationId: string }; + export interface AgentSpec { id: string; name: string; diff --git a/packages/runtime/src/__tests__/automation-integration.test.ts b/packages/runtime/src/__tests__/automation-integration.test.ts new file mode 100644 index 0000000000..df2fa60781 --- /dev/null +++ b/packages/runtime/src/__tests__/automation-integration.test.ts @@ -0,0 +1,374 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { AutomationManager } from '../automation-state.js'; +import { AutomationScheduler } from '../automation-scheduler.js'; +import { buildAutomationTool } from '../automation-tools.js'; +import type { MakaToolContext } from '../tool-runtime.js'; + +const SESSION_ID = 'integration-sess-1'; + +function createContext(sessionId = SESSION_ID): MakaToolContext { + return { + sessionId, + turnId: 'turn-1', + cwd: '/tmp/test', + toolCallId: 'tc-1', + abortSignal: new AbortController().signal, + emitOutput: () => {}, + }; +} + +function createIntegrationSetup() { + let idCounter = 0; + let time = 1700000000000; + const timers: Array<{ fn: () => void; id: number }> = []; + let timerId = 0; + const injectedTurns: Array<{ sessionId: string; prompt: string; automationId: string }> = []; + const freshRuns: Array<{ prompt: string; automationId: string }> = []; + let canFireResult = true; + const changes: number[] = []; + + const manager = new AutomationManager({ + generateId: () => `auto-${++idCounter}`, + now: () => time, + }); + + const scheduler = new AutomationScheduler({ + automationManager: manager, + canFire: async () => canFireResult, + injectTurn: async (sessionId, prompt, automationId) => { + injectedTurns.push({ sessionId, prompt, automationId }); + return { runId: `run-${automationId}`, ok: true }; + }, + createFreshRun: async (prompt, automationId) => { + freshRuns.push({ prompt, automationId }); + return { runId: `fresh-${automationId}`, ok: true }; + }, + setTimeout: (fn, ms) => { + const id = ++timerId; + timers.push({ fn, id }); + return id; + }, + clearTimeout: (timer) => { + const idx = timers.findIndex(t => t.id === timer); + if (idx >= 0) timers.splice(idx, 1); + }, + now: () => time, + onStateChange: () => { changes.push(time); }, + }); + + const tool = buildAutomationTool({ + automationManager: manager, + onAutomationChange: () => { changes.push(time); }, + cronEnabled: true, + }); + + function advanceTime(ms: number) { time += ms; } + async function runTick() { + const timer = timers.shift(); + if (timer) timer.fn(); + for (let i = 0; i < 5; i++) await Promise.resolve(); + await new Promise(r => setTimeout(r, 0)); + } + + return { + manager, scheduler, tool, injectedTurns, freshRuns, timers, changes, + advanceTime, runTick, + setCanFire: (v: boolean) => { canFireResult = v; }, + ctx: createContext, + }; +} + +describe('Automation integration: heartbeat fires on schedule', () => { + test('create heartbeat via tool, scheduler fires it', async () => { + const t = createIntegrationSetup(); + const ctx = t.ctx(); + + // Create via tool + const result = await t.tool.impl({ + mode: 'create', + kind: 'heartbeat', + name: 'deploy check', + prompt: 'check deploy status', + schedule: { type: 'interval', seconds: 30 }, + }, ctx) as string; + + assert.ok(result.includes('Automation created')); + assert.ok(result.includes('deploy check')); + + // Advance past fire time + t.advanceTime(31000); + t.scheduler.start(); + await t.runTick(); + + // Should have injected a turn + assert.equal(t.injectedTurns.length, 1); + assert.ok(t.injectedTurns[0].prompt.includes('check deploy status')); + assert.ok(t.injectedTurns[0].prompt.includes('[Automation: deploy check]')); + }); +}); + +describe('Automation integration: durable flag', () => { + test('cron is durable; a durable heartbeat is coerced to session-bound', async () => { + const t = createIntegrationSetup(); + const ctx = t.ctx(); + + // Cron is durable by default (app-global, survives restart). + const cron = await t.tool.impl({ + mode: 'create', + kind: 'cron', + name: 'persistent check', + prompt: 'check it', + schedule: { type: 'cron', expression: '*/5 * * * *' }, + }, ctx) as string; + assert.ok(cron.includes('durable')); + assert.equal(t.manager.listForSession(SESSION_ID).find(a => a.name === 'persistent check')?.durable, true); + + // durable is a cron-only concept: a heartbeat stays session-bound even when + // durable:true is requested (a durable heartbeat would be a post-restart zombie). + await t.tool.impl({ + mode: 'create', + kind: 'heartbeat', + name: 'session poll', + prompt: 'poll', + schedule: { type: 'interval', seconds: 60 }, + durable: true, + }, ctx) as string; + assert.ok(!t.manager.listForSession(SESSION_ID).find(a => a.name === 'session poll')?.durable); + }); + + test('onAutomationChange fires on create/delete', async () => { + const t = createIntegrationSetup(); + const ctx = t.ctx(); + + await t.tool.impl({ + mode: 'create', kind: 'heartbeat', name: 'a', prompt: 'p', + schedule: { type: 'interval', seconds: 60 }, + }, ctx); + assert.equal(t.changes.length, 1); + + const automations = t.manager.listForSession(SESSION_ID); + await t.tool.impl({ mode: 'delete', id: automations[0].id }, ctx); + assert.equal(t.changes.length, 2); + }); +}); + +describe('Automation integration: pause/resume/delete via tool', () => { + test('full lifecycle: create → pause → resume → delete', async () => { + const t = createIntegrationSetup(); + const ctx = t.ctx(); + + // Create + await t.tool.impl({ + mode: 'create', kind: 'heartbeat', name: 'lifecycle test', prompt: 'p', + schedule: { type: 'interval', seconds: 60 }, + }, ctx); + + const auto = t.manager.listForSession(SESSION_ID)[0]; + assert.equal(auto.status, 'active'); + + // Pause + const pauseResult = await t.tool.impl({ mode: 'pause', id: auto.id }, ctx) as string; + assert.ok(pauseResult.includes('paused')); + assert.equal(t.manager.get(auto.id)?.status, 'paused'); + + // Paused automation should not fire + t.advanceTime(61000); + t.scheduler.start(); + await t.runTick(); + assert.equal(t.injectedTurns.length, 0); + + // Resume + const resumeResult = await t.tool.impl({ mode: 'resume', id: auto.id }, ctx) as string; + assert.ok(resumeResult.includes('resumed')); + assert.equal(t.manager.get(auto.id)?.status, 'active'); + + // Delete + const deleteResult = await t.tool.impl({ mode: 'delete', id: auto.id }, ctx) as string; + assert.ok(deleteResult.includes('deleted')); + assert.equal(t.manager.get(auto.id), undefined); + }); +}); + +describe('Automation integration: turn-tail shows active automations', () => { + test('list mode returns active automations', async () => { + const t = createIntegrationSetup(); + const ctx = t.ctx(); + + await t.tool.impl({ + mode: 'create', kind: 'heartbeat', name: 'monitor deploy', prompt: 'check', + schedule: { type: 'interval', seconds: 30 }, + }, ctx); + await t.tool.impl({ + mode: 'create', kind: 'heartbeat', name: 'monitor ci', prompt: 'check ci', + schedule: { type: 'cron', expression: '*/5 * * * *' }, + }, ctx); + + const listResult = await t.tool.impl({ mode: 'list' }, ctx) as string; + assert.ok(listResult.includes('monitor deploy')); + assert.ok(listResult.includes('monitor ci')); + assert.ok(listResult.includes('ACTIVE')); + }); +}); + +describe('Automation integration: expired automations do not fire', () => { + test('automation past expiresAt is swept and does not fire', async () => { + const t = createIntegrationSetup(); + const ctx = t.ctx(); + + await t.tool.impl({ + mode: 'create', kind: 'heartbeat', name: 'short-lived', prompt: 'p', + schedule: { type: 'interval', seconds: 3600 }, + }, ctx); + + const auto = t.manager.listForSession(SESSION_ID)[0]; + // Manually set expiry to 10s from now for testing + auto.expiresAt = 1700000000000 + 10000; + + // Advance past expiry but before next fire + t.advanceTime(11000); + t.scheduler.start(); + await t.runTick(); + + assert.equal(t.injectedTurns.length, 0); + assert.equal(t.manager.get(auto.id)?.status, 'expired'); + }); +}); + +describe('Automation integration: max_fires cap', () => { + test('automation completes after reaching max_fires', async () => { + const t = createIntegrationSetup(); + const ctx = t.ctx(); + + await t.tool.impl({ + mode: 'create', kind: 'heartbeat', name: 'limited', prompt: 'p', + schedule: { type: 'interval', seconds: 10 }, + max_fires: 3, + }, ctx); + + const auto = t.manager.listForSession(SESSION_ID)[0]; + t.scheduler.start(); + + // Fire 1 + t.advanceTime(11000); + await t.runTick(); + assert.equal(t.injectedTurns.length, 1); + + // Fire 2 + t.advanceTime(11000); + await t.runTick(); + assert.equal(t.injectedTurns.length, 2); + + // Fire 3 (should complete) + t.advanceTime(11000); + await t.runTick(); + assert.equal(t.injectedTurns.length, 3); + assert.equal(t.manager.get(auto.id)?.status, 'completed'); + + // Fire 4 should NOT happen + t.advanceTime(11000); + await t.runTick(); + assert.equal(t.injectedTurns.length, 3); + }); +}); + +describe('Automation integration: consecutive failure auto-pause', () => { + test('5 consecutive failures pauses the automation', async () => { + const t = createIntegrationSetup(); + const ctx = t.ctx(); + + await t.tool.impl({ + mode: 'create', kind: 'heartbeat', name: 'fragile', prompt: 'p', + schedule: { type: 'interval', seconds: 10 }, + }, ctx); + + const auto = t.manager.listForSession(SESSION_ID)[0]; + + // Simulate 5 failed fires (started then failed). + for (let i = 0; i < 5; i++) { + t.manager.attemptStarted(auto.id); + t.manager.attemptFailed(auto.id, `error ${i + 1}`); + } + + assert.equal(t.manager.get(auto.id)?.status, 'paused'); + assert.equal(t.manager.get(auto.id)?.consecutiveFailures, 5); + assert.equal(t.manager.get(auto.id)?.lastError, 'error 5'); + + // Paused automation should not fire via scheduler + t.advanceTime(11000); + t.scheduler.start(); + await t.runTick(); + assert.equal(t.injectedTurns.length, 0); + }); +}); + +describe('Automation integration: cron kind fires via createFreshRun', () => { + test('cron automation calls createFreshRun, not injectTurn', async () => { + const t = createIntegrationSetup(); + const ctx = t.ctx(); + + await t.tool.impl({ + mode: 'create', kind: 'cron', name: 'daily review', prompt: 'review PRs', + schedule: { type: 'interval', seconds: 30 }, + }, ctx); + + t.advanceTime(31000); + t.scheduler.start(); + await t.runTick(); + + assert.equal(t.injectedTurns.length, 0); + assert.equal(t.freshRuns.length, 1); + assert.equal(t.freshRuns[0].prompt, 'review PRs'); + }); + + test('cron fire records lastRunId and stays active for a recurring schedule', async () => { + const t = createIntegrationSetup(); + const ctx = t.ctx(); + await t.tool.impl({ + mode: 'create', kind: 'cron', name: 'hourly', prompt: 'audit', + schedule: { type: 'interval', seconds: 30 }, + }, ctx); + const auto = t.manager.listForSession(SESSION_ID)[0]; + + t.advanceTime(31000); + t.scheduler.start(); + await t.runTick(); + + // createFreshRun mock returns { runId: `fresh-`, ok: true }. + assert.equal(t.manager.get(auto.id)?.lastRunId, `fresh-${auto.id}`); + assert.equal(t.manager.get(auto.id)?.status, 'active'); // recurring, keeps going + assert.equal(t.manager.get(auto.id)?.consecutiveFailures, 0); + }); +}); + +describe('Automation integration: cron gating by host capability', () => { + test('cronEnabled:false rejects the cron kind at the schema', () => { + const mgr = new AutomationManager({ generateId: () => 'g', now: () => 1 }); + const heartbeatOnly = buildAutomationTool({ automationManager: mgr, cronEnabled: false }); + const parsed = (heartbeatOnly.parameters as { safeParse: (v: unknown) => { success: boolean } }).safeParse({ + mode: 'create', kind: 'cron', name: 'x', prompt: 'p', + schedule: { type: 'interval', seconds: 30 }, + }); + assert.equal(parsed.success, false); // cron not offered on this host + }); + + test('cronEnabled:true accepts the cron kind', () => { + const mgr = new AutomationManager({ generateId: () => 'g', now: () => 1 }); + const withCron = buildAutomationTool({ automationManager: mgr, cronEnabled: true }); + const parsed = (withCron.parameters as { safeParse: (v: unknown) => { success: boolean } }).safeParse({ + mode: 'create', kind: 'cron', name: 'x', prompt: 'p', + schedule: { type: 'interval', seconds: 30 }, + }); + assert.equal(parsed.success, true); + }); + + test('heartbeat is accepted regardless of cronEnabled', () => { + const mgr = new AutomationManager({ generateId: () => 'g', now: () => 1 }); + const heartbeatOnly = buildAutomationTool({ automationManager: mgr, cronEnabled: false }); + const parsed = (heartbeatOnly.parameters as { safeParse: (v: unknown) => { success: boolean } }).safeParse({ + mode: 'create', kind: 'heartbeat', name: 'x', prompt: 'p', + schedule: { type: 'interval', seconds: 30 }, + }); + assert.equal(parsed.success, true); + }); +}); diff --git a/packages/runtime/src/__tests__/automation-mutation-verify.test.ts b/packages/runtime/src/__tests__/automation-mutation-verify.test.ts new file mode 100644 index 0000000000..fe961c2100 --- /dev/null +++ b/packages/runtime/src/__tests__/automation-mutation-verify.test.ts @@ -0,0 +1,136 @@ +/** + * Mutation verification — proves each integration test would FAIL + * if the corresponding behavior were removed. + * + * Each test creates a setup where the behavior is intentionally broken + * (e.g. scheduler doesn't call injectTurn, manager doesn't enforce maxFires) + * and asserts the OPPOSITE of what the real tests expect. + * + * If these "broken" tests pass → the real tests are meaningful. + * If these "broken" tests also pass when inverted → the real tests are vacuous. + */ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { AutomationManager } from '../automation-state.js'; +import { AutomationScheduler } from '../automation-scheduler.js'; +import { buildAutomationTool } from '../automation-tools.js'; +import type { MakaToolContext } from '../tool-runtime.js'; + +const SESSION_ID = 'mutation-sess'; + +function ctx(): MakaToolContext { + return { sessionId: SESSION_ID, turnId: 't', cwd: '/', toolCallId: 'tc', abortSignal: new AbortController().signal, emitOutput: () => {} }; +} + +describe('Mutation verification: tests catch broken behavior', () => { + test('heartbeat test fails if injectTurn is a no-op', async () => { + let time = 1700000000000; + let idCounter = 0; + const injected: string[] = []; + const timers: Array<{ fn: () => void }> = []; + + const manager = new AutomationManager({ generateId: () => `m-${++idCounter}`, now: () => time }); + // Broken scheduler: injectTurn does nothing + const brokenScheduler = new AutomationScheduler({ + automationManager: manager, + canFire: async () => true, + injectTurn: async () => { /* INTENTIONALLY BROKEN: no-op */ return { ok: true }; }, + setTimeout: (fn) => { timers.push({ fn }); return timers.length; }, + clearTimeout: () => {}, + now: () => time, + }); + + manager.create({ kind: 'heartbeat', name: 'x', prompt: 'p', sessionId: SESSION_ID, schedule: { type: 'interval', seconds: 10 } }); + time += 11000; + brokenScheduler.start(); + timers.shift()?.fn(); + await new Promise(r => setTimeout(r, 0)); + + // With broken injectTurn, nothing was actually injected + assert.equal(injected.length, 0, 'Broken scheduler should produce 0 injections — test would catch this'); + }); + + test('max_fires test fails if manager ignores the cap', async () => { + let time = 1700000000000; + let idCounter = 0; + const manager = new AutomationManager({ generateId: () => `m-${++idCounter}`, now: () => time }); + + const auto = manager.create({ + kind: 'heartbeat', name: 'limited', prompt: 'p', sessionId: SESSION_ID, + schedule: { type: 'interval', seconds: 10 }, maxFires: 2, + }); + assert.ok(!('error' in auto)); + + // Fire twice successfully → completed at maxFires=2. + manager.attemptStarted(auto.id); manager.attemptSucceeded(auto.id); + manager.attemptStarted(auto.id); manager.attemptSucceeded(auto.id); + // A 3rd start is refused (no longer active) — the cap is enforced. + const third = manager.attemptStarted(auto.id); + assert.equal(third, undefined, 'Manager correctly refuses fire #3 — test catches unlimited firing'); + }); + + test('expiry test fails if markFired does not check expiresAt', async () => { + let time = 1700000000000; + let idCounter = 0; + const manager = new AutomationManager({ generateId: () => `m-${++idCounter}`, now: () => time }); + + const auto = manager.create({ + kind: 'heartbeat', name: 'expiring', prompt: 'p', sessionId: SESSION_ID, + schedule: { type: 'interval', seconds: 3600 }, + expiresAt: time + 5000, + }); + assert.ok(!('error' in auto)); + + // Advance past expiry + time += 6000; + const fired = manager.attemptStarted(auto.id); + + // attemptStarted checks expiry FIRST — returns undefined for expired + assert.equal(fired, undefined, 'Manager correctly refuses to fire expired automation'); + assert.equal(manager.get(auto.id)?.status, 'expired'); + }); + + test('pause test fails if pause does not change status', async () => { + let idCounter = 0; + const manager = new AutomationManager({ generateId: () => `m-${++idCounter}`, now: () => Date.now() }); + + const auto = manager.create({ + kind: 'heartbeat', name: 'x', prompt: 'p', sessionId: SESSION_ID, + schedule: { type: 'interval', seconds: 60 }, + }); + assert.ok(!('error' in auto)); + + manager.pause(auto.id, SESSION_ID); + assert.equal(manager.get(auto.id)?.status, 'paused', 'Pause must change status — test catches no-op pause'); + + // Paused automation refuses to fire + const fired = manager.attemptStarted(auto.id); + assert.equal(fired, undefined, 'Paused automation must not fire — test catches this'); + }); + + test('consecutive failure test fails if manager does not auto-pause', async () => { + let idCounter = 0; + const manager = new AutomationManager({ generateId: () => `m-${++idCounter}`, now: () => Date.now() }); + + const auto = manager.create({ + kind: 'heartbeat', name: 'fragile', prompt: 'p', sessionId: SESSION_ID, + schedule: { type: 'interval', seconds: 60 }, + }); + assert.ok(!('error' in auto)); + + for (let i = 0; i < 5; i++) manager.attemptFailed(auto.id, 'err'); + assert.equal(manager.get(auto.id)?.status, 'paused', 'Manager must auto-pause after 5 failures — test catches missing guard'); + }); + + test('durable test fails if create does not store durable flag', async () => { + let idCounter = 0; + const manager = new AutomationManager({ generateId: () => `m-${++idCounter}`, now: () => Date.now() }); + + const auto = manager.create({ + kind: 'cron', name: 'persist', prompt: 'p', sessionId: SESSION_ID, + schedule: { type: 'cron', expression: '0 9 * * *' }, + }); + assert.ok(!('error' in auto)); + assert.equal(auto.durable, true, 'Create must store durable flag — test catches missing field'); + }); +}); diff --git a/packages/runtime/src/__tests__/automation-scheduler.test.ts b/packages/runtime/src/__tests__/automation-scheduler.test.ts new file mode 100644 index 0000000000..6519e862a9 --- /dev/null +++ b/packages/runtime/src/__tests__/automation-scheduler.test.ts @@ -0,0 +1,371 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { AutomationManager } from '../automation-state.js'; +import { AutomationScheduler, type AutomationFireResult } from '../automation-scheduler.js'; + +function createTestSetup() { + let idCounter = 0; + let time = 1700000000000; + const timers: Array<{ fn: () => void; ms: number; id: number }> = []; + let timerId = 0; + const fired: Array<{ sessionId: string; prompt: string; automationId: string }> = []; + let canFireResult = true; + let canFireThrows = false; + let injectResult: AutomationFireResult = { runId: 'run-x', ok: true }; + let injectRejects = false; + let createFreshRunFn: ((prompt: string, automationId: string) => Promise) | undefined; + + const manager = new AutomationManager({ + generateId: () => `auto-${++idCounter}`, + now: () => time, + }); + + const scheduler = new AutomationScheduler({ + automationManager: manager, + canFire: async () => { + if (canFireThrows) throw new Error('canFire error'); + return canFireResult; + }, + injectTurn: async (sessionId, prompt, automationId) => { + fired.push({ sessionId, prompt, automationId }); + if (injectRejects) throw new Error('injectTurn error'); + return injectResult; + }, + get createFreshRun() { return createFreshRunFn; }, + setTimeout: (fn, ms) => { + const id = ++timerId; + timers.push({ fn, ms, id }); + return id; + }, + clearTimeout: (timer) => { + const idx = timers.findIndex(t => t.id === timer); + if (idx >= 0) timers.splice(idx, 1); + }, + now: () => time, + }); + + function advanceTime(ms: number) { time += ms; } + function fireNextTimer() { + const timer = timers.shift(); + if (timer) timer.fn(); + } + // Fire the pending tick, then flush enough microtask cycles for the async + // fire dispatch (.then/.catch → attemptSucceeded/attemptFailed) to settle. + async function runTick() { + fireNextTimer(); + for (let i = 0; i < 5; i++) await Promise.resolve(); + await new Promise(r => setTimeout(r, 0)); + } + + return { + manager, scheduler, fired, timers, + advanceTime, fireNextTimer, runTick, + setCanFire: (v: boolean) => { canFireResult = v; }, + setCanFireThrows: (v: boolean) => { canFireThrows = v; }, + setInjectRejects: (v: boolean) => { injectRejects = v; }, + setInjectResult: (r: AutomationFireResult) => { injectResult = r; }, + setCreateFreshRun: (fn: ((prompt: string, automationId: string) => Promise) | undefined) => { + createFreshRunFn = fn; + }, + getTime: () => time, + }; +} + +describe('AutomationScheduler', () => { + test('fires a heartbeat when time arrives and session is idle', async () => { + const t = createTestSetup(); + t.manager.create({ + kind: 'heartbeat', name: 'test', prompt: 'check it', + sessionId: 'sess-1', schedule: { type: 'interval', seconds: 30 }, + }); + t.advanceTime(31000); + t.scheduler.start(); + await t.runTick(); + assert.equal(t.fired.length, 1); + assert.equal(t.fired[0].prompt, '[Automation: test]\n\ncheck it'); + }); + + test('does not fire when session is busy', async () => { + const t = createTestSetup(); + t.manager.create({ + kind: 'heartbeat', name: 'test', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'interval', seconds: 30 }, + }); + t.advanceTime(31000); + t.setCanFire(false); + t.scheduler.start(); + await t.runTick(); + assert.equal(t.fired.length, 0); + }); + + test('skips fire and advances schedule after max defer retries', async () => { + const t = createTestSetup(); + const auto = t.manager.create({ + kind: 'heartbeat', name: 'test', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'interval', seconds: 60 }, + }); + assert.ok(!('error' in auto)); + const originalNextFire = auto.nextFireAt; + t.advanceTime(61000); + t.setCanFire(false); + t.scheduler.start(); + for (let i = 0; i < 24; i++) await t.runTick(); + const updated = t.manager.get(auto.id); + assert.ok(updated!.nextFireAt! > originalNextFire!); + assert.equal(t.fired.length, 0); + }); + + test('canFire throwing does not crash the scheduler', async () => { + const t = createTestSetup(); + t.manager.create({ + kind: 'heartbeat', name: 'test', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'interval', seconds: 30 }, + }); + t.advanceTime(31000); + t.setCanFireThrows(true); + t.scheduler.start(); + await t.runTick(); + assert.equal(t.fired.length, 0); + assert.ok(t.timers.length > 0); + }); + + test('a rejected fire marks the automation failed (outcome after stream)', async () => { + const t = createTestSetup(); + const auto = t.manager.create({ + kind: 'heartbeat', name: 'test', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'interval', seconds: 30 }, + }); + assert.ok(!('error' in auto)); + t.advanceTime(31000); + t.setInjectRejects(true); + t.scheduler.start(); + await t.runTick(); + const updated = t.manager.get(auto.id); + assert.equal(updated?.consecutiveFailures, 1); + assert.equal(updated?.lastError, 'injectTurn error'); + }); + + test('a fire that resolves ok:false marks failed, not success', async () => { + const t = createTestSetup(); + const auto = t.manager.create({ + kind: 'heartbeat', name: 'test', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'interval', seconds: 30 }, + }); + assert.ok(!('error' in auto)); + t.advanceTime(31000); + t.setInjectResult({ runId: 'run-1', ok: false, error: 'turn errored' }); + t.scheduler.start(); + await t.runTick(); + const updated = t.manager.get(auto.id); + assert.equal(updated?.consecutiveFailures, 1); + assert.equal(updated?.lastError, 'turn errored'); + }); + + test('a successful fire records the runId', async () => { + const t = createTestSetup(); + const auto = t.manager.create({ + kind: 'heartbeat', name: 'test', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'interval', seconds: 30 }, + }); + assert.ok(!('error' in auto)); + t.advanceTime(31000); + t.setInjectResult({ runId: 'run-42', ok: true }); + t.scheduler.start(); + await t.runTick(); + assert.equal(t.manager.get(auto.id)?.lastRunId, 'run-42'); + }); + + test('dispose stops the tick loop', async () => { + const t = createTestSetup(); + t.scheduler.start(); + assert.ok(t.timers.length > 0); + t.scheduler.dispose(); + assert.equal(t.timers.length, 0); + }); + + test('does not fire expired automations', async () => { + const t = createTestSetup(); + const auto = t.manager.create({ + kind: 'heartbeat', name: 'expiring', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'interval', seconds: 30 }, + expiresAt: t.getTime() + 20000, + }); + assert.ok(!('error' in auto)); + t.advanceTime(31000); + t.scheduler.start(); + await t.runTick(); + assert.equal(t.fired.length, 0); + assert.equal(t.manager.get(auto.id)?.status, 'expired'); + }); + + test('one-shot fires once then completes (on success)', async () => { + const t = createTestSetup(); + const auto = t.manager.create({ + kind: 'heartbeat', name: 'once', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'once', delaySeconds: 10 }, + }); + assert.ok(!('error' in auto)); + t.advanceTime(11000); + t.scheduler.start(); + await t.runTick(); + assert.equal(t.fired.length, 1); + assert.equal(t.manager.get(auto.id)?.status, 'completed'); + // Next tick should not fire again + t.advanceTime(11000); + await t.runTick(); + assert.equal(t.fired.length, 1); + }); + + test('cron fires via createFreshRun, not injectTurn', async () => { + const t = createTestSetup(); + const freshRuns: Array<{ prompt: string; id: string }> = []; + t.setCreateFreshRun(async (prompt, automationId) => { + freshRuns.push({ prompt, id: automationId }); + return { runId: 'fresh-1', ok: true }; + }); + const auto = t.manager.create({ + kind: 'cron', name: 'daily', prompt: 'review PRs', + sessionId: 'sess-1', schedule: { type: 'interval', seconds: 30 }, + }); + assert.ok(!('error' in auto)); + t.advanceTime(31000); + t.scheduler.start(); + await t.runTick(); + assert.equal(freshRuns.length, 1); + assert.equal(freshRuns[0].prompt, 'review PRs'); + assert.equal(freshRuns[0].id, auto.id); + assert.equal(t.fired.length, 0); + assert.equal(t.manager.get(auto.id)?.lastRunId, 'fresh-1'); + }); + + test('cron is silently ignored when createFreshRun is not provided (no state corruption)', async () => { + const t = createTestSetup(); + const auto = t.manager.create({ + kind: 'cron', name: 'daily', prompt: 'review PRs', + sessionId: 'sess-1', schedule: { type: 'interval', seconds: 30 }, + }); + assert.ok(!('error' in auto)); + const originalFireCount = auto.fireCount; + const originalNextFireAt = auto.nextFireAt; + t.advanceTime(31000); + t.scheduler.start(); + await t.runTick(); + await t.runTick(); + assert.equal(t.fired.length, 0); + const updated = t.manager.get(auto.id); + // A host without a cron executor must leave the cron COMPLETELY untouched — + // no failure, no pause, no advance — because the durable store may be shared + // with a host that CAN run it (heartbeat-only CLI + desktop share a store). + assert.equal(updated?.status, 'active'); + assert.equal(updated?.consecutiveFailures, 0); + assert.equal(updated?.lastError, null); + assert.equal(updated?.fireCount, originalFireCount); + assert.equal(updated?.nextFireAt, originalNextFireAt); + }); + + test('expired automations are swept even before nextFireAt', async () => { + const t = createTestSetup(); + const auto = t.manager.create({ + kind: 'heartbeat', name: 'expiring', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'interval', seconds: 3600 }, + expiresAt: t.getTime() + 30000, + }); + assert.ok(!('error' in auto)); + t.advanceTime(31000); + t.scheduler.start(); + await t.runTick(); + assert.equal(t.fired.length, 0); + assert.equal(t.manager.get(auto.id)?.status, 'expired'); + }); + + test('the expiry sweep leaves an expired CRON untouched when createFreshRun is absent', async () => { + // A host that cannot run cron must not mutate/persist crons at all — the + // durable store may be shared with (and owned by) a host that can, and this + // host's copy may be stale. Sweeping the cron here could clobber that store. + const t = createTestSetup(); // createFreshRun undefined → cron disabled + const cron = t.manager.create({ + kind: 'cron', name: 'daily', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'interval', seconds: 3600 }, + expiresAt: t.getTime() + 30000, + }); + assert.ok(!('error' in cron)); + // A heartbeat with the same expiry IS swept (control). + const beat = t.manager.create({ + kind: 'heartbeat', name: 'poll', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'interval', seconds: 3600 }, + expiresAt: t.getTime() + 30000, + }); + assert.ok(!('error' in beat)); + t.advanceTime(31000); + t.scheduler.start(); + await t.runTick(); + assert.equal(t.manager.get(cron.id)?.status, 'active', 'expired cron must be left untouched on a cron-disabled host'); + assert.equal(t.manager.get(beat.id)?.status, 'expired', 'heartbeat is still swept'); + }); + + test('a failed maxFires=1 fire ends failed/paused, never completed', async () => { + const t = createTestSetup(); + const auto = t.manager.create({ + kind: 'heartbeat', name: 'limited', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'interval', seconds: 30 }, + maxFires: 1, + }); + assert.ok(!('error' in auto)); + t.advanceTime(31000); + t.setInjectRejects(true); + t.scheduler.start(); + await t.runTick(); + const updated = t.manager.get(auto.id); + assert.notEqual(updated?.status, 'completed'); + }); + + test('in-flight guard: a slow cron does not re-fire concurrently', async () => { + const t = createTestSetup(); + let dispatches = 0; + let release!: (r: AutomationFireResult) => void; + // A createFreshRun that hangs until we release it — models a run slower than + // the cadence (the exact concurrency window). + t.setCreateFreshRun((_p, _id) => { + dispatches++; + return new Promise((res) => { release = (r) => res(r); }); + }); + const auto = t.manager.create({ + kind: 'cron', name: 'slow', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'interval', seconds: 10 }, + }); + assert.ok(!('error' in auto)); + t.scheduler.start(); + // Fire is due; run it multiple times while the first dispatch is still pending. + t.advanceTime(11000); + await t.runTick(); // dispatch #1 (hangs) + t.advanceTime(11000); + await t.runTick(); // due again — must be skipped (in-flight) + t.advanceTime(11000); + await t.runTick(); // still in-flight — skipped + assert.equal(dispatches, 1, 'only one fire dispatched while the run is in flight'); + // Release the run → next due tick may fire again. + release({ runId: 'r1', ok: true }); + for (let i = 0; i < 5; i++) await Promise.resolve(); + t.advanceTime(11000); + await t.runTick(); + assert.equal(dispatches, 2, 're-fires only after the prior run resolves'); + }); + + test('maxFires bounds fire ATTEMPTS even when every run fails', async () => { + const t = createTestSetup(); + const auto = t.manager.create({ + kind: 'heartbeat', name: 'flaky', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'interval', seconds: 10 }, + maxFires: 2, + }); + assert.ok(!('error' in auto)); + t.setInjectRejects(true); // every fire fails + t.scheduler.start(); + // Tick well past 2 fire windows. + for (let i = 0; i < 6; i++) { t.advanceTime(11000); await t.runTick(); } + const updated = t.manager.get(auto.id); + // Fired at most maxFires times (2), NOT up to the consecutive-failure cap (5). + assert.ok(updated!.fireCount <= 2, `fireCount=${updated!.fireCount} should be <= maxFires(2)`); + assert.equal(updated!.nextFireAt, null, 'no further fires scheduled past maxFires'); + }); +}); diff --git a/packages/runtime/src/__tests__/automation.test.ts b/packages/runtime/src/__tests__/automation.test.ts new file mode 100644 index 0000000000..57202b7f4d --- /dev/null +++ b/packages/runtime/src/__tests__/automation.test.ts @@ -0,0 +1,966 @@ +import { describe, test, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { dirname, join } from 'node:path'; +import { AutomationManager, computeNextCronFire, matchesCronField } from '../automation-state.js'; +import type { AutomationSchedule } from '../automation-state.js'; + +let idCounter = 0; +function createManager() { + idCounter = 0; + return new AutomationManager({ + generateId: () => `auto-${++idCounter}`, + now: () => 1700000000000, + }); +} + +describe('AutomationManager', () => { + describe('create', () => { + test('creates a heartbeat automation', () => { + const mgr = createManager(); + const result = mgr.create({ + kind: 'heartbeat', + name: 'check deploy', + prompt: 'Run deploy check', + sessionId: 'sess-1', + schedule: { type: 'interval', seconds: 30 }, + }); + assert.ok(!('error' in result)); + assert.equal(result.id, 'auto-1'); + assert.equal(result.kind, 'heartbeat'); + assert.equal(result.status, 'active'); + assert.equal(result.fireCount, 0); + assert.ok(result.nextFireAt); + }); + + test('creates a cron automation', () => { + const mgr = createManager(); + const result = mgr.create({ + kind: 'cron', + name: 'daily review', + prompt: 'Review PRs', + sessionId: 'sess-1', + schedule: { type: 'cron', expression: '0 9 * * 1-5' }, + }); + assert.ok(!('error' in result)); + assert.equal(result.kind, 'cron'); + }); + + test('creates a one-shot automation', () => { + const mgr = createManager(); + const result = mgr.create({ + kind: 'heartbeat', + name: 'remind me', + prompt: 'Check the thing', + sessionId: 'sess-1', + schedule: { type: 'once', delaySeconds: 300 }, + }); + assert.ok(!('error' in result)); + assert.equal(result.schedule.type, 'once'); + }); + + test('rejects when max automations reached', () => { + const mgr = createManager(); + for (let i = 0; i < 20; i++) { + mgr.create({ + kind: 'heartbeat', + name: `auto-${i}`, + prompt: 'test', + sessionId: 'sess-1', + schedule: { type: 'interval', seconds: 60 }, + }); + } + const result = mgr.create({ + kind: 'heartbeat', + name: 'overflow', + prompt: 'test', + sessionId: 'sess-1', + schedule: { type: 'interval', seconds: 60 }, + }); + assert.ok('error' in result); + assert.ok(result.error.includes('Maximum')); + }); + + test('different sessions have independent limits', () => { + const mgr = createManager(); + for (let i = 0; i < 20; i++) { + mgr.create({ + kind: 'heartbeat', + name: `auto-${i}`, + prompt: 'test', + sessionId: 'sess-1', + schedule: { type: 'interval', seconds: 60 }, + }); + } + const result = mgr.create({ + kind: 'heartbeat', + name: 'another session', + prompt: 'test', + sessionId: 'sess-2', + schedule: { type: 'interval', seconds: 60 }, + }); + assert.ok(!('error' in result)); + }); + + test('respects maxFires', () => { + const mgr = createManager(); + const result = mgr.create({ + kind: 'heartbeat', + name: 'limited', + prompt: 'test', + sessionId: 'sess-1', + schedule: { type: 'interval', seconds: 60 }, + maxFires: 3, + }); + assert.ok(!('error' in result)); + assert.equal(result.maxFires, 3); + }); + + test('cron defaults to durable (survives restart without an explicit flag)', () => { + const mgr = createManager(); + const result = mgr.create({ + kind: 'cron', name: 'daily', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'cron', expression: '0 9 * * *' }, + }); + assert.ok(!('error' in result)); + assert.equal(result.durable, true); + }); + + test('heartbeat defaults to non-durable (bound to its session)', () => { + const mgr = createManager(); + const result = mgr.create({ + kind: 'heartbeat', name: 'poll', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'interval', seconds: 60 }, + }); + assert.ok(!('error' in result)); + assert.ok(!result.durable); + }); + + test('explicit durable refines cron; heartbeat is always session-bound', () => { + const mgr = createManager(); + const cron = mgr.create({ + kind: 'cron', name: 'ephemeral-cron', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'cron', expression: '0 9 * * *' }, + durable: false, + }); + assert.ok(!('error' in cron)); + assert.ok(!cron.durable); + // durable is a cron-only concept — a heartbeat cannot opt into it. + const beat = mgr.create({ + kind: 'heartbeat', name: 'durable-beat', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'interval', seconds: 60 }, + durable: true, + }); + assert.ok(!('error' in beat)); + assert.ok(!beat.durable); + }); + }); + + describe('delete', () => { + test('deletes own automation', () => { + const mgr = createManager(); + const auto = mgr.create({ + kind: 'heartbeat', name: 'test', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'interval', seconds: 60 }, + }); + assert.ok(!('error' in auto)); + assert.equal(mgr.delete(auto.id, 'sess-1'), true); + assert.equal(mgr.get(auto.id), undefined); + }); + + test('cannot delete another sessions automation', () => { + const mgr = createManager(); + const auto = mgr.create({ + kind: 'heartbeat', name: 'test', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'interval', seconds: 60 }, + }); + assert.ok(!('error' in auto)); + assert.equal(mgr.delete(auto.id, 'sess-2'), false); + }); + }); + + describe('durable automations are app-global (queryable + manageable across sessions)', () => { + // A durable cron persisted from one session must remain visible and + // manageable from a *different* session after a restart re-homes it under + // its original sessionId. Non-durable heartbeats stay session-private. + function makeDurableCron(mgr: ReturnType, sessionId = 'creator-sess') { + const auto = mgr.create({ + kind: 'cron', name: 'nightly backup', prompt: 'back up', + sessionId, schedule: { type: 'cron', expression: '0 3 * * *' }, + }); + assert.ok(!('error' in auto)); + return auto as Extract; + } + + test('listVisibleForSession surfaces durable automations owned by another session', () => { + const mgr = createManager(); + makeDurableCron(mgr, 'creator-sess'); + // A brand-new session (as after a restart) sees the persisted cron. + const visible = mgr.listVisibleForSession('fresh-sess'); + assert.equal(visible.length, 1); + assert.equal(visible[0].name, 'nightly backup'); + }); + + test('a non-durable heartbeat stays private to its session', () => { + const mgr = createManager(); + const beat = mgr.create({ + kind: 'heartbeat', name: 'poll', prompt: 'p', + sessionId: 'creator-sess', schedule: { type: 'interval', seconds: 60 }, + }); + assert.ok(!('error' in beat)); + assert.equal(mgr.listVisibleForSession('other-sess').length, 0); + // …and cannot be managed from another session. + assert.equal(mgr.pause((beat as { id: string }).id, 'other-sess'), undefined); + }); + + test('pause / resume / delete a durable cron from a different session', () => { + const mgr = createManager(); + const cron = makeDurableCron(mgr, 'creator-sess'); + // Pause from a fresh session. + assert.equal(mgr.pause(cron.id, 'fresh-sess')?.status, 'paused'); + // Resume from yet another session. + assert.equal(mgr.resume(cron.id, 'another-sess')?.status, 'active'); + // Delete from a fresh session. + assert.equal(mgr.delete(cron.id, 'fresh-sess'), true); + assert.equal(mgr.get(cron.id), undefined); + }); + + test('global durables do not count against a new session create limit', () => { + const mgr = createManager(); + // Fill the store with durable crons owned by an old session. + for (let i = 0; i < 20; i++) { + const a = mgr.create({ + kind: 'cron', name: `c${i}`, prompt: 'p', + sessionId: 'old-sess', schedule: { type: 'cron', expression: '0 3 * * *' }, + }); + assert.ok(!('error' in a)); + } + // A fresh session can still create its own — the per-session cap counts + // only session-owned automations, not the global durable ones it can see. + const mine = mgr.create({ + kind: 'heartbeat', name: 'mine', prompt: 'p', + sessionId: 'fresh-sess', schedule: { type: 'interval', seconds: 60 }, + }); + assert.ok(!('error' in mine)); + }); + }); + + describe('pause and resume', () => { + test('pause sets status to paused', () => { + const mgr = createManager(); + const auto = mgr.create({ + kind: 'heartbeat', name: 'test', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'interval', seconds: 60 }, + }); + assert.ok(!('error' in auto)); + const paused = mgr.pause(auto.id, 'sess-1'); + assert.equal(paused?.status, 'paused'); + }); + + test('resume reactivates paused automation', () => { + const mgr = createManager(); + const auto = mgr.create({ + kind: 'heartbeat', name: 'test', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'interval', seconds: 60 }, + }); + assert.ok(!('error' in auto)); + mgr.pause(auto.id, 'sess-1'); + const resumed = mgr.resume(auto.id, 'sess-1'); + assert.equal(resumed?.status, 'active'); + assert.ok(resumed?.nextFireAt); + }); + + test('cannot pause already paused', () => { + const mgr = createManager(); + const auto = mgr.create({ + kind: 'heartbeat', name: 'test', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'interval', seconds: 60 }, + }); + assert.ok(!('error' in auto)); + mgr.pause(auto.id, 'sess-1'); + assert.equal(mgr.pause(auto.id, 'sess-1'), undefined); + }); + + test('resume refuses to re-arm a maxFires-exhausted automation (no fire beyond the hard cap)', () => { + const mgr = createManager(); + const auto = mgr.create({ + kind: 'cron', name: 'capped', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'cron', expression: '* * * * *' }, + maxFires: 1, + }); + assert.ok(!('error' in auto)); + // The single allowed fire starts (fireCount=1, cap nulls nextFireAt)… + const started = mgr.attemptStarted(auto.id); + assert.equal(started?.fireCount, 1); + assert.equal(started?.nextFireAt, null); + // …then FAILS, settling to paused (the resumable-but-spent trap). + mgr.attemptFailed(auto.id, 'boom'); + assert.equal(mgr.get(auto.id)?.status, 'paused'); + // resume must NOT revive the spent budget. + const resumed = mgr.resume(auto.id, 'sess-1'); + assert.equal(resumed, undefined); + assert.equal(mgr.get(auto.id)?.status, 'paused'); + assert.equal(mgr.get(auto.id)?.nextFireAt, null); + }); + + test('resume refuses to re-fire a one-shot that already fired', () => { + const mgr = createManager(); + const auto = mgr.create({ + kind: 'cron', name: 'once', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'once', delaySeconds: 30 }, + }); + assert.ok(!('error' in auto)); + mgr.attemptStarted(auto.id); + mgr.attemptFailed(auto.id, 'boom'); + assert.equal(mgr.get(auto.id)?.status, 'paused'); + assert.equal(mgr.resume(auto.id, 'sess-1'), undefined); + assert.equal(mgr.get(auto.id)?.nextFireAt, null); + }); + }); + + describe('markFired', () => { + test('increments fireCount and updates nextFireAt', () => { + const mgr = createManager(); + const auto = mgr.create({ + kind: 'heartbeat', name: 'test', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'interval', seconds: 60 }, + }); + assert.ok(!('error' in auto)); + const fired = mgr.attemptStarted(auto.id); + assert.equal(fired?.fireCount, 1); + assert.ok(fired?.nextFireAt); + assert.ok(fired?.lastFireAt); + }); + + test('one-shot completes after a successful fire', () => { + const mgr = createManager(); + const auto = mgr.create({ + kind: 'heartbeat', name: 'once', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'once', delaySeconds: 30 }, + }); + assert.ok(!('error' in auto)); + // Started nulls nextFireAt but stays active until the outcome is known. + const started = mgr.attemptStarted(auto.id); + assert.equal(started?.status, 'active'); + assert.equal(started?.nextFireAt, null); + mgr.attemptSucceeded(auto.id, 'run-1'); + assert.equal(mgr.get(auto.id)?.status, 'completed'); + assert.equal(mgr.get(auto.id)?.lastRunId, 'run-1'); + }); + + test('maxFires completes on the successful fire that reaches the cap', () => { + const mgr = createManager(); + const auto = mgr.create({ + kind: 'heartbeat', name: 'limited', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'interval', seconds: 60 }, + maxFires: 2, + }); + assert.ok(!('error' in auto)); + mgr.attemptStarted(auto.id); + mgr.attemptSucceeded(auto.id); + assert.equal(mgr.get(auto.id)?.status, 'active'); // 1/2 + mgr.attemptStarted(auto.id); + mgr.attemptSucceeded(auto.id); + assert.equal(mgr.get(auto.id)?.status, 'completed'); // 2/2 + }); + + test('a failed fire does NOT complete (even at maxFires)', () => { + const mgr = createManager(); + const auto = mgr.create({ + kind: 'heartbeat', name: 'limited', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'interval', seconds: 60 }, + maxFires: 1, + }); + assert.ok(!('error' in auto)); + mgr.attemptStarted(auto.id); + mgr.attemptFailed(auto.id, 'boom'); + // Not 'completed' — a failure never masquerades as success. + assert.notEqual(mgr.get(auto.id)?.status, 'completed'); + }); + + test('does not fire paused automation', () => { + const mgr = createManager(); + const auto = mgr.create({ + kind: 'heartbeat', name: 'test', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'interval', seconds: 60 }, + }); + assert.ok(!('error' in auto)); + mgr.pause(auto.id, 'sess-1'); + assert.equal(mgr.attemptStarted(auto.id), undefined); + }); + }); + + describe('attemptFailed', () => { + test('increments consecutiveFailures', () => { + const mgr = createManager(); + const auto = mgr.create({ + kind: 'heartbeat', name: 'test', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'interval', seconds: 60 }, + }); + assert.ok(!('error' in auto)); + mgr.attemptFailed(auto.id, 'timeout'); + assert.equal(mgr.get(auto.id)?.consecutiveFailures, 1); + assert.equal(mgr.get(auto.id)?.lastError, 'timeout'); + }); + + test('auto-pauses after MAX_CONSECUTIVE_FAILURES', () => { + const mgr = createManager(); + const auto = mgr.create({ + kind: 'heartbeat', name: 'test', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'interval', seconds: 60 }, + }); + assert.ok(!('error' in auto)); + for (let i = 0; i < 5; i++) mgr.attemptFailed(auto.id, 'fail'); + assert.equal(mgr.get(auto.id)?.status, 'paused'); + }); + + test('a one-shot failure pauses (visible, not a silent zombie)', () => { + const mgr = createManager(); + const auto = mgr.create({ + kind: 'heartbeat', name: 'once', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'once', delaySeconds: 10 }, + }); + assert.ok(!('error' in auto)); + mgr.attemptStarted(auto.id); // nextFireAt → null + mgr.attemptFailed(auto.id, 'boom'); + assert.equal(mgr.get(auto.id)?.status, 'paused'); + }); + + test('attemptSucceeded resets failure count', () => { + const mgr = createManager(); + const auto = mgr.create({ + kind: 'heartbeat', name: 'test', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'interval', seconds: 60 }, + }); + assert.ok(!('error' in auto)); + mgr.attemptFailed(auto.id, 'fail'); + mgr.attemptFailed(auto.id, 'fail'); + mgr.attemptSucceeded(auto.id); + assert.equal(mgr.get(auto.id)?.consecutiveFailures, 0); + assert.equal(mgr.get(auto.id)?.lastError, null); + }); + }); + + describe('removeAllForSession', () => { + test('removes heartbeat automations only', () => { + const mgr = createManager(); + mgr.create({ kind: 'heartbeat', name: 'h1', prompt: 'p', sessionId: 's1', schedule: { type: 'interval', seconds: 60 } }); + mgr.create({ kind: 'cron', name: 'c1', prompt: 'p', sessionId: 's1', schedule: { type: 'cron', expression: '0 9 * * *' } }); + const removed = mgr.removeAllForSession('s1'); + assert.equal(removed, 1); + assert.equal(mgr.listForSession('s1').length, 1); + assert.equal(mgr.listForSession('s1')[0].kind, 'cron'); + }); + }); + + describe('registerAll — restart recovery', () => { + function load(mgr: ReturnType, over: Partial>) { + const base = { + id: 'loaded', kind: 'cron', name: 'c', status: 'active', prompt: 'p', sessionId: 's1', + schedule: { type: 'cron', expression: '0 9 * * *' }, createdAt: 0, updatedAt: 0, + nextFireAt: null, lastFireAt: null, lastRunId: null, fireCount: 0, maxFires: null, + expiresAt: null, lastError: null, consecutiveFailures: 0, durable: true, + }; + mgr.registerAll([{ ...base, ...over }] as never); + return mgr.get('loaded'); + } + + test('re-arms a corrupt recurring automation (active + nextFireAt=null, budget not spent)', () => { + // A recurring automation should always carry a future fire time; a null + // one is a corrupt/interrupted state → re-arm rather than leave a zombie. + const healed = load(createManager(), { status: 'active', nextFireAt: null, fireCount: 1 }); + assert.ok(healed?.nextFireAt, 'corrupt recurring automation should be re-armed on load'); + assert.equal(healed?.status, 'active'); + }); + + test('settles a spent-maxFires interrupted fire to completed (at-most-once, no re-run)', () => { + const settled = load(createManager(), { status: 'active', nextFireAt: null, fireCount: 3, maxFires: 3 }); + assert.equal(settled?.status, 'completed'); + assert.equal(settled?.nextFireAt, null); + // Surfaces the uncertainty rather than asserting a clean success. + assert.ok(settled?.lastError, 'an interrupted-then-settled fire must record its unknown outcome'); + }); + + test('settles an interrupted once fire to completed (no drift, no re-run)', () => { + const settled = load(createManager(), { + status: 'active', nextFireAt: null, fireCount: 1, + schedule: { type: 'once', delaySeconds: 30 }, + }); + assert.equal(settled?.status, 'completed'); + assert.equal(settled?.nextFireAt, null); + }); + + test('leaves a normally-scheduled automation untouched', () => { + const kept = load(createManager(), { status: 'active', nextFireAt: 999999 }); + assert.equal(kept?.nextFireAt, 999999); + }); + }); + + describe('resume — streak reset', () => { + test('resume clears consecutiveFailures so one later failure does not re-pause', () => { + const mgr = createManager(); + const auto = mgr.create({ + kind: 'cron', name: 'flaky', prompt: 'p', + sessionId: 's1', schedule: { type: 'cron', expression: '* * * * *' }, + }); + assert.ok(!('error' in auto)); + const id = (auto as { id: string }).id; + // Accumulate failures short of the pause threshold, then pause + resume. + mgr.attemptStarted(id); mgr.attemptFailed(id, 'boom'); + mgr.attemptStarted(id); mgr.attemptFailed(id, 'boom'); + assert.equal(mgr.get(id)?.consecutiveFailures, 2); + mgr.pause(id, 's1'); + const resumed = mgr.resume(id, 's1'); + assert.equal(resumed?.consecutiveFailures, 0, 'resume must reset the failure streak'); + assert.equal(resumed?.lastError, null); + }); + }); + + describe('skipFire', () => { + test('advances a recurring automation to its next slot', () => { + const mgr = createManager(); + const auto = mgr.create({ + kind: 'cron', name: 'daily', prompt: 'p', + sessionId: 's1', schedule: { type: 'interval', seconds: 60 }, + }); + assert.ok(!('error' in auto)); + const id = (auto as { id: string }).id; + const before = mgr.get(id)?.nextFireAt; + mgr.skipFire(id); + const after = mgr.get(id); + assert.equal(after?.status, 'active'); + assert.ok(after?.nextFireAt && before && after.nextFireAt >= before); + }); + + test('a skipped once is settled terminally (no drift, not re-armed)', () => { + const mgr = createManager(); + const auto = mgr.create({ + kind: 'cron', name: 'remind', prompt: 'p', + sessionId: 's1', schedule: { type: 'once', delaySeconds: 30 }, + }); + assert.ok(!('error' in auto)); + const id = (auto as { id: string }).id; + mgr.skipFire(id); + const after = mgr.get(id); + assert.equal(after?.status, 'expired', 'a skipped one-shot must not drift forward'); + assert.equal(after?.nextFireAt, null); + // Idempotent: skipping again does nothing (already terminal). + mgr.skipFire(id); + assert.equal(mgr.get(id)?.status, 'expired'); + }); + }); + + describe('dispose', () => { + test('clears all automations', () => { + const mgr = createManager(); + mgr.create({ kind: 'heartbeat', name: 'h1', prompt: 'p', sessionId: 's1', schedule: { type: 'interval', seconds: 60 } }); + mgr.create({ kind: 'cron', name: 'c1', prompt: 'p', sessionId: 's2', schedule: { type: 'cron', expression: '0 9 * * *' } }); + mgr.dispose(); + assert.equal(mgr.listActive().length, 0); + }); + }); +}); + +describe('computeNextCronFire', () => { + test('every 5 minutes', () => { + const base = new Date('2026-07-06T10:00:00').getTime(); + const next = computeNextCronFire('*/5 * * * *', base); + assert.ok(next); + const d = new Date(next!); + assert.equal(d.getMinutes() % 5, 0); + assert.ok(next! > base); + }); + + describe('validation + named tokens (O(1), no multi-second scan)', () => { + test('named weekday MON resolves to Monday', () => { + const base = new Date('2026-07-06T08:00:00').getTime(); // 2026-07-06 is a Monday + const named = computeNextCronFire('0 9 * * MON', base); + const numeric = computeNextCronFire('0 9 * * 1', base); + assert.ok(named); + assert.equal(named, numeric, 'MON must resolve identically to 1'); + assert.equal(new Date(named!).getDay(), 1); + }); + + test('named month JAN resolves to January; case-insensitive', () => { + const base = new Date('2026-07-06T00:00:00').getTime(); + const next = computeNextCronFire('0 0 1 jan *', base); + assert.ok(next); + assert.equal(new Date(next!).getMonth(), 0); // January + assert.equal(new Date(next!).getDate(), 1); + }); + + test('named weekday range MON-FRI fires on a weekday', () => { + const base = new Date('2026-07-06T00:00:00').getTime(); + const next = computeNextCronFire('0 9 * * mon-fri', base); + assert.ok(next); + const dow = new Date(next!).getDay(); + assert.ok(dow >= 1 && dow <= 5); + }); + + test('out-of-range fields are rejected in O(1)', () => { + const base = Date.now(); + const start = Date.now(); + assert.equal(computeNextCronFire('0 9 32 * *', base), null); // day 32 + assert.equal(computeNextCronFire('0 25 * * *', base), null); // hour 25 + assert.equal(computeNextCronFire('0 9 * 13 *', base), null); // month 13 + assert.equal(computeNextCronFire('0 9 * * BADTOKEN', base), null); + assert.ok(Date.now() - start < 100, 'invalid expressions must fail fast, not scan'); + }); + + test('impossible calendar dates fail fast (no 8-year scan)', () => { + const base = Date.now(); + const start = Date.now(); + assert.equal(computeNextCronFire('0 0 30 2 *', base), null); // Feb 30 + assert.equal(computeNextCronFire('0 0 31 4 *', base), null); // Apr 31 + assert.ok(Date.now() - start < 100, 'impossible dates must fail fast'); + }); + + test('impossible dom is NOT rejected when dow is also restricted (Vixie OR)', () => { + // `0 0 30 2 5` = Feb 30 (impossible) OR any Friday in Feb (valid) → fires. + const base = new Date('2026-01-01T00:00:00').getTime(); + const next = computeNextCronFire('0 0 30 2 5', base); + assert.ok(next, 'must still fire on Fridays in February'); + const d = new Date(next!); + assert.equal(d.getMonth(), 1); // February + assert.equal(d.getDay(), 5); // Friday + }); + + test('DST fall-back: next fire is strictly after fromTime (regression: no re-fire storm)', () => { + // Under America/New_York, 2026-11-01T06:30Z is inside the REPEATED (fall-back) + // local hour. A local wall-clock round-trip would shift the scan start ~59min + // before fromTime, returning a candidate <= fromTime → the scheduler would + // re-fire every tick for the whole hour. Run in a child with TZ set; the + // snippet exits non-zero (→ execFileSync throws) if strictly-after is violated. + const modUrl = pathToFileURL(join(dirname(fileURLToPath(import.meta.url)), '..', 'automation-state.js')).href; + const snippet = + `import(${JSON.stringify(modUrl)}).then(m => {` + + `const from = Date.parse('2026-11-01T06:30:00Z');` + + `const next = m.computeNextCronFire('30 1 * * *', from);` + + `process.exit(typeof next === 'number' && next > from ? 0 : 1);` + + `}).catch(() => process.exit(2));`; + assert.doesNotThrow(() => execFileSync(process.execPath, ['--input-type=module', '-e', snippet], { + env: { ...process.env, TZ: 'America/New_York' }, + stdio: 'pipe', + })); + }); + }); + + test('specific time (9:30)', () => { + const base = new Date('2026-07-06T08:00:00').getTime(); + const next = computeNextCronFire('30 9 * * *', base); + assert.ok(next); + const d = new Date(next!); + assert.equal(d.getHours(), 9); + assert.equal(d.getMinutes(), 30); + }); + + test('weekdays only', () => { + // 2026-07-06 is a Monday + const base = new Date('2026-07-06T10:00:00').getTime(); + const next = computeNextCronFire('0 9 * * 1-5', base); + assert.ok(next); + const d = new Date(next!); + const dow = d.getDay(); + assert.ok(dow >= 1 && dow <= 5); + }); + + test('returns null for invalid expression', () => { + assert.equal(computeNextCronFire('invalid', Date.now()), null); + }); + + test('handles range in field', () => { + const base = new Date('2026-07-06T00:00:00').getTime(); + const next = computeNextCronFire('0 9-17 * * *', base); + assert.ok(next); + const d = new Date(next!); + assert.ok(d.getHours() >= 9 && d.getHours() <= 17); + }); + + test('handles comma-separated values', () => { + const base = new Date('2026-07-06T00:00:00').getTime(); + const next = computeNextCronFire('0,30 * * * *', base); + assert.ok(next); + const d = new Date(next!); + assert.ok(d.getMinutes() === 0 || d.getMinutes() === 30); + }); + + test('range/step 10-30/5 only matches 10,15,20,25,30', () => { + const base = new Date('2026-07-06T10:00:00').getTime(); + const results: number[] = []; + let cursor = base; + for (let i = 0; i < 10; i++) { + const next = computeNextCronFire('10-30/5 * * * *', cursor); + if (!next) break; + results.push(new Date(next).getMinutes()); + cursor = next; + } + for (const min of results) { + assert.ok(min >= 10 && min <= 30, `minute ${min} should be in range 10-30`); + assert.equal((min - 10) % 5, 0, `minute ${min} should be step of 5 from 10`); + } + }); + + test('range/step */10 matches 0,10,20,30,40,50', () => { + const base = new Date('2026-07-06T10:00:00').getTime(); + const next = computeNextCronFire('*/10 * * * *', base); + assert.ok(next); + const min = new Date(next!).getMinutes(); + assert.equal(min % 10, 0); + }); + + test('range/step 5-15/3 does not match 18,21,24...', () => { + // Verify values outside the range don't match + assert.equal(matchesCronField('5-15/3', 18, 0, 59), false); + assert.equal(matchesCronField('5-15/3', 21, 0, 59), false); + assert.equal(matchesCronField('5-15/3', 5, 0, 59), true); + assert.equal(matchesCronField('5-15/3', 8, 0, 59), true); + assert.equal(matchesCronField('5-15/3', 11, 0, 59), true); + assert.equal(matchesCronField('5-15/3', 14, 0, 59), true); + assert.equal(matchesCronField('5-15/3', 15, 0, 59), false); // 15-5=10, 10%3≠0 + }); + + test('timestamps are on clean minute boundaries', () => { + const base = new Date('2026-07-06T10:00:37.123').getTime(); + const next = computeNextCronFire('*/5 * * * *', base); + assert.ok(next); + const d = new Date(next!); + assert.equal(d.getSeconds(), 0); + assert.equal(d.getMilliseconds(), 0); + }); + + // --- Bug 1: sparse annual crons must resolve within a bounded window --- + + test('sparse annual cron 0 0 29 2 * resolves to Feb 29 in a leap year (not null)', () => { + const base = new Date('2026-07-06T10:00:00').getTime(); + const next = computeNextCronFire('0 0 29 2 *', base); + assert.ok(next, 'Feb 29 cron should resolve within the extended search window'); + const d = new Date(next!); + assert.equal(d.getMonth(), 1, 'month should be February (0-indexed 1)'); + assert.equal(d.getDate(), 29, 'day should be the 29th'); + assert.equal(d.getHours(), 0); + assert.equal(d.getMinutes(), 0); + const y = d.getFullYear(); + const isLeap = (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0; + assert.ok(isLeap, `${y} should be a leap year`); + assert.ok(next! > base); + }); + + test('impossible cron 0 0 30 2 * returns null (bounded, no infinite loop)', () => { + const base = new Date('2026-07-06T10:00:00').getTime(); + const next = computeNextCronFire('0 0 30 2 *', base); + assert.equal(next, null, 'Feb 30 never occurs, must return null after bounded search'); + }); + + // --- Bug 2: dom + dow are OR (not AND) when BOTH fields are restricted --- + + test('dom+dow OR: 0 0 13 * 5 matches the 13th OR any Friday (not Friday-the-13th)', () => { + const base = new Date('2026-07-06T10:00:00').getTime(); + const fires: Date[] = []; + let cursor = base; + for (let i = 0; i < 8; i++) { + const next = computeNextCronFire('0 0 13 * 5', cursor); + assert.ok(next); + fires.push(new Date(next!)); + cursor = next!; + } + // Every fire is at midnight and is either the 13th OR a Friday (dow 5). + for (const d of fires) { + assert.equal(d.getHours(), 0); + assert.equal(d.getMinutes(), 0); + assert.ok( + d.getDate() === 13 || d.getDay() === 5, + `${d.toISOString()} should be the 13th or a Friday`, + ); + } + // Proves OR (not AND): a Friday that is NOT the 13th must appear ... + assert.ok( + fires.some(d => d.getDay() === 5 && d.getDate() !== 13), + 'expected at least one Friday that is not the 13th', + ); + // ... and a 13th that is NOT a Friday must appear. + assert.ok( + fires.some(d => d.getDate() === 13 && d.getDay() !== 5), + 'expected at least one 13th that is not a Friday', + ); + }); + + test('dom-only 0 0 13 * * matches only the 13th (dow unrestricted → AND)', () => { + const base = new Date('2026-07-06T10:00:00').getTime(); + let cursor = base; + for (let i = 0; i < 4; i++) { + const next = computeNextCronFire('0 0 13 * *', cursor); + assert.ok(next); + const d = new Date(next!); + assert.equal(d.getDate(), 13, `${d.toISOString()} should be the 13th`); + assert.equal(d.getHours(), 0); + assert.equal(d.getMinutes(), 0); + cursor = next!; + } + }); + + test('dow-only 0 0 * * 5 matches only Fridays (dom unrestricted → AND)', () => { + const base = new Date('2026-07-06T10:00:00').getTime(); + let cursor = base; + for (let i = 0; i < 4; i++) { + const next = computeNextCronFire('0 0 * * 5', cursor); + assert.ok(next); + const d = new Date(next!); + assert.equal(d.getDay(), 5, `${d.toISOString()} should be a Friday`); + assert.equal(d.getHours(), 0); + assert.equal(d.getMinutes(), 0); + cursor = next!; + } + }); + + test('dow=7 matches Sundays (cron allows 0 OR 7 for Sunday)', () => { + const base = new Date('2026-07-06T10:00:00').getTime(); // Monday + for (const field of ['0 0 * * 7', '0 0 * * 0', '0 0 * * 5-7', '0 0 * * 0,3']) { + const next = computeNextCronFire(field, base); + assert.ok(next, `${field} should resolve`); + } + // "* * * * 7" must actually land on a Sunday. + const sun = computeNextCronFire('0 0 * * 7', base); + assert.equal(new Date(sun!).getDay(), 0, 'dow=7 lands on Sunday (getDay()===0)'); + // "5-7" (Fri/Sat/Sun) includes Sunday. + let cursor = base; + let sawSunday = false; + for (let i = 0; i < 6; i++) { + const n = computeNextCronFire('0 0 * * 5-7', cursor); + if (n && new Date(n).getDay() === 0) sawSunday = true; + cursor = n ?? cursor; + } + assert.ok(sawSunday, '5-7 range includes Sunday'); + }); + + // --- Regression: common crons keep working after the OR/window changes --- + + test('regression: */5 * * * * still fires every 5 minutes', () => { + const base = new Date('2026-07-06T10:02:00').getTime(); + const next = computeNextCronFire('*/5 * * * *', base); + assert.ok(next); + const d = new Date(next!); + assert.equal(d.getMinutes() % 5, 0); + assert.equal(d.getMinutes(), 5); + }); + + test('regression: 0 9 * * 1-5 still fires 09:00 on weekdays only', () => { + const base = new Date('2026-07-06T10:00:00').getTime(); + let cursor = base; + for (let i = 0; i < 6; i++) { + const next = computeNextCronFire('0 9 * * 1-5', cursor); + assert.ok(next); + const d = new Date(next!); + assert.equal(d.getHours(), 9); + assert.equal(d.getMinutes(), 0); + const dow = d.getDay(); + assert.ok(dow >= 1 && dow <= 5, `${d.toISOString()} should be Mon-Fri`); + cursor = next!; + } + }); + + test('regression: 10-30/5 * * * * only matches 10,15,20,25,30', () => { + const base = new Date('2026-07-06T10:00:00').getTime(); + let cursor = base; + for (let i = 0; i < 12; i++) { + const next = computeNextCronFire('10-30/5 * * * *', cursor); + assert.ok(next); + const min = new Date(next!).getMinutes(); + assert.ok(min >= 10 && min <= 30, `minute ${min} should be in range 10-30`); + assert.equal((min - 10) % 5, 0, `minute ${min} should be step of 5 from 10`); + cursor = next!; + } + }); +}); + +describe('AutomationManager edge cases', () => { + test('create rejects invalid cron expression', () => { + const mgr = createManager(); + const result = mgr.create({ + kind: 'heartbeat', name: 'bad cron', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'cron', expression: 'not valid' }, + }); + assert.ok('error' in result); + assert.ok(result.error.includes('Invalid cron')); + }); + + test('pruneTerminal removes old completed automations', () => { + const mgr = createManager(); + // Create and complete 10 automations + for (let i = 0; i < 10; i++) { + const auto = mgr.create({ + kind: 'heartbeat', name: `auto-${i}`, prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'once', delaySeconds: 10 }, + }); + assert.ok(!('error' in auto)); + mgr.attemptStarted(auto.id); + mgr.attemptSucceeded(auto.id); + } + // Pruning is triggered on next create + mgr.create({ + kind: 'heartbeat', name: 'trigger-prune', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'interval', seconds: 60 }, + }); + const all = mgr.listForSession('sess-1'); + const completed = all.filter(a => a.status === 'completed'); + assert.ok(completed.length <= 5, `Expected <=5 completed, got ${completed.length}`); + }); + + test('skipFire advances nextFireAt without incrementing fireCount', () => { + let time = 1700000000000; + const mgr = new AutomationManager({ + generateId: () => 'skip-test', + now: () => time, + }); + const auto = mgr.create({ + kind: 'heartbeat', name: 'skip test', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'interval', seconds: 60 }, + }); + assert.ok(!('error' in auto)); + const originalNext = auto.nextFireAt!; + // Advance time so skipFire computes a different nextFireAt + time += 30000; + mgr.skipFire(auto.id); + const updated = mgr.get(auto.id)!; + assert.ok(updated.nextFireAt! > originalNext, `expected ${updated.nextFireAt} > ${originalNext}`); + assert.equal(updated.fireCount, 0); + }); + + test('attemptFailed does not overwrite completed status', () => { + const mgr = createManager(); + const auto = mgr.create({ + kind: 'heartbeat', name: 'terminal', prompt: 'p', + sessionId: 'sess-1', schedule: { type: 'once', delaySeconds: 10 }, + }); + assert.ok(!('error' in auto)); + mgr.attemptStarted(auto.id); + mgr.attemptSucceeded(auto.id); // completes (one-shot) + mgr.attemptFailed(auto.id, 'should not change status'); + assert.equal(mgr.get(auto.id)?.status, 'completed'); + }); + + test('listAll returns all automations regardless of status', () => { + const mgr = createManager(); + mgr.create({ kind: 'heartbeat', name: 'active', prompt: 'p', sessionId: 's1', schedule: { type: 'interval', seconds: 60 } }); + const once = mgr.create({ kind: 'heartbeat', name: 'done', prompt: 'p', sessionId: 's1', schedule: { type: 'once', delaySeconds: 10 } }); + assert.ok(!('error' in once)); + mgr.attemptStarted(once.id); + mgr.attemptSucceeded(once.id); + + const all = mgr.listAll(); + assert.ok(all.length >= 2); + const statuses = all.map(a => a.status); + assert.ok(statuses.includes('active')); + assert.ok(statuses.includes('completed')); + }); + + test('registerAll bulk-loads automations', () => { + const mgr = createManager(); + mgr.registerAll([ + { id: 'loaded-1', kind: 'heartbeat', name: 'a', status: 'active', prompt: 'p', sessionId: 's1', schedule: { type: 'interval', seconds: 60 }, createdAt: 0, updatedAt: 0, nextFireAt: 999, lastFireAt: null, lastRunId: null, fireCount: 0, maxFires: null, expiresAt: null, lastError: null, consecutiveFailures: 0 }, + { id: 'loaded-2', kind: 'cron', name: 'b', status: 'paused', prompt: 'p', sessionId: 's1', schedule: { type: 'cron', expression: '0 9 * * *' }, createdAt: 0, updatedAt: 0, nextFireAt: 999, lastFireAt: null, lastRunId: null, fireCount: 0, maxFires: null, expiresAt: null, lastError: null, consecutiveFailures: 0 }, + ]); + assert.equal(mgr.get('loaded-1')?.name, 'a'); + assert.equal(mgr.get('loaded-2')?.status, 'paused'); + }); +}); diff --git a/packages/runtime/src/__tests__/wakeup-scheduler.test.ts b/packages/runtime/src/__tests__/wakeup-scheduler.test.ts deleted file mode 100644 index 97f6758234..0000000000 --- a/packages/runtime/src/__tests__/wakeup-scheduler.test.ts +++ /dev/null @@ -1,461 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; -import { WakeupScheduler, computeNextCronRun, computeJitter, MAX_RECORDS_PER_SESSION } from '../wakeup-scheduler.js'; - -function createTestScheduler(overrides: Partial[0]> = {}) { - const fired: Array<{ sessionId: string; turnId: string; text: string }> = []; - const timers: Array<{ id: number; cb: () => void; ms: number; cleared: boolean }> = []; - let nextId = 1; - let mockNow = 1000000; - - const scheduler = new WakeupScheduler({ - newId: () => `id-${nextId++}`, - now: () => mockNow, - injectTurn: (sessionId, input) => { fired.push({ sessionId, ...input }); }, - canFire: async () => true, - setTimer: (cb, ms) => { - const timer = { id: timers.length + 1, cb, ms, cleared: false }; - timers.push(timer); - return timer.id as unknown as ReturnType; - }, - clearTimer: (id) => { - const timer = timers.find(t => t.id === (id as unknown as number)); - if (timer) timer.cleared = true; - }, - ...overrides, - }); - - return { - scheduler, - fired, - timers, - advanceTime: (ms: number) => { mockNow += ms; }, - fireNextTimer: async () => { - const pending = timers.find(t => !t.cleared); - if (pending) { - pending.cleared = true; - mockNow += pending.ms; - pending.cb(); - await new Promise(resolve => setTimeout(resolve, 0)); - } - }, - }; -} - -describe('WakeupScheduler', () => { - test('schedule creates a pending record and sets a timer', () => { - const { scheduler, timers } = createTestScheduler(); - const record = scheduler.schedule('session-1', { - delaySeconds: 60, - message: 'check status', - reason: 'polling deployment', - }); - assert.equal(record.status, 'pending'); - assert.equal(record.sessionId, 'session-1'); - assert.equal(record.message, 'check status'); - assert.equal(timers.length, 1); - assert.equal(timers[0].ms, 60000); - }); - - test('timer fire injects a turn with the message', async () => { - const { scheduler, fired, fireNextTimer } = createTestScheduler(); - scheduler.schedule('session-1', { delaySeconds: 10, message: 'hello wakeup', reason: 'test' }); - await fireNextTimer(); - assert.equal(fired.length, 1); - assert.equal(fired[0].sessionId, 'session-1'); - assert.ok(fired[0].text.includes('hello wakeup')); - }); - - test('fired non-recurring record stays listed as terminal history', async () => { - const { scheduler, fireNextTimer } = createTestScheduler(); - const record = scheduler.schedule('session-1', { delaySeconds: 5, message: 'check', reason: 'test' }); - await fireNextTimer(); - assert.equal(record.status, 'fired'); - // Review fix: terminal records stay observable (capped by pruneSession) - // so CronList can show what just fired. - const records = scheduler.listForSession('session-1'); - assert.equal(records.length, 1, 'fired record should stay as terminal history'); - assert.equal(records[0]!.status, 'fired'); - }); - - test('cancel prevents firing', () => { - const { scheduler, fired, timers } = createTestScheduler(); - const record = scheduler.schedule('session-1', { delaySeconds: 30, message: 'should not fire', reason: 'test' }); - const cancelled = scheduler.cancel(record.id); - assert.equal(cancelled, true); - assert.ok(timers[0].cleared); - assert.equal(fired.length, 0); - assert.equal(scheduler.listForSession('session-1')[0].status, 'cancelled'); - }); - - test('cancelAllForSession cancels all pending wakeups', () => { - const { scheduler } = createTestScheduler(); - scheduler.schedule('session-1', { delaySeconds: 10, message: 'a', reason: 'test' }); - scheduler.schedule('session-1', { delaySeconds: 20, message: 'b', reason: 'test' }); - scheduler.schedule('session-2', { delaySeconds: 30, message: 'c', reason: 'test' }); - scheduler.cancelAllForSession('session-1'); - const s1 = scheduler.listForSession('session-1'); - const s2 = scheduler.listForSession('session-2'); - assert.ok(s1.every(r => r.status === 'cancelled')); - assert.equal(s2[0].status, 'pending'); - }); - - test('rejects more than 5 pending wakeups per session', () => { - const { scheduler } = createTestScheduler(); - for (let i = 0; i < 5; i++) { - scheduler.schedule('session-1', { delaySeconds: 10, message: `m${i}`, reason: 'test' }); - } - assert.throws( - () => scheduler.schedule('session-1', { delaySeconds: 10, message: 'overflow', reason: 'test' }), - /Max 5 pending wakeups/, - ); - }); - - test('rejects delay out of range', () => { - const { scheduler } = createTestScheduler(); - assert.throws(() => scheduler.schedule('s', { delaySeconds: 0, message: 'm', reason: 'r' }), /delay_seconds/); - assert.throws(() => scheduler.schedule('s', { delaySeconds: 100000, message: 'm', reason: 'r' }), /delay_seconds/); - }); - - test('delaySeconds=1 (minimum boundary) succeeds', () => { - const { scheduler, timers } = createTestScheduler(); - const record = scheduler.schedule('session-1', { delaySeconds: 1, message: 'boundary', reason: 'test' }); - assert.equal(record.status, 'pending'); - assert.equal(timers.length, 1); - assert.equal(timers[0].ms, 1000); - }); - - test('rejects when both cronExpression and delaySeconds are provided', () => { - const { scheduler } = createTestScheduler(); - assert.throws( - () => scheduler.schedule('s', { cronExpression: '* * * * *', delaySeconds: 60, message: 'm', reason: 'r' }), - /Provide cronExpression or delaySeconds, not both/, - ); - }); - - test('backs off and retries when canFire returns false', async () => { - let canFireCount = 0; - const { scheduler, fired, fireNextTimer } = createTestScheduler({ - canFire: async () => { canFireCount++; return canFireCount >= 3; }, - }); - scheduler.schedule('session-1', { delaySeconds: 5, message: 'retry-test', reason: 'test' }); - await fireNextTimer(); - assert.equal(fired.length, 0); - await fireNextTimer(); - assert.equal(fired.length, 0); - await fireNextTimer(); - assert.equal(fired.length, 1); - assert.ok(fired[0].text.includes('retry-test')); - }); - - test('expires after max retries', async () => { - const { scheduler, fired, fireNextTimer } = createTestScheduler({ canFire: async () => false }); - const record = scheduler.schedule('session-1', { delaySeconds: 5, message: 'expire-test', reason: 'test' }); - // Review fix: the idle-gate now retries 12 times with exponential - // backoff (a 15s window silently dropped wakeups landing mid-turn); - // drain the initial fire plus all 12 retries. - for (let i = 0; i < 13; i++) { - await fireNextTimer(); - } - assert.equal(fired.length, 0); - assert.equal(scheduler.listForSession('session-1').find(r => r.id === record.id)?.status, 'expired'); - }); - - test('dispose clears all timers', () => { - const { scheduler, timers } = createTestScheduler(); - scheduler.schedule('session-1', { delaySeconds: 10, message: 'a', reason: 'test' }); - scheduler.schedule('session-1', { delaySeconds: 20, message: 'b', reason: 'test' }); - scheduler.dispose(); - assert.ok(timers.every(t => t.cleared)); - }); - - test('multiple sessions work independently', async () => { - const { scheduler, fired, fireNextTimer } = createTestScheduler(); - scheduler.schedule('session-1', { delaySeconds: 5, message: 'msg-1', reason: 'test' }); - scheduler.schedule('session-2', { delaySeconds: 10, message: 'msg-2', reason: 'test' }); - await fireNextTimer(); - assert.equal(fired.length, 1); - assert.equal(fired[0].sessionId, 'session-1'); - await fireNextTimer(); - assert.equal(fired.length, 2); - assert.equal(fired[1].sessionId, 'session-2'); - }); - - // ─── Cron expression tests ────────────────────────────────────────────────── - - test('computeNextCronRun returns a future timestamp for valid expression', () => { - // "every minute" should return 1 minute after the reference time - const refMs = Date.now(); - const next = computeNextCronRun('* * * * *', refMs); - assert.notEqual(next, null); - assert.ok(next! > refMs, 'next run should be in the future'); - // Should be within 60 seconds of the reference - assert.ok(next! - refMs <= 60_000, 'next run for * * * * * should be within 60s'); - }); - - test('computeNextCronRun returns null for invalid expression', () => { - assert.equal(computeNextCronRun('invalid', 1000000), null); - assert.equal(computeNextCronRun('60 * * * *', 1000000), null); // minute out of range - assert.equal(computeNextCronRun('* * * *', 1000000), null); // only 4 fields - }); - - test('schedule with cronExpression creates a pending record', () => { - const { scheduler, timers } = createTestScheduler(); - const record = scheduler.schedule('session-1', { - cronExpression: '* * * * *', - message: 'cron check', - reason: 'every minute', - }); - assert.equal(record.status, 'pending'); - assert.equal(record.cronExpression, '* * * * *'); - assert.ok(record.firesAt > 1000000, 'firesAt should be in the future'); - assert.equal(timers.length, 1); - assert.ok(timers[0].ms > 0, 'timer delay should be positive'); - }); - - test('schedule rejects when neither delaySeconds nor cronExpression provided', () => { - const { scheduler } = createTestScheduler(); - assert.throws( - () => scheduler.schedule('s', { message: 'm', reason: 'r' }), - /Either cronExpression or delaySeconds/, - ); - }); - - test('schedule rejects invalid cron expression', () => { - const { scheduler } = createTestScheduler(); - assert.throws( - () => scheduler.schedule('s', { cronExpression: 'bad bad bad', message: 'm', reason: 'r' }), - /Invalid cron expression/, - ); - }); - - test('cron-based recurring job reschedules in-place after firing', async () => { - const { scheduler, fired, fireNextTimer } = createTestScheduler(); - const record = scheduler.schedule('session-1', { - cronExpression: '* * * * *', - message: 'cron recurring', - reason: 'every minute', - recurring: true, - }); - const originalId = record.id; - await fireNextTimer(); - assert.equal(fired.length, 1); - assert.ok(fired[0].text.includes('cron recurring')); - // The same record should be reused in-place (no new record created) - const pending = scheduler.listForSession('session-1').filter(r => r.status === 'pending'); - assert.equal(pending.length, 1, 'should have one pending record'); - assert.equal(pending[0].id, originalId, 'should reuse the same record id'); - assert.equal(pending[0].cronExpression, '* * * * *'); - }); - - // ─── Auto-expire tests ───────────────────────────────────────────────────── - - test('recurring job expires after 7 days', async () => { - const sevenDaysMs = 7 * 24 * 60 * 60 * 1000; - const { scheduler, fired, timers, advanceTime, fireNextTimer } = createTestScheduler(); - const record = scheduler.schedule('session-1', { - delaySeconds: 60, - message: 'long running', - reason: 'test', - recurring: true, - }); - // Verify expiresAt is set - assert.notEqual(record.expiresAt, null); - assert.equal(record.expiresAt, record.scheduledAt + sevenDaysMs); - - // Advance time past the 7-day expiry before firing - advanceTime(sevenDaysMs + 1000); - await fireNextTimer(); - - // The job should have expired, not fired - assert.equal(fired.length, 0); - const records = scheduler.listForSession('session-1'); - assert.equal(records.find(r => r.id === record.id)?.status, 'expired'); - }); - - test('one-shot jobs have null expiresAt', () => { - const { scheduler } = createTestScheduler(); - const record = scheduler.schedule('session-1', { - delaySeconds: 60, - message: 'one-shot', - reason: 'test', - recurring: false, - }); - assert.equal(record.expiresAt, null); - }); - - // ─── Jitter tests ────────────────────────────────────────────────────────── - - test('computeJitter for recurring returns a value within bounds', () => { - for (let i = 0; i < 50; i++) { - const delayMs = 600_000; // 10 minutes - const jitter = computeJitter(delayMs, true); - // 10% of 600000 = 60000, which is < MAX_JITTER_MS (15 min = 900000) - assert.ok(jitter >= 0, 'recurring jitter should be non-negative'); - assert.ok(jitter <= 60_000, 'recurring jitter should be <= 10% of delay'); - } - }); - - test('computeJitter for one-shot returns 0 when the fire time is off the round mark', () => { - // Review fix: the round-mark property belongs to the fire TIMESTAMP, - // not the delay. 10:07 + 30min = 10:37 → no early jitter. - const firesAt = new Date(2026, 0, 1, 10, 37, 0, 0).getTime(); - const jitter = computeJitter(30 * 60 * 1000, false, Math.random, firesAt); - assert.equal(jitter, 0); - // Without a timestamp there is no round-mark evidence → no jitter. - assert.equal(computeJitter(60_000, false), 0); - }); - - // ─── Idle-gate observability tests ───────────────────────────────────────── - - test('fireAttempts increments and deferredFires logs timestamps on idle rejection', async () => { - let callCount = 0; - const { scheduler, fireNextTimer } = createTestScheduler({ - canFire: async () => { callCount++; return callCount >= 2; }, - }); - const record = scheduler.schedule('session-1', { - delaySeconds: 5, - message: 'idle-gate test', - reason: 'test', - }); - // First fire attempt: rejected - await fireNextTimer(); - // Record is still pending (not yet fired), so it remains in the scheduler - assert.equal(record.fireAttempts, 1); - assert.equal(record.deferredFires.length, 1); - // Second fire attempt: succeeds - await fireNextTimer(); - // After successful fire, the non-recurring record is removed from the map - // but the object reference still has the final state - assert.equal(record.fireAttempts, 2); - assert.equal(record.status, 'fired'); - // deferredFires should still have 1 entry (only logged on rejection) - assert.equal(record.deferredFires.length, 1); - }); - - // ─── Cancel edge-case tests ──────────────────────────────────────────────── - - test('cancel returns false when the wakeup has already fired', async () => { - const { scheduler, fireNextTimer } = createTestScheduler(); - const record = scheduler.schedule('session-1', { delaySeconds: 5, message: 'fire-first', reason: 'test' }); - await fireNextTimer(); - assert.equal(record.status, 'fired'); - const result = scheduler.cancel(record.id); - assert.equal(result, false); - assert.equal(record.status, 'fired'); - }); - - test('cancel returns false on second call (already cancelled)', () => { - const { scheduler } = createTestScheduler(); - const record = scheduler.schedule('session-1', { delaySeconds: 30, message: 'cancel-twice', reason: 'test' }); - const first = scheduler.cancel(record.id); - assert.equal(first, true); - assert.equal(record.status, 'cancelled'); - const second = scheduler.cancel(record.id); - assert.equal(second, false); - assert.equal(record.status, 'cancelled'); - }); - - // ─── Race condition: cancel during canFire await ─────────────────────────── - - test('cancel during canFire await prevents firing', async () => { - let canFireResolve: (value: boolean) => void; - const { scheduler, fired, fireNextTimer } = createTestScheduler({ - canFire: () => new Promise((resolve) => { canFireResolve = resolve; }), - }); - const record = scheduler.schedule('session-1', { delaySeconds: 5, message: 'race', reason: 'test' }); - - // Fire the timer -- this starts the async canFire call - const firePromise = fireNextTimer(); - - // While canFire is in-flight, cancel the wakeup - scheduler.cancel(record.id); - assert.equal(record.status, 'cancelled'); - - // Now resolve canFire -- fire() should see the cancelled status and bail - canFireResolve!(true); - await firePromise; - - assert.equal(fired.length, 0, 'cancelled wakeup should not have fired'); - assert.equal(record.status, 'cancelled'); - }); - - // ─── One-shot jitter on 30-minute-aligned delays ────────────────────────── - - test('computeJitter for one-shot firing on a :00/:30 minute returns negative value in bounds', () => { - for (let i = 0; i < 50; i++) { - const firesAt = new Date(2026, 0, 1, 11, i % 2 === 0 ? 0 : 30, 0, 0).getTime(); - const jitter = computeJitter(17 * 60 * 1000, false, Math.random, firesAt); - assert.ok(jitter <= 0, `one-shot jitter should be <= 0, got ${jitter}`); - assert.ok(jitter >= -90_000, `one-shot jitter should be >= -90000, got ${jitter}`); - } - }); - - // ─── Record accumulation prevention tests ───────────────────────────────── - - test('fired non-recurring job stays observable as a terminal record', async () => { - // Review fix: fired one-shots used to be deleted immediately, making - // the 'fired' status unreachable and CronList unable to show what just - // happened. Terminal records stay (pruneSession caps the history). - const { scheduler, fired, fireNextTimer } = createTestScheduler(); - scheduler.schedule('session-1', { delaySeconds: 5, message: 'one-shot', reason: 'test' }); - await fireNextTimer(); - assert.equal(fired.length, 1); - const records = scheduler.listForSession('session-1'); - assert.equal(records.length, 1, 'fired one-shot should stay as a terminal record'); - assert.equal(records[0]!.status, 'fired'); - assert.equal(scheduler.listForSession('session-1', { activeOnly: true }).length, 0); - }); - - test('recurring job does not duplicate records on fire', async () => { - const { scheduler, fired, fireNextTimer } = createTestScheduler(); - scheduler.schedule('session-1', { - delaySeconds: 60, - message: 'recurring-check', - reason: 'test', - recurring: true, - }); - // Fire multiple times - await fireNextTimer(); - await fireNextTimer(); - await fireNextTimer(); - assert.equal(fired.length, 3, 'should have fired 3 times'); - // Only ONE record should exist (reused in-place) - const records = scheduler.listForSession('session-1'); - assert.equal(records.length, 1, 'should have exactly one record for recurring job'); - assert.equal(records[0].status, 'pending', 'record should be pending for next fire'); - }); - - test('max records cap is enforced', () => { - const { scheduler } = createTestScheduler(); - // Create many records that exceed the cap by scheduling and cancelling - for (let i = 0; i < MAX_RECORDS_PER_SESSION + 10; i++) { - const rec = scheduler.schedule('session-1', { - delaySeconds: 60, - message: `job-${i}`, - reason: 'test', - }); - scheduler.cancel(rec.id); - } - // All records are cancelled (terminal), schedule() should prune - // Schedule one more to trigger pruning - scheduler.schedule('session-1', { delaySeconds: 60, message: 'final', reason: 'test' }); - const records = scheduler.listForSession('session-1'); - assert.ok(records.length <= MAX_RECORDS_PER_SESSION, `records (${records.length}) should not exceed cap (${MAX_RECORDS_PER_SESSION})`); - }); - - test('listForSession with activeOnly only returns pending records', async () => { - const { scheduler, fireNextTimer } = createTestScheduler(); - scheduler.schedule('session-1', { delaySeconds: 5, message: 'will-fire', reason: 'test', recurring: true }); - const cancelMe = scheduler.schedule('session-1', { delaySeconds: 30, message: 'will-cancel', reason: 'test' }); - scheduler.cancel(cancelMe.id); - // Fire the recurring job once (it stays as pending for next) - await fireNextTimer(); - // Now we have: 1 pending (recurring, re-scheduled) + 1 cancelled - const all = scheduler.listForSession('session-1'); - const active = scheduler.listForSession('session-1', { activeOnly: true }); - assert.equal(all.length, 2, 'all records includes cancelled'); - assert.equal(active.length, 1, 'activeOnly returns only pending'); - assert.equal(active[0].status, 'pending'); - }); -}); diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index 7f6e37f683..7c10c1b21a 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -553,6 +553,9 @@ export class AgentRun { ...this.lineage, ...(this.input.userInput.agentId ? { agentId: this.input.userInput.agentId } : {}), ...(this.input.userInput.agentName ? { agentName: this.input.userInput.agentName } : {}), + ...(this.input.userInput.origin?.kind === 'automation' + ? { automationId: this.input.userInput.origin.automationId } + : {}), }; try { await this.input.runStore.createRun(header); diff --git a/packages/runtime/src/automation-can-fire.ts b/packages/runtime/src/automation-can-fire.ts new file mode 100644 index 0000000000..ab04862412 --- /dev/null +++ b/packages/runtime/src/automation-can-fire.ts @@ -0,0 +1,52 @@ +import type { AutomationDefinition } from './automation-state.js'; + +/** + * Session statuses a heartbeat may fire into. A heartbeat injects a turn into + * its own session, so it must only fire when that session is genuinely idle — + * not mid-turn ('running'), waiting on a human ('waiting_for_user'), under + * review, blocked, aborted, or archived. Shared by every host so the desktop + * and CLI gates cannot diverge. + */ +export const HEARTBEAT_IDLE_STATUSES: ReadonlySet = new Set(['active', 'done']); + +/** Minimal session-header shape the fire gate reads. */ +export interface CanFireSessionHeader { + archivedAt?: number | null; + status: string; +} + +export interface EvaluateAutomationCanFireDeps { + /** Global privacy gate — true blocks every kind. */ + isIncognitoActive: () => Promise; + /** Reads the session header; may THROW if the session file is gone (deleted). */ + readSessionHeader: (sessionId: string) => Promise; + /** Session statuses a heartbeat may fire into (idle). Defaults to HEARTBEAT_IDLE_STATUSES. */ + idleStatuses?: ReadonlySet; +} + +/** + * Decide whether an automation may fire now. Kind-aware: + * - Global privacy (incognito) blocks every kind. + * - Cron spawns a FRESH session, so its creator session is irrelevant — it is + * never gated on that session. This is what lets a durable cron keep firing + * after the conversation that created it is archived or deleted. + * - Heartbeat injects into its own session, so that session must exist (reading + * it must not throw) and be idle (not archived, an idle status). + * Pure and injectable so the gate is unit-testable and identical across hosts. + */ +export async function evaluateAutomationCanFire( + automation: Pick, + deps: EvaluateAutomationCanFireDeps, +): Promise { + if (await deps.isIncognitoActive()) return false; + if (automation.kind === 'cron') return true; + const idle = deps.idleStatuses ?? HEARTBEAT_IDLE_STATUSES; + let header: CanFireSessionHeader | null; + try { + header = await deps.readSessionHeader(automation.sessionId); + } catch { + return false; // session file gone (deleted) → nothing to inject into + } + if (!header || header.archivedAt) return false; + return idle.has(header.status); +} diff --git a/packages/runtime/src/automation-scheduler.ts b/packages/runtime/src/automation-scheduler.ts new file mode 100644 index 0000000000..54cc3b1122 --- /dev/null +++ b/packages/runtime/src/automation-scheduler.ts @@ -0,0 +1,215 @@ +/** + * Automation scheduler — manages a tick loop that fires active automations. + * + * Fixes applied from adversarial review: + * - canFire errors are caught per-automation (don't abort the whole tick) + * - injectTurn/createFreshRun failures properly mark the automation as failed + * - Defer-skip advances nextFireAt via skipFire() instead of looping forever + * - deferCounts are pruned when automations disappear + * - dispose() sets flag checked in async paths to prevent post-dispose execution + * - Uses deps.now() consistently (injectable for testing) + */ + +import type { AutomationDefinition, AutomationManager } from './automation-state.js'; + +/** Outcome of a dispatched fire, decided only after the run's stream finishes. */ +export interface AutomationFireResult { + /** The run/turn id the fire produced (for attribution / lastRunId). */ + runId?: string; + /** Whether the run completed successfully (no error / abort). */ + ok: boolean; + /** Failure reason when !ok. */ + error?: string; +} + +export interface AutomationSchedulerDeps { + automationManager: AutomationManager; + /** + * Whether this automation may fire right now. Receives the whole automation + * so the host can gate kind-appropriately: a heartbeat injects into its own + * session (gate on that session's existence/idleness), while a cron spawns a + * FRESH session (its creator session is irrelevant — gate only on global + * concerns like privacy mode). Global gates (e.g. incognito) apply to both. + */ + canFire: (automation: AutomationDefinition) => Promise; + /** + * Inject a turn into the automation's own session (heartbeat kind). + * Resolves with the run outcome AFTER the turn's stream finishes. + */ + injectTurn: (sessionId: string, prompt: string, automationId: string) => Promise; + /** + * Spawn a fresh session and run the prompt there (cron kind). + * Resolves with the run outcome AFTER the run's stream finishes. + * When absent, the host does not support cron and cron fires fail. + */ + createFreshRun?: (prompt: string, automationId: string) => Promise; + setTimeout: (fn: () => void, ms: number) => unknown; + clearTimeout: (timer: unknown) => void; + now?: () => number; + onStateChange?: () => void; +} + +const FIRE_CHECK_INTERVAL_MS = 5000; // 5s tick (must be < minimum interval of 10s) +const MAX_DEFER_RETRIES = 24; // 24 * 5s tick = ~120s max wait for idle + +export class AutomationScheduler { + private tickTimer: unknown = null; + private disposed = false; + private deferCounts = new Map(); + /** Automation ids whose fire is currently executing (prevents concurrent re-fire). */ + private inFlight = new Set(); + private readonly now: () => number; + + constructor(private readonly deps: AutomationSchedulerDeps) { + this.now = deps.now ?? (() => Date.now()); + } + + start(): void { + if (this.disposed) return; + this.scheduleTick(); + } + + stop(): void { + if (this.tickTimer !== null) { + this.deps.clearTimeout(this.tickTimer); + this.tickTimer = null; + } + } + + dispose(): void { + this.disposed = true; + this.stop(); + this.deferCounts.clear(); + this.inFlight.clear(); + } + + private scheduleTick(): void { + if (this.disposed) return; + this.tickTimer = this.deps.setTimeout(() => { + if (this.disposed) return; + this.checkAndFire().catch(() => {}).finally(() => { + if (!this.disposed) this.scheduleTick(); + }); + }, FIRE_CHECK_INTERVAL_MS); + } + + private async checkAndFire(): Promise { + const now = this.now(); + const active = this.deps.automationManager.listActive(); + + // Prune deferCounts for automations that no longer exist. + const activeIds = new Set(active.map(a => a.id)); + for (const id of this.deferCounts.keys()) { + if (!activeIds.has(id)) this.deferCounts.delete(id); + } + + // Eager expiry sweep: expire automations whose expiresAt has passed, + // regardless of nextFireAt. Prevents zombie-active entries. + let sweptAny = false; + for (const automation of active) { + // Same invariant as attemptFire: a host without a cron executor must not + // mutate/persist crons at all — the durable store may be shared with a + // host that CAN run them, and this host's in-memory copy may be stale + // (no reload after startup), so expiring a cron here could clobber the + // owning host's edits on disk. Leave crons entirely to that host. + if (automation.kind === 'cron' && !this.deps.createFreshRun) continue; + if (automation.expiresAt && now >= automation.expiresAt) { + if (this.deps.automationManager.sweepExpired(automation.id)) sweptAny = true; + } + } + if (sweptAny) this.deps.onStateChange?.(); + + // Re-fetch active list after expiry sweep. + const stillActive = this.deps.automationManager.listActive(); + for (const automation of stillActive) { + if (this.disposed) return; + if (!automation.nextFireAt || automation.nextFireAt > now) continue; + await this.attemptFire(automation); + } + } + + private async attemptFire(automation: AutomationDefinition): Promise { + if (this.disposed) return; + + // A host without a cron executor cannot run cron automations. Leave them + // COMPLETELY untouched — do not fail, pause, or advance them, and emit no + // state change. The durable store may be shared with a host that CAN run + // them (e.g. the desktop shares its workspace with the `maka` CLI), so + // marking a cron failed/paused here would corrupt that shared durable state + // (a heartbeat-only CLI would otherwise pause the desktop's crons on disk). + if (automation.kind === 'cron' && !this.deps.createFreshRun) return; + + // In-flight guard: a fire whose run is still executing must not be started + // again. canFire protects heartbeat (its run occupies the automation's own + // session), but NOT cron (createFreshRun spawns a separate session, leaving + // the creator session idle), so a cron whose run outlasts its cadence would + // otherwise re-fire every tick — spawning duplicate sessions, blowing past + // maxFires, and committing outcomes out of order. This guard closes that + // window for every kind, independent of canFire. + if (this.inFlight.has(automation.id)) return; + + let canFire: boolean; + try { + canFire = await this.deps.canFire(automation); + } catch { + // canFire failure: skip this automation this tick, don't crash the loop. + return; + } + + if (this.disposed) return; + // Re-check the guard after the async canFire (another tick may have started). + if (this.inFlight.has(automation.id)) return; + + if (!canFire) { + const deferCount = (this.deferCounts.get(automation.id) ?? 0) + 1; + if (deferCount >= MAX_DEFER_RETRIES) { + this.deferCounts.delete(automation.id); + // Skip this fire entirely — advance to next scheduled time. + this.deps.automationManager.skipFire(automation.id); + this.deps.onStateChange?.(); + return; + } + this.deferCounts.set(automation.id, deferCount); + return; + } + + this.deferCounts.delete(automation.id); + + const started = this.deps.automationManager.attemptStarted(automation.id); + if (!started) { + this.deps.onStateChange?.(); + return; + } + // Persist the started state (fireCount/nextFireAt advanced) immediately. + this.deps.onStateChange?.(); + + const id = automation.id; + this.inFlight.add(id); + // Dispatch WITHOUT awaiting the tick — the run resolves its outcome later. + // The outcome (success/failure) is committed only after the stream finishes, + // so a failed or aborted fire is never recorded as a success. + const dispatch = automation.kind === 'heartbeat' + ? this.deps.injectTurn(automation.sessionId, `[Automation: ${automation.name}]\n\n${automation.prompt}`, id) + : this.deps.createFreshRun!(automation.prompt, id); + + void dispatch.then((result) => { + this.inFlight.delete(id); + if (this.disposed) return; + if (result.ok) { + this.deps.automationManager.attemptSucceeded(id, result.runId); + } else { + this.deps.automationManager.attemptFailed(id, result.error ?? 'Automation run failed'); + } + this.deps.onStateChange?.(); + }).catch((err) => { + this.inFlight.delete(id); + if (this.disposed) return; + const message = err instanceof Error ? err.message : String(err); + this.deps.automationManager.attemptFailed(id, message); + this.deps.onStateChange?.(); + }); + } +} + +export { FIRE_CHECK_INTERVAL_MS, MAX_DEFER_RETRIES }; + diff --git a/packages/runtime/src/automation-state.ts b/packages/runtime/src/automation-state.ts new file mode 100644 index 0000000000..2ed7e3d153 --- /dev/null +++ b/packages/runtime/src/automation-state.ts @@ -0,0 +1,596 @@ +/** + * Unified Automation — Codex-style automation system. + * + * Two kinds: + * - "heartbeat": session-scoped polling (resume into same session) + * - "cron": standalone scheduled runs (create fresh session each time) + */ + +export type AutomationKind = 'heartbeat' | 'cron'; +export type AutomationStatus = 'active' | 'paused' | 'completed' | 'expired'; + +export interface AutomationDefinition { + id: string; + kind: AutomationKind; + name: string; + status: AutomationStatus; + prompt: string; + sessionId: string; + schedule: AutomationSchedule; + createdAt: number; + updatedAt: number; + nextFireAt: number | null; + lastFireAt: number | null; + lastRunId: string | null; + fireCount: number; + maxFires: number | null; + expiresAt: number | null; + lastError: string | null; + consecutiveFailures: number; + /** When true, this automation persists across app restarts. */ + durable?: boolean; +} + +export type AutomationSchedule = + | { type: 'cron'; expression: string } + | { type: 'interval'; seconds: number } + | { type: 'once'; delaySeconds: number }; + +export interface AutomationManagerDeps { + generateId: () => string; + now: () => number; +} + +const MAX_AUTOMATIONS_PER_SESSION = 20; +const MAX_CONSECUTIVE_FAILURES = 5; +const DEFAULT_EXPIRY_DAYS = 7; + +export class AutomationManager { + private automations = new Map(); + + constructor(private readonly deps: AutomationManagerDeps) {} + + create(input: { + kind: AutomationKind; + name: string; + prompt: string; + sessionId: string; + schedule: AutomationSchedule; + maxFires?: number; + expiresAt?: number; + durable?: boolean; + }): AutomationDefinition | { error: string } { + // Only count active/paused automations toward the limit (not completed/expired). + const activeCount = this.listForSession(input.sessionId) + .filter(a => a.status === 'active' || a.status === 'paused').length; + if (activeCount >= MAX_AUTOMATIONS_PER_SESSION) { + return { error: `Maximum ${MAX_AUTOMATIONS_PER_SESSION} active automations per session reached.` }; + } + + if (input.kind === 'heartbeat') { + const existing = this.listForSession(input.sessionId) + .filter(a => a.kind === 'heartbeat' && a.status === 'active'); + if (existing.length >= 5) { + return { error: 'Maximum 5 active heartbeat automations per session.' }; + } + } + + const now = this.deps.now(); + const id = this.deps.generateId(); + const nextFireAt = this.computeNextFire(input.schedule, now); + + if (nextFireAt === null && input.schedule.type === 'cron') { + return { error: `Invalid cron expression: "${input.schedule.expression}". Could not compute next fire time.` }; + } + + const defaultExpiry = now + DEFAULT_EXPIRY_DAYS * 24 * 60 * 60 * 1000; + + // Cron is a standalone scheduled task (fresh session each run) — it is + // meaningless if it dies on restart, so it defaults to durable. Heartbeat + // injects into its own session and has no coherent post-restart target, so + // it is ALWAYS session-bound (never durable) — a durable heartbeat would be + // a zombie after restart. `durable` is therefore a cron-only concept; an + // explicit value only refines cron. + const durable = input.kind === 'cron' ? (input.durable ?? true) : false; + + const automation: AutomationDefinition = { + id, + kind: input.kind, + name: input.name, + status: 'active', + prompt: input.prompt, + sessionId: input.sessionId, + schedule: input.schedule, + createdAt: now, + updatedAt: now, + nextFireAt, + lastFireAt: null, + lastRunId: null, + fireCount: 0, + maxFires: input.maxFires ?? null, + expiresAt: input.expiresAt ?? defaultExpiry, + lastError: null, + consecutiveFailures: 0, + ...(durable ? { durable: true } : {}), + }; + + this.automations.set(id, automation); + this.pruneTerminal(input.sessionId); + return automation; + } + + get(id: string): AutomationDefinition | undefined { + return this.automations.get(id); + } + + delete(id: string, sessionId?: string): boolean { + const automation = this.automations.get(id); + if (!automation) return false; + if (sessionId && !this.manageableBy(automation, sessionId)) return false; + this.automations.delete(id); + return true; + } + + pause(id: string, sessionId: string): AutomationDefinition | undefined { + const automation = this.automations.get(id); + if (!automation || !this.manageableBy(automation, sessionId)) return undefined; + if (automation.status !== 'active') return undefined; + automation.status = 'paused'; + automation.updatedAt = this.deps.now(); + return automation; + } + + resume(id: string, sessionId: string): AutomationDefinition | undefined { + const automation = this.automations.get(id); + if (!automation || !this.manageableBy(automation, sessionId)) return undefined; + if (automation.status !== 'paused') return undefined; + // Refuse to resume an automation whose fire budget is already spent. A + // maxFires-exhausted (or a one-shot that already fired) automation only + // reaches 'paused' via the attemptFailed path, which leaves nextFireAt=null. + // Re-arming it here would grant a fire beyond the declared hard cap — the + // next tick would bump fireCount past maxFires (or re-fire a 'once'), + // spawning a real extra run. maxFires is a cap on ATTEMPTS, so a spent + // budget cannot be revived by resume. + if (automation.maxFires && automation.fireCount >= automation.maxFires) return undefined; + if (automation.schedule.type === 'once' && automation.fireCount > 0) return undefined; + automation.status = 'active'; + automation.updatedAt = this.deps.now(); + // Resume starts a clean streak — a fire that paused this automation must not + // count toward re-pausing it after a single fresh failure. + automation.consecutiveFailures = 0; + automation.lastError = null; + automation.nextFireAt = this.computeNextFire(automation.schedule, this.deps.now()); + return automation; + } + + listForSession(sessionId: string): AutomationDefinition[] { + return [...this.automations.values()].filter(a => a.sessionId === sessionId); + } + + /** + * Automations a session can see and manage: its own (any kind) plus every + * durable one. Durable automations (cron by default) are app-global — they + * outlive their creator session and reload from disk on restart with their + * original sessionId, so a fresh session must still be able to list and + * manage them. Non-durable heartbeats stay private to their session. + */ + listVisibleForSession(sessionId: string): AutomationDefinition[] { + return [...this.automations.values()].filter(a => a.sessionId === sessionId || a.durable === true); + } + + /** A session may manage its own automations plus any durable (app-global) one. */ + private manageableBy(automation: AutomationDefinition, sessionId: string): boolean { + return automation.sessionId === sessionId || automation.durable === true; + } + + listActive(): AutomationDefinition[] { + return [...this.automations.values()].filter(a => a.status === 'active'); + } + + /** + * Mark an expired automation terminal. Returns true if it was expired. + * Used by the scheduler's eager expiry sweep. + */ + sweepExpired(id: string): boolean { + const automation = this.automations.get(id); + if (!automation || automation.status !== 'active') return false; + const now = this.deps.now(); + if (automation.expiresAt && now >= automation.expiresAt) { + automation.status = 'expired'; + automation.nextFireAt = null; + automation.updatedAt = now; + return true; + } + return false; + } + + /** + * Begin a fire attempt: advance the schedule and counters, but do NOT commit + * terminal completion — that happens only on a real success (attemptSucceeded). + * Checks expiry first. Returns the automation if it should fire, else undefined. + */ + attemptStarted(id: string): AutomationDefinition | undefined { + const automation = this.automations.get(id); + if (!automation || automation.status !== 'active') return undefined; + + const now = this.deps.now(); + // Check expiry BEFORE firing — don't execute expired automations. + if (automation.expiresAt && now >= automation.expiresAt) { + automation.status = 'expired'; + automation.nextFireAt = null; + automation.updatedAt = now; + return undefined; + } + + automation.lastFireAt = now; + automation.fireCount++; + automation.updatedAt = now; + + // A one-shot does not auto-retry: null its nextFireAt now. A recurring job + // advances to its next slot. Completion (once / maxFires) is committed only + // after a successful outcome in attemptSucceeded. + automation.nextFireAt = automation.schedule.type === 'once' + ? null + : this.computeNextFire(automation.schedule, now); + + // maxFires is a hard cap on the number of fire ATTEMPTS: once this attempt + // reaches the cap, no further fire is scheduled — regardless of whether this + // one ultimately succeeds or fails. (Terminal status is still committed by + // attemptSucceeded/attemptFailed based on this attempt's outcome.) Without + // this, a failing recurring automation would keep firing past maxFires until + // the consecutive-failure cap, and fireCount could exceed maxFires. + if (automation.maxFires && automation.fireCount >= automation.maxFires) { + automation.nextFireAt = null; + } + + return automation; + } + + /** + * Skip a fire without executing — advance to next schedule time. + * Used when the session is busy for too long. + */ + skipFire(id: string): void { + const automation = this.automations.get(id); + if (!automation || automation.status !== 'active') return; + const now = this.deps.now(); + automation.updatedAt = now; + // A one-shot has no "next slot": re-arming it via computeNextFire re-adds the + // full delay, so repeated skips (e.g. a long incognito window or a busy + // session) would drift it forward indefinitely and then silently drop it at + // expiry. Its fire window has passed — settle it terminally instead. + if (automation.schedule.type === 'once') { + automation.nextFireAt = null; + automation.status = 'expired'; + automation.lastError = 'Fire window skipped (session busy or privacy mode)'; + return; + } + automation.nextFireAt = this.computeNextFire(automation.schedule, now); + } + + /** + * Commit a successful fire outcome: reset failure state, record the run id, + * and NOW apply completion (once / maxFires reached). + */ + attemptSucceeded(id: string, runId?: string): void { + const automation = this.automations.get(id); + if (!automation) return; + if (automation.status !== 'active') return; + automation.consecutiveFailures = 0; + automation.lastError = null; + if (runId) automation.lastRunId = runId; + automation.updatedAt = this.deps.now(); + + if (automation.schedule.type === 'once') { + automation.status = 'completed'; + automation.nextFireAt = null; + } else if (automation.maxFires && automation.fireCount >= automation.maxFires) { + automation.status = 'completed'; + automation.nextFireAt = null; + } + } + + /** + * Record a failed fire outcome. Accumulates toward the consecutive-failure + * cap (→ paused). A one-shot that fails has no next fire, so it is paused so + * it is visible rather than a silent idle zombie. + */ + attemptFailed(id: string, error: string): void { + const automation = this.automations.get(id); + if (!automation) return; + if (automation.status !== 'active') return; + automation.consecutiveFailures++; + automation.lastError = error; + automation.updatedAt = this.deps.now(); + + if (automation.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + automation.status = 'paused'; + } else if (automation.nextFireAt === null) { + // Nothing will fire this again (one-shot failure) — pause so it is a + // visible terminal-ish state, not a silent zombie. + automation.status = 'paused'; + } + } + + removeAllForSession(sessionId: string): number { + let count = 0; + for (const [id, auto] of this.automations) { + if (auto.sessionId === sessionId && auto.kind === 'heartbeat') { + this.automations.delete(id); + count++; + } + } + return count; + } + + dispose(): void { + this.automations.clear(); + } + + /** Bulk-register pre-existing automations (e.g. loaded from durable store on startup). */ + registerAll(automations: AutomationDefinition[]): void { + const now = this.deps.now(); + for (const automation of automations) { + // Reconcile an interrupted fire: a fire that started (fireCount bumped, + // nextFireAt nulled) but whose run never settled — the app quit mid-run — + // persists as active with nextFireAt=null. Left alone it is a silent + // zombie (never fires again until the 7-day expiry sweep). + if (automation.status === 'active' && automation.nextFireAt === null) { + const budgetSpent = + (automation.maxFires != null && automation.fireCount >= automation.maxFires) || + (automation.schedule.type === 'once' && automation.fireCount > 0); + if (budgetSpent) { + // The one/last fire was already attempted (fireCount reflects it), so + // settle it terminally rather than re-run it (at-most-once semantics). + // Its outcome was never committed, so record the uncertainty instead of + // asserting a clean success — a genuine success leaves lastError null. + automation.status = 'completed'; + automation.lastError = 'Interrupted on restart before the fire outcome was recorded; not re-run.'; + } else { + // A recurring automation should always carry a future fire time; a null + // here is a corrupt/interrupted state — re-arm it. + automation.nextFireAt = this.computeNextFire(automation.schedule, now); + } + } + this.automations.set(automation.id, automation); + } + } + + /** Return all automations (all statuses, all sessions). */ + listAll(): AutomationDefinition[] { + return [...this.automations.values()]; + } + + /** Remove completed/expired automations beyond a small grace buffer. */ + private pruneTerminal(sessionId: string): void { + const terminal = this.listForSession(sessionId) + .filter(a => a.status === 'completed' || a.status === 'expired'); + const MAX_TERMINAL_KEPT = 5; + if (terminal.length <= MAX_TERMINAL_KEPT) return; + terminal.sort((a, b) => a.updatedAt - b.updatedAt); + for (let i = 0; i < terminal.length - MAX_TERMINAL_KEPT; i++) { + this.automations.delete(terminal[i].id); + } + } + + private computeNextFire(schedule: AutomationSchedule, fromTime: number): number | null { + switch (schedule.type) { + case 'once': + return fromTime + schedule.delaySeconds * 1000; + case 'interval': + return fromTime + schedule.seconds * 1000; + case 'cron': + return computeNextCronFire(schedule.expression, fromTime); + } + } +} + +const MINUTES_PER_DAY = 24 * 60; + +/** + * Upper bound on the minute-by-minute search window. + * + * A valid but sparse cron such as `0 0 29 2 *` (Feb 29, leap years only) can be + * several years out. The maximum gap between two consecutive Feb 29ths is + * 8 years: a century year that is not divisible by 400 (e.g. 2100, 2200) is NOT + * a leap year, so the sequence 2096 -> 2104 skips 2100 entirely. Searching a + * full ~8-year window guarantees every legally-satisfiable expression resolves, + * while the bound still lets genuinely-impossible expressions (e.g. + * `0 0 30 2 *`, Feb 30 never exists) terminate and return null instead of + * looping forever. + */ +const MAX_SEARCH_MINUTES = 8 * 366 * MINUTES_PER_DAY; // ~8 years, bounded + +const CRON_MONTH_ALIASES: Record = { + jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12, +}; +const CRON_DOW_ALIASES: Record = { + sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6, +}; +// Leap-year max days per month (Feb=29) — used only for impossible-date detection. +const CRON_MAX_DAYS_IN_MONTH = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + +/** Replace alphabetic cron tokens (e.g. MON, JAN) with their numeric value. */ +function translateCronAliases(field: string, aliases: Record): string { + return field.replace(/[a-zA-Z]+/g, (tok) => { + const n = aliases[tok.toLowerCase()]; + return n === undefined ? tok : String(n); + }); +} + +/** + * Expand a numeric cron field to the set of values it matches within [min,max]. + * Returns 'star' for "*", or null if any token is malformed or out of range. + */ +function expandCronField(field: string, min: number, max: number): number[] | 'star' | null { + if (field === '*') return 'star'; + const values = new Set(); + for (const part of field.split(',')) { + let range = part; + let step = 1; + if (part.includes('/')) { + const [r, s] = part.split('/'); + step = parseInt(s, 10); + if (!Number.isInteger(step) || step <= 0) return null; + range = r; + } + let lo: number; + let hi: number; + if (range === '*') { + lo = min; hi = max; + } else if (range.includes('-')) { + const [a, b] = range.split('-'); + lo = parseInt(a, 10); hi = parseInt(b, 10); + if (!Number.isInteger(lo) || !Number.isInteger(hi)) return null; + } else { + lo = parseInt(range, 10); + if (!Number.isInteger(lo)) return null; + hi = part.includes('/') ? max : lo; // "5/10" means 5,15,25… up to max + } + if (lo < min || hi > max || lo > hi) return null; + for (let v = lo; v <= hi; v += step) values.add(v); + } + return [...values]; +} + +interface NormalizedCron { + minuteField: string; hourField: string; domField: string; monthField: string; dowField: string; +} + +/** + * Validate + normalize a 5-field cron expression in O(1): translate named + * day/month tokens to numbers, reject out-of-range values, and fast-fail + * impossible calendar dates (e.g. Feb 30, Apr 31). Returns null for anything + * malformed or unsatisfiable so the caller skips the expensive minute scan. + */ +function normalizeCronExpression(expression: string): NormalizedCron | null { + const parts = expression.trim().split(/\s+/); + if (parts.length !== 5) return null; + const minuteField = parts[0]; + const hourField = parts[1]; + const domField = parts[2]; + const monthField = translateCronAliases(parts[3], CRON_MONTH_ALIASES); + const dowField = translateCronAliases(parts[4], CRON_DOW_ALIASES); + + if (expandCronField(minuteField, 0, 59) === null) return null; + if (expandCronField(hourField, 0, 23) === null) return null; + const domVals = expandCronField(domField, 1, 31); + const monthVals = expandCronField(monthField, 1, 12); + const dowVals = expandCronField(dowField, 0, 7); // 0 and 7 both = Sunday + if (domVals === null || monthVals === null || dowVals === null) return null; + + // Impossible calendar date: only fast-fail when the day is constrained ONLY by + // dom+month (dow="*"). If dow is also restricted, Vixie OR-semantics mean a + // matching weekday can still fire, so we must NOT reject. + if (domVals !== 'star' && monthVals !== 'star' && dowVals === 'star') { + const maxDays = Math.max(...monthVals.map((m) => CRON_MAX_DAYS_IN_MONTH[m - 1])); + if (Math.min(...domVals) > maxDays) return null; // e.g. Feb 30, Apr 31 + } + + return { minuteField, hourField, domField, monthField, dowField }; +} + +/** + * Compute the next Unix-ms timestamp at which a 5-field cron expression fires, + * strictly after `fromTime`. Returns null for a malformed expression or one + * that cannot occur within the bounded search window. + * + * TIMEZONE CONTRACT: evaluation happens in the HOST's local timezone. Candidate + * instants are decomposed with `Date` local getters (`getMinutes`, `getHours`, + * `getDate`, `getMonth`, `getDay`), so `0 9 * * *` means "09:00 local wall-clock + * time" on the machine running this process. Across DST transitions the wall + * clock is respected (a skipped/repeated local hour shifts the fire instant + * accordingly). There is no per-automation IANA timezone; if the process moves + * timezones, schedules re-anchor to the new local time. Threading an explicit + * IANA zone would ripple through the schedule type and every caller, so it is + * intentionally out of scope for this parser. + */ +export function computeNextCronFire(expression: string, fromTime: number): number | null { + // Validate + normalize BEFORE the bounded scan so an unsatisfiable or + // unsupported expression fails in O(1) instead of blocking the (main-process) + // thread for a multi-second full-window scan. This translates named tokens + // (MON-SUN, JAN-DEC), rejects out-of-range values, and fast-fails impossible + // calendar dates (e.g. Feb 30). + const normalized = normalizeCronExpression(expression); + if (!normalized) return null; + const { minuteField, hourField, domField, monthField, dowField } = normalized; + + // Vixie-cron day semantics: when BOTH the day-of-month and day-of-week fields + // are restricted (neither is "*"), a day matches if it satisfies EITHER field + // (OR) — e.g. `0 0 13 * 5` fires on the 13th of any month OR on any Friday, + // NOT only on Friday the 13th. When at least one field is "*", that field + // matches every value, so the two are combined with AND (the "*" field is a + // no-op and only the other constrains). + const domIsStar = domField === '*'; + const dowIsStar = dowField === '*'; + const bothDayFieldsRestricted = !domIsStar && !dowIsStar; + + // Start the scan at the next whole-minute boundary strictly after fromTime, + // computed in EPOCH arithmetic. Using Date.setSeconds() would round-trip the + // instant through local wall-clock; during a DST fall-back (a repeated local + // hour) V8 re-encodes the ambiguous time to the earlier offset, shifting the + // start ~59 min BEFORE fromTime. The scan would then return a candidate + // <= fromTime, breaking the strictly-after contract and making the scheduler + // re-fire every tick for the whole repeated hour. Epoch math is offset-safe; + // candidate wall-clock fields are still read with local getters below. + const baseTime = fromTime - (fromTime % 60000) + 60000; + + for (let attempt = 0; attempt < MAX_SEARCH_MINUTES; attempt++) { + const candidateTime = baseTime + attempt * 60000; + const candidate = new Date(candidateTime); + + // Cheapest, most-selective checks first so most candidates are pruned before + // the day-field matching runs. + if (!matchesCronField(minuteField, candidate.getMinutes(), 0, 59)) continue; + if (!matchesCronField(hourField, candidate.getHours(), 0, 23)) continue; + if (!matchesCronField(monthField, candidate.getMonth() + 1, 1, 12)) continue; + + const domMatch = matchesCronField(domField, candidate.getDate(), 1, 31); + // Day-of-week: cron allows both 0 and 7 for Sunday, but Date.getDay() only + // returns 0-6 (0=Sunday). Match against the raw value, plus the 7-alias when + // the day is Sunday, so fields like "7", "5-7", "0,7" all fire on Sundays. + const dow = candidate.getDay(); + const dowMatch = matchesCronField(dowField, dow, 0, 7) + || (dow === 0 && matchesCronField(dowField, 7, 0, 7)); + const dayMatch = bothDayFieldsRestricted + ? domMatch || dowMatch // OR when both are constrained + : domMatch && dowMatch; // AND when one is "*" + + if (dayMatch) return candidateTime; + } + return null; +} + +export function matchesCronField(field: string, value: number, min: number, max: number): boolean { + if (field === '*') return true; + + for (const part of field.split(',')) { + if (part.includes('/')) { + const [range, stepStr] = part.split('/'); + const step = parseInt(stepStr, 10); + if (isNaN(step) || step <= 0) continue; + let start = min; + let end = max; + if (range !== '*') { + if (range.includes('-')) { + const [lo, hi] = range.split('-').map(Number); + if (isNaN(lo) || isNaN(hi)) continue; + start = lo; + end = hi; + } else { + start = parseInt(range, 10); + if (isNaN(start)) continue; + } + } + if (value >= start && value <= end && (value - start) % step === 0) return true; + } else if (part.includes('-')) { + const [lo, hi] = part.split('-').map(Number); + if (!isNaN(lo) && !isNaN(hi) && value >= lo && value <= hi) return true; + } else { + if (parseInt(part, 10) === value) return true; + } + } + return false; +} + +export { MAX_AUTOMATIONS_PER_SESSION, MAX_CONSECUTIVE_FAILURES, DEFAULT_EXPIRY_DAYS }; diff --git a/packages/runtime/src/automation-tools.ts b/packages/runtime/src/automation-tools.ts new file mode 100644 index 0000000000..731cc85c1b --- /dev/null +++ b/packages/runtime/src/automation-tools.ts @@ -0,0 +1,216 @@ +/** + * Unified Automation tool — single tool with mode parameter. + * + * Modes: create, delete, list, pause, resume + * Kinds: heartbeat (session-internal polling) | cron (standalone scheduled runs) + * + * Follows Codex Desktop's pattern: one tool, parameters decide behavior. + */ + +import { z } from 'zod'; +import type { MakaTool } from './tool-runtime.js'; +import type { AutomationManager, AutomationDefinition } from './automation-state.js'; + +export const AUTOMATION_TOOL_NAME = 'Automation'; + +export interface AutomationToolDeps { + automationManager: AutomationManager; + onAutomationChange?: () => void; + /** Whether the host can run cron (fresh-session) automations. When false, the + * cron kind is not advertised and is rejected at creation. */ + cronEnabled?: boolean; +} + +const scheduleSchema = z.union([ + z.object({ + type: z.literal('cron'), + expression: z.string().min(9).max(100) + .describe('5-field cron expression: "minute hour day-of-month month day-of-week". Example: "*/5 * * * *" = every 5 min, "0 9 * * 1-5" = weekdays at 9am.'), + }), + z.object({ + type: z.literal('interval'), + seconds: z.number().int().min(10).max(86400) + .describe('Repeat interval in seconds (10s to 24h).'), + }), + z.object({ + type: z.literal('once'), + delay_seconds: z.number().int().min(5).max(86400) + .describe('One-shot delay in seconds (5s to 24h). Fires once then auto-completes.'), + }), +]); + +// A SINGLE top-level object schema (Anthropic tool input_schema.type must be +// "object" — a discriminated union serializes as anyOf with no top-level type +// and the API rejects it). Per-mode fields are optional here and validated in +// impl(). mode selects the operation. +function makeAutomationSchema(kindSchema: z.ZodType) { + return z.object({ + mode: z.enum(['create', 'delete', 'list', 'pause', 'resume']) + .describe('Operation: create a new automation, delete/pause/resume one by id, or list this session\'s automations.'), + kind: kindSchema.optional(), + name: z.string().trim().min(1).max(100).optional() + .describe('[create] Short human-readable name.'), + prompt: z.string().trim().min(1).max(2000).optional() + .describe('[create] The prompt to execute on each fire.'), + schedule: scheduleSchema.optional() + .describe('[create] When to fire. Use "interval" for simple repeats, "cron" for complex schedules, "once" for one-shot.'), + max_fires: z.number().int().min(1).max(10000).optional() + .describe('[create] Maximum fires before auto-completing. Omit for unlimited (7-day expiry still applies).'), + durable: z.boolean().optional() + .describe('[create] When true, persists across app restarts. Cron defaults to true (standalone scheduled task); heartbeat defaults to false (bound to this session).'), + id: z.string().min(1).max(64).optional() + .describe('[delete/pause/resume] Automation id.'), + }); +} + +const AUTOMATION_SCHEMA_WITH_CRON = makeAutomationSchema( + z.enum(['heartbeat', 'cron']) + .describe('[create] heartbeat = resume into current session (polling/monitoring). cron = create fresh session each run (standalone scheduled tasks).'), +); +const AUTOMATION_SCHEMA_HEARTBEAT_ONLY = makeAutomationSchema( + z.enum(['heartbeat']) + .describe('[create] heartbeat = resume into current session. This host supports heartbeat only.'), +); + +// Type from the broadest (cron-enabled) schema so kind can be 'heartbeat'|'cron'. +type AutomationInput = z.infer; + +export function buildAutomationTool(deps: AutomationToolDeps): MakaTool { + const cronEnabled = deps.cronEnabled === true; + return { + name: AUTOMATION_TOOL_NAME, + displayName: 'Automation', + description: + 'Create, manage, and list recurring automations. ' + + 'Use kind "heartbeat" for session-internal polling (resumes into this conversation). ' + + (cronEnabled ? 'Use kind "cron" for standalone scheduled tasks (creates a fresh session each run). ' : '') + + 'Automations auto-expire after 7 days unless deleted earlier.', + parameters: cronEnabled ? AUTOMATION_SCHEMA_WITH_CRON : AUTOMATION_SCHEMA_HEARTBEAT_ONLY, + permissionRequired: false, + impl: (input, ctx) => { + let result: string; + switch (input.mode) { + case 'create': + result = handleCreate(deps, input, ctx.sessionId, cronEnabled); + break; + case 'delete': + result = handleById(input, (id) => deps.automationManager.delete(id, ctx.sessionId) + ? `Automation "${id}" deleted.` + : `Automation "${id}" not found or not owned by this session.`); + break; + case 'list': + return handleList(deps, ctx.sessionId); + case 'pause': { + result = handleById(input, (id) => { + const r = deps.automationManager.pause(id, ctx.sessionId); + return r ? `Automation "${r.name}" paused. Use mode "resume" to reactivate.` + : `Cannot pause "${id}": not found, not owned, or not active.`; + }); + break; + } + case 'resume': { + result = handleById(input, (id) => { + const r = deps.automationManager.resume(id, ctx.sessionId); + if (r) { + return `Automation "${r.name}" resumed. Next fire: ${r.nextFireAt ? new Date(r.nextFireAt).toLocaleString() : 'N/A'}`; + } + // Distinguish a spent fire budget from other resume failures so the + // agent doesn't keep retrying a cap that can never be revived. + const existing = deps.automationManager.get(id); + if (existing && existing.status === 'paused') { + const spent = (existing.maxFires != null && existing.fireCount >= existing.maxFires) + || (existing.schedule.type === 'once' && existing.fireCount > 0); + if (spent) { + return `Cannot resume "${id}": its fire budget is exhausted (fired ${existing.fireCount}${existing.maxFires != null ? `/${existing.maxFires}` : ''} time(s)). Create a new automation instead.`; + } + } + return `Cannot resume "${id}": not found, not owned, or not paused.`; + }); + break; + } + } + deps.onAutomationChange?.(); + return result; + }, + }; +} + +function handleById(input: AutomationInput, run: (id: string) => string): string { + if (!input.id) return 'Error: "id" is required for delete/pause/resume.'; + return run(input.id); +} + +function handleCreate( + deps: AutomationToolDeps, + input: AutomationInput, + sessionId: string, + cronEnabled: boolean, +): string { + if (!input.kind) return 'Error: "kind" is required for create.'; + if (!input.name) return 'Error: "name" is required for create.'; + if (!input.prompt) return 'Error: "prompt" is required for create.'; + if (!input.schedule) return 'Error: "schedule" is required for create.'; + if (input.kind === 'cron' && !cronEnabled) { + return 'Error: cron automations are not supported on this host. Use kind "heartbeat".'; + } + const schedule = input.schedule.type === 'once' + ? { type: 'once' as const, delaySeconds: input.schedule.delay_seconds } + : input.schedule; + + const result = deps.automationManager.create({ + kind: input.kind as 'heartbeat' | 'cron', + name: input.name, + prompt: input.prompt, + sessionId, + schedule, + maxFires: input.max_fires, + durable: input.durable, + }); + + if ('error' in result) { + return `Error: ${result.error}`; + } + + const scheduleDesc = describeSchedule(result.schedule); + return [ + `Automation created: "${result.name}" (${result.kind}${result.durable ? ', durable' : ''})`, + `ID: ${result.id}`, + `Schedule: ${scheduleDesc}`, + `Next fire: ${result.nextFireAt ? new Date(result.nextFireAt).toLocaleString() : 'N/A'}`, + result.kind === 'heartbeat' + ? 'Fires into this session. Stops when session ends or after 7 days.' + : 'Creates a fresh session each run. Expires after 7 days.', + ].join('\n'); +} + +function handleList(deps: AutomationToolDeps, sessionId: string): string { + // Includes this session's automations plus every durable (app-global) one, + // so persisted cron jobs stay queryable and manageable after a restart even + // from a fresh session. + const automations = deps.automationManager.listVisibleForSession(sessionId); + if (automations.length === 0) return 'No automations for this session.'; + + return automations.map(a => formatAutomation(a)).join('\n---\n'); +} + +function formatAutomation(a: AutomationDefinition): string { + const lines = [ + `[${a.status.toUpperCase()}] ${a.name} (${a.kind}${a.durable ? ', durable' : ''})`, + ` ID: ${a.id}`, + ` Schedule: ${describeSchedule(a.schedule)}`, + ` Fires: ${a.fireCount}${a.maxFires ? `/${a.maxFires}` : ''}`, + ]; + if (a.nextFireAt) lines.push(` Next: ${new Date(a.nextFireAt).toLocaleString()}`); + if (a.lastFireAt) lines.push(` Last: ${new Date(a.lastFireAt).toLocaleString()}`); + if (a.lastError) lines.push(` Error: ${a.lastError}`); + if (a.consecutiveFailures > 0) lines.push(` Consecutive failures: ${a.consecutiveFailures}`); + return lines.join('\n'); +} + +function describeSchedule(schedule: AutomationDefinition['schedule']): string { + switch (schedule.type) { + case 'cron': return `cron "${schedule.expression}"`; + case 'interval': return `every ${schedule.seconds}s`; + case 'once': return `once after ${schedule.delaySeconds}s`; + } +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 7015e55113..35099b343b 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -627,21 +627,6 @@ export type { RuntimeEventLike, } from './tool-availability.js'; -// ─────────────────────────────────────────────────────────────────────────── -// WakeupScheduler — session-internal CronJob scheduling (Issue #15, Primitive 4). -// ─────────────────────────────────────────────────────────────────────────── -export { WakeupScheduler, computeNextCronRun, computeJitter } from './wakeup-scheduler.js'; -export type { WakeupRecord, WakeupSchedulerDeps } from './wakeup-scheduler.js'; -export { - CRON_CREATE_TOOL_NAME, - CRON_DELETE_TOOL_NAME, - CRON_LIST_TOOL_NAME, - buildCronCreateTool, - buildCronDeleteTool, - buildCronListTool, - buildCronTools, -} from './wakeup-tools.js'; - // ─────────────────────────────────────────────────────────────────────────── // System-prompt fragments (shared by the desktop app and the CLI/TUI). // Read-only, stateless builders for project instructions, personalization, git @@ -676,3 +661,21 @@ export { export type { ProjectGitInfo } from './system-prompt/project-context.js'; export { buildSessionEnvironmentPromptFragment } from './system-prompt/session-environment-prompt.js'; export type { SessionEnvironmentPromptInput } from './system-prompt/session-environment-prompt.js'; + +// ─────────────────────────────────────────────────────────────────────────── +// Unified Automation (Codex-style: heartbeat + cron, single tool). +// ─────────────────────────────────────────────────────────────────────────── +export { AutomationManager, computeNextCronFire, matchesCronField } from './automation-state.js'; +export type { + AutomationDefinition, + AutomationKind, + AutomationSchedule, + AutomationStatus, + AutomationManagerDeps, +} from './automation-state.js'; +export { AutomationScheduler, FIRE_CHECK_INTERVAL_MS, MAX_DEFER_RETRIES } from './automation-scheduler.js'; +export type { AutomationSchedulerDeps, AutomationFireResult } from './automation-scheduler.js'; +export { buildAutomationTool, AUTOMATION_TOOL_NAME } from './automation-tools.js'; +export type { AutomationToolDeps } from './automation-tools.js'; +export { evaluateAutomationCanFire, HEARTBEAT_IDLE_STATUSES } from './automation-can-fire.js'; +export type { CanFireSessionHeader, EvaluateAutomationCanFireDeps } from './automation-can-fire.js'; diff --git a/packages/runtime/src/wakeup-scheduler.ts b/packages/runtime/src/wakeup-scheduler.ts deleted file mode 100644 index e3de8e1507..0000000000 --- a/packages/runtime/src/wakeup-scheduler.ts +++ /dev/null @@ -1,476 +0,0 @@ -/** - * WakeupScheduler — session-internal timer that re-injects a turn after a delay. - * - * The agent calls `schedule_wakeup` to arrange a future synthetic turn in the - * same session, enabling polling/monitoring loops without user interaction. - * Analogous to Claude Code's ScheduleWakeup tool. - */ - -// ─── Minimal cron parser (no import from core to avoid circular deps) ──────── - -interface ParsedCronField { - wildcard: boolean; - values: Set; -} - -interface ParsedCronExpression { - minute: ParsedCronField; - hour: ParsedCronField; - dayOfMonth: ParsedCronField; - month: ParsedCronField; - dayOfWeek: ParsedCronField; -} - -function parseCronInteger(input: string, min: number, max: number): number | null { - if (!/^\d+$/.test(input)) return null; - const value = Number(input); - if (!Number.isSafeInteger(value) || value < min || value > max) return null; - return value; -} - -function parseCronField(input: string, min: number, max: number, normalizeSevenToZero: boolean): ParsedCronField | null { - if (!/^[\d*,/\-]+$/.test(input)) return null; - const values = new Set(); - let wildcard = false; - - for (const rawPart of input.split(',')) { - if (!rawPart) return null; - const stepSplit = rawPart.split('/'); - if (stepSplit.length > 2) return null; - - const base = stepSplit[0] ?? ''; - const stepVal = stepSplit[1] === undefined ? 1 : parseCronInteger(stepSplit[1], 1, max - min + 1); - if (stepVal === null) return null; - - let start: number; - let end: number; - - if (base === '*') { - if (stepSplit.length === 1) wildcard = true; - start = min; - end = max; - } else if (base.includes('-')) { - const range = base.split('-'); - if (range.length !== 2) return null; - const parsedStart = parseCronInteger(range[0] ?? '', min, max); - const parsedEnd = parseCronInteger(range[1] ?? '', min, max); - if (parsedStart === null || parsedEnd === null || parsedStart > parsedEnd) return null; - start = parsedStart; - end = parsedEnd; - } else { - const parsed = parseCronInteger(base, min, max); - if (parsed === null) return null; - start = parsed; - end = parsed; - } - - for (let v = start; v <= end; v += stepVal) { - values.add(normalizeSevenToZero && v === 7 ? 0 : v); - } - } - - if (values.size === 0) return null; - return { wildcard, values }; -} - -function parseCronExpression(expression: string): ParsedCronExpression | null { - const parts = expression.trim().split(/\s+/); - if (parts.length !== 5) return null; - - const minute = parseCronField(parts[0]!, 0, 59, false); - if (!minute) return null; - const hour = parseCronField(parts[1]!, 0, 23, false); - if (!hour) return null; - const dayOfMonth = parseCronField(parts[2]!, 1, 31, false); - if (!dayOfMonth) return null; - const month = parseCronField(parts[3]!, 1, 12, false); - if (!month) return null; - const dayOfWeek = parseCronField(parts[4]!, 0, 7, true); - if (!dayOfWeek) return null; - - return { minute, hour, dayOfMonth, month, dayOfWeek }; -} - -function cronExpressionMatches(expr: ParsedCronExpression, date: Date): boolean { - if (!expr.minute.values.has(date.getMinutes())) return false; - if (!expr.hour.values.has(date.getHours())) return false; - if (!expr.month.values.has(date.getMonth() + 1)) return false; - - const dayOfMonthMatches = expr.dayOfMonth.values.has(date.getDate()); - const dayOfWeekMatches = expr.dayOfWeek.values.has(date.getDay()); - - // Standard cron: if both day-of-month and day-of-week are restricted (non-wildcard), - // match if EITHER matches. If only one is restricted, both must match. - if (!expr.dayOfMonth.wildcard && !expr.dayOfWeek.wildcard) { - return dayOfMonthMatches || dayOfWeekMatches; - } - return dayOfMonthMatches && dayOfWeekMatches; -} - -/** - * Compute the next cron run time after the given epoch-ms timestamp. - * Scans minute-by-minute up to ~366 days into the future. - * Returns epoch-ms of next matching minute, or null if expression is invalid - * or no match found within the scan window. - */ -export function computeNextCronRun(expression: string, afterMs: number): number | null { - const parsed = parseCronExpression(expression); - if (!parsed) return null; - - // Start from the next full minute after `afterMs` - const start = new Date(afterMs); - start.setSeconds(0, 0); - start.setMinutes(start.getMinutes() + 1); - - // Scan up to 366 days * 24 hours * 60 minutes = 527,040 iterations max - const maxIterations = 366 * 24 * 60; - const candidate = new Date(start.getTime()); - - for (let i = 0; i < maxIterations; i++) { - if (cronExpressionMatches(parsed, candidate)) { - return candidate.getTime(); - } - candidate.setMinutes(candidate.getMinutes() + 1); - } - - return null; -} - -// ─── WakeupScheduler ───────────────────────────────────────────────────────── - -export interface WakeupRecord { - id: string; - sessionId: string; - message: string; - reason: string; - scheduledAt: number; - firesAt: number; - delaySeconds: number; - recurring: boolean; - /** Standard 5-field cron expression (minute hour dom month dow). */ - cronExpression?: string; - status: 'pending' | 'fired' | 'cancelled' | 'expired'; - /** For recurring jobs: absolute timestamp when this job chain expires. */ - expiresAt: number | null; - /** Number of times fire() was attempted (including idle-deferred attempts). */ - fireAttempts: number; - /** Timestamps when fire was deferred due to non-idle session. */ - deferredFires: number[]; -} - -export interface WakeupSchedulerDeps { - newId: () => string; - now: () => number; - injectTurn: (sessionId: string, input: { turnId: string; text: string }) => void; - canFire: (sessionId: string) => Promise; - setTimer?: (cb: () => void, ms: number) => ReturnType; - clearTimer?: (timer: ReturnType) => void; - random?: () => number; -} - -const MAX_WAKEUPS_PER_SESSION = 5; -const MAX_DELAY_SECONDS = 86_400; -const BACKOFF_BASE_MS = 5_000; -/** Idle-gate backoff cap: retries stretch 5s → 10s → … → 5min. */ -const BACKOFF_MAX_MS = 5 * 60 * 1000; -/** - * Review fix (first-principles): the whole point of a wakeup is to fire - * after long-running work; a 3×5s retry window silently dropped any wakeup - * that landed mid-turn (agent turns routinely run for minutes). Exponential - * backoff up to 5min × 12 attempts waits ~45 minutes before giving up. - */ -const MAX_FIRE_RETRIES = 12; - -/** Recurring jobs auto-expire after 7 days to prevent infinite loops. */ -const MAX_RECURRING_AGE_MS = 7 * 24 * 60 * 60 * 1000; - -/** Maximum jitter cap for recurring re-schedules: 15 minutes. */ -const MAX_JITTER_MS = 15 * 60 * 1000; - -/** Maximum early jitter for one-shot jobs firing on round minutes: 90 seconds. */ -const ONE_SHOT_JITTER_MS = 90 * 1000; - -/** Maximum total records per session before oldest fired/expired records are pruned. */ -export const MAX_RECORDS_PER_SESSION = 50; - -/** - * Compute jitter to add to a scheduled delay. - * - * - Recurring: up to 10% of the delay, capped at 15 minutes. - * - One-shot firing on :00 or :30: up to 90s early jitter (returned as negative). - * Otherwise 0 for one-shot. - */ -export function computeJitter( - delayMs: number, - recurring: boolean, - random: () => number = Math.random, - firesAtMs?: number, -): number { - if (recurring) { - const maxJitter = Math.min(delayMs * 0.1, MAX_JITTER_MS); - return Math.floor(random() * maxJitter); - } - // One-shot thundering-herd mitigation: if the ACTUAL fire time lands on a - // :00/:30 wall-clock minute, pull it up to 90s early. (Review fix: this - // used to test `delayMs % 30min`, but a 30-minute delay from 10:07 fires - // at 10:37 — the round-mark property belongs to the timestamp, not the - // delay.) - if (firesAtMs !== undefined && new Date(firesAtMs).getMinutes() % 30 === 0) { - return -(Math.floor(random() * ONE_SHOT_JITTER_MS)); - } - return 0; -} - -export class WakeupScheduler { - private readonly records = new Map(); - private readonly timers = new Map>(); - private readonly retries = new Map(); - private readonly deps: Required; - private disposed = false; - - constructor(deps: WakeupSchedulerDeps) { - this.deps = { - setTimer: deps.setTimer ?? setTimeout, - clearTimer: deps.clearTimer ?? clearTimeout, - random: deps.random ?? Math.random, - ...deps, - } as Required; - } - - schedule( - sessionId: string, - input: { - delaySeconds?: number; - cronExpression?: string; - message: string; - reason: string; - recurring?: boolean; - }, - ): WakeupRecord { - const pendingCount = this.listForSession(sessionId).filter(r => r.status === 'pending').length; - if (pendingCount >= MAX_WAKEUPS_PER_SESSION) { - throw new Error(`Max ${MAX_WAKEUPS_PER_SESSION} pending wakeups per session.`); - } - - const now = this.deps.now(); - const recurring = input.recurring ?? false; - let firesAt: number; - let delaySeconds: number; - - if (input.cronExpression && input.delaySeconds !== undefined) { - throw new Error('Provide cronExpression or delaySeconds, not both.'); - } - - if (input.cronExpression) { - // Cron-based scheduling: compute next matching time - const nextRun = computeNextCronRun(input.cronExpression, now); - if (nextRun === null) { - throw new Error('Invalid cron expression or no matching time found within scan window.'); - } - firesAt = nextRun; - delaySeconds = Math.round((nextRun - now) / 1000); - } else if (input.delaySeconds !== undefined) { - if (input.delaySeconds < 1 || input.delaySeconds > MAX_DELAY_SECONDS) { - throw new Error(`delay_seconds must be between 1 and ${MAX_DELAY_SECONDS}.`); - } - delaySeconds = input.delaySeconds; - const delayMs = delaySeconds * 1000; - // Apply jitter to the initial fire time (round-mark check uses the - // actual candidate timestamp, not the delay) - const jitter = computeJitter(delayMs, recurring, this.deps.random, now + delayMs); - const adjustedDelay = Math.max(0, delayMs + jitter); - firesAt = now + adjustedDelay; - } else { - throw new Error('Either cronExpression or delaySeconds must be provided.'); - } - - const record: WakeupRecord = { - id: this.deps.newId(), - sessionId, - message: input.message, - reason: input.reason, - scheduledAt: now, - firesAt, - delaySeconds, - recurring, - cronExpression: input.cronExpression, - status: 'pending', - expiresAt: recurring ? now + MAX_RECURRING_AGE_MS : null, - fireAttempts: 0, - deferredFires: [], - }; - - this.records.set(record.id, record); - this.pruneSession(sessionId); - this.scheduleTimer(record); - return record; - } - - cancel(wakeupId: string): boolean { - const record = this.records.get(wakeupId); - if (!record || record.status !== 'pending') return false; - record.status = 'cancelled'; - const timer = this.timers.get(wakeupId); - if (timer !== undefined) { - this.deps.clearTimer(timer); - this.timers.delete(wakeupId); - } - this.retries.delete(wakeupId); - return true; - } - - cancelAllForSession(sessionId: string): void { - for (const record of this.records.values()) { - if (record.sessionId === sessionId && record.status === 'pending') { - this.cancel(record.id); - } - } - } - - listForSession(sessionId: string, opts?: { activeOnly?: boolean }): WakeupRecord[] { - const all = [...this.records.values()].filter(r => r.sessionId === sessionId); - if (opts?.activeOnly) { - return all.filter(r => r.status === 'pending'); - } - return all; - } - - /** - * Remove fired/expired/cancelled records for a session that exceed the cap. - * Keeps all pending records; drops oldest terminal records first. - */ - private pruneSession(sessionId: string): void { - const sessionRecords = [...this.records.values()].filter(r => r.sessionId === sessionId); - if (sessionRecords.length <= MAX_RECORDS_PER_SESSION) return; - - // Sort terminal records by scheduledAt ascending (oldest first) - const terminal = sessionRecords - .filter(r => r.status === 'fired' || r.status === 'expired' || r.status === 'cancelled') - .sort((a, b) => a.scheduledAt - b.scheduledAt); - - const excess = sessionRecords.length - MAX_RECORDS_PER_SESSION; - for (let i = 0; i < excess && i < terminal.length; i++) { - this.records.delete(terminal[i].id); - } - } - - dispose(): void { - this.disposed = true; - for (const timer of this.timers.values()) { - this.deps.clearTimer(timer); - } - for (const record of this.records.values()) { - if (record.status === 'pending') record.status = 'cancelled'; - } - this.timers.clear(); - this.records.clear(); - this.retries.clear(); - } - - private scheduleTimer(record: WakeupRecord): void { - const delay = Math.max(0, record.firesAt - this.deps.now()); - const timer = this.deps.setTimer(() => { - this.timers.delete(record.id); - void this.fire(record); - }, delay); - this.timers.set(record.id, timer); - } - - private async fire(record: WakeupRecord): Promise { - if (this.disposed) return; - if (record.status !== 'pending') return; - - // Track fire attempts for idle-gate observability - record.fireAttempts += 1; - - // Auto-expire check for recurring jobs - if (record.recurring && record.expiresAt !== null) { - const now = this.deps.now(); - if (now >= record.expiresAt) { - record.status = 'expired'; - this.retries.delete(record.id); - return; - } - } - - const canFire = await this.deps.canFire(record.sessionId).catch(() => false); - // Re-check after async gap: cancel() or dispose() may have changed status - if (record.status !== 'pending') return; - if (!canFire) { - // Idle-gate: log the deferred fire attempt - record.deferredFires.push(this.deps.now()); - - const retryCount = (this.retries.get(record.id) ?? 0) + 1; - this.retries.set(record.id, retryCount); - if (retryCount >= MAX_FIRE_RETRIES) { - record.status = 'expired'; - this.retries.delete(record.id); - return; - } - const backoffMs = Math.min(BACKOFF_BASE_MS * 2 ** (retryCount - 1), BACKOFF_MAX_MS); - const backoffTimer = this.deps.setTimer(() => { - this.timers.delete(record.id); - void this.fire(record); - }, backoffMs); - this.timers.set(record.id, backoffTimer); - return; - } - - record.status = 'fired'; - this.retries.delete(record.id); - const turnId = this.deps.newId(); - this.deps.injectTurn(record.sessionId, { - turnId, - text: `[Scheduled wakeup: ${record.reason}]\n\n${record.message}`, - }); - - if (record.recurring) { - const now = this.deps.now(); - - // Check auto-expire before scheduling next occurrence - if (record.expiresAt !== null && now >= record.expiresAt) { - // Chain has expired; keep the terminal record for observability. - record.status = 'expired'; - this.pruneSession(record.sessionId); - return; - } - - let nextFiresAt: number; - let nextDelaySeconds: number; - - if (record.cronExpression) { - // For cron-based recurring: compute the NEXT cron match after now - const nextRun = computeNextCronRun(record.cronExpression, now); - if (nextRun === null) { - // No future match found — stop recurring; keep the terminal record. - record.status = 'expired'; - this.pruneSession(record.sessionId); - return; - } - nextFiresAt = nextRun; - nextDelaySeconds = Math.round((nextRun - now) / 1000); - } else { - // Fixed-delay recurring: same delay as before, with jitter - const delayMs = record.delaySeconds * 1000; - const jitter = computeJitter(delayMs, true, this.deps.random); - const adjustedDelay = Math.max(0, delayMs + jitter); - nextFiresAt = now + adjustedDelay; - nextDelaySeconds = record.delaySeconds; - } - - // Reuse the same record in-place: update fields for next occurrence - record.status = 'pending'; - record.scheduledAt = now; - record.firesAt = nextFiresAt; - record.delaySeconds = nextDelaySeconds; - record.fireAttempts = 0; - record.deferredFires = []; - this.scheduleTimer(record); - } else { - // Non-recurring job: keep the fired record for observability - // (list/CronList can show what just fired); pruneSession caps - // per-session terminal history at MAX_RECORDS_PER_SESSION. - this.pruneSession(record.sessionId); - } - } -} diff --git a/packages/runtime/src/wakeup-tools.ts b/packages/runtime/src/wakeup-tools.ts deleted file mode 100644 index d97cc3572e..0000000000 --- a/packages/runtime/src/wakeup-tools.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Agent-facing cron tools aligned with Claude Code's CronCreate/CronDelete/CronList. - * - * CronCreate schedules a prompt to fire at a future time — either recurring - * (cron expression) or one-shot. Session-only by default (v1: no durable mode). - */ -import { z } from 'zod'; -import type { MakaTool } from './tool-runtime.js'; -import type { WakeupScheduler } from './wakeup-scheduler.js'; - -export const CRON_CREATE_TOOL_NAME = 'CronCreate'; -export const CRON_DELETE_TOOL_NAME = 'CronDelete'; -export const CRON_LIST_TOOL_NAME = 'CronList'; - -export function buildCronCreateTool(scheduler: WakeupScheduler): MakaTool< - { delay_seconds?: number; cron?: string; prompt: string; reason: string; recurring?: boolean }, - unknown -> { - return { - name: CRON_CREATE_TOOL_NAME, - displayName: '定时任务', - description: - 'Schedule a prompt to run at a future time within this session. ' + - 'When the timer fires, the prompt is injected as a new turn and the agent continues working. ' + - 'Use for polling, monitoring, periodic checks, or delayed actions. ' + - 'Provide either delay_seconds (one-shot/fixed-interval) or cron (5-field cron expression). ' + - 'Uses standard 5-field cron in the user\'s local timezone. ' + - 'Jobs are session-only (in-memory, gone when the session ends). ' + - 'Max 5 pending jobs per session, delay 1-86400 seconds.', - parameters: z.object({ - delay_seconds: z.number().int().min(1).max(86400).optional() - .describe('Seconds from now to fire. Min 1, max 86400 (24h). Either this or cron must be provided.'), - cron: z.string().max(100).optional() - .describe('Standard 5-field cron expression (minute hour day-of-month month day-of-week). Uses the user\'s local timezone. Either this or delay_seconds must be provided.'), - prompt: z.string().min(1).max(10000) - .describe('The prompt/instruction to execute when the timer fires.'), - reason: z.string().min(1).max(200) - .describe('One short sentence explaining the chosen schedule. Shown to the user.'), - recurring: z.boolean().optional() - .describe('If true, re-schedule the same job after each fire. Default false (one-shot).'), - }).refine( - data => data.delay_seconds !== undefined || (data.cron !== undefined && data.cron.length > 0), - { message: 'Either delay_seconds or cron must be provided.' }, - ), - categoryHint: 'custom_tool', - impl: async (input, ctx) => { - try { - const record = scheduler.schedule(ctx.sessionId, { - delaySeconds: input.delay_seconds, - cronExpression: input.cron, - message: input.prompt, - reason: input.reason, - recurring: input.recurring, - }); - return { - ok: true, - job_id: record.id, - fires_at: record.firesAt, - fires_in_seconds: record.delaySeconds, - recurring: input.recurring ?? false, - cron: input.cron ?? null, - }; - } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : String(error) }; - } - }, - }; -} - -export function buildCronDeleteTool(scheduler: WakeupScheduler): MakaTool<{ job_id: string }, unknown> { - return { - name: CRON_DELETE_TOOL_NAME, - displayName: '取消定时任务', - description: 'Cancel a scheduled cron job by its ID. Use CronList to find job IDs.', - parameters: z.object({ - job_id: z.string().min(1).describe('The job_id returned by CronCreate.'), - }), - categoryHint: 'custom_tool', - impl: async ({ job_id }) => { - const cancelled = scheduler.cancel(job_id); - return { ok: true, cancelled }; - }, - }; -} - -export function buildCronListTool(scheduler: WakeupScheduler): MakaTool, unknown> { - return { - name: CRON_LIST_TOOL_NAME, - displayName: '列出定时任务', - description: 'List all active (pending) cron jobs in this session.', - parameters: z.object({}), - categoryHint: 'custom_tool', - impl: async (_input, ctx) => { - const records = scheduler.listForSession(ctx.sessionId, { activeOnly: true }); - return { - ok: true, - count: records.length, - jobs: records.map(r => ({ - job_id: r.id, - status: r.status, - prompt: r.message.slice(0, 100), - reason: r.reason, - fires_at: r.firesAt, - recurring: r.recurring ?? false, - cron: r.cronExpression ?? null, - fire_attempts: r.fireAttempts, - })), - }; - }, - }; -} - -export function buildCronTools(scheduler: WakeupScheduler): MakaTool[] { - return [ - buildCronCreateTool(scheduler), - buildCronDeleteTool(scheduler), - buildCronListTool(scheduler), - ] as MakaTool[]; -} diff --git a/packages/storage/src/__tests__/automation-store.test.ts b/packages/storage/src/__tests__/automation-store.test.ts new file mode 100644 index 0000000000..7270ccce2a --- /dev/null +++ b/packages/storage/src/__tests__/automation-store.test.ts @@ -0,0 +1,126 @@ +import { describe, test, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdir, rm, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createAutomationStore } from '../automation-store.js'; + +interface TestRecord { + id: string; + name: string; + status?: string; +} + +const TEST_DIR = join(tmpdir(), `maka-automation-store-test-${process.pid}`); + +describe('AutomationStore', () => { + beforeEach(async () => { + await mkdir(TEST_DIR, { recursive: true }); + }); + + afterEach(async () => { + await rm(TEST_DIR, { recursive: true, force: true }); + }); + + test('loadAll returns empty array when file does not exist', async () => { + const store = createAutomationStore(TEST_DIR); + const result = await store.loadAll(); + assert.deepEqual(result, []); + }); + + test('save persists automation to disk', async () => { + const store = createAutomationStore(TEST_DIR); + await store.save({ id: 'auto-1', name: 'test', status: 'active' }); + + const raw = await readFile(join(TEST_DIR, 'automations.json'), 'utf8'); + const parsed = JSON.parse(raw); + assert.equal(parsed.version, 1); + assert.equal(parsed.automations.length, 1); + assert.equal(parsed.automations[0].id, 'auto-1'); + }); + + test('loadAll reads back saved automations', async () => { + const store = createAutomationStore(TEST_DIR); + await store.save({ id: 'auto-1', name: 'first' }); + await store.save({ id: 'auto-2', name: 'second' }); + + const result = await store.loadAll(); + assert.equal(result.length, 2); + assert.equal(result[0].id, 'auto-1'); + assert.equal(result[1].id, 'auto-2'); + }); + + test('save updates existing automation by id', async () => { + const store = createAutomationStore(TEST_DIR); + await store.save({ id: 'auto-1', name: 'original' }); + await store.save({ id: 'auto-1', name: 'updated' }); + + const result = await store.loadAll(); + assert.equal(result.length, 1); + assert.equal(result[0].name, 'updated'); + }); + + test('remove deletes automation from file', async () => { + const store = createAutomationStore(TEST_DIR); + await store.save({ id: 'auto-1', name: 'a' }); + await store.save({ id: 'auto-2', name: 'b' }); + + await store.remove('auto-1'); + const result = await store.loadAll(); + assert.equal(result.length, 1); + assert.equal(result[0].id, 'auto-2'); + }); + + test('remove with nonexistent id is a no-op', async () => { + const store = createAutomationStore(TEST_DIR); + await store.save({ id: 'auto-1', name: 'a' }); + + await store.remove('nonexistent'); + const result = await store.loadAll(); + assert.equal(result.length, 1); + }); + + test('sync replaces all automations at once', async () => { + const store = createAutomationStore(TEST_DIR); + await store.save({ id: 'old-1', name: 'old' }); + + await store.sync([ + { id: 'new-1', name: 'alpha' }, + { id: 'new-2', name: 'beta' }, + ]); + + const result = await store.loadAll(); + assert.equal(result.length, 2); + assert.equal(result[0].id, 'new-1'); + assert.equal(result[1].id, 'new-2'); + }); + + test('loadAll FAILS LOUD on a corrupt file (never masks unreadable data as empty)', async () => { + const { writeFile } = await import('node:fs/promises'); + await writeFile(join(TEST_DIR, 'automations.json'), 'not valid json{{{', 'utf8'); + + const store = createAutomationStore(TEST_DIR); + // Returning [] here would let a subsequent full-overwrite sync erase real data. + await assert.rejects(() => store.loadAll(), /not valid JSON/); + }); + + test('loadAll FAILS LOUD on an unrecognized version/shape', async () => { + const { writeFile } = await import('node:fs/promises'); + await writeFile(join(TEST_DIR, 'automations.json'), JSON.stringify({ version: 99, automations: [] }), 'utf8'); + + const store = createAutomationStore(TEST_DIR); + await assert.rejects(() => store.loadAll(), /unrecognized shape or version/); + }); + + test('atomic write: file is not corrupted on concurrent saves', async () => { + const store = createAutomationStore(TEST_DIR); + await Promise.all([ + store.save({ id: 'a', name: 'alpha' }), + store.save({ id: 'b', name: 'beta' }), + store.save({ id: 'c', name: 'gamma' }), + ]); + + const result = await store.loadAll(); + assert.equal(result.length, 3); + }); +}); diff --git a/packages/storage/src/automation-store.ts b/packages/storage/src/automation-store.ts new file mode 100644 index 0000000000..392ad79d07 --- /dev/null +++ b/packages/storage/src/automation-store.ts @@ -0,0 +1,104 @@ +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { chainWrite } from './write-queue.js'; + +/** Minimal constraint for records stored by the automation store. */ +export interface AutomationRecord { + id: string; +} + +export interface AutomationStore { + loadAll(): Promise; + save(automation: T): Promise; + remove(id: string): Promise; + sync(automations: T[]): Promise; +} + +interface AutomationFile { + version: 1; + automations: AutomationRecord[]; +} + +export function createAutomationStore( + workspaceRoot: string, +): AutomationStore { + return new FileAutomationStore(workspaceRoot); +} + +class FileAutomationStore implements AutomationStore { + private readonly filePath: string; + private readonly writeQueue = new Map>(); + private static readonly QUEUE_KEY = 'automations'; + + constructor(workspaceRoot: string) { + this.filePath = join(workspaceRoot, 'automations.json'); + } + + async loadAll(): Promise { + let text: string; + try { + text = await readFile(this.filePath, 'utf8'); + } catch (error) { + // Absent file → legitimately empty store, safe to start fresh. + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + // A present-but-unreadable file (EMFILE/EBUSY/EACCES/…) must NOT be masked + // as empty: a caller that then full-overwrites the store would erase data + // it never read. Fail loud so the host disables persistence instead. + throw new Error(`[automation-store] failed to read ${this.filePath}: ${(error as Error).message}`); + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (error) { + throw new Error(`[automation-store] ${this.filePath} is not valid JSON: ${(error as Error).message}`); + } + if (!isAutomationFile(parsed)) { + // Present but unrecognized (wrong version/shape) — same danger as a read + // error: treating it as empty and overwriting would drop real data. + throw new Error(`[automation-store] ${this.filePath} has an unrecognized shape or version`); + } + return parsed.automations as T[]; + } + + async save(automation: T): Promise { + await chainWrite(this.writeQueue, FileAutomationStore.QUEUE_KEY, async () => { + const current = await this.loadAll(); + const index = current.findIndex(a => a.id === automation.id); + if (index >= 0) { + current[index] = automation; + } else { + current.push(automation); + } + await this.writeFile(current); + }); + } + + async remove(id: string): Promise { + await chainWrite(this.writeQueue, FileAutomationStore.QUEUE_KEY, async () => { + const current = await this.loadAll(); + const filtered = current.filter(a => a.id !== id); + if (filtered.length === current.length) return; + await this.writeFile(filtered); + }); + } + + async sync(automations: T[]): Promise { + await chainWrite(this.writeQueue, FileAutomationStore.QUEUE_KEY, async () => { + await this.writeFile(automations); + }); + } + + private async writeFile(automations: T[]): Promise { + const data: AutomationFile = { version: 1, automations }; + await mkdir(dirname(this.filePath), { recursive: true }); + const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.tmp`; + await writeFile(tempPath, JSON.stringify(data, null, 2) + '\n', 'utf8'); + await rename(tempPath, this.filePath); + } +} + +function isAutomationFile(value: unknown): value is AutomationFile { + if (typeof value !== 'object' || value === null) return false; + const obj = value as Record; + return obj.version === 1 && Array.isArray(obj.automations); +} diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index 227623eb36..2a9bbdd0d4 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -21,3 +21,4 @@ export * from './artifact-store.js'; export * from './plan-reminder-store.js'; export * from './task-ledger-store.js'; export * from './config-transfer.js'; +export * from './automation-store.js'; diff --git a/packages/ui/src/tool-activity.tsx b/packages/ui/src/tool-activity.tsx index 69f5053085..4b6ebce511 100644 --- a/packages/ui/src/tool-activity.tsx +++ b/packages/ui/src/tool-activity.tsx @@ -72,84 +72,6 @@ function LoadToolResultPreview(props: { args: unknown; value: unknown }) { ); } -// ── CronJob result preview ────────────────────────────────────────────────── - -const CRON_TOOL_NAMES: ReadonlySet = new Set(['CronCreate', 'CronDelete', 'CronList']); - -function isCronTool(name: string): boolean { - return CRON_TOOL_NAMES.has(name); -} - -/** Compact preview card for CronCreate / CronDelete / CronList results. */ -function CronJobResultPreview(props: { toolName: string; value: unknown }) { - const data = props.value && typeof props.value === 'object' ? (props.value as Record) : {}; - const ok = data.ok === true; - - if (props.toolName === 'CronCreate' && ok) { - const recurring = data.recurring === true; - const firesIn = typeof data.fires_in_seconds === 'number' ? data.fires_in_seconds : null; - const cron = typeof data.cron === 'string' ? data.cron : null; - const jobId = typeof data.job_id === 'string' ? data.job_id : null; - const Icon = recurring ? Repeat : Clock; - return ( -
-

-

- {firesIn !== null && ( -

- {firesIn < 60 ? `${firesIn}s 后触发` : `${Math.round(firesIn / 60)}min 后触发`} -

- )} - {cron &&

cron: {cron}

} - {jobId &&

job_id: {jobId.slice(0, 8)}

} -
- ); - } - - if (props.toolName === 'CronDelete' && ok) { - const cancelled = data.cancelled === true; - return ( -
-

-

-
- ); - } - - if (props.toolName === 'CronList' && ok) { - const count = typeof data.count === 'number' ? data.count : 0; - const jobs = Array.isArray(data.jobs) ? data.jobs : []; - return ( -
-

-

- {jobs.slice(0, 5).map((job, i) => { - const j = job && typeof job === 'object' ? (job as Record) : {}; - const recurring = j.recurring === true; - const status = typeof j.status === 'string' ? j.status : 'unknown'; - const reason = typeof j.reason === 'string' ? j.reason : ''; - const JobIcon = recurring ? Repeat : Clock; - return ( -

-

- ); - })} -
- ); - } - - // Fallback for error or unexpected shape - return ; -} - const STATUS_LABEL: Record = { pending: '排队中', waiting_permission: '等待权限', @@ -311,8 +233,6 @@ function ToolCardBody({ item }: { item: ToolActivityItem }) { {item.result && !permissionDenied && ( isConnectorTool(item.toolName) && item.result.kind === 'json' ? ( - ) : isCronTool(item.toolName) && item.result.kind === 'json' ? ( - ) : ( ) diff --git a/scripts/check-console.mjs b/scripts/check-console.mjs index f551c4ba7b..43a4e6ab51 100644 --- a/scripts/check-console.mjs +++ b/scripts/check-console.mjs @@ -82,6 +82,14 @@ const ALLOW = new Map([ 'scripts/check-console.mjs', 'this script — explicit allow.', ], + [ + 'apps/desktop/src/main/automation-wiring.ts', + 'best-effort sync warning when durable automation persistence fails.', + ], + [ + 'packages/storage/src/automation-store.ts', + 'best-effort warning when automation store read/write fails.', + ], ]); async function walk(root) {