Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/changes/unreleased/2469-own-idle-chat-send.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
kind: fixed
title: Typing into your own idle chat no longer says it's available to continue here
pr: 2469
surface: [app, remote]
invalidates: []
---

Sending a message into your own idle background chat opens it again when its
hold is still free. Previously, the old ownership watcher could mistake the
chat's own release for losing ownership. Send could then refuse with
`This chat is available to continue here.`

Closing now stops that watcher before releasing the hold, while still waiting
for the final upload to finish. Checks for another computer taking the chat
and recovery after a failed continuation keep their existing rules.
2 changes: 2 additions & 0 deletions internal/manual/chat/desktop-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,8 @@ ownership changes or repeat unchanged chat rows.
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.
Sending, queueing or retrying opens it through the normal ownership check.
Sending into your own released chat opens it again when its hold is still free;
its own idle release does not require Continue here.
Steering still needs a running turn. A task or job can also open it. Running
turns, unanswered questions, held crew members and kept services retain their
conversations. If another computer continues a chat, the existing Continue here
Expand Down
34 changes: 27 additions & 7 deletions internal/syncsetup/drive.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,16 @@ type Drive struct {
directoryID string
recordedFence uint64

cancel context.CancelFunc
done chan struct{}
once sync.Once
closeErr error
publicationDone func()
ownership chan struct{}
cancel context.CancelFunc
done chan struct{}
once sync.Once
closeErr error
publicationDone func()
watcherCancel context.CancelFunc
watcherDone chan struct{}
ownership chan struct{}
beforeOwnershipCheck func(context.Context) func()
afterReleaseInTests func()

sent string // fingerprint of the vault as last sent; touched by the flush loop and Close only

Expand Down Expand Up @@ -129,10 +133,17 @@ func (s *Sync) Drive(ctx context.Context, eng cellstore.Engine, c cell.Cell, opt
}
run, cancel := context.WithCancel(context.WithoutCancel(ctx))
d.cancel = cancel
watch, cancelWatch := context.WithCancel(run)
d.watcherCancel = cancelWatch
d.watcherDone = make(chan struct{})
go func() {
defer close(d.done)
var work sync.WaitGroup
work.Go(func() { d.followOwnership(run) })
work.Go(func() {
defer close(d.watcherDone)
defer cancelWatch()
d.followOwnership(watch)
})
_ = d.batcher.Run(run)
work.Wait()
}()
Expand Down Expand Up @@ -779,13 +790,22 @@ func (d *Drive) Close(ctx context.Context) error {
return
}
stop := context.AfterFunc(ctx, d.cancel)
// Stop ownership readings before our own release can look like displacement.
// The batcher keeps its run context until the final publish has joined.
if d.watcherCancel != nil {
d.watcherCancel()
<-d.watcherDone
}
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.
d.closeErr = d.batcher.Close(ctx)
} else {
d.closeErr = pending
}
if d.afterReleaseInTests != nil {
d.afterReleaseInTests()
}
d.cancel()
<-d.done
stop()
Expand Down
90 changes: 90 additions & 0 deletions internal/syncsetup/drive_own_release_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package syncsetup

import (
"context"
"sync"
"testing"
"time"

"github.com/Agent-Field/codeaf/internal/directory"
)

func TestDriveOwnReleaseDoesNotBecomeViewer(t *testing.T) {
released, checked := make(chan struct{}), make(chan struct{})
r := newDriveRig(t)
r.spokenFixture()
var notices noticeLog
c := r.startChat(&notices)
c.mustSay()
waitFor(t, "published head", func() bool { return r.directoryHead(r.cell.ID) == r.head() })
entered := make(chan struct{})
var once sync.Once
c.drive.BeforeOwnershipCheckInTests(func(ctx context.Context) func() {
var after func()
once.Do(func() {
close(entered)
select {
case <-released:
case <-ctx.Done():
}
after = func() { close(checked) }
})
return after
}, func() { close(released); <-checked })
// An unrelated directory change places the watcher at the read barrier.
if err := r.b.Dir.PutDevice(t.Context(), r.b.Device.ID(), directory.Device{V: 1, Name: "barrier"}); err != nil {
t.Fatal(err)
}
select {
case <-entered:
case <-t.Context().Done():
t.Fatal(t.Context().Err())
}
if err := c.drive.Close(t.Context()); err != nil {
t.Fatal(err)
}
if line, viewer := c.drive.Viewer(); viewer {
t.Fatalf("own clean release became viewer: %q", line)
}
if got := r.directoryHead(r.cell.ID); got != r.head() {
t.Fatalf("close head = %q, want %q", got, r.head())
}
}

// Cancelling the run must stop its child watcher even without a Close call.
func TestDriveRunCancelStopsOwnershipWatcher(t *testing.T) {
r := newDriveRig(t)
r.spokenFixture()
var notices noticeLog
c := r.startChat(&notices)
c.mustSay()
waitFor(t, "published head", func() bool { return r.directoryHead(r.cell.ID) == r.head() })
entered := make(chan struct{})
var once sync.Once
c.drive.BeforeOwnershipCheckInTests(func(ctx context.Context) func() {
once.Do(func() {
close(entered)
<-ctx.Done()
})
return nil
}, nil)
if err := r.b.Dir.PutDevice(t.Context(), r.b.Device.ID(), directory.Device{V: 1, Name: "barrier"}); err != nil {
t.Fatal(err)
}
select {
case <-entered:
case <-time.After(5 * time.Second):
t.Fatal("ownership watcher did not enter its reading")
}
c.drive.cancel()
select {
case <-c.drive.watcherDone:
case <-time.After(5 * time.Second):
t.Fatal("run cancellation did not join the ownership watcher")
}
select {
case <-c.drive.done:
case <-time.After(5 * time.Second):
t.Fatal("run cancellation did not join the batcher")
}
}
10 changes: 10 additions & 0 deletions internal/syncsetup/drive_watch.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,17 @@ func (d *Drive) followOwnership(ctx context.Context) {
if state.Version > version && d.batcher.Fence() != 0 {
version = state.Version
read, cancel := context.WithTimeout(ctx, requestTimeout)
d.mu.Lock()
before := d.beforeOwnershipCheck
d.mu.Unlock()
var after func()
if before != nil {
after = before(read)
}
_ = d.CheckLease(read)
if after != nil {
after()
}
cancel()
}
if state.Refused != nil {
Expand Down
19 changes: 19 additions & 0 deletions internal/syncsetup/ownership_undertest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package syncsetup

import (
"context"
"testing"
)

// BeforeOwnershipCheckInTests pauses this drive's watcher before its reading.
// The returned callback runs after the reading and its viewer notification.
// Install only while the drive is idle, and close the drive before cleanup.
func (d *Drive) BeforeOwnershipCheckInTests(before func(context.Context) func(), afterRelease func()) {
if !testing.Testing() {
panic("syncsetup: ownership hook requires a test binary")
}
d.mu.Lock()
d.beforeOwnershipCheck = before
d.afterReleaseInTests = afterRelease
d.mu.Unlock()
}
Loading