diff --git a/.changeset/hard-timeout-and-dispose.md b/.changeset/hard-timeout-and-dispose.md new file mode 100644 index 0000000..4df564b --- /dev/null +++ b/.changeset/hard-timeout-and-dispose.md @@ -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. diff --git a/__tests__/runner.test.ts b/__tests__/runner.test.ts index 286e1a4..08d33a6 100644 --- a/__tests__/runner.test.ts +++ b/__tests__/runner.test.ts @@ -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: { @@ -85,11 +86,22 @@ const finishedWait = (result: string) => () => status: "finished" as const, }); +/** A promise that never settles, for runs that ignore cancellation. */ +const pending = (): Promise => Promise.withResolvers().promise; + +const neverEndingStream = () => ({ + [Symbol.asyncIterator]: () => ({ + next: () => pending>(), + }), +}); + describe("runAgent", () => { beforeEach(() => { mock.clearAllMocks(); + mockDispose.mockImplementation(() => Promise.resolve()); mockAgentCreate.mockResolvedValue({ + [Symbol.asyncDispose]: mockDispose, send: mockAgentSend, }); @@ -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") + ); + }); }); diff --git a/dist/index.mjs b/dist/index.mjs index 3ad1f54..26d102d 100644 --- a/dist/index.mjs +++ b/dist/index.mjs @@ -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 = [ @@ -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 @@ -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."); @@ -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."; @@ -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, diff --git a/docs/content/behaviour.mdx b/docs/content/behaviour.mdx index e28590f..a0abcbc 100644 --- a/docs/content/behaviour.mdx +++ b/docs/content/behaviour.mdx @@ -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. diff --git a/docs/content/troubleshooting.mdx b/docs/content/troubleshooting.mdx index a59a678..0e0dba1 100644 --- a/docs/content/troubleshooting.mdx +++ b/docs/content/troubleshooting.mdx @@ -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` diff --git a/src/runner.ts b/src/runner.ts index bcff008..f17d6bf 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -1,11 +1,24 @@ import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; import { info, warning } from "@actions/core"; import { Agent } from "@cursor/sdk"; -import type { RunResult, SDKMessage } from "@cursor/sdk"; +import type { Run, RunResult, SDKAgent, SDKMessage } from "@cursor/sdk"; import type { ActionInputs, AgentResult, TokenUsageStats } from "./types"; +/** Longest delay Node's timers accept; larger values would fire immediately. */ +const MAX_TIMER_MS = 2_147_483_647; +/** How long to wait for the SDK to settle after a cancel request. */ +const DEFAULT_CANCEL_GRACE_MS = 10_000; +/** How long to wait for the agent to release its local executor. */ +const DEFAULT_DISPOSE_TIMEOUT_MS = 15_000; + +export interface RunAgentOptions { + cancelGraceMs?: number; + disposeTimeoutMs?: number; +} + const extractErrorMessage = (error: unknown): string => { if (error instanceof Error) { return error.cause @@ -15,6 +28,49 @@ const extractErrorMessage = (error: unknown): string => { return String(error); }; +/** Resolves to `undefined` when `promise` has not settled within `ms`. */ +const withTimeout = async ( + promise: Promise, + ms: number +): Promise => { + const controller = new AbortController(); + try { + return await Promise.race([ + promise, + delay(ms, undefined, { signal: controller.signal }), + ]); + } finally { + // Cancels the pending timer; the aborted delay is already handled by the race. + controller.abort(); + } +}; + +/** Resolves to `undefined` instead of rejecting. */ +const quietly = async (promise: Promise): Promise => { + try { + return await promise; + } catch { + return undefined; + } +}; + +const disposeAgent = async ( + agent: SDKAgent, + timeoutMs: number +): Promise => { + try { + const disposed = await withTimeout( + agent[Symbol.asyncDispose]().then(() => true), + timeoutMs + ); + if (disposed === undefined) { + 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 @@ -53,8 +109,53 @@ const mapUsage = (usage: RunResult["usage"]): TokenUsageStats | undefined => { }; }; -export const runAgent = async (inputs: ActionInputs): Promise => { +interface SettledRun { + /** `undefined` when the run did not settle, or failed after the deadline. */ + result: RunResult | undefined; + timedOut: boolean; +} + +/** + * 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: Run, + consume: Promise, + timeoutSeconds: number, + cancelGraceMs: number +): Promise => { + // Once the deadline passes nobody awaits `consume` directly; this keeps a + // late rejection from surfacing as an unhandled rejection. + const settled = quietly(consume); + + const timeoutMs = timeoutSeconds * 1000; + const hasDeadline = timeoutMs > 0 && timeoutMs <= MAX_TIMER_MS; + const result = hasDeadline + ? await withTimeout(consume, timeoutMs) + : await consume; + if (result !== undefined) { + return { result, timedOut: false }; + } + + if (run.supports("cancel")) { + try { + await withTimeout(run.cancel(), cancelGraceMs); + } catch { + // Best-effort cancel on timeout; the grace period below bounds the wait. + } + } + return { result: await withTimeout(settled, cancelGraceMs), timedOut: true }; +}; + +export const runAgent = async ( + inputs: ActionInputs, + options: RunAgentOptions = {} +): Promise => { 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}`); @@ -71,9 +172,10 @@ export const runAgent = async (inputs: ActionInputs): Promise => { let status = "finished"; let durationMs: number | undefined; let usage: TokenUsageStats | undefined; + let agent: SDKAgent | undefined; try { - const agent = await Agent.create({ + agent = await Agent.create({ apiKey: inputs.apiKey, local: { cwd }, model: { id: inputs.model }, @@ -81,40 +183,34 @@ export const runAgent = async (inputs: ActionInputs): Promise => { const run = await agent.send(inputs.prompt); - const timeoutMs = inputs.timeout * 1000; - let cancelTimer: ReturnType | undefined; - let timedOut = false; - - if (timeoutMs > 0 && Number.isFinite(timeoutMs)) { - cancelTimer = setTimeout(() => { - timedOut = true; - // Fire-and-forget: timeout handler must not block the timer callback. - void (async () => { - if (run.supports("cancel")) { - try { - await run.cancel(); - } catch { - // Best-effort cancel on timeout; errors are surfaced via stream/result. - } - } - })(); - }, timeoutMs); - } - - try { + const consume = (async (): Promise => { for await (const event of run.stream()) { stdout += assistantText(event); } + return await run.wait(); + })(); + + const { result: runResult, timedOut } = await settleRun( + run, + consume, + inputs.timeout, + cancelGraceMs + ); - const runResult = await run.wait(); - ({ durationMs } = runResult); + if (runResult === undefined) { + 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."; @@ -127,10 +223,10 @@ export const runAgent = async (inputs: ActionInputs): Promise => { : "Agent run was cancelled."; stderr += stderr ? `\n${msg}` : msg; warning(msg); - } - } finally { - if (cancelTimer !== undefined) { - clearTimeout(cancelTimer); + } else if (timedOut) { + warning( + `Agent run exceeded the ${inputs.timeout}s timeout but finished while the cancel request was pending.` + ); } } } catch (error) { @@ -138,6 +234,10 @@ export const runAgent = async (inputs: ActionInputs): Promise => { status = "error"; stderr += extractErrorMessage(error); warning(`Agent execution failed: ${stderr}`); + } finally { + if (agent !== undefined) { + await disposeAgent(agent, disposeTimeoutMs); + } } return {