diff --git a/CHANGELOG.md b/CHANGELOG.md index b448227a..21800a08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/supervisor/src/docker.ts b/supervisor/src/docker.ts index 7b3d551f..898513ed 100644 --- a/supervisor/src/docker.ts +++ b/supervisor/src/docker.ts @@ -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 { + const { promise, resolve } = Promise.withResolvers(); + setTimeout(resolve, ms); + return promise; +} + export type ComputerState = { botId: string; container: string; @@ -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); } } @@ -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));