Bugtracker-Durchgang: 13 Bugs behoben, Build ohne Warnungen

Alle Behebungen auf der Linux-Test-VM gegengeprueft (Ubuntu, .NET 10.0.111):
Build 0 Warnungen / 0 Fehler, 637 Tests gruen, 1 uebersprungen (WindowsFact).

Sicherheit und Zielplattform
  #373 PathBoundary.Canonicalize fiel offen aus. File.ResolveLinkTarget wirft
       fuer einen fehlenden Abschnitt FileNotFoundException; der catch(IOException)
       verliess daraufhin die GANZE Aufloesung mit dem unaufgeloesten Pfad - und
       der besteht jede Praefixpruefung. Der Symlink-Schutz war damit bei jedem
       Schreibvorgang wirkungslos. Jetzt ueber FileSystemInfo.LinkTarget, und
       TryCanonicalize meldet "nicht bestimmbar", statt fail-open durchzureichen.
  #374 WorkspacePath weist den Backslash rundheraus ab, wie den Doppelpunkt.
       Eine Schreibweise statt einer Sonderregel je Windows-Form.
  #375 Log-Verzeichnis: leer heisst "Anwendungseinstellung"; ein relativer Wert
       zaehlt gegen das Instanzverzeichnis, nicht gegen das Arbeitsverzeichnis
       des Prozesses. Ein systemd-Dienst landet nicht mehr auf /Logs.

Agentenkommunikation
  #206 Delegationskette gegen A->B->A (Test A13). Zyklusfrei per Bauart, dazu
       MaxDelegationDepth gegen die Staffel, die niemand beauftragt hat.
  #461 Zwei unabhaengige Ketten ueber Kreuz - beim Beheben von #206 gefunden und
       reproduziert. Ein delegierter Lauf wartet nur noch begrenzt auf ein fremdes
       Gate; ein Chat vom Menschen reiht sich weiter unbegrenzt ein.
  #234 Instanz-Id wird durchgereicht statt aus einem nur beilaeufig gesetzten Feld
       gelesen.

Betrieb und Oberflaeche
  #376 Log-Ansicht chronologisch statt gruppiert je Moduldatei, mit Stacktraces
       an ihrer Meldung - auch ueber Taktgrenzen hinweg.
  #377 Info-Tabelle: feste Spaltenbreiten, damit ein langer Aenderungstext den
       anderen Spalten nicht den Platz nimmt.
  #378 Sicherung: Mindestabstand zwischen Ueberschrift und Schaltflaechenleiste.
  #379 Hellmodus: drei von vier gedaempften Textpinseln lagen unter 4,5:1. Die
       helle Palette ist als Ganzes verschoben, damit die Staffelung sichtbar
       bleibt statt auf einen Wert zusammenzufallen.

Hygiene
  #233 stock_add: der Indexstand wird innerhalb der Sperre festgehalten.
  #235 Zeitumstellung: eine nicht existierende Ortszeit wird auf den ersten
       Zeitpunkt nach der Luecke verschoben statt uebersprungen (R4, R5).
  #236 45 Nullable-Warnungen und eine CA1416 - nicht unterdrueckt, sondern die
       Stellen so umgebaut, dass die Zusage nachvollziehbar wird. Mail meldet
       jetzt, WELCHER Zugangsschluessel fehlt, statt an einer
       KeyNotFoundException zu scheitern.
  #203 Kompaktiert wird vor dem API-Aufruf statt danach; die gemessenen
       Prompt-Groessen kalibrieren die Schaetzung je Modell.

Neu: tests/ClawdDotNet.App.Tests - fuer #375 und #376 gab es keinen Ort fuer
Tests. Dazu Regressionstests fuer jede Behebung; jeder wurde gegen den alten
Stand geprueft, damit belegt ist, dass er den Fehler faengt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-03 19:04:48 +02:00
co-authored by Claude Opus 5
parent 740649789e
commit cffd6f4b77
44 changed files with 1942 additions and 228 deletions
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ClawdDotNet.App.Tests</RootNamespace>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Shouldly" Version="4.2.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ClawdDotNet.App\ClawdDotNet.App.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1 @@
global using Xunit;
@@ -0,0 +1,97 @@
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Storage;
using Shouldly;
namespace ClawdDotNet.App.Tests;
/// <summary>
/// #375: Die Einstellungsseite zeigte ein Log-Verzeichnis, das nie galt.
///
/// <c>InstanceConfig.LogDirectory</c> war mit <c>"./Logs"</c> vorbelegt und damit nie
/// leer — der Zweig auf die Anwendungseinstellung war unerreichbar. Aufgelöst wurde der
/// Wert zudem mit <c>Path.GetFullPath</c> ohne Basis, also gegen das Arbeitsverzeichnis
/// des Prozesses: Wo die Logs landeten, hing davon ab, aus welchem Verzeichnis jemand
/// gestartet hatte. Für den Zielbetrieb (systemd-Dienst, startet in <c>/</c>) war das
/// <c>/Logs</c> — für ein Dienstkonto nicht anlegbar.
/// </summary>
public sealed class LogDirectoryResolutionTests
{
private static readonly string InstancePath =
Path.GetFullPath(Path.Combine(Path.GetTempPath(), "clawd-tests", "Instance-Test"));
private static readonly string ApplicationLogDirectory =
Path.Combine(AppPaths.DataDirectory, "Logs");
private static InstanceConfig Instance(string logDirectory, string? workingDirectory = null)
=> new()
{
LogDirectory = logDirectory,
WorkingDirectory = workingDirectory ?? InstancePath
};
[Fact]
public void Die_Vorbelegung_ist_leer()
{
// Der Kern des Befunds: Solange hier "./Logs" stand, konnte die
// Anwendungseinstellung nicht greifen.
new InstanceConfig().LogDirectory.ShouldBe("");
}
[Fact]
public void Ohne_Angabe_in_der_Instanz_gilt_die_Anwendungseinstellung()
{
AppHost.ResolveLogDirectory(Instance(""), ApplicationLogDirectory, InstancePath)
.ShouldBe(Path.GetFullPath(ApplicationLogDirectory));
}
[Fact]
public void Ein_absoluter_Wert_in_der_Instanz_geht_vor()
{
var eigen = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "clawd-eigene-logs"));
AppHost.ResolveLogDirectory(Instance(eigen), ApplicationLogDirectory, InstancePath)
.ShouldBe(eigen);
}
[Fact]
public void Ein_relativer_Wert_zaehlt_gegen_das_Instanzverzeichnis()
{
// Nicht gegen das Arbeitsverzeichnis des Prozesses — das war der Fehler.
AppHost.ResolveLogDirectory(Instance("./Logs"), ApplicationLogDirectory, InstancePath)
.ShouldBe(Path.Combine(InstancePath, "Logs"));
}
[Fact]
public void Ein_relativer_Wert_haengt_nicht_am_Arbeitsverzeichnis_des_Prozesses()
{
AppHost.ResolveLogDirectory(Instance("./Logs"), ApplicationLogDirectory, InstancePath)
.ShouldNotBe(Path.GetFullPath("./Logs"));
}
[Fact]
public void Ein_leeres_WorkingDirectory_faellt_auf_den_Instanzpfad_zurueck()
{
// Das Feld steht seit CreateInstance absolut in der Datei, kann aber von Hand
// geleert worden sein. Dann trägt der Ort, aus dem geladen wurde.
AppHost.ResolveLogDirectory(
Instance("Logs", workingDirectory: ""), ApplicationLogDirectory, InstancePath)
.ShouldBe(Path.Combine(InstancePath, "Logs"));
}
[Fact]
public void Ein_relatives_WorkingDirectory_faellt_ebenfalls_auf_den_Instanzpfad_zurueck()
{
// "./data/" ist die alte Vorbelegung des Feldes und wäre wieder gegen das
// Arbeitsverzeichnis des Prozesses aufgelöst worden.
AppHost.ResolveLogDirectory(
Instance("Logs", workingDirectory: "./data/"), ApplicationLogDirectory, InstancePath)
.ShouldBe(Path.Combine(InstancePath, "Logs"));
}
[Fact]
public void Nur_Leerzeichen_gelten_als_keine_Angabe()
{
AppHost.ResolveLogDirectory(Instance(" "), ApplicationLogDirectory, InstancePath)
.ShouldBe(Path.GetFullPath(ApplicationLogDirectory));
}
}
@@ -0,0 +1,88 @@
using System.Text.Json;
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Config;
using Shouldly;
namespace ClawdDotNet.App.Tests.Services;
/// <summary>
/// Die andere Hälfte von #375: Bestehende Instanzen tragen <c>"logDirectory": "./Logs"</c>
/// in ihrer <c>InstanceSettings.json</c> — <see cref="InstanceDirectoryManager.CreateInstance"/>
/// hat es dort hineingeschrieben. Der Wert hatte nie die Bedeutung, die er vorgab, und
/// wird beim Laden zu „leer", also zur Anwendungseinstellung.
///
/// Wer bewusst einen anderen Pfad eingetragen hat, behält ihn.
/// </summary>
public sealed class InstanceLogDirectoryMigrationTests : IDisposable
{
private readonly string _instancesDir;
private readonly InstanceDirectoryManager _manager;
public InstanceLogDirectoryMigrationTests()
{
_instancesDir = Path.Combine(
Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"), "Instances");
_manager = new InstanceDirectoryManager(_instancesDir);
}
public void Dispose()
{
try { Directory.Delete(Path.GetDirectoryName(_instancesDir)!, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
/// <summary>Legt eine Instanz an und setzt logDirectory in der Datei auf den Altwert.</summary>
private string InstanceWithLogDirectory(string value)
{
var dir = _manager.CreateInstance("Test");
var path = Path.Combine(dir, "InstanceSettings.json");
var config = JsonSerializer.Deserialize<InstanceConfig>(File.ReadAllText(path))!;
config.LogDirectory = value;
File.WriteAllText(path, JsonSerializer.Serialize(config));
return dir;
}
[Theory]
[InlineData("./Logs")]
[InlineData(@".\Logs")]
[InlineData("Logs")]
public void Die_alte_Vorbelegung_wird_zu_keiner_Angabe(string alt)
{
var dir = InstanceWithLogDirectory(alt);
_manager.LoadInstanceConfig(dir).LogDirectory.ShouldBe("");
}
[Fact]
public void Ein_bewusst_gesetzter_Pfad_bleibt_stehen()
{
var eigen = Path.Combine(Path.GetTempPath(), "clawd-eigene-logs");
var dir = InstanceWithLogDirectory(eigen);
_manager.LoadInstanceConfig(dir).LogDirectory.ShouldBe(eigen);
}
[Fact]
public void Die_Datei_wird_beim_Laden_nicht_zurueckgeschrieben()
{
// Die Migration greift nur im Speicher. Ein Ladevorgang darf nichts anfassen —
// erst das nächste Speichern lässt den Altwert verschwinden.
var dir = InstanceWithLogDirectory("./Logs");
var path = Path.Combine(dir, "InstanceSettings.json");
_manager.LoadInstanceConfig(dir);
File.ReadAllText(path).ShouldContain("./Logs");
}
[Fact]
public void Eine_neue_Instanz_bekommt_gar_keinen_Wert()
{
var dir = _manager.CreateInstance("Frisch");
_manager.LoadInstanceConfig(dir).LogDirectory.ShouldBe("");
File.ReadAllText(Path.Combine(dir, "InstanceSettings.json")).ShouldNotContain("./Logs");
}
}
@@ -0,0 +1,200 @@
using ClawdDotNet.App.Services;
using Shouldly;
namespace ClawdDotNet.App.Tests.Services;
/// <summary>
/// #376: Jedes Modul schreibt in seine eigene Datei, und eingesammelt wurde Datei für
/// Datei — also alphabetisch nach Modulname. In der Log-Ansicht standen die Zeilen
/// damit in Blöcken je Modul statt in der Reihenfolge, in der sie entstanden sind.
/// Beobachtet wurde eine Zeile von 14:34:40 über vier Zeilen von 14:34:39.
///
/// Das Dateisystem wird hier nicht abstrahiert: <see cref="LogTail"/> ist genau der
/// Umgang mit echten, nebenher wachsenden Dateien — eine Abstraktion würde das
/// Verhalten verstecken, um das es geht.
/// </summary>
public sealed class LogTailTests : IDisposable
{
private readonly string _logDirectory;
private readonly string _today;
public LogTailTests()
{
_logDirectory = Path.Combine(
Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"), "Logs");
// LogTail liest ausschließlich das Verzeichnis des heutigen Tages, und zwar
// nach Ortszeit — die Schreibseite legt es genauso an.
_today = Path.Combine(_logDirectory, DateTime.Now.ToString("yyyy-MM-dd"));
Directory.CreateDirectory(_today);
}
public void Dispose()
{
try { Directory.Delete(Path.GetDirectoryName(_logDirectory)!, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
private void Write(string module, params string[] lines)
=> File.AppendAllLines(Path.Combine(_today, $"{module}.log"), lines);
private LogTail Tail() => new(_logDirectory);
// ═══════════════════════════════════════════════════════════
// Reihenfolge über Moduldateien hinweg
// ═══════════════════════════════════════════════════════════
[Fact]
public void Zeilen_aus_mehreren_Modulen_stehen_in_zeitlicher_Reihenfolge()
{
// Genau der beobachtete Fall: "Deploymentcenter" steht alphabetisch vor
// "Startup", seine Zeile ist aber die jüngste von allen.
Write("Deploymentcenter", "[14:34:40.246] [INF] kein Token hinterlegt");
Write("Startup",
"[14:34:39.840] [INF] ClawdDotNet startet",
"[14:34:39.871] [INF] Instanz-Verzeichnis",
"[14:34:40.238] [WRN] Kein OpenRouter API-Key konfiguriert");
var lines = Tail().ReadNew();
lines.Select(l => l.Text).ShouldBe([
"[14:34:39.840] [INF] ClawdDotNet startet",
"[14:34:39.871] [INF] Instanz-Verzeichnis",
"[14:34:40.238] [WRN] Kein OpenRouter API-Key konfiguriert",
"[14:34:40.246] [INF] kein Token hinterlegt"
]);
}
[Fact]
public void Bei_gleichem_Zeitstempel_bleibt_die_bisherige_Ordnung()
{
// Stabil sortiert: Zwei Zeilen derselben Millisekunde dürfen nicht die Plätze
// tauschen, sonst zerfällt ein zusammenhängender Ablauf innerhalb eines Moduls.
//
// Über Moduldateien hinweg entscheidet dann der Modulname — "Alpha" vor "Beta".
// Directory.GetFiles liefert unter Linux die Verzeichnisreihenfolge; ohne die
// Sortierung in ReadNew stünde hier von Lauf zu Lauf etwas anderes.
Write("Alpha", "[10:00:00.000] [INF] alpha eins", "[10:00:00.000] [INF] alpha zwei");
Write("Beta", "[10:00:00.000] [INF] beta eins");
var lines = Tail().ReadNew();
lines.Select(l => l.Text).ShouldBe([
"[10:00:00.000] [INF] alpha eins",
"[10:00:00.000] [INF] alpha zwei",
"[10:00:00.000] [INF] beta eins"
]);
}
[Fact]
public void Ein_Stacktrace_bleibt_bei_seiner_Meldung()
{
// Eine Ausnahme hängt ihre Zeilen ohne eigenen Zeitstempel unter die Meldung.
// Ohne Sonderbehandlung wären sie ohne Schlüssel nach ganz vorn gerutscht.
Write("Alpha", "[10:00:00.500] [INF] danach");
Write("Engine",
"[10:00:00.100] [ERR] Aufruf fehlgeschlagen",
"System.InvalidOperationException: kaputt",
" bei ClawdDotNet.Engine.Run()");
var lines = Tail().ReadNew();
lines.Select(l => l.Text).ShouldBe([
"[10:00:00.100] [ERR] Aufruf fehlgeschlagen",
"System.InvalidOperationException: kaputt",
" bei ClawdDotNet.Engine.Run()",
"[10:00:00.500] [INF] danach"
]);
}
[Fact]
public void Ein_Stacktrace_bleibt_auch_ueber_die_Taktgrenze_bei_seiner_Meldung()
{
// Der Takt kann mitten in einem Eintrag enden: Die Meldung ist gelesen, die
// Fortsetzung kommt erst beim nächsten Aufruf. Der Zeitstempel muss dann aus
// dem vorigen Takt weitergetragen werden.
var tail = Tail();
Write("Engine", "[10:00:00.100] [ERR] Aufruf fehlgeschlagen");
tail.ReadNew();
Write("Alpha", "[10:00:00.500] [INF] danach");
Write("Engine", " bei ClawdDotNet.Engine.Run()");
var lines = tail.ReadNew();
lines.Select(l => l.Text).ShouldBe([
" bei ClawdDotNet.Engine.Run()",
"[10:00:00.500] [INF] danach"
]);
}
[Fact]
public void Eine_Uhrzeit_mitten_in_der_Meldung_ist_kein_Sortierschluessel()
{
// Gesucht wird der Zeitstempel am Zeilenanfang, nicht irgendeine Uhrzeit im Text.
Write("Alpha", "[10:00:00.100] [INF] naechster Lauf um [23:59:59.999]");
Write("Beta", "[10:00:00.200] [INF] danach");
var lines = Tail().ReadNew();
lines.Select(l => l.Text).ShouldBe([
"[10:00:00.100] [INF] naechster Lauf um [23:59:59.999]",
"[10:00:00.200] [INF] danach"
]);
}
// ═══════════════════════════════════════════════════════════
// Was die Sortierung nicht kaputtmachen darf
// ═══════════════════════════════════════════════════════════
[Fact]
public void Nur_das_Neue_kommt_zurueck()
{
var tail = Tail();
Write("Alpha", "[10:00:00.100] [INF] erste");
tail.ReadNew().Count.ShouldBe(1);
Write("Alpha", "[10:00:00.200] [INF] zweite");
tail.ReadNew().Select(l => l.Text).ShouldBe(["[10:00:00.200] [INF] zweite"]);
}
[Fact]
public void Der_Modulfilter_laesst_nur_sein_Modul_durch()
{
Write("Alpha", "[10:00:00.100] [INF] alpha");
Write("Beta", "[10:00:00.200] [INF] beta");
var lines = Tail().ReadNew(module: "Beta");
lines.Select(l => l.Text).ShouldBe(["[10:00:00.200] [INF] beta"]);
}
[Fact]
public void Eine_weggefilterte_Zeile_traegt_ihren_Zeitstempel_trotzdem_weiter()
{
// Der Stufenfilter darf die Zuordnung der Fortsetzungszeilen nicht verlieren:
// Die Meldung fällt weg, ihr Stacktrace nicht — er hat keine eigene Stufe und
// wird bewusst durchgelassen. Er muss dann an ihrer Stelle stehen.
Write("Alpha", "[10:00:00.500] [ERR] spaeter Fehler");
Write("Engine",
"[10:00:00.100] [INF] harmlos",
" Fortsetzung ohne Stufe");
var lines = Tail().ReadNew(level: LogLevelFilter.Error);
lines.Select(l => l.Text).ShouldBe([
" Fortsetzung ohne Stufe",
"[10:00:00.500] [ERR] spaeter Fehler"
]);
}
[Fact]
public void Ohne_Tagesverzeichnis_kommt_eine_leere_Liste()
{
Directory.Delete(_today, recursive: true);
Tail().ReadNew().ShouldBeEmpty();
}
}