[BUG] Resuming a background subagent swaps the header of absorbed task notifications, forcing a messages_changed cache rebuild

Status Open
Reported on v2.1.280
Maintainer reply None cached
Activity 0 comments · opened Sep 24, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

When a background task finishes while a background subagent is in the middle of a turn, the task's <task-notification> is absorbed into that turn. It is sent with the task-notification preamble, which begins:

[SYSTEM NOTIFICATION - NOT USER INPUT]
This is an automated background-task event, NOT a message from the user.

The block keeps those bytes on every later request of the run. After the subagent goes idle and is resumed, the first request sends the same notification with the generic non-user header instead:

[MESSAGE FROM NON-USER SOURCE - NOT USER INPUT]

The preamble is 494 characters and the replacement is 48, so the message shrinks by 446 characters. Nothing else in the prefix changes. The API reports messages_changed, and the subagent writes most of its history to the cache again. Every absorbed notification in the history flips in that one request. A block that has flipped stays short at later resumes, but a notification absorbed after a resume flips at the next one.

One account's transcripts for the week to 2026-09-23 show four of these rebuilds on 2.1.280. Each read about 40,000 tokens from the cache and wrote the rest of the history again:

| flipped message index | message length (chars) | cache_read_input_tokens | cache_creation_input_tokens |
|---|---|---|---|
| 40 | 1,242 to 796 | 40,774 | 150,582 |
| 69 | 1,370 to 924 | 40,774 | 230,620 |
| 106 | 1,234 to 788 | 39,612 | 178,840 |
| 52 | 1,318 to 872 | 41,384 | 208,178 |

Together that is 768,220 cache-creation tokens for four resumes.

What Should Happen?

A notification that has already been sent should keep the same bytes for the life of the conversation, including across a resume. The first request after a resume should then read the cache that the subagent's original run warmed.

Error Messages/Logs

$ bash repro.sh ./claude   # stock 2.1.281, sha256 56fe3da88458465f...
binary exit=0
requests logged: 9  by class: {"main": 4, "sub": 5}
-- stream main
   req   1 step=0 msgs=2 reply=tool_use:Agent | -
   req   2 step=1 msgs=5 reply=text:main done | -
   req   7 step=2 msgs=7 reply=text:main done | a8f6c8b5f352898df@msg6(user):rWe
   req   9 step=3 msgs=9 reply=text:main done | a8f6c8b5f352898df@msg6(user):rWe, a8f6c8b5f352898df@msg8(user):rWe
-- stream sub
   req   3 step=0 msgs=2 reply=tool_use:Bash | -
   req   4 step=1 msgs=5 reply=tool_use:Bash | -
   req   5 step=2 msgs=8 reply=tool_use:Bash | bpaosrtto@msg7(system):rWe
   req   6 step=3 msgs=11 reply=text:Waiting for T2 to finish. | bpaosrtto@msg7(system):rWe
   req   8 step=4 msgs=13 reply=text:All done. | bpaosrtto@msg7(system):sG, bgmn9bfv1@msg12(user):rWe
resume boundary 1: req 6 (last before idle) -> req 8 (first after resume); shared notifications: 1
FLIP task=bpaosrtto msg=7 role=system->system: head rWe (1071 chars) -> sG (625 chars), delta=446
RED: a delivered task-notification re-rendered with a different header after the resume

Steps to Reproduce

  1. From the main loop, launch a subagent with the Agent tool and run_in_background: true.
  2. In the subagent, start a short background Bash command (sleep 1, run_in_background: true). Then run a foreground command that outlasts it (sleep 5). The background command finishes mid-turn, so its notification is absorbed into the turn rather than waking the agent later.
  3. Start a second background command (sleep 4, run_in_background: true) and end the turn. The subagent goes idle.
  4. When the second command finishes, its notification resumes the subagent. A SendMessage to the idle subagent resumes it the same way.
  5. Compare the subagent's last request before it went idle with its first request after the resume. The first notification's header has changed from the long preamble to the short one.

The harness below runs these steps deterministically against a scripted local stand-in for the Messages API (ANTHROPIC_BASE_URL points at 127.0.0.1). It needs no account and makes no real API calls. It runs the binary with HOME, CLAUDE_CONFIG_DIR and TMPDIR inside its own run folder, and writes every request body there. The labels rWe and sG in its output are the 2.1.280 bundle names of the long preamble and the short header.

bash repro.sh /path/to/claude
# exit 1: the header flipped; 0: it held; 2: inconclusive; 3: harness error

<details><summary>repro.sh</summary>

#!/bin/bash
# Drive a Claude Code native binary against the scripted mock API and assert
# whether a delivered task-notification keeps its header across requests.
# Usage: repro.sh <claude binary> ; exit 1 = RED (flip), 0 = GREEN, 2 = inconclusive, 3 = harness error.
# The binary runs fully isolated: HOME, CLAUDE_CONFIG_DIR and the temp roots all
# point inside the run directory, and the API base URL is a local mock.
set -u
E=$(cd "$(dirname "$0")" && pwd)
BIN=${1:?usage: repro.sh <path to the claude binary>}
MODEL=${MODEL:-claude-opus-5-5}
HOLD=${HOLD:-15}
RUN=$E/runs/$(date -u +%Y%m%dT%H%M%SZ)-$$
mkdir -p "$RUN/home" "$RUN/tmp" "$RUN/cfg" "$RUN/work" "$RUN/mock" || exit 3
echo "run dir: $RUN"
echo "binary:  $BIN"
python3 "$E/mock_api.py" --log-dir "$RUN/mock" --port-file "$RUN/port" --resumes "${RESUMES:-1}" > "$RUN/mock.log" 2>&1 &
MOCK=$!
trap 'kill $MOCK 2>/dev/null' EXIT
for _ in $(seq 1 100); do [ -s "$RUN/port" ] && break; sleep 0.1; done
[ -s "$RUN/port" ] || { echo "mock did not start"; cat "$RUN/mock.log"; exit 3; }
PORT=$(cat "$RUN/port")
cd "$RUN/work" || exit 3
# stdin stays open for HOLD seconds so the stream-json session outlives the
# subagent's idle period and the resume that follows it.
{ printf '%s\n' '{"type":"user","message":{"role":"user","content":"MAIN_MARKER_7Q start the background worker"}}'; sleep "$HOLD"; } |
env -i HOME="$RUN/home" PATH=/usr/local/bin:/usr/bin:/bin SHELL=/bin/bash TERM=dumb LANG=C.UTF-8 \
  TMPDIR="$RUN/tmp" CLAUDE_CODE_TMPDIR="$RUN/tmp" CLAUDE_CONFIG_DIR="$RUN/cfg" \
  ANTHROPIC_BASE_URL="http://127.0.0.1:$PORT" ANTHROPIC_API_KEY=mock-key-000 \
  DISABLE_AUTOUPDATER=1 CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 DISABLE_TELEMETRY=1 DISABLE_ERROR_REPORTING=1 \
  ${EXTRA_ENV:-} \
  timeout 120 "$BIN" -p --input-format stream-json --output-format stream-json --verbose --model "$MODEL" \
  --dangerously-skip-permissions --setting-sources user \
  --debug-file "$RUN/debug.log" > "$RUN/stdout.jsonl" 2> "$RUN/stderr.txt"
echo "binary exit=$?"
kill $MOCK 2>/dev/null
python3 "$E/check_flip.py" "$RUN"

</details>

<details><summary>mock_api.py</summary>

"""Scripted Anthropic Messages API stand-in for driving a Claude Code binary.

Scenario bg-resume:
  main loop: step0 spawns a background Agent; later steps end the turn, so the
  main loop sits idle while the subagent runs (the caller keeps stdin open so the
  stream-json session stays alive).
  subagent:  step0 starts background Bash T1; step1 runs a foreground sleep so T1
  completes mid-turn; step2 starts background Bash T2; step3 ends the turn while
  T2 is still running; step4+ (after T2's notification resumes the agent) ends.

Every request body is written to <log-dir>/req-NNNN.json and summarised in
<log-dir>/index.jsonl.
"""
import argparse
import json
import os
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

MAIN_MARKER = "MAIN_MARKER_7Q"
SUB_MARKER = "SUBAGENT_MARKER_7Q"

LOCK = threading.Lock()
COUNTER = [0]
ARGS = None


def all_text(x, out):
    if isinstance(x, str):
        out.append(x)
    elif isinstance(x, dict):
        for v in x.values():
            all_text(v, out)
    elif isinstance(x, list):
        for v in x:
            all_text(v, out)
    return out


def classify(body):
    tools = body.get("tools") or []
    names = [t.get("name") for t in tools if isinstance(t, dict)]
    if "Bash" not in names:
        return "other", names
    first_user = next((m for m in body.get("messages", []) if isinstance(m, dict) and m.get("role") == "user"), {})
    text = "\n".join(all_text(first_user.get("content", ""), []))
    if SUB_MARKER in text:
        return "sub", names
    if MAIN_MARKER in text:
        return "main", names
    return "other", names


def n_assistant(body):
    return sum(1 for m in body.get("messages", []) if isinstance(m, dict) and m.get("role") == "assistant")


def plan(cls, step, names, sub_prompt):
    agent_tool = "Agent" if "Agent" in names else ("Task" if "Task" in names else None)
    if cls == "main":
        if step == 0 and agent_tool:
            return ("tool_use", agent_tool, {
                "description": "background worker",
                "prompt": sub_prompt,
                "subagent_type": "general-purpose",
                "run_in_background": True,
            })
        return ("text", "main done", None)
    if cls == "sub":
        if step == 0:
            return ("tool_use", "Bash", {"command": "sleep 1; echo T1-done", "description": "T1 background",
                                         "run_in_background": True})
        if step == 1:
            return ("tool_use", "Bash", {"command": "sleep 5; echo fg-done", "description": "foreground wait"})
        if step == 2:
            return ("tool_use", "Bash", {"command": "sleep 4; echo T2-done", "description": "T2 background",
                                         "run_in_background": True})
        if step == 3:
            return ("text", "Waiting for T2 to finish.", None)
        if ARGS.resumes >= 2 and step == 4:
            return ("tool_use", "Bash", {"command": "sleep 3; echo T3-done", "description": "T3 background",
                                         "run_in_background": True})
        if ARGS.resumes >= 2 and step == 5:
            return ("text", "Waiting for T3 to finish.", None)
        return ("text", "All done.", None)
    return ("text", "ok", None)


def sse(event, data):
    return f"event: {event}\ndata: {json.dumps(data)}\n\n".encode()


class H(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def log_message(self, fmt, *a):
        sys.stderr.write("[mock] " + (fmt % a) + "\n")

    def _json(self, code, obj):
        b = json.dumps(obj).encode()
        self.send_response(code)
        self.send_header("content-type", "application/json")
        self.send_header("content-length", str(len(b)))
        self.end_headers()
        self.wfile.write(b)

    def do_GET(self):
        self._json(200, {})

    def do_HEAD(self):
        self.send_response(200)
        self.send_header("content-length", "0")
        self.end_headers()

    def do_POST(self):
        ln = int(self.headers.get("content-length") or 0)
        raw = self.rfile.read(ln) if ln else b""
        path = self.path
        if "/count_tokens" in path:
            return self._json(200, {"input_tokens": 1000})
        if not path.split("?")[0].endswith("/v1/messages"):
            with LOCK:
                with open(os.path.join(ARGS.log_dir, "other-posts.log"), "a") as f:
                    f.write(f"{time.time():.3f} {path} {len(raw)}\n")
            return self._json(404, {"type": "error", "error": {"type": "not_found_error", "message": path}})
        try:
            body = json.loads(raw)
        except Exception as e:
            return self._json(400, {"type": "error", "error": {"type": "invalid_request_error", "message": str(e)}})
        cls, names = classify(body)
        step = n_assistant(body)
        kind, a, b = plan(cls, step, names, ARGS.sub_prompt)
        with LOCK:
            COUNTER[0] += 1
            n = COUNTER[0]
        fn = os.path.join(ARGS.log_dir, f"req-{n:04d}.json")
        with open(fn, "w") as f:
            json.dump(body, f)
        with LOCK:
            with open(os.path.join(ARGS.log_dir, "index.jsonl"), "a") as f:
                f.write(json.dumps({"n": n, "t": time.time(), "path": path, "cls": cls, "step": step,
                                    "n_msgs": len(body.get("messages", [])), "stream": bool(body.get("stream")),
                                    "reply": [kind, a], "file": os.path.basename(fn)}) + "\n")
        mid = f"msg_mock_{n:04d}"
        model = body.get("model", "mock")
        usage = {"input_tokens": 100, "output_tokens": 5, "cache_creation_input_tokens": 0,
                 "cache_read_input_tokens": 0}
        if kind == "tool_use":
            block = {"type": "tool_use", "id": f"toolu_mock_{n:04d}", "name": a, "input": b}
            stop = "tool_use"
        else:
            block = {"type": "text", "text": a}
            stop = "end_turn"
        if not body.get("stream"):
            return self._json(200, {"id": mid, "type": "message", "role": "assistant", "model": model,
                                    "content": [block], "stop_reason": stop, "stop_sequence": None,
                                    "usage": usage})
        self.send_response(200)
        self.send_header("content-type", "text/event-stream")
        self.send_header("cache-control", "no-cache")
        self.send_header("request-id", f"req_mock_{n:04d}")
        self.send_header("connection", "close")
        self.end_headers()
        out = [sse("message_start", {"type": "message_start", "message": {
            "id": mid, "type": "message", "role": "assistant", "model": model, "content": [],
            "stop_reason": None, "stop_sequence": None, "usage": usage}})]
        if kind == "tool_use":
            out.append(sse("content_block_start", {"type": "content_block_start", "index": 0, "content_block": {
                "type": "tool_use", "id": block["id"], "name": a, "input": {}}}))
            out.append(sse("content_block_delta", {"type": "content_block_delta", "index": 0, "delta": {
                "type": "input_json_delta", "partial_json": json.dumps(b)}}))
        else:
            out.append(sse("content_block_start", {"type": "content_block_start", "index": 0, "content_block": {
                "type": "text", "text": ""}}))
            out.append(sse("content_block_delta", {"type": "content_block_delta", "index": 0, "delta": {
                "type": "text_delta", "text": a}}))
        out.append(sse("content_block_stop", {"type": "content_block_stop", "index": 0}))
        out.append(sse("message_delta", {"type": "message_delta", "delta": {"stop_reason": stop,
                                                                             "stop_sequence": None},
                                         "usage": {"output_tokens": 5}}))
        out.append(sse("message_stop", {"type": "message_stop"}))
        try:
            self.wfile.write(b"".join(out))
            self.wfile.flush()
        except BrokenPipeError:
            pass
        self.close_connection = True


def main():
    global ARGS
    p = argparse.ArgumentParser()
    p.add_argument("--port", type=int, default=0)
    p.add_argument("--log-dir", required=True)
    p.add_argument("--port-file", required=True)
    p.add_argument("--sub-prompt", default=f"{SUB_MARKER} run the scripted steps.")
    p.add_argument("--resumes", type=int, default=1)
    ARGS = p.parse_args()
    if not os.path.isabs(ARGS.log_dir):
        sys.exit("log-dir must be absolute")
    os.makedirs(ARGS.log_dir, exist_ok=True)
    srv = ThreadingHTTPServer(("127.0.0.1", ARGS.port), H)
    with open(ARGS.port_file, "w") as f:
        f.write(str(srv.server_address[1]))
    srv.serve_forever()


if __name__ == "__main__":
    main()

</details>

<details><summary>check_flip.py</summary>

"""Assert whether a subagent's delivered task-notification keeps its bytes across a resume.

Reads <run>/mock/index.jsonl and the logged request bodies. The resume request is
the first subagent request that follows a subagent request answered with text
(end_turn), i.e. the first request after the idle agent was resumed. Every
<task-notification> occurrence, keyed by (task-id, message index), that appears
both in the last request before the resume and in the resume request is compared
byte for byte, from its header through </task-notification>.

Exit 1 RED: an occurrence re-rendered across the resume.
Exit 0 GREEN: all shared occurrences are byte-identical.
Exit 2 INCONCLUSIVE: no resume request, or no occurrence shared across it.
"""
import json
import os
import re
import sys

RWE_HEAD = "[SYSTEM NOTIFICATION - NOT USER INPUT]"
RWE = ("[SYSTEM NOTIFICATION - NOT USER INPUT]\nThis is an automated background-task event, NOT a message "
       "from the user.\n")
SG = "[MESSAGE FROM NON-USER SOURCE - NOT USER INPUT]\n"


def strings(x, path, out):
    if isinstance(x, str):
        out.append((path, x))
    elif isinstance(x, dict):
        for k, v in x.items():
            strings(v, path + [k], out)
    elif isinstance(x, list):
        for i, v in enumerate(x):
            strings(v, path + [i], out)
    return out


def header(prefix):
    tail = prefix[-700:]
    if tail.endswith(SG):
        return "sG", SG
    i = tail.rfind(RWE_HEAD)
    if i != -1 and tail[i:].startswith(RWE):
        return "rWe", tail[i:]
    return "other:" + repr(tail[-60:]), ""


def notifications(body):
    found = {}
    for path, s in strings(body.get("messages", []), [], []):
        for m in re.finditer(r"<task-notification>\s*<task-id>([^<]+)</task-id>.*?</task-notification>", s, re.S):
            msg_idx = path[0] if path else None
            role = body["messages"][msg_idx].get("role") if isinstance(msg_idx, int) else None
            kind, head_text = header(s[:m.start()])
            found[(m.group(1), msg_idx)] = {"role": role, "head": kind, "text": head_text + m.group(0)}
    return found


def main():
    run = sys.argv[1]
    idx_path = os.path.join(run, "mock", "index.jsonl")
    rows = [json.loads(l) for l in open(idx_path)] if os.path.exists(idx_path) else []
    print(f"requests logged: {len(rows)}  by class: " +
          json.dumps({c: sum(1 for r in rows if r['cls'] == c) for c in sorted({r['cls'] for r in rows})}))
    bodies = {}
    for cls in ("main", "sub"):
        seq = [r for r in rows if r["cls"] == cls]
        if seq:
            print(f"-- stream {cls}")
        for r in seq:
            ns = notifications(json.load(open(os.path.join(run, "mock", r["file"]))))
            bodies[r["n"]] = ns
            desc = ", ".join(f"{t}@msg{mi}({v['role']}):{v['head']}" for (t, mi), v in ns.items()) or "-"
            print(f"   req {r['n']:>3} step={r['step']} msgs={r['n_msgs']} reply={r['reply'][0]}:{r['reply'][1]} | {desc}")
    sub = [r for r in rows if r["cls"] == "sub"]
    resumes = [(a, b) for a, b in zip(sub, sub[1:]) if a["reply"][0] == "text"]
    if not resumes:
        print("INCONCLUSIVE: the subagent was never resumed after ending its turn")
        sys.exit(2)
    flips = 0
    compared = 0
    for k, (pre, post) in enumerate(resumes, 1):
        shared = sorted(set(bodies[pre["n"]]) & set(bodies[post["n"]]), key=str)
        print(f"resume boundary {k}: req {pre['n']} (last before idle) -> req {post['n']} (first after resume); "
              f"shared notifications: {len(shared)}")
        for key in shared:
            a, b = bodies[pre["n"]][key], bodies[post["n"]][key]
            same = a["text"] == b["text"]
            flips += not same
            compared += 1
            print(f"{'SAME' if same else 'FLIP'} task={key[0]} msg={key[1]} role={a['role']}->{b['role']}: "
                  f"head {a['head']} ({len(a['text'])} chars) -> {b['head']} ({len(b['text'])} chars), "
                  f"delta={len(a['text']) - len(b['text'])}")
    if not compared:
        print("INCONCLUSIVE: no task-notification was re-sent across a resume")
        sys.exit(2)
    if flips:
        print("RED: a delivered task-notification re-rendered with a different header after the resume")
        sys.exit(1)
    print("GREEN: every task-notification re-sent across the resume kept its bytes")
    sys.exit(0)


if __name__ == "__main__":
    main()

</details>

Claude Model

Not sure / Multiple models

Is this a regression?

I don't know

Last Working Version

None found. On 2.1.198, 2.1.247 and 2.1.251 the absorbed notification does not survive the resume at all, so the header cannot flip there.

Claude Code Version

2.1.281 (Claude Code)

Platform

Anthropic API

Operating System

Ubuntu/Debian Linux

Terminal/Shell

Non-interactive/CI environment

Additional Information

Root cause. Identifiers are from the 2.1.280 bundle. Two code paths disagree about what a missing origin means on a queued command whose mode is task-notification.

  1. Absorbed notifications are stored without an origin. The shell, Monitor and agent-completion enqueuers set mode: "task-notification" and no origin. The mid-turn queued_command attachment builder then stores commandMode: h.mode, origin: OB(h.origin), which leaves origin undefined. The idle-delivery paths store origin: cce(e.origin, e.mode) instead. A notification that arrives after the subagent settles therefore becomes the wake prompt with an explicit {kind: "task-notification"} origin, and it is not affected. In 3,236 local transcripts, 0 of 999 absorbed task-notification records carry an origin key.
  2. The first render falls back to the mode. The render resolves the origin with cce, and the result is memoized in the record's rendered field:

``js
function cce(e,n){return OB(e)??(n==="task-notification"?{kind:"task-notification"}:void 0)}
``

  1. The resume sanitizer demotes the missing origin. resumeAgentBackground passes the loaded history through tYt. The call is zhe(P.messages),tYt(P.messages), just after the disk transcript missing; using N in-memory messages mirrored during the run fallback. For each queued_command attachment, tYt deletes rendered and renderedInHumanTurn. It then stamps the origin without reading commandMode:

``js
let l=r.attachment.origin;if(l==null||l.kind==null||l.kind==="human"||l.kind==="auto-continuation")r.attachment.origin={kind:"unclassified"}
``

  1. The fresh render takes the other branch. {kind: "unclassified"} is not nullish, so cce no longer falls back to the mode. The header switch takes case "unclassified", which prepends [MESSAGE FROM NON-USER SOURCE - NOT USER INPUT]\n. The stamp is made in memory only, so each later resume stamps it again and the block stays short.

A main-loop --resume does not call tYt, and there the block keeps the long preamble. The cold resume of an in-process teammate also calls tYt. That path was read in the code but not run.

Suggested fix. In the mid-turn builder, store origin: cce(h.origin, h.mode), as the idle-delivery paths already do. For records already on disk, have the sanitizer give a missing origin on a task-notification command the kind that cce would have derived:

r.attachment.origin = l==null && r.attachment.commandMode==="task-notification"
  ? {kind:"task-notification"}
  : {kind:"unclassified"}

Every other case is stamped as before: explicit human, auto-continuation and kind-less origins, and commands of any other mode. The absorbed notification gets the origin kind that the idle-delivery path already stores for a notification. Both kinds render the block as non-user meta content, so only the header changes, not how the block is treated.

On a local 2.1.280 build, adding only the sanitizer change as a byte edit turned the harness from flipping to holding. It held across one resume, across two resumes and across a SendMessage resume. Over five resume boundaries, no message in the shared prefix differed, and the system prompt and tools were unchanged, ignoring cache_control markers.

Versions. Reproduced with the harness on stock 2.1.270, 2.1.278, 2.1.280 and 2.1.281. The sanitizer code is unchanged in 2.1.281. Builds of 2.1.261 and 2.1.267 also flipped, but they were not stock downloads. The production rebuilds above came from Linux under WSL2, and the harness runs headless.

Related, not duplicate. #94728 reports a messages_changed miss on the first request after a background subagent is resumed, on the same resume path. Its 2.1.273 case is dropped thinking blocks. It also notes that the resume deletes each attachment's stored rendered text and re-renders it, which is the step where this bug happens. Here the history is complete and only the header of absorbed notifications changes. That report's 2.1.269 misses without a size change are left unexplained there. This bug produces a messages_changed miss at the first resume of any subagent that absorbed a notification mid-turn, so it may account for some of them. That was not checked.

View original on GitHub ↗