Remote-Keep-Alive gegen 180s-Timeout + System-Prompt via Datei

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 <noreply@anthropic.com>
This commit is contained in:
MelekiandClaude Opus 4.8 committed 2026-07-21 09:21:55 +02:00
1 parent 9db9bd4cc7
commit ace5036f00
7 files changed
+118 -26

No files matched your search

+7 -3
View File
@@ -42,7 +42,7 @@ public static class InternalEndpoint
app.MapPost("/api/internal/memory-list", HandleMemoryList); app.MapPost("/api/internal/memory-list", HandleMemoryList);
} }
private static async Task<IResult> HandleRemoteExecute(HttpContext ctx, RemoteConnectionManager mgr) private static async Task<IResult> HandleRemoteExecute(HttpContext ctx, RemoteConnectionManager mgr, ServerSettingsService settings)
{ {
try try
{ {
@@ -71,8 +71,12 @@ public static class InternalEndpoint
if (conn == null) if (conn == null)
return Results.NotFound(new { error = "Keine Remote-Verbindung für diese Session" }); return Results.NotFound(new { error = "Keine Remote-Verbindung für diese Session" });
using var cts = new CancellationTokenSource(System.TimeSpan.FromSeconds(180)); // Kein hartes Wall-Clock-Limit mehr: ExecuteCommand nutzt einen INAKTIVITÄTS-Timeout
var result = await mgr.ExecuteCommand(conn.Id, command, 180, cts.Token); // (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 return Results.Ok(new
{ {
+7
View File
@@ -138,6 +138,13 @@ public static class RemoteEndpoint
mgr.HandleResult(conn.Id, cmdResult); mgr.HandleResult(conn.Id, cmdResult);
break; 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": case "ping":
var pong = Encoding.UTF8.GetBytes("{\"type\":\"pong\"}"); var pong = Encoding.UTF8.GetBytes("{\"type\":\"pong\"}");
await ws.SendAsync(pong, WebSocketMessageType.Text, true, ct); await ws.SendAsync(pong, WebSocketMessageType.Text, true, ct);
+23 -3
View File
@@ -466,10 +466,19 @@ public class ClaudeCliService
args.Add(modelId); 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)) if (!string.IsNullOrEmpty(systemPrompt))
{ {
args.Add("--system-prompt"); systemPromptFile = Path.Combine(Path.GetTempPath(), $"gott-sysprompt-{Guid.NewGuid():N}.txt");
args.Add(systemPrompt); await File.WriteAllTextAsync(systemPromptFile, systemPrompt); // UTF-8 ohne BOM
args.Add("--system-prompt-file");
args.Add(systemPromptFile);
} }
if (!string.IsNullOrEmpty(mcpConfigPath)) if (!string.IsNullOrEmpty(mcpConfigPath))
@@ -478,7 +487,18 @@ public class ClaudeCliService
args.Add(mcpConfigPath); 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<CliChatResult> RunCliStreamingAsync( private async Task<CliChatResult> RunCliStreamingAsync(
+47 -6
View File
@@ -18,6 +18,10 @@ public class RemoteConnection
public string OsVersion { get; set; } public string OsVersion { get; set; }
public DateTime ConnectedAt { get; set; } public DateTime ConnectedAt { get; set; }
public DateTime LastActivityAt { 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 string AssignedSessionId { get; set; }
public WebSocket Socket { get; set; } public WebSocket Socket { get; set; }
@@ -92,8 +96,13 @@ public class RemoteConnectionManager
/// <summary> /// <summary>
/// Sendet einen Befehl an den Remote-Client und wartet auf das Ergebnis. /// Sendet einen Befehl an den Remote-Client und wartet auf das Ergebnis.
/// <paramref name="inactivityTimeoutSeconds"/> 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.
/// </summary> /// </summary>
public async Task<RemoteCommandResult> ExecuteCommand(string connectionId, string command, int timeoutSeconds = 180, CancellationToken ct = default) public async Task<RemoteCommandResult> ExecuteCommand(string connectionId, string command, int inactivityTimeoutSeconds = 180, CancellationToken ct = default)
{ {
if (!_connections.TryGetValue(connectionId, out var conn)) if (!_connections.TryGetValue(connectionId, out var conn))
return new RemoteCommandResult { ExitCode = -1, Error = "Verbindung nicht gefunden" }; return new RemoteCommandResult { ExitCode = -1, Error = "Verbindung nicht gefunden" };
@@ -105,26 +114,48 @@ public class RemoteConnectionManager
try try
{ {
conn.PendingResult = new TaskCompletionSource<RemoteCommandResult>(); conn.PendingResult = new TaskCompletionSource<RemoteCommandResult>();
conn.LastProgressAt = DateTime.UtcNow;
// Befehl senden // Befehl senden
var msg = JsonSerializer.Serialize(new { type = "execute", command }); var msg = JsonSerializer.Serialize(new { type = "execute", command });
var bytes = Encoding.UTF8.GetBytes(msg); var bytes = Encoding.UTF8.GetBytes(msg);
await conn.Socket.SendAsync(bytes, WebSocketMessageType.Text, true, ct); await conn.Socket.SendAsync(bytes, WebSocketMessageType.Text, true, ct);
// Auf Ergebnis warten (mit Timeout)
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); 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 try
{ {
var registration = cts.Token.Register(() => conn.PendingResult.TrySetCanceled());
var result = await conn.PendingResult.Task; var result = await conn.PendingResult.Task;
registration.Dispose();
return result; return result;
} }
catch (TaskCanceledException) 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 finally
@@ -134,6 +165,16 @@ public class RemoteConnectionManager
} }
} }
/// <summary>
/// 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.
/// </summary>
public void HandleProgress(string connectionId)
{
if (_connections.TryGetValue(connectionId, out var conn))
conn.LastProgressAt = DateTime.UtcNow;
}
/// <summary> /// <summary>
/// Wird vom WebSocket-Receiver aufgerufen wenn ein Ergebnis vom Client kommt. /// Wird vom WebSocket-Receiver aufgerufen wenn ein Ergebnis vom Client kommt.
/// </summary> /// </summary>
+2 -2
View File
@@ -357,8 +357,8 @@ public class ToolService
{ {
var isLinux = (remoteOs ?? "").Contains("Linux", StringComparison.OrdinalIgnoreCase); var isLinux = (remoteOs ?? "").Contains("Linux", StringComparison.OrdinalIgnoreCase);
var desc = isLinux 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 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). 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). 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 var cmdDesc = isLinux
? "Bash-Befehl zum Ausführen auf dem Remote-PC" ? "Bash-Befehl zum Ausführen auf dem Remote-PC"
: "PowerShell-Befehl zum Ausführen auf dem Remote-PC"; : "PowerShell-Befehl zum Ausführen auf dem Remote-PC";
+14 -2
View File
@@ -127,10 +127,22 @@ try {
$proc.StartInfo.UseShellExecute = $false $proc.StartInfo.UseShellExecute = $false
$proc.StartInfo.CreateNoWindow = $true $proc.StartInfo.CreateNoWindow = $true
$proc.Start() | Out-Null $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() $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() $stderrTask.Wait()
$stdout = $stdoutTask.Result
$stderr = $stderrTask.Result $stderr = $stderrTask.Result
$proc.WaitForExit() $proc.WaitForExit()
$exitCode = $proc.ExitCode $exitCode = $proc.ExitCode
+18 -10
View File
@@ -272,18 +272,26 @@ def main():
stderr = "" stderr = ""
try: try:
result = subprocess.run( proc = subprocess.Popen(
["bash", "-c", command], ["bash", "-c", command],
capture_output=True, stdout=subprocess.PIPE,
text=True, stderr=subprocess.PIPE,
timeout=300 text=True
) )
stdout = result.stdout # Warten und dabei periodisch ein Keep-Alive senden, damit lange Befehle
stderr = result.stderr # nicht in den Server-Inaktivitaets-Timeout laufen. communicate(timeout)
exit_code = result.returncode # liest die Pipes leer (kein Deadlock) und laesst den Prozess bei einem
except subprocess.TimeoutExpired: # Timeout weiterlaufen -> beliebig lange Laufzeit moeglich.
stderr = "Timeout nach 300 Sekunden" while True:
exit_code = 124 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: except Exception as e:
stderr = str(e) stderr = str(e)
exit_code = 1 exit_code = 1