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
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,4 +120,4 @@ runs:
INPUT_SERVICE_DATA: ${{ inputs.service-data }}
INPUT_SERVICE_MEMORY: ${{ inputs.service-memory }}
INPUT_SERVICE_TIMEOUT_MS: ${{ inputs.service-timeout-ms }}
run: node ${{ github.action_path }}/dist/action/index.js
run: exec node "${{ github.action_path }}/dist/action/index.js"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need this (or something like it) so that when github interrupts/kills the process, the signal makes it to node.

34 changes: 34 additions & 0 deletions packages/warden/src/action/cancellation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from 'vitest';
import { ActionCancellation, createActionSignalHandler } from './cancellation.js';

describe('Action cancellation signals', () => {
it('aborts gracefully on the first cancellation signal', () => {
const cancellation = new ActionCancellation();
const exit = vi.fn();
const handler = createActionSignalHandler({ cancellation, exit });

handler('SIGTERM');

expect(cancellation.requested).toBe(true);
expect(cancellation.signalName).toBe('SIGTERM');
expect(cancellation.signal.aborted).toBe(true);
expect(cancellation.exitCode).toBe(143);
expect(exit).not.toHaveBeenCalled();
});

it('ignores duplicate delivery before forcing a later exit', () => {
const cancellation = new ActionCancellation();
const exit = vi.fn();
let now = 1_000;
const handler = createActionSignalHandler({ cancellation, exit, now: () => now });

handler('SIGINT');
now += 100;
handler('SIGTERM');
expect(exit).not.toHaveBeenCalled();

now += 1_000;
handler('SIGTERM');
expect(exit).toHaveBeenCalledWith(143);
});
});
58 changes: 58 additions & 0 deletions packages/warden/src/action/cancellation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
export type ActionCancelSignal = 'SIGINT' | 'SIGTERM';

const DEFAULT_DUPLICATE_SIGNAL_WINDOW_MS = 750;

/** Run-scoped cancellation state shared by the Action entrypoint and workflows. */
export class ActionCancellation {
private readonly controller = new AbortController();
readonly signal: AbortSignal = this.controller.signal;
signalName: ActionCancelSignal | undefined;

get requested(): boolean {
return this.signalName !== undefined;
}

request(signalName: ActionCancelSignal): boolean {
if (this.requested) return false;

this.signalName = signalName;
this.controller.abort(new Error(`Action cancelled by ${signalName}`));
return true;
}

get exitCode(): number {
return this.signalName === 'SIGTERM' ? 143 : 130;
}
}

interface ActionSignalHandlerOptions {
cancellation: ActionCancellation;
now?: () => number;
exit?: (code: number) => void;
duplicateWindowMs?: number;
}

/** Create a shared SIGINT/SIGTERM handler with graceful-first, force-second behavior. */
export function createActionSignalHandler(
options: ActionSignalHandlerOptions
): (signalName: ActionCancelSignal) => void {
const duplicateWindowMs = options.duplicateWindowMs ?? DEFAULT_DUPLICATE_SIGNAL_WINDOW_MS;
const now = options.now ?? (() => Date.now());
const exit = options.exit ?? ((code) => process.exit(code));
let lastSignalAt = 0;

return (signalName) => {
const receivedAt = now();
if (options.cancellation.requested && receivedAt - lastSignalAt < duplicateWindowMs) {
return;
}

lastSignalAt = receivedAt;
if (!options.cancellation.request(signalName)) {
exit(signalName === 'SIGTERM' ? 143 : 130);
return;
}

console.warn(`Cancellation requested by ${signalName}; finalizing partial results`);
};
}
43 changes: 34 additions & 9 deletions packages/warden/src/action/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,47 @@
import { initSentry, flushSentry } from '../sentry.js';
import { ActionFailedError } from './workflow/base.js';
import { runAction } from './runner.js';
import { ActionCancellation, createActionSignalHandler } from './cancellation.js';

async function flushActionTelemetry(): Promise<void> {
if (!(await flushSentry())) {
const CANCELLATION_TELEMETRY_FLUSH_TIMEOUT_MS = 3_000;

async function flushActionTelemetry(timeoutMs?: number): Promise<void> {
if (!(await flushSentry(timeoutMs))) {
console.warn('::warning::Timed out while flushing Sentry telemetry');
}
}

initSentry('action');
runAction()
.then(() => flushActionTelemetry())
.catch(async (error) => {
async function main(): Promise<void> {
const cancellation = new ActionCancellation();
const handleSignal = createActionSignalHandler({ cancellation });
const onSigint = () => handleSignal('SIGINT');
const onSigterm = () => handleSignal('SIGTERM');
process.on('SIGINT', onSigint);
process.on('SIGTERM', onSigterm);

try {
await runAction(cancellation);
await flushActionTelemetry(
cancellation.requested ? CANCELLATION_TELEMETRY_FLUSH_TIMEOUT_MS : undefined,
);
if (cancellation.requested) {
process.exitCode = cancellation.exitCode;
}
} catch (error) {
if (error instanceof ActionFailedError) {
console.error(`::error::${error.message}`);
} else {
console.error(`::error::Unexpected error: ${error}`);
}
await flushActionTelemetry();
process.exit(1);
});
await flushActionTelemetry(
cancellation.requested ? CANCELLATION_TELEMETRY_FLUSH_TIMEOUT_MS : undefined,
);
process.exitCode = cancellation.requested ? cancellation.exitCode : 1;
} finally {
process.off('SIGINT', onSigint);
process.off('SIGTERM', onSigterm);
}
}

initSentry('action');
void main();
48 changes: 46 additions & 2 deletions packages/warden/src/action/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ vi.mock('./workflow/schedule.js', () => ({
}));

import { runAction } from './runner.js';
import { ActionCancellation } from './cancellation.js';

const baseInputs: ActionInputs = {
anthropicApiKey: 'test-api-key',
Expand Down Expand Up @@ -79,7 +80,8 @@ describe('runAction without telemetry', () => {
expect(mocks.runScheduleWorkflow).toHaveBeenCalledWith(
mocks.octokit,
baseInputs,
'/tmp/workspace'
'/tmp/workspace',
expect.any(Object),
);
});
});
Expand Down Expand Up @@ -173,7 +175,8 @@ describe('runAction', () => {
baseInputs,
'push',
'/tmp/event.json',
'/tmp/workspace'
'/tmp/workspace',
expect.any(Object),
);
expect(emit).toHaveBeenCalledWith(
'processMetric',
Expand All @@ -200,6 +203,47 @@ describe('runAction', () => {
);
});

it('records a requested cancellation as the Action outcome', async () => {
const cancellation = new ActionCancellation();
cancellation.request('SIGTERM');
const emit = spyOnClientEmit();

await runAction(cancellation);
await Sentry.flush(1000);

expect(emit).toHaveBeenCalledWith(
'processMetric',
expect.objectContaining({
name: 'warden.action.runs',
attributes: expect.objectContaining({
'warden.action.outcome': 'cancelled',
}),
}),
);
});

it('keeps the cancelled outcome when cleanup throws', async () => {
const cancellation = new ActionCancellation();
cancellation.request('SIGTERM');
const error = new Error('cleanup failed');
mocks.runScheduleWorkflow.mockRejectedValueOnce(error);
const emit = spyOnClientEmit();

await expect(runAction(cancellation)).rejects.toBe(error);
await Sentry.flush(1000);

expect(emit).toHaveBeenCalledWith(
'processMetric',
expect.objectContaining({
name: 'warden.action.runs',
attributes: expect.objectContaining({
'warden.action.outcome': 'cancelled',
}),
}),
);
expect(capturedEvents).toHaveLength(0);
});

it('attributes input parsing failures before capturing them', async () => {
const error = new Error('Invalid mode "later"');
const setTag = vi.spyOn(Sentry.getIsolationScope(), 'setTag');
Expand Down
20 changes: 15 additions & 5 deletions packages/warden/src/action/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ import { parseActionInputs, setupAuthEnv, validateInputs } from './inputs.js';
import { ActionFailedError, setFailed } from './workflow/base.js';
import { runPRWorkflow } from './workflow/pr-workflow.js';
import { runScheduleWorkflow } from './workflow/schedule.js';
import { ActionCancellation } from './cancellation.js';

function isPullRequestEvent(eventName: string): boolean {
return eventName === 'pull_request';
}

/** Run the GitHub Action dispatcher once. */
export async function runAction(): Promise<void> {
export async function runAction(cancellation = new ActionCancellation()): Promise<void> {
const eventName = process.env['GITHUB_EVENT_NAME'];
const actionAttributes = setGitHubActionScope(eventName);

Expand Down Expand Up @@ -48,18 +49,27 @@ export async function runAction(): Promise<void> {
if (inputs.mode !== 'run') {
setFailed(`${inputs.mode} mode is only supported for pull request workflows`);
}
await runScheduleWorkflow(octokit, inputs, repoPath);
await runScheduleWorkflow(octokit, inputs, repoPath, cancellation);
} else {
if (inputs.mode !== 'run' && !isPullRequestEvent(eventName)) {
setFailed(`${inputs.mode} mode is only supported for pull request workflows`);
}
await runPRWorkflow(octokit, inputs, eventName, eventPath, repoPath);
await runPRWorkflow(octokit, inputs, eventName, eventPath, repoPath, cancellation);
}

span.setAttribute('warden.action.outcome', 'success');
const outcome = cancellation.requested ? 'cancelled' : 'success';
span.setAttribute('warden.action.outcome', outcome);
span.setStatus({ code: SPAN_STATUS_OK });
emitActionRunMetric('success', stage);
emitActionRunMetric(outcome, stage);
} catch (error) {
if (cancellation.requested) {
span.setAttribute('warden.action.outcome', 'cancelled');
span.setAttribute('warden.action.stage', stage);
span.setStatus({ code: SPAN_STATUS_OK });
emitActionRunMetric('cancelled', stage);
throw error;
}

const { code } = classifyError(error);
span.setAttribute('warden.action.outcome', 'failure');
span.setAttribute('warden.action.stage', stage);
Expand Down
18 changes: 18 additions & 0 deletions packages/warden/src/action/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,24 @@ afterEach(() => {
});

describe('Action service integration', () => {
it('preserves a cancelled findings outcome in the service envelope', () => {
const output = buildFindingsOutput([report], context, [], {
runId: 'cancelled-action-run',
timestamp: '2026-08-12T12:00:01.000Z',
outcome: 'cancelled',
});

const envelope = buildFindingsServiceRunEnvelope(output, {
url: 'https://warden.example.com',
token: 'service-token',
data: 'findings',
memory: false,
timeoutMs: 2_000,
}, 'action');

expect(envelope.outcome).toBe('cancelled');
});

it('defaults a URL-and-token-only Action setup to findings and memory', () => {
expect(resolveActionServiceOptions(inputs({
serviceUrl: 'https://warden.example.com',
Expand Down
26 changes: 26 additions & 0 deletions packages/warden/src/action/triggers/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ describe('executeTrigger', () => {
...checkOptions,
...options,
}),
cancel: (report: SkillReport) =>
updateSkillCheck(mockOctokit, check.checkRunId, report, {
...checkOptions,
conclusion: 'cancelled',
title: 'Analysis cancelled',
}),
fail: (error: unknown) =>
failSkillCheck(mockOctokit, check.checkRunId, error, checkOptions),
};
Expand Down Expand Up @@ -320,6 +326,26 @@ describe('executeTrigger', () => {
expect(result.error).toBeUndefined();
});

it('cancels the skill check when Action cancellation was requested', async () => {
const mockReport = createReport();
const cancellation = new AbortController();
cancellation.abort();
vi.mocked(runSkillTask).mockResolvedValue({ name: 'test-trigger', report: mockReport });
vi.mocked(createSkillCheck).mockResolvedValue({ checkRunId: 123, url: 'https://github.com/check/123' });
vi.mocked(updateSkillCheck).mockResolvedValue(undefined);

await executeTrigger(mockTrigger, {
...mockDeps,
cancellationSignal: cancellation.signal,
});

expect(updateSkillCheck).toHaveBeenCalledWith(mockOctokit, 123, mockReport, {
...checkOptions,
conclusion: 'cancelled',
title: 'Analysis cancelled',
});
});

it('handles skill resolution failure', async () => {
vi.mocked(runSkillTask).mockResolvedValue({ name: 'test-trigger', error: new Error('Skill not found') });
vi.mocked(createSkillCheck).mockResolvedValue({ checkRunId: 123, url: 'https://github.com/check/123' });
Expand Down
21 changes: 15 additions & 6 deletions packages/warden/src/action/triggers/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export interface TriggerCheckRun {
url?: string;
checkRunId?: number;
complete(report: SkillReport, options: TriggerCheckCompleteOptions): Promise<void>;
cancel(report: SkillReport): Promise<void>;
fail(error: unknown): Promise<void>;
}

Expand Down Expand Up @@ -104,6 +105,8 @@ export interface TriggerExecutorDeps {
analysisQueue?: AsyncWorkQueue;
/** Shared controller for stopping the whole action run */
abortController?: AbortController;
/** User-requested Action cancellation, distinct from provider circuit-breaker aborts. */
cancellationSignal?: AbortSignal;
/** Shared circuit breaker for auth/provider failures */
circuitBreaker?: ProviderFailureCircuitBreaker;
/** Optional context-bound check writer. Omit for analyze mode. */
Expand Down Expand Up @@ -134,6 +137,8 @@ export interface TriggerResult {
auxiliaryModel?: string;
synthesisModel?: string;
error?: unknown;
/** The trigger matched but cancellation stopped it before dispatch. */
pending?: boolean;
/** Verification/merge events captured during post-processing, for provenance export. */
findingProcessingEvents?: FindingProcessingEvent[];
/**
Expand Down Expand Up @@ -263,12 +268,16 @@ export async function executeTrigger(
// Update skill check with results
if (skillCheck && context.pullRequest) {
try {
await skillCheck.complete(report, {
failOn,
reportOn,
minConfidence,
failCheck,
});
if (deps.cancellationSignal?.aborted) {
await skillCheck.cancel(report);
} else {
await skillCheck.complete(report, {
failOn,
reportOn,
minConfidence,
failCheck,
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cancelled skills still fail checks

Medium Severity

Cancellation is only honored after a clean skill report. If the task returns report.error or throws, the executor still captures a Sentry exception, marks the GitHub check as failed, and drops the report. The run-level execute catch similarly fails the core check and writes a non-cancelled artifact even when cancellation was requested.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d97dff9. Configure here.

} catch (error) {
console.error(`::warning::Failed to update skill check for ${trigger.skill}: ${error}`);
}
Expand Down
Loading
Loading