diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Logging/AgentLoggerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Logging/AgentLoggerTests.cs
new file mode 100644
index 00000000..b20b59f4
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Logging/AgentLoggerTests.cs
@@ -0,0 +1,114 @@
+#nullable enable
+using System;
+using System.IO;
+using System.Linq;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Logging
+{
+ ///
+ /// PR2: file-size cap rotation. Long-running verbose sessions used to grow the daily log
+ /// into hundreds of MB; the logger now rolls over to a numeric suffix once a segment
+ /// reaches the cap, keeping every byte but in manageable files.
+ ///
+ public sealed class AgentLoggerTests
+ {
+ private static string TodayBaseName => $"agent_{DateTime.Now:yyyyMMdd}";
+
+ [Fact]
+ public void Log_writes_to_unsuffixed_base_file_when_dir_is_empty()
+ {
+ using var tmp = new TempDirectory();
+ var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info, maxFileSizeBytes: 1024L * 1024);
+
+ logger.Info("hello");
+
+ var basePath = Path.Combine(tmp.Path, TodayBaseName + ".log");
+ Assert.True(File.Exists(basePath), $"expected base file at {basePath}");
+ Assert.Contains("hello", File.ReadAllText(basePath));
+ }
+
+ [Fact]
+ public void Log_rotates_when_active_segment_crosses_cap()
+ {
+ using var tmp = new TempDirectory();
+ // 1 KB cap so rotation triggers after a few writes.
+ var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info, maxFileSizeBytes: 1024);
+
+ // Each line ~80 bytes after timestamp/severity envelope; 30 lines clears 1 KB
+ // even at the smallest payload. The first lines fill the base file; once the
+ // FileInfo.Length read sees >= 1024 the next call rotates to _002.
+ for (int i = 0; i < 60; i++)
+ {
+ logger.Info($"line-{i:D4}-padded-with-some-extra-context-to-cross-1KB-quickly");
+ }
+
+ var files = Directory.GetFiles(tmp.Path, TodayBaseName + "*.log").OrderBy(s => s).ToArray();
+ Assert.True(files.Length >= 2, $"expected at least 2 segments, got: {string.Join(",", files.Select(Path.GetFileName))}");
+
+ // The unsuffixed file is the first segment; rotated segments use _002 onward.
+ var rotated = files.FirstOrDefault(p => Path.GetFileNameWithoutExtension(p).EndsWith("_002"));
+ Assert.NotNull(rotated);
+ }
+
+ [Fact]
+ public void Log_writes_rotation_marker_as_first_line_of_new_segment()
+ {
+ using var tmp = new TempDirectory();
+ var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info, maxFileSizeBytes: 1024);
+
+ for (int i = 0; i < 60; i++)
+ {
+ logger.Info($"pad-{i:D4}-content-content-content-content-content-content");
+ }
+
+ var rotatedPath = Path.Combine(tmp.Path, TodayBaseName + "_002.log");
+ Assert.True(File.Exists(rotatedPath), "expected agent_YYYYMMDD_002.log to exist");
+ var first = File.ReadAllLines(rotatedPath).FirstOrDefault();
+ Assert.NotNull(first);
+ Assert.Contains("AgentLogger: rotated", first);
+ }
+
+ [Fact]
+ public void Log_continues_existing_rotation_on_restart_without_overwriting()
+ {
+ using var tmp = new TempDirectory();
+ // Pre-seed: looks like an earlier process already rotated up to _003.
+ File.WriteAllText(Path.Combine(tmp.Path, TodayBaseName + ".log"), "older-base\n");
+ File.WriteAllText(Path.Combine(tmp.Path, TodayBaseName + "_002.log"), "older-002\n");
+ File.WriteAllText(Path.Combine(tmp.Path, TodayBaseName + "_003.log"), "older-003\n");
+
+ var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info, maxFileSizeBytes: 1024L * 1024);
+ logger.Info("after-restart");
+
+ // New entries must land in the highest existing segment (_003), not in a fresh
+ // base file. Older content in _003 must be preserved.
+ var resumed = File.ReadAllText(Path.Combine(tmp.Path, TodayBaseName + "_003.log"));
+ Assert.Contains("older-003", resumed);
+ Assert.Contains("after-restart", resumed);
+
+ // Older segments are left alone.
+ Assert.Equal("older-base\n", File.ReadAllText(Path.Combine(tmp.Path, TodayBaseName + ".log")));
+ Assert.Equal("older-002\n", File.ReadAllText(Path.Combine(tmp.Path, TodayBaseName + "_002.log")));
+ }
+
+ [Fact]
+ public void Log_swallows_io_errors_silently()
+ {
+ using var tmp = new TempDirectory();
+ var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info, maxFileSizeBytes: 1024L * 1024);
+
+ // Hold an exclusive lock on today's file to force AppendAllText to throw.
+ var lockedPath = Path.Combine(tmp.Path, TodayBaseName + ".log");
+ File.WriteAllText(lockedPath, ""); // ensure exists
+ using var holder = new FileStream(lockedPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
+
+ // Must NOT throw — logger swallows IO errors to keep the agent running.
+ logger.Info("dropped");
+ // No assertion needed beyond "did not throw"; the regression we guard against is
+ // the original swallow-all behaviour, which the rotation refactor must preserve.
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Logging/AgentLogger.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Logging/AgentLogger.cs
index f1f11070..3883a3d3 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Logging/AgentLogger.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Logging/AgentLogger.cs
@@ -23,27 +23,82 @@ namespace AutopilotMonitor.Agent.V2.Core.Logging
///
public class AgentLogger
{
- private readonly string _logFilePath;
+ // Rotation cap. With per-enrollment lifecycle (30 min – 4 h) and verbose mode the
+ // active log can grow into the hundreds of MB territory; 50 MB keeps individual
+ // files manageable for forensics tooling without hiding any data — rotated files
+ // are kept alongside the active one with a numeric suffix.
+ private const long DefaultMaxFileSizeBytes = 50L * 1024 * 1024;
+
private AgentLogLevel _logLevel;
private readonly object _lockObject = new object();
+ private readonly long _maxFileSizeBytes;
+ private readonly string _logDirectory;
+ private readonly string _logFileBaseName; // "agent_20260425"
+ private string _logFilePath; // mutable — rotates to "_002", "_003" ...
+ private int _rotationSuffix; // 0 = base file, 2+ = "_002" etc.
+
///
/// When true, log entries are also written to Console.Out (same format as the log file).
/// Set via --console flag or programmatically.
///
public bool EnableConsoleOutput { get; set; }
- public AgentLogger(string logDirectory, AgentLogLevel logLevel = AgentLogLevel.Info)
+ public AgentLogger(string logDirectory, AgentLogLevel logLevel = AgentLogLevel.Info, long maxFileSizeBytes = DefaultMaxFileSizeBytes)
{
_logLevel = logLevel;
+ _maxFileSizeBytes = maxFileSizeBytes > 0 ? maxFileSizeBytes : DefaultMaxFileSizeBytes;
+ _logDirectory = logDirectory;
if (!Directory.Exists(logDirectory))
{
Directory.CreateDirectory(logDirectory);
}
- var logFileName = $"agent_{DateTime.Now:yyyyMMdd}.log";
- _logFilePath = Path.Combine(logDirectory, logFileName);
+ _logFileBaseName = $"agent_{DateTime.Now:yyyyMMdd}";
+ _rotationSuffix = ProbeNextRotationSuffix(logDirectory, _logFileBaseName);
+ _logFilePath = BuildLogFilePath();
+ }
+
+ // Picks up the highest existing rotation suffix on the same day so a process restart
+ // doesn't overwrite previous segments. Returns 0 when no log file exists yet — the
+ // first segment uses the unsuffixed name `agent_YYYYMMDD.log`.
+ private static int ProbeNextRotationSuffix(string dir, string baseName)
+ {
+ if (!Directory.Exists(dir)) return 0;
+ string[] existing;
+ try { existing = Directory.GetFiles(dir, baseName + "*.log"); }
+ catch { return 0; }
+
+ int max = 0;
+ foreach (var path in existing)
+ {
+ var name = Path.GetFileNameWithoutExtension(path);
+ if (string.Equals(name, baseName, StringComparison.OrdinalIgnoreCase))
+ {
+ if (max < 1) max = 1;
+ continue;
+ }
+ if (name.Length > baseName.Length + 1 &&
+ name.StartsWith(baseName + "_", StringComparison.OrdinalIgnoreCase) &&
+ int.TryParse(name.Substring(baseName.Length + 1), out var suffix) &&
+ suffix > max)
+ {
+ max = suffix;
+ }
+ }
+ // 0 means "no file yet" → use the unsuffixed base; otherwise continue at the
+ // highest existing suffix so the active writer appends to the most recent file
+ // (rotation kicks in once it crosses the cap).
+ return max;
+ }
+
+ private string BuildLogFilePath()
+ {
+ var name = _rotationSuffix < 2
+ ? $"{_logFileBaseName}.log"
+ : $"{_logFileBaseName}_{_rotationSuffix:D3}.log";
+ return Path.Combine(_logDirectory, name);
}
///
@@ -99,6 +154,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Logging
var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff");
var logEntry = $"[{timestamp}] [{level}] {SanitizeLogMessage(message)}";
+ RotateIfOverCap(timestamp);
+
File.AppendAllText(_logFilePath, logEntry + Environment.NewLine);
if (EnableConsoleOutput)
@@ -113,6 +170,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Logging
}
}
+ // Best-effort size check. FileInfo.Length is briefly cached, so under bursty writes
+ // the active file can drift slightly past the cap before rotation kicks in — that's
+ // acceptable. Rotation is non-fatal: any failure here is silently ignored so the
+ // logger never breaks the agent.
+ private void RotateIfOverCap(string timestamp)
+ {
+ try
+ {
+ var fi = new FileInfo(_logFilePath);
+ if (!fi.Exists || fi.Length < _maxFileSizeBytes) return;
+
+ _rotationSuffix = _rotationSuffix < 2 ? 2 : _rotationSuffix + 1;
+ _logFilePath = BuildLogFilePath();
+
+ var capMb = _maxFileSizeBytes / (1024 * 1024);
+ var rotateMsg = $"[{timestamp}] [INFO] AgentLogger: rotated from previous segment ({capMb}MB cap reached).";
+ File.AppendAllText(_logFilePath, rotateMsg + Environment.NewLine);
+ }
+ catch
+ {
+ // Best-effort: never break logging on a rotation glitch.
+ }
+ }
+
///
/// Sanitizes log messages to prevent log injection attacks.
/// Escapes control characters that could forge log entries.
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
index ec6cab01..55bd0ac4 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
@@ -1 +1 @@
-331
+333
diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
index afe5b3bb..378d4b14 100644
--- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
@@ -1 +1 @@
-1161
+1162