From ace5036f0081572cdae8016cd7a5bf518b0ea2db Mon Sep 17 00:00:00 2001 From: Meleki Date: Tue, 21 Jul 2026 09:21:55 +0200 Subject: [PATCH] Remote-Keep-Alive gegen 180s-Timeout + System-Prompt via Datei MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 1 – Remote-Befehle laufen nicht mehr in einen starren 180s-Timeout: - RemoteConnectionManager.ExecuteCommand nutzt jetzt einen INAKTIVITÄTS- Timeout (Watchdog auf LastProgressAt) statt hartem Wall-Clock-Limit; ein Befehl darf beliebig lange laufen, solange der Client Progress-Frames sendet. - RemoteEndpoint: neuer "progress"-Case setzt die Frist zurück. - InternalEndpoint (MCP-Pfad remote_execute): hartes CancellationTokenSource(180s) entfernt → nutzt konfigurierbares ExecuteTimeout-Setting + RequestAborted. - remote.ps1 / remote.sh: senden während laufender Befehle alle 15s ein {"type":"progress"}-Keep-Alive; async stdout/stderr-Lesen gegen Pipe-Deadlocks. - ToolService: Tool-Beschreibungen erklären den Inaktivitäts-Timeout. Task 2 – System-Prompt über --system-prompt-file statt CLI-Argument: - ClaudeCliService schreibt den vollen System-Prompt in eine Temp-Datei und übergibt --system-prompt-file, um das Windows-Kommandozeilen-Längenlimit zu umgehen. Cleanup im finally. Agent-Verhalten unverändert (MEMORY.md bleibt inline). Co-Authored-By: Claude Opus 4.8 --- Endpoints/InternalEndpoint.cs | 10 ++++-- Endpoints/RemoteEndpoint.cs | 7 ++++ Services/ClaudeCliService.cs | 26 ++++++++++++-- Services/RemoteConnectionManager.cs | 53 +++++++++++++++++++++++++---- Services/ToolService.cs | 4 +-- wwwroot/remote.ps1 | 16 +++++++-- wwwroot/remote.sh | 28 +++++++++------ 7 files changed, 118 insertions(+), 26 deletions(-) diff --git a/Endpoints/InternalEndpoint.cs b/Endpoints/InternalEndpoint.cs index 6b164b8..d70c3f4 100644 --- a/Endpoints/InternalEndpoint.cs +++ b/Endpoints/InternalEndpoint.cs @@ -42,7 +42,7 @@ public static class InternalEndpoint app.MapPost("/api/internal/memory-list", HandleMemoryList); } - private static async Task HandleRemoteExecute(HttpContext ctx, RemoteConnectionManager mgr) + private static async Task HandleRemoteExecute(HttpContext ctx, RemoteConnectionManager mgr, ServerSettingsService settings) { try { @@ -71,8 +71,12 @@ public static class InternalEndpoint if (conn == null) return Results.NotFound(new { error = "Keine Remote-Verbindung für diese Session" }); - using var cts = new CancellationTokenSource(System.TimeSpan.FromSeconds(180)); - var result = await mgr.ExecuteCommand(conn.Id, command, 180, cts.Token); + // Kein hartes Wall-Clock-Limit mehr: ExecuteCommand nutzt einen INAKTIVITÄTS-Timeout + // (Client sendet Progress-Keep-Alives). Als Frist die konfigurierbare ExecuteTimeout- + // Einstellung verwenden; abgebrochen wird nur bei echtem Client-Stillstand oder wenn + // der Request selbst abbricht (RequestAborted). + var inactivityTimeout = settings.GetInt("ExecuteTimeout", 180); + var result = await mgr.ExecuteCommand(conn.Id, command, inactivityTimeout, ctx.RequestAborted); return Results.Ok(new { diff --git a/Endpoints/RemoteEndpoint.cs b/Endpoints/RemoteEndpoint.cs index 25ab222..b707554 100644 --- a/Endpoints/RemoteEndpoint.cs +++ b/Endpoints/RemoteEndpoint.cs @@ -138,6 +138,13 @@ public static class RemoteEndpoint mgr.HandleResult(conn.Id, cmdResult); break; + case "progress": + // Keep-Alive während eines laufenden Befehls: Inaktivitäts-Timeout in + // ExecuteCommand zurücksetzen (LastActivityAt oben deckt zusätzlich den + // WebSocket-Dead-Timeout ab). So laufen lange Befehle nicht in den Timeout. + mgr.HandleProgress(conn.Id); + break; + case "ping": var pong = Encoding.UTF8.GetBytes("{\"type\":\"pong\"}"); await ws.SendAsync(pong, WebSocketMessageType.Text, true, ct); diff --git a/Services/ClaudeCliService.cs b/Services/ClaudeCliService.cs index 3767feb..0a68156 100644 --- a/Services/ClaudeCliService.cs +++ b/Services/ClaudeCliService.cs @@ -466,10 +466,19 @@ public class ClaudeCliService args.Add(modelId); } + // System-Prompt NICHT als CLI-Argument, sondern über eine Temp-Datei übergeben. + // Grund: Der volle System-Prompt (Identity + Soul + Memory/MEMORY.md + Skills + + // recalled_context) kann sehr groß werden und unter Windows das Kommandozeilen- + // Längenlimit (~32k Zeichen bzw. 8191 bei cmd.exe-Shims) sprengen → CLI-Start scheitert. + // --system-prompt-file umgeht das komplett (CLI v2.1.183+ unterstützt das Flag, + // verifiziert am Binary). Semantik identisch zu --system-prompt (Override, kein Append). + string systemPromptFile = null; if (!string.IsNullOrEmpty(systemPrompt)) { - args.Add("--system-prompt"); - args.Add(systemPrompt); + systemPromptFile = Path.Combine(Path.GetTempPath(), $"gott-sysprompt-{Guid.NewGuid():N}.txt"); + await File.WriteAllTextAsync(systemPromptFile, systemPrompt); // UTF-8 ohne BOM + args.Add("--system-prompt-file"); + args.Add(systemPromptFile); } if (!string.IsNullOrEmpty(mcpConfigPath)) @@ -478,7 +487,18 @@ public class ClaudeCliService args.Add(mcpConfigPath); } - return await RunCliStreamingAsync(args, prompt, onEvent, cancellationToken, effortToUse, workspace, modelId, sessionId, chainId); + try + { + return await RunCliStreamingAsync(args, prompt, onEvent, cancellationToken, effortToUse, workspace, modelId, sessionId, chainId); + } + finally + { + // Temp-Datei nach CLI-Ende wieder entfernen + if (systemPromptFile != null) + { + try { File.Delete(systemPromptFile); } catch { } + } + } } private async Task RunCliStreamingAsync( diff --git a/Services/RemoteConnectionManager.cs b/Services/RemoteConnectionManager.cs index bc0e0fd..1632ee4 100644 --- a/Services/RemoteConnectionManager.cs +++ b/Services/RemoteConnectionManager.cs @@ -18,6 +18,10 @@ public class RemoteConnection public string OsVersion { get; set; } public DateTime ConnectedAt { get; set; } public DateTime LastActivityAt { get; set; } + // Zeitpunkt der letzten Aktivität WÄHREND eines laufenden Befehls (progress/result). + // Basis für den Inaktivitäts-Timeout in ExecuteCommand: Solange der Client periodisch + // Progress-Frames sendet, darf ein Befehl beliebig lange laufen. + public DateTime LastProgressAt { get; set; } public string AssignedSessionId { get; set; } public WebSocket Socket { get; set; } @@ -92,8 +96,13 @@ public class RemoteConnectionManager /// /// Sendet einen Befehl an den Remote-Client und wartet auf das Ergebnis. + /// ist ein INAKTIVITÄTS-Timeout (kein hartes + /// Wall-Clock-Limit): Der Befehl darf beliebig lange laufen, solange der Client periodisch + /// Progress-Frames ({"type":"progress"}) sendet. Erst wenn für diese Dauer KEINE Aktivität + /// (progress oder result) mehr kommt, wird abgebrochen. So laufen schwere Aufgaben nicht mehr + /// in einen starren 180s-Timeout. /// - public async Task ExecuteCommand(string connectionId, string command, int timeoutSeconds = 180, CancellationToken ct = default) + public async Task ExecuteCommand(string connectionId, string command, int inactivityTimeoutSeconds = 180, CancellationToken ct = default) { if (!_connections.TryGetValue(connectionId, out var conn)) return new RemoteCommandResult { ExitCode = -1, Error = "Verbindung nicht gefunden" }; @@ -105,26 +114,48 @@ public class RemoteConnectionManager try { conn.PendingResult = new TaskCompletionSource(); + conn.LastProgressAt = DateTime.UtcNow; // Befehl senden var msg = JsonSerializer.Serialize(new { type = "execute", command }); var bytes = Encoding.UTF8.GetBytes(msg); await conn.Socket.SendAsync(bytes, WebSocketMessageType.Text, true, ct); - // Auf Ergebnis warten (mit Timeout) using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); - cts.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds)); + using var registration = cts.Token.Register(() => conn.PendingResult.TrySetCanceled()); + + // Watchdog: bricht nur ab, wenn seit inactivityTimeoutSeconds keine Progress-/ + // Result-Aktivität mehr vom Client kam. Progress-Frames setzen conn.LastProgressAt + // (siehe RemoteEndpoint.ReceiveLoop) und verlängern damit die Frist. + var watchdog = Task.Run(async () => + { + try + { + while (!cts.IsCancellationRequested) + { + var idleSec = (DateTime.UtcNow - conn.LastProgressAt).TotalSeconds; + var remaining = inactivityTimeoutSeconds - idleSec; + if (remaining <= 0) { cts.Cancel(); return; } + // Häufig genug prüfen, aber nicht busy-spinnen (max 5s Takt) + await Task.Delay(TimeSpan.FromSeconds(Math.Min(remaining, 5)), cts.Token); + } + } + catch (OperationCanceledException) { } + }, cts.Token); try { - var registration = cts.Token.Register(() => conn.PendingResult.TrySetCanceled()); var result = await conn.PendingResult.Task; - registration.Dispose(); return result; } catch (TaskCanceledException) { - return new RemoteCommandResult { ExitCode = -1, Error = $"Timeout nach {timeoutSeconds} Sekunden" }; + return new RemoteCommandResult { ExitCode = -1, Error = $"Timeout: keine Aktivität vom Remote-Client für {inactivityTimeoutSeconds}s" }; + } + finally + { + cts.Cancel(); + try { await watchdog; } catch { } } } finally @@ -134,6 +165,16 @@ public class RemoteConnectionManager } } + /// + /// Wird vom WebSocket-Receiver aufgerufen wenn ein Progress-/Keep-Alive-Frame vom Client kommt. + /// Setzt den Inaktivitäts-Zähler zurück, damit ein lang laufender Befehl nicht abgebrochen wird. + /// + public void HandleProgress(string connectionId) + { + if (_connections.TryGetValue(connectionId, out var conn)) + conn.LastProgressAt = DateTime.UtcNow; + } + /// /// Wird vom WebSocket-Receiver aufgerufen wenn ein Ergebnis vom Client kommt. /// diff --git a/Services/ToolService.cs b/Services/ToolService.cs index 244febb..9547b2d 100644 --- a/Services/ToolService.cs +++ b/Services/ToolService.cs @@ -357,8 +357,8 @@ public class ToolService { var isLinux = (remoteOs ?? "").Contains("Linux", StringComparison.OrdinalIgnoreCase); var desc = isLinux - ? "Führt einen Bash-Befehl auf der verbundenen Remote-Linux-Workstation aus. Gibt stdout und stderr zurück. Nutze dies für Diagnose und Administration des Remote-PCs (z.B. Netzwerk, Services, Logs, Pakete). Timeout: 180 Sekunden." - : "Führt einen PowerShell-Befehl auf der verbundenen Remote-Windows-Workstation aus. Gibt stdout und stderr zurück. Nutze dies für Diagnose und Administration des Remote-PCs (z.B. Druckerprobleme, Netzwerk, Services, Registry). Timeout: 180 Sekunden."; + ? "Führt einen Bash-Befehl auf der verbundenen Remote-Linux-Workstation aus. Gibt stdout und stderr zurück. Nutze dies für Diagnose und Administration des Remote-PCs (z.B. Netzwerk, Services, Logs, Pakete). Lange Befehle sind ok: abgebrochen wird nur, wenn der Client für längere Zeit gar keine Aktivität mehr meldet (Inaktivitäts-Timeout, konfigurierbar über ExecuteTimeout)." + : "Führt einen PowerShell-Befehl auf der verbundenen Remote-Windows-Workstation aus. Gibt stdout und stderr zurück. Nutze dies für Diagnose und Administration des Remote-PCs (z.B. Druckerprobleme, Netzwerk, Services, Registry). Lange Befehle sind ok: abgebrochen wird nur, wenn der Client für längere Zeit gar keine Aktivität mehr meldet (Inaktivitäts-Timeout, konfigurierbar über ExecuteTimeout)."; var cmdDesc = isLinux ? "Bash-Befehl zum Ausführen auf dem Remote-PC" : "PowerShell-Befehl zum Ausführen auf dem Remote-PC"; diff --git a/wwwroot/remote.ps1 b/wwwroot/remote.ps1 index 58b908e..59c9d72 100644 --- a/wwwroot/remote.ps1 +++ b/wwwroot/remote.ps1 @@ -127,10 +127,22 @@ try { $proc.StartInfo.UseShellExecute = $false $proc.StartInfo.CreateNoWindow = $true $proc.Start() | Out-Null - # stderr async lesen um Deadlocks zu vermeiden + # stdout+stderr async lesen um Pipe-Deadlocks zu vermeiden + $stdoutTask = $proc.StandardOutput.ReadToEndAsync() $stderrTask = $proc.StandardError.ReadToEndAsync() - $stdout = $proc.StandardOutput.ReadToEnd() + # Auf Prozessende warten und dabei periodisch ein Keep-Alive senden, + # damit lange Befehle nicht in den Server-Inaktivitaets-Timeout laufen. + $lastProgress = [DateTime]::UtcNow + while (-not $proc.HasExited) { + Start-Sleep -Milliseconds 500 + if (([DateTime]::UtcNow - $lastProgress).TotalSeconds -ge 15) { + try { Send-WsMessage $ws '{"type":"progress"}' $ct } catch { } + $lastProgress = [DateTime]::UtcNow + } + } + $stdoutTask.Wait() $stderrTask.Wait() + $stdout = $stdoutTask.Result $stderr = $stderrTask.Result $proc.WaitForExit() $exitCode = $proc.ExitCode diff --git a/wwwroot/remote.sh b/wwwroot/remote.sh index 8341f4e..067be53 100644 --- a/wwwroot/remote.sh +++ b/wwwroot/remote.sh @@ -272,18 +272,26 @@ def main(): stderr = "" try: - result = subprocess.run( + proc = subprocess.Popen( ["bash", "-c", command], - capture_output=True, - text=True, - timeout=300 + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True ) - stdout = result.stdout - stderr = result.stderr - exit_code = result.returncode - except subprocess.TimeoutExpired: - stderr = "Timeout nach 300 Sekunden" - exit_code = 124 + # Warten und dabei periodisch ein Keep-Alive senden, damit lange Befehle + # nicht in den Server-Inaktivitaets-Timeout laufen. communicate(timeout) + # liest die Pipes leer (kein Deadlock) und laesst den Prozess bei einem + # Timeout weiterlaufen -> beliebig lange Laufzeit moeglich. + while True: + try: + stdout, stderr = proc.communicate(timeout=15) + break + except subprocess.TimeoutExpired: + try: + ws.send('{"type":"progress"}') + except Exception: + pass + exit_code = proc.returncode except Exception as e: stderr = str(e) exit_code = 1