mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
feat(ai-missions): richer error surfacing + retry button on failed runs
Replaces the single-line summary ("Planner failed: fetch …") with
full diagnostic detail: error name + message + last-active phase +
stack trace, all persisted onto the iteration itself. UI expands a
collapsed details block next to each failed iteration, so the user
can see *where* it broke ("TypeError in calling-llm") without opening
DevTools.
Paired with a one-click Retry button that re-runs the mission under
the same config — useful while debugging a flaky backend (GPU server
down, Gemini quota, etc.).
- `packages/shared-ai/src/missions/types.ts` — new
`MissionIteration.errorDetails: { name, message, phase?, stack? }`
- `finishIteration` accepts the field, deep-clones it, and also now
clears the transient phase markers (currentPhase/phaseStartedAt/
phaseDetail/cancelRequested) whenever an iteration finalises — keeps
the schema honest (phases are sub-state of \`running\` only).
- `runMission` tracks \`lastPhase\` via a new \`enterPhase\` helper that
wraps setIterationPhase. The catch handler populates errorDetails
with lastPhase + message + stack.
- ListView: \`<details>\` block under each failed iteration + Retry
button (disabled while another run is in-flight).
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
70c62e7584
commit
2497a65937
4 changed files with 137 additions and 17 deletions
|
|
@ -120,6 +120,18 @@ export async function runMission(
|
|||
}
|
||||
}
|
||||
|
||||
// Track the phase that was last active — so a catch handler can
|
||||
// attribute the error ("calling-llm" vs "parsing-response" is
|
||||
// enough context for most debugging without a stack trace).
|
||||
let lastPhase: import('@mana/shared-ai').IterationPhase | undefined;
|
||||
async function enterPhase(
|
||||
phase: import('@mana/shared-ai').IterationPhase,
|
||||
detail?: string
|
||||
): Promise<void> {
|
||||
lastPhase = phase;
|
||||
await setIterationPhase(mission!.id, iterationId, phase, detail);
|
||||
}
|
||||
|
||||
async function runPipeline(): Promise<{
|
||||
recordedSteps: PlanStep[];
|
||||
stagedCount: number;
|
||||
|
|
@ -128,9 +140,7 @@ export async function runMission(
|
|||
planStepCount: number;
|
||||
}> {
|
||||
// ── Phase: resolving-inputs ────────────────────────────
|
||||
await setIterationPhase(
|
||||
mission!.id,
|
||||
iterationId,
|
||||
await enterPhase(
|
||||
'resolving-inputs',
|
||||
mission!.inputs.length > 0 ? `${mission!.inputs.length} Input(s)` : 'keine Inputs'
|
||||
);
|
||||
|
|
@ -139,17 +149,12 @@ export async function runMission(
|
|||
await checkCancel();
|
||||
|
||||
// ── Phase: calling-llm ─────────────────────────────────
|
||||
await setIterationPhase(mission!.id, iterationId, 'calling-llm', 'frage Planner an');
|
||||
await enterPhase('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 enterPhase('parsing-response', `${plan.steps.length} Step(s) erhalten`);
|
||||
await checkCancel();
|
||||
|
||||
// ── Phase: staging-proposals ───────────────────────────
|
||||
|
|
@ -159,12 +164,7 @@ export async function runMission(
|
|||
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 enterPhase('staging-proposals', `Step ${i + 1} von ${plan.steps.length}`);
|
||||
await checkCancel();
|
||||
|
||||
const outcome = await stage(ps, aiActor);
|
||||
|
|
@ -188,7 +188,7 @@ export async function runMission(
|
|||
}
|
||||
}
|
||||
|
||||
await setIterationPhase(mission!.id, iterationId, 'finalizing');
|
||||
await enterPhase('finalizing');
|
||||
return {
|
||||
recordedSteps,
|
||||
stagedCount,
|
||||
|
|
@ -216,6 +216,12 @@ export async function runMission(
|
|||
await finishIteration(mission.id, iterationId, {
|
||||
summary: isCancellation ? msg : `Planner failed: ${msg}`,
|
||||
overallStatus: 'failed',
|
||||
errorDetails: {
|
||||
name: err instanceof Error ? err.name : 'UnknownError',
|
||||
message: msg,
|
||||
phase: lastPhase,
|
||||
stack: err instanceof Error ? err.stack : undefined,
|
||||
},
|
||||
});
|
||||
return emptyResult(mission, iterationId, 'failed', msg);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -276,6 +276,8 @@ export interface FinishIterationInput {
|
|||
overallStatus: MissionIteration['overallStatus'];
|
||||
/** Replace the plan with the post-run state (steps with proposal ids / final statuses). */
|
||||
plan?: PlanStep[];
|
||||
/** Diagnostic detail for failed iterations — surfaced in the UI. */
|
||||
errorDetails?: MissionIteration['errorDetails'];
|
||||
}
|
||||
|
||||
export async function finishIteration(
|
||||
|
|
@ -292,8 +294,16 @@ export async function finishIteration(
|
|||
...it,
|
||||
finishedAt: new Date().toISOString(),
|
||||
overallStatus: input.overallStatus,
|
||||
// Clear in-flight phase markers — the iteration has finalised.
|
||||
currentPhase: undefined,
|
||||
phaseStartedAt: undefined,
|
||||
phaseDetail: undefined,
|
||||
cancelRequested: undefined,
|
||||
...(input.summary !== undefined ? { summary: input.summary } : {}),
|
||||
...(input.plan !== undefined ? { plan: deepClone(input.plan) } : {}),
|
||||
...(input.errorDetails !== undefined
|
||||
? { errorDetails: deepClone(input.errorDetails) }
|
||||
: {}),
|
||||
}
|
||||
: it
|
||||
);
|
||||
|
|
|
|||
|
|
@ -416,6 +416,34 @@
|
|||
{/if}
|
||||
|
||||
{#if it.summary}<p class="it-summary">{it.summary}</p>{/if}
|
||||
|
||||
{#if it.overallStatus === 'failed' && it.errorDetails}
|
||||
<details class="err-details">
|
||||
<summary>
|
||||
<span class="err-name">{it.errorDetails.name}</span>
|
||||
{#if it.errorDetails.phase}
|
||||
<span class="err-phase"
|
||||
>in {PHASE_LABELS[it.errorDetails.phase] ?? it.errorDetails.phase}</span
|
||||
>
|
||||
{/if}
|
||||
</summary>
|
||||
<p class="err-message">{it.errorDetails.message}</p>
|
||||
{#if it.errorDetails.stack}
|
||||
<pre class="err-stack">{it.errorDetails.stack}</pre>
|
||||
{/if}
|
||||
</details>
|
||||
<div class="retry-row">
|
||||
<button
|
||||
type="button"
|
||||
class="retry-btn"
|
||||
disabled={runningNow}
|
||||
onclick={() => handleRunNow(selected)}
|
||||
>
|
||||
{runningNow ? 'Läuft…' : '↻ Erneut versuchen'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if it.userFeedback}
|
||||
<blockquote class="fb">{it.userFeedback}</blockquote>
|
||||
{:else if it.overallStatus === 'awaiting-review'}
|
||||
|
|
@ -743,6 +771,68 @@
|
|||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.err-details {
|
||||
margin-top: 0.375rem;
|
||||
border: 1px solid #f7d7d7;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: color-mix(in oklab, #8a1b1b 4%, transparent);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.err-details summary {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
align-items: center;
|
||||
}
|
||||
.err-name {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-weight: 600;
|
||||
color: #8a1b1b;
|
||||
}
|
||||
.err-phase {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.err-message {
|
||||
margin: 0.375rem 0 0;
|
||||
color: #6a1515;
|
||||
word-break: break-word;
|
||||
}
|
||||
.err-stack {
|
||||
margin: 0.375rem 0 0;
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: hsl(var(--color-surface));
|
||||
border-radius: 0.25rem;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.6875rem;
|
||||
max-height: 10rem;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.retry-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
.retry-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border: 1px solid color-mix(in oklab, hsl(var(--color-primary)) 45%, transparent);
|
||||
border-radius: 0.25rem;
|
||||
background: color-mix(in oklab, hsl(var(--color-primary)) 12%, hsl(var(--color-surface)));
|
||||
color: hsl(var(--color-primary));
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.retry-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.it-summary {
|
||||
margin: 0 0 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
|
|
|
|||
|
|
@ -71,6 +71,20 @@ export interface MissionIteration {
|
|||
* pre-server iterations.
|
||||
*/
|
||||
readonly source?: 'browser' | 'server';
|
||||
/**
|
||||
* Full diagnostic detail for failed iterations. Populated when the
|
||||
* runner catches an error; omitted on success / cancel.
|
||||
*
|
||||
* `phase` is the last phase the iteration was in before failing —
|
||||
* usually enough to diagnose without a stack trace ("timeout in
|
||||
* calling-llm" is already actionable).
|
||||
*/
|
||||
readonly errorDetails?: {
|
||||
readonly name: string;
|
||||
readonly message: string;
|
||||
readonly phase?: IterationPhase;
|
||||
readonly stack?: string;
|
||||
};
|
||||
/** Sub-status while `overallStatus === 'running'`. Undefined otherwise. */
|
||||
readonly currentPhase?: IterationPhase;
|
||||
/** When the runner advanced into the current phase — for elapsed-in-phase. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue