Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 8 additions & 5 deletions PERF.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,16 +121,16 @@ same commit; derive repeated values from constants.

## Paired team history

These sharing budgets apply only when **share teams** is on in Settings → Devices.
Sharing is off by default on each computer; off rounds run no team carriers,
These sharing budgets apply whenever **share teams** is on in Settings → General,
which is the default for paired computers. Off rounds run no team carriers,
identity discovery, mirror writes or ownership lookups and retain existing polling.

`teams.TrafficSyncLines` limits each writing computer's window to 32 lines per
team, and `teams.TrafficSyncBytes` limits encoded lines to 16 KiB per team.
`teams.TrafficSyncTextBytes` shortens text beyond 2 KiB at a rune boundary.
`teams.TrafficSyncDeviceBytes` caps each device's encoded Traffic share at 64 KiB
across teams, newest local activity first. `teams.TrafficSyncEvery` permits a
publication at most once every 30 seconds; team record edits are not delayed.
publication at most once every 2 minutes; team record edits are not delayed.
Capture reads the bounded backward tail across the current and rotated logs. Received windows replace a
separate display mirror and are never republished. An unchanged round on 500
chats and 5 teams performs zero world scans and opens zero cell metadata files;
Expand All @@ -140,8 +140,11 @@ compare deterministic values, and verify quiet rounds leave the vault unchanged.
Mirror filtering makes zero directory requests per vault round, using only an
already-held local roster, or every other writer when none is available. A send
shares one ownership reading across its recipients, with a 3-second timeout and
a 5-second process cache (including failures). Cursor saves stat identity files
but decode them only after a stamp changes. Mirror refresh never acknowledges
a 5-second process cache (including failures). With effective sharing, cursor saves stat identity files
but decode them only after a stamp changes. Unpaired computers retain legacy
cursors, repairing reads and completion catalogs, with no sharing refresh clock
or mirror reads. The first team capture preserves the original file once as
`teams.before-sharing.json` before creating its ledger. Mirror refresh never acknowledges
local news and each hosted window carries its own mirror version.

## Binary size
Expand Down
1 change: 1 addition & 0 deletions cmd/codeaf/chatv3.go
Original file line number Diff line number Diff line change
Expand Up @@ -1015,6 +1015,7 @@ func v3ConfigFor(proc *v3Process, opts v3Options, workspace string, place sessio

cfg := session.Config{
TeamComputers: teamComputers,
TeamsPaired: teamPaired,
Workspace: workspace,
Model: chosen,
APIKey: settings.APIKey,
Expand Down
17 changes: 16 additions & 1 deletion cmd/codeaf/chatv3_host_teams.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ func hostTeamsSeam(far hostFar, welcome remote.Welcome) tui3.TeamsSeam {
// when the engine answers them, so an older engine's window has a seam whose
// delegation doors are nil, which the surface says rather than guesses at.
func (h *hostTeams) seam() tui3.TeamsSeam {
s := tui3.TeamsSeam{Load: h.load, ReadSince: h.readSince, Update: h.update, Traffic: h.readTraffic}
s := tui3.TeamsSeam{Sharing: h.sharing, Load: h.load, ReadSince: h.readSince, Update: h.update, Traffic: h.readTraffic}
if h.history != nil {
s.DisplayTraffic = func(team, after, version string, limit int) ([]teamstore.Entry, string, string, bool, error) {
got, err := h.history(team, after, version, limit)
Expand Down Expand Up @@ -306,3 +306,18 @@ func (h *hostTeams) forgetChecked(team string, expected []string) ([]string, err
}
return reply.Gone, nil
}

// sharing asks the engine rather than borrowing the viewing computer's pairing.
func (h *hostTeams) sharing() (bool, error) {
_, stamp, _ := h.held(nil)
reading, err := h.read(stamp, h.reservedHues())
if err != nil {
return false, err
}
if reading.Sharing != nil {
return *reading.Sharing, nil
}
// Older engines gate their own history. Keep that display door available
// when they cannot report the gate separately.
return h.history != nil, nil
}
37 changes: 37 additions & 0 deletions cmd/codeaf/chatv3_host_teams_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package main

import (
"errors"
"github.com/Agent-Field/codeaf/internal/config"
"sync/atomic"
"testing"

"github.com/Agent-Field/codeaf/internal/remote"
Expand Down Expand Up @@ -239,3 +241,38 @@ func TestHostTeamsKeepsClosedHistoryOffTheWire(t *testing.T) {
t.Fatal("the host seam offered to read closed history from this laptop")
}
}

// R4/F1/F7 carries effective sharing from the actual engine response through the hosted seam.
func TestHostedTeamsSeamReadsEngineEffectiveSharing(t *testing.T) {
dir := t.TempDir()
var paired atomic.Bool
loop, err := remote.Loopback(remote.Hello{Version: remote.Version}, remote.Options{Boot: func(remote.Hello) (*remote.Engine, error) {
return &remote.Engine{Agent: &quietAgent{}, ProfileDir: dir, TeamsPaired: paired.Load}, nil
}})
if err != nil {
t.Fatal(err)
}
defer loop.Close()
seam := hostTeamsSeam(hostFar{client: loop.Client}, loop.Client.Welcome())
if seam.Sharing == nil {
t.Fatal("hosted seam has no engine sharing read")
}
if _, _, _, err := seam.ReadSince("", nil); err != nil {
t.Fatal(err)
}
row, _ := config.NewSettings(config.SettingsOptions{ProfileDir: dir}).Row(config.KeyTeamsShare)
for _, tc := range []struct {
paired bool
choice string
want bool
}{{true, "on", true}, {false, "on", false}, {true, "off", false}, {true, "on", true}} {
paired.Store(tc.paired)
if err := row.Apply(tc.choice); err != nil {
t.Fatal(err)
}
got, err := seam.Sharing()
if err != nil || got != tc.want {
t.Fatalf("engine paired=%v preference=%s: sharing=%v err=%v", tc.paired, tc.choice, got, err)
}
}
}
4 changes: 4 additions & 0 deletions cmd/codeaf/chatv3_pair.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"context"
"errors"

"github.com/Agent-Field/codeaf/internal/config"
"github.com/Agent-Field/codeaf/internal/home"
"github.com/Agent-Field/codeaf/internal/identity"
"github.com/Agent-Field/codeaf/internal/pair"
Expand Down Expand Up @@ -110,3 +111,6 @@ type chatOfferUI struct {
func (u chatOfferUI) Show(code *pair.Code, _ string) {
u.OfferUI.Show(code, pair.ChatCardLines(code, u.service))
}

// HasTeams checks this computer's profile even while a hosted chat is open.
func (chatPairDoor) HasTeams() bool { return pair.HasTeams(config.ProfileDir()) }
176 changes: 176 additions & 0 deletions cmd/codeaf/chatv3_pair_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,19 @@ package main
// same fact points at the /pair keystroke the person is already sitting at.

import (
"bytes"
"context"
"errors"
"fmt"
"github.com/Agent-Field/codeaf/internal/identity"
"github.com/Agent-Field/codeaf/internal/syncsetup"
"github.com/Agent-Field/codeaf/internal/teams"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/Agent-Field/codeaf/internal/pair"
)
Expand All @@ -32,10 +41,177 @@ func TestChatJoinRefusalNamesTheInChatReplace(t *testing.T) {

// The replacing errand reaches pair.Join with replace set: the interface keeps
// the two doors from disagreeing about what a replace is.
// F1 preserves the existing team surface contract with an explicit pairing gate.
func TestChatPairDoorImplementsBothJoins(t *testing.T) {
var _ interface {
Join(ctx context.Context, typed string, ui pair.JoinUI) (pair.Joined, error)
JoinReplacing(ctx context.Context, typed string, ui pair.JoinUI) (pair.Joined, error)
HasOwnChats() bool
} = chatPairDoor{}
}

// F3 includes teams in terminal replacement confirmation even without an identity or chats.
func TestTerminalPairReplaceWithOnlyTeamsAsksBeforeRelay(t *testing.T) {
for _, link := range []bool{false, true} {
t.Run(fmt.Sprintf("link=%v", link), func(t *testing.T) {
dir := t.TempDir()
if err := teams.Update(dir, func(f *teams.File) error { f.Teams = []teams.Team{{ID: "local", Name: "harbor"}}; return nil }); err != nil {
t.Fatal(err)
}
before, _ := os.ReadFile(teams.Path(dir))
out := &bytes.Buffer{}
d := newPairDoor(strings.NewReader("n\n"), out)
d.asksName = true
d.joining = func(replace bool) pair.Joining { return pair.Joining{Home: dir, Replace: replace} }
d.mailbox = func(string) (pair.Mailbox, error) {
t.Fatal("declined replacement reached relay")
return pair.Mailbox{}, nil
}
var err error
if link {
err = d.askToJoin(t.Context(), "", true)
} else {
err = d.join(t.Context(), "", "71-869-571", true)
}
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), pair.ReplaceAskLine) {
t.Fatal("terminal omitted teams confirmation", out.String())
}
after, _ := os.ReadFile(teams.Path(dir))
if !bytes.Equal(before, after) {
t.Fatal("declining changed teams")
}
})
}
}

// F1 uses the same quiet pairing gate for the production session and surface callbacks.
func TestTeamPairedFollowsPairingAndSyncOff(t *testing.T) {
root := t.TempDir()
t.Setenv("CODEAF_HOME", root)
t.Setenv(syncsetup.URLVar, "")
if teamPaired() {
t.Fatal("missing identity was paired")
}
if _, err := identity.EnsureSolo(root); err != nil {
t.Fatal(err)
}
if teamPaired() {
t.Fatal("solo identity was paired")
}
if err := identity.EndSolo(root); err != nil {
t.Fatal(err)
}
if !teamPaired() {
t.Fatal("shared identity was unpaired")
}
t.Setenv(syncsetup.URLVar, "off")
if teamPaired() {
t.Fatal("sync off kept sharing effective")
}
}

// R3/F3 preserves non-terminal --replace for existing chats and teams without reading a silent pipe.
func TestTerminalPairReplaceWithoutTerminalNeverWaitsForInput(t *testing.T) {
for _, ownChats := range []bool{false, true} {
for _, link := range []bool{false, true} {
t.Run(fmt.Sprintf("ownChats=%v/link=%v", ownChats, link), func(t *testing.T) {
dir := t.TempDir()
if ownChats {
if _, err := identity.Ensure(dir); err != nil {
t.Fatal(err)
}
}
if err := teams.Update(dir, func(f *teams.File) error { f.Teams = []teams.Team{{ID: "local", Name: "harbor"}}; return nil }); err != nil {
t.Fatal(err)
}
in, writer := io.Pipe()
defer in.Close()
defer writer.Close()
out := &pairScreenText{}
d := newPairDoor(in, out)
d.joining = func(replace bool) pair.Joining { return pair.Joining{Home: dir, Replace: replace} }
reached := errors.New("reached pairing without input")
d.mailbox = func(string) (pair.Mailbox, error) { return pair.Mailbox{}, reached }
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
done := make(chan error, 1)
go func() {
if link {
done <- d.askToJoin(ctx, "", true)
} else {
done <- d.join(ctx, "", "71-869-571", true)
}
}()
select {
case err := <-done:
if !errors.Is(err, reached) {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("non-terminal replacement waited for input")
}
if strings.Contains(out.String(), pair.ReplaceAskLine) {
t.Fatal("non-terminal replacement asked a question")
}
})
}
}
}

// R3/F3 completes non-terminal teams-only replacement and prints the preserved team's actual path.
func TestTerminalTeamsOnlyReplacePrintsAsideReceiptWithoutInput(t *testing.T) {
rig := newPairRig(t)
if err := teams.Update(rig.homeB, func(f *teams.File) error { f.Teams = []teams.Team{{ID: "local", Name: "harbor"}}; return nil }); err != nil {
t.Fatal(err)
}
before, err := os.ReadFile(teams.Path(rig.homeB))
if err != nil {
t.Fatal(err)
}
shown := rig.show(t)
code := shown.code(t)
in, writer := io.Pipe()
defer in.Close()
defer writer.Close()
out := &pairScreenText{}
d := rig.door(rig.homeB, in, out)
joining := d.joining
d.joining = func(replace bool) pair.Joining {
j := joining(replace)
j.TeamsPaired = func() bool { return true }
return j
}
ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second)
defer cancel()
done := make(chan error, 1)
go func() { done <- d.run(ctx, []string{code, "--replace"}) }()
shown.out.waitFor(t, `y / n `)
if _, err := io.WriteString(shown.typed, "yes\n"); err != nil {
t.Fatal(err)
}
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-ctx.Done():
t.Fatal("teams-only replacement waited for terminal input")
}
if err := shown.end(t); err != nil {
t.Fatal(err)
}
paths, err := filepath.Glob(teams.Path(rig.homeB) + ".unreadable-*")
if err != nil || len(paths) != 1 {
t.Fatal("replacement did not set teams aside", paths, err, out.String())
}
if !strings.Contains(out.String(), pair.TeamsAsideLine(paths[0])) || strings.Contains(out.String(), pair.ReplaceAskLine) {
t.Fatal("missing non-terminal receipt", out.String())
}
got, err := os.ReadFile(paths[0])
if err != nil || !bytes.Equal(got, before) {
t.Fatal("receipt's file did not preserve teams", string(got), err)
}
}
8 changes: 8 additions & 0 deletions cmd/codeaf/chatv3_surface.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
internalenv "github.com/Agent-Field/codeaf/internal/env"
"github.com/Agent-Field/codeaf/internal/guard"
"github.com/Agent-Field/codeaf/internal/home"
"github.com/Agent-Field/codeaf/internal/teams"
"github.com/Agent-Field/codeaf/internal/tui3"
codeupdate "github.com/Agent-Field/codeaf/internal/update"
)
Expand Down Expand Up @@ -48,6 +49,12 @@ var (
// it would lack is everything below, which is why [TestOnlyTheSurfaceHelperRunsTheV3Surface]
// reads this package's sources rather than trusting the next door to remember.
func runSurface(ctx context.Context, options tui3.Options) error {
if options.Host == "" {
if options.Notices == nil {
options.Notices = surfaceNotices
}
defer teams.WatchSharingErrors(options.ProfileDir, options.Notices.SayOnce)()
}
if options.SyncService == "" && options.Host == "" {
options.SyncService = chatSyncService()
}
Expand Down Expand Up @@ -100,6 +107,7 @@ func runSurface(ctx context.Context, options tui3.Options) error {
options.Restart = restart
}
options.CellsUnavailable = cell.UnavailableReason()
options.TeamsPaired = teamPaired
if options.TaskComputers == nil && options.Host == "" {
options.TaskComputers = v3TaskComputers(options.ProfileDir)
}
Expand Down
1 change: 1 addition & 0 deletions cmd/codeaf/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -1170,6 +1170,7 @@ func bootEngine(hello remote.Hello, workspaceFlag, sessionFlag string) (*remote.
proc.warmModels("engine/models", agent, launch.Model)

return &remote.Engine{
TeamsPaired: teamPaired,
SyncService: chatSyncService(),
ReleaseOnLeave: func(current remote.WrappedAgent) bool {
built, ok := current.(*session.Agent)
Expand Down
Loading
Loading