From b51313a0421cc3c700507dfb96d93aa2a1b5bd16 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 26 Mar 2026 12:18:08 +0000 Subject: [PATCH 1/4] ci: pin bun to 1.2.15 to match dogfood version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI workflow used bun-version: latest, which recently jumped to 1.3.11. Bun 1.3.10 changed the bundler preamble (WeakMap caching, __accessProp, __exportSetter), so dist/index.js built locally with 1.2.x no longer matches what CI produces — instant check_unstaged failure for anyone not running bleeding-edge bun. ci: rebuild dist/index.js with bun 1.2.15 --- .github/workflows/ci.yaml | 2 +- dist/index.js | 32 +++++++------------------------- 2 files changed, 8 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 80fd07e..5bf4b11 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -19,7 +19,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v1 with: - bun-version: latest + bun-version: "1.2.15" - name: Install dependencies run: bun install diff --git a/dist/index.js b/dist/index.js index fcc3a31..9d0d742 100644 --- a/dist/index.js +++ b/dist/index.js @@ -3,43 +3,25 @@ var __getProtoOf = Object.getPrototypeOf; var __defProp = Object.defineProperty; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; -function __accessProp(key) { - return this[key]; -} -var __toESMCache_node; -var __toESMCache_esm; var __toESM = (mod, isNodeMode, target) => { - var canCache = mod != null && typeof mod === "object"; - if (canCache) { - var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap; - var cached = cache.get(mod); - if (cached) - return cached; - } target = mod != null ? __create(__getProtoOf(mod)) : {}; const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target; for (let key of __getOwnPropNames(mod)) if (!__hasOwnProp.call(to, key)) __defProp(to, key, { - get: __accessProp.bind(mod, key), + get: () => mod[key], enumerable: true }); - if (canCache) - cache.set(mod, to); return to; }; var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); -var __returnValue = (v) => v; -function __exportSetter(name, newValue) { - this[name] = __returnValue.bind(null, newValue); -} var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true, configurable: true, - set: __exportSetter.bind(all, name) + set: (newValue) => all[name] = () => newValue }); }; @@ -3465,7 +3447,7 @@ var require_constants2 = __commonJS((exports2, module2) => { } })(); var channel; - var structuredClone = globalThis.structuredClone ?? function structuredClone2(value, options = undefined) { + var structuredClone = globalThis.structuredClone ?? function structuredClone(value, options = undefined) { if (arguments.length === 0) { throw new TypeError("missing argument"); } @@ -16390,7 +16372,7 @@ var require_undici = __commonJS((exports2, module2) => { module2.exports.getGlobalDispatcher = getGlobalDispatcher; if (util.nodeMajor > 16 || util.nodeMajor === 16 && util.nodeMinor >= 8) { let fetchImpl = null; - module2.exports.fetch = async function fetch2(resource) { + module2.exports.fetch = async function fetch(resource) { if (!fetchImpl) { fetchImpl = require_fetch().fetch; } @@ -22726,11 +22708,11 @@ var require_github = __commonJS((exports2) => { }); // src/index.ts -var core2 = __toESM(require_core(), 1); -var github = __toESM(require_github(), 1); +var core2 = __toESM(require_core()); +var github = __toESM(require_github()); // src/action.ts -var core = __toESM(require_core(), 1); +var core = __toESM(require_core()); // node_modules/zod/v3/external.js var exports_external = {}; From 1ce13ac882eebd2dfb345e947ad056f3504dc222 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 26 Mar 2026 09:32:54 +0000 Subject: [PATCH 2/4] fix: handle 404 in waitForTaskActive when task is deleted by concurrent run When a concurrent workflow run deletes a task while waitForTaskActive is polling getTaskById, the action now catches the 404 and falls through to create a new task instead of crashing. - Add TaskDeletedError class for explicit error discrimination - Catch 404 in waitForTaskActive polling loop, throw TaskDeletedError - Wrap existing-task path in action.ts run() with try-catch to handle TaskDeletedError and fall through to task creation Closes #12 --- dist/index.js | 49 +++++++++++++++++++++-------- src/action.test.ts | 61 +++++++++++++++++++++++++++++++++++++ src/action.ts | 66 ++++++++++++++++++++++++---------------- src/coder-client.test.ts | 42 +++++++++++++++++++++++++ src/coder-client.ts | 19 +++++++++++- 5 files changed, 197 insertions(+), 40 deletions(-) diff --git a/dist/index.js b/dist/index.js index 9d0d742..7162e13 100644 --- a/dist/index.js +++ b/dist/index.js @@ -26781,7 +26781,15 @@ class RealCoderClient { const startTime = Date.now(); let idleSince = null; while (Date.now() - startTime < timeoutMs) { - const task = await this.getTaskById(owner, taskId); + let task; + try { + task = await this.getTaskById(owner, taskId); + } catch (error) { + if (error instanceof CoderAPIError && error.statusCode === 404) { + throw new TaskDeletedError(taskId); + } + throw error; + } if (task.status === "error") { throw new CoderAPIError(`Task entered error state while waiting for active state`, 500, task); } @@ -26863,6 +26871,15 @@ var ExperimentalCoderSDKTaskListResponseSchema = exports_external.object({ tasks: exports_external.array(ExperimentalCoderSDKTaskSchema) }); +class TaskDeletedError extends Error { + taskId; + constructor(taskId) { + super(`Task ${taskId} was deleted while waiting for it to become active`); + this.taskId = taskId; + this.name = "TaskDeletedError"; + } +} + class CoderAPIError extends Error { statusCode; response; @@ -26979,17 +26996,25 @@ class CoderTaskAction { const existingTask = await this.coder.getTask(coderUsername, taskName); if (existingTask) { core.info(`Coder Task: already exists: ${existingTask.name} (id: ${existingTask.id} status: ${existingTask.status})`); - core.info(`Coder Task: waiting for task ${existingTask.name} to become active and idle...`); - await this.coder.waitForTaskActive(coderUsername, existingTask.id, core.debug, 1200000); - core.info("Coder Task: Sending prompt to existing task..."); - await this.coder.sendTaskInput(coderUsername, existingTask.id, this.inputs.coderTaskPrompt); - core.info("Coder Task: Prompt sent successfully"); - return { - coderUsername, - taskName: existingTask.name, - taskUrl: this.generateTaskUrl(coderUsername, existingTask.id), - taskCreated: false - }; + try { + core.info(`Coder Task: waiting for task ${existingTask.name} to become active and idle...`); + await this.coder.waitForTaskActive(coderUsername, existingTask.id, core.debug, 1200000); + core.info("Coder Task: Sending prompt to existing task..."); + await this.coder.sendTaskInput(coderUsername, existingTask.id, this.inputs.coderTaskPrompt); + core.info("Coder Task: Prompt sent successfully"); + return { + coderUsername, + taskName: existingTask.name, + taskUrl: this.generateTaskUrl(coderUsername, existingTask.id), + taskCreated: false + }; + } catch (error2) { + if (error2 instanceof TaskDeletedError) { + core.warning(`Existing task '${existingTask.name}' was deleted (likely by a concurrent run). Creating a new task.`); + } else { + throw error2; + } + } } core.info("Creating Coder task..."); const req = { diff --git a/src/action.test.ts b/src/action.test.ts index bec3c7c..d2f8085 100644 --- a/src/action.test.ts +++ b/src/action.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test, beforeEach } from "bun:test"; import { CoderTaskAction } from "./action"; import type { Octokit } from "./action"; import { ActionOutputsSchema, type ActionOutputs } from "./schemas"; +import { TaskDeletedError, TaskIdSchema, TaskNameSchema } from "./coder-client"; import { MockCoderClient, createMockOctokit, @@ -695,6 +696,66 @@ describe("CoderTaskAction", () => { expect(action.run()).rejects.toThrow("Permission denied"); }); + + test("creates new task when existing task is deleted during waitForTaskActive", async () => { + const newTask = { + ...mockTask, + id: TaskIdSchema.parse("aa0e8400-e29b-41d4-a716-446655440000"), + name: TaskNameSchema.parse("task-123"), + }; + + // Setup: existing task found, but waitForTaskActive throws TaskDeletedError + coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetTemplateByOrganizationAndName.mockResolvedValue( + mockTemplate, + ); + coderClient.mockGetTemplateVersionPresets.mockResolvedValue([]); + coderClient.mockGetTask.mockResolvedValue(mockTask); + coderClient.mockWaitForTaskActive.mockRejectedValue( + new TaskDeletedError(mockTask.id), + ); + coderClient.mockCreateTask.mockResolvedValue(newTask); + octokit.rest.issues.listComments.mockResolvedValue({ + data: [], + } as ReturnType); + octokit.rest.issues.createComment.mockResolvedValue( + {} as ReturnType, + ); + + const inputs = createMockInputs({ + githubUserID: 12345, + }); + const action = new CoderTaskAction( + coderClient, + octokit as unknown as Octokit, + inputs, + ); + + // Execute + const result = await action.run(); + + // Verify: should have fallen through to create a new task + expect(coderClient.mockGetTask).toHaveBeenCalledWith( + mockUser.username, + mockTask.name, + ); + expect(coderClient.mockWaitForTaskActive).toHaveBeenCalled(); + expect(coderClient.mockCreateTask).toHaveBeenCalledWith( + mockUser.username, + { + name: mockTask.name, + template_version_id: mockTemplate.active_version_id, + template_version_preset_id: undefined, + input: inputs.coderTaskPrompt, + }, + ); + // Should NOT have tried to send input to the deleted task + expect(coderClient.mockSendTaskInput).not.toHaveBeenCalled(); + + const parsedResult = ActionOutputsSchema.parse(result); + expect(parsedResult.taskCreated).toBe(true); + expect(parsedResult.coderUsername).toBe(mockUser.username); + }); }); // NOTE: this may or may not work in the real world depending on the permissions of the user diff --git a/src/action.ts b/src/action.ts index a953d7f..9850a8a 100644 --- a/src/action.ts +++ b/src/action.ts @@ -2,6 +2,7 @@ import * as core from "@actions/core"; import { type ExperimentalCoderSDKCreateTaskRequest, TaskNameSchema, + TaskDeletedError, type CoderClient, type TaskId, } from "./coder-client"; @@ -173,34 +174,45 @@ export class CoderTaskAction { `Coder Task: already exists: ${existingTask.name} (id: ${existingTask.id} status: ${existingTask.status})`, ); - // Wait for task to become active and idle before sending - // input. The agent may be in "working" state even when the - // task status is "active", and sending input in that state - // causes 409/502 errors. - core.info( - `Coder Task: waiting for task ${existingTask.name} to become active and idle...`, - ); - await this.coder.waitForTaskActive( - coderUsername, - existingTask.id, - core.debug, - 1_200_000, - ); + try { + // Wait for task to become active and idle before sending + // input. The agent may be in "working" state even when + // the task status is "active", and sending input in that + // state causes 409/502 errors. + core.info( + `Coder Task: waiting for task ${existingTask.name} to become active and idle...`, + ); + await this.coder.waitForTaskActive( + coderUsername, + existingTask.id, + core.debug, + 1_200_000, + ); - core.info("Coder Task: Sending prompt to existing task..."); - // Send prompt to existing task using the task ID (UUID) - await this.coder.sendTaskInput( - coderUsername, - existingTask.id, - this.inputs.coderTaskPrompt, - ); - core.info("Coder Task: Prompt sent successfully"); - return { - coderUsername, - taskName: existingTask.name, - taskUrl: this.generateTaskUrl(coderUsername, existingTask.id), - taskCreated: false, - }; + core.info("Coder Task: Sending prompt to existing task..."); + // Send prompt to existing task using the task ID (UUID) + await this.coder.sendTaskInput( + coderUsername, + existingTask.id, + this.inputs.coderTaskPrompt, + ); + core.info("Coder Task: Prompt sent successfully"); + return { + coderUsername, + taskName: existingTask.name, + taskUrl: this.generateTaskUrl(coderUsername, existingTask.id), + taskCreated: false, + }; + } catch (error) { + if (error instanceof TaskDeletedError) { + core.warning( + `Existing task '${existingTask.name}' was deleted (likely by a concurrent run). Creating a new task.`, + ); + // Fall through to task creation below. + } else { + throw error; + } + } } core.info("Creating Coder task..."); diff --git a/src/coder-client.test.ts b/src/coder-client.test.ts index 0d8ee85..8508a43 100644 --- a/src/coder-client.test.ts +++ b/src/coder-client.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test, beforeEach, mock } from "bun:test"; import { RealCoderClient, CoderAPIError, + TaskDeletedError, type ExperimentalCoderSDKTask, } from "./coder-client"; import { @@ -486,6 +487,47 @@ describe("CoderClient", () => { ), ).rejects.toThrow("Timeout waiting for task to reach active state"); }); + + test("throws TaskDeletedError when task is deleted during polling (404)", async () => { + const pendingTask: ExperimentalCoderSDKTask = { + ...mockTask, + status: "pending", + }; + + // First poll succeeds (task exists), second poll returns 404 + // (task was deleted by concurrent run). + mockFetch + .mockResolvedValueOnce(createMockResponse(pendingTask)) + .mockResolvedValueOnce( + createMockResponse( + { + message: + "Resource not found or you do not have access to this resource", + }, + { ok: false, status: 404, statusText: "Not Found" }, + ), + ); + + const err = await client + .waitForTaskActive( + mockUser.username, + mockTask.id, + console.log, + 10000, + 0, + 10, + ) + .catch((e: unknown) => e); + + expect(err).toBeInstanceOf(TaskDeletedError); + expect((err as TaskDeletedError).message).toBe( + `Task ${mockTask.id} was deleted while waiting for it to become active`, + ); + expect((err as TaskDeletedError).taskId).toBe(mockTask.id); + + // Should have polled twice: first success, then 404. + expect(mockFetch).toHaveBeenCalledTimes(2); + }); }); describe("sendTaskInput", () => { diff --git a/src/coder-client.ts b/src/coder-client.ts index 22ad29f..b2b7d51 100644 --- a/src/coder-client.ts +++ b/src/coder-client.ts @@ -229,7 +229,15 @@ export class RealCoderClient implements CoderClient { let idleSince: number | null = null; while (Date.now() - startTime < timeoutMs) { - const task = await this.getTaskById(owner, taskId); + let task: ExperimentalCoderSDKTask; + try { + task = await this.getTaskById(owner, taskId); + } catch (error) { + if (error instanceof CoderAPIError && error.statusCode === 404) { + throw new TaskDeletedError(taskId); + } + throw error; + } if (task.status === "error") { throw new CoderAPIError( @@ -387,6 +395,15 @@ export type ExperimentalCoderSDKTaskListResponse = z.infer< typeof ExperimentalCoderSDKTaskListResponseSchema >; +// TaskDeletedError is thrown when a task is deleted while waiting +// for it to become active (e.g. by a concurrent workflow run). +export class TaskDeletedError extends Error { + constructor(public readonly taskId: TaskId) { + super(`Task ${taskId} was deleted while waiting for it to become active`); + this.name = "TaskDeletedError"; + } +} + // CoderAPIError is a custom error class for Coder API errors. export class CoderAPIError extends Error { constructor( From b7eadbb86db165de608629531f1525433ecaf04d Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 27 Mar 2026 10:23:45 +0000 Subject: [PATCH 3/4] fix: address review feedback on 404 handling --- dist/index.js | 20 ++++++--- src/action.test.ts | 69 ++++++++++++++++++++++++++-- src/action.ts | 10 +++-- src/coder-client.test.ts | 97 +++++++++++++++++++++++++++++++++++----- src/coder-client.ts | 22 ++++++--- 5 files changed, 190 insertions(+), 28 deletions(-) diff --git a/dist/index.js b/dist/index.js index 7162e13..e64e457 100644 --- a/dist/index.js +++ b/dist/index.js @@ -26780,16 +26780,24 @@ class RealCoderClient { async waitForTaskActive(owner, taskId, logFn, timeoutMs = 120000, stableIdleMs = 30000, pollIntervalMs = 2000) { const startTime = Date.now(); let idleSince = null; + let consecutive404s = 0; while (Date.now() - startTime < timeoutMs) { let task; try { task = await this.getTaskById(owner, taskId); } catch (error) { if (error instanceof CoderAPIError && error.statusCode === 404) { - throw new TaskDeletedError(taskId); + consecutive404s++; + if (consecutive404s >= 2) { + throw new TaskNotFoundError(taskId); + } + logFn(`waitForTaskActive: task_id: ${taskId} transient 404 (${consecutive404s}/2), will retry`); + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + continue; } throw error; } + consecutive404s = 0; if (task.status === "error") { throw new CoderAPIError(`Task entered error state while waiting for active state`, 500, task); } @@ -26871,12 +26879,12 @@ var ExperimentalCoderSDKTaskListResponseSchema = exports_external.object({ tasks: exports_external.array(ExperimentalCoderSDKTaskSchema) }); -class TaskDeletedError extends Error { +class TaskNotFoundError extends Error { taskId; constructor(taskId) { - super(`Task ${taskId} was deleted while waiting for it to become active`); + super(`Task ${taskId} returned 404 during polling`); this.taskId = taskId; - this.name = "TaskDeletedError"; + this.name = "TaskNotFoundError"; } } @@ -27009,8 +27017,8 @@ class CoderTaskAction { taskCreated: false }; } catch (error2) { - if (error2 instanceof TaskDeletedError) { - core.warning(`Existing task '${existingTask.name}' was deleted (likely by a concurrent run). Creating a new task.`); + if (error2 instanceof TaskNotFoundError || error2 instanceof CoderAPIError && error2.statusCode === 404) { + core.warning(`Lost contact with task '${existingTask.name}' (404 during polling). Creating a new task.`); } else { throw error2; } diff --git a/src/action.test.ts b/src/action.test.ts index d2f8085..b79077e 100644 --- a/src/action.test.ts +++ b/src/action.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test, beforeEach } from "bun:test"; import { CoderTaskAction } from "./action"; import type { Octokit } from "./action"; import { ActionOutputsSchema, type ActionOutputs } from "./schemas"; -import { TaskDeletedError, TaskIdSchema, TaskNameSchema } from "./coder-client"; +import { TaskNotFoundError, CoderAPIError, TaskIdSchema, TaskNameSchema } from "./coder-client"; import { MockCoderClient, createMockOctokit, @@ -697,7 +697,7 @@ describe("CoderTaskAction", () => { expect(action.run()).rejects.toThrow("Permission denied"); }); - test("creates new task when existing task is deleted during waitForTaskActive", async () => { + test("creates new task when existing task returns 404 during waitForTaskActive", async () => { const newTask = { ...mockTask, id: TaskIdSchema.parse("aa0e8400-e29b-41d4-a716-446655440000"), @@ -712,7 +712,7 @@ describe("CoderTaskAction", () => { coderClient.mockGetTemplateVersionPresets.mockResolvedValue([]); coderClient.mockGetTask.mockResolvedValue(mockTask); coderClient.mockWaitForTaskActive.mockRejectedValue( - new TaskDeletedError(mockTask.id), + new TaskNotFoundError(mockTask.id), ); coderClient.mockCreateTask.mockResolvedValue(newTask); octokit.rest.issues.listComments.mockResolvedValue({ @@ -755,6 +755,69 @@ describe("CoderTaskAction", () => { const parsedResult = ActionOutputsSchema.parse(result); expect(parsedResult.taskCreated).toBe(true); expect(parsedResult.coderUsername).toBe(mockUser.username); + expect(parsedResult.taskUrl).toContain(newTask.id); + }); + + test("falls through to create new task when sendTaskInput returns 404", async () => { + const newTask = { + ...mockTask, + id: TaskIdSchema.parse("bb0e8400-e29b-41d4-a716-446655440000"), + name: TaskNameSchema.parse("task-123"), + }; + + coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetTemplateByOrganizationAndName.mockResolvedValue(mockTemplate); + coderClient.mockGetTemplateVersionPresets.mockResolvedValue([]); + coderClient.mockGetTask.mockResolvedValue(mockTask); + coderClient.mockWaitForTaskActive.mockResolvedValue(undefined); + // sendTaskInput returns 404 — task was deleted after waitForTaskActive. + coderClient.mockSendTaskInput.mockRejectedValue( + new CoderAPIError("Not Found", 404), + ); + coderClient.mockCreateTask.mockResolvedValue(newTask); + octokit.rest.issues.listComments.mockResolvedValue({ + data: [], + } as ReturnType); + octokit.rest.issues.createComment.mockResolvedValue( + {} as ReturnType, + ); + + const inputs = createMockInputs({ githubUserID: 12345 }); + const action = new CoderTaskAction( + coderClient, + octokit as unknown as Octokit, + inputs, + ); + + const result = await action.run(); + + // Should have fallen through to create a new task. + expect(coderClient.mockCreateTask).toHaveBeenCalled(); + expect(coderClient.mockSendTaskInput).toHaveBeenCalled(); + + const parsedResult = ActionOutputsSchema.parse(result); + expect(parsedResult.taskCreated).toBe(true); + expect(parsedResult.taskUrl).toContain(newTask.id); + }); + + test("re-throws non-404 errors from waitForTaskActive", async () => { + coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetTemplateByOrganizationAndName.mockResolvedValue(mockTemplate); + coderClient.mockGetTemplateVersionPresets.mockResolvedValue([]); + coderClient.mockGetTask.mockResolvedValue(mockTask); + coderClient.mockWaitForTaskActive.mockRejectedValue( + new CoderAPIError("Request Timeout", 408), + ); + + const inputs = createMockInputs({ githubUserID: 12345 }); + const action = new CoderTaskAction( + coderClient, + octokit as unknown as Octokit, + inputs, + ); + + await expect(action.run()).rejects.toThrow("Request Timeout"); + expect(coderClient.mockCreateTask).not.toHaveBeenCalled(); }); }); diff --git a/src/action.ts b/src/action.ts index 9850a8a..e77a3d6 100644 --- a/src/action.ts +++ b/src/action.ts @@ -2,7 +2,8 @@ import * as core from "@actions/core"; import { type ExperimentalCoderSDKCreateTaskRequest, TaskNameSchema, - TaskDeletedError, + TaskNotFoundError, + CoderAPIError, type CoderClient, type TaskId, } from "./coder-client"; @@ -204,9 +205,12 @@ export class CoderTaskAction { taskCreated: false, }; } catch (error) { - if (error instanceof TaskDeletedError) { + if ( + error instanceof TaskNotFoundError || + (error instanceof CoderAPIError && error.statusCode === 404) + ) { core.warning( - `Existing task '${existingTask.name}' was deleted (likely by a concurrent run). Creating a new task.`, + `Lost contact with task '${existingTask.name}' (404 during polling). Creating a new task.`, ); // Fall through to task creation below. } else { diff --git a/src/coder-client.test.ts b/src/coder-client.test.ts index 8508a43..36045dd 100644 --- a/src/coder-client.test.ts +++ b/src/coder-client.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test, beforeEach, mock } from "bun:test"; import { RealCoderClient, CoderAPIError, - TaskDeletedError, + TaskNotFoundError, type ExperimentalCoderSDKTask, } from "./coder-client"; import { @@ -488,16 +488,61 @@ describe("CoderClient", () => { ).rejects.toThrow("Timeout waiting for task to reach active state"); }); - test("throws TaskDeletedError when task is deleted during polling (404)", async () => { + test("tolerates a single transient 404 and continues polling", async () => { const pendingTask: ExperimentalCoderSDKTask = { ...mockTask, status: "pending", }; + const readyTask: ExperimentalCoderSDKTask = { + ...mockTask, + status: "active", + current_state: { state: "idle" }, + }; + + // Poll 1: pending (ok). Poll 2: transient 404. Poll 3: active+idle (ok). + mockFetch + .mockResolvedValueOnce(createMockResponse(pendingTask)) + .mockResolvedValueOnce( + createMockResponse( + { message: "Not Found" }, + { ok: false, status: 404, statusText: "Not Found" }, + ), + ) + .mockResolvedValueOnce(createMockResponse(readyTask)); + + // Should resolve successfully — one 404 is tolerated. + await expect( + client.waitForTaskActive( + mockUser.username, + mockTask.id, + console.log, + 10000, + 0, + 10, + ), + ).resolves.toBeUndefined(); - // First poll succeeds (task exists), second poll returns 404 - // (task was deleted by concurrent run). + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + + test("throws TaskNotFoundError when task returns 404 during polling", async () => { + const pendingTask: ExperimentalCoderSDKTask = { + ...mockTask, + status: "pending", + }; + + // First poll succeeds, then two consecutive 404s trigger the error. mockFetch .mockResolvedValueOnce(createMockResponse(pendingTask)) + .mockResolvedValueOnce( + createMockResponse( + { + message: + "Resource not found or you do not have access to this resource", + }, + { ok: false, status: 404, statusText: "Not Found" }, + ), + ) .mockResolvedValueOnce( createMockResponse( { @@ -519,14 +564,46 @@ describe("CoderClient", () => { ) .catch((e: unknown) => e); - expect(err).toBeInstanceOf(TaskDeletedError); - expect((err as TaskDeletedError).message).toBe( - `Task ${mockTask.id} was deleted while waiting for it to become active`, + expect(err).toBeInstanceOf(TaskNotFoundError); + expect((err as TaskNotFoundError).message).toBe( + `Task ${mockTask.id} returned 404 during polling`, ); - expect((err as TaskDeletedError).taskId).toBe(mockTask.id); + expect((err as TaskNotFoundError).taskId).toBe(mockTask.id); + + // Should have polled three times: first success, then two 404s. + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + + test("propagates non-404 errors from getTaskById during polling", async () => { + const pendingTask: ExperimentalCoderSDKTask = { + ...mockTask, + status: "pending", + }; + + // First poll succeeds, second poll returns 500. + mockFetch + .mockResolvedValueOnce(createMockResponse(pendingTask)) + .mockResolvedValueOnce( + createMockResponse( + { message: "Internal Server Error" }, + { ok: false, status: 500, statusText: "Internal Server Error" }, + ), + ); + + const err = await client + .waitForTaskActive( + mockUser.username, + mockTask.id, + console.log, + 10000, + 0, + 10, + ) + .catch((e: unknown) => e); - // Should have polled twice: first success, then 404. - expect(mockFetch).toHaveBeenCalledTimes(2); + expect(err).toBeInstanceOf(CoderAPIError); + expect(err).not.toBeInstanceOf(TaskNotFoundError); + expect((err as CoderAPIError).statusCode).toBe(500); }); }); diff --git a/src/coder-client.ts b/src/coder-client.ts index b2b7d51..b7d94c6 100644 --- a/src/coder-client.ts +++ b/src/coder-client.ts @@ -227,6 +227,7 @@ export class RealCoderClient implements CoderClient { ): Promise { const startTime = Date.now(); let idleSince: number | null = null; + let consecutive404s = 0; while (Date.now() - startTime < timeoutMs) { let task: ExperimentalCoderSDKTask; @@ -234,11 +235,19 @@ export class RealCoderClient implements CoderClient { task = await this.getTaskById(owner, taskId); } catch (error) { if (error instanceof CoderAPIError && error.statusCode === 404) { - throw new TaskDeletedError(taskId); + consecutive404s++; + if (consecutive404s >= 2) { + throw new TaskNotFoundError(taskId); + } + logFn(`waitForTaskActive: task_id: ${taskId} transient 404 (${consecutive404s}/2), will retry`); + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + continue; } throw error; } + consecutive404s = 0; + if (task.status === "error") { throw new CoderAPIError( `Task entered error state while waiting for active state`, @@ -395,12 +404,13 @@ export type ExperimentalCoderSDKTaskListResponse = z.infer< typeof ExperimentalCoderSDKTaskListResponseSchema >; -// TaskDeletedError is thrown when a task is deleted while waiting -// for it to become active (e.g. by a concurrent workflow run). -export class TaskDeletedError extends Error { +// TaskNotFoundError is thrown when a task returns 404 during +// polling in waitForTaskActive (e.g. deleted by a concurrent +// workflow run, or lost due to a permissions change). +export class TaskNotFoundError extends Error { constructor(public readonly taskId: TaskId) { - super(`Task ${taskId} was deleted while waiting for it to become active`); - this.name = "TaskDeletedError"; + super(`Task ${taskId} returned 404 during polling`); + this.name = "TaskNotFoundError"; } } From a9121b316516082e1ef8c095e42712085520a8d1 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 27 Mar 2026 10:28:27 +0000 Subject: [PATCH 4/4] style: apply biome formatting --- src/action.test.ts | 15 ++++++++++++--- src/coder-client.ts | 4 +++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/action.test.ts b/src/action.test.ts index b79077e..90cdb49 100644 --- a/src/action.test.ts +++ b/src/action.test.ts @@ -2,7 +2,12 @@ import { describe, expect, test, beforeEach } from "bun:test"; import { CoderTaskAction } from "./action"; import type { Octokit } from "./action"; import { ActionOutputsSchema, type ActionOutputs } from "./schemas"; -import { TaskNotFoundError, CoderAPIError, TaskIdSchema, TaskNameSchema } from "./coder-client"; +import { + TaskNotFoundError, + CoderAPIError, + TaskIdSchema, + TaskNameSchema, +} from "./coder-client"; import { MockCoderClient, createMockOctokit, @@ -766,7 +771,9 @@ describe("CoderTaskAction", () => { }; coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); - coderClient.mockGetTemplateByOrganizationAndName.mockResolvedValue(mockTemplate); + coderClient.mockGetTemplateByOrganizationAndName.mockResolvedValue( + mockTemplate, + ); coderClient.mockGetTemplateVersionPresets.mockResolvedValue([]); coderClient.mockGetTask.mockResolvedValue(mockTask); coderClient.mockWaitForTaskActive.mockResolvedValue(undefined); @@ -802,7 +809,9 @@ describe("CoderTaskAction", () => { test("re-throws non-404 errors from waitForTaskActive", async () => { coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); - coderClient.mockGetTemplateByOrganizationAndName.mockResolvedValue(mockTemplate); + coderClient.mockGetTemplateByOrganizationAndName.mockResolvedValue( + mockTemplate, + ); coderClient.mockGetTemplateVersionPresets.mockResolvedValue([]); coderClient.mockGetTask.mockResolvedValue(mockTask); coderClient.mockWaitForTaskActive.mockRejectedValue( diff --git a/src/coder-client.ts b/src/coder-client.ts index b7d94c6..0f87e33 100644 --- a/src/coder-client.ts +++ b/src/coder-client.ts @@ -239,7 +239,9 @@ export class RealCoderClient implements CoderClient { if (consecutive404s >= 2) { throw new TaskNotFoundError(taskId); } - logFn(`waitForTaskActive: task_id: ${taskId} transient 404 (${consecutive404s}/2), will retry`); + logFn( + `waitForTaskActive: task_id: ${taskId} transient 404 (${consecutive404s}/2), will retry`, + ); await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); continue; }