From 0ef56b7177a9617f977c50bdf0549a1d98cf4bbc Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 11:07:34 -0400 Subject: [PATCH 01/23] Keep a chat continued at startup owned by the window that took it A bare start that continued another computer's released chat dropped the command batch Home returned, which carried the directory ask whose single-flight guard it had already set. The guard then stayed set, so the window kept the listing it read before the take: the first message opened "Continue this chat here?" for a chat this computer held, and choosing continue here retired this computer's own session and released its own hold before the conditional take was refused. Home's commands are now returned on every automatic continuation outcome. A completed take marks the chat as held here at once and replaces a listing that was asked before it. A send decides the continuation card from a fresh ownership read rather than the cached listing, and that read no longer ends an outstanding listing request. Continuing checks the fresh holder before retiring anything: a chat this computer already holds stays open as it is, and a different holder is refused before local work is touched. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/synctake.go | 2 +- internal/e2e/quit_continuation_e2e_test.go | 242 ++++++++++++++++++ .../continuing-a-chat-on-another-computer.md | 4 + internal/syncsetup/continue_ownership_test.go | 98 +++++++ internal/syncsetup/take.go | 21 +- internal/tui3/app.go | 5 +- internal/tui3/continuation_listing_test.go | 196 ++++++++++++++ internal/tui3/homeband_resume.go | 10 +- internal/tui3/homepanel_continue.go | 25 +- internal/tui3/homepanel_continue_test.go | 4 +- internal/tui3/homepanel_heldelsewhere.go | 10 +- internal/tui3/homepanel_machines.go | 18 +- internal/tui3/input.go | 8 +- internal/tui3/wormhole_contract_test.go | 4 +- 14 files changed, 623 insertions(+), 24 deletions(-) create mode 100644 internal/e2e/quit_continuation_e2e_test.go create mode 100644 internal/syncsetup/continue_ownership_test.go create mode 100644 internal/tui3/continuation_listing_test.go diff --git a/cmd/codeaf/synctake.go b/cmd/codeaf/synctake.go index adbbac3a89..49f6a42d4b 100644 --- a/cmd/codeaf/synctake.go +++ b/cmd/codeaf/synctake.go @@ -156,7 +156,7 @@ func (t takeover) OpenBranch(ctx context.Context, id string) (tui3.Taken, error) } func surfaceTaken(got syncsetup.Continued, elapsed time.Duration) tui3.Taken { - return tui3.Taken{Kept: got.Taken.Kept, KeptTurns: got.KeptTurns, Device: got.Device, TaskCopies: got.TaskCopies, Resume: got.Resume, Transcript: filepath.Join(got.Taken.Cell.Root, cell.TranscriptPath), Elapsed: elapsed} + return tui3.Taken{AlreadyHere: got.AlreadyHere, Kept: got.Taken.Kept, KeptTurns: got.KeptTurns, Device: got.Device, TaskCopies: got.TaskCopies, Resume: got.Resume, Transcript: filepath.Join(got.Taken.Cell.Root, cell.TranscriptPath), Elapsed: elapsed} } // takeRootFor is where this machine keeps the chat with an id: the folder it diff --git a/internal/e2e/quit_continuation_e2e_test.go b/internal/e2e/quit_continuation_e2e_test.go new file mode 100644 index 0000000000..9bf7869bf2 --- /dev/null +++ b/internal/e2e/quit_continuation_e2e_test.go @@ -0,0 +1,242 @@ +//go:build e2e + +package e2e + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "syscall" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/reqsign" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// TestQuitContinuationFirstSend proves, with the shipped terminal and the native +// service, that a chat continued by a bare start takes its first message with no +// continuation card and without releasing this computer's own hold. +func TestQuitContinuationFirstSend(t *testing.T) { + if _, err := exec.LookPath("tmux"); err != nil { + t.Fatal(err) + } + root, err := os.MkdirTemp("", "l2lfix-continue-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(root) }) + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + const first = "Remember the amber lighthouse." + const second = "Continue with the amber lighthouse." + const reply = "AMBER_LIGHTHOUSE_ANSWER" + const continuedReply = "AMBER_LIGHTHOUSE_CONTINUED" + var answered atomic.Bool + brain := &automationBrain{} + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/models", brain.serveCatalog) + mux.HandleFunc("/api/v1/chat/completions", func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + text := reply + if strings.Contains(string(raw), second) { + answered.Store(true) + text = continuedReply + } + send, ok := automationStream(w) + if !ok { + return + } + send(fmt.Sprintf(`{"content":%q}`, text), "") + send(`{}`, "stop") + send(`{}`, "[DONE]") + }) + model := httptest.NewServer(mux) + t.Cleanup(model.Close) + relay := quitContinuationRelay(t, root) + upstream, _ := url.Parse(relay) + proxy := httputil.NewSingleHostReverseProxy(upstream) + var releasesA, releasesB, acquiresB atomic.Int32 + var devA, devB string + proxy.ModifyResponse = func(response *http.Response) error { + if response.StatusCode == http.StatusNoContent && strings.HasSuffix(response.Request.URL.Path, "/release") { + raw, _ := base64.RawURLEncoding.DecodeString(response.Request.Header.Get(reqsign.HeaderCert)) + var cert identity.Cert + _ = json.Unmarshal(raw, &cert) + if cert.DeviceID() == devA { + releasesA.Add(1) + } + } + return nil + } + audit := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + raw, _ := base64.RawURLEncoding.DecodeString(r.Header.Get(reqsign.HeaderCert)) + var cert identity.Cert + _ = json.Unmarshal(raw, &cert) + device := cert.DeviceID() + if strings.HasSuffix(r.URL.Path, "/release") || strings.HasSuffix(r.URL.Path, "/acquire") { + t.Logf("directory %s device=%s first=%s second=%s", r.URL.Path, device, devA, devB) + } + if strings.HasSuffix(r.URL.Path, "/release") { + if device == devB { + releasesB.Add(1) + } + } + if strings.HasSuffix(r.URL.Path, "/acquire") && device == devB { + acquiresB.Add(1) + } + } + proxy.ServeHTTP(w, r) + })) + t.Cleanup(audit.Close) + t.Setenv(syncsetup.URLVar, audit.URL) + homeA, homeB := filepath.Join(root, "a"), filepath.Join(root, "b") + for _, home := range []string{homeA, homeB} { + if err := os.Mkdir(home, 0700); err != nil { + t.Fatal(err) + } + rows := map[string]any{"api_key": "stub-key", "model.talk": automationStubModel, "memory.enabled": false, "tools.approvalMode": "allow", "setup_seen_at": time.Now().UTC().Format(time.RFC3339), config.KeyIcons: config.IconsPlain} + for _, key := range []string{config.KeyTaskModel, config.KeyTierWorkerModel, config.KeyTierLowModel, config.KeyTierHighModel, config.KeyTierReflexModel, config.KeyTierMastermindModel} { + rows[key] = automationStubModel + } + raw, _ := json.Marshal(rows) + if err := os.WriteFile(filepath.Join(home, "config.json"), raw, 0600); err != nil { + t.Fatal(err) + } + } + // Paired homes share the identity while retaining distinct device keys. + id, err := identity.Ensure(homeA) + if err != nil { + t.Fatal(err) + } + if _, err := identity.Adopt(homeB, id, false); err != nil { + t.Fatal(err) + } + for index, home := range []string{homeA, homeB} { + name := []string{"tower", "laptop"}[index] + if _, err := devname.Set(home, name); err != nil { + t.Fatal(err) + } + s, ok, err := syncsetup.Open(home) + if err != nil || !ok { + t.Fatalf("paired home: %v %v", ok, err) + } + if index == 0 { + devA = s.Device.ID() + } else { + devB = s.Device.ID() + } + if err := syncsetup.PutOwnDevice(t.Context(), s.Dir, s.Identity, s.Device, name); err != nil { + t.Fatal(err) + } + } + env := []string{config.APIKeyEnv + "=stub-key", "CODEAF_BASE_URL=" + model.URL + "/api/v1", "CODEAF_PROFILE_DIR=", "CODEAF_SYNC_URL=" + audit.URL, "CODEAF_SYNC_INTERVAL_MS=100", "CODEAF_TELEMETRY=off", "DO_NOT_TRACK=1", "CODEAF_NO_UPDATE_CHECK=1", "CODEAF_NO_AUTOMATIONS=1"} + wsA, wsB := filepath.Join(root, "work-a"), filepath.Join(root, "work-b") + for _, ws := range []string{wsA, wsB} { + if err := os.Mkdir(ws, 0700); err != nil { + t.Fatal(err) + } + } + // Engines belong to these temporary homes, including any moved workspaces. + t.Cleanup(func() { + pids := engineDaemonsUnder(root) + for _, pid := range pids { + _ = syscall.Kill(pid, syscall.SIGTERM) + } + deadline := time.Now().Add(10 * time.Second) + for _, pid := range pids { + for !terminalProcessExited(pid) && time.Now().Before(deadline) { + time.Sleep(20 * time.Millisecond) + } + if !terminalProcessExited(pid) { + _ = syscall.Kill(pid, syscall.SIGKILL) + } + } + }) + a := startWithEnv(t, env, "quit-continue-a", homeA, wsA, 160, 45) + a.lit(first) + a.keys("Enter") + a.waitFor(35*time.Second, reply) + a.lit("/quit") + a.keys("Enter") + if !waitUntil(15*time.Second, func() bool { return releasesA.Load() > 0 }) { + t.Fatalf("clean quit did not release the saved chat:\n%s", a.capture()) + } + a.kill() + b := startWithEnv(t, env, "quit-continue-b", homeB, wsB, 160, 45) + b.waitFor(35*time.Second, "Moved from tower") + if acquiresB.Load() == 0 { + t.Fatal("startup did not acquire") + } + b.lit(second) + b.keys("Enter") + deadline := time.Now().Add(35 * time.Second) + for { + screen := b.capture() + if strings.Contains(screen, "Continue this chat here?") { + t.Fatalf("first send raised the continuation card:\n%s", screen) + } + if strings.Contains(screen, "this window now only shows it") { + t.Fatalf("the window that continued the chat said another computer holds it:\n%s", screen) + } + if answered.Load() && strings.Contains(screen, continuedReply) { + break + } + if time.Now().After(deadline) { + t.Fatalf("first send did not reach the scripted model:\n%s", screen) + } + time.Sleep(20 * time.Millisecond) + } + if releasesB.Load() != 0 { + t.Fatalf("this window released its own hold before sending: %d", releasesB.Load()) + } + t.Logf("first send answered; acquires=%d releases before send=%d", acquiresB.Load(), releasesB.Load()) +} + +func quitContinuationRelay(t *testing.T, root string) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + addr := ln.Addr().String() + _ = ln.Close() + env := append(os.Environ(), "CODEAF_HOME="+filepath.Join(root, "relay-home"), "CODEAF_TELEMETRY=off", "DO_NOT_TRACK=1") + cmd := guardedCommand(t, context.Background(), filepath.Join(root, "relay-home"), env, binary(t), "relay", "--listen", addr, "--store", filepath.Join(root, "store"), "--quiet") + log, err := os.Create(filepath.Join(root, "relay.log")) + if err != nil { + t.Fatal(err) + } + cmd.Stdout, cmd.Stderr = log, log + if err := cmd.Start(); err != nil { + _ = log.Close() + t.Fatal(err) + } + t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait(); _ = log.Close() }) + if !waitUntil(10*time.Second, func() bool { + c, err := net.Dial("tcp", addr) + if err == nil { + _ = c.Close() + } + return err == nil + }) { + t.Fatal("native service did not listen") + } + return "http://" + addr +} 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 6c025de18e..c937046c52 100644 --- a/internal/manual/chat/continuing-a-chat-on-another-computer.md +++ b/internal/manual/chat/continuing-a-chat-on-another-computer.md @@ -21,6 +21,8 @@ Your words stay in the box. Nothing was sent, and no branch nobody asked for was An attended window keeps following your other devices while the chat is open, so a move is shown without returning to home first. Slash commands such as `/help` always work. +Before showing this question for a send, codeaf checks which computer holds the chat now. An older listing alone cannot raise it. If the check cannot reach your other devices and this window already knows the chat moved away, your words stay in the box and nothing is sent. Continuing a chat this computer already holds keeps its open conversation and your typed words. + If you continue it, the other device's window stops taking new turns and says ` continued this chat; this window now only shows it`. The page *Home* has the rest of that card. ## What does leave it there do @@ -239,6 +241,8 @@ The original older chat is untouched. A turn typed into the original by an older A clean quit lets go after the last save is durable. Opening bare `codeaf` on your other paired computer within 24 hours continues the released chat automatically, with the usual `Moved from` banner and `not brought along:` lines. `esc` goes Home. A sleeping laptop has not let go: Home offers the chat and `alt+c` continues it, asking only if local work needs a choice. +Once the chat arrives, you can send your next message immediately. The window keeps following changes, including renamed computers and a later move to another computer. + 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 diff --git a/internal/syncsetup/continue_ownership_test.go b/internal/syncsetup/continue_ownership_test.go new file mode 100644 index 0000000000..20928c8439 --- /dev/null +++ b/internal/syncsetup/continue_ownership_test.go @@ -0,0 +1,98 @@ +package syncsetup + +import ( + "context" + "errors" + "net/http" + "os" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/filelock" + "github.com/Agent-Field/codeaf/internal/session" +) + +// TestContinueChecksOwnershipBeforeRetiring proves against the native service that +// continuing never releases this computer's own hold, and that a different holder +// is refused before any local session is retired. +func TestContinueChecksOwnershipBeforeRetiring(t *testing.T) { + for _, own := range []bool{true, false} { + t.Run(map[bool]string{true: "already held here", false: "different holder won"}[own], func(t *testing.T) { + var releases, acquires atomic.Int32 + h := newTwoHomes(t, func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/release") { + releases.Add(1) + } + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/acquire") { + acquires.Add(1) + } + next.ServeHTTP(w, r) + }) + }) + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + a := h.openA() + a.mustSay("saved before continuing") + h.durable(h.cell.ID, h.cell) + view, err := h.a.Dir.Cell(ctx, h.cell.ID) + mustNil(t, err) + seen := &directory.LeaseExpectation{Device: h.b.Device.ID(), Fence: view.Cell.Lease.Fence - 1} + if !own { + seen = &directory.LeaseExpectation{Device: view.Cell.Lease.Device, Fence: view.Cell.Lease.Fence} + _, err = h.b.Dir.Acquire(ctx, h.cell.ID, directory.AcquireOpts{Force: true, Expect: seen, RequireConditional: true}) + mustNil(t, err) + } + before, err := h.a.Dir.Cell(ctx, h.cell.ID) + mustNil(t, err) + journal, err := os.Open(transcriptOf(h.cell)) + mustNil(t, err) + defer journal.Close() + mustNil(t, filelock.Lock(journal, true, true)) + defer filelock.Unlock(journal) + var retired atomic.Bool + stop, done := make(chan struct{}), make(chan struct{}) + go func() { + defer close(done) + beat := time.NewTicker(time.Millisecond) + defer beat.Stop() + for { + select { + case <-stop: + return + case <-beat.C: + if _, err := os.Stat(session.TakeoverPath(h.cell.Root)); err == nil { + retired.Store(true) + _ = a.drive.Close(ctx) + _ = filelock.Unlock(journal) + return + } + } + } + }() + defer func() { close(stop); <-done }() + r0, a0 := releases.Load(), acquires.Load() + got, err := h.continuerA().TakeSeen(ctx, h.cell.ID, seen) + if own && err != nil { + t.Errorf("already held here refused: %v", err) + } + if !own && !errors.Is(err, directory.ErrLeaseHeld) { + t.Errorf("different holder was not refused: %v", err) + } + after, readErr := h.a.Dir.Cell(ctx, h.cell.ID) + mustNil(t, readErr) + if retired.Load() || releases.Load() != r0 || acquires.Load() != a0 || !session.InUse(transcriptOf(h.cell)) { + t.Errorf("continuation retired the session or changed ownership: retired=%v releases=%d acquires=%d inUse=%v", retired.Load(), releases.Load()-r0, acquires.Load()-a0, session.InUse(transcriptOf(h.cell))) + } + if after.Cell.Lease != before.Cell.Lease { + t.Errorf("lease changed: before=%+v after=%+v", before.Cell.Lease, after.Cell.Lease) + } + if own && (got.Taken.Driving.Fence != before.Cell.Lease.Fence || got.Taken.Cell.Root != h.cell.Root) { + t.Errorf("no-op lost the local chat: %+v", got) + } + }) + } +} diff --git a/internal/syncsetup/take.go b/internal/syncsetup/take.go index 7f454c89c4..cf88a4e929 100644 --- a/internal/syncsetup/take.go +++ b/internal/syncsetup/take.go @@ -43,6 +43,8 @@ type TakeOptions struct { // Continued is a takeover that worked, with what the surface says about it. type Continued struct { Taken handoff.Taken + // AlreadyHere keeps an active local session attached when no move was needed. + AlreadyHere bool // KeptTurns is how many turns the kept branch holds, 0 when nothing was kept. KeptTurns uint32 // Device is this machine's name. @@ -128,7 +130,24 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx if branchOnly && (view.Cell.ParentCell == "" || view.Cell.Archived) { return Continued{}, errors.New("these turns are no longer available to open separately") } - from := c.holderName(ctx, id) + lease := view.Cell.Lease + // Kept turns may name this device before their separate copy exists here. + if !branchOnly && view.Cell.ParentCell == "" && lease.Device == c.sync.Device.ID() && lease.Expires > view.Now { + opened, err := cell.OpenAt(root, id) + if err != nil { + return Continued{}, err + } + return Continued{AlreadyHere: true, Device: c.opt.DeviceName, Taken: handoff.Taken{ + Cell: opened, Driving: cellsync.Driving{Cell: opened, Fence: lease.Fence, Head: view.Cell.Head, Frames: view.Cell.Frames}, + }}, nil + } + if seen != nil && (lease.Device != seen.Device || lease.Fence != seen.Fence) { + return Continued{}, directory.ErrLeaseHeld + } + if seen == nil { + seen = &directory.LeaseExpectation{Device: lease.Device, Fence: lease.Fence} + } + from := c.sync.deviceName(lease.Device) c.retireHolder(ctx, id) sc := c.sync.scope(id) defer sc.Settle() diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 1ee11313c7..901c749c90 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -2314,7 +2314,10 @@ type app struct { branches BranchActions machineRead machineReading machinesAsking bool - machinePoll machinePoll + // machinesRetaken replaces a listing begun before a completed take, so + // its late answer cannot put the previous holder back on screen. + machinesRetaken bool + machinePoll machinePoll // fleet counts this person's devices and addMachine is the home card that // uses the count (homeband_addmachine.go). fleet Fleet diff --git a/internal/tui3/continuation_listing_test.go b/internal/tui3/continuation_listing_test.go new file mode 100644 index 0000000000..c61669e47b --- /dev/null +++ b/internal/tui3/continuation_listing_test.go @@ -0,0 +1,196 @@ +package tui3 + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" +) + +type continuationListing struct { + mu sync.Mutex + row chatlist.Row + err error + reads int +} + +func (s *continuationListing) Rows(context.Context) ([]chatlist.Row, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.reads++ + return []chatlist.Row{s.row}, s.err +} + +func (s *continuationListing) change(edit func(*chatlist.Row)) { + s.mu.Lock() + defer s.mu.Unlock() + edit(&s.row) +} + +// TestStartupContinueKeepsOwnershipAndLaterListings proves that a startup take is +// reflected at once, that later listings land, and that the first send needs no card. +func TestStartupContinueKeepsOwnershipAndLaterListings(t *testing.T) { + lab := newHomeLab(t) + file := lab.session("project", heldCell, "Mine", lab.workspace("project"), time.Now()) + a := lab.app(file) + a.width, a.height = 200, 70 + now := time.Now() + a.clock = func() time.Time { return now } + a.lastQuestionKey = now + startupClock := a.homeGen + src := &continuationListing{row: leftOffRow(heldCell, "tower", chatlist.Idle, time.Minute)} + src.row.Released = true + taker := &safeContinueTaker{clean: true} + taker.on = func(string) { + src.change(func(row *chatlist.Row) { row.Mine, row.Status, row.Released = true, chatlist.Here, false }) + } + a.machines, a.taker, a.autoContinue = src, taker, true + drain(t, a, a.askMachines()) + if len(taker.cells) != 1 || a.at(pageHome) { + t.Fatalf("startup did not open the taken chat: takes=%v home=%v", taker.cells, a.at(pageHome)) + } + if a.machinesAsking || len(a.machineRead.rows) != 1 || !a.machineRead.rows[0].Mine { + t.Errorf("take left the pre-take reading: asking=%v reads=%d rows=%+v", a.machinesAsking, src.reads, a.machineRead.rows) + } + t.Logf("listing after arrival: reads=%d asking=%v mine=%v", src.reads, a.machinesAsking, a.machineRead.rows[0].Mine) + src.change(func(row *chatlist.Row) { row.Device = "renamed tower" }) + now = now.Add(homeEvery) + drive(t, a, homeTickMsg{gen: startupClock}) + if a.machineRead.rows[0].Device != "renamed tower" { + t.Errorf("later listing did not land: asking=%v reads=%d rows=%+v", a.machinesAsking, src.reads, a.machineRead.rows) + } + agent := a.agent.(*fakeAgent) + typeText(t, a, "first message after moving") + drive(t, a, key("enter")) + if len(agent.sent) != 1 || a.at(pageHome) { + t.Fatalf("first send raised a continuation door: sent=%v home=%v", agent.sent, a.at(pageHome)) + } + src.change(func(row *chatlist.Row) { row.Mine, row.Status = false, chatlist.Running }) + if a.machinePoll.due.Sub(now) > machinesCap { + t.Fatal("the next listing exceeded the normal poll cap") + } + now = a.machinePoll.due + drive(t, a, homeTickMsg{gen: a.homeGen}) + if _, elsewhere := a.heldElsewhere(); !elsewhere { + t.Fatal("later takeover did not reach the open window") + } +} + +// TestContinueSendUsesFreshOwnership proves that a send decides the continuation +// card from a fresh reading, including when the service is unreachable. +func TestContinueSendUsesFreshOwnership(t *testing.T) { + for _, unreachable := range []bool{false, true} { + t.Run(map[bool]string{false: "held here", true: "unreachable"}[unreachable], func(t *testing.T) { + src := &continuationListing{row: leftOffRow(heldCell, "tower", chatlist.Running, time.Minute)} + a, agent := heldLab(t, src, &fakeTaker{}) + a.closeHome() + src.change(func(row *chatlist.Row) { row.Mine, row.Status = true, chatlist.Here }) + if unreachable { + src.err = directory.ErrUnreachable + } + before := src.reads + typeText(t, a, "keep these words") + drive(t, a, key("enter")) + if src.reads != before+1 { + t.Errorf("send made %d ownership reads, want one", src.reads-before) + } + if _, card := a.homeAsking(); card { + t.Error("cached ownership raised the continuation card") + } + if unreachable { + if len(agent.sent) != 0 || a.input.String() != "keep these words" { + t.Fatalf("unreachable send=%v draft=%q", agent.sent, a.input.String()) + } + } else if len(agent.sent) != 1 { + t.Fatalf("locally held chat did not send: %v", agent.sent) + } + }) + } +} + +// TestResumeChordAcquiresTakeableChat proves through the key router that alt+c +// acquires a takeable chat rather than only reading it. +func TestResumeChordAcquiresTakeableChat(t *testing.T) { + for _, status := range []chatlist.Status{chatlist.Idle, chatlist.Off, chatlist.Running} { + t.Run(string(status), func(t *testing.T) { + src := &continuationListing{row: leftOffRow(heldCell, "tower", status, time.Minute)} + taker := &safeContinueTaker{clean: true} + a, _ := heldLab(t, src, taker) + if status == chatlist.Running { + _, feed := devicesRig() + a.dirFeed = feed + } + taker.on = func(string) { src.change(func(row *chatlist.Row) { row.Mine, row.Status = true, chatlist.Here }) } + drain(t, a, a.askMachines()) + drive(t, a, key(resumeKey)) + if len(taker.cells) != 1 { + t.Fatalf("chord did not acquire: %v", taker.cells) + } + }) + } +} + +// TestContinueTakeReplacesAnOutstandingListing proves that a listing asked before +// a take cannot put the previous holder back on screen. +func TestContinueTakeReplacesAnOutstandingListing(t *testing.T) { + src := &continuationListing{row: leftOffRow(heldCell, "tower", chatlist.Running, time.Minute)} + taker := &fakeTaker{} + a, _ := heldLab(t, src, taker) + old := a.askChats()() + taker.on = func(string) { src.change(func(row *chatlist.Row) { row.Mine, row.Status = true, chatlist.Here }) } + drain(t, a, a.takeRemote(src.row)) + if !a.machineRead.rows[0].Mine { + t.Fatal("successful take waited for the old listing before showing local ownership") + } + drive(t, a, old) + if a.machinesAsking || !a.machineRead.rows[0].Mine { + t.Fatalf("old answer restored the previous holder: asking=%v rows=%+v", a.machinesAsking, a.machineRead.rows) + } +} + +// TestContinueAlreadyHeldHereKeepsTheSessionAndDraft proves at the surface that +// continuing a chat this computer holds keeps its session and the typed words. +func TestContinueAlreadyHeldHereKeepsTheSessionAndDraft(t *testing.T) { + src := &continuationListing{row: leftOffRow(heldCell, "tower", chatlist.Running, time.Minute)} + taker := &fakeTaker{} + a, agent := heldLab(t, src, taker) + a.closeHome() + taker.taken = Taken{AlreadyHere: true, Transcript: a.file} + taker.on = func(string) { src.change(func(row *chatlist.Row) { row.Mine, row.Status = true, chatlist.Here }) } + a.open = func(string, string) (Conversation, error) { + t.Fatal("already held chat reopened its active session") + return Conversation{}, nil + } + typeText(t, a, "words kept through the stale card") + drive(t, a, key("enter")) + confirm(t, a) + if a.at(pageHome) || a.agent != agent || a.input.String() != "words kept through the stale card" { + t.Fatalf("no-op lost the active session or draft: home=%v draft=%q", a.at(pageHome), a.input.String()) + } + drive(t, a, key("enter")) + if len(agent.sent) != 1 { + t.Fatalf("send after the no-op did not proceed: %v", agent.sent) + } +} + +// TestOwnershipReadDoesNotCompleteAnOutstandingListing proves that a send's own +// reading leaves the window's outstanding listing request to finish by itself. +func TestOwnershipReadDoesNotCompleteAnOutstandingListing(t *testing.T) { + src := &continuationListing{row: leftOffRow(heldCell, "tower", chatlist.Here, time.Minute)} + src.row.Mine = true + a, _ := heldLab(t, src, &fakeTaker{}) + a.closeHome() + listing := a.askChats() + typeText(t, a, "send during the listing") + drive(t, a, key("enter")) + if !a.machinesAsking { + t.Fatal("the send completed a listing that has not answered") + } + drain(t, a, listing) + if a.machinesAsking { + t.Fatal("the listing's own answer did not clear its guard") + } +} diff --git a/internal/tui3/homeband_resume.go b/internal/tui3/homeband_resume.go index d80c94787c..7aaf11a71f 100644 --- a/internal/tui3/homeband_resume.go +++ b/internal/tui3/homeband_resume.go @@ -252,17 +252,19 @@ func (a *app) continuationReady(msg continuationChecked) tea.Cmd { } } + var opened tea.Cmd if msg.automatic && !a.at(pageHome) { - _ = a.openHome() + // Home starts reads and a clock even when the chat opens immediately. + opened = a.openHome() } if msg.err != nil { - return a.tookTakeover(homeTakenMsg{row: msg.row, err: msg.err}) + return tea.Batch(opened, a.tookTakeover(homeTakenMsg{row: msg.row, err: msg.err})) } if !msg.clean { if msg.automatic { - return nil + return opened } return a.raiseContinue(msg.row, continueYesAt) } - return a.takeRemote(msg.row) + return tea.Batch(opened, a.takeRemote(msg.row)) } diff --git a/internal/tui3/homepanel_continue.go b/internal/tui3/homepanel_continue.go index 3622437e2f..6060d613d6 100644 --- a/internal/tui3/homepanel_continue.go +++ b/internal/tui3/homepanel_continue.go @@ -37,9 +37,11 @@ import ( // holds edits made here which the takeover set aside before it fetched, with // how many turns it holds and the name of this machine. type Taken struct { - Kept string - KeptTurns uint32 - Device string + // AlreadyHere preserves the attached session when this device already holds it. + AlreadyHere bool + Kept string + KeptTurns uint32 + Device string // TaskCopies names the tasks whose working copies came along with the chat. TaskCopies []string // Resume is what the chat left behind on the other machine that this one @@ -323,9 +325,22 @@ func (a *app) rebuildMachines() { // the one they are arriving in. A shared window opens in place, and the ride // is spent here rather than at the landing. func (a *app) openTaken(msg homeTakenMsg) tea.Cmd { + rows := append([]chatlist.Row(nil), a.machineRead.rows...) + for i := range rows { + if rows[i].Cell == msg.row.Cell { + rows[i].Mine, rows[i].Status, rows[i].Released = true, chatlist.Here, false + rows[i].Device, rows[i].DeviceID, rows[i].Fence = msg.taken.Device, "", 0 + } + } + a.machineRead.rows = rows + a.machinesRetaken = a.machinesAsking + a.considerResume() a.refreshHome() kept := takenSaid(msg.taken) moved := movedSaid(msg.taken) + if msg.taken.AlreadyHere { + moved = "" + } line, ok := a.takenLine(msg) if !ok { a.home.say(joinSaid(moved, kept), "") @@ -335,7 +350,7 @@ func (a *app) openTaken(msg homeTakenMsg) tea.Cmd { // A local session of this same chat was retired before replacement. // Its old handle cannot remain a selectable copy behind the new one. key := a.convKey(line.row.Transcript) - if held := a.behind[key]; held != nil { + if held := a.behind[key]; held != nil && !msg.taken.AlreadyHere { if held.watch != nil { held.watch.stop() } @@ -349,7 +364,7 @@ func (a *app) openTaken(msg homeTakenMsg) tea.Cmd { return nil } var cmd tea.Cmd - if msg.taken.Transcript != "" && a.holding(line.row.Transcript) { + if !msg.taken.AlreadyHere && msg.taken.Transcript != "" && a.holding(line.row.Transcript) { // The transcript was replaced by the handoff. Reattach through the // real door instead of bringing the superseded in-memory session back. cmd = a.homeOpenLater(line, false) diff --git a/internal/tui3/homepanel_continue_test.go b/internal/tui3/homepanel_continue_test.go index 66bb423163..ee3deb43a6 100644 --- a/internal/tui3/homepanel_continue_test.go +++ b/internal/tui3/homepanel_continue_test.go @@ -167,11 +167,13 @@ func TestTakeoverScreenOpensTheChatAndSaysWhatWasKept(t *testing.T) { a := lab.app(mine) a.width, a.height = 200, 70 drain(t, a, a.openHome()) - a.machines = continueRows + src := &continuationListing{row: continueRows[0]} + a.machines = src a.taker = &fakeTaker{ taken: Taken{Kept: "c-kept", KeptTurns: 2, Device: "desk", Elapsed: 3200 * time.Millisecond}, on: func(cell string) { lab.session("project", cell, "Port the picker", workspace, time.Now()) + src.change(func(row *chatlist.Row) { row.Mine, row.Status = true, chatlist.Here }) }, } drain(t, a, a.askMachines()) diff --git a/internal/tui3/homepanel_heldelsewhere.go b/internal/tui3/homepanel_heldelsewhere.go index aff938b190..20b4395169 100644 --- a/internal/tui3/homepanel_heldelsewhere.go +++ b/internal/tui3/homepanel_heldelsewhere.go @@ -19,9 +19,9 @@ import ( // instead: home, with the chat's row from that machine under the cursor and the // `Continue here` card up. The words stay in the box. // -// The cached reading catches a known move immediately. A submitted message -// also asks once off the loop, because its ownership-change frame may still be -// on the way; the draft stays intact until that answer arrives. +// A submitted message asks once off the loop before choosing the door, because +// the cached holder may have changed. The draft stays intact until the answer +// arrives; an unreachable reading keeps the existing local availability rules. // heldElsewhere is the row of the chat on screen when another machine holds it. func (a *app) heldElsewhere() (chatlist.Row, bool) { @@ -122,7 +122,7 @@ func (a *app) afterOwnershipRead(msg ownershipReadMsg) tea.Cmd { // draft, which must not invalidate that send while ownership is read. next := a.liveComposer() a.putComposer(*msg.first) - reading := a.tookMachines(msg.reading) + reading := a.recordMachines(msg.reading) cmd := msg.resume() if a.liveComposer().empty() { a.putComposer(next) @@ -138,6 +138,6 @@ func (a *app) afterOwnershipRead(msg ownershipReadMsg) tea.Cmd { if a.input.String() != msg.draft || !slices.Equal(a.chips, msg.chips) { return nil } - reading := a.tookMachines(msg.reading) + reading := a.recordMachines(msg.reading) return tea.Batch(reading, msg.resume()) } diff --git a/internal/tui3/homepanel_machines.go b/internal/tui3/homepanel_machines.go index 9a0870528c..258a25ea7c 100644 --- a/internal/tui3/homepanel_machines.go +++ b/internal/tui3/homepanel_machines.go @@ -84,8 +84,18 @@ func (a *app) askChats() tea.Cmd { // tookMachines files the answer. An error keeps the last rows and only marks // them stale. func (a *app) tookMachines(msg homeMachinesMsg) tea.Cmd { - before, wasElsewhere := a.heldElsewhere() a.machinesAsking = false + if a.machinesRetaken { + a.machinesRetaken = false + return a.askMachines() + } + return a.recordMachines(msg) +} + +// recordMachines also accepts a send's independent read without ending an +// outstanding listing request that still owes its own answer. +func (a *app) recordMachines(msg homeMachinesMsg) tea.Cmd { + before, wasElsewhere := a.heldElsewhere() a.fileMachines(msg) if row, elsewhere := a.heldElsewhere(); msg.err == nil && elsewhere && (!wasElsewhere || before.DeviceID != row.DeviceID || before.Device != row.Device) { a.note(chatlist.Superseded(row.Device)) @@ -95,7 +105,11 @@ func (a *app) tookMachines(msg homeMachinesMsg) tea.Cmd { a.machineRead.separate = a.branches.Open != nil a.considerResume() a.rebuildMachines() - return tea.Batch(a.readOwedByFrames(msg.err == nil), a.finishBring(), a.continueAfterQuit(msg.err)) + var owed tea.Cmd + if !a.machinesAsking { + owed = a.readOwedByFrames(msg.err == nil) + } + return tea.Batch(owed, a.finishBring(), a.continueAfterQuit(msg.err)) } // into merges the other machines' chats into the panel's own lines, newest diff --git a/internal/tui3/input.go b/internal/tui3/input.go index 4d905a4196..ab908cb12c 100644 --- a/internal/tui3/input.go +++ b/internal/tui3/input.go @@ -1543,12 +1543,14 @@ func (a *app) enterCheckedLine(ownershipRead bool) tea.Cmd { // asking for a provider connection or a model. The move may bring the // saved connection this computer needs along with the chat. if !strings.HasPrefix(line, "/") && (line != "" || held) { - if door, shown := a.heldElsewhereDoor(); shown { - return door - } if !ownershipRead && a.watchesConversation() { return a.checkBeforeSend() } + if ownershipRead { + if door, shown := a.heldElsewhereDoor(); shown { + return door + } + } } // A MODEL MESSAGE THAT STILL NEEDS THE DEFAULT PROVIDER OPENS ITS CONNECTION // BEFORE THE DRAFT IS CLEARED. This is the returning half of the key gate: a diff --git a/internal/tui3/wormhole_contract_test.go b/internal/tui3/wormhole_contract_test.go index aca8b5cb21..4b6a0e90a3 100644 --- a/internal/tui3/wormhole_contract_test.go +++ b/internal/tui3/wormhole_contract_test.go @@ -34,8 +34,10 @@ func TestC5OnlineContinueOpensTheArrivedCopy(t *testing.T) { if err := os.WriteFile(transcript, []byte("{}\n"), 0600); err != nil { t.Fatal(err) } - a.machines = continueRows + src := &continuationListing{row: continueRows[4]} + a.machines = src a.taker = &fakeTaker{taken: Taken{Transcript: transcript, Elapsed: 3 * time.Second, Resume: machine.Resume{From: "host-a", Now: work, Uncommitted: []string{"README.md"}}}, on: func(id string) { + src.change(func(row *chatlist.Row) { row.Mine, row.Status = true, chatlist.Here }) // This is the stale derived row read immediately after the handoff. It has // the right transcript and an old or unavailable project path. if err := session.SaveMeta(at, session.Meta{ID: id, Title: "Arrived", Workspace: filepath.Join(lab.root, "gone"), Created: time.Now(), LastUserAt: time.Now()}); err != nil { From bb17565fef0ee2010a49a52916a52f109dab6cd9 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 12:26:08 -0400 Subject: [PATCH 02/23] Let a coordinating computer's clean quit release its chats, then hand coordination back A personal computer that led coordination ran its member inside the open terminal and stopped it when the terminal exited. The engine's closing release for the chat it held then reached a coordinator that was already leaving, got 503, and was never tried again, so the chat read as held offline until its lease ran out and coordination paused until the anchors elected a new leader. A coordinating terminal now waits, as it leaves its chats, for each idle chat's closing release to finish, then transfers leadership to an anchor and waits for an anchor to answer a fresh majority read before its member stops. That hand-back is bounded by LeaderQuitWithin (5 seconds); if the anchors cannot take over, the ordinary fallback applies. The anchors then restore their own voters as fallback already does, and the computer that quit does not get leadership back. A terminal that is not coordinating leaves exactly as before. A closing release that meets a temporary coordination refusal, including a bare 503, is retried within its existing close deadline; a definitive refusal is never retried. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/chatv3_quit_test.go | 86 +++++++ cmd/codeaf/chatv3_surface.go | 13 +- docs/relay.md | 5 +- internal/cellsync/batch.go | 25 +- internal/cellsync/quit_release_test.go | 112 +++++++++ internal/directory/http.go | 7 + .../continuing-a-chat-on-another-computer.md | 4 +- .../manual/chat/coordination-and-workers.md | 6 +- internal/remote/client.go | 10 + internal/remote/quit_release_test.go | 87 +++++++ internal/remote/server.go | 32 ++- internal/remote/wire.go | 6 + internal/syncsetup/member.go | 6 + internal/syncsetup/member_hosted_quit_test.go | 102 +++++++++ internal/syncsetup/member_quit.go | 126 ++++++++++ internal/syncsetup/member_quit_test.go | 215 ++++++++++++++++++ internal/tui3/app.go | 2 + internal/tui3/keeper.go | 7 + internal/tui3/quit_release_test.go | 35 +++ internal/tui3/tui3.go | 3 + .../test/raft/hosted-fallback-go.test.js | 2 + 21 files changed, 878 insertions(+), 13 deletions(-) create mode 100644 cmd/codeaf/chatv3_quit_test.go create mode 100644 internal/cellsync/quit_release_test.go create mode 100644 internal/remote/quit_release_test.go create mode 100644 internal/syncsetup/member_hosted_quit_test.go create mode 100644 internal/syncsetup/member_quit.go create mode 100644 internal/syncsetup/member_quit_test.go create mode 100644 internal/tui3/quit_release_test.go diff --git a/cmd/codeaf/chatv3_quit_test.go b/cmd/codeaf/chatv3_quit_test.go new file mode 100644 index 0000000000..1d62866507 --- /dev/null +++ b/cmd/codeaf/chatv3_quit_test.go @@ -0,0 +1,86 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/filelock" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/relayserve" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/tui3" +) + +// A clean quit requires the shared terminal exit to leave an anchor answering fresh +// reads before the terminal's personal member has stopped. +func TestClusterSurfaceQuitHandsBackBeforeStoppingMember(t *testing.T) { + celltest.Available(t) + root, err := os.MkdirTemp("", "l2lfix-surface-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(root) }) + t.Setenv("CODEAF_HOME", root) + _, err = identity.Ensure(home.Dir()) + if err != nil { + t.Fatal(err) + } + svc := relayserve.New(relayserve.Config{Store: t.TempDir()}) + defer svc.Close() + server := httptest.NewServer(svc.Handler) + defer server.Close() + t.Setenv(syncsetup.URLVar, server.URL) + previous := runSurfaceProgram + t.Cleanup(func() { runSurfaceProgram = previous }) + var leader uint64 + runSurfaceProgram = func(ctx context.Context, options tui3.Options) error { + s, err := syncOf() + if err != nil { + return err + } + deadline := time.Now().Add(20 * time.Second) + for time.Now().Before(deadline) { + sum := sha256.Sum256([]byte(s.Ledger + "/" + s.Device.ID())) + lock, err := os.Open(filepath.Join(s.Home, "cluster-member", hex.EncodeToString(sum[:]), "lock")) + owned := false + if err == nil { + owned = filelock.IsBusy(filelock.Lock(lock, true, true)) + _ = lock.Close() + } + if owned { + receipt, err := s.LeadCluster(ctx, 0) + if err == nil { + leader = receipt.Node + if options.WaitForRelease == nil || !options.WaitForRelease() { + t.Fatal("coordinating quit did not join closing releases") + } + return nil + } + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("personal member did not lead while the surface was open") + return nil + } + if err := runSurface(context.Background(), tui3.Options{ProfileDir: root}); err != nil { + t.Fatal(err) + } + s, err := syncOf() + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + status, err := s.ClusterStatus(ctx) + if err != nil || status.Leader == leader || status.Leader == 0 || status.Leader > 3 { + t.Fatalf("quit returned before an anchor could coordinate: %+v %v", status, err) + } +} diff --git a/cmd/codeaf/chatv3_surface.go b/cmd/codeaf/chatv3_surface.go index 92e753e730..81c1c70e0a 100644 --- a/cmd/codeaf/chatv3_surface.go +++ b/cmd/codeaf/chatv3_surface.go @@ -62,6 +62,10 @@ func runSurface(ctx context.Context, options tui3.Options) error { options.CellsUnavailable = cell.UnavailableReason() if options.CellsUnavailable == "" { wireSync(&options) + options.WaitForRelease = func() bool { + s, err := syncOf() + return err == nil && s.Coordinating() + } stateCtx, stopState := context.WithCancel(ctx) stateDone := make(chan struct{}) guard.Go("sync/home-state", func() { @@ -72,7 +76,14 @@ func runSurface(ctx context.Context, options tui3.Options) error { }) copiesDone := make(chan struct{}) guard.Go("sync/older-chats", func() { defer close(copiesDone); runOlderChats(stateCtx) }) - defer func() { stopState(); <-stateDone; <-copiesDone }() + defer func() { + if s, err := syncOf(); err == nil { + _ = s.HandBack(ctx) + } + stopState() + <-stateDone + <-copiesDone + }() options.Pairing = chatPairDoor{} options.Approvals = newApprovalsDoor(home.Dir()) } diff --git a/docs/relay.md b/docs/relay.md index 772908b623..c09c6f3422 100644 --- a/docs/relay.md +++ b/docs/relay.md @@ -57,7 +57,10 @@ term, revision and read barrier. A pending request is not success; retrying lead the saved signed request. Keep codeaf open: without an open terminal the member runs only for that command. Leadership does not transfer chat ownership or fetch content. -When that personal leader sleeps, exits or turns sync off, the two remaining anchors +On a clean terminal quit, that personal leader releases its idle chats and returns +coordination to an anchor before stopping. Returning coordination adds at most five +seconds (`syncsetup.LeaderQuitWithin`); unavailable anchors retain ordinary fallback. +When that personal leader sleeps, crashes, loses its connection or turns sync off, the two remaining anchors can elect a leader and restore the third anchor. Healthy fallback targets about 65 seconds; delays and partitions can take longer. Returning computers remain learners until explicitly asked to lead again. Losing the personal leader and either voting diff --git a/internal/cellsync/batch.go b/internal/cellsync/batch.go index 38ef2ce5f8..b1f8132f80 100644 --- a/internal/cellsync/batch.go +++ b/internal/cellsync/batch.go @@ -9,6 +9,7 @@ import ( "github.com/Agent-Field/codeaf/internal/cell" "github.com/Agent-Field/codeaf/internal/cellstore" "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/wireauth" ) const ( @@ -18,6 +19,9 @@ const ( DefaultInterval = 5 * time.Second // MaxBackoff caps the wait between flushes after failures. MaxBackoff = 60 * time.Second + // ReleaseRetryEvery absorbs a brief coordinator change inside the close + // deadline without delaying a successful release or a definitive refusal. + ReleaseRetryEvery = 50 * time.Millisecond ) // Publishing wraps the Stage 0 store: seal exactly as before, then note the @@ -374,11 +378,24 @@ func (b *Batcher) release(ctx context.Context) error { if d.Fence == 0 || stale { return nil } - err := b.Publisher.Dir.Release(ctx, d.ID(), d.Fence) - if errors.Is(err, directory.ErrFenceStale) { - return nil + for { + if err := ctx.Err(); err != nil { + return err + } + err := b.Publisher.Dir.Release(ctx, d.ID(), d.Fence) + if errors.Is(err, directory.ErrFenceStale) { + return nil + } + if !errors.Is(err, directory.ErrUnreachable) && !errors.Is(err, wireauth.ErrUnavailable) { + return err + } + if _, bounded := ctx.Deadline(); !bounded { + return err + } + if err := b.sleeper()(ctx, ReleaseRetryEvery); err != nil { + return err + } } - return err } // flush publishes the newest noted turn and says how long to rest before the diff --git a/internal/cellsync/quit_release_test.go b/internal/cellsync/quit_release_test.go new file mode 100644 index 0000000000..2ee4505949 --- /dev/null +++ b/internal/cellsync/quit_release_test.go @@ -0,0 +1,112 @@ +package cellsync + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" +) + +type quitReleaseDir struct { + directory.Client + failures []error + calls int +} + +func (d *quitReleaseDir) Release(ctx context.Context, id string, fence uint64) error { + d.calls++ + if d.calls <= len(d.failures) { + return d.failures[d.calls-1] + } + return d.Client.Release(ctx, id, fence) +} + +// A clean quit requires a temporary coordination refusal or lost reply during close +// to retry the same release, within the caller's existing close deadline. +func TestClosingReleaseRetriesTransientRefusal(t *testing.T) { + r := newRig(t) + b := r.batcher() + r.publishFirst(map[string]string{"a": "saved"}) + d := &quitReleaseDir{Client: b.Publisher.Dir, failures: []error{directory.ErrUnreachable}} + b.Publisher.Dir = d + b.Sleep = func(context.Context, time.Duration) error { return nil } + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + if err := b.Close(ctx); err != nil { + t.Fatal("transient refusal stranded a release", err) + } + if d.calls != 2 || r.head(cellID).Lease.ReleasedAt == 0 { + t.Fatal("retry did not commit the release", d.calls) + } +} + +// A temporary refusal includes a relay's plain HTTP unavailable response, even when the +// coordinator could not produce the usual signed directory error body. +func TestClosingReleaseRetriesPlainHTTPUnavailable(t *testing.T) { + r := newRig(t) + b := r.batcher() + r.publishFirst(map[string]string{"a": "saved"}) + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, q *http.Request) { + calls++ + if calls == 1 { + http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable) + return + } + if err := r.dirA.Release(q.Context(), cellID, r.drv.Fence); err != nil { + t.Error(err) + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + b.Publisher.Dir = directory.NewHTTP(server.URL, func(*http.Request, []byte) {}, server.Client()) + b.Sleep = func(context.Context, time.Duration) error { return nil } + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + if err := b.Close(ctx); err != nil { + t.Fatal("plain HTTP refusal stranded a release", err) + } + if calls != 2 || r.head(cellID).Lease.ReleasedAt == 0 { + t.Fatal("plain HTTP retry did not commit", calls) + } +} + +// A clean quit requires definitive refusals to end immediately, while repeated +// unavailability remains bounded by the closing context. +func TestClosingReleaseNeverRetriesDefinitiveRefusal(t *testing.T) { + for _, refusal := range []error{directory.ErrFenceStale, directory.ErrRevoked} { + t.Run(refusal.Error(), func(t *testing.T) { + r := newRig(t) + b := r.batcher() + r.publishFirst(map[string]string{"a": "saved"}) + d := &quitReleaseDir{Client: b.Publisher.Dir, failures: []error{refusal}} + b.Publisher.Dir = d + err := b.Close(t.Context()) + if d.calls != 1 || refusal == directory.ErrRevoked && !errors.Is(err, refusal) { + t.Fatal("definitive refusal retried or changed", d.calls, err) + } + }) + } +} + +// A clean quit requires a persistent temporary refusal to finish at the caller's +// deadline, without issuing another request after that deadline. +func TestClosingReleaseRespectsDeadline(t *testing.T) { + r := newRig(t) + b := r.batcher() + r.publishFirst(map[string]string{"a": "saved"}) + d := &quitReleaseDir{Client: b.Publisher.Dir, failures: []error{directory.ErrUnreachable}} + b.Publisher.Dir = d + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + ctx, deadline := context.WithTimeout(ctx, time.Second) + defer deadline() + b.Sleep = func(ctx context.Context, _ time.Duration) error { cancel(); return ctx.Err() } + if err := b.Close(ctx); !errors.Is(err, context.Canceled) || d.calls != 1 { + t.Fatal("release ignored its closing context", err, d.calls) + } +} diff --git a/internal/directory/http.go b/internal/directory/http.go index 254d8ed0bb..0f7eeea004 100644 --- a/internal/directory/http.go +++ b/internal/directory/http.go @@ -200,6 +200,13 @@ func refusal(resp *http.Response) error { if resp.StatusCode == http.StatusNotFound { return ErrTooOld } + if resp.StatusCode == http.StatusServiceUnavailable { + // Coordination can refuse before a directory handler has an error + // body. It is the same retryable refusal the coded answer names, not + // an unreachable network, so closing releases retry it and nothing + // tells a person to check a connection that works. + return fmt.Errorf("%w: %s", wireauth.ErrUnavailable, resp.Status) + } return fmt.Errorf("directory: %s", resp.Status) } 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 c937046c52..c046939990 100644 --- a/internal/manual/chat/continuing-a-chat-on-another-computer.md +++ b/internal/manual/chat/continuing-a-chat-on-another-computer.md @@ -2,7 +2,7 @@ To continue on this machine, select the chat on home and choose `continue here`. When your paired computer is offline or has let go of its latest chat, home can ask `Continue tower's latest chat here?`: `alt+c` continues immediately when there is no local choice to make, and `alt+x` dismisses the offer. Moving chats is available by default on Linux, macOS and native Windows with a compatible engine. Older chats become movable through independent copies after pairing. -## Why can't I type in this chat — my message did not send and home opened with a question +## Continue on another computer — why can't I type in this chat, my message did not send and home opened with a question Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. @@ -241,6 +241,8 @@ The original older chat is untouched. A turn typed into the original by an older A clean quit lets go after the last save is durable. Opening bare `codeaf` on your other paired computer within 24 hours continues the released chat automatically, with the usual `Moved from` banner and `not brought along:` lines. `esc` goes Home. A sleeping laptop has not let go: Home offers the chat and `alt+c` continues it, asking only if local work needs a choice. +If this computer is also coordinating, a clean terminal quit releases its idle chats and returns coordination to an anchor before stopping. With the anchors reachable, the other computer can continue immediately. Sleep, a crash or a lost connection still use the fallback described on the coordination and workers page. + Once the chat arrives, you can send your next message immediately. The window keeps following changes, including renamed computers and a later move to another computer. 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. diff --git a/internal/manual/chat/coordination-and-workers.md b/internal/manual/chat/coordination-and-workers.md index a5ff3317ed..85a57eabf4 100644 --- a/internal/manual/chat/coordination-and-workers.md +++ b/internal/manual/chat/coordination-and-workers.md @@ -45,9 +45,13 @@ Older clients may send an empty frame plan as an omitted field, `null` or `[]`. A lost reply or restarted home keeps the original signed leadership request. Retry `codeaf cluster lead` to resume it; a pending request cannot choose a different computer. A completed refusal says `the leadership request could not complete; refresh cluster status and try again` and is acknowledged before another request is saved. Only one configuration change can run at a time. A different personal coordinator cannot directly replace an active personal coordinator; let the anchors resume, then request leadership again. +## Clean quit on the leader — can another computer continue immediately? + +On the hosted relay and a self-hosted relay with storage, a clean terminal quit finishes releasing its idle chats, then hands coordination to an anchor before stopping. Another paired computer can continue those chats immediately, without waiting for an election or a lease to expire. Work that is still running keeps going and releases its chat when it finishes. Returning coordination adds at most 5 seconds to quit; a computer that is not coordinating adds no requests or wait. If the anchors cannot take over within that bound, ordinary fallback applies. + ## What happens if my leader goes away — my leading laptop went to sleep or the leader computer was turned off -On the hosted relay and a self-hosted relay with storage, when a personal coordinator sleeps, exits or turns sync off, the two remaining anchor voters can elect a coordinator and restore the third anchor after it catches up. The missing computer need not approve its removal. A returning computer catches up as a learner and does not automatically regain leadership: request `codeaf cluster lead` again. Losing a majority pauses coordination and keeps saved chats. The refusal is `this cluster cannot coordinate right now; reconnect enough of its voting computers and try again; your saved chats are still here`. +When a personal coordinator sleeps, crashes, loses its connection or turns sync off, the two remaining anchor voters can elect a coordinator and restore the third anchor after it catches up. The missing computer need not approve its removal. A returning computer catches up as a learner and does not automatically regain leadership, including after a clean quit: request `codeaf cluster lead` again. Losing a majority pauses coordination and keeps saved chats. The refusal is `this cluster cannot coordinate right now; reconnect enough of its voting computers and try again; your saved chats are still here`. Coordination ticks every 5 seconds. The provisioned anchors’ ranked election windows are 6–7 ticks (30–35 seconds), 8–9 ticks (40–45 seconds) and 10–11 ticks (50–55 seconds); other computers use 12–13 ticks (60–65 seconds). Healthy fallback targets about 65 seconds. Scheduling delays or a partition can take longer. Fully idle groups stop periodic ticks only after every voter has applied the same idle barrier; a new operation or a leading computer’s disconnection wakes coordination and needs a fresh majority response. diff --git a/internal/remote/client.go b/internal/remote/client.go index 56edc16319..c67fd6e908 100644 --- a/internal/remote/client.go +++ b/internal/remote/client.go @@ -1964,6 +1964,16 @@ func (a *Agent) Detach() error { return a.Close() } +// DetachAndWait joins an idle chat's closing release before acknowledging +// departure, so its terminal can safely stop coordinating afterwards. +func (a *Agent) DetachAndWait() error { + if !a.WorkOutlivesExit() { + return a.Detach() + } + _, err := a.c.call(nil, MethodDetach, DetachArgs{WaitRelease: true}) + return err +} + // Model is the model the next request will use. // // IT IS A MEMORY READ. The engine states this at the door and again whenever it diff --git a/internal/remote/quit_release_test.go b/internal/remote/quit_release_test.go new file mode 100644 index 0000000000..68d36752fe --- /dev/null +++ b/internal/remote/quit_release_test.go @@ -0,0 +1,87 @@ +package remote + +import ( + "testing" + "testing/synctest" + "time" +) + +// A clean quit requires the last idle chat's closing boundary to finish before the +// terminal receives its deliberate departure acknowledgment. +func TestDetachAcknowledgesOnlyAfterClosingRelease(t *testing.T) { + far := newCloseGatedAgent() + engine := &Engine{Agent: far, ReleaseOnLeave: func(WrappedAgent) bool { return true }} + client := viewOn(t, NewSession(engine, true)) + done := make(chan error, 1) + go func() { done <- client.Agent().DetachAndWait() }() + select { + case <-far.entered: + case <-done: + close(far.release) + _ = client.Close() + t.Fatal("detach acknowledged before starting the closing release") + case <-time.After(time.Second): + close(far.release) + t.Fatal("detach did not start the closing release") + } + acknowledged := false + select { + case <-done: + acknowledged = true + t.Error("detach acknowledged before the closing release finished") + default: + } + close(far.release) + if !acknowledged { + if err := <-done; err != nil { + t.Fatal(err) + } + } +} + +// A clean quit requires an ordinary window to keep its asynchronous detach even +// when its chat will release after the last connection closes. +func TestOrdinaryDetachDoesNotWaitForRelease(t *testing.T) { + far := newCloseGatedAgent() + engine := &Engine{Agent: far, ReleaseOnLeave: func(WrappedAgent) bool { return true }} + client := viewOn(t, NewSession(engine, true)) + defer close(far.release) + if err := client.Agent().Detach(); err != nil { + t.Fatal(err) + } + select { + case <-far.entered: + t.Fatal("ordinary detach waited for the closing release") + default: + } +} + +// A clean quit requires an overlapping closing boundary to join the release already +// in progress, rather than treating the early closed flag as a completed save. +func TestConcurrentCloseJoinsTheClosingRelease(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + far := newCloseGatedAgent() + sess := NewSession(&Engine{Agent: far}, true) + first, second := make(chan error, 1), make(chan error, 1) + go func() { first <- sess.Close() }() + <-far.entered + go func() { second <- sess.Close() }() + synctest.Wait() + acknowledged := false + select { + case <-second: + acknowledged = true + t.Error("overlapping close returned before its release finished") + default: + } + close(far.release) + if err := <-first; err != nil { + t.Fatal(err) + } + if !acknowledged { + if err := <-second; err != nil { + t.Fatal(err) + } + } + }) +} diff --git a/internal/remote/server.go b/internal/remote/server.go index 6aca8764dd..254edbea30 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -405,6 +405,7 @@ type Session struct { // stream finishes. A new attached window cancels that departure (C31.1). releaseOnIdle bool instance string + closeDone chan struct{} // generation counts the session swaps. Every pump remembers the generation // it was born in and goes quiet the moment it is not the current one — see @@ -530,6 +531,7 @@ func NewSession(engine *Engine, persistent bool) *Session { agent: engine.Agent, persistent: persistent, instance: rand.Text(), + closeDone: make(chan struct{}), rings: map[uint64]*ring{}, follows: map[uint64]followQueued{}, held: newHeldSet(), @@ -790,6 +792,17 @@ func (sess *Session) RetireIfIdle(olderThan time.Duration) bool { // shutDown is what ending a conversation does once the caller has won the // closed flag: every lane left, the turn interrupted, the journal flushed. func (sess *Session) shutDown(agent WrappedAgent, already bool, door session.StopDoor, name string) error { + if already { + // A concurrent final stream may already be releasing this chat. Its + // closed flag cannot acknowledge a departure before that release ends. + if sess.closeDone != nil { + <-sess.closeDone + } + return nil + } + if sess.closeDone != nil { + defer close(sess.closeDone) + } // The rails are left BEFORE the agent is, so no lane is still delivering off // a conversation that is being flushed and shut (tasklane.go), and version // 11's subscription goes the same way (standinglane.go). @@ -805,7 +818,7 @@ func (sess *Session) shutDown(agent WrappedAgent, already bool, door session.Sto // is what decides whether a stalled pinned lane is asked about or quietly // borrowed against (news.go). sess.dropNews() - if agent == nil || already { + if agent == nil { return nil } // THE DOOR IS THE ONE THIS ENDING CAME THROUGH, not a single sentence @@ -1717,6 +1730,7 @@ type server struct { // happens after the lane has drained. detached atomic.Bool goodbye atomic.Bool + left atomic.Bool // window releases the automations presence this connection holds on this // machine because its hello said it is a window ([Hello.Window]), and nil @@ -1851,6 +1865,9 @@ func (s *server) serve(in io.Reader) (err error) { // turn stops where it is and keeps its partial reply — exactly what ctrl+c // does locally — and then the agent is closed, which flushes the file. func (s *server) leave() { + if s.left.Swap(true) { + return + } sess := s.session if sess == nil { return @@ -2713,11 +2730,16 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { return nil, sess.closeLeaving() case MethodDetach: - // THE ONE METHOD WHOSE VALUE IS THE DIFFERENCE BETWEEN IT AND SILENCE. - // Nothing is interrupted and nothing is closed; the reader loop sees - // this flag on its next pass and ends the connection, leaving the turn - // to finish (wire.go's MethodDetach states the whole case). + args, err := arg[DetachArgs](call) + if err != nil { + return nil, err + } s.detached.Store(true) + if args.WaitRelease { + // A coordinating terminal must finish closing its idle chats + // before handing leadership back and stopping its member. + s.leave() + } return nil, nil case MethodHeldQuestions: diff --git a/internal/remote/wire.go b/internal/remote/wire.go index 3e2bbb5a1c..7a9881dd1a 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -846,6 +846,12 @@ func (l *LaunchShape) Same(other *LaunchShape) bool { return mine == theirs } +// DetachArgs lets a coordinating terminal join its idle chat releases before +// stopping its member. Ordinary windows retain their asynchronous departure. +type DetachArgs struct { + WaitRelease bool `json:"wait_release,omitempty"` +} + // Hello is the client's first frame ("hello"). Workspace is the path AS TYPED // after the colon — empty means the engine's own home — and the engine answers // with the path it resolved. diff --git a/internal/syncsetup/member.go b/internal/syncsetup/member.go index 82215a7eb8..e699eb7cac 100644 --- a/internal/syncsetup/member.go +++ b/internal/syncsetup/member.go @@ -16,6 +16,7 @@ import ( "os" "path/filepath" "sync" + "sync/atomic" "time" "github.com/Agent-Field/codeaf/internal/clusterctl" @@ -29,6 +30,7 @@ import ( "github.com/Agent-Field/codeaf/internal/rafttransport" "github.com/Agent-Field/codeaf/internal/reqsign" "github.com/coder/websocket" + "go.etcd.io/raft/v3" pb "go.etcd.io/raft/v3/raftpb" "google.golang.org/protobuf/proto" ) @@ -51,6 +53,8 @@ type memberRecord struct { } type personalMember struct { + // A learner's quit must never wait behind ongoing replication work. + coordinating atomic.Bool chunkWaiting map[uint64]uint64 sleeping bool outSnapshots map[uint64]*memberSnapshot @@ -283,6 +287,7 @@ func (m *personalMember) step(ctx context.Context, input raftcore.Input) error { return err } m.status = effects.Status + m.coordinating.Store(m.status.Role == raft.StateLeader) if proposal, ok := input.(raftcore.Propose); ok && effects.Persist != nil { for id, work := range m.forwardPending { if work.Action == "submit" && work.Barrier == 0 && bytes.Equal(work.Command, proposal.Command) { @@ -602,6 +607,7 @@ func (m *personalMember) start(ctx context.Context) error { } func (m *personalMember) close() { + m.coordinating.Store(false) m.withdrawDisplay() m.mu.Lock() if !m.closed { diff --git a/internal/syncsetup/member_hosted_quit_test.go b/internal/syncsetup/member_hosted_quit_test.go new file mode 100644 index 0000000000..0b022492d6 --- /dev/null +++ b/internal/syncsetup/member_hosted_quit_test.go @@ -0,0 +1,102 @@ +package syncsetup + +import ( + "slices" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" +) + +// A clean quit requires the hosted relay to commit closing releases and +// accept another home's immediate take through an anchor without election ticks. +func TestHostedLeaderCleanQuitReleasesAndHandsBack(t *testing.T) { + r := newHostedLeadRig(t) + p, ok, err := Open(machine(t, r.server.URL)) + mustNil(t, err) + if !ok { + t.Fatal("open") + } + stop := hostedOwner(t, p) + receipt := hostedPersonalLeader(t, p) + id := p.Identity + b := r.home(&id) + results := []<-chan error{} + for _, cell := range []string{"01J00000000000000000QUIT1", "01J00000000000000000QUIT2"} { + client, result := quitChatView(t, p, cell) + mustNil(t, client.Agent().DetachAndWait()) + mustNil(t, client.Close()) + results = append(results, result) + } + mustNil(t, p.HandBack(t.Context())) + stop() + for _, result := range results { + select { + case err := <-result: + mustNil(t, err) + case <-time.After(requestTimeout): + t.Fatal("hosted closing release never completed") + } + } + for _, cell := range []string{"01J00000000000000000QUIT1", "01J00000000000000000QUIT2"} { + v, err := b.Dir.Cell(t.Context(), cell) + mustNil(t, err) + if v.Cell.Lease.ReleasedAt == 0 || v.Cell.Lease.Expires != 0 { + t.Fatal("hosted release did not commit", v) + } + _, err = b.Dir.Acquire(t.Context(), cell, directory.AcquireOpts{}) + mustNil(t, err) + } + status, err := b.clusterClient().Status(t.Context()) + mustNil(t, err) + if !slices.Contains([]uint64{1, 2, 3}, status.Leader) { + t.Fatal("hosted anchor did not take over", status) + } + state := hostedFallbackTicks(t, p, 8, "") + if !slices.Equal(state.Configuration.Voters, []uint64{1, 2, 3}) || state.Configuration.Joint() || !slices.Contains(state.Configuration.Learners, receipt.Node) || state.Membership.Leadership != nil { + t.Fatal("hosted topology did not restore", state) + } + hostedOwner(t, p) + hostedReady(t, p) + status, err = p.clusterClient().Status(t.Context()) + mustNil(t, err) + if status.Leader == receipt.Node || !slices.Contains(status.Configuration.Learners, receipt.Node) { + t.Fatal("hosted return regained leadership", status) + } +} + +// A clean quit requires a hosted partition to bound quit without changing the +// ordinary fallback, and require a non-leading member to send no hand-back. +func TestHostedUnreachableAndNonLeaderQuit(t *testing.T) { + r := newHostedLeadRig(t) + down := partitionQuitPeers(r.server.Config) + p, ok, err := Open(machine(t, r.server.URL)) + mustNil(t, err) + if !ok { + t.Fatal("open") + } + stop := hostedOwner(t, p) + hostedReady(t, p) + waitMember(t, p, func(m *personalMember) bool { return m.idle }) + before := p.relayAttempts.Load() + p.clusterFrames.Load() + mustNil(t, p.HandBack(t.Context())) + if p.relayAttempts.Load()+p.clusterFrames.Load() != before { + t.Fatal("non-leading quit sent requests") + } + receipt := hostedPersonalLeader(t, p) + down.Store(true) + start := time.Now() + if err := p.HandBack(t.Context()); err == nil { + t.Fatal("hosted partition claimed a hand-back") + } + stop() + if time.Since(start) > LeaderQuitWithin+time.Second { + t.Fatal("hosted quit exceeded its bound") + } + down.Store(false) + hostedLeaderDeparted(t, p) + state := hostedFallbackTicks(t, p, 21, "") + if state.Leader == receipt.Node || !slices.Equal(state.Configuration.Voters, []uint64{1, 2, 3}) { + t.Fatal("hosted fallback failed", state) + } +} diff --git a/internal/syncsetup/member_quit.go b/internal/syncsetup/member_quit.go new file mode 100644 index 0000000000..cdd51208df --- /dev/null +++ b/internal/syncsetup/member_quit.go @@ -0,0 +1,126 @@ +package syncsetup + +import ( + "context" + "errors" + "slices" + "time" + + "github.com/Agent-Field/codeaf/internal/clusterctl" + "github.com/Agent-Field/codeaf/internal/raftcore" + "github.com/Agent-Field/codeaf/internal/raftdirectory" + "go.etcd.io/raft/v3" +) + +// LeaderQuitWithin bounds the extra time a clean terminal quit can spend +// returning coordination to the anchors. Unreachable anchors retain fallback. +const LeaderQuitWithin = 5 * time.Second + +const leaderQuitRetryEvery = 50 * time.Millisecond +const leaderQuitTransferEvery = time.Second + +// Coordinating is a local lifetime hint, never authority for a directory read +// or write. It does not wait behind a learner's ongoing replication work. +func (s *Sync) Coordinating() bool { + if !s.memberAllowed() { + return false + } + m := s.displayMember() + return m != nil && m.coordinating.Load() +} + +// HandBack runs after the terminal has let its chats go and before its state +// owner stops. A local learner or a home without a running member stays quiet. +// A command-scoped member and an unclean disconnection keep their old lifetime. +func (s *Sync) HandBack(ctx context.Context) error { + if !s.memberAllowed() { + return nil + } + m := s.displayMember() + if m == nil || !m.coordinating.Load() { + return nil + } + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), LeaderQuitWithin) + defer cancel() + ticker := time.NewTicker(leaderQuitRetryEvery) + defer ticker.Stop() + lock := func() error { + for !m.mu.TryLock() { + select { + case <-ticker.C: + case <-ctx.Done(): + return ctx.Err() + } + } + return nil + } + if err := lock(); err != nil { + return err + } + if m.closed || m.status.Role != raft.StateLeader { + m.mu.Unlock() + return nil + } + h, err := raftdirectory.LeadershipState(ctx, m.store) + term := m.status.Term + m.mu.Unlock() + if err != nil { + return err + } + var transferred time.Time + next := 0 + for { + if err := ctx.Err(); err != nil { + return err + } + if err := lock(); err != nil { + return err + } + if m.closed || m.failure != nil { + err := m.failure + m.mu.Unlock() + if err == nil { + err = clusterctl.ErrUnavailable + } + return err + } + anchor := m.status.Term > term && slices.Contains(h.Anchors, m.status.Leader) + if m.status.Role == raft.StateLeader && time.Since(transferred) >= leaderQuitTransferEvery { + // The core checks catch-up and current voting membership. Cycling + // eligible anchors lets a surviving voter take over if another is lost. + for range h.Anchors { + target := h.Anchors[next%len(h.Anchors)] + next++ + err = m.step(ctx, raftcore.Transfer{Target: target}) + if errors.Is(err, raftcore.ErrTransfer) { + continue + } + if err == nil { + m.sleeping = false + err = m.flushPeer(ctx) + transferred = time.Now() + } + break + } + } + changed := m.changed + m.mu.Unlock() + if err != nil && !errors.Is(err, raftcore.ErrTransfer) { + return err + } + if anchor { + // Observing a higher term alone does not prove the new leader can + // serve the next home. Its ordinary fresh majority read does. + status, e := s.clusterClient().Status(ctx) + if e == nil && slices.Contains(h.Anchors, status.Leader) { + return nil + } + } + select { + case <-changed: + case <-ticker.C: + case <-ctx.Done(): + return ctx.Err() + } + } +} diff --git a/internal/syncsetup/member_quit_test.go b/internal/syncsetup/member_quit_test.go new file mode 100644 index 0000000000..06bb330b0b --- /dev/null +++ b/internal/syncsetup/member_quit_test.go @@ -0,0 +1,215 @@ +package syncsetup + +import ( + "bufio" + "context" + "net" + "net/http" + "slices" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellsync" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/rafttransport" + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/session" +) + +type quitPartitionConn struct { + net.Conn + down *atomic.Bool +} + +func (c quitPartitionConn) Read(b []byte) (int, error) { + for { + n, err := c.Conn.Read(b) + if err != nil || !c.down.Load() { + return n, err + } + } +} +func (c quitPartitionConn) Write(b []byte) (int, error) { + if c.down.Load() { + return len(b), nil + } + return c.Conn.Write(b) +} + +type quitPartitionWriter struct { + http.ResponseWriter + down *atomic.Bool +} + +func (w quitPartitionWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + c, rw, err := w.ResponseWriter.(http.Hijacker).Hijack() + if err != nil { + return nil, nil, err + } + wrapped := quitPartitionConn{c, w.down} + rw.Writer.Reset(wrapped) + return wrapped, rw, nil +} + +func partitionQuitPeers(server *http.Server) *atomic.Bool { + down := &atomic.Bool{} + original := server.Handler + server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == rafttransport.SocketPath { + w = quitPartitionWriter{w, down} + } + original.ServeHTTP(w, r) + }) + return down +} + +type quitChat struct { + remote.WrappedAgent + batch *cellsync.Batcher + result chan error +} + +func (*quitChat) Model() string { return "" } +func (*quitChat) Title() string { return "" } +func (*quitChat) Usage() session.Usage { return session.Usage{} } +func (*quitChat) ContextTokens() int { return 0 } +func (*quitChat) ReasoningLevels() map[string]string { return nil } +func (*quitChat) InterruptFor(session.StopDoor) {} +func (a *quitChat) Close() error { + ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) + defer cancel() + err := a.batch.Close(ctx) + a.result <- err + return err +} + +func quitChatView(t *testing.T, s *Sync, id string) (*remote.Client, <-chan error) { + t.Helper() + v, err := s.Dir.Create(t.Context(), id, directory.CellInit{Head: strings.Repeat("1", 64)}) + mustNil(t, err) + a := &quitChat{batch: &cellsync.Batcher{Publisher: &cellsync.Publisher{Dir: s.Dir}, Driving: &cellsync.Driving{Cell: cell.Cell{ID: id}, Fence: v.Cell.Lease.Fence}}, result: make(chan error, 1)} + sess := remote.NewSession(&remote.Engine{Agent: a, ReleaseOnLeave: func(remote.WrappedAgent) bool { return true }}, true) + surface, engine := remote.Pipe() + go func() { + _ = remote.ServeAttach(engine, engine, remote.AttachOptions{Open: func(remote.Hello) (*remote.Session, error) { return sess, nil }}) + _ = engine.Close() + }() + client, err := remote.Dial(surface, "loopback", remote.Hello{Version: remote.Version}) + mustNil(t, err) + t.Cleanup(func() { _ = client.Close() }) + return client, a.result +} + +// A clean quit requires closing releases to commit before the terminal's +// coordination member leaves, with no lease expiry or election ticks. +func TestNativeLeaderCleanQuitReleasesAndHandsBack(t *testing.T) { + r := newMemberRig(t) + p := r.home(nil) + stop := hostedOwner(t, p) + receipt := personalLeader(t, r, p) + id := p.Identity + b := r.home(&id) + cells := []string{"01J00000000000000000QUIT1", "01J00000000000000000QUIT2"} + results := []<-chan error{} + for _, id := range cells { + client, result := quitChatView(t, p, id) + mustNil(t, client.Agent().DetachAndWait()) + mustNil(t, client.Close()) + results = append(results, result) + } + // The surface hands coordination back while its state owner still runs. + mustNil(t, p.HandBack(t.Context())) + stop() + for i, id := range cells { + select { + case err := <-results[i]: + mustNil(t, err) + case <-time.After(requestTimeout): + t.Fatal("closing release never completed") + } + v, err := b.Dir.Cell(t.Context(), id) + mustNil(t, err) + if v.Cell.Lease.ReleasedAt == 0 || v.Cell.Lease.Expires != 0 { + t.Fatal("release did not commit", v) + } + _, err = b.Dir.Acquire(t.Context(), id, directory.AcquireOpts{}) + mustNil(t, err) + } + status, err := b.clusterClient().Status(t.Context()) + mustNil(t, err) + if !slices.Contains([]uint64{1, 2, 3}, status.Leader) { + t.Fatal("no anchor immediately coordinates", status) + } + for tick := 0; tick < 8; tick++ { + r.clock.Advance(DefaultInterval) + } + status, err = b.clusterClient().Status(t.Context()) + mustNil(t, err) + if !slices.Equal(status.Configuration.Voters, []uint64{1, 2, 3}) || status.Configuration.Joint() || !slices.Contains(status.Configuration.Learners, receipt.Node) || status.Membership.Leadership != nil { + t.Fatal("anchors did not restore their topology", status) + } + readyMember(t, p) + status, err = p.clusterClient().Status(t.Context()) + mustNil(t, err) + if status.Leader == receipt.Node || !slices.Contains(status.Configuration.Learners, receipt.Node) { + t.Fatal("returning member regained leadership", status) + } +} + +// A clean quit requires a learner or a never-started member to add no quit traffic. +func TestNativeNonLeaderAndAbsentMemberQuitIsQuiet(t *testing.T) { + for _, started := range []bool{false, true} { + t.Run(map[bool]string{false: "absent", true: "learner"}[started], func(t *testing.T) { + r := newMemberRig(t) + p := r.home(nil) + if started { + readyMember(t, p) + p.member.mu.Lock() + defer p.member.mu.Unlock() + } + before := p.relayAttempts.Load() + p.clusterFrames.Load() + done := make(chan error, 1) + go func() { done <- p.HandBack(t.Context()) }() + select { + case err := <-done: + mustNil(t, err) + case <-time.After(time.Second): + t.Fatal("non-leading quit waited for ongoing member work") + } + if p.relayAttempts.Load()+p.clusterFrames.Load() != before { + t.Fatal("quiet quit sent requests") + } + }) + } +} + +// The hand-back bounds a clean departure when an existing peer connection is lost; +// the same ordinary election clocks still restore the surviving anchors. +func TestNativeUnreachableLeaderQuitIsBounded(t *testing.T) { + r := newMemberRig(t) + down := partitionQuitPeers(r.server.Config) + p := r.home(nil) + stop := hostedOwner(t, p) + receipt := personalLeader(t, r, p) + down.Store(true) + start := time.Now() + if err := p.HandBack(t.Context()); err == nil { + t.Fatal("unreachable anchors claimed a hand-back") + } + stop() + if time.Since(start) > LeaderQuitWithin+time.Second { + t.Fatal("quit exceeded its bound") + } + down.Store(false) + for tick := 0; tick < 21; tick++ { + r.clock.Advance(DefaultInterval) + } + status, err := p.clusterClient().Status(t.Context()) + mustNil(t, err) + if status.Leader == receipt.Node || !slices.Equal(status.Configuration.Voters, []uint64{1, 2, 3}) { + t.Fatal("ordinary fallback failed", status) + } +} diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 901c749c90..b5d6727f47 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -886,6 +886,7 @@ type ( ) type app struct { + waitForRelease func() bool chatStarts map[string]bool conversationRequest uint64 conversationOpening bool @@ -3047,6 +3048,7 @@ func newApp(ctx context.Context, opts Options) *app { } a := &app{ knowledgeProfile: opts.Knowledge, + waitForRelease: opts.WaitForRelease, machines: opts.Machines, taker: opts.Takeover, pairing: opts.Pairing, diff --git a/internal/tui3/keeper.go b/internal/tui3/keeper.go index 6796a600f0..981bce5c62 100644 --- a/internal/tui3/keeper.go +++ b/internal/tui3/keeper.go @@ -1386,11 +1386,18 @@ func (a *app) leaveEverything() { delete(a.behind, key) } a.prev = nil + waitRelease := a.waitForRelease != nil && a.waitForRelease() var wg sync.WaitGroup for _, agent := range agents { wg.Add(1) go func(agent Agent) { defer wg.Done() + if waitRelease { + if hosted, ok := agent.(interface{ DetachAndWait() error }); ok { + _ = hosted.DetachAndWait() + return + } + } leaveAgent(agent) }(agent) } diff --git a/internal/tui3/quit_release_test.go b/internal/tui3/quit_release_test.go new file mode 100644 index 0000000000..dd04b20dee --- /dev/null +++ b/internal/tui3/quit_release_test.go @@ -0,0 +1,35 @@ +package tui3 + +import "testing" + +type releasingHostedAgent struct { + *hostedAgent + joined bool +} + +func (h *releasingHostedAgent) DetachAndWait() error { + h.joined = true + return h.Detach() +} + +// A clean quit requires every kept chat to join its release only while this +// terminal coordinates. Ordinary windows retain their existing departure. +func TestQuitJoinsEveryReleaseOnlyWhileCoordinating(t *testing.T) { + for _, coordinating := range []bool{false, true} { + t.Run(map[bool]string{false: "learner", true: "leader"}[coordinating], func(t *testing.T) { + front := &releasingHostedAgent{hostedAgent: &hostedAgent{fakeAgent: &fakeAgent{model: "m"}}} + behind := &releasingHostedAgent{hostedAgent: &hostedAgent{fakeAgent: &fakeAgent{model: "m"}}} + a := newTestApp(front) + a.file = "/tmp/lab/one/transcript.jsonl" + a.stirs = make(chan behindStirMsg, stirDepth) + stowAgent(t, a, behind, "/tmp/lab/two/transcript.jsonl") + a.waitForRelease = func() bool { return coordinating } + a.leaveEverything() + for _, agent := range []*releasingHostedAgent{front, behind} { + if agent.joined != coordinating || agent.detaches != 1 || agent.closes != 0 { + t.Fatal("quit used the wrong departure", agent.joined, agent.detaches, agent.closes) + } + } + }) + } +} diff --git a/internal/tui3/tui3.go b/internal/tui3/tui3.go index 52a84f29cf..e07c75968d 100644 --- a/internal/tui3/tui3.go +++ b/internal/tui3/tui3.go @@ -430,6 +430,9 @@ type CodexFlow interface { type Options struct { // Knowledge is the native vault on this machine. Hosted windows leave it nil. Knowledge *knowledge.Vault + // WaitForRelease is a local lifecycle hint. A coordinating terminal must + // join its idle chat releases before its coordination member can stop. + WaitForRelease func() bool // CellsUnavailable is resolved before the surface starts, so paint never // probes an engine and unsupported capabilities have no live doors. CellsUnavailable string diff --git a/relay/hosted/test/raft/hosted-fallback-go.test.js b/relay/hosted/test/raft/hosted-fallback-go.test.js index f4cabeec31..9c5284fc0b 100644 --- a/relay/hosted/test/raft/hosted-fallback-go.test.js +++ b/relay/hosted/test/raft/hosted-fallback-go.test.js @@ -17,6 +17,8 @@ try{ await run(['go','test','-p=1','-timeout=15m','./internal/syncsetup','-c','-o',binary]); // Each identity proof gets a fresh isolate, retaining the shared admission cap. for(const name of [ + 'TestHostedLeaderCleanQuitReleasesAndHandsBack', + 'TestHostedUnreachableAndNonLeaderQuit', 'TestR7HostedPersonalLeaderFallbackAndNoAutomaticRegain', 'TestR7HostedPersonalLeaderProcessDeathRestoresAnchors', 'TestR8HostedPersonalLeaderAndAnchorLossNeverCreatesSecondCluster', From 055aa2704d5df4a5a7c8701384d8e0cc71da1fe3 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 12:37:29 -0400 Subject: [PATCH 03/23] Say a removed computer was removed when it asks for cluster status or lead The cluster control client read every answer other than 200, 404 and 409 as a coordination outage, including the 401 a removed device receives. So on a removed computer `codeaf cluster status` said the cluster could not coordinate and told the person to reconnect voting computers, while `codeaf devices` correctly said the device was removed. A 401 carrying the directory's revoked code is now the removed-device error, and `codeaf cluster status` and `codeaf cluster lead` explain it with the same sentence `codeaf devices` uses, from one shared definition. Every other refusal keeps its words. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/cluster.go | 4 +-- cmd/codeaf/cluster_removed_test.go | 26 +++++++++++++++ cmd/codeaf/devices.go | 10 +++++- internal/clusterctl/control.go | 10 ++++++ internal/clusterctl/removed_test.go | 33 +++++++++++++++++++ .../manual/chat/coordination-and-workers.md | 4 +++ 6 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 cmd/codeaf/cluster_removed_test.go create mode 100644 internal/clusterctl/removed_test.go diff --git a/cmd/codeaf/cluster.go b/cmd/codeaf/cluster.go index cfb461946a..6228a0e0df 100644 --- a/cmd/codeaf/cluster.go +++ b/cmd/codeaf/cluster.go @@ -51,14 +51,14 @@ func clusterCommand(ctx context.Context, c clusterDoor, verb string, out io.Writ if verb == "lead" { r, err := c.LeadCluster(ctx, 0) if err != nil { - return err + return explainRemovedDevice(err) } _, err = fmt.Fprintf(out, "computer %d is coordinating · term %d · revision %d · read barrier %d\n", r.Node, r.Term, r.Revision, r.Barrier) return err } s, err := c.ClusterStatus(ctx) if err != nil { - return err + return explainRemovedDevice(err) } if s.Leader != 0 { fmt.Fprintf(out, "coordinator %d\n", s.Leader) diff --git a/cmd/codeaf/cluster_removed_test.go b/cmd/codeaf/cluster_removed_test.go new file mode 100644 index 0000000000..837e6e3999 --- /dev/null +++ b/cmd/codeaf/cluster_removed_test.go @@ -0,0 +1,26 @@ +package main + +import ( + "bytes" + "context" + "errors" + "testing" + + "github.com/Agent-Field/codeaf/internal/directory" +) + +func TestClusterRemovedDeviceUsesDevicesSentence(t *testing.T) { + want := (chatsKind{}).explain(directory.ErrRevoked).Error() + for _, verb := range []string{"status", "lead"} { + t.Run(verb, func(t *testing.T) { + var out bytes.Buffer + err := clusterCommand(context.Background(), clusterFixture{err: directory.ErrRevoked}, verb, &out) + if err == nil || exitCodeOf(err) == 0 || !errors.Is(err, directory.ErrRevoked) { + t.Fatalf("removed command must fail with its cause: %v", err) + } + if got := plainWords(err.Error()); got != plainWords(want) || out.Len() != 0 { + t.Fatalf("command says %q, want %q; stdout=%q", got, plainWords(want), out.String()) + } + }) + } +} diff --git a/cmd/codeaf/devices.go b/cmd/codeaf/devices.go index 682e94662d..2b9b8ae188 100644 --- a/cmd/codeaf/devices.go +++ b/cmd/codeaf/devices.go @@ -407,7 +407,7 @@ func (c chatsKind) revoke(row chatsRow) (string, error) { func (c chatsKind) explain(err error) error { switch { case errors.Is(err, directory.ErrRevoked): - return toldLine{line: "this device was removed by another of your devices, so it can no longer reach your chats — run `codeaf pair` to bring it back", err: err} + return explainRemovedDevice(err) case errors.Is(err, wireauth.ErrSkew): return errors.New(chatlist.ClockOff) case errors.Is(err, directory.ErrUnreachable): @@ -417,3 +417,11 @@ func (c chatsKind) explain(err error) error { } return err } + +// explainRemovedDevice gives every command the same removal and recovery words. +func explainRemovedDevice(err error) error { + if errors.Is(err, directory.ErrRevoked) { + return toldLine{line: "this device was removed by another of your devices, so it can no longer reach your chats — run `codeaf pair` to bring it back", err: err} + } + return err +} diff --git a/internal/clusterctl/control.go b/internal/clusterctl/control.go index 6dd547dcf1..9c90a9a600 100644 --- a/internal/clusterctl/control.go +++ b/internal/clusterctl/control.go @@ -5,8 +5,10 @@ package clusterctl import ( "bytes" "context" + "encoding/json" "errors" cs "github.com/Agent-Field/codeaf/internal/clusterstate" + "github.com/Agent-Field/codeaf/internal/directory" "github.com/Agent-Field/codeaf/internal/wireauth" "io" "net/http" @@ -155,6 +157,14 @@ func (c Client) request(ctx context.Context, method string, body []byte, out any } } if res.StatusCode != 200 { + if res.StatusCode == http.StatusUnauthorized { + var refusal struct { + Err string `json:"err"` + } + if json.Unmarshal(b, &refusal) == nil && refusal.Err == "revoked" { + return directory.ErrRevoked + } + } if res.StatusCode == 404 { return ErrUnsupported } diff --git a/internal/clusterctl/removed_test.go b/internal/clusterctl/removed_test.go new file mode 100644 index 0000000000..699516e056 --- /dev/null +++ b/internal/clusterctl/removed_test.go @@ -0,0 +1,33 @@ +package clusterctl_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + ctl "github.com/Agent-Field/codeaf/internal/clusterctl" + "github.com/Agent-Field/codeaf/internal/directory" +) + +func TestStatusPreservesRemovedDeviceRefusal(t *testing.T) { + for _, code := range []string{"revoked", "unauthorized", "skew"} { + t.Run(code, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"err":"` + code + `"}`)) + })) + defer server.Close() + client := ctl.Client{Base: server.URL, Sign: func(*http.Request, []byte) {}} + _, err := client.Status(context.Background()) + want := ctl.ErrUnavailable + if code == "revoked" { + want = directory.ErrRevoked + } + if !errors.Is(err, want) { + t.Fatalf("status refusal = %v, want %v", err, want) + } + }) + } +} diff --git a/internal/manual/chat/coordination-and-workers.md b/internal/manual/chat/coordination-and-workers.md index 85a57eabf4..55c619196f 100644 --- a/internal/manual/chat/coordination-and-workers.md +++ b/internal/manual/chat/coordination-and-workers.md @@ -173,3 +173,7 @@ Watch sockets show presence; older clients still send their normal heartbeats. The relay's selected-worker API accepts an original signed command or a signed request for one chat or assignment. A delegated worker receives only its selected sealed grant. Each read commits current time, waits for a fresh majority response and checks its current scope and process number. A retained claim alone cannot authorize execution after another computer takes the chat. A worker cannot use this API to pair computers, rotate the identity or promote itself. Native and hosted watch connections carry committed receipts to clients that ask for them. Each receipt names the exact chat and ownership number, durable position and hold-until time. Older clients receive their original version and presence frames and keep ordinary heartbeats. A ping without a receipt cannot suppress those heartbeats. A released chat cannot be revived by a delayed ping. Idle presence pings do not start a recurring directory request loop. + +## Cluster status or lead after this device was removed + +On a removed computer, `codeaf cluster status` and `codeaf cluster lead` fail with the same removal and pairing guidance as `codeaf devices`. Pair that computer again with `codeaf pair` to restore access. A removed device is different from a cluster temporarily unable to coordinate. From 04175fc838aadc4285e17e46bbef8ad9dbba80fd Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 12:37:45 -0400 Subject: [PATCH 04/23] Keep continued words, count only real local work, hide empty remote chats, and keep version checks side-effect free Four small findings from a two-computer run: - Choosing continue here on the card a send raised replaced the displaced session with a fresh one and lost the words held behind the card. The arriving chat's box now receives the same draft, pasted text and attachments, unsent until the person presses enter again. - Taking a chat back into a displaced window counted the retired session's closing save of unchanged bookkeeping as unsent turns, so the move said edits were kept and Home grew a branch row for turns the relay already had. A sealed turn is now kept as a branch only when the engine finds real local work in it, the same rule continuing already uses. - Every window registers its empty new chat with the directory, and the other computer listed it as an untitled running chat. A record with no saved turn is no longer drawn on another computer's Home; it appears once its first turn is saved. Registration and ownership are unchanged. - codeaf --version, help and manual requests ran the former-folder adoption and removed old scheduler timers before parsing anything, so a version check could move a state folder and recreate the compatibility link. Those informational requests now skip both; every real start keeps them. The footer's fit-by-priority, the engine outliving its window, and /quit closing the conversation in front are the documented behaviour and are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/main.go | 11 +- cmd/codeaf/startup_maintenance.go | 27 +++++ cmd/codeaf/startup_maintenance_test.go | 28 +++++ internal/chatlist/rows.go | 3 + .../continuing-a-chat-on-another-computer.md | 4 + internal/manual/chat/home.md | 4 + .../manual/chat/running-from-the-terminal.md | 2 + internal/manual/chat/starting-codeaf.md | 5 +- internal/syncsetup/pending_seal_test.go | 2 +- internal/syncsetup/take.go | 23 +++- internal/syncsetup/take_published_test.go | 100 ++++++++++++++++++ internal/syncsetup/twohomes_test.go | 2 +- internal/tui3/empty_remote_chat_test.go | 34 ++++++ internal/tui3/homepanel_continue.go | 11 ++ internal/tui3/homepanel_machines.go | 2 +- internal/tui3/send_continue_draft_test.go | 70 ++++++++++++ 16 files changed, 313 insertions(+), 15 deletions(-) create mode 100644 cmd/codeaf/startup_maintenance.go create mode 100644 cmd/codeaf/startup_maintenance_test.go create mode 100644 internal/syncsetup/take_published_test.go create mode 100644 internal/tui3/empty_remote_chat_test.go create mode 100644 internal/tui3/send_continue_draft_test.go diff --git a/cmd/codeaf/main.go b/cmd/codeaf/main.go index d3a3f1b4c1..491c793f0b 100644 --- a/cmd/codeaf/main.go +++ b/cmd/codeaf/main.go @@ -45,11 +45,12 @@ import ( ) func main() { - home.Adopt(log.Printf) - // CODEAF SCHEDULES NOTHING ON THE MACHINE. The timers the feature that - // automations replaced installed are taken off this login on every start, - // whoever installed them (internal/automation's legacy.go). - automation.RemoveOldTimers() + if startupMaintenanceNeeded(os.Args[1:]) { + home.Adopt(log.Printf) + // CODEAF SCHEDULES NOTHING ON THE MACHINE. Real starts remove the + // timers installed by the feature that automations replaced. + automation.RemoveOldTimers() + } registerRunningCLI() os.Exit(execute()) } diff --git a/cmd/codeaf/startup_maintenance.go b/cmd/codeaf/startup_maintenance.go new file mode 100644 index 0000000000..3ec1ae2a2c --- /dev/null +++ b/cmd/codeaf/startup_maintenance.go @@ -0,0 +1,27 @@ +package main + +import "github.com/Agent-Field/codeaf/internal/delegate/builtin" + +// startupMaintenanceNeeded leaves informational requests free of home adoption +// and scheduler changes. Real starts retain the compatibility cleanup. +func startupMaintenanceNeeded(args []string) bool { + if len(args) == 0 { + return true + } + switch args[0] { + case "version", "--version", "-v", "help", "--help", "-h", "manual": + return false + } + if _, carried := builtin.Find(args[0]); carried && len(args) > 1 && args[1] == "help" { + return false + } + for _, arg := range args[1:] { + if arg == "--" { + break + } + if askedForHelp([]string{arg}) { + return false + } + } + return true +} diff --git a/cmd/codeaf/startup_maintenance_test.go b/cmd/codeaf/startup_maintenance_test.go new file mode 100644 index 0000000000..94acca53e8 --- /dev/null +++ b/cmd/codeaf/startup_maintenance_test.go @@ -0,0 +1,28 @@ +package main + +import "testing" + +func TestVersionHelpAndManualSkipAdoptionAndTimerCleanup(t *testing.T) { + for _, args := range [][]string{ + {"--version"}, {"version"}, {"-v"}, {"-h"}, {"--help"}, {"help"}, {"help", "env"}, + {"manual"}, {"manual", "starting-codeaf"}, {"manual", "how do I pair"}, + {"chat", "--help"}, {"devices", "revoke", "--help"}, {"cluster", "lead", "-h"}, + {"plan", "new", "-help"}, {"senior-dev", "help"}, {"senior-dev", "run", "--help"}, + } { + if startupMaintenanceNeeded(args) { + t.Errorf("informational invocation %q would adopt the home or remove timers", args) + } + } +} + +func TestRealStartsKeepAdoptionAndTimerCleanup(t *testing.T) { + for _, args := range [][]string{ + nil, {"chat"}, {"resume"}, {"devices"}, {"cluster", "status"}, {"cluster", "lead"}, + {"pair"}, {"engine", "--status"}, {"tick"}, {"wake"}, {"update", "--version", "v1.2.3"}, + {"do", "explain help"}, {"do", "--", "--help"}, {"patch", "--", "--help"}, + } { + if !startupMaintenanceNeeded(args) { + t.Errorf("real invocation %q skipped startup maintenance", args) + } + } +} diff --git a/internal/chatlist/rows.go b/internal/chatlist/rows.go index 9c44b533fe..3e4cbd8a62 100644 --- a/internal/chatlist/rows.go +++ b/internal/chatlist/rows.go @@ -37,6 +37,8 @@ type Row struct { // Mine says this device was the chat's last holder, so a lapsed lease is // this device's own to pick up and not another device's work gone quiet. Mine bool + // Empty marks an ownership record with no saved turn to open elsewhere. + Empty bool // Released distinguishes a real release from a revoked or offline holder. Released bool ReleasedAgo time.Duration @@ -122,6 +124,7 @@ func rowOf(id string, c directory.Cell, l directory.Listing, self string, open O Parent: c.ParentCell, Status: heldStatus(c, l, self), Mine: c.Lease.Device == self, + Empty: c.Head == "", Released: c.Lease.Expires == 0 && c.Lease.ReleasedAt > 0 && !l.Devices[c.Lease.Device].Revoked, ReleasedAgo: time.Duration(l.Now-c.Lease.ReleasedAt) * time.Millisecond, DurableAgo: time.Duration(l.Now-c.DurableAt) * time.Millisecond, 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 c046939990..8a8a5d13e1 100644 --- a/internal/manual/chat/continuing-a-chat-on-another-computer.md +++ b/internal/manual/chat/continuing-a-chat-on-another-computer.md @@ -19,6 +19,8 @@ running on tower; last saved turn 4s ago Your words stay in the box. Nothing was sent, and no branch nobody asked for was made. This card came up because you sent a message, not because you chose to continue, so `leave it there` is already chosen and leaning on enter changes nothing. Press `1`, then enter, to continue the chat on this device. +After `continue here` succeeds, those same words, pasted text and attachments are in the arrived chat's box. Press enter again when you want to send them. Choosing `leave it there` or a move that fails also keeps the whole draft. + An attended window keeps following your other devices while the chat is open, so a move is shown without returning to home first. Slash commands such as `/help` always work. Before showing this question for a send, codeaf checks which computer holds the chat now. An older listing alone cannot raise it. If the check cannot reach your other devices and this window already knows the chat moved away, your words stay in the box and nothing is sent. Continuing a chat this computer already holds keeps its open conversation and your typed words. @@ -153,6 +155,8 @@ Reopening an older copy does not replace newer history on your other devices, ev Locally saved turns that never reached your other devices are kept as a branch before the newer copy replaces them, including turns already sealed on disk. Unsaved workspace edits are sealed and kept too. The move says `your unsaved edits here were kept as turns from `. Open that branch as a separate chat or discard it; merging is unavailable. +Turns already shared do not become kept edits. A closing or startup save that only refreshes unchanged bookkeeping creates no extra branch or kept-turns row. + Press enter on the kept turns' row. The card offers `1 open separately`, `2 discard`, and `3 leave them`, with `leave them` selected. Choose a number, then enter. `open separately` opens a chat with its own conversation id, the kept history and the files as those turns left them, in its own copy on this computer. The main chat's history and files stay as they are. Discard archives the kept turns; it does not delete them. ## The chat is arriving on this computer — why it waits to open diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 5944781e34..24575d539f 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -2716,3 +2716,7 @@ from a previous visit cannot replace the current visit's selection or draft. After a clean quit lets a paired computer's chat go, bare `codeaf` on another paired computer continues that chat automatically within 24 hours. You arrive with the `Moved from` banner and the usual `not brought along:` facts; `esc` goes Home. If the laptop only slept or lost its connection, Home offers its latest chat and `alt+c` continues immediately. When local unsaved edits need to be kept as a branch, the continuation card asks first. Nothing moves automatically while the other computer is running that chat, for a named chat or `--host`/`--at`/`--session`, when another local window holds a chat, or when local unsaved edits need a choice. If the move fails, Home stays open and says why. + +## Empty chats on another computer — untitled rows before the first saved turn + +An empty new chat on another computer stays off Home until it has a saved turn. Opening a window there does not add an `untitled` row here. Once that chat has its first saved turn, it appears with the other chats you can continue. diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index d95bbb8bd1..cfaea2e719 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -618,6 +618,8 @@ other call. ## What codeaf --help prints — the six groups, and where the environment table went +Version requests (`version`, `--version`, `-v`), help requests and `codeaf manual` do not move a state folder, create a compatibility link or remove old scheduled timers. Those startup steps wait for a real command that uses codeaf. + `codeaf help`, `--help` and `-h` all print the same thing: every command under six headings, in this order — **Talk to it**, **Hand it work**, **Hand it a whole task**, **Look at what happened**, **Housekeeping** and **Plan work by hand** — then five worked diff --git a/internal/manual/chat/starting-codeaf.md b/internal/manual/chat/starting-codeaf.md index 0e74910f2e..e513e6970a 100644 --- a/internal/manual/chat/starting-codeaf.md +++ b/internal/manual/chat/starting-codeaf.md @@ -38,12 +38,13 @@ name that appeared in one early wordmark and never shipped a release. Three things you already have keep working, so nothing on your machine has to be moved by hand: -- **Your state folder.** The first time the renamed build starts it renames +- **Your state folder.** The first real start of the renamed build renames `~/.aforge` to `~/.codeaf` and tries to leave a link behind at the old path, so a `PATH` line pointing at `~/.aforge/bin`, a login item, a running daemon or a script of your own still resolves. It happens once, it never overwrites a `~/.codeaf` directory that is already there. If the move itself cannot be done, - it says so and carries on reading the old folder. + it says so and carries on reading the old folder. Asking for version, help + or the manual does not perform this adoption or create the link. - **Your environment variables.** A variable spelled `AFORGE_…` is still read wherever the `CODEAF_…` one is unset or empty — `AFORGE_HOME` still moves the state folder, for example. Set the new spelling when you next edit that file; diff --git a/internal/syncsetup/pending_seal_test.go b/internal/syncsetup/pending_seal_test.go index 99072a3412..c68ec0937f 100644 --- a/internal/syncsetup/pending_seal_test.go +++ b/internal/syncsetup/pending_seal_test.go @@ -88,7 +88,7 @@ func TestD4TakebackKeepsDeadCallsWithoutWorkspaceEdits(t *testing.T) { t.Fatal(err) } fetcher := h.b.Continuer(eng, TakeOptions{RootFor: func(string) string { return root }}) - if err := fetcher.taker(h.b.scope(h.cell.ID)).Fetch.Fetch(t.Context(), cell.Cell{ID: h.cell.ID, Root: root}, branch.Cell.Head); err != nil { + if err := fetcher.taker(t.Context(), h.b.scope(h.cell.ID)).Fetch.Fetch(t.Context(), cell.Cell{ID: h.cell.ID, Root: root}, branch.Cell.Head); err != nil { t.Fatal(err) } for path, file := range readTree(t, root) { diff --git a/internal/syncsetup/take.go b/internal/syncsetup/take.go index cf88a4e929..d9f0b6b213 100644 --- a/internal/syncsetup/take.go +++ b/internal/syncsetup/take.go @@ -77,7 +77,7 @@ func (s *Sync) Continuer(eng cellstore.Engine, opt TakeOptions) *Continuer { // taker is the Taker for chat id. It is built per takeover because the store // it fetches through is counted under the chat it serves, and so is the branch // that keeps this machine's unsent edits: both are the cost of taking this chat. -func (c *Continuer) taker(sc *Scope) handoff.Taker { +func (c *Continuer) taker(ctx context.Context, sc *Scope) handoff.Taker { s := c.sync sync := c.eng.Sync(cellstore.SyncKeys{CellKey: s.Identity.CellKey(), Dedup: s.Identity.DedupSecret()}, s.Ledger) engine := landing{Engine: sync} @@ -91,7 +91,7 @@ func (c *Continuer) taker(sc *Scope) handoff.Taker { return handoff.Taker{ Dir: s.Dir, Fetch: &cellsync.Fetcher{Engine: engine, Store: sc.Store, Dir: s.Dir, Inbox: sync.Inbox}, - Local: engineLocal{c.eng}, + Local: engineLocal{eng: c.eng, ctx: ctx}, Branch: c.branch(brancher), RootFor: c.opt.RootFor, InPlace: func(c cell.Cell) bool { return borrowsProject(c.Root) }, @@ -151,7 +151,7 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx c.retireHolder(ctx, id) sc := c.sync.scope(id) defer sc.Settle() - taker := c.taker(sc) + taker := c.taker(ctx, sc) if view.Cell.ParentCell != "" { // The branch's snapshot was sealed before it had its own id. Rebind // its journal before rebuilding the row, so two chats never share an id. @@ -304,10 +304,23 @@ func (l landing) Want(ctx context.Context, c cell.Cell, head string) ([]string, // engineLocal is the Taker's view of a copy of the chat this machine has: what // the engine says differs from the newest turn, and a seal of it. -type engineLocal struct{ eng cellstore.Engine } +type engineLocal struct { + eng cellstore.Engine + ctx context.Context +} func (l engineLocal) Unpublished(c cell.Cell, remoteHead string) (string, uint32, error) { - return cellstore.Unpublished(c, remoteHead) + head, turns, err := cellstore.Unpublished(c, remoteHead) + if err != nil || head == "" { + return head, turns, err + } + // A retired session can seal unchanged bookkeeping after losing its + // hold. Only work beyond the shared snapshot belongs in a kept branch. + work, err := l.eng.LocalWork(l.ctx, c) + if err != nil || !work { + return "", 0, err + } + return head, turns, nil } func (l engineLocal) Dirty(ctx context.Context, c cell.Cell) (bool, error) { diff --git a/internal/syncsetup/take_published_test.go b/internal/syncsetup/take_published_test.go new file mode 100644 index 0000000000..718e5905c0 --- /dev/null +++ b/internal/syncsetup/take_published_test.go @@ -0,0 +1,100 @@ +package syncsetup + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/filelock" + "github.com/Agent-Field/codeaf/internal/session" +) + +func TestTakeBackDoesNotKeepPublishedOrBookkeepingTurns(t *testing.T) { + for _, closingSave := range []bool{false, true} { + t.Run(map[bool]string{false: "published turns", true: "retired closing save"}[closingSave], func(t *testing.T) { + h := newTwoHomes(t) + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + defer cancel() + a := h.openA() + a.mustSay("first shared reply") + a.mustSay("second shared reply") + h.durable(h.cell.ID, h.cell) + first, err := h.continuerB().Take(ctx, h.cell.ID) + mustNil(t, err) + b := h.openOn(h.b, h.engB, first.Taken.Cell, workspaceOf(first.Taken.Cell.Root), nameB) + b.mustSay("reply on the other computer") + h.durable(h.cell.ID, first.Taken.Cell) + mustNil(t, b.drive.Close(ctx)) + mustNil(t, a.drive.Close(ctx)) + if closingSave { + journal, err := os.Open(transcriptOf(h.cell)) + mustNil(t, err) + defer journal.Close() + mustNil(t, filelock.Lock(journal, true, true)) + defer filelock.Unlock(journal) + done := make(chan error, 1) + go func() { + tick := time.NewTicker(time.Millisecond) + defer tick.Stop() + for { + select { + case <-ctx.Done(): + done <- ctx.Err() + return + case <-tick.C: + if _, err := os.Stat(session.TakeoverPath(h.cell.Root)); err == nil { + _, err = h.engine.Seal(ctx, h.cell, cellstore.TurnInfo{}) + _ = filelock.Unlock(journal) + done <- err + return + } + } + } + }() + t.Cleanup(func() { + cancel() + if err := <-done; err != nil { + t.Error(err) + } + }) + } + back, err := h.continuerA().Take(ctx, h.cell.ID) + mustNil(t, err) + if back.Taken.Kept != "" || back.KeptTurns != 0 { + t.Errorf("shared history was kept as a branch: %s (%d turns)", back.Taken.Kept, back.KeptTurns) + } + if branch, ok := branchRowOf(t, h.b, h.cell.ID); ok { + t.Errorf("other Home gained a spurious kept row: %+v", branch) + } + raw, err := os.ReadFile(transcriptOf(back.Taken.Cell)) + mustNil(t, err) + for _, reply := range []string{"first shared reply", "second shared reply", "reply on the other computer"} { + if !strings.Contains(string(raw), reply) { + t.Errorf("arrived chat lost %q", reply) + } + } + }) + } +} + +func TestTakeBackKeepsFilesSealedAfterDisplacement(t *testing.T) { + h := bContinued(t) + ctx := context.Background() + mustNil(t, os.WriteFile(filepath.Join(h.work, "unsent.txt"), []byte("keep this file"), 0600)) + _, err := h.engine.Seal(ctx, h.cell, cellstore.TurnInfo{}) + mustNil(t, err) + back, err := h.continuerA().Take(ctx, h.cell.ID) + mustNil(t, err) + if back.Taken.Kept == "" || back.KeptTurns != 1 { + t.Fatalf("unsent file was not kept once: %+v", back) + } + view, err := h.a.Dir.Cell(ctx, back.Taken.Kept) + mustNil(t, err) + if got := h.fetchInto(view.Cell.Head)["unsent.txt"].body; got != "keep this file" { + t.Fatalf("kept file = %q", got) + } +} diff --git a/internal/syncsetup/twohomes_test.go b/internal/syncsetup/twohomes_test.go index d01bb8a04e..e3e04af372 100644 --- a/internal/syncsetup/twohomes_test.go +++ b/internal/syncsetup/twohomes_test.go @@ -391,7 +391,7 @@ func (h *twoHomes) fetchInto(head string) map[string]entry { if err := os.MkdirAll(root, 0o700); err != nil { h.t.Fatal(err) } - if err := fetcher.taker(h.b.scope(h.cell.ID)).Fetch.Fetch(context.Background(), stage, head); err != nil { + if err := fetcher.taker(context.Background(), h.b.scope(h.cell.ID)).Fetch.Fetch(context.Background(), stage, head); err != nil { h.t.Fatalf("the head is not in the store: %v", err) } return readTree(h.t, workspaceOf(root)) diff --git a/internal/tui3/empty_remote_chat_test.go b/internal/tui3/empty_remote_chat_test.go new file mode 100644 index 0000000000..3649990eaf --- /dev/null +++ b/internal/tui3/empty_remote_chat_test.go @@ -0,0 +1,34 @@ +package tui3 + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" +) + +func TestHomeOmitsRemoteChatsUntilTheirFirstSavedTurn(t *testing.T) { + listing := directory.Listing{Now: 1000, Devices: map[string]directory.Device{"other": {Name: "tower"}}, Cells: map[string]directory.Cell{ + "empty": {Lease: directory.Lease{Device: "other", Expires: 2000}}, + "saved": {Head: "saved-head", Title: "Saved chat", DurableAt: 900, Lease: directory.Lease{Device: "other", Expires: 2000}}, + }} + rows := func() chatlist.Static { + return chatlist.Rows(listing, "here", func(s string) (string, error) { return s, nil }) + } + a := machinesHome(t, rows(), 200) + if strings.Contains(homeText(a), "untitled") { + t.Fatalf("empty remote chat appears on Home:\n%s", homeText(a)) + } + if !strings.Contains(homeText(a), "Saved chat") { + t.Fatal("saved remote chat was hidden") + } + saved := listing.Cells["empty"] + saved.Head, saved.Title, saved.DurableAt = "first-head", "First saved turn", 950 + listing.Cells["empty"] = saved + a.machines = rows() + drain(t, a, a.askMachines()) + if !strings.Contains(homeText(a), "First saved turn") { + t.Fatal("first saved turn did not reveal the chat") + } +} diff --git a/internal/tui3/homepanel_continue.go b/internal/tui3/homepanel_continue.go index 6060d613d6..583e127118 100644 --- a/internal/tui3/homepanel_continue.go +++ b/internal/tui3/homepanel_continue.go @@ -3,6 +3,7 @@ package tui3 import ( "context" "errors" + "path/filepath" "strings" "time" @@ -346,6 +347,13 @@ func (a *app) openTaken(msg homeTakenMsg) tea.Cmd { a.home.say(joinSaid(moved, kept), "") return a.askMachines() } + var draft *composerState + if a.file != "" && filepath.Base(session.DirOf(a.file)) == msg.row.Cell { + // Replacing a displaced session must carry its unsent message into + // the arriving chat, where another enter still decides whether to send. + composer := a.liveComposer() + draft = &composer + } a.homeTakenSaid = func() tea.Cmd { // A local session of this same chat was retired before replacement. // Its old handle cannot remain a selectable copy behind the new one. @@ -359,6 +367,9 @@ func (a *app) openTaken(msg homeTakenMsg) tea.Cmd { } a.note(joinSaid(moved, kept)) if a.file == line.row.Transcript { + if draft != nil { + a.putComposer(*draft) + } a.offerSetup(msg.taken.Resume) } return nil diff --git a/internal/tui3/homepanel_machines.go b/internal/tui3/homepanel_machines.go index 258a25ea7c..5db6dd9d2a 100644 --- a/internal/tui3/homepanel_machines.go +++ b/internal/tui3/homepanel_machines.go @@ -151,7 +151,7 @@ func (m machineReading) lines(in *homeGridInput, own []homeLine) []homeLine { // held here is never drawn twice; a chat listed here is drawn again only while // another machine holds it, live or gone quiet. func drawnAsRemote(row chatlist.Row, listedHere bool) bool { - if row.Status == chatlist.Here { + if row.Empty || row.Status == chatlist.Here { return false } return !listedHere || heldElsewhere(row) diff --git a/internal/tui3/send_continue_draft_test.go b/internal/tui3/send_continue_draft_test.go new file mode 100644 index 0000000000..95e3335a0c --- /dev/null +++ b/internal/tui3/send_continue_draft_test.go @@ -0,0 +1,70 @@ +package tui3 + +import ( + "errors" + "reflect" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" +) + +func TestSendContinuationKeepsTheWholeComposer(t *testing.T) { + for _, outcome := range []string{"continue", "leave", "failed", "lost race"} { + t.Run(outcome, func(t *testing.T) { + src := &continuationListing{row: leftOffRow(heldCell, "tower", chatlist.Running, time.Minute)} + taker := &fakeTaker{} + a, old := heldLab(t, src, taker) + a.closeHome() + originalFile := a.file + taker.taken = Taken{Transcript: originalFile} + if outcome == "failed" { + taker.err = errors.New("take failed") + } + if outcome == "lost race" { + taker.err = directory.ErrLeaseHeld + } + taker.on = func(string) { + if taker.err == nil { + src.change(func(row *chatlist.Row) { row.Mine, row.Status = true, chatlist.Here }) + } + } + a.input.setText(" keep these words\nexactly as typed ") + a.input.cursor = 7 + a.chips = []chip{{path: "/tmp/l2lfix-picture.png"}, {path: "/tmp/l2lfix-notes.txt", file: true}} + a.pastes = []pasteChip{{n: 1, text: "a pasted document\nwith several\nunchanged lines"}} + want := a.liveComposer() + drive(t, a, key("enter")) + if _, card := a.homeAsking(); !card { + t.Fatal("send did not raise the continuation door") + } + if outcome == "leave" { + drive(t, a, key("2"), key("enter")) + } else { + confirm(t, a) + } + got := a.liveComposer() + if !reflect.DeepEqual(got, want) { + t.Fatalf("composer after %s = %+v, want %+v", outcome, got, want) + } + if len(old.sent) != 0 { + t.Fatalf("held words were sent: %v", old.sent) + } + if outcome == "continue" { + if a.at(pageHome) || a.file != originalFile || a.agent == old { + t.Fatal("take did not reopen the arrived chat") + } + arrived := a.agent.(*switchAgent).fakeAgent + if len(arrived.sent) != 0 { + t.Fatal("take sent without a second enter") + } + a.chips = nil + drive(t, a, key("enter")) + if len(arrived.sent) != 1 { + t.Fatalf("second enter did not send: %v", arrived.sent) + } + } + }) + } +} From 216ec5cfa2befcac7b9bb12df4725b03d241d313 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 12:38:01 -0400 Subject: [PATCH 05/23] Add the change entry for the two-computer fixes The entry is numbered when the pull request exists. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/changes/unreleased/two-computer-fixes.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 docs/changes/unreleased/two-computer-fixes.md diff --git a/docs/changes/unreleased/two-computer-fixes.md b/docs/changes/unreleased/two-computer-fixes.md new file mode 100644 index 0000000000..d12fadb2d9 --- /dev/null +++ b/docs/changes/unreleased/two-computer-fixes.md @@ -0,0 +1,13 @@ +--- +kind: fixed +title: Continuing on another computer sends at once, and a leading computer's clean quit hands back +pr: 0 +surface: [chat, remote, docs] +invalidates: + - "After bare codeaf continued another computer's released chat, the first message opened Continue this chat here? and continue here released this computer's own hold. The window now follows the chat it took, a send decides that card from a fresh ownership read, and continuing a chat this computer already holds keeps it as it is." + - "When a computer leading coordination quit cleanly, its closing chat release was refused and the chat stayed held offline for about 90 seconds while the anchors elected a new leader. A clean quit now finishes those releases and hands coordination to an anchor first, adding at most 5 seconds; sleep, crashes and lost connections still use fallback." + - "On a removed computer, codeaf cluster status and codeaf cluster lead said the cluster could not coordinate. They now say this device was removed, in the same words as codeaf devices." + - "Taking a chat back into a displaced window could report already shared turns as kept edits and add a branch row. Only real local work is kept now, and an empty new chat on another computer no longer shows as an untitled row on Home." + - "codeaf --version, help and manual requests adopted the former state folder and removed old timers before doing anything else. Those informational requests now change nothing on the machine." +--- +After continue here from a send, the held words are left in the arrived chat's box for the person to send. From 7e081cb346aab59c773d750620c8108056877584 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 12:42:37 -0400 Subject: [PATCH 06/23] Keep the manual's moving and task questions reaching the pages that answer them The new paragraphs about a send's ownership check and the words kept after continue here lengthened the page's first section until "continue on another computer" no longer reached the section that says where moving works, and the clean-quit heading competed for the same words. They now have a section of their own and the clean-quit heading says what it is about. The version-request note on the help page drew "old version of the file" away from the task pages; the starting page already says that version, help and manual requests change nothing. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../chat/continuing-a-chat-on-another-computer.md | 10 +++++++--- internal/manual/chat/coordination-and-workers.md | 2 +- internal/manual/chat/running-from-the-terminal.md | 2 -- 3 files changed, 8 insertions(+), 6 deletions(-) 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 8a8a5d13e1..9300cc9e7c 100644 --- a/internal/manual/chat/continuing-a-chat-on-another-computer.md +++ b/internal/manual/chat/continuing-a-chat-on-another-computer.md @@ -19,13 +19,17 @@ running on tower; last saved turn 4s ago Your words stay in the box. Nothing was sent, and no branch nobody asked for was made. This card came up because you sent a message, not because you chose to continue, so `leave it there` is already chosen and leaning on enter changes nothing. Press `1`, then enter, to continue the chat on this device. -After `continue here` succeeds, those same words, pasted text and attachments are in the arrived chat's box. Press enter again when you want to send them. Choosing `leave it there` or a move that fails also keeps the whole draft. - An attended window keeps following your other devices while the chat is open, so a move is shown without returning to home first. Slash commands such as `/help` always work. +If you continue it, the other device's window stops taking new turns and says ` continued this chat; this window now only shows it`. The page *Home* has the rest of that card. + +## My typed message after continue here — is it kept, does it send by itself + +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. + Before showing this question for a send, codeaf checks which computer holds the chat now. An older listing alone cannot raise it. If the check cannot reach your other devices and this window already knows the chat moved away, your words stay in the box and nothing is sent. Continuing a chat this computer already holds keeps its open conversation and your typed words. -If you continue it, the other device's window stops taking new turns and says ` continued this chat; this window now only shows it`. The page *Home* has the rest of that card. +After `continue here` succeeds, those same words, pasted text and attachments are in the arrived chat's box. Press enter again when you want to send them. Choosing `leave it there` or a move that fails also keeps the whole draft. ## What does leave it there do diff --git a/internal/manual/chat/coordination-and-workers.md b/internal/manual/chat/coordination-and-workers.md index 55c619196f..27cbc73df5 100644 --- a/internal/manual/chat/coordination-and-workers.md +++ b/internal/manual/chat/coordination-and-workers.md @@ -45,7 +45,7 @@ Older clients may send an empty frame plan as an omitted field, `null` or `[]`. A lost reply or restarted home keeps the original signed leadership request. Retry `codeaf cluster lead` to resume it; a pending request cannot choose a different computer. A completed refusal says `the leadership request could not complete; refresh cluster status and try again` and is acknowledged before another request is saved. Only one configuration change can run at a time. A different personal coordinator cannot directly replace an active personal coordinator; let the anchors resume, then request leadership again. -## Clean quit on the leader — can another computer continue immediately? +## Clean quit on the leader — quitting the coordinating laptop, do the anchors take over at once On the hosted relay and a self-hosted relay with storage, a clean terminal quit finishes releasing its idle chats, then hands coordination to an anchor before stopping. Another paired computer can continue those chats immediately, without waiting for an election or a lease to expire. Work that is still running keeps going and releases its chat when it finishes. Returning coordination adds at most 5 seconds to quit; a computer that is not coordinating adds no requests or wait. If the anchors cannot take over within that bound, ordinary fallback applies. diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index cfaea2e719..d95bbb8bd1 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -618,8 +618,6 @@ other call. ## What codeaf --help prints — the six groups, and where the environment table went -Version requests (`version`, `--version`, `-v`), help requests and `codeaf manual` do not move a state folder, create a compatibility link or remove old scheduled timers. Those startup steps wait for a real command that uses codeaf. - `codeaf help`, `--help` and `-h` all print the same thing: every command under six headings, in this order — **Talk to it**, **Hand it work**, **Hand it a whole task**, **Look at what happened**, **Housekeeping** and **Plan work by hand** — then five worked From 9800960b916cf7b54172bc2db35a248ceca09e24 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 12:47:06 -0400 Subject: [PATCH 07/23] Number the two-computer change entry. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../{two-computer-fixes.md => 1967-two-computer-fixes.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/changes/unreleased/{two-computer-fixes.md => 1967-two-computer-fixes.md} (100%) diff --git a/docs/changes/unreleased/two-computer-fixes.md b/docs/changes/unreleased/1967-two-computer-fixes.md similarity index 100% rename from docs/changes/unreleased/two-computer-fixes.md rename to docs/changes/unreleased/1967-two-computer-fixes.md From 11df87425964977fa75d0f9299614bb182f8959f Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 12:55:29 -0400 Subject: [PATCH 08/23] Name the pull request in the two-computer change entry. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/changes/unreleased/1967-two-computer-fixes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changes/unreleased/1967-two-computer-fixes.md b/docs/changes/unreleased/1967-two-computer-fixes.md index d12fadb2d9..e062b9919e 100644 --- a/docs/changes/unreleased/1967-two-computer-fixes.md +++ b/docs/changes/unreleased/1967-two-computer-fixes.md @@ -1,7 +1,7 @@ --- kind: fixed title: Continuing on another computer sends at once, and a leading computer's clean quit hands back -pr: 0 +pr: 1967 surface: [chat, remote, docs] invalidates: - "After bare codeaf continued another computer's released chat, the first message opened Continue this chat here? and continue here released this computer's own hold. The window now follows the chat it took, a send decides that card from a fresh ownership read, and continuing a chat this computer already holds keeps it as it is." From 74d2d2930e6ee16f9d89b364f921385ae81cc208 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 13:52:32 -0400 Subject: [PATCH 09/23] Keep a displaced window's reply bookkeeping out of kept local work Taking a chat back into a window another computer had displaced reported "your unsaved edits here were kept as 1 turn" and left a branch row on both Homes, in both directions. A real reply appends pace, usage and call accounting after its shared save, and retiring the displaced session seals that tail. Both the take's unsaved-work check and the old drive's closing publish counted it as new work. Local work is now decided from the journal beyond the shared boundary: accounting records are not work, while conversation turns, tool results, unreadable records and workspace changes are still kept. The same decision guards the unsaved-work check and the closing publish, so neither creates a branch for what the relay already has. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/cellstore/localwork.go | 55 ++++++++- internal/cellstore/syncops.go | 13 +++ internal/cellsync/batch.go | 15 +++ .../continuing-a-chat-on-another-computer.md | 2 +- internal/syncsetup/take.go | 8 +- internal/syncsetup/take_published_test.go | 19 ---- .../syncsetup/take_retired_session_test.go | 107 ++++++++++++++++++ 7 files changed, 193 insertions(+), 26 deletions(-) create mode 100644 internal/syncsetup/take_retired_session_test.go diff --git a/internal/cellstore/localwork.go b/internal/cellstore/localwork.go index 7b9800788d..5d3d0d9047 100644 --- a/internal/cellstore/localwork.go +++ b/internal/cellstore/localwork.go @@ -1,9 +1,11 @@ package cellstore import ( + "bufio" "context" "encoding/json" "errors" + "io" "log" "os" "path" @@ -14,7 +16,7 @@ import ( // LocalWork says whether continuing a remote chat needs a local-work choice. // Startup can seal unchanged files with fresh bookkeeping, which is a local -// save but not something a person needs to keep as a branch (C31.2). Compare +// save but not something a person needs to keep as a branch. Compare // against the shared boundary as well as the current head, so an editor's work // captured by that startup save is never mistaken for mere bookkeeping. func (e Engine) LocalWork(ctx context.Context, c cell.Cell) (bool, error) { @@ -37,9 +39,8 @@ func (e Engine) LocalWork(ctx context.Context, c cell.Cell) (bool, error) { if err != nil || head == nil { return head == nil, err } - if sizeOf(rel(c, cell.TranscriptPath)) != head.Receipt.Transcript.End { - log.Printf("continuation needs a choice: conversation bytes %d, last saved %d", sizeOf(rel(c, cell.TranscriptPath)), head.Receipt.Transcript.End) - return true, nil + if work, err := transcriptWork(c, head.Receipt.Transcript.End, sizeOf(rel(c, cell.TranscriptPath))); err != nil || work { + return work, err } if head.Turn.ID != base { work, err := unsharedCalls(c, base) @@ -105,9 +106,12 @@ func unsharedCalls(c cell.Cell, base string) (bool, error) { if err != nil { return false, err } - if len(receipt.Calls) > 0 || len(receipt.ModelCalls) > 0 || receipt.Transcript.Start != receipt.Transcript.End { + if len(receipt.Calls) > 0 || len(receipt.ModelCalls) > 0 { return true, nil } + if work, err := transcriptWork(c, receipt.Transcript.Start, receipt.Transcript.End); err != nil || work { + return work, err + } if turn.Parent == base { return false, nil } @@ -115,6 +119,47 @@ func unsharedCalls(c cell.Cell, base string) (bool, error) { return true, nil } +// transcriptWork distinguishes a conversation change from the timing and +// auxiliary accounting appended after a reply's shared seal. Unknown records +// and a shortened journal remain work, so a failed reading never loses edits. +func transcriptWork(c cell.Cell, start, end int64) (bool, error) { + if start == end { + return false, nil + } + if end < start { + return true, nil + } + file, err := os.Open(rel(c, cell.TranscriptPath)) + if err != nil { + return false, err + } + defer file.Close() + scan := bufio.NewScanner(io.NewSectionReader(file, start, end-start)) + scan.Buffer(make([]byte, 4096), 8<<20) + for scan.Scan() { + var record struct { + Type string `json:"type"` + Usage struct { + Aux bool `json:"aux"` + } `json:"usage"` + Call struct { + Role string `json:"role"` + } `json:"call"` + } + if json.Unmarshal(scan.Bytes(), &record) != nil { + return true, nil + } + switch { + case record.Type == "pace": + case record.Type == "usage" && record.Usage.Aux: + case record.Type == "call" && record.Call.Role != "": + default: + return true, nil + } + } + return false, scan.Err() +} + // localPlanWork ignores only reproducible capture bookkeeping. Conversation // truth, unknown state and all workspace files still count as local work. func localPlanWork(raw []byte) (bool, bool, error) { diff --git a/internal/cellstore/syncops.go b/internal/cellstore/syncops.go index 76ed6ed28b..3175b64bf0 100644 --- a/internal/cellstore/syncops.go +++ b/internal/cellstore/syncops.go @@ -155,6 +155,9 @@ type Export struct { // each on the cell's own device-local store. type SyncEngine struct { Transport Transport + // Work distinguishes unpublished work from a displaced session's + // accounting-only seals before sync preserves an orphaned head. + Work func(context.Context, cell.Cell) (bool, error) // BeforeExport joins local records and the engine group before frames can // leave this computer. Bare independent clients can use the transport alone. BeforeExport func(context.Context, cell.Cell) error @@ -175,6 +178,15 @@ type SyncEngine struct { MaterializeWith func(context.Context, cell.Cell, Op) ([]byte, error) } +// LocalWork reports work beyond the shared boundary. Standalone sync engines +// without a local journal retain every orphan rather than assume it is empty. +func (e SyncEngine) LocalWork(ctx context.Context, c cell.Cell) (bool, error) { + if e.Work == nil { + return true, nil + } + return e.Work(ctx, c) +} + // Export implements the sync seam. func (e SyncEngine) Export(ctx context.Context, c cell.Cell, head string) (Export, error) { var out Export @@ -315,6 +327,7 @@ func (e SyncEngine) ask(ctx context.Context, c cell.Cell, op Op, out any) error // wrote. The inbox is device-local scratch beside the cell's store. func (e Engine) Sync(keys SyncKeys, ledger string) SyncEngine { return SyncEngine{ + Work: e.LocalWork, Transport: e.transport(), BeforeExport: func(ctx context.Context, c cell.Cell) error { if err := Await(ctx, c); err != nil { diff --git a/internal/cellsync/batch.go b/internal/cellsync/batch.go index b1f8132f80..f79b60cc2b 100644 --- a/internal/cellsync/batch.go +++ b/internal/cellsync/batch.go @@ -497,6 +497,21 @@ func (b *Batcher) lost(ctx context.Context, seen Driving) { func (b *Batcher) resolve(ctx context.Context) error { head, n := b.newest() by := b.holder(ctx) + if local, ok := b.Publisher.Engine.(interface { + LocalWork(context.Context, cell.Cell) (bool, error) + }); ok && n > 0 { + driving, _, _ := b.view() + work, err := local.LocalWork(ctx, driving.Cell) + if err != nil { + return err + } + if !work { + // Retirement can seal late accounting after the shared reply. + // Forget that pending save without creating a kept-work branch. + b.durable(head) + n = 0 + } + } if n == 0 { b.superseded(Superseded{By: by}) return nil 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 9300cc9e7c..d687fd67c9 100644 --- a/internal/manual/chat/continuing-a-chat-on-another-computer.md +++ b/internal/manual/chat/continuing-a-chat-on-another-computer.md @@ -159,7 +159,7 @@ Reopening an older copy does not replace newer history on your other devices, ev Locally saved turns that never reached your other devices are kept as a branch before the newer copy replaces them, including turns already sealed on disk. Unsaved workspace edits are sealed and kept too. The move says `your unsaved edits here were kept as turns from `. Open that branch as a separate chat or discard it; merging is unavailable. -Turns already shared do not become kept edits. A closing or startup save that only refreshes unchanged bookkeeping creates no extra branch or kept-turns row. +Turns already shared do not become kept edits. A closing or startup save that only refreshes bookkeeping creates no extra branch or kept-turns row. This includes timing and background accounting written after a shared reply, even when the older window is retired during the move. Press enter on the kept turns' row. The card offers `1 open separately`, `2 discard`, and `3 leave them`, with `leave them` selected. Choose a number, then enter. `open separately` opens a chat with its own conversation id, the kept history and the files as those turns left them, in its own copy on this computer. The main chat's history and files stay as they are. Discard archives the kept turns; it does not delete them. diff --git a/internal/syncsetup/take.go b/internal/syncsetup/take.go index d9f0b6b213..0ac85197ab 100644 --- a/internal/syncsetup/take.go +++ b/internal/syncsetup/take.go @@ -324,7 +324,13 @@ func (l engineLocal) Unpublished(c cell.Cell, remoteHead string) (string, uint32 } func (l engineLocal) Dirty(ctx context.Context, c cell.Cell) (bool, error) { - return l.eng.Dirty(ctx, c) + dirty, err := l.eng.Dirty(ctx, c) + if err != nil || !dirty { + return dirty, err + } + // A displaced session can append accounting as it retires. The same + // shared-work check applies whether that tail has been sealed yet or not. + return l.eng.LocalWork(ctx, c) } func (l engineLocal) Follow(ctx context.Context, from, to cell.Cell) error { diff --git a/internal/syncsetup/take_published_test.go b/internal/syncsetup/take_published_test.go index 718e5905c0..aae498a044 100644 --- a/internal/syncsetup/take_published_test.go +++ b/internal/syncsetup/take_published_test.go @@ -3,7 +3,6 @@ package syncsetup import ( "context" "os" - "path/filepath" "strings" "testing" "time" @@ -80,21 +79,3 @@ func TestTakeBackDoesNotKeepPublishedOrBookkeepingTurns(t *testing.T) { }) } } - -func TestTakeBackKeepsFilesSealedAfterDisplacement(t *testing.T) { - h := bContinued(t) - ctx := context.Background() - mustNil(t, os.WriteFile(filepath.Join(h.work, "unsent.txt"), []byte("keep this file"), 0600)) - _, err := h.engine.Seal(ctx, h.cell, cellstore.TurnInfo{}) - mustNil(t, err) - back, err := h.continuerA().Take(ctx, h.cell.ID) - mustNil(t, err) - if back.Taken.Kept == "" || back.KeptTurns != 1 { - t.Fatalf("unsent file was not kept once: %+v", back) - } - view, err := h.a.Dir.Cell(ctx, back.Taken.Kept) - mustNil(t, err) - if got := h.fetchInto(view.Cell.Head)["unsent.txt"].body; got != "keep this file" { - t.Fatalf("kept file = %q", got) - } -} diff --git a/internal/syncsetup/take_retired_session_test.go b/internal/syncsetup/take_retired_session_test.go new file mode 100644 index 0000000000..33a9374be3 --- /dev/null +++ b/internal/syncsetup/take_retired_session_test.go @@ -0,0 +1,107 @@ +package syncsetup + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/session" +) + +func TestTakeBackRetiresARepliedSessionWithoutKeepingSharedWork(t *testing.T) { + h := newTwoHomes(t) + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + defer cancel() + a := h.openA() + mustNil(t, os.Remove(transcriptOf(h.cell))) + model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"shared reply\"},\"finish_reason\":null}]}\n\ndata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1}}\n\ndata: [DONE]\n\n") + })) + t.Cleanup(model.Close) + agent, err := session.New(session.Config{ + Workspace: h.work, Model: "stub/chat", APIKey: "stub-key", BaseURL: model.URL, + SessionFile: transcriptOf(h.cell), Place: session.Place{Dir: h.cell.Root, Workspace: h.work}, Seat: a.seat, + }) + mustNil(t, err) + t.Cleanup(func() { _ = agent.Close() }) + events, err := agent.Submit(ctx, "Remember the saved word.") + mustNil(t, err) + for event := range events { + if event.Kind == session.EventError { + t.Fatalf("scripted reply failed: %s", event.Text) + } + } + h.durable(h.cell.ID, h.cell) + before, err := os.ReadFile(transcriptOf(h.cell)) + mustNil(t, err) + first, err := h.continuerB().Take(ctx, h.cell.ID) + mustNil(t, err) + b := h.openOn(h.b, h.engB, first.Taken.Cell, workspaceOf(first.Taken.Cell.Root), nameB) + b.mustSay("reply after the move") + h.durable(h.cell.ID, first.Taken.Cell) + mustNil(t, a.drive.CheckLease(ctx)) + if _, viewing := a.drive.Viewer(); !viewing { + t.Fatal("displaced session still drives the chat") + } + retired := make(chan error, 1) + go func() { + tick := time.NewTicker(time.Millisecond) + defer tick.Stop() + for { + select { + case <-ctx.Done(): + retired <- ctx.Err() + return + case <-tick.C: + if agent.TakeoverAsked() { + agent.InterruptFor(session.StopByTakeover) + retired <- agent.Close() + return + } + } + } + }() + back, err := h.continuerA().Take(ctx, h.cell.ID) + mustNil(t, err) + mustNil(t, <-retired) + if session.InUse(transcriptOf(h.cell)) { + t.Error("retired session still holds the journal") + } + if back.Taken.Kept != "" || back.KeptTurns != 0 { + t.Errorf("shared work became a kept branch: %s (%d turns); shared journal bytes=%d", back.Taken.Kept, back.KeptTurns, len(before)) + } + if branch, ok := branchRowOf(t, h.b, h.cell.ID); ok { + t.Errorf("another computer lists a branch for shared work: %+v", branch) + } + raw, err := os.ReadFile(transcriptOf(back.Taken.Cell)) + mustNil(t, err) + if !strings.Contains(string(raw), "reply after the move") { + t.Fatal("arrived chat lost the newer reply") + } +} + +func TestTakeBackKeepsFilesSealedAfterDisplacement(t *testing.T) { + h := bContinued(t) + ctx := context.Background() + mustNil(t, os.WriteFile(filepath.Join(h.work, "unsent.txt"), []byte("keep this file"), 0600)) + _, err := h.engine.Seal(ctx, h.cell, cellstore.TurnInfo{}) + mustNil(t, err) + back, err := h.continuerA().Take(ctx, h.cell.ID) + mustNil(t, err) + if back.Taken.Kept == "" || back.KeptTurns != 1 { + t.Fatalf("unsent file was not kept once: %+v", back) + } + view, err := h.a.Dir.Cell(ctx, back.Taken.Kept) + mustNil(t, err) + if got := h.fetchInto(view.Cell.Head)["unsent.txt"].body; got != "keep this file" { + t.Fatalf("kept file = %q", got) + } +} From 390cb96edb72e8953bb1f08a18d2ea5a294cdfed Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 13:52:32 -0400 Subject: [PATCH 10/23] Carry the words typed in a displaced window into the chat it continues A send in a displaced window raises Home with the continuation card. The typed words then live in the conversation's stashed composer, not in the input Home shows, and retiring the displaced session can clear them before the moved chat opens. The moved chat opened with an empty box. The conversation's composer is now taken when the confirmed take starts, carried with the take's answer and restored into the arrived chat, even when its transcript or session differs from the displaced one. Nothing is sent until the person presses enter. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/manual/chat/keys.md | 4 +++ internal/tui3/homepanel_continue.go | 25 ++++++++------- internal/tui3/send_continue_draft_test.go | 39 +++++++++++++++++++++++ 3 files changed, 56 insertions(+), 12 deletions(-) diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index ed2faa0ecb..d00abc447d 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -1171,6 +1171,10 @@ that has moved on. There is nothing to press; it is automatic. - CR and CRLF in a restored draft are normalised to LF. - No draft is kept at all if codeaf was started without a draft file. +## Continue here keeps my typed message, pasted text and attachments + +If sending from an older window opens `Continue this chat here?`, choosing `continue here` puts your exact draft in the arrived chat's message box, including spaces, newlines, pasted text and attachments. This also works when the chat reopens in a different folder. It stays unsent until you press enter again. Choosing `leave it there`, or a move that fails, keeps the draft too. + ## What a crash does to your draft — what is saved, what comes back, what it will not send Everything the box is holding is written to one record per conversation, and the plain diff --git a/internal/tui3/homepanel_continue.go b/internal/tui3/homepanel_continue.go index 583e127118..1ae53c116f 100644 --- a/internal/tui3/homepanel_continue.go +++ b/internal/tui3/homepanel_continue.go @@ -250,6 +250,7 @@ type homeTakenMsg struct { row chatlist.Row taken Taken err error + draft *composerState } // takeRemote asks the taker, off the update loop: a takeover fetches and waits @@ -257,13 +258,20 @@ type homeTakenMsg struct { func (a *app) takeRemote(row chatlist.Row) tea.Cmd { a.home.say(continuing, "") taker, ctx := a.taker, a.ctx + var draft *composerState + if a.file != "" && filepath.Base(session.DirOf(a.file)) == row.Cell { + // Retirement can replace the displaced session while the move is in + // flight. Its unsent message belongs to the chat that is arriving. + composer := a.mainComposer() + draft = &composer + } return func() tea.Msg { if conditional, ok := taker.(conditionalTaker); ok && row.DeviceID != "" { taken, err := conditional.TakeSeen(ctx, row.Cell, &directory.LeaseExpectation{Device: row.DeviceID, Fence: row.Fence}) - return homeTakenMsg{row: row, taken: taken, err: err} + return homeTakenMsg{row: row, taken: taken, err: err, draft: draft} } taken, err := taker.Take(ctx, row.Cell) - return homeTakenMsg{row: row, taken: taken, err: err} + return homeTakenMsg{row: row, taken: taken, err: err, draft: draft} } } @@ -347,13 +355,6 @@ func (a *app) openTaken(msg homeTakenMsg) tea.Cmd { a.home.say(joinSaid(moved, kept), "") return a.askMachines() } - var draft *composerState - if a.file != "" && filepath.Base(session.DirOf(a.file)) == msg.row.Cell { - // Replacing a displaced session must carry its unsent message into - // the arriving chat, where another enter still decides whether to send. - composer := a.liveComposer() - draft = &composer - } a.homeTakenSaid = func() tea.Cmd { // A local session of this same chat was retired before replacement. // Its old handle cannot remain a selectable copy behind the new one. @@ -366,10 +367,10 @@ func (a *app) openTaken(msg homeTakenMsg) tea.Cmd { a.forget(key) } a.note(joinSaid(moved, kept)) + if msg.draft != nil { + a.putComposer(*msg.draft) + } if a.file == line.row.Transcript { - if draft != nil { - a.putComposer(*draft) - } a.offerSetup(msg.taken.Resume) } return nil diff --git a/internal/tui3/send_continue_draft_test.go b/internal/tui3/send_continue_draft_test.go index 95e3335a0c..ccc8a7203b 100644 --- a/internal/tui3/send_continue_draft_test.go +++ b/internal/tui3/send_continue_draft_test.go @@ -2,12 +2,14 @@ package tui3 import ( "errors" + "path/filepath" "reflect" "testing" "time" "github.com/Agent-Field/codeaf/internal/chatlist" "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/session" ) func TestSendContinuationKeepsTheWholeComposer(t *testing.T) { @@ -68,3 +70,40 @@ func TestSendContinuationKeepsTheWholeComposer(t *testing.T) { }) } } + +func TestSendContinuationCarriesTheDraftAcrossSessionRetirement(t *testing.T) { + src := &continuationListing{row: leftOffRow(heldCell, "other", chatlist.Running, time.Minute)} + taker := &fakeTaker{} + a, old := heldLab(t, src, taker) + a.closeHome() + arrivedFile := filepath.Join(t.TempDir(), heldCell, ".cell", "transcript.jsonl") + arrived := &fakeAgent{model: "m"} + a.open = func(workspace, file string) (Conversation, error) { + return Conversation{Agent: arrived, Workspace: workspace, SessionFile: file}, nil + } + taker.taken = Taken{Transcript: arrivedFile} + a.input.setText(" keep every word " + pasteToken(1, 3) + "\nincluding this line ") + a.input.cursor = 9 + a.chips = []chip{{path: "/tmp/picture.png"}, {path: "/tmp/notes.txt", file: true}} + a.pastes = []pasteChip{{n: 1, text: "pasted\ndocument\nbody"}} + want := a.liveComposer() + drive(t, a, key("enter")) + if _, card := a.homeAsking(); !card { + t.Fatal("send did not raise the continuation card") + } + taker.on = func(string) { + // The engine releases its session before the arriving journal replaces it. + spend(t, a, a.taskEvent(session.Event{Kind: session.EventTakeover, Text: session.TakeoverWord})) + src.change(func(row *chatlist.Row) { row.Mine, row.Status = true, chatlist.Here }) + } + confirm(t, a) + if a.file != arrivedFile || a.agent != arrived || a.at(pageHome) { + t.Fatalf("continuation did not open the arrived chat: file=%q", a.file) + } + if got := a.mainComposer(); !reflect.DeepEqual(got, want) { + t.Errorf("arrived composer = %+v, want %+v", got, want) + } + if len(old.sent) != 0 || len(arrived.sent) != 0 { + t.Fatal("continuation sent the draft without another enter") + } +} From a60bd5f9036df5fecdd97e566c5e284b128bfd8b Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 13:52:32 -0400 Subject: [PATCH 11/23] Publish a chat to other computers only after its first conversation turn A window's launch chat saves a bookkeeping head at start, and that head was published, so the other computer listed the never-used chat as "untitled running on ". A directory record always has a head, so the earlier filter on an empty head could never match. The drive keeps saving startup bookkeeping locally but notes a head for publication only once its saved journal holds a conversation message, bounded to that save, so text typed later cannot publish an earlier empty snapshot. A never-used chat therefore never reaches another computer's Home; it appears with its first turn and keeps ownership, quit and continue as before. A keyless end-to-end run covers the displaced take-back in both directions and both Homes. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/chatlist/rows.go | 3 - .../e2e/displaced_continuation_e2e_test.go | 250 ++++++++++++++++++ internal/manual/chat/home.md | 2 +- internal/session/peek.go | 23 +- internal/syncsetup/drive.go | 35 ++- internal/syncsetup/drive_empty_chat_test.go | 65 +++++ internal/tui3/empty_remote_chat_test.go | 34 --- internal/tui3/homepanel_machines.go | 2 +- 8 files changed, 366 insertions(+), 48 deletions(-) create mode 100644 internal/e2e/displaced_continuation_e2e_test.go create mode 100644 internal/syncsetup/drive_empty_chat_test.go delete mode 100644 internal/tui3/empty_remote_chat_test.go diff --git a/internal/chatlist/rows.go b/internal/chatlist/rows.go index 3e4cbd8a62..9c44b533fe 100644 --- a/internal/chatlist/rows.go +++ b/internal/chatlist/rows.go @@ -37,8 +37,6 @@ type Row struct { // Mine says this device was the chat's last holder, so a lapsed lease is // this device's own to pick up and not another device's work gone quiet. Mine bool - // Empty marks an ownership record with no saved turn to open elsewhere. - Empty bool // Released distinguishes a real release from a revoked or offline holder. Released bool ReleasedAgo time.Duration @@ -124,7 +122,6 @@ func rowOf(id string, c directory.Cell, l directory.Listing, self string, open O Parent: c.ParentCell, Status: heldStatus(c, l, self), Mine: c.Lease.Device == self, - Empty: c.Head == "", Released: c.Lease.Expires == 0 && c.Lease.ReleasedAt > 0 && !l.Devices[c.Lease.Device].Revoked, ReleasedAgo: time.Duration(l.Now-c.Lease.ReleasedAt) * time.Millisecond, DurableAgo: time.Duration(l.Now-c.DurableAt) * time.Millisecond, diff --git a/internal/e2e/displaced_continuation_e2e_test.go b/internal/e2e/displaced_continuation_e2e_test.go new file mode 100644 index 0000000000..ae868e812b --- /dev/null +++ b/internal/e2e/displaced_continuation_e2e_test.go @@ -0,0 +1,250 @@ +//go:build e2e + +package e2e + +import ( + "context" + "encoding/json" + "fmt" + "io" + "io/fs" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "syscall" + "testing" + "time" + + "github.com/charmbracelet/x/ansi" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +func TestDisplacedWindowsContinueWithoutBranchesOrLostDrafts(t *testing.T) { + root, err := os.MkdirTemp("", "l2lfix-displaced-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(root) }) + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + const first = "Remember the mango code word." + const second = "Recall the mango code word." + const draftA = "Add 2 mangoes to the saved count." + const draftB = "Add 3 mangoes to the saved count." + var requests atomic.Int32 + brain := &automationBrain{} + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/models", brain.serveCatalog) + mux.HandleFunc("/api/v1/chat/completions", func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + reply := "MANGO_REMEMBERED" + if strings.Contains(string(raw), second) { + reply = "MANGO_RECALLED" + } + if strings.Contains(string(raw), draftA) { + reply = "MANGO_ADDED" + } + if strings.Contains(string(raw), draftA) || strings.Contains(string(raw), draftB) { + requests.Add(1) + } + send, ok := automationStream(w) + if !ok { + return + } + send(fmt.Sprintf(`{"content":%q}`, reply), "") + send(`{}`, "stop") + send(`{}`, "[DONE]") + }) + model := httptest.NewServer(mux) + t.Cleanup(model.Close) + relay := quitContinuationRelay(t, root) + t.Setenv(syncsetup.URLVar, relay) + homes := []string{filepath.Join(root, "first"), filepath.Join(root, "second")} + names := []string{"first", "second"} + work := []string{filepath.Join(root, "work-first"), filepath.Join(root, "work-second")} + for i, home := range homes { + if err := os.MkdirAll(home, 0700); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(work[i], 0700); err != nil { + t.Fatal(err) + } + rows := map[string]any{"api_key": "stub-key", "model.talk": automationStubModel, "memory.enabled": false, "tools.approvalMode": "allow", "setup_seen_at": time.Now().UTC().Format(time.RFC3339), config.KeyIcons: config.IconsPlain} + for _, key := range []string{config.KeyTaskModel, config.KeyTierWorkerModel, config.KeyTierLowModel, config.KeyTierHighModel, config.KeyTierReflexModel, config.KeyTierMastermindModel} { + rows[key] = automationStubModel + } + raw, _ := json.Marshal(rows) + if err := os.WriteFile(filepath.Join(home, "config.json"), raw, 0600); err != nil { + t.Fatal(err) + } + } + id, err := identity.Ensure(homes[0]) + if err != nil { + t.Fatal(err) + } + if _, err := identity.Adopt(homes[1], id, false); err != nil { + t.Fatal(err) + } + var syncs []*syncsetup.Sync + for i, home := range homes { + if _, err := devname.Set(home, names[i]); err != nil { + t.Fatal(err) + } + s, ok, err := syncsetup.Open(home) + if err != nil || !ok { + t.Fatalf("paired home: %v %v", ok, err) + } + syncs = append(syncs, s) + if err := syncsetup.PutOwnDevice(t.Context(), s.Dir, s.Identity, s.Device, names[i]); err != nil { + t.Fatal(err) + } + } + env := []string{config.APIKeyEnv + "=stub-key", "CODEAF_BASE_URL=" + model.URL + "/api/v1", "CODEAF_PROFILE_DIR=", "CODEAF_SYNC_URL=" + relay, "CODEAF_SYNC_INTERVAL_MS=100", "CODEAF_TELEMETRY=off", "DO_NOT_TRACK=1", "CODEAF_NO_UPDATE_CHECK=1", "CODEAF_NO_AUTOMATIONS=1"} + t.Cleanup(func() { + pids := engineDaemonsUnder(root) + for _, pid := range pids { + _ = syscall.Kill(pid, syscall.SIGTERM) + } + deadline := time.Now().Add(10 * time.Second) + for _, pid := range pids { + for !terminalProcessExited(pid) && time.Now().Before(deadline) { + time.Sleep(20 * time.Millisecond) + } + if !terminalProcessExited(pid) { + _ = syscall.Kill(pid, syscall.SIGKILL) + } + } + }) + // Both launch chats remain unused while a separate conversation travels. + for i, home := range homes { + startWithEnv(t, env, fmt.Sprintf("unused-%d", i), home, work[i], 180, 55) + work[i] += "-chat" + if err := os.Mkdir(work[i], 0700); err != nil { + t.Fatal(err) + } + } + a := startWithEnv(t, env, "displaced-first", homes[0], work[0], 180, 55) + b := startWithEnv(t, env, "displaced-second", homes[1], work[1], 180, 55) + a.lit(first) + a.keys("Enter") + a.waitFor(35*time.Second, "MANGO_REMEMBERED", "idle") + waitDisplacedChatSaved(t, syncs[0], first) + b.lit("/home") + b.keys("Enter") + b.waitFor(35*time.Second, "running on "+names[0], "type to search or start") + selected := false + for y, line := range strings.Split(b.capture(), "\n") { + if strings.Contains(line, "running on "+names[0]) && !strings.Contains(line, "untitled") { + x := ansi.StringWidth(line[:strings.Index(line, "running on")]) + t.Logf("continuing the used chat: %s", strings.TrimSpace(line)) + b.mouseClick(x+2, y+1) + selected = true + break + } + } + if !selected { + t.Fatalf("Home did not list the used chat:\n%s", b.capture()) + } + if screen := b.waitFor(10*time.Second, "Continue this chat here?"); !strings.Contains(screen, "Continue this chat here?") { + t.FailNow() + } + b.keys("1", "Enter") + b.waitFor(35*time.Second, "Moved from "+names[0]) + b.lit(second) + b.keys("Enter") + b.waitFor(35*time.Second, "MANGO_RECALLED", "idle") + waitDisplacedChatSaved(t, syncs[1], second) + for i, turn := range []struct { + window *rig + draft string + from string + }{{a, draftA, names[1]}, {b, draftB, names[0]}} { + turn.window.waitFor(15*time.Second, "this window now only shows it") + turn.window.lit(turn.draft) + before := requests.Load() + turn.window.keys("Enter") + turn.window.waitFor(10*time.Second, "Continue this chat here?") + turn.window.keys("1", "Enter") + screen := turn.window.waitFor(35*time.Second, "Moved from "+turn.from) + if strings.Contains(screen, "were kept as") { + t.Errorf("shared work was reported as kept edits:\n%s", screen) + } + if !strings.Contains(screen, "› "+turn.draft) { + t.Errorf("arrived composer lost its unsent words:\n%s", screen) + } + if requests.Load() != before { + t.Error("continuation sent without another enter") + } + if i == 0 { + // Keep the sequence moving even on a failing draft assertion. + turn.window.keys("C-u") + turn.window.lit(turn.draft) + turn.window.keys("Enter") + turn.window.waitFor(35*time.Second, "MANGO_ADDED", "idle") + waitDisplacedChatSaved(t, syncs[0], draftA) + } + } + for _, window := range []*rig{a, b} { + window.keys("C-u") + window.lit("/home") + window.keys("Enter") + screen := window.waitFor(10*time.Second, "type to search or start") + if strings.Contains(screen, "turn from") || strings.Contains(screen, "turns from") { + t.Errorf("Home lists a branch for shared work:\n%s", screen) + } + for _, line := range strings.Split(screen, "\n") { + if strings.Contains(line, "untitled") && strings.Contains(line, "running on") { + t.Errorf("Home lists an unused chat from another computer: %s", line) + } + } + } +} + +// A reply can reach the screen before its asynchronous drive has acquired its +// final fence and published the seal. The next move waits for that boundary. +func waitDisplacedChatSaved(t *testing.T, client *syncsetup.Sync, words string) { + t.Helper() + var at cell.Cell + err := filepath.WalkDir(filepath.Join(client.Home, "v3", "projects"), func(path string, entry fs.DirEntry, err error) error { + if err != nil || entry.IsDir() || entry.Name() != "transcript.jsonl" { + return err + } + raw, err := os.ReadFile(path) + if err == nil && strings.Contains(string(raw), words) { + root := session.DirOf(path) + at = cell.Cell{ID: filepath.Base(root), Root: root} + } + return err + }) + if err != nil || at.ID == "" { + t.Fatalf("saved conversation was not found: %v", err) + } + var fence uint64 + if !waitUntil(25*time.Second, func() bool { + head, err := cellstore.Head(at) + if err != nil || head == nil { + return false + } + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + view, err := client.Dir.Cell(ctx, at.ID) + if err != nil || view.Cell.Head != head.Turn.ID || view.Cell.Lease.Device != client.Device.ID() { + return false + } + stable := fence != 0 && fence == view.Cell.Lease.Fence + fence = view.Cell.Lease.Fence + return stable + }) { + t.Fatal("reply did not become durable under its driving session") + } +} diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 24575d539f..290dd38012 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -2719,4 +2719,4 @@ If the laptop only slept or lost its connection, Home offers its latest chat and ## Empty chats on another computer — untitled rows before the first saved turn -An empty new chat on another computer stays off Home until it has a saved turn. Opening a window there does not add an `untitled` row here. Once that chat has its first saved turn, it appears with the other chats you can continue. +An empty new chat on another computer stays off Home until it has a saved turn. Opening a window there does not add an `untitled` row here, even while that window remains open and running. Its startup save alone does not make it a conversation you can continue. Once that chat has its first saved turn, it appears with the other chats you can continue. diff --git a/internal/session/peek.go b/internal/session/peek.go index d039268b16..163d66116f 100644 --- a/internal/session/peek.go +++ b/internal/session/peek.go @@ -23,6 +23,7 @@ package session import ( "bufio" "encoding/json" + "io" "os" "path/filepath" "sort" @@ -185,6 +186,19 @@ func Peek(path string) (Summary, bool) { // a schema this build has never seen — leaves sure false, and a caller that // destroys on silence must read that as "do not". func SpokeIn(path string) (spoken bool, sure bool) { + return spokeIn(path, false, -1) +} + +// ConversationIn reports whether a journal contains a conversation message. +// Startup headers and the session's own notes do not start a conversation. +// Only bytes through end belong to the saved turn; later messages cannot +// publish an earlier startup snapshot. The second result distinguishes an +// empty journal from an unreadable one. +func ConversationIn(path string, end int64) (spoken bool, sure bool) { + return spokeIn(path, true, end) +} + +func spokeIn(path string, includeAnswers bool, end int64) (spoken bool, sure bool) { file, err := os.Open(path) if err != nil { return false, os.IsNotExist(err) @@ -192,7 +206,11 @@ func SpokeIn(path string) (spoken bool, sure bool) { defer file.Close() clean := true - scanner := bufio.NewScanner(file) + var reader io.Reader = file + if end >= 0 { + reader = io.LimitReader(file, end) + } + scanner := bufio.NewScanner(reader) // The same buffer [Peek] takes, for the same reason: one enormous tool // result would otherwise end the scan early — and here an early end is the // difference between "nobody spoke" and "we stopped listening". @@ -213,7 +231,8 @@ func SpokeIn(path string) (spoken bool, sure bool) { } // A MESSAGE WITH NO WORDS IN IT IS STILL A PERSON SPEAKING, exactly as // [Peek] counts it: a picture and nothing else is an ordinary turn. - if entry.Type == "message" && entry.Role == "user" { + conversation := entry.Role == "user" || includeAnswers && entry.Role == "assistant" + if entry.Type == "message" && conversation && (!includeAnswers || !entry.Note) { return true, true } } diff --git a/internal/syncsetup/drive.go b/internal/syncsetup/drive.go index d481673614..bfdf0ff4f3 100644 --- a/internal/syncsetup/drive.go +++ b/internal/syncsetup/drive.go @@ -14,6 +14,7 @@ import ( "github.com/Agent-Field/codeaf/internal/directory" "github.com/Agent-Field/codeaf/internal/identity" "github.com/Agent-Field/codeaf/internal/keys" + "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/vaultsync" ) @@ -54,8 +55,9 @@ type Drive struct { sent string // fingerprint of the vault as last sent; touched by the flush loop and Close only - mu sync.Mutex - line string // the superseded line, empty while this device drives + mu sync.Mutex + line string // the superseded line, empty while this device drives + spoken bool } // Drive starts the drive side of c. The lease is acquired when the chat has a @@ -113,7 +115,7 @@ func (d *Drive) catchUp(drv cellsync.Driving) { if err != nil || head.Turn.ID == drv.Head || !known { return } - d.batcher.Note(head.Turn) + d.note(*head) } // CatchUp notes the newest startup seal once an asynchronous drive is ready. @@ -123,7 +125,22 @@ func (d *Drive) CatchUp() { return } if head, err := cellstore.Head(d.cell); err == nil && head != nil { - d.batcher.Note(head.Turn) + d.note(*head) + } +} + +// note publishes only chats that have conversation work. A window's startup +// head still saves its bookkeeping locally without creating an untitled chat +// on every other computer. Once used, the chat keeps its ordinary save path. +func (d *Drive) note(sealed cellstore.Sealed) { + d.mu.Lock() + if !d.spoken { + d.spoken, _ = session.ConversationIn(session.Place{Dir: d.cell.Root}.Transcript(), sealed.Receipt.Transcript.End) + } + spoken := d.spoken + d.mu.Unlock() + if spoken { + d.batcher.Note(sealed.Turn) } } @@ -417,8 +434,7 @@ func (d *Drive) Store(e cellstore.Engine) cellstore.Store { if d.batcher == nil { return e } - stamped := stamping{engine: e, device: d.sync.Device.Cert.Device, fence: d.batcher.Fence} - return &cellsync.Publishing{Inner: stamped, Batcher: d.batcher} + return stamping{engine: e, device: d.sync.Device.Cert.Device, fence: d.batcher.Fence, note: d.note} } // stamping writes the device key and the current fence into each turn it seals. @@ -426,6 +442,7 @@ type stamping struct { engine cellstore.Engine device string fence func() uint64 + note func(cellstore.Sealed) } var _ cellstore.Store = stamping{} @@ -434,7 +451,11 @@ var _ cellstore.Store = stamping{} func (s stamping) Seal(ctx context.Context, c cell.Cell, info cellstore.TurnInfo) (cellstore.Sealed, error) { e := s.engine e.Identity = cellstore.Identity{Device: s.device, Fence: s.fence()} - return e.Seal(ctx, c, info) + sealed, err := e.Seal(ctx, c, info) + if err == nil { + s.note(sealed) + } + return sealed, err } // Close ends the drive side: what is sealed is published, and the lease is diff --git a/internal/syncsetup/drive_empty_chat_test.go b/internal/syncsetup/drive_empty_chat_test.go new file mode 100644 index 0000000000..1e27282dac --- /dev/null +++ b/internal/syncsetup/drive_empty_chat_test.go @@ -0,0 +1,65 @@ +package syncsetup + +import ( + "errors" + "testing" + + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/directory" +) + +func TestUnusedChatWaitsForConversationBeforeDirectoryPublication(t *testing.T) { + r := newTwoHomes(t) + ctx := t.Context() + appendTo(t, transcriptOf(r.cell), `{"type":"session","version":1,"id":"unused"}`+"\n") + d, err := r.a.Drive(ctx, r.engine, r.cell, DriveOptions{}) + mustNil(t, err) + // The window seals a real bookkeeping head at startup and notes it again + // when its asynchronous drive becomes ready. + _, err = d.Store(r.engine).Seal(ctx, r.cell, cellstore.TurnInfo{}) + mustNil(t, err) + d.CatchUp() + mustNil(t, d.Close(ctx)) + if view, err := r.b.Dir.Cell(ctx, r.cell.ID); !errors.Is(err, directory.ErrNotFound) { + t.Errorf("unused chat has a directory record: head=%s, err=%v", view.Cell.Head, err) + } + rows, err := r.b.Rows(ctx) + mustNil(t, err) + if len(rows) != 0 { + t.Errorf("another computer lists an unused chat: %+v", rows) + } + d, err = r.a.Drive(ctx, r.engine, r.cell, DriveOptions{}) + mustNil(t, err) + appendTo(t, transcriptOf(r.cell), `{"type":"message","role":"user","content":"hello"}`+"\n") + _, err = d.Store(r.engine).Seal(ctx, r.cell, cellstore.TurnInfo{}) + mustNil(t, err) + r.durable(r.cell.ID, r.cell) + view, err := r.b.Dir.Cell(ctx, r.cell.ID) + mustNil(t, err) + if view.Cell.Head == "" || view.Cell.Lease.Device != r.a.Device.ID() { + t.Errorf("used chat was not published and held: %+v", view) + } + mustNil(t, d.Close(ctx)) + view, err = r.b.Dir.Cell(ctx, r.cell.ID) + mustNil(t, err) + if view.Cell.Lease.Expires != 0 { + t.Error("used chat did not release on close") + } +} + +func TestUnsealedFirstMessageDoesNotPublishTheStartupHead(t *testing.T) { + r := newTwoHomes(t) + ctx := t.Context() + d, err := r.a.Drive(ctx, r.engine, r.cell, DriveOptions{}) + mustNil(t, err) + _, err = r.engine.Seal(ctx, r.cell, cellstore.TurnInfo{}) + mustNil(t, err) + // The first message can arrive before the asynchronous drive catches up + // with the startup seal. That seal still has no conversation to continue. + appendTo(t, transcriptOf(r.cell), `{"type":"message","role":"user","content":"not saved yet"}`+"\n") + d.CatchUp() + mustNil(t, d.Close(ctx)) + if view, err := r.b.Dir.Cell(ctx, r.cell.ID); !errors.Is(err, directory.ErrNotFound) { + t.Errorf("startup-only head was published using an unsealed message: head=%s, err=%v", view.Cell.Head, err) + } +} diff --git a/internal/tui3/empty_remote_chat_test.go b/internal/tui3/empty_remote_chat_test.go deleted file mode 100644 index 3649990eaf..0000000000 --- a/internal/tui3/empty_remote_chat_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package tui3 - -import ( - "strings" - "testing" - - "github.com/Agent-Field/codeaf/internal/chatlist" - "github.com/Agent-Field/codeaf/internal/directory" -) - -func TestHomeOmitsRemoteChatsUntilTheirFirstSavedTurn(t *testing.T) { - listing := directory.Listing{Now: 1000, Devices: map[string]directory.Device{"other": {Name: "tower"}}, Cells: map[string]directory.Cell{ - "empty": {Lease: directory.Lease{Device: "other", Expires: 2000}}, - "saved": {Head: "saved-head", Title: "Saved chat", DurableAt: 900, Lease: directory.Lease{Device: "other", Expires: 2000}}, - }} - rows := func() chatlist.Static { - return chatlist.Rows(listing, "here", func(s string) (string, error) { return s, nil }) - } - a := machinesHome(t, rows(), 200) - if strings.Contains(homeText(a), "untitled") { - t.Fatalf("empty remote chat appears on Home:\n%s", homeText(a)) - } - if !strings.Contains(homeText(a), "Saved chat") { - t.Fatal("saved remote chat was hidden") - } - saved := listing.Cells["empty"] - saved.Head, saved.Title, saved.DurableAt = "first-head", "First saved turn", 950 - listing.Cells["empty"] = saved - a.machines = rows() - drain(t, a, a.askMachines()) - if !strings.Contains(homeText(a), "First saved turn") { - t.Fatal("first saved turn did not reveal the chat") - } -} diff --git a/internal/tui3/homepanel_machines.go b/internal/tui3/homepanel_machines.go index 5db6dd9d2a..258a25ea7c 100644 --- a/internal/tui3/homepanel_machines.go +++ b/internal/tui3/homepanel_machines.go @@ -151,7 +151,7 @@ func (m machineReading) lines(in *homeGridInput, own []homeLine) []homeLine { // held here is never drawn twice; a chat listed here is drawn again only while // another machine holds it, live or gone quiet. func drawnAsRemote(row chatlist.Row, listedHere bool) bool { - if row.Empty || row.Status == chatlist.Here { + if row.Status == chatlist.Here { return false } return !listedHere || heldElsewhere(row) From 5601d847639cb2523eb5821345b040fa04293c8a Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 13:52:32 -0400 Subject: [PATCH 12/23] Say a removed computer was removed when its cluster commands are refused `codeaf cluster status` and `codeaf cluster lead` start with a control request whose handler answers an authentication refusal as a plain 403, so the client still reported the generic coordination failure on a removed computer. When a cluster request fails without a typed cause, the command now checks through the signed directory read whether this device was removed and, if so, explains it with the `codeaf devices` sentence. An unexplained 403 is never guessed to be a removal, and other failures keep their words. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../manual/chat/coordination-and-workers.md | 2 +- internal/syncsetup/cluster.go | 27 +++++++++++---- internal/syncsetup/cluster_revoked_test.go | 34 +++++++++++++++++++ 3 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 internal/syncsetup/cluster_revoked_test.go diff --git a/internal/manual/chat/coordination-and-workers.md b/internal/manual/chat/coordination-and-workers.md index 27cbc73df5..d9b35664b0 100644 --- a/internal/manual/chat/coordination-and-workers.md +++ b/internal/manual/chat/coordination-and-workers.md @@ -176,4 +176,4 @@ Native and hosted watch connections carry committed receipts to clients that ask ## Cluster status or lead after this device was removed -On a removed computer, `codeaf cluster status` and `codeaf cluster lead` fail with the same removal and pairing guidance as `codeaf devices`. Pair that computer again with `codeaf pair` to restore access. A removed device is different from a cluster temporarily unable to coordinate. +On a removed computer, `codeaf cluster status` and `codeaf cluster lead` fail with the same removal and pairing guidance as `codeaf devices`. This applies to the default relay and to your own relay. Pair that computer again with `codeaf pair` to restore access. A removed device is different from a cluster temporarily unable to coordinate. diff --git a/internal/syncsetup/cluster.go b/internal/syncsetup/cluster.go index b7a7a70120..7b578c3b5b 100644 --- a/internal/syncsetup/cluster.go +++ b/internal/syncsetup/cluster.go @@ -6,6 +6,7 @@ import ( "errors" "github.com/Agent-Field/codeaf/internal/clusterctl" cs "github.com/Agent-Field/codeaf/internal/clusterstate" + "github.com/Agent-Field/codeaf/internal/directory" "github.com/Agent-Field/codeaf/internal/filelock" "github.com/Agent-Field/codeaf/internal/fsreplace" "github.com/Agent-Field/codeaf/internal/reqsign" @@ -19,11 +20,11 @@ func (s *Sync) clusterClient() clusterctl.Client { } func (s *Sync) ClusterStatus(ctx context.Context) (clusterctl.Status, error) { if !s.memberAllowed() { - return clusterctl.Status{}, ErrQuiet + return clusterctl.Status{}, s.clusterRefusal(ctx, ErrQuiet) } status, err := s.clusterClient().Status(ctx) if err != nil { - return status, err + return status, s.clusterRefusal(ctx, err) } s.memberMu.Lock() defer s.memberMu.Unlock() @@ -35,10 +36,24 @@ func (s *Sync) ClusterStatus(ctx context.Context) (clusterctl.Status, error) { return status, nil } if e != nil { - return status, e + return status, s.clusterRefusal(ctx, e) } m.close() - return s.clusterClient().Status(ctx) + status, err = s.clusterClient().Status(ctx) + return status, s.clusterRefusal(ctx, err) +} + +// clusterRefusal verifies removal through the directory's authenticated door. +// Both relays can return an untyped coordination refusal, including a plain +// forbidden response. That response alone does not establish device removal. +func (s *Sync) clusterRefusal(ctx context.Context, err error) error { + if err == nil || errors.Is(err, directory.ErrRevoked) { + return err + } + if _, refusal := s.Dir.List(ctx); errors.Is(refusal, directory.ErrRevoked) { + return refusal + } + return err } type leadershipSession struct { @@ -49,8 +64,8 @@ type leadershipSession struct { // LeadCluster saves the exact signed request before transmission. A lost reply // retries that same operation after restart, including its observed revision; // it cannot silently promote a newer target or allocate a second request. -func (s *Sync) LeadCluster(ctx context.Context, node uint64) (cs.LeadershipReceipt, error) { - var receipt cs.LeadershipReceipt +func (s *Sync) LeadCluster(ctx context.Context, node uint64) (receipt cs.LeadershipReceipt, err error) { + defer func() { err = s.clusterRefusal(ctx, err) }() if Quiet(s.Home) { return receipt, ErrQuiet } diff --git a/internal/syncsetup/cluster_revoked_test.go b/internal/syncsetup/cluster_revoked_test.go new file mode 100644 index 0000000000..a510d45c5f --- /dev/null +++ b/internal/syncsetup/cluster_revoked_test.go @@ -0,0 +1,34 @@ +package syncsetup + +import ( + "errors" + "testing" + + "github.com/Agent-Field/codeaf/internal/directory" +) + +func TestRemovedHomeClusterCommandsPreserveTheRemoval(t *testing.T) { + for _, command := range []string{"status", "lead"} { + t.Run(command, func(t *testing.T) { + r := newMemberRig(t) + a := r.home(nil) + b := r.home(&a.Identity) + ctx := t.Context() + mustNil(t, a.putDevice(ctx, "")) + mustNil(t, b.putDevice(ctx, "")) + mustNil(t, a.Dir.Revoke(ctx, b.Device.ID())) + var err error + if command == "status" { + _, err = b.ClusterStatus(ctx) + } else { + _, err = b.LeadCluster(ctx, 0) + } + if !errors.Is(err, directory.ErrRevoked) { + t.Errorf("cluster %s lost the removal: %v", command, err) + } + if _, err := b.Dir.List(ctx); !errors.Is(err, directory.ErrRevoked) { + t.Fatalf("removed home directory refusal = %v", err) + } + }) + } +} From bdfd45098ce2c4c30294210064da64e0c876276a Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 13:52:32 -0400 Subject: [PATCH 13/23] Stop a removed computer's sync traffic until it is paired again After a removal, the open window kept listing chats, sending presence and heartbeats and running state and member rounds every few seconds, each answered 401. The sync HTTP gate now remembers the removed refusal, and so do directory watches and a committed local removal. A marker bound to this device, identity and relay shares that fact across windows and restarts, and blocked requests are answered locally with the same removed refusal. State and member rounds stand down and open members close. Pairing installs a new device key, which lifts the marker, and an open Home resumes with the new device. Local chats keep working throughout. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../unreleased/1967-two-computer-fixes.md | 1 + internal/identity/device.go | 6 +- .../manual/chat/add-or-remove-a-computer.md | 6 + internal/syncsetup/member.go | 6 +- internal/syncsetup/member_display.go | 7 +- internal/syncsetup/quiet.go | 48 +++++- internal/syncsetup/removed.go | 60 ++++++++ internal/syncsetup/revoked_traffic_test.go | 142 ++++++++++++++++++ internal/syncsetup/state.go | 8 + internal/syncsetup/syncsetup.go | 12 +- 10 files changed, 280 insertions(+), 16 deletions(-) create mode 100644 internal/syncsetup/removed.go create mode 100644 internal/syncsetup/revoked_traffic_test.go diff --git a/docs/changes/unreleased/1967-two-computer-fixes.md b/docs/changes/unreleased/1967-two-computer-fixes.md index e062b9919e..fd199283c8 100644 --- a/docs/changes/unreleased/1967-two-computer-fixes.md +++ b/docs/changes/unreleased/1967-two-computer-fixes.md @@ -8,6 +8,7 @@ invalidates: - "When a computer leading coordination quit cleanly, its closing chat release was refused and the chat stayed held offline for about 90 seconds while the anchors elected a new leader. A clean quit now finishes those releases and hands coordination to an anchor first, adding at most 5 seconds; sleep, crashes and lost connections still use fallback." - "On a removed computer, codeaf cluster status and codeaf cluster lead said the cluster could not coordinate. They now say this device was removed, in the same words as codeaf devices." - "Taking a chat back into a displaced window could report already shared turns as kept edits and add a branch row. Only real local work is kept now, and an empty new chat on another computer no longer shows as an untitled row on Home." + - "A removed computer kept polling and heartbeating while its window stayed open. Once removal is known, sync now stays stopped across windows and restarts until that computer pairs again; local chats remain usable." - "codeaf --version, help and manual requests adopted the former state folder and removed old timers before doing anything else. Those informational requests now change nothing on the machine." --- After continue here from a send, the held words are left in the arrived chat's box for the person to send. diff --git a/internal/identity/device.go b/internal/identity/device.go index 3ac06408fc..6c8f2124b1 100644 --- a/internal/identity/device.go +++ b/internal/identity/device.go @@ -84,7 +84,7 @@ func Device(home string) (Dev, error) { // deviceFor returns the stored device when its cert verifies under id, else a // fresh device. A replaced identity therefore never keeps a stale device. func deviceFor(home string, id Identity) (Dev, error) { - if d, err := loadDevice(home); err == nil && d.Cert.Verify(id.PublicKey()) == nil { + if d, err := LoadDevice(home); err == nil && d.Cert.Verify(id.PublicKey()) == nil { return d, nil } d, err := newDevice(id) @@ -106,7 +106,9 @@ func (d Dev) document() deviceDocument { return deviceDocument{V: version, Seed: hex.EncodeToString(d.seed[:]), Cert: d.Cert} } -func loadDevice(home string) (Dev, error) { +// LoadDevice reads an existing device without creating an identity or keys. +// A missing device is returned as a file error, so authority checks stay reads. +func LoadDevice(home string) (Dev, error) { raw, err := os.ReadFile(filepath.Join(home, DeviceFile)) if err != nil { return Dev{}, err diff --git a/internal/manual/chat/add-or-remove-a-computer.md b/internal/manual/chat/add-or-remove-a-computer.md index 5f8ca329cb..eb7904f0df 100644 --- a/internal/manual/chat/add-or-remove-a-computer.md +++ b/internal/manual/chat/add-or-remove-a-computer.md @@ -212,3 +212,9 @@ been saved. If the reply is interrupted, retrying the same approval keeps the same decision. A different answer cannot replace a decision already made. If the link has run out, ask the new computer for a fresh one. Removing a device blocks its later requests; it cannot take back bytes the device already received. + +## Does a removed computer keep syncing while its window is open + +Once codeaf learns that this computer was removed, it stops asking the relay for chats, sending heartbeats and reconnecting its watches or coordination. That refusal is remembered across windows and restarts. Your window stays usable for local chats. + +Run `codeaf pair` to add the computer again. Pairing can still contact the relay and installs a new device key. An already open Home can then show your other computers again. diff --git a/internal/syncsetup/member.go b/internal/syncsetup/member.go index e699eb7cac..906f327a8b 100644 --- a/internal/syncsetup/member.go +++ b/internal/syncsetup/member.go @@ -94,12 +94,13 @@ func (s *Sync) memberPath() string { // memberAllowed fences delayed work as well as new dials. A re-pair can leave // the old handle paired but bound to a different identity or relay. func (s *Sync) memberAllowed() bool { - if !MayTalk(s.Home) { + if !MayTalk(s.Home) || s.isRemoved() { return false } id, err := identity.Load(s.Home) + dev, deviceErr := identity.LoadDevice(s.Home) base, e := relayBase(Resolve(s.Home).URL) - return err == nil && e == nil && id.ID() == s.Identity.ID() && base == s.Relay + return err == nil && deviceErr == nil && e == nil && id.ID() == s.Identity.ID() && dev.ID() == s.Device.ID() && base == s.Relay } func (s *Sync) enrollMember(ctx context.Context, token string) (raftdirectory.PersonalEnrollment, error) { @@ -696,6 +697,7 @@ func (s *Sync) memberRound(ctx context.Context) error { } } if err != nil { + err = s.clusterRefusal(ctx, err) if errors.Is(err, clusterctl.ErrUnsupported) { s.memberUnsupported = true } diff --git a/internal/syncsetup/member_display.go b/internal/syncsetup/member_display.go index 6c8afd7e8b..fb33c1d9fc 100644 --- a/internal/syncsetup/member_display.go +++ b/internal/syncsetup/member_display.go @@ -213,6 +213,7 @@ func (s *memberDisplayStream) refresh(ctx context.Context) (<-chan struct{}, boo return nil, false, dirwatch.ErrRotated } if listing.Devices[s.owner.Device.ID()].Revoked { + s.owner.rememberRemoved() return nil, false, dirwatch.ErrRevoked } s.project(listing) @@ -258,11 +259,15 @@ func (s *Sync) currentDisplaySync() (*Sync, error) { if err != nil { return nil, err } + dev, err := identity.LoadDevice(s.Home) + if err != nil { + return nil, err + } base, err := relayBase(Resolve(s.Home).URL) if err != nil { return nil, err } - if id.ID() == s.Identity.ID() && base == s.Relay { + if id.ID() == s.Identity.ID() && dev.ID() == s.Device.ID() && base == s.Relay { return s, nil } next, ok, err := Open(s.Home) diff --git a/internal/syncsetup/quiet.go b/internal/syncsetup/quiet.go index 978000b86d..4aea8271b8 100644 --- a/internal/syncsetup/quiet.go +++ b/internal/syncsetup/quiet.go @@ -12,8 +12,10 @@ package syncsetup // empty directory while quiet, which is what a fleet of one looks like. import ( + "bytes" "context" "errors" + "io" "net/http" "github.com/Agent-Field/codeaf/internal/directory" @@ -39,28 +41,35 @@ func Quiet(home string) bool { return !MayTalk(home) } // quietWire is the one gate: nothing leaves the machine while it is quiet. type quietWire struct { - home string - next http.RoundTripper + home string + next http.RoundTripper + owner *Sync } func (w quietWire) RoundTrip(r *http.Request) (*http.Response, error) { + if w.owner != nil && w.owner.isRemoved() { + return removedReply(r), nil + } if Quiet(w.home) { return nil, ErrQuiet } return w.next.RoundTrip(r) } -func gated(home string, c *http.Client) *http.Client { +func gated(owner *Sync, c *http.Client) *http.Client { next := c.Transport if next == nil { next = http.DefaultTransport } - c.Transport = quietWire{home: home, next: next} + c.Transport = quietWire{home: owner.Home, next: next, owner: owner} return c } // list reads the directory, or an empty one while quiet: no devices, no chats. func (s *Sync) list(ctx context.Context) (directory.Listing, error) { + if s.isRemoved() { + return directory.Listing{}, directory.ErrRevoked + } if Quiet(s.Home) { return directory.Listing{}, nil } @@ -83,7 +92,7 @@ func (s *Sync) ClusterHTTP(client *http.Client) *http.Client { next = http.DefaultTransport } copyClient.Transport = relayCountingWire{s: s, next: next} - return gated(s.Home, ©Client) + return gated(s, ©Client) } // quietStream rechecks an existing socket at every read/ping, so a later off @@ -99,13 +108,25 @@ func (s quietStream) allowed() bool { } func (s quietStream) Next(ctx context.Context) (dirwatch.Frame, error) { + if s.owner != nil && s.owner.isRemoved() { + s.Stream.Close() + return dirwatch.Frame{}, dirwatch.ErrRevoked + } if !s.allowed() { s.Stream.Close() return dirwatch.Frame{}, ErrQuiet } - return s.Stream.Next(ctx) + frame, err := s.Stream.Next(ctx) + if s.owner != nil && (errors.Is(err, dirwatch.ErrRevoked) || errors.Is(err, directory.ErrRevoked)) { + s.owner.rememberRemoved() + } + return frame, err } func (s quietStream) Ping(ctx context.Context) error { + if s.owner != nil && s.owner.isRemoved() { + s.Stream.Close() + return dirwatch.ErrRevoked + } if !s.allowed() { s.Stream.Close() return ErrQuiet @@ -123,5 +144,18 @@ type relayCountingWire struct { func (w relayCountingWire) RoundTrip(r *http.Request) (*http.Response, error) { w.s.relayAttempts.Add(1) - return w.next.RoundTrip(r) + response, err := w.next.RoundTrip(r) + if err == nil && response.StatusCode == http.StatusUnauthorized { + body, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) + response.Body = bufferedRefusal{Reader: io.MultiReader(bytes.NewReader(body), response.Body), Closer: response.Body} + if removedBody(body) { + w.s.rememberRemoved() + } + } + return response, err +} + +type bufferedRefusal struct { + io.Reader + io.Closer } diff --git a/internal/syncsetup/removed.go b/internal/syncsetup/removed.go new file mode 100644 index 0000000000..efd1051794 --- /dev/null +++ b/internal/syncsetup/removed.go @@ -0,0 +1,60 @@ +package syncsetup + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "os" + "path/filepath" + "strings" +) + +// removedPath binds the refusal to this relay, identity and device key. A +// replacement device installed by pairing has a different path; old handles +// retain their refusal even after another window pairs this home again. +func (s *Sync) removedPath() string { + key := sha256.Sum256([]byte(s.Ledger + "/" + s.Device.ID())) + return filepath.Join(s.Home, "v3", "sync", "removed", hex.EncodeToString(key[:])) +} + +func (s *Sync) isRemoved() bool { + if s.removed.Load() { + return true + } + _, err := os.Stat(s.removedPath()) + if err == nil { + s.removed.Store(true) + } + return err == nil +} + +// rememberRemoved stops this handle immediately and shares that knowledge +// with other windows and later launches. Failure to save the marker cannot +// make a live handle resume sending requests. +func (s *Sync) rememberRemoved() { + if !s.removed.Swap(true) { + if member := s.displayMember(); member != nil { + go member.owner.stopMember() + } + } + path := s.removedPath() + if os.MkdirAll(filepath.Dir(path), 0700) == nil { + _ = os.WriteFile(path, nil, 0600) + } +} + +// removedReply preserves the directory's refusal through clients that wrap +// transport errors as unreachable. It is answered locally without a request. +func removedReply(r *http.Request) *http.Response { + return &http.Response{StatusCode: http.StatusUnauthorized, Header: http.Header{"Content-Type": {"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"err":"revoked"}`)), Request: r} +} + +func removedBody(raw []byte) bool { + var body struct { + Err string `json:"err"` + } + return json.Unmarshal(raw, &body) == nil && body.Err == "revoked" +} diff --git a/internal/syncsetup/revoked_traffic_test.go b/internal/syncsetup/revoked_traffic_test.go new file mode 100644 index 0000000000..4902a797cc --- /dev/null +++ b/internal/syncsetup/revoked_traffic_test.go @@ -0,0 +1,142 @@ +package syncsetup + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/reqsign" +) + +func TestRemovedHomeStopsTrafficUntilPairedAgain(t *testing.T) { + var device atomic.Value + device.Store("") + var requests, beats atomic.Int64 + h := newTwoHomes(t, func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ := base64.RawURLEncoding.DecodeString(r.Header.Get(reqsign.HeaderCert)) + var cert identity.Cert + _ = json.Unmarshal(raw, &cert) + if cert.DeviceID() == device.Load().(string) { + requests.Add(1) + if strings.HasSuffix(r.URL.Path, "/heartbeat") { + beats.Add(1) + } + } + next.ServeHTTP(w, r) + }) + }) + h.a = openSync(t, h.homeA) + device.Store(h.a.Device.ID()) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + // The accelerated sleeper represents repeated heartbeat intervals. + options := DriveOptions{Sleep: func(ctx context.Context, _ time.Duration) error { + select { + case <-time.After(10 * time.Millisecond): + return nil + case <-ctx.Done(): + return ctx.Err() + } + }} + d, err := h.a.Drive(ctx, h.engine, h.cell, options) + mustNil(t, err) + appendTo(t, transcriptOf(h.cell), `{"type":"message","role":"user","content":"keep working locally"}`+"\n") + _, err = d.Store(h.engine).Seal(ctx, h.cell, cellstore.TurnInfo{}) + mustNil(t, err) + mustNil(t, d.Close(ctx)) + d, err = h.a.Drive(ctx, h.engine, h.cell, options) + mustNil(t, err) + defer func() { + closing, stop := context.WithTimeout(context.Background(), 5*time.Second) + defer stop() + _ = d.Close(closing) + }() + mustNil(t, h.a.memberRound(ctx)) + watch := h.a.Follow() + defer watch.Close() + h.a.Interval = 10 * time.Millisecond + stopped := make(chan struct{}) + go func() { defer close(stopped); h.a.RunState(ctx) }() + polling := make(chan struct{}) + go func() { + defer close(polling) + for ctx.Err() == nil { + _, _ = h.a.Rows(ctx) + _, _ = h.a.FleetSize(ctx) + _, _ = h.a.Roster(ctx) + _, _ = h.a.Dir.Presence(ctx) + select { + case <-time.After(10 * time.Millisecond): + case <-ctx.Done(): + } + } + }() + mustNil(t, h.b.putDevice(ctx, "")) + mustNil(t, h.b.Dir.Revoke(ctx, h.a.Device.ID())) + if _, err := h.a.Dir.List(ctx); !errors.Is(err, directory.ErrRevoked) { + t.Fatalf("removal was not learned: %v", err) + } + // Let requests already in flight finish, then sample many accelerated + // poll, state and heartbeat intervals while the window remains open. + time.Sleep(30 * time.Millisecond) + before := requests.Load() + time.Sleep(250 * time.Millisecond) + if extra := requests.Load() - before; extra != 0 { + t.Errorf("removed home sent %d further requests (heartbeats=%d)", extra, beats.Load()) + } + if err := d.Gate(); err != nil { + t.Errorf("local tool work was disabled: %v", err) + } + cancel() + <-polling + <-stopped + reopened := openSync(t, h.homeA) + before = requests.Load() + _, _ = reopened.Rows(t.Context()) + _, _ = reopened.ClusterStatus(t.Context()) + if requests.Load() != before { + t.Error("reopening forgot the known removal") + } + dev, err := identity.NewDevice(h.a.Identity) + mustNil(t, err) + mustNil(t, identity.Install(h.homeA, h.a.Identity, dev)) + paired := openSync(t, h.homeA) + mustNil(t, paired.putDevice(t.Context(), "")) + if _, err := paired.Dir.List(t.Context()); err != nil { + t.Errorf("pairing did not restore access: %v", err) + } +} + +func TestOpenRemovedHomeAdoptsItsNewlyPairedDevice(t *testing.T) { + r := newMemberRig(t) + a := r.home(nil) + b := r.home(&a.Identity) + ctx := t.Context() + mustNil(t, a.putDevice(ctx, "")) + mustNil(t, b.putDevice(ctx, "")) + mustNil(t, a.Dir.Revoke(ctx, b.Device.ID())) + if _, err := b.Dir.List(ctx); !errors.Is(err, directory.ErrRevoked) { + t.Fatalf("removal was not learned: %v", err) + } + dev, err := identity.NewDevice(b.Identity) + mustNil(t, err) + mustNil(t, identity.Install(b.Home, b.Identity, dev)) + paired := buildAt(b.Home, b.Relay, b.Identity, dev, b.Interval, b.now) + mustNil(t, paired.putDevice(ctx, "")) + if _, err := b.Rows(ctx); err != nil { + t.Fatalf("open Home did not resume after pairing: %v", err) + } + watch := b.Follow() + defer watch.Close() + waitFor(t, "the paired Home watch to connect", func() bool { return watch.State().Up }) +} diff --git a/internal/syncsetup/state.go b/internal/syncsetup/state.go index 9af240b796..dd6eb0043f 100644 --- a/internal/syncsetup/state.go +++ b/internal/syncsetup/state.go @@ -5,6 +5,7 @@ import ( "sync" "time" + "github.com/Agent-Field/codeaf/internal/directory" "github.com/Agent-Field/codeaf/internal/keys" "github.com/Agent-Field/codeaf/internal/vaultsync" ) @@ -14,6 +15,9 @@ var stateMu sync.Mutex // SyncState completes one encrypted home-state round even when no chat is // open. The quiet gate is checked before any capture or request (C30.4). func (s *Sync) SyncState(ctx context.Context) error { + if s.isRemoved() { + return directory.ErrRevoked + } if Quiet(s.Home) { return nil } @@ -87,6 +91,10 @@ func (s *Sync) stateRound(ctx context.Context) (*Sync, error) { } else { s.stopMember() } + if next.isRemoved() { + next.stopMember() + return next, directory.ErrRevoked + } next.memberRound(ctx) return next, next.SyncState(ctx) } diff --git a/internal/syncsetup/syncsetup.go b/internal/syncsetup/syncsetup.go index e6cead1e50..7e3931fbe7 100644 --- a/internal/syncsetup/syncsetup.go +++ b/internal/syncsetup/syncsetup.go @@ -53,6 +53,7 @@ var ErrNoIdentity = errors.New(chatlist.NoIdentity) // Sync is everything that talks to the relay, bound to this device. type Sync struct { + removed atomic.Bool memberRetries uint64 clock func() time.Time relayAttempts atomic.Uint64 @@ -236,11 +237,14 @@ func (s *Sync) RowsAt(ctx context.Context) ([]chatlist.Row, uint64, error) { // nil when the directory client cannot open a watch socket, which is a screen // that polls alone. func (s *Sync) Follow() dirwatch.Follower { + if current, err := s.currentDisplaySync(); err == nil && current != s { + return current.Follow() + } w, ok := s.Dir.(directory.Watcher) if !ok { return nil } - return dirwatch.Follow(s.Ledger+"#home:"+s.Home, func(ctx context.Context) (dirwatch.Stream, error) { + return dirwatch.Follow(s.Ledger+"#home:"+s.Home+":"+s.Device.ID(), func(ctx context.Context) (dirwatch.Stream, error) { if Quiet(s.Home) { return nil, ErrQuiet } @@ -260,13 +264,13 @@ func (s *Sync) Follow() dirwatch.Follower { if err != nil { return nil, err } - return quietStream{Stream: stream, home: s.Home}, nil + return quietStream{Stream: stream, home: current.Home, owner: current}, nil }) } // holdKey keeps the holding socket's feed apart from the home screen's, which // is keyed by the ledger alone: the home socket names no holds. -func (s *Sync) holdKey() string { return s.Ledger + "#holding" } +func (s *Sync) holdKey() string { return s.Ledger + "#holding:" + s.Device.ID() } // Liveness is the process's holding socket for this identity, which lets the // chats it holds stop heartbeating while committed hold receipts cover them. It answers @@ -285,7 +289,7 @@ func (s *Sync) Liveness() cellsync.Liveness { if err != nil { return nil, err } - return quietStream{Stream: stream, home: s.Home}, nil + return quietStream{Stream: stream, home: s.Home, owner: s}, nil })} } From d3324959b398c58efd1bfb2fab32bc9542ba482e Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 15:00:06 -0400 Subject: [PATCH 14/23] Read repositories without taking git's optional index lock Home's repository band ran `git status` without `--no-optional-locks`, so each refresh could create `.git/index.lock` and rewrite `.git/index`. When that landed during a move's in-place restore, the restore saw `.git` change and cancelled, and the take gave the chat back. Every git command codeaf runs on its own to read a repository now passes `--no-optional-locks`, so a background read never takes the index lock or rewrites the index. Commands that deliberately write are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf-demo-home/seed.go | 2 +- cmd/codeaf-release/promotion.go | 4 +- cmd/codeaf/chatv3_layout.go | 2 +- cmd/codeaf/chatv3_place.go | 2 +- internal/cellstore/captureplan.go | 4 +- internal/cellstore/gitwork.go | 4 +- internal/cellstore/ignored.go | 6 +- internal/gitidentity/project.go | 2 +- internal/praf/brief.go | 2 +- internal/praf/orch/resolve.go | 2 +- internal/preflight/uncommitted.go | 2 +- internal/rebuild/rule.go | 2 +- internal/secaf/diffanalysis/diffanalysis.go | 2 + internal/seniordev/app/engine_backend.go | 2 +- internal/seniordev/app/pipeline.go | 2 +- .../seniordev/app/workspace_recorder_git.go | 6 +- internal/seniordev/project/store.go | 2 +- internal/seniordev/tool/ripgrep_fallback.go | 2 +- internal/seniordev/util/gitexclude.go | 2 +- internal/seniordev/util/gitidentity.go | 2 +- internal/session/task_run.go | 2 +- internal/shell/shell.go | 2 +- internal/taskcopy/git.go | 2 +- internal/tui3/app.go | 4 +- internal/tui3/homeband_repo.go | 2 +- internal/tui3/repository_read_safety_test.go | 193 ++++++++++++++++++ 26 files changed, 227 insertions(+), 32 deletions(-) create mode 100644 internal/tui3/repository_read_safety_test.go diff --git a/cmd/codeaf-demo-home/seed.go b/cmd/codeaf-demo-home/seed.go index c45a38120a..f5a99f11c2 100644 --- a/cmd/codeaf-demo-home/seed.go +++ b/cmd/codeaf-demo-home/seed.go @@ -285,7 +285,7 @@ func initRepository(dir string, now time.Time) error { {"add", "."}, {"-c", "user.name=Demo", "-c", "user.email=demo@example.invalid", "commit", "--quiet", "-m", "the first commit"}, } { - command := exec.Command("git", append([]string{"-C", dir}, argv...)...) + command := exec.Command("git", append([]string{"--no-optional-locks", "-C", dir}, argv...)...) command.Env = append(os.Environ(), "GIT_AUTHOR_DATE="+stamp, "GIT_COMMITTER_DATE="+stamp, // A demo home must not inherit the machine's git configuration: diff --git a/cmd/codeaf-release/promotion.go b/cmd/codeaf-release/promotion.go index 39a6959899..14e7d37bbb 100644 --- a/cmd/codeaf-release/promotion.go +++ b/cmd/codeaf-release/promotion.go @@ -86,7 +86,7 @@ func runPromotionDecision(args []string, out io.Writer) error { // gitAt runs read-only git queries in the selected repository fixture or checkout. func gitAt(repo string, args ...string) (string, error) { - cmd := exec.Command("git", append([]string{"-C", repo}, args...)...) + cmd := exec.Command("git", append([]string{"--no-optional-locks", "-C", repo}, args...)...) out, err := cmd.Output() if err != nil { return "", fmt.Errorf("git %s: %w", strings.Join(args, " "), err) @@ -96,7 +96,7 @@ func gitAt(repo string, args ...string) (string, error) { // ancestor uses git's ancestry relation because a timestamp cannot prove a safe fast-forward. func ancestor(repo, older, newer string) bool { - cmd := exec.Command("git", "-C", repo, "merge-base", "--is-ancestor", older, newer) + cmd := exec.Command("git", "--no-optional-locks", "-C", repo, "merge-base", "--is-ancestor", older, newer) return cmd.Run() == nil } diff --git a/cmd/codeaf/chatv3_layout.go b/cmd/codeaf/chatv3_layout.go index df2a854b92..8c65effd40 100644 --- a/cmd/codeaf/chatv3_layout.go +++ b/cmd/codeaf/chatv3_layout.go @@ -182,7 +182,7 @@ func v3GitRoot(dir string) (string, bool) { } ctx, cancel := context.WithTimeout(context.Background(), v3GitRootTimeout) defer cancel() - command := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel") + command := exec.CommandContext(ctx, "git", "--no-optional-locks", "rev-parse", "--show-toplevel") command.Dir = dir out, err := command.Output() if err != nil { diff --git a/cmd/codeaf/chatv3_place.go b/cmd/codeaf/chatv3_place.go index a192e1cc7a..2e08cea4a7 100644 --- a/cmd/codeaf/chatv3_place.go +++ b/cmd/codeaf/chatv3_place.go @@ -120,7 +120,7 @@ func initOwnedRepository(work string) error { // an editor would hang a launch on a terminal it is about to hand to the // surface. func gitIn(dir string, args ...string) (string, error) { - command := exec.Command("git", args...) + command := exec.Command("git", append([]string{"--no-optional-locks"}, args...)...) command.Dir = dir command.Env = append(os.Environ(), "GIT_PAGER=cat", "GIT_EDITOR=true", "GIT_TERMINAL_PROMPT=0") out, err := command.CombinedOutput() diff --git a/internal/cellstore/captureplan.go b/internal/cellstore/captureplan.go index e538696f67..9ceb58525e 100644 --- a/internal/cellstore/captureplan.go +++ b/internal/cellstore/captureplan.go @@ -216,7 +216,7 @@ func ignoredFiles(tree string) ([]string, error) { if !hasGitMarker(tree) { return nil, nil } - cmd := exec.Command("git", "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z") //codeaf:plumbing Ignored individual secrets still belong in the vault. + cmd := exec.Command("git", "--no-optional-locks", "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z") //codeaf:plumbing Ignored individual secrets still belong in the vault. cmd.Dir = tree raw, err := cmd.Output() if err != nil { @@ -246,7 +246,7 @@ func ignoredFiles(tree string) ([]string, error) { if inRules(dirs, strings.TrimSuffix(dir, "/")) { continue } - cmd := exec.Command("git", "ls-files", "--others", "--ignored", "--exclude-standard", "-z", "--", dir) //codeaf:plumbing Collapsed names do not hide individual vault files. + cmd := exec.Command("git", "--no-optional-locks", "ls-files", "--others", "--ignored", "--exclude-standard", "-z", "--", dir) //codeaf:plumbing Collapsed names do not hide individual vault files. cmd.Dir = tree raw, err := cmd.Output() if err != nil { diff --git a/internal/cellstore/gitwork.go b/internal/cellstore/gitwork.go index 6c71f15ac7..17070c7571 100644 --- a/internal/cellstore/gitwork.go +++ b/internal/cellstore/gitwork.go @@ -34,7 +34,7 @@ func (e Engine) sameIndex(ctx context.Context, base string, target Target) (bool if err != nil { return false, err } - locate := exec.CommandContext(ctx, "git", "-C", target.Tree, "rev-parse", "--git-path", "index") + locate := exec.CommandContext(ctx, "git", "--no-optional-locks", "-C", target.Tree, "rev-parse", "--git-path", "index") found, err := locate.Output() if err != nil { return false, err @@ -62,7 +62,7 @@ func (e Engine) sameIndex(ctx context.Context, base string, target Target) (bool } func indexEntries(ctx context.Context, root, index string) ([]byte, error) { - command := exec.CommandContext(ctx, "git", "-C", root, "ls-files", "--stage", "-v", "-z") + command := exec.CommandContext(ctx, "git", "--no-optional-locks", "-C", root, "ls-files", "--stage", "-v", "-z") command.Env = append(os.Environ(), "GIT_OPTIONAL_LOCKS=0") if index != "" { command.Env = append(command.Env, "GIT_INDEX_FILE="+index) diff --git a/internal/cellstore/ignored.go b/internal/cellstore/ignored.go index 61981d125f..de44ce86e6 100644 --- a/internal/cellstore/ignored.go +++ b/internal/cellstore/ignored.go @@ -23,7 +23,7 @@ const ignoredReadFailure = "cannot read which folders Git ignores here" func gitIgnored(tree, path string) bool { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - cmd := exec.CommandContext(ctx, "git", "check-ignore", "-q", "--", path) //codeaf:plumbing A rebuild needs the lockfile in the capture. + cmd := exec.CommandContext(ctx, "git", "--no-optional-locks", "check-ignore", "-q", "--", path) //codeaf:plumbing A rebuild needs the lockfile in the capture. cmd.Dir = tree _, err := cmd.Output() if err == nil { @@ -44,7 +44,7 @@ func gitIgnored(tree, path string) bool { func ignoredDirectories(tree string) ([]string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - cmd := exec.CommandContext(ctx, "git", "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z") //codeaf:plumbing Git names ignored directories without opening them. + cmd := exec.CommandContext(ctx, "git", "--no-optional-locks", "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z") //codeaf:plumbing Git names ignored directories without opening them. cmd.Dir = tree raw, err := cmd.Output() if err != nil { @@ -65,7 +65,7 @@ func ignoredDirectories(tree string) ([]string, error) { } // ls-files may collapse a directory whose only child is an ignored file. // Pruning that directory would hide config/.env.local from the vault. - check := exec.CommandContext(ctx, "git", "check-ignore", "--stdin", "-z") //codeaf:plumbing Only an ignored directory itself can bypass the secret screen. + check := exec.CommandContext(ctx, "git", "--no-optional-locks", "check-ignore", "--stdin", "-z") //codeaf:plumbing Only an ignored directory itself can bypass the secret screen. check.Dir = tree check.Stdin = strings.NewReader(strings.Join(candidates, "\x00") + "\x00") checked, err := check.Output() diff --git a/internal/gitidentity/project.go b/internal/gitidentity/project.go index 33aa365a6f..1bdd29b063 100644 --- a/internal/gitidentity/project.go +++ b/internal/gitidentity/project.go @@ -56,7 +56,7 @@ func hashKey(value string) string { // the authority; a failed read is "no remote", never a failure — the path // fallback is the designed answer for a workspace without one. func originRemote(root string) string { - out, err := exec.Command("git", "-C", root, "remote", "get-url", "origin").Output() + out, err := exec.Command("git", "--no-optional-locks", "-C", root, "remote", "get-url", "origin").Output() if err != nil { return "" } diff --git a/internal/praf/brief.go b/internal/praf/brief.go index 880e0fec6d..541aa090b8 100644 --- a/internal/praf/brief.go +++ b/internal/praf/brief.go @@ -162,7 +162,7 @@ func folderGit(ctx context.Context, dir string, args ...string) (string, bool) { } ctx, cancel := context.WithTimeout(ctx, gitTimeout) defer cancel() - out, err := exec.CommandContext(ctx, "git", append([]string{"-C", dir}, args...)...).Output() + out, err := exec.CommandContext(ctx, "git", append([]string{"--no-optional-locks", "-C", dir}, args...)...).Output() if err != nil { return "", false } diff --git a/internal/praf/orch/resolve.go b/internal/praf/orch/resolve.go index 519c70de06..2f07e64079 100644 --- a/internal/praf/orch/resolve.go +++ b/internal/praf/orch/resolve.go @@ -94,7 +94,7 @@ func setEnvValue(env []string, name, value string) []string { func runGit(parent context.Context, token string, timeout time.Duration, args ...string) (stdout, stderr string, err error) { ctx, cancel := context.WithTimeout(parent, timeout) defer cancel() - cmd := exec.CommandContext(ctx, "git", args...) + cmd := exec.CommandContext(ctx, "git", append([]string{"--no-optional-locks"}, args...)...) configureGitProcess(cmd) cmd.Env = gitEnv(token) cmd.WaitDelay = time.Second diff --git a/internal/preflight/uncommitted.go b/internal/preflight/uncommitted.go index adf2e34cb9..20f31b5ca4 100644 --- a/internal/preflight/uncommitted.go +++ b/internal/preflight/uncommitted.go @@ -17,7 +17,7 @@ const gitLookBound = 2 * time.Second func Uncommitted(dir string) []string { ctx, cancel := context.WithTimeout(context.Background(), gitLookBound) defer cancel() - out, err := exec.CommandContext(ctx, "git", "-C", dir, "status", "--porcelain", "-z").Output() //codeaf:plumbing read-only status look at the folder a chat arrived in + out, err := exec.CommandContext(ctx, "git", "--no-optional-locks", "-C", dir, "status", "--porcelain", "-z").Output() //codeaf:plumbing read-only status look at the folder a chat arrived in if err != nil { return nil } diff --git a/internal/rebuild/rule.go b/internal/rebuild/rule.go index a34a4b324a..1a20276ad9 100644 --- a/internal/rebuild/rule.go +++ b/internal/rebuild/rule.go @@ -46,7 +46,7 @@ func nearest(tree, dir string, kind Kind) (string, bool) { // repository has no tracked file. A folder that git could not be asked about is // treated as tracked, because the safe answer to "may this be left out" is no. func Tracked(tree, folder string) bool { - cmd := exec.Command("git", "ls-files", "-z", "--", folder) //codeaf:plumbing asks the project's own repository whether a folder is source + cmd := exec.Command("git", "--no-optional-locks", "ls-files", "-z", "--", folder) //codeaf:plumbing asks the project's own repository whether a folder is source cmd.Dir = tree out, err := cmd.Output() if err == nil { diff --git a/internal/secaf/diffanalysis/diffanalysis.go b/internal/secaf/diffanalysis/diffanalysis.go index af7d6e1b24..13a74d6e42 100644 --- a/internal/secaf/diffanalysis/diffanalysis.go +++ b/internal/secaf/diffanalysis/diffanalysis.go @@ -20,6 +20,7 @@ package diffanalysis import ( "bytes" "context" + "os" "os/exec" "sort" "strings" @@ -99,6 +100,7 @@ func runGit(ctx context.Context, dir string, argv []string) (string, error) { cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) cmd.Dir = dir + cmd.Env = append(os.Environ(), "GIT_OPTIONAL_LOCKS=0") var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr diff --git a/internal/seniordev/app/engine_backend.go b/internal/seniordev/app/engine_backend.go index 1d4151e68e..d89fa4903f 100644 --- a/internal/seniordev/app/engine_backend.go +++ b/internal/seniordev/app/engine_backend.go @@ -171,7 +171,7 @@ func turnSystemContext(ctx context.Context, workspace string) systemprompt.Conte } if vcs == "" && directory != "" { //codeaf:plumbing git probe of the workspace - command := exec.CommandContext(ctx, "git", "-C", directory, "rev-parse", "--is-inside-work-tree") + command := exec.CommandContext(ctx, "git", "--no-optional-locks", "-C", directory, "rev-parse", "--is-inside-work-tree") if output, err := command.Output(); err == nil && strings.TrimSpace(string(output)) == "true" { vcs = "git" } diff --git a/internal/seniordev/app/pipeline.go b/internal/seniordev/app/pipeline.go index 0f64b19e33..dc013cd0f7 100644 --- a/internal/seniordev/app/pipeline.go +++ b/internal/seniordev/app/pipeline.go @@ -401,7 +401,7 @@ func splitModelID(value string) (providerID, modelID string) { func gitOutput(ctx context.Context, workspace string, args ...string) string { //codeaf:plumbing git read of the workspace - command := exec.CommandContext(ctx, "git", args...) + command := exec.CommandContext(ctx, "git", append([]string{"--no-optional-locks"}, args...)...) command.Dir = workspace output, err := command.Output() if err != nil { diff --git a/internal/seniordev/app/workspace_recorder_git.go b/internal/seniordev/app/workspace_recorder_git.go index e8d46f7f30..1c67583030 100644 --- a/internal/seniordev/app/workspace_recorder_git.go +++ b/internal/seniordev/app/workspace_recorder_git.go @@ -198,7 +198,7 @@ func (recorder *gitRecorder) Snapshot() (string, error) { // even after the model rewrote .gitignore. This is a private temporary index; // the real index never stages these paths. //codeaf:plumbing git recorder of workspace changes - listed := exec.Command("git", "ls-files", "--cached", "-z") + listed := exec.Command("git", "--no-optional-locks", "ls-files", "--cached", "-z") listed.Dir, listed.Env = recorder.workspace, env staged, err := listed.CombinedOutput() if err != nil { @@ -387,7 +387,7 @@ func (recorder *gitRecorder) ListPaths( ) ([]string, int, bool, error) { //codeaf:plumbing git recorder of workspace changes command := exec.CommandContext( - ctx, "git", "ls-files", "-z", "--cached", "--others", "--exclude-standard", + ctx, "git", "--no-optional-locks", "ls-files", "-z", "--cached", "--others", "--exclude-standard", ) command.Dir = recorder.workspace stdout, err := command.StdoutPipe() @@ -469,7 +469,7 @@ func (recorder *gitRecorder) Summary( var patchBytes countingWriter var diffError bytes.Buffer //codeaf:plumbing git recorder of workspace changes - command := exec.CommandContext(ctx, "git", diffArgs("--binary", "--no-renames")...) + command := exec.CommandContext(ctx, "git", append([]string{"--no-optional-locks"}, diffArgs("--binary", "--no-renames")...)...) command.Dir, command.Stdout, command.Stderr = workspace, &patchBytes, &diffError status := "completed" if err := command.Run(); err != nil { diff --git a/internal/seniordev/project/store.go b/internal/seniordev/project/store.go index 78baa6b63d..c67eed632d 100644 --- a/internal/seniordev/project/store.go +++ b/internal/seniordev/project/store.go @@ -162,7 +162,7 @@ func (s *Store) Provide(ctx context.Context, input LoadInput, fn func(context.Co func runGit(ctx context.Context, cwd string, args ...string) (string, bool) { //codeaf:plumbing git read of project state - command := exec.CommandContext(ctx, "git", args...) + command := exec.CommandContext(ctx, "git", append([]string{"--no-optional-locks"}, args...)...) command.Dir = cwd var stdout bytes.Buffer command.Stdout = &stdout diff --git a/internal/seniordev/tool/ripgrep_fallback.go b/internal/seniordev/tool/ripgrep_fallback.go index 9c2b2899d8..097678b108 100644 --- a/internal/seniordev/tool/ripgrep_fallback.go +++ b/internal/seniordev/tool/ripgrep_fallback.go @@ -200,7 +200,7 @@ func collectSearchFiles( // work tree (or git is unavailable), leaving the caller to walk instead. func gitTrackedFiles(ctx context.Context, dir string) (files []string, ok bool) { res, err := executor.For(ctx).In("").Exec(ctx, executor.ExecRequest{ - Argv: []string{"git", "-C", dir, "ls-files", "-z", "--cached", "--others", "--exclude-standard"}, + Argv: []string{"git", "--no-optional-locks", "-C", dir, "ls-files", "-z", "--cached", "--others", "--exclude-standard"}, Group: executor.GroupInherit, WaitDelay: -1, }, nil) if err != nil || res.Failure() != nil { diff --git a/internal/seniordev/util/gitexclude.go b/internal/seniordev/util/gitexclude.go index afc6e73de8..c7ef281fed 100644 --- a/internal/seniordev/util/gitexclude.go +++ b/internal/seniordev/util/gitexclude.go @@ -29,7 +29,7 @@ const excludeSentinel = "# senior-dev: workflow artifacts (managed by senior-dev // actually consults, which for a linked worktree is the repository's shared // one. func EnsureSeniorDevExcluded(ctx context.Context, workspace string) (bool, error) { - result, err := RunProcess(ctx, []string{"git", "rev-parse", "--git-path", "info/exclude"}, RunOptions{ + result, err := RunProcess(ctx, []string{"git", "--no-optional-locks", "rev-parse", "--git-path", "info/exclude"}, RunOptions{ ProcessOptions: ProcessOptions{Cwd: workspace}, NoThrow: true, }) diff --git a/internal/seniordev/util/gitidentity.go b/internal/seniordev/util/gitidentity.go index 4edf5f4113..799a799756 100644 --- a/internal/seniordev/util/gitidentity.go +++ b/internal/seniordev/util/gitidentity.go @@ -28,6 +28,6 @@ const ( // GitArgv is a git command line that carries senior-dev's commit identity. func GitArgv(args ...string) []string { - argv := []string{"git", "-c", "user.name=" + CommitterName, "-c", "user.email=" + CommitterEmail} + argv := []string{"git", "--no-optional-locks", "-c", "user.name=" + CommitterName, "-c", "user.email=" + CommitterEmail} return append(argv, args...) } diff --git a/internal/session/task_run.go b/internal/session/task_run.go index d5458f3f5f..c707d621b7 100644 --- a/internal/session/task_run.go +++ b/internal/session/task_run.go @@ -9477,7 +9477,7 @@ func gitContext(ctx context.Context, dir string, environment []string, args ...s if strings.TrimSpace(dir) == "" { return "", errors.New("git: no directory to run in") } - command := exec.CommandContext(ctx, "git", args...) + command := exec.CommandContext(ctx, "git", append([]string{"--no-optional-locks"}, args...)...) command.Dir = dir // A pager or an editor in the middle of a merge would hang a node forever on // a terminal it does not have. diff --git a/internal/shell/shell.go b/internal/shell/shell.go index 49df2cb31d..e03c47f26e 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -245,7 +245,7 @@ func Bash() (string, error) { // path. A broken shim cannot hold up the first command forever. ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() - cmd := exec.CommandContext(ctx, git, "--exec-path") + cmd := exec.CommandContext(ctx, git, "--no-optional-locks", "--exec-path") cmd.WaitDelay = 100 * time.Millisecond output, err := cmd.Output() return strings.TrimSpace(string(output)), err diff --git a/internal/taskcopy/git.go b/internal/taskcopy/git.go index 9673f62933..a74d38a274 100644 --- a/internal/taskcopy/git.go +++ b/internal/taskcopy/git.go @@ -12,7 +12,7 @@ import ( // cannot be put back. func git(dir string, args ...string) (string, error) { var stderr strings.Builder - cmd := exec.Command("git", args...) + cmd := exec.Command("git", append([]string{"--no-optional-locks"}, args...)...) cmd.Dir = dir cmd.Stderr = &stderr out, err := cmd.Output() diff --git a/internal/tui3/app.go b/internal/tui3/app.go index b5d6727f47..84892818cc 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -10550,7 +10550,7 @@ func gitHead(dir string) (string, bool, bool) { ctx, cancel := context.WithTimeout(context.Background(), gitTimeout) defer cancel() run := func(args ...string) (string, bool) { - cmd := exec.CommandContext(ctx, "git", args...) + cmd := exec.CommandContext(ctx, "git", append([]string{"--no-optional-locks"}, args...)...) cmd.Dir = dir out, err := cmd.Output() if err != nil { @@ -10564,7 +10564,7 @@ func gitHead(dir string) (string, bool, bool) { } // An unreadable status is reported as CLEAN rather than dirty: the star is a // claim, and a claim this surface could not check is one it should not make. - status, _ := run("--no-optional-locks", "status", "--porcelain", "--untracked-files=no") + status, _ := run("status", "--porcelain", "--untracked-files=no") return branch, status != "", true } diff --git a/internal/tui3/homeband_repo.go b/internal/tui3/homeband_repo.go index 1c27dd9371..97d38516e3 100644 --- a/internal/tui3/homeband_repo.go +++ b/internal/tui3/homeband_repo.go @@ -24,7 +24,7 @@ type homeRepoReading struct { } var homeGitStatus = func(ctx context.Context, workspace string) ([]byte, error) { - return exec.CommandContext(ctx, "git", "-C", workspace, "status", "--porcelain=v2", "--branch").Output() + return exec.CommandContext(ctx, "git", "--no-optional-locks", "-C", workspace, "status", "--porcelain=v2", "--branch").Output() } func init() { diff --git a/internal/tui3/repository_read_safety_test.go b/internal/tui3/repository_read_safety_test.go new file mode 100644 index 0000000000..2e6362c063 --- /dev/null +++ b/internal/tui3/repository_read_safety_test.go @@ -0,0 +1,193 @@ +package tui3 + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/furrow" + "github.com/Agent-Field/codeaf/internal/handoff" +) + +func repositoryWithStaleStat(t *testing.T) string { + t.Helper() + root := t.TempDir() + git := func(args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", root}, args...)...) + cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("fixture git %v: %v: %s", args, err, out) + } + } + git("init", "-q") + if err := os.WriteFile(filepath.Join(root, "tracked.txt"), []byte("unchanged\n"), 0600); err != nil { + t.Fatal(err) + } + git("add", "tracked.txt") + git("-c", "user.name=Test", "-c", "user.email=test@example.invalid", "-c", "commit.gpgsign=false", "commit", "-qm", "Initial file") + staleRepositoryStat(t, root) + return root +} + +func staleRepositoryStat(t *testing.T, root string) { + t.Helper() + old := time.Unix(1600000000, 0) + if err := os.Chtimes(filepath.Join(root, "tracked.txt"), old, old); err != nil { + t.Fatal(err) + } +} + +func unchangedRepositoryRead(t *testing.T, root string) error { + t.Helper() + index := filepath.Join(root, ".git", "index") + before, err := os.ReadFile(index) + if err != nil { + return err + } + stat, err := os.Stat(index) + if err != nil { + return err + } + dir, err := os.Stat(filepath.Dir(index)) + if err != nil { + return err + } + if _, err := homeGitStatus(t.Context(), root); err != nil { + return err + } + after, err := os.ReadFile(index) + if err != nil { + return err + } + now, err := os.Stat(index) + if err != nil { + return err + } + currentDir, err := os.Stat(filepath.Dir(index)) + if err != nil { + return err + } + if !bytes.Equal(before, after) || !stat.ModTime().Equal(now.ModTime()) { + return fmt.Errorf("repository reading rewrote the index") + } + if !dir.ModTime().Equal(currentDir.ModTime()) { + return fmt.Errorf("repository reading created or removed an index lock") + } + if _, err := os.Stat(index + ".lock"); !os.IsNotExist(err) { + return fmt.Errorf("repository reading left an index lock: %v", err) + } + return nil +} + +func TestHomeRepositoryReadingLeavesTheIndexUntouched(t *testing.T) { + if err := unchangedRepositoryRead(t, repositoryWithStaleStat(t)); err != nil { + t.Fatal(err) + } +} + +type readingRestore struct { + cellstore.Transport + t *testing.T + root string + read bool +} + +func (r *readingRestore) Do(ctx context.Context, target cellstore.Target, op cellstore.Op) ([]byte, error) { + if op.Verb() == "materialize" && target.Tree == r.root { + r.read = true + staleRepositoryStat(r.t, r.root) + if err := unchangedRepositoryRead(r.t, r.root); err != nil { + return nil, err + } + } + return r.Transport.Do(ctx, target, op) +} + +type restoreFetch struct{ engine cellstore.SyncEngine } + +func (f restoreFetch) Complete(context.Context, cell.Cell, string) error { return nil } +func (f restoreFetch) Fetch(ctx context.Context, c cell.Cell, head string) error { + return f.engine.Materialize(ctx, c, head) +} + +type cleanRestore struct{ handoff.Local } + +func (cleanRestore) Unpublished(cell.Cell, string) (string, uint32, error) { return "", 0, nil } +func (cleanRestore) Dirty(context.Context, cell.Cell) (bool, error) { return false, nil } + +type restoreDirectory struct { + directory.Client + view directory.CellView + released bool +} + +func (d *restoreDirectory) Cell(context.Context, string) (directory.CellView, error) { + return d.view, nil +} +func (d *restoreDirectory) Acquire(_ context.Context, _ string, opts directory.AcquireOpts) (directory.CellView, error) { + if opts.Expect == nil || opts.Expect.Fence != d.view.Cell.Lease.Fence { + return directory.CellView{}, directory.ErrLeaseHeld + } + d.view.Cell.Lease.Fence++ + return d.view, nil +} +func (d *restoreDirectory) Release(context.Context, string, uint64) error { + d.released = true + return nil +} + +func TestInPlaceTakeWithTheHomeRepositoryReading(t *testing.T) { + celltest.RequireEngine(t) + bin, err := furrow.ResolveOwned() + if err != nil { + t.Fatal(err) + } + root := repositoryWithStaleStat(t) + c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + transport := &readingRestore{Transport: cellstore.Spawn{Binary: bin}, t: t, root: root} + e := cellstore.Engine{Binary: bin, DataRoot: t.TempDir(), Workspace: root, Transport: transport} + t.Cleanup(cellstore.StopSpawned) + if err := os.WriteFile(filepath.Join(root, "restored.txt"), []byte("before\n"), 0600); err != nil { + t.Fatal(err) + } + sealed, err := e.Seal(t.Context(), c, cellstore.TurnInfo{}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "restored.txt"), []byte("later\n"), 0600); err != nil { + t.Fatal(err) + } + syncEngine := e.Sync(cellstore.SyncKeys{}, "test") + syncEngine.Target = func(at cell.Cell) cellstore.Target { + tree := at.Root + if at.Root == c.Root { + tree = root + } + return cellstore.Target{Tree: tree, CellDir: at.Root, DataDir: e.Root()} + } + dir := &restoreDirectory{view: directory.CellView{Cell: directory.Cell{Head: sealed.Turn.ID}}} + taker := handoff.Taker{Dir: dir, Local: cleanRestore{}, Fetch: restoreFetch{syncEngine}, RootFor: func(string) string { return c.Root }, InPlace: func(cell.Cell) bool { return true }} + taken, err := taker.Take(t.Context(), c.ID) + if err != nil { + t.Fatalf("in-place take with repository reading: %v", err) + } + if !transport.read || dir.released || taken.Driving.Head != sealed.Turn.ID { + t.Fatal("take did not finish with the repository read at restore") + } + if body, _ := os.ReadFile(filepath.Join(root, "restored.txt")); string(body) != "before\n" { + t.Fatalf("restored body = %q", body) + } +} From c85c3bd9cae09ff2d52ccae3aca5c76bbbe4e42d Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 15:00:06 -0400 Subject: [PATCH 15/23] Send only from a chat this window holds, and leave a failed move safe After a failed take the chat was free, so the window's send door, which only blocked a chat another computer held, let a turn run on a stale copy that could never publish. A send now goes ahead only when this window holds the chat at the fence it recorded, or the chat was never shared. A free chat whose local head equals the shared head is taken back quietly with a conditional take, then sent; otherwise the continuation card or the view-only state appears and the words stay in the box. A failed take marks the window's copy as one it may not send on and says why in plain words, and a stale-fence refusal makes the window view-only. When the ownership check cannot reach the other computers, a chat this window holds keeps working and its turns wait to be shared, and anything else stays unsent. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/synctake.go | 2 +- .../unreleased/1967-two-computer-fixes.md | 3 + internal/cellsync/batch.go | 12 +- internal/cellsync/lost_work_safety_test.go | 24 ++ internal/chatlist/rows.go | 2 + internal/syncsetup/drive.go | 53 ++++- .../syncsetup/drive_refusal_safety_test.go | 66 ++++++ internal/syncsetup/sendlease.go | 81 +++++++ internal/syncsetup/take.go | 15 +- internal/tui3/homepanel_continue.go | 56 ++++- internal/tui3/homepanel_heldelsewhere.go | 205 +++++++++++++++--- internal/tui3/homepanel_machines.go | 22 +- internal/tui3/input.go | 5 - internal/tui3/machinepoll.go | 2 +- internal/tui3/removed_computer.go | 13 ++ internal/tui3/send_ownership_safety_test.go | 152 +++++++++++++ 16 files changed, 659 insertions(+), 54 deletions(-) create mode 100644 internal/cellsync/lost_work_safety_test.go create mode 100644 internal/syncsetup/drive_refusal_safety_test.go create mode 100644 internal/syncsetup/sendlease.go create mode 100644 internal/tui3/removed_computer.go create mode 100644 internal/tui3/send_ownership_safety_test.go diff --git a/cmd/codeaf/synctake.go b/cmd/codeaf/synctake.go index 49f6a42d4b..e883495c66 100644 --- a/cmd/codeaf/synctake.go +++ b/cmd/codeaf/synctake.go @@ -156,7 +156,7 @@ func (t takeover) OpenBranch(ctx context.Context, id string) (tui3.Taken, error) } func surfaceTaken(got syncsetup.Continued, elapsed time.Duration) tui3.Taken { - return tui3.Taken{AlreadyHere: got.AlreadyHere, Kept: got.Taken.Kept, KeptTurns: got.KeptTurns, Device: got.Device, TaskCopies: got.TaskCopies, Resume: got.Resume, Transcript: filepath.Join(got.Taken.Cell.Root, cell.TranscriptPath), Elapsed: elapsed} + return tui3.Taken{Fence: got.Taken.Driving.Fence, Head: got.Taken.Driving.Head, AlreadyHere: got.AlreadyHere, Kept: got.Taken.Kept, KeptTurns: got.KeptTurns, Device: got.Device, TaskCopies: got.TaskCopies, Resume: got.Resume, Transcript: filepath.Join(got.Taken.Cell.Root, cell.TranscriptPath), Elapsed: elapsed} } // takeRootFor is where this machine keeps the chat with an id: the folder it diff --git a/docs/changes/unreleased/1967-two-computer-fixes.md b/docs/changes/unreleased/1967-two-computer-fixes.md index fd199283c8..268ec9f580 100644 --- a/docs/changes/unreleased/1967-two-computer-fixes.md +++ b/docs/changes/unreleased/1967-two-computer-fixes.md @@ -10,5 +10,8 @@ invalidates: - "Taking a chat back into a displaced window could report already shared turns as kept edits and add a branch row. Only real local work is kept now, and an empty new chat on another computer no longer shows as an untitled row on Home." - "A removed computer kept polling and heartbeating while its window stayed open. Once removal is known, sync now stays stopped across windows and restarts until that computer pairs again; local chats remain usable." - "codeaf --version, help and manual requests adopted the former state folder and removed old timers before doing anything else. Those informational requests now change nothing on the machine." + - "A failed move could leave a free chat sending on an older copy and lose the captured draft. Sending now requires this window's current hold, every failed move restores the whole draft, and an idle computer can return silently only from the current saved turn." + - "Background repository reads could refresh the index and interrupt a move into the project folder. Every automatic Git reading now disables optional index locks." + - "A removed computer could keep offering to continue a chat. Home now withdraws the offer, chord and row action while keeping the removal explanation visible." --- After continue here from a send, the held words are left in the arrived chat's box for the person to send. diff --git a/internal/cellsync/batch.go b/internal/cellsync/batch.go index f79b60cc2b..742fd1910a 100644 --- a/internal/cellsync/batch.go +++ b/internal/cellsync/batch.go @@ -42,8 +42,10 @@ type Batcher struct { Info func() PublishInfo OnFlush func(Flush) // telemetry hook OnSuperseded func(Superseded) // the chat becomes a viewer (L7) - OnError func(error) // a refusal (see RefusalOf) the person should hear of, once per episode - Now func() time.Time // default time.Now; tests inject a fake clock + // OnLost refuses new work before keeping pending work can fail. + OnLost func() + OnError func(error) // a refusal (see RefusalOf) the person should hear of, once per episode + Now func() time.Time // default time.Now; tests inject a fake clock // Liveness, when set, is a watch socket that can vouch for the lease, so // the heartbeat is sent only while it cannot. Nil beats every tick. Liveness Liveness @@ -447,6 +449,9 @@ func (b *Batcher) publish(ctx context.Context, head string) error { ex, err := b.Publisher.publish(ctx, &d, head, b.info()) if errors.Is(err, ErrSuperseded) { b.setStale() + if b.OnLost != nil { + b.OnLost() + } return b.resolve(ctx) } if err != nil { @@ -489,6 +494,9 @@ func (b *Batcher) lost(ctx context.Context, seen Driving) { return } b.setStale() + if b.OnLost != nil { + b.OnLost() + } _ = b.resolve(ctx) } diff --git a/internal/cellsync/lost_work_safety_test.go b/internal/cellsync/lost_work_safety_test.go new file mode 100644 index 0000000000..5b1ce7c999 --- /dev/null +++ b/internal/cellsync/lost_work_safety_test.go @@ -0,0 +1,24 @@ +package cellsync + +import ( + "github.com/Agent-Field/codeaf/internal/blobstore" + "testing" +) + +func TestStaleHeartbeatRefusesWorkBeforePreservationCanFail(t *testing.T) { + r := newRig(t) + r.publishFirst(map[string]string{"file": "shared"}) + b := r.batcher() + note(b, r.seal(map[string]string{"file": "local work"})) + r.takeOver() + r.store.set(blobstore.ErrUnreachable, nil) + lost := false + b.OnLost = func() { lost = true } + b.beat(t.Context()) + if !lost { + t.Fatal("stale heartbeat left new work enabled when preservation failed") + } + if b.Pending() == 0 { + t.Fatal("failed preservation discarded pending work") + } +} diff --git a/internal/chatlist/rows.go b/internal/chatlist/rows.go index 9c44b533fe..b9b63e0642 100644 --- a/internal/chatlist/rows.go +++ b/internal/chatlist/rows.go @@ -28,6 +28,7 @@ const ( // Row is one chat as the list shows it. type Row struct { Cell, Title, Device, DeviceID, Parent string + Head string Status Status DurableAgo time.Duration // Listing.Now - DurableAt, directory clock only DurableAt int64 // directory ms of the last durable turn: the fact DurableAgo is read from @@ -115,6 +116,7 @@ func newer(a, b Row) bool { func rowOf(id string, c directory.Cell, l directory.Listing, self string, open Opener) Row { return Row{ Cell: id, + Head: c.Head, Title: nameOr(open, c.Title, untitled), Device: deviceName(l.Devices, c.Lease.Device, open), DeviceID: c.Lease.Device, diff --git a/internal/syncsetup/drive.go b/internal/syncsetup/drive.go index bfdf0ff4f3..f37080166b 100644 --- a/internal/syncsetup/drive.go +++ b/internal/syncsetup/drive.go @@ -46,7 +46,8 @@ type Drive struct { scope *Scope // what this chat's publishes are counted under // Ownership stays on the record this window opened. Preserving displaced // work in a branch does not transfer this window's permission to run tools. - directoryID string + directoryID string + recordedFence uint64 cancel context.CancelFunc done chan struct{} @@ -90,6 +91,9 @@ func (s *Sync) Drive(ctx context.Context, eng cellstore.Engine, c cell.Cell, opt if d.batcher, err = s.batcher(eng, c, drv, d); err != nil { return nil, err } + if err := d.recordSendLease(); err != nil { + return nil, errors.Join(err, s.Dir.Release(ctx, drv.ID(), drv.Fence)) + } d.catchUp(drv) run, cancel := context.WithCancel(context.WithoutCancel(ctx)) d.cancel = cancel @@ -178,6 +182,9 @@ func (d *Drive) pushChanged(ctx context.Context, v vaultsync.Syncer) error { // flushed is what runs after each publish: the telemetry, then the vault. func (d *Drive) flushed() func(cellsync.Flush) { return func(f cellsync.Flush) { + if err := d.recordSendLease(); err != nil { + d.becomeViewer(cellsync.Superseded{}) + } if err := cellstore.NotePublished(d.cell, f.Head); err != nil { d.notice("could not record which turns reached your other devices: " + err.Error()) } @@ -239,9 +246,23 @@ func (s *Sync) driving(ctx context.Context, c cell.Cell) (cellsync.Driving, stri // The ordinary continuation restores the newer head and keeps local work; // until the person takes that door this window only shows its local copy. if !known { - return drv, view.Cell.Lease.Device, nil + return drv, nonemptyHolder(view.Cell.Lease.Device), nil + } + var localHead string + wasFree := view.Cell.Lease.Expires <= view.Now + if wasFree { + head, err := cellstore.Head(c) + if err != nil { + return drv, "", err + } + if head != nil { + localHead = head.Turn.ID + } + if localHead != view.Cell.Head { + return drv, nonemptyHolder(view.Cell.Lease.Device), nil + } } - v, err := s.Dir.Acquire(ctx, drv.ID(), directory.AcquireOpts{Expect: &directory.LeaseExpectation{ + v, err := s.Dir.Acquire(ctx, drv.ID(), directory.AcquireOpts{RequireConditional: wasFree, Expect: &directory.LeaseExpectation{ Device: view.Cell.Lease.Device, Fence: view.Cell.Lease.Fence, }}) switch { @@ -253,9 +274,9 @@ func (s *Sync) driving(ctx context.Context, c cell.Cell) (cellsync.Driving, stri return drv, "", err } known, err = cellstore.KnowsHead(c, v.Cell.Head) - if err != nil || !known { + if err != nil || !known || (wasFree && localHead != v.Cell.Head) { _ = s.Dir.Release(ctx, drv.ID(), v.Cell.Lease.Fence) - return drv, view.Cell.Lease.Device, err + return drv, nonemptyHolder(view.Cell.Lease.Device), err } drv.Fence, drv.Head = v.Cell.Lease.Fence, v.Cell.Head // The plan comes with the record: the frames the last publisher named for @@ -294,6 +315,7 @@ func (s *Sync) batcher(eng cellstore.Engine, c cell.Cell, drv cellsync.Driving, Info: d.info, OnFlush: d.flushed(), OnSuperseded: d.becomeViewer, + OnLost: func() { d.refusal(directory.ErrFenceStale) }, OnError: d.refusal, Sleep: d.opt.Sleep, Liveness: s.Liveness(), @@ -312,7 +334,11 @@ func (d *Drive) info() cellsync.PublishInfo { // becomeViewer is the Batcher's word that another device took the chat over: // from now on this window only shows it, and says who has it. func (d *Drive) becomeViewer(s cellsync.Superseded) { - line := chatlist.Superseded(d.sync.deviceName(s.By)) + device := unknownDevice + if s.By != "" && s.By != unknownDevice { + device = d.sync.deviceName(s.By) + } + line := chatlist.Superseded(device) d.mu.Lock() d.line = line d.mu.Unlock() @@ -332,6 +358,10 @@ func (d *Drive) becomeViewer(s cellsync.Superseded) { // refusal shows the person-facing line of a refused request, which the // refusal table in cellsync holds beside how the request is retried. func (d *Drive) refusal(err error) { + if errors.Is(err, directory.ErrFenceStale) || errors.Is(err, cellsync.ErrSuperseded) { + d.becomeViewer(cellsync.Superseded{}) + return + } if r, ok := cellsync.RefusalOf(err); ok { d.notice(r.Say(err)) } @@ -383,7 +413,7 @@ func (d *Drive) HoldsLease(ctx context.Context) bool { } lease := v.Cell.Lease // The first publish may establish the fence while the read is in flight. - return lease.Device == d.sync.Device.ID() && lease.Fence == d.batcher.Fence() + return lease.Expires > v.Now && lease.Device == d.sync.Device.ID() && lease.Fence == d.batcher.Fence() } // CheckLease closes the stale-window gap before a tool runs, even when no @@ -396,12 +426,19 @@ func (d *Drive) CheckLease(ctx context.Context) error { return nil } v, err := d.sync.Dir.Cell(ctx, d.directoryID) - if err == nil && (v.Cell.Lease.Device != d.sync.Device.ID() || v.Cell.Lease.Fence != d.batcher.Fence()) { + if err == nil && (v.Cell.Lease.Expires <= v.Now || v.Cell.Lease.Device != d.sync.Device.ID() || v.Cell.Lease.Fence != d.batcher.Fence()) { d.becomeViewer(cellsync.Superseded{By: v.Cell.Lease.Device}) } return nil } +func nonemptyHolder(id string) string { + if id == "" { + return unknownDevice + } + return id +} + // Gate refuses with the superseded line once the chat is a viewer, and answers // nil while this device drives it. It is what a viewer's tool calls meet. func (d *Drive) Gate() error { diff --git a/internal/syncsetup/drive_refusal_safety_test.go b/internal/syncsetup/drive_refusal_safety_test.go new file mode 100644 index 0000000000..ab18352543 --- /dev/null +++ b/internal/syncsetup/drive_refusal_safety_test.go @@ -0,0 +1,66 @@ +package syncsetup + +import ( + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/directory" +) + +func TestAStaleDriveRefusalImmediatelyMakesTheWindowAViewer(t *testing.T) { + d := &Drive{sync: &Sync{}} + d.refusal(directory.ErrFenceStale) + if d.Gate() == nil { + t.Fatal("a refused drive still allows new work") + } +} + +func TestTheSendProofNamesTheDrivesActualFence(t *testing.T) { + r := newDriveRig(t) + sealed, err := r.engine.Seal(t.Context(), r.cell, cellstore.TurnInfo{}) + mustNil(t, err) + _, err = r.a.Dir.Create(t.Context(), r.cell.ID, directory.CellInit{Head: sealed.Turn.ID}) + mustNil(t, err) + drive, err := r.a.Drive(t.Context(), r.engine, r.cell, DriveOptions{}) + mustNil(t, err) + defer drive.Close(t.Context()) + id, fence, err := r.a.SendLease(r.cell.ID) + mustNil(t, err) + if id != r.cell.ID || fence != drive.batcher.Fence() { + t.Fatalf("send proof = %s/%d, drive = %d", id, fence, drive.batcher.Fence()) + } +} + +func TestAnExpiredHoldCannotAuthorizeTools(t *testing.T) { + r := newDriveRig(t) + sealed, err := r.engine.Seal(t.Context(), r.cell, cellstore.TurnInfo{}) + mustNil(t, err) + _, err = r.a.Dir.Create(t.Context(), r.cell.ID, directory.CellInit{Head: sealed.Turn.ID}) + mustNil(t, err) + drive, err := r.a.Drive(t.Context(), r.engine, r.cell, DriveOptions{}) + mustNil(t, err) + defer drive.Close(t.Context()) + r.clock.Advance(directory.LeaseTTL + time.Second) + mustNil(t, drive.CheckLease(t.Context())) + if drive.Gate() == nil { + t.Fatal("expired ownership still authorizes tools") + } +} + +func TestReleasedOlderHeadIsNotAcquiredOverLocalWork(t *testing.T) { + r := newDriveRig(t) + first, err := r.engine.Seal(t.Context(), r.cell, cellstore.TurnInfo{}) + mustNil(t, err) + created, err := r.a.Dir.Create(t.Context(), r.cell.ID, directory.CellInit{Head: first.Turn.ID}) + mustNil(t, err) + mustNil(t, r.a.Dir.Release(t.Context(), r.cell.ID, created.Cell.Lease.Fence)) + _, err = r.engine.Seal(t.Context(), r.cell, cellstore.TurnInfo{}) + mustNil(t, err) + drive, err := r.a.Drive(t.Context(), r.engine, r.cell, DriveOptions{}) + mustNil(t, err) + defer drive.Close(t.Context()) + if drive.Gate() == nil { + t.Error("a released older head was silently acquired over local work") + } +} diff --git a/internal/syncsetup/sendlease.go b/internal/syncsetup/sendlease.go new file mode 100644 index 0000000000..2c2de2f96a --- /dev/null +++ b/internal/syncsetup/sendlease.go @@ -0,0 +1,81 @@ +package syncsetup + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + + "github.com/Agent-Field/codeaf/internal/fsreplace" +) + +type sendLease struct { + Cell string `json:"cell"` + Fence uint64 `json:"fence"` +} + +func (s *Sync) sendLeasePath(id string) string { + key := sha256.Sum256([]byte(s.Ledger + "/" + s.Device.ID() + "/" + id)) + return filepath.Join(s.Home, "v3", "sync", "holds", hex.EncodeToString(key[:])) +} + +// SendLease names the hold the local session actually publishes under. It +// stays outside the saved chat so restoring another computer's copy cannot +// grant this window permission to send. +func (s *Sync) SendLease(id string) (string, uint64, error) { + current, err := s.currentDisplaySync() + if err != nil { + return "", 0, err + } + if current != s { + return current.SendLease(id) + } + raw, err := os.ReadFile(s.sendLeasePath(id)) + if err != nil { + return "", 0, err + } + var held sendLease + err = json.Unmarshal(raw, &held) + return held.Cell, held.Fence, err +} + +func (d *Drive) recordSendLease() error { + fence := d.batcher.Fence() + if fence == 0 || fence == d.recordedFence { + return nil + } + if err := d.sync.recordSendLease(d.cell.ID, sendLease{Cell: d.directoryID, Fence: fence}); err != nil { + return err + } + d.recordedFence = fence + return nil +} + +func (s *Sync) recordSendLease(id string, held sendLease) error { + raw, err := json.Marshal(held) + if err != nil { + return err + } + path := s.sendLeasePath(id) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".hold-*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + _, writeErr := tmp.Write(raw) + closeErr := tmp.Close() + if writeErr != nil { + return writeErr + } + if closeErr != nil { + return closeErr + } + if err := fsreplace.Rename(tmp.Name(), path); err != nil { + return err + } + return nil +} diff --git a/internal/syncsetup/take.go b/internal/syncsetup/take.go index 0ac85197ab..e5c32bb7d5 100644 --- a/internal/syncsetup/take.go +++ b/internal/syncsetup/take.go @@ -132,7 +132,11 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx } lease := view.Cell.Lease // Kept turns may name this device before their separate copy exists here. - if !branchOnly && view.Cell.ParentCell == "" && lease.Device == c.sync.Device.ID() && lease.Expires > view.Now { + known, err := cellstore.KnowsHead(cell.Cell{ID: id, Root: root}, view.Cell.Head) + if err != nil { + return Continued{}, err + } + if !branchOnly && known && view.Cell.ParentCell == "" && lease.Device == c.sync.Device.ID() && lease.Expires > view.Now { opened, err := cell.OpenAt(root, id) if err != nil { return Continued{}, err @@ -175,10 +179,10 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx return Continued{}, err } if err := cellstore.AdoptedFrom(taken.Cell, from); err != nil { - return Continued{}, err + return Continued{}, errors.Join(err, c.sync.Dir.Release(ctx, id, taken.Driving.Fence)) } if err := cellstore.NotePublished(taken.Cell, taken.Driving.Head); err != nil { - return Continued{}, err + return Continued{}, errors.Join(err, c.sync.Dir.Release(ctx, id, taken.Driving.Fence)) } var filed func() defer func() { @@ -190,6 +194,9 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx return Continued{}, errors.Join(err, c.sync.Dir.Release(ctx, id, taken.Driving.Fence)) } taken.Driving.Cell = taken.Cell + if err := c.sync.recordSendLease(taken.Cell.ID, sendLease{Cell: id, Fence: taken.Driving.Fence}); err != nil { + return Continued{}, errors.Join(err, c.sync.Dir.Release(ctx, id, taken.Driving.Fence)) + } out := Continued{Taken: taken, Device: c.opt.DeviceName, TaskCopies: c.restoreCopies(taken.Cell), Resume: c.arrive(taken.Cell, from)} if taken.Kept != "" { out.KeptTurns = c.orphanTurns(ctx, taken.Kept) @@ -581,7 +588,7 @@ func (s *Sync) Driving(ctx context.Context, c cell.Cell) (*cellsync.Driving, err if err != nil { return nil, err } - if v.Cell.Lease.Device == s.Device.ID() { + if v.Cell.Lease.Expires > v.Now && v.Cell.Lease.Device == s.Device.ID() { d.Fence, d.Head = v.Cell.Lease.Fence, v.Cell.Head } return d, nil diff --git a/internal/tui3/homepanel_continue.go b/internal/tui3/homepanel_continue.go index 1ae53c116f..1402c8acf9 100644 --- a/internal/tui3/homepanel_continue.go +++ b/internal/tui3/homepanel_continue.go @@ -38,6 +38,8 @@ import ( // holds edits made here which the takeover set aside before it fetched, with // how many turns it holds and the name of this machine. type Taken struct { + Fence uint64 + Head string // AlreadyHere preserves the attached session when this device already holds it. AlreadyHere bool Kept string @@ -152,6 +154,10 @@ var remoteOffers = map[chatlist.OfferKind]func(*app, chatlist.Row, chatlist.Offe // homeMachineEnter is enter on a chat from another machine. func (a *app) homeMachineEnter(line homeLine) tea.Cmd { + if a.machineRead.computerRemoved { + a.home.say(chatlist.Removed, "") + return nil + } row := *line.remote offer := chatlist.OfferFor(row) run, ok := remoteOffers[offer.Kind] @@ -196,6 +202,10 @@ func (a *app) askContinue(row chatlist.Row) tea.Cmd { } func (a *app) raiseContinue(row chatlist.Row, at int) tea.Cmd { + if a.machineRead.computerRemoved { + a.home.say(chatlist.Removed, "") + return nil + } if a.sayCellsUnavailable() { return nil } @@ -251,11 +261,16 @@ type homeTakenMsg struct { taken Taken err error draft *composerState + send func() tea.Cmd } // takeRemote asks the taker, off the update loop: a takeover fetches and waits // on the directory, and a keystroke may not wait on a network. func (a *app) takeRemote(row chatlist.Row) tea.Cmd { + if a.machineRead.computerRemoved { + a.home.say(chatlist.Removed, "") + return nil + } a.home.say(continuing, "") taker, ctx := a.taker, a.ctx var draft *composerState @@ -266,7 +281,7 @@ func (a *app) takeRemote(row chatlist.Row) tea.Cmd { draft = &composer } return func() tea.Msg { - if conditional, ok := taker.(conditionalTaker); ok && row.DeviceID != "" { + if conditional, ok := taker.(conditionalTaker); ok { taken, err := conditional.TakeSeen(ctx, row.Cell, &directory.LeaseExpectation{Device: row.DeviceID, Fence: row.Fence}) return homeTakenMsg{row: row, taken: taken, err: err, draft: draft} } @@ -278,6 +293,12 @@ func (a *app) takeRemote(row chatlist.Row) tea.Cmd { // tookTakeover files the answer. A takeover that failed leaves the row where it // was, and one line says why; one that worked opens the chat. func (a *app) tookTakeover(msg homeTakenMsg) tea.Cmd { + var failure string + if msg.err != nil { + failure = a.failedTake(msg) + a.restoreTakeDraft(msg) + a.note(failure) + } switch { case errors.Is(msg.err, directory.ErrLeaseHeld): return a.lostRace(msg.row) @@ -287,8 +308,7 @@ func (a *app) tookTakeover(msg homeTakenMsg) tea.Cmd { case errors.Is(msg.err, directory.ErrConditionalTake): a.home.say(directory.ErrConditionalTake.Error(), "") case msg.err != nil: - // Say why: the bare sentence left a failing takeover undiagnosable. - a.home.say(continueFails+" · "+firstLine(msg.err.Error()), "") + a.home.say(failure, "") default: return a.openTaken(msg) } @@ -334,11 +354,20 @@ func (a *app) rebuildMachines() { // the one they are arriving in. A shared window opens in place, and the ride // is spent here rather than at the landing. func (a *app) openTaken(msg homeTakenMsg) tea.Cmd { + if a.machineRead.sendHolds == nil { + a.machineRead.sendHolds = map[string]sendHold{} + } + previous := a.machineRead.sendHolds[msg.row.Cell] + if previous.failed || (previous.fence != 0 && previous.fence != msg.taken.Fence) { + msg.taken.AlreadyHere = false + } + a.machineRead.sendHolds[msg.row.Cell] = sendHold{fence: msg.taken.Fence} rows := append([]chatlist.Row(nil), a.machineRead.rows...) for i := range rows { if rows[i].Cell == msg.row.Cell { rows[i].Mine, rows[i].Status, rows[i].Released = true, chatlist.Here, false - rows[i].Device, rows[i].DeviceID, rows[i].Fence = msg.taken.Device, "", 0 + rows[i].Device, rows[i].DeviceID, rows[i].Fence = msg.taken.Device, "", msg.taken.Fence + rows[i].Head = msg.taken.Head } } a.machineRead.rows = rows @@ -352,6 +381,7 @@ func (a *app) openTaken(msg homeTakenMsg) tea.Cmd { } line, ok := a.takenLine(msg) if !ok { + a.restoreTakeDraft(msg) a.home.say(joinSaid(moved, kept), "") return a.askMachines() } @@ -373,6 +403,9 @@ func (a *app) openTaken(msg homeTakenMsg) tea.Cmd { if a.file == line.row.Transcript { a.offerSetup(msg.taken.Resume) } + if msg.send != nil { + return msg.send() + } return nil } var cmd tea.Cmd @@ -383,8 +416,12 @@ func (a *app) openTaken(msg homeTakenMsg) tea.Cmd { } else { cmd = a.homeOpenLine(line) } + if a.conversationOpening { + return tea.Batch(cmd, a.askMachines()) + } switch { case cmd == nil && a.at(pageHome): + a.restoreTakeDraft(msg) // THE OPEN REFUSED and home still stands: the sentence has no // conversation to arrive in, so home itself says the takeover // happened, the way it says every row's refusal. @@ -402,6 +439,17 @@ func (a *app) openTaken(msg homeTakenMsg) tea.Cmd { return tea.Batch(cmd, a.askMachines()) } +func (a *app) restoreTakeDraft(msg homeTakenMsg) { + if msg.draft == nil { + return + } + if a.composerOwner == mainRecipient { + a.putComposer(*msg.draft) + } else { + a.keepComposer(mainRecipient, *msg.draft) + } +} + // takenLine is the line of the chat a takeover just materialized: the one home // lists, else one built from where the takeover put it, so the chat opens // whether or not home lists its folder. diff --git a/internal/tui3/homepanel_heldelsewhere.go b/internal/tui3/homepanel_heldelsewhere.go index 20b4395169..8b8ada9105 100644 --- a/internal/tui3/homepanel_heldelsewhere.go +++ b/internal/tui3/homepanel_heldelsewhere.go @@ -2,13 +2,17 @@ package tui3 import ( "context" + "errors" "path/filepath" "slices" "strings" tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" "github.com/Agent-Field/codeaf/internal/session" ) @@ -21,7 +25,7 @@ import ( // // A submitted message asks once off the loop before choosing the door, because // the cached holder may have changed. The draft stays intact until the answer -// arrives; an unreachable reading keeps the existing local availability rules. +// arrives; an unreachable reading leaves those words unsent. // heldElsewhere is the row of the chat on screen when another machine holds it. func (a *app) heldElsewhere() (chatlist.Row, bool) { @@ -30,38 +34,38 @@ func (a *app) heldElsewhere() (chatlist.Row, bool) { } cell := filepath.Base(session.DirOf(a.file)) for _, row := range a.machineRead.rows { - if row.Cell == cell && !row.Mine && (row.Status == chatlist.Running || row.Status == chatlist.Off || row.Status == chatlist.Idle) { + if row.Cell == cell && (row.Status != chatlist.Here || a.machineRead.sendHolds[cell].blocked || a.machineRead.sendHolds[cell].fence != row.Fence) { return row, true } } return chatlist.Row{}, false } -// heldElsewhereDoor shows the door and reports that it did, so the caller sends -// nothing. -func (a *app) heldElsewhereDoor() (tea.Cmd, bool) { - row, ok := a.heldElsewhere() - if !ok { - return nil, false - } - if a.taker == nil || a.machineRead.down { - a.note(chatlist.Superseded(row.Device)) - return nil, true - } - cmd := a.openHome() - a.home.pointAt(func(l homeLine) bool { return l.kind == homeMachineRow && l.remote != nil && l.remote.Cell == row.Cell }) - return tea.Batch(cmd, a.askContinue(row)), true -} - // ownershipReadMsg carries one submission's directory reading. The draft and // tray identify what was waiting, so an answer cannot send words the person // changed or move a message into a different conversation. type ownershipReadMsg struct { - file, draft string - chips []chip - reading homeMachinesMsg - resume func() tea.Cmd - first *composerState + file, draft string + chips []chip + reading homeMachinesMsg + resume func() tea.Cmd + first *composerState + head, leaseCell string + fence uint64 + proof bool +} + +type sendHold struct { + fence uint64 + blocked bool + // failed marks a hold whose take did not finish, so its session is a + // copy this window may not keep using even if a later take finds the + // chat already here. + failed bool +} + +type sendLeaseSource interface { + SendLease(string) (string, uint64, error) } // checkBeforeSend closes the interval between another device's acquire and @@ -91,7 +95,25 @@ func (a *app) checkBeforeSendWith(resume func() tea.Cmd) tea.Cmd { ctx, cancel := context.WithTimeout(context.Background(), machinesAskTimeout) defer cancel() rows, version, err := chatlist.RowsAt(ctx, src) - return ownershipReadMsg{file: file, draft: draft, chips: chips, resume: resume, first: first, reading: homeMachinesMsg{rows: rows, version: version, err: err}} + msg := ownershipReadMsg{file: file, draft: draft, chips: chips, resume: resume, first: first, reading: homeMachinesMsg{rows: rows, version: version, err: err}} + id := filepath.Base(session.DirOf(file)) + if local, ok := src.(sendLeaseSource); ok { + msg.proof = true + msg.leaseCell, msg.fence, _ = local.SendLease(id) + } + remote := id + if msg.leaseCell != "" { + remote = msg.leaseCell + } + for _, row := range rows { + if row.Cell == remote && (row.Status == chatlist.Idle || row.Status == chatlist.Off) { + if head, _ := cellstore.Head(cell.Cell{ID: id, Root: session.DirOf(file)}); head != nil { + msg.head = head.Turn.ID + } + break + } + } + return msg } } @@ -123,7 +145,7 @@ func (a *app) afterOwnershipRead(msg ownershipReadMsg) tea.Cmd { next := a.liveComposer() a.putComposer(*msg.first) reading := a.recordMachines(msg.reading) - cmd := msg.resume() + cmd := a.resumeOwnedSend(msg) if a.liveComposer().empty() { a.putComposer(next) } else { @@ -139,5 +161,136 @@ func (a *app) afterOwnershipRead(msg ownershipReadMsg) tea.Cmd { return nil } reading := a.recordMachines(msg.reading) - return tea.Batch(reading, msg.resume()) + return tea.Batch(reading, a.resumeOwnedSend(msg)) +} + +// resumeOwnedSend spends only the submitted gesture after its fresh ownership +// reading. A free chat can resume silently only from the same saved turn. +func (a *app) resumeOwnedSend(msg ownershipReadMsg) tea.Cmd { + if a.machineRead.computerRemoved { + a.note(chatlist.Removed) + return nil + } + id := filepath.Base(session.DirOf(msg.file)) + remote := id + if msg.leaseCell != "" { + remote = msg.leaseCell + } + if msg.reading.err != nil { + // AN UNREACHABLE SERVICE DOES NOT TAKE A HELD CHAT AWAY. The window + // keeps working on a chat whose hold it recorded and last saw as its + // own, and on a chat that was never shared; its turns wait to publish. + // Anything else stays unsent, because this window cannot show it holds + // the chat. + if a.offlineSendHeld(id, remote, msg) { + return msg.resume() + } + a.note(chatlist.Unreachable) + return nil + } + for _, row := range msg.reading.rows { + if row.Cell != remote { + continue + } + held := a.machineRead.sendHolds[id] + fence := held.fence + if msg.proof { + fence = msg.fence + } + if row.Status == chatlist.Here && row.Fence == fence && !held.blocked { + return msg.resume() + } + if !held.blocked && (row.Status == chatlist.Idle || row.Status == chatlist.Off) && row.Head != "" && row.Head == msg.head { + if taker, ok := a.taker.(conditionalTaker); ok { + draft := a.mainComposer() + ctx := a.ctx + return func() tea.Msg { + taken, err := taker.TakeSeen(ctx, row.Cell, &directory.LeaseExpectation{Device: row.DeviceID, Fence: row.Fence}) + return homeTakenMsg{row: row, taken: taken, err: err, draft: &draft, send: msg.resume} + } + } + } + if a.taker == nil { + a.note(chatlist.Superseded(nonemptyDevice(row.Device))) + return nil + } + a.machineRead.sendHolds[id] = sendHold{fence: fence, blocked: true} + cmd := a.openHome() + return tea.Batch(cmd, a.askContinue(row)) + } + if held, known := a.machineRead.sendHolds[id]; held.blocked || (known && held.fence != 0) || (msg.proof && msg.fence != 0) { + a.note(chatlist.Superseded("another device")) + return nil + } + return msg.resume() +} + +// offlineSendHeld judges a send by the last good reading when the fresh one +// could not be made: the chat is this window's own at the fence it recorded, or +// it was never shared and nobody is known to hold it. +func (a *app) offlineSendHeld(id, remote string, msg ownershipReadMsg) bool { + held := a.machineRead.sendHolds[id] + if held.blocked { + return false + } + fence := held.fence + if msg.proof { + fence = msg.fence + } + for _, row := range a.machineRead.rows { + if row.Cell == remote { + return fence != 0 && row.Status == chatlist.Here && row.Fence == fence + } + } + return fence == 0 +} + +func nonemptyDevice(device string) string { + if device == "" { + return "another device" + } + return device +} + +func (a *app) rememberSendHolds(rows []chatlist.Row) { + if a.machineRead.sendHolds == nil { + a.machineRead.sendHolds = map[string]sendHold{} + } + for _, row := range rows { + if _, known := a.machineRead.sendHolds[row.Cell]; !known && row.Status == chatlist.Here { + a.machineRead.sendHolds[row.Cell] = sendHold{fence: row.Fence} + } + } +} + +func (a *app) failedTake(msg homeTakenMsg) string { + if a.machineRead.sendHolds == nil { + a.machineRead.sendHolds = map[string]sendHold{} + } + held := a.machineRead.sendHolds[msg.row.Cell] + held.blocked, held.failed = true, true + a.machineRead.sendHolds[msg.row.Cell] = held + if a.file != "" { + current := filepath.Base(session.DirOf(a.file)) + if current != msg.row.Cell { + returned := a.machineRead.sendHolds[current] + returned.blocked = true + a.machineRead.sendHolds[current] = returned + } + } + switch { + case errors.Is(msg.err, directory.ErrLeaseHeld): + return lostRaceWord + case errors.Is(msg.err, directory.ErrUnreachable): + return chatlist.Unreachable + case errors.Is(msg.err, directory.ErrConditionalTake): + return directory.ErrConditionalTake.Error() + case errors.Is(msg.err, directory.ErrRevoked): + a.removedComputer() + return chatlist.Removed + case strings.Contains(msg.err.Error(), "rewind cancelled"), strings.Contains(msg.err.Error(), "changed during rewind"): + return continueFails + " because the folder changed during the move; your words are still in the box" + default: + return continueFails + " because the chat could not be put in this folder; your words are still in the box. Try continue here again." + } } diff --git a/internal/tui3/homepanel_machines.go b/internal/tui3/homepanel_machines.go index 258a25ea7c..03b955df14 100644 --- a/internal/tui3/homepanel_machines.go +++ b/internal/tui3/homepanel_machines.go @@ -2,12 +2,14 @@ package tui3 import ( "context" + "errors" "sort" "time" tea "charm.land/bubbletea/v2" "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" ) // ── CHATS ON OTHER MACHINES, IN THE SESSIONS PANEL ────────────────────────── @@ -30,7 +32,9 @@ import ( // last ask failed. A failed ask keeps the rows: the list stays on screen, dim, // under `other machines unreachable`. type machineReading struct { - rows []chatlist.Row + sendHolds map[string]sendHold + computerRemoved bool + rows []chatlist.Row // at is when the listing was read. Each row's age is counted from it, so a // row keeps ageing on the clock between two asks instead of standing still. at time.Time @@ -95,12 +99,24 @@ func (a *app) tookMachines(msg homeMachinesMsg) tea.Cmd { // recordMachines also accepts a send's independent read without ending an // outstanding listing request that still owes its own answer. func (a *app) recordMachines(msg homeMachinesMsg) tea.Cmd { + if errors.Is(msg.err, directory.ErrRevoked) { + a.removedComputer() + } + if msg.err == nil && a.machineRead.computerRemoved { + if source, ok := a.machines.(interface{ Removed() bool }); ok && !source.Removed() { + a.machineRead.computerRemoved = false + a.homeAccountNotice = "" + } + } + if msg.err == nil { + a.rememberSendHolds(msg.rows) + } before, wasElsewhere := a.heldElsewhere() a.fileMachines(msg) if row, elsewhere := a.heldElsewhere(); msg.err == nil && elsewhere && (!wasElsewhere || before.DeviceID != row.DeviceID || before.Device != row.Device) { - a.note(chatlist.Superseded(row.Device)) + a.note(chatlist.Superseded(nonemptyDevice(row.Device))) } - a.machineRead.down = msg.err != nil + a.machineRead.down = msg.err != nil && !a.machineRead.computerRemoved a.machineRead.merge = a.branches.Merge != nil a.machineRead.separate = a.branches.Open != nil a.considerResume() diff --git a/internal/tui3/input.go b/internal/tui3/input.go index ab908cb12c..74f492e4b5 100644 --- a/internal/tui3/input.go +++ b/internal/tui3/input.go @@ -1546,11 +1546,6 @@ func (a *app) enterCheckedLine(ownershipRead bool) tea.Cmd { if !ownershipRead && a.watchesConversation() { return a.checkBeforeSend() } - if ownershipRead { - if door, shown := a.heldElsewhereDoor(); shown { - return door - } - } } // A MODEL MESSAGE THAT STILL NEEDS THE DEFAULT PROVIDER OPENS ITS CONNECTION // BEFORE THE DRAFT IS CLEARED. This is the returning half of the key gate: a diff --git a/internal/tui3/machinepoll.go b/internal/tui3/machinepoll.go index 1a698dda32..c9136220ea 100644 --- a/internal/tui3/machinepoll.go +++ b/internal/tui3/machinepoll.go @@ -126,6 +126,6 @@ func (a *app) fileMachines(msg homeMachinesMsg) { changed := msg.err == nil && !chatlist.Same(a.machineRead.rows, msg.rows) a.machinePoll.landed(now, changed, a.socketUp() && msg.err == nil) if msg.err == nil { - a.machineRead = machineReading{rows: msg.rows, at: now, version: msg.version} + a.machineRead = machineReading{rows: msg.rows, at: now, version: msg.version, sendHolds: a.machineRead.sendHolds, computerRemoved: a.machineRead.computerRemoved} } } diff --git a/internal/tui3/removed_computer.go b/internal/tui3/removed_computer.go new file mode 100644 index 0000000000..9c86d909a4 --- /dev/null +++ b/internal/tui3/removed_computer.go @@ -0,0 +1,13 @@ +package tui3 + +import "github.com/Agent-Field/codeaf/internal/chatlist" + +func (a *app) removedComputer() { + a.machineRead.computerRemoved = true + a.leftOff.row = nil + a.continueGeneration++ + a.dropHomeAsk() + a.homeAccountNotice = chatlist.Removed + a.home.say(chatlist.Removed, "") + a.touch() +} diff --git a/internal/tui3/send_ownership_safety_test.go b/internal/tui3/send_ownership_safety_test.go new file mode 100644 index 0000000000..c5a65a1d2b --- /dev/null +++ b/internal/tui3/send_ownership_safety_test.go @@ -0,0 +1,152 @@ +package tui3 + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/session" +) + +func TestSendRequiresTheWindowsCurrentHold(t *testing.T) { + for _, state := range []struct { + name string + status chatlist.Status + mine bool + fence uint64 + }{ + {"free newer copy", chatlist.Idle, true, 2}, + {"expired own copy", chatlist.Off, true, 2}, + {"returned with another hold", chatlist.Here, true, 3}, + } { + t.Run(state.name, func(t *testing.T) { + src := &continuationListing{row: chatlist.Row{Cell: heldCell, Status: chatlist.Here, Mine: true, Fence: 2}} + a, agent := heldLab(t, src, &fakeTaker{}) + a.closeHome() + src.change(func(r *chatlist.Row) { r.Status, r.Mine, r.Fence = state.status, state.mine, state.fence }) + src.change(func(r *chatlist.Row) { r.Head = "newer" }) + a.input.setText("keep this unsent") + drive(t, a, key("enter")) + if len(agent.sent) != 0 { + t.Errorf("unheld copy ran a turn: %v", agent.sent) + } + if _, card := a.homeAsking(); !card && !strings.Contains(plain(lastNote(t, a)), "only shows it") { + t.Error("unheld copy has neither a continuation card nor a viewing explanation") + } + if got := string(a.mainComposer().box.value); got != "keep this unsent" { + t.Errorf("draft = %q", got) + } + }) + } +} + +type windowProofSource struct{ *continuationListing } + +func (*windowProofSource) SendLease(string) (string, uint64, error) { return heldCell, 1, nil } + +func TestFreshReadRejectsAnotherWindowsHold(t *testing.T) { + src := &windowProofSource{&continuationListing{row: chatlist.Row{Cell: heldCell, Status: chatlist.Here, Mine: true, Fence: 2}}} + a, agent := heldLab(t, src, &fakeTaker{}) + a.closeHome() + a.input.setText("still unsent") + drive(t, a, key("enter")) + if len(agent.sent) != 0 { + t.Fatal("a first reading mistook another window's hold for its own") + } + if _, card := a.homeAsking(); !card { + t.Fatal("a mismatched window did not offer continuation") + } +} + +func TestHeldSendUsesOneFreshReading(t *testing.T) { + src := &continuationListing{row: chatlist.Row{Cell: heldCell, Status: chatlist.Here, Mine: true, Fence: 2}} + a, agent := heldLab(t, src, &fakeTaker{}) + a.closeHome() + before := src.reads + a.input.setText("send this") + drive(t, a, key("enter")) + if len(agent.sent) != 1 || src.reads-before != 1 { + t.Fatalf("sends=%v, ownership reads=%d", agent.sent, src.reads-before) + } +} + +type returningTaker struct { + fakeTaker + seen *directory.LeaseExpectation +} + +func (t *returningTaker) TakeSeen(ctx context.Context, id string, seen *directory.LeaseExpectation) (Taken, error) { + t.seen = seen + return t.Take(ctx, id) +} + +func TestLapsedCurrentChatSendsAfterAConditionalReturn(t *testing.T) { + src := &continuationListing{row: chatlist.Row{Cell: heldCell, Status: chatlist.Off, Mine: true, DeviceID: "dev_here", Fence: 2, Head: "saved"}} + taker := &returningTaker{} + a, old := heldLab(t, src, taker) + a.closeHome() + root := session.DirOf(a.file) + if err := os.MkdirAll(filepath.Join(root, ".cell"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".cell", "adopted.json"), []byte(`{"V":1,"head":"saved","turns_size":0}`), 0600); err != nil { + t.Fatal(err) + } + taker.taken = Taken{Transcript: a.file, Fence: 3, Head: "saved"} + taker.on = func(string) { src.change(func(r *chatlist.Row) { r.Status, r.Fence = chatlist.Here, 3 }) } + a.input.setText("send after waking") + drive(t, a, key("enter")) + if taker.seen == nil || taker.seen.Device != "dev_here" || taker.seen.Fence != 2 { + t.Errorf("return did not condition on the observed hold: %+v", taker.seen) + } + if len(old.sent) != 0 { + t.Error("the retired session sent") + } + arrived, ok := a.agent.(*switchAgent) + if !ok || len(arrived.sent) != 1 { + t.Fatalf("conditional return did not send through the reopened chat: %T", a.agent) + } +} + +func TestFailedRestoreCannotSendOnTheReleasedCopy(t *testing.T) { + src := &continuationListing{row: leftOffRow(heldCell, "other", chatlist.Running, time.Minute)} + taker := &fakeTaker{err: errors.New("handoff: engine materialize: rewind cancelled before mutation: workspace path changed during rewind: .git")} + a, agent := heldLab(t, src, taker) + a.closeHome() + a.input.setText("keep this unsent") + drive(t, a, key("enter")) + taker.on = func(string) { src.change(func(r *chatlist.Row) { r.Status, r.Mine = chatlist.Idle, true }) } + confirm(t, a) + said := homeText(a) + if !strings.Contains(said, "folder changed") || strings.Contains(said, "engine materialize") { + t.Errorf("failure did not give a plain reason: %s", said) + } + a.closeHome() + a.input.setText("try again") + drive(t, a, key("enter")) + if len(agent.sent) != 0 { + t.Errorf("failed take's released copy ran a turn: %v", agent.sent) + } +} + +func TestHeldAndUnpublishedChatsStillSend(t *testing.T) { + for _, published := range []bool{false, true} { + var rows chatlist.Static + if published { + rows = chatlist.Static{{Cell: heldCell, Status: chatlist.Here, Mine: true, Fence: 2}} + } + a, agent := heldLab(t, rows, &fakeTaker{}) + a.closeHome() + a.input.setText("send this") + drive(t, a, key("enter")) + if len(agent.sent) != 1 { + t.Errorf("published=%v: sent %v", published, agent.sent) + } + } +} From ac3d3f32efdc1777cd53bc911f1e7e8f50642971 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 15:00:06 -0400 Subject: [PATCH 16/23] Keep the whole draft when continuing a chat fails The draft captured when a take started was restored only on success, so a lost race, a refused or unreachable take, or a failed in-place restore lost the words typed behind the card. Every failure path now puts the draft back in the box, unsent, and the manual says what a failed move leaves behind. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../continuing-a-chat-on-another-computer.md | 13 +++++ internal/tui3/take_failure_draft_test.go | 53 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 internal/tui3/take_failure_draft_test.go 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 d687fd67c9..fd7cbd5b02 100644 --- a/internal/manual/chat/continuing-a-chat-on-another-computer.md +++ b/internal/manual/chat/continuing-a-chat-on-another-computer.md @@ -295,3 +295,16 @@ Older builds can still list, save, publish and release chats through a compatibl ## Can an older build save a chat with large encrypted metadata? Yes. Directory requests from older builds keep the existing 1 MiB request limit, including large encrypted metadata and frame hints. The service receives the complete signed request before making the change visible. A failed or interrupted save never exposes only part of that metadata. Reconnect and check the latest save before retrying. Encrypted names, key wraps and frame hints are preserved. The identity's storage capacity still applies. + + +## Why did Enter show Continue here instead of sending my message + +Before sending, codeaf checks that this window still holds the chat. If another computer holds it, its latest saved turn is newer, or this window's earlier move failed, Enter opens **Continue this chat here?** or leaves the window showing the chat only. The words stay in the box. Choose **continue here** to bring the saved chat here, then press Enter to send. + +A new chat can send its first message normally. A sleeping computer can also resume sending when nobody else has taken the chat and its local saved turn is still current. That safe return happens without another question. If the ownership check cannot reach your other computers, a chat this window already holds keeps working and its turns wait to be shared; any other message stays unsent. + +## A move that failed partway — are my typed words safe, can the old copy still send + +If something changes the chat's files while a move is putting them in place, codeaf stops the move and lets go of the chat. It says the move could not finish and that your words are still in the box. The window keeps showing the chat or returns to Home, and it cannot send on the old copy. Try **continue here** again. + +Every failed move keeps the captured draft, including its whitespace, caret, pasted text and attachments, even if the old session closed during the move. Losing a race, a connection failure or a refused move does the same. A failed move never sends those words by itself. diff --git a/internal/tui3/take_failure_draft_test.go b/internal/tui3/take_failure_draft_test.go new file mode 100644 index 0000000000..bd3a4e16ae --- /dev/null +++ b/internal/tui3/take_failure_draft_test.go @@ -0,0 +1,53 @@ +package tui3 + +import ( + "errors" + "reflect" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/session" +) + +func TestEveryFailedTakeReturnsTheCapturedComposer(t *testing.T) { + for _, failure := range []struct { + name string + err error + }{ + {"lost race", directory.ErrLeaseHeld}, + {"unreachable", directory.ErrUnreachable}, + {"conditional refusal", directory.ErrConditionalTake}, + {"restore cancelled", errors.New("rewind cancelled before mutation: workspace path changed during rewind: .git")}, + } { + t.Run(failure.name, func(t *testing.T) { + src := &continuationListing{row: leftOffRow(heldCell, "other", chatlist.Running, time.Minute)} + taker := &fakeTaker{err: failure.err} + a, old := heldLab(t, src, taker) + a.closeHome() + a.input.setText(" keep " + pasteToken(1, 3) + "\nall these words ") + a.input.cursor = 5 + a.pastes = []pasteChip{{n: 1, text: "pasted\nbody\nhere"}} + a.chips = []chip{{path: "/tmp/l2lfix-picture.png"}, {path: "/tmp/l2lfix-file.txt", file: true}} + want := a.mainComposer() + drive(t, a, key("enter")) + taker.on = func(string) { + spend(t, a, a.taskEvent(session.Event{Kind: session.EventTakeover, Text: session.TakeoverWord})) + } + confirm(t, a) + if got := a.mainComposer(); !reflect.DeepEqual(got, want) { + t.Errorf("returned composer = %+v, want %+v", got, want) + } + if len(old.sent) != 0 { + t.Errorf("failure sent words: %v", old.sent) + } + if a.at(pageHome) { + a.closeHome() + } + if got := a.liveComposer(); !reflect.DeepEqual(got, want) { + t.Errorf("visible composer = %+v, want %+v", got, want) + } + }) + } +} From 61b333a4b0fd3c9278c6790493ad3d9ac6f44837 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 15:00:07 -0400 Subject: [PATCH 17/23] Offer nothing about continuing on a computer that was removed A removed computer's Home still offered "Continue 's latest chat here?" with its chord. Once this computer knows it was removed, Home drops the left-off offer, the continue chord and the continue action on rows, and keeps the removal sentence. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/syncsetup/removed.go | 7 ++++ internal/tui3/homeband_devices.go | 3 ++ internal/tui3/homeband_resume.go | 11 +++++- internal/tui3/notices.go | 9 ++++- internal/tui3/removed_continuation_test.go | 44 ++++++++++++++++++++++ 5 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 internal/tui3/removed_continuation_test.go diff --git a/internal/syncsetup/removed.go b/internal/syncsetup/removed.go index efd1051794..d6885de206 100644 --- a/internal/syncsetup/removed.go +++ b/internal/syncsetup/removed.go @@ -30,6 +30,13 @@ func (s *Sync) isRemoved() bool { return err == nil } +// Removed follows the current pairing so an open Home can offer moves again +// after this computer has been added back. +func (s *Sync) Removed() bool { + current, err := s.currentDisplaySync() + return err != nil || current.isRemoved() +} + // rememberRemoved stops this handle immediately and shares that knowledge // with other windows and later launches. Failure to save the marker cannot // make a live handle resume sending requests. diff --git a/internal/tui3/homeband_devices.go b/internal/tui3/homeband_devices.go index d6a556fefa..9d4cb299c4 100644 --- a/internal/tui3/homeband_devices.go +++ b/internal/tui3/homeband_devices.go @@ -139,6 +139,9 @@ func drawDevicesBand(a *app, ctx bandContext) []string { // devicePicks are the chats an online device offers: for each, the newest one it // holds that can be brought here, in the roster's order. func (a *app) devicePicks() []chatlist.Row { + if a.machineRead.computerRemoved { + return nil + } online, known := a.presence() if !known { return nil diff --git a/internal/tui3/homeband_resume.go b/internal/tui3/homeband_resume.go index 7aaf11a71f..dd360c80c6 100644 --- a/internal/tui3/homeband_resume.go +++ b/internal/tui3/homeband_resume.go @@ -120,7 +120,7 @@ func (s resumeState) away(row chatlist.Row) bool { func (a *app) considerResume() { r := &a.leftOff var row *chatlist.Row - if a.taker != nil { + if a.taker != nil && !a.machineRead.computerRemoved { row = a.resumeState().pick() } if !sameOffer(r.row, row) { @@ -149,6 +149,9 @@ func sameOffer(x, y *chatlist.Row) bool { // resumeKeyHandler claims the card's two chords where the card stands. func (a *app) resumeKeyHandler(key string) (tea.Cmd, bool) { + if a.machineRead.computerRemoved { + return nil, false + } row := a.leftOff.row if row == nil { return nil, false @@ -170,6 +173,9 @@ func (a *app) resumeKeyHandler(key string) (tea.Cmd, bool) { } func drawResumeBand(a *app, ctx bandContext) []string { + if a.machineRead.computerRemoved { + return nil + } row := a.leftOff.row if row == nil { return nil @@ -240,6 +246,9 @@ func (a *app) continueAfterQuit(err error) tea.Cmd { } func (a *app) continuationReady(msg continuationChecked) tea.Cmd { + if a.machineRead.computerRemoved { + return nil + } if msg.automatic && msg.generation != a.continueGeneration { return nil } diff --git a/internal/tui3/notices.go b/internal/tui3/notices.go index eb9a6b2763..25162833c2 100644 --- a/internal/tui3/notices.go +++ b/internal/tui3/notices.go @@ -1,6 +1,10 @@ package tui3 -import "sync" +import ( + "sync" + + "github.com/Agent-Field/codeaf/internal/chatlist" +) // ── THE NOTICE SEAM: A SENTENCE FROM OUTSIDE THE LOOP, SHOWN AT ONCE ──────── // @@ -124,6 +128,9 @@ func (a *app) useNotices(desk *Notices) { // loop on the door again. func (a *app) saidNotices() { for _, line := range a.outsideDesk.take() { + if line.text == chatlist.Removed { + a.removedComputer() + } if line.home { a.homeAccountNotice = firstLine(line.text) } diff --git a/internal/tui3/removed_continuation_test.go b/internal/tui3/removed_continuation_test.go new file mode 100644 index 0000000000..4b2c3d02c9 --- /dev/null +++ b/internal/tui3/removed_continuation_test.go @@ -0,0 +1,44 @@ +package tui3 + +import ( + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" +) + +func TestRemovedComputerOffersNoContinuation(t *testing.T) { + for _, notice := range []bool{false, true} { + a := continueHome(t, &fakeTaker{}, chatlist.Static{leftOffRow("away", "other", chatlist.Off, time.Minute)}, 200) + a.considerResume() + if a.leftOff.row == nil { + t.Fatal("fixture has no continuation offer") + } + if notice { + a.useNotices(NewNotices()) + a.outsideDesk.SayHomeOnce(chatlist.Removed) + a.saidNotices() + } else { + drain(t, a, a.recordMachines(homeMachinesMsg{err: directory.ErrRevoked})) + } + text := homeText(a) + if strings.Contains(text, "latest chat here?") || strings.Contains(text, "continue here") { + t.Errorf("removed computer offered continuing: %s", text) + } + if !strings.Contains(text, "this device was removed") { + t.Errorf("removal sentence missing: %s", text) + } + if _, claimed := a.resumeKeyHandler(resumeKey); claimed { + t.Error("removed computer claimed the continue chord") + } + standOn(t, a, "away") + if _, card := a.homeAsking(); card { + t.Error("removed computer raised a continuation card") + } + if taker := a.taker.(*fakeTaker); len(taker.cells) != 0 { + t.Error("removed computer tried to take a chat") + } + } +} From 4c7ccbd1b3b44f80bdaa84a99cd1cd5a484121cf Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 15:00:07 -0400 Subject: [PATCH 18/23] Explain a removed computer's stopped syncing without the word relay The device pages keep to the words people use for their computers; the removal paragraph now says the computer stops asking the other computers for chats, and that pairing can still reach the service. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/manual/chat/add-or-remove-a-computer.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/manual/chat/add-or-remove-a-computer.md b/internal/manual/chat/add-or-remove-a-computer.md index eb7904f0df..b7e9e33523 100644 --- a/internal/manual/chat/add-or-remove-a-computer.md +++ b/internal/manual/chat/add-or-remove-a-computer.md @@ -215,6 +215,6 @@ blocks its later requests; it cannot take back bytes the device already received ## Does a removed computer keep syncing while its window is open -Once codeaf learns that this computer was removed, it stops asking the relay for chats, sending heartbeats and reconnecting its watches or coordination. That refusal is remembered across windows and restarts. Your window stays usable for local chats. +Once codeaf learns that this computer was removed, it stops asking your other computers for chats and stops sending updates. That refusal is remembered across windows and restarts. Your window stays usable for local chats. Home removes its offer to continue another computer's chat, its continue chord and the continue action on chat rows. The removal explanation stays visible. -Run `codeaf pair` to add the computer again. Pairing can still contact the relay and installs a new device key. An already open Home can then show your other computers again. +Run `codeaf pair` to add the computer again. Pairing can still reach the service and gives this computer a new device key. An already open Home can then show your other computers again. From 09983a2a676691f69bdd58abc2d655580e9fec1c Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 15:00:11 -0400 Subject: [PATCH 19/23] Pace the removal traffic test's heartbeats until the removal is known The test drove the drive's heartbeat every 10 ms from the start, which queued relay commits ahead of the member's first catch-up. On a slow disk the member round waited minutes, and the request signed before that wait was refused as clock skew. The heartbeat now keeps an ordinary pace until the removal is learned and only then runs fast, so the observation still spans many heartbeat intervals. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/syncsetup/revoked_traffic_test.go | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/internal/syncsetup/revoked_traffic_test.go b/internal/syncsetup/revoked_traffic_test.go index 4902a797cc..9ca8423f96 100644 --- a/internal/syncsetup/revoked_traffic_test.go +++ b/internal/syncsetup/revoked_traffic_test.go @@ -39,10 +39,18 @@ func TestRemovedHomeStopsTrafficUntilPairedAgain(t *testing.T) { device.Store(h.a.Device.ID()) ctx, cancel := context.WithCancel(t.Context()) defer cancel() - // The accelerated sleeper represents repeated heartbeat intervals. + // The accelerated sleeper represents repeated heartbeat intervals. Until + // the removal is known it keeps an ordinary pace, so heartbeats do not + // queue up commits ahead of the member's first catch-up; afterwards it + // runs fast, so the observation below spans many heartbeat intervals. + var fast atomic.Bool options := DriveOptions{Sleep: func(ctx context.Context, _ time.Duration) error { + wait := 250 * time.Millisecond + if fast.Load() { + wait = 10 * time.Millisecond + } select { - case <-time.After(10 * time.Millisecond): + case <-time.After(wait): return nil case <-ctx.Done(): return ctx.Err() @@ -86,9 +94,11 @@ func TestRemovedHomeStopsTrafficUntilPairedAgain(t *testing.T) { if _, err := h.a.Dir.List(ctx); !errors.Is(err, directory.ErrRevoked) { t.Fatalf("removal was not learned: %v", err) } - // Let requests already in flight finish, then sample many accelerated - // poll, state and heartbeat intervals while the window remains open. - time.Sleep(30 * time.Millisecond) + // Let requests already in flight finish and the heartbeat reach its fast + // pace, then sample many accelerated poll, state and heartbeat intervals + // while the window remains open. + fast.Store(true) + time.Sleep(300 * time.Millisecond) before := requests.Load() time.Sleep(250 * time.Millisecond) if extra := requests.Load() - before; extra != 0 { From f2d4c46a95172f8dd9d5693bc5db1b62b83ccb80 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 15:25:46 -0400 Subject: [PATCH 20/23] Announce a displaced chat only when another computer took it After every take the window that took the chat said " continued this chat; this window now only shows it". The take's acquire is followed by the arrived drive's own acquire, which raises the fence, and the window compared the listing's fence with the hold it recorded at the take. The window's recorded hold now follows its own drive's publishing receipt, so a newer fence held by this computer for this window is still this window's hold. The notice and the card come only from a hold by another computer; a stale local receipt still cannot send. The second acquire stays: it is the boundary that keeps two local drives from publishing under one fence. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/tui3/homepanel_heldelsewhere.go | 15 +++-- internal/tui3/homepanel_machines.go | 21 +++++-- internal/tui3/taken_drive_hold_test.go | 78 ++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 10 deletions(-) create mode 100644 internal/tui3/taken_drive_hold_test.go diff --git a/internal/tui3/homepanel_heldelsewhere.go b/internal/tui3/homepanel_heldelsewhere.go index 8b8ada9105..3accbf6e0a 100644 --- a/internal/tui3/homepanel_heldelsewhere.go +++ b/internal/tui3/homepanel_heldelsewhere.go @@ -34,7 +34,7 @@ func (a *app) heldElsewhere() (chatlist.Row, bool) { } cell := filepath.Base(session.DirOf(a.file)) for _, row := range a.machineRead.rows { - if row.Cell == cell && (row.Status != chatlist.Here || a.machineRead.sendHolds[cell].blocked || a.machineRead.sendHolds[cell].fence != row.Fence) { + if row.Cell == cell && heldElsewhere(row) { return row, true } } @@ -100,6 +100,7 @@ func (a *app) checkBeforeSendWith(resume func() tea.Cmd) tea.Cmd { if local, ok := src.(sendLeaseSource); ok { msg.proof = true msg.leaseCell, msg.fence, _ = local.SendLease(id) + msg.reading.holdCell, msg.reading.holdFence = id, msg.fence } remote := id if msg.leaseCell != "" { @@ -252,12 +253,18 @@ func nonemptyDevice(device string) string { return device } -func (a *app) rememberSendHolds(rows []chatlist.Row) { +func (a *app) rememberSendHolds(msg homeMachinesMsg) { if a.machineRead.sendHolds == nil { a.machineRead.sendHolds = map[string]sendHold{} } - for _, row := range rows { - if _, known := a.machineRead.sendHolds[row.Cell]; !known && row.Status == chatlist.Here { + for _, row := range msg.rows { + held, known := a.machineRead.sendHolds[row.Cell] + if row.Status != chatlist.Here || held.blocked || held.failed { + continue + } + // The directory can show another local window's fence. Only the + // publishing receipt lets this window follow its own drive's new hold. + if !known || (msg.holdCell == row.Cell && msg.holdFence != 0 && msg.holdFence == row.Fence) { a.machineRead.sendHolds[row.Cell] = sendHold{fence: row.Fence} } } diff --git a/internal/tui3/homepanel_machines.go b/internal/tui3/homepanel_machines.go index 03b955df14..8a51ac722e 100644 --- a/internal/tui3/homepanel_machines.go +++ b/internal/tui3/homepanel_machines.go @@ -3,6 +3,7 @@ package tui3 import ( "context" "errors" + "path/filepath" "sort" "time" @@ -10,6 +11,7 @@ import ( "github.com/Agent-Field/codeaf/internal/chatlist" "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/session" ) // ── CHATS ON OTHER MACHINES, IN THE SESSIONS PANEL ────────────────────────── @@ -54,9 +56,11 @@ type machineReading struct { // homeMachinesMsg is one listing, coming BACK from the source. type homeMachinesMsg struct { - rows []chatlist.Row - version uint64 - err error + rows []chatlist.Row + version uint64 + err error + holdCell string + holdFence uint64 } // machinesAskTimeout bounds one ask, so a silent relay is unreachable rather @@ -76,12 +80,17 @@ func (a *app) askChats() tea.Cmd { return nil } a.machinesAsking = true - src := a.machines + src, file := a.machines, a.file return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), machinesAskTimeout) defer cancel() rows, version, err := chatlist.RowsAt(ctx, src) - return homeMachinesMsg{rows: rows, version: version, err: err} + msg := homeMachinesMsg{rows: rows, version: version, err: err} + if local, ok := src.(sendLeaseSource); ok && file != "" { + msg.holdCell = filepath.Base(session.DirOf(file)) + _, msg.holdFence, _ = local.SendLease(msg.holdCell) + } + return msg } } @@ -109,7 +118,7 @@ func (a *app) recordMachines(msg homeMachinesMsg) tea.Cmd { } } if msg.err == nil { - a.rememberSendHolds(msg.rows) + a.rememberSendHolds(msg) } before, wasElsewhere := a.heldElsewhere() a.fileMachines(msg) diff --git a/internal/tui3/taken_drive_hold_test.go b/internal/tui3/taken_drive_hold_test.go new file mode 100644 index 0000000000..bae4d5cecf --- /dev/null +++ b/internal/tui3/taken_drive_hold_test.go @@ -0,0 +1,78 @@ +package tui3 + +import ( + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/chatlist" +) + +type arrivingDriveSource struct { + *continuationListing + fence uint64 +} + +func (s *arrivingDriveSource) SendLease(string) (string, uint64, error) { + return heldCell, s.fence, nil +} + +func TestTakenChatFollowsItsLocalDriveLease(t *testing.T) { + for _, holder := range []string{"this drive", "another local drive", "another device"} { + t.Run(holder, func(t *testing.T) { + src := &arrivingDriveSource{continuationListing: &continuationListing{ + row: leftOffRow(heldCell, "other device", chatlist.Running, time.Minute), + }} + src.row.Fence = 10 + taker := &fakeTaker{taken: Taken{Fence: 11, Device: "this device"}} + a, _ := heldLab(t, src, taker) + taker.taken.Transcript = a.file + taker.on = func(string) { + src.fence = 11 + src.change(func(row *chatlist.Row) { + row.Mine, row.Status, row.Fence = true, chatlist.Here, 11 + row.Device, row.DeviceID = "this device", "dev_here" + }) + } + standOn(t, a, heldCell) + confirm(t, a) + if a.at(pageHome) || len(taker.cells) != 1 { + t.Fatal("continuation did not open the taken chat") + } + arrived := a.agent.(*switchAgent).fakeAgent + src.change(func(row *chatlist.Row) { + row.Fence = 12 + if holder == "another device" { + row.Mine, row.Status = false, chatlist.Running + row.Device, row.DeviceID = "other device", "dev_other" + } + }) + if holder == "this drive" { + src.fence = 12 + } + drain(t, a, a.askMachines()) + notice := false + for _, entry := range a.entries { + if entry.kind == entryNote && strings.Contains(plain(entry.text), "this window now only shows it") { + notice = true + } + } + if notice != (holder == "another device") { + t.Errorf("displacement notice = %v for %s", notice, holder) + } + if holder == "this drive" && a.machineRead.sendHolds[heldCell].fence != 12 { + t.Error("the window did not follow its drive's publishing hold") + } + a.input.setText("send after continuing") + drive(t, a, key("enter")) + _, card := a.homeAsking() + if holder == "this drive" { + if card || len(arrived.sent) != 1 { + t.Errorf("held send: card=%v messages=%v", card, arrived.sent) + } + } else if !card || len(arrived.sent) != 0 { + t.Errorf("displaced send: card=%v messages=%v", card, arrived.sent) + } + }) + } +} From 9f7be957f25eaf324ef766ae5930341011c3419d Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 15:25:46 -0400 Subject: [PATCH 21/23] Keep each chat's draft with that chat when continuing from Home A take captured whatever the window's main box held and restored it into the chat that arrived, so an unsent draft typed for another chat appeared in the moved chat's box and was saved as its draft. The arrived chat's box now holds only the words typed for it: the draft held behind the card its own send raised, otherwise its own saved draft, otherwise nothing. A draft typed for another chat stays with that chat, including when a take fails. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../unreleased/1967-two-computer-fixes.md | 2 + .../continuing-a-chat-on-another-computer.md | 2 + internal/tui3/draft.go | 10 +- internal/tui3/draftkeep.go | 16 ++- internal/tui3/home_take_draft_owner_test.go | 116 ++++++++++++++++++ internal/tui3/homepanel_continue.go | 8 +- internal/tui3/keeper.go | 2 +- 7 files changed, 144 insertions(+), 12 deletions(-) create mode 100644 internal/tui3/home_take_draft_owner_test.go diff --git a/docs/changes/unreleased/1967-two-computer-fixes.md b/docs/changes/unreleased/1967-two-computer-fixes.md index 268ec9f580..5c75172c22 100644 --- a/docs/changes/unreleased/1967-two-computer-fixes.md +++ b/docs/changes/unreleased/1967-two-computer-fixes.md @@ -13,5 +13,7 @@ invalidates: - "A failed move could leave a free chat sending on an older copy and lose the captured draft. Sending now requires this window's current hold, every failed move restores the whole draft, and an idle computer can return silently only from the current saved turn." - "Background repository reads could refresh the index and interrupt a move into the project folder. Every automatic Git reading now disables optional index locks." - "A removed computer could keep offering to continue a chat. Home now withdraws the offer, chord and row action while keeping the removal explanation visible." + - "The window that took a chat could name its own computer in a displacement notice after its drive established a newer hold. It now follows its drive's publishing receipt and announces displacement only when another computer holds the chat." + - "Taking a chat from Home could restore another chat's draft from the same project or miss the arrived chat's saved words. Arrival now restores only that chat's own draft, while other chats keep their words." --- After continue here from a send, the held words are left in the arrived chat's box for the person to send. 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 fd7cbd5b02..68d4c1d433 100644 --- a/internal/manual/chat/continuing-a-chat-on-another-computer.md +++ b/internal/manual/chat/continuing-a-chat-on-another-computer.md @@ -31,6 +31,8 @@ Before showing this question for a send, codeaf checks which computer holds the After `continue here` succeeds, those same words, pasted text and attachments are in the arrived chat's box. Press enter again when you want to send them. Choosing `leave it there` or a move that fails also keeps the whole draft. +Taking a chat from Home restores that chat's own saved draft, or leaves its box empty. Words typed for a different chat stay with that chat, even when both work in one place on disk. A draft held behind a send's continuation card stays with the chat that raised it. + ## What does leave it there do Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. diff --git a/internal/tui3/draft.go b/internal/tui3/draft.go index fb2c30024a..1893dd0747 100644 --- a/internal/tui3/draft.go +++ b/internal/tui3/draft.go @@ -300,10 +300,7 @@ func draftFirstHere(own, workspace string) bool { // record — an older build's leftovers, or an orphan adopted from a dead window on // this directory — and [app.legacyDraftText] is where it is read. func (a *app) restoreDraft() { - if a.draftFile == "" { - return - } - a.layKeptDrafts() + a.layKeptDrafts(true) } // adoptDraft takes over the newest draft on this directory whose window is gone, @@ -352,6 +349,11 @@ func adoptDraft(own, workspace string) string { if path == own || draftWindowAlive(path) { continue } + // A saved record names the chat these words belong to. Its plain + // export must stay with that chat until its own composer is restored. + if keep, how := readDraftKeep(draftKeepPath(path)); how == draftKeepFound && namedConversation(keep.Owner) { + continue + } info, err := os.Stat(path) if err != nil || info.ModTime().Before(latest) { continue diff --git a/internal/tui3/draftkeep.go b/internal/tui3/draftkeep.go index 0a7b04688d..02a30db469 100644 --- a/internal/tui3/draftkeep.go +++ b/internal/tui3/draftkeep.go @@ -1124,7 +1124,12 @@ func (a *app) stowDrafts(conv Conversation, side *aside) { // THE BOX IS EMPTY; falling through to the plain file there is how a sentence // that was sent came back, because the export is written after the record and a // crash between the two leaves it holding the words the record has just spent. -func (a *app) layKeptDrafts() { +// Taking a named chat restores only its own saved composers; an unowned plain +// draft can be adopted only during an ordinary window opening. +func (a *app) layKeptDrafts(allowUnowned bool) { + if a.draftFile == "" { + return + } states := map[recipient]composerState{} a.keptFrom, a.keptElsewhere = a.draftFile, nil @@ -1134,10 +1139,9 @@ func (a *app) layKeptDrafts() { if keep, how := readDraftKeep(draftKeepPath(a.draftFile)); how == draftKeepFound { a.keptElsewhere, _ = a.takeKeptSlots(keep, states) settled = a.speaksFor(keep) - // A shared connection reuses this window's store across selections. - // Its plain export belongs to the record's owner; falling back to it - // would put the outgoing chat's unsent text into an empty incoming chat. - if a.shared && namedConversation(keep.Owner) { + // The plain export belongs to the record's named chat. A new chat + // cannot adopt it just because the draft store or project matches. + if namedConversation(keep.Owner) { settled = true } } @@ -1148,7 +1152,7 @@ func (a *app) layKeptDrafts() { // Only then the plain file, which is all an older build or a stranger's // orphan can offer ([app.legacyDraftText]). - if _, held := states[mainRecipient]; !held && !settled { + if _, held := states[mainRecipient]; !held && !settled && allowUnowned { if text := a.legacyDraftText(); text != "" { runes := []rune(text) states[mainRecipient] = composerState{box: editor{value: runes, cursor: len(runes)}} diff --git a/internal/tui3/home_take_draft_owner_test.go b/internal/tui3/home_take_draft_owner_test.go new file mode 100644 index 0000000000..b541e385f4 --- /dev/null +++ b/internal/tui3/home_take_draft_owner_test.go @@ -0,0 +1,116 @@ +package tui3 + +import ( + "errors" + "os" + "reflect" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/chatlist" +) + +func TestHomeTakeKeepsEachChatsDraft(t *testing.T) { + for _, shared := range []bool{false, true} { + for _, saved := range []bool{false, true} { + name := "separate sessions" + if shared { + name = "shared session" + } + if saved { + name += "/saved draft" + } else { + name += "/empty draft" + } + t.Run(name, func(t *testing.T) { + lab := newHomeLab(t) + work := lab.workspace("project") + otherFile := lab.session("project", "0000000000000002", "Other chat", work, time.Now()) + arrivedFile := lab.session("project", heldCell, "Arriving chat", work, time.Now()) + drafts := t.TempDir() + orphan := lab.app(otherFile) + orphan.draftFile = asWindow(t, deadPid(t), func() string { return DraftFile(drafts, work) }) + orphan.input.setText("words for the other chat from an earlier window") + if err := orphan.draftSaveOf(orphan.mainDraftText()).commit(); err != nil { + t.Fatal(err) + } + orphanText, err := os.ReadFile(orphan.draftFile) + if err != nil { + t.Fatal(err) + } + orphanRecord, err := os.ReadFile(draftKeepPath(orphan.draftFile)) + if err != nil { + t.Fatal(err) + } + want := "" + if saved { + previous := lab.app(arrivedFile) + previous.draftFile = asWindow(t, deadPid(t), func() string { return DraftFile(drafts, work) }) + want = "words saved for the arriving chat" + previous.input.setText(want) + if err := previous.draftSaveOf(previous.mainDraftText()).commit(); err != nil { + t.Fatal(err) + } + } + a := lab.app(otherFile) + a.width, a.height = 200, 70 + a.shared = shared + a.draftFile = DraftFile(drafts, work) + a.input.setText(" current words for the other chat ") + a.input.cursor = 8 + otherComposer := a.mainComposer() + otherDraft := a.draftFile + arrivedDraft := DraftFile(drafts, work) + a.open = func(workspace, file string) (Conversation, error) { + return Conversation{Agent: &fakeAgent{model: "m"}, Workspace: workspace, SessionFile: file, DraftFile: arrivedDraft}, nil + } + src := &continuationListing{row: leftOffRow(heldCell, "other device", chatlist.Running, time.Minute)} + taker := &fakeTaker{taken: Taken{Transcript: arrivedFile}} + taker.on = func(string) { src.change(func(row *chatlist.Row) { row.Mine, row.Status = true, chatlist.Here }) } + a.machines, a.taker = src, taker + drain(t, a, a.openHome()) + standOn(t, a, heldCell) + confirm(t, a) + if a.at(pageHome) || a.file != arrivedFile { + t.Fatal("continuation did not open the selected chat") + } + if got := a.input.String(); got != want { + t.Errorf("arrived composer = %q, want %q", got, want) + } + spend(t, a, a.saveDraft(a.draftFile)) + if got := readDraft(arrivedDraft); got != want { + t.Errorf("arrived draft file = %q, want %q", got, want) + } + if got := readDraft(otherDraft); got != string(otherComposer.box.value) { + t.Errorf("other chat's draft changed to %q", got) + } + for path, want := range map[string][]byte{orphan.draftFile: orphanText, draftKeepPath(orphan.draftFile): orphanRecord} { + got, err := os.ReadFile(path) + if err != nil || !reflect.DeepEqual(got, want) { + t.Errorf("another chat's saved draft changed: %v", err) + } + } + if !shared { + if kept := a.behind[a.convKey(otherFile)]; kept == nil || kept.side.draft != string(otherComposer.box.value) { + t.Error("the other chat did not keep its composer") + } + } + }) + } + } +} + +func TestFailedHomeTakeKeepsTheOtherChatsComposer(t *testing.T) { + src := &continuationListing{row: leftOffRow("another-chat", "other device", chatlist.Running, time.Minute)} + a, _ := heldLab(t, src, &fakeTaker{err: errors.New("move could not finish")}) + a.closeHome() + a.input.setText(" words for the current chat ") + a.input.cursor = 5 + want := a.mainComposer() + drain(t, a, a.openHome()) + standOn(t, a, src.row.Cell) + confirm(t, a) + if got := a.mainComposer(); !reflect.DeepEqual(got, want) { + t.Errorf("failed continuation changed the other chat's composer: %+v", got) + } +} diff --git a/internal/tui3/homepanel_continue.go b/internal/tui3/homepanel_continue.go index 1402c8acf9..f2faefcfc8 100644 --- a/internal/tui3/homepanel_continue.go +++ b/internal/tui3/homepanel_continue.go @@ -278,7 +278,9 @@ func (a *app) takeRemote(row chatlist.Row) tea.Cmd { // Retirement can replace the displaced session while the move is in // flight. Its unsent message belongs to the chat that is arriving. composer := a.mainComposer() - draft = &composer + if !composer.empty() { + draft = &composer + } } return func() tea.Msg { if conditional, ok := taker.(conditionalTaker); ok { @@ -399,6 +401,10 @@ func (a *app) openTaken(msg homeTakenMsg) tea.Cmd { a.note(joinSaid(moved, kept)) if msg.draft != nil { a.putComposer(*msg.draft) + } else if !msg.taken.AlreadyHere { + // A take from Home restores the arrived chat's saved words. The + // conversation being left keeps its own composer in its own draft. + a.layKeptDrafts(false) } if a.file == line.row.Transcript { a.offerSetup(msg.taken.Resume) diff --git a/internal/tui3/keeper.go b/internal/tui3/keeper.go index 981bce5c62..a5de1a2624 100644 --- a/internal/tui3/keeper.go +++ b/internal/tui3/keeper.go @@ -1205,7 +1205,7 @@ func (a *app) takeBeside(conv Conversation) tea.Cmd { } } cmd := tea.Batch(parked, a.attachConversation(conv, nil)) - if a.shared { + if a.shared && a.homeTakenSaid == nil { a.restoreDraft() } if key := a.convKey(conv.SessionFile); key != "" { From efb04b1dea2c21453bb229d0e98c2a1de8a4dcea Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 15:40:48 -0400 Subject: [PATCH 22/23] Keep the directory's orphaned draft adoption, and limit draft ownership to takes Keeping each chat's draft with that chat had also stopped an ordinary opening from adopting the draft a dead window left in its directory, which is how the words follow the person and the directory. Adoption is restored. Only a chat arriving through a take now brings just its own saved words and never adopts another chat's draft. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/tui3/draft.go | 5 ----- internal/tui3/draftkeep.go | 13 ++++++++----- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/internal/tui3/draft.go b/internal/tui3/draft.go index 1893dd0747..90fa79970e 100644 --- a/internal/tui3/draft.go +++ b/internal/tui3/draft.go @@ -349,11 +349,6 @@ func adoptDraft(own, workspace string) string { if path == own || draftWindowAlive(path) { continue } - // A saved record names the chat these words belong to. Its plain - // export must stay with that chat until its own composer is restored. - if keep, how := readDraftKeep(draftKeepPath(path)); how == draftKeepFound && namedConversation(keep.Owner) { - continue - } info, err := os.Stat(path) if err != nil || info.ModTime().Before(latest) { continue diff --git a/internal/tui3/draftkeep.go b/internal/tui3/draftkeep.go index 02a30db469..fa85b55ff0 100644 --- a/internal/tui3/draftkeep.go +++ b/internal/tui3/draftkeep.go @@ -1124,8 +1124,9 @@ func (a *app) stowDrafts(conv Conversation, side *aside) { // THE BOX IS EMPTY; falling through to the plain file there is how a sentence // that was sent came back, because the export is written after the record and a // crash between the two leaves it holding the words the record has just spent. -// Taking a named chat restores only its own saved composers; an unowned plain -// draft can be adopted only during an ordinary window opening. +// Taking a named chat restores only its own saved composers; an orphaned plain +// draft from a window that is gone is adopted only by an ordinary opening in +// its directory, never by a chat arriving through a take. func (a *app) layKeptDrafts(allowUnowned bool) { if a.draftFile == "" { return @@ -1139,9 +1140,11 @@ func (a *app) layKeptDrafts(allowUnowned bool) { if keep, how := readDraftKeep(draftKeepPath(a.draftFile)); how == draftKeepFound { a.keptElsewhere, _ = a.takeKeptSlots(keep, states) settled = a.speaksFor(keep) - // The plain export belongs to the record's named chat. A new chat - // cannot adopt it just because the draft store or project matches. - if namedConversation(keep.Owner) { + // A shared connection reuses this window's store across selections, + // and a chat arriving through a take brings only its own words. In + // both, the plain export belongs to the record's owner; falling back + // to it would put another chat's unsent text into the incoming chat. + if (a.shared || !allowUnowned) && namedConversation(keep.Owner) { settled = true } } From 958e7f017b267a5b0df59d25121ceeb31a3e7843 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 15:40:48 -0400 Subject: [PATCH 23/23] Keep "how do I add another computer" reaching the pairing steps The removal and empty-chat sections spoke of "another computer" and "adding the computer again", which drew the question away from the section with the pairing steps once that section's card text changed. They now say "machine" and "your other devices", and the question reaches the steps again. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/manual/chat/add-or-remove-a-computer.md | 6 +++--- internal/manual/chat/home.md | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/manual/chat/add-or-remove-a-computer.md b/internal/manual/chat/add-or-remove-a-computer.md index b7e9e33523..214fc2517b 100644 --- a/internal/manual/chat/add-or-remove-a-computer.md +++ b/internal/manual/chat/add-or-remove-a-computer.md @@ -213,8 +213,8 @@ same decision. A different answer cannot replace a decision already made. If the link has run out, ask the new computer for a fresh one. Removing a device blocks its later requests; it cannot take back bytes the device already received. -## Does a removed computer keep syncing while its window is open +## After removal — does this machine keep syncing while its window is open -Once codeaf learns that this computer was removed, it stops asking your other computers for chats and stops sending updates. That refusal is remembered across windows and restarts. Your window stays usable for local chats. Home removes its offer to continue another computer's chat, its continue chord and the continue action on chat rows. The removal explanation stays visible. +Once codeaf learns that this machine was removed, it stops asking for chats and stops sending updates. That refusal is remembered across windows and restarts. Your window stays usable for local chats. Home removes its offer to continue a chat from your other devices, its continue chord and the continue action on chat rows. The removal explanation stays visible. -Run `codeaf pair` to add the computer again. Pairing can still reach the service and gives this computer a new device key. An already open Home can then show your other computers again. +Run `codeaf pair` to pair it again. Pairing can still reach the service and gives this machine a new device key. An already open Home then shows your other devices again. diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 290dd38012..e37a649025 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -2717,6 +2717,6 @@ After a clean quit lets a paired computer's chat go, bare `codeaf` on another pa If the laptop only slept or lost its connection, Home offers its latest chat and `alt+c` continues immediately. When local unsaved edits need to be kept as a branch, the continuation card asks first. Nothing moves automatically while the other computer is running that chat, for a named chat or `--host`/`--at`/`--session`, when another local window holds a chat, or when local unsaved edits need a choice. If the move fails, Home stays open and says why. -## Empty chats on another computer — untitled rows before the first saved turn +## Empty untitled chats from your other devices — rows before the first saved turn -An empty new chat on another computer stays off Home until it has a saved turn. Opening a window there does not add an `untitled` row here, even while that window remains open and running. Its startup save alone does not make it a conversation you can continue. Once that chat has its first saved turn, it appears with the other chats you can continue. +An empty new chat on one of your other devices stays off Home until it has a saved turn. Opening a window there does not add an `untitled` row here, even while that window remains open and running. Its startup save alone does not make it a conversation you can continue. Once that chat has its first saved turn, it appears with the other chats you can continue.