pilotphp / runtime
Transport-neutral execution kernel, worker lifecycle, and runtime plan compilation for the PilotPHP Agent-First framework.
Requires
- php: ^8.5
- pilotphp/contracts: ^0.1.4
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.88
- phpstan/phpstan: ^2.1
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^11.5
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:
- Composition —
RuntimePackage.register()→RuntimeBuildStage→runtime/plan.json(build time only). - 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 / LifecycleCoordinator | Approved execution kernel and lifecycle sequencing |
RuntimePlan / RuntimePlanLoader | Finalized lifecycle plan (identifiers only) |
RuntimeBuildStage | Build-time compilation of lifecycle declarations |
Invocation | An immutable envelope: operation name, typed input, context |
RequestContext | Per-invocation ID, deadline, cancellation, metadata |
Deadline | Value object answering "has it expired, how long is left" |
CancellationSource / NeverCancelled | Cooperative cancellation, split into a write side and a read side |
WorkerLifecycle | Boot once, process many, shut down once — the one entry point an adapter uses |
WorkerState / WorkerHealth | Where the worker is in its life, and whether it may still be used |
CleanupFailure / CleanupStage | What failed during cleanup, and at which step |
ProcessingFailure / ProcessingFailureStage | What failed during the work itself, and at which step |
InvocationCleanupException | Both of the above at once, with neither lost |
RuntimePackage | This 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:
- A raw byte limit — 255, 512, and 255 bytes.
- Well-formed UTF-8.
- No character in the Unicode categories
Cc,Cf,Zl, orZp— every ASCII and C1 control, every format character, and both line separators. - 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.0once passed, the exact truncated count when it fits in anint,PHP_INT_MAXwhen 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
RuntimeInterfaceimplementation. 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.