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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ 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.
### A component the server refused is no longer drawn anyway

A sandboxed component asks the server at call time whether the Bot may still use it, and a refusal
Expand Down
12 changes: 12 additions & 0 deletions server/src/computer/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
66 changes: 66 additions & 0 deletions server/tests/computer-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
});
});