Two agents, one handoff file: how exit 0 hid a data-clobbering bug
Our multi-agent Claude Code setup keeps a session handoff file per agent: a markdown note the agent writes at the end of every turn, so that after a restart — planned or not — the next session starts with “here’s what I was doing, here’s what’s in flight”. It’s the difference between an agent that resumes work and an agent that wakes up with amnesia.
One morning, one of our agents restarted and dutifully read its handoff file. The file was well-formed, recent, and completely wrong — it described another agent’s work in another agent’s voice. No hook had failed. No script had errored. Every exit code along the way was 0.
This is the post-mortem of a shared-path bug that two rounds of review initially got wrong, the piece of evidence that settled it, and the checks we now run so that “the file exists and the writer exited cleanly” is never again confused with “the file is yours”.
The setup
Both agents run on the same macOS machine as the same OS user, separated by working directory and a HOME-override pattern (the full isolation story is in Running multiple Claude Code agents on one Mac):
- Agent A works out of
~ - Agent B works out of
~/agents/beta
A Stop hook — Claude Code’s “the turn just ended” event — runs a small writer script, session_handoff_writer.py, which snapshots the session state into the handoff file. The writer had one path hardcoded:
HANDOFF_PATH = Path.home() / ".claude" / "session_handoff.md"
One path. Two agents. You can see the ending already — but it’s worth walking through why nobody else could, for weeks.
Why it stayed invisible
The failure mode has three properties that make it nearly undetectable by normal means:
1. Every write succeeds. The writer opens the file, writes valid content, exits 0. From the hook system’s perspective this is a healthy hook. There is no error to alert on, because nothing is erroring — the wrong data is being written flawlessly.
2. The content is plausible. A handoff file clobbered by another agent doesn’t contain garbage. It contains a perfectly coherent handoff — for someone else. Automated checks that validate “file exists, recent mtime, non-empty, parses fine” all pass. Only a reader who knows which agent’s state should be in the file can spot the problem.
3. The overwrite is last-writer-wins with agents that talk constantly. Whichever agent ended a turn most recently owns the file’s content. Most of the time the agent that reads the handoff (at restart) had also been the last to write it, so the content happened to be correct. The corruption only surfaced when the other agent had spoken more recently — which is exactly the kind of timing-dependent behavior that makes people say “weird, it’s fine now” and move on.
The review that got it wrong — and the evidence that settled it
Here’s the part I find most instructive, and it’s about process rather than code.
When the shared path was first suspected, the agent who owned the writer checked the configuration and reported back: the writer hook is only registered in my settings; the other agent’s project settings don’t reference it; therefore it never fires in the other agent’s sessions. The config genuinely reads that way. Case closed?
The counter-argument was one sentence about semantics: Claude Code merges hooks across settings scopes. User-level settings and project-level settings don’t replace each other — hooks from both run. A Stop hook registered at the user level fires in every session, no matter which project-level file the reviewer happened to be reading. Looking at one scope’s config told us nothing.
What settled it wasn’t a better reading of the config. It was data. The handoff backups included session-ID stamps, and the machine keeps per-project transcript directories with those same IDs. Cross-referencing them showed session IDs from both agents’ transcript trees appearing in the backups of one shared file:
backups of ~/.claude/session_handoff.md contain session ids:
7f3a91c2, 08d4be67, e5c2a90d ← agent A's transcript dir
91b0f8a3, 3ce7d215 ← agent B's transcript dir (!)
Agent B’s sessions had been writing to agent A’s handoff. Not “could in theory” — had been, with timestamps. When a config’s behavior is in dispute, an artifact that could only exist under one interpretation beats any amount of re-reading the config.
The fix
Three parts, smallest possible surface:
1. The writer resolves its output path from the hook payload. Claude Code passes hook scripts a JSON payload on stdin that includes cwd — the working directory of the session that fired the hook. The writer now derives the agent identity from that:
def resolve_handoff_path(payload):
cwd = payload.get("cwd", "")
beta_root = str(Path.home() / "agents" / "beta")
if cwd == beta_root or cwd.startswith(beta_root + os.sep):
return beta_root + "/.claude/session_handoff.md"
return str(Path.home() / ".claude" / "session_handoff.md")
Three details that earned their place in review: the comparison is separator-bounded (so a sibling directory like ~/agents/beta-old can’t false-match on prefix); both sides pass through os.path.realpath() before comparison, so a symlinked working directory can’t dodge the check — a boundary check on raw strings is never quite enough on its own; and the fallback when cwd is missing goes to a defined default rather than raising — a handoff writer that crashes at turn end protects nobody.
The pleasant surprise: the user-scope hook registration didn’t need to change at all. One shared hook, per-agent behavior, driven by data the hook system already provides.
2. Recovery restores only from your own backups. The restart tooling used to restore “the most recent handoff backup” — which, with a shared path, meant an agent could resurrect the other agent’s state even after the writer was fixed. Backup filenames now carry the agent name, and restore filters on it.
3. Missing hooks got noticed and added. While verifying, we found agent B had no SessionStart hook at all — its handoff was being read by convention (an instruction in a config file) rather than by mechanism. Convention is what you have before the first incident. It got a real hook.
Verification
The proof of the fix is boring, which is the point:
[recovery:agent-A] handoff OK (6,700 bytes)
[recovery:agent-B] handoff OK (2,287 bytes) ← previously both said 6,700
Two different files, two different sizes, each containing its own agent’s state. Plus a table of cwd → resolved-path cases (agent root, nested subdirectory, missing cwd, deliberately similar sibling path) run against the resolver directly.
What generalizes
- Exit code 0 measures “the program ran”, not “the program did the right thing.” For anything that writes shared state, verify the content — who does this data belong to? — not just the write.
- Multi-tenant defaults are time bombs. Any hardcoded path in code that more than one agent (or service, or cron job) executes is a collision waiting for the second tenant. Resolve identity from runtime context, not from constants.
- Know your config system’s merge semantics. “It’s not in this file” is only evidence if scopes replace each other. If they merge — as Claude Code’s hook scopes do — absence in one file proves nothing.
- Settle disputes with artifacts, not re-reads. The session-ID cross-reference ended in five minutes an argument that config-reading had kept alive for a day.
- Fix the restore path too. A corrected writer plus an uncorrected “restore latest” would have re-imported the corruption on the next incident.
Further reading
The isolation layer this bug slipped past — per-agent HOME, tmux sockets, persona files — is covered in Running multiple Claude Code agents on one Mac, and the identity-bleed failure mode specifically in Why your second Claude Code agent speaks as your first.
The handoff templates, hook set, and restart runbooks that came out of incidents like this one — including the per-agent path resolution — ship as part of the enrai Agent Pack ($33 on Gumroad), with every placeholder filled in.