diff --git a/src/commands/deploy.test.ts b/src/commands/deploy.test.ts index 868a126..33ba3bb 100644 --- a/src/commands/deploy.test.ts +++ b/src/commands/deploy.test.ts @@ -102,6 +102,33 @@ describe("runDeploy", () => { expect(console.log).toHaveBeenCalledWith("Deployment completed: dep-1"); }); + it("includes capacity error when --wait times out with queued job error", async () => { + vi.useFakeTimers(); + + getDeployment.mockResolvedValue({ + id: "dep-1", + org_id: "org-1", + project_id: "proj-1", + build_id: "build-1", + status: "queued", + mode: "drain", + bullmq_job_id: "job-1", + error: + "CLUSTER_NO_CAPACITY: deploy_admission cpu_deficit=255m memory_deficit=0", + created_at: "2026-01-01T00:00:00Z", + completed_at: null, + }); + + const promise = runDeploy({ + wait: true, + pollIntervalMs: 1, + timeoutMs: 5_000, + }); + const rejection = expect(promise).rejects.toThrow(/CLUSTER_NO_CAPACITY/); + await vi.advanceTimersByTimeAsync(10_000); + await rejection; + }); + it("includes last queued status when --wait times out", async () => { vi.useFakeTimers(); diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index 77acd30..7edc91d 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -29,6 +29,7 @@ async function pollDeployment( progressId: job.build_id, }), getRetryAfterMs: (job) => job.retry_after_ms, + getLastDetail: (job) => job.error, onPoll: (job) => logVerbose(`deployment ${jobId}: status=${job.status}`), baseIntervalMs: options.pollIntervalMs, timeoutMs: options.timeoutMs, diff --git a/src/lib/poll-backoff.test.ts b/src/lib/poll-backoff.test.ts index 2d7f68a..6e25113 100644 --- a/src/lib/poll-backoff.test.ts +++ b/src/lib/poll-backoff.test.ts @@ -198,6 +198,37 @@ describe("pollWithBackoff", () => { expect(pollCount).toBeLessThan(5); }); + it("includes last detail after status when timeout elapses", async () => { + vi.useFakeTimers(); + + const promise = pollWithBackoff({ + poll: async () => ({ + status: "queued", + build_id: "build-1", + error: + "CLUSTER_NO_CAPACITY: deploy_admission cpu_deficit=255m memory_deficit=0", + }), + isTerminal: () => false, + getProgress: (job) => ({ + status: job.status, + progressId: job.build_id, + }), + getLastDetail: (job) => job.error, + baseIntervalMs: 1000, + timeoutMs: 5_000, + timeoutMessage: "timed out", + runtime: { + sleep: (ms) => vi.advanceTimersByTimeAsync(ms), + now: () => Date.now(), + random: () => 0.5, + }, + }); + + await expect(promise).rejects.toThrow( + /timed out \(last status=queued; CLUSTER_NO_CAPACITY: deploy_admission cpu_deficit=255m memory_deficit=0\)/, + ); + }); + it("includes last status=none when timeout elapses before any poll", async () => { await expect( pollWithBackoff({ diff --git a/src/lib/poll-backoff.ts b/src/lib/poll-backoff.ts index 9c115b4..b31045a 100644 --- a/src/lib/poll-backoff.ts +++ b/src/lib/poll-backoff.ts @@ -119,6 +119,8 @@ export async function pollWithBackoff(options: { isTerminal: (value: T) => boolean; getProgress: (value: T) => PollProgressSnapshot; getRetryAfterMs?: (value: T) => number | null | undefined; + /** Optional extra detail appended after last status on timeout (e.g. deploy job error). */ + getLastDetail?: (value: T) => string | null | undefined; onPoll?: (value: T) => void; baseIntervalMs: number; timeoutMs: number; @@ -130,9 +132,11 @@ export async function pollWithBackoff(options: { let attemptIndex = 0; let previousProgress: PollProgressSnapshot | null = null; let lastProgress: PollProgressSnapshot | null = null; + let lastValue: T | null = null; while (runtime.now() - started < options.timeoutMs) { const value = await options.poll(); + lastValue = value; options.onPoll?.(value); if (options.isTerminal(value)) { return value; @@ -156,8 +160,14 @@ export async function pollWithBackoff(options: { await runtime.sleep(delayMs); } + const lastDetail = + lastValue != null ? options.getLastDetail?.(lastValue) : undefined; + const detailSuffix = + lastDetail != null && lastDetail.trim() !== "" + ? `; ${lastDetail.trim()}` + : ""; const lastStatusSuffix = lastProgress - ? ` (last status=${lastProgress.status})` + ? ` (last status=${lastProgress.status}${detailSuffix})` : ` (last status=none)`; throw new Error(`${options.timeoutMessage}${lastStatusSuffix}`); }