Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 45 additions & 12 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 = {
Expand Down
133 changes: 133 additions & 0 deletions src/action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<typeof octokit.rest.issues.listComments>);
octokit.rest.issues.createComment.mockResolvedValue(
{} as ReturnType<typeof octokit.rest.issues.createComment>,
);

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<typeof octokit.rest.issues.listComments>);
octokit.rest.issues.createComment.mockResolvedValue(
{} as ReturnType<typeof octokit.rest.issues.createComment>,
);

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
Expand Down
70 changes: 43 additions & 27 deletions src/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import * as core from "@actions/core";
import {
type ExperimentalCoderSDKCreateTaskRequest,
TaskNameSchema,
TaskNotFoundError,
CoderAPIError,
type CoderClient,
type TaskId,
} from "./coder-client";
Expand Down Expand Up @@ -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...");

Expand Down
Loading
Loading