Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8545e6a
release: expand an empty test argument list safely under macOS bash 3.2
AbirAbbas Oct 31, 2026
7accc18
test(syncsetup): give hand-built Sync fixtures their own home
AbirAbbas Oct 31, 2026
b82f185
test: engine fixtures record a real conversation and wait for async b…
AbirAbbas Oct 31, 2026
a8ddb0d
Never write a new identity into a codeaf home that was deleted
AbirAbbas Oct 31, 2026
65f4918
A stopping engine ends at once when its home is deleted
AbirAbbas Oct 31, 2026
d9839ac
Bound how long a busy engine store spends naming its holder
AbirAbbas Oct 31, 2026
f9bb2e7
A chat made with chat --once always reaches your other computers
AbirAbbas Oct 31, 2026
f28b0f4
manual: --once waits for your other computers, doctor finds a chat th…
AbirAbbas Oct 31, 2026
0207d37
test: spell the Option chords the way the product does on macOS
AbirAbbas Oct 31, 2026
7faad2d
changes: entry for #2086
AbirAbbas Oct 31, 2026
7d22830
test(syncsetup): make the quiet solo home in a fresh folder
AbirAbbas Oct 31, 2026
e1a6b3b
test(session): task-copy fixtures that hold on macOS
AbirAbbas Oct 31, 2026
765254c
Revert "manual: --once waits for your other computers, doctor finds a…
AbirAbbas Oct 31, 2026
d3b0468
Revert "A chat made with chat --once always reaches your other comput…
AbirAbbas Oct 31, 2026
2c90d8f
manual: a deleted home is refused, and a stopping engine exits when i…
AbirAbbas Oct 31, 2026
fff8847
changes: #2086 no longer carries the --once change
AbirAbbas Oct 31, 2026
b534a6d
Private apply tolerates macOS's own com.apple.provenance attribute
AbirAbbas Oct 31, 2026
58e1327
test(session): Apply fixtures leave macOS provenance in place
AbirAbbas Oct 31, 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
3 changes: 2 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,8 @@ jobs:
fi
test_args=()
[ -z "$skip" ] || test_args=(-skip "^(${skip})$")
go test "${test_args[@]}" \
# Bash 3.2 treats an empty array as unbound under nounset, so expand it conditionally.
go test ${test_args[@]+"${test_args[@]}"} \
./cmd/codeaf \
./internal/config \
./internal/exec/... \
Expand Down
3 changes: 2 additions & 1 deletion cmd/codeaf/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -945,7 +945,8 @@ func runEngineHost(workspaceFlag, sessionFlag string) error {
return fmt.Errorf("open %s: %w", workspace, err)
}
err = enginehost.Run(workspace, enginehost.Options{
Cells: cell.Available(),
HomeRemoved: func() { os.Exit(0) },
Cells: cell.Available(),
Boot: func(hello remote.Hello) (*remote.Engine, error) {
return bootEngine(hello, workspace, sessionFlag)
}, // usage writer owner: see CloseUsage after the host returns
Expand Down
6 changes: 5 additions & 1 deletion cmd/codeaf/hosted_takeback_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,11 @@ func TestKilledComputerTakesBackFromHomeAndLocalCard(t *testing.T) {
if _, err := io.WriteString(typed, " "); err != nil {
t.Fatal(err)
}
h.wait("Home continuation offer", func() bool { return strings.Contains(screen.String(), "alt+c") })
// The question and its continue chord must be visible in the same offer.
h.wait("Home continuation offer", func() bool {
text := screen.String()
return strings.Contains(text, "Continue other computer's latest chat here?") && (strings.Contains(text, "alt+c") || strings.Contains(text, "opt+c"))
})
if _, err := io.WriteString(typed, "\x1bc"); err != nil {
t.Fatal(err)
}
Expand Down
1 change: 1 addition & 0 deletions cmd/codeaf/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ func main() {
// timers installed by the feature that automations replaced.
automation.RemoveOldTimers()
}
home.Witness(home.Dir())
registerRunningCLI()
os.Exit(execute())
}
Expand Down
15 changes: 15 additions & 0 deletions docs/changes/unreleased/2086-deleted-home-and-once-chats.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
kind: fixed
title: A deleted codeaf home is never rewritten by an engine that is stopping
pr: 2086
surface: [chat, engine]
invalidates:
- "An engine still saving after it was asked to stop could find its home deleted and write a brand-new identity.json, device.json, identity-solo and sync ledgers into it, as if it were a first run; codeaf now refuses with \"codeaf home was removed or replaced; restart codeaf to use the current folder\", never re-creates a home it started in, and a stopping engine exits at once when its home is removed. A first run is unchanged."
- "Naming the process that holds a busy engine store could take many seconds on macOS (one lsof per lock file, 500 ms each); one short budget now covers the whole lookup."
---

Also fixed: tests that failed on macOS (Option chords spelled `opt+`, files that
take their folder's group, bare `mktemp` ignoring `TMPDIR`, a bash 3.2 empty
array in the release workflow) and a test that wrote a lock file into the source
tree. Engine-dependent tests skip on GitHub's full tests because no furrow is
staged there; that is a known follow-up.
43 changes: 43 additions & 0 deletions internal/cellstats/home_removed_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package cellstats

import (
"os"
"path/filepath"
"testing"

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

// SPEC-3 item 2: late settlement cannot recreate a removed or replaced home.
func TestFixRound2StatsSettleRefusesRemovedHome(t *testing.T) {
for _, replaced := range []bool{false, true} {
t.Run(map[bool]string{false: "missing", true: "replaced"}[replaced], func(t *testing.T) {
h := filepath.Join(t.TempDir(), "home")
if err := os.Mkdir(h, 0700); err != nil {
t.Fatal(err)
}
home.Witness(h)
r := NewRecorder(h, "chat", MeterFunc(func() Counts { return Counts{Puts: 1} }))
if err := os.Rename(h, h+".old"); err != nil {
t.Fatal(err)
}
if replaced {
if err := os.Mkdir(h, 0700); err != nil {
t.Fatal(err)
}
}
r.Settle()
if r.Err() == nil {
t.Fatal("settled into removed home")
}
if replaced {
entries, err := os.ReadDir(h)
if err != nil || len(entries) != 0 {
t.Fatalf("replacement changed: %v %v", entries, err)
}
} else if _, err := os.Stat(h); !os.IsNotExist(err) {
t.Fatalf("home recreated: %v", err)
}
})
}
}
13 changes: 8 additions & 5 deletions internal/cellstats/recorder.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"sync"

"github.com/Agent-Field/codeaf/internal/cellsync"
"github.com/Agent-Field/codeaf/internal/home"
)

// Counts is a snapshot of what one store client has asked of the store so far.
Expand All @@ -32,22 +33,24 @@ type Recorder struct {
Path string
Meter Meter // nil records zero request figures

home string

mu sync.Mutex
last Counts
err error
}

// NewRecorder records one cell's flushes under home. A meter is optional.
func NewRecorder(home, cellID string, m Meter) *Recorder {
return &Recorder{Path: Path(home, cellID), Meter: m}
return &Recorder{Path: Path(home, cellID), Meter: m, home: home}
}

// OnFlush is the cellsync.Batcher hook. It never fails the flush: a stats file
// that cannot be written is kept in Err and the sync carries on.
func (r *Recorder) OnFlush(f cellsync.Flush) {
r.mu.Lock()
defer r.mu.Unlock()
r.err = appendLine(r.Path, r.line(f))
r.err = appendLine(r.home, r.Path, r.line(f))
}

// Settle writes what the meter has counted since the last line as a line of its
Expand All @@ -59,7 +62,7 @@ func (r *Recorder) Settle() {
r.mu.Lock()
defer r.mu.Unlock()
if l := r.line(cellsync.Flush{}); l != (Line{V: Version}) {
r.err = appendLine(r.Path, l)
r.err = appendLine(r.home, r.Path, l)
}
}

Expand Down Expand Up @@ -88,8 +91,8 @@ func (r *Recorder) snapshot() Counts {

// appendLine adds one line with a single write on an append-only handle, so a
// line is never split and an earlier line is never touched.
func appendLine(path string, l Line) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
func appendLine(root, path string, l Line) error {
if err := home.MkdirAllUnder(root, filepath.Dir(path), 0o700); err != nil {
return err
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
Expand Down
6 changes: 6 additions & 0 deletions internal/cellstore/homecapture_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,12 @@ func TestStartupInALargeHomeWalksNoHome(t *testing.T) {
if err != nil {
t.Fatal(err)
}
// Warm capture only prepares an unused chat, so a completed call must give Drain a turn to save.
if err := seat.Around(t.Context(), executor.Call{Tool: "read", Changed: []string{}}, func() ([]byte, bool) {
return []byte("fixture"), false
}); err != nil {
t.Fatal(err)
}
if err := executor.Drain(t.Context(), seat); err != nil {
t.Fatal(err)
}
Expand Down
29 changes: 21 additions & 8 deletions internal/cellstore/store_busy.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cellstore

import (
"context"
"encoding/json"
"errors"
"fmt"
Expand All @@ -10,6 +11,7 @@ import (
"strconv"
"strings"
"sync"
"time"

"github.com/Agent-Field/codeaf/internal/filelock"
"github.com/Agent-Field/codeaf/internal/home"
Expand All @@ -19,8 +21,9 @@ import (
var ErrStoreBusy = errors.New("engine store is busy")

const (
storeWriterLockName = "writer.lock"
maxLockPayloadBytes = 4 << 10
storeWriterLockName = "writer.lock"
maxLockPayloadBytes = 4 << 10
storeBusyInspectWithin = time.Second
)

// StoreHolder identifies the process holding a store writer lock.
Expand Down Expand Up @@ -122,15 +125,25 @@ func HoldStoreWriter(dataDir, command string) (func() error, error) {
}, nil
}

var inspectStoreLock = probeStoreLockBusy

// InspectStoreBusy checks whether another live process holds an advisory writer
// lock on dataDir. A daemon serving ownSocket is treated as this client's own
// transport rather than a competing holder.
func InspectStoreBusy(dataDir, ownSocket string) (StoreHolder, bool) {
ctx, cancel := context.WithTimeout(context.Background(), storeBusyInspectWithin)
defer cancel()
if strings.TrimSpace(dataDir) == "" {
return StoreHolder{}, false
}
for _, lockPath := range storeLockCandidates(dataDir, ownSocket) {
holder, busy := probeStoreLockBusy(lockPath, ownSocket)
if ctx.Err() != nil {
break
}
holder, busy := inspectStoreLock(ctx, lockPath, ownSocket)
if ctx.Err() != nil {
return StoreHolder{}, busy
}
if busy {
return holder, true
}
Expand Down Expand Up @@ -201,7 +214,7 @@ func storeLockCandidates(dataDir, ownSocket string) []string {
return paths
}

func probeStoreLockBusy(lockPath, ownSocket string) (StoreHolder, bool) {
func probeStoreLockBusy(ctx context.Context, lockPath, ownSocket string) (StoreHolder, bool) {
if strings.HasSuffix(lockPath, ".sock.lock") {
// An active engine daemon socket lock is the normal daemon lease, not a
// stuck store writer, unless its socket file is missing.
Expand Down Expand Up @@ -229,19 +242,19 @@ func probeStoreLockBusy(lockPath, ownSocket string) (StoreHolder, bool) {
return StoreHolder{}, false
}

holder := lockHolderFromOS(lockPath)
holder := lockHolderFromOS(ctx, lockPath)
if holder.PID == os.Getpid() {
return StoreHolder{}, false
}
if holder.PID > 0 && pidServesSocket(holder.PID, ownSocket) {
if holder.PID > 0 && pidServesSocket(ctx, holder.PID, ownSocket) {
return StoreHolder{}, false
}
if holder.PID == 0 || holder.Command == "" {
recorded := readLockPayload(file)
if recorded.PID == os.Getpid() {
return StoreHolder{}, false
}
if recorded.PID > 0 && pidServesSocket(recorded.PID, ownSocket) {
if recorded.PID > 0 && pidServesSocket(ctx, recorded.PID, ownSocket) {
return StoreHolder{}, false
}
if holder.PID == 0 {
Expand All @@ -252,7 +265,7 @@ func probeStoreLockBusy(lockPath, ownSocket string) (StoreHolder, bool) {
}
}
if holder.Command == "" && holder.PID > 0 {
holder.Command = describeProcess(holder.PID)
holder.Command = describeProcess(ctx, holder.PID)
}
holder.Lock = lockPath
return holder, true
Expand Down
60 changes: 60 additions & 0 deletions internal/cellstore/store_busy_budget_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package cellstore

import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"testing"
"time"
)

// C4.4: classification shares one total budget across all candidates, and
// preserves the plain busy sentence when no holder can be found in time.
func TestStoreBusyClassificationSharesOneBudget(t *testing.T) {
root := t.TempDir()
for i := 0; i < 30; i++ {
if err := os.WriteFile(filepath.Join(root, fmt.Sprintf("%02d.lock", i)), nil, 0o600); err != nil {
t.Fatal(err)
}
}
old := inspectStoreLock
t.Cleanup(func() { inspectStoreLock = old })
calls := 0
inspectStoreLock = func(ctx context.Context, _, _ string) (StoreHolder, bool) {
calls++
select {
case <-time.After(storeBusyInspectWithin / 4):
return StoreHolder{}, false
case <-ctx.Done():
return StoreHolder{}, false
}
}
start := time.Now()
err := classifyStoreBusyError(root, "", errors.New("engine store is busy"))
if elapsed := time.Since(start); elapsed > storeBusyInspectWithin+500*time.Millisecond {
t.Fatalf("classification took %s, budget %s", elapsed, storeBusyInspectWithin)
}
if calls >= 30 || calls == 0 {
t.Fatalf("inspected %d candidates", calls)
}
if err.Error() != "engine store is busy" {
t.Fatal(err)
}
// A timely holder ends the scan immediately and keeps the existing message.
calls = 0
inspectStoreLock = func(context.Context, string, string) (StoreHolder, bool) {
calls++
return StoreHolder{PID: 42, Command: "codeaf chat"}, true
}
err = classifyStoreBusyError(root, "", errors.New("engine store is busy"))
if calls != 1 || err.Error() != "engine store is busy (held by codeaf chat, pid 42)" {
t.Fatalf("calls=%d: %v", calls, err)
}
calls = 0
_ = classifyStoreBusyError(root, "", errors.New("unrelated failure"))
if calls != 0 {
t.Fatal("healthy path inspected locks")
}
}
Loading
Loading