Skip to content

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.stub stays 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>\Application exists → 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).