ANTIGRAVITY LABJP
Articles/Editor View
Editor View/2026-08-13Intermediate

2.6.0 Changed How Hooks Wait. A Triage Order for Turns That Never Finish

When an agent turn never finishes, the cause is often a hook you wrote rather than the model. Here is what changed in IDE 2.6.0, an audit script that finds hooks capable of stalling a turn, and how to give a hook its own cutoff.

Antigravity351hooks4troubleshooting109CLI6automation88

I have opened my laptop in the morning to find a turn sitting exactly where it was the night before. No error. Nothing red. The turn simply had not ended.

As an indie developer running several repositories, hooks accumulate a few at a time, and every addition adds one more place that can quietly wait.

My instinct was to blame model latency or the network. I held that assumption for longer than I should have. What was actually holding everything up was a hook I had written myself: it handed a judgment off to an external service, and when that service stopped answering, the hook kept waiting.

IDE 2.6.0, released on August 7, changed how that kind of waiting is handled. Let's look at what moved, then build a way to find which of your own hooks can stall a turn, and finally give the hook its own cutoff.

Stalls come in three shapes

Lumping everything under "it hangs" hides the cause. Split by behavior, you get roughly three shapes.

SymptomWhat is happeningHow visible
Turn never advancesA hook is still waiting for a responseLow — nothing reaches the log
Turn almost ends, then resumesA Stop hook keeps refusing to let the turn finishMedium — work repeats
Hook has no effectIt never fires, or fires later than expectedLow — it looks like success

The first and third both stay quiet. One stalls, the other waves things through. The third is the more expensive one, because you find out afterward that something you meant to block went through. I wrote about the over-matching side of that in an audit of hook matchers that fire more often than intended. This piece stays with the stalling half.

What 2.6.0 and CLI 1.1.11 changed about waiting

IDE 2.6.0 and CLI 1.1.11 both shipped on August 7, and between them they cover most of this area.

  • A hook that calls a model now stops with an explicit error at its configured timeout. Previously it could wait indefinitely
  • A Stop hook that repeatedly refuses to end a turn no longer blocks forever; the turn completes after a bounded number of refusals
  • Hook configurations that could never possibly run are now rejected with a clear error at load time instead of being silently ignored
  • Custom hooks now run at the end of the turn rather than being skipped before firing
  • Stopping a subagent now cascades to nested subagents and their background tasks

Read together, this release is less about new capability and more about failing loudly. The flip side matters: on anything older than 2.6.0, a hook without a timeout really can wait forever. Checking your version is the first move.

The hook schema and its key names are still an area under active change. Confirm the wording for your version in the Antigravity changelog before you copy a configuration. The examples below assume a timeoutSec key.

Finding which of your hooks can stall a turn

Reading the config file rarely reveals the risky entries. A line missing timeoutSec is missing it because, at the time, you judged the work to be short. Whether that judgment still holds is not something you can see by looking.

So classify it mechanically instead. The script below checks three things: whether a timeout is configured, whether the script the hook invokes carries its own cutoff, and whether that script can actually start.

#!/usr/bin/env python3
"""Read a hooks config and surface the hooks that can stall a turn."""
import json, os, re, stat, sys
 
SELF_GUARD = re.compile(r"\btimeout\b|\bSIGALRM\b|signal\.alarm|AbortSignal\.timeout")
STALL_EVENTS = {"PreToolUse", "PostToolUse", "Stop", "SubagentStop", "UserPromptSubmit"}
 
 
def load(path):
    with open(path, encoding="utf-8") as f:
        return json.load(f).get("hooks", {})
 
 
def script_of(command):
    """Return the path if the first token of command is one of our scripts."""
    token = command.split()[0] if command.split() else ""
    return token if token.endswith((".sh", ".py", ".js", ".mjs")) else ""
 
 
def audit(path, default_limit=60):
    rows = []
    for event, entries in load(path).items():
        for i, h in enumerate(entries):
            cmd = h.get("command", "")
            limit = h.get("timeoutSec")
            script = script_of(cmd)
            exists = bool(script) and os.path.exists(script)
            execbit = exists and bool(os.stat(script).st_mode & stat.S_IXUSR)
 
            guarded = False
            if exists:
                body = open(script, encoding="utf-8", errors="replace").read()
                guarded = bool(SELF_GUARD.search(body))
 
            if limit is None and not guarded:
                risk = "HIGH"          # nobody owns an upper bound
            elif limit is None:
                risk = "MED"           # only the script owns one
            elif limit > default_limit and not guarded:
                risk = "MED"           # the bound exists but is generous
            else:
                risk = "LOW"
 
            # A hook that cannot start is a different problem from a slow one
            if script and not exists:
                risk = "BROKEN"
            elif exists and not execbit:
                risk = "BROKEN"
 
            rows.append({
                "event": event, "risk": risk, "timeoutSec": limit,
                "selfGuard": guarded, "command": cmd,
                "blocksTurn": event in STALL_EVENTS,
            })
    return rows
 
 
def main():
    path = sys.argv[1] if len(sys.argv) > 1 else "hooks.json"
    rows = audit(path)
    order = {"BROKEN": 0, "HIGH": 1, "MED": 2, "LOW": 3}
    rows.sort(key=lambda r: (order[r["risk"]], r["event"]))
 
    print(f"{'risk':<7}{'event':<16}{'timeoutSec':<12}{'selfGuard':<11}command")
    for r in rows:
        t = "-" if r["timeoutSec"] is None else str(r["timeoutSec"])
        print(f"{r['risk']:<7}{r['event']:<16}{t:<12}{str(r['selfGuard']):<11}{r['command']}")
 
    bad = sum(1 for r in rows if r["risk"] in ("BROKEN", "HIGH"))
    print(f"\n{bad} of {len(rows)} hooks can stall a turn.")
    return 1 if bad else 0
 
 
if __name__ == "__main__":
    sys.exit(main())

SELF_GUARD looks for timeout or signal.alarm inside the script body because the configured timeoutSec and the script's own cutoff live in different layers. Either one is enough to prevent an unbounded wait, so only entries missing both drop to HIGH.

Run against a config holding four hooks:

risk   event           timeoutSec  selfGuard  command
BROKEN PostToolUse     30          False      ./fmt.sh
HIGH   PreToolUse      -           False      ./review.sh
MED    Stop            120         False      ./review.sh
LOW    PreToolUse      600         True       ./gate.sh

2 of 4 hooks can stall a turn.

The BROKEN row has a timeoutSec but had lost its execute bit. A hook in that state never gets as far as waiting, so from the outside it looks fast. It is not fast. It is absent.

Put the cutoff in both layers

There are two reasons not to lean on the configured timeoutSec alone. Version-to-version, how strictly it is honored is still moving. And if the same script is also invoked from CI or a local git hook, that path never sees the configured bound at all.

Here is the script-side version.

#!/usr/bin/env bash
# A slow judgment the hook itself can abandon
set -u
LIMIT="${HOOK_LIMIT_SEC:-3}"
 
timeout --signal=TERM --kill-after=2 "$LIMIT" bash -c 'sleep 8; echo "judge: ok" >&2'
RC=$?          # captured immediately, not wrapped in an if
 
case "$RC" in
  0)   exit 0 ;;
  124) echo "hook: no judgment within ${LIMIT}s. Skipping the check this time." >&2; exit 0 ;;
  *)   echo "hook: the judgment failed with exit code ${RC}." >&2; exit "$RC" ;;
esac

--kill-after=2 is the grace period before KILL follows TERM. Without it, a child that ignores TERM can defeat the cutoff you just added.

Running an eight-second job under different limits, the wall time tracks the limit closely.

LimitActual elapsedExit code
1s1.01s0 (skipped)
3s3.01s0 (skipped)
5s5.01s0 (skipped)
no limit8.01s0 (completed)

Mapping 124 to exit 0 is a deliberate choice. "The judgment did not come back" and "the judgment said no" are different facts. Blocking the tool call on the former means one flaky external service can stop the agent from moving at all. For a gate protecting an irreversible action, the same 124 should probably become exit 1 instead — I wrote about that stricter posture in a two-layer gate placed in front of push. Identical exit code, opposite answer, depending on what you are protecting.

Wrapping it in if makes 124 disappear

There is a reason RC=$? sits outside the if above. My first version read the obvious way:

if timeout --signal=TERM "$LIMIT" bash -c '...'; then
  exit 0
fi
RC=$?

It reads fine. It also never puts 124 into RC.

if timeout 1 sleep 5; then :; fi
echo "after the if block, \$? = $?"   # → 0
 
timeout 1 sleep 5
echo "captured directly, \$?   = $?"  # → 124

An if statement whose condition is false and which has no else exits with 0. So the $? you read after fi is the status of the if statement, not of the command inside it. A guard written specifically to detect a cutoff was swallowing the cutoff and reporting success.

A hook's exit code is its reply to the agent. Returning 0 there tells the agent that a check which timed out has passed. Adding the guard had quietly added one more path straight through.

The same thing happens with if ! cmd; then RC=$?. Negating the command resets $? to 0. If you need the exit code, capture it on the line right after the command — there is no reliable alternative.

Work the triage in this order

Fixing the order you investigate in saves the deliberation each time.

  1. Check the version. Anything before 2.6.0 can wait indefinitely when no timeout is set
  2. Run the audit script and clear the HIGH and BROKEN rows first. This often removes half the candidates
  3. Run each remaining suspect straight from the command line, outside the agent. If it does not return by hand, the problem is inside the hook
  4. If it returns standalone but stalls inside a turn, suspect a Stop hook refusal loop. Look for the same work repeating
  5. Only once those come back clean should you start suspecting the model or the network

The point of the order is to clear the quiet failures first. Steps 1 and 2 take a few minutes and need no reproduction.

Where to start

Run the audit script over your config once. If no HIGH row appears, you have at least eliminated the unbounded-wait path.

I ran that if-wrapped guard for a while myself. Mistakes of this shape survive precisely because the person who wrote them believes the situation is now handled. It may be worth checking whether the same pattern is sitting in your own hooks.

Share

Thank You for Reading

Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

Agents & Manager2026-07-15
When Your Agent Commits a .bak File: Why Fix-Tool Artifacts End Up in Git
Backup files like .bak and .orig slip into commits after an agent runs a --fix tool. Here are the reproduction conditions, the real root cause, and three fixes: narrowing the staged scope, wrapping the fixer, and adding a pre-commit extension gate.
Antigravity2026-06-24
Antigravity 2.0, CLI, IDE, SDK — Weaving All Four Surfaces Through a Real Project
Antigravity ships as a desktop app, a CLI, an IDE, and a Python SDK. Beyond picking one, this guide shows how to weave all four across a single project — with a headless-execution wrapper for automation, plus the cost and migration traps to sidestep.
Integrations2026-06-17
When the Antigravity CLI Stalls on a 401 During Unattended Runs
If your scheduled Antigravity CLI job suddenly stops producing output after a single 401 in the logs, here is how to separate an expired token from a silent re-login prompt and rebuild your unattended setup.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →