[BUG] Stop hook background_tasks is empty while a finished task's notification is still queued, so hooks treat a mid-chain turn end as session done

Status Open
Reported on v2.1.301
Maintainer reply None cached
Activity 0 comments · opened Oct 16, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report
  • [x] I am using the latest version of Claude Code

What's Wrong?

When a background task finishes while a turn is in flight, its <task-notification> is queued, and Claude Code starts another turn with it as soon as the current turn's Stop hooks return. The current turn's Stop payload doesn't include that task: it is already completed, and background_tasks keeps only running/pending entries. So the array is empty although the session is about to wake itself.

The docs say background_tasks lets hooks "distinguish 'session is done' from 'session is paused waiting for background work to wake it back up'". Here it reports "done" while the session is one dequeue away from waking. A notification hook that follows the documented contract sends "Task complete" for the intermediate turn (whose last message is literally "still waiting on B"), then again for the real final turn.

Claude Code's own state disagrees with the payload. In the 2.1.301 bundle, the count behind turn_duration.pendingBackgroundAgentCount treats a task as pending when it is running, or terminal with notified false, plus any task notification in the queue. In the original incident (two background agents), the turn_duration record for this turn end had pendingBackgroundAgentCount: 1 while the Stop payload had no pending entry. The Stop payload builder filters on status === "running" || "pending" only, so the two disagree exactly in this window.

What Should Happen?

The Stop payload should show that background work will still wake the session, for example:

  • use the same rule as pendingBackgroundAgentCount: include terminal tasks with notified: false and tasks whose notification is queued, with their real status, or
  • add a field such as pending_notifications listing queued task notifications.

Error Messages/Logs

Raw hook payloads from the reproduction below, logged by a Stop + UserPromptSubmit hook (local time, trimmed):

{"logged_at": "12:56:12.125", "event": "Stop", "background_tasks": [{"id": "b793t901x", "type": "shell", "status": "running", "description": "Sleep 20s then print A-done"}, {"id": "b9n5spfjh", "type": "shell", "status": "running", "description": "Sleep 21s then print B-done"}], "last_assistant_message": "Started A and B."}
{"logged_at": "12:56:31.316", "event": "UserPromptSubmit", "prompt": "<task-notification>\n<task-id>b793t901x</task-id>…"}
{"logged_at": "12:56:32.779", "event": "Stop", "background_tasks": [], "last_assistant_message": "A finished; still waiting on B."}
{"logged_at": "12:56:32.854", "event": "UserPromptSubmit", "prompt": "<task-notification>\n<task-id>b9n5spfjh</task-id>…"}
{"logged_at": "12:56:34.234", "event": "Stop", "background_tasks": [], "last_assistant_message": "Both done."}

The transcript's queue records for the same turn (UTC):

{"type":"queue-operation","operation":"enqueue","timestamp":"2026-10-16T01:56:31.223Z","content":"<task-notification>\n<task-id>b793t901x</task-id>…"}
{"type":"queue-operation","operation":"dequeue","timestamp":"2026-10-16T01:56:31.251Z"}
{"type":"queue-operation","operation":"enqueue","timestamp":"2026-10-16T01:56:32.218Z","content":"<task-notification>\n<task-id>b9n5spfjh</task-id>…"}
{"type":"assistant","timestamp":"2026-10-16T01:56:32.702Z","message":{"content":[{"type":"text","text":"A finished; still waiting on B."}],"stop_reason":"end_turn"}}
{"type":"system","subtype":"stop_hook_summary","timestamp":"2026-10-16T01:56:32.787Z"}
{"type":"queue-operation","operation":"dequeue","timestamp":"2026-10-16T01:56:32.789Z"}

B's notification was queued 484 ms before the turn ended, the Stop hooks ran with background_tasks: [], and the queued notification was dequeued 2 ms after they returned.

Steps to Reproduce

  1. Add Stop and UserPromptSubmit hooks that append the raw stdin payload to a log file.
  2. Run claude -p with:

> In ONE message, start two Bash commands, both with run_in_background: true: A sleep 20; echo A-done, B sleep 21; echo B-done. Then end your turn with "Started A and B." When a background task finishes, reply with one short sentence naming which task(s) finished so far and, if any are still pending, that you are waiting on them. Do not run any tools. When both have finished, reply "Both done."

  1. The turn started by A's notification ends with "A finished; still waiting on B." Its Stop payload has "background_tasks": [], and B's notification starts the next turn ~75 ms later.

Reproduced on the first attempt. The second task has to finish while the turn started by the first one's notification is still running; a 1 s gap was enough here. The original incident was the same pattern with two background agents (Agent with run_in_background) in an interactive session.

Claude Model

Opus

Is this a regression?

I don't know

Claude Code Version

2.1.301 (Claude Code)

Platform

Anthropic API

Operating System

Windows

Terminal/Shell

VS Code integrated terminal (original incident); reproduced non-interactively with claude -p

Additional Information

Related: #98816 (background_tasks over-reports ambient watchers, the opposite direction of the same field) and #100201 (requested background-task-end hook event; its proposed notifies_model would also cover this).

Workaround we use: replay the transcript's queue-operation records inside the Stop hook. It works because Claude Code dequeues only after the Stop hooks return, but the transcript is flushed on a 100 ms timer, so a task that finishes in the last ~100 ms of the turn is still missed. Only the payload can be exact.

View original on GitHub ↗