Skip to content

Latest commit

 

History

History
348 lines (281 loc) · 17.4 KB

multi-session-coordination.md

File metadata and controls

348 lines (281 loc) · 17.4 KB

Multi-session coordination — cross-session signal

When two or more Claude Code sessions are working in the same repo at the same time, they can produce overlapping or wasted work without ever colliding at the git level. This doc names the failure modes, explains why the cheap ones are caught and the costly ones aren't, and lists the signals that would actually shrink the costly category.

The two outcomes of shared-surface contention

When two sessions touch the same surface, their work ends up in one of two buckets. Only one of them is currently caught.

Duplicate work (cheap — git catches it)

Two sessions independently write the same fix.

  • Git detects identical patches at merge/push time.
  • "Already up-to-date" or merge conflict surfaces the collision.
  • Worst case: a chore(cleanup) commit lands twice on the same branch under the same name (e.g. #65/#73 both titled fix(nav): … remove unused GroupMode, same branch, identical 1806-byte diff, 9h apart). Even then the damage is bounded — wasted review cycles, no broken behavior.

In practice this is rare in this repo. When the historical search turned up near-duplicate commits, most were rebase-replay artifacts: the same logical commit (same author_date, different commit_date and parent) lands on main twice via two merge resolutions. That isn't duplicate authorship; it's the same patch counted twice.

Orphaned work (expensive — nothing catches it)

Session A polishes a surface; Session B replaces or removes that surface upstream without knowing. A's work lands, builds clean, ships, and dies within the hour.

The canonical example (2026-06-08 / 09 evening):

23:20  #305 lands — polishes sessions-view.tsx (+286 lines incl. tests)
23:58  ced714c    — ChatSurface drops the SessionsView fallback
00:30  14466de    — sessions-view.tsx, .css, polish test all deleted (-2132)

PR #305 lived for ~70 minutes. The polish ideas may have informed the ChatList direction, but the actual code is gone from main. The author of the deletion knew — they explicitly named "the chat-polish test that targeted only SessionsView internals" as something to drop, meaning they were aware they were reverting just-merged work.

Both sessions did locally-valid work. Neither did the other's work. The waste isn't redundancy; it's misallocation.

Why duplicate is caught but orphaned isn't

Duplicate work lives in the codebase. Two diffs against the same lines collide at commit/push time, and git refuses to silently lose data.

Orphaned work lives in intent. "Session B is about to delete this surface" exists only in Session B's running context — never in the file tree, never in the index, never in git status. Session A's diff against the soon-to-be- dead file passes typecheck, passes the suite, builds clean, and ships. There is no point in the standard developer loop where the fact of impending removal becomes visible.

This means orphaned-work prevention can't be retrofitted via git or CI alone. The signal has to live somewhere both sessions check before committing to a direction.

Signals that would actually help

Listed cheapest first.

1. Surface-claim file — .claude/claims.json ✅ IMPLEMENTED

Status: Built as an automatic PreToolUse hook — scripts/surface-claim-guard.mjs, wired in .claude/settings.json (matcher Edit|Write|NotebookEdit). It removes the "nothing forces sessions to write claims" fragility below: claims are now a byproduct of editing, requiring zero discipline.

On every Edit/Write to the shared primary checkout, the hook:

  • records this session's claim on the target file (keyed by session_id);
  • prunes claims with no activity in the last ~2h;
  • if another live session already claimed that exact file, surfaces a collision warning to both the user (systemMessage) and the model (hookSpecificOutput.additionalContext).

It is advisory only — it never blocks or fails an edit (always exits 0, even on corrupt input). Edits inside .worktrees/ are skipped: those sessions are already isolated, and their divergence surfaces at PR/merge time (see §2). The warning fires for the second session to touch a file — exactly the moment a clobber would otherwise happen silently. Covered by scripts/surface-claim-guard.test.mjs.


A flat file recording "Session X has begun work touching surfaces Y and Z." Updated when a session starts a non-trivial task on a surface; cleared on session exit or when the task lands.

{
  "ttys029": {
    "started": "2026-06-08T23:24Z",
    "surfaces": ["src/components/inbox-escalations-view.tsx"],
    "intent": "feat: severity rails + filter chips + bulk actions"
  },
  "ttys044": {
    "started": "2026-06-08T23:55Z",
    "surfaces": ["src/components/chat-surface.tsx", "src/components/sessions-view.tsx"],
    "intent": "refactor: dedupe to single chat list, then delete SessionsView"
  }
}

Before starting work on a surface, a session greps claims for collisions. Before committing, a session re-checks for surface overlap and surfaces the collision to the user.

Fragile because: nothing forces sessions to write claims, claims go stale, and the file becomes another source of merge conflict if checked in. Mitigations: keep it gitignored (so it doesn't conflict), expire entries older than ~2 hours, and treat the absence of a claim as "no claim made" rather than "no work in progress."

This is the cheapest first move and the most likely to actually get adopted.

2. Worktree-per-session as default, not exception

CLAUDE.md already documents the worktree convention. The orphaned-work case above happened on the primary checkout with 5+ sessions sharing it. If each session had its own .worktrees/<branch> from the start, they'd still have been writing to the same main eventually — but the divergence would have surfaced at the merge/PR moment, not at the silent deletion moment.

Worktrees don't prevent orphaning; they make it visible earlier. A gh pr create for a worktree's branch surfaces the diff against current main before merging, which is the first moment another session's deletion would show up as a conflict or a stale-context warning.

3. Intent-tagged commits with sentinel-style assertions

The sidebar-minimal.test.ts update in PR #298 is a good pattern: when removing a surface, leave a negative-import sentinel test that asserts the file isn't reintroduced. Future sessions running the suite see the assertion break the moment they recreate the surface — even if they don't read commit history. This works after the orphaning has happened but at least prevents re-orphaning the same surface twice.

Companion to this: when adding a new surface that's likely to be touched by parallel sessions (large component, IA-load-bearing), drop a positive-import sentinel in the same test that says "X must still be the consumer of Y" so that someone removing Y notices Y has a known consumer.

4. Pre-commit broadcast (expensive, rarely justified)

A pre-commit hook that pings a shared file/socket announcing "session X is about to commit Y" and waits N seconds for objections. Catches the most adversarial cases but adds latency to every commit and forces network/IPC plumbing. Mention here for completeness; don't build it unless the cheaper options have demonstrably failed.

5. Destructive-op guard — scripts/worktree-guard.mjs ✅ IMPLEMENTED

Duplicate and orphaned work are the slow failure modes. The fast one is destroyed work: on 2026-07-03 an actor found another session's in-progress worktree, pushed its (unpushed) commit, merged it as PR #2290, and ran the standard post-merge cleanup — git worktree remove + git branch -D — while the owning session was mid-edit. Every uncommitted change was lost. The same gutted-worktree "husk" pattern (only a .next/ or tsconfig.tsbuildinfo recreated by a process still running inside) had already hit two other worktrees on prior days. A sibling incident (#2286) chained git push origin --delete after a merge that had actually failed, which auto-closed the still-open PR.

The guard is a PreToolUse hook on Bash (wired next to the surface-claim guard in .claude/settings.json) and — unlike the claim guard — it blocks (exit 2), because these ops destroy unrecoverable state:

  • git worktree remove <path> / rm -rf .worktrees/<name> (the worktree root; deeper paths are the owner's business) is blocked when the worktree is dirty or its HEAD exists on no remote ref. Husks (no .git link) and clean+pushed worktrees pass silently, so normal post-merge cleanup and husk GC stay frictionless. rm -rf .worktrees (the whole container) checks every child.
  • git branch -D <name> is blocked when the branch tip is contained in no remote-tracking ref (deletion would orphan unpushed commits).
  • git push <remote> --delete <branch> (or push <remote> :<branch>) is blocked while an open PR still has that head branch. Needs gh; fails open offline.

Deliberate destruction: prefix the command with WT_GUARD_BYPASS=1 — the guard only ensures it can't happen by accident. The remote is the only store a local actor can't destroy, so unpushed commits are the work most at risk. scripts/worktree-retention-push.mjs (§6) now pushes them automatically, as the branch itself or as a retention/<slug>-<sha> tag. That hook doesn't grant push authority: whether a session may push otherwise is set by AGENTS.md and the current request.

Known hole: the guard only covers actors that run through Claude Code hooks. The 2026-07-03 actor left no session transcript (likely a familiar/automation running outside the hook system) — for those, the pushed-branch discipline and the branch-protection rules are the only backstops.

Audit log (cave-boor8). On 2026-07-29 .worktrees/probe-timing was destroyed holding 3 uncommitted files, and the post-mortem could not tell a deliberate WT_GUARD_BYPASS=1 from a hole in the guard — the bypass path left no trace. The guard now appends one JSON line per bypass and per block to .claude/worktree-guard-bypass.log (gitignored): at, verdict, session, cwd, command, and for blocks the reason. Read it as evidence, in three tiers: a bypass entry names a deliberate override; a block entry followed by the worktree's disappearance names an actor that retried outside the hook; and no entry at all means the destruction never routed through the Bash tool — an external terminal, editor, or automation, which no PreToolUse hook can see. That last tier is the one the pushed-branch discipline exists for.

Audits record paths, not counts (cave-boor8's other lesson). probe-timing was unrecoverable even in principle because the sweep that flagged it logged only dirty=3 — once the directory was gone there was no filename or content signature to match git fsck debris against. Any sweep or audit that touches a dirty worktree must capture the full git status --porcelain output (paths, not counts) in its notes or issue. Three lines of porcelain is the difference between "recovery impossible" and a targeted lost-found search — and for UNSTAGED edits (which never enter the object store) it is the only record that the work existed at all.

6. At-risk work hooks — worktree-autolock.mjs and worktree-retention-push.mjs ✅ IMPLEMENTED

.claude/settings.json wires two more advisory hooks next to the surface-claim and destructive-op guards. scripts/worktree-autolock.mjs (PreToolUse) locks worktrees that hold work a removal would destroy, so an actor outside Claude Code, such as GitHub Desktop, can't remove them with a single --force. scripts/worktree-retention-push.mjs (PostToolUse) pushes commits that exist on no remote: as the branch itself, or as a retention/<slug>-<sha> tag when the branch's upstream was deleted. Both exit 0. Neither merges, opens a PR, deletes a branch, or grants ownership.

The PR layer: duplicate publication of the same work

Every guard above assumes the collision happens locally — worktree-guard blocks destruction, surface-claim-guard warns on co-edited files, and the sections above cover duplicate and orphaned work. A fourth failure mode happens entirely on GitHub, where none of them can see (observed 2026-07-29, cave-pimgr):

  1. Session A commits on feat/grant-audit-console and pushes.
  2. Nine minutes later a different actor opens a PR on that same branch — same commit, same title — and merges it. The repo's auto-delete setting removes the head branch.
  3. Session A's own gh pr create now fails with No commits between main and feat/grant-audit-console.

Nothing was destroyed and no file was co-edited, so no hook fired. The cost came from misreading the aftermath: session A interpreted the (correct) gh error as branch corruption, "recovered" a branch that needed no recovery, and opened a duplicate PR; a second session doing unrelated GC found the branch gone and independently wrote "another session deleted work" into its summary — which session A then treated as corroboration. Two agents inferring destruction from identical ambiguous evidence is not confirmation. Net: a false destructive-actor incident report, a duplicate PR, and an hour of investigation. Across the preceding 200 PRs, four head refs carried more than one PR — rare, not unique.

Two habits kill this failure mode outright:

  • Before gh pr create, check whether the branch already has one: gh pr list --state all --head <branch>. If a PR exists — open or merged — report it instead of creating a second. One command ends duplicate publication entirely.
  • Treat No commits between main and <branch> as a SIGNAL, not an error. It usually means the work is already ON main. Before concluding anything was lost, verify in two steps: git merge-base --is-ancestor <your-sha> origin/main answers yes for merge/fast-forward landings, but a SQUASH merge — this repo's default — rewrites the sha, so on "no" continue with git cherry origin/main <your-sha> (a leading - means a patch-equivalent commit is already upstream) and gh pr list --state merged --head <branch> (a merged PR on the branch IS the landing record, whatever the merge style). Any one of the three answering "landed" means the branch deletion was routine post-merge cleanup. That check would have ended the 2026-07-29 incident in seconds.

The corollary for the reading side: a vanished branch plus a merged PR containing your commit is the signature of completed publication, not destruction. Check the PR history for the branch before writing "another session deleted my work" into any summary — the next session will believe you.

A heavier option — a branch-claim mechanism mirroring surface-claim-guard so a session can see another owns a pushed branch — remains unbuilt; the two habits above have so far been sufficient.

Practical recommendations for sessions

Signals 1, 5 and 6 are built. Alongside them, sessions should:

  1. Check live sessions before non-trivial work. ps -ef | grep ' claude --' plus lsof -p <pid> | awk '$4=="cwd"' shows who's where. Five sessions in one cwd is the smell.

  2. Diagnose before risky git operations. Already in CLAUDE.md, but worth re-emphasising: rebases, cherry-picks, and reset --hard on a shared checkout race with concurrent work. The 2026-06-08 incident where another session's reset --hard wiped uncommitted edits is the canonical example.

  3. When polishing a surface, briefly check git log -p on its consumers. If the consumer's recent history shows it's being decoupled from your surface, your polish may be about to be orphaned. A 30-second check, not a full audit.

  4. Surface intent to the user when starting structural work. "I'm about to delete SessionsView" is the kind of statement that, if surfaced to the user, gives them a chance to say "wait, another session is polishing that." The user is the only entity with cross-session visibility today.

  5. Prefer worktrees for any task that involves more than three file edits or any structural change. The worktree itself is a cheap signal — git worktree list is one of the only places where "Session B is working on branch C" is visible to Session A.

  6. Check the PR layer before publishing and before crying destruction. gh pr list --state all --head <branch> before gh pr create; and on No commits between main and <branch>, verify the work landed (merge-base --is-ancestor, then git cherry for squash merges, then gh pr list --state merged --head) before concluding anything was lost. See "The PR layer" section above for the incident that made both habits policy.

Open questions

  • Does the advisory claim hook (§1) catch enough, or is the unbuilt branch-claim mechanism still needed?
  • Does any of this generalise beyond Claude Code? The same failure mode could exist for human pair-programming sessions sharing one checkout, but humans usually have a Slack channel.
  • The user is currently the cross-session bridge by hand ("session A is doing X, session B is doing Y"). Does any of this make their job easier or just relocate the bookkeeping?

Source

Patterns observed via git log on origin/main over the 2026-05-30 → 2026-06-09 window. The orphaning case (PR #305 → 14466de) is reconstructable from commits alone; the duplicate-work false positives required diffing author_date vs commit_date on near-duplicate subjects. No prior cross- session coordination doc existed in this repo when this was written.