[Bug] Sibling subagents share ONE scratchpad dir (and a forked Skill inherits the PARENT's), so generic filenames silently overwrite each other
Bug Description
---
The scratchpad is described in the system prompt as "session-specific, isolated from the user's project", which reads as per-agent isolation. It is not. Every subagent spawned by one orchestrator session shares a single scratchpad directory (keyed by the parent session UUID), and a Skill launched as a background fork inherits the parent conversation's own scratchpad path. Concurrent agents that independently pick an obvious filename — preview.html, threads.json, out.png — silently overwrite each other, with no error and no warning at any layer.
Why this is worse than ordinary clobbering: the failure is silent and inverted. The path stays valid, the read succeeds, and the content is simply someone else's. An agent doing read-back verification can conclude "my artifact is correct" while looking at a sibling's, or "mine is broken" while looking at content that was never theirs.
Convergent naming is the mechanism, and it is not a coincidence. Colliding agents are working the same task off the same prompt, so they reach for the same obvious name. Telling agents to "pick unique names" cannot fix it — the collision has to be prevented by the harness.
---
Occurrence 1 — sibling subagents (parallel fan-out)
Several sibling agents spawned by one orchestrator each wrote a visual-verification harness to <scratchpad>/preview.html. One agent wrote its own specimens, opened the file, and screenshotted; a later capture rendered a sibling's specimens. location.href still reported its own path, so the natural first diagnosis was browser session bleed — a debugging cycle was spent there before checking the file:
$ grep -o 'SPECIMEN: P[0-9]*' preview.html
SPECIMEN: P22 # a sibling's, not mine
SPECIMEN: P23
The directory listing showed the collision surface plainly: preview.html, preview2.html, preview-light.html, preview-dark.html, all mtime-adjacent, authored by different agents.
Occurrence 2 — a forked Skill collides with its own PARENT
The main conversation had written <scratchpad>/threads.json and was querying it with jq. A Skill launched as a background fork inherited the parent's scratchpad path, chose the same generic filename for the same conceptual artifact, and ran:
gh api graphql ... --paginate --slurp > <scratchpad>/threads.json
The redirect truncated the file at open. The parent's next jq call failed with:
jq: parse error: Unfinished JSON term at EOF at line 1, column 18437044
That message accuses the wrong thing — it reads as "the data you fetched is malformed" or "your query is wrong". The document had parsed cleanly minutes earlier, so the natural inference was API truncation, not that another agent overwrote the file between two of the parent's own reads. The true cause was only understood much later, from the fork's transcript, where the offending redirect was the last command it ran.
Sharing a scratchpad with your own parent has no upside: a fork already inherits full conversation context, so there is nothing it needs to hand back through a shared path.
---
Suggested fix, in preference order
- Provision per-agent scratchpad subdirectories —
…/scratchpad/<agent-id>/per spawned subagent, and a distinct directory for a forked Skill rather than the parent's. This removes the failure mode rather than asking every agent to remember. Strictly preferable. - If sharing is deliberate, say so in the prompt and require namespacing — amend the scratchpad description to "shared with sibling agents spawned by this session; prefix filenames with a token unique to your task", so agents namespace by default instead of after being burned.
- Failing both, make the collision loud — refuse (or warn on) a truncating redirect onto a path another live agent in the session has written this turn.
A cheap mitigation regardless: agents doing read-back verification should confirm identity via a marker string rather than trusting the path. That is a workaround, not a fix, and it only helps agents who already suspect the problem.
Environment Info
- Platform: linux
- Version: 2.1.233
- Both occurrences reproduced on multi-agent orchestration (parallel
Agent-tool fan-out; background-forked Skill)
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
Another occurrence class, from a different task shape: the shared scratchpad also carries paths to things outside it, and then the clobbering stops being confined to the scratchpad.
Two teams of four agents each ran a review/remediation pass over the same repository. Each agent worked in its own git worktree and used the shared scratchpad to record which worktree was its own. Seven incidents across the two teams:
git worktree remove --forceagainst a peer's path. It failed only because of an accidental prefix mismatch. Had the prefix matched, a peer's uncommitted work would have been destroyed by an agent that believed it was cleaning up after itself.git statusreading — the evidence for a claim it was about to publish — turned out to have come from a peer's tree, and the run was discarded and re-derived in a stamp-verified worktree.All four recovered by re-deriving after the fact, and no contaminated number shipped. But recovery cost more than the work itself, and in each case the first diagnosis was wrong for the same reason the issue body gives: the path was valid, the read succeeded, and the content was plausible — just another agent's.
The escalation worth noting is the third one. When the shared file holds a path, a collision no longer produces a wrong artifact — it produces a correctly-executed destructive command aimed at the wrong target. Uniquifying filenames by convention does not help here either, for the reason already stated: agents working the same prompt converge on the same name for the same conceptual artifact, and "the path to my worktree" is exactly such an artifact.
Adding a corroborating occurrence plus one clean repro — independent setup, same root cause.
Independent confirmation (minimal repro)
Before trusting a report of this, wanted to verify the root-cause claim (scratchpad keyed to the top-level session, not per-subagent) directly. Spawned one throwaway
general-purposesubagent with nothing but: "reply with only the exact scratchpad path from your own system prompt." It returned the exact same path as the parent session's own scratchpad, byte for byte. No file writes needed — just reading the system-prompt string back from two different session contexts confirms it in one round trip.Occurrence — git commit message corruption via
commit -F, and a gap in the existing drift-detection tripwire8 sibling agents (Agent-tool fan-out from one orchestrator), each in its own git worktree, each working an independent task off a shared backlog. One agent:
git commitsucceeded, butgit logshowed a different sibling's commit message entirely — a description of unrelated work, not in this agent's diff or its worktree. The diff content itself was untouched, only the message was wrong, and it was caught by manual review ofgit log, not by anything at commit time.The harness's own "
commit-msg-1.txtchanged on disk since you last read it" reminder did eventually fire and confirmed the mechanism — but only once the agent's own Read/Write tool next touched that path (while investigating why the log looked wrong), well aftergit commit -Fhad already consumed the clobbered content via Bash. So the existing drift tripwire (relevant to suggested fix #3 above) only covers re-entry through Claude's own file tools — it can't protect a shell command that reads the shared path directly inside the collision window. By the time the tripwire fires, the external process has already acted on the bad data. Fixed here withgit commit --amendbefore pushing; low-stakes since it was only commit metadata, but the same window against afixture.sqlor config file consumed by a build/deploy step would ship silently corrupted state instead of just mislabeling a commit.Net effect agrees with suggested fix #1 (per-agent scratchpad subdirectory) as the only version that actually closes the gap — namespacing-by-convention and louder-write-detection both still lose to any external, non-Claude-tool consumer racing the read.
Third occurrence, and it adds one case this issue doesn't yet name:
Agent(isolation: "worktree").Two lanes were dispatched with
isolation: "worktree"to build two unrelated fixes in one repo. Git isolation worked exactly as documented — two worktrees, two branches, disjoint file sets, zero conflict, both PRs clean. Both lanes then wrote their PR body to<scratchpad>/prbody.md. Afterwards, exactly one file existed, holding the second lane's body.Neither agent worktree had a scratchpad of its own — only
tasks/.Why this case is worth calling out separately.
isolation: "worktree"is the dispatch shape you reach for precisely because you want lanes that cannot interfere. It delivers loudly on the half you can see: branches, commits and diffs are all provably separate, and that visible success is what makes the invisible half so easy to miss. An orchestrator that verifies isolation by checking the branches — the obvious check — gets a clean bill every time. The guarantee you can watch working is not the guarantee that broke.No damage here, and only by timing: the losing lane had already submitted its body at
gh pr createtime. A lane that writes its body and then does anything slow before creating the PR — polls CI, re-runs a duplicate scan, rebases — files the other lane's PR body, becausegh pr create -Freads the file at call time rather than write time. The PR would look entirely plausible: well-formed Markdown, describing a real change, just not that one.This matches the mechanism already described here. The scratchpad path is keyed by session id, and subagents inherit
CLAUDE_CODE_SESSION_IDverbatim from the parent — so same id, same directory, with no decision point in between. Worth noting that the worktree half of this exact problem was already solved by giving each agent its own worktree; the scratchpad simply didn't get the same treatment.Agreed that "tell agents to pick unique names" cannot be the fix. In our case both lanes were briefed from one template for one task, which is exactly the convergent-naming pressure described above — the obvious name is obvious to every lane simultaneously.