diff --git a/src/Cli/WorkflowMode.php b/src/Cli/WorkflowMode.php index d370466..25ee923 100644 --- a/src/Cli/WorkflowMode.php +++ b/src/Cli/WorkflowMode.php @@ -306,6 +306,7 @@ private function tailRun(ServerClient $client, array $server, string $key, strin $interactive = stream_isatty(STDIN); $since = 0; $openPrompt = null; + $openQuestion = 0; $hinted = false; while (true) { @@ -316,6 +317,7 @@ private function tailRun(ServerClient $client, array $server, string $key, strin if ($type === 'question') { $openPrompt = (string) ($data['prompt'] ?? 'the run is waiting for a human'); + $openQuestion = \is_int($row['spanId'] ?? null) ? $row['spanId'] : 0; $hinted = false; } elseif ($type === 'answer') { $openPrompt = null; @@ -348,7 +350,7 @@ private function tailRun(ServerClient $client, array $server, string $key, strin // it can be answered and keep following, so a piped `claw run` never blocks on input. if ($openPrompt !== null) { if ($interactive) { - if ($this->answerGate($client, $server, $key, $issueId, $openPrompt)) { + if ($this->answerGate($client, $server, $key, $issueId, $openPrompt, $openQuestion)) { $openPrompt = null; } else { // input closed — stop asking; suppress the hint this round so it is not doubled @@ -376,7 +378,7 @@ private function tailRun(ServerClient $client, array $server, string $key, strin * * @param array{host: string, port: int} $server */ - private function answerGate(ServerClient $client, array $server, string $key, string $issueId, string $prompt): bool + private function answerGate(ServerClient $client, array $server, string $key, string $issueId, string $prompt, int $questionId): bool { fwrite(STDOUT, "\n⏸ {$prompt}\n"); @@ -400,7 +402,7 @@ private function answerGate(ServerClient $client, array $server, string $key, st } try { - $client->answer($server, $key, $issueId, $text); + $client->answer($server, $key, $issueId, $text, $questionId); } catch (ClawException $e) { fwrite(STDERR, ' ' . $e->getMessage() . "\n"); } diff --git a/src/Server.php b/src/Server.php index ec5fcd3..6da7ce7 100644 --- a/src/Server.php +++ b/src/Server.php @@ -223,6 +223,20 @@ private function adoptOrphanedRuns(): void * the resumed run reads the journal on its way into the gate, so the answer has to be there before * it looks. Reversed, it would ask again and the reply would land on a question nobody is at. */ + /** The id of the question an issue's run is currently waiting on, or null when none is open. */ + private function openQuestionId(ProjectStoreInterface $store, string $issueId): ?int + { + $reader = new TraceReader($store->pdo()); + + foreach ($store->runningRuns() as $run) { + if ($run['issue'] === $issueId && ($gate = $reader->openGate($run['id'])) !== null) { + return (int) $gate['id']; + } + } + + return null; + } + private function deliverToADeadGate( HttpResponse $response, ProjectStoreInterface $store, @@ -1252,6 +1266,20 @@ private function answer(HttpRequest $request, HttpResponse $response, string $ke $payload = \json_decode($request->getBody(), true); $text = \is_array($payload) && isset($payload['text']) ? (string) $payload['text'] : ''; + + // When the caller names the question it is answering, refuse a reply meant for one that has + // already moved on — a terminal user typing while the same gate was answered in the dashboard + // and the run opened a new question. The id lives in the trace the caller is reading. Callers + // that send no id (older ones) keep the previous behaviour. + $question = \is_array($payload) && isset($payload['question']) ? (int) $payload['question'] : 0; + $open = $question > 0 ? $this->openQuestionId($store, $issueId) : null; + + if ($open !== null && $open !== $question) { + $response->json(['error' => 'that question has been answered — a newer one is waiting'], 409); + + return; + } + $channel = $this->gates[$key . '/' . $issueId] ?? null; if ($channel !== null) { diff --git a/src/ServerClient.php b/src/ServerClient.php index 3f831c8..e8e82c4 100644 --- a/src/ServerClient.php +++ b/src/ServerClient.php @@ -6,6 +6,7 @@ use Claw\Exceptions\ClawException; use Claw\Http\HttpClientInterface; +use Claw\Http\HttpResponse; /** * The CLI's side of the one-writer rule: it does not open a project db, it asks the server to. The @@ -96,11 +97,16 @@ public function startRun(array $server, string $key, string $issueId): void /** * Send a person's reply to a run's open gate. * + * $questionId names the question being answered (0 to omit). The server refuses a reply aimed at a + * question that has since been answered and replaced — the id is what closes the terminal-vs-dashboard + * race the blocking prompt opens. + * * @param array{host: string, port: int} $server * - * @throws ClawException when the run is not waiting (409) or the server refuses the answer + * @throws ClawException when the run is not waiting, the question has moved on (409), or the server + * refuses the answer */ - public function answer(array $server, string $key, string $issueId, string $text): void + public function answer(array $server, string $key, string $issueId, string $text, int $questionId = 0): void { $url = sprintf( 'http://%s:%d/api/projects/%s/issues/%s/answer', @@ -110,7 +116,13 @@ public function answer(array $server, string $key, string $issueId, string $text rawurlencode($issueId), ); - $body = json_encode(['text' => $text]); + $fields = ['text' => $text]; + + if ($questionId > 0) { + $fields['question'] = $questionId; + } + + $body = json_encode($fields); if ($body === false) { throw new ClawException('cannot send the answer: it is not valid text'); @@ -123,12 +135,20 @@ public function answer(array $server, string $key, string $issueId, string $text } if ($response->status === 409) { - throw new ClawException('the run is not waiting for an answer right now'); + throw new ClawException($this->errorText($response) ?? 'the run is not waiting for an answer right now'); } throw new ClawException("the server refused the answer (HTTP {$response->status})"); } + /** The server's own `error` message from a JSON body, or null when there is not one to show. */ + private function errorText(HttpResponse $response): ?string + { + $data = json_decode($response->body, true); + + return \is_array($data) && \is_string($data['error'] ?? null) ? $data['error'] : null; + } + /** * The runs recorded for an issue, oldest first — how the CLI finds the run a start produced, since * the id is minted inside the run and not returned by the start. diff --git a/tests/ServerClientTest.php b/tests/ServerClientTest.php index 353621b..67a3fda 100644 --- a/tests/ServerClientTest.php +++ b/tests/ServerClientTest.php @@ -170,16 +170,36 @@ public function anAnswerAcceptedReturnsQuietlyAndCarriesTheText(): void } #[Test] - public function answeringAGateThatIsNotWaitingIsReported(): void + public function anAnswerNamesTheQuestionItIsFor(): void + { + $http = new FakeHttpClient(new HttpResponse(202, '')); + new ServerClient($http, $this->workspace, '/opt/claw') + ->answer(['host' => '127.0.0.1', 'port' => 8787], 'proj', '7', 'merge sort', 42); + + Assert::same(str_contains((string) $http->lastBody, '"question":42'), true); + } + + #[Test] + public function withoutAQuestionIdNoneIsSent(): void + { + $http = new FakeHttpClient(new HttpResponse(202, '')); + new ServerClient($http, $this->workspace, '/opt/claw') + ->answer(['host' => '127.0.0.1', 'port' => 8787], 'proj', '7', 'hi'); + + Assert::same(str_contains((string) $http->lastBody, 'question'), false); + } + + #[Test] + public function aRejectedAnswerSurfacesTheServersOwnReason(): void { $threw = false; try { - $this->client(new HttpResponse(409, '{"error":"the run is not waiting for an answer right now"}')) - ->answer(['host' => '127.0.0.1', 'port' => 8787], 'proj', '7', 'hi'); + $this->client(new HttpResponse(409, '{"error":"that question has been answered — a newer one is waiting"}')) + ->answer(['host' => '127.0.0.1', 'port' => 8787], 'proj', '7', 'hi', 42); } catch (ClawException $e) { $threw = true; - Assert::same(str_contains($e->getMessage(), 'not waiting'), true); + Assert::same(str_contains($e->getMessage(), 'a newer one is waiting'), true); } Assert::same($threw, true);