Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
8e9d62e
codeaf: open a chat, pair route and Continue here for a given home
AbirAbbas Oct 28, 2026
eed902b
app: pairing, chats on other computers and Continue here for the desktop
AbirAbbas Oct 28, 2026
99915d8
docs: the desktop's relay, pairing and Continue here doors
AbirAbbas Oct 28, 2026
a7efa79
app: the desktop's own lines for an unpaired home and for sync off
AbirAbbas Oct 28, 2026
9866591
app: publish chats made before pairing, desktop words, quieter chat e…
AbirAbbas Oct 28, 2026
992b3e0
handoff: a real seal, fetch and swap test for the take path
AbirAbbas Oct 28, 2026
e8f723d
docs: chats from before pairing, open on <name>, failed takes
AbirAbbas Oct 28, 2026
6f7fd25
app: synced settings reach the desktop; devices.changed only on change
AbirAbbas Oct 28, 2026
f9c0f1a
provider: never send reasoning to a model that refuses it
AbirAbbas Oct 28, 2026
8cca2fe
app: a failed take leaves the chat free, and its source can write again
AbirAbbas Oct 28, 2026
4843f6b
app: keep takeover staging folders out of agent admission
AbirAbbas Oct 28, 2026
a4a001a
docs: failed takes, synced settings and reasoning refusals
AbirAbbas Oct 28, 2026
a0577a5
relayserve: forward quiet releases through the presence wrapper
AbirAbbas Oct 28, 2026
22a5dc1
directory: carry a chat's saved-turn time beside its publish time
AbirAbbas Oct 28, 2026
5cb3279
app: chat rows say offline only when it is, and change only when they do
AbirAbbas Oct 28, 2026
67e454d
docs: chat-row presence, saved-turn times and quiet releases
AbirAbbas Oct 28, 2026
5d0933a
sync: a model choice travels only to computers that can use its provider
AbirAbbas Oct 28, 2026
462adde
sync: a holder's fence moves only when ownership changes
AbirAbbas Oct 28, 2026
d6d09ff
presence: a reconnect does not make a computer look offline
AbirAbbas Oct 28, 2026
6fe463f
app: one complete devices.changed per change, no row events on reconnect
AbirAbbas Oct 28, 2026
fbf727e
docs: model sync, stable fences, presence and head-guarded takes
AbirAbbas Oct 28, 2026
fcedd1a
cellstore: materialize a take's tree through a one-shot engine
AbirAbbas Oct 28, 2026
96d9c47
cellstore: run follow and plans from the store, naming the workspace
AbirAbbas Oct 28, 2026
30f601a
cellstore: pass the composed cell folder as an extended path on Windows
AbirAbbas Oct 28, 2026
a588aba
handoff: take tests with the shared daemon, long paths and composed t…
AbirAbbas Oct 28, 2026
a9dd0ca
docs: Continue here onto Windows
AbirAbbas Oct 28, 2026
584f0b5
sync: a computer keeps its own chat through a relay outage
AbirAbbas Oct 28, 2026
2bbfc4e
app: a taken chat whose model this computer can't use continues on th…
AbirAbbas Oct 28, 2026
3d3ec24
app: outage and model-fallback acceptance tests
AbirAbbas Oct 28, 2026
6c54dee
docs: relay outages and chats whose model is not set up here
AbirAbbas Oct 28, 2026
2d56e79
app: an open chat keeps its hold instead of churning its fence
AbirAbbas Oct 28, 2026
99d68b6
syncsetup: refuse a send-lease path without a home
AbirAbbas Oct 28, 2026
2b83e34
docs: open chats keep their hold
AbirAbbas Oct 28, 2026
246d65f
presence: a restarted relay does not call a computer it has not heard…
AbirAbbas Oct 28, 2026
6dee42d
app: Continue here completes when this computer has no usable model
AbirAbbas Oct 28, 2026
98f6087
docs: taking a chat without a usable model, and presence after a rela…
AbirAbbas Oct 28, 2026
eff181b
cellstore: a take's one-shot materialize keeps a transport the caller…
AbirAbbas Oct 28, 2026
eda09e5
syncsetup: the idle-wake test's homes can use the model they sync
AbirAbbas Oct 28, 2026
d05ed81
app: guard the relay goroutines and release every new lock from a defer
AbirAbbas Oct 28, 2026
18dd0d9
session: a job whose ending is already known does not reopen its chat
AbirAbbas Oct 28, 2026
7a891fe
session: a chat's model choice travels with it
AbirAbbas Oct 28, 2026
667ccc7
session: a continued chat explains its model change in one line
AbirAbbas Oct 28, 2026
a49e132
docs: background chats stay released, and a chat's model comes back w…
AbirAbbas Oct 28, 2026
48396e4
raftstore: open the relay's raft store on Windows
AbirAbbas Oct 28, 2026
447d787
relayserve: say why a directory is unavailable
AbirAbbas Oct 28, 2026
b3be129
app: a paired computer shows up on its other computers at once
AbirAbbas Oct 28, 2026
113327c
pair: words that match the desktop, and computers told apart by platform
AbirAbbas Oct 28, 2026
da1e2f5
docs: computers appear at once, pairing words, the relay on Windows
AbirAbbas Oct 28, 2026
052e9d3
pair: a pending rename says when the name arrives without naming the …
AbirAbbas Oct 28, 2026
e8e96c8
app: chat rows go out even when device publication connected the watc…
AbirAbbas Oct 28, 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
7 changes: 7 additions & 0 deletions PERF.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ is diagnostic; regression gates count deterministic work, bytes, allocations,
request fanout, retained data and cache reuse. Do not gate on wall-clock thresholds.
Use scripted providers and explicit synchronization rather than real sleeps.

Keyed vault restores read the sealed vault at most twice when no selection
policy is needed, and at most three times when incoming model choices need
provider and revision checks. These caps are independent of the number of keys;
`TestARestoreOpensTheVaultAFixedNumberOfTimes` and
`TestSelectiveRestoreM1BoundedVaultReads` exercise 16 and 256 keys. A model's
optional provider metadata is backfilled without advancing its choice clock.

Activity layout lines share immutable task records instead of copying both task
and conversation payloads into every line. Conversation overlays keep their own
copy, so live titles do not mutate the held reading. Paint scratch still expires
Expand Down
78 changes: 62 additions & 16 deletions cmd/codeaf/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ package main
// AND IT IS LAZY BY LAW. Startup opens agents with work or recovery to settle;
// idle conversations stay as indexed records, crew members included. Viewing,
// sending or waking opens one through the same per-agent gate. Listing agents,
// tasks and crews reads the disk, and a conversation left alone for
// [appAgentIdle] is let go and rebuilt transparently on demand.
// tasks and crews reads the disk. A conversation left alone for [appAgentIdle]
// without an open view is let go and rebuilt transparently on demand.

import (
"cmp"
Expand Down Expand Up @@ -67,9 +67,9 @@ import (
)

// appAgentIdle is how long a conversation the app holds may sit with no turn
// running, no question waiting and nothing sent before it is let go. Letting go
// closes the conversation in this process and nothing else: its journal is on
// disk, its state still reads idle, and the next send opens it again.
// running, no question waiting and no open view before it is let go. Its
// journal stays on disk, its state still reads idle, and the next send opens
// it again. An explicitly open view keeps the conversation and its hold.
const appAgentIdle = 10 * time.Minute

// appSweepEvery is how often the idle rule above is asked.
Expand Down Expand Up @@ -455,12 +455,15 @@ func appConnOf(client *remote.Client, release func(), assembled bool) *appConn {

// appBackend is the [appwire.Engine] over this process's conversations.
type appBackend struct {
chats appChats
pair appPair
drives *driveBook
sync appSync
syncPending map[string][]appSyncFact

// devices observes existing sync traffic without starting a roster watch.
devices *appDevices
devices *appDevices
stopSyncedSettings func()

// organizeProposer supplies a naming door when the backend is embedded without held conversations.
organizeProposer session.TeamProposer
Expand Down Expand Up @@ -618,6 +621,9 @@ type appAgent struct {
syncGate sync.Mutex
localNotices map[string]bool
windowMoved bool
// viewOpen preserves the person's open view through idle sweeps. Retiring
// it would let its next read immediately take back the released hold.
viewOpen bool
// crewHeld keeps an established crew hold due after its engine closes.
crewHeld bool

Expand Down Expand Up @@ -873,10 +879,12 @@ func newAppBackend(emit appwire.Emitter, open appOpener) *appBackend {
mint: appMint,
}
b.emit = b.newAppInbox(emit)
b.stopSyncedSettings = b.listenSyncedSettings()
b.devices = b.listenDevices()
b.stopPhases = b.listenPhases()
b.stopProgress = b.listenProgress()
guard.Go("app/sweep", b.sweep)
b.startLocalChatPublication()
return b
}

Expand Down Expand Up @@ -959,7 +967,21 @@ func (b *appBackend) remember(row session.SessionRow) (view appwire.Agent) {
// Read freshness against this backend's clock, including in deterministic
// tests, rather than trusting a row another reader dated earlier.
row.Presence, row.Live = session.ReadSessionPresence(row.Dir, b.now())
b.mu.Lock()
// Publish cannot miss an agent first remembered from a saved row.
// Holding both locks makes its initial location agree with the inventory;
// a later publication will see the new agent and send its location change.
lockRememberedLocation := func() *appwire.Location {
b.chats.mu.Lock()
defer b.chats.mu.Unlock()
// The backend lock stays held after the inventory lock is released.
// The remainder of remember owns its existing deferred release.
b.mu.Lock()
if cached, ok := b.chats.byAgent[row.ID]; ok {
return chatLocation(cached)
}
return nil
}
location := lockRememberedLocation()
a := b.agentLocked(row.ID)
defer func() {
b.mu.Unlock()
Expand All @@ -968,6 +990,9 @@ func (b *appBackend) remember(row session.SessionRow) (view appwire.Agent) {
}()
if a.transcript == "" {
a.transcript = row.Transcript
if a.view.Location == nil {
a.view.Location = location
}
}
disk := appRowView(row)
disk.Location = a.view.Location
Expand Down Expand Up @@ -1054,6 +1079,7 @@ func appRowOnDisk(id string) (session.SessionRow, bool) {

// ListAgents reads the disk and never opens a conversation.
func (b *appBackend) ListAgents(workspace string) ([]appwire.Agent, error) {
b.startChatInventory()
want := ""
if strings.TrimSpace(workspace) != "" {
want = filepath.Clean(workspace)
Expand All @@ -1080,7 +1106,7 @@ func (b *appBackend) ListAgents(workspace string) ([]appwire.Agent, error) {
seen := map[string]bool{}
var agents []appwire.Agent
for _, row := range session.ReadWorld(session.PlacesRoot()).Sessions() {
if row.DeletionPending || factoryOwnedConversation(row.Transcript) || !matches(row.Workspace, row.ProjectDir) {
if appChatStaging(row.Dir) || row.DeletionPending || factoryOwnedConversation(row.Transcript) || !matches(row.Workspace, row.ProjectDir) {
continue
}
seen[row.ID] = true
Expand Down Expand Up @@ -1163,6 +1189,9 @@ type appCreatePrep struct {
// prepareCreate is everything agents.create can refuse, checked with nothing
// made: the workspace, shutdown, and every attachment read whole.
func (b *appBackend) prepareCreate(ask appwire.CreateAsk) (appCreatePrep, error) {
if err := b.pairingWorkRefusal(); err != nil {
return appCreatePrep{}, err
}
if err := (session.Submission{ClientID: ask.ClientID}).Check(); err != nil {
return appCreatePrep{}, appEngineError(err)
}
Expand Down Expand Up @@ -1489,17 +1518,27 @@ func (b *appBackend) OpenAgent(id string) (appwire.Agent, error) {
// workspace is many seconds, and its own agent.changed says the rest.
var view appwire.Agent
busy := false
b.locked(func() { view, busy = a.view, a.live == nil && (a.opening || a.starting) })
b.locked(func() {
view, busy = a.view, a.live == nil && (a.opening || a.starting)
if busy {
a.viewOpen = true
}
})
if busy {
return view, nil
}
view, opened, err := b.ensure(a)
a.gate.Lock()
defer a.gate.Unlock()
b.locked(func() { a.viewOpen = true })
view, opened, err := b.ensureLocked(a)
if err != nil {
return appwire.Agent{}, err
}
if opened {
b.emit.Emit(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view})
}
b.startChatInventory()
b.dress(&view)
return view, nil
}

Expand Down Expand Up @@ -2077,7 +2116,7 @@ func (b *appBackend) send(id, text, clientID string, shown *appSending) (uint64,
// sendLocked opens a's conversation if it is not held and starts one turn on
// it. The caller holds a.gate.
func (b *appBackend) sendLocked(a *appAgent, text, clientID string, shown *appSending) (uint64, error) {
if err := b.knownWorkRefusal(a); err != nil {
if err := b.refreshWorkRefusal(a); err != nil {
return 0, err
}
// Opening can wake saved crew work, so reject an invalid identity before
Expand Down Expand Up @@ -2383,6 +2422,7 @@ func (b *appBackend) startTurnLocked(a *appAgent, text, clientID string, shown *
func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n int, text string, shown *appSending, events <-chan session.Event) {
defer close(live.pumpDone)
ok, failure := true, ""
retryable := true
kind, status := "", 0
entry := func(e appwire.Entry) {
e.Agent, e.Turn = id, turn
Expand Down Expand Up @@ -2623,6 +2663,7 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n
case session.EventError:
ok = false
kind, status, failure = appTurnFailure(event)
retryable = !provider.ReasoningUnsupported(status, failure)
case session.EventTurnDone:
// A turn the person steered ends once per stretch; the last ending
// on the stream is the turn's.
Expand Down Expand Up @@ -2654,7 +2695,7 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n
}
a.failed, a.failedKind, a.failedShown, a.failedClient = "", "", nil, ""
a.view.Retryable = false
if !ok {
if !ok && retryable {
a.failed, a.failedKind, a.failedShown = text, kind, shown
a.view.Retryable = text != ""
}
Expand Down Expand Up @@ -2965,7 +3006,7 @@ func (b *appBackend) closeAgent(a *appAgent) (view appwire.Agent) {
// hold a.gate.
func (b *appBackend) release(a *appAgent) {
var live *appLive
b.locked(func() { live, a.live = a.live, nil })
b.locked(func() { live, a.live, a.viewOpen = a.live, nil, false })
if live == nil {
return
}
Expand Down Expand Up @@ -4092,6 +4133,11 @@ func (b *appBackend) Close() error {
if already {
return nil
}
b.closePair()
if b.stopSyncedSettings != nil {
b.stopSyncedSettings()
}
b.closeChats()
if b.devices != nil {
b.devices.close()
}
Expand Down Expand Up @@ -4191,7 +4237,7 @@ func (b *appBackend) sweep() {
}

// sweepOnce is one look at the idle rule. A conversation is kept while a turn
// runs or a question waits for the person, whatever the clock says.
// runs, a question waits for the person or a view stays open, whatever the clock says.
func (b *appBackend) sweepOnce() {
var syncing bool
b.locked(func() { syncing = b.drives != nil })
Expand All @@ -4202,7 +4248,7 @@ func (b *appBackend) sweepOnce() {
var idle []*appAgent
b.locked(func() {
for _, a := range b.agents {
if live := a.live; live != nil && !live.running && now.Sub(live.lastUse) >= appAgentIdle {
if live := a.live; live != nil && !a.viewOpen && !live.running && now.Sub(live.lastUse) >= appAgentIdle {
idle = append(idle, a)
}
}
Expand All @@ -4223,7 +4269,7 @@ func (b *appBackend) sweepAgent(a *appAgent, now time.Time) {
keep := true
b.locked(func() {
live = a.live
keep = live == nil || live.running || now.Sub(live.lastUse) < appAgentIdle
keep = live == nil || a.viewOpen || live.running || now.Sub(live.lastUse) < appAgentIdle
})
// A member of an open crew stays held while the crew is open.
if !keep && !b.liveNeedsYou(live) && !b.crewKeeps(a.view.ID) && !b.serviceKeeps(a.view.ID) {
Expand Down
37 changes: 37 additions & 0 deletions cmd/codeaf/app_chat_copy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package main

import (
"fmt"
"strings"

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

const appTakeFailed = "This chat could not be moved to this computer."

// A live lease means this chat is open there; it says nothing about a turn.
func appChatStatusLine(r chatlist.Row, online *bool) string {
if online != nil && !*online && r.Device != "" {
return fmt.Sprintf("%s offline", r.Device)
}
if r.Status == chatlist.Running {
return fmt.Sprintf("open on %s", r.Device)
}
if r.Status == chatlist.Off {
return ""
}
return chatlist.StatusLine(r)
}

// The desktop formats SavedAt locally. Only holder and pending-work facts add
// anything to the takeover sentence, and neither changes as the clock advances.
func appChatTakeoverLine(r chatlist.Row, online *bool) string {
parts := []string{}
if status := appChatStatusLine(r, online); status != "" {
parts = append(parts, status)
}
if r.Pending > 0 {
parts = append(parts, fmt.Sprintf("Some work may still be on %s.", r.Device))
}
return strings.Join(parts, "; ")
}
Loading
Loading