feat(ai-missions): live phase + elapsed + cancel for running iterations

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-15 14:15:48 +02:00
parent bb3da78d5c
commit ef47adb7d7
6 changed files with 357 additions and 44 deletions

View file

@ -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<AiPlanOutput>;
@ -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<never>((_, 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<void> {
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,
};

View file

@ -194,21 +194,83 @@ export async function startIteration(
): Promise<MissionIteration> {
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<void> {
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<void> {
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<boolean> {
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'];

View file

@ -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<string, string> = {
'resolving-inputs': 'Lade Inputs',
'calling-llm': 'Frage Planner',
'parsing-response': 'Validiere Antwort',
'staging-proposals': 'Stage Vorschläge',
finalizing: 'Schließe ab',
};
let cancelling = $state<string | null>(null);
async function handleCancel(missionId: string, iterationId: string) {
cancelling = iterationId;
try {
await requestIterationCancel(missionId, iterationId);
} finally {
cancelling = null;
}
}
</script>
{#if mode === 'list'}
@ -347,11 +385,36 @@
<p class="empty">Noch keine Iteration gelaufen.</p>
{:else}
{#each [...selected.iterations].reverse() as it (it.id)}
<article class="it">
{@const isRunning = it.overallStatus === 'running'}
<article class="it" class:it-running={isRunning}>
<header>
<span class="it-date">{new Date(it.startedAt).toLocaleString('de-DE')}</span>
<span class="badge badge-{it.overallStatus}">{it.overallStatus}</span>
</header>
{#if isRunning}
<div class="phase-block">
<div class="phase-line">
<span class="phase-spinner" aria-hidden="true"></span>
<span class="phase-label">
{PHASE_LABELS[it.currentPhase ?? ''] ?? it.currentPhase ?? 'Initialisiere'}
{#if it.phaseDetail}<span class="phase-detail"> · {it.phaseDetail}</span>{/if}
</span>
<span class="elapsed">{formatElapsed(elapsedSeconds(it.startedAt))}</span>
</div>
<div class="phase-actions">
<button
type="button"
class="cancel-btn"
disabled={cancelling === it.id || it.cancelRequested}
onclick={() => handleCancel(selected.id, it.id)}
>
{it.cancelRequested ? 'Wird abgebrochen…' : 'Abbrechen'}
</button>
</div>
</div>
{/if}
{#if it.summary}<p class="it-summary">{it.summary}</p>{/if}
{#if it.userFeedback}
<blockquote class="fb">{it.userFeedback}</blockquote>
@ -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;

View file

@ -11,6 +11,7 @@ export type { Actor } from './actor';
export { USER_ACTOR, isAiActor, isSystemActor } from './actor';
export type {
IterationPhase,
Mission,
MissionCadence,
MissionInputRef,

View file

@ -1,4 +1,5 @@
export type {
IterationPhase,
Mission,
MissionCadence,
MissionInputRef,

View file

@ -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 {