From bb85d595e67c8b6ba009b426ae750b14f376d04d Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Tue, 27 Oct 2026 10:44:11 -0400 Subject: [PATCH 1/2] fix(sync): never copy an older chat that works in the home folder The background older-chat copier sealed each recent chat's folder. A chat started in the home folder has the home folder as its workspace, and a copy's first seal has no change hints, so furrow planned over every file under it while holding the shared store's one writer. Every open chat's tool calls queued behind that plan, hit the 30 s client wait, and failed as "engine capture-plan answer interrupted: ... use of closed network connection". Reuse the existing rule that the home folder is not a project (session.HoldsHomeFolder, exported from delegate_door.go): the background round and the open-time copy both skip a non-owned chat whose folder is or holds the home folder. It keeps opening in place, as before Wormhole. --- cmd/codeaf/olderchats.go | 17 +++++++++- cmd/codeaf/olderchats_retry_test.go | 31 +++++++++++++++++++ .../continuing-a-chat-on-another-computer.md | 4 ++- internal/session/delegate_door.go | 8 ++--- internal/session/programfolder.go | 4 +-- 5 files changed, 56 insertions(+), 8 deletions(-) diff --git a/cmd/codeaf/olderchats.go b/cmd/codeaf/olderchats.go index 3ee7fe2c2f..c473b36f77 100644 --- a/cmd/codeaf/olderchats.go +++ b/cmd/codeaf/olderchats.go @@ -132,7 +132,7 @@ func (o *olderChats) visit(ctx context.Context, dir string) (cell.Cell, session. return cell.Cell{}, session.Meta{}, false } meta, _ := session.LoadMeta(dir) - if meta.LastUserAt.IsZero() || time.Since(meta.LastUserAt) > olderChatRecent || session.InUse(filepath.Join(dir, "transcript.jsonl")) { + if meta.LastUserAt.IsZero() || time.Since(meta.LastUserAt) > olderChatRecent || session.InUse(filepath.Join(dir, "transcript.jsonl")) || !olderChatCopyable(meta) { return cell.Cell{}, session.Meta{}, false } c, err := o.copy(ctx, dir) @@ -150,6 +150,18 @@ func (o *olderChats) visit(ctx context.Context, dir string) (cell.Cell, session. return c, meta, true } +// olderChatCopyable says a copy of the chat may seal its folder. +// +// A CHAT IN THE HOME FOLDER IS NOT COPIED. A copy's first seal has no record of +// what changed, so it walks the chat's whole folder, and the engine holds its +// one writer for the shared store while it does. Over the home folder that walk +// takes minutes, and every open chat's tools wait behind it until they time out. +// The home folder is not a project (session.HoldsHomeFolder); such a chat keeps +// opening where it is. A chat with its own folder is always copyable. +func olderChatCopyable(meta session.Meta) bool { + return meta.Owned || !session.HoldsHomeFolder(meta.Workspace) +} + // movablePlace resolves a completed copy or prepares one when the person opens // an older chat. Selection runs on the existing asynchronous opening door. func movablePlace(ctx context.Context, place session.Place) (session.Place, error) { @@ -165,6 +177,9 @@ func movablePlace(ctx context.Context, place session.Place) (session.Place, erro if _, err := os.Stat(filepath.Join(original, "transcript.jsonl")); os.IsNotExist(err) { return place, nil } + if meta, err := session.LoadMeta(original); err == nil && !olderChatCopyable(meta) { + return place, nil + } c, err := copyOlderChat(ctx, original) if err != nil { return place, err diff --git a/cmd/codeaf/olderchats_retry_test.go b/cmd/codeaf/olderchats_retry_test.go index deeb76d89e..c20a43affa 100644 --- a/cmd/codeaf/olderchats_retry_test.go +++ b/cmd/codeaf/olderchats_retry_test.go @@ -59,3 +59,34 @@ func TestAFailedOlderChatCopyWaitsForTheChatToChange(t *testing.T) { t.Fatalf("a chat that changed after its copy failed made %d copy attempts in total, want 2", attempts) } } + +// TestAnOlderChatInTheHomeFolderIsNotCopied keeps the copier off a chat whose +// folder is the home folder. A copy seals the chat's folder, and sealing the home +// folder walks every file under it while holding the engine's one writer, so +// every open chat's tools waited behind it until they timed out. +func TestAnOlderChatInTheHomeFolderIsNotCopied(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + person := t.TempDir() + t.Setenv("HOME", person) + original := filepath.Join(home.Dir(), "v3", "projects", "home", "home-session") + if err := os.MkdirAll(original, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(original, "transcript.jsonl"), []byte(`{"type":"session","version":1,"id":"home-session"}`+"\n"), 0600); err != nil { + t.Fatal(err) + } + if err := session.SaveMeta(original, session.Meta{ID: "home-session", LastUserAt: time.Now(), Workspace: person}); err != nil { + t.Fatal(err) + } + attempts := 0 + chats := olderChats{copy: func(context.Context, string) (cell.Cell, error) { + attempts++ + return cell.Cell{}, nil + }} + if _, _, ok := chats.visit(t.Context(), original); ok { + t.Fatal("a chat in the home folder was reported as copied") + } + if attempts != 0 { + t.Fatalf("a chat in the home folder made %d copy attempts, want 0", attempts) + } +} diff --git a/internal/manual/chat/continuing-a-chat-on-another-computer.md b/internal/manual/chat/continuing-a-chat-on-another-computer.md index d0854f83c2..b099fc61e6 100644 --- a/internal/manual/chat/continuing-a-chat-on-another-computer.md +++ b/internal/manual/chat/continuing-a-chat-on-another-computer.md @@ -330,10 +330,12 @@ Once the chat arrives, you can send your next message immediately. The window ke A running chat on the other computer never moves automatically. Neither does a named chat, `--host`, `--at` or `--session`, a chat with unsaved local edits, or a launch while another local window already has a chat open. If the service cannot be reached or another device got there first, Home says `other machines unreachable` or `another device continued this chat first` and keeps the local chat as it was. -## Why an older chat could not be copied +## Why an older chat could not be copied — symbolic links, home folder An older chat whose folder is a symbolic link, or whose saved conversation state contains symbolic links, is left untouched. codeaf refuses to copy those links because writing through one could change the original. The original still opens with an older codeaf. +An older chat that worked in your home folder, or in a folder that holds it, is not copied either. A copy saves the whole folder as it stands, and your home folder is not one project: saving all of it takes minutes and holds up every open chat's tools meanwhile. That chat keeps opening where it is, on this computer only, and it does not move to your other devices. Chats with their own folder are copied as usual. + ## A save has not reached the other computer yet — upload failed or another device continued first A saved chat reaches your other computers only after its encrypted objects have diff --git a/internal/session/delegate_door.go b/internal/session/delegate_door.go index 5ec726f241..19f12521fc 100644 --- a/internal/session/delegate_door.go +++ b/internal/session/delegate_door.go @@ -296,7 +296,7 @@ func programCopyReceipt(ground string, via delegate.Delegate, record *TaskCopyRe // or advising `git init` inside their dotfiles; it names the repository, the // way the run's ending does ([ProgramFolderEnd.Sentence]). func delegateFolderReceipt(ground string, via delegate.Delegate) string { - if _, _, outer, _ := programFolderOf(ground); outer != "" && !holdsHomeFolder(outer) { + if _, _, outer, _ := programFolderOf(ground); outer != "" && !HoldsHomeFolder(outer) { return "It is " + via.Name + "'s: it works alone in " + ground + " itself; git ignores this folder inside " + outer + ", so codeaf cuts no branch there and commits nothing; its changes are there as it makes them." } else if outer != "" { @@ -357,7 +357,7 @@ func delegateStartedReceipt(id uint64, title, on, where, elsewhere string) strin // what it changed — every file under the home folder, and a refusal from the // first one macOS keeps to itself. instead is what the one refused can do. func programHomeRefusal(program delegate.Delegate, dir, instead string) string { - if !program.LandsTree() || !holdsHomeFolder(dir) { + if !program.LandsTree() || !HoldsHomeFolder(dir) { return "" } what := "holds your home folder" @@ -367,8 +367,8 @@ func programHomeRefusal(program delegate.Delegate, dir, instead string) string { return program.Name + " works in one project's folder, and " + dir + " " + what + "; " + instead } -// holdsHomeFolder says dir is the person's home folder or a folder above it. -func holdsHomeFolder(dir string) bool { +// HoldsHomeFolder says dir is the person's home folder or a folder above it. +func HoldsHomeFolder(dir string) bool { home, err := os.UserHomeDir() if err != nil || strings.TrimSpace(home) == "" || strings.TrimSpace(dir) == "" { return false diff --git a/internal/session/programfolder.go b/internal/session/programfolder.go index b33b282e00..863cc430c5 100644 --- a/internal/session/programfolder.go +++ b/internal/session/programfolder.go @@ -304,7 +304,7 @@ func PrepareProgramFolder(order ProgramFolderOrder) (*ProgramFolder, error) { folder.release() return nil, err } - if outer != "" && !holdsHomeFolder(outer) { + if outer != "" && !HoldsHomeFolder(outer) { folder.IgnoredOuter = outer folder.Outer = "" } @@ -359,7 +359,7 @@ func programFolderOf(asked string) (dir string, repo bool, outer string, refusal switch { case !ok || !hasCommit(root): return asked, false, "", "" - case holdsHomeFolder(root): + case HoldsHomeFolder(root): // A REPOSITORY AT THE HOME FOLDER IS NOBODY'S PROJECT. A dotfiles // repository there would otherwise have every folder under home read as // its subfolder, and the program's branch cut in the person's dotfiles. From e95ac34da16c165887f328f93f40a29e8be374a8 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Tue, 27 Oct 2026 10:46:26 -0400 Subject: [PATCH 2/2] docs: change entry for #2002 --- .../unreleased/2002-older-chat-home-folder.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 docs/changes/unreleased/2002-older-chat-home-folder.md diff --git a/docs/changes/unreleased/2002-older-chat-home-folder.md b/docs/changes/unreleased/2002-older-chat-home-folder.md new file mode 100644 index 0000000000..5708405a06 --- /dev/null +++ b/docs/changes/unreleased/2002-older-chat-home-folder.md @@ -0,0 +1,13 @@ +--- +kind: fixed +title: Open chats no longer stall while an older chat in the home folder is copied +pr: 2002 +surface: [chat, engine, docs] +invalidates: + - "After pairing, codeaf copied a recent older chat that worked in the home folder by saving the whole home folder; it now leaves that chat where it is." + - "While that copy ran, every open chat's tools failed with an engine capture-plan answer interrupted error; they no longer wait behind it." +--- +A copy's first save walks the chat's whole folder while the engine's one +writer is held. Over the home folder that took minutes. The home folder is not +a project, so a chat that worked there keeps opening in place on this computer +and does not move to other devices.