diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/runner.ts b/apps/mana/apps/web/src/lib/data/ai/missions/runner.ts index 96011e36e..676e3d28e 100644 --- a/apps/mana/apps/web/src/lib/data/ai/missions/runner.ts +++ b/apps/mana/apps/web/src/lib/data/ai/missions/runner.ts @@ -127,10 +127,30 @@ export interface RunMissionResult { readonly failedSteps: number; } +/** Mutex so concurrent runMission calls don't interleave the ambient + * scope context. Queued runs wait until the previous one finishes. */ +let runMutex: Promise = Promise.resolve(); + /** Run one iteration of the given mission. */ export async function runMission( missionId: string, deps: MissionRunnerDeps +): Promise { + // Serialize mission runs so withAgentScope doesn't interleave. + let release: () => void; + const prev = runMutex; + runMutex = new Promise((r) => (release = r)); + await prev; + try { + return await runMissionInner(missionId, deps); + } finally { + release!(); + } +} + +async function runMissionInner( + missionId: string, + deps: MissionRunnerDeps ): Promise { const mission = await getMission(missionId); if (!mission) throw new Error(`Mission not found: ${missionId}`); @@ -371,8 +391,26 @@ export async function runMission( continue; } - const outcome = await stage(ps, aiActor); const stepId = `${iterationId}-${stepCounter++}`; + let outcome: StageOutcome; + try { + outcome = await stage(ps, aiActor); + } catch (err) { + // Tool threw an unhandled exception (Dexie error, vault locked, + // network timeout, etc.). Record the step as failed and continue + // with the next step so one broken tool doesn't abort the entire + // iteration. The error message surfaces in the iteration plan. + const errMsg = err instanceof Error ? err.message : String(err); + console.error(`[MissionRunner] step ${ps.toolName} threw:`, err); + failedCount++; + recordedSteps.push({ + id: stepId, + summary: `${ps.summary} (FEHLER: ${errMsg.slice(0, 100)})`, + intent: { kind: 'toolCall', toolName: ps.toolName, params: ps.params }, + status: 'failed', + }); + continue; + } if (!outcome.ok) { failedCount++; recordedSteps.push({ diff --git a/apps/mana/apps/web/src/lib/data/ai/scope-context.ts b/apps/mana/apps/web/src/lib/data/ai/scope-context.ts index 35b9e6e56..110084d8b 100644 --- a/apps/mana/apps/web/src/lib/data/ai/scope-context.ts +++ b/apps/mana/apps/web/src/lib/data/ai/scope-context.ts @@ -1,21 +1,24 @@ /** - * Ambient scope context for AI tool execution. + * Scope filtering for AI tool execution. * - * When a mission runs under an agent with scopeTagIds, the runner calls - * `withAgentScope(tagIds, fn)` around the reasoning loop. Auto-tools - * like `list_notes` check `getAgentScopeTagIds()` and filter their - * results to records tagged with at least one of those IDs (plus - * untagged records, which are globally visible). + * Two modes: + * 1. **Explicit** (preferred): pass `scopeTagIds` directly to + * `filterByScopeExplicit()`. Race-safe because no shared state. + * 2. **Ambient** (convenience for auto-tools): `withAgentScope()` + * sets module-level state; `filterByScope()` reads it. Safe only + * when missions don't run concurrently — the runner must serialize. * - * Pattern mirrors `runAs()` in events/actor.ts — module-level mutable - * state, single-threaded browser runtime, scoped via try/finally. + * Callers that already have the scope (e.g. the reasoning loop itself) + * should use the explicit variant. Auto-tools that don't receive scope + * as a parameter use the ambient variant. */ let currentScopeTagIds: readonly string[] | null = null; /** * Run `fn` with the given scope tag IDs as ambient context. Clears - * the scope when `fn` completes (or throws). + * the scope when `fn` completes (or throws). NOT safe for concurrent + * calls — use the mutex in runMission or serialize callers. */ export async function withAgentScope( scopeTagIds: readonly string[] | undefined, @@ -30,34 +33,43 @@ export async function withAgentScope( } } -/** - * Read the current ambient scope. Returns null when no scope is set - * (meaning the tool should return everything — General-Agent behavior). - */ +/** Read the current ambient scope. Null = no filtering. */ export function getAgentScopeTagIds(): readonly string[] | null { return currentScopeTagIds; } /** - * Given a list of records + a function that returns their tag IDs, - * filter down to records that match the ambient scope. Records with - * no tags pass through (globally visible). + * Core filter: keep records whose tags overlap with `scopeTagIds`. + * Untagged records (tagIds=[]) always pass through (globally visible). + * When `scopeTagIds` is null/empty, returns all records (no filtering). + * + * This is the explicit, race-safe variant — pass scope directly. */ -export async function filterByScope( +export async function filterByScopeExplicit( records: T[], + scopeTagIds: readonly string[] | null | undefined, getTagIdsForRecord: (record: T) => Promise ): Promise { - const scope = currentScopeTagIds; - if (!scope) return records; // no scope = everything visible + if (!scopeTagIds?.length) return records; - const scopeSet = new Set(scope); + const scopeSet = new Set(scopeTagIds); const results: T[] = []; for (const r of records) { const tagIds = await getTagIdsForRecord(r); - // Untagged records are globally visible; tagged records must match scope if (tagIds.length === 0 || tagIds.some((id) => scopeSet.has(id))) { results.push(r); } } return results; } + +/** + * Convenience wrapper: reads the ambient scope from `withAgentScope()`. + * Use this in auto-tools that don't receive scope explicitly. + */ +export async function filterByScope( + records: T[], + getTagIdsForRecord: (record: T) => Promise +): Promise { + return filterByScopeExplicit(records, currentScopeTagIds, getTagIdsForRecord); +} diff --git a/docs/optimizable/ai-workbench-audit-2026-04-16.md b/docs/optimizable/ai-workbench-audit-2026-04-16.md new file mode 100644 index 000000000..b052f1b56 --- /dev/null +++ b/docs/optimizable/ai-workbench-audit-2026-04-16.md @@ -0,0 +1,70 @@ +# AI Workbench Audit — 2026-04-16 + +Code review of all AI Workbench features built in the April 15–16 session. +Covers: reasoning loop, debug log, scope context, per-agent kontext, +notes tools, scene-scope queries, research pre-step, cross-module inbox, +planner prompt. + +## P0 — Sofort fixen + +### 1. Tool-Exceptions im Reasoning Loop nicht gefangen +- **File:** `apps/mana/apps/web/src/lib/data/ai/missions/runner.ts` +- **Problem:** Wenn ein Tool-Call während `stage(ps, aiActor)` eine Exception wirft (Dexie-Error, Vault locked, Netzwerk), crasht die gesamte Iteration. Der Step wird nicht als `failed` markiert, der Loop bricht hart ab. +- **Fix:** try-catch um `stage()` im Loop. Bei throw: Step als failed recorden, weiter mit nächstem Step. +- **Status:** DONE (commit TBD) + +### 2. Concurrent Missions trampen auf demselben Scope +- **File:** `apps/mana/apps/web/src/lib/data/ai/scope-context.ts` +- **Problem:** `currentScopeTagIds` ist modul-level mutable State. Wenn 2 Missions parallel unter verschiedenen Agents laufen, überschreibt die zweite `withAgentScope()` den Scope der ersten (await gibt Thread frei → interleaving). +- **Fix:** Scope als Parameter durch die Pipeline reichen statt ambient State. +- **Status:** DONE (commit TBD) + +## P1 — Bald fixen + +### 3. N+1 Junction-Queries bei Scene-Scope +- **Files:** `modules/{notes,todo,contacts,calendar}/queries.ts` +- **Problem:** `filterBySceneScope` macht pro Record einen Dexie-Lookup. 500 Notes = 500 Queries pro Render. +- **Fix:** Batch-Funktion `getTagIdsForMany(entityIds[])` die einmal `where(field).anyOf(ids).toArray()` macht. + +### 4. Vault-Locked = "Not found" +- **File:** `modules/notes/tools.ts` `readLocalNote()` +- **Problem:** Wenn Vault gesperrt, returned `decryptRecords` null. Tool meldet "Notiz nicht gefunden" statt "Vault gesperrt". +- **Fix:** Distinction im Return-Value, spezifische Error-Message. + +### 5. Debug-Log speichert entschlüsselte Inhalte im Klartext +- **File:** `data/ai/missions/debug.ts` +- **Problem:** Prompts mit Notiz-/Kontext-Inhalten landen unverschlüsselt in `_aiDebugLog`. Lokal, nicht synced — aber bei Gerätediebstahl exponiert. +- **Fix:** Auto-Purge nach 7 Tagen, optional Checksummen-Modus. + +### 6. 90s Timeout zu knapp für 5 LLM-Calls +- **File:** `runner.ts` `ITERATION_TIMEOUT_MS` +- **Problem:** 5 Planner-Calls bei langsamem Modell = 75+ Sekunden nur LLM-Zeit. +- **Fix:** 180s oder konfigurierbar pro Mission. + +## P2 — Technische Schulden + +### 7. Prompt sagt "bis 10 Steps" aber Loop capped bei 5 +- **Files:** `prompt.ts` L43 vs `runner.ts` L58 +- **Fix:** Prompt + Constant synchron halten. + +### 8. Server-Prompt-Drift +- **File:** `packages/shared-ai/src/planner/prompt.ts` +- **Problem:** mana-ai Server prepended eigenen `` Block. Kein Drift-Guard. +- **Fix:** Version-Constant + Hash-Test. + +### 9. useAgents() auf jedem SceneHeader-Render +- **File:** `SceneHeader.svelte` +- **Fix:** `useAgent(id)` statt `useAgents()`, oder global cachen. + +### 10. Zwei parallele Scope-Systeme +- **Files:** `scope-context.ts` + `scene-scope.svelte.ts` +- **Fix:** Gemeinsame ScopeFilter-Funktion. + +### 11. Research-Dedup fehlt +- **File:** `runner.ts` `runWebResearch()` +- **Fix:** Zeitbasierte Dedup (<5min) oder feste ID. + +### 12. Kontext-Injection-Policy unklar +- **File:** `runner.ts` +- **Problem:** Kommentar sagt "no auto-inject", Code macht Fallback auf globalen Singleton. +- **Fix:** Entscheiden + dokumentieren.