diff --git a/PERF.md b/PERF.md index 40195268f4..f564b60e6f 100644 --- a/PERF.md +++ b/PERF.md @@ -831,3 +831,21 @@ method and measurements are in [docs/perf/memory-2026-10-24.md](docs/perf/memory once a minute. macOS keeps freed pages in a process's resident size until it needs them (`MADV_FREE_REUSABLE`), so `ps` RSS shows the peak of a burst while the footprint (`vmmap --summary`, Activity Monitor) shows what is held. + +## Paired idle save certification + +The idle-only `cellstore.SavedState` keeps the first fingerprint for each path +and stops climbing at an already visited ancestor. A proof admits at most +20,000 paths and 50,000 directory entries in total, 8 MiB per native output/diagnostic stream, +and 64 MiB of current/saved chat-file content reads. Directory reads request +only the remaining entry budget plus one. Native comparison uses its existing +30-second preparation deadline. Exceeding a budget retains the hold; it never +certifies a partial scope or limits ordinary saves or transfers. + +`TestIdleRelease_R8_WideWorkspaceAndReceiptsAllocateLinearly` measures native +proofs over 100, 500 and 5,000 workspace files plus as many receipt records. +It caps retained paths and visited entries at `2*N+100`, and Go allocations at +`6 MiB + N*32,000` bytes. Before deduplication, 500+500 allocated 106,534,776 +bytes; afterward it allocated 2,505,888 bytes. The 5,000+5,000 proof allocated +21,807,368 bytes. Bounds tests require refusal for paths, entries, reply size +and content bytes. Final metadata revalidation remains mandatory. diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index de76b633b4..4511cc0b8e 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -1811,7 +1811,32 @@ func appSendUnsure(err error) bool { } // OpenAgent attaches a conversation to a saved agent and answers it. -func (b *appBackend) OpenAgent(id string) (appwire.Agent, error) { return b.openAgent(id, false) } +func (b *appBackend) OpenAgent(id string) (appwire.Agent, error) { + // Only a row already read for this paired identity can enable automatic + // pull. Ordinary local opens and single-device homes add no relay request. + if b.idleHandoffEnabled() { + row, found := b.chats.rowFor(id) + if found && row.CanOpen && !row.Here { + return b.takeAgent(appwire.TakeParams{Cell: row.Cell, Seen: row.Seen}, true) + } + } + return b.openAgent(id, false) +} + +// rowFor is the last listed row for an agent id or a shared chat's cell id. +func (c *appChats) rowFor(id string) (appwire.ChatRow, bool) { + c.mu.Lock() + defer c.mu.Unlock() + if row, found := c.byAgent[id]; found { + return row, true + } + for _, row := range c.rows { + if row.Cell == id { + return row, true + } + } + return appwire.ChatRow{}, false +} // WarmAgent is OpenAgent for a window's guess at a view (a hover's prefetch): // an agent in Closed or the archive is refused, never taken out again @@ -2549,7 +2574,7 @@ func (b *appBackend) sendLocked(a *appAgent, text, clientID string, shown *appSe func (b *appBackend) knownWorkRefusalGated(a *appAgent) error { a.gate.Lock() defer a.gate.Unlock() - return b.knownWorkRefusal(a) + return b.idleWorkRefusal(a) } // Retry sends the words of the agent's last failed turn again. @@ -4225,7 +4250,13 @@ func (b *appBackend) SetModel(id, model string) (appwire.Agent, error) { if live.conn.Lent { return appwire.Agent{}, errLentStage("model") } - live.conn.Conv.SetModel(model) + if checked, ok := live.conn.Conv.(interface{ SetModelChecked(string) error }); ok { + if err := checked.SetModelChecked(model); err != nil { + return appwire.Agent{}, appEngineError(err) + } + } else { + live.conn.Conv.SetModel(model) + } if now := live.conn.Conv.Model(); now != "" { model = now } @@ -4270,7 +4301,18 @@ func (b *appBackend) SetEffort(id, word string) (appwire.Agent, error) { SetConversationEffort(string) bool ConversationEffort() string }) - if !ok || !dial.SetConversationEffort(rung.String()) { + took := false + if ok { + if checked, yes := dial.(interface{ SetConversationEffortChecked(string) (bool, error) }); yes { + took, err = checked.SetConversationEffortChecked(rung.String()) + if err != nil { + return appwire.Agent{}, appEngineError(err) + } + } else { + took = dial.SetConversationEffort(rung.String()) + } + } + if !ok || !took { return appwire.Agent{}, appwire.Errorf(appwire.CodeEngine, "this conversation has no thinking dial; update the engine and reconnect") } chosen := dial.ConversationEffort() @@ -5040,6 +5082,9 @@ func appEngineError(err error) error { } var refusal *executor.WorkRefusal if errors.As(err, &refusal) { + if refusal.Retryable { + return &appwire.Error{Code: appwire.CodeUnreachable, Message: refusal.Line} + } location := &appwire.Location{Here: false, Line: refusal.Line} if refusal.HolderID != "" { location.Holder = &appwire.LocationHolder{ID: refusal.HolderID, Name: refusal.HolderName} diff --git a/cmd/codeaf/app_chats.go b/cmd/codeaf/app_chats.go index ef01ff87ca..02a1519473 100644 --- a/cmd/codeaf/app_chats.go +++ b/cmd/codeaf/app_chats.go @@ -14,7 +14,9 @@ import ( "github.com/Agent-Field/codeaf/internal/appwire" "github.com/Agent-Field/codeaf/internal/cell" "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/dirwatch" + "github.com/Agent-Field/codeaf/internal/env" "github.com/Agent-Field/codeaf/internal/guard" "github.com/Agent-Field/codeaf/internal/handoff" "github.com/Agent-Field/codeaf/internal/pair" @@ -406,6 +408,7 @@ func (b *appBackend) readChats(parent context.Context, s *syncsetup.Sync, feed d _, installed := local[r.Cell] here := r.Mine && (r.Status == chatlist.Here || installed) row := appwire.ChatRow{Cell: r.Cell, Title: r.Title, Here: here, Status: string(r.Status), Seen: appwire.Seen{Device: r.DeviceID, Fence: r.Fence, Head: r.Head}, CanTake: !here && r.Head != ""} + row.CanOpen = s.IdleHandoffAllowed() && r.AutoOpen && r.Head != "" if service, ok := s.Dir.(interface{ HeadTakes() bool }); ok && !service.HeadTakes() { row.Seen.Head = "" } @@ -760,6 +763,17 @@ func (b *appBackend) cachedChatLocation(id string) *appwire.Location { return chatLocation(row) } +func (b *appBackend) idleHandoffEnabled() bool { + profile := strings.TrimSpace(b.profileDir) + if profile == "" { + profile = strings.TrimSpace(env.Get(config.ProfileDirEnv)) + } + if profile == "" { + profile = b.relayHome() + } + return config.IdleHandoffAt(profile) && syncsetup.StorageForHome(b.relayHome()) != "off" +} + func (b *appBackend) dressChatLocation(view *appwire.Agent) { fresh := b.cachedChatLocation(view.ID) if fresh == nil { diff --git a/cmd/codeaf/app_lend.go b/cmd/codeaf/app_lend.go index af21a3518a..4501195b67 100644 --- a/cmd/codeaf/app_lend.go +++ b/cmd/codeaf/app_lend.go @@ -152,6 +152,13 @@ func endsAView(door session.StopDoor) bool { // model change has no error to return. func (l lentAgent) SetModel(string) {} +// SetModelChecked preserves the legacy wrapper's model policy at the checked +// RPC door; the embedded agent's promoted setter must not bypass it. +func (l lentAgent) SetModelChecked(model string) error { + l.SetModel(model) + return nil +} + // SetSpendRail refuses: the stage's rail is the factory's. func (l lentAgent) SetSpendRail(float64) error { return errLentStage("spend limit") } diff --git a/cmd/codeaf/app_take.go b/cmd/codeaf/app_take.go index 6170706328..632e328e5d 100644 --- a/cmd/codeaf/app_take.go +++ b/cmd/codeaf/app_take.go @@ -67,13 +67,19 @@ var ( appTakeSeen = func(ctx context.Context, s *syncsetup.Sync, id string, seen *directory.LeaseExpectation, onMove func(syncsetup.MoveProgress)) (syncsetup.Continued, error) { return chatMover(s, onMove).TakeSeen(ctx, id, seen) } - appTakeOpen = (*appBackend).OpenAgent + appTakeOpen = func(b *appBackend, id string) (appwire.Agent, error) { + return b.openAgent(id, false) + } ) // TakeAgent moves a chat here. Its row says the move is queued, then how far // it has got, and, when it did not move the chat, how it ended, so a client // that stopped waiting still learns the outcome. -func (b *appBackend) TakeAgent(p appwire.TakeParams) (agent appwire.Agent, err error) { +func (b *appBackend) TakeAgent(p appwire.TakeParams) (appwire.Agent, error) { + return b.takeAgent(p, false) +} + +func (b *appBackend) takeAgent(p appwire.TakeParams, available bool) (agent appwire.Agent, err error) { if p.Cell == "" || p.Seen.Device == "" { return appwire.Agent{}, appwire.Errorf(appwire.CodeBadParams, "a chat and its seen computer are required") } @@ -131,8 +137,11 @@ func (b *appBackend) TakeAgent(p appwire.TakeParams) (agent appwire.Agent, err e return appwire.Agent{}, err } seen := &directory.LeaseExpectation{Device: p.Seen.Device, Fence: p.Seen.Fence, Head: p.Seen.Head} + if available && view.Cell.Lease.Expires != 0 { + return appwire.Agent{}, appAvailableRefusal(ctx, s, view.Cell.Lease) + } if !seen.Matches(view.Cell) { - return appwire.Agent{}, appTakeError(directory.ErrLeaseHeld) + return appwire.Agent{}, appTakeRefusal(ctx, s, p.Cell, seen, directory.ErrLeaseHeld) } if view.Cell.Archived || view.Cell.ParentCell != "" { return appwire.Agent{}, appwire.Errorf(appwire.CodeNotFound, "that chat is not available") @@ -159,7 +168,7 @@ func (b *appBackend) TakeAgent(p appwire.TakeParams) (agent appwire.Agent, err e if busy { return appwire.Errorf(appwire.CodeBusy, "Finish this chat's work before continuing it here.") } - if moved { + if available || moved { b.release(existing) } return nil @@ -169,17 +178,27 @@ func (b *appBackend) TakeAgent(p appwire.TakeParams) (agent appwire.Agent, err e } } report, stopReport := b.moveReporter(p.Cell, move) - got, err := appTakeSeen(ctx, s, p.Cell, seen, report) + var got syncsetup.Continued + if available { + got, err = chatMover(s, report).OpenAvailable(ctx, p.Cell, seen) + } else { + got, err = appTakeSeen(ctx, s, p.Cell, seen, report) + } stopReport() if err != nil { - return appwire.Agent{}, err + if available && (errors.Is(err, directory.ErrLeaseHeld) || errors.Is(err, directory.ErrFenceStale)) { + if fresh, readErr := s.Dir.Cell(ctx, p.Cell); readErr == nil && fresh.Cell.Lease.Expires > fresh.Now { + return appwire.Agent{}, appAvailableRefusal(ctx, s, fresh.Cell.Lease) + } + } + return appwire.Agent{}, appTakeRefusal(ctx, s, p.Cell, seen, err) } // The chat is this computer's from here on, even if opening it fails. b.chats.landTake(p.Cell, got.Taken.Driving.Fence) // The continuer's already-here shortcut may observe a local acquisition // that happened after our preflight read. It must not accept older consent. if got.AlreadyHere && (p.Seen.Device != s.Device.ID() || !seen.Matches(directory.Cell{Head: got.Taken.Driving.Head, Lease: directory.Lease{Device: s.Device.ID(), Fence: got.Taken.Driving.Fence}})) { - return appwire.Agent{}, appTakeError(directory.ErrLeaseHeld) + return appwire.Agent{}, appTakeRefusal(ctx, s, p.Cell, seen, directory.ErrLeaseHeld) } root := got.Taken.Cell.Root meta, err := session.LoadMeta(root) @@ -193,7 +212,7 @@ func (b *appBackend) TakeAgent(p appwire.TakeParams) (agent appwire.Agent, err e } b.remember(session.SessionRow{ID: id, Dir: root, Transcript: transcript, Workspace: meta.Workspace, Title: meta.Title, Created: meta.Created, Model: meta.Model, ModelIdentity: meta.ModelIdentity}) a := b.agent(id) - b.installTaken(a, transcript, meta, !got.AlreadyHere, moved) + b.installTaken(a, transcript, meta, !got.AlreadyHere, available || moved) location := &appwire.Location{Here: true, Cell: p.Cell, Seen: &appwire.Seen{Device: s.Device.ID(), Fence: got.Taken.Driving.Fence, Head: got.Taken.Driving.Head}, Holder: &appwire.LocationHolder{ID: s.Device.ID(), Name: got.Device}} if service, ok := s.Dir.(interface{ HeadTakes() bool }); ok && !service.HeadTakes() { location.Seen.Head = "" @@ -231,6 +250,35 @@ func (b *appBackend) TakeAgent(p appwire.TakeParams) (agent appwire.Agent, err e return agent, nil } +// appTakeRefusal reads ownership again because the observed computer may have +// reclaimed its own idle release while this take was on the way. +func appTakeRefusal(ctx context.Context, s *syncsetup.Sync, id string, seen *directory.LeaseExpectation, err error) error { + if !errors.Is(err, directory.ErrLeaseHeld) && !errors.Is(err, directory.ErrFenceStale) { + return err + } + wire := appTakeError(err).(*appwire.Error) + if seen != nil && seen.Fence > 0 { + if fresh, readErr := s.Dir.Cell(ctx, id); readErr == nil && fresh.Cell.Lease.Device == seen.Device && fresh.Cell.Lease.Fence > seen.Fence { + wire.Message = fmt.Sprintf(chatlist.PickedUpAgain, appHolderName(ctx, s, fresh.Cell.Lease.Device)) + } + } + return wire +} + +func appHolderName(ctx context.Context, s *syncsetup.Sync, device string) string { + name := chatlist.DeviceName(nil, device, nil) + if listing, err := s.Dir.List(ctx); err == nil { + key := directory.MetadataKey(s.Identity.CellKey()) + name = chatlist.DeviceName(listing.Devices, device, func(sealed string) (string, error) { return directory.OpenName(key, sealed) }) + } + return name +} + +func appAvailableRefusal(ctx context.Context, s *syncsetup.Sync, lease directory.Lease) error { + name := appHolderName(ctx, s, lease.Device) + return appLocationRefusal(&appwire.Location{Holder: &appwire.LocationHolder{ID: lease.Device, Name: name}, Line: chatlist.Superseded(name)}) +} + // installTaken points the agent at the chat a take installed. installed: the // take replaced the saved conversation; moved: another computer held it. // Under the agent's gate, as releaseForTake: a send waits for the reset diff --git a/cmd/codeaf/app_take_r3_test.go b/cmd/codeaf/app_take_r3_test.go new file mode 100644 index 0000000000..e92c112f40 --- /dev/null +++ b/cmd/codeaf/app_take_r3_test.go @@ -0,0 +1,123 @@ +package main + +import ( + "context" + "encoding/json" + "reflect" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +func TestIdleRelease_R3_AppTakeSameHolderNewFenceSaysPickedUpAgain(t *testing.T) { + for _, tc := range []struct { + name string + different bool + during bool + refusal error + }{ + {name: "same holder before take"}, + {name: "different holder before take", different: true}, + {name: "same holder during take lease held", during: true, refusal: directory.ErrLeaseHeld}, + {name: "same holder during take fence stale", during: true, refusal: directory.ErrFenceStale}, + {name: "different holder during take", different: true, during: true, refusal: directory.ErrLeaseHeld}, + } { + t.Run(tc.name, func(t *testing.T) { + celltest.Available(t) + appDevicesRelay(t) + aHome, bHome, cHome := t.TempDir(), t.TempDir(), t.TempDir() + id, err := identity.Ensure(aHome) + if err != nil { + t.Fatal(err) + } + for _, dir := range []string{bHome, cHome} { + if _, err := identity.Adopt(dir, id, false); err != nil { + t.Fatal(err) + } + } + a, b, c := appDeviceSync(t, aHome), appDeviceSync(t, bHome), appDeviceSync(t, cHome) + for s, name := range map[*syncsetup.Sync]string{a: "Studio Mac", b: "Desktop", c: "Tower"} { + if err := syncsetup.PutOwnDevice(t.Context(), s.Dir, s.Identity, s.Device, name); err != nil { + t.Fatal(err) + } + } + chat, err := cell.NewID(time.Now()) + if err != nil { + t.Fatal(err) + } + initial, err := a.Dir.Create(t.Context(), chat, directory.CellInit{Head: "saved-head"}) + if err != nil { + t.Fatal(err) + } + _, _, wire := appDeviceBackend(t, bHome) + findRow := func() appwire.ChatRow { + for _, row := range machineCall[appwire.ChatsResult](t, wire, "chats.list", struct{}{}).Chats { + if row.Cell == chat { + return row + } + } + t.Fatal("chat is missing from the listing") + return appwire.ChatRow{} + } + seen := findRow().Seen + holder := a + if tc.different { + holder = c + } + var before directory.CellView + advance := func() error { + if err := directory.ReleaseQuiet(t.Context(), a.Dir, chat, initial.Cell.Lease.Fence); err != nil { + return err + } + before, err = holder.Dir.Acquire(t.Context(), chat, directory.AcquireOpts{}) + return err + } + originalTake, originalOpen := appTakeSeen, appTakeOpen + t.Cleanup(func() { appTakeSeen, appTakeOpen = originalTake, originalOpen }) + takeCalls, opened := 0, false + appTakeSeen = func(context.Context, *syncsetup.Sync, string, *directory.LeaseExpectation, func(syncsetup.MoveProgress)) (syncsetup.Continued, error) { + takeCalls++ + if err := advance(); err != nil { + return syncsetup.Continued{}, err + } + return syncsetup.Continued{}, tc.refusal + } + appTakeOpen = func(*appBackend, string) (appwire.Agent, error) { + opened = true + return appwire.Agent{}, nil + } + if !tc.during { + if err := advance(); err != nil { + t.Fatal(err) + } + } + _, refusal := wire.call("agents.take", appwire.TakeParams{Cell: chat, Seen: seen}) + row := findRow() + want := row.Holder.Name + " picked this chat up again" + if tc.different { + want = chatlist.LostRace + } + if refusal == nil || refusal.Code != appwire.CodeConflict || refusal.Message != want { + t.Fatalf("agents.take refusal = %+v, want conflict %q", refusal, want) + } + var detail appwire.HolderChanged + if err := json.Unmarshal(e2JSON(refusal.Detail), &detail); err != nil || detail.Kind != "holder_changed" { + t.Fatalf("refusal detail = %+v, error = %v", refusal.Detail, err) + } + after, err := a.Dir.Cell(t.Context(), chat) + if err != nil || !reflect.DeepEqual(after.Cell, before.Cell) || opened || row.Here || row.Taking { + t.Fatalf("refusal changed the chat: before=%+v after=%+v opened=%v row=%+v error=%v", before.Cell, after.Cell, opened, row, err) + } + if wantCalls := map[bool]int{false: 0, true: 1}[tc.during]; takeCalls != wantCalls { + t.Fatalf("take calls = %d, want %d", takeCalls, wantCalls) + } + }) + } +} diff --git a/cmd/codeaf/appconvo.go b/cmd/codeaf/appconvo.go index 4e37588c62..46664229e0 100644 --- a/cmd/codeaf/appconvo.go +++ b/cmd/codeaf/appconvo.go @@ -170,7 +170,7 @@ func (b *appBackend) steer(agent, text, clientID string, shown *appSending) erro // gate, which the open holds for as long as it takes. A chat known to // belong elsewhere is refused now. After a stop there is no turn to give // it to, and it is refused as any steer with nothing running is. - if err := b.knownWorkRefusal(a); err != nil { + if err := b.idleWorkRefusal(a); err != nil { return err } kept := false @@ -353,7 +353,7 @@ func (b *appBackend) queue(agent, text, clientID string, shown *appSending) ([]a // before the gate, which the open holds for as long as it takes; the // queue sends it through the ordinary door, admission and all, and a chat // known to belong elsewhere is refused now. - if err := b.knownWorkRefusal(a); err != nil { + if err := b.idleWorkRefusal(a); err != nil { return nil, err } starting := false diff --git a/cmd/codeaf/appsync.go b/cmd/codeaf/appsync.go index cf668afe5e..9fb28e93cf 100644 --- a/cmd/codeaf/appsync.go +++ b/cmd/codeaf/appsync.go @@ -11,6 +11,7 @@ import ( "github.com/Agent-Field/codeaf/internal/appwire" "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/executor" "github.com/Agent-Field/codeaf/internal/guard" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/syncsetup" @@ -44,6 +45,7 @@ func (*appBackend) ChatLocationsKnown() bool { return true } // keeps the default desk, including its home notices and FirstRun marker. func (b *appBackend) useSyncDrives(book *driveBook) { b.locked(func() { b.drives = book }) + book.idleCheck = b.idleForRelay book.notices = &appDriveNotices{ firstRun: b.syncStarted, say: func(c cell.Cell, line string) { b.syncFact(c, appSyncFact{line: line}) }, @@ -58,6 +60,26 @@ func (b *appBackend) useSyncDrives(book *driveBook) { } } +// idleForRelay includes queue and steering entries the app has accepted but +// not yet handed to the session. A busy snapshot keeps the existing hold. +func (b *appBackend) idleForRelay(id string) bool { + if !b.mu.TryLock() { + return false + } + defer b.mu.Unlock() + for _, a := range b.agents { + if a.view.ID != id && filepath.Base(session.DirOf(a.transcript)) != id { + continue + } + if !a.gate.TryLock() { + return false + } + a.gate.Unlock() + return !a.starting && !a.opening && len(a.queue) == 0 && len(a.early) == 0 && (a.live == nil || !a.live.running && a.live.adopting == 0 && len(a.live.asks) == 0) + } + return true +} + // syncFact uses the physical folder, rather than assuming the app id and the // cell id agree. A conversion can start a drive before its new transcript has // reached hold; those facts wait here until the connection names its folder. @@ -303,9 +325,16 @@ func (b *appBackend) refreshWorkRefusal(a *appAgent) error { return refusal } drive := live.started() - ctx, cancel := context.WithTimeout(context.Background(), driveWorkWithin) + within := driveWorkWithin + if drive.IdleReleased() { + within = executor.TakeBackWithin + } + ctx, cancel := context.WithTimeout(context.Background(), within) defer cancel() if _, err := drive.ConfirmWork(ctx); err != nil { + if current := drive.Gate(); current != nil && drive.IdleReleased() { + return appEngineError(current) + } return refusal } if err := drive.Gate(); err != nil { @@ -314,11 +343,32 @@ func (b *appBackend) refreshWorkRefusal(a *appAgent) error { return b.knownWorkRefusal(a) } -// workAllowed reads cached ownership before handing new work to an open -// conversation. A joined host also checks its session's cached fact remotely. +// idleWorkRefusal lets every work door take back a voluntarily released chat. +// Other viewers retain their cached held-elsewhere refusal. +func (b *appBackend) idleWorkRefusal(a *appAgent) error { + if err := b.knownWorkRefusal(a); err != nil { + var transcript string + var drives *driveBook + b.locked(func() { transcript, drives = a.transcript, b.drives }) + dir, folder := session.FolderOf(transcript) + if drives == nil || !folder { + return err + } + live := drives.renewalsOf(filepath.Base(dir)) + if live == nil || live.started() == nil || !live.started().IdleReleased() { + return err + } + if err := b.refreshWorkRefusal(a); err != nil { + return err + } + } + return nil +} + +// workAllowed checks admission before handing work to an open conversation. // Callers hold a.gate, never b.mu across I/O. func (b *appBackend) workAllowed(a *appAgent) error { - if err := b.knownWorkRefusal(a); err != nil { + if err := b.idleWorkRefusal(a); err != nil { return err } var transcript string diff --git a/cmd/codeaf/chatv3_layout.go b/cmd/codeaf/chatv3_layout.go index e2b83fc697..13350cc555 100644 --- a/cmd/codeaf/chatv3_layout.go +++ b/cmd/codeaf/chatv3_layout.go @@ -853,6 +853,8 @@ func v3SeatPending(place session.Place, say func(string), drives *driveBook) (ex failed(watch, err) drive, claim := drives.pendingDriveOf(c, cellstore.EngineFor(place.Workspace), watch.SyncReport, watch) if drive != nil { + drive.setIdleCheck(watch.IdleReady) + watch.OnMetadataWrite = drive.SavedMetadata // The turn can finish locally while its final capture is still running. watch.OnTurnEnd = func() { drive.TurnEnded(c, watch.Report) } watch.OnTurnStart = func() { drive.Working(true) } @@ -867,7 +869,7 @@ func v3SeatPending(place session.Place, say func(string), drives *driveBook) (ex // the first input; their journal writes need a tail seal even before chat. // Window attachment reuses the engine's seat rather than constructing one. watch.Waiting() - return executor.Gated(seat, driveGate(drive), driveWorkGate(drive)), machine, watch, claim + return executor.Gated(seat, driveGate(drive), driveWorkGate(drive), drivePreparedGate(drive), driveSettingsGate(drive)), machine, watch, claim } // sealTail seals what the chat wrote after the turn-end seal (titles, summaries, diff --git a/cmd/codeaf/copy_bucket.go b/cmd/codeaf/copy_bucket.go new file mode 100644 index 0000000000..34a3e2cd67 --- /dev/null +++ b/cmd/codeaf/copy_bucket.go @@ -0,0 +1,68 @@ +package main + +import ( + "crypto/sha256" + "fmt" + "path/filepath" + "strings" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" +) + +// A bucket remains below NAME_MAX, with room for platform-specific suffixes. +const copyBucketBytes = 240 + +// copyBucketName preserves an arriving copy's bucket instead of encoding its +// receiver's home into the next name. Ordinary short project names stay intact. +func copyBucketName(origin string) string { + identity := filepath.Clean(origin) + if canonical, err := filepath.EvalSymlinks(identity); err == nil { + identity = canonical + } + name := "copy-" + encodeWorkspace(origin) + if bucket := ownedCopyBucket(identity); bucket != "" { + name = bucket + } + if len(name) <= copyBucketBytes { + return name + } + suffix := fmt.Sprintf("-%x", sha256.Sum256([]byte(identity)))[:17] + prefix := name[:copyBucketBytes-len(suffix)] + for !utf8.ValidString(prefix) { + prefix = prefix[:len(prefix)-1] + } + return prefix + suffix +} + +// Only the exact workspace of an existing owned copy can name its bucket. +// A nested project, or a path that merely looks like a copy, keeps its identity. +func ownedCopyBucket(work string) string { + if filepath.Base(work) != "work" { + return "" + } + root := filepath.Dir(work) + bucket := filepath.Dir(root) + projects := filepath.Dir(bucket) + if !strings.HasPrefix(filepath.Base(bucket), "copy-") || filepath.Base(projects) != "projects" || filepath.Base(filepath.Dir(projects)) != "v3" { + return "" + } + meta, err := session.LoadMeta(root) + if err != nil || !meta.Owned || meta.Origin == "" || meta.ID != filepath.Base(root) { + return "" + } + canonical := filepath.Clean(meta.Workspace) + if resolved, err := filepath.EvalSymlinks(canonical); err == nil { + canonical = resolved + } + if canonical != work { + return "" + } + return filepath.Base(bucket) +} + +func takeCopyBucket(origin string) string { return takeCopyBucketAt(home.Dir(), origin) } +func takeCopyBucketAt(dir, origin string) string { + return filepath.Join(dir, "v3", "projects", copyBucketName(origin)) +} diff --git a/cmd/codeaf/copy_bucket_native_test.go b/cmd/codeaf/copy_bucket_native_test.go new file mode 100644 index 0000000000..81fa6ef3f5 --- /dev/null +++ b/cmd/codeaf/copy_bucket_native_test.go @@ -0,0 +1,112 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "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/home" + "github.com/Agent-Field/codeaf/internal/preflight" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +func TestCopyBucket_D3_TenNativeMovesKeepExistingRoots(t *testing.T) { + celltest.RequireEngine(t) + t.Setenv("CODEAF_IDLE_HANDOFF", "on") + h := newHostedMoveRig(t) + c, err := cell.CreateIn(filepath.Join(h.a.Home, "v3", "projects", "original"), cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + work := filepath.Join(c.Root, "work") + if err = os.MkdirAll(work, 0700); err != nil { + t.Fatal(err) + } + if err = session.SaveMeta(c.Root, session.Meta{ID: c.ID, Workspace: work, Owned: true, Title: "ten moves"}); err != nil { + t.Fatal(err) + } + if err = os.WriteFile(session.Place{Dir: c.Root}.Transcript(), []byte(fmt.Sprintf("{\"type\":\"session\",\"version\":1,\"id\":%q}\n{\"type\":\"message\",\"role\":\"user\",\"content\":\"ten moves\"}\n", c.ID)), 0600); err != nil { + t.Fatal(err) + } + homes := []*syncsetup.Sync{h.a, h.b} + engines := []cellstore.Engine{cellstore.EngineFor(""), cellstore.EngineFor("")} + for i, s := range homes { + engines[i].DataRoot = filepath.Join(s.Home, "v3", "stores") + if err = syncsetup.PutOwnDevice(t.Context(), s.Dir, s.Identity, s.Device, fmt.Sprintf("home %d", i)); err != nil { + t.Fatal(err) + } + } + roots := map[string]string{h.a.Home: c.Root} + for hop := 0; hop < 10; hop++ { + from, to := hop%2, (hop+1)%2 + src, dst := homes[from], homes[to] + t.Setenv(home.EnvVar, src.Home) + work = filepath.Join(c.Root, "work") + marker := fmt.Sprintf("move %d", hop) + if err = os.WriteFile(filepath.Join(work, "latest.txt"), []byte(marker), 0600); err != nil { + t.Fatal(err) + } + journal, err := os.OpenFile(session.Place{Dir: c.Root}.Transcript(), os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + t.Fatal(err) + } + _, writeErr := fmt.Fprintf(journal, "{\"type\":\"message\",\"role\":\"assistant\",\"content\":%q}\n", marker) + closeErr := journal.Close() + if writeErr != nil || closeErr != nil { + t.Fatalf("journal: %v, %v", writeErr, closeErr) + } + machine, err := preflight.OpenMachine(c.Root, work) + if err != nil { + t.Fatal(err) + } + machine.Observer().Refresh() + engine := engines[from] + engine.Workspace = work + drive, err := src.Drive(t.Context(), engine, c, syncsetup.DriveOptions{DeviceName: fmt.Sprintf("home %d", from)}) + if err != nil { + t.Fatal(err) + } + sealed, sealErr := drive.Store(engine).Seal(t.Context(), c, cellstore.TurnInfo{}) + closeErr = drive.Close(context.Background()) + if sealErr != nil || closeErr != nil { + t.Fatalf("publish: %v, %v", sealErr, closeErr) + } + view, err := src.Dir.Cell(t.Context(), c.ID) + if err != nil { + t.Fatal(err) + } + if view.Cell.Head != sealed.Turn.ID || view.Cell.Lease.Expires != 0 { + t.Fatalf("unpublished or held move: %+v", view) + } + t.Setenv(home.EnvVar, dst.Home) + got, err := chatContinuer(dst, nil, nil).TakeSeen(t.Context(), c.ID, nil) + if err != nil { + t.Fatalf("D3 hop %d: %v", hop, err) + } + c = got.Taken.Cell + if hop == 0 && !strings.HasPrefix(filepath.Base(filepath.Dir(c.Root)), "copy-") { + t.Fatalf("D3 first arrival was not filed into its origin bucket: %s", c.Root) + } + if prior := roots[dst.Home]; prior != "" && c.Root != prior { + t.Fatalf("D3 hop %d moved an existing local root: %s -> %s", hop, prior, c.Root) + } + roots[dst.Home] = c.Root + for _, part := range strings.Split(filepath.ToSlash(c.Root), "/") { + if len(part) > 255 { + t.Fatalf("D3 hop %d component has %d bytes", hop, len(part)) + } + } + raw, err := os.ReadFile(filepath.Join(c.Root, "work", "latest.txt")) + if err != nil || string(raw) != marker { + t.Fatalf("D3 restored file=%q, %v", raw, err) + } + t.Logf("D3 hop %d root=%s", hop, c.Root) + } +} diff --git a/cmd/codeaf/copy_bucket_test.go b/cmd/codeaf/copy_bucket_test.go new file mode 100644 index 0000000000..d25f41ea48 --- /dev/null +++ b/cmd/codeaf/copy_bucket_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" +) + +func TestCopyBucket_D3_TenAlternatingOriginsStayBoundedAndStable(t *testing.T) { + homes := []string{t.TempDir(), t.TempDir()} + for _, origin := range []string{"/work/project", "/work/" + strings.Repeat("project", 50), "/work/" + strings.Repeat("目录", 70)} { + t.Run(origin[:min(len(origin), 24)], func(t *testing.T) { + first := "" + for hop := 0; hop < 10; hop++ { + t.Setenv(home.EnvVar, homes[hop%2]) + bucket := takeCopyBucket(origin) + name := filepath.Base(bucket) + if hop == 0 { + first = name + } else if name != first { + t.Errorf("D3 hop %d changed bucket: %q != %q", hop, name, first) + } + if !utf8.ValidString(name) { + t.Error("D3 split a UTF-8 path component") + } + for _, component := range strings.Split(filepath.ToSlash(bucket), "/") { + if len(component) > 255 { + t.Fatalf("D3 hop %d component has %d bytes", hop, len(component)) + } + } + if err := os.MkdirAll(bucket, 0700); err != nil { + t.Fatal(err) + } + previous := origin + origin = filepath.Join(bucket, "01J0000000000000000000000A", "work") + if err := os.MkdirAll(origin, 0700); err != nil { + t.Fatal(err) + } + if err := session.SaveMeta(filepath.Dir(origin), session.Meta{ID: "01J0000000000000000000000A", Owned: true, Workspace: origin, Origin: previous}); err != nil { + t.Fatal(err) + } + } + }) + } +} +func TestCopyBucket_D3_ExistingShortNamesStayTheSame(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + for _, origin := range []string{"/work/project", "/work/copy-not-a-codeaf-bucket"} { + want := filepath.Join(home.Dir(), "v3", "projects", "copy-"+encodeWorkspace(origin)) + if got := takeCopyBucket(origin); got != want { + t.Fatalf("existing short bucket %q became %q", want, got) + } + } +} diff --git a/cmd/codeaf/idle_handoff_contract_test.go b/cmd/codeaf/idle_handoff_contract_test.go new file mode 100644 index 0000000000..c6e047bde5 --- /dev/null +++ b/cmd/codeaf/idle_handoff_contract_test.go @@ -0,0 +1,324 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "os" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +type idleAppContract struct { + b *appBackend + rec *appRecorder + model *recordingModel + agent appwire.Agent + own *syncsetup.Sync + peerHome string +} + +func newIdleAppContract(t *testing.T, wrappers ...func(http.Handler) http.Handler) *idleAppContract { + t.Helper() + celltest.RequireEngine(t) + t.Setenv("CODEAF_IDLE_HANDOFF", "on") + relay := e2WireRelay(t, wrappers...) + t.Setenv(syncsetup.URLVar, relay.URL) + b, rec, model, workspace := appSyncBackend(t) + _, peerHome := outageHomes(t, relay.URL) + t.Cleanup(func() { _ = b.Close() }) + agent, err := mustCreate(t, b, appwire.CreateAsk{Workspace: workspace}) + if err != nil { + t.Fatal(err) + } + turn, err := b.Send(agent.ID, "! echo idle-contract-seed", "") + if err != nil { + t.Fatal(err) + } + appTurnDoneFor(t, rec, turn) + return &idleAppContract{b: b, rec: rec, model: model, agent: agent, own: appSyncOwn(t), peerHome: peerHome} +} + +func (f *idleAppContract) released(t *testing.T) directory.CellView { + t.Helper() + var view directory.CellView + appSyncWait(t, "C1 paired app release", func() bool { + var err error + view, err = f.own.Dir.Cell(t.Context(), f.agent.ID) + return err == nil && view.Cell.Head != "" && view.Cell.Lease.Expires == 0 + }) + return view +} + +func TestIdleHandoff_DefaultOff_AppRetainsHoldAndRefusesAutoOpen(t *testing.T) { + celltest.RequireEngine(t) + t.Setenv("CODEAF_IDLE_HANDOFF", "") + relay := e2WireRelay(t) + t.Setenv(syncsetup.URLVar, relay.URL) + b, rec, _, workspace := appSyncBackend(t) + _, peerHome := outageHomes(t, relay.URL) + t.Cleanup(func() { _ = b.Close() }) + agent, err := mustCreate(t, b, appwire.CreateAsk{Workspace: workspace}) + if err != nil { + t.Fatal(err) + } + turn, err := b.Send(agent.ID, "! echo default-off-seed", "") + if err != nil { + t.Fatal(err) + } + appTurnDoneFor(t, rec, turn) + own := appSyncOwn(t) + appSyncWait(t, "published head on relay", func() bool { + v, err := own.Dir.Cell(t.Context(), agent.ID) + return err == nil && v.Cell.Head != "" + }) + time.Sleep(150 * time.Millisecond) + v, err := own.Dir.Cell(t.Context(), agent.ID) + if err != nil || v.Cell.Lease.Expires == 0 || v.Cell.Lease.Expires <= v.Now { + t.Fatalf("default-off app released its idle hold: %+v, %v", v, err) + } + if err := b.Close(); err != nil { + t.Fatal(err) + } + peer := e2StartApp(t, peerHome) + t.Cleanup(func() { e2Quit(t, peer) }) + row := outageChatRow(t, peer, agent.ID, func(row appwire.ChatRow) bool { return row.Status == "idle" }) + if row.CanOpen || !row.CanTake || row.Here { + t.Fatalf("default-off released remote row offered auto-open: %+v", row) + } +} + +func TestIdleRelease_C1_C3_AppSendReacquiresBeforeModelCall(t *testing.T) { + f := newIdleAppContract(t) + before := f.released(t) + var checked atomic.Int32 + original := f.model.server.Config.Handler + f.model.server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/chat/completions") { + v, err := f.own.Dir.Cell(r.Context(), f.agent.ID) + if err != nil || v.Cell.Lease.Device != f.own.Device.ID() || v.Cell.Lease.Expires <= v.Now || v.Cell.Lease.Fence <= before.Cell.Lease.Fence { + t.Errorf("C3 provider reached without reacquisition: %+v, %v", v, err) + } + checked.Add(1) + } + original.ServeHTTP(w, r) + }) + turn, err := f.b.Send(f.agent.ID, "reply after idle release", "") + if err != nil { + t.Fatal(err) + } + appTurnDoneFor(t, f.rec, turn) + if checked.Load() == 0 { + t.Fatal("C3 work never reached its scripted model") + } +} + +func TestIdleRelease_C4_AppRefusesMovedChatWithoutJournalOrModelWrite(t *testing.T) { + f := newIdleAppContract(t) + seen := f.released(t) + peer := appDeviceSync(t, f.peerHome) + _, err := peer.Dir.Acquire(t.Context(), f.agent.ID, directory.AcquireOpts{ + RequireConditional: true, + Expect: &directory.LeaseExpectation{Device: seen.Cell.Lease.Device, Fence: seen.Cell.Lease.Fence}, + }) + if err != nil { + t.Fatal(err) + } + path := appTranscriptOf(f.b, f.agent.ID) + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var called atomic.Bool + original := f.model.server.Config.Handler + f.model.server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/chat/completions") { + called.Store(true) + } + original.ServeHTTP(w, r) + }) + _, err = f.b.Send(f.agent.ID, "must stay unsent", "") + var wire *appwire.Error + if !errors.As(err, &wire) || wire.Code != appwire.CodeConflict { + t.Fatalf("C4 held-elsewhere refusal=%v", err) + } + raw, _ := json.Marshal(wire.Detail) + var moved appwire.HeldElsewhere + if err := json.Unmarshal(raw, &moved); err != nil || moved.Kind != "held_elsewhere" || moved.Holder == nil || moved.Holder.ID != peer.Device.ID() { + t.Fatalf("C4 moved holder detail=%s, %v", raw, err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, after) || called.Load() { + t.Fatal("C4 refused work wrote or called the model") + } +} + +func TestIdleOpen_C6_C8_AppOpensReleasedChatAfterSourceStops(t *testing.T) { + f := newIdleAppContract(t) + before := f.released(t) + if err := f.b.Close(); err != nil { + t.Fatal(err) + } + peer := e2StartApp(t, f.peerHome) + t.Cleanup(func() { e2Quit(t, peer) }) + row := outageChatRow(t, peer, f.agent.ID, func(row appwire.ChatRow) bool { return row.CanOpen }) + if row.Here || !row.CanTake { + t.Fatalf("C6 available row=%+v", row) + } + opened := machineCall[struct { + Agent appwire.Agent `json:"agent"` + }](t, peer.w, "agents.open", map[string]any{"id": f.agent.ID}).Agent + if opened.Location == nil || !opened.Location.Here || opened.Location.Seen == nil || opened.Location.Seen.Fence != before.Cell.Lease.Fence+1 { + t.Fatalf("C6 engine did not open here: %+v", opened) + } + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + view, err := f.own.Dir.Cell(ctx, f.agent.ID) + if err != nil || view.Cell.Head != before.Cell.Head || view.Cell.Lease.Device == f.own.Device.ID() { + t.Fatalf("C6/C8 relay state=%+v, %v", view, err) + } +} + +func TestIdleRelease_C2_C7_AppRunningAndQueuedWorkKeepTheLiveHold(t *testing.T) { + f := newIdleAppContract(t) + f.released(t) + pause, entered, resume := appSyncHoldReply(t, f.model) + pause() + turn, err := f.b.Send(f.agent.ID, "hold the model reply", "") + if err != nil { + t.Fatal(err) + } + select { + case <-entered: + case <-time.After(10 * time.Second): + t.Fatal("C2 model did not start") + } + if _, err := f.b.Queue(f.agent.ID, "queued after this answer", ""); err != nil { + t.Fatal(err) + } + time.Sleep(3 * time.Second) + before, err := f.own.Dir.Cell(t.Context(), f.agent.ID) + if err != nil || before.Cell.Lease.Expires <= before.Now { + t.Fatalf("C2 active hold=%+v, %v", before, err) + } + peer := e2StartApp(t, f.peerHome) + t.Cleanup(func() { e2Quit(t, peer) }) + row := outageChatRow(t, peer, f.agent.ID, func(row appwire.ChatRow) bool { return row.Seen.Fence == before.Cell.Lease.Fence }) + if row.CanOpen || !row.CanTake || row.Here { + t.Fatalf("C7 running chat lost its explicit Continue here choice: %+v", row) + } + after, err := f.own.Dir.Cell(t.Context(), f.agent.ID) + if err != nil || after.Cell.Lease.Device != before.Cell.Lease.Device || after.Cell.Lease.Fence != before.Cell.Lease.Fence { + t.Fatalf("C7 watching took ownership: %+v, %v", after, err) + } + resume() + appTurnDoneFor(t, f.rec, turn) + appSyncWait(t, "C2 queued work to settle", func() bool { return len(f.b.queued(f.b.agent(f.agent.ID))) == 0 }) + f.released(t) +} + +func TestIdleRelease_C12_AppSendRacesAutomaticOpen(t *testing.T) { + f := newIdleAppContract(t) + seen := f.released(t) + peer := e2StartApp(t, f.peerHome) + t.Cleanup(func() { e2Quit(t, peer) }) + outageChatRow(t, peer, f.agent.ID, func(row appwire.ChatRow) bool { return row.CanOpen }) + path := appTranscriptOf(f.b, f.agent.ID) + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + resume := make(chan struct{}) + var once sync.Once + unblock := func() { once.Do(func() { close(resume) }) } + t.Cleanup(unblock) + var called atomic.Int32 + original := f.model.server.Config.Handler + f.model.server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/chat/completions") { + v, err := f.own.Dir.Cell(r.Context(), f.agent.ID) + if err != nil || v.Cell.Lease.Device != f.own.Device.ID() || v.Cell.Lease.Fence != seen.Cell.Lease.Fence+1 || v.Cell.Lease.Expires <= v.Now { + t.Errorf("C12 model entered without the winning fence: %+v, %v", v, err) + } + called.Add(1) + select { + case <-resume: + case <-r.Context().Done(): + return + } + } + original.ServeHTTP(w, r) + }) + type sendResult struct { + turn uint64 + err error + } + sent := make(chan sendResult, 1) + go func() { turn, err := f.b.Send(f.agent.ID, "C12 real send", ""); sent <- sendResult{turn, err} }() + raw, openErr := peer.w.call("agents.open", map[string]any{"id": f.agent.ID}) + var send sendResult + select { + case send = <-sent: + case <-time.After(3 * time.Second): + t.Fatal("C12 send admission blocked") + } + if (send.err == nil) == (openErr == nil) { + t.Fatalf("C12 want one winner: send=%v open=%v", send.err, openErr) + } + v, err := f.own.Dir.Cell(t.Context(), f.agent.ID) + if err != nil || v.Cell.Lease.Fence != seen.Cell.Lease.Fence+1 || v.Cell.Lease.Expires <= v.Now { + t.Fatalf("C12 directory winner=%+v, %v", v, err) + } + var loser *appwire.Error + if send.err == nil { + if v.Cell.Lease.Device != f.own.Device.ID() { + t.Fatal("C12 successful send does not own the chat") + } + loser = openErr + } else { + if v.Cell.Lease.Device == f.own.Device.ID() { + t.Fatal("C12 refused send still owns the chat") + } + var opened struct { + Agent appwire.Agent `json:"agent"` + } + if err := json.Unmarshal(raw, &opened); err != nil || opened.Agent.Location == nil || !opened.Agent.Location.Here { + t.Fatalf("C12 automatic open=%s, %v", raw, err) + } + if !errors.As(send.err, &loser) { + t.Fatalf("C12 send refusal=%v", send.err) + } + after, err := os.ReadFile(path) + if err != nil || !bytes.Equal(before, after) || called.Load() != 0 { + t.Fatalf("C12 losing send wrote or called model: %v", err) + } + } + if loser == nil || loser.Code != appwire.CodeConflict { + t.Fatalf("C12 losing door=%v", loser) + } + detail, _ := json.Marshal(loser.Detail) + var held appwire.HeldElsewhere + if err := json.Unmarshal(detail, &held); err != nil || held.Kind != "held_elsewhere" { + t.Fatalf("C12 losing detail=%s, %v", detail, err) + } + unblock() + if send.err == nil { + appTurnDoneFor(t, f.rec, send.turn) + if called.Load() == 0 { + t.Fatal("C12 winning send never entered its scripted model") + } + } +} diff --git a/cmd/codeaf/idle_r10_surface_lock_test.go b/cmd/codeaf/idle_r10_surface_lock_test.go new file mode 100644 index 0000000000..97fb4117e4 --- /dev/null +++ b/cmd/codeaf/idle_r10_surface_lock_test.go @@ -0,0 +1,94 @@ +package main + +import ( + "errors" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +func TestIdleRelease_R10_RetryableRefusalIsNotHeldElsewhere(t *testing.T) { + err := appEngineError(&executor.WorkRefusal{Line: "couldn't confirm taking this chat back from the sync service; try again", Retryable: true}) + var wire *appwire.Error + if !errors.As(err, &wire) || wire.Code != appwire.CodeUnreachable || wire.Detail != nil { + t.Fatalf("uncertain take-back offered a move: %+v", err) + } +} + +func TestIdleRelease_R10_NeverReleasedRenameAvoidsLiveDriveMutex(t *testing.T) { + live := &liveDrive{} + live.install(&syncsetup.Drive{}, nil, make(chan struct{})) + a, err := session.New(session.Config{Workspace: t.TempDir(), ProfileDir: t.TempDir(), Model: "test/model", APIKey: "fixture", BaseURL: "http://127.0.0.1:1", System: "SYSTEM", Seat: executor.Gated(executor.Stance{Class: executor.HostBound}, func() error { return nil }, nil, nil, driveSettingsGate(live))}) + if err != nil { + t.Fatal(err) + } + defer a.Close() + done := make(chan error, 1) + blocked := func() bool { + live.mu.Lock() + defer live.mu.Unlock() + go func() { + err := a.RenameByPerson("ordinary local title") + live.SavedMetadata() + done <- err + }() + select { + case err := <-done: + if err != nil { + t.Error(err) + } + return false + case <-time.After(driveWorkWithin + 100*time.Millisecond): + return true + } + }() + if blocked { + if err := <-done; err != nil { + t.Error(err) + } + t.Fatal("ordinary durable write acquired a new unbounded startup mutex before its released-only check") + } +} + +func TestIdleRelease_R10_ReleasedRenameStillAdmitsWhileLiveMutexIsBusy(t *testing.T) { + f := newIdleAppContract(t) + before := f.released(t) + var book *driveBook + f.b.locked(func() { book = f.b.drives }) + live := book.renewalsOf(f.agent.ID) + if live == nil { + t.Fatal("no installed drive") + } + done := make(chan error, 1) + acquired := func() bool { + live.mu.Lock() + defer live.mu.Unlock() + go func() { _, err := f.b.Rename(f.agent.ID, "kept while catch-up is busy"); done <- err }() + // The existing RPC acknowledgement also publishes all facts, whose + // renewal reader uses this mutex. Check the acquire itself while it + // is held, then let that unchanged acknowledgement finish normally. + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + view, err := f.own.Dir.Cell(t.Context(), f.agent.ID) + if err == nil && view.Cell.Lease.Fence > before.Cell.Lease.Fence { + return true + } + time.Sleep(20 * time.Millisecond) + } + return false + }() + if renamed := <-done; renamed != nil { + t.Fatal(renamed) + } + if !acquired { + t.Fatal("busy live mutex prevented or skipped released-window admission") + } + after, err := f.own.Dir.Cell(t.Context(), f.agent.ID) + if err != nil || after.Cell.Lease.Fence <= before.Cell.Lease.Fence { + t.Fatalf("busy live mutex skipped released-window admission: before=%d after=%+v error=%v", before.Cell.Lease.Fence, after.Cell.Lease, err) + } +} diff --git a/cmd/codeaf/idle_r10_terminal_test.go b/cmd/codeaf/idle_r10_terminal_test.go new file mode 100644 index 0000000000..ff4e8b0a17 --- /dev/null +++ b/cmd/codeaf/idle_r10_terminal_test.go @@ -0,0 +1,281 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/relayserve" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/tui3" +) + +var r10RestartRounds = flag.Int("r10-restart-rounds", 2, "consecutive relay restarts in the R10 terminal walk") + +// Two cycles in one live terminal, repeated five times by the contract shard, +// cover ten restart/new/title/release/!/model sequences with the real seam. +func TestIdleRelease_R10_RealTerminalRelayRestartNewThenSend(t *testing.T) { + r10TerminalSequence(t, *r10RestartRounds, false) +} + +func TestIdleRelease_R10b_RealTerminalLostReplyRecoversAfterLapse(t *testing.T) { + r10TerminalSequence(t, 1, true) +} + +func r10TerminalSequence(t *testing.T, rounds int, loseReply bool) { + t.Helper() + t.Setenv("CODEAF_IDLE_HANDOFF", "on") + var clockOffset atomic.Int64 + now := func() time.Time { return time.Now().Add(time.Duration(clockOffset.Load())) } + store := t.TempDir() + var service atomic.Pointer[relayserve.Service] + service.Store(relayserve.New(relayserve.Config{Store: store, Now: now})) + t.Cleanup(func() { + if s := service.Swap(nil); s != nil { + _ = s.Close() + } + }) + var slow atomic.Int32 + h := newHostedMoveRig(t, func(http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s := service.Load() + if s == nil { + http.Error(w, "restarting", http.StatusServiceUnavailable) + return + } + if strings.HasSuffix(r.URL.Path, "/acquire") { + mode := slow.Swap(0) + if mode != 0 { + reply := httptest.NewRecorder() + s.Handler.ServeHTTP(reply, r.WithContext(context.WithoutCancel(r.Context()))) + if mode == 2 && reply.Code == http.StatusOK { + conn, _, err := w.(http.Hijacker).Hijack() + if err == nil { + _ = conn.Close() + } + return + } + select { + case <-time.After(2700 * time.Millisecond): + case <-r.Context().Done(): + return + } + for k, values := range reply.Header() { + w.Header()[k] = values + } + w.WriteHeader(reply.Code) + _, _ = io.Copy(w, reply.Body) + return + } + } + s.Handler.ServeHTTP(w, r) + }) + }) + t.Setenv("CODEAF_NO_UPDATE_CHECK", "1") + t.Setenv("CODEAF_ENGINE_DAEMON", "1") + t.Setenv("CODEAF_PROFILE_DIR", h.a.Home) + t.Setenv("OPENROUTER_API_KEY", "stub") + t.Setenv(syncsetup.IntervalVar, "5000") + if err := h.b.Rename(t.Context(), "other computer"); err != nil { + t.Fatal(err) + } + model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/models") { + _, _ = io.WriteString(w, `{"data":[{"id":"stub/chat","name":"Stub","context_length":131072,"pricing":{"prompt":"0","completion":"0"}}]}`) + return + } + _, _ = io.Copy(io.Discard, r.Body) + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"r10 terminal answer\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n") + })) + defer model.Close() + t.Setenv("CODEAF_BASE_URL", model.URL) + proc, err := openV3ProcessWith("chat", true) + if err != nil { + t.Fatal(err) + } + defer func() { proc.closeAll(); syncDrives.closeAll() }() + seed := v3Options{Model: "stub/chat", Workspace: h.work, Interactive: true, Yolo: true, OneModel: true} + launch, err := openV3Launch(proc, seed) + if err != nil { + t.Fatal(err) + } + seam := &v3Seam{proc: proc, boot: launch, seed: seed} + initial, err := seam.open(launch, launch.Config, false) + if err != nil { + t.Fatal(err) + } + var current atomic.Pointer[tui3.Conversation] + current.Store(&initial) + started := make(chan *tui3.Conversation, 1) + start := func(workspace string) (tui3.Conversation, error) { + conv, err := seam.start(workspace) + if err == nil { + current.Store(&conv) + started <- &conv + } + return conv, err + } + input, typed := io.Pipe() + screen := &surfaceFrameWriter{ready: make(chan struct{})} + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + previous := runSurfaceProgram + runSurfaceProgram = func(ctx context.Context, o tui3.Options) error { + o.Input, o.Output, o.Width, o.Height = input, screen, 120, 40 + return tui3.Run(ctx, o) + } + defer func() { runSurfaceProgram = previous }() + noRestore := &walkNoRestore{attempted: make(chan struct{}, 1)} + go func() { + done <- runSurface(ctx, tui3.Options{Agent: initial.Agent, Workspace: initial.Workspace, SessionFile: initial.SessionFile, Owned: initial.Owned, Seal: initial.Seal, ProfileDir: h.a.Home, Start: start, Machines: surfaceMachines{h.a}, Takeover: noRestore, Input: input, Output: screen, Width: 120, Height: 40}) + }() + defer func() { cancel(); _ = input.Close(); _ = typed.Close(); <-done }() + select { + case <-screen.ready: + case <-time.After(10 * time.Second): + t.Fatal("no terminal frame") + } + uncertainSeen := false + send := func(text string, model bool) { + t.Helper() + conv := current.Load() + before, _ := os.ReadFile(conv.SessionFile) + frame := len(screen.String()) + if _, err := io.WriteString(typed, text+"\r"); err != nil { + t.Fatal(err) + } + retried := false + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + raw, _ := os.ReadFile(conv.SessionFile) + tail := "" + if len(raw) >= len(before) { + tail = string(raw[len(before):]) + } + if !model && strings.Contains(tail, "r10-runs.txt") { + return + } + users, answered := 0, false + for _, line := range strings.Split(tail, "\n") { + var record struct { + Role string + Content json.RawMessage + } + if json.Unmarshal([]byte(line), &record) != nil { + continue + } + var content string + _ = json.Unmarshal(record.Content, &content) + if record.Role == "user" && content == text { + users++ + } + if users > 0 && record.Role == "assistant" && strings.Contains(string(record.Content), "r10 terminal answer") { + answered = true + } + } + if users > 1 { + t.Fatalf("model send recorded %d times", users) + } + if model && users == 1 && answered { + return + } + if !retried && strings.Contains(screen.String()[min(frame, len(screen.String())):], "couldn't confirm taking this chat back from the sync service; try again") { + if users != 0 { + t.Fatal("uncertain take-back recorded the draft before admission") + } + uncertainSeen = true + // Only the relay fixture clock advances. The real 90-second + // lease must lapse before this window can acquire another fence. + clockOffset.Add(int64(directory.LeaseTTL + time.Millisecond)) + // Renderer output is incremental, so its appended bytes are not + // a complete screen. Enter without retyping must send exactly + // the retained draft, checked against the real journal above. + _, _ = io.WriteString(typed, "\r") + retried = true + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("terminal did not execute %q: %s", text, screen.String()) + } + released := func(after uint64) directory.CellView { + t.Helper() + id := filepath.Base(session.DirOf(current.Load().SessionFile)) + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + view, err := h.a.Dir.Cell(t.Context(), id) + if err == nil && view.Cell.Lease.Fence > after && view.Cell.Lease.Expires == 0 && view.Cell.Head != "" { + return view + } + time.Sleep(40 * time.Millisecond) + } + t.Fatalf("terminal did not release: %s", screen.String()) + return directory.CellView{} + } + if rounds < 1 || rounds > 10 { + t.Fatal("R10 terminal rounds must be between 1 and 10") + } + for round := range rounds { + old := service.Swap(nil) + if err := old.Close(); err != nil { + t.Fatal(err) + } + service.Store(relayserve.New(relayserve.Config{Store: store, Now: now})) + if _, err := io.WriteString(typed, "/new\r"); err != nil { + t.Fatal(err) + } + select { + case <-started: + case <-time.After(30 * time.Second): + t.Fatalf("/new failed: %s", screen.String()) + } + send(fmt.Sprintf("r10 title round %d", round), true) + first := released(0) + if title := current.Load().Agent.Title(); strings.TrimSpace(title) == "" { + t.Fatal("untitled chat") + } + slow.Store(1) + send(fmt.Sprintf("! printf 'round-%d\\n' >> r10-runs.txt; printf 'node_modules/\\n' > .gitignore; mkdir -p node_modules/x; printf kept > node_modules/x/index.js", round), false) + second := released(first.Cell.Lease.Fence) + // The separate lost-reply case advances the fixture past lease expiry; + // the repeated sequence uses a slow reply to check the write budget. + if loseReply { + slow.Store(2) + } else { + slow.Store(1) + } + send(fmt.Sprintf("r10 ordinary model round %d", round), true) + third := released(second.Cell.Lease.Fence) + if loseReply && (!uncertainSeen || third.Cell.Lease.Fence != second.Cell.Lease.Fence+2) { + t.Fatalf("uncertain take-back skipped refusal or lapsed reacquire: uncertain=%v fences=%d/%d", uncertainSeen, second.Cell.Lease.Fence, third.Cell.Lease.Fence) + } + if noRestore.calls.Load() != 0 { + t.Fatal("take-back restored the workspace") + } + if raw, err := os.ReadFile(filepath.Join(h.work, "node_modules/x/index.js")); err != nil || string(raw) != "kept" { + t.Fatalf("ignored install=%q error=%v", raw, err) + } + for _, forbidden := range []string{"Continue this chat here?", "submit failed: This chat is available to continue here.", "Moved here", "Set this machine up like"} { + if strings.Contains(screen.String(), forbidden) { + t.Fatalf("terminal showed %q", forbidden) + } + } + raw, err := os.ReadFile(filepath.Join(h.work, "r10-runs.txt")) + if err != nil || strings.Count(string(raw), fmt.Sprintf("round-%d\n", round)) != 1 { + t.Fatalf("shell execution count: %q %v", raw, err) + } + t.Logf("R10 restart/new/title/release/!/model cycle %d completed: fences %d/%d/%d", round+1, first.Cell.Lease.Fence, second.Cell.Lease.Fence, third.Cell.Lease.Fence) + } +} diff --git a/cmd/codeaf/idle_r5_bucket_test.go b/cmd/codeaf/idle_r5_bucket_test.go new file mode 100644 index 0000000000..ea4ac8e372 --- /dev/null +++ b/cmd/codeaf/idle_r5_bucket_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" +) + +func TestIdleOpen_R5_3_DistinctCopyOrigins(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + origin := "/home/alice/profile/v3/projects/copy--work-repo/01J0000000000000000000000A/work" + nested := origin + "/subproject" + a, b := takeCopyBucket(origin), takeCopyBucket(nested) + t.Logf("origin=%s\nnested=%s\nbuckets=%s ; %s", origin, nested, a, b) + if a == b { + t.Error("different project origins share a copy bucket") + } +} + +func TestIdleOpen_R5_3_LongOriginsHashOriginalIdentity(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + prefix := "/work/" + strings.Repeat("segment/", 40) + a, b := prefix+"/x:y", prefix+"/x-y" + ba, bb := filepath.Base(takeCopyBucket(a)), filepath.Base(takeCopyBucket(b)) + t.Logf("two distinct long origins produce bucket %q and %q", ba, bb) + if ba == bb { + t.Error("long-origin digest hashes the lossy encoded name") + } +} + +func TestIdleOpen_R5_3_OnlyVerifiedExactCopyWorkspaceReusesBucket(t *testing.T) { + bucket := filepath.Join(t.TempDir(), "v3", "projects", "copy-original") + root := filepath.Join(bucket, "01J0000000000000000000000A") + work := filepath.Join(root, "work") + if err := os.MkdirAll(work, 0700); err != nil { + t.Fatal(err) + } + meta := session.Meta{ID: filepath.Base(root), Owned: true, Workspace: work, Origin: "/original/project"} + if err := session.SaveMeta(root, meta); err != nil { + t.Fatal(err) + } + if got := copyBucketName(work); got != "copy-original" { + t.Fatalf("verified copy bucket=%s", got) + } + if got := copyBucketName(filepath.Join(work, "subproject")); got == "copy-original" { + t.Fatal("nested project inherited the parent's bucket") + } + meta.Owned = false + if err := session.SaveMeta(root, meta); err != nil { + t.Fatal(err) + } + if got := copyBucketName(work); got == "copy-original" { + t.Fatal("unowned folder inherited a copy bucket") + } +} diff --git a/cmd/codeaf/idle_r5c_offline_test.go b/cmd/codeaf/idle_r5c_offline_test.go new file mode 100644 index 0000000000..7ae6209015 --- /dev/null +++ b/cmd/codeaf/idle_r5c_offline_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +type r5cSilentDirectory struct { + directory.Client + t *testing.T +} + +func (d r5cSilentDirectory) Cell(ctx context.Context, _ string) (directory.CellView, error) { + deadline, ok := ctx.Deadline() + if !ok || time.Until(deadline) > 2500*time.Millisecond { + d.t.Error("free-open directory read must have a two-second deadline") + return directory.CellView{}, context.DeadlineExceeded + } + <-ctx.Done() + return directory.CellView{}, ctx.Err() +} + +func TestIdleOpen_R5c_FreeTakeBoundsInitialRead(t *testing.T) { + for _, roots := range []bool{false, true} { + t.Run(map[bool]string{false: "ordinary", true: "workspace choices"}[roots], func(t *testing.T) { + s := &syncsetup.Sync{Dir: r5cSilentDirectory{t: t}} + root := t.TempDir() + c := s.Continuer(cellstore.Engine{}, syncsetup.TakeOptions{RootFor: func(string) string { return root }}) + var err error + if roots { + _, err = c.OpenAvailableWithWorkspaceRoots(t.Context(), "saved-chat", nil, map[string]string{"root": root}) + } else { + _, err = c.OpenAvailable(t.Context(), "saved-chat", nil) + } + if !errors.Is(err, directory.ErrUnreachable) || !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("silent directory must report an unreachable timeout: %v", err) + } + }) + } +} diff --git a/cmd/codeaf/idle_r6_offline_test.go b/cmd/codeaf/idle_r6_offline_test.go new file mode 100644 index 0000000000..391c6015ab --- /dev/null +++ b/cmd/codeaf/idle_r6_offline_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "bytes" + "errors" + "net/http" + "os" + "strings" + "sync/atomic" + "testing" + + "github.com/Agent-Field/codeaf/internal/appwire" +) + +func TestIdleRelease_R6_ReleasedWindowOfflineSendSaysWhy(t *testing.T) { + var down atomic.Bool + f := newIdleAppContract(t, func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if down.Load() { + http.Error(w, "fixture offline", http.StatusServiceUnavailable) + return + } + next.ServeHTTP(w, r) + }) + }) + released := f.released(t) + var calls atomic.Int32 + original := f.model.server.Config.Handler + f.model.server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/chat/completions") { + calls.Add(1) + } + original.ServeHTTP(w, r) + }) + path := appTranscriptOf(f.b, f.agent.ID) + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + down.Store(true) + t.Cleanup(func() { down.Store(false) }) + const words = "answer these kept words when sync returns" + _, err = f.b.Send(f.agent.ID, words, "") + var wire *appwire.Error + const want = "the sync service is not answering, so this chat cannot be taken back yet; your words are still in the box" + if !errors.As(err, &wire) || wire.Code != appwire.CodeUnreachable || wire.Message != want { + t.Fatalf("released offline send = %v; want one unreachable refusal: %q", err, want) + } + after, err := os.ReadFile(path) + if err != nil || !bytes.Equal(before, after) || calls.Load() != 0 { + t.Fatalf("offline send wrote or called the model: journal unchanged=%v model calls=%d err=%v", bytes.Equal(before, after), calls.Load(), err) + } + down.Store(false) + turn, err := f.b.Send(f.agent.ID, words, "") + if err != nil { + t.Fatal(err) + } + appTurnDoneFor(t, f.rec, turn) + view, err := f.own.Dir.Cell(t.Context(), f.agent.ID) + if err != nil || view.Cell.Lease.Device != f.own.Device.ID() || view.Cell.Lease.Fence <= released.Cell.Lease.Fence || calls.Load() == 0 { + t.Fatalf("retry did not take a new fence and answer: %+v calls=%d err=%v", view, calls.Load(), err) + } +} diff --git a/cmd/codeaf/idle_r6_rows_test.go b/cmd/codeaf/idle_r6_rows_test.go new file mode 100644 index 0000000000..e8838e4029 --- /dev/null +++ b/cmd/codeaf/idle_r6_rows_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/directory" + "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" +) + +func r6AppRow(t *testing.T, released bool) (appwire.ChatRow, directory.CellView, directory.CellView) { + t.Helper() + celltest.Available(t) + t.Setenv("CODEAF_IDLE_HANDOFF", "on") + var offset atomic.Int64 + service := relayserve.New(relayserve.Config{Store: t.TempDir(), Now: func() time.Time { + return time.Now().Add(time.Duration(offset.Load())) + }}) + t.Cleanup(func() { _ = service.Close() }) + relay := httptest.NewServer(service.Handler) + t.Cleanup(relay.Close) + t.Setenv(syncsetup.URLVar, relay.URL) + aHome, bHome := t.TempDir(), t.TempDir() + id, err := identity.Ensure(aHome) + if err != nil { + t.Fatal(err) + } + if _, err := identity.Adopt(bHome, id, false); err != nil { + t.Fatal(err) + } + a, b := appDeviceSync(t, aHome), appDeviceSync(t, bHome) + for s, name := range map[*syncsetup.Sync]string{a: "tower", b: "laptop"} { + if err := syncsetup.PutOwnDevice(t.Context(), s.Dir, s.Identity, s.Device, name); err != nil { + t.Fatal(err) + } + } + c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.FilesOnly}) + if err != nil { + t.Fatal(err) + } + created, err := a.Dir.Create(t.Context(), c.ID, directory.CellInit{Head: "saved"}) + if err != nil { + t.Fatal(err) + } + if _, err := a.Dir.Heartbeat(t.Context(), c.ID, directory.Beat{Fence: created.Cell.Lease.Fence, Working: true}); err != nil { + t.Fatal(err) + } + if released { + if err := a.Dir.Release(t.Context(), c.ID, created.Cell.Lease.Fence); err != nil { + t.Fatal(err) + } + } else { + offset.Store(int64(directory.LeaseTTL + time.Second)) + } + before, err := a.Dir.Cell(t.Context(), c.ID) + if err != nil { + t.Fatal(err) + } + t.Setenv(home.EnvVar, bHome) + backend := newFakeBackend(newAppRecorder(), nil) + t.Cleanup(func() { _ = backend.Close() }) + listed, err := backend.readChats(t.Context(), b, nil, false) + if err != nil || len(listed.Chats) != 1 { + t.Fatalf("app rows=%+v err=%v", listed, err) + } + after, err := a.Dir.Cell(t.Context(), c.ID) + if err != nil { + t.Fatal(err) + } + return listed.Chats[0], before, after +} + +func TestIdleOpen_R6_LapsedWhileWorkingKeepsTheCard(t *testing.T) { + row, before, after := r6AppRow(t, false) + if !before.Cell.Lease.Working || before.Cell.Lease.Expires == 0 || before.Cell.Lease.Expires > before.Now { + t.Fatalf("fixture must lapse while working: %+v", before) + } + if row.CanOpen || !row.CanTake || row.Here || row.Status != "off" { + t.Fatalf("app lapsed working row must keep Continue here: %+v", row) + } + if before.Cell.Head != after.Cell.Head || before.Cell.Lease != after.Cell.Lease { + t.Fatal("listing a lapsed chat took it") + } +} + +func TestIdleOpen_R6_ReleasedStillOpensDirectly(t *testing.T) { + row, before, after := r6AppRow(t, true) + if !row.CanOpen || !row.CanTake || row.Here || before.Cell.Lease.Expires != 0 { + t.Fatalf("app released row lost direct opening: %+v", row) + } + if before.Cell.Head != after.Cell.Head || before.Cell.Lease != after.Cell.Lease { + t.Fatal("listing a released chat took it before open") + } +} diff --git a/cmd/codeaf/idle_r7_reopen_test.go b/cmd/codeaf/idle_r7_reopen_test.go new file mode 100644 index 0000000000..810e71a21b --- /dev/null +++ b/cmd/codeaf/idle_r7_reopen_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "context" + "errors" + "testing" + + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/session" +) + +func TestIdleRelease_R7_Reopen_AfterFailedCloseInstallsNewWriter(t *testing.T) { + cfg, c := spokenDoorChatWithPairing(t, true) + _, _, _ = heldChatRelay(t, c.ID) + book := syncDrives + takeFinalSaveFailure() + t.Cleanup(func() { takeFinalSaveFailure() }) + t.Cleanup(book.closeAll) + first, seated, _, err := openV3Agent(cfg, cfg.Place.Workspace, session.New) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = first.Close() }) + old := book.renewalsOf(c.ID) + if old == nil { + t.Fatal("opener installed no writer") + } + if err := old.Gate(); err != nil || old.started() == nil { + t.Fatalf("initial writer did not install: %v", err) + } + if err := first.Close(); err != nil { + t.Fatal(err) + } + if err := executor.Drain(t.Context(), seated.Seat); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + closeErr := old.Close(ctx) + if closeErr == nil || !errors.Is(old.Gate(), cellstore.ErrReadOnly) { + t.Fatalf("fixture needs a failed final save and a stopped writer: close=%v gate=%v", closeErr, old.Gate()) + } + reopened, _, _, err := openV3Agent(cfg, cfg.Place.Workspace, session.New) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = reopened.Close() }) + fresh := book.renewalsOf(c.ID) + if fresh == nil || fresh == old { + t.Fatal("reopening reattached the stopped writer after its failed final save") + } + failure, _, _ := takeFinalSaveFailure() + if !errors.Is(failure, closeErr) || unsavedTranscript(failure) != cfg.SessionFile { + t.Fatalf("old final-save failure was not retained with its transcript: got=%v want=%v", failure, closeErr) + } + if err := fresh.Gate(); err != nil || fresh.started() == nil { + t.Fatalf("replacement writer did not install: %v", err) + } + if err := reopened.CheckWork(); err != nil { + t.Fatalf("reopened session cannot admit work: %v", err) + } + if !errors.Is(old.Gate(), cellstore.ErrReadOnly) { + t.Fatal("replacement revived the closed writer") + } +} diff --git a/cmd/codeaf/idle_r8_lent_test.go b/cmd/codeaf/idle_r8_lent_test.go new file mode 100644 index 0000000000..3ad3b8888d --- /dev/null +++ b/cmd/codeaf/idle_r8_lent_test.go @@ -0,0 +1,15 @@ +package main + +import "testing" + +func TestIdleRelease_R8_LentModelRPCPreservesFactoryChoice(t *testing.T) { + r := newLendRig(t, "") + r.askTitled(t, "read the ledger") + before := r.live.Model() + conv := r.backend.liveOf(mustFind(t, r.backend, r.agent)).conn.Conv + conv.SetModel("other/model") + t.Logf("factory model before=%q after direct lent RPC=%q", before, r.live.Model()) + if got := r.live.Model(); got != before { + t.Errorf("lent model RPC bypassed the factory model fence: %q -> %q", before, got) + } +} diff --git a/cmd/codeaf/idle_r8_rename_test.go b/cmd/codeaf/idle_r8_rename_test.go new file mode 100644 index 0000000000..12c7dc9382 --- /dev/null +++ b/cmd/codeaf/idle_r8_rename_test.go @@ -0,0 +1,44 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +func TestIdleRelease_R8_AppRenameReachesReceiver(t *testing.T) { + f := newIdleAppContract(t) + first := f.released(t) + chosen := "Kept after release" + view, err := f.b.Rename(f.agent.ID, chosen) + if err != nil || view.Title != chosen { + t.Fatalf("rename=%+v error=%v", view, err) + } + appSyncWait(t, "app rename to save under a new released fence", func() bool { + v, err := f.own.Dir.Cell(t.Context(), f.agent.ID) + return err == nil && v.Cell.Lease.Fence > first.Cell.Lease.Fence && v.Cell.Lease.Expires == 0 + }) + peer := appDeviceSync(t, f.peerHome) + engine := cellstore.EngineFor("") + engine.DataRoot = filepath.Join(peer.Home, "v3", "stores") + got, err := peer.Continuer(engine, syncsetup.TakeOptions{RootFor: func(id string) string { return takeRootForAt(peer.Home, id) }}).OpenAvailable(t.Context(), f.agent.ID, nil) + if err != nil { + t.Fatal(err) + } + meta, err := session.LoadMeta(got.Taken.Cell.Root) + if err != nil || meta.Title != chosen { + t.Fatalf("received title=%q error=%v", meta.Title, err) + } + raw, err := os.ReadFile(session.Place{Dir: got.Taken.Cell.Root}.Transcript()) + if err != nil || !strings.Contains(string(raw), chosen) { + t.Fatalf("received journal=%s error=%v", raw, err) + } + if _, err := f.b.Rename(f.agent.ID, "refused words"); err == nil { + t.Fatal("app acknowledged a rename after the peer took the chat") + } +} diff --git a/cmd/codeaf/idle_settings_test.go b/cmd/codeaf/idle_settings_test.go new file mode 100644 index 0000000000..7d032436e8 --- /dev/null +++ b/cmd/codeaf/idle_settings_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "encoding/json" + "errors" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +func TestIdleRelease_R5_1_AppSettingsRetakeOrKeepRequestedChoice(t *testing.T) { + f := newIdleAppContract(t) + first := f.released(t) + if _, err := f.b.SetEffort(f.agent.ID, "high"); err != nil { + t.Fatal(err) + } + second := f.released(t) + if second.Cell.Lease.Fence <= first.Cell.Lease.Fence { + t.Fatal("app setting did not retake ownership") + } + peer := appDeviceSync(t, f.peerHome) + engine := cellstore.EngineFor("") + engine.DataRoot = filepath.Join(peer.Home, "v3", "stores") + got, err := peer.Continuer(engine, syncsetup.TakeOptions{RootFor: func(id string) string { return takeRootForAt(peer.Home, id) }}).OpenAvailable(t.Context(), f.agent.ID, nil) + if err != nil { + t.Fatal(err) + } + m, err := session.LoadMeta(got.Taken.Cell.Root) + if err != nil || m.Effort != "high" { + t.Fatalf("receiver effort=%q err=%v", m.Effort, err) + } + for _, change := range []func() error{ + func() error { _, err := f.b.SetEffort(f.agent.ID, "low"); return err }, + func() error { _, err := f.b.SetModel(f.agent.ID, "stub/other"); return err }, + } { + var wire *appwire.Error + if err := change(); !errors.As(err, &wire) { + t.Fatalf("taken setting error=%v", err) + } + raw, _ := json.Marshal(wire.Detail) + var moved appwire.HeldElsewhere + if err := json.Unmarshal(raw, &moved); err != nil || moved.Kind != "held_elsewhere" || moved.Holder == nil || moved.Holder.ID != peer.Device.ID() { + t.Fatalf("held-elsewhere detail=%s err=%v", raw, err) + } + } + m, err = session.LoadMeta(session.DirOf(appTranscriptOf(f.b, f.agent.ID))) + if err != nil || m.Effort != "high" { + t.Fatalf("refused app setting saved effort=%q err=%v", m.Effort, err) + } +} diff --git a/cmd/codeaf/idle_terminal_regression_test.go b/cmd/codeaf/idle_terminal_regression_test.go new file mode 100644 index 0000000000..041c2a88bb --- /dev/null +++ b/cmd/codeaf/idle_terminal_regression_test.go @@ -0,0 +1,257 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "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/relayserve" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/tui3" +) + +// Exercise the terminal's own construction, including its liveDrive, watch, +// adaptive session and memory store. A hand-bound Drive misses this boundary. +func TestIdleRelease_R6_RealTerminalReleasesAfterModelAndShell(t *testing.T) { + idleRealTerminal(t, false) +} +func TestIdleRelease_R5_1_RealTerminalSettingsAndMemoryReachReceiver(t *testing.T) { + idleRealTerminal(t, true) +} +func idleRealTerminal(t *testing.T, settings bool) { + t.Setenv("CODEAF_IDLE_HANDOFF", "on") + service := relayserve.New(relayserve.Config{Store: t.TempDir()}) + t.Cleanup(func() { _ = service.Close() }) + h := newHostedMoveRig(t, func(http.Handler) http.Handler { return service.Handler }) + t.Setenv("CODEAF_NO_UPDATE_CHECK", "1") + t.Setenv("CODEAF_ENGINE_DAEMON", "1") + t.Setenv(syncsetup.IntervalVar, "5000") + t.Setenv("CODEAF_PROFILE_DIR", h.a.Home) + if err := h.b.Rename(t.Context(), "other computer"); err != nil { + t.Fatal(err) + } + place, err := v3MintSession(filepath.Join(h.a.Home, "v3", "projects", "terminal"), h.work, h.work, true) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(place.Workspace, 0700); err != nil { + t.Fatal(err) + } + if raw, err := exec.Command("git", "init", "--quiet", place.Workspace).CombinedOutput(); err != nil { + t.Fatalf("fixture git: %s %v", raw, err) + } + brain := v3Memory(h.a.Home) + if brain == nil { + t.Fatal("no terminal memory store") + } + defer brain.Close() + model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + answer := "terminal answer" + if strings.Contains(string(raw), "D1 second question") { + answer = "terminal second answer" + } + content, _ := json.Marshal(answer) + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":%s},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n", content) + })) + defer model.Close() + cfg := session.Config{Workspace: place.Workspace, Place: place, SessionFile: place.Transcript(), ProfileDir: h.a.Home, + Model: "stub/chat", APIKey: "stub", BaseURL: model.URL, System: "SYSTEM", Memory: brain, MemoryIndex: brain} + agent, cfg, _, err := openV3Agent(cfg, place.Workspace, v3OpenSession) + if err != nil { + t.Fatal(err) + } + defer func() { agent.Close(); syncDrives.closeAll() }() + noRestore := &walkNoRestore{attempted: make(chan struct{}, 1)} + input, typed := io.Pipe() + screen := &surfaceFrameWriter{ready: make(chan struct{})} + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + previousRun := runSurfaceProgram + runSurfaceProgram = func(ctx context.Context, o tui3.Options) error { + o.Input, o.Output, o.Width, o.Height = input, screen, 120, 35 + return tui3.Run(ctx, o) + } + defer func() { runSurfaceProgram = previousRun }() + go func() { + done <- runSurface(ctx, tui3.Options{Agent: agent, Workspace: place.Workspace, SessionFile: place.Transcript(), ProfileDir: h.a.Home, Machines: surfaceMachines{h.a}, Takeover: noRestore, Memory: v3MemorySeam(brain), Input: input, Output: screen, Width: 120, Height: 35}) + }() + defer func() { cancel(); input.Close(); typed.Close(); <-done }() + select { + case <-screen.ready: + case <-time.After(5 * time.Second): + t.Fatal("no terminal frame") + } + send := func(text, saved string) { + t.Helper() + if _, err := io.WriteString(typed, text+"\r"); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + raw, _ := os.ReadFile(place.Transcript()) + if strings.Contains(string(raw), saved) { + time.Sleep(200 * time.Millisecond) + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("terminal did not save %s: %s", saved, screen.String()) + } + send("terminal model question", "terminal answer") + send("! printf terminal-file > terminal.txt; printf 'node_modules/\\n' > .gitignore; mkdir -p node_modules/x; printf local-install > node_modules/x/index.js", "terminal-file") + + id := place.ID() + released := func(after uint64) directory.CellView { + t.Helper() + c, err := cell.OpenAt(place.Dir, id) + if err != nil { + t.Fatal(err) + } + // A command's words enter the journal before its tool has finished. + // Measure idle release from settled work and its published local head, + // as in the hand walk, rather than charging execution to release latency. + settling := time.Now() + readyBy := settling.Add(30 * time.Second) + published := false + for time.Now().Before(readyBy) { + head, headErr := cellstore.Head(c) + view, viewErr := h.a.Dir.Cell(t.Context(), id) + if headErr == nil && head != nil && viewErr == nil && view.Cell.Head == head.Turn.ID && view.Cell.Lease.Fence > after && cfg.Seals.(interface{ IdleReady() bool }).IdleReady() { + published = true + break + } + time.Sleep(20 * time.Millisecond) + } + if !published { + head, _ := cellstore.Head(c) + view, viewErr := h.a.Dir.Cell(t.Context(), id) + t.Fatalf("terminal work did not settle and publish: ready=%v local=%+v relay=%+v error=%v title=%q", cfg.Seals.(interface{ IdleReady() bool }).IdleReady(), head, view, viewErr, agent.Title()) + } + t.Logf("terminal work settled and published in %s", time.Since(settling)) + quiet := time.Now() + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + view, e := h.a.Dir.Cell(t.Context(), id) + if e == nil && view.Cell.Head != "" && view.Cell.Lease.Expires == 0 && view.Cell.Lease.Fence > after { + t.Logf("fence %d released %s after settled publication", view.Cell.Lease.Fence, time.Since(quiet)) + return view + } + time.Sleep(50 * time.Millisecond) + } + stack := make([]byte, 256<<10) + stack = stack[:runtime.Stack(stack, true)] + for _, block := range strings.Split(string(stack), "\n\n") { + if strings.Contains(block, "syncsetup") || strings.Contains(block, "idleForHandoff") || strings.Contains(block, "liveDrive") { + t.Log(block) + } + } + proof, proofErr := cellstore.EngineFor(place.Workspace).SavedState(t.Context(), c) + var changes []string + if proof != nil { + changes = proof.Changes + } + t.Fatalf("terminal did not release: watchReady=%v dirty=%v err=%v screen=%s", cfg.Seals.(interface{ IdleReady() bool }).IdleReady(), changes, proofErr, screen.String()) + return directory.CellView{} + } + first := released(0) + if strings.TrimSpace(agent.Title()) == "" { + t.Fatal("terminal chat has no title after the model turn") + } + t.Logf("model and ! turns released fence %d", first.Cell.Lease.Fence) + send("D1 second question", "terminal second answer") + second := released(first.Cell.Lease.Fence) + if noRestore.calls.Load() != 0 { + t.Fatal("D1 send used restore") + } + ignored := filepath.Join(place.Workspace, "node_modules", "x", "index.js") + if raw, e := os.ReadFile(ignored); e != nil || string(raw) != "local-install" { + t.Fatalf("D1 install=%q err=%v", raw, e) + } + for _, line := range []string{"Moved here", "Set this machine up like", "Continue this chat here?", "submit failed: This chat is available to continue here."} { + if strings.Contains(screen.String(), line) { + t.Fatalf("D1 showed %q", line) + } + } + if !settings { + return + } + if _, err := io.WriteString(typed, "/effort high\r/remember terminal kept memory\r"); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + m, _ := session.LoadMeta(place.Dir) + if m.Effort == "high" { + break + } + time.Sleep(20 * time.Millisecond) + } + third := released(second.Cell.Lease.Fence) + got, err := h.b.Continuer(h.eb, syncsetup.TakeOptions{DeviceName: "receiver", RootFor: func(id string) string { return filepath.Join(h.bRoots, id) }}).OpenAvailable(t.Context(), id, &directory.LeaseExpectation{Device: third.Cell.Lease.Device, Fence: third.Cell.Lease.Fence}) + if err != nil { + t.Fatal(err) + } + m, err := session.LoadMeta(got.Taken.Cell.Root) + if err != nil || m.Effort != "high" { + t.Fatalf("C5 received effort=%q err=%v", m.Effort, err) + } + raw, err := os.ReadFile(filepath.Join(got.Taken.Cell.Root, cell.StateDir, "memories.jsonl")) + if err != nil || !strings.Contains(string(raw), "terminal kept memory") { + t.Fatalf("C5 received memory=%q err=%v", raw, err) + } +} + +func TestIdleRelease_R6_RealTerminalOpeningSavedChatReleases(t *testing.T) { + t.Setenv("CODEAF_IDLE_HANDOFF", "on") + service := relayserve.New(relayserve.Config{Store: t.TempDir()}) + t.Cleanup(func() { _ = service.Close() }) + h := newHostedMoveRig(t, func(http.Handler) http.Handler { return service.Handler }) + t.Setenv("CODEAF_ENGINE_DAEMON", "1") + t.Setenv("CODEAF_PROFILE_DIR", h.a.Home) + if err := h.b.Rename(t.Context(), "peer"); err != nil { + t.Fatal(err) + } + // Seed the terminal's real store, rather than the rig's private native store. + // Otherwise reopening cannot find the already-published snapshot locally. + engine := cellstore.EngineFor(h.work) + source := h.publish(h.a, engine, h.c, "source") + if err := source.Close(t.Context()); err != nil { + t.Fatal(err) + } + before, err := h.a.Dir.Cell(t.Context(), h.c.ID) + if err != nil { + t.Fatal(err) + } + place := session.Place{Dir: h.c.Root, Workspace: h.work} + agent, cfg, _, err := openV3Agent(session.Config{Workspace: h.work, Place: place, SessionFile: place.Transcript(), ProfileDir: h.a.Home, Model: "stub/chat", APIKey: "stub", BaseURL: "http://127.0.0.1:1", System: "SYSTEM"}, h.work, v3OpenSession) + if err != nil { + t.Fatal(err) + } + defer func() { agent.Close(); syncDrives.closeAll() }() + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + v, err := h.a.Dir.Cell(t.Context(), h.c.ID) + if err == nil && v.Cell.Lease.Fence > before.Cell.Lease.Fence && v.Cell.Lease.Expires == 0 { + return + } + time.Sleep(30 * time.Millisecond) + } + proof, proofErr := engine.SavedState(t.Context(), h.c) + t.Fatalf("opening a saved terminal chat never released: ready=%v proof=%+v error=%v", cfg.Seals.(interface{ IdleReady() bool }).IdleReady(), proof, proofErr) +} diff --git a/cmd/codeaf/idle_walk_terminal_test.go b/cmd/codeaf/idle_walk_terminal_test.go new file mode 100644 index 0000000000..f31f0d8231 --- /dev/null +++ b/cmd/codeaf/idle_walk_terminal_test.go @@ -0,0 +1,155 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "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/executor" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/tui3" +) + +type walkNoRestore struct { + calls atomic.Int32 + attempted chan struct{} +} + +func (t *walkNoRestore) Take(context.Context, string) (tui3.Taken, error) { + t.calls.Add(1) + select { + case t.attempted <- struct{}{}: + default: + } + return tui3.Taken{}, errors.New("D1 attempted a restore for same-window work") +} +func (t *walkNoRestore) TakeSeen(ctx context.Context, id string, _ *directory.LeaseExpectation) (tui3.Taken, error) { + return t.Take(ctx, id) +} +func (t *walkNoRestore) OpenAvailable(ctx context.Context, id string, _ *directory.LeaseExpectation) (tui3.Taken, error) { + return t.Take(ctx, id) +} + +func TestIdleRelease_C3_D1_NativeTerminalModelSendReacquiresInPlace(t *testing.T) { + t.Setenv("CODEAF_IDLE_HANDOFF", "on") + celltest.RequireEngine(t) + h := newHostedMoveRig(t) + if err := h.b.Rename(t.Context(), "laptop"); err != nil { + t.Fatal(err) + } + if raw, err := exec.Command("git", "init", "--quiet", h.work).CombinedOutput(); err != nil { + t.Fatalf("fixture repository: %s, %v", raw, err) + } + ignored := filepath.Join(h.work, "node_modules", "x", "index.js") + if err := os.MkdirAll(filepath.Dir(ignored), 0700); err != nil { + t.Fatal(err) + } + for file, text := range map[string]string{filepath.Join(h.work, ".gitignore"): "node_modules/\n", ignored: "local install"} { + if err := os.WriteFile(file, []byte(text), 0600); err != nil { + t.Fatal(err) + } + } + drive := h.publish(h.a, h.ea, h.c, "desktop") + drive.SetIdleCheck(func() bool { return false }) + entered := make(chan struct{}, 4) + var sending atomic.Bool + model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if sending.Load() && r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/chat/completions") { + view, err := h.a.Dir.Cell(r.Context(), h.c.ID) + if err != nil || view.Cell.Lease.Device != h.a.Device.ID() || view.Cell.Lease.Expires <= view.Now || view.Cell.Lease.Fence < 2 { + t.Errorf("D1 provider entered without a new owned fence: %+v, %v", view, err) + } + select { + case entered <- struct{}{}: + default: + } + } + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"walk answer\"},\"finish_reason\":null}]}\n\ndata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n") + })) + t.Cleanup(model.Close) + seat, err := cellstore.SeatOver(executor.HostBound, h.c, h.work, nil, nil, func(cellstore.Engine) cellstore.Store { return drive.Store(h.ea) }) + if err != nil { + t.Fatal(err) + } + workGate := func() error { + ctx, cancel := context.WithTimeout(t.Context(), driveWorkWithin) + defer cancel() + confirmed, err := drive.ConfirmWork(ctx) + if err != nil { + return err + } + if !confirmed { + return cellstore.ErrReadOnly + } + return drive.Gate() + } + seat = executor.Gated(seat, drive.Gate, workGate) + watch := &cellstore.SealWatch{OnTurnEnd: drive.Idle} + watch.OnTail = func() { executor.Settle(t.Context(), seat); drive.Idle() } + watch.Waiting() + file := session.Place{Dir: h.c.Root}.Transcript() + agent, err := session.New(session.Config{Workspace: h.work, Place: session.Place{Dir: h.c.Root, Workspace: h.work}, SessionFile: file, + Model: "stub/chat", APIKey: "stub", BaseURL: model.URL, System: "SYSTEM", Seat: seat, Seals: watch, Renewals: moveRenewals{drive, h.changes}, ProfileDir: h.a.Home}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = agent.Close() }) + drive.SetIdleCheck(watch.IdleReady) + agent.SettleWrites() + if _, err = drive.Store(h.ea).Seal(t.Context(), h.c, cellstore.TurnInfo{}); err != nil { + t.Fatal(err) + } + drive.Idle() + h.wait("source to release", func() bool { + v, err := h.a.Dir.Cell(t.Context(), h.c.ID) + return err == nil && v.Cell.Lease.Expires == 0 + }) + noRestore := &walkNoRestore{attempted: make(chan struct{}, 1)} + input, typed := io.Pipe() + screen := &surfaceFrameWriter{ready: make(chan struct{})} + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { + done <- tui3.Run(ctx, tui3.Options{Agent: agent, Workspace: h.work, SessionFile: file, Machines: surfaceMachines{h.a}, Takeover: noRestore, + Input: input, Output: screen, Width: 120, Height: 35}) + }() + defer func() { cancel(); input.Close(); typed.Close(); <-done }() + <-screen.ready + sending.Store(true) + if _, err = io.WriteString(typed, "ordinary model message in this window\r"); err != nil { + t.Fatal(err) + } + select { + case <-noRestore.attempted: + t.Fatal("D1 ordinary send took the restore door") + case <-entered: + case <-time.After(10 * time.Second): + t.Fatalf("D1 no model call: %s", screen.String()) + } + h.wait("model answer saved", func() bool { raw, _ := os.ReadFile(file); return strings.Contains(string(raw), "walk answer") }) + if noRestore.calls.Load() != 0 { + t.Fatal("D1 restore ran") + } + if raw, err := os.ReadFile(ignored); err != nil || string(raw) != "local install" { + t.Fatalf("D1 ignored install changed: %q, %v", raw, err) + } + for _, text := range []string{"Moved here", "Set this machine up like"} { + if strings.Contains(screen.String(), text) { + t.Fatalf("D1 showed %q", text) + } + } +} diff --git a/cmd/codeaf/idle_walk_test.go b/cmd/codeaf/idle_walk_test.go new file mode 100644 index 0000000000..d388bbaad2 --- /dev/null +++ b/cmd/codeaf/idle_walk_test.go @@ -0,0 +1,169 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +func TestIdleRelease_C3_D1_AppModelSendPreservesIgnoredInstall(t *testing.T) { + f := newIdleAppContract(t) + root := session.DirOf(appTranscriptOf(f.b, f.agent.ID)) + meta, err := session.LoadMeta(root) + if err != nil { + t.Fatal(err) + } + work := meta.Workspace + if meta.Owned { + work = session.Place{Dir: root, Owned: true}.Work() + } + ignored := filepath.Join(work, "node_modules", "x", "index.js") + if raw, err := exec.Command("git", "init", "--quiet", work).CombinedOutput(); err != nil { + t.Fatalf("fixture repository: %s, %v", raw, err) + } + if err := os.MkdirAll(filepath.Dir(ignored), 0700); err != nil { + t.Fatal(err) + } + for file, text := range map[string]string{filepath.Join(work, ".gitignore"): "node_modules/\n", ignored: "local install"} { + if err := os.WriteFile(file, []byte(text), 0600); err != nil { + t.Fatal(err) + } + } + seed, err := f.b.Send(f.agent.ID, "seal the ignored-install fixture", "") + if err != nil { + t.Fatal(err) + } + appTurnDoneFor(t, f.rec, seed) + f.released(t) + if raw, err := os.ReadFile(ignored); err != nil || string(raw) != "local install" { + journal, _ := os.ReadFile(appTranscriptOf(f.b, f.agent.ID)) + t.Fatalf("fixture ignored file: %q, %v; journal:\n%s", raw, err, journal) + } + f.rec.mu.Lock() + before := len(f.rec.data) + f.rec.mu.Unlock() + live := f.b.agent(f.agent.ID).live + turn, err := f.b.Send(f.agent.ID, "ordinary message after release", "") + if err != nil { + t.Fatal(err) + } + appTurnDoneFor(t, f.rec, turn) + if f.b.agent(f.agent.ID).live != live { + t.Fatal("D1 replaced the open app conversation") + } + if raw, err := os.ReadFile(ignored); err != nil || string(raw) != "local install" { + t.Fatalf("D1 changed ignored local file: %q, %v", raw, err) + } + f.rec.mu.Lock() + defer f.rec.mu.Unlock() + raw, err := json.Marshal(f.rec.data[before:]) + if err != nil { + t.Fatal(err) + } + for _, text := range []string{"Moved here", "Set this machine up like", "This chat moved here"} { + if strings.Contains(string(raw), text) { + t.Fatalf("D1 emitted move/setup text: %s", text) + } + } +} + +func TestIdleOpen_C6_D2_AppExistingViewerPullsNewestHead(t *testing.T) { + f := newIdleAppContract(t) + seen := f.released(t) + peer := appDeviceSync(t, f.peerHome) + receiverEngine := cellstore.EngineFor("") + receiverEngine.DataRoot = filepath.Join(peer.Home, "v3", "stores") + receiver := peer.Continuer(receiverEngine, syncsetup.TakeOptions{ + RootFor: func(id string) string { return takeRootForAt(peer.Home, id) }, + }) + got, err := receiver.OpenAvailable(t.Context(), f.agent.ID, nil) + if err != nil { + t.Fatal(err) + } + sourceAgent := f.b.agent(f.agent.ID) + appSyncWait(t, "source viewer", func() bool { + viewer := false + f.b.locked(func() { view := sourceAgent.view; viewer = view.Location != nil && !view.Location.Here }) + return viewer + }) + root := got.Taken.Cell.Root + meta, err := session.LoadMeta(root) + if err != nil { + t.Fatal(err) + } + work := meta.Workspace + if meta.Owned { + work = session.Place{Dir: root, Owned: true}.Work() + } + journal, err := os.OpenFile(session.Place{Dir: root}.Transcript(), os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + t.Fatal(err) + } + _, writeErr := fmt.Fprintln(journal, `{"type":"message","role":"assistant","content":"newest D2 journal from B"}`) + closeErr := journal.Close() + if writeErr != nil || closeErr != nil { + t.Fatalf("journal: %v, %v", writeErr, closeErr) + } + engine := cellstore.EngineFor(work) + engine.DataRoot = receiverEngine.DataRoot + drive, err := peer.Drive(t.Context(), engine, got.Taken.Cell, syncsetup.DriveOptions{DeviceName: "laptop"}) + if err != nil { + t.Fatal(err) + } + // Reopening acquires a new fence rather than renewing the take's + // (TestStartupAcquiresNewHold), so the row that frees up is the one at the + // fence this reopened writer holds, read from the directory before it closes. + dirID := got.Taken.Driving.Remote + if dirID == "" { + dirID = got.Taken.Driving.Cell.ID + } + writer, err := peer.Dir.Cell(t.Context(), dirID) + if err != nil { + t.Fatal(err) + } + writerFence := writer.Cell.Lease.Fence + if writer.Cell.Lease.Device != peer.Device.ID() || writerFence <= got.Taken.Driving.Fence { + t.Fatalf("reopened writer holds %+v; want this device at a fence newer than the take's %d", writer.Cell.Lease, got.Taken.Driving.Fence) + } + sealed, sealErr := drive.Store(engine).Seal(t.Context(), got.Taken.Cell, cellstore.TurnInfo{}) + closeErr = drive.Close(context.Background()) + if sealErr != nil || closeErr != nil { + t.Fatalf("publish: %v, %v", sealErr, closeErr) + } + appSyncWait(t, "free newest row", func() bool { + rows, err := f.b.ListChats() + if err != nil { + return false + } + for _, r := range rows.Chats { + if r.Cell == f.agent.ID { + return r.CanOpen && !r.Here && r.Seen.Fence == writerFence + } + } + return false + }) + opened, err := f.b.OpenAgent(f.agent.ID) + if err != nil { + t.Fatal(err) + } + if opened.Location == nil || !opened.Location.Here { + t.Fatalf("D2 viewer did not open here: %+v", opened) + } + raw, err := os.ReadFile(appTranscriptOf(f.b, f.agent.ID)) + if err != nil || !strings.Contains(string(raw), "newest D2 journal from B") { + t.Fatalf("D2 retained stale journal: %q, %v", raw, err) + } + view, err := f.own.Dir.Cell(t.Context(), f.agent.ID) + if err != nil || view.Cell.Lease.Device != f.own.Device.ID() || view.Cell.Lease.Fence <= seen.Cell.Lease.Fence || view.Cell.Head != sealed.Turn.ID { + t.Fatalf("D2 receiver hold=%+v, %v", view, err) + } +} diff --git a/cmd/codeaf/syncdrive.go b/cmd/codeaf/syncdrive.go index b9e567e438..bca0fdbc9a 100644 --- a/cmd/codeaf/syncdrive.go +++ b/cmd/codeaf/syncdrive.go @@ -11,6 +11,7 @@ import ( "github.com/Agent-Field/codeaf/internal/cellstore" "github.com/Agent-Field/codeaf/internal/cellsync" "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/executor" "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/syncsetup" @@ -35,6 +36,8 @@ type driveBook struct { // notices is installed by an app assembly before any drive starts. Nil // retains the terminal's desk and its marking first-run behaviour. notices *appDriveNotices + // idleCheck includes app work waiting outside the session's own queue. + idleCheck func(string) bool } type appDriveNotices struct { @@ -84,7 +87,9 @@ func (b *driveBook) pendingDriveOf(c cell.Cell, engine cellstore.Engine, report cancel() if err != nil { report(err) - return d, b.claim(d, c.ID) + // Close stops the writer even when its last save fails. Retain that + // failure for the final receipt, then give the reopened chat a writer. + recordFinalSaveFailure(session.SavingError(d.transcript, err)) } } // A drive side that no longer holds the chat's lease (another machine took it, or it was moved @@ -102,6 +107,9 @@ func (b *driveBook) pendingDriveOf(c cell.Cell, engine cellstore.Engine, report d := newAdmissionDrive(func(changed func()) (*syncsetup.Drive, error) { return startLiveDrive(c, engine, report, appNotices, sayNotice, changed) }, notices) + if check := b.idleCheck; check != nil { + d.surfaceIdle = func() bool { return check(c.ID) } + } if appNotices != nil { d.say = sayNotice } @@ -303,6 +311,38 @@ func driveWorkGate(d *liveDrive) func() error { } } +func driveSettingsGate(d *liveDrive) func() error { + if d == nil { + return nil + } + return func() error { + if started := d.published.Load(); started != nil { + ctx, cancel := context.WithTimeout(context.Background(), executor.TakeBackWithin) + defer cancel() + return started.ConfirmSettingsWork(ctx) + } + return nil + } +} + +func drivePreparedGate(d *liveDrive) func() error { + if d == nil { + return nil + } + return func() error { + if started := d.started(); started != nil { + within := driveWorkWithin + if started.IdleReleased() { + within = executor.TakeBackWithin + } + ctx, cancel := context.WithTimeout(context.Background(), within) + defer cancel() + return started.ConfirmPreparedWork(ctx) + } + return nil + } +} + // driveStore is how the seat's seals reach the drive side; nil seals on the // engine itself. func driveStore(d *liveDrive) func(cellstore.Engine) cellstore.Store { diff --git a/cmd/codeaf/syncdrive_admission.go b/cmd/codeaf/syncdrive_admission.go index 49690be603..c7e872b27d 100644 --- a/cmd/codeaf/syncdrive_admission.go +++ b/cmd/codeaf/syncdrive_admission.go @@ -6,6 +6,7 @@ import ( "sync" "time" + "github.com/Agent-Field/codeaf/internal/executor" "github.com/Agent-Field/codeaf/internal/syncsetup" ) @@ -51,11 +52,19 @@ func newAdmissionDrive(start func(func()) (*syncsetup.Drive, error), notices ... } func (w *workAdmission) admit(d *syncsetup.Drive) error { - if err := d.Gate(); err != nil { - return err + released := d.BeginWork() + within := driveWorkWithin + if released { + within = executor.TakeBackWithin } - ctx, cancel := context.WithTimeout(context.Background(), driveWorkWithin) + ctx, cancel := context.WithTimeout(context.Background(), within) defer cancel() + if err := d.Gate(); err != nil && !released { + return err + } + if released { + w.freshFor(0) + } // Concurrent doors share the read and its result, including a failure. // Waiting for another door consumes this caller's budget too. done, mine := w.join() diff --git a/cmd/codeaf/syncdrive_live.go b/cmd/codeaf/syncdrive_live.go index 6b6b43ab09..79e10f0cc1 100644 --- a/cmd/codeaf/syncdrive_live.go +++ b/cmd/codeaf/syncdrive_live.go @@ -4,6 +4,7 @@ import ( "context" "errors" "sync" + "sync/atomic" "time" "github.com/Agent-Field/codeaf/internal/cell" @@ -33,6 +34,9 @@ type liveDrive struct { transcript string localGate *localToolGate + // published is visible before idle release is enabled. Settings must not + // wait for installation or catch-up disk work under mu. + published atomic.Pointer[syncsetup.Drive] mu sync.Mutex drive *syncsetup.Drive working bool // A turn may start before asynchronous startup finishes. @@ -50,7 +54,9 @@ type liveDrive struct { refusedSince time.Time told error // changes coalesces local health updates for the host's cached surface state. - changes chan struct{} + changes chan struct{} + idleCheck func() bool + surfaceIdle func() bool } // newLiveDrive starts the drive side behind the first paint. Local startup @@ -182,6 +188,7 @@ func (l *liveDrive) install(d *syncsetup.Drive, err error, ready chan struct{}) defer close(ready) l.ready = nil l.drive, l.waiting, l.attempted = d, errors.Is(err, syncsetup.ErrNoIdentity) || errors.Is(err, syncsetup.ErrQuiet) || syncwords.NetworkFailure(err), true + l.published.Store(d) if d == nil && !l.closed { if r, ok := cellsync.RefusalOf(err); ok { now := l.nowTime() @@ -214,6 +221,7 @@ func (l *liveDrive) install(d *syncsetup.Drive, err error, ready chan struct{}) l.told = nil l.refusedSince = time.Time{} d.Working(l.working) + d.SetIdleCheck(l.idleReady) d.CatchUp() l.retryWait = 0 } else if !l.closed && cellsync.Unreachable(err) { @@ -223,6 +231,24 @@ func (l *liveDrive) install(d *syncsetup.Drive, err error, ready chan struct{}) return l.closed } +func (l *liveDrive) setIdleCheck(check func() bool) { + l.mu.Lock() + defer l.mu.Unlock() + l.idleCheck = check +} + +func (l *liveDrive) idleReady() bool { + check, surface, closed := l.idleChecks() + return !closed && check != nil && (surface == nil || surface()) && check() +} + +// idleChecks reads the checks under mu; they run after it is released. +func (l *liveDrive) idleChecks() (check, surface func() bool, closed bool) { + l.mu.Lock() + defer l.mu.Unlock() + return l.idleCheck, l.surfaceIdle, l.closed +} + // retryStartup owns the next attempt after a transport failure. It runs under // mu; gates and seals remain local until the single startup attempt succeeds. func (l *liveDrive) retryStartup() { @@ -324,6 +350,12 @@ func (l *liveDrive) Gate() error { return nil } +func (l *liveDrive) SavedMetadata() { + if d := l.published.Load(); d != nil { + d.SavedMetadata() + } +} + // Working retains the turn state while startup runs, so a first send cannot // lose its start beat. Installing and changing it share the lock to prevent // startup from replaying an older state after the turn has ended. @@ -427,9 +459,13 @@ const holdsLeaseWithin = 5 * time.Second // superseded says a drive side was running and the chat has since been taken from it, by // another machine or by a move back to this one, so that it no longer holds the lease it -// publishes under. A drive side that still waits for an identity, or that drives, is not. +// publishes under. A closed writer must also be replaced, even if its failed +// final save left a live lease. An open drive still waiting for identity is not. func (l *liveDrive) superseded() bool { - d := l.started() + d, closed := l.driveAndClosed() + if closed { + return true + } if d == nil { return l.localGate != nil && l.localGate.superseded() } @@ -438,6 +474,13 @@ func (l *liveDrive) superseded() bool { return !d.HoldsLease(ctx) } +// driveAndClosed reads the drive side and whether it was closed under one lock. +func (l *liveDrive) driveAndClosed() (*syncsetup.Drive, bool) { + l.mu.Lock() + defer l.mu.Unlock() + return l.drive, l.closed +} + // started is the drive side if one ever started, without trying to start it. func (l *liveDrive) started() *syncsetup.Drive { l.mu.Lock() diff --git a/cmd/codeaf/synctake.go b/cmd/codeaf/synctake.go index fe32f74a93..7816a67d1c 100644 --- a/cmd/codeaf/synctake.go +++ b/cmd/codeaf/synctake.go @@ -2,8 +2,6 @@ package main import ( "context" - "crypto/sha256" - "encoding/hex" "errors" "fmt" "log" @@ -215,7 +213,13 @@ func (s surfaceMachines) RowsForSend(ctx context.Context) ([]chatlist.Row, uint6 } key := directory.MetadataKey(s.Identity.CellKey()) open := func(sealed string) (string, error) { return directory.OpenName(key, sealed) } - return chatlist.Rows(l, s.Device.ID(), open), l.Version, nil + rows := chatlist.Rows(l, s.Device.ID(), open) + if !s.IdleHandoffAllowed() { + for i := range rows { + rows[i].AutoOpen = false + } + } + return rows, l.Version, nil } // standingWithin bounds the launch-time check so a silent relay costs nothing. @@ -286,6 +290,10 @@ type takeover struct { moving *takeProgress } +func (t takeover) IdleHandoffAllowed() bool { + return t.c != nil && t.c.IdleHandoffAllowed() +} + // takeProgress is how far the terminal's one running continue has got. type takeProgress struct { mu sync.Mutex @@ -346,6 +354,26 @@ func (t takeover) TakeSeen(ctx context.Context, id string, seen *directory.Lease return surfaceTaken(got, elapsed), nil } +func (t takeover) OpenAvailable(ctx context.Context, id string, seen *directory.LeaseExpectation) (tui3.Taken, error) { + began := time.Now() + t.moving.start(id) + got, err := t.c.OpenAvailable(ctx, id, seen) + if err != nil { + return tui3.Taken{}, surfaceTakeError(err) + } + return surfaceTaken(got, time.Since(began)), nil +} + +func (t takeover) OpenAvailableWithWorkspaceRoots(ctx context.Context, id string, seen *directory.LeaseExpectation, roots map[string]string) (tui3.Taken, error) { + began := time.Now() + t.moving.start(id) + got, err := t.c.OpenAvailableWithWorkspaceRoots(ctx, id, seen, roots) + if err != nil { + return tui3.Taken{}, surfaceTakeError(err) + } + return surfaceTaken(got, time.Since(began)), nil +} + func (t takeover) TakeSeenWithWorkspaceRoots(ctx context.Context, id string, seen *directory.LeaseExpectation, roots map[string]string) (tui3.Taken, error) { began := time.Now() t.moving.start(id) @@ -382,21 +410,6 @@ func takeRootForAt(dir, id string) string { return filepath.Join(dir, "v3", "projects", "copies", id) } -// takeCopyBucket files each copy under the folder it came from, apart from -// conversations that work in this computer's own folder of the same name. A -// chat's origin grows with every move, so an uncapped bucket name stops working -// at the filesystem's 255-byte component limit. -func takeCopyBucket(origin string) string { return takeCopyBucketAt(home.Dir(), origin) } - -func takeCopyBucketAt(dir, origin string) string { - name := "copy-" + encodeWorkspace(origin) - if len(name) > 255 { - digest := sha256.Sum256([]byte(filepath.Clean(origin))) - name = "copy-" + hex.EncodeToString(digest[:]) - } - return filepath.Join(dir, "v3", "projects", name) -} - // deviceName is what this machine is called in a sentence about it: the name a // person gave it, else its host name. func deviceName() string { return devname.Name(home.Dir()) } diff --git a/docs/changes/unreleased/2287-idle-chat-releases-to-relay.md b/docs/changes/unreleased/2287-idle-chat-releases-to-relay.md new file mode 100644 index 0000000000..464b8ceda3 --- /dev/null +++ b/docs/changes/unreleased/2287-idle-chat-releases-to-relay.md @@ -0,0 +1,138 @@ +--- +kind: changed +title: Optional idle handoff releases paired idle chats and opens free chats here +pr: 2287 +surface: [chat, engine, app, remote, docs] +invalidates: + - "An open idle chat previously kept renewing its computer's hold. That remains the default (`idle handoff` is off by default); with `idle handoff` (`CODEAF_IDLE_HANDOFF`) turned on in a paired set, an idle chat releases its hold after all saved state has reached the sync service." + - "Selecting a released remote chat previously required Continue here. That remains the default (`idle handoff` is off by default); with `idle handoff` turned on, paired opens acquire a released chat automatically on terminal Home and through agents.open (`can_open`) without displacing a live holder." + - "With `idle handoff` on (off by default), the first Home open of a local copy after restart could bypass free acquisition while the shared listing caught up. Ambiguous paired readings with `idle handoff` on now get a short ownership check, with local opening preserved on failure." + - "With `idle handoff` on (off by default), every paired local Home open briefly required a successful sync-service read. Answered display rows now open without that read, and an ambiguous reading waits at most two seconds before falling back locally." + - "With `idle handoff` on (off by default), a listed free local chat could still require a reachable sync service to open. Home now opens the saved local copy when the listing or free-open ownership read is unreachable; remote-only chats explain the failure." + - "A desktop take previously blamed another device for every ownership conflict. A newer hold by the same computer now says that computer picked the chat up again." + - "Automatic open with `idle handoff` on (off by default) briefly included lapsed holds. Lapsed holds retain Continue here whether `idle handoff` is off or on, including a holder last known to be working; an absent working advertisement cannot reliably prove idle." + - "Opening a chat could claim it was asking again before checking whether a reply was owed. Refused owed replies now keep their question and show only the refusal." + - "A released window's offline send could report that the chat was available without explaining why it could not send. It now names the unreachable sync service and keeps the words." + - "With `idle handoff` on (off by default), an idle release attempted while the sync service was down could leave a live chat unable to send after a short outage. A release that never reached the service is now undone, and the chat sends on its own hold." + - "After an outage in which this computer's own hold lapsed, the chat's window could refuse every Enter until restart. The first Enter now takes it back into a fresh writer at a new fence, with no card, when no other computer holds it." + - "A send refused by the engine cleared the message box. The refused words now go back into the box." +--- + +The new paired idle-release and automatic-open behaviours are controlled by the +`idle handoff` setting (`idle_handoff` in `settings.json`, `CODEAF_IDLE_HANDOFF` +in the environment) and are **off by default**. With `idle handoff` unset or off, +paired computers behave as before: idle chats keep renewing their hold, and a +released remote chat still requires `Continue here`. + +When `idle handoff` is turned on, running turns, queued or steering work, tasks, +jobs, deferred writes and unsent turns retain the hold. Task startup and parked +queued tasks retain it too. Saved settings and workspace edits made after the +last seal are captured and uploaded before release. Failed uploads retain the +hold. Idle preparation is canceled by new work and does not extend its ownership +deadline. With sync storage off (`StorageAllowed() == false`), idle release and +automatic open remain off even if `idle handoff` is enabled. The original window +stays open as a reader and reacquires before accepting new work. If another +computer won the hold, it refuses without recording the work and names the new +holder. Automatic open restores through the existing transfer path with +conditional, non-forcing acquisition. Older builds' live holds still require +Continue here. + +Pairing is learned from existing device roster reads and watch events. Sync off, +unpaired and single-computer homes add no idle-release network requests. When +`idle handoff` is on, the app engine exposes available shared chats (`can_open`) +through its open door; desktop clients that only preview them need to call that +door to use automatic opening. + +Ordinary messages and `!` shell commands in the original released window +reacquire in place, preserving local ignored install folders without a restore or +setup card. Opening a free chat refreshes an already-open stale viewer too. +Repeated moves keep an existing local root and use bounded, stable copy-folder +names on first arrival. When `idle handoff` is on and this window has no open +copy and Home has no reading for a local chat or still shows this computer's old +live hold, a paired home checks ownership for up to two seconds. A failed or +timed-out check opens the local chat as usual. Answered display rows need no +extra read, including a listed free chat or a chat already open in this window. +With `idle handoff` on, released paired chats restore the latest head through +conditional automatic acquisition at a new fence, including the first open after +a restart. Unpaired, sync-off, single-device and default (`idle handoff` off) +homes add no ownership read for local opening. + +A lapsed hold on another computer retains the Continue-here card on terminal +Home and the app's chat row. Only explicitly released holds when `idle handoff` +is on advertise `can_open` or use automatic acquisition. This build keeps +consent for all lapsed holds: the optional working advertisement cannot +distinguish idle from an older holder that never advertised its working state. A +confirmed continuation still acquires conditionally at a new fence; stale +publishes cannot move that head, and the former holder keeps its local turns +separately. + +Opening a conversation that owes no stopped reply never checks resume work +admission or prints the asking-again line. An owed reply refused by admission +shows the refusal once without claiming to resume, and retains the question. +A new send (plain message or `!` shell command) whose ownership read cannot +reach sync says `the sync service is not answering, so this chat cannot be taken +back yet; your words are still in the box` (or names the unreachable service). +It records nothing and calls no model or shell; the same words stay in the box +and can be retried at a new fence when the service returns. + +When `idle handoff` is on, an unreachable Home listing opens saved local chats +directly, including this computer's released chats. A free take's initial +directory read has the same two-second limit; an unreachable read falls back to +the local copy, or keeps the existing unreachable refusal for a remote-only row. +Successful transfers retain their normal move budget and local opening keeps +existing send admission. + +Terminal certification obtains a complete workspace scope while the warm engine +keeps sealing turns. Saved conversation choices retake the idle hold before +acknowledging a change, or retain the requested choice on a held-elsewhere +refusal. Ordinary held tasks keep their existing offline admission, and +home-state exchanges serialize behind competing flushes under their own context. + +Delayed idle-release observations cannot close the work gate after this window +has acquired a newer hold. Manual renames and other durable edits outside a turn +retake the released hold before writing. Idle comparison visits shared ancestors +once and retains the hold beyond its bounded complete-proof budgets. Closing +always stops the writer even when its budget expires, and model RPCs preserve +the factory's existing lent-view restriction. + +Durable edits read the published drive without waiting on installation or +catch-up's mutex. Taking back an idle-released hold has a separate 15-second +write budget; ordinary held admission keeps its 750 ms read. A lost acquire reply +stays retryable and keeps the draft. A newer own-device fence cannot identify the +window that acquired it, so an uncertain window does not adopt or renew it and +never offers itself a Continue-here card. If nobody adopted the hold, retrying +after its ordinary lease lapses takes a new hold through the existing conditional +acquire. + +Existing ownership expiry and consent rules still hold: an expired hold or one +released outside its window admits nothing until continued, reopening a chat +takes a new fence, and takes require the exact observed holder and fence. Only +a window that released its own hold retakes on new work, by conditionally +acquiring a new fence with the saved head unchanged; a heartbeat at the +released fence never revives work permission. No directory protocol or +replicated lease record changes are included. + +A desktop take that loses to the same computer reclaiming an idle release now +says that computer picked the chat up again, retaining the existing conflict +code and holder-changed detail. A different holder keeps the existing refusal; +neither refusal takes the chat. + +A lapsed or outside-released hold on this computer never resumes in place when +idle: its fence stops renewal beats and new work once the lapse is observed, and +the next Enter (including after an earlier displaced turn was saved to a kept +branch) makes a conditional take on the original chat, expecting the holder and +fence it observed, into a fresh writer that sends the kept words while the kept +branch remains a separate copy. Unpublished turns carry over or branch exactly +as an ordinary take does. A window that released its own hold still reacquires +in place. Another computer's hold, or any newer fence, keeps the Continue-here +card. + +A turn that is already running when its own lease is observed to have lapsed +attempts a conditional in-place reacquire at a new fence when no other computer +holds the chat; on success it clears the outage notice and continues on the +original chat without creating a copy branch, and never renews a lapsed lease by +publishing. When another computer has taken the chat, running calls finish into +local history and go to a kept branch; an upload never overwrites a newer holder +and never reopens new work. Enter after recovery checks again even when Home +still shows the outage, or a failed move to another chat left this one marked, +and the box stays intact until the send succeeds. diff --git a/internal/appwire/chats.go b/internal/appwire/chats.go index 6f92d8deb1..6246cde460 100644 --- a/internal/appwire/chats.go +++ b/internal/appwire/chats.go @@ -27,6 +27,7 @@ type Seen struct { Head string `json:"head,omitempty"` } type ChatRow struct { + CanOpen bool `json:"can_open,omitempty"` Cell string `json:"cell"` Title string `json:"title"` LocalAgent string `json:"local_agent,omitempty"` diff --git a/internal/cellstore/idle_r8_cost_test.go b/internal/cellstore/idle_r8_cost_test.go new file mode 100644 index 0000000000..c39a61a0b8 --- /dev/null +++ b/internal/cellstore/idle_r8_cost_test.go @@ -0,0 +1,136 @@ +package cellstore + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "testing" + "time" +) + +func TestIdleRelease_R8_RepeatedPathRetainsItsFirstFingerprint(t *testing.T) { + dir := t.TempDir() + s := &SavedState{files: map[string]os.FileInfo{}, members: map[string]string{}} + if err := s.remember(t.Context(), dir); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "new"), []byte("new state"), 0600); err != nil { + t.Fatal(err) + } + stamp := time.Unix(100, 0) + if err := os.Chtimes(dir, stamp, stamp); err != nil { + t.Fatal(err) + } + if err := s.remember(t.Context(), dir); err != nil { + t.Fatal(err) + } + if s.Unchanged(t.Context()) { + t.Fatal("a repeated ancestor replaced the first fingerprint and hid an edit") + } +} + +func TestIdleRelease_R8_WideWorkspaceAndReceiptsAllocateLinearly(t *testing.T) { + for _, n := range []int{100, 500, 5000} { + t.Run(fmt.Sprint(n), func(t *testing.T) { + e, c := realEngine(t), newCell(t) + for _, dir := range []string{filepath.Join(c.Root, "src"), filepath.Join(c.Root, ReceiptsDir)} { + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + for i := 0; i < n; i++ { + if err := os.WriteFile(filepath.Join(dir, fmt.Sprintf("file-%06d", i)), []byte("plain content"), 0600); err != nil { + t.Fatal(err) + } + } + } + if _, err := e.Seal(t.Context(), c, TurnInfo{}); err != nil { + t.Fatal(err) + } + if err := Await(t.Context(), c); err != nil { + t.Fatal(err) + } + runtime.GC() + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + proof, err := e.SavedState(t.Context(), c) + runtime.ReadMemStats(&after) + if err != nil { + t.Fatal(err) + } + if proof == nil || len(proof.Changes) != 0 { + t.Fatalf("proof=%+v", proof) + } + // Each directory entry and candidate is visited once, including + // the receipt history. Shared ancestors must not multiply work. + if len(proof.files) > 2*n+100 || proof.entries > 2*n+100 { + t.Fatalf("nonlinear proof work: paths=%d entries=%d", len(proof.files), proof.entries) + } + allocated := after.TotalAlloc - before.TotalAlloc + limit := uint64(6<<20) + uint64(n)*32_000 + t.Logf("files=%d receipts=%d allocated_bytes=%d limit=%d", n, n, allocated, limit) + if allocated > limit { + t.Fatalf("certification allocates %d bytes; linear allowance is %d", allocated, limit) + } + }) + } +} + +func TestIdleRelease_R8_ProofBudgetsRefuseIncompleteCertification(t *testing.T) { + t.Run("unique paths", func(t *testing.T) { + s := &SavedState{files: map[string]os.FileInfo{}, members: map[string]string{}} + for i := 0; i < savedStatePaths; i++ { + s.files[fmt.Sprint(i)] = nil + } + if err := s.remember(t.Context(), filepath.Join(t.TempDir(), "absent")); !errors.Is(err, ErrSavedStateTooLarge) { + t.Fatalf("budget error=%v", err) + } + }) + t.Run("directory entries", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "entry"), nil, 0600); err != nil { + t.Fatal(err) + } + s := &SavedState{files: map[string]os.FileInfo{}, members: map[string]string{}, entries: savedStateEntries} + if err := s.remember(t.Context(), dir); !errors.Is(err, ErrSavedStateTooLarge) { + t.Fatalf("budget error=%v", err) + } + }) + t.Run("chat bytes", func(t *testing.T) { + file := filepath.Join(t.TempDir(), "journal") + if err := os.WriteFile(file, []byte("whole journal"), 0600); err != nil { + t.Fatal(err) + } + remaining := int64(1) + if _, err := savedFile(t.Context(), file, &remaining); !errors.Is(err, ErrSavedStateTooLarge) { + t.Fatalf("budget error=%v", err) + } + }) + t.Run("native reply", func(t *testing.T) { + b := &savedStateReply{} + if _, err := b.Write(bytes.Repeat([]byte("x"), savedStateReplyBytes+1)); !errors.Is(err, ErrSavedStateTooLarge) || b.buffer.Len() != 0 { + t.Fatalf("oversized reply retained: bytes=%d err=%v", b.buffer.Len(), err) + } + run := savedStateRunner(func(context.Context, string, []string, ...string) ([]byte, error) { + return bytes.Repeat([]byte("x"), savedStateReplyBytes+1), nil + }) + if _, err := run(t.Context(), "", nil, "unused"); !errors.Is(err, ErrSavedStateTooLarge) { + t.Fatalf("runner budget error=%v", err) + } + }) +} + +func TestIdleRelease_R8_NativeReplyStreamCannotBypassBudget(t *testing.T) { + reply := &savedStateReply{} + // os/exec copies a pipe into this writer with io.Copy. Exercise that door, + // including io.ReaderFrom optimization, rather than only direct Write calls. + stream := io.LimitReader(bytes.NewReader(bytes.Repeat([]byte("x"), savedStateReplyBytes+1)), savedStateReplyBytes+1) + _, err := io.Copy(reply, stream) + if !errors.Is(err, ErrSavedStateTooLarge) || reply.buffer.Len() > savedStateReplyBytes { + t.Fatalf("stream bytes=%d error=%v", reply.buffer.Len(), err) + } +} diff --git a/internal/cellstore/idle_release_test.go b/internal/cellstore/idle_release_test.go new file mode 100644 index 0000000000..d18f096e60 --- /dev/null +++ b/internal/cellstore/idle_release_test.go @@ -0,0 +1,41 @@ +package cellstore + +import ( + "testing" + "time" +) + +func TestIdleRelease_C2_TailWritesAndSessionActivityRetainHold(t *testing.T) { + entered, finish := make(chan struct{}), make(chan struct{}) + w := &SealWatch{Quiet: time.Millisecond, OnTail: func() { close(entered); <-finish }} + w.Waiting() + if w.IdleReady() { + t.Fatal("a watch without its session certified idle") + } + w.SetIdleCheck(func() bool { return true }) + if !w.IdleReady() { + t.Fatal("a settled session was not idle") + } + w.TurnStarted() + if w.IdleReady() { + t.Fatal("a running turn certified idle") + } + w.TurnEnded() + w.Wrote() + if w.IdleReady() { + t.Fatal("an unsaved tail certified idle") + } + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("tail did not start") + } + if w.IdleReady() { + t.Fatal("an in-flight tail seal certified idle") + } + close(finish) + w.SetIdleCheck(func() bool { return false }) + if w.IdleReady() { + t.Fatal("a running task or job certified idle") + } +} diff --git a/internal/cellstore/savedstate.go b/internal/cellstore/savedstate.go new file mode 100644 index 0000000000..a97ce3e537 --- /dev/null +++ b/internal/cellstore/savedstate.go @@ -0,0 +1,370 @@ +package cellstore + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path" + "path/filepath" + "sort" + "strings" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +// ErrSavedStateChanged is an optimistic comparison that lost to a local write. +// It retains ownership and retries at the quiet cadence rather than backing +// off like a failed relay or engine request. +var ErrSavedStateChanged = errors.New("save check: files changed during comparison") + +// Idle certification retains the hold when any proof budget is exceeded. +// These caps apply only to the new idle proof, never to ordinary saves. +const ( + savedStatePaths = 20_000 + savedStateEntries = 50_000 + savedStateReplyBytes = 8 << 20 + savedStateContentBytes = 64 << 20 +) + +// ErrSavedStateTooLarge refuses partial idle certification and retains the hold. +var ErrSavedStateTooLarge = errors.New("save check: idle comparison budget exceeded") + +// SavedState certifies the saved head against current workspace and chat files. +// The native plan supplies the transfer scope, including metadata under .cell. +// File identities and directory membership fence later edits without another +// capture or content read at the final ownership boundary. +type SavedState struct { + Head string + Changes []string + files map[string]fs.FileInfo + members map[string]string + entries int +} + +// SavedState reads and compares outside admission. Extra files are the profile +// carriers that accompany the ordinary snapshot through the vault. +func (e Engine) SavedState(ctx context.Context, c cell.Cell, extra ...string) (*SavedState, error) { + head, err := Head(c) + if err != nil || head == nil { + return nil, err + } + if err := e.composeExtra(c); err != nil { + return nil, err + } + // The warm daemon can answer with its incremental metadata scope even + // without change hints. Certification needs a fresh complete workspace + // walk; the standalone planner supplies that, while seals keep the daemon. + scope := e + if scope.Transport == nil { + scope.Transport = Spawn{Binary: e.Binary, Run: savedStateRunner(e.Run)} + } + raw, err := scope.do(ctx, c, e.cellDir(c), capturePlanOp{}) + if err != nil { + return nil, err + } + if len(raw) > savedStateReplyBytes { + return nil, ErrSavedStateTooLarge + } + var candidates capturePlan + if err := json.Unmarshal(raw, &candidates); err != nil || candidates.Paths == nil || !candidates.Full { + return nil, fmt.Errorf("save check: engine did not return a complete file scope") + } + if len(candidates.Paths) > savedStatePaths { + return nil, ErrSavedStateTooLarge + } + s := &SavedState{Head: head.Turn.ID, files: map[string]fs.FileInfo{}, members: map[string]string{}} + tree, state := e.tree(c), stateDir(c) + for _, physical := range append(candidates.Paths, tree, state) { + if !filepath.IsAbs(physical) { + physical = filepath.Join(tree, physical) + } + for { + // The first fingerprint wins; shared ancestors need no more work. + if _, seen := s.files[physical]; seen { + break + } + if err := s.remember(ctx, physical); err != nil { + return nil, err + } + if physical == tree || physical == state { + break + } + next := filepath.Dir(physical) + if next == physical { + return nil, fmt.Errorf("save check: candidate is outside the saved tree") + } + physical = next + } + } + for _, physical := range extra { + if err := s.remember(ctx, physical); err != nil { + return nil, err + } + } + comparison := e + comparison.Run = savedStateRunner(e.Run) + raw, err = comparison.plan(ctx, c, head.Turn.ID) + if err != nil { + return nil, err + } + if len(raw) > savedStateReplyBytes { + return nil, ErrSavedStateTooLarge + } + var plan struct{ Changes []struct{ Path string } } + if err := json.Unmarshal(raw, &plan); err != nil { + return nil, err + } + for _, change := range plan.Changes { + name := path.Clean(change.Path) + if releaseBookkeeping(name) { + continue + } + s.Changes = append(s.Changes, name) + } + stateChanges, err := e.savedChatChanges(ctx, c, head.Turn.ID, candidates.Paths) + if err != nil { + return nil, err + } + s.Changes = append(s.Changes, stateChanges...) + if changed := s.changed(ctx); changed != "" { + return nil, fmt.Errorf("%w: %s", ErrSavedStateChanged, changed) + } + return s, nil +} + +func releaseBookkeeping(name string) bool { + return name == cell.StateDir || name == TurnsPath || name == publishedPath || name == adoptedPath || name == cell.CopyOfferedPath || name == cell.CopyReadyPath || name == ReceiptsDir || strings.HasPrefix(name, ReceiptsDir+"/") +} + +// Native rewind plans describe workspace edits. Compare the composed chat +// truth explicitly too: a metadata-only edit need not appear in that plan. +func (e Engine) savedChatChanges(ctx context.Context, c cell.Cell, head string, candidates []string) ([]string, error) { + scratch, err := os.MkdirTemp("", "codeaf-saved-state-") + if err != nil { + return nil, err + } + defer os.RemoveAll(scratch) + target := e.target(c) + target.Tree, target.CellDir, target.Timeline = scratch, "", filepath.Base(scratch) + transport := Spawn{Binary: e.Binary, Run: savedStateRunner(e.Run)} + if _, err := transport.Do(ctx, target, indexScratchOp{}); err != nil { + return nil, err + } + defer func() { _, _ = transport.Do(ctx, target, indexScratchOp{forget: true}) }() + if _, err := transport.Do(ctx, target, restoreOp{Snapshot: head, Paths: []string{cell.StateDir}}); err != nil { + return nil, err + } + paths := map[string]bool{} + for _, physical := range candidates { + rel, err := filepath.Rel(stateDir(c), physical) + if err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + paths[filepath.ToSlash(rel)] = true + } + } + base := filepath.Join(scratch, cell.StateDir) + if err := filepath.WalkDir(base, func(physical string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(base, physical) + paths[filepath.ToSlash(rel)] = true + if len(paths) > savedStatePaths { + return ErrSavedStateTooLarge + } + return ctx.Err() + }); err != nil { + return nil, err + } + var changes []string + remaining := int64(savedStateContentBytes) + for rel := range paths { + name := path.Join(cell.StateDir, rel) + if releaseBookkeeping(name) { + continue + } + current, err := savedFile(ctx, filepath.Join(stateDir(c), filepath.FromSlash(rel)), &remaining) + if err != nil { + return nil, err + } + saved, err := savedFile(ctx, filepath.Join(base, filepath.FromSlash(rel)), &remaining) + if err != nil { + return nil, err + } + if current != saved { + changes = append(changes, name) + } + } + return changes, nil +} + +func savedFile(ctx context.Context, physical string, remaining *int64) (string, error) { + info, err := os.Lstat(physical) + if os.IsNotExist(err) { + return "", nil + } + if err != nil { + return "", err + } + if info.IsDir() { + return "directory", nil + } + if info.Mode()&os.ModeSymlink != 0 { + target, err := os.Readlink(physical) + return "link:" + target, err + } + if info.Size() > *remaining { + return "", ErrSavedStateTooLarge + } + f, err := os.Open(physical) + if err != nil { + return "", err + } + defer f.Close() + hash := sha256.New() + buf := make([]byte, 64<<10) + for { + if err := ctx.Err(); err != nil { + return "", err + } + n, err := f.Read(buf) + *remaining -= int64(n) + if *remaining < 0 { + return "", ErrSavedStateTooLarge + } + _, _ = hash.Write(buf[:n]) + if err == io.EOF { + return fmt.Sprintf("%o:%x", info.Mode()&0111, hash.Sum(nil)), nil + } + if err != nil { + return "", err + } + } +} + +func (s *SavedState) remember(ctx context.Context, physical string) error { + if err := ctx.Err(); err != nil { + return err + } + if _, seen := s.files[physical]; seen { + return nil + } + if len(s.files) >= savedStatePaths { + return ErrSavedStateTooLarge + } + info, err := os.Lstat(physical) + if err != nil && !os.IsNotExist(err) { + return err + } + s.files[physical] = info + if info != nil && info.IsDir() { + names, count, err := directoryNames(physical, savedStateEntries-s.entries) + if err != nil { + return err + } + s.members[physical] = names + s.entries += count + } + return nil +} + +func directoryNames(physical string, remaining int) (string, int, error) { + dir, err := os.Open(physical) + if err != nil { + return "", 0, err + } + defer dir.Close() + entries, err := dir.ReadDir(remaining + 1) + if err != nil && err != io.EOF { + return "", 0, err + } + if len(entries) > remaining { + return "", 0, ErrSavedStateTooLarge + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + var names strings.Builder + for _, entry := range entries { + fmt.Fprintf(&names, "%s\x00%d\x00", entry.Name(), entry.Type()) + } + return names.String(), len(entries), nil +} + +// Unchanged is a bounded metadata recheck; it never captures, reads file +// contents or contacts the engine/relay. A changed parent catches new names. +func (s *SavedState) Unchanged(ctx context.Context) bool { + return s.changed(ctx) == "" +} + +func (s *SavedState) changed(ctx context.Context) string { + for physical, before := range s.files { + if ctx.Err() != nil { + return ctx.Err().Error() + } + now, err := os.Lstat(physical) + if before == nil && os.IsNotExist(err) { + continue + } + if err != nil || before == nil || before.Mode() != now.Mode() || !os.SameFile(before, now) { + return physical + } + if before.Size() != now.Size() || !before.ModTime().Equal(now.ModTime()) { + // Native comparisons and atomic writers can create then remove + // scratch names. Directory times alone do not change saved state. + if !before.IsDir() { + return physical + } + if names, _, err := directoryNames(physical, savedStateEntries); err != nil || names != s.members[physical] { + return physical + } + } + } + if ctx.Err() != nil { + return ctx.Err().Error() + } + return "" +} + +// The complete planner runs outside the daemon, with a bounded reply. The +// ordinary engine runner and its error handling remain unchanged. +func savedStateRunner(run Runner) Runner { + return func(ctx context.Context, dir string, environ []string, argv ...string) ([]byte, error) { + if run != nil { + out, err := run(ctx, dir, environ, argv...) + if len(out) > savedStateReplyBytes { + return nil, ErrSavedStateTooLarge + } + return out, err + } + cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) //codeaf:plumbing the idle proof drives the embedded engine + cmd.Dir, cmd.Env = dir, environ + out, errb := &savedStateReply{}, &savedStateReply{} + cmd.Stdout, cmd.Stderr = out, errb + err := cmd.Run() + if out.overflow || errb.overflow { + return nil, ErrSavedStateTooLarge + } + if err != nil { + return nil, fmt.Errorf("%s: %w: %s", filepath.Base(argv[0]), err, strings.TrimSpace(errb.buffer.String())) + } + return out.buffer.Bytes(), nil + } +} + +type savedStateReply struct { + buffer bytes.Buffer + overflow bool +} + +func (b *savedStateReply) Write(p []byte) (int, error) { + if len(p) > savedStateReplyBytes-b.buffer.Len() { + b.overflow = true + return 0, ErrSavedStateTooLarge + } + return b.buffer.Write(p) +} diff --git a/internal/cellstore/sealwatch.go b/internal/cellstore/sealwatch.go index f538f347ce..286778564a 100644 --- a/internal/cellstore/sealwatch.go +++ b/internal/cellstore/sealwatch.go @@ -47,6 +47,8 @@ type SealWatch struct { // OnTurnEnd, when set, hears each time the agent finishes a turn. The door // sets it to the sync side's idle call. OnTurnEnd func() + // OnMetadataWrite is installed only by the paired idle ownership door. + OnMetadataWrite func() // OnTurnStart, when set, hears each time a turn begins. The door sets it to // the sync side's working call, so other computers can say a turn runs here. OnTurnStart func() @@ -62,6 +64,8 @@ type SealWatch struct { idle bool started bool tail *time.Timer + tailRunning bool + idleCheck func() bool cause string said []string changes chan struct{} @@ -287,18 +291,50 @@ func (w *SealWatch) stopTail() { } func (w *SealWatch) sealTail() { - owed := func() bool { - w.mu.Lock() - defer w.mu.Unlock() - owed := w.idle && w.tail != nil - w.tail = nil - return owed - }() + w.mu.Lock() + owed := w.idle && w.tail != nil + w.tail = nil + w.tailRunning = owed + w.mu.Unlock() if owed { + defer func() { + w.mu.Lock() + w.tailRunning = false + w.mu.Unlock() + }() w.OnTail() } } +// MetadataWrote leaves ordinary metadata saves on their original path. An +// idle ownership door can schedule certification after a retaken setting save. +func (w *SealWatch) MetadataWrote() { + if w.OnMetadataWrite != nil { + w.OnMetadataWrite() + } +} + +// SetIdleCheck binds this watch to its session after construction. +func (w *SealWatch) SetIdleCheck(check func() bool) { + w.mu.Lock() + w.idleCheck = check + w.mu.Unlock() +} + +// IdleReady includes trailing seals and session work that outlives a reply. +// A watch without a session cannot certify that releasing ownership is safe. +func (w *SealWatch) IdleReady() bool { + w.mu.Lock() + ready, check := w.idle && w.tail == nil && !w.tailRunning && !w.failing, w.idleCheck + w.mu.Unlock() + if !ready || check == nil || !check() { + return false + } + w.mu.Lock() + defer w.mu.Unlock() + return w.idle && w.tail == nil && !w.tailRunning && !w.failing +} + // TurnEnded is the session's word that the agent finished a turn. It only // forwards, so a watch without a listener costs nothing. func (w *SealWatch) TurnEnded() { diff --git a/internal/cellsync/batch.go b/internal/cellsync/batch.go index 9bc1ea4268..6f370aeb4f 100644 --- a/internal/cellsync/batch.go +++ b/internal/cellsync/batch.go @@ -69,6 +69,10 @@ type Batcher struct { working bool // a turn is running here now toldWork bool // the working flag the directory last heard from a beat closing bool // The closing caller owns subsequent publishes and release. + released bool // A retired writer cannot send renewal beats or admit new work; sealed turns still drain. + // acquireUncertain keeps this released window read-only until a fresh + // conditional acquisition returns. Device identity cannot prove a window. + acquireUncertain bool refusedSince time.Time // when the present run of refusals began; zero while the relay is answering told string // the kind of refusal the person has heard of in this run, so each is said once @@ -77,12 +81,14 @@ type Batcher struct { // renewalEnded prevents a displaced or removed writer from claiming a hold. renewalEnded bool - work wakeup // tells the flush loop a turn was noted, so an upload can start at once - idle wakeup // asks the flush loop to upload now: the agent stopped to wait for the person - freed wakeup // ends a halt: something the relay refused for has changed - turns wakeup // a turn started or ended, so the directory hears it now and not at the next tick + work wakeup // tells the flush loop a turn was noted, so an upload can start at once + idle wakeup // asks the flush loop to upload now: the agent stopped to wait for the person + freed wakeup // ends a halt: something the relay refused for has changed + leaseChanged wakeup // wakes the heartbeat loop to drop a released socket hold + turns wakeup // a turn started or ended, so the directory hears it now and not at the next tick flushMu sync.Mutex // one flush, branch or close at a time + renewMu sync.Mutex // a heartbeat already in flight finishes before idle release backoff time.Duration failing bool // the last flush failed, so Idle must wait out the backoff // lastFlushErr retains the latest publish or branch result when Close waits behind it. @@ -276,13 +282,14 @@ func (b *Batcher) beats(ctx context.Context) { var held holding defer held.release() for { - held.follow(b.Liveness, b.view) + held.follow(b.Liveness, b.renewalView) select { case <-ticks: b.tick(ctx, &held) handled <- struct{}{} case <-held.changes(): b.socketMoved(ctx, &held) + case <-b.leaseChanged.ch(): case <-b.turns.ch(): if !b.toldWorking() { b.beat(ctx) @@ -322,7 +329,7 @@ func (b *Batcher) tick(ctx context.Context, held *holding) { if b.takePublished() { return } - if !held.covers(b.view, b.toldPending) || !b.toldWorking() { + if !held.covers(b.renewalView, b.toldPending) || !b.toldWorking() { b.beat(ctx) } } @@ -468,8 +475,11 @@ func (b *Batcher) Close(ctx context.Context) error { // release gives the lease up so another device can take over at once. A lease // already lost has nothing to release. func (b *Batcher) release(ctx context.Context) error { + b.mu.Lock() + finished := b.released && b.renewalEnded + b.mu.Unlock() d, _, stale := b.view() - if d.Fence == 0 || stale { + if d.Fence == 0 || stale || finished { return nil } for { @@ -613,11 +623,14 @@ func (b *Batcher) heartbeat(ctx context.Context, d Driving, pending uint32) erro // beat renews the lease and reports how many turns are not durable yet. func (b *Batcher) beat(ctx context.Context) { + b.renewMu.Lock() if b.isClosing() { + b.renewMu.Unlock() return } - d, pending, stale := b.view() + d, pending, stale := b.renewalView() if d.Fence == 0 || stale { + b.renewMu.Unlock() return } up := b.beatUpload(pending) @@ -627,6 +640,7 @@ func (b *Batcher) beat(ctx context.Context) { working := b.working || b.toldWork && len(b.noted) > 0 b.mu.Unlock() _, err := b.Publisher.Dir.Heartbeat(ctx, d.ID(), directory.Beat{Fence: d.Fence, Pending: pending, UploadSent: uint64(up.sent), UploadTotal: uint64(up.total), Working: working}) + b.renewMu.Unlock() b.renewalResult(d, err) switch { case err == nil: @@ -775,6 +789,14 @@ func (b *Batcher) view() (Driving, uint32, bool) { return *b.Driving, uint32(len(b.noted)), b.stale } +// renewalView omits an idle reader's hold without calling it displaced. A +// failed release reply can be retried under the same fence without renewing. +func (b *Batcher) renewalView() (Driving, uint32, bool) { + b.mu.Lock() + defer b.mu.Unlock() + return *b.Driving, uint32(len(b.noted)), b.stale || b.released +} + func (b *Batcher) adopt(d Driving) { b.mu.Lock() changed := b.Driving.ID() != d.ID() || b.Driving.Fence != d.Fence diff --git a/internal/cellsync/idle_r10_acquire_test.go b/internal/cellsync/idle_r10_acquire_test.go new file mode 100644 index 0000000000..4f4857be61 --- /dev/null +++ b/internal/cellsync/idle_r10_acquire_test.go @@ -0,0 +1,77 @@ +package cellsync + +import ( + "context" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" +) + +type lostIdleAcquire struct { + directory.Client + winner directory.Client + later bool +} + +func (c lostIdleAcquire) Acquire(ctx context.Context, id string, opts directory.AcquireOpts) (directory.CellView, error) { + if c.later { + v, err := c.Client.Acquire(ctx, id, opts) + if err != nil { + return v, err + } + opts.Expect = &directory.LeaseExpectation{Device: v.Cell.Lease.Device, Fence: v.Cell.Lease.Fence} + } + if _, err := c.winner.Acquire(ctx, id, opts); err != nil { + return directory.CellView{}, err + } + <-ctx.Done() + return directory.CellView{}, ctx.Err() +} + +func TestIdleRelease_R10_UncertainAcquireNeverAdoptsAnotherWindow(t *testing.T) { + for _, scenario := range []string{"same device", "other device", "same device later fence"} { + t.Run(scenario, func(t *testing.T) { + r := newRig(t) + r.publishFirst(map[string]string{"file": "saved"}) + b := r.batcher() + released, err := b.ReleaseIdle(t.Context(), r.drv.Head, func() error { return nil }) + if err != nil || !released { + t.Fatalf("release=%v error=%v", released, err) + } + seen := r.head(cellID) + winner := r.dir.For(devA) + if scenario == "other device" { + winner = r.dir.For(devB) + } + b.Publisher.Dir = lostIdleAcquire{Client: r.dir.For(devA), winner: winner, later: scenario == "same device later fence"} + ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) + defer cancel() + ok, err := b.Reacquire(ctx, directory.CellView{Now: seen.Lease.ReleasedAt, Cell: seen}) + if err == nil || ok { + t.Fatalf("unknown acquire=%v error=%v", ok, err) + } + view, err := winner.Cell(t.Context(), cellID) + if err != nil { + t.Fatal(err) + } + ok, err = b.Reacquire(t.Context(), view) + if err != nil || ok || b.Fence() == view.Cell.Lease.Fence { + t.Fatalf("adopted another window: resumed=%v error=%v local=%d remote=%+v", ok, err, b.Fence(), view.Cell.Lease) + } + if _, _, holding := b.RenewalStatus(); holding { + t.Fatal("uncertain writer claims renewal authority") + } + r.clock.Advance(directory.LeaseTTL + time.Millisecond) + view, err = winner.Cell(t.Context(), cellID) + if err != nil { + t.Fatal(err) + } + b.Publisher.Dir = r.dir.For(devA) + ok, err = b.Reacquire(t.Context(), view) + if err != nil || !ok || b.Fence() != view.Cell.Lease.Fence+1 || b.AcquireUncertain() { + t.Fatalf("lapsed retry stuck: resumed=%v error=%v local=%d remote=%+v", ok, err, b.Fence(), view.Cell.Lease) + } + }) + } +} diff --git a/internal/cellsync/idle_r8_failure_test.go b/internal/cellsync/idle_r8_failure_test.go new file mode 100644 index 0000000000..7730062b12 --- /dev/null +++ b/internal/cellsync/idle_r8_failure_test.go @@ -0,0 +1,101 @@ +package cellsync + +import ( + "context" + "errors" + "fmt" + "net" + "net/url" + "syscall" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" +) + +type r8ReleaseDirectory struct { + directory.Client + failure error + commit bool + calls int +} + +func (d *r8ReleaseDirectory) Release(ctx context.Context, id string, fence uint64) error { + d.calls++ + if d.failure != nil && !d.commit { + return d.failure + } + if err := d.Client.Release(ctx, id, fence); err != nil { + return err + } + return d.failure +} + +func TestIdleRelease_R8_RefusedReleaseKeepsHoldingSameFence(t *testing.T) { + r := newRig(t) + head := r.publishFirst(map[string]string{"journal": "saved"}) + b := r.batcher() + cause := &url.Error{Op: "Post", URL: "http://fixture.invalid/release", Err: &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED}} + dir := &r8ReleaseDirectory{Client: r.dirA, failure: fmt.Errorf("%w: %w", directory.ErrUnreachable, cause)} + b.Publisher.Dir = dir + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + // An idle release must preserve the dial error rather than hide it behind + // close's retry deadline, and must not sleep inside this ownership boundary. + b.Sleep = func(context.Context, time.Duration) error { return context.DeadlineExceeded } + released, err := b.ReleaseIdle(ctx, head, func() error { return nil }) + if released || !errors.Is(err, syscall.ECONNREFUSED) || dir.calls != 1 { + t.Fatalf("refused release=%v error=%v requests=%d", released, err, dir.calls) + } + if _, _, holding := b.RenewalStatus(); !holding || b.Fence() != 1 { + t.Fatalf("refused release retired fence %d, holding=%v", b.Fence(), holding) + } + dir.failure = nil + r.clock.Advance(20 * time.Second) + b.beat(t.Context()) + if got := r.head(cellID); r.dirA.beats.Load() != 1 || got.Lease.Fence != 1 || got.Lease.Expires != r.clock.Now().Add(directory.LeaseTTL).UnixMilli() { + t.Fatalf("next renewal did not renew fence 1: %+v beats=%d", got.Lease, r.dirA.beats.Load()) + } +} + +func TestIdleRelease_R8_UncertainReleaseNeverRenewsOrResumesOldFence(t *testing.T) { + for _, committed := range []bool{false, true} { + t.Run(fmt.Sprint(committed), func(t *testing.T) { + r := newRig(t) + head := r.publishFirst(map[string]string{"journal": "saved"}) + b := r.batcher() + dir := &r8ReleaseDirectory{Client: r.dirA, failure: context.DeadlineExceeded, commit: committed} + b.Publisher.Dir = dir + if released, err := b.ReleaseIdle(t.Context(), head, func() error { return nil }); released || !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("uncertain release=%v error=%v", released, err) + } + // Even an outside renewal at the same fence cannot undo uncertainty. + _, err := r.dir.For(devA).Heartbeat(t.Context(), cellID, directory.Beat{Fence: 1}) + if err != nil { + t.Fatal(err) + } + seen, err := dir.Cell(t.Context(), cellID) + if err != nil { + t.Fatal(err) + } + if b.ResumeHeld(t.Context(), seen) { + t.Fatal("ResumeHeld revived an uncertain release") + } + b.beat(t.Context()) + if _, _, holding := b.RenewalStatus(); holding || r.dirA.beats.Load() != 0 { + t.Fatal("uncertain release renewed its old fence") + } + dir.failure = &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED} + _, _ = b.ReleaseIdle(t.Context(), head, func() error { return nil }) + b.beat(t.Context()) + if _, _, holding := b.RenewalStatus(); holding || r.dirA.beats.Load() != 0 { + t.Fatal("a refused retry revived the earlier uncertain release") + } + dir.failure = nil + ok, err := b.Reacquire(t.Context(), seen) + if err != nil || !ok || b.Fence() != 2 { + t.Fatalf("uncertain release did not acquire fence 2: ok=%v error=%v fence=%d", ok, err, b.Fence()) + } + }) + } +} diff --git a/internal/cellsync/idle_r9_retirement_test.go b/internal/cellsync/idle_r9_retirement_test.go new file mode 100644 index 0000000000..bce9ecf450 --- /dev/null +++ b/internal/cellsync/idle_r9_retirement_test.go @@ -0,0 +1,114 @@ +package cellsync + +import ( + "errors" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/syncwords" +) + +func TestIdleRelease_R9_RetiredFenceDrainsSealsWithoutRenewalBeats(t *testing.T) { + for _, takeover := range []bool{false, true} { + t.Run(map[bool]string{false: "own expiry", true: "other computer acquired"}[takeover], func(t *testing.T) { + r := newRig(t) + r.publishFirst(map[string]string{"journal": "saved"}) + b := r.batcher() + r.clock.Advance(directory.LeaseTTL + time.Second) + if takeover { + r.takeOver() + } + b.RetireFence(cellID, 1) + before := r.head(cellID) + beats := r.dirA.beats.Load() + b.beat(t.Context()) + if after := r.head(cellID); r.dirA.beats.Load() != beats || after.Lease != before.Lease { + t.Fatal("a retired writer sent a revival beat") + } + if _, _, holding := b.RenewalStatus(); holding { + t.Fatal("retired writer claimed a live hold") + } + sealed := r.seal(map[string]string{"journal": "finished after retirement"}) + note(b, sealed) + err := b.drain(t.Context()) + if err != nil || b.Pending() != 0 { + t.Fatalf("retired writer stranded a sealed turn: error=%v pending=%d", err, b.Pending()) + } + after := r.head(cellID) + if takeover { + if b.Driving.ID() == cellID || r.head(b.Driving.ID()).Head != sealed || after.Head != before.Head || after.Lease != before.Lease { + t.Fatal("retired takeover lost its branch or changed the winner") + } + } else if after.Head != sealed || after.Lease.Fence != before.Lease.Fence || after.Lease.Expires <= before.Lease.Expires { + t.Fatal("late publish did not renew the unchanged expired fence") + } + b.beat(t.Context()) + if r.dirA.beats.Load() != beats { + t.Fatal("draining a late seal restored renewal beats") + } + if _, _, holding := b.RenewalStatus(); holding { + t.Fatal("draining a late seal restored the retired hold") + } + }) + } +} + +func TestIdleRelease_R9_RetiredCloseDrainsSealedWork(t *testing.T) { + for _, pending := range []bool{false, true} { + t.Run(map[bool]string{false: "none", true: "some"}[pending], func(t *testing.T) { + r := newRig(t) + r.publishFirst(map[string]string{"journal": "saved"}) + b := r.batcher() + b.RetireFence(cellID, 1) + before := r.head(cellID) + sealed := before.Head + if pending { + sealed = r.seal(map[string]string{"journal": "pending"}) + note(b, sealed) + } + err := b.Close(t.Context()) + if err != nil || b.Pending() != 0 { + t.Fatalf("retired close did not drain its sealed work: error=%v pending=%d", err, b.Pending()) + } + if after := r.head(cellID); after.Head != sealed || after.Lease.Fence != before.Lease.Fence { + t.Fatal("retired close lost its head or changed its fence") + } + }) + } +} + +func TestIdleRelease_R9_RetiredCloseRetainsSealsDuringOutage(t *testing.T) { + r := newRig(t) + r.publishFirst(map[string]string{"journal": "saved"}) + b := r.batcher() + b.RetireFence(cellID, 1) + note(b, r.seal(map[string]string{"journal": "pending"})) + r.store.set(directory.ErrUnreachable, nil) + if err := b.Close(t.Context()); !errors.Is(err, directory.ErrUnreachable) || !syncwords.UploadPending(err) || b.Pending() != 1 { + t.Fatalf("unreachable close must retain the pending seal: error=%v pending=%d", err, b.Pending()) + } + r.store.set(nil, nil) + if err := b.Close(t.Context()); err != nil || b.Pending() != 0 { + t.Fatalf("recovered close did not drain the pending seal: error=%v pending=%d", err, b.Pending()) + } +} + +func TestIdleRelease_R9_LateRetirementCannotCloseANewFence(t *testing.T) { + r := newRig(t) + r.publishFirst(map[string]string{"journal": "saved"}) + b := r.batcher() + b.RetireFence(cellID, 1) + seen, err := r.dirA.Cell(t.Context(), cellID) + if err != nil { + t.Fatal(err) + } + ok, err := b.Reacquire(t.Context(), seen) + if err != nil || !ok || b.Fence() != 2 { + t.Fatalf("conditional acquire=%v, %v; fence=%d", ok, err, b.Fence()) + } + b.RetireFence(cellID, 1) + if _, _, holding := b.RenewalStatus(); !holding { + t.Fatal("a delayed old-fence reading closed the new hold") + } +} diff --git a/internal/cellsync/idle_release_test.go b/internal/cellsync/idle_release_test.go new file mode 100644 index 0000000000..20fbb62e99 --- /dev/null +++ b/internal/cellsync/idle_release_test.go @@ -0,0 +1,107 @@ +package cellsync + +import ( + "errors" + "sync" + "testing" + + "github.com/Agent-Field/codeaf/internal/directory" +) + +func TestIdleRelease_C1_C3_ReleaseStopsRenewalAndReacquires(t *testing.T) { + r := newRig(t) + head := r.publishFirst(map[string]string{"journal": "saved answer"}) + b := r.batcher() + released, err := b.ReleaseIdle(t.Context(), head, func() error { return nil }) + if err != nil || !released { + t.Fatalf("C1 release: %v, %v", released, err) + } + b.beat(t.Context()) + v, err := r.dir.For(devA).Cell(t.Context(), cellID) + if err != nil || v.Cell.Lease.Expires != 0 || r.dirA.beats.Load() != 0 { + t.Fatalf("C1 released reader renewed: %+v, %v", v, err) + } + if _, _, held := b.RenewalStatus(); held { + t.Fatal("C1 released reader still reports a hold") + } + ok, err := b.Reacquire(t.Context(), v) + if err != nil || !ok { + t.Fatalf("C3 take-back: %v, %v", ok, err) + } + note(b, r.seal(map[string]string{"journal": "new work"})) + b.flush(t.Context()) + got := r.head(cellID) + if got.Lease.Fence != v.Cell.Lease.Fence+1 || got.Head != r.drv.Head || b.Pending() != 0 { + t.Fatalf("C3 work did not publish under the new fence: %+v", got) + } +} + +func TestIdleRelease_C2_C5_UnsentTurnsOrFailedVaultKeepHold(t *testing.T) { + for _, failure := range []string{"unsent turns", "older head", "vault upload"} { + t.Run(failure, func(t *testing.T) { + r := newRig(t) + head := r.publishFirst(map[string]string{"journal": "saved"}) + b := r.batcher() + before := r.head(cellID) + if failure == "unsent turns" { + note(b, r.seal(map[string]string{"journal": "not uploaded"})) + } + if failure == "older head" { + head = "older" + } + released, _ := b.ReleaseIdle(t.Context(), head, func() error { + if failure == "vault upload" { + return errors.New("vault upload failed") + } + return nil + }) + if released || r.head(cellID).Lease != before.Lease { + t.Fatal("C2/C5 incomplete transfer released the hold") + } + b.beat(t.Context()) + if r.dirA.beats.Load() != 1 { + t.Fatal("C2/C5 hold no longer renews") + } + }) + } +} + +func TestIdleRelease_C12_SendAndOpenHaveOneFencedWinner(t *testing.T) { + r := newRig(t) + head := r.publishFirst(map[string]string{"journal": "last answer"}) + b := r.batcher() + if released, err := b.ReleaseIdle(t.Context(), head, func() error { return nil }); err != nil || !released { + t.Fatalf("release: %v, %v", released, err) + } + seen, err := r.dir.For(devB).Cell(t.Context(), cellID) + if err != nil { + t.Fatal(err) + } + start := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(2) + var sent, opened bool + go func() { + defer wg.Done() + <-start + sent, _ = b.Reacquire(t.Context(), seen) + }() + go func() { + defer wg.Done() + <-start + _, err := r.dir.For(devB).Acquire(t.Context(), cellID, directory.AcquireOpts{ + RequireConditional: true, + Expect: &directory.LeaseExpectation{Device: seen.Cell.Lease.Device, Fence: seen.Cell.Lease.Fence}, + }) + opened = err == nil + }() + close(start) + wg.Wait() + if sent == opened { + t.Fatalf("C12 send won=%v, open won=%v", sent, opened) + } + got := r.head(cellID) + if got.Head != head || got.Lease.Fence != seen.Cell.Lease.Fence+1 { + t.Fatalf("C12 race changed turns or split the fence: %+v", got) + } +} diff --git a/internal/cellsync/ownership.go b/internal/cellsync/ownership.go index fc4984d3a9..393fa306fe 100644 --- a/internal/cellsync/ownership.go +++ b/internal/cellsync/ownership.go @@ -2,9 +2,12 @@ package cellsync import ( "context" + "errors" + "sync" "time" "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/syncwords" ) // Lease names the directory record and fence this batcher publishes under. @@ -15,20 +18,74 @@ func (b *Batcher) Lease() (id string, fence uint64) { return d.ID(), d.Fence } +// RetireFence stops renewal beats under an observed old hold, leaving sealed turns +// to publish or branch through the directory's ordinary fencing checks. +// It is safe inside ownership callbacks that already hold the publisher lock. +// Requests already in flight remain fenced by the directory; their replies +// cannot restore permission or change the expectation of the next acquisition. +// The caller owns public notices after its admission and receipt retirement. +func (b *Batcher) RetireFence(id string, fence uint64) { + changed := func() bool { + b.mu.Lock() + defer b.mu.Unlock() + if b.Driving.ID() != id || b.Driving.Fence != fence { + return false + } + changed := !b.released || !b.renewalEnded + b.released, b.renewalEnded = true, true + return changed + }() + if changed { + b.leaseChanged.fire() + } +} + // Reacquire resumes an unchanged saved chat after its lease became free. The // conditional acquisition cannot displace a winner that arrived after the read. // Serializing with publishes keeps an old flush from replacing the new fence. func (b *Batcher) Reacquire(ctx context.Context, seen directory.CellView) (bool, error) { - b.flushMu.Lock() - defer b.flushMu.Unlock() + idle := func() bool { b.mu.Lock(); defer b.mu.Unlock(); return b.released }() + if idle { + if err := lockOwnership(ctx, &b.flushMu); err != nil { + return false, err + } + defer b.flushMu.Unlock() + if err := lockOwnership(ctx, &b.renewMu); err != nil { + return false, err + } + defer b.renewMu.Unlock() + } else { + b.flushMu.Lock() + defer b.flushMu.Unlock() + } d, pending, _ := b.view() - if b.isClosing() || pending != 0 || d.Head != seen.Cell.Head || seen.Cell.Lease.Expires > seen.Now { + // A completed idle release never revives its old fence even if an outside + // heartbeat lifted its expiry; Acquire advances this device to a new fence. + sameReleasedFence := idle && seen.Cell.Lease.Fence == d.Fence + if b.isClosing() || pending != 0 || d.Head != seen.Cell.Head || seen.Cell.Lease.Expires > seen.Now && !sameReleasedFence { return false, nil } + wasUncertain := false + if idle { + wasUncertain = func() bool { + b.mu.Lock() + defer b.mu.Unlock() + before := b.acquireUncertain + b.acquireUncertain = true + return before + }() + } v, err := b.Publisher.Dir.Acquire(ctx, d.ID(), directory.AcquireOpts{ RequireConditional: true, Expect: &directory.LeaseExpectation{Device: seen.Cell.Lease.Device, Fence: seen.Cell.Lease.Fence}, }) + if idle && (err == nil && v.Cell.Head != d.Head || errors.Is(err, directory.ErrConditionalTake) || !wasUncertain && (errors.Is(err, directory.ErrLeaseHeld) || errors.Is(err, directory.ErrFenceStale))) { + func() { + b.mu.Lock() + defer b.mu.Unlock() + b.acquireUncertain = false + }() + } if err != nil { return false, err } @@ -38,9 +95,156 @@ func (b *Batcher) Reacquire(ctx context.Context, seen directory.CellView) (bool, d.Fence, d.Frames, d.Size = v.Cell.Lease.Fence, v.Cell.Frames, v.Cell.Size b.mu.Lock() *b.Driving = d - b.stale, b.renewalEnded = false, false + b.stale, b.released, b.renewalEnded = false, false, false + // Acquiring a fresh fence clears the lease's pending, upload and working + // state on the directory, so the next beat reports any change from zero. + b.beatSent, b.beatUp, b.toldWork = 0, uploaded{}, false + if idle { + // Ownership readers must see the confirmed fence before uncertainty + // clears, so an in-flight reading cannot revive an older fence. + b.acquireUncertain = false + } b.renewalSince = time.Time{} b.mu.Unlock() b.renewalChanged() + if idle { + b.leaseChanged.fire() + } return true, nil } + +// ReacquireWorking conditionally reacquires an in-flight turn's own lapsed +// lease in place (Fence+1) when no other computer has taken the chat and the +// published head on the relay still matches this batcher's base head. +func (b *Batcher) ReacquireWorking(ctx context.Context, seen directory.CellView) (bool, error) { + if err := lockOwnership(ctx, &b.flushMu); err != nil { + return false, err + } + defer b.flushMu.Unlock() + if err := lockOwnership(ctx, &b.renewMu); err != nil { + return false, err + } + defer b.renewMu.Unlock() + d, pending, stale := b.view() + if b.isClosing() || stale || d.Fence == 0 || seen.Cell.Lease.Fence != d.Fence || d.Head != seen.Cell.Head || seen.Cell.Lease.Expires > seen.Now { + return false, nil + } + v, err := b.Publisher.Dir.Acquire(ctx, d.ID(), directory.AcquireOpts{ + RequireConditional: true, + Expect: &directory.LeaseExpectation{Device: seen.Cell.Lease.Device, Fence: seen.Cell.Lease.Fence}, + }) + if err != nil { + return false, err + } + if v.Cell.Head != d.Head { + return false, b.Publisher.Dir.Release(ctx, d.ID(), v.Cell.Lease.Fence) + } + d.Fence, d.Frames, d.Size = v.Cell.Lease.Fence, v.Cell.Frames, v.Cell.Size + b.mu.Lock() + *b.Driving = d + b.stale, b.released, b.renewalEnded = false, false, false + b.acquireUncertain = false + b.beatSent, b.beatUp, b.toldWork = 0, uploaded{}, false + b.renewalSince = time.Time{} + b.mu.Unlock() + b.recovered(false) + b.renewalChanged() + b.leaseChanged.fire() + b.turns.fire() + if pending > 0 { + b.work.fire() + } + return true, nil +} + +// ReleaseIdle releases only a fully published head. The caller excludes new +// work and seals; all uploads have already finished. Contended publisher or +// heartbeat locks retain the hold for a later attempt instead of admission. +func (b *Batcher) ReleaseIdle(ctx context.Context, head string, beforeRelease func() error) (bool, error) { + if !b.flushMu.TryLock() { + return false, nil + } + defer b.flushMu.Unlock() + d, pending, stale := b.view() + if b.isClosing() || stale || pending != 0 || d.Fence == 0 || d.Head != head { + return false, nil + } + if !b.renewMu.TryLock() { + return false, nil + } + defer b.renewMu.Unlock() + if err := beforeRelease(); err != nil { + return false, err + } + wasReleased := func() bool { + b.mu.Lock() + defer b.mu.Unlock() + before := b.released + b.released = true + return before + }() + b.leaseChanged.fire() + // Idle retries belong to the caller's backoff. Retrying inside close's + // release loop could hide an unsent dial failure behind a later timeout. + if err := b.Publisher.Dir.Release(ctx, d.ID(), d.Fence); err != nil && !errors.Is(err, directory.ErrFenceStale) { + if !wasReleased && syncwords.RequestNotSent(err) { + func() { b.mu.Lock(); defer b.mu.Unlock(); b.released = false }() + b.leaseChanged.fire() + } + return false, syncwords.UploadFailure(err) + } + b.endRenewals() + return true, nil +} + +// ResumeHeld confirms a retained fence is live without an acquisition request. +// It cannot resume a possibly committed release, a changed head or a later writer. +func (b *Batcher) ResumeHeld(ctx context.Context, seen directory.CellView) bool { + if lockOwnership(ctx, &b.flushMu) != nil { + return false + } + defer b.flushMu.Unlock() + resumed := func() bool { + b.mu.Lock() + defer b.mu.Unlock() + d := b.Driving + if b.closing || b.stale || b.released || b.renewalEnded || len(b.noted) != 0 || d.Fence != seen.Cell.Lease.Fence || d.Head != seen.Cell.Head || seen.Cell.Lease.Expires <= seen.Now { + return false + } + b.released, b.renewalEnded = false, false + b.renewalSince = time.Time{} + return true + }() + if !resumed { + return false + } + b.leaseChanged.fire() + b.renewalChanged() + return true +} + +func lockOwnership(ctx context.Context, mu *sync.Mutex) error { + for { + if err := ctx.Err(); err != nil { + return err + } + if mu.TryLock() { + return nil + } + timer := time.NewTimer(time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } + } +} + +// AcquireUncertain says an idle take-back may have committed without a reply. +// A later own-device fence is not evidence that this window acquired it. +func (b *Batcher) AcquireUncertain() bool { + b.mu.Lock() + defer b.mu.Unlock() + return b.acquireUncertain +} diff --git a/internal/cellsync/renewal.go b/internal/cellsync/renewal.go index 16bd819ad5..c04fc8224a 100644 --- a/internal/cellsync/renewal.go +++ b/internal/cellsync/renewal.go @@ -12,7 +12,7 @@ import ( func (b *Batcher) RenewalStatus() (time.Time, time.Duration, bool) { b.mu.Lock() defer b.mu.Unlock() - held := b.Driving.Fence != 0 && !b.stale && !b.renewalEnded + held := b.Driving.Fence != 0 && !b.stale && !b.released && !b.renewalEnded return b.renewalSince, directory.HeartbeatEvery, held } @@ -22,7 +22,7 @@ func (b *Batcher) recovering(seen Driving) bool { b.mu.Lock() defer b.mu.Unlock() return seen.Fence != 0 && b.Driving.ID() == seen.ID() && b.Driving.Fence == seen.Fence && - !b.stale && !b.renewalEnded && !b.renewalSince.IsZero() + !b.stale && !b.released && !b.renewalEnded && !b.renewalSince.IsZero() } // renewalResult belongs only to the hold the request actually renewed. @@ -30,7 +30,7 @@ func (b *Batcher) recovering(seen Driving) bool { func (b *Batcher) renewalResult(seen Driving, err error) { b.mu.Lock() changed := false - if b.Driving.ID() == seen.ID() && b.Driving.Fence == seen.Fence && !b.stale && !b.renewalEnded { + if b.Driving.ID() == seen.ID() && b.Driving.Fence == seen.Fence && !b.stale && !b.released && !b.renewalEnded { switch { case errors.Is(err, directory.ErrFenceStale), errors.Is(err, wireauth.ErrRevoked), errors.Is(err, wireauth.ErrGone), errors.Is(err, wireauth.ErrRotated): b.renewalEnded, b.renewalSince, changed = true, time.Time{}, true diff --git a/internal/chatlist/copy.go b/internal/chatlist/copy.go index 1b41d38b98..9f22183a9e 100644 --- a/internal/chatlist/copy.go +++ b/internal/chatlist/copy.go @@ -20,14 +20,17 @@ const ( MoveHere = "Move here" ContinueVerb = "Continue here" LostRace = "another device continued this chat first" + PickedUpAgain = "%s picked this chat up again" NoIdentity = "this machine has no identity yet: codeaf identity import" RelayStorageOff = "sync storage is off for this account, so chats stay on this computer; request access in Settings under Devices" SyncOff = "sync is off: set CODEAF_SYNC_URL to your sync service's address" Unreachable = "the sync service is not answering" - ReadTimedOut = "the sync service did not answer in time" - SyncPaused = syncwords.UnknownOutage - SyncRestored = "this chat is syncing again" - ClockOff = "this computer's clock is off by more than 5 minutes" + // TakeBackUnreachable explains why a released writer keeps a send unsent. + TakeBackUnreachable = Unreachable + ", so this chat cannot be taken back yet; your words are still in the box" + ReadTimedOut = "the sync service did not answer in time" + SyncPaused = syncwords.UnknownOutage + SyncRestored = "this chat is syncing again" + ClockOff = "this computer's clock is off by more than 5 minutes" DevicesQuiet = "Only this computer is here. Add another computer by pairing from a terminal with `codeaf pair`." RemoveSelf = "that is this device — remove it from another of your devices instead" diff --git a/internal/chatlist/idle_open_test.go b/internal/chatlist/idle_open_test.go new file mode 100644 index 0000000000..2de8bfc527 --- /dev/null +++ b/internal/chatlist/idle_open_test.go @@ -0,0 +1,35 @@ +package chatlist + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/directory" +) + +func TestIdleOpen_C6_C7_C10_C11_OnlyPairedFreeRowsAutoOpen(t *testing.T) { + for _, scenario := range []struct { + name string + peers int + expires int64 + auto bool + }{ + {"paired released", 2, 0, true}, + {"paired lapsed", 2, 9, false}, + {"paired live old build", 2, 11, false}, + {"single released", 1, 0, false}, + {"single live", 1, 11, false}, + } { + t.Run(scenario.name, func(t *testing.T) { + listing := directory.Listing{Now: 10, Devices: map[string]directory.Device{"A": {}}, Cells: map[string]directory.Cell{ + "chat": {Head: "saved", Lease: directory.Lease{Device: "A", Fence: 1, Expires: scenario.expires}}, + }} + if scenario.peers == 2 { + listing.Devices["B"] = directory.Device{} + } + row := Rows(listing, "B", nil)[0] + if row.AutoOpen != scenario.auto || row.Quiet().AutoOpen != scenario.auto { + t.Fatalf("auto open=%v, want %v; offline decoration changed authority=%v", row.AutoOpen, scenario.auto, row.Quiet().AutoOpen) + } + }) + } +} diff --git a/internal/chatlist/idle_r6_test.go b/internal/chatlist/idle_r6_test.go new file mode 100644 index 0000000000..59b5c578c0 --- /dev/null +++ b/internal/chatlist/idle_r6_test.go @@ -0,0 +1,29 @@ +package chatlist + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/directory" +) + +func TestIdleOpen_R6_LapsedWhileWorkingKeepsTheCard(t *testing.T) { + for _, working := range []bool{true, false} { + listing := directory.Listing{Now: 1000, Devices: map[string]directory.Device{"A": {}, "B": {}}, Cells: map[string]directory.Cell{ + "chat": {Head: "saved", Lease: directory.Lease{Device: "A", Fence: 7, Expires: 500, Working: working}}, + }} + row := Rows(listing, "B", nil)[0] + if row.AutoOpen || row.Quiet().AutoOpen || OfferFor(row).Kind != ContinueHere { + t.Fatalf("lapsed hold must keep consent (last working=%v): %+v", working, row) + } + } +} + +func TestIdleOpen_R6_ReleasedStillOpensDirectly(t *testing.T) { + listing := directory.Listing{Now: 1000, Devices: map[string]directory.Device{"A": {}, "B": {}}, Cells: map[string]directory.Cell{ + "chat": {Head: "saved", Lease: directory.Lease{Device: "A", Fence: 7, ReleasedAt: 500}}, + }} + row := Rows(listing, "B", nil)[0] + if !row.AutoOpen || !row.Released || OfferFor(row).Kind != Open { + t.Fatalf("released chat lost direct opening: %+v", row) + } +} diff --git a/internal/chatlist/rows.go b/internal/chatlist/rows.go index 3e97556255..0cd327d6af 100644 --- a/internal/chatlist/rows.go +++ b/internal/chatlist/rows.go @@ -36,6 +36,10 @@ type BranchCopy struct { Redundant bool } +// OpenReadTimeout bounds the ownership reading before an ordinary chat open. +// Transferring a reachable chat keeps its separate move budget. +const OpenReadTimeout = 2 * time.Second + // Row is one chat as the list shows it. type Row struct { Cell, Title, Device, DeviceID, Parent string @@ -60,6 +64,10 @@ type Row struct { // Mine says this device was the chat's last holder, so a lapsed lease is // this device's own to pick up and not another device's work gone quiet. Mine bool + // AutoOpen requires a paired roster and an explicitly released hold. + // Expiry cannot prove the previous holder finished working: older holders + // and services do not advertise working state. + AutoOpen bool // Released distinguishes a real release from a revoked or offline holder. Released bool ReleasedAgo time.Duration @@ -139,10 +147,18 @@ const ( // Archived cells are hidden; the newest durable turn comes first and ties // break by cell id so the order never flickers. func Rows(l directory.Listing, self string, open Opener) []Row { + paired := 0 + for _, dev := range l.Devices { + if !dev.Revoked { + paired++ + } + } rows := make([]Row, 0, len(l.Cells)) for id, c := range l.Cells { if !c.Archived { - rows = append(rows, rowOf(id, c, l, self, open)) + r := rowOf(id, c, l, self, open) + r.AutoOpen = paired >= 2 && c.Lease.Expires == 0 && c.OrphanTurns == 0 + rows = append(rows, r) } } sort.Slice(rows, func(i, j int) bool { return newer(rows[i], rows[j]) }) diff --git a/internal/chatlist/same.go b/internal/chatlist/same.go index f2adff5ca8..79a1efc904 100644 --- a/internal/chatlist/same.go +++ b/internal/chatlist/same.go @@ -33,6 +33,7 @@ type timelessRow struct { pending, orphanTurns uint32 fence uint64 mine, released bool + autoOpen bool releasedAgo time.Duration holderRemoved, branchRedundant bool } @@ -52,6 +53,7 @@ func timeless(r Row) timelessRow { orphanTurns: r.OrphanTurns, fence: r.Fence, mine: r.Mine, + autoOpen: r.AutoOpen, released: r.Released, releasedAgo: r.ReleasedAgo, holderRemoved: r.HolderRemoved, diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 97650919be..61649539d5 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -799,3 +799,22 @@ func TestAttributionInProfileOrEnvironmentIsConsumedAndNeverUnread(t *testing.T) t.Fatalf("attribution in both profile and shell was reported unread: %v", unread) } } + +func TestIdleHandoffInProfileOrEnvironmentIsConsumedAndNeverUnread(t *testing.T) { + dir := t.TempDir() + t.Setenv("CODEAF_IDLE_HANDOFF", "") + if retiredProfileKeys["idle_handoff"] { + t.Fatal("idle_handoff is listed as a retired profile key") + } + values := map[string]json.RawMessage{"idle_handoff": json.RawMessage(`true`)} + if unread := warnUnreadProfileKeys(dir, values); len(unread) != 0 { + t.Fatalf("a profile saying idle_handoff: true was reported unread: %v", unread) + } + t.Setenv("CODEAF_IDLE_HANDOFF", "on") + if unread := warnUnreadProfileKeys(dir, nil); len(unread) != 0 { + t.Fatalf("CODEAF_IDLE_HANDOFF=on was reported unread: %v", unread) + } + if unread := warnUnreadProfileKeys(dir, values); len(unread) != 0 { + t.Fatalf("idle_handoff in both profile and shell was reported unread: %v", unread) + } +} diff --git a/internal/config/derivation_test.go b/internal/config/derivation_test.go index 8ac407750f..bced2a1e3d 100644 --- a/internal/config/derivation_test.go +++ b/internal/config/derivation_test.go @@ -132,6 +132,7 @@ var settingReaders = map[string]string{ // name through: the leaf loops' doors hand [AssistedByModelAt]'s answer to // the attribution line. KeyAttributionModel: "AssistedByModelAt", + KeyIdleHandoff: "IdleHandoffAt", // The pool row is read through [ModelPoolAt] since the telemetry off switch // started capping the pool at read: `codeaf pool` calls [ModelPoolResolved] // with its injected environment (`codeaf telemetry` did too, until the diff --git a/internal/config/preferences.go b/internal/config/preferences.go index c1a0371436..98586ee8cb 100644 --- a/internal/config/preferences.go +++ b/internal/config/preferences.go @@ -17,7 +17,7 @@ func PreferenceKey(key string) bool { KeyTaskAudit, KeyReplyGuard, KeyTaskStart, KeyMemoryEnabled, KeyPromptProfile, KeyTierLowModel, KeyTierHighModel, KeyTierWorkerModel, KeyTierReflexModel, KeyTierMastermindModel, KeyKnowledgeModel, KeyModelRoles, KeyModelFallbacks, KeyRouting, - KeyVisionModel, KeyDocumentEngine, KeyAttribution, KeyAttributionModel, KeyContextFill, + KeyVisionModel, KeyDocumentEngine, KeyAttribution, KeyAttributionModel, KeyIdleHandoff, KeyContextFill, KeyCompletionReserve, KeyWorkingSet, KeyContextReuse: return true } diff --git a/internal/config/settings.go b/internal/config/settings.go index 4835464b44..4df89c6ade 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -125,6 +125,7 @@ const ( KeyModelPoolPublicKey = "models.pool.public_key" KeyAttribution = "attribution" KeyAttributionModel = "attribution.model" + KeyIdleHandoff = "idle_handoff" KeySplitPct = "split_pct" // The two rows the v3 chat surface keeps on disk BESIDE the conversation: @@ -1458,6 +1459,11 @@ const ( // somebody auditing the history later actually wants. DefaultAttributionModel = true + // DefaultIdleHandoff leaves an open chat's hold on the computer that + // holds it and keeps remote chats waiting for Continue here unless the + // person turns idle handoff on. + DefaultIdleHandoff = false + // The divider clamps so neither pane can be set into uselessness. The TUI // reads these so the drag, the [ ] nudge, and the sheet agree. DefaultSplitPct = 80 @@ -2791,6 +2797,13 @@ func (s *Settings) build() []Setting { read: func() string { return formatBool(AttributionModelAt(dir)) }, write: func(raw string) error { return writeBool(dir, KeyAttributionModel, raw) }, }, + Setting{ + Key: KeyIdleHandoff, Category: CategoryInterface, Kind: SettingBool, + Label: "idle handoff", Env: "CODEAF_IDLE_HANDOFF", + Hint: IdleHandoffHint, + read: func() string { return formatBool(IdleHandoffAt(dir)) }, + write: func(raw string) error { return writeBool(dir, KeyIdleHandoff, raw) }, + }, // The ssh carrier is local surface policy, so its overrides live beside // the other interface choices. Keeping them in the registry matters more // than their rarity: a network that drops idle TCP or rewrites DSCP is a @@ -3288,6 +3301,28 @@ func AssistedByModelAt(profileDir, model string) string { return model } +// IdleHandoffHint is the `idle_handoff` row's hint: what happens when an open +// shared chat goes idle with the row on, and what happens with it off. It never +// says "pair": that word is how a person searching settings finds the Devices +// tab, and a General row matching it took the search there instead. +const IdleHandoffHint = "On, an idle chat shared with your other computers releases its hold after upload and free shared chats open here without asking; off, open chats keep their hold and remote chats wait for Continue here." + +// IdleHandoffAt resolves whether an idle chat on a paired computer releases its +// hold to the relay after upload and whether free shared chats auto-open. A +// malformed pin reads as the default rather than refusing a launch over a toggle. +func IdleHandoffAt(profileDir string) bool { + if raw := strings.TrimSpace(env.Get("CODEAF_IDLE_HANDOFF")); raw != "" { + if value, err := parseBool(raw); err == nil { + return value + } + return DefaultIdleHandoff + } + if value, ok := persistedBool(profileDir, KeyIdleHandoff); ok { + return value + } + return DefaultIdleHandoff +} + // HistoryEnabledAt resolves whether the v3 chat surface records what was typed // into ~/.codeaf/v3/history.jsonl. A malformed pin reads as the default // rather than refusing a launch over a recall list. diff --git a/internal/config/settings_presentation.go b/internal/config/settings_presentation.go index b77dc57666..0ab01edd27 100644 --- a/internal/config/settings_presentation.go +++ b/internal/config/settings_presentation.go @@ -70,6 +70,9 @@ func (s Setting) ChatPresentation() SettingPresentation { p.Label = "sign commits, pull requests and issues" case KeyAttributionModel: p.Label = "include model in commit attribution" + case KeyIdleHandoff: + p.Label = "idle handoff" + p.Activation = "Applies immediately" case KeyHistoryEnabled: p.Category, p.Label = "Privacy", "save input history" case KeyDraftPersist: @@ -228,6 +231,8 @@ func (s Setting) ChatPresentation() SettingPresentation { p.Aliases = append(p.Aliases, "budget", "cost", "limit") case KeyHistoryEnabled, KeyDraftPersist: p.Aliases = append(p.Aliases, "privacy", "saved messages") + case KeyIdleHandoff: + p.Aliases = append(p.Aliases, "handoff", "idle release", "continue here", "other computers", "sync") } return p } diff --git a/internal/config/settings_test.go b/internal/config/settings_test.go index 06738b4aae..414d391abe 100644 --- a/internal/config/settings_test.go +++ b/internal/config/settings_test.go @@ -484,6 +484,58 @@ func TestTheModelNameRowDefaultsOnAndTurnsOnlyTheNameOff(t *testing.T) { } } +func TestTheIdleHandoffRowDefaultsOffAndRespectsProfileAndEnvironment(t *testing.T) { + dir := t.TempDir() + t.Setenv("CODEAF_IDLE_HANDOFF", "") + rows := registry(t, dir) + row, ok := rows.Row(KeyIdleHandoff) + if !ok { + t.Fatalf("%s is not registered", KeyIdleHandoff) + } + if KeyIdleHandoff != "idle_handoff" || row.Env != "CODEAF_IDLE_HANDOFF" || + row.Category != CategoryInterface || row.Kind != SettingBool || row.Label != "idle handoff" { + t.Fatalf("the idle-handoff row = %+v", row) + } + if row.Hint != IdleHandoffHint { + t.Fatalf("hint = %q, want %q", row.Hint, IdleHandoffHint) + } + if row.Value() != "off" || IdleHandoffAt(dir) { + t.Fatalf("idle handoff must be off by default: %q", row.Value()) + } + if err := row.Apply("on"); err != nil { + t.Fatal(err) + } + if !IdleHandoffAt(dir) { + t.Fatal("on did not enable idle handoff") + } + if reread, _ := registry(t, dir).Row(KeyIdleHandoff); reread.Value() != "on" { + t.Fatalf("the reread row lost the persisted choice: %q", reread.Value()) + } + if err := row.Apply("off"); err != nil { + t.Fatal(err) + } + if IdleHandoffAt(dir) { + t.Fatal("off did not turn idle handoff back off") + } + + t.Setenv("CODEAF_IDLE_HANDOFF", "on") + if !IdleHandoffAt(dir) { + t.Fatal("the environment lost to the persisted file") + } + pinned, _ := registry(t, dir).Row(KeyIdleHandoff) + if name, isPinned := pinned.PinnedBy(); !isPinned || name != "CODEAF_IDLE_HANDOFF" { + t.Fatalf("the idle-handoff row did not report its pin: %q", name) + } + t.Setenv("CODEAF_IDLE_HANDOFF", "off") + if IdleHandoffAt(dir) { + t.Fatal("CODEAF_IDLE_HANDOFF=off did not turn idle handoff off") + } + t.Setenv("CODEAF_IDLE_HANDOFF", "sure") + if IdleHandoffAt(dir) { + t.Fatal("a malformed pin did not fall back to the default off") + } +} + func TestSplitPercentClampsAndSavesThroughTheRegistry(t *testing.T) { saved := 0 rows := NewSettings(SettingsOptions{ diff --git a/internal/config/testdata/profile-keys.ledger b/internal/config/testdata/profile-keys.ledger index b3e414d4ad..bf962efb43 100644 --- a/internal/config/testdata/profile-keys.ledger +++ b/internal/config/testdata/profile-keys.ledger @@ -27,6 +27,7 @@ effort google_oauth_client google_oauth_secret history.enabled +idle_handoff knowledge.model lane.guard lane.talk diff --git a/internal/executor/environment.go b/internal/executor/environment.go index 00f248466e..9b362e14b9 100644 --- a/internal/executor/environment.go +++ b/internal/executor/environment.go @@ -34,6 +34,8 @@ func (s environmentSeat) Drain(ctx context.Context) error { return Drain(ctx, s. // CheckWork keeps the ownership gate beneath answering user admission. func (s environmentSeat) CheckWork() error { return CheckWork(s.Seat) } +func (s environmentSeat) CheckPreparedWork() error { return CheckPreparedWork(s.Seat) } + type environmentRunner struct { Runner environment []string @@ -68,3 +70,5 @@ func mergeEnvironment(base, extra []string) []string { } return append(result, extra...) } + +func (s environmentSeat) CheckSettingsWork() error { return CheckSettingsWork(s.Seat) } diff --git a/internal/executor/gated.go b/internal/executor/gated.go index 3b377d100b..c60d5eaf51 100644 --- a/internal/executor/gated.go +++ b/internal/executor/gated.go @@ -1,10 +1,21 @@ package executor -import "context" +import ( + "context" + "time" +) + +// TakeBackWithin bounds a released window's ownership write on a slow relay. +const TakeBackWithin = 15 * time.Second + +// TakeBackRetryLine keeps an unknown ownership write distinct from displacement. +const TakeBackRetryLine = "couldn't confirm taking this chat back from the sync service; try again" // WorkRefusal retains the drive's sentence and the device that continued the // chat. Surfaces may dress the facts without classifying the sentence. type WorkRefusal struct { + // Retryable keeps an uncertain take-back out of the displacement card. + Retryable bool `json:"retryable,omitempty"` Line string `json:"line"` HolderID string `json:"holder_id,omitempty"` HolderName string `json:"holder_name,omitempty"` @@ -21,6 +32,23 @@ func CheckWork(seat Seat) error { return nil } +// CheckPreparedWork confirms ownership again after slow task preparation. +func CheckPreparedWork(seat Seat) error { + if gate, ok := seat.(interface{ CheckPreparedWork() error }); ok { + return gate.CheckPreparedWork() + } + return nil +} + +// CheckSettingsWork admits persistent choices only when the seat installed a +// setting door. Older seats and ordinary local sessions retain their behavior. +func CheckSettingsWork(seat Seat) error { + if gate, ok := seat.(interface{ CheckSettingsWork() error }); ok { + return gate.CheckSettingsWork() + } + return nil +} + // Gated is seat with a gate in front of every tool call: while gate answers an // error the call is refused with that error and nothing runs, and while it // answers nil the seat is untouched. It is how a chat that another machine @@ -35,13 +63,37 @@ func Gated(seat Seat, gate func() error, admission ...func() error) Seat { if len(admission) > 0 { work = admission[0] } - return gated{Seat: seat, gate: gate, work: work} + var prepared func() error + if len(admission) > 1 { + prepared = admission[1] + } + var settings func() error + if len(admission) > 2 { + settings = admission[2] + } + return gated{Seat: seat, gate: gate, work: work, prepared: prepared, settings: settings} } type gated struct { Seat - gate func() error - work func() error + gate func() error + work func() error + prepared func() error + settings func() error +} + +func (g gated) CheckSettingsWork() error { + if g.settings != nil { + return g.settings() + } + return nil +} + +func (g gated) CheckPreparedWork() error { + if g.prepared != nil { + return g.prepared() + } + return nil } // CheckWork reads the ownership fact before a turn or task can start. @@ -53,7 +105,9 @@ func (g gated) CheckWork() error { } // ForSetup implements SetupSeat: a setup turn's calls are gated too. -func (g gated) ForSetup() Seat { return Gated(ForSetup(g.Seat), g.gate, g.work) } +func (g gated) ForSetup() Seat { + return Gated(ForSetup(g.Seat), g.gate, g.work, g.prepared, g.settings) +} // NoteModelCall implements ModelNoter: the seat beneath keeps the record. func (g gated) NoteModelCall(m ModelCall) { NoteModelCall(g.Seat, m) } diff --git a/internal/executor/gated_test.go b/internal/executor/gated_test.go index 930fdd6290..c9cbb36e7f 100644 --- a/internal/executor/gated_test.go +++ b/internal/executor/gated_test.go @@ -34,3 +34,36 @@ func TestC1C6WorkAdmissionReusesTheViewerGate(t *testing.T) { t.Fatalf("ungated seat changed: %v", err) } } + +func TestIdleRelease_C2_C4_PreparedWorkBypassesCachedAdmission(t *testing.T) { + refusal := &WorkRefusal{Line: "laptop continued this chat"} + seat := Gated(Host, func() error { return refusal }, func() error { return nil }, func() error { return refusal }) + carried := WithEnvironment(seat, []string{"CODEAF_AGENT_DID=fixture"}) + for _, form := range []Seat{seat, ForSetup(seat), carried, ForSetup(carried), Watching(carried, new(sightings))} { + if err := CheckWork(form); err != nil { + t.Fatalf("cached admission changed: %v", err) + } + if err := CheckPreparedWork(form); !errors.Is(err, refusal) { + t.Fatalf("prepared ownership refusal=%v", err) + } + } +} + +func TestIdleRelease_R5_Audit_LegacySeatsKeepTheirExistingAdmissionCalls(t *testing.T) { + calls := 0 + seat := Gated(Host, func() error { return nil }, func() error { calls++; return nil }) + for _, form := range []Seat{seat, ForSetup(seat), WithEnvironment(seat, nil), Watching(seat, new(sightings))} { + if err := CheckPreparedWork(form); err != nil { + t.Fatal(err) + } + if err := CheckSettingsWork(form); err != nil { + t.Fatal(err) + } + } + if calls != 0 { + t.Fatalf("optional idle doors added %d ordinary admission calls", calls) + } + if err := CheckWork(seat); err != nil || calls != 1 { + t.Fatalf("ordinary admission changed: calls=%d err=%v", calls, err) + } +} diff --git a/internal/executor/watched.go b/internal/executor/watched.go index af108f4375..97c64e14c3 100644 --- a/internal/executor/watched.go +++ b/internal/executor/watched.go @@ -42,6 +42,8 @@ func (w watched) Interrupted() Interrupted { return InterruptedOn(w.Seat) } // CheckWork keeps the ownership gate beneath answering user admission. func (w watched) CheckWork() error { return CheckWork(w.Seat) } +func (w watched) CheckPreparedWork() error { return CheckPreparedWork(w.Seat) } + // Around implements Seat: the call runs on the seat beneath, and the commands // its command line named, and whatever it left running, are observed as soon as // it has run, each with the whole of its words and the call's own outcome. That @@ -182,3 +184,5 @@ func (w watched) Settle(ctx context.Context) { Settle(ctx, w.Seat) } // Drain forwards the pending capture boundary to the seat beneath. func (w watched) Drain(ctx context.Context) error { return Drain(ctx, w.Seat) } + +func (w watched) CheckSettingsWork() error { return CheckSettingsWork(w.Seat) } diff --git a/internal/handoff/handoff.go b/internal/handoff/handoff.go index 1a55809f19..f86879b2d7 100644 --- a/internal/handoff/handoff.go +++ b/internal/handoff/handoff.go @@ -47,6 +47,9 @@ type Local interface { // Taker continues a chat on this device. type Taker struct { + // FreeOnly opens an explicitly released chat. A lapsed holder may still + // have unfinished work and requires the person's continuation choice. + FreeOnly bool // stagedHead joins a claim's value copies so a failure restores only // a native binding that the claim may have moved. stagedHead *string @@ -116,12 +119,18 @@ func (t Taker) TakeSeen(ctx context.Context, id string, seen *directory.LeaseExp if seen != nil && !seen.Matches(view.Cell) { return Taken{}, directory.ErrLeaseHeld } + if t.FreeOnly && view.Cell.Lease.Expires != 0 { + return Taken{}, directory.ErrLeaseHeld + } c := cell.Cell{ID: id, Root: t.RootFor(id)} kept, err := t.keepLocal(ctx, c, view.Cell.Head, view.Cell.Frames) if err != nil { return Taken{}, err } how := displacing(view) + if t.FreeOnly { + how.Force = false + } if seen != nil && seen.Head != "" { how.Expect.Head = seen.Head } diff --git a/internal/handoff/idle_open_test.go b/internal/handoff/idle_open_test.go new file mode 100644 index 0000000000..6b37fb1525 --- /dev/null +++ b/internal/handoff/idle_open_test.go @@ -0,0 +1,81 @@ +package handoff + +import ( + "errors" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" +) + +func TestIdleOpen_C6_C8_ReleasedAndLapsedChatNeedsOnlyRelay(t *testing.T) { + for _, released := range []bool{true, false} { + t.Run(map[bool]string{true: "released", false: "lapsed"}[released], func(t *testing.T) { + w := newWorld(t) + a, b := w.device(devA), w.device(devB) + drive := a.start(map[string]string{"journal": "final answer", "work/file": "saved"}) + if released { + a.release(drive) + } else { + w.clock.Advance(directory.LeaseTTL + time.Second) + } + // A has no live drive or server in this fixture. B can only read + // the relay's immutable objects and acquire its directory hold. + taker := b.taker() + taker.FreeOnly = true + if !released { + if _, err := taker.Take(t.Context(), chatID); !errors.Is(err, directory.ErrLeaseHeld) { + t.Fatalf("lapsed hold bypassed Continue here: %v", err) + } + // The confirmed continuation still needs only the relay. + taker.FreeOnly = false + } + got, err := taker.Take(t.Context(), chatID) + if err != nil { + t.Fatal(err) + } + files := tree(t, got.Cell.Root) + if files["journal"] != "final answer" || files["work/file"] != "saved" || w.cellRec(chatID).Lease.Device != devB { + t.Fatalf("C6/C8 incoming state: %v", files) + } + }) + } +} + +func TestIdleOpen_C7_C11_LiveOldHolderIsNeverTaken(t *testing.T) { + w := newWorld(t) + a, b := w.device(devA), w.device(devB) + a.start(map[string]string{"journal": "old build still open"}) + before := w.cellRec(chatID) + taker := b.taker() + taker.FreeOnly = true + if _, err := taker.Take(t.Context(), chatID); !errors.Is(err, directory.ErrLeaseHeld) { + t.Fatalf("C7/C11 live holder: %v", err) + } + if got := w.cellRec(chatID); got.Lease != before.Lease || got.Head != before.Head { + t.Fatal("C7/C11 open changed the old holder") + } +} + +func TestIdleOpen_C12_HolderRetakingDuringFetchWins(t *testing.T) { + w := newWorld(t) + a, b := w.device(devA), w.device(devB) + drive := a.start(map[string]string{"journal": "saved"}) + a.release(drive) + before := w.cellRec(chatID) + b.onFetc = func() { + _, err := a.dir.Acquire(t.Context(), chatID, directory.AcquireOpts{RequireConditional: true, Expect: &directory.LeaseExpectation{Device: devA, Fence: drive.Fence}}) + if err != nil { + t.Fatal(err) + } + } + taker := b.taker() + taker.FreeOnly = true + seen := &directory.LeaseExpectation{Device: before.Lease.Device, Fence: before.Lease.Fence} + if _, err := taker.TakeSeen(t.Context(), chatID, seen); !errors.Is(err, directory.ErrLeaseHeld) { + t.Fatalf("C12 renewed holder was forced: %v", err) + } + if got := w.cellRec(chatID); got.Lease.Device != devA || got.Lease.Fence != before.Lease.Fence+1 || got.Head != before.Head { + t.Fatalf("C12 renewed ownership changed: %+v", got) + } +} diff --git a/internal/manual/chat/add-or-remove-a-computer.md b/internal/manual/chat/add-or-remove-a-computer.md index 5a1a0cbf69..8e4860c0cd 100644 --- a/internal/manual/chat/add-or-remove-a-computer.md +++ b/internal/manual/chat/add-or-remove-a-computer.md @@ -285,7 +285,7 @@ Too many requests from one network are refused too: at most 10 new links in an h On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). -After a clean quit and final durable save, bare `codeaf` on another paired computer within 24 hours opens the released chat automatically. A sleeping or disconnected laptop still needs `alt+c` on Home. Local unsaved edits, another open local window, a named chat, `--host`, `--at` or `--session` prevent automatic continuation. A running chat never moves by itself. The arrival retains its `Moved from` banner and `not brought along:` facts; `esc` goes Home. +After a clean quit and final durable save, bare `codeaf` on another paired computer within 24 hours opens the released chat automatically. By default (**Settings → General → idle handoff** is off), open idle chats keep their hold and selecting a released chat on Home asks **Continue here**. Turn on **idle handoff** in `/settings` (or set `CODEAF_IDLE_HANDOFF=1`) so an idle paired chat lets go about 2 seconds after it goes idle once saved to the sync service, and Enter on Home (or `agents.open` in the app) opens a released chat directly. A lapsed hold on another computer still asks Continue here, including when its holder was working or its last working state is unknown. A laptop that went to sleep or disconnected while still holding the chat needs `alt+c` on Home while its hold is live. Local unsaved edits, another open local window, a named chat, `--host`, `--at` or `--session` prevent automatic continuation. A running chat never moves by itself. The arrival retains its `Moved from` banner and `not brought along:` facts; `esc` goes Home. Personal memory and stored model, display, approval and limit defaults follow the paired computers too. Limits are shared as values; spending counted against them stays per computer. Automations and machine-specific paths and settings stay local. 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 4de1b47c9a..4170a294d8 100644 --- a/internal/manual/chat/continuing-a-chat-on-another-computer.md +++ b/internal/manual/chat/continuing-a-chat-on-another-computer.md @@ -1,6 +1,6 @@ # Continuing a chat that one of your machines holds -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. +To continue on this machine, select the chat on home and choose `continue here` (the default while `idle handoff` is off; when **Settings → General → idle handoff** or `CODEAF_IDLE_HANDOFF=1` is turned on in a paired set, a released chat opens here automatically, while a live hold or a lapsed hold on another computer still offers `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. ## Continue on another computer — moving chats and pairing availability @@ -8,6 +8,14 @@ Moving chats and pairing are available by default on Linux, macOS and native Win Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. +## Idle chats release their hold — open on another paired computer, even when the first is off + +**Settings → General → idle handoff** (`idle_handoff`, or `CODEAF_IDLE_HANDOFF=1`) is **off by default**: while off, open idle chats keep renewing their hold and a released remote chat still uses **Continue here**. When you turn `idle handoff` on with two or more paired computers (and sync storage on), an idle chat lets go of its hold about 2 seconds after it goes idle, once its saved turns, journal, working files, shared memory and settings are on the sync service. The open window stays as a reader. A running turn, queued or steering work, a running task or job, unsaved tail writes or an upload failure keeps the hold. Rebuildable folders such as `node_modules` follow the usual transfer exclusions. + +With `idle handoff` on, a chat explicitly released by its holder opens directly on another paired computer with no Continue-here step: press Enter on terminal Home, or open its `can_open` row through `agents.open` in the app. The original computer can be off. A chat another computer still holds (`held on ` or `working on `, including an idle chat when `idle handoff` is off or on an older build that never releases) still asks, with **Move here** or **Continue here** as your choice. A hold that lapsed without a release also keeps that card, including one whose holder was working. This applies even when the last working state is unknown. Existing local edits use the ordinary kept-work rules during transfer. + +Sending new work in the window that released its idle hold takes the unchanged saved chat back in place, with no prompt, before recording your words or calling a model. If another computer took it, the window gets the held-elsewhere refusal. A hold that merely lapsed (not released by its window) admits nothing until you reopen or continue it. Only one computer can win a simultaneous open and send. A released window needs a successful ownership check before it can accept work; an outage leaves that work unsent. Unpaired, sync-off, single-device and default (`idle handoff` off) homes keep their hold while open. + ## Continue on another computer — continuing a chat on another computer, why can't I type in this chat, my message did not send and home opened with a question On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). @@ -31,6 +39,15 @@ 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. +## Which folder does a continued chat use — repeated moves and long copy-folder names + +A chat that already has a local folder keeps that folder when you bring it back. +A first incoming copy is filed under this computer's `v3/projects` folder. +Copy-folder names stay bounded and stable across repeated moves; long original +paths receive a short stable suffix. Existing short folder names stay as they +are. A long path on the other computer does not make the folder name grow on +every round trip. + ## A tool finishes after the chat hold expires — late saves and kept turns A finished tool call is saved locally even after this computer has noticed its @@ -114,9 +131,11 @@ On the hosted sync service this needs sync storage approval (Settings → Device 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. -Home lists chats from your other devices. A chat another device still holds says `held on `: it is open there, and nothing is running in it right now. While that device is in the middle of a turn the row says `working on ` instead; it changes back as soon as the turn ends. A sync service too old to carry that word never shows it, so its rows say `held on ` either way. One that a device let go of, or that went off because its lid closed, says ` offline`. +Home lists chats from your other devices. By default (`idle handoff` is off by default; turn it on in **Settings → General → idle handoff** or with `CODEAF_IDLE_HANDOFF=1`), an open idle chat keeps its hold and a released chat asks **Continue here**. With `idle handoff` on in paired computers, an idle chat lets go about 2 seconds after it goes idle once everything is on the sync service, and a released chat opens here directly: press enter on it to bring its latest saved copy here and open it directly (in the app, `agents.open` does the same on `can_open` rows). There is no Continue-here card for a released chat when `idle handoff` is on, and a free row shows no holder label. A lapsed hold on another computer keeps the card, including when its holder was working. -Press enter on either row to continue it here. The verb on the card depends on the row: **Move here** for a chat another device is running right now, **Continue here** for one it let go of. You already chose to continue, so the cursor starts on `continue here` and enter completes the move; `esc` leaves it there. The card says how fresh the copy is, so you know how many turns may still be on the other device. +A chat with a live hold on another device says `held on ` when it is open there with nothing running in it right now, and `working on ` while that device is in the middle of a turn; it changes back as soon as the turn ends. A sync service too old to carry that word shows `held on ` either way, so a hold alone does not establish that work is running there. When a computer stops answering without letting go (for example its lid closed), its row says ` offline`; offline alone does not make a live hold free. + +Opening a chat with a live hold still asks before moving it, with **Move here** or **Continue here** as your choice: the devices row offers **Move here** while it is running there and **Continue here** when it went off, and Enter on Home's row opens the `Continue this chat here?` card showing how fresh the copy is and how many turns may still be there (`esc` leaves it there). Watching never takes a live hold. ## Continue on this machine — Continue where you left off, alt+c and alt+x @@ -253,7 +272,7 @@ Moving chats and pairing are available by default on Linux, macOS and native Win while a chat you explicitly chose is moving here, the card says `continuing here…`. another explicit continue during that move says `a chat is already continuing here; wait for it to open` on the card. an automatic startup move instead lets your explicit choice go next; see “my choice while an automatic chat is arriving”. -every failure stays where you are looking: on Home's card with `1 retry` and `2 leave it there`, or inside the open chat window. a fresh reading decides whether a different computer continued first. if the same computer picked the chat up again, the card asks again with ` picked this chat up again; press enter to continue it here`. it does not blame another device. +every failure stays where you are looking: on Home's card with `1 retry` and `2 leave it there`, or inside the open chat window. a fresh reading decides whether a different computer continued first. if the same computer picked the chat up again, the card asks again with ` picked this chat up again; press enter to continue it here`. The desktop's `agents.take` refusal says ` picked this chat up again`, keeping its `conflict` code and `holder_changed` detail. Nothing is taken by either refusal. ## why continue here failed — retry or leave it there @@ -278,7 +297,7 @@ a failed move keeps the draft and shows `1 retry` and `2 leave it there`. retry 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. -A chat has one holder at a time. To take a chat back from the computer that took it, continue it there the same way: press enter on its row on that computer and choose `continue here`. Nothing has to be returned or switched off first. A computer with the chat open when another takes it says ` continued this chat; this window now only shows it`, stops taking new turns, and keeps what it had not yet sent as a branch (` turns from : open separately / discard`). A tool call the model tries there answers with that same line. Taking the chat back leaves a `node_modules` or other install folder that computer already has alone. +A chat has one holder at a time. To take a chat back from the computer that took it, press enter on its row on that computer: choose `continue here` by default (`idle handoff` is off by default; when turned on in **Settings → General → idle handoff** or with `CODEAF_IDLE_HANDOFF=1`, a released paired chat opens directly while a live hold or a lapsed hold on another computer still asks for `continue here`). Nothing has to be returned or switched off first. A computer with the chat open when another takes it says ` continued this chat; this window now only shows it`, stops taking new turns, and keeps what it had not yet sent as a branch (` turns from : open separately / discard`). A tool call the model tries there answers with that same line. Taking the chat back leaves a `node_modules` or other install folder that computer already has alone. The choice covers the holder and turn of ownership shown on the card. If another computer continues the chat before you confirm or while the copy is arriving, your choice cannot take it from that new holder. The slower one sees `another device continued this chat first` on the card with `1 retry` and `2 leave it there`; its local copy is not replaced, and its row goes back to `held on `. if the same holder picked it up again, the refreshed card says ` picked this chat up again; press enter to continue it here` and asks for a new confirmation instead. a complete chat already held here gets one silent recovery attempt; another disagreement keeps the retry card and draft. No turn is lost on either side: whatever the earlier holder had not sent is kept for you as the branch above. This build offers discard, which archives a branch, and opening the branch as a separate chat; merging it into the original chat is unavailable. Pressing `2` (`leave it there`) on the question, or `esc`, changes nothing and leaves the chat where it is. @@ -295,8 +314,7 @@ Once this window learns that another computer holds the chat, Enter opens as the live update or the ` continued this chat; this window now only shows it` notice arrives; it does not wait for the next background ownership check. Enter cannot start a reply, publish a turn or create a separate chat -from this displaced window. Choose `continue here` to bring the latest chat -back before sending. +from this displaced window. Open the latest chat from Home to bring it back before sending (choose `continue here` by default; with `idle handoff` on, a released paired chat opens directly while a live hold or a lapsed hold on another computer still asks for `continue here`). A chat this computer still holds sends on one Enter without an extra request to the sync service. A chat that has never been shared does too. @@ -304,8 +322,7 @@ to the sync service. A chat that has never been shared does too. ## Take my chat back after a crash, a killed process or a power loss If this computer was killed and another computer continued the chat, reopen -codeaf and choose **continue here** from Home or from the question raised by -Enter in the old local copy. The move brings the latest saved chat here and +codeaf and open the chat from Home: choose **continue here** by default (`idle handoff` off), or with `idle handoff` on a released paired chat opens directly while a live hold or a lapsed hold on another computer still asks for **continue here**, as does the question raised by Enter in the old local copy. The move brings the latest saved chat here and says `Moved from `. An old saved hold cannot keep the chat here or make that copy send a message. The question uses the other computer and last saved turn reported by the sync service. An offline holder is named as @@ -321,7 +338,9 @@ On the hosted sync service this needs sync storage approval (Settings → Device 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. -Reopening an older copy does not replace newer history on your other devices, even if the other computer has closed the chat. This window only shows its local copy until you choose `continue here`. Submitting a message opens that question and keeps your words in the box. Continuing brings the newer history here before writing can resume. +Reopening an older copy does not replace newer history on your other devices, even if the other computer has closed the chat. By default (`idle handoff` is off by default; turn it on in **Settings → General → idle handoff** or with `CODEAF_IDLE_HANDOFF=1`), this window only shows its local copy until you choose `continue here`. On paired computers with `idle handoff` on, opening a released chat from Home brings the latest saved history here automatically before you can write, including when an older viewer tab is already open. An older local copy does not overwrite newer history. A live hold or a lapsed hold on another computer still asks for Continue here, including when that holder was working. + +An already-open older window remains a reader after another computer continues the chat. Sending there refuses before recording your words. Open the latest chat from Home to resume it; if its hold is still live elsewhere (or `idle handoff` is off), the move remains your choice. 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. @@ -345,7 +364,9 @@ Moving chats and pairing are available by default on Linux, macOS and native Win While a chat is moving onto this computer, pressing enter on its row says `this chat is arriving on this computer; it opens by itself in a moment`. Nothing is wrong and nothing needs doing: the move takes a second or two, and the chat opens by itself when it ends, with the other computer's latest turns in it. Opening it earlier is refused on purpose, because a window opened halfway would show the chat as it was before the move and could not save your turns. -When the holder is off (its lid is shut), the row reads ` offline` and you can still continue the chat here: the card says `last saved turn ago; up to turns may still be on `. Turns that never left that computer are not in the copy you get; if its window is still running when it reconnects, it keeps those turns as a branch. After a restart, the turns stay in the local copy until you choose `continue here`, which keeps them as a branch before replacing that copy. Open the branch separately or discard it. Merging into the original chat is unavailable. +When the holder stops answering (for example, its lid is shut), the row may read ` offline`. While its hold is live, Continue here still asks and the card says `last saved turn ago; up to turns may still be on `. Once the holder releases the hold, Continue here brings it here by default (`idle handoff` is off by default; when turned on in **Settings → General → idle handoff** or with `CODEAF_IDLE_HANDOFF=1`, opening the paired released chat brings it here directly). A hold that merely lapses keeps the Continue-here card, including when its holder was working or its last working state is unknown. + +Turns that never left that computer are not in the copy you get. If its window is still running when it reconnects, it keeps those turns as a branch. After a restart, opening the latest copy keeps unpublished local turns and workspace edits as a branch before replacing them. Open the branch separately or discard it. Merging into the original chat is unavailable. ## Can the chat on the other computer still open the job logs it mentions — logs, saved tool output, task journals and the task history after a move @@ -484,7 +505,7 @@ show no warning rather than guessing. A headless open does not consume the notic On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). -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. +A clean quit lets go after the last save is durable, and when **Settings → General → idle handoff** (`CODEAF_IDLE_HANDOFF=1`, off by default) is turned on, an idle paired chat also lets go about 2 seconds after it goes idle once everything is on the sync service. 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 laptop that went to sleep while still holding the chat (for example mid-turn, before the save finished, while `idle handoff` is off, or on an older build) has not let go: while its hold is live, Home offers the chat and `alt+c` continues it, asking only if local work needs a choice; once the hold lapses, pressing Enter on its Home row still asks Continue here. 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. @@ -548,16 +569,24 @@ Older builds can still list, save, publish and release chats through a compatibl 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. +## Opening a free chat from Home after restarting + +By default (**Settings → General → idle handoff** is off), selecting a released remote chat on Home asks **Continue here** and local chats open locally. Turn on **idle handoff** in `/settings` (or set `CODEAF_IDLE_HANDOFF=1`) on paired computers so selecting a released chat on Home brings its latest saved history here with a new hold, without **Continue here**. This also works on the first open of this computer's local copy after a restart. + +With `idle handoff` on, Home uses its shared listing when it already answers. If this window has no open copy and the listing is missing the chat or still shows this computer's old live hold, Home checks its current hold for up to two seconds. If that check fails or times out, the locally saved chat opens as usual, without a connection refusal. A chat already open in this window needs no extra check. A listed free chat still refreshes an older viewer with its latest history. A live hold or a lapsed hold elsewhere keeps the existing **Continue here** card, including when its holder was working. Only explicitly released holds open directly on another computer when `idle handoff` is on. Unpaired, sync-off, single-device and default (`idle handoff` off) homes keep their local opening behavior. + +If the sync service is unreachable, Enter on a chat saved on this computer opens its local copy, including a listed free chat last held here. With `idle handoff` on, a free open's initial ownership read also waits at most two seconds: an unreachable service opens the local copy if one exists, or explains that the sync service is not answering for a remote-only chat. Successful transfers keep their normal move budget. Opening locally keeps the chat's usual offline send restrictions. + ## 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. +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. Open it from Home to bring the saved chat here, then press Enter to send (choose **continue here** by default; with `idle handoff` on, a released paired chat opens directly while a live hold or a lapsed hold on another computer still asks for **continue here**). Once this window says another computer continued the chat, its first Enter raises that question without running a turn or making a branch. A saved hold from an earlier process cannot bypass the question. A window whose writer still holds the chat sends with one Enter without asking the service first. -A new chat can send its first message normally. A sleeping computer whose ownership check finds an expired hold must use Continue here before sending again. Reopening the chat acquires a new ownership fence. While storage is off, a free chat last held here resumes quietly without reopening if its local history contains every shared turn, even with newer turns saved only here. 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 new chat can send its first message normally. A hold that merely lapsed (not released by its window) is reopened conditionally on the next Enter when no other computer holds the chat, or continued with Continue here; a window that released its own idle hold (when `idle handoff` is on) takes it back in place on new work when the saved head is unchanged. Reopening the chat acquires a new ownership fence. While storage is off, a free chat last held here resumes quietly without reopening if its local history contains every shared turn, even with newer turns saved only here. 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 @@ -575,7 +604,7 @@ Run `codeaf doctor` from a terminal to inspect this computer's engine store and - `unfinished continue · chat `: a continue started on this computer never completed or let go. - `duplicate rows · chat (2 local folders)`: more than one local folder on disk shares one chat id. - `duplicate rows · chat (branch copy , all turns in main chat)` or `duplicate rows · chat (branch copy , 3 turns not in main chat)`: branch copies exist for a chat. -- `lapsed hold on this computer · chat `: an expired hold from this computer would raise a question on open. +- `lapsed hold on this computer · chat `: a stale local hold receipt needs repair. With `idle handoff` on (`CODEAF_IDLE_HANDOFF=1`, off by default), current builds open an explicitly released paired chat directly; on Home, an unchanged expired hold last held here also opens without a question. Run `codeaf doctor --fix` to repair what is safe. It prints one line per repair: @@ -603,3 +632,35 @@ A current window understands the earlier outage and removed-computer notices too ## Why can't I continue new turns from my offline computer — sync storage is off Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. Existing saved content remains readable; turning storage off deletes nothing. The refusal is `sync storage is off for this account, so chats stay on this computer; request access in Settings under Devices`. Once approval is read in the background, waiting chats start within a minute without restarting. + +## Changing thinking or model settings after idle release + +With **Settings → General → idle handoff** (`CODEAF_IDLE_HANDOFF=1`, off by default) turned on in a paired set, changing this chat's name, thinking, model, automatic model choice, approval posture or attached folders in its released window first takes the hold back in place. The setting is saved with the chat before it is handed back again. `/remember` after the last turn also travels with the saved chat. + +If another computer took the chat, the setting is refused through the usual held-elsewhere message. The terminal keeps the requested `/effort` or `/model` choice in the input box. Open the newest free chat before applying it there. Your already-held chat can still work offline, including starting a task. + +## Renaming, rewinding or compacting a released chat + +A manual name is saved in both the chat's title and journal. When `idle handoff` (`CODEAF_IDLE_HANDOFF=1`, off by default) has released this window's hold, renaming, rewinding, undoing a rewind, compacting, anchoring a workspace, changing project autonomy, editing a plan, changing a task's next model, reviewing, applying, resolving or deleting task work, landing a folder and moving a memory line first take back this window's released hold. If another computer continued the chat, these changes are refused before writing. Open its newest saved chat before applying the change there. + +## Large workspaces keep their hold when the idle save check is too large + +When `idle handoff` (`CODEAF_IDLE_HANDOFF=1`, off by default) is on, idle release requires a complete comparison. Its check allows 20,000 paths, 50,000 directory entries, 8 MiB per comparison output or diagnostic stream and 64 MiB of chat-file contents across the saved and current copies. It checks shared directories once. If any budget or the 30-second comparison deadline is exceeded, the chat keeps its hold and retries later. Ordinary work and saving keep their existing behavior; no incomplete comparison releases the chat. + +## Opening a saved chat offline, asking again, and keeping unsent words + +Opening a chat with no stopped reply to resume does not say `the reply stopped when this conversation moved — asking again` or refuse work through the resume door. This includes opening your released chat's local copy while the sync service is unreachable. + +If an unanswered reply was stopped by moving the chat, it asks again once when this window can work. If ownership refuses that resume, the refusal appears once without the asking-again line, and the unanswered question is kept for a later resume. + +A new send in the window that released its chat (when `idle handoff` is on) needs to take the hold back. If the ownership check cannot reach sync, it says `the sync service is not answering, so this chat cannot be taken back yet; your words are still in the box`. The draft stays in the box; the send records nothing and calls no model. When the service returns, press Enter again to send those words after taking a new hold. An uncertain take-back write keeps its separate retry sentence below. + +Enter checks again even when Home previously read another computer's chats or still shows an outage. If this window's hold merely lapsed, Enter reopens the chat at a new fence and sends the kept draft without a card. The box stays intact until the send succeeds. Another holder still requires your continuation choice. A failed move from Home to a different chat also leaves this chat able to check again and conditionally reopen its unchanged own fence on the next Enter. + +## Take-back timeout: retry a released chat when the sync service is slow + +When `idle handoff` (`CODEAF_IDLE_HANDOFF=1`, off by default) is on, taking back your released chat is a bounded ownership write, with up to 15 seconds to finish. The terminal can show `waiting for ; still checking` while it waits. An already-held chat keeps its ordinary short ownership check and offline behavior. + +If the answer is lost, codeaf says `couldn't confirm taking this chat back from the sync service; try again`. Your words stay unsent. Press Enter again to retry in the same window. The chat may be briefly unavailable: another hold on this computer does not prove which window acquired it. codeaf does not offer its own uncertain hold through “Continue this chat here?”. + +If no window adopted that uncertain hold, nobody renews it. It lapses within the ordinary 90-second hold, and a retry then takes a new hold before sending your words. A live hold belonging to another window or computer never grants this window permission to write. diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index b74bdb6b5d..f4b942bd9c 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -465,23 +465,20 @@ Turning it off on either computer stops sharing between them: that computer stops sending and receiving teams and Traffic. What was already exchanged stays, including teams and encrypted copies in the vault. -Open a member whose chat is on another paired computer to see -`Continue this chat here?` on Home. Choose **continue here** to move the chat -and open it. Choose **leave it there** to stay on Home with nothing changed. -A chat that is not shared names the computer that has it: open it there. Until -its name is known, it says `the other computer`. Under `--host` or `--at`, a -member on another computer shows that notice; open it on its computer. - -A local member opens through the same path as its chat on Home, without an added -ownership lookup or relay refusal. A chat positively known to be elsewhere still -needs continuation. - -A send to one member or manager whose chat is known to be on another computer -is refused: it would not reach that chat. Continue the chat here first, then -send again. A room post or send to several members still reaches local members -and names anyone not reached, such as `Not reached: @web (on desktop).` If every -recipient is elsewhere, nothing is written. If the relay is unavailable, local messages still work. A local message may -wait while this computer finishes sending an earlier change to the relay. +Opening a remote member from terminal Teams raises `Continue this chat here?` +on Home (`continue here` moves and opens it; `leave it there` changes nothing). +By default (`idle handoff` is off by default; turn it on in **Settings → General → idle handoff** or with `CODEAF_IDLE_HANDOFF=1`), selecting a released chat on Home also asks **Continue here**. With `idle handoff` on, selecting a released chat on terminal Home (`enter`), or opening a +`can_open` row via `agents.open` in the app, opens it directly with no +Continue-here step; a live hold or a lapsed hold still asks, including when its holder +was working. Unshared chats, or members under +`--host` / `--at`, name the other computer (`the other computer` until known): +open them there. Local members open like Home without an extra ownership lookup. + +A send to one member or manager known to be on another computer is refused until +you bring that chat here. A room post or multi-member send reaches local members +and names anyone skipped (`Not reached: @web (on desktop).`); if all are +elsewhere, nothing is written. Local messages still work when the sync service +is down, and may wait briefly while an earlier change finishes uploading. ## Will deleting a team on my laptop delete it on my desktop? @@ -515,8 +512,10 @@ older lines leave this view. They remain on the computer that wrote them until its ordinary log rotation. A send only to chats known to be elsewhere is refused. A send with local -recipients is written once and names those not reached. Open a remote chat and -choose **continue here** on Home before sending to it. Under `--host` or `--at`, +recipients is written once and names those not reached. Bring a remote chat +here first (choose **continue here** by default, or with `idle handoff` on a released chat opens directly on Home or through +`agents.open` while a live hold or a lapsed hold asks for **continue here**, including when +its holder was working) before sending to it. Under `--host` or `--at`, open it on its computer. Decision details, spending records and working copies do not travel with Traffic. A decision or escalation whose recipient is elsewhere is refused before it changes anything. An unpaired computer or one diff --git a/internal/manual/chat/coordination-and-workers.md b/internal/manual/chat/coordination-and-workers.md index 1ddadf98fa..a3cad94afa 100644 --- a/internal/manual/chat/coordination-and-workers.md +++ b/internal/manual/chat/coordination-and-workers.md @@ -72,7 +72,7 @@ This release does not offer a command to submit or retry a leadership request, i ## 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. +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 (or, when **Settings → General → idle handoff** / `CODEAF_IDLE_HANDOFF=1` is turned on — off by default — open those released chats directly with no Continue-here step), 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 diff --git a/internal/manual/chat/desktop-sync.md b/internal/manual/chat/desktop-sync.md index a97616c8f1..a92943e7cd 100644 --- a/internal/manual/chat/desktop-sync.md +++ b/internal/manual/chat/desktop-sync.md @@ -97,7 +97,7 @@ engine's sentence and does not add a computer. A computer with submitted chats and its own pairing keys refuses ordinary pairing and explains replacement. Confirm `--replace` to join the other set: local chats stay here but stop syncing, shared memories and settings clear, -and local provider keys stay here. Use Continue here to move a shared chat. A failed +and local provider keys stay here. Use Continue here to move a shared chat (or, when **Settings → General → idle handoff** / `CODEAF_IDLE_HANDOFF=1` is turned on — off by default — a shared chat no computer holds advertises `can_open` and opens directly through `agents.open` with no Continue-here step; use **Move here** or **Continue here** when another computer still holds it). A failed upload stays saved locally and is retried while codeaf is open or on the next start. Sync status says `Sharing this computer's chats.` during the pass and `Some chats could not be shared yet. They are still saved here; codeaf will try @@ -131,22 +131,10 @@ Chats from other computers appear under On your other computers, with the computer's name. An online dot appears only when the engine knows its presence. Finished movable chats saved before pairing are shared after pairing and when the paired app engine starts. This does not open the chat or start a new turn. -An open, idle chat says `open on `; this describes where it is open and -does not mean a reply is running. While a reply runs, the engine advertises -`working: true`, including for a chat just continued -onto that computer. It clears the working flag after the reply finishes and its -last saved turn reaches the relay. Closing a chat does not make its computer -offline. ` offline` appears only when that computer is known to be offline; -unknown presence has no online dot or offline claim. The saved time is the time -of the newest shared saved turn, shown in your local time, rather than the time -it was copied to the relay. An unknown saved time is left out. The engine's -takeover sentence adds holder or pending-work facts without repeating a date or age. -After a relay starts or restarts, computers it has not heard from have unknown -presence for up to 75 seconds. They have no offline line during that window. -An observed computer whose connection actually lapses can still be marked offline. -Select a chat to preview its title, saved time and engine sentence. Continue -here moves it through the engine, then opens the returned local chat. Its -history and working copies use the same continuation path as the terminal. +An open, idle chat another computer still holds says `open on ` (`held on ` in the terminal); this says where it is open, not that a reply is running. By default (`idle handoff` is off by default; turn it on in **Settings → General → idle handoff** or with `CODEAF_IDLE_HANDOFF=1`), an open idle chat keeps its hold; with `idle handoff` on in paired builds, an idle chat lets go about 2 seconds after going idle once saved to the sync service, so rows stay `open on ` only before release, while unsaved work keeps the hold, when `idle handoff` is off, or on older builds. While a reply runs, the engine advertises `working: true` (`working on `), including right after a move, and clears it once the reply finishes and its last saved turn reaches the sync service. Closing a chat does not make its computer offline; ` offline` appears only when that computer is known to be offline, and unknown presence omits both dot and offline line. The saved time is the newest shared saved turn's time in your local time, not upload time, and is omitted when unknown. The engine's takeover sentence adds holder or pending-work facts without repeating a date or age. After the sync service starts or restarts, computers it has not heard from have unknown presence (no offline line) for up to 75 seconds; an observed connection that lapses can still be marked offline. +With `idle handoff` on, a shared chat explicitly released by its holder advertises `can_open: true` and `agents.open` pulls and opens it directly with no Continue-here step (while `idle handoff` is off, `can_open` is not advertised and released chats use Continue here). A lapsed hold never advertises `can_open`, including when its holder was working or its last working state is unknown. Select a held or lapsed chat (or use a preview-only client, or when `idle handoff` is off) to see its title, saved time and engine sentence: **Move here** or **Continue here** moves it through the engine and opens the returned local chat, using the same continuation path as the terminal. + +## Continuing a desktop chat here — fences, retries and local copies An already local chat that moved away keeps its place and becomes read-only. Continue here is available when the engine says it can move. Continuing here @@ -162,11 +150,11 @@ Completed turns are shared while the chat stays open, including after a move. ## Send a message to a chat working on another computer — queued there, runs after the turn -While a chat is open or working on another computer, the desktop composer +While another computer still holds a chat (working, or open while `idle handoff` is off, before idle release, or on an older build), the desktop composer writes to that computer instead of being read-only. The field says `Message `, and where the model picker usually is, a chip beside Send says where it goes: `To · after this turn` while a turn runs there, -`To ` while the chat is only open. Rest on the chip or press it to see +`To ` while the chat is only held open. When `idle handoff` (`CODEAF_IDLE_HANDOFF=1`, off by default) is on and an idle chat lets go of its hold, no computer holds it: opening it via `agents.open` pulls it here directly, and sending in the window that released it takes it back in place. Rest on the chip or press it to see which computer it is (` · your Linux computer`), what happens to a message, and `Continue here instead`. The chat does not move. The message goes into the chat's queue on that computer, so it runs when the @@ -203,14 +191,19 @@ Limits and refusals: - A message is at most 2000 characters: `Messages to another computer are limited to 2000 characters.` - A sync service that does not carry messages says `This sync service can't carry a message to another computer yet. Bring the chat here to write to it.` - Only an app engine (the desktop app, or `codeaf app`) on the holding computer takes messages. A terminal chat there does not; the message waits. -- A chat whose computer is offline stays read-only, and a handed-over chat's next message still moves it here. +- A chat whose holding computer is offline with a live hold or a lapsed hold stays read-only until you continue it here, including when its holder was working. With `idle handoff` on (`CODEAF_IDLE_HANDOFF=1`, off by default), an explicitly released chat opens here directly through `agents.open` on `can_open` rows, and new work in the window that released its own idle hold takes it back in place (with `idle handoff` off, a handed-over chat's next message moves it here via Continue here). + +## Does an idle desktop chat keep its hold on the sync service + +By default (**Settings → General → idle handoff** is off), a chat opened for viewing keeps its conversation and sync-service hold until it is explicitly closed, including while its tab is hidden; heartbeats renew the same hold without releasing it or moving its fence, and reading the chat does not cause ownership changes or repeat unchanged chat rows. -## Does an idle desktop chat keep its hold on the relay +When you turn **idle handoff** on in `/settings` or with `CODEAF_IDLE_HANDOFF=1` with two or more paired computers (and sync storage on), an idle chat lets go of its hold about 2 seconds after it goes idle once its journal, working files, shared memory and settings are on the sync service, including when its tab stays open. The conversation remains open as a reader. Running turns, queued or steering work, running tasks or jobs, deferred writes and failed uploads retain the hold. Rebuildable files keep their existing transfer exclusions. -A chat opened for viewing keeps its conversation and relay hold until it is -explicitly closed, including while its tab is hidden. Heartbeats renew the same -hold without releasing it or moving its fence. Reading the chat does not cause -ownership changes or repeat unchanged chat rows. +New work in the window that released its idle hold takes the unchanged chat back in place, with no prompt, before recording it or calling a model. If another computer took it, the existing moved notice and held-elsewhere refusal name that computer. A hold that merely lapsed (not released by its window) admits nothing until you reopen or continue it. A released window stays read-only when ownership cannot be confirmed. Unpaired, sync-off, single-device and default (`idle handoff` off) homes keep their existing hold and renewal behavior. + +With `idle handoff` on, through `agents.open` and `can_open` rows, the engine opens an explicitly released shared chat on another paired computer directly with no Continue-here step. A lapsed hold keeps Continue here, including when its holder was working or its last working state is unknown. App versions that only preview shared chats (or homes with `idle handoff` off) still offer Continue here in that preview. A chat another computer still holds (`held on ` / `working on `, including idle chats when `idle handoff` is off or on older builds) continues to ask with **Move here** or **Continue here** as your choice. + +## Background desktop conversations release after ten idle minutes A conversation opened only for background work is let go after ten idle minutes; the sweep checks every 30 seconds. It stays released until work needs it again. @@ -233,8 +226,14 @@ sync or repeat settled preferences. While the relay is unreachable, an open chat continues to run and save on this computer. Unpublished turns retry automatically with waits capped at 60 seconds, without another send, quit or restart. Once an ownership check finds the hold expired, -the window stops admitting work. Use Continue here to acquire a new hold. -Reopening a chat also acquires a new ownership fence. +an idle window stops admitting work. In an open terminal chat, the next Enter +conditionally reopens it at a new fence and sends the kept draft without a +Continue here card. A desktop can use Continue here to acquire a new hold. +Reopening a chat also acquires a new ownership fence. If this computer's renewal +or the relay's renewal at restart reaches the unchanged hold before an ownership +check observes expiry, the open chat keeps its fence and uploads its local turns. + +A turn that is already running when the hold is observed to have expired attempts a conditional in-place reacquire at a new fence when no other computer holds the chat, clearing the outage notice and continuing on the original chat without a copy branch; if another computer holds the chat, finished calls are kept as a branch. An upload never overwrites another computer's newer hold and never renews a lapsed lease by publishing. If the service is down, those saved turns remain local and retry when it returns. If another computer continued the chat meanwhile, this window becomes read-only and names that computer. Work saved here during the outage is kept through the @@ -265,11 +264,10 @@ If moving the chat fails, the app says `This chat could not be moved to this computer.` Technical causes are kept in the error details. A plain model setup reason is carried in the visible message. If installation failed after acquisition, the chat is released. -The computer without the installed chat still offers Continue here and names the -last holder without claiming it is open there. The original open chat says -`This chat is available to continue here.` Its next send can acquire the free -lease when its saved head still matches the relay. -If another computer saved newer work, use Continue here to restore that work. +The computer without the installed chat can choose Continue here (or, with `idle handoff` on, open the now-free row directly through `agents.open` / `can_open`), and the row names the +last holder without claiming it is open there. A window that was displaced by that move attempt says +`This chat is available to continue here.` Because another computer's move displaced it rather than its own idle release, it admits nothing until you reopen or continue it here. +If another computer saved newer work, reopening or Continue here restores that work. ## Shared settings after pairing — model, effort and spending limits diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 8b21618a26..5bea589732 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -301,7 +301,8 @@ happened. The row says where the chat is only where that tells you something: | The chat is | The row says | | --- | --- | -| held on another machine | `held on ` | +| running a turn on another machine | `working on ` | +| held open on another machine | `held on ` | | on a machine that went off or to sleep without letting go | ` offline` | | a branch left by a takeover | ` turns from : discard` (this build has no merge) | | held by this machine, or let go by everyone | nothing | @@ -310,7 +311,7 @@ happened. The row says where the chat is only where that tells you something: On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). -For a chat on another paired computer, read the two words apart: `held on build-box` says that computer still holds the chat, including while it is idle, and `build-box offline` says that computer has not answered for 25 seconds (it is switched off, asleep, frozen or offline; the word appears within 30 seconds, and goes at once when it answers again) and has not let the chat go, so the chat's last saved turns are what you would get. The name is only the device's. A chat on a computer that is `offline` can still be continued here; the card says how many turns may still be there. A count of one reads `1 turn`, never `1 turns`. The device is the name you gave that machine. A chat whose name will not open shows +For a chat on another paired computer, read the words apart: `working on build-box` says a turn is running on that computer right now, `held on build-box` says that computer still holds the chat with nothing running right now (by default while `idle handoff` is off, before idle release when `idle handoff` is on, or while an older build is idle), and `build-box offline` says that computer has not answered for 25 seconds (it is switched off, asleep, frozen or offline; the word appears within 30 seconds, and goes at once when it answers again) and has not let the chat go, so the chat's last saved turns are what you would get. The name is only the device's. A live hold on a computer that says `working on`, `held on` or `offline` still asks before **Move here** / **Continue here**; the card says how many turns may still be there. Offline alone does not make the chat free. By default (`idle handoff` is off by default; turn it on in **Settings → General → idle handoff** or with `CODEAF_IDLE_HANDOFF=1`), a released chat asks **Continue here**; on paired computers with `idle handoff` on, a released chat opens here directly with its latest saved copy and no Continue-here card, including when its older viewer tab is already open. A lapsed hold on another computer still asks, including when its holder was working. A count of one reads `1 turn`, never `1 turns`. The device is the name you gave that machine. A chat whose name will not open shows `untitled`, and a machine whose name will not open shows the first eight characters of its id. On a narrow frame a branch's sentence shortens to ` turns · `, and where even that will not fit the sentence gives way whole before the chat's name is cut, so a @@ -346,7 +347,7 @@ Moving chats and pairing are available by default on Linux, macOS and native Win Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. -Press enter on a row that says ` offline` or `held on ` and home asks **Continue this chat here?** +By default (`idle handoff` is off by default; turn it on in **Settings → General → idle handoff** or with `CODEAF_IDLE_HANDOFF=1`), selecting a released remote chat asks **Continue this chat here?**, while on paired computers with `idle handoff` on a released chat opens directly with its latest saved copy. If the row has a live hold or a lapsed hold on another computer (or is released while `idle handoff` is off), whether it says `working on `, `held on ` or ` offline`, press enter and Home asks **Continue this chat here?** with the cursor on `continue here`, because pressing enter on the row already said you want it; `esc` or `2` leaves it there. The card says how fresh the copy is: `last saved turn ago; up to turns may still be on `. The `up to` clause is left out when nothing is waiting there. On a @@ -2753,9 +2754,9 @@ from a previous visit cannot replace the current visit's selection or draft. On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). -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. +After a clean quit lets a paired computer's chat go (or, when **Settings → General → idle handoff** / `CODEAF_IDLE_HANDOFF=1` is turned on — off by default — about 2 seconds after an idle paired chat goes idle once saved to the sync service), bare `codeaf` on another paired computer continues that released chat automatically within 24 hours, and with `idle handoff` on Enter on Home opens a released chat directly (with `idle handoff` off, Home rows ask **Continue here**). A lapsed hold on another computer still asks Continue here, including when its holder was working. 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. +If the laptop went to sleep or lost its connection while still holding the chat, Home offers its latest chat while the hold is live and `alt+c` continues immediately (once the hold lapses, Enter on its Home row still asks Continue here). 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. ## my chosen chat opens while a startup chat is still arriving diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index ecb5e657e7..334afb432e 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -42,7 +42,7 @@ The path names the transcript on the computer running the chat, including over The command exits with a failure status. One sentence stays visible after the full-screen chat closes, including when error output goes to a log. On the next start, retained turns are published if this computer still holds the chat. -If another computer has continued it, take the chat back to see its newest history; +If another computer has continued it, choose continue here from Home (or, with `idle handoff` on, open it from Home once no computer holds it) to see its newest history; local accounting alone does not create a separate chat. A computer removed from your paired devices keeps saving this chat locally. diff --git a/internal/manual/chat/naming-a-device.md b/internal/manual/chat/naming-a-device.md index 6456c201ca..a2d30063f2 100644 --- a/internal/manual/chat/naming-a-device.md +++ b/internal/manual/chat/naming-a-device.md @@ -6,7 +6,7 @@ Moving chats and pairing are available by default on Linux, macOS and native Win Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. -Every screen that names a device uses one name for it: the home rows (`held on tower`, `tower offline`), `Continue tower's latest chat here?`, the approve screen, `tower is now paired. Your chats can continue there.`, `/devices`, `/settings` → `Devices`, and the line after a move, `Moved from tower in 4s`. A device is called by its host name, the word your operating system already uses for it, until you give it a name. A name is only a label. It is not a key and it signs nothing, and renaming a device changes nothing about what it can reach. +Every screen that names a device uses one name for it: the home rows (`working on tower`, `held on tower`, `tower offline`), `Continue tower's latest chat here?`, the approve screen, `tower is now paired. Your chats can continue there.`, `/devices`, `/settings` → `Devices`, and the line after a move, `Moved from tower in 4s`. A device is called by its host name, the word your operating system already uses for it, until you give it a name. A name is only a label. It is not a key and it signs nothing, and renaming a device changes nothing about what it can reach. Because you never named this device, it kept the default. Name it and every other device shows the new name. diff --git a/internal/manual/chat/relay-hosted-or-your-own.md b/internal/manual/chat/relay-hosted-or-your-own.md index b8cbabea2c..8a73834f24 100644 --- a/internal/manual/chat/relay-hosted-or-your-own.md +++ b/internal/manual/chat/relay-hosted-or-your-own.md @@ -72,7 +72,7 @@ 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 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. +An older sync service still pairs computers, lists and shows chats, saves every chat you write, and opens or continues a chat no computer holds (one the other computer closed, let go of after about 2 idle seconds when **Settings → General → idle handoff** / `CODEAF_IDLE_HANDOFF=1` is turned on — off by default — or let expire). Only taking a chat another computer still holds (running, or idle while `idle handoff` is off, before release, or on an older build) 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 (or let it go idle with `idle handoff` on), then continue or open it 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 ae40a81b07..74726ad186 100644 --- a/internal/manual/chat/relay-limits-and-refusals.md +++ b/internal/manual/chat/relay-limits-and-refusals.md @@ -2,7 +2,7 @@ ## Taking an open chat from another computer waits for a sync service 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. +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 opening or continuing a chat no computer holds (closed, released after about 2 idle seconds when `idle handoff` / `CODEAF_IDLE_HANDOFF=1` is turned on — off by default — or lapsed) keep working. Close the chat on that computer (or let it go idle with `idle handoff` on), then continue or open it 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 677945760e..6d567282db 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -814,7 +814,7 @@ key the first time the identity is made on that machine. On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). **`codeaf cell list --all`** prints every chat you have on any of your machines, one line -each: id, title, device, where it runs (`held on `, ` offline`, +each: id, title, device, where it runs (`working on `, `held on `, ` offline`, ` turns from : discard`, or `-` for one held here or let go), and how long ago its last saved turn was. It needs no terminal UI, so it works over ssh. Without `--all` it prints the usage line. With sync off (`CODEAF_SYNC_URL=off`, or no relay to sync through) it prints @@ -869,13 +869,11 @@ no file content it can read. Requests are signed by this machine's own device ke relay can tell your machines apart. `codeaf cell list --all` and the sessions panel on home show your chats on the other machines once both machines share an identity and this setting. -## Why did my tool call not run after I continued the chat on my laptop — the view-only window, kept branch and clock warning +## Why did my tool call not run after I continued the chat on my laptop — closing the chat, the view-only window, kept branch and clock warning -With sync on, the chat you are typing in sends each saved turn to your relay, and holds the chat -for this machine while it is open. Closing the chat sends what is left and lets go of it, so your -other machine can pick it up at once. If this machine sleeps or loses its connection, presence stops answering; the other computers mark it offline after 25 seconds of silence, within 30 seconds. The lease itself expires after about ninety seconds, and an explicit continue can take it sooner. -You do not have to wait for that: press enter on a chat that says `held on ` on another -machine and continue it there, and it is taken at once (see "Continue a chat here that another +With sync on, the chat you are typing in sends each saved turn to your sync service, and by default (`idle handoff` is off by default; turn it on in **Settings → General → idle handoff** or with `CODEAF_IDLE_HANDOFF=1`) holds the chat for this machine while it is open. Closing the chat sends what is left and lets go of it, and with `idle handoff` on in two or more paired machines an idle chat also lets go of its hold about 2 seconds after it goes idle once everything is saved, so another machine can continue it (or with `idle handoff` on open it directly on Home or through `agents.open` with no Continue-here step). New work in the window that released it takes it back in place if nobody else took it; unpaired, sync-off, single-device and default (`idle handoff` off) homes keep their hold while open. If this machine sleeps or loses its connection while still holding the chat, presence stops answering; the other machines mark it offline after 25 seconds of silence, within 30 seconds. The lease itself expires after about ninety seconds (after which another paired machine still asks Continue here, including when the holder was working; a window whose hold merely lapsed admits nothing until you reopen or continue it). +You do not have to wait for expiry while a hold is still live: press enter on a chat that says `working on `, `held on ` or ` offline` on another +machine and choose **Move here** / **Continue here**, and it is taken at once (see "Continue a chat here that another machine left" in home). When another machine does, this window says ` continued this chat; this window now only shows it`, diff --git a/internal/manual/chat/settings.md b/internal/manual/chat/settings.md index 9a44ab8538..f583d69559 100644 --- a/internal/manual/chat/settings.md +++ b/internal/manual/chat/settings.md @@ -91,6 +91,8 @@ history is hidden while sharing is off and is kept for when it is turned back on. Unpaired computers are unaffected. A missing or empty choice reads on; a damaged configuration or invalid value reads off, as shown in Settings. +**Settings → General → idle handoff** (`idle_handoff`, environment `CODEAF_IDLE_HANDOFF`) is **off by default**. While off, open idle chats keep renewing their hold and released remote chats use **Continue here**. Turn it on in Settings or with `CODEAF_IDLE_HANDOFF=1` so paired idle chats release their hold about 2 seconds after upload and released shared chats open directly on Home and through `agents.open` (`can_open`). + ## AI teams settings versus an individual AI team **AI teams** contains defaults for groups of AI chats, including questions to an diff --git a/internal/manual/chat/use-this-on-another-computer.md b/internal/manual/chat/use-this-on-another-computer.md index 2644050c81..1d356004e5 100644 --- a/internal/manual/chat/use-this-on-another-computer.md +++ b/internal/manual/chat/use-this-on-another-computer.md @@ -37,7 +37,7 @@ The code is good for 10 minutes. It works once. ## Share my conversation with my other computer Pair the computers with `/pair` or `codeaf pair`. A computer with submitted -chats requires `--replace`. On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). Continue here moves a conversation to the other computer. +chats requires `--replace`. On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). By default (`idle handoff` is off by default; turn it on in **Settings → General → idle handoff** or with `CODEAF_IDLE_HANDOFF=1`), **Continue here** moves a conversation to the other computer. With `idle handoff` on, an idle chat lets go about 2 seconds after going idle once saved to the sync service, and an explicitly released chat opens directly on the other computer (Enter on terminal Home, or `agents.open` on `can_open` rows in the app) with no Continue-here step; **Move here** or **Continue here** moves one another computer still holds or whose hold merely lapsed, including when its holder was working. ## Sync my chats — get my chats on my other computer, how to sync between paired computers @@ -54,9 +54,11 @@ Older chats become movable through independent copies after pairing; their origi ## My idle chat was continued on another computer — sending, queueing, steering and retrying -Before accepting new work, an open synced chat checks who holds it if its last ownership confirmation is older than two seconds. Back-to-back submissions reuse that confirmation. The check waits at most three quarters of a second. If the relay is slow or unreachable, it is logged, you keep working locally, and the chat does not ask again for 30 seconds. +Before accepting new work, an open synced chat checks who holds it if its last confirmation is older than 2 seconds (back-to-back sends reuse it). The read waits at most 750 ms; sending cancels idle handoff preparation immediately without extending that deadline, and tasks recheck after slow preparation. If the sync service is unreachable, a window that still holds its chat keeps working locally and waits 30 seconds before asking again; a released window must confirm ownership first or leave your work unsent. -If another computer has continued the chat, this window only shows it. Sending, queueing, steering and retrying are refused before saving your words or calling a model. The moved notice names the other computer. You can still answer an existing question or stop work. Desktop and terminal chats share this check. With sync off, an unpaired home, unavailable sync or no sync drive, there is no added network wait. +By default (**Settings → General → idle handoff** is off), open idle chats keep renewing their hold and a released remote chat asks **Continue here**. When you turn **idle handoff** on in `/settings` or with `CODEAF_IDLE_HANDOFF=1` with two or more paired computers, an idle chat lets go about 2 seconds after going idle once its journal, working files, shared memory and settings are on the sync service (excluding rebuildable folders). Its window stays open as a reader; running turns, queued or steering work, tasks, jobs, unsaved writes and failed uploads keep the hold. With `idle handoff` on, a released chat opens directly on another paired computer with no Continue-here step (Enter on terminal Home, or `agents.open` on `can_open` rows); a live hold or a lapsed hold on another computer asks with **Move here** or **Continue here**. New work in the window that released its own idle hold takes the unchanged chat back in place with no prompt, keeping local install folders without a setup card; a newer saved head or a hold that merely lapsed admits nothing until you open or continue it. + +If another computer continued the chat, this window only shows it: sending, queueing, steering and retrying refuse before saving words or calling a model, naming that computer, while existing questions and stop still work. Sync-off, unpaired, single-computer and default (`idle handoff` off) homes keep their hold while open. ## What does /laptop do — the laptop command pairs chats with my other computer; /pair, /sync and /link @@ -507,8 +509,8 @@ Moving chats and pairing are available by default on Linux, macOS and native Win Older chats move through independent copies after pairing. Their original files and layout remain untouched for older builds. Nothing is deleted automatically to save disk. Native Windows moves chats with the native engine, just like Linux and macOS. -Continue here installs the saved chat while other chats stay open. You can send -from the installed copy and continue it back on your other computer. +Continue here (or opening a free paired chat directly when `idle handoff` is on) installs the saved chat while other chats stay open. You can send +from the installed copy and open or continue it back on your other computer. Install Git for Windows to run commands; codeaf uses Git Bash, never the WSL launcher. A build with no compatible engine keeps moving chats absent and says `moving chats needs a newer furrow engine than this build carries`. @@ -584,7 +586,7 @@ Once this computer has its own saved pairing keys, a sent turn, tracked work, a On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). -If this computer has no saved pairing keys yet — for example it has only ever run `codeaf chat --once` — those chats are kept when it first pairs without `--replace`, and pairing does not discard or hide them. They are not sent at the moment of pairing. The next time codeaf's window opens on this computer, each one is saved for syncing and appears on your other computers' Home, where it can be continued. +If this computer has no saved pairing keys yet — for example it has only ever run `codeaf chat --once` — those chats are kept when it first pairs without `--replace`, and pairing does not discard or hide them. They are not sent at the moment of pairing. The next time codeaf's window opens on this computer, each one is saved for syncing and appears on your other computers' Home, where it can be continued (or opened directly when `idle handoff` is turned on, because no computer holds it). A computer that already has its own saved pairing keys follows the local-work refusal and explicit `--replace` rules above. diff --git a/internal/manual/idle_handoff_test.go b/internal/manual/idle_handoff_test.go new file mode 100644 index 0000000000..9434708b91 --- /dev/null +++ b/internal/manual/idle_handoff_test.go @@ -0,0 +1,51 @@ +package manual + +import ( + "strings" + "testing" +) + +func TestIdleOpen_C6_C7_IndexedAnswersDistinguishFreeAndHeld(t *testing.T) { + for _, probe := range []struct{ question, phrase string }{ + {"a row on home and the verb it offers", "There is no Continue-here card"}, + {"I reopened an old local chat after the other computer let go", "latest saved history here automatically"}, + {"Continue a chat here that another machine left", "released chat opens directly"}, + {"Why a chat says held on build-box or build-box offline", "released chat opens here directly"}, + } { + found := false + for _, section := range Chat().Search(probe.question, DefaultResults) { + if strings.Contains(section.Body, probe.phrase) && strings.Contains(section.Body, "live hold") && strings.Contains(section.Body, "idle handoff") { + found = true + break + } + } + if !found { + t.Errorf("%q must retrieve the free-open, live-hold and idle-handoff setting distinction", probe.question) + } + } +} + +func TestIdleOpen_R6_ManualExplainsLapsedConsentAndOfflineDraft(t *testing.T) { + for _, question := range []string{ + "Idle chats release their hold", + "opening a free chat from Home after restarting", + "what does held on tower or working on tower mean", + } { + found := false + for _, section := range Chat().Search(question, DefaultResults) { + if strings.Contains(section.Body, "lapsed") && strings.Contains(section.Body, "working") && strings.Contains(section.Body, "card") && strings.Contains(section.Body, "off by default") { + found = true + } + } + if !found { + t.Errorf("%q must explain consent for a lapsed working holder and that idle handoff is off by default", question) + } + } + found := false + for _, section := range Chat().Search("opening a saved chat offline asking again keeping unsent words", DefaultResults) { + found = found || strings.Contains(section.Body, "your words are still in the box") && strings.Contains(section.Body, "calls no model") + } + if !found { + t.Error("offline opening and send must explain why the draft is kept") + } +} diff --git a/internal/offpath/offpath.go b/internal/offpath/offpath.go index 7c06e2115c..b8a78aee58 100644 --- a/internal/offpath/offpath.go +++ b/internal/offpath/offpath.go @@ -199,3 +199,15 @@ func (w *Write) Settle() { <-idle } } + +// Idle observes deferred writes without joining work that may be stalled. +func (w *Write) Idle() bool { + if w == nil { + return true + } + if !w.mu.TryLock() { + return false + } + defer w.mu.Unlock() + return !w.owed && !w.running +} diff --git a/internal/remote/client.go b/internal/remote/client.go index 1fd84511a4..bf190d3267 100644 --- a/internal/remote/client.go +++ b/internal/remote/client.go @@ -90,7 +90,8 @@ const callDeadline = 10 * time.Second // it has to be: the surface asks synchronous getters from its update loop while // a turn's events are arriving on the reader. type Client struct { - clientTools clientToolLink + settingError atomic.Pointer[settingResult] + clientTools clientToolLink savingNotices []string @@ -1599,10 +1600,15 @@ type Agent struct{ c *Client } // CheckWork reads the session's own gate. An older engine has no new call. func (a *Agent) CheckWork() error { + return a.CheckWorkContext(context.Background()) +} + +// CheckWorkContext bounds an ownership check made before a surface sends. +func (a *Agent) CheckWorkContext(ctx context.Context) error { if !a.c.Welcome().WorkAdmission { return nil } - _, err := a.c.call(context.Background(), MethodCheckWork, nil) + _, err := a.c.call(ctx, MethodCheckWork, nil) return err } @@ -2296,8 +2302,31 @@ func (a *Agent) ModelIdentity() session.ModelIdentity { // what is assumed here; the assumption only covers the round trip, which is the // gap in which a person who pressed a key is looking at the row it changed. func (a *Agent) SetModel(model string) { + a.c.settingError.Store(&settingResult{err: a.SetModelChecked(model)}) +} + +// SetModelChecked reports this call's ownership refusal with the ordinary RPC policy. +func (a *Agent) SetModelChecked(model string) error { + previous := a.Model() a.c.facts.setModel(model) - _, _ = a.c.call(nil, MethodSetModel, model) + _, err := a.c.call(nil, MethodSetModel, model) + var refusal *executor.WorkRefusal + if !errors.As(err, &refusal) { + err = nil + } else { + a.c.facts.setModel(previous) + } + return err +} + +type settingResult struct{ err error } + +// SettingError reads the last ownership refusal without crossing the wire. +func (a *Agent) SettingError() error { + if result := a.c.settingError.Load(); result != nil { + return result.err + } + return nil } // SetSpendRail waits for the engine to bind the new conversation limit before diff --git a/internal/remote/effort.go b/internal/remote/effort.go index 6d4749948e..3ab73741f3 100644 --- a/internal/remote/effort.go +++ b/internal/remote/effort.go @@ -1,6 +1,11 @@ package remote -import "encoding/json" +import ( + "encoding/json" + "errors" + + "github.com/Agent-Field/codeaf/internal/executor" +) // ── THE CONVERSATION'S THINKING RUNG, ACROSS THE WIRE ─────────────────────── // @@ -102,19 +107,34 @@ func (a *Agent) ConversationEffort() string { // — and the engine's own push is still on the wire at that moment. A read that // waited for the push would report the rung from before the keystroke. func (a *Agent) SetConversationEffort(rung string) bool { + took, err := a.SetConversationEffortChecked(rung) + a.c.settingError.Store(&settingResult{err: err}) + return took +} + +func (a *Agent) SetConversationEffortChecked(rung string) (bool, error) { + took, err := a.setConversationEffort(rung) + var refusal *executor.WorkRefusal + if !errors.As(err, &refusal) { + err = nil + } + return took, err +} + +func (a *Agent) setConversationEffort(rung string) (bool, error) { if !a.EffortSupported() { - return false + return false, nil } payload, err := a.c.call(nil, MethodSetEffort, rung) if err != nil { - return false + return false, err } var took bool if err := json.Unmarshal(payload, &took); err != nil || !took { - return false + return false, nil } a.c.facts.setThinking(a.farResolvedEffort()) - return true + return true, nil } // farResolvedEffort asks the engine what the rung resolved to, once, on the diff --git a/internal/remote/idle_r10_retry_test.go b/internal/remote/idle_r10_retry_test.go new file mode 100644 index 0000000000..bf02b67f29 --- /dev/null +++ b/internal/remote/idle_r10_retry_test.go @@ -0,0 +1,23 @@ +package remote + +import ( + "errors" + "testing" + + "github.com/Agent-Field/codeaf/internal/executor" +) + +func TestIdleRelease_R10_RemoteKeepsRetryableOwnershipRefusal(t *testing.T) { + want := &executor.WorkRefusal{Line: "couldn't confirm taking this chat back from the sync service; try again", Retryable: true} + far := &viewerAdmissionAgent{fakeAgent: &fakeAgent{}, refusal: want} + loop, err := Loopback(Hello{Version: Version}, Options{Boot: func(Hello) (*Engine, error) { return &Engine{Agent: far}, nil }}) + if err != nil { + t.Fatal(err) + } + defer loop.Close() + err = loop.Client.Agent().CheckWork() + var got *executor.WorkRefusal + if !errors.As(err, &got) || *got != *want { + t.Fatalf("lost retryable refusal over RPC: %+v, %v", got, err) + } +} diff --git a/internal/remote/idle_settings_test.go b/internal/remote/idle_settings_test.go new file mode 100644 index 0000000000..c8a34cfa80 --- /dev/null +++ b/internal/remote/idle_settings_test.go @@ -0,0 +1,134 @@ +package remote + +import ( + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/executor" +) + +type refusedSettingsAgent struct { + *rungAgent + refusal *executor.WorkRefusal +} + +func (a *refusedSettingsAgent) SetModel(string) {} +func (a *refusedSettingsAgent) SetConversationEffort(string) bool { return false } +func (a *refusedSettingsAgent) SettingError() error { return a.refusal } + +func (a *refusedSettingsAgent) SetModelChecked(string) error { return a.refusal } +func (a *refusedSettingsAgent) SetConversationEffortChecked(string) (bool, error) { + return false, a.refusal +} + +func TestIdleRelease_R5_1_RemoteSettingsKeepRefusalAndReplica(t *testing.T) { + want := &executor.WorkRefusal{Line: "laptop continued this chat", HolderID: "B", HolderName: "laptop"} + far := &refusedSettingsAgent{rungAgent: &rungAgent{fakeAgent: &fakeAgent{model: "old/model"}, stored: "low"}, refusal: want} + loop, err := Loopback(Hello{Version: Version}, Options{Boot: func(Hello) (*Engine, error) { return &Engine{Agent: far}, nil }}) + if err != nil { + t.Fatal(err) + } + defer loop.Close() + a := loop.Client.Agent() + for _, call := range []func() error{ + func() error { return a.SetModelChecked("new/model") }, + func() error { _, err := a.SetConversationEffortChecked("high"); return err }, + func() error { a.SetModel("new/model"); return a.SettingError() }, + func() error { + if a.SetConversationEffort("high") { + t.Error("refused effort acknowledged") + } + return a.SettingError() + }, + } { + var got *executor.WorkRefusal + if err := call(); !errors.As(err, &got) || *got != *want { + t.Fatalf("setting refusal=%+v, %v", got, err) + } + } + if a.Model() != "old/model" || a.ResolvedEffort() != "low" || far.Model() != "old/model" || far.stored != "low" { + t.Fatal("refusal changed the model or effort") + } +} + +// This pause models preemption after a legacy setter records its error. Two +// windows share the agent but have independent ordered request lanes. +type idleR8ResultAgent struct { + *rungAgent + chosen atomic.Value + result atomic.Pointer[executor.WorkRefusal] + refusal *executor.WorkRefusal + entered chan struct{} + release chan struct{} +} + +func (a *idleR8ResultAgent) Model() string { return a.chosen.Load().(string) } +func (a *idleR8ResultAgent) Close() error { return nil } +func (a *idleR8ResultAgent) SetModel(model string) { + if model == "denied/model" { + a.result.Store(a.refusal) + close(a.entered) + <-a.release + return + } + a.chosen.Store(model) + a.result.Store(nil) +} +func (a *idleR8ResultAgent) SettingError() error { + if result := a.result.Load(); result != nil { + return result + } + return nil +} +func (a *idleR8ResultAgent) SetModelChecked(model string) error { + if model == "denied/model" { + close(a.entered) + <-a.release + return a.refusal + } + a.chosen.Store(model) + return nil +} + +func TestIdleRelease_R8_ConcurrentModelRPCsKeepTheirOwnResult(t *testing.T) { + want := &executor.WorkRefusal{Line: "another computer continued this chat", HolderID: "B"} + far := &idleR8ResultAgent{rungAgent: &rungAgent{fakeAgent: &fakeAgent{model: "old/model"}, stored: "low"}, refusal: want, entered: make(chan struct{}), release: make(chan struct{})} + far.chosen.Store("old/model") + open := func() *Agent { + loop, err := Loopback(Hello{Version: Version}, Options{Boot: func(Hello) (*Engine, error) { return &Engine{Agent: far}, nil }}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = loop.Close() }) + return loop.Client.Agent() + } + first, second := open(), open() + finish := sync.OnceFunc(func() { close(far.release) }) + defer finish() + done := make(chan error, 1) + go func() { done <- first.SetModelChecked("denied/model") }() + select { + case <-far.entered: + case <-time.After(5 * time.Second): + t.Fatal("first window did not enter the setter") + } + if err := second.SetModelChecked("accepted/model"); err != nil { + t.Fatal(err) + } + finish() + select { + case err := <-done: + var got *executor.WorkRefusal + if !errors.As(err, &got) || *got != *want { + t.Fatalf("first window lost its own refusal: %+v, %v", got, err) + } + case <-time.After(5 * time.Second): + t.Fatal("first window did not finish") + } + if first.Model() == "denied/model" || far.Model() != "accepted/model" { + t.Fatalf("refused model acknowledged: first=%q owner=%q", first.Model(), far.Model()) + } +} diff --git a/internal/remote/server.go b/internal/remote/server.go index d7e3dd70fd..dd4859d921 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -3202,7 +3202,13 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { if refresh := sess.engine.RefreshModelSources; refresh != nil { refresh() } - agent.SetModel(model) + if checked, ok := agent.(interface{ SetModelChecked(string) error }); ok { + if err := checked.SetModelChecked(model); err != nil { + return nil, err + } + } else { + agent.SetModel(model) + } // AND EVERY SURFACE IS TOLD, not only the one that turned the knob. Two // windows on one conversation is a shape this protocol supports // ([Welcome.Attached]), and a model changed in one of them is a fact the @@ -3282,7 +3288,15 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { if err != nil { return nil, err } - took := door.SetConversationEffort(rung) + var took bool + if checked, ok := agent.(interface{ SetConversationEffortChecked(string) (bool, error) }); ok { + took, err = checked.SetConversationEffortChecked(rung) + if err != nil { + return nil, err + } + } else { + took = door.SetConversationEffort(rung) + } // AND EVERY SURFACE IS TOLD, on [MethodSetModel]'s terms: the rung rides // the fact set every window on this conversation draws from, and a dial // moved in one of them is a cell the others are painting right now. diff --git a/internal/remote/work_admission_test.go b/internal/remote/work_admission_test.go index 07fc09ea6e..2e29d8b17f 100644 --- a/internal/remote/work_admission_test.go +++ b/internal/remote/work_admission_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "testing" + "time" "github.com/Agent-Field/codeaf/internal/executor" "github.com/Agent-Field/codeaf/internal/session" @@ -95,3 +96,26 @@ func TestC1RemoteViewerAcceptsEveryAnswerDoor(t *testing.T) { t.Fatalf("answers did not reach the producer: %+v", far) } } + +type waitingWorkAgent struct { + *fakeAgent + release chan struct{} +} + +func (a *waitingWorkAgent) CheckWork() error { <-a.release; return nil } +func TestIdleRelease_C3_D1_RemoteWorkAdmissionHonorsContext(t *testing.T) { + far := &waitingWorkAgent{fakeAgent: &fakeAgent{}, release: make(chan struct{})} + loop, err := Loopback(Hello{Version: Version}, Options{Boot: func(Hello) (*Engine, error) { return &Engine{Agent: far}, nil }}) + if err != nil { + t.Fatal(err) + } + defer loop.Close() + defer close(far.release) + ctx, cancel := context.WithTimeout(t.Context(), 25*time.Millisecond) + defer cancel() + start := time.Now() + err = loop.Client.Agent().CheckWorkContext(ctx) + if !errors.Is(err, context.DeadlineExceeded) || time.Since(start) > 150*time.Millisecond { + t.Fatalf("bounded remote admission: %v, %s", err, time.Since(start)) + } +} diff --git a/internal/session/agent.go b/internal/session/agent.go index 7cb739a066..2f9db41492 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -564,6 +564,9 @@ func newAgent(config Config, client Completer) (*Agent, error) { agent.mu.Lock() agent.opened = true agent.mu.Unlock() + if idle, ok := config.Seals.(interface{ SetIdleCheck(func() bool) }); ok && !config.InTask && !config.Errand { + idle.SetIdleCheck(agent.idleForHandoff) + } // THE WALL IS READ AFTER THE SESSION IS OPEN. Recovery may put work back on // the frontier, but no ending or notice may run before the caller can hold // the agent and a surface can subscribe to its standing lane. @@ -801,7 +804,38 @@ func (a *Agent) Model() string { // this turn. [Agent.scrubBlindImagePartsLocked] states the whole rule and its // three deliberate limits — the journal is untouched, the swap is one-way, and a // model that CAN see is handed everything unchanged. -func (a *Agent) SetModel(model string) { a.setModel(model) } +func (a *Agent) SetModel(model string) { + a.settingsWrites.Add(1) + defer a.settingsWrites.Add(-1) + err := a.CheckSettingsWork() + a.settingError.Store(&settingResult{err: err}) + if err != nil { + return + } + a.setModel(model) +} + +// SettingError is the local result of a legacy bool or void setting door. +// Reading it never asks an engine or a relay; checked callers use their error. +type settingResult struct{ err error } + +func (a *Agent) SettingError() error { + if result := a.settingError.Load(); result != nil { + return result.err + } + return nil +} + +// SetModelChecked retains the requested choice when ownership refuses a save. +func (a *Agent) SetModelChecked(model string) error { + a.settingsWrites.Add(1) + defer a.settingsWrites.Add(-1) + if err := a.CheckSettingsWork(); err != nil { + return err + } + a.setModel(model) + return nil +} // setModel is SetModel with the answer to "when does this land", which the doors // inside this package that have somebody to tell need ([Agent.RetargetTask]) and @@ -1127,6 +1161,8 @@ func (a *Agent) Submit(ctx context.Context, text string) (<-chan Event, error) { // checked before journaling and again when a queued message starts its turn. func (a *Agent) CheckWork() error { return procexec.CheckWork(a.config.Seat) } +func (a *Agent) CheckSettingsWork() error { return procexec.CheckSettingsWork(a.config.Seat) } + // submitText is Submit's body with the PERSON'S MARK beside the words: what a // door that knows more than the words said about the message // ([Agent.SubmitMessage]), nil for every other door. The mark rides the message @@ -2586,6 +2622,11 @@ func (a *Agent) Compact(ctx context.Context) error { // (internal/tui3), and a person who types the old form gets the pass they asked // for rather than an error about a machine that used to exist. func (a *Agent) CompactWithFocus(ctx context.Context, _ string) error { + a.settingsWrites.Add(1) + defer a.settingsWrites.Add(-1) + if err := a.CheckSettingsWork(); err != nil { + return err + } _, skipped, err := a.compactWithPolicyResult(ctx, nil, a.requestedCompactPolicy()) if err == nil && skipped != "" { return &SummarySkipped{Why: skipped} diff --git a/internal/session/appnames.go b/internal/session/appnames.go index ff641b1dda..d86de5bb96 100644 --- a/internal/session/appnames.go +++ b/internal/session/appnames.go @@ -49,6 +49,11 @@ func (a *Agent) RenameByPerson(title string) error { if title == "" { return errors.New("a name needs some words") } + a.settingsWrites.Add(1) + defer a.settingsWrites.Add(-1) + if err := a.CheckSettingsWork(); err != nil { + return err + } a.mu.Lock() if a.closed { a.mu.Unlock() diff --git a/internal/session/approvalposture.go b/internal/session/approvalposture.go index 7d51974518..ad63279df8 100644 --- a/internal/session/approvalposture.go +++ b/internal/session/approvalposture.go @@ -155,6 +155,11 @@ func (a *Agent) StandingApprovalPosture() string { // refuses a nil: the answer to "I could not read the rules" is never a session // whose posture and gate disagree. func (a *Agent) SetApprovalPosture(posture string) error { + a.settingsWrites.Add(1) + defer a.settingsWrites.Add(-1) + if err := a.CheckSettingsWork(); err != nil { + return err + } word := strings.ToLower(strings.TrimSpace(posture)) if !knownPosture(word) { return fmt.Errorf("%q is not a posture. Use one of: %s", posture, strings.Join(ApprovalPostures, ", ")) diff --git a/internal/session/autonomy.go b/internal/session/autonomy.go index 08c0e985c9..1192b2cf0a 100644 --- a/internal/session/autonomy.go +++ b/internal/session/autonomy.go @@ -55,6 +55,11 @@ func (a *Agent) legacyAutonomyFile() string { // SetAutonomy is the one door surfaces use for the D-key promise. func (a *Agent) SetAutonomy(kind AskKind, policy Policy) error { + a.settingsWrites.Add(1) + defer a.settingsWrites.Add(-1) + if err := a.CheckSettingsWork(); err != nil { + return err + } // THE TWO ROWS NOBODY MAY CHANGE, refused at the door that writes them so // that no surface has to hold a second copy of the rule. // diff --git a/internal/session/compaction_facts.go b/internal/session/compaction_facts.go index 064b09c155..78b7dda67c 100644 --- a/internal/session/compaction_facts.go +++ b/internal/session/compaction_facts.go @@ -147,6 +147,11 @@ func (a *Agent) ContextUse() ContextUse { // for the length of the pass ([Agent.manualPass]) and Interrupt, StopWork and // Close cut it too: the model request ends, not only what a surface draws. func (a *Agent) CompactObserved(ctx context.Context, observe func(Event)) error { + a.settingsWrites.Add(1) + defer a.settingsWrites.Add(-1) + if err := a.CheckSettingsWork(); err != nil { + return err + } ctx, cancel := context.WithCancel(ctx) defer cancel() cut := &manualPassCut{cancel: cancel, done: make(chan struct{})} diff --git a/internal/session/conversation_models.go b/internal/session/conversation_models.go index 0c09edc900..60075077bd 100644 --- a/internal/session/conversation_models.go +++ b/internal/session/conversation_models.go @@ -43,6 +43,11 @@ func (a *Agent) ConversationModelPair() (ConversationModels, bool) { // SetConversationModels changes future requests only. In particular it never // calls hearModelLocked, whose terminal behavior may cancel an unproductive stream. func (a *Agent) SetConversationModels(pair ConversationModels) ([]ModelSwitch, error) { + a.settingsWrites.Add(1) + defer a.settingsWrites.Add(-1) + if err := a.CheckSettingsWork(); err != nil { + return nil, err + } return a.setConversationModels(pair, "") } diff --git a/internal/session/effort.go b/internal/session/effort.go index 3d47fef5ac..172e60e57b 100644 --- a/internal/session/effort.go +++ b/internal/session/effort.go @@ -79,23 +79,33 @@ func (a *Agent) ResolvedEffort() string { // the model (loop.go), so a change made while the agent is working lands on the // next call and never half-way through the one being answered. func (a *Agent) SetConversationEffort(rung string) bool { + took, err := a.SetConversationEffortChecked(rung) + a.settingError.Store(&settingResult{err: err}) + return took +} + +// SetConversationEffortChecked reports an ownership refusal before mutation. +func (a *Agent) SetConversationEffortChecked(rung string) (bool, error) { + a.settingsWrites.Add(1) + defer a.settingsWrites.Add(-1) parsed, ok := effort.Parse(rung) if !ok { - return false + return false, nil } - a.mu.Lock() - a.effort = parsed - a.mu.Unlock() + if err := a.CheckSettingsWork(); err != nil { + return false, err + } + func() { a.mu.Lock(); defer a.mu.Unlock(); a.effort = parsed }() // Reapplying a stored dial on resume changes memory, not the chat's files. if a.config.Place.Dir != "" { if meta, err := LoadMeta(a.config.Place.Dir); err == nil && meta.Effort == parsed.String() { - return true + return true, nil } } // Written through the same door every other fact about this conversation is // written through, so the file keeps one shape and one writer. a.stampEffort() - return true + return true, nil } // DefaultEffort is the install's own rung, as this session was launched with. @@ -134,6 +144,11 @@ func (a *Agent) TaskEffort(id uint64) string { // next worker this task starts — a retry, a repair, a part handed out after the // change — and never reshapes a request already on the wire. func (a *Agent) SetTaskEffort(id uint64, rung string) error { + a.settingsWrites.Add(1) + defer a.settingsWrites.Add(-1) + if err := a.CheckSettingsWork(); err != nil { + return err + } parsed, ok := effort.Parse(rung) if !ok { return fmt.Errorf("%q is not a thinking level. Use one of: %s, or auto", diff --git a/internal/session/idle_handoff.go b/internal/session/idle_handoff.go new file mode 100644 index 0000000000..545ab070d3 --- /dev/null +++ b/internal/session/idle_handoff.go @@ -0,0 +1,104 @@ +package session + +import ( + "sync" + "sync/atomic" +) + +// sessionJobs retains the existing join while allowing an idle handoff to +// observe writers without waiting for a model or adding a waiter goroutine. +type sessionJobs struct { + sync.WaitGroup + pending atomic.Int64 +} + +func (j *sessionJobs) Add(n int) { + j.pending.Add(int64(n)) + j.WaitGroup.Add(n) +} + +func (j *sessionJobs) Done() { + j.WaitGroup.Done() + j.pending.Add(-1) +} + +// idleForHandoff is conservative while any activity lock is busy. Admission +// calls the ownership gate before recording work; refusing a busy snapshot +// avoids taking that gate's lock in the opposite order to a session writer. +func (a *Agent) idleForHandoff() bool { + if !a.mu.TryLock() { + return false + } + idle := a.opened && !a.closed && !a.running && len(a.steering) == 0 && len(a.followups) == 0 && len(a.ambient) == 0 && len(a.settling) == 0 && !a.jobWakePending + graph, jobs := a.tasks, a.jobs + if a.config.tasker != nil { + graph = a.config.tasker + } + a.mu.Unlock() + if !idle || a.settingsWrites.Load() != 0 || a.taskStarting.Load() != 0 || a.titleJobs.pending.Load() != 0 || a.memoryJobs.pending.Load() != 0 || a.orchestrateWorkers.pending.Load() != 0 { + return false + } + if !a.beltStartMu.TryLock() { + return false + } + a.beltStartMu.Unlock() + if !a.beltMu.TryLock() { + return false + } + belt := a.beltRun != nil + a.beltMu.Unlock() + if belt { + return false + } + if graph != nil { + if !graph.mu.TryLock() { + return false + } + idle = graph.runners.pending.Load() == 0 + for _, node := range graph.nodes { + if node != nil && !node.state.settled() { + idle = false + break + } + } + for _, row := range graph.runRowsLocked() { + if !row.State.settled() { + idle = false + break + } + } + graph.mu.Unlock() + if !idle { + return false + } + } + if jobs != nil { + if !jobs.mu.TryLock() { + return false + } + for _, job := range jobs.jobs { + select { + case <-job.done: + default: + jobs.mu.Unlock() + return false + } + } + jobs.mu.Unlock() + } + // Readiness must never join deferred disk work on an admission boundary. + if !a.metaStamp().idle() || !a.modelStamp().idle() || !a.spendStamp().idle() || !a.toldStamp().idle() || !a.treesAhead().Idle() { + return false + } + for _, store := range []*fixStore{a.fixShelfFor().project, a.fixShelfFor().global} { + if store != nil && !store.counters().Idle() { + return false + } + } + lane := &a.team.events + if !lane.mu.TryLock() { + return false + } + defer lane.mu.Unlock() + return len(lane.queue) == 0 && lane.done == nil +} diff --git a/internal/session/idle_handoff_test.go b/internal/session/idle_handoff_test.go new file mode 100644 index 0000000000..cbc3a65b98 --- /dev/null +++ b/internal/session/idle_handoff_test.go @@ -0,0 +1,96 @@ +package session + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/cellstore" +) + +func TestIdleRelease_C2_RunningQueuedAndSteeringWorkRetainOwnership(t *testing.T) { + entered, finish := make(chan struct{}), make(chan struct{}) + watch := &cellstore.SealWatch{} + watch.Waiting() + a, _ := newTestAgent(t, &scriptedCompleter{steps: []step{ + func(ctx context.Context, _ []ai.Message) (*ai.Response, error) { + close(entered) + select { + case <-finish: + return textResponse("first answer"), nil + case <-ctx.Done(): + return nil, ctx.Err() + } + }, + }}, func(cfg *Config) { cfg.Seals = watch }) + waitFor(t, "initial saved session", watch.IdleReady) + turn, err := a.Submit(t.Context(), "work until released") + if err != nil { + t.Fatal(err) + } + select { + case <-entered: + case <-time.After(10 * time.Second): + t.Fatal("model did not start") + } + if watch.IdleReady() { + t.Fatal("running turn released ownership") + } + queued, err := a.FollowUp("queued work") + if err != nil { + t.Fatal(err) + } + steered, err := a.Submit(t.Context(), "steering work") + if err != nil { + t.Fatal(err) + } + if watch.IdleReady() { + t.Fatal("queued or steering work released ownership") + } + close(finish) + collect(t, turn) + collect(t, queued) + collect(t, steered) + waitFor(t, "all queued work to settle", watch.IdleReady) +} + +func TestIdleRelease_C2_BackgroundJobRetainsOwnership(t *testing.T) { + watch := &cellstore.SealWatch{} + watch.Waiting() + a, _ := newTestAgent(t, &scriptedCompleter{}, func(cfg *Config) { cfg.Seals = watch }) + id := startJob(t, a, "sleep 30") + if watch.IdleReady() { + t.Fatal("running background job released ownership") + } + runTool(t, a, "jobs", fmt.Sprintf(`{"op":"kill","id":%d}`, id)) +} + +func TestIdleRelease_C2_BackgroundTaskRetainsOwnership(t *testing.T) { + watch := &cellstore.SealWatch{} + watch.Waiting() + entered := make(chan struct{}, 8) + a, _ := newTestAgent(t, &scriptedCompleter{steps: []step{ + func(ctx context.Context, _ []ai.Message) (*ai.Response, error) { + entered <- struct{}{} + <-ctx.Done() + return nil, ctx.Err() + }, + }}, func(cfg *Config) { + cfg.Seals = watch + cfg.Workspace = newTestRepo(t) + cfg.TaskAudit = false + }) + if _, _, _, err := a.StartTask(t.Context(), "read the workspace and report", true); err != nil { + t.Fatal(err) + } + select { + case <-entered: + case <-time.After(10 * time.Second): + t.Fatal("background task did not reach its model") + } + if watch.IdleReady() { + t.Fatal("running task released ownership") + } +} diff --git a/internal/session/idle_settings_test.go b/internal/session/idle_settings_test.go new file mode 100644 index 0000000000..7c5d203c0f --- /dev/null +++ b/internal/session/idle_settings_test.go @@ -0,0 +1,127 @@ +package session + +import ( + "errors" + "path/filepath" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/executor" +) + +func TestIdleRelease_R5_1_PersistentChoicesRefuseBeforeMutation(t *testing.T) { + want := &executor.WorkRefusal{Line: "laptop continued this chat", HolderID: "B"} + a, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.Seat = executor.Gated(c.Seat, func() error { return nil }, nil, nil, func() error { return want }) + }) + model, effort := a.Model(), a.ConversationEffort() + for _, check := range []struct { + name string + run func() error + }{ + {"model", func() error { return a.SetModelChecked("new/model") }}, + {"effort", func() error { _, err := a.SetConversationEffortChecked("high"); return err }}, + {"models", func() error { _, err := a.SetConversationModels(ConversationModels{Main: "new/model"}); return err }}, + {"automatic models", func() error { return a.SetAutomaticModels(true) }}, + {"approval", func() error { return a.SetApprovalPosture(PostureAsk) }}, + {"refer place", func() error { _, err := a.ReferPlace(t.TempDir(), PlaceSaid); return err }}, + {"remove place", func() error { return a.RemovePlace("kept") }}, + {"place mode", func() error { return a.SetPlaceMode("kept", "read") }}, + {"task effort", func() error { return a.SetTaskEffort(1, "high") }}, + {"memory", func() error { _, err := a.Remember("keep my requested words"); return err }}, + } { + t.Run(check.name, func(t *testing.T) { + if err := check.run(); !errors.Is(err, want) { + t.Fatalf("setting refusal=%v", err) + } + }) + } + if a.Model() != model || a.ConversationEffort() != effort { + t.Fatal("refused choice mutated the conversation") + } + a.SetModel("another/model") + if !errors.Is(a.SettingError(), want) { + t.Fatal("void model door lost the refusal") + } + if a.SetConversationEffort("high") || !errors.Is(a.SettingError(), want) { + t.Fatal("bool effort door lost the refusal") + } +} + +func TestIdleRelease_R5_1_SettingAdmissionRetainsTheHoldWhileBusy(t *testing.T) { + entered, release := make(chan struct{}), make(chan struct{}) + watch := &cellstore.SealWatch{} + watch.Waiting() + a, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.Seals = watch + c.Seat = executor.Gated(c.Seat, func() error { return nil }, nil, nil, func() error { close(entered); <-release; return nil }) + }) + a.SettleWrites() + done := make(chan bool, 1) + go func() { done <- a.SetConversationEffort("high") }() + defer func() { close(release); <-done }() + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("setting did not enter admission") + } + if watch.IdleReady() { + t.Fatal("setting admission still owns the choice, but the chat looks idle") + } +} + +func TestIdleRelease_R8_DurableWritesOutsideTurnsRefuseBeforeMutation(t *testing.T) { + want := &executor.WorkRefusal{Line: "laptop continued this chat", HolderID: "B"} + a, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.SessionFile = filepath.Join(t.TempDir(), "session.jsonl") + c.Seat = executor.Gated(c.Seat, func() error { return nil }, nil, nil, func() error { return want }) + }) + for _, check := range []struct { + name string + run func() error + }{ + {"rename", func() error { return a.RenameByPerson("person's title") }}, + {"rewind", func() error { _, err := a.Rewind(); return err }}, + {"rewind at", func() error { _, err := a.RewindAt(0); return err }}, + {"rewind keep", func() error { _, err := a.RewindKeep(0); return err }}, + {"undo rewind", func() error { return a.UndoRewind("token") }}, + {"compact", func() error { return a.Compact(t.Context()) }}, + {"observed compact", func() error { return a.CompactObserved(t.Context(), nil) }}, + {"anchor", func() error { _, err := a.AnchorWorkspace(t.TempDir()); return err }}, + {"move memory", func() error { _, err := a.MoveMemory("id", "project"); return err }}, + {"plan note", func() error { return a.PlanNote("id", "person's note") }}, + {"delete task", func() error { return a.DeleteTask("id") }}, + } { + t.Run(check.name, func(t *testing.T) { + if err := check.run(); !errors.Is(err, want) { + t.Fatalf("durable write refusal=%v", err) + } + }) + } +} + +func TestIdleRelease_R8_MoreDurableWritesOutsideTurnsRefuse(t *testing.T) { + want := &executor.WorkRefusal{Line: "laptop continued this chat", HolderID: "B"} + a, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.Seat = executor.Gated(c.Seat, func() error { return nil }, nil, nil, func() error { return want }) + }) + for _, check := range []struct { + name string + run func() error + }{ + {"resolve unverified", func() error { return a.ResolveUnverified(1, TaskAccept, "accepted") }}, + {"review root", func() error { _, err := a.ReviewTaskWorkspaceRoot(t.Context(), 1, "slot"); return err }}, + {"apply root", func() error { _, err := a.ApplyTaskWorkspaceRoot(t.Context(), 1, "slot"); return err }}, + {"task model", func() error { _, err := a.RetargetTask(1, "new/model"); return err }}, + {"conflict resolution", func() error { return a.ResolveConflict(1) }}, + {"land", func() error { _, err := a.Land("folder"); return err }}, + {"autonomy", func() error { return a.SetAutonomy(AskConfirmation, Policy{Kind: PolicyAsk}) }}, + } { + t.Run(check.name, func(t *testing.T) { + if err := check.run(); !errors.Is(err, want) { + t.Fatalf("durable write refusal=%v", err) + } + }) + } +} diff --git a/internal/session/idle_start_test.go b/internal/session/idle_start_test.go new file mode 100644 index 0000000000..7933104b52 --- /dev/null +++ b/internal/session/idle_start_test.go @@ -0,0 +1,75 @@ +package session + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/crewroute" +) + +func TestIdleRelease_C2_ParkedTaskRetainsOwnership(t *testing.T) { + w := &cellstore.SealWatch{} + w.Waiting() + a, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.Seals = w }) + a.SettleWrites() + if !w.IdleReady() { + t.Fatal("fixture did not become idle") + } + p := &stagedProposal{id: a.graph().reserve()} + _, parked := a.parkUnroutedHandoff(context.Background(), p, taskSpec{title: "Waiting task", brief: "repair fixtures"}, "", nil, &crewroute.NoEligibleError{Seat: crewroute.Checker, Mode: crewroute.ModeAuto}) + if !parked { + t.Fatal("fixture did not park the task") + } + row, ok := a.appRunRow(p.id) + if !ok || row.State != TaskQueued { + t.Fatalf("fixture lost queued task: %+v", row) + } + a.SettleWrites() + if w.IdleReady() { + t.Errorf("queued task state=%s waiting=%s, but IdleReady=true", row.State, row.Waiting) + } +} + +func TestIdleRelease_C2_TaskPreparationRetainsOwnership(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + double := newBeltRunDouble("done") + registerBeltRunEngine(t, double) + w := &cellstore.SealWatch{} + w.Waiting() + repo, dir := newTestRepo(t), t.TempDir() + a, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.Workspace = repo + c.Place = Place{Dir: dir} + c.SessionFile = filepath.Join(dir, placeTranscript) + c.Seals = w + c.TaskMaxLoad, c.TaskMinFreeMB = 0, 0 + }) + a.SettleWrites() + entered, release := make(chan struct{}), make(chan struct{}) + previous := beltRunPrepare + beltRunPrepare = func(context.Context, Place, string, string, uint64, string, taskStand) (taskTree, error) { + close(entered) + <-release + return taskTree{}, errors.New("review preparation stopped") + } + t.Cleanup(func() { beltRunPrepare = previous }) + done := make(chan error, 1) + go func() { _, _, _, err := a.StartTask(t.Context(), "repair fixtures", true); done <- err }() + select { + case <-entered: + case err := <-done: + t.Fatalf("did not reach preparation: %v", err) + case <-time.After(10 * time.Second): + t.Fatal("preparation did not start") + } + idle := w.IdleReady() + close(release) + <-done + if idle { + t.Error("StartTask is still preparing its working copy, but IdleReady=true") + } +} diff --git a/internal/session/memory.go b/internal/session/memory.go index d7f90da895..6bf21d4b56 100644 --- a/internal/session/memory.go +++ b/internal/session/memory.go @@ -762,6 +762,11 @@ func (a *Agent) RememberAlways(text string, everywhere bool) (string, error) { } func (a *Agent) rememberAs(text, scope string) (string, error) { + a.settingsWrites.Add(1) + defer a.settingsWrites.Add(-1) + if err := a.CheckSettingsWork(); err != nil { + return "", err + } memory, reach, added, err := a.keep(text, scope, store.OriginPerson, nil) if errors.Is(err, store.ErrMemoryFull) { return "", a.memoryFullError(err) @@ -813,6 +818,11 @@ func (a *Agent) AlwaysMemories(everywhere bool) ([]MemoryLine, error) { // or `project` (this conversation's project, or the person everywhere when it // has none). It answers the receipt. func (a *Agent) MoveMemory(id, scope string) (string, error) { + a.settingsWrites.Add(1) + defer a.settingsWrites.Add(-1) + if err := a.CheckSettingsWork(); err != nil { + return "", err + } if !a.memoryWritable() { return "", errors.New("this conversation keeps no memory") } diff --git a/internal/session/model_identity.go b/internal/session/model_identity.go index 65fb609cab..fdf86da372 100644 --- a/internal/session/model_identity.go +++ b/internal/session/model_identity.go @@ -173,6 +173,11 @@ func (a *Agent) automaticModelsLockReason() string { return "" } func (a *Agent) SetAutomaticModels(enabled bool) error { + a.settingsWrites.Add(1) + defer a.settingsWrites.Add(-1) + if err := a.CheckSettingsWork(); err != nil { + return err + } if reason := a.automaticModelsLockReason(); enabled && reason != "" { return errors.New(reason) } diff --git a/internal/session/placemeta.go b/internal/session/placemeta.go index a7842b6d5d..5eebc2f877 100644 --- a/internal/session/placemeta.go +++ b/internal/session/placemeta.go @@ -178,6 +178,15 @@ func (s *stampWriter) settle() { write.Settle() } +func (s *stampWriter) idle() bool { + if !s.mu.TryLock() { + return false + } + write := s.write + s.mu.Unlock() + return write.Idle() +} + // placeholderTitle cuts the folder's working name out of what a person said: // their first line, one space between the words, to the row's length. It is one // function because it is asked twice — once when they say it, and once when a @@ -273,7 +282,7 @@ func (a *Agent) updateMeta(dir string, snapshot Meta, patch func(*Meta)) bool { if dir == "" { return false } - return withMetaLock(dir, func() error { + err := withMetaLock(dir, func() error { meta, err := LoadMeta(dir) if err != nil { return err @@ -309,7 +318,13 @@ func (a *Agent) updateMeta(dir string, snapshot Meta, patch func(*Meta)) bool { } patch(&meta) return saveMetaLocked(dir, meta) - }) == nil + }) + if err == nil { + if watch, ok := a.config.Seals.(interface{ MetadataWrote() }); ok { + watch.MetadataWrote() + } + } + return err == nil } // fillMetaLocked fills in everything about a session that does not change while diff --git a/internal/session/places.go b/internal/session/places.go index 2efa519a20..43cbba6b34 100644 --- a/internal/session/places.go +++ b/internal/session/places.go @@ -142,6 +142,11 @@ func (a *Agent) Places() []PlaceRef { return a.referredPlaces() } // directory inside it. Chose preserves each independently selected context scope: // two subdirectories may share a working ground without becoming one attachment. func (a *Agent) ReferPlace(path string, arrival PlaceArrival) (PlaceRef, error) { + a.settingsWrites.Add(1) + defer a.settingsWrites.Add(-1) + if err := a.CheckSettingsWork(); err != nil { + return PlaceRef{}, err + } dir, err := a.placePath(path) if err != nil { return PlaceRef{}, err @@ -191,6 +196,11 @@ func (a *Agent) ReferPlace(path string, arrival PlaceArrival) (PlaceRef, error) // never on the set has been told something false about which folders are // attached. func (a *Agent) RemovePlace(path string) error { + a.settingsWrites.Add(1) + defer a.settingsWrites.Add(-1) + if err := a.CheckSettingsWork(); err != nil { + return err + } want := strings.TrimSpace(path) if want == "" { return fmt.Errorf("a place is a folder · this one has no path") @@ -236,6 +246,11 @@ func (a *Agent) RemovePlace(path string) error { // rather than invented: the mode is a fact ABOUT a place, so there has to be // one. func (a *Agent) SetPlaceMode(path, word string) error { + a.settingsWrites.Add(1) + defer a.settingsWrites.Add(-1) + if err := a.CheckSettingsWork(); err != nil { + return err + } dir, err := a.placePath(path) if err != nil { return err diff --git a/internal/session/plandb_steer.go b/internal/session/plandb_steer.go index 8775ce5c1f..15894437d8 100644 --- a/internal/session/plandb_steer.go +++ b/internal/session/plandb_steer.go @@ -52,6 +52,11 @@ func planNoTask(id string) error { // cancelled, a revision is only for work that has not started — travels back // as the store wrote it, because the store is the one that knows its own laws. func (a *Agent) planSteer(id string, write func(*plandb.Store, *plandb.Task) error, stopRoot ...bool) error { + a.settingsWrites.Add(1) + defer a.settingsWrites.Add(-1) + if err := a.CheckSettingsWork(); err != nil { + return err + } rootStop := len(stopRoot) > 0 && stopRoot[0] for _, notice := range a.historicalPlanNotices() { if planTaskID(notice.PlanTask) == planTaskID(id) && !rootStop { diff --git a/internal/session/resume.go b/internal/session/resume.go index e7f33b7dca..0c746f3cca 100644 --- a/internal/session/resume.go +++ b/internal/session/resume.go @@ -90,14 +90,15 @@ func resumesUnanswered(door StopDoor) bool { // ResumeStoppedTurn asks the person's last question again, once, when this // conversation was opened onto the exact shape resume.go describes, and reports -// whether it did. It answers nil and false in every other case, which is nearly -// every conversation ever opened. +// whether it did. An owed turn refused by work admission returns a refusal +// stream and false, retaining the turn for a later attempt. With nothing owed, +// it returns nil and false without asking the work gate. // -// THE FACT IS SPENT WHEN IT IS READ, whether or not a turn starts. A window that -// takes a conversation may attach to it more than once — the switcher brings it +// THE FACT IS SPENT AFTER ADMISSION. A window that takes a conversation may +// attach to it more than once — the switcher brings it // forward, the keeper hands it back — and a question asked twice is two answers // and two bills for one thing the person typed. It is cleared here, under the -// lock, before anything else can decide anything. +// lock, before another caller can start the same turn. // // THE MESSAGE IS THE ONE ALREADY IN THE TRANSCRIPT. Nothing is appended: the // person's words are in a.messages because the journal put them there, and in @@ -111,8 +112,17 @@ func resumesUnanswered(door StopDoor) bool { // stopped attempt's reasoning and half-written reply are simply gone, because // nothing kept them. func (a *Agent) ResumeStoppedTurn(ctx context.Context) (<-chan Event, bool) { + a.mu.Lock() + owed := resumesUnanswered(a.stoppedTurn) + _, hasQuestion := a.lastPersonMessageLocked() + a.mu.Unlock() + if !owed || !hasQuestion { + return nil, false + } + // Admission may reach the sync service, so it cannot hold the session + // lock. Recheck the journal shape under the lock before spending it. if err := a.CheckWork(); err != nil { - return refusedStream(err), true + return refusedStream(err), false } a.mu.Lock() door := a.stoppedTurn diff --git a/internal/session/resume_r6_test.go b/internal/session/resume_r6_test.go new file mode 100644 index 0000000000..32f2e89bfa --- /dev/null +++ b/internal/session/resume_r6_test.go @@ -0,0 +1,78 @@ +package session + +import ( + "context" + "errors" + "os" + "sync/atomic" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/executor" +) + +func TestR6_OpeningAChatThatOwesNoTurnNeverAsksAgain(t *testing.T) { + var checks atomic.Int32 + a, _ := newTestAgent(t, &scriptedCompleter{}, func(cfg *Config) { + cfg.Seat = executor.Gated(cfg.Seat, func() error { + checks.Add(1) + return &executor.WorkRefusal{Line: "chat held elsewhere"} + }) + }) + events, resumed := a.ResumeStoppedTurn(t.Context()) + if events != nil || resumed || checks.Load() != 0 { + t.Fatalf("opening with no owed turn returned stream=%v resumed=%v work checks=%d", events != nil, resumed, checks.Load()) + } +} + +func TestR6_OwedTurnRefusedKeepsTheTurnAndSaysItOnce(t *testing.T) { + journal := takenOverMidThought(t, func(a *Agent) { a.InterruptFor(StopByTakeover) }) + var allowed atomic.Bool + var calls atomic.Int32 + refusal := &executor.WorkRefusal{Line: "chat held elsewhere"} + a, _ := newTestAgent(t, &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { + calls.Add(1) + return textResponse("the kept question is answered"), nil + }, + }}, func(cfg *Config) { + cfg.SessionFile = journal + cfg.Seat = executor.Gated(cfg.Seat, func() error { + if allowed.Load() { + return nil + } + return refusal + }) + }) + a.SettleWrites() + before, err := os.ReadFile(journal) + if err != nil { + t.Fatal(err) + } + events, resumed := a.ResumeStoppedTurn(t.Context()) + if resumed || events == nil { + t.Fatalf("refused turn returned stream=%v resumed=%v", events != nil, resumed) + } + var refusals int + for _, event := range collect(t, events) { + if event.Kind == EventError && errors.Is(event.Err, refusal) { + refusals++ + } + } + if refusals != 1 || calls.Load() != 0 { + t.Fatalf("refusal events=%d model calls=%d", refusals, calls.Load()) + } + after, err := os.ReadFile(journal) + if err != nil || string(after) != string(before) { + t.Fatalf("resume refusal changed the journal: %v", err) + } + allowed.Store(true) + events, resumed = a.ResumeStoppedTurn(t.Context()) + if !resumed || events == nil { + t.Fatal("refusal lost the owed turn") + } + collect(t, events) + if events, resumed = a.ResumeStoppedTurn(t.Context()); resumed || events != nil || calls.Load() != 1 { + t.Fatalf("owed turn did not run exactly once: resumed=%v calls=%d", resumed, calls.Load()) + } +} diff --git a/internal/session/rewind.go b/internal/session/rewind.go index dcd8f2fad7..370f5746ba 100644 --- a/internal/session/rewind.go +++ b/internal/session/rewind.go @@ -59,6 +59,11 @@ var ErrNothingToRewind = errors.New("session: nothing to rewind") // keeps its own cutting path. THERE IS ONE CUT IN THIS PACKAGE, and every door // onto it only chooses where to put it. func (a *Agent) Rewind() ([]DisplayEntry, error) { + a.settingsWrites.Add(1) + defer a.settingsWrites.Add(-1) + if err := a.CheckSettingsWork(); err != nil { + return nil, err + } a.mu.Lock() defer a.mu.Unlock() if err := a.rewindReadyLocked(); err != nil { @@ -142,6 +147,11 @@ func (a *Agent) RewindPoints() []RewindPoint { // this package quietly remove a different turn. The error names the nearest // valid point so the caller can say what it should have asked for. func (a *Agent) RewindAt(index int) ([]DisplayEntry, error) { + a.settingsWrites.Add(1) + defer a.settingsWrites.Add(-1) + if err := a.CheckSettingsWork(); err != nil { + return nil, err + } a.mu.Lock() defer a.mu.Unlock() if err := a.rewindReadyLocked(); err != nil { @@ -351,6 +361,11 @@ type rewindUndo struct { // RewindKeep uses the terminal's cut and retains one tail until another turn or cut. func (a *Agent) RewindKeep(index int) (string, error) { + a.settingsWrites.Add(1) + defer a.settingsWrites.Add(-1) + if err := a.CheckSettingsWork(); err != nil { + return "", err + } a.mu.Lock() defer a.mu.Unlock() if err := a.rewindReadyLocked(); err != nil { @@ -384,6 +399,11 @@ func (a *Agent) RewindKeep(index int) (string, error) { // UndoRewind refuses once anything newer uses the shortened conversation. func (a *Agent) UndoRewind(token string) error { + a.settingsWrites.Add(1) + defer a.settingsWrites.Add(-1) + if err := a.CheckSettingsWork(); err != nil { + return err + } a.mu.Lock() defer a.mu.Unlock() if err := a.rewindReadyLocked(); err != nil { diff --git a/internal/session/session.go b/internal/session/session.go index 61a554a108..9f15cfbd30 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -2808,6 +2808,8 @@ type Config struct { // events — every Submit streams, whether it started the turn or steered it. // The methods live in agent.go; the loop they drive lives in loop.go. type Agent struct { + settingError atomic.Pointer[settingResult] + settingsWrites atomic.Int64 modelChanges sync.Mutex conversationModels *ConversationModels modelRequestSlot string @@ -3106,7 +3108,7 @@ type Agent struct { // atomic is "closed, therefore no new job" (see [Agent.startMemoryJob]). memoryCtx context.Context memoryStop context.CancelFunc - memoryJobs sync.WaitGroup + memoryJobs sessionJobs // laneStop cancels the session's lane context, including probes. laneDone // joins the optional sheet beat, whose cache writes must finish before Close @@ -3261,6 +3263,8 @@ type Agent struct { // tool calls in one turn that both find no copy would otherwise both make // one — leaving a registered worktree that nothing holds a record of. treeCut sync.Mutex + // taskStarting covers admission and preparation before a task has a row. + taskStarting atomic.Int64 // toldStanding is which folders' working copies THIS PROCESS has already // told the model about (standingbelt.go). It is not on the meta on purpose: // what it tracks is whether the model in front of it has been told, and a @@ -4008,7 +4012,7 @@ type Agent struct { // thing that can tell a run in flight that the session has left. // Admission and Close share mu: no accepted run can appear after the quit // starts waiting. The count covers setup, settlement and every child call. - orchestrateWorkers sync.WaitGroup + orchestrateWorkers sessionJobs orchestrateContext context.Context orchestrateStop context.CancelFunc orchestrateSeq uint64 @@ -4192,7 +4196,7 @@ type Agent struct { // "closed, therefore no new errand" ([Agent.startTitleJob]). titleCtx context.Context titleStop context.CancelFunc - titleJobs sync.WaitGroup + titleJobs sessionJobs // titleWatchers is the standing subscription to the name this session gives // itself, and it exists because THE NAME NOW ARRIVES AFTER THE TURN THAT // BOUGHT IT MAY HAVE ENDED. It is [Agent.harnessWatchers]' shape exactly diff --git a/internal/session/standingtree.go b/internal/session/standingtree.go index 93335d6c6e..6e2d031c09 100644 --- a/internal/session/standingtree.go +++ b/internal/session/standingtree.go @@ -687,6 +687,11 @@ func (a *Agent) standingName(name string) string { // conversation would go on writing into after its branch was kept would be work // piling up behind a landing that already failed. func (a *Agent) Land(folder string) (FolderLanding, error) { + a.settingsWrites.Add(1) + defer a.settingsWrites.Add(-1) + if err := a.CheckSettingsWork(); err != nil { + return FolderLanding{}, err + } name := a.standingName(folder) if name == "" { return FolderLanding{}, errors.New("nothing is waiting to go into a folder") diff --git a/internal/session/task_audit.go b/internal/session/task_audit.go index ffd71de6cb..5c83a89000 100644 --- a/internal/session/task_audit.go +++ b/internal/session/task_audit.go @@ -2566,6 +2566,11 @@ func (n *TaskNode) startedAt() time.Time { // It is exported because two callers need it: a surface with a person in front // of it, and the model through the `tasks` tool (tools_tasks.go). func (a *Agent) ResolveUnverified(id uint64, resolution TaskResolution, why string) error { + a.settingsWrites.Add(1) + defer a.settingsWrites.Add(-1) + if err := a.CheckSettingsWork(); err != nil { + return err + } return a.resolveUnverifiedBy(id, resolution, why, TaskAskOwnerPerson) } diff --git a/internal/session/task_delete.go b/internal/session/task_delete.go index cf3c9f8943..975ed82cb7 100644 --- a/internal/session/task_delete.go +++ b/internal/session/task_delete.go @@ -94,6 +94,11 @@ func DeleteTaskUnder(root, file, id string, owned func(string, string) (bool, er // DeleteTask keeps the conversation and siblings alive. Cancellation settles // workers before their journals and resumable records are removed. func (a *Agent) DeleteTask(id string) error { + a.settingsWrites.Add(1) + defer a.settingsWrites.Add(-1) + if err := a.CheckSettingsWork(); err != nil { + return err + } return deleteTaskRecord(a.config.SessionFile, id, a) } diff --git a/internal/session/task_merge_round.go b/internal/session/task_merge_round.go index 202f2f3829..e237e231c5 100644 --- a/internal/session/task_merge_round.go +++ b/internal/session/task_merge_round.go @@ -515,6 +515,11 @@ func (n *TaskNode) releaseResolving(gen uint64) { // has nothing to resolve IN, and a node whose round is already running must not // be given a second worker in the same directory as the first. func (a *Agent) ResolveConflict(id uint64) error { + a.settingsWrites.Add(1) + defer a.settingsWrites.Add(-1) + if err := a.CheckSettingsWork(); err != nil { + return err + } node := a.taskNode(id) if node == nil { if _, found, err := a.resolveCompletedRun(context.Background(), id, TaskAccept, "", TaskAskOwnerPerson); found { diff --git a/internal/session/task_person.go b/internal/session/task_person.go index 432485e1ad..bfa9642d78 100644 --- a/internal/session/task_person.go +++ b/internal/session/task_person.go @@ -72,6 +72,8 @@ type taskJudgeVerdict struct { // WHAT IS STILL THEIRS, WORD FOR WORD, is the summary under the row and the // request the worker is told outranks anything a model wrote. func (a *Agent) StartTask(ctx context.Context, brief string, solo bool) (uint64, string, string, error) { + a.taskStarting.Add(1) + defer a.taskStarting.Add(-1) if err := a.CheckWork(); err != nil { return 0, "", "", err } diff --git a/internal/session/task_room.go b/internal/session/task_room.go index eaa155c623..def11202ef 100644 --- a/internal/session/task_room.go +++ b/internal/session/task_room.go @@ -727,6 +727,11 @@ func (a *Agent) spoken(text string, waiting bool, origin messageOrigin, directio // this door was changed for was a step thirteen minutes into a wait the person // could do nothing about. func (a *Agent) RetargetTask(id uint64, model string) (ModelLanding, error) { + a.settingsWrites.Add(1) + defer a.settingsWrites.Add(-1) + if err := a.CheckSettingsWork(); err != nil { + return ModelLandsNextRequest, err + } model = strings.TrimSpace(model) if model == "" { return ModelLandsNextRequest, errors.New("no model to move to") diff --git a/internal/session/task_run.go b/internal/session/task_run.go index 3f925c6ede..14d00732e2 100644 --- a/internal/session/task_run.go +++ b/internal/session/task_run.go @@ -1161,7 +1161,7 @@ type TaskGraph struct { // [TaskGraph.handBackLane] WITHOUT settling, on purpose, so that recovery // resumes it — its `done` is never closed, and a stop that waited on that // channel would wait for something nobody is going to send. - runners sync.WaitGroup + runners sessionJobs // quitting closes the frontier: after it is set, nothing starts. The graph's // bounded stop sets it when the session quits so no goroutine can join the // wait already under way; the wall sets it because a run past its ceiling diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 566a446a62..85123fda93 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -47,6 +47,7 @@ import ( "github.com/Agent-Field/codeaf/internal/crewroute" "github.com/Agent-Field/codeaf/internal/delegate" "github.com/Agent-Field/codeaf/internal/effort" + procexec "github.com/Agent-Field/codeaf/internal/executor" "github.com/Agent-Field/codeaf/internal/modelsource" "github.com/Agent-Field/codeaf/internal/plandb" "github.com/Agent-Field/codeaf/internal/roles" @@ -620,6 +621,8 @@ func (a *Agent) startOrJoinTaskRun(ctx context.Context, id uint64, title, brief // startOrJoinTaskRunVia is the one body behind every door above: the start // lock, the join, the folder or the copy, the store and the first row. func (a *Agent) startOrJoinTaskRunVia(ctx context.Context, id uint64, title, brief string, dependsOn []uint64, stand taskStand, question string, via *delegate.Delegate, asked ...string) (bool, error) { + a.taskStarting.Add(1) + defer a.taskStarting.Add(-1) epoch, err := a.beltAdmissionEpoch() if err != nil { return false, err @@ -2056,6 +2059,10 @@ func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun return } } + if err := procexec.CheckPreparedWork(a.config.Seat); err != nil { + a.settlePendingBeltRun(run, err) + return + } // THE RUN'S MONEY REACHES THE CONVERSATION'S BOOKS THROUGH ONE FOLD // (task_run_money.go): each call whole as a program's model API meters it, // and whatever the run's running total holds beyond those — a bash diff --git a/internal/session/task_workspace_apply.go b/internal/session/task_workspace_apply.go index 45a0109f21..d70dc5c556 100644 --- a/internal/session/task_workspace_apply.go +++ b/internal/session/task_workspace_apply.go @@ -33,6 +33,11 @@ const TaskWorkspaceUnavailable = "review and apply are for private task copies; // ReviewTaskWorkspaceRoot reads actual committed and uncommitted changes. This // person-facing door is intentionally absent from model tools. func (a *Agent) ReviewTaskWorkspaceRoot(ctx context.Context, id uint64, slot string) (TaskWorkspaceReview, error) { + a.settingsWrites.Add(1) + defer a.settingsWrites.Add(-1) + if err := a.CheckSettingsWork(); err != nil { + return TaskWorkspaceReview{}, err + } if err := a.taskWorkspaceDoor(ctx); err != nil { return TaskWorkspaceReview{}, err } @@ -217,6 +222,11 @@ func taskWorkspaceApplicable(id uint64, use WorkspaceRootUse) error { // ApplyTaskWorkspaceRoot grants permission only for this selected operation. // It never records Landing, starts a worker, or broadens another root's access. func (a *Agent) ApplyTaskWorkspaceRoot(ctx context.Context, id uint64, slot string) (string, error) { + a.settingsWrites.Add(1) + defer a.settingsWrites.Add(-1) + if err := a.CheckSettingsWork(); err != nil { + return "", err + } if err := a.taskWorkspaceDoor(ctx); err != nil { return "", err } diff --git a/internal/session/tools_anchor_workspace.go b/internal/session/tools_anchor_workspace.go index ff2590754f..505db97f10 100644 --- a/internal/session/tools_anchor_workspace.go +++ b/internal/session/tools_anchor_workspace.go @@ -52,6 +52,11 @@ func (a *Agent) anchorWorkspaceTools() []bare.Tool { // project-less conversation may acquire its subject, but an ordinary project // conversation does not silently become a different project midway through. func (a *Agent) AnchorWorkspace(path string) (string, error) { + a.settingsWrites.Add(1) + defer a.settingsWrites.Add(-1) + if err := a.CheckSettingsWork(); err != nil { + return "", err + } a.mu.Lock() defer a.mu.Unlock() if !a.config.Place.Owned { diff --git a/internal/syncsetup/confirm_work.go b/internal/syncsetup/confirm_work.go index 9e4345459a..76a7b037d9 100644 --- a/internal/syncsetup/confirm_work.go +++ b/internal/syncsetup/confirm_work.go @@ -2,28 +2,127 @@ package syncsetup import ( "context" + "errors" + "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/executor" + "github.com/Agent-Field/codeaf/internal/syncwords" ) +// ConfirmSettingsWork leaves unpaired and unreleased choices local as before. +// A released writer must retake its hold before acknowledging a durable choice. +func (d *Drive) ConfirmSettingsWork(ctx context.Context) error { + if !d.released.Load() && !d.committing.Load() { + return nil + } + if !d.BeginWork() { + d.SavedMetadata() + return nil + } + confirmed, err := d.ConfirmWork(ctx) + if err != nil || !confirmed { + return d.refusedSetting() + } + // The session counts this setting until its save or refusal completes. + // A failed setting must also return an otherwise idle retaken hold. + d.SavedMetadata() + if d.Gate() != nil { + return d.refusedSetting() + } + return nil +} + +// Every refused idle setting carries ownership facts across legacy RPC doors. +// Ordinary transport failures keep their pre-existing client behavior. +func (d *Drive) refusedSetting() error { + err := d.Gate() + var refusal *executor.WorkRefusal + if errors.As(err, &refusal) { + return refusal + } + line := chatlist.Available + if err != nil { + line = err.Error() + } + return &executor.WorkRefusal{Line: line} +} + +// ConfirmPreparedWork bypasses admission's cached reading after task setup. +// Solo and private, unpublished chats keep their existing local door. +func (d *Drive) ConfirmPreparedWork(ctx context.Context) error { + if !d.paired() || d.batcher == nil || d.batcher.Fence() == 0 { + if d.released.Load() { + return d.Gate() + } + return nil + } + confirmed, err := d.ConfirmWork(ctx) + if err != nil { + // A failed read retains ordinary offline admission for a held writer. + // Released, displaced and stopped writers remain closed by Gate. + return d.Gate() + } + if !confirmed { + if err := d.Gate(); err != nil { + return err + } + return cellstore.ErrReadOnly + } + return d.Gate() +} + // ConfirmWork checks only this chat's directory record without waiting for // capture before reading ownership. The caller bounds ctx and decides how to // degrade a failed read. True confirms the current fence; false means either a // viewer or a drive that cannot confirm ownership yet. func (d *Drive) ConfirmWork(ctx context.Context) (bool, error) { + idleAdmission := d.BeginWork() + if idleAdmission { + var pending []ownershipNotice + ctx = context.WithValue(ctx, ownershipNoticesKey{}, &pending) + defer func() { + for _, n := range pending { + d.deliverOwnership(n) + } + }() + if err := lockDrive(ctx, &d.workMu); err != nil { + return false, err + } + defer d.workMu.Unlock() + } held := d.sendLeaseSnapshot() - if d.batcher == nil || d.displaced() { + stopped := func() bool { + d.mu.Lock() + defer d.mu.Unlock() + return d.stopped + }() + if stopped { + return false, cellstore.ErrReadOnly + } + if d.batcher == nil { return false, nil } id, fence := d.batcher.Lease() // Preserving orphaned work in a branch does not revive this displaced // window. A deliberate reopen owns that branch through a new drive. if id != d.directoryID { + v, err := d.sync.Dir.Cell(ctx, d.directoryID) + d.retakeOffline.Store(syncwords.NetworkFailure(err) || errors.Is(err, context.DeadlineExceeded)) + if err != nil { + return false, err + } + if v.Cell.Lease.Device == d.sync.Device.ID() { + d.availableAfterWithin(ctx, held, v.Cell.Lease) + } else { + d.confirmHolder(ctx, v.Cell.Lease) + } return false, nil } v, err := d.sync.Dir.Cell(ctx, id) + d.retakeOffline.Store(syncwords.NetworkFailure(err) || errors.Is(err, context.DeadlineExceeded)) if err != nil { return false, err } @@ -33,41 +132,171 @@ func (d *Drive) ConfirmWork(ctx context.Context) (bool, error) { if currentID, currentFence := d.batcher.Lease(); currentID != id || currentFence != fence { return false, nil } - if lease.Expires <= v.Now { - d.retireHold(ctx, held) - d.availableAfter(held, lease) + switch d.workOwnership(v, fence) { + case workUncertain: + return false, d.Gate() + case workConsent: + // A free higher fence still records a takeover. Retiring renewal beats + // here prevents a subsequent heartbeat from reviving that writer. + if lease.Device == d.sync.Device.ID() { + d.availableAfter(held, lease) + } else { + d.confirmHolder(ctx, lease) + } + d.batcher.RetireFence(id, fence) return false, nil - } - if lease.Device == d.sync.Device.ID() && lease.Fence == fence { - d.resumeViewer() + case workReopen: + if d.canReacquireWorking(v, fence) { + if ok, err := d.reacquireWorking(ctx, v); err != nil || ok { + return ok, err + } + } + if !d.released.Load() { + d.retireHold(ctx, held) + } + d.availableAfterWithin(ctx, held, lease) + d.batcher.RetireFence(id, fence) + return false, nil + case workAcquire: + d.batcher.RetireFence(id, fence) + d.availableAfterWithin(ctx, held, lease) + // Only a self-initiated release can retake in place. Its saved head + // must still match, and release requires all local turns published. + if err := cellstore.Captured(ctx, d.cell); err != nil { + return false, err + } + head, err := cellstore.Head(d.cell) + if err != nil || head == nil || head.Turn.ID != v.Cell.Head { + return false, err + } + resumed, err := d.batcher.Reacquire(ctx, v) + if errors.Is(err, directory.ErrLeaseHeld) || errors.Is(err, directory.ErrFenceStale) { + // A send can lose to an automatic open after the free reading. + // Learn the winner before returning the ordinary moved refusal. + fresh, readErr := d.sync.Dir.Cell(ctx, id) + if readErr != nil { + return false, readErr + } + if d.batcher.AcquireUncertain() { + return false, d.Gate() + } + if fresh.Cell.Lease.Expires > fresh.Now { + d.confirmHolder(ctx, fresh.Cell.Lease) + } + return false, nil + } + if err != nil || !resumed { + if d.batcher.AcquireUncertain() { + d.uncertainAcquire(ctx) + } + return false, err + } + return d.finishIdleAcquire(ctx) + default: + d.releaseDone.Store(false) + d.resumeViewerWithin(ctx) return true, nil } - if lease.Device == d.sync.Device.ID() { - d.availableAfter(held, lease) +} + +func (d *Drive) canReacquireWorking(v directory.CellView, fence uint64) bool { + if d == nil || d.batcher == nil || !d.working.Load() || d.released.Load() || d.displaced() { + return false + } + if d.run == nil || d.run.Err() != nil { + return false + } + lease := v.Cell.Lease + return lease.Device == d.sync.Device.ID() && lease.Fence == fence && lease.Expires > 0 && lease.Expires <= v.Now +} + +func (d *Drive) reacquireWorking(ctx context.Context, v directory.CellView) (bool, error) { + if err := cellstore.Captured(ctx, d.cell); err != nil { + return false, err + } + extends, err := cellstore.Extends(d.cell, v.Cell.Head) + if err == nil && v.Cell.Head == "" && v.Cell.Lease.Device == d.sync.Device.ID() { + extends = true + } + if err != nil || !extends { + return false, err + } + resumed, err := d.batcher.ReacquireWorking(ctx, v) + if errors.Is(err, directory.ErrLeaseHeld) || errors.Is(err, directory.ErrFenceStale) || errors.Is(err, directory.ErrConditionalTake) { return false, nil } + if err != nil || !resumed { + return false, err + } + if err := d.confirmSendLease(ctx); err != nil { + return false, err + } + d.resumeViewerWithin(ctx) + d.ownershipNotice(ctx, d.noticeVersion.Add(1), Viewer{}) + return true, nil +} + +type workOwnership uint8 +const ( + workHeld workOwnership = iota + workAcquire + workConsent + workUncertain + workReopen +) + +// workOwnership is the drive's admission decision for both direct engine work +// and the terminal's CheckWork door. A fresh listing alone cannot restore a +// retired fence, and a higher fence remembers a takeover even after release. +func (d *Drive) workOwnership(v directory.CellView, fence uint64) workOwnership { + lease := v.Cell.Lease + if lease.Device != d.sync.Device.ID() { + return workConsent + } + if lease.Fence != fence { + if d.released.Load() && d.batcher.AcquireUncertain() { + if lease.Expires > v.Now { + return workUncertain + } + return workAcquire + } + return workConsent + } + if d.released.Load() { + return workAcquire + } + if lease.Expires <= v.Now || d.displaced() { + return workReopen + } + return workHeld +} + +func (d *Drive) confirmHolder(ctx context.Context, lease directory.Lease) { // Naming the holder shares the admission budget. Once the fence is known // lost, a slow name lookup cannot turn the refusal back into admission. name := chatlist.DeviceName(nil, lease.Device, nil) if lease.Device == "" { name = unknownDevice - } else if listing, err := d.sync.Dir.List(ctx); err == nil { + } + if l, err := d.sync.Dir.List(ctx); err == nil { key := directory.MetadataKey(d.sync.Identity.CellKey()) - name = chatlist.DeviceName(listing.Devices, lease.Device, func(sealed string) (string, error) { return directory.OpenName(key, sealed) }) + name = chatlist.DeviceName(l.Devices, lease.Device, func(sealed string) (string, error) { + return directory.OpenName(key, sealed) + }) } viewer := Viewer{ID: lease.Device, Name: name, Line: chatlist.Superseded(name)} d.mu.Lock() - if d.viewer.Line != "" { + d.lost = true + if !d.idleNotices.Load() && d.viewer.Line != "" { d.mu.Unlock() - return false, nil + return } d.viewer = viewer + version := d.noticeVersion.Add(1) expected := sendLease{Cell: d.directoryID, Fence: d.recordedFence} d.mu.Unlock() - if d.opt.OnViewer != nil { - d.opt.OnViewer(viewer) - } + d.ownershipNotice(ctx, version, viewer) if d.opt.OnRenewalChange != nil { d.opt.OnRenewalChange() } @@ -84,20 +313,25 @@ func (d *Drive) ConfirmWork(ctx context.Context) (bool, error) { } d.notice(viewer.Line) }() - return false, nil } -func (d *Drive) resumeViewer() { +func (d *Drive) resumeViewer() { d.resumeViewerWithin(context.Background()) } + +func (d *Drive) resumeViewerWithin(ctx context.Context) { d.mu.Lock() - if d.stopped { + if d.stopped || d.released.Load() { d.mu.Unlock() return } changed := d.viewer.Line != "" || d.lost d.viewer, d.lost = Viewer{}, false + version := d.noticeVersion.Load() + if changed { + version = d.noticeVersion.Add(1) + } d.mu.Unlock() - if changed && d.opt.OnDriving != nil { - d.opt.OnDriving() + if changed { + d.ownershipNotice(ctx, version, Viewer{}) } } @@ -112,11 +346,20 @@ func (d *Drive) available() { // Expiry and release retire the receipt too. Readers hear the change after // retirement finishes; continuation must acquire a new hold. func (d *Drive) availableAfter(held sendLease, reading directory.Lease) { + if d.idleNotices.Load() { + ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) + defer cancel() + d.availableAfterWithin(ctx, held, reading) + return + } viewer := Viewer{Line: chatlist.Available} d.mu.Lock() changed := d.viewer != viewer d.viewer = viewer d.mu.Unlock() + if d.batcher != nil && held.Fence != 0 && reading.Device == d.sync.Device.ID() && reading.Fence == held.Fence { + d.batcher.RetireFence(d.directoryID, held.Fence) + } if held.Fence != 0 { if err := d.sync.forgetSendLease(d.cell.ID, held); err != nil { d.notice(err.Error()) @@ -129,3 +372,77 @@ func (d *Drive) availableAfter(held sendLease, reading directory.Lease) { d.opt.OnViewer(viewer) } } + +func (d *Drive) availableAfterWithin(ctx context.Context, held sendLease, reading directory.Lease) { + if d.released.Load() && d.batcher != nil && d.batcher.AcquireUncertain() { + return + } + viewer := Viewer{Line: chatlist.Available} + d.mu.Lock() + // A zero receipt belongs to the released viewer. Reacquisition clears + // that viewer before receipt I/O, so a late observation cannot close its + // gate in the gap. An empty snapshot intentionally forces a refusal. + if held.Cell != "" && (held.Fence != d.recordedFence || held.Fence == 0 && d.viewer.Line == "") { + d.mu.Unlock() + return + } + changed := d.viewer != viewer + d.viewer = viewer + version := d.noticeVersion.Load() + if changed { + version = d.noticeVersion.Add(1) + } + d.mu.Unlock() + if d.batcher != nil && held.Fence != 0 && reading.Device == d.sync.Device.ID() && reading.Fence == held.Fence { + d.batcher.RetireFence(d.directoryID, held.Fence) + } + if held.Fence != 0 { + if err := d.sync.forgetSendLeaseWithin(ctx, d.cell.ID, held); err != nil { + d.notice(err.Error()) + } + } + if changed { + d.renewalChanged() + } + if changed { + d.ownershipNotice(ctx, version, viewer) + } +} + +// confirmSendLease uses bounded receipt I/O for idle-certified writers. +func (d *Drive) confirmSendLease(ctx context.Context) error { + if d.idleNotices.Load() { + return d.recordSendLeaseWithin(ctx) + } + return d.recordSendLease() +} + +func (d *Drive) uncertainAcquire(ctx context.Context) { + viewer := Viewer{Line: executor.TakeBackRetryLine} + version := func() uint64 { + d.mu.Lock() + defer d.mu.Unlock() + d.viewer = viewer + return d.noticeVersion.Add(1) + }() + d.ownershipNotice(ctx, version, viewer) +} + +// finishIdleAcquire records authority before opening the work door. +// The caller holds workMu for this self-initiated retake. +func (d *Drive) finishIdleAcquire(ctx context.Context) (bool, error) { + version := func() uint64 { + d.mu.Lock() + defer d.mu.Unlock() + d.viewer, d.lost = Viewer{}, false + return d.noticeVersion.Add(1) + }() + if err := d.confirmSendLease(ctx); err != nil { + d.availableAfterWithin(ctx, sendLease{}, directory.Lease{}) + return false, err + } + d.released.Store(false) + d.releaseDone.Store(false) + d.ownershipNotice(ctx, version, Viewer{}) + return true, nil +} diff --git a/internal/syncsetup/copy_filing.go b/internal/syncsetup/copy_filing.go new file mode 100644 index 0000000000..25dee502e8 --- /dev/null +++ b/internal/syncsetup/copy_filing.go @@ -0,0 +1,52 @@ +package syncsetup + +import ( + "context" + "errors" + "os" + "path/filepath" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/fsreplace" + "github.com/Agent-Field/codeaf/internal/handoff" + "github.com/Agent-Field/codeaf/internal/session" +) + +// fileCopy uses the origin from the sealed inventory rather than this +// computer's home directory. A first arrival stays a copy even if an unrelated +// folder with the origin's path exists here. A root known before restoration +// stays in place, including an owned copy returning from another computer. +func (c *Continuer) fileCopy(ctx context.Context, at cell.Cell, keepRoot bool) (cell.Cell, func(), error) { + m, err := session.LoadMeta(at.Root) + if err != nil || keepRoot || c.opt.BucketFor == nil || !m.Owned || m.Origin == "" { + return at, nil, err + } + dest := filepath.Join(c.opt.BucketFor(m.Origin), at.ID) + if filepath.Clean(dest) == filepath.Clean(at.Root) { + return at, nil, nil + } + if _, err := os.Stat(dest); !errors.Is(err, os.ErrNotExist) { + if err == nil { + err = errors.New("a copy of this chat is already filed here") + } + return at, nil, err + } + // The destination becomes discoverable at rename. Keep that root fenced + // through the caller's remaining arrival work, as the incoming root was. + filed := handoff.Arrive(dest) + if err := os.MkdirAll(filepath.Dir(dest), 0o700); err != nil { + return at, filed, err + } + if err := fsreplace.Rename(at.Root, dest); err != nil { + return at, filed, err + } + landed := cell.Cell{ID: at.ID, Root: dest} + if err := session.RebaseOwnedCopy(dest); err != nil { + return landed, filed, err + } + if err := c.eng.Follow(ctx, at, landed); err != nil { + return landed, filed, err + } + opened, err := cell.OpenAt(dest, at.ID) + return opened, filed, err +} diff --git a/internal/syncsetup/drive.go b/internal/syncsetup/drive.go index 73c4d49814..54eabeb2fc 100644 --- a/internal/syncsetup/drive.go +++ b/internal/syncsetup/drive.go @@ -4,7 +4,10 @@ import ( "context" "errors" "log" + "os" + "path/filepath" "sync" + "sync/atomic" "time" "github.com/Agent-Field/codeaf/internal/blobstore" @@ -41,20 +44,42 @@ type DriveOptions struct { OnDriving func() // OnRenewalChange wakes local readers after a renewal or ownership change. OnRenewalChange func() - // Sleep replaces real time between flushes; tests inject a fake. + // Sleep replaces real time between background attempts; tests inject a fake. Sleep func(ctx context.Context, d time.Duration) error } // Drive is the drive side of one chat: it publishes each sealed turn, holds -// the lease with heartbeats, and gives it back when the chat closes. When -// another device takes the chat over it becomes a viewer (L7). +// the lease with heartbeats, and gives it back when the chat closes or a paired +// chat finishes uploading its idle state. Another holder makes it a viewer (L7). type Drive struct { - sync *Sync - files vaultsync.Files // the chat's withheld files, carried beside the secrets - cell cell.Cell - opt DriveOptions - batcher *cellsync.Batcher - scope *Scope // what this chat's publishes are counted under + noticeMu sync.Mutex + noticeVersion atomic.Uint64 + idleNotices atomic.Bool + idleRequested atomic.Bool + idlePrepared atomic.Bool + working atomic.Bool + workMu sync.Mutex // Admission and voluntary release cannot cross each other. + activity atomic.Uint64 + released atomic.Bool + retakeOffline atomic.Bool // A released window's latest ownership read could not reach sync. + committing atomic.Bool + releaseDone atomic.Bool + idleCheck func() bool + idleMu sync.Mutex + idleStop context.CancelFunc + idleClosed bool + idleWorkers sync.WaitGroup + engine cellstore.Engine // The latest ordinary seal's composers and capture policy. + vaultMu sync.Mutex + receiptMu sync.Mutex + run context.Context + devices *DeviceObserver + sync *Sync + files vaultsync.Files // the chat's withheld files, carried beside the secrets + cell cell.Cell + opt DriveOptions + batcher *cellsync.Batcher + 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 @@ -71,7 +96,7 @@ type Drive struct { beforeOwnershipCheck func(context.Context) func() afterReleaseInTests func() - sent string // fingerprint of the vault as last sent; touched by the flush loop and Close only + sent string // Fingerprint of the last sent vault, guarded by vaultMu. spoken bool sealMu sync.Mutex @@ -106,11 +131,18 @@ func (s *Sync) Drive(ctx context.Context, eng cellstore.Engine, c cell.Cell, opt if err != nil { return nil, err } - d := &Drive{sync: s, files: files, cell: c, opt: opt, done: make(chan struct{}), ownership: make(chan struct{}, 1)} + d := &Drive{sync: s, files: files, cell: c, engine: eng, opt: opt, done: make(chan struct{}), ownership: make(chan struct{}, 1)} + d.devices = ObserveDevices(s.Home) + started := false + defer func() { + if !started { + d.devices.Close() + } + }() if err := s.putDevice(ctx, opt.DeviceName); err != nil { return nil, err } - d.syncVault(ctx) + _ = d.syncVault(ctx) drv, held, err := s.driving(ctx, c) if err != nil { return nil, err @@ -118,6 +150,7 @@ func (s *Sync) Drive(ctx context.Context, eng cellstore.Engine, c cell.Cell, opt if held != "" { d.becomeViewer(cellsync.Superseded{By: held}) close(d.done) + started = true return d, nil } d.directoryID = drv.ID() @@ -136,10 +169,11 @@ func (s *Sync) Drive(ctx context.Context, eng cellstore.Engine, c cell.Cell, opt opt.OnDriving() } run, cancel := context.WithCancel(context.WithoutCancel(ctx)) - d.cancel = cancel + d.run, d.cancel = run, cancel watch, cancelWatch := context.WithCancel(run) d.watcherCancel = cancelWatch d.watcherDone = make(chan struct{}) + started = true go func() { defer close(d.done) var work sync.WaitGroup @@ -209,29 +243,61 @@ func (d *Drive) note(sealed cellstore.Sealed) { // connection may be saved), so it is sent whenever its content differs from what // was last sent, not once at the start. The withheld files and credentials.json are captured into the // vault first, since a save to it is a change of the vault the fingerprint must -// see. It is best effort and says nothing when it fails: the fingerprint is then -// not recorded, so the next flush tries again, and a seal never waits on it. -func (d *Drive) syncVault(ctx context.Context) { +// see. Ordinary flushes ignore failures and retry without recording the +// fingerprint. Idle release requires success before giving up the hold. +func (d *Drive) syncVault(ctx context.Context) error { return d.syncVaultRound(ctx, false) } + +func (d *Drive) syncIdleVault(ctx context.Context) error { return d.syncVaultRound(ctx, true) } + +func (d *Drive) syncVaultRound(ctx context.Context, idle bool) error { if !d.sync.StorageAllowed() { - return + if idle { + return wireauth.ErrStorageOff + } + return nil + } + if err := ctx.Err(); err != nil { + return err } - err := d.sync.withVault(func(v vaultsync.Syncer) error { + if idle { + if !d.vaultMu.TryLock() { + return errStateBusy + } + } else if err := lockDrive(ctx, &d.vaultMu); err != nil { + return err + } + defer d.vaultMu.Unlock() + err := d.sync.withVaultRound(ctx, 0, idle, func(v vaultsync.Syncer) error { if err := v.Capture(); err != nil { return err } - return d.pushChanged(ctx, v) + if err := ctx.Err(); err != nil { + return err + } + return d.pushChanged(ctx, v, idle) }, d.files) if err != nil { - return + return err } // The push merges what the directory holds, which may rewrite the file. d.sent, _ = keys.Fingerprint(d.sync.Home) + return nil } // pushChanged sends the vault only when its content differs from what was last // sent, so an unchanged vault costs no request. -func (d *Drive) pushChanged(ctx context.Context, v vaultsync.Syncer) error { - if fp, ok := keys.Fingerprint(d.sync.Home); !ok || fp == d.sent { +func (d *Drive) pushChanged(ctx context.Context, v vaultsync.Syncer, idle bool) error { + fp, ok := keys.Fingerprint(d.sync.Home) + if !ok { + if !idle { + return nil // Ordinary publication keeps its existing retry policy. + } + if _, err := os.Stat(filepath.Join(d.sync.Home, "vault.enc")); os.IsNotExist(err) { + return nil // Capture found nothing that needs a vault yet. + } + return errors.New("sync: could not read the saved-key fingerprint") + } + if fp == d.sent { return nil } return v.Push(ctx) @@ -247,9 +313,14 @@ func (d *Drive) flushed() func(cellsync.Flush) { d.notice("could not record which turns reached your other devices: " + err.Error()) } d.scope.Flushed(f) - ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) + parent := context.Background() + if d.idlePrepared.Load() { + // Only idle preparation adds a join that closing must cancel. + parent = d.run + } + ctx, cancel := context.WithTimeout(parent, requestTimeout) defer cancel() - d.syncVault(ctx) + _ = d.syncVault(ctx) } } @@ -294,9 +365,7 @@ func PutOwnDevice(ctx context.Context, dir directory.Client, id identity.Identit // the same upsert every chat start makes ([Sync.putDevice]): a device may only // ever write its own record, the name travels sealed under the identity's key, // and the relay never learns it. -func (s *Sync) Rename(ctx context.Context, name string) error { - return PutOwnDevice(ctx, s.Dir, s.Identity, s.Device, name) -} +func (s *Sync) Rename(ctx context.Context, name string) error { return s.putDevice(ctx, name) } // driving finds where this chat stands in the directory: no record yet (fence // 0), a record this device now holds, or a record another device holds, in @@ -487,6 +556,11 @@ func (d *Drive) becomeViewer(s cellsync.Superseded) { return } if err == nil && v.Cell.Lease.Expires <= v.Now { + if d.canReacquireWorking(v, d.batcher.Fence()) { + if ok, reacquireErr := d.reacquireWorking(ctx, v); reacquireErr == nil && ok { + return + } + } d.availableAfter(held, v.Cell.Lease) return } @@ -533,10 +607,9 @@ func (d *Drive) becomeViewer(s cellsync.Superseded) { viewer := Viewer{ID: s.By, Name: device, Line: line} d.mu.Lock() d.viewer = viewer + version := d.noticeVersion.Add(1) d.mu.Unlock() - if d.opt.OnViewer != nil { - d.opt.OnViewer(viewer) - } + d.ownershipNotice(context.Background(), version, viewer) // Refuse new work immediately, then try to preserve the pending save before // announcing displacement. A routine ownership check does no fsync (C28). if d.batcher != nil { @@ -604,9 +677,23 @@ func (d *Drive) Viewer() (string, bool) { // RenewalStatus reads this writer's cached renewal health without network work. func (d *Drive) RenewalStatus() (time.Time, time.Duration, bool) { - if d.displaced() || d.batcher == nil || d.sync.removed.Load() { + if d.batcher == nil || d.sync.removed.Load() { return time.Time{}, 0, false } + d.mu.Lock() + // A voluntary idle release or an expired own hold stops renewals, not this + // writer's installation. Home must finish its startup wait so the first send + // can check ownership and either conditionally acquire a new fence through + // ConfirmWork or reopen the cell. + available := !d.stopped && d.viewer.Line == chatlist.Available + inactive := d.stopped || (d.lost || d.viewer.Line != "") && !available + d.mu.Unlock() + if inactive { + return time.Time{}, 0, false + } + if available && !d.released.Load() { + return time.Time{}, directory.HeartbeatEvery, false + } return d.batcher.RenewalStatus() } @@ -617,7 +704,11 @@ func (d *Drive) RenewalStatus() (time.Time, time.Duration, bool) { // directory that cannot be asked answers true, because a drive side that cannot // tell must not be thrown away for it. func (d *Drive) HoldsLease(ctx context.Context) bool { - if d.displaced() || d.batcher == nil { + if d.released.Load() && d.batcher != nil && d.batcher.AcquireUncertain() { + // Retain the open reader, not authority to write or renew. + return true + } + if d.displaced() || d.released.Load() || d.batcher == nil { return false } v, err := d.sync.Dir.Cell(ctx, d.directoryID) @@ -626,7 +717,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.Expires > v.Now && lease.Device == d.sync.Device.ID() && lease.Fence == d.batcher.Fence() + return !d.released.Load() && 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 @@ -641,11 +732,23 @@ func (d *Drive) CheckLease(ctx context.Context) error { } id, fence := d.batcher.Lease() v, err := d.sync.Dir.Cell(ctx, d.directoryID) + if err == nil && d.released.Load() && d.batcher.AcquireUncertain() { + // Even an own-device observation cannot attribute a timed-out write + // to this window. ConfirmWork retries only after the lease lapses. + return nil + } if currentID, currentFence := d.batcher.Lease(); currentID != id || currentFence != fence { return nil } if err == nil && v.Cell.Lease.Expires <= v.Now { - d.retireHold(ctx, held) + if d.canReacquireWorking(v, fence) { + if ok, reacquireErr := d.reacquireWorking(ctx, v); reacquireErr == nil && ok { + return nil + } + } + if !d.released.Load() { + d.retireHold(ctx, held) + } d.availableAfter(held, v.Cell.Lease) return nil } @@ -668,8 +771,7 @@ func (d *Drive) retireHold(ctx context.Context, held sendLease) { d.mu.Lock() d.lost = true d.mu.Unlock() - d.sealMu.Lock() - d.sealMu.Unlock() + func() { d.sealMu.Lock(); defer d.sealMu.Unlock() }() if err := d.sync.forgetSendLease(d.cell.ID, held); err != nil { d.notice(err.Error()) } @@ -700,6 +802,12 @@ func (d *Drive) Gate() error { if stopped { return cellstore.ErrReadOnly } + if d.retakeOffline.Load() && (d.released.Load() || viewer.Line == chatlist.Available) { + return &executor.WorkRefusal{Line: chatlist.TakeBackUnreachable, Retryable: true} + } + if d.released.Load() && d.batcher != nil && d.batcher.AcquireUncertain() && viewer.ID == "" { + return &executor.WorkRefusal{Line: executor.TakeBackRetryLine, Retryable: true} + } if viewer.Line != "" { return &executor.WorkRefusal{Line: viewer.Line, HolderID: viewer.ID, HolderName: viewer.Name} } @@ -714,23 +822,47 @@ func (d *Drive) Gate() error { // Working tells the directory, through the next beat, whether a turn is // running on this computer, so another one can say the chat is working here. func (d *Drive) Working(on bool) { + d.working.Store(on) if d.batcher != nil { d.batcher.Working(on) } } func (d *Drive) Idle() { - if d.batcher != nil { - go func() { - ctx, cancel := context.WithTimeout(context.Background(), cellstore.SealWithin) - defer cancel() - if err := cellstore.Await(ctx, d.cell); err != nil { - d.notice(err.Error()) - return - } - d.batcher.Idle() - }() + if d.batcher == nil { + return } + d.idleRequested.Store(true) + activity := d.activity.Load() + // Publication keeps its original lifetime, including for solo homes. Only + // the subsequent idle certification is canceled by new work. + go func() { + ctx, cancel := context.WithTimeout(context.Background(), cellstore.SealWithin) + defer cancel() + if err := cellstore.Await(ctx, d.cell); err != nil { + d.notice(err.Error()) + return + } + d.batcher.Idle() + if d.idleHandoffAllowed() { + d.scheduleIdle(activity) + } + }() +} + +func (d *Drive) scheduleIdle(activity uint64) { + d.idleMu.Lock() + defer d.idleMu.Unlock() + if d.idleClosed || activity != d.activity.Load() || !d.idleHandoffAllowed() { + return + } + if d.idleStop != nil { + d.idleStop() + } + run, stop := context.WithCancel(context.Background()) + d.idleStop = stop + d.idleWorkers.Add(1) + go func() { defer d.idleWorkers.Done(); defer stop(); d.releaseWhenIdle(run, activity) }() } // Store is the store a seal goes through: the engine stamped with this device @@ -753,6 +885,7 @@ func (s stamping) Seal(ctx context.Context, c cell.Cell, info cellstore.TurnInfo d := s.drive d.sealMu.Lock() defer d.sealMu.Unlock() + d.engine = s.engine d.mu.Lock() stopped := d.stopped d.mu.Unlock() @@ -796,14 +929,29 @@ func (d *Drive) Close(ctx context.Context) error { ctx, cancel := context.WithTimeout(ctx, cellstore.SealWithin) defer cancel() d.once.Do(func() { + if d.idlePrepared.Load() { + // Retain transport/local provenance while recognizing this idle close's + // own deadline when cancellation interrupts the native save reply. + defer func() { d.closeErr = errors.Join(d.closeErr, ctx.Err()) }() + } + + if d.devices != nil { + defer d.devices.Close() + } + idleErr := d.stopIdle(ctx) pending := cellstore.Await(ctx, d.cell) + if idleErr != nil { + pending = errors.Join(idleErr, pending) + } // A completed capture may race the first wait. Stop new seals under // the writer's lock, then keep every seal that crossed before it closed. - d.sealMu.Lock() - d.mu.Lock() - d.stopped = true - d.mu.Unlock() - d.sealMu.Unlock() + func() { + d.sealMu.Lock() + defer d.sealMu.Unlock() + d.mu.Lock() + defer d.mu.Unlock() + d.stopped = true + }() if pending == nil { pending = cellstore.Await(ctx, d.cell) } @@ -837,7 +985,7 @@ func (d *Drive) Close(ctx context.Context) error { d.closeErr = pending } if pending == nil && !removed { - d.syncVault(ctx) + _ = d.syncVault(ctx) } d.scope.Settle() // a publish that failed after the last flush line still counts if d.closeErr == nil { diff --git a/internal/syncsetup/idle_93d4_test.go b/internal/syncsetup/idle_93d4_test.go new file mode 100644 index 0000000000..2f283881a6 --- /dev/null +++ b/internal/syncsetup/idle_93d4_test.go @@ -0,0 +1,194 @@ +package syncsetup + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "os" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" +) + +func TestIdleRelease_93d4_SelfReleasedRetakeIsANewFenceNeverARevival(t *testing.T) { + type wireEvent struct { + op string + fence uint64 + outside bool + } + var ( + mu sync.Mutex + events []wireEvent + outsideBeat atomic.Bool + ) + recordFence := func(r *http.Request) uint64 { + raw, _ := io.ReadAll(r.Body) + r.Body = io.NopCloser(bytes.NewReader(raw)) + var body struct { + Fence uint64 `json:"fence"` + } + _ = json.Unmarshal(raw, &body) + return body.Fence + } + h := newTwoHomes(t, func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/release"): + fence := recordFence(r) + next.ServeHTTP(w, r) + mu.Lock() + events = append(events, wireEvent{op: "release", fence: fence}) + mu.Unlock() + case strings.HasSuffix(r.URL.Path, "/heartbeat"): + fence := recordFence(r) + outside := outsideBeat.Load() + next.ServeHTTP(w, r) + mu.Lock() + events = append(events, wireEvent{op: "heartbeat", fence: fence, outside: outside}) + mu.Unlock() + default: + next.ServeHTTP(w, r) + } + }) + }) + + c := idleContractChat(t, h) + d := c.drive + + // (a) Self-release closes Gate(), sends no heartbeat at the released fence, + // and an outside heartbeat at that fence does not reopen Gate(). + released := idleContractReleased(t, h, d, uploadedIdleReleaseWithin) + releasedFence := released.Cell.Lease.Fence + if d.Gate() == nil { + t.Fatal("self-released drive still admits work") + } + if _, _, err := h.a.SendLease(h.cell.ID); !os.IsNotExist(err) { + t.Fatalf("self-released drive kept its send receipt: %v", err) + } + // Wake the batcher's beat loop while released to prove it stays silent. + d.Working(true) + d.Working(false) + time.Sleep(30 * time.Millisecond) + mu.Lock() + sawRelease := false + for _, ev := range events { + if ev.op == "release" && ev.fence == releasedFence { + sawRelease = true + continue + } + if sawRelease && ev.op == "heartbeat" && !ev.outside && ev.fence == releasedFence { + mu.Unlock() + t.Fatalf("drive sent a heartbeat at released fence %d", releasedFence) + } + } + mu.Unlock() + if !sawRelease { + t.Fatalf("did not observe idle release at fence %d", releasedFence) + } + + outsideBeat.Store(true) + _, err := h.a.Dir.Heartbeat(t.Context(), h.cell.ID, directory.Beat{Fence: releasedFence}) + outsideBeat.Store(false) + mustNil(t, err) + mustNil(t, d.CheckLease(t.Context())) + if d.Gate() == nil { + t.Fatal("outside heartbeat at released fence reopened Gate()") + } + if _, _, err := h.a.SendLease(h.cell.ID); !os.IsNotExist(err) { + t.Fatalf("outside heartbeat at released fence restored send receipt: %v", err) + } + + // (b) New work on the self-released drive acquires a strictly greater fence + // and reopens Gate() rather than reviving the released fence. + if !d.BeginWork() { + t.Fatal("BeginWork did not report the released window") + } + confirmed, err := d.ConfirmWork(t.Context()) + if err != nil || !confirmed { + t.Fatalf("self-released retake = %v, %v; want true, nil", confirmed, err) + } + retaken, err := h.a.Dir.Cell(t.Context(), h.cell.ID) + mustNil(t, err) + if retaken.Cell.Lease.Device != h.a.Device.ID() || retaken.Cell.Lease.Expires <= retaken.Now { + t.Fatalf("retaken lease is not live for this device: %+v at %d", retaken.Cell.Lease, retaken.Now) + } + if retaken.Cell.Lease.Fence <= releasedFence { + t.Fatalf("retaken fence = %d, want strictly greater than released fence %d", retaken.Cell.Lease.Fence, releasedFence) + } + if err := d.Gate(); err != nil { + t.Fatalf("retaken drive refuses work: %v", err) + } + if _, receiptFence, err := h.a.SendLease(h.cell.ID); err != nil || receiptFence != retaken.Cell.Lease.Fence { + t.Fatalf("retaken send receipt = %d, %v; want %d, nil", receiptFence, err, retaken.Cell.Lease.Fence) + } + + // (c) With the same self-released drive, if another device acquires first, + // ConfirmWork returns false and Gate() refuses with held-elsewhere. + secondRelease := idleContractReleased(t, h, d, uploadedIdleReleaseWithin) + if secondRelease.Cell.Lease.Fence != retaken.Cell.Lease.Fence { + t.Fatalf("second release fence = %d, want %d", secondRelease.Cell.Lease.Fence, retaken.Cell.Lease.Fence) + } + _, err = h.b.Dir.Acquire(t.Context(), h.cell.ID, directory.AcquireOpts{ + RequireConditional: true, + Expect: &directory.LeaseExpectation{Device: secondRelease.Cell.Lease.Device, Fence: secondRelease.Cell.Lease.Fence}, + }) + mustNil(t, err) + d.BeginWork() + confirmed, err = d.ConfirmWork(t.Context()) + if err != nil || confirmed { + t.Fatalf("displaced self-released ConfirmWork = %v, %v; want false, nil", confirmed, err) + } + gateErr := d.Gate() + if gateErr == nil || !strings.Contains(gateErr.Error(), chatlist.Superseded(nameB)) { + t.Fatalf("displaced self-released Gate() = %v; want %q", gateErr, chatlist.Superseded(nameB)) + } + if _, _, err := h.a.SendLease(h.cell.ID); !os.IsNotExist(err) { + t.Fatalf("displaced self-released drive kept a send receipt: %v", err) + } + + // (d) A drive whose hold merely lapsed or was released from outside never + // retakes on ConfirmWork, matching 93d4b7fed. + t.Run("merely lapsed", func(t *testing.T) { + lh := newTwoHomes(t) + lc := idleContractChat(t, lh) + lh.clock.advance(directory.LeaseTTL + time.Second) + lc.drive.BeginWork() + confirmed, err := lc.drive.ConfirmWork(t.Context()) + if err != nil || confirmed { + t.Fatalf("lapsed ConfirmWork = %v, %v; want false, nil", confirmed, err) + } + if lc.drive.Gate() == nil { + t.Fatal("lapsed drive admits work without continuation") + } + }) + t.Run("released from outside", func(t *testing.T) { + oh := newTwoHomes(t) + oc := idleContractChat(t, oh) + held := oc.drive.batcher.Fence() + mustNil(t, oc.drive.sync.Dir.Release(t.Context(), oh.cell.ID, held)) + mustNil(t, oc.drive.CheckLease(t.Context())) + if oc.drive.Gate() == nil { + t.Fatal("outside-released drive admits work") + } + _, err := oc.drive.sync.Dir.Heartbeat(t.Context(), oh.cell.ID, directory.Beat{Fence: held}) + mustNil(t, err) + mustNil(t, oc.drive.CheckLease(t.Context())) + if oc.drive.Gate() == nil { + t.Fatal("heartbeat revived outside-released drive") + } + oc.drive.BeginWork() + confirmed, err := oc.drive.ConfirmWork(t.Context()) + if err != nil || confirmed { + t.Fatalf("outside-released ConfirmWork = %v, %v; want false, nil", confirmed, err) + } + if oc.drive.Gate() == nil { + t.Fatal("outside-released drive admits work after ConfirmWork") + } + }) +} diff --git a/internal/syncsetup/idle_r10_acquire_test.go b/internal/syncsetup/idle_r10_acquire_test.go new file mode 100644 index 0000000000..e4e70143b1 --- /dev/null +++ b/internal/syncsetup/idle_r10_acquire_test.go @@ -0,0 +1,158 @@ +package syncsetup + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" +) + +func TestIdleRelease_R10_LateCommitWaitsForLapseAfterEarlyRetry(t *testing.T) { + var requests atomic.Int32 + allow, committed := make(chan struct{}), make(chan struct{}) + release := sync.OnceFunc(func() { close(allow) }) + defer release() + h := newTwoHomes(t, func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/watch") && r.URL.Query().Get("events") == "1" { + <-r.Context().Done() + return + } + if strings.HasSuffix(r.URL.Path, "/acquire") && requests.Load() > 0 { + switch requests.Add(1) { + case 2: + raw, _ := io.ReadAll(r.Body) + <-r.Context().Done() + <-allow + late := r.Clone(context.WithoutCancel(r.Context())) + late.Body = io.NopCloser(bytes.NewReader(raw)) + next.ServeHTTP(httptest.NewRecorder(), late) + close(committed) + return + case 3: + // The retry read the old free fence. Commit the first request + // before the retry reaches the relay's conditional acquire. + release() + <-committed + } + } + next.ServeHTTP(w, r) + }) + }) + c := idleContractChat(t, h) + before := idleContractReleased(t, h, c.drive) + requests.Store(1) + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + if confirmed, err := c.drive.ConfirmWork(ctx); err == nil || confirmed { + t.Fatalf("first acquire=%v error=%v", confirmed, err) + } + confirmed, err := c.drive.ConfirmWork(t.Context()) + if err == nil || confirmed || c.drive.Gate() == nil || !strings.Contains(c.drive.Gate().Error(), "try again") { + t.Fatalf("late commit must remain uncertain: confirmed=%v error=%v gate=%v", confirmed, err, c.drive.Gate()) + } + h.clock.advance(directory.LeaseTTL + time.Millisecond) + confirmed, err = c.drive.ConfirmWork(t.Context()) + if err != nil || !confirmed || c.drive.Gate() != nil { + t.Fatalf("retry after lapse stuck: confirmed=%v error=%v gate=%v", confirmed, err, c.drive.Gate()) + } + at, err := h.a.Dir.Cell(t.Context(), h.cell.ID) + mustNil(t, err) + if at.Cell.Lease.Fence != before.Cell.Lease.Fence+2 { + t.Fatalf("lapsed acquire was not replaced: before=%d after=%d", before.Cell.Lease.Fence, at.Cell.Lease.Fence) + } +} + +// The relay commits the real signed acquire, but its reply misses the caller's +// deadline. No ownership or readiness result is substituted by this wrapper. +func TestIdleRelease_R10_CommittedAcquireLostReplyRecoversAfterLapse(t *testing.T) { + var armed atomic.Bool + committed := make(chan struct{}, 1) + h := newTwoHomes(t, func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/watch") && r.URL.Query().Get("events") == "1" { + <-r.Context().Done() + return + } + if strings.HasSuffix(r.URL.Path, "/acquire") && armed.CompareAndSwap(true, false) { + reply := httptest.NewRecorder() + next.ServeHTTP(reply, r.WithContext(context.WithoutCancel(r.Context()))) + if reply.Code == http.StatusOK { + committed <- struct{}{} + } + <-r.Context().Done() + return + } + next.ServeHTTP(w, r) + }) + }) + c := idleContractChat(t, h) + a, _ := idleContractSession(t, h, c) + before := idleContractReleased(t, h, c.drive) + armed.Store(true) + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + confirmed, err := c.drive.ConfirmWork(ctx) + if err == nil || confirmed { + t.Fatalf("lost acquire reply: confirmed=%v error=%v", confirmed, err) + } + select { + case <-committed: + default: + t.Fatal("fixture did not commit the acquire before the deadline") + } + if gate := c.drive.Gate(); gate == nil || strings.Contains(gate.Error(), chatlist.Available) || !strings.Contains(gate.Error(), "try again") { + t.Fatalf("uncertain acquire needs an honest retryable refusal, got %v", gate) + } + mustNil(t, c.drive.CheckLease(t.Context())) + if gate := c.drive.Gate(); gate == nil || !strings.Contains(gate.Error(), "try again") { + t.Fatalf("ownership observation guessed the uncertain writer: %v", gate) + } + confirmed, err = c.drive.ConfirmWork(t.Context()) + if err == nil || confirmed { + t.Fatalf("live uncertain fence was adopted: confirmed=%v error=%v", confirmed, err) + } + at, err := h.a.Dir.Cell(t.Context(), h.cell.ID) + mustNil(t, err) + if at.Cell.Lease.Fence != before.Cell.Lease.Fence+1 { + t.Fatalf("uncertain retry acquired again: before=%d after=%d", before.Cell.Lease.Fence, at.Cell.Lease.Fence) + } + if _, _, holding := c.drive.RenewalStatus(); holding { + t.Fatal("uncertain writer must not renew") + } + h.clock.advance(directory.LeaseTTL + time.Millisecond) + lapsed, err := h.a.Dir.Cell(t.Context(), h.cell.ID) + mustNil(t, err) + if lapsed.Cell.Lease.Expires != at.Cell.Lease.Expires || lapsed.Cell.Lease.Expires > lapsed.Now { + t.Fatalf("uncertain lease did not lapse unchanged: before=%+v after=%+v", at, lapsed) + } + confirmed, err = c.drive.ConfirmWork(t.Context()) + if err != nil || !confirmed || c.drive.Gate() != nil { + t.Fatalf("retry after lapse stuck: confirmed=%v error=%v gate=%v", confirmed, err, c.drive.Gate()) + } + at, err = h.a.Dir.Cell(t.Context(), h.cell.ID) + mustNil(t, err) + if at.Cell.Lease.Fence != before.Cell.Lease.Fence+2 { + t.Fatalf("retry did not conditionally acquire a new fence: %+v", at.Cell.Lease) + } + events, err := a.SubmitBash(t.Context(), "! printf ran > r10-late-acquire.txt") + mustNil(t, err) + for range events { + } + raw, err := os.ReadFile(filepath.Join(h.work, "r10-late-acquire.txt")) + mustNil(t, err) + if string(raw) != "ran" { + t.Fatalf("retried send did not run: %q", raw) + } +} diff --git a/internal/syncsetup/idle_r10_ordering_test.go b/internal/syncsetup/idle_r10_ordering_test.go new file mode 100644 index 0000000000..e37bb065bc --- /dev/null +++ b/internal/syncsetup/idle_r10_ordering_test.go @@ -0,0 +1,231 @@ +package syncsetup + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/cellsync" + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/directory/directorytest" +) + +func r10OrderingMatrix() []r9Cell { + var table []r9Cell + for _, relay := range []string{"live", "lapsed"} { + for _, turn := range []string{"idle", "working"} { + for _, pending := range []string{"none", "some"} { + for _, call := range []string{"no_call", "call_in_flight_at_expiry"} { + for _, order := range []string{"observation_first", "own_beat_first", "restart_renew_holds_first"} { + observers := []string{""} + if order == "observation_first" { + observers = []string{"check", "confirm"} + } + for _, observer := range observers { + want := "same_fence_upload" + if order == "observation_first" { + want = "retire_keep_reopen" + } + table = append(table, r9Cell{relay: relay, local: "holding", service: "down_then_answering", turn: turn, unpublished: pending, inFlight: call, ordering: order, observer: observer, want: want}) + } + } + } + } + } + } + return table +} + +func TestIdleRelease_R10_OrderingMatrix(t *testing.T) { + for _, c := range r10OrderingMatrix() { + t.Run(c.name(), func(t *testing.T) { r10Ordering(t, c) }) + } +} + +// A beat runs on the real batcher while upload remains unavailable. Joining +// Run before lifting that failure chooses the revival event without a race. +type r10BeatDirectory struct { + directory.Client + beat chan struct{} +} + +func (d r10BeatDirectory) Publish(context.Context, string, directory.Publish) (directory.CellView, error) { + return directory.CellView{}, directory.ErrUnreachable +} + +func (d r10BeatDirectory) Heartbeat(ctx context.Context, id string, in directory.Beat) (directory.CellView, error) { + v, err := d.Client.Heartbeat(ctx, id, in) + select { + case d.beat <- struct{}{}: + default: + } + return v, err +} + +func r10OwnBeat(t *testing.T, d *Drive, dir *r9Directory) { + t.Helper() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + beat := make(chan struct{}, 1) + tick := make(chan struct{}, 1) + tick <- struct{}{} + d.batcher.Publisher.Dir = r10BeatDirectory{Client: dir.Client, beat: beat} + d.batcher.Sleep = func(ctx context.Context, duration time.Duration) error { + if duration == directory.HeartbeatEvery { + select { + case <-tick: + return nil + case <-ctx.Done(): + return ctx.Err() + } + } + <-ctx.Done() + return ctx.Err() + } + done := make(chan struct{}) + go func() { _ = d.batcher.Run(ctx); close(done) }() + select { + case <-beat: + case <-time.After(5 * time.Second): + cancel() + <-done + t.Fatal("batcher did not send its revival beat") + } + cancel() + <-done + d.batcher.Publisher.Dir, d.batcher.Sleep = dir, nil +} + +func r10Ordering(t *testing.T, c r9Cell) { + t.Helper() + ctx := t.Context() + clock := directorytest.NewFakeClock() + d, dir := r9Fixture(t, r9Cell{relay: "live", local: "holding", service: "answering", turn: c.turn}, clock) + engine := d.batcher.Publisher.Engine.(*cellsync.FakeEngine) + first, err := dir.Client.Cell(ctx, d.cell.ID) + mustNil(t, err) + if c.inFlight != "no_call" { + mustNil(t, d.ConfirmPreparedWork(ctx)) + } + if c.relay == "lapsed" { + clock.Advance(directory.LeaseTTL + time.Second) + } + dir.failure = directory.ErrUnreachable + clock.Advance(directory.LeaseTTL + time.Second) + mustNil(t, d.CheckLease(ctx)) + mustNil(t, d.ConfirmPreparedWork(ctx)) + if d.Gate() != nil { + t.Fatal("an unsuccessful read locally expired the hold") + } + latest := first.Cell.Head + turns := 0 + seal := func(words string) { + latest = engine.Seal(d.cell, map[string]string{"journal": words, cell.MetaPath: `{"V":1,"class":"files-only","cell_key_id":"00000000000000000000000000000000"}`}) + mustNil(t, os.WriteFile(filepath.Join(d.cell.Root, ".cell", "adopted.json"), []byte(fmt.Sprintf(`{"V":1,"head":%q,"turns_size":0}`, latest)), 0600)) + d.batcher.Note(cellstore.Turn{ID: latest}) + turns++ + } + if c.unpublished == "some" { + seal("offline turn") + } + // The previously admitted call has not returned or sealed at the observation. + dir.failure = nil + if c.ordering == "observation_first" { + if c.observer == "check" { + mustNil(t, d.CheckLease(ctx)) + } else if ok, err := d.ConfirmWork(ctx); err != nil || ok { + t.Fatalf("observed expiry admitted new work: %v %v", ok, err) + } + if d.Gate() == nil { + t.Fatal("observed expiry did not retire admission") + } + } else if c.ordering == "own_beat_first" { + r10OwnBeat(t, d, dir) + mustNil(t, d.CheckLease(ctx)) + mustNil(t, d.ConfirmPreparedWork(ctx)) + } else { + // The fake relay applies the same unchanged-fence renewal as restart's + // authenticated renew_holds effect, before delivering an ownership read. + _, err := dir.Client.Heartbeat(ctx, d.cell.ID, directory.Beat{Fence: first.Cell.Lease.Fence}) + mustNil(t, err) + mustNil(t, d.CheckLease(ctx)) + mustNil(t, d.ConfirmPreparedWork(ctx)) + } + if c.inFlight != "no_call" { + seal("offline turn; late external call finished once") + } + mustNil(t, d.batcher.Close(ctx)) + after, err := dir.Client.Cell(ctx, d.cell.ID) + mustNil(t, err) + if after.Cell.Lease.Fence != first.Cell.Lease.Fence { + t.Fatal("preservation changed the original fence") + } + if c.ordering != "observation_first" { + if after.Cell.Head != latest || d.Gate() != nil { + t.Fatal("revival did not upload at the retained fence") + } + if _, err := dir.Client.Cell(ctx, "kept-chat"); err == nil { + t.Fatal("revival unexpectedly branched") + } + } else { + wantHead := first.Cell.Head + if after.Cell.Head != wantHead || d.Gate() == nil { + t.Fatal("retirement overwrote the shared head or revived admission") + } + if turns > 0 { + kept, err := dir.Client.Cell(ctx, "kept-chat") + mustNil(t, err) + if kept.Cell.Head != latest || kept.Cell.ParentCell != d.cell.ID || kept.Cell.OrphanTurns == 0 { + t.Fatalf("offline work not kept: %+v", kept) + } + listing, err := dir.Client.List(ctx) + mustNil(t, err) + found := false + for _, row := range chatlist.Rows(listing, "B", nil) { + if row.Cell == "kept-chat" { + found = row.Status == chatlist.Branch && row.OrphanTurns > 0 + } + } + if !found { + t.Fatal("B did not see the turns as kept") + } + copy := cell.Cell{ID: "kept-chat", Root: t.TempDir()} + fetch := &cellsync.Fetcher{Engine: cellsync.NewFakeEngine(t.TempDir()), Store: d.batcher.Publisher.Store, Dir: dir, Inbox: func(cell.Cell) string { return t.TempDir() }} + mustNil(t, fetch.Fetch(ctx, copy, latest)) + words, err := os.ReadFile(filepath.Join(copy.Root, "journal")) + mustNil(t, err) + want := "offline turn" + if c.inFlight != "no_call" { + want = "offline turn; late external call finished once" + } + if string(words) != want { + t.Fatalf("B lost kept work: %q", words) + } + } else { + if _, err := dir.Client.Cell(ctx, "kept-chat"); err == nil { + t.Fatal("cell unexpectedly branched") + } + } + if ok, err := d.ConfirmWork(ctx); err != nil || ok { + t.Fatal("retired drive admitted new work after preservation") + } + seen, err := dir.Client.Cell(ctx, d.cell.ID) + mustNil(t, err) + // The existing conditional reopen boundary installs a fresh drive; the + // TUI matrix verifies the same Enter carries its box without a card. + taken, err := dir.Acquire(ctx, d.cell.ID, directory.AcquireOpts{RequireConditional: true, Expect: &directory.LeaseExpectation{Device: seen.Cell.Lease.Device, Fence: seen.Cell.Lease.Fence}}) + mustNil(t, err) + if taken.Cell.Lease.Fence != first.Cell.Lease.Fence+1 { + t.Fatal("reopen did not advance the fence") + } + } + if d.batcher.Pending() != 0 { + t.Fatal("preservation stranded a sealed turn") + } +} diff --git a/internal/syncsetup/idle_r4_ra_test.go b/internal/syncsetup/idle_r4_ra_test.go new file mode 100644 index 0000000000..17c04b34fa --- /dev/null +++ b/internal/syncsetup/idle_r4_ra_test.go @@ -0,0 +1,76 @@ +package syncsetup + +import ( + "context" + "os" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" +) + +// Home waits for installation before reading ownership for its first send. +// A taken-back writer may idle-release before that send; its installed status +// must survive release without granting any authority at the released fence. +func TestIdleRelease_R4_RA_TakenBackReleasedWriterRemainsInstalled(t *testing.T) { + h := newTwoHomes(t) + a := idleContractChat(t, h) + first := idleContractReleased(t, h, a.drive) + expect := func(v directory.CellView) *directory.LeaseExpectation { + return &directory.LeaseExpectation{Device: v.Cell.Lease.Device, Fence: v.Cell.Lease.Fence} + } + got, err := h.continuerB().OpenAvailable(t.Context(), h.cell.ID, expect(first)) + mustNil(t, err) + b := h.openOn(h.b, h.engB, got.Taken.Cell, workspaceOf(got.Taken.Cell.Root), nameB) + release := func(d *Drive) directory.CellView { + t.Helper() + d.Idle() + var view directory.CellView + waitFor(t, "this writer's explicit idle release", func() bool { + var err error + view, err = h.a.Dir.Cell(t.Context(), h.cell.ID) + return err == nil && d.IdleReleased() && view.Cell.Lease.Expires == 0 && view.Cell.Lease.Fence == d.batcher.Fence() + }) + return view + } + second := release(b.drive) + back, err := h.continuerA().OpenAvailable(t.Context(), h.cell.ID, expect(second)) + mustNil(t, err) + current := h.openOn(h.a, h.engine, back.Taken.Cell, workspaceOf(back.Taken.Cell.Root), nameA) + seen := release(current.drive) + before, err := os.ReadFile(transcriptOf(back.Taken.Cell)) + mustNil(t, err) + _, period, holding := current.drive.RenewalStatus() + if period != directory.HeartbeatEvery || holding { + t.Fatalf("taken-back writer looks uninstalled after idle release: period=%s holding=%v", period, holding) + } + if current.drive.Gate() == nil { + t.Fatal("installed status revived work permission at the released fence") + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + confirmed, err := current.drive.ConfirmWork(ctx) + if err != nil || !confirmed || current.drive.Gate() != nil { + t.Fatalf("first send could not take back the installed writer: confirmed=%v error=%v gate=%v", confirmed, err, current.drive.Gate()) + } + view, err := h.a.Dir.Cell(t.Context(), h.cell.ID) + mustNil(t, err) + if view.Cell.Lease.Fence <= seen.Cell.Lease.Fence { + t.Fatal("taking back the installed writer revived its released fence") + } + after, err := os.ReadFile(transcriptOf(back.Taken.Cell)) + mustNil(t, err) + if string(after) != string(before) || !strings.Contains(string(after), "save this chat") { + t.Fatal("writer readiness or take-back changed the saved conversation") + } + mustNil(t, b.drive.CheckLease(ctx)) + _, period, holding = b.drive.RenewalStatus() + if period != 0 || holding { + t.Fatal("the displaced other computer reported itself installed or holding again") + } + _, _, holding = a.drive.RenewalStatus() + if holding || a.drive.Gate() == nil { + t.Fatal("the original window revived authority at its released fence") + } +} diff --git a/internal/syncsetup/idle_r5_notify_test.go b/internal/syncsetup/idle_r5_notify_test.go new file mode 100644 index 0000000000..3ce2e424b4 --- /dev/null +++ b/internal/syncsetup/idle_r5_notify_test.go @@ -0,0 +1,104 @@ +package syncsetup + +import ( + "context" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/chatlist" +) + +func TestIdleRelease_R5_4_IdleNoticeCannotOvertakeReacquire(t *testing.T) { idleR5Notice(t, true) } +func TestIdleRelease_R5_4_UnchangedFreeObservationKeepsNotice(t *testing.T) { idleR5Notice(t, false) } +func idleR5Notice(t *testing.T, reacquire bool) { + t.Setenv("CODEAF_IDLE_HANDOFF", "on") + h := newTwoHomes(t) + mustNil(t, h.b.putDevice(t.Context(), nameB)) + var current atomic.Pointer[Drive] + var armed atomic.Bool + at, resume := make(chan struct{}), make(chan struct{}) + var unblock sync.Once + release := func() { unblock.Do(func() { close(resume) }) } + defer release() + var mu sync.Mutex + var events []string + record := func(event string) { mu.Lock(); defer mu.Unlock(); events = append(events, event) } + d, err := h.a.Drive(t.Context(), h.engine, h.cell, DriveOptions{ + DeviceName: nameA, + OnDriving: func() { record("driving") }, + OnViewer: func(v Viewer) { record(v.Line) }, + OnRenewalChange: func() { + drive := current.Load() + if !armed.Load() || drive == nil || !drive.IdleReleased() { + return + } + if !drive.workMu.TryLock() { + return + } + drive.workMu.Unlock() + if armed.Swap(false) { + close(at) + <-resume + } + }, + }) + mustNil(t, err) + current.Store(d) + t.Cleanup(func() { release(); _ = d.Close(context.Background()) }) + appendTo(t, transcriptOf(h.cell), "{\"type\":\"message\",\"role\":\"user\",\"content\":\"notice ordering\"}\n") + _, err = d.Store(h.engine).Seal(t.Context(), h.cell, cellstore.TurnInfo{}) + mustNil(t, err) + h.durable(h.cell.ID, h.cell) + settleIdleFixture(t, h, d) + func() { mu.Lock(); defer mu.Unlock(); events = nil }() + armed.Store(true) + d.Idle() + select { + case <-at: + case <-time.After(15 * time.Second): + t.Fatal("deferred release notification did not start") + } + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + if reacquire { + confirmed, err := d.ConfirmWork(ctx) + if err != nil || !confirmed { + t.Fatalf("reacquire=%v,%v", confirmed, err) + } + } else { + mustNil(t, d.CheckLease(ctx)) + } + release() + if !reacquire { + // This assertion concerns the deferred notice, not a timed-out release's + // later retry. Cancel preparation without changing the ownership version. + d.BeginWork() + } + done := make(chan struct{}) + go func() { d.idleWorkers.Wait(); close(done) }() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("release worker did not finish") + } + got, last := func() (string, string) { + mu.Lock() + defer mu.Unlock() + last := "" + if len(events) > 0 { + last = events[len(events)-1] + } + return strings.Join(events, " -> "), last + }() + t.Logf("surface events=%s; gate=%v; released=%v", got, d.Gate(), d.IdleReleased()) + if reacquire && last == chatlist.Available { + t.Error("stale idle notification overwrote the successful reacquisition") + } + if !reacquire && last != chatlist.Available { + t.Error("unchanged free observation canceled the pending release notice") + } +} diff --git a/internal/syncsetup/idle_r5_offline_test.go b/internal/syncsetup/idle_r5_offline_test.go new file mode 100644 index 0000000000..6b3e7e09f6 --- /dev/null +++ b/internal/syncsetup/idle_r5_offline_test.go @@ -0,0 +1,118 @@ +package syncsetup + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/crewroute" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/session" +) + +type reviewR5OfflineDirectory struct { + directory.Client + down atomic.Bool +} + +func (d *reviewR5OfflineDirectory) Cell(ctx context.Context, id string) (directory.CellView, error) { + if d.down.Load() { + return directory.CellView{}, directory.ErrUnreachable + } + return d.Client.Cell(ctx, id) +} + +type reviewR5Engine struct{ started chan struct{} } + +func (e *reviewR5Engine) Start(context.Context, session.RunSpec) session.RunSummary { + close(e.started) + return session.RunSummary{Outcome: "fixture complete", KeepCopy: true} +} +func (e *reviewR5Engine) Land(context.Context, *plandb.Store, string, string, string) (session.RunLanding, error) { + return session.RunLanding{}, nil +} +func TestIdleRelease_R5_2_HeldTaskStartsDuringRelayOutage(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + h := newTwoHomes(t) + mustNil(t, h.b.putDevice(t.Context(), nameB)) + dir := &reviewR5OfflineDirectory{Client: h.a.Dir} + h.a.Dir = dir + c := h.openA() + raw, err := os.ReadFile(transcriptOf(h.cell)) + mustNil(t, err) + mustNil(t, os.WriteFile(transcriptOf(h.cell), bytes.Replace(raw, []byte(`"version":3`), []byte(`"version":1`), 1), 0600)) + appendTo(t, transcriptOf(h.cell), fmt.Sprintf(`{"type":"message","role":"user","content":"seed held chat","timestamp":%q}`+"\n", h.clock.Now().UTC().Format(time.RFC3339Nano))) + engine := &reviewR5Engine{started: make(chan struct{})} + session.RegisterRunEngine(engine) + t.Cleanup(func() { session.RegisterRunEngine(nil) }) + work := func() error { + ctx, cancel := context.WithTimeout(t.Context(), 750*time.Millisecond) + defer cancel() + _, _ = c.drive.ConfirmWork(ctx) + return c.drive.Gate() + } + prepared := func() error { + if gate, ok := any(c.drive).(interface{ ConfirmPreparedWork(context.Context) error }); ok { + ctx, cancel := context.WithTimeout(t.Context(), 750*time.Millisecond) + defer cancel() + return gate.ConfirmPreparedWork(ctx) + } + return c.drive.Gate() + } + a, err := session.New(session.Config{Workspace: h.work, Place: session.Place{Dir: h.cell.Root, Workspace: h.work}, SessionFile: transcriptOf(h.cell), Seat: executor.Gated(c.seat, c.drive.Gate, work, prepared), Model: "stub/chat", APIKey: "stub", BaseURL: "http://127.0.0.1:1", System: "SYSTEM", ProfileDir: h.homeA, TaskMaxLoad: 0, TaskMinFreeMB: 0, RouteCrew: func(config.CrewAsk) (crewroute.Decision, error) { + return crewroute.Decision{Mode: crewroute.ModeOne}, nil + }}) + mustNil(t, err) + t.Cleanup(func() { dir.down.Store(false); _ = a.Close() }) + a.SettleWrites() + _, err = c.drive.Store(h.engine).Seal(t.Context(), h.cell, cellstore.TurnInfo{}) + mustNil(t, err) + h.durable(h.cell.ID, h.cell) + before, err := dir.Cell(t.Context(), h.cell.ID) + mustNil(t, err) + dir.down.Store(true) + id, _, _, err := a.StartTask(t.Context(), "repair fixtures", true) + mustNil(t, err) + select { + case <-engine.started: + t.Logf("task %d started with retained hold fence %d", id, before.Cell.Lease.Fence) + case <-time.After(2 * time.Second): + t.Errorf("accepted task %d never entered its run engine; gate=%v; index=%+v", id, c.drive.Gate(), a.TaskIndex()) + } + dir.down.Store(false) +} + +func TestIdleRelease_R5_2_ReadFailureDoesNotAdmitReleasedOrTakenWriter(t *testing.T) { + for _, taken := range []bool{false, true} { + t.Run(map[bool]string{false: "released", true: "taken"}[taken], func(t *testing.T) { + h := newTwoHomes(t) + dir := &reviewR5OfflineDirectory{Client: h.a.Dir} + h.a.Dir = dir + c := idleContractChat(t, h) + idleContractReleased(t, h, c.drive) + if taken { + _, err := h.continuerB().OpenAvailable(t.Context(), h.cell.ID, nil) + mustNil(t, err) + _, err = c.drive.ConfirmWork(t.Context()) + mustNil(t, err) + } + dir.down.Store(true) + if err := c.drive.ConfirmPreparedWork(t.Context()); err == nil { + t.Fatal("unreachable relay admitted a released or taken writer") + } + var refusal *executor.WorkRefusal + if err := c.drive.ConfirmSettingsWork(t.Context()); !errors.As(err, &refusal) { + t.Fatalf("setting admission lost its typed refusal: %v", err) + } + }) + } +} diff --git a/internal/syncsetup/idle_r5_setting_test.go b/internal/syncsetup/idle_r5_setting_test.go new file mode 100644 index 0000000000..e522f9022d --- /dev/null +++ b/internal/syncsetup/idle_r5_setting_test.go @@ -0,0 +1,58 @@ +package syncsetup + +import ( + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/session" +) + +func TestIdleRelease_R5_1_SettingAfterIdleReleaseSurvivesOpen(t *testing.T) { + h := newTwoHomes(t) + c := idleContractChat(t, h) + a, _ := idleContractSession(t, h, c) + before := idleContractReleased(t, h, c.drive) + if !a.SetConversationEffort("high") { + t.Fatal("valid setting refused") + } + a.SettleWrites() + seen := idleContractReleased(t, h, c.drive) + got, err := h.continuerB().OpenAvailable(t.Context(), h.cell.ID, &directory.LeaseExpectation{Device: seen.Cell.Lease.Device, Fence: seen.Cell.Lease.Fence}) + mustNil(t, err) + restored, err := session.LoadMeta(got.Taken.Cell.Root) + mustNil(t, err) + if restored.Effort != "high" { + t.Errorf("setting accepted after release did not reach B: effort=%q", restored.Effort) + } + if seen.Cell.Lease.Fence <= before.Cell.Lease.Fence { + t.Error("setting did not acquire a new hold before saving") + } +} + +func TestIdleRelease_R5_1_SettingBeforeReleaseDoesNotCancelHandoff(t *testing.T) { + h := newTwoHomes(t) + c := idleContractChat(t, h) + a, _ := idleContractSession(t, h, c) + c.drive.Idle() + if !a.SetConversationEffort("high") { + t.Fatal("effort refused") + } + a.SettleWrites() + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + v, err := h.a.Dir.Cell(t.Context(), h.cell.ID) + if err == nil && v.Cell.Lease.Expires == 0 { + got, err := h.continuerB().OpenAvailable(t.Context(), h.cell.ID, nil) + mustNil(t, err) + m, err := session.LoadMeta(got.Taken.Cell.Root) + mustNil(t, err) + if m.Effort != "high" { + t.Fatalf("received effort=%q", m.Effort) + } + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("a setting before the release canceled handoff permanently") +} diff --git a/internal/syncsetup/idle_r5_state_test.go b/internal/syncsetup/idle_r5_state_test.go new file mode 100644 index 0000000000..646cafc665 --- /dev/null +++ b/internal/syncsetup/idle_r5_state_test.go @@ -0,0 +1,35 @@ +package syncsetup + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestIdleRelease_R5_5_OrdinaryStateRoundWaitsForCompetingFlush(t *testing.T) { + h := newTwoHomes(t) + mustNil(t, h.a.putDevice(t.Context(), nameA)) + entered, release, ended := make(chan struct{}), make(chan struct{}), make(chan struct{}) + go func() { stateMu.Lock(); defer stateMu.Unlock(); defer close(ended); close(entered); <-release }() + defer func() { close(release); <-ended }() + <-entered + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + result := make(chan error, 1) + go func() { result <- h.a.SyncState(ctx) }() + select { + case err := <-result: + t.Fatalf("ordinary state round did not serialize behind the flush: %v", err) + case <-time.After(500 * time.Millisecond): + } + cancel() + select { + case err := <-result: + if !errors.Is(err, context.Canceled) { + t.Fatalf("state round cancellation=%v", err) + } + case <-time.After(time.Second): + t.Fatal("state round ignored its caller's cancellation") + } +} diff --git a/internal/syncsetup/idle_r6_fencing_test.go b/internal/syncsetup/idle_r6_fencing_test.go new file mode 100644 index 0000000000..7e9105c9b0 --- /dev/null +++ b/internal/syncsetup/idle_r6_fencing_test.go @@ -0,0 +1,131 @@ +package syncsetup + +import ( + "bytes" + "context" + "errors" + "os" + "strings" + "testing" + "time" + + "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/executor" + "github.com/Agent-Field/codeaf/internal/session" +) + +type r6AcquireDirectory struct { + directory.Client + acquires []directory.AcquireOpts +} + +func (d *r6AcquireDirectory) Acquire(ctx context.Context, id string, opts directory.AcquireOpts) (directory.CellView, error) { + d.acquires = append(d.acquires, opts) + return d.Client.Acquire(ctx, id, opts) +} + +func TestIdleRelease_R6_LapsedHolderIsFencedAfterAnOpenElsewhere(t *testing.T) { + h := newTwoHomes(t) + c := idleContractChat(t, h) + d := c.drive + a, _ := idleContractSession(t, h, c, func(cfg *session.Config) { + // Match foreground admission: a failed confirmation leaves the drive's + // typed refusal intact, including the holder's name. + cfg.Seat = executor.Gated(c.seat, d.Gate, func() error { + if _, err := d.ConfirmWork(t.Context()); err != nil { + return err + } + return d.Gate() + }) + }) + d.SetIdleCheck(func() bool { return false }) + d.Working(true) + // Freeze A's renewal and publication loops while its unfinished work stays + // on disk, then lapse the relay clock without waiting ninety real seconds. + d.cancel() + <-d.done + old, err := h.a.Dir.Cell(t.Context(), h.cell.ID) + mustNil(t, err) + _, err = h.a.Dir.Heartbeat(t.Context(), h.cell.ID, directory.Beat{Fence: old.Cell.Lease.Fence, Working: true}) + mustNil(t, err) + h.block.set(true) + t.Cleanup(func() { h.block.set(false) }) + c.mustSay("unfinished local turn during the outage") + localHead := h.head() + localWords, err := os.ReadFile(transcriptOf(h.cell)) + mustNil(t, err) + h.clock.advance(directory.LeaseTTL + time.Second) + mustNil(t, d.CheckLease(t.Context())) + if confirmed, err := d.ConfirmWork(t.Context()); err != nil || confirmed { + t.Fatalf("expired holder retook automatically: confirmed=%v err=%v", confirmed, err) + } + // (a) The real tool seat and durable-save door both reject new work. + ran := false + err = c.seat.Around(t.Context(), executor.Call{Tool: "bash"}, func() ([]byte, bool) { + ran = true + return nil, false + }) + if err == nil || ran { + t.Fatalf("expired holder admitted a new tool: ran=%v err=%v", ran, err) + } + if _, err := d.Store(h.engine).Seal(t.Context(), h.cell, cellstore.TurnInfo{}); !errors.Is(err, cellstore.ErrReadOnly) { + t.Fatalf("expired holder admitted a new durable write: %v", err) + } + // (b) Under R6-5/R6-6, B opens this lapsed chat after confirming its card. + // That consent still uses the conditional acquire and a strictly new fence. + seen, err := h.b.Dir.Cell(t.Context(), h.cell.ID) + mustNil(t, err) + if seen.Cell.Lease.Expires == 0 || seen.Cell.Lease.Expires > seen.Now || !seen.Cell.Lease.Working { + t.Fatalf("fixture did not lapse while working: %+v", seen) + } + record := &r6AcquireDirectory{Client: h.b.Dir} + h.b.Dir = record + continuer := h.continuerB() + expect := &directory.LeaseExpectation{Device: seen.Cell.Lease.Device, Fence: seen.Cell.Lease.Fence} + if _, err := continuer.OpenAvailable(t.Context(), h.cell.ID, expect); !errors.Is(err, directory.ErrLeaseHeld) || len(record.acquires) != 0 { + t.Fatalf("B bypassed the lapsed holder's card: err=%v acquires=%d", err, len(record.acquires)) + } + got, err := continuer.TakeSeen(t.Context(), h.cell.ID, expect) + mustNil(t, err) + if len(record.acquires) != 1 { + t.Fatalf("B acquired %d times", len(record.acquires)) + } + claim := record.acquires[0] + if !claim.RequireConditional || claim.Force || claim.Expect == nil || claim.Expect.Device != old.Cell.Lease.Device || claim.Expect.Fence != old.Cell.Lease.Fence || got.Taken.Driving.Fence <= old.Cell.Lease.Fence { + t.Fatalf("B's open was not conditional at a new fence: %+v, fence=%d", claim, got.Taken.Driving.Fence) + } + bHead := got.Taken.Driving.Head + // (c) A's delayed publish reaches the directory with its original fence. + _, err = h.a.Dir.Publish(t.Context(), h.cell.ID, directory.Publish{Fence: old.Cell.Lease.Fence, OldHead: old.Cell.Head, Head: localHead}) + if !errors.Is(err, directory.ErrFenceStale) { + t.Fatalf("directory accepted A's stale publish: %v", err) + } + fresh, err := h.b.Dir.Cell(t.Context(), h.cell.ID) + mustNil(t, err) + if fresh.Cell.Head != bHead || fresh.Cell.Lease.Fence != got.Taken.Driving.Fence || fresh.Cell.Lease.Device != h.b.Device.ID() { + t.Fatalf("stale A publish changed B's head or hold: %+v", fresh) + } + // (d) Neither the local journal nor its sealed head is lost when A learns + // about B. Its background loop remains frozen, so pending branch upload is + // left for the normal publication retry after this check. + h.block.set(false) + mustNil(t, d.CheckLease(t.Context())) + after, err := os.ReadFile(transcriptOf(h.cell)) + mustNil(t, err) + if !bytes.Equal(localWords, after) || h.head() != localHead || localHead == old.Cell.Head || !bytes.Contains(after, []byte("unfinished local turn during the outage")) { + t.Fatal("A's unfinished local turn was lost") + } + // (e) The next real session send refuses with B's name before journaling. + events, err := a.Submit(t.Context(), "new work must stay unsent") + var refusal *executor.WorkRefusal + if events != nil || !errors.As(err, &refusal) || refusal.HolderID != h.b.Device.ID() || !strings.Contains(refusal.Line, chatlist.Superseded(nameB)) { + t.Fatalf("A's next send did not say held elsewhere once: stream=%v err=%v gate=%v", events != nil, err, d.Gate()) + } + after, err = os.ReadFile(transcriptOf(h.cell)) + mustNil(t, err) + if !bytes.Equal(localWords, after) { + t.Fatal("A's refused send wrote to its journal") + } +} diff --git a/internal/syncsetup/idle_r6_retry_test.go b/internal/syncsetup/idle_r6_retry_test.go new file mode 100644 index 0000000000..0450485c0c --- /dev/null +++ b/internal/syncsetup/idle_r6_retry_test.go @@ -0,0 +1,45 @@ +package syncsetup + +import ( + "sync" + "testing" + "time" +) + +func TestIdleRelease_R6_LocalVaultContentionKeepsQuietCadence(t *testing.T) { + h := newTwoHomes(t) + c := idleContractChat(t, h) + d := c.drive + d.SetIdleCheck(func() bool { return true }) + settleIdleFixture(t, h, d) + proof, err := h.engine.SavedState(t.Context(), h.cell, d.profileFiles()...) + mustNil(t, err) + if proof == nil || len(proof.Changes) != 0 { + t.Fatalf("fixture has unsaved changes: %+v", proof) + } + held, resume := make(chan struct{}), make(chan struct{}) + var once sync.Once + unblock := func() { once.Do(func() { close(resume) }) } + defer unblock() + go func() { + d.vaultMu.Lock() + defer d.vaultMu.Unlock() + close(held) + <-resume + }() + <-held + d.Idle() + // A competing ordinary flush spans the first quiet attempt. Once it ends, + // release should use another quiet tick, rather than an outage backoff. + time.Sleep(3 * time.Second) + unblock() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + v, err := h.a.Dir.Cell(t.Context(), h.cell.ID) + if err == nil && v.Cell.Lease.Expires == 0 { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("a completed local flush delayed idle release beyond the next quiet tick") +} diff --git a/internal/syncsetup/idle_r8_close_test.go b/internal/syncsetup/idle_r8_close_test.go new file mode 100644 index 0000000000..e1d73af7d3 --- /dev/null +++ b/internal/syncsetup/idle_r8_close_test.go @@ -0,0 +1,39 @@ +package syncsetup + +import ( + "context" + "testing" + "time" +) + +func TestIdleRelease_R8_CanceledCloseStopsOrdinaryWriter(t *testing.T) { + h := newTwoHomes(t) + c := h.openA() + c.mustSay("ordinary never-released chat") + h.durable(h.cell.ID, h.cell) + d := c.drive + // This test deliberately exercises the expired shared shutdown budget. Clean + // up the test's own surviving run even if Close consumed its once too soon. + defer func() { d.cancel(); <-d.done }() + listing, err := h.a.Dir.List(t.Context()) + mustNil(t, err) + t.Logf("registered devices=%d; never called Idle", len(listing.Devices)) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + closeErr := d.Close(ctx) + done := false + select { + case <-d.done: + done = true + case <-time.After(200 * time.Millisecond): + } + t.Logf("Close(canceled)=%v; gate=%v; drive_done=%v", closeErr, d.Gate(), done) + if d.Gate() == nil { + t.Error("closed ordinary writer still admits work") + } + if !done { + t.Error("Close left the ordinary publisher and heartbeat run alive") + } + again := d.Close(t.Context()) + t.Logf("second Close=%v; gate=%v", again, d.Gate()) +} diff --git a/internal/syncsetup/idle_r8_failure_test.go b/internal/syncsetup/idle_r8_failure_test.go new file mode 100644 index 0000000000..30664b340b --- /dev/null +++ b/internal/syncsetup/idle_r8_failure_test.go @@ -0,0 +1,249 @@ +package syncsetup + +import ( + "context" + "errors" + "fmt" + "net" + "net/url" + "os" + "slices" + "syscall" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "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/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/directory/directorytest" +) + +type r8ReleaseDirectory struct { + directory.Client + failure error + commit bool + clock *directorytest.FakeClock + readErr error +} + +func (d *r8ReleaseDirectory) Cell(ctx context.Context, id string) (directory.CellView, error) { + if d.readErr != nil { + return directory.CellView{}, d.readErr + } + return d.Client.Cell(ctx, id) +} + +func (d *r8ReleaseDirectory) Release(ctx context.Context, id string, fence uint64) error { + if d.failure != nil && !d.commit { + return d.failure + } + if err := d.Client.Release(ctx, id, fence); err != nil { + return err + } + return d.failure +} + +func TestIdleRelease_R8_UncertainReleaseRetakesOnSend(t *testing.T) { + for _, tc := range []struct { + name string + cause error + commit bool + }{ + {"lost reply before commit", context.DeadlineExceeded, false}, + {"lost reply after commit", context.DeadlineExceeded, true}, + {"connection reset", &net.OpError{Op: "read", Net: "tcp", Err: syscall.ECONNRESET}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + d, dir := r8IdleDrive(t) + fence := d.batcher.Fence() + dir.failure, dir.commit = tc.cause, tc.commit + finished, err := d.releaseIdle(t.Context(), d.activity.Load()) + if finished || !errors.Is(err, tc.cause) || !d.IdleReleased() || d.Gate() == nil { + t.Fatalf("uncertain release=%v error=%v released=%v gate=%v", finished, err, d.IdleReleased(), d.Gate()) + } + if _, _, holding := d.RenewalStatus(); holding { + t.Fatal("uncertain release still claims a renewing hold") + } + if _, _, err := d.sync.SendLease(d.cell.ID); !os.IsNotExist(err) { + t.Fatalf("uncertain release kept send receipt: %v", err) + } + // A later refused retry cannot undo an earlier uncertain request. + dir.failure, dir.commit = &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED}, false + _, err = d.releaseIdle(t.Context(), d.activity.Load()) + if !errors.Is(err, syscall.ECONNREFUSED) || !d.IdleReleased() { + t.Fatalf("refused retry revived uncertain release: %v", err) + } + dir.failure = nil + confirmed, err := d.ConfirmWork(t.Context()) + if err != nil || !confirmed || d.Gate() != nil || d.IdleReleased() || d.batcher.Fence() != fence+1 { + t.Fatalf("send did not retake: confirmed=%v error=%v fence=%d gate=%v", confirmed, err, d.batcher.Fence(), d.Gate()) + } + _, receipt, err := d.sync.SendLease(d.cell.ID) + if err != nil || receipt != fence+1 { + t.Fatalf("retaken receipt=%d error=%v", receipt, err) + } + }) + } +} + +// r8IdleDrive has no background loops or network. The real local engine proves +// the saved state, while the memory directory and clock control ownership. +func r8IdleDrive(t *testing.T) (*Drive, *r8ReleaseDirectory) { + t.Helper() + t.Setenv("CODEAF_IDLE_HANDOFF", "on") + celltest.RequireEngine(t) + clock := directorytest.NewFakeClock() + d := sameComputerDrive(t, clock.Now) + c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.HostBound}) + mustNil(t, err) + c.ID = d.cell.ID + d.cell, d.batcher.Driving.Cell = c, c + d.sync.Store = blobstore.NewMemory() + bin := celltest.Engine(t) + d.engine = cellstore.Engine{Binary: bin, DataRoot: t.TempDir(), Transport: cellstore.Spawn{Binary: bin}} + d.files, err = d.sync.files(d.engine, d.cell) + mustNil(t, err) + sealed, err := d.engine.Seal(t.Context(), d.cell, cellstore.TurnInfo{}) + mustNil(t, err) + mustNil(t, cellstore.Captured(t.Context(), d.cell)) + _, err = d.sync.Dir.Publish(t.Context(), d.cell.ID, directory.Publish{Fence: d.batcher.Fence(), Head: sealed.Turn.ID}) + mustNil(t, err) + d.batcher.Driving.Head = sealed.Turn.ID + dir := &r8ReleaseDirectory{Client: d.sync.Dir, clock: clock} + d.sync.Dir, d.batcher.Publisher.Dir = dir, dir + d.batcher.Sleep = func(context.Context, time.Duration) error { return context.DeadlineExceeded } + d.devices = &DeviceObserver{home: d.sync.Home, binding: deviceBindingFor(Resolve(d.sync.Home).URL, d.sync.Identity, d.sync.Device), state: DeviceSnapshot{Complete: true, Devices: map[string]directory.Device{"A": {}, "B": {}}}} + d.idleCheck = func() bool { return true } + return d, dir +} + +func TestIdleRelease_R8_RetrySettlesAfterRecovery(t *testing.T) { + for _, uncertain := range []bool{false, true} { + t.Run(fmt.Sprint(uncertain), func(t *testing.T) { + d, dir := r8IdleDrive(t) + var waits []time.Duration + dir.failure = &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED} + if uncertain { + dir.failure = context.DeadlineExceeded + } + d.opt.Sleep = func(ctx context.Context, wait time.Duration) error { + waits = append(waits, wait) + dir.clock.Advance(wait) + if len(waits) == 7 { + dir.failure = nil + } + if len(waits) > 7 { + t.Fatal("idle release did not settle on recovery") + } + if _, _, holding := d.RenewalStatus(); holding { + _, err := dir.Heartbeat(ctx, d.cell.ID, directory.Beat{Fence: 1}) + mustNil(t, err) + } else if !uncertain { + t.Fatal("a refused release stopped renewing during retry backoff") + } + return nil + } + d.releaseWhenIdle(t.Context(), d.activity.Load()) + want := []time.Duration{2 * time.Second, 5 * time.Second, 10 * time.Second, 20 * time.Second, 40 * time.Second, time.Minute, time.Minute} + if !slices.Equal(waits, want) || !d.releaseDone.Load() || !d.IdleReleased() { + t.Fatalf("recovery did not settle at bounded backoff: waits=%v done=%v released=%v", waits, d.releaseDone.Load(), d.IdleReleased()) + } + v, err := dir.Cell(t.Context(), d.cell.ID) + mustNil(t, err) + if v.Cell.Lease.Fence != 1 || v.Cell.Lease.Expires != 0 { + t.Fatalf("retry did not release the original hold: %+v", v.Cell.Lease) + } + }) + } +} + +func TestIdleRelease_R8_LongTurnAfterOutageStillReleases(t *testing.T) { + d, dir := r8IdleDrive(t) + running := true + d.idleCheck = func() bool { return !running } + d.Working(true) + for range 10 { + dir.clock.Advance(30 * time.Second) + _, err := dir.Heartbeat(t.Context(), d.cell.ID, directory.Beat{Fence: 1, Working: true}) + mustNil(t, err) + } + dir.clock.Advance(20 * time.Second) + dir.readErr = &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED} + mustNil(t, d.ConfirmPreparedWork(t.Context())) + mustNil(t, d.CheckLease(t.Context())) + if finished, err := d.releaseIdle(t.Context(), d.activity.Load()); finished || err != nil { + t.Fatalf("running turn ended idle retries: finished=%v error=%v", finished, err) + } + if line, viewing := d.Viewer(); viewing || line != "" { + t.Fatalf("outage created a permanent viewer: %q", line) + } + dir.readErr = nil + _, err := dir.Heartbeat(t.Context(), d.cell.ID, directory.Beat{Fence: 1, Working: true}) + mustNil(t, err) + d.Working(false) + running = false + available := make(chan Viewer, 1) + d.opt.OnViewer = func(v Viewer) { available <- v } + d.opt.Sleep = func(ctx context.Context, wait time.Duration) error { + dir.clock.Advance(wait) + return ctx.Err() + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) + defer cancel() + mustNil(t, d.stopIdle(ctx)) + }) + // Use the real turn-end door, including its capture join and scheduler. + d.Idle() + select { + case viewer := <-available: + if viewer.Line != chatlist.Available || !d.releaseDone.Load() || !d.idleRequested.Load() { + t.Fatalf("finished long turn did not release: viewer=%+v done=%v requested=%v", viewer, d.releaseDone.Load(), d.idleRequested.Load()) + } + case <-t.Context().Done(): + t.Fatal("turn-end Idle did not schedule a release") + } + d.idleWorkers.Wait() + v, err := dir.Cell(t.Context(), d.cell.ID) + mustNil(t, err) + if v.Cell.Lease.Fence != 1 || v.Cell.Lease.Expires != 0 { + t.Fatalf("long turn lapsed instead of releasing: %+v", v.Cell.Lease) + } +} + +func TestIdleRelease_R8_RefusedReleaseKeepsHoldingSameFence(t *testing.T) { + d, dir := r8IdleDrive(t) + before := d.sendLeaseSnapshot() + var notices []Viewer + d.opt.OnViewer = func(v Viewer) { notices = append(notices, v) } + dir.failure = fmt.Errorf("%w: %w", directory.ErrUnreachable, &url.Error{Op: "Post", URL: "http://fixture.invalid/release", Err: &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED}}) + finished, err := d.releaseIdle(t.Context(), d.activity.Load()) + if finished || !errors.Is(err, syscall.ECONNREFUSED) { + t.Fatalf("release finished=%v error=%v", finished, err) + } + if d.IdleReleased() || d.Gate() != nil || d.sendLeaseSnapshot() != before { + t.Fatalf("refused release changed holding state: released=%v gate=%v receipt=%+v", d.IdleReleased(), d.Gate(), d.sendLeaseSnapshot()) + } + if line, viewer := d.Viewer(); viewer || line != "" || len(notices) != 0 { + t.Fatalf("refused release changed viewer or emitted Available: line=%q viewer=%v notices=%v", line, viewer, notices) + } + if _, _, holding := d.RenewalStatus(); !holding || d.batcher.Fence() != before.Fence { + t.Fatalf("refused release lost renewal: holding=%v fence=%d", holding, d.batcher.Fence()) + } + id, fence, err := d.sync.SendLease(d.cell.ID) + if err != nil || id != before.Cell || fence != before.Fence { + t.Fatalf("refused release lost send receipt: %q %d %v", id, fence, err) + } + dir.failure = nil + confirmed, err := d.ConfirmWork(t.Context()) + if err != nil || !confirmed || d.batcher.Fence() != before.Fence { + t.Fatalf("recovered send did not retain fence: confirmed=%v error=%v fence=%d", confirmed, err, d.batcher.Fence()) + } + finished, err = d.releaseIdle(t.Context(), d.activity.Load()) + if err != nil || !finished || !d.IdleReleased() || len(notices) != 1 || notices[0].Line != chatlist.Available { + t.Fatalf("normal idle retry did not release: finished=%v error=%v notices=%v", finished, err, notices) + } +} diff --git a/internal/syncsetup/idle_r8_observation_test.go b/internal/syncsetup/idle_r8_observation_test.go new file mode 100644 index 0000000000..4184ec11f2 --- /dev/null +++ b/internal/syncsetup/idle_r8_observation_test.go @@ -0,0 +1,70 @@ +package syncsetup + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +func TestIdleRelease_R8_LateReleasedObservationCannotRefuseRetakenWork(t *testing.T) { + h := newTwoHomes(t) + c := idleContractChat(t, h) + previous := c.drive.sendLeaseSnapshot() + a, _ := idleContractSession(t, h, c) + d := c.drive + before := idleContractReleased(t, h, d) + held := d.sendLeaseSnapshot() + if held.Fence != 0 { + t.Fatal("idle release retained its send receipt") + } + done := make(chan error, 1) + func() { + d.receiptMu.Lock() + defer d.receiptMu.Unlock() + go func() { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + confirmed, err := d.ConfirmWork(ctx) + if err == nil && !confirmed { + err = context.Canceled + } + done <- err + }() + waitFor(t, "reacquisition before its send receipt", func() bool { + return d.batcher.Fence() > before.Cell.Lease.Fence && d.Gate() == nil + }) + // Deliver the old free reading after the new acquisition has cleared + // the viewer, but before it can write its receipt. No ownership or + // readiness result is substituted. + d.availableAfter(held, before.Cell.Lease) + }() + mustNil(t, <-done) + if err := d.Gate(); err != nil { + t.Fatalf("retake acquired fence %d but still refuses work: %v", d.batcher.Fence(), err) + } + if d.IdleReleased() { + t.Fatal("retaken window still reports released") + } + _, fence, err := h.a.SendLease(h.cell.ID) + mustNil(t, err) + if fence != d.batcher.Fence() { + t.Fatalf("retake receipt=%d; acquired=%d", fence, d.batcher.Fence()) + } + // A second observation after receipt publication must be harmless too. + d.availableAfter(held, before.Cell.Lease) + d.availableAfter(previous, before.Cell.Lease) + if err := d.Gate(); err != nil { + t.Fatal(err) + } + events, err := a.SubmitBash(t.Context(), "! printf ran > r8-f1-ran.txt") + mustNil(t, err) + for range events { + } + raw, err := os.ReadFile(filepath.Join(h.work, "r8-f1-ran.txt")) + mustNil(t, err) + if string(raw) != "ran" { + t.Fatalf("retaken ! send did not run: %q", raw) + } +} diff --git a/internal/syncsetup/idle_r8_rename_test.go b/internal/syncsetup/idle_r8_rename_test.go new file mode 100644 index 0000000000..43c5f53425 --- /dev/null +++ b/internal/syncsetup/idle_r8_rename_test.go @@ -0,0 +1,44 @@ +package syncsetup + +import ( + "bytes" + "os" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" +) + +func TestIdleRelease_R8_ManualRenameReachesReceiverOrRefuses(t *testing.T) { + h := newTwoHomes(t) + c := idleContractChat(t, h) + a, _ := idleContractSession(t, h, c) + before := idleContractReleased(t, h, c.drive) + chosen := "Kept after release" + mustNil(t, a.RenameByPerson(chosen)) + a.SettleWrites() + // Observe the setter's own save/release. Do not seal or signal Idle here. + waitFor(t, "manual name and journal to reach a new released fence", func() bool { + v, err := h.a.Dir.Cell(t.Context(), h.cell.ID) + return err == nil && v.Cell.Lease.Fence > before.Cell.Lease.Fence && v.Cell.Lease.Expires == 0 + }) + got, err := h.continuerB().OpenAvailable(t.Context(), h.cell.ID, nil) + mustNil(t, err) + meta, err := session.LoadMeta(got.Taken.Cell.Root) + mustNil(t, err) + raw, err := os.ReadFile(transcriptOf(got.Taken.Cell)) + mustNil(t, err) + if meta.Title != chosen || !strings.Contains(string(raw), chosen) { + t.Fatalf("received title=%q journal=%s", meta.Title, raw) + } + prior, err := os.ReadFile(transcriptOf(h.cell)) + mustNil(t, err) + if err := a.RenameByPerson("refused words"); err == nil { + t.Fatal("rename in the displaced reader acknowledged") + } + after, err := os.ReadFile(transcriptOf(h.cell)) + mustNil(t, err) + if a.Title() != chosen || !bytes.Equal(prior, after) { + t.Fatal("refused rename mutated the name or journal") + } +} diff --git a/internal/syncsetup/idle_r9_matrix_test.go b/internal/syncsetup/idle_r9_matrix_test.go new file mode 100644 index 0000000000..40fa2e066e --- /dev/null +++ b/internal/syncsetup/idle_r9_matrix_test.go @@ -0,0 +1,609 @@ +package syncsetup + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/cellsync" + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/directory/directorytest" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/handoff" + "github.com/Agent-Field/codeaf/internal/syncwords" +) + +// The table enumerates the Cartesian product before filtering unreachable +// states. Each excluded cell retains its reason in the test output and notes. +type r9Cell struct { + relay, local, service, turn, unpublished string + impossible, want string + ordering, inFlight, observer string +} + +func r9Matrix() []r9Cell { + var table []r9Cell + for _, relay := range []string{"live", "revived", "lapsed", "released", "other_live", "other_released", "other_lapsed"} { + for _, local := range []string{"holding", "expired", "self_released", "release_uncertain", "release_undone", "stopped", "acquire_uncertain"} { + for _, service := range []string{"answering", "refusing", "hanging"} { + for _, turn := range []string{"idle", "working", "call_in_flight_at_expiry"} { + for _, unpublished := range []string{"none", "some"} { + c := r9Cell{relay: relay, local: local, service: service, turn: turn, unpublished: unpublished} + switch { + case turn == "call_in_flight_at_expiry": + switch { + case local != "expired": + c.impossible = "This call starts under a held fence and finishes after observed expiry." + case unpublished != "some": + c.impossible = "The finished in-flight call produces one unpublished seal." + case relay != "lapsed" && relay != "other_live": + c.impossible = "The late-call axis selects an unchanged expired fence or a live newer holder." + default: + c.want = "late_seal" + } + case turn == "working" && local != "holding" && local != "release_undone": + c.impossible = "A retired writer cannot start a new turn; an earlier call finishing after expiry has its own axis." + case local == "expired" && relay == "live": + c.impossible = "A live original fence after observed expiry is the revived row." + case local == "self_released" && relay == "live": + c.impossible = "A live fence after confirmed release is the revived row." + case unpublished == "some" && (local == "self_released" || local == "release_uncertain" || local == "acquire_uncertain"): + c.impossible = "Release requires no pending turns, and released or uncertain acquisition admits no new work." + case local == "stopped": + c.want = "replace" + case service != "answering" && local != "holding" && local != "release_undone": + c.want = "unreachable" + case service != "answering": + c.want = "offline" + case relay == "other_live" || relay == "other_released" || relay == "other_lapsed": + c.want = "consent" + case (local == "holding" || local == "release_undone") && (relay == "live" || relay == "revived"): + c.want = "same" + case local == "self_released" || local == "release_uncertain" || local == "acquire_uncertain": + c.want = "acquire" + default: + c.want = "reopen" + } + table = append(table, c) + } + } + } + } + } + return append(table, r10OrderingMatrix()...) +} + +func (c r9Cell) name() string { + if c.ordering != "" { + name := "ordering/" + c.relay + "/" + c.turn + "/" + c.unpublished + "/" + c.inFlight + "/" + c.ordering + if c.observer != "" { + name += "/" + c.observer + } + return name + } + return c.relay + "/" + c.local + "/" + c.service + "/" + c.turn + "/" + c.unpublished +} + +type r9Directory struct { + directory.Client + failure error + reads int + takes []directory.AcquireOpts + notices chan string +} + +func (d *r9Directory) Cell(ctx context.Context, id string) (directory.CellView, error) { + d.reads++ + if d.failure != nil { + return directory.CellView{}, d.failure + } + return d.Client.Cell(ctx, id) +} + +func (d *r9Directory) Acquire(ctx context.Context, id string, opt directory.AcquireOpts) (directory.CellView, error) { + d.takes = append(d.takes, opt) + if d.failure != nil { + return directory.CellView{}, d.failure + } + return d.Client.Acquire(ctx, id, opt) +} + +func (d *r9Directory) Publish(ctx context.Context, id string, in directory.Publish) (directory.CellView, error) { + if d.failure != nil { + return directory.CellView{}, d.failure + } + return d.Client.Publish(ctx, id, in) +} + +func (d *r9Directory) Create(ctx context.Context, id string, in directory.CellInit) (directory.CellView, error) { + if d.failure != nil { + return directory.CellView{}, d.failure + } + return d.Client.Create(ctx, id, in) +} + +func r9Fixture(t *testing.T, c r9Cell, clocks ...*directorytest.FakeClock) (*Drive, *r9Directory) { + t.Helper() + clock := directorytest.NewFakeClock() + if len(clocks) != 0 { + clock = clocks[0] + } + d, memory := sameComputerDriveWithDirectory(t, clock.Now) + d.idleNotices.Store(true) + d.devices = &DeviceObserver{home: d.sync.Home, binding: deviceBindingFor(Resolve(d.sync.Home).URL, d.sync.Identity, d.sync.Device), state: DeviceSnapshot{Complete: true, Devices: map[string]directory.Device{"A": {}, "B": {}}}} + d.batcher.Now = clock.Now + mustNil(t, os.MkdirAll(filepath.Join(d.cell.Root, ".cell"), 0700)) + engine := cellsync.NewFakeEngine(t.TempDir()) + metadata := `{"V":1,"class":"files-only","cell_key_id":"00000000000000000000000000000000"}` + saved := engine.Seal(d.cell, map[string]string{"journal": "saved", cell.MetaPath: metadata}) + adopt := func(head string) { + t.Helper() + mustNil(t, os.WriteFile(filepath.Join(d.cell.Root, ".cell", "adopted.json"), []byte(fmt.Sprintf(`{"V":1,"head":%q,"turns_size":0}`, head)), 0600)) + } + adopt(saved) + d.batcher.Publisher.Engine, d.batcher.Publisher.Store = engine, blobstore.NewMemory() + d.batcher.Brancher = cellsync.Brancher{Dir: d.sync.Dir, Publisher: d.batcher.Publisher, NewID: func() (string, error) { return "kept-chat", nil }} + err := d.batcher.Publisher.Publish(t.Context(), d.batcher.Driving, saved, cellsync.PublishInfo{}) + mustNil(t, err) + raw := d.sync.Dir + dir := &r9Directory{Client: raw, notices: make(chan string, 8)} + d.opt.OnNotice = func(line string) { dir.notices <- line } + d.sync.Dir, d.batcher.Publisher.Dir, d.batcher.Brancher.Dir = dir, dir, dir + switch c.local { + case "expired": + clock.Advance(directory.LeaseTTL + time.Second) + mustNil(t, d.CheckLease(t.Context())) + case "self_released", "release_uncertain", "acquire_uncertain": + // The lower release door establishes the real renewal state without + // invoking certification or an engine process in each matrix cell. + lower := &r8ReleaseDirectory{Client: raw} + if c.local != "self_released" { + lower.failure = context.DeadlineExceeded + } + d.batcher.Publisher.Dir = lower + _, _ = d.batcher.ReleaseIdle(t.Context(), saved, func() error { return nil }) + d.released.Store(true) + d.availableAfterWithin(t.Context(), d.sendLeaseSnapshot(), directory.Lease{}) + d.batcher.Publisher.Dir = dir + if c.local == "acquire_uncertain" { + dir.failure = context.DeadlineExceeded + seen, readErr := raw.Cell(t.Context(), d.cell.ID) + mustNil(t, readErr) + _, _ = d.batcher.Reacquire(t.Context(), seen) + dir.failure = nil + d.uncertainAcquire(t.Context()) + } + case "release_undone": + lower := &r8ReleaseDirectory{Client: raw, failure: &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED}} + d.batcher.Publisher.Dir = lower + _, err = d.batcher.ReleaseIdle(t.Context(), saved, func() error { return nil }) + if !errors.Is(err, syscall.ECONNREFUSED) { + t.Fatal(err) + } + d.batcher.Publisher.Dir = dir + case "stopped": + d.stopped = true + } + if c.relay == "revived" { + clock.Advance(directory.LeaseTTL + time.Second) + _, err = raw.Heartbeat(t.Context(), d.cell.ID, directory.Beat{Fence: 1}) + mustNil(t, err) + } + if c.relay == "lapsed" { + if c.local == "self_released" { + _, err = raw.Heartbeat(t.Context(), d.cell.ID, directory.Beat{Fence: 1}) + mustNil(t, err) + } + clock.Advance(directory.LeaseTTL + time.Second) + } + if c.relay == "released" { + mustNil(t, raw.Release(t.Context(), d.cell.ID, 1)) + } + if c.relay == "other_live" || c.relay == "other_released" || c.relay == "other_lapsed" { + other := memory.For("B") + v, takeErr := other.Acquire(t.Context(), d.cell.ID, directory.AcquireOpts{Force: true, Expect: &directory.LeaseExpectation{Device: d.sync.Device.ID(), Fence: 1}}) + mustNil(t, takeErr) + if c.relay == "other_released" { + mustNil(t, other.Release(t.Context(), d.cell.ID, v.Cell.Lease.Fence)) + } else if c.relay == "other_lapsed" { + clock.Advance(directory.LeaseTTL + time.Second) + } + } + if c.unpublished == "some" { + // A turn admitted before the outage can finish with unpublished local work. + head := engine.Seal(d.cell, map[string]string{"journal": "finished during the outage", cell.MetaPath: metadata}) + adopt(head) + d.batcher.Note(cellstore.Turn{ID: head}) + } + switch c.service { + case "refusing": + dir.failure = fmt.Errorf("%w: %w", directory.ErrUnreachable, &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED}) + case "hanging": + dir.failure = context.DeadlineExceeded + } + d.Working(c.turn == "working") + dir.reads, dir.takes = 0, nil + return d, dir +} + +func TestIdleRelease_R9_RevivalRequiresANewFence(t *testing.T) { + d, dir := r9Fixture(t, r9Cell{relay: "revived", local: "expired", service: "answering", turn: "idle"}) + confirmed, err := d.ConfirmWork(t.Context()) + if err != nil || confirmed || d.Gate() == nil || d.batcher.Fence() != 1 || len(dir.takes) != 0 { + t.Fatalf("retired drive must await reopen: confirmed=%v error=%v gate=%v fence=%d takes=%+v", confirmed, err, d.Gate(), d.batcher.Fence(), dir.takes) + } + if _, _, held := d.batcher.RenewalStatus(); held { + t.Fatal("observing expiry left renewal active") + } +} + +func TestIdleRelease_R9_StateMatrix(t *testing.T) { + for _, c := range r9Matrix() { + t.Run(c.name(), func(t *testing.T) { + if c.ordering != "" { + r10Ordering(t, c) + return + } + if c.impossible != "" { + t.Skip(c.impossible) + } + if c.want == "late_seal" { + for _, observation := range []string{"confirm", "check"} { + t.Run(observation, func(t *testing.T) { r9LateSeal(t, c, observation) }) + } + return + } + d, dir := r9Fixture(t, c) + if c.want == "reopen" { + seen, err := dir.Client.Cell(t.Context(), d.cell.ID) + mustNil(t, err) + if d.workOwnership(seen, 1) != workReopen { + t.Fatal("ordinary lapse or outside release must require reopen") + } + confirmed, err := d.ConfirmWork(t.Context()) + if err != nil || confirmed || d.Gate() == nil { + t.Fatalf("the existing drive admitted a reopen cell: confirmed=%v error=%v gate=%v", confirmed, err, d.Gate()) + } + } + err := d.ConfirmPreparedWork(t.Context()) + + head, headErr := cellstore.Head(d.cell) + mustNil(t, headErr) + switch c.want { + case "same", "offline", "acquire": + if err != nil || d.Gate() != nil { + t.Fatalf("want %s admission, got %v (gate %v)", c.want, err, d.Gate()) + } + wantFence := uint64(1) + if c.want == "acquire" { + wantFence++ + if len(dir.takes) != 1 || !dir.takes[0].RequireConditional || dir.takes[0].Force || dir.takes[0].Expect == nil || dir.takes[0].Expect.Device != d.sync.Device.ID() || dir.takes[0].Expect.Fence != 1 { + t.Fatalf("retake lost its exact expectation: %+v", dir.takes) + } + } else if len(dir.takes) != 0 { + t.Fatalf("retained hold acquired again: %+v", dir.takes) + } + _, fence, receiptErr := d.sync.SendLease(d.cell.ID) + if receiptErr != nil || fence != wantFence || d.batcher.Fence() != wantFence { + t.Fatalf("admitted receipt=%d, %v; writer=%d, want %d", fence, receiptErr, d.batcher.Fence(), wantFence) + } + case "reopen": + if err == nil || d.Gate() == nil || len(dir.takes) != 0 || d.batcher.Fence() != 1 { + t.Fatalf("lapsed or outside-released drive must refuse until reopen: error=%v gate=%v takes=%+v", err, d.Gate(), dir.takes) + } + case "replace": + if !errors.Is(err, cellstore.ErrReadOnly) { + t.Fatalf("stopped writer must require replacement: %v", err) + } + case "consent": + if err == nil || len(dir.takes) != 0 || d.Gate() == nil { + t.Fatalf("another holder bypassed consent: %v, takes=%+v", err, dir.takes) + } + var refusal *executor.WorkRefusal + if !errors.As(err, &refusal) || refusal.HolderID != "B" || refusal.Retryable { + t.Fatalf("fresh takeover must name its holder instead of replaying uncertainty: %v", err) + } + <-dir.notices + case "unreachable": + if err == nil || err.Error() != chatlist.TakeBackUnreachable || len(dir.takes) != 0 { + t.Fatalf("unheld outage needs honest retry: %v, takes=%+v", err, dir.takes) + } + // The next Enter uses the same writer and fresh ownership facts. + dir.failure = nil + retry := d.ConfirmPreparedWork(t.Context()) + other := c.relay == "other_live" || c.relay == "other_released" || c.relay == "other_lapsed" + reopen := c.local == "expired" && !other + if (retry != nil) != (other || reopen) { + t.Fatalf("recovery retained a cached refusal: %v", retry) + } + if other { + <-dir.notices + } + } + if c.unpublished == "some" { + // Closing exercises the real drain after admission, retry, replacement or consent. + dir.failure = nil + before, readErr := dir.Client.Cell(t.Context(), d.cell.ID) + mustNil(t, readErr) + mustNil(t, d.batcher.Close(t.Context())) + after, readErr := dir.Client.Cell(t.Context(), d.cell.ID) + mustNil(t, readErr) + other := c.relay == "other_live" || c.relay == "other_released" || c.relay == "other_lapsed" + if other || c.local == "expired" || c.want == "reopen" { + kept, keptErr := dir.Client.Cell(t.Context(), "kept-chat") + if keptErr != nil || kept.Cell.Head != head.Turn.ID || kept.Cell.ParentCell != d.cell.ID || after.Cell.Head != before.Cell.Head || after.Cell.Lease != before.Cell.Lease { + t.Fatalf("takeover lost kept turns or changed the winner: kept=%+v error=%v after=%+v", kept, keptErr, after) + } + } else if after.Cell.Head != head.Turn.ID { + t.Fatalf("unpublished turns stranded: relay=%s local=%s", after.Cell.Head, head.Turn.ID) + } + if d.batcher.Pending() != 0 { + t.Fatal("completed preservation left turns pending") + } + if c.want == "reopen" || c.local == "expired" { + if confirmed, err := d.ConfirmWork(t.Context()); err != nil || confirmed || d.Gate() == nil { + t.Fatalf("draining a late seal restored admission: confirmed=%v error=%v gate=%v", confirmed, err, d.Gate()) + } + if other { + <-dir.notices + } + } + } + }) + } + t.Run("pending_reopen/expired/refusing/idle/some", func(t *testing.T) { + d, dir := r9Fixture(t, r9Cell{relay: "lapsed", local: "expired", service: "refusing", turn: "idle", unpublished: "some"}) + head, err := cellstore.Head(d.cell) + mustNil(t, err) + if err := d.batcher.Close(t.Context()); !syncwords.UploadPending(err) || d.batcher.Pending() != 1 { + t.Fatalf("outage did not retain the seal before reopen: error=%v pending=%d", err, d.batcher.Pending()) + } + dir.failure = nil + r9TakePending(t, d, dir, head.Turn.ID) + }) +} + +// r9LateSeal expires the hold inside a real recorded call, before its output +// and seal exist. Ownership checks and publication use the ordinary drive doors. +func r9LateSeal(t *testing.T, c r9Cell, observation string) { + t.Helper() + r := newDriveRig(t) + ctx := t.Context() + r.spokenFixture() + first, err := r.engine.Seal(ctx, r.cell, cellstore.TurnInfo{}) + mustNil(t, err) + _, err = r.a.Dir.Create(ctx, r.cell.ID, directory.CellInit{Head: first.Turn.ID}) + mustNil(t, err) + mustNil(t, cellstore.NotePublished(r.cell, first.Turn.ID)) + d, err := r.a.Drive(ctx, r.engine, r.cell, DriveOptions{}) + mustNil(t, err) + t.Cleanup(func() { mustNil(t, d.Close(context.Background())) }) + // Stop maintenance so the callback alone chooses the loss observation. + d.cancel() + <-d.done + dir := &r9Directory{Client: r.a.Dir} + d.sync.Dir, d.batcher.Publisher.Dir, d.batcher.Brancher.Dir = dir, dir, dir + confirmed, err := d.ConfirmWork(ctx) + if err != nil || !confirmed || d.Gate() != nil { + t.Fatalf("call did not start under an admitted hold: confirmed=%v error=%v gate=%v", confirmed, err, d.Gate()) + } + before, err := dir.Client.Cell(ctx, r.cell.ID) + mustNil(t, err) + rec, err := cellstore.NewRecorder(executor.Local{}, d.Store(r.engine), r.cell, r.engine.WALPath(r.cell), cellstore.Options{Async: true, Now: r.clock.Now}) + mustNil(t, err) + t.Cleanup(func() { _ = rec.Drain(context.Background()) }) + ran := 0 + mustNil(t, rec.Around(ctx, executor.Call{Tool: "external", Deferred: true}, executor.EffectExternal, cellstore.AgentRun, func() ([]byte, bool) { + ran++ + r.clock.Advance(directory.LeaseTTL + time.Second) + if c.relay == "other_live" { + _, err := r.b.Dir.Acquire(ctx, r.cell.ID, directory.AcquireOpts{RequireConditional: true, Expect: &directory.LeaseExpectation{Device: before.Cell.Lease.Device, Fence: before.Cell.Lease.Fence}}) + mustNil(t, err) + _, err = r.b.Dir.Publish(ctx, r.cell.ID, directory.Publish{Fence: before.Cell.Lease.Fence + 1, OldHead: first.Turn.ID, Head: strings.Repeat("b", 64)}) + mustNil(t, err) + } + if observation == "check" { + mustNil(t, d.CheckLease(ctx)) + } else if ok, err := d.ConfirmWork(ctx); err != nil || ok { + t.Fatalf("loss admitted another call: confirmed=%v error=%v", ok, err) + } + if d.Gate() == nil { + t.Fatal("loss left the new-work gate open inside the earlier call") + } + mustNil(t, os.WriteFile(filepath.Join(r.work, "late-call.txt"), []byte("finished once"), 0600)) + appendTo(t, transcriptOf(r.cell), "{\"type\":\"message\",\"role\":\"tool\",\"content\":\"finished once\"}\n") + return []byte("finished once"), false + })) + mustNil(t, rec.Drain(ctx)) + sealed, err := cellstore.Head(r.cell) + mustNil(t, err) + if sealed == nil || sealed.Turn.Fence != before.Cell.Lease.Fence || len(sealed.Receipt.Calls) != 1 || sealed.Receipt.Calls[0].Started >= sealed.Receipt.Calls[0].Ended || d.batcher.Pending() != 1 || ran != 1 { + t.Fatalf("late call was lost or sealed at a new fence: head=%+v pending=%d runs=%d", sealed, d.batcher.Pending(), ran) + } + output, err := os.ReadFile(filepath.Join(r.cell.Root, cellstore.BlobsDir, sealed.Receipt.Calls[0].StdoutHash)) + if err != nil || string(output) != "finished once" { + t.Fatalf("late call lost its recorded output: output=%q error=%v", output, err) + } + winner, err := dir.Client.Cell(ctx, r.cell.ID) + mustNil(t, err) + if c.service != "answering" { + dir.failure = directory.ErrUnreachable + if c.service == "hanging" { + dir.failure = context.DeadlineExceeded + } + closeErr := d.batcher.Close(ctx) + if closeErr == nil || !syncwords.UploadPending(closeErr) || d.batcher.Pending() != 1 { + t.Fatalf("outage lost the local seal: error=%v pending=%d", closeErr, d.batcher.Pending()) + } + local, err := cellstore.Head(r.cell) + mustNil(t, err) + if local == nil || local.Turn.ID != sealed.Turn.ID || len(local.Receipt.Calls) != 1 { + t.Fatal("outage removed the completed call from local history") + } + dir.failure = nil + } + mustNil(t, d.batcher.Close(ctx)) + after, err := dir.Client.Cell(ctx, r.cell.ID) + mustNil(t, err) + branches, err := branchMapOf(r.homeA) + mustNil(t, err) + id := branches.Resolve(r.cell.ID) + if id == r.cell.ID || after.Cell.Head != winner.Cell.Head || after.Cell.Lease != winner.Cell.Lease { + t.Fatal("late seal lost its branch or changed the winner's head or lease") + } + kept, err := dir.Client.Cell(ctx, id) + mustNil(t, err) + if kept.Cell.Head != sealed.Turn.ID || kept.Cell.ParentCell != r.cell.ID || kept.Cell.OrphanTurns != 1 { + t.Fatal("kept branch did not preserve the completed call") + } + if d.batcher.Pending() != 0 { + t.Fatal("successful preservation left the call pending") + } + if ok, err := d.ConfirmWork(ctx); err != nil || ok || d.Gate() == nil || len(dir.takes) != 0 { + t.Fatalf("late publish restored new-work admission: confirmed=%v error=%v gate=%v takes=%+v", ok, err, d.Gate(), dir.takes) + } + if _, _, held := d.RenewalStatus(); held { + t.Fatal("late publish restored the drive's retired renewal state") + } + if _, _, err := d.sync.SendLease(r.cell.ID); !os.IsNotExist(err) { + t.Fatalf("late publish restored a send receipt: %v", err) + } +} + +// r9TakeLocal supplies the fake engine's sealed-head facts to the real taker. +// There are no unsealed edits or path bindings in this fixture. +type r9TakeLocal struct{ head string } + +func (l r9TakeLocal) Unpublished(_ cell.Cell, remote string) (string, uint32, error) { + if l.head == remote { + return "", 0, nil + } + return l.head, 1, nil +} + +func (r9TakeLocal) Dirty(context.Context, cell.Cell) (bool, error) { return false, nil } +func (r9TakeLocal) Seal(context.Context, cell.Cell) (string, uint32, error) { + return "", 0, errors.New("the fixture has no unsealed edits") +} +func (r9TakeLocal) Follow(context.Context, cell.Cell, cell.Cell) error { return nil } + +func r9TakePending(t *testing.T, d *Drive, dir *r9Directory, head string) { + t.Helper() + seen, err := dir.Client.Cell(t.Context(), d.cell.ID) + mustNil(t, err) + pub := d.batcher.Publisher + brancher := d.batcher.Brancher + taker := handoff.Taker{ + Dir: dir, + Local: r9TakeLocal{head: head}, + Fetch: &cellsync.Fetcher{Engine: pub.Engine, Store: pub.Store, Dir: dir, Inbox: func(cell.Cell) string { return t.TempDir() }}, + RootFor: func(string) string { return d.cell.Root }, + Branch: func(ctx context.Context, from *cellsync.Driving, head string, turns uint32) (string, error) { + return brancher.Branch(ctx, from, head, turns, cellsync.PublishInfo{}) + }, + } + taken, err := taker.TakeSeen(t.Context(), d.cell.ID, &directory.LeaseExpectation{Device: seen.Cell.Lease.Device, Fence: seen.Cell.Lease.Fence}) + mustNil(t, err) + kept, err := dir.Client.Cell(t.Context(), taken.Kept) + mustNil(t, err) + if kept.Cell.Head != head || kept.Cell.ParentCell != d.cell.ID || kept.Cell.OrphanTurns != 1 || taken.Driving.Fence <= seen.Cell.Lease.Fence || taken.Driving.Head != seen.Cell.Head { + t.Fatalf("normal reopen stranded pending turns: taken=%+v kept=%+v", taken, kept) + } + engine := cellsync.NewFakeEngine(t.TempDir()) + copy := cell.Cell{ID: taken.Kept, Root: t.TempDir()} + fetcher := &cellsync.Fetcher{Engine: engine, Store: pub.Store, Dir: dir, Inbox: func(cell.Cell) string { return t.TempDir() }} + mustNil(t, fetcher.Fetch(t.Context(), copy, kept.Cell.Head)) + words, err := os.ReadFile(filepath.Join(copy.Root, "journal")) + if err != nil || string(words) != "finished during the outage" { + t.Fatalf("kept turns did not reach shared storage: words=%q error=%v", words, err) + } +} + +func TestIdleRelease_R9_RevivedFenceReopensThroughTheRealContinuer(t *testing.T) { + for _, unpublished := range []string{"none", "some"} { + t.Run(unpublished, func(t *testing.T) { + h := newTwoHomes(t) + chat := idleContractChat(t, h) + d := chat.drive + // Freeze background traffic so expiry and revival are explicit observations. + d.cancel() + <-d.done + h.clock.advance(directory.LeaseTTL + time.Second) + mustNil(t, d.CheckLease(t.Context())) + if d.Gate() == nil { + t.Fatal("observed expiry left work admitted") + } + _, err := h.a.Dir.Heartbeat(t.Context(), h.cell.ID, directory.Beat{Fence: d.batcher.Fence()}) + mustNil(t, err) + seen, err := h.a.Dir.Cell(t.Context(), h.cell.ID) + mustNil(t, err) + if confirmed, err := d.ConfirmWork(t.Context()); err != nil || confirmed || d.Gate() == nil { + t.Fatalf("revived old drive admitted work: confirmed=%v error=%v gate=%v", confirmed, err, d.Gate()) + } + if unpublished == "some" { + appendTo(t, transcriptOf(h.cell), "{\"type\":\"message\",\"role\":\"user\",\"content\":\"finished during the outage\"}\n") + _, err := d.Store(h.engine).Seal(t.Context(), h.cell, cellstore.TurnInfo{Calls: []cellstore.Executed{{Call: cellstore.Call{Tool: "earlier", Started: 0}}}}) + mustNil(t, err) + } + wire := &r9Directory{Client: d.batcher.Publisher.Dir} + if unpublished == "some" { + // A real outage retains the seal for the pending-turn reopen road. + wire.failure = directory.ErrUnreachable + } + d.batcher.Publisher.Dir, d.batcher.Brancher.Dir = wire, wire + closeErr := d.Close(t.Context()) + wire.failure = nil + if unpublished == "some" { + if !syncwords.UploadPending(closeErr) || d.batcher.Pending() != 1 { + t.Fatalf("unreachable close did not retain its pending save: error=%v pending=%d", closeErr, d.batcher.Pending()) + } + } else { + mustNil(t, closeErr) + } + got, err := h.continuerA().TakeSeen(t.Context(), h.cell.ID, &directory.LeaseExpectation{Device: seen.Cell.Lease.Device, Fence: seen.Cell.Lease.Fence}) + mustNil(t, err) + if got.AlreadyHere || got.Taken.Driving.Fence <= seen.Cell.Lease.Fence { + t.Fatalf("retired own live fence reused the old writer: alreadyHere=%v fence=%d observed=%d", got.AlreadyHere, got.Taken.Driving.Fence, seen.Cell.Lease.Fence) + } + if unpublished == "some" { + if got.Taken.Kept == "" || got.KeptTurns == 0 { + t.Fatal("reopen did not keep the unpublished local turn") + } + kept, err := h.a.Dir.Cell(t.Context(), got.Taken.Kept) + mustNil(t, err) + if kept.Cell.ParentCell != h.cell.ID { + t.Fatalf("kept turns lost their parent: %+v", kept.Cell) + } + // Normal continuation may seal remaining edits again before keeping them. + // Fetch the resulting branch to verify the original words survived. + engine := h.engB.Sync(cellstore.SyncKeys{CellKey: h.a.Identity.CellKey(), Dedup: h.a.Identity.DedupSecret()}, h.a.Ledger) + scope := h.a.scope(got.Taken.Kept) + defer scope.Settle() + copy := cell.Cell{ID: got.Taken.Kept, Root: t.TempDir()} + fetcher := cellsync.Fetcher{Engine: engine, Store: scope.Store, Dir: h.a.Dir, Inbox: engine.Inbox} + mustNil(t, fetcher.Fetch(t.Context(), copy, kept.Cell.Head)) + words, err := os.ReadFile(transcriptOf(copy)) + if err != nil || !strings.Contains(string(words), "finished during the outage") { + t.Fatalf("reopen lost unpublished words: journal=%q error=%v", words, err) + } + } + replacement := h.openOn(h.a, h.engine, got.Taken.Cell, h.work, nameA) + if replacement.drive == d || replacement.drive.Gate() != nil { + t.Fatal("reopen failed to install an admitted replacement drive") + } + replacement.mustSay("first Enter after observed expiry") + h.durable(h.cell.ID, got.Taken.Cell) + }) + } +} diff --git a/internal/syncsetup/idle_release.go b/internal/syncsetup/idle_release.go new file mode 100644 index 0000000000..da5be61071 --- /dev/null +++ b/internal/syncsetup/idle_release.go @@ -0,0 +1,337 @@ +package syncsetup + +import ( + "context" + "errors" + "path/filepath" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/syncwords" +) + +// idleReleaseWithin bounds the final, upload-free ownership boundary. +const idleReleaseWithin = 250 * time.Millisecond + +func (d *Drive) idleHandoffAllowed() bool { + return d != nil && d.sync != nil && d.sync.IdleHandoffAllowed() +} + +func (d *Drive) SetIdleCheck(check func() bool) { + func() { d.workMu.Lock(); defer d.workMu.Unlock(); d.idleCheck = check }() + // A restored terminal can already be idle without another foreground turn. + if d.batcher != nil { + d.idleRequested.Store(true) + if d.idleHandoffAllowed() { + d.scheduleIdle(d.activity.Load()) + } + } +} + +// BeginWork cancels preparation immediately. Only the short final release can +// be in flight, in which case admission must bypass its cached confirmation. +// A retired viewer also bypasses that cache to reconsider fresh ownership. +func (d *Drive) BeginWork() bool { + d.activity.Add(1) + d.idleMu.Lock() + if d.idleStop != nil { + d.idleStop() + } + d.idleMu.Unlock() + return d.released.Load() || d.committing.Load() || d.displaced() +} + +// SavedMetadata resumes a paired chat's requested idle certification. It does +// not trigger ordinary publication; readiness excludes settings and live work. +func (d *Drive) SavedMetadata() { + if d.idleRequested.Load() && d.idleHandoffAllowed() && d.paired() { + d.scheduleIdle(d.activity.Load()) + } +} + +func (d *Drive) IdleReleased() bool { return d.released.Load() } + +func lockDrive(ctx context.Context, mu *sync.Mutex) error { + for { + if err := ctx.Err(); err != nil { + return err + } + if mu.TryLock() { + return nil + } + timer := time.NewTimer(time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } + } +} + +// stopIdle closes admission to new idle attempts before joining canceled work. +func (d *Drive) stopIdle(ctx context.Context) error { + started := func() bool { + d.idleMu.Lock() + defer d.idleMu.Unlock() + d.idleClosed = true + if d.idleStop == nil { + return false + } + d.idleStop() + return true + }() + if !started { + return nil + } + done := make(chan struct{}) + go func() { d.idleWorkers.Wait(); close(done) }() + select { + case <-done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (d *Drive) releaseWhenIdle(ctx context.Context, activity uint64) { + wait := cellstore.TailQuiet + for { + if !d.idleHandoffAllowed() { + return + } + if !d.paired() { + select { + case <-ctx.Done(): + return + case <-d.done: + return + case <-d.devices.Changes(): + continue + } + } + if d.opt.Sleep != nil { + if d.opt.Sleep(ctx, wait) != nil { + return + } + } else { + timer := time.NewTimer(wait) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-d.done: + timer.Stop() + return + case <-timer.C: + } + } + finished, err := d.releaseIdle(ctx, activity) + if finished { + return + } + if err == nil || errors.Is(err, cellstore.ErrSavedStateChanged) || errors.Is(err, errStateBusy) { + wait = cellstore.TailQuiet + } else { + wait = min(max(wait*2, 5*time.Second), time.Minute) + } + } +} + +func (d *Drive) idleState(activity uint64) (func() bool, bool) { + d.mu.Lock() + stopped := d.stopped || d.lost || d.viewer.Line != "" && !(d.released.Load() && d.viewer.Line == chatlist.Available) + d.mu.Unlock() + return d.idleCheck, activity != d.activity.Load() || d.releaseDone.Load() || stopped +} + +func (d *Drive) profileFiles() []string { + return []string{ + filepath.Join(d.sync.profile(), "config.json"), + filepath.Join(d.sync.profile(), "credentials.json"), + filepath.Join(d.sync.Home, "graph.db"), + filepath.Join(d.sync.Home, "graph.db-wal"), + } +} + +// prepareIdle performs every capture and upload without holding admission. +// Dirty metadata and workspace changes take the same ordinary sealed-turn road +// as tool work; a failed capture or publication leaves the hold intact. +func (d *Drive) prepareIdle(ctx context.Context, activity uint64) (*cellstore.SavedState, bool, error) { + if err := lockDrive(ctx, &d.workMu); err != nil { + return nil, false, err + } + check, finished := d.idleState(activity) + d.workMu.Unlock() + if finished || !d.idleHandoffAllowed() || d.sync.isRemoved() || Quiet(d.sync.Home) { + return nil, true, nil + } + // A private unpublished chat has no hold to release. Do not add captures + // or vault work to its existing local save path. + if d.batcher.Fence() == 0 && d.batcher.Pending() == 0 { + return nil, true, nil + } + ready := check == nil || check() + if !ready || d.batcher.Pending() != 0 { + return nil, false, nil + } + if err := cellstore.Captured(ctx, d.cell); err != nil { + return nil, false, err + } + if d.batcher.Pending() != 0 { + d.batcher.Idle() + return nil, false, nil + } + d.idlePrepared.Store(true) + if err := lockDrive(ctx, &d.sealMu); err != nil { + return nil, false, err + } + proof, err := d.engine.SavedState(ctx, d.cell, d.profileFiles()...) + if err == nil && proof != nil && len(proof.Changes) != 0 { + if ctx.Err() != nil || activity != d.activity.Load() || d.Gate() != nil { + d.sealMu.Unlock() + return nil, true, ctx.Err() + } + engine := d.engine + engine.Identity = cellstore.Identity{Device: d.sync.Device.Cert.Device, Fence: d.batcher.Fence()} + var sealed cellstore.Sealed + sealed, err = engine.Seal(ctx, d.cell, cellstore.TurnInfo{}) + if err == nil { + d.note(sealed) + d.batcher.Idle() + } + d.sealMu.Unlock() + return nil, false, err + } + d.sealMu.Unlock() + if err != nil || proof == nil { + return nil, false, err + } + if err := d.syncIdleVault(ctx); err != nil { + return nil, false, err + } + return proof, false, nil +} + +func (d *Drive) releaseIdle(parent context.Context, activity uint64) (bool, error) { + notify := false + var version uint64 + defer func() { + // Surface callbacks can take their own locks. They run after admission + // and capture locks are free; the local work gate has already closed. + if notify { + d.renewalChanged() + d.ownershipNotice(context.Background(), version, Viewer{Line: chatlist.Available}) + } + }() + prepare, cancel := context.WithTimeout(parent, requestTimeout) + proof, finished, err := d.prepareIdle(prepare, activity) + cancel() + if err != nil || finished || proof == nil { + return finished, err + } + + // No capture, upload, deferred-write join or contended publisher lock belongs + // here. A sender invalidates preparation without waiting for any of them. + ctx, cancel := context.WithTimeout(parent, idleReleaseWithin) + defer cancel() + if !d.workMu.TryLock() { + return false, nil + } + defer d.workMu.Unlock() + d.committing.Store(true) + defer d.committing.Store(false) + check, finished := d.idleState(activity) + if finished || !d.idleHandoffAllowed() { + return true, nil + } + if ctx.Err() != nil || !d.paired() || check != nil && !check() { + return false, ctx.Err() + } + if !d.sealMu.TryLock() { + return false, nil + } + defer d.sealMu.Unlock() + if cellstore.Unsaved(d.cell) != 0 { + return false, nil + } + head, err := cellstore.Head(d.cell) + if err != nil || head == nil || head.Turn.ID != proof.Head || !proof.Unchanged(ctx) { + return false, err + } + wasReleased := d.released.Load() + held := d.sendLeaseSnapshot() + previous := func() Viewer { d.mu.Lock(); defer d.mu.Unlock(); return d.viewer }() + released, err := d.batcher.ReleaseIdle(ctx, proof.Head, func() error { + if err := d.sync.forgetSendLeaseWithin(ctx, d.cell.ID, d.sendLeaseSnapshot()); err != nil { + return err + } + d.mu.Lock() + defer d.mu.Unlock() + d.recordedFence = 0 + notify = d.viewer != (Viewer{Line: chatlist.Available}) + d.viewer = Viewer{Line: chatlist.Available} + d.idleNotices.Store(true) + version = d.noticeVersion.Load() + if notify { + version = d.noticeVersion.Add(1) + } + d.released.Store(true) + return nil + }) + if err != nil && !wasReleased && d.released.Load() && syncwords.RequestNotSent(err) { + // A canceled admission budget must still put back the receipt it retired. + // Keep the work gate closed if local restoration itself fails. + restore, cancel := context.WithTimeout(context.WithoutCancel(parent), idleReleaseWithin) + defer cancel() + if restoreErr := d.undoUnsentRelease(restore, held, previous, version); restoreErr != nil { + return false, errors.Join(err, restoreErr) + } + notify = false + } + if err != nil || !released { + return false, err + } + d.releaseDone.Store(true) + return true, nil +} + +func (d *Drive) undoUnsentRelease(ctx context.Context, held sendLease, previous Viewer, version uint64) error { + if err := lockDrive(ctx, &d.receiptMu); err != nil { + return err + } + defer d.receiptMu.Unlock() + d.mu.Lock() + defer d.mu.Unlock() + // Ownership news can arrive while the dial fails. It must still win over + // the saved viewer, and no later writer's receipt may be replaced here. + if d.stopped || d.lost || d.noticeVersion.Load() != version { + return nil + } + if err := d.sync.recordSendLeaseWithin(ctx, d.cell.ID, held); err != nil { + return err + } + d.viewer, d.recordedFence = previous, held.Fence + d.released.Store(false) + d.noticeVersion.Add(1) + return nil +} + +func (d *Drive) paired() bool { + if d.devices == nil { + return false + } + state := d.devices.Snapshot(d.sync.Identity, d.sync.Device) + if !state.Complete || state.Refused != nil { + return false + } + n := 0 + for _, dev := range state.Devices { + if !dev.Revoked { + n++ + } + } + return n >= 2 +} diff --git a/internal/syncsetup/idle_release_test.go b/internal/syncsetup/idle_release_test.go new file mode 100644 index 0000000000..b83f6e26f4 --- /dev/null +++ b/internal/syncsetup/idle_release_test.go @@ -0,0 +1,502 @@ +package syncsetup + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/store" +) + +func idleContractChat(t *testing.T, h *twoHomes) *openChat { + t.Helper() + t.Setenv("CODEAF_IDLE_HANDOFF", "on") + mustNil(t, h.b.putDevice(t.Context(), nameB)) + c := h.openA() + // Knowledge capture needs a complete timestamp to advance past this seed + // instead of retrying an invalid journal record during idle certification. + appendTo(t, transcriptOf(h.cell), fmt.Sprintf(`{"type":"message","role":"user","content":"save this chat","timestamp":%q}`+"\n", h.clock.Now().UTC().Format(time.RFC3339Nano))) + _, err := c.drive.Store(h.engine).Seal(t.Context(), h.cell, cellstore.TurnInfo{}) + mustNil(t, err) + h.durable(h.cell.ID, h.cell) + return c +} + +const uploadedIdleReleaseWithin = 8 * time.Second + +func idleContractReleased(t *testing.T, h *twoHomes, d *Drive, within ...time.Duration) directory.CellView { + t.Helper() + // Functional cases include native capture and publication. C1 supplies its + // already-uploaded release budget explicitly. + budget := 30 * time.Second + if len(within) > 0 { + budget = within[0] + } + d.Idle() + var view directory.CellView + deadline := time.Now().Add(budget) + for { + var err error + view, err = h.a.Dir.Cell(t.Context(), h.cell.ID) + if err == nil && view.Cell.Head == h.head() && view.Cell.Lease.Expires == 0 { + return view + } + if time.Now().After(deadline) { + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + if lockDrive(ctx, &d.sealMu) == nil { + proof, saveErr := d.engine.SavedState(ctx, d.cell, d.profileFiles()...) + d.sealMu.Unlock() + if proof != nil { + t.Logf("save check: head=%s changes=%v err=%v", proof.Head, proof.Changes, saveErr) + } else { + t.Logf("save check: %v", saveErr) + } + } + if d.idleCheck != nil { + t.Logf("idle readiness: %v", d.idleCheck()) + } + // Capturing diagnostics can outlast the last directory poll. + freshCtx, freshCancel := context.WithTimeout(t.Context(), time.Second) + fresh, freshErr := h.a.Dir.Cell(freshCtx, h.cell.ID) + freshCancel() + t.Logf("latest relay: %+v, %v; pending: %d; released locally: %v", fresh, freshErr, d.batcher.Pending(), d.IdleReleased()) + t.Fatalf("idle chat still held: %+v, %v; notices: %v", view, err, h.wall.all()) + } + time.Sleep(20 * time.Millisecond) + } +} + +func TestIdleRelease_C1_PairedIdleReleasesUploadedTurns(t *testing.T) { + r := newTwoHomes(t) + c := idleContractChat(t, r) + idleContractReleased(t, r, c.drive, uploadedIdleReleaseWithin) + if _, _, err := r.a.SendLease(r.cell.ID); !os.IsNotExist(err) { + t.Fatalf("C1 released writer retains send permission: %v", err) + } +} + +func TestIdleRelease_DefaultOff_RetainsHoldAndRequiresContinueHere(t *testing.T) { + h := newTwoHomes(t) + mustNil(t, h.b.putDevice(t.Context(), nameB)) + c := h.openA() + appendTo(t, transcriptOf(h.cell), fmt.Sprintf(`{"type":"message","role":"user","content":"default off retains hold","timestamp":%q}`+"\n", h.clock.Now().UTC().Format(time.RFC3339Nano))) + _, err := c.drive.Store(h.engine).Seal(t.Context(), h.cell, cellstore.TurnInfo{}) + mustNil(t, err) + h.durable(h.cell.ID, h.cell) + + // With idle handoff unset (default false), Idle() must be a no-op and retain the lease. + c.drive.Idle() + finished, err := c.drive.releaseIdle(t.Context(), c.drive.activity.Load()) + if err != nil || !finished || c.drive.IdleReleased() { + t.Fatalf("default-off releaseIdle = %v, %v, released=%v", finished, err, c.drive.IdleReleased()) + } + v, err := h.a.Dir.Cell(t.Context(), h.cell.ID) + mustNil(t, err) + if v.Cell.Lease.Expires <= v.Now || c.drive.Gate() != nil { + t.Fatalf("default-off idle chat released its hold: %+v, gate=%v", v.Cell.Lease, c.drive.Gate()) + } + + // Even when the chat is explicitly released on the relay, B's Rows() and RowsForSend() + // must report AutoOpen: false when idle handoff is off by default. + mustNil(t, h.a.Dir.Release(t.Context(), h.cell.ID, v.Cell.Lease.Fence)) + rows, err := h.b.Rows(t.Context()) + mustNil(t, err) + found := false + for _, row := range rows { + if row.Cell == h.cell.ID { + found = true + if row.AutoOpen { + t.Fatalf("default-off Rows() set AutoOpen=true on %+v", row) + } + } + } + if !found { + t.Fatalf("did not find cell %s in B's rows", h.cell.ID) + } +} + +func TestIdleRelease_StorageOff_RefusesReleaseAndAutoOpen(t *testing.T) { + t.Setenv("CODEAF_IDLE_HANDOFF", "on") + h := newTwoHomes(t) + mustNil(t, h.b.putDevice(t.Context(), nameB)) + c := h.openA() + appendTo(t, transcriptOf(h.cell), fmt.Sprintf(`{"type":"message","role":"user","content":"storage off guard","timestamp":%q}`+"\n", h.clock.Now().UTC().Format(time.RFC3339Nano))) + _, err := c.drive.Store(h.engine).Seal(t.Context(), h.cell, cellstore.TurnInfo{}) + mustNil(t, err) + h.durable(h.cell.ID, h.cell) + + mustNil(t, SaveStorage(h.homeA, h.a.Relay, h.a.Identity.ID(), "off")) + mustNil(t, SaveStorage(h.homeB, h.b.Relay, h.b.Identity.ID(), "off")) + confirmed, err := c.drive.ConfirmWork(t.Context()) + if err != nil || !confirmed { + t.Fatalf("ConfirmWork before storage-off check = %v, %v", confirmed, err) + } + c.drive.Idle() + finished, err := c.drive.releaseIdle(t.Context(), c.drive.activity.Load()) + if err != nil || !finished || c.drive.IdleReleased() { + t.Fatalf("storage-off releaseIdle = %v, %v, released=%v", finished, err, c.drive.IdleReleased()) + } + v, err := h.a.Dir.Cell(t.Context(), h.cell.ID) + mustNil(t, err) + if v.Cell.Lease.Expires <= v.Now { + t.Fatalf("storage-off idle chat released its hold: %+v", v.Cell.Lease) + } + mustNil(t, h.a.Dir.Release(t.Context(), h.cell.ID, v.Cell.Lease.Fence)) + rows, err := h.b.Rows(t.Context()) + mustNil(t, err) + for _, row := range rows { + if row.Cell == h.cell.ID && row.AutoOpen { + t.Fatalf("storage-off Rows() set AutoOpen=true on %+v", row) + } + } +} + +func TestIdleRelease_P9X2_DisplacedBranchReopensAndReusesKeptCopy(t *testing.T) { + h := newTwoHomes(t) + c := idleContractChat(t, h) + d := c.drive + d.cancel() + <-d.done + origView, err := h.a.Dir.Cell(t.Context(), h.cell.ID) + mustNil(t, err) + + // Seal an unpublished turn locally on A while A's hold expires, then let Displaced + // branch it into a kept copy on the relay and record J -> B in branches.json. + h.block.set(true) + c.mustSay("orphaned turn kept in branch") + h.clock.advance(directory.LeaseTTL + time.Second) + mustNil(t, d.CheckLease(t.Context())) + h.block.set(false) + mustNil(t, d.batcher.Close(t.Context())) + + branches, err := branchMapOf(h.homeA) + mustNil(t, err) + keptID := branches.Resolve(h.cell.ID) + if keptID == h.cell.ID { + t.Fatal("Displaced did not record a kept branch in branches.json") + } + + // Reopening the original chat J via TakeSeen must reuse keptID without creating + // a second branch on the relay, clear J -> keptID from branches.json, and restore J. + seen, err := h.a.Dir.Cell(t.Context(), h.cell.ID) + mustNil(t, err) + got, err := h.continuerA().TakeSeen(t.Context(), h.cell.ID, &directory.LeaseExpectation{Device: seen.Cell.Lease.Device, Fence: seen.Cell.Lease.Fence}) + mustNil(t, err) + if got.Taken.Kept != keptID { + t.Fatalf("TakeSeen Kept = %q, want reused %q", got.Taken.Kept, keptID) + } + if got.KeptTurns != 0 { + t.Fatalf("TakeSeen re-announced KeptTurns = %d for already-branched copy", got.KeptTurns) + } + if got.Taken.Driving.Head != origView.Cell.Head || got.Taken.Driving.Fence != origView.Cell.Lease.Fence+1 { + t.Fatalf("TakeSeen restored head=%s fence=%d, want head=%s fence=%d", got.Taken.Driving.Head, got.Taken.Driving.Fence, origView.Cell.Head, origView.Cell.Lease.Fence+1) + } + listing, err := h.a.Dir.List(t.Context()) + mustNil(t, err) + branchCount := 0 + for _, cell := range listing.Cells { + if cell.ParentCell == h.cell.ID { + branchCount++ + } + } + if branchCount != 1 { + t.Fatalf("relay has %d branches for %s, want 1", branchCount, h.cell.ID) + } + branches, err = branchMapOf(h.homeA) + mustNil(t, err) + if branches.Resolve(h.cell.ID) != h.cell.ID { + t.Fatalf("TakeSeen left %s mapped to %s in branches.json", h.cell.ID, branches.Resolve(h.cell.ID)) + } +} + +func TestIdleRelease_P9X3_RunningTurnReacquiresLapsedOwnHoldInPlace(t *testing.T) { + h := newTwoHomes(t) + c := idleContractChat(t, h) + d := c.drive + d.SetIdleCheck(func() bool { return false }) + d.Working(true) + defer d.Working(false) + + var before directory.CellView + for { + var err error + before, err = h.a.Dir.Cell(t.Context(), h.cell.ID) + mustNil(t, err) + if before.Cell.Lease.Working { + break + } + time.Sleep(5 * time.Millisecond) + } + + // Simulate relay outage + restart / clock jump so the running turn's own hold + // is observed as lapsed with no other computer holding the chat. + h.clock.advance(directory.LeaseTTL + time.Second) + mustNil(t, d.CheckLease(t.Context())) + + if d.Gate() != nil { + t.Fatalf("running turn lost admission instead of reacquiring in place: %v", d.Gate()) + } + after, err := h.a.Dir.Cell(t.Context(), h.cell.ID) + mustNil(t, err) + if after.Cell.Lease.Device != h.a.Device.ID() || after.Cell.Lease.Fence != before.Cell.Lease.Fence+1 || after.Cell.Lease.Expires <= after.Now { + t.Fatalf("running turn did not conditionally reacquire Fence+1 in place: before=%+v after=%+v", before.Cell.Lease, after.Cell.Lease) + } + if _, receiptFence, err := h.a.SendLease(h.cell.ID); err != nil || receiptFence != before.Cell.Lease.Fence+1 { + t.Fatalf("SendLease after in-place reacquire = %d, %v; want %d", receiptFence, err, before.Cell.Lease.Fence+1) + } + // Finish the turn and verify it publishes on the original chat with zero copy branches. + c.mustSay("turn completed after in-place reacquire") + h.durable(h.cell.ID, h.cell) + published, err := h.a.Dir.Cell(t.Context(), h.cell.ID) + mustNil(t, err) + if published.Cell.Head != h.head() { + t.Fatalf("turn after in-place reacquire did not publish on original chat: relay=%s local=%s", published.Cell.Head, h.head()) + } + listing, err := h.a.Dir.List(t.Context()) + mustNil(t, err) + for _, cell := range listing.Cells { + if cell.ParentCell == h.cell.ID { + t.Fatalf("in-place reacquire unexpectedly created a copy branch: %+v", cell) + } + } +} + +func TestIdleRelease_C3_NewWorkReacquiresBeforeWriting(t *testing.T) { + h := newTwoHomes(t) + c := idleContractChat(t, h) + before := idleContractReleased(t, h, c.drive) + confirmed, err := c.drive.ConfirmWork(t.Context()) + if err != nil || !confirmed || c.drive.Gate() != nil { + t.Fatalf("C3 take-back: %v, %v, gate=%v", confirmed, err, c.drive.Gate()) + } + held, err := h.a.Dir.Cell(t.Context(), h.cell.ID) + mustNil(t, err) + if held.Cell.Lease.Device != h.a.Device.ID() || held.Cell.Lease.Fence != before.Cell.Lease.Fence+1 { + t.Fatalf("C3 no new hold before writing: %+v", held) + } + appendTo(t, transcriptOf(h.cell), `{"type":"message","role":"user","content":"new work"}`+"\n") + _, err = c.drive.Store(h.engine).Seal(t.Context(), h.cell, cellstore.TurnInfo{}) + mustNil(t, err) + h.durable(h.cell.ID, h.cell) +} + +func TestIdleRelease_C4_TakenChatRefusesWithoutWriting(t *testing.T) { + h := newTwoHomes(t) + c := idleContractChat(t, h) + seen := idleContractReleased(t, h, c.drive) + _, err := h.b.Dir.Acquire(t.Context(), h.cell.ID, directory.AcquireOpts{RequireConditional: true, Expect: &directory.LeaseExpectation{Device: seen.Cell.Lease.Device, Fence: seen.Cell.Lease.Fence}}) + mustNil(t, err) + before, err := os.ReadFile(transcriptOf(h.cell)) + mustNil(t, err) + head := h.head() + confirmed, err := c.drive.ConfirmWork(t.Context()) + if err != nil || confirmed || c.drive.Gate() == nil { + t.Fatalf("C4 new work was admitted: %v, %v", confirmed, err) + } + after, err := os.ReadFile(transcriptOf(h.cell)) + mustNil(t, err) + if !bytes.Equal(before, after) || h.head() != head { + t.Fatal("C4 refusal wrote local conversation work") + } + waitFor(t, "C4 moved notice", func() bool { return h.wall.has(chatlist.Superseded(nameB)) }) +} + +func TestIdleRelease_C4_NewerReleasedChatDoesNotReviveTheOldWindow(t *testing.T) { + h := newTwoHomes(t) + a := idleContractChat(t, h) + seen := idleContractReleased(t, h, a.drive) + got, err := h.continuerB().OpenAvailable(t.Context(), h.cell.ID, &directory.LeaseExpectation{Device: seen.Cell.Lease.Device, Fence: seen.Cell.Lease.Fence}) + mustNil(t, err) + b := h.openOn(h.b, h.engB, got.Taken.Cell, workspaceOf(got.Taken.Cell.Root), nameB) + appendTo(t, transcriptOf(got.Taken.Cell), `{"type":"message","role":"assistant","content":"newer work on B"}`+"\n") + _, err = b.drive.Store(h.engB).Seal(t.Context(), got.Taken.Cell, cellstore.TurnInfo{}) + mustNil(t, err) + h.durable(h.cell.ID, got.Taken.Cell) + mustNil(t, b.drive.Close(t.Context())) + before, err := os.ReadFile(transcriptOf(h.cell)) + mustNil(t, err) + confirmed, err := a.drive.ConfirmWork(t.Context()) + if err != nil || confirmed || a.drive.Gate() == nil { + t.Fatalf("C4 older window revived over B's released work: %v, %v", confirmed, err) + } + after, err := os.ReadFile(transcriptOf(h.cell)) + mustNil(t, err) + if !bytes.Equal(before, after) { + t.Fatal("C4 stale window wrote locally") + } + waitFor(t, "C4 last computer's moved notice", func() bool { return h.wall.has(chatlist.Superseded(nameB)) }) +} + +func TestIdleRelease_C2_C5_C6_C8_C9_UploadBeforeReleaseAndRestore(t *testing.T) { + h := newTwoHomes(t) + c := idleContractChat(t, h) + a, _ := idleContractSession(t, h, c) + first := h.head() + h.block.set(true) + mustNil(t, os.WriteFile(filepath.Join(h.work, ".gitignore"), []byte("node_modules/\n"), 0600)) + mustNil(t, os.WriteFile(filepath.Join(h.work, "package-lock.json"), []byte(`{"lockfileVersion":3}`), 0600)) + mustNil(t, os.WriteFile(filepath.Join(h.work, "handoff.txt"), []byte("saved workspace"), 0600)) + mustNil(t, os.MkdirAll(filepath.Join(h.work, "node_modules", "fixture"), 0700)) + mustNil(t, os.WriteFile(filepath.Join(h.work, "node_modules", "fixture", "index.js"), []byte("rebuild me"), 0600)) + brain, err := store.Open(filepath.Join(h.homeA, "graph.db")) + mustNil(t, err) + memory, err := brain.AddMemory(store.Memory{Owner: store.OwnerUser, Text: "Keep the handoff receipt.", Origin: store.OriginPerson}) + mustNil(t, err) + mustNil(t, brain.Close()) + mustNil(t, os.WriteFile(filepath.Join(h.homeA, "config.json"), []byte(`{"ui.icons":"ascii","daily_budget_usd":7}`), 0600)) + appendTo(t, transcriptOf(h.cell), `{"type":"message","role":"assistant","content":"final saved answer"}`+"\n") + _, err = c.drive.Store(h.engine).Seal(t.Context(), h.cell, cellstore.TurnInfo{}) + mustNil(t, err) + // These saves deliberately happen after the last explicit seal. Idle must + // capture both the metadata-only change and the workspace edit itself. + if !a.SetConversationEffort("high") { + t.Fatal("could not save effort") + } + a.SettleWrites() + mustNil(t, os.WriteFile(filepath.Join(h.work, "handoff.txt"), []byte("workspace saved after seal"), 0600)) + c.drive.Idle() + time.Sleep(3 * time.Second) + v, err := h.a.Dir.Cell(t.Context(), h.cell.ID) + mustNil(t, err) + if v.Cell.Lease.Expires <= v.Now || v.Cell.Head != first { + t.Fatalf("C2/C5 unsent work released the hold: %+v", v) + } + h.block.set(false) + h.durable(h.cell.ID, h.cell) + seen := idleContractReleased(t, h, c.drive) + mustNil(t, c.drive.Close(t.Context())) + // B's ordinary continuer also removes A's recorded workspace link. No + // source drive is running; everything below must come from the relay. + got, err := h.continuerB().OpenAvailable(t.Context(), h.cell.ID, &directory.LeaseExpectation{Device: seen.Cell.Lease.Device, Fence: seen.Cell.Lease.Fence}) + mustNil(t, err) + work := workspaceOf(got.Taken.Cell.Root) + raw, err := os.ReadFile(filepath.Join(work, "handoff.txt")) + mustNil(t, err) + if string(raw) != "workspace saved after seal" { + t.Fatalf("C5/C6 restored workspace=%q", raw) + } + if _, err := os.Stat(filepath.Join(work, "node_modules", "fixture", "index.js")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("C9 rebuildable file transferred: %v", err) + } + raw, err = os.ReadFile(transcriptOf(got.Taken.Cell)) + mustNil(t, err) + if !strings.Contains(string(raw), "final saved answer") || got.Taken.Driving.Head != seen.Cell.Head { + t.Fatal("C5/C6 journal or saved head was lost") + } + restored, err := session.LoadMeta(got.Taken.Cell.Root) + mustNil(t, err) + if restored.Effort != "high" { + t.Fatalf("C5/C6 restored effort=%q", restored.Effort) + } + other, err := store.Open(filepath.Join(h.homeB, "graph.db")) + mustNil(t, err) + defer other.Close() + kept, found, err := other.MemoryRecord(memory.ID) + mustNil(t, err) + if !found || kept.Text != memory.Text { + t.Fatal("C5 shared memory did not precede release") + } + prefs, err := config.ReadPreferences(h.homeB) + mustNil(t, err) + if prefs[config.KeyIcons] != `"ascii"` { + t.Fatalf("C5 setting did not precede release: %v", prefs) + } +} + +// idleRequests records every HTTP request, including unexpected endpoints. +// Looking only at list/release would miss cell reads, uploads and acquisitions. +type idleRequests struct { + mu sync.Mutex + calls []idleRequest +} +type idleRequest struct { + key string + at time.Time +} + +func (c *idleRequests) wrap(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.mu.Lock() + c.calls = append(c.calls, idleRequest{r.Method + " " + r.URL.Path, time.Now()}) + c.mu.Unlock() + next.ServeHTTP(w, r) + }) +} +func (c *idleRequests) since(n int) []idleRequest { + c.mu.Lock() + defer c.mu.Unlock() + return append([]idleRequest(nil), c.calls[n:]...) +} + +func TestIdleRelease_C10_SingleDeviceAddsNoRequests(t *testing.T) { + t.Setenv("CODEAF_IDLE_HANDOFF", "on") + var idle, control idleRequests + h := newTwoHomes(t, idle.wrap) + baseline := newTwoHomes(t, control.wrap) + open := func(h *twoHomes) (*openChat, time.Time) { + c := h.openA() + started := time.Now() + appendTo(t, transcriptOf(h.cell), `{"type":"message","role":"user","content":"single device"}`+"\n") + _, err := c.drive.Store(h.engine).Seal(t.Context(), h.cell, cellstore.TurnInfo{}) + mustNil(t, err) + h.durable(h.cell.ID, h.cell) + waitFor(t, "initial vault exchange", func() bool { + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + return c.drive.syncVault(ctx) == nil + }) + return c, started + } + c, started := open(h) + _, controlStarted := open(baseline) + before, controlBefore := len(idle.since(0)), len(control.since(0)) + c.drive.Idle() + // A publish covers the first 30-second tick. Observe the next tick too, + // using real time and the untouched heartbeat loop in a no-Idle control. + until := controlStarted.Add(2*directory.HeartbeatEvery + 2*time.Second) + timer := time.NewTimer(time.Until(until)) + defer timer.Stop() + select { + case <-timer.C: + case <-t.Context().Done(): + t.Fatal(t.Context().Err()) + } + for _, one := range []struct { + name string + calls []idleRequest + start time.Time + id string + }{ + {"idle", idle.since(before), started, h.cell.ID}, + {"control", control.since(controlBefore), controlStarted, baseline.cell.ID}, + } { + want := "/cells/" + one.id + "/heartbeat" + if len(one.calls) != 1 || !strings.HasPrefix(one.calls[0].key, "POST ") || !strings.HasSuffix(one.calls[0].key, want) { + t.Fatalf("C10 %s requests=%v; want exactly one ordinary heartbeat (%s)", one.name, one.calls, want) + } + elapsed := one.calls[0].at.Sub(one.start) + if elapsed < 2*directory.HeartbeatEvery-time.Second || elapsed > 2*directory.HeartbeatEvery+time.Second { + t.Errorf("C10 %s renewal arrived at %s; want the ordinary second 30s tick", one.name, elapsed) + } + t.Logf("C10 %s: all requests=1 (heartbeat), first tick covered by publication, next renewal at %s", one.name, elapsed) + } + v, err := h.a.Dir.Cell(t.Context(), h.cell.ID) + mustNil(t, err) + if v.Cell.Lease.Expires <= v.Now || c.drive.Gate() != nil { + t.Fatal("C10 single computer stopped holding its chat") + } +} diff --git a/internal/syncsetup/idle_review_test.go b/internal/syncsetup/idle_review_test.go new file mode 100644 index 0000000000..dcbc8b6ee0 --- /dev/null +++ b/internal/syncsetup/idle_review_test.go @@ -0,0 +1,330 @@ +package syncsetup + +import ( + "bytes" + "context" + "errors" + "os" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/crewroute" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/session" +) + +func idleContractSession(t *testing.T, h *twoHomes, c *openChat, change ...func(*session.Config)) (*session.Agent, *cellstore.SealWatch) { + t.Helper() + raw, err := os.ReadFile(transcriptOf(h.cell)) + mustNil(t, err) + mustNil(t, os.WriteFile(transcriptOf(h.cell), bytes.Replace(raw, []byte(`"version":3`), []byte(`"version":1`), 1), 0600)) + watch := &cellstore.SealWatch{OnTurnEnd: c.drive.Idle, OnMetadataWrite: c.drive.SavedMetadata} + watch.OnTail = func() { executor.Settle(context.Background(), c.seat); c.drive.Idle() } + watch.Waiting() + work := func() error { + ctx, cancel := context.WithTimeout(t.Context(), 750*time.Millisecond) + defer cancel() + confirmed, err := c.drive.ConfirmWork(ctx) + if err != nil { + return err + } + if !confirmed { + return cellstore.ErrReadOnly + } + return c.drive.Gate() + } + prepared := func() error { + ctx, cancel := context.WithTimeout(t.Context(), 750*time.Millisecond) + defer cancel() + return c.drive.ConfirmPreparedWork(ctx) + } + cfg := session.Config{Workspace: h.work, Place: session.Place{Dir: h.cell.Root, Workspace: h.work}, + SessionFile: transcriptOf(h.cell), Seat: executor.Gated(c.seat, c.drive.Gate, work, prepared, func() error { + ctx, cancel := context.WithTimeout(t.Context(), 750*time.Millisecond) + defer cancel() + return c.drive.ConfirmSettingsWork(ctx) + }), Seals: watch, + Model: "stub/chat", APIKey: "stub", BaseURL: "http://127.0.0.1:1", System: "SYSTEM", ProfileDir: h.homeA} + for _, apply := range change { + apply(&cfg) + } + a, err := session.New(cfg) + mustNil(t, err) + t.Cleanup(func() { _ = a.Close() }) + c.drive.SetIdleCheck(watch.IdleReady) + a.SettleWrites() + _, err = c.drive.Store(h.engine).Seal(t.Context(), h.cell, cellstore.TurnInfo{}) + mustNil(t, err) + h.durable(h.cell.ID, h.cell) + return a, watch +} + +func TestIdleRelease_C5_C6_SettingSavedAfterSealSurvivesOpen(t *testing.T) { + h := newTwoHomes(t) + c := idleContractChat(t, h) + a, _ := idleContractSession(t, h, c) + before := h.head() + if !a.SetConversationEffort("high") { + t.Fatal("could not save effort") + } + a.SettleWrites() + // C1 times release after upload. Idle must first capture and publish this + // metadata-only save, without an explicit seal hiding the original defect. + c.drive.Idle() + waitFor(t, "the metadata-only save to reach the relay", func() bool { + head := h.head() + return head != before && h.directoryHead(h.cell.ID) == head + }) + seen := idleContractReleased(t, h, c.drive, uploadedIdleReleaseWithin) + if seen.Cell.Head == before { + t.Fatal("C5 metadata-only save was not sealed") + } + got, err := h.continuerB().OpenAvailable(t.Context(), h.cell.ID, &directory.LeaseExpectation{Device: seen.Cell.Lease.Device, Fence: seen.Cell.Lease.Fence}) + mustNil(t, err) + restored, err := session.LoadMeta(got.Taken.Cell.Root) + mustNil(t, err) + if restored.Effort != "high" { + t.Fatalf("C5/C6 restored effort=%q", restored.Effort) + } +} + +type idleStartedEngine struct{ started chan struct{} } + +func (e *idleStartedEngine) Start(context.Context, session.RunSpec) session.RunSummary { + close(e.started) + return session.RunSummary{Outcome: "fixture ended", KeepCopy: true} +} +func (e *idleStartedEngine) Land(context.Context, *plandb.Store, string, string, string) (session.RunLanding, error) { + return session.RunLanding{}, nil +} + +func TestIdleRelease_C2_C4_TaskStartupRechecksOwnership(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + h := newTwoHomes(t) + c := idleContractChat(t, h) + engine := &idleStartedEngine{started: make(chan struct{})} + session.RegisterRunEngine(engine) + t.Cleanup(func() { session.RegisterRunEngine(nil) }) + entered, unblock := make(chan struct{}), make(chan struct{}) + var once sync.Once + release := func() { once.Do(func() { close(unblock) }) } + t.Cleanup(release) + a, watch := idleContractSession(t, h, c, func(cfg *session.Config) { + cfg.RouteCrew = func(config.CrewAsk) (crewroute.Decision, error) { + close(entered) + <-unblock + return crewroute.Decision{Mode: crewroute.ModeOne}, nil + } + }) + t.Cleanup(release) + done := make(chan error, 1) + go func() { _, _, _, err := a.StartTask(t.Context(), "repair fixtures", true); done <- err }() + select { + case <-entered: + case err := <-done: + t.Fatalf("setup did not start: %v", err) + case <-time.After(5 * time.Second): + t.Fatal("setup did not start") + } + watch.Wrote() + time.Sleep(cellstore.TailQuiet + time.Second) + view, err := h.a.Dir.Cell(t.Context(), h.cell.ID) + mustNil(t, err) + if view.Cell.Lease.Expires <= view.Now { + t.Error("C2 source released during task startup") + } + // An explicit move can displace even busy work. Slow setup must not retain + // the admission it received before that move. + _, err = h.b.Dir.Acquire(t.Context(), h.cell.ID, directory.AcquireOpts{Force: true, RequireConditional: true, Expect: &directory.LeaseExpectation{Device: view.Cell.Lease.Device, Fence: view.Cell.Lease.Fence}}) + mustNil(t, err) + release() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("task setup did not finish") + } + select { + case <-engine.started: + t.Fatal("C4 source entered its engine after ownership moved") + case <-time.After(100 * time.Millisecond): + } +} + +type idleStateMarker struct{} +type idleWaitingStore struct { + blobstore.Store + entered chan struct{} + all bool + ended chan struct{} +} + +func (b *idleWaitingStore) PutFrame(ctx context.Context, frame []byte) (blobstore.FrameID, error) { + if !b.all && ctx.Value(idleStateMarker{}) != true { + return b.Store.PutFrame(ctx, frame) + } + select { + case b.entered <- struct{}{}: + default: + } + <-ctx.Done() + if b.ended != nil { + b.ended <- struct{}{} + } + return "", ctx.Err() +} + +func settleIdleFixture(t *testing.T, h *twoHomes, d *Drive) { + t.Helper() + // Head visibility precedes the publisher's vault callback. Stop earlier + // idle preparation and drain that callback before arming a store fault. + d.BeginWork() + joined := make(chan struct{}) + go func() { d.idleWorkers.Wait(); close(joined) }() + select { + case <-joined: + case <-time.After(5 * time.Second): + t.Fatal("startup idle preparation did not stop") + } + ready := errors.New("startup publication finished") + waitFor(t, "the startup publisher's vault callback", func() bool { + // Refuse the release at its callback: taking the publisher lock proves + // the preceding flush completed, and changes no ownership state. + _, err := d.batcher.ReleaseIdle(t.Context(), h.head(), func() error { return ready }) + return errors.Is(err, ready) + }) + ctx, cancel := context.WithTimeout(t.Context(), 750*time.Millisecond) + defer cancel() + confirmed, err := d.ConfirmWork(ctx) + if err != nil || !confirmed { + t.Fatalf("fixture could not resume its own hold: %v, %v", confirmed, err) + } +} + +func TestIdleRelease_C3_C5_IdleUploadIsCanceledAndJoined(t *testing.T) { + h := newTwoHomes(t) + c := idleContractChat(t, h) + _, watch := idleContractSession(t, h, c) + waitFor(t, "saved session", watch.IdleReady) + settleIdleFixture(t, h, c.drive) + waiting := &idleWaitingStore{Store: h.a.Store, entered: make(chan struct{}, 2), ended: make(chan struct{}, 4), all: true} + // A visible head can precede its publisher's vault callback. + stateMu.Lock() + h.a.Store = waiting + stateMu.Unlock() + mustNil(t, config.WriteAPIKey(h.homeA, fakeKeyOne)) + start := func() { + c.drive.Idle() + select { + case <-waiting.entered: + case <-time.After(8 * time.Second): + t.Fatal("idle upload did not start") + } + } + start() + ctx, cancel := context.WithTimeout(t.Context(), 750*time.Millisecond) + defer cancel() + confirmed, err := c.drive.ConfirmWork(ctx) + if err != nil || !confirmed { + t.Fatalf("C3 work admission: %v, %v", confirmed, err) + } + select { + case <-waiting.ended: + case <-time.After(100 * time.Millisecond): + t.Fatal("new work did not cancel idle upload") + } + start() + ctx, stop := context.WithTimeout(t.Context(), 300*time.Millisecond) + defer stop() + closed := make(chan error, 1) + go func() { closed <- c.drive.Close(ctx) }() + select { + case err := <-closed: + if err != nil && !errors.Is(err, context.DeadlineExceeded) { + t.Fatal(err) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("Close did not cancel and join its idle attempt") + } + joined := make(chan struct{}) + go func() { c.drive.idleWorkers.Wait(); close(joined) }() + select { + case <-joined: + case <-time.After(100 * time.Millisecond): + t.Fatal("Close left an idle attempt running") + } +} + +func TestIdleRelease_C3_C5_StalledVaultDoesNotTrapSendOrClose(t *testing.T) { + h := newTwoHomes(t) + c := idleContractChat(t, h) + a, watch := idleContractSession(t, h, c) + waitFor(t, "session to settle before the stalled exchange", watch.IdleReady) + settleIdleFixture(t, h, c.drive) + waiting := &idleWaitingStore{Store: h.a.Store, entered: make(chan struct{}, 1)} + stateMu.Lock() + h.a.Store = waiting + stateMu.Unlock() + mustNil(t, config.WriteAPIKey(h.homeA, fakeKeyOne)) + stateCtx, stopState := context.WithCancel(context.WithValue(t.Context(), idleStateMarker{}, true)) + stateDone := make(chan struct{}) + go func() { defer close(stateDone); _ = h.a.SyncState(stateCtx) }() + defer func() { stopState(); <-stateDone }() + select { + case <-waiting.entered: + case <-time.After(5 * time.Second): + t.Fatal("home-state upload did not start") + } + c.drive.Idle() + time.Sleep(cellstore.TailQuiet + 300*time.Millisecond) + ctx, cancel := context.WithTimeout(t.Context(), 25*time.Millisecond) + defer cancel() + confirmed := make(chan struct{}) + go func() { defer close(confirmed); _, _ = c.drive.ConfirmWork(ctx) }() + select { + case <-confirmed: + case <-time.After(150 * time.Millisecond): + t.Error("25ms ownership admission blocked behind a home-state upload") + } + sent := make(chan error, 1) + sendDone := make(chan struct{}) + go func() { + defer close(sendDone) + events, err := a.SubmitBash(t.Context(), "! true") + sent <- err + if err == nil { + for range events { + } + } + }() + select { + case <-sent: + case <-time.After(900 * time.Millisecond): + t.Error("normal send admission exceeded its ownership deadline") + } + closed := make(chan error, 1) + closeCtx, stopClose := context.WithTimeout(t.Context(), 300*time.Millisecond) + defer stopClose() + go func() { closed <- c.drive.Close(closeCtx) }() + select { + case err := <-closed: + if err != nil && !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("close: %v", err) + } + case <-time.After(500 * time.Millisecond): + t.Error("Close waited for the unrelated stalled upload") + } + select { + case <-stateDone: + t.Error("fixture's stalled exchange ended before its cancellation") + default: + } + stopState() + <-confirmed + <-sendDone +} diff --git a/internal/syncsetup/ownership_notice.go b/internal/syncsetup/ownership_notice.go new file mode 100644 index 0000000000..55d9dec5d6 --- /dev/null +++ b/internal/syncsetup/ownership_notice.go @@ -0,0 +1,40 @@ +package syncsetup + +import "context" + +type ownershipNotice struct { + version uint64 + viewer Viewer +} + +type ownershipNoticesKey struct{} + +// Idle transitions and their later acquisitions share one notification order. +// Admission collects callbacks until its writer locks have been released. +func (d *Drive) ownershipNotice(ctx context.Context, version uint64, viewer Viewer) { + n := ownershipNotice{version, viewer} + if d.idleNotices.Load() { + if pending, ok := ctx.Value(ownershipNoticesKey{}).(*[]ownershipNotice); ok { + *pending = append(*pending, n) + return + } + } + d.deliverOwnership(n) +} + +func (d *Drive) deliverOwnership(n ownershipNotice) { + if d.idleNotices.Load() { + d.noticeMu.Lock() + defer d.noticeMu.Unlock() + if n.version != d.noticeVersion.Load() { + return + } + } + if n.viewer.Line != "" { + if d.opt.OnViewer != nil { + d.opt.OnViewer(n.viewer) + } + } else if d.opt.OnDriving != nil { + d.opt.OnDriving() + } +} diff --git a/internal/syncsetup/sendlease.go b/internal/syncsetup/sendlease.go index 6ad56284b2..89e7578e20 100644 --- a/internal/syncsetup/sendlease.go +++ b/internal/syncsetup/sendlease.go @@ -1,6 +1,7 @@ package syncsetup import ( + "context" "crypto/sha256" "encoding/hex" "encoding/json" @@ -8,6 +9,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/Agent-Field/codeaf/internal/directory" "github.com/Agent-Field/codeaf/internal/filelock" @@ -66,6 +68,32 @@ func (d *Drive) recordSendLease() error { return nil } +func (d *Drive) recordSendLeaseWithin(ctx context.Context) error { + if err := lockDrive(ctx, &d.receiptMu); err != nil { + return err + } + defer d.receiptMu.Unlock() + fence := d.batcher.Fence() + d.mu.Lock() + if d.lost || d.viewer.Line != "" || fence == 0 || fence == d.recordedFence { + d.mu.Unlock() + return nil + } + d.mu.Unlock() + held := sendLease{Cell: d.directoryID, Fence: fence} + if err := d.sync.recordSendLeaseWithin(ctx, d.cell.ID, held); err != nil { + return err + } + d.mu.Lock() + if d.stopped || d.lost || d.viewer.Line != "" || d.batcher.Fence() != fence { + d.mu.Unlock() + return d.sync.forgetSendLeaseWithin(ctx, d.cell.ID, held) + } + d.recordedFence = fence + d.mu.Unlock() + return nil +} + // reconcileSendLease removes a dead writer's receipt only after a fresh relay reading. // Conditional removal preserves a newer writer that recorded its hold meanwhile. func (s *Sync) reconcileSendLease(id, remoteID string, view directory.CellView) error { @@ -119,6 +147,48 @@ func (s *Sync) recordSendLease(id string, held sendLease) error { return nil } +func (s *Sync) recordSendLeaseWithin(ctx context.Context, id string, held sendLease) error { + lock, err := s.lockChatWithin(ctx, id, "") + if err != nil { + return err + } + defer lock.Close() + raw, err := json.Marshal(held) + if err != nil { + return err + } + path, err := s.sendLeasePath(id) + if err != nil { + return err + } + if before, err := os.ReadFile(path); err == nil { + var current sendLease + if json.Unmarshal(before, ¤t) == nil && current.Cell == held.Cell && current.Fence > held.Fence { + return directory.ErrFenceStale + } + } + if err := home.MkdirAllUnder(s.Home, 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 +} + // forgetSendLease retires only this writer's fence, preserving a newer local owner. func (s *Sync) forgetSendLease(id string, expected sendLease) error { lock, err := s.lockChat(id, "") @@ -151,6 +221,37 @@ func (s *Sync) forgetSendLease(id string, expected sendLease) error { return err } +func (s *Sync) forgetSendLeaseWithin(ctx context.Context, id string, expected sendLease) error { + lock, err := s.lockChatWithin(ctx, id, "") + if err != nil { + return err + } + defer lock.Close() + path, err := s.sendLeasePath(id) + if err != nil { + return err + } + raw, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + var held sendLease + if err := json.Unmarshal(raw, &held); err != nil { + return err + } + if held != expected { + return nil + } + err = os.Remove(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err +} + func (s *Sync) lockChat(id, suffix string) (*os.File, error) { path, err := s.sendLeasePath(id) if err != nil { @@ -169,3 +270,39 @@ func (s *Sync) lockChat(id, suffix string) (*os.File, error) { } return lock, nil } + +func (s *Sync) lockChatWithin(ctx context.Context, id, suffix string) (*os.File, error) { + path, err := s.sendLeasePath(id) + if err != nil { + return nil, err + } + if err := home.MkdirAllUnder(s.Home, filepath.Dir(path), 0700); err != nil { + return nil, err + } + lock, err := os.OpenFile(path+suffix+".lock", os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return nil, err + } + for { + if err := ctx.Err(); err != nil { + lock.Close() + return nil, err + } + err := filelock.Lock(lock, true, true) + if err == nil { + return lock, nil + } + if !filelock.IsBusy(err) { + lock.Close() + return nil, err + } + timer := time.NewTimer(time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + lock.Close() + return nil, ctx.Err() + case <-timer.C: + } + } +} diff --git a/internal/syncsetup/state.go b/internal/syncsetup/state.go index 5ca5fb4124..35f468808d 100644 --- a/internal/syncsetup/state.go +++ b/internal/syncsetup/state.go @@ -92,7 +92,7 @@ func (s *Sync) SyncState(ctx context.Context) error { if err != nil { return err } - err = s.withVault(func(v vaultsync.Syncer) error { + err = s.withVaultWithin(ctx, 0, func(v vaultsync.Syncer) error { sharing := s.teamsSharing() if sharing != s.teamsShared { s.stateVaultKnown = false diff --git a/internal/syncsetup/syncsetup.go b/internal/syncsetup/syncsetup.go index a4b1a19181..4fd459bb64 100644 --- a/internal/syncsetup/syncsetup.go +++ b/internal/syncsetup/syncsetup.go @@ -261,7 +261,13 @@ func (s *Sync) RowsMailAt(ctx context.Context) ([]chatlist.Row, Mail, uint64, er } key := directory.MetadataKey(s.Identity.CellKey()) open := func(sealed string) (string, error) { return directory.OpenName(key, sealed) } - return chatlist.Rows(l, s.Device.ID(), open), MailOf(l, s.Device.ID(), key), l.Version, nil + rows := chatlist.Rows(l, s.Device.ID(), open) + if !s.IdleHandoffAllowed() { + for i := range rows { + rows[i].AutoOpen = false + } + } + return rows, MailOf(l, s.Device.ID(), key), l.Version, nil } // Follow joins this process's change feed for the identity on this relay, so a diff --git a/internal/syncsetup/take.go b/internal/syncsetup/take.go index 6a31609e3c..1bdee47b45 100644 --- a/internal/syncsetup/take.go +++ b/internal/syncsetup/take.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "github.com/Agent-Field/codeaf/internal/fsreplace" "log" "os" "path/filepath" @@ -17,6 +16,7 @@ import ( "github.com/Agent-Field/codeaf/internal/cellstore" "github.com/Agent-Field/codeaf/internal/cellsync" "github.com/Agent-Field/codeaf/internal/chatfiles" + "github.com/Agent-Field/codeaf/internal/chatlist" "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/directory" "github.com/Agent-Field/codeaf/internal/filelock" @@ -84,9 +84,15 @@ type Continued struct { // Continuer is the take side of one machine: `continue here`. type Continuer struct { - sync *Sync - eng cellstore.Engine - opt TakeOptions + sync *Sync + eng cellstore.Engine + opt TakeOptions + freeOnly bool +} + +// IdleHandoffAllowed reports whether the opt-in idle handoff setting is on. +func (c *Continuer) IdleHandoffAllowed() bool { + return c != nil && c.sync != nil && c.sync.IdleHandoffAllowed() } // Continuer builds the take side over eng, the engine every chat this machine @@ -120,14 +126,15 @@ func (c *Continuer) taker(ctx context.Context, sc *Scope) handoff.Taker { // local chat does become the branch.) brancher := cellsync.Brancher{Dir: s.Dir, Publisher: pub, NewID: newCellID} return handoff.Taker{ - OnPhase: c.opt.OnPhase, - Dir: s.Dir, - Fetch: &cellsync.Fetcher{Engine: engine, Store: sc.Store, Dir: s.Dir, Inbox: sync.Inbox, ManyMax: cellsync.ManyMaxFor(s.Relay)}, - 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) }, - After: []func(context.Context, cell.Cell) error{rebuildIndexes, recordOrigin, restoreChatFiles, c.pullVault}, + FreeOnly: c.freeOnly, + OnPhase: c.opt.OnPhase, + Dir: s.Dir, + Fetch: &cellsync.Fetcher{Engine: engine, Store: sc.Store, Dir: s.Dir, Inbox: sync.Inbox, ManyMax: cellsync.ManyMaxFor(s.Relay)}, + 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) }, + After: []func(context.Context, cell.Cell) error{rebuildIndexes, recordOrigin, restoreChatFiles, c.pullVault}, } } @@ -150,6 +157,22 @@ func (c *Continuer) TakeSeen(ctx context.Context, id string, seen *directory.Lea return c.take(ctx, id, seen, false) } +// OpenAvailable is an open gesture from a paired set's free-chat row. It +// restores through the ordinary continuation path, but never forces a hold. +func (c *Continuer) OpenAvailable(ctx context.Context, id string, seen *directory.LeaseExpectation) (Continued, error) { + copy := *c + copy.freeOnly = true + return copy.take(ctx, id, seen, false) +} + +// OpenAvailableWithWorkspaceRoots keeps a receiver's explicit folder choices +// while retaining the automatic open's prohibition on displacing a live hold. +func (c *Continuer) OpenAvailableWithWorkspaceRoots(ctx context.Context, id string, seen *directory.LeaseExpectation, roots map[string]string) (Continued, error) { + copy := *c + copy.freeOnly = true + return copy.TakeSeenWithWorkspaceRoots(ctx, id, seen, roots) +} + // TakeSeenWithWorkspaceRoots applies only this person's selected receiver // bindings to one takeover, retaining the holder expectation they approved. func (c *Continuer) TakeSeenWithWorkspaceRoots(ctx context.Context, id string, seen *directory.LeaseExpectation, roots map[string]string) (Continued, error) { @@ -204,6 +227,14 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx phases["local capture"], phases["directory"], phases["retirement"], phases["fetch"], phases["acquire"], phases["restore"], phases["hooks"], phases["filing"], phases["unpublished check"], phases["dirty check"], phases["kept-branch upload"], phases["staging/preflight"], time.Since(started), outcome) }() root := c.opt.RootFor(id) + _, localRootErr := cell.OpenAt(root, id) + keepRoot := localRootErr == nil + preKept := "" + if branches, err := cellsync.OpenBranchMap(c.sync.Home); err == nil { + if resolved := branches.Resolve(id); resolved != id { + preKept = resolved + } + } defer handoff.Arrive(root)() localCapture := handoff.Trace(c.opt.OnPhase, "local capture") if err := cellstore.Await(ctx, cell.Cell{Root: root}); err != nil { @@ -212,9 +243,18 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx } localCapture() directoryRead := handoff.Trace(c.opt.OnPhase, "directory") - view, err := c.sync.Dir.Cell(ctx, id) + readCtx := ctx + cancelRead := func() {} + if c.freeOnly { + readCtx, cancelRead = context.WithTimeout(ctx, chatlist.OpenReadTimeout) + } + view, err := c.sync.Dir.Cell(readCtx, id) + cancelRead() directoryRead() if err != nil { + if c.freeOnly && errors.Is(err, context.DeadlineExceeded) { + err = fmt.Errorf("%w: %w", directory.ErrUnreachable, err) + } return Continued{}, err } if err := c.sync.reconcileSendLease(id, id, view); err != nil { @@ -224,12 +264,25 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx return Continued{}, errors.New("these turns are no longer available to open separately") } lease := view.Cell.Lease + if c.freeOnly && lease.Expires != 0 { + return Continued{}, directory.ErrLeaseHeld + } // 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 { + heldHere := !branchOnly && known && view.Cell.ParentCell == "" && lease.Device == c.sync.Device.ID() && lease.Expires > view.Now + if heldHere { + // A revived own-device fence cannot restore a retired writer. Only + // its matching send receipt allows the active-session shortcut. + remote, fence, err := c.sync.SendLease(id) + if err != nil && !os.IsNotExist(err) { + return Continued{}, err + } + heldHere = err == nil && remote == id && fence == lease.Fence + } + if heldHere { opened, err := cell.OpenAt(root, id) if err != nil { return Continued{}, err @@ -272,7 +325,13 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx if !keepOpen { retire() } - localWork := engineLocal{eng: c.eng, ctx: ctx} + alreadyBranched := "" + if preKept != "" { + if kv, err := c.sync.Dir.Cell(ctx, preKept); err == nil && kv.Cell.ParentCell == id { + alreadyBranched = kv.Cell.Head + } + } + localWork := engineLocal{eng: c.eng, ctx: ctx, alreadyBranched: alreadyBranched} if storageOff { local := cell.Cell{ID: id, Root: root} if _, err := os.Stat(root); err == nil { @@ -395,7 +454,7 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx filed() } }() - if taken.Cell, filed, err = c.fileCopy(ctx, taken.Cell); err != nil { + if taken.Cell, filed, err = c.fileCopy(ctx, taken.Cell, keepRoot); err != nil { return Continued{}, errors.Join(err, c.giveBack(ctx, id, taken.Driving.Fence)) } taken.Driving.Cell = taken.Cell @@ -422,7 +481,7 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx } } out = Continued{LocalOnly: storageOff, Taken: taken, Device: c.opt.DeviceName, TaskCopies: taskCopies, Resume: c.arrive(taken.Cell, from)} - if taken.Kept != "" { + if taken.Kept != "" && taken.Kept != preKept { out.KeptTurns = c.orphanTurns(ctx, taken.Kept) } return out, nil @@ -539,6 +598,14 @@ func (c *Continuer) orphanTurns(ctx context.Context, branch string) uint32 { // the kept edits become. func (c *Continuer) branch(b cellsync.Brancher) func(context.Context, *cellsync.Driving, string, uint32) (string, error) { return func(ctx context.Context, from *cellsync.Driving, head string, turns uint32) (string, error) { + if branches, err := cellsync.OpenBranchMap(c.sync.Home); err == nil { + if existing := branches.Resolve(from.Cell.ID); existing != from.Cell.ID { + if v, err := c.sync.Dir.Cell(ctx, existing); err == nil && v.Cell.ParentCell == from.Cell.ID && v.Cell.Head == head { + from.Remote, from.Fence, from.Head = existing, 1, head + return existing, nil + } + } + } opened, err := cell.OpenAt(from.Cell.Root, from.Cell.ID) if err != nil { return "", err @@ -584,8 +651,9 @@ 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 - ctx context.Context + eng cellstore.Engine + ctx context.Context + alreadyBranched string } func (l engineLocal) Unpublished(c cell.Cell, remoteHead string) (string, uint32, error) { @@ -603,6 +671,13 @@ func (l engineLocal) Unpublished(c cell.Cell, remoteHead string) (string, uint32 } func (l engineLocal) Dirty(ctx context.Context, c cell.Cell) (bool, error) { + if l.alreadyBranched != "" { + if head, err := cellstore.Head(c); err == nil && head != nil && head.Turn.ID == l.alreadyBranched && cellstore.Unsaved(c) == 0 { + if proof, err := l.eng.SavedState(ctx, c); err == nil && proof != nil && len(proof.Changes) == 0 { + return false, nil + } + } + } // Both appended accounting and edits inside an older message must be checked // against the shared snapshot, even when the current journal has the same size. return l.eng.LocalWork(ctx, c) @@ -678,45 +753,6 @@ func recordOrigin(_ context.Context, c cell.Cell) error { return session.SaveMeta(c.Root, m) } -// fileCopy uses the origin from the sealed inventory rather than this -// computer's home directory. A first arrival stays a copy even if an unrelated -// folder with the origin's path exists here; an existing borrowed project is -// kept in place by the takeover's ordinary in-place road. -func (c *Continuer) fileCopy(ctx context.Context, at cell.Cell) (cell.Cell, func(), error) { - m, err := session.LoadMeta(at.Root) - if err != nil || c.opt.BucketFor == nil || !m.Owned || m.Origin == "" { - return at, nil, err - } - dest := filepath.Join(c.opt.BucketFor(m.Origin), at.ID) - if filepath.Clean(dest) == filepath.Clean(at.Root) { - return at, nil, nil - } - if _, err := os.Stat(dest); !errors.Is(err, os.ErrNotExist) { - if err == nil { - err = errors.New("a copy of this chat is already filed here") - } - return at, nil, err - } - // The destination becomes discoverable at rename. Keep that root fenced - // through the caller's remaining arrival work, as the incoming root was. - filed := handoff.Arrive(dest) - if err := os.MkdirAll(filepath.Dir(dest), 0o700); err != nil { - return at, filed, err - } - if err := fsreplace.Rename(at.Root, dest); err != nil { - return at, filed, err - } - landed := cell.Cell{ID: at.ID, Root: dest} - if err := session.RebaseOwnedCopy(dest); err != nil { - return landed, filed, err - } - if err := c.eng.Follow(ctx, at, landed); err != nil { - return landed, filed, err - } - opened, err := cell.OpenAt(dest, at.ID) - return opened, filed, err -} - func titleOf(root string) string { m, _ := session.LoadMeta(root) return m.Title @@ -797,7 +833,19 @@ const ( // withVaultWithin is withVault for a caller that waits up to within for another // codeaf on this computer to finish its own exchange; zero never waits. func (s *Sync) withVaultWithin(ctx context.Context, within time.Duration, use func(vaultsync.Syncer) error, extra ...vaultsync.Carrier) error { - stateMu.Lock() + return s.withVaultRound(ctx, within, false, use, extra...) +} + +// Only idle certification may skip a competing in-process exchange. Ordinary +// callers serialize as before, with cancellation belonging to their context. +func (s *Sync) withVaultRound(ctx context.Context, within time.Duration, idle bool, use func(vaultsync.Syncer) error, extra ...vaultsync.Carrier) error { + if idle { + if !stateMu.TryLock() { + return errStateBusy + } + } else if err := lockDrive(ctx, &stateMu); err != nil { + return err + } defer stateMu.Unlock() // Refuse before opening the lock file as well as before creating keys, so // a replacement directory receives none of the previous home's state. @@ -823,6 +871,8 @@ func (s *Sync) withVaultWithin(ctx context.Context, within time.Duration, use fu Carry: append(s.carried(), extra...)}) } +var errStateBusy = errors.New("sync: another home-state exchange is in progress") + // lockState takes the vault's lock without blocking, and while another process // holds it retries every vaultBusyRetry until within has passed or ctx ends. The // answer after the bound is the busy refusal itself, so a caller that gives up @@ -860,6 +910,12 @@ func (s *Sync) teamsSharing() bool { return config.TeamsSharingAt(s.profile(), func() bool { return MayTalk(s.Home) }) } +// IdleHandoffAllowed reports whether the idle-handoff setting is on and +// encrypted relay storage is allowed for this home. +func (s *Sync) IdleHandoffAllowed() bool { + return s != nil && s.StorageAllowed() && config.IdleHandoffAt(s.profile()) +} + // carried is the profile state that travels in the vault beside the secrets. func (s *Sync) carried() []vaultsync.Carrier { dir := s.profile() diff --git a/internal/syncwords/errors.go b/internal/syncwords/errors.go index 477136ad8a..5aa2a5e47c 100644 --- a/internal/syncwords/errors.go +++ b/internal/syncwords/errors.go @@ -68,6 +68,13 @@ func NetworkFailure(err error) bool { return errors.As(err, &request) || errors.As(err, &operation) || errors.As(err, &dns) || errors.Is(err, directory.ErrUnreachable) || errors.Is(err, wireauth.ErrUnavailable) || errors.Is(err, clusterctl.ErrUnavailable) || errors.Is(err, relay.ErrUnreachable) || errors.Is(err, relay.ErrStruggling) } +// RequestNotSent requires a failed dial, before an HTTP request could be sent. +// A response timeout, reset or generic unreachable error proves no such thing. +func RequestNotSent(err error) bool { + var operation *net.OpError + return errors.As(err, &operation) && operation.Op == "dial" && !errors.Is(err, syscall.ECONNRESET) +} + // UploadFailure marks a failure after local durability succeeded. It survives // error wrapping and is also carried explicitly by remote saving replies. func UploadFailure(err error) error { diff --git a/internal/syncwords/idle_r8_failure_test.go b/internal/syncwords/idle_r8_failure_test.go new file mode 100644 index 0000000000..fd9fa945e7 --- /dev/null +++ b/internal/syncwords/idle_r8_failure_test.go @@ -0,0 +1,36 @@ +package syncwords + +import ( + "context" + "fmt" + "net" + "net/url" + "syscall" + "testing" + + "github.com/Agent-Field/codeaf/internal/directory" +) + +func TestIdleRelease_R8_OnlyDialFailureProvesRequestNotSent(t *testing.T) { + for _, tc := range []struct { + name string + err error + want bool + }{ + {"refused", &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED}, true}, + {"unreachable route", &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ENETUNREACH}, true}, + {"dns", &net.OpError{Op: "dial", Net: "tcp", Err: &net.DNSError{Err: "no such host"}}, true}, + {"dial deadline", &net.OpError{Op: "dial", Net: "tcp", Err: context.DeadlineExceeded}, true}, + {"response deadline", context.DeadlineExceeded, false}, + {"read reset", &net.OpError{Op: "read", Net: "tcp", Err: syscall.ECONNRESET}, false}, + {"reset", syscall.ECONNRESET, false}, + {"unknown unreachable", directory.ErrUnreachable, false}, + } { + t.Run(tc.name, func(t *testing.T) { + err := UploadFailure(fmt.Errorf("%w: %w", directory.ErrUnreachable, &url.Error{Op: "Post", URL: "http://fixture.invalid/release", Err: tc.err})) + if got := RequestNotSent(err); got != tc.want { + t.Fatalf("RequestNotSent(%v)=%v, want %v", err, got, tc.want) + } + }) + } +} diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 5a6b110a09..ca4dc7a626 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -676,6 +676,8 @@ type ( call *hostCall ch <-chan session.Event err error + // refused keeps the submitted words until the engine accepts them. + refused string // echo names WHICH echoed line this answer settles, and ZERO when none // was drawn (echo.go). It is stamped where the line was drawn rather // than looked up when the answer lands, because by then the person may @@ -5650,6 +5652,8 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { case homeMachinesSlowMsg: a.machinesStillChecking(msg) return a, nil + case homeLocalOpenMsg: + return a, a.homeLocalOpenReady(msg) case homeMachinesMsg: // THE OTHER MACHINES' CHATS, COMING BACK, off the update loop for the // reason the repository's reading is (homepanel_machines.go). @@ -6461,6 +6465,16 @@ func (a *app) adopt(msg submittedMsg) (cmd tea.Cmd) { // its pictures, an accepted one has spent them (attach.go). a.chipsSettled(msg.err) if msg.err != nil { + if msg.refused != "" { + a.atMainComposer(func(state *composerState) { + words := msg.refused + if !state.box.empty() { + words += "\n" + unfoldPastes(state.box.String(), state.pastes) + } + state.box.setText(words) + state.pastes = nil + }) + } // A MESSAGE THE ENGINE REFUSED NEVER HAPPENED, so the line the surface // drew for it comes off the page and the refusal is put where it was // (echo.go). The sentence is the engine's own, unchanged: it knows why — @@ -7604,10 +7618,10 @@ func (a *app) submittingShown(text, shown string, plain []segment, start func() ctx, agent := a.ctx, a.agent return tea.Batch(func() tea.Msg { if err := waitModelSelection(ctx, agent); err != nil { - return submittedMsg{err: err, echo: mark, call: call} + return submittedMsg{err: err, echo: mark, call: call, refused: text} } ch, err := start() - return submittedMsg{ch: ch, err: err, echo: mark, call: call} + return submittedMsg{ch: ch, err: err, echo: mark, call: call, refused: text} }, a.wake()) } diff --git a/internal/tui3/attach.go b/internal/tui3/attach.go index d7124ac0c3..e86fc21951 100644 --- a/internal/tui3/attach.go +++ b/internal/tui3/attach.go @@ -959,7 +959,7 @@ func (a *app) submitImagesShown(text, shown string, plain []segment) tea.Cmd { a.touch() return tea.Batch(func() tea.Msg { ch, err := start() - return submittedMsg{ch: ch, err: err, echo: mark} + return submittedMsg{ch: ch, err: err, echo: mark, refused: text} }, a.wake()) } diff --git a/internal/tui3/chrome_test.go b/internal/tui3/chrome_test.go index ff583aa910..ad64e94307 100644 --- a/internal/tui3/chrome_test.go +++ b/internal/tui3/chrome_test.go @@ -28,7 +28,7 @@ func sheetApp(t *testing.T) (*app, string) { // and a developer with CODEAF_ATTRIBUTION_MODEL exported would otherwise be // testing their shell. Empty reads as unset everywhere in that package. for _, pin := range []string{ - "CODEAF_ATTRIBUTION_MODEL", "CODEAF_NERD_FONT", "CODEAF_CHAT_LINEAR", + "CODEAF_ATTRIBUTION_MODEL", "CODEAF_IDLE_HANDOFF", "CODEAF_NERD_FONT", "CODEAF_CHAT_LINEAR", "CODEAF_HISTORY", "CODEAF_DRAFT_PERSIST", "CODEAF_DOC_ENGINE", "CODEAF_CONTEXT_FILL_PCT", "CODEAF_DAILY_BUDGET", "EXA_API_KEY", "FIRECRAWL_API_KEY", "JINA_API_KEY", // The capability slots resolve their environment variable before the diff --git a/internal/tui3/effortchip.go b/internal/tui3/effortchip.go index 669990f668..8ebfc9d06d 100644 --- a/internal/tui3/effortchip.go +++ b/internal/tui3/effortchip.go @@ -362,6 +362,12 @@ func (a *app) cycleEffort() tea.Cmd { // outside, which is the exact defect the ladder was written to end. func (a *app) setEffortRung(dial effortDialer, rung effort.Rung) tea.Cmd { if !dial.SetConversationEffort(rung.String()) { + if result, ok := dial.(interface{ SettingError() error }); ok { + if err := result.SettingError(); err != nil { + a.input.setText("/effort " + rung.String()) + a.note(err.Error()) + } + } return nil } a.effortLit = effortMoved{where: effortScopeConversation, at: a.now()} diff --git a/internal/tui3/home.go b/internal/tui3/home.go index 891edb8591..1ce28dc85e 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -3126,6 +3126,30 @@ func (a *app) homeOpenLine(line homeLine) tea.Cmd { return nil } } + // A missing reading or stale own hold can hide a free chat after restart. + // An open window or an answered display needs no ownership round trip. + if a.idleOpenEnabled() && !a.homeTakeOpening && a.taker != nil && !a.hosted() && !a.machineRead.computerRemoved { + if a.machineRead.down || a.machineRead.err != nil { + return a.homeOpenLocalLine(line) + } + if source, ok := a.machines.(chatlist.SendSource); ok && a.homeLocalReadNeeded(line) { + if _, available := a.taker.(availableTaker); available { + return a.homeResolveLocal(line, source) + } + } + id := homeSessionID(line.row) + for _, row := range a.machineRead.rows { + if row.Cell == id && row.AutoOpen { + return a.homeMachineEnter(homeLine{remote: &row}) + } + } + } + return a.homeOpenLocalLine(line) +} + +// homeOpenLocalLine is the ordinary local door, including offline opening. +func (a *app) homeOpenLocalLine(line homeLine) tea.Cmd { + h := &a.home if line.row.Transcript == a.file { a.rememberOpen(a.frontTabKey()) a.closeHome() diff --git a/internal/tui3/home_local_open.go b/internal/tui3/home_local_open.go new file mode 100644 index 0000000000..1a602b3420 --- /dev/null +++ b/internal/tui3/home_local_open.go @@ -0,0 +1,82 @@ +package tui3 + +import ( + "context" + + tea "charm.land/bubbletea/v2" + + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/config" +) + +const homeLocalOpenTimeout = chatlist.OpenReadTimeout + +type homeLocalOpenMsg struct { + line homeLine + fromFile string + generation uint64 + reading homeMachinesMsg +} + +func (a *app) idleOpenEnabled() bool { + if gate, ok := a.taker.(interface{ IdleHandoffAllowed() bool }); ok { + return gate.IdleHandoffAllowed() + } + if gate, ok := a.machines.(interface{ IdleHandoffAllowed() bool }); ok { + return gate.IdleHandoffAllowed() + } + return config.IdleHandoffAt("") +} + +// homeLocalReadNeeded limits restart recovery to ambiguous display readings. +// Reusing an open window still lets a displayed free row refresh a stale viewer. +func (a *app) homeLocalReadNeeded(line homeLine) bool { + if a.teamsPaired != nil && !a.teamsPaired() || a.addMachine.known && a.addMachine.size < fleetEnough { + return false + } + if line.row.Transcript == a.file || a.holding(line.row.Transcript) { + return false + } + id := homeSessionID(line.row) + for _, row := range a.machineRead.rows { + if row.Cell == id { + return !row.AutoOpen && row.Mine && row.Status == chatlist.Here + } + } + return true +} + +// homeResolveLocal checks a potentially stale local copy without making the +// sync service a requirement for opening a chat saved on this computer. +func (a *app) homeResolveLocal(line homeLine, source chatlist.SendSource) tea.Cmd { + msg := homeLocalOpenMsg{line: line, fromFile: a.file, generation: a.continueGeneration} + base := a.ctx + return func() tea.Msg { + ctx, cancel := context.WithTimeout(base, homeLocalOpenTimeout) + defer cancel() + rows, version, err := source.RowsForSend(ctx) + msg.reading = homeMachinesMsg{rows: rows, version: version, err: err, readWithin: homeLocalOpenTimeout} + return msg + } +} + +func (a *app) homeLocalOpenReady(msg homeLocalOpenMsg) tea.Cmd { + if !a.at(pageHome) || msg.generation != a.continueGeneration || msg.fromFile != a.file || a.hosted() || a.machineRead.computerRemoved { + return nil + } + if msg.reading.err != nil { + return a.homeOpenLocalLine(msg.line) + } + recorded := a.recordMachines(msg.reading) + id := homeSessionID(msg.line.row) + for _, row := range msg.reading.rows { + if row.Cell != id { + continue + } + if row.AutoOpen || heldElsewhere(row) && msg.line.row.Transcript != a.file && !a.holding(msg.line.row.Transcript) { + return tea.Batch(recorded, a.homeMachineEnter(homeLine{remote: &row})) + } + break + } + return tea.Batch(recorded, a.homeOpenLocalLine(msg.line)) +} diff --git a/internal/tui3/homepanel_continue.go b/internal/tui3/homepanel_continue.go index 25c6e639ed..63f9ced4f7 100644 --- a/internal/tui3/homepanel_continue.go +++ b/internal/tui3/homepanel_continue.go @@ -76,6 +76,14 @@ type conditionalTaker interface { TakeSeen(context.Context, string, *directory.LeaseExpectation) (Taken, error) } +type availableTaker interface { + OpenAvailable(context.Context, string, *directory.LeaseExpectation) (Taken, error) +} + +type availableRootTaker interface { + OpenAvailableWithWorkspaceRoots(context.Context, string, *directory.LeaseExpectation, map[string]string) (Taken, error) +} + type workspaceRootTaker interface { TakeSeenWithWorkspaceRoots(context.Context, string, *directory.LeaseExpectation, map[string]string) (Taken, error) } @@ -157,11 +165,8 @@ func (a *app) machineScreen(width int) []string { // remoteOffers answers each offer with what enter does on it. A table rather // than a switch, so a new offer is a new entry. // -// A CHAT ANOTHER MACHINE LET GO OF (`Open`, which is what a released chat offers -// where it is held) IS CONTINUED THE WAY A CHAT THAT WENT OFF IS: it is not on -// this machine, so opening it is continuing it, and the lease is free. A chat -// that is running on another machine is continued the same way, and the lease -// is taken from it at once: the screen names the machine, the person decides. +// Paired free rows take the non-forcing open door before this table. Other +// rows keep the existing continuation choice, including live older holders. var remoteOffers = map[chatlist.OfferKind]func(*app, chatlist.Row, chatlist.Offer) tea.Cmd{ chatlist.Open: (*app).offerContinue, chatlist.ContinueHere: (*app).offerContinue, @@ -186,7 +191,12 @@ func (a *app) homeMachineEnter(line homeLine) tea.Cmd { // THE DIRECTORY IS THE ONLY THING THAT CAN GRANT A LEASE OR ARCHIVE A // BRANCH, so with it out of reach the acting offers are disabled and say why // on the foot, where every other refusal on this screen is said. - if a.machineRead.down { + if a.machineRead.down || a.machineRead.err != nil { + if a.idleOpenEnabled() { + if local, ok := a.homeLineOf(row.Cell); ok && row.AutoOpen { + return a.homeOpenLocalLine(local) + } + } a.home.say(a.machineRead.unavailableWord(), "") return nil } @@ -195,6 +205,11 @@ func (a *app) homeMachineEnter(line homeLine) tea.Cmd { a.home.say(continueAlreadyRunning, "") return nil } + if a.idleOpenEnabled() && row.AutoOpen { + if _, ok := a.taker.(availableTaker); ok { + return a.takeRemoteState(homeTakenMsg{row: row}) + } + } return run(a, row, offer) } @@ -562,9 +577,14 @@ func (a *app) takeRemoteState(msg homeTakenMsg) tea.Cmd { msg.draft = &composer } } + idleOpen := a.idleOpenEnabled() && msg.row.AutoOpen take := func() tea.Msg { seen := &directory.LeaseExpectation{Device: msg.row.DeviceID, Fence: msg.row.Fence} - if mapped, ok := taker.(workspaceRootTaker); ok && len(msg.roots) > 0 { + if open, ok := taker.(availableRootTaker); ok && idleOpen && len(msg.roots) > 0 { + msg.taken, msg.err = open.OpenAvailableWithWorkspaceRoots(ctx, msg.row.Cell, seen, msg.roots) + } else if open, ok := taker.(availableTaker); ok && idleOpen { + msg.taken, msg.err = open.OpenAvailable(ctx, msg.row.Cell, seen) + } else if mapped, ok := taker.(workspaceRootTaker); ok && len(msg.roots) > 0 { msg.taken, msg.err = mapped.TakeSeenWithWorkspaceRoots(ctx, msg.row.Cell, seen, msg.roots) } else if conditional, ok := taker.(conditionalTaker); ok { msg.taken, msg.err = conditional.TakeSeen(ctx, msg.row.Cell, seen) @@ -686,6 +706,16 @@ func (a *app) tookTakeover(msg homeTakenMsg) tea.Cmd { if msg.err != nil { a.finishHomeTake(false) } + // A failed availability read has not brought a newer chat here. Keep the + // ordinary local door available, with send admission still checking its hold. + if !msg.automatic && msg.row.AutoOpen && a.at(pageHome) && !a.machineRead.computerRemoved && + (errors.Is(msg.err, directory.ErrUnreachable) || errors.Is(msg.err, context.DeadlineExceeded)) { + if local, ok := a.homeLineOf(msg.row.Cell); ok { + a.failedTake(msg) + a.restoreTakeDraft(msg) + return a.homeOpenLocalLine(local) + } + } var missing *session.WorkspaceBindingsRequired if errors.As(msg.err, &missing) { if _, ok := a.taker.(workspaceRootTaker); ok && len(missing.Roots) > 0 { @@ -703,7 +733,7 @@ func (a *app) tookTakeover(msg homeTakenMsg) tea.Cmd { switch { case errors.Is(msg.err, directory.ErrLeaseHeld): return a.lostRace(msg) - case errors.Is(msg.err, directory.ErrUnreachable): + case errors.Is(msg.err, directory.ErrUnreachable), msg.row.AutoOpen && errors.Is(msg.err, context.DeadlineExceeded): // Home is where the failed move lands, so it carries the whole sentence: // the reason alone does not say the chat stayed and the words were kept. a.machineRead.down = true @@ -839,7 +869,7 @@ func (a *app) continueConflictRead(msg continueConflictMsg) tea.Cmd { } sameHolder := row.DeviceID != "" && row.DeviceID == failed.row.DeviceID || row.DeviceID == "" && failed.row.DeviceID == "" && row.Device == failed.row.Device if sameHolder && row.Fence != failed.row.Fence && row.Device != "" { - failure = fmt.Sprintf("%s picked this chat up again; press enter to continue it here", nonemptyDevice(row.Device)) + failure = fmt.Sprintf(chatlist.PickedUpAgain+"; press enter to continue it here", nonemptyDevice(row.Device)) } else if !sameHolder && !row.Mine { failure = lostRaceWord } @@ -892,7 +922,7 @@ func (a *app) openTaken(msg homeTakenMsg) tea.Cmd { rows := append([]chatlist.Row(nil), a.machineRead.rows...) for i := range rows { if rows[i].Cell == msg.row.Cell { - rows[i].Mine, rows[i].Status, rows[i].Released = true, chatlist.Here, false + rows[i].Mine, rows[i].Status, rows[i].Released, rows[i].AutoOpen = true, chatlist.Here, false, false rows[i].Device, rows[i].DeviceID, rows[i].Fence = msg.taken.Device, "", msg.taken.Fence rows[i].Head = msg.taken.Head } diff --git a/internal/tui3/homepanel_heldelsewhere.go b/internal/tui3/homepanel_heldelsewhere.go index acb66789cc..f4c9c7550b 100644 --- a/internal/tui3/homepanel_heldelsewhere.go +++ b/internal/tui3/homepanel_heldelsewhere.go @@ -16,6 +16,7 @@ import ( "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/executor" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/syncwords" ) @@ -48,6 +49,10 @@ func (a *app) heldElsewhere() (chatlist.Row, bool) { // 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 { + retryIdle bool + admissionErr error + // reopen records a drive refusal that requires a fresh conditional take. + reopen bool file, draft string host string chips []chip @@ -61,15 +66,20 @@ type ownershipReadMsg struct { first *composerState head, leaseCell string fence uint64 + ownFence uint64 proof bool local bool extends bool + // fresh records the directory answer even when admission then proves a local hold. + fresh bool // notHeld prevents a saved receipt from reviving a displaced writer offline. notHeld bool writerStopped bool } type sendHold struct { + // retryIdle belongs only to an uncertain take-back by this open window. + retryIdle bool // awaitingWriter belongs only to this window's successful move, never to a listing receipt. awaitingWriter bool // A listing or notice wakes every send waiting for this window's writer. @@ -122,6 +132,24 @@ func (a *app) checkBeforeSendWith(resume func() tea.Cmd) tea.Cmd { a.machineRead.sendHolds[filepath.Base(session.DirOf(file))] = hold } host := a.relayHost() + takeBack := hold.retryIdle + for _, row := range a.machineRead.rows { + if row.Cell == filepath.Base(session.DirOf(file)) && row.Mine { + takeBack = true + } + if row.Cell == filepath.Base(session.DirOf(file)) && (hold.retryIdle || row.AutoOpen && row.Mine && row.Released) { + waiting := a.machineRead + waiting.checking, waiting.host = true, host + if !blocked && !hold.failed { + // This progress describes the new check, not Home's prior failure. + waiting.down = false + } + if line := waiting.waitWord(); line != "" { + a.note(line) + } + break + } + } chips := slices.Clone(a.chips) pastes := slices.Clone(a.pastes) var first *composerState @@ -164,24 +192,53 @@ func (a *app) checkBeforeSendWith(resume func() tea.Cmd) tea.Cmd { if blocked { holding, notHeld = false, true } + if holding { + if receipts, ok := src.(sendLeaseSource); ok { + receiptCell, receiptFence, receiptErr := receipts.SendLease(filepath.Base(session.DirOf(file))) + if receiptErr != nil || receiptCell == "" || receiptFence == 0 { + // Receipt retirement can precede the engine's writer-status + // frame. That cached status cannot suppress a recovery probe. + holding, notHeld = false, true + } + } + } + // A released or lapsed window needs current ownership to take its chat + // back. Home's cached refusal cannot answer the person's next Enter. + fresh := notHeld + sendOffline := offline + if fresh && err == nil { + sendOffline = false + cancel() + readWithin = idleSendProbeTimeout + ctx, cancel = context.WithTimeout(context.Background(), readWithin) + defer cancel() + } if err != nil { // An expired startup wait keeps the original submission unsent. } else if scoped, ok := src.(interface { RowsForChatSend(context.Context, string, bool, bool) ([]chatlist.Row, uint64, bool, error) }); ok { - rows, version, local, err = scoped.RowsForChatSend(ctx, file, offline, holding) - } else if offline { + rows, version, local, err = scoped.RowsForChatSend(ctx, file, sendOffline, holding) + } else if sendOffline { err = directory.ErrUnreachable } else { rows, version, err = chatlist.RowsForSend(ctx, src) } - msg := ownershipReadMsg{file: file, draft: draft, chips: chips, resume: resume, first: first, local: local, notHeld: notHeld, reading: homeMachinesMsg{rows: rows, version: version, err: err, readWithin: readWithin}, host: host, pastes: pastes, writerWaitFailed: writerWaitFailed, writerStopped: writerStopped, writerDisplaced: writerDisplaced} + msg := ownershipReadMsg{retryIdle: hold.retryIdle, file: file, draft: draft, chips: chips, resume: resume, first: first, local: local, notHeld: notHeld, reading: homeMachinesMsg{rows: rows, version: version, err: err, readWithin: readWithin}, host: host, pastes: pastes, writerWaitFailed: writerWaitFailed, writerStopped: writerStopped, writerDisplaced: writerDisplaced} + msg.fresh = fresh && !local && err == nil + if fresh && takeBack && !writerWaitFailed && (syncwords.NetworkFailure(err) || readTimedOut(err)) { + msg.admissionErr = &executor.WorkRefusal{Line: chatlist.TakeBackUnreachable, Retryable: true} + } 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 } + msg.ownFence = hold.fence + if msg.ownFence == 0 { + msg.ownFence = msg.fence + } if msg.notHeld { msg.leaseCell = "" } @@ -190,7 +247,7 @@ func (a *app) checkBeforeSendWith(resume func() tea.Cmd) tea.Cmd { remote = msg.leaseCell } for _, row := range rows { - if row.Cell == remote && (row.Status == chatlist.Idle || row.Status == chatlist.Off) { + if row.Cell == remote && (row.Status == chatlist.Idle || row.Status == chatlist.Off || hold.retryIdle && row.Mine) { at := cell.Cell{ID: id, Root: session.DirOf(file)} if head, _ := cellstore.Head(at); head != nil { msg.head = head.Turn.ID @@ -201,7 +258,7 @@ func (a *app) checkBeforeSendWith(resume func() tea.Cmd) tea.Cmd { break } } - return msg + return resumeIdleSend(ctx, agent, src, msg, blocked || hold.failed) } } @@ -234,7 +291,7 @@ func (a *app) afterOwnershipRead(msg ownershipReadMsg) tea.Cmd { next := a.liveComposer() a.putComposer(*msg.first) var reading tea.Cmd - if !msg.writerWaitFailed && !msg.writerDisplaced && (!msg.local || msg.reading.err != nil) { + if !msg.writerWaitFailed && !msg.writerDisplaced && (!msg.local || msg.fresh || msg.reading.err != nil) { reading = a.recordMachines(msg.reading) } cmd := a.resumeOwnedSend(msg) @@ -254,16 +311,16 @@ func (a *app) afterOwnershipRead(msg ownershipReadMsg) tea.Cmd { return nil } var reading tea.Cmd - if !msg.writerWaitFailed && !msg.writerDisplaced && (!msg.local || msg.reading.err != nil) { + if !msg.writerWaitFailed && !msg.writerDisplaced && (!msg.local || msg.fresh || msg.reading.err != nil) { reading = a.recordMachines(msg.reading) } return tea.Batch(reading, a.resumeOwnedSend(msg)) } // resumeOwnedSend spends only the submitted gesture after its fresh ownership -// reading. A storage-off return can include later local turns when every saved turn is here. +// reading. The taker preserves pending turns before replacing an unheld writer. func (a *app) resumeOwnedSend(msg ownershipReadMsg) tea.Cmd { - if msg.writerDisplaced { + if msg.writerDisplaced || a.refuseIdleSend(msg) { return nil } id := filepath.Base(session.DirOf(msg.file)) @@ -307,17 +364,26 @@ func (a *app) resumeOwnedSend(msg ownershipReadMsg) tea.Cmd { if msg.proof { fence = msg.fence } - if row.Status == chatlist.Here && row.Fence == fence && !(msg.notHeld && (held.awaitingWriter || msg.writerStopped) && !msg.local) && (!held.blocked || held.seen && row.Mine && !held.failed) { + if !msg.reopen && row.Status == chatlist.Here && row.Fence == fence && !(msg.notHeld && (held.awaitingWriter || msg.writerStopped) && !msg.local) && (!held.blocked || held.seen && row.Mine && !held.failed) { + if held.awaitingWriter { + held.awaitingWriter = false + a.machineRead.sendHolds[id] = held + } if held.blocked { held.blocked, held.seen = false, false a.machineRead.sendHolds[id] = held } + if held.retryIdle { + held.retryIdle = false + a.machineRead.sendHolds[id] = held + } return msg.resume() } - // Local-only work can be ahead of the last shared turn. Missing turns - // still need a choice, and the conditional take checks ancestry again. - localHead := row.Head != "" && row.Head == msg.head || row.Mine && (row.Head == "" || msg.local && msg.extends) - if !held.blocked && (row.Status == chatlist.Idle || row.Status == chatlist.Off) && localHead { + // A failed move to a different chat can block this window too. An + // unchanged own fence may recover through the full conditional reopen; + // it never authorizes reuse of the writer that the failure stopped. + localHead := row.Head != "" && (row.Head == msg.head || (a.idleOpenEnabled() || msg.reopen) && row.Mine) || row.Mine && (row.Head == "" || msg.local && msg.extends) + if row.Mine && (msg.ownFence == 0 || row.Fence == msg.ownFence) && (!held.blocked || msg.ownFence != 0) && (msg.reopen || held.blocked || !row.AutoOpen && (row.Status == chatlist.Idle || row.Status == chatlist.Off)) && localHead { if _, ok := a.taker.(conditionalTaker); ok { draft := a.mainComposer() return a.takeRemoteState(homeTakenMsg{row: row, draft: &draft, send: msg.resume}) @@ -439,7 +505,7 @@ func (a *app) continueFailure(msg homeTakenMsg) string { // Before unreachable: the stall ends the request it waited on, and // that request's error reads as an unreachable relay. return fmt.Sprintf("stopped: nothing came through for 2 minutes; the chat is still on %s", nonemptyDevice(msg.row.Device)) - case errors.Is(msg.err, directory.ErrUnreachable): + case errors.Is(msg.err, directory.ErrUnreachable), msg.row.AutoOpen && errors.Is(msg.err, context.DeadlineExceeded): return continueUnreachable case errors.Is(msg.err, directory.ErrConditionalTake): return directory.ErrConditionalTake.Error() @@ -464,7 +530,10 @@ func (a *app) continueFailure(msg homeTakenMsg) string { // awaitSendWriter waits only for an uninstalled writer. Displacement ends the // wait even when the writer's renewal facts are indistinguishable from startup. -func awaitSendWriter(ctx context.Context, writer renewalReader, displaced <-chan struct{}) (bool, error) { +func awaitSendWriter(ctx context.Context, writer renewalReader, displaced <-chan struct{}, retaking ...bool) (bool, error) { + // A stopped writer still ends an ordinary startup wait. Only a previously + // uncertain take-back waits for its successful admission's cached status. + pendingTakeBack := len(retaking) > 0 && retaking[0] var poll *time.Ticker defer func() { if poll != nil { @@ -487,7 +556,7 @@ func awaitSendWriter(ctx context.Context, writer renewalReader, displaced <-chan changed = source.RenewalChanges() } _, period, holding := writer.RenewalStatus() - if holding || period > 0 { + if holding || period > 0 && !pendingTakeBack { return holding, nil } if changed == nil { diff --git a/internal/tui3/homepanel_machines.go b/internal/tui3/homepanel_machines.go index 92e578a441..f5cc3c27f8 100644 --- a/internal/tui3/homepanel_machines.go +++ b/internal/tui3/homepanel_machines.go @@ -75,6 +75,7 @@ const ( machinesAskTimeout = 25 * time.Second machinesSlowAfter = 5 * time.Second sendOwnershipTimeout = 5 * time.Second + idleSendProbeTimeout = 3 * time.Second writerStartTimeout = 30 * time.Second ) diff --git a/internal/tui3/idle_open_test.go b/internal/tui3/idle_open_test.go new file mode 100644 index 0000000000..603c99f84c --- /dev/null +++ b/internal/tui3/idle_open_test.go @@ -0,0 +1,82 @@ +package tui3 + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" +) + +type idleOpenTaker struct { + fakeTaker + opens int + seen *directory.LeaseExpectation +} + +func (t *idleOpenTaker) OpenAvailable(_ context.Context, _ string, seen *directory.LeaseExpectation) (Taken, error) { + t.opens++ + t.seen = seen + return Taken{}, directory.ErrLeaseHeld +} + +func TestIdleOpen_C6_C7_C10_TerminalEnterUsesTheFreeDoorOnly(t *testing.T) { + t.Setenv("CODEAF_IDLE_HANDOFF", "on") + for _, automatic := range []bool{true, false} { + t.Run(map[bool]string{true: "paired free", false: "live or single"}[automatic], func(t *testing.T) { + taker := &idleOpenTaker{} + a, _ := heldLab(t, &flipping{status: chatlist.Running}, taker) + row := chatlist.Row{Cell: heldCell, Title: "saved chat", Device: "tower", DeviceID: "A", Fence: 7, Head: "saved", Status: chatlist.Running, AutoOpen: automatic} + if automatic { + row.Status = chatlist.Idle + } + command := a.homeMachineEnter(homeLine{remote: &row}) + if automatic { + if strings.Contains(homeText(a), continueAsk) { + t.Fatal("C6 free open raised Continue here") + } + if command == nil { + t.Fatal("C6 free open did not start") + } + // Observe the open request before handling its raced-holder + // response, which may legitimately offer an explicit continuation. + _ = command() + if taker.opens != 1 || len(taker.cells) != 0 || taker.seen == nil || taker.seen.Fence != 7 || taker.seen.Device != "A" { + t.Fatalf("C6 wrong open door: %+v", taker) + } + } else if taker.opens != 0 || len(taker.cells) != 0 || !strings.Contains(homeText(a), continueAsk) { + t.Fatal("C7/C10 held chat no longer waits for the person's choice") + } + }) + } +} + +func TestIdleOpen_DefaultOff_ReleasedRowRequiresContinueHereAndLocalRowOpensLocally(t *testing.T) { + taker := &idleOpenTaker{} + a, _ := heldLab(t, &flipping{status: chatlist.Idle}, taker) + row := chatlist.Row{Cell: heldCell, Title: "saved chat", Device: "tower", DeviceID: "A", Fence: 7, Head: "saved", Status: chatlist.Idle, Released: true, AutoOpen: true} + command := a.homeMachineEnter(homeLine{remote: &row}) + if command != nil || taker.opens != 0 || len(taker.cells) != 0 || !strings.Contains(homeText(a), continueAsk) { + t.Fatalf("default-off released remote row must require Continue here: opens=%d cells=%v home=%s", taker.opens, taker.cells, homeText(a)) + } + + source := &r5bHomeSource{read: func(context.Context) ([]chatlist.Row, uint64, error) { + t.Fatal("default-off local open must not probe the relay") + return nil, 0, nil + }} + lab := newHomeLab(t) + file := lab.session("project", heldCell, "Mine", lab.workspace("project"), time.Now()) + localApp := lab.app("") + keepSurfaceWorkersInTest(t, localApp) + localApp.width, localApp.height = 200, 70 + localApp.teamsPaired = func() bool { return true } + localApp.machines, localApp.taker = source, taker + drain(t, localApp, localApp.openHome()) + r5bSelectChat(t, localApp, file) + drive(t, localApp, key("enter")) + if source.reads != 0 || taker.opens != 0 || localApp.at(pageHome) || localApp.file != file { + t.Fatalf("default-off local open changed dev behaviour: reads=%d opens=%d home=%v file=%q", source.reads, taker.opens, localApp.at(pageHome), localApp.file) + } +} diff --git a/internal/tui3/idle_r10_cache_test.go b/internal/tui3/idle_r10_cache_test.go new file mode 100644 index 0000000000..67daf09013 --- /dev/null +++ b/internal/tui3/idle_r10_cache_test.go @@ -0,0 +1,73 @@ +package tui3 + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/chatlist" +) + +type r10CachedWriter struct { + *fakeAgent + source *r10RetrySource + held atomic.Bool + read chan struct{} + changed chan struct{} +} + +func (a *r10CachedWriter) CheckWorkContext(context.Context) error { + a.source.held.Store(true) + return nil +} +func (a *r10CachedWriter) WriterChanges() <-chan struct{} { return a.changed } +func (a *r10CachedWriter) RenewalStatus() (time.Time, time.Duration, bool) { + held := a.held.Load() + if !held { + select { + case a.read <- struct{}{}: + default: + } + } + return time.Time{}, time.Second, held +} + +func TestIdleRelease_R10_ConfirmedRetryWaitsForCachedWriter(t *testing.T) { + r10ConfirmedWriter(t, true) +} + +func TestIdleRelease_R10_ConfirmedAdmissionWaitsForCachedWriter(t *testing.T) { + r10ConfirmedWriter(t, false) +} + +func r10ConfirmedWriter(t *testing.T, retry bool) { + t.Helper() + row := chatlist.Row{Cell: heldCell, Status: chatlist.Here, Mine: true, Fence: 3, Head: "saved"} + source := &r10RetrySource{continuationListing: &continuationListing{row: row}} + agent := &r10CachedWriter{fakeAgent: &fakeAgent{}, source: source, read: make(chan struct{}, 1), changed: make(chan struct{})} + done := make(chan ownershipReadMsg, 1) + release := sync.OnceFunc(func() { agent.held.Store(true); close(agent.changed) }) + joined := false + defer func() { + release() + if !joined { + <-done + } + }() + go func() { + done <- resumeIdleSend(t.Context(), agent, source, ownershipReadMsg{retryIdle: retry, notHeld: true, head: "saved", leaseCell: heldCell, reading: homeMachinesMsg{rows: []chatlist.Row{row}}}, false) + }() + select { + case <-agent.read: + case <-time.After(time.Second): + t.Fatal("no cached writer read after admission") + } + release() + msg := <-done + joined = true + if msg.admissionErr != nil || !msg.local || msg.fence != 3 { + t.Fatalf("confirmed own receipt became an availability refusal: %v; local=%v fence=%d", msg.admissionErr, msg.local, msg.fence) + } +} diff --git a/internal/tui3/idle_r10_replay_test.go b/internal/tui3/idle_r10_replay_test.go new file mode 100644 index 0000000000..ee8aa43964 --- /dev/null +++ b/internal/tui3/idle_r10_replay_test.go @@ -0,0 +1,251 @@ +package tui3 + +import ( + "context" + "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/directory/directorytest" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/session" +) + +// r10ReplaySource preserves the production source's distinction between a +// cached writer status and the current local send receipt. +type r10ReplaySource struct { + *continuationListing + clock *directorytest.FakeClock + priorHome, receipt, down bool + probes int +} + +type r10OrderingWriter struct { + *fakeAgent + source *r10ReplaySource +} + +func (w *r10OrderingWriter) RenewalStatus() (time.Time, time.Duration, bool) { + return time.Time{}, time.Second, w.source.receipt +} + +func (w *r10OrderingWriter) CheckWorkContext(context.Context) error { + if !w.source.receipt { + return &executor.WorkRefusal{Line: chatlist.Available} + } + return nil +} + +// The drive matrix proves preservation and fence effects. This matrix crosses +// those outcomes with Home history at the real Enter and composer boundary. +func TestIdleOpen_R10_RecoveryOrderingPriorHomeMatrix(t *testing.T) { + t.Setenv("CODEAF_IDLE_HANDOFF", "on") + for _, relay := range []string{"live", "lapsed"} { + for _, posture := range []string{"idle", "working"} { + for _, pending := range []string{"none", "some"} { + for _, call := range []string{"no_call", "call_in_flight_at_expiry"} { + for _, ordering := range []string{"observation_first", "own_beat_first", "restart_renew_holds_first"} { + for _, prior := range []bool{false, true} { + priorName := map[bool]string{false: "prior_Home_other_holder_no", true: "prior_Home_other_holder_yes"}[prior] + t.Run(strings.Join([]string{relay, posture, pending, call, ordering, priorName}, "/"), func(t *testing.T) { + source := &r10ReplaySource{continuationListing: &continuationListing{row: chatlist.Row{Cell: heldCell, Title: "I", Mine: true, DeviceID: "A", Status: chatlist.Here, Fence: 2, Head: "saved"}}, clock: directorytest.NewFakeClock(), priorHome: prior, receipt: true} + taker := &returningTaker{} + a, old := heldLab(t, source, taker) + keepSurfaceWorkersInTest(t, a) + a.closeHome() + a.clock = source.clock.Now + a.agent = &r10OrderingWriter{fakeAgent: old, source: source} + if posture == "working" { + a.state = stateWorking + } + if relay == "lapsed" { + source.clock.Advance(directory.LeaseTTL + time.Second) + } + source.down = true + source.clock.Advance(directory.LeaseTTL + time.Second) + drain(t, a, a.askMachines()) + if pending == "some" || call != "no_call" { + root := filepath.Join(session.DirOf(a.file), ".cell") + if err := os.MkdirAll(root, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "adopted.json"), []byte(`{"V":1,"head":"offline-and-late-call","turns_size":0}`), 0600); err != nil { + t.Fatal(err) + } + } + const words = "the recovered Enter spends this draft once" + a.input.setText(words) + source.down = false + if ordering == "observation_first" { + source.receipt = false + source.change(func(row *chatlist.Row) { row.Status = chatlist.Idle }) + taker.taken = Taken{Transcript: a.file, Fence: 3, Head: "saved"} + taker.on = func(string) { source.change(func(row *chatlist.Row) { row.Status, row.Fence = chatlist.Here, 3 }) } + } + drive(t, a, key("enter")) + if !a.input.empty() || a.at(pageHome) { + t.Fatalf("recovery kept the box or showed a card: box=%q notes=%v", a.input.String(), noteTexts(a)) + } + if ordering == "observation_first" { + arrived, ok := a.agent.(*switchAgent) + if !ok || len(arrived.sent) != 1 || arrived.sent[0] != words || len(old.sent) != 0 || taker.seen == nil || taker.seen.Fence != 2 || taker.seen.Device != "A" { + t.Fatal("retirement did not send through the conditional replacement") + } + } else { + delivered := old.sent + if posture == "working" { + delivered = old.steered + } + if len(delivered) != 1 || delivered[0] != words || taker.seen != nil { + t.Fatalf("revival did not deliver once through the retained writer: sent=%v steered=%v", old.sent, old.steered) + } + } + }) + } + } + } + } + } + } +} + +func (*r10ReplaySource) RelayHost() string { return "127.0.0.1" } + +func (s *r10ReplaySource) Rows(ctx context.Context) ([]chatlist.Row, error) { + if s.down { + return nil, directory.ErrUnreachable + } + rows, err := s.continuationListing.Rows(ctx) + if s.priorHome { + rows = append(rows, chatlist.Row{Cell: "chat-J", Title: "J", DeviceID: "B", Device: "computer B", Status: chatlist.Running, Fence: 9}) + } + return rows, err +} + +func (s *r10ReplaySource) SendLease(string) (string, uint64, error) { + if !s.receipt { + return "", 0, os.ErrNotExist + } + return heldCell, 2, nil +} + +func (s *r10ReplaySource) RowsForChatSend(ctx context.Context, _ string, offline, holding bool) ([]chatlist.Row, uint64, bool, error) { + if holding && s.receipt { + return []chatlist.Row{{Cell: heldCell, Mine: true, Status: chatlist.Here, Fence: 2}}, 0, true, nil + } + if offline { + return nil, 0, false, directory.ErrUnreachable + } + s.probes++ + rows, err := s.Rows(ctx) + return rows, 10, false, err +} + +type r10ReplayWriter struct{ *fakeAgent } + +func (*r10ReplayWriter) RenewalStatus() (time.Time, time.Duration, bool) { + // The engine retired its receipt before its status frame reached this window. + return time.Time{}, time.Second, true +} + +func (*r10ReplayWriter) CheckWorkContext(context.Context) error { + return &executor.WorkRefusal{Line: chatlist.Available} +} + +func TestIdleOpen_R10_Run1PriorHomeMatrix(t *testing.T) { + t.Setenv("CODEAF_IDLE_HANDOFF", "on") + for _, prior := range []bool{false, true} { + for _, recovered := range []string{"lapsed", "revived", "released"} { + t.Run(map[bool]string{false: "prior_Home_other_holder_no", true: "prior_Home_other_holder_yes"}[prior]+"/"+recovered, func(t *testing.T) { + source := &r10ReplaySource{continuationListing: &continuationListing{row: chatlist.Row{Cell: heldCell, Title: "I", Mine: true, DeviceID: "A", Status: chatlist.Idle, Fence: 2, Head: "saved"}}, clock: directorytest.NewFakeClock(), priorHome: prior} + taker := &returningTaker{} + a, old := heldLab(t, source, taker) + keepSurfaceWorkersInTest(t, a) + if prior && !strings.Contains(homeText(a), "computer B") { + t.Fatal("Home did not read J's other holder") + } + a.closeHome() + a.agent = &r10ReplayWriter{old} + a.clock = source.clock.Now + source.down = true + source.clock.Advance(directory.LeaseTTL + time.Second) + drain(t, a, a.askMachines()) + const words = "run 1 sends on the first recovered Enter" + before, err := os.ReadFile(a.file) + if err != nil { + t.Fatal(err) + } + a.input.setText(words) + drive(t, a, key("enter")) + after, err := os.ReadFile(a.file) + if err != nil || string(after) != string(before) || len(old.sent) != 0 || a.input.String() != words { + t.Fatal("outage spent the draft") + } + if !strings.Contains(strings.Join(noteTexts(a), "\n"), chatlist.TakeBackUnreachable) { + t.Fatal("outage did not report the fresh take-back attempt") + } + if _, card := a.homeAsking(); card { + t.Fatal("outage raised an own-chat card") + } + source.down = false + source.change(func(row *chatlist.Row) { + if recovered == "revived" { + row.Status = chatlist.Here + } + if recovered == "released" { + row.Status, row.Released, row.AutoOpen = chatlist.Idle, true, true + } + }) + source.clock.Advance(5 * time.Second) + taker.taken = Taken{Transcript: a.file, Fence: 3, Head: "saved"} + taker.on = func(string) { source.change(func(row *chatlist.Row) { row.Status, row.Fence = chatlist.Here, 3 }) } + drive(t, a, key("enter")) + arrived, ok := a.agent.(*switchAgent) + if !ok || len(arrived.sent) != 1 || arrived.sent[0] != words || !a.input.empty() || a.at(pageHome) { + t.Fatalf("first recovered Enter stranded the message: agent=%T old=%v box=%q notes=%v", a.agent, old.sent, a.input.String(), noteTexts(a)) + } + if taker.seen == nil || taker.seen.Device != "A" || taker.seen.Fence != 2 || source.probes != 2 { + t.Fatalf("reopen did not use fresh facts: seen=%+v probes=%d", taker.seen, source.probes) + } + }) + } + } +} + +func TestIdleOpen_R10_Run1AfterFailedOtherChatMove(t *testing.T) { + t.Setenv("CODEAF_IDLE_HANDOFF", "on") + source := &r10ReplaySource{continuationListing: &continuationListing{row: chatlist.Row{Cell: heldCell, Title: "I", Mine: true, DeviceID: "A", Status: chatlist.Here, Fence: 2, Head: "saved"}}, clock: directorytest.NewFakeClock(), priorHome: true, receipt: true} + taker := &returningTaker{} + taker.err = directory.ErrUnreachable + a, old := heldLab(t, source, taker) + keepSurfaceWorkersInTest(t, a) + a.agent = &r10OrderingWriter{fakeAgent: old, source: source} + a.clock = source.clock.Now + standOn(t, a, "chat-J") + confirm(t, a) + a.closeHome() + source.down = true + source.clock.Advance(directory.LeaseTTL + time.Second) + drain(t, a, a.askMachines()) + const words = "I still sends after a failed move to J" + a.input.setText(words) + drive(t, a, key("enter")) + if len(old.sent) != 0 || a.input.String() != words { + t.Fatal("outage spent I's draft") + } + source.down = false + source.change(func(row *chatlist.Row) { row.Status = chatlist.Idle }) + source.clock.Advance(5 * time.Second) + taker.err = nil + taker.taken = Taken{Transcript: a.file, Fence: 3, Head: "saved"} + taker.on = func(string) { source.change(func(row *chatlist.Row) { row.Status, row.Fence = chatlist.Here, 3 }) } + drive(t, a, key("enter")) + arrived, ok := a.agent.(*switchAgent) + if !ok || len(arrived.sent) != 1 || arrived.sent[0] != words || !a.input.empty() || a.at(pageHome) { + t.Fatalf("J's failed move stranded I: agent=%T old=%v box=%q notes=%v", a.agent, old.sent, a.input.String(), noteTexts(a)) + } +} diff --git a/internal/tui3/idle_r10_retry_test.go b/internal/tui3/idle_r10_retry_test.go new file mode 100644 index 0000000000..6f2dbb5df0 --- /dev/null +++ b/internal/tui3/idle_r10_retry_test.go @@ -0,0 +1,131 @@ +package tui3 + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/session" +) + +type r10RetrySource struct { + *continuationListing + held atomic.Bool + fence atomic.Uint64 +} + +func (*r10RetrySource) RelayHost() string { return "slow.example" } +func (s *r10RetrySource) SendLease(string) (string, uint64, error) { + if !s.held.Load() { + return "", 0, os.ErrNotExist + } + fence := s.fence.Load() + if fence == 0 { + fence = 3 + } + return heldCell, fence, nil +} + +type r10RetryAgent struct { + *fakeAgent + source *r10RetrySource + budgets []time.Duration + first error + unknown int +} + +func (a *r10RetryAgent) RenewalStatus() (time.Time, time.Duration, bool) { + return time.Time{}, time.Second, a.source.held.Load() +} +func (a *r10RetryAgent) CheckWorkContext(ctx context.Context) error { + deadline, _ := ctx.Deadline() + a.budgets = append(a.budgets, time.Until(deadline)) + if len(a.budgets) <= max(1, a.unknown) { + a.source.change(func(row *chatlist.Row) { + row.Status, row.Fence, row.Released, row.AutoOpen = chatlist.Here, 3, false, false + }) + return a.first + } + if a.unknown > 1 { + a.source.fence.Store(4) + } + a.source.held.Store(true) + return nil +} + +func TestIdleRelease_R10_RetryableTakeBackKeepsDraftAndAvoidsSelfContinue(t *testing.T) { + r10RetryTakeBack(t, &executor.WorkRefusal{Line: executor.TakeBackRetryLine, Retryable: true}, 1) +} + +func TestIdleRelease_R10_ClientDeadlineKeepsDraftAndAvoidsSelfContinue(t *testing.T) { + r10RetryTakeBack(t, context.DeadlineExceeded, 1) +} + +func TestIdleRelease_R10b_UncertainOwnFenceKeepsDraftUntilLapsedRetry(t *testing.T) { + r10RetryTakeBack(t, &executor.WorkRefusal{Line: executor.TakeBackRetryLine, Retryable: true}, 2) +} + +func r10RetryTakeBack(t *testing.T, first error, unknown int) { + t.Setenv("CODEAF_IDLE_HANDOFF", "on") + source := &r10RetrySource{continuationListing: &continuationListing{row: chatlist.Row{Cell: heldCell, Status: chatlist.Idle, Mine: true, Released: true, AutoOpen: true, Fence: 2, Head: "saved", DeviceID: "A"}}} + taker := &fakeTaker{} + a, old := heldLab(t, source, taker) + keepSurfaceWorkersInTest(t, a) + 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) + } + agent := &r10RetryAgent{fakeAgent: old, source: source, first: first, unknown: unknown} + a.agent = agent + a.input.setText("keep these words") + drive(t, a, key("enter")) + if len(old.sent) != 0 || a.input.String() != "keep these words" { + t.Fatal("uncertain take-back spent the draft") + } + if _, card := a.homeAsking(); card { + t.Fatal("uncertain take-back offered Continue here") + } + var notes strings.Builder + for _, entry := range a.entries { + if entry.kind == entryNote { + notes.WriteString(plain(entry.text)) + } + } + if !strings.Contains(notes.String(), "waiting for slow.example; still checking") { + t.Fatalf("missing take-back wait: %s", notes.String()) + } + if unknown > 1 { + drive(t, a, key("enter")) + if len(old.sent) != 0 || a.input.String() != "keep these words" { + t.Fatal("live uncertain fence spent the draft") + } + if _, card := a.homeAsking(); card { + t.Fatal("live uncertain own fence offered Continue here") + } + // The relay listing now reports expiry; the next admission must + // return a newly acquired fence rather than the uncertain fence. + source.change(func(row *chatlist.Row) { row.Status = chatlist.Idle }) + } + drive(t, a, key("enter")) + if len(agent.budgets) != unknown+1 || len(old.sent) != 1 || len(taker.cells) != 0 { + t.Fatalf("retry failed: checks=%d sent=%v takes=%v", len(agent.budgets), old.sent, taker.cells) + } + for _, budget := range agent.budgets { + if budget < 10*time.Second || budget > executor.TakeBackWithin { + t.Fatalf("take-back budget=%s", budget) + } + } + if _, card := a.homeAsking(); card { + t.Fatal("own held fence offered Continue here") + } +} diff --git a/internal/tui3/idle_r5_return_open_test.go b/internal/tui3/idle_r5_return_open_test.go new file mode 100644 index 0000000000..ecab8c5269 --- /dev/null +++ b/internal/tui3/idle_r5_return_open_test.go @@ -0,0 +1,173 @@ +package tui3 + +import ( + "context" + "errors" + "os" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" +) + +// The display may start empty or retain this computer's pre-restart hold. +// Ownership reads have the newer head and B's released or lapsed lease. +type r5HomeSource struct { + display chatlist.Static + fresh *continuationListing +} + +func (s *r5HomeSource) Rows(ctx context.Context) ([]chatlist.Row, error) { + return s.display.Rows(ctx) +} + +func (s *r5HomeSource) RowsForSend(ctx context.Context) ([]chatlist.Row, uint64, error) { + rows, err := s.fresh.Rows(ctx) + return rows, 10, err +} + +func TestIdleOpen_R5_RestartedHomeTakesFreeChatOnFirstOpen(t *testing.T) { + t.Setenv("CODEAF_IDLE_HANDOFF", "on") + for _, expires := range []int64{0, 500} { + for _, cached := range []bool{false, true} { + name := map[bool]string{false: "first directory read pending", true: "previous own hold cached"}[cached] + name += map[bool]string{true: "/released", false: "/lapsed"}[expires == 0] + t.Run(name, func(t *testing.T) { + lab := newHomeLab(t) + workspace := lab.workspace("project") + file := lab.session("project", heldCell, "Mine", workspace, time.Now()) + original, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + lease := directory.Lease{Device: "B", Fence: 3, Expires: expires} + if expires == 0 { + lease.ReleasedAt = 750 + } else { + lease.Working = true + } + listing := directory.Listing{ + Now: 1000, + Devices: map[string]directory.Device{"A": {}, "B": {Name: "laptop"}}, + Cells: map[string]directory.Cell{heldCell: { + Head: "newest", + Lease: lease, + }}, + } + row := chatlist.Rows(listing, "A", func(s string) (string, error) { return s, nil })[0] + fresh := &continuationListing{row: row} + source := &r5HomeSource{fresh: fresh} + if cached { + source.display = chatlist.Static{{Cell: heldCell, Status: chatlist.Here, Mine: true, DeviceID: "A", Fence: 1, Head: "old"}} + } + taker := &walkOpenTaker{source: fresh, file: file, journal: string(original) + `{"type":"message","role":"assistant","content":"newest journal from B"}` + "\n"} + // A new surface starts on Home with only A's transcript on disk. + // There is no retained viewer, displaced notice or send hold. + a := lab.app("") + keepSurfaceWorkersInTest(t, a) + a.width, a.height = 200, 70 + a.machines, a.taker = source, taker + drain(t, a, a.openHome()) + for i, line := range a.home.lines { + if line.kind == homeSession && line.row.Transcript == file { + a.home.cursor = i + drive(t, a, key("enter")) + if expires != 0 { + raw, err := os.ReadFile(file) + if err != nil || string(raw) != string(original) || taker.opens != 0 || len(taker.cells) != 0 || !strings.Contains(homeText(a), continueAsk) { + t.Fatalf("lapsed working hold bypassed consent: free=%d takes=%v err=%v", taker.opens, taker.cells, err) + } + return + } + if taker.opens != 1 || len(taker.cells) != 0 { + t.Fatalf("first Home open used local viewer: automatic opens=%d forced takes=%v", taker.opens, taker.cells) + } + raw, err := os.ReadFile(file) + if err != nil || string(raw) != taker.journal { + t.Fatalf("newest head not restored: %q, %v", raw, err) + } + rows, _ := fresh.Rows(t.Context()) + if !rows[0].Mine || rows[0].Status != chatlist.Here || rows[0].Fence != 4 { + t.Fatalf("no new ownership fence: %+v", rows) + } + if a.file != file || strings.Contains(homeText(a), continueAsk) { + t.Fatalf("first open did not arrive directly: file=%q", a.file) + } + return + } + } + t.Fatal("fixture did not expose the persisted local Home row") + }) + } + } +} + +func TestIdleOpen_R5_LocalRowKeepsLiveConsentAndUnpairedOpening(t *testing.T) { + t.Setenv("CODEAF_IDLE_HANDOFF", "on") + for _, mode := range []string{"live", "unpaired", "unreachable", "own free"} { + t.Run(mode, func(t *testing.T) { + row := chatlist.Row{Cell: heldCell, Status: chatlist.Running, DeviceID: "B", Device: "laptop", Fence: 3, Head: "newest"} + if mode != "live" { + row.Status = chatlist.Idle + } + if mode == "own free" { + row.Mine, row.Released, row.AutoOpen = true, true, true + } + fresh := &continuationListing{row: row} + if mode == "unreachable" { + fresh.err = errors.New("fixture relay unavailable") + } + source := &r5HomeSource{fresh: fresh} + taker := &walkOpenTaker{source: fresh} + lab := newHomeLab(t) + workspace := lab.workspace("project") + file := lab.session("project", heldCell, "Mine", workspace, time.Now()) + taker.file = file + journal, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + taker.journal = string(journal) + a := lab.app("") + keepSurfaceWorkersInTest(t, a) + a.width, a.height = 200, 70 + a.machines, a.taker = source, taker + drain(t, a, a.openHome()) + found := false + for i, line := range a.home.lines { + if line.kind == homeSession && line.row.Transcript == file { + a.home.cursor, found = i, true + break + } + } + if !found { + t.Fatal("missing local row") + } + drive(t, a, key("enter")) + if len(taker.cells) != 0 { + t.Fatalf("forced take: %v", taker.cells) + } + switch mode { + case "live": + if taker.opens != 0 || !a.at(pageHome) || !strings.Contains(homeText(a), continueAsk) { + t.Fatal("live hold did not retain consent") + } + case "unpaired": + if taker.opens != 0 || a.file != file { + t.Fatal("unpaired local opening changed") + } + case "unreachable": + if taker.opens != 0 || a.at(pageHome) || a.file != file { + t.Fatal("unavailable ownership prevented ordinary local opening") + } + case "own free": + rows, _ := fresh.Rows(t.Context()) + if taker.opens != 1 || a.file != file || rows[0].Fence != 4 { + t.Fatalf("own free reopen reused the old fence: opens=%d rows=%+v", taker.opens, rows) + } + } + }) + } +} diff --git a/internal/tui3/idle_r5b_local_open_test.go b/internal/tui3/idle_r5b_local_open_test.go new file mode 100644 index 0000000000..8613cd1c3c --- /dev/null +++ b/internal/tui3/idle_r5b_local_open_test.go @@ -0,0 +1,171 @@ +package tui3 + +import ( + "context" + "errors" + "os" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/chatlist" +) + +type r5bHomeSource struct { + display chatlist.Static + reads int + read func(context.Context) ([]chatlist.Row, uint64, error) +} + +func (s *r5bHomeSource) Rows(ctx context.Context) ([]chatlist.Row, error) { + return s.display.Rows(ctx) +} + +func (s *r5bHomeSource) RowsForSend(ctx context.Context) ([]chatlist.Row, uint64, error) { + s.reads++ + return s.read(ctx) +} + +func r5bLocalHome(t *testing.T, source *r5bHomeSource, alreadyOpen bool) (*app, string, *walkOpenTaker) { + t.Helper() + t.Setenv("CODEAF_IDLE_HANDOFF", "on") + lab := newHomeLab(t) + file := lab.session("project", heldCell, "Mine", lab.workspace("project"), time.Now()) + standing := "" + if alreadyOpen { + standing = file + } + a := lab.app(standing) + keepSurfaceWorkersInTest(t, a) + a.width, a.height = 200, 70 + a.teamsPaired = func() bool { return true } + journal, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + fresh := &continuationListing{row: chatlist.Row{Cell: heldCell, Status: chatlist.Idle, Released: true, AutoOpen: true, DeviceID: "B", Fence: 3, Head: "newest"}} + taker := &walkOpenTaker{source: fresh, file: file, journal: string(journal)} + a.machines, a.taker = source, taker + drain(t, a, a.openHome()) + return a, file, taker +} + +func r5bSelectChat(t *testing.T, a *app, file string) homeLine { + t.Helper() + for i, line := range a.home.lines { + if line.kind == homeSession && line.row.Transcript == file || line.remote != nil && line.remote.Cell == heldCell { + a.home.cursor = i + return line + } + } + t.Fatal("missing chat on Home") + return homeLine{} +} + +func TestIdleOpen_R5b_LocalOpenWithUnreachableSyncOpensLocal(t *testing.T) { + for _, mode := range []string{"error", "timeout", "stale own hold", "unrelated display"} { + t.Run(mode, func(t *testing.T) { + source := &r5bHomeSource{} + if mode == "stale own hold" { + source.display = chatlist.Static{{Cell: heldCell, Status: chatlist.Here, Mine: true, DeviceID: "A", Fence: 1}} + } else if mode == "unrelated display" { + source.display = chatlist.Static{{Cell: "another-chat", Status: chatlist.Here, Mine: true}} + } + source.read = func(ctx context.Context) ([]chatlist.Row, uint64, error) { + if mode != "timeout" { + return nil, 0, errors.New("fixture sync service unreachable") + } + deadline, ok := ctx.Deadline() + if !ok || time.Until(deadline) > 2500*time.Millisecond { + t.Error("local open ownership read must be bounded to about two seconds") + return nil, 0, context.DeadlineExceeded + } + <-ctx.Done() + return nil, 0, ctx.Err() + } + a, file, taker := r5bLocalHome(t, source, false) + opens := 0 + open := a.open + a.open = func(workspace, transcript string) (Conversation, error) { + opens++ + return open(workspace, transcript) + } + r5bSelectChat(t, a, file) + drive(t, a, key("enter")) + if source.reads != 1 || opens != 1 || a.at(pageHome) || a.file != file || taker.opens != 0 || len(taker.cells) != 0 { + t.Fatalf("local open failed: reads=%d local opens=%d file=%q home=%v automatic=%d forced=%v", source.reads, opens, a.file, a.at(pageHome), taker.opens, taker.cells) + } + if strings.Contains(a.home.msg, "unreachable") || strings.Contains(a.home.msg, "answer") || strings.Contains(frame(a), chatlist.Unreachable) || strings.Contains(frame(a), chatlist.ReadTimedOut) { + t.Fatalf("local open showed an unavailable refusal: %q", a.home.msg) + } + }) + } +} + +func TestIdleOpen_R5b_LocalOpenUsesDisplayWhenItAnswers(t *testing.T) { + for _, mode := range []string{"this window", "this window without display", "this window with unrelated display", "free", "stale viewer free", "held elsewhere"} { + t.Run(mode, func(t *testing.T) { + row := chatlist.Row{Cell: heldCell, Status: chatlist.Here, Mine: true, DeviceID: "A", Fence: 1, Head: "old"} + if mode == "free" || mode == "stale viewer free" { + row = chatlist.Row{Cell: heldCell, Status: chatlist.Idle, Released: true, AutoOpen: true, DeviceID: "B", Fence: 3, Head: "newest"} + } else if mode == "held elsewhere" { + row = chatlist.Row{Cell: heldCell, Status: chatlist.Running, Device: "laptop", DeviceID: "B", Fence: 3, Head: "newest"} + } + source := &r5bHomeSource{display: chatlist.Static{row}, read: func(context.Context) ([]chatlist.Row, uint64, error) { + return nil, 0, errors.New("display already answers; ownership read forbidden") + }} + if mode == "this window without display" { + source.display = nil + } else if mode == "this window with unrelated display" { + source.display = chatlist.Static{{Cell: "another-chat", Status: chatlist.Here, Mine: true}} + } + a, file, taker := r5bLocalHome(t, source, strings.HasPrefix(mode, "this window") || mode == "stale viewer free") + if mode == "stale viewer free" { + a.blockDisplacedSend(chatlist.Superseded("laptop")) + } + r5bSelectChat(t, a, file) + drive(t, a, key("enter")) + if source.reads != 0 { + t.Errorf("display answer made %d ownership reads; want zero", source.reads) + } + if len(taker.cells) != 0 { + t.Fatalf("forced takes=%v", taker.cells) + } + switch mode { + case "free", "stale viewer free": + if taker.opens != 1 || a.at(pageHome) || a.file != file { + t.Fatalf("free row did not open directly: opens=%d file=%q", taker.opens, a.file) + } + case "held elsewhere": + if taker.opens != 0 || !a.at(pageHome) || !strings.Contains(homeText(a), continueAsk) { + t.Fatal("live hold did not keep the existing choice") + } + default: + if taker.opens != 0 || a.at(pageHome) || a.file != file { + t.Fatal("this window's open chat did not reopen immediately") + } + } + }) + } +} + +func TestIdleOpen_R5b_UnpairedNeverReads(t *testing.T) { + for _, mode := range []string{"unpaired", "sync off", "single device"} { + t.Run(mode, func(t *testing.T) { + source := &r5bHomeSource{read: func(context.Context) ([]chatlist.Row, uint64, error) { + return nil, 0, errors.New("ownership read forbidden without paired computers") + }} + a, file, taker := r5bLocalHome(t, source, false) + if mode == "single device" { + a.addMachine.known, a.addMachine.size = true, 1 + } else { + a.teamsPaired = func() bool { return false } + } + r5bSelectChat(t, a, file) + drive(t, a, key("enter")) + if source.reads != 0 || a.at(pageHome) || a.file != file || taker.opens != 0 || len(taker.cells) != 0 { + t.Fatalf("local-only opening changed: reads=%d home=%v file=%q automatic=%d forced=%v", source.reads, a.at(pageHome), a.file, taker.opens, taker.cells) + } + }) + } +} diff --git a/internal/tui3/idle_r5c_offline_test.go b/internal/tui3/idle_r5c_offline_test.go new file mode 100644 index 0000000000..1c3aeee1e7 --- /dev/null +++ b/internal/tui3/idle_r5c_offline_test.go @@ -0,0 +1,126 @@ +package tui3 + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" +) + +type r5cOfflineTaker struct { + fakeTaker + opens int + err error + seen *directory.LeaseExpectation + on func() + taken Taken +} + +func (f *r5cOfflineTaker) OpenAvailable(_ context.Context, _ string, seen *directory.LeaseExpectation) (Taken, error) { + f.opens++ + f.seen = seen + if f.on != nil { + f.on() + } + return f.taken, f.err +} + +func TestIdleOpen_R5c_OwnReleasedChatOpensLocallyWhenSyncDown(t *testing.T) { + for _, mode := range []string{"down", "reading error", "take unreachable", "take timeout", "local copy"} { + t.Run(mode, func(t *testing.T) { + row := chatlist.Row{Cell: heldCell, Status: chatlist.Idle, Mine: mode != "local copy", Released: true, AutoOpen: true, DeviceID: "A", Fence: 7} + source := &r5bHomeSource{display: chatlist.Static{row}, read: func(context.Context) ([]chatlist.Row, uint64, error) { + return nil, 0, directory.ErrUnreachable + }} + a, file, _ := r5bLocalHome(t, source, false) + taker := &r5cOfflineTaker{err: directory.ErrUnreachable} + if mode == "take timeout" { + taker.err = context.DeadlineExceeded + } + a.taker = taker + if mode == "down" || mode == "local copy" { + a.machineRead.down = true + } else if mode == "reading error" { + a.machineRead.err = directory.ErrUnreachable + } + opens := 0 + open := a.open + a.open = func(workspace, transcript string) (Conversation, error) { + opens++ + return open(workspace, transcript) + } + r5bSelectChat(t, a, file) + drive(t, a, key("enter")) + wantTakes := 0 + if strings.HasPrefix(mode, "take ") { + wantTakes = 1 + } + if opens != 1 || a.at(pageHome) || a.file != file || taker.opens != wantTakes || len(taker.cells) != 0 || source.reads != 0 || a.homeTaking != "" { + t.Fatalf("offline local open: local=%d home=%v file=%q free=%d forced=%v reads=%d pending=%q", opens, a.at(pageHome), a.file, taker.opens, taker.cells, source.reads, a.homeTaking) + } + }) + } +} + +func TestIdleOpen_R5c_RemoteRowOfflineSaysWhy(t *testing.T) { + for _, mode := range []string{"down", "reading error", "take unreachable", "take timeout"} { + t.Run(mode, func(t *testing.T) { + row := chatlist.Row{Cell: "remote-only", Status: chatlist.Idle, AutoOpen: true, DeviceID: "B", Fence: 3} + source := &r5bHomeSource{display: chatlist.Static{row}} + a, _, _ := r5bLocalHome(t, source, false) + taker := &r5cOfflineTaker{err: directory.ErrUnreachable} + if mode == "take timeout" { + taker.err = context.DeadlineExceeded + } + a.taker = taker + if mode == "down" { + a.machineRead.down = true + } else if mode == "reading error" { + a.machineRead.err = directory.ErrUnreachable + } + standOn(t, a, row.Cell) + wantTakes := 0 + if strings.HasPrefix(mode, "take ") { + wantTakes = 1 + } + if !a.at(pageHome) || !strings.Contains(a.home.msg, chatlist.Unreachable) || a.homeTaking != "" || len(taker.cells) != 0 || taker.opens != wantTakes { + t.Fatalf("offline remote refusal: home=%v message=%q pending=%q forced=%v", a.at(pageHome), a.home.msg, a.homeTaking, taker.cells) + } + }) + } +} + +func TestIdleOpen_R5c_OwnReleasedChatOnlineTakesNewFence(t *testing.T) { + row := chatlist.Row{Cell: heldCell, Status: chatlist.Idle, Mine: true, Released: true, AutoOpen: true, DeviceID: "A", Fence: 7} + source := &r5bHomeSource{display: chatlist.Static{row}, read: func(context.Context) ([]chatlist.Row, uint64, error) { + return nil, 0, errors.New("answered display must not add a read") + }} + a, file, _ := r5bLocalHome(t, source, false) + taker := &r5cOfflineTaker{taken: Taken{Transcript: file, Fence: 8}, on: func() { + source.display[0].Status, source.display[0].Fence = chatlist.Here, 8 + source.display[0].Released, source.display[0].AutoOpen = false, false + }} + a.taker = taker + r5bSelectChat(t, a, file) + drive(t, a, key("enter")) + if a.at(pageHome) || a.file != file || taker.opens != 1 || len(taker.cells) != 0 || source.reads != 0 || taker.seen == nil || taker.seen.Device != "A" || taker.seen.Fence != 7 || source.display[0].Fence != 8 { + t.Fatalf("own released chat bypassed conditional free acquisition: free=%d forced=%v seen=%+v", taker.opens, taker.cells, taker.seen) + } +} + +func TestIdleOpen_R5c_LocalCopyKeepsTakeRaceRefusal(t *testing.T) { + row := chatlist.Row{Cell: heldCell, Status: chatlist.Idle, Mine: true, AutoOpen: true, DeviceID: "A", Fence: 7} + source := &r5bHomeSource{display: chatlist.Static{row}, read: func(context.Context) ([]chatlist.Row, uint64, error) { + return nil, 0, errors.New("unexpected ownership read") + }} + a, file, _ := r5bLocalHome(t, source, false) + a.taker = &r5cOfflineTaker{err: directory.ErrConditionalTake} + r5bSelectChat(t, a, file) + drive(t, a, key("enter")) + if !a.at(pageHome) || !strings.Contains(a.home.msg, directory.ErrConditionalTake.Error()) { + t.Fatal("a conditional-acquire race incorrectly opened the local copy") + } +} diff --git a/internal/tui3/idle_r6_open_test.go b/internal/tui3/idle_r6_open_test.go new file mode 100644 index 0000000000..9894c68658 --- /dev/null +++ b/internal/tui3/idle_r6_open_test.go @@ -0,0 +1,42 @@ +package tui3 + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" +) + +func TestIdleOpen_R6_LapsedWhileWorkingKeepsTheCard(t *testing.T) { + t.Setenv("CODEAF_IDLE_HANDOFF", "on") + listing := directory.Listing{Now: 1000, Devices: map[string]directory.Device{"A": {}, "B": {}}, Cells: map[string]directory.Cell{ + heldCell: {Head: "saved", Lease: directory.Lease{Device: "A", Fence: 7, Expires: 500, Working: true}}, + }} + row := chatlist.Rows(listing, "B", nil)[0] + taker := &idleOpenTaker{} + a, _ := heldLab(t, &flipping{status: chatlist.Off}, taker) + command := a.homeMachineEnter(homeLine{remote: &row}) + drain(t, a, command) + if row.AutoOpen || taker.opens != 0 || len(taker.cells) != 0 || !strings.Contains(homeText(a), continueAsk) { + t.Fatalf("lapsed working hold opened without consent: row=%+v free opens=%d takes=%v\n%s", row, taker.opens, taker.cells, homeText(a)) + } +} + +func TestIdleOpen_R6_ReleasedStillOpensDirectly(t *testing.T) { + t.Setenv("CODEAF_IDLE_HANDOFF", "on") + listing := directory.Listing{Now: 1000, Devices: map[string]directory.Device{"A": {}, "B": {}}, Cells: map[string]directory.Cell{ + heldCell: {Head: "saved", Lease: directory.Lease{Device: "A", Fence: 7, ReleasedAt: 500}}, + }} + row := chatlist.Rows(listing, "B", nil)[0] + taker := &idleOpenTaker{} + a, _ := heldLab(t, &flipping{status: chatlist.Idle}, taker) + command := a.homeMachineEnter(homeLine{remote: &row}) + if !row.AutoOpen || command == nil || strings.Contains(homeText(a), continueAsk) { + t.Fatal("released hold did not enter direct opening") + } + _ = command() + if taker.opens != 1 || len(taker.cells) != 0 || taker.seen == nil || taker.seen.Device != "A" || taker.seen.Fence != 7 { + t.Fatalf("released hold did not use conditional free opening: %+v", taker) + } +} diff --git a/internal/tui3/idle_r6_send_test.go b/internal/tui3/idle_r6_send_test.go new file mode 100644 index 0000000000..766637b02e --- /dev/null +++ b/internal/tui3/idle_r6_send_test.go @@ -0,0 +1,85 @@ +package tui3 + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/session" +) + +type r6OfflineSendAgent struct { + *walkSendAgent + offline bool +} + +func (a *r6OfflineSendAgent) CheckWork() error { + if a.offline { + return &executor.WorkRefusal{Line: chatlist.TakeBackUnreachable, Retryable: true} + } + return a.walkSendAgent.CheckWork() +} + +func TestIdleRelease_R6_ReleasedWindowOfflineSendSaysWhy(t *testing.T) { + t.Setenv("CODEAF_IDLE_HANDOFF", "on") + source := walkSendSource{&continuationListing{row: chatlist.Row{Cell: heldCell, Status: chatlist.Idle, Mine: true, Released: true, AutoOpen: true, Fence: 2, Head: "saved", DeviceID: "A"}}} + taker := &fakeTaker{} + a, old := heldLab(t, source, taker) + keepSurfaceWorkersInTest(t, a) + 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) + } + agent := &r6OfflineSendAgent{walkSendAgent: &walkSendAgent{fakeAgent: old, source: source}, offline: true} + a.agent = agent + const words = "keep my unsent words" + a.input.setText(words) + drive(t, a, key("enter")) + if a.input.String() != words || len(old.sent) != 0 || len(taker.cells) != 0 { + t.Fatal("offline refusal spent the draft or took the chat") + } + notes := strings.Join(noteTexts(a), "\n") + if strings.Count(notes, chatlist.TakeBackUnreachable) != 1 || strings.Contains(notes, chatlist.Available) { + t.Fatalf("offline send must explain the cause once: %s", notes) + } + if _, card := a.homeAsking(); card { + t.Fatal("offline send offered a displacement card") + } + agent.offline = false + drive(t, a, key("enter")) + rows, _ := source.Rows(t.Context()) + if len(old.sent) != 1 || old.sent[0] != words || rows[0].Fence != 3 || len(taker.cells) != 0 { + t.Fatalf("retry did not send the kept draft at a new fence: sent=%v rows=%+v", old.sent, rows) + } +} + +func TestIdleOpen_R6_LapsedWorkingSendKeepsTheCard(t *testing.T) { + t.Setenv("CODEAF_IDLE_HANDOFF", "on") + source := walkSendSource{&continuationListing{row: chatlist.Row{Cell: heldCell, Status: chatlist.Off, Fence: 2, Head: "saved", DeviceID: "B"}}} + taker := &returningTaker{} + a, old := heldLab(t, source, taker) + keepSurfaceWorkersInTest(t, a) + 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) + } + a.input.setText("keep the words until I confirm") + drive(t, a, key("enter")) + if len(taker.cells) != 0 || len(old.sent) != 0 || string(a.mainComposer().box.value) != "keep the words until I confirm" { + t.Fatalf("send silently took a lapsed hold: takes=%v sends=%v", taker.cells, old.sent) + } + if _, card := a.homeAsking(); !card { + t.Fatal("a lapsed holder's unfinished work lost its Continue-here card") + } +} diff --git a/internal/tui3/idle_r7_send_test.go b/internal/tui3/idle_r7_send_test.go new file mode 100644 index 0000000000..4454019272 --- /dev/null +++ b/internal/tui3/idle_r7_send_test.go @@ -0,0 +1,154 @@ +package tui3 + +import ( + "context" + "net/url" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/session" +) + +type r7SendSource struct { + walkSendSource + err error + reads int + within time.Duration +} + +func (*r7SendSource) RelayHost() string { return "fixture-relay" } + +func (s *r7SendSource) RowsForChatSend(ctx context.Context, _ string, offline, holding bool) ([]chatlist.Row, uint64, bool, error) { + if holding { + rows, err := s.Rows(ctx) + return rows, 0, true, err + } + if offline { + return nil, 0, false, directory.ErrUnreachable + } + s.reads++ + deadline, ok := ctx.Deadline() + if ok { + s.within = time.Until(deadline) + } + if s.err != nil { + return nil, 0, false, s.err + } + rows, err := s.Rows(ctx) + return append(rows, chatlist.Row{Cell: "another-chat", Title: "Keep this row"}), 7, false, err +} + +func r7ReleasedWindow(t *testing.T) (*app, *fakeAgent, *r7SendSource) { + t.Helper() + t.Setenv("CODEAF_IDLE_HANDOFF", "on") + source := &r7SendSource{walkSendSource: walkSendSource{&continuationListing{row: chatlist.Row{Cell: heldCell, Status: chatlist.Idle, Mine: true, Released: true, AutoOpen: true, Fence: 2, Head: "saved", DeviceID: "A"}}}} + a, old := heldLab(t, source, &fakeTaker{}) + keepSurfaceWorkersInTest(t, a) + 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) + } + a.agent = &walkSendAgent{fakeAgent: old, source: source.walkSendSource} + a.machineRead.down, a.machineRead.err = true, syscall.ECONNREFUSED + return a, old, source +} + +func TestIdleOpen_R7_FirstEnterAfterRecoverySends(t *testing.T) { + a, old, source := r7ReleasedWindow(t) + a.input.setText("send on the first Enter") + drive(t, a, key("enter")) + rows, _ := source.Rows(t.Context()) + if len(old.sent) != 1 || old.sent[0] != "send on the first Enter" || rows[0].Fence != 3 { + t.Fatalf("first Enter did not send at a new fence: sent=%v rows=%+v notes=%v", old.sent, rows, noteTexts(a)) + } + if source.reads != 1 || source.within <= 0 || source.within > 3*time.Second { + t.Fatalf("fresh directory attempts=%d budget=%v; want one bounded by 3s", source.reads, source.within) + } + if a.machineRead.down || a.machineRead.err != nil || a.machineRead.version != 7 { + t.Fatalf("successful send retained Home's outage: %+v", a.machineRead) + } + if len(a.machineRead.rows) != 2 || a.machineRead.rows[1].Cell != "another-chat" { + t.Fatalf("recovery lost the rest of the directory reading: %+v", a.machineRead.rows) + } + if notes := strings.Join(noteTexts(a), "\n"); strings.Contains(notes, "unreachable") { + t.Fatalf("Enter replayed the cached refusal despite recovery: %s", notes) + } +} + +func TestIdleOpen_R7_StillDownKeepsWordsWithHonestLine(t *testing.T) { + for _, cause := range []error{syscall.ECONNREFUSED, context.DeadlineExceeded} { + t.Run(cause.Error(), func(t *testing.T) { + a, old, source := r7ReleasedWindow(t) + source.err = &url.Error{Op: "Get", URL: "http://127.0.0.1:1", Err: cause} + before, err := os.ReadFile(a.file) + if err != nil { + t.Fatal(err) + } + const words = "keep these words until recovery" + a.input.setText(words) + drive(t, a, key("enter")) + after, err := os.ReadFile(a.file) + if err != nil || string(after) != string(before) || len(old.sent) != 0 || a.input.String() != words { + t.Fatalf("failed probe spent words or wrote the chat: sent=%v draft=%q err=%v", old.sent, a.input.String(), err) + } + notes := strings.Join(noteTexts(a), "\n") + if source.reads != 1 || strings.Count(notes, chatlist.TakeBackUnreachable) != 1 || strings.Contains(notes, chatlist.Available) { + t.Fatalf("failed probe did not explain take-back: reads=%d notes=%s", source.reads, notes) + } + source.err = nil + drive(t, a, key("enter")) + if source.reads != 2 || len(old.sent) != 1 || old.sent[0] != words || a.machineRead.down { + t.Fatalf("first retry after recovery did not send: reads=%d sent=%v notes=%v", source.reads, old.sent, noteTexts(a)) + } + }) + } +} + +func TestIdleOpen_R7_LiveHeldOfflineSendKeepsLocalAdmission(t *testing.T) { + a, old, source := r7ReleasedWindow(t) + source.change(func(row *chatlist.Row) { + row.Status, row.Released, row.AutoOpen = chatlist.Here, false, false + }) + source.err = directory.ErrUnreachable + a.input.setText("held work stays local") + drive(t, a, key("enter")) + rows, _ := source.Rows(t.Context()) + if source.reads != 0 || len(old.sent) != 1 || rows[0].Fence != 2 || !a.machineRead.down { + t.Fatalf("live-held offline admission changed: reads=%d sent=%v rows=%+v down=%v", source.reads, old.sent, rows, a.machineRead.down) + } +} + +func TestIdleOpen_R7_LapsedWindowProbesBeforeConditionalReturn(t *testing.T) { + a, old, source := r7ReleasedWindow(t) + source.change(func(row *chatlist.Row) { + row.Status, row.Released, row.AutoOpen = chatlist.Off, false, false + }) + taker := &returningTaker{} + taker.taken = Taken{Transcript: a.file, Fence: 3, Head: "saved"} + taker.on = func(string) { + source.change(func(row *chatlist.Row) { row.Status, row.Fence = chatlist.Here, 3 }) + } + a.taker = taker + a.input.setText("first Enter after lapse") + drive(t, a, key("enter")) + if source.reads != 1 || source.within <= 0 || source.within > 3*time.Second { + t.Fatalf("lapsed window did not make one bounded probe: reads=%d budget=%v", source.reads, source.within) + } + if taker.seen == nil || taker.seen.Device != "A" || taker.seen.Fence != 2 { + t.Fatalf("lapsed return lost its observed holder and fence: %+v", taker.seen) + } + arrived, ok := a.agent.(*switchAgent) + if len(old.sent) != 0 || !ok || len(arrived.sent) != 1 || arrived.sent[0] != "first Enter after lapse" || a.machineRead.down { + t.Fatalf("first Enter did not send through the replacement: old=%v new=%T notes=%v", old.sent, a.agent, noteTexts(a)) + } +} diff --git a/internal/tui3/idle_r8_send_test.go b/internal/tui3/idle_r8_send_test.go new file mode 100644 index 0000000000..2d932f6836 --- /dev/null +++ b/internal/tui3/idle_r8_send_test.go @@ -0,0 +1,117 @@ +package tui3 + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/session" +) + +type r8SendSource struct { + *r7SendSource + holding bool +} + +func (s *r8SendSource) SendLease(id string) (string, uint64, error) { + if !s.holding { + return "", 0, os.ErrNotExist + } + return s.r7SendSource.SendLease(id) +} + +type r8SendAgent struct { + *fakeAgent + source *r8SendSource + checks int + retake bool +} + +func (a *r8SendAgent) RenewalStatus() (time.Time, time.Duration, bool) { + return time.Time{}, time.Second, a.source.holding +} + +func (a *r8SendAgent) CheckWork() error { + a.checks++ + a.source.holding = true + if a.retake { + a.source.change(func(row *chatlist.Row) { row.Fence++ }) + } + return nil +} + +func r8OwnLiveWindow(t *testing.T, retake bool) (*app, *r8SendAgent, *r8SendSource) { + t.Helper() + a, old, source := r7ReleasedWindow(t) + source.change(func(row *chatlist.Row) { + row.Status, row.Released, row.AutoOpen = chatlist.Here, false, false + }) + s := &r8SendSource{r7SendSource: source} + agent := &r8SendAgent{fakeAgent: old, source: s, retake: retake} + a.machines, a.agent = s, agent + a.machineRead.sendHolds = map[string]sendHold{heldCell: {fence: 2}} + return a, agent, s +} + +func TestIdleOpen_R8_OwnLiveRowAfterBlipSendsNoCard(t *testing.T) { + for _, tc := range []struct { + name string + retake bool + known bool + dirty bool + }{ + {"same fence", false, true, false}, + {"uncertain release retakes", true, true, false}, + {"first fence not yet listed", true, false, false}, + {"unuploaded local head on retained hold", false, true, true}, + } { + t.Run(tc.name, func(t *testing.T) { + retake := tc.retake + a, agent, source := r8OwnLiveWindow(t, retake) + if !tc.known { + a.machineRead.sendHolds = nil + } + if tc.dirty { + path := filepath.Join(session.DirOf(a.file), ".cell", "adopted.json") + if err := os.WriteFile(path, []byte(`{"V":1,"head":"unsent","turns_size":0}`), 0600); err != nil { + t.Fatal(err) + } + } + a.input.setText("send after the blip") + drive(t, a, key("enter")) + wantFence := uint64(2) + if retake { + wantFence++ + } + rows, _ := source.Rows(t.Context()) + if agent.checks != 1 || len(agent.sent) != 1 || agent.sent[0] != "send after the blip" || rows[0].Fence != wantFence || a.at(pageHome) || a.agent != agent { + t.Fatalf("own live send: checks=%d sent=%v rows=%+v home=%v notes=%v", agent.checks, agent.sent, rows, a.at(pageHome), noteTexts(a)) + } + if strings.Contains(homeText(a), continueAsk) || strings.Contains(strings.Join(noteTexts(a), "\n"), "last held here") { + t.Fatal("own live row raised a continuation card") + } + }) + } +} + +func TestIdleOpen_R8_OtherHolderStillGetsCard(t *testing.T) { + for _, ownNewer := range []bool{false, true} { + t.Run(map[bool]string{false: "another computer", true: "another window on this computer"}[ownNewer], func(t *testing.T) { + a, agent, source := r8OwnLiveWindow(t, false) + source.change(func(row *chatlist.Row) { + row.Mine, row.DeviceID, row.Device, row.Status = false, "B", "laptop", chatlist.Running + if ownNewer { + row.Mine, row.DeviceID, row.Status, row.Fence = true, "A", chatlist.Here, 3 + } + }) + a.input.setText("keep this draft") + drive(t, a, key("enter")) + if agent.checks != 0 || len(agent.sent) != 0 || !a.at(pageHome) || !strings.Contains(homeText(a), continueAsk) || a.input.String() != "keep this draft" { + t.Fatalf("changed holder skipped its card: checks=%d sent=%v home=%s draft=%q", agent.checks, agent.sent, homeText(a), a.input.String()) + } + }) + } +} diff --git a/internal/tui3/idle_r9_send_test.go b/internal/tui3/idle_r9_send_test.go new file mode 100644 index 0000000000..dc9d69ca3e --- /dev/null +++ b/internal/tui3/idle_r9_send_test.go @@ -0,0 +1,440 @@ +package tui3 + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/session" +) + +type r9SendAgent struct { + *r8SendAgent + reopen bool +} + +func (a *r9SendAgent) CheckWork() error { + if a.reopen { + a.checks++ + a.source.holding = false + return &executor.WorkRefusal{Line: chatlist.Available} + } + if err := a.r8SendAgent.CheckWork(); err != nil { + return err + } + a.source.change(func(row *chatlist.Row) { + row.Status, row.Released, row.AutoOpen = chatlist.Here, false, false + }) + return nil +} + +// r9ReceiptSource models the concrete source's local receipt shortcut. +// A holding writer returns no relay facts until admission retires that receipt. +type r9ReceiptSource struct{ *r8SendSource } + +func (s *r9ReceiptSource) SendLease(string) (string, uint64, error) { + if s.holding { + return heldCell, 2, nil + } + return "", 0, os.ErrNotExist +} + +func (s *r9ReceiptSource) RowsForChatSend(ctx context.Context, file string, offline, holding bool) ([]chatlist.Row, uint64, bool, error) { + if holding { + return []chatlist.Row{{Cell: heldCell, Status: chatlist.Here, Mine: true, Fence: 2}}, 0, true, nil + } + return s.r7SendSource.RowsForChatSend(ctx, file, offline, false) +} + +func (s *r9ReceiptSource) RowsForSend(ctx context.Context) ([]chatlist.Row, uint64, error) { + rows, version, _, err := s.r7SendSource.RowsForChatSend(ctx, "", false, false) + return rows, version, err +} + +func TestIdleOpen_R9_EnterMatrix(t *testing.T) { + cells := []struct{ relay, local, turn string }{ + {"revived", "expired", "idle"}, + {"lapsed", "holding", "idle"}, {"lapsed", "holding", "working"}, + {"lapsed", "expired", "idle"}, + {"lapsed", "release_undone", "idle"}, {"lapsed", "release_undone", "working"}, + {"released", "holding", "idle"}, {"released", "holding", "working"}, + {"released", "expired", "idle"}, + {"released", "release_undone", "idle"}, {"released", "release_undone", "working"}, + } + for _, c := range cells { + for _, unpublished := range []string{"none", "some"} { + t.Run(c.relay+"/"+c.local+"/answering/"+c.turn+"/"+unpublished, func(t *testing.T) { + a, old, source := r8OwnLiveWindow(t, false) + a.agent = &r9SendAgent{r8SendAgent: old, reopen: true} + source.holding = c.local == "holding" || c.local == "release_undone" + a.machines = &r9ReceiptSource{source} + if c.turn == "working" { + a.state = stateWorking + } + source.change(func(row *chatlist.Row) { + if c.relay == "lapsed" { + row.Status = chatlist.Off + } + if c.relay == "released" { + row.Status, row.Released, row.AutoOpen = chatlist.Idle, true, true + } + }) + if unpublished == "some" { + path := filepath.Join(session.DirOf(a.file), ".cell", "adopted.json") + if err := os.WriteFile(path, []byte(`{"V":1,"head":"unpublished","turns_size":0}`), 0600); err != nil { + t.Fatal(err) + } + } + taker := &returningTaker{} + taker.taken = Taken{Transcript: a.file, Fence: 3, Head: "saved"} + taker.on = func(string) { + source.change(func(row *chatlist.Row) { + row.Status, row.Fence, row.Released, row.AutoOpen = chatlist.Here, 3, false, false + }) + } + a.taker = taker + const words = "R9 first Enter via reopen" + a.input.setText(words) + drive(t, a, key("enter")) + if taker.seen == nil || taker.seen.Device != "A" || taker.seen.Fence != 2 { + t.Fatalf("reopen lost exact observed expectation: %+v", taker.seen) + } + arrived, ok := a.agent.(*switchAgent) + if len(old.sent) != 0 || !ok || len(arrived.sent) != 1 || arrived.sent[0] != words || !a.input.empty() || a.at(pageHome) || strings.Contains(homeText(a), continueAsk) { + t.Fatalf("first Enter did not reopen and send without a card: old=%v new=%T box=%q notes=%v", old.sent, a.agent, a.input.String(), noteTexts(a)) + } + if source.reads != 1 { + t.Fatalf("probes=%d; want one", source.reads) + } + }) + } + } +} + +func TestIdleOpen_R9_RelayOutcomes(t *testing.T) { + for _, rowState := range []string{"live", "revived", "lapsed", "released", "other_live", "other_released", "other_lapsed"} { + for _, service := range []string{"answering", "refusing", "hanging"} { + t.Run(rowState+"/"+service, func(t *testing.T) { + a, agent, source := r8OwnLiveWindow(t, rowState != "live") + a.agent = &r9SendAgent{r8SendAgent: agent, reopen: rowState == "lapsed"} + taker := &returningTaker{} + taker.taken = Taken{Transcript: a.file, Fence: 3, Head: "saved"} + taker.on = func(string) { source.change(func(row *chatlist.Row) { row.Status, row.Fence = chatlist.Here, 3 }) } + a.taker = taker + source.change(func(row *chatlist.Row) { + switch rowState { + case "lapsed", "other_lapsed": + row.Status = chatlist.Off + case "released", "other_released": + row.Status, row.Released, row.AutoOpen = chatlist.Idle, true, true + } + if strings.HasPrefix(rowState, "other_") { + row.Mine, row.DeviceID, row.Device, row.Fence = false, "B", "laptop", 3 + row.AutoOpen = false + } + }) + if service == "refusing" { + source.err = syscall.ECONNREFUSED + } else if service == "hanging" { + source.err = context.DeadlineExceeded + } + before, err := os.ReadFile(a.file) + if err != nil { + t.Fatal(err) + } + const words = "R9 first Enter" + a.input.setText(words) + drive(t, a, key("enter")) + other := strings.HasPrefix(rowState, "other_") + if service != "answering" || other { + after, err := os.ReadFile(a.file) + if err != nil || string(after) != string(before) || len(agent.sent) != 0 || a.input.String() != words { + t.Fatalf("refusal spent words: sent=%v box=%q error=%v", agent.sent, a.input.String(), err) + } + if service == "answering" { + if !a.at(pageHome) || !strings.Contains(homeText(a), continueAsk) || taker.seen != nil { + t.Fatalf("other holder needs consent: %s", homeText(a)) + } + drive(t, a, key("esc")) + if len(agent.sent) != 0 || a.input.String() != words { + t.Fatal("Esc changed the held chat or draft") + } + return + } + notes := strings.Join(noteTexts(a), "\n") + if strings.Count(notes, chatlist.TakeBackUnreachable)+strings.Count(notes, "has not answered; your message is still in the box") != 1 { + t.Fatalf("outage needs one honest line: %v", noteTexts(a)) + } + source.err = nil + if other { + drive(t, a, key("enter")) + if !strings.Contains(homeText(a), continueAsk) || len(agent.sent) != 0 { + t.Fatal("recovery bypassed consent") + } + return + } + drive(t, a, key("enter")) + } + if rowState == "lapsed" { + arrived, ok := a.agent.(*switchAgent) + if taker.seen == nil || len(agent.sent) != 0 || !ok || len(arrived.sent) != 1 || arrived.sent[0] != words || a.at(pageHome) { + t.Fatalf("lapsed retry did not reopen: old=%v agent=%T notes=%v", agent.sent, a.agent, noteTexts(a)) + } + return + } + if agent.checks != 1 || len(agent.sent) != 1 || agent.sent[0] != words || !a.input.empty() || a.at(pageHome) { + t.Fatalf("first admitted Enter did not send: checks=%d sent=%v box=%q notes=%v", agent.checks, agent.sent, a.input.String(), noteTexts(a)) + } + }) + } + } +} + +func TestIdleOpen_R9_FreeHigherOwnFenceNeedsConsent(t *testing.T) { + for _, status := range []chatlist.Status{chatlist.Here, chatlist.Idle, chatlist.Off} { + t.Run(string(status), func(t *testing.T) { + a, agent, source := r8OwnLiveWindow(t, true) + taker := &returningTaker{} + a.taker = taker + source.change(func(row *chatlist.Row) { + row.Fence, row.Status = 3, status + row.Released, row.AutoOpen = status == chatlist.Idle, status == chatlist.Idle + }) + a.input.setText("keep this choice mine") + drive(t, a, key("enter")) + if agent.checks != 0 || taker.seen != nil || len(agent.sent) != 0 || a.input.String() != "keep this choice mine" || !strings.Contains(homeText(a), continueAsk) { + t.Fatalf("higher fence skipped consent: checks=%d take=%+v sent=%v box=%q", agent.checks, taker.seen, agent.sent, a.input.String()) + } + }) + } +} + +func TestIdleOpen_R9_EngineRefusalRestoresWords(t *testing.T) { + for _, hosted := range []bool{false, true} { + for _, next := range []string{"", "new draft"} { + t.Run(fmt.Sprintf("hosted=%v/next=%s", hosted, next), func(t *testing.T) { + agent := &fakeAgent{model: "a/b", failing: errors.New(chatlist.Available)} + a := newTestApp(agent) + keepSurfaceWorkersInTest(t, a) + if hosted { + a.host = "fixture" + } + a.input.setText("keep the refused words") + msg := runSubmit(t, a.enter()) + a.input.setText(next) + a.adopt(msg) + want := "keep the refused words" + if next != "" { + want += "\n" + next + } + if a.input.String() != want { + t.Fatalf("engine refusal lost words: box=%q want=%q", a.input.String(), want) + } + agent.failing = nil + drive(t, a, key("enter")) + if len(agent.sent) != 2 || agent.sent[1] != want || !a.input.empty() { + t.Fatalf("engine recovery did not send the retained draft: sent=%v box=%q", agent.sent, a.input.String()) + } + }) + } + } +} + +func TestIdleOpen_R9_AttachmentRefusalRestoresWordsOnce(t *testing.T) { + for _, hosted := range []bool{false, true} { + for _, next := range []string{"", "new draft"} { + t.Run(fmt.Sprintf("hosted=%v/next=%s", hosted, next), func(t *testing.T) { + a, agent, dir := attachLab(t, map[string]int{"shot.png": 12}) + keepSurfaceWorkersInTest(t, a) + if hosted { + a.host = "fixture" + } + agent.refuse = errors.New(chatlist.Available) + a.attach(filepath.Join(dir, "shot.png")) + a.input.setText("keep these picture words") + msg := runSubmit(t, a.enter()) + a.input.setText(next) + a.adopt(msg) + want := "keep these picture words" + if next != "" { + want += "\n" + next + } + if a.input.String() != want || len(a.chips) != 1 { + t.Fatalf("attachment refusal lost or duplicated the draft: words=%q chips=%v", a.input.String(), chipNames(a)) + } + agent.refuse = nil + drive(t, a, key("enter")) + if agent.calls != 2 || !strings.HasPrefix(agent.text, want) || !a.input.empty() || len(a.chips) != 0 { + t.Fatalf("attachment retry did not send once: calls=%d words=%q box=%q chips=%v", agent.calls, agent.text, a.input.String(), chipNames(a)) + } + }) + } + } +} + +func TestIdleOpen_R9_RetiredReceiptProbeKeepsWordsOnFailure(t *testing.T) { + for _, cause := range []error{syscall.ECONNREFUSED, context.DeadlineExceeded} { + t.Run(cause.Error(), func(t *testing.T) { + a, old, source := r8OwnLiveWindow(t, false) + a.agent = &r9SendAgent{r8SendAgent: old, reopen: true} + a.machines = &r9ReceiptSource{source} + source.holding, source.err = true, &net.OpError{Op: "dial", Net: "tcp", Err: cause} + source.change(func(row *chatlist.Row) { row.Status = chatlist.Off }) + taker := &returningTaker{} + taker.taken = Taken{Transcript: a.file, Fence: 3, Head: "saved"} + taker.on = func(string) { source.change(func(row *chatlist.Row) { row.Status, row.Fence = chatlist.Here, 3 }) } + a.taker = taker + before, err := os.ReadFile(a.file) + if err != nil { + t.Fatal(err) + } + const words = "keep this retired receipt draft" + a.input.setText(words) + drive(t, a, key("enter")) + after, err := os.ReadFile(a.file) + if err != nil || string(before) != string(after) || len(old.sent) != 0 || taker.seen != nil || a.input.String() != words || source.reads != 1 || source.within <= 0 || source.within > idleSendProbeTimeout { + t.Fatalf("failed receipt probe spent the gesture: sent=%v take=%+v box=%q probes=%d budget=%v error=%v", old.sent, taker.seen, a.input.String(), source.reads, source.within, err) + } + if notes := strings.Join(noteTexts(a), "\n"); strings.Count(notes, chatlist.TakeBackUnreachable) != 1 || strings.Contains(homeText(a), continueAsk) { + t.Fatalf("failed receipt probe needs one honest line and no card: %s", notes) + } + source.err = nil + drive(t, a, key("enter")) + arrived, ok := a.agent.(*switchAgent) + if !ok || taker.seen == nil || taker.seen.Device != "A" || taker.seen.Fence != 2 || len(old.sent) != 0 || len(arrived.sent) != 1 || arrived.sent[0] != words || !a.input.empty() || source.reads != 2 { + t.Fatalf("receipt recovery did not reopen and send: agent=%T take=%+v old=%v box=%q reads=%d", a.agent, taker.seen, old.sent, a.input.String(), source.reads) + } + }) + } +} + +type p9xBashAgent struct { + *r9SendAgent + bashSent []string + bashErr error +} + +func (a *p9xBashAgent) SubmitBash(_ context.Context, line string) (<-chan session.Event, error) { + a.bashSent = append(a.bashSent, line) + if a.bashErr != nil { + return nil, a.bashErr + } + ch := make(chan session.Event) + close(ch) + return ch, nil +} + +func (a *switchAgent) SubmitBash(ctx context.Context, line string) (<-chan session.Event, error) { + return a.Submit(ctx, line) +} + +func TestIdleOpen_P9X1_BashCommandRunsOwnershipCheckAndKeepsWords(t *testing.T) { + t.Run("offline refusal keeps bash line in box and prints one line", func(t *testing.T) { + a, old, source := r8OwnLiveWindow(t, false) + bash := &p9xBashAgent{r9SendAgent: &r9SendAgent{r8SendAgent: old, reopen: true}} + a.agent = bash + source.holding, source.err = false, syscall.ECONNREFUSED + source.change(func(row *chatlist.Row) { row.Status = chatlist.Idle }) + const cmdLine = "!sleep 400" + a.input.setText(cmdLine) + drive(t, a, key("enter")) + if len(bash.bashSent) != 0 || a.input.String() != cmdLine { + t.Fatalf("offline bash bypassed checkBeforeSend: bashSent=%v box=%q", bash.bashSent, a.input.String()) + } + if notes := strings.Join(noteTexts(a), "\n"); strings.Count(notes, chatlist.TakeBackUnreachable)+strings.Count(notes, "has not answered; your message is still in the box") != 1 { + t.Fatalf("offline bash refusal must print one line: %v", noteTexts(a)) + } + }) + + t.Run("held elsewhere raises Continue here and keeps bash line in box", func(t *testing.T) { + a, old, source := r8OwnLiveWindow(t, false) + bash := &p9xBashAgent{r9SendAgent: &r9SendAgent{r8SendAgent: old}} + a.agent = bash + source.holding = false + source.change(func(row *chatlist.Row) { + row.Mine, row.DeviceID, row.Device, row.Status, row.Fence = false, "B", "laptop", chatlist.Running, 3 + }) + const cmdLine = "!sleep 400" + a.input.setText(cmdLine) + drive(t, a, key("enter")) + if len(bash.bashSent) != 0 || !a.at(pageHome) || !strings.Contains(homeText(a), continueAsk) || a.input.String() != cmdLine { + t.Fatalf("displaced bash did not offer Continue here: bashSent=%v home=%v box=%q", bash.bashSent, a.at(pageHome), a.input.String()) + } + }) + + t.Run("engine refusal puts bash line back in box and prints one line", func(t *testing.T) { + bash := &p9xBashAgent{r9SendAgent: &r9SendAgent{r8SendAgent: &r8SendAgent{fakeAgent: &fakeAgent{model: "a/b"}}}, bashErr: errors.New(chatlist.Available)} + a := newTestApp(bash) + keepSurfaceWorkersInTest(t, a) + const cmdLine = "!sleep 400" + a.input.setText(cmdLine) + msg := runSubmit(t, a.enter()) + a.adopt(msg) + if a.input.String() != cmdLine { + t.Fatalf("refused bash line lost from box: got %q want %q", a.input.String(), cmdLine) + } + if notes := strings.Join(noteTexts(a), "\n"); strings.Count(notes, chatlist.Available) != 1 { + t.Fatalf("refused bash must print one line: %v", noteTexts(a)) + } + }) +} + +func TestIdleOpen_P9X2_DisplacedKeptBranchReopensOnEnterForPlainAndBash(t *testing.T) { + for _, words := range []string{"!sleep 400", "plain message after kept branch"} { + t.Run(words, func(t *testing.T) { + a, old, source := r8OwnLiveWindow(t, false) + bash := &p9xBashAgent{r9SendAgent: &r9SendAgent{r8SendAgent: old, reopen: true}} + a.agent = bash + source.holding = false + // Simulate P9-X: J was taken via openTaken (awaitingWriter: true), + // then its own lease at f4 lapsed and the aborted turn was branched + // into a kept copy (so local head is "orphan-turn" != relay head "saved"). + a.machineRead.sendHolds = map[string]sendHold{heldCell: {fence: 4, awaitingWriter: true}} + source.change(func(row *chatlist.Row) { + row.Status, row.Mine, row.DeviceID, row.Fence, row.Head = chatlist.Idle, true, "A", 4, "saved" + row.Released, row.AutoOpen = false, false + }) + path := filepath.Join(session.DirOf(a.file), ".cell", "adopted.json") + if err := os.WriteFile(path, []byte(`{"V":1,"head":"orphan-turn","turns_size":0}`), 0600); err != nil { + t.Fatal(err) + } + taker := &returningTaker{} + taker.taken = Taken{Transcript: a.file, Fence: 5, Head: "orphan-turn"} + taker.on = func(string) { + source.change(func(row *chatlist.Row) { + row.Status, row.Fence, row.Head = chatlist.Here, 5, "orphan-turn" + }) + } + a.taker = taker + // First Enter while relay is unreachable must print one refusal line and keep words. + source.err = syscall.ECONNREFUSED + a.input.setText(words) + drive(t, a, key("enter")) + if a.input.String() != words || taker.seen != nil || len(old.sent) != 0 || len(bash.bashSent) != 0 { + t.Fatalf("refused Enter spent words or took early: box=%q seen=%+v", a.input.String(), taker.seen) + } + if notes := strings.Join(noteTexts(a), "\n"); strings.Count(notes, chatlist.TakeBackUnreachable)+strings.Count(notes, "has not answered; your message is still in the box") != 1 { + t.Fatalf("refused Enter must print one line, got: %v", noteTexts(a)) + } + + // Next Enter after relay answers takes the reopen road on the original cell. + source.err = nil + drive(t, a, key("enter")) + if taker.seen == nil || taker.seen.Device != "A" || taker.seen.Fence != 4 || len(taker.cells) != 1 || taker.cells[0] != heldCell { + t.Fatalf("Enter did not reopen original cell at f4: cells=%v seen=%+v", taker.cells, taker.seen) + } + arrived, ok := a.agent.(*switchAgent) + if !ok || len(arrived.sent) != 1 || arrived.sent[0] != words || !a.input.empty() || a.at(pageHome) { + t.Fatalf("reopen did not run %q on replacement session: agent=%T sent=%v box=%q notes=%v", words, a.agent, arrived.sent, a.input.String(), noteTexts(a)) + } + }) + } +} diff --git a/internal/tui3/idle_send.go b/internal/tui3/idle_send.go new file mode 100644 index 0000000000..19daee8af0 --- /dev/null +++ b/internal/tui3/idle_send.go @@ -0,0 +1,142 @@ +package tui3 + +import ( + "context" + "errors" + "path/filepath" + "slices" + + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncwords" +) + +// resumeIdleSend asks this open writer to confirm or reacquire in place. +// The drive checks ownership and its saved head before an in-place retake. +// An ordinary retired writer instead requires a conditional reopen. +func resumeIdleSend(ctx context.Context, agent Agent, source chatlist.Source, msg ownershipReadMsg, blocked bool) ownershipReadMsg { + if blocked || msg.reading.err != nil || msg.notHeld && msg.local { + return msg + } + receipts, ok := source.(sendLeaseSource) + if !ok { + return msg + } + id := msg.leaseCell + if id == "" { + id = filepath.Base(session.DirOf(msg.file)) + } + for _, row := range msg.reading.rows { + if row.Cell != id { + continue + } + if !row.Mine { + continue + } + if msg.ownFence != 0 && row.Fence != msg.ownFence && !msg.retryIdle { + continue + } + if !msg.ownLiveRow(row) && !(row.Released && row.AutoOpen) && (msg.head == "" || row.Head != msg.head || !msg.retryIdle && (!row.AutoOpen || row.Status != chatlist.Idle && row.Status != chatlist.Off)) { + continue + } + // The directory read is complete. Taking the hold back is a separate, + // bounded write and must not inherit the read's remaining deadline. + ctx, cancel := context.WithTimeout(context.Background(), executor.TakeBackWithin) + defer cancel() + var check func() error + if gate, ok := agent.(interface{ CheckWorkContext(context.Context) error }); ok { + check = func() error { return gate.CheckWorkContext(ctx) } + } else if gate, ok := agent.(interface{ CheckWork() error }); ok { + check = gate.CheckWork + } else { + return msg + } + if err := check(); err != nil { + // The client can lose its RPC before the engine can return the + // typed refusal. This window was taking back its released chat; + // keep that uncertainty across Enter and later ownership readings. + var refusal *executor.WorkRefusal + if errors.As(err, &refusal) && refusal.Line == chatlist.Available && refusal.HolderID == "" && !refusal.Retryable { + // A lapsed or outside-released drive stays closed. The taker + // preserves pending turns and installs its replacement session. + msg.reopen = true + if msg.local && row.DeviceID == "" { + // A receipt-only row names no observed holder. Read that + // expectation before taking, after the drive retired its hold. + probe, cancel := context.WithTimeout(context.Background(), idleSendProbeTimeout) + defer cancel() + msg.reading.rows, msg.reading.version, msg.reading.err = chatlist.RowsForSend(probe, source) + msg.reading.readWithin = idleSendProbeTimeout + msg.local, msg.notHeld, msg.fresh = false, true, msg.reading.err == nil + if syncwords.NetworkFailure(msg.reading.err) || readTimedOut(msg.reading.err) { + msg.admissionErr = &executor.WorkRefusal{Line: chatlist.TakeBackUnreachable, Retryable: true} + } + } + return msg + } + if !errors.As(err, &refusal) { + err = &executor.WorkRefusal{Line: executor.TakeBackRetryLine, Retryable: true} + } + msg.admissionErr = err + return msg + } + remote, fence, err := receipts.SendLease(row.Cell) + holding := false + if writer, ok := agent.(renewalReader); ok { + _, _, holding = writer.RenewalStatus() + if err == nil && fence >= row.Fence && !holding { + // Confirmed admission can answer before its writer-status + // frame. Wait for that cached fact; a different window's receipt + // alone must never authorize this one. + holding, _ = awaitSendWriter(ctx, writer, nil, true) + } + } + if err != nil || remote != row.Cell || fence == 0 || fence < row.Fence || fence == row.Fence && (row.Released || !holding) { + msg.admissionErr = &executor.WorkRefusal{Line: chatlist.Available} + if msg.retryIdle { + msg.admissionErr = &executor.WorkRefusal{Line: executor.TakeBackRetryLine, Retryable: true} + } + return msg + } + msg.proof, msg.local, msg.notHeld, msg.writerStopped = true, true, false, false + msg.leaseCell, msg.fence = remote, fence + msg.reading.holdCell, msg.reading.holdFence = row.Cell, fence + msg.reading.rows = slices.Clone(msg.reading.rows) + for i := range msg.reading.rows { + if current := &msg.reading.rows[i]; current.Cell == row.Cell { + current.Status, current.Mine, current.Fence = chatlist.Here, true, fence + current.Released, current.AutoOpen = false, false + } + } + return msg + } + return msg +} + +// ownLiveRow routes an unheld window or a receipt-only local row through the +// drive's admission decision before any words leave the box. +func (msg ownershipReadMsg) ownLiveRow(row chatlist.Row) bool { + return (!msg.local && msg.notHeld || msg.local && row.DeviceID == "") && row.Mine && row.Status == chatlist.Here && row.Fence != 0 && (msg.ownFence == 0 || row.Fence == msg.ownFence) +} + +func (a *app) refuseIdleSend(msg ownershipReadMsg) bool { + if msg.admissionErr == nil { + return false + } + var refusal *executor.WorkRefusal + if errors.As(msg.admissionErr, &refusal) { + id := filepath.Base(session.DirOf(msg.file)) + if a.machineRead.sendHolds == nil { + a.machineRead.sendHolds = map[string]sendHold{} + } + hold := a.machineRead.sendHolds[id] + hold.retryIdle = refusal.Retryable + a.machineRead.sendHolds[id] = hold + a.note(refusal.Line) + a.blockDisplacedSend(refusal.Line) + } else { + a.note(messageKept(msg.host)) + } + return true +} diff --git a/internal/tui3/idle_settings_test.go b/internal/tui3/idle_settings_test.go new file mode 100644 index 0000000000..28eea1e944 --- /dev/null +++ b/internal/tui3/idle_settings_test.go @@ -0,0 +1,32 @@ +package tui3 + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/effort" + "github.com/Agent-Field/codeaf/internal/executor" +) + +type refusedSettingAgent struct { + *effortAgent + refusal error +} + +func (a *refusedSettingAgent) SetConversationEffort(string) bool { return false } +func (a *refusedSettingAgent) SetModel(string) {} +func (a *refusedSettingAgent) SettingError() error { return a.refusal } + +func TestIdleRelease_R5_1_TerminalKeepsRefusedSettingWords(t *testing.T) { + want := &executor.WorkRefusal{Line: "laptop continued this chat", HolderID: "B"} + far := &refusedSettingAgent{effortAgent: &effortAgent{fakeAgent: &fakeAgent{model: "old/model"}, conversation: effort.Low}, refusal: want} + a := newTestApp(far) + a.slash("/effort high") + if !strings.Contains(a.input.String(), "/effort high") || far.ConversationEffort() != "low" { + t.Fatal("refused effort choice was lost or applied") + } + a.slash("/model new/model") + if !strings.Contains(a.input.String(), "/model new/model") || far.Model() != "old/model" { + t.Fatal("refused model choice was lost or applied") + } +} diff --git a/internal/tui3/idle_walk_test.go b/internal/tui3/idle_walk_test.go new file mode 100644 index 0000000000..3bb10918b2 --- /dev/null +++ b/internal/tui3/idle_walk_test.go @@ -0,0 +1,200 @@ +package tui3 + +import ( + "context" + "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/executor" + "github.com/Agent-Field/codeaf/internal/session" +) + +type walkSendSource struct{ *continuationListing } + +func (s walkSendSource) SendLease(string) (string, uint64, error) { + rows, _ := s.Rows(context.Background()) + if len(rows) == 1 && rows[0].Status == chatlist.Here { + return heldCell, rows[0].Fence, nil + } + return "", 0, os.ErrNotExist +} + +type walkSendAgent struct { + *fakeAgent + source walkSendSource + checks int + moved bool +} + +func (a *walkSendAgent) RenewalStatus() (time.Time, time.Duration, bool) { + rows, _ := a.source.Rows(context.Background()) + return time.Time{}, time.Second, rows[0].Status == chatlist.Here +} +func (a *walkSendAgent) CheckWork() error { + a.checks++ + if a.moved { + return &executor.WorkRefusal{Line: chatlist.Superseded("laptop"), HolderID: "B", HolderName: "laptop"} + } + a.source.change(func(r *chatlist.Row) { r.Status, r.Fence = chatlist.Here, r.Fence+1 }) + return nil +} + +func TestIdleRelease_C3_D1_TerminalModelSendKeepsTheOpenWorkspace(t *testing.T) { + t.Setenv("CODEAF_IDLE_HANDOFF", "on") + for _, moved := range []bool{false, true} { + t.Run(map[bool]string{false: "own release", true: "another computer wins"}[moved], func(t *testing.T) { + source := walkSendSource{&continuationListing{row: chatlist.Row{Cell: heldCell, Status: chatlist.Idle, Mine: true, Released: true, AutoOpen: true, Fence: 2, Head: "saved", DeviceID: "A"}}} + taker := &fakeTaker{} + a, old := heldLab(t, source, taker) + keepSurfaceWorkersInTest(t, a) + 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) + } + ignored := filepath.Join(a.workspace, "node_modules", "x", "index.js") + if err := os.MkdirAll(filepath.Dir(ignored), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(ignored, []byte("keep this local install"), 0600); err != nil { + t.Fatal(err) + } + taker.taken = Taken{Transcript: a.file, Fence: 3, Head: "saved"} + taker.on = func(string) { + _ = os.RemoveAll(filepath.Join(a.workspace, "node_modules")) + source.change(func(r *chatlist.Row) { r.Status, r.Fence = chatlist.Here, 3 }) + } + agent := &walkSendAgent{fakeAgent: old, source: source, moved: moved} + a.agent = agent + a.input.setText("ordinary model message") + drive(t, a, key("enter")) + if len(taker.cells) != 0 { + t.Errorf("D1 same-window send restored through takeover: %v", taker.cells) + } + if raw, err := os.ReadFile(ignored); err != nil || string(raw) != "keep this local install" { + t.Errorf("D1 ignored local file changed: %q, %v", raw, err) + } + if agent.checks != 1 { + t.Errorf("D1 ownership admissions=%d", agent.checks) + } + if moved { + if len(old.sent) != 0 || string(a.mainComposer().box.value) != "ordinary model message" { + t.Fatal("D1 losing send spent the draft") + } + } else if len(old.sent) != 1 || a.agent != agent { + t.Fatalf("D1 did not send through the same session: sent=%v agent=%T", old.sent, a.agent) + } + var notes strings.Builder + for _, entry := range a.entries { + if entry.kind == entryNote { + notes.WriteString(plain(entry.text)) + } + } + head, card := a.questionHead() + if card && head.question.Kind == resumeKind || strings.Contains(notes.String(), "Moved here") { + t.Fatal("D1 raised a move/setup notice") + } + }) + } +} + +type walkOpenTaker struct { + fakeTaker + opens int + source *continuationListing + file string + journal string +} + +func (t *walkOpenTaker) OpenAvailable(_ context.Context, _ string, seen *directory.LeaseExpectation) (Taken, error) { + t.opens++ + if seen == nil || seen.Device != "B" || seen.Fence != 3 { + return Taken{}, directory.ErrLeaseHeld + } + if err := os.WriteFile(t.file, []byte(t.journal), 0600); err != nil { + return Taken{}, err + } + t.source.change(func(r *chatlist.Row) { r.Status, r.Mine, r.Fence = chatlist.Here, true, r.Fence+1 }) + return Taken{Transcript: t.file, Fence: 4, Head: "newest", Device: "laptop"}, nil +} +func TestIdleOpen_C6_D2_LocalViewerRowPullsTheReleasedHead(t *testing.T) { + t.Setenv("CODEAF_IDLE_HANDOFF", "on") + source := &continuationListing{row: chatlist.Row{Cell: heldCell, Status: chatlist.Running, Device: "laptop", DeviceID: "B", Fence: 3, Head: "newest"}} + taker := &walkOpenTaker{source: source} + a, old := heldLab(t, source, taker) + keepSurfaceWorkersInTest(t, a) + taker.file = a.file + initial, err := os.ReadFile(a.file) + if err != nil { + t.Fatal(err) + } + taker.journal = string(initial) + `{"type":"message","role":"assistant","content":"newest journal from B"}` + "\n" + if err := os.WriteFile(a.file, append(initial, []byte(`{"type":"message","role":"assistant","content":"stale journal on A"}`+"\n")...), 0600); err != nil { + t.Fatal(err) + } + a.blockDisplacedSend(chatlist.Superseded("laptop")) + source.change(func(r *chatlist.Row) { r.Status, r.Released, r.AutoOpen = chatlist.Idle, true, true }) + drain(t, a, a.askMachines()) + found := false + for i, line := range a.home.lines { + if line.kind == homeSession && line.row.Transcript == a.file { + a.home.cursor = i + found = true + break + } + } + if !found { + t.Fatal("fixture did not expose the existing local row") + } + drive(t, a, key("enter")) + if taker.opens != 1 { + t.Fatalf("D2 existing viewer row opens=%d; want one non-forcing pull", taker.opens) + } + raw, err := os.ReadFile(taker.file) + if err != nil || string(raw) != taker.journal { + t.Fatalf("D2 stale journal retained: %q, %v", raw, err) + } + rows, _ := source.Rows(t.Context()) + if !rows[0].Mine || rows[0].Status != chatlist.Here || rows[0].Fence != 4 { + t.Fatalf("D2 receiver did not hold the newest chat: %+v", rows) + } + if a.agent == old || a.machineRead.sendHolds[heldCell].blocked { + t.Fatal("D2 retained the stale viewer") + } +} + +func TestIdleOpen_C7_D2_LocalViewerKeepsLiveHoldChoice(t *testing.T) { + t.Setenv("CODEAF_IDLE_HANDOFF", "on") + source := &continuationListing{row: chatlist.Row{Cell: heldCell, Status: chatlist.Idle, Released: true, AutoOpen: true, Device: "laptop", DeviceID: "B", Fence: 3, Head: "newest"}} + taker := &walkOpenTaker{source: source} + a, _ := heldLab(t, source, taker) + keepSurfaceWorkersInTest(t, a) + a.blockDisplacedSend(chatlist.Superseded("laptop")) + var selected homeLine + found := false + for _, line := range a.home.lines { + if line.kind == homeSession && line.row.Transcript == a.file { + selected, found = line, true + break + } + } + if !found { + t.Fatal("fixture did not expose the existing local row") + } + // A live hold keeps the base's existing local-viewer shortcut. Only a + // paired free row may redirect that shortcut through automatic open. + source.change(func(r *chatlist.Row) { r.Status, r.Released, r.AutoOpen = chatlist.Running, false, false }) + drain(t, a, a.askMachines()) + drain(t, a, a.homeOpenLine(selected)) + if taker.opens != 0 || len(taker.cells) != 0 || strings.Contains(homeText(a), continueAsk) { + t.Fatal("a live local viewer changed the base open shortcut") + } +} diff --git a/internal/tui3/input.go b/internal/tui3/input.go index d1ff056033..b31b55483e 100644 --- a/internal/tui3/input.go +++ b/internal/tui3/input.go @@ -1549,6 +1549,17 @@ func (a *app) enterCheckedLine(ownershipRead bool) tea.Cmd { return a.edited() } if _, bash := session.BashCommand(a.pastesUnfolded(line)); bash { + if !ownershipRead && a.watchesConversation() { + if tag := a.missingPaste(line); tag != "" { + a.note(draftOrphanSendWord + " · " + tag) + return nil + } + if refusal := bashRefusal(a.pastesUnfolded(line), len(a.chips) > 0, a.stream != nil || a.state == stateWorking); refusal != "" { + a.note(refusal) + return nil + } + return a.checkBeforeSend() + } return a.enterBash(line) } // Continuing is a local choice, so a displaced window offers it before diff --git a/internal/tui3/palette.go b/internal/tui3/palette.go index e8967e69b3..5330853736 100644 --- a/internal/tui3/palette.go +++ b/internal/tui3/palette.go @@ -3116,6 +3116,13 @@ func (a *app) switchModel(id string, window int) { } } a.agent.SetModel(id) + if result, ok := a.agent.(interface{ SettingError() error }); ok { + if err := result.SettingError(); err != nil { + a.input.setText("/model " + id) + a.note(err.Error()) + return + } + } a.modelSwitched(id, window, true) } diff --git a/internal/tui3/resume_r6_test.go b/internal/tui3/resume_r6_test.go new file mode 100644 index 0000000000..5c98e26b85 --- /dev/null +++ b/internal/tui3/resume_r6_test.go @@ -0,0 +1,30 @@ +package tui3 + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" +) + +type r6RefusedResumer struct{ *switchAgent } + +func (*r6RefusedResumer) ResumeStoppedTurn(context.Context) (<-chan session.Event, bool) { + events := make(chan session.Event, 1) + events <- session.Event{Kind: session.EventError, Err: errors.New("chat held elsewhere")} + close(events) + return events, false +} + +func TestR6_OwedTurnRefusalIsShownWithoutAskingAgain(t *testing.T) { + lab := newHomeLab(t) + a := lab.app("") + a.agent = &r6RefusedResumer{switchAgent: &switchAgent{fakeAgent: &fakeAgent{model: "m"}}} + drain(t, a, a.resumeStoppedTurn()) + text := transcriptText(a) + if strings.Contains(text, session.ResumedWord) || strings.Count(text, "chat held elsewhere") != 1 { + t.Fatalf("resume refusal must appear once without asking again:\n%s", text) + } +} diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index 078510f86b..9f746d8790 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -517,6 +517,10 @@ var settingUI = map[string]settingMeta{ tab: tabWorkspace, label: "model in commits", widget: widgetToggle, about: config.AttributionModelHint, }, + config.KeyIdleHandoff: { + tab: tabWorkspace, label: "idle handoff", widget: widgetToggle, + about: config.IdleHandoffHint, + }, // The three rows Google and Slack connections are signed with. They belong on this tab // and not under Providers because they are not about which model answers // what: they are about what codeaf may REACH on your behalf, which is the diff --git a/internal/tui3/takeover.go b/internal/tui3/takeover.go index e46998d124..3029bc1dd5 100644 --- a/internal/tui3/takeover.go +++ b/internal/tui3/takeover.go @@ -639,11 +639,12 @@ type turnResumer interface { ResumeStoppedTurn(ctx context.Context) (<-chan session.Event, bool) } -// resumedTurnMsg is that door's answer on its way back to the loop, and it is -// sent only when a turn really started. +// resumedTurnMsg is that door's answer on its way back to the loop. It carries +// either an admitted turn or the refusal of an owed turn. type resumedTurnMsg struct { - gen int - ch <-chan session.Event + gen int + ch <-chan session.Event + resumed bool } // resumeStoppedTurn is the one line [app.attachConversation] spends on this: ask @@ -671,10 +672,10 @@ func (a *app) resumeStoppedTurn() tea.Cmd { return nil } events, resumed := door.ResumeStoppedTurn(ctx) - if !resumed { + if events == nil { return nil } - return resumedTurnMsg{gen: gen, ch: events} + return resumedTurnMsg{gen: gen, ch: events, resumed: resumed} } } @@ -694,7 +695,9 @@ func (a *app) tookResumedTurn(msg resumedTurnMsg) tea.Cmd { if msg.ch == nil || msg.gen != a.gen || a.stream != nil { return nil } - a.note(session.ResumedWord) + if msg.resumed { + a.note(session.ResumedWord) + } return a.takeStream(msg.ch) }