Commit Graph

52 Commits

Author SHA1 Message Date
Oliver Kieselbach 6d607c9045 fix(agent): network-link gate before initial config fetch + post-registration recovery
Root cause (tenant aebdce78 audit 2026-08-24, session ba828038): on boot-time
relaunches the agent starts before the Wi-Fi link is up. The Phase 4 config fetch
had no network gate (98f8114d only guarded Phase 6 registration), burned its
10/30/60s retry budget into a dead link and stranded the whole session on built-in
defaults (ConfigVersion=0) — up to ~50% of that tenant's sessions, with no
self-heal short of a manual rotate_config action. Not a version regression: rates
varied wildly per day within the same agent build; the failed requests never
reached the backend.

Two complementary layers:
- RemoteConfigService gates the initial fetch on the shared NetworkLinkWait poll
  (60s cap — longer than registration's 15s because the fetch runs first), so the
  retry budget starts on a live link. rotate_config stays ungated (latency-bound).
- Phase 6.5: after registration proves the backend reachable, TryRecoverConfigAsync
  refetches once and AgentRuntimeConfig.TryRecoverAfterRegistration re-applies the
  live config (same merge/integrity pipeline as Phase 4). LastFetch* keeps the
  original failure record; remote_config_fetch_failed reports it with a recovered
  flag at Info severity, agent_started flags remoteConfigRecovered.

Deliberately not re-run in recovery: kill-signal check (telemetry-channel control
signals cover post-registration kills) and endpoint migration (next start).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 11:53:04 +02:00
Oliver Kieselbach ff35270d5d agent: legacy bootstrap-config without TenantIdWaitSeconds must not disable the wait
A bootstrap-config.json written before the TenantIdWaitSeconds property
existed deserialized the missing field to 0, which the runtime treated as
an explicit opt-out and skipped the 600s TenantId wait entirely — exactly
the wait Hybrid-Join devices need while their Entra registration lands.

TenantIdWaitSeconds is now nullable: missing/null means 'not configured'
and falls through to the agent default (600s); an explicit 0 remains the
legacy fast-fail opt-out. The install merge carries null through instead
of freezing the default into the file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 16:24:50 +02:00
Oliver Kieselbach 4ebd60fb1e feat: EnableGatherRuleDebugLog — per-tenant gather-rule debug trace (ConfigVersion 36)
Customers cannot tell why a gather rule produces nothing in the timeline
(e.g. a logparser rule with emit_mode on_change that never emits). New
per-tenant boolean EnableGatherRuleDebugLog (EnableImeMatchLog-style
plumbing end to end): when enabled, the agent writes
%ProgramData%\AutopilotMonitor\Logs\gather_rules_debug.log with one
pipe-delimited line per evaluation decision across all previously silent
"produced nothing" exits — disabled rules, interval without
intervalSeconds, first-tick-after-one-full-interval, phase-scope skips
incl. currentPhase=Unknown, per-(rule, phase) dedup, empty collector
results (emitOnlyIfExists), on_change hash suppression (hash prefix +
streak), guard blocks, logparser per-file outcomes (position skips,
format=text hint when every line fails CMTrace parsing, maxLines and
wildcard caps), and full exception stacks the agent log drops.

- Backend: ConfigVersion 35 -> 36; TenantConfiguration/AgentConfigResponse
  field + TableConfigRepository Store+Map
- Agent: GatherRuleDebugLog writer (10 MB cap, one .old generation,
  failures swallowed, optional console echo), GatherRuleContext.DebugLog
  (null-safe no-op), trace points in executor + collectors; never writes
  under _scopeLock (reasons captured inside, written after release);
  merger bool->path projection with --gather-debug-log CLI override
  tolerance; deliberately NOT in the offline-cache strip lists;
  --run-gather-rules always traces; zero-rules delivery leaves a one-line
  file instead of nothing
- Side fix: GatherRuleExecutorHost now passes the logger into
  InformationalEventPost so gather-event emissions hit the DEBUG log
- Web: tenant settings toggle (Settings -> Agent) + admin tenant-config
  report rows
- Tests: merger on/off/override, writer format+rotation, executor debug
  lines, logparser debug lines (agent 2131, backend 3561, tsc green)
- Docs: docs/agent/gather-rule-debug-log.md + index/log entries

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:59:30 +02:00
Oliver Kieselbach b005c20598 feat(agent,backend,web): config-channel agent endpoint migration (MigrateToApiBaseUrl)
Re-home agents to a new API base URL through the agent/config response
(backend move or per-tenant EU->US region move) - the lesson from the
2026-07-18 WEU->GWC cutover, where the compiled-in API hostname hard-cut
every in-flight agent (no DNS indirection possible: mTLS breaks behind a
proxy, Flex custom-domain TLS is broken).

Backend (ConfigVersion 33):
- AdminConfiguration.AgentMigrateApiBaseUrl (global) +
  AgentMigrateTenantOverridesJson (tenantId->URL; empty value pins a
  tenant against the global target; override wins)
- GetAgentConfigFunction.ResolveMigrateTarget serves the validated target;
  AgentMigrateServed / AgentMigrateRejected warnings as delivery evidence
- TableConfigRepository Store+Map; admin portal Global Settings round-trip

Agent (kill-switch-mirrored hardening):
- Shared AgentEndpointMigrationRules: https-only, *.azurewebsites.net on a
  label boundary, no path/port/userinfo - enforced on BOTH sides
- Live fetch only: RemoteConfigService cache strips the field on write AND
  read; EndpointMigration.ResolveTarget re-checks the fetch outcome
- AgentRuntimeConfig.Resolve returns before merge/integrity/bootstrap
  cleanup on a pending migration (no spurious IntegrityCheckFailed against
  a stale migration stub), Program.RunAgent rebuilds Phase 3+4 against the
  new URL - single hop, kill signal wins before and after the hop

Tests: AgentEndpointMigrationTests (backend rules + resolution),
EndpointMigrationTests + cache-hardening strip (agent). Full suites green
(Functions 2992, Agent Core 1822, vitest 557, tsc silent).

Doc: docs/agent/endpoint-migration.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 17:48:48 +02:00
Oliver Kieselbach 12e05f2ffb docs: move design docs out of docs/design; keep customer work-files git-ignored
Reorganize docs/ tree:
- docs/design/mcp-oauth-flow.md -> docs/mcp-oauth-flow.md (no customer data)
- enrollment-status-reclassification{,-plan}.md -> tasks/ (git-ignored work
  files; they contained a customer domain that must not ship in the public repo)
- remove now-empty docs/design/ folder

Repoint the 22 source files whose comments referenced
docs/design/enrollment-status-reclassification.md to the new tasks/ path.
Comment/docstring text only; no functional code changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 10:59:31 +02:00
Claude 824ef9a19c feat(agent): Part C — best-effort emergency-break report [net48, CI-verify]
Make the agent's silent 48h absolute session-age break visible: on break, before
cleanup/exit, fire a SessionAgeEmergencyBreak AgentErrorReport over the resilient
emergency channel; the backend (Part B) materializes it as an agent_emergency_break
timeline event and the timeout classifier terminalizes the session instead of
waiting out grace.

Decoupled so the guard stays pure and its unit tests are untouched:
- CheckSessionAgeEmergencyBreak gains optional Action onBreakFired = null, invoked
  once when the break is confirmed, before marker/cleanup, fully swallowed.
- New EmergencyBreakReporter.TrySend builds a throwaway auth bundle
  (BackendClientFactory) and sends, bounded to a 5s wait. AgentBootstrap wires it;
  GetAgentVersion made internal.

NOTE: agent targets net48 and was NOT compiled in this environment (only the .NET 8
SDK is available) — to be built/verified locally. The Shared AgentErrorType value is
verified (compiled in the backend build).
2026-07-08 15:36:53 +00:00
Oliver Kieselbach 34582c0f0e feat(kill-switch): deliver kill on config channel + KillSignalDelivered ops event
Kill/Block rules were only served on the telemetry response, which cannot
reach an agent whose upload loop is paused (indefinite block pins the drain
until DateTime.MaxValue), whose spool is empty, or whose binary is kill-blind.
Config is fetched at every process start (boot via Scheduled Task), making it
the reliable control channel - no heartbeat added by design (48h emergency
break + 6h max lifetime bound the remaining cases).

Backend (effective on deploy):
- KillSwitchEvaluator: shared device+version check for both channels; ingest
  wire response and check order unchanged (device first, short-circuits)
- Every served Kill emits ops event KillSignalDelivered (Security/Warning),
  throttled 24h per tenant+serial+pattern; Blocks stay event-free
- GET agent/config (incl. bootstrap wrapper) now returns DeviceBlocked/
  DeviceKillSignal/UnblockAt alongside the full config; old binaries ignore
  the new fields
- Web: KillSignalDelivered dual-registered in OPS_EVENT_TYPES

Agent V2 (effective with next agent release):
- CheckConfigKillSignal guard after the Phase-4 fetch: forced self-destruct
  (overrides SelfDestructOnComplete), enrollment-complete marker, session
  cleared, exit before mTLS/registration
- Kill honoured ONLY from a live fetch: config disk cache strips the flags on
  write and read (UnrestrictedMode pattern), guard re-checks LastFetchOutcome

Tests: 9 KillSwitchEvaluatorTests + 7 ProgramConfigKillSignalTests;
Functions 2557/2557, Agent V2 1578/1578, web tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:49:32 +02:00
Oliver Kieselbach e0d4094a25 feat(agent-v2): capture mini-dumps + emit previous_crash_detected event
When the V2 agent process dies via AppDomain.UnhandledException or
TaskScheduler.UnobservedTaskException, synchronously write a compact
MiniDump (data-segs + handle + thread-info, ~5-20 MB) and a CrashRecord
JSON sibling to %ProgramData%\AutopilotMonitor\Crashes\. Next agent
start scans the directory, drains pending records into the timeline as
previous_crash_detected events (Warning, ImmediateUpload), and applies
retention (max 5 dumps, max 7 days). Dumps stay on disk so the existing
customer-opt-in diagnostics-upload can collect them.

Handlers register in Program.Main before SelfUpdater so any downstream
crash is captured; AgentRuntimeHost re-registers once session/tenant
are known so the CrashRecord carries those labels. 6 builder/retention
tests; 1163/1163 V2.Core tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:25:38 +02:00
Oliver Kieselbach d51bde6017 refactor(release): parametric per-line agent build pipeline
Replaces V1/V2-hardcoded build + bootstrap with a single parametric
pipeline that scales to V3+ without rewrites. Full architecture in
.claude/plans/v2-cutover.md.

Stable namespace (Bootstrap-side, never renamed): generic Bootstrap
script, agent owns its --install defaults so it works for any line.
Versioned namespace (per-line, SelfUpdater-side): isolated *-v{N}.*
blobs; SelfUpdater never crosses lines or touches stable.

Backend GetAgentConfig dispatches per X-Agent-Version-Major to
AdminConfiguration.GetAgentLine(int) -> LatestAgentV{N}* fields.
Future V3 = add field set + switch arm. TableConfigRepository.Map
falls back to legacy unsuffixed columns for transparent migration.

V1 SelfUpdater now reads version-v1.json (would have been clobbered
by the V2 cutover otherwise). V2 agent owns its own 600s TenantId-wait
default so the Bootstrap calls --install plain.

Tests: 3289 across 6 suites, all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:36:53 +02:00
Oliver Kieselbach d8e1eb9292 Changed bootstrap script and V2 agent binary name (now without V2, preperation for V1 replacement) 2026-05-07 10:12:37 +02:00
Oliver Kieselbach b86c294771 fix(agent): merge bootstrap-config.json on --install instead of clobbering
Three follow-ups from the TenantIdAwaiter PR review:

* HIGH: A redeploy that passed only --tenant-id-wait (PS1's default
  invocation today) was overwriting bootstrap-config.json with
  BootstrapToken=null / TenantId=null, pushing devices to cert-auth
  before their MDM cert was usable. The install path now reads the
  existing file, merges only fields explicitly given on this
  invocation, and writes the merged result back. Field-given booleans
  drive the merge so that prior values survive an absent CLI flag.

* MEDIUM: --tenant-id-wait 0 is now a real opt-out. Previously the
  persist block was gated on tenantIdWaitSeconds > 0, so an admin who
  flipped the PS1 default to 0 and redeployed kept the old non-zero
  wait persisted. The gate is now "any flag explicitly set" and the
  merge writes 0 over the prior value when 0 is what was given.

* LOW: --tenant-id-wait <SEC> is now in --help under a new
  "TenantId resolution" section so the start-reliability switch is
  discoverable.

The merge logic is extracted as Program.MergeBootstrapConfig — a pure
function — and covered by 7 InstallModeMergeTests cases (wait-only
preserve, explicit-zero override, tenant-only preserve, token-only
preserve, first-install path, PS1 default invocation, legacy
existing-config without the new field).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 22:15:29 +02:00
Oliver Kieselbach 3016c15919 feat(agent): event-driven TenantId wait via RegistryWatcher
Bridges the OOBE / hybrid-AAD-join race window where the agent fires
before AADTenantID / CloudDomainJoin\TenantInfo are written to the
registry. Field evidence (GardnerMedia, 2026-04-30): the agent fired
~5 minutes before the AAD device cert landed; an event-driven wait of
10 minutes recovers the run instead of bailing and waiting for next
reboot.

Mechanism (V2 only — V1 keeps the fast-fail path):
* New TenantIdAwaiter.WaitForTenantId(timeoutSec, logger, ct) sets up
  a CompositeRegistryChangeSignal over HKLM\SOFTWARE\Microsoft\Enrollments
  and HKLM\SYSTEM\...\CloudDomainJoin (Registry64 view, recursive,
  Name+LastSet+ThreadAgnostic filter), re-probes silently on every
  change, and returns the resolved TenantId or null on timeout.
* 30 s belt-and-suspenders periodic re-probe protects against a missed
  RegNotifyChangeKeyValue notification.
* 100 ms debounce after each signal so multi-value enrollment
  transactions land before the re-probe.

Configuration:
* Opt-in via --tenant-id-wait <sec>. Default 0 = no wait (legacy
  fast-fail). The PS1 bootstrap script owns the production default;
  the agent itself never assumes one.
* CLI value persisted to bootstrap-config.json by --install so the
  Scheduled Task picks it up on each restart.
* Install-AutopilotMonitor-v2.ps1 sets $tenantIdWaitSec = 600. Admins
  override per tenant by editing this single line and re-deploying.

Testability:
* IRegistryChangeSignal abstraction lets unit tests drive the loop
  with a fake signal and scripted probe — 8 deterministic cases cover
  timeout, signal hit, multi-signal, cancellation, periodic re-probe,
  whitespace/empty/null handling, zero-timeout short-circuit, and
  argument validation.
* RegistryWatcherIntegrationTests exercise the real Win32 plumbing
  (RegNotifyChangeKeyValue → Changed event) against HKCU so no
  elevation is required and the test runs in standard CI.
* Both classes opt out of xUnit parallelisation — the timing-sensitive
  Thread.Sleep handshakes are unreliable on a busy parallel pool.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 21:54:43 +02:00
Oliver Kieselbach ba0fb7ae0c feat(agent): V2 WhiteGlove Part 1 graceful-exit hardening
Three coordinated fixes so a WhiteGlove Part-1 reseal-reboot does not race
the agent's termination path and leave the timeline missing
agent_shutting_down / whiteglove_part1_complete events plus a stale
previousExit=reboot_kill on the next run.

- Real spool-empty drain: EnrollmentTerminationHandler.DrainSpool polls
  PendingItemCount + IngressPendingSignalCount instead of blind Task.Delay,
  so it returns the moment the backend actually ack'd the lifecycle events
  (or hits the bounded fallback timeout).

- Early clean-exit.marker write: marker is written before _signalShutdown
  returns control, in addition to the existing AppDomain.ProcessExit
  fallback. Wins the race against admin-triggered shutdown.exe.

- Off-worker termination dispatch: Terminated event from
  OnDecisionTerminalStage now goes through Task.Run so the SignalIngress
  worker is freed to process events the handler itself just posted. Adds
  PendingSignalCount with race-free posted-not-yet-completed semantics
  (Interlocked.Increment in Post BEFORE channel.Add, decrement in
  ProcessItem finally + add-throw paths). Two-phase drain (ingress queue
  -> spool) replaces the previous spool-only wait.

- Fast-path inline classifier on WhiteGloveShellCoreSuccess: emits
  whiteglove_complete in a single reduce step with the full
  DecisionAuditTrailBuilder TypedPayload (decisionSource, trigger,
  signalsSeen, classifier verdict, classifierInputs, scenario), preserving
  byte-stable forensics across fast and slow paths.

Tests:
- DecisionCore: fast-path audit-trail completeness, fast-path single-step
  transition, slow-path fallback when AccountSetup activity excludes WG,
  scenario-replay run/skip counts.
- V2 Core: Termination two-phase drain (immediate / poll-until-zero /
  timeout-fallback / blind-delay-fallback / accessor-throw safe), early
  clean-exit-marker ordering vs signalShutdown, off-worker
  Terminated-dispatch verified by Thread.Name + handler-posted-signal
  drain, SignalIngress PendingSignalCount idle/in-flight/post-stop and
  deterministic race-leak regression via internal test seam.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 18:05:37 +02:00
Oliver Kieselbach 0e08af8f58 refactor(v2): extract AgentRuntimeHost + TerminationPipeline (PR7)
Final PR of the V2 Runtime-Host slice — Phase 7+8 of RunAgent (the
EnrollmentOrchestrator-using block, the shutdown synchronisation
primitives, the onIngressReady wiring, the post-startup decision-signal
posts, the analyzer + probes startup, the shutdown.Wait + finally)
moves into Runtime/AgentRuntimeHost.cs and Runtime/TerminationPipeline.cs.

AgentRuntimeHost.Run takes the bundles produced by PR2-PR5
(BootstrapResult, BackendAuthBundle, RuntimeConfigBundle,
TelemetryClientResult, SessionRegistrationOutcomeResult) plus the
pre-computed paths and returns the agent's process exit code. The
cadence-clamp helpers (Min/Max/Default Upload* constants +
ClampUploadIntervalSeconds + ClampUploadBatchSize) move with it — they
are an internal concern of the runtime host, not a Program-level
configuration helper.

TerminationPipeline.Run owns the finally-block contract: unsubscribe
event handlers (Ctrl+C / ProcessExit / Terminated / ThresholdExceeded)
to prevent re-entrant shutdown, run orchestrator.Stop, dispose the
mTLS HttpClient and the BackendApiClient, then signal shutdownComplete
LAST so any ingest-dispatcher thread parked in the
ServerActionDispatcher terminate-callback only resumes once a
fully-cleaned-up agent is visible. The Plan §6.2 synchronous-shutdown
contract is preserved verbatim.

RunAgent collapses to a linear ~75-line composer: Phase 1+2 →
AgentBootstrap.Run, Phase 3 → BackendClientFactory.BuildAuthClients,
Phase 4 → AgentRuntimeConfig.Resolve, Phase 5 →
BackendClientFactory.BuildTelemetryClients, Phase 6 →
BackendSessionRegistration.Register, Phase 7+8 →
AgentRuntimeHost.Run. Every dead alias from the previous PRs is
cleared; each phase is a single named call with a clear exit-code
result.

Program.cs: 1607 LOC (pre-PR1) → 441 LOC (after PR7). RunAgent body:
~770 LOC → ~75 LOC, fully linear. No behavioural change — every
construction order, exit code, side effect and synchronisation contract
is preserved exactly.

Build clean (0 W / 0 E). Full V2.Core suite 766/767 (1 known
RecoveryPathTests threading flake, passes solo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:36:56 +02:00
Oliver Kieselbach fb71fb1c9c refactor(v2): extract LifecycleEmitters + ServerControlPlane (PR6)
PR6 of the V2 Runtime-Host slice — the heaviest closure-capture block
of RunAgent moves into two new factory classes plus the
ServerActionSynthesis partial is folded into the new home.

LifecycleEmitters (Runtime/LifecycleEmitters.cs):
- EmitAgentStarted, EmitVersionCheckIfAny,
  EmitUnrestrictedModeAuditIfChanged
- PostSessionStarted, PostAdminPreemption, PostSystemRebootObserved
  (decision-signal posters that previously lived as Program statics)
- CreateMaxLifetimeEmitter / CreateAuthThresholdHandler — factory
  methods returning EventHandlers that close over a Func<Post>
  accessor so the watchdogs can be subscribed BEFORE the live
  InformationalEventPost is constructed in the orchestrator's
  onIngressReady hook (the trick that made the inline lambdas hard
  to read).

ServerControlPlane (Runtime/ServerControlPlane.cs):
- BuildDispatcher — constructs the live ServerActionDispatcher with
  its rotate_config / upload_diagnostics / terminate_session callbacks
  and the 60-second synchronous-shutdown wait
- Wire — the EnrollmentOrchestrator.Transport.ServerResponseReceived
  handler that translates DeviceKillSignal / AdminAction / Actions[]
  into ServerActions
- HandleAdminAction, MapAdminOutcome — moved from Program.cs and the
  Program.ServerActionSynthesis.cs partial (which is deleted)

The terminate_session callback's synchronous-shutdown wait, the
dispatcher's reentry short-circuit (Codex Finding 1) and the
admin-outcome mapping (Codex Finding 2) are preserved verbatim with
identical comments. RunAgent's onIngressReady block shrinks from
~135 lines to ~12 lines of factory invocations.

Existing tests (AdminActionOutcomeMappingTests, HandleAdminActionTests,
PostSessionStartedSignalTests) migrated to the new namespace
(global::AutopilotMonitor.Agent.V2.Runtime.* instead of Program.*).

New tests (LifecycleEmittersTests, 9 cases): max-lifetime suppress on
null post, no-emit on non-max-lifetime reason, emit-on-max-lifetime,
auth-threshold signalShutdown side-effect, agent_shutdown event
emission, signalShutdown still fires when post is null,
unrestricted-mode no-op gates (null/unchanged/changed).

Build clean (0 W / 0 E). Full V2.Core suite 766/767 (1 known
RecoveryPathTests threading flake, passes solo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:25:25 +02:00
Oliver Kieselbach 79622515f0 refactor(v2): extract BackendSessionRegistration from Program.RunAgent (PR5)
Phase 6 (POST /api/agent/register-session with retry, outcome-based
exit-code mapping, on-failure mTLS+API-client disposal) moves into
Runtime/BackendSessionRegistration.cs.

SessionRegistrationOutcomeResult is the discriminated outcome — Continue
with the live SessionRegistrationResult (consumed downstream by
PostSessionStartedSignal, PostAdminPreemptionSignal,
WireTelemetryServerResponse, HandleAdminAction) or Exit with the
V1-parity codes (6 = AuthFailed, 7 = anything else; the diag skill
keys on the distinction in Scheduled-Task history).

Disposal of mtlsHttpClient + auth.BackendApiClient on registration
failure is preserved inside the factory, matching the original
inline behaviour. RunAgent's Phase 6 block shrinks from ~20 lines
to 8 lines (factory call + result destructuring).

Tests: 4 new BackendSessionRegistrationTests (Exit-6, Exit-7, Continue,
null-arg validation). Existing SessionRegistrationHelperTests cover
the underlying retry/outcome behaviour. Full V2.Core suite 757/758
(1 known RecoveryPathTests threading flake, passes solo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:02:50 +02:00
Oliver Kieselbach 6b804d38d9 refactor(v2): extract AgentRuntimeConfig from Program.RunAgent (PR4)
Phase 4 (RemoteConfigService.FetchConfigAsync, RemoteConfigMerger.Merge,
AuthFailureTracker ceiling refresh, logger SetLogLevel, SelfUpdater
hash propagation, BinaryIntegrityVerifier check + IntegrityCheckFailed
emergency-report dispatch, BootstrapConfigCleanup H-2 mitigation) moves
into Runtime/AgentRuntimeConfig.cs.

RuntimeConfigBundle exposes the live RemoteConfigService (still needed
later by the rotate_config ServerAction callback), the fetched
AgentConfigResponse and the RemoteConfigMergeResult diff (consumed by
the EmitUnrestrictedModeAuditIfChanged lifecycle hook).

VerifyBinaryIntegrity and TryCleanupBootstrapConfig stay private to
the factory — they are pure side-effect helpers with no return value
needed downstream. The runtime-self-update trigger closure no longer
captures Program.GetAgentVersion via implicit access; it receives the
agent version as a parameter instead.

RunAgent's Phase 4 block shrinks from ~70 lines to 7 lines (factory
call + bundle destructuring). No behavioural change: ordering of
side effects (Merge before UpdateLimits before SetLogLevel before
SelfUpdater hash before VerifyIntegrity before BootstrapCleanup) is
preserved exactly.

Tests: 4 new AgentRuntimeConfigTests (RuntimeConfigBundle property
mapping + 3 null-arg validation cases). Full V2.Core suite 753/754
(1 known RecoveryPathTests threading flake, passes solo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:57:23 +02:00
Oliver Kieselbach 1785854ac7 refactor(v2): extract BackendClientFactory from Program.RunAgent (PR3)
Phase 3 (BackendApiClient + DistressReporter + EmergencyReporter +
AuthFailureTracker + AuthCertificateMissing distress emit) and Phase 5
(mTLS HttpClient + BackendTelemetryUploader) move into
Runtime/BackendClientFactory.cs.

BackendAuthBundle holds the four clients plus the cached
manufacturer/model/serial from HardwareInfo so RunAgent + Phase 5 read
the same hardware identity. TelemetryClientResult is the discriminated
outcome for Phase 5 — Continue with mTLS+uploader, or Exit with the
V1-parity codes (4 = mTLS construction failure, 5 = uploader
construction failure).

Phase 4 (RemoteConfig fetch + merge + binary-integrity + bootstrap-
config cleanup) stays inline in RunAgent for PR4. RunAgent reads the
Phase 3 clients via local aliases so Phase 4 wiring is unchanged.

RunAgent's Phase 3+5 blocks shrink from ~80 lines to ~12 lines of
factory invocations + result destructuring. No behavioural change:
constructor argument lists, distress-emit guard, exit-code mapping
and the disposal-on-failure semantics are preserved.

Tests: 4 new BackendClientFactoryTests (auth-bundle smoke,
no-cert-lookup-on-bootstrap-auth, TelemetryClientResult.Exit,
TelemetryClientResult.Continue). V2.Core 747/750 without the 3 known
EnrollmentOrchestrator threading flakes (pass solo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:50:01 +02:00
Oliver Kieselbach 9b9ad61315 refactor(v2): extract AgentBootstrap from Program.RunAgent (PR2)
Phase 1+2 of RunAgent (previous-exit classification, persisted-config
load, TenantId resolution, AgentConfiguration build, SessionId
persistence, completion-marker / emergency-break guards, optional
--await-enrollment cert wait) move into Runtime/AgentBootstrap.cs.

The static helpers (DetectPreviousExit, TryReadBootstrapConfig,
TryReadAwaitEnrollmentConfig, DeleteAwaitEnrollmentConfig,
CheckEnrollmentCompleteMarker, CheckSessionAgeEmergencyBreak,
BuildAgentConfiguration) stay on Program (Program.Guards.cs) and are
called via internal access from the new Runtime namespace.

BootstrapResult is the discriminated outcome — ShouldExit + ExitCode
for the V1-parity early-return paths (0 = guard handled, 2 = no
TenantId, 3 = await timeout), or a Continue payload carrying
AgentConfiguration, SessionIdPersistence, PreviousExitSummary,
IsWhiteGloveResume and the cleanup-service factory.

RunAgent's first ~95 lines collapse to a single AgentBootstrap.Run
call + result destructuring. No behavioural change: every helper
invocation, exit code and side effect (sessionPersistence.Delete on
--new-session, IsWhiteGloveResume snapshot before GetOrCreate,
DeleteAwaitEnrollmentConfig on cert success) is preserved.

Tests: 4 new AgentBootstrapTests (marker-handled exit, age-emergency
exit, continue-with-bootstrap-config, first_run classification);
existing 18 ProgramGuardsTests still pass; full V2.Core suite 745/746
(1 known RecoveryPathTests threading flake).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:39:24 +02:00
Oliver Kieselbach a775f3288b fix(v2): apply remote telemetry cadence + spool perf/observability
P1 — UploadIntervalSeconds and MaxBatchSize were merged into agentConfig
by RemoteConfigMerger but never reached the orchestrator: drainInterval
fell back to a hardcoded 30s and TelemetryUploadOrchestrator to its
default batchSize=100. Pass both from agentConfig at construction with
clamped bounds (5..300s, 1..500 items) and log the applied values.

P2 step 1 — TelemetrySpool.Enqueue used FileOptions.WriteThrough +
Flush(true) per item. Make fsync conditional on RequiresImmediateFlush
so periodic snapshot items go through the OS cache only; backend
dedupes via (PartitionKey, RowKey), so the worst-case loss on crash is
a few non-lifecycle items.

P2 step 2 — Replace File.ReadAllLines on every Peek with an in-memory
LinkedList tail cache populated once in the ctor and kept in sync by
Enqueue/MarkUploaded. Disk JSONL stays the source of truth for crash
recovery and diagnostics ZIP forensics; the cache makes drain O(batchSize)
instead of O(N).

Telemetry — surface spool stats (pendingItemCount, peakPendingItemCount,
fileSizeBytes, totalEnqueuedCount) in agent_metrics_snapshot, plumbed
via DefaultComponentFactory.SetTelemetrySpool. Emit a one-shot
spool_pressure_detected event with ImmediateUpload=true when pending
crosses 2000 items or file grows past 5 MB, so upload-stall conditions
become queryable on the backend without waiting for the next snapshot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:25:16 +02:00
Oliver Kieselbach 1d42332c7b fix(v2): count BackendTelemetryUploader requests in NetworkMetrics
Insert a NetworkMetricsRecordingHandler at the head of the mTLS HttpClient
pipeline so every outbound request through MtlsHttpClientFactory.Create
(in particular BackendTelemetryUploader's POST /api/agent/telemetry, which
dominates per-session traffic) is recorded on the shared NetworkMetrics
instance. Previously only the legacy BackendApiClient calls
(config + gather-rules + emergency) called RecordRequest directly, so
agent_metrics_snapshot.net_total_requests undercounted by ~25-30x
(observed: ~2/session in V2 vs ~58/session in V1).

The handler is wired by passing backendApiClient.NetworkMetrics — the same
instance later read by AgentSelfMetricsCollector — into the factory. No
double-count: BackendApiClient builds its own HttpClient and is unaffected
by the mTLS pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:04:17 +02:00
Oliver Kieselbach fdc0e3e8ce fix(v2): normalize timeline event Source values + stop periodic collectors before diag ZIP
Source attribution review on session df7abbc2 surfaced four inconsistencies in the Source field
on the Events table — assembly leakage, V1-parity carry-overs, and a CamelCase / snake_case mix
on the same emitter. Normalize so every Source is a CamelCase class-name or the stable lifecycle
label "Agent":

- Program.cs `agent_started` — "AutopilotMonitor.Agent.V2" -> "Agent" (V1 parity, drops assembly leak)
- ImeLogTrackerAdapter `script_completed` — "IME" -> "ImeLogTracker" (consistent with the seven
  sibling events emitted from the same adapter)
- StartupEnvironmentProbes (5 sites: device_location, agent_trace, timezone_auto_set, two
  ntp_time_check shapes) — "Network" -> "StartupEnvironmentProbes" (class-name consistency;
  eventType already encodes the sub-probe)
- EventTimelineEmitter default — "decision_engine" -> "DecisionEngine" (CamelCase, matches the
  override path used by phase_transition; eliminates the per-event mix on the wire)

Tests adjusted: ImeLogTrackerAdapterTests, StartupEnvironmentProbesTests, EventTimelineEmitterTests,
InformationalEventPostTests. EnrollmentEvent.Source XML doc updated to reflect the new convention.

Same session also showed `performance_snapshot` (seq 220) firing AFTER `diagnostics_collecting`
(seq 219), because periodic collectors keep ticking through the termination handler. Fix:

- New EnrollmentOrchestrator.StopCollectorHosts() — idempotent; the existing Stop() now delegates
  to it so we never double-stop.
- EnrollmentTerminationHandler gains an optional `stopPeripheralCollectors` callback and invokes
  it best-effort right before RunUploadDiagnosticsWithEvents (after DelayLateEventGrace). The
  WhiteGlove Part 1 path returns earlier and is unaffected — the agent stays alive for Part 2.
- Program.cs wires `() => orchestrator.StopCollectorHosts()` so all collector hosts (Performance,
  AgentSelfMetrics, DeliveryOptimization, ESP/Hello, IME log, …) are stopped before the diag ZIP
  is built. Late `performance_snapshot` / `agent_metrics_snapshot` no longer slip in after the
  `diagnostics_collecting` event.

Tests: V2.Core 732/732, DecisionCore 232/232, Backend 736/736.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:02:11 +02:00
Oliver Kieselbach 07312ee2a3 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>
2026-04-25 19:20:42 +02:00
Oliver Kieselbach b243706629 fix(v2): 882fef64 PR1 — Company Portal UI, diagnostics ZIP State/Spool, agent_shutting_down V1 parity
- DownloadProgress.tsx: exempt app_download_started from <1024B drop so WinGet/Store
  apps (Company Portal etc.) appear in the UI; filter logic extracted to pure helpers
  in lib/downloadProgressFilters.ts for vitest coverage.
- DiagnosticsPackageService: ZIP now also packages AgentState/ (snapshot, journal,
  signal-log, ime-tracker-state, enrollment-complete.marker, final-status,
  whiteglove-backfill-state + .quarantine), AgentSpool/ (pending uploads + cursor),
  and AgentMarkers/ (top-level whiteglove.complete + clean-exit). BuildArchiveBytes
  extracted as internal seam so xUnit asserts ZIP contents without HTTP.
- EnrollmentTerminationHandler: agent_shutting_down emission moved to top of Handle()
  so it fires unconditionally — including SelfDestruct=false dev VMs and WhiteGlove
  Part-1 exits. Data carries reason/outcome/stage/uptimeMinutes/agentVersion.

Tests: V2.Core 711 (+12), DecisionCore 220, Functions 736, vitest 179 (+11),
solution build 0/0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:59:00 +02:00
Oliver Kieselbach 8b7a5f509e fix(v2): debrief 7dd4e593 followups + final-status schema 2 + V2 dialog renderer
F3 — SummaryDialog launch crash: switch temp dir from C:\WINDOWS\SystemTemp\
(unreadable for standard users when agent runs as SYSTEM) to flat
%ProgramData%\AutopilotMonitor-Summary\ + wipe-on-launch (V1 parity), and
grant BUILTIN\Users delete rights so the dialog's --cleanup self-removal
works.

F4 — Backend phase classification: classify `phase_transition` events
(emitted by V2 DecisionEngine for AppsDevice/AppsUser/FinalizingSetup) as
phase updates so CurrentPhase advances during App phases. Also force
CurrentPhase=Complete on Status=Succeeded to materialise the terminal
phase regardless of the carrier event's Phase value (V2 emits enrollment_
complete with Phase=Unknown per feedback_phase_strategy).

F5 — App tracking summary covers all phases: ImeLogTracker now snapshots
AppPackageState references (not serialized dicts) before _packageStates.
Clear() at the AccountSetup transition, and exposes a deduped union
GetAllKnownPackageStates() for the termination summary path. Without this,
DeviceSetup apps were silently dropped from app_tracking_summary and the
SummaryDialog after the phase transition.

Schema 2 / V2 dialog — final-status.json now carries schemaVersion=2 plus
failureReason, signalTimestamps, and per-app errorPatternId/errorDetail/
errorCode. SummaryDialog branches on schemaVersion: V1 path is the
unchanged legacy renderer (deletable when V1 retires), V2 path renders
6 outcome states (Success / PreProvisioningComplete / TimedOut / Failure
/ Unknown / WhiteGlovePart2), shows a coloured banner with the agent-
supplied failure reason, and per-app rows with duration on the right plus
the IME-pattern error detail under the app name when an install failed.

V1 agent + V1 dialog rendering remain bit-for-bit identical (no schema
field → V1 render path). V1 Agent.Core.Tests 507/507, DecisionCore 220/220.

Tests: V2.Core 702/702 (+12 schema-2 builder, signal-timestamps, failure-
reason, per-app errors, phase-snapshot dedup, launcher path). Backend
736/736. New SummaryDialog.Tests 45/45 (OutcomeMapper + V1/V2 JSON
roundtrip + FormatDuration). Build clean (0 warnings).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 13:37:31 +02:00
Oliver Kieselbach fef5834ad0 v2 single-rail 13: remove dead Registry-guard in CheckEnrollmentCompleteMarker
The registry-based ghost-restart branch in CheckEnrollmentCompleteMarker
(HKLM\SOFTWARE\AutopilotMonitor\Deployed present + !SessionExists) has
never fired in production and was structurally unreachable:

- session.id is created via SessionIdPersistence.GetOrCreate BEFORE the
  guard runs (both in V1 LoadConfiguration:131 and V2 Program.cs:216), so
  the !SessionExists() retry-cleanup arm is never reachable at runtime.
- Even if the arm were reachable, the Deployed regkey cannot distinguish
  "fresh install" from "ghost restart": it is written by --install and
  intentionally preserved by CleanupService as a bootstrap-script
  reentry lock (read by scripts/Bootstrap/Install-AutopilotMonitor-v2.ps1
  Guard 1 to prevent re-install on production devices). Both scenarios
  therefore present as "Deployed present + session.id absent".

Remove the registry-guard block and its dead retry-cleanup path. Keep
the file-based enrollment-complete.marker path intact — it is written
by CheckSessionAgeEmergencyBreak before its own retry-cleanup attempt
and serves the "cleanup-disabled emergency break" exit-cleanly case.

Also drop the now-obsolete "Create SessionId BEFORE guards" justification
comment in Program.cs and the matching boot-sequence doc wording —
neither remaining guard (file-based marker, session-age watchdog) keys
on session.id absence for ghost detection. CheckEnrollmentCompleteMarker
loses the now-unused dataDirectory parameter; callsites updated.

Tests: ProgramGuardsTests.cs keeps marker-absent / marker-without-
selfdestruct / marker-with-selfdestruct-retry coverage; the first-run-
after-install regression test is renamed to match its actual scope
(returns_false_when_marker_file_is_absent). 630/630 V2.Core tests green.

No behavior change — removed path was unreachable. TryWriteDeploymentMarker
in Program.InstallMode.cs stays (writes the bootstrap-reentry-lock);
CleanupService continues to intentionally leave the regkey in place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 03:24:52 +02:00
Oliver Kieselbach 8fe75a0f4e v2 single-rail 12: first-session-fixes PR 4 — data completeness + operability (Fixes 4+5)
Closes the "final-status.json shows only 3 apps", "every app ~30 s" and
"agent log silent during uploads" gaps observed in session 30410cd7.

Fix 4c — Per-app install-lifecycle timing. New AppInstallTiming model
(StartedAtUtc / CompletedAtUtc / DurationSeconds). ImeLogTrackerAdapter
tracks timing in a set-once dictionary (first Downloading/Installing/
InProgress stamps StartedAt; first terminal stamps CompletedAt) and
exposes it via a new public AppTimings snapshot. app_install_* DataJson
carries startedAt / completedAt / durationSeconds when known; omitted
otherwise so the UI can distinguish "not tracked" from "stamp=epoch".

Fix 4a — FinalStatusBuilder uses timings. Optional appTimings param on
Build(); FinalStatusPackageInfo gets StartedAt / CompletedAt /
DurationSeconds with NullValueHandling.Ignore. Existing per-targeted
phase bucketing preserved — that was correct.

Fix 4b — app_tracking_summary terminal event. EnrollmentTerminationHandler.
EmitAppTrackingSummary runs once at termination before diagnostics upload
with aggregate counts, per-phase breakdown, and per-app records
(appId/name/phase/finalState/optional timing). ImmediateUpload=true,
Phase=Unknown (feedback_phase_strategy). Skipped on WhiteGlove Part 1
(apps haven't installed yet).

Fix 5 — Upload-cadence logging. BackendTelemetryUploader + TelemetrySpool
gained optional AgentLogger params. Uploader logs INFO flushing/ingest OK,
WARN ingest TRANSIENT (5xx/timeout/network), ERROR ingest PERMANENT (4xx),
with items + durationMs. Spool logs DEBUG per-enqueue with kind + immediate
flag + pending count. Empty-batch path stays silent (short-circuit).

Plumbing: ImeLogHost.AppTimings → DefaultComponentFactory.ImeAppTimings →
Program.cs wires appTimingsAccessor into EnrollmentTerminationHandler;
logger flows into uploader (Program.cs) and spool (EnrollmentOrchestrator).
All new ctor params are nullable-default → zero breakage for existing
call sites and tests.

Tests: +18 new (7 adapter timing, 2 builder timing, 5 handler summary,
4 uploader logging). DecisionCore 139, Agent.V2.Core 630, Functions 727,
Web vitest 168 — all green. Web typecheck clean. Solution build 0/0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 02:24:04 +02:00
Oliver Kieselbach 7b64f98e63 v2 single-rail 9: soft admin-action semantics + shutdown-race fixes
Addresses two tasks/todo.md entries from 2026-04-23:
  - Soft Admin-Action Semantik + SignalIngress-Race-Fix
  - Backend AdminAction-Trigger fix

Backend — authoritative AdminMarkedAction field:
  New Session.AdminMarkedAction property (null | "Succeeded" | "Failed")
  replaces the fragile heuristic "Status is terminal AND current event is not
  a completion marker", which fired falsely on every post-completion event
  the agent sent (agent_shutting_down, diagnostics_uploaded,
  enrollment_summary_shown). The backend's AdminAction response to /register
  and /ingest now reads the explicit field. ISessionRepository +
  TableSessionRepository + TableStorageService.Sessions +
  Ingest{Events,Telemetry}Function + MarkSession{Succeeded,Failed}Function +
  RegisterSessionFunction + EventIngestProcessor all updated to set / clear /
  read the new field coherently.

Agent — Program.cs WireTelemetryServerResponse refactor:
  Routes TelemetryUploadOrchestrator.ServerResponseReceived through three
  distinct semantic paths instead of one terminate_session synthesis:
    1. DeviceKillSignal  -> terminate_session with forceSelfDestruct=true
                            (hard admin kill — overrides SelfDestructOnComplete).
    2. AdminAction=Succeeded -> single admin_marked_session timeline entry
                                 (informational; agent continues own path).
    3. AdminAction=Failed -> EnrollmentTerminationHandler.Handle(outcome=Failed)
                              (soft shutdown; same analyzers/diagnostics/summary
                              as a locally-detected failure).
  Plus a shutdown-race guard: if shutdownComplete is already set, short-circuit
  instead of crashing deep in the dispatcher with an InvalidOperationException
  from a disposed SignalIngress. Backed by new HandleAdminActionTests.

Agent — ServerActionDispatcher ingress-disposed race:
  Added InvalidOperationException swallow around InformationalEventPost.Emit
  for the narrow window between SignalIngress.Stop and
  TelemetryUploadOrchestrator.DrainAllAsync completion. Dropped events are
  acceptable during teardown; the alternative was a nested Emit failure that
  corrupted the outer termination sequence. Backed by new
  ServerActionDispatcherTests case.

Tests all green: DecisionCore 133, Agent.V2.Core 586, Functions 727.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 00:35:36 +02:00
Oliver Kieselbach 92d300d4c2 v2 single-rail 7: codex-review feedback — 4 findings fixed
Addresses all 4 findings from the Codex review on PR #48. 564 V2.Core +
111 DecisionCore tests green (up from 559 + 109; 7 new regression tests).

Finding 1 (P1) — terminate_session drain deadlock
  TelemetryUploadOrchestrator raised ServerResponseReceived synchronously
  while holding _drainGuard. The terminate callback then invoked
  orchestrator.Stop() on the main thread, which tried a terminal
  DrainAllAsync and blocked on the same guard — timing out before the
  final agent_shutting_down event could be uploaded.
  Fix: buffer signal-bearing UploadResults during drain, release the
  guard, then raise events. Also added a defensive re-entry short-circuit
  in onTerminateRequested (skips if shutdown.IsSet) for the pathological
  case of a nested drain producing a different ActionId.
  Files: TelemetryUploadOrchestrator.cs, Program.cs
  Test: Handler_may_invoke_reentrant_drain_without_deadlock

Finding 2 (P1) — structured Event.Data was degenerating to type-name strings
  InformationalEventPost.StringifyData fell through to kv.Value.ToString()
  for nested containers, so e.g. network_adapters.adapters became the
  literal string "System.Collections.Generic.List\`1[...]" on the wire.
  Fix (redesigned after review): typed-sidecar through the bus, mirroring
  the existing DecisionEffect.ClassifierSnapshot pattern.
    DecisionSignal.TypedPayload (object?)   ← new
    DecisionEffect.TypedPayload (object?)   ← new
    ISignalIngressSink.Post(..., typedPayload)
    EventTimelineEmitter.Emit(..., typedPayload) uses it as Data verbatim
  Live path does ZERO serialization for structured event data — the
  Dictionary<string,object> flows object-identity-preserving from
  collector to TelemetryEventEmitter, where JsonConvert.SerializeObject
  runs once at the wire boundary. Persistence writes TypedPayload to
  JSONL once at the disk boundary (SignalSerializer); replay restores
  it as JObject/JArray tokens that re-serialize identically.
  Files: DecisionSignal.cs, DecisionEffect.cs, DecisionEngine.Shared.cs,
    SignalSerializer.cs, ISignalIngressSink.cs, SignalIngress.cs,
    EnrollmentOrchestrator.cs (LazyIngressSinkRelay), IEventTimelineEmitter.cs,
    EventTimelineEmitter.cs, EffectRunner.cs, InformationalEventPost.cs,
    + FakeSignalIngressSink, FakeEventTimelineEmitter test doubles
  Tests: TypedPayload_dictionary_becomes_EnrollmentEvent_Data_verbatim_on_wire,
    TypedPayload_missing_falls_back_to_string_parameter_reconstruction,
    Emit_EnrollmentEvent_overload_forwards_Data_as_typed_payload_by_reference,
    Emit_EnrollmentEvent_overload_passes_DateTime_as_live_object_through_typed_payload,
    SignalSerializer_roundtrip_preserves_TypedPayload_structure,
    SignalSerializer_roundtrip_null_TypedPayload_stays_null

Finding 3 (P2) — ImmediateUpload=false silently flipped to true
  Helper wrote the ImmediateUpload payload key only when true; the emitter
  defaulted "missing key → true". Net effect: a caller passing
  immediateUpload:false (e.g. performance_snapshot, agent_metrics_snapshot)
  had their intent silently overridden; every snapshot was flushed
  immediately.
  Fix: helper now writes the key both-ways ("true" or "false"). Emitter
  default stays true because reducer-synthesised terminal events
  (whiteglove_complete, enrollment_complete, enrollment_failed) construct
  effect parameters with just eventType and rely on that default for
  immediate flush — documented inline.
  Files: InformationalEventPost.cs, EventTimelineEmitter.cs (doc-only)
  Tests: ImmediateUpload_false_writes_explicit_false_key_to_payload,
    Emit_optional_payload_keys_are_set_only_when_caller_provides_them (updated)

Finding 4 (P2) — PeriodicCollectorLifecycleHost saw only its own posts
  IdleActivityObservingIngressSink wrapped the ingress per-host, so
  _lastRealEventTimeUtc was only updated by signals routed through this
  host's own _post. Every other component (Program.cs lifecycle, IME,
  ESP, DeviceInfo, Analyzer, Gather, StallProbe, Network, AAD, Desktop,
  etc.) constructs its own InformationalEventPost against the raw shared
  ingress and bypassed the wrapper — and periodic events posted by the
  host itself were correctly filtered out. Result: the idle clock never
  advanced past session start; collectors stopped after idle timeout
  regardless of live enrollment activity.
  Fix: moved observation into SignalIngress itself as an event
  SignalPosted, fired after every successful enqueue (fast path + slow
  path). PeriodicCollectorLifecycleHost subscribes via the concrete
  SignalIngress type at Start, unsubscribes at Stop. The observer now
  sees ALL signal kinds (ESP, IME, Classifier, DeadlineFired, etc.), not
  just InformationalEvents. Deleted the obsolete wrapper class.
  Files: SignalIngress.cs, DefaultComponentFactory.cs
  Tests: SignalPosted_fires_for_every_successful_post_regardless_of_kind,
    SignalPosted_handler_exception_does_not_break_ingress,
    SignalPosted_is_raised_even_when_channel_took_slow_path

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 19:58:14 +02:00
Oliver Kieselbach 818fa53728 v2 single-rail 5.7: migrate Analyzer trio + AgentAnalyzerManager to Signal-Post (B24-B26)
Full migration — the 3 analyzer classes and their manager all move to
InformationalEventPost, no Action<EnrollmentEvent>-bypass remains in the
analyzer surface (no dual-use concerns; no standalone CLI mode touches
these types).

Analyzers (1 Emit-site each):
  * LocalAdminAnalyzer (B24): local_admin_analysis.
  * SoftwareInventoryAnalyzer (B25): software_inventory_analysis
    (startup baseline + shutdown delta; WG-part discriminator unchanged).
  * IntegrityBypassAnalyzer (B26): integrity_bypass_analysis.

Manager:
  * AgentAnalyzerManager stores InformationalEventPost _post and passes
    it to all 3 analyzer constructors in Initialize().

Program.cs wiring:
  * AgentAnalyzerManager construction deferred into orchestrator.Start's
    onIngressReady hook so lifecyclePost is non-null at construction time.
    The outer-scope `AgentAnalyzerManager analyzerManager = null;`
    declaration is captured by both the later RunStartup() call (after
    Start returns) and the terminationHandler constructor (inside the
    same hook).

Tests:
  * AgentAnalyzerManagerTests helper NewPost() returns a throwaway
    InformationalEventPost(new FakeSignalIngressSink(), VirtualClock)
    for the 7 ctor sites including the null-arg test.
  * 538 V2.Core + 108 DecisionCore = 646 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:57:44 +02:00
Oliver Kieselbach 0bc04c3a9a v2 single-rail 5.3c: Program.cs terminate callback waits for shutdown completion
Plan §6.2 synchronous-shutdown — the ServerActionDispatcher terminate
callback now blocks until the main-thread cleanup sequence (orchestrator
stop, client disposal) has fully run, instead of returning
Task.CompletedTask immediately after terminationHandler.Handle.

Mechanism: a second ManualResetEventSlim `shutdownComplete` is Set() at
the very end of the finally block (after orchestrator.Stop + HTTP-client
disposal). The terminate callback waits on it for up to 60s after
terminationHandler.Handle returns. If the bound expires we log and return
rather than deadlock the ingest-dispatcher thread forever.

Effect: no fresh ingest call can happen between
terminationHandler.Handle returning and the process actually exiting,
which would otherwise give the backend another window to re-deliver
terminate_session. Combined with the PR #3a dedup this closes the
77×-storm loop from the root.

Non-server-action termination paths (decision-engine enrollment_complete,
max-lifetime, auth-failure, Ctrl+C) are unaffected — they already drive
shutdown from the main thread itself.

V2.Core: 538, DecisionCore: 108, all green.
2026-04-23 14:01:07 +02:00
Oliver Kieselbach 491c749688 v2 single-rail 5.3b: migrate EnrollmentTerminationHandler + agent_shutting_down event
Plan §5.3 + §6.2 — EnrollmentTerminationHandler now emits the six
peripheral lifecycle events (enrollment_summary_shown,
diagnostics_collecting/uploaded/upload_failed, reboot_triggered,
whiteglove_part1_complete) through InformationalEventPost instead of the
legacy Action<EnrollmentEvent> bypass.

New acknowledgement: `agent_shutting_down` fires with ImmediateUpload=true
just before CleanupService.ExecuteSelfDestruct. This gives the backend a
definitive "agent accepted termination" signal so it can stop re-queuing
terminate_session even when the cleanup script wins the race against the
final telemetry drain.

Program.cs: EnrollmentTerminationHandler construction moved into
orchestrator.Start's onIngressReady hook (alongside ServerActionDispatcher)
so the live lifecyclePost backs it. The Terminated-event subscription uses
a null-checking dispatch wrapper so the registration can sit before Start
returns without referencing the not-yet-constructed handler; by the time
the decision loop processes its first signal, the hook has run
synchronously and the handler is live.

Constants.EventTypes.AgentShuttingDown = "agent_shutting_down" added.

Tests: EnrollmentTerminationHandlerTests refactored — Rig now creates a
real InformationalEventPost backed by FakeSignalIngressSink; assertions
consult EmittedEventTypes / PayloadOf(eventType) helpers that read the
posted signal payloads. Three new tests cover
 - agent_shutting_down emitted on self-destruct path with correct payload
 - skipped when SelfDestructOnComplete=false
 - skipped on WhiteGlove Part-1 exit (no self-destruct)

V2.Core: 535 → 538, DecisionCore: 108, all green.
Architecture baseline unchanged — EnrollmentTerminationHandler now takes
InformationalEventPost, no structural reference to TelemetryEventEmitter.
2026-04-23 13:58:16 +02:00
Oliver Kieselbach c2c55a191e v2 single-rail 5.3a: migrate ServerActionDispatcher to Signal-Post + ActionId dedup
Plan §5.3 + §6.2 — ServerActionDispatcher now emits
server_action_received/executed/failed through InformationalEventPost
(single-rail pipe), not the legacy Action<EnrollmentEvent> bypass.

Adds dedup of at-least-once re-deliveries from the backend PendingActions
queue: composite key (Type|RuleId|QueuedAt) maps every re-delivery of the
same server-queued action to the same slot, squelching duplicates before
any telemetry or handler side effect fires. Fixes the 77× terminate_session
storm observed in the PR-motivating session without rejecting two genuinely
distinct admin-issued terminates.

Program.cs: ServerActionDispatcher construction moved inside
orchestrator.Start's onIngressReady hook so the live lifecyclePost backs it;
WireTelemetryServerResponse picks it up from the outer-scope local after
Start returns.

Tests: 8 new ServerActionDispatcherTests cover success / failure / unknown-
type emission plus the dedup matrix (same key twice, same-batch dup,
distinct QueuedAt, distinct RuleId, distinct Type with identical QueuedAt).

V2.Core test suite: 527 → 535, all green.
Architecture baseline unchanged — ServerActionDispatcher still holds no
structural dependency on TelemetryEventEmitter.
2026-04-23 13:52:53 +02:00
Oliver Kieselbach b02313b7ea v2 single-rail 5.2: migrate StartupEnvironmentProbes to Signal-Post (B7)
StartupEnvironmentProbes.RunAsync no longer takes a TelemetryEventEmitter
— it now routes device_location, timezone_auto_set, ntp_time_check, and
agent_trace (geo-failure) events through InformationalEventPost. The
existing Source labels ("Network") survive via the Emit(EnrollmentEvent)
overload's payload decomposition.

- Parameter rename: emitter → post throughout RunAsync / RunGeoAndTimezone /
  RunNtpCheck / SafeEmit. Bodies unchanged apart from the dispatched Emit.
- Unused using removed (Telemetry.Events namespace no longer referenced
  from this file — the probes live outside the emitter-internal zone).
- Program.cs passes lifecyclePost (the single-rail helper instance
  established in PR #1) to the background Task.Run invocation, which runs
  after orchestrator.Start has completed so the hook has already assigned
  the variable.
- Architecture baseline test (PR #0 §4.4) now expects 3 dependents
  instead of 4 — StartupEnvironmentProbes dropped out. Comment updated
  to mirror the migration inventory so future PRs see the correct
  removal sequence.

527 V2.Core tests + 108 DecisionCore tests still pass. No behavior
change on the wire: device_location / ntp / timezone_auto_set events
carry the same EventType / Source / DataJson as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:31:33 +02:00
Oliver Kieselbach 8b413d59a8 v2 single-rail 5.1c: migrate Program.cs lifecycle events to Signal-Post
Shifts every direct orchestrator.EventEmitter.Emit(...) in Program.cs
(B2-B6 in the plan inventory) onto InformationalEventPost. Fixes the
Parity Issue #1 ordering regression: agent_started now lands on the
signal log as Seq=1 because it is posted from inside the orchestrator's
onIngressReady hook, before any collector-generated signal reaches
the ingress.

Call sites migrated:
- agent_started, agent_version_check, agent_unrestricted_mode_changed:
  emitted inside the new orchestrator.Start(ingress => { ... }) hook,
  in the canonical boot order.
- enrollment_failed (max_lifetime watchdog), agent_shutdown
  (auth_failure watchdog): emitted from their event-handler lambdas
  via a lifecyclePost variable captured by reference — the handlers
  are registered before Start but only fire later, by which time the
  callback has assigned the live helper.

Three private helpers (EmitAgentStartedEvent, EmitVersionCheckEventIfAny,
EmitUnrestrictedModeAuditIfChanged) switched from an EnrollmentOrchestrator
parameter to an InformationalEventPost parameter; bodies unchanged apart
from the final Emit call.

Side fixes:
- enrollment_failed (max_lifetime) no longer declares Phase=Complete
  (plan §1.4 phase invariant — only phase-declaration events may set
  a non-Unknown phase). The UI bucketing behavior is unchanged; the
  legacy setting produced a phantom phase in the timeline.
- Null-guard on the lifecyclePost variable: if a watchdog fires before
  Start's hook has assigned the helper, emission is skipped with a
  warning rather than NRE. In practice impossible (handlers only fire
  after Terminated / ThresholdExceeded which come later), but defensive.

Left for later PRs (plan §2 inventory):
- line ~575 ServerActionDispatcher emitEvent callback → PR #3 (B28).
- orchestrator.EventEmitter property itself → PR #10 enforcement step.

Tests: 527 V2.Core + 108 DecisionCore pass. Full solution builds clean.
Functional impact on a real session: the V2 parity audit's Parity
Issue #1 (agent_started = Seq=13) is structurally fixed — verification
will be via an E2E session after this lands on a ring device.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:26:29 +02:00
Oliver Kieselbach 2e82dd2ba6 fix folder structure v2 agent 2026-04-22 18:12:52 +02:00
Oliver Kieselbach f149eef3fe feat(agent-v2): wire --replay-log-dir CLI flag to ImeLogTracker SimulationMode
V2 had the complete replay infrastructure in place but never wired up:
ImeLogTracker.SimulationMode / SpeedFactor properties (ImeLogTracker.cs:66-67)
and the ApplySimulationDelay compression loop (ImeLogTracker.Handlers.cs:529-546)
were fully implemented, and AgentConfiguration had ReplayLogDir + ReplaySpeedFactor
fields (L.128, L.134), but nothing set them from the CLI and nothing read them in
the factory.

This commit adds:
- CLI args --replay-log-dir <path> and --replay-speed-factor <n> in
  Program.BuildAgentConfiguration (invariant-culture parse, zero/malformed rejected).
- DefaultComponentFactory.ImeLogHost ctor accepts a simulation mode + speed factor
  and applies them to the inner tracker when ReplayLogDir is non-empty.
- PrintUsage documents the flags under a new "Dev / Test" section.

For the deterministic signal-level replay (V2 ReplayHarness + signal-log.jsonl
fixtures) no wiring is needed - that is already the primary V2 test path. The IME-
log replay activated here is the V1 compatibility dev-mode for support cases where
only raw IME logs are available.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:06:58 +02:00
Oliver Kieselbach 5b7f424d19 refactor(agent-v2): route whiteglove_resumed / system_reboot_detected / admin preemption through reducer effects
V2 parity cleanup - the three lifecycle events were emitted from Program.cs
directly while the corresponding reducer handlers (HandleWhiteGlovePart1To2Bridge,
HandleSystemRebootObservedV1) returned Array.Empty<DecisionEffect>(). Admin
preemption synthesised an EnrollmentTerminatedEventArgs and invoked
terminationHandler.Handle directly, bypassing the reducer entirely.

This commit unifies all three flows: the reducer is the single state owner, the
telemetry event appears as an EmitEventTimelineEntry effect of the state
transition, and the orchestrator's Terminated event fires automatically when
stage goes terminal - no more synthetic bypass.

- B1: HandleWhiteGlovePart1To2Bridge emits whiteglove_resumed timeline entry.
- B2: HandleSystemRebootObservedV1 emits system_reboot_detected with payload
  keys previousExitType/lastBootUtc.
- B3: new DecisionSignalKind.AdminPreemptionDetected + new SessionOutcome.AdminPreempted.
  Reducer HandleAdminPreemptionDetectedV1 transitions to Completed/Failed based on
  adminOutcome payload, emits enrollment_complete/_failed timeline entry with
  adminAction+source data keys. Program.cs posts the signal; terminationHandler
  still subscribes to orchestrator.Terminated (no direct Handle calls on the
  startup-preemption path).

Fixtures: tests/fixtures/signal-kinds/{system-reboot-observed,admin-preemption-detected}-v1.json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:00:25 +02:00
Oliver Kieselbach a63512d139 feat(agent-v2): post SessionStarted signal after register-session (V2 reducer anchor)
V2 parity lack: EnrollmentOrchestrator.Start never synthesised a SessionStarted
signal, so HandleSessionStartedV1 (DecisionEngine.Shared.cs:36) was unreachable in
production. DecisionState.Stage stayed at Unknown for the entire session lifetime
while raw signals (ESP / Hello / etc.) landed on an uninitialised state. The
reducer tolerated this silently, but it left us no session-anchor timestamp and
no early stage-classification hook.

Program.cs now posts SessionStarted directly after register-session success, with
the tenant-registered metadata (EnrollmentType, IsHybridJoin, ValidatedBy, ...)
as signal payload. Skipped on WhiteGlove Part-2 resume (orchestrator posts
SessionRecovered instead) and on admin preemption (subsequent AdminPreemption
signal drives straight to terminal; see next commit).

Follow-up: the reducer handler currently only bumps StepIndex - a later patch
should have it consume the enrollmentType/isHybridJoin payload for flow routing.
Logged in tasks/todo.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:52:49 +02:00
Oliver Kieselbach bcd3c23168 feat(agent-v2): register-session handshake + periodic-collector idle mgmt + network-change + DO handler-chain fix
Closes the remaining critical V1 parity gaps uncovered by the audit. Without these,
the V2 agent would accept events into /api/agent/telemetry but the backend Sessions
table would never get a row (so every UpdateSessionStatusAsync / IncrementSession
silently no-ops), PerformanceCollector + AgentSelfMetricsCollector would run for
the entire session lifetime filling the spool, network transitions would be
invisible, and the DO collector would overwrite newer handlers on shutdown.

PR #51 — Session registration (V1 MonitoringService.RegisterSessionAsync)
- New SessionRegistrationHelper with 5-retry + V1 exponential backoff (2s/4s/8s/16s).
  Auth failures (401/403) short-circuit via AuthFailureTracker.RecordFailure
  ("agent/register-session") — which already fires first-failure distress with
  AuthCertificateRejected / DeviceNotRegistered. Other exceptions on the last
  attempt trigger EmergencyReporter with AgentErrorType.RegisterSessionFailed.
- New EnrollmentRegistryDetector — V1-parity registry probes for
  CloudAssignedDomainJoinMethod (hybrid join) and CloudAssignedDeviceRegistration /
  CloudAssignedEspEnabled (v1 vs v2 / WDP) so SessionRegistration carries the same
  EnrollmentType / IsHybridJoin values V1 sent.
- Program.cs calls RegisterWithRetryAsync after BackendTelemetryUploader is ready
  and BEFORE orchestrator.Start. On non-Succeeded outcome, the agent exits
  cleanly (exit code 6 for auth, 7 for other) instead of spinning up collectors
  for an unregistered session — V1 parity:
  "=== SESSION REGISTRATION FAILED — collectors will NOT start ==="
- BackendApiClient.RegisterSessionAsync is now `virtual` so test doubles can
  intercept the HTTP call.
- Admin-override preemption: when the register response carries AdminAction
  ("Succeeded"/"Failed") we emit the matching enrollment_complete/_failed event
  and synthesise a Terminated so the termination handler runs cleanup
  immediately — V1 parity with MonitoringService.cs:388-413.

Periodic-collector idle lifecycle (V1 PeriodicCollectorManager)
- New PeriodicCollectorLifecycleHost owns PerformanceCollector +
  AgentSelfMetricsCollector under a single CollectorIdleTimeoutMinutes window
  (default 15). Stops both after N minutes of no real enrollment activity and
  restarts them on the next real event. Without this the V2 agent kept polling
  perf counters every 60s for the agent's full lifetime on dormant sessions.
- Activity observation: the host wraps the onEnrollmentEvent sink passed to its
  internal collectors so events with "performance_snapshot" /
  "agent_metrics_snapshot" / "stall_probe_*" / "session_stalled" types do NOT
  count as activity — only real events reset the idle timer.
- Emits performance_collector_stopped / agent_metrics_collector_stopped on
  idle-stop (V1 parity event types + data schema).

Network-change wiring
- NetworkChangeDetector existed in V2 but was never instantiated. New
  NetworkChangeHost wires it into DefaultComponentFactory so WiFi SSID /
  default-route / MDM-endpoint-reachability transitions are emitted as
  network_change events — V1 parity with
  CollectorCoordinator.StartOptionalCollectors:375-382.

DeliveryOptimizationHost Stop() bug
- V1 comparison `ReferenceEquals(x.OnAppStateChanged, x.OnAppStateChanged)` was
  a tautology (always true) — silently overwrote any newer handler that had
  taken over the slot. Fixed by capturing the chained handler reference and
  comparing against it instead.

Tests
- 481/481 V2 tests green (+5 new SessionRegistrationHelper tests covering the
  5-retry happy path, retry-then-success, auth short-circuit, exhaustion after 5
  exceptions, and registration payload correctness).
- 708/708 Backend tests untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 13:26:26 +02:00
Oliver Kieselbach fc778b4154 feat(agent-v2): V1-parity remediation — lifecycle events, auth/binary watchdogs, remote config
Closes the main V1/V2 divergences identified by the parity audit (tasks/plan_v2_parity.md).

PR #47 — RemoteConfigMerger V1-parity
- Tenant remote wins unconditionally for every 1:1-mapped knob (V1 parity with
  MonitoringService.ApplyRuntimeSettingsFromRemoteConfig). CLI flags only seed the
  initial AgentConfiguration.
- Map EnableImeMatchLog → ImeMatchLogPath (Constants.ImeMatchLogPath default), with a
  safeguard for --ime-match-log dev overrides.
- UploadIntervalSeconds, NtpServer, DiagnosticsUploadMode, DiagnosticsUploadEnabled,
  MaxAuthFailures, AuthFailureTimeoutMinutes all flow through.
- Merge returns RemoteConfigMergeResult so the caller can audit UnrestrictedMode and
  LogLevel transitions. Program.cs emits `agent_unrestricted_mode_changed` after
  orchestrator.Start when the guardrail flips (V1 AuditUnrestrictedModeChange).

PR #48 — BinaryIntegrityVerifier runtime self-update
- Use Process.GetCurrentProcess().MainModule.FileName (V1 parity; Assembly.Location is
  unreliable under ngen / shadow copy).
- Match → Info log; mismatch → Error log with V1-format message.
- New `runtimeSelfUpdateTrigger` delegate fired with an Interlocked single-shot guard.
  Program.cs wires it to SelfUpdater.CheckAndApplyUpdateAsync(forceUpdate=true,
  triggerReason="runtime_hash_mismatch") so the agent auto-heals on SHA drift.

PR #49 — AuthFailureTracker V1-parity
- Tracker is now the single dispatch point for auth-failure distress. First 401/403
  → AuthCertificateRejected / DeviceNotRegistered distress (V1 HandleAuthFailure).
  Duplicate ConfigFetchDenied send removed from RemoteConfigService.
- Threshold log uses the V1 === AGENT SHUTDOWN === banner.
- Program.cs emits `agent_shutdown` (reason=auth_failure) with the full V1 data
  schema before tripping the shutdown signal.

PR #50 — Lifecycle events + SaveWhiteGloveComplete + standalone reboot
- SessionIdPersistence gains SaveWhiteGloveComplete / ClearWhiteGloveComplete so
  WhiteGlove Part-2 resume detection finally works (IsWhiteGloveResume was
  unreachable before because no path wrote the marker).
- EnrollmentTerminationHandler emits the V1 lifecycle events:
  `enrollment_summary_shown`, `diagnostics_collecting`, `diagnostics_uploaded` /
  `diagnostics_upload_failed`, `whiteglove_part1_complete`, `reboot_triggered`.
- Standalone-reboot flow: when SelfDestructOnComplete=false and RebootOnComplete=true
  the handler drains the spool briefly and invokes shutdown.exe /r /t <delay>.
- Program.cs emits `agent_started` (with 11+ data fields), `whiteglove_resumed`, and
  `system_reboot_detected` (previousExitType=reboot_kill) after orchestrator.Start.

PR #52 — Collector parity
- EnrollmentOrchestrator's max-lifetime watchdog now emits a V1-format
  `enrollment_failed` event (failureType=agent_timeout, failureSource=max_lifetime_timer,
  Phase=Complete) in addition to raising Terminated(MaxLifetimeExceeded).
- GatherRuleExecutorHost propagates AgentConfiguration.UnrestrictedMode into the
  runtime executor so gather rules can bypass AllowList checks when the tenant
  admin elevates the guardrail (V1 CollectorCoordinator.StartGatherRuleExecutor).

Tests
- 476/476 V2 tests pass (was 475 — one timing-sensitive mismatch test bumped to 10s).
- 507/507 V1 Agent tests + 708/708 Backend tests unaffected.
- New tests cover: Remote-wins-always, UnrestrictedMode audit, EnableImeMatchLog,
  runtime self-update trigger single-shot, distress type per status code,
  diagnostics event emission, standalone reboot, WhiteGlove marker write.

Deferred
- PR #51 (register-session 5-retry) is out of scope — V2 does not call
  /api/agent/register-session in the main flow today. Porting that is a feature
  addition, not a V1 parity fix.
- PR #53 (emergency-break, RunStartupAnalyzers) already landed in main via earlier
  PRs #44–#46; verified against plan §3.11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 12:10:05 +02:00
Oliver Kieselbach 6a9203905d feat(agent): implement MaxAuthFailures watchdog + post-config binary integrity check
Closes three DTO-only fields on AgentConfigResponse that have been dead since
the config schema was defined — neither V1 nor V2 ever consumed them. V2 now
honours them end-to-end.

1. MaxAuthFailures + AuthFailureTimeoutMinutes — AuthFailureTracker

New Security/AuthFailureTracker: thread-safe counter + time-window observer
that fires a single-shot ThresholdExceeded event when either ceiling is
crossed. Wired into:
- RemoteConfigService.FetchConfigAsync: RecordSuccess on 2xx, RecordFailure
  on BackendAuthException (401/403 from /api/agent/config).
- BackendTelemetryUploader.MapResponseAsync: RecordSuccess on 2xx,
  RecordFailure on 401/403 (moved from static to instance method).
- Program.cs: tracker constructed before FetchConfig with CLI defaults;
  UpdateLimits called after RemoteConfigMerger.Merge so tenant policy wins;
  ThresholdExceeded handler sets the main shutdown signal so the agent exits
  cleanly instead of retrying a permanently-revoked certificate forever.

Defaults: MaxAuthFailures=5, AuthFailureTimeoutMinutes=0 (time-window
disabled). Both honour 0 = "feature off", and UpdateLimits can tighten or
relax the ceilings mid-run when the tenant admin changes policy.

2. LatestAgentExeSha256 — BinaryIntegrityVerifier

New Security/BinaryIntegrityVerifier: pure-static helper that computes
SHA-256 over the running agent EXE and compares against the backend-
advertised hash. Case-insensitive hex compare. Three outcomes:
- Skipped: no hash provided / local path not resolvable → no-op.
- Match: silent success path (debug-log only).
- Mismatch: emits AgentErrorType.IntegrityCheckFailed via EmergencyReporter
  (the enum slot was already reserved — just never used). Does NOT terminate
  or auto-update; SelfUpdater's own hash-based trigger handles the "wrong
  EXE" case at next startup, and auto-terminating the agent on every
  mismatch would risk boot loops during rollouts.

Wired in Program.cs immediately after RemoteConfigMerger.Merge.

Context

These are three of the four "DTO-only" fields I flagged in PR #45's body.
User feedback: do not leave dead config schema entries. V1 never implemented
any of these either (V1 has a ZIP-hash check in SelfUpdater via
LatestAgentSha256, but no EXE-hash check and no auth-failure shutdown
threshold) — so this is net-new functionality that makes V2 strictly
better than V1 in these three dimensions, not a regression closure.

Test coverage

- AuthFailureTrackerTests (12 tests): count-based threshold, time-window
  threshold, RecordSuccess resets both, single-shot idempotence,
  handler-exception isolation, disabled-limits never fire, UpdateLimits
  retroactively tightens or relaxes, thread-safety under 8 concurrent
  callers, ctor null-guards.
- BinaryIntegrityVerifierTests (6 tests): skipped on null/empty/missing
  file, match path, case-insensitive compare, mismatch path, and a
  known-vector test against SHA-256("abc") to pin the hex formatter.

464 / 464 V2.Core tests green (vs. 446 before).

Not mapped / remaining dead-config

EnableImeMatchLog stays DTO-only — it duplicates the CLI flag
--ime-match-log with no semantic overlap to a backend switch, and mapping
it would just overwrite a dev-only debug toggle. Left as documented
non-feature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:19:39 +02:00
Oliver Kieselbach a46ecb7ebd fix(agent): wire remote config onto AgentConfiguration + reorder Transport subscription
Two V2 agent bugs surfaced in the VM smoke-test after the first-run ghost-detect
hotfix (PR #44) let the agent reach the backend fetch phase:

1. INIT-ORDER CRASH — Program.WireTelemetryServerResponse touched
   orchestrator.Transport before orchestrator.Start() was called, and the
   Transport getter throws InvalidOperationException pre-Start because
   _transport is only constructed inside Start() at EnrollmentOrchestrator.cs:311.
   Every VM crashed at this exact line. Fix: move the wire call to immediately
   after orchestrator.Start() inside the same try block. No behavioural loss —
   the first ServerResponseReceived cannot fire until a telemetry upload
   completes, which happens after Start().

2. REMOTE-CONFIG WIRING GAP — RemoteConfigService fetched AgentConfigResponse
   from the backend but only Collectors, ImeLogPatterns, GatherRules,
   WhiteGloveSealingPatternIds and LatestAgentSha256 were consumed downstream.
   ~20 tenant-controlled fields were bound to AgentConfiguration defaults from
   CLI / bootstrap-config and never got projected from the remote DTO —
   SelfDestructOnComplete, KeepLogFile, RebootOnComplete, NtpServer,
   EnableTimezoneAutoSet, EnableGeoLocation, DiagnosticsUploadMode,
   DiagnosticsLogPaths, DiagnosticsUploadEnabled, ShowEnrollmentSummary +
   EnrollmentSummary* (4 fields), LogLevel, AgentMaxLifetimeMinutes,
   HelloWaitTimeoutSeconds, UploadIntervalSeconds, MaxBatchSize,
   SendTraceEvents, UnrestrictedMode, RebootDelaySeconds.

   Every consumer site (CleanupService, SummaryDialogLauncher,
   StartupEnvironmentProbes, DiagnosticsPackageService,
   EnrollmentTerminationHandler, AgentLogger, EnrollmentOrchestrator,
   GatherRuleGuards) reads agentConfig.XXX — so tenant admin settings were
   effectively ignored. A tenant with SelfDestructOnComplete=false still saw
   its agent wipe ProgramData on enrollment complete, because the CLI-default
   !noCleanup=true always won.

   Fix: new RemoteConfigMerger.Merge(agentConfig, remoteConfig, args) pure
   static helper, called immediately after FetchConfigAsync. Projects all
   ~20 tenant-controlled fields onto AgentConfiguration. CLI flags remain
   authoritative: flags explicitly passed (--no-cleanup, --keep-logfile,
   --reboot-on-complete, --disable-geolocation, --log-level) skip the remote
   overwrite so local dev runs keep their ad-hoc overrides.

   Partial-remote-config tolerance: empty strings, null lists and non-positive
   ints keep the AgentConfiguration default. Non-V1-parity fields
   (MaxAuthFailures, AuthFailureTimeoutMinutes, LatestAgentExeSha256) are
   deliberately skipped — they are DTO-only in V1 too and have no consumer in
   either version, no regression.

Test coverage added:
- ProgramGuardsTests: Transport pre-Start now explicitly asserted to throw
  (regression guard); Transport_is_available_after_start confirms the post-
  Start happy path.
- RemoteConfigMergerTests (20 tests): happy path (all fields), 5 CLI override
  tests (no-cleanup, keep-logfile, reboot, disable-geolocation, log-level),
  log-level case-insensitivity, log-level parse failure preserves default,
  null remote-config + null args + null nested Collectors tolerated, empty-
  string NtpServer + null DiagnosticsUploadMode keep defaults, null
  DiagnosticsLogPaths becomes empty list (not null), non-positive int clamping
  (UploadIntervalSeconds, MaxBatchSize, HelloWaitTimeoutSeconds), zero
  AgentMaxLifetimeMinutes allowed (explicit "disabled"), null-agent-config
  argument guard, and a regression test for the exact VM scenario
  (tenant SelfDestructOnComplete=false + agent with CLI-default true).

446 / 446 V2.Core tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:02:21 +02:00
Oliver Kieselbach bf090c436e fix(agent): create SessionId before startup guards to prevent first-run ghost-detect
The V2 agent was deterministically self-destructing on every first run after --install on a fresh VM. CheckEnrollmentCompleteMarker reads HKLM\SOFTWARE\AutopilotMonitor\Deployed (written by --install) and treats "marker present + session.id absent" as a ghost restart, triggering ExecuteSelfDestruct and wiping %ProgramData%\AutopilotMonitor before FetchConfigAsync can run. Remote config's SelfDestructOnComplete=false was never honored because the config fetch never happened.

Legacy V1 agent creates the session in LoadConfiguration (Program.Configuration.cs:131) before the guard runs (Program.cs:169). V2 reversed this ordering with a comment claiming it prevents "dead session resurrection" — but the guards key on file-system state (SessionPersistence.SessionExists()), not the runtime-loaded session object, so the reordering was functionally unnecessary and introduced this regression.

Fix: move SessionIdPersistence.GetOrCreate + --new-session handling back before the guards, restoring Legacy boot order. Ghost-restart detection still triggers in its intended case (session.id file deleted by incomplete self-destruct, but Scheduled Task + registry marker survive).

Test coverage: adds 3 missing tests for CheckEnrollmentCompleteMarker in ProgramGuardsTests.cs (first-run with session, marker without selfdestruct, marker with selfdestruct retry). The M4.6.alpha guard suite had no coverage for this path, which is how the regression shipped.

425 V2.Core tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:10:58 +02:00
Oliver Kieselbach 51e9966e0a fix(agent): forward AdminAction outcome to termination handler (Codex Finding 2)
Portal Mark-Succeeded traffic was silently landing as Failed locally. The
M4.6.ε ServerAction synthesis in Program.cs already copied the adminOutcome
value into the terminate_session ServerAction's Params bag, but the
terminate_session callback that fed the TerminationHandler hard-coded
EnrollmentTerminationOutcome.Failed regardless of what adminOutcome said.

Downstream consequences of the hard-coded Failed:
- FinalStatusBuilder.MapOutcome rendered "Failed" in the SummaryDialog
  even when the operator had explicitly marked the session Succeeded.
- EnrollmentTerminationHandler's DiagnosticsUploadMode.OnFailure gate
  fired a diagnostics package for every admin-override, regardless of
  actual outcome.

Fix:

- New Program.ServerActionSynthesis.cs partial (public static class
  Program, matches existing declaration) carrying a pure static helper
  MapAdminOutcome(IReadOnlyDictionary<string,string>? params) →
  EnrollmentTerminationOutcome. Case-insensitive "Succeeded" → Succeeded;
  missing/null/empty/any-other-value → Failed (default-failure-safe for
  kill-signal-driven synthesis that never sets adminOutcome).
- The terminate_session callback in Program.cs now calls the helper and
  threads the mapped outcome into EnrollmentTerminatedEventArgs. Log
  message extended to include the mapped outcome so ops can correlate
  the server-side override with the local termination result.

Reason stays DecisionTerminalStage. A distinct AdminAction reason would
need a DecisionCore enum extension — out of scope for this fix. What
Codex flagged concretely (Mark-Succeeded → visible as Failed) is now
addressed.

Tests (+10 in V2.Core.Tests): AdminActionOutcomeMappingTests
- "Succeeded" → Succeeded
- Case-insensitive variants (succeeded / SUCCEEDED / mixed case)
- "Failed" → Failed
- Unknown values (TimedOut / weird-future / empty) → Failed
- Missing adminOutcome param (kill-signal case) → Failed
- Null params dict → Failed

Accesses the helper through InternalsVisibleTo("AutopilotMonitor.Agent.V2.Core.Tests")
that was already set on the V2 agent assembly.

Suites: V2.Core 416 green (prev 406 + 10 new). Full solution build green.

This closes the last of the three Codex findings from the PR-43 review.
Remaining review comment on cross-layer test coverage is already
satisfied by the TelemetryWireRoundTripTests added in commit b6ea13f1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 14:36:10 +02:00
Oliver Kieselbach 9615b4c902 feat(agent-v2): M4.6.ε backend kill-switch + device-block + admin-action plumbing
Closes the last peripheral-parity gap flagged by the user: Legacy's admin
kill-switch / device-block / admin-mark-failed signals flow through the
batch-upload response and drive the same termination / pause paths. Without
this the V2 agent had no way to be remotely stopped or quarantined — it
could only be self-destructed by its own decision engine.

UploadResult extended
- New optional fields on success: DeviceBlocked, UnblockAt, DeviceKillSignal,
  AdminAction, Actions. Factory OkWithSignals(...) for success-with-control.
- Legacy-parity: same semantic as IngestEventsResponse's quarantine / kill /
  admin surface, just riding on the new /api/telemetry/batch response body.

BackendTelemetryUploader.TryReadControlSignalsAsync
- Parses the 2xx response body as JSON (defensive — empty body or malformed
  JSON degrades cleanly to plain Ok). Accepts both camelCase and PascalCase
  field names so an M5 backend can choose either without breaking V2
  agents in the wild.
- Recognises deviceBlocked (bool) + unblockAt (ISO-8601 UTC) + deviceKillSignal
  (bool) + adminAction (string) + actions (ServerAction[]).

TelemetryUploadOrchestrator
- New public event ServerResponseReceived fires on successful uploads that
  carry any control signal. Handlers run after the cursor has moved, so a
  handler exception does not abort or re-run the batch.
- New public IsPaused property — DeviceBlocked sets an internal
  _pausedUntilUtc; subsequent DrainAllAsync calls short-circuit with a
  "device blocked" LastErrorReason until UnblockAt elapses (or indefinitely
  when UnblockAt is null). UnblockAt=null maps to DateTime.MaxValue so the
  comparison stays deterministic without a null branch per tick.

EnrollmentOrchestrator.Transport property
- Exposes the live TelemetryUploadOrchestrator so Program.cs can subscribe
  to ServerResponseReceived without leaking transport internals through
  public orchestrator API.

Program.cs WireTelemetryServerResponse
- Routes ServerResponseReceived through the existing ServerActionDispatcher:
  • DeviceKillSignal → synthesises terminate_session with forceSelfDestruct=
    true + gracePeriodSeconds=0 (kill overrides local SelfDestructOnComplete
    config — Legacy parity, the operator's decision wins)
  • AdminAction → synthesises soft terminate_session respecting local config
  • Actions[] → dispatched directly
  • DeviceBlocked → logged (the transport already paused itself)
- onTerminateRequested extended: reads params["forceSelfDestruct"] and
  flips agentConfig.SelfDestructOnComplete at runtime when set, so the
  cleanup service actually runs.

Tests (+15 in V2.Core.Tests → 384)
- BackendTelemetryUploaderControlSignalsTests: empty body, malformed JSON,
  DeviceBlocked + UnblockAt, PascalCase accepted, DeviceKillSignal,
  AdminAction, Actions array round-trip, non-2xx does not parse signals,
  body with only unknown fields.
- TelemetryUploadOrchestratorServerResponseTests: plain Ok is silent,
  kill-signal raises event once, admin-action forwards field, actions
  forward list, DeviceBlocked pauses → unblock lifts pause → next drain
  uploads the queued item, handler exceptions do not break cursor.

All 4 suites green: 88 DecisionCore + 384 V2.Core + 507 Agent.Core + 557 Functions
= 1536 tests green.

With this commit M4.6 closes all known Legacy-vs-V2 peripheral-parity gaps:
agent_kill (DeviceKillSignal → synthesized terminate_session), agent_blocked
(DeviceBlocked → paused drain), portal Mark-Failed/Mark-Succeeded (AdminAction
→ synthesized terminate_session), plus the generic server-queued Actions[].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 01:56:44 +02:00
Oliver Kieselbach 50c3bf0516 feat(agent-v2): M4.6.δ diagnostic modes + analyzer manager + gather-rules runtime
Closes the final M4.6 batch of peripheral-parity gaps. After this commit the
V2 agent has full feature parity with the Legacy agent around every
capability NOT inside the DecisionEngine itself.

Diagnostic CLI modes (Program.*.cs partials)
- --run-gather-rules: ported 1:1 from Legacy. Resolves TenantId via V2
  TenantIdResolver, creates a fresh ephemeral SessionId, registers it,
  fetches remote config, runs GatherRuleExecutor against all Trigger=startup
  rules (120s cap), uploads the collected events in-memory via
  BackendApiClient.IngestEventsAsync. Never touches the live agent's spool.
- --run-ime-matching <path> [--patterns <file>]: ported 1:1 from Legacy.
  Single-file or directory scan through the same IME log-file patterns the
  live tracker watches, loads patterns from GitHub (fallback: cached
  remote-config.json), compiles regexes with {GUID} substitution + 1s
  timeout, scans + writes ime_pattern_matching.log with per-pattern counts.
- Both modes fork early in Main before orchestrator startup — no side
  effects on the Scheduled Task, no process conflict with the live agent.

AgentAnalyzerManager (V2.Core/Runtime/)
- Ported from Legacy AnalyzerManager. Reads AnalyzerConfiguration from
  AgentConfigResponse.Analyzers directly (no RemoteConfigService
  indirection — the cache is already resolved at call site).
- Initialize() registers LocalAdminAnalyzer / SoftwareInventoryAnalyzer /
  IntegrityBypassAnalyzer gated by Enable* flags; idempotent across
  repeated calls.
- RunStartup() fires on a background task, swallows per-analyzer
  exceptions. Called from Program.cs right after orchestrator.Start so
  analyzers emit through the live TelemetryEventEmitter.
- RunShutdown(int? whiteGlovePart) runs synchronously before the
  diagnostics upload, routing SoftwareInventoryAnalyzer to its overload
  that distinguishes WhiteGlove part 1 baseline from part 2 delta.

EnrollmentTerminationHandler integration
- New optional `analyzerManager` ctor param. RunShutdownAnalyzers runs
  FIRST in the termination sequence (before FinalStatus + diagnostics
  upload) so the analyzers' delta events make it into the final
  diagnostics ZIP. Passes whiteGlovePart=1 on WhiteGloveSealed terminal.

GatherRuleExecutorHost (DefaultComponentFactory nested)
- Runtime gather-rules execution via a new ICollectorHost that wraps
  GatherRuleExecutor. Only registered when remoteConfig.GatherRules has
  entries. Picks up ImeLogPathOverride for file-collector paths.
- Legacy parity: gather rules run as part of the active enrollment session
  alongside the collector hosts, not only via the standalone --run-
  gather-rules diagnostic mode.

Program.cs mode fork (Main)
- --install / --run-gather-rules / --run-ime-matching handled in order
  after --help / --version, before multi-instance guard and self-update
  (those only apply to the normal monitoring mode).

Tests (+8 in V2.Core.Tests → 369)
- AgentAnalyzerManagerTests: toggle-matrix, null-config default behaviour,
  idempotent Initialize, argument-null guards, fake-analyzer injection for
  RunShutdown success/exception/empty-list paths. The three real analyzers
  already have their own parity-tested implementations in V2.Core shared
  with Legacy.

All 4 suites green: 88 DecisionCore + 369 V2.Core + 507 Agent.Core + 557 Functions
= 1521 tests green.

M4.6 is now complete. The V2 agent matches Legacy feature parity for:
install mode, self-update, multi-instance guard, clean-exit marker,
previous-exit classification, enrollment-complete ghost-restart detection,
session-age emergency break, AgentMaxLifetime watchdog, BootstrapConfigCleanup,
terminal-stage detection with typed Terminated event, FinalStatus.json +
SummaryDialog launch via UserSessionProcessLauncher, CleanupService self-
destruct (with WhiteGlove-part-1 pause), DiagnosticsPackageService upload,
ServerActionDispatcher (terminate_session / request_diagnostics / rotate_
config callbacks ready for M5 response-path plumbing), Geo/NTP/Timezone
probes, Emergency + Distress reporters, DeliveryOptimizationCollector,
3 analyzers, --run-gather-rules + --run-ime-matching diagnostic modes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 01:10:56 +02:00
Oliver Kieselbach 7e199019ce feat(agent-v2): M4.6.γ diagnostic probes, reporters, DO collector wired
Closes the third batch of peripheral-parity gaps — Legacy's fire-and-forget
startup probes, the two cert-less + authenticated error-reporting channels,
and Delivery-Optimization telemetry all run in V2.

StartupEnvironmentProbes (V2.Core/Runtime/)
- New helper that replicates the Legacy MonitoringService startup diagnostic
  trio: device_location / timezone_auto_set / ntp_time_check.
- Sequential RunAsync(config, logger, emitter): geo lookup → (optional)
  tzutil auto-set → NTP offset check. Each step is best-effort and feeds
  through the orchestrator's TelemetryEventEmitter.
- Geo probe respects EnableGeoLocation; timezone probe respects
  EnableTimezoneAutoSet AND requires an IANA tz from geo. NTP probe always
  runs (diagnostic-only; Warning on offset >60s or failure).
- Program.cs wires this via Task.Run right after orchestrator.Start so a
  slow network never delays the critical path.

EmergencyReporter + DistressReporter (live in Program.cs)
- Previously instantiated nowhere — now built once at startup with the
  shared hardware info. RemoteConfigService ctor takes both so config-fetch
  failures flow to the right channel (auth → distress, network → emergency).
- Initial AuthCertificateMissing distress fires when UseClientCertAuth=true
  but BackendApiClient.ClientCertificate is null — surfaces pre-MDM-
  enrollment dead-ends via the cert-less channel (Legacy parity).
- BackendTelemetryUploader uses the same hardware info (dedup'd the second
  HardwareInfo.GetHardwareInfo call that was duplicated since M4.5.b).

DeliveryOptimizationHost (DefaultComponentFactory nested)
- New ICollectorHost wrapping DeliveryOptimizationCollector with:
  • getPackageStates → ImeLogHost.PackageStates (existing accessor)
  • onDoTelemetryReceived → ImeLogHost.Tracker.OnDoTelemetryReceived pass-
    through (new internal accessor; keeps encapsulation)
  • OnAppStateChanged chained into ImeLogTracker so WakeUp() fires when
    any app enters Downloading or Installing — before any fire we preserve
    the existing handler (the adapter's signal-ingest path must keep
    running unchanged).
- Gated by CollectorConfiguration.EnableDeliveryOptimizationCollector.

Tests (+8 in V2.Core.Tests → 353 → 361)
- StartupEnvironmentProbesTests: event-shape for all three probes + geo
  success/failure/null attempt, timezone success/failure severity, NTP
  small/large offset + failure-with-detail.

All 4 suites green: 88 DecisionCore + 361 V2.Core + 507 Agent.Core + 557 Functions
= 1513 tests green.

Note: the "RegistryWatcher standalone admin-action-detector" from the
original M4.6 gap list was a Subagent fabrication — verified no such usage
exists in either Legacy or V2 (the class is only consumed transitively
inside AadJoinWatcher + ProvisioningStatusTracker, both already wired via
DefaultComponentFactory since M4.5.b). Removed from the M4.6.γ scope.

Remaining: M4.6.δ (--run-gather-rules + --run-ime-matching modes,
AnalyzerManager + 3 Analyzers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 00:50:15 +02:00
Oliver Kieselbach 17063be600 feat(agent-v2): M4.6.β terminal dispatch — FinalStatus, SummaryDialog, cleanup, server actions
Closes the second batch of peripheral-parity gaps so the V2 agent actually
terminates like Legacy does: write FinalStatus.json, launch the WPF summary
dialog in the user session, upload diagnostics (respecting mode/outcome),
write the enrollment-complete marker, run self-destruct, and signal shutdown.
All of this is driven off a single typed kernel event so nothing leaks back
into the DecisionEngine.

Terminal-stage detection (kernel hook)
- DecisionCore/State/SessionStageExtensions.IsTerminal() + IsPauseBeforePart2()
  — Completed, Failed, WhiteGloveSealed, WhiteGloveCompletedPart2 are terminal;
  WhiteGloveSealed is the "pause before Part 2" special case (session resumes
  on reboot, cleanup MUST be suppressed).
- DecisionStepProcessor ctor gains Action<DecisionState>? onTerminalStageReached;
  fires at most once per processor lifetime, swallows handler exceptions,
  suppressed when initial state was already terminal (crash-recovery race).
- EnrollmentOrchestrator.OnDecisionTerminalStage translates the hook into the
  public Terminated event with Reason=DecisionTerminalStage + Outcome mapped
  from stage (Completed/Part2/Sealed→Succeeded, Failed→Failed, else TimedOut).
  MaxLifetime watchdog is cancelled on the real terminal so the two paths can't
  race each other.

Peripheral termination (V2.Core/Termination/*)
- FinalStatus DTO — wire-format mirror of SummaryDialog's Models/FinalStatus
  (same JSON property names). Lives in V2.Core because the dialog is a
  separate net48 WPF exe we can't ProjectReference.
- FinalStatusBuilder — pure function: DecisionState + AppPackageStateList +
  TerminatedArgs + agent start time → FinalStatus. Maps outcome string
  ("succeeded" | "failed" | "timed_out" | "whiteglove_part1" | "whiteglove_part2"),
  derives signalsSeen from DecisionState facts, counts apps by AppTargeted
  phase (Device/User), tallies device vs user errors.
- SummaryDialogLauncher — writes final-status.json, copies dialog exe + deps
  + status file to a per-launch temp dir (%TEMP%\AutopilotMonitor-Summary\<guid>),
  composes the Legacy-compatible CLI (--status-file, --timeout, --cleanup,
  optional --branding-url), invokes UserSessionProcessLauncher.LaunchInUserSession
  with the configured retry window. Fire-and-forget, swallows all errors.
- EnrollmentTerminationHandler — the Terminated-event handler that sequences:
  build FinalStatus → write JSON + launch dialog → upload diagnostics
  (respects DiagnosticsUploadMode: Off/Always/OnFailure) → write
  enrollment-complete.marker (suppressed on WhiteGloveSealed) → run
  CleanupService.ExecuteSelfDestruct (suppressed on WhiteGloveSealed OR when
  SelfDestructOnComplete=false) → signal shutdown MRE. Idempotent; each step
  is best-effort and logs rather than throws — nothing is allowed to prevent
  agent shutdown.

DefaultComponentFactory
- Stores the ImeLogHost it creates and exposes its PackageStates to the
  termination pipeline (factory.ImePackageStates) so FinalStatusBuilder has
  the live package list without breaking host encapsulation.

Program.cs wiring
- Instantiates DiagnosticsPackageService, EnrollmentTerminationHandler, and
  ServerActionDispatcher with all callbacks live:
  • rotate_config → RemoteConfigService.FetchConfigAsync()
  • request_diagnostics → DiagnosticsPackageService.CreateAndUploadAsync()
  • terminate_session → EnrollmentTerminationHandler.Handle() synthesising a
    DecisionTerminalStage/Failed termination
- Terminated event routed to the handler; the handler signals the shutdown
  MRE so the main thread cleanly calls orchestrator.Stop().
- ServerActionDispatcher is alive but has no ingest-response consumer yet —
  V2 batch endpoint is M5 scope, we plumb the API contract here so M5 can
  drop in the response parser.

Tests (+47 total)
- DecisionCore.Tests: +15 SessionStageExtensionsTests (IsTerminal + IsPauseBeforePart2)
- V2.Core.Tests: +32 across terminal-stage hook, FinalStatusBuilder, handler
  • DecisionStepProcessorTerminalStageTests: fires once per stage, skips
    non-terminal, respects initial-terminal-state (recovery), swallows
    handler exceptions, works with null callback
  • FinalStatusBuilderTests: outcome mapping per stage, uptime, HelloOutcome
    fact path, signalsSeen composition, empty package list, device/user
    error tallying, JSON property-name contract
  • EnrollmentTerminationHandlerTests: writes FinalStatus.json + marker,
    runs/skips cleanup per config + WhiteGlove rule, idempotent shutdown
    signal, diagnostics mode matrix (Off/Always/OnFailure × Succeeded/Failed),
    suffix ("success" | "failure") per outcome

All 4 suites green: 88 DecisionCore + 353 V2.Core + 507 Agent.Core + 557 Functions
= 1505 tests green.

Remaining M4.6 scope: γ (Geo/NTP/Timezone/Emergency/Distress services wiring,
RegistryWatcher admin-action detector, DeliveryOptimizationCollector in
DefaultComponentFactory) + δ (--run-gather-rules + --run-ime-matching modes,
AnalyzerManager + 3 analyzers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 00:37:36 +02:00
Oliver Kieselbach 02bbb5490c feat(agent-v2): M4.6.α peripheral parity — lifecycle, install, self-update, watchdog
Closes the first batch of peripheral-parity gaps between Legacy and V2 so the
V2 agent is actually deployable/runnable in production. M4 kernel is unchanged
— only wiring and startup helpers.

Deployment + install
- Program.InstallMode.cs: --install mode ported 1:1 (payload copy, schtasks
  Create/Run, bootstrap-config.json + await-enrollment.json persistence,
  HKLM\SOFTWARE\AutopilotMonitor\Deployed marker)
- Configuration/BootstrapConfigFile.cs + AwaitEnrollmentConfigFile.cs
- Scheduled Task target is AutopilotMonitor.Agent.V2.exe (same task name
  as Legacy — V2 replaces Legacy on the device)

Startup guards (Program.Guards.cs)
- Multi-instance guard (IsAnotherAgentInstanceRunning)
- clean-exit.marker writer registered in AppDomain.ProcessExit
- DetectPreviousExit: reads marker + crash_*.log + session files + event log
  → classifies {first_run, clean, exception_crash, hard_kill, reboot_kill}
- CheckEnrollmentCompleteMarker: ghost-restart detection via HKLM Deployed
  key + enrollment-complete.marker → cleanup-retry + exit
- CheckSessionAgeEmergencyBreak: AbsoluteMaxSessionHours watchdog across
  restarts (respects whiteglove.complete pause)
- WriteCrashLog: last-resort crash_YYYYMMDD_HHMMSS.log
- TryRead/Delete bootstrap-config.json + await-enrollment.json helpers

Self-update (SelfUpdater wired for the first time in V2 startup)
- CleanupPreviousUpdate + CheckAndApplyUpdateAsync called BEFORE logger init
- Cached remote-config.json read → BackendExpectedSha256 + AllowAgentDowngrade
- VersionCheckEventBuilder wired: emits agent_version_check event via
  orchestrator.EventEmitter right after Start (when startup markers exist)

Agent max-lifetime watchdog (EnrollmentOrchestrator)
- New ctor param agentMaxLifetime: TimeSpan?
- New typed API: event EventHandler<EnrollmentTerminatedEventArgs> Terminated
  with EnrollmentTerminationReason (MaxLifetimeExceeded | DecisionTerminalStage)
  + EnrollmentTerminationOutcome (Succeeded | Failed | TimedOut)
- M4.6.α fires MaxLifetimeExceeded only; DecisionTerminalStage is the M4.6.β
  hook for CleanupService self-destruct + SummaryDialog launch
- Program.cs wires the event to the shutdown ManualResetEventSlim

Bootstrap-config cleanup (H-2 mitigation)
- BootstrapConfigCleanup.TryDeleteIfCertReadyAsync now actually called after
  RemoteConfigService.FetchConfigAsync (cert probe confirms mTLS works)

SessionIdPersistence extensions
- New session.created companion file (UTC roundtrip) for the emergency break
- whiteglove.complete resume marker awareness
- SessionExists / LoadSessionCreatedAt / SaveSessionCreatedAt / IsWhiteGloveResume
- Delete now clears session.id + session.created + whiteglove.complete

Tests (+26 in V2.Core.Tests → 295 → 321)
- SessionIdPersistenceTests: +8 tests covering the new methods
- EnrollmentOrchestratorMaxLifetimeTests: watchdog fires once, respects null,
  rejects non-positive, is cancelled by Stop (under SerialThreading collection)
- ProgramGuardsTests: 13 tests covering DetectPreviousExit state machine,
  CheckSessionAgeEmergencyBreak (pass/trip/whiteglove-resume/recover-missing-
  session.created paths), TryRead/Delete bootstrap + await-enrollment configs,
  WriteCrashLog format
- V2.Core.Tests gains ProjectReference to V2 exe + V2 exe exposes
  InternalsVisibleTo so the Program.Guards internal statics are testable
  without duplicating code into V2.Core

All 4 suites green: 73 DecisionCore + 321 V2.Core + 507 Agent.Core + 557 Functions
= 1458 tests green.

Still open for M4.6.β: CleanupService terminal-event wiring, ServerActionDispatcher,
DiagnosticsPackageService, SummaryDialog launch + FinalStatusBuilder,
UserSessionProcessLauncher / UserProfileResolver wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 00:14:48 +02:00