From ef47adb7d755a38eb844370258d96b1b0064be36 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 15 Apr 2026 14:15:48 +0200 Subject: [PATCH] feat(ai-missions): live phase + elapsed + cancel for running iterations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the "iteration is running, no feedback" black hole. The user now sees, per running iteration: ⏳ Frage Planner · frage Planner an ⏱ 23s [Abbrechen] Phases (\`IterationPhase\`): resolving-inputs → calling-llm → parsing-response → staging-proposals → finalizing The runner advances through these via \`setIterationPhase\` between each await, writing currentPhase + phaseDetail + phaseStartedAt onto the iteration. UI reads them via Dexie liveQuery — no polling. Cancel: - \`requestIterationCancel\` writes cancelRequested=true on the iteration - runner polls \`isCancelRequested\` between every phase + per stage step - cancellation finalises as \`failed\` with summary \`'cancelled by user'\` - UI button is disabled + relabelled "Wird abgebrochen…" until the next poll picks it up Hard timeout: 90 s wall-clock per iteration via Promise.race against a CancelledError. Wedged backends (e.g. flaky mana-llm) fail fast with "timeout after 90s" instead of sitting in \`running\` forever. Elapsed counter is a \$state variable ticking once a second, scoped to the ListView component — Dexie isn't touched. Auto-cleaned on component destroy. shared-ai re-exports \`IterationPhase\` so server-side mana-ai can inspect the same phase enum (no consumer there yet, but the type is ready for the run-status endpoint planned in HEALTH page). 77/77 webapp tests still green; svelte-check clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/src/lib/data/ai/missions/runner.ts | 171 +++++++++++++----- .../web/src/lib/data/ai/missions/store.ts | 66 ++++++- .../lib/modules/ai-missions/ListView.svelte | 136 +++++++++++++- packages/shared-ai/src/index.ts | 1 + packages/shared-ai/src/missions/index.ts | 1 + packages/shared-ai/src/missions/types.ts | 26 +++ 6 files changed, 357 insertions(+), 44 deletions(-) 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 c91f2c3f3..822a44a32 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 @@ -19,7 +19,13 @@ * code passes those in via the setup module. */ -import { getMission, startIteration, finishIteration } from './store'; +import { + getMission, + startIteration, + finishIteration, + setIterationPhase, + isCancelRequested, +} from './store'; import { resolveMissionInputs } from './input-resolvers'; import { getAvailableToolsForAi } from './available-tools'; import { executeTool } from '../../tools/executor'; @@ -27,6 +33,19 @@ import type { Actor } from '../../events/actor'; import type { Mission, MissionIteration, PlanStep } from './types'; import type { AiPlanInput, AiPlanOutput, PlannedStep } from './planner/types'; +/** Hard timeout for one mission run. Cancels the in-flight planner call + * and finalises the iteration as failed. 90 s is comfortable for a + * cloud-tier model but short enough that a wedged backend doesn't sit + * in `running` indefinitely. */ +const ITERATION_TIMEOUT_MS = 90_000; + +class CancelledError extends Error { + constructor(reason: string) { + super(reason); + this.name = 'CancelledError'; + } +} + export interface MissionRunnerDeps { /** Invoke the Planner LLM task with the fully-built input. */ plan: (input: AiPlanInput) => Promise; @@ -86,62 +105,132 @@ export async function runMission( rationale: mission.objective, }; - // Gather context - const resolvedInputs = await resolveMissionInputs(mission.inputs); - const availableTools = getAvailableToolsForAi(aiActor); + // Hard timeout: any phase taking longer than ITERATION_TIMEOUT_MS aborts + // the run. Wraps the whole pipeline in a Promise.race against a timer. + const timeoutPromise = new Promise((_, reject) => + setTimeout( + () => reject(new CancelledError(`timeout after ${ITERATION_TIMEOUT_MS / 1000}s`)), + ITERATION_TIMEOUT_MS + ) + ); - // Ask the planner - let plan: AiPlanOutput; + async function checkCancel(): Promise { + if (await isCancelRequested(mission!.id, iterationId)) { + throw new CancelledError('cancelled by user'); + } + } + + async function runPipeline(): Promise<{ + recordedSteps: PlanStep[]; + stagedCount: number; + failedCount: number; + planSummary: string; + planStepCount: number; + }> { + // ── Phase: resolving-inputs ──────────────────────────── + await setIterationPhase( + mission!.id, + iterationId, + 'resolving-inputs', + mission!.inputs.length > 0 ? `${mission!.inputs.length} Input(s)` : 'keine Inputs' + ); + const resolvedInputs = await resolveMissionInputs(mission!.inputs); + const availableTools = getAvailableToolsForAi(aiActor); + await checkCancel(); + + // ── Phase: calling-llm ───────────────────────────────── + await setIterationPhase(mission!.id, iterationId, 'calling-llm', 'frage Planner an'); + const plan = await deps.plan({ mission: mission!, resolvedInputs, availableTools }); + await checkCancel(); + + // ── Phase: parsing-response ──────────────────────────── + await setIterationPhase( + mission!.id, + iterationId, + 'parsing-response', + `${plan.steps.length} Step(s) erhalten` + ); + await checkCancel(); + + // ── Phase: staging-proposals ─────────────────────────── + const stage = deps.stageStep ?? defaultStageStep; + const recordedSteps: PlanStep[] = []; + let stagedCount = 0; + let failedCount = 0; + + for (const [i, ps] of plan.steps.entries()) { + await setIterationPhase( + mission!.id, + iterationId, + 'staging-proposals', + `Step ${i + 1} von ${plan.steps.length}` + ); + await checkCancel(); + + const outcome = await stage(ps, aiActor); + if (outcome.ok) { + stagedCount++; + recordedSteps.push({ + id: `${iterationId}-${i}`, + summary: ps.summary, + intent: { kind: 'toolCall', toolName: ps.toolName, params: ps.params }, + proposalId: outcome.proposalId || undefined, + status: outcome.proposalId ? 'staged' : 'approved', + }); + } else { + failedCount++; + recordedSteps.push({ + id: `${iterationId}-${i}`, + summary: ps.summary, + intent: { kind: 'toolCall', toolName: ps.toolName, params: ps.params }, + status: 'failed', + }); + } + } + + await setIterationPhase(mission!.id, iterationId, 'finalizing'); + return { + recordedSteps, + stagedCount, + failedCount, + planSummary: plan.summary, + planStepCount: plan.steps.length, + }; + } + + let recordedSteps: PlanStep[] = []; + let stagedCount = 0; + let failedCount = 0; + let planSummary = ''; + let planStepCount = 0; try { - plan = await deps.plan({ mission, resolvedInputs, availableTools }); + const result = await Promise.race([runPipeline(), timeoutPromise]); + recordedSteps = result.recordedSteps; + stagedCount = result.stagedCount; + failedCount = result.failedCount; + planSummary = result.planSummary; + planStepCount = result.planStepCount; } catch (err) { const msg = err instanceof Error ? err.message : String(err); + const isCancellation = err instanceof CancelledError; await finishIteration(mission.id, iterationId, { - summary: `Planner failed: ${msg}`, + summary: isCancellation ? msg : `Planner failed: ${msg}`, overallStatus: 'failed', }); return emptyResult(mission, iterationId, 'failed', msg); } - // Stage each planned step as a Proposal (or auto-execute if policy says so). - const stage = deps.stageStep ?? defaultStageStep; - const recordedSteps: PlanStep[] = []; - let stagedCount = 0; - let failedCount = 0; - - for (const [i, ps] of plan.steps.entries()) { - const outcome = await stage(ps, aiActor); - if (outcome.ok) { - stagedCount++; - recordedSteps.push({ - id: `${iterationId}-${i}`, - summary: ps.summary, - intent: { kind: 'toolCall', toolName: ps.toolName, params: ps.params }, - proposalId: outcome.proposalId || undefined, - status: outcome.proposalId ? 'staged' : 'approved', - }); - } else { - failedCount++; - recordedSteps.push({ - id: `${iterationId}-${i}`, - summary: ps.summary, - intent: { kind: 'toolCall', toolName: ps.toolName, params: ps.params }, - status: 'failed', - }); - } - } - const overallStatus: MissionIteration['overallStatus'] = - plan.steps.length === 0 + planStepCount === 0 ? 'approved' // nothing to do is a valid outcome - : failedCount === plan.steps.length + : failedCount === planStepCount ? 'failed' : stagedCount > 0 ? 'awaiting-review' : 'approved'; await finishIteration(mission.id, iterationId, { - summary: plan.summary, + summary: planSummary, overallStatus, plan: recordedSteps, }); @@ -151,10 +240,10 @@ export async function runMission( id: iterationId, startedAt: new Date().toISOString(), plan: recordedSteps, - summary: plan.summary, + summary: planSummary, overallStatus, }, - plannedSteps: plan.steps.length, + plannedSteps: planStepCount, stagedSteps: stagedCount, failedSteps: failedCount, }; diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/store.ts b/apps/mana/apps/web/src/lib/data/ai/missions/store.ts index 68fb3c8df..c71e2d92b 100644 --- a/apps/mana/apps/web/src/lib/data/ai/missions/store.ts +++ b/apps/mana/apps/web/src/lib/data/ai/missions/store.ts @@ -194,21 +194,83 @@ export async function startIteration( ): Promise { const mission = await getMission(missionId); if (!mission) throw new Error(`Mission not found: ${missionId}`); + const now = new Date().toISOString(); const iteration: MissionIteration = { id: crypto.randomUUID(), - startedAt: new Date().toISOString(), + startedAt: now, // Strip $state Proxies from the plan array so structured-clone // doesn't fail when Dexie serialises the row. plan: deepClone(input.plan), overallStatus: 'running', + currentPhase: 'resolving-inputs', + phaseStartedAt: now, }; await table().update(missionId, { iterations: [...mission.iterations, iteration], - updatedAt: new Date().toISOString(), + updatedAt: now, }); return iteration; } +/** + * Update the running iteration's sub-phase. Best-effort; failures swallowed + * so a transient Dexie hiccup doesn't kill the run mid-flight. + */ +export async function setIterationPhase( + missionId: string, + iterationId: string, + phase: import('@mana/shared-ai').IterationPhase, + detail?: string +): Promise { + try { + const mission = await getMission(missionId); + if (!mission) return; + const now = new Date().toISOString(); + const updated = mission.iterations.map((it) => + it.id === iterationId + ? { + ...it, + currentPhase: phase, + phaseStartedAt: now, + phaseDetail: detail, + } + : it + ); + await table().update(missionId, { iterations: updated, updatedAt: now }); + } catch (err) { + console.warn('[mission-store] setIterationPhase failed:', err); + } +} + +/** + * Mark an iteration for cancellation. The runner polls between phases + * and exits early as `failed` with summary `'cancelled'`. + */ +export async function requestIterationCancel( + missionId: string, + iterationId: string +): Promise { + const mission = await getMission(missionId); + if (!mission) return; + const updated = mission.iterations.map((it) => + it.id === iterationId ? { ...it, cancelRequested: true } : it + ); + await table().update(missionId, { + iterations: updated, + updatedAt: new Date().toISOString(), + }); +} + +/** + * True if the user has clicked Cancel on this still-running iteration. + * The runner reads this after each await point. + */ +export async function isCancelRequested(missionId: string, iterationId: string): Promise { + const mission = await getMission(missionId); + const it = mission?.iterations.find((x) => x.id === iterationId); + return Boolean(it?.cancelRequested); +} + export interface FinishIterationInput { summary?: string; overallStatus: MissionIteration['overallStatus']; diff --git a/apps/mana/apps/web/src/lib/modules/ai-missions/ListView.svelte b/apps/mana/apps/web/src/lib/modules/ai-missions/ListView.svelte index 117e51e75..6dd48e64e 100644 --- a/apps/mana/apps/web/src/lib/modules/ai-missions/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/ai-missions/ListView.svelte @@ -14,7 +14,9 @@ deleteMission, addIterationFeedback, revokeMissionGrant, + requestIterationCancel, } from '$lib/data/ai/missions/store'; + import { onDestroy } from 'svelte'; import { runMission } from '$lib/data/ai/missions/runner'; import { productionDeps } from '$lib/data/ai/missions/setup'; import MissionInputPicker from '$lib/components/ai/MissionInputPicker.svelte'; @@ -152,6 +154,42 @@ selectedId = id; mode = 'detail'; } + + // ── Live elapsed-time ticker for `running` iterations ──────── + // $state-backed `now` updates every second; any $derived that reads + // it gets recomputed, so the UI counter ticks without a Dexie write. + let now = $state(Date.now()); + const tickHandle = setInterval(() => (now = Date.now()), 1000); + onDestroy(() => clearInterval(tickHandle)); + + function elapsedSeconds(iso: string): number { + return Math.max(0, Math.floor((now - new Date(iso).getTime()) / 1000)); + } + + function formatElapsed(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}m ${String(s).padStart(2, '0')}s`; + } + + const PHASE_LABELS: Record = { + 'resolving-inputs': 'Lade Inputs', + 'calling-llm': 'Frage Planner', + 'parsing-response': 'Validiere Antwort', + 'staging-proposals': 'Stage Vorschläge', + finalizing: 'Schließe ab', + }; + + let cancelling = $state(null); + async function handleCancel(missionId: string, iterationId: string) { + cancelling = iterationId; + try { + await requestIterationCancel(missionId, iterationId); + } finally { + cancelling = null; + } + } {#if mode === 'list'} @@ -347,11 +385,36 @@

Noch keine Iteration gelaufen.

{:else} {#each [...selected.iterations].reverse() as it (it.id)} -
+ {@const isRunning = it.overallStatus === 'running'} +
{new Date(it.startedAt).toLocaleString('de-DE')} {it.overallStatus}
+ + {#if isRunning} +
+
+ + + {PHASE_LABELS[it.currentPhase ?? ''] ?? it.currentPhase ?? 'Initialisiere'} + {#if it.phaseDetail} · {it.phaseDetail}{/if} + + ⏱ {formatElapsed(elapsedSeconds(it.startedAt))} +
+
+ +
+
+ {/if} + {#if it.summary}

{it.summary}

{/if} {#if it.userFeedback}
{it.userFeedback}
@@ -609,6 +672,77 @@ background: #f7d7d7; color: #8a1b1b; } + .badge-running { + background: #d7ecff; + color: #0a548b; + } + .it-running { + border-color: color-mix(in oklab, #0a548b 35%, hsl(var(--color-border))); + } + .phase-block { + display: flex; + flex-direction: column; + gap: 0.375rem; + padding: 0.5rem 0.625rem; + margin: 0.375rem 0; + background: color-mix(in oklab, #0a548b 6%, transparent); + border-radius: 0.375rem; + } + .phase-line { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; + } + .phase-spinner { + display: inline-flex; + font-size: 0.875rem; + animation: phase-pulse 1.4s ease-in-out infinite; + } + @keyframes phase-pulse { + 0%, + 100% { + opacity: 0.5; + } + 50% { + opacity: 1; + } + } + .phase-label { + flex: 1; + font-weight: 500; + } + .phase-detail { + color: hsl(var(--color-muted-foreground)); + font-weight: 400; + } + .elapsed { + font-variant-numeric: tabular-nums; + font-size: 0.75rem; + color: hsl(var(--color-muted-foreground)); + } + .phase-actions { + display: flex; + justify-content: flex-end; + } + .cancel-btn { + padding: 0.25rem 0.625rem; + border: 1px solid hsl(var(--color-border)); + border-radius: 0.25rem; + background: hsl(var(--color-surface)); + color: hsl(var(--color-muted-foreground)); + cursor: pointer; + font: inherit; + font-size: 0.75rem; + } + .cancel-btn:hover:not(:disabled) { + color: #8a1b1b; + border-color: #e99; + } + .cancel-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } .it-summary { margin: 0 0 0.375rem; font-size: 0.8125rem; diff --git a/packages/shared-ai/src/index.ts b/packages/shared-ai/src/index.ts index cd0032fc0..5d76a3c2b 100644 --- a/packages/shared-ai/src/index.ts +++ b/packages/shared-ai/src/index.ts @@ -11,6 +11,7 @@ export type { Actor } from './actor'; export { USER_ACTOR, isAiActor, isSystemActor } from './actor'; export type { + IterationPhase, Mission, MissionCadence, MissionInputRef, diff --git a/packages/shared-ai/src/missions/index.ts b/packages/shared-ai/src/missions/index.ts index a4d7db526..efbd5d648 100644 --- a/packages/shared-ai/src/missions/index.ts +++ b/packages/shared-ai/src/missions/index.ts @@ -1,4 +1,5 @@ export type { + IterationPhase, Mission, MissionCadence, MissionInputRef, diff --git a/packages/shared-ai/src/missions/types.ts b/packages/shared-ai/src/missions/types.ts index 4f97f4ece..fc151b7d0 100644 --- a/packages/shared-ai/src/missions/types.ts +++ b/packages/shared-ai/src/missions/types.ts @@ -41,6 +41,18 @@ export interface PlanStep { readonly status: 'planned' | 'staged' | 'approved' | 'rejected' | 'skipped' | 'failed'; } +/** + * Sub-state of a `running` iteration so the UI can show meaningful + * progress instead of a spinner with no context. Set by the runner as + * it advances; cleared when the iteration leaves `running`. + */ +export type IterationPhase = + | 'resolving-inputs' + | 'calling-llm' + | 'parsing-response' + | 'staging-proposals' + | 'finalizing'; + export interface MissionIteration { readonly id: string; readonly startedAt: string; @@ -59,6 +71,20 @@ export interface MissionIteration { * pre-server iterations. */ readonly source?: 'browser' | 'server'; + /** Sub-status while `overallStatus === 'running'`. Undefined otherwise. */ + readonly currentPhase?: IterationPhase; + /** When the runner advanced into the current phase — for elapsed-in-phase. */ + readonly phaseStartedAt?: string; + /** + * Human-readable detail for the current phase (e.g. "staging 2/5"). + * Pure UI hint; not load-bearing. + */ + readonly phaseDetail?: string; + /** + * Set to true by the user clicking "Abbrechen". The runner polls this + * between phases and exits early as `failed` with summary 'cancelled'. + */ + readonly cancelRequested?: boolean; } export interface Mission {