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:
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:
- The failure is silent. Exit code 0,
is_error: false, subtype: "success". Nothing in the Actions UI distinguishes it from a successful run.
- 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:
- Targeted pre-check —
robot reason / robot verify on the changed terms, which completes in minutes. Best fit when the agent is editing three stanzas, not rebuilding the ontology.
- 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
Related: #58 (actions template / ODK image)
Summary
Agents run headlessly in GitHub Actions (
claude -pviaclaude-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:
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
ai4c-agent-issue-29437-run10337created locally, never pushedterms/*.obo, never even checked back intogo-edit.oboA near-miss worth noting: in its last two actions the agent ran
ToolSearch("select:Monitor"), loading the schema forMonitor— 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
-pheadless mode there is no interactive user, so no subsequent turn is ever triggered.end_turnterminates 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:
is_error: false,subtype: "success". Nothing in the Actions UI distinguishes it from a successful run.ai-agent.ymlprompt instructs the agent to communicate viaghitself, 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=1Setting this env var removes
run_in_backgroundfrom the Bash tool schema entirely. Tested: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.2.
Stophook — the general fixA
Stophook fires when the agent tries to end its turn; returning{"decision": "block", "reason": "..."}forces it to continue.stop_hook_activein 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:
Both
claude-code-actionandclaude-code-base-actionaccept asettingsinput (JSON string or path to a settings file), which is how hooks get injected: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.
PreToolUsehook, if we prefer not to depend on an undocumented env varThe
PreToolUsehook input includestool_input.run_in_background, so with matcherBash:4. Workflow-level backstop (
if: always())Hooks do not run if the process itself dies. A final step that reads
claude-execution-output.jsonand posts theresultfield 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-actiontoclaude-code-actionv1We 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-actionis now just a mirror of the base-action subdirectory insideclaude-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_buildcannot 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:robot reason/robot verifyon the changed terms, which completes in minutes. Best fit when the agent is editing three stanzas, not rebuilding the ontology.qc.ymlPR 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
CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1to the workflow templatesStophook +if: always()backstop to the templatesRelated: #58 (actions template / ODK image)