Skip to content

Commit cc40f97

Browse files
committed
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
1 parent e53fdfa commit cc40f97

5 files changed

Lines changed: 197 additions & 40 deletions

File tree

dist/index.js

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26781,7 +26781,15 @@ class RealCoderClient {
2678126781
const startTime = Date.now();
2678226782
let idleSince = null;
2678326783
while (Date.now() - startTime < timeoutMs) {
26784-
const task = await this.getTaskById(owner, taskId);
26784+
let task;
26785+
try {
26786+
task = await this.getTaskById(owner, taskId);
26787+
} catch (error) {
26788+
if (error instanceof CoderAPIError && error.statusCode === 404) {
26789+
throw new TaskDeletedError(taskId);
26790+
}
26791+
throw error;
26792+
}
2678526793
if (task.status === "error") {
2678626794
throw new CoderAPIError(`Task entered error state while waiting for active state`, 500, task);
2678726795
}
@@ -26863,6 +26871,15 @@ var ExperimentalCoderSDKTaskListResponseSchema = exports_external.object({
2686326871
tasks: exports_external.array(ExperimentalCoderSDKTaskSchema)
2686426872
});
2686526873

26874+
class TaskDeletedError extends Error {
26875+
taskId;
26876+
constructor(taskId) {
26877+
super(`Task ${taskId} was deleted while waiting for it to become active`);
26878+
this.taskId = taskId;
26879+
this.name = "TaskDeletedError";
26880+
}
26881+
}
26882+
2686626883
class CoderAPIError extends Error {
2686726884
statusCode;
2686826885
response;
@@ -26979,17 +26996,25 @@ class CoderTaskAction {
2697926996
const existingTask = await this.coder.getTask(coderUsername, taskName);
2698026997
if (existingTask) {
2698126998
core.info(`Coder Task: already exists: ${existingTask.name} (id: ${existingTask.id} status: ${existingTask.status})`);
26982-
core.info(`Coder Task: waiting for task ${existingTask.name} to become active and idle...`);
26983-
await this.coder.waitForTaskActive(coderUsername, existingTask.id, core.debug, 1200000);
26984-
core.info("Coder Task: Sending prompt to existing task...");
26985-
await this.coder.sendTaskInput(coderUsername, existingTask.id, this.inputs.coderTaskPrompt);
26986-
core.info("Coder Task: Prompt sent successfully");
26987-
return {
26988-
coderUsername,
26989-
taskName: existingTask.name,
26990-
taskUrl: this.generateTaskUrl(coderUsername, existingTask.id),
26991-
taskCreated: false
26992-
};
26999+
try {
27000+
core.info(`Coder Task: waiting for task ${existingTask.name} to become active and idle...`);
27001+
await this.coder.waitForTaskActive(coderUsername, existingTask.id, core.debug, 1200000);
27002+
core.info("Coder Task: Sending prompt to existing task...");
27003+
await this.coder.sendTaskInput(coderUsername, existingTask.id, this.inputs.coderTaskPrompt);
27004+
core.info("Coder Task: Prompt sent successfully");
27005+
return {
27006+
coderUsername,
27007+
taskName: existingTask.name,
27008+
taskUrl: this.generateTaskUrl(coderUsername, existingTask.id),
27009+
taskCreated: false
27010+
};
27011+
} catch (error2) {
27012+
if (error2 instanceof TaskDeletedError) {
27013+
core.warning(`Existing task '${existingTask.name}' was deleted (likely by a concurrent run). Creating a new task.`);
27014+
} else {
27015+
throw error2;
27016+
}
27017+
}
2699327018
}
2699427019
core.info("Creating Coder task...");
2699527020
const req = {

src/action.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, test, beforeEach } from "bun:test";
22
import { CoderTaskAction } from "./action";
33
import type { Octokit } from "./action";
44
import { ActionOutputsSchema, type ActionOutputs } from "./schemas";
5+
import { TaskDeletedError, TaskIdSchema, TaskNameSchema } from "./coder-client";
56
import {
67
MockCoderClient,
78
createMockOctokit,
@@ -695,6 +696,66 @@ describe("CoderTaskAction", () => {
695696

696697
expect(action.run()).rejects.toThrow("Permission denied");
697698
});
699+
700+
test("creates new task when existing task is deleted during waitForTaskActive", async () => {
701+
const newTask = {
702+
...mockTask,
703+
id: TaskIdSchema.parse("aa0e8400-e29b-41d4-a716-446655440000"),
704+
name: TaskNameSchema.parse("task-123"),
705+
};
706+
707+
// Setup: existing task found, but waitForTaskActive throws TaskDeletedError
708+
coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser);
709+
coderClient.mockGetTemplateByOrganizationAndName.mockResolvedValue(
710+
mockTemplate,
711+
);
712+
coderClient.mockGetTemplateVersionPresets.mockResolvedValue([]);
713+
coderClient.mockGetTask.mockResolvedValue(mockTask);
714+
coderClient.mockWaitForTaskActive.mockRejectedValue(
715+
new TaskDeletedError(mockTask.id),
716+
);
717+
coderClient.mockCreateTask.mockResolvedValue(newTask);
718+
octokit.rest.issues.listComments.mockResolvedValue({
719+
data: [],
720+
} as ReturnType<typeof octokit.rest.issues.listComments>);
721+
octokit.rest.issues.createComment.mockResolvedValue(
722+
{} as ReturnType<typeof octokit.rest.issues.createComment>,
723+
);
724+
725+
const inputs = createMockInputs({
726+
githubUserID: 12345,
727+
});
728+
const action = new CoderTaskAction(
729+
coderClient,
730+
octokit as unknown as Octokit,
731+
inputs,
732+
);
733+
734+
// Execute
735+
const result = await action.run();
736+
737+
// Verify: should have fallen through to create a new task
738+
expect(coderClient.mockGetTask).toHaveBeenCalledWith(
739+
mockUser.username,
740+
mockTask.name,
741+
);
742+
expect(coderClient.mockWaitForTaskActive).toHaveBeenCalled();
743+
expect(coderClient.mockCreateTask).toHaveBeenCalledWith(
744+
mockUser.username,
745+
{
746+
name: mockTask.name,
747+
template_version_id: mockTemplate.active_version_id,
748+
template_version_preset_id: undefined,
749+
input: inputs.coderTaskPrompt,
750+
},
751+
);
752+
// Should NOT have tried to send input to the deleted task
753+
expect(coderClient.mockSendTaskInput).not.toHaveBeenCalled();
754+
755+
const parsedResult = ActionOutputsSchema.parse(result);
756+
expect(parsedResult.taskCreated).toBe(true);
757+
expect(parsedResult.coderUsername).toBe(mockUser.username);
758+
});
698759
});
699760

700761
// NOTE: this may or may not work in the real world depending on the permissions of the user

src/action.ts

Lines changed: 39 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as core from "@actions/core";
22
import {
33
type ExperimentalCoderSDKCreateTaskRequest,
44
TaskNameSchema,
5+
TaskDeletedError,
56
type CoderClient,
67
type TaskId,
78
} from "./coder-client";
@@ -173,34 +174,45 @@ export class CoderTaskAction {
173174
`Coder Task: already exists: ${existingTask.name} (id: ${existingTask.id} status: ${existingTask.status})`,
174175
);
175176

176-
// Wait for task to become active and idle before sending
177-
// input. The agent may be in "working" state even when the
178-
// task status is "active", and sending input in that state
179-
// causes 409/502 errors.
180-
core.info(
181-
`Coder Task: waiting for task ${existingTask.name} to become active and idle...`,
182-
);
183-
await this.coder.waitForTaskActive(
184-
coderUsername,
185-
existingTask.id,
186-
core.debug,
187-
1_200_000,
188-
);
177+
try {
178+
// Wait for task to become active and idle before sending
179+
// input. The agent may be in "working" state even when
180+
// the task status is "active", and sending input in that
181+
// state causes 409/502 errors.
182+
core.info(
183+
`Coder Task: waiting for task ${existingTask.name} to become active and idle...`,
184+
);
185+
await this.coder.waitForTaskActive(
186+
coderUsername,
187+
existingTask.id,
188+
core.debug,
189+
1_200_000,
190+
);
189191

190-
core.info("Coder Task: Sending prompt to existing task...");
191-
// Send prompt to existing task using the task ID (UUID)
192-
await this.coder.sendTaskInput(
193-
coderUsername,
194-
existingTask.id,
195-
this.inputs.coderTaskPrompt,
196-
);
197-
core.info("Coder Task: Prompt sent successfully");
198-
return {
199-
coderUsername,
200-
taskName: existingTask.name,
201-
taskUrl: this.generateTaskUrl(coderUsername, existingTask.id),
202-
taskCreated: false,
203-
};
192+
core.info("Coder Task: Sending prompt to existing task...");
193+
// Send prompt to existing task using the task ID (UUID)
194+
await this.coder.sendTaskInput(
195+
coderUsername,
196+
existingTask.id,
197+
this.inputs.coderTaskPrompt,
198+
);
199+
core.info("Coder Task: Prompt sent successfully");
200+
return {
201+
coderUsername,
202+
taskName: existingTask.name,
203+
taskUrl: this.generateTaskUrl(coderUsername, existingTask.id),
204+
taskCreated: false,
205+
};
206+
} catch (error) {
207+
if (error instanceof TaskDeletedError) {
208+
core.warning(
209+
`Existing task '${existingTask.name}' was deleted (likely by a concurrent run). Creating a new task.`,
210+
);
211+
// Fall through to task creation below.
212+
} else {
213+
throw error;
214+
}
215+
}
204216
}
205217
core.info("Creating Coder task...");
206218

src/coder-client.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, test, beforeEach, mock } from "bun:test";
22
import {
33
RealCoderClient,
44
CoderAPIError,
5+
TaskDeletedError,
56
type ExperimentalCoderSDKTask,
67
} from "./coder-client";
78
import {
@@ -486,6 +487,47 @@ describe("CoderClient", () => {
486487
),
487488
).rejects.toThrow("Timeout waiting for task to reach active state");
488489
});
490+
491+
test("throws TaskDeletedError when task is deleted during polling (404)", async () => {
492+
const pendingTask: ExperimentalCoderSDKTask = {
493+
...mockTask,
494+
status: "pending",
495+
};
496+
497+
// First poll succeeds (task exists), second poll returns 404
498+
// (task was deleted by concurrent run).
499+
mockFetch
500+
.mockResolvedValueOnce(createMockResponse(pendingTask))
501+
.mockResolvedValueOnce(
502+
createMockResponse(
503+
{
504+
message:
505+
"Resource not found or you do not have access to this resource",
506+
},
507+
{ ok: false, status: 404, statusText: "Not Found" },
508+
),
509+
);
510+
511+
const err = await client
512+
.waitForTaskActive(
513+
mockUser.username,
514+
mockTask.id,
515+
console.log,
516+
10000,
517+
0,
518+
10,
519+
)
520+
.catch((e: unknown) => e);
521+
522+
expect(err).toBeInstanceOf(TaskDeletedError);
523+
expect((err as TaskDeletedError).message).toBe(
524+
`Task ${mockTask.id} was deleted while waiting for it to become active`,
525+
);
526+
expect((err as TaskDeletedError).taskId).toBe(mockTask.id);
527+
528+
// Should have polled twice: first success, then 404.
529+
expect(mockFetch).toHaveBeenCalledTimes(2);
530+
});
489531
});
490532

491533
describe("sendTaskInput", () => {

src/coder-client.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,15 @@ export class RealCoderClient implements CoderClient {
229229
let idleSince: number | null = null;
230230

231231
while (Date.now() - startTime < timeoutMs) {
232-
const task = await this.getTaskById(owner, taskId);
232+
let task: ExperimentalCoderSDKTask;
233+
try {
234+
task = await this.getTaskById(owner, taskId);
235+
} catch (error) {
236+
if (error instanceof CoderAPIError && error.statusCode === 404) {
237+
throw new TaskDeletedError(taskId);
238+
}
239+
throw error;
240+
}
233241

234242
if (task.status === "error") {
235243
throw new CoderAPIError(
@@ -387,6 +395,15 @@ export type ExperimentalCoderSDKTaskListResponse = z.infer<
387395
typeof ExperimentalCoderSDKTaskListResponseSchema
388396
>;
389397

398+
// TaskDeletedError is thrown when a task is deleted while waiting
399+
// for it to become active (e.g. by a concurrent workflow run).
400+
export class TaskDeletedError extends Error {
401+
constructor(public readonly taskId: TaskId) {
402+
super(`Task ${taskId} was deleted while waiting for it to become active`);
403+
this.name = "TaskDeletedError";
404+
}
405+
}
406+
390407
// CoderAPIError is a custom error class for Coder API errors.
391408
export class CoderAPIError extends Error {
392409
constructor(

0 commit comments

Comments
 (0)