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>
189 lines
6.9 KiB
C#
189 lines
6.9 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Net.WebSockets;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace Gott.Services;
|
|
|
|
public class RemoteConnection
|
|
{
|
|
public string Id { get; set; }
|
|
public string Hostname { get; set; }
|
|
public string Username { get; set; }
|
|
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; }
|
|
|
|
// Für synchrone Command-Execution: ein Befehl wird gesendet, Ergebnis abgewartet
|
|
public SemaphoreSlim CommandLock { get; } = new SemaphoreSlim(1, 1);
|
|
public TaskCompletionSource<RemoteCommandResult> PendingResult { get; set; }
|
|
}
|
|
|
|
public class RemoteCommandResult
|
|
{
|
|
public int ExitCode { get; set; }
|
|
public string Output { get; set; }
|
|
public string Error { get; set; }
|
|
}
|
|
|
|
public class RemoteConnectionManager
|
|
{
|
|
private readonly ConcurrentDictionary<string, RemoteConnection> _connections = new();
|
|
|
|
public RemoteConnection Register(WebSocket socket)
|
|
{
|
|
var conn = new RemoteConnection
|
|
{
|
|
Id = Guid.NewGuid().ToString("N")[..12],
|
|
ConnectedAt = DateTime.UtcNow,
|
|
LastActivityAt = DateTime.UtcNow,
|
|
Socket = socket
|
|
};
|
|
_connections[conn.Id] = conn;
|
|
return conn;
|
|
}
|
|
|
|
public void Unregister(string connectionId)
|
|
{
|
|
if (_connections.TryRemove(connectionId, out var conn))
|
|
{
|
|
// Falls ein Befehl auf Antwort wartet, abbrechen
|
|
conn.PendingResult?.TrySetCanceled();
|
|
conn.AssignedSessionId = null;
|
|
}
|
|
}
|
|
|
|
public List<RemoteConnection> GetAll()
|
|
{
|
|
return _connections.Values.ToList();
|
|
}
|
|
|
|
public RemoteConnection GetById(string connectionId)
|
|
{
|
|
_connections.TryGetValue(connectionId, out var conn);
|
|
return conn;
|
|
}
|
|
|
|
public RemoteConnection GetBySessionId(string sessionId)
|
|
{
|
|
return _connections.Values.FirstOrDefault(c => c.AssignedSessionId == sessionId);
|
|
}
|
|
|
|
public bool Assign(string connectionId, string sessionId)
|
|
{
|
|
if (!_connections.TryGetValue(connectionId, out var conn)) return false;
|
|
conn.AssignedSessionId = sessionId;
|
|
return true;
|
|
}
|
|
|
|
public bool Unassign(string connectionId)
|
|
{
|
|
if (!_connections.TryGetValue(connectionId, out var conn)) return false;
|
|
conn.AssignedSessionId = null;
|
|
return true;
|
|
}
|
|
|
|
/// <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 inactivityTimeoutSeconds = 180, CancellationToken ct = default)
|
|
{
|
|
if (!_connections.TryGetValue(connectionId, out var conn))
|
|
return new RemoteCommandResult { ExitCode = -1, Error = "Verbindung nicht gefunden" };
|
|
|
|
if (conn.Socket.State != WebSocketState.Open)
|
|
return new RemoteCommandResult { ExitCode = -1, Error = "WebSocket nicht verbunden" };
|
|
|
|
await conn.CommandLock.WaitAsync(ct);
|
|
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);
|
|
|
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
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 result = await conn.PendingResult.Task;
|
|
return result;
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
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
|
|
{
|
|
conn.PendingResult = null;
|
|
conn.CommandLock.Release();
|
|
}
|
|
}
|
|
|
|
/// <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>
|
|
public void HandleResult(string connectionId, RemoteCommandResult result)
|
|
{
|
|
if (_connections.TryGetValue(connectionId, out var conn))
|
|
{
|
|
conn.PendingResult?.TrySetResult(result);
|
|
}
|
|
}
|
|
}
|