fix(v2): 882fef64 PR3 — kernel-path observability (A2 + A3 + D1-D5)

Session 882fef64's 12-min agent log carried only `EnrollmentOrchestrator: started.`
and `decision terminal stage reached` between the first event and termination — no
stage-transition trail, no signal cadence, no effect failures. PR3 wires logging
into the kernel hot path so every Decision-Stage transition, every signal accept/
reject, every effect retry/critical-failure, and every IME app state change leaves
a trace at the right severity.

A2 — noise reduction:
- TelemetrySpool `enqueued itemId=...` DEBUG -> VERBOSE (600+ lines/session before)
- IME `impersonation` lines deduplicated by user with 60s rollup

A3 — strengthened existing weak lines:
- `Command line:` shows `(no args)` instead of empty
- `Process exit code: 0` -> `Process completed: <name> -> exit=N, durationMs=T`
- IME session change now carries sessionId+user
- HelloTracker 358/360 suppression includes eventTime+provider
- EnrollmentOrchestrator started/stopping carry sessionId/stage/hosts/signals

D1-D5 — observability into the silent kernel components:
- DecisionStepProcessor: per-step DEBUG (stage transitions + effects) + VERBOSE for no-ops
- SignalIngress: 5 strategic log lines (drop, append-fail, worker-fault, stop summary, processed)
- EffectRunner: 5 strategic log lines (retry, exhausted, critical, anti-loop, classifier throw)
- ImeLogTrackerAdapter: 7 emit-site log lines covering all 7 emission paths
- InformationalEventPost: per-emit DEBUG with eventType/source/immediate/severity/dataKeys

All log statements are null-safe (_logger?.Debug); legacy unit-test rigs without a
logger keep working. New ctor overloads on InformationalEventPost / SignalIngress /
EffectRunner / ImeLogTrackerAdapter are backwards-compatible (logger param defaults
null).

Side fix: BinaryIntegrityVerifierTests pinned to [Collection("SerialThreading")].
Process-global `_runtimeSelfUpdateFired` static raced with parallel mismatch tests;
PR3's logging shifted thread timing enough to surface the race consistently.

Tests: V2.Core 727, DecisionCore 231, Functions 736, vitest 179, solution build 0/0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-25 19:20:42 +02:00
parent ae330f54d9
commit 07312ee2a3
16 changed files with 241 additions and 25 deletions
@@ -9,6 +9,12 @@ using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
{
// PR3 (882fef64 debrief): BinaryIntegrityVerifier holds a process-global static
// `_runtimeSelfUpdateFired` flag. The single-shot guarantee inside
// `Check_mismatch_fires_runtime_self_update_trigger_exactly_once` races with parallel
// tests that may also call Check() with a mismatch and pre-set the flag. Pin to the
// existing serial collection so mismatch-tests don't interleave across classes.
[Collection("SerialThreading")]
public sealed class BinaryIntegrityVerifierTests
{
private static AgentLogger NewLogger(TempDirectory tmp)
@@ -18,6 +18,40 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
/// </summary>
public partial class ImeLogTracker
{
// PR3-A2: dedup state for `IME impersonation` lines. ~24 identical per session at
// DEBUG flooded the log; we now emit on user change + a 60s rollup with the count.
private string _lastImpersonationUser;
private int _impersonationRepeatCount;
private DateTime _impersonationLastRollupUtc = DateTime.MinValue;
private static readonly TimeSpan ImpersonationRollupInterval = TimeSpan.FromSeconds(60);
private void HandleImeImpersonation(string user)
{
var current = string.IsNullOrEmpty(user) ? "(unknown)" : user;
if (!string.Equals(current, _lastImpersonationUser, StringComparison.Ordinal))
{
if (_impersonationRepeatCount > 0)
{
_logger.Debug($"IME impersonation: previous '{_lastImpersonationUser}' seen {_impersonationRepeatCount}x before change");
}
_logger.Debug($"IME impersonation: {current}");
_lastImpersonationUser = current;
_impersonationRepeatCount = 0;
_impersonationLastRollupUtc = DateTime.UtcNow;
return;
}
_impersonationRepeatCount++;
var now = DateTime.UtcNow;
if (now - _impersonationLastRollupUtc >= ImpersonationRollupInterval)
{
_logger.Debug($"IME impersonation: same as before (user='{current}', n={_impersonationRepeatCount} since last rollup)");
_impersonationLastRollupUtc = now;
_impersonationRepeatCount = 0;
}
}
private void HandleDoTelemetry(string json)
{
try
@@ -197,7 +197,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
case "imesessionchange":
var sessionChange = match.Groups["change"]?.Value;
_logger.Debug($"IME session change: {sessionChange}");
// PR3-A3: lift sessionId + user from match if the regex captures them, so the
// line carries enough context to correlate without cross-referencing.
var sessionChangeSid = match.Groups["sessionId"]?.Value;
var sessionChangeUser = match.Groups["user"]?.Value;
var sessionChangeContext = (!string.IsNullOrEmpty(sessionChangeSid) || !string.IsNullOrEmpty(sessionChangeUser))
? $" (sessionId={sessionChangeSid ?? "?"}, user={sessionChangeUser ?? "?"})"
: string.Empty;
_logger.Debug($"IME session change: {sessionChange}{sessionChangeContext}");
OnImeSessionChange?.Invoke(sessionChange);
break;
@@ -221,7 +228,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
break;
case "imeimpersonation":
_logger.Debug($"IME impersonation: {match.Groups["user"]?.Value}");
// PR3-A2: dedup. The same user triggers ~24 identical lines per session;
// log on first/changed user, otherwise count and emit a single rollup
// every 60s ("same as before (n=…)") so the log stays readable but the
// sequence stays reconstructible.
HandleImeImpersonation(match.Groups["user"]?.Value);
break;
case "enrollmentcompleted":
@@ -491,6 +491,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
}
}
// PR3-A3: when 358/360 are suppressed below, include eventTime + provider so log readers
// can still tell which exact occurrence was dropped without cross-referencing the eventlog.
private void LogSuppressedSnapshotEvent(int eventId, DateTime timestamp, string providerName, string label)
{
_logger.Debug($"Hello EventID {eventId} ({label}) observed — snapshot only, suppressed (eventTime={timestamp:O}, provider={providerName ?? "(unknown)"})");
}
internal void ProcessHelloEvent(int eventId, DateTime timestamp, string providerName, bool isBackfill)
{
string eventType;
@@ -509,7 +516,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
// event only fueled UI-timeline noise. Keep the debug log line so the
// sequence is still reconstructible from agent-logs during diagnostics,
// but do not emit a backend event.
_logger.Debug("Hello EventID 358 (willlaunch) observed — snapshot only, suppressed");
LogSuppressedSnapshotEvent(eventId, timestamp, providerName, "willlaunch");
return;
case EventId_NgcKeyRegistered: // 300
@@ -531,7 +538,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
case EventId_ProvisioningWillNotLaunch: // 360
// Sibling of 358 — also a snapshot flag. DecisionEngine does not treat it
// as terminal either, so suppress for the same noise-reduction reason.
_logger.Debug("Hello EventID 360 (willnotlaunch) observed — snapshot only, suppressed");
LogSuppressedSnapshotEvent(eventId, timestamp, providerName, "willnotlaunch");
return;
case EventId_ProvisioningBlocked: // 362
@@ -1,5 +1,6 @@
#nullable enable
using System;
using System.Linq;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Persistence;
@@ -103,6 +104,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
if (step == null) throw new ArgumentNullException(nameof(step));
if (signal == null) throw new ArgumentNullException(nameof(signal));
// PR3-D1: capture pre-step stage so the post-step log line can show the transition.
var previousStage = _currentState.Stage;
// 1) Journal-Append — einziger Fehler-Pfad der hart wirft + Quarantine eskaliert.
try
{
@@ -200,6 +204,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
_currentState = step.NewState;
_consecutiveJournalFailures = 0;
// PR3-D1: per-step observability. Stage transitions and effect-bearing steps fire
// at DEBUG (operator-relevant); pure no-op steps (Stage unchanged + no effects)
// fire at VERBOSE so a steady stream of e.g. ClassifierTick + InformationalEvent
// pass-throughs doesn't drown the log.
var newStage = step.NewState.Stage;
if (previousStage != newStage || step.Effects.Count > 0)
{
_logger.Debug(
$"DecisionStep: ord={signal.SessionSignalOrdinal} kind={signal.Kind} " +
$"stage={previousStage}->{newStage} effects=[{string.Join(",", step.Effects.Select(e => e.Kind))}] " +
$"abort={effectResult.SessionMustAbort} failures={effectResult.Failures.Count}");
}
else
{
_logger.Verbose($"DecisionStep: ord={signal.SessionSignalOrdinal} kind={signal.Kind} stage={previousStage} (no-op)");
}
// 5) Terminal-stage detection (M4.6.β). Fires exactly once per agent run when the
// DecisionEngine transitions the session into a terminal SessionStage — the
// orchestrator turns this into the public EnrollmentTerminated event so peripheral
@@ -583,7 +583,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
logger.Info($"ImeLogHost: SimulationMode ENABLED (speedFactor={simulationSpeedFactor}, path={logFolder})");
}
_adapter = new ImeLogTrackerAdapter(_tracker, ingress, clock, whiteGloveSealingPatternIds);
_adapter = new ImeLogTrackerAdapter(_tracker, ingress, clock, whiteGloveSealingPatternIds, logger);
var processWatcherPost = new InformationalEventPost(ingress, clock);
_processWatcher = new ImeProcessWatcher(sessionId, tenantId, processWatcherPost, logger);
@@ -3,6 +3,7 @@ using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Persistence;
using AutopilotMonitor.DecisionCore.Classifiers;
using AutopilotMonitor.DecisionCore.Engine;
@@ -35,6 +36,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private readonly ISnapshotPersistence _snapshot;
private readonly IClock _clock;
private readonly IReadOnlyList<TimeSpan> _retryBackoffs;
// PR3-D3: optional logger so transient retries, exhausted retries, critical failures,
// anti-loop skips, and classifier exceptions are visible. Null in legacy unit tests.
private readonly AgentLogger? _logger;
public EffectRunner(
IDeadlineScheduler scheduler,
@@ -43,7 +47,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
IEventTimelineEmitter emitter,
ISnapshotPersistence snapshot,
IClock clock,
IReadOnlyList<TimeSpan>? retryBackoffs = null)
IReadOnlyList<TimeSpan>? retryBackoffs = null,
AgentLogger? logger = null)
{
_scheduler = scheduler ?? throw new ArgumentNullException(nameof(scheduler));
_classifiers = classifiers ?? throw new ArgumentNullException(nameof(classifiers));
@@ -52,6 +57,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
_snapshot = snapshot ?? throw new ArgumentNullException(nameof(snapshot));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_retryBackoffs = retryBackoffs ?? DefaultRetryBackoffs;
_logger = logger;
}
public async Task<EffectRunResult> RunAsync(
@@ -155,6 +161,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
}
catch (Exception ex)
{
_logger?.Warning(
$"EffectRunner: CRITICAL effect {effect.Kind} failed — session must abort: {ex.GetType().Name}: {ex.Message}");
return new EffectFailure(
effectKind: effect.Kind,
errorReason: $"{ex.GetType().Name}: {ex.Message}",
@@ -198,6 +206,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
!string.IsNullOrEmpty(lastVerdictId) &&
snapshotHash == lastVerdictId)
{
_logger?.Debug(
$"EffectRunner: classifier {effect.ClassifierId} skipped (snapshotHash unchanged: {ShortHash(snapshotHash)})");
return new ClassifierEffectOutcome(invoked: false, skippedByAntiLoop: true, failure: null);
}
@@ -209,6 +219,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
catch (Exception ex)
{
// Optional-class failure: emit an Inconclusive verdict instead of aborting.
_logger?.Warning(
$"EffectRunner: classifier {effect.ClassifierId} threw -> emitting Inconclusive: {ex.GetType().Name}: {ex.Message}");
var inconclusive = new ClassifierVerdict(
classifierId: effect.ClassifierId!,
level: HypothesisLevel.Inconclusive,
@@ -272,11 +284,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
lastErr = $"{ex.GetType().Name}: {ex.Message}";
if (attempt < _retryBackoffs.Count)
{
await _clock.Delay(_retryBackoffs[attempt], cancellationToken).ConfigureAwait(false);
var backoff = _retryBackoffs[attempt];
_logger?.Debug(
$"EffectRunner: effect {effect.Kind} attempt {attempt + 1}/{_retryBackoffs.Count + 1} failed -> retry in {backoff.TotalMilliseconds:F0}ms: {lastErr}");
await _clock.Delay(backoff, cancellationToken).ConfigureAwait(false);
}
}
}
_logger?.Warning(
$"EffectRunner: TRANSIENT effect {effect.Kind} exhausted after {_retryBackoffs.Count + 1} attempts: {lastErr ?? "unknown transient error"}");
failures.Add(new EffectFailure(
effect.Kind,
lastErr ?? "unknown transient error",
@@ -284,6 +302,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
exhaustedRetries: true));
}
// PR3-D3: short hash for log readability — full SHA in the verdict already.
private static string ShortHash(string h) => string.IsNullOrEmpty(h) ? "(empty)" : (h.Length <= 8 ? h : h.Substring(0, 8));
private readonly struct ClassifierEffectOutcome
{
public ClassifierEffectOutcome(bool invoked, bool skippedByAntiLoop, EffectFailure? failure)
@@ -482,7 +482,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
ingress: _sinkRelay,
emitter: _timelineEmitter,
snapshot: _snapshot,
clock: _clock);
clock: _clock,
logger: _logger);
// 8) Processor — owns the initial state + journal + snapshot + quarantine hook +
// M4.6.β terminal-stage hook (fires Terminated event when the engine reaches a
@@ -522,7 +523,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
clock: _clock,
backPressureObserver: _backPressureObserver,
signalEmitter: _signalEmitter,
channelCapacity: _channelCapacity);
channelCapacity: _channelCapacity,
logger: _logger);
// 11) Relay auf den echten Ingress umbiegen.
_sinkRelay.Target = _ingress;
@@ -695,7 +697,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
_logger.Info($"EnrollmentOrchestrator: max-lifetime watchdog armed ({_agentMaxLifetime.Value.TotalMinutes:F0}min).");
}
_logger.Info("EnrollmentOrchestrator: started.");
// PR3-A3: previous "started." line had zero context. Add the cardinality info that
// tells a forensic reader what the orchestrator is actually running (initial stage,
// collector-host count, short session id) and how long max-lifetime is set for.
var sessionShort = _sessionId != null && _sessionId.Length >= 8 ? _sessionId.Substring(0, 8) : (_sessionId ?? string.Empty);
var initialStage = CurrentState?.Stage.ToString() ?? "Unknown";
var hostCount = _collectorHosts?.Count ?? 0;
var maxLifetimeMin = _agentMaxLifetime.HasValue ? _agentMaxLifetime.Value.TotalMinutes.ToString("F0") + "min" : "off";
_logger.Info($"EnrollmentOrchestrator: started (sessionId={sessionShort}, initialStage={initialStage}, hosts={hostCount}, maxLifetime={maxLifetimeMin}).");
}
/// <summary>
@@ -840,7 +849,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
if (Volatile.Read(ref _started) == 0) return;
if (Interlocked.Exchange(ref _stopRequested, 1) == 1) return;
_logger.Info("EnrollmentOrchestrator: stopping.");
// PR3-A3: include current stage + observed signal count so forensic readers can see
// what the orchestrator was working on at stop time.
var stopStage = CurrentState?.Stage.ToString() ?? "Unknown";
var stopSignalsApplied = CurrentState?.LastAppliedSignalOrdinal ?? -1;
_logger.Info($"EnrollmentOrchestrator: stopping (currentStage={stopStage}, signalsApplied={stopSignalsApplied + 1}).");
// -1) Max-lifetime watchdog stoppen (M4.6.α). Idempotent — safe even if never armed.
try { _maxLifetimeTimer?.Dispose(); _maxLifetimeTimer = null; }
@@ -1,6 +1,8 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.Shared.Models;
@@ -42,11 +44,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private readonly ISignalIngressSink _ingress;
private readonly IClock _clock;
// PR3-D5: optional logger so every lifecycle event passing through the single rail
// shows up in the agent log with eventType + source + immediate-flag. Null in legacy
// unit tests; production wires the agent logger via the new ctor overload below.
private readonly AgentLogger? _logger;
public InformationalEventPost(ISignalIngressSink ingress, IClock clock)
: this(ingress, clock, logger: null)
{
}
public InformationalEventPost(ISignalIngressSink ingress, IClock clock, AgentLogger? logger)
{
_ingress = ingress ?? throw new ArgumentNullException(nameof(ingress));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_logger = logger;
}
/// <summary>
@@ -129,6 +141,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
evidence: evidence,
payload: payload,
typedPayload: typedPayload);
// PR3-D5: lifecycle-event observability — DEBUG so it doesn't dominate at INFO,
// but every event that hits the timeline has a matching agent-log line. First
// 8 payload keys are usually enough to identify the event variant.
if (_logger != null)
{
var keys = string.Join(",", payload.Keys.Take(8));
_logger.Debug(
$"InformationalEventPost: emit eventType={eventType} source={source} " +
$"immediate={immediateUpload} severity={severity?.ToString() ?? "default"} " +
$"dataKeys=[{keys}]");
}
}
/// <summary>
@@ -3,6 +3,7 @@ using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Persistence;
using AutopilotMonitor.Agent.V2.Core.Telemetry.Signals;
using AutopilotMonitor.DecisionCore.Engine;
@@ -48,6 +49,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private readonly IClock _clock;
private readonly TimeSpan _backPressureThrottle;
private readonly int _channelCapacity;
// PR3-D2: optional logger so production wiring gets observability into the previously
// silent paths (SignalLog append failures, DecisionSignal ctor rejections, worker
// faults). Null in unit-test rigs that don't care.
private readonly AgentLogger? _logger;
private long _processedCount;
private long _stopRequestedAtTicks;
private readonly BlockingCollection<IngressItem> _channel;
private readonly Dictionary<string, DateTime> _lastBackPressureEmittedUtc =
@@ -69,7 +76,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
IBackPressureObserver? backPressureObserver = null,
TelemetrySignalEmitter? signalEmitter = null,
int channelCapacity = DefaultChannelCapacity,
TimeSpan? backPressureThrottle = null)
TimeSpan? backPressureThrottle = null,
AgentLogger? logger = null)
{
if (channelCapacity <= 0)
{
@@ -85,6 +93,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
_signalEmitter = signalEmitter;
_channelCapacity = channelCapacity;
_backPressureThrottle = backPressureThrottle ?? DefaultBackPressureThrottle;
_logger = logger;
_channel = new BlockingCollection<IngressItem>(boundedCapacity: channelCapacity);
_nextSignalOrdinal = signalLog.LastOrdinal + 1;
@@ -143,6 +152,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
return;
}
var stopStartUtc = _clock.UtcNow;
Interlocked.Exchange(ref _stopRequestedAtTicks, stopStartUtc.Ticks);
var pendingAtStop = _channel.Count;
// CompleteAdding unblockt GetConsumingEnumerable sobald die Queue leer ist.
_channel.CompleteAdding();
@@ -152,6 +165,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
worker.Join();
}
// PR3-D2: lifecycle marker so forensic readers see how many items remained queued
// and how long the drain took. Worker fault is logged here (not just thrown) so a
// crash dump catches the message before the exception unwinds.
var drainMs = (long)(_clock.UtcNow - stopStartUtc).TotalMilliseconds;
var lastOrd = LastAssignedSignalOrdinal;
var processedTotal = Interlocked.Read(ref _processedCount);
_logger?.Info($"SignalIngress: stop requested — drained pending={pendingAtStop} items, processedTotal={processedTotal}, lastOrd={lastOrd}, durationMs={drainMs}.");
if (_workerFault != null)
{
throw new InvalidOperationException(
@@ -279,9 +300,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
}
catch (Exception ex)
{
// Darf nicht passieren — ProcessItem fängt alles ab. Falls doch, für Stop()
// aufheben, damit die Regression sichtbar ist.
// PR3-D2: this branch should never fire — ProcessItem swallows everything. If it
// does, the worker is now stopped and no further signals will be processed.
// Surface that catastrophe explicitly so the agent log shows it before the next
// Post() throws "ingress has been stopped".
_workerFault = ex;
_logger?.Error("SignalIngress.Worker faulted unexpectedly — ingress STOPPED. No further signals will be processed.", ex);
}
}
@@ -305,10 +329,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
payload: item.Payload,
typedPayload: item.TypedPayload);
}
catch
catch (Exception ex)
{
// DecisionSignal-Ctor validiert Pflichtfelder. Invalid input → überspringen,
// Ordinal NICHT verbrauchen (sonst entsteht eine Lücke im SignalLog).
_logger?.Warning(
$"SignalIngress: dropped invalid signal kind={item.Kind} origin={item.SourceOrigin} " +
$"— DecisionSignal ctor threw, ordinal NOT consumed: {ex.Message}");
return;
}
@@ -316,16 +343,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
{
_signalLog.Append(signal);
}
catch
catch (Exception ex)
{
// Append fehlgeschlagen (z.B. Disk-Full). Ohne on-disk-Persistenz darf der
// Reducer nicht laufen (L.1 Signal-Log-Determinismus). Ordinal bleibt „geplant",
// wird beim nächsten Versuch neu vergeben — monoton, da _nextSignalOrdinal
// noch nicht inkrementiert wurde.
_logger?.Error(
$"SignalIngress: SignalLog.Append failed for ord={signalOrdinal} kind={item.Kind} " +
$"— ordinal will be re-attempted on next post: {ex.Message}",
ex);
return;
}
_nextSignalOrdinal = signalOrdinal + 1;
// PR3-D2: 1:1 correlation marker for D1 (DecisionStepProcessor logs the same ord +
// kind on the receive side). Verbose because the steady-state happy-path is the
// dominant cardinality.
_logger?.Verbose($"SignalIngress: processed ord={signalOrdinal} kind={item.Kind} origin={item.SourceOrigin}.");
Interlocked.Increment(ref _processedCount);
// Project the signal onto the telemetry transport for backend upload. Local
// SignalLog is authoritative (§2.7c / L.1); a transport enqueue failure must NOT
@@ -1,6 +1,7 @@
#nullable enable
using System;
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.DecisionCore.Engine;
@@ -57,6 +58,9 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
private readonly InformationalEventPost _post;
private readonly HashSet<string> _whiteGloveSealingPatternIds;
private readonly object _lock = new object();
// PR3-D4: optional logger so the largest signal adapter (777 LOC, 0 logger calls
// before this PR) gets DEBUG-level visibility into emissions. Null in legacy tests.
private readonly AgentLogger? _logger;
// Previous action-handlers — restored on Dispose so we don't leave a dead callback
// hanging on the tracker.
@@ -100,12 +104,14 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
ImeLogTracker tracker,
ISignalIngressSink ingress,
IClock clock,
IReadOnlyCollection<string>? whiteGloveSealingPatternIds = null)
IReadOnlyCollection<string>? whiteGloveSealingPatternIds = null,
AgentLogger? logger = null)
{
_tracker = tracker ?? throw new ArgumentNullException(nameof(tracker));
_ingress = ingress ?? throw new ArgumentNullException(nameof(ingress));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_post = new InformationalEventPost(ingress, clock);
_logger = logger;
_whiteGloveSealingPatternIds = whiteGloveSealingPatternIds == null
? new HashSet<string>(StringComparer.Ordinal)
@@ -267,6 +273,9 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
phase: mappedPhase == EnrollmentPhase.Unknown ? (EnrollmentPhase?)null : mappedPhase,
data: data,
occurredAtUtc: now);
// PR3-D4: phase emissions are decision-relevant — operators want to see them.
_logger?.Debug($"ImeAdapter: EspPhase '{phase}' posted (mappedTo={mappedPhase}, patternId={patternId ?? "(none)"})");
}
private void EmitUserSessionCompleted()
@@ -306,6 +315,9 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
message: "IME user session completed",
data: data,
occurredAtUtc: now);
// PR3-D4: terminal-ish lifecycle marker — INFO so it's visible at the default level.
_logger?.Info($"ImeAdapter: user session completed -> posting ImeUserSessionCompleted (patternId={patternId ?? "(none)"})");
}
private void EmitAppState(AppPackageState app, AppInstallationState oldState, AppInstallationState newState)
@@ -376,6 +388,13 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
immediateUpload: true,
data: BuildAppStatePayload(app, newState, timing),
occurredAtUtc: now);
// PR3-D4: app state transitions are the heaviest cardinality observability target —
// a forensic reader needs (oldState->newState, terminal flag, sub-phase emit) to
// reconstruct why the UI shows what it shows.
var shortId = (app.Id != null && app.Id.Length >= 8) ? app.Id.Substring(0, 8) : (app.Id ?? "?");
_logger?.Debug(
$"ImeAdapter: app '{app.Name ?? "?"}' ({shortId}) {oldState}->{newState} appType={app.AppType ?? "?"} bytesTotal={app.BytesTotal} terminal={(terminalKind != null)} eventType={eventType}");
}
/// <summary>
@@ -511,6 +530,9 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
{
[SignalPayloadKeys.ImePatternId] = patternId,
});
// PR3-D4: WG Part-1 boundary — INFO-level lifecycle marker, fires at most once.
_logger?.Info($"ImeAdapter: WG sealing pattern '{patternId}' detected -> posting WhiteGloveSealingPatternDetected (Part-1 boundary)");
}
private void EmitImeAgentVersion(string version)
@@ -531,6 +553,9 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
message: $"IME Agent version: {version}",
data: data,
occurredAtUtc: _clock.UtcNow);
// PR3-D4: fire-once and useful for compatibility-debugging — INFO level.
_logger?.Info($"ImeAdapter: IME agent version detected: {version}");
}
private void EmitDoTelemetry(AppPackageState app)
@@ -578,6 +603,10 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
immediateUpload: true,
data: data,
occurredAtUtc: _clock.UtcNow);
// PR3-D4: per-app DO summary at DEBUG with the headline metrics.
_logger?.Debug(
$"ImeAdapter: DO telemetry app='{app.Name ?? "?"}' total={app.DoFileSize} peers={app.DoBytesFromPeers} http={app.DoBytesFromHttp} mode={app.DoDownloadMode}");
}
private void EmitScriptCompleted(ScriptExecutionState script)
@@ -630,6 +659,11 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
severity: severity,
data: data,
occurredAtUtc: _clock.UtcNow);
// PR3-D4: per-script summary at DEBUG (failures already surface via Severity=Error
// in the event itself; the log line carries forensic context for both outcomes).
_logger?.Debug(
$"ImeAdapter: script completed policyId={shortId} type={script.ScriptType ?? "?"} result={script.Result ?? "?"} exit={(script.ExitCode.HasValue ? script.ExitCode.Value.ToString() : "n/a")}");
}
private static bool IsRemediation(string? scriptType) =>
@@ -104,9 +104,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
}
_lastAssignedItemId = itemId;
// Plan §5 Fix 5 — spool-cadence logging (DEBUG to avoid noise under heavy
// event flows; users can enable at troubleshoot-time).
_logger?.Debug(
// Plan §5 Fix 5 — spool-cadence logging. PR3-A2: VERBOSE (was DEBUG)
// because per-item logging hits ~600 lines per session and floods the log
// at troubleshoot level. Activate only at micro-repro level.
_logger?.Verbose(
$"TelemetrySpool: enqueued itemId={itemId} kind={item.Kind} immediate={item.RequiresImmediateFlush} pending={itemId - _lastUploadedItemId}.");
}
@@ -181,6 +181,10 @@ namespace AutopilotMonitor.Agent.V2
logger.Info($"Executing: {fileName} {arguments}");
// PR3-A3: prior `Process exit code: 0` line had no context — here we add the
// process name + duration so log readers can correlate the line with the
// command issued without scrolling back.
var sw = System.Diagnostics.Stopwatch.StartNew();
using (var process = Process.Start(psi))
{
if (process == null)
@@ -189,11 +193,13 @@ namespace AutopilotMonitor.Agent.V2
var stdout = process.StandardOutput.ReadToEnd();
var stderr = process.StandardError.ReadToEnd();
process.WaitForExit();
sw.Stop();
if (!string.IsNullOrWhiteSpace(stdout)) logger.Info($"Process output: {stdout.Trim()}");
if (!string.IsNullOrWhiteSpace(stderr)) logger.Warning($"Process error output: {stderr.Trim()}");
logger.Info($"Process exit code: {process.ExitCode}");
var processName = System.IO.Path.GetFileName(fileName);
logger.Info($"Process completed: {processName} -> exit={process.ExitCode}, durationMs={sw.ElapsedMilliseconds}");
return process.ExitCode;
}
}
@@ -152,7 +152,9 @@ namespace AutopilotMonitor.Agent.V2
var logger = new AgentLogger(logDirectory) { EnableConsoleOutput = consoleMode };
logger.Info($"AutopilotMonitor.Agent.V2 starting (version {GetAgentVersion()}).");
logger.Info($"Command line: {FormatArgsForLog(args)}");
// PR3-A3: empty args looked broken (`Command line: `). Make the no-args case explicit.
var formattedArgs = FormatArgsForLog(args);
logger.Info($"Command line: {(string.IsNullOrEmpty(formattedArgs) ? "(no args)" : formattedArgs)}");
try
{
@@ -664,7 +666,7 @@ namespace AutopilotMonitor.Agent.V2
// (plan Parity Issue #1).
orchestrator.Start(ingress =>
{
lifecyclePost = new InformationalEventPost(ingress, SystemClock.Instance);
lifecyclePost = new InformationalEventPost(ingress, SystemClock.Instance, logger);
EmitAgentStartedEvent(lifecyclePost, agentConfig, previousExit, logger);
EmitVersionCheckEventIfAny(lifecyclePost, agentConfig, logger);
EmitUnrestrictedModeAuditIfChanged(lifecyclePost, agentConfig, configMergeResult, logger);
@@ -1 +1 @@
336
338
@@ -1 +1 @@
1163
1164