From 1db47718d977aee7ac6b7280509619dfd6c14584 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 31 Oct 2026 09:57:13 -0700 Subject: [PATCH] fix(hub): wait longer for a new project's coordinator The 120 s readiness wait was shorter than the native sidecar stages, so a slow first start archived the new project. Wait 270 s: past those stages and before the web's 5-minute request ceiling. Closes CL-11872 --- .../src/projects/coordinator-startup.test.ts | 55 +++++++++++++++++++ apps/hub/src/projects/coordinator-startup.ts | 7 ++- 2 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 apps/hub/src/projects/coordinator-startup.test.ts diff --git a/apps/hub/src/projects/coordinator-startup.test.ts b/apps/hub/src/projects/coordinator-startup.test.ts new file mode 100644 index 00000000..50e0a328 --- /dev/null +++ b/apps/hub/src/projects/coordinator-startup.test.ts @@ -0,0 +1,55 @@ +import { afterEach, expect, setSystemTime, test } from "bun:test"; +import type { DB } from "@intx/db"; +import { workflowRun } from "@intx/db/schema"; +import type { SidecarAllocationStatus } from "@intx/types"; +import { awaitCoordinatorReady } from "./coordinator-startup.ts"; + +const run = { + id: "run", + anchorRunId: "run", + address: "run@tenant", + status: "running", + expiresAt: null, + cancellationRequestedAt: null, +}; + +// Each poll reads the next allocation status, then moves the clock on by `stepMs`. +function nativeStates(statuses: SidecarAllocationStatus[], stepMs: number) { + let polls = 0; + const db = { + select: () => ({ + from: (table: unknown) => ({ + where: async () => { + if (table === workflowRun) return [run]; + const status = statuses[Math.min(polls, statuses.length - 1)]; + polls += 1; + setSystemTime(new Date(Date.now() + stepMs)); + return [{ status, nextAttemptAt: null }]; + }, + }), + }), + }; + return db as unknown as DB["db"]; +} + +afterEach(() => { + setSystemTime(); +}); + +test("a coordinator ready after more than 120 s still starts", async () => { + await awaitCoordinatorReady( + nativeStates(["provisioning", "allocated"], 121_000), + "tenant", + "run", + ); +}); + +test("a start still pending past 270 s fails", async () => { + await expect( + awaitCoordinatorReady( + nativeStates(["provisioning"], 271_000), + "tenant", + "run", + ), + ).rejects.toMatchObject({ code: "coordinator_start_failed" }); +}); diff --git a/apps/hub/src/projects/coordinator-startup.ts b/apps/hub/src/projects/coordinator-startup.ts index 0958817d..fa2e91f3 100644 --- a/apps/hub/src/projects/coordinator-startup.ts +++ b/apps/hub/src/projects/coordinator-startup.ts @@ -22,7 +22,10 @@ import { ProjectError } from "./errors.ts"; import type { Actor } from "../lib/actor.ts"; const START_FAILED = "coordinator_start_failed"; -const READY_WAIT_MS = 120_000; +// Longer than the native sidecar ensure and connection stages (120 s each) normally take, and +// shorter than the web's 5-minute request ceiling, so a failed start reaches the person who +// created the project instead of archiving it after they stopped waiting. +const READY_WAIT_MS = 270_000; const READY_POLL_MS = 250; type BoundRun = { runId: string; ended: boolean }; @@ -188,7 +191,7 @@ async function attach( return true; } -async function awaitCoordinatorReady( +export async function awaitCoordinatorReady( db: DB["db"], tenantId: string, runId: string,