Skip to content

fix(workflows): an interrupted gate prompt must not approve the gate - #4529

Open
jawwad-ali wants to merge 1 commit into
github:mainfrom
jawwad-ali:fix/gate-interrupt-not-a-choice
Open

fix(workflows): an interrupted gate prompt must not approve the gate#4529
jawwad-ali wants to merge 1 commit into
github:mainfrom
jawwad-ali:fix/gate-interrupt-not-a-choice

Conversation

@jawwad-ali

Copy link
Copy Markdown
Contributor

Problem

GateStep._prompt treats Ctrl+C / Ctrl+D as a choice:

except (EOFError, KeyboardInterrupt):
    print()
    return options[-1]  # default to last (usually reject)

"usually reject" is an assumption validate never enforces. It requires only that some option is reject/abort — never that it is last:

reject_choices = {"reject", "abort"}
if not any(o.lower() in reject_choices for o in options):

So a hand-written options list whose reject choice is not last validates clean, and options[-1] is then an approving option. execute classifies the result with if choice.lower() in ("reject", "abort"), so the gate reports COMPLETED and the run continues past the human review the gate exists to enforce.

Reproduction on current main (c173bf1)

Interrupting the prompt, with validate() run on each config first:

options=['approve', 'reject']                    validate=[] -> status=failed    choice='reject'
options=['reject', 'approve']                    validate=[] -> status=completed choice='approve'
options=['approve', 'reject', 'request-changes'] validate=[] -> status=completed choice='request-changes'

Identical for both KeyboardInterrupt and EOFError. All three validate with zero errorsspecify workflow validate gives the author no warning.

The middle and last rows are the bug: the operator pressed Ctrl+C at an approval gate and the workflow recorded an approval.

It also contradicts the engine's own interrupt contract

WorkflowEngine catches KeyboardInterrupt and sets RunStatus.PAUSED with a workflow_interrupted log event (engine.py:1059-1061), so Ctrl+C anywhere else in a run pauses it for specify workflow resume. Only at a gate did it silently make a decision instead.

Fix

Prefer the declared reject/abort option; fall back to the last option when none is declared.

return next(
    (o for o in options if o.lower() in ("reject", "abort")),
    options[-1],
)

After the fix, every config carrying a reject option rejects on interrupt:

KeyboardInterrupt options=['approve', 'reject']                    -> failed    choice='reject'
KeyboardInterrupt options=['reject', 'approve']                    -> failed    choice='reject'
KeyboardInterrupt options=['approve', 'reject', 'request-changes'] -> failed    choice='reject'
KeyboardInterrupt options=['yes', 'no']                            -> completed choice='no'   <-- unchanged

Behaviour change — disclosed

The returned choice changes only when the prompt is interrupted and a reject/abort option is not last. The documented default options: [approve, reject] is byte-for-byte unchanged, which the reject_last parametrization pins by passing both before and after. A workflow that deliberately relied on Ctrl+C selecting a trailing non-reject option (say defer) would now reject instead — that reliance is precisely the defect being fixed, but it is a real change and worth stating.

Verification

  • Fail-before / pass-after: 5 new-vs-baseline failures with the source reverted to upstream/main55 passed with the fix.
  • Parametrized over both interrupt types × three option orderings, plus a step-level test asserting the gate does not report COMPLETED, plus a guard that the no-reject-option fallback is unchanged.
  • Scoped regression on tests/test_workflows.py: 21 failed / 964 passed against a clean-main baseline of 21 failed / 956 passed — no new failures (the 21 are the known Windows os.replace flakiness in that file).
  • uvx ruff@0.15.0 check src tests → clean

Written with assistance from Claude Code. Bug found, reproduced, and verified by me on current main.

🤖 Generated with Claude Code

`GateStep._prompt` treated Ctrl+C / Ctrl+D as a choice:

    except (EOFError, KeyboardInterrupt):
        print()
        return options[-1]  # default to last (usually reject)

"usually reject" is an assumption `validate` never enforces. It requires only
that *some* option is 'reject'/'abort':

    reject_choices = {"reject", "abort"}
    if not any(o.lower() in reject_choices for o in options):

so a hand-written `options` list whose reject choice is not last validates
clean, and `options[-1]` is then an approving option. `execute` classifies the
result with `if choice.lower() in ("reject", "abort")`, so the gate reported
COMPLETED and the run walked straight past the human review:

  options=['approve', 'reject']                    -> failed    choice='reject'
  options=['reject', 'approve']                    -> completed choice='approve'
  options=['approve', 'reject', 'request-changes'] -> completed choice='request-changes'

All three validate with zero errors.

This also contradicted the engine's own interrupt contract: `WorkflowEngine`
catches KeyboardInterrupt and sets `RunStatus.PAUSED` with a
`workflow_interrupted` log event, so Ctrl+C anywhere else pauses the run for
`specify workflow resume`. Only at a gate did it silently make an approval
decision.

Now prefers the declared reject/abort option, falling back to the last option
when none is declared. For the documented default `[approve, reject]` this is
byte-for-byte the previous behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jawwad-ali
jawwad-ali requested a review from mnriem as a code owner September 11, 2026 15:48
@mnriem mnriem added the triage-nice-to-have Verdict: evidence-backed fix or greenlit feature — land after review label Sep 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

triage-nice-to-have Verdict: evidence-backed fix or greenlit feature — land after review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants