mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
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:
parent
bb3da78d5c
commit
ef47adb7d7
6 changed files with 357 additions and 44 deletions
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export type { Actor } from './actor';
|
|||
export { USER_ACTOR, isAiActor, isSystemActor } from './actor';
|
||||
|
||||
export type {
|
||||
IterationPhase,
|
||||
Mission,
|
||||
MissionCadence,
|
||||
MissionInputRef,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export type {
|
||||
IterationPhase,
|
||||
Mission,
|
||||
MissionCadence,
|
||||
MissionInputRef,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue