From 0f350a24eb3a954a6f274d7c7f782689c5307027 Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:31:06 +0900 Subject: [PATCH] Report a Stop that lands mid-action as a stop --- CHANGELOG.md | 10 +++++ server/src/computer/client.ts | 12 +++++ server/tests/computer-client.test.ts | 66 ++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c658b496..92d38afb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### Pressing Stop is recorded as a stop, not as a computer that is not running + +The computer transport answered a Stop correctly only when it arrived before the request left. The +caller's signal is handed to `fetch` precisely so a Stop can also land mid-action, and a fetch +aborted that way rejects with an `AbortError`, which fell through to the message for a computer that +cannot be reached. The person was told their own click had failed because the assistant's computer +was not running, and the gateway wrote that sentence into the action's audit row as its failure -- +so a deliberate stop read back as an outage. A genuinely unreachable computer and a timeout still +say what they said. + ### The server connects to Postgres on Windows, and `localhost` is no longer a coin toss Two separate faults, both of which stop a deployment reaching its own database and neither of which diff --git a/server/src/computer/client.ts b/server/src/computer/client.ts index 1b1a12a3f..a322de1e9 100644 --- a/server/src/computer/client.ts +++ b/server/src/computer/client.ts @@ -155,6 +155,18 @@ export function createComputerTransport( : AbortSignal.timeout(timeoutMs), }); } catch (error) { + /* + * The caller's own abort is answered first, and says what the check above the fetch says. + * + * The signal is handed to fetch precisely so a Stop can land mid-flight, and a fetch aborted + * that way rejects with an AbortError, which is neither a TimeoutError nor a computer that is + * not running. Both of the other answers are statements about the infrastructure, and this + * message is not only read by the model: the gateway writes it into the action's audit row as + * `failure`, so a person pressing Stop was recorded as an outage. + */ + if (caller?.aborted) { + throw new ComputerUnavailableError("The action was stopped."); + } throw new ComputerUnavailableError( error instanceof Error && error.name === "TimeoutError" ? "The assistant's computer did not respond in time." diff --git a/server/tests/computer-client.test.ts b/server/tests/computer-client.test.ts index f7c0f0ebe..ef25d877b 100644 --- a/server/tests/computer-client.test.ts +++ b/server/tests/computer-client.test.ts @@ -395,3 +395,69 @@ describe("the deadline a call is given", () => { ).resolves.toBeDefined(); }); }); + +describe("a person's Stop", () => { + /* + * Stop is the person saying "not that". The transport already answers it that way when the signal + * is already aborted before the request leaves; the signal is handed to fetch precisely so Stop can + * also land mid-flight, and that half was reported as a dead computer. The message is not only what + * the model reads: the gateway writes it into the action's audit row as `failure`, so a person's + * Stop was recorded as an outage. + */ + test("a Stop that lands mid-action is reported as a stop, not as a dead computer", async () => { + const controller = new AbortController(); + const aborting = ((_url: string, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + // What fetch does when the signal it was given aborts. + reject(new DOMException("The operation was aborted.", "AbortError")); + }); + controller.abort(); + })) as unknown as typeof fetch; + + const transport = createComputerTransport({ fetchImpl: aborting }); + + await expect( + transport.post( + "http://computer", + "bot-1", + "/click", + {}, + controller.signal, + ), + ).rejects.toThrow("The action was stopped."); + }); + + test("a Stop pressed before the request leaves still says the same thing", async () => { + const controller = new AbortController(); + controller.abort(); + const transport = createComputerTransport({ + fetchImpl: (() => { + throw new Error("the request should never have been sent"); + }) as unknown as typeof fetch, + }); + + await expect( + transport.post( + "http://computer", + "bot-1", + "/click", + {}, + controller.signal, + ), + ).rejects.toThrow("The action was stopped."); + }); + + test("a computer that is really unreachable still says so", async () => { + const transport = createComputerTransport({ + fetchImpl: (() => + Promise.reject( + new Error("connect ECONNREFUSED"), + )) as unknown as typeof fetch, + }); + + await expect( + transport.post("http://computer", "bot-1", "/click", {}), + ).rejects.toThrow("The assistant's computer is not running."); + }); +});