From ad04e3d708205dc34d8eba6f1e8f969e7f38f7ec Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:15:45 +0000 Subject: [PATCH 1/2] feat(cli): claw run hands the run to the server, starting one if needed The CLI stops opening a project db for a run: it locates the workspace's server (ServerClient over ServerLocator), starts one detached when none answers, and POSTs the issue's start. One process writes the db. Autospawn rebuilds the launch command from PHP_BINARY and CLAW_SERVER_EXTENSION and detaches it with setsid --fork so it outlives the CLI and holds none of its stdio. Verified live: cold autospawn, warm reuse, 409 on an already-active run, and a create-then-run cycle. --- src/Cli/WorkflowMode.php | 25 ++++-- src/ServerClient.php | 171 +++++++++++++++++++++++++++++++++++++ tests/ServerClientTest.php | 116 +++++++++++++++++++++++++ 3 files changed, 304 insertions(+), 8 deletions(-) create mode 100644 src/ServerClient.php create mode 100644 tests/ServerClientTest.php diff --git a/src/Cli/WorkflowMode.php b/src/Cli/WorkflowMode.php index c4bb0e2..a5aa20d 100644 --- a/src/Cli/WorkflowMode.php +++ b/src/Cli/WorkflowMode.php @@ -13,10 +13,9 @@ use Claw\Project\IssueStatus; use Claw\Project\ProjectStore; use Claw\Project\Strategy; -use Claw\Run\ConsoleRunFrontend; -use Claw\Run\IssueRunner; use Claw\Run\Triage; use Claw\Server; +use Claw\ServerClient; use Claw\ServerLocator; use Claw\Trace\Level; use Claw\Trace\TraceReader; @@ -253,23 +252,33 @@ private function runIssue(array $args, ?string $projectDir, ?Level $verbosity): try { $store = $this->resolve($projectDir); $issue = $store->loadIssue($issueId); - $config = Config::load($this->root . '/.env'); } catch (ClawException $e) { fwrite(STDERR, 'claw run: ' . $e->getMessage() . "\n"); return 1; } - $agent = AgentFactory::make($config, new CurlHttpClient()); + // The CLI is a thin client: the server is the one process that writes a project db, so hand the + // run to it rather than opening the db here. If none is running, start one and wait for it. + $client = new ServerClient(new CurlHttpClient(), $this->appHome(), $this->root); + + try { + if ($client->running() === null) { + fwrite(STDOUT, "claw run: no server running for this workspace — starting one…\n"); + } - if ($agent === null) { - fwrite(STDERR, "claw run: agent '{$config->agent}' is not wired yet.\n"); + $server = $client->ensure(); + $client->startRun($server, $store->project()->id, $issue->id); + } catch (ClawException $e) { + fwrite(STDERR, 'claw run: ' . $e->getMessage() . "\n"); return 1; } - return new IssueRunner($this->projectsDir(), $store, $config, $agent, new ConsoleRunFrontend($verbosity)) - ->run($issue); + fwrite(STDOUT, "Run started for issue #{$issue->id} on the server at {$server['host']}:{$server['port']}.\n"); + fwrite(STDOUT, " watch it: claw log\n"); + + return 0; } /** diff --git a/src/ServerClient.php b/src/ServerClient.php new file mode 100644 index 0000000..83dd9cd --- /dev/null +++ b/src/ServerClient.php @@ -0,0 +1,171 @@ +locator = new ServerLocator($workspace); + } + + /** + * The address of a server already answering for this workspace, or null when none is — so the CLI + * can say it is about to start one before {@see ensure()} does. + * + * @return array{host: string, port: int}|null + */ + public function running(): ?array + { + $found = $this->locator->locate(); + + return $found !== null && $this->locator->alive($found['host'], $found['port']) + ? ['host' => $found['host'], 'port' => $found['port']] + : null; + } + + /** + * The address of a live server for this workspace, starting one if none answers. + * + * @return array{host: string, port: int} + * + * @throws ClawException when no server can be reached and none can be started + */ + public function ensure(): array + { + return $this->running() ?? $this->spawn(); + } + + /** + * Ask the server to start the issue's solver. Returns once the run is launched — the server owns + * it from there. + * + * @param array{host: string, port: int} $server + * + * @throws ClawException when the server refuses the start + */ + public function startRun(array $server, string $key, string $issueId): void + { + $url = sprintf( + 'http://%s:%d/api/projects/%s/issues/%s/start', + $server['host'], + $server['port'], + rawurlencode($key), + rawurlencode($issueId), + ); + + $response = $this->http->post($url, '', ['Content-Type: application/json']); + + if ($response->status === 202) { + return; + } + + if ($response->status === 409) { + throw new ClawException('a run for this issue is already active'); + } + + throw new ClawException("the server refused to start the run (HTTP {$response->status})"); + } + + /** + * The command that launches a server, or null when one cannot be built (no php binary, or bin/claw + * is not where it should be). Pure so the shape can be asserted without spawning anything. + */ + public static function spawnCommand(string $root, string $host, int $port): ?string + { + $claw = $root . '/bin/claw'; + + if (!is_file($claw)) { + return null; + } + + $extension = getenv('CLAW_SERVER_EXTENSION'); + $extension = $extension === false || $extension === '' ? 'true_async_server.so' : $extension; + + return sprintf( + '%s -d extension=%s %s serve --host %s --port %d', + escapeshellarg(\PHP_BINARY), + escapeshellarg($extension), + escapeshellarg($claw), + escapeshellarg($host), + $port, + ); + } + + /** + * Start a server and wait for it to answer. + * + * @return array{host: string, port: int} + * + * @throws ClawException + */ + private function spawn(): array + { + $command = self::spawnCommand($this->root, $this->host, $this->port); + + if ($command === null) { + throw new ClawException('cannot start a server: no php binary or bin/claw to launch'); + } + + // setsid puts the server in its own session so it outlives this CLI and holds no terminal; the + // descriptor spec points its stdio at files, so it never inherits — and never blocks — the pipe + // this process was invoked through. The loser of a bind race exits on its own duplicate-guard, + // and the poll below simply finds the winner instead. + $log = $this->workspace . \DIRECTORY_SEPARATOR . 'server.log'; + $descriptors = [ + 0 => ['file', '/dev/null', 'r'], + 1 => ['file', $log, 'a'], + 2 => ['file', $log, 'a'], + ]; + // --fork so setsid forks the server and returns at once: without it setsid execs the server in + // place and proc_close would block on it for the server's whole life. + $process = @proc_open('setsid --fork ' . $command, $descriptors, $pipes); + + if (\is_resource($process)) { + proc_close($process); + } + + $waited = 0; + + while ($waited < self::SPAWN_TIMEOUT_MS) { + $found = $this->locator->locate(); + + if ($found !== null && $this->locator->alive($found['host'], $found['port'])) { + return ['host' => $found['host'], 'port' => $found['port']]; + } + + usleep(self::POLL_MS * 1000); + $waited += self::POLL_MS; + } + + throw new ClawException( + 'the server did not come up within ' . (self::SPAWN_TIMEOUT_MS / 1000) . 's — is the server ' + . 'extension available? (set CLAW_SERVER_EXTENSION to its .so path)', + ); + } +} diff --git a/tests/ServerClientTest.php b/tests/ServerClientTest.php new file mode 100644 index 0000000..0a2eb03 --- /dev/null +++ b/tests/ServerClientTest.php @@ -0,0 +1,116 @@ +workspace = sys_get_temp_dir() . '/claw-client-' . bin2hex(random_bytes(6)); + mkdir($this->workspace); + } + + public function __destruct() + { + @rmdir($this->workspace); + } + + private function client(HttpResponse $response): ServerClient + { + return new ServerClient(new FakeHttpClient($response), $this->workspace, '/opt/claw'); + } + + #[Test] + public function aStartAcceptedReturnsQuietly(): void + { + $http = new FakeHttpClient(new HttpResponse(202, '')); + // a 202 must return without throwing; that the POST was issued is the proof it ran + new ServerClient($http, $this->workspace, '/opt/claw') + ->startRun(['host' => '127.0.0.1', 'port' => 8787], 'proj', '7'); + + Assert::same(str_ends_with((string) $http->lastUrl, '/issues/7/start'), true); + } + + #[Test] + public function anAlreadyActiveRunIsReportedNotSwallowed(): void + { + $threw = false; + + try { + $this->client(new HttpResponse(409, '{"error":"a run for this issue is already active"}')) + ->startRun(['host' => '127.0.0.1', 'port' => 8787], 'proj', '7'); + } catch (ClawException $e) { + $threw = true; + Assert::same(str_contains($e->getMessage(), 'already active'), true); + } + + Assert::same($threw, true); + } + + #[Test] + public function aServerErrorIsSurfaced(): void + { + $threw = false; + + try { + $this->client(new HttpResponse(500, '')) + ->startRun(['host' => '127.0.0.1', 'port' => 8787], 'proj', '7'); + } catch (ClawException $e) { + $threw = true; + Assert::same(str_contains($e->getMessage(), '500'), true); + } + + Assert::same($threw, true); + } + + #[Test] + public function theStartTargetsTheIssuesRunEndpointWithEscapedKeys(): void + { + $http = new FakeHttpClient(new HttpResponse(202, '')); + new ServerClient($http, $this->workspace, '/opt/claw') + ->startRun(['host' => '10.0.0.5', 'port' => 9000], 'a b/c', '7'); + + Assert::same($http->lastUrl, 'http://10.0.0.5:9000/api/projects/a%20b%2Fc/issues/7/start'); + } + + #[Test] + public function runningIsNullWhenNothingIsRecorded(): void + { + Assert::same($this->client(new HttpResponse(202, ''))->running(), null); + } + + #[Test] + public function theSpawnCommandCarriesTheExtensionHostAndPort(): void + { + // bin/claw must exist under the root for a command to be built, so point the root at the repo + $root = \dirname(__DIR__); + $command = ServerClient::spawnCommand($root, '127.0.0.1', 8787); + + Assert::notSame($command, null); + Assert::same(str_contains((string) $command, 'extension='), true); + Assert::same(str_contains((string) $command, 'serve --host'), true); + Assert::same(str_contains((string) $command, '--port 8787'), true); + } + + #[Test] + public function noSpawnCommandWhenBinClawIsAbsent(): void + { + Assert::same(ServerClient::spawnCommand('/nowhere/at/all', '127.0.0.1', 8787), null); + } +} From 2b429f8509e7383ec5825c5ed051678f7e757179 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:19:27 +0000 Subject: [PATCH 2/2] feat(cli): spawn the server cross-platform (Windows and POSIX) spawnCommand returns argv rather than a shell string, so it carries no quoting to platform-mismatch. launchDetached branches: POSIX uses 'setsid --fork', Windows uses proc_open's create_new_console/bypass_shell; stdio goes to files (/dev/null or NUL) either way. POSIX re-verified live; Windows is written to the API but unverified on this machine. --- src/ServerClient.php | 87 ++++++++++++++++++++++++-------------- tests/ServerClientTest.php | 12 +++--- 2 files changed, 62 insertions(+), 37 deletions(-) diff --git a/src/ServerClient.php b/src/ServerClient.php index 83dd9cd..4b93f96 100644 --- a/src/ServerClient.php +++ b/src/ServerClient.php @@ -93,12 +93,19 @@ public function startRun(array $server, string $key, string $issueId): void } /** - * The command that launches a server, or null when one cannot be built (no php binary, or bin/claw - * is not where it should be). Pure so the shape can be asserted without spawning anything. + * The argv that launches a server, or null when one cannot be built (bin/claw is not where it + * should be). Returned as a list, not a shell string, so it carries no quoting to get wrong and can + * be handed to proc_open's array form on either platform. Pure, so the shape can be asserted without + * spawning anything. + * + * The extension defaults to the `.so` name php resolves from its extension_dir; on Windows (a `.dll`) + * or a non-standard install, set CLAW_SERVER_EXTENSION to the file to load. + * + * @return list|null */ - public static function spawnCommand(string $root, string $host, int $port): ?string + public static function spawnCommand(string $root, string $host, int $port): ?array { - $claw = $root . '/bin/claw'; + $claw = $root . \DIRECTORY_SEPARATOR . 'bin' . \DIRECTORY_SEPARATOR . 'claw'; if (!is_file($claw)) { return null; @@ -107,14 +114,10 @@ public static function spawnCommand(string $root, string $host, int $port): ?str $extension = getenv('CLAW_SERVER_EXTENSION'); $extension = $extension === false || $extension === '' ? 'true_async_server.so' : $extension; - return sprintf( - '%s -d extension=%s %s serve --host %s --port %d', - escapeshellarg(\PHP_BINARY), - escapeshellarg($extension), - escapeshellarg($claw), - escapeshellarg($host), - $port, - ); + return [ + \PHP_BINARY, '-d', 'extension=' . $extension, + $claw, 'serve', '--host', $host, '--port', (string) $port, + ]; } /** @@ -129,26 +132,10 @@ private function spawn(): array $command = self::spawnCommand($this->root, $this->host, $this->port); if ($command === null) { - throw new ClawException('cannot start a server: no php binary or bin/claw to launch'); + throw new ClawException('cannot start a server: bin/claw is not where it should be'); } - // setsid puts the server in its own session so it outlives this CLI and holds no terminal; the - // descriptor spec points its stdio at files, so it never inherits — and never blocks — the pipe - // this process was invoked through. The loser of a bind race exits on its own duplicate-guard, - // and the poll below simply finds the winner instead. - $log = $this->workspace . \DIRECTORY_SEPARATOR . 'server.log'; - $descriptors = [ - 0 => ['file', '/dev/null', 'r'], - 1 => ['file', $log, 'a'], - 2 => ['file', $log, 'a'], - ]; - // --fork so setsid forks the server and returns at once: without it setsid execs the server in - // place and proc_close would block on it for the server's whole life. - $process = @proc_open('setsid --fork ' . $command, $descriptors, $pipes); - - if (\is_resource($process)) { - proc_close($process); - } + $this->launchDetached($command); $waited = 0; @@ -165,7 +152,45 @@ private function spawn(): array throw new ClawException( 'the server did not come up within ' . (self::SPAWN_TIMEOUT_MS / 1000) . 's — is the server ' - . 'extension available? (set CLAW_SERVER_EXTENSION to its .so path)', + . 'extension available? (set CLAW_SERVER_EXTENSION to its .so/.dll path)', ); } + + /** + * Start the server as a process that outlives this CLI and holds none of its stdio. + * + * The two platforms detach differently, and neither is the shell `&` a terminal-launched CLI would + * leak its console through. POSIX: `setsid --fork` gives the server its own session and returns at + * once (without --fork setsid execs in place and proc_close would block for the server's life). + * Windows: `create_new_console` cuts it from this console so it survives the CLI exiting. Either way + * the descriptor spec points stdio at files, so the server never inherits the pipe we were called + * through. + * + * @param list $command + */ + private function launchDetached(array $command): void + { + $log = $this->workspace . \DIRECTORY_SEPARATOR . 'server.log'; + $windows = \PHP_OS_FAMILY === 'Windows'; + + $descriptors = [ + 0 => ['file', $windows ? 'NUL' : '/dev/null', 'r'], + 1 => ['file', $log, 'a'], + 2 => ['file', $log, 'a'], + ]; + + if ($windows) { + $process = @proc_open($command, $descriptors, $pipes, null, null, [ + 'bypass_shell' => true, + 'create_new_console' => true, + 'create_process_group' => true, + ]); + } else { + $process = @proc_open(['setsid', '--fork', ...$command], $descriptors, $pipes); + } + + if (\is_resource($process)) { + proc_close($process); + } + } } diff --git a/tests/ServerClientTest.php b/tests/ServerClientTest.php index 0a2eb03..6e638ab 100644 --- a/tests/ServerClientTest.php +++ b/tests/ServerClientTest.php @@ -99,13 +99,13 @@ public function runningIsNullWhenNothingIsRecorded(): void public function theSpawnCommandCarriesTheExtensionHostAndPort(): void { // bin/claw must exist under the root for a command to be built, so point the root at the repo - $root = \dirname(__DIR__); - $command = ServerClient::spawnCommand($root, '127.0.0.1', 8787); + $command = ServerClient::spawnCommand(\dirname(__DIR__), '127.0.0.1', 8787) ?? []; - Assert::notSame($command, null); - Assert::same(str_contains((string) $command, 'extension='), true); - Assert::same(str_contains((string) $command, 'serve --host'), true); - Assert::same(str_contains((string) $command, '--port 8787'), true); + Assert::same(\in_array('serve', $command, true), true); + Assert::same(\in_array('--host', $command, true), true); + Assert::same(\in_array('127.0.0.1', $command, true), true); + Assert::same(\in_array('8787', $command, true), true); + Assert::same(array_any($command, static fn (string $a): bool => str_starts_with($a, 'extension=')), true); } #[Test]