The SCCM client that was running, registered, and ignoring every deployment

Why a GPO and a script on a fileshare quietly underperforms, and what to replace it with

A machine stops getting deployments. The console says Active. CcmExec is running. It registered this morning. Every check you would think to run comes back clean, and the machine has not installed anything in two months.

Most shops end up with the same answer: a GPO and a remediation script on a fileshare. It half works, nobody entirely trusts it, and the manual triage never actually goes away. This is about why that happens and what to do instead.

The check everyone runs first is the wrong one

The instinct is to confirm the service is up.

Get-Service CcmExec
# Status   Name       DisplayName
# ------   ----       -----------
# Running  CcmExec    SMS Agent Host

Running. So the client is fine, and the problem must be the deployment, or the collection, or the boundary group. That is where the afternoon goes.

The service being up tells you almost nothing. CcmExec will happily run for months in states where it processes no policy at all.

Provisioning mode

Here is one of those states, and it is far more common than its obscurity suggests:

Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\CCM\CcmExec' |
    Select-Object ProvisioningMode, SystemTaskExcludes

# ProvisioningMode   : true
# SystemTaskExcludes : Configuration,Inventory,Software

Provisioning mode is what a task sequence sets during OS deployment, to stop the client acting on policy while the build is still running. The task sequence is supposed to clear it at the end.

When a task sequence fails midway — or is cancelled, or the machine reboots at the wrong moment — nothing clears it. The client comes up, registers, sends its heartbeat, appears Active in the console, and silently discards every deployment it is handed.

The fix is trivial:

Set-ItemProperty 'HKLM:\SOFTWARE\Microsoft\CCM\CcmExec' -Name ProvisioningMode   -Value 'false'
Set-ItemProperty 'HKLM:\SOFTWARE\Microsoft\CCM\CcmExec' -Name SystemTaskExcludes -Value ''
Restart-Service CcmExec

Clearing SystemTaskExcludes matters as much as the mode flag. Set the flag to false on its own and the agent components named in that value stay suppressed. The machine looks fixed and is not.

Finding it is the hard part. Fixing it takes ten seconds.

Why you cannot just deploy a remediation

The obvious plan: write a script, deploy it with ConfigMgr, done.

Except the machines that need it are the machines whose client is broken. You cannot use ConfigMgr to fix ConfigMgr. A remediation delivered through the client reaches exactly the population that did not need it.

That is why nearly everyone lands on a GPO and a script on a fileshare. It is the right instinct. The usual implementation is where it comes apart.

Six reasons the GPO-and-fileshare approach underperforms

1. The script runs from the share

# The classic GPO startup script
\\fileserver\netlogon\Fix-SccmClient.ps1

Laptop on a hotel network, VPN not up yet at boot, site link down, share unavailable during a maintenance window: no script. And the population that is off-network, badly-connected or half-broken correlates heavily with the population whose client is also broken.

Fix: copy the script to the endpoint once, then run it locally on a schedule. The share becomes a distribution point, not a runtime dependency.

2. It only runs at startup

Startup scripts are attractive because they run as SYSTEM. But machines that are never rebooted are never checked — and a machine with 90 days of uptime is more likely to have drifted, not less.

There is a second problem: GPO startup scripts run synchronously before the logon screen, against a timeout (MaxGPOScriptWait, 600 seconds by default). A remediation script that waits on a policy cycle is now extending everyone's boot time, so somebody eventually trims it until it no longer does anything useful.

Fix: a scheduled task with both a daily trigger and a startup trigger, running independently of the boot path.

3. Every machine runs it at the same moment

# Every endpoint, 08:00, simultaneously
$trigger = New-ScheduledTaskTrigger -Daily -At '08:00'

Trigger a policy retrieval and a hardware inventory on a few thousand clients in the same minute and you have converted a client health problem into a management point outage.

Fix: a random delay, as a parameter so it can be tuned to fleet size.

$trigger = New-ScheduledTaskTrigger -Daily -At $Time
$trigger.RandomDelay = "PT$($RandomDelayMinutes)M"

4. It runs as SYSTEM, and the share ACL does not expect that

This one is worth internalising, because it fails silently.

A scheduled task or startup script running as SYSTEM authenticates over the network as the machine account — DOMAIN\COMPUTERNAME$ — not as the logged-on user. A share granted to Domain Users will refuse it. Nothing is logged anywhere you are looking, the script simply never runs.

Fix: grant Domain Computers Read on the distribution share, and Modify on any share you expect results written back to.

5. Nothing comes back

This is the big one, and it is why "it's not working optimally" is such a common verdict. The script runs, does something, and reports nowhere. You cannot answer:

  • Which machines are currently broken?
  • Did last night's repair actually work?
  • Which machines did not run the script at all?

That last question is the one that matters most, and it is invisible by construction. A machine that has stopped reporting is a stronger signal than any individual failed check.

Fix: you already have a fileshare. Use it. Every endpoint writes one <COMPUTERNAME>.json back to a results share; a collector script turns that directory into a worklist. No database, no web service, no agent.

Copy-Item  -Path $LocalReportPath -Destination $temp -Force
Move-Item  -Path $temp -Destination $final -Force

Write to a temporary name and move into place — a rename is atomic, so a collector reading the share concurrently never picks up a half-written file. And publishing must be best-effort: a laptop off the network still completed a perfectly valid local health check, and losing the upload must not turn that into a failure.

Then the fleet view answers all three questions at once:

  Fleet client health - \\fileserver\ClientHealth$
  842 machines reporting

    Healthy          769
    Self-repaired     41
    Need attention    24
    Stale (>3d)        8

  Most common failures (fix these at source):
      19x  CLI-PROVMODE       Client not stuck in provisioning mode
       4x  SVC-CCMEXEC        Service 'CcmExec' running
       1x  FND-WMI            WMI repository readable

  Gone quiet (no report in >3 days - these are the worrying ones):
    WS-0431            last seen 14 days ago
    WS-0570            last seen  9 days ago

Nineteen machines failing the same check is not nineteen endpoint problems. It is one task sequence problem wearing a disguise. You cannot see that from a console, and you certainly cannot see it from a script that reports nowhere.

6. A repair that is not verified is a lie

The rule that matters most, and the one I would keep if I threw everything else away: after a repair runs, re-run the test that failed. If it still fails, the result is Failed. It is never reported as Repaired.

$repair = Invoke-CheckTest -Block $check.Repair
# ...
# The part that makes this trustworthy: prove the repair worked.
$verify = Invoke-CheckTest -Block $check.Test
if ($verify.Ok -and $verify.Value) {
    # Repaired and verified
} else {
    # Repair ran but verification still failed
}

It is very easy to write a remediation that calls Restart-Service, catches no error, and reports success while the service dies again immediately. Do that across a fleet and you get a dashboard that is confidently, uniformly wrong. Nobody trusts the tool after that, and they are right not to.

Symptoms are cheap, causes are not

Once you start listing checks, the obvious design is a flat list: service running, policy fresh, inventory current, cache sized, site assigned.

Run that against a machine with a corrupt WMI repository and you get fourteen findings, thirteen of which are noise. Policy is stale because WMI is broken. Inventory has not run because WMI is broken. An engineer reading that report top-down starts on the wrong layer.

So the checks are a dependency graph, not a list. Policy checks depend on a running CcmExec, which depends on a readable WMI repository. If a prerequisite is unhealthy, its dependents are skipped rather than run and reported as failures:

[Failed]  FND-WMI          root\cimv2 is unreadable, repository likely corrupt
[Skipped] FND-CCMNS        Skipped: prerequisite not healthy (FND-WMI)
[Skipped] SVC-CCMEXEC      Skipped: prerequisite not healthy (FND-WMI)
[Skipped] POL-HEARTBEAT    Skipped: prerequisite not healthy (FND-CCMNS, SVC-CCMEXEC)

One finding. One thing to fix. That is the difference between a report and a worklist.

Some things should not be automated

Low disk space and pending reboots get detected. Neither gets fixed.

Deleting files to reclaim space, or rebooting somebody's workstation unattended, is how an automated remediation tool gets switched off within a fortnight. Destructive repairs — WMI salvage, client reinstall — sit behind a separate tier that is off by default, so they cannot happen because someone forgot a flag:

# Safe tier only: services, policy cycles, clock resync, provisioning mode.
.\Invoke-SccmClientHealth.ps1 -Mode Remediate

# Explicitly permits WMI salvage and client reinstall.
.\Invoke-SccmClientHealth.ps1 -Mode Remediate -MaxTier Risky

Note what is not in the risky tier either: winmgmt /resetrepository. Salvage is attempted; a full reset discards third-party provider registrations and has broken more endpoints than it has fixed. If salvage does not work, a human should be looking at that machine.

Everything site-specific is a parameter

Nothing above is worth much if the tool has your site code baked into it. Thresholds and environment settings live in one JSON file, and unknown keys are rejected at startup rather than ignored — a typo in a fleet-wide config should fail loudly, not quietly revert a thousand machines to defaults.

{
  "ExpectedSiteCode": "ABC",
  "ClientSourcePath": "\\\\<sccm-server>\\SMS_ABC\\Client",
  "MinimumClientVersion": "5.00.0000.0000",
  "TimeSource": "<domain-controller-fqdn>",

  "MinFreeDiskSpaceMb": 2048,
  "MaxTimeSkewSeconds": 120,
  "MaxPolicyAgeDays": 3,
  "MaxHeartbeatAgeDays": 7,

  "RequiredServices": ["CcmExec", "BITS", "wuauserv", "Winmgmt"],
  "ReportPath": "C:\\Windows\\CCM\\Logs\\SccmClientHealth.json",
  "ReportSharePath": "\\\\<fileserver>\\ClientHealth$"
}

Defaults are deliberately conservative. They describe a client that is clearly broken rather than merely slow, because a health tool that cries wolf gets disabled within a fortnight — the same fate as the unverified one.

Clock skew, while we are here

One more that hides well. Kerberos tolerates five minutes of drift by default. Past that, client authentication to the management point fails while every service still reports as Running and nothing anywhere says "time".

w32tm /stripchart /computer:<time-source> /samples:1 /dataonly

Worth having in any health check, and worth threshold-ing well below the Kerberos limit so you hear about it before it breaks anything.

Testing Windows tooling on Linux

Every Windows and SCCM call goes through one file of thin wrappers. Not for purity — so the checks can be mocked and the entire suite runs on a Linux build agent with no endpoint and no ConfigMgr infrastructure anywhere near it.

That constraint paid for itself three times, each time finding a real bug rather than test-harness friction.

One. The dependency resolver crashed with Value cannot be null. (Parameter 'key') for any check declared with no dependencies — a null slipping into a pipeline feeding ContainsKey. That would have taken down an entire run on a real endpoint, with an error message pointing nowhere useful.

Two. Parsing WMI timestamps with the obvious API:

[Management.ManagementDateTimeConverter]::ToDateTime($entry.LastTriggerTime)
# System.Management currently is only supported for Windows desktop applications.

Fair enough on Linux, but the real cost is that it makes every timestamp-dependent check permanently untestable in CI. DMTF format is yyyyMMddHHmmss.ffffffsUUU, where the trailing signed number is the offset from UTC in minutes. Twenty lines of regex and TryParseExact replaced it, and those checks now have real unit tests — including the timezone handling the original silently got right and I would never have verified.

Three. The build splices the modules into one deployable file, and -replace treats $_ and $1 in the replacement string as capture-group substitutions. Feeding it PowerShell source shredded 2,178 lines. [string]::Replace is literal; the regex operator is not.

The build runs the source tests, produces the single file, then runs a separate suite against that artefact. String-splicing a build is exactly the step that quietly produces something which parses fine and no longer behaves.

What about ConfigMgr Client Health?

Worth being straight about the prior art. Anders Rødland's ConfigMgr Client Health has been the community standard for years, it is genuinely good, and it checks more things than are listed here. If you have it deployed and working, keep it.

The catch is that its last substantive commit was January 2021, with around thirty open issues. The maintained community forks — paulwetter/ConfigMgrClientHealthV3 and marobin/ConfigMgrClientHealth-2023 — are real but single-maintainer with very little adoption.

For a five-year-unmaintained 4,000-line monolith with a SQL webservice attached, something smaller that can actually be tested is a reasonable trade.

The honest caveat

The engine, the checks and the build pipeline are tested and green — sixty-five tests, running anywhere. The SCCM-specific WMI paths, registry locations and repair commands follow documented behaviour, but they have not yet been validated against a live ConfigMgr environment.

So: audit mode against a pilot group first, findings compared against machines you already know the state of, and only then let it change anything. That is the difference between a tool you can deploy and a tool you have deployed, and it is not worth blurring.

The takeaway

If you are chasing SCCM clients by hand, four things are worth stealing regardless of what you run:

  1. Check provisioning mode. One registry read, and it explains a startling share of "the client looks fine but nothing deploys".
  2. Copy the script local, schedule it, do not run it off the share. The share is a distribution point, not a runtime dependency.
  3. Make the results come back. One JSON per machine on a share you already have. The machines that stop reporting are the ones you most need to know about.
  4. Verify every repair by re-running the test that failed. An unverified fix is a guess with a green tick next to it.