Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 44 additions & 37 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
};

Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 = {};
Expand Down Expand Up @@ -26799,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);
}
Expand Down Expand Up @@ -26881,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;
Expand Down Expand Up @@ -26997,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 = {
Expand Down
61 changes: 61 additions & 0 deletions src/action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<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();

Comment thread
johnstcn marked this conversation as resolved.
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
Expand Down
66 changes: 39 additions & 27 deletions src/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as core from "@actions/core";
import {
type ExperimentalCoderSDKCreateTaskRequest,
TaskNameSchema,
TaskDeletedError,
type CoderClient,
type TaskId,
} from "./coder-client";
Expand Down Expand Up @@ -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,
Comment thread
johnstcn marked this conversation as resolved.
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(
Comment thread
johnstcn marked this conversation as resolved.
`Existing task '${existingTask.name}' was deleted (likely by a concurrent run). Creating a new task.`,
Comment thread
johnstcn marked this conversation as resolved.
Outdated
);
// Fall through to task creation below.
} else {
throw error;
}
}
}
core.info("Creating Coder task...");

Expand Down
42 changes: 42 additions & 0 deletions src/coder-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, test, beforeEach, mock } from "bun:test";
import {
RealCoderClient,
CoderAPIError,
TaskDeletedError,
type ExperimentalCoderSDKTask,
} from "./coder-client";
import {
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading
Loading