Commits
The "Update" button now actually updates, and the connector updates itself
We closed the loop on dashboard updates: inventory refreshes are now real, core updates work again with WordPress 7.1, and the dashboard pushes connector updates to sites without needing to enter any wp-admin.
We had long had an "Update" button that did not update anything: it simply reproduced the latest inventory snapshot, even if that snapshot was up to two hours old. Today we closed that loop at both ends: the dashboard now genuinely refreshes when you ask it to, and it also pushes connector updates to sites without anyone entering wp-admin.
A button that merely looked active
The "Update" handler was, literally, a read from the dashboard's own database. There was not a single call to WordPress. Underneath, three layers were chained together: the worker only refreshed sites whose cache was more than two hours old, updating a plugin or the core did not invalidate anything, and the version columns in the listing were only populated by the "Check" action. Ironically, the connector was the honest one: its /info was always live. The listing and the workspace were telling two different stories about the same site.
The new setup: a single refresh service used by both the worker and the button, so the two paths cannot diverge; an endpoint for each site; invalidation after updating a plugin, theme, or the core; and a listing that distinguishes "no data" from "refresh pending." In the frontend, "Update sites" opens a dialog with a progress bar and works site by site, pausing for two seconds, retrying, and listing failed sites as skipped.
The smoke test caught a subtle detail: when invalidating, the updated_at column was refreshed automatically through SQLAlchemy's onupdate, causing the data's age to drop back to zero. A two-hour-old snapshot would have suddenly read "just now" precisely when the data mattered—the exact opposite of what we had decided. An explicit UPDATE preserves the age, and a test locks it down as an invariant: invalidating does not change when the data was read.
The core update that died instantly
The report was blunt: "When I update the core, it makes the request, but it seems not to perform the update." WordPress 7.1 changed the signature of Core_Upgrader::upgrade() and began requiring the update object; the connector called it without arguments, as earlier versions had allowed. The result was an argument error within 0.6 seconds and no version change. Because the plugin's fatal-error handler loses the race against WordPress's, the real detail was in the hosting provider's PHP log, which is not readily accessible. We found it by setting up a disposable WordPress installation on fxserver, using the same WordPress and PHP versions, and the log revealed the cause.
The fix passes the update offer object to the upgrader, remaining compatible with older versions, where it was optional; after a successful update, it refreshes the update_core transient—the bug announced in ADR-0001: the dashboard continued to see the update as pending until wp-cron ran—and the routes that invoke upgraders are wrapped in a guard that turns any error into a message the dashboard can read, instead of a cryptic page. On the debug WordPress installation, the call went from an immediate 500 to genuinely updating from 7.1.3 to 7.1.4.
The dashboard pushes the connector
With the core fixed, the other half of the problem remained: deploying the new connector meant entering the wp-admin of every site. Now the dashboard pushes its own ZIP through a new signed route, and the site installs it with Plugin_Upgrader without clearing the destination directory, so the connector can never be left half-installed. It is a push rather than a download for a simple reason: the connector does not store the dashboard's URL, only its secret, so it has nowhere to request the ZIP from.
Two issues that the test environment could not reveal were exposed by a real WordPress installation: PHP's zip:// wrapper separates the ZIP archive from the file with a hash, not a slash—with a slash, it returns false and the ZIP appears to be corrupt—and in a real WordPress installation, the required wp-admin include files were missing. The route guard pinpointed the problem cleanly, surfacing the actual error instead of the generic page.
Verified end to end in the disposable WordPress installation: the connector was upgraded from 1.13.0 to 1.14.0 by the dashboard, with the plugin operational and the site responding; then the stack was removed. The test environment runs 62 cases, and the API environment runs 172 tests. The nine production sites need a manual upgrade to 1.13.0; from then on, they update themselves.