Compare commits

..

1 Commits

Author SHA1 Message Date
Oliver Kieselbach b1e7077d0a fix(agent): spool pending-bytes drift guard + WDP backstop fallback test
Codex findings on d8c84f41/a369ff99: (1) MarkUploaded now recomputes
_pendingBytes from the pending list when accounting drifts negative or
stays non-zero on an empty list — a silent negative would disarm the
spool-pressure trigger. (2) New test covers the DevicePrepCompletion
handler''s AwaitingDesktop fallback (stale/replayed timer without a
desktop fact): synthetic Hello timeout, park, and completion on the
later real desktop arrival. Third finding (HashingCappedReadStream
maxBytes:0) refuted — the 0=uncapped contract is implemented in the
same class and exercised by the heal-path tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 12:59:56 +02:00
2 changed files with 39 additions and 0 deletions
@@ -258,6 +258,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
_pendingBytes -= _pending.First.Value.ByteLength;
_pending.RemoveFirst();
}
// _pendingBytes drives the spool-pressure trigger: negative drift would
// silently disarm it, a stale positive on an empty list would pin it on.
// The list is authoritative — on drift, recompute from it and log loudly.
if (_pendingBytes < 0 || (_pending.Count == 0 && _pendingBytes != 0))
{
long recomputed = 0;
foreach (var entry in _pending) recomputed += entry.ByteLength;
_logger?.Warning(
$"TelemetrySpool: pending-bytes accounting drifted to {_pendingBytes} with {_pending.Count} pending items after MarkUploaded({upToItemIdInclusive}) — recomputed to {recomputed}.");
_pendingBytes = recomputed;
}
}
}
@@ -192,6 +192,33 @@ namespace AutopilotMonitor.DecisionCore.Tests
Assert.Equal(SessionStage.Completed, completed.NewState.Stage);
}
[Fact]
public void BackstopFired_WithoutDesktop_ParksInAwaitingDesktopUntilDesktopCompletes()
{
// The arming site (HandleDesktopArrivedV1) guarantees desktop-first, so this
// fires only on a stale/replayed timer — Dispatch routes DeadlineFired purely on
// the payload name, no armed-state check. The fallback must keep the handler
// total: synthetic Hello timeout recorded, session parks in AwaitingDesktop, and
// a later real desktop arrival still completes it.
var engine = new DecisionEngine();
var state = SeedWdpSession(engine);
var fired = engine.Reduce(state, DeadlineFired(20, T0.AddMinutes(39), DeadlineNames.DevicePrepCompletion));
Assert.Equal(SessionStage.AwaitingDesktop, fired.NewState.Stage);
Assert.NotNull(fired.NewState.HelloResolvedUtc);
Assert.Equal("Timeout", fired.NewState.HelloOutcome!.Value);
Assert.DoesNotContain(fired.NewState.Deadlines, d => d.Name == DeadlineNames.DevicePrepCompletion);
// Hello is satisfied (synthetically) — only the desktop is still missing.
var waiting = Assert.Single(CompletionWaitingEffects(fired));
Assert.Equal("desktop_arrival", waiting.Parameters!["missingPrerequisites"]);
var completed = engine.Reduce(fired.NewState,
MakeSignal(21, DecisionSignalKind.DesktopArrived, T0.AddMinutes(45)));
Assert.Equal(SessionStage.Finalizing, completed.NewState.Stage);
}
[Fact]
public void HelloResolved_BeforeBackstopFires_CompletesNormally()
{