diff --git a/cmd/codeaf-release/main.go b/cmd/codeaf-release/main.go index 91ca7415ab..805dcd696f 100644 --- a/cmd/codeaf-release/main.go +++ b/cmd/codeaf-release/main.go @@ -11,6 +11,7 @@ // codeaf-release next --channel dev|staging --sha --date YYYYMMDD // codeaf-release kind // codeaf-release prune --channel dev|staging +// codeaf-release service [--url ] package main import ( @@ -46,6 +47,8 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return 0 case "prune": err = runPrune(args[1:], stdin, stdout) + case "service": + err = runService(args[1:], stdout) case "promotion-plan": err = runPromotionPlan(args[1:], stdout) case "promotion-message": @@ -90,6 +93,9 @@ func usage(w io.Writer) { Print stable, rc, dev, staging, or other. prune --channel dev|staging Read lines and print expired tags. + service [--url ] + Fail unless the live sync service (the hosted one by default) promises + every capability this build needs. promotion-plan|promotion-decision|promotion-recheck|promotion-release|promotion-published|promotion-jobs|promotion-push-error|promotion-message Plan, check, and report the staging promotion. See docs/rules/promotion.md. `) diff --git a/cmd/codeaf-release/service.go b/cmd/codeaf-release/service.go new file mode 100644 index 0000000000..336fd3e474 --- /dev/null +++ b/cmd/codeaf-release/service.go @@ -0,0 +1,50 @@ +package main + +import ( + "context" + "fmt" + "io" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// serviceProbeWithin bounds the live check, so a silent service fails the +// release instead of holding the workflow. +const serviceProbeWithin = 60 * time.Second + +// probeService is the live check; tests replace it. +var probeService = syncsetup.ProbeService + +// runService fails when the sync service a build will talk to does not promise +// what the build needs. A client never ships ahead of its service (#2072). +func runService(args []string, stdout io.Writer) error { + url := syncsetup.HostedRelayURL + switch { + case len(args) == 2 && args[0] == "--url": + url = args[1] + case len(args) != 0: + return usageErr("service takes only --url ") + } + ctx, cancel := context.WithTimeout(context.Background(), serviceProbeWithin) + defer cancel() + missing, err := probeService(ctx, url) + if err != nil { + return fmt.Errorf("could not check the sync service at %s: %w", url, err) + } + if len(missing) > 0 { + return fmt.Errorf("the sync service at %s must be updated before this build ships; it does not promise %s", url, describe(missing)) + } + fmt.Fprintf(stdout, "the sync service at %s promises everything this build needs\n", url) + return nil +} + +func describe(missing []directory.Capability) string { + parts := make([]string, len(missing)) + for i, c := range missing { + parts[i] = fmt.Sprintf("%s (needed for %s)", c.Header, c.Feature) + } + return strings.Join(parts, ", ") +} diff --git a/cmd/codeaf-release/service_test.go b/cmd/codeaf-release/service_test.go new file mode 100644 index 0000000000..1aa0f41526 --- /dev/null +++ b/cmd/codeaf-release/service_test.go @@ -0,0 +1,30 @@ +package main + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/directory" +) + +// TestServiceGateFailsAheadOfTheService pins #2072's release gate: a build +// whose service lacks a promise does not ship, and the refusal names it. +func TestServiceGateFailsAheadOfTheService(t *testing.T) { + defer func(was func(context.Context, string) ([]directory.Capability, error)) { probeService = was }(probeService) + probeService = func(context.Context, string) ([]directory.Capability, error) { + return directory.Capabilities, nil + } + var out, errs bytes.Buffer + if code := run([]string{"service", "--url", "https://sync.example"}, nil, &out, &errs); code != 1 { + t.Fatalf("exit %d, stderr %q", code, errs.String()) + } + if !strings.Contains(errs.String(), directory.ConditionalTakeHeader) { + t.Fatalf("the refusal does not name the promise: %q", errs.String()) + } + probeService = func(context.Context, string) ([]directory.Capability, error) { return nil, nil } + if code := run([]string{"service"}, nil, &out, &errs); code != 0 { + t.Fatalf("a complete service failed the gate: %q", errs.String()) + } +} diff --git a/cmd/codeaf/devices.go b/cmd/codeaf/devices.go index 0371e2f0e1..b7b801de5e 100644 --- a/cmd/codeaf/devices.go +++ b/cmd/codeaf/devices.go @@ -140,7 +140,7 @@ func listDevices(kinds []deviceKind, now time.Time) error { // `codeaf devices revoke --help`, and named in the one usage table. func stopDevice(kinds []deviceKind, args []string) error { flags := commandFlags("devices revoke") - all := flags.Bool("all", false, "remove every device of that name that can use this machine, not just the one") + all := flags.Bool("all", false, "remove every device of that name, computers that sync your chats included, not just the one") if err := parseCommandFlags(flags, reorder(flags, args)); err != nil { return err } @@ -148,16 +148,16 @@ func stopDevice(kinds []deviceKind, args []string) error { return errors.New("usage: codeaf devices revoke [--all] — `codeaf devices` lists the names") } name := flags.Arg(0) + // A partial --all still says what it did stop before what it could not. said, err := stopByName(kinds, name, *all) - if err != nil { - return err + if said != "" { + fmt.Println(said) } - fmt.Println(said) - return nil + return err } // stopByName finds the one kind that has the name and stops it there. With -// --all the name is taken to mean the kind that can stop several at once. +// --all it stops every device of that name, in every kind that has one. func stopByName(kinds []deviceKind, name string, all bool) (string, error) { if all { return stopAllByName(kinds, name) @@ -169,13 +169,32 @@ func stopByName(kinds []deviceKind, name string, all bool) (string, error) { return kind.stop(name) } +// stopAllByName stops every device called name. A kind that can stop several +// stops them all; any other kind stops its one, so --all never misses a device +// that `codeaf devices` lists (#2072). func stopAllByName(kinds []deviceKind, name string) (string, error) { + var said []string + var failed error for _, kind := range kinds { + n, err := kind.find(name) + failed = cmp.Or(failed, err) + if n == 0 { + continue + } + stop := kind.stop if many, ok := kind.(allStopper); ok { - return many.stopAll(name) + stop = many.stopAll } + line, err := stop(name) + if err != nil { + return strings.Join(said, "\n"), err + } + said = append(said, line) + } + if len(said) == 0 { + return "", cmp.Or(failed, unknownDevice(name)) } - return "", unknownDevice(name) + return strings.Join(said, "\n"), nil } // kindOf is the one kind that has a device called name. A name in two kinds is diff --git a/cmd/codeaf/devices_test.go b/cmd/codeaf/devices_test.go index ded1f0f2e3..dc3b3e96f4 100644 --- a/cmd/codeaf/devices_test.go +++ b/cmd/codeaf/devices_test.go @@ -123,12 +123,35 @@ func TestDevicesRevokeNamesThatCannotPickOne(t *testing.T) { if l, _ := dir.List(devicesCtx); l.Devices[otherComputer].Revoked || l.Devices[thisComputer].Revoked { t.Fatalf("a refused name still stopped a computer: %+v", l.Devices) } - // --all is the remote-access kind's word, so the name means that kind. + // --all means every device of that name, whichever kind it is (#2072). if _, err := captureStdout(t, func() error { return stopDevice(kinds, []string{"phone", "--all"}) }); err != nil { t.Fatal(err) } - if l, _ := dir.List(devicesCtx); l.Devices[otherComputer].Revoked { - t.Fatal("--all stopped a computer that holds the chats") + if l, _ := dir.List(devicesCtx); !l.Devices[otherComputer].Revoked || l.Devices[thisComputer].Revoked { + t.Fatalf("--all did not stop the computer of that name: %+v", l.Devices) + } + if left, _ := kinds[1].(bookKind).book.Devices(); len(left) != 0 { + t.Fatalf("--all left the remote device of that name: %v", left) + } +} + +// TestDevicesRevokeAllFindsAComputerThatSyncsChats pins #2072: a computer that +// `codeaf devices` lists is removed by `revoke --all` as by +// `revoke `, though no device of that name can use this machine. +func TestDevicesRevokeAllFindsAComputerThatSyncsChats(t *testing.T) { + chats, dir := chatsWith(t, "laptop") + kinds := []deviceKind{chats, bookKind{bookWith(t, "phone")}} + if _, err := captureStdout(t, func() error { return stopDevice(kinds, []string{"laptop", "--all"}) }); err != nil { + t.Fatalf("revoke laptop --all: %v", err) + } + if l, _ := dir.List(devicesCtx); !l.Devices[otherComputer].Revoked { + t.Fatal("--all missed the computer that syncs the chats") + } + if left, _ := kinds[1].(bookKind).book.Devices(); len(left) != 1 { + t.Fatalf("--all stopped a device of another name: %v", left) + } + if _, err := captureStdout(t, func() error { return stopDevice(kinds, []string{"ghost", "--all"}) }); err == nil || !strings.Contains(err.Error(), "no device is called") { + t.Fatalf("revoke ghost --all: %v", err) } } diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go index 2ecbc82840..4ea0ece01f 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -429,9 +429,11 @@ func nothingHolds(workspace string) bool { // differentEngineEnvironmentSentence names the host's workspace so the command // stops the folder actually holding the old environment, including from Home. +// It offers --no-host first: that opens this window with this terminal's +// environment and never interrupts the other window (#2072). func differentEngineEnvironmentSentence(workspace string) string { stop := engineStopWords(workspace) - return "the background engine for this folder started with a different environment than this terminal and is still in use — after work finishes, close every window for this workspace and reopen. To stop now (interrupts active turns and tasks), run " + stop + return "the background engine for this folder started with a different environment than this terminal and is still in use — codeaf chat --no-host opens this window on its own and leaves the other one running; or, after work finishes, close every window for this workspace and reopen. To stop now (interrupts active turns and tasks), run " + stop } // settleCellsMode checks the capability of an already-running host before diff --git a/cmd/codeaf/engine_environment_test.go b/cmd/codeaf/engine_environment_test.go index ca027c5f7c..d09d39cf6d 100644 --- a/cmd/codeaf/engine_environment_test.go +++ b/cmd/codeaf/engine_environment_test.go @@ -464,7 +464,7 @@ func TestEnvironmentStopCommandQuotesTheWorkspaceForTheShell(t *testing.T) { t.Run(workspace, func(t *testing.T) { note := differentEngineEnvironmentSentence(workspace) _, command, ok := strings.Cut(note, "), run ") - if !ok || !strings.Contains(note, "after work finishes, close every window for this workspace and reopen") || !strings.Contains(note, "To stop now (interrupts active turns and tasks)") { + if !ok || !strings.Contains(note, "codeaf chat --no-host opens this window on its own") || !strings.Contains(note, "after work finishes, close every window for this workspace and reopen") || !strings.Contains(note, "To stop now (interrupts active turns and tasks)") { t.Fatalf("busy note does not explain the recovery: %s", note) } // Keep the production command's complete shell argument text. Only diff --git a/docs/changes/unreleased/2075-never-locked-out.md b/docs/changes/unreleased/2075-never-locked-out.md new file mode 100644 index 0000000000..8f3abbace5 --- /dev/null +++ b/docs/changes/unreleased/2075-never-locked-out.md @@ -0,0 +1,25 @@ +--- +kind: fixed +title: A chat can always be written on the computer in front of you, even on an older sync service +pr: 2075 +surface: [chat, remote, build, docs] +invalidates: + - "A chat whose hold had lapsed could only be written after a conditional take, so on a sync service without codeaf-conditional-takes it opened read-only and Enter showed Continue this chat here? again; it is now taken and written on every service, and only taking a chat that is open on another computer waits for the update." + - "Turns this computer saved on top of the chat's last uploaded turn made a reopened chat read-only; they are now published when the chat opens." + - "A chat this computer held last and let lapse read as off and asked Continue this chat here? with this chat was last held here; it now reads as free and opens without the question." + - "Home offered Continue 's latest chat here? for a device removed with codeaf devices revoke; a removed device is no longer offered." + - "codeaf devices revoke --all looked only at devices that can use this machine and missed computers that sync chats; it now removes every device of that name of either kind." + - "The older-service refusal said the sync service must be updated before chats can move; it now says that taking an open chat from another computer waits for a sync service update and that everything else works." +--- + +A chat whose hold had lapsed needed a conditional take, which the client made +only when the service promised one. The deployed hosted service does not, so +every lapsed chat was refused, turns stayed local, and the next open found this +computer's own newer turns and opened the chat read-only. An older service +already refuses a live holder atomically and raises the fence, and the caller +checks the head the take answers, so a lapsed take is now made on every service. + +`codeaf-release service` asks the live hosted service, as a throwaway identity, +for every promise this build needs and fails when one is missing. The engine's +different-environment refusal now offers `codeaf chat --no-host`, and the +`.moving` folder that holds older chats' copies carries a `README.txt`. diff --git a/internal/cell/copy.go b/internal/cell/copy.go index bca0022863..2003d9fb2c 100644 --- a/internal/cell/copy.go +++ b/internal/cell/copy.go @@ -27,6 +27,30 @@ func CopyRoot(original string) string { return filepath.Join(filepath.Dir(original), ".moving", encode128(raw)) } +// CopyFolderReadme is the note in the folder that holds moved chats. The +// folder's name reads like a transfer in progress, and removing it by hand +// would delete those chats, so the folder says what it is (#2072). The name is +// kept: older builds and copies already made look for it. +const CopyFolderReadme = "README.txt" + +const copyFolderNote = `This folder holds your codeaf chats. They are the live copies codeaf writes to, +not a move in progress. Do not delete it or anything in it: that deletes those +chats. The older originals beside it stay untouched for older codeaf builds. +` + +// explainCopyFolder writes the note once; an existing note is left as it is. +func explainCopyFolder(dir string) error { + f, err := os.OpenFile(filepath.Join(dir, CopyFolderReadme), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if errors.Is(err, fs.ErrExist) { + return nil + } + if err != nil { + return err + } + _, err = f.WriteString(copyFolderNote) + return errors.Join(err, f.Close()) +} + // Copied resolves only a fully prepared, durable copy. A crash before the // completion marker leaves the original as the only visible conversation. func Copied(original string) string { @@ -55,6 +79,9 @@ func CopyLegacy(ctx context.Context, original string, prepare func(context.Conte if err := os.MkdirAll(filepath.Dir(root), 0700); err != nil { return Cell{}, err } + if err := explainCopyFolder(filepath.Dir(root)); err != nil { + return Cell{}, err + } lock, err := os.OpenFile(root+".lock", os.O_CREATE|os.O_RDWR, 0600) if err != nil { return Cell{}, err diff --git a/internal/cell/copy_test.go b/internal/cell/copy_test.go index f5c2e8c4d5..16b7de617d 100644 --- a/internal/cell/copy_test.go +++ b/internal/cell/copy_test.go @@ -9,6 +9,7 @@ import ( "os/exec" "path/filepath" "reflect" + "strings" "testing" "time" ) @@ -181,3 +182,17 @@ func TestC29LinkedFolderCannotAliasTheOriginal(t *testing.T) { t.Fatal("copy through a folder link changed the original") } } + +// TestTheCopyFolderSaysItIsTheLiveStore pins #2072: the folder holding moved +// chats is named for a move in progress, so it says, to anyone who looks, that +// it holds the chats themselves and must not be removed. +func TestTheCopyFolderSaysItIsTheLiveStore(t *testing.T) { + original := olderFixture(t) + if _, err := CopyLegacy(t.Context(), original, func(context.Context, Cell) error { return nil }); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(filepath.Dir(CopyRoot(original)), CopyFolderReadme)) + if err != nil || !strings.Contains(string(raw), "Do not delete it") { + t.Fatalf("the copy folder does not explain itself: %q, %v", raw, err) + } +} diff --git a/internal/cellstore/published.go b/internal/cellstore/published.go index 8d82bd06e4..970755b4c0 100644 --- a/internal/cellstore/published.go +++ b/internal/cellstore/published.go @@ -46,6 +46,37 @@ func KnowsHead(c cell.Cell, head string) (bool, error) { return false, err } +// Extends says whether this computer's newest turn is head or was sealed on +// top of it. A computer whose own turns did not reach the service (it ran +// unsynced, or its last upload died with it) still owns that work: publishing +// it continues the record's history instead of replacing it. An adopted head +// has no line in the log, but its first local child names it as the parent. +func Extends(c cell.Cell, head string) (bool, error) { + local, err := Head(c) + if err != nil || local == nil { + return head == "", err + } + turns, err := Turns(c) + if err != nil { + return false, err + } + parent := make(map[string]string, len(turns)) + for _, t := range turns { + parent[t.ID] = t.Parent + } + for at, seen := local.Turn.ID, 0; seen <= len(turns); seen++ { + if at == head { + return true, nil + } + next, ok := parent[at] + if !ok || next == "" { + return false, nil + } + at = next + } + return false, nil +} + // NotePublished records a head that the directory accepted or this device // received, so a later take can distinguish uploaded history from local work. func NotePublished(c cell.Cell, head string) error { diff --git a/internal/chatlist/rows.go b/internal/chatlist/rows.go index 180895df88..49a49b2d8a 100644 --- a/internal/chatlist/rows.go +++ b/internal/chatlist/rows.go @@ -41,6 +41,9 @@ type Row struct { // Released distinguishes a real release from a revoked or offline holder. Released bool ReleasedAgo time.Duration + // HolderRemoved says the last holder was removed from the person's + // devices: its lease reads as released and it is never offered (#2072). + HolderRemoved bool } // Quiet is the row of a chat whose live holder has gone: a lease that still @@ -144,6 +147,8 @@ func rowOf(id string, c directory.Cell, l directory.Listing, self string, open O DurableAt: c.DurableAt, Pending: c.Lease.Pending, OrphanTurns: c.OrphanTurns, + + HolderRemoved: l.Devices[c.Lease.Device].Revoked, } } @@ -158,7 +163,9 @@ func heldStatus(c directory.Cell, l directory.Listing, self string) Status { return st } -// statusOf applies the precedence Branch, Here, Running, Off, Idle. +// statusOf applies the precedence Branch, Here, Running, Off, Idle. A lapsed +// lease of this device's own is free, not off: nobody else holds the chat, so +// there is nothing to continue from and it opens without a question (#2072). func statusOf(c directory.Cell, now int64, self string) Status { switch { case c.OrphanTurns > 0: @@ -167,7 +174,7 @@ func statusOf(c directory.Cell, now int64, self string) Status { return Here case c.Lease.Expires > now: return Running - case c.Lease.Expires != 0: + case c.Lease.Expires != 0 && c.Lease.Device != self: return Off } return Idle diff --git a/internal/chatlist/rows_test.go b/internal/chatlist/rows_test.go index cea824bad5..e4f90bfa9b 100644 --- a/internal/chatlist/rows_test.go +++ b/internal/chatlist/rows_test.go @@ -152,3 +152,23 @@ func TestEveryLabelOfADeviceDropsTheLocalSuffix(t *testing.T) { t.Errorf("DeviceName shows %q", got) } } + +// TestThisComputersLapsedChatOpensWithoutAQuestion pins #2072: a chat this +// computer last held, whose lease lapsed with nobody else holding it, has +// nothing to continue from. It reads as free, so it opens without the card. +func TestThisComputersLapsedChatOpensWithoutAQuestion(t *testing.T) { + r := Rows(listing(map[string]directory.Cell{"c": cell(self, 900_000, nil)}), self, plain)[0] + if r.Status != Idle || !r.Mine || OfferFor(r).Kind != Open { + t.Fatalf("this computer's lapsed chat: status %s, offer %+v", r.Status, OfferFor(r)) + } +} + +// TestARowSaysItsHolderWasRemoved pins #2072: a removed device is never named +// in an offer, so the row carries that its holder is gone. +func TestARowSaysItsHolderWasRemoved(t *testing.T) { + l := listing(map[string]directory.Cell{"c": cell(other, 900_000, nil)}) + l.Devices[other] = directory.Device{Name: "host-a", Revoked: true} + if r := Rows(l, self, plain)[0]; !r.HolderRemoved { + t.Fatalf("row of a removed holder: %+v", r) + } +} diff --git a/internal/directory/capabilities.go b/internal/directory/capabilities.go new file mode 100644 index 0000000000..6c72c51139 --- /dev/null +++ b/internal/directory/capabilities.go @@ -0,0 +1,48 @@ +package directory + +import ( + "context" + "net/http" +) + +// Capability is a promise a sync service makes in a response header, and the +// one feature that waits while a service does not make it. Every other feature +// works on a service that lacks it. +type Capability struct { + Header string + Feature string +} + +// Capabilities is what this build of codeaf needs a sync service to promise. +// The release gate (codeaf-release service) refuses to ship a build whose +// service does not answer every one, and the client says which feature waits. +var Capabilities = []Capability{ + {Header: ConditionalTakeHeader, Feature: conditionalTakeFeature}, +} + +// conditionalTakeFeature is what waits on a service without conditional takes: +// displacing a live holder. A lapsed chat is taken on every service. +const conditionalTakeFeature = "taking an open chat from another computer" + +// missingFrom answers the capabilities a response did not promise. +func missingFrom(h http.Header) []Capability { + var missing []Capability + for _, c := range Capabilities { + if h.Get(c.Header) != "1" { + missing = append(missing, c) + } + } + return missing +} + +// Missing reads chat id and answers the capabilities the service did not +// promise on that read. A chat's own read carries every promise, because the +// promises concern what may be done to a chat. +func (h *HTTP) Missing(ctx context.Context, id string) ([]Capability, error) { + var v CellView + hdr, err := h.doHeader(ctx, http.MethodGet, cellPath(id, ""), nil, &v) + if err != nil { + return nil, err + } + return missingFrom(hdr), nil +} diff --git a/internal/directory/client.go b/internal/directory/client.go index 93bff63921..1c49b7c0ce 100644 --- a/internal/directory/client.go +++ b/internal/directory/client.go @@ -49,9 +49,10 @@ type Client interface { // AcquireOpts says how a device takes a lease. Its JSON is the acquire // request body, and an empty body means the zero value. type AcquireOpts struct { - // RequireConditional is a continuation's local safety requirement, even - // when the observed holder has gone quiet. An ordinary non-forced resume - // still works on older services, whose live-holder check is already atomic. + // RequireConditional asks the coordinating runtime to check the expected + // holder and fence even when the observed holder has gone quiet. A service + // without conditional takes still takes a lapsed lease, because its + // live-holder check is already atomic; only Force waits for the promise. RequireConditional bool `json:"-"` // Force takes the lease from a live holder. It is for a person who chose to // continue a chat where it runs now: waiting out the holder's lease would @@ -93,9 +94,9 @@ var ( ErrUnauthorized = errors.New("directory: unauthorized") ErrUnreachable = errors.New("directory: unreachable") // transport; callers degrade ErrTooOld = errors.New("directory: this relay is too old for that") - // ErrConditionalTake names the update needed before an old service can - // safely move chats; its other read and pairing doors remain usable. - ErrConditionalTake = errors.New("the sync service must be updated before chats can move; codeaf's operators update the hosted service, or run this build of codeaf relay for your own service") + // ErrConditionalTake is the one feature an older service cannot offer + // safely. It says what still works and the one thing to do instead. + ErrConditionalTake = errors.New(conditionalTakeFeature + " waits for a sync service update; all else works, so close it there and continue here") ) // ReleaseQuiet distinguishes background preparation from quitting an open diff --git a/internal/directory/conditional_take_test.go b/internal/directory/conditional_take_test.go index 0474de12fb..3670340119 100644 --- a/internal/directory/conditional_take_test.go +++ b/internal/directory/conditional_take_test.go @@ -13,8 +13,8 @@ import ( ) // TestC7OldServiceRefusesTakesAcrossClients proves C7 at the HTTP door: a -// service without the promise never receives a take, from any client, and its -// absence is read again before each attempt (one read per refused take). +// service without the promise never receives a forced take, from any client, +// and its absence is read again before each attempt (one read per refused take). func TestC7OldServiceRefusesTakesAcrossClients(t *testing.T) { reads, takes := 0, 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -26,9 +26,9 @@ func TestC7OldServiceRefusesTakesAcrossClients(t *testing.T) { _ = json.NewEncoder(w).Encode(directory.CellView{}) })) defer srv.Close() - for _, opts := range []directory.AcquireOpts{{Force: true}, {RequireConditional: true}} { + for range 2 { client := directory.NewHTTP(srv.URL, fakeSign("id_one", devA), srv.Client()) - _, err := client.Acquire(context.Background(), cellX, opts) + _, err := client.Acquire(context.Background(), cellX, directory.AcquireOpts{Force: true, RequireConditional: true}) if !errors.Is(err, directory.ErrConditionalTake) { t.Fatalf("take = %v", err) } @@ -38,6 +38,25 @@ func TestC7OldServiceRefusesTakesAcrossClients(t *testing.T) { } } +// TestOldServiceStillTakesALapsedLease pins #2072: a lapsed lease is taken on +// a service without the promise, because its own acquire still refuses a live +// holder. Waiting for the service's update must never lock a person out. +func TestOldServiceStillTakesALapsedLease(t *testing.T) { + takes := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/acquire") { + takes++ + } + _ = json.NewEncoder(w).Encode(directory.CellView{}) + })) + defer srv.Close() + client := directory.NewHTTP(srv.URL, fakeSign("id_one", devA), srv.Client()) + _, err := client.Acquire(context.Background(), cellX, directory.AcquireOpts{RequireConditional: true, Expect: &directory.LeaseExpectation{Device: devA, Fence: 1}}) + if err != nil || takes != 1 { + t.Fatalf("lapsed take on an old service: err=%v takes=%d", err, takes) + } +} + // TestC7OldServiceStillAllowsAnOrdinaryResume proves C7's compatibility // boundary: a local non-forced resume cannot displace a live other holder. func TestC7OldServiceStillAllowsAnOrdinaryResume(t *testing.T) { diff --git a/internal/directory/http.go b/internal/directory/http.go index c2ab3ffa3d..0fd199b0e7 100644 --- a/internal/directory/http.go +++ b/internal/directory/http.go @@ -74,7 +74,11 @@ func (h *HTTP) Create(ctx context.Context, id string, in CellInit) (v CellView, } func (h *HTTP) Acquire(ctx context.Context, id string, o AcquireOpts) (v CellView, err error) { - if o.Force || o.RequireConditional { + // Only displacing a live holder needs the promise. A take of a lapsed lease + // is safe on every service: an older one still refuses a live holder + // atomically, raises the fence, and answers the head the caller compares + // with the one it saw, so a person is never kept from their own chat (#2072). + if o.Force { if err := h.requireConditionalTake(ctx, id); err != nil { return v, err } 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 e2a7e68616..99302b64b2 100644 --- a/internal/manual/chat/continuing-a-chat-on-another-computer.md +++ b/internal/manual/chat/continuing-a-chat-on-another-computer.md @@ -23,6 +23,10 @@ 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. +## Where an older chat's copy lives — the .moving folder, README.txt, can I delete it + +An older chat's copy is kept in a folder called `.moving` beside the original, in codeaf's state folder for that project. The name is historical: nothing in it is a move in progress. It holds the live copies this build writes to, so deleting it deletes those chats. The folder carries a `README.txt` that says so. The originals beside it stay untouched for older codeaf builds. + ## 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. @@ -59,9 +63,11 @@ A continue card previews only that chat's unsent words, or nothing when no draft Home says `other machines did not answer in time` when its ownership listing reaches a deadline. Your words stay unsent. This sentence does not establish a network failure. -## Why does continue here say this chat was last held here +## Why does continue here say this chat was last held here — my only computer asks to continue + +A chat this computer held last, whose hold expired with no other computer holding it, opens without a question: there is nothing to continue from. Enter sends, and turns this computer saved while it could not reach your other devices are sent to them when the chat opens. Earlier builds asked `Continue this chat here?` on the only computer that used the chat. -When this computer was the chat's last holder and its hold expired, the card says `this chat was last held here; last saved turn 2h ago`, with the age of its saved turn. Choose `continue here` to resume it. The landing says `Moved here` and never names this computer as somewhere the chat moved from. +The card still says `this chat was last held here; last saved turn 2h ago` when this window's own earlier move failed. Choose `continue here` to resume it. The landing says `Moved here` and never names this computer as somewhere the chat moved from. If unpublished work needs to be kept separately, the number of kept turns counts conversation turns. Intermediate saves and tool records within one question and answer do not become extra turns. @@ -188,7 +194,7 @@ If nothing happens when you press `Continue here`, the cause is one of these: - `other machines unreachable`: this device cannot reach the service. Nothing can be moved until it can. - `could not continue this chat here`: the move failed. The row stays where it was; try again. - `could not continue this chat here because other machines are unreachable; your words are still in the box`: the service went away during the move. Nothing was sent and the chat stays where it was; send again once the other machines are reachable. -- A sentence starting `the sync service must be updated before chats can move` means the service is too old to move chats safely. The operators update the hosted service; for your own service, follow the update command in that sentence on the terminal. Pairing, listing and viewing still work. Reopen codeaf after the service is updated. +- `taking an open chat from another computer waits for a sync service update; all else works, so close it there and continue here` means the chat is open on another computer and the sync service is too old to take it from there safely. Everything else works, including continuing a chat the other computer has closed. Close the chat on that computer, then choose `continue here` again. - With no way to continue set up, an off row has no `continue here`: enter says why. ## Taking a chat back, and two computers racing for the same chat — who wins, and what the loser sees diff --git a/internal/manual/chat/reaching-this-machine-without-ssh.md b/internal/manual/chat/reaching-this-machine-without-ssh.md index f9a759416e..7ac4ea529c 100644 --- a/internal/manual/chat/reaching-this-machine-without-ssh.md +++ b/internal/manual/chat/reaching-this-machine-without-ssh.md @@ -334,6 +334,11 @@ the name or after it; `codeaf devices revoke --help` prints it. It used to be re it came first, so `codeaf devices revoke laptop --all` was refused with a usage line that did not mention `--all` at all. +`--all` reaches every device `codeaf devices` lists under that name, of either kind: a +computer that syncs your chats as well as a device that can use this machine. It used to look +only at devices that can use this machine, so `codeaf devices revoke --all` said `no +device called "" is paired with this machine` for a computer the list showed. + Removing one device with `--all` answers in the ordinary sentence — `laptop was removed — it can no longer open a chat here, and it needs a new pairing code to come back.` Removing several answers `2 devices called laptop were removed — each needs a new pairing code to come diff --git a/internal/manual/chat/relay-hosted-or-your-own.md b/internal/manual/chat/relay-hosted-or-your-own.md index 9fc2b5ef0e..ad89717d82 100644 --- a/internal/manual/chat/relay-hosted-or-your-own.md +++ b/internal/manual/chat/relay-hosted-or-your-own.md @@ -56,4 +56,6 @@ Moving chats and pairing are available by default on Linux, macOS and native Win ## My sync service is too old to move chats — update before continuing here -An older service can still pair computers and list or show chats, but this build refuses to move a chat through it. It says `the sync service must be updated before chats can move; codeaf's operators update the hosted service, or run this build of codeaf relay for your own service`. The hosted service is updated by codeaf's operators. For your own service, replace the running `codeaf relay` with this build. Reopen codeaf after the update; the service's answer is remembered for the current run. +An older sync service still pairs computers, lists and shows chats, saves every chat you write, and continues a chat another computer has closed. Only taking a chat that is open on another computer waits for it, because only an updated service can promise that the take displaces the holder you saw. This build says `taking an open chat from another computer waits for a sync service update; all else works, so close it there and continue here`. Close the chat on that computer and continue here. For your own service, replace the running `codeaf relay` with this build; the next attempt uses the update without reopening codeaf. + +A chat you write on this computer is never held back by an older service. Releases of codeaf check that the hosted service answers what they need before they ship. diff --git a/internal/manual/chat/relay-limits-and-refusals.md b/internal/manual/chat/relay-limits-and-refusals.md index c2ead41b16..36d4797582 100644 --- a/internal/manual/chat/relay-limits-and-refusals.md +++ b/internal/manual/chat/relay-limits-and-refusals.md @@ -1,8 +1,8 @@ # Relay limits and refusals -## The sync service must be updated before chats can move +## Taking an open chat from another computer waits for a sync service update -If an old service cannot safely decide which computer continues a chat first, no move is sent. The sentence is `the sync service must be updated before chats can move; codeaf's operators update the hosted service, or run this build of codeaf relay for your own service`. Pairing, listing and viewing keep working. codeaf's operators update the hosted service; update your own `codeaf relay` to this build. Reopen codeaf after the update. +An older service cannot promise that taking a chat displaces only the computer you saw holding it, so this build never sends that take to it. The sentence is `taking an open chat from another computer waits for a sync service update; all else works, so close it there and continue here`. Writing, saving, pairing, listing, viewing and continuing a chat the other computer closed keep working. Close the chat on that computer and continue here; update your own `codeaf relay` to this build. ## Is there a storage limit on the relay — how much can I sync diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index 020422d464..bfe2e32354 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -462,7 +462,9 @@ other machines cannot be reached it starts with `other machines unreachable`. ## A different sync setting on an existing engine -The engine keeps its sync setting from startup. Changing `CODEAF_SYNC_URL` to another address or `off` retires an idle engine before opening a chat and says `the engine restarted to pick up this terminal's sync setting`. If another window or work still uses it, the new window refuses to attach: `the background engine for this folder started with a different environment than this terminal and is still in use — after work finishes, close every window for this workspace and reopen. To stop now (interrupts active turns and tasks), run codeaf engine --stop --workspace `. The command names the exact folder. Let that work finish or stop the engine explicitly before reopening with the new setting. +The engine keeps its sync setting from startup. Changing `CODEAF_SYNC_URL` to another address or `off` retires an idle engine before opening a chat and says `the engine restarted to pick up this terminal's sync setting`. If another window or work still uses it, the new window refuses to attach: `the background engine for this folder started with a different environment than this terminal and is still in use — codeaf chat --no-host opens this window on its own and leaves the other one running; or, after work finishes, close every window for this workspace and reopen. To stop now (interrupts active turns and tasks), run codeaf engine --stop --workspace `. The command names the exact folder. + +To use the new setting at once without touching the other window, run `codeaf chat --no-host`, with `--session ` to open a particular chat; for example `CODEAF_SYNC_URL=off codeaf chat --no-host`. That window runs its own engine in this terminal and ends with it. Only a terminal that sets `CODEAF_SYNC_URL` itself is asking for a different relay. A window with the variable unset — `codeaf chat --host` reaching this computer over ssh, whose shell usually does not carry what you exported, or a terminal you never set it in — joins the running engine and its relay as it is. It restarts the engine only when the relay a pairing saved on this computer has changed since the engine started. diff --git a/internal/relayserve/compatibility_refusal_test.go b/internal/relayserve/compatibility_refusal_test.go index d1483cabc8..1e59eb641f 100644 --- a/internal/relayserve/compatibility_refusal_test.go +++ b/internal/relayserve/compatibility_refusal_test.go @@ -13,7 +13,7 @@ func TestR25CompatibilityRefusalSentences(t *testing.T) { for _, one := range []struct{ got, want string }{ {cs.EnrollmentMessage(cs.UpdateRequired), "this codeaf build must be updated before it can join this cluster; your saved chats are still here"}, {cs.LeadershipUnavailable, "this cluster cannot coordinate right now; reconnect enough of its voting computers and try again; your saved chats are still here"}, - {directory.ErrConditionalTake.Error(), "the sync service must be updated before chats can move; codeaf's operators update the hosted service, or run this build of codeaf relay for your own service"}, + {directory.ErrConditionalTake.Error(), "taking an open chat from another computer waits for a sync service update; all else works, so close it there and continue here"}, {clusterctl.CommittedRefusal{Code: cs.Conflict, Sequence: 1}.Error(), "the leadership request could not complete; refresh cluster status and try again"}, } { if one.got != one.want { diff --git a/internal/syncsetup/drive.go b/internal/syncsetup/drive.go index 13f4c6d885..71cc7013a6 100644 --- a/internal/syncsetup/drive.go +++ b/internal/syncsetup/drive.go @@ -267,17 +267,15 @@ func (s *Sync) driving(ctx context.Context, c cell.Cell) (cellsync.Driving, stri if !known { return drv, nonemptyHolder(view.Cell.Lease.Device), nil } - var localHead string + // Turns sealed here on top of the record's head are this computer's own + // work, not an older tree: taking the free lease publishes them (catchUp). wasFree := view.Cell.Lease.Expires <= view.Now if wasFree { - head, err := cellstore.Head(c) + extends, err := cellstore.Extends(c, view.Cell.Head) if err != nil { return drv, "", err } - if head != nil { - localHead = head.Turn.ID - } - if localHead != view.Cell.Head { + if !extends { return drv, nonemptyHolder(view.Cell.Lease.Device), nil } } @@ -293,7 +291,7 @@ 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 || (wasFree && localHead != v.Cell.Head) { + if err != nil || !known || (wasFree && view.Cell.Head != v.Cell.Head) { _ = s.Dir.Release(ctx, drv.ID(), v.Cell.Lease.Fence) return drv, nonemptyHolder(view.Cell.Lease.Device), err } diff --git a/internal/syncsetup/drive_refusal_safety_test.go b/internal/syncsetup/drive_refusal_safety_test.go index ab18352543..3d3f57a1b4 100644 --- a/internal/syncsetup/drive_refusal_safety_test.go +++ b/internal/syncsetup/drive_refusal_safety_test.go @@ -48,7 +48,11 @@ func TestAnExpiredHoldCannotAuthorizeTools(t *testing.T) { } } -func TestReleasedOlderHeadIsNotAcquiredOverLocalWork(t *testing.T) { +// TestReleasedHeadIsContinuedWithThisComputersNewerTurns pins #2072: turns +// this computer sealed on top of a released head are its own work, so the +// drive takes the free lease and publishes them instead of opening the chat +// read-only. It replaces a law that locked the only computer out of its chat. +func TestReleasedHeadIsContinuedWithThisComputersNewerTurns(t *testing.T) { r := newDriveRig(t) first, err := r.engine.Seal(t.Context(), r.cell, cellstore.TurnInfo{}) mustNil(t, err) @@ -60,7 +64,7 @@ func TestReleasedOlderHeadIsNotAcquiredOverLocalWork(t *testing.T) { 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") + if err := drive.Gate(); err != nil { + t.Errorf("this computer's newer turns opened read-only: %v", err) } } diff --git a/internal/syncsetup/old_service_write_test.go b/internal/syncsetup/old_service_write_test.go new file mode 100644 index 0000000000..941db6ceff --- /dev/null +++ b/internal/syncsetup/old_service_write_test.go @@ -0,0 +1,77 @@ +package syncsetup + +import ( + "context" + "net/http" + "testing" + + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/executor" +) + +// oldService is a sync service deployed before conditional takes: every route +// answers, but no response carries the promise. +func oldService(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(withoutTakePromise{w}, r) + }) +} + +// TestOldServiceStillLetsTheOnlyComputerWriteItsChat pins #2072's lockout: a +// chat whose lease has lapsed, and that no other device holds, opens as this +// computer's to write even when the service makes no conditional-take promise. +// The turn is published, so the other computers still see it. +func TestOldServiceStillLetsTheOnlyComputerWriteItsChat(t *testing.T) { + h := newTwoHomes(t, oldService) + ctx := context.Background() + a := h.openA() + a.mustSay("first words") + h.durable(h.cell.ID, h.cell) + if err := a.drive.Close(ctx); err != nil { + t.Fatal(err) + } + h.lapse() + + again := h.openA() + if err := again.drive.Gate(); err != nil { + t.Fatalf("the only computer reopened its chat as a viewer: %v", err) + } + again.mustSay("second words") + h.durable(h.cell.ID, h.cell) +} + +// TestOldServiceLetsLocalTurnsCatchUp pins the lockout's second step: turns +// this computer saved while it could not publish, on top of the head the +// service has, are this computer's own work. Reopening the chat publishes them +// rather than opening it read-only behind "Continue this chat here?". +func TestOldServiceLetsLocalTurnsCatchUp(t *testing.T) { + h := newTwoHomes(t, oldService) + ctx := context.Background() + a := h.openA() + a.mustSay("published words") + h.durable(h.cell.ID, h.cell) + if err := a.drive.Close(ctx); err != nil { + t.Fatal(err) + } + h.lapse() + + // A window that ran unsynced seals on the engine alone. + seat, err := cellstore.SeatOver(executor.HostBound, h.cell, h.work, nil, nil, + func(cellstore.Engine) cellstore.Store { return h.engine }) + if err != nil { + t.Fatal(err) + } + local := &openChat{t: t, seat: seat, cell: h.cell, work: h.work, calls: 10} + local.mustSay("words saved only here") + if h.directoryHead(h.cell.ID) == headOf(t, h.cell) { + t.Fatal("the unsynced turn reached the service") + } + + again := h.openA() + if err := again.drive.Gate(); err != nil { + t.Fatalf("this computer's own newer turns opened read-only: %v", err) + } + h.durable(h.cell.ID, h.cell) + again.mustSay("third words") + h.durable(h.cell.ID, h.cell) +} diff --git a/internal/syncsetup/service.go b/internal/syncsetup/service.go new file mode 100644 index 0000000000..0ee6930a0c --- /dev/null +++ b/internal/syncsetup/service.go @@ -0,0 +1,55 @@ +package syncsetup + +import ( + "context" + "errors" + "os" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/identity" +) + +// ProbeService asks the sync service at url what this build needs it to +// promise ([directory.Capabilities]), and answers the promises it did not +// make. It acts as a new identity of its own, in a home it removes, so it +// reads nothing of any person and leaves only one empty archived chat record. +// The release gate runs it against the live service before a build ships. +func ProbeService(ctx context.Context, url string) ([]directory.Capability, error) { + base, err := relayBase(url) + if err != nil { + return nil, err + } + home, err := os.MkdirTemp("", "codeaf-probe-") + if err != nil { + return nil, err + } + defer os.RemoveAll(home) + id, err := identity.Ensure(home) + if err != nil { + return nil, err + } + dev, err := identity.Device(home) + if err != nil { + return nil, err + } + s := build(home, base, id, dev, 0) + if err := s.putDevice(ctx, "release check"); err != nil { + return nil, err + } + probe, err := cell.NewID(s.now()) + if err != nil { + return nil, err + } + if _, err := s.Dir.Create(ctx, probe, directory.CellInit{Class: string(cell.HostBound)}); err != nil { + return nil, err + } + reader, ok := s.Dir.(interface { + Missing(context.Context, string) ([]directory.Capability, error) + }) + if !ok { + return nil, errors.New("this sync client cannot read a service's promises") + } + missing, err := reader.Missing(ctx, probe) + return missing, errors.Join(err, s.Dir.Archive(ctx, probe)) +} diff --git a/internal/syncsetup/service_test.go b/internal/syncsetup/service_test.go new file mode 100644 index 0000000000..fededab543 --- /dev/null +++ b/internal/syncsetup/service_test.go @@ -0,0 +1,38 @@ +package syncsetup + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/relayserve" +) + +// TestProbeServiceNamesWhatAnOlderServiceLacks pins #2072's release gate: the +// probe finds every promise on the current service, and names the feature that +// waits on a service deployed before conditional takes. +func TestProbeServiceNamesWhatAnOlderServiceLacks(t *testing.T) { + svc := relayserve.New(relayserve.Config{Store: t.TempDir(), Grace: directory.GraceBounds{Min: time.Second, Max: time.Hour, Default: 10 * time.Second}}) + t.Cleanup(func() { _ = svc.Close() }) + for _, old := range []bool{false, true} { + var h http.Handler = svc.Handler + if old { + h = oldService(h) + } + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + missing, err := ProbeService(context.Background(), srv.URL) + if err != nil { + t.Fatalf("old=%v: probe: %v", old, err) + } + if !old && len(missing) != 0 { + t.Fatalf("the current service lacks %v", missing) + } + if old && (len(missing) != 1 || missing[0].Header != directory.ConditionalTakeHeader) { + t.Fatalf("the older service lacks %v; want conditional takes", missing) + } + } +} diff --git a/internal/tui3/homeband_resume.go b/internal/tui3/homeband_resume.go index 2c38baba4d..e566935e02 100644 --- a/internal/tui3/homeband_resume.go +++ b/internal/tui3/homeband_resume.go @@ -102,7 +102,7 @@ func (s resumeState) pick() *chatlist.Row { // offered says a row is a chat to rescue. func (s resumeState) offered(row chatlist.Row) bool { live := row.Status == chatlist.Running || row.Status == chatlist.Off || (row.Status == chatlist.Idle && !row.Mine) - return live && !row.Mine && (s.self == "" || row.DeviceID != s.self) && row.Device != "" && !s.answered[row.Device] && row.DurableAgo <= resumeRecent && s.away(row) + return live && !row.Mine && !row.HolderRemoved && (s.self == "" || row.DeviceID != s.self) && row.Device != "" && !s.answered[row.Device] && row.DurableAgo <= resumeRecent && s.away(row) } // away says the chat's device has let go of it or is gone: its lease lapsed, diff --git a/internal/tui3/homeband_resume_test.go b/internal/tui3/homeband_resume_test.go index 80aa550090..1e9925937c 100644 --- a/internal/tui3/homeband_resume_test.go +++ b/internal/tui3/homeband_resume_test.go @@ -13,6 +13,10 @@ func leftOffRow(cell, device string, status chatlist.Status, ago time.Duration) return chatlist.Row{Cell: cell, Title: cell, Device: device, DeviceID: "dev_" + device, Status: status, DurableAgo: ago} } +// removedRow is a row whose holder was removed from the person's devices: its +// lease reads as released, and it is never offered (#2072). +func removedRow(r chatlist.Row) chatlist.Row { r.HolderRemoved = true; return r } + // TestResumePicksTheNewestRecentChatOnAnOfflineDevice proves D6 for released and offline chats. func TestResumePicksTheNewestRecentChatOnAnOfflineDevice(t *testing.T) { cases := []struct { @@ -28,6 +32,7 @@ func TestResumePicksTheNewestRecentChatOnAnOfflineDevice(t *testing.T) { {"online and let go", resumeState{feedUp: true, online: []string{"dev_tower"}, rows: []chatlist.Row{leftOffRow("a", "tower", chatlist.Idle, time.Minute)}}, "a"}, {"too old", resumeState{rows: []chatlist.Row{leftOffRow("a", "tower", chatlist.Off, 3*resumeRecent)}}, ""}, {"this device", resumeState{rows: []chatlist.Row{leftOffRow("a", "mac", chatlist.Here, time.Minute)}}, ""}, + {"a removed device", resumeState{rows: []chatlist.Row{removedRow(leftOffRow("a", "tower", chatlist.Idle, time.Minute))}}, ""}, {"newest wins", resumeState{rows: []chatlist.Row{ leftOffRow("old", "desk", chatlist.Off, 5*time.Hour), leftOffRow("new", "tower", chatlist.Off, time.Hour)}}, "new"}, } diff --git a/internal/tui3/homepanel_continue_test.go b/internal/tui3/homepanel_continue_test.go index 3a07825319..7203b06537 100644 --- a/internal/tui3/homepanel_continue_test.go +++ b/internal/tui3/homepanel_continue_test.go @@ -353,7 +353,7 @@ func TestC7HomeExplainsAnOldSyncService(t *testing.T) { a := continueHome(t, &fakeTaker{err: directory.ErrConditionalTake}, continueRows, 220) standOn(t, a, "c-off") confirm(t, a) - if !strings.Contains(homeText(a), "the sync service must be updated before chats can move") { + if !strings.Contains(homeText(a), "waits for a sync service update") { t.Fatalf("old service refusal missing:\n%s", homeText(a)) } }