pilotphp/runtime

Transport-neutral worker lifecycle primitives for the PilotPHP Agent-First framework.

Maintainers

Package info

gitlab.com/pilotphp/runtime

Issues

pkg:composer/pilotphp/runtime

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

0.0.2 2026-07-28 09:08 UTC

This package is not auto-updated.

Last update: 2026-07-28 22:03:48 UTC


README

Transport-neutral worker lifecycle primitives for the PilotPHP Agent-First framework.

A PilotPHP application runs in long-lived PHP workers: the process outlives the request, so anything one request leaves behind is still there for the next one. This package owns the small set of rules that keeps that safe — boot once, serve many, clean up after every invocation whatever happened, and stop the worker the moment cleanup itself cannot be trusted.

It is deliberately not a runtime. There is no loop here, no server, no transport. RoadRunner, gRPC, HTTP, and queue adapters are separate packages; they all reuse the same WorkerLifecycle so that a request means the same thing whichever one is driving.

Installation

composer require pilotphp/runtime

Requires PHP 8.5. Depends on nothing but PHP and pilotphp/contracts.

What it provides

InvocationAn immutable unit of work: operation name, typed input, context
RequestContextPer-invocation ID, deadline, cancellation, metadata
DeadlineValue object answering "has it expired, how long is left"
CancellationSource / NeverCancelledCooperative cancellation, split into a write side and a read side
WorkerLifecycleBoot once, process many, shut down once — the one entry point an adapter uses
WorkerState / WorkerHealthWhere the worker is in its life, and whether it may still be used
CleanupFailure / CleanupStageWhat failed during cleanup, and at which step
ProcessingFailure / ProcessingFailureStageWhat failed during the work itself, and at which step
InvocationCleanupExceptionBoth of the above at once, with neither lost
RuntimePackageThis package's own descriptor

Every type's intended consumer and stability level is listed in docs/public-api.md, including the ones that are @internal and deliberately absent from the table above — InvocationProcessor, CancellationToken, CancellationState, and Identifier.

What it does not provide

No kernel, DI container, config loader, RoadRunner worker, HTTP server, gRPC server, queue consumer, event loop, coroutine scheduler, CLI, Blueprint, ORM, or Composer package discovery. Each belongs to its own package — see docs/architecture.md.

Invocation

An adapter translates whatever arrived on the wire into an operation name, a typed input object, and a context. From there, every transport looks the same to the kernel.

use PilotPHP\Runtime\Context\RequestContext;
use PilotPHP\Runtime\Invocation\Invocation;

$context = new RequestContext(
    invocationId: 'inv-01H8XW',
    deadline: new DateTimeImmutable('+30 seconds'),
    cancellation: $source->token(),
    metadata: ['X-Trace-Id' => ['abc-123']],
);

$invocation = new Invocation('user.create', new CreateUser('ada@example.com'), $context);

$invocation->operationName();           // 'user.create'
$invocation->input();                   // CreateUser — statically typed, not `object`
$invocation->context()->invocationId(); // 'inv-01H8XW'

Identity lives on the context and is read from there. Invocation declares exactly the accessors InvocationInterface does and no others. It briefly had an id() reading through to the context — it could not disagree with anything, but it was not on the contract, so code written against it worked with this class and broke against every other implementation, including the ones transport packages will bring.

The input type is preserved for static analysis — input() returns CreateUser, not a bare object — via a covariant generic. PHPStan runs at level max over this package and its tests to keep that true.

Identifiers are validated

An invocation ID, an operation name, and a metadata name are held to three rules at construction, in this order — anything else raises InvalidInvocationException:

  1. A raw byte limit — 255, 512, and 255 bytes, measured before trimming.
  2. Well-formed UTF-8.
  3. No character in the Unicode categories Cc, Cf, Zl, or Zp — every ASCII and C1 control, every format character, and both line separators.

These values are interpolated into exception messages and written to operator logs, so a \r\n in one forges a log line, a U+2028 forges one in every viewer that treats it as a break, an ESC reaches a terminal as a control sequence, and a U+202E makes an identifier render as something other than what it is. Refusing them at the boundary makes every consumer downstream safe at once.

No mbstring is required — the encoding check is preg_match('//u', …), and the fast path is a single scan for anything outside printable ASCII, so ordinary identifiers never reach the Unicode rules at all. Ordinary Unicode passes through untouched: identifiant-créé, заказ-создан, and 注文.作成 are all perfectly good IDs, they just count more bytes than characters. Plain spaces (Zs) are fine too.

Length is raw because the alternative did not bound anything: measured after trimming, a megabyte of spaces followed by id came to two bytes and was accepted. Padding that fits the raw limit is still trimmed as before.

Everything is refused before trimming, so a trailing \n is an error rather than something silently removed: an ID that arrives with a line terminator on it means the adapter mis-parsed its wire format, and repairing this package's copy would leave the adapter's own copy broken.

Nothing prints an identifier this package did not validate itself. That is why InvocationDeadlineExceededException names only the deadline: the runtime is typed against RequestContextInterface, and only RequestContext runs these rules — a transport's own context or a decorator satisfies the contract while returning whatever it likes. Correlation is the adapter's or the logger's job, as a structured field.

RequestContext

Four things: identity, deadline, cancellation, metadata. Nothing else.

$context->invocationId();  // string
$context->deadline();      // ?DateTimeImmutable, exactly as the transport reported it
$context->cancellation();  // CancellationInterface
$context->metadata();      // array<string, list<string>>

Metadata names keep their case and are not normalized by HTTP rules — this context is not an HTTP message, and baking one transport's naming into every other transport is how a framework ends up with getHeader() on its queue consumer. Values are always a list of strings; an empty list is allowed. The returned array is a copy, so nothing a caller does to it can reach the context.

RequestContext is not a service locator. No container, no current user, no logger, no tracer, no mutable attribute bag — see docs/architecture.md for why that line is drawn hard.

Cancellation

Cancellation is split in two so that the ability to observe it and the ability to cause it are different objects. The adapter keeps the source; application code only ever sees the token.

use PilotPHP\Runtime\Cancellation\CancellationSource;

$source = new CancellationSource();
$token = $source->token();          // CancellationInterface; goes into the RequestContext

$token->isCancelled();              // false
$source->cancel(new ClientGoneAway()); // the adapter noticed the caller left
$token->isCancelled();              // true
$token->throwIfCancelled();         // RuntimeCancellationException, reason as `previous`

Two names are all this takes: CancellationSource to create and cancel, CancellationInterface to hold and poll. The concrete CancellationToken behind token() is @internal — its only constructor takes the internal CancellationState, and anything able to satisfy that would already hold the cancel() the split exists to withhold. Nothing about how cancellation behaves depends on naming the class.

Cancellation is cooperative: nothing here interrupts running PHP. Use NeverCancelled::Instance when an invocation genuinely cannot be cancelled — an enum case, so it costs no allocation on a path a worker takes once per invocation.

Source and token share an internal state object rather than referencing each other, so the pair a worker creates and drops per invocation leaves no reference cycle behind — measured by benchmarks/cancellation-churn.php. See docs/cancellation.md.

WorkerLifecycle

use PilotPHP\Runtime\Worker\WorkerLifecycle;

$worker = new WorkerLifecycle($kernel, $requestScope);

$worker->boot();                    // once

while ($request = $transport->receive()) {      // the adapter's loop, not ours
    try {
        $result = $worker->process($invocation);
    } catch (Throwable $error) {
        $poisoned = $worker->isPoisoned();      // before any transport call

        $transport->fail($request, $error);

        if ($poisoned) {
            break;                  // the worker is finished; stop taking work
        }

        continue;
    }

    $transport->respond($request, $result);     // a separate try in real code
}

$worker->shutdown();                // once, from a finally

return $worker->isPoisoned() ? 1 : 0;

Note that process() and respond() are not in one try. A response that fails to send is a transport problem; running it through the worker's health check treats a hung-up caller as a broken worker, and answers an already-answered request with a second, contradictory response. Each stage of an adapter loop gets its own try — the full shape, with shutdown() guaranteed by a finally, is in docs/runtime-adapters.md.

WorkerLifecycle is the only supported way to execute a worker. It takes the kernel and the request scope and builds its own processor around them: that is the only way to be sure boot(), invoke(), reset(), and shutdown() all address the same kernel instance.

Check health after every exception

The if ($worker->isPoisoned()) break; above is load-bearing, and the tempting shortcut is wrong. Catching InvocationCleanupException and RuntimePoisonedException and treating everything else as an ordinary failed request looks exhaustive, but a requestScope->enter() that throws poisons the worker and then rethrows the container's own error, unwrapped — a plain RuntimeException, indistinguishable from a handler's. Classify by type and you report it as a normal failure, come back round, and call receive() on a worker that is already finished; the request that arrives has left the transport before anything refuses it.

Worker health is the single source of truth, it is monotonic, and it is derived from what happened rather than from what an adapter thought to enumerate. See docs/runtime-adapters.md, where the full contract is written out and an integration test drives it.

State and health are separate

$worker->state();       // WorkerState: Created, Booting, Running,
                        //              Processing, Stopping, Stopped
$worker->health();      // WorkerHealth: Healthy or Poisoned
$worker->isPoisoned();  // bool

state() is the stage of the worker's life; health() is whether it may still be used. They are separate because every worker ends up Stopped, and a poisoned worker that shut down cleanly must not look like a successful one — the process exit code depends on the difference. health() is monotonic: nothing returns a worker to Healthy.

Booting twice, processing before boot, or shutting down a worker that never booted throws InvalidWorkerStateException. A failed boot throws WorkerBootException; a failed teardown throws WorkerShutdownException. Both poison the worker and keep the kernel's original error as previous. See docs/lifecycle.md.

Re-entrant calls are refused

While the kernel is booting the worker is WorkerState::Booting; while an invocation is in flight it is WorkerState::Processing. A kernel, bootstrap step, service, or error handler that calls back into boot(), process(), or shutdown() from inside either gets an InvalidWorkerStateException instead of a second boot of the whole application, a second invocation sharing the first one's request scope, or a teardown pulled out from under running code. The processing state is restored in a finally, so an invocation that throws still leaves the worker usable.

Booting exists because without it a nested boot() was simply allowed: the worker stayed Created for the whole of the kernel's boot, so the guard saw nothing wrong, the kernel booted twice, and the worker still came out Running and Healthy with no record that anything had happened.

What one invocation does

process() runs exactly one invocation:

poisoned? → cancelled? → deadline passed? → scope enter → kernel invoke
         → kernel reset → scope reset → scope leave

The three refusals happen before any setup, so work nobody is waiting for costs almost nothing. An invocation that is refused for a passed deadline throws InvocationDeadlineExceededException and never reaches the kernel.

The last three steps are always attempted, in that order, even after the invocation threw and even after one of them threw. See docs/cleanup.md.

The class carrying this out, InvocationProcessor, is @internal. WorkerLifecycle builds one and nothing else should: driving it directly skips the boot check, the re-entrancy refusal, and the shutdown ordering — and, worst of the three, its poisoning never reaches a WorkerHealth, so the adapter reading isPoisoned() for its exit code is told a broken worker ended cleanly.

Cleanup failure

When cleanup fails, both stories survive: what broke the request, and what broke the worker.

try {
    $result = $worker->process($invocation);
} catch (InvocationCleanupException $failure) {
    $primary = $failure->primaryFailure();   // ?ProcessingFailure

    $primary?->stage();   // ProcessingFailureStage::KernelInvoke
                          //   or ::RequestScopeEnter
    $primary?->error();   // the original Throwable, unwrapped

    foreach ($failure->cleanupFailures() as $cleanupFailure) {
        $cleanupFailure->stage();  // CleanupStage::KernelReset, ...
        $cleanupFailure->error();  // the original Throwable, unwrapped
    }
}

primaryFailure() is typed rather than a bare ?Throwable because the two things it can report are different diagnoses pointing at different components: kernel.invoke means the application's handler threw, request_scope.enter means the container could not open a scope and the kernel was never called at all. The accessor this replaces was called invocationError(), and it returned the second under a name that asserted the first.

The message names the phase and the failed stages and nothing else — no input, no metadata, no result, no IDs. This exception lands in operator logs, which is the wrong place for a caller's payload.

Poisoned worker

A worker whose cleanup failed — or whose request scope failed to open — is poisoned: its state can no longer be trusted to be clean, so it refuses every later invocation with RuntimePoisonedException and must be terminated. There is no recover() and there will not be one; the failure being recorded is "we could not clean up", and retrying does not establish that we did. A supervisor starts a replacement worker.

Shutting a poisoned worker down is still allowed and still orderly, and shutting down does not clear the verdict:

$worker->state();       // WorkerState::Stopped   — it ended
$worker->health();      // WorkerHealth::Poisoned — it did not end well
$worker->isPoisoned();  // true → the adapter exits non-zero

The refusal touches nothing on the rejected invocation: no context(), no ID, no input. A worker that cannot be trusted to serve a request cannot be trusted to interrogate one either, so RuntimePoisonedException names no request — correlation belongs to the adapter's logging context.

Limitations

  • No loop. This package never fetches an invocation. Adapters do; see docs/runtime-adapters.md.
  • Deadlines do not interrupt anything. A deadline is checked before the kernel is invoked. Once PHP is running, nothing here stops it — no timer, no signal, no fiber. Long operations must poll cancellation themselves.
  • remainingMilliseconds() saturates. 0 once passed, the exact truncated count when it fits in an int, PHP_INT_MAX when the deadline is further out than that can express. It never returns a float and never throws; a budgeting helper must not fail on a distant deadline.
  • Cancellation is cooperative for the same reason.
  • One invocation at a time. No concurrency, no scheduler; a worker is single-threaded by design, and re-entrant process() is refused rather than queued.
  • A request scope that fails to open takes the worker out of service. The contracts promise nothing about what a partially failed enter() left behind, so the conservative reading is the only safe one.
  • No RuntimeInterface implementation. A runtime needs a source of invocations, and this package deliberately has none.

Development

make install
make check      # validate + cs-check + analyse + test
make benchmark  # measurement only, never blocks