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
88 changes: 75 additions & 13 deletions src/Cli/WorkflowMode.php
Original file line number Diff line number Diff line change
Expand Up @@ -303,30 +303,31 @@ private function tailRun(ServerClient $client, array $server, string $key, strin
{
$threshold = ($verbosity ?? Level::Info)->value;
$color = stream_isatty(STDOUT) && getenv('NO_COLOR') === false;
$interactive = stream_isatty(STDIN);
$since = 0;
$flaggedGate = false;
$openPrompt = null;
$hinted = false;

while (true) {
foreach ($client->trace($server, $key, $runId, $since) as $row) {
$since = max($since, \is_int($row['seq'] ?? null) ? $row['seq'] : $since);
$type = (string) ($row['type'] ?? '');
$data = \is_array($row['data'] ?? null) ? $row['data'] : [];

if ($type === 'question') {
$openPrompt = (string) ($data['prompt'] ?? 'the run is waiting for a human');
$hinted = false;
} elseif ($type === 'answer') {
$openPrompt = null;
}

if ((\is_int($row['level'] ?? null) ? $row['level'] : 0) < $threshold) {
continue;
}

$data = \is_array($row['data'] ?? null) ? $row['data'] : [];
$head = TraceFormat::line((string) ($row['phase'] ?? ''), (string) ($row['type'] ?? ''), $data);
$head = TraceFormat::line((string) ($row['phase'] ?? ''), $type, $data);
$depth = \is_int($row['depth'] ?? null) ? $row['depth'] : 0;
fwrite(STDOUT, str_repeat(' ', $depth) . TraceFormat::paint((string) ($row['type'] ?? ''), $head, $color) . "\n");

if (($row['type'] ?? '') === 'question' && !$flaggedGate) {
$flaggedGate = true;
fwrite(STDOUT, " ⏸ waiting for a human — answer it in the dashboard; Ctrl-C detaches (the run continues).\n");
}

if (($row['type'] ?? '') === 'answer') {
$flaggedGate = false;
}
fwrite(STDOUT, str_repeat(' ', $depth) . TraceFormat::paint($type, $head, $color) . "\n");
}

$status = $this->runStatus($client->runs($server, $key, $issueId), $runId);
Expand All @@ -343,10 +344,71 @@ private function tailRun(ServerClient $client, array $server, string $key, strin
return 1;
}

// A gate is open. On a terminal, answer it here and the run carries on; otherwise say where
// 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)) {
$openPrompt = null;
} else {
// input closed — stop asking; suppress the hint this round so it is not doubled
// with the "detaching" line answerGate just printed, and let the next gate re-hint
$interactive = false;
$hinted = true;
}
} elseif (!$hinted) {
$hinted = true;
fwrite(STDOUT, " ⏸ waiting for a human — answer it in the dashboard; Ctrl-C detaches (the run continues).\n");
}
}

usleep(500_000);
}
}

/**
* Ask the person the run's open question and send their reply.
*
* Returns false ONLY when the input stream is closed (Ctrl-D / a pipe ran dry) — the one case the
* caller must stop asking on, or it would spin on an EOF that never carries text. An answer that
* the server rejects returns true: the gate is handled (it was answered elsewhere, or the run moved
* on), so the caller drops it rather than re-prompting in a tight loop.
*
* @param array{host: string, port: int} $server
*/
private function answerGate(ServerClient $client, array $server, string $key, string $issueId, string $prompt): bool
{
fwrite(STDOUT, "\n⏸ {$prompt}\n");

while (true) {
fwrite(STDOUT, '› ');
$line = fgets(STDIN);

if ($line === false) {
fwrite(STDOUT, "\n (input closed — detaching; the run continues, answer it in the dashboard.)\n");

return false;
}

$text = trim($line);

if ($text === '') {
// An empty reply wakes the run with nothing to act on — ask again rather than spend the gate.
fwrite(STDOUT, " (type a reply — an empty answer would spend the gate for nothing.)\n");

continue;
}

try {
$client->answer($server, $key, $issueId, $text);
} catch (ClawException $e) {
fwrite(STDERR, ' ' . $e->getMessage() . "\n");
}

return true;
}
}

/**
* A run's current status from a runs listing, or '' when it is not there.
*
Expand Down
36 changes: 36 additions & 0 deletions src/ServerClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,42 @@ public function startRun(array $server, string $key, string $issueId): void
throw new ClawException("the server refused to start the run (HTTP {$response->status})");
}

/**
* Send a person's reply to a run's open gate.
*
* @param array{host: string, port: int} $server
*
* @throws ClawException when the run is not waiting (409) or the server refuses the answer
*/
public function answer(array $server, string $key, string $issueId, string $text): void
{
$url = sprintf(
'http://%s:%d/api/projects/%s/issues/%s/answer',
$server['host'],
$server['port'],
rawurlencode($key),
rawurlencode($issueId),
);

$body = json_encode(['text' => $text]);

if ($body === false) {
throw new ClawException('cannot send the answer: it is not valid text');
}

$response = $this->http->post($url, $body, ['Content-Type: application/json']);

if ($response->status === 202) {
return;
}

if ($response->status === 409) {
throw new ClawException('the run is not waiting for an answer right now');
}

throw new ClawException("the server refused the answer (HTTP {$response->status})");
}

/**
* 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
27 changes: 27 additions & 0 deletions tests/ServerClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -157,4 +157,31 @@ public function traceIsEmptyOnAnErrorResponse(): void

Assert::same($client->trace(['host' => '127.0.0.1', 'port' => 8787], 'proj', '9', 0), []);
}

#[Test]
public function anAnswerAcceptedReturnsQuietlyAndCarriesTheText(): void
{
$http = new FakeHttpClient(new HttpResponse(202, ''));
new ServerClient($http, $this->workspace, '/opt/claw')
->answer(['host' => '127.0.0.1', 'port' => 8787], 'proj', '7', 'use the merge sort');

Assert::same(str_ends_with((string) $http->lastUrl, '/issues/7/answer'), true);
Assert::same(str_contains((string) $http->lastBody, 'use the merge sort'), true);
}

#[Test]
public function answeringAGateThatIsNotWaitingIsReported(): 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');
} catch (ClawException $e) {
$threw = true;
Assert::same(str_contains($e->getMessage(), 'not waiting'), true);
}

Assert::same($threw, true);
}
}
Loading