Hand the pre-release checks to an agent in the evening, read the results in the morning. As an indie developer maintaining several apps alone, I lean on that rhythm more than I probably should. Split the work into stages, delegate each stage to a subagent, and the whole pass should complete while you sleep.
That morning, the screen still looked alive. The spinner was turning. No errors anywhere. The only clue was the timestamp on the last output: more than thirty minutes old.
I opened the subagent's log and went looking for the failure. Nothing unusual. The model was fine, the tools were fine. Everything was simply waiting.
I had been reading the wrong log. The child was not stuck. The parent was.
A healthy child cannot move while the parent is waiting for approval
When a subagent runs on delegation from a parent, the artifacts it produces are handed back to the parent for disposition. If that parent is sitting behind an approval dialog of its own, the child's request never gets its turn.
The child's log gives you nothing but "waiting." Because it is not an error, timeouts may never fire. The run ends up in the quietest possible failure state and stays there until morning.
Google clearly recognized the shape of this problem. In August 2026, an "always proceeds" mode arrived specifically to address subagents that keep waiting while the parent is blocked, by auto-approving their artifacts. Which tells you something useful in reverse: until then, waiting forever was the default behavior.
Not every stalled turn has this cause. When hooks are involved, the triage order is different, and I wrote that case up separately in 2.6.0 Changed How Hooks Wait. A Triage Order for Turns That Never Finish. What follows happens even when you use no hooks at all.
Find the stalled conversations from the log instead of by eye
Reading logs by eye stops working the moment your hierarchy goes past two levels. So I run the log through a script that lists every conversation that issued an approval request and never produced another event.
The August update helps here. Delegated subagents now carry a subagent_info payload containing conversation_id and log_uri, so parent and child can be matched by identifier rather than by guesswork. Each tool call carries tool_info with a normalized tool name, so you can also see what the run is waiting on.
Start by checking which event names actually appear in your own logs. They change between versions.
# types.py - count the event types present in a log
import json, sys, collections
c = collections.Counter()
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
ev = json.loads(line)
except json.JSONDecodeError:
continue
c[ev.get("type", "(no type)")] += 1
for k, v in c.most_common():
print(f"{v:>5} {k}")On my machine (Python 3.10.12) it printed this:
2 approval_request
2 subagent_start
2 tool_call
1 turn_start
1 result
1 heartbeat
Feed those names into the checks in the next script. Record every approval request, drop it when a resolution or a result arrives, and whatever is left is your stall.
#!/usr/bin/env python3
"""Read stream-json lines and list conversations stuck waiting for approval."""
import json, sys
from datetime import datetime, timezone
PENDING = {} # conversation_id -> (timestamp, tool being waited on)
LAST = {} # conversation_id -> last event timestamp
PARENT = {} # child conversation_id -> parent conversation_id
LOGURI = {} # conversation_id -> log_uri
def ts(v):
return datetime.fromisoformat(v.replace("Z", "+00:00"))
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
ev = json.loads(line)
except json.JSONDecodeError:
continue # truncated lines are normal while a run is still going
sub = ev.get("subagent_info") or {}
cid = sub.get("conversation_id") or ev.get("conversation_id")
if not cid:
continue
if sub.get("log_uri"):
LOGURI[cid] = sub["log_uri"]
if sub.get("parent_conversation_id"):
PARENT[cid] = sub["parent_conversation_id"]
t = ts(ev["timestamp"])
LAST[cid] = t
kind = ev.get("type")
if kind == "approval_request":
PENDING[cid] = (t, (ev.get("tool_info") or {}).get("name", "unknown"))
elif kind in ("approval_resolved", "result"):
PENDING.pop(cid, None)
now = max(LAST.values()) if LAST else datetime.now(timezone.utc)
rows = sorted(
((round((now - t).total_seconds()), cid, tool,
PARENT.get(cid, "-"), LOGURI.get(cid, "-"))
for cid, (t, tool) in PENDING.items()),
reverse=True,
)
if not rows:
print("No conversations are stalled on approval")
sys.exit(0)
print(f"{'wait_s':>7} {'conversation_id':<16} {'tool':<12} {'parent':<16} log_uri")
for sec, cid, tool, par, uri in rows:
print(f"{sec:>7} {cid:<16} {tool:<12} {par:<16} {uri}")
blocked = {r[3] for r in rows if r[3] != "-"}
for cid, _ in PENDING.items():
if cid in blocked:
print(f"\n-> {cid} is itself waiting for approval. Its children cannot move until it does.")Running it against a log shaped like that morning produced this:
wait_s conversation_id tool parent log_uri
2028 conv-parent-01 write_file - -
1977 conv-child-a write_file conv-parent-01 file:///logs/conv-child-a.jsonl
-> conv-parent-01 is itself waiting for approval. Its children cannot move until it does.
The ordering is not the interesting part. The last line is. The parent had been stalled for 2,028 seconds and the child for 1,977 — a gap of only 51 seconds. The child had spent almost its entire wait blocked behind the parent.
Add a line resolving the parent's approval, run it again, and the output becomes "No conversations are stalled on approval." Restarting the child alone would not have cleared anything.
You can run this against a log that is still being written. Point it at a copy, or pipe the tail of the file in; the decoder skips the half-written final line rather than aborting. I check it once before I look at anything else, because the answer changes what I open next: a stalled parent means I go resolve a dialog, a stalled child means I go read what the child asked for.
Worth noting what the script deliberately ignores. Heartbeat events keep a conversation's last-seen timestamp fresh without moving the work forward, so "recent activity" is a poor signal on its own. Only the unresolved approval request counts as a stall here.
One disclosure: I assembled that log myself to match the published shape of subagent_info and tool_info as of August. The numbers come from actually running the script, but they were not captured from a production run of the product.
What "always proceeds" actually closes
always proceeds auto-approves artifacts produced by subagents. The child stops waiting even when the parent is tied up with something else.
It helps when the handoff itself is the cause. Nothing was dangerous about the pending approval; the request was simply queued behind another one. In that case a single setting recovers the thirty minutes you lost overnight.
Depth matters too. The same August update made nested subagents visible past the first generation, with tool confirmation requests handled at any depth. Before that, a stall two levels down could sit outside everything you were looking at, which is exactly the case where a scan over identifiers beats scrolling through a transcript.
It does not help with the parent's own dialog. Parent approvals still wait for a human. And if your symptom is the same dialog appearing over and over, the cause lives somewhere else entirely — I covered that in Antigravity Keeps Asking Permission for the Same Command? Fix the Approval Dialog Loop.
| Symptom | What the stall scan shows | Where to intervene |
|---|---|---|
| Child stalled, parent moving | Only the child appears | Inspect the child's request. Revisit tool permissions |
| Parent and child stalled together | Both appear with close wait times | Resolve the parent. Add always proceeds to prevent recurrence |
| The same prompt keeps returning | Stalls appear but wait times stay short and rotate | Revisit how your approval rules match |
| Stalls at grandchild depth or deeper | Rows whose parent column holds a child ID | Confirm approvals are handled at every depth |
Where I stop letting approvals happen automatically
Convenience here is bought with a piece of oversight, so I did not make this a single global decision.
I drew the line at where the output lands. Anything that stays inside the working directory gets auto-approved: reads and analysis, draft generation, scratch files for comparison. If those break, I throw them away and regenerate.
Working alone, I have no second reviewer to catch a bad artifact, so the boundary has to do that job for me. Anything that leaves the repository — signing, distribution, anything touching credentials — I leave for the version of me who shows up in the morning. Finishing overnight feels good, but being able to undo something at 8 a.m. is worth more.
The genuinely hard part is that approval rules often match more broadly than intended. You can define an auto-approval scope carefully and still have the rule let things through that you never meant to include. I learned that the expensive way, and the audit procedure is in The Line I Thought Matched Nothing Was Approving Everything. Confirm what your rules currently permit before you widen anything.
What to leave in place for tomorrow morning
Three things are enough to keep a stalled morning from turning into a lost one.
- Keep the stall scan next to wherever your run logs land. One command in the morning tells you whether to read the parent or the child
- Hold on to
log_urifromsubagent_info. It takes you straight to the stalled child's log instead of hunting for it - Write down your auto-approval scope in terms of where output lands, so you are not re-deciding it every time
Get the location of the stall wrong and you will spend twenty minutes reading a perfectly healthy log. I did exactly that. Next time, start with the parent.
Handing your nights to an agent is a quiet, pleasant way to work when it holds together. I hope this helps you get that quiet back.