Max turns from claude_args now stops the run

1 week ago

A user set max turns to 60 in claude args, and a long review ran for 73 turns. That's thirteen turns past the cap. The result came back as a success, with no error, so the action went green. Max turns is the main cost guardrail for unattended runs, and here it failed open. This pull request makes it fail closed.

Ask

Ask about this presentation

Answers are generated from this presentation.

Chapters

  1. 0:00The overrun
  2. 0:18Where the cap went
  3. 0:38Change one: promote the flag
  4. 0:56Change two: fail closed
  5. 1:14The evidence
  6. 1:32What to look at
  7. 2:01Where it leaves you
Show transcript

The overrun

turn budgetclaude-code-action · pull request 1607

A 60-turn cap let a run
go to 73 and pass

turn 1cap: 6073
subtype: success

A user set max turns to 60 in claude args, and a long review ran for 73 turns. That's thirteen turns past the cap. The result came back as a success, with no error, so the action went green. Max turns is the main cost guardrail for unattended runs, and here it failed open. This pull request makes it fail closed.

Where the cap went

turn budgetthe path

The cap now goes to the SDK's own option

claude_args--max-turns 60
→
Before: CLI pass-throughextraArgs["max-turns"]
sdkOptions.maxTurns = undefined
→
After: typed optionsdkOptions.maxTurns = 60
flag removed from extraArgs

The top-level action has no max-turns input, so claude_args is the only way to set it.

Here's where the number went. Until now, the max turns flag in claude args was passed through to the CLI as a loose extra argument, and the Agent SDK's typed max turns option stayed empty. Now the flag is pulled out of claude args and set as that typed option. This matters because the main action doesn't have a separate max turns input, so every user who sets a cap sets it this way.

Change one: promote the flag

turn budgetchange one

Pull the flag out, then use it

base-action/src/parse-sdk-options.ts
const maxTurnsFromClaudeArgs = extraArgs["max-turns"] || undefined;
delete extraArgs["max-turns"];
// …
maxTurns: options.maxTurns
  ? parseInt(options.maxTurns, 10)
  : maxTurnsFromClaudeArgs
    ? parseInt(maxTurnsFromClaudeArgs, 10)
    : undefined,

The first change copies a pattern that already exists for the model flag. It reads max turns out of the parsed claude args and deletes it, so the CLI never sees it twice. Then the typed option takes the direct max turns input first, and falls back to the claude args value. The base action still exposes that direct input, so it wins when both are set.

Change two: fail closed

turn budgetchange two

A success past the cap now throws

base-action/src/run-claude-sdk.ts
if (
  resultMessage.subtype === "success" &&
  !resultMessage.is_error &&
  sdkOptions.maxTurns !== undefined &&
  resultMessage.num_turns > sdkOptions.maxTurns
) {
  const message = `Claude reported a successful result after ${…} turns, …`;
  core.error(message);
  throw new Error(message);
}

The second change is a backstop. The author's reasoning is that a bug in the SDK or the CLI shouldn't be able to produce a green action. So after the run, if the result says success but the reported turn count is above the cap, the action logs an error and throws. Results that already failed, like a max turns error, are left alone.

The evidence

turn budgettests

The 60-to-73 run, replayed in a test

maxTurns: 60num_turns: 73
subtype: "success"
→
Rejectserror message names 73 and 60
+
Log keptexecution file still written

Two parser tests: the flag maps to 60 and leaves extraArgs, and a direct 25 beats a claude_args 60.

The new test feeds the exact numbers from the bug report through a mocked SDK: a cap of 60 and a successful result at 73 turns. The run rejects with an error naming both numbers. The execution log is still written first, so the transcript survives for debugging. Two parser tests cover the flag mapping and the precedence rule.

What to look at

turn budgetfor the reviewer

Check these before trusting the backstop

  • The backstop runs after the workAll 73 turns, and their cost and side effects, already happened. It turns green to red.
  • How turns are countedThe issue's cap of 1 reported 2 turns. Can a clean finish at the cap read one over?
  • A non-numeric valueparseInt gives NaN, the SDK gets NaN, and the backstop never fires.
  • What a throw skipsThe conclusion and session id outputs aren't set; the tracking comment shows a failure.

The backstop only runs once the session ends. Comments, commits and spend from the extra turns have already happened; the check just makes the run red. Then there's turn counting. In the bug report, a cap of one reported two turns. If a run that ends cleanly at the cap can report one more, this check would fail a good run. A typo like "sixty" parses to not a number. The SDK is handed that value, and the backstop comparison is always false, so it never fires. And a throw skips setting the conclusion and session outputs, which downstream steps may read.

Where it leaves you

turn budgetanthropics/claude-code-action · 1607

The cap is enforced, and an overrun goes red

It doesn't explain why the CLI flag slipped past 60.

turn 1cap: 6073
action failed

One limit to keep in mind. This change doesn't find out why the CLI flag let the run go past 60. It stops relying on that flag, and adds a check in case the SDK slips too. So the same 73-turn run from the start now ends red, with an error naming both numbers. Max turns from claude args now stops the run, or fails it.