feat(sync): agents hear what teammates changed before they overwrite it (BEA-127) (#144)

A teammate's agent rewrites a file and yours never hears about it — the only
trace is a `.bdrive-conflict-*` nobody opens. Now the turn-start hook names
the paths that arrived since the last turn: "re-read before editing".

The record lives in a spool (`internal/store/inbound.go`), not in Cycle's
Result, because the daemon usually materializes a peer's change seconds
before the turn starts — so the hook's own cycle sees nothing. materialize
appends every path it writes or removes, `bdrive sync --hook` drains it after
its cycle and renders it under each mount's own prefix (stripping the session
subpath when the run is inside a mount, dropping paths outside it).

Advisory only: nothing blocks, nothing prompts, no per-Write remote call. The
spool is capped, 0600, in the volume dir, and best-effort everywhere — a
spool failure never fails a cycle or a turn.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow Lee (Sungwon)
2026-08-10 14:33:20 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent 2875e033be
commit 5623113ff7
11 changed files with 584 additions and 12 deletions
+7 -1
View File
@@ -84,7 +84,13 @@ a single JSON object, so the formula carries **every** mount as a `prefix →
URL` pair — the prefix being the mount's path as the agent sees it from the
session's folder, or an empty prefix with the session's own subpath baked into
the URL when the session runs inside the mount; emitting only the first mount
hung one project's paths on another project's base URL. Then an
hung one project's paths on another project's base URL. The same context also
names what teammates changed since the last turn ("re-read before editing"),
drained from the **inbound spool** (`internal/store/inbound.go`, a near-copy of
the read spool): `materialize` appends every path it writes or removes, and the
hook drains it *after* its own cycle — a `Result` field would report nothing,
because the daemon has usually materialized the peer's change seconds earlier.
Advisory only: nothing blocks a write. Then an
async push on PostToolUse Write/Edit, and `bdrive read-log` on
Read/Grep/Bash for the read heatmap. The inline hook commands
`internal/agenthooks` writes must stay a fast no-op outside BearDrive folders
+2
View File
@@ -116,9 +116,11 @@ classDiagram
+SaveNote / LoadNote
+LogRead(rel, session) read spool
+PendingReads dedup on path+session
+LogInbound / DrainInbound
+Lock() flock
}
note for Store "internal/store — ~/.bdrive/volumes/mount-id: content-addressed blobs, per-device journal copies, state cache, paused marker (free funcs Paused/SetPaused, no flock)"
note for Store "inbound.jsonl is the read spool's twin, running the other way: materialize appends every path it wrote or removed for a peer, and `sync --hook` drains it into the turn's context (re-read before editing). A spool and not a Result field because the daemon usually materializes the change seconds before the turn starts, so the hook's own cycle sees nothing. Capped, best-effort, never fails a cycle"
class Op {
+Seq +Lamport +Time +Device
+4 -2
View File
@@ -105,8 +105,10 @@ list in .bdrive/config.json is never pruned against either.`,
if err != nil || !ok || syncBlocked(proj) != "" {
continue
}
if base, ok := runHookSync(cmd, target, sessionID, hookLabel); ok {
links = append(links, hookLinkFor(folder, target, base))
if h, ok := runHookSync(cmd, target, sessionID, hookLabel); ok {
link := hookLinkFor(folder, target, h.base)
link.paths = h.paths
links = append(links, link)
}
}
emitHookContext(cmd, links)
+92 -8
View File
@@ -9,6 +9,8 @@ import (
"time"
"github.com/spf13/cobra"
"github.com/runbear-io/beardrive/internal/store"
)
// `bdrive sync --hook <label>` is the agent-hook flavor of sync, run by the
@@ -33,12 +35,19 @@ import (
const hookNoteTTL = 30 * time.Minute
// hookLink pairs the path prefix an agent writes with the hub URL that
// prefix maps to.
// prefix maps to, and carries what this mount pulled in since the last turn.
type hookLink struct {
prefix string // "wiki/", or "" when the hook ran at or inside the mount
base string // https://hub/<project-id>[/<the run folder's subpath>]
sub string // the run folder's mount-relative path, "" at or above the mount
paths []store.InboundEvent
}
// hookChangedMax caps the changed-file list the turn pays for. Past it the
// tail is a count — the first cycle on a fresh mount materializes the whole
// project, and no turn should carry that.
const hookChangedMax = 20
// hookSessionID reads the platform's event JSON from stdin — once per run,
// since stdin can only be consumed once and the sync loop may cover several
// mounts.
@@ -59,11 +68,19 @@ func eventSessionID(data []byte) string {
return event.SessionID
}
// runHookSync syncs one mount and reports its hub base URL, if it has one.
func runHookSync(cmd *cobra.Command, target, sessionID, label string) (string, bool) {
// hookSync is one mount's contribution to the turn: where its files live on
// the hub, and which of them moved since the last turn.
type hookSync struct {
base string
paths []store.InboundEvent
}
// runHookSync syncs one mount and reports its hub base URL, if it has one,
// plus the peer changes waiting for this turn.
func runHookSync(cmd *cobra.Command, target, sessionID, label string) (hookSync, bool) {
sess, proj, err := openSession(cmd.Context(), target, true)
if err != nil {
return "", false // not a mount / no session: fast no-op
return hookSync{}, false // not a mount / no session: fast no-op
}
defer closeSession(sess)
@@ -83,13 +100,19 @@ func runHookSync(cmd *cobra.Command, target, sessionID, label string) (string, b
// The pull. Offline is fine — the link formula below is still valid
// for teammates who are online.
if _, err := sess.Cycle(cmd.Context()); err != nil {
return "", false // never break the turn
return hookSync{}, false // never break the turn
}
// Drained after the cycle, not from its Result: in the ordinary case the
// daemon materialized the peer's change seconds ago, so this cycle saw
// nothing and the spool is where the record is. Errors are ignored — the
// links matter more than the list.
paths, _ := sess.Store.DrainInbound()
server, projectID, err := splitHubRemote(proj.Remote)
if err != nil {
return "", false // non-hub remote: nothing to link to
return hookSync{}, false // non-hub remote: nothing to link to
}
return server + "/" + projectID, true
return hookSync{base: server + "/" + projectID, paths: paths}, true
}
// hookLinkFor places one mount relative to the folder the hook ran in.
@@ -114,7 +137,10 @@ func hookLinkFor(folder, target, base string) hookLink {
if err != nil {
return hookLink{base: base}
}
return hookLink{base: base + "/" + encodePathSegments(filepath.ToSlash(sub))}
return hookLink{
base: base + "/" + encodePathSegments(filepath.ToSlash(sub)),
sub: filepath.ToSlash(sub),
}
default:
return hookLink{prefix: rel + "/", base: base}
}
@@ -158,6 +184,10 @@ func emitHookContext(cmd *cobra.Command, links []hookLink) {
tail, strings.Join(parts, ", "))
}
if changed := hookChanged(links); changed != "" {
context += " " + changed
}
out := map[string]any{
"hookSpecificOutput": map[string]any{
"hookEventName": "UserPromptSubmit",
@@ -170,3 +200,57 @@ func emitHookContext(cmd *cobra.Command, links []hookLink) {
}
fmt.Fprintln(cmd.OutOrStdout(), string(enc))
}
// hookChanged renders what teammates' devices pulled in since the last turn:
// the whole point of the spool, and the only defense an agent has against
// rewriting a file that moved underneath it. Advisory — nothing blocks.
//
// Each path is translated into what the agent sees from the folder the hook
// ran in, using the same placement hookLinkFor computed for the links: a
// mount below that folder prepends its prefix, a run inside a mount strips
// its own subpath (and paths outside it are not the agent's to re-read).
func hookChanged(links []hookLink) string {
var paths []string
over := 0
for _, l := range links {
for _, e := range l.paths {
p, ok := hookAgentPath(l, e.Path)
if !ok {
continue
}
if len(paths) >= hookChangedMax {
over++
continue
}
if e.Deleted {
p += " (deleted)"
}
paths = append(paths, "`"+p+"`")
}
}
if len(paths) == 0 {
return ""
}
s := "Changed since your last turn by a teammate or another device — re-read before editing: " + strings.Join(paths, ", ")
if over > 0 {
s += fmt.Sprintf(", +%d more", over)
}
return s + "."
}
// hookAgentPath maps one mount-relative spool path to the path an agent
// writes, reporting false for paths the agent cannot reach from here.
func hookAgentPath(l hookLink, path string) (string, bool) {
switch {
case l.prefix != "":
return l.prefix + path, true
case l.sub != "":
// The session runs inside the mount: its own subpath is implicit in
// every path it writes, so strip it — and a sibling folder's file is
// outside this session's view entirely.
rest, ok := strings.CutPrefix(path, l.sub+"/")
return rest, ok
default:
return path, true
}
}
+138
View File
@@ -2,6 +2,8 @@ package main
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
@@ -305,3 +307,139 @@ func TestSyncRefusesUnenrolledAndPaused(t *testing.T) {
t.Fatalf("paused sync error = %v, want a paused message", err)
}
}
// seedInbound pretends an earlier cycle — the daemon's, in the ordinary case
// — materialized these paths on this mount.
func seedInbound(t *testing.T, proj config.Project, paths ...string) {
t.Helper()
vdir, err := config.VolumeDir(proj.ID)
if err != nil {
t.Fatal(err)
}
st, err := store.Open(vdir)
if err != nil {
t.Fatal(err)
}
for _, p := range paths {
deleted := strings.HasPrefix(p, "-")
if err := st.LogInbound(strings.TrimPrefix(p, "-"), deleted); err != nil {
t.Fatal(err)
}
}
}
// The whole point of the spool: a path materialized by an EARLIER cycle (the
// daemon's) is still reported by the hook, whose own cycle sees nothing. A
// Result field would report nothing here.
func TestSyncHookModeReportsInboundChanges(t *testing.T) {
t.Setenv("BDRIVE_HOME", t.TempDir())
root := t.TempDir()
root, _ = filepath.EvalSymlinks(root)
proj := mountAt(t, root, "wiki", "https://hub.example.com/p/p-12345678")
seedInbound(t, proj, "notes/readme.md", "-old.md")
got := runHook(t, filepath.Join(root, "wiki"))
for _, want := range []string{
"re-read before editing",
"`notes/readme.md`",
"`old.md (deleted)`",
} {
if !strings.Contains(got, want) {
t.Errorf("hook output missing %q:\n%s", want, got)
}
}
// The drain cleared: a second run with no peer activity says nothing.
if again := runHook(t, filepath.Join(root, "wiki")); strings.Contains(again, "re-read before editing") {
t.Errorf("second run repeated the changed list:\n%s", again)
}
}
// Each path carries its own mount's prefix — never another mount's.
func TestSyncHookModeInboundMultipleMounts(t *testing.T) {
t.Setenv("BDRIVE_HOME", t.TempDir())
root := t.TempDir()
root, _ = filepath.EvalSymlinks(root)
a := mountAt(t, root, "projA", "https://hub.example.com/p/p-aaaaaaaa")
b := mountAt(t, root, "projB", "https://hub.example.com/p/p-bbbbbbbb")
seedInbound(t, a, "notes/a.md")
seedInbound(t, b, "notes/b.md")
got := runHook(t, root)
for _, want := range []string{"`projA/notes/a.md`", "`projB/notes/b.md`"} {
if !strings.Contains(got, want) {
t.Errorf("hook output missing %q:\n%s", want, got)
}
}
}
// A session started inside a mount sees paths relative to its own directory:
// its subpath is stripped, and a sibling folder's file is not its to re-read.
func TestSyncHookModeInboundInsideMount(t *testing.T) {
t.Setenv("BDRIVE_HOME", t.TempDir())
root := t.TempDir()
root, _ = filepath.EvalSymlinks(root)
proj := mountAt(t, root, "wiki", "https://hub.example.com/p/p-12345678")
sub := filepath.Join(root, "wiki", "docs", "notes")
if err := os.MkdirAll(sub, 0o755); err != nil {
t.Fatal(err)
}
seedInbound(t, proj, "docs/notes/mine.md", "elsewhere/theirs.md")
got := runHook(t, sub)
if !strings.Contains(got, "`mine.md`") {
t.Errorf("path under the session folder not stripped to what the agent sees:\n%s", got)
}
if strings.Contains(got, "theirs.md") {
t.Errorf("path outside the session folder must not be listed:\n%s", got)
}
}
// The first cycle on a fresh mount materializes everything; the turn must not
// carry the whole project.
func TestSyncHookModeInboundCap(t *testing.T) {
t.Setenv("BDRIVE_HOME", t.TempDir())
root := t.TempDir()
root, _ = filepath.EvalSymlinks(root)
proj := mountAt(t, root, "wiki", "https://hub.example.com/p/p-12345678")
var paths []string
for i := 0; i < hookChangedMax+5; i++ {
paths = append(paths, fmt.Sprintf("f%02d.md", i))
}
seedInbound(t, proj, paths...)
got := runHook(t, filepath.Join(root, "wiki"))
if !strings.Contains(got, "+5 more") {
t.Errorf("capped list missing its tail:\n%s", got)
}
if strings.Contains(got, "f24.md") {
t.Errorf("list rendered past the cap:\n%s", got)
}
}
// An unreadable spool leaves the turn intact: exit 0, valid JSON, links still
// emitted.
func TestSyncHookModeInboundSpoolUnreadable(t *testing.T) {
t.Setenv("BDRIVE_HOME", t.TempDir())
root := t.TempDir()
root, _ = filepath.EvalSymlinks(root)
proj := mountAt(t, root, "wiki", "https://hub.example.com/p/p-12345678")
vdir, err := config.VolumeDir(proj.ID)
if err != nil {
t.Fatal(err)
}
// A directory where the spool should be: every read of it fails.
if err := os.MkdirAll(filepath.Join(vdir, "inbound.jsonl"), 0o755); err != nil {
t.Fatal(err)
}
got := runHook(t, filepath.Join(root, "wiki"))
if !strings.Contains(got, `"hookSpecificOutput"`) || !strings.Contains(got, "https://hub.example.com/p-12345678") {
t.Errorf("unreadable spool broke the turn's context:\n%s", got)
}
var out map[string]any
if err := json.Unmarshal([]byte(got), &out); err != nil {
t.Fatalf("hook emitted invalid JSON: %v\n%s", err, got)
}
}
+111
View File
@@ -0,0 +1,111 @@
package store
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"time"
)
// The inbound spool queues the paths a cycle materialized from peers, until
// the agent hook (`bdrive sync --hook`) drains it into the turn's context —
// "these changed since your last turn, re-read before editing". It is a spool
// rather than a field on Result because the daemon usually materializes the
// peer's change seconds before the turn starts, so the hook's own cycle
// reports nothing: the record has to outlive the cycle that made it.
// InboundEvent is one path a cycle wrote or removed on a peer's behalf
// (mount-relative).
type InboundEvent struct {
Path string `json:"path"`
Deleted bool `json:"deleted,omitempty"`
Time time.Time `json:"time"`
}
// inboundSpoolMax caps the spool: a machine with no agent hooks never drains,
// so past the cap new events are dropped rather than growing without bound.
const inboundSpoolMax = 1 << 20
// inboundDrainMax bounds one drained batch; the hook renders far fewer.
const inboundDrainMax = 4096
func (s *Store) inboundSpoolPath() string { return filepath.Join(s.dir, "inbound.jsonl") }
func (s *Store) inboundDrainPath() string { return filepath.Join(s.dir, "inbound-draining.jsonl") }
// LogInbound appends one materialized path to the spool. Single-line O_APPEND
// writes keep the daemon and a concurrent CLI cycle from interleaving.
func (s *Store) LogInbound(rel string, deleted bool) error {
if fi, err := os.Stat(s.inboundSpoolPath()); err == nil && fi.Size() > inboundSpoolMax {
return nil // spool full: drop, never grow unbounded
}
line, err := json.Marshal(InboundEvent{Path: rel, Deleted: deleted, Time: time.Now().UTC()})
if err != nil {
return err
}
// 0600: the spool is a list of this project's file paths.
f, err := os.OpenFile(s.inboundSpoolPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return err
}
defer f.Close()
_, err = f.Write(append(line, '\n'))
return err
}
// DrainInbound returns the queued batch and clears it, deduplicated by path
// (latest event wins, so a path written and then deleted reports as deleted).
// The spool is rotated aside first, so events logged during the drain land in
// a fresh spool — the drain runs outside the volume flock, and a daemon on
// the same mount may be appending.
//
// One call, unlike PendingReads/ClearPendingReads: those are two steps
// because a read report can fail over the network and must be retried, and
// rendering a string onto stdout cannot.
func (s *Store) DrainInbound() ([]InboundEvent, error) {
if _, err := os.Stat(s.inboundDrainPath()); os.IsNotExist(err) {
if err := os.Rename(s.inboundSpoolPath(), s.inboundDrainPath()); err != nil {
if os.IsNotExist(err) {
return nil, nil // nothing queued
}
return nil, err
}
}
data, err := os.ReadFile(s.inboundDrainPath())
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
// Unreadable: drop it rather than wedging every future drain behind
// it. Losing a turn's list is the cheaper failure.
os.Remove(s.inboundDrainPath())
return nil, err
}
defer os.Remove(s.inboundDrainPath())
latest := map[string]InboundEvent{}
var order []string
for _, line := range bytes.Split(data, []byte("\n")) {
if len(bytes.TrimSpace(line)) == 0 {
continue
}
var e InboundEvent
if json.Unmarshal(line, &e) != nil || e.Path == "" {
continue // torn or corrupt line; drop it
}
if _, ok := latest[e.Path]; !ok {
order = append(order, e.Path)
}
if prev, ok := latest[e.Path]; !ok || !e.Time.Before(prev.Time) {
latest[e.Path] = e
}
}
if len(order) > inboundDrainMax {
order = order[len(order)-inboundDrainMax:]
}
out := make([]InboundEvent, 0, len(order))
for _, p := range order {
out = append(out, latest[p])
}
return out, nil
}
+118
View File
@@ -0,0 +1,118 @@
package store
import (
"os"
"strings"
"testing"
)
func TestInboundSpool(t *testing.T) {
s := openTestStore(t)
// Nothing queued: no batch, no error.
if evs, err := s.DrainInbound(); err != nil || len(evs) != 0 {
t.Fatalf("empty spool = %v, %v", evs, err)
}
if err := s.LogInbound("wiki/a.md", false); err != nil {
t.Fatal(err)
}
if err := s.LogInbound("b.md", false); err != nil {
t.Fatal(err)
}
// A path written and then removed reports as deleted: latest wins.
if err := s.LogInbound("b.md", true); err != nil {
t.Fatal(err)
}
evs, err := s.DrainInbound()
if err != nil {
t.Fatal(err)
}
if len(evs) != 2 || evs[0].Path != "wiki/a.md" || evs[1].Path != "b.md" {
t.Fatalf("batch = %+v, want wiki/a.md + b.md", evs)
}
if evs[0].Deleted || !evs[1].Deleted {
t.Fatalf("batch = %+v, want b.md marked deleted", evs)
}
if evs[0].Time.IsZero() {
t.Fatal("events must carry their time")
}
// The drain clears: a second run with no activity in between reports
// nothing.
if again, err := s.DrainInbound(); err != nil || len(again) != 0 {
t.Fatalf("second drain = %+v, %v, want empty", again, err)
}
}
func TestInboundSpoolSurvivesCorruptLines(t *testing.T) {
s := openTestStore(t)
s.LogInbound("good.md", false)
f, err := os.OpenFile(s.inboundSpoolPath(), os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
t.Fatal(err)
}
f.WriteString(`{"path": "torn`) // a torn write
f.Close()
s.LogInbound("also-good.md", false)
evs, err := s.DrainInbound()
if err != nil {
t.Fatal(err)
}
// The torn line joins the next event's line; both are dropped, but the
// batch itself survives.
if len(evs) == 0 || evs[0].Path != "good.md" {
t.Fatalf("batch = %+v, want good.md to survive the torn line", evs)
}
}
func TestInboundSpoolCap(t *testing.T) {
s := openTestStore(t)
long := strings.Repeat("d", 1024)
for i := 0; i < 1100; i++ { // ~1.1 MB of events
if err := s.LogInbound(long+"/"+string(rune('a'+i%26))+".md", false); err != nil {
t.Fatal(err)
}
}
fi, err := os.Stat(s.inboundSpoolPath())
if err != nil {
t.Fatal(err)
}
if fi.Size() > inboundSpoolMax+4096 {
t.Fatalf("spool grew past its cap: %d bytes", fi.Size())
}
}
// The spool is a plain file in the volume dir at 0600 — never in the working
// folder, never synced.
func TestInboundSpoolPermissions(t *testing.T) {
s := openTestStore(t)
if err := s.LogInbound("a.md", false); err != nil {
t.Fatal(err)
}
fi, err := os.Stat(s.inboundSpoolPath())
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm() != 0o600 {
t.Fatalf("spool mode = %v, want 0600", fi.Mode().Perm())
}
}
// An unreadable spool must not wedge every later drain behind it.
func TestInboundSpoolUnreadableRecovers(t *testing.T) {
s := openTestStore(t)
if err := os.MkdirAll(s.inboundSpoolPath(), 0o755); err != nil {
t.Fatal(err)
}
if _, err := s.DrainInbound(); err == nil {
t.Fatal("unreadable spool should report its error")
}
if err := s.LogInbound("a.md", false); err != nil {
t.Fatal(err)
}
evs, err := s.DrainInbound()
if err != nil || len(evs) != 1 || evs[0].Path != "a.md" {
t.Fatalf("drain after failure = %+v, %v, want a.md", evs, err)
}
}
+14
View File
@@ -1129,6 +1129,11 @@ func (s *Session) materialize(target map[string]journal.FileState, cache map[str
continue
}
pruneEmptyDirs(s.Folder, filepath.Dir(abs))
// Tell the next agent turn what vanished under it. Best-effort:
// a spool failure must never fail a cycle. Only the branch that
// actually unlinked a file logs — the paths below it were already
// gone locally, so nothing changed under the agent.
_ = s.Store.LogInbound(rel, true)
}
delete(cache, rel)
changed++
@@ -1171,6 +1176,15 @@ func (s *Session) materializeFile(rel string, want journal.FileState, cache map[
return false, err
}
cache[rel] = store.CachedFile{Blob: want.Blob, Size: fi.Size(), Mode: want.Mode, MTimeNS: fi.ModTime().UnixNano()}
// Spool it for the next agent turn ("changed since your last turn"). Only
// peer content reaches here — a local edit is journaled by the scan and
// lands in the cache, so the compare above short-circuits before any
// write. Best-effort: a spool failure must never fail a cycle.
//
// Known trade: the first cycle on a fresh mount materializes the whole
// project, so that one turn's list is "everything" — bounded by the
// hook's render cap, and cheaper than special-casing an empty cache.
_ = s.Store.LogInbound(rel, false)
return true, nil
}
+81
View File
@@ -830,3 +830,84 @@ func TestRenameConvergesAsPutPlusDelete(t *testing.T) {
t.Fatalf("the halves landed %v apart — wider than the hub's pairing window", d)
}
}
// TestInboundSpool is the test that matters for the turn-start warning: the
// paths a cycle materializes from a peer are spooled for the next agent turn,
// this device's own edits never are, and the drain clears.
func TestInboundSpool(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
b := newDevice(t, "devb", be)
// B writes, A pulls: A's spool names the path.
write(t, b.Folder, "notes/readme.md", "from b")
cycle(t, b)
cycle(t, a)
evs, err := a.Store.DrainInbound()
if err != nil {
t.Fatal(err)
}
if len(evs) != 1 || evs[0].Path != "notes/readme.md" || evs[0].Deleted {
t.Fatalf("a inbound = %+v, want notes/readme.md written", evs)
}
// A's own edit is not inbound — it is scanned, not materialized.
write(t, a.Folder, "local.md", "mine")
cycle(t, a)
if evs, _ := a.Store.DrainInbound(); len(evs) != 0 {
t.Fatalf("a inbound = %+v, want own edits absent", evs)
}
// A peer delete is reported as removed, not as changed.
cycle(t, b)
os.Remove(filepath.Join(b.Folder, "notes", "readme.md"))
cycle(t, b)
cycle(t, a)
evs, err = a.Store.DrainInbound()
if err != nil {
t.Fatal(err)
}
if len(evs) != 1 || evs[0].Path != "notes/readme.md" || !evs[0].Deleted {
t.Fatalf("a inbound = %+v, want notes/readme.md deleted", evs)
}
// The drain cleared: a quiet cycle reports nothing.
cycle(t, a)
if evs, _ := a.Store.DrainInbound(); len(evs) != 0 {
t.Fatalf("a inbound = %+v, want empty after a quiet cycle", evs)
}
}
// TestInboundSpoolOutlivesItsCycle is the reason this is a spool and not a
// Result field: in the ordinary case the daemon materializes a peer's change
// seconds before the turn starts, so the cycle the agent hook runs sees
// nothing. A second Session on the same volume — which is what the daemon and
// the hook are — still finds the path waiting.
func TestInboundSpoolOutlivesItsCycle(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
b := newDevice(t, "devb", be)
write(t, b.Folder, "notes/readme.md", "from b")
cycle(t, b)
// The "daemon" cycle materializes it.
res := cycle(t, a)
if res.Materialized != 1 {
t.Fatalf("Materialized = %d, want 1", res.Materialized)
}
// A later, quiet cycle — the hook's — reports nothing itself...
later := &Session{Folder: a.Folder, Store: a.Store, Device: a.Device, Backend: be}
if res := cycle(t, later); res.Materialized != 0 {
t.Fatalf("second cycle Materialized = %d, want 0", res.Materialized)
}
// ...but the spool still names what arrived.
evs, err := later.Store.DrainInbound()
if err != nil {
t.Fatal(err)
}
if len(evs) != 1 || evs[0].Path != "notes/readme.md" {
t.Fatalf("inbound = %+v, want notes/readme.md from the earlier cycle", evs)
}
}
@@ -90,6 +90,22 @@ bdrive log wiki # which areas are alive
— and, if this device created the project, offer to draft `AGENTS.md` for the
team.
## Your agent is told what moved
Six people's agents in one folder means an agent can start a turn holding a
copy of a file a teammate's agent rewrote an hour ago. So it is told: at turn
start, the sync hook names the files that arrived from teammates since the
last turn — "changed since your last turn, re-read before editing" — with
deletions marked.
It is advisory. Nothing blocks, nothing prompts, no write is refused; the
agent gets the fact and decides. That needs the turn-start hooks `bdrive init`
registers ([Hooks in detail](/manual/hooks/)) — without them nothing drains
the list, and the agent hears nothing.
The list is capped, so the first turn after joining a project names some of
what arrived rather than the whole project.
## What belongs in shared memory
Good candidates are the things that are expensive to rediscover and cheap to
+1 -1
View File
@@ -21,7 +21,7 @@ One binary, `bdrive` — the CLI, the sync daemon, and the web server.
| `bdrive forget <path>...` | Stop syncing a path and remove it from the hub. Adds the rule to `.bdriveignore` (which syncs) and prunes in one step. Local files are never touched, here or on teammates' devices |
| `bdrive url [path]` | Internal hub link for a file or folder — sign-in and membership required. `--sync` pushes first; no argument gives the project home. Computed locally |
| `bdrive share <file>` | Public URL for a synced file. `--list`, `--revoke`, `--expires` (the hub's Share dialog can also set an expiry on an existing link). Refuses a file whose first 1 MiB holds credential-shaped strings — `--force` shares it anyway |
| `bdrive sync [folder]` | Run one sync cycle now. Refuses folders this device never `init`ed and folders paused by `bdrive stop`. `--note <text>` stamps session context onto changes; `--note-ttl` (default 30m) bounds it. `--prune` also removes from the hub what `.bdriveignore` now excludes (files stay on disk everywhere). `--hook <label>` is agent-hook plumbing |
| `bdrive sync [folder]` | Run one sync cycle now. Refuses folders this device never `init`ed and folders paused by `bdrive stop`. `--note <text>` stamps session context onto changes; `--note-ttl` (default 30m) bounds it. `--prune` also removes from the hub what `.bdriveignore` now excludes (files stay on disk everywhere). `--hook <label>` is agent-hook plumbing: it also reports the files teammates changed since the agent's last turn |
| `bdrive hooks [install\|uninstall]` | Register turn-boundary sync hooks in each detected agent platform's user config — once per machine, covering every folder. Run automatically by `bdrive init`; idempotent; `--agent` overrides detection. `uninstall` removes only BearDrive's own hook entries |
| `bdrive read-log [folder]` | Hook plumbing: queue agent file reads for the hub's read heatmap. Registered by `bdrive hooks install` |
| `bdrive status [folder]` | Projects, daemon state, pending changes |