29 July 2026¶
3 changes:
- Worker liveness: WorkerLock + WorkerReloader
- SPA scaffolding: a build-less app-shell with asset cache-busting
- An app-specific Application subclass is now optional
Worker liveness: WorkerLock + WorkerReloader¶
Long-running CLI workers get two standalone primitives — a robust single-instance
lock with a built-in heartbeat, and a reloader that keeps a daemon current across
deploys. CommandBase delegates its lock lifecycle to WorkerLock, so every
console worker gains the same guarantees without changing a line; a bespoke worker
script uses either class directly.
See the Console Guide for the full API tables and examples.
Added¶
Pramnos\Console\WorkerLock — a single-instance lock + heartbeat that works
where advisory flock() silently doesn't (notably Docker bind mounts on macOS,
where two processes both "acquire" the same lock with no error). The lock is a JSON
file whose atomic creation (fopen($path, 'x') → O_CREAT|O_EXCL) is the mutex,
and which doubles as the worker's heartbeat.
$lock = new WorkerLock('chat-worker', WorkerLock::defaultPath('chat-worker'));
if (!$lock->acquire($takenOverFrom)) {
exit("another worker holds the lock\n");
}
while ($working) {
/* ... one job ... */
if (!$lock->heartbeat(['jobs_processed' => ++$n])) break; // taken over → stop
if ($lock->stopRequested()) break; // <path>.stop sentinel
}
$lock->release();
A holder is respected only when it is both alive and progressing — its pid is
alive (checked on the same host) and its heartbeat is fresh within the stale
window. A crashed holder (dead pid) or a wedged one (alive but no longer
heartbeating — the case a plain pid check misses) is taken over, with
$takenOverFrom describing whom for logging. WorkerLock::pidFromFile() reads the
JSON pid and falls back to a legacy plain-text "<pid>\n..." lock.
Pramnos\Console\WorkerReloader — keeps a daemon from running forever on the
code and configuration it started with. Both inputs are constructor parameters (no
application coupling): the watched paths and a settings-version resolver
callback.
$reloader = new WorkerReloader(ROOT, ['src', 'worker.php', 'composer.lock'],
fn () => MySettings::versionStamp());
$reloader->baseline();
// between jobs:
if ($reloader->settingsChanged()) { /* rebuild snapshot objects in place */ }
if ($reloader->codeChanged()) {
$lock->release();
WorkerReloader::isSupervised() ? exit(0) : /* respawn self */ ;
}
codeChanged() fingerprints watched files' size+mtime; settingsChanged() fires
once per stamp move; isSupervised() detects systemd/supervisord/WORKER_SUPERVISED
so the worker knows whether exiting reloads or just stops.
Notes (BC)¶
CommandBase::startJob()/heartbeat()/endJob() now acquire/refresh/release a
WorkerLock JSON lock, and both CommandBase::readPidFromLockFile() and
DaemonOrchestrator::readWorkerPidFromLockFile() delegate to
WorkerLock::pidFromFile(). checkIfRunning() keeps its pid+mtime guard, now
JSON-aware through the shared parser, and endJob() still removes the file — so the
single-instance guard and the orchestrator's dead-pid / live-pid / stale-lock
recovery all behave exactly as before, including for a lock left behind by an older
build across an upgrade.
Tests¶
tests/Unit/Console/WorkerLockTest.php and WorkerReloaderTest.php (20 new cases),
plus the existing CommandBaseTest / DaemonOrchestratorTest / ProcessQueueCommandTest
lock suites unchanged and green.
SPA scaffolding: a build-less app-shell with asset cache-busting¶
The "Services + API + SPA" scaffolding gains a PHP app-shell stub so a build-less SPA gets correct cache-busting out of the box, and the styles guide now spells out the shell-vs-assets cache discipline.
See the Application Styles Guide.
Why¶
The only SPA shell stub was a static spa-index.html.stub with no cache-busting
at all — fine if you run a build tool that emits content-hashed filenames, but a
foot-gun for the build-less app the style is meant to make easy: on deploy the
browser keeps serving the old app.js against the new API. The framework itself
punted on versioning, so every app re-invented it (or shipped the bug).
Added¶
scaffolding/templates/spa-index.php.stub— a one-file PHP app-shell (now the documented default). It stamps each asset URL with the file's modification time (app.css?v=…) using__DIR__, so it is self-contained wherever it is copied: a deploy changes the mtime → the browser refetches, unchanged assets stay cached far-future. It is explicitly not an MVC view (no theme/getView()); it is a page a thin front controller renders for unmatched non-API GETs.- The existing
spa-index.html.stubstays for build-tool setups, where the content hash in the filename is already the cache-buster.
Docs¶
The styles guide's "The SPA front end" section now explains the pattern the major
frameworks share — dynamic HTML shell (never cached) + fingerprinted assets
(cached hard, busted on change) — and which stub to pick by whether you run a
front-end build, with the Cache-Control headers to set on each.
Notes¶
Additive and docs-only on the runtime side (stubs are copy-manually starting points, not wired into a command), so nothing changes for existing apps.
An app-specific Application subclass is now optional¶
Application::getInstance() falls back to the base kernel when an app declares a
namespace but ships no <Namespace>\Application subclass — so an app that needs no
custom kernel behaviour no longer has to carry an empty one.
Before¶
getInstance() built \<namespace>\Application from app.php's namespace and
instantiated it only if the class existed — with no fallback. An app that set
namespace (as every real app does) therefore had to provide a
<Namespace>\Application class, even a one-line empty subclass, or getInstance()
returned nothing (and a namespace-less config resolved to \Pramnos\Application,
a namespace rather than a class — also nothing). The empty subclass was pure
boilerplate the framework forced on every app.
After¶
Resolution is extracted into a testable
Application::resolveApplicationClass(array $config): string:
<Namespace>\Applicationexists → use it (unchanged for apps with a custom kernel).- namespace set but no such class → the base
Pramnos\Application\Application. - no namespace → the base kernel.
So an app can delete its empty Application subclass and keep working; the base
kernel is instantiated and getInstance() behaves exactly as before otherwise.
Only previously-broken paths change (missing class / absent namespace now resolve
instead of returning nothing) — additive and BC.
Consequential fix — service:policy-engine guard¶
Because getInstance() no longer returns null for an app without a kernel
subclass, a command that used !$app instanceof Application to mean "no usable
application" would proceed into a kernel with no database. PolicyEngine::execute()
now guards on a usable database ($app->database instanceof Database), so it
still fails gracefully ("No application instance available", Command::FAILURE)
instead of crashing — a more accurate check regardless of the fallback.
Tests¶
tests/Unit/Pramnos/Application/ApplicationClassResolutionTest.php — no-namespace,
empty-namespace and missing-class all fall back to the base kernel; an app that
ships its own kernel subclass is still honoured (via a fixture subclass).