mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-08-04 02:08:30 +08:00
In some workflow contexts — reliably reproducible for us on
pull_request-triggered runs of this action — the Claude Agent SDK
query() async iterator does not close after the terminal result
message is emitted. The for-await loop in runClaudeWithSdk blocks
indefinitely after Claude has finished its work, until the workflow's
timeout-minutes cap kills the job.
Symptoms observed in production (4× in our scan-reviewer workflow):
- Claude completes successfully: SDK emits { type: "result",
subtype: "success", ... } with the cost / turns / duration set.
- The action then sits with zero log output for the rest of
timeout-minutes (we measured 18-19 min of dead time after result).
- The job is cancelled at timeout. writeExecutionFile is never
called → no claude-execution-output.json → cost-tracker and other
post-steps see nothing.
- Run shows as cancelled, even though Claude did its work and any
verdict it posted via gh tools already landed.
Author-mode (workflow_dispatch) runs from the same codebase
terminate cleanly the same day, so the hang is specific to certain
event triggers.
By SDK contract the result message is terminal — no further messages
follow. Break out of the loop immediately after capturing it,
regardless of whether the upstream iterator ever closes. If the SDK
is later fixed to close cleanly in all contexts, this break becomes
a no-op.
251 lines
7.7 KiB
TypeScript
251 lines
7.7 KiB
TypeScript
import * as core from "@actions/core";
|
|
import { readFile, access } from "fs/promises";
|
|
import { dirname, join } from "path";
|
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
import type {
|
|
SDKMessage,
|
|
SDKResultMessage,
|
|
SDKUserMessage,
|
|
} from "@anthropic-ai/claude-agent-sdk";
|
|
import type { ParsedSdkOptions } from "./parse-sdk-options";
|
|
import { writeExecutionFile } from "./execution-file";
|
|
|
|
export type ClaudeRunResult = {
|
|
executionFile?: string;
|
|
sessionId?: string;
|
|
conclusion: "success" | "failure";
|
|
structuredOutput?: string;
|
|
};
|
|
|
|
/** Filename for the user request file, written by prompt generation */
|
|
const USER_REQUEST_FILENAME = "claude-user-request.txt";
|
|
|
|
/**
|
|
* Check if a file exists
|
|
*/
|
|
async function fileExists(path: string): Promise<boolean> {
|
|
try {
|
|
await access(path);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a prompt configuration for the SDK.
|
|
* If a user request file exists alongside the prompt file, returns a multi-block
|
|
* SDKUserMessage that enables slash command processing in the CLI.
|
|
* Otherwise, returns the prompt as a simple string.
|
|
*/
|
|
async function createPromptConfig(
|
|
promptPath: string,
|
|
showFullOutput: boolean,
|
|
): Promise<string | AsyncIterable<SDKUserMessage>> {
|
|
const promptContent = await readFile(promptPath, "utf-8");
|
|
|
|
// Check for user request file in the same directory
|
|
const userRequestPath = join(dirname(promptPath), USER_REQUEST_FILENAME);
|
|
const hasUserRequest = await fileExists(userRequestPath);
|
|
|
|
if (!hasUserRequest) {
|
|
// No user request file - use simple string prompt
|
|
return promptContent;
|
|
}
|
|
|
|
// User request file exists - create multi-block message
|
|
const userRequest = await readFile(userRequestPath, "utf-8");
|
|
if (showFullOutput) {
|
|
console.log("Using multi-block message with user request:", userRequest);
|
|
} else {
|
|
console.log("Using multi-block message with user request (content hidden)");
|
|
}
|
|
|
|
// Create an async generator that yields a single multi-block message
|
|
// The context/instructions go first, then the user's actual request last
|
|
// This allows the CLI to detect and process slash commands in the user request
|
|
async function* createMultiBlockMessage(): AsyncGenerator<SDKUserMessage> {
|
|
yield {
|
|
type: "user",
|
|
session_id: "",
|
|
message: {
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: promptContent }, // Instructions + GitHub context
|
|
{ type: "text", text: userRequest }, // User's request (may be a slash command)
|
|
],
|
|
},
|
|
parent_tool_use_id: null,
|
|
};
|
|
}
|
|
|
|
return createMultiBlockMessage();
|
|
}
|
|
|
|
/**
|
|
* Sanitizes SDK output to match CLI sanitization behavior
|
|
*/
|
|
function sanitizeSdkOutput(
|
|
message: SDKMessage,
|
|
showFullOutput: boolean,
|
|
): string | null {
|
|
if (showFullOutput) {
|
|
return JSON.stringify(message, null, 2);
|
|
}
|
|
|
|
// System initialization - safe to show
|
|
if (message.type === "system" && message.subtype === "init") {
|
|
return JSON.stringify(
|
|
{
|
|
type: "system",
|
|
subtype: "init",
|
|
message: "Claude Code initialized",
|
|
model: "model" in message ? message.model : "unknown",
|
|
},
|
|
null,
|
|
2,
|
|
);
|
|
}
|
|
|
|
// Result messages - show sanitized summary
|
|
if (message.type === "result") {
|
|
const resultMsg = message as SDKResultMessage;
|
|
return JSON.stringify(
|
|
{
|
|
type: "result",
|
|
subtype: resultMsg.subtype,
|
|
is_error: resultMsg.is_error,
|
|
duration_ms: resultMsg.duration_ms,
|
|
num_turns: resultMsg.num_turns,
|
|
total_cost_usd: resultMsg.total_cost_usd,
|
|
permission_denials_count: resultMsg.permission_denials?.length ?? 0,
|
|
},
|
|
null,
|
|
2,
|
|
);
|
|
}
|
|
|
|
// Suppress other message types in non-full-output mode
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Run Claude using the Agent SDK
|
|
*/
|
|
export async function runClaudeWithSdk(
|
|
promptPath: string,
|
|
{ sdkOptions, showFullOutput, hasJsonSchema }: ParsedSdkOptions,
|
|
): Promise<ClaudeRunResult> {
|
|
// Create prompt configuration - may be a string or multi-block message
|
|
const prompt = await createPromptConfig(promptPath, showFullOutput);
|
|
|
|
if (!showFullOutput) {
|
|
console.log(
|
|
"Running Claude Code via SDK (full output hidden for security)...",
|
|
);
|
|
console.log(
|
|
"Rerun in debug mode or enable `show_full_output: true` in your workflow file for full output.",
|
|
);
|
|
}
|
|
|
|
console.log(`Running Claude with prompt from file: ${promptPath}`);
|
|
// Log SDK options without env (which could contain sensitive data)
|
|
const { env, extraArgs, ...optionsToLog } = sdkOptions;
|
|
console.log("SDK options:", JSON.stringify(optionsToLog, null, 2));
|
|
|
|
const messages: SDKMessage[] = [];
|
|
let resultMessage: SDKResultMessage | undefined;
|
|
|
|
try {
|
|
for await (const message of query({ prompt, options: sdkOptions })) {
|
|
messages.push(message);
|
|
|
|
const sanitized = sanitizeSdkOutput(message, showFullOutput);
|
|
if (sanitized) {
|
|
console.log(sanitized);
|
|
}
|
|
|
|
if (message.type === "result") {
|
|
resultMessage = message as SDKResultMessage;
|
|
// The SDK's query() iterator should close itself after the
|
|
// result message, but in some workflow contexts (notably
|
|
// pull_request-triggered runs) it stays open indefinitely and
|
|
// the for-await hangs until the workflow's timeout-minutes
|
|
// kills the job. This causes the action to "succeed" inside
|
|
// Claude (verdict posted, $cost recorded) but be reported as
|
|
// cancelled with no execution-output.json written. Break
|
|
// explicitly: by SDK contract no further messages follow a
|
|
// result, so the break is safe.
|
|
break;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("SDK execution error:", error);
|
|
await writeExecutionFile(messages);
|
|
throw new Error(`SDK execution error: ${error}`);
|
|
}
|
|
|
|
const result: ClaudeRunResult = {
|
|
conclusion: "failure",
|
|
};
|
|
|
|
const executionFile = await writeExecutionFile(messages);
|
|
if (executionFile) {
|
|
result.executionFile = executionFile;
|
|
}
|
|
|
|
// Extract session_id from system.init message
|
|
const initMessage = messages.find(
|
|
(m) => m.type === "system" && "subtype" in m && m.subtype === "init",
|
|
);
|
|
if (initMessage && "session_id" in initMessage && initMessage.session_id) {
|
|
result.sessionId = initMessage.session_id as string;
|
|
core.info(`Set session_id: ${result.sessionId}`);
|
|
}
|
|
|
|
if (!resultMessage) {
|
|
core.error("No result message received from Claude");
|
|
throw new Error("No result message received from Claude");
|
|
}
|
|
|
|
const isSuccess = resultMessage.subtype === "success";
|
|
result.conclusion = isSuccess ? "success" : "failure";
|
|
|
|
// Handle structured output
|
|
if (hasJsonSchema) {
|
|
if (
|
|
isSuccess &&
|
|
"structured_output" in resultMessage &&
|
|
resultMessage.structured_output
|
|
) {
|
|
result.structuredOutput = JSON.stringify(resultMessage.structured_output);
|
|
core.info(
|
|
`Set structured_output with ${Object.keys(resultMessage.structured_output as object).length} field(s)`,
|
|
);
|
|
} else {
|
|
core.setFailed(
|
|
`--json-schema was provided but Claude did not return structured_output. Result subtype: ${resultMessage.subtype}`,
|
|
);
|
|
result.conclusion = "failure";
|
|
throw new Error(
|
|
`--json-schema was provided but Claude did not return structured_output. Result subtype: ${resultMessage.subtype}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (!isSuccess) {
|
|
if ("errors" in resultMessage && resultMessage.errors) {
|
|
core.error(`Execution failed: ${resultMessage.errors.join(", ")}`);
|
|
}
|
|
throw new Error(
|
|
`Claude execution failed: ${
|
|
"errors" in resultMessage && resultMessage.errors
|
|
? resultMessage.errors.join(", ")
|
|
: "unknown error"
|
|
}`,
|
|
);
|
|
}
|
|
|
|
return result;
|
|
}
|