[BUG] Agent Teams: lead session loops on idle notifications and duplicate task_assignment echoes, burns ~13–22% of input tokens on no-op acks

Status Open
Reported on v2.1.107
Maintainer reply None cached
Activity 7 comments · opened Apr 14, 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 orchestrating a team of 5–8 teammates via Agent Teams (CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1), the lead session gets wedged into an acknowledgement loop that costs a full lead turn for every notification:

  1. Idle-notification turns. After every teammate turn ends, the lead receives a {"type":"idle_notification","from":"dev-N","idleReason":"available"} message as a fresh turn. For 8 teammates completing 2–3 turns each, this produces 20–40 lead-turn firings whose entire work is "acknowledge idle."
  2. Duplicate task_assignment echoes. Teammates report receiving stale/self-originating dispatches for tasks they've already completed. Each echo triggers a teammate turn → a new completion message → another lead turn → another idle notification loop. One teammate wrote: "Received a task_assignment for #3 that appears to be from my own agent ID (likely an echo/stale queue message) — ignoring."
  3. Lead never gracefully stops reacting. Even after every task is marked completed, idle notifications keep arriving for several minutes. The lead keeps answering each one.

Net effect: a piece of coordination work that should take ~6–8 lead turns takes 60–100. Token consumption scales with the product (teammates × notifications), not with actual work.

Token-cost evidence from a real session

I instrumented the transcript JSONL of a single session that orchestrated 8 teammates (1× TeamCreate + 5 TaskCreate initial + 3 incremental tasks, all tasks completed, no rework). Classified every lead turn by what message triggered it:

| Trigger | Turns | Input tokens | % of teams-portion |
|---|---:|---:|---:|
| Pure idle-notification (lead "ack") | 13 | 2,218,178 | 9.5 % |
| Pure duplicate/echo ack | 5 | 806,114 | 3.5 % |
| Human-typed instruction | 12 | 2,280,630 | 9.8 % |
| Tool-result follow-up (useful work) | 94 | 15,824,993 | 68.0 % |
| Lead woke by teammate msg, no human input | 14 | 2,153,791 | 9.3 % |
| TOTAL (teams portion) | 138 | 23,283,706 | 100 % |

Strict noise from the loop bug (idle-only + duplicate-ack turns where the human did not instruct anything):

18 turns / 3.03 M input tokens = 13.0 % of the Teams-portion input spend, paid purely to say "acknowledged" to notifications the skill docs say I should ignore.

Broader noise (every turn where the lead was woken by a teammate message with no human instruction in the same turn):

32 turns / 5.18 M input tokens = 22.2 % of the Teams-portion input spend.

For comparison, the same session before entering Teams mode (the ~682 pre-team turns doing regular Agent tool calls, reads, edits, etc.) averaged similar per-turn input (~157 K vs ~167 K), but had 0 idle-ack turns and 0 duplicate-completion acks. The loop behaviour is unique to Teams mode.

Token multiplier vs equivalent non-team dispatch: comparable work done in earlier sessions via plain Agent(run_in_background: true) × 5 (no TeamCreate, no shared task list) completes in ~30–40 lead turns with no idle/echo cost. Teams-mode paid ~138 turns for the same delivered artifacts — a ~3–4× lead-turn inflation, roughly half of which is the loop bug.

What Should Happen?

One or more of:

  1. Idle-notification deliveries should not generate a new lead turn by default. Treat them like a presence update — surface in UI, but do not wake the lead model.
  2. task_assignment dispatches should be suppressed when the target task status is already completed, ideally at the team service layer before the teammate mailbox.
  3. Self-originated deliveries (sender agentId == recipient agentId) should be dropped on send.
  4. Provide an env toggle like CLAUDE_CODE_TEAM_LEAD_SUPPRESS_IDLE=1 for users who want current behaviour off until a better default ships.

Error Messages/Logs

No error. The waste is silent. Representative trace of what the lead sees, back to back, each as its own lead turn (sanitized):

<teammate-message teammate_id="dev-3" color="yellow">
{"type":"idle_notification","from":"dev-3","timestamp":"2026-04-14T12:37:38.194Z","idleReason":"available"}
</teammate-message>

<teammate-message teammate_id="dev-4" color="purple" summary="Task #4 already completed — no-op">
Received what looks like a re-echo of the original task #4 assignment
(sender field is my own ID). Task #4 is already marked completed — no
additional work performed. Standing by.
</teammate-message>

<teammate-message teammate_id="dev-4" color="purple">
{"type":"idle_notification","from":"dev-4","timestamp":"2026-04-14T12:37:58.164Z","idleReason":"available"}
</teammate-message>

<teammate-message teammate_id="dev-3" color="yellow" summary="Task #3 already done — no-op on re-assignment">
Received a task_assignment echo for #3, but that task is already
completed. Status confirmed via TaskGet: #3 = completed. No further
action — standing by.
</teammate-message>

<teammate-message teammate_id="dev-3" color="yellow">
{"type":"idle_notification","from":"dev-3","timestamp":"2026-04-14T12:38:00.473Z","idleReason":"available"}
</teammate-message>

Each of the five blocks above arrived as a separate lead-model turn (~167 K input tokens each at cache-read rates). The lead's reply to each is effectively "acknowledged" — and then another idle notification arrives.

Steps to Reproduce

  1. Enable Agent Teams: "env": { "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1" } in ~/.claude/settings.json.
  2. From a lead session, run:

``
TeamCreate(team_name: "repro")
TaskCreate x 5 # distinct tasks, no dependencies
Agent(subagent_type: "fullstack-developer", model: "opus", run_in_background: true,
team_name: "repro", name: "dev-N") # x 5
``

  1. Let the 5 teammates complete their tasks.
  2. Watch the lead transcript: for every teammate that marks a task completed, expect 2–4 idle_notification turns and 1–2 task_assignment echo responses, each as its own lead turn.
  3. Grep your session JSONL for "idle_notification" and "already completed" to quantify noise-vs-signal.

Pressing Esc on the lead breaks the loop (the queued notification turns are discarded) and the lead resumes normal behaviour when told to continue.

Quantifying in your own session

Drop this into a shell (adjust the project dir):

python3 - <<'PY'
import json, glob, os
newest = max(glob.glob(os.path.expanduser("~/.claude/projects/*/*.jsonl")), key=os.path.getmtime)
idle=echo=total=0
tokens_idle=tokens_total=0
with open(newest) as f:
    entries = [json.loads(l) for l in f if l.strip()]
for i,r in enumerate(entries):
    if r.get("type") != "assistant": continue
    u = (r.get("message") or {}).get("usage") or {}
    if not u: continue
    cost = u.get("input_tokens",0)+u.get("cache_creation_input_tokens",0)+u.get("cache_read_input_tokens",0)
    total += 1; tokens_total += cost
    prev = next((entries[j] for j in range(i-1,-1,-1) if entries[j].get("type")=="user"), {})
    p = json.dumps(prev)
    if '"idle_notification"' in p and '<teammate-message' in p and len(p) < 3000:
        idle += 1; tokens_idle += cost
    elif "already completed" in p or "duplicate" in p.lower():
        echo += 1; tokens_idle += cost
print(f"{newest}")
print(f"idle+echo turns: {idle+echo}/{total} ({100*(idle+echo)/max(1,total):.1f}%)")
print(f"idle+echo input tokens: {tokens_idle:,} / {tokens_total:,} ({100*tokens_idle/max(1,tokens_total):.1f}%)")
PY

Claude Model

Opus (claude-opus-4-6)

Is this a regression?

Not sure — this is my first extended use of Agent Teams so I can't point to a "last working" version. Filing under "likely always-been-this-way" rather than regression.

Last Working Version

_No response_

Claude Code Version

Claude Code 2.1.107

Platform

Claude Pro/Max subscription

Operating System

macOS (darwin 25.3.0)

Terminal/Shell

Terminal.app / zsh

Additional Information

  • Esc + "continue" reliably breaks the loop, but requires human babysitting, which defeats the purpose of background teams.
  • Teammates themselves correctly detect the stale traffic (they explicitly write "appears to be from my own agent ID" in replies) but still must emit a full response turn per delivery, which then wakes the lead. A fix at either end (suppress delivery or suppress model-turn trigger) would resolve the loop.
  • The 13–22 % noise share quoted above is on a single 8-teammate session that ran to completion without rework; longer sessions with more inter-teammate DMs would be higher.
  • Happy to share a trimmed, anonymized JSONL slice if the team needs a deterministic reproducer.

View original on GitHub ↗

6 Comments

github-actions[bot] · 4 months ago

Found 2 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/39699
  2. https://github.com/anthropics/claude-code/issues/31389

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

robstolarz · 3 months ago

I found a way to lock the mailboxes and intercept the idle messages, only marking them unread after 2 minutes of not having received one (and some other conditions), using the following external script that I prompted Claude to write:

#!/usr/bin/env python3
"""
idle-sweeper: suppress repetitive Claude Code teammate idle_notification
messages from reaching the team-lead's InboxPoller, while letting through:

  - the FIRST idle we've ever seen from a teammate (no baseline yet), OR
  - the first idle after a lead-to-teammate DM (the teammate's "I'm done
    with what you asked" response signal), OR
  - the first idle after a configurable debounce window
    (IDLE_SWEEPER_DEBOUNCE_SEC, default 120).

For any idle that should be suppressed, marks read:true in the lead's inbox
JSON file before Claude's InboxPoller picks it up.

Watches ~/.claude/teams/*/inboxes/*.json and cooperates with Claude's own
writers via proper-lockfile-compatible mkdir locking at <path>.lock.
"""
import json
import os
import sys
import time
from datetime import datetime
from pathlib import Path

HOME = Path.home()
TEAMS_ROOT = HOME / ".claude" / "teams"
LOG_PATH = HOME / ".claude" / "idle-sweeper.log"
PID_PATH = HOME / ".claude" / "idle-sweeper.pid"

POLL_INTERVAL = 0.5
STALE_THRESHOLD = 10.0
ACQUIRE_TIMEOUT = 2.0
DEBOUNCE_SEC = float(os.environ.get("IDLE_SWEEPER_DEBOUNCE_SEC", "120"))
TARGET_TYPE = "idle_notification"

# teammate_name -> ISO 8601 timestamp of last idle we let through for that teammate
last_let_through = {}
# teammate_inbox_path_str -> {"mtime": float, "dm_ts": sorted ascending list of ISO strings}
teammate_inbox_cache = {}


def log(msg):
    line = f"[{time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime())}Z] {msg}\n"
    try:
        with open(LOG_PATH, "a") as f:
            f.write(line)
    except OSError:
        pass
    sys.stderr.write(line)
    sys.stderr.flush()


def acquire_lock(lockdir, deadline_s=ACQUIRE_TIMEOUT):
    start = time.monotonic()
    backoff = 0.005
    while True:
        try:
            os.mkdir(lockdir)
            return True
        except FileExistsError:
            try:
                age = time.time() - os.stat(lockdir).st_mtime
                if age > STALE_THRESHOLD:
                    try:
                        os.rmdir(lockdir)
                    except OSError:
                        pass
                    continue
            except FileNotFoundError:
                continue
            if time.monotonic() - start > deadline_s:
                return False
            time.sleep(backoff)
            backoff = min(0.1, backoff * 2)


def release_lock(lockdir):
    try:
        os.rmdir(lockdir)
    except FileNotFoundError:
        pass


def parse_iso(ts):
    if not isinstance(ts, str):
        return None
    try:
        return datetime.fromisoformat(ts.replace("Z", "+00:00"))
    except ValueError:
        return None


def get_dm_timestamps(team_dir, teammate_name):
    """Return ascending-sorted ISO timestamps of messages in the teammate's
    inbox whose `from` is not the teammate themselves — i.e., incoming
    messages (lead-to-X or system-to-X). Cached by file mtime."""
    path = team_dir / "inboxes" / f"{teammate_name}.json"
    try:
        mtime = path.stat().st_mtime
    except FileNotFoundError:
        return []
    key = str(path)
    cached = teammate_inbox_cache.get(key)
    if cached and cached["mtime"] == mtime:
        return cached["dm_ts"]
    try:
        with open(path) as f:
            msgs = json.load(f)
    except (FileNotFoundError, json.JSONDecodeError):
        teammate_inbox_cache[key] = {"mtime": mtime, "dm_ts": []}
        return []
    dm_ts = []
    if isinstance(msgs, list):
        for m in msgs:
            if not isinstance(m, dict):
                continue
            if m.get("from") == teammate_name:
                continue
            ts = m.get("timestamp")
            if isinstance(ts, str):
                dm_ts.append(ts)
    dm_ts.sort()
    teammate_inbox_cache[key] = {"mtime": mtime, "dm_ts": dm_ts}
    return dm_ts


def should_let_through(teammate, idle_ts, dm_ts_list, debounce_sec):
    """Returns (let_through: bool, reason: str)."""
    prev = last_let_through.get(teammate)
    if prev is None:
        return True, "first-seen"

    # is there a DM strictly inside (prev, idle_ts)?
    # dm_ts_list is sorted ascending; ISO 8601 with Z is lex-comparable.
    for dm in reversed(dm_ts_list):
        if dm >= idle_ts:
            continue
        if dm > prev:
            return True, "post-dm"
        break

    prev_dt = parse_iso(prev)
    idle_dt = parse_iso(idle_ts)
    if prev_dt is not None and idle_dt is not None:
        if (idle_dt - prev_dt).total_seconds() >= debounce_sec:
            return True, "debounce-elapsed"
    return False, "suppress"


def sweep_file(path):
    lockdir = str(path) + ".lock"
    if not acquire_lock(lockdir):
        log(f"could not acquire lock for {path.name}")
        return (0, 0)
    try:
        try:
            with open(path) as f:
                msgs = json.load(f)
        except FileNotFoundError:
            return (0, 0)
        except json.JSONDecodeError as e:
            log(f"skipping {path.name}: parse error {e}")
            return (0, 0)
        if not isinstance(msgs, list):
            return (0, 0)

        team_dir = path.parent.parent  # .../teams/<team>
        suppressed = 0
        passed = 0
        for m in msgs:
            if not isinstance(m, dict):
                continue
            if m.get("read"):
                continue
            text = m.get("text")
            if not isinstance(text, str):
                continue
            try:
                inner = json.loads(text)
            except (json.JSONDecodeError, TypeError):
                continue
            if not isinstance(inner, dict) or inner.get("type") != TARGET_TYPE:
                continue

            teammate = inner.get("from") or m.get("from")
            if not isinstance(teammate, str):
                continue
            idle_ts = m.get("timestamp") or inner.get("timestamp")
            if not isinstance(idle_ts, str):
                continue

            dm_ts_list = get_dm_timestamps(team_dir, teammate)
            let_through, reason = should_let_through(teammate, idle_ts, dm_ts_list, DEBOUNCE_SEC)
            if let_through:
                last_let_through[teammate] = idle_ts
                passed += 1
                log(f"PASS {team_dir.name}/{teammate} @ {idle_ts} ({reason})")
            else:
                m["read"] = True
                suppressed += 1

        if suppressed:
            tmp = str(path) + ".sweeper.tmp"
            with open(tmp, "w") as f:
                json.dump(msgs, f, indent=2)
            os.replace(tmp, path)
        return (suppressed, passed)
    finally:
        release_lock(lockdir)


def main():
    PID_PATH.write_text(f"{os.getpid()}\n")
    log(f"sweeper starting pid={os.getpid()} debounce={DEBOUNCE_SEC}s root={TEAMS_ROOT}")

    seen_mtimes = {}
    initial_pass = True
    while True:
        try:
            try:
                inboxes = list(TEAMS_ROOT.glob("*/inboxes/*.json"))
            except FileNotFoundError:
                inboxes = []
            for ib in inboxes:
                if ib.name.endswith(".sweeper.tmp"):
                    continue
                try:
                    mtime = ib.stat().st_mtime
                except FileNotFoundError:
                    continue
                key = str(ib)
                if seen_mtimes.get(key) == mtime:
                    continue
                seen_mtimes[key] = mtime
                try:
                    suppressed, passed = sweep_file(ib)
                except Exception as e:
                    log(f"error sweeping {ib}: {e!r}")
                    continue
                if suppressed or passed:
                    team = ib.parent.parent.name
                    log(f"{team}/{ib.name}: suppressed={suppressed} passed={passed}")
            if initial_pass:
                log(f"initial pass complete; tracked {len(seen_mtimes)} inbox file(s)")
                initial_pass = False
            time.sleep(POLL_INTERVAL)
        except KeyboardInterrupt:
            break
        except Exception as e:
            log(f"main loop error: {e!r}")
            time.sleep(POLL_INTERVAL)

    log("sweeper exiting")
    try:
        PID_PATH.unlink()
    except FileNotFoundError:
        pass


if __name__ == "__main__":
    main()

I wouldn't normally have added all of this fancy logic, but it turns out that the idle pings are fairly important in keeping the loop from getting stuck and letting the main thread keep an eye on the pulse of the teammates. However, the current logic causes teammates to issue an idle signal at every turn completion, including the end of each tool call, which I believe to be rather overzealous. I would prefer something like the /recap trigger to be what issues an idle signal instead, as its timing is much less noisy and expensive. I've tried to sort of emulate my desired behavior in this script. So at the cost of potentially letting your loop sit idle for a couple more minutes than intended before it self-heals, you can run this script and save a substantial number of idle pokes from ending up in context unnecessarily.

If you use this script, let me know if it helps.

abhinas90 · 3 months ago

Great quantification. Instrumented our 4-agent fleet and found ~18% waste over a week of production runs.

Two biggest culprits: 1) task_assignment messages echoed by every agent even when unchanged, 2) idle polling loops re-sending full context on every tick.

Quick mitigation that cut waste ~60%: state-diff check before re-sending task_assignment, and interval bump from 5s to 30s for idle agents. Would love to see platform-level handling.

kcarriedo · 2 months ago

The 13–22% token overhead on no-op acknowledgments is a real problem — and the mechanics you've described (idle_notification as individual turns, stale task_assignment echoes) make it easy to understand why the math works out so badly at 8 teammates.

A few observations from running similar Agent Teams setups:

On idle notifications as separate turns: The protocol seems to be treating "teammate finished" as an event that deserves a full round-trip to the lead, when really it should be a side-channel signal (think: heartbeat, not conversation turn). If idle_notification were delivered as a structured metadata event rather than a conversation message, the lead wouldn't need to generate an acknowledgment at all — it could just update its internal teammate-status table and continue. This feels like a protocol-layer fix rather than a prompt engineering fix.

On duplicate task_assignment echoes: The pattern you're describing (teammates receiving tasks they've already completed) suggests the team's task ledger isn't being marked as consumed before the echo propagates. A possible mitigation while waiting for an upstream fix: have the lead check a simple _completed_tasks list in CLAUDE.md before sending a task assignment, and skip if the task hash is already there. Crude, but it eliminates the "re-send to already-done teammate" case.

On the cost model: The framing of "18 no-op turns = 3.03M input tokens" is exactly the kind of concrete data that helps justify fixing this at the infrastructure layer. Token consumption should scale with actual work content, not with team size times notification frequency. I'd suggest adding a request for a /notifymode batch option — batch idle notifications into a single turn at the end of each work wave rather than one per teammate.

I'm building Claudeverse (claudeverse.ai) to address the broader session-cost and coordination-overhead problems in multi-agent Claude Code workflows. The Agent Teams notification loop is one of the patterns we've explicitly worked around at the coordination layer. Happy to share the approach if it would help inform the upstream fix.

Munsik-Park · 2 months ago

We ran the same setup (one lead orchestrator + N sub-agent teammates) and tested a fix on the no-op-ack path. Reporting the result.

Fix tested: a UserPromptSubmit hook forcing the lead to emit zero output when the incoming turn is an idle_notification. Kept for 2 days, then reverted.

Measurements:

  • Ack suppression saved ~50 input tokens per idle event (~0.4% of a ~336K-token session). The dominant cost is not the lead's reply — the idle_notification arrives as a conversation turn, so the accumulated context is re-fed on the next round trip whether or not the lead replies.
  • With an idle_notification and a shutdown_approved in the same turn, the zero-output rule also suppressed the legitimate TeamDelete: a ~3.5-min zombie teammate, a double shutdown, and a downstream TeamCreate failure.

Conclusion: both the token cost and the control failure come from idle being delivered as a conversation turn, not from the lead's reply. A prompt/hook layer addresses neither.

Current workaround: multi-agent deliberation runs in an isolated Workflow sub-context; teammate idle/echo turns stay there and never reach the lead's conversation, which receives only one structured result. This removes the accumulation at the source. A side-channel idle signal (a metadata event, or a batched notify mode) would cover the case where the lead must stay in the loop.

tfabrad · 1 month ago

Adding evidence + concrete solution shapes from a production multi-agent deployment (overnight 4-worker coordination sessions driving a machine-vision/laser rig), since we dug into whether this is workaround-able today. Our findings agree with and extend the hook-test comment above:

  • No config/env gate exists: we inspected the v2.1.204 binary — the idle-notification constructor ({type:"idle_notification", from, timestamp, idleReason}) is invoked unconditionally; CLAUDE_CODE_IDLE_THRESHOLD_MINUTES / CLAUDE_CODE_IDLE_TOKEN_THRESHOLD gate unrelated idle machinery.
  • Our measured cost matches this issue's numbers: ~20 byte-identical pings in one overnight session ≈ $8–10 of lead-turn overhead at large-context rates, plus permanent context bloat (each ping arrives wrapped in ~100 words of injected peer-message boilerplate — see #73647).
  • We independently hit the wedge case that defeats polite cleanup: an agent that idle-pings repeatedly without draining its inbox (same family as #74113), so shutdown_request is never processed and only a hard TaskStop ends it. Any suppress-the-reply approach also can't fix this — consistent with the hook-test result above that the delivered TURN, not the lead's reply, is both the cost and the control surface.
  • Workarounds we run in production: an external cron watchdog appending shutdown_request entries to idle agents' team-inbox JSON files (undocumented internal state — we'd rather not depend on it), lead-side kill-on-ping (spends the very turns it saves), and — after reading the Workflow-isolation comment above — adopting Workflow sub-contexts for non-interactive parallel work. The uncovered case remains exactly as that comment says: work where the lead must stay in the loop (for us: live-hardware coordination with an operator present, where mid-task teammate↔lead decisions are the point).

Any one of these would resolve the remaining case:

  1. "teammateIdleNotifications": "off" | "first-only" | "on" in settings — first-only (one ping per agent per idle period) keeps the legitimate signal ("worker went idle without starting its task") at 1/N the cost.
  2. A side-channel/metadata idle signal or batched-notify mode (per the comment above) instead of a full conversation turn.
  3. A per-spawn idle_notifications: false option on the Agent tool, so leads opt out for workers they manage by deliverable.

Showing cached comments. Read the full discussion on GitHub ↗