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:
1 parent
9db9bd4cc7
commit
ace5036f00
7 files changed
+118
-26
No files matched your search
@@ -42,7 +42,7 @@ public static class InternalEndpoint
|
||||
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
|
||||
{
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<CliChatResult> RunCliStreamingAsync(
|
||||
|
||||
@@ -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
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
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))
|
||||
return new RemoteCommandResult { ExitCode = -1, Error = "Verbindung nicht gefunden" };
|
||||
@@ -105,26 +114,48 @@ public class RemoteConnectionManager
|
||||
try
|
||||
{
|
||||
conn.PendingResult = new TaskCompletionSource<RemoteCommandResult>();
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// Wird vom WebSocket-Receiver aufgerufen wenn ein Ergebnis vom Client kommt.
|
||||
/// </summary>
|
||||
|
||||
@@ -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";
|
||||
|
||||
+14
-2
@@ -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
|
||||
|
||||
+18
-10
@@ -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
|
||||
|
||||
Reference in new issue
Block a user