From c2e2f4354612e627572a534ca78e57758506b176 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 00:07:15 -0500 Subject: [PATCH 01/21] syncsetup: a paired computer lets go of an idle chat once everything is on the relay When a chat on a computer that shares its identity with another goes idle, its saved state (turns, settings, workspace, vault) is certified and uploaded outside the admission lock, then the hold is released in a short bounded step. Running, queued, starting or parked work keeps the hold. New work takes the hold back in place, or is refused if another computer took the chat. A home with one device makes no new requests. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/syncdrive.go | 19 ++ cmd/codeaf/syncdrive_admission.go | 10 +- internal/cellstore/idle_release_test.go | 41 +++ internal/cellstore/savedstate.go | 260 +++++++++++++++++++ internal/cellstore/sealwatch.go | 29 +++ internal/cellsync/batch.go | 48 +++- internal/cellsync/idle_release_test.go | 107 ++++++++ internal/cellsync/ownership.go | 81 +++++- internal/cellsync/renewal.go | 4 +- internal/executor/environment.go | 2 + internal/executor/gated.go | 28 +- internal/executor/gated_test.go | 14 + internal/executor/watched.go | 2 + internal/offpath/offpath.go | 12 + internal/session/agent.go | 3 + internal/session/idle_handoff.go | 104 ++++++++ internal/session/idle_handoff_test.go | 96 +++++++ internal/session/idle_start_test.go | 74 ++++++ internal/session/placemeta.go | 14 +- internal/session/session.go | 8 +- internal/session/task_person.go | 2 + internal/session/task_run.go | 2 +- internal/session/task_run_belt.go | 7 + internal/syncsetup/confirm_work.go | 87 ++++++- internal/syncsetup/drive.go | 174 ++++++++++--- internal/syncsetup/idle_release.go | 244 ++++++++++++++++++ internal/syncsetup/idle_release_test.go | 311 ++++++++++++++++++++++ internal/syncsetup/idle_review_test.go | 326 ++++++++++++++++++++++++ internal/syncsetup/sendlease.go | 77 +++++- 29 files changed, 2110 insertions(+), 76 deletions(-) create mode 100644 internal/cellstore/idle_release_test.go create mode 100644 internal/cellstore/savedstate.go create mode 100644 internal/cellsync/idle_release_test.go create mode 100644 internal/session/idle_handoff.go create mode 100644 internal/session/idle_handoff_test.go create mode 100644 internal/session/idle_start_test.go create mode 100644 internal/syncsetup/idle_release.go create mode 100644 internal/syncsetup/idle_release_test.go create mode 100644 internal/syncsetup/idle_review_test.go diff --git a/cmd/codeaf/syncdrive.go b/cmd/codeaf/syncdrive.go index c9100a91b5..7e2e8b7565 100644 --- a/cmd/codeaf/syncdrive.go +++ b/cmd/codeaf/syncdrive.go @@ -32,6 +32,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 *driveNotices + // idleCheck includes app work waiting outside the session's own queue. + idleCheck func(string) bool } type driveNotices struct { @@ -86,6 +88,9 @@ func (b *driveBook) pendingDriveOf(c cell.Cell, engine cellstore.Engine, report d := newAdmissionDrive(func(changed func()) (*syncsetup.Drive, error) { return startDriveWith(c, engine, report, b.notices, changed) }) + if check := b.idleCheck; check != nil { + d.surfaceIdle = func() bool { return check(c.ID) } + } d.transcript = (session.Place{Dir: c.Root}).Transcript() if d != nil { if b.drive == nil { @@ -269,6 +274,20 @@ func driveWorkGate(d *liveDrive) func() error { } } +func drivePreparedGate(d *liveDrive) func() error { + if d == nil { + return nil + } + return func() error { + if started := d.started(); started != nil { + ctx, cancel := context.WithTimeout(context.Background(), driveWorkWithin) + 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 489bd8c09d..b837ff7c09 100644 --- a/cmd/codeaf/syncdrive_admission.go +++ b/cmd/codeaf/syncdrive_admission.go @@ -51,11 +51,15 @@ func newAdmissionDrive(start func(func()) (*syncsetup.Drive, error)) *liveDrive } func (w *workAdmission) admit(d *syncsetup.Drive) error { - if err := d.Gate(); err != nil { - return err - } ctx, cancel := context.WithTimeout(context.Background(), driveWorkWithin) defer cancel() + released := d.BeginWork() + 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/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..13980164b2 --- /dev/null +++ b/internal/cellstore/savedstate.go @@ -0,0 +1,260 @@ +package cellstore + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "io/fs" + "os" + "path" + "path/filepath" + "strings" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +// 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 +} + +// 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 + } + raw, err := e.do(ctx, c, e.cellDir(c), capturePlanOp{}) + if err != nil { + return nil, err + } + 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") + } + 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 { + 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 + } + } + raw, err = e.plan(ctx, c, head.Turn.ID) + if err != nil { + return nil, err + } + 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("save check: files changed during comparison: %s", 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: 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 + return ctx.Err() + }); err != nil { + return nil, err + } + var changes []string + 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))) + if err != nil { + return nil, err + } + saved, err := savedFile(ctx, filepath.Join(base, filepath.FromSlash(rel))) + if err != nil { + return nil, err + } + if current != saved { + changes = append(changes, name) + } + } + return changes, nil +} + +func savedFile(ctx context.Context, physical string) (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 + } + 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) + _, _ = 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 + } + info, err := os.Lstat(physical) + if err != nil && !os.IsNotExist(err) { + return err + } + s.files[physical] = info + if info != nil && info.IsDir() { + names, err := directoryNames(physical) + if err != nil { + return err + } + s.members[physical] = names + } + return nil +} + +func directoryNames(physical string) (string, error) { + entries, err := os.ReadDir(physical) + if err != nil { + return "", err + } + var names strings.Builder + for _, entry := range entries { + fmt.Fprintf(&names, "%s\x00%d\x00", entry.Name(), entry.Type()) + } + return names.String(), 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); err != nil || names != s.members[physical] { + return physical + } + } + } + if ctx.Err() != nil { + return ctx.Err().Error() + } + return "" +} diff --git a/internal/cellstore/sealwatch.go b/internal/cellstore/sealwatch.go index 35f6081a97..30aa696770 100644 --- a/internal/cellstore/sealwatch.go +++ b/internal/cellstore/sealwatch.go @@ -51,6 +51,8 @@ type SealWatch struct { idle bool started bool tail *time.Timer + tailRunning bool + idleCheck func() bool cause string said []string changes chan struct{} @@ -198,12 +200,39 @@ func (w *SealWatch) sealTail() { 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() } } +// 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 78091877fa..e38f78697d 100644 --- a/internal/cellsync/batch.go +++ b/internal/cellsync/batch.go @@ -61,6 +61,7 @@ type Batcher struct { published bool // a publish renewed the lease since the last heartbeat tick beatSent uint32 // the pending count the directory last heard, from a beat or a publish closing bool // The closing caller owns subsequent publishes and release. + released bool // An idle reader must reacquire before publishing or renewing. 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 @@ -69,11 +70,13 @@ 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 + 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 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. @@ -253,13 +256,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 <-ctx.Done(): return } @@ -295,7 +299,7 @@ func (b *Batcher) tick(ctx context.Context, held *holding) { if b.takePublished() { return } - if !held.covers(b.view, b.toldPending) { + if !held.covers(b.renewalView, b.toldPending) { b.beat(ctx) } } @@ -377,7 +381,16 @@ func (b *Batcher) Close(ctx context.Context) error { b.mu.Lock() b.closing = true b.mu.Unlock() - b.flushMu.Lock() + if err := lockOwnership(ctx, &b.flushMu); err != nil { + // Cancellation also stops the drive's active publisher. Briefly join + // its outcome so the closing error retains upload/local provenance, + // without inheriting an unresponsive publisher's lifetime. + join, cancel := context.WithTimeout(context.WithoutCancel(ctx), 50*time.Millisecond) + defer cancel() + if lockOwnership(join, &b.flushMu) != nil { + return err + } + } defer b.flushMu.Unlock() // A release is the cross-computer clean-quit signal. Publishing must finish // first; otherwise another home could automatically open an older head. @@ -401,8 +414,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 { @@ -508,14 +524,18 @@ func (b *Batcher) publish(ctx context.Context, head string) error { // 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 } _, err := b.Publisher.Dir.Heartbeat(ctx, d.ID(), directory.Beat{Fence: d.Fence, Pending: pending}) + b.renewMu.Unlock() b.renewalResult(d, err) switch { case err == nil: @@ -543,7 +563,9 @@ func (b *Batcher) Displaced(ctx context.Context) { // flush in flight, and does nothing if that flush already branched. When the // branch fails the state stays stale and the next flush retries it. func (b *Batcher) lost(ctx context.Context, seen Driving) { - b.flushMu.Lock() + if lockOwnership(ctx, &b.flushMu) != nil { + return + } defer b.flushMu.Unlock() if b.isClosing() { return @@ -657,6 +679,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_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 4f461ad09e..e0f1b2e717 100644 --- a/internal/cellsync/ownership.go +++ b/internal/cellsync/ownership.go @@ -2,6 +2,7 @@ package cellsync import ( "context" + "sync" "time" "github.com/Agent-Field/codeaf/internal/directory" @@ -19,8 +20,14 @@ func (b *Batcher) Lease() (id string, fence uint64) { // 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() + 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, _ := b.view() if b.isClosing() || pending != 0 || d.Head != seen.Cell.Head || seen.Cell.Lease.Expires > seen.Now { return false, nil @@ -38,9 +45,79 @@ func (b *Batcher) Reacquire(ctx context.Context, seen directory.CellView) (bool, d.Fence, d.Frames = v.Cell.Lease.Fence, v.Cell.Frames b.mu.Lock() *b.Driving = d - b.stale, b.renewalEnded = false, false + b.stale, b.released, b.renewalEnded = false, false, false b.renewalSince = time.Time{} b.mu.Unlock() b.renewalChanged() + b.leaseChanged.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 + } + b.mu.Lock() + b.released = true + b.mu.Unlock() + b.leaseChanged.fire() + if err := b.release(ctx); err != nil { + return false, err + } + b.endRenewals() return true, nil } + +// ResumeHeld confirms that a failed release left this fence live. It cannot +// resume a changed head or a later writer, and sends no acquisition request. +func (b *Batcher) ResumeHeld(ctx context.Context, seen directory.CellView) bool { + if lockOwnership(ctx, &b.flushMu) != nil { + return false + } + defer b.flushMu.Unlock() + b.mu.Lock() + d := b.Driving + if b.closing || b.stale || len(b.noted) != 0 || d.Fence != seen.Cell.Lease.Fence || d.Head != seen.Cell.Head || seen.Cell.Lease.Expires <= seen.Now { + b.mu.Unlock() + return false + } + b.released, b.renewalEnded = false, false + b.renewalSince = time.Time{} + b.mu.Unlock() + 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: + } + } +} diff --git a/internal/cellsync/renewal.go b/internal/cellsync/renewal.go index dd8eb98f92..c227562199 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 } @@ -21,7 +21,7 @@ func (b *Batcher) RenewalStatus() (time.Time, time.Duration, 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/executor/environment.go b/internal/executor/environment.go index 00f248466e..8ad3f9570d 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 diff --git a/internal/executor/gated.go b/internal/executor/gated.go index 3b377d100b..b029e11e45 100644 --- a/internal/executor/gated.go +++ b/internal/executor/gated.go @@ -21,6 +21,14 @@ 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 CheckWork(seat) +} + // 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 +43,25 @@ 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} + prepared := work + if len(admission) > 1 { + prepared = admission[1] + } + return gated{Seat: seat, gate: gate, work: work, prepared: prepared} } type gated struct { Seat - gate func() error - work func() error + gate func() error + work func() error + prepared func() error +} + +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 +73,7 @@ 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) } // 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..bb979d66ba 100644 --- a/internal/executor/gated_test.go +++ b/internal/executor/gated_test.go @@ -34,3 +34,17 @@ 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) + } + } +} diff --git a/internal/executor/watched.go b/internal/executor/watched.go index af108f4375..b89190226b 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 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/session/agent.go b/internal/session/agent.go index c65f00ccab..84e796025b 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -541,6 +541,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. diff --git a/internal/session/idle_handoff.go b/internal/session/idle_handoff.go new file mode 100644 index 0000000000..a0a450ae57 --- /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.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.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_start_test.go b/internal/session/idle_start_test.go new file mode 100644 index 0000000000..57dc08f3bd --- /dev/null +++ b/internal/session/idle_start_test.go @@ -0,0 +1,74 @@ +package session + +import ( + "context" + "errors" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/crewroute" + "path/filepath" + "testing" + "time" +) + +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/placemeta.go b/internal/session/placemeta.go index f12fa3df36..3b6e718ce7 100644 --- a/internal/session/placemeta.go +++ b/internal/session/placemeta.go @@ -174,6 +174,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 @@ -268,7 +277,7 @@ func (a *Agent) updateMeta(dir string, snapshot Meta, patch func(*Meta)) { if dir == "" { return } - _ = withMetaLock(dir, func() error { + err := withMetaLock(dir, func() error { meta, err := LoadMeta(dir) if err != nil { return err @@ -305,6 +314,9 @@ func (a *Agent) updateMeta(dir string, snapshot Meta, patch func(*Meta)) { patch(&meta) return saveMetaLocked(dir, meta) }) + if err == nil { + a.tellWrote() + } } // fillMetaLocked fills in everything about a session that does not change while diff --git a/internal/session/session.go b/internal/session/session.go index 196a892bab..bfc37b412d 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -3028,7 +3028,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 @@ -3176,6 +3176,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 @@ -3909,7 +3911,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 @@ -4090,7 +4092,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/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_run.go b/internal/session/task_run.go index e9c6bc8d1c..767e762923 100644 --- a/internal/session/task_run.go +++ b/internal/session/task_run.go @@ -1151,7 +1151,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 f35607087f..df2c5bd29c 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" @@ -596,6 +597,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 @@ -2024,6 +2027,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/syncsetup/confirm_work.go b/internal/syncsetup/confirm_work.go index 31082b19d1..548b1a4ca9 100644 --- a/internal/syncsetup/confirm_work.go +++ b/internal/syncsetup/confirm_work.go @@ -2,17 +2,48 @@ 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" ) +// 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 { + return d.Gate() + } + confirmed, err := d.ConfirmWork(ctx) + if err != nil { + return err + } + if !confirmed { + if err := d.Gate(); err != nil { + return err + } + return cellstore.ErrReadOnly + } + return d.Gate() +} + // ConfirmWork checks only this chat's directory record. Unlike CheckLease it // never waits for a 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) { + d.BeginWork() + if err := lockDrive(ctx, &d.workMu); err != nil { + return false, err + } + defer d.workMu.Unlock() + d.mu.Lock() + stopped := d.stopped + d.mu.Unlock() + if stopped { + return false, cellstore.ErrReadOnly + } held := d.sendLeaseSnapshot() if d.batcher == nil { return false, nil @@ -38,37 +69,68 @@ func (d *Drive) ConfirmWork(ctx context.Context) (bool, error) { return false, nil } if lease.Expires <= v.Now { - d.availableAfter(held, lease) + d.availableAfterWithin(ctx, held, lease) // Never resume an older tree over work saved by another computer. head, err := cellstore.Head(d.cell) if err != nil || head == nil || head.Turn.ID != v.Cell.Head { + if err == nil && lease.Device != d.sync.Device.ID() { + d.confirmHolder(ctx, lease) + } 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 fresh.Cell.Lease.Expires > fresh.Now { + d.confirmHolder(ctx, fresh.Cell.Lease) + } + return false, nil + } if err != nil || !resumed { return false, err } d.mu.Lock() d.viewer, d.lost = Viewer{}, false d.mu.Unlock() - if err := d.recordSendLease(); err != nil { + if err := d.recordSendLeaseWithin(ctx); err != nil { d.available() return false, err } + d.released.Store(false) + d.releaseDone = false if d.opt.OnDriving != nil { d.opt.OnDriving() } return true, nil } if lease.Device == d.sync.Device.ID() && lease.Fence == fence { + if d.released.Load() && !d.batcher.ResumeHeld(ctx, v) { + return false, nil + } + d.released.Store(false) + d.releaseDone = false d.resumeViewer() + if err := d.recordSendLeaseWithin(ctx); err != nil { + d.available() + return false, err + } return true, nil } if lease.Device == d.sync.Device.ID() { - d.availableAfter(held, lease) + d.availableAfterWithin(ctx, held, lease) return false, nil } + d.confirmHolder(ctx, lease) + return false, nil +} + +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) @@ -80,9 +142,9 @@ func (d *Drive) ConfirmWork(ctx context.Context) (bool, error) { } viewer := Viewer{ID: lease.Device, Name: name, Line: chatlist.Superseded(name)} d.mu.Lock() - if d.viewer.Line != "" { + if d.viewer == viewer { d.mu.Unlock() - return false, nil + return } d.viewer = viewer expected := sendLease{Cell: d.directoryID, Fence: d.recordedFence} @@ -106,12 +168,11 @@ func (d *Drive) ConfirmWork(ctx context.Context) (bool, error) { } d.notice(viewer.Line) }() - return false, nil } func (d *Drive) resumeViewer() { d.mu.Lock() - if d.stopped { + if d.stopped || d.released.Load() { d.mu.Unlock() return } @@ -134,13 +195,23 @@ func (d *Drive) available() { // An unchanged owner and fence keeps its receipt even when released, because a // heartbeat can still renew it. Readers hear the change after retirement finishes. func (d *Drive) availableAfter(held sendLease, reading directory.Lease) { + ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) + defer cancel() + d.availableAfterWithin(ctx, held, reading) +} + +func (d *Drive) availableAfterWithin(ctx context.Context, held sendLease, reading directory.Lease) { viewer := Viewer{Line: chatlist.Available} d.mu.Lock() + if held.Fence != 0 && held.Fence != d.recordedFence { + d.mu.Unlock() + return + } changed := d.viewer != viewer d.viewer = viewer d.mu.Unlock() if held.Fence != 0 && (reading.Device != d.sync.Device.ID() || reading.Fence != held.Fence) { - if err := d.sync.forgetSendLease(d.cell.ID, held); err != nil { + if err := d.sync.forgetSendLeaseWithin(ctx, d.cell.ID, held); err != nil { d.notice(err.Error()) } } diff --git a/internal/syncsetup/drive.go b/internal/syncsetup/drive.go index be0588dcd6..42b57e7bc7 100644 --- a/internal/syncsetup/drive.go +++ b/internal/syncsetup/drive.go @@ -3,7 +3,10 @@ package syncsetup import ( "context" "errors" + "os" + "path/filepath" "sync" + "sync/atomic" "time" "github.com/Agent-Field/codeaf/internal/blobstore" @@ -44,15 +47,30 @@ type DriveOptions struct { } // 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 + workMu sync.Mutex // Admission and voluntary release cannot cross each other. + activity atomic.Uint64 + released atomic.Bool + committing atomic.Bool + releaseDone 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 @@ -65,7 +83,7 @@ type Drive struct { publicationDone func() ownership chan struct{} - 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 @@ -97,7 +115,14 @@ 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 } @@ -109,6 +134,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() @@ -127,7 +153,8 @@ 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 + started = true go func() { defer close(d.done) var work sync.WaitGroup @@ -191,26 +218,44 @@ 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) { - err := d.sync.withVault(func(v vaultsync.Syncer) error { +// 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 { + if err := ctx.Err(); err != nil { + return err + } + if !d.vaultMu.TryLock() { + return errStateBusy + } + defer d.vaultMu.Unlock() + err := d.sync.withVaultWithin(ctx, 0, func(v vaultsync.Syncer) error { if err := v.Capture(); err != nil { return err } + if err := ctx.Err(); err != nil { + return err + } return d.pushChanged(ctx, v) }, 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 { + fp, ok := keys.Fingerprint(d.sync.Home) + if !ok { + 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) @@ -226,7 +271,7 @@ 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) + ctx, cancel := context.WithTimeout(d.run, requestTimeout) defer cancel() d.syncVault(ctx) } @@ -582,7 +627,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. - held := lease.Expires > v.Now && lease.Device == d.sync.Device.ID() && lease.Fence == d.batcher.Fence() + held := !d.released.Load() && lease.Expires > v.Now && lease.Device == d.sync.Device.ID() && lease.Fence == d.batcher.Fence() if held { d.resumeViewer() } @@ -634,9 +679,11 @@ func (d *Drive) retireForAnotherHolder(ctx context.Context, held sendLease) { d.mu.Lock() d.lost = true d.mu.Unlock() - d.sealMu.Lock() + if err := lockDrive(ctx, &d.sealMu); err != nil { + return + } d.sealMu.Unlock() - if err := d.sync.forgetSendLease(d.cell.ID, held); err != nil { + if err := d.sync.forgetSendLeaseWithin(ctx, d.cell.ID, held); err != nil { d.notice(err.Error()) } d.batcher.Displaced(ctx) @@ -658,7 +705,9 @@ func (d *Drive) renewIdleLease(ctx context.Context, v directory.CellView, fence if lease.Expires != 0 && lease.Expires <= v.Now && lease.Device == d.sync.Device.ID() && lease.Fence == fence { // Close stops writers under the same lock before its release. A late // ownership read must never resurrect that released fence. - d.sealMu.Lock() + if err := lockDrive(ctx, &d.sealMu); err != nil { + return v, err + } d.mu.Lock() stopped := d.stopped d.mu.Unlock() @@ -666,10 +715,23 @@ func (d *Drive) renewIdleLease(ctx context.Context, v directory.CellView, fence d.sealMu.Unlock() return v, cellstore.ErrReadOnly } + if d.released.Load() { + d.sealMu.Unlock() + return v, nil + } renewed, err := d.sync.Dir.Heartbeat(ctx, d.directoryID, directory.Beat{Fence: fence, Pending: d.batcher.Pending()}) d.sealMu.Unlock() if errors.Is(err, directory.ErrFenceStale) { - d.becomeViewer(cellsync.Superseded{}) + // Admission must not inherit becomeViewer's background capture, + // receipt and naming waits after a refused renewal. + d.loseHold() + if fresh, readErr := d.sync.Dir.Cell(ctx, d.directoryID); readErr == nil { + if fresh.Cell.Lease.Expires > fresh.Now { + d.confirmHolder(ctx, fresh.Cell.Lease) + } else { + d.availableAfterWithin(ctx, d.sendLeaseSnapshot(), fresh.Cell.Lease) + } + } } else if err != nil && !cellsync.Unreachable(err) { d.available() } @@ -710,14 +772,32 @@ func (d *Drive) Gate() error { // uploaded goes up now. A chat that only views has nothing to upload. func (d *Drive) Idle() { if d.batcher != nil { + d.idleMu.Lock() + if d.idleClosed { + d.idleMu.Unlock() + return + } + if d.idleStop != nil { + d.idleStop() + } + run, stop := context.WithCancel(context.Background()) + d.idleStop = stop + activity := d.activity.Load() + d.idleWorkers.Add(1) + d.idleMu.Unlock() go func() { - ctx, cancel := context.WithTimeout(context.Background(), cellstore.SealWithin) + defer d.idleWorkers.Done() + defer stop() + ctx, cancel := context.WithTimeout(run, cellstore.SealWithin) defer cancel() if err := cellstore.Await(ctx, d.cell); err != nil { - d.notice(err.Error()) + if run.Err() == nil { + d.notice(err.Error()) + } return } d.batcher.Idle() + d.releaseWhenIdle(run, activity) }() } } @@ -740,8 +820,11 @@ var _ cellstore.Store = stamping{} // Seal implements cellstore.Store. func (s stamping) Seal(ctx context.Context, c cell.Cell, info cellstore.TurnInfo) (cellstore.Sealed, error) { d := s.drive - d.sealMu.Lock() + if err := lockDrive(ctx, &d.sealMu); err != nil { + return cellstore.Sealed{}, err + } defer d.sealMu.Unlock() + d.engine = s.engine if d.Gate() != nil { return cellstore.Sealed{}, cellstore.ErrReadOnly } @@ -763,7 +846,9 @@ func (s stamping) Seal(ctx context.Context, c cell.Cell, info cellstore.TurnInfo // Prepare screens only the current writer's files before its first call. func (s stamping) Prepare(ctx context.Context, c cell.Cell) error { d := s.drive - d.sealMu.Lock() + if err := lockDrive(ctx, &d.sealMu); err != nil { + return err + } defer d.sealMu.Unlock() if d.Gate() != nil || d.batcher == nil && !d.sync.isRemoved() { return nil @@ -779,13 +864,27 @@ func (d *Drive) Close(ctx context.Context) error { ctx, cancel := context.WithTimeout(ctx, cellstore.SealWithin) defer cancel() d.once.Do(func() { - pending := cellstore.Await(ctx, d.cell) - // 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() + if d.devices != nil { + defer d.devices.Close() + } + if d.cancel != nil { + stop := context.AfterFunc(ctx, d.cancel) + defer stop() + } d.mu.Lock() d.stopped = true d.mu.Unlock() + if err := d.stopIdle(ctx); err != nil { + d.closeErr = err + return + } + pending := cellstore.Await(ctx, d.cell) + // 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. + if err := lockDrive(ctx, &d.sealMu); err != nil { + d.closeErr = err + return + } d.sealMu.Unlock() if pending == nil { pending = cellstore.Await(ctx, d.cell) @@ -794,7 +893,6 @@ func (d *Drive) Close(ctx context.Context) error { d.closeErr = pending return } - stop := context.AfterFunc(ctx, d.cancel) if pending == nil && !d.sync.isRemoved() { // Join the publish before stopping its request. Canceling a committed // request can lose its reply and leave the next publish using an old head. @@ -803,8 +901,14 @@ func (d *Drive) Close(ctx context.Context) error { d.closeErr = pending } d.cancel() - <-d.done - stop() + select { + case <-d.done: + case <-ctx.Done(): + if d.closeErr == nil { + d.closeErr = ctx.Err() + } + return + } removed := d.sync.isRemoved() if removed { // Removal keeps this chat local. Only its local durability can fail. @@ -818,7 +922,7 @@ func (d *Drive) Close(ctx context.Context) error { d.mu.Lock() expected := sendLease{Cell: d.directoryID, Fence: d.recordedFence} d.mu.Unlock() - d.closeErr = d.sync.forgetSendLease(d.cell.ID, expected) + d.closeErr = d.sync.forgetSendLeaseWithin(ctx, d.cell.ID, expected) } if d.closeErr == nil && d.publicationDone != nil { d.publicationDone() diff --git a/internal/syncsetup/idle_release.go b/internal/syncsetup/idle_release.go new file mode 100644 index 0000000000..193b2c9851 --- /dev/null +++ b/internal/syncsetup/idle_release.go @@ -0,0 +1,244 @@ +package syncsetup + +import ( + "context" + "path/filepath" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/chatlist" +) + +// idleReleaseWithin bounds the final, upload-free ownership boundary. +const idleReleaseWithin = 250 * time.Millisecond + +func (d *Drive) SetIdleCheck(check func() bool) { + d.workMu.Lock() + d.idleCheck = check + d.workMu.Unlock() +} + +// BeginWork cancels preparation immediately. Only the short final release can +// be in flight, in which case admission must bypass its cached confirmation. +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() +} + +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 { + d.idleMu.Lock() + d.idleClosed = true + if d.idleStop != nil { + d.idleStop() + } + d.idleMu.Unlock() + 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.paired() { + select { + case <-ctx.Done(): + return + case <-d.done: + return + case <-d.devices.Changes(): + continue + } + } + 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 { + 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 || 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.sync.isRemoved() || Quiet(d.sync.Home) { + return nil, true, nil + } + if check != nil && !check() || d.batcher.Pending() != 0 { + return nil, false, nil + } + 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.syncVault(ctx); err != nil { + return nil, false, err + } + return proof, false, nil +} + +func (d *Drive) releaseIdle(parent context.Context, activity uint64) (bool, error) { + notify := false + 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() + if d.opt.OnViewer != nil { + d.opt.OnViewer(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 { + 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() + head, err := cellstore.Head(d.cell) + if err != nil || head == nil || head.Turn.ID != proof.Head || !proof.Unchanged(ctx) { + return false, err + } + 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() + d.recordedFence = 0 + notify = d.viewer != (Viewer{Line: chatlist.Available}) + d.viewer = Viewer{Line: chatlist.Available} + d.mu.Unlock() + d.released.Store(true) + return nil + }) + if err != nil || !released { + return false, err + } + d.releaseDone = true + return true, 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..1ab2e75724 --- /dev/null +++ b/internal/syncsetup/idle_release_test.go @@ -0,0 +1,311 @@ +package syncsetup + +import ( + "bytes" + "context" + "errors" + "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() + mustNil(t, h.b.putDevice(t.Context(), nameB)) + c := h.openA() + appendTo(t, transcriptOf(h.cell), `{"type":"message","role":"user","content":"save this chat"}`+"\n") + _, 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_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) { + 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..670d0d5206 --- /dev/null +++ b/internal/syncsetup/idle_review_test.go @@ -0,0 +1,326 @@ +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} + 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), 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/sendlease.go b/internal/syncsetup/sendlease.go index 025318df0d..a452fa7f12 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" @@ -53,16 +55,34 @@ func (s *Sync) SendLease(id string) (string, uint64, error) { } func (d *Drive) recordSendLease() error { + ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) + defer cancel() + return d.recordSendLeaseWithin(ctx) +} + +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() - defer d.mu.Unlock() if d.lost || d.viewer.Line != "" || fence == 0 || fence == d.recordedFence { + d.mu.Unlock() return nil } - if err := d.sync.recordSendLease(d.cell.ID, sendLease{Cell: d.directoryID, Fence: fence}); err != 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 } @@ -84,7 +104,13 @@ func (s *Sync) reconcileSendLease(id, remoteID string, view directory.CellView) } func (s *Sync) recordSendLease(id string, held sendLease) error { - lock, err := s.lockChat(id, "") + ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) + defer cancel() + return s.recordSendLeaseWithin(ctx, id, held) +} + +func (s *Sync) recordSendLeaseWithin(ctx context.Context, id string, held sendLease) error { + lock, err := s.lockChatWithin(ctx, id, "") if err != nil { return err } @@ -97,6 +123,12 @@ func (s *Sync) recordSendLease(id string, held sendLease) error { 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 } @@ -121,7 +153,13 @@ func (s *Sync) recordSendLease(id string, held sendLease) error { // 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, "") + ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) + defer cancel() + return s.forgetSendLeaseWithin(ctx, id, expected) +} + +func (s *Sync) forgetSendLeaseWithin(ctx context.Context, id string, expected sendLease) error { + lock, err := s.lockChatWithin(ctx, id, "") if err != nil { return err } @@ -152,6 +190,12 @@ func (s *Sync) forgetSendLease(id string, expected sendLease) error { } func (s *Sync) lockChat(id, suffix string) (*os.File, error) { + ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) + defer cancel() + return s.lockChatWithin(ctx, id, suffix) +} + +func (s *Sync) lockChatWithin(ctx context.Context, id, suffix string) (*os.File, error) { path, err := s.sendLeasePath(id) if err != nil { return nil, err @@ -163,9 +207,26 @@ func (s *Sync) lockChat(id, suffix string) (*os.File, error) { if err != nil { return nil, err } - if err := filelock.Lock(lock, true, false); err != nil { - lock.Close() - 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: + } } - return lock, nil } From ea945b51ae2b75a5754efae3646e1bf7cb5e0429 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 00:07:15 -0500 Subject: [PATCH 02/21] handoff: opening a released chat on a paired computer takes it without asking A chat whose hold was released or has lapsed is marked openable in the list, only for paired homes and only from the directory's own clock. Opening it fetches and takes it with a non-forcing conditional acquire, checked again at acquisition, so a holder that renews during the fetch keeps the chat. A live hold still offers Continue here. Co-Authored-By: Claude Opus 5.5 --- internal/chatlist/idle_open_test.go | 35 ++++++++ internal/chatlist/rows.go | 13 ++- internal/chatlist/same.go | 2 + internal/handoff/handoff.go | 9 +++ internal/handoff/idle_open_test.go | 74 +++++++++++++++++ internal/syncsetup/copy_filing.go | 52 ++++++++++++ internal/syncsetup/take.go | 121 ++++++++++++++++------------ 7 files changed, 252 insertions(+), 54 deletions(-) create mode 100644 internal/chatlist/idle_open_test.go create mode 100644 internal/handoff/idle_open_test.go create mode 100644 internal/syncsetup/copy_filing.go diff --git a/internal/chatlist/idle_open_test.go b/internal/chatlist/idle_open_test.go new file mode 100644 index 0000000000..fe4dc13cc1 --- /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, true}, + {"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/rows.go b/internal/chatlist/rows.go index 910cffe1d3..8c4a3890de 100644 --- a/internal/chatlist/rows.go +++ b/internal/chatlist/rows.go @@ -50,6 +50,9 @@ 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 is certified by this listing's active roster and lease expiry. + // Advisory offline presence alone cannot authorize automatic acquisition. + AutoOpen bool // Released distinguishes a real release from a revoked or offline holder. Released bool ReleasedAgo time.Duration @@ -129,10 +132,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 <= l.Now && 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/handoff/handoff.go b/internal/handoff/handoff.go index 411515c97a..1427bb1ddf 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 available chat without consenting to displace a live + // holder, including one that renewed while the incoming files were fetched. + 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 > view.Now { + 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..f29d734181 --- /dev/null +++ b/internal/handoff/idle_open_test.go @@ -0,0 +1,74 @@ +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 + 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_HolderRenewingDuringFetchWins(t *testing.T) { + w := newWorld(t) + a, b := w.device(devA), w.device(devB) + drive := a.start(map[string]string{"journal": "saved"}) + w.clock.Advance(directory.LeaseTTL + time.Second) + before := w.cellRec(chatID) + b.onFetc = func() { + _, err := a.dir.Heartbeat(t.Context(), chatID, directory.Beat{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 || got.Head != before.Head { + t.Fatalf("C12 renewed ownership changed: %+v", got) + } +} 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/take.go b/internal/syncsetup/take.go index 447047b98a..975f80b688 100644 --- a/internal/syncsetup/take.go +++ b/internal/syncsetup/take.go @@ -3,7 +3,6 @@ package syncsetup import ( "context" "errors" - "github.com/Agent-Field/codeaf/internal/fsreplace" "log" "os" "path/filepath" @@ -74,9 +73,10 @@ 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 } // Continuer builds the take side over eng, the engine every chat this machine @@ -110,14 +110,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}, - 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}, + 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}, } } @@ -133,6 +134,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) { @@ -178,6 +195,8 @@ 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 defer handoff.Arrive(root)() localCapture := handoff.Trace(c.opt.OnPhase, "local capture") if err := cellstore.Await(ctx, cell.Cell{Root: root}); err != nil { @@ -198,6 +217,9 @@ 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 > view.Now { + return Continued{}, directory.ErrLeaseHeld + } if seen != nil && !seen.Matches(view.Cell) { return Continued{}, directory.ErrLeaseHeld } @@ -327,7 +349,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.sync.Dir.Release(ctx, id, taken.Driving.Fence)) } taken.Driving.Cell = taken.Cell @@ -594,45 +616,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 @@ -713,7 +696,9 @@ 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() + if err := lockStateRound(ctx, within); 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. @@ -739,6 +724,36 @@ 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") + +// The process gate follows the same bounded rule as the cross-process lock. +// A stalled home-state upload cannot lend its lifetime to another caller. +func lockStateRound(ctx context.Context, within time.Duration) error { + if err := ctx.Err(); err != nil { + return err + } + if stateMu.TryLock() { + return nil + } + if within <= 0 { + return errStateBusy + } + ctx, cancel := context.WithTimeout(ctx, within) + defer cancel() + tick := time.NewTicker(vaultBusyRetry) + defer tick.Stop() + for { + select { + case <-ctx.Done(): + return errors.Join(errStateBusy, ctx.Err()) + case <-tick.C: + if stateMu.TryLock() { + return nil + } + } + } +} + // 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 From 264eda789226b5fb1931578360f25fb1eadb164d Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 00:07:28 -0500 Subject: [PATCH 03/21] tui3: a released window takes its chat back in place, and Home opens a free chat directly An ordinary message in the window that released the chat reacquires the hold through the work gate: no restore, no setup question, and local files such as node_modules stay. A Home row for a free chat pulls the newest saved head even when an older viewer of it is open here. Remote pre-send checks honour the caller's deadline. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/chatv3_draft_identity_test.go | 2 + cmd/codeaf/chatv3_layout.go | 3 +- cmd/codeaf/idle_walk_terminal_test.go | 154 ++++++++++++++++++ cmd/codeaf/syncdrive_live.go | 18 ++- cmd/codeaf/synctake.go | 26 ++- internal/remote/client.go | 7 +- internal/remote/work_admission_test.go | 24 +++ internal/tui3/home.go | 10 ++ internal/tui3/homepanel_continue.go | 28 +++- internal/tui3/homepanel_heldelsewhere.go | 15 +- internal/tui3/idle_open_test.go | 52 ++++++ internal/tui3/idle_send.go | 76 +++++++++ internal/tui3/idle_walk_test.go | 197 +++++++++++++++++++++++ 13 files changed, 588 insertions(+), 24 deletions(-) create mode 100644 cmd/codeaf/idle_walk_terminal_test.go create mode 100644 internal/tui3/idle_open_test.go create mode 100644 internal/tui3/idle_send.go create mode 100644 internal/tui3/idle_walk_test.go diff --git a/cmd/codeaf/chatv3_draft_identity_test.go b/cmd/codeaf/chatv3_draft_identity_test.go index c1be4bbff4..f08b57f663 100644 --- a/cmd/codeaf/chatv3_draft_identity_test.go +++ b/cmd/codeaf/chatv3_draft_identity_test.go @@ -13,6 +13,8 @@ func TestCopiedChatDoorsKeepDistinctStableDraftKeys(t *testing.T) { profile := t.TempDir() t.Setenv("CODEAF_HOME", profile) t.Setenv("CODEAF_PROFILE_DIR", profile) + // hostOptions starts profile writers; join them before TempDir removes it. + t.Cleanup(func() { stopPoolErrands(profile) }) fleet := onePipeFleet("", hostedClient(t)) t.Cleanup(fleet.closeAll) welcome := remote.Welcome{Version: remote.Version, ProfileDir: profile} diff --git a/cmd/codeaf/chatv3_layout.go b/cmd/codeaf/chatv3_layout.go index d447661ee1..5a61d80b71 100644 --- a/cmd/codeaf/chatv3_layout.go +++ b/cmd/codeaf/chatv3_layout.go @@ -843,6 +843,7 @@ 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) if drive != nil { + drive.setIdleCheck(watch.IdleReady) watch.OnTurnEnd = drive.Idle // the agent waits for the person: upload what is sealed now } seat, err := cellstore.SeatOver(class, c, place.Workspace, observerOf(machine), watch.Report, driveStore(drive), cellstore.WithNotices(watch.Note), cellstore.WithPipeline(), cellstore.WithWarmCapture()) @@ -852,7 +853,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)), machine, watch, claim } // sealTail seals what the chat wrote after the turn-end seal (titles, summaries, diff --git a/cmd/codeaf/idle_walk_terminal_test.go b/cmd/codeaf/idle_walk_terminal_test.go new file mode 100644 index 0000000000..4a849ffa54 --- /dev/null +++ b/cmd/codeaf/idle_walk_terminal_test.go @@ -0,0 +1,154 @@ +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) { + 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/syncdrive_live.go b/cmd/codeaf/syncdrive_live.go index 253c6c7137..8caee4c7b3 100644 --- a/cmd/codeaf/syncdrive_live.go +++ b/cmd/codeaf/syncdrive_live.go @@ -36,7 +36,9 @@ type liveDrive struct { retry *time.Timer retryWait time.Duration // 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 @@ -131,6 +133,7 @@ func (l *liveDrive) install(d *syncsetup.Drive, err error, ready chan struct{}) l.ready = nil l.drive, l.waiting, l.attempted = d, errors.Is(err, syncsetup.ErrNoIdentity) || errors.Is(err, syncsetup.ErrQuiet) || syncwords.NetworkFailure(err), true if d != nil { + d.SetIdleCheck(l.idleReady) d.CatchUp() l.retryWait = 0 } else if !l.closed && cellsync.Unreachable(err) { @@ -140,6 +143,19 @@ 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() + l.idleCheck = check + l.mu.Unlock() +} + +func (l *liveDrive) idleReady() bool { + l.mu.Lock() + check, surface, closed := l.idleCheck, l.surfaceIdle, l.closed + l.mu.Unlock() + return !closed && check != nil && (surface == nil || surface()) && check() +} + // 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() { diff --git a/cmd/codeaf/synctake.go b/cmd/codeaf/synctake.go index 7f6cabb000..5805dc561f 100644 --- a/cmd/codeaf/synctake.go +++ b/cmd/codeaf/synctake.go @@ -292,6 +292,24 @@ 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() + got, err := t.c.OpenAvailable(ctx, id, seen) + if err != nil { + return tui3.Taken{}, 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() + got, err := t.c.OpenAvailableWithWorkspaceRoots(ctx, id, seen, roots) + if err != nil { + return tui3.Taken{}, 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() got, err := t.c.TakeSeenWithWorkspaceRoots(ctx, id, seen, roots) @@ -327,12 +345,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. -func takeCopyBucket(origin string) string { - return home.Join("v3", "projects", "copy-"+encodeWorkspace(origin)) -} - // 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()) } @@ -377,7 +389,7 @@ func chatContinuer(s *syncsetup.Sync, notify func(string)) *syncsetup.Continuer DeviceName: devname.Name(s.Home), RootFor: func(id string) string { return takeRootForAt(s.Home, id) }, BucketFor: func(origin string) string { - return filepath.Join(s.Home, "v3", "projects", "copy-"+encodeWorkspace(origin)) + return takeCopyBucketAt(s.Home, origin) }, Notify: notify, }) diff --git a/internal/remote/client.go b/internal/remote/client.go index e4f0a80932..0c425fb555 100644 --- a/internal/remote/client.go +++ b/internal/remote/client.go @@ -1562,10 +1562,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 } 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/tui3/home.go b/internal/tui3/home.go index 891edb8591..1c82cb67d8 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -3126,6 +3126,16 @@ func (a *app) homeOpenLine(line homeLine) tea.Cmd { return nil } } + // A retained local handle can be a stale viewer. Resolve its shared row + // before the current-tab and already-open shortcuts can reuse old history. + if !a.homeTakeOpening && a.taker != nil && !a.hosted() && !a.machineRead.computerRemoved { + id := homeSessionID(line.row) + for _, row := range a.machineRead.rows { + if row.Cell == id && (heldElsewhere(row) || row.AutoOpen && (!row.Mine || a.machineRead.sendHolds[id].blocked)) { + return a.homeMachineEnter(homeLine{remote: &row}) + } + } + } if line.row.Transcript == a.file { a.rememberOpen(a.frontTabKey()) a.closeHome() diff --git a/internal/tui3/homepanel_continue.go b/internal/tui3/homepanel_continue.go index 94ac86042f..0e0e6ec3dc 100644 --- a/internal/tui3/homepanel_continue.go +++ b/internal/tui3/homepanel_continue.go @@ -74,6 +74,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) } @@ -155,11 +163,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, @@ -193,6 +198,11 @@ func (a *app) homeMachineEnter(line homeLine) tea.Cmd { a.home.say(continueAlreadyRunning, "") return nil } + if row.AutoOpen { + if _, ok := a.taker.(availableTaker); ok { + return a.takeRemoteState(homeTakenMsg{row: row}) + } + } return run(a, row, offer) } @@ -552,7 +562,11 @@ func (a *app) takeRemoteState(msg homeTakenMsg) tea.Cmd { } return 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 && msg.row.AutoOpen && len(msg.roots) > 0 { + msg.taken, msg.err = open.OpenAvailableWithWorkspaceRoots(ctx, msg.row.Cell, seen, msg.roots) + } else if open, ok := taker.(availableTaker); ok && msg.row.AutoOpen { + 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) @@ -876,7 +890,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 364491e286..7faec613ba 100644 --- a/internal/tui3/homepanel_heldelsewhere.go +++ b/internal/tui3/homepanel_heldelsewhere.go @@ -46,10 +46,11 @@ 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 { - file, draft string - host string - chips []chip - pastes []pasteChip + admissionErr error + file, draft string + host string + chips []chip + pastes []pasteChip // A local startup timeout is not a failed Home directory listing. writerWaitFailed bool // Displacement has already been explained by the notice that ended this wait. @@ -194,7 +195,7 @@ func (a *app) checkBeforeSendWith(resume func() tea.Cmd) tea.Cmd { break } } - return msg + return resumeIdleSend(ctx, agent, src, msg, blocked || hold.failed) } } @@ -256,7 +257,7 @@ func (a *app) afterOwnershipRead(msg ownershipReadMsg) tea.Cmd { // resumeOwnedSend spends only the submitted gesture after its fresh ownership // reading. A free chat can resume silently only from the same saved turn. func (a *app) resumeOwnedSend(msg ownershipReadMsg) tea.Cmd { - if msg.writerDisplaced { + if msg.writerDisplaced || a.refuseIdleSend(msg) { return nil } id := filepath.Base(session.DirOf(msg.file)) @@ -307,7 +308,7 @@ func (a *app) resumeOwnedSend(msg ownershipReadMsg) tea.Cmd { } return msg.resume() } - if !held.blocked && (row.Status == chatlist.Idle || row.Status == chatlist.Off) && row.Head != "" && row.Head == msg.head { + if !row.AutoOpen && !held.blocked && (row.Status == chatlist.Idle || row.Status == chatlist.Off) && row.Head != "" && row.Head == msg.head { if _, ok := a.taker.(conditionalTaker); ok { draft := a.mainComposer() return a.takeRemoteState(homeTakenMsg{row: row, draft: &draft, send: msg.resume}) diff --git a/internal/tui3/idle_open_test.go b/internal/tui3/idle_open_test.go new file mode 100644 index 0000000000..3a58327b52 --- /dev/null +++ b/internal/tui3/idle_open_test.go @@ -0,0 +1,52 @@ +package tui3 + +import ( + "context" + "strings" + "testing" + + "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) { + 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") + } + }) + } +} diff --git a/internal/tui3/idle_send.go b/internal/tui3/idle_send.go new file mode 100644 index 0000000000..683337db8d --- /dev/null +++ b/internal/tui3/idle_send.go @@ -0,0 +1,76 @@ +package tui3 + +import ( + "context" + "errors" + "path/filepath" + + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/session" +) + +// resumeIdleSend asks this open writer to reacquire in place. A matching head +// is evidence for admission, never permission to restore over its workspace. +func resumeIdleSend(ctx context.Context, agent Agent, source chatlist.Source, msg ownershipReadMsg, blocked bool) ownershipReadMsg { + if blocked || msg.head == "" || msg.reading.err != nil { + 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.AutoOpen || !row.Mine || row.Head != msg.head || row.Status != chatlist.Idle && row.Status != chatlist.Off { + continue + } + 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 { + 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 || remote != row.Cell || fence == 0 || fence < row.Fence || fence == row.Fence && (row.Released || !holding) { + msg.admissionErr = &executor.WorkRefusal{Line: chatlist.Available} + 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 = []chatlist.Row{{Cell: row.Cell, Status: chatlist.Here, Mine: true, Fence: fence, Head: row.Head}} + return msg + } + return msg +} + +func (a *app) refuseIdleSend(msg ownershipReadMsg) bool { + if msg.admissionErr == nil { + return false + } + var refusal *executor.WorkRefusal + if errors.As(msg.admissionErr, &refusal) { + a.note(refusal.Line) + a.blockDisplacedSend(refusal.Line) + } else { + a.note(messageKept(msg.host)) + } + return true +} diff --git a/internal/tui3/idle_walk_test.go b/internal/tui3/idle_walk_test.go new file mode 100644 index 0000000000..88ee146b71 --- /dev/null +++ b/internal/tui3/idle_walk_test.go @@ -0,0 +1,197 @@ +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) { + 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) { + 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) { + 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") + } + // The selected local row predates the news that B resumed work. Even + // this retained handle must honor the new live hold before reusing a tab. + 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("D2 opening an existing live viewer bypassed the person's choice") + } +} From ac27347a4b5527d0ff5e7c5620ea642d1684ead2 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 00:07:28 -0500 Subject: [PATCH 04/21] app: chat rows say when a chat can be opened here, and sends take a released chat back Rows carry an optional can_open. agents.open with a shared chat's id takes a free chat without the Continue-here step. Sends, queues, steers, retries and actions in a window that released its chat take the hold back first; app queues and actions count as work, so they keep the hold. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/app.go | 22 +- cmd/codeaf/app_chats.go | 1 + cmd/codeaf/app_idle_release_test.go | 32 ++- cmd/codeaf/app_take.go | 33 ++- cmd/codeaf/appconvo.go | 4 +- cmd/codeaf/appsync.go | 48 +++- cmd/codeaf/idle_handoff_contract_test.go | 285 +++++++++++++++++++++++ cmd/codeaf/idle_walk_test.go | 154 ++++++++++++ internal/appwire/chats.go | 1 + 9 files changed, 553 insertions(+), 27 deletions(-) create mode 100644 cmd/codeaf/idle_handoff_contract_test.go create mode 100644 cmd/codeaf/idle_walk_test.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 6c638632a7..cf9581748f 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -1700,7 +1700,25 @@ 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. + b.chats.mu.Lock() + row, found := b.chats.byAgent[id] + if !found { + for _, candidate := range b.chats.rows { + if candidate.Cell == id { + row, found = candidate, true + break + } + } + } + b.chats.mu.Unlock() + if found && row.CanOpen && !row.Here { + return b.takeAgent(appwire.TakeParams{Cell: row.Cell, Seen: row.Seen}, true) + } + return b.openAgent(id, 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 @@ -2375,7 +2393,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. diff --git a/cmd/codeaf/app_chats.go b/cmd/codeaf/app_chats.go index 61a397a9e4..e8e6c6aa65 100644 --- a/cmd/codeaf/app_chats.go +++ b/cmd/codeaf/app_chats.go @@ -333,6 +333,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 = r.AutoOpen && r.Head != "" if service, ok := s.Dir.(interface{ HeadTakes() bool }); ok && !service.HeadTakes() { row.Seen.Head = "" } diff --git a/cmd/codeaf/app_idle_release_test.go b/cmd/codeaf/app_idle_release_test.go index 654e15bf17..904be1ab8d 100644 --- a/cmd/codeaf/app_idle_release_test.go +++ b/cmd/codeaf/app_idle_release_test.go @@ -176,6 +176,11 @@ func appIdleReleaseI1I2(t *testing.T, viewed bool, stopped ...bool) { a.locked(func() { idle = a.agents[agent.ID].live != nil && !a.agents[agent.ID].live.running }) return idle }) + appSyncWait(t, "paired idle hold released before sweeping", func() bool { + view, err := own.Dir.Cell(t.Context(), agent.ID) + return err == nil && view.Cell.Lease.Expires == 0 + }) + row = idleReleaseRow(t, wire, agent.ID, func(r appwire.ChatRow) bool { return r.Status == "idle" }) var transcript string if leftover { // Model the old engine's mismatch after its final checkpoint: the @@ -257,12 +262,8 @@ func appIdleReleaseI1I2(t *testing.T, viewed bool, stopped ...bool) { if err != nil { t.Fatal(err) } - wantReleases := int32(1) - if viewed { - wantReleases = 0 - } live := v.Cell.Lease.Expires > v.Now - if live != viewed || v.Cell.Lease.Fence != row.Seen.Fence || acquires.Load() != 0 || releases.Load() != wantReleases { + if live || v.Cell.Lease.Fence != row.Seen.Fence || acquires.Load() != 0 || releases.Load() != 0 { t.Fatalf("I1/I2 period %d: lease=%+v acquires=%d releases=%d", period+1, v.Cell.Lease, acquires.Load(), releases.Load()) } if leftover { @@ -272,10 +273,13 @@ func appIdleReleaseI1I2(t *testing.T, viewed bool, stopped ...bool) { } } if viewed { - appSyncWait(t, "I1 open idle hold to renew beyond its original deadline", func() bool { - v, err := own.Dir.Cell(t.Context(), agent.ID) - return err == nil && v.Cell.Lease.Expires > before.Cell.Lease.Expires - }) + // Keeping the open conversation no longer keeps its paired idle hold. + // The release keeps the same fence until the next work admission. + var kept bool + a.locked(func() { kept = a.agents[agent.ID].live != nil }) + if !kept || before.Cell.Lease.Expires != 0 { + t.Fatal("I1 open reader was closed or still held its idle lease") + } if acquires.Load() != 0 || releases.Load() != 0 { t.Fatalf("I1 renewal acquired=%d released=%d", acquires.Load(), releases.Load()) } @@ -288,10 +292,7 @@ func appIdleReleaseI1I2(t *testing.T, viewed bool, stopped ...bool) { } } peer.mu.Unlock() - wantChanges := 1 - if viewed { - wantChanges = 0 - } + wantChanges := 0 if changes != wantChanges { t.Fatalf("I2 idle chat emitted %d changes, want %d", changes, wantChanges) } @@ -309,7 +310,7 @@ func appIdleReleaseI1I2(t *testing.T, viewed bool, stopped ...bool) { for range 2 { time.Sleep(250 * time.Millisecond) v, err := own.Dir.Cell(t.Context(), agent.ID) - if err != nil || v.Cell.Lease.Fence != row.Seen.Fence || acquires.Load() != 0 || releases.Load() != 1 { + if err != nil || v.Cell.Lease.Fence != row.Seen.Fence || acquires.Load() != 0 || releases.Load() != 0 { t.Fatalf("B1/B3 restart acquired: lease=%+v acquires=%d releases=%d err=%v", v.Cell.Lease, acquires.Load(), releases.Load(), err) } } @@ -326,9 +327,6 @@ func appIdleReleaseI1I2(t *testing.T, viewed bool, stopped ...bool) { } appTurnDoneFor(t, rec, turn) wantFence, wantAcquires := row.Seen.Fence+1, int32(1) - if viewed { - wantFence, wantAcquires = row.Seen.Fence, 0 - } newRow := idleReleaseRow(t, wire, agent.ID, func(r appwire.ChatRow) bool { return r.Seen.Fence == wantFence && r.Seen.Head != row.Seen.Head }) if acquires.Load() != wantAcquires || newRow.Holder == nil || newRow.Holder.ID != own.Device.ID() { t.Fatalf("I1 send: row=%+v acquires=%d", newRow, acquires.Load()) diff --git a/cmd/codeaf/app_take.go b/cmd/codeaf/app_take.go index 1d341a555c..d97a394fec 100644 --- a/cmd/codeaf/app_take.go +++ b/cmd/codeaf/app_take.go @@ -62,6 +62,10 @@ func appTakeWords(words string) string { return words } func (b *appBackend) TakeAgent(p appwire.TakeParams) (appwire.Agent, error) { + return b.takeAgent(p, false) +} + +func (b *appBackend) takeAgent(p appwire.TakeParams, available bool) (appwire.Agent, error) { if p.Cell == "" || p.Seen.Device == "" { return appwire.Agent{}, appwire.Errorf(appwire.CodeBadParams, "a chat and its seen computer are required") } @@ -95,6 +99,9 @@ func (b *appBackend) TakeAgent(p appwire.TakeParams) (appwire.Agent, error) { return appwire.Agent{}, appTakeError(err) } seen := &directory.LeaseExpectation{Device: p.Seen.Device, Fence: p.Seen.Fence, Head: p.Seen.Head} + if available && view.Cell.Lease.Expires > view.Now { + return appwire.Agent{}, appAvailableRefusal(ctx, s, view.Cell.Lease) + } if !seen.Matches(view.Cell) { return appwire.Agent{}, appTakeError(directory.ErrLeaseHeld) } @@ -120,7 +127,7 @@ func (b *appBackend) TakeAgent(p appwire.TakeParams) (appwire.Agent, error) { if busy { return appwire.Errorf(appwire.CodeBusy, "Finish this chat's work before continuing it here.") } - if view.Cell.Lease.Device != s.Device.ID() { + if available || view.Cell.Lease.Device != s.Device.ID() { b.release(existing) } return nil @@ -129,8 +136,19 @@ func (b *appBackend) TakeAgent(p appwire.TakeParams) (appwire.Agent, error) { return appwire.Agent{}, err } } - got, err := chatContinuer(s, nil).TakeSeen(ctx, p.Cell, seen) + continuer := chatContinuer(s, nil) + var got syncsetup.Continued + if available { + got, err = continuer.OpenAvailable(ctx, p.Cell, seen) + } else { + got, err = continuer.TakeSeen(ctx, p.Cell, seen) + } if err != nil { + 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{}, appTakeError(err) } // The continuer's already-here shortcut may observe a local acquisition @@ -164,7 +182,7 @@ func (b *appBackend) TakeAgent(p appwire.TakeParams) (appwire.Agent, error) { location.Seen.Head = "" } b.located(a, location) - agent, err := b.OpenAgent(id) + agent, err := b.openAgent(id, false) if err != nil { return appwire.Agent{}, appTakeError(err) } @@ -196,3 +214,12 @@ func (b *appBackend) TakeAgent(p appwire.TakeParams) (appwire.Agent, error) { } return agent, nil } + +func appAvailableRefusal(ctx context.Context, s *syncsetup.Sync, lease directory.Lease) error { + name := chatlist.DeviceName(nil, lease.Device, nil) + if listing, err := s.Dir.List(ctx); err == nil { + key := directory.MetadataKey(s.Identity.CellKey()) + name = chatlist.DeviceName(listing.Devices, lease.Device, func(sealed string) (string, error) { return directory.OpenName(key, sealed) }) + } + return appLocationRefusal(&appwire.Location{Holder: &appwire.LocationHolder{ID: lease.Device, Name: name}, Line: chatlist.Superseded(name)}) +} diff --git a/cmd/codeaf/appconvo.go b/cmd/codeaf/appconvo.go index d179f30e98..c1ae5c169b 100644 --- a/cmd/codeaf/appconvo.go +++ b/cmd/codeaf/appconvo.go @@ -166,7 +166,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 @@ -345,7 +345,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 6d15c5fff3..f9a6aa75c5 100644 --- a/cmd/codeaf/appsync.go +++ b/cmd/codeaf/appsync.go @@ -42,6 +42,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 = &driveNotices{ firstRun: b.syncStarted, say: func(c cell.Cell, line string) { b.syncFact(c, appSyncFact{line: line}) }, @@ -56,6 +57,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. @@ -293,11 +314,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/idle_handoff_contract_test.go b/cmd/codeaf/idle_handoff_contract_test.go new file mode 100644 index 0000000000..0a27dff017 --- /dev/null +++ b/cmd/codeaf/idle_handoff_contract_test.go @@ -0,0 +1,285 @@ +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) *idleAppContract { + t.Helper() + celltest.RequireEngine(t) + relay := e2WireRelay(t) + 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 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_walk_test.go b/cmd/codeaf/idle_walk_test.go new file mode 100644 index 0000000000..d394b5410b --- /dev/null +++ b/cmd/codeaf/idle_walk_test.go @@ -0,0 +1,154 @@ +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) + } + 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 == got.Taken.Driving.Fence + } + } + 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/internal/appwire/chats.go b/internal/appwire/chats.go index cf6438fd73..5b3f31f42d 100644 --- a/internal/appwire/chats.go +++ b/internal/appwire/chats.go @@ -15,6 +15,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"` From 5dad3aed9dc056d92cf1b781574877315ac1f220 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 00:07:28 -0500 Subject: [PATCH 05/21] synctake: a chat's copy folder keeps one bounded name however often it moves The copy bucket was "copy-" plus the origin's full encoded path, so a chat moving back and forth nested the name and failed with 'file name too long' on the third hop. A chat that already has a local root keeps it, an origin that is itself a copy reuses its own name, and longer names are capped at 240 bytes with a stable hash. Existing short names are unchanged. This predates the idle hand-off, which makes repeated moves common. (The filing code moved to internal/syncsetup/copy_filing.go in the previous commits.) Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/copy_bucket.go | 41 ++++++++++ cmd/codeaf/copy_bucket_native_test.go | 111 ++++++++++++++++++++++++++ cmd/codeaf/copy_bucket_test.go | 51 ++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 cmd/codeaf/copy_bucket.go create mode 100644 cmd/codeaf/copy_bucket_native_test.go create mode 100644 cmd/codeaf/copy_bucket_test.go diff --git a/cmd/codeaf/copy_bucket.go b/cmd/codeaf/copy_bucket.go new file mode 100644 index 0000000000..3dcfd1b08f --- /dev/null +++ b/cmd/codeaf/copy_bucket.go @@ -0,0 +1,41 @@ +package main + +import ( + "crypto/sha256" + "fmt" + "path/filepath" + "strings" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/home" +) + +// 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 { + name := "copy-" + encodeWorkspace(origin) + for at := filepath.Clean(origin); at != filepath.Dir(at); at = filepath.Dir(at) { + parent := filepath.Dir(at) + if strings.HasPrefix(filepath.Base(at), "copy-") && filepath.Base(parent) == "projects" && filepath.Base(filepath.Dir(parent)) == "v3" { + name = filepath.Base(at) + break + } + } + if len(name) <= copyBucketBytes { + return name + } + suffix := fmt.Sprintf("-%x", sha256.Sum256([]byte(name)))[:17] + prefix := name[:copyBucketBytes-len(suffix)] + for !utf8.ValidString(prefix) { + prefix = prefix[:len(prefix)-1] + } + return prefix + suffix +} + +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..faf4c55a84 --- /dev/null +++ b/cmd/codeaf/copy_bucket_native_test.go @@ -0,0 +1,111 @@ +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) + 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).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..30f6f684d1 --- /dev/null +++ b/cmd/codeaf/copy_bucket_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/home" +) + +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) + } + origin = filepath.Join(bucket, "01J0000000000000000000000A", "work") + } + }) + } +} +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) + } + } +} From f2d40e5a313d53c1bc2ec49d79528a6cb8ca7489 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 00:07:28 -0500 Subject: [PATCH 06/21] manual: an idle chat moves with you between paired computers Describe idle release, opening a free chat directly, take-back in the same window, and that a running chat still asks. Add the change entry. Co-Authored-By: Claude Opus 5.5 --- .../0000-idle-chat-releases-to-relay.md | 29 ++++++++++ .../continuing-a-chat-on-another-computer.md | 58 +++++++++++++++---- internal/manual/chat/desktop-sync.md | 21 +++++-- internal/manual/chat/home.md | 4 +- .../chat/use-this-on-another-computer.md | 16 ++++- internal/manual/idle_handoff_test.go | 26 +++++++++ 6 files changed, 135 insertions(+), 19 deletions(-) create mode 100644 docs/changes/unreleased/0000-idle-chat-releases-to-relay.md create mode 100644 internal/manual/idle_handoff_test.go diff --git a/docs/changes/unreleased/0000-idle-chat-releases-to-relay.md b/docs/changes/unreleased/0000-idle-chat-releases-to-relay.md new file mode 100644 index 0000000000..1315a6aa24 --- /dev/null +++ b/docs/changes/unreleased/0000-idle-chat-releases-to-relay.md @@ -0,0 +1,29 @@ +--- +kind: changed +title: Paired idle chats release their hold after upload and available chats open here automatically +pr: 0 +surface: [chat, engine, app, remote, docs] +invalidates: + - "An open idle chat previously kept renewing its computer's hold. In a paired set it now releases after all saved state has reached the relay." + - "Selecting a released or lapsed remote chat previously required Continue here. Paired terminal opens now acquire it automatically without displacing a live holder." +--- + +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. 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. The +app engine exposes available shared chats through its open door; desktop +clients that only preview them need to call that door to use automatic opening. + +Ordinary messages 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. 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 c5f35379cf..e177c501bb 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. In a paired set, a released or lapsed chat opens here automatically. A chat with a live hold 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,29 @@ 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 + +With two or more paired computers, an idle chat gives up its hold after its +saved turns, journal, working files, shared memory and settings have reached the +relay. 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. + +Select a released or lapsed chat on terminal Home to bring its latest saved +state here without a Continue-here question. The original computer can be off. +If another computer still has a live hold, including an older build that keeps +idle holds, Continue here remains your choice. Existing local edits use the +ordinary kept-work rules during transfer. + +Sending new work in the original idle window takes the unchanged saved chat +back before recording your words or calling a model. If another computer has +taken it, the window names that computer and refuses new work. 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. +Homes with sync off, no pairing or only one computer keep their existing +behavior. + ## 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 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. @@ -29,6 +52,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. + ## Where an older chat's copy lives — the .moving folder, README.txt, can I delete it An older chat's copy is kept in a folder called `.moving` beside the original, in codeaf's state folder for that project. The name is historical: nothing in it is a move in progress. It holds the live copies this build writes to, so deleting it deletes those chats. The folder carries a `README.txt` that says so. The originals beside it stay untouched for older codeaf builds. @@ -83,9 +115,9 @@ If unpublished work needs to be kept separately, the number of kept turns counts 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 `. The hold alone does not establish that work is running there. One that a device let go of, or that went off because its lid closed, says ` offline`. +Home lists chats from your other devices. On paired computers, press enter on a released or lapsed chat to bring its latest saved copy here and open it directly. There is no Continue-here card. A fully saved idle chat releases within a few seconds; older builds may keep their idle hold. -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 live hold says `held on ` and still asks before a move. The hold alone does not establish that work is running there. For a chat running on that computer, the card offers **Move here**. A device that has stopped answering may say ` offline` while its hold remains live: offline alone does not make the chat free. Enter offers the existing Continue-here choice and explains 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 @@ -218,7 +250,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. A released or lapsed paired chat opens directly; a live hold 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. @@ -229,8 +261,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. A released or lapsed paired chat opens directly; a live hold 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. @@ -238,8 +269,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. A released or lapsed paired chat opens directly; a live hold 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 @@ -253,7 +283,9 @@ need another upload before the move. 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. +On paired computers, opening a released or lapsed 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 on another computer still asks for Continue here. + +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, 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. @@ -269,7 +301,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 hold is released or lapsed, opening the paired chat brings it here directly. + +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 @@ -417,7 +451,7 @@ Yes. Directory requests from older builds keep the existing 1 MiB request limit, ## 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. A released or lapsed paired chat opens directly; a live hold 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 @@ -442,7 +476,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. Current builds open a free paired chat directly; an unchanged expired hold last held here also resumes without a question. Run `codeaf doctor --fix` to repair what is safe. It prints one line per repair: diff --git a/internal/manual/chat/desktop-sync.md b/internal/manual/chat/desktop-sync.md index fe84d06451..10a01c8f72 100644 --- a/internal/manual/chat/desktop-sync.md +++ b/internal/manual/chat/desktop-sync.md @@ -156,10 +156,23 @@ Completed turns are shared while the chat stays open, including after a move. ## Does an idle desktop chat keep its hold on the relay -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. +With two or more paired computers, a fully saved idle chat releases its relay +hold within a few seconds, 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. Release waits for +the journal, working files, shared memory and settings to reach the relay. +Rebuildable files keep their existing transfer exclusions. + +New work in that window takes the unchanged chat back before recording it or +calling a model. If another computer took it, the existing moved notice and +held-elsewhere refusal name that computer. A released window stays read-only +when ownership cannot be confirmed. A home with only one computer keeps its +existing hold and renewal behavior. + +The engine can open a released or lapsed shared chat here without a +Continue-here confirmation. App versions that only preview shared chats still +offer Continue here in that preview. A chat another computer actively holds +continues to require that choice, including idle chats on older builds. 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. diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 27ec5213e9..bf48ff5ef6 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -306,7 +306,7 @@ happened. The row says where the chat is only where that tells you something: ## Why a chat says held on build-box or build-box offline -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 two words apart: `held on build-box` says that computer still holds the chat, for example during work 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 is `offline` still asks before Continue here; the card says how many turns may still be there. Offline alone does not make the chat free. On paired computers a released or lapsed chat opens here directly with its latest saved copy and no Continue-here card, including when its older viewer tab is already open. 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 @@ -340,7 +340,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?** +On paired computers a released or lapsed chat opens directly with its latest saved copy. If the row still has a live hold, whether it says ` offline` or `held on `, 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 diff --git a/internal/manual/chat/use-this-on-another-computer.md b/internal/manual/chat/use-this-on-another-computer.md index 7902ed80fa..8b3e33cef1 100644 --- a/internal/manual/chat/use-this-on-another-computer.md +++ b/internal/manual/chat/use-this-on-another-computer.md @@ -41,7 +41,21 @@ 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 ownership confirmation is older than two seconds. Back-to-back submissions reuse that confirmation. The ownership read waits at most three quarters of a second. Sending cancels idle handoff preparation immediately and uses the same ownership deadline. Slow capture or upload does not extend that wait. A task also confirms ownership again after slow preparation before it starts. If the relay is slow or unreachable, a window that still holds its chat keeps working locally and does not ask again for 30 seconds. A window that released its idle hold must confirm ownership again before accepting work; if that check fails, your work stays unsent. + +With two or more paired computers, a fully saved idle chat releases its hold +within a few seconds. Its window stays open as a reader. A running turn, queued +or steering work, tasks, jobs, unsaved writes or failed uploads keep the hold. +Selecting a released or lapsed chat on terminal Home brings the latest saved +copy here automatically; a live hold still offers Continue here. Sending in the +original window takes the unchanged chat back in place if nobody else has taken it. +Ordinary messages, shell work, queueing, steering, retries and tasks use the same +ownership gate. This does not restore files or show a moved/setup card; local +ignored install folders stay intact. A newer saved head leaves this window a +reader until you open the latest chat from Home. +The journal, working files, shared memory and settings must reach the relay +before release; rebuildable folders remain excluded under the usual rules. +Sync off, unpaired and single-computer homes keep their existing behavior. 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. diff --git a/internal/manual/idle_handoff_test.go b/internal/manual/idle_handoff_test.go new file mode 100644 index 0000000000..6dddbc1072 --- /dev/null +++ b/internal/manual/idle_handoff_test.go @@ -0,0 +1,26 @@ +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 or lapsed chat opens directly"}, + {"Why a chat says held on build-box or build-box offline", "released or lapsed 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") { + found = true + break + } + } + if !found { + t.Errorf("%q must retrieve the free-open and live-hold distinction", probe.question) + } + } +} From eab23686c6d630f7520dd3365140695895902de9 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 00:20:56 -0500 Subject: [PATCH 07/21] app, terminal: the new lock sections unlock from a defer The lock-defer law keeps an absorbed panic from becoming a deadlock. The open-row lookup and the live drive's idle checks now read under a helper that unlocks from a defer. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/app.go | 27 ++++++++++++++++----------- cmd/codeaf/syncdrive_live.go | 13 +++++++++---- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index cf9581748f..9f019b8fa9 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -1703,23 +1703,28 @@ func appSendUnsure(err error) bool { 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. - b.chats.mu.Lock() - row, found := b.chats.byAgent[id] - if !found { - for _, candidate := range b.chats.rows { - if candidate.Cell == id { - row, found = candidate, true - break - } - } - } - b.chats.mu.Unlock() + 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 // ([appwire.WarmOpener]). The check holds the agent's gate, as a close's mark diff --git a/cmd/codeaf/syncdrive_live.go b/cmd/codeaf/syncdrive_live.go index 8caee4c7b3..34873dab91 100644 --- a/cmd/codeaf/syncdrive_live.go +++ b/cmd/codeaf/syncdrive_live.go @@ -145,17 +145,22 @@ func (l *liveDrive) install(d *syncsetup.Drive, err error, ready chan struct{}) func (l *liveDrive) setIdleCheck(check func() bool) { l.mu.Lock() + defer l.mu.Unlock() l.idleCheck = check - l.mu.Unlock() } func (l *liveDrive) idleReady() bool { - l.mu.Lock() - check, surface, closed := l.idleCheck, l.surfaceIdle, l.closed - l.mu.Unlock() + 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() { From 7aa12fafd2876418bf8663728cf0a207e42253c7 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 00:26:23 -0500 Subject: [PATCH 08/21] docs: number the change entry for #2287 Co-Authored-By: Claude Opus 5.5 --- ...t-releases-to-relay.md => 2287-idle-chat-releases-to-relay.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/changes/unreleased/{0000-idle-chat-releases-to-relay.md => 2287-idle-chat-releases-to-relay.md} (100%) diff --git a/docs/changes/unreleased/0000-idle-chat-releases-to-relay.md b/docs/changes/unreleased/2287-idle-chat-releases-to-relay.md similarity index 100% rename from docs/changes/unreleased/0000-idle-chat-releases-to-relay.md rename to docs/changes/unreleased/2287-idle-chat-releases-to-relay.md From 614c142ad8239ad47dbf6828286ffb6b92dc94b3 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 00:31:38 -0500 Subject: [PATCH 09/21] docs: the change entry names #2287 Co-Authored-By: Claude Opus 5.5 --- docs/changes/unreleased/2287-idle-chat-releases-to-relay.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changes/unreleased/2287-idle-chat-releases-to-relay.md b/docs/changes/unreleased/2287-idle-chat-releases-to-relay.md index 1315a6aa24..94e9cc665a 100644 --- a/docs/changes/unreleased/2287-idle-chat-releases-to-relay.md +++ b/docs/changes/unreleased/2287-idle-chat-releases-to-relay.md @@ -1,7 +1,7 @@ --- kind: changed title: Paired idle chats release their hold after upload and available chats open here automatically -pr: 0 +pr: 2287 surface: [chat, engine, app, remote, docs] invalidates: - "An open idle chat previously kept renewing its computer's hold. In a paired set it now releases after all saved state has reached the relay." From 942993a724e08a50f8b40fc2ee912e2571bbd69f Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 03:23:56 -0500 Subject: [PATCH 10/21] syncsetup: an idle terminal chat releases again, and a late notice never undoes a take-back The terminal seals through the warm native daemon, whose capture plan is metadata-only, so the saved-state proof refused it and the hold was renewed forever. Certification now asks for a complete standalone plan; ordinary sealing stays on the daemon. A lost optimistic comparison or a competing local vault round retries at the quiet cadence. Ownership notices carry a version, so a release notice delivered after a newer acquisition is dropped. A held writer whose fresh ownership read fails keeps working offline, as before. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/syncdrive_live.go | 6 + internal/cellstore/savedstate.go | 17 +- internal/cellstore/sealwatch.go | 10 + internal/cellsync/batch.go | 15 +- internal/cellsync/ownership.go | 24 ++- internal/session/idle_handoff.go | 2 +- internal/session/session.go | 2 + internal/syncsetup/drive.go | 212 ++++++++++----------- internal/syncsetup/idle_r5_notify_test.go | 102 ++++++++++ internal/syncsetup/idle_r5_offline_test.go | 116 +++++++++++ internal/syncsetup/idle_r5_setting_test.go | 57 ++++++ internal/syncsetup/idle_r5_state_test.go | 35 ++++ internal/syncsetup/idle_r6_retry_test.go | 45 +++++ internal/syncsetup/idle_release.go | 47 +++-- internal/syncsetup/idle_review_test.go | 8 +- internal/syncsetup/ownership_notice.go | 40 ++++ 16 files changed, 595 insertions(+), 143 deletions(-) create mode 100644 internal/syncsetup/idle_r5_notify_test.go create mode 100644 internal/syncsetup/idle_r5_offline_test.go create mode 100644 internal/syncsetup/idle_r5_setting_test.go create mode 100644 internal/syncsetup/idle_r5_state_test.go create mode 100644 internal/syncsetup/idle_r6_retry_test.go create mode 100644 internal/syncsetup/ownership_notice.go diff --git a/cmd/codeaf/syncdrive_live.go b/cmd/codeaf/syncdrive_live.go index 34873dab91..1681d2d5f3 100644 --- a/cmd/codeaf/syncdrive_live.go +++ b/cmd/codeaf/syncdrive_live.go @@ -256,6 +256,12 @@ func (l *liveDrive) Gate() error { return nil } +func (l *liveDrive) SavedMetadata() { + if d := l.started(); d != nil { + d.SavedMetadata() + } +} + // Idle uploads what is sealed, once a drive side exists. func (l *liveDrive) Idle() { if d := l.current(); d != nil { diff --git a/internal/cellstore/savedstate.go b/internal/cellstore/savedstate.go index 13980164b2..fe1f3da1be 100644 --- a/internal/cellstore/savedstate.go +++ b/internal/cellstore/savedstate.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/json" + "errors" "fmt" "io" "io/fs" @@ -15,6 +16,11 @@ import ( "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") + // 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 @@ -36,7 +42,14 @@ func (e Engine) SavedState(ctx context.Context, c cell.Cell, extra ...string) (* if err := e.composeExtra(c); err != nil { return nil, err } - raw, err := e.do(ctx, c, e.cellDir(c), capturePlanOp{}) + // 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: e.Run} + } + raw, err := scope.do(ctx, c, e.cellDir(c), capturePlanOp{}) if err != nil { return nil, err } @@ -90,7 +103,7 @@ func (e Engine) SavedState(ctx context.Context, c cell.Cell, extra ...string) (* } s.Changes = append(s.Changes, stateChanges...) if changed := s.changed(ctx); changed != "" { - return nil, fmt.Errorf("save check: files changed during comparison: %s", changed) + return nil, fmt.Errorf("%w: %s", ErrSavedStateChanged, changed) } return s, nil } diff --git a/internal/cellstore/sealwatch.go b/internal/cellstore/sealwatch.go index 30aa696770..93a254b62c 100644 --- a/internal/cellstore/sealwatch.go +++ b/internal/cellstore/sealwatch.go @@ -39,6 +39,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() // OnTail, when set, is called once the record has been quiet for Quiet after // writes that came behind the turn-end seal. The door seals the tree and asks // the sync side to upload, exactly as it does at the end of a turn, so there @@ -212,6 +214,14 @@ func (w *SealWatch) sealTail() { } } +// 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() diff --git a/internal/cellsync/batch.go b/internal/cellsync/batch.go index e38f78697d..45cf35acfc 100644 --- a/internal/cellsync/batch.go +++ b/internal/cellsync/batch.go @@ -381,16 +381,7 @@ func (b *Batcher) Close(ctx context.Context) error { b.mu.Lock() b.closing = true b.mu.Unlock() - if err := lockOwnership(ctx, &b.flushMu); err != nil { - // Cancellation also stops the drive's active publisher. Briefly join - // its outcome so the closing error retains upload/local provenance, - // without inheriting an unresponsive publisher's lifetime. - join, cancel := context.WithTimeout(context.WithoutCancel(ctx), 50*time.Millisecond) - defer cancel() - if lockOwnership(join, &b.flushMu) != nil { - return err - } - } + b.flushMu.Lock() defer b.flushMu.Unlock() // A release is the cross-computer clean-quit signal. Publishing must finish // first; otherwise another home could automatically open an older head. @@ -563,9 +554,7 @@ func (b *Batcher) Displaced(ctx context.Context) { // flush in flight, and does nothing if that flush already branched. When the // branch fails the state stays stale and the next flush retries it. func (b *Batcher) lost(ctx context.Context, seen Driving) { - if lockOwnership(ctx, &b.flushMu) != nil { - return - } + b.flushMu.Lock() defer b.flushMu.Unlock() if b.isClosing() { return diff --git a/internal/cellsync/ownership.go b/internal/cellsync/ownership.go index e0f1b2e717..97478a266c 100644 --- a/internal/cellsync/ownership.go +++ b/internal/cellsync/ownership.go @@ -20,14 +20,20 @@ func (b *Batcher) Lease() (id string, fence uint64) { // 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) { - 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 + 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() } - defer b.renewMu.Unlock() d, pending, _ := b.view() if b.isClosing() || pending != 0 || d.Head != seen.Cell.Head || seen.Cell.Lease.Expires > seen.Now { return false, nil @@ -49,7 +55,9 @@ func (b *Batcher) Reacquire(ctx context.Context, seen directory.CellView) (bool, b.renewalSince = time.Time{} b.mu.Unlock() b.renewalChanged() - b.leaseChanged.fire() + if idle { + b.leaseChanged.fire() + } return true, nil } diff --git a/internal/session/idle_handoff.go b/internal/session/idle_handoff.go index a0a450ae57..0bfe1be839 100644 --- a/internal/session/idle_handoff.go +++ b/internal/session/idle_handoff.go @@ -35,7 +35,7 @@ func (a *Agent) idleForHandoff() bool { graph = a.config.tasker } a.mu.Unlock() - if !idle || a.taskStarting.Load() != 0 || a.titleJobs.pending.Load() != 0 || a.memoryJobs.pending.Load() != 0 || a.orchestrateWorkers.pending.Load() != 0 { + 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() { diff --git a/internal/session/session.go b/internal/session/session.go index bfc37b412d..b779b733b9 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -2742,6 +2742,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 diff --git a/internal/syncsetup/drive.go b/internal/syncsetup/drive.go index 42b57e7bc7..dac6a167a4 100644 --- a/internal/syncsetup/drive.go +++ b/internal/syncsetup/drive.go @@ -50,27 +50,32 @@ type DriveOptions struct { // 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 { - workMu sync.Mutex // Admission and voluntary release cannot cross each other. - activity atomic.Uint64 - released atomic.Bool - committing atomic.Bool - releaseDone 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 + noticeMu sync.Mutex + noticeVersion atomic.Uint64 + idleNotices atomic.Bool + idleRequested atomic.Bool + idlePrepared atomic.Bool + workMu sync.Mutex // Admission and voluntary release cannot cross each other. + activity atomic.Uint64 + released atomic.Bool + 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 @@ -220,22 +225,30 @@ func (d *Drive) note(sealed cellstore.Sealed) { // vault first, since a save to it is a change of the vault the fingerprint must // 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 { +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 err := ctx.Err(); err != nil { return err } - if !d.vaultMu.TryLock() { - return errStateBusy + 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.withVaultWithin(ctx, 0, func(v vaultsync.Syncer) error { + err := d.sync.withVaultRound(ctx, 0, idle, func(v vaultsync.Syncer) error { if err := v.Capture(); err != nil { return err } if err := ctx.Err(); err != nil { return err } - return d.pushChanged(ctx, v) + return d.pushChanged(ctx, v, idle) }, d.files) if err != nil { return err @@ -247,9 +260,12 @@ func (d *Drive) syncVault(ctx context.Context) error { // 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 { +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. } @@ -271,7 +287,12 @@ 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(d.run, 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) } @@ -531,10 +552,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 preserve the pending save before // announcing displacement. A routine ownership check does no fsync (C28). if d.batcher != nil { @@ -679,11 +699,8 @@ func (d *Drive) retireForAnotherHolder(ctx context.Context, held sendLease) { d.mu.Lock() d.lost = true d.mu.Unlock() - if err := lockDrive(ctx, &d.sealMu); err != nil { - return - } - d.sealMu.Unlock() - if err := d.sync.forgetSendLeaseWithin(ctx, d.cell.ID, held); err != nil { + func() { d.sealMu.Lock(); defer d.sealMu.Unlock() }() + if err := d.sync.forgetSendLease(d.cell.ID, held); err != nil { d.notice(err.Error()) } d.batcher.Displaced(ctx) @@ -702,12 +719,13 @@ func (d *Drive) sendLeaseSnapshot() sendLease { // renew; an explicit release or a different fence still needs continuation. func (d *Drive) renewIdleLease(ctx context.Context, v directory.CellView, fence uint64) (directory.CellView, error) { lease := v.Cell.Lease + if d.released.Load() { + return v, nil + } if lease.Expires != 0 && lease.Expires <= v.Now && lease.Device == d.sync.Device.ID() && lease.Fence == fence { // Close stops writers under the same lock before its release. A late // ownership read must never resurrect that released fence. - if err := lockDrive(ctx, &d.sealMu); err != nil { - return v, err - } + d.sealMu.Lock() d.mu.Lock() stopped := d.stopped d.mu.Unlock() @@ -715,23 +733,10 @@ func (d *Drive) renewIdleLease(ctx context.Context, v directory.CellView, fence d.sealMu.Unlock() return v, cellstore.ErrReadOnly } - if d.released.Load() { - d.sealMu.Unlock() - return v, nil - } renewed, err := d.sync.Dir.Heartbeat(ctx, d.directoryID, directory.Beat{Fence: fence, Pending: d.batcher.Pending()}) d.sealMu.Unlock() if errors.Is(err, directory.ErrFenceStale) { - // Admission must not inherit becomeViewer's background capture, - // receipt and naming waits after a refused renewal. - d.loseHold() - if fresh, readErr := d.sync.Dir.Cell(ctx, d.directoryID); readErr == nil { - if fresh.Cell.Lease.Expires > fresh.Now { - d.confirmHolder(ctx, fresh.Cell.Lease) - } else { - d.availableAfterWithin(ctx, d.sendLeaseSnapshot(), fresh.Cell.Lease) - } - } + d.becomeViewer(cellsync.Superseded{}) } else if err != nil && !cellsync.Unreachable(err) { d.available() } @@ -771,35 +776,38 @@ func (d *Drive) Gate() error { // Idle says the agent stopped to wait for the person: what is sealed and not yet // uploaded goes up now. A chat that only views has nothing to upload. func (d *Drive) Idle() { - if d.batcher != nil { - d.idleMu.Lock() - if d.idleClosed { - d.idleMu.Unlock() + 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 } - if d.idleStop != nil { - d.idleStop() - } - run, stop := context.WithCancel(context.Background()) - d.idleStop = stop - activity := d.activity.Load() - d.idleWorkers.Add(1) - d.idleMu.Unlock() - go func() { - defer d.idleWorkers.Done() - defer stop() - ctx, cancel := context.WithTimeout(run, cellstore.SealWithin) - defer cancel() - if err := cellstore.Await(ctx, d.cell); err != nil { - if run.Err() == nil { - d.notice(err.Error()) - } - return - } - d.batcher.Idle() - d.releaseWhenIdle(run, activity) - }() + d.batcher.Idle() + d.scheduleIdle(activity) + }() +} + +func (d *Drive) scheduleIdle(activity uint64) { + d.idleMu.Lock() + defer d.idleMu.Unlock() + if d.idleClosed || activity != d.activity.Load() { + 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 @@ -820,9 +828,7 @@ var _ cellstore.Store = stamping{} // Seal implements cellstore.Store. func (s stamping) Seal(ctx context.Context, c cell.Cell, info cellstore.TurnInfo) (cellstore.Sealed, error) { d := s.drive - if err := lockDrive(ctx, &d.sealMu); err != nil { - return cellstore.Sealed{}, err - } + d.sealMu.Lock() defer d.sealMu.Unlock() d.engine = s.engine if d.Gate() != nil { @@ -846,9 +852,7 @@ func (s stamping) Seal(ctx context.Context, c cell.Cell, info cellstore.TurnInfo // Prepare screens only the current writer's files before its first call. func (s stamping) Prepare(ctx context.Context, c cell.Cell) error { d := s.drive - if err := lockDrive(ctx, &d.sealMu); err != nil { - return err - } + d.sealMu.Lock() defer d.sealMu.Unlock() if d.Gate() != nil || d.batcher == nil && !d.sync.isRemoved() { return nil @@ -864,16 +868,15 @@ 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() } - if d.cancel != nil { - stop := context.AfterFunc(ctx, d.cancel) - defer stop() - } - d.mu.Lock() - d.stopped = true - d.mu.Unlock() if err := d.stopIdle(ctx); err != nil { d.closeErr = err return @@ -881,11 +884,13 @@ func (d *Drive) Close(ctx context.Context) error { pending := cellstore.Await(ctx, d.cell) // 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. - if err := lockDrive(ctx, &d.sealMu); err != nil { - d.closeErr = err - return - } - 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) } @@ -893,6 +898,7 @@ func (d *Drive) Close(ctx context.Context) error { d.closeErr = pending return } + stop := context.AfterFunc(ctx, d.cancel) if pending == nil && !d.sync.isRemoved() { // Join the publish before stopping its request. Canceling a committed // request can lose its reply and leave the next publish using an old head. @@ -901,14 +907,8 @@ func (d *Drive) Close(ctx context.Context) error { d.closeErr = pending } d.cancel() - select { - case <-d.done: - case <-ctx.Done(): - if d.closeErr == nil { - d.closeErr = ctx.Err() - } - return - } + <-d.done + stop() removed := d.sync.isRemoved() if removed { // Removal keeps this chat local. Only its local durability can fail. @@ -922,7 +922,7 @@ func (d *Drive) Close(ctx context.Context) error { d.mu.Lock() expected := sendLease{Cell: d.directoryID, Fence: d.recordedFence} d.mu.Unlock() - d.closeErr = d.sync.forgetSendLeaseWithin(ctx, d.cell.ID, expected) + d.closeErr = d.sync.forgetSendLease(d.cell.ID, expected) } if d.closeErr == nil && d.publicationDone != nil { d.publicationDone() diff --git a/internal/syncsetup/idle_r5_notify_test.go b/internal/syncsetup/idle_r5_notify_test.go new file mode 100644 index 0000000000..87eee3157d --- /dev/null +++ b/internal/syncsetup/idle_r5_notify_test.go @@ -0,0 +1,102 @@ +package syncsetup + +import ( + "context" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/chatlist" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +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) { + 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..f37e8498f1 --- /dev/null +++ b/internal/syncsetup/idle_r5_offline_test.go @@ -0,0 +1,116 @@ +package syncsetup + +import ( + "bytes" + "context" + "errors" + "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" + "os" + "sync/atomic" + "testing" + "time" +) + +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), "{\"type\":\"message\",\"role\":\"user\",\"content\":\"seed held chat\"}\n") + 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..aa36d0144f --- /dev/null +++ b/internal/syncsetup/idle_r5_setting_test.go @@ -0,0 +1,57 @@ +package syncsetup + +import ( + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/session" + "testing" + "time" +) + +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_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_release.go b/internal/syncsetup/idle_release.go index 193b2c9851..e5a2256931 100644 --- a/internal/syncsetup/idle_release.go +++ b/internal/syncsetup/idle_release.go @@ -2,6 +2,7 @@ package syncsetup import ( "context" + "errors" "path/filepath" "sync" "time" @@ -14,9 +15,12 @@ import ( const idleReleaseWithin = 250 * time.Millisecond func (d *Drive) SetIdleCheck(check func() bool) { - d.workMu.Lock() - d.idleCheck = check - d.workMu.Unlock() + 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) + d.scheduleIdle(d.activity.Load()) + } } // BeginWork cancels preparation immediately. Only the short final release can @@ -31,6 +35,14 @@ func (d *Drive) BeginWork() bool { return d.released.Load() || d.committing.Load() } +// 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.paired() { + d.scheduleIdle(d.activity.Load()) + } +} + func (d *Drive) IdleReleased() bool { return d.released.Load() } func lockDrive(ctx context.Context, mu *sync.Mutex) error { @@ -96,7 +108,9 @@ func (d *Drive) releaseWhenIdle(ctx context.Context, activity uint64) { if finished { return } - if err != nil { + 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) } } @@ -106,7 +120,7 @@ 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 || stopped + return d.idleCheck, activity != d.activity.Load() || d.releaseDone.Load() || stopped } func (d *Drive) profileFiles() []string { @@ -130,9 +144,16 @@ func (d *Drive) prepareIdle(ctx context.Context, activity uint64) (*cellstore.Sa if finished || d.sync.isRemoved() || Quiet(d.sync.Home) { return nil, true, nil } - if check != nil && !check() || d.batcher.Pending() != 0 { + // 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 } + d.idlePrepared.Store(true) if err := lockDrive(ctx, &d.sealMu); err != nil { return nil, false, err } @@ -157,7 +178,7 @@ func (d *Drive) prepareIdle(ctx context.Context, activity uint64) (*cellstore.Sa if err != nil || proof == nil { return nil, false, err } - if err := d.syncVault(ctx); err != nil { + if err := d.syncIdleVault(ctx); err != nil { return nil, false, err } return proof, false, nil @@ -165,14 +186,13 @@ func (d *Drive) prepareIdle(ctx context.Context, activity uint64) (*cellstore.Sa 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() - if d.opt.OnViewer != nil { - d.opt.OnViewer(Viewer{Line: chatlist.Available}) - } + d.ownershipNotice(context.Background(), version, Viewer{Line: chatlist.Available}) } }() prepare, cancel := context.WithTimeout(parent, requestTimeout) @@ -215,6 +235,11 @@ func (d *Drive) releaseIdle(parent context.Context, activity uint64) (bool, erro 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.mu.Unlock() d.released.Store(true) return nil @@ -222,7 +247,7 @@ func (d *Drive) releaseIdle(parent context.Context, activity uint64) (bool, erro if err != nil || !released { return false, err } - d.releaseDone = true + d.releaseDone.Store(true) return true, nil } diff --git a/internal/syncsetup/idle_review_test.go b/internal/syncsetup/idle_review_test.go index 670d0d5206..dcbc8b6ee0 100644 --- a/internal/syncsetup/idle_review_test.go +++ b/internal/syncsetup/idle_review_test.go @@ -24,7 +24,7 @@ func idleContractSession(t *testing.T, h *twoHomes, c *openChat, change ...func( 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} + 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 { @@ -45,7 +45,11 @@ func idleContractSession(t *testing.T, h *twoHomes, c *openChat, change ...func( 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), Seals: watch, + 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) 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() + } +} From 315cd1f7c40c77442b34a881d0063239fa9ad7be Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 03:23:56 -0500 Subject: [PATCH 11/21] syncsetup: ordinary home-state rounds wait for a competing flush again Only the idle-release vault step uses the non-blocking lock. An ordinary SyncState waits under its own context, as on the base. Co-Authored-By: Claude Opus 5.5 --- internal/syncsetup/confirm_work.go | 176 ++++++++++++++++++++++------- internal/syncsetup/sendlease.go | 100 ++++++++++++++-- internal/syncsetup/state.go | 2 +- internal/syncsetup/take.go | 40 ++----- 4 files changed, 237 insertions(+), 81 deletions(-) diff --git a/internal/syncsetup/confirm_work.go b/internal/syncsetup/confirm_work.go index 548b1a4ca9..ae08c27bbf 100644 --- a/internal/syncsetup/confirm_work.go +++ b/internal/syncsetup/confirm_work.go @@ -7,17 +7,61 @@ 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" ) +// 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.paired() && !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 { - return d.Gate() + if d.released.Load() { + return d.Gate() + } + return nil } confirmed, err := d.ConfirmWork(ctx) if err != nil { - return err + // 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 { @@ -33,16 +77,23 @@ func (d *Drive) ConfirmPreparedWork(ctx context.Context) error { // 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) { - d.BeginWork() - if err := lockDrive(ctx, &d.workMu); err != nil { - return false, err - } - defer d.workMu.Unlock() - d.mu.Lock() - stopped := d.stopped - d.mu.Unlock() - if stopped { - return false, cellstore.ErrReadOnly + 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() + stopped := func() bool { d.mu.Lock(); defer d.mu.Unlock(); return d.stopped }() + if stopped { + return false, cellstore.ErrReadOnly + } } held := d.sendLeaseSnapshot() if d.batcher == nil { @@ -69,17 +120,21 @@ func (d *Drive) ConfirmWork(ctx context.Context) (bool, error) { return false, nil } if lease.Expires <= v.Now { - d.availableAfterWithin(ctx, held, lease) + if idleAdmission { + d.availableAfterWithin(ctx, held, lease) + } else { + d.availableAfter(held, lease) + } // Never resume an older tree over work saved by another computer. head, err := cellstore.Head(d.cell) if err != nil || head == nil || head.Turn.ID != v.Cell.Head { - if err == nil && lease.Device != d.sync.Device.ID() { + if idleAdmission && err == nil && lease.Device != d.sync.Device.ID() { d.confirmHolder(ctx, lease) } return false, err } resumed, err := d.batcher.Reacquire(ctx, v) - if errors.Is(err, directory.ErrLeaseHeld) || errors.Is(err, directory.ErrFenceStale) { + if idleAdmission && (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) @@ -96,33 +151,39 @@ func (d *Drive) ConfirmWork(ctx context.Context) (bool, error) { } d.mu.Lock() d.viewer, d.lost = Viewer{}, false + version := d.noticeVersion.Add(1) d.mu.Unlock() - if err := d.recordSendLeaseWithin(ctx); err != nil { - d.available() + if err := d.confirmSendLease(ctx); err != nil { + d.availableAfterWithin(ctx, sendLease{}, directory.Lease{}) return false, err } d.released.Store(false) - d.releaseDone = false - if d.opt.OnDriving != nil { - d.opt.OnDriving() - } + d.releaseDone.Store(false) + d.ownershipNotice(ctx, version, Viewer{}) return true, nil } if lease.Device == d.sync.Device.ID() && lease.Fence == fence { + wasReleased := d.released.Load() if d.released.Load() && !d.batcher.ResumeHeld(ctx, v) { return false, nil } d.released.Store(false) - d.releaseDone = false - d.resumeViewer() - if err := d.recordSendLeaseWithin(ctx); err != nil { - d.available() - return false, err + d.releaseDone.Store(false) + d.resumeViewerWithin(ctx) + if wasReleased { + if err := d.recordSendLeaseWithin(ctx); err != nil { + d.availableAfterWithin(ctx, sendLease{}, directory.Lease{}) + return false, err + } } return true, nil } if lease.Device == d.sync.Device.ID() { - d.availableAfterWithin(ctx, held, lease) + if idleAdmission { + d.availableAfterWithin(ctx, held, lease) + } else { + d.availableAfter(held, lease) + } return false, nil } @@ -142,16 +203,15 @@ func (d *Drive) confirmHolder(ctx context.Context, lease directory.Lease) { } viewer := Viewer{ID: lease.Device, Name: name, Line: chatlist.Superseded(name)} d.mu.Lock() - if d.viewer == viewer { + if d.viewer == viewer || !d.idleNotices.Load() && d.viewer.Line != "" { d.mu.Unlock() 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() } @@ -170,7 +230,9 @@ func (d *Drive) confirmHolder(ctx context.Context, lease directory.Lease) { }() } -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 || d.released.Load() { d.mu.Unlock() @@ -178,9 +240,13 @@ func (d *Drive) resumeViewer() { } 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{}) } } @@ -195,9 +261,28 @@ func (d *Drive) available() { // An unchanged owner and fence keeps its receipt even when released, because a // heartbeat can still renew it. Readers hear the change after retirement finishes. func (d *Drive) availableAfter(held sendLease, reading directory.Lease) { - ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) - defer cancel() - d.availableAfterWithin(ctx, held, reading) + 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 held.Fence != 0 && (reading.Device != d.sync.Device.ID() || reading.Fence != held.Fence) { + if err := d.sync.forgetSendLease(d.cell.ID, held); err != nil { + d.notice(err.Error()) + } + } + if changed { + d.renewalChanged() + } + if changed && d.opt.OnViewer != nil { + d.opt.OnViewer(viewer) + } } func (d *Drive) availableAfterWithin(ctx context.Context, held sendLease, reading directory.Lease) { @@ -209,6 +294,10 @@ func (d *Drive) availableAfterWithin(ctx context.Context, held sendLease, readin } changed := d.viewer != viewer d.viewer = viewer + version := d.noticeVersion.Load() + if changed { + version = d.noticeVersion.Add(1) + } d.mu.Unlock() if held.Fence != 0 && (reading.Device != d.sync.Device.ID() || reading.Fence != held.Fence) { if err := d.sync.forgetSendLeaseWithin(ctx, d.cell.ID, held); err != nil { @@ -218,7 +307,16 @@ func (d *Drive) availableAfterWithin(ctx context.Context, held sendLease, readin if changed { d.renewalChanged() } - if changed && d.opt.OnViewer != nil { - d.opt.OnViewer(viewer) + if changed { + d.ownershipNotice(ctx, version, viewer) + } +} + +// An ordinary lapsed writer retains its original receipt path. The idle door +// uses the bounded writer because it also retires receipts at release. +func (d *Drive) confirmSendLease(ctx context.Context) error { + if d.idleNotices.Load() { + return d.recordSendLeaseWithin(ctx) } + return d.recordSendLease() } diff --git a/internal/syncsetup/sendlease.go b/internal/syncsetup/sendlease.go index a452fa7f12..677b7b9f85 100644 --- a/internal/syncsetup/sendlease.go +++ b/internal/syncsetup/sendlease.go @@ -55,9 +55,17 @@ func (s *Sync) SendLease(id string) (string, uint64, error) { } func (d *Drive) recordSendLease() error { - ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) - defer cancel() - return d.recordSendLeaseWithin(ctx) + fence := d.batcher.Fence() + d.mu.Lock() + defer d.mu.Unlock() + if d.lost || d.viewer.Line != "" || fence == 0 || fence == d.recordedFence { + return nil + } + if err := d.sync.recordSendLease(d.cell.ID, sendLease{Cell: d.directoryID, Fence: fence}); err != nil { + return err + } + d.recordedFence = fence + return nil } func (d *Drive) recordSendLeaseWithin(ctx context.Context) error { @@ -104,9 +112,39 @@ func (s *Sync) reconcileSendLease(id, remoteID string, view directory.CellView) } func (s *Sync) recordSendLease(id string, held sendLease) error { - ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) - defer cancel() - return s.recordSendLeaseWithin(ctx, id, held) + lock, err := s.lockChat(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 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 } func (s *Sync) recordSendLeaseWithin(ctx context.Context, id string, held sendLease) error { @@ -153,9 +191,34 @@ func (s *Sync) recordSendLeaseWithin(ctx context.Context, id string, held sendLe // forgetSendLease retires only this writer's fence, preserving a newer local owner. func (s *Sync) forgetSendLease(id string, expected sendLease) error { - ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) - defer cancel() - return s.forgetSendLeaseWithin(ctx, id, expected) + lock, err := s.lockChat(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) forgetSendLeaseWithin(ctx context.Context, id string, expected sendLease) error { @@ -190,9 +253,22 @@ func (s *Sync) forgetSendLeaseWithin(ctx context.Context, id string, expected se } func (s *Sync) lockChat(id, suffix string) (*os.File, error) { - ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) - defer cancel() - return s.lockChatWithin(ctx, id, suffix) + 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 + } + if err := filelock.Lock(lock, true, false); err != nil { + lock.Close() + return nil, err + } + return lock, nil } func (s *Sync) lockChatWithin(ctx context.Context, id, suffix string) (*os.File, error) { diff --git a/internal/syncsetup/state.go b/internal/syncsetup/state.go index f737b6639e..dd81d659bf 100644 --- a/internal/syncsetup/state.go +++ b/internal/syncsetup/state.go @@ -81,7 +81,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/take.go b/internal/syncsetup/take.go index 975f80b688..d4d0a9b765 100644 --- a/internal/syncsetup/take.go +++ b/internal/syncsetup/take.go @@ -696,7 +696,17 @@ 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 { - if err := lockStateRound(ctx, within); err != nil { + 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() @@ -726,34 +736,6 @@ func (s *Sync) withVaultWithin(ctx context.Context, within time.Duration, use fu var errStateBusy = errors.New("sync: another home-state exchange is in progress") -// The process gate follows the same bounded rule as the cross-process lock. -// A stalled home-state upload cannot lend its lifetime to another caller. -func lockStateRound(ctx context.Context, within time.Duration) error { - if err := ctx.Err(); err != nil { - return err - } - if stateMu.TryLock() { - return nil - } - if within <= 0 { - return errStateBusy - } - ctx, cancel := context.WithTimeout(ctx, within) - defer cancel() - tick := time.NewTicker(vaultBusyRetry) - defer tick.Stop() - for { - select { - case <-ctx.Done(): - return errors.Join(errStateBusy, ctx.Err()) - case <-tick.C: - if stateMu.TryLock() { - return nil - } - } - } -} - // 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 From b96dda3967407d1132d4ffcc91b8e39841dadf38 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 03:23:57 -0500 Subject: [PATCH 12/21] synctake: a copy folder is reused only for the exact copy root it names A nested project under a received copy keeps its own bucket, and the long-name hash is taken from the original path rather than the lossy encoded one. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/copy_bucket.go | 41 ++++++++++++++++++---- cmd/codeaf/copy_bucket_test.go | 8 +++++ cmd/codeaf/idle_r5_bucket_test.go | 58 +++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 7 deletions(-) create mode 100644 cmd/codeaf/idle_r5_bucket_test.go diff --git a/cmd/codeaf/copy_bucket.go b/cmd/codeaf/copy_bucket.go index 3dcfd1b08f..34a3e2cd67 100644 --- a/cmd/codeaf/copy_bucket.go +++ b/cmd/codeaf/copy_bucket.go @@ -8,6 +8,7 @@ import ( "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. @@ -16,18 +17,18 @@ 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) - for at := filepath.Clean(origin); at != filepath.Dir(at); at = filepath.Dir(at) { - parent := filepath.Dir(at) - if strings.HasPrefix(filepath.Base(at), "copy-") && filepath.Base(parent) == "projects" && filepath.Base(filepath.Dir(parent)) == "v3" { - name = filepath.Base(at) - break - } + if bucket := ownedCopyBucket(identity); bucket != "" { + name = bucket } if len(name) <= copyBucketBytes { return name } - suffix := fmt.Sprintf("-%x", sha256.Sum256([]byte(name)))[:17] + suffix := fmt.Sprintf("-%x", sha256.Sum256([]byte(identity)))[:17] prefix := name[:copyBucketBytes-len(suffix)] for !utf8.ValidString(prefix) { prefix = prefix[:len(prefix)-1] @@ -35,6 +36,32 @@ func copyBucketName(origin string) string { 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_test.go b/cmd/codeaf/copy_bucket_test.go index 30f6f684d1..d25f41ea48 100644 --- a/cmd/codeaf/copy_bucket_test.go +++ b/cmd/codeaf/copy_bucket_test.go @@ -8,6 +8,7 @@ import ( "unicode/utf8" "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" ) func TestCopyBucket_D3_TenAlternatingOriginsStayBoundedAndStable(t *testing.T) { @@ -35,7 +36,14 @@ func TestCopyBucket_D3_TenAlternatingOriginsStayBoundedAndStable(t *testing.T) { 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) + } } }) } diff --git a/cmd/codeaf/idle_r5_bucket_test.go b/cmd/codeaf/idle_r5_bucket_test.go new file mode 100644 index 0000000000..e7dc908b30 --- /dev/null +++ b/cmd/codeaf/idle_r5_bucket_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" + "os" + "path/filepath" + "strings" + "testing" +) + +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") + } +} From 1aebd7df374adddde4ef9439a7b0a69f1ad82638 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 03:23:57 -0500 Subject: [PATCH 13/21] tui3: a setting changed in a released terminal window retakes the hold first Adds the real-terminal test: openV3Agent and runSurface with the default native daemon, an on-disk relay and two homes, asserting release, in-place take-back and that effort and memory set after the last turn reach the other computer. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/chatv3_layout.go | 3 +- cmd/codeaf/idle_terminal_regression_test.go | 255 ++++++++++++++++++++ internal/tui3/effortchip.go | 6 + internal/tui3/home.go | 2 +- internal/tui3/idle_settings_test.go | 32 +++ internal/tui3/idle_walk_test.go | 8 +- internal/tui3/palette.go | 7 + 7 files changed, 307 insertions(+), 6 deletions(-) create mode 100644 cmd/codeaf/idle_terminal_regression_test.go create mode 100644 internal/tui3/idle_settings_test.go diff --git a/cmd/codeaf/chatv3_layout.go b/cmd/codeaf/chatv3_layout.go index 5a61d80b71..ba9e455a65 100644 --- a/cmd/codeaf/chatv3_layout.go +++ b/cmd/codeaf/chatv3_layout.go @@ -844,6 +844,7 @@ func v3SeatPending(place session.Place, say func(string), drives *driveBook) (ex drive, claim := drives.pendingDriveOf(c, cellstore.EngineFor(place.Workspace), watch.SyncReport) if drive != nil { drive.setIdleCheck(watch.IdleReady) + watch.OnMetadataWrite = drive.SavedMetadata watch.OnTurnEnd = drive.Idle // the agent waits for the person: upload what is sealed now } seat, err := cellstore.SeatOver(class, c, place.Workspace, observerOf(machine), watch.Report, driveStore(drive), cellstore.WithNotices(watch.Note), cellstore.WithPipeline(), cellstore.WithWarmCapture()) @@ -853,7 +854,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), drivePreparedGate(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/idle_terminal_regression_test.go b/cmd/codeaf/idle_terminal_regression_test.go new file mode 100644 index 0000000000..077de170f3 --- /dev/null +++ b/cmd/codeaf/idle_terminal_regression_test.go @@ -0,0 +1,255 @@ +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) { + 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"} { + 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) { + 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/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 1c82cb67d8..26d7141734 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -3131,7 +3131,7 @@ func (a *app) homeOpenLine(line homeLine) tea.Cmd { if !a.homeTakeOpening && a.taker != nil && !a.hosted() && !a.machineRead.computerRemoved { id := homeSessionID(line.row) for _, row := range a.machineRead.rows { - if row.Cell == id && (heldElsewhere(row) || row.AutoOpen && (!row.Mine || a.machineRead.sendHolds[id].blocked)) { + if row.Cell == id && row.AutoOpen && (!row.Mine || a.machineRead.sendHolds[id].blocked) { return a.homeMachineEnter(homeLine{remote: &row}) } } 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 index 88ee146b71..252b0278dc 100644 --- a/internal/tui3/idle_walk_test.go +++ b/internal/tui3/idle_walk_test.go @@ -186,12 +186,12 @@ func TestIdleOpen_C7_D2_LocalViewerKeepsLiveHoldChoice(t *testing.T) { if !found { t.Fatal("fixture did not expose the existing local row") } - // The selected local row predates the news that B resumed work. Even - // this retained handle must honor the new live hold before reusing a tab. + // 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("D2 opening an existing live viewer bypassed the person's choice") + 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/palette.go b/internal/tui3/palette.go index 8d80f9468a..7c1ec36b84 100644 --- a/internal/tui3/palette.go +++ b/internal/tui3/palette.go @@ -3107,6 +3107,13 @@ func (a *app) windowFor(id string) int { // saying so would keep compacting as if it were on the 128k one it started on. 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.model = a.agent.Model() if a.model == "" { a.model = id From dd8f90f390424f9bb8fdfb12817c8f5b0258d1ea Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 03:23:57 -0500 Subject: [PATCH 14/21] session: a persistent setting in a released window goes through the ownership door Effort, model choices, approval posture, places, task effort and a remembered note retake the hold or get the held-elsewhere refusal before they are saved, so a save is never acknowledged where it cannot reach the relay. Unpaired and unreleased windows keep their old path. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/syncdrive.go | 14 +++++ internal/executor/environment.go | 2 + internal/executor/gated.go | 31 +++++++++-- internal/executor/gated_test.go | 19 +++++++ internal/executor/watched.go | 2 + internal/remote/client.go | 26 ++++++++- internal/remote/effort.go | 29 ++++++++-- internal/remote/idle_settings_test.go | 48 +++++++++++++++++ internal/remote/server.go | 18 ++++++- internal/session/agent.go | 35 +++++++++++- internal/session/approvalposture.go | 5 ++ internal/session/conversation_models.go | 5 ++ internal/session/effort.go | 25 +++++++-- internal/session/idle_settings_test.go | 71 +++++++++++++++++++++++++ internal/session/memory.go | 5 ++ internal/session/model_identity.go | 5 ++ internal/session/placemeta.go | 4 +- internal/session/places.go | 15 ++++++ 18 files changed, 340 insertions(+), 19 deletions(-) create mode 100644 internal/remote/idle_settings_test.go create mode 100644 internal/session/idle_settings_test.go diff --git a/cmd/codeaf/syncdrive.go b/cmd/codeaf/syncdrive.go index 7e2e8b7565..1e84a3eee9 100644 --- a/cmd/codeaf/syncdrive.go +++ b/cmd/codeaf/syncdrive.go @@ -274,6 +274,20 @@ func driveWorkGate(d *liveDrive) func() error { } } +func driveSettingsGate(d *liveDrive) func() error { + if d == nil { + return nil + } + return func() error { + if started := d.started(); started != nil { + ctx, cancel := context.WithTimeout(context.Background(), driveWorkWithin) + defer cancel() + return started.ConfirmSettingsWork(ctx) + } + return nil + } +} + func drivePreparedGate(d *liveDrive) func() error { if d == nil { return nil diff --git a/internal/executor/environment.go b/internal/executor/environment.go index 8ad3f9570d..9b362e14b9 100644 --- a/internal/executor/environment.go +++ b/internal/executor/environment.go @@ -70,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 b029e11e45..d0697a29f4 100644 --- a/internal/executor/gated.go +++ b/internal/executor/gated.go @@ -26,7 +26,16 @@ func CheckPreparedWork(seat Seat) error { if gate, ok := seat.(interface{ CheckPreparedWork() error }); ok { return gate.CheckPreparedWork() } - return CheckWork(seat) + 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 @@ -43,11 +52,15 @@ func Gated(seat Seat, gate func() error, admission ...func() error) Seat { if len(admission) > 0 { work = admission[0] } - prepared := work + var prepared func() error if len(admission) > 1 { prepared = admission[1] } - return gated{Seat: seat, gate: gate, work: work, prepared: prepared} + 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 { @@ -55,6 +68,14 @@ type gated struct { 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 { @@ -73,7 +94,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, g.prepared) } +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 bb979d66ba..c9cbb36e7f 100644 --- a/internal/executor/gated_test.go +++ b/internal/executor/gated_test.go @@ -48,3 +48,22 @@ func TestIdleRelease_C2_C4_PreparedWorkBypassesCachedAdmission(t *testing.T) { } } } + +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 b89190226b..97c64e14c3 100644 --- a/internal/executor/watched.go +++ b/internal/executor/watched.go @@ -184,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/remote/client.go b/internal/remote/client.go index 0c425fb555..8417abb72e 100644 --- a/internal/remote/client.go +++ b/internal/remote/client.go @@ -89,6 +89,7 @@ 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 { + settingError atomic.Pointer[settingResult] // host keeps the typed destination as the fallback until the window chooses // its displayed name. Keeping the two apart preserves the connection route. host string @@ -2264,8 +2265,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..418cdf688e 100644 --- a/internal/remote/effort.go +++ b/internal/remote/effort.go @@ -1,6 +1,10 @@ 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 +106,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_settings_test.go b/internal/remote/idle_settings_test.go new file mode 100644 index 0000000000..b3a4833a4f --- /dev/null +++ b/internal/remote/idle_settings_test.go @@ -0,0 +1,48 @@ +package remote + +import ( + "errors" + "testing" + + "github.com/Agent-Field/codeaf/internal/executor" +) + +type refusedSettingsAgent struct { + *rungAgent + refusal *executor.WorkRefusal +} + +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") + } +} diff --git a/internal/remote/server.go b/internal/remote/server.go index 5a341d8c64..29db8dd51c 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -3148,7 +3148,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 @@ -3228,7 +3234,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/session/agent.go b/internal/session/agent.go index 84e796025b..18e54c5eb2 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -781,7 +781,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 @@ -1095,6 +1126,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 diff --git a/internal/session/approvalposture.go b/internal/session/approvalposture.go index 510949de50..107fe725c9 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/conversation_models.go b/internal/session/conversation_models.go index 4dc9de4761..5d33de0edf 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 d39c3f2abe..6d65db16e7 100644 --- a/internal/session/effort.go +++ b/internal/session/effort.go @@ -79,17 +79,27 @@ 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 }() // 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. @@ -128,6 +138,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_settings_test.go b/internal/session/idle_settings_test.go new file mode 100644 index 0000000000..619c379f28 --- /dev/null +++ b/internal/session/idle_settings_test.go @@ -0,0 +1,71 @@ +package session + +import ( + "errors" + "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") + } +} diff --git a/internal/session/memory.go b/internal/session/memory.go index ff8bd351d4..50879825d8 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) diff --git a/internal/session/model_identity.go b/internal/session/model_identity.go index 7bf1abcde8..da4291777a 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 3b6e718ce7..4ae2d23325 100644 --- a/internal/session/placemeta.go +++ b/internal/session/placemeta.go @@ -315,7 +315,9 @@ func (a *Agent) updateMeta(dir string, snapshot Meta, patch func(*Meta)) { return saveMetaLocked(dir, meta) }) if err == nil { - a.tellWrote() + if watch, ok := a.config.Seals.(interface{ MetadataWrote() }); ok { + watch.MetadataWrote() + } } } 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 From 0335dc04402ce88df457256497c9e54eca36f93f Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 03:23:57 -0500 Subject: [PATCH 15/21] app: settings in a released window retake the hold or are refused Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/app.go | 21 +++++++++++-- cmd/codeaf/idle_settings_test.go | 54 ++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 cmd/codeaf/idle_settings_test.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 9f019b8fa9..f94ece930f 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -3989,7 +3989,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 } @@ -4034,7 +4040,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() 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) + } +} From 2e3935d8f118653cd82092b62a84b7f2a001c579 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 03:23:57 -0500 Subject: [PATCH 16/21] manual: settings after an idle release, and offline work in a held chat Co-Authored-By: Claude Opus 5.5 --- .../unreleased/2287-idle-chat-releases-to-relay.md | 6 ++++++ .../chat/continuing-a-chat-on-another-computer.md | 12 ++++++++++++ 2 files changed, 18 insertions(+) diff --git a/docs/changes/unreleased/2287-idle-chat-releases-to-relay.md b/docs/changes/unreleased/2287-idle-chat-releases-to-relay.md index 94e9cc665a..e474986be8 100644 --- a/docs/changes/unreleased/2287-idle-chat-releases-to-relay.md +++ b/docs/changes/unreleased/2287-idle-chat-releases-to-relay.md @@ -27,3 +27,9 @@ Ordinary messages 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. + +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. 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 e177c501bb..c74e820709 100644 --- a/internal/manual/chat/continuing-a-chat-on-another-computer.md +++ b/internal/manual/chat/continuing-a-chat-on-another-computer.md @@ -500,3 +500,15 @@ presence; it does not guess which computers are online. ## Sync notices between older and current builds A current window understands the earlier outage and removed-computer notices too. It uses one outage line while local work continues, and points to `/pair` for recovery in a chat. A current engine keeps the earlier notice spelling for an older window, so that window's existing outage and removal handling still works. + +## Changing thinking or model settings after idle release + +In a paired set, changing this chat's 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. From 95c077ba4338612c90d875bf963006bda8c077ca Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 06:50:20 -0500 Subject: [PATCH 17/21] syncsetup: Close always stops an ordinary writer, and a take-back clears a stale release snapshot A canceled or expired Close budget skipped the base cleanup and left an ordinary writer running; Close now keeps the idle-join error and always runs the capture, stop, cancel and join it ran before. A zero send receipt from the release is discarded once a reacquire has cleared the viewer, so a send after take-back is no longer refused as "available to continue here". Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/idle_terminal_regression_test.go | 2 +- internal/syncsetup/confirm_work.go | 7 +- internal/syncsetup/drive.go | 8 +-- internal/syncsetup/idle_r8_close_test.go | 39 +++++++++++ .../syncsetup/idle_r8_observation_test.go | 70 +++++++++++++++++++ internal/syncsetup/idle_release.go | 15 ++-- 6 files changed, 130 insertions(+), 11 deletions(-) create mode 100644 internal/syncsetup/idle_r8_close_test.go create mode 100644 internal/syncsetup/idle_r8_observation_test.go diff --git a/cmd/codeaf/idle_terminal_regression_test.go b/cmd/codeaf/idle_terminal_regression_test.go index 077de170f3..73b455b291 100644 --- a/cmd/codeaf/idle_terminal_regression_test.go +++ b/cmd/codeaf/idle_terminal_regression_test.go @@ -182,7 +182,7 @@ func idleRealTerminal(t *testing.T, settings bool) { 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"} { + 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) } diff --git a/internal/syncsetup/confirm_work.go b/internal/syncsetup/confirm_work.go index ae08c27bbf..1725e149fc 100644 --- a/internal/syncsetup/confirm_work.go +++ b/internal/syncsetup/confirm_work.go @@ -13,7 +13,7 @@ import ( // 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.paired() && !d.released.Load() && !d.committing.Load() { + if !d.released.Load() && !d.committing.Load() { return nil } if !d.BeginWork() { @@ -288,7 +288,10 @@ func (d *Drive) availableAfter(held sendLease, reading directory.Lease) { func (d *Drive) availableAfterWithin(ctx context.Context, held sendLease, reading directory.Lease) { viewer := Viewer{Line: chatlist.Available} d.mu.Lock() - if held.Fence != 0 && held.Fence != d.recordedFence { + // 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 } diff --git a/internal/syncsetup/drive.go b/internal/syncsetup/drive.go index dac6a167a4..75881f43fd 100644 --- a/internal/syncsetup/drive.go +++ b/internal/syncsetup/drive.go @@ -877,11 +877,11 @@ func (d *Drive) Close(ctx context.Context) error { if d.devices != nil { defer d.devices.Close() } - if err := d.stopIdle(ctx); err != nil { - d.closeErr = err - return - } + 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. func() { 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_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_release.go b/internal/syncsetup/idle_release.go index e5a2256931..3c3bd1d195 100644 --- a/internal/syncsetup/idle_release.go +++ b/internal/syncsetup/idle_release.go @@ -65,12 +65,19 @@ func lockDrive(ctx context.Context, mu *sync.Mutex) error { // stopIdle closes admission to new idle attempts before joining canceled work. func (d *Drive) stopIdle(ctx context.Context) error { - d.idleMu.Lock() - d.idleClosed = true - if d.idleStop != nil { + 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 } - d.idleMu.Unlock() done := make(chan struct{}) go func() { d.idleWorkers.Wait(); close(done) }() select { From ce8e9004342b34c8535776cab5c73391f6735e91 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 06:50:20 -0500 Subject: [PATCH 18/21] session: a person's durable write in a released window retakes the hold first Rename, rewind, task rooms, deletes and merges, plan steering, autonomy, names, anchors, standing tree and remembered notes enter the same released-window door before they write, so none is acknowledged where it cannot reach the relay. Every other window returns to its old path before any network read. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/idle_r8_rename_test.go | 44 +++++++++++ internal/remote/idle_settings_test.go | 86 ++++++++++++++++++++++ internal/session/agent.go | 5 ++ internal/session/appnames.go | 5 ++ internal/session/autonomy.go | 5 ++ internal/session/compaction_facts.go | 5 ++ internal/session/idle_settings_test.go | 56 ++++++++++++++ internal/session/memory.go | 5 ++ internal/session/plandb_steer.go | 5 ++ internal/session/rewind.go | 20 +++++ internal/session/standingtree.go | 5 ++ internal/session/task_audit.go | 5 ++ internal/session/task_delete.go | 5 ++ internal/session/task_merge_round.go | 5 ++ internal/session/task_room.go | 5 ++ internal/session/task_workspace_apply.go | 10 +++ internal/session/tools_anchor_workspace.go | 5 ++ internal/syncsetup/idle_r8_rename_test.go | 44 +++++++++++ 18 files changed, 320 insertions(+) create mode 100644 cmd/codeaf/idle_r8_rename_test.go create mode 100644 internal/syncsetup/idle_r8_rename_test.go 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/internal/remote/idle_settings_test.go b/internal/remote/idle_settings_test.go index b3a4833a4f..c8a34cfa80 100644 --- a/internal/remote/idle_settings_test.go +++ b/internal/remote/idle_settings_test.go @@ -2,7 +2,10 @@ package remote import ( "errors" + "sync" + "sync/atomic" "testing" + "time" "github.com/Agent-Field/codeaf/internal/executor" ) @@ -12,6 +15,10 @@ type refusedSettingsAgent struct { 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 @@ -46,3 +53,82 @@ func TestIdleRelease_R5_1_RemoteSettingsKeepRefusalAndReplica(t *testing.T) { 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/session/agent.go b/internal/session/agent.go index 18e54c5eb2..011ccf5be5 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -2578,6 +2578,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/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/idle_settings_test.go b/internal/session/idle_settings_test.go index 619c379f28..7c5d203c0f 100644 --- a/internal/session/idle_settings_test.go +++ b/internal/session/idle_settings_test.go @@ -2,6 +2,7 @@ package session import ( "errors" + "path/filepath" "testing" "time" @@ -69,3 +70,58 @@ func TestIdleRelease_R5_1_SettingAdmissionRetainsTheHoldWhileBusy(t *testing.T) 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/memory.go b/internal/session/memory.go index 50879825d8..0af59a19ac 100644 --- a/internal/session/memory.go +++ b/internal/session/memory.go @@ -818,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/plandb_steer.go b/internal/session/plandb_steer.go index 5171b78c71..6967c1cc93 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) error { + a.settingsWrites.Add(1) + defer a.settingsWrites.Add(-1) + if err := a.CheckSettingsWork(); err != nil { + return err + } for _, notice := range a.historicalPlanNotices() { if planTaskID(notice.PlanTask) == planTaskID(id) { return errPlanEndedRun 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/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 73472fc8c8..ee12840cf5 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 48165c49d0..4e2c70e083 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 { return fmt.Errorf("no task %d in this session", id) 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_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/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") + } +} From ca395d09e1932d7921999459ff5347d75f0e2f32 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 06:50:20 -0500 Subject: [PATCH 19/21] cellstore: idle certification visits each path once, within fixed bounds Beyond 20,000 paths, 50,000 entries or 64 MiB of chat files the proof fails and the chat keeps its hold, as before this change. Co-Authored-By: Claude Opus 5.5 --- PERF.md | 18 ++++ internal/cellstore/idle_r8_cost_test.go | 136 ++++++++++++++++++++++++ internal/cellstore/savedstate.go | 121 ++++++++++++++++++--- 3 files changed, 263 insertions(+), 12 deletions(-) create mode 100644 internal/cellstore/idle_r8_cost_test.go diff --git a/PERF.md b/PERF.md index 73c6251f99..359e418efd 100644 --- a/PERF.md +++ b/PERF.md @@ -379,3 +379,21 @@ always coordinate through it, so no build leaves it out. On linux/amd64 it adds 3,379,200 bytes to the stripped binary (75,755,785 to 79,134,985 on the same dev base), and the cap moves by the same amount, from 75,811,848 to 79,191,048, so the 56,063 bytes of headroom the base had are kept. + +## 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/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/savedstate.go b/internal/cellstore/savedstate.go index fe1f3da1be..a97ce3e537 100644 --- a/internal/cellstore/savedstate.go +++ b/internal/cellstore/savedstate.go @@ -1,6 +1,7 @@ package cellstore import ( + "bytes" "context" "crypto/sha256" "encoding/json" @@ -9,8 +10,10 @@ import ( "io" "io/fs" "os" + "os/exec" "path" "path/filepath" + "sort" "strings" "github.com/Agent-Field/codeaf/internal/cell" @@ -21,6 +24,18 @@ import ( // 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 @@ -30,6 +45,7 @@ type SavedState struct { Changes []string files map[string]fs.FileInfo members map[string]string + entries int } // SavedState reads and compares outside admission. Extra files are the profile @@ -47,16 +63,22 @@ func (e Engine) SavedState(ctx context.Context, c cell.Cell, extra ...string) (* // walk; the standalone planner supplies that, while seals keep the daemon. scope := e if scope.Transport == nil { - scope.Transport = Spawn{Binary: e.Binary, Run: e.Run} + 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) { @@ -64,6 +86,10 @@ func (e Engine) SavedState(ctx context.Context, c cell.Cell, extra ...string) (* 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 } @@ -82,10 +108,15 @@ func (e Engine) SavedState(ctx context.Context, c cell.Cell, extra ...string) (* return nil, err } } - raw, err = e.plan(ctx, c, head.Turn.ID) + 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 @@ -122,7 +153,7 @@ func (e Engine) savedChatChanges(ctx context.Context, c cell.Cell, head string, defer os.RemoveAll(scratch) target := e.target(c) target.Tree, target.CellDir, target.Timeline = scratch, "", filepath.Base(scratch) - transport := Spawn{Binary: e.Binary, Run: e.Run} + transport := Spawn{Binary: e.Binary, Run: savedStateRunner(e.Run)} if _, err := transport.Do(ctx, target, indexScratchOp{}); err != nil { return nil, err } @@ -144,21 +175,25 @@ func (e Engine) savedChatChanges(ctx context.Context, c cell.Cell, head string, } 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))) + 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))) + saved, err := savedFile(ctx, filepath.Join(base, filepath.FromSlash(rel)), &remaining) if err != nil { return nil, err } @@ -169,7 +204,7 @@ func (e Engine) savedChatChanges(ctx context.Context, c cell.Cell, head string, return changes, nil } -func savedFile(ctx context.Context, physical string) (string, error) { +func savedFile(ctx context.Context, physical string, remaining *int64) (string, error) { info, err := os.Lstat(physical) if os.IsNotExist(err) { return "", nil @@ -184,6 +219,9 @@ func savedFile(ctx context.Context, physical string) (string, error) { 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 @@ -196,6 +234,10 @@ func savedFile(ctx context.Context, physical string) (string, error) { 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 @@ -210,31 +252,47 @@ 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, err := directoryNames(physical) + 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) (string, error) { - entries, err := os.ReadDir(physical) +func directoryNames(physical string, remaining int) (string, int, error) { + dir, err := os.Open(physical) if err != nil { - return "", err + 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(), nil + return names.String(), len(entries), nil } // Unchanged is a bounded metadata recheck; it never captures, reads file @@ -261,7 +319,7 @@ func (s *SavedState) changed(ctx context.Context) string { if !before.IsDir() { return physical } - if names, err := directoryNames(physical); err != nil || names != s.members[physical] { + if names, _, err := directoryNames(physical, savedStateEntries); err != nil || names != s.members[physical] { return physical } } @@ -271,3 +329,42 @@ func (s *SavedState) changed(ctx context.Context) string { } 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) +} From 8b0bfa66907e538f821d29a03838ee20c96b6259 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 06:50:20 -0500 Subject: [PATCH 20/21] app: a lent factory view keeps its model policy on the checked door Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/app_lend.go | 7 +++++++ cmd/codeaf/idle_r8_lent_test.go | 15 +++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 cmd/codeaf/idle_r8_lent_test.go 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/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) + } +} From e6dcd9fb31edaa9e8c9d76880cf9b0724c243156 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 06:50:20 -0500 Subject: [PATCH 21/21] manual: say sync service, and settings retake a released chat Co-Authored-By: Claude Opus 5.5 --- .../2287-idle-chat-releases-to-relay.md | 7 +++++ .../continuing-a-chat-on-another-computer.md | 26 ++++++++++++++++--- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/docs/changes/unreleased/2287-idle-chat-releases-to-relay.md b/docs/changes/unreleased/2287-idle-chat-releases-to-relay.md index e474986be8..5e75d4005a 100644 --- a/docs/changes/unreleased/2287-idle-chat-releases-to-relay.md +++ b/docs/changes/unreleased/2287-idle-chat-releases-to-relay.md @@ -33,3 +33,10 @@ 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. 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 c74e820709..e6ccce6af9 100644 --- a/internal/manual/chat/continuing-a-chat-on-another-computer.md +++ b/internal/manual/chat/continuing-a-chat-on-another-computer.md @@ -11,8 +11,8 @@ Older chats move through an independent copy. Once paired, codeaf prepares chats ## Idle chats release their hold — open on another paired computer, even when the first is off With two or more paired computers, an idle chat gives up its hold after its -saved turns, journal, working files, shared memory and settings have reached the -relay. The open window stays as a reader. A running turn, queued or steering +saved turns, journal, working files, shared memory and settings are saved by 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. @@ -503,8 +503,8 @@ A current window understands the earlier outage and removed-computer notices too ## Changing thinking or model settings after idle release -In a paired set, changing this chat's thinking, model, automatic model choice, -approval posture or attached folders in its released window first takes the +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. @@ -512,3 +512,21 @@ 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. 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 + +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.