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/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/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/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/cmd/codeaf/synctake.go b/cmd/codeaf/synctake.go index adbbac3a89..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{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 new file mode 100644 index 0000000000..5c75172c22 --- /dev/null +++ b/docs/changes/unreleased/1967-two-computer-fixes.md @@ -0,0 +1,19 @@ +--- +kind: fixed +title: Continuing on another computer sends at once, and a leading computer's clean quit hands back +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." + - "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." + - "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/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/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/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 38ef2ce5f8..742fd1910a 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 @@ -38,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 @@ -374,11 +380,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 @@ -430,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 { @@ -472,6 +494,9 @@ func (b *Batcher) lost(ctx context.Context, seen Driving) { return } b.setStale() + if b.OnLost != nil { + b.OnLost() + } _ = b.resolve(ctx) } @@ -480,6 +505,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/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/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/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/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/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/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/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/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/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..214fc2517b 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. + +## After removal — does this machine keep syncing while its window is open + +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 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/continuing-a-chat-on-another-computer.md b/internal/manual/chat/continuing-a-chat-on-another-computer.md index 6c025de18e..68d4c1d433 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. @@ -23,6 +23,16 @@ An attended window keeps following your other devices while the chat is open, so 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. + +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. @@ -151,6 +161,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 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. ## The chat is arriving on this computer — why it waits to open @@ -239,6 +251,10 @@ 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. ## Why an older chat could not be copied @@ -281,3 +297,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/manual/chat/coordination-and-workers.md b/internal/manual/chat/coordination-and-workers.md index a5ff3317ed..d9b35664b0 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 — 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. + ## 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. @@ -169,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`. 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/manual/chat/home.md b/internal/manual/chat/home.md index 5944781e34..e37a649025 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 untitled chats from your other devices — rows before the first saved turn + +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. 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/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/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/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/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/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/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/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) + } + }) + } +} 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/drive.go b/internal/syncsetup/drive.go index d481673614..f37080166b 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" ) @@ -45,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{} @@ -54,8 +56,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 @@ -88,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 @@ -113,7 +119,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 +129,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) } } @@ -161,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()) } @@ -222,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 { @@ -236,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 @@ -277,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(), @@ -295,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() @@ -315,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)) } @@ -366,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 @@ -379,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 { @@ -417,8 +471,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 +479,7 @@ type stamping struct { engine cellstore.Engine device string fence func() uint64 + note func(cellstore.Sealed) } var _ cellstore.Store = stamping{} @@ -434,7 +488,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/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/member.go b/internal/syncsetup/member.go index 82215a7eb8..906f327a8b 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 @@ -90,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) { @@ -283,6 +288,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 +608,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 { @@ -690,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/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/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/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..d6885de206 --- /dev/null +++ b/internal/syncsetup/removed.go @@ -0,0 +1,67 @@ +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 +} + +// 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. +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..9ca8423f96 --- /dev/null +++ b/internal/syncsetup/revoked_traffic_test.go @@ -0,0 +1,152 @@ +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. 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(wait): + 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 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 { + 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/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/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 })} } diff --git a/internal/syncsetup/take.go b/internal/syncsetup/take.go index 7f454c89c4..e5c32bb7d5 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. @@ -75,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} @@ -89,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) }, @@ -128,11 +130,32 @@ 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. + 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 + } + 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() - 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. @@ -156,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() { @@ -171,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) @@ -285,14 +311,33 @@ 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) { - 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 { @@ -543,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/syncsetup/take_published_test.go b/internal/syncsetup/take_published_test.go new file mode 100644 index 0000000000..aae498a044 --- /dev/null +++ b/internal/syncsetup/take_published_test.go @@ -0,0 +1,81 @@ +package syncsetup + +import ( + "context" + "os" + "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) + } + } + }) + } +} 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) + } +} 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/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 1ee11313c7..84892818cc 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 @@ -2314,7 +2315,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 @@ -3044,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, @@ -10545,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 { @@ -10559,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/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/draft.go b/internal/tui3/draft.go index fb2c30024a..90fa79970e 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, diff --git a/internal/tui3/draftkeep.go b/internal/tui3/draftkeep.go index 0a7b04688d..fa85b55ff0 100644 --- a/internal/tui3/draftkeep.go +++ b/internal/tui3/draftkeep.go @@ -1124,7 +1124,13 @@ 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 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 + } states := map[recipient]composerState{} a.keptFrom, a.keptElsewhere = a.draftFile, nil @@ -1134,10 +1140,11 @@ 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) { + // 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 } } @@ -1148,7 +1155,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/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_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/homeband_resume.go b/internal/tui3/homeband_resume.go index d80c94787c..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 } @@ -252,17 +261,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..f2faefcfc8 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" @@ -37,9 +38,13 @@ 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 + Fence uint64 + Head 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 @@ -149,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] @@ -193,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 } @@ -247,26 +260,47 @@ type homeTakenMsg struct { row chatlist.Row 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 + 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() + if !composer.empty() { + 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} + 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} } } // 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) @@ -276,8 +310,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) } @@ -323,11 +356,34 @@ 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, "", msg.taken.Fence + rows[i].Head = msg.taken.Head + } + } + 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.restoreTakeDraft(msg) a.home.say(joinSaid(moved, kept), "") return a.askMachines() } @@ -335,7 +391,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() } @@ -343,21 +399,35 @@ func (a *app) openTaken(msg homeTakenMsg) tea.Cmd { a.forget(key) } 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) } + if msg.send != nil { + return msg.send() + } 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) } 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. @@ -375,6 +445,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_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..3accbf6e0a 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" ) @@ -19,9 +23,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 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 && heldElsewhere(row) { 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,26 @@ 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) + msg.reading.holdCell, msg.reading.holdFence = id, msg.fence + } + 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 } } @@ -122,8 +145,8 @@ 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) - cmd := msg.resume() + reading := a.recordMachines(msg.reading) + cmd := a.resumeOwnedSend(msg) if a.liveComposer().empty() { a.putComposer(next) } else { @@ -138,6 +161,143 @@ 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) - return tea.Batch(reading, msg.resume()) + reading := a.recordMachines(msg.reading) + 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(msg homeMachinesMsg) { + if a.machineRead.sendHolds == nil { + a.machineRead.sendHolds = map[string]sendHold{} + } + 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} + } + } +} + +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 9a0870528c..8a51ac722e 100644 --- a/internal/tui3/homepanel_machines.go +++ b/internal/tui3/homepanel_machines.go @@ -2,12 +2,16 @@ package tui3 import ( "context" + "errors" + "path/filepath" "sort" "time" tea "charm.land/bubbletea/v2" "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 ────────────────────────── @@ -30,7 +34,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 @@ -50,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 @@ -72,30 +80,61 @@ 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 } } // 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 { + 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) + } + 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() 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..74f492e4b5 100644 --- a/internal/tui3/input.go +++ b/internal/tui3/input.go @@ -1543,9 +1543,6 @@ 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() } diff --git a/internal/tui3/keeper.go b/internal/tui3/keeper.go index 6796a600f0..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 != "" { @@ -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/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/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/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/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/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") + } + } +} 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) + } +} diff --git a/internal/tui3/send_continue_draft_test.go b/internal/tui3/send_continue_draft_test.go new file mode 100644 index 0000000000..ccc8a7203b --- /dev/null +++ b/internal/tui3/send_continue_draft_test.go @@ -0,0 +1,109 @@ +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) { + 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) + } + } + }) + } +} + +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") + } +} 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) + } + } +} 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) + } + }) + } +} 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) + } + }) + } +} 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/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 { 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',