From f5780534b318b722ae5f47401a73d79db3af07cc Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:13:30 +0000 Subject: [PATCH 01/19] fix: resolve console commands after global options Use Symfony ArgvInput command discovery instead of assuming the command is always argv[1]. Preserve the existing public Application API while correctly recognizing commands preceded by valueless global options or inline option values. Add focused coverage for long and short option prefixes. --- src/foundation/src/Application.php | 3 ++- .../ApplicationRunningInConsoleTest.php | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/foundation/src/Application.php b/src/foundation/src/Application.php index 06b3ca08a..d04383a81 100644 --- a/src/foundation/src/Application.php +++ b/src/foundation/src/Application.php @@ -28,6 +28,7 @@ use JsonException; use ReflectionClass; use RuntimeException; +use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\HttpKernel\Exception\HttpException; @@ -812,7 +813,7 @@ public function runningConsoleCommand(string|array ...$commands): bool } return in_array( - $_SERVER['argv'][1] ?? null, + (new ArgvInput)->getFirstArgument(), is_array($commands[0] ?? null) ? $commands[0] : $commands, true ); diff --git a/tests/Foundation/ApplicationRunningInConsoleTest.php b/tests/Foundation/ApplicationRunningInConsoleTest.php index 3d98caf84..e02c2f787 100644 --- a/tests/Foundation/ApplicationRunningInConsoleTest.php +++ b/tests/Foundation/ApplicationRunningInConsoleTest.php @@ -227,6 +227,24 @@ public function testRunningConsoleCommandMatchesSingleCommand() $this->assertTrue($app->runningConsoleCommand('migrate')); } + public function testRunningConsoleCommandMatchesCommandAfterLongOption(): void + { + $_SERVER['argv'] = ['artisan', '--env=production', 'migrate']; + $app = new Application; + + $this->assertTrue($app->runningConsoleCommand('migrate')); + $this->assertFalse($app->runningConsoleCommand('--env=production')); + } + + public function testRunningConsoleCommandMatchesCommandAfterShortOption(): void + { + $_SERVER['argv'] = ['artisan', '-v', 'queue:work']; + $app = new Application; + + $this->assertTrue($app->runningConsoleCommand('queue:work')); + $this->assertFalse($app->runningConsoleCommand('-v')); + } + public function testRunningConsoleCommandMatchesOneOfMultiple() { $_SERVER['argv'] = ['artisan', 'migrate']; From fa79e976045e53390b8583ccd87534b60986dd50 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:13:37 +0000 Subject: [PATCH 02/19] fix: classify Testbench server commands through ArgvInput Create one Symfony ArgvInput before Testbench application bootstrap and use its first argument for serve and watch mode detection. Pass the same input instance into the console kernel so bootstrap classification and command execution cannot disagree. --- src/testbench/hypervel/artisan | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/testbench/hypervel/artisan b/src/testbench/hypervel/artisan index c32b3dc84..743ac4b8a 100644 --- a/src/testbench/hypervel/artisan +++ b/src/testbench/hypervel/artisan @@ -49,20 +49,20 @@ if ( ! defined('SWOOLE_HOOK_FLAGS') && define('SWOOLE_HOOK_FLAGS', SWOOLE_HOOK_ALL); $httpBootstrapCommands = ['serve', 'watch']; +$input = new Symfony\Component\Console\Input\ArgvInput(); -if (in_array($_SERVER['argv'][1] ?? null, $httpBootstrapCommands, true)) { +if (in_array($input->getFirstArgument(), $httpBootstrapCommands, true)) { putenv('APP_RUNNING_IN_CONSOLE=false'); $_ENV['APP_RUNNING_IN_CONSOLE'] = 'false'; $_SERVER['APP_RUNNING_IN_CONSOLE'] = 'false'; } -(function () { +(function () use ($input) { /** @var Hypervel\Contracts\Foundation\Application $app */ $app = require BASE_PATH . '/bootstrap/app.php'; $kernel = $app->make(Hypervel\Contracts\Console\Kernel::class); - $input = new Symfony\Component\Console\Input\ArgvInput(); $output = new Symfony\Component\Console\Output\ConsoleOutput(); $status = $kernel->handle($input, $output); From 70bd955e73fc14a56cd9fb1e5b1e203e2f58c6e7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:13:44 +0000 Subject: [PATCH 03/19] feat: expose request-owned start time Capture the precise worker-side request timestamp during Request initialization and normalize the standard server time fields when they are absent. Expose the stable instant as a lazily-created CarbonImmutable through startedAt(), regenerate the Request facade metadata, and cover construction, conversion, duplication, bridge precision, mutation stability, and kernel termination boundaries. --- src/http/src/Request.php | 19 +++++ src/support/src/Facades/Request.php | 1 + tests/Foundation/Http/KernelTest.php | 2 + tests/Http/HttpRequestTest.php | 111 +++++++++++++++++++++++++ tests/HttpServer/RequestBridgeTest.php | 3 + 5 files changed, 136 insertions(+) diff --git a/src/http/src/Request.php b/src/http/src/Request.php index 0330d545b..482327fe5 100644 --- a/src/http/src/Request.php +++ b/src/http/src/Request.php @@ -10,6 +10,7 @@ use Hypervel\Contracts\Support\Arrayable; use Hypervel\Session\SymfonySessionDecorator; use Hypervel\Support\Arr; +use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Collection; use Hypervel\Support\Str; use Hypervel\Support\Traits\Conditionable; @@ -66,6 +67,11 @@ class Request extends SymfonyRequest implements Arrayable, ArrayAccess self::HEADER_X_FORWARDED_PREFIX => 'X_FORWARDED_PREFIX', ]; + /** + * The timestamp when the server started processing the request. + */ + protected float $startedAtTimestamp; + /** * The decoded JSON content for the request. */ @@ -147,6 +153,11 @@ class Request extends SymfonyRequest implements Arrayable, ArrayAccess #[Override] public function initialize(array $query = [], array $request = [], array $attributes = [], array $cookies = [], array $files = [], array $server = [], $content = null): void { + $this->startedAtTimestamp = (float) ($server['REQUEST_TIME_FLOAT'] ?? microtime(true)); + + $server['REQUEST_TIME_FLOAT'] ??= $this->startedAtTimestamp; + $server['REQUEST_TIME'] ??= (int) $this->startedAtTimestamp; + parent::initialize($query, $request, $attributes, $cookies, $files, $server, $content); $this->trustedProxiesValue = []; @@ -262,6 +273,14 @@ public function instance(): static return $this; } + /** + * Get when the server started processing the request. + */ + public function startedAt(): CarbonImmutable + { + return CarbonImmutable::createFromTimestamp($this->startedAtTimestamp); + } + /** * Get the request method. */ diff --git a/src/support/src/Facades/Request.php b/src/support/src/Facades/Request.php index e1c5dc82c..a123574c6 100644 --- a/src/support/src/Facades/Request.php +++ b/src/support/src/Facades/Request.php @@ -13,6 +13,7 @@ * @method static void setTrustedHosts(array $hostPatterns) * @method static string[] getTrustedHosts() * @method static \Hypervel\Http\Request instance() + * @method static \Hypervel\Support\CarbonImmutable startedAt() * @method static string method() * @method static \Hypervel\Support\Uri uri() * @method static string root() diff --git a/tests/Foundation/Http/KernelTest.php b/tests/Foundation/Http/KernelTest.php index 9452502d4..6f2ebfb26 100644 --- a/tests/Foundation/Http/KernelTest.php +++ b/tests/Foundation/Http/KernelTest.php @@ -402,6 +402,7 @@ public function testDurationHandlerReceivesConvertedImmutableStartTimeFromContex $kernel = new Kernel($app, $router); $request = Request::create('/'); + $transportStartedAt = $request->startedAt(); $captured = null; CarbonImmutable::setTestNow('2026-07-23 12:34:56 UTC'); @@ -420,6 +421,7 @@ public function testDurationHandlerReceivesConvertedImmutableStartTimeFromContex $this->assertNotSame($original, $captured); $this->assertSame($original?->getTimestamp(), $captured?->getTimestamp()); $this->assertNull($kernel->requestStartedAt()); + $this->assertTrue($transportStartedAt->equalTo($request->startedAt())); } public function testRequestStartedAtIsIsolatedBetweenConcurrentCoroutines(): void diff --git a/tests/Http/HttpRequestTest.php b/tests/Http/HttpRequestTest.php index fbd099895..dde78b512 100644 --- a/tests/Http/HttpRequestTest.php +++ b/tests/Http/HttpRequestTest.php @@ -38,6 +38,117 @@ public function testInstanceMethod(): void $this->assertSame($request, $request->instance()); } + public function testStartedAtUsesThePreciseServerTimestamp(): void + { + $request = new Request(server: [ + 'REQUEST_TIME_FLOAT' => 1_700_000_000.123456, + ]); + + $this->assertSame(1_700_000_000.123456, $request->server('REQUEST_TIME_FLOAT')); + $this->assertSame(1_700_000_000, $request->server('REQUEST_TIME')); + $this->assertSame(1_700_000_000_123_456.0, $request->startedAt()->getPreciseTimestamp(6)); + + $request = new Request(server: [ + 'REQUEST_TIME_FLOAT' => '1700000000.654321', + ]); + + $this->assertSame('1700000000.654321', $request->server('REQUEST_TIME_FLOAT')); + $this->assertSame(1_700_000_000_654_321.0, $request->startedAt()->getPreciseTimestamp(6)); + } + + public function testStartedAtCapturesAndNormalizesAPreciseFallback(): void + { + $before = microtime(true); + $request = new Request; + $after = microtime(true); + + $startedAtTimestamp = $request->server('REQUEST_TIME_FLOAT'); + + $this->assertIsFloat($startedAtTimestamp); + $this->assertGreaterThanOrEqual($before, $startedAtTimestamp); + $this->assertLessThanOrEqual($after, $startedAtTimestamp); + $this->assertSame((int) $startedAtTimestamp, $request->server('REQUEST_TIME')); + $this->assertSame( + round($startedAtTimestamp * 1_000_000), + $request->startedAt()->getPreciseTimestamp(6) + ); + } + + public function testStartedAtReturnsTheSameInstantOnEveryCall(): void + { + $request = new Request(server: [ + 'REQUEST_TIME_FLOAT' => 1_700_000_000.123456, + ]); + + $this->assertSame( + $request->startedAt()->getPreciseTimestamp(6), + $request->startedAt()->getPreciseTimestamp(6) + ); + } + + public function testStartedAtRemainsStableWhenTheServerBagChanges(): void + { + $request = new Request(server: [ + 'REQUEST_TIME_FLOAT' => 1_700_000_000.123456, + ]); + + $request->server->set('REQUEST_TIME_FLOAT', 1_800_000_000.654321); + + $this->assertSame(1_700_000_000_123_456.0, $request->startedAt()->getPreciseTimestamp(6)); + + $request->server->remove('REQUEST_TIME_FLOAT'); + + $this->assertSame(1_700_000_000_123_456.0, $request->startedAt()->getPreciseTimestamp(6)); + } + + public function testStartedAtIsPreservedByCloningAndDuplication(): void + { + $request = new Request(server: [ + 'REQUEST_TIME_FLOAT' => 1_700_000_000.123456, + ]); + + $clone = clone $request; + $duplicate = $request->duplicate(); + $duplicateWithServer = $request->duplicate(server: [ + 'REQUEST_TIME_FLOAT' => 1_800_000_000.654321, + ]); + + $this->assertSame(1_700_000_000_123_456.0, $clone->startedAt()->getPreciseTimestamp(6)); + $this->assertSame(1_700_000_000_123_456.0, $duplicate->startedAt()->getPreciseTimestamp(6)); + $this->assertSame(1_700_000_000_123_456.0, $duplicateWithServer->startedAt()->getPreciseTimestamp(6)); + $this->assertSame(1_800_000_000.654321, $duplicateWithServer->server('REQUEST_TIME_FLOAT')); + } + + public function testStartedAtIsPreservedWhenCreatingFromAnotherRequest(): void + { + $request = new Request(server: [ + 'REQUEST_TIME_FLOAT' => 1_700_000_000.123456, + ]); + + $createdFromRequest = Request::createFrom($request); + $createdFromBase = Request::createFromBase(SymfonyRequest::create('/', server: [ + 'REQUEST_TIME_FLOAT' => 1_800_000_000.654321, + ])); + + $this->assertSame(1_700_000_000_123_456.0, $createdFromRequest->startedAt()->getPreciseTimestamp(6)); + $this->assertSame(1_800_000_000_654_321.0, $createdFromBase->startedAt()->getPreciseTimestamp(6)); + } + + public function testInitializeStartsANewRequestTimingLifecycle(): void + { + $request = new Request(server: [ + 'REQUEST_TIME_FLOAT' => 1_700_000_000.123456, + ]); + + $request->initialize(server: [ + 'REQUEST_TIME_FLOAT' => 1_800_000_000.654321, + ]); + + $this->assertSame(1_800_000_000.654321, $request->server('REQUEST_TIME_FLOAT')); + $this->assertSame(1_800_000_000, $request->server('REQUEST_TIME')); + $this->assertSame(1_800_000_000_654_321.0, $request->startedAt()->getPreciseTimestamp(6)); + } + public function testMethodMethod(): void { $request = Request::create('', 'GET'); diff --git a/tests/HttpServer/RequestBridgeTest.php b/tests/HttpServer/RequestBridgeTest.php index c9b4146ad..9b3ba15c2 100644 --- a/tests/HttpServer/RequestBridgeTest.php +++ b/tests/HttpServer/RequestBridgeTest.php @@ -118,6 +118,7 @@ public function testServerParamsAreUppercased(): void 'server_protocol' => 'HTTP/1.1', 'remote_addr' => '192.168.1.1', 'remote_port' => '54321', + 'request_time_float' => 1_700_000_000.123456, ], header: ['host' => 'example.com'], ); @@ -126,6 +127,8 @@ public function testServerParamsAreUppercased(): void $this->assertSame('192.168.1.1', $request->server->get('REMOTE_ADDR')); $this->assertSame('54321', $request->server->get('REMOTE_PORT')); + $this->assertSame(1_700_000_000.123456, $request->server('REQUEST_TIME_FLOAT')); + $this->assertSame(1_700_000_000_123_456.0, $request->startedAt()->getPreciseTimestamp(6)); } public function testHeadersGetHttpPrefix(): void From 1be76313a86c03cb58e59fae90e76540cd52df0e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:13:50 +0000 Subject: [PATCH 04/19] fix: render health duration from the current request Pass the routed Request explicitly into both framework and Testbench health views and calculate render duration from its owned start instant. Remove the process-wide constant guard and add deterministic consecutive-request coverage so long-lived workers cannot report accumulated process uptime. --- .../src/Configuration/ApplicationBuilder.php | 1 + .../src/resources/health-up.blade.php | 4 +--- src/testbench/src/Workbench/Workbench.php | 5 +++-- .../RouteServiceProviderHealthTest.php | 17 ++++++++++++++ tests/Testbench/Workbench/DiscoversTest.php | 22 +++++++------------ 5 files changed, 30 insertions(+), 19 deletions(-) diff --git a/src/foundation/src/Configuration/ApplicationBuilder.php b/src/foundation/src/Configuration/ApplicationBuilder.php index 3052070fc..72b49bb37 100644 --- a/src/foundation/src/Configuration/ApplicationBuilder.php +++ b/src/foundation/src/Configuration/ApplicationBuilder.php @@ -207,6 +207,7 @@ protected function buildRoutingCallback( } return response(View::file(__DIR__ . '/../resources/health-up.blade.php', [ + 'request' => $request, 'status' => $health, ]), status: $status); }); diff --git a/src/foundation/src/resources/health-up.blade.php b/src/foundation/src/resources/health-up.blade.php index bb9c1b3e1..4538f5cd2 100644 --- a/src/foundation/src/resources/health-up.blade.php +++ b/src/foundation/src/resources/health-up.blade.php @@ -34,9 +34,7 @@

HTTP request received. - @if (defined('HYPERVEL_START')) - Response rendered in {{ round((microtime(true) - HYPERVEL_START) * 1000) }}ms. - @endif + Response rendered in {{ round($request->startedAt()->diffInMilliseconds()) }}ms.

diff --git a/src/testbench/src/Workbench/Workbench.php b/src/testbench/src/Workbench/Workbench.php index e80ab0d6e..047ab8f84 100644 --- a/src/testbench/src/Workbench/Workbench.php +++ b/src/testbench/src/Workbench/Workbench.php @@ -10,6 +10,7 @@ use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Database\Eloquent\Factories\Factory; use Hypervel\Foundation\Events\DiagnosingHealth; +use Hypervel\Http\Request; use Hypervel\Routing\Router; use Hypervel\Support\Collection; use Hypervel\Support\Env; @@ -129,7 +130,7 @@ public static function discoverRoutes(ApplicationContract $app, ConfigContract $ } if ($healthCheckEnabled === true) { - $router->get('/up', static function () { + $router->get('/up', static function (Request $request) { $exception = null; try { @@ -150,7 +151,7 @@ public static function discoverRoutes(ApplicationContract $app, ConfigContract $ return response( View::file( dirname(__DIR__, 3) . '/foundation/src/resources/health-up.blade.php', - ['status' => $health], + ['request' => $request, 'status' => $health], ), status: $status, ); diff --git a/tests/Integration/Foundation/Support/Providers/RouteServiceProviderHealthTest.php b/tests/Integration/Foundation/Support/Providers/RouteServiceProviderHealthTest.php index 5e98be65a..d9e1821e6 100644 --- a/tests/Integration/Foundation/Support/Providers/RouteServiceProviderHealthTest.php +++ b/tests/Integration/Foundation/Support/Providers/RouteServiceProviderHealthTest.php @@ -7,6 +7,7 @@ use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Foundation\Application; use Hypervel\Foundation\Events\DiagnosingHealth; +use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Facades\Event; use Hypervel\Support\Str; use Hypervel\Testbench\Attributes\WithConfig; @@ -41,6 +42,22 @@ public function testItCanLoadHealthPage(): void ->assertSee('Application up'); } + public function testItRendersTheCurrentRequestDuration(): void + { + CarbonImmutable::setTestNow('2026-08-06 12:00:00 UTC'); + + $this->call('GET', '/up', server: [ + 'REQUEST_TIME_FLOAT' => CarbonImmutable::now()->subSeconds(5)->getPreciseTimestamp(6) / 1_000_000, + ])->assertOk() + ->assertSee('Response rendered in 5000ms.'); + + $this->call('GET', '/up', server: [ + 'REQUEST_TIME_FLOAT' => CarbonImmutable::now()->subSeconds(2)->getPreciseTimestamp(6) / 1_000_000, + ])->assertOk() + ->assertSee('Response rendered in 2000ms.') + ->assertDontSee('Response rendered in 5000ms.'); + } + public function testItReturnsJsonWhenRequestExpectsJson(): void { $this->getJson('/up') diff --git a/tests/Testbench/Workbench/DiscoversTest.php b/tests/Testbench/Workbench/DiscoversTest.php index bc6c1262c..f5846ad42 100644 --- a/tests/Testbench/Workbench/DiscoversTest.php +++ b/tests/Testbench/Workbench/DiscoversTest.php @@ -8,11 +8,11 @@ use Hypervel\Database\Eloquent\Factories\Factory; use Hypervel\Foundation\Events\DiagnosingHealth; use Hypervel\Foundation\Testing\Concerns\InteractsWithViews; +use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Facades\Event; use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Testbench\Concerns\WithWorkbench; use Hypervel\Testbench\TestCase; -use Override; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; @@ -25,16 +25,6 @@ class DiscoversTest extends TestCase use InteractsWithViews; use WithWorkbench; - #[Override] - protected function setUp(): void - { - if (! \defined('HYPERVEL_START')) { - \define('HYPERVEL_START', microtime(true)); - } - - parent::setUp(); - } - #[Test] public function itCanResolveWebRoutesFromDiscovers() { @@ -56,12 +46,16 @@ public function itCanResolveWebRoutesUsingMacroFromDiscovers() } #[Test] - public function itCanResolveHealthCheckFromDiscovers() + public function itCanResolveHealthCheckFromDiscovers(): void { - $this->get('/up') + CarbonImmutable::setTestNow('2026-08-06 12:00:00 UTC'); + + $this->call('GET', '/up', server: [ + 'REQUEST_TIME_FLOAT' => CarbonImmutable::now()->subSeconds(5)->getPreciseTimestamp(6) / 1_000_000, + ]) ->assertOk() ->assertSee('HTTP request received') - ->assertSee('Response rendered in'); + ->assertSee('Response rendered in 5000ms.'); } #[Test] From ff3cbc023c76d6f8ff2c73e998e80fd1d377880a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:14:00 +0000 Subject: [PATCH 05/19] fix: use request-owned timing in Telescope Derive Telescope request duration from Request::startedAt() instead of reading transport metadata directly. Remove the nullable fallback branch and freeze time in the watcher regression so the recorded millisecond duration is exact. --- src/telescope/src/Watchers/RequestWatcher.php | 4 +--- tests/Telescope/Watchers/RequestWatchersTest.php | 10 ++++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/telescope/src/Watchers/RequestWatcher.php b/src/telescope/src/Watchers/RequestWatcher.php index e3b71f937..97d40d994 100644 --- a/src/telescope/src/Watchers/RequestWatcher.php +++ b/src/telescope/src/Watchers/RequestWatcher.php @@ -54,8 +54,6 @@ public function recordRequest(RequestHandled $event): void return; } - $startTime = (float) $event->request->server('REQUEST_TIME_FLOAT'); - Telescope::recordRequest(IncomingEntry::make([ 'ip_address' => $event->request->ip(), 'uri' => str_replace($event->request->root(), '', $event->request->fullUrl()) ?: '/', @@ -70,7 +68,7 @@ public function recordRequest(RequestHandled $event): void 'response' => $this->response($event->response), 'context' => $this->facadeContext(), 'coroutine_context' => $this->getContext(), - 'duration' => $startTime > 0 ? floor((microtime(true) - $startTime) * 1000) : null, + 'duration' => floor($event->request->startedAt()->diffInMilliseconds()), 'memory' => round(memory_get_peak_usage(true) / 1024 / 1024, 1), ])); diff --git a/tests/Telescope/Watchers/RequestWatchersTest.php b/tests/Telescope/Watchers/RequestWatchersTest.php index 0e1376a6a..c769ba4c6 100644 --- a/tests/Telescope/Watchers/RequestWatchersTest.php +++ b/tests/Telescope/Watchers/RequestWatchersTest.php @@ -6,6 +6,7 @@ use Hypervel\Http\UploadedFile; use Hypervel\Log\Context\Repository as ContextRepository; +use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Facades\Response; use Hypervel\Support\Facades\Route; use Hypervel\Support\Facades\View; @@ -20,12 +21,16 @@ ])] class RequestWatchersTest extends FeatureTestCase { - public function testRequestWatcherRegistersRequests() + public function testRequestWatcherRegistersRequests(): void { + CarbonImmutable::setTestNow('2026-08-06 12:00:00 UTC'); + $result = ['email' => 'albert@hypervel.org']; Route::get('/emails', fn () => $result); - $this->get('/emails')->assertSuccessful(); + $this->call('GET', '/emails', server: [ + 'REQUEST_TIME_FLOAT' => CarbonImmutable::now()->subSeconds(5)->getPreciseTimestamp(6) / 1_000_000, + ])->assertSuccessful(); $entry = $this->loadTelescopeEntries()->first(); @@ -34,6 +39,7 @@ public function testRequestWatcherRegistersRequests() $this->assertSame(200, $entry->content['response_status']); $this->assertSame('/emails', $entry->content['uri']); $this->assertSame($result, $entry->content['response']); + $this->assertSame(5000, $entry->content['duration']); } public function testRequestWatcherRegisters404() From b8582510284eea0b60adf2ae76431039e00df113 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:14:06 +0000 Subject: [PATCH 06/19] fix: use request-owned timing in Sentry tracing Start HTTP transactions from the Request-owned Carbon instant and preserve its microsecond precision when converting to Sentry epoch seconds. Delete the server-value and process-constant fallback chain, and prove later ServerBag mutation cannot alter the captured transaction start. --- src/sentry/src/Tracing/Middleware.php | 11 +++-------- tests/Sentry/Tracing/MiddlewareTest.php | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/sentry/src/Tracing/Middleware.php b/src/sentry/src/Tracing/Middleware.php index 8aafaf5c2..ec84f325c 100644 --- a/src/sentry/src/Tracing/Middleware.php +++ b/src/sentry/src/Tracing/Middleware.php @@ -141,13 +141,6 @@ private function startTransaction(Request $request): void return; } - $requestStartTime = $request->server( - 'REQUEST_TIME_FLOAT', - defined('HYPERVEL_START') - ? HYPERVEL_START - : microtime(true) - ); - $context = continueTrace( $request->header('sentry-trace', ''), $request->header('baggage', '') @@ -159,7 +152,9 @@ private function startTransaction(Request $request): void $context->setName($requestPath); $context->setOrigin('auto.http.server'); $context->setSource(TransactionSource::url()); - $context->setStartTimestamp($requestStartTime); + $context->setStartTimestamp( + $request->startedAt()->getPreciseTimestamp(6) / 1_000_000 + ); $context->setData([ 'url' => $requestPath, diff --git a/tests/Sentry/Tracing/MiddlewareTest.php b/tests/Sentry/Tracing/MiddlewareTest.php index 343353a1b..d52091199 100644 --- a/tests/Sentry/Tracing/MiddlewareTest.php +++ b/tests/Sentry/Tracing/MiddlewareTest.php @@ -87,6 +87,25 @@ public function testFlushStateClearsBootedTimestamp() $this->assertNull($property->getValue()); } + public function testTransactionUsesTheRequestStartTimestamp(): void + { + $middleware = $this->app->make(Middleware::class); + $request = new Request(server: [ + 'REQUEST_TIME_FLOAT' => 1_700_000_000.123456, + ]); + + $request->server->set('REQUEST_TIME_FLOAT', 1_800_000_000.654321); + + $response = $middleware->handle($request, fn () => new Response('OK')); + + Middleware::signalRouteWasMatched(); + $middleware->terminate($request, $response); + $middleware->finishTransaction(); + + $this->assertSentryTransactionCount(1); + $this->assertSame(1_700_000_000.123456, $this->getLastSentryEvent()?->getStartTimestamp()); + } + public function testAfterResponseSpansAreCapturedOnTransaction() { $middleware = $this->app->make(Middleware::class); From 3530cded11c48dea1dbdd1927ab9a1fe0c491f15 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:14:16 +0000 Subject: [PATCH 07/19] docs: document request timing and invocation-local deadlines Document Request::startedAt(), normalized uppercase server metadata, kernel timing boundaries, and WebSocket handshake semantics. Replace the collection timeout example built from process startup with an invocation-local now() deadline and remove its obsolete import. --- src/boost/docs/collections.md | 5 +---- src/boost/docs/requests.md | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/boost/docs/collections.md b/src/boost/docs/collections.md index b9bd19add..2cceb0473 100644 --- a/src/boost/docs/collections.md +++ b/src/boost/docs/collections.md @@ -4606,12 +4606,9 @@ To illustrate the usage of this method, imagine an application that submits invo ```php use App\Models\Invoice; -use Hypervel\Support\CarbonImmutable; Invoice::pending()->cursor() - ->takeUntilTimeout( - CarbonImmutable::createFromTimestamp(HYPERVEL_START)->add(14, 'minutes') - ) + ->takeUntilTimeout(now()->plus(minutes: 14)) ->each(fn (Invoice $invoice) => $invoice->submit()); ``` diff --git a/src/boost/docs/requests.md b/src/boost/docs/requests.md index 78a76eb3d..9d0d79576 100644 --- a/src/boost/docs/requests.md +++ b/src/boost/docs/requests.md @@ -3,6 +3,7 @@ - [Introduction](#introduction) - [Interacting With The Request](#interacting-with-the-request) - [Accessing the Request](#accessing-the-request) + - [Request Start Time and Server Metadata](#request-start-time-and-server-metadata) - [Request Path, Host, and Method](#request-path-and-method) - [Request Headers](#request-headers) - [Request IP Address](#request-ip-address) @@ -105,6 +106,27 @@ class UserController extends Controller } ``` + +### Request Start Time and Server Metadata + +The `startedAt` method returns a `Hypervel\Support\CarbonImmutable` instance representing when Swoole began processing the current request on the worker, before Hypervel's server bridge and HTTP kernel handled it: + +```php +$startedAt = $request->startedAt(); +``` + +You may retrieve an individual server value using the `server` method, or call the method without an argument to retrieve all server metadata. Values received from Swoole use uppercase PHP / Symfony names: + +```php +$requestTime = $request->server('REQUEST_TIME_FLOAT'); + +$server = $request->server(); +``` + +The request start time remains available for the lifetime of the request object, including after the HTTP kernel has terminated the request. The kernel's request lifecycle timer is a separate, later timing boundary used by lifecycle duration handlers. + +When handling a WebSocket connection, the request start time describes the initial HTTP handshake, not subsequent WebSocket messages. + ### Request Path, Host, and Method From 272d96c1e8ad19a6cfc7438df7a556d112897f47 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:14:27 +0000 Subject: [PATCH 08/19] feat: support context copying in waited coroutines Add the established boolean or selected-key context-copy contract to Waiter and the global wait helper while preserving a fresh child context by default. Move synthetic HTTP testing onto the base Waiter with explicit full-context copying, retain replication-failure coverage, delete the redundant Foundation wrapper, and document the public behavior. --- src/boost/docs/coroutines.md | 16 +++++ src/coroutine/src/Waiter.php | 12 +++- src/coroutine/src/functions.php | 7 ++- .../Testing/Concerns/MakesHttpRequests.php | 4 +- .../src/Testing/Coroutine/Waiter.php | 23 ------- tests/Coroutine/WaiterTest.php | 54 ++++++++++++++++ .../Testing/Coroutine/WaiterTest.php | 62 ------------------- .../RequestContextSynchronizerTest.php | 8 +-- 8 files changed, 90 insertions(+), 96 deletions(-) delete mode 100644 src/foundation/src/Testing/Coroutine/Waiter.php delete mode 100644 tests/Foundation/Testing/Coroutine/WaiterTest.php diff --git a/src/boost/docs/coroutines.md b/src/boost/docs/coroutines.md index 56906667f..5df1e7cd1 100644 --- a/src/boost/docs/coroutines.md +++ b/src/boost/docs/coroutines.md @@ -436,6 +436,22 @@ $result = wait(function () { If no timeout is provided, `wait` will wait up to 10 seconds for the closure to finish. +The child coroutine receives a fresh context by default. You may copy all parent context keys, or only the keys the child needs, using the `copyContext` argument: + +```php +use Hypervel\Context\CoroutineContext; + +$result = wait(function () { + return CoroutineContext::get('request_id'); +}, copyContext: true); + +$result = wait(function () { + return CoroutineContext::get('request_id'); +}, copyContext: ['request_id']); +``` + +Copied object values follow the same replication behavior as [`go` and `Coroutine::fork`](#copying-coroutine-context). + If the closure throws an exception, `wait` rethrows it in the waiting coroutine after the child's deferred callbacks have finished. If the timeout is reached, Hypervel cancels the child by throwing `Swoole\Coroutine\CanceledException` inside it. Hypervel then gives the child up to 10 seconds to finish and run its deferred callbacks before throwing `Hypervel\Coroutine\Exceptions\WaitTimeoutException` in the waiting coroutine. diff --git a/src/coroutine/src/Waiter.php b/src/coroutine/src/Waiter.php index a30b1f3ec..d16e36ace 100644 --- a/src/coroutine/src/Waiter.php +++ b/src/coroutine/src/Waiter.php @@ -28,17 +28,20 @@ public function __construct(float $timeout = 10.0) * @template TReturn * @param Closure():TReturn $closure * @param null|float $timeout Timeout in seconds (null uses default) + * @param array|bool $copyContext When set, parent coroutine context is copied to the child. + * false = fresh context (default), true or empty array = copy all keys, non-empty array = copy listed keys only. + * Object values are shared by reference unless they implement Hypervel\Context\ReplicableContext. * @return TReturn * @throws WaitTimeoutException When the wait times out */ - public function wait(Closure $closure, ?float $timeout = null): mixed + public function wait(Closure $closure, ?float $timeout = null, bool|array $copyContext = false): mixed { if ($timeout === null) { $timeout = $this->popTimeout; } $channel = new Channel(1); - $childCoroutineId = Coroutine::create(function () use ($channel, $closure) { + $callable = function () use ($channel, $closure): void { $result = null; Coroutine::defer(function () use ($channel, &$result): void { @@ -50,7 +53,10 @@ public function wait(Closure $closure, ?float $timeout = null): mixed } catch (Throwable $exception) { $result = new ExceptionThrower($exception); } - }); + }; + $childCoroutineId = $copyContext === false + ? Coroutine::create($callable) + : Coroutine::fork($callable, is_array($copyContext) ? $copyContext : []); $result = $channel->pop($timeout); if ($result === false && $channel->isTimeout()) { diff --git a/src/coroutine/src/functions.php b/src/coroutine/src/functions.php index 2e9c9e7c4..9e41ca07f 100644 --- a/src/coroutine/src/functions.php +++ b/src/coroutine/src/functions.php @@ -29,13 +29,16 @@ function parallel(array $callables, int $concurrent = 0, bool|array $copyContext * @template TReturn * * @param Closure():TReturn $closure + * @param array|bool $copyContext When set, parent coroutine context is copied to the child. + * false = fresh context (default), true or empty array = copy all keys, non-empty array = copy listed keys only. + * Object values are shared by reference unless they implement Hypervel\Context\ReplicableContext. * @return TReturn */ -function wait(Closure $closure, ?float $timeout = null) +function wait(Closure $closure, ?float $timeout = null, bool|array $copyContext = false): mixed { return Container::getInstance() ->make(Waiter::class) - ->wait($closure, $timeout); + ->wait($closure, $timeout, $copyContext); } /** diff --git a/src/foundation/src/Testing/Concerns/MakesHttpRequests.php b/src/foundation/src/Testing/Concerns/MakesHttpRequests.php index 61550c355..d4b454cb6 100644 --- a/src/foundation/src/Testing/Concerns/MakesHttpRequests.php +++ b/src/foundation/src/Testing/Concerns/MakesHttpRequests.php @@ -9,7 +9,7 @@ use Hypervel\Context\RequestContext; use Hypervel\Contracts\Http\Kernel as HttpKernel; use Hypervel\Cookie\CookieValuePrefix; -use Hypervel\Foundation\Testing\Coroutine\Waiter; +use Hypervel\Coroutine\Waiter; use Hypervel\Foundation\Testing\RequestContextSynchronizer; use Hypervel\Foundation\Testing\Stubs\FakeMiddleware; use Hypervel\Http\Request; @@ -537,7 +537,7 @@ public function call( } return $response; - }, 10.0); + }, 10.0, copyContext: true); } /** diff --git a/src/foundation/src/Testing/Coroutine/Waiter.php b/src/foundation/src/Testing/Coroutine/Waiter.php deleted file mode 100644 index faf6e1c3f..000000000 --- a/src/foundation/src/Testing/Coroutine/Waiter.php +++ /dev/null @@ -1,23 +0,0 @@ -assertSame($id + 1, $result); } + public function testWaitStartsWithFreshContextByDefault(): void + { + CoroutineContext::set('key_a', 'value_a'); + + $this->assertNull(wait( + static fn (): mixed => CoroutineContext::get('key_a') + )); + } + + public function testWaitCanCopyAllContext(): void + { + CoroutineContext::set('key_a', 'value_a'); + CoroutineContext::set('key_b', 'value_b'); + + $readContext = static fn (): array => [ + CoroutineContext::get('key_a'), + CoroutineContext::get('key_b'), + ]; + + $this->assertSame(['value_a', 'value_b'], wait($readContext, copyContext: true)); + $this->assertSame(['value_a', 'value_b'], wait($readContext, copyContext: [])); + } + + public function testWaitCanCopySelectedContextKeys(): void + { + CoroutineContext::set('key_a', 'value_a'); + CoroutineContext::set('key_b', 'value_b'); + + $result = (new Waiter)->wait( + static fn (): array => [ + CoroutineContext::get('key_a'), + CoroutineContext::get('key_b'), + ], + copyContext: ['key_a'], + ); + + $this->assertSame(['value_a', null], $result); + } + + public function testContextReplicationFailureIsReportedInsteadOfTimingOut(): void + { + CoroutineContext::set('throwing', new ThrowingReplicableContext); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unable to replicate context.'); + + (new Waiter(0.01))->wait( + static fn (): string => 'never', + copyContext: true, + ); + } + public function testWaitNone() { $callback = function () { diff --git a/tests/Foundation/Testing/Coroutine/WaiterTest.php b/tests/Foundation/Testing/Coroutine/WaiterTest.php deleted file mode 100644 index 6a763b3ab..000000000 --- a/tests/Foundation/Testing/Coroutine/WaiterTest.php +++ /dev/null @@ -1,62 +0,0 @@ -wait(function () use (&$childCoroutineId): mixed { - $childCoroutineId = Coroutine::id(); - - return CoroutineContext::get('request_id'); - }); - - $this->assertSame('request-value', $result); - $this->assertIsInt($childCoroutineId); - $this->assertFalse(Coroutine::exists($childCoroutineId)); - } - - public function testContextReplicationFailureIsReportedInsteadOfTimingOut(): void - { - CoroutineContext::set('throwing', new ThrowingReplicableContext); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Unable to replicate context.'); - - (new Waiter(0.01))->wait(static fn (): string => 'never'); - } - - public function testTimeoutCancelsTheChildCoroutine(): void - { - $childCoroutineId = null; - $waiter = new class(0.001) extends Waiter { - protected float $pushTimeout = 0.01; - }; - - try { - $waiter->wait(function () use (&$childCoroutineId): void { - $childCoroutineId = Coroutine::id(); - Coroutine::sleep(0.1); - }); - $this->fail('The waiter should time out.'); - } catch (WaitTimeoutException) { - } - - $this->assertIsInt($childCoroutineId); - $this->assertFalse(Coroutine::exists($childCoroutineId)); - } -} diff --git a/tests/Foundation/Testing/RequestContextSynchronizerTest.php b/tests/Foundation/Testing/RequestContextSynchronizerTest.php index 297b38c12..df54dd6aa 100644 --- a/tests/Foundation/Testing/RequestContextSynchronizerTest.php +++ b/tests/Foundation/Testing/RequestContextSynchronizerTest.php @@ -6,7 +6,7 @@ use ArrayObject; use Hypervel\Context\CoroutineContext; -use Hypervel\Foundation\Testing\Coroutine\Waiter; +use Hypervel\Coroutine\Waiter; use Hypervel\Foundation\Testing\RequestContextSynchronizer; use Hypervel\Tests\TestCase; @@ -25,7 +25,7 @@ public function testSyncContextKeysToParentCopiesPresentKeysAndForgetsMissingKey 'foundation.testing.present', 'foundation.testing.missing', ]); - }); + }, copyContext: true); $this->assertSame('new', CoroutineContext::get('foundation.testing.present')); $this->assertFalse(CoroutineContext::has('foundation.testing.missing')); @@ -43,7 +43,7 @@ public function testSyncSnapshotToParentCopiesPresentKeysAndForgetsMissingKeys() 'foundation.testing.snapshot.present', 'foundation.testing.snapshot.missing', ]); - }); + }, copyContext: true); $this->assertSame('new', CoroutineContext::get('foundation.testing.snapshot.present')); $this->assertFalse(CoroutineContext::has('foundation.testing.snapshot.missing')); @@ -61,7 +61,7 @@ public function testSyncSnapshotToParentSupportsArrayAccessSnapshots(): void 'foundation.testing.array-access.present', 'foundation.testing.array-access.missing', ]); - }); + }, copyContext: true); $this->assertSame('new', CoroutineContext::get('foundation.testing.array-access.present')); $this->assertFalse(CoroutineContext::has('foundation.testing.array-access.missing')); From a5e229ddb9443724bfc5300dfd6a58cf02c3656b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:14:38 +0000 Subject: [PATCH 09/19] fix: isolate scheduled task execution lifecycles Keep pause, interruption, and maintenance control in the scheduler while running user filters and each task invocation in a finite child carrying only replicated Log Context. Share foreground and bounded-background dispatch across initial and repeated runs, preserve foreground ordering, and make claimed single-server work count as handled. Guard never-checked repeat events at their public predicate and advance paused repeats at their configured cadence, with regressions for context isolation, defers, background repeats, pause behavior, and user-visible output. --- .../src/Commands/ScheduleRunCommand.php | 81 ++++++---- src/console/src/Scheduling/Event.php | 3 +- tests/Console/Scheduling/EventTest.php | 8 + .../Scheduling/ScheduleRunCommandTest.php | 149 ++++++++++++++++++ .../ScheduleRunContextPropagationTest.php | 22 ++- .../Scheduling/SubMinuteSchedulingTest.php | 42 +++++ 6 files changed, 271 insertions(+), 34 deletions(-) diff --git a/src/console/src/Commands/ScheduleRunCommand.php b/src/console/src/Commands/ScheduleRunCommand.php index f209f58b8..f27490e48 100644 --- a/src/console/src/Commands/ScheduleRunCommand.php +++ b/src/console/src/Commands/ScheduleRunCommand.php @@ -5,6 +5,7 @@ namespace Hypervel\Console\Commands; use Carbon\CarbonInterface; +use Closure; use Hypervel\Console\Command; use Hypervel\Console\Events\ScheduledBackgroundTaskFinished; use Hypervel\Console\Events\ScheduledTaskFailed; @@ -207,7 +208,7 @@ protected function runOnce(): void if ($events->contains->isRepeatable()) { $this->repeatEvents($events->filter->isRepeatable()); } - }); + }, copyContext: [ContextRepository::CONTEXT_KEY]); if (! $this->eventsRan && ! $this->option('whisper')) { $this->info('No scheduled commands are ready to run.'); @@ -245,24 +246,23 @@ protected function repeatEvents(Collection $events): void } if ($paused && ! $event->runsWhenPaused()) { + $event->lastChecked = Date::now(); $this->dispatchTaskSkipped($event); continue; } - if (! $event->filtersPass($this->hypervel)) { - $this->dispatchTaskSkipped($event); + $this->runTaskInCoroutine(function () use ($event): void { + if (! $event->filtersPass($this->hypervel)) { + $this->dispatchTaskSkipped($event); - continue; - } + return; + } - if ($event->onOneServer) { - $this->runSingleServerEvent($event, $this->startedAt); - } else { - $this->runEvent($event); - } + $this->runScheduledEvent($event, $this->startedAt); - $this->eventsRan = true; + $this->eventsRan = true; + }); } Sleep::usleep(100_000); @@ -283,34 +283,59 @@ protected function runEvents(Collection $events, CarbonInterface $startedAt): vo } if ($paused && ! $event->runsWhenPaused()) { + $event->lastChecked = Date::now(); $this->dispatchTaskSkipped($event); continue; } - if (! $event->filtersPass($this->hypervel)) { - $this->dispatchTaskSkipped($event); + $this->runTaskInCoroutine(function () use ($event, $startedAt): void { + if (! $event->filtersPass($this->hypervel)) { + $this->dispatchTaskSkipped($event); - continue; - } + return; + } + + $this->runScheduledEvent($event, $startedAt); + + $this->eventsRan = true; + }); + } + } - $runEvent = fn () => $event->onOneServer - ? $this->runSingleServerEvent($event, $startedAt) - : $this->runEvent($event); + /** + * Run user task evaluation in a finite coroutine. + */ + protected function runTaskInCoroutine(Closure $callback): void + { + (new Waiter(-1))->wait( + $callback, + copyContext: [ContextRepository::CONTEXT_KEY], + ); + } - if ($event->runInBackground) { - $this->concurrent->fork(function () use ($runEvent, $event) { - $runEvent(); + /** + * Dispatch a scheduled event in the foreground or background. + */ + protected function runScheduledEvent(Event $event, CarbonInterface $startedAt): void + { + $runEvent = fn () => $event->onOneServer + ? $this->runSingleServerEvent($event, $startedAt) + : $this->runEvent($event); - if ($this->dispatcher->hasListeners(ScheduledBackgroundTaskFinished::class)) { - $this->dispatcher->dispatch(new ScheduledBackgroundTaskFinished($event)); - } - }, [ContextRepository::CONTEXT_KEY]); - continue; - } + if ($event->runInBackground) { + $this->concurrent->fork(function () use ($runEvent, $event): void { + $runEvent(); - $runEvent(); + if ($this->dispatcher->hasListeners(ScheduledBackgroundTaskFinished::class)) { + $this->dispatcher->dispatch(new ScheduledBackgroundTaskFinished($event)); + } + }, [ContextRepository::CONTEXT_KEY]); + + return; } + + $runEvent(); } /** diff --git a/src/console/src/Scheduling/Event.php b/src/console/src/Scheduling/Event.php index a036a8172..4b7f03d89 100644 --- a/src/console/src/Scheduling/Event.php +++ b/src/console/src/Scheduling/Event.php @@ -231,7 +231,8 @@ public function isRepeatable(): bool public function shouldRepeatNow(): bool { return $this->isRepeatable() - && abs($this->lastChecked?->diffInSeconds()) >= $this->repeatSeconds; + && $this->lastChecked !== null + && abs($this->lastChecked->diffInSeconds()) >= $this->repeatSeconds; } /** diff --git a/tests/Console/Scheduling/EventTest.php b/tests/Console/Scheduling/EventTest.php index 5b2020c85..8961addc0 100644 --- a/tests/Console/Scheduling/EventTest.php +++ b/tests/Console/Scheduling/EventTest.php @@ -155,6 +155,14 @@ public function testEventRunsWhenMarkedAsEvenWhenPaused(): void $this->assertTrue($event->runsWhenPaused()); } + public function testNeverCheckedRepeatableEventIsNotReadyToRepeat(): void + { + $event = new Event(m::mock(EventMutex::class), 'php -i'); + $event->repeatSeconds = 1; + + $this->assertFalse($event->shouldRepeatNow()); + } + public function testEventMarksSkippedWhenMutexAlreadyExists(): void { $eventMutex = m::mock(EventMutex::class); diff --git a/tests/Console/Scheduling/ScheduleRunCommandTest.php b/tests/Console/Scheduling/ScheduleRunCommandTest.php index be385ff04..2419b5696 100644 --- a/tests/Console/Scheduling/ScheduleRunCommandTest.php +++ b/tests/Console/Scheduling/ScheduleRunCommandTest.php @@ -22,7 +22,9 @@ use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Coroutine\Concurrent; +use Hypervel\Coroutine\Coroutine as HypervelCoroutine; use Hypervel\Engine\Channel; +use Hypervel\Log\Context\Repository as ContextRepository; use Hypervel\Support\Carbon; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Collection; @@ -89,6 +91,89 @@ public function testForegroundCallbackDispatchesStartingAndFinishedEvents() $this->assertIsFloat($this->dispatched[1]->runtime); } + public function testForegroundTaskEvaluationsUseFiniteCoroutinesWithSelectedLogContext(): void + { + ContextRepository::getInstance()->add('trace_id', 'parent-trace'); + CoroutineContext::set('__test.schedule.unrelated', 'parent-value'); + + $parentCoroutineId = Coroutine::getCid(); + $observations = []; + $deferred = []; + $events = []; + + foreach (['first', 'second'] as $name) { + $event = new CallbackEvent(m::mock(EventMutex::class), function () use (&$observations, &$deferred, $name): int { + $observations[$name]['run'] = [Coroutine::getCid(), count($deferred)]; + + return 0; + }); + $event->when(function () use (&$observations, &$deferred, $name): bool { + $context = CoroutineContext::get(ContextRepository::CONTEXT_KEY); + + $observations[$name]['filter'] = [ + Coroutine::getCid(), + $context instanceof ContextRepository ? $context->get('trace_id') : null, + CoroutineContext::get('__test.schedule.unrelated'), + count($deferred), + ]; + + HypervelCoroutine::defer(function () use (&$deferred, $name): void { + $deferred[] = $name; + }); + + return true; + }); + $events[] = $event; + } + + $command = $this->makeCommand(); + $this->invokeRunEvents($command, $events); + + $this->assertNotSame($parentCoroutineId, $observations['first']['filter'][0]); + $this->assertNotSame($parentCoroutineId, $observations['second']['filter'][0]); + $this->assertNotSame($observations['first']['filter'][0], $observations['second']['filter'][0]); + $this->assertSame($observations['first']['filter'][0], $observations['first']['run'][0]); + $this->assertSame($observations['second']['filter'][0], $observations['second']['run'][0]); + $this->assertSame('parent-trace', $observations['first']['filter'][1]); + $this->assertSame('parent-trace', $observations['second']['filter'][1]); + $this->assertNull($observations['first']['filter'][2]); + $this->assertNull($observations['second']['filter'][2]); + $this->assertSame(0, $observations['first']['filter'][3]); + $this->assertSame(1, $observations['second']['filter'][3]); + $this->assertSame(0, $observations['first']['run'][1]); + $this->assertSame(1, $observations['second']['run'][1]); + $this->assertSame(['first', 'second'], $deferred); + } + + public function testRunOncePreservesLogContextAcrossItsOuterCoroutine(): void + { + ContextRepository::getInstance()->add('trace_id', 'parent-trace'); + $observedTraceId = null; + $event = new CallbackEvent(m::mock(EventMutex::class), function () use (&$observedTraceId): int { + $context = CoroutineContext::get(ContextRepository::CONTEXT_KEY); + $observedTraceId = $context instanceof ContextRepository + ? $context->get('trace_id') + : null; + + return 0; + }); + + $schedule = m::mock(Schedule::class); + $schedule->shouldReceive('dueEventsAt') + ->once() + ->with($this->app, m::type(CarbonInterface::class)) + ->andReturn(new Collection([$event])); + + $command = $this->makeCommand(); + (new ReflectionProperty($command, 'schedule'))->setValue($command, $schedule); + $command->setInput(new ArrayInput(['--whisper' => true], $command->getDefinition())); + $this->captureOutput($command); + + (new ReflectionMethod($command, 'runOnce'))->invoke($command); + + $this->assertSame('parent-trace', $observedTraceId); + } + public function testTaskLifecycleEventsAreNotDispatchedWithoutListeners(): void { $eventMutex = m::mock(EventMutex::class); @@ -571,6 +656,37 @@ public function testRepeatableEventIsThrottledByLastChecked() $this->assertSame(1, $runCount); } + public function testSingleServerEventClaimedElsewhereDoesNotReportThatNoCommandsWereReady(): void + { + $event = new Event(m::mock(EventMutex::class), 'test:single-server'); + $event->onOneServer(); + + $schedule = m::mock(Schedule::class); + $schedule->shouldReceive('dueEventsAt') + ->once() + ->with($this->app, m::type(CarbonInterface::class)) + ->andReturn(new Collection([$event])); + $schedule->shouldReceive('serverShouldRun') + ->once() + ->with($event, m::type(CarbonInterface::class)) + ->andReturnFalse(); + + $command = $this->makeCommand(); + (new ReflectionProperty($command, 'schedule'))->setValue($command, $schedule); + $command->setInput(new ArrayInput([], $command->getDefinition())); + $output = $this->captureOutput($command); + + (new ReflectionMethod($command, 'runOnce'))->invoke($command); + + $display = $output->fetch(); + + $this->assertStringContainsString( + 'Skipping [test:single-server], as command already run on another server.', + $display, + ); + $this->assertStringNotContainsString('No scheduled commands are ready to run.', $display); + } + #[DataProvider('dateClassProvider')] public function testRepeatEventsPreservesOriginalStartForSingleServerMutex(string $dateClass): void { @@ -623,6 +739,39 @@ public static function dateClassProvider(): array ]; } + public function testRepeatEventsUseTheBackgroundDispatchPath(): void + { + Date::setTestNow('2026-05-28 12:34:00'); + + $startedAt = Date::now()->startOfMinute(); + $event = m::mock(Event::class, [m::mock(EventMutex::class), 'test:repeating-background', null, false]) + ->makePartial(); + $event->shouldReceive('run') + ->once() + ->andReturnUsing(function () use ($startedAt): void { + Date::setTestNow($startedAt->addMinute()); + }); + $event->repeatSeconds = 1; + $event->lastChecked = $startedAt->subSecond(); + $event->runInBackground(); + + $command = $this->makeCommand(); + $concurrent = new Concurrent(10); + (new ReflectionProperty($command, 'concurrent'))->setValue($command, $concurrent); + (new ReflectionProperty($command, 'startedAt'))->setValue($command, $startedAt); + + $this->invokeRepeatEvents($command, [$event]); + $this->waitForConcurrent($concurrent); + + $backgroundFinished = array_values(array_filter( + $this->dispatched, + static fn (object $event): bool => $event instanceof ScheduledBackgroundTaskFinished + )); + + $this->assertCount(1, $backgroundFinished); + $this->assertSame($event, $backgroundFinished[0]->task); + } + public function testConcurrentFinishesUseRunLocalExitCodeForSuccessAndFailureCallbacks() { $eventMutex = m::mock(EventMutex::class); diff --git a/tests/Console/Scheduling/ScheduleRunContextPropagationTest.php b/tests/Console/Scheduling/ScheduleRunContextPropagationTest.php index fe98db5fe..8588adaee 100644 --- a/tests/Console/Scheduling/ScheduleRunContextPropagationTest.php +++ b/tests/Console/Scheduling/ScheduleRunContextPropagationTest.php @@ -109,24 +109,36 @@ public function testBackgroundTaskDoesNotReceiveNonContextCoroutineState() $this->assertSame('yes', $channel->pop(1.0)); } - public function testForegroundTaskSharesParentContext() + public function testForegroundTaskReceivesIndependentCopyOfParentLogContext(): void { ContextRepository::getInstance()->add('parent_key', 'original'); + $childValues = []; $eventMutex = m::mock(EventMutex::class); $eventMutex->shouldReceive('create')->andReturn(true); $eventMutex->shouldReceive('forget'); - $event = new CallbackEvent($eventMutex, function () { - ContextRepository::getInstance()->add('parent_key', 'modified'); + $event = new CallbackEvent($eventMutex, function () use (&$childValues) { + $context = ContextRepository::getInstance(); + $childValues['inherited'] = $context->get('parent_key'); + + $context->add('parent_key', 'modified'); + $context->add('child_only', 'child'); + + $childValues['modified'] = $context->get('parent_key'); + $childValues['child_only'] = $context->get('child_only'); + return 0; }); $command = $this->makeCommand(); $this->invokeRunEvents($command, [$event]); - // Foreground tasks run in the same coroutine — mutations are visible - $this->assertSame('modified', ContextRepository::getInstance()->get('parent_key')); + $this->assertSame('original', $childValues['inherited']); + $this->assertSame('modified', $childValues['modified']); + $this->assertSame('child', $childValues['child_only']); + $this->assertSame('original', ContextRepository::getInstance()->get('parent_key')); + $this->assertNull(ContextRepository::getInstance()->get('child_only')); } /** diff --git a/tests/Integration/Console/Scheduling/SubMinuteSchedulingTest.php b/tests/Integration/Console/Scheduling/SubMinuteSchedulingTest.php index 7c1378ab4..f06abe188 100644 --- a/tests/Integration/Console/Scheduling/SubMinuteSchedulingTest.php +++ b/tests/Integration/Console/Scheduling/SubMinuteSchedulingTest.php @@ -6,6 +6,7 @@ use Hypervel\Cache\Repository; use Hypervel\Cache\WorkerArrayStore; +use Hypervel\Console\Events\ScheduledTaskSkipped; use Hypervel\Console\Scheduling\CacheEventMutex; use Hypervel\Console\Scheduling\CacheSchedulingMutex; use Hypervel\Console\Scheduling\EventMutex; @@ -14,6 +15,7 @@ use Hypervel\Container\Container; use Hypervel\Contracts\Cache\Factory; use Hypervel\Contracts\Cache\Repository as CacheRepository; +use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Sleep; use Hypervel\Testbench\TestCase; @@ -249,13 +251,52 @@ public function testSubMinuteEventsCanBeRunWhenScheduleIsPaused(): void $this->assertEquals(60, $runs); } + public function testPausedSubMinuteEventsAreSkippedAtTheirNaturalCadenceFromStart(): void + { + $runs = 0; + $skips = 0; + $this->schedule->call(function () use (&$runs) { + ++$runs; + })->everySecond(); + + $this->app->make(Dispatcher::class)->listen( + ScheduledTaskSkipped::class, + function () use (&$skips): void { + ++$skips; + }, + ); + + config(['cache.default' => 'worker-array']); + CarbonImmutable::setTestNow(now()->startOfMinute()); + Sleep::fake(); + Sleep::whenFakingSleep(fn ($duration) => CarbonImmutable::setTestNow(now()->add($duration))); + + $this->artisan('schedule:pause') + ->expectsOutputToContain('Scheduled task processing has been paused.'); + + $this->artisan('schedule:run', ['--once' => true]) + ->assertSuccessful(); + + Sleep::assertSleptTimes(600); + $this->assertSame(0, $runs); + $this->assertSame(60, $skips); + } + public function testSubMinuteEventsStopForTheRestOfTheMinuteOnceScheduleIsPaused(): void { $runs = 0; + $skips = 0; $this->schedule->call(function () use (&$runs) { ++$runs; })->everySecond(); + $this->app->make(Dispatcher::class)->listen( + ScheduledTaskSkipped::class, + function () use (&$skips): void { + ++$skips; + }, + ); + CarbonImmutable::setTestNow(now()->startOfMinute()); $startedAt = now(); $cache = $this->app->make(CacheRepository::class); @@ -274,6 +315,7 @@ public function testSubMinuteEventsStopForTheRestOfTheMinuteOnceScheduleIsPaused Sleep::assertSleptTimes(600); $this->assertEquals(30, $runs); + $this->assertSame(30, $skips); } public function testSubMinuteSchedulingRespectsFilters(): void From 9cd47f2e957bf7b1837981722681d06a18a5078e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:14:45 +0000 Subject: [PATCH 10/19] fix: classify Telescope recording by resolved command identity Use BeforeHandle command names instead of positional process arguments, correct the default ignored command set, and keep the long-lived schedule daemon outside recording. Start recording inside approved scheduled-task coroutines at the storage-opportunity boundary so every enabled watcher shares the real task lifecycle. Cover configured ignores, daemon silence, package discovery, and task-local recording. --- .../src/ListensForStorageOpportunities.php | 35 ++++--- src/telescope/src/Telescope.php | 12 +-- src/telescope/src/Watchers/CommandWatcher.php | 5 +- tests/Telescope/Telescope/TelescopeTest.php | 96 +++++++++++++++++++ .../Telescope/Watchers/CommandWatcherTest.php | 23 +++++ 5 files changed, 152 insertions(+), 19 deletions(-) diff --git a/src/telescope/src/ListensForStorageOpportunities.php b/src/telescope/src/ListensForStorageOpportunities.php index f3b9ae5ab..a2331210e 100644 --- a/src/telescope/src/ListensForStorageOpportunities.php +++ b/src/telescope/src/ListensForStorageOpportunities.php @@ -6,6 +6,7 @@ use Closure; use Hypervel\Console\Events\BeforeHandle as BeforeHandleCommand; +use Hypervel\Console\Events\ScheduledTaskStarting; use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Events\Dispatcher; @@ -81,21 +82,33 @@ public static function recordEntriesForRequests(Container $app): void } /** - * Manage starting and stopping the recording state for commands. + * Start recording for approved console commands and scheduled tasks. * - * Boot-only. Registers a worker-lifetime command listener; runtime use - * would accumulate duplicate listeners. + * Boot-only. Registers worker-lifetime command and scheduled-task listeners; + * runtime use would accumulate duplicate listeners. */ public static function manageRecordingStateForCommands(Container $app): void { - $app->make(Dispatcher::class) - ->listen(BeforeHandleCommand::class, function () { - if (static::shouldListen() - && static::runningApprovedArtisanCommand() - ) { - static::startRecording(); - } - }); + $events = $app->make(Dispatcher::class); + + $events->listen(BeforeHandleCommand::class, function (BeforeHandleCommand $event) { + // The long-lived scheduler records only inside each finite task coroutine. + if ($event->command->getName() === 'schedule:run') { + return; + } + + if (static::shouldListen() + && static::commandIsApproved($event->command->getName()) + ) { + static::startRecording(); + } + }); + + $events->listen(ScheduledTaskStarting::class, function () { + if (static::shouldListen() && static::commandIsApproved('schedule:run')) { + static::startRecording(); + } + }); } /** diff --git a/src/telescope/src/Telescope.php b/src/telescope/src/Telescope.php index 5ac811506..381cf04b7 100644 --- a/src/telescope/src/Telescope.php +++ b/src/telescope/src/Telescope.php @@ -140,12 +140,12 @@ public static function start(Application $app): void } /** - * Determine if the application is running an approved command. + * Determine if the given command is approved for recording. */ - protected static function runningApprovedArtisanCommand(): bool + protected static function commandIsApproved(?string $command): bool { return ! in_array( - $_SERVER['argv'][1] ?? null, + $command, array_merge([ // 'migrate', 'migrate:rollback', @@ -153,15 +153,15 @@ protected static function runningApprovedArtisanCommand(): bool // 'migrate:refresh', 'migrate:reset', 'migrate:install', + 'package:discover', 'queue:listen', 'queue:work', 'horizon', 'horizon:work', 'horizon:supervisor', 'watch', - 'start', - 'serve', - ], config('telescope.ignore_commands', [])) + ], config('telescope.ignore_commands', [])), + true ); } diff --git a/src/telescope/src/Watchers/CommandWatcher.php b/src/telescope/src/Watchers/CommandWatcher.php index d3bd6e123..f253034b8 100644 --- a/src/telescope/src/Watchers/CommandWatcher.php +++ b/src/telescope/src/Watchers/CommandWatcher.php @@ -49,8 +49,9 @@ private function shouldIgnore(Command $command): bool $command->getName(), array_merge($this->options['ignore'] ?? [], [ 'schedule:run', - 'crontab:run', - ]) + 'package:discover', + ]), + true ); } } diff --git a/tests/Telescope/Telescope/TelescopeTest.php b/tests/Telescope/Telescope/TelescopeTest.php index be7b48965..df32f6f38 100644 --- a/tests/Telescope/Telescope/TelescopeTest.php +++ b/tests/Telescope/Telescope/TelescopeTest.php @@ -4,7 +4,12 @@ namespace Hypervel\Tests\Telescope\Telescope; +use Hypervel\Console\Command; +use Hypervel\Console\Events\BeforeHandle; +use Hypervel\Console\Events\ScheduledTaskStarting; +use Hypervel\Console\Scheduling\Event; use Hypervel\Contracts\Bus\Dispatcher; +use Hypervel\Contracts\Events\Dispatcher as EventDispatcher; use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Telescope\Contracts\EntriesRepository; use Hypervel\Telescope\IncomingEntry; @@ -13,6 +18,8 @@ use Hypervel\Telescope\Watchers\QueryWatcher; use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Tests\Telescope\FeatureTestCase; +use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; #[WithConfig('telescope.watchers', [ QueryWatcher::class => [ @@ -107,6 +114,95 @@ public function testFlushStateClearsShouldListenCallback() $this->assertTrue(Telescope::shouldListen()); } + + public function testResolvedCommandStartsRecording(): void + { + Telescope::stopRecording(); + + $this->app->make(EventDispatcher::class) + ->dispatch(new BeforeHandle(new RecordingStateCommand('telescope:test-command'))); + + $this->assertTrue(Telescope::isRecording()); + } + + #[DataProvider('ignoredCommandProvider')] + public function testResolvedIgnoredCommandDoesNotStartRecording(string $command): void + { + Telescope::stopRecording(); + + $this->app->make(EventDispatcher::class) + ->dispatch(new BeforeHandle(new RecordingStateCommand($command))); + + $this->assertFalse(Telescope::isRecording()); + } + + public static function ignoredCommandProvider(): array + { + return [ + ['package:discover'], + ['watch'], + ]; + } + + public function testResolvedConfiguredIgnoredCommandDoesNotStartRecording(): void + { + config()->set('telescope.ignore_commands', ['custom:ignored']); + Telescope::stopRecording(); + + $this->app->make(EventDispatcher::class) + ->dispatch(new BeforeHandle(new RecordingStateCommand('custom:ignored'))); + + $this->assertFalse(Telescope::isRecording()); + } + + public function testSchedulerDaemonDoesNotStartRecording(): void + { + Telescope::stopRecording(); + + $this->app->make(EventDispatcher::class) + ->dispatch(new BeforeHandle(new RecordingStateCommand('schedule:run'))); + + $this->assertFalse(Telescope::isRecording()); + + Telescope::recordCache(IncomingEntry::make(['key' => 'scheduler-cache-read'])); + + $this->assertSame([], Telescope::getEntriesQueue()); + } + + public function testScheduledTaskStartsRecordingWhenSchedulerIsApproved(): void + { + Telescope::stopRecording(); + + $this->app->make(EventDispatcher::class) + ->dispatch(new ScheduledTaskStarting(m::mock(Event::class))); + + $this->assertTrue(Telescope::isRecording()); + } + + public function testConfiguredIgnoredSchedulerDoesNotStartTaskRecording(): void + { + config()->set('telescope.ignore_commands', ['schedule:run']); + Telescope::stopRecording(); + + $this->app->make(EventDispatcher::class) + ->dispatch(new ScheduledTaskStarting(m::mock(Event::class))); + + $this->assertFalse(Telescope::isRecording()); + } +} + +class RecordingStateCommand extends Command +{ + public function __construct(string $command) + { + $this->signature = $command; + + parent::__construct(); + } + + public function handle(): void + { + } } class MySyncJob implements ShouldQueue diff --git a/tests/Telescope/Watchers/CommandWatcherTest.php b/tests/Telescope/Watchers/CommandWatcherTest.php index 067f9fa21..de85c12f7 100644 --- a/tests/Telescope/Watchers/CommandWatcherTest.php +++ b/tests/Telescope/Watchers/CommandWatcherTest.php @@ -7,6 +7,7 @@ use Hypervel\Console\Command; use Hypervel\Contracts\Console\Kernel as KernelContract; use Hypervel\Telescope\EntryType; +use Hypervel\Telescope\Telescope; use Hypervel\Telescope\Watchers\CommandWatcher; use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Tests\Telescope\FeatureTestCase; @@ -30,6 +31,19 @@ public function testCommandWatcherRegisterEntry() $this->assertSame('telescope:test-command', $entry->content['command']); $this->assertSame(0, $entry->content['exit_code']); } + + public function testPackageDiscoveryIsIgnoredWhileRecordingIsActive(): void + { + $this->app->make(KernelContract::class) + ->registerCommand($this->app->make(PackageDiscoverCommand::class)); + + $this->assertTrue(Telescope::isRecording()); + + $this->app->make(KernelContract::class) + ->call('package:discover'); + + $this->assertCount(0, $this->loadTelescopeEntries()); + } } class MyCommand extends Command @@ -40,3 +54,12 @@ public function handle() { } } + +class PackageDiscoverCommand extends Command +{ + protected ?string $signature = 'package:discover'; + + public function handle(): void + { + } +} From 6e381db49d62a7f27c2bfdf67de4e6eb056a5918 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:14:54 +0000 Subject: [PATCH 11/19] fix: persist Telescope scheduled tasks at task completion Boot-register scheduled-task terminal listeners without argv gates and let Telescope own deferred storage at each finite coroutine boundary. Remove explicit repository and store ownership, suppress duplicate Finished-to-Failed terminal delivery for one task, and verify success, failure, configured ignores, distinct batches, output, and persistence before the scheduler parent exits. --- .../src/Watchers/ScheduleWatcher.php | 34 ++--- .../Watchers/ScheduleWatcherTest.php | 122 +++++++++++++++--- 2 files changed, 113 insertions(+), 43 deletions(-) diff --git a/src/telescope/src/Watchers/ScheduleWatcher.php b/src/telescope/src/Watchers/ScheduleWatcher.php index 427dade38..bf93aa371 100644 --- a/src/telescope/src/Watchers/ScheduleWatcher.php +++ b/src/telescope/src/Watchers/ScheduleWatcher.php @@ -6,62 +6,52 @@ use Hypervel\Console\Events; use Hypervel\Console\Scheduling\CallbackEvent; +use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Foundation\Application; -use Hypervel\Telescope\Contracts\EntriesRepository; use Hypervel\Telescope\IncomingEntry; use Hypervel\Telescope\Telescope; class ScheduleWatcher extends Watcher { - /** - * The entries repository. - */ - protected ?EntriesRepository $entriesRepository = null; + protected const LAST_RECORDED_TASK_CONTEXT_KEY = '__telescope.schedule_watcher.last_recorded_task'; /** * The application instance. */ - protected ?Application $app = null; + protected Application $app; /** * Register the watcher. */ public function register(Application $app): void { - if (! in_array($_SERVER['argv'][1] ?? null, ['crontab:run', 'schedule:run'])) { - return; - } - $this->app = $app; - $this->entriesRepository = $app->make(EntriesRepository::class); - - Telescope::startRecording(); - $app->make(Dispatcher::class) ->listen([ - Events\ScheduledTaskStarting::class, Events\ScheduledTaskFinished::class, Events\ScheduledTaskFailed::class, ], [$this, 'recordCommand']); } /** - * Record a scheduled command was executed. + * Record a scheduled command that was executed. */ - public function recordCommand(object $event): void + public function recordCommand(Events\ScheduledTaskFailed|Events\ScheduledTaskFinished $event): void { - if ($event instanceof Events\ScheduledTaskStarting) { - Telescope::startRecording(); + if (! Telescope::isRecording()) { return; } - if (! Telescope::isRecording()) { + $task = $event->task; + $taskId = spl_object_id($task); + + if (CoroutineContext::get(static::LAST_RECORDED_TASK_CONTEXT_KEY) === $taskId) { return; } - $task = $event->task; + CoroutineContext::set(static::LAST_RECORDED_TASK_CONTEXT_KEY, $taskId); Telescope::recordScheduledCommand(IncomingEntry::make([ 'command' => $task instanceof CallbackEvent ? 'Closure' : $task->command, @@ -71,7 +61,5 @@ public function recordCommand(object $event): void 'user' => $task->user, 'output' => $task->getOutput($this->app), ])); - - Telescope::store($this->entriesRepository); } } diff --git a/tests/Telescope/Watchers/ScheduleWatcherTest.php b/tests/Telescope/Watchers/ScheduleWatcherTest.php index c990784b4..238c3c57f 100644 --- a/tests/Telescope/Watchers/ScheduleWatcherTest.php +++ b/tests/Telescope/Watchers/ScheduleWatcherTest.php @@ -4,42 +4,29 @@ namespace Hypervel\Tests\Telescope\Watchers; +use Hypervel\Console\Events\ScheduledTaskFailed; use Hypervel\Console\Events\ScheduledTaskFinished; use Hypervel\Console\Events\ScheduledTaskStarting; use Hypervel\Console\Scheduling\Event; +use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Coroutine\Waiter; use Hypervel\Telescope\EntryType; +use Hypervel\Telescope\Storage\EntryModel; +use Hypervel\Telescope\Telescope; use Hypervel\Telescope\Watchers\ScheduleWatcher; use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Tests\Telescope\FeatureTestCase; use Mockery as m; +use RuntimeException; #[WithConfig('telescope.watchers', [ ScheduleWatcher::class => true, ])] class ScheduleWatcherTest extends FeatureTestCase { - protected function defineEnvironment($app): void + public function testScheduleRegistersEntryWithoutACommandStartEvent(): void { - $_SERVER['argv'][1] = 'schedule:run'; - - parent::defineEnvironment($app); - } - - protected function tearDown(): void - { - unset($_SERVER['argv'][1]); - - parent::tearDown(); - } - - public function testScheduleRegistersEntry() - { - $this->app->make(Dispatcher::class) - ->dispatch(new ScheduledTaskStarting( - m::mock(Event::class) - )); - $task = m::mock(Event::class); $task->command = $command = 'command'; $task->description = $description = 'description'; @@ -63,4 +50,99 @@ public function testScheduleRegistersEntry() $this->assertSame($user, $entry->content['user']); $this->assertSame($output, $entry->content['output']); } + + public function testFailedScheduleRegistersOneEntry(): void + { + $task = $this->makeTask('failed-command'); + + $this->app->make(Dispatcher::class) + ->dispatch(new ScheduledTaskFailed($task, new RuntimeException('Task failed.'))); + + $entries = $this->loadTelescopeEntries(); + + $this->assertCount(1, $entries); + $this->assertSame('failed-command', $entries->first()->content['command']); + } + + public function testFinishedThenFailedScheduleRegistersOneEntry(): void + { + $task = $this->makeTask('non-zero-command'); + $events = $this->app->make(Dispatcher::class); + + $events->dispatch(new ScheduledTaskFinished($task, 0.1)); + $events->dispatch(new ScheduledTaskFailed($task, new RuntimeException('Non-zero exit.'))); + + $this->assertCount(1, $this->loadTelescopeEntries()); + } + + public function testDifferentSchedulesRegisterSeparateEntriesInTheSameCoroutine(): void + { + $events = $this->app->make(Dispatcher::class); + $events->dispatch(new ScheduledTaskFinished($this->makeTask('first-command'), 0.1)); + $events->dispatch(new ScheduledTaskFinished($this->makeTask('second-command'), 0.1)); + + $entries = $this->loadTelescopeEntries(); + + $this->assertCount(2, $entries); + $this->assertEqualsCanonicalizing( + ['first-command', 'second-command'], + $entries->pluck('content.command')->all(), + ); + } + + public function testIgnoredSchedulerDoesNotRegisterAnEntry(): void + { + config()->set('telescope.ignore_commands', ['schedule:run']); + Telescope::stopRecording(); + + $task = m::mock(Event::class); + $task->shouldNotReceive('getOutput'); + $events = $this->app->make(Dispatcher::class); + $events->dispatch(new ScheduledTaskStarting($task)); + $events->dispatch(new ScheduledTaskFinished($task, 0.1)); + + $this->assertCount(0, $this->loadTelescopeEntries()); + } + + public function testFiniteTaskCoroutinesStoreDistinctBatchesBeforeTheirParentExits(): void + { + config()->set('telescope.defer', true); + Telescope::stopRecording(); + CoroutineContext::forget(Telescope::BATCH_ID_CONTEXT_KEY); + + $events = $this->app->make(Dispatcher::class); + + foreach (['first-command', 'second-command'] as $command) { + $task = $this->makeTask($command); + + (new Waiter(-1))->wait(function () use ($events, $task): void { + $events->dispatch(new ScheduledTaskStarting($task)); + $events->dispatch(new ScheduledTaskFinished($task, 0.1)); + }); + } + + $entries = EntryModel::query()->get(); + + $this->assertCount(2, $entries); + $this->assertCount(2, $entries->pluck('batch_id')->unique()); + } + + /** + * Create a scheduled task with recordable metadata. + */ + protected function makeTask(string $command): Event + { + $task = m::mock(Event::class); + $task->command = $command; + $task->description = $command . ' description'; + $task->expression = '* * * * *'; + $task->timezone = 'UTC'; + $task->user = 'user'; + $task->shouldReceive('getOutput') + ->once() + ->with($this->app) + ->andReturn($command . ' output'); + + return $task; + } } From 8d4198616799cb1ebbd262c5835eec79bfb204c2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:15:01 +0000 Subject: [PATCH 12/19] fix: finalize scheduled Sentry transactions from task outcomes Read the task exit code published before ScheduledTaskFinished to assign success or internal-error status from the finalized outcome. Finish and flush each scheduled transaction once across successful, non-zero Finished-to-Failed, and throw-before-Finished paths, with integration coverage for every terminal sequence. --- .../src/Features/ConsoleSchedulingFeature.php | 18 +++++-- .../ConsoleSchedulingIntegrationTest.php | 54 +++++++++++++++++++ 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/src/sentry/src/Features/ConsoleSchedulingFeature.php b/src/sentry/src/Features/ConsoleSchedulingFeature.php index 85834904a..776734c75 100644 --- a/src/sentry/src/Features/ConsoleSchedulingFeature.php +++ b/src/sentry/src/Features/ConsoleSchedulingFeature.php @@ -139,16 +139,24 @@ public function handleScheduledTaskStarting(ScheduledTaskStarting $event): void $this->pushSpan($transaction); } - public function handleScheduledTaskFinished(): void + public function handleScheduledTaskFinished(ScheduledTaskFinished $event): void { - $this->maybeFinishSpan(SpanStatus::ok()); - $this->maybePopScope(); + $exitCode = $event->task->exitCode(); + $status = $exitCode === null || $exitCode === 0 + ? SpanStatus::ok() + : SpanStatus::internalError(); + + // Gate maybePopScope's flush so duplicate terminal events do not flush twice. + if ($this->maybeFinishSpan($status) !== null) { + $this->maybePopScope(); + } } public function handleScheduledTaskFailed(): void { - $this->maybeFinishSpan(SpanStatus::internalError()); - $this->maybePopScope(); + if ($this->maybeFinishSpan(SpanStatus::internalError()) !== null) { + $this->maybePopScope(); + } } private function startCheckIn( diff --git a/tests/Sentry/Features/ConsoleSchedulingIntegrationTest.php b/tests/Sentry/Features/ConsoleSchedulingIntegrationTest.php index 588e3ed81..abd43ca18 100644 --- a/tests/Sentry/Features/ConsoleSchedulingIntegrationTest.php +++ b/tests/Sentry/Features/ConsoleSchedulingIntegrationTest.php @@ -12,6 +12,9 @@ use Hypervel\Sentry\Features\ConsoleSchedulingFeature; use Hypervel\Tests\Sentry\SentryTestCase; use RuntimeException; +use Sentry\Event as SentryEvent; +use Sentry\EventType; +use Sentry\Tracing\SpanStatus; class ConsoleSchedulingIntegrationTest extends SentryTestCase { @@ -170,6 +173,41 @@ public function testScheduledClosureCreatesTransaction(): void $transaction = $this->getLastSentryEvent(); $this->assertEquals('Closure', $transaction->getTransaction()); + $this->assertSame((string) SpanStatus::ok(), $transaction->getContexts()['trace']['status']); + } + + public function testScheduledClosureWithNonZeroExitCreatesOneFailedTransaction(): void + { + $this->getScheduler()->call(static fn (): false => false)->everyMinute(); + + $this->artisan('schedule:run --once'); + + $this->assertSentryTransactionCount(1); + + $transaction = $this->getCapturedTransactions()[0]; + + $this->assertSame( + (string) SpanStatus::internalError(), + $transaction->getContexts()['trace']['status'], + ); + } + + public function testScheduledClosureThatThrowsBeforeFinishedCreatesOneFailedTransaction(): void + { + $this->getScheduler()->call(static function (): never { + throw new RuntimeException('Scheduled closure failed.'); + })->everyMinute(); + + $this->artisan('schedule:run --once'); + + $this->assertSentryTransactionCount(1); + + $transaction = $this->getCapturedTransactions()[0]; + + $this->assertSame( + (string) SpanStatus::internalError(), + $transaction->getContexts()['trace']['status'], + ); } /** @define-env envSamplingAllTransactions */ @@ -273,6 +311,22 @@ public function testCheckInStateIsCleanedUpAfterTaskCompletes(): void $this->assertSentryCheckInCount(4); } + /** + * Get the captured Sentry transactions. + * + * @return list + */ + protected function getCapturedTransactions(): array + { + return array_values(array_map( + static fn (array $captured): SentryEvent => $captured[0], + array_filter( + $this->getCapturedSentryEvents(), + static fn (array $captured): bool => $captured[0]->getType() === EventType::transaction(), + ), + )); + } + protected function getScheduler(): Schedule { return $this->app->make(Schedule::class); From 4aaf48071390cf031d96497a9d8121c8e20b8aed Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:15:08 +0000 Subject: [PATCH 13/19] fix: remove unused Sentry command configuration Delete the published ignore_commands option because no current Hypervel or Sentry code consumes it. Remove the matching provider filter entry rather than preserving dead configuration or adding a command-tracing mechanism solely to justify the stale surface. --- src/sentry/config/sentry.php | 9 --------- src/sentry/src/SentryServiceProvider.php | 1 - 2 files changed, 10 deletions(-) diff --git a/src/sentry/config/sentry.php b/src/sentry/config/sentry.php index a1fd80e62..8ceee1404 100644 --- a/src/sentry/config/sentry.php +++ b/src/sentry/config/sentry.php @@ -178,15 +178,6 @@ ValidationException::class, ], - // Artisan commands that should not be traced - 'ignore_commands' => [ - 'crontab:run', - 'make:*', - 'migrate*', - 'tinker', - 'vendor:publish', - ], - // HTTP timeout for the Sentry SDK transport (seconds) 'http_timeout' => (float) env('SENTRY_HTTP_TIMEOUT', 2.0), diff --git a/src/sentry/src/SentryServiceProvider.php b/src/sentry/src/SentryServiceProvider.php index 8e9ded87f..689fbc84f 100644 --- a/src/sentry/src/SentryServiceProvider.php +++ b/src/sentry/src/SentryServiceProvider.php @@ -59,7 +59,6 @@ class SentryServiceProvider extends ServiceProvider 'breadcrumbs', 'features', 'pool', - 'ignore_commands', // We resolve the integrations through the container later, so we initially do not pass it to the SDK yet 'integrations', // We have this setting to allow us to capture the .env LOG_LEVEL for the sentry_logs channel From c31f6f5198c869182b84877012e6ce00a6299f4d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:15:23 +0000 Subject: [PATCH 14/19] docs: record request timing and runtime lifecycle design Capture the Laravel, Swoole, Hypervel, Telescope, Sentry, console, and scheduler research behind the request-owned timing design. Document the final ownership boundaries, anti-overengineering constraints, implementation map, regression plan, validation cadence, and completion criteria so future maintenance can distinguish deliberate runtime adaptations from missing Laravel behavior. --- ...1-request-start-time-and-runtime-timing.md | 646 ++++++++++++++++++ 1 file changed, 646 insertions(+) create mode 100644 docs/plans/2026-08-06-1121-request-start-time-and-runtime-timing.md diff --git a/docs/plans/2026-08-06-1121-request-start-time-and-runtime-timing.md b/docs/plans/2026-08-06-1121-request-start-time-and-runtime-timing.md new file mode 100644 index 000000000..4fd14c68b --- /dev/null +++ b/docs/plans/2026-08-06-1121-request-start-time-and-runtime-timing.md @@ -0,0 +1,646 @@ +# Request Start Time and Runtime Timing Correctness + +## Scope + +Replace the process-entry `HYPERVEL_START` constant with request-owned timing, expose one small Hypervel-native request API, migrate every semantic first-party consumer, repair the health view, and remove the stale command-lifecycle assumptions uncovered by the same audit. The implementation spans this components worktree and the separate `contrib/hypervel/hypervel` application skeleton repository. + +This plan is the implementation source of truth. Before implementation, reread `AGENTS.md` and this file in full. Work from the components worktree root unless a step explicitly names the application skeleton repository. + +## Desired outcome + +- Every `Hypervel\Http\Request` has an immutable-by-value start instant that belongs to that request rather than to its worker process. +- Developers retrieve that instant through `$request->startedAt()` as a `Hypervel\Support\CarbonImmutable`. +- `REQUEST_TIME_FLOAT` and `REQUEST_TIME` remain available through the existing server bag, with PHP/Symfony-style uppercase names. +- The HTML health response always reports the current request's render duration accurately in long-lived workers and in Testbench. +- Telescope and Sentry consume the request API rather than reading transport details or a global constant. +- `HYPERVEL_START` disappears from executable framework/application behavior without a deprecated alias, fallback, or replacement global. +- Related Telescope command decisions use dispatched command identity rather than positional process arguments, scheduled tasks execute in finite task-owned coroutines with task-local observability state, and both shipped artisan entrypoints use Symfony's first-argument primitive instead of raw `argv[1]` for their pre-bootstrap server-mode check. +- No unrelated time API changes: `now()`, the Date facade, Carbon, scheduler clocks, and the existing kernel/console lifecycle timers continue to behave as they do now. + +## Goals and invariants + +- Store request-specific state on the request object. Never put it in a global, constant, static, singleton property, or new coroutine-context slot. +- Preserve Laravel's familiar Carbon-facing style while adapting to Hypervel's long-lived Swoole runtime. +- Preserve `Request::server()` and `$request->server->all()` as the complete server-metadata API. Do not expose the raw Swoole request or duplicate its metadata behind another abstraction. +- Normalize absent standard request-time server keys at the request boundary, but preserve values supplied by a supported transport or test. +- Keep `Request::startedAt()` distinct from `Http\Kernel::requestStartedAt()`: the former is transport/request creation time and remains readable for the request object's lifetime; the latter is the later kernel-handle time used by lifecycle threshold callbacks and is cleared during termination. +- Keep `Console\Kernel::commandStartedAt()` behavior unchanged. It is command-kernel lifecycle state, not an HTTP request clock or a per-scheduled-task clock. +- Keep WebSocket semantics honest: the Request object and its start time describe the HTTP upgrade/handshake, not each later WebSocket frame. +- Preserve the existing RequestBridge uppercase conversion for all server values and `HTTP_` header normalization. +- Remove dead branches, imports, configuration, comments, tests, and documentation made obsolete by the final design. +- Preserve the unrelated FacadeDocumenter fixture whose intentionally awkward class/constant name is `HYPERVEL_START`; it tests PHP import grammar and is not the framework constant. + +## Anti-overengineering and performance rules + +- Add exactly one public API: `Request::startedAt()`. Do not add a request-start service, facade, contract method, raw timestamp accessor, middleware, event, DTO, transport wrapper, or metadata registry. +- Keep one protected float on each Request. Do not eagerly allocate Carbon for every HTTP, gRPC, WebSocket, and Reverb request; construct it only when the accessor is called. +- Do not add caching merely to promise Carbon object identity. The contract is the instant's value, not whether repeated calls return the same object. +- Do not add a `REQUEST_TIME` integer fallback. Supported Swoole HTTP/1 and HTTP/2 paths and Symfony's synthetic-request factory provide `REQUEST_TIME_FLOAT`; direct construction needs only the precise `microtime(true)` fallback. Falling back to integer seconds is an unreachable production branch that destroys useful duration precision. +- Do not validate, clamp, reconcile, or invent policy for deliberately malformed server values. Cast the supported `REQUEST_TIME_FLOAT` input to float at the owning boundary. +- Preserve a supplied server-bag value with `??=` rather than rewriting it merely to normalize its PHP scalar type. The Request-owned property is always the canonical float; supported Swoole and Symfony inputs are floats already. +- Do not synchronize the stored instant after arbitrary later mutation of the public server bag. The instant is captured during initialization and is stable for that Request object's lifecycle. +- Do not special-case a caller that deliberately removes the server timestamp before `createFrom()` / `createFromBase()`. Ordinary conversions naturally propagate the normalized server values; contrived stripping gets normal fallback behavior. +- Do not add real network/Swoole timing tests. The bridge contract and application-backed health tests cover the owned boundaries deterministically; Swoole's own timing placement is source-proven. +- Keep scheduler-owned interruption, pause, and maintenance gates in the scheduler coroutine. Give user filters and each actual scheduled-task invocation a finite coroutine boundary, including foreground execution and background dispatch. Preserve sequential foreground behavior and existing background concurrency; do not add a scheduled-task timing API, reset framework, subprocess wrapper, or Telescope-specific scheduler hook. +- Keep the long-lived `schedule:run` command coroutine outside Telescope recording. Start observability inside each task coroutine so queues, defers, spans, and batch identity share the task's real lifecycle. +- The coroutine and channel allocation per due scheduled invocation is deliberate lifecycle ownership on a low-frequency scheduler path, not request hot-path overhead. Do not add pooling or reuse machinery. +- Do not add an entry to `docs/ai/differences-vs-laravel.md`; that file explicitly says it is queued for deletion and forbids new entries. +- Do not build or duplicate Symfony's complete console input definition merely to classify `serve` / `watch` before application bootstrap. Reuse unbound `ArgvInput::getFirstArgument()` and document its narrow residual limit for a space-separated optional `--env` value. +- Churn and compatibility with pre-0.4 code do not justify retaining an inferior shape. Conversely, do not expand the work beyond verified request/command lifecycle defects discovered by this audit. + +## Research and settled facts + +### Repositories and baselines + +- Components implementation baseline: this worktree's `feature/request-start-time` branch, created from components `0.4` at `457fa11af`. +- Application skeleton: `contrib/hypervel/hypervel`, current `0.4`; `artisan:9` is the only semantic skeleton definition of `HYPERVEL_START`. +- Laravel framework reference: `examples/laravel/framework`, current local `13.x`. +- Laravel application skeleton reference: `examples/laravel/laravel`; both `public/index.php:6` and `artisan:7` define `LARAVEL_START` at entry-script startup. +- Swoole reference: upstream `swoole/swoole-src` tag `v6.2.2`, commit `8e8c49915ca5`, cloned at `/tmp/hypervel-swoole-source.w8U8GW` for this audit. +- Broad searches found no semantic `HYPERVEL_START` use in `packages/hypervel`, `packages/hypervel-dev`, or their applications. + +### What Laravel's constant means + +Laravel's `LARAVEL_START` is an entry-script timestamp. Under ordinary PHP-FPM, `public/index.php` runs for each request, so that process-entry timestamp is also a useful approximation of that request's application start. `artisan` similarly records the start of one conventional Artisan invocation. The constant is not a Carbon/global clock implementation and does not power `now()`, the Date facade, Carbon parsing, or PHP time functions. + +Laravel does not expose a Request start-time accessor. Its current health template still reads `LARAVEL_START`, guarded for runtimes where it is absent. Framework PRs `#50012` and `#50018` added and corrected that guard for Octane; they did not create a per-request replacement. Laravel Telescope PR `#664` added a `REQUEST_TIME_FLOAT` fallback specifically for long-lived/custom entry points. Laravel framework PR `#44122` added the separate kernel request-lifecycle duration hook that Hypervel already ports. + +The correct conclusion is not to copy Laravel's entry constant into each Swoole request. The Carbon return type and method naming are Laravel-like; owning the timestamp on Request is a Hypervel enhancement required by its runtime model. + +### Why `HYPERVEL_START` is wrong in Hypervel + +The skeleton added the constant in unreleased 0.4 commit `2915dce`; it is absent from `v0.3.19`. The health template was ported earlier in components commit `193db2a30`, while Testbench later defined the constant solely to make its health assertion pass. + +Hypervel's `artisan` process can remain alive while `serve`, `watch`, or the default `schedule:run` loop handles later work. One process-entry float therefore measures worker/scheduler uptime, not a request or task. In a server process, every later health response and any fallback consumer sees an ever-growing duration. Removing the constant loses no valid per-request capability because it never had request ownership in this architecture. + +The existing timers remain valid at their narrower boundaries: + +- `Foundation\Http\Kernel::handle()` writes a coroutine-local Carbon start for kernel lifecycle duration handlers. It begins after the Request/bridge boundary and is cleared by `terminate()`. +- `Foundation\Console\Kernel::handle()` stores command-kernel start time and clears it during console termination. +- `Console\Commands\ScheduleRunCommand` owns its own loop/task clocks. Scheduled Artisan events call `Kernel::call()` in-process, so an outer process/command constant is not a per-task clock. + +### What Swoole supplies and when + +Swoole's server array includes, as applicable, `request_method`, `request_uri`, `path_info`, `request_time`, `request_time_float`, `server_protocol`, `server_port`, `server_addr`, `remote_port`, `remote_addr`, `master_time`, and `query_string`; request headers are exposed separately. + +The timing fields do not all mean the same thing: + +- HTTP/1 adds integer `request_time` and floating `request_time_float` in `ext-src/swoole_http_request.cc:471-472`, when the worker parses the already-dispatched request buffer and reaches headers-complete. +- The reactor waits for the complete fixed-length or chunked body before dispatching that buffer (`src/server/port.cc`, the body-length/EOF checks before `dispatch_request`). A slow upload therefore is not included in the HTTP/1 start timestamp. +- HTTP/2 adds both request times immediately before the request callback in `ext-src/swoole_http2_server.cc:294-295`. +- `master_time` is `(zend_long) conn->last_recv_time`, a connection-level integer-second value. It is neither precise enough nor semantically correct as a request-start replacement. + +For every valid supported Swoole HTTP/1 or HTTP/2 callback, `request_time_float` is present. The fallback in Request exists for direct `new Request(...)`, custom/test construction, and defensive completeness of the public constructor—not because normal Swoole traffic is expected to omit the field. + +### Why uppercase server names are correct + +`HttpServer\RequestBridge::transformServerParams()` uppercases every Swoole server key and maps headers to PHP/Symfony `$_SERVER` conventions. Thus Swoole's `request_time_float` correctly becomes `REQUEST_TIME_FLOAT`, just as `remote_addr` becomes `REMOTE_ADDR`. Special content headers remain `CONTENT_TYPE` / `CONTENT_LENGTH`. + +The common bridge feeds four first-party server paths: + +- `src/http-server/src/Server.php` +- `src/grpc/src/Server/Server.php` +- `src/websocket-server/src/Server.php` +- `src/reverb/src/Servers/Hypervel/HttpServer.php` + +All normalized Swoole fields already remain available through `$request->server('MASTER_TIME')` or `$request->server()` / `$request->server->all()`. A second metadata API would be redundant and transport-coupled. + +### Current consumers and failures + +| Consumer | Current behavior | Final owner | +|---|---|---| +| Foundation health Blade | Reads process constant when defined; otherwise hides duration | Inject the current Request and call `startedAt()` | +| Testbench Workbench health route | Defines the process constant in the test class | Inject/pass current Request; delete fixture constant | +| Telescope RequestWatcher | Reads `REQUEST_TIME_FLOAT` and has a nullable/positive fallback branch | Call non-null `Request::startedAt()` | +| Sentry tracing middleware | Reads server value, then process constant, then `microtime(true)` | Call `Request::startedAt()` and convert precisely for Sentry | +| Boost collections guide | Uses process start to create a scheduled-task deadline | Use `now()->plus(minutes: 14)` at task invocation | +| Application skeleton `artisan` | Defines `HYPERVEL_START` once for a possibly long-lived process | Delete the definition | + +### Why tests did not catch the health defect + +- The main application-backed health test asserts only status/text such as `Application up`; with no constant, the Blade guard silently omits the duration line. +- `tests/Testbench/Workbench/DiscoversTest.php` defines `HYPERVEL_START` once in `setUp()` and only asserts that `Response rendered in` exists. It neither verifies the number nor issues discriminating requests with different start times. +- The application skeleton is a separate repository, so components tests do not run through its real long-lived `artisan serve` process. +- Telescope reads the correct server field already, while Sentry's fallback masks missing/incorrect setup. No test asserts either consumer's exact start timestamp. + +The new tests must assert exact deterministic values and consecutive-request independence, not merely the presence of timing text. + +### Related console, Telescope, scheduler, and Sentry findings + +The same audit found five live positional-argv defects and one stale config surface: + +- `ScheduleWatcher::register()` decides at provider boot from `$_SERVER['argv'][1]`, recognizes nonexistent `crontab:run`, and misses programmatic/global-option-safe command identity. +- `Telescope::runningApprovedArtisanCommand()` also reads `argv[1]` even though its `BeforeHandle` listener receives the resolved `Command` instance. +- The ignored list contains nonexistent/unreachable `start` and `serve`. `ServerStartCommand` extends Symfony Command directly and does not dispatch Hypervel's `BeforeHandle`; `WatchCommand` does dispatch it and must remain ignored. +- The live Composer command `package:discover` is absent from Telescope's default ignore paths. +- Sentry publishes `sentry.ignore_commands`, but no source in current Hypervel or v0.3 consumes it. It is dead configuration, not an incomplete feature contract. +- Both the canonical and Testbench artisan templates use raw `$_SERVER['argv'][1]` to enter HTTP mode for `serve` / `watch`, although both already create `ArgvInput`. Supported forms such as `--env=production serve`, `-v serve`, and `--ansi watch` therefore miss the mode switch and hit the misleading `APP_RUNNING_IN_CONSOLE is true` server guard. + +These are included because they are verified manifestations of the same process-entry/argv lifecycle assumption. Do not generalize this into a console subsystem rewrite. + +A fifth instance exists on the public `Foundation\Application::runningConsoleCommand()` / `App` facade API. It is a faithful Laravel port but misclassifies a supported invocation such as `artisan --env=production migrate`. The maintainer approved correcting that behavior as an intentional Hypervel improvement. `src/testing/src/Console/TestCommandBase.php` separately slices argv from offset two to forward test-runner options; that is not command classification and is not part of this work. + +A source/test search found no first-party production caller of `runningConsoleCommand()`: only its public contract declaration, facade annotation, implementation, and focused tests exist. The correction therefore has no internal framework call-site blast radius and improves only the behavior promised by the existing public contract. + +The implementation audit then found five scheduler-observability defects behind the old ScheduleWatcher gate: + +- `Dispatcher::listen()` is boot-only worker state. Telescope can register the scheduled-task listeners unconditionally when the enabled watcher boots, as Sentry already does. +- `ScheduleWatcher` force-starts recording on `ScheduledTaskStarting`, overriding an explicit `telescope.ignore_commands: ['schedule:run']`. Conversely, ordinary `BeforeHandle` starts recording in the never-ending scheduler command, so cache polling and any other observed daemon work accumulate until process exit. +- Telescope's default deferred store runs at coroutine exit. Foreground tasks currently execute inline in the daemon coroutine, so task entries are not persisted while the daemon runs and its queue/defer state grows indefinitely. +- `repeatEvents()` bypasses the background branch used by `runEvents()`. A `--once` sub-minute event configured for background execution therefore runs its first invocation in the background and later invocations synchronously. +- The scheduler dispatches `ScheduledTaskFinished` before converting a non-zero exit code to `ScheduledTaskFailed`. ScheduleWatcher records both events identically, while Sentry finalizes the transaction as successful on the first event and cannot correct the already-finished span on the second. + +The root correction is a finite coroutine around each task's user filters and execution, a non-recording daemon, task-local recording from the storage-opportunity layer, one shared foreground/background dispatch method, duplicate outcome suppression in ScheduleWatcher, and finalized-exit-code status in Sentry. Scheduler-owned pause and maintenance gates remain in the scheduler coroutine. This is not a Swoole defect and requires no workaround. + +The implementation gate exposed why that ownership split is required. Testbench's default `array` cache stores values in coroutine context, so a task child copying only Log Context cannot observe a pause flag written in its scheduler parent. Cache-backed maintenance mode has a separate mechanism: `WorkerCachedMaintenanceMode` clears a worker-wide static snapshot after the parent writes its coroutine-local array store, and whichever coroutine reads next repopulates that shared snapshot. A task child reading first can publish a false local result to the whole worker. Production cache-backed maintenance mode requires a store accessible by every server, so no maintenance-specific workaround or broader context copy belongs in the scheduler; the framework test's array store remains useful for proving that scheduler control checks stay with their owner. + +The same audit found that `Event::shouldRepeatNow()` passes null into `abs()` before a repeatable event has ever reached `filtersPass()`. Under strict types this throws a `TypeError`; `schedule:run --once` reaches it when the schedule is already paused and a repeatable event is due. The public owning method must return false until `lastChecked` exists. Paused repeatable events must also advance `lastChecked` when skipped, so their public skipped event follows the configured repeat cadence and a resumed schedule continues at the next natural interval. Keep `runEvents()`'s separate truthy-`lastChecked` guard: it deliberately allows the first evaluation through even though `shouldRepeatNow()` returns false for a never-checked event. + +`Console\Kernel::commandStartedAt()` remains deliberately unchanged. It is the start of the top-level command the Kernel is handling, so a long-running `schedule:run` or `queue:work` correctly retains that lifecycle timestamp; nested `Kernel::call()` / `$this->call()` invocations do not establish another Kernel handle/terminate lifecycle in Laravel or Hypervel. Laravel scheduled commands happen to run in subprocesses and therefore trigger separate command-duration lifecycle handlers. Hypervel cannot reproduce that by calling `Kernel::terminate()` per task because termination tears down application state still owned by the long-lived daemon and concurrent coroutines. Scheduled-task events, including the runtime on `ScheduledTaskFinished`, own per-task observation instead. This settled architectural difference is reported in the final handoff rather than deferred as a todo or obscured behind a partial lifecycle emulation. + +## Final design + +### 1. Request owns the start instant + +Add one protected float and one public accessor to `Hypervel\Http\Request`: + +```php +/** + * The timestamp when the server started processing the request. + */ +protected float $startedAtTimestamp; + +/** + * Get when the server started processing the request. + */ +public function startedAt(): CarbonImmutable +{ + return CarbonImmutable::createFromTimestamp($this->startedAtTimestamp); +} +``` + +Capture and normalize at the top of `initialize()`, before the parent creates its ServerBag: + +```php +$this->startedAtTimestamp = (float) ($server['REQUEST_TIME_FLOAT'] ?? microtime(true)); + +$server['REQUEST_TIME_FLOAT'] ??= $this->startedAtTimestamp; +$server['REQUEST_TIME'] ??= (int) $this->startedAtTimestamp; + +parent::initialize($query, $request, $attributes, $cookies, $files, $server, $content); +``` + +Import `Hypervel\Support\CarbonImmutable` directly. The HTTP package already depends on Carbon and `hypervel/support`; no Composer change is required. + +`CarbonImmutable::createFromTimestamp()` creates the value at zero offset by default (Carbon reports the zone name as `+00:00`, not necessarily the named zone `UTC`). Preserve that dependency behavior rather than resolving application configuration from the HTTP value object; epoch equality and duration comparison are timezone-independent, and callers may use ordinary Carbon timezone conversion for display. Do not assert a timezone name in the Request contract tests. + +This shape gives every construction route a non-null value: + +- Swoole bridge input keeps its exact supplied float. +- Symfony `Request::create()` already supplies both standard time keys; Hypervel preserves them. +- Direct construction captures `microtime(true)` once and exposes that same value through the accessor and server bag. +- Explicit `initialize()` starts a new Request lifecycle and recaptures/replaces the property. +- `createFrom()` and `createFromBase()` naturally propagate the normalized server bag and therefore the instant. +- A direct clone and Symfony `duplicate()` preserve the source Request's property because they represent clones/subrequests of that logical request. Even `duplicate(server: [...])` does not redefine object identity by replacing a public parameter bag. +- Later mutation/removal of `REQUEST_TIME_FLOAT` from the ServerBag does not rewrite the stored start instant. + +Do not add the method to an HTTP contract: the concrete Request is the existing public request-information API, and no contract currently promises its full convenience surface. + +### 2. Keep server metadata on the existing API + +Make no production change to `RequestBridge`. Extend its existing uppercase test with a precise `request_time_float` value and prove both: + +```php +$request->server('REQUEST_TIME_FLOAT'); +$request->startedAt(); +``` + +represent the same microsecond instant. Keep exact type/precision for the float. The public request guide should mention `server()` with and without a key so developers know how to retrieve the rest of Swoole's normalized metadata. + +Do not promise that the timestamp includes socket acceptance, queueing, or body upload. Describe it as Swoole's worker-side request time before Hypervel's bridge/kernel processing. + +### 3. Migrate timing consumers + +#### Telescope request duration + +Delete its direct server read and nullable guard. Derive milliseconds from the request-owned Carbon value and Carbon's current clock: + +```php +'duration' => floor($event->request->startedAt()->diffInMilliseconds()), +``` + +The accessor is non-null, so a `> 0 ? ... : null` branch would be dead code. Freeze Carbon and seed `REQUEST_TIME_FLOAT` in the watcher test to assert an exact duration. + +#### Sentry transaction start + +Delete the `REQUEST_TIME_FLOAT` / `HYPERVEL_START` / `microtime(true)` fallback chain. Sentry requires epoch seconds as a float, so preserve Carbon's microseconds explicitly: + +```php +$context->setStartTimestamp( + $request->startedAt()->getPreciseTimestamp(6) / 1_000_000 +); +``` + +Do not add a second raw-float Request method solely for this consumer. A Carbon-to-float conversion happens only when tracing starts, while every untraced request avoids eager Carbon allocation. + +#### Kernel lifecycle + +Do not replace `Kernel::requestStartedAt()` with the Request accessor. Its start boundary, timezone conversion, duration callbacks, coroutine cleanup, and nullable post-termination behavior are different and valid. Add a small assertion to existing kernel lifecycle coverage that the Request accessor remains stable after kernel termination while the kernel getter becomes null. + +### 4. Repair both health routes + +The main `ApplicationBuilder` route already injects `Request $request`; pass it explicitly to the view: + +```php +return response(View::file($path, [ + 'request' => $request, + 'status' => $health, +]), status: $status); +``` + +Change the Testbench Workbench route closure to inject `Request $request`, import the class, and pass the same explicit view data. Do not call the global `request()` helper inside the Blade template: without RequestContext, `HttpServiceProvider` deliberately returns a throwaway synthetic Request, which would fabricate a near-zero duration. + +Replace the guarded constant branch with unconditional request timing: + +```blade +Response rendered in {{ round($request->startedAt()->diffInMilliseconds()) }}ms. +``` + +JSON health responses remain exactly `{"status":"up|down"}` and do not gain timing fields. + +Use Carbon's frozen current time plus seeded server variables to assert deterministic `5000ms` output. In the main health integration test, send two sequential requests in the same application process with different seeded starts and assert their distinct durations. This proves request ownership rather than worker uptime without sleeping. + +In Testbench, remove the entire `setUp()` override that defines the constant and remove `Override` if no other use remains. Make the existing health assertion exact instead of merely checking the phrase. + +### 5. Remove `HYPERVEL_START` and its stale documentation + +- Delete `define('HYPERVEL_START', microtime(true));` from the separate application skeleton's `artisan` entry point. +- In that same entrypoint, instantiate `ArgvInput` once before the HTTP-bootstrap check, classify with `$input->getFirstArgument()`, and pass the same input to `Application::handleCommand()`: + +```php +$input = new ArgvInput(); + +if (in_array($input->getFirstArgument(), ['serve', 'watch'], true)) { + // Existing environment assignments. +} + +$status = $app->handleCommand($input); +``` + +- Make the equivalent change in `src/testbench/hypervel/artisan`: construct its fully-qualified `ArgvInput` once, use `getFirstArgument()` for the check, capture the input in the immediately invoked closure, and remove the inner duplicate construction. +- An unbound `ArgvInput` correctly skips valueless global options and `--env=value`, covering `-v serve`, `--ansi watch`, and `--env=production serve`. It cannot know that the separate token after optional `--env` is an option value, so `--env production serve` still resolves `production`. Record this limitation honestly; reproducing/binding the full application and command option definitions before bootstrap is disproportionate to this two-command mode check. +- Replace the collections scheduled-task example with the already-established local idiom: + +```php +Invoice::pending()->cursor() + ->takeUntilTimeout(now()->plus(minutes: 14)) + ->each(fn (Invoice $invoice) => $invoice->submit()); +``` + +- Remove that snippet's now-unused `CarbonImmutable` import. +- Do not retain a deprecated constant, `defined()` branch, compatibility shim, migration note, changelog entry, or upgrade guide. The constant has no released 0.3 contract, and compatibility/churn is not a design constraint for this work. +- Leave the FacadeDocumenter import-resolution fixture untouched. Final stale searches must distinguish that fixture from semantic uses. + +### 6. Correct Telescope command and schedule lifecycle detection + +#### Recording-state classifier + +Rename the protected argv-dependent classifier to reflect its actual input and accept the resolved command name. This is a deliberate divergence from upstream Telescope's `runningApprovedArtisanCommand($app)`: Hypervel no longer inspects process state here and the honest method name prevents a future upstream merge from restoring the wrong runtime assumption. + +```php +protected static function commandIsApproved(?string $command): bool +{ + return ! in_array($command, array_merge([ + // 'migrate', + 'migrate:rollback', + 'migrate:fresh', + // 'migrate:refresh', + 'migrate:reset', + 'migrate:install', + 'package:discover', + 'queue:listen', + 'queue:work', + 'horizon', + 'horizon:work', + 'horizon:supervisor', + 'watch', + ], config('telescope.ignore_commands', [])), true); +} +``` + +Keep the two upstream commented migration entries according to the repository's port rules. Remove `start` and `serve`; keep `watch`; add `package:discover`. + +Update `manageRecordingStateForCommands()` to receive `BeforeHandle $event` and pass `$event->command->getName()` to this classifier. Do not retain an argv fallback or `runningInConsole()` guard: `BeforeHandle` is already a Hypervel-console event carrying authoritative resolved identity. + +Do not start recording when that resolved command is `schedule:run`. Unlike the other long-running commands, it is not added to the default ignored list because the same classifier must decide whether a user explicitly allows task-local recording. Leave one concise WHY comment at this branch. + +At the same boot-time storage-opportunity boundary, listen for `ScheduledTaskStarting`. Start recording in that task coroutine only when `shouldListen()` and `commandIsApproved('schedule:run')` are both true. This respects `telescope.ignore_commands: ['schedule:run']`, keeps the daemon coroutine clean, and lets every enabled watcher observe the task even if ScheduleWatcher itself is disabled. Do not add recording-state save/restore machinery: a finite programmatic `schedule:run --once` invoked from an already-recording outer operation may remain part of that operation's recording. + +#### CommandWatcher + +Its default `shouldIgnore()` list becomes: + +```php +[ + 'schedule:run', + 'package:discover', +] +``` + +Delete nonexistent `crontab:run`. This preserves upstream's relative order after omitting Hypervel's nonexistent `schedule:finish`. Configuration-provided ignores remain merged as today, and command-name membership uses strict comparison at both classifier boundaries. + +#### ScheduleWatcher + +Register `ScheduledTaskFinished` and `ScheduledTaskFailed` listeners unconditionally when the enabled watcher boots. Do not inspect argv, listen for `CommandStarting`, register listeners at runtime, retain a boolean guard, or listen for `ScheduledTaskStarting`; recording state belongs to the storage-opportunity layer above. + +Keep only the non-null Application field needed to retrieve task output. Delete the `EntriesRepository` property/resolution and explicit `Telescope::store()` call: `Telescope::record()` already installs one deferred store per coroutine through `HAS_STORED_CONTEXT_KEY`, and each real task now has a finite coroutine lifetime. + +The scheduler may dispatch Finished and then Failed for one non-zero task. Store the last recorded task object's ID in one ScheduleWatcher-owned coroutine-context key. Return when the same task reaches a second terminal event; otherwise publish the ID before recording. This is constant-space invocation state, not a registry, and it also prevents duplicate dispatch/listener delivery while allowing a different task in the same synthetic test coroutine. + +Do not add `ScheduledTaskSkipped`: upstream Telescope does not record it and no verified requirement exists. + +#### Scheduler task coroutine boundary + +Extend `Waiter::wait()` and the global `wait()` helper with the same trailing `bool|array $copyContext = false` option already used by `Parallel`, `co()`, and `go()`. `false` preserves a fresh child context; `true` or an empty array copies all keys; a non-empty array copies only those keys. Internally choose `Coroutine::create()` or `Coroutine::fork()` and retain Waiter's existing result, original-exception, timeout, cancellation, defer, and join semantics. Document and test the additive public option. + +The additive base capability makes `Foundation\Testing\Coroutine\Waiter` redundant and its old override fatally incompatible. Delete that one-method subclass and its duplicate test file. Type `MakesHttpRequests`'s protected waiter property/getter to the base Waiter and pass `copyContext: true` at the single synthetic-request boundary; update `RequestContextSynchronizerTest` to use the base Waiter explicitly. Move the historical `ThrowingReplicableContext` regression into the base Waiter suite before deletion so replication failures remain proven to surface directly instead of becoming timeouts. This is the minimum resolution of the inheritance fatal, not a new testing abstraction. + +In `ScheduleRunCommand`, keep scheduler-owned gates in the scheduler coroutine: `runEvents()` reads pause state once before its event loop; `repeatEvents()` reads pause state once per while iteration and checks sticky maintenance state per due event. A paused event is marked checked and dispatches `ScheduledTaskSkipped` in the scheduler coroutine; a maintenance-blocked event remains silent, matching the existing public behavior. + +Run each event's user filters and either foreground execution or background spawn in a synchronously waited child coroutine with no timeout and only `ContextRepository::CONTEXT_KEY` copied. A filter rejection dispatches `ScheduledTaskSkipped` in that task child. The two skipped-event contexts are deliberately different because pause is scheduler-owned while filters are user task code. No first-party listener consumes skipped events, and synchronous userland listeners run in the context that owns the decision; do not add an event-owned coroutine or deferred-work wrapper. + +Framework `Coroutine::afterCreated` hooks continue to propagate their own explicitly owned observability state; arbitrary parent CoroutineContext is not copied. Foreground scheduled tasks receive an independent replicated Log Context, so their log-context mutations do not leak back to the scheduler or later tasks. Also copy log Context into the existing outer `runOnce()` Waiter so `--once` no longer drops it before task dispatch. + +Fix `Event::shouldRepeatNow()` at its public boundary by requiring a non-null `lastChecked` before computing the absolute elapsed seconds. Preserve the separate first-evaluation guard in `runEvents()`. When a repeatable event is skipped because the schedule is paused, set `lastChecked` to the current scheduler time before dispatching the skipped event. This prevents both the never-checked crash and repeated skipped-event delivery on every 100-millisecond poll without adding state or another coroutine. + +Extract the foreground/background dispatch branch shared by `runEvents()` and `repeatEvents()`. Foreground work stays sequential because the parent waits for the invocation child. Background work still forks through the existing bounded `Concurrent`, returns after the spawn, and dispatches `ScheduledBackgroundTaskFinished` in that background child. This fixes sub-minute `--once` repeats bypassing background execution without adding another concurrency mechanism. + +The normal scheduler daemon never starts Telescope recording and therefore has no batch ID. Each task child initially receives an absent/null batch key and `ScheduledTaskStarting` generates a fresh UUID, so consecutive tasks store distinct batches without a reset or special-case batch API. + +Keep `onOneServer` unchanged. `Schedule::serverShouldRun()` caches the elected result on the shared Schedule object by event mutex name and minute, so later sub-minute task children reuse the first result without consulting a coroutine-local cache store. Existing shared-store requirements continue to govern election across workers and servers. + +#### Sentry scheduled-task outcome + +Type `ConsoleSchedulingFeature::handleScheduledTaskFinished()` with `ScheduledTaskFinished` and derive the final status from the task's already-published coroutine-local `exitCode()`: null or zero is `ok`, non-zero is `internalError`. Finish and pop exactly once on Finished. A subsequent Failed event is harmless because the span stack is empty; a task that throws before Finished still reaches Failed with its span open and is finalized as `internalError` there. Do not duplicate scheduler failure rules or defer outcome resolution. + +### 7. Fix public application command classification + +`Hypervel\Foundation\Application::runningConsoleCommand()` is public, facade-exposed, and intentionally mirrors Laravel, but its `$_SERVER['argv'][1]` implementation has the same verified option-prefix defect: + +```php +$_SERVER['argv'] = ['artisan', '--env=production', 'migrate']; + +$app->runningConsoleCommand('migrate'); // currently false +``` + +Preserve the method name, parameters, return type, facade annotation, contract declaration, and ordinary Laravel behavior while resolving the command with `(new ArgvInput())->getFirstArgument()`. Import `Symfony\Component\Console\Input\ArgvInput` directly. Add focused `tests/Foundation/ApplicationRunningInConsoleTest.php` coverage for `--env=production migrate`, `-v queue:work`, and the existing direct forms. + +Do not cache the parsed command or add a console-input service: argv can vary in tests and programmatic environments, the method has no first-party hot-path caller, and a fresh unbound ArgvInput is the smallest correct primitive for the supported option forms. Retain the same honest residual limit as the entrypoints: without binding a full application input definition, `--env production migrate` resolves `production` as the first argument. Duplicating Symfony/Hypervel's global option definition solely to classify this pre-bootstrap edge form would be a brittle parallel parser; keep the bounded native improvement instead. + +### 8. Remove dead Sentry command configuration + +Delete the `ignore_commands` key and explanatory block from `src/sentry/config/sentry.php`, and remove it from `SentryServiceProvider::HYPERVEL_SPECIFIC_OPTIONS`. Do not implement a command-tracing filter just to preserve dead configuration, and do not add a compatibility `unset` path. + +Keep all live Sentry tracing, feature, pool, breadcrumb, and SDK configuration unchanged. + +### 9. Public documentation + +Add `Request Start Time and Server Metadata` to `src/boost/docs/requests.md` under “Interacting With The Request” and its table of contents. Keep it concise and task-oriented: + +```php +$startedAt = $request->startedAt(); + +$requestTime = $request->server('REQUEST_TIME_FLOAT'); +$server = $request->server(); +``` + +State that: + +- `startedAt()` returns `Hypervel\Support\CarbonImmutable`; +- it represents Swoole's worker-side start for this request, before Hypervel bridge/kernel handling; +- all normalized server values use PHP/Symfony uppercase names; +- the Request start remains available after termination, while the kernel lifecycle getter is a separate, later timing boundary; +- for WebSocket handling it describes the HTTP handshake, not individual messages. + +Do not enumerate every Swoole key as a guaranteed cross-version public contract in the user guide; show the generic `server()` access and use request time as the stable example. Do not claim that `server('REQUEST_TIME_FLOAT')` and `startedAt()` are universally interchangeable: they agree on the ordinary transport/conversion path, while the accessor deliberately remains stable across later ServerBag mutation and `duplicate(server: [...])`, and a caller-supplied numeric string remains a string in the bag. Keep the audited key list and those edge semantics in this plan. + +Do not edit package READMEs or `docs/ai/differences-vs-laravel.md`. This is a documented Hypervel API, not an omitted Laravel feature requiring the triple-record convention, and the AI differences file expressly rejects new entries. `AGENTS.md` still names that file as a documentation destination, which contradicts the file's own first-line prohibition; do not resolve that governance conflict opportunistically in this work, but report it to the maintainer in the final handoff. + +### 10. Keep the settled Kernel lifecycle boundary + +Do not change `Console\Kernel::commandStartedAt()` or command lifecycle duration handlers. They describe the top-level command passed through Kernel `handle()` / `terminate()`, including a long-running `schedule:run`; nested `Kernel::call()` and `$this->call()` do not establish a second Kernel lifecycle in Laravel or Hypervel. + +Do not add a scheduler todo. Hypervel's in-process scheduled command intentionally uses `Kernel::call()` and task events rather than a subprocess. Calling `Kernel::terminate()` per task would dispatch application termination and tear down state still owned by the daemon and concurrent coroutines. The new task coroutine supplies invocation-local cleanup, while `ScheduledTaskFinished::$runtime` supplies task duration. Report the narrower Laravel difference—top-level command lifecycle duration handlers do not fire per Hypervel scheduled task—in the final maintainer handoff; do not implement a partial termination lifecycle or a second timing API. + +## File-by-file implementation map + +### Components worktree + +| File | Change | +|---|---| +| `src/http/src/Request.php` | Add float ownership, initialize normalization, Carbon accessor. | +| `src/support/src/Facades/Request.php` | Regenerate the facade docblock so the new Request accessor is exposed to static analysis. | +| `tests/Http/HttpRequestTest.php` | Add construction, normalization, stability, conversion, clone/duplicate, and reinitialize coverage. | +| `tests/HttpServer/RequestBridgeTest.php` | Pin lowercase-to-uppercase float transport and accessor precision. | +| `src/foundation/src/Application.php` | Resolve public console-command classification through unbound `ArgvInput::getFirstArgument()`. | +| `tests/Foundation/ApplicationRunningInConsoleTest.php` | Preserve direct forms and cover option-prefixed command classification. | +| `src/foundation/src/Configuration/ApplicationBuilder.php` | Pass the already-injected Request into health view data. | +| `src/testbench/src/Workbench/Workbench.php` | Inject and pass Request into Workbench health view. | +| `src/testbench/hypervel/artisan` | Reuse one ArgvInput and replace positional server-mode detection with `getFirstArgument()`. | +| `src/foundation/src/resources/health-up.blade.php` | Remove constant guard and render request-owned deterministic duration. | +| `tests/Integration/Foundation/Support/Providers/RouteServiceProviderHealthTest.php` | Add exact and sequential HTML timing regressions; preserve JSON/error cases. | +| `tests/Testbench/Workbench/DiscoversTest.php` | Delete constant setup and assert exact request duration. | +| `src/telescope/src/Watchers/RequestWatcher.php` | Use `startedAt()`; delete nullable server fallback. | +| `tests/Telescope/Watchers/RequestWatchersTest.php` | Assert exact deterministic request duration. | +| `src/telescope/src/Telescope.php` | Classify the supplied resolved command and correct default ignore names. | +| `src/telescope/src/ListensForStorageOpportunities.php` | Classify BeforeHandle's resolved command, keep the scheduler daemon unrecorded, and start approved task-local recording on `ScheduledTaskStarting`. | +| `tests/Telescope/Telescope/TelescopeTest.php` | Cover ordinary, ignored, resolved-name, scheduler-daemon, task-local, batch, and deferred-storage recording behavior. | +| `src/telescope/src/Watchers/CommandWatcher.php` | Ignore live `package:discover`/`schedule:run`; delete `crontab:run`. | +| `tests/Telescope/Watchers/CommandWatcherTest.php` | Prove package discovery is not recorded even if recording is already active. | +| `src/telescope/src/Watchers/ScheduleWatcher.php` | Boot-register terminal task listeners, rely on coroutine-deferred storage, and suppress a duplicate terminal event for the same task. | +| `tests/Telescope/Watchers/ScheduleWatcherTest.php` | Cover boot registration, successful/failed task entries, duplicate terminal suppression, output, and finite-coroutine persistence. | +| `src/coroutine/src/Waiter.php` | Add the existing selective context-copy contract to finite waited child coroutines. | +| `src/coroutine/src/functions.php` | Expose Waiter's additive `copyContext` option through the global `wait()` helper. | +| `tests/Coroutine/WaiterTest.php` | Cover fresh/default, all-key, selected-key, exception, defer, timeout, and cancellation behavior. | +| `src/boost/docs/coroutines.md` | Document `wait()` context-copy semantics alongside `go()`, `co()`, and `parallel()`. | +| `src/foundation/src/Testing/Coroutine/Waiter.php` | Delete the wrapper made redundant by the base context-copy API. | +| `tests/Foundation/Testing/Coroutine/WaiterTest.php` | Delete after moving its unique replication-failure regression to the base suite. | +| `src/foundation/src/Testing/Concerns/MakesHttpRequests.php` | Type the protected waiter extension points to the base class and explicitly copy all context for synthetic test requests. | +| `tests/Foundation/Testing/RequestContextSynchronizerTest.php` | Use the base Waiter with explicit full-context copying. | +| `src/console/src/Scheduling/Event.php` | Make a never-checked repeatable event report not-ready instead of passing null into `abs()`. | +| `tests/Console/Scheduling/EventTest.php` | Cover the public never-checked repeat predicate. | +| `src/console/src/Commands/ScheduleRunCommand.php` | Keep scheduler gates in the parent, give user filters/task execution a waited child, advance paused repeat cadence, copy log context, and share foreground/background dispatch across first and repeated invocations. | +| `tests/Console/Scheduling/ScheduleRunCommandTest.php` | Cover finite invocation boundaries, filters/defers, log-context isolation, sequential foreground work, and repeated background dispatch. | +| `tests/Console/Scheduling/ScheduleRunContextPropagationTest.php` | Replace shared foreground context expectations with the independent replicated Log Context contract. | +| `tests/Integration/Console/Scheduling/SubMinuteSchedulingTest.php` | Cover pre-paused repeat safety, natural skipped-event cadence, maintenance, and `evenWhenPaused()` behavior. | +| `src/sentry/src/Tracing/Middleware.php` | Use accessor and exact Carbon-to-epoch conversion. | +| `tests/Sentry/Tracing/MiddlewareTest.php` | Assert captured transaction's exact microsecond start timestamp. | +| `src/sentry/src/Features/ConsoleSchedulingFeature.php` | Finalize scheduled transactions from the published task exit code exactly once. | +| `tests/Sentry/Features/ConsoleSchedulingIntegrationTest.php` | Prove successful and non-zero scheduled commands publish one transaction with the correct final status. | +| `src/sentry/config/sentry.php` | Remove never-consumed `ignore_commands`. | +| `src/sentry/src/SentryServiceProvider.php` | Stop filtering the deleted non-SDK option. | +| `tests/Foundation/Http/KernelTest.php` | Pin Request start persistence versus kernel timing cleanup in existing lifecycle coverage. | +| `src/boost/docs/requests.md` | Document accessor and existing server metadata API. | +| `src/boost/docs/collections.md` | Use invocation-local `now()` deadline and remove unused import. | + +### Separate application skeleton + +| File | Change | +|---|---| +| `contrib/hypervel/hypervel/artisan` | Delete the process-entry constant, reuse one ArgvInput, and replace positional server-mode detection. | + +No private package or application file changes are expected; repeat the broad search before completion in case the repositories move during implementation. + +## Detailed test plan + +Every new or modified test method declares `: void`. Preserve the established test base and file location for each package; do not create a production seam solely to make timing controllable. + +### Request unit contract + +Use fixed microsecond timestamps; compare exact epoch/microsecond values rather than formatted approximate seconds. + +1. Explicit `REQUEST_TIME_FLOAT` accepts Swoole's float and a numeric-string form via the boundary cast, preserves the caller's supplied server-bag scalar, populates a missing integer `REQUEST_TIME`, and returns the exact zero-offset Carbon instant from the canonical float property without asserting the timezone's display name. +2. Direct construction with neither time key captures one fallback between before/after `microtime(true)` bounds and writes matching float/integer server values. Do not sleep or add a clock-injection seam. +3. Repeated `startedAt()` calls compare equal by timestamp. Do not assert Carbon object identity. +4. Mutating/removing `REQUEST_TIME_FLOAT` after initialization does not alter the stored instant. +5. Direct clone and `duplicate()` preserve the instant; `duplicate(server: [...])` still preserves the logical source Request instant even though its replacement ServerBag may differ. +6. `createFrom()` and `createFromBase()` preserve ordinary normalized timestamps. +7. Calling `initialize()` with a new precise timestamp resets the instant and both normalized server fields for the new lifecycle. +8. Do not add an integer-only fallback test; the deliberate behavior is precise fallback capture when the float is absent. + +### Transport and consumer regressions + +- RequestBridge: a lowercase Swoole `request_time_float` emerges as uppercase `REQUEST_TIME_FLOAT` with exact float type/precision, and `startedAt()` matches. This single shared-bridge test covers the contract used by HTTP, gRPC, WebSocket, and Reverb servers. +- Health application route: freeze Carbon, seed five seconds earlier, assert `Response rendered in 5000ms.`; then issue a second request with a different start and assert its own value rather than accumulated worker time. +- Health Testbench route: the same deterministic five-second assertion works without defining any constant. +- Health JSON and diagnosing-error tests remain unchanged in shape/status. +- Telescope RequestWatcher: fixed current Carbon time plus seeded start produces the exact floored millisecond duration and never null. +- Sentry middleware: the captured transaction's `getStartTimestamp()` exactly equals a six-decimal request timestamp. +- Kernel: after `terminate()`, `Kernel::requestStartedAt()` is null while the same Request's `startedAt()` value remains available. + +### Telescope console regressions + +- `BeforeHandle` for an ordinary resolved command starts recording. +- `BeforeHandle` for `package:discover`, `watch`, and a configured ignored command does not start recording. Keep this table focused; do not test removed nonexistent command names. +- `BeforeHandle` for `schedule:run` does not start recording in the long-lived daemon coroutine. +- CommandWatcher records an ordinary command and omits `package:discover` while recording is already active. +- `ScheduledTaskStarting` starts task-local recording when `schedule:run` is approved, independent of ScheduleWatcher being enabled. +- Configuring `telescope.ignore_commands: ['schedule:run']` leaves task recording disabled and produces no completed-task entry. +- ScheduleWatcher's terminal listeners exist at watcher boot without argv or a preceding command event. +- A successful task records one entry, and a thrown task records one entry. +- `ScheduledTaskFinished` followed by `ScheduledTaskFailed` for the same non-zero task records one entry, while a different task in the same coroutine still records normally. +- Two finite task coroutines receive distinct Telescope batch IDs without reset machinery. +- With Telescope's normal deferred storage enabled, a completed task entry is persisted before its long-lived parent coroutine exits. +- Scheduler cache polling and other daemon work produce no Telescope entries. +- Existing scheduled-task fields and output assertions remain intact. + +### Coroutine and scheduler regressions + +- Waiter starts with a fresh context by default, can copy all context, and can copy only named keys through both `Waiter::wait()` and `wait()`. +- Waiter preserves its original result, exception, timeout, cancellation, join, and defer-completion semantics for every context-copy mode. +- A `ReplicableContext` failure while copying through Waiter surfaces as the original exception before child execution rather than as a wait timeout. +- Synthetic HTTP test requests and RequestContextSynchronizer regressions continue to inherit parent context explicitly through the base API after the Foundation wrapper is removed. +- A repeatable Event with no `lastChecked` value returns false from `shouldRepeatNow()` rather than throwing. +- Scheduler interruption, pause, and maintenance checks stay in the scheduler coroutine. User filters and foreground execution share one distinct waited task child, and invocation defers complete before the next foreground event begins. +- A schedule paused before `schedule:run --once` starts completes safely with a due repeatable event instead of reaching `abs(null)`. +- A repeatable event paused at second 30 publishes exactly its 30 natural skipped occurrences through seconds 30–59, not one event per 100-millisecond scheduler poll. An `evenWhenPaused()` event still runs all 60 occurrences. +- Only logging `ContextRepository::CONTEXT_KEY` is copied from the scheduler parent; unrelated arbitrary context is absent. +- A foreground task sees the parent's initial Log Context through an independent replica; changed and child-only values do not leak back to the scheduler parent. +- Foreground events remain sequential. +- Background events remain non-blocking and bounded by the existing `Concurrent`; repeated sub-minute `--once` invocations use the same background path and complete through the existing background-finished event. + +### Sentry scheduled-transaction regressions + +- A successful scheduled task finishes exactly one captured transaction with `ok` status. +- A task that publishes a non-zero exit code through Finished then Failed finishes exactly one captured transaction with `internal_error` status. +- A task that throws before Finished is finalized as `internal_error` by Failed, with no open transaction left behind. + +### Public application command classification + +- Existing direct command-name, array/variadic, non-console, missing-argv, `serve`, and `watch` cases retain their behavior. +- `--env=production migrate` and `-v queue:work` resolve the actual command name and match only the requested command. +- Keep the separated-value `--env production migrate` limitation explicit in this plan; do not duplicate or partially bind the console application's global option definition to make this one classifier parse more than Symfony's unbound `getFirstArgument()` supports. + +### Artisan entrypoint checks + +- With components' installed Symfony Console, directly verify unbound `ArgvInput::getFirstArgument()` returns `serve` / `watch` for `--env=production serve`, `-v serve`, and `--ansi watch`; record that `--env production serve` returns `production` and remains outside this bounded fix. +- Inspect both entrypoints to ensure the same ArgvInput instance used for classification is later passed to the command kernel/application; do not retain a second construction or raw `argv[1]` check. +- Run `php -l src/testbench/hypervel/artisan` and the Testbench suites. The canonical skeleton has no installed `vendor/`, so its real local gate is `php -l artisan`; do not run `composer test` there unless dependencies are installed for some independent reason. + +### Negative/stale checks + +- No semantic `HYPERVEL_START` remains in components, the application skeleton, private Hypervel packages, or applications. The five expected matches in `tests/FacadeDocumenter/ImportResolutionTest.php` remain and are manually verified as grammar fixtures. +- No `sentry.ignore_commands`, Sentry `ignore_commands` config entry, Telescope `crontab:run`, or Telescope default `start`/`serve` ignore remains. +- Sweep raw argv use rather than checking only known files: run `grep -rn '\$_SERVER.*argv' src/` in components and inspect the canonical skeleton `artisan` separately. The only permitted components production matches are whole-vector environment detection in `Application::detectEnvironment()` and argument forwarding in `src/testing/src/Console/TestCommandBase.php`. No positional `$_SERVER['argv'][1]` production match may remain. Confirm that result with the narrower `grep -rn '\$_SERVER.*argv.*\[1\]' src/` sweep and the separate skeleton inspection. +- No direct `REQUEST_TIME_FLOAT` read remains in first-party consumers outside Request itself, bridge/tests, and public documentation. +- The generated Request facade docblock contains `@method static \Hypervel\Support\CarbonImmutable startedAt()` and its lint test passes. +- No dead imports (`Override`, `CarbonImmutable`, event aliases) or obsolete comments remain. + +## Implementation sequence + +Work one file at a time with `apply_patch`, preserving unrelated worktree changes. Run the named focused test immediately after each coherent source/test pair. + +1. Add `Symfony\Component\Console\Input\ArgvInput` to `src/foundation/src/Application.php`, update `runningConsoleCommand()` to use `getFirstArgument()`, add direct/option-prefixed cases to `tests/Foundation/ApplicationRunningInConsoleTest.php`, and run that focused test file. +2. Add Request unit regressions, implement `Request::$startedAtTimestamp`, initialization normalization, and `startedAt()`, then run `tests/Http/HttpRequestTest.php`. +3. Extend RequestBridge coverage and run `tests/HttpServer/RequestBridgeTest.php`. Do not change RequestBridge production normalization unless the counterfactual test disproves the audited behavior. +4. Update both health route owners and the Blade template; add deterministic application health coverage and run that test file. +5. Update Testbench Workbench and its health test, then run `tests/Testbench/Workbench/DiscoversTest.php` and `composer test:testbench`. +6. Migrate Telescope RequestWatcher and its exact-duration test; run the watcher test file. +7. Migrate Sentry middleware and its exact transaction test; remove dead Sentry command configuration; run `tests/Sentry/Tracing/MiddlewareTest.php`, `tests/Sentry/ConfigTest.php`, and `tests/Sentry/ContainerConfigOptionsTest.php`. +8. Correct Telescope's resolved-command classifier and CommandWatcher with their tests. Keep `schedule:run` excluded from daemon recording and add task-local recording at the storage-opportunity boundary. +9. Add Waiter's selective context-copy option, its focused tests (including the moved replication-failure regression), the matching global helper, and coroutine documentation. Replace the now-redundant Foundation testing wrapper with explicit base-Waiter use in `MakesHttpRequests` and `RequestContextSynchronizerTest`, delete the wrapper/source tests, and run both focused test files plus Testbench. +10. Fix `Event::shouldRepeatNow()` at its public null boundary and run `tests/Console/Scheduling/EventTest.php`. Refactor `ScheduleRunCommand` so scheduler-owned gates remain in the parent while user filters and task execution use one child; advance paused repeat cadence and retain one shared foreground/background dispatch path. Update scheduler context and sub-minute regressions, then run `tests/Console/Scheduling/ScheduleRunCommandTest.php`, `tests/Console/Scheduling/ScheduleRunContextPropagationTest.php`, and `tests/Integration/Console/Scheduling/SubMinuteSchedulingTest.php`. +11. Boot-register ScheduleWatcher terminal listeners, remove explicit storage ownership, add duplicate terminal suppression, and update its focused tests. Complete the Telescope recording, distinct-batch, daemon-silence, and deferred-persistence regressions, then run `tests/Telescope/Watchers/ScheduleWatcherTest.php` and `tests/Telescope/Telescope/TelescopeTest.php`. +12. Correct Sentry's scheduled-task final status and exactly-once completion, add the integration regressions, and run `tests/Sentry/Features/ConsoleSchedulingIntegrationTest.php`. +13. Add the small HTTP kernel lifecycle assertion and run `tests/Foundation/Http/KernelTest.php`. Keep console kernel lifecycle behavior unchanged. +14. Regenerate the Request facade docblock, then update the request, coroutine, and collections documentation. Do not create a scheduler todo, edit the forbidden AI differences file, or edit package READMEs. +15. Update `src/testbench/hypervel/artisan`, run its syntax/ArgvInput checks and focused Testbench coverage, then run `composer test:testbench`. +16. In `contrib/hypervel/hypervel`, remove the skeleton constant, reuse ArgvInput for mode detection and command handling, and run `php -l artisan`. The repository currently has no `vendor/`; do not install dependencies or attempt `composer test` solely for this entry-script edit. +17. Run package-focused groups, the final validation gates, stale searches, and the complete fresh review below. + +## Validation cadence + +From the components worktree: + +1. Run every changed test file immediately as listed above with `vendor/bin/phpunit `. +2. Run the focused groups with `vendor/bin/phpunit tests/Http tests/HttpServer`, `vendor/bin/phpunit tests/Foundation/Http/KernelTest.php tests/Integration/Foundation/Support/Providers/RouteServiceProviderHealthTest.php`, `vendor/bin/phpunit tests/Coroutine/WaiterTest.php tests/Console/Scheduling/EventTest.php tests/Console/Scheduling/ScheduleRunCommandTest.php tests/Console/Scheduling/ScheduleRunContextPropagationTest.php tests/Integration/Console/Scheduling/SubMinuteSchedulingTest.php`, `vendor/bin/phpunit tests/Telescope`, and `vendor/bin/phpunit tests/Sentry`. +3. Run `composer test:testbench` after all Testbench changes. +4. Run `composer fix` once the coherent implementation is complete. It owns formatting, both PHPStan configurations, parallel components tests, Testbench, and dogfood tests. +5. Run `git diff --check`. +6. Run the semantic stale searches from the test plan, including the separate skeleton and private packages. +7. Inspect `git status --short` independently in the components worktree and the application skeleton repository so changes are attributed to the correct repository. + +Do not run redundant full formatter/PHPStan/test passes immediately before `composer fix`; focused tests remain required while editing. + +## Fresh review and completion criteria + +Before requesting code review: + +- Reread `AGENTS.md`, this plan, the full diff, and every changed caller/callee. +- Re-trace all Request construction paths through Symfony constructor/`initialize()`, `create()`, `createFrom()`, `createFromBase()`, clone, `duplicate()`, RequestBridge, and the four bridge consumers. +- Confirm every supported runtime has exactly one request-owned capture and no per-request eager Carbon allocation unless the accessor is used. +- Recheck Swoole v6.2.2 source placement so documentation does not claim socket-accept/body-upload timing. +- Recheck Laravel framework/skeleton and Telescope references; distinguish parity evidence from Hypervel's approved runtime-specific enhancement. +- Verify health views receive the actual routed Request explicitly and cannot resolve a throwaway helper request. +- Verify deterministic health/Telescope/Sentry assertions fail against the old behavior for the intended reason and use no sleeps. +- Verify Request and kernel timing retain their different boundaries and cleanup contracts. +- Trace Telescope events end to end: Hypervel BeforeHandle in the command coroutine, the deliberate `schedule:run` daemon exclusion, task-local `ScheduledTaskStarting`, terminal task events, recording state, finite-coroutine deferred storage, and distinct task batch IDs. +- Confirm ScheduleWatcher's terminal listeners are boot-registered once, explicit repository/store ownership is gone, and its constant-space task-ID marker suppresses only a duplicate terminal event for the same task. +- Trace scheduler-owned interruption/pause/maintenance gates in the parent separately from user filter/skip/execution in the task child. Confirm paused repeats advance at their natural cadence, pre-paused repeats cannot reach `abs(null)`, and `evenWhenPaused()` remains unaffected. +- Trace foreground/background dispatch, repeated sub-minute execution, independent Log Context propagation, task-local observability hooks, and child completion. Confirm foreground order and existing bounded background concurrency are unchanged. +- Confirm `Schedule::serverShouldRun()` still caches one election result per event/minute on the shared Schedule object, so task children do not change `onOneServer` behavior. +- Confirm Waiter's additive context-copy option preserves the fresh default and every existing result/error/cleanup contract. +- Confirm no Foundation-specific Waiter wrapper or empty tracked directory remains, and synthetic test HTTP requests explicitly retain their all-context-copy policy through the base API. +- Confirm Sentry reads the published exit code on Finished, records non-zero commands as `internal_error`, and finishes/pops each scheduled transaction exactly once. +- Confirm dead Sentry config is removed rather than silently ignored or newly implemented. +- Confirm the application skeleton and components no longer rely on the same commit/repository lifecycle. +- Inspect hot paths for added object allocations, container/config reads, statics, context writes, locks, or callbacks; the intended runtime cost is one float property/capture per constructed Request and Carbon only for accessor consumers. +- Remove dead code, stale comments, obsolete tests, unused imports, compatibility branches, and superseded documentation. +- Confirm the generated Request facade exposes `startedAt()` and the facade-docblock lint is current. +- Confirm no second metadata API, raw Swoole exposure, raw timestamp method, request contract expansion, recording-state stack, batch-reset mechanism, alternate scheduler concurrency layer, or forbidden AI-difference entry slipped in. +- Confirm both artisan entrypoints use the bounded `getFirstArgument()` improvement, state its separated-`--env` limitation honestly, and do not contain a partial custom option parser. +- Confirm `Console\Kernel::commandStartedAt()` and its lifecycle handlers are unchanged: they still describe only the top-level Kernel `handle()` / `terminate()` lifecycle. +- Include the narrower Laravel difference in the final handoff rather than source or todo documentation: scheduled Laravel subprocesses establish their own command lifecycle, while Hypervel's intentional in-process `Kernel::call()` does not and cannot safely simulate `terminate()` without tearing down the long-lived application. +- Report the `AGENTS.md` versus `docs/ai/differences-vs-laravel.md` instruction conflict to the maintainer without changing either file in this work. + +Request independent review of this complete plan before implementation and loop until sign-off. Implementation is complete only when both repositories contain the intended clean design, every focused regression passes, `composer fix` is green, stale searches have only the explicitly preserved grammar fixture, and the final diff passes a second independent review. From 5be5ebcf534267d612f65f3ddf08d291632807b9 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:51:17 +0000 Subject: [PATCH 15/19] fix(console): resolve commands after global options Pre-bootstrap command checks used an unbound ArgvInput, so a separated optional --env value could be mistaken for the command name. Add one shared resolver that binds Symfony's authoritative global definition plus Hypervel's environment option using Symfony's own catch-and-rebind flow. Use it for the public application classifier while preserving the existing protected environment-option extension point. Cover attached and separated global option values, command-specific options rejected during preliminary binding, and execution after the real console kernel rebinds the same input. --- src/console/src/Application.php | 42 ++++++++++++- src/foundation/src/Application.php | 3 +- .../ConsoleApplicationCommandNameTest.php | 63 +++++++++++++++++++ .../ApplicationRunningInConsoleTest.php | 9 +++ tests/Foundation/Console/KernelTest.php | 36 +++++++++++ 5 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 tests/Console/ConsoleApplicationCommandNameTest.php diff --git a/src/console/src/Application.php b/src/console/src/Application.php index 826280aaf..b1cef7b8f 100644 --- a/src/console/src/Application.php +++ b/src/console/src/Application.php @@ -19,6 +19,8 @@ use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command as SymfonyCommand; use Symfony\Component\Console\Exception\CommandNotFoundException; +use Symfony\Component\Console\Exception\ExceptionInterface; +use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputDefinition; use Symfony\Component\Console\Input\InputInterface; @@ -115,6 +117,31 @@ public static function artisanBinary(): string ); } + /** + * Resolve the command name from argv before the console application boots. + * + * Symfony's inherited getCommandName() reports the command selected by a + * running application. This method first binds the framework's global + * options so their values are not mistaken for the command name. + */ + public static function resolveCommandName(ArgvInput $input): ?string + { + // No Hypervel console application exists yet, so application-specific + // getEnvironmentOption() overrides cannot participate. Constructing + // Symfony's authoritative definition also enables async PCNTL signals + // when supported; the real console application does the same next. + $definition = (new SymfonyApplication)->getDefinition(); + $definition->addOption(self::createEnvironmentOption()); + + try { + $input->bind($definition); + } catch (ExceptionInterface) { + // Command-specific options cannot be validated until the command is known. + } + + return $input->getFirstArgument(); + } + /** * Format the given command as a fully-qualified executable command. */ @@ -519,9 +546,20 @@ protected function getDefaultInputDefinition(): InputDefinition */ protected function getEnvironmentOption() { - $message = 'The environment the command should run under'; + return self::createEnvironmentOption(); + } - return new InputOption('--env', null, InputOption::VALUE_OPTIONAL, $message); + /** + * Create the global environment option. + */ + private static function createEnvironmentOption(): InputOption + { + return new InputOption( + '--env', + null, + InputOption::VALUE_OPTIONAL, + 'The environment the command should run under' + ); } /** diff --git a/src/foundation/src/Application.php b/src/foundation/src/Application.php index d04383a81..4a4950e3f 100644 --- a/src/foundation/src/Application.php +++ b/src/foundation/src/Application.php @@ -6,6 +6,7 @@ use Closure; use Composer\Autoload\ClassLoader; +use Hypervel\Console\Application as ConsoleApplication; use Hypervel\Container\Container; use Hypervel\Contracts\Console\Kernel as ConsoleKernelContract; use Hypervel\Contracts\Container\Container as ContainerContract; @@ -813,7 +814,7 @@ public function runningConsoleCommand(string|array ...$commands): bool } return in_array( - (new ArgvInput)->getFirstArgument(), + ConsoleApplication::resolveCommandName(new ArgvInput), is_array($commands[0] ?? null) ? $commands[0] : $commands, true ); diff --git a/tests/Console/ConsoleApplicationCommandNameTest.php b/tests/Console/ConsoleApplicationCommandNameTest.php new file mode 100644 index 000000000..1587faa66 --- /dev/null +++ b/tests/Console/ConsoleApplicationCommandNameTest.php @@ -0,0 +1,63 @@ +assertSame($command, Application::resolveCommandName($input)); + } + + /** + * Provide commands containing only global options. + * + * @return array, string}> + */ + public static function globalOptionCommandProvider(): array + { + return [ + 'direct command' => [['migrate'], 'migrate'], + 'attached environment value' => [['--env=production', 'migrate'], 'migrate'], + 'separated environment value' => [['--env', 'production', 'migrate'], 'migrate'], + 'short verbosity option' => [['-v', 'queue:work'], 'queue:work'], + 'long ANSI option' => [['--ansi', 'watch'], 'watch'], + ]; + } + + #[DataProvider('commandOptionProvider')] + public function testResolvesCommandNamesWhenPreliminaryBindingRejectsCommandOptions( + array $arguments, + string $command, + ): void { + $input = new ArgvInput(['artisan', ...$arguments]); + + $this->assertSame($command, Application::resolveCommandName($input)); + } + + /** + * Provide commands containing options that are unavailable before command resolution. + * + * @return array, string}> + */ + public static function commandOptionProvider(): array + { + return [ + 'command option' => [['serve', '--host', '0.0.0.0'], 'serve'], + 'environment value and command option' => [ + ['--env', 'production', 'serve', '--host=0.0.0.0'], + 'serve', + ], + ]; + } +} diff --git a/tests/Foundation/ApplicationRunningInConsoleTest.php b/tests/Foundation/ApplicationRunningInConsoleTest.php index e02c2f787..e31be6c98 100644 --- a/tests/Foundation/ApplicationRunningInConsoleTest.php +++ b/tests/Foundation/ApplicationRunningInConsoleTest.php @@ -236,6 +236,15 @@ public function testRunningConsoleCommandMatchesCommandAfterLongOption(): void $this->assertFalse($app->runningConsoleCommand('--env=production')); } + public function testRunningConsoleCommandMatchesCommandAfterSeparatedLongOptionValue(): void + { + $_SERVER['argv'] = ['artisan', '--env', 'production', 'migrate']; + $app = new Application; + + $this->assertTrue($app->runningConsoleCommand('migrate')); + $this->assertFalse($app->runningConsoleCommand('--env', 'production')); + } + public function testRunningConsoleCommandMatchesCommandAfterShortOption(): void { $_SERVER['argv'] = ['artisan', '-v', 'queue:work']; diff --git a/tests/Foundation/Console/KernelTest.php b/tests/Foundation/Console/KernelTest.php index de224faca..a1bfa5118 100644 --- a/tests/Foundation/Console/KernelTest.php +++ b/tests/Foundation/Console/KernelTest.php @@ -4,6 +4,8 @@ namespace Hypervel\Tests\Foundation\Console; +use Hypervel\Console\Application as ConsoleApplication; +use Hypervel\Console\Command; use Hypervel\Contracts\Console\Kernel as KernelContract; use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; use Hypervel\Events\Dispatcher; @@ -16,6 +18,7 @@ use ReflectionMethod; use ReflectionProperty; use RuntimeException; +use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\StringInput; use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Console\Output\ConsoleOutput; @@ -185,6 +188,29 @@ public function bootstrap(): void $this->assertSame(1, $kernel->handle(new StringInput(''))); } + public function testHandleRebindsInputAfterPreBootstrapCommandResolution(): void + { + $kernel = $this->app->make(KernelContract::class); + $kernel->registerCommand(new KernelPreboundInputCommand); + $input = new ArgvInput([ + 'artisan', + 'test:prebound-input', + '--value=configured', + ]); + $output = new BufferedOutput; + + $this->assertSame( + 'test:prebound-input', + ConsoleApplication::resolveCommandName($input), + ); + + $status = $kernel->handle($input, $output); + $kernel->terminate($input, $status); + + $this->assertSame(0, $status); + $this->assertSame('configured', trim($output->fetch())); + } + public function testItDispatchesTerminatingEvent() { $called = []; @@ -207,3 +233,13 @@ public function testItDispatchesTerminatingEvent() ], $called); } } + +class KernelPreboundInputCommand extends Command +{ + protected ?string $signature = 'test:prebound-input {--value=}'; + + public function handle(): void + { + $this->line((string) $this->option('value')); + } +} From 8d4bd37239f991a516dbfad8ffb559eef1915fe3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:51:25 +0000 Subject: [PATCH 16/19] fix(testbench): resolve server commands after global options Both Testbench CLI paths classified server commands before Symfony had bound the global input definition. A separated --env value could therefore leave APP_RUNNING_IN_CONSOLE enabled for a serve invocation. Route the skeleton artisan and bin/testbench Commander through the shared console resolver, keep the same ArgvInput for kernel execution, and normalize the entrypoint imports and constructor style. Declare Testbench's direct hypervel/console dependency and add a focused regression for the separated environment option form. --- src/testbench/composer.json | 3 ++- src/testbench/hypervel/artisan | 19 +++++++++++++------ src/testbench/src/Console/Commander.php | 10 +++++----- tests/Testbench/CommanderEnvironmentTest.php | 17 +++++++++++++++++ 4 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/testbench/composer.json b/src/testbench/composer.json index 57612c3b3..399b6dc9c 100644 --- a/src/testbench/composer.json +++ b/src/testbench/composer.json @@ -31,13 +31,14 @@ "symfony/yaml": "^8.1", "vlucas/phpdotenv": "^5.6.1", "hypervel/collections": "^0.4", + "hypervel/console": "^0.4", "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", "hypervel/coordinator": "^0.4", + "hypervel/core": "^0.4", "hypervel/database": "^0.4", "hypervel/filesystem": "^0.4", "hypervel/foundation": "^0.4", - "hypervel/core": "^0.4", "hypervel/process": "^0.4", "hypervel/queue": "^0.4", "hypervel/routing": "^0.4", diff --git a/src/testbench/hypervel/artisan b/src/testbench/hypervel/artisan index 743ac4b8a..e41a7c49a 100644 --- a/src/testbench/hypervel/artisan +++ b/src/testbench/hypervel/artisan @@ -3,6 +3,13 @@ declare(strict_types=1); +use Hypervel\Console\Application as ConsoleApplication; +use Hypervel\Contracts\Console\Kernel as ConsoleKernelContract; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Foundation\Application; +use Symfony\Component\Console\Input\ArgvInput; +use Symfony\Component\Console\Output\ConsoleOutput; + ini_set('display_errors', 'on'); ini_set('display_startup_errors', 'on'); ini_set('memory_limit', '1G'); @@ -27,7 +34,7 @@ foreach ($autoloadPaths as $autoloadPath) { } } -if (! class_exists(Hypervel\Foundation\Application::class)) { +if (! class_exists(Application::class)) { fwrite(STDERR, "Unable to locate Composer autoloader for the Hypervel testbench workbench.\n"); exit(1); } @@ -49,21 +56,21 @@ if ( ! defined('SWOOLE_HOOK_FLAGS') && define('SWOOLE_HOOK_FLAGS', SWOOLE_HOOK_ALL); $httpBootstrapCommands = ['serve', 'watch']; -$input = new Symfony\Component\Console\Input\ArgvInput(); +$input = new ArgvInput; -if (in_array($input->getFirstArgument(), $httpBootstrapCommands, true)) { +if (in_array(ConsoleApplication::resolveCommandName($input), $httpBootstrapCommands, true)) { putenv('APP_RUNNING_IN_CONSOLE=false'); $_ENV['APP_RUNNING_IN_CONSOLE'] = 'false'; $_SERVER['APP_RUNNING_IN_CONSOLE'] = 'false'; } (function () use ($input) { - /** @var Hypervel\Contracts\Foundation\Application $app */ + /** @var ApplicationContract $app */ $app = require BASE_PATH . '/bootstrap/app.php'; - $kernel = $app->make(Hypervel\Contracts\Console\Kernel::class); + $kernel = $app->make(ConsoleKernelContract::class); - $output = new Symfony\Component\Console\Output\ConsoleOutput(); + $output = new ConsoleOutput; $status = $kernel->handle($input, $output); diff --git a/src/testbench/src/Console/Commander.php b/src/testbench/src/Console/Commander.php index 75fdf617b..10f505c92 100644 --- a/src/testbench/src/Console/Commander.php +++ b/src/testbench/src/Console/Commander.php @@ -5,6 +5,7 @@ namespace Hypervel\Testbench\Console; use Closure; +use Hypervel\Console\Application as ConsoleApplication; use Hypervel\Contracts\Console\Kernel as ConsoleKernel; use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Contracts\Foundation\Application as ApplicationContract; @@ -17,9 +18,8 @@ use Hypervel\Testbench\Foundation\Console\TerminatingConsole; use Hypervel\Testbench\TestbenchServiceProvider; use Hypervel\Testbench\Workbench\Workbench; -use Symfony\Component\Console\Application as ConsoleApplication; +use Symfony\Component\Console\Application as SymfonyApplication; use Symfony\Component\Console\Input\ArgvInput; -use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -252,7 +252,7 @@ protected function handleException(OutputInterface $output, Throwable $error): i } } - (new ConsoleApplication)->renderThrowable($error, $output); + (new SymfonyApplication)->renderThrowable($error, $output); return 1; } @@ -260,9 +260,9 @@ protected function handleException(OutputInterface $output, Throwable $error): i /** * Prepare environment variables required by the incoming command. */ - protected function prepareCommandEnvironment(InputInterface $input): void + protected function prepareCommandEnvironment(ArgvInput $input): void { - if ($input->getFirstArgument() !== 'serve') { + if (ConsoleApplication::resolveCommandName($input) !== 'serve') { return; } diff --git a/tests/Testbench/CommanderEnvironmentTest.php b/tests/Testbench/CommanderEnvironmentTest.php index 8c0a23b8e..9f8f9f7e2 100644 --- a/tests/Testbench/CommanderEnvironmentTest.php +++ b/tests/Testbench/CommanderEnvironmentTest.php @@ -35,6 +35,23 @@ public function itMarksServeCommandsAsNonConsoleBeforeBootstrappingTheApplicatio $this->assertSame('false', $_SERVER['APP_RUNNING_IN_CONSOLE']); } + #[Test] + public function itMarksServeCommandsAfterSeparatedEnvironmentValuesAsNonConsole(): void + { + $commander = new CommanderHarness([], package_path()); + + $commander->prepareCommandEnvironmentPublic(new ArgvInput([ + 'testbench', + '--env', + 'production', + 'serve', + ])); + + $this->assertSame('false', getenv('APP_RUNNING_IN_CONSOLE')); + $this->assertSame('false', $_ENV['APP_RUNNING_IN_CONSOLE']); + $this->assertSame('false', $_SERVER['APP_RUNNING_IN_CONSOLE']); + } + #[Test] public function itLeavesNonServeCommandsOnTheNormalConsolePath(): void { From 03b40ad7e55d2457c959fbf80f1fa71a20b04285 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:51:31 +0000 Subject: [PATCH 17/19] style(telescope): type storage listener callbacks Declare the command and scheduled-task storage opportunity closures as void. This makes their callback contracts explicit and aligns the listeners with the repository's full-typing convention without changing recording behavior. --- src/telescope/src/ListensForStorageOpportunities.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/telescope/src/ListensForStorageOpportunities.php b/src/telescope/src/ListensForStorageOpportunities.php index a2331210e..d565db872 100644 --- a/src/telescope/src/ListensForStorageOpportunities.php +++ b/src/telescope/src/ListensForStorageOpportunities.php @@ -91,7 +91,7 @@ public static function manageRecordingStateForCommands(Container $app): void { $events = $app->make(Dispatcher::class); - $events->listen(BeforeHandleCommand::class, function (BeforeHandleCommand $event) { + $events->listen(BeforeHandleCommand::class, function (BeforeHandleCommand $event): void { // The long-lived scheduler records only inside each finite task coroutine. if ($event->command->getName() === 'schedule:run') { return; @@ -104,7 +104,7 @@ public static function manageRecordingStateForCommands(Container $app): void } }); - $events->listen(ScheduledTaskStarting::class, function () { + $events->listen(ScheduledTaskStarting::class, function (): void { if (static::shouldListen() && static::commandIsApproved('schedule:run')) { static::startRecording(); } From ea920404b31d9dddff10f35b5fae81106be6d74d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:51:36 +0000 Subject: [PATCH 18/19] docs(sentry): describe scheduled task handlers Add concise Laravel-style method documentation to the three scheduled-task lifecycle handlers. The comments state each handler's tracing responsibility while keeping the upstream-derived feature implementation free of a broad documentation-only rewrite. --- src/sentry/src/Features/ConsoleSchedulingFeature.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/sentry/src/Features/ConsoleSchedulingFeature.php b/src/sentry/src/Features/ConsoleSchedulingFeature.php index 776734c75..b05c42806 100644 --- a/src/sentry/src/Features/ConsoleSchedulingFeature.php +++ b/src/sentry/src/Features/ConsoleSchedulingFeature.php @@ -122,6 +122,9 @@ public function onBootInactive(): void $this->shouldHandleCheckIn = false; } + /** + * Start tracing the scheduled task. + */ public function handleScheduledTaskStarting(ScheduledTaskStarting $event): void { // When scheduling a command class the command name will be the most descriptive @@ -139,6 +142,9 @@ public function handleScheduledTaskStarting(ScheduledTaskStarting $event): void $this->pushSpan($transaction); } + /** + * Finish tracing the scheduled task with its published outcome. + */ public function handleScheduledTaskFinished(ScheduledTaskFinished $event): void { $exitCode = $event->task->exitCode(); @@ -152,6 +158,9 @@ public function handleScheduledTaskFinished(ScheduledTaskFinished $event): void } } + /** + * Mark tracing for the scheduled task as failed. + */ public function handleScheduledTaskFailed(): void { if ($this->maybeFinishSpan(SpanStatus::internalError()) !== null) { From 359354b8765b1e37a00db19b9369ae6ba5cb02e3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:51:49 +0000 Subject: [PATCH 19/19] docs: update request timing implementation plan Replace the earlier bounded command-name parsing proposal with the final shared Symfony-definition-backed resolver design. Record all three shipped CLI consumers, the generic kernel boundary, signal setup behavior, direct Testbench dependency, regression coverage, stale-code checks, and final review criteria so the plan matches the implemented code without preserving rejected approaches. --- ...1-request-start-time-and-runtime-timing.md | 78 ++++++++++++------- 1 file changed, 52 insertions(+), 26 deletions(-) diff --git a/docs/plans/2026-08-06-1121-request-start-time-and-runtime-timing.md b/docs/plans/2026-08-06-1121-request-start-time-and-runtime-timing.md index 4fd14c68b..f90a388a8 100644 --- a/docs/plans/2026-08-06-1121-request-start-time-and-runtime-timing.md +++ b/docs/plans/2026-08-06-1121-request-start-time-and-runtime-timing.md @@ -14,7 +14,7 @@ This plan is the implementation source of truth. Before implementation, reread ` - The HTML health response always reports the current request's render duration accurately in long-lived workers and in Testbench. - Telescope and Sentry consume the request API rather than reading transport details or a global constant. - `HYPERVEL_START` disappears from executable framework/application behavior without a deprecated alias, fallback, or replacement global. -- Related Telescope command decisions use dispatched command identity rather than positional process arguments, scheduled tasks execute in finite task-owned coroutines with task-local observability state, and both shipped artisan entrypoints use Symfony's first-argument primitive instead of raw `argv[1]` for their pre-bootstrap server-mode check. +- Related Telescope command decisions use dispatched command identity rather than positional process arguments, scheduled tasks execute in finite task-owned coroutines with task-local observability state, and all three shipped CLI entrypoints use one shared Symfony-definition-backed resolver instead of unbound input scans or raw `argv[1]` for their pre-bootstrap server-mode check. - No unrelated time API changes: `now()`, the Date facade, Carbon, scheduler clocks, and the existing kernel/console lifecycle timers continue to behave as they do now. ## Goals and invariants @@ -32,7 +32,7 @@ This plan is the implementation source of truth. Before implementation, reread ` ## Anti-overengineering and performance rules -- Add exactly one public API: `Request::startedAt()`. Do not add a request-start service, facade, contract method, raw timestamp accessor, middleware, event, DTO, transport wrapper, or metadata registry. +- Add exactly one public request-timing API: `Request::startedAt()`. Do not add a request-start service, facade, contract method, raw timestamp accessor, middleware, event, DTO, transport wrapper, or metadata registry. - Keep one protected float on each Request. Do not eagerly allocate Carbon for every HTTP, gRPC, WebSocket, and Reverb request; construct it only when the accessor is called. - Do not add caching merely to promise Carbon object identity. The contract is the instant's value, not whether repeated calls return the same object. - Do not add a `REQUEST_TIME` integer fallback. Supported Swoole HTTP/1 and HTTP/2 paths and Symfony's synthetic-request factory provide `REQUEST_TIME_FLOAT`; direct construction needs only the precise `microtime(true)` fallback. Falling back to integer seconds is an unreachable production branch that destroys useful duration precision. @@ -45,7 +45,7 @@ This plan is the implementation source of truth. Before implementation, reread ` - Keep the long-lived `schedule:run` command coroutine outside Telescope recording. Start observability inside each task coroutine so queues, defers, spans, and batch identity share the task's real lifecycle. - The coroutine and channel allocation per due scheduled invocation is deliberate lifecycle ownership on a low-frequency scheduler path, not request hot-path overhead. Do not add pooling or reuse machinery. - Do not add an entry to `docs/ai/differences-vs-laravel.md`; that file explicitly says it is queued for deletion and forbids new entries. -- Do not build or duplicate Symfony's complete console input definition merely to classify `serve` / `watch` before application bootstrap. Reuse unbound `ArgvInput::getFirstArgument()` and document its narrow residual limit for a space-separated optional `--env` value. +- Resolve pre-bootstrap command names by binding Symfony's authoritative default application definition plus Hypervel's global `--env` option, following Symfony's own `Application::doRun()` flow. Do not hand-build a parallel option definition, special-case argv tokens, or cache mutable definitions. - Churn and compatibility with pre-0.4 code do not justify retaining an inferior shape. Conversely, do not expand the work beyond verified request/command lifecycle defects discovered by this audit. ## Research and settled facts @@ -127,7 +127,7 @@ The new tests must assert exact deterministic values and consecutive-request ind ### Related console, Telescope, scheduler, and Sentry findings -The same audit found five live positional-argv defects and one stale config surface: +The same audit found several live command-classification defects, stale ignore entries, and one stale config surface: - `ScheduleWatcher::register()` decides at provider boot from `$_SERVER['argv'][1]`, recognizes nonexistent `crontab:run`, and misses programmatic/global-option-safe command identity. - `Telescope::runningApprovedArtisanCommand()` also reads `argv[1]` even though its `BeforeHandle` listener receives the resolved `Command` instance. @@ -135,10 +135,11 @@ The same audit found five live positional-argv defects and one stale config surf - The live Composer command `package:discover` is absent from Telescope's default ignore paths. - Sentry publishes `sentry.ignore_commands`, but no source in current Hypervel or v0.3 consumes it. It is dead configuration, not an incomplete feature contract. - Both the canonical and Testbench artisan templates use raw `$_SERVER['argv'][1]` to enter HTTP mode for `serve` / `watch`, although both already create `ArgvInput`. Supported forms such as `--env=production serve`, `-v serve`, and `--ansi watch` therefore miss the mode switch and hit the misleading `APP_RUNNING_IN_CONSOLE is true` server guard. +- Testbench's separate `bin/testbench` entrypoint creates one unbound `ArgvInput` in `Console\Commander`, classifies `serve` before application boot, and then passes the same input to the Kernel. Its direct `getFirstArgument()` check likewise mistakes the separated `--env` value for the command. These are included because they are verified manifestations of the same process-entry/argv lifecycle assumption. Do not generalize this into a console subsystem rewrite. -A fifth instance exists on the public `Foundation\Application::runningConsoleCommand()` / `App` facade API. It is a faithful Laravel port but misclassifies a supported invocation such as `artisan --env=production migrate`. The maintainer approved correcting that behavior as an intentional Hypervel improvement. `src/testing/src/Console/TestCommandBase.php` separately slices argv from offset two to forward test-runner options; that is not command classification and is not part of this work. +The same positional defect exists on the public `Foundation\Application::runningConsoleCommand()` / `App` facade API. It is a faithful Laravel port but misclassifies a supported invocation such as `artisan --env=production migrate`. The maintainer approved correcting that behavior as an intentional Hypervel improvement. `src/testing/src/Console/TestCommandBase.php` separately slices argv from offset two to forward test-runner options; that is not command classification and is not part of this work. A source/test search found no first-party production caller of `runningConsoleCommand()`: only its public contract declaration, facade annotation, implementation, and focused tests exist. The correction therefore has no internal framework call-site blast radius and improves only the behavior promised by the existing public contract. @@ -272,23 +273,32 @@ Use Carbon's frozen current time plus seeded server variables to assert determin In Testbench, remove the entire `setUp()` override that defines the constant and remove `Override` if no other use remains. Make the existing health assertion exact instead of merely checking the phrase. -### 5. Remove `HYPERVEL_START` and its stale documentation +### 5. Remove `HYPERVEL_START` and resolve entrypoint command names + +Add `Hypervel\Console\Application::resolveCommandName(ArgvInput $input)` as the shared command-classification boundary used before the real console application boots. It obtains a fresh Symfony default application definition, adds a fresh Hypervel environment option, binds the caller-owned input inside Symfony's `ExceptionInterface` catch pattern, and then calls `getFirstArgument()`. The catch permits command-specific options that cannot be validated until the command is known; real command execution rebinds the same input and reports invalid options normally. + +Keep `ArgvInput` as the parameter type because its token scan is the behavior being prepared. Use one private static environment-option factory from both the resolver and the existing Laravel-compatible protected `getEnvironmentOption()` method. Do not change that protected method's signature or visibility, unify the static and instance definition paths, cache definitions, or add shared state. + +The resolver must construct a Symfony application because Symfony exposes its authoritative default definition through the application instance. Before bootstrap, no Hypervel console application exists and application-specific `getEnvironmentOption()` overrides cannot participate. Symfony application construction enables async PCNTL signals when supported; the real console application does the same immediately after entrypoint classification. Record both facts in one concise source comment rather than adding signal save/restore machinery. - Delete `define('HYPERVEL_START', microtime(true));` from the separate application skeleton's `artisan` entry point. -- In that same entrypoint, instantiate `ArgvInput` once before the HTTP-bootstrap check, classify with `$input->getFirstArgument()`, and pass the same input to `Application::handleCommand()`: +- In that same entrypoint, instantiate `ArgvInput` once before the HTTP-bootstrap check, classify it with the shared resolver, and pass the same input to `Application::handleCommand()`: ```php $input = new ArgvInput(); -if (in_array($input->getFirstArgument(), ['serve', 'watch'], true)) { +if (in_array(ConsoleApplication::resolveCommandName($input), ['serve', 'watch'], true)) { // Existing environment assignments. } $status = $app->handleCommand($input); ``` -- Make the equivalent change in `src/testbench/hypervel/artisan`: construct its fully-qualified `ArgvInput` once, use `getFirstArgument()` for the check, capture the input in the immediately invoked closure, and remove the inner duplicate construction. -- An unbound `ArgvInput` correctly skips valueless global options and `--env=value`, covering `-v serve`, `--ansi watch`, and `--env=production serve`. It cannot know that the separate token after optional `--env` is an option value, so `--env production serve` still resolves `production`. Record this limitation honestly; reproducing/binding the full application and command option definitions before bootstrap is disproportionate to this two-command mode check. +- Make the equivalent change in `src/testbench/hypervel/artisan`: import every referenced class coherently, construct `ArgvInput` once, classify through the resolver, capture the input in the immediately invoked closure, and remove the inner duplicate construction. +- Make the equivalent classifier change in Testbench's `Console\Commander`, which owns the shipped `bin/testbench` entrypoint. Its sole caller creates an `ArgvInput`, so narrow the protected preparation method from generic `InputInterface` to `ArgvInput`, pass the same object onward, and cover `testbench --env production serve` in the existing focused environment test. Rename Commander's existing Symfony Application alias to avoid colliding with Hypervel's `ConsoleApplication` alias. +- Declare `hypervel/console` directly in Testbench's standalone package manifest because Commander now imports its Application class; do not rely on Foundation's transitive dependency. +- The resolver covers valueless global options, attached values, and separated optional values, including `-v serve`, `--ansi watch`, `--env=production serve`, and `--env production serve`. A later command-specific option may make the preliminary bind throw, but Symfony's catch pattern still leaves the command name available and final command binding remains authoritative. +- In Commander, resolver construction enables async PCNTL signals before `prepareCommandSignals()` snapshots the flag. That method therefore records `true`, and cleanup restores `true` immediately before `handle()` exits; no live caller can observe the restoration. Keep the existing order so process signal handlers are not installed before application bootstrap. - Replace the collections scheduled-task example with the already-established local idiom: ```php @@ -393,9 +403,9 @@ $_SERVER['argv'] = ['artisan', '--env=production', 'migrate']; $app->runningConsoleCommand('migrate'); // currently false ``` -Preserve the method name, parameters, return type, facade annotation, contract declaration, and ordinary Laravel behavior while resolving the command with `(new ArgvInput())->getFirstArgument()`. Import `Symfony\Component\Console\Input\ArgvInput` directly. Add focused `tests/Foundation/ApplicationRunningInConsoleTest.php` coverage for `--env=production migrate`, `-v queue:work`, and the existing direct forms. +Preserve the method name, parameters, return type, facade annotation, contract declaration, and ordinary Laravel behavior while delegating a fresh `ArgvInput` to `Console\Application::resolveCommandName()`. Import both owning classes directly. Add focused `tests/Foundation/ApplicationRunningInConsoleTest.php` coverage for `--env=production migrate`, `--env production migrate`, `-v queue:work`, and the existing direct forms. -Do not cache the parsed command or add a console-input service: argv can vary in tests and programmatic environments, the method has no first-party hot-path caller, and a fresh unbound ArgvInput is the smallest correct primitive for the supported option forms. Retain the same honest residual limit as the entrypoints: without binding a full application input definition, `--env production migrate` resolves `production` as the first argument. Duplicating Symfony/Hypervel's global option definition solely to classify this pre-bootstrap edge form would be a brittle parallel parser; keep the bounded native improvement instead. +Do not cache the parsed command or add a console-input service: argv can vary in tests and programmatic environments, the method has no first-party hot-path caller, and fresh local input/definition objects avoid shared mutation. This is an intentional additive Hypervel API absent from Laravel; every existing Laravel method signature, contract, facade annotation, and protected extension point remains unchanged. ### 8. Remove dead Sentry command configuration @@ -430,6 +440,8 @@ Do not edit package READMEs or `docs/ai/differences-vs-laravel.md`. This is a do Do not change `Console\Kernel::commandStartedAt()` or command lifecycle duration handlers. They describe the top-level command passed through Kernel `handle()` / `terminate()`, including a long-running `schedule:run`; nested `Kernel::call()` and `$this->call()` do not establish a second Kernel lifecycle in Laravel or Hypervel. +Keep `Console\Kernel::handle()`'s generic `InputInterface::getFirstArgument()` check for `env:encrypt` / `env:decrypt` unchanged. All three shipped `ArgvInput` entrypoints now bind the global definition through the resolver before the Kernel sees the same input, while programmatic `ArrayInput` callers read the command from their parameter map directly. Do not add an `ArgvInput` type branch to the generic Kernel boundary or duplicate pre-bootstrap resolution there. + Do not add a scheduler todo. Hypervel's in-process scheduled command intentionally uses `Kernel::call()` and task events rather than a subprocess. Calling `Kernel::terminate()` per task would dispatch application termination and tear down state still owned by the daemon and concurrent coroutines. The new task coroutine supplies invocation-local cleanup, while `ScheduledTaskFinished::$runtime` supplies task duration. Report the narrower Laravel difference—top-level command lifecycle duration handlers do not fire per Hypervel scheduled task—in the final maintainer handoff; do not implement a partial termination lifecycle or a second timing API. ## File-by-file implementation map @@ -442,11 +454,17 @@ Do not add a scheduler todo. Hypervel's in-process scheduled command intentional | `src/support/src/Facades/Request.php` | Regenerate the facade docblock so the new Request accessor is exposed to static analysis. | | `tests/Http/HttpRequestTest.php` | Add construction, normalization, stability, conversion, clone/duplicate, and reinitialize coverage. | | `tests/HttpServer/RequestBridgeTest.php` | Pin lowercase-to-uppercase float transport and accessor precision. | -| `src/foundation/src/Application.php` | Resolve public console-command classification through unbound `ArgvInput::getFirstArgument()`. | -| `tests/Foundation/ApplicationRunningInConsoleTest.php` | Preserve direct forms and cover option-prefixed command classification. | +| `src/console/src/Application.php` | Add the shared Symfony-definition-backed command-name resolver and one environment-option factory. | +| `tests/Console/ConsoleApplicationCommandNameTest.php` | Cover direct/global-option resolution and preliminary command-option binding failures. | +| `src/foundation/src/Application.php` | Delegate public console-command classification to the shared resolver. | +| `tests/Foundation/ApplicationRunningInConsoleTest.php` | Preserve direct forms and cover attached/separated option-prefixed command classification. | +| `tests/Foundation/Console/KernelTest.php` | Prove a pre-bound input is rebound and its command-specific options execute correctly. | | `src/foundation/src/Configuration/ApplicationBuilder.php` | Pass the already-injected Request into health view data. | | `src/testbench/src/Workbench/Workbench.php` | Inject and pass Request into Workbench health view. | -| `src/testbench/hypervel/artisan` | Reuse one ArgvInput and replace positional server-mode detection with `getFirstArgument()`. | +| `src/testbench/hypervel/artisan` | Import all classes coherently, reuse one ArgvInput, and classify server mode through the shared resolver. | +| `src/testbench/src/Console/Commander.php` | Classify the shipped Testbench CLI's ArgvInput through the shared resolver while preserving signal-handler setup order. | +| `src/testbench/composer.json` | Declare the directly used `hypervel/console` package. | +| `tests/Testbench/CommanderEnvironmentTest.php` | Cover separated `--env` command classification through the shared resolver. | | `src/foundation/src/resources/health-up.blade.php` | Remove constant guard and render request-owned deterministic duration. | | `tests/Integration/Foundation/Support/Providers/RouteServiceProviderHealthTest.php` | Add exact and sequential HTML timing regressions; preserve JSON/error cases. | | `tests/Testbench/Workbench/DiscoversTest.php` | Delete constant setup and assert exact request duration. | @@ -475,7 +493,7 @@ Do not add a scheduler todo. Hypervel's in-process scheduled command intentional | `tests/Integration/Console/Scheduling/SubMinuteSchedulingTest.php` | Cover pre-paused repeat safety, natural skipped-event cadence, maintenance, and `evenWhenPaused()` behavior. | | `src/sentry/src/Tracing/Middleware.php` | Use accessor and exact Carbon-to-epoch conversion. | | `tests/Sentry/Tracing/MiddlewareTest.php` | Assert captured transaction's exact microsecond start timestamp. | -| `src/sentry/src/Features/ConsoleSchedulingFeature.php` | Finalize scheduled transactions from the published task exit code exactly once. | +| `src/sentry/src/Features/ConsoleSchedulingFeature.php` | Finalize scheduled transactions from the published task exit code exactly once and document the three task handlers. | | `tests/Sentry/Features/ConsoleSchedulingIntegrationTest.php` | Prove successful and non-zero scheduled commands publish one transaction with the correct final status. | | `src/sentry/config/sentry.php` | Remove never-consumed `ignore_commands`. | | `src/sentry/src/SentryServiceProvider.php` | Stop filtering the deleted non-SDK option. | @@ -487,7 +505,7 @@ Do not add a scheduler todo. Hypervel's in-process scheduled command intentional | File | Change | |---|---| -| `contrib/hypervel/hypervel/artisan` | Delete the process-entry constant, reuse one ArgvInput, and replace positional server-mode detection. | +| `contrib/hypervel/hypervel/artisan` | Delete the process-entry constant, reuse one ArgvInput, and classify server mode through the shared resolver. | No private package or application file changes are expected; repeat the broad search before completion in case the repositories move during implementation. @@ -558,13 +576,20 @@ Use fixed microsecond timestamps; compare exact epoch/microsecond values rather ### Public application command classification - Existing direct command-name, array/variadic, non-console, missing-argv, `serve`, and `watch` cases retain their behavior. -- `--env=production migrate` and `-v queue:work` resolve the actual command name and match only the requested command. -- Keep the separated-value `--env production migrate` limitation explicit in this plan; do not duplicate or partially bind the console application's global option definition to make this one classifier parse more than Symfony's unbound `getFirstArgument()` supports. +- `--env=production migrate`, `--env production migrate`, and `-v queue:work` resolve the actual command name and match only the requested command. + +### Pre-bootstrap command-name resolution + +- Direct commands, `--env=production migrate`, `--env production migrate`, `-v queue:work`, and `--ansi watch` resolve through a successful global-definition bind. +- `serve --host 0.0.0.0` and `--env production serve --host=0.0.0.0` resolve `serve` after the preliminary bind rejects command-specific options. Removing the catch must fail these regressions. +- An `ArgvInput` first classified by the resolver can be passed to the real console kernel, rebound against a registered command, and execute with the command-specific option value intact. +- Do not add an `-e` case; Hypervel's global `--env` option has no shortcut, so that invocation is invalid. -### Artisan entrypoint checks +### CLI entrypoint checks -- With components' installed Symfony Console, directly verify unbound `ArgvInput::getFirstArgument()` returns `serve` / `watch` for `--env=production serve`, `-v serve`, and `--ansi watch`; record that `--env production serve` returns `production` and remains outside this bounded fix. -- Inspect both entrypoints to ensure the same ArgvInput instance used for classification is later passed to the command kernel/application; do not retain a second construction or raw `argv[1]` check. +- With components' installed Symfony Console, verify the shared resolver returns `serve` / `watch` for `--env=production serve`, `--env production serve`, `-v serve`, and `--ansi watch`, including command-specific options after the command. +- Inspect all three entrypoints to ensure the same ArgvInput instance resolved before bootstrap is later passed to the command kernel/application; do not retain a second construction, raw `argv[1]` check, or direct unbound `getFirstArgument()` classification. +- Run `tests/Testbench/CommanderEnvironmentTest.php` to prove the Testbench CLI wiring accepts a separated `--env` value. The shared resolver suite, rather than this wiring test, owns the remaining global-option matrix. - Run `php -l src/testbench/hypervel/artisan` and the Testbench suites. The canonical skeleton has no installed `vendor/`, so its real local gate is `php -l artisan`; do not run `composer test` there unless dependencies are installed for some independent reason. ### Negative/stale checks @@ -580,7 +605,7 @@ Use fixed microsecond timestamps; compare exact epoch/microsecond values rather Work one file at a time with `apply_patch`, preserving unrelated worktree changes. Run the named focused test immediately after each coherent source/test pair. -1. Add `Symfony\Component\Console\Input\ArgvInput` to `src/foundation/src/Application.php`, update `runningConsoleCommand()` to use `getFirstArgument()`, add direct/option-prefixed cases to `tests/Foundation/ApplicationRunningInConsoleTest.php`, and run that focused test file. +1. Add focused command-name resolver coverage, implement `Console\Application::resolveCommandName()` with Symfony's definition-bind/catch flow and the shared environment-option factory, then run the new test file. Delegate `Foundation\Application::runningConsoleCommand()` to it and cover both attached and separated `--env` values. Add the real kernel rebind regression and run both changed Foundation test files. 2. Add Request unit regressions, implement `Request::$startedAtTimestamp`, initialization normalization, and `startedAt()`, then run `tests/Http/HttpRequestTest.php`. 3. Extend RequestBridge coverage and run `tests/HttpServer/RequestBridgeTest.php`. Do not change RequestBridge production normalization unless the counterfactual test disproves the audited behavior. 4. Update both health route owners and the Blade template; add deterministic application health coverage and run that test file. @@ -594,8 +619,8 @@ Work one file at a time with `apply_patch`, preserving unrelated worktree change 12. Correct Sentry's scheduled-task final status and exactly-once completion, add the integration regressions, and run `tests/Sentry/Features/ConsoleSchedulingIntegrationTest.php`. 13. Add the small HTTP kernel lifecycle assertion and run `tests/Foundation/Http/KernelTest.php`. Keep console kernel lifecycle behavior unchanged. 14. Regenerate the Request facade docblock, then update the request, coroutine, and collections documentation. Do not create a scheduler todo, edit the forbidden AI differences file, or edit package READMEs. -15. Update `src/testbench/hypervel/artisan`, run its syntax/ArgvInput checks and focused Testbench coverage, then run `composer test:testbench`. -16. In `contrib/hypervel/hypervel`, remove the skeleton constant, reuse ArgvInput for mode detection and command handling, and run `php -l artisan`. The repository currently has no `vendor/`; do not install dependencies or attempt `composer test` solely for this entry-script edit. +15. Update `src/testbench/hypervel/artisan` to import all class references, classify its one ArgvInput through the shared resolver, and pass that same input onward. Update Testbench `Console\Commander` to use the resolver for its own one ArgvInput, declare the direct console-package dependency, preserve its signal setup order, and add the focused environment regression. Run all three CLI entrypoint checks and focused Testbench coverage, then run `composer test:testbench`. +16. In `contrib/hypervel/hypervel`, remove the skeleton constant, classify its one ArgvInput through the shared resolver, pass that input to command handling, and run `php -l artisan`. The repository currently has no `vendor/`; do not install dependencies or attempt `composer test` solely for this entry-script edit. 17. Run package-focused groups, the final validation gates, stale searches, and the complete fresh review below. ## Validation cadence @@ -638,7 +663,8 @@ Before requesting code review: - Remove dead code, stale comments, obsolete tests, unused imports, compatibility branches, and superseded documentation. - Confirm the generated Request facade exposes `startedAt()` and the facade-docblock lint is current. - Confirm no second metadata API, raw Swoole exposure, raw timestamp method, request contract expansion, recording-state stack, batch-reset mechanism, alternate scheduler concurrency layer, or forbidden AI-difference entry slipped in. -- Confirm both artisan entrypoints use the bounded `getFirstArgument()` improvement, state its separated-`--env` limitation honestly, and do not contain a partial custom option parser. +- Confirm all three shipped CLI entrypoints use the shared Symfony-definition-backed resolver, support separated `--env` values, pass the same input onward, and contain no partial custom option parser. +- Confirm Testbench Commander keeps signal-handler installation after application bootstrap preparation and the generic Kernel contains no duplicate ArgvInput-specific resolver branch. - Confirm `Console\Kernel::commandStartedAt()` and its lifecycle handlers are unchanged: they still describe only the top-level Kernel `handle()` / `terminate()` lifecycle. - Include the narrower Laravel difference in the final handoff rather than source or todo documentation: scheduled Laravel subprocesses establish their own command lifecycle, while Hypervel's intentional in-process `Kernel::call()` does not and cannot safely simulate `terminate()` without tearing down the long-lived application. - Report the `AGENTS.md` versus `docs/ai/differences-vs-laravel.md` instruction conflict to the maintainer without changing either file in this work.