Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
da6171e
feat(runtime): unified Automation tool — Codex-style heartbeat + cron…
hqhq1025 Jul 6, 2026
6ec7960
test(runtime): add integration tests covering PR test plan scenarios
hqhq1025 Jul 6, 2026
cee7955
test(runtime): add mutation verification proving tests catch broken b…
hqhq1025 Jul 6, 2026
e48d8a5
test: complete test coverage for automation system
hqhq1025 Jul 6, 2026
0d669da
feat(runtime): goal-based autonomous execution (Issue #15 Primitive 6)
hqhq1025 Jul 6, 2026
3930ad5
fix(runtime): wire cron fresh-session execution + outcome-after-strea…
hqhq1025 Jul 7, 2026
a4beec0
Merge remote-tracking branch 'origin/main' into feat/unified-automation
hqhq1025 Jul 7, 2026
bf90fc0
fix(runtime): Automation tool schema must be a top-level object (Anth…
hqhq1025 Jul 7, 2026
d72c739
feat(cli): parameterize cron support via automationCreateFreshRun (re…
hqhq1025 Jul 7, 2026
2a9a263
fix(runtime): cron parser correctness — sparse annual, dom+dow OR, ti…
hqhq1025 Jul 7, 2026
7686b96
fix(runtime): concurrency + maxFires + attribution + session/cron cor…
hqhq1025 Jul 7, 2026
64bed00
fix(runtime): resume() must not revive a spent fire budget (self-revi…
hqhq1025 Jul 7, 2026
966ceb3
fix(runtime): cron automations default to durable so they survive res…
hqhq1025 Jul 7, 2026
03f628c
chore(runtime,cli,desktop): split Goal (P6) out of the Automation PR
hqhq1025 Jul 7, 2026
74452b0
fix(runtime): durable automations are app-global — queryable + manage…
hqhq1025 Jul 7, 2026
364c13d
wip(desktop): gate automation firing on incognito privacy mode
hqhq1025 Jul 7, 2026
67d2478
fix(runtime,desktop,cli): decouple cron firing from creator session +…
hqhq1025 Jul 7, 2026
8808d69
test(desktop): e2e — durable cron fires after creator session archive…
hqhq1025 Jul 7, 2026
2c99f1b
fix(runtime,cli): round-2 review — no shared-store corruption, real r…
hqhq1025 Jul 8, 2026
e3d5883
Merge remote-tracking branch 'origin/main' into feat/unified-automation
hqhq1025 Jul 8, 2026
bb556c6
fix(runtime): round-3 review — sweep honours cron-untouched invariant…
hqhq1025 Jul 8, 2026
b1a1a1e
fix(cli,desktop): a cron-disabled host must not persist/adopt durable…
hqhq1025 Jul 8, 2026
25fa05f
fix(storage,runtime): store fails loud on unreadable data; cron valid…
hqhq1025 Jul 8, 2026
9363fa8
fix(runtime): cron scan start uses epoch arithmetic — no DST fall-bac…
hqhq1025 Jul 8, 2026
9566521
Merge remote-tracking branch 'origin/main' into feat/unified-automation
hqhq1025 Jul 8, 2026
4a24c6a
Merge branch 'feat/unified-automation' into feat/unified-automation-v2
hqhq1025 Jul 8, 2026
6ae9c9f
feat(runtime): replace session-internal CronJob (wakeup-scheduler) wi…
hqhq1025 Jul 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions apps/desktop/src/main/__tests__/automation-canfire.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<Parameters<typeof evaluateAutomationCanFire>[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);
});
});
Original file line numberDiff line numberDiff line change
@@ -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();
});
});
217 changes: 217 additions & 0 deletions apps/desktop/src/main/__tests__/automation-persistence-e2e.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
/**
* End-to-end: durable cron persistence + cross-session query/management.
*
* Exercises the REAL host wiring (createMainAutomationWiring) against a REAL
* FileAutomationStore on a real temp workspace, simulating an app restart:
*
* session A creates a durable cron ──sync──► <workspace>/automations.json
* │
* (restart: a fresh wiring loads it) ◄──loadAll────────┘
* │
* session B (never saw it) lists / pauses / resumes / deletes it
*
* This is the query-and-persistence loop the reviewer asked for: a persisted
* cron is not just fireable after restart, it stays visible and manageable
* from a brand-new session. Nothing here is mocked except the fire executors
* (we assert on persisted state, not on runs).
*/

import { strict as assert } from 'node:assert';
import { describe, it, before, after } from 'node:test';
import { mkdtemp, rm, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { MakaToolContext, MakaTool } from '@maka/runtime';
import { createMainAutomationWiring } from '../automation-wiring.js';

function ctx(sessionId: string): MakaToolContext {
return {
sessionId,
turnId: 'turn-1',
cwd: '/tmp',
toolCallId: 'tc-1',
abortSignal: new AbortController().signal,
emitOutput: () => {},
};
}

function makeWiring(workspaceRoot: string) {
return createMainAutomationWiring({
workspaceRoot,
canFire: async () => true,
injectTurn: async () => ({ runId: 'run', ok: true }),
// Presence of createFreshRun is what advertises the cron kind to the tool.
createFreshRun: async () => ({ runId: 'run', ok: true }),
});
}

/** A cron-DISABLED host (heartbeat-only), like the `maka` CLI — no createFreshRun. */
function makeCronDisabledWiring(workspaceRoot: string) {
return createMainAutomationWiring({
workspaceRoot,
canFire: async () => true,
injectTurn: async () => ({ runId: 'run', ok: true }),
// createFreshRun omitted → cron disabled → must not persist/adopt durable state.
});
}

function automationTool(wiring: ReturnType<typeof makeWiring>): MakaTool {
return wiring.tools[0];
}

async function readStore(workspaceRoot: string): Promise<Array<{ id: string; name: string }>> {
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<Array<{ id: string; name: string }>> {
const deadline = Date.now() + timeoutMs;
for (;;) {
const rows = await readStore(workspaceRoot);
if (predicate(rows)) return rows;
if (Date.now() >= deadline) return rows;
await new Promise((r) => setTimeout(r, 25));
}
}

describe('E2E: durable cron persistence + cross-session query/management', () => {
let workspaceRoot: string;

before(async () => {
workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-automation-e2e-'));
});
after(async () => {
await rm(workspaceRoot, { recursive: true, force: true });
});

it('a durable cron created in one session is queryable and manageable from a fresh session after restart', async () => {
const SESSION_A = 'session-A-original';
const SESSION_B = 'session-B-after-restart';

// ── session A: create a durable cron via the real Automation tool ──────
const wiring1 = makeWiring(workspaceRoot);
const created = await automationTool(wiring1).impl({
mode: 'create',
kind: 'cron',
name: 'nightly backup',
prompt: 'run the nightly backup',
schedule: { type: 'cron', expression: '0 3 * * *' },
}, ctx(SESSION_A)) as string;
assert.ok(created.includes('Automation created'), created);
// cron defaults to durable, so it must be advertised as such.
assert.ok(created.includes('durable'), created);

// ── it reaches disk (persistence) ─────────────────────────────────────
const persisted = await waitForStore(workspaceRoot, (rows) => rows.some((r) => r.name === 'nightly backup'));
assert.equal(persisted.length, 1);
assert.equal(persisted[0].name, 'nightly backup');
const cronId = persisted[0].id;

// ── restart: a fresh wiring loads the persisted cron from disk ─────────
const wiring2 = makeWiring(workspaceRoot);
await wiring2.loadDurableAutomations();

// ── session B (never saw the cron): query it ──────────────────────────
const listed = await automationTool(wiring2).impl({ mode: 'list' }, ctx(SESSION_B)) as string;
assert.ok(listed.includes('nightly backup'), `session B should see the persisted cron:\n${listed}`);
assert.ok(listed.includes(cronId), listed);

// ── session B: manage it (pause → resume → delete) ────────────────────
const paused = await automationTool(wiring2).impl({ mode: 'pause', id: cronId }, ctx(SESSION_B)) as string;
assert.ok(paused.includes('paused'), paused);
assert.equal(wiring2.manager.get(cronId)?.status, 'paused');

const resumed = await automationTool(wiring2).impl({ mode: 'resume', id: cronId }, ctx(SESSION_B)) as string;
assert.ok(resumed.includes('resumed'), resumed);
assert.equal(wiring2.manager.get(cronId)?.status, 'active');

const deleted = await automationTool(wiring2).impl({ mode: 'delete', id: cronId }, ctx(SESSION_B)) as string;
assert.ok(deleted.toLowerCase().includes('delet'), deleted);
assert.equal(wiring2.manager.get(cronId), undefined);

// ── the deletion is durable too: disk no longer holds it ──────────────
const afterDelete = await waitForStore(workspaceRoot, (rows) => rows.every((r) => r.id !== cronId));
assert.ok(afterDelete.every((r) => r.id !== cronId), 'deleted cron must be gone from disk');

wiring1.scheduler.dispose();
wiring2.scheduler.dispose();
});

it('a non-durable heartbeat does NOT leak into another session and is not persisted', async () => {
const ws = await mkdtemp(join(tmpdir(), 'maka-automation-e2e-hb-'));
try {
const wiring = makeWiring(ws);
const created = await automationTool(wiring).impl({
mode: 'create',
kind: 'heartbeat',
name: 'poll status',
prompt: 'check status',
schedule: { type: 'interval', seconds: 60 },
}, ctx('owner-session')) as string;
assert.ok(created.includes('Automation created'), created);

// A different session cannot see or manage the session-private heartbeat.
const listedElsewhere = await automationTool(wiring).impl({ mode: 'list' }, ctx('stranger-session')) as string;
assert.ok(listedElsewhere.includes('No automations'), listedElsewhere);

// And it never hits disk (non-durable).
const rows = await waitForStore(ws, () => false, 300); // give sync a chance, expect empty
assert.equal(rows.length, 0, 'a non-durable heartbeat must not be persisted');

wiring.scheduler.dispose();
} finally {
await rm(ws, { recursive: true, force: true });
}
});
});

describe('E2E: a cron-disabled host (CLI) sharing the workspace never clobbers durable crons', () => {
it('a heartbeat-only wiring neither loads nor overwrites the owner\'s automations.json', async () => {
const ws = await mkdtemp(join(tmpdir(), 'maka-automation-clobber-'));
try {
// ── owner (cron-enabled, desktop) creates a durable cron ──────────────
const owner = makeWiring(ws);
await automationTool(owner).impl({
mode: 'create', kind: 'cron', name: 'daily backup', prompt: 'back up',
schedule: { type: 'cron', expression: '0 3 * * *' },
}, ctx('desktop-session')) as string;
const persisted = await waitForStore(ws, (rows) => rows.some(r => r.name === 'daily backup'));
assert.equal(persisted.length, 1);
owner.scheduler.dispose();

// ── a cron-disabled host (CLI) boots on the SAME workspace ────────────
const cli = makeCronDisabledWiring(ws);
// It must not adopt the cron it cannot run.
await cli.loadDurableAutomations();
assert.equal(cli.manager.listAll().length, 0, 'cron-disabled host must not load crons it cannot run');

// It creates a heartbeat and manages it — all the activity that would
// trigger a durable sync on a cron-enabled host.
await automationTool(cli).impl({
mode: 'create', kind: 'heartbeat', name: 'poll', prompt: 'p',
schedule: { type: 'interval', seconds: 60 },
}, ctx('cli-session')) as string;
const listed = await automationTool(cli).impl({ mode: 'list' }, ctx('cli-session')) as string;
const idMatch = listed.match(/ID: ([a-f0-9-]+)/i);
if (idMatch) await automationTool(cli).impl({ mode: 'delete', id: idMatch[1] }, ctx('cli-session')) as string;

// Give any (erroneous) sync a chance to land, then assert the owner's cron
// is STILL on disk, untouched.
await new Promise(r => setTimeout(r, 200));
const after = await readStore(ws);
assert.deepEqual(after.map(r => r.name), ['daily backup'], 'CLI must not overwrite/erase the desktop\'s durable cron');
cli.scheduler.dispose();
} finally {
await rm(ws, { recursive: true, force: true });
}
});
});
Loading
Loading