Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0ef56b7
Keep a chat continued at startup owned by the window that took it
AbirAbbas Oct 26, 2026
bb17565
Let a coordinating computer's clean quit release its chats, then hand…
AbirAbbas Oct 26, 2026
055aa27
Say a removed computer was removed when it asks for cluster status or…
AbirAbbas Oct 26, 2026
04175fc
Keep continued words, count only real local work, hide empty remote c…
AbirAbbas Oct 26, 2026
216ec5c
Add the change entry for the two-computer fixes
AbirAbbas Oct 26, 2026
7e081cb
Keep the manual's moving and task questions reaching the pages that a…
AbirAbbas Oct 26, 2026
9800960
Number the two-computer change entry.
AbirAbbas Oct 26, 2026
11df874
Name the pull request in the two-computer change entry.
AbirAbbas Oct 26, 2026
74d2d29
Keep a displaced window's reply bookkeeping out of kept local work
AbirAbbas Oct 26, 2026
390cb96
Carry the words typed in a displaced window into the chat it continues
AbirAbbas Oct 26, 2026
a60bd5f
Publish a chat to other computers only after its first conversation turn
AbirAbbas Oct 26, 2026
5601d84
Say a removed computer was removed when its cluster commands are refused
AbirAbbas Oct 26, 2026
bdfd450
Stop a removed computer's sync traffic until it is paired again
AbirAbbas Oct 26, 2026
d332495
Read repositories without taking git's optional index lock
AbirAbbas Oct 26, 2026
c85c3bd
Send only from a chat this window holds, and leave a failed move safe
AbirAbbas Oct 26, 2026
ac3d3f3
Keep the whole draft when continuing a chat fails
AbirAbbas Oct 26, 2026
61b333a
Offer nothing about continuing on a computer that was removed
AbirAbbas Oct 26, 2026
4c7ccbd
Explain a removed computer's stopped syncing without the word relay
AbirAbbas Oct 26, 2026
09983a2
Pace the removal traffic test's heartbeats until the removal is known
AbirAbbas Oct 26, 2026
f2d4c46
Announce a displaced chat only when another computer took it
AbirAbbas Oct 26, 2026
9f7be95
Keep each chat's draft with that chat when continuing from Home
AbirAbbas Oct 26, 2026
efb04b1
Keep the directory's orphaned draft adoption, and limit draft ownersh…
AbirAbbas Oct 26, 2026
958e7f0
Keep "how do I add another computer" reaching the pairing steps
AbirAbbas Oct 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/codeaf-demo-home/seed.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions cmd/codeaf-release/promotion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/codeaf/chatv3_layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion cmd/codeaf/chatv3_place.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
86 changes: 86 additions & 0 deletions cmd/codeaf/chatv3_quit_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
13 changes: 12 additions & 1 deletion cmd/codeaf/chatv3_surface.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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())
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/codeaf/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions cmd/codeaf/cluster_removed_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
})
}
}
10 changes: 9 additions & 1 deletion cmd/codeaf/devices.go
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
}
11 changes: 6 additions & 5 deletions cmd/codeaf/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down
27 changes: 27 additions & 0 deletions cmd/codeaf/startup_maintenance.go
Original file line number Diff line number Diff line change
@@ -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
}
28 changes: 28 additions & 0 deletions cmd/codeaf/startup_maintenance_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
2 changes: 1 addition & 1 deletion cmd/codeaf/synctake.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions docs/changes/unreleased/1967-two-computer-fixes.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 4 additions & 1 deletion docs/relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions internal/cellstore/captureplan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions internal/cellstore/gitwork.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading