diff --git a/dist/index.js b/dist/index.js index 9d0d742..e64e457 100644 --- a/dist/index.js +++ b/dist/index.js @@ -26780,8 +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) { - 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) { + 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); } @@ -26863,6 +26879,15 @@ var ExperimentalCoderSDKTaskListResponseSchema = exports_external.object({ tasks: exports_external.array(ExperimentalCoderSDKTaskSchema) }); +class TaskNotFoundError extends Error { + taskId; + constructor(taskId) { + super(`Task ${taskId} returned 404 during polling`); + this.taskId = taskId; + this.name = "TaskNotFoundError"; + } +} + class CoderAPIError extends Error { statusCode; response; @@ -26979,17 +27004,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 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; + } + } } core.info("Creating Coder task..."); const req = { diff --git a/src/action.test.ts b/src/action.test.ts index bec3c7c..90cdb49 100644 --- a/src/action.test.ts +++ b/src/action.test.ts @@ -2,6 +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 { MockCoderClient, createMockOctokit, @@ -695,6 +701,133 @@ describe("CoderTaskAction", () => { expect(action.run()).rejects.toThrow("Permission denied"); }); + + test("creates new task when existing task returns 404 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 TaskNotFoundError(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); + 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(); + }); }); // 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..e77a3d6 100644 --- a/src/action.ts +++ b/src/action.ts @@ -2,6 +2,8 @@ import * as core from "@actions/core"; import { type ExperimentalCoderSDKCreateTaskRequest, TaskNameSchema, + TaskNotFoundError, + CoderAPIError, type CoderClient, type TaskId, } from "./coder-client"; @@ -173,34 +175,48 @@ 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 TaskNotFoundError || + (error instanceof CoderAPIError && error.statusCode === 404) + ) { + core.warning( + `Lost contact with task '${existingTask.name}' (404 during polling). 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..36045dd 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, + TaskNotFoundError, type ExperimentalCoderSDKTask, } from "./coder-client"; import { @@ -486,6 +487,124 @@ describe("CoderClient", () => { ), ).rejects.toThrow("Timeout waiting for task to reach active state"); }); + + 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(); + + 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( + { + 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(TaskNotFoundError); + expect((err as TaskNotFoundError).message).toBe( + `Task ${mockTask.id} returned 404 during polling`, + ); + 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); + + expect(err).toBeInstanceOf(CoderAPIError); + expect(err).not.toBeInstanceOf(TaskNotFoundError); + expect((err as CoderAPIError).statusCode).toBe(500); + }); }); describe("sendTaskInput", () => { diff --git a/src/coder-client.ts b/src/coder-client.ts index 22ad29f..0f87e33 100644 --- a/src/coder-client.ts +++ b/src/coder-client.ts @@ -227,9 +227,28 @@ export class RealCoderClient implements CoderClient { ): Promise { const startTime = Date.now(); let idleSince: number | null = null; + let consecutive404s = 0; 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) { + 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( @@ -387,6 +406,16 @@ export type ExperimentalCoderSDKTaskListResponse = z.infer< typeof ExperimentalCoderSDKTaskListResponseSchema >; +// 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} returned 404 during polling`); + this.name = "TaskNotFoundError"; + } +} + // CoderAPIError is a custom error class for Coder API errors. export class CoderAPIError extends Error { constructor(