Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/Cli/WorkflowMode.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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");

Expand All @@ -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");
}
Expand Down
28 changes: 28 additions & 0 deletions src/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
28 changes: 24 additions & 4 deletions src/ServerClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand All @@ -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');
Expand All @@ -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.
Expand Down
28 changes: 24 additions & 4 deletions tests/ServerClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading