Workflow-Totalverlust beheben: inkrementelle Persistenz + Token-Fix + Resume

Bisher wurden Workflow-Subagent-Outputs nur beim sauberen FinalizeChain in
die DB gerettet - bei Abbruch (529/Timeout/Stop/Hard-Kill) war alles weg,
Tokens standen auf 0.

- A) Inkrementelle Persistenz: MaybeFlushWorkflowProgress() (throttled 6s)
  schreibt workflow_info + Token + runId waehrend des Laufs -> ueberlebt
  Hard-Kill (OOM/Redeploy), wo kein catch/finally laeuft.
- B) Output-Rettung: ReadLive liest jetzt vollstaendige Subagent-Outputs aus
  journal.jsonl (nicht nur Counts), inkl. State.
- C) Token-Fix: neuer assistant-Branch -> CliEventType.Usage; EffectiveUsage()
  rettet Orchestrator-Token bei Abbruch statt 0.
- D) Resume: Feld/Migration workflow_run_id; FinalizeChain persistiert die
  runId; naechster Chat injiziert einen resumeFromRunId-Hinweis.
- UI: Workflow-Status-Modal zeigt geretteten Output + runId/"fortsetzbar"-Banner.

Build 0/0, ReadLive gegen echtes journal.jsonl verifiziert, adversarial 2x geprueft.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MelekiandClaude Opus 4.8 committed 2026-07-02 16:14:47 +02:00
1 parent 5f7dec6448
commit 3ae94fb697
9 files changed
+217 -19

No files matched your search

+73 -3
View File
@@ -54,6 +54,19 @@ public partial class AnthropicService
// Panel-Member-Tokens (Fusion/MoA) vorab in die Chain-Usage falten → erscheinen im
// done-Event (Live-Badge) UND in FinalizeChain (DB, überlebt F5). Null/leer = No-op.
if (seedUsage != null) usage.Add(seedUsage);
// Token-Fix bei Abbruch: Die Token stammen aus dem finalen result-Event – das kommt bei einem
// Abbruch NIE, also bliebe usage 0. Wir akkumulieren die Orchestrator-Usage live aus den
// assistant-Turns (CliEventType.Usage) in partialUsage und nehmen bei Abbruch/Zwischen-Persistenz
// das jeweils Größere pro Feld (kein Doppelzählen mit dem normalen result-Pfad).
var partialUsage = new TokenUsage();
TokenUsage EffectiveUsage() => new TokenUsage
{
InputTokens = Math.Max(usage.InputTokens, partialUsage.InputTokens),
OutputTokens = Math.Max(usage.OutputTokens, partialUsage.OutputTokens),
CacheCreationTokens = Math.Max(usage.CacheCreationTokens, partialUsage.CacheCreationTokens),
CacheReadTokens = Math.Max(usage.CacheReadTokens, partialUsage.CacheReadTokens)
};
var lastWfFlush = DateTime.MinValue; // Throttle für die inkrementelle Workflow-Persistenz
var fullText = new StringBuilder();
var tempImagePaths = new List<string>();
string mcpConfigPath = null;
@@ -119,6 +132,32 @@ public partial class AnthropicService
}
}
// ── Resume-Hinweis: Lief in dieser Session zuletzt ein Workflow, der ABGEBROCHEN wurde,
// injizieren wir seine runId in den System-Prompt. Der Agent kann den Workflow dann mit
// resumeFromRunId erneut starten → bereits fertige Subagenten werden aus dem Journal
// wiederverwendet statt neu ausgeführt. Best-effort (hängt an LLM-Kooperation). Nur bei
// ultracode relevant (nur dann kann der Agent überhaupt das Workflow-Tool nutzen).
if (ultracode && !string.IsNullOrEmpty(sessionId))
{
try
{
var priorChains = await _db.GetActiveChains(sessionId); // ORDER BY id DESC
// Nur der ZULETZT gelaufene Workflow zählt: ist er abgeschlossen → kein Resume;
// wurde er abgebrochen → Hinweis anbieten.
var lastWf = priorChains.FirstOrDefault(c =>
c.ChainId != chainId && !string.IsNullOrEmpty(c.WorkflowRunId));
if (lastWf != null && lastWf.StopReason != "end_turn"
&& !(lastWf.WorkflowInfo != null && lastWf.WorkflowInfo.Contains("\"Status\":\"completed\"")))
{
systemText.AppendLine(
$"\n<workflow_resume>\nIn dieser Session wurde zuletzt ein dynamischer Workflow (runId: {lastWf.WorkflowRunId}) vorzeitig beendet (Grund: {lastWf.StopReason ?? "unbekannt"}) und ist unvollständig. Falls die aktuelle Aufgabe die Fortsetzung genau dieses Workflows ist: starte ihn erneut mit DEMSELBEN Skript und übergib dem Workflow-Tool resumeFromRunId: \"{lastWf.WorkflowRunId}\" – bereits abgeschlossene Subagenten werden dann aus dem Journal wiederverwendet statt neu ausgeführt (spart Token und Zeit). Ist die aktuelle Aufgabe NICHT die Fortsetzung, ignoriere diesen Hinweis vollständig.\n</workflow_resume>");
await SseHelper.TryWriteSse(response, "workflow_resume_available",
new { runId = lastWf.WorkflowRunId, stopReason = lastWf.StopReason }, sseCt);
}
}
catch (Exception ex) { Console.WriteLine($"[Wf-Resume/CLI] Error: {ex.Message}"); }
}
// Letzte User-Message als Prompt extrahieren
var prompt = ExtractLastUserText(messages) ?? "";
@@ -282,7 +321,38 @@ public partial class AnthropicService
}
break;
}
case CliEventType.Usage:
// Orchestrator-Usage live akkumulieren (Token-Fix-Fallback für Abbruch):
// Output/Cache summieren (pro Turn additiv), Input = größter Turn-Wert
// (Kontextgröße wächst pro Turn – Summieren würde massiv doppelt zählen).
partialUsage.OutputTokens += evt.OutputTokens;
partialUsage.CacheReadTokens += evt.CacheReadTokens;
partialUsage.CacheCreationTokens += evt.CacheCreationTokens;
if (evt.InputTokens > partialUsage.InputTokens) partialUsage.InputTokens = evt.InputTokens;
break;
}
// Inkrementelle Zwischen-Persistenz (throttled) – so überlebt der letzte Workflow-Stand
// (Subagent-Outputs + Token) auch einen Hard-Kill (OOM/Redeploy), bei dem KEIN catch läuft.
await MaybeFlushWorkflowProgress();
}
// Persistiert – höchstens alle ~6 s – die aktuelle Workflow-Telemetrie dieser Chain in die DB.
async Task MaybeFlushWorkflowProgress()
{
if (_workflowTracker == null || string.IsNullOrEmpty(sessionId)) return;
if (_workflowTracker.ChainIdFor(sessionId) != chainId) return; // nur bei Workflow FÜR diese Chain
var now = DateTime.UtcNow;
if ((now - lastWfFlush).TotalSeconds < 6) return; // Throttle
lastWfFlush = now;
try
{
var run = _workflowTracker.Get(sessionId);
var wp = WorkflowTelemetryReader.Read(run);
if (wp == null) return;
wp.ChainId = chainId;
await _db.UpdateChainWorkflowProgress(chainId, JsonSerializer.Serialize(wp), run?.RunId, EffectiveUsage());
}
catch { /* inkrementelle Persistenz darf den Stream nie stören */ }
}
do
@@ -440,7 +510,7 @@ public partial class AnthropicService
// Wenn ask_user_choice aktiv war: als awaiting_user_choice markieren,
// damit die nächste Chain den Kontext (Frage + Optionen) behält.
var stoppedReason = isUserChoice ? "awaiting_user_choice" : "stopped";
await FinalizeChain(chain, usage, 1, stoppedReason, sw);
await FinalizeChain(chain, EffectiveUsage(), 1, stoppedReason, sw);
await SseHelper.TryWriteSse(response, "done", new
{
model = modelId,
@@ -456,7 +526,7 @@ public partial class AnthropicService
// Client-Disconnect (sseCt gecancelled, aber NICHT workCt)
// CLI-Prozess läuft weiter im Hintergrund – hier nur Chain finalisieren
// (sollte nicht mehr passieren, da CLI jetzt workCt nutzt)
await FinalizeChain(chain, usage, 1, "client_disconnected", sw);
await FinalizeChain(chain, EffectiveUsage(), 1, "client_disconnected", sw);
}
catch (Exception ex)
{
@@ -470,7 +540,7 @@ public partial class AnthropicService
Content = errorMsg
});
await SseHelper.TryWriteSse(response, "error", new { error = errorMsg }, sseCt);
await FinalizeChain(chain, usage, 1, "error", sw);
await FinalizeChain(chain, EffectiveUsage(), 1, "error", sw);
await SseHelper.TryWriteSse(response, "done", new
{
model = modelId,