Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/hard-timeout-and-dispose.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pungrumpy/cursor-action": patch
---

Treat `timeout` as a hard deadline: after requesting cancellation the action waits at most ten seconds for the run to stop, then fails the step as `cancelled` instead of waiting indefinitely. The agent is now released (`Symbol.asyncDispose`) when the run ends on every path, bounded by its own fifteen-second limit.
60 changes: 60 additions & 0 deletions __tests__/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ mock.module("@actions/core", () => ({
const mockAgentCreate = mock();
const mockAgentSend = mock();
const mockRunCancel = mock(() => Promise.resolve());
const mockDispose = mock(() => Promise.resolve());

mock.module("@cursor/sdk", () => ({
Agent: {
Expand Down Expand Up @@ -85,11 +86,22 @@ const finishedWait = (result: string) => () =>
status: "finished" as const,
});

/** A promise that never settles, for runs that ignore cancellation. */
const pending = <T = never>(): Promise<T> => Promise.withResolvers<T>().promise;

const neverEndingStream = () => ({
[Symbol.asyncIterator]: () => ({
next: () => pending<IteratorResult<unknown>>(),
}),
});

describe("runAgent", () => {
beforeEach(() => {
mock.clearAllMocks();
mockDispose.mockImplementation(() => Promise.resolve());

mockAgentCreate.mockResolvedValue({
[Symbol.asyncDispose]: mockDispose,
send: mockAgentSend,
});

Expand Down Expand Up @@ -290,4 +302,52 @@ describe("runAgent", () => {
expect(result.stdout).toBe("final answer");
expect(result.exitCode).toBe(0);
});

it("stops waiting when the run ignores the cancel request", async () => {
mockAgentSend.mockResolvedValue({
cancel: mockRunCancel,
stream: neverEndingStream,
supports: (op: string) => op === "cancel",
wait: () => pending(),
});

const startedAt = Date.now();
const result = await runAgent(
{ ...baseInputs, timeout: 1 },
{ cancelGraceMs: 200, disposeTimeoutMs: 200 }
);

expect(Date.now() - startedAt).toBeLessThan(3000);
expect(mockRunCancel).toHaveBeenCalled();
expect(result.exitCode).toBe(1);
expect(result.status).toBe("cancelled");
expect(result.stderr).toContain("timed out");
expect(mockDispose).toHaveBeenCalledTimes(1);
}, 5000);

it("releases the agent after a successful run", async () => {
await runAgent(baseInputs);
expect(mockDispose).toHaveBeenCalledTimes(1);
});

it("releases the agent when send throws", async () => {
mockAgentSend.mockRejectedValue(new Error("send exploded"));

const result = await runAgent(baseInputs);

expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("send exploded");
expect(mockDispose).toHaveBeenCalledTimes(1);
});

it("returns even when releasing the agent hangs", async () => {
mockDispose.mockImplementation(() => pending());

const result = await runAgent(baseInputs, { disposeTimeoutMs: 100 });

expect(result.exitCode).toBe(0);
expect(mockWarning).toHaveBeenCalledWith(
expect.stringContaining("did not release")
);
});
});
93 changes: 72 additions & 21 deletions dist/index.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { endGroup, getInput, info, setFailed, setOutput, setSecret, startGroup, summary, warning } from "@actions/core";
import path from "node:path";
import { setTimeout } from "node:timers/promises";
import { Agent } from "@cursor/sdk";
//#region src/input.ts
const VALID_PERMISSIONS = [
Expand Down Expand Up @@ -95,10 +96,40 @@ const setOutputs = async (result) => {
const maskSecret = (apiKey) => setSecret(apiKey);
//#endregion
//#region src/runner.ts
/** Longest delay Node's timers accept; larger values would fire immediately. */
const MAX_TIMER_MS = 2147483647;
/** How long to wait for the SDK to settle after a cancel request. */
const DEFAULT_CANCEL_GRACE_MS = 1e4;
/** How long to wait for the agent to release its local executor. */
const DEFAULT_DISPOSE_TIMEOUT_MS = 15e3;
const extractErrorMessage = (error) => {
if (error instanceof Error) return error.cause ? `${error.message}\nCause: ${error.cause}` : error.message;
return String(error);
};
/** Resolves to `undefined` when `promise` has not settled within `ms`. */
const withTimeout = async (promise, ms) => {
const controller = new AbortController();
try {
return await Promise.race([promise, setTimeout(ms, void 0, { signal: controller.signal })]);
} finally {
controller.abort();
}
};
/** Resolves to `undefined` instead of rejecting. */
const quietly = async (promise) => {
try {
return await promise;
} catch {
return;
}
};
const disposeAgent = async (agent, timeoutMs) => {
try {
if (await withTimeout(agent[Symbol.asyncDispose]().then(() => true), timeoutMs) === void 0) warning(`The agent did not release its resources within ${timeoutMs}ms.`);
} catch (error) {
warning(`Failed to release the agent: ${extractErrorMessage(error)}`);
}
};
/**
* The SDK streams the answer as `assistant` events, one text delta per event.
* `thinking` and `task` events also carry a `text` field, but that is the
Expand All @@ -121,8 +152,30 @@ const mapUsage = (usage) => {
totalTokens
};
};
const runAgent = async (inputs) => {
/**
* Waits for `consume` until the `timeoutSeconds` deadline, then asks the SDK to
* cancel and waits at most `cancelGraceMs` for the run to stop.
*/
const settleRun = async (run, consume, timeoutSeconds, cancelGraceMs) => {
const settled = quietly(consume);
const timeoutMs = timeoutSeconds * 1e3;
const result = timeoutMs > 0 && timeoutMs <= MAX_TIMER_MS ? await withTimeout(consume, timeoutMs) : await consume;
if (result !== void 0) return {
result,
timedOut: false
};
if (run.supports("cancel")) try {
await withTimeout(run.cancel(), cancelGraceMs);
} catch {}
return {
result: await withTimeout(settled, cancelGraceMs),
timedOut: true
};
};
const runAgent = async (inputs, options = {}) => {
const cwd = path.resolve(inputs.workingDirectory);
const cancelGraceMs = options.cancelGraceMs ?? DEFAULT_CANCEL_GRACE_MS;
const disposeTimeoutMs = options.disposeTimeoutMs ?? DEFAULT_DISPOSE_TIMEOUT_MS;
info(`Running Cursor Agent in: ${cwd}`);
info(`Model: ${inputs.model}`);
if (inputs.permissions !== "read-only") warning("The `permissions` input is not passed to Cursor SDK Agent.create; tool access follows your API key / account, not this field.");
Expand All @@ -132,30 +185,28 @@ const runAgent = async (inputs) => {
let status = "finished";
let durationMs;
let usage;
let agent;
try {
const run = await (await Agent.create({
agent = await Agent.create({
apiKey: inputs.apiKey,
local: { cwd },
model: { id: inputs.model }
})).send(inputs.prompt);
const timeoutMs = inputs.timeout * 1e3;
let cancelTimer;
let timedOut = false;
if (timeoutMs > 0 && Number.isFinite(timeoutMs)) cancelTimer = setTimeout(() => {
timedOut = true;
(async () => {
if (run.supports("cancel")) try {
await run.cancel();
} catch {}
})();
}, timeoutMs);
try {
});
const run = await agent.send(inputs.prompt);
const { result: runResult, timedOut } = await settleRun(run, (async () => {
for await (const event of run.stream()) stdout += assistantText(event);
const runResult = await run.wait();
({durationMs} = runResult);
return await run.wait();
})(), inputs.timeout, cancelGraceMs);
if (runResult === void 0) {
exitCode = 1;
status = "cancelled";
const msg = `Agent run timed out after ${inputs.timeout}s and did not stop within ${cancelGraceMs}ms of the cancel request.`;
stderr += stderr ? `\n${msg}` : msg;
warning(msg);
} else {
({durationMs, status} = runResult);
usage = mapUsage(runResult.usage);
if (runResult.result && typeof runResult.result === "string") stdout = runResult.result;
({status} = runResult);
if (status === "error") {
exitCode = 1;
const msg = runResult.error?.message ?? "Agent run failed with error.";
Expand All @@ -166,15 +217,15 @@ const runAgent = async (inputs) => {
const msg = timedOut ? `Agent run timed out after ${inputs.timeout}s and was cancelled.` : "Agent run was cancelled.";
stderr += stderr ? `\n${msg}` : msg;
warning(msg);
}
} finally {
if (cancelTimer !== void 0) clearTimeout(cancelTimer);
} else if (timedOut) warning(`Agent run exceeded the ${inputs.timeout}s timeout but finished while the cancel request was pending.`);
}
} catch (error) {
exitCode = 1;
status = "error";
stderr += extractErrorMessage(error);
warning(`Agent execution failed: ${stderr}`);
} finally {
if (agent !== void 0) await disposeAgent(agent, disposeTimeoutMs);
}
return {
diagnostics: exitCode === 0 ? void 0 : stderr,
Expand Down
3 changes: 2 additions & 1 deletion docs/content/behaviour.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ description: What the action does at runtime and how it reports results.
3. A local SDK agent is created against the resolved `working-directory` (`Agent.create({ local: { cwd } })`) and the prompt is sent.
4. Streamed text is collected, then replaced by the final run result when the SDK provides one. `summary` is that text verbatim, with surrounding whitespace trimmed; a JSON-shaped answer is not unwrapped.
5. A job summary is written with the status, exit code, agent response, duration, token usage, and any stderr or diagnostics.
6. The agent is released (`Symbol.asyncDispose`), which shuts down the local executor the SDK started for the run.

## Timeouts and failures

When `timeout` elapses, the action requests a run cancellation. Cancelled runs exit with code `1`. The step fails and reports the cancellation in the job summary.
When `timeout` elapses, the action requests a run cancellation and waits at most ten seconds for the run to stop. A run that stops, or never answers, is reported as `cancelled`, the step exits with code `1`, and the job summary records the cancellation. A run that finishes during those ten seconds keeps its result and exit code `0`, with a warning that it overran the timeout.

The same applies when a run ends with an error. The action reads `status` and `error` from `run.wait()`, sets exit code `1`, and writes the error details to stderr.

Expand Down
2 changes: 1 addition & 1 deletion docs/content/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Start from `default`. The SDK rejects `auto`, and the error lists the ids your a

## The step timed out or failed

Check the job summary. When a run reports `cancelled` (including timeout-triggered cancellation) or `error`, the action exits with code `1`. If an agent does not support cancellation, it may finish with exit code `0`. You can increase `timeout` if the agent needs more time to complete.
Check the job summary. When a run reports `cancelled` (including timeout-triggered cancellation) or `error`, the action exits with code `1`. When `timeout` elapses the action asks the SDK to cancel the run and waits up to ten seconds for it to stop; if it does not, the step still fails as `cancelled`. Increase `timeout` if the agent needs more time to complete.

## `Cannot find module`

Expand Down
Loading
Loading