AI

做到一半的代理任务要换台机器接着干:怎么交接才不丢东西?(中英切换)

This post is currently available in Chinese only. The original text follows.Open the Chinese page →

还是那个三人团队、七台电脑:1–3 号机开发,4、5 号机审查,6 号机做 Windows 验证,7 号机负责合并和运维。

周一下午四点,1 号机上的代理正在做 feat/cny-rounding:人民币金额改成四舍五入,日元保留 0 位小数。它已经做了一半,这时候的 1 号机上是这样的:分支推过一次,后来又有 2 个提交没推;src/rounding.py 里把 ROUND_HALF_EVEN 换成 ROUND_HALF_UP,已经暂存;测试文件改了一行,还没暂存;新建了 src/money_ctx.py 和一份测试用例 CSV,都没被 Git 跟踪;src/legacy_round.py 被 git rm 了;stash 里压着一个“银行家舍入表”的实验;被忽略的 .wip/notes.md 里记着最值钱的一句话:“全用 HALF_EVEN 时,发票 2019-0042 差一分钱,财务不收”。另外还有一个 .env.local,里面是发票接口的令牌,以及 3 MB 的缓存。

小李要去开会,任务得交给 2 号机上的代理接着做。最顺手的三个办法分别是:直接推分支;git add -A 提交一个 WIP 再推;把整个目录拷过去。我把这个现场写成脚本,用这三种办法再加一个我自己写的办法,各交接一次,然后在 2 号机上逐个文件比对哈希。

我的判断是:**交接分两件事,代码状态要完整、安全地搬过去,思路要压成一页必须填写的说明;而且同一时刻只能有一台机器在做这个任务。**Git 能搬代码,但它只认提交,暂存区、未跟踪文件、stash、被忽略的笔记都只存在于那台机器上。代理的对话记录也只存在于那台机器上,而且和路径、账号绑在一起。下面是实测结果、我写的两个脚本,以及过程中两次写错的地方。

四种交接方式,2 号机上各剩下什么

玩具仓库、分支和 1 号机的状态都由 lab/build.sh 生成,每种方式都从同一份状态重建。远端是一个裸仓库,挂了一个 post-receive 钩子:只要有 refs/heads/* 被推送,就记一行“审查触发”,模拟 4、5 号机监听分支推送、来了新代码就开始审。

四种交接方式在 11 项上的结果:推分支只保住 2 项;WIP 提交保住 5 项,暂存和未暂存的区分全部丢失;整个目录拷贝保住 8 项,但把 .env.local 里的令牌带走了;wip-handoff 保住 11 项

**A:直接推分支。**2 号机拉下来,只有那 2 个提交过去了。暂存的舍入改动、测试改动、两个新文件、删除、stash、笔记全部没过去。2 号机的代理面对的是一棵干净的树,它会从头再把 HALF_EVEN 换一遍,也不知道发票 2019-0042 的教训。顺带,这次推送触发了一次审查,4 号机去审了一个半成品。

**B:WIP 提交再推。**这是我见过最常用的办法。文件内容都过去了,2 号机 git reset HEAD~1 之后,原来暂存的和没暂存的修改全变成了“未暂存”。这个区分本身是信息:代理暂存舍入改动,意思是“这部分我确认了”,测试那一行还在试。被忽略的笔记和 stash 照样丢失,审查也照样被触发。

更麻烦的是两台机器同时在干的时候。我让 2 号机提交“m2: JPY zero decimals”并推送,然后让 1 号机上还没停下的代理按它的习惯 --amend 一下再 push -f。远端最后三个提交是“WIP handoff / add rounding table / add per_currency”,2 号机的提交没了,而且不报错。这个场景并不罕见,代理不会因为人走了就自己停下来。

**C:整个目录拷贝。**rsync 或者网盘同步。所有东西都过去了,包括 stash 和暂存区,一共 3,033,115 字节。问题在于它不加挑选:.env.local 里的令牌也到了 2 号机,3 MB 缓存也到了。而且拷贝不防双开,1 号机的代理照样能推。

**D:wip-handoff / wip-takeover。**2 号机上除了 .env.local 不在以外,每个文件的哈希、暂存区、删除、stash 数量都和 1 号机一致。交接本身没有触发审查,审查只在 2 号机接手后正常推送时才触发。

D 前两次都被它自己的检查拒绝了。第一次是因为 HANDOFF.md 还是模板,“下一步”一节写着 TODO。第二次我在 lab 里模拟了代理常干的两件事:目录里留一个 8 MB 的数据库转储,测试夹具里写一个 ghp_ 开头的令牌。这两样东西用 B 或者 C 交接都会被一起带走,B 还会把它们推上 GitHub。

机制:什么放在 Git 里,什么放在旁路上

交接走 Git 的旁路:1 号机把快照和租约推到 refs/wip/,分支 refs/heads 不动,审查不被触发;2 号机检查租约写的是自己才恢复;1 号机继续推、交给 3 号机、3 号机抢着接手,三种情况都被拒绝

办法是把交接内容放在 refs/heads/ 以外的引用里。Git 允许推送任意 refs/ 下的引用,GitHub 也接受:我往这个博客的私有仓库推了一个 refs/wip/probe,推送成功,一个新的 git clone 不会把它带下来,测完已删除。这里有一点我没验证:GitHub 文档说 push 事件针对分支和标签,我没有在带 workflow 的仓库上实测过自定义引用会不会触发 Actions。

交接时会写三类引用:

  • refs/wip/snap/<分支>:快照提交。第一个父提交是 HEAD,第二个父提交记录暂存区,提交本身的树是工作区加未跟踪文件,再加 .wip/carry 里列出的被忽略文件。这是 git stash 的结构,区别在于用的是临时索引文件,1 号机的工作区、暂存区和分支一个字节都不动。
  • refs/wip/lease/<分支>:租约。一个空树提交,提交说明里写 owner=m2 snap=<快照哈希>。
  • refs/wip/stash/<分支>/N:stash 条目。

快照、租约和 stash 在一次 git push --atomic 里一起写,租约用 --force-with-lease=<租约>:<旧值> 做比较并交换:远端租约必须还是我读到的那个值,否则整次推送失败。2 号机恢复时先检查租约写的是自己、租约里的快照哈希和取到的快照一致、本地工作区干净,然后 git read-tree -u --reset 恢复工作区,再 git read-tree 恢复暂存区,最后 git stash store 放回 stash。

防双开有两层。租约保证同一时间只有一台机器能接手或再交出去;pre-push 钩子让 1 号机推 refs/heads/feat/cny-rounding 时先查租约,主人不是自己就拒绝。lab 里 1 号机的代理继续推送、1 号机改主意交给 3 号机、3 号机抢着接手,三种都被拒绝了。

思路这一半,我选的是一页必填的 HANDOFF.md,三节:下一步、试过不行的、别碰。脚本只检查三节都有内容、没有 TODO,内容好坏还是靠写的人。

代理的对话记录要不要一起搬

我在盒子上的 Claude Code 2.1.292 里看了它怎么存会话:放在 ~/.claude/projects/ 下,目录名是工作目录把 / 换成 -,比如 -tmp-cctest/<会话 id>.jsonl。每一行都带着绝对路径 cwd、gitBranch,还有当时的系统环境说明。也就是说,记录和“哪台机器、哪个路径”绑在一起,6 号机是 Windows,路径格式都不一样。

已经有人把这件事做得很完整。SameSession 把原生会话记录和工作区状态加密后放进隔离的 Git 引用,用比较并交换的租约防止两台机器同时恢复,思路和我这套很接近,而且它的设计文档老实写了租约只是“防止误双开”,不是分布式一致性。有一处不同:为了兼容各家托管平台,它默认把数据放在 refs/heads/same-session/... 这样的分支引用下,在一个分支一推就开审的流水线里,需要让 4、5 号机先把这个前缀过滤掉。agent-handoff 走 SSH 直接把会话、分支和未提交修改送到另一台机器,并改写记录里的旧路径。

我最后没搬记录,原因有三个。第一,在这七台机器上,交接经常是换人,不只是换机器;2 号机可能是小王的账号。会话记录里有对方读过的每个文件、跑过的每条命令输出,交给另一个人之前得有人看过。第二,长会话里大部分是走过的弯路,接手的代理需要的是结论。第三,记录格式是厂商的内部实现,SameSession 自己的 README 也提醒格式可能变。如果你们是同一个人在两台自己的机器之间搬,并且长会话的上下文真的值钱,用 SameSession 比用我这个脚本合适。

我写错的两个地方

**第一个:接手后推不上去。**第一版 wip-takeover 用 checkout -B 重建分支,没有设置上游。2 号机提交后 git push,报 “has no upstream branch”。代理遇到这种报错,常见的处理是 git push -u origin HEAD,大多数时候没事。现在脚本会先取远端分支并 --set-upstream-to,远端还没有这个分支时就直接写好 branch.<分支>.remote 和 merge。

**第二个更危险:租约根本没起作用。**第一版把所有引用拼成 "${REFS[@]/#/+}",每条前面都加了 +。我原以为 --force-with-lease 会兜底,后来单独测了一下:引用前面带 + 时,--force-with-lease 指定的旧值被直接忽略,过期的值也能强推成功。lab 一开始没暴露这个问题,因为它是串行的,每次推送前读到的租约都是最新的。

我补了一个竞速测试 lab/race.sh:同一台 1 号机的两个终端几乎同时交接,一个交给 m2,一个交给 m3;后者读完租约后停 2 秒,前者先推完。第一版 5 次里 5 次两边都报“成功”,最后租约归 m3,而 m2 收到的提示是自己接手了。修好后 5 次里每次只有一边成功,另一边报 stale info。修改只有一处:

-REFS=("$WCOMMIT:$SNAP" "$LCOMMIT:$LEASE")
-git push -q --atomic --force-with-lease="$LEASE:$OLD" "$REMOTE" "${REFS[@]/#/+}"
+# 租约那条不能带 "+",带了 --force-with-lease 就失效
+REFS=("+$WCOMMIT:$SNAP" "$LCOMMIT:$LEASE")
+git push -q --atomic --force-with-lease="$LEASE:$OLD" "$REMOTE" "${REFS[@]}"

还有一个小插曲:在 2 号机删已合并的分支时,pre-push 钩子拦住了我自己,因为那个终端没设 MACHINE_ID。钩子对删除分支也生效,这是对的;合并后删分支最好交给 GitHub 的自动删除,或者在 7 号机上做。

脚本

交出方:

scripts/wip-handoff

#!/usr/bin/env bash
# wip-handoff --to <machine>
# Snapshot the current branch's in-flight state (unpushed commits, staged,
# unstaged, untracked, deletions, files listed in .wip/carry) into
# refs/wip/snap/<branch>, and move the lease refs/wip/lease/<branch> to <machine>.
# Never touches your working tree, index, branch, or refs/heads on the remote.
set -euo pipefail
die(){ echo "wip-handoff: $*" >&2; exit 1; }
TO=""; while [ $# -gt 0 ]; do case "$1" in --to) TO="$2"; shift 2;; *) die "unknown arg $1";; esac; done
ME="${MACHINE_ID:-$(git config --get wip.machine || true)}"
[ -n "$ME" ] || die "set MACHINE_ID or git config wip.machine"
[ -n "$TO" ] || die "--to <machine> required"
REMOTE="${WIP_REMOTE:-origin}"
MAX_BYTES="${WIP_MAX_BYTES:-5242880}"
BR="$(git symbolic-ref --short HEAD)" || die "detached HEAD"
TOP="$(git rev-parse --show-toplevel)"; cd "$TOP"
SNAP="refs/wip/snap/$BR"; LEASE="refs/wip/lease/$BR"

# 1. intent: a handoff without "why" and "next" is just a tarball
H=.wip/HANDOFF.md
[ -f "$H" ] || die "write $H first (sections: ## Next, ## Tried and failed, ## Do not touch)"
for s in "## Next" "## Tried and failed" "## Do not touch"; do
  body="$(awk -v s="$s" 'index($0,s)==1{f=1;next} /^## /{f=0} f' "$H" | grep -v '^[[:space:]]*$' | grep -v 'TODO' || true)"
  [ -n "$body" ] || die "$H: section '$s' is empty or TODO"
done

# 2. lease: only the current owner (or nobody) may hand off
OLD="$(git ls-remote "$REMOTE" "$LEASE" | cut -f1)"
if [ -n "$OLD" ]; then
  git fetch -q "$REMOTE" "+$LEASE:refs/wip-remote/lease/$BR"
  OWNER="$(git log -1 --format=%B "refs/wip-remote/lease/$BR" | sed -n 's/^owner=//p')"
  [ "$OWNER" = "$ME" ] || die "lease for $BR is held by $OWNER, not $ME"
fi

sleep "${WIP_TEST_PAUSE:-0}"   # test hook: widen the read-then-push window (lab/race.sh)

# 3. build snapshot: I = current index, W = worktree + untracked + .wip/carry
HEADC="$(git rev-parse HEAD)"
ITREE="$(git write-tree)"
ICOMMIT="$(git commit-tree "$ITREE" -p "$HEADC" -m "wip index $BR")"
TMPIDX="$(mktemp)"; trap 'rm -f "$TMPIDX"' EXIT
cp "$(git rev-parse --git-path index)" "$TMPIDX"
export GIT_INDEX_FILE="$TMPIDX"
git add -A
git add -f "$H"
if [ -f .wip/carry ]; then
  grep -v '^\s*#' .wip/carry | grep -v '^\s*$' | while read -r p; do git add -f -- "$p"; done
fi
WTREE="$(git write-tree)"
unset GIT_INDEX_FILE

# 4. guards on what would leave this machine
CHANGED="$(git diff --name-only --no-renames "$HEADC" "$WTREE")"
bad=""
while IFS= read -r f; do
  [ -n "$f" ] || continue
  case "$f" in .env|.env.*|*/.env|*/.env.*|*.pem|*id_rsa*) bad="$bad\n  secret-looking file: $f";; esac
  sz="$(git cat-file -s "$WTREE:$f" 2>/dev/null || echo 0)"
  [ "$sz" -le "$MAX_BYTES" ] || bad="$bad\n  too large ($sz bytes): $f  (add to .gitignore or raise WIP_MAX_BYTES)"
done <<< "$CHANGED"
hits="$(git diff "$HEADC" "$WTREE" | grep '^+' | grep -nE 'gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{40,}|AKIA[0-9A-Z]{16}|sk-[A-Za-z0-9_-]{20,}|-----BEGIN [A-Z ]*PRIVATE KEY' || true)"
[ -z "$hits" ] || bad="$bad\n  token-looking content in diff:\n$(echo "$hits" | cut -c1-80 | sed 's/^/    /')"
[ -z "$bad" ] || die "refusing to snapshot:$(printf "$bad")"

STASHES="$(git stash list --format=%H 2>/dev/null || true)"
NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
WCOMMIT="$(printf 'wip snapshot %s\n\nbranch=%s\nfrom=%s\nto=%s\nbase=%s\nat=%s\nstashes=%s\n' \
  "$BR" "$BR" "$ME" "$TO" "$HEADC" "$NOW" "$(echo $STASHES | wc -w)" \
  | git commit-tree "$WTREE" -p "$HEADC" -p "$ICOMMIT")"
EMPTY="$(git hash-object -t tree /dev/null)"
LCOMMIT="$(printf 'lease %s\n\nowner=%s\nfrom=%s\nsnap=%s\nat=%s\n' "$BR" "$TO" "$ME" "$WCOMMIT" "$NOW" | git commit-tree "$EMPTY")"

# 5. one atomic push: snapshot + lease (compare-and-swap on the lease)
# NOTE: no "+" on the lease refspec -- a "+" silently overrides --force-with-lease.
REFS=("+$WCOMMIT:$SNAP" "$LCOMMIT:$LEASE")
i=0; for s in $STASHES; do REFS+=("+$s:refs/wip/stash/$BR/$i"); i=$((i+1)); done
git push -q --atomic --force-with-lease="$LEASE:$OLD" "$REMOTE" "${REFS[@]}" \
  || die "push rejected (someone moved the lease?)"
git config "wip.branch.$BR.handedoff" "$TO"
echo "handed off $BR -> $TO  snap=${WCOMMIT:0:10} base=${HEADC:0:10} files=$(echo "$CHANGED" | grep -c . || true) stashes=$i"
echo "stop the agent on this machine now; pushes to $BR from $ME are blocked by the pre-push hook."

接手方:

scripts/wip-takeover

#!/usr/bin/env bash
# wip-takeover <branch>
# On the receiving machine: fetch refs/wip/snap/<branch>, check the lease names
# this machine, recreate branch + index + worktree exactly, print HANDOFF.md.
set -euo pipefail
die(){ echo "wip-takeover: $*" >&2; exit 1; }
BR="${1:-}"; [ -n "$BR" ] || die "usage: wip-takeover <branch>"
ME="${MACHINE_ID:-$(git config --get wip.machine || true)}"; [ -n "$ME" ] || die "set MACHINE_ID"
REMOTE="${WIP_REMOTE:-origin}"
cd "$(git rev-parse --show-toplevel)"
git fetch -q "$REMOTE" "+refs/wip/snap/$BR:refs/wip-remote/snap/$BR" "+refs/wip/lease/$BR:refs/wip-remote/lease/$BR" \
  || die "no handoff for $BR on $REMOTE"
L="refs/wip-remote/lease/$BR"; W="refs/wip-remote/snap/$BR"
OWNER="$(git log -1 --format=%B "$L" | sed -n 's/^owner=//p')"
[ "$OWNER" = "$ME" ] || die "lease for $BR names $OWNER, not $ME"
[ "$(git log -1 --format=%B "$L" | sed -n 's/^snap=//p')" = "$(git rev-parse "$W")" ] || die "lease and snapshot disagree; ask the sender to re-run wip-handoff"
[ -z "$(git status --porcelain --untracked-files=all)" ] || die "working tree not clean; commit or stash first"
BASE="$(git rev-parse "$W^1")"; ITREE="$(git rev-parse "$W^2^{tree}")"
if git rev-parse -q --verify "refs/heads/$BR" >/dev/null; then
  git merge-base --is-ancestor "refs/heads/$BR" "$BASE" || die "local $BR has commits not in the snapshot; resolve by hand"
fi
git checkout -q -B "$BR" "$BASE"
git read-tree -u --reset "$W"      # worktree := snapshot (adds, edits, deletions)
git read-tree "$ITREE"             # index := what the sender had staged
n=0
git fetch -q "$REMOTE" "+refs/wip/stash/$BR/*:refs/wip-remote/stash/$BR/*" 2>/dev/null || true
for s in $(git for-each-ref --format='%(objectname)' --sort=-refname "refs/wip-remote/stash/$BR/"); do
  git stash store -m "from handoff" "$s"; n=$((n+1)); done
# the branch itself may exist on the remote (pushed before the handoff): track it
if git fetch -q "$REMOTE" "+refs/heads/$BR:refs/remotes/$REMOTE/$BR" 2>/dev/null; then
  git branch -q --set-upstream-to="$REMOTE/$BR" "$BR"
else
  git config "branch.$BR.remote" "$REMOTE"; git config "branch.$BR.merge" "refs/heads/$BR"
fi
git config "wip.branch.$BR.handedoff" ""
echo "took over $BR at base ${BASE:0:10}; restored stashes=$n"
echo "----- .wip/HANDOFF.md -----"; cat .wip/HANDOFF.md

装在 1–3 号机上的钩子:

hooks/pre-push

#!/usr/bin/env bash
# Refuse to push refs/heads/<b> unless this machine holds refs/wip/lease/<b>
# (or there is no lease). Install: cp hooks/pre-push .git/hooks/ && chmod +x
ME="${MACHINE_ID:-$(git config --get wip.machine)}"
remote="$1"
while read -r lref lsha rref rsha; do
  case "$rref" in refs/heads/*) b="${rref#refs/heads/}";; *) continue;; esac
  l="$(git ls-remote "$remote" "refs/wip/lease/$b" | cut -f1)"
  [ -n "$l" ] || continue
  git fetch -q "$remote" "+refs/wip/lease/$b:refs/wip-remote/lease/$b"
  owner="$(git log -1 --format=%B "refs/wip-remote/lease/$b" | sed -n 's/^owner=//p')"
  if [ "$owner" != "$ME" ]; then
    echo "pre-push: $b was handed off to $owner; $ME may not push it. (wip-takeover on $ME first, or ask $owner to hand it back)" >&2
    exit 1
  fi
done
exit 0

写进开发机 AGENTS.md 的规则:

templates/AGENTS-handoff.md

## Handing off unfinished work (machines 1-3)
- Never push a "WIP" commit to a branch to move work to another machine; review machines 4/5 pick up every branch push.
- To hand off: stop, update .wip/HANDOFF.md (Next / Tried and failed / Do not touch, no TODO left), then run
  `wip-handoff --to <machine>`. If it refuses (big file, token, empty section), fix the cause; never raise limits silently.
- Put local-only notes you want carried in .wip/carry (one path per line). .env* never travels.
- On the receiving machine run `wip-takeover <branch>` in a clean clone, read the printed HANDOFF.md before editing anything.
- If `git push` says the branch was handed off, stop working on it. Do not delete the lease or the hook.

代价,以及什么时候不该用

  • **pre-push 钩子拦不住 --no-verify。**它是安全带,不是锁。代理真想推还是能推。GitHub 的分支保护和规则集按分支名、按人配置,写不出“租约主人是谁才能推”这种条件,我没找到服务端的办法。要更硬的约束,只能让 1 号机交接后把代理进程停掉,或者干脆撤掉它对这个分支的推送权限。
  • **能读仓库的人都能取 refs/wip/*。**公开仓库不要用,私有仓库也要想清楚快照里有什么。秘密检查只是几条正则,.env* 按文件名拦,别的格式它不认识。
  • **引用在 GitHub 网页上看不到,合并后要清理。**包里有 wip-prune,在 7 号机上跑,删除那些远端分支已经不存在的 refs/wip/*。删掉引用后,对象不会马上从服务器上消失。
  • **大文件和二进制产物不适合。**超过 5 MB 的文件默认拒绝,这是故意的。要交接的是模型权重、构建产物这类东西,应该放到对象存储,在 HANDOFF.md 里写地址。
  • **同一个人临时离开座位,用不着这些。**用 Claude Code 的 Remote Control 或者 SSH 加 tmux,进程留在原机器上,你换个设备接着看就行。
  • **半小时以内的小任务,直接重来更便宜。**让 2 号机从 issue 开始做,比恢复一个半成品快,而且不会继承 1 号机的错误假设。
  • 6 号机是 Windows,我没有在 Git Bash 下测过这几个脚本。

最常见的反对意见是:直接推一个 WIP 提交,接手的人 reset 一下,十秒钟的事,何必写引用、租约、钩子。如果你们只有一台开发机、没有自动审查、交接一个月才一两次,这个意见完全成立。到了七台机器、分支一推就有两台机器开始审、代理不会自己停下来的时候,WIP 提交会带来三个问题:4 号机白白审一个半成品,令牌和转储被推上 GitHub,强推覆盖对方的提交且不报错。前一件和后一件 lab 里实际发生了;中间那件,只要代理在目录里留下 D 第二次碰到的那两个文件,git add -A 就会照单全收。每一件的代价都比多跑一个脚本大。

明天就能照做的

  1. 解开下面的包,跑 bash lab/run_all.sh,看 lab/out/ 里四种方式各丢了什么;再跑 bash lab/race.sh "$PWD/scripts/wip-handoff" 5,确认每次只有一方成功。
  2. 在 1–3 号机上 git config wip.machine m1(m2、m3 同理),把 hooks/pre-push 拷到 .git/hooks/,把 scripts/ 放进 PATH。
  3. 把上面那段规则加进开发机的 AGENTS.md,把 templates/HANDOFF.md 放进仓库。
  4. 在 7 号机的例行任务里加一行 wip-prune。
  5. 下一次有人要中途离开时,不再推 WIP 提交,先写 HANDOFF.md,再 wip-handoff --to m2。

我的结论是:多台机器协作时,最容易丢的是那些从来没进过 Git 的东西,最危险的是两台机器都以为任务归自己。代码状态用 Git 的旁路引用完整搬过去,思路压成一页必填的说明,再用一个比较并交换的租约保证同一时间只有一台机器在做。今天可以先把包解开,在 /tmp 里跑一遍 lab,然后在你们最常交接的那个仓库里试一次真实的交接。

完整的脚本、钩子、模板、lab 构建脚本和竞速测试:wip-handoff-kit.tar.gz。

Same three-person team, same seven computers: machines 1–3 develop, 4 and 5 review, 6 does Windows validation, 7 handles merging and ops.

Monday, 4 p.m. The agent on machine 1 is working on feat/cny-rounding: round CNY amounts half-up, keep zero decimals for JPY. It is about halfway done, and machine 1 looks like this. The branch was pushed once, and there are 2 more commits that weren't. In src/rounding.py, ROUND_HALF_EVEN has been changed to ROUND_HALF_UP and staged. One line of the test file has changed but isn't staged. src/money_ctx.py and a CSV of test cases are new and untracked. src/legacy_round.py has been git rm'd. A "bankers-rounding table" experiment sits in the stash. The ignored .wip/notes.md holds the most valuable sentence of all: "with HALF_EVEN everywhere, invoice 2019-0042 is off by one cent and finance rejects it." There is also a .env.local holding the invoice API token, and 3 MB of cache.

Xiao Li has to go to a meeting, and the agent on machine 2 needs to pick the task up. The three obvious ways are: push the branch; git add -A, commit a WIP and push; or copy the whole directory over. I scripted this exact state and handed it off with each of those three methods plus one I wrote myself, then compared every file hash on machine 2.

My position: a handoff is two jobs. The code state has to move completely and safely, and the reasoning has to be compressed into one page that must be filled in. And only one machine at a time may be working on the task. Git can move code, but it only knows about commits. The index, untracked files, the stash and ignored notes exist only on that machine. So does the agent's conversation transcript, and it is tied to paths and accounts. Below are the measurements, the two scripts I wrote, and two places where I got them wrong.

Four handoff methods, and what each one leaves on machine 2

The toy repo, the branch and machine 1's state are all generated by lab/build.sh; every method starts from a fresh copy of the same state. The remote is a bare repo with a post-receive hook that logs a "review trigger" for every push to refs/heads/*, to simulate machines 4 and 5 listening for branch pushes and starting a review whenever new code arrives.

Results of four handoff methods on 11 items: pushing the branch keeps 2; a WIP commit keeps 5 and loses the staged/unstaged split; copying the whole directory keeps 8 but carries the token in .env.local; wip-handoff keeps 11

A: push the branch. When machine 2 pulls, only the 2 commits arrive. The staged rounding change, the test change, the two new files, the deletion, the stash and the notes are all gone. Machine 2's agent faces a clean tree. It will redo the HALF_EVEN swap from scratch and never learn about invoice 2019-0042. As a bonus, the push triggered a review, and machine 4 went off to review half-finished work.

B: WIP commit, then push. This is the method I see most. All file contents arrive, but after git reset HEAD~1 on machine 2, whatever was staged and whatever wasn't all comes back as "unstaged." That distinction is information in itself: the agent staged the rounding change because it meant "this part is settled," while the test line was still being tried. The ignored notes and the stash are lost again, and review is triggered again.

Worse is when both machines keep working. I had machine 2 commit "m2: JPY zero decimals" and push. Then I had machine 1's agent, which hadn't stopped, do what it habitually does: --amend and push -f. The remote's last three commits ended up as "WIP handoff / add rounding table / add per_currency". Machine 2's commit was gone, with no error. This is not a rare case: an agent does not stop just because its human walked away.

C: copy the whole directory, with rsync or a sync drive. Everything arrives, including the stash and the index: 3,033,115 bytes in total. The problem is that nothing gets filtered. The token in .env.local lands on machine 2, and so does the 3 MB cache. Copying also does nothing to prevent two machines working at once; machine 1's agent can still push.

D: wip-handoff / wip-takeover. On machine 2, every file hash, the index, the deletion and the stash count match machine 1. The only difference is that .env.local isn't there. The handoff itself triggered no review; review starts only when machine 2 pushes normally after taking over.

D was refused by its own checks on the first two attempts. The first time, HANDOFF.md was still the template and its "Next" section said TODO. For the second attempt I simulated two things agents commonly do: leave an 8 MB database dump in the directory, and write a token starting with ghp_ into a test fixture. With B or C both of those would have travelled along, and B would have pushed them to GitHub.

Mechanism: what goes in Git, and what goes on the side channel

The handoff uses a Git side channel: machine 1 pushes a snapshot and a lease to refs/wip/, refs/heads is untouched and no review fires; machine 2 restores only if the lease names it; machine 1 pushing again, handing it to machine 3 instead, and machine 3 trying to take it are all refused

The idea is to keep the handoff in refs outside refs/heads/. Git lets you push any ref under refs/, and GitHub accepts them. I pushed a refs/wip/probe to this blog's private repo: the push succeeded, a fresh git clone doesn't bring it down, and I deleted it after testing. One thing I didn't verify: GitHub's docs describe the push event in terms of branches and tags, and I haven't tested on a repo with workflows whether a custom ref triggers Actions.

A handoff writes three kinds of refs:

  • refs/wip/snap/<branch>: the snapshot commit. Its first parent is HEAD, its second parent records the index, and its own tree is the working tree plus untracked files plus any ignored files listed in .wip/carry. That's the same structure git stash uses. The difference is that it is built with a temporary index file, so machine 1's working tree, index and branch don't change by a single byte.
  • refs/wip/lease/<branch>: the lease. An empty-tree commit whose message says owner=m2 snap=<snapshot sha>.
  • refs/wip/stash/<branch>/N: the stash entries.

The snapshot, the lease and the stashes are written in a single git push --atomic. The lease uses --force-with-lease=<lease>:<old value> as a compare-and-swap: the remote lease must still have the value I read, or the whole push fails. When machine 2 restores, it first checks that the lease names it, that the snapshot sha in the lease matches the snapshot it fetched, and that its local working tree is clean. Then git read-tree -u --reset restores the working tree, git read-tree restores the index, and git stash store puts the stashes back.

Preventing two machines from working at once has two layers. The lease ensures only one machine at a time can take the task over or hand it on. The pre-push hook makes machine 1 check the lease before pushing refs/heads/feat/cny-rounding, and refuses if the owner isn't machine 1. In the lab, machine 1's agent pushing again, machine 1 changing its mind and handing to machine 3, and machine 3 trying to take over were all refused.

For the reasoning half I chose a one-page HANDOFF.md that must be filled in, with three sections: Next, Tried and failed, Do not touch. The script only checks that all three have content and no TODO. Whether the content is any good still depends on whoever writes it.

Should the agent's transcript move too?

I looked at how Claude Code 2.1.292 on the box stores sessions. They live under ~/.claude/projects/, in a directory named after the working directory with / replaced by -, for example -tmp-cctest/<session id>.jsonl. Every line carries the absolute cwd, the gitBranch, and the environment description from that moment. In other words, the transcript is tied to "which machine, which path." Machine 6 runs Windows, where even the path format differs.

Others have already done this thoroughly. SameSession encrypts the native session transcript and workspace state into isolated Git refs, and uses a compare-and-swap lease to stop two machines resuming at once. The idea is very close to mine, and its design doc honestly says the lease only "prevents accidental concurrent resumes" and is not distributed consensus. One difference: for compatibility across hosting providers it stores data under branch refs like refs/heads/same-session/... by default. In a pipeline where every branch push starts a review, machines 4 and 5 would need to filter that prefix out first. agent-handoff sends the session, the branch and uncommitted changes straight to the other machine over SSH and rewrites the old paths inside the transcript.

I ended up not moving the transcript, for three reasons. First, on these seven machines a handoff often means a different person, not just a different machine; machine 2 might be on Xiao Wang's account. The transcript holds every file the other agent read and the output of every command it ran, and someone should look at it before it goes to another person. Second, most of a long session is detours, and the agent taking over needs conclusions. Third, the transcript format is the vendor's internal implementation; SameSession's own README warns it can change. If it is one person moving between two of their own machines, and the context of a long session is genuinely valuable, SameSession is a better fit than my script.

The two things I got wrong

First: the receiver couldn't push. The first version of wip-takeover rebuilt the branch with checkout -B and didn't set an upstream. After machine 2 committed, git push failed with "has no upstream branch." An agent hitting that error usually runs git push -u origin HEAD, which is fine most of the time. Now the script fetches the remote branch first and runs --set-upstream-to; if the remote doesn't have the branch yet, it writes branch.<branch>.remote and merge directly.

The second was more dangerous: the lease did nothing. The first version built the refspecs as "${REFS[@]/#/+}", putting a + in front of every one. I assumed --force-with-lease would still protect the lease, then tested it on its own: with a + in front of the refspec, the old value given to --force-with-lease is simply ignored, and a stale value still force-pushes successfully. The lab didn't catch this at first because it ran serially, so every push read the latest lease.

I added a race test, lab/race.sh: two terminals on the same machine 1 hand off almost simultaneously, one to m2 and one to m3. The second pauses 2 seconds after reading the lease, while the first finishes its push. The first version reported "success" on both sides in 5 of 5 runs, the lease ended up with m3, and m2 had been told it was taking over. After the fix, exactly one side succeeded in each of the 5 runs, and the other got stale info. The change is one spot:

-REFS=("$WCOMMIT:$SNAP" "$LCOMMIT:$LEASE")
-git push -q --atomic --force-with-lease="$LEASE:$OLD" "$REMOTE" "${REFS[@]/#/+}"
+# no "+" on the lease refspec: with it, --force-with-lease stops working
+REFS=("+$WCOMMIT:$SNAP" "$LCOMMIT:$LEASE")
+git push -q --atomic --force-with-lease="$LEASE:$OLD" "$REMOTE" "${REFS[@]}"

One smaller episode: when I deleted the merged branch from machine 2, the pre-push hook stopped me, because that terminal had no MACHINE_ID set. The hook also applies to branch deletions, which is correct. Deleting branches after merge is better left to GitHub's automatic deletion, or done on machine 7.

The scripts

Sender:

scripts/wip-handoff

#!/usr/bin/env bash
# wip-handoff --to <machine>
# Snapshot the current branch's in-flight state (unpushed commits, staged,
# unstaged, untracked, deletions, files listed in .wip/carry) into
# refs/wip/snap/<branch>, and move the lease refs/wip/lease/<branch> to <machine>.
# Never touches your working tree, index, branch, or refs/heads on the remote.
set -euo pipefail
die(){ echo "wip-handoff: $*" >&2; exit 1; }
TO=""; while [ $# -gt 0 ]; do case "$1" in --to) TO="$2"; shift 2;; *) die "unknown arg $1";; esac; done
ME="${MACHINE_ID:-$(git config --get wip.machine || true)}"
[ -n "$ME" ] || die "set MACHINE_ID or git config wip.machine"
[ -n "$TO" ] || die "--to <machine> required"
REMOTE="${WIP_REMOTE:-origin}"
MAX_BYTES="${WIP_MAX_BYTES:-5242880}"
BR="$(git symbolic-ref --short HEAD)" || die "detached HEAD"
TOP="$(git rev-parse --show-toplevel)"; cd "$TOP"
SNAP="refs/wip/snap/$BR"; LEASE="refs/wip/lease/$BR"

# 1. intent: a handoff without "why" and "next" is just a tarball
H=.wip/HANDOFF.md
[ -f "$H" ] || die "write $H first (sections: ## Next, ## Tried and failed, ## Do not touch)"
for s in "## Next" "## Tried and failed" "## Do not touch"; do
  body="$(awk -v s="$s" 'index($0,s)==1{f=1;next} /^## /{f=0} f' "$H" | grep -v '^[[:space:]]*$' | grep -v 'TODO' || true)"
  [ -n "$body" ] || die "$H: section '$s' is empty or TODO"
done

# 2. lease: only the current owner (or nobody) may hand off
OLD="$(git ls-remote "$REMOTE" "$LEASE" | cut -f1)"
if [ -n "$OLD" ]; then
  git fetch -q "$REMOTE" "+$LEASE:refs/wip-remote/lease/$BR"
  OWNER="$(git log -1 --format=%B "refs/wip-remote/lease/$BR" | sed -n 's/^owner=//p')"
  [ "$OWNER" = "$ME" ] || die "lease for $BR is held by $OWNER, not $ME"
fi

sleep "${WIP_TEST_PAUSE:-0}"   # test hook: widen the read-then-push window (lab/race.sh)

# 3. build snapshot: I = current index, W = worktree + untracked + .wip/carry
HEADC="$(git rev-parse HEAD)"
ITREE="$(git write-tree)"
ICOMMIT="$(git commit-tree "$ITREE" -p "$HEADC" -m "wip index $BR")"
TMPIDX="$(mktemp)"; trap 'rm -f "$TMPIDX"' EXIT
cp "$(git rev-parse --git-path index)" "$TMPIDX"
export GIT_INDEX_FILE="$TMPIDX"
git add -A
git add -f "$H"
if [ -f .wip/carry ]; then
  grep -v '^\s*#' .wip/carry | grep -v '^\s*$' | while read -r p; do git add -f -- "$p"; done
fi
WTREE="$(git write-tree)"
unset GIT_INDEX_FILE

# 4. guards on what would leave this machine
CHANGED="$(git diff --name-only --no-renames "$HEADC" "$WTREE")"
bad=""
while IFS= read -r f; do
  [ -n "$f" ] || continue
  case "$f" in .env|.env.*|*/.env|*/.env.*|*.pem|*id_rsa*) bad="$bad\n  secret-looking file: $f";; esac
  sz="$(git cat-file -s "$WTREE:$f" 2>/dev/null || echo 0)"
  [ "$sz" -le "$MAX_BYTES" ] || bad="$bad\n  too large ($sz bytes): $f  (add to .gitignore or raise WIP_MAX_BYTES)"
done <<< "$CHANGED"
hits="$(git diff "$HEADC" "$WTREE" | grep '^+' | grep -nE 'gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{40,}|AKIA[0-9A-Z]{16}|sk-[A-Za-z0-9_-]{20,}|-----BEGIN [A-Z ]*PRIVATE KEY' || true)"
[ -z "$hits" ] || bad="$bad\n  token-looking content in diff:\n$(echo "$hits" | cut -c1-80 | sed 's/^/    /')"
[ -z "$bad" ] || die "refusing to snapshot:$(printf "$bad")"

STASHES="$(git stash list --format=%H 2>/dev/null || true)"
NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
WCOMMIT="$(printf 'wip snapshot %s\n\nbranch=%s\nfrom=%s\nto=%s\nbase=%s\nat=%s\nstashes=%s\n' \
  "$BR" "$BR" "$ME" "$TO" "$HEADC" "$NOW" "$(echo $STASHES | wc -w)" \
  | git commit-tree "$WTREE" -p "$HEADC" -p "$ICOMMIT")"
EMPTY="$(git hash-object -t tree /dev/null)"
LCOMMIT="$(printf 'lease %s\n\nowner=%s\nfrom=%s\nsnap=%s\nat=%s\n' "$BR" "$TO" "$ME" "$WCOMMIT" "$NOW" | git commit-tree "$EMPTY")"

# 5. one atomic push: snapshot + lease (compare-and-swap on the lease)
# NOTE: no "+" on the lease refspec -- a "+" silently overrides --force-with-lease.
REFS=("+$WCOMMIT:$SNAP" "$LCOMMIT:$LEASE")
i=0; for s in $STASHES; do REFS+=("+$s:refs/wip/stash/$BR/$i"); i=$((i+1)); done
git push -q --atomic --force-with-lease="$LEASE:$OLD" "$REMOTE" "${REFS[@]}" \
  || die "push rejected (someone moved the lease?)"
git config "wip.branch.$BR.handedoff" "$TO"
echo "handed off $BR -> $TO  snap=${WCOMMIT:0:10} base=${HEADC:0:10} files=$(echo "$CHANGED" | grep -c . || true) stashes=$i"
echo "stop the agent on this machine now; pushes to $BR from $ME are blocked by the pre-push hook."

Receiver:

scripts/wip-takeover

#!/usr/bin/env bash
# wip-takeover <branch>
# On the receiving machine: fetch refs/wip/snap/<branch>, check the lease names
# this machine, recreate branch + index + worktree exactly, print HANDOFF.md.
set -euo pipefail
die(){ echo "wip-takeover: $*" >&2; exit 1; }
BR="${1:-}"; [ -n "$BR" ] || die "usage: wip-takeover <branch>"
ME="${MACHINE_ID:-$(git config --get wip.machine || true)}"; [ -n "$ME" ] || die "set MACHINE_ID"
REMOTE="${WIP_REMOTE:-origin}"
cd "$(git rev-parse --show-toplevel)"
git fetch -q "$REMOTE" "+refs/wip/snap/$BR:refs/wip-remote/snap/$BR" "+refs/wip/lease/$BR:refs/wip-remote/lease/$BR" \
  || die "no handoff for $BR on $REMOTE"
L="refs/wip-remote/lease/$BR"; W="refs/wip-remote/snap/$BR"
OWNER="$(git log -1 --format=%B "$L" | sed -n 's/^owner=//p')"
[ "$OWNER" = "$ME" ] || die "lease for $BR names $OWNER, not $ME"
[ "$(git log -1 --format=%B "$L" | sed -n 's/^snap=//p')" = "$(git rev-parse "$W")" ] || die "lease and snapshot disagree; ask the sender to re-run wip-handoff"
[ -z "$(git status --porcelain --untracked-files=all)" ] || die "working tree not clean; commit or stash first"
BASE="$(git rev-parse "$W^1")"; ITREE="$(git rev-parse "$W^2^{tree}")"
if git rev-parse -q --verify "refs/heads/$BR" >/dev/null; then
  git merge-base --is-ancestor "refs/heads/$BR" "$BASE" || die "local $BR has commits not in the snapshot; resolve by hand"
fi
git checkout -q -B "$BR" "$BASE"
git read-tree -u --reset "$W"      # worktree := snapshot (adds, edits, deletions)
git read-tree "$ITREE"             # index := what the sender had staged
n=0
git fetch -q "$REMOTE" "+refs/wip/stash/$BR/*:refs/wip-remote/stash/$BR/*" 2>/dev/null || true
for s in $(git for-each-ref --format='%(objectname)' --sort=-refname "refs/wip-remote/stash/$BR/"); do
  git stash store -m "from handoff" "$s"; n=$((n+1)); done
# the branch itself may exist on the remote (pushed before the handoff): track it
if git fetch -q "$REMOTE" "+refs/heads/$BR:refs/remotes/$REMOTE/$BR" 2>/dev/null; then
  git branch -q --set-upstream-to="$REMOTE/$BR" "$BR"
else
  git config "branch.$BR.remote" "$REMOTE"; git config "branch.$BR.merge" "refs/heads/$BR"
fi
git config "wip.branch.$BR.handedoff" ""
echo "took over $BR at base ${BASE:0:10}; restored stashes=$n"
echo "----- .wip/HANDOFF.md -----"; cat .wip/HANDOFF.md

Hook installed on machines 1–3:

hooks/pre-push

#!/usr/bin/env bash
# Refuse to push refs/heads/<b> unless this machine holds refs/wip/lease/<b>
# (or there is no lease). Install: cp hooks/pre-push .git/hooks/ && chmod +x
ME="${MACHINE_ID:-$(git config --get wip.machine)}"
remote="$1"
while read -r lref lsha rref rsha; do
  case "$rref" in refs/heads/*) b="${rref#refs/heads/}";; *) continue;; esac
  l="$(git ls-remote "$remote" "refs/wip/lease/$b" | cut -f1)"
  [ -n "$l" ] || continue
  git fetch -q "$remote" "+refs/wip/lease/$b:refs/wip-remote/lease/$b"
  owner="$(git log -1 --format=%B "refs/wip-remote/lease/$b" | sed -n 's/^owner=//p')"
  if [ "$owner" != "$ME" ]; then
    echo "pre-push: $b was handed off to $owner; $ME may not push it. (wip-takeover on $ME first, or ask $owner to hand it back)" >&2
    exit 1
  fi
done
exit 0

Rules for the dev machines' AGENTS.md:

templates/AGENTS-handoff.md

## Handing off unfinished work (machines 1-3)
- Never push a "WIP" commit to a branch to move work to another machine; review machines 4/5 pick up every branch push.
- To hand off: stop, update .wip/HANDOFF.md (Next / Tried and failed / Do not touch, no TODO left), then run
  `wip-handoff --to <machine>`. If it refuses (big file, token, empty section), fix the cause; never raise limits silently.
- Put local-only notes you want carried in .wip/carry (one path per line). .env* never travels.
- On the receiving machine run `wip-takeover <branch>` in a clean clone, read the printed HANDOFF.md before editing anything.
- If `git push` says the branch was handed off, stop working on it. Do not delete the lease or the hook.

Costs, and when not to use this

  • The pre-push hook can't stop --no-verify. It's a seatbelt, not a lock. An agent that really wants to push still can. GitHub's branch protection and rulesets are configured by branch name and by person; there's no way to express "only the lease owner may push," and I found no server-side option. For a harder guarantee, stop the agent process on machine 1 after the handoff, or remove its push access to the branch.
  • Anyone who can read the repo can fetch refs/wip/*. Don't use this on public repos, and on private ones think about what's in the snapshot. The secret check is a few regexes plus a filename rule for .env*; it doesn't know other formats.
  • The refs don't show in GitHub's web UI and need cleaning up after merge. The kit includes wip-prune, which runs on machine 7 and deletes refs/wip/* for branches that no longer exist on the remote. Deleting a ref doesn't make the objects disappear from the server right away.
  • Not for large files or binary artifacts. Files over 5 MB are refused by default, on purpose. If what needs to move is model weights or build output, put it in object storage and write the location in HANDOFF.md.
  • For the same person briefly leaving their desk, none of this is needed. Use Claude Code's Remote Control or SSH plus tmux: the process stays on the original machine and you keep watching from another device.
  • For small tasks under half an hour, starting over is cheaper. Having machine 2 start from the issue is faster than restoring half-finished work, and it doesn't inherit machine 1's wrong assumptions.
  • Machine 6 runs Windows, and I haven't tested these scripts under Git Bash.

The most common objection: just push a WIP commit and have the receiver reset. It takes ten seconds, so why bother with refs, leases and hooks? With one dev machine, no automated review, and a handoff once or twice a month, that objection fully holds. With seven machines, two of them starting a review on every branch push, and agents that don't stop on their own, a WIP commit brings three problems. Machine 4 reviews half-finished work for nothing. Tokens and dumps get pushed to GitHub. A force-push overwrites the other machine's commit without an error. The first and last actually happened in the lab. The middle one happens as soon as an agent leaves behind the two files D hit on its second attempt, because git add -A takes everything it sees. Each costs more than running one extra script.

What you can do tomorrow

  1. Unpack the kit below and run bash lab/run_all.sh to see in lab/out/ what each of the four methods loses. Then run bash lab/race.sh "$PWD/scripts/wip-handoff" 5 and confirm only one side succeeds each time.
  2. On machines 1–3, run git config wip.machine m1 (m2 and m3 likewise), copy hooks/pre-push into .git/hooks/, and put scripts/ on PATH.
  3. Add the rules above to the dev machines' AGENTS.md, and put templates/HANDOFF.md in the repo.
  4. Add a wip-prune line to machine 7's routine jobs.
  5. Next time someone has to leave mid-task, don't push a WIP commit. Write HANDOFF.md first, then run wip-handoff --to m2.

My conclusion: when several machines share work, the things most easily lost are the ones that never entered Git, and the most dangerous situation is two machines each believing the task is theirs. Move the code state completely through Git side refs, compress the reasoning into one page that must be filled in, and use a compare-and-swap lease so that only one machine at a time is working. Today you can unpack the kit, run the lab once in /tmp, and then try one real handoff in the repo where your team hands off most often.

All scripts, the hook, templates, lab build scripts and the race test: wip-handoff-kit.tar.gz.