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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,13 @@ Sessions survive and nobody signs in again.
laptop `http://localhost` counts as one, so this never showed up in development; on a real
address it does not, and the surface did nothing at all when you pressed send. No message, no
error. Ids now come from an API with no such restriction.
- **The first browser action a Bot was ever asked for failed.** Creating a computer and starting it
are two calls to Docker, and a name the daemon has not published yet answers the second with a 404.
The supervisor treats that as a lost race and rebuilds, which is right, but it went straight back
round: the retry landed a millisecond later, saw the same unpublished name, and spent the only
other attempt on it. The whole request then failed as Docker being unreachable, the person was told
the computer could not be started, and the next message worked. It waits one poll interval before
rebuilding now, which is what the health wait already uses for the same question.
- **A framework Bot asked for a browser action and nothing happened.** `agent-langgraph` ends a run
when the model calls a tool the surface owns, which is how a tool that lives in the browser is
supposed to work: the run finishes, the surface acts, and the next run carries the result. But the
Expand Down
29 changes: 24 additions & 5 deletions supervisor/src/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ function statusOf(error: unknown): number | undefined {
return (error as { statusCode?: number }).statusCode;
}

/** One poll interval, used both by the health wait and by the retry that follows a lost race. */
function pause(ms: number): Promise<void> {
const { promise, resolve } = Promise.withResolvers<void>();
setTimeout(resolve, ms);
return promise;
}

export type ComputerState = {
botId: string;
container: string;
Expand Down Expand Up @@ -180,7 +187,7 @@ async function waitUntilAnswering(
} catch {
// Mid-creation, or gone. The deadline is what ends this.
}
await new Promise((resolve) => setTimeout(resolve, 250));
await pause(250);
}
}

Expand Down Expand Up @@ -311,10 +318,22 @@ export async function ensure(
await docker.getContainer(names.container).start();
} catch (error) {
const status = statusOf(error);
// Gone between creating it and starting it: a concurrent reset took the container away, or
// the create this request lost the race to was itself rolled back. Nothing about the Bot has
// changed, so the answer is to build it again rather than to report Docker as unreachable.
if (status === 404 && attempt > 1) continue;
/*
* Gone between creating it and starting it: a concurrent reset took the container away, the
* create this request lost the race to was itself rolled back, or the daemon has not yet
* published the name this request just created. Nothing about the Bot has changed, so the
* answer is to build it again rather than to report Docker as unreachable.
*
* Paused first, because the retry is the whole budget. Going straight back round arrives
* within a millisecond, sees the same not-yet-published name, and spends the second attempt
* on the state that failed the first: the first browser action a Bot is ever asked for fails,
* and the second one, seconds later, works. One poll interval is what the health wait uses
* for the same question.
*/
if (status === 404 && attempt > 1) {
await pause(250);
continue;
}
// 304 is "already running", which is success for an idempotent verb.
if (status !== 304) {
throw new DockerUnavailableError(String(error));
Expand Down