pilotphp/runtime

Transport-neutral execution kernel, worker lifecycle, and runtime plan compilation for the PilotPHP Agent-First framework.

Maintainers

Package info

gitlab.com/pilotphp/runtime

Issues

pkg:composer/pilotphp/runtime

Transparency log

Statistics

Installs: 28

Dependents: 5

Suggesters: 0

Stars: 0

0.1.4 2026-08-02 19:19 UTC

This package is auto-updated.

Last update: 2026-08-02 16:22:01 UTC


README

Transport-neutral execution kernel, worker lifecycle, and runtime plan compilation 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 two axes:

  1. CompositionRuntimePackage.register()RuntimeBuildStageruntime/plan.json (build time only).
  2. Execution — finalized plan + collaborators → RuntimeKernel + WorkerLifecycle (runtime; does not re-run composition).

The execution axis keeps workers 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 transport loop. 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.

Blocker: RuntimeKernel::invoke() throws InvocationDispatchBoundaryException until pilotphp/contracts publishes InvocationDispatcherInterface. See docs/blockers/invocation-dispatcher.md.

Installation

composer require pilotphp/runtime

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

Package discovery

Build tooling discovers this package through the fixed manifest path pilot/package.json (schema version 1). Composer declares no extra.pilotphp.manifest pointer.

pilot/package.json
→ entrypoint: PilotPHP\Runtime\RuntimePackage
→ PackageInterface
→ descriptor()
→ register()  → RuntimeBuildStage
→ compiled runtime plan

The manifest names the single package entrypoint, PilotPHP\Runtime\RuntimePackage. Build tooling validates and constructs it without required constructor arguments, reads descriptor(), invokes register(), and compiles declarations into runtime/plan.json.

Package discovery never runs on the request hot path. See docs/package-build-integration.md.

What it provides

RuntimeKernel / LifecycleCoordinatorApproved execution kernel and lifecycle sequencing
RuntimePlan / RuntimePlanLoaderFinalized lifecycle plan (identifiers only)
RuntimeBuildStageBuild-time compilation of lifecycle declarations
InvocationAn immutable envelope: 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 DI container, config loader, operation dispatcher, RoadRunner worker, HTTP server, gRPC server, queue consumer, event loop, coroutine scheduler, CLI, Blueprint, ORM, or transport loop. OperationDispatcherInterface belongs in pilotphp/operation (future), not here. 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 opaque values. Runtime validates but never repairs or normalizes them. An adapter must perform any transport-specific normalization before constructing the invocation model. They are held to these rules at construction — anything else raises InvalidInvocationException:

  1. A raw byte limit — 255, 512, and 255 bytes.
  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.
  4. Non-empty, with no Unicode separator (Z) at either edge.

These values may reach exception messages and 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.

Identifiers are protected against malformed UTF-8, control-character, bidi/format and line-separator injection. They are not pre-escaped for JSON, HTML, SQL, shell or an arbitrary custom log format. Pass identifiers as structured context and let the logger encode them:

$logger->info(
    'Invocation received.',
    [
        'invocation_id' => $context->invocationId(),
        'operation' => $invocation->operationName(),
    ],
);

Do not build JSON or log lines by manual string concatenation.

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. Edge validation also stays byte-wise when both boundaries are ASCII: only byte 0x20 can be an ASCII category-Z separator, so even Unicode in the middle does not require the Unicode edge regex. Ordinary Unicode passes through untouched: identifiant-créé, заказ-создан, and 注文.作成 are all perfectly good IDs, they just count more bytes than characters. Plain spaces and other Zs separators are allowed internally, but never at either edge.

Identifiers are opaque byte identities. Canonical Unicode normalization is not performed, so visually identical strings may have different byte sequences and remain different IDs. A security-sensitive adapter may impose a stricter UUID or ASCII policy before construction. The runtime does not require ext-intl.

Length is raw because every byte reaches the worker and must count toward the bound. Leading or trailing separators are rejected rather than trimmed. "id", " id", and "id " can never collapse into one runtime identity: the first is accepted and the other two are invalid. Lowercasing, Unicode normalization, symbol replacement, and whitespace collapsing are likewise absent.

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; subclasses normalized
$context->cancellation();  // CancellationInterface
$context->metadata();      // array<string, list<string>>

Metadata names keep their exact bytes and case and are not normalized by HTTP rules. Empty, separator-only, and leading/trailing-separator names are rejected; internal spaces remain valid. 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. Construction creates fresh value lists and removes explicit PHP references; changing either the input arrays or an array returned by metadata() cannot reach the context.

RequestContext validates structure and value types, not arbitrary wire payload size. Before constructing it, each adapter must limit metadata key count, values per key, individual value size, and total metadata size. HTTP, gRPC, and queue adapters may choose different limits.

A base DateTimeImmutable deadline is retained as-is; a subclass is copied to a base instance so overrides cannot alter its instant, timezone, or microseconds. Deadline applies the same conditional normalization to both expiresAt and an optional $now.

Invocation and RequestContext are immutable envelopes: their own bindings cannot be replaced after construction. They are not deeply immutable object graphs. An input DTO may be mutable, and cancellation is intentionally live observable state. The runtime does not clone arbitrary input objects or serialize them for copying; transport and application packages own input DTO immutability.

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.

A cancellation subscription is disposed after its invocation. If disposal fails, the adapter records that transport failure, exits non-zero, performs shutdown, and must not call receive() again. It does not poison an otherwise healthy WorkerLifecycle.

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. Its pre-release factory is parameterless: InvocationDeadlineExceededException::beforeInvoke(); correlation and deadline values belong in structured adapter logging, not in the exception.

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
    }
}

The cleanup-failure list is validated as a non-empty list and rebuilt into a canonical array. Caller-owned arrays and PHP references cannot change the message, previous, stages, or accessor result after construction.

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

PHP-CS-Fixer 3.88 or newer is required because the project uses @PHP8x5Migration. This library does not track composer.lock; dependency constraints are the source of truth, and a future CI setup should exercise both lowest and highest supported dependency resolutions.