feat(ai): production wiring for Mission Runner + default input resolvers

Connects the dependency-injected Runner to the real LlmOrchestrator and
drives it on a foreground tick in the app shell. Registers sensible
default input resolvers so Missions linked to notes / kontext / goals
work without per-module opt-in.

- `data/ai/missions/setup.ts`
  - `productionDeps` wires `aiPlanTask` through `llmOrchestrator.run`
  - `startMissionTick(intervalMs = 60_000)` kicks an immediate run then
    schedules `runDueMissions` on interval. Idempotent + overlap-guarded
    so a slow LLM run can't pile up ticks.
  - `stopMissionTick` clears the interval for teardown / HMR.
- `data/ai/missions/default-resolvers.ts` — resolvers for notes (title +
  decrypted content), kontext (singleton markdown), goals (progress
  projection). Registered once when the tick starts.
- `(app)/+layout.svelte` wires startMissionTick into the idle-phase init
  block alongside startEventStore / startStreakTracker / etc., and
  stopMissionTick into the teardown path.

System is now end-to-end runnable in the browser: create a Mission with
cadence 'interval', wait for the tick, see proposals appear in
/todo's AiProposalInbox. Missions UI (create/edit form) still open.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-14 21:24:13 +02:00
parent 1c6201be50
commit 7535480007
4 changed files with 228 additions and 0 deletions

View file

@ -0,0 +1,85 @@
/**
* Default input resolvers.
*
* Registered from `setup.ts` so the production MissionRunner can load
* notes / kontext / goals without every module having to know about the
* AI subsystem. Modules that need special projection logic register their
* own resolver on init and override these defaults.
*/
import { db } from '../../database';
import { decryptRecords } from '../../crypto';
import { registerInputResolver } from './input-resolvers';
import type { InputResolver } from './input-resolvers';
interface NoteLike {
id: string;
title?: string;
content?: string;
deletedAt?: string;
}
const notesResolver: InputResolver = async (ref) => {
const local = await db.table<NoteLike>(ref.table).get(ref.id);
if (!local || local.deletedAt) return null;
const [decrypted] = await decryptRecords(ref.table, [local]);
return {
id: ref.id,
module: ref.module,
table: ref.table,
title: decrypted.title,
content: decrypted.content ?? '',
};
};
interface KontextDocLike {
id: string;
content?: string;
}
const kontextResolver: InputResolver = async (ref) => {
const doc = await db.table<KontextDocLike>('kontextDoc').get(ref.id);
if (!doc) return null;
const [decrypted] = await decryptRecords('kontextDoc', [doc]);
return {
id: ref.id,
module: ref.module,
table: ref.table,
title: 'Kontext',
content: decrypted.content ?? '',
};
};
interface GoalLike {
id: string;
title?: string;
currentValue?: number;
target?: { value: number };
period?: string;
deletedAt?: string;
}
const goalsResolver: InputResolver = async (ref) => {
const goal = await db.table<GoalLike>(ref.table).get(ref.id);
if (!goal || goal.deletedAt) return null;
const current = goal.currentValue ?? 0;
const target = goal.target?.value ?? '?';
return {
id: ref.id,
module: ref.module,
table: ref.table,
title: goal.title ?? 'Goal',
content: `Fortschritt: ${current} / ${target} (${goal.period ?? 'unbekannt'})`,
};
};
let registered = false;
/** Register the default resolvers once. Idempotent. */
export function registerDefaultInputResolvers(): void {
if (registered) return;
registerInputResolver('notes', notesResolver);
registerInputResolver('kontext', kontextResolver);
registerInputResolver('goals', goalsResolver);
registered = true;
}

View file

@ -0,0 +1,58 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
// Mock the heavy deps so this is a pure scheduler test — we don't exercise
// the orchestrator or Dexie from here; those are covered by runner.test.ts.
vi.mock('@mana/shared-llm', () => ({
llmOrchestrator: {
run: vi.fn().mockResolvedValue({ value: { summary: '', steps: [] } }),
},
}));
vi.mock('./runner', () => ({
runDueMissions: vi.fn().mockResolvedValue([]),
}));
vi.mock('./default-resolvers', () => ({
registerDefaultInputResolvers: vi.fn(),
}));
import { startMissionTick, stopMissionTick, isMissionTickRunning } from './setup';
import { runDueMissions } from './runner';
import { registerDefaultInputResolvers } from './default-resolvers';
afterEach(() => {
stopMissionTick();
vi.clearAllMocks();
});
describe('mission tick scheduler', () => {
it('starts, runs once immediately, and schedules an interval', async () => {
vi.useFakeTimers();
try {
startMissionTick(5_000);
expect(isMissionTickRunning()).toBe(true);
expect(registerDefaultInputResolvers).toHaveBeenCalledOnce();
// Wait for the immediate run to complete
await vi.advanceTimersByTimeAsync(1);
expect(runDueMissions).toHaveBeenCalledTimes(1);
// Advance past one interval — should tick again
await vi.advanceTimersByTimeAsync(5_000);
expect(runDueMissions).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
it('is idempotent — double-start does not schedule two intervals', () => {
startMissionTick(10_000);
startMissionTick(10_000);
expect(isMissionTickRunning()).toBe(true);
});
it('stop clears the interval', () => {
startMissionTick(10_000);
expect(isMissionTickRunning()).toBe(true);
stopMissionTick();
expect(isMissionTickRunning()).toBe(false);
});
});

View file

@ -0,0 +1,79 @@
/**
* Production wiring for the Mission Runner.
*
* Connects the dependency-injected `runMission` to the real LlmOrchestrator
* (via `aiPlanTask`) and drives `runDueMissions` on a foreground interval.
*
* Use pattern:
*
* // in +layout.svelte (app-shell init)
* import { startMissionTick } from '$lib/data/ai/missions/setup';
* onMount(() => startMissionTick());
*
* The tick is intentionally foreground-only: the Runner requires the
* LlmOrchestrator which needs WebGPU / network. A background service for
* offline-of-tab execution is tracked as Phase 7 see
* COMPANION_BRAIN_ARCHITECTURE.md §20.5.
*/
import { llmOrchestrator } from '@mana/shared-llm';
import { aiPlanTask } from '$lib/llm-tasks/ai-plan';
import { runDueMissions, type MissionRunnerDeps } from './runner';
import { registerDefaultInputResolvers } from './default-resolvers';
import type { AiPlanInput, AiPlanOutput } from './planner/types';
/** Default interval between tick scans. One minute is fine for foreground use. */
const DEFAULT_TICK_INTERVAL_MS = 60_000;
/** Swap-in planner that routes through the real LLM orchestrator. */
const productionPlan = async (input: AiPlanInput): Promise<AiPlanOutput> => {
const result = await llmOrchestrator.run(aiPlanTask, input);
return result.value;
};
export const productionDeps: MissionRunnerDeps = {
plan: productionPlan,
// stageStep defaults to the policy-gated executor — nothing to override here.
};
let tickHandle: ReturnType<typeof setInterval> | null = null;
let ticking = false;
/**
* Start the Mission tick. Idempotent calling twice is a no-op. Returns a
* stop function so test / teardown code can cancel it.
*/
export function startMissionTick(intervalMs: number = DEFAULT_TICK_INTERVAL_MS): () => void {
if (tickHandle !== null) return stopMissionTick;
registerDefaultInputResolvers();
const tickOnce = async () => {
// Guard against overlap — a slow LLM run could pile up multiple ticks.
if (ticking) return;
ticking = true;
try {
await runDueMissions(new Date(), productionDeps);
} catch (err) {
console.error('[MissionTick] scan threw:', err);
} finally {
ticking = false;
}
};
// Run once immediately so a just-due mission doesn't wait a full interval.
void tickOnce();
tickHandle = setInterval(() => void tickOnce(), intervalMs);
return stopMissionTick;
}
export function stopMissionTick(): void {
if (tickHandle !== null) {
clearInterval(tickHandle);
tickHandle = null;
}
}
/** Test / debug helper — whether the tick is currently scheduled. */
export function isMissionTickRunning(): boolean {
return tickHandle !== null;
}

View file

@ -6,6 +6,7 @@
import { createReminderScheduler } from '@mana/shared-stores';
import { todoReminderSource } from '$lib/modules/todo/reminder-source';
import { startEventStore, stopEventStore } from '$lib/data/events/event-store';
import { startMissionTick, stopMissionTick } from '$lib/data/ai/missions/setup';
import { initTools } from '$lib/data/tools/init';
import { startEventBridge, stopEventBridge } from '$lib/triggers/event-bridge';
import { startStreakTracker, stopStreakTracker } from '$lib/data/projections/streaks';
@ -509,6 +510,10 @@
// routes don't read from it on first paint.
void dashboardStore.initialize();
reminderScheduler.start();
// AI Mission tick — scans pendingProposals/aiMissions on an
// interval and runs any that are due. Safe idempotent; see
// data/ai/missions/setup.ts.
startMissionTick();
});
// Restore nav collapsed state (cheap, keep inline)
@ -604,6 +609,7 @@
stopEventStore();
stopEventBridge();
stopStreakTracker();
stopMissionTick();
guestMode?.destroy();
// Fire-and-forget — we don't need to await; the in-flight task
// will finish in the background and the next page session will