post_sync: run a local command when teammates' changes land - #163
Merged
ssowonny merged 1 commit intoAug 13, 2026
Merged
Conversation
Inbound sync was invisible to the machine it landed on — a local index, cache or notifier had to poll. A `post_sync` command in the folder's own .bdrive/config.json now runs once per cycle that applied peer changes, with the batch as JSON on stdin. The batch rides out on a new Result.Inbound rather than the inbound spool: DrainInbound is destructive and `bdrive sync --hook` is its only consumer, so a second drainer would silently empty the agent's "teammates changed X" context. Both are kept, and both comments now say why. Cycle becomes a thin wrapper over cycleLocked so the hook is spawned after the volume flock drops — a property of the code shape, not a rule each of the seven call sites has to remember. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ssowonny
deleted the
bea-137-post-sync-local-hook-run-a-command-on-this-device-when
branch
August 13, 2026 18:04
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR
post_syncin the folder's own.bdrive/config.jsonand it runs after a cycle applies peers' changes, with the applied paths as JSON on stdin. Off unless you set it.write/delete(no created-vs-modified split), and.bdrive/travels with a folder you copy by hand, so a hand-copied teammate folder brings theirpost_syncalong.What it looks like
stdin, with the command's working directory set to the folder:
{ "project": "m-5a10b713", "folder": "/Users/you/notes", "changed": [ { "path": "wiki/onboarding.md", "op": "write" }, { "path": "notes/retired.md", "op": "delete" } ] }It lives in
.bdrive/config.jsonand only there. That directory is inconfig.ReservedDirsand never syncs, so no hub response and no teammate's journal op can put a command on someone else's machine — which is what the issue required.The one thing to actually review
The inbound spool (
internal/store/inbound.go) looks like the obvious source for the changed paths. It is a trap:DrainInbound()is destructive andcmd/bdrive/hooksync.gois its only consumer, so a second drainer would silently empty the agent's "teammates changed X, re-read before editing" context about half the time, with no error anywhere.So the batch rides out on a new
Result.Inboundinstead, andlogInboundwrites to both — the spool for the cross-process agent hook, the Result for the in-process post_sync hook.TestPostSyncLeavesInboundSpoolis the regression guard, and the comments on both sides now say why they coexist.Shape
Cycleis now a six-line wrapper:cycleLockeddoes today's work under the volume flock, thenfirePostSyncspawns after the lock drops. That makes the ordering a property of the code rather than a rule each of the sevenCyclecall sites has to remember.stdin is an unlinked temp file, not a
bytes.Reader: with a non-*os.Filestdin, os/exec serves the child from a goroutine in the parent, and a one-shotbdrive syncexits before the child has read it — delivering truncated JSON.Deliberately not built (each argued in the issue's plan): no single-flight guard, no timeout, no
Setsid, no created/modified split. The spawn carries aponytail:comment naming the pileup ceiling and its upgrade path.Where I deviated from the plan
The plan claimed a hook spawned inside the flock would deadlock when it runs a
bdrivecommand, and that the e2e test proves the ordering. Neither is true: the child is detached and nothing waits on it, so spawning inside the lock only stalls the child onLOCK_EXuntil the cycle ends. I verified this by movingfirePostSyncinsidecycleLockedand watching the e2e test still pass.Firing after the unlock is still right — a hook shouldn't queue behind a long push — but it is a code-shape property, not something the test asserts. The e2e test is kept for what it does prove: the real binary, a real flock, a hook running
bdrive syncand completing with the right batch on stdin. Both the test comment and this PR say so rather than implying a guarantee that isn't there.Acceptance
go build ./...,go vet ./...,go test ./...— all pass (full suite, twice).internal/syncer/postsync_test.go): fire-once-per-batch, silent without config, no-fire on a local-only cycle, deletes reported asop:"delete",exit 3+ nonexistent binary leavingResultand the next cycle untouched, and the spool left intact forbdrive sync --hook.internal/config:post_syncround-trips and is omitted from the file when unset.internal/webapp/cli_postsync_e2e_test.go: real binary, two devices, one hub, apost_syncthat runsbdrive sync.npm run e2eand no UI evaluation — nothing underinternal/webapp/frontendis touched.bdrive stopexits the daemon andsyncBlockedrefuses a manual sync, so no cycle runs at all.Docs:
README.md,web/docs/src/content/docs/reference/project-files.md.reference/cli.mduntouched — this is config, not a flag.Architecture changes
architecture/cli-sync.md:SessiongainscycleLocked,firePostSyncandlogInbound(withCycledemoted to a wrapper),ResultgainsInbound, andProjectgainsPostSync.Storeis deliberately unchanged —LogInbound/DrainInboundkeep their exact semantics, which is the point of the section above. Nothing was removed.flowchart TB Session["<div style='text-align:left'><b>Session</b><br/>+Folder string<br/>+MountID string<br/>+Store *store.Store<br/>+Backend remote.Backend<br/>+Cycle(ctx) Result<br/>+Restore(ctx, path, sha) error<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -cycleLocked(ctx) Result</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -firePostSync(res)</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -logInbound(rel, deleted)</span></div>"] Result["<div style='text-align:left'><b>Result</b><br/>+LocalOps +PulledOps<br/>+Conflicts +Adopted +Pruned +Materialized<br/>+Pushed +Offline +OfflineErr<br/>+ReadOnly +NoAccess +AccessErr<br/>+Reason() string<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +Inbound []store.InboundEvent</span></div>"] Project["<div style='text-align:left'><b>Project</b><br/>+ID stable mount id<br/>+Volume +Remote<br/>+Include legacy, read-only<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +PostSync local hook command</span></div>"] Store["<div style='text-align:left'><b>Store</b><br/>+LogInbound / DrainInbound<br/>+Lock() flock</div>"] Wrapper["Cycle is now a thin wrapper:<br/>cycleLocked does the work under Lock()<br/>firePostSync spawns sh -c PostSync AFTER it drops<br/>so a hook running a bdrive command<br/>does not block on the flock"] Both["logInbound records each peer path TWICE, on purpose:<br/>Result.Inbound -> post_sync hook (same process)<br/>store spool -> bdrive sync --hook (later process)<br/>DrainInbound is destructive and single-consumer,<br/>so the hook must never drain it"] Session -- "returns" --> Result Session -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ reads PostSync after unlock</span>" --> Project Session -- "Lock() + LogInbound, unchanged" --> Store Session -.- Wrapper Result -.- Both classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2 class Wrapper noteBox class Both noteBox linkStyle 1 stroke:#22c55e,stroke-width:2pxCloses BEA-137.
Build session
(only works on this machine)