Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c2e2f43
syncsetup: a paired computer lets go of an idle chat once everything …
AbirAbbas Nov 3, 2026
ea945b5
handoff: opening a released chat on a paired computer takes it withou…
AbirAbbas Nov 3, 2026
264eda7
tui3: a released window takes its chat back in place, and Home opens …
AbirAbbas Nov 3, 2026
ac27347
app: chat rows say when a chat can be opened here, and sends take a r…
AbirAbbas Nov 3, 2026
5dad3ae
synctake: a chat's copy folder keeps one bounded name however often i…
AbirAbbas Nov 3, 2026
f2d40e5
manual: an idle chat moves with you between paired computers
AbirAbbas Nov 3, 2026
eab2368
app, terminal: the new lock sections unlock from a defer
AbirAbbas Nov 3, 2026
7aa12fa
docs: number the change entry for #2287
AbirAbbas Nov 3, 2026
614c142
docs: the change entry names #2287
AbirAbbas Nov 3, 2026
942993a
syncsetup: an idle terminal chat releases again, and a late notice ne…
AbirAbbas Nov 3, 2026
315cd1f
syncsetup: ordinary home-state rounds wait for a competing flush again
AbirAbbas Nov 3, 2026
b96dda3
synctake: a copy folder is reused only for the exact copy root it names
AbirAbbas Nov 3, 2026
1aebd7d
tui3: a setting changed in a released terminal window retakes the hol…
AbirAbbas Nov 3, 2026
dd8f90f
session: a persistent setting in a released window goes through the o…
AbirAbbas Nov 3, 2026
0335dc0
app: settings in a released window retake the hold or are refused
AbirAbbas Nov 3, 2026
2e3935d
manual: settings after an idle release, and offline work in a held chat
AbirAbbas Nov 3, 2026
95c077b
syncsetup: Close always stops an ordinary writer, and a take-back cle…
AbirAbbas Nov 3, 2026
ce8e900
session: a person's durable write in a released window retakes the ho…
AbirAbbas Nov 3, 2026
ca395d0
cellstore: idle certification visits each path once, within fixed bounds
AbirAbbas Nov 3, 2026
8b0bfa6
app: a lent factory view keeps its model policy on the checked door
AbirAbbas Nov 3, 2026
e6dcd9f
manual: say sync service, and settings retake a released chat
AbirAbbas Nov 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions PERF.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
48 changes: 44 additions & 4 deletions cmd/codeaf/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -1700,7 +1700,30 @@ 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.
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
Expand Down Expand Up @@ -2375,7 +2398,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.
Expand Down Expand Up @@ -3966,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
}
Expand Down Expand Up @@ -4011,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()
Expand Down
1 change: 1 addition & 0 deletions cmd/codeaf/app_chats.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
}
Expand Down
32 changes: 15 additions & 17 deletions cmd/codeaf/app_idle_release_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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())
}
Expand All @@ -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)
}
Expand All @@ -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)
}
}
Expand All @@ -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())
Expand Down
7 changes: 7 additions & 0 deletions cmd/codeaf/app_lend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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") }

Expand Down
33 changes: 30 additions & 3 deletions cmd/codeaf/app_take.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)})
}
4 changes: 2 additions & 2 deletions cmd/codeaf/appconvo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
48 changes: 45 additions & 3 deletions cmd/codeaf/appsync.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}) },
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions cmd/codeaf/chatv3_draft_identity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
4 changes: 3 additions & 1 deletion cmd/codeaf/chatv3_layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -843,6 +843,8 @@ func v3SeatPending(place session.Place, say func(string), drives *driveBook) (ex
failed(watch, err)
drive, claim := drives.pendingDriveOf(c, cellstore.EngineFor(place.Workspace), watch.SyncReport)
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())
Expand All @@ -852,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)), 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,
Expand Down
Loading
Loading