Commit Graph

1933 Commits

Author SHA1 Message Date
Oliver Kieselbach e63a0bc2bd perf(metrics): fix Platform Usage client timeout — parallel projected scans, 15min cache, single-flight, progress UX
Fresh cross-tenant usage computes took 20-43s server-side but the web app's
default 30s fetch timeout aborted them client-side (HTTP 499), and every
retry stacked another concurrent full compute.

Backend:
- UsageMetricsService: cache 5→15min, single-flight per days-window (retries
  join the in-flight compute), the four source scans run concurrently via
  Task.WhenAll, statics converted to instance fields (singleton, testable)
- New column-projected queries for the usage compute only:
  GetUsageWindowSessionsAsync ($select 13 consumed columns, drops
  FailureSnapshotJson & co.) and GetAppInstallRefsAsync ($select
  SessionId+AppName, lean SessionAppRef DTO); GetSessionsByDateRangeAsync
  is untouched for its other consumers
- Projection equivalence pinned by UsageMetricsProjectionEquivalenceTests
  (incl. the WhiteGlove Part-2 duration branch via IsPreProvisioned/
  ResumedAt); single-flight/cache/failure-eviction pinned by
  UsageMetricsServiceSingleFlightTests

Web (SectionPlatformUsage):
- 180s AbortSignal for this call (backend keeps computing and caches)
- Calculating card with elapsed time and a progress bar sized from the last
  fresh-compute duration (localStorage, cache hits excluded, capped at 95%)
- Friendly timeout error explaining the retry will hit the server cache

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 12:10:56 +02:00
Oliver Kieselbach 860efbbd1b fix(metrics): gate platform TotalEnrollments increment on fresh registration + document totalAllTime in MCP
- IncrementPlatformStatAsync("TotalEnrollments") fired on EVERY
  re-register (agent restarts, WG Part 2 resumes) — the nightly
  recompute corrected it, but the public stat visibly overcounted in
  between. Now gated on isFreshRegistration alongside the per-tenant
  counter (one shared block)
- MCP get_usage_metrics: the tool passes the backend response through,
  so sessions.totalAllTime flows automatically — extend the tool
  description so callers know the field is cumulative since signup and
  retention-independent; bump MCP server to 1.5.1

Backend tests 2876/2876, MCP tsc clean + vitest 393 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 12:01:14 +02:00
Oliver Kieselbach 778021bc8b feat(metrics): cumulative per-tenant enrollment counter (since signup)
"Total Sessions" on the usage-metrics page is window-bound (90d default)
and capped by retention. Add a retention-independent per-tenant counter
surfaced as "Total Enrollments (since signup)".

- TenantStats row in the PlatformStats table (PK=tenantId, RK="current";
  the platform row's "global" PK cannot collide with a tenant GUID)
- Incremented at session registration, fire-and-forget, fresh
  registrations only (agent restarts / WG Part 2 resumes re-register the
  same session and must not inflate the counter)
- ETag CAS with retries instead of last-writer-wins: lost increments are
  permanent (no recompute can recover pruned sessions), and rollout
  waves register concurrently
- Nightly platform-stats recompute seeds/self-heals the counter to at
  least the live session count (raise-only floor, analog TotalUsers clamp)
- Tenant usage metrics expose SessionMetrics.TotalAllTime =
  max(counter, window total); web page shows a "Total Enrollments" card
  plus clarifying window note ("Total Sessions" is not a lifetime total)

Backend tests 2876/2876, tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 11:52:14 +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
Oliver Kieselbach 6a61ec5360 docs(design): MCP OAuth flow — who authenticates where
Sequence diagram and explanation of the dual-identity connect flow (AI-product
account vs. Entra work account), including the frequently-missed requirement
that the default browser must hold an active vendor session (claude.ai) for
the callback in step 6 to be accepted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 10:36:34 +02:00
Oliver Kieselbach c20d9df8dc fix(mcp): pin OAuth redirect allowlist to exact vendor callbacks + token/log hygiene
Medium finding: the anonymous dynamic-client-registration allowlist accepted
any path on whole vendor domains plus wildcard subdomains, so an open redirect
(or controllable subdomain) on an allowed domain could exfiltrate authorization
codes — PKCE does not help when the attacker runs the flow with their own
verifier. Defaults are now the documented callback URIs only (claude.ai +
claude.com /api/mcp/auth_callback, chatgpt.com /connector_platform_oauth_redirect,
vscode.dev + insiders.vscode.dev /redirect); gemini.google.com dropped (Gemini
CLI uses loopback). MCP_ALLOWED_REDIRECT_HOSTS gains optional /path templates
(host-only entries still work). redirect_uris with userinfo or fragments are
rejected outright, loopback included; localhost stays allowed because Claude
Code and Gemini CLI register http://localhost callbacks.

Low finding: /oauth/token now sets Cache-Control: no-store + Pragma: no-cache
on every response; Entra error logging is reduced to status/error/error_codes/
correlation_id; all user-controlled log fields (client_name, redirect_uri) pass
through sanitizeForLog (control-char strip + length cap).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 10:17:09 +02:00
Oliver Kieselbach 9b023ac1d6 fix(web): keep post-part1-complete collector stragglers in WhiteGlove Pre-Provisioning
A periodic collector (e.g. StartupEnvironmentProbes power_state_check) can flush
one last event AFTER whiteglove_part1_complete, still from the draining Part-1
agent. computeWhiteGloveSplitSequence's pre-provisioning-only branch split at the
last cleanup marker, stranding such a straggler into a phantom "User Enrollment /
Resumed" block despite no whiteglove_resumed and no Part-2 agent_started.

Restore the invariant: no Part-2 boot evidence => no User Enrollment part. That
branch now splits at the highest sequence in the session so every trailing
straggler stays in Pre-Provisioning; the session renders the accurate "Awaiting
User Enrollment" placeholder instead. Adds a regression test from field session
1293f80e. Existing case-3 tests unaffected (marker was already the max sequence).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 10:12:45 +02:00
Oliver Kieselbach 611db9261c fix(agent): defer ImeUserSessionCompleted while required user-ESP installs are pending
IME writes its "[Win32App] .... Completed user session N ...." line at the
end of EVERY user-session processing pass, not when user-scope enforcement
is finished. Field sessions 14690fc2 and 6cb01530 (2026-07-10, WhiteGlove
Part 2 + mid-AccountSetup reboot) matched the line on the first post-reboot
pass with 13-18 required apps still pending; arm C of
ShouldTransitionToAwaitingHello took the signal as its fourth fact and the
session completed Succeeded five minutes later (HelloSafety timeout ->
FinalizingGrace) mid-install, spraying app_install_starved events.

Harden at the signal source: ImeLogTrackerAdapter.EmitUserSessionCompleted
now consults the new ImeLogTracker.GetPendingRequiredUserEspInstallApps()
probe (AccountSetup phase, Install intent, not terminal) and defers the
decision signal while the list is non-empty. The fire-once flag stays
unclaimed, so the completion line of a later pass re-evaluates and posts
once the apps have settled. Error counts as terminal (GRS owns retries) so
a permanently failed app cannot park the signal - and with it the
AdvisoryCompletion conjunction - into a 30-min-backstop false Failed.
Non-AccountSetup completions post as before (pre-anchor stamps stay
harmless via IsImeUserSessionGenuine).

Observability for the field watch: INFO log line per suppressed pass plus
a one-shot agent_trace (decision=ime_user_session_completion_deferred,
pending count + capped names) on the session timeline.

Session 04e8d52e

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 21:17:45 +02:00
Oliver Kieselbach fa03e290b2 feat(agent): os_build_changed detection + WU channel blind-spot census (session 7443317c)
Session 7443317c proved a WU-tracker blind spot: an OOBE quality update
installed (OsBuild 26200.8037 -> 26200.8655 across the mid-enrollment reboot)
yet the deployed WindowsUpdateTracker emitted ZERO events - the watcher armed
fine (no collector_degradation), the spool is durable, and two 60-min backfills
covered the install window, so the WindowsUpdateClient channel simply carried
none of the targeted EventIDs 19/20/43/44. The tenant has diagnostics upload
disabled, so the fix makes the feature self-evidencing instead of guessing IDs:

- OsBuildChangeHost: persists CurrentBuild.UBR (Registry64 view) to
  AgentState/os-build-state.json and emits os_build_changed (Info, immediate)
  when it differs across a restart - deterministic update corroboration that
  cannot be lost to timing, watermarks, or unexpected servicing paths. First
  run seeds silently; corrupt state re-seeds.
- WindowsUpdateTracker channel census: when the build provably changed AND zero
  targeted WU events were emitted this run, emit a one-shot
  windows_update_channel_census (Debug) with an unfiltered EventID histogram of
  the WindowsUpdateClient AND UpdateOrchestrator channels over the lookback
  window (capped at 5000 records, cap surfaced as censusTruncated). The
  histogram arrives via normal event upload and tells us which IDs to add to
  WindowsUpdateTargetedEventIds via remote config - no redeploy, no diag ZIP.
  Gated by CollectorConfiguration.WindowsUpdateChannelCensusEnabled (default
  on, model-default pattern, no ConfigVersion bump).
- No new hardcoded EventIDs (per the device-validation rule) - census data
  decides what to enable remotely.
- ANALYZE-DEV-006 "OS Build Changed During Enrollment" (info, not KO):
  interpolates {{previousBuild}}/{{currentBuild}}, counts the census as
  corroboration. Catalogs: Constants.cs + MCP resource-catalog.ts + web
  eventTypes.ts (drift-test enforced).

Tests: 6 OsBuildChangeHost (seed/same/changed/unreadable/corrupt/no-dir),
7 census (gating matrix, truncation surfaced, scanner-throw swallowed,
histogram format), 3 ANALYZE-DEV-006. Agent.V2.Core 1754/1754, Backend
2874/2874, MCP 379 (drift green), web tsc + vitest 367/367, sln 0 errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:42:48 +02:00
Oliver Kieselbach 76993478a5 fix(decision-core): rebase esp-exit advisory window on reboot instead of cancelling (session 7443317c)
The 9a107a19 reboot-cancel guard correctly stopped false
esp_exit_without_completion_evidence failures, but a pure cancel left the
post-AccountSetup dead-end zone with no resolution-capable deadline: session
7443317c (same tenant/shape as 1924092e, plus a mid-OOBE Windows-Update reboot
chain) tripped the session_parked_without_deadline liveness tripwire on a live
enrollment that later completed normally.

RebaseEspExitVariantAdvisoryWindowOnReboot now replaces the stale window with a
fresh 30-min one whose enforcement baselines (armSignalOrdinal /
armAppTerminalCount) anchor at the reboot signal, from both
HandleSystemRebootObservedV1 and HandleEspResumedV1:

- A live enrollment keeps disproving the dead-end at each fire via
  HasEnforcementProgressSinceArming (re-arm, never fail).
- A true dead-end where the user rebooted inside the window now resolves one
  window later (30-60 min) instead of idling to the 6-h max-lifetime watchdog -
  the 9a107a19 trade-off gap is closed.
- The parked tripwire needs no change: a resolution-capable deadline is armed
  throughout, so IsParkedWithoutDeadline stays false and the tripwire keeps its
  "every fire is a bug" charter.
- The rebase emits a completion_waiting with the new resolutionDeadlineDueAtUtc
  (trigger <Signal>:AdvisoryRebase), deliberately bypassing the fingerprint
  dedupe like the EnforcementActive re-arm site - bounded to one per reboot.

Tests: rebase with reboot-anchored baselines (both call sites), advisory
variant exempt, no-window no-op, true-dead-end-after-reboot fails at the
rebased due-time, 7443317c double-reboot live-install replay, fire-once keeps
the rebased window, 1924092e full replay updated (stray fire re-arms on
progress), engine-driven parked-tripwire regression.
DecisionCore 445/445, Agent.V2.Core 1754/1754.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:42:23 +02:00
Oliver Kieselbach c6b6e4504a feat(decision-core): proactive completion arm C from 4-fact post-ESP user-session evidence
Session a4537c36: Windows closed the User-ESP page normally (Shell-Core 62407,
errorCode=0) while the AccountSetup registry never wrote categorySucceeded -
the strong gate (arm A) was unsatisfiable by construction and the session only
completed because the Hello policy happened to flip to enabled after a reboot.

ShouldTransitionToAwaitingHello gains arm C: AccountSetup entered + normal ESP
final exit (post-AccountSetup by INGEST ordinal - backdated CMTrace timestamps
are irrelevant, sequence is canonical) + genuine IME user-session completion
(defaultuser0 guard: at-or-after the AccountSetup anchor, extracted as shared
IsImeUserSessionGenuine and reused by the AdvisoryCompletion backstop) + DAD-
validated real-user desktop. ALL FOUR mandatory; Hello gating stays at the
call sites - arm C never bypasses an enabled-unresolved Hello.

Wiring per signal ordering: desktop last via the fast-path (desktopArrived-
InFlight), exit last via HandleEspExitingV1 (espFinalExitInFlight + deferred-
completion parity mirroring the caa6cf50 branch), IME last via new completion/
promote wiring in HandleImeUserSessionCompletedV1. completion_waiting stops
reporting account_setup_provisioning_complete once the evidence set holds.
AdvisoryCompletion stays as the 30-min backstop for incomplete evidence.

The 08c99638 regression guard (3-of-4, no IME fact -> parked) stays green
unmodified; the L5 backdated-exit test now asserts the eager completion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:05:00 +02:00
Oliver Kieselbach e27c7b4298 feat(agent): surface ESP tracking-registry app lists on esp_config_detected
Session a4537c36: the agent could not tell ESP-blocking apps from merely-required
IME assignments. New EspTrackingInfoProbe reads the ESP''s own tracking lists from
HKLM\...\Autopilot\EnrollmentStatusTracking\ESPTrackingInfo\Diagnostics (device
scope + per-SID user scope; categories ExpectedMSIAppPackages / Expected-
ModernAppPackages / Sidecar, deduped across timestamped status-write subkeys)
and attaches them to esp_config_detected: espTrackedWin32AppIds (+ user-scoped
subset), espTrackedMsiProductCodes, espTrackedModernAppPfns, plus uncapped
counts (lists capped at 50). Registry semantics per Get-AutopilotDiagnostics.ps1.

Sidecar GUIDs are normalized to lowercase dashed form so they string-match
AppPackageState.Id - prepared for the espBlocking annotation follow-up on
app_install_starved (deliberately not wired yet; collect field data first).
Fail-soft like EspSkipConfigurationProbe; keys fully omitted when the
Diagnostics key is absent. Decision-signal payload untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:48:04 +02:00
Oliver Kieselbach c6cdae5228 fix(agent): starved-app detection Install-intent only, soften to Info on Succeeded termination
Session a4537c36 debug: 4 of 6 app_install_starved warnings were Uninstall-intent
system-app removals (Xbox, WMP, Ubuntu) - the ESP apps gate never blocks on
uninstalls and "never started installing" is meaningless for them. Filter at the
single chokepoint ImeLogTracker.GetStarvedUserEspApps so both emission paths
(live esp_exited + termination sweep) are covered.

The termination sweep also claimed "the app starved the enrollment completion"
on terminationOutcome=Succeeded - contradictory on a successful session. Now
Info severity + neutral wording (installs continue via IME post-ESP); Failed/
TimedOut keep the Warning verdict. Data fields unchanged.

AreUserEspAppsSettled deliberately untouched (completion behaviour stays
conservative until field data proves the ESP never waits on uninstalls).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:40:05 +02:00
Oliver Kieselbach 44e3efdcad updated commands 2026-07-10 15:53:32 +02:00
Oliver Kieselbach 9a107a1929 fix(decision-core): stop failing live enrollments via the esp-exit advisory window (session 1924092e)
The 1ec8f4c6 dead-end window (guard-blocked post-AccountSetup esp_exiting arms a
30-min advisory_completion resolution deadline) misfired on the Device->Account
handoff exit whenever the IME logged an AccountSetup phase line pre-sign-in
(defaultuser0/AutoLogon frame). The recovered deadline then failed sessions
mid-AccountSetup while apps were actively installing:
enrollment_failed: esp_exit_without_completion_evidence on a live enrollment.
41 of the 65 affected sessions since 2026-06-15 show reboots or app activity
after the arming exit - false positives.

Two guards, esp-exit variant only (the advisory variant keeps its real-failure
un-defang semantics):

- Reboot cancel: SystemRebootObserved / EspResumed cancel the armed window -
  an arming exit that predates a reboot was a pre-reboot page close, not the
  "page closed, nothing happens" dead-end. A later genuine guard-blocked exit
  re-arms a fresh window.
- Enforcement-progress re-arm: arming baselines (armSignalOrdinal,
  armAppTerminalCount) ride on the deadline's FiresPayload (replay-deterministic,
  snapshot-round-trips, no DecisionState schema change). At fire time, apps
  reaching terminal states or an ESP user-phase re-assert since arming re-arms
  the window instead of failing. Convergent: each re-arm demands new progress;
  legacy deadlines without baselines resolve with pre-fix semantics.

11 new tests incl. a full session-1924092e replay (handoff exit -> reboot ->
active installs -> stray recovered-timer fire dead-ends -> Completed) and pins
for the true dead-end verdict, the advisory variant, and baseline serialization.
DecisionCore 435/435, Agent.V2.Core 1725/1725.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:40:16 +02:00
Oliver Kieselbach cc3d23efe3 added tip of the day for report session 2026-07-10 15:26:37 +02:00
Oliver Kieselbach f1d00fec0f fix(web): dedupe rule-result evidence chips by eventId
Multiple matched conditions often extract different fields from the same
event (e.g. enrollment_failed + enrollment_failed_reason), which rendered
duplicate identically-labeled chips all jumping to the same timeline event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 12:59:22 +02:00
Oliver Kieselbach 5b7a602574 fix bandwidth estimation event sequene ordering 2026-07-10 12:51:56 +02:00
Oliver Kieselbach 0cba2e5611 feat(mcp): expose list_tenants to delegated (MSP) admins for display-name resolution
A delegated caller previously had no way to resolve its managed tenants
GUIDs to display names - the tool catalog registered list_tenants only
for platform scope (GA/Reader), and the server instructions listed the
managed set as bare GUIDs. "Which tenants can I access?" answered with
GUIDs, and "search sessions of tenant contoso.com" had no resolution
path, although the backend (config/all, GlobalReadOrDelegatedSubset)
already serves the bounded, secret-redacted managed subset.

- register list_tenants for delegated callers with role-aware wording;
  backend bounds the response, delegatedTenantListView re-intersects as
  defense-in-depth (web analog: delegatedScopedTenantList)
- surface the caller's home tenant (isHome: true), synthesized on the
  first page with the UPN-domain label when not in the managed subset
- carry the caller UPN through CallerContext for that label
- point tenantIdDescription, enforceDelegatedTenant errors and the
  delegated instructions line at list_tenants for name resolution
- bump MCP server to 1.5.0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 12:18:51 +02:00
Oliver Kieselbach cc96b5f074 fix(web): restore home-tenant access for delegated (MSP) admins
Web analog of the MCP fix (88a432dc): a delegated admin who is also a
member/operator of their own home tenant could not see or read it in the
web app — every delegated surface bound the tenant list strictly to
delegatedTenantIds (which never contains the home tenant) and routed all
reads over /global/* endpoints, whose delegated fan-out is bounded to
the managed set and would return empty for the home tenant.

Fix is web-only; the backend already authorizes home-tenant reads on the
JWT-bound member path (MemberRead) and stays authoritative:

- utils/homeTenantScope.ts: isHomeTenantTarget, upnDomain, and
  delegatedScopedTenantList — managed subset plus a synthesized home
  entry (UPN-suffix domain, isHome flag) when the caller has a home
  member role (user.role). Returns empty while config/all is still
  loading so the render-time seeding cannot discard a persisted managed
  selection in favor of an early home-only list.
- useGlobalAdminScope / useAggregatedAdminScope: home tenant in the
  selector list; new routeGlobal flag (= isGlobalAdmin EXCEPT
  delegated+home -> member path), mirroring MCP pickGlobalOrTenantPath.
- Pages switched from isGlobalAdmin to routeGlobal for endpoint choice:
  analyze-rules (rule stats), audit, fleet-health (hook param renamed,
  also drives SignalR scope), apps tabs (installs/inventory/vulns),
  geographic-performance (+ sessions drill via homeDrill carve-out).
  isGlobalOverride-keyed pages (gather-rules, analyze-rules list,
  usage-metrics, sla) and apps/[appName] were already correct.
- Dashboard: home tenant in the filter autocomplete; session list and
  stats route a home-tenant filter to the member endpoints before the
  delegated bound (boundTenantToDelegatedScope itself stays strict).
- Fleet: home-tenant card ("your tenant" badge); useFleetSummaries
  fetches the home summary via the member stats endpoint.
- TenantInfo.isHome + selector label "— your tenant".

Verify: tsc clean; vitest 556 passed (+13 homeTenantScope cases).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 12:01:36 +02:00
Oliver Kieselbach 170e66133d fix(backend): honor Hello-disabled + SkipUserEsp in the user-completed-setup reconcile rule
Follow-up to 049b6e5b: tenants running with Windows Hello disabled AND
the User ESP skipped can never produce a Hello terminal event, so the
reconcile rule would never fire for them and silent post-desktop
sessions stayed mislabeled AwaitingUser -> Incomplete. Mirror the
agent's Hello-disabled fast-path (HandleDesktopArrivedV1: policy
disabled + SkipUserEsp + desktop => complete):

- ExtractRollup reads hello_policy_detected Data.helloEnabled and
  esp_config_detected Data.skipUserStatusPage (contradicting
  observations resolve pessimistically to enabled/required).
- Rule 4 accepts desktop + (Hello terminal OR policy-disabled +
  SkipUserEsp). Hello disabled WITHOUT SkipUserEsp keeps waiting —
  the agent's strong post-AccountSetup gate (session 08c99638) blocks
  its fast-path there too, and rule 2 covers the rollup-complete case.
- esp_exiting stays rejected as success evidence (page-lifecycle event,
  fires with errorCode=0 while apps are unfinished — lesson 08c99638).
- Snapshot v3 + Web block additionally carry skipUserEsp.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:51:57 +02:00
Oliver Kieselbach 049b6e5b4a fix(backend): reconcile silent sessions to Succeeded when user provably finished setup (session 294ab5b4)
The timeout classifier only read the ESP registry rollup, so a session
where the user reached the desktop and completed Windows Hello — with
only the RealmJoin completion gate still pending when the agent went
silent — was labeled AwaitingUser (and would have graduated to
Incomplete at grace expiry). Desktop arrival + a positive Hello terminal
are the agent's own Classic completion AND-gate; the RJ gate
self-releases after 60 min, so such a session's enrollment succeeded and
only the completion report never left the device.

- EnrollmentTimeoutClassifier: rollup now carries DesktopArrived,
  HelloResolved (hello_provisioning_completed/hello_skipped only) and
  RealmJoinDetected/Resolved (realmjoin_resolved/realmjoin_timeout);
  new rule 4 reconciles desktop+Hello sessions to Succeeded with an
  RJ-aware reason. Explicit failure and agent_emergency_break still win.
- FailureSnapshotBuilder: schema v3 embeds the new evidence
  (helloResolved, realmJoinDetected, realmJoinResolved).
- Web FailureSnapshotBlock: renders Hello completed + RealmJoin rows.
- Design doc: decision table updated; desktop_arrived ALONE stays
  rejected as a completion signal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:26:37 +02:00
Oliver Kieselbach a0238a4b30 fix(agent): route runtime self-update restart through graceful shutdown (B1)
Follow-up hardening to 57e4334b (session b9b92d89). The runtime hash-mismatch
trigger runs the update pipeline on a thread-pool task concurrent with the live
orchestrator; RestartAgent then called Environment.Exit directly, leaving the
ProcessExit handlers only the CLR's ~2-3s window — far too short for the full
drain (ingress -> orchestrator.Stop -> spool flush -> snapshot). That kill
window is what produced the duplicated SignalLog line in the field.

Coordination via static hook (same idiom as BackendExpectedSha256):
- AgentRuntimeHost wires SelfUpdater.RequestGracefulShutdown BEFORE
  orchestrator.Start (no window where the SignalLog is open but a restart
  would hard-exit) and clears it in the shutdown finally
- Hook emits agent_shutting_down(reason=self_update_restart) through the
  cross-path idempotency gate and signals the host loop -> normal
  TerminationPipeline drain, process exits on its own
- RestartAgent: graceful path waits bounded (15s hard-exit fallback, well
  under the restart script's 30s Wait-Process cap so the new agent never
  races the old one into the multi-instance guard); startup-phase updates
  (hook unset, no orchestrator) keep the original immediate exit
- Hook invocation is fail-safe: a race with the host's teardown (disposed
  shutdown event) degrades to the immediate-exit fallback, which is safe
  again once the orchestrator has stopped

Suites green: Agent.V2.Core 1724 (5 new hook-contract tests), DecisionCore 423.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 23:39:41 +02:00
Oliver Kieselbach 57e4334bed fix(agent): recovery crash-loop on duplicated SignalLog line (session b9b92d89)
A runtime self-update restart (runtime_hash_mismatch, force) killed the agent
mid-append: the final agent_shutting_down line landed byte-identically twice
(FileStream 4096-buffer re-flushed by Dispose after a faulted flush). The next
start's ReducerReplay threw non-monotonic ordinal (27 <= 27) and the uncaught
exception turned one crash artifact into a PERMANENT startup crash-loop.

Three surgical fixes:
- SignalLogWriter.Append: bufferSize 1 -> unbuffered write; Dispose can never
  re-flush the payload (at-most-once physical append)
- SignalLogWriter.ReadAll: skip byte-identical consecutive duplicate lines
  (crash artifact, not tamper); non-identical duplicate ordinals stay visible
- RecoveryCoordinator.Recover: catch InvalidOperationException from replay ->
  quarantine all reducer segments + snapshot, reseed from Initial state
  (mirrors the existing log-head-corrupt path) instead of fatal exit

E2E-verified against the real customer signal-log.jsonl: 29 lines -> 28
distinct signals -> replay OK (LastAppliedOrdinal=27, zero signal loss).
Suites green: Agent.V2.Core 1719, DecisionCore 423.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 23:22:51 +02:00
Oliver Kieselbach 7afa03aaf1 fix(agent+web): honest script durations — runtime vs IME reporting latency, live counter for platform scripts
Remediation rows showed "8m" for scripts that ran seconds: durationSeconds was
measured HS-SCRIPT-START -> HS-NEW-RESULT, and IME writes that line only after
its batched service report. The early-signal (HS-COMPLIANCE) completion carried
no duration at all because it never read the cycle-start map.

Agent:
- HS-COMPLIANCE early-signal completions now PEEK _healthScriptStartTimes and
  emit the actual run time (durationBasis=script_runtime); HS-NEW-RESULT keeps
  consuming the entry and stamps its phases cycle_including_reporting_latency.
- durationBasis now comes from the emitting handler (ScriptExecutionState.
  DurationBasis) instead of pauschal per scriptType.
- Platform scripts emit the live script_started signal too (gated on pending-
  slot creation so the duplicate agentexecutor+ime start line stays one signal).

Web:
- Reducer normalizes durationBasis: runtime -> durationSeconds, cycle ->
  reportedAfterSeconds (legacy events without basis infer from scriptType, so
  old remediation cycle values are never displayed as run time again).
- Dedupe merges both timings across the early/late emissions instead of
  dropping the runtime when the more complete HS-NEW-RESULT entry wins.
- Card run time = max of phase runtimes; "Reported after Xm" shown in the
  expanded Details strip (per UX decision), never on the compact row.

Inert for new events until agent redeploy; web deploy alone already fixes the
misleading display of stored data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 19:20:12 +02:00
Oliver Kieselbach 831bd1f37b feat(agent): ESP settle-window recovery check + pending-app correlation (session c071e92b)
An ESP subcategory failure can be retracted inside the 30s settle window
(user clicks "Try again" / IME re-sync flips failed -> inProgress). The
expiry handler fired the latched EspFailureDetectedEventArgs
unconditionally, terminating the agent on a failure Windows no longer
reported and losing the retry outcome (session c071e92b: recovery at
+12s, terminal esp_terminal_failure at +30s anyway).

- OnProvisioningFailureSettleExpired re-checks current registry-derived
  state under _stateLock: recovered = categorySucceeded != false AND no
  subcategory currently "failed". On recovery: emit new
  esp_failure_settle_recovered event, suppress EspFailureDetected, clear
  the fire-once gate (+resolved set) so a re-failure arms a FRESH settle
  window and is then terminal.
- esp_failure_settle_started now names tracked apps not completed
  (trackedAppsNotCompleted/-Count) on Apps-subcategory failures via a new
  packageStatesProbe (ImeLogHost.AllKnownPackageStates through
  DefaultComponentFactory -> EspAndHelloHost -> EspAndHelloTracker).
  Covers the Store/WinGet blind spot (Company Portal pending with zero
  IME events) that both app_install_starved (IME-phase + intent gates)
  and esp_apps_failure_correlation (IsActive device apps only) miss.
- New event type registered in Constants.EventTypes + MCP resource
  catalog (drift test green); 5 new settle-window tests, suite 1711/1711.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 17:57:08 +02:00
Oliver Kieselbach f2fcb7606c fix(shared): suppress CS8603 false positive in RuleProvenance.Normalize
netstandard2.0's string.IsNullOrEmpty lacks [NotNullWhen(false)], so the
compiler cannot prove the else branch is non-null. Null-forgiving assert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 15:55:15 +02:00
Oliver Kieselbach 50295e8304 feat(agent): AppX/MSIX failure enrichment in ESP settle window (esp_appx_failure_analysis)
Session 2bc884b6: ESP failed AccountSetup/Apps with 0x80073cf9 while all 8
tracked Win32/WinGet apps succeeded — the failing app was a MSIX/Store app
that installs outside the IME Win32 pipeline and is invisible to
ImeLogTracker. The session ended as a blackbox failure.

When an Apps-subcategory failure arms the 30s settle window, the tracker now
queues a one-shot scan of Microsoft-Windows-AppXDeploymentServer/Operational
(Critical+Error, session window, 200-record/10s cap), scores candidate
packages (exact-HRESULT match > AppX-family error > time proximity, recency
bonus), and emits esp_appx_failure_analysis with flat RuleEngine fields plus
a capped candidates[] detail list — before enrollment_failed lands in the
timeline. Negative results are emitted too (no_appx_candidates points back
at Win32). Fire-once per session; scan runs lock-free on the threadpool;
settle expiry never waits; failures degrade to collector_degraded.

- New: AppxDeploymentFailureScanner (one-shot reader, injectable seam),
  AppxFailureAnalyzer (pure scoring/verdicts, incl. negative-decimal
  HRESULT + PackageFullName extraction from message/properties)
- ProvisioningStatusTracker: optional scanner/dispatcher ctor seams,
  MaybeStartAppxFailureScan at settle-arm time
- Rule ANALYZE-APP-014 (MSIX/Store app failure during ESP) keyed on
  verdict=appx_candidate_identified; dist regenerated
- MCP resource-catalog + web HRESULT badge + gather-rules eventTypes
- Tests: 16 analyzer + 7 trigger; settle-window fixture now injects a
  fake scanner + sync dispatcher (no real event log in tests)

Inert until agent redeploy (rule needs backend redeploy). Post-deploy
checks in tasks/todo.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 15:49:27 +02:00
Oliver Kieselbach 3bbfac165e added filter for user feedback table 2026-07-09 14:35:41 +02:00
Oliver Kieselbach 5ff1e8c006 added exact matches to tenant search 2026-07-09 14:15:19 +02:00
Oliver Kieselbach a15ad56097 active tenants calculation fix 2026-07-09 13:13:24 +02:00
Oliver Kieselbach ac6b008387 feat(agent): DO throttle-policy context fields on bandwidth estimate
The network_bandwidth_estimate event now carries the CONFIGURED Delivery
Optimization bandwidth caps, so analyses can distinguish line-limited
(no caps -> the p90 is a real capacity lower bound) from policy-limited
(caps present -> the measured rate reflects the throttle, the line is
likely faster).

Verified against MS docs (Policy CSP + DO reference): GPO and Intune/CSP
do NOT share a registry store - both are read from the Registry64 view:
- GPO (ADMX): HKLM\SOFTWARE\Policies\Microsoft\Windows\
  DeliveryOptimization, DO-prefixed value names
- Intune/Policy CSP: HKLM\SOFTWARE\Microsoft\PolicyManager\current\
  device\DeliveryOptimization, same value names

Semantics: absolute caps in KB/s, percentage caps 0-100; a value of 0
means dynamic/no limit (OS default) and is treated as not configured.
DODownloadMode 0 is a real setting (HTTP only) and is reported whenever
present, but is not a throttle. Policies are read FRESH at each emission
because Intune policies land during the enrollment.

Event fields (omitted when absent): doThrottleConfigured,
doThrottleSources (gpo|mdm|gpo+mdm), doPolicy{Gpo,Mdm}{MaxForegroundKBps,
MaxBackgroundKBps,PctMaxForeground,PctMaxBackground,DownloadMode}; the
message gains a "[DO throttled by policy]" marker. Not covered (v1):
DOSetHoursToLimit* business-hours string policies.

+6 DoThrottlePolicyReaderTests (two-store split, 0-semantics,
string-DWORD tolerance, fail-soft); agent suite 1674/1674 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 12:33:28 +02:00
Oliver Kieselbach dd7b10ace9 feat(agent): bandwidth estimate survives reboots via state persistence
The estimator accumulator previously lived in process memory only - a
mid-DeviceSetup reboot (e.g. a Win32 app forcing a restart) silently
discarded every rate sample collected so far. One enrollment commonly
spans several reboots.

- BandwidthStatePersistence (bandwidth-state.json in the state dir):
  mirrors the OfficeInstallStatePersistence pattern - atomic write via
  temp+rename, fail-soft load (missing/corrupt/foreign-session -> fresh
  start, never throws). SessionId-validated on load.
- BandwidthEstimator.ExportState/ImportState: sanitizes NaN/Infinity/
  negative samples, caps list sizes; baselines deliberately not
  persisted (a reboot gap always exceeds the per-file delta window).
- Save points are event-driven, never per-poll: the dormant transition
  (downloads just finished - exactly when app-forced reboots happen),
  after the interim emission, and at collector stop.
- The InterimEmitted guard rides in the same file: device_setup_end
  now fires once per SESSION, not once per process. Persisted AFTER
  the emission (StartupEventGate MarkEmitted ordering) so a crash
  between emit and save re-emits instead of suppressing forever.
- Wiring: DefaultComponentFactory passes the state directory through
  DeliveryOptimizationHost; the collector param is optional (null =
  in-memory only).

+13 tests (estimator roundtrip/merge/sanitizing, persistence roundtrip/
session-mismatch/corruption, collector-level resume incl. guard
survival); agent suite 1668/1668 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 11:55:45 +02:00
Oliver Kieselbach 076ea7e5fd feat(agent): passive internet-bandwidth estimate from DO counters
New one-shot network_bandwidth_estimate event derived passively from the
Delivery Optimization byte counters the DeliveryOptimizationCollector
already polls every 3s - zero synthetic traffic, zero extra load, no
config change.

- BandwidthEstimator: per-FileId delta windows (a DO job that drops off
  the list and returns is averaged over its own absence window), p90/max
  reduction, WAN (HTTP + internet peers) vs LAN (LAN/group/link-local
  peers + Connected Cache) split so peer-fed downloads cannot inflate
  the WAN figure; bucket (<10/10-50/50-100/100-250/250+) + confidence.
- Emitted at most twice per session (snapshotTrigger): interim snapshot
  on first AccountSetup sighting (device_setup_end - survives sessions
  that starve in the account phase) and authoritative final at collector
  stop (collector_stop, lands before diagnostics_collecting).
- All DO jobs feed it (IME apps, Office CDN, WU/Store/Defender).
- Registered in Constants.EventTypes, MCP resource-catalog
  (network_events) and web gather-rules autocomplete.
- Drive-by: add missing agent_emergency_break to the MCP catalog
  (pre-existing drift, event-types-drift test was red).

13 new estimator unit tests; agent suite 1655/1655, MCP drift 6/6,
web tsc + eventTypes vitest green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 11:25:30 +02:00
Oliver Kieselbach 8b266570cb feat(notifications): multi-channel notifications + per-rule notify
Phase 1 - named notification channels (1..N per tenant):
- NotificationChannel model (provider/url/headers/enabled + per-event
  opt-ins incl. single NotifyOnSlaEvents); stored as JSON column
  NotificationChannelsJson, cap 10, fail-soft ParseList
- No data migration: GetNotificationChannels() synthesizes one channel
  from the legacy single-webhook fields under stable id "legacy";
  web materializes on first save and clears legacy fields
- All dispatch sites (ingest success/failure/white-glove/ESP, SLA breach/
  resolved/consecutive, enrollment-start, hardware-rejected, test) route
  via SendToChannelsAsync with per-channel toggle filtering
- Per-channel secret redaction (structure stays visible to readers) with
  restore-by-id; save-time validation (dup ids, provider, https, headers)
- Settings UI: channel card list with add/remove, event pills, per-channel
  test button ({channelId} body, legacy-compatible without body)

Phase 2 - analyze-rule channel notifications:
- NotifyDefault (definition, false for all built-ins, test-guarded) +
  Notify/NotifyChannelIds tenant override in RuleStates (two-tier mirror
  of MarkSessionAsFailed); custom rules fold into the tenant-owned row
- Dispatch in AnalyzeOnEnrollmentEndHandler only: outcome.Results is
  new-findings-only (engine dedup) so no re-notification; manual
  "Analyze Now" reanalyze never dispatches
- BuildRuleFiredAlert (eventType analyze_rule_fired): device, serial,
  rule, severity, category, confidence, explanation + session deep link
- Rule card: notify toggle + channel target pills, bell header badge

2858/2858 Functions tests (19 new), web tsc + vitest clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 10:35:41 +02:00
Oliver Kieselbach 3597e9f2e8 feat(fleet-health): link "Health by Device Model" rows to filtered dashboard
Each model row now deep-links to /dashboard?status=Failed&search=<model>,
carrying the selected tenant for scoped global admins. The dashboard reads
?search= and ?status= from the URL (status validated against the known
buckets) and seeds the filter state via useDashboardFilters.

The search term stays fully client-side (never sent to the backend), the
status param is whitelist-validated, and the tenant param remains bounded
by the existing delegated-scope + backend enforcement, so no new trust
boundary is introduced.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 08:40:36 +02:00
Oliver Kieselbach bad563127f update platform usage wordings 2026-07-09 08:22:26 +02:00
Oliver Kieselbach 55744248d5 plan wording retention fix 2026-07-08 21:34:08 +02:00
Oliver Kieselbach 7e68f9d965 fix(metrics): surface terminal Incomplete bucket in usage stats
The 2026-07-08 timeout reclassification (Incomplete = terminal, non-failure)
migrated SessionStats, FleetHealthStats and SessionStatusBuckets, but two
reporting surfaces were overlooked: the live UsageMetricsService.SessionMetrics
(both platform + tenant paths, feeding get_usage_metrics and the usage
dashboards) and the persisted daily UsageMetricsSnapshot. Terminal Incomplete
sessions were counted in Total but in no breakdown bucket, so
Succeeded+Failed+InProgress no longer reconciled to Total and operators lost
sight of those devices.

- SessionMetrics + UsageMetricsSnapshot gain an Incomplete / SessionsIncomplete
  count, mirroring the migrated peers (Incomplete only, not AwaitingUser).
- Snapshot column stored + mapped in TableStorageService.Metrics; legacy rows
  deserialize to 0 (no seed bump needed).
- Success rate unchanged everywhere: denominator was already Succeeded+Failed,
  correctly excluding Incomplete/AwaitingUser. SLA paths verified already-correct
  (BuildSnapshot + breach evaluator receive terminal-filtered sessions).
- Web: Incomplete card added to both usage views (slate, matches the badge).
- MCP: query_raw_sessions status enum learns AwaitingUser/Incomplete so callers
  can filter by the new states.
- Test: UsageMetricsServiceWindowTests pins the Incomplete bucket + 50% rate on
  the crcins 1/1/8 shape.

Backend build clean, full Functions suite 2825 pass, web + MCP tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 21:24:52 +02:00
Oliver Kieselbach a91ad12788 Merge pull request #86 from okieselbach/claude/crcins-enrollment-failures-ac943m
Enrollment status reclassification: timeout != failure (AwaitingUser / Incomplete)
2026-07-08 19:28:10 +02:00
Oliver Kieselbach 419d93a2d8 fix(reclassification): review hardening for timeout status states
Addresses four review rounds on the enrollment-status reclassification
(timeout != failure; new AwaitingUser/Incomplete states).

Backend correctness:
- UpdateSessionStatusAsync ETag force-fallback now mirrors the normal path:
  uses IsTerminalTransitionAllowed + admin-marked guard, stamps CompletedAt
  for Incomplete, and persists FailureReason/FailureSnapshotJson for
  Incomplete/AwaitingUser (not just Failed) so a contended write cannot
  regress a terminal or drop the classification reason/snapshot.
- StoreSessionAsync CAS loop re-reads the terminal tuple in the fresh-read
  window and preserves a just-landed terminal (Succeeded/Failed/Incomplete)
  verbatim, so re-registration can no longer revert it via the Replace.
- SessionGraceHours + AbsoluteMaxSessionHours now roundtrip through the
  tenant-config Store/Map pair (overrides were silently lost).
- ResolveGraceHours clamps the assumed agent cap to >= the real runtime
  default (48h) until the agent honors the override, so a low override can
  never terminalize a still-running session early.
- Incomplete now funnels through the terminal counter-reconcile seam, and
  the maintenance sweep re-reconciles after the synthetic session_timeout
  event (also fixes the pre-existing off-by-one on the Failed path).

Web:
- SessionInfoCard delegates its status pill to the shared SessionStatusBadge
  (removes a divergent local map; picks up AwaitingUser/Incomplete).
- SessionTable search color map + status filter chips learn the new states.
- useSessionEvents uses isTerminalStatus() instead of a hard-coded
  Succeeded/Failed check (no needless refetch on Incomplete).
- DataManagementSection copy no longer says "Failed - Timed Out".

Docs/comments:
- Plan doc + TenantConfiguration comments corrected (+3h/51h buffer, and the
  timeout now reclassifies to AwaitingUser/Incomplete, not "Failed").

Tests: +ResolveGraceHours clamp case, +grace/abs-cap config roundtrip +
legacy-row defaults. Full backend suite green (2712), web tsc green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:26:41 +02:00
Oliver Kieselbach 33618e183d feat(wu-oobe): detect & surface Windows Update during OOBE + self-maintaining rule sunset
Windows Update activity during Autopilot OOBE/ESP is a blind spot no other tool
(Intune console included) surfaces well — a cumulative update installing mid-OOBE
can break enrollment (r/Intune KB5095189). This makes it visible with secured
evidence.

Agent (net48):
- WindowsUpdateTracker + WindowsUpdateWatcherHost: watch
  Microsoft-Windows-WindowsUpdateClient/Operational (EventIDs 19/20/43/44 =
  success/failure/install-started/download-started), startup backfill (OOBE updates
  run before the agent starts), RecordId watermark dedup (restart boundary vs
  intra-run seen-set so a high-id live event can't hide older backfill events),
  HRESULT->symbol decode. Emits windows_update_succeeded/_failed/_started with the
  real event Timestamp. Default-on config knobs; uncertain EventIDs configurable
  without redeploy.
- RegistryCollector: emitOnlyIfExists (suppress positive-assertion events when the
  key is absent) + Registry64 base view by default (bitness-independent, matches
  TenantIdResolver) with optional registryView override.

Rules:
- ANALYZE-DEV-004 (high: WU failed during enrollment, high-insight not KO) +
  ANALYZE-DEV-005 (info: WU installed). reboot_pending corroboration matches
  event_data exists==true.
- GATHER-DEVICE-005 -> windows_update_reboot_pending (emitOnlyIfExists),
  GATHER-DEVICE-004 -> windows_update_history (Get-HotFix, opt-in).
- 5 EventTypes synced across Constants + MCP resource-catalog + web eventTypes.

Self-maintaining rule lifecycle (analyze + gather parity):
- Gather rules gain the sunset/orphan-GC that only analyze had
  (ProcessSunsetGatherRuleAsync + runtime filter + seed/reseed/github paths).
- RuleProvenance field (embedded/github, null=embedded back-compat): the embedded
  catalog only sunsets its own rules; GitHub-reseeded rules ahead of the deployed
  binary (new ids AND content updates to existing ids) are exempt and reclaimed to
  embedded once the binary catches up. No orphans, no operator knowledge required.

Verified: full solution builds; backend suite 2770 pass, agent suite 1638 pass;
rules AJV-valid + combined.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 18:22:41 +02:00
Claude ff8a442195 docs: correct plan — Fleet Health Incomplete done in PR5 2026-07-08 15:39:27 +00: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
Claude a2a5d2fc0e feat(web): PR5 — surface Incomplete/AwaitingUser in the UI
- sessionStatus.ts: isTerminalStatus includes Incomplete.
- SessionStatusBadge: Incomplete = neutral slate (clearly not a red failure),
  AwaitingUser = sky/blue 'still going'; clock affordance extended to Incomplete.
- Fleet Health: FleetHealthStats.Incomplete (model + BuildFleetHealthPayload) +
  web interface + a dedicated slate Incomplete stat card (FleetStatCard gained a
  'slate' color), grid widened to 5.
- Main dashboard KPI row unchanged (its Success Rate is already terminal-only/honest).

Verified: web tsc --noEmit clean; backend Functions.Tests 2709 pass.
2026-07-08 15:30:47 +00:00
Claude 96ad65f715 feat(backend): PR4 — surface Incomplete as a third state, honest failure rate
- SessionStatusBuckets: explicit AwaitingUser + Incomplete buckets (out of Other),
  still reconciling to Total.
- Metrics summary: expose awaitingUser + incomplete; failure rate is now
  Failed/(Succeeded+Failed) — Incomplete and non-terminal states are out of the
  denominator (crcins drops from failed/total to the real terminal rate).
- SessionStats.IncompleteLastNDays; dashboard SuccessRatePct was already terminal-only.
- Daily aggregation already used Succeeded+Failed denominator — unchanged.
- Tests: SessionStatusBucketsTests. Suite: 2705 pass. Fleet Health Incomplete surfacing
  deferred to PR5 (web) with its documented over-all-sessions semantics.
2026-07-08 15:17:35 +00:00
Claude c0585133be feat(backend): PR3 — reconcile late completions to Succeeded
A device that reaches the desktop is enrolled regardless of an earlier sweep
timeout guess, so a genuine completion must be able to heal a prior verdict.

- Replace the terminal idempotency guard with pure IsTerminalTransitionAllowed:
  Succeeded UPGRADES any non-Succeeded state (Failed/Incomplete/AwaitingUser →
  Succeeded); Failed/Incomplete and AwaitingUser never overwrite an existing
  terminal. Ingest already calls UpdateSessionStatusAsync(Succeeded) on a late
  enrollment_complete without a pre-terminal skip, so the relaxed guard wires the
  reconcile end-to-end.
- Clear stale FailureReason + FailureSnapshotJson on Succeeded (reconcile hygiene).
- Preserve admin-marked terminals: a late completion never auto-overrides a
  hand-marked Failed/Incomplete.
- Tests: SessionStatusTransitionTests (full matrix, 20). Suite: 2705 pass.
2026-07-08 15:11:52 +00:00
Claude 4749c21d4e feat(backend): Part B — materialize agent_emergency_break timeline event
ReportAgentErrorFunction now injects ISessionRepository and, on a
SessionAgeEmergencyBreak report, writes an agent_emergency_break EnrollmentEvent
into the session stream (Sequence one past the last event, idempotent, best-effort
— never turns the always-200 emergency channel into a retry loop). This is what
makes the agent's otherwise-silent 48h break visible in the timeline and lets the
timeout classifier see it. Pure BuildAgentEmergencyBreakEvent helper (Warning,
non-terminal) + tests. Functions.Tests: 2685 pass.
2026-07-08 15:03:42 +00:00
Claude ebc9a19857 docs: plan the agent emergency-break event (backend materialize + agent emit) 2026-07-08 14:56:16 +00:00
Claude a6f328b4c2 feat: agent_emergency_break signal — classifier consumes it (skip grace)
Groundwork for making the agent's silent 48h emergency break transparent
(docs/design/enrollment-status-reclassification.md):
- Constants.EventTypes.AgentEmergencyBreak + AgentErrorType.SessionAgeEmergencyBreak.
- EnrollmentTimeoutClassifier: ExtractRollup.HasAgentEmergencyBreak; classify a
  session carrying the break as Incomplete NOW (agent is gone) instead of holding it
  as AwaitingUser through the grace — a real completion still wins. Tests added (22 total).

Next: agent best-effort emit at the break + backend materializes the timeline event.
2026-07-08 14:54:55 +00:00