Skip to content

Headless agents in GitHub Actions can stall silently: background tasks, green checks, and zero output #83

Description

@cmungall

Summary

Agents run headlessly in GitHub Actions (claude -p via claude-code-base-action) can end their turn waiting for an event that will never arrive, exit 0, and produce no visible output at all. The job shows a green check. Nothing is committed, no PR is opened, no comment is posted on the triggering issue.

We should document this failure mode and harden our workflow templates against it. It is not specific to one repo or one agent — it is a structural property of running an interactive-assumption agent in a non-interactive harness.

Worked example

go-ontology run 31436984094, responding to a mention on geneontology/go-ontology#29437.

The agent launched ontology pre-validation as a background Bash task:

Bash {
  "command": "cd .../src/ontology && make travis_build 2>&1 | tail -80",
  "timeout": 600000,
  "run_in_background": true
}
// → "Command running in background with ID: bze8lu1so.
//    You will be notified when it completes."

It then made all three requested ontology edits correctly, polled the output file twice, saw it still empty, and ended its turn with:

"I'll wait for the pre-validation completion notification."

That was the final message. From the run artifact (claude-execution-output.json):

{ "is_error": false, "subtype": "success", "stop_reason": "end_turn",
  "terminal_reason": "completed", "duration_ms": 163587 }

Outcome: exit 0, green check, and

  • nothing posted to #29437 beyond the workflow's own "🤖 Working on it..." comment
  • branch ai4c-agent-issue-29437-run10337 created locally, never pushed
  • edits left in terms/*.obo, never even checked back into go-edit.obo
  • 2m44s used against a 30-minute budget

A near-miss worth noting: in its last two actions the agent ran ToolSearch("select:Monitor"), loading the schema for Monitor — the tool that does block inside a turn and would have worked — and then ended the turn without calling it.

(A later re-run, 31441317508, completed the same task and opened PR #32434. So this is intermittent, which makes it more insidious, not less.)

Root cause

The "you will be notified when it completes" contract requires a subsequent turn to deliver the notification. In -p headless mode there is no interactive user, so no subsequent turn is ever triggered. end_turn terminates the process.

The agent is not wrong about how background tasks work interactively. It simply has no way to know it is in a harness where that contract cannot be honoured.

Two compounding factors:

  1. The failure is silent. Exit code 0, is_error: false, subtype: "success". Nothing in the Actions UI distinguishes it from a successful run.
  2. Our template has no result-posting step. The ai-agent.yml prompt instructs the agent to communicate via gh itself, so all user-visible output is downstream of the stall.

Mitigations

Verified locally against Claude Code unless noted.

1. Remove the trigger: CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1

Setting this env var removes run_in_background from the Bash tool schema entirely. Tested:

InputValidationError: Bash failed due to the following issue:
An unexpected parameter `run_in_background` was provided

It also covers the auto-backgrounding path (Claude Code will otherwise move a long-running foreground command to the background on its own, via CLAUDE_CODE_AUTO_BACKGROUND_TIMEOUT_MS). Verified: with the flag set, a 150s foreground command — comfortably past the 120s default Bash timeout — ran to completion in-turn, session duration 161.8s. Not tested at multi-minute scale beyond that.

- name: Run Claude Code
  uses: anthropics/claude-code-base-action@main
  env:
    CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1"

⚠️ This env var is recognized by the CLI but is not in the public docs. Treat it as working-but-unsupported and keep #2 as the real safety net.

2. Stop hook — the general fix

A Stop hook fires when the agent tries to end its turn; returning {"decision": "block", "reason": "..."} forces it to continue. stop_hook_active in the hook input guards against infinite loops.

Our completion criterion is objective — did it post a comment? — so the hook can check reality rather than nag:

#!/usr/bin/env bash
# .github/hooks/require-comment.sh
input=$(cat)
[ "$(jq -r '.stop_hook_active' <<<"$input")" = "true" ] && exit 0  # already forced once

posted=$(gh issue view "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --json comments \
  --jq '[.comments[] | select(.author.login=="ai4c-agent[bot]" and (.body|test("Working on it")|not))] | length')

if [ "$posted" -eq 0 ]; then
  jq -n '{decision:"block", reason:"You have not posted a result comment. This is a non-interactive GitHub Actions run: ending your turn terminates the session and the requester sees nothing. Finish now — commit, push, open the PR if applicable, and post via `gh issue comment`. Do not wait for background task notifications; they are never delivered in this environment."}'
fi
exit 0

Both claude-code-action and claude-code-base-action accept a settings input (JSON string or path to a settings file), which is how hooks get injected:

settings: |
  {"hooks": {"Stop": [{"hooks": [{"type": "command",
    "command": "${{ github.workspace }}/.github/hooks/require-comment.sh"}]}]}}

This catches every premature-stop variant, not just background waiting: "let me know if you'd like me to proceed", giving up after an error, running out of plan.

3. PreToolUse hook, if we prefer not to depend on an undocumented env var

The PreToolUse hook input includes tool_input.run_in_background, so with matcher Bash:

input=$(cat)
if [ "$(jq -r '.tool_input.run_in_background // false' <<<"$input")" = "true" ]; then
  jq -n '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",
    permissionDecisionReason:"Background tasks do not work in this non-interactive run — completion notifications are never delivered. Re-run in the foreground with an explicit timeout."}}'
fi
exit 0

4. Workflow-level backstop (if: always())

Hooks do not run if the process itself dies. A final step that reads claude-execution-output.json and posts the result field when no comment landed also catches OOM, job timeout, and API errors. Cheap, and it converts silent-green into a visible "agent stalled, here is its last message".

5. Evaluate moving from base-action to claude-code-action v1

We currently use claude-code-base-action, the raw runner, which does nothing about communication — hence "green run, zero output". v1 manages a tracking comment itself (creates it, updates it with progress and results). base-action is now just a mirror of the base-action subdirectory inside claude-code-action.

Related constraint: the 10-minute Bash ceiling

Worth documenting alongside this, because it is what pushed the agent toward backgrounding in the first place: the Bash tool's max timeout is 600000ms (10 minutes) — a hard cap, not a raisable default. The agent in the failing run passed exactly 600000.

So make travis_build cannot be a single foreground Bash call. Disabling background tasks converts the silent stall into an honest 10-minute kill; it does not make a full ODK build fit. Options:

  1. Targeted pre-checkrobot reason / robot verify on the changed terms, which completes in minutes. Best fit when the agent is editing three stanzas, not rebuilding the ontology.
  2. Move validation out of the agent — let it commit and open the PR, and let the existing qc.yml PR checks do the real validation. This is what they are for, and what the successful re-run effectively did.

Duplicating CI inside a 30-minute agent budget bounded by a 1-hour app installation token is the wrong division of labour regardless of this bug.

Proposed actions

  • Add a best-practices doc: "Running agents headlessly in CI", covering the no-interactive-turn contract, silent success, and the mitigation layers above
  • Add CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1 to the workflow templates
  • Add a reusable Stop hook + if: always() backstop to the templates
  • Document the 10-minute Bash ceiling and the "let PR checks validate" pattern
  • Add prompt guidance: never wait on background notifications in CI; prefer PR checks over in-agent full builds

Related: #58 (actions template / ODK image)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdocumentationImprovements or additions to documentationenhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions