From da6171e79e0434ddd85a9c3287d7afead7add9f2 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Mon, 6 Jul 2026 08:22:00 +0800 Subject: [PATCH 01/23] =?UTF-8?q?feat(runtime):=20unified=20Automation=20t?= =?UTF-8?q?ool=20=E2=80=94=20Codex-style=20heartbeat=20+=20cron=20scheduli?= =?UTF-8?q?ng?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the fragmented CronCreate/CronDelete/CronList approach (#545) with a single `Automation` tool using a `mode` parameter (create/delete/list/pause/resume) and a `kind` parameter (heartbeat vs cron), following Codex Desktop's pattern. Key design decisions: - One tool, one concept: model only decides heartbeat (continue session) vs cron (fresh session) - Schedule supports: cron 5-field expressions, interval seconds, or one-shot delay - Optional durable persistence (JSON file, atomic writes) for cross-restart survival - 7-day auto-expiry, max_fires cap, consecutive-failure auto-pause (5 strikes) - Scheduler tick every 5s, defers when session busy (max 120s then skip) - Desktop + CLI/TUI both integrated with full persistence and turn-tail injection Addresses PR #545 reviewer feedback (Astro-Han): - Not in "unhappy middle": ephemeral heartbeats are honestly session-scoped, durable automations persist to disk. Each kind delivers what its API promises. - Trace is free: injectTurn → sendMessage → AgentRun → full RuntimeEvent trace without new event types or data model changes. - No dependency on #544 task/run/trace infrastructure. 3 rounds of adversarial review, 26 bugs found and fixed. --- apps/desktop/src/main/automation-wiring.ts | 59 +++ apps/desktop/src/main/main.ts | 25 ++ packages/cli/src/cli-system-prompt.ts | 33 +- packages/cli/src/runtime-bootstrap.ts | 50 ++- .../__tests__/automation-scheduler.test.ts | 271 ++++++++++++++ .../runtime/src/__tests__/automation.test.ts | 339 ++++++++++++++++++ packages/runtime/src/automation-scheduler.ts | 151 ++++++++ packages/runtime/src/automation-state.ts | 339 ++++++++++++++++++ packages/runtime/src/automation-tools.ts | 211 +++++++++++ packages/runtime/src/index.ts | 16 + packages/storage/src/automation-store.ts | 94 +++++ packages/storage/src/index.ts | 1 + scripts/check-console.mjs | 8 + 13 files changed, 1593 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/main/automation-wiring.ts create mode 100644 packages/runtime/src/__tests__/automation-scheduler.test.ts create mode 100644 packages/runtime/src/__tests__/automation.test.ts create mode 100644 packages/runtime/src/automation-scheduler.ts create mode 100644 packages/runtime/src/automation-state.ts create mode 100644 packages/runtime/src/automation-tools.ts create mode 100644 packages/storage/src/automation-store.ts diff --git a/apps/desktop/src/main/automation-wiring.ts b/apps/desktop/src/main/automation-wiring.ts new file mode 100644 index 0000000000..6c16544b74 --- /dev/null +++ b/apps/desktop/src/main/automation-wiring.ts @@ -0,0 +1,59 @@ +import { randomUUID } from 'node:crypto'; +import { AutomationManager, AutomationScheduler, buildAutomationTool, type AutomationDefinition, type MakaTool } from '@maka/runtime'; +import { createAutomationStore } from '@maka/storage'; + +/** + * 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: (sessionId: string) => Promise; + injectTurn: (sessionId: string, prompt: string, automationId: string) => void; + createFreshRun?: (prompt: string, automationId: string) => void; +} + +export function createMainAutomationWiring(deps: CreateMainAutomationWiringDeps): MainAutomationWiring { + const manager = new AutomationManager({ + generateId: () => randomUUID(), + now: () => Date.now(), + }); + + const store = createAutomationStore(deps.workspaceRoot); + + const syncDurableToStore = (): void => { + 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); + }); + }; + + 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, + })]; + + const loadDurableAutomations = async (): Promise => { + const saved = await store.loadAll(); + manager.registerAll(saved); + }; + + return { manager, scheduler, tools, loadDurableAutomations }; +} diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index f2a38b33db..29f9a8478a 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -165,6 +165,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 } from './automation-wiring.js'; import { createOAuthModelConnectionsMainService } from './oauth-model-connections-main.js'; import { applyNetworkPatch, @@ -303,6 +304,26 @@ 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(sessionId: string): Promise { + const header = await store.readHeader(sessionId); + if (!header || header.archivedAt) return false; + if (header.status === 'running' || header.status === 'blocked') return false; + return true; + }, + injectTurn(sessionId: string, prompt: string, _automationId: string) { + const turnId = randomUUID(); + const iterator = runtime.sendMessage(sessionId, { turnId, text: prompt }); + void streamEvents(sessionId, iterator, turnId); + }, +}); + +// 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 }; @@ -408,6 +429,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, + // 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, @@ -1869,6 +1892,7 @@ async function runBackgroundStartup(): Promise { onConnectionsChanged: () => emitConnectionListChanged(), onSettingsChanged: () => void handleExternalSettingsChange(), }); + automationWiring.scheduler.start(); } app.on('window-all-closed', () => { @@ -1876,6 +1900,7 @@ app.on('window-all-closed', () => { }); app.on('before-quit', () => { + automationWiring.scheduler.dispose(); configWatcher?.stop(); planReminders.stopTimers(); dailyReview.stopScheduler(); diff --git a/packages/cli/src/cli-system-prompt.ts b/packages/cli/src/cli-system-prompt.ts index 9c31152e0b..94b2154233 100644 --- a/packages/cli/src/cli-system-prompt.ts +++ b/packages/cli/src/cli-system-prompt.ts @@ -4,6 +4,7 @@ import { 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/runtime-bootstrap.ts b/packages/cli/src/runtime-bootstrap.ts index 74e8270620..96d9d72b0e 100644 --- a/packages/cli/src/runtime-bootstrap.ts +++ b/packages/cli/src/runtime-bootstrap.ts @@ -3,9 +3,12 @@ import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { AiSdkBackend, + AutomationManager, + AutomationScheduler, BackendRegistry, PermissionEngine, SessionManager, + buildAutomationTool, buildBuiltinTools, buildDefaultContextBudgetPolicy, buildManualCompactLookupPolicy, @@ -14,10 +17,12 @@ import { getAIModel, loadHistoryCompactBlocksFromArtifacts, persistHistoryCompactBlocksToArtifacts, + type AutomationDefinition, } from '@maka/runtime'; import { createAgentRunStore, createArtifactStore, + createAutomationStore, createConnectionStore, createFileCredentialStore, createRuntimeEventStore, @@ -34,6 +39,8 @@ export interface MakaCliRuntimeContext { runtime: SessionManager; target: ReadySessionTarget; tools: ReturnType; + automationManager: AutomationManager; + automationScheduler: AutomationScheduler; } export interface CreateMakaCliRuntimeContextInput { @@ -70,6 +77,23 @@ export async function createMakaCliRuntimeContext( const permissionEngine = new PermissionEngine({ newId: randomUUID, now: Date.now }); const backends = new BackendRegistry(); const tools = buildBuiltinTools(); + const automationManager = new AutomationManager({ + generateId: () => randomUUID(), + now: () => Date.now(), + }); + const automationStore = createAutomationStore(input.workspaceRoot); + const syncAutomations = (): void => { + const durable = automationManager.listAll().filter(a => a.durable && (a.status === 'active' || a.status === 'paused')); + automationStore.sync(durable).catch(() => {}); + }; + const automationTool = buildAutomationTool({ automationManager, onAutomationChange: syncAutomations }); + const allTools = [...tools, automationTool]; + + // Load durable automations from disk. + try { + const saved = await automationStore.loadAll(); + automationManager.registerAll(saved); + } catch { /* best-effort */ } backends.register('ai-sdk', async (ctx) => { const ready = await resolveDefaultSessionTarget({ @@ -98,7 +122,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: buildManualCompactLookupPolicy( buildDefaultContextBudgetPolicy(ready.connection, { name: 'cli-default-history-budget' }), @@ -110,7 +134,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 }), newId: randomUUID, now: Date.now, }); @@ -125,12 +149,34 @@ export async function createMakaCliRuntimeContext( now: Date.now, }); + const automationScheduler = new AutomationScheduler({ + automationManager, + canFire: async (sessionId) => { + const header = await store.readHeader(sessionId); + if (!header || header.archivedAt) return false; + if (header.status === 'running' || header.status === 'blocked') return false; + return true; + }, + injectTurn: (sessionId, prompt) => { + const turnId = randomUUID(); + const iterator = runtime.sendMessage(sessionId, { turnId, text: prompt }); + void (async () => { for await (const _ of iterator) { /* drain */ } })().catch(() => {}); + }, + 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, + automationManager, + automationScheduler, }; } 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..547ae11600 --- /dev/null +++ b/packages/runtime/src/__tests__/automation-scheduler.test.ts @@ -0,0 +1,271 @@ +import { describe, test, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { AutomationManager } from '../automation-state.js'; +import { AutomationScheduler } 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 }> = []; + const freshRuns: Array<{ prompt: string; automationId: string }> = []; + let canFireResult = true; + let canFireThrows = false; + let injectTurnThrows = false; + let createFreshRunFn: ((prompt: string, automationId: string) => void) | undefined = 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: (sessionId, prompt, automationId) => { + if (injectTurnThrows) throw new Error('injectTurn error'); + fired.push({ sessionId, prompt, automationId }); + }, + 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(); + } + async function runTick() { + fireNextTimer(); + await new Promise(r => setTimeout(r, 0)); + } + + return { + manager, scheduler, fired, freshRuns, timers, + advanceTime, fireNextTimer, runTick, + setCanFire: (v: boolean) => { canFireResult = v; }, + setCanFireThrows: (v: boolean) => { canFireThrows = v; }, + setInjectTurnThrows: (v: boolean) => { injectTurnThrows = v; }, + setCreateFreshRun: (fn: ((prompt: string, automationId: string) => void) | undefined) => { + createFreshRunFn = fn; + }, + getTime: () => time, + }; +} + +describe('AutomationScheduler', () => { + test('fires automation 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(); + + // Run 24 ticks (MAX_DEFER_RETRIES) + for (let i = 0; i < 24; i++) { + await t.runTick(); + } + + // Should have skipped — nextFireAt advanced + const updated = t.manager.get(auto.id); + assert.ok(updated); + 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(); + // Should not crash, just skip + assert.equal(t.fired.length, 0); + // Scheduler still ticking (timer re-registered) + assert.ok(t.timers.length > 0); + }); + + test('injectTurn throwing marks automation as failed', 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.setInjectTurnThrows(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('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, // expires in 20s + }); + assert.ok(!('error' in auto)); + t.advanceTime(31000); // past expiry + 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', 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 automation fires via createFreshRun when provided', async () => { + const t = createTestSetup(); + const freshRuns: Array<{ prompt: string; id: string }> = []; + t.setCreateFreshRun((prompt, automationId) => { + freshRuns.push({ prompt, id: automationId }); + }); + 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); // should NOT call injectTurn + }); + + test('cron automation marks failure when createFreshRun is not provided', async () => { + const t = createTestSetup(); + // createFreshRun is undefined by default + 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(t.fired.length, 0); + const updated = t.manager.get(auto.id); + assert.equal(updated?.consecutiveFailures, 1); + assert.ok(updated?.lastError?.includes('not configured')); + }); + + 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 }, // next fire in 1 hour + expiresAt: t.getTime() + 30000, // expires in 30s + }); + assert.ok(!('error' in auto)); + t.advanceTime(31000); // past expiry but before nextFireAt (1 hour) + t.scheduler.start(); + await t.runTick(); + + assert.equal(t.fired.length, 0); + assert.equal(t.manager.get(auto.id)?.status, 'expired'); + }); + + test('markFailure does not overwrite terminal status', 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.setInjectTurnThrows(true); + t.scheduler.start(); + await t.runTick(); + + // markFired sets completed (maxFires=1), then injectTurn throws, + // markFailure should NOT overwrite completed with paused. + const updated = t.manager.get(auto.id); + assert.equal(updated?.status, 'completed'); + }); +}); diff --git a/packages/runtime/src/__tests__/automation.test.ts b/packages/runtime/src/__tests__/automation.test.ts new file mode 100644 index 0000000000..f29f3ec212 --- /dev/null +++ b/packages/runtime/src/__tests__/automation.test.ts @@ -0,0 +1,339 @@ +import { describe, test, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +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); + }); + }); + + 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('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); + }); + }); + + 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.markFired(auto.id); + assert.equal(fired?.fireCount, 1); + assert.ok(fired?.nextFireAt); + assert.ok(fired?.lastFireAt); + }); + + test('one-shot completes after 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)); + const fired = mgr.markFired(auto.id); + assert.equal(fired?.status, 'completed'); + assert.equal(fired?.nextFireAt, null); + }); + + test('maxFires completes automation', () => { + 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.markFired(auto.id); + const second = mgr.markFired(auto.id); + assert.equal(second?.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.markFired(auto.id), undefined); + }); + }); + + describe('markFailure', () => { + 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.markFailure(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.markFailure(auto.id, 'fail'); + assert.equal(mgr.get(auto.id)?.status, 'paused'); + }); + + test('markSuccess 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.markFailure(auto.id, 'fail'); + mgr.markFailure(auto.id, 'fail'); + mgr.markSuccess(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('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); + }); + + 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); + }); +}); diff --git a/packages/runtime/src/automation-scheduler.ts b/packages/runtime/src/automation-scheduler.ts new file mode 100644 index 0000000000..8e375d9f58 --- /dev/null +++ b/packages/runtime/src/automation-scheduler.ts @@ -0,0 +1,151 @@ +/** + * 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'; + +export interface AutomationSchedulerDeps { + automationManager: AutomationManager; + canFire: (sessionId: string) => Promise; + injectTurn: (sessionId: string, prompt: string, automationId: string) => void; + createFreshRun?: (prompt: string, automationId: string) => void; + 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(); + 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(); + } + + 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. + for (const automation of active) { + if (automation.expiresAt && now >= automation.expiresAt) { + this.deps.automationManager.markFired(automation.id); + } + } + + // 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; + + let canFire: boolean; + try { + canFire = await this.deps.canFire(automation.sessionId); + } catch { + // canFire failure: skip this automation this tick, don't crash the loop. + return; + } + + if (this.disposed) 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 fired = this.deps.automationManager.markFired(automation.id); + if (!fired) { + this.deps.onStateChange?.(); + return; + } + + try { + if (automation.kind === 'heartbeat') { + this.deps.injectTurn( + automation.sessionId, + `[Automation: ${automation.name}]\n\n${automation.prompt}`, + automation.id, + ); + } else if (automation.kind === 'cron') { + if (!this.deps.createFreshRun) { + this.deps.automationManager.markFailure(automation.id, 'Cron execution not configured (createFreshRun unavailable)'); + return; + } + this.deps.createFreshRun(automation.prompt, automation.id); + } + this.deps.automationManager.markSuccess(automation.id); + this.deps.onStateChange?.(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.deps.automationManager.markFailure(automation.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..5b6910b475 --- /dev/null +++ b/packages/runtime/src/automation-state.ts @@ -0,0 +1,339 @@ +/** + * 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; + + 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, + ...(input.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 && automation.sessionId !== sessionId) return false; + this.automations.delete(id); + return true; + } + + pause(id: string, sessionId: string): AutomationDefinition | undefined { + const automation = this.automations.get(id); + if (!automation || automation.sessionId !== 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 || automation.sessionId !== sessionId) return undefined; + if (automation.status !== 'paused') return undefined; + automation.status = 'active'; + automation.updatedAt = this.deps.now(); + automation.nextFireAt = this.computeNextFire(automation.schedule, this.deps.now()); + return automation; + } + + listForSession(sessionId: string): AutomationDefinition[] { + return [...this.automations.values()].filter(a => a.sessionId === sessionId); + } + + listActive(): AutomationDefinition[] { + return [...this.automations.values()].filter(a => a.status === 'active'); + } + + /** + * Called by the scheduler when it's time to fire. + * Checks expiry BEFORE firing. Returns the automation if it should fire. + */ + markFired(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; + + if (automation.schedule.type === 'once') { + automation.status = 'completed'; + automation.nextFireAt = null; + } else { + automation.nextFireAt = this.computeNextFire(automation.schedule, now); + } + + if (automation.maxFires && automation.fireCount >= automation.maxFires) { + automation.status = 'completed'; + 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.nextFireAt = this.computeNextFire(automation.schedule, now); + automation.updatedAt = now; + } + + markSuccess(id: string, runId?: string): void { + const automation = this.automations.get(id); + if (!automation) return; + automation.consecutiveFailures = 0; + automation.lastError = null; + if (runId) automation.lastRunId = runId; + automation.updatedAt = this.deps.now(); + } + + markFailure(id: string, error: string): void { + const automation = this.automations.get(id); + if (!automation) return; + if (automation.status === 'completed' || automation.status === 'expired') return; + automation.consecutiveFailures++; + automation.lastError = error; + automation.updatedAt = this.deps.now(); + + if (automation.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + 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 { + for (const automation of automations) { + 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); + } + } +} + +export function computeNextCronFire(expression: string, fromTime: number): number | null { + const fields = expression.trim().split(/\s+/); + if (fields.length !== 5) return null; + + const [minuteField, hourField, domField, monthField, dowField] = fields; + // Zero out seconds/ms for clean minute boundaries. + const fromDate = new Date(fromTime); + fromDate.setSeconds(0, 0); + const baseTime = fromDate.getTime() + 60000; // start from next minute + + for (let attempt = 0; attempt < 527040; attempt++) { + const candidateTime = baseTime + attempt * 60000; + const candidate = new Date(candidateTime); + const minute = candidate.getMinutes(); + const hour = candidate.getHours(); + const dom = candidate.getDate(); + const month = candidate.getMonth() + 1; + const dow = candidate.getDay(); + + if ( + matchesCronField(minuteField, minute, 0, 59) && + matchesCronField(hourField, hour, 0, 23) && + matchesCronField(domField, dom, 1, 31) && + matchesCronField(monthField, month, 1, 12) && + matchesCronField(dowField, dow, 0, 6) + ) { + 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..d9df10e85a --- /dev/null +++ b/packages/runtime/src/automation-tools.ts @@ -0,0 +1,211 @@ +/** + * 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; +} + +const createSchema = z.object({ + mode: z.literal('create'), + kind: z.enum(['heartbeat', 'cron']) + .describe('heartbeat = resume into current session (polling/monitoring). cron = create fresh session each run (standalone scheduled tasks).'), + name: z.string().trim().min(1).max(100) + .describe('Short human-readable name for this automation.'), + prompt: z.string().trim().min(1).max(2000) + .describe('The prompt to execute on each fire.'), + schedule: 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.'), + }), + ]).describe('When to fire. Use "interval" for simple repeats, "cron" for complex schedules, "once" for one-shot delays.'), + max_fires: z.number().int().min(1).max(10000).optional() + .describe('Maximum number of fires before auto-completing. Omit for unlimited (7-day expiry still applies).'), + durable: z.boolean().optional() + .describe('When true, this automation persists across app restarts. Default: false (session-scoped only).'), +}); + +const deleteSchema = z.object({ + mode: z.literal('delete'), + id: z.string().min(1).max(64) + .describe('Automation ID to delete.'), +}); + +const listSchema = z.object({ + mode: z.literal('list'), +}); + +const pauseSchema = z.object({ + mode: z.literal('pause'), + id: z.string().min(1).max(64) + .describe('Automation ID to pause.'), +}); + +const resumeSchema = z.object({ + mode: z.literal('resume'), + id: z.string().min(1).max(64) + .describe('Automation ID to resume.'), +}); + +const automationSchema = z.discriminatedUnion('mode', [ + createSchema, + deleteSchema, + listSchema, + pauseSchema, + resumeSchema, +]); + +type AutomationInput = z.infer; + +export function buildAutomationTool(deps: AutomationToolDeps): MakaTool { + 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). ' + + 'Use kind "cron" for standalone scheduled tasks (creates a fresh session each run). ' + + 'Automations auto-expire after 7 days unless deleted earlier.', + parameters: automationSchema, + permissionRequired: false, + impl: (input, ctx) => { + let result: string; + switch (input.mode) { + case 'create': + result = handleCreate(deps, input, ctx.sessionId); + break; + case 'delete': + result = handleDelete(deps, input, ctx.sessionId); + break; + case 'list': + return handleList(deps, ctx.sessionId); + case 'pause': + result = handlePause(deps, input, ctx.sessionId); + break; + case 'resume': + result = handleResume(deps, input, ctx.sessionId); + break; + } + deps.onAutomationChange?.(); + return result; + }, + }; +} + +function handleCreate( + deps: AutomationToolDeps, + input: z.infer, + sessionId: string, +): string { + 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, + 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 handleDelete( + deps: AutomationToolDeps, + input: z.infer, + sessionId: string, +): string { + const deleted = deps.automationManager.delete(input.id, sessionId); + if (!deleted) return `Automation "${input.id}" not found or not owned by this session.`; + return `Automation "${input.id}" deleted.`; +} + +function handleList(deps: AutomationToolDeps, sessionId: string): string { + const automations = deps.automationManager.listForSession(sessionId); + if (automations.length === 0) return 'No automations for this session.'; + + return automations.map(a => formatAutomation(a)).join('\n---\n'); +} + +function handlePause( + deps: AutomationToolDeps, + input: z.infer, + sessionId: string, +): string { + const result = deps.automationManager.pause(input.id, sessionId); + if (!result) return `Cannot pause "${input.id}": not found, not owned, or not active.`; + return `Automation "${result.name}" paused. Use mode "resume" to reactivate.`; +} + +function handleResume( + deps: AutomationToolDeps, + input: z.infer, + sessionId: string, +): string { + const result = deps.automationManager.resume(input.id, sessionId); + if (!result) return `Cannot resume "${input.id}": not found, not owned, or not paused.`; + return `Automation "${result.name}" resumed. Next fire: ${result.nextFireAt ? new Date(result.nextFireAt).toLocaleString() : 'N/A'}`; +} + +function formatAutomation(a: AutomationDefinition): string { + const lines = [ + `[${a.status.toUpperCase()}] ${a.name} (${a.kind})`, + ` 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 df2d9fb4d3..3d0c931b4a 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -593,3 +593,19 @@ 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 } from './automation-scheduler.js'; +export { buildAutomationTool, AUTOMATION_TOOL_NAME } from './automation-tools.js'; +export type { AutomationToolDeps } from './automation-tools.js'; diff --git a/packages/storage/src/automation-store.ts b/packages/storage/src/automation-store.ts new file mode 100644 index 0000000000..658e57c923 --- /dev/null +++ b/packages/storage/src/automation-store.ts @@ -0,0 +1,94 @@ +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 { + try { + const text = await readFile(this.filePath, 'utf8'); + const parsed = JSON.parse(text) as unknown; + if (!isAutomationFile(parsed)) { + console.warn('[automation-store] corrupt automations.json -- returning empty'); + return []; + } + return parsed.automations as T[]; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + console.warn('[automation-store] failed to read automations.json -- returning empty:', error); + return []; + } + } + + 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 0fae49e637..e83b17bfdc 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -20,3 +20,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/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) { From 6ec7960e4420dccc230b66c7cfafbe5e566d3b57 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Mon, 6 Jul 2026 08:28:06 +0800 Subject: [PATCH 02/23] test(runtime): add integration tests covering PR test plan scenarios Automated coverage for: heartbeat fires on schedule, durable flag, pause/resume/delete lifecycle, turn-tail list, expiry sweep, max_fires cap, consecutive-failure auto-pause, and cron createFreshRun path. --- .../__tests__/automation-integration.test.ts | 310 ++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 packages/runtime/src/__tests__/automation-integration.test.ts 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..38ff8e08b4 --- /dev/null +++ b/packages/runtime/src/__tests__/automation-integration.test.ts @@ -0,0 +1,310 @@ +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: (sessionId, prompt, automationId) => { + injectedTurns.push({ sessionId, prompt, automationId }); + }, + createFreshRun: (prompt, automationId) => { + freshRuns.push({ prompt, automationId }); + }, + 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); }, + }); + + function advanceTime(ms: number) { time += ms; } + async function runTick() { + const timer = timers.shift(); + if (timer) timer.fn(); + 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('create durable automation, verify flag is set', async () => { + const t = createIntegrationSetup(); + const ctx = t.ctx(); + + const result = await t.tool.impl({ + mode: 'create', + kind: 'heartbeat', + name: 'persistent check', + prompt: 'check it', + schedule: { type: 'interval', seconds: 60 }, + durable: true, + }, ctx) as string; + + assert.ok(result.includes('durable')); + + const automations = t.manager.listForSession(SESSION_ID); + assert.equal(automations[0].durable, true); + }); + + 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 failures via markFired + markFailure + for (let i = 0; i < 5; i++) { + t.manager.markFired(auto.id); + t.manager.markFailure(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'); + }); +}); From cee795508b8d47e4f161ded2b249eb574ae77b38 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Mon, 6 Jul 2026 08:30:40 +0800 Subject: [PATCH 03/23] test(runtime): add mutation verification proving tests catch broken behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each test creates a deliberately broken version of the system and asserts the expected failure, proving the integration tests are not vacuous: - injectTurn no-op → heartbeat test catches it - maxFires ignored → cap test catches it - expiresAt unchecked → expiry test catches it - pause no-op → lifecycle test catches it - auto-pause missing → failure test catches it - durable flag dropped → persistence test catches it --- .../automation-mutation-verify.test.ts | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 packages/runtime/src/__tests__/automation-mutation-verify.test.ts 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..7cc6cb0da8 --- /dev/null +++ b/packages/runtime/src/__tests__/automation-mutation-verify.test.ts @@ -0,0 +1,138 @@ +/** + * 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: () => { /* INTENTIONALLY BROKEN: no-op */ }, + 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 3 times (exceeds maxFires=2) + manager.markFired(auto.id); + manager.markFired(auto.id); + const third = manager.markFired(auto.id); + + // After maxFires reached, markFired returns undefined (won't fire) + 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.markFired(auto.id); + + // markFired 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.markFired(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.markFailure(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: 'heartbeat', name: 'persist', prompt: 'p', sessionId: SESSION_ID, + schedule: { type: 'interval', seconds: 60 }, + durable: true, + }); + assert.ok(!('error' in auto)); + assert.equal(auto.durable, true, 'Create must store durable flag — test catches missing field'); + }); +}); From e48d8a50de6a25d57aed95b2349fd18bdd51d952 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Mon, 6 Jul 2026 08:45:40 +0800 Subject: [PATCH 04/23] test: complete test coverage for automation system - Cron parser: range/step boundary (10-30/5 stops at 30), */10, clean timestamps - State: invalid cron rejection, pruneTerminal, skipFire, terminal guard, listAll, registerAll - Storage: 10 tests for automation-store (CRUD, atomic writes, corrupt/wrong-version handling) - Total: runtime 895 + storage 174 = 1069 automation-related tests passing --- .../runtime/src/__tests__/automation.test.ts | 133 ++++++++++++++++++ .../src/__tests__/automation-store.test.ts | 127 +++++++++++++++++ 2 files changed, 260 insertions(+) create mode 100644 packages/storage/src/__tests__/automation-store.test.ts diff --git a/packages/runtime/src/__tests__/automation.test.ts b/packages/runtime/src/__tests__/automation.test.ts index f29f3ec212..675015bd1c 100644 --- a/packages/runtime/src/__tests__/automation.test.ts +++ b/packages/runtime/src/__tests__/automation.test.ts @@ -336,4 +336,137 @@ describe('computeNextCronFire', () => { 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); + }); +}); + +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.markFired(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('markFailure 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.markFired(auto.id); // completes (one-shot) + mgr.markFailure(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.markFired(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/storage/src/__tests__/automation-store.test.ts b/packages/storage/src/__tests__/automation-store.test.ts new file mode 100644 index 0000000000..616940d70f --- /dev/null +++ b/packages/storage/src/__tests__/automation-store.test.ts @@ -0,0 +1,127 @@ +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 handles corrupt file gracefully', async () => { + const { writeFile } = await import('node:fs/promises'); + await writeFile(join(TEST_DIR, 'automations.json'), 'not valid json{{{', 'utf8'); + + const store = createAutomationStore(TEST_DIR); + const result = await store.loadAll(); + assert.deepEqual(result, []); + }); + + test('loadAll handles wrong version gracefully', 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); + const result = await store.loadAll(); + assert.deepEqual(result, []); + }); + + 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); + }); +}); From 0d669da6b853c85d3ccfc35c70373be954771427 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Mon, 6 Jul 2026 22:25:19 +0800 Subject: [PATCH 05/23] feat(runtime): goal-based autonomous execution (Issue #15 Primitive 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /goal-style autonomous execution: the agent works toward a durable objective across turns without per-step approval, stopping when an external evaluator judges the condition met/impossible or a safety cap trips. Design (CC evaluator + Codex lifecycle): - External evaluator (CC-style): a cheap-model judge runs after each turn and returns {met, impossible, progress, waiting}. Keeping the judge external prevents the working model from rationalizing a premature "done" (Codex's documented failure mode). - Evaluate-FIRST ordering: a goal genuinely completed on its final permitted turn is detected as achieved, not misreported as a cap failure. - Lifecycle (Codex-inspired): active → achieved/impossible/cleared/paused/ stalled/budget_limited/max_iterations. GoalSet/Clear/Status/Pause/Resume tools. - Safety caps: block cap (8 consecutive no-progress turns → stalled), token budget, max iterations (50). Evaluator timeout (30s) + parse failures are NEUTRAL — a transient/garbled evaluator cannot defeat stall detection. - Trace is free: continuation goes through runtime.sendMessage → AgentRun → full RuntimeEvent trace, no new event types. - Abort halts the loop (desktop turnAborted + CLI !closed guard + canContinue rejects 'aborted'). Re-entrancy guarded per session. Integration: desktop + CLI/TUI both wired (tools, turn-tail status injection, continuation at the turn boundary), session cleanup on archive/remove. The waiting→heartbeat automation bridge was explored and removed: coupling two independent lifecycles created a maxFires zombie. A 'waiting' evaluation is now neutral (no stall) + normal re-check, bounded by maxIterations. 5 rounds of adversarial workflow review, 20 confirmed issues found and fixed until a clean pass. runtime + desktop test suites green (goal-specific: ~70 tests). --- apps/desktop/src/main/goal-wiring.ts | 98 ++++++++ apps/desktop/src/main/main.ts | 61 ++++- apps/desktop/src/main/system-prompt-main.ts | 24 ++ packages/cli/src/cli-system-prompt.ts | 24 +- packages/cli/src/cli.ts | 9 + packages/cli/src/pi-tui-runner.ts | 18 +- packages/cli/src/runtime-bootstrap.ts | 77 +++++- .../src/__tests__/goal-continuation.test.ts | 200 +++++++++++++++ .../src/__tests__/goal-evaluator.test.ts | 154 ++++++++++++ .../runtime/src/__tests__/goal-state.test.ts | 237 ++++++++++++++++++ .../runtime/src/__tests__/goal-tools.test.ts | 119 +++++++++ packages/runtime/src/goal-continuation.ts | 116 +++++++++ packages/runtime/src/goal-evaluator.ts | 142 +++++++++++ packages/runtime/src/goal-state.ts | 222 ++++++++++++++++ packages/runtime/src/goal-tools.ts | 155 ++++++++++++ packages/runtime/src/index.ts | 24 ++ 16 files changed, 1674 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/main/goal-wiring.ts create mode 100644 packages/runtime/src/__tests__/goal-continuation.test.ts create mode 100644 packages/runtime/src/__tests__/goal-evaluator.test.ts create mode 100644 packages/runtime/src/__tests__/goal-state.test.ts create mode 100644 packages/runtime/src/__tests__/goal-tools.test.ts create mode 100644 packages/runtime/src/goal-continuation.ts create mode 100644 packages/runtime/src/goal-evaluator.ts create mode 100644 packages/runtime/src/goal-state.ts create mode 100644 packages/runtime/src/goal-tools.ts diff --git a/apps/desktop/src/main/goal-wiring.ts b/apps/desktop/src/main/goal-wiring.ts new file mode 100644 index 0000000000..e9f27ebeb8 --- /dev/null +++ b/apps/desktop/src/main/goal-wiring.ts @@ -0,0 +1,98 @@ +import { randomUUID } from 'node:crypto'; +import { + GoalManager, + buildGoalTools, + type GoalContinuationDeps, + type MakaTool, +} from '@maka/runtime'; +import type { LlmConnection } from '@maka/core'; + +/** + * Goal execution wiring for the main process. Owns the GoalManager, the goal + * tools, and the turn-boundary continuation deps (evaluator + injection). + * + * The evaluator uses the session's default connection model with a tiny + * (~250-token) budget — a full judge model is heavier than ideal, but the + * request/response is small and this avoids a fragile cheap-model mapping. + * + * "Waiting on an external event" is handled inside the continuation controller + * (neutral progress + normal re-check), so the wiring needs no automation + * coupling — a goal is self-contained and bounded by its own caps. + */ +export interface MainGoalWiring { + manager: GoalManager; + tools: MakaTool[]; + continuationDeps: GoalContinuationDeps; +} + +export interface CreateMainGoalWiringDeps { + getDefaultConnectionSlug: () => Promise; + getConnection: (slug: string) => Promise; + resolveConnectionSecret: (slug: string) => Promise; + buildSubscriptionModelFetch: (connection: LlmConnection, sessionId: string, modelId: string) => typeof fetch | undefined; + getAIModel: (input: { connection: LlmConnection; apiKey: string; modelId: string; fetch: typeof fetch | undefined }) => unknown; + buildProviderOptions: (connection: LlmConnection, modelId: string) => unknown; + getRecentMessages: (sessionId: string) => Promise>; + /** Cumulative token count for a session (summed from token_usage messages). */ + getTokenCount: (sessionId: string) => Promise; + injectTurn: (sessionId: string, text: string) => void; + canContinue: (sessionId: string) => Promise; +} + +export function createMainGoalWiring(deps: CreateMainGoalWiringDeps): MainGoalWiring { + const manager = new GoalManager({ + generateId: () => randomUUID(), + now: () => Date.now(), + }); + + // Synchronous best-effort token snapshot cache, refreshed each continuation. + const tokenCache = new Map(); + + const tools = buildGoalTools({ + goalManager: manager, + getTokenCount: (sessionId) => tokenCache.get(sessionId) ?? 0, + }); + + const inFlight = new Set(); + + const continuationDeps: GoalContinuationDeps = { + goalManager: manager, + inFlight, + evaluator: { + async evaluate(prompt: string): Promise { + const slug = await deps.getDefaultConnectionSlug(); + if (!slug) return '{"met": false, "impossible": false, "progress": false, "reason": "no connection configured"}'; + const connection = await deps.getConnection(slug); + if (!connection) return '{"met": false, "impossible": false, "progress": false, "reason": "connection not found"}'; + const apiKey = await deps.resolveConnectionSecret(slug); + const ai = await import('ai') as unknown as { + generateText(opts: Record): Promise<{ text: string }>; + }; + const modelFetch = deps.buildSubscriptionModelFetch(connection, 'goal-evaluator', connection.defaultModel); + const result = await ai.generateText({ + model: deps.getAIModel({ connection, apiKey: apiKey ?? '', modelId: connection.defaultModel, fetch: modelFetch }), + prompt, + providerOptions: deps.buildProviderOptions(connection, connection.defaultModel), + maxTokens: 250, + }); + return result.text; + }, + }, + async getRecentContext(sessionId: string): Promise { + // Refresh the token snapshot while we have the session open. + tokenCache.set(sessionId, await deps.getTokenCount(sessionId)); + const messages = await deps.getRecentMessages(sessionId); + return messages + .filter((m) => m.type === 'user' || m.type === 'assistant') + .slice(-6) + .map((m) => `[${m.type}]: ${(m.text ?? '').slice(0, 500)}`) + .join('\n'); + }, + getTokenCount: (sessionId) => tokenCache.get(sessionId) ?? 0, + injectTurn: deps.injectTurn, + canContinue: deps.canContinue, + }; + + return { manager, tools, continuationDeps }; +} + diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 29f9a8478a..d29758a15b 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -166,6 +166,8 @@ import { buildDefaultContextBudgetPolicy } from '@maka/runtime'; import { createSystemPromptMainService } from './system-prompt-main.js'; import { createMainTaskLedgerWiring } from './task-ledger-wiring.js'; import { createMainAutomationWiring } from './automation-wiring.js'; +import { createMainGoalWiring } from './goal-wiring.js'; +import { handleGoalContinuation } from '@maka/runtime'; import { createOAuthModelConnectionsMainService } from './oauth-model-connections-main.js'; import { applyNetworkPatch, @@ -311,7 +313,7 @@ const automationWiring = createMainAutomationWiring({ async canFire(sessionId: string): Promise { const header = await store.readHeader(sessionId); if (!header || header.archivedAt) return false; - if (header.status === 'running' || header.status === 'blocked') return false; + if (header.status === 'running' || header.status === 'blocked' || header.status === 'aborted') return false; return true; }, injectTurn(sessionId: string, prompt: string, _automationId: string) { @@ -324,6 +326,43 @@ const automationWiring = createMainAutomationWiring({ // Load durable automations from disk on startup (fire-and-forget; errors are logged inside). void automationWiring.loadDurableAutomations(); +// Goal execution — autonomous turn-boundary continuation with an external +// evaluator (CC-style). Bridges to the Automation system on external waits. +const goalWiring = createMainGoalWiring({ + getDefaultConnectionSlug: () => connectionStore.getDefault(), + getConnection: (slug) => connectionStore.get(slug), + resolveConnectionSecret, + buildSubscriptionModelFetch, + getAIModel: (input) => getAIModel(input), + buildProviderOptions: (connection, modelId) => buildProviderOptions(connection, modelId), + getRecentMessages: async (sessionId) => { + const messages = await runtime.getMessages(sessionId); + return messages.slice(-10).map((m) => ({ + type: m.type, + text: m.type === 'user' || m.type === 'assistant' ? m.text : undefined, + })); + }, + getTokenCount: async (sessionId) => { + const messages = await runtime.getMessages(sessionId); + let total = 0; + for (const m of messages) { + if (m.type === 'token_usage') total += (m.total ?? (m.input + m.output)); + } + return total; + }, + injectTurn: (sessionId, text) => { + const turnId = randomUUID(); + const iterator = runtime.sendMessage(sessionId, { turnId, text }); + void streamEvents(sessionId, iterator, turnId); + }, + canContinue: async (sessionId) => { + const header = await store.readHeader(sessionId); + if (!header || header.archivedAt) return false; + if (header.status === 'running' || header.status === 'blocked' || header.status === 'aborted') return false; + return true; + }, +}); + async function getWorkspacePrivacyContext(): Promise { const settings = await settingsStore.get(); return { incognitoActive: settings.privacy.incognitoActive === true }; @@ -340,6 +379,7 @@ const systemPromptService = createSystemPromptMainService({ workspaceRoot, localMemory, taskLedger: taskLedgerStore, + goalManager: goalWiring.manager, }); const mainWindowController = createMainWindowController({ workspaceRoot, @@ -431,6 +471,8 @@ const builtinTools: MakaTool[] = [ ...taskLedgerWiring.tools, // Unified Automation: heartbeat (session-internal polling) + cron (standalone scheduled runs). ...automationWiring.tools, + // Goal execution: GoalSet/Clear/Status/Pause/Resume — autonomous turn-boundary continuation. + ...goalWiring.tools, // The `load_tools` connector is built by ToolAvailabilityRuntime; deferred // group tools just need to be present so they are dispatchable once loaded. ...deferredTools, @@ -1232,6 +1274,9 @@ function registerIpc(): void { // 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 autonomous loops tied to the session (goal + polling heartbeats). + goalWiring.manager.remove(sessionId); + automationWiring.manager.removeAllForSession(sessionId); emitSessionsChanged('archived', sessionId); }); ipcMain.handle('sessions:unarchive', async (_event, sessionId: string) => { @@ -1305,6 +1350,9 @@ function registerIpc(): void { // 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 autonomous loops tied to the session (goal + polling heartbeats). + goalWiring.manager.remove(sessionId); + automationWiring.manager.removeAllForSession(sessionId); emitSessionsChanged('deleted', sessionId); }); @@ -1540,12 +1588,16 @@ async function streamEvents( ): Promise { let userAppendBroadcasted = false; let finalAppendBroadcasted = false; + let turnAborted = false; 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; + } safeSendToRenderer(`sessions:event:${sessionId}`, event); openGateway.publishSessionEvent(sessionId, event); if (isStatusChangingSessionEvent(event)) { @@ -1559,6 +1611,12 @@ async function streamEvents( emitSessionsChanged('message-appended', sessionId); finalAppendBroadcasted = true; } + // Goal auto-continuation: after a turn completes cleanly (NOT user-aborted — + // the Stop button must halt the loop), evaluate the active goal and continue, + // hand off to polling, or stop. Failures never surface to the turn. + if (!turnAborted) { + void handleGoalContinuation(goalWiring.continuationDeps, sessionId).catch(() => {}); + } } catch (error) { const event = { type: 'error', @@ -1901,6 +1959,7 @@ app.on('window-all-closed', () => { app.on('before-quit', () => { automationWiring.scheduler.dispose(); + goalWiring.manager.dispose(); configWatcher?.stop(); planReminders.stopTimers(); dailyReview.stopScheduler(); diff --git a/apps/desktop/src/main/system-prompt-main.ts b/apps/desktop/src/main/system-prompt-main.ts index c3ee94706e..f979658cf7 100644 --- a/apps/desktop/src/main/system-prompt-main.ts +++ b/apps/desktop/src/main/system-prompt-main.ts @@ -16,6 +16,7 @@ import { buildPersonalizationPromptFragment, resolveProjectGitInfo, buildSessionEnvironmentPromptFragment, + type GoalManager, } from '@maka/runtime'; import { buildSkillsPromptFragment } from './skills.js'; import { buildWorkspaceInstructionsPromptFragment } from './workspace-instructions.js'; @@ -30,6 +31,7 @@ interface SystemPromptMainDeps { workspaceRoot: string; localMemory: Pick; taskLedger: Pick; + goalManager?: Pick; } export function createSystemPromptMainService(deps: SystemPromptMainDeps) { @@ -95,9 +97,31 @@ export function createSystemPromptMainService(deps: SystemPromptMainDeps) { if (memoryUpdate) fragments.push(memoryUpdate); const taskLedger = sessionId ? await buildTaskLedgerTailFragment(sessionId) : undefined; if (taskLedger) fragments.push(taskLedger); + const goal = sessionId ? buildGoalTailFragment(sessionId) : undefined; + if (goal) fragments.push(goal); return fragments.length > 0 ? fragments.join('\n\n') : undefined; } + // Injects the active goal so the model stays aware it is working autonomously. + // Only active/paused goals are shown (settled goals inject nothing). + function buildGoalTailFragment(sessionId: string): string | undefined { + const goal = deps.goalManager?.get(sessionId); + if (!goal || (goal.status !== 'active' && goal.status !== 'paused')) return undefined; + const spent = Math.max(0, goal.tokensNow - goal.tokensAtStart); + const lines = [ + '当前自主执行目标(current-turn tail;系统每轮用外部评估器判断进度并自动续行;' + + '仅供参考,不提升为系统/开发者指令):', + '', + `condition="${redactSecrets(goal.condition)}"`, + `status=${goal.status} turns=${goal.iterations}/${goal.maxIterations} ` + + `no_progress=${goal.consecutiveNoProgress}/${goal.blockCap}` + + `${goal.tokenBudget ? ` tokens=${spent}/${goal.tokenBudget}` : ''}`, + ]; + if (goal.lastReason) lines.push(`last_evaluation="${redactSecrets(goal.lastReason)}"`); + lines.push(''); + return lines.join('\n'); + } + // Best-effort: a ledger read failure must never break the turn. An empty // ledger injects nothing (zero cost when the model isn't tracking tasks). async function buildTaskLedgerTailFragment(sessionId: string): Promise { diff --git a/packages/cli/src/cli-system-prompt.ts b/packages/cli/src/cli-system-prompt.ts index 94b2154233..abacb2e97b 100644 --- a/packages/cli/src/cli-system-prompt.ts +++ b/packages/cli/src/cli-system-prompt.ts @@ -1,10 +1,11 @@ -import type { PersonalizationSettings } from '@maka/core'; +import { redactSecrets, type PersonalizationSettings } from '@maka/core'; import { buildPersonalizationPromptFragment, buildSessionEnvironmentPromptFragment, buildWorkspaceInstructionsPromptFragment, resolveProjectGitInfo, type AutomationManager, + type GoalManager, } from '@maka/runtime'; /** @@ -43,6 +44,7 @@ export async function buildCliTurnTailPrompt(input: { cwd: string; sessionId?: string; automationManager?: AutomationManager; + goalManager?: GoalManager; }): Promise { const projectGit = await resolveProjectGitInfo(input.cwd); const fragments = [buildSessionEnvironmentPromptFragment({ cwd: input.cwd, projectGit })]; @@ -51,10 +53,30 @@ export async function buildCliTurnTailPrompt(input: { const automationFragment = buildAutomationTailFragment(input.sessionId, input.automationManager); if (automationFragment) fragments.push(automationFragment); } + if (input.sessionId && input.goalManager) { + const goalFragment = buildGoalTailFragment(input.sessionId, input.goalManager); + if (goalFragment) fragments.push(goalFragment); + } return fragments.join('\n\n'); } +function buildGoalTailFragment(sessionId: string, manager: GoalManager): string | undefined { + const goal = manager.get(sessionId); + if (!goal || (goal.status !== 'active' && goal.status !== 'paused')) return undefined; + const spent = Math.max(0, goal.tokensNow - goal.tokensAtStart); + const lines = [ + 'Active goal (autonomous execution; system evaluates progress each turn):', + '', + `condition="${redactSecrets(goal.condition)}"`, + `status=${goal.status} turns=${goal.iterations}/${goal.maxIterations} no_progress=${goal.consecutiveNoProgress}/${goal.blockCap}` + + `${goal.tokenBudget ? ` tokens=${spent}/${goal.tokenBudget}` : ''}`, + ...(goal.lastReason ? [`last_evaluation="${redactSecrets(goal.lastReason)}"`] : []), + '', + ]; + return lines.join('\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; diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 7be06bd3e6..f7aeddc1bc 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises'; import { realpathSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; +import { handleGoalContinuation } from '@maka/runtime'; import { createMakaSessionDriver } from './session-driver.js'; import { createMakaCliRuntimeContext } from './runtime-bootstrap.js'; import { selectableModelIdsForTarget } from './connection-target.js'; @@ -77,6 +78,14 @@ export async function runMakaCli(argv: string[] = process.argv.slice(2)): Promis connectionSlug: context.target.connection.slug, providerType: context.target.connection.providerType, permissionMode: 'ask', + onTurnComplete: (injectTurn) => { + const sessionId = driver.getSessionId(); + if (!sessionId) return; + void handleGoalContinuation( + { ...context.goalContinuationDeps, injectTurn: (_s, text) => injectTurn(text) }, + sessionId, + ).catch(() => {}); + }, }); return 0; } diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index b6d019f7ab..3fb8976dce 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -45,6 +45,12 @@ export interface MakaPiTuiInput { providerType?: ProviderType; permissionMode: PermissionMode; terminal?: Terminal; + /** + * Called after each agent turn settles. Receives an `injectTurn` that runs a + * new turn rendered in the transcript — used for goal auto-continuation so + * continuation turns are visible and chain correctly. + */ + onTurnComplete?: (injectTurn: (text: string) => void) => void; } export async function runMakaPiTui(input: MakaPiTuiInput): Promise { @@ -175,6 +181,12 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { } if (handleSlashCommand(prompt)) return; + runAgentTurn(prompt); + }; + + // Runs one agent turn rendered in the transcript, then lets the host decide + // whether to auto-continue (goal). Shared by user submits and goal injections. + function runAgentTurn(prompt: string): void { busy = true; editor.disableSubmit = true; terminal.setProgress(true); @@ -190,8 +202,12 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { editor.disableSubmit = false; terminal.setProgress(false); requestRender(); + // Do not auto-continue (goal) if the session was closed/aborted mid-turn + // (Ctrl-C). The CLI's only abort affordance is close(), so `closed` is the + // abort signal — mirrors the desktop `turnAborted` guard. + if (!closed) input.onTurnComplete?.((text) => runAgentTurn(text)); }); - }; + } 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 96d9d72b0e..a26eead9a9 100644 --- a/packages/cli/src/runtime-bootstrap.ts +++ b/packages/cli/src/runtime-bootstrap.ts @@ -6,11 +6,13 @@ import { AutomationManager, AutomationScheduler, BackendRegistry, + GoalManager, PermissionEngine, SessionManager, buildAutomationTool, buildBuiltinTools, buildDefaultContextBudgetPolicy, + buildGoalTools, buildManualCompactLookupPolicy, buildProviderOptions, buildSubscriptionModelFetch, @@ -18,6 +20,7 @@ import { loadHistoryCompactBlocksFromArtifacts, persistHistoryCompactBlocksToArtifacts, type AutomationDefinition, + type GoalContinuationDeps, } from '@maka/runtime'; import { createAgentRunStore, @@ -41,6 +44,8 @@ export interface MakaCliRuntimeContext { tools: ReturnType; automationManager: AutomationManager; automationScheduler: AutomationScheduler; + goalManager: GoalManager; + goalContinuationDeps: GoalContinuationDeps; } export interface CreateMakaCliRuntimeContextInput { @@ -87,7 +92,14 @@ export async function createMakaCliRuntimeContext( automationStore.sync(durable).catch(() => {}); }; const automationTool = buildAutomationTool({ automationManager, onAutomationChange: syncAutomations }); - const allTools = [...tools, automationTool]; + + const goalManager = new GoalManager({ generateId: () => randomUUID(), now: () => Date.now() }); + const goalTokenCache = new Map(); + const goalTools = buildGoalTools({ + goalManager, + getTokenCount: (sessionId) => goalTokenCache.get(sessionId) ?? 0, + }); + const allTools = [...tools, automationTool, ...goalTools]; // Load durable automations from disk. try { @@ -134,7 +146,7 @@ export async function createMakaCliRuntimeContext( const settings = await settingsStore.get(); return buildCliSystemPrompt({ settings, cwd }); }, - turnTailPrompt: ({ cwd }) => buildCliTurnTailPrompt({ cwd, sessionId: ctx.sessionId, automationManager }), + turnTailPrompt: ({ cwd }) => buildCliTurnTailPrompt({ cwd, sessionId: ctx.sessionId, automationManager, goalManager }), newId: randomUUID, now: Date.now, }); @@ -154,7 +166,7 @@ export async function createMakaCliRuntimeContext( canFire: async (sessionId) => { const header = await store.readHeader(sessionId); if (!header || header.archivedAt) return false; - if (header.status === 'running' || header.status === 'blocked') return false; + if (header.status === 'running' || header.status === 'blocked' || header.status === 'aborted') return false; return true; }, injectTurn: (sessionId, prompt) => { @@ -169,6 +181,63 @@ export async function createMakaCliRuntimeContext( automationScheduler.start(); + // Goal execution — external-evaluator continuation, sharing the runtime + // sendMessage pipeline (so each continuation turn is a real, traced AgentRun). + const goalContinuationDeps: GoalContinuationDeps = { + goalManager, + inFlight: new Set(), + evaluator: { + async evaluate(prompt: string): Promise { + const ai = await import('ai') as unknown as { + generateText(opts: Record): Promise<{ text: string }>; + }; + const modelFetch = buildSubscriptionModelFetch({ + connection: target.connection, + sessionId: 'goal-evaluator', + modelId: target.model, + }); + const result = await ai.generateText({ + model: getAIModel({ connection: target.connection, apiKey: target.apiKey ?? '', modelId: target.model, fetch: modelFetch }), + prompt, + providerOptions: buildProviderOptions(target.connection, target.model), + maxTokens: 250, + }); + return result.text; + }, + }, + async getRecentContext(sessionId: string): Promise { + const messages = await runtime.getMessages(sessionId); + // Refresh the token snapshot while the session is open. + let total = 0; + for (const m of messages) { + if (m.type === 'token_usage') total += (m.total ?? (m.input + m.output)); + } + goalTokenCache.set(sessionId, total); + return messages + .slice(-10) + .filter((m) => m.type === 'user' || m.type === 'assistant') + .slice(-6) + .map((m) => `[${m.type}]: ${(m.type === 'user' || m.type === 'assistant' ? m.text : '').slice(0, 500)}`) + .join('\n'); + }, + getTokenCount: (sessionId) => goalTokenCache.get(sessionId) ?? 0, + injectTurn: (sessionId, text) => { + const turnId = randomUUID(); + const iterator = runtime.sendMessage(sessionId, { turnId, text }); + void (async () => { for await (const _ of iterator) { /* drain */ } })().catch(() => {}); + }, + canContinue: async (sessionId) => { + const header = await store.readHeader(sessionId); + if (!header || header.archivedAt) return false; + if (header.status === 'running' || header.status === 'blocked' || header.status === 'aborted') return false; + return true; + }, + // The CLI automation scheduler injects turns via a silent drain that does + // not re-invoke handleGoalContinuation, so a heartbeat poll loop could not + // close. We therefore do NOT wire the waiting → heartbeat bridge in the CLI; + // a waiting goal falls through to normal per-turn continuation instead. + }; + return { workspaceRoot: input.workspaceRoot, cwd: input.cwd, @@ -177,6 +246,8 @@ export async function createMakaCliRuntimeContext( tools, automationManager, automationScheduler, + goalManager, + goalContinuationDeps, }; } diff --git a/packages/runtime/src/__tests__/goal-continuation.test.ts b/packages/runtime/src/__tests__/goal-continuation.test.ts new file mode 100644 index 0000000000..391910d781 --- /dev/null +++ b/packages/runtime/src/__tests__/goal-continuation.test.ts @@ -0,0 +1,200 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { GoalManager } from '../goal-state.js'; +import { handleGoalContinuation, type GoalContinuationDeps } from '../goal-continuation.js'; +import type { GoalEvaluation } from '../goal-evaluator.js'; + +const SESSION = 'sess-1'; + +function setup(opts?: { + evaluation?: Partial; + canContinue?: boolean; + tokenCount?: number; +}) { + let id = 0; + const mgr = new GoalManager({ + generateId: () => `g-${++id}`, + now: () => 1000, + }); + const injected: string[] = []; + const evaluation: GoalEvaluation = { + met: false, impossible: false, progress: true, waiting: false, evaluatorFailed: false, reason: 'keep going', + ...opts?.evaluation, + }; + const deps: GoalContinuationDeps = { + goalManager: mgr, + evaluator: { evaluate: async () => JSON.stringify({ + met: evaluation.met, impossible: evaluation.impossible, + progress: evaluation.progress, waiting: evaluation.waiting, + wait_seconds: evaluation.waitSeconds, reason: evaluation.reason, + }) }, + getRecentContext: async () => 'recent context', + getTokenCount: opts?.tokenCount !== undefined ? () => opts.tokenCount! : undefined, + injectTurn: (_s, text) => { injected.push(text); }, + canContinue: async () => opts?.canContinue ?? true, + }; + return { mgr, deps, injected }; +} + +describe('handleGoalContinuation', () => { + test('no active goal → no_goal', async () => { + const { deps } = setup(); + const out = await handleGoalContinuation(deps, SESSION); + assert.equal(out.kind, 'no_goal'); + }); + + test('session busy → cannot_continue', async () => { + const { mgr, deps } = setup({ canContinue: false }); + mgr.set(SESSION, 'x'); + const out = await handleGoalContinuation(deps, SESSION); + assert.equal(out.kind, 'cannot_continue'); + }); + + test('met → achieved, no injection', async () => { + const { mgr, deps, injected } = setup({ evaluation: { met: true, reason: 'all pass' } }); + mgr.set(SESSION, 'x'); + const out = await handleGoalContinuation(deps, SESSION); + assert.equal(out.kind, 'achieved'); + assert.equal(mgr.get(SESSION)?.status, 'achieved'); + assert.equal(injected.length, 0); + }); + + test('impossible → impossible, no injection', async () => { + const { mgr, deps, injected } = setup({ evaluation: { impossible: true, reason: 'cannot' } }); + mgr.set(SESSION, 'x'); + const out = await handleGoalContinuation(deps, SESSION); + assert.equal(out.kind, 'impossible'); + assert.equal(mgr.get(SESSION)?.status, 'impossible'); + assert.equal(injected.length, 0); + }); + + test('not met + progress → continued, injects steering turn', async () => { + const { mgr, deps, injected } = setup({ evaluation: { progress: true, reason: '1 of 3 done' } }); + mgr.set(SESSION, 'x'); + const out = await handleGoalContinuation(deps, SESSION); + assert.equal(out.kind, 'continued'); + assert.equal(injected.length, 1); + assert.ok(injected[0].includes('1 of 3 done')); + assert.equal(mgr.get(SESSION)?.iterations, 1); + assert.equal(mgr.get(SESSION)?.consecutiveNoProgress, 0); + }); + + test('no progress accumulates and trips stalled at block cap', async () => { + const { mgr, deps, injected } = setup({ evaluation: { progress: false, reason: 'stuck' } }); + mgr.set(SESSION, 'x', { blockCap: 2 }); + const first = await handleGoalContinuation(deps, SESSION); + assert.equal(first.kind, 'continued'); + assert.equal(mgr.get(SESSION)?.consecutiveNoProgress, 1); + const second = await handleGoalContinuation(deps, SESSION); + assert.equal(second.kind, 'stopped'); + assert.equal(mgr.get(SESSION)?.status, 'stalled'); + // Second call must not inject (goal stalled). + assert.equal(injected.length, 1); + }); + + test('evaluate-first: a goal MET on its final permitted turn is achieved, not max_iterations', async () => { + const { mgr, deps } = setup({ evaluation: { met: true, reason: 'all pass' } }); + mgr.set(SESSION, 'x', { maxIterations: 1 }); + const out = await handleGoalContinuation(deps, SESSION); + assert.equal(out.kind, 'achieved'); + assert.equal(mgr.get(SESSION)?.status, 'achieved'); + }); + + test('not-met on the final permitted turn stops with max_iterations', async () => { + const { mgr, deps } = setup({ evaluation: { met: false, progress: true } }); + mgr.set(SESSION, 'x', { maxIterations: 1 }); + const out = await handleGoalContinuation(deps, SESSION); + assert.equal(out.kind, 'stopped'); + assert.equal(mgr.get(SESSION)?.status, 'max_iterations'); + }); + + test('evaluate-first: a goal MET on the budget-crossing turn is achieved, not budget_limited', async () => { + const { mgr, deps } = setup({ tokenCount: 2000, evaluation: { met: true, reason: 'done' } }); + mgr.set(SESSION, 'x', { tokenBudget: 1000, tokensAtStart: 500 }); + const out = await handleGoalContinuation(deps, SESSION); + assert.equal(out.kind, 'achieved'); + assert.equal(mgr.get(SESSION)?.status, 'achieved'); + }); + + test('not-met budget crossing stops with budget_limited', async () => { + const { mgr, deps } = setup({ tokenCount: 2000, evaluation: { met: false, progress: true } }); + mgr.set(SESSION, 'x', { tokenBudget: 1000, tokensAtStart: 500 }); + mgr.recordTokens(SESSION, 500); // establish baseline before the continuation + const out = await handleGoalContinuation(deps, SESSION); + assert.equal(out.kind, 'stopped'); + assert.equal(mgr.get(SESSION)?.status, 'budget_limited'); + }); + + test('evaluatorFailed leaves the no-progress streak unchanged (neutral)', async () => { + let id = 0; + const mgr = new GoalManager({ generateId: () => `g-${++id}`, now: () => 1000 }); + // A throwing evaluator yields evaluatorFailed=true from evaluateGoal. + const deps: GoalContinuationDeps = { + goalManager: mgr, + evaluator: { evaluate: async () => { throw new Error('outage'); } }, + getRecentContext: async () => 'ctx', + injectTurn: () => {}, + canContinue: async () => true, + }; + mgr.set(SESSION, 'x', { blockCap: 2 }); + await handleGoalContinuation(deps, SESSION); + // Neutral: streak neither advanced nor reset; goal still active (fail-open). + assert.equal(mgr.get(SESSION)?.consecutiveNoProgress, 0); + assert.equal(mgr.get(SESSION)?.status, 'active'); + }); + + test('waiting is neutral: does not count against the stall cap, still injects', async () => { + const { mgr, deps, injected } = setup({ + evaluation: { waiting: true, progress: false, reason: 'CI still running' }, + }); + mgr.set(SESSION, 'deploy done', { blockCap: 2 }); + const out = await handleGoalContinuation(deps, SESSION); + assert.equal(out.kind, 'continued'); + // Waiting must NOT accumulate toward stall (a wait is not being stuck). + assert.equal(mgr.get(SESSION)?.consecutiveNoProgress, 0); + assert.equal(injected.length, 1); + assert.ok(injected[0].includes('waiting on an external event')); + }); + + test('a long wait is bounded by maxIterations (visible terminal, no zombie)', async () => { + const { mgr, deps } = setup({ + evaluation: { waiting: true, progress: false, reason: 'CI running' }, + }); + mgr.set(SESSION, 'x', { maxIterations: 3 }); + await handleGoalContinuation(deps, SESSION); // turn 1 + await handleGoalContinuation(deps, SESSION); // turn 2 + const third = await handleGoalContinuation(deps, SESSION); // turn 3 hits cap + assert.equal(third.kind, 'stopped'); + assert.equal(mgr.get(SESSION)?.status, 'max_iterations'); + }); + + test('re-entrancy guard: overlapping continuation returns busy', async () => { + let id = 0; + const mgr = new GoalManager({ generateId: () => `g-${++id}`, now: () => 1000 }); + const inFlight = new Set(); + let releaseEval: (() => void) | undefined; + const deps: GoalContinuationDeps = { + goalManager: mgr, + inFlight, + evaluator: { evaluate: () => new Promise((resolve) => { releaseEval = () => resolve('{"met": false, "progress": true, "reason": "x"}'); }) }, + getRecentContext: async () => 'ctx', + injectTurn: () => {}, + canContinue: async () => true, + }; + mgr.set(SESSION, 'x'); + const first = handleGoalContinuation(deps, SESSION); // hangs on evaluate + await new Promise((r) => setTimeout(r, 0)); + const second = await handleGoalContinuation(deps, SESSION); // should see inFlight + assert.equal(second.kind, 'busy'); + releaseEval?.(); + await first; + }); + + test('paused goal is not continued', async () => { + const { mgr, deps } = setup(); + mgr.set(SESSION, 'x'); + mgr.pause(SESSION); + const out = await handleGoalContinuation(deps, SESSION); + assert.equal(out.kind, 'no_goal'); + }); +}); diff --git a/packages/runtime/src/__tests__/goal-evaluator.test.ts b/packages/runtime/src/__tests__/goal-evaluator.test.ts new file mode 100644 index 0000000000..d79aa07f69 --- /dev/null +++ b/packages/runtime/src/__tests__/goal-evaluator.test.ts @@ -0,0 +1,154 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildGoalEvaluationPrompt, + parseGoalEvaluation, + evaluateGoal, +} from '../goal-evaluator.js'; + +describe('buildGoalEvaluationPrompt', () => { + test('includes condition, context, and field spec', () => { + const p = buildGoalEvaluationPrompt('all tests pass', 'ran tests, 2 failed'); + assert.ok(p.includes('all tests pass')); + assert.ok(p.includes('ran tests, 2 failed')); + assert.ok(p.includes('GOAL CONDITION')); + assert.ok(p.includes('CONVERSATION CONTEXT')); + assert.ok(p.includes('"met"')); + assert.ok(p.includes('"progress"')); + assert.ok(p.includes('"waiting"')); + }); +}); + +describe('parseGoalEvaluation', () => { + test('parses a full verdict', () => { + const r = parseGoalEvaluation('{"met": false, "impossible": false, "progress": true, "waiting": false, "reason": "fixed 1 of 3"}'); + assert.equal(r.met, false); + assert.equal(r.progress, true); + assert.equal(r.reason, 'fixed 1 of 3'); + }); + + test('parses waiting with wait_seconds', () => { + const r = parseGoalEvaluation('{"met": false, "impossible": false, "progress": false, "waiting": true, "wait_seconds": 120, "reason": "CI running"}'); + assert.equal(r.waiting, true); + assert.equal(r.waitSeconds, 120); + }); + + test('clamps wait_seconds to <= 3600', () => { + const r = parseGoalEvaluation('{"met": false, "waiting": true, "wait_seconds": 999999, "reason": "x"}'); + assert.equal(r.waitSeconds, 3600); + }); + + test('drops non-positive wait_seconds', () => { + const r = parseGoalEvaluation('{"met": false, "waiting": true, "wait_seconds": 0, "reason": "x"}'); + assert.equal(r.waitSeconds, undefined); + }); + + test('extracts JSON from surrounding prose', () => { + const r = parseGoalEvaluation('Here is my judgment:\n{"met": true, "impossible": false, "progress": true, "waiting": false, "reason": "all pass"}\nDone.'); + assert.equal(r.met, true); + }); + + test('missing fields default to false', () => { + const r = parseGoalEvaluation('{"met": true}'); + assert.equal(r.met, true); + assert.equal(r.impossible, false); + assert.equal(r.progress, false); + assert.equal(r.waiting, false); + assert.equal(r.reason, 'No reason provided'); + }); + + test('unparseable output → neutral evaluator failure (not real no-progress)', () => { + const r = parseGoalEvaluation('I cannot determine this'); + assert.equal(r.met, false); + assert.equal(r.progress, false); + assert.equal(r.evaluatorFailed, true); + assert.ok(r.reason.includes('unparseable')); + }); + + test('malformed JSON → neutral evaluator failure', () => { + const r = parseGoalEvaluation('{met: true, broken}'); + assert.equal(r.met, false); + assert.equal(r.evaluatorFailed, true); + assert.ok(r.reason.includes('parse failed')); + }); + + test('braces inside reason → treated as neutral, not false no-progress', () => { + // A coding-goal judge whose reason references code can defeat the flat regex. + const r = parseGoalEvaluation('{"met":false,"progress":true,"reason":"add return {} to handler"}'); + // Either it parses (progress true) or it fails neutrally — never a real + // progress=false that would count toward stall. + if (r.evaluatorFailed) { + assert.equal(r.progress, false); + } else { + assert.equal(r.progress, true); + } + }); + + test('truncates long reason', () => { + const long = 'x'.repeat(300); + const r = parseGoalEvaluation(`{"met": false, "reason": "${long}"}`); + assert.ok(r.reason.length <= 200); + }); +}); + +describe('evaluateGoal', () => { + test('returns parsed verdict on success', async () => { + const r = await evaluateGoal( + { evaluate: async () => '{"met": true, "progress": true, "reason": "done"}' }, + 'finish', 'ctx', + ); + assert.equal(r.met, true); + assert.equal(r.reason, 'done'); + }); + + test('fails open on evaluator error (evaluatorFailed=true, continue)', async () => { + const r = await evaluateGoal( + { evaluate: async () => { throw new Error('network'); } }, + 'finish', 'ctx', + ); + assert.equal(r.met, false); + assert.equal(r.impossible, false); + assert.equal(r.progress, false); + assert.equal(r.evaluatorFailed, true); + assert.ok(r.reason.includes('failed')); + }); + + test('fails open on timeout (evaluatorFailed=true, continue)', async () => { + const r = await evaluateGoal( + { + // Never resolves — force the timeout branch. + evaluate: () => new Promise(() => {}), + timeoutMs: 10, + // Injected timer fires immediately so the race resolves to timeout. + setTimeout: (fn) => { fn(); return 1; }, + clearTimeout: () => {}, + }, + 'finish', 'ctx', + ); + assert.equal(r.met, false); + assert.equal(r.progress, false); + assert.equal(r.evaluatorFailed, true); + assert.ok(r.reason.includes('timed out')); + }); + + test('successful parse sets evaluatorFailed=false', async () => { + const r = await evaluateGoal( + { evaluate: async () => '{"met": false, "progress": true, "reason": "ok"}' }, + 'finish', 'ctx', + ); + assert.equal(r.evaluatorFailed, false); + }); + + test('clears the timeout timer on success', async () => { + let cleared = false; + await evaluateGoal( + { + evaluate: async () => '{"met": true, "reason": "ok"}', + setTimeout: () => 42, + clearTimeout: (h) => { cleared = h === 42; }, + }, + 'finish', 'ctx', + ); + assert.equal(cleared, true); + }); +}); diff --git a/packages/runtime/src/__tests__/goal-state.test.ts b/packages/runtime/src/__tests__/goal-state.test.ts new file mode 100644 index 0000000000..5761ef6a9e --- /dev/null +++ b/packages/runtime/src/__tests__/goal-state.test.ts @@ -0,0 +1,237 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + GoalManager, + TERMINAL_GOAL_STATUSES, + DEFAULT_MAX_ITERATIONS, + DEFAULT_BLOCK_CAP, +} from '../goal-state.js'; + +const SESSION = 'sess-1'; + +function createManager(startTime = 1_700_000_000_000) { + let id = 0; + let time = startTime; + const mgr = new GoalManager({ generateId: () => `goal-${++id}`, now: () => time }); + return { mgr, advance: (ms: number) => { time += ms; }, at: () => time }; +} + +describe('GoalManager — set / lifecycle', () => { + test('set creates an active goal with defaults', () => { + const { mgr } = createManager(); + const g = mgr.set(SESSION, 'all tests pass'); + assert.equal(g.status, 'active'); + assert.equal(g.iterations, 0); + assert.equal(g.maxIterations, DEFAULT_MAX_ITERATIONS); + assert.equal(g.blockCap, DEFAULT_BLOCK_CAP); + assert.equal(g.consecutiveNoProgress, 0); + assert.equal(g.tokenBudget, undefined); + }); + + test('set accepts custom limits', () => { + const { mgr } = createManager(); + const g = mgr.set(SESSION, 'x', { maxIterations: 10, blockCap: 3, tokenBudget: 5000, tokensAtStart: 100 }); + assert.equal(g.maxIterations, 10); + assert.equal(g.blockCap, 3); + assert.equal(g.tokenBudget, 5000); + assert.equal(g.tokensAtStart, 100); + assert.equal(g.tokensNow, 100); + }); + + test('set replaces an active goal (old marked cleared)', () => { + const { mgr } = createManager(); + const first = mgr.set(SESSION, 'first'); + mgr.set(SESSION, 'second'); + assert.equal(first.status, 'cleared'); + assert.equal(mgr.get(SESSION)?.condition, 'second'); + }); + + test('set after a terminal goal does not mutate the settled one', () => { + const { mgr } = createManager(); + const first = mgr.set(SESSION, 'first'); + mgr.markAchieved(SESSION, 'done'); + assert.equal(first.status, 'achieved'); + mgr.set(SESSION, 'second'); + // The achieved goal object keeps its status; a new goal replaces the map entry. + assert.equal(first.status, 'achieved'); + assert.equal(mgr.get(SESSION)?.condition, 'second'); + }); + + test('getActive only returns active goals', () => { + const { mgr } = createManager(); + mgr.set(SESSION, 'x'); + assert.ok(mgr.getActive(SESSION)); + mgr.pause(SESSION); + assert.equal(mgr.getActive(SESSION), undefined); + }); +}); + +describe('GoalManager — iteration ceiling', () => { + test('incrementIteration trips max_iterations', () => { + const { mgr } = createManager(); + mgr.set(SESSION, 'x', { maxIterations: 2 }); + mgr.incrementIteration(SESSION); + const g = mgr.incrementIteration(SESSION); + assert.equal(g?.status, 'max_iterations'); + }); + + test('incrementIteration on non-active returns undefined', () => { + const { mgr } = createManager(); + mgr.set(SESSION, 'x'); + mgr.pause(SESSION); + assert.equal(mgr.incrementIteration(SESSION), undefined); + }); +}); + +describe('GoalManager — block cap (stall detection)', () => { + test('progress resets the no-progress streak', () => { + const { mgr } = createManager(); + mgr.set(SESSION, 'x', { blockCap: 3 }); + mgr.recordProgress(SESSION, false); + mgr.recordProgress(SESSION, false); + assert.equal(mgr.get(SESSION)?.consecutiveNoProgress, 2); + mgr.recordProgress(SESSION, true); + assert.equal(mgr.get(SESSION)?.consecutiveNoProgress, 0); + assert.equal(mgr.get(SESSION)?.status, 'active'); + }); + + test('block cap trips stalled', () => { + const { mgr } = createManager(); + mgr.set(SESSION, 'x', { blockCap: 3 }); + mgr.recordProgress(SESSION, false); + mgr.recordProgress(SESSION, false); + const g = mgr.recordProgress(SESSION, false); + assert.equal(g?.status, 'stalled'); + assert.ok(g?.lastReason?.includes('No progress')); + }); + + test('recordProgress on non-active is a no-op', () => { + const { mgr } = createManager(); + mgr.set(SESSION, 'x'); + mgr.markAchieved(SESSION, 'done'); + assert.equal(mgr.recordProgress(SESSION, false), undefined); + }); +}); + +describe('GoalManager — token budget', () => { + test('recordTokens trips budget_limited (after baseline established)', () => { + const { mgr } = createManager(); + mgr.set(SESSION, 'x', { tokenBudget: 1000, tokensAtStart: 500 }); + mgr.recordTokens(SESSION, 500); // establishes baseline at 500 + mgr.recordTokens(SESSION, 1200); // spent 700, under budget + assert.equal(mgr.get(SESSION)?.status, 'active'); + mgr.recordTokens(SESSION, 1600); // spent 1100, over budget + assert.equal(mgr.get(SESSION)?.status, 'budget_limited'); + }); + + test('tokensSpent computes delta from established baseline', () => { + const { mgr } = createManager(); + mgr.set(SESSION, 'x', { tokensAtStart: 500 }); + mgr.recordTokens(SESSION, 500); // baseline + mgr.recordTokens(SESSION, 800); + assert.equal(mgr.tokensSpent(SESSION), 300); + }); + + test('token count is monotonic (stale smaller read ignored)', () => { + const { mgr } = createManager(); + mgr.set(SESSION, 'x', { tokensAtStart: 0 }); + mgr.recordTokens(SESSION, 0); // baseline + mgr.recordTokens(SESSION, 1000); + mgr.recordTokens(SESSION, 500); // stale + assert.equal(mgr.get(SESSION)?.tokensNow, 1000); + }); + + test('no budget → recordTokens never trips budget_limited', () => { + const { mgr } = createManager(); + mgr.set(SESSION, 'x'); + mgr.recordTokens(SESSION, 0); + mgr.recordTokens(SESSION, 1_000_000); + assert.equal(mgr.get(SESSION)?.status, 'active'); + }); +}); + +describe('GoalManager — pause / resume', () => { + test('pause then resume', () => { + const { mgr } = createManager(); + mgr.set(SESSION, 'x'); + const paused = mgr.pause(SESSION); + assert.equal(paused?.status, 'paused'); + assert.ok(paused?.pausedAt); + const resumed = mgr.resume(SESSION); + assert.equal(resumed?.status, 'active'); + assert.equal(resumed?.pausedAt, undefined); + }); + + test('cannot pause a non-active goal', () => { + const { mgr } = createManager(); + mgr.set(SESSION, 'x'); + mgr.markAchieved(SESSION, 'done'); + assert.equal(mgr.pause(SESSION), undefined); + }); + + test('cannot resume a non-paused goal', () => { + const { mgr } = createManager(); + mgr.set(SESSION, 'x'); + assert.equal(mgr.resume(SESSION), undefined); + }); +}); + +describe('GoalManager — terminal transitions', () => { + test('markAchieved / markImpossible only from active', () => { + const { mgr } = createManager(); + mgr.set(SESSION, 'x'); + assert.equal(mgr.markAchieved(SESSION, 'done')?.status, 'achieved'); + assert.equal(mgr.markImpossible(SESSION, 'no'), undefined); + }); + + test('clear from active → cleared; clear from terminal keeps outcome', () => { + const { mgr } = createManager(); + mgr.set(SESSION, 'x'); + assert.equal(mgr.clear(SESSION)?.status, 'cleared'); + + mgr.set(SESSION, 'y'); + mgr.markAchieved(SESSION, 'done'); + assert.equal(mgr.clear(SESSION)?.status, 'achieved'); + }); + + test('TERMINAL_GOAL_STATUSES covers all stop states', () => { + for (const s of ['achieved', 'impossible', 'cleared', 'stalled', 'budget_limited', 'max_iterations'] as const) { + assert.ok(TERMINAL_GOAL_STATUSES.has(s), `${s} should be terminal`); + } + assert.ok(!TERMINAL_GOAL_STATUSES.has('active')); + assert.ok(!TERMINAL_GOAL_STATUSES.has('paused')); + }); + + test('remove and dispose', () => { + const { mgr } = createManager(); + mgr.set(SESSION, 'x'); + assert.equal(mgr.remove(SESSION), true); + assert.equal(mgr.get(SESSION), undefined); + mgr.set('a', '1'); + mgr.set('b', '2'); + mgr.dispose(); + assert.equal(mgr.get('a'), undefined); + assert.equal(mgr.get('b'), undefined); + }); + + test('different sessions are independent', () => { + const { mgr } = createManager(); + mgr.set('a', 'goal A'); + mgr.set('b', 'goal B'); + assert.equal(mgr.get('a')?.condition, 'goal A'); + assert.equal(mgr.get('b')?.condition, 'goal B'); + }); +}); + +describe('GoalManager — token baseline', () => { + test('first recordTokens establishes the baseline (spend starts at 0)', () => { + const { mgr } = createManager(); + // GoalSet captured a stale/0 baseline; the goal actually starts at 50k. + mgr.set(SESSION, 'x', { tokenBudget: 20000, tokensAtStart: 0 }); + mgr.recordTokens(SESSION, 50000); // first real observation → re-baseline + assert.equal(mgr.tokensSpent(SESSION), 0); + assert.equal(mgr.get(SESSION)?.status, 'active'); // NOT budget_limited + mgr.recordTokens(SESSION, 71000); // spent 21000 > 20000 + assert.equal(mgr.get(SESSION)?.status, 'budget_limited'); + }); +}); diff --git a/packages/runtime/src/__tests__/goal-tools.test.ts b/packages/runtime/src/__tests__/goal-tools.test.ts new file mode 100644 index 0000000000..53910c824f --- /dev/null +++ b/packages/runtime/src/__tests__/goal-tools.test.ts @@ -0,0 +1,119 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { GoalManager } from '../goal-state.js'; +import { + buildGoalTools, + GOAL_SET_TOOL_NAME, + GOAL_CLEAR_TOOL_NAME, + GOAL_STATUS_TOOL_NAME, + GOAL_PAUSE_TOOL_NAME, + GOAL_RESUME_TOOL_NAME, +} from '../goal-tools.js'; +import type { MakaTool, MakaToolContext } from '../tool-runtime.js'; + +const SESSION = 'sess-1'; + +function ctx(): MakaToolContext { + return { sessionId: SESSION, turnId: 't', cwd: '/', toolCallId: 'tc', abortSignal: new AbortController().signal, emitOutput: () => {} }; +} + +function findTool(tools: MakaTool[], name: string): MakaTool { + const t = tools.find(x => x.name === name); + assert.ok(t, `tool ${name} exists`); + return t!; +} + +function makeTools(getTokenCount?: (s: string) => number) { + const mgr = new GoalManager({ generateId: () => 'g-1', now: () => 5000 }); + const tools = buildGoalTools({ goalManager: mgr, getTokenCount, now: () => 5000 }); + return { mgr, tools }; +} + +describe('goal tools', () => { + test('exposes 5 tools', () => { + const { tools } = makeTools(); + const names = tools.map(t => t.name).sort(); + assert.deepEqual(names, [ + GOAL_CLEAR_TOOL_NAME, GOAL_PAUSE_TOOL_NAME, GOAL_RESUME_TOOL_NAME, + GOAL_SET_TOOL_NAME, GOAL_STATUS_TOOL_NAME, + ].sort()); + }); + + test('all tools are permission-free', () => { + const { tools } = makeTools(); + for (const t of tools) assert.equal(t.permissionRequired, false); + }); + + test('GoalSet creates a goal with custom limits', async () => { + const { mgr, tools } = makeTools(); + const set = findTool(tools, GOAL_SET_TOOL_NAME); + const out = await set.impl({ condition: 'all tests pass', max_iterations: 10, block_cap: 3, token_budget: 5000 }, ctx()) as string; + assert.ok(out.includes('Goal set')); + assert.ok(out.includes('all tests pass')); + assert.ok(out.includes('max 10 turns')); + assert.ok(out.includes('budget 5000')); + const g = mgr.get(SESSION)!; + assert.equal(g.maxIterations, 10); + assert.equal(g.blockCap, 3); + assert.equal(g.tokenBudget, 5000); + }); + + test('GoalSet captures the token baseline', async () => { + const { mgr, tools } = makeTools(() => 1234); + const set = findTool(tools, GOAL_SET_TOOL_NAME); + await set.impl({ condition: 'x' }, ctx()); + assert.equal(mgr.get(SESSION)?.tokensAtStart, 1234); + }); + + test('GoalPause / GoalResume lifecycle', async () => { + const { mgr, tools } = makeTools(); + await findTool(tools, GOAL_SET_TOOL_NAME).impl({ condition: 'x' }, ctx()); + + const pauseOut = await findTool(tools, GOAL_PAUSE_TOOL_NAME).impl({}, ctx()) as string; + assert.ok(pauseOut.includes('paused')); + assert.equal(mgr.get(SESSION)?.status, 'paused'); + + const resumeOut = await findTool(tools, GOAL_RESUME_TOOL_NAME).impl({}, ctx()) as string; + assert.ok(resumeOut.includes('resumed')); + assert.equal(mgr.get(SESSION)?.status, 'active'); + }); + + test('GoalPause with no goal', async () => { + const { tools } = makeTools(); + const out = await findTool(tools, GOAL_PAUSE_TOOL_NAME).impl({}, ctx()) as string; + assert.ok(out.includes('No active goal')); + }); + + test('GoalResume with no paused goal', async () => { + const { tools } = makeTools(); + await findTool(tools, GOAL_SET_TOOL_NAME).impl({ condition: 'x' }, ctx()); + const out = await findTool(tools, GOAL_RESUME_TOOL_NAME).impl({}, ctx()) as string; + assert.ok(out.includes('No paused goal')); + }); + + test('GoalClear', async () => { + const { mgr, tools } = makeTools(); + await findTool(tools, GOAL_SET_TOOL_NAME).impl({ condition: 'x' }, ctx()); + const out = await findTool(tools, GOAL_CLEAR_TOOL_NAME).impl({}, ctx()) as string; + assert.ok(out.includes('cleared')); + assert.equal(mgr.get(SESSION)?.status, 'cleared'); + }); + + test('GoalStatus shows full lifecycle detail', async () => { + const { mgr, tools } = makeTools(); + await findTool(tools, GOAL_SET_TOOL_NAME).impl({ condition: 'deploy', token_budget: 5000 }, ctx()); + mgr.recordTokens(SESSION, 1000); // establishes baseline at 1000 + mgr.recordTokens(SESSION, 2500); // spent 1500 since baseline + const out = await findTool(tools, GOAL_STATUS_TOOL_NAME).impl({}, ctx()) as string; + assert.ok(out.includes('deploy')); + assert.ok(out.includes('Status: active')); + assert.ok(out.includes('No-progress streak: 0/8')); + assert.ok(out.includes('Tokens: 1500/5000')); + }); + + test('GoalStatus with no goal', async () => { + const { tools } = makeTools(); + const out = await findTool(tools, GOAL_STATUS_TOOL_NAME).impl({}, ctx()) as string; + assert.ok(out.includes('No goal set')); + }); +}); diff --git a/packages/runtime/src/goal-continuation.ts b/packages/runtime/src/goal-continuation.ts new file mode 100644 index 0000000000..2f8838c42e --- /dev/null +++ b/packages/runtime/src/goal-continuation.ts @@ -0,0 +1,116 @@ +/** + * Goal continuation controller — the pure decision logic for what happens at a + * turn boundary when a goal is active. Lives in @maka/runtime so desktop and + * CLI share one implementation (Desktop/TUI parity rule). + * + * Called after a turn's event stream drains. Order of operations matters: + * the external evaluator runs FIRST so a goal genuinely completed on its last + * permitted turn is detected as achieved/impossible rather than misreported as + * a cap failure. Caps (iterations / token budget / stall) are enforced only + * after the evaluator has had its say. + * + * "Waiting on an external event" is treated as a NEUTRAL signal: the turn does + * not count against the stall cap (the agent is legitimately blocked, not + * stuck), and a normal continuation turn is injected so the agent re-checks. + * A long wait is bounded by maxIterations and surfaces as a visible terminal + * state — never a silent zombie. (A scheduled poll handoff to the automation + * system is deliberately out of scope for v1; it couples two independent + * lifecycles and is easy to get wrong.) + */ + +import { evaluateGoal, type GoalEvaluation, type GoalEvaluatorDeps } from './goal-evaluator.js'; +import type { GoalManager } from './goal-state.js'; + +export type GoalContinuationOutcome = + | { kind: 'no_goal' } + | { kind: 'cannot_continue' } + | { kind: 'busy' } + | { kind: 'achieved'; evaluation: GoalEvaluation } + | { kind: 'impossible'; evaluation: GoalEvaluation } + | { kind: 'stopped'; reason: string; status: string } + | { kind: 'continued'; evaluation: GoalEvaluation }; + +export interface GoalContinuationDeps { + goalManager: GoalManager; + evaluator: GoalEvaluatorDeps; + /** Summarized recent conversation (last ~5 messages) for the evaluator. */ + getRecentContext: (sessionId: string) => Promise; + /** Current cumulative token count for the session (for budget tracking). */ + getTokenCount?: (sessionId: string) => number; + /** Inject a continuation turn into the session. */ + injectTurn: (sessionId: string, text: string) => void; + /** Session is idle and can accept a new turn (exists, not archived, not running). */ + canContinue: (sessionId: string) => Promise; + /** + * Per-session re-entrancy guard. Prevents two overlapping continuations for + * the same session (the evaluator call spans multiple seconds, during which a + * second turn could complete). Supplied by the wiring; omitted in unit tests. + */ + inFlight?: Set; +} + +const CONTINUATION_PREAMBLE = + '[Goal continuation] The goal is not yet met. Keep working toward it. ' + + 'Do not redefine success around a smaller task; match your verification to the full requirement.'; + +export async function handleGoalContinuation( + deps: GoalContinuationDeps, + sessionId: string, +): Promise { + const goal = deps.goalManager.getActive(sessionId); + if (!goal) return { kind: 'no_goal' }; + + // Re-entrancy guard: only one continuation in flight per session. + if (deps.inFlight?.has(sessionId)) return { kind: 'busy' }; + deps.inFlight?.add(sessionId); + try { + if (!(await deps.canContinue(sessionId))) return { kind: 'cannot_continue' }; + + // Evaluate FIRST — a genuine completion on the final permitted turn must be + // detected before any cap short-circuits the loop. + const context = await deps.getRecentContext(sessionId); + const evaluation = await evaluateGoal(deps.evaluator, goal.condition, context); + + if (evaluation.met) { + deps.goalManager.markAchieved(sessionId, evaluation.reason); + return { kind: 'achieved', evaluation }; + } + if (evaluation.impossible) { + deps.goalManager.markImpossible(sessionId, evaluation.reason); + return { kind: 'impossible', evaluation }; + } + + // Enforce caps AFTER evaluation. Each may flip the goal terminal. + if (deps.getTokenCount) { + deps.goalManager.recordTokens(sessionId, deps.getTokenCount(sessionId)); + } + deps.goalManager.incrementIteration(sessionId); + // Progress signal drives the stall cap. Skip it (neutral) when the evaluator + // failed (transient outage must not defeat stall detection) OR when the + // agent is legitimately waiting on an external event (a wait is not a stall). + if (!evaluation.evaluatorFailed && !evaluation.waiting) { + deps.goalManager.recordProgress(sessionId, evaluation.progress); + } + + const settled = deps.goalManager.get(sessionId); + if (!settled || settled.status !== 'active') { + return { kind: 'stopped', reason: settled?.lastReason ?? 'Goal settled', status: settled?.status ?? 'unknown' }; + } + + // Re-check idle immediately before injecting — the evaluator call may have + // spanned seconds during which a user send started a new turn. + if (!(await deps.canContinue(sessionId))) return { kind: 'cannot_continue' }; + + settled.lastReason = evaluation.reason; + const waitNote = evaluation.waiting ? ' (waiting on an external event — re-check, do not spin uselessly)' : ''; + deps.injectTurn( + sessionId, + `${CONTINUATION_PREAMBLE}\n\nEvaluation: ${evaluation.reason}${waitNote}\n` + + `Goal: "${settled.condition}" (turn ${settled.iterations}/${settled.maxIterations}` + + `${settled.consecutiveNoProgress > 0 ? `, ${settled.consecutiveNoProgress}/${settled.blockCap} no-progress` : ''})`, + ); + return { kind: 'continued', evaluation }; + } finally { + deps.inFlight?.delete(sessionId); + } +} diff --git a/packages/runtime/src/goal-evaluator.ts b/packages/runtime/src/goal-evaluator.ts new file mode 100644 index 0000000000..3cf2bb1a6d --- /dev/null +++ b/packages/runtime/src/goal-evaluator.ts @@ -0,0 +1,142 @@ +/** + * Goal evaluator — CC-style external judge. Uses a cheap/fast model (e.g. haiku) + * to decide, after each turn, whether the goal is met, impossible, making + * progress, or waiting on an external event. + * + * The working model never judges its own completion (unlike Codex): keeping the + * judge external prevents the agent from rationalizing itself into a premature + * "done", which is Codex's documented failure mode. + */ + +export interface GoalEvaluation { + /** Condition is satisfied — stop, success. */ + met: boolean; + /** Fundamentally unachievable — stop, give up. */ + impossible: boolean; + /** The last turn advanced toward the goal (resets the block cap). */ + progress: boolean; + /** The agent is blocked waiting on an external event (CI, deploy, review). */ + waiting: boolean; + /** Suggested seconds to wait before re-checking, when `waiting`. */ + waitSeconds?: number; + /** + * The evaluator failed (timeout/error) and produced no real judgment. The + * caller should treat `progress` as UNKNOWN — neither advancing nor resetting + * the stall counter — so a transient evaluator outage cannot silently defeat + * stall detection. Fail-open on continuation still applies. + */ + evaluatorFailed: boolean; + /** One-sentence rationale, fed back to the agent as steering. */ + reason: string; +} + +export interface GoalEvaluatorDeps { + /** + * Single-shot LLM call for goal evaluation. Should use a cheap/fast model. + * The evaluator must not run tools or read files — it judges from text only. + */ + evaluate: (prompt: string) => Promise; + /** Hard timeout for the evaluator call (ms). Defaults to 30_000 (CC's limit). */ + timeoutMs?: number; + /** Injectable timer for tests. Defaults to global setTimeout/clearTimeout. */ + setTimeout?: (fn: () => void, ms: number) => unknown; + clearTimeout?: (handle: unknown) => void; +} + +const DEFAULT_EVALUATOR_TIMEOUT_MS = 30_000; + +const EVALUATOR_SYSTEM = `You are a goal evaluation judge for an autonomous coding agent. Given a GOAL CONDITION and recent CONVERSATION CONTEXT, judge the agent's progress. + +Respond ONLY with valid JSON in this exact shape: +{"met": boolean, "impossible": boolean, "progress": boolean, "waiting": boolean, "wait_seconds": number, "reason": "one sentence"} + +Field rules: +- met: true ONLY if there is clear, concrete evidence the condition is fully satisfied. Match verification scope to the requirement scope — do not accept a narrower substitute. +- impossible: true ONLY for a truly unachievable goal (violates constraints/physics), not merely a hard one. +- progress: true if the last turn moved measurably closer to the goal (fixed a failure, advanced a step). false if the turn spun, repeated itself, or did nothing useful. +- waiting: true if the agent is correctly blocked on an external event it cannot speed up (CI run, deploy, remote queue, human review). Set wait_seconds to a sensible poll interval (default 60). +- reason: concise (under 120 chars), specific, actionable steering for the next turn. + +Be conservative on "met" and "impossible". When uncertain, met=false impossible=false progress=false waiting=false.`; + +export function buildGoalEvaluationPrompt(condition: string, context: string): string { + return [ + EVALUATOR_SYSTEM, + '', + '--- GOAL CONDITION ---', + condition, + '', + '--- RECENT CONVERSATION CONTEXT ---', + context, + '', + '--- YOUR JUDGMENT (JSON only) ---', + ].join('\n'); +} + +export function parseGoalEvaluation(raw: string): GoalEvaluation { + // Unparseable output is "no real judgment" — treat it as a NEUTRAL evaluator + // failure (like a timeout), NOT as real "no progress", so a garbled cheap-model + // response cannot skew stall detection into a false 'stalled' termination. + const fallback: GoalEvaluation = { + met: false, impossible: false, progress: false, waiting: false, evaluatorFailed: true, + reason: 'Evaluator produced unparseable output', + }; + // Prefer the object that mentions "met"; fall back to the first object. + const jsonMatch = raw.match(/\{[^{}]*"met"[^{}]*\}/s) ?? raw.match(/\{[\s\S]*?\}/); + if (!jsonMatch) return fallback; + try { + const parsed = JSON.parse(jsonMatch[0]) as Record; + const waitSecondsRaw = parsed.wait_seconds; + const waitSeconds = typeof waitSecondsRaw === 'number' && Number.isFinite(waitSecondsRaw) && waitSecondsRaw > 0 + ? Math.min(3600, Math.round(waitSecondsRaw)) + : undefined; + return { + met: Boolean(parsed.met), + impossible: Boolean(parsed.impossible), + progress: Boolean(parsed.progress), + waiting: Boolean(parsed.waiting), + evaluatorFailed: false, + ...(waitSeconds !== undefined ? { waitSeconds } : {}), + reason: typeof parsed.reason === 'string' && parsed.reason.trim() + ? parsed.reason.slice(0, 200) + : 'No reason provided', + }; + } catch { + return { ...fallback, reason: 'Evaluator JSON parse failed' }; + } +} + +/** + * Race the evaluator against a hard timeout. On timeout or error, fail OPEN + * for continuation (goal keeps working) but flag `evaluatorFailed` so the + * caller does not treat the outage as either progress or a stall. + */ +export async function evaluateGoal( + deps: GoalEvaluatorDeps, + condition: string, + context: string, +): Promise { + const prompt = buildGoalEvaluationPrompt(condition, context); + const timeoutMs = deps.timeoutMs ?? DEFAULT_EVALUATOR_TIMEOUT_MS; + const setT = deps.setTimeout ?? ((fn, ms) => setTimeout(fn, ms)); + const clearT = deps.clearTimeout ?? ((h) => clearTimeout(h as ReturnType)); + + let timer: unknown; + const timeout = new Promise<'__timeout__'>((resolve) => { + timer = setT(() => resolve('__timeout__'), timeoutMs); + }); + + try { + const result = await Promise.race([deps.evaluate(prompt), timeout]); + if (result === '__timeout__') { + return { met: false, impossible: false, progress: false, waiting: false, evaluatorFailed: true, reason: 'Evaluator timed out (continuing)' }; + } + return parseGoalEvaluation(result); + } catch { + return { met: false, impossible: false, progress: false, waiting: false, evaluatorFailed: true, reason: 'Evaluator call failed (continuing)' }; + } finally { + clearT(timer); + } +} + +export { DEFAULT_EVALUATOR_TIMEOUT_MS }; diff --git a/packages/runtime/src/goal-state.ts b/packages/runtime/src/goal-state.ts new file mode 100644 index 0000000000..0e90d3a139 --- /dev/null +++ b/packages/runtime/src/goal-state.ts @@ -0,0 +1,222 @@ +/** + * Goal execution state — session-scoped, in-memory. + * + * A goal is a durable objective the agent works toward autonomously across + * turns. After each turn, an external evaluator (CC-style, uses a cheap model) + * judges whether the condition is met; if not, the system auto-continues. + * + * Lifecycle (Codex-inspired): + * active → achieved / impossible / cleared / paused + * → stalled (block cap: N consecutive no-progress turns) + * → budget_limited (token budget exhausted) + * → max_iterations (total turn ceiling) + */ + +export type GoalStatus = + | 'active' + | 'achieved' + | 'impossible' + | 'cleared' + | 'paused' + | 'stalled' + | 'budget_limited' + | 'max_iterations'; + +/** Terminal statuses — a goal in one of these states will not continue. */ +export const TERMINAL_GOAL_STATUSES: ReadonlySet = new Set([ + 'achieved', + 'impossible', + 'cleared', + 'stalled', + 'budget_limited', + 'max_iterations', +]); + +export interface GoalState { + id: string; + sessionId: string; + condition: string; + status: GoalStatus; + setAt: number; + iterations: number; + maxIterations: number; + /** Consecutive turns with no progress (drives the block cap → stalled). */ + consecutiveNoProgress: number; + /** Force-stop after this many consecutive no-progress turns (CC's 8). */ + blockCap: number; + /** Optional token budget; goal → budget_limited when exceeded. */ + tokenBudget?: number; + /** Token count observed when the goal was set (baseline for spend). */ + tokensAtStart: number; + /** Latest observed token count (used to compute spend). */ + tokensNow: number; + /** + * True until the first real token observation. The baseline captured at set + * time can be stale/0 (the model calls GoalSet before any continuation has + * observed the session's token count), so the first recordTokens re-baselines + * to measure only tokens the goal itself spends. + */ + tokensBaselinePending: boolean; + lastReason?: string; + achievedAt?: number; + pausedAt?: number; +} + +export interface GoalManagerDeps { + generateId: () => string; + now: () => number; +} + +export const DEFAULT_MAX_ITERATIONS = 50; +export const DEFAULT_BLOCK_CAP = 8; + +export class GoalManager { + private goals = new Map(); + + constructor(private readonly deps: GoalManagerDeps) {} + + set(sessionId: string, condition: string, opts?: { + maxIterations?: number; + blockCap?: number; + tokenBudget?: number; + tokensAtStart?: number; + }): GoalState { + // Replacing an existing goal: settle the old one before overwriting. + const existing = this.goals.get(sessionId); + if (existing && !TERMINAL_GOAL_STATUSES.has(existing.status)) { + existing.status = 'cleared'; + } + const start = opts?.tokensAtStart ?? 0; + const goal: GoalState = { + id: this.deps.generateId(), + sessionId, + condition, + status: 'active', + setAt: this.deps.now(), + iterations: 0, + maxIterations: opts?.maxIterations ?? DEFAULT_MAX_ITERATIONS, + consecutiveNoProgress: 0, + blockCap: opts?.blockCap ?? DEFAULT_BLOCK_CAP, + tokenBudget: opts?.tokenBudget, + tokensAtStart: start, + tokensNow: start, + tokensBaselinePending: true, + }; + this.goals.set(sessionId, goal); + return goal; + } + + get(sessionId: string): GoalState | undefined { + return this.goals.get(sessionId); + } + + getActive(sessionId: string): GoalState | undefined { + const goal = this.goals.get(sessionId); + return goal?.status === 'active' ? goal : undefined; + } + + tokensSpent(sessionId: string): number { + const goal = this.goals.get(sessionId); + if (!goal) return 0; + return Math.max(0, goal.tokensNow - goal.tokensAtStart); + } + + incrementIteration(sessionId: string): GoalState | undefined { + const goal = this.goals.get(sessionId); + if (!goal || goal.status !== 'active') return undefined; + goal.iterations++; + if (goal.iterations >= goal.maxIterations) { + goal.status = 'max_iterations'; + goal.lastReason = `Reached maximum iterations (${goal.maxIterations})`; + } + return goal; + } + + recordProgress(sessionId: string, madeProgress: boolean): GoalState | undefined { + const goal = this.goals.get(sessionId); + if (!goal || goal.status !== 'active') return undefined; + if (madeProgress) { + goal.consecutiveNoProgress = 0; + } else { + goal.consecutiveNoProgress++; + if (goal.consecutiveNoProgress >= goal.blockCap) { + goal.status = 'stalled'; + goal.lastReason = `No progress for ${goal.blockCap} consecutive turns`; + } + } + return goal; + } + + recordTokens(sessionId: string, tokensNow: number): GoalState | undefined { + const goal = this.goals.get(sessionId); + if (!goal) return undefined; + // The first real observation establishes the baseline (see field doc). + if (goal.tokensBaselinePending) { + goal.tokensAtStart = tokensNow; + goal.tokensNow = tokensNow; + goal.tokensBaselinePending = false; + return goal; + } + // Token counts are monotonic; never let a stale/smaller read regress spend. + goal.tokensNow = Math.max(goal.tokensNow, tokensNow); + if ( + goal.status === 'active' && + goal.tokenBudget !== undefined && + goal.tokensNow - goal.tokensAtStart >= goal.tokenBudget + ) { + goal.status = 'budget_limited'; + goal.lastReason = `Token budget exhausted (${goal.tokenBudget} tokens)`; + } + return goal; + } + + markAchieved(sessionId: string, reason: string): GoalState | undefined { + const goal = this.goals.get(sessionId); + if (!goal || goal.status !== 'active') return undefined; + goal.status = 'achieved'; + goal.lastReason = reason; + goal.achievedAt = this.deps.now(); + return goal; + } + + markImpossible(sessionId: string, reason: string): GoalState | undefined { + const goal = this.goals.get(sessionId); + if (!goal || goal.status !== 'active') return undefined; + goal.status = 'impossible'; + goal.lastReason = reason; + return goal; + } + + pause(sessionId: string): GoalState | undefined { + const goal = this.goals.get(sessionId); + if (!goal || goal.status !== 'active') return undefined; + goal.status = 'paused'; + goal.pausedAt = this.deps.now(); + return goal; + } + + resume(sessionId: string): GoalState | undefined { + const goal = this.goals.get(sessionId); + if (!goal || goal.status !== 'paused') return undefined; + goal.status = 'active'; + goal.pausedAt = undefined; + return goal; + } + + clear(sessionId: string): GoalState | undefined { + const goal = this.goals.get(sessionId); + if (!goal) return undefined; + if (!TERMINAL_GOAL_STATUSES.has(goal.status)) { + goal.status = 'cleared'; + } + return goal; + } + + remove(sessionId: string): boolean { + return this.goals.delete(sessionId); + } + + dispose(): void { + this.goals.clear(); + } +} diff --git a/packages/runtime/src/goal-tools.ts b/packages/runtime/src/goal-tools.ts new file mode 100644 index 0000000000..9491ab3bab --- /dev/null +++ b/packages/runtime/src/goal-tools.ts @@ -0,0 +1,155 @@ +/** + * Goal tools — GoalSet / GoalClear / GoalStatus / GoalPause / GoalResume. + * + * Model-facing autonomous-execution controls. The agent can arm its own stop + * condition (GoalSet), and pause/resume/clear the loop. PascalCase names match + * the builtin tool family (Bash/Read/TaskCreate/Automation). + */ + +import { z } from 'zod'; +import type { MakaTool } from './tool-runtime.js'; +import type { GoalManager, GoalState } from './goal-state.js'; + +export const GOAL_SET_TOOL_NAME = 'GoalSet'; +export const GOAL_CLEAR_TOOL_NAME = 'GoalClear'; +export const GOAL_STATUS_TOOL_NAME = 'GoalStatus'; +export const GOAL_PAUSE_TOOL_NAME = 'GoalPause'; +export const GOAL_RESUME_TOOL_NAME = 'GoalResume'; + +export interface GoalToolsDeps { + goalManager: GoalManager; + /** Current cumulative token count for a session (baseline for budget). */ + getTokenCount?: (sessionId: string) => number; + now?: () => number; +} + +export function buildGoalTools(deps: GoalToolsDeps): MakaTool[] { + return [ + buildGoalSetTool(deps), + buildGoalClearTool(deps), + buildGoalStatusTool(deps), + buildGoalPauseTool(deps), + buildGoalResumeTool(deps), + ]; +} + +function buildGoalSetTool(deps: GoalToolsDeps): MakaTool<{ + condition: string; + max_iterations?: number; + block_cap?: number; + token_budget?: number; +}, string> { + return { + name: GOAL_SET_TOOL_NAME, + displayName: 'Goal Set', + description: + 'Set an autonomous execution goal. After each turn an evaluator judges progress; ' + + 'if the condition is not met the system continues working turn after turn until it is ' + + 'met, deemed impossible, stalls, or hits a limit. Only one goal is active per session; ' + + 'setting a new one replaces the previous.', + parameters: z.object({ + condition: z.string().trim().min(1).max(500) + .describe('The objective to achieve. Should be observable and verifiable (e.g. "all tests in packages/runtime pass", "PR #522 review comments addressed").'), + max_iterations: z.number().int().min(1).max(200).optional() + .describe('Absolute ceiling on total turns before giving up. Defaults to 50.'), + block_cap: z.number().int().min(1).max(50).optional() + .describe('Stop after this many consecutive turns with no progress (stall detection). Defaults to 8.'), + token_budget: z.number().int().min(1000).optional() + .describe('Optional token budget; the goal stops (budget_limited) once this many tokens are spent working toward it.'), + }), + permissionRequired: false, + impl: (input, ctx) => { + const tokensAtStart = deps.getTokenCount?.(ctx.sessionId) ?? 0; + const goal = deps.goalManager.set(ctx.sessionId, input.condition, { + maxIterations: input.max_iterations, + blockCap: input.block_cap, + tokenBudget: input.token_budget, + tokensAtStart, + }); + const limits = [ + `max ${goal.maxIterations} turns`, + `stall after ${goal.blockCap} no-progress turns`, + goal.tokenBudget ? `budget ${goal.tokenBudget} tokens` : undefined, + ].filter(Boolean).join(', '); + return `Goal set: "${goal.condition}" (${limits}). ` + + 'The system will evaluate progress after each turn and continue autonomously until the condition is met.'; + }, + }; +} + +function buildGoalClearTool(deps: GoalToolsDeps): MakaTool, string> { + return { + name: GOAL_CLEAR_TOOL_NAME, + displayName: 'Goal Clear', + description: 'Clear the active goal, stopping autonomous execution after the current turn.', + parameters: z.object({}), + permissionRequired: false, + impl: (_input, ctx) => { + const goal = deps.goalManager.clear(ctx.sessionId); + if (!goal) return 'No active goal to clear.'; + return `Goal cleared: "${goal.condition}" after ${goal.iterations} turn(s).`; + }, + }; +} + +function buildGoalPauseTool(deps: GoalToolsDeps): MakaTool, string> { + return { + name: GOAL_PAUSE_TOOL_NAME, + displayName: 'Goal Pause', + description: 'Pause the active goal. Autonomous continuation stops until GoalResume is called; state is preserved.', + parameters: z.object({}), + permissionRequired: false, + impl: (_input, ctx) => { + const goal = deps.goalManager.pause(ctx.sessionId); + if (!goal) return 'No active goal to pause.'; + return `Goal paused: "${goal.condition}" at turn ${goal.iterations}. Use GoalResume to continue.`; + }, + }; +} + +function buildGoalResumeTool(deps: GoalToolsDeps): MakaTool, string> { + return { + name: GOAL_RESUME_TOOL_NAME, + displayName: 'Goal Resume', + description: 'Resume a paused goal, re-enabling autonomous continuation.', + parameters: z.object({}), + permissionRequired: false, + impl: (_input, ctx) => { + const goal = deps.goalManager.resume(ctx.sessionId); + if (!goal) return 'No paused goal to resume.'; + return `Goal resumed: "${goal.condition}". Autonomous continuation re-enabled.`; + }, + }; +} + +function buildGoalStatusTool(deps: GoalToolsDeps): MakaTool, string> { + return { + name: GOAL_STATUS_TOOL_NAME, + displayName: 'Goal Status', + description: 'Check the current goal status for this session.', + parameters: z.object({}), + permissionRequired: false, + impl: (_input, ctx) => { + const goal = deps.goalManager.get(ctx.sessionId); + if (!goal) return 'No goal set for this session.'; + return formatGoal(goal, deps); + }, + }; +} + +function formatGoal(goal: GoalState, deps: GoalToolsDeps): string { + const now = deps.now?.() ?? Date.now(); + const elapsed = Math.round((now - goal.setAt) / 1000); + const spent = Math.max(0, goal.tokensNow - goal.tokensAtStart); + const lines = [ + `Goal: "${goal.condition}"`, + `Status: ${goal.status}`, + `Turns: ${goal.iterations}/${goal.maxIterations}`, + `No-progress streak: ${goal.consecutiveNoProgress}/${goal.blockCap}`, + `Elapsed: ${elapsed}s`, + ]; + if (goal.tokenBudget) lines.push(`Tokens: ${spent}/${goal.tokenBudget}`); + else if (spent > 0) lines.push(`Tokens spent: ${spent}`); + if (goal.lastReason) lines.push(`Last evaluation: ${goal.lastReason}`); + return lines.join('\n'); +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 3d0c931b4a..9f2b36182d 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -609,3 +609,27 @@ export { AutomationScheduler, FIRE_CHECK_INTERVAL_MS, MAX_DEFER_RETRIES } from ' export type { AutomationSchedulerDeps } from './automation-scheduler.js'; export { buildAutomationTool, AUTOMATION_TOOL_NAME } from './automation-tools.js'; export type { AutomationToolDeps } from './automation-tools.js'; + +// ─────────────────────────────────────────────────────────────────────────── +// Goal execution (Issue #15 Primitive 6). +// ─────────────────────────────────────────────────────────────────────────── +export { GoalManager, TERMINAL_GOAL_STATUSES, DEFAULT_MAX_ITERATIONS, DEFAULT_BLOCK_CAP } from './goal-state.js'; +export type { GoalState, GoalStatus, GoalManagerDeps } from './goal-state.js'; +export { + evaluateGoal, + buildGoalEvaluationPrompt, + parseGoalEvaluation, + DEFAULT_EVALUATOR_TIMEOUT_MS, +} from './goal-evaluator.js'; +export type { GoalEvaluation, GoalEvaluatorDeps } from './goal-evaluator.js'; +export { + buildGoalTools, + GOAL_SET_TOOL_NAME, + GOAL_CLEAR_TOOL_NAME, + GOAL_STATUS_TOOL_NAME, + GOAL_PAUSE_TOOL_NAME, + GOAL_RESUME_TOOL_NAME, +} from './goal-tools.js'; +export type { GoalToolsDeps } from './goal-tools.js'; +export { handleGoalContinuation } from './goal-continuation.js'; +export type { GoalContinuationDeps, GoalContinuationOutcome } from './goal-continuation.js'; From 3930ad5b16b2396498256f12e62b9e9334b161af Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Tue, 7 Jul 2026 17:25:05 +0800 Subject: [PATCH 06/23] fix(runtime): wire cron fresh-session execution + outcome-after-stream (PR #558 review G1-G5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Astro-Han's PR review — the must-fix functional gaps that stopped cron from working: - G1: cron now actually runs. Desktop wires createFreshRun (createSession in explore mode + sendMessage), so a cron fire spawns a real session + run labelled `automation`/`cron`. CLI has no multi-session surface, so cron is gated off there (cronEnabled derives from the executor; the tool advertises heartbeat only and rejects the cron kind at the schema). - G4: outcome is decided AFTER the run's stream finishes. Split the fire into attemptStarted / attemptSucceeded / attemptFailed; terminal completion (once / maxFires) commits only on a real success. injectTurn/createFreshRun now return a Promise; a rejected or ok:false run is recorded as a failure, never a success. A one-shot failure pauses (visible, not a zombie). - G2: fires carry their runId — markSuccess(id, runId) sets lastRunId. - G3b: automation-triggered runs are labelled. New TurnOrigin on UserMessageInput threads to AgentRunHeader.automationId, so trace can tell an automation run from a hand-typed one. - G5: CLI wraps the TUI in try/finally { scheduler.dispose() } so its timer does not keep the process alive; desktop already clears session heartbeats on archive/remove. - G6 (partial): canFire only fires into a genuinely idle session. Deferred (per reviewer's own sequencing — settle the data model first): G3 AutomationEvent ledger, G7 cron-parser edge cases (sparse annual crons, dom+dow OR semantics, timezone). Will follow up as an RFC/owned issue. Tests updated for the attempt* lifecycle; new cron integration + gating tests. --- apps/desktop/src/main/automation-wiring.ts | 11 +- apps/desktop/src/main/main.ts | 45 ++++++- packages/cli/src/cli.ts | 42 ++++--- packages/cli/src/runtime-bootstrap.ts | 16 ++- packages/core/src/agent-run.ts | 2 + packages/core/src/runtime-inputs.ts | 6 + .../__tests__/automation-integration.test.ts | 65 +++++++++- .../automation-mutation-verify.test.ts | 21 ++-- .../__tests__/automation-scheduler.test.ts | 115 ++++++++++-------- .../runtime/src/__tests__/automation.test.ts | 80 ++++++++---- packages/runtime/src/agent-run.ts | 3 + packages/runtime/src/automation-scheduler.ts | 77 ++++++++---- packages/runtime/src/automation-state.ts | 70 ++++++++--- packages/runtime/src/automation-tools.ts | 103 +++++++++------- packages/runtime/src/index.ts | 2 +- 15 files changed, 458 insertions(+), 200 deletions(-) diff --git a/apps/desktop/src/main/automation-wiring.ts b/apps/desktop/src/main/automation-wiring.ts index 6c16544b74..dac6c701a5 100644 --- a/apps/desktop/src/main/automation-wiring.ts +++ b/apps/desktop/src/main/automation-wiring.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto'; -import { AutomationManager, AutomationScheduler, buildAutomationTool, type AutomationDefinition, type MakaTool } from '@maka/runtime'; +import { AutomationManager, AutomationScheduler, buildAutomationTool, type AutomationDefinition, type AutomationFireResult, type MakaTool } from '@maka/runtime'; import { createAutomationStore } from '@maka/storage'; /** @@ -16,8 +16,10 @@ export interface MainAutomationWiring { export interface CreateMainAutomationWiringDeps { workspaceRoot: string; canFire: (sessionId: string) => Promise; - injectTurn: (sessionId: string, prompt: string, automationId: string) => void; - createFreshRun?: (prompt: string, automationId: string) => void; + /** 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 { @@ -48,6 +50,8 @@ export function createMainAutomationWiring(deps: CreateMainAutomationWiringDeps) 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 => { @@ -57,3 +61,4 @@ export function createMainAutomationWiring(deps: CreateMainAutomationWiringDeps) return { manager, scheduler, tools, loadDurableAutomations }; } + diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index d29758a15b..67de0f897a 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -313,13 +313,41 @@ const automationWiring = createMainAutomationWiring({ async canFire(sessionId: string): Promise { const header = await store.readHeader(sessionId); if (!header || header.archivedAt) return false; - if (header.status === 'running' || header.status === 'blocked' || header.status === 'aborted') return false; + // Only fire into a genuinely idle session — not mid-turn, blocked, aborted, + // waiting on the user, or already settled/under review. + if (header.status !== 'active' && header.status !== 'waiting_for_user' && header.status !== 'done') return false; + if (header.status === 'waiting_for_user') return false; return true; }, - injectTurn(sessionId: string, prompt: string, _automationId: string) { + // Heartbeat: inject into the automation's own session; resolve after the stream. + injectTurn(sessionId: string, prompt: string, automationId: string) { const turnId = randomUUID(); - const iterator = runtime.sendMessage(sessionId, { turnId, text: prompt }); - void streamEvents(sessionId, iterator, turnId); + const iterator = runtime.sendMessage(sessionId, { + turnId, text: prompt, origin: { kind: 'automation', automationId }, + }); + return streamEvents(sessionId, iterator, turnId); + }, + // 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 }, + }); + return streamEvents(session.id, iterator, turnId); }, }); @@ -1585,10 +1613,12 @@ 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) { @@ -1598,6 +1628,9 @@ async function streamEvents( 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)) { @@ -1617,6 +1650,7 @@ async function streamEvents( if (!turnAborted) { void handleGoalContinuation(goalWiring.continuationDeps, sessionId).catch(() => {}); } + return { turnId, ok: !turnAborted && !turnError, ...(turnError ? { error: turnError } : {}) }; } catch (error) { const event = { type: 'error', @@ -1636,6 +1670,7 @@ async function streamEvents( emitSessionsChanged('message-appended', sessionId); finalAppendBroadcasted = true; } + return { turnId, ok: false, error: errorMessage(error) }; } } diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f7aeddc1bc..e749ee2ef7 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -69,24 +69,30 @@ export async function runMakaCli(argv: string[] = process.argv.slice(2)): Promis model: context.target.model, permissionMode: 'ask', }); - await runMakaPiTui({ - driver, - title: 'Maka', - cwd: context.cwd, - model: context.target.model, - models: selectableModelIdsForTarget(context.target), - connectionSlug: context.target.connection.slug, - providerType: context.target.connection.providerType, - permissionMode: 'ask', - onTurnComplete: (injectTurn) => { - const sessionId = driver.getSessionId(); - if (!sessionId) return; - void handleGoalContinuation( - { ...context.goalContinuationDeps, injectTurn: (_s, text) => injectTurn(text) }, - sessionId, - ).catch(() => {}); - }, - }); + try { + await runMakaPiTui({ + driver, + title: 'Maka', + cwd: context.cwd, + model: context.target.model, + models: selectableModelIdsForTarget(context.target), + connectionSlug: context.target.connection.slug, + providerType: context.target.connection.providerType, + permissionMode: 'ask', + onTurnComplete: (injectTurn) => { + const sessionId = driver.getSessionId(); + if (!sessionId) return; + void handleGoalContinuation( + { ...context.goalContinuationDeps, injectTurn: (_s, text) => injectTurn(text) }, + sessionId, + ).catch(() => {}); + }, + }); + } finally { + // Stop the automation scheduler so its 5s timer does not keep the + // process alive and tick into a stopped session after the TUI exits. + context.automationScheduler.dispose(); + } return 0; } } diff --git a/packages/cli/src/runtime-bootstrap.ts b/packages/cli/src/runtime-bootstrap.ts index a26eead9a9..7438c6f04d 100644 --- a/packages/cli/src/runtime-bootstrap.ts +++ b/packages/cli/src/runtime-bootstrap.ts @@ -169,10 +169,20 @@ export async function createMakaCliRuntimeContext( if (header.status === 'running' || header.status === 'blocked' || header.status === 'aborted') return false; return true; }, - injectTurn: (sessionId, prompt) => { + // 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 }); - void (async () => { for await (const _ of iterator) { /* drain */ } })().catch(() => {}); + 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) }; + } }, setTimeout: (fn, ms) => setTimeout(fn, ms), clearTimeout: (timer) => clearTimeout(timer as ReturnType), 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 2e795e5afb..8f4ff84faf 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 index 38ff8e08b4..ed81d9217a 100644 --- a/packages/runtime/src/__tests__/automation-integration.test.ts +++ b/packages/runtime/src/__tests__/automation-integration.test.ts @@ -36,11 +36,13 @@ function createIntegrationSetup() { const scheduler = new AutomationScheduler({ automationManager: manager, canFire: async () => canFireResult, - injectTurn: (sessionId, prompt, automationId) => { + injectTurn: async (sessionId, prompt, automationId) => { injectedTurns.push({ sessionId, prompt, automationId }); + return { runId: `run-${automationId}`, ok: true }; }, - createFreshRun: (prompt, automationId) => { + createFreshRun: async (prompt, automationId) => { freshRuns.push({ prompt, automationId }); + return { runId: `fresh-${automationId}`, ok: true }; }, setTimeout: (fn, ms) => { const id = ++timerId; @@ -58,12 +60,14 @@ function createIntegrationSetup() { 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)); } @@ -271,10 +275,10 @@ describe('Automation integration: consecutive failure auto-pause', () => { const auto = t.manager.listForSession(SESSION_ID)[0]; - // Simulate 5 failures via markFired + markFailure + // Simulate 5 failed fires (started then failed). for (let i = 0; i < 5; i++) { - t.manager.markFired(auto.id); - t.manager.markFailure(auto.id, `error ${i + 1}`); + t.manager.attemptStarted(auto.id); + t.manager.attemptFailed(auto.id, `error ${i + 1}`); } assert.equal(t.manager.get(auto.id)?.status, 'paused'); @@ -307,4 +311,55 @@ describe('Automation integration: cron kind fires via createFreshRun', () => { 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 index 7cc6cb0da8..963310f581 100644 --- a/packages/runtime/src/__tests__/automation-mutation-verify.test.ts +++ b/packages/runtime/src/__tests__/automation-mutation-verify.test.ts @@ -34,7 +34,7 @@ describe('Mutation verification: tests catch broken behavior', () => { const brokenScheduler = new AutomationScheduler({ automationManager: manager, canFire: async () => true, - injectTurn: () => { /* INTENTIONALLY BROKEN: no-op */ }, + injectTurn: async () => { /* INTENTIONALLY BROKEN: no-op */ return { ok: true }; }, setTimeout: (fn) => { timers.push({ fn }); return timers.length; }, clearTimeout: () => {}, now: () => time, @@ -61,12 +61,11 @@ describe('Mutation verification: tests catch broken behavior', () => { }); assert.ok(!('error' in auto)); - // Fire 3 times (exceeds maxFires=2) - manager.markFired(auto.id); - manager.markFired(auto.id); - const third = manager.markFired(auto.id); - - // After maxFires reached, markFired returns undefined (won't fire) + // 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'); }); @@ -84,9 +83,9 @@ describe('Mutation verification: tests catch broken behavior', () => { // Advance past expiry time += 6000; - const fired = manager.markFired(auto.id); + const fired = manager.attemptStarted(auto.id); - // markFired checks expiry FIRST — returns undefined for expired + // 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'); }); @@ -105,7 +104,7 @@ describe('Mutation verification: tests catch broken behavior', () => { 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.markFired(auto.id); + const fired = manager.attemptStarted(auto.id); assert.equal(fired, undefined, 'Paused automation must not fire — test catches this'); }); @@ -119,7 +118,7 @@ describe('Mutation verification: tests catch broken behavior', () => { }); assert.ok(!('error' in auto)); - for (let i = 0; i < 5; i++) manager.markFailure(auto.id, 'err'); + 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'); }); diff --git a/packages/runtime/src/__tests__/automation-scheduler.test.ts b/packages/runtime/src/__tests__/automation-scheduler.test.ts index 547ae11600..925edb264c 100644 --- a/packages/runtime/src/__tests__/automation-scheduler.test.ts +++ b/packages/runtime/src/__tests__/automation-scheduler.test.ts @@ -1,7 +1,7 @@ -import { describe, test, beforeEach } from 'node:test'; +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 { AutomationScheduler, type AutomationFireResult } from '../automation-scheduler.js'; function createTestSetup() { let idCounter = 0; @@ -9,11 +9,11 @@ function createTestSetup() { const timers: Array<{ fn: () => void; ms: number; id: number }> = []; let timerId = 0; const fired: Array<{ sessionId: string; prompt: string; automationId: string }> = []; - const freshRuns: Array<{ prompt: string; automationId: string }> = []; let canFireResult = true; let canFireThrows = false; - let injectTurnThrows = false; - let createFreshRunFn: ((prompt: string, automationId: string) => void) | undefined = undefined; + 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}`, @@ -26,9 +26,10 @@ function createTestSetup() { if (canFireThrows) throw new Error('canFire error'); return canFireResult; }, - injectTurn: (sessionId, prompt, automationId) => { - if (injectTurnThrows) throw new Error('injectTurn error'); + 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) => { @@ -48,18 +49,22 @@ function createTestSetup() { 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, freshRuns, timers, + manager, scheduler, fired, timers, advanceTime, fireNextTimer, runTick, setCanFire: (v: boolean) => { canFireResult = v; }, setCanFireThrows: (v: boolean) => { canFireThrows = v; }, - setInjectTurnThrows: (v: boolean) => { injectTurnThrows = v; }, - setCreateFreshRun: (fn: ((prompt: string, automationId: string) => void) | undefined) => { + setInjectRejects: (v: boolean) => { injectRejects = v; }, + setInjectResult: (r: AutomationFireResult) => { injectResult = r; }, + setCreateFreshRun: (fn: ((prompt: string, automationId: string) => Promise) | undefined) => { createFreshRunFn = fn; }, getTime: () => time, @@ -67,7 +72,7 @@ function createTestSetup() { } describe('AutomationScheduler', () => { - test('fires automation when time arrives and session is idle', async () => { + 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', @@ -101,19 +106,11 @@ describe('AutomationScheduler', () => { }); assert.ok(!('error' in auto)); const originalNextFire = auto.nextFireAt; - t.advanceTime(61000); t.setCanFire(false); t.scheduler.start(); - - // Run 24 ticks (MAX_DEFER_RETRIES) - for (let i = 0; i < 24; i++) { - await t.runTick(); - } - - // Should have skipped — nextFireAt advanced + for (let i = 0; i < 24; i++) await t.runTick(); const updated = t.manager.get(auto.id); - assert.ok(updated); assert.ok(updated!.nextFireAt! > originalNextFire!); assert.equal(t.fired.length, 0); }); @@ -128,13 +125,11 @@ describe('AutomationScheduler', () => { t.setCanFireThrows(true); t.scheduler.start(); await t.runTick(); - // Should not crash, just skip assert.equal(t.fired.length, 0); - // Scheduler still ticking (timer re-registered) assert.ok(t.timers.length > 0); }); - test('injectTurn throwing marks automation as failed', async () => { + 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', @@ -142,15 +137,44 @@ describe('AutomationScheduler', () => { }); assert.ok(!('error' in auto)); t.advanceTime(31000); - t.setInjectTurnThrows(true); + 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(); @@ -164,18 +188,17 @@ describe('AutomationScheduler', () => { const auto = t.manager.create({ kind: 'heartbeat', name: 'expiring', prompt: 'p', sessionId: 'sess-1', schedule: { type: 'interval', seconds: 30 }, - expiresAt: t.getTime() + 20000, // expires in 20s + expiresAt: t.getTime() + 20000, }); assert.ok(!('error' in auto)); - t.advanceTime(31000); // past expiry + 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', async () => { + test('one-shot fires once then completes (on success)', async () => { const t = createTestSetup(); const auto = t.manager.create({ kind: 'heartbeat', name: 'once', prompt: 'p', @@ -185,21 +208,20 @@ describe('AutomationScheduler', () => { 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 automation fires via createFreshRun when provided', async () => { + test('cron fires via createFreshRun, not injectTurn', async () => { const t = createTestSetup(); const freshRuns: Array<{ prompt: string; id: string }> = []; - t.setCreateFreshRun((prompt, automationId) => { + 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', @@ -209,48 +231,48 @@ describe('AutomationScheduler', () => { 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); // should NOT call injectTurn + assert.equal(t.fired.length, 0); + assert.equal(t.manager.get(auto.id)?.lastRunId, 'fresh-1'); }); - test('cron automation marks failure when createFreshRun is not provided', async () => { + test('cron marks failure when createFreshRun is not provided (does not advance)', async () => { const t = createTestSetup(); - // createFreshRun is undefined by default 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; t.advanceTime(31000); t.scheduler.start(); await t.runTick(); - assert.equal(t.fired.length, 0); const updated = t.manager.get(auto.id); assert.equal(updated?.consecutiveFailures, 1); assert.ok(updated?.lastError?.includes('not configured')); + // The fire did not "start" (no fresh executor) — fireCount unchanged. + assert.equal(updated?.fireCount, originalFireCount); }); 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 }, // next fire in 1 hour - expiresAt: t.getTime() + 30000, // expires in 30s + sessionId: 'sess-1', schedule: { type: 'interval', seconds: 3600 }, + expiresAt: t.getTime() + 30000, }); assert.ok(!('error' in auto)); - t.advanceTime(31000); // past expiry but before nextFireAt (1 hour) + 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('markFailure does not overwrite terminal status', async () => { + 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', @@ -259,13 +281,10 @@ describe('AutomationScheduler', () => { }); assert.ok(!('error' in auto)); t.advanceTime(31000); - t.setInjectTurnThrows(true); + t.setInjectRejects(true); t.scheduler.start(); await t.runTick(); - - // markFired sets completed (maxFires=1), then injectTurn throws, - // markFailure should NOT overwrite completed with paused. const updated = t.manager.get(auto.id); - assert.equal(updated?.status, 'completed'); + assert.notEqual(updated?.status, 'completed'); }); }); diff --git a/packages/runtime/src/__tests__/automation.test.ts b/packages/runtime/src/__tests__/automation.test.ts index 675015bd1c..d4c8fd5a02 100644 --- a/packages/runtime/src/__tests__/automation.test.ts +++ b/packages/runtime/src/__tests__/automation.test.ts @@ -183,25 +183,29 @@ describe('AutomationManager', () => { sessionId: 'sess-1', schedule: { type: 'interval', seconds: 60 }, }); assert.ok(!('error' in auto)); - const fired = mgr.markFired(auto.id); + const fired = mgr.attemptStarted(auto.id); assert.equal(fired?.fireCount, 1); assert.ok(fired?.nextFireAt); assert.ok(fired?.lastFireAt); }); - test('one-shot completes after fire', () => { + 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)); - const fired = mgr.markFired(auto.id); - assert.equal(fired?.status, 'completed'); - assert.equal(fired?.nextFireAt, null); + // 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 automation', () => { + test('maxFires completes on the successful fire that reaches the cap', () => { const mgr = createManager(); const auto = mgr.create({ kind: 'heartbeat', name: 'limited', prompt: 'p', @@ -209,9 +213,26 @@ describe('AutomationManager', () => { maxFires: 2, }); assert.ok(!('error' in auto)); - mgr.markFired(auto.id); - const second = mgr.markFired(auto.id); - assert.equal(second?.status, 'completed'); + 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', () => { @@ -222,11 +243,11 @@ describe('AutomationManager', () => { }); assert.ok(!('error' in auto)); mgr.pause(auto.id, 'sess-1'); - assert.equal(mgr.markFired(auto.id), undefined); + assert.equal(mgr.attemptStarted(auto.id), undefined); }); }); - describe('markFailure', () => { + describe('attemptFailed', () => { test('increments consecutiveFailures', () => { const mgr = createManager(); const auto = mgr.create({ @@ -234,7 +255,7 @@ describe('AutomationManager', () => { sessionId: 'sess-1', schedule: { type: 'interval', seconds: 60 }, }); assert.ok(!('error' in auto)); - mgr.markFailure(auto.id, 'timeout'); + mgr.attemptFailed(auto.id, 'timeout'); assert.equal(mgr.get(auto.id)?.consecutiveFailures, 1); assert.equal(mgr.get(auto.id)?.lastError, 'timeout'); }); @@ -246,20 +267,32 @@ describe('AutomationManager', () => { sessionId: 'sess-1', schedule: { type: 'interval', seconds: 60 }, }); assert.ok(!('error' in auto)); - for (let i = 0; i < 5; i++) mgr.markFailure(auto.id, 'fail'); + 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('markSuccess resets failure count', () => { + 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.markFailure(auto.id, 'fail'); - mgr.markFailure(auto.id, 'fail'); - mgr.markSuccess(auto.id); + 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); }); @@ -402,7 +435,8 @@ describe('AutomationManager edge cases', () => { sessionId: 'sess-1', schedule: { type: 'once', delaySeconds: 10 }, }); assert.ok(!('error' in auto)); - mgr.markFired(auto.id); + mgr.attemptStarted(auto.id); + mgr.attemptSucceeded(auto.id); } // Pruning is triggered on next create mgr.create({ @@ -434,15 +468,16 @@ describe('AutomationManager edge cases', () => { assert.equal(updated.fireCount, 0); }); - test('markFailure does not overwrite completed status', () => { + 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.markFired(auto.id); // completes (one-shot) - mgr.markFailure(auto.id, 'should not change status'); + 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'); }); @@ -451,7 +486,8 @@ describe('AutomationManager edge cases', () => { 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.markFired(once.id); + mgr.attemptStarted(once.id); + mgr.attemptSucceeded(once.id); const all = mgr.listAll(); assert.ok(all.length >= 2); diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index 3cc1471719..c5d0434749 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -554,6 +554,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-scheduler.ts b/packages/runtime/src/automation-scheduler.ts index 8e375d9f58..ac18ff9dcf 100644 --- a/packages/runtime/src/automation-scheduler.ts +++ b/packages/runtime/src/automation-scheduler.ts @@ -12,11 +12,30 @@ 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; canFire: (sessionId: string) => Promise; - injectTurn: (sessionId: string, prompt: string, automationId: string) => void; - createFreshRun?: (prompt: string, automationId: string) => void; + /** + * 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; @@ -76,11 +95,13 @@ export class AutomationScheduler { // Eager expiry sweep: expire automations whose expiresAt has passed, // regardless of nextFireAt. Prevents zombie-active entries. + let sweptAny = false; for (const automation of active) { if (automation.expiresAt && now >= automation.expiresAt) { - this.deps.automationManager.markFired(automation.id); + 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(); @@ -118,34 +139,46 @@ export class AutomationScheduler { } this.deferCounts.delete(automation.id); - const fired = this.deps.automationManager.markFired(automation.id); - if (!fired) { + + // Cron without an executor cannot run — fail fast, do not advance the fire. + if (automation.kind === 'cron' && !this.deps.createFreshRun) { + this.deps.automationManager.attemptFailed(automation.id, 'Cron execution not configured (createFreshRun unavailable)'); this.deps.onStateChange?.(); return; } - try { - if (automation.kind === 'heartbeat') { - this.deps.injectTurn( - automation.sessionId, - `[Automation: ${automation.name}]\n\n${automation.prompt}`, - automation.id, - ); - } else if (automation.kind === 'cron') { - if (!this.deps.createFreshRun) { - this.deps.automationManager.markFailure(automation.id, 'Cron execution not configured (createFreshRun unavailable)'); - return; - } - this.deps.createFreshRun(automation.prompt, 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; + // 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) => { + 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.automationManager.markSuccess(automation.id); this.deps.onStateChange?.(); - } catch (err) { + }).catch((err) => { + if (this.disposed) return; const message = err instanceof Error ? err.message : String(err); - this.deps.automationManager.markFailure(automation.id, message); + 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 index 5b6910b475..8b8653d558 100644 --- a/packages/runtime/src/automation-state.ts +++ b/packages/runtime/src/automation-state.ts @@ -151,15 +151,32 @@ export class AutomationManager { } /** - * Called by the scheduler when it's time to fire. - * Checks expiry BEFORE firing. Returns the automation if it should fire. + * Mark an expired automation terminal. Returns true if it was expired. + * Used by the scheduler's eager expiry sweep. */ - markFired(id: string): AutomationDefinition | undefined { + 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'; @@ -172,17 +189,12 @@ export class AutomationManager { automation.fireCount++; automation.updatedAt = now; - if (automation.schedule.type === 'once') { - automation.status = 'completed'; - automation.nextFireAt = null; - } else { - automation.nextFireAt = this.computeNextFire(automation.schedule, now); - } - - if (automation.maxFires && automation.fireCount >= automation.maxFires) { - automation.status = 'completed'; - automation.nextFireAt = null; - } + // 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); return automation; } @@ -199,25 +211,47 @@ export class AutomationManager { automation.updatedAt = now; } - markSuccess(id: string, runId?: string): void { + /** + * 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; + } } - markFailure(id: string, error: string): void { + /** + * 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 === 'completed' || automation.status === 'expired') 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'; } } diff --git a/packages/runtime/src/automation-tools.ts b/packages/runtime/src/automation-tools.ts index d9df10e85a..4e52bb7fd4 100644 --- a/packages/runtime/src/automation-tools.ts +++ b/packages/runtime/src/automation-tools.ts @@ -16,38 +16,45 @@ 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 createSchema = z.object({ - mode: z.literal('create'), - kind: z.enum(['heartbeat', 'cron']) - .describe('heartbeat = resume into current session (polling/monitoring). cron = create fresh session each run (standalone scheduled tasks).'), - name: z.string().trim().min(1).max(100) - .describe('Short human-readable name for this automation.'), - prompt: z.string().trim().min(1).max(2000) - .describe('The prompt to execute on each fire.'), - schedule: 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.'), - }), - ]).describe('When to fire. Use "interval" for simple repeats, "cron" for complex schedules, "once" for one-shot delays.'), - max_fires: z.number().int().min(1).max(10000).optional() - .describe('Maximum number of fires before auto-completing. Omit for unlimited (7-day expiry still applies).'), - durable: z.boolean().optional() - .describe('When true, this automation persists across app restarts. Default: false (session-scoped only).'), -}); +function buildCreateSchema(cronEnabled: boolean) { + const kind = cronEnabled + ? z.enum(['heartbeat', 'cron']).describe('heartbeat = resume into current session (polling/monitoring). cron = create fresh session each run (standalone scheduled tasks).') + : z.literal('heartbeat').describe('heartbeat = resume into current session (polling/monitoring). This host supports heartbeat automations only.'); + return z.object({ + mode: z.literal('create'), + kind, + name: z.string().trim().min(1).max(100) + .describe('Short human-readable name for this automation.'), + prompt: z.string().trim().min(1).max(2000) + .describe('The prompt to execute on each fire.'), + schedule: 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.'), + }), + ]).describe('When to fire. Use "interval" for simple repeats, "cron" for complex schedules, "once" for one-shot delays.'), + max_fires: z.number().int().min(1).max(10000).optional() + .describe('Maximum number of fires before auto-completing. Omit for unlimited (7-day expiry still applies).'), + durable: z.boolean().optional() + .describe('When true, this automation persists across app restarts. Default: false (session-scoped only).'), + }); +} const deleteSchema = z.object({ mode: z.literal('delete'), @@ -71,26 +78,34 @@ const resumeSchema = z.object({ .describe('Automation ID to resume.'), }); -const automationSchema = z.discriminatedUnion('mode', [ - createSchema, - deleteSchema, - listSchema, - pauseSchema, - resumeSchema, -]); +function buildAutomationSchema(cronEnabled: boolean) { + return z.discriminatedUnion('mode', [ + buildCreateSchema(cronEnabled), + deleteSchema, + listSchema, + pauseSchema, + resumeSchema, + ]); +} -type AutomationInput = z.infer; +// Broad input type (covers both cron-enabled and heartbeat-only schemas). +type AutomationInput = z.infer>; +type CreateInput = z.infer>; +type DeleteInput = z.infer; +type PauseInput = z.infer; +type ResumeInput = 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). ' - + 'Use kind "cron" for standalone scheduled tasks (creates a fresh session each run). ' + + (cronEnabled ? 'Use kind "cron" for standalone scheduled tasks (creates a fresh session each run). ' : '') + 'Automations auto-expire after 7 days unless deleted earlier.', - parameters: automationSchema, + parameters: buildAutomationSchema(cronEnabled), permissionRequired: false, impl: (input, ctx) => { let result: string; @@ -118,7 +133,7 @@ export function buildAutomationTool(deps: AutomationToolDeps): MakaTool, + input: CreateInput, sessionId: string, ): string { const schedule = input.schedule.type === 'once' @@ -153,7 +168,7 @@ function handleCreate( function handleDelete( deps: AutomationToolDeps, - input: z.infer, + input: DeleteInput, sessionId: string, ): string { const deleted = deps.automationManager.delete(input.id, sessionId); @@ -170,7 +185,7 @@ function handleList(deps: AutomationToolDeps, sessionId: string): string { function handlePause( deps: AutomationToolDeps, - input: z.infer, + input: PauseInput, sessionId: string, ): string { const result = deps.automationManager.pause(input.id, sessionId); @@ -180,7 +195,7 @@ function handlePause( function handleResume( deps: AutomationToolDeps, - input: z.infer, + input: ResumeInput, sessionId: string, ): string { const result = deps.automationManager.resume(input.id, sessionId); diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 9f2b36182d..9f67e87ec6 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -606,7 +606,7 @@ export type { AutomationManagerDeps, } from './automation-state.js'; export { AutomationScheduler, FIRE_CHECK_INTERVAL_MS, MAX_DEFER_RETRIES } from './automation-scheduler.js'; -export type { AutomationSchedulerDeps } 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'; From bf90fc043ec30feafd6788b8c19e337cca684288 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Tue, 7 Jul 2026 18:02:04 +0800 Subject: [PATCH 07/23] fix(runtime): Automation tool schema must be a top-level object (Anthropic API) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caught by a headless end-to-end run against a real Anthropic model: the unified Automation tool used a discriminatedUnion, which serializes to JSON Schema as { anyOf: [...] } with NO top-level "type". Anthropic rejects tool definitions whose input_schema.type is not "object" ("tools.0.custom.input_schema.type: Field required", HTTP 500) — and since all tools are sent every turn, this broke EVERY turn in any session that had the Automation tool registered (the model's reply came back empty and the session wedged in 'blocked'). Fix: flatten to a single top-level z.object with `mode` (enum) and per-mode fields optional, validated in impl(). The `kind` enum still gates cron off on hosts without a fresh-run executor. Nested `schedule` union stays (a union under a property is fine; only the top level must be an object). Verified: headless cron run now gets a real LLM reply (assistant="CRON_OK") and the fresh session settles 'active' instead of 'blocked'. --- packages/runtime/src/automation-tools.ts | 185 ++++++++++------------- 1 file changed, 80 insertions(+), 105 deletions(-) diff --git a/packages/runtime/src/automation-tools.ts b/packages/runtime/src/automation-tools.ts index 4e52bb7fd4..a6b9bd07c6 100644 --- a/packages/runtime/src/automation-tools.ts +++ b/packages/runtime/src/automation-tools.ts @@ -21,79 +21,59 @@ export interface AutomationToolDeps { cronEnabled?: boolean; } -function buildCreateSchema(cronEnabled: boolean) { - const kind = cronEnabled - ? z.enum(['heartbeat', 'cron']).describe('heartbeat = resume into current session (polling/monitoring). cron = create fresh session each run (standalone scheduled tasks).') - : z.literal('heartbeat').describe('heartbeat = resume into current session (polling/monitoring). This host supports heartbeat automations only.'); +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.literal('create'), - kind, - name: z.string().trim().min(1).max(100) - .describe('Short human-readable name for this automation.'), - prompt: z.string().trim().min(1).max(2000) - .describe('The prompt to execute on each fire.'), - schedule: 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.'), - }), - ]).describe('When to fire. Use "interval" for simple repeats, "cron" for complex schedules, "once" for one-shot delays.'), + 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('Maximum number of fires before auto-completing. Omit for unlimited (7-day expiry still applies).'), + .describe('[create] Maximum fires before auto-completing. Omit for unlimited (7-day expiry still applies).'), durable: z.boolean().optional() - .describe('When true, this automation persists across app restarts. Default: false (session-scoped only).'), + .describe('[create] When true, persists across app restarts. Default: false.'), + id: z.string().min(1).max(64).optional() + .describe('[delete/pause/resume] Automation id.'), }); } -const deleteSchema = z.object({ - mode: z.literal('delete'), - id: z.string().min(1).max(64) - .describe('Automation ID to delete.'), -}); - -const listSchema = z.object({ - mode: z.literal('list'), -}); - -const pauseSchema = z.object({ - mode: z.literal('pause'), - id: z.string().min(1).max(64) - .describe('Automation ID to pause.'), -}); - -const resumeSchema = z.object({ - mode: z.literal('resume'), - id: z.string().min(1).max(64) - .describe('Automation ID to resume.'), -}); - -function buildAutomationSchema(cronEnabled: boolean) { - return z.discriminatedUnion('mode', [ - buildCreateSchema(cronEnabled), - deleteSchema, - listSchema, - pauseSchema, - resumeSchema, - ]); -} +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.'), +); -// Broad input type (covers both cron-enabled and heartbeat-only schemas). -type AutomationInput = z.infer>; -type CreateInput = z.infer>; -type DeleteInput = z.infer; -type PauseInput = z.infer; -type ResumeInput = z.infer; +// 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; @@ -105,25 +85,37 @@ export function buildAutomationTool(deps: AutomationToolDeps): MakaTool { let result: string; switch (input.mode) { case 'create': - result = handleCreate(deps, input, ctx.sessionId); + result = handleCreate(deps, input, ctx.sessionId, cronEnabled); break; case 'delete': - result = handleDelete(deps, input, ctx.sessionId); + 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 = handlePause(deps, input, 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 = handleResume(deps, input, ctx.sessionId); + } + case 'resume': { + result = handleById(input, (id) => { + const r = deps.automationManager.resume(id, ctx.sessionId); + return r ? `Automation "${r.name}" resumed. Next fire: ${r.nextFireAt ? new Date(r.nextFireAt).toLocaleString() : 'N/A'}` + : `Cannot resume "${id}": not found, not owned, or not paused.`; + }); break; + } } deps.onAutomationChange?.(); return result; @@ -131,17 +123,30 @@ export function buildAutomationTool(deps: AutomationToolDeps): MakaTool string): string { + if (!input.id) return 'Error: "id" is required for delete/pause/resume.'; + return run(input.id); +} + function handleCreate( deps: AutomationToolDeps, - input: CreateInput, + 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, + kind: input.kind as 'heartbeat' | 'cron', name: input.name, prompt: input.prompt, sessionId, @@ -166,16 +171,6 @@ function handleCreate( ].join('\n'); } -function handleDelete( - deps: AutomationToolDeps, - input: DeleteInput, - sessionId: string, -): string { - const deleted = deps.automationManager.delete(input.id, sessionId); - if (!deleted) return `Automation "${input.id}" not found or not owned by this session.`; - return `Automation "${input.id}" deleted.`; -} - function handleList(deps: AutomationToolDeps, sessionId: string): string { const automations = deps.automationManager.listForSession(sessionId); if (automations.length === 0) return 'No automations for this session.'; @@ -183,26 +178,6 @@ function handleList(deps: AutomationToolDeps, sessionId: string): string { return automations.map(a => formatAutomation(a)).join('\n---\n'); } -function handlePause( - deps: AutomationToolDeps, - input: PauseInput, - sessionId: string, -): string { - const result = deps.automationManager.pause(input.id, sessionId); - if (!result) return `Cannot pause "${input.id}": not found, not owned, or not active.`; - return `Automation "${result.name}" paused. Use mode "resume" to reactivate.`; -} - -function handleResume( - deps: AutomationToolDeps, - input: ResumeInput, - sessionId: string, -): string { - const result = deps.automationManager.resume(input.id, sessionId); - if (!result) return `Cannot resume "${input.id}": not found, not owned, or not paused.`; - return `Automation "${result.name}" resumed. Next fire: ${result.nextFireAt ? new Date(result.nextFireAt).toLocaleString() : 'N/A'}`; -} - function formatAutomation(a: AutomationDefinition): string { const lines = [ `[${a.status.toUpperCase()}] ${a.name} (${a.kind})`, From d72c73908883ef0e216e85156f54387c2e6fd774 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Tue, 7 Jul 2026 18:19:31 +0800 Subject: [PATCH 08/23] feat(cli): parameterize cron support via automationCreateFreshRun (reviewer G1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createMakaCliRuntimeContext now accepts an optional automationCreateFreshRun. When a host provides it, the Automation tool advertises the cron kind and cron fires spawn a fresh session + run through that executor; omitted (the default CLI, no multi-session surface) means heartbeat only. This matches the reviewer's G1 guidance — a host derives cron support from the executor it passes in. Verified end-to-end through the REAL Maka agent chain (runtime.sendMessage → AgentRun → AiSdkBackend + real Maka system prompt) with natural user phrasing against a real LLM: "每20秒检查系统状态" → heartbeat/interval/20; "工作日9点日报" → cron 0 9 * * 1-5; "5分钟后提醒" → once/300; "长期保留重启别丢" → durable; pause/resume/delete by natural reference (model lists then acts); GoalSet with max_iterations from "最多5轮" and pause. 9/9. --- packages/cli/src/runtime-bootstrap.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/runtime-bootstrap.ts b/packages/cli/src/runtime-bootstrap.ts index da9f8310d7..1913be4890 100644 --- a/packages/cli/src/runtime-bootstrap.ts +++ b/packages/cli/src/runtime-bootstrap.ts @@ -55,6 +55,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 { @@ -100,7 +107,11 @@ export async function createMakaCliRuntimeContext( const durable = automationManager.listAll().filter(a => a.durable && (a.status === 'active' || a.status === 'paused')); automationStore.sync(durable).catch(() => {}); }; - const automationTool = buildAutomationTool({ automationManager, onAutomationChange: syncAutomations }); + const automationTool = buildAutomationTool({ + automationManager, + onAutomationChange: syncAutomations, + cronEnabled: input.automationCreateFreshRun !== undefined, + }); const goalManager = new GoalManager({ generateId: () => randomUUID(), now: () => Date.now() }); const goalTokenCache = new Map(); @@ -209,6 +220,7 @@ export async function createMakaCliRuntimeContext( 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, From 2a9a263eb2adf3864c675aee2d2b531969e6170b Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Tue, 7 Jul 2026 23:04:18 +0800 Subject: [PATCH 09/23] =?UTF-8?q?fix(runtime):=20cron=20parser=20correctne?= =?UTF-8?q?ss=20=E2=80=94=20sparse=20annual,=20dom+dow=20OR,=20timezone=20?= =?UTF-8?q?doc=20(PR=20#558=20G7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sparse annual crons: search window 366d → ~8y (MAX_SEARCH_MINUTES). The max gap between Feb 29ths is 8 years, not 4 — a century year not divisible by 400 (2100) is skipped, so 2096→2104. 8y guarantees every satisfiable expression resolves while staying bounded. "0 0 29 2 *" now resolves; "0 0 30 2 *" still returns null after a bounded search (no infinite loop). - day-of-month + day-of-week semantics: when BOTH fields are restricted, a day matches if it satisfies EITHER (Vixie OR), not both. "0 0 13 * 5" now means "the 13th OR any Friday", not "Friday the 13th". When one field is *, AND applies (the * is a no-op). dom-only and dow-only unchanged. - timezone: documented contract (host local time via Date local getters, incl. DST behavior); per-automation IANA zones out of scope for this pass (would ripple through the schedule type and every caller). 8 new tests (Feb 29 resolves, Feb 30 null, dom+dow OR both directions, dom-only, dow-only, regressions for */5, 0 9 * * 1-5, 10-30/5). runtime suite green (pre-existing flaky shell tests aside). --- .../runtime/src/__tests__/automation.test.ts | 123 ++++++++++++++++++ packages/runtime/src/automation-state.ts | 73 ++++++++--- 2 files changed, 180 insertions(+), 16 deletions(-) diff --git a/packages/runtime/src/__tests__/automation.test.ts b/packages/runtime/src/__tests__/automation.test.ts index d4c8fd5a02..8d0c040f81 100644 --- a/packages/runtime/src/__tests__/automation.test.ts +++ b/packages/runtime/src/__tests__/automation.test.ts @@ -413,6 +413,129 @@ describe('computeNextCronFire', () => { 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!; + } + }); + + // --- 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', () => { diff --git a/packages/runtime/src/automation-state.ts b/packages/runtime/src/automation-state.ts index 8b8653d558..bc022133ff 100644 --- a/packages/runtime/src/automation-state.ts +++ b/packages/runtime/src/automation-state.ts @@ -306,34 +306,75 @@ export class AutomationManager { } } +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 + +/** + * 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 { const fields = expression.trim().split(/\s+/); if (fields.length !== 5) return null; const [minuteField, hourField, domField, monthField, dowField] = fields; + + // 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; + // Zero out seconds/ms for clean minute boundaries. const fromDate = new Date(fromTime); fromDate.setSeconds(0, 0); const baseTime = fromDate.getTime() + 60000; // start from next minute - for (let attempt = 0; attempt < 527040; attempt++) { + for (let attempt = 0; attempt < MAX_SEARCH_MINUTES; attempt++) { const candidateTime = baseTime + attempt * 60000; const candidate = new Date(candidateTime); - const minute = candidate.getMinutes(); - const hour = candidate.getHours(); - const dom = candidate.getDate(); - const month = candidate.getMonth() + 1; - const dow = candidate.getDay(); - - if ( - matchesCronField(minuteField, minute, 0, 59) && - matchesCronField(hourField, hour, 0, 23) && - matchesCronField(domField, dom, 1, 31) && - matchesCronField(monthField, month, 1, 12) && - matchesCronField(dowField, dow, 0, 6) - ) { - return 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); + const dowMatch = matchesCronField(dowField, candidate.getDay(), 0, 6); + const dayMatch = bothDayFieldsRestricted + ? domMatch || dowMatch // OR when both are constrained + : domMatch && dowMatch; // AND when one is "*" + + if (dayMatch) return candidateTime; } return null; } From 7686b9693ca80a6ec19cc242098648c7cad8d4cd Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Tue, 7 Jul 2026 23:32:24 +0800 Subject: [PATCH 10/23] fix(runtime): concurrency + maxFires + attribution + session/cron correctness (self-review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of the done cronjob work surfaced 5 real bugs; all fixed: - P1 concurrent cron re-fire: the scheduler had no in-flight guard, and canFire gates the automation's CREATOR session — which stays idle for cron (the run happens in a spawned session), so a cron whose run outlasts its cadence re-fired every tick (duplicate sessions, maxFires blown, out-of-order counter corruption). Added a per-automation in-flight Set: skip while a fire's run is executing; add before dispatch, clear in both .then and .catch (and on dispose). - P2 maxFires not enforced on failure: fireCount++ is unconditional in attemptStarted but the cap only lived in attemptSucceeded, so a failing recurring automation fired up to the consecutive-failure cap (5), and fireCount could exceed maxFires ("Fires: 5/2"). maxFires is now a hard cap on ATTEMPTS — attemptStarted nulls nextFireAt once fireCount reaches maxFires. - P2 desktop fires never recorded lastRunId: streamEvents returns {turnId,...} but the scheduler reads result.runId. Desktop injectTurn/createFreshRun now map turnId → runId so attemptSucceeded sets lastRunId. - P2 cron sessions accumulated unbounded: each cron fire spawned a fresh session forever. createFreshRun now archives the fresh session after its run finalizes (run/trace preserved, active list not flooded). - P2 dow=7 never matched: cron allows 0 or 7 for Sunday but Date.getDay() is 0-6. Added the Sunday 7-alias so "7", "5-7", "0,7" fire on Sundays. New tests: in-flight guard (slow cron doesn't re-fire concurrently), maxFires bounds attempts even when every run fails, dow=7 Sunday matching. Runtime + desktop suites green (pre-existing flaky shell tests aside). --- apps/desktop/src/main/main.ts | 13 +++-- .../__tests__/automation-scheduler.test.ts | 50 +++++++++++++++++++ .../runtime/src/__tests__/automation.test.ts | 20 ++++++++ packages/runtime/src/automation-scheduler.ts | 17 +++++++ packages/runtime/src/automation-state.ts | 17 ++++++- 5 files changed, 113 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index c4bf1d4f53..77b94f661e 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -352,12 +352,13 @@ const automationWiring = createMainAutomationWiring({ return true; }, // Heartbeat: inject into the automation's own session; resolve after the stream. - injectTurn(sessionId: string, prompt: string, automationId: string) { + async injectTurn(sessionId: string, prompt: string, automationId: string) { const turnId = randomUUID(); const iterator = runtime.sendMessage(sessionId, { turnId, text: prompt, origin: { kind: 'automation', automationId }, }); - return streamEvents(sessionId, iterator, turnId); + 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. @@ -379,7 +380,13 @@ const automationWiring = createMainAutomationWiring({ const iterator = runtime.sendMessage(session.id, { turnId, text: prompt, origin: { kind: 'automation', automationId }, }); - return streamEvents(session.id, iterator, turnId); + 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 } : {}) }; }, }); diff --git a/packages/runtime/src/__tests__/automation-scheduler.test.ts b/packages/runtime/src/__tests__/automation-scheduler.test.ts index 925edb264c..a38750dcca 100644 --- a/packages/runtime/src/__tests__/automation-scheduler.test.ts +++ b/packages/runtime/src/__tests__/automation-scheduler.test.ts @@ -287,4 +287,54 @@ describe('AutomationScheduler', () => { 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 index 8d0c040f81..5f2dd66af9 100644 --- a/packages/runtime/src/__tests__/automation.test.ts +++ b/packages/runtime/src/__tests__/automation.test.ts @@ -498,6 +498,26 @@ describe('computeNextCronFire', () => { } }); + 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', () => { diff --git a/packages/runtime/src/automation-scheduler.ts b/packages/runtime/src/automation-scheduler.ts index ac18ff9dcf..0779e9094a 100644 --- a/packages/runtime/src/automation-scheduler.ts +++ b/packages/runtime/src/automation-scheduler.ts @@ -49,6 +49,8 @@ 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) { @@ -71,6 +73,7 @@ export class AutomationScheduler { this.disposed = true; this.stop(); this.deferCounts.clear(); + this.inFlight.clear(); } private scheduleTick(): void { @@ -115,6 +118,15 @@ export class AutomationScheduler { private async attemptFire(automation: AutomationDefinition): Promise { if (this.disposed) 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.sessionId); @@ -124,6 +136,8 @@ export class AutomationScheduler { } 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; @@ -156,6 +170,7 @@ export class AutomationScheduler { 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. @@ -164,6 +179,7 @@ export class AutomationScheduler { : 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); @@ -172,6 +188,7 @@ export class AutomationScheduler { } 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); diff --git a/packages/runtime/src/automation-state.ts b/packages/runtime/src/automation-state.ts index bc022133ff..62cf11f097 100644 --- a/packages/runtime/src/automation-state.ts +++ b/packages/runtime/src/automation-state.ts @@ -196,6 +196,16 @@ export class AutomationManager { ? 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; } @@ -369,7 +379,12 @@ export function computeNextCronFire(expression: string, fromTime: number): numbe if (!matchesCronField(monthField, candidate.getMonth() + 1, 1, 12)) continue; const domMatch = matchesCronField(domField, candidate.getDate(), 1, 31); - const dowMatch = matchesCronField(dowField, candidate.getDay(), 0, 6); + // 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 "*" From 64bed00ca2342d3f70b18da1bc01fdeffb95a6c9 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Wed, 8 Jul 2026 00:19:20 +0800 Subject: [PATCH 11/23] fix(runtime): resume() must not revive a spent fire budget (self-review round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A maxFires-exhausted (or one-shot that already fired) automation only reaches 'paused' via the attemptFailed path, which leaves nextFireAt=null. resume() previously re-armed it unconditionally, so the next tick bumped fireCount past maxFires (or re-fired the 'once') — spawning a real extra run beyond the declared hard cap. resume() now refuses when the fire budget is spent, and the Automation tool reports the exhausted budget instead of a misleading 'not paused'. Adds two regression tests. --- .../runtime/src/__tests__/automation.test.ts | 36 +++++++++++++++++++ packages/runtime/src/automation-state.ts | 9 +++++ packages/runtime/src/automation-tools.ts | 16 +++++++-- 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/__tests__/automation.test.ts b/packages/runtime/src/__tests__/automation.test.ts index 5f2dd66af9..d48cf3106a 100644 --- a/packages/runtime/src/__tests__/automation.test.ts +++ b/packages/runtime/src/__tests__/automation.test.ts @@ -173,6 +173,42 @@ describe('AutomationManager', () => { 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', () => { diff --git a/packages/runtime/src/automation-state.ts b/packages/runtime/src/automation-state.ts index 62cf11f097..c1f06b2f12 100644 --- a/packages/runtime/src/automation-state.ts +++ b/packages/runtime/src/automation-state.ts @@ -136,6 +136,15 @@ export class AutomationManager { const automation = this.automations.get(id); if (!automation || automation.sessionId !== 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(); automation.nextFireAt = this.computeNextFire(automation.schedule, this.deps.now()); diff --git a/packages/runtime/src/automation-tools.ts b/packages/runtime/src/automation-tools.ts index a6b9bd07c6..00c7ecbbb5 100644 --- a/packages/runtime/src/automation-tools.ts +++ b/packages/runtime/src/automation-tools.ts @@ -111,8 +111,20 @@ export function buildAutomationTool(deps: AutomationToolDeps): MakaTool { const r = deps.automationManager.resume(id, ctx.sessionId); - return r ? `Automation "${r.name}" resumed. Next fire: ${r.nextFireAt ? new Date(r.nextFireAt).toLocaleString() : 'N/A'}` - : `Cannot resume "${id}": not found, not owned, or not paused.`; + 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; } From 966ceb375f6519cfa0a65031cce87263fd6ec4f1 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Wed, 8 Jul 2026 00:56:04 +0800 Subject: [PATCH 12/23] fix(runtime): cron automations default to durable so they survive restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persistence infra (FileAutomationStore + loadDurableAutomations + sync-on- mutation + scheduler.start) was fully wired in both desktop and cli, but durability was opt-in via a `durable` flag defaulting to false for BOTH kinds. A cron is a standalone scheduled task (fresh session each run) — it is pointless if it dies on restart, yet it only persisted when the model happened to pass durable:true. create() now defaults durable by kind: cron=true, heartbeat=false (bound to its session), with an explicit flag always winning. Updates the tool schema description and adds create-default tests. --- .../runtime/src/__tests__/automation.test.ts | 38 +++++++++++++++++++ packages/runtime/src/automation-state.ts | 8 +++- packages/runtime/src/automation-tools.ts | 2 +- 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/__tests__/automation.test.ts b/packages/runtime/src/__tests__/automation.test.ts index d48cf3106a..1f6ecedbf1 100644 --- a/packages/runtime/src/__tests__/automation.test.ts +++ b/packages/runtime/src/__tests__/automation.test.ts @@ -113,6 +113,44 @@ describe('AutomationManager', () => { 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 overrides the per-kind default', () => { + 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); + 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.equal(beat.durable, true); + }); }); describe('delete', () => { diff --git a/packages/runtime/src/automation-state.ts b/packages/runtime/src/automation-state.ts index c1f06b2f12..ad7dc3a0b4 100644 --- a/packages/runtime/src/automation-state.ts +++ b/packages/runtime/src/automation-state.ts @@ -85,6 +85,12 @@ export class AutomationManager { 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 + // resumes into its creator session, whose lifetime bounds it, so it stays + // opt-in. An explicit `durable` value always wins. + const durable = input.durable ?? input.kind === 'cron'; + const automation: AutomationDefinition = { id, kind: input.kind, @@ -103,7 +109,7 @@ export class AutomationManager { expiresAt: input.expiresAt ?? defaultExpiry, lastError: null, consecutiveFailures: 0, - ...(input.durable ? { durable: true } : {}), + ...(durable ? { durable: true } : {}), }; this.automations.set(id, automation); diff --git a/packages/runtime/src/automation-tools.ts b/packages/runtime/src/automation-tools.ts index 00c7ecbbb5..72f39a23fc 100644 --- a/packages/runtime/src/automation-tools.ts +++ b/packages/runtime/src/automation-tools.ts @@ -57,7 +57,7 @@ function makeAutomationSchema(kindSchema: z.ZodType) { 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. Default: false.'), + .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.'), }); From 03f628c874e061f3e2a5bc7aa94208468578fc36 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Wed, 8 Jul 2026 01:17:15 +0800 Subject: [PATCH 13/23] chore(runtime,cli,desktop): split Goal (P6) out of the Automation PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Goal is independent of Automation (the heartbeat bridge was removed in the self-review pass), so it moves to its own stacked PR. Removes the 9 goal-only files plus every goal wiring point — index.ts exports, cli runtime-bootstrap (GoalManager/buildGoalTools/goalContinuationDeps), cli.ts + pi-tui-runner onTurnComplete hook, cli/desktop turn-tail goal fragments, and desktop main goalWiring (tools, session-lifecycle removal, turn-boundary continuation, quit dispose). Automation is untouched. Goal is re-added verbatim on the stacked branch via the inverse of this commit. Verified: runtime automation 87/0, cli 115/0, desktop main typecheck clean, zero goal/automation-named test failures. --- apps/desktop/src/main/goal-wiring.ts | 98 -------- apps/desktop/src/main/main.ts | 55 +--- apps/desktop/src/main/system-prompt-main.ts | 24 -- packages/cli/src/cli-system-prompt.ts | 22 -- packages/cli/src/cli.ts | 9 - packages/cli/src/pi-tui-runner.ts | 13 +- packages/cli/src/runtime-bootstrap.ts | 74 +----- .../src/__tests__/goal-continuation.test.ts | 200 --------------- .../src/__tests__/goal-evaluator.test.ts | 154 ------------ .../runtime/src/__tests__/goal-state.test.ts | 237 ------------------ .../runtime/src/__tests__/goal-tools.test.ts | 119 --------- packages/runtime/src/goal-continuation.ts | 116 --------- packages/runtime/src/goal-evaluator.ts | 142 ----------- packages/runtime/src/goal-state.ts | 222 ---------------- packages/runtime/src/goal-tools.ts | 155 ------------ packages/runtime/src/index.ts | 24 -- 16 files changed, 5 insertions(+), 1659 deletions(-) delete mode 100644 apps/desktop/src/main/goal-wiring.ts delete mode 100644 packages/runtime/src/__tests__/goal-continuation.test.ts delete mode 100644 packages/runtime/src/__tests__/goal-evaluator.test.ts delete mode 100644 packages/runtime/src/__tests__/goal-state.test.ts delete mode 100644 packages/runtime/src/__tests__/goal-tools.test.ts delete mode 100644 packages/runtime/src/goal-continuation.ts delete mode 100644 packages/runtime/src/goal-evaluator.ts delete mode 100644 packages/runtime/src/goal-state.ts delete mode 100644 packages/runtime/src/goal-tools.ts diff --git a/apps/desktop/src/main/goal-wiring.ts b/apps/desktop/src/main/goal-wiring.ts deleted file mode 100644 index e9f27ebeb8..0000000000 --- a/apps/desktop/src/main/goal-wiring.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import { - GoalManager, - buildGoalTools, - type GoalContinuationDeps, - type MakaTool, -} from '@maka/runtime'; -import type { LlmConnection } from '@maka/core'; - -/** - * Goal execution wiring for the main process. Owns the GoalManager, the goal - * tools, and the turn-boundary continuation deps (evaluator + injection). - * - * The evaluator uses the session's default connection model with a tiny - * (~250-token) budget — a full judge model is heavier than ideal, but the - * request/response is small and this avoids a fragile cheap-model mapping. - * - * "Waiting on an external event" is handled inside the continuation controller - * (neutral progress + normal re-check), so the wiring needs no automation - * coupling — a goal is self-contained and bounded by its own caps. - */ -export interface MainGoalWiring { - manager: GoalManager; - tools: MakaTool[]; - continuationDeps: GoalContinuationDeps; -} - -export interface CreateMainGoalWiringDeps { - getDefaultConnectionSlug: () => Promise; - getConnection: (slug: string) => Promise; - resolveConnectionSecret: (slug: string) => Promise; - buildSubscriptionModelFetch: (connection: LlmConnection, sessionId: string, modelId: string) => typeof fetch | undefined; - getAIModel: (input: { connection: LlmConnection; apiKey: string; modelId: string; fetch: typeof fetch | undefined }) => unknown; - buildProviderOptions: (connection: LlmConnection, modelId: string) => unknown; - getRecentMessages: (sessionId: string) => Promise>; - /** Cumulative token count for a session (summed from token_usage messages). */ - getTokenCount: (sessionId: string) => Promise; - injectTurn: (sessionId: string, text: string) => void; - canContinue: (sessionId: string) => Promise; -} - -export function createMainGoalWiring(deps: CreateMainGoalWiringDeps): MainGoalWiring { - const manager = new GoalManager({ - generateId: () => randomUUID(), - now: () => Date.now(), - }); - - // Synchronous best-effort token snapshot cache, refreshed each continuation. - const tokenCache = new Map(); - - const tools = buildGoalTools({ - goalManager: manager, - getTokenCount: (sessionId) => tokenCache.get(sessionId) ?? 0, - }); - - const inFlight = new Set(); - - const continuationDeps: GoalContinuationDeps = { - goalManager: manager, - inFlight, - evaluator: { - async evaluate(prompt: string): Promise { - const slug = await deps.getDefaultConnectionSlug(); - if (!slug) return '{"met": false, "impossible": false, "progress": false, "reason": "no connection configured"}'; - const connection = await deps.getConnection(slug); - if (!connection) return '{"met": false, "impossible": false, "progress": false, "reason": "connection not found"}'; - const apiKey = await deps.resolveConnectionSecret(slug); - const ai = await import('ai') as unknown as { - generateText(opts: Record): Promise<{ text: string }>; - }; - const modelFetch = deps.buildSubscriptionModelFetch(connection, 'goal-evaluator', connection.defaultModel); - const result = await ai.generateText({ - model: deps.getAIModel({ connection, apiKey: apiKey ?? '', modelId: connection.defaultModel, fetch: modelFetch }), - prompt, - providerOptions: deps.buildProviderOptions(connection, connection.defaultModel), - maxTokens: 250, - }); - return result.text; - }, - }, - async getRecentContext(sessionId: string): Promise { - // Refresh the token snapshot while we have the session open. - tokenCache.set(sessionId, await deps.getTokenCount(sessionId)); - const messages = await deps.getRecentMessages(sessionId); - return messages - .filter((m) => m.type === 'user' || m.type === 'assistant') - .slice(-6) - .map((m) => `[${m.type}]: ${(m.text ?? '').slice(0, 500)}`) - .join('\n'); - }, - getTokenCount: (sessionId) => tokenCache.get(sessionId) ?? 0, - injectTurn: deps.injectTurn, - canContinue: deps.canContinue, - }; - - return { manager, tools, continuationDeps }; -} - diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 77b94f661e..710775ba8a 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -178,8 +178,6 @@ import { buildDefaultContextBudgetPolicy } from '@maka/runtime'; import { createSystemPromptMainService } from './system-prompt-main.js'; import { createMainTaskLedgerWiring } from './task-ledger-wiring.js'; import { createMainAutomationWiring } from './automation-wiring.js'; -import { createMainGoalWiring } from './goal-wiring.js'; -import { handleGoalContinuation } from '@maka/runtime'; import { createOAuthModelConnectionsMainService } from './oauth-model-connections-main.js'; import { applyNetworkPatch, @@ -393,43 +391,6 @@ const automationWiring = createMainAutomationWiring({ // Load durable automations from disk on startup (fire-and-forget; errors are logged inside). void automationWiring.loadDurableAutomations(); -// Goal execution — autonomous turn-boundary continuation with an external -// evaluator (CC-style). Bridges to the Automation system on external waits. -const goalWiring = createMainGoalWiring({ - getDefaultConnectionSlug: () => connectionStore.getDefault(), - getConnection: (slug) => connectionStore.get(slug), - resolveConnectionSecret, - buildSubscriptionModelFetch, - getAIModel: (input) => getAIModel(input), - buildProviderOptions: (connection, modelId) => buildProviderOptions(connection, modelId), - getRecentMessages: async (sessionId) => { - const messages = await runtime.getMessages(sessionId); - return messages.slice(-10).map((m) => ({ - type: m.type, - text: m.type === 'user' || m.type === 'assistant' ? m.text : undefined, - })); - }, - getTokenCount: async (sessionId) => { - const messages = await runtime.getMessages(sessionId); - let total = 0; - for (const m of messages) { - if (m.type === 'token_usage') total += (m.total ?? (m.input + m.output)); - } - return total; - }, - injectTurn: (sessionId, text) => { - const turnId = randomUUID(); - const iterator = runtime.sendMessage(sessionId, { turnId, text }); - void streamEvents(sessionId, iterator, turnId); - }, - canContinue: async (sessionId) => { - const header = await store.readHeader(sessionId); - if (!header || header.archivedAt) return false; - if (header.status === 'running' || header.status === 'blocked' || header.status === 'aborted') return false; - return true; - }, -}); - async function getWorkspacePrivacyContext(): Promise { const settings = await settingsStore.get(); return { incognitoActive: settings.privacy.incognitoActive === true }; @@ -446,7 +407,6 @@ const systemPromptService = createSystemPromptMainService({ workspaceRoot, localMemory, taskLedger: taskLedgerStore, - goalManager: goalWiring.manager, }); // Window is created hidden for E2E and visual-smoke runs so it never steals // focus. Derived from the same isE2e gate as userData/fake-backend so the @@ -549,8 +509,6 @@ const builtinTools: MakaTool[] = [ ...taskLedgerWiring.tools, // Unified Automation: heartbeat (session-internal polling) + cron (standalone scheduled runs). ...automationWiring.tools, - // Goal execution: GoalSet/Clear/Status/Pause/Resume — autonomous turn-boundary continuation. - ...goalWiring.tools, // The `load_tools` connector is built by ToolAvailabilityRuntime; deferred // group tools just need to be present so they are dispatchable once loaded. ...deferredTools, @@ -1379,8 +1337,7 @@ function registerIpc(): void { // 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 autonomous loops tied to the session (goal + polling heartbeats). - goalWiring.manager.remove(sessionId); + // Stop any automation loops (polling heartbeats) tied to the session. automationWiring.manager.removeAllForSession(sessionId); emitSessionsChanged('archived', sessionId); }); @@ -1455,8 +1412,7 @@ function registerIpc(): void { // 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 autonomous loops tied to the session (goal + polling heartbeats). - goalWiring.manager.remove(sessionId); + // Stop any automation loops (polling heartbeats) tied to the session. automationWiring.manager.removeAllForSession(sessionId); emitSessionsChanged('deleted', sessionId); }); @@ -1740,12 +1696,6 @@ async function streamEvents( emitSessionsChanged('message-appended', sessionId); finalAppendBroadcasted = true; } - // Goal auto-continuation: after a turn completes cleanly (NOT user-aborted — - // the Stop button must halt the loop), evaluate the active goal and continue, - // hand off to polling, or stop. Failures never surface to the turn. - if (!turnAborted) { - void handleGoalContinuation(goalWiring.continuationDeps, sessionId).catch(() => {}); - } return { turnId, ok: !turnAborted && !turnError, ...(turnError ? { error: turnError } : {}) }; } catch (error) { const event = { @@ -2104,7 +2054,6 @@ app.on('before-quit', (event) => { async function runBeforeQuitCleanup(): Promise { automationWiring.scheduler.dispose(); - goalWiring.manager.dispose(); configWatcher?.stop(); planReminders.stopTimers(); dailyReview.stopScheduler(); diff --git a/apps/desktop/src/main/system-prompt-main.ts b/apps/desktop/src/main/system-prompt-main.ts index f979658cf7..c3ee94706e 100644 --- a/apps/desktop/src/main/system-prompt-main.ts +++ b/apps/desktop/src/main/system-prompt-main.ts @@ -16,7 +16,6 @@ import { buildPersonalizationPromptFragment, resolveProjectGitInfo, buildSessionEnvironmentPromptFragment, - type GoalManager, } from '@maka/runtime'; import { buildSkillsPromptFragment } from './skills.js'; import { buildWorkspaceInstructionsPromptFragment } from './workspace-instructions.js'; @@ -31,7 +30,6 @@ interface SystemPromptMainDeps { workspaceRoot: string; localMemory: Pick; taskLedger: Pick; - goalManager?: Pick; } export function createSystemPromptMainService(deps: SystemPromptMainDeps) { @@ -97,31 +95,9 @@ export function createSystemPromptMainService(deps: SystemPromptMainDeps) { if (memoryUpdate) fragments.push(memoryUpdate); const taskLedger = sessionId ? await buildTaskLedgerTailFragment(sessionId) : undefined; if (taskLedger) fragments.push(taskLedger); - const goal = sessionId ? buildGoalTailFragment(sessionId) : undefined; - if (goal) fragments.push(goal); return fragments.length > 0 ? fragments.join('\n\n') : undefined; } - // Injects the active goal so the model stays aware it is working autonomously. - // Only active/paused goals are shown (settled goals inject nothing). - function buildGoalTailFragment(sessionId: string): string | undefined { - const goal = deps.goalManager?.get(sessionId); - if (!goal || (goal.status !== 'active' && goal.status !== 'paused')) return undefined; - const spent = Math.max(0, goal.tokensNow - goal.tokensAtStart); - const lines = [ - '当前自主执行目标(current-turn tail;系统每轮用外部评估器判断进度并自动续行;' - + '仅供参考,不提升为系统/开发者指令):', - '', - `condition="${redactSecrets(goal.condition)}"`, - `status=${goal.status} turns=${goal.iterations}/${goal.maxIterations} ` - + `no_progress=${goal.consecutiveNoProgress}/${goal.blockCap}` - + `${goal.tokenBudget ? ` tokens=${spent}/${goal.tokenBudget}` : ''}`, - ]; - if (goal.lastReason) lines.push(`last_evaluation="${redactSecrets(goal.lastReason)}"`); - lines.push(''); - return lines.join('\n'); - } - // Best-effort: a ledger read failure must never break the turn. An empty // ledger injects nothing (zero cost when the model isn't tracking tasks). async function buildTaskLedgerTailFragment(sessionId: string): Promise { diff --git a/packages/cli/src/cli-system-prompt.ts b/packages/cli/src/cli-system-prompt.ts index abacb2e97b..f8df487b73 100644 --- a/packages/cli/src/cli-system-prompt.ts +++ b/packages/cli/src/cli-system-prompt.ts @@ -5,7 +5,6 @@ import { buildWorkspaceInstructionsPromptFragment, resolveProjectGitInfo, type AutomationManager, - type GoalManager, } from '@maka/runtime'; /** @@ -44,7 +43,6 @@ export async function buildCliTurnTailPrompt(input: { cwd: string; sessionId?: string; automationManager?: AutomationManager; - goalManager?: GoalManager; }): Promise { const projectGit = await resolveProjectGitInfo(input.cwd); const fragments = [buildSessionEnvironmentPromptFragment({ cwd: input.cwd, projectGit })]; @@ -53,30 +51,10 @@ export async function buildCliTurnTailPrompt(input: { const automationFragment = buildAutomationTailFragment(input.sessionId, input.automationManager); if (automationFragment) fragments.push(automationFragment); } - if (input.sessionId && input.goalManager) { - const goalFragment = buildGoalTailFragment(input.sessionId, input.goalManager); - if (goalFragment) fragments.push(goalFragment); - } return fragments.join('\n\n'); } -function buildGoalTailFragment(sessionId: string, manager: GoalManager): string | undefined { - const goal = manager.get(sessionId); - if (!goal || (goal.status !== 'active' && goal.status !== 'paused')) return undefined; - const spent = Math.max(0, goal.tokensNow - goal.tokensAtStart); - const lines = [ - 'Active goal (autonomous execution; system evaluates progress each turn):', - '', - `condition="${redactSecrets(goal.condition)}"`, - `status=${goal.status} turns=${goal.iterations}/${goal.maxIterations} no_progress=${goal.consecutiveNoProgress}/${goal.blockCap}` - + `${goal.tokenBudget ? ` tokens=${spent}/${goal.tokenBudget}` : ''}`, - ...(goal.lastReason ? [`last_evaluation="${redactSecrets(goal.lastReason)}"`] : []), - '', - ]; - return lines.join('\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; diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 2ffdff1d7f..6641bc350f 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -3,7 +3,6 @@ import { readFile } from 'node:fs/promises'; import { realpathSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { handleGoalContinuation } from '@maka/runtime'; import { createMakaSessionDriver } from './session-driver.js'; import { createMakaCliRuntimeContext } from './runtime-bootstrap.js'; import { selectableModelIdsForTarget } from './connection-target.js'; @@ -79,14 +78,6 @@ export async function runMakaCli(argv: string[] = process.argv.slice(2)): Promis connectionSlug: context.target.connection.slug, providerType: context.target.connection.providerType, permissionMode: 'ask', - onTurnComplete: (injectTurn) => { - const sessionId = driver.getSessionId(); - if (!sessionId) return; - void handleGoalContinuation( - { ...context.goalContinuationDeps, injectTurn: (_s, text) => injectTurn(text) }, - sessionId, - ).catch(() => {}); - }, }); return 0; } finally { diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index ce6d45dad3..bec5c834ad 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -48,12 +48,6 @@ export interface MakaPiTuiInput { providerType?: ProviderType; permissionMode: PermissionMode; terminal?: Terminal; - /** - * Called after each agent turn settles. Receives an `injectTurn` that runs a - * new turn rendered in the transcript — used for goal auto-continuation so - * continuation turns are visible and chain correctly. - */ - onTurnComplete?: (injectTurn: (text: string) => void) => void; } export async function runMakaPiTui(input: MakaPiTuiInput): Promise { @@ -195,8 +189,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { runAgentTurn(prompt); }; - // Runs one agent turn rendered in the transcript, then lets the host decide - // whether to auto-continue (goal). Shared by user submits and goal injections. + // Runs one agent turn rendered in the transcript. Shared by user submits. function runAgentTurn(prompt: string): void { busy = true; turnRunning = true; @@ -218,10 +211,6 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { editor.disableSubmit = false; terminal.setProgress(false); requestRender(); - // Do not auto-continue (goal) if the session was closed/aborted mid-turn - // (Ctrl-C). The CLI's only abort affordance is close(), so `closed` is the - // abort signal — mirrors the desktop `turnAborted` guard. - if (!closed) input.onTurnComplete?.((text) => runAgentTurn(text)); }); } diff --git a/packages/cli/src/runtime-bootstrap.ts b/packages/cli/src/runtime-bootstrap.ts index 1913be4890..49f9ddbb5c 100644 --- a/packages/cli/src/runtime-bootstrap.ts +++ b/packages/cli/src/runtime-bootstrap.ts @@ -6,14 +6,12 @@ import { AutomationManager, AutomationScheduler, BackendRegistry, - GoalManager, PermissionEngine, SessionManager, ShellRunProcessManager, buildAutomationTool, buildBuiltinTools, buildDefaultContextBudgetPolicy, - buildGoalTools, buildLlmHistorySummarizer, buildProviderOptions, buildSubscriptionModelFetch, @@ -21,7 +19,6 @@ import { loadHistoryCompactBlocksFromArtifacts, persistHistoryCompactBlocksToArtifacts, type AutomationDefinition, - type GoalContinuationDeps, } from '@maka/runtime'; import { createAgentRunStore, @@ -46,8 +43,6 @@ export interface MakaCliRuntimeContext { tools: ReturnType; automationManager: AutomationManager; automationScheduler: AutomationScheduler; - goalManager: GoalManager; - goalContinuationDeps: GoalContinuationDeps; close(): Promise; } @@ -113,13 +108,7 @@ export async function createMakaCliRuntimeContext( cronEnabled: input.automationCreateFreshRun !== undefined, }); - const goalManager = new GoalManager({ generateId: () => randomUUID(), now: () => Date.now() }); - const goalTokenCache = new Map(); - const goalTools = buildGoalTools({ - goalManager, - getTokenCount: (sessionId) => goalTokenCache.get(sessionId) ?? 0, - }); - const allTools = [...tools, automationTool, ...goalTools]; + const allTools = [...tools, automationTool]; // Load durable automations from disk. try { @@ -179,7 +168,7 @@ export async function createMakaCliRuntimeContext( const settings = await settingsStore.get(); return buildCliSystemPrompt({ settings, cwd }); }, - turnTailPrompt: ({ cwd }) => buildCliTurnTailPrompt({ cwd, sessionId: ctx.sessionId, automationManager, goalManager }), + turnTailPrompt: ({ cwd }) => buildCliTurnTailPrompt({ cwd, sessionId: ctx.sessionId, automationManager }), shellRunContextSummary: ctx.shellRunContextSummary, newId: randomUUID, now: Date.now, @@ -228,63 +217,6 @@ export async function createMakaCliRuntimeContext( automationScheduler.start(); - // Goal execution — external-evaluator continuation, sharing the runtime - // sendMessage pipeline (so each continuation turn is a real, traced AgentRun). - const goalContinuationDeps: GoalContinuationDeps = { - goalManager, - inFlight: new Set(), - evaluator: { - async evaluate(prompt: string): Promise { - const ai = await import('ai') as unknown as { - generateText(opts: Record): Promise<{ text: string }>; - }; - const modelFetch = buildSubscriptionModelFetch({ - connection: target.connection, - sessionId: 'goal-evaluator', - modelId: target.model, - }); - const result = await ai.generateText({ - model: getAIModel({ connection: target.connection, apiKey: target.apiKey ?? '', modelId: target.model, fetch: modelFetch }), - prompt, - providerOptions: buildProviderOptions(target.connection, target.model), - maxTokens: 250, - }); - return result.text; - }, - }, - async getRecentContext(sessionId: string): Promise { - const messages = await runtime.getMessages(sessionId); - // Refresh the token snapshot while the session is open. - let total = 0; - for (const m of messages) { - if (m.type === 'token_usage') total += (m.total ?? (m.input + m.output)); - } - goalTokenCache.set(sessionId, total); - return messages - .slice(-10) - .filter((m) => m.type === 'user' || m.type === 'assistant') - .slice(-6) - .map((m) => `[${m.type}]: ${(m.type === 'user' || m.type === 'assistant' ? m.text : '').slice(0, 500)}`) - .join('\n'); - }, - getTokenCount: (sessionId) => goalTokenCache.get(sessionId) ?? 0, - injectTurn: (sessionId, text) => { - const turnId = randomUUID(); - const iterator = runtime.sendMessage(sessionId, { turnId, text }); - void (async () => { for await (const _ of iterator) { /* drain */ } })().catch(() => {}); - }, - canContinue: async (sessionId) => { - const header = await store.readHeader(sessionId); - if (!header || header.archivedAt) return false; - if (header.status === 'running' || header.status === 'blocked' || header.status === 'aborted') return false; - return true; - }, - // The CLI automation scheduler injects turns via a silent drain that does - // not re-invoke handleGoalContinuation, so a heartbeat poll loop could not - // close. We therefore do NOT wire the waiting → heartbeat bridge in the CLI; - // a waiting goal falls through to normal per-turn continuation instead. - }; - return { workspaceRoot: input.workspaceRoot, cwd: input.cwd, @@ -293,8 +225,6 @@ export async function createMakaCliRuntimeContext( tools, automationManager, automationScheduler, - goalManager, - goalContinuationDeps, 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. diff --git a/packages/runtime/src/__tests__/goal-continuation.test.ts b/packages/runtime/src/__tests__/goal-continuation.test.ts deleted file mode 100644 index 391910d781..0000000000 --- a/packages/runtime/src/__tests__/goal-continuation.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { describe, test } from 'node:test'; -import assert from 'node:assert/strict'; -import { GoalManager } from '../goal-state.js'; -import { handleGoalContinuation, type GoalContinuationDeps } from '../goal-continuation.js'; -import type { GoalEvaluation } from '../goal-evaluator.js'; - -const SESSION = 'sess-1'; - -function setup(opts?: { - evaluation?: Partial; - canContinue?: boolean; - tokenCount?: number; -}) { - let id = 0; - const mgr = new GoalManager({ - generateId: () => `g-${++id}`, - now: () => 1000, - }); - const injected: string[] = []; - const evaluation: GoalEvaluation = { - met: false, impossible: false, progress: true, waiting: false, evaluatorFailed: false, reason: 'keep going', - ...opts?.evaluation, - }; - const deps: GoalContinuationDeps = { - goalManager: mgr, - evaluator: { evaluate: async () => JSON.stringify({ - met: evaluation.met, impossible: evaluation.impossible, - progress: evaluation.progress, waiting: evaluation.waiting, - wait_seconds: evaluation.waitSeconds, reason: evaluation.reason, - }) }, - getRecentContext: async () => 'recent context', - getTokenCount: opts?.tokenCount !== undefined ? () => opts.tokenCount! : undefined, - injectTurn: (_s, text) => { injected.push(text); }, - canContinue: async () => opts?.canContinue ?? true, - }; - return { mgr, deps, injected }; -} - -describe('handleGoalContinuation', () => { - test('no active goal → no_goal', async () => { - const { deps } = setup(); - const out = await handleGoalContinuation(deps, SESSION); - assert.equal(out.kind, 'no_goal'); - }); - - test('session busy → cannot_continue', async () => { - const { mgr, deps } = setup({ canContinue: false }); - mgr.set(SESSION, 'x'); - const out = await handleGoalContinuation(deps, SESSION); - assert.equal(out.kind, 'cannot_continue'); - }); - - test('met → achieved, no injection', async () => { - const { mgr, deps, injected } = setup({ evaluation: { met: true, reason: 'all pass' } }); - mgr.set(SESSION, 'x'); - const out = await handleGoalContinuation(deps, SESSION); - assert.equal(out.kind, 'achieved'); - assert.equal(mgr.get(SESSION)?.status, 'achieved'); - assert.equal(injected.length, 0); - }); - - test('impossible → impossible, no injection', async () => { - const { mgr, deps, injected } = setup({ evaluation: { impossible: true, reason: 'cannot' } }); - mgr.set(SESSION, 'x'); - const out = await handleGoalContinuation(deps, SESSION); - assert.equal(out.kind, 'impossible'); - assert.equal(mgr.get(SESSION)?.status, 'impossible'); - assert.equal(injected.length, 0); - }); - - test('not met + progress → continued, injects steering turn', async () => { - const { mgr, deps, injected } = setup({ evaluation: { progress: true, reason: '1 of 3 done' } }); - mgr.set(SESSION, 'x'); - const out = await handleGoalContinuation(deps, SESSION); - assert.equal(out.kind, 'continued'); - assert.equal(injected.length, 1); - assert.ok(injected[0].includes('1 of 3 done')); - assert.equal(mgr.get(SESSION)?.iterations, 1); - assert.equal(mgr.get(SESSION)?.consecutiveNoProgress, 0); - }); - - test('no progress accumulates and trips stalled at block cap', async () => { - const { mgr, deps, injected } = setup({ evaluation: { progress: false, reason: 'stuck' } }); - mgr.set(SESSION, 'x', { blockCap: 2 }); - const first = await handleGoalContinuation(deps, SESSION); - assert.equal(first.kind, 'continued'); - assert.equal(mgr.get(SESSION)?.consecutiveNoProgress, 1); - const second = await handleGoalContinuation(deps, SESSION); - assert.equal(second.kind, 'stopped'); - assert.equal(mgr.get(SESSION)?.status, 'stalled'); - // Second call must not inject (goal stalled). - assert.equal(injected.length, 1); - }); - - test('evaluate-first: a goal MET on its final permitted turn is achieved, not max_iterations', async () => { - const { mgr, deps } = setup({ evaluation: { met: true, reason: 'all pass' } }); - mgr.set(SESSION, 'x', { maxIterations: 1 }); - const out = await handleGoalContinuation(deps, SESSION); - assert.equal(out.kind, 'achieved'); - assert.equal(mgr.get(SESSION)?.status, 'achieved'); - }); - - test('not-met on the final permitted turn stops with max_iterations', async () => { - const { mgr, deps } = setup({ evaluation: { met: false, progress: true } }); - mgr.set(SESSION, 'x', { maxIterations: 1 }); - const out = await handleGoalContinuation(deps, SESSION); - assert.equal(out.kind, 'stopped'); - assert.equal(mgr.get(SESSION)?.status, 'max_iterations'); - }); - - test('evaluate-first: a goal MET on the budget-crossing turn is achieved, not budget_limited', async () => { - const { mgr, deps } = setup({ tokenCount: 2000, evaluation: { met: true, reason: 'done' } }); - mgr.set(SESSION, 'x', { tokenBudget: 1000, tokensAtStart: 500 }); - const out = await handleGoalContinuation(deps, SESSION); - assert.equal(out.kind, 'achieved'); - assert.equal(mgr.get(SESSION)?.status, 'achieved'); - }); - - test('not-met budget crossing stops with budget_limited', async () => { - const { mgr, deps } = setup({ tokenCount: 2000, evaluation: { met: false, progress: true } }); - mgr.set(SESSION, 'x', { tokenBudget: 1000, tokensAtStart: 500 }); - mgr.recordTokens(SESSION, 500); // establish baseline before the continuation - const out = await handleGoalContinuation(deps, SESSION); - assert.equal(out.kind, 'stopped'); - assert.equal(mgr.get(SESSION)?.status, 'budget_limited'); - }); - - test('evaluatorFailed leaves the no-progress streak unchanged (neutral)', async () => { - let id = 0; - const mgr = new GoalManager({ generateId: () => `g-${++id}`, now: () => 1000 }); - // A throwing evaluator yields evaluatorFailed=true from evaluateGoal. - const deps: GoalContinuationDeps = { - goalManager: mgr, - evaluator: { evaluate: async () => { throw new Error('outage'); } }, - getRecentContext: async () => 'ctx', - injectTurn: () => {}, - canContinue: async () => true, - }; - mgr.set(SESSION, 'x', { blockCap: 2 }); - await handleGoalContinuation(deps, SESSION); - // Neutral: streak neither advanced nor reset; goal still active (fail-open). - assert.equal(mgr.get(SESSION)?.consecutiveNoProgress, 0); - assert.equal(mgr.get(SESSION)?.status, 'active'); - }); - - test('waiting is neutral: does not count against the stall cap, still injects', async () => { - const { mgr, deps, injected } = setup({ - evaluation: { waiting: true, progress: false, reason: 'CI still running' }, - }); - mgr.set(SESSION, 'deploy done', { blockCap: 2 }); - const out = await handleGoalContinuation(deps, SESSION); - assert.equal(out.kind, 'continued'); - // Waiting must NOT accumulate toward stall (a wait is not being stuck). - assert.equal(mgr.get(SESSION)?.consecutiveNoProgress, 0); - assert.equal(injected.length, 1); - assert.ok(injected[0].includes('waiting on an external event')); - }); - - test('a long wait is bounded by maxIterations (visible terminal, no zombie)', async () => { - const { mgr, deps } = setup({ - evaluation: { waiting: true, progress: false, reason: 'CI running' }, - }); - mgr.set(SESSION, 'x', { maxIterations: 3 }); - await handleGoalContinuation(deps, SESSION); // turn 1 - await handleGoalContinuation(deps, SESSION); // turn 2 - const third = await handleGoalContinuation(deps, SESSION); // turn 3 hits cap - assert.equal(third.kind, 'stopped'); - assert.equal(mgr.get(SESSION)?.status, 'max_iterations'); - }); - - test('re-entrancy guard: overlapping continuation returns busy', async () => { - let id = 0; - const mgr = new GoalManager({ generateId: () => `g-${++id}`, now: () => 1000 }); - const inFlight = new Set(); - let releaseEval: (() => void) | undefined; - const deps: GoalContinuationDeps = { - goalManager: mgr, - inFlight, - evaluator: { evaluate: () => new Promise((resolve) => { releaseEval = () => resolve('{"met": false, "progress": true, "reason": "x"}'); }) }, - getRecentContext: async () => 'ctx', - injectTurn: () => {}, - canContinue: async () => true, - }; - mgr.set(SESSION, 'x'); - const first = handleGoalContinuation(deps, SESSION); // hangs on evaluate - await new Promise((r) => setTimeout(r, 0)); - const second = await handleGoalContinuation(deps, SESSION); // should see inFlight - assert.equal(second.kind, 'busy'); - releaseEval?.(); - await first; - }); - - test('paused goal is not continued', async () => { - const { mgr, deps } = setup(); - mgr.set(SESSION, 'x'); - mgr.pause(SESSION); - const out = await handleGoalContinuation(deps, SESSION); - assert.equal(out.kind, 'no_goal'); - }); -}); diff --git a/packages/runtime/src/__tests__/goal-evaluator.test.ts b/packages/runtime/src/__tests__/goal-evaluator.test.ts deleted file mode 100644 index d79aa07f69..0000000000 --- a/packages/runtime/src/__tests__/goal-evaluator.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { describe, test } from 'node:test'; -import assert from 'node:assert/strict'; -import { - buildGoalEvaluationPrompt, - parseGoalEvaluation, - evaluateGoal, -} from '../goal-evaluator.js'; - -describe('buildGoalEvaluationPrompt', () => { - test('includes condition, context, and field spec', () => { - const p = buildGoalEvaluationPrompt('all tests pass', 'ran tests, 2 failed'); - assert.ok(p.includes('all tests pass')); - assert.ok(p.includes('ran tests, 2 failed')); - assert.ok(p.includes('GOAL CONDITION')); - assert.ok(p.includes('CONVERSATION CONTEXT')); - assert.ok(p.includes('"met"')); - assert.ok(p.includes('"progress"')); - assert.ok(p.includes('"waiting"')); - }); -}); - -describe('parseGoalEvaluation', () => { - test('parses a full verdict', () => { - const r = parseGoalEvaluation('{"met": false, "impossible": false, "progress": true, "waiting": false, "reason": "fixed 1 of 3"}'); - assert.equal(r.met, false); - assert.equal(r.progress, true); - assert.equal(r.reason, 'fixed 1 of 3'); - }); - - test('parses waiting with wait_seconds', () => { - const r = parseGoalEvaluation('{"met": false, "impossible": false, "progress": false, "waiting": true, "wait_seconds": 120, "reason": "CI running"}'); - assert.equal(r.waiting, true); - assert.equal(r.waitSeconds, 120); - }); - - test('clamps wait_seconds to <= 3600', () => { - const r = parseGoalEvaluation('{"met": false, "waiting": true, "wait_seconds": 999999, "reason": "x"}'); - assert.equal(r.waitSeconds, 3600); - }); - - test('drops non-positive wait_seconds', () => { - const r = parseGoalEvaluation('{"met": false, "waiting": true, "wait_seconds": 0, "reason": "x"}'); - assert.equal(r.waitSeconds, undefined); - }); - - test('extracts JSON from surrounding prose', () => { - const r = parseGoalEvaluation('Here is my judgment:\n{"met": true, "impossible": false, "progress": true, "waiting": false, "reason": "all pass"}\nDone.'); - assert.equal(r.met, true); - }); - - test('missing fields default to false', () => { - const r = parseGoalEvaluation('{"met": true}'); - assert.equal(r.met, true); - assert.equal(r.impossible, false); - assert.equal(r.progress, false); - assert.equal(r.waiting, false); - assert.equal(r.reason, 'No reason provided'); - }); - - test('unparseable output → neutral evaluator failure (not real no-progress)', () => { - const r = parseGoalEvaluation('I cannot determine this'); - assert.equal(r.met, false); - assert.equal(r.progress, false); - assert.equal(r.evaluatorFailed, true); - assert.ok(r.reason.includes('unparseable')); - }); - - test('malformed JSON → neutral evaluator failure', () => { - const r = parseGoalEvaluation('{met: true, broken}'); - assert.equal(r.met, false); - assert.equal(r.evaluatorFailed, true); - assert.ok(r.reason.includes('parse failed')); - }); - - test('braces inside reason → treated as neutral, not false no-progress', () => { - // A coding-goal judge whose reason references code can defeat the flat regex. - const r = parseGoalEvaluation('{"met":false,"progress":true,"reason":"add return {} to handler"}'); - // Either it parses (progress true) or it fails neutrally — never a real - // progress=false that would count toward stall. - if (r.evaluatorFailed) { - assert.equal(r.progress, false); - } else { - assert.equal(r.progress, true); - } - }); - - test('truncates long reason', () => { - const long = 'x'.repeat(300); - const r = parseGoalEvaluation(`{"met": false, "reason": "${long}"}`); - assert.ok(r.reason.length <= 200); - }); -}); - -describe('evaluateGoal', () => { - test('returns parsed verdict on success', async () => { - const r = await evaluateGoal( - { evaluate: async () => '{"met": true, "progress": true, "reason": "done"}' }, - 'finish', 'ctx', - ); - assert.equal(r.met, true); - assert.equal(r.reason, 'done'); - }); - - test('fails open on evaluator error (evaluatorFailed=true, continue)', async () => { - const r = await evaluateGoal( - { evaluate: async () => { throw new Error('network'); } }, - 'finish', 'ctx', - ); - assert.equal(r.met, false); - assert.equal(r.impossible, false); - assert.equal(r.progress, false); - assert.equal(r.evaluatorFailed, true); - assert.ok(r.reason.includes('failed')); - }); - - test('fails open on timeout (evaluatorFailed=true, continue)', async () => { - const r = await evaluateGoal( - { - // Never resolves — force the timeout branch. - evaluate: () => new Promise(() => {}), - timeoutMs: 10, - // Injected timer fires immediately so the race resolves to timeout. - setTimeout: (fn) => { fn(); return 1; }, - clearTimeout: () => {}, - }, - 'finish', 'ctx', - ); - assert.equal(r.met, false); - assert.equal(r.progress, false); - assert.equal(r.evaluatorFailed, true); - assert.ok(r.reason.includes('timed out')); - }); - - test('successful parse sets evaluatorFailed=false', async () => { - const r = await evaluateGoal( - { evaluate: async () => '{"met": false, "progress": true, "reason": "ok"}' }, - 'finish', 'ctx', - ); - assert.equal(r.evaluatorFailed, false); - }); - - test('clears the timeout timer on success', async () => { - let cleared = false; - await evaluateGoal( - { - evaluate: async () => '{"met": true, "reason": "ok"}', - setTimeout: () => 42, - clearTimeout: (h) => { cleared = h === 42; }, - }, - 'finish', 'ctx', - ); - assert.equal(cleared, true); - }); -}); diff --git a/packages/runtime/src/__tests__/goal-state.test.ts b/packages/runtime/src/__tests__/goal-state.test.ts deleted file mode 100644 index 5761ef6a9e..0000000000 --- a/packages/runtime/src/__tests__/goal-state.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { describe, test } from 'node:test'; -import assert from 'node:assert/strict'; -import { - GoalManager, - TERMINAL_GOAL_STATUSES, - DEFAULT_MAX_ITERATIONS, - DEFAULT_BLOCK_CAP, -} from '../goal-state.js'; - -const SESSION = 'sess-1'; - -function createManager(startTime = 1_700_000_000_000) { - let id = 0; - let time = startTime; - const mgr = new GoalManager({ generateId: () => `goal-${++id}`, now: () => time }); - return { mgr, advance: (ms: number) => { time += ms; }, at: () => time }; -} - -describe('GoalManager — set / lifecycle', () => { - test('set creates an active goal with defaults', () => { - const { mgr } = createManager(); - const g = mgr.set(SESSION, 'all tests pass'); - assert.equal(g.status, 'active'); - assert.equal(g.iterations, 0); - assert.equal(g.maxIterations, DEFAULT_MAX_ITERATIONS); - assert.equal(g.blockCap, DEFAULT_BLOCK_CAP); - assert.equal(g.consecutiveNoProgress, 0); - assert.equal(g.tokenBudget, undefined); - }); - - test('set accepts custom limits', () => { - const { mgr } = createManager(); - const g = mgr.set(SESSION, 'x', { maxIterations: 10, blockCap: 3, tokenBudget: 5000, tokensAtStart: 100 }); - assert.equal(g.maxIterations, 10); - assert.equal(g.blockCap, 3); - assert.equal(g.tokenBudget, 5000); - assert.equal(g.tokensAtStart, 100); - assert.equal(g.tokensNow, 100); - }); - - test('set replaces an active goal (old marked cleared)', () => { - const { mgr } = createManager(); - const first = mgr.set(SESSION, 'first'); - mgr.set(SESSION, 'second'); - assert.equal(first.status, 'cleared'); - assert.equal(mgr.get(SESSION)?.condition, 'second'); - }); - - test('set after a terminal goal does not mutate the settled one', () => { - const { mgr } = createManager(); - const first = mgr.set(SESSION, 'first'); - mgr.markAchieved(SESSION, 'done'); - assert.equal(first.status, 'achieved'); - mgr.set(SESSION, 'second'); - // The achieved goal object keeps its status; a new goal replaces the map entry. - assert.equal(first.status, 'achieved'); - assert.equal(mgr.get(SESSION)?.condition, 'second'); - }); - - test('getActive only returns active goals', () => { - const { mgr } = createManager(); - mgr.set(SESSION, 'x'); - assert.ok(mgr.getActive(SESSION)); - mgr.pause(SESSION); - assert.equal(mgr.getActive(SESSION), undefined); - }); -}); - -describe('GoalManager — iteration ceiling', () => { - test('incrementIteration trips max_iterations', () => { - const { mgr } = createManager(); - mgr.set(SESSION, 'x', { maxIterations: 2 }); - mgr.incrementIteration(SESSION); - const g = mgr.incrementIteration(SESSION); - assert.equal(g?.status, 'max_iterations'); - }); - - test('incrementIteration on non-active returns undefined', () => { - const { mgr } = createManager(); - mgr.set(SESSION, 'x'); - mgr.pause(SESSION); - assert.equal(mgr.incrementIteration(SESSION), undefined); - }); -}); - -describe('GoalManager — block cap (stall detection)', () => { - test('progress resets the no-progress streak', () => { - const { mgr } = createManager(); - mgr.set(SESSION, 'x', { blockCap: 3 }); - mgr.recordProgress(SESSION, false); - mgr.recordProgress(SESSION, false); - assert.equal(mgr.get(SESSION)?.consecutiveNoProgress, 2); - mgr.recordProgress(SESSION, true); - assert.equal(mgr.get(SESSION)?.consecutiveNoProgress, 0); - assert.equal(mgr.get(SESSION)?.status, 'active'); - }); - - test('block cap trips stalled', () => { - const { mgr } = createManager(); - mgr.set(SESSION, 'x', { blockCap: 3 }); - mgr.recordProgress(SESSION, false); - mgr.recordProgress(SESSION, false); - const g = mgr.recordProgress(SESSION, false); - assert.equal(g?.status, 'stalled'); - assert.ok(g?.lastReason?.includes('No progress')); - }); - - test('recordProgress on non-active is a no-op', () => { - const { mgr } = createManager(); - mgr.set(SESSION, 'x'); - mgr.markAchieved(SESSION, 'done'); - assert.equal(mgr.recordProgress(SESSION, false), undefined); - }); -}); - -describe('GoalManager — token budget', () => { - test('recordTokens trips budget_limited (after baseline established)', () => { - const { mgr } = createManager(); - mgr.set(SESSION, 'x', { tokenBudget: 1000, tokensAtStart: 500 }); - mgr.recordTokens(SESSION, 500); // establishes baseline at 500 - mgr.recordTokens(SESSION, 1200); // spent 700, under budget - assert.equal(mgr.get(SESSION)?.status, 'active'); - mgr.recordTokens(SESSION, 1600); // spent 1100, over budget - assert.equal(mgr.get(SESSION)?.status, 'budget_limited'); - }); - - test('tokensSpent computes delta from established baseline', () => { - const { mgr } = createManager(); - mgr.set(SESSION, 'x', { tokensAtStart: 500 }); - mgr.recordTokens(SESSION, 500); // baseline - mgr.recordTokens(SESSION, 800); - assert.equal(mgr.tokensSpent(SESSION), 300); - }); - - test('token count is monotonic (stale smaller read ignored)', () => { - const { mgr } = createManager(); - mgr.set(SESSION, 'x', { tokensAtStart: 0 }); - mgr.recordTokens(SESSION, 0); // baseline - mgr.recordTokens(SESSION, 1000); - mgr.recordTokens(SESSION, 500); // stale - assert.equal(mgr.get(SESSION)?.tokensNow, 1000); - }); - - test('no budget → recordTokens never trips budget_limited', () => { - const { mgr } = createManager(); - mgr.set(SESSION, 'x'); - mgr.recordTokens(SESSION, 0); - mgr.recordTokens(SESSION, 1_000_000); - assert.equal(mgr.get(SESSION)?.status, 'active'); - }); -}); - -describe('GoalManager — pause / resume', () => { - test('pause then resume', () => { - const { mgr } = createManager(); - mgr.set(SESSION, 'x'); - const paused = mgr.pause(SESSION); - assert.equal(paused?.status, 'paused'); - assert.ok(paused?.pausedAt); - const resumed = mgr.resume(SESSION); - assert.equal(resumed?.status, 'active'); - assert.equal(resumed?.pausedAt, undefined); - }); - - test('cannot pause a non-active goal', () => { - const { mgr } = createManager(); - mgr.set(SESSION, 'x'); - mgr.markAchieved(SESSION, 'done'); - assert.equal(mgr.pause(SESSION), undefined); - }); - - test('cannot resume a non-paused goal', () => { - const { mgr } = createManager(); - mgr.set(SESSION, 'x'); - assert.equal(mgr.resume(SESSION), undefined); - }); -}); - -describe('GoalManager — terminal transitions', () => { - test('markAchieved / markImpossible only from active', () => { - const { mgr } = createManager(); - mgr.set(SESSION, 'x'); - assert.equal(mgr.markAchieved(SESSION, 'done')?.status, 'achieved'); - assert.equal(mgr.markImpossible(SESSION, 'no'), undefined); - }); - - test('clear from active → cleared; clear from terminal keeps outcome', () => { - const { mgr } = createManager(); - mgr.set(SESSION, 'x'); - assert.equal(mgr.clear(SESSION)?.status, 'cleared'); - - mgr.set(SESSION, 'y'); - mgr.markAchieved(SESSION, 'done'); - assert.equal(mgr.clear(SESSION)?.status, 'achieved'); - }); - - test('TERMINAL_GOAL_STATUSES covers all stop states', () => { - for (const s of ['achieved', 'impossible', 'cleared', 'stalled', 'budget_limited', 'max_iterations'] as const) { - assert.ok(TERMINAL_GOAL_STATUSES.has(s), `${s} should be terminal`); - } - assert.ok(!TERMINAL_GOAL_STATUSES.has('active')); - assert.ok(!TERMINAL_GOAL_STATUSES.has('paused')); - }); - - test('remove and dispose', () => { - const { mgr } = createManager(); - mgr.set(SESSION, 'x'); - assert.equal(mgr.remove(SESSION), true); - assert.equal(mgr.get(SESSION), undefined); - mgr.set('a', '1'); - mgr.set('b', '2'); - mgr.dispose(); - assert.equal(mgr.get('a'), undefined); - assert.equal(mgr.get('b'), undefined); - }); - - test('different sessions are independent', () => { - const { mgr } = createManager(); - mgr.set('a', 'goal A'); - mgr.set('b', 'goal B'); - assert.equal(mgr.get('a')?.condition, 'goal A'); - assert.equal(mgr.get('b')?.condition, 'goal B'); - }); -}); - -describe('GoalManager — token baseline', () => { - test('first recordTokens establishes the baseline (spend starts at 0)', () => { - const { mgr } = createManager(); - // GoalSet captured a stale/0 baseline; the goal actually starts at 50k. - mgr.set(SESSION, 'x', { tokenBudget: 20000, tokensAtStart: 0 }); - mgr.recordTokens(SESSION, 50000); // first real observation → re-baseline - assert.equal(mgr.tokensSpent(SESSION), 0); - assert.equal(mgr.get(SESSION)?.status, 'active'); // NOT budget_limited - mgr.recordTokens(SESSION, 71000); // spent 21000 > 20000 - assert.equal(mgr.get(SESSION)?.status, 'budget_limited'); - }); -}); diff --git a/packages/runtime/src/__tests__/goal-tools.test.ts b/packages/runtime/src/__tests__/goal-tools.test.ts deleted file mode 100644 index 53910c824f..0000000000 --- a/packages/runtime/src/__tests__/goal-tools.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, test } from 'node:test'; -import assert from 'node:assert/strict'; -import { GoalManager } from '../goal-state.js'; -import { - buildGoalTools, - GOAL_SET_TOOL_NAME, - GOAL_CLEAR_TOOL_NAME, - GOAL_STATUS_TOOL_NAME, - GOAL_PAUSE_TOOL_NAME, - GOAL_RESUME_TOOL_NAME, -} from '../goal-tools.js'; -import type { MakaTool, MakaToolContext } from '../tool-runtime.js'; - -const SESSION = 'sess-1'; - -function ctx(): MakaToolContext { - return { sessionId: SESSION, turnId: 't', cwd: '/', toolCallId: 'tc', abortSignal: new AbortController().signal, emitOutput: () => {} }; -} - -function findTool(tools: MakaTool[], name: string): MakaTool { - const t = tools.find(x => x.name === name); - assert.ok(t, `tool ${name} exists`); - return t!; -} - -function makeTools(getTokenCount?: (s: string) => number) { - const mgr = new GoalManager({ generateId: () => 'g-1', now: () => 5000 }); - const tools = buildGoalTools({ goalManager: mgr, getTokenCount, now: () => 5000 }); - return { mgr, tools }; -} - -describe('goal tools', () => { - test('exposes 5 tools', () => { - const { tools } = makeTools(); - const names = tools.map(t => t.name).sort(); - assert.deepEqual(names, [ - GOAL_CLEAR_TOOL_NAME, GOAL_PAUSE_TOOL_NAME, GOAL_RESUME_TOOL_NAME, - GOAL_SET_TOOL_NAME, GOAL_STATUS_TOOL_NAME, - ].sort()); - }); - - test('all tools are permission-free', () => { - const { tools } = makeTools(); - for (const t of tools) assert.equal(t.permissionRequired, false); - }); - - test('GoalSet creates a goal with custom limits', async () => { - const { mgr, tools } = makeTools(); - const set = findTool(tools, GOAL_SET_TOOL_NAME); - const out = await set.impl({ condition: 'all tests pass', max_iterations: 10, block_cap: 3, token_budget: 5000 }, ctx()) as string; - assert.ok(out.includes('Goal set')); - assert.ok(out.includes('all tests pass')); - assert.ok(out.includes('max 10 turns')); - assert.ok(out.includes('budget 5000')); - const g = mgr.get(SESSION)!; - assert.equal(g.maxIterations, 10); - assert.equal(g.blockCap, 3); - assert.equal(g.tokenBudget, 5000); - }); - - test('GoalSet captures the token baseline', async () => { - const { mgr, tools } = makeTools(() => 1234); - const set = findTool(tools, GOAL_SET_TOOL_NAME); - await set.impl({ condition: 'x' }, ctx()); - assert.equal(mgr.get(SESSION)?.tokensAtStart, 1234); - }); - - test('GoalPause / GoalResume lifecycle', async () => { - const { mgr, tools } = makeTools(); - await findTool(tools, GOAL_SET_TOOL_NAME).impl({ condition: 'x' }, ctx()); - - const pauseOut = await findTool(tools, GOAL_PAUSE_TOOL_NAME).impl({}, ctx()) as string; - assert.ok(pauseOut.includes('paused')); - assert.equal(mgr.get(SESSION)?.status, 'paused'); - - const resumeOut = await findTool(tools, GOAL_RESUME_TOOL_NAME).impl({}, ctx()) as string; - assert.ok(resumeOut.includes('resumed')); - assert.equal(mgr.get(SESSION)?.status, 'active'); - }); - - test('GoalPause with no goal', async () => { - const { tools } = makeTools(); - const out = await findTool(tools, GOAL_PAUSE_TOOL_NAME).impl({}, ctx()) as string; - assert.ok(out.includes('No active goal')); - }); - - test('GoalResume with no paused goal', async () => { - const { tools } = makeTools(); - await findTool(tools, GOAL_SET_TOOL_NAME).impl({ condition: 'x' }, ctx()); - const out = await findTool(tools, GOAL_RESUME_TOOL_NAME).impl({}, ctx()) as string; - assert.ok(out.includes('No paused goal')); - }); - - test('GoalClear', async () => { - const { mgr, tools } = makeTools(); - await findTool(tools, GOAL_SET_TOOL_NAME).impl({ condition: 'x' }, ctx()); - const out = await findTool(tools, GOAL_CLEAR_TOOL_NAME).impl({}, ctx()) as string; - assert.ok(out.includes('cleared')); - assert.equal(mgr.get(SESSION)?.status, 'cleared'); - }); - - test('GoalStatus shows full lifecycle detail', async () => { - const { mgr, tools } = makeTools(); - await findTool(tools, GOAL_SET_TOOL_NAME).impl({ condition: 'deploy', token_budget: 5000 }, ctx()); - mgr.recordTokens(SESSION, 1000); // establishes baseline at 1000 - mgr.recordTokens(SESSION, 2500); // spent 1500 since baseline - const out = await findTool(tools, GOAL_STATUS_TOOL_NAME).impl({}, ctx()) as string; - assert.ok(out.includes('deploy')); - assert.ok(out.includes('Status: active')); - assert.ok(out.includes('No-progress streak: 0/8')); - assert.ok(out.includes('Tokens: 1500/5000')); - }); - - test('GoalStatus with no goal', async () => { - const { tools } = makeTools(); - const out = await findTool(tools, GOAL_STATUS_TOOL_NAME).impl({}, ctx()) as string; - assert.ok(out.includes('No goal set')); - }); -}); diff --git a/packages/runtime/src/goal-continuation.ts b/packages/runtime/src/goal-continuation.ts deleted file mode 100644 index 2f8838c42e..0000000000 --- a/packages/runtime/src/goal-continuation.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Goal continuation controller — the pure decision logic for what happens at a - * turn boundary when a goal is active. Lives in @maka/runtime so desktop and - * CLI share one implementation (Desktop/TUI parity rule). - * - * Called after a turn's event stream drains. Order of operations matters: - * the external evaluator runs FIRST so a goal genuinely completed on its last - * permitted turn is detected as achieved/impossible rather than misreported as - * a cap failure. Caps (iterations / token budget / stall) are enforced only - * after the evaluator has had its say. - * - * "Waiting on an external event" is treated as a NEUTRAL signal: the turn does - * not count against the stall cap (the agent is legitimately blocked, not - * stuck), and a normal continuation turn is injected so the agent re-checks. - * A long wait is bounded by maxIterations and surfaces as a visible terminal - * state — never a silent zombie. (A scheduled poll handoff to the automation - * system is deliberately out of scope for v1; it couples two independent - * lifecycles and is easy to get wrong.) - */ - -import { evaluateGoal, type GoalEvaluation, type GoalEvaluatorDeps } from './goal-evaluator.js'; -import type { GoalManager } from './goal-state.js'; - -export type GoalContinuationOutcome = - | { kind: 'no_goal' } - | { kind: 'cannot_continue' } - | { kind: 'busy' } - | { kind: 'achieved'; evaluation: GoalEvaluation } - | { kind: 'impossible'; evaluation: GoalEvaluation } - | { kind: 'stopped'; reason: string; status: string } - | { kind: 'continued'; evaluation: GoalEvaluation }; - -export interface GoalContinuationDeps { - goalManager: GoalManager; - evaluator: GoalEvaluatorDeps; - /** Summarized recent conversation (last ~5 messages) for the evaluator. */ - getRecentContext: (sessionId: string) => Promise; - /** Current cumulative token count for the session (for budget tracking). */ - getTokenCount?: (sessionId: string) => number; - /** Inject a continuation turn into the session. */ - injectTurn: (sessionId: string, text: string) => void; - /** Session is idle and can accept a new turn (exists, not archived, not running). */ - canContinue: (sessionId: string) => Promise; - /** - * Per-session re-entrancy guard. Prevents two overlapping continuations for - * the same session (the evaluator call spans multiple seconds, during which a - * second turn could complete). Supplied by the wiring; omitted in unit tests. - */ - inFlight?: Set; -} - -const CONTINUATION_PREAMBLE = - '[Goal continuation] The goal is not yet met. Keep working toward it. ' - + 'Do not redefine success around a smaller task; match your verification to the full requirement.'; - -export async function handleGoalContinuation( - deps: GoalContinuationDeps, - sessionId: string, -): Promise { - const goal = deps.goalManager.getActive(sessionId); - if (!goal) return { kind: 'no_goal' }; - - // Re-entrancy guard: only one continuation in flight per session. - if (deps.inFlight?.has(sessionId)) return { kind: 'busy' }; - deps.inFlight?.add(sessionId); - try { - if (!(await deps.canContinue(sessionId))) return { kind: 'cannot_continue' }; - - // Evaluate FIRST — a genuine completion on the final permitted turn must be - // detected before any cap short-circuits the loop. - const context = await deps.getRecentContext(sessionId); - const evaluation = await evaluateGoal(deps.evaluator, goal.condition, context); - - if (evaluation.met) { - deps.goalManager.markAchieved(sessionId, evaluation.reason); - return { kind: 'achieved', evaluation }; - } - if (evaluation.impossible) { - deps.goalManager.markImpossible(sessionId, evaluation.reason); - return { kind: 'impossible', evaluation }; - } - - // Enforce caps AFTER evaluation. Each may flip the goal terminal. - if (deps.getTokenCount) { - deps.goalManager.recordTokens(sessionId, deps.getTokenCount(sessionId)); - } - deps.goalManager.incrementIteration(sessionId); - // Progress signal drives the stall cap. Skip it (neutral) when the evaluator - // failed (transient outage must not defeat stall detection) OR when the - // agent is legitimately waiting on an external event (a wait is not a stall). - if (!evaluation.evaluatorFailed && !evaluation.waiting) { - deps.goalManager.recordProgress(sessionId, evaluation.progress); - } - - const settled = deps.goalManager.get(sessionId); - if (!settled || settled.status !== 'active') { - return { kind: 'stopped', reason: settled?.lastReason ?? 'Goal settled', status: settled?.status ?? 'unknown' }; - } - - // Re-check idle immediately before injecting — the evaluator call may have - // spanned seconds during which a user send started a new turn. - if (!(await deps.canContinue(sessionId))) return { kind: 'cannot_continue' }; - - settled.lastReason = evaluation.reason; - const waitNote = evaluation.waiting ? ' (waiting on an external event — re-check, do not spin uselessly)' : ''; - deps.injectTurn( - sessionId, - `${CONTINUATION_PREAMBLE}\n\nEvaluation: ${evaluation.reason}${waitNote}\n` - + `Goal: "${settled.condition}" (turn ${settled.iterations}/${settled.maxIterations}` - + `${settled.consecutiveNoProgress > 0 ? `, ${settled.consecutiveNoProgress}/${settled.blockCap} no-progress` : ''})`, - ); - return { kind: 'continued', evaluation }; - } finally { - deps.inFlight?.delete(sessionId); - } -} diff --git a/packages/runtime/src/goal-evaluator.ts b/packages/runtime/src/goal-evaluator.ts deleted file mode 100644 index 3cf2bb1a6d..0000000000 --- a/packages/runtime/src/goal-evaluator.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * Goal evaluator — CC-style external judge. Uses a cheap/fast model (e.g. haiku) - * to decide, after each turn, whether the goal is met, impossible, making - * progress, or waiting on an external event. - * - * The working model never judges its own completion (unlike Codex): keeping the - * judge external prevents the agent from rationalizing itself into a premature - * "done", which is Codex's documented failure mode. - */ - -export interface GoalEvaluation { - /** Condition is satisfied — stop, success. */ - met: boolean; - /** Fundamentally unachievable — stop, give up. */ - impossible: boolean; - /** The last turn advanced toward the goal (resets the block cap). */ - progress: boolean; - /** The agent is blocked waiting on an external event (CI, deploy, review). */ - waiting: boolean; - /** Suggested seconds to wait before re-checking, when `waiting`. */ - waitSeconds?: number; - /** - * The evaluator failed (timeout/error) and produced no real judgment. The - * caller should treat `progress` as UNKNOWN — neither advancing nor resetting - * the stall counter — so a transient evaluator outage cannot silently defeat - * stall detection. Fail-open on continuation still applies. - */ - evaluatorFailed: boolean; - /** One-sentence rationale, fed back to the agent as steering. */ - reason: string; -} - -export interface GoalEvaluatorDeps { - /** - * Single-shot LLM call for goal evaluation. Should use a cheap/fast model. - * The evaluator must not run tools or read files — it judges from text only. - */ - evaluate: (prompt: string) => Promise; - /** Hard timeout for the evaluator call (ms). Defaults to 30_000 (CC's limit). */ - timeoutMs?: number; - /** Injectable timer for tests. Defaults to global setTimeout/clearTimeout. */ - setTimeout?: (fn: () => void, ms: number) => unknown; - clearTimeout?: (handle: unknown) => void; -} - -const DEFAULT_EVALUATOR_TIMEOUT_MS = 30_000; - -const EVALUATOR_SYSTEM = `You are a goal evaluation judge for an autonomous coding agent. Given a GOAL CONDITION and recent CONVERSATION CONTEXT, judge the agent's progress. - -Respond ONLY with valid JSON in this exact shape: -{"met": boolean, "impossible": boolean, "progress": boolean, "waiting": boolean, "wait_seconds": number, "reason": "one sentence"} - -Field rules: -- met: true ONLY if there is clear, concrete evidence the condition is fully satisfied. Match verification scope to the requirement scope — do not accept a narrower substitute. -- impossible: true ONLY for a truly unachievable goal (violates constraints/physics), not merely a hard one. -- progress: true if the last turn moved measurably closer to the goal (fixed a failure, advanced a step). false if the turn spun, repeated itself, or did nothing useful. -- waiting: true if the agent is correctly blocked on an external event it cannot speed up (CI run, deploy, remote queue, human review). Set wait_seconds to a sensible poll interval (default 60). -- reason: concise (under 120 chars), specific, actionable steering for the next turn. - -Be conservative on "met" and "impossible". When uncertain, met=false impossible=false progress=false waiting=false.`; - -export function buildGoalEvaluationPrompt(condition: string, context: string): string { - return [ - EVALUATOR_SYSTEM, - '', - '--- GOAL CONDITION ---', - condition, - '', - '--- RECENT CONVERSATION CONTEXT ---', - context, - '', - '--- YOUR JUDGMENT (JSON only) ---', - ].join('\n'); -} - -export function parseGoalEvaluation(raw: string): GoalEvaluation { - // Unparseable output is "no real judgment" — treat it as a NEUTRAL evaluator - // failure (like a timeout), NOT as real "no progress", so a garbled cheap-model - // response cannot skew stall detection into a false 'stalled' termination. - const fallback: GoalEvaluation = { - met: false, impossible: false, progress: false, waiting: false, evaluatorFailed: true, - reason: 'Evaluator produced unparseable output', - }; - // Prefer the object that mentions "met"; fall back to the first object. - const jsonMatch = raw.match(/\{[^{}]*"met"[^{}]*\}/s) ?? raw.match(/\{[\s\S]*?\}/); - if (!jsonMatch) return fallback; - try { - const parsed = JSON.parse(jsonMatch[0]) as Record; - const waitSecondsRaw = parsed.wait_seconds; - const waitSeconds = typeof waitSecondsRaw === 'number' && Number.isFinite(waitSecondsRaw) && waitSecondsRaw > 0 - ? Math.min(3600, Math.round(waitSecondsRaw)) - : undefined; - return { - met: Boolean(parsed.met), - impossible: Boolean(parsed.impossible), - progress: Boolean(parsed.progress), - waiting: Boolean(parsed.waiting), - evaluatorFailed: false, - ...(waitSeconds !== undefined ? { waitSeconds } : {}), - reason: typeof parsed.reason === 'string' && parsed.reason.trim() - ? parsed.reason.slice(0, 200) - : 'No reason provided', - }; - } catch { - return { ...fallback, reason: 'Evaluator JSON parse failed' }; - } -} - -/** - * Race the evaluator against a hard timeout. On timeout or error, fail OPEN - * for continuation (goal keeps working) but flag `evaluatorFailed` so the - * caller does not treat the outage as either progress or a stall. - */ -export async function evaluateGoal( - deps: GoalEvaluatorDeps, - condition: string, - context: string, -): Promise { - const prompt = buildGoalEvaluationPrompt(condition, context); - const timeoutMs = deps.timeoutMs ?? DEFAULT_EVALUATOR_TIMEOUT_MS; - const setT = deps.setTimeout ?? ((fn, ms) => setTimeout(fn, ms)); - const clearT = deps.clearTimeout ?? ((h) => clearTimeout(h as ReturnType)); - - let timer: unknown; - const timeout = new Promise<'__timeout__'>((resolve) => { - timer = setT(() => resolve('__timeout__'), timeoutMs); - }); - - try { - const result = await Promise.race([deps.evaluate(prompt), timeout]); - if (result === '__timeout__') { - return { met: false, impossible: false, progress: false, waiting: false, evaluatorFailed: true, reason: 'Evaluator timed out (continuing)' }; - } - return parseGoalEvaluation(result); - } catch { - return { met: false, impossible: false, progress: false, waiting: false, evaluatorFailed: true, reason: 'Evaluator call failed (continuing)' }; - } finally { - clearT(timer); - } -} - -export { DEFAULT_EVALUATOR_TIMEOUT_MS }; diff --git a/packages/runtime/src/goal-state.ts b/packages/runtime/src/goal-state.ts deleted file mode 100644 index 0e90d3a139..0000000000 --- a/packages/runtime/src/goal-state.ts +++ /dev/null @@ -1,222 +0,0 @@ -/** - * Goal execution state — session-scoped, in-memory. - * - * A goal is a durable objective the agent works toward autonomously across - * turns. After each turn, an external evaluator (CC-style, uses a cheap model) - * judges whether the condition is met; if not, the system auto-continues. - * - * Lifecycle (Codex-inspired): - * active → achieved / impossible / cleared / paused - * → stalled (block cap: N consecutive no-progress turns) - * → budget_limited (token budget exhausted) - * → max_iterations (total turn ceiling) - */ - -export type GoalStatus = - | 'active' - | 'achieved' - | 'impossible' - | 'cleared' - | 'paused' - | 'stalled' - | 'budget_limited' - | 'max_iterations'; - -/** Terminal statuses — a goal in one of these states will not continue. */ -export const TERMINAL_GOAL_STATUSES: ReadonlySet = new Set([ - 'achieved', - 'impossible', - 'cleared', - 'stalled', - 'budget_limited', - 'max_iterations', -]); - -export interface GoalState { - id: string; - sessionId: string; - condition: string; - status: GoalStatus; - setAt: number; - iterations: number; - maxIterations: number; - /** Consecutive turns with no progress (drives the block cap → stalled). */ - consecutiveNoProgress: number; - /** Force-stop after this many consecutive no-progress turns (CC's 8). */ - blockCap: number; - /** Optional token budget; goal → budget_limited when exceeded. */ - tokenBudget?: number; - /** Token count observed when the goal was set (baseline for spend). */ - tokensAtStart: number; - /** Latest observed token count (used to compute spend). */ - tokensNow: number; - /** - * True until the first real token observation. The baseline captured at set - * time can be stale/0 (the model calls GoalSet before any continuation has - * observed the session's token count), so the first recordTokens re-baselines - * to measure only tokens the goal itself spends. - */ - tokensBaselinePending: boolean; - lastReason?: string; - achievedAt?: number; - pausedAt?: number; -} - -export interface GoalManagerDeps { - generateId: () => string; - now: () => number; -} - -export const DEFAULT_MAX_ITERATIONS = 50; -export const DEFAULT_BLOCK_CAP = 8; - -export class GoalManager { - private goals = new Map(); - - constructor(private readonly deps: GoalManagerDeps) {} - - set(sessionId: string, condition: string, opts?: { - maxIterations?: number; - blockCap?: number; - tokenBudget?: number; - tokensAtStart?: number; - }): GoalState { - // Replacing an existing goal: settle the old one before overwriting. - const existing = this.goals.get(sessionId); - if (existing && !TERMINAL_GOAL_STATUSES.has(existing.status)) { - existing.status = 'cleared'; - } - const start = opts?.tokensAtStart ?? 0; - const goal: GoalState = { - id: this.deps.generateId(), - sessionId, - condition, - status: 'active', - setAt: this.deps.now(), - iterations: 0, - maxIterations: opts?.maxIterations ?? DEFAULT_MAX_ITERATIONS, - consecutiveNoProgress: 0, - blockCap: opts?.blockCap ?? DEFAULT_BLOCK_CAP, - tokenBudget: opts?.tokenBudget, - tokensAtStart: start, - tokensNow: start, - tokensBaselinePending: true, - }; - this.goals.set(sessionId, goal); - return goal; - } - - get(sessionId: string): GoalState | undefined { - return this.goals.get(sessionId); - } - - getActive(sessionId: string): GoalState | undefined { - const goal = this.goals.get(sessionId); - return goal?.status === 'active' ? goal : undefined; - } - - tokensSpent(sessionId: string): number { - const goal = this.goals.get(sessionId); - if (!goal) return 0; - return Math.max(0, goal.tokensNow - goal.tokensAtStart); - } - - incrementIteration(sessionId: string): GoalState | undefined { - const goal = this.goals.get(sessionId); - if (!goal || goal.status !== 'active') return undefined; - goal.iterations++; - if (goal.iterations >= goal.maxIterations) { - goal.status = 'max_iterations'; - goal.lastReason = `Reached maximum iterations (${goal.maxIterations})`; - } - return goal; - } - - recordProgress(sessionId: string, madeProgress: boolean): GoalState | undefined { - const goal = this.goals.get(sessionId); - if (!goal || goal.status !== 'active') return undefined; - if (madeProgress) { - goal.consecutiveNoProgress = 0; - } else { - goal.consecutiveNoProgress++; - if (goal.consecutiveNoProgress >= goal.blockCap) { - goal.status = 'stalled'; - goal.lastReason = `No progress for ${goal.blockCap} consecutive turns`; - } - } - return goal; - } - - recordTokens(sessionId: string, tokensNow: number): GoalState | undefined { - const goal = this.goals.get(sessionId); - if (!goal) return undefined; - // The first real observation establishes the baseline (see field doc). - if (goal.tokensBaselinePending) { - goal.tokensAtStart = tokensNow; - goal.tokensNow = tokensNow; - goal.tokensBaselinePending = false; - return goal; - } - // Token counts are monotonic; never let a stale/smaller read regress spend. - goal.tokensNow = Math.max(goal.tokensNow, tokensNow); - if ( - goal.status === 'active' && - goal.tokenBudget !== undefined && - goal.tokensNow - goal.tokensAtStart >= goal.tokenBudget - ) { - goal.status = 'budget_limited'; - goal.lastReason = `Token budget exhausted (${goal.tokenBudget} tokens)`; - } - return goal; - } - - markAchieved(sessionId: string, reason: string): GoalState | undefined { - const goal = this.goals.get(sessionId); - if (!goal || goal.status !== 'active') return undefined; - goal.status = 'achieved'; - goal.lastReason = reason; - goal.achievedAt = this.deps.now(); - return goal; - } - - markImpossible(sessionId: string, reason: string): GoalState | undefined { - const goal = this.goals.get(sessionId); - if (!goal || goal.status !== 'active') return undefined; - goal.status = 'impossible'; - goal.lastReason = reason; - return goal; - } - - pause(sessionId: string): GoalState | undefined { - const goal = this.goals.get(sessionId); - if (!goal || goal.status !== 'active') return undefined; - goal.status = 'paused'; - goal.pausedAt = this.deps.now(); - return goal; - } - - resume(sessionId: string): GoalState | undefined { - const goal = this.goals.get(sessionId); - if (!goal || goal.status !== 'paused') return undefined; - goal.status = 'active'; - goal.pausedAt = undefined; - return goal; - } - - clear(sessionId: string): GoalState | undefined { - const goal = this.goals.get(sessionId); - if (!goal) return undefined; - if (!TERMINAL_GOAL_STATUSES.has(goal.status)) { - goal.status = 'cleared'; - } - return goal; - } - - remove(sessionId: string): boolean { - return this.goals.delete(sessionId); - } - - dispose(): void { - this.goals.clear(); - } -} diff --git a/packages/runtime/src/goal-tools.ts b/packages/runtime/src/goal-tools.ts deleted file mode 100644 index 9491ab3bab..0000000000 --- a/packages/runtime/src/goal-tools.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * Goal tools — GoalSet / GoalClear / GoalStatus / GoalPause / GoalResume. - * - * Model-facing autonomous-execution controls. The agent can arm its own stop - * condition (GoalSet), and pause/resume/clear the loop. PascalCase names match - * the builtin tool family (Bash/Read/TaskCreate/Automation). - */ - -import { z } from 'zod'; -import type { MakaTool } from './tool-runtime.js'; -import type { GoalManager, GoalState } from './goal-state.js'; - -export const GOAL_SET_TOOL_NAME = 'GoalSet'; -export const GOAL_CLEAR_TOOL_NAME = 'GoalClear'; -export const GOAL_STATUS_TOOL_NAME = 'GoalStatus'; -export const GOAL_PAUSE_TOOL_NAME = 'GoalPause'; -export const GOAL_RESUME_TOOL_NAME = 'GoalResume'; - -export interface GoalToolsDeps { - goalManager: GoalManager; - /** Current cumulative token count for a session (baseline for budget). */ - getTokenCount?: (sessionId: string) => number; - now?: () => number; -} - -export function buildGoalTools(deps: GoalToolsDeps): MakaTool[] { - return [ - buildGoalSetTool(deps), - buildGoalClearTool(deps), - buildGoalStatusTool(deps), - buildGoalPauseTool(deps), - buildGoalResumeTool(deps), - ]; -} - -function buildGoalSetTool(deps: GoalToolsDeps): MakaTool<{ - condition: string; - max_iterations?: number; - block_cap?: number; - token_budget?: number; -}, string> { - return { - name: GOAL_SET_TOOL_NAME, - displayName: 'Goal Set', - description: - 'Set an autonomous execution goal. After each turn an evaluator judges progress; ' - + 'if the condition is not met the system continues working turn after turn until it is ' - + 'met, deemed impossible, stalls, or hits a limit. Only one goal is active per session; ' - + 'setting a new one replaces the previous.', - parameters: z.object({ - condition: z.string().trim().min(1).max(500) - .describe('The objective to achieve. Should be observable and verifiable (e.g. "all tests in packages/runtime pass", "PR #522 review comments addressed").'), - max_iterations: z.number().int().min(1).max(200).optional() - .describe('Absolute ceiling on total turns before giving up. Defaults to 50.'), - block_cap: z.number().int().min(1).max(50).optional() - .describe('Stop after this many consecutive turns with no progress (stall detection). Defaults to 8.'), - token_budget: z.number().int().min(1000).optional() - .describe('Optional token budget; the goal stops (budget_limited) once this many tokens are spent working toward it.'), - }), - permissionRequired: false, - impl: (input, ctx) => { - const tokensAtStart = deps.getTokenCount?.(ctx.sessionId) ?? 0; - const goal = deps.goalManager.set(ctx.sessionId, input.condition, { - maxIterations: input.max_iterations, - blockCap: input.block_cap, - tokenBudget: input.token_budget, - tokensAtStart, - }); - const limits = [ - `max ${goal.maxIterations} turns`, - `stall after ${goal.blockCap} no-progress turns`, - goal.tokenBudget ? `budget ${goal.tokenBudget} tokens` : undefined, - ].filter(Boolean).join(', '); - return `Goal set: "${goal.condition}" (${limits}). ` - + 'The system will evaluate progress after each turn and continue autonomously until the condition is met.'; - }, - }; -} - -function buildGoalClearTool(deps: GoalToolsDeps): MakaTool, string> { - return { - name: GOAL_CLEAR_TOOL_NAME, - displayName: 'Goal Clear', - description: 'Clear the active goal, stopping autonomous execution after the current turn.', - parameters: z.object({}), - permissionRequired: false, - impl: (_input, ctx) => { - const goal = deps.goalManager.clear(ctx.sessionId); - if (!goal) return 'No active goal to clear.'; - return `Goal cleared: "${goal.condition}" after ${goal.iterations} turn(s).`; - }, - }; -} - -function buildGoalPauseTool(deps: GoalToolsDeps): MakaTool, string> { - return { - name: GOAL_PAUSE_TOOL_NAME, - displayName: 'Goal Pause', - description: 'Pause the active goal. Autonomous continuation stops until GoalResume is called; state is preserved.', - parameters: z.object({}), - permissionRequired: false, - impl: (_input, ctx) => { - const goal = deps.goalManager.pause(ctx.sessionId); - if (!goal) return 'No active goal to pause.'; - return `Goal paused: "${goal.condition}" at turn ${goal.iterations}. Use GoalResume to continue.`; - }, - }; -} - -function buildGoalResumeTool(deps: GoalToolsDeps): MakaTool, string> { - return { - name: GOAL_RESUME_TOOL_NAME, - displayName: 'Goal Resume', - description: 'Resume a paused goal, re-enabling autonomous continuation.', - parameters: z.object({}), - permissionRequired: false, - impl: (_input, ctx) => { - const goal = deps.goalManager.resume(ctx.sessionId); - if (!goal) return 'No paused goal to resume.'; - return `Goal resumed: "${goal.condition}". Autonomous continuation re-enabled.`; - }, - }; -} - -function buildGoalStatusTool(deps: GoalToolsDeps): MakaTool, string> { - return { - name: GOAL_STATUS_TOOL_NAME, - displayName: 'Goal Status', - description: 'Check the current goal status for this session.', - parameters: z.object({}), - permissionRequired: false, - impl: (_input, ctx) => { - const goal = deps.goalManager.get(ctx.sessionId); - if (!goal) return 'No goal set for this session.'; - return formatGoal(goal, deps); - }, - }; -} - -function formatGoal(goal: GoalState, deps: GoalToolsDeps): string { - const now = deps.now?.() ?? Date.now(); - const elapsed = Math.round((now - goal.setAt) / 1000); - const spent = Math.max(0, goal.tokensNow - goal.tokensAtStart); - const lines = [ - `Goal: "${goal.condition}"`, - `Status: ${goal.status}`, - `Turns: ${goal.iterations}/${goal.maxIterations}`, - `No-progress streak: ${goal.consecutiveNoProgress}/${goal.blockCap}`, - `Elapsed: ${elapsed}s`, - ]; - if (goal.tokenBudget) lines.push(`Tokens: ${spent}/${goal.tokenBudget}`); - else if (spent > 0) lines.push(`Tokens spent: ${spent}`); - if (goal.lastReason) lines.push(`Last evaluation: ${goal.lastReason}`); - return lines.join('\n'); -} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 0e9e8873f4..814c8d1484 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -645,27 +645,3 @@ export { AutomationScheduler, FIRE_CHECK_INTERVAL_MS, MAX_DEFER_RETRIES } from ' export type { AutomationSchedulerDeps, AutomationFireResult } from './automation-scheduler.js'; export { buildAutomationTool, AUTOMATION_TOOL_NAME } from './automation-tools.js'; export type { AutomationToolDeps } from './automation-tools.js'; - -// ─────────────────────────────────────────────────────────────────────────── -// Goal execution (Issue #15 Primitive 6). -// ─────────────────────────────────────────────────────────────────────────── -export { GoalManager, TERMINAL_GOAL_STATUSES, DEFAULT_MAX_ITERATIONS, DEFAULT_BLOCK_CAP } from './goal-state.js'; -export type { GoalState, GoalStatus, GoalManagerDeps } from './goal-state.js'; -export { - evaluateGoal, - buildGoalEvaluationPrompt, - parseGoalEvaluation, - DEFAULT_EVALUATOR_TIMEOUT_MS, -} from './goal-evaluator.js'; -export type { GoalEvaluation, GoalEvaluatorDeps } from './goal-evaluator.js'; -export { - buildGoalTools, - GOAL_SET_TOOL_NAME, - GOAL_CLEAR_TOOL_NAME, - GOAL_STATUS_TOOL_NAME, - GOAL_PAUSE_TOOL_NAME, - GOAL_RESUME_TOOL_NAME, -} from './goal-tools.js'; -export type { GoalToolsDeps } from './goal-tools.js'; -export { handleGoalContinuation } from './goal-continuation.js'; -export type { GoalContinuationDeps, GoalContinuationOutcome } from './goal-continuation.js'; From 74452b0864b879671168cbc80e41743a247a64b9 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Wed, 8 Jul 2026 01:46:18 +0800 Subject: [PATCH 14/23] =?UTF-8?q?fix(runtime):=20durable=20automations=20a?= =?UTF-8?q?re=20app-global=20=E2=80=94=20queryable=20+=20manageable=20acro?= =?UTF-8?q?ss=20sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persisted crons reload from disk under their original sessionId, but list / pause / resume / delete were session-scoped, so after a restart a fresh session could not see or manage them — persistence without query. Durable automations are now app-global: listVisibleForSession surfaces this session's own plus every durable one, and pause/resume/delete accept a durable target from any session. Non-durable heartbeats stay session-private; the per-session create cap still counts only session-owned automations. Adds cross-session unit tests and a real desktop e2e (createMainAutomationWiring + FileAutomationStore on temp disk): create durable cron -> persist -> restart -> fresh session lists/manages/deletes it, deletion re-persisted. Verified end-to-end through the real Maka chain (runtime.sendMessage + real LLM): natural phrasing creates a durable cron that a brand-new session lists after restart. --- .../automation-persistence-e2e.test.ts | 165 ++++++++++++++++++ .../runtime/src/__tests__/automation.test.ts | 66 +++++++ packages/runtime/src/automation-state.ts | 22 ++- packages/runtime/src/automation-tools.ts | 7 +- 4 files changed, 255 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/automation-persistence-e2e.test.ts 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..e204c3cf22 --- /dev/null +++ b/apps/desktop/src/main/__tests__/automation-persistence-e2e.test.ts @@ -0,0 +1,165 @@ +/** + * 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 }), + }); +} + +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 }); + } + }); +}); diff --git a/packages/runtime/src/__tests__/automation.test.ts b/packages/runtime/src/__tests__/automation.test.ts index 1f6ecedbf1..260632ca32 100644 --- a/packages/runtime/src/__tests__/automation.test.ts +++ b/packages/runtime/src/__tests__/automation.test.ts @@ -176,6 +176,72 @@ describe('AutomationManager', () => { }); }); + 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(); diff --git a/packages/runtime/src/automation-state.ts b/packages/runtime/src/automation-state.ts index ad7dc3a0b4..c1a5bbfe7c 100644 --- a/packages/runtime/src/automation-state.ts +++ b/packages/runtime/src/automation-state.ts @@ -124,14 +124,14 @@ export class AutomationManager { delete(id: string, sessionId?: string): boolean { const automation = this.automations.get(id); if (!automation) return false; - if (sessionId && automation.sessionId !== sessionId) 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 || automation.sessionId !== sessionId) return undefined; + if (!automation || !this.manageableBy(automation, sessionId)) return undefined; if (automation.status !== 'active') return undefined; automation.status = 'paused'; automation.updatedAt = this.deps.now(); @@ -140,7 +140,7 @@ export class AutomationManager { resume(id: string, sessionId: string): AutomationDefinition | undefined { const automation = this.automations.get(id); - if (!automation || automation.sessionId !== sessionId) return undefined; + 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 @@ -161,6 +161,22 @@ export class AutomationManager { 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'); } diff --git a/packages/runtime/src/automation-tools.ts b/packages/runtime/src/automation-tools.ts index 72f39a23fc..731cc85c1b 100644 --- a/packages/runtime/src/automation-tools.ts +++ b/packages/runtime/src/automation-tools.ts @@ -184,7 +184,10 @@ function handleCreate( } function handleList(deps: AutomationToolDeps, sessionId: string): string { - const automations = deps.automationManager.listForSession(sessionId); + // 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'); @@ -192,7 +195,7 @@ function handleList(deps: AutomationToolDeps, sessionId: string): string { function formatAutomation(a: AutomationDefinition): string { const lines = [ - `[${a.status.toUpperCase()}] ${a.name} (${a.kind})`, + `[${a.status.toUpperCase()}] ${a.name} (${a.kind}${a.durable ? ', durable' : ''})`, ` ID: ${a.id}`, ` Schedule: ${describeSchedule(a.schedule)}`, ` Fires: ${a.fireCount}${a.maxFires ? `/${a.maxFires}` : ''}`, From 364c13d95525165c8c969b03385bcfb2345d4585 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Wed, 8 Jul 2026 02:05:17 +0800 Subject: [PATCH 15/23] wip(desktop): gate automation firing on incognito privacy mode --- apps/desktop/src/main/main.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 710775ba8a..f6764baa13 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -341,6 +341,13 @@ const taskLedgerStore = taskLedgerWiring.store; const automationWiring = createMainAutomationWiring({ workspaceRoot, async canFire(sessionId: string): Promise { + // Respect the workspace privacy mode. While incognito is active, automations + // must not fire — a heartbeat injects a turn and a cron spawns a fresh run, + // both of which send data to the model. This mirrors the plan-reminders + // privacy gate (plan-reminders-main.ts); the scheduler defers while blocked, + // then skips to the next occurrence if incognito outlasts the defer window. + const { incognitoActive } = await getWorkspacePrivacyContext(); + if (incognitoActive) return false; const header = await store.readHeader(sessionId); if (!header || header.archivedAt) return false; // Only fire into a genuinely idle session — not mid-turn, blocked, aborted, From 67d247851a67f9c92e1c584a15704398f495b59a Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Wed, 8 Jul 2026 02:30:08 +0800 Subject: [PATCH 16/23] fix(runtime,desktop,cli): decouple cron firing from creator session + lifecycle hardening (adversarial review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of 3 P1s: canFire gated EVERY automation on its creator session's state, but a cron spawns a FRESH session — so archiving/deleting the creating conversation (or losing it across a restart) permanently stopped a durable cron from ever firing, then it silently expired. canFire is now kind-aware (receives the automation): cron is gated only on the global privacy (incognito) check; heartbeat still requires its own session to exist and be idle. The desktop gate is extracted to a pure, unit-tested evaluateAutomationCanFire. Also from the review: - heartbeat is now ALWAYS session-bound (durable is a cron-only concept) — a durable heartbeat was a post-restart zombie and evaded persistence on removeAllForSession. - registerAll heals an interrupted fire (active + nextFireAt=null, budget not spent → re-armed) instead of leaving a silent zombie until expiry. - resume() resets consecutiveFailures/lastError so a resumed automation isn't re-paused after a single fresh failure. - CLI canFire gates incognito and is kind-aware; CLI durable-sync no longer swallows disk-write errors silently. - desktop canFire drops the dead waiting_for_user branch and no longer throws when a heartbeat's session file is gone. Tests: runtime automation 96/0, cli 118/0, desktop canfire 9/0 + persistence e2e. New: evaluateAutomationCanFire gate, registerAll recovery, resume streak. --- .../main/__tests__/automation-canfire.test.ts | 71 +++++++++++++++++++ apps/desktop/src/main/automation-wiring.ts | 40 ++++++++++- apps/desktop/src/main/main.ts | 26 +++---- packages/cli/src/runtime-bootstrap.ts | 15 +++- .../__tests__/automation-integration.test.ts | 25 ++++--- .../automation-mutation-verify.test.ts | 5 +- .../runtime/src/__tests__/automation.test.ts | 63 +++++++++++++++- packages/runtime/src/automation-scheduler.ts | 11 ++- packages/runtime/src/automation-state.ts | 26 ++++++- 9 files changed, 244 insertions(+), 38 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/automation-canfire.test.ts 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/automation-wiring.ts b/apps/desktop/src/main/automation-wiring.ts index dac6c701a5..1b67583514 100644 --- a/apps/desktop/src/main/automation-wiring.ts +++ b/apps/desktop/src/main/automation-wiring.ts @@ -15,13 +15,51 @@ export interface MainAutomationWiring { export interface CreateMainAutomationWiringDeps { workspaceRoot: string; - canFire: (sessionId: string) => Promise; + 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; } +/** 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). */ + 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 without Electron/disk. + */ +export async function evaluateAutomationCanFire( + automation: Pick, + deps: EvaluateAutomationCanFireDeps, +): Promise { + if (await deps.isIncognitoActive()) return false; + if (automation.kind === 'cron') return true; + 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 deps.idleStatuses.has(header.status); +} + export function createMainAutomationWiring(deps: CreateMainAutomationWiringDeps): MainAutomationWiring { const manager = new AutomationManager({ generateId: () => randomUUID(), diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index f6764baa13..6fa63d24e2 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -177,7 +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 } from './automation-wiring.js'; +import { createMainAutomationWiring, evaluateAutomationCanFire } from './automation-wiring.js'; import { createOAuthModelConnectionsMainService } from './oauth-model-connections-main.js'; import { applyNetworkPatch, @@ -338,23 +338,17 @@ 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 AUTOMATION_HEARTBEAT_IDLE_STATUSES: ReadonlySet = new Set(['active', 'done']); const automationWiring = createMainAutomationWiring({ workspaceRoot, - async canFire(sessionId: string): Promise { - // Respect the workspace privacy mode. While incognito is active, automations - // must not fire — a heartbeat injects a turn and a cron spawns a fresh run, - // both of which send data to the model. This mirrors the plan-reminders - // privacy gate (plan-reminders-main.ts); the scheduler defers while blocked, - // then skips to the next occurrence if incognito outlasts the defer window. - const { incognitoActive } = await getWorkspacePrivacyContext(); - if (incognitoActive) return false; - const header = await store.readHeader(sessionId); - if (!header || header.archivedAt) return false; - // Only fire into a genuinely idle session — not mid-turn, blocked, aborted, - // waiting on the user, or already settled/under review. - if (header.status !== 'active' && header.status !== 'waiting_for_user' && header.status !== 'done') return false; - if (header.status === 'waiting_for_user') return false; - return true; + 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), + idleStatuses: AUTOMATION_HEARTBEAT_IDLE_STATUSES, + }); }, // Heartbeat: inject into the automation's own session; resolve after the stream. async injectTurn(sessionId: string, prompt: string, automationId: string) { diff --git a/packages/cli/src/runtime-bootstrap.ts b/packages/cli/src/runtime-bootstrap.ts index 49f9ddbb5c..baf6c74ed0 100644 --- a/packages/cli/src/runtime-bootstrap.ts +++ b/packages/cli/src/runtime-bootstrap.ts @@ -100,7 +100,9 @@ export async function createMakaCliRuntimeContext( const automationStore = createAutomationStore(input.workspaceRoot); const syncAutomations = (): void => { const durable = automationManager.listAll().filter(a => a.durable && (a.status === 'active' || a.status === 'paused')); - automationStore.sync(durable).catch(() => {}); + automationStore.sync(durable).catch(err => { + console.warn('[runtime-bootstrap] failed to persist durable automations:', err); + }); }; const automationTool = buildAutomationTool({ automationManager, @@ -188,8 +190,15 @@ export async function createMakaCliRuntimeContext( const automationScheduler = new AutomationScheduler({ automationManager, - canFire: async (sessionId) => { - const header = await store.readHeader(sessionId); + canFire: async (automation) => { + // Global privacy gate (shared settings schema): don't fire under incognito. + const settings = await settingsStore.get(); + if (settings.privacy?.incognitoActive === true) return false; + // The CLI enables heartbeat only (createFreshRun omitted); a cron short- + // circuits here so the scheduler reaches its "cron not configured" failure + // path instead of being gated on a session it never uses. + if (automation.kind === 'cron') return true; + const header = await store.readHeader(automation.sessionId); if (!header || header.archivedAt) return false; if (header.status === 'running' || header.status === 'blocked' || header.status === 'aborted') return false; return true; diff --git a/packages/runtime/src/__tests__/automation-integration.test.ts b/packages/runtime/src/__tests__/automation-integration.test.ts index ed81d9217a..df2fa60781 100644 --- a/packages/runtime/src/__tests__/automation-integration.test.ts +++ b/packages/runtime/src/__tests__/automation-integration.test.ts @@ -109,23 +109,32 @@ describe('Automation integration: heartbeat fires on schedule', () => { }); describe('Automation integration: durable flag', () => { - test('create durable automation, verify flag is set', async () => { + test('cron is durable; a durable heartbeat is coerced to session-bound', async () => { const t = createIntegrationSetup(); const ctx = t.ctx(); - const result = await t.tool.impl({ + // Cron is durable by default (app-global, survives restart). + const cron = await t.tool.impl({ mode: 'create', - kind: 'heartbeat', + 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(result.includes('durable')); - - const automations = t.manager.listForSession(SESSION_ID); - assert.equal(automations[0].durable, true); + assert.ok(!t.manager.listForSession(SESSION_ID).find(a => a.name === 'session poll')?.durable); }); test('onAutomationChange fires on create/delete', async () => { diff --git a/packages/runtime/src/__tests__/automation-mutation-verify.test.ts b/packages/runtime/src/__tests__/automation-mutation-verify.test.ts index 963310f581..fe961c2100 100644 --- a/packages/runtime/src/__tests__/automation-mutation-verify.test.ts +++ b/packages/runtime/src/__tests__/automation-mutation-verify.test.ts @@ -127,9 +127,8 @@ describe('Mutation verification: tests catch broken behavior', () => { const manager = new AutomationManager({ generateId: () => `m-${++idCounter}`, now: () => Date.now() }); const auto = manager.create({ - kind: 'heartbeat', name: 'persist', prompt: 'p', sessionId: SESSION_ID, - schedule: { type: 'interval', seconds: 60 }, - durable: true, + 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.test.ts b/packages/runtime/src/__tests__/automation.test.ts index 260632ca32..071c7175c8 100644 --- a/packages/runtime/src/__tests__/automation.test.ts +++ b/packages/runtime/src/__tests__/automation.test.ts @@ -134,7 +134,7 @@ describe('AutomationManager', () => { assert.ok(!result.durable); }); - test('explicit durable overrides the per-kind default', () => { + test('explicit durable refines cron; heartbeat is always session-bound', () => { const mgr = createManager(); const cron = mgr.create({ kind: 'cron', name: 'ephemeral-cron', prompt: 'p', @@ -143,13 +143,14 @@ describe('AutomationManager', () => { }); 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.equal(beat.durable, true); + assert.ok(!beat.durable); }); }); @@ -450,6 +451,64 @@ describe('AutomationManager', () => { }); }); + 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('heals an interrupted fire: active + nextFireAt=null gets re-armed', () => { + // App quit mid-run after attemptStarted nulled nextFireAt but before the + // outcome settled → persisted as active with nextFireAt=null. + const healed = load(createManager(), { status: 'active', nextFireAt: null, fireCount: 1 }); + assert.ok(healed?.nextFireAt, 'interrupted active automation should be re-armed on load'); + }); + + test('does NOT re-arm a spent maxFires automation on load', () => { + const kept = load(createManager(), { status: 'active', nextFireAt: null, fireCount: 3, maxFires: 3 }); + assert.equal(kept?.nextFireAt, null); + }); + + test('does NOT re-arm a once automation that already fired', () => { + const kept = load(createManager(), { + status: 'active', nextFireAt: null, fireCount: 1, + schedule: { type: 'once', delaySeconds: 30 }, + }); + assert.equal(kept?.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('dispose', () => { test('clears all automations', () => { const mgr = createManager(); diff --git a/packages/runtime/src/automation-scheduler.ts b/packages/runtime/src/automation-scheduler.ts index 0779e9094a..9ec249cb8a 100644 --- a/packages/runtime/src/automation-scheduler.ts +++ b/packages/runtime/src/automation-scheduler.ts @@ -24,7 +24,14 @@ export interface AutomationFireResult { export interface AutomationSchedulerDeps { automationManager: AutomationManager; - canFire: (sessionId: string) => Promise; + /** + * 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. @@ -129,7 +136,7 @@ export class AutomationScheduler { let canFire: boolean; try { - canFire = await this.deps.canFire(automation.sessionId); + canFire = await this.deps.canFire(automation); } catch { // canFire failure: skip this automation this tick, don't crash the loop. return; diff --git a/packages/runtime/src/automation-state.ts b/packages/runtime/src/automation-state.ts index c1a5bbfe7c..b1daa48c71 100644 --- a/packages/runtime/src/automation-state.ts +++ b/packages/runtime/src/automation-state.ts @@ -87,9 +87,11 @@ export class AutomationManager { // Cron is a standalone scheduled task (fresh session each run) — it is // meaningless if it dies on restart, so it defaults to durable. Heartbeat - // resumes into its creator session, whose lifetime bounds it, so it stays - // opt-in. An explicit `durable` value always wins. - const durable = input.durable ?? input.kind === 'cron'; + // 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, @@ -153,6 +155,10 @@ export class AutomationManager { 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; } @@ -313,7 +319,21 @@ export class AutomationManager { /** 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) { + // Heal an interrupted fire: a fire that started (fireCount bumped, + // nextFireAt nulled) but whose run never settled — because the app quit + // mid-run — persists as active with nextFireAt=null. Left alone it is a + // silent zombie (never fires again until expiry). If its budget isn't + // spent, re-arm it so it fires again. + if ( + automation.status === 'active' && + automation.nextFireAt === null && + !(automation.maxFires != null && automation.fireCount >= automation.maxFires) && + !(automation.schedule.type === 'once' && automation.fireCount > 0) + ) { + automation.nextFireAt = this.computeNextFire(automation.schedule, now); + } this.automations.set(automation.id, automation); } } From 8808d69ab4562c0f87704bd51018b8b62e865254 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Wed, 8 Jul 2026 02:33:13 +0800 Subject: [PATCH 17/23] =?UTF-8?q?test(desktop):=20e2e=20=E2=80=94=20durabl?= =?UTF-8?q?e=20cron=20fires=20after=20creator=20session=20archived;=20hear?= =?UTF-8?q?tbeat=20+=20incognito=20gated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ties the P1 fix through the real AutomationManager + AutomationScheduler + the kind-aware evaluateAutomationCanFire gate (injectable timers, no Electron): a durable cron still fires when its creating conversation is archived, while a heartbeat in that same archived session does not, and incognito blocks both. --- .../automation-cron-lifecycle.test.ts | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 apps/desktop/src/main/__tests__/automation-cron-lifecycle.test.ts 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(); + }); +}); From 2c99f1bc22b3581db055b438264758e7231aad0a Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Wed, 8 Jul 2026 14:04:45 +0800 Subject: [PATCH 18/23] =?UTF-8?q?fix(runtime,cli):=20round-2=20review=20?= =?UTF-8?q?=E2=80=94=20no=20shared-store=20corruption,=20real=20recovery,?= =?UTF-8?q?=20no=20once=20drift?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1 regression: a heartbeat-only host (CLI) sharing the desktop workspace store paused the desktop's durable crons. The cron-without-executor branch called attemptFailed (never advancing nextFireAt) → a tight failure loop that persisted paused state to the shared automations.json. The scheduler now SILENTLY IGNORES a cron when no createFreshRun is configured — no fail, pause, advance, or emit — leaving shared durable state untouched for a host that can run it. - registerAll recovery was inert (it excluded the only naturally-reachable interrupted state). It now settles an interrupted spent-budget fire (once fired / at maxFires, active + nextFireAt=null) to 'completed' (at-most-once, no re-run), and re-arms only a corrupt recurring null. - skipFire on a one-shot settled it via computeNextFire, re-adding the full delay → drift + silent loss under sustained defer (busy/incognito). It now settles a skipped once to 'expired' with a reason instead of drifting. - The kind-aware fire gate moved to @maka/runtime (evaluateAutomationCanFire + HEARTBEAT_IDLE_STATUSES) so desktop and CLI share ONE idle-status definition; the CLI no longer fires a heartbeat into 'waiting_for_user'/'review' sessions. Tests: runtime automation 98/0, cli 118/0, desktop automation 14/0. New: skipFire once-terminal + recurring-advance, registerAll settle-to-completed. --- apps/desktop/src/main/automation-wiring.ts | 43 ++------------ apps/desktop/src/main/main.ts | 2 - packages/cli/src/runtime-bootstrap.ts | 22 +++---- .../__tests__/automation-scheduler.test.ts | 14 +++-- .../runtime/src/__tests__/automation.test.ts | 57 +++++++++++++++---- packages/runtime/src/automation-can-fire.ts | 52 +++++++++++++++++ packages/runtime/src/automation-scheduler.ts | 15 ++--- packages/runtime/src/automation-state.ts | 41 ++++++++----- packages/runtime/src/index.ts | 2 + 9 files changed, 161 insertions(+), 87 deletions(-) create mode 100644 packages/runtime/src/automation-can-fire.ts diff --git a/apps/desktop/src/main/automation-wiring.ts b/apps/desktop/src/main/automation-wiring.ts index 1b67583514..3f15e7bb33 100644 --- a/apps/desktop/src/main/automation-wiring.ts +++ b/apps/desktop/src/main/automation-wiring.ts @@ -2,6 +2,11 @@ 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. */ @@ -22,44 +27,6 @@ export interface CreateMainAutomationWiringDeps { createFreshRun?: (prompt: string, automationId: string) => Promise; } -/** 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). */ - 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 without Electron/disk. - */ -export async function evaluateAutomationCanFire( - automation: Pick, - deps: EvaluateAutomationCanFireDeps, -): Promise { - if (await deps.isIncognitoActive()) return false; - if (automation.kind === 'cron') return true; - 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 deps.idleStatuses.has(header.status); -} - export function createMainAutomationWiring(deps: CreateMainAutomationWiringDeps): MainAutomationWiring { const manager = new AutomationManager({ generateId: () => randomUUID(), diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 6fa63d24e2..a56e25444f 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -338,7 +338,6 @@ 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 AUTOMATION_HEARTBEAT_IDLE_STATUSES: ReadonlySet = new Set(['active', 'done']); const automationWiring = createMainAutomationWiring({ workspaceRoot, async canFire(automation): Promise { @@ -347,7 +346,6 @@ const automationWiring = createMainAutomationWiring({ return evaluateAutomationCanFire(automation, { isIncognitoActive: async () => (await getWorkspacePrivacyContext()).incognitoActive, readSessionHeader: (sessionId) => store.readHeader(sessionId), - idleStatuses: AUTOMATION_HEARTBEAT_IDLE_STATUSES, }); }, // Heartbeat: inject into the automation's own session; resolve after the stream. diff --git a/packages/cli/src/runtime-bootstrap.ts b/packages/cli/src/runtime-bootstrap.ts index baf6c74ed0..ae2390886e 100644 --- a/packages/cli/src/runtime-bootstrap.ts +++ b/packages/cli/src/runtime-bootstrap.ts @@ -15,6 +15,7 @@ import { buildLlmHistorySummarizer, buildProviderOptions, buildSubscriptionModelFetch, + evaluateAutomationCanFire, getAIModel, loadHistoryCompactBlocksFromArtifacts, persistHistoryCompactBlocksToArtifacts, @@ -190,19 +191,14 @@ export async function createMakaCliRuntimeContext( const automationScheduler = new AutomationScheduler({ automationManager, - canFire: async (automation) => { - // Global privacy gate (shared settings schema): don't fire under incognito. - const settings = await settingsStore.get(); - if (settings.privacy?.incognitoActive === true) return false; - // The CLI enables heartbeat only (createFreshRun omitted); a cron short- - // circuits here so the scheduler reaches its "cron not configured" failure - // path instead of being gated on a session it never uses. - if (automation.kind === 'cron') return true; - const header = await store.readHeader(automation.sessionId); - if (!header || header.archivedAt) return false; - if (header.status === 'running' || header.status === 'blocked' || header.status === 'aborted') return false; - return true; - }, + 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. diff --git a/packages/runtime/src/__tests__/automation-scheduler.test.ts b/packages/runtime/src/__tests__/automation-scheduler.test.ts index a38750dcca..87537d2fe7 100644 --- a/packages/runtime/src/__tests__/automation-scheduler.test.ts +++ b/packages/runtime/src/__tests__/automation-scheduler.test.ts @@ -238,7 +238,7 @@ describe('AutomationScheduler', () => { assert.equal(t.manager.get(auto.id)?.lastRunId, 'fresh-1'); }); - test('cron marks failure when createFreshRun is not provided (does not advance)', async () => { + 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', @@ -246,15 +246,21 @@ describe('AutomationScheduler', () => { }); 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); - assert.equal(updated?.consecutiveFailures, 1); - assert.ok(updated?.lastError?.includes('not configured')); - // The fire did not "start" (no fresh executor) — fireCount unchanged. + // 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 () => { diff --git a/packages/runtime/src/__tests__/automation.test.ts b/packages/runtime/src/__tests__/automation.test.ts index 071c7175c8..7172fb8164 100644 --- a/packages/runtime/src/__tests__/automation.test.ts +++ b/packages/runtime/src/__tests__/automation.test.ts @@ -463,24 +463,27 @@ describe('AutomationManager', () => { return mgr.get('loaded'); } - test('heals an interrupted fire: active + nextFireAt=null gets re-armed', () => { - // App quit mid-run after attemptStarted nulled nextFireAt but before the - // outcome settled → persisted as active with nextFireAt=null. + 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, 'interrupted active automation should be re-armed on load'); + assert.ok(healed?.nextFireAt, 'corrupt recurring automation should be re-armed on load'); + assert.equal(healed?.status, 'active'); }); - test('does NOT re-arm a spent maxFires automation on load', () => { - const kept = load(createManager(), { status: 'active', nextFireAt: null, fireCount: 3, maxFires: 3 }); - assert.equal(kept?.nextFireAt, null); + 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); }); - test('does NOT re-arm a once automation that already fired', () => { - const kept = load(createManager(), { + 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(kept?.nextFireAt, null); + assert.equal(settled?.status, 'completed'); + assert.equal(settled?.nextFireAt, null); }); test('leaves a normally-scheduled automation untouched', () => { @@ -509,6 +512,40 @@ describe('AutomationManager', () => { }); }); + 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(); 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 index 9ec249cb8a..2959e20ec7 100644 --- a/packages/runtime/src/automation-scheduler.ts +++ b/packages/runtime/src/automation-scheduler.ts @@ -125,6 +125,14 @@ export class AutomationScheduler { 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 @@ -161,13 +169,6 @@ export class AutomationScheduler { this.deferCounts.delete(automation.id); - // Cron without an executor cannot run — fail fast, do not advance the fire. - if (automation.kind === 'cron' && !this.deps.createFreshRun) { - this.deps.automationManager.attemptFailed(automation.id, 'Cron execution not configured (createFreshRun unavailable)'); - this.deps.onStateChange?.(); - return; - } - const started = this.deps.automationManager.attemptStarted(automation.id); if (!started) { this.deps.onStateChange?.(); diff --git a/packages/runtime/src/automation-state.ts b/packages/runtime/src/automation-state.ts index b1daa48c71..22e74dd34c 100644 --- a/packages/runtime/src/automation-state.ts +++ b/packages/runtime/src/automation-state.ts @@ -254,8 +254,18 @@ export class AutomationManager { const automation = this.automations.get(id); if (!automation || automation.status !== 'active') return; const now = this.deps.now(); - automation.nextFireAt = this.computeNextFire(automation.schedule, 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); } /** @@ -321,18 +331,23 @@ export class AutomationManager { registerAll(automations: AutomationDefinition[]): void { const now = this.deps.now(); for (const automation of automations) { - // Heal an interrupted fire: a fire that started (fireCount bumped, - // nextFireAt nulled) but whose run never settled — because the app quit - // mid-run — persists as active with nextFireAt=null. Left alone it is a - // silent zombie (never fires again until expiry). If its budget isn't - // spent, re-arm it so it fires again. - if ( - automation.status === 'active' && - automation.nextFireAt === null && - !(automation.maxFires != null && automation.fireCount >= automation.maxFires) && - !(automation.schedule.type === 'once' && automation.fireCount > 0) - ) { - automation.nextFireAt = this.computeNextFire(automation.schedule, now); + // 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). + automation.status = 'completed'; + } 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); } diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 814c8d1484..999f6bda17 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -645,3 +645,5 @@ export { AutomationScheduler, FIRE_CHECK_INTERVAL_MS, MAX_DEFER_RETRIES } from ' 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'; From bb556c60a55c2205fc4b5d54fc6a94055f0d9a27 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Wed, 8 Jul 2026 14:26:40 +0800 Subject: [PATCH 19/23] =?UTF-8?q?fix(runtime):=20round-3=20review=20?= =?UTF-8?q?=E2=80=94=20sweep=20honours=20cron-untouched=20invariant;=20int?= =?UTF-8?q?errupted=20fire=20records=20its=20unknown=20outcome?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two P3s from round-3 (converged from round-2's P1+7): - The eager expiry sweep mutated+persisted crons even on a host without a cron executor, bypassing the 'leave crons untouched' invariant that attemptFire enforces — on a shared workspace a stale heartbeat-only CLI could expire and drop the desktop's cron from automations.json. The sweep now skips crons when createFreshRun is absent, mirroring attemptFire. - registerAll settled an interrupted (crash mid-run) fire to a clean 'completed' with no error, indistinguishable from a real success even though the run's outcome was never committed. It now records lastError='Interrupted on restart ... not re-run.' so the unknown outcome is surfaced (no silent unknown state). Tests: runtime automation 99/0, cli 137/0, desktop automation 14/0. New: sweep-skips-cron-on-cron-disabled-host + settle-records-uncertainty. --- .../__tests__/automation-scheduler.test.ts | 25 +++++++++++++++++++ .../runtime/src/__tests__/automation.test.ts | 2 ++ packages/runtime/src/automation-scheduler.ts | 6 +++++ packages/runtime/src/automation-state.ts | 3 +++ 4 files changed, 36 insertions(+) diff --git a/packages/runtime/src/__tests__/automation-scheduler.test.ts b/packages/runtime/src/__tests__/automation-scheduler.test.ts index 87537d2fe7..6519e862a9 100644 --- a/packages/runtime/src/__tests__/automation-scheduler.test.ts +++ b/packages/runtime/src/__tests__/automation-scheduler.test.ts @@ -278,6 +278,31 @@ describe('AutomationScheduler', () => { 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({ diff --git a/packages/runtime/src/__tests__/automation.test.ts b/packages/runtime/src/__tests__/automation.test.ts index 7172fb8164..2371607dba 100644 --- a/packages/runtime/src/__tests__/automation.test.ts +++ b/packages/runtime/src/__tests__/automation.test.ts @@ -475,6 +475,8 @@ describe('AutomationManager', () => { 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)', () => { diff --git a/packages/runtime/src/automation-scheduler.ts b/packages/runtime/src/automation-scheduler.ts index 2959e20ec7..54cc3b1122 100644 --- a/packages/runtime/src/automation-scheduler.ts +++ b/packages/runtime/src/automation-scheduler.ts @@ -107,6 +107,12 @@ export class AutomationScheduler { // 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; } diff --git a/packages/runtime/src/automation-state.ts b/packages/runtime/src/automation-state.ts index 22e74dd34c..c1c6c1427a 100644 --- a/packages/runtime/src/automation-state.ts +++ b/packages/runtime/src/automation-state.ts @@ -342,7 +342,10 @@ export class AutomationManager { 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. From b1a1a1ea4e12b894c1dfbae50036b443b77866cf Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Wed, 8 Jul 2026 15:18:58 +0800 Subject: [PATCH 20/23] fix(cli,desktop): a cron-disabled host must not persist/adopt durable automations (round-4 P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI shares the desktop's workspace by design (resolveMakaWorkspaceRoot reconstructs the Electron userData path), so its automations.json IS the desktop's. store.sync() is a full-file overwrite. Two P1 data-loss paths: - The heartbeat-only CLI has NO durable automations of its own (heartbeats are never durable), yet syncAutomations wrote its empty/stale durable list over the shared file, ERASING the desktop's crons. - loadDurableAutomations + registerAll adopted+reconciled crons the CLI can't run; the round-3 settle-to-completed then dropped them on the next sync. Root-cause fix: durable persistence is now gated on cron capability. A host without createFreshRun neither loads nor writes the durable store — it leaves that state entirely to the host that owns it. Applied symmetrically in the CLI (runtime-bootstrap) and desktop (automation-wiring). Two cron-enabled hosts sharing a store remains the separate, deferred leader-lock (G6). Tests: runtime automation 99/0, cli 137/0, desktop automation 21/0. New e2e: a cron-disabled host boots on a shared workspace, does heartbeat activity, and the owner's durable cron stays intact on disk. --- .../automation-persistence-e2e.test.ts | 52 +++++++++++++++++++ apps/desktop/src/main/automation-wiring.ts | 23 +++++--- packages/cli/src/runtime-bootstrap.ts | 40 +++++++++----- 3 files changed, 97 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/main/__tests__/automation-persistence-e2e.test.ts b/apps/desktop/src/main/__tests__/automation-persistence-e2e.test.ts index e204c3cf22..f17fb28783 100644 --- a/apps/desktop/src/main/__tests__/automation-persistence-e2e.test.ts +++ b/apps/desktop/src/main/__tests__/automation-persistence-e2e.test.ts @@ -45,6 +45,16 @@ function makeWiring(workspaceRoot: string) { }); } +/** 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]; } @@ -163,3 +173,45 @@ describe('E2E: durable cron persistence + cross-session query/management', () => } }); }); + +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 index 3f15e7bb33..df2f73ce5c 100644 --- a/apps/desktop/src/main/automation-wiring.ts +++ b/apps/desktop/src/main/automation-wiring.ts @@ -35,12 +35,22 @@ export function createMainAutomationWiring(deps: CreateMainAutomationWiringDeps) const store = createAutomationStore(deps.workspaceRoot); - const syncDurableToStore = (): void => { - 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); - }); - }; + // 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; + + const syncDurableToStore = cronEnabled + ? (): void => { + 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, @@ -60,6 +70,7 @@ export function createMainAutomationWiring(deps: CreateMainAutomationWiringDeps) })]; const loadDurableAutomations = async (): Promise => { + if (!cronEnabled) return; // a cron-disabled host must not adopt/reconcile crons it doesn't own const saved = await store.loadAll(); manager.registerAll(saved); }; diff --git a/packages/cli/src/runtime-bootstrap.ts b/packages/cli/src/runtime-bootstrap.ts index ae2390886e..27165225a9 100644 --- a/packages/cli/src/runtime-bootstrap.ts +++ b/packages/cli/src/runtime-bootstrap.ts @@ -98,26 +98,42 @@ export async function createMakaCliRuntimeContext( 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); - const syncAutomations = (): void => { - 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); - }); - }; + const syncAutomations = cronEnabled + ? (): void => { + 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: input.automationCreateFreshRun !== undefined, + cronEnabled, }); const allTools = [...tools, automationTool]; - // Load durable automations from disk. - try { - const saved = await automationStore.loadAll(); - automationManager.registerAll(saved); - } catch { /* best-effort */ } + // 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 { /* best-effort */ } + } backends.register('ai-sdk', async (ctx) => { const ready = await resolveDefaultSessionTarget({ From 25fa05f10f2cc1bb25ecfdac1d980472b49ef2f8 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Wed, 8 Jul 2026 15:43:02 +0800 Subject: [PATCH 21/23] fix(storage,runtime): store fails loud on unreadable data; cron validates in O(1) (round-5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pre-existing defects surfaced by round-5 (the round-4 clobber class was confirmed fully closed): - Store loadAll() masked a corrupt/unreadable automations.json as an empty store, so a subsequent full-overwrite sync would silently and permanently erase real durable crons (a transient EMFILE/EBUSY or a version-mismatch at startup was enough). loadAll now distinguishes ENOENT (legitimately empty) from a present-but-unreadable file, which it FAILS LOUD on. Both hosts catch that on load and DISABLE persistence (durableStoreReadable=false) so a later mutation can never overwrite data they failed to read. - computeNextCronFire scanned up to ~8 years (4.2M iterations) synchronously in the Electron main process for a schema-valid-but-unsatisfiable expression — a ~1s freeze easily triggered by common LLM output like '0 9 * * MON' (named tokens were unsupported) or an impossible date like '0 0 30 2 *'. It now normalizes+validates in O(1) first: translates named day/month tokens (MON-SUN, JAN-DEC), rejects out-of-range fields, and fast-fails impossible calendar dates (respecting Vixie dom/dow OR-semantics), before any scan. Tests: runtime automation 105/0 (+6 cron validation), cli 137/0, desktop automation 21/0, storage 10/0. Store corrupt/version tests now assert fail-loud. --- apps/desktop/src/main/automation-wiring.ts | 17 ++- packages/cli/src/runtime-bootstrap.ts | 9 +- .../runtime/src/__tests__/automation.test.ts | 55 ++++++++++ packages/runtime/src/automation-state.ts | 101 +++++++++++++++++- .../src/__tests__/automation-store.test.ts | 11 +- packages/storage/src/automation-store.ts | 28 +++-- 6 files changed, 199 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/main/automation-wiring.ts b/apps/desktop/src/main/automation-wiring.ts index df2f73ce5c..9603ff744a 100644 --- a/apps/desktop/src/main/automation-wiring.ts +++ b/apps/desktop/src/main/automation-wiring.ts @@ -43,8 +43,14 @@ export function createMainAutomationWiring(deps: CreateMainAutomationWiringDeps) // 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); @@ -71,8 +77,15 @@ export function createMainAutomationWiring(deps: CreateMainAutomationWiringDeps) const loadDurableAutomations = async (): Promise => { if (!cronEnabled) return; // a cron-disabled host must not adopt/reconcile crons it doesn't own - const saved = await store.loadAll(); - manager.registerAll(saved); + 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/packages/cli/src/runtime-bootstrap.ts b/packages/cli/src/runtime-bootstrap.ts index 27165225a9..99c91abc05 100644 --- a/packages/cli/src/runtime-bootstrap.ts +++ b/packages/cli/src/runtime-bootstrap.ts @@ -110,8 +110,12 @@ export async function createMakaCliRuntimeContext( // 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); @@ -132,7 +136,10 @@ export async function createMakaCliRuntimeContext( try { const saved = await automationStore.loadAll(); automationManager.registerAll(saved); - } catch { /* best-effort */ } + } 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) => { diff --git a/packages/runtime/src/__tests__/automation.test.ts b/packages/runtime/src/__tests__/automation.test.ts index 2371607dba..a87a179dee 100644 --- a/packages/runtime/src/__tests__/automation.test.ts +++ b/packages/runtime/src/__tests__/automation.test.ts @@ -569,6 +569,61 @@ describe('computeNextCronFire', () => { 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('specific time (9:30)', () => { const base = new Date('2026-07-06T08:00:00').getTime(); const next = computeNextCronFire('30 9 * * *', base); diff --git a/packages/runtime/src/automation-state.ts b/packages/runtime/src/automation-state.ts index c1c6c1427a..a28dd84d43 100644 --- a/packages/runtime/src/automation-state.ts +++ b/packages/runtime/src/automation-state.ts @@ -401,6 +401,95 @@ const MINUTES_PER_DAY = 24 * 60; */ 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 @@ -417,10 +506,14 @@ const MAX_SEARCH_MINUTES = 8 * 366 * MINUTES_PER_DAY; // ~8 years, bounded * intentionally out of scope for this parser. */ export function computeNextCronFire(expression: string, fromTime: number): number | null { - const fields = expression.trim().split(/\s+/); - if (fields.length !== 5) return null; - - const [minuteField, hourField, domField, monthField, dowField] = fields; + // 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 diff --git a/packages/storage/src/__tests__/automation-store.test.ts b/packages/storage/src/__tests__/automation-store.test.ts index 616940d70f..7270ccce2a 100644 --- a/packages/storage/src/__tests__/automation-store.test.ts +++ b/packages/storage/src/__tests__/automation-store.test.ts @@ -95,22 +95,21 @@ describe('AutomationStore', () => { assert.equal(result[1].id, 'new-2'); }); - test('loadAll handles corrupt file gracefully', async () => { + 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); - const result = await store.loadAll(); - assert.deepEqual(result, []); + // Returning [] here would let a subsequent full-overwrite sync erase real data. + await assert.rejects(() => store.loadAll(), /not valid JSON/); }); - test('loadAll handles wrong version gracefully', async () => { + 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); - const result = await store.loadAll(); - assert.deepEqual(result, []); + await assert.rejects(() => store.loadAll(), /unrecognized shape or version/); }); test('atomic write: file is not corrupted on concurrent saves', async () => { diff --git a/packages/storage/src/automation-store.ts b/packages/storage/src/automation-store.ts index 658e57c923..392ad79d07 100644 --- a/packages/storage/src/automation-store.ts +++ b/packages/storage/src/automation-store.ts @@ -35,19 +35,29 @@ class FileAutomationStore implements AutomationStore } async loadAll(): Promise { + let text: string; try { - const text = await readFile(this.filePath, 'utf8'); - const parsed = JSON.parse(text) as unknown; - if (!isAutomationFile(parsed)) { - console.warn('[automation-store] corrupt automations.json -- returning empty'); - return []; - } - return parsed.automations as T[]; + 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 []; - console.warn('[automation-store] failed to read automations.json -- returning empty:', error); - 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 { From 9363fa83ec86e2a127ea0fa6bf866cc2dde2f7e3 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Wed, 8 Jul 2026 16:10:42 +0800 Subject: [PATCH 22/23] =?UTF-8?q?fix(runtime):=20cron=20scan=20start=20use?= =?UTF-8?q?s=20epoch=20arithmetic=20=E2=80=94=20no=20DST=20fall-back=20re-?= =?UTF-8?q?fire=20storm=20(round-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit computeNextCronFire minute-aligned the scan start with Date.setSeconds(0,0), which round-trips the instant through local wall-clock. During a DST fall-back (the repeated local hour), V8 re-encodes the ambiguous time to the earlier offset, shifting the start ~59 min BEFORE fromTime — so the scan returned a candidate <= fromTime, violating the strictly-after contract. attemptStarted then re-armed nextFireAt to a past time, and checkAndFire re-fired every tick for the whole repeated hour: one daily cron became a storm of duplicate fresh sessions + LLM runs (annual, per DST zone). Now the start is computed in epoch arithmetic (fromTime - fromTime%60000 + 60000), which is offset-safe; candidate wall-clock fields are still read with local getters, so 'N am local' semantics are unchanged and results are byte-identical for all non-DST expressions. Empirically verified (TZ=America/New_York, '30 1 * * *' at 2026-11-01T06:30Z): was returning an equal/past time, now strictly after. Regression test runs the built module in a child process with TZ set. Tests: runtime automation 106/0. --- .../runtime/src/__tests__/automation.test.ts | 22 +++++++++++++++++++ packages/runtime/src/automation-state.ts | 13 +++++++---- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/__tests__/automation.test.ts b/packages/runtime/src/__tests__/automation.test.ts index a87a179dee..57202b7f4d 100644 --- a/packages/runtime/src/__tests__/automation.test.ts +++ b/packages/runtime/src/__tests__/automation.test.ts @@ -1,5 +1,8 @@ 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'; @@ -622,6 +625,25 @@ describe('computeNextCronFire', () => { 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)', () => { diff --git a/packages/runtime/src/automation-state.ts b/packages/runtime/src/automation-state.ts index a28dd84d43..2ed7e3d153 100644 --- a/packages/runtime/src/automation-state.ts +++ b/packages/runtime/src/automation-state.ts @@ -525,10 +525,15 @@ export function computeNextCronFire(expression: string, fromTime: number): numbe const dowIsStar = dowField === '*'; const bothDayFieldsRestricted = !domIsStar && !dowIsStar; - // Zero out seconds/ms for clean minute boundaries. - const fromDate = new Date(fromTime); - fromDate.setSeconds(0, 0); - const baseTime = fromDate.getTime() + 60000; // start from next minute + // 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; From 6ae9c9fc791a41fbf4c073366d411665e7e0ff4a Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Wed, 8 Jul 2026 23:32:35 +0800 Subject: [PATCH 23/23] feat(runtime): replace session-internal CronJob (wakeup-scheduler) with unified Automation Corrective PR for #639, which merged the earlier CC-style CronJob (#545) that had been superseded by the Codex-style unified Automation design (discussed and agreed before #545 was merged; #545 was left open, which misled the merge). Removes the wakeup-scheduler primitive and its CronCreate/CronDelete/CronList tools + UI cards, and replaces them with the single unified Automation tool (mode + kind: heartbeat = session-internal polling, cron = standalone fresh- session runs). The Automation implementation carries 7 rounds of adversarial review over the wakeup design: cron/creator-session decoupling, cross-session query+management, durable persistence with fail-loud reads + cron-capability gating (no shared-store clobber), incognito privacy gating, O(1) cron validation (named tokens, impossible-date fast-fail), and a DST fall-back re-fire-storm fix. Removed: wakeup-scheduler.ts, wakeup-tools.ts + tests; index.ts exports; desktop main.ts wiring; ui/tool-activity.tsx CronJob preview cards. Goal (P6) is unaffected and tracked separately. --- apps/desktop/src/main/main.ts | 31 -- .../src/__tests__/wakeup-scheduler.test.ts | 461 ----------------- packages/runtime/src/index.ts | 15 - packages/runtime/src/wakeup-scheduler.ts | 476 ------------------ packages/runtime/src/wakeup-tools.ts | 119 ----- packages/ui/src/tool-activity.tsx | 80 --- 6 files changed, 1182 deletions(-) delete mode 100644 packages/runtime/src/__tests__/wakeup-scheduler.test.ts delete mode 100644 packages/runtime/src/wakeup-scheduler.ts delete mode 100644 packages/runtime/src/wakeup-tools.ts diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 245dd145df..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, @@ -478,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: [ @@ -1289,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); @@ -1369,7 +1341,6 @@ 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); @@ -1444,7 +1415,6 @@ 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. @@ -2092,7 +2062,6 @@ 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/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/index.ts b/packages/runtime/src/index.ts index 82c299865c..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 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/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' ? ( - ) : ( )