From 4790d51b0adcbc35a68219736556656e07f50e74 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 22:28:20 -0400 Subject: [PATCH 01/28] perf(sync): a take-back acquires before it restores the whole tree Fetch the incoming objects, inspect only the workspace metadata in a scratch timeline, and leave an ordinary chat's one whole-tree restore until after its lease is acquired. Chats that carry a workspace plan, or an older root set, still run their full admission before the lease, and a refusal leaves the folder and the lease untouched. The holder also reads a retirement request without waiting for its presence heartbeat, so a displaced session lets go at once. Continuation phases can now be observed, and the first acquire after a killed holder lands well inside two seconds on a loopback relay. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/cellstore/admission_native_test.go | 87 +++++++++++++ internal/cellstore/materialize.go | 28 +++++ internal/handoff/admission_test.go | 88 +++++++++++++ internal/handoff/handoff.go | 105 ++++++++++++---- internal/handoff/phases.go | 20 +++ internal/session/takeover.go | 3 + internal/session/takeover_test.go | 13 ++ internal/session/workspace_transfer.go | 31 +++++ .../syncsetup/multiroot_preflight_test.go | 51 +++++--- internal/syncsetup/take.go | 51 +++++++- internal/syncsetup/take_phases_test.go | 118 ++++++++++++++++++ .../syncsetup/take_retired_session_test.go | 56 ++++++++- 12 files changed, 605 insertions(+), 46 deletions(-) create mode 100644 internal/cellstore/admission_native_test.go create mode 100644 internal/handoff/admission_test.go create mode 100644 internal/handoff/phases.go create mode 100644 internal/syncsetup/take_phases_test.go diff --git a/internal/cellstore/admission_native_test.go b/internal/cellstore/admission_native_test.go new file mode 100644 index 0000000000..17f82c08fa --- /dev/null +++ b/internal/cellstore/admission_native_test.go @@ -0,0 +1,87 @@ +package cellstore + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/furrow" +) + +// Contract A1 inspects incoming metadata without restoring the working tree. +func TestIncomingAdmissionRestoresOnlyMetadata(t *testing.T) { + celltest.RequireEngine(t) + bin, err := furrow.ResolveOwned() + if err != nil { + t.Fatal(err) + } + source := newCell(t) + for name, body := range map[string]string{ + ".cell/session.json": `{"id":"incoming","workspace_policy":{"version":1,"mode":"private-tasks"}}`, + ".cell/tasks.json": `{"type":"tasks","version":1,"seq":0}`, + "work.txt": "the working tree waits for the lease", + } { + if err := os.WriteFile(filepath.Join(source.Root, name), []byte(body), 0600); err != nil { + t.Fatal(err) + } + } + engine := Engine{Binary: bin, DataRoot: t.TempDir(), Transport: daemonFor(t, bin)} + sealed, err := engine.Seal(t.Context(), source, TurnInfo{}) + if err != nil { + t.Fatal(err) + } + receiver := newCell(t) + if err := engine.StageAdmission(t.Context(), receiver, sealed.Turn.ID); err != nil { + t.Fatal(err) + } + for _, name := range []string{".cell/session.json", ".cell/tasks.json"} { + want, err := os.ReadFile(filepath.Join(source.Root, name)) + if err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(filepath.Join(receiver.Root, name)) + if err != nil || string(got) != string(want) { + t.Fatalf("incoming metadata %s=%q: %v", name, got, err) + } + } + if _, err := os.Stat(filepath.Join(receiver.Root, "work.txt")); !os.IsNotExist(err) { + t.Fatalf("working tree restored during metadata inspection: %v", err) + } + if dirty, err := engine.Dirty(t.Context(), source); err != nil || dirty { + t.Fatalf("metadata inspection changed the sender's binding: dirty=%v err=%v", dirty, err) + } +} + +// Contract A1 keeps a full admission restore independent of the chat's timeline. +func TestIncomingWorkspaceAdmissionLeavesItsOriginalTimelineAlone(t *testing.T) { + celltest.RequireEngine(t) + bin, err := furrow.ResolveOwned() + if err != nil { + t.Fatal(err) + } + source := newCell(t) + marker := filepath.Join(source.Root, "source-only.txt") + if err := os.WriteFile(marker, []byte("original folder"), 0600); err != nil { + t.Fatal(err) + } + engine := Engine{Binary: bin, DataRoot: t.TempDir(), Transport: daemonFor(t, bin)} + sealed, err := engine.Seal(t.Context(), source, TurnInfo{}) + if err != nil { + t.Fatal(err) + } + stage := cell.Cell{ID: source.ID, Root: t.TempDir()} + if err := engine.StageWorkspace(t.Context(), stage, sealed.Turn.ID); err != nil { + t.Fatal(err) + } + if body, err := os.ReadFile(filepath.Join(stage.Root, "source-only.txt")); err != nil || string(body) != "original folder" { + t.Fatalf("full staged tree missing: %q %v", body, err) + } + if body, err := os.ReadFile(marker); err != nil || string(body) != "original folder" { + t.Fatalf("admission changed original files: %q %v", body, err) + } + if dirty, err := engine.Dirty(t.Context(), source); err != nil || dirty { + t.Fatalf("admission changed original native binding: dirty=%v err=%v", dirty, err) + } +} diff --git a/internal/cellstore/materialize.go b/internal/cellstore/materialize.go index 85b9206e35..7cec959cb6 100644 --- a/internal/cellstore/materialize.go +++ b/internal/cellstore/materialize.go @@ -3,6 +3,7 @@ package cellstore import ( "context" "fmt" + "os" "github.com/Agent-Field/codeaf/internal/cell" "github.com/Agent-Field/codeaf/internal/taskcopy" @@ -97,3 +98,30 @@ func (e Engine) SealRecovered(ctx context.Context, c cell.Cell) (Sealed, error) } return sealed, wal.Sealed(recovery.Completed) } + +// StageAdmission reads only the incoming workspace policy and checkpoint into +// private scratch space. Ordinary chats need no full tree before their lease. +func (e Engine) StageAdmission(ctx context.Context, c cell.Cell, head string) error { + return e.stageIncoming(ctx, c, head, []string{cell.MetaPath, cell.StateDir + "/session.json", cell.StateDir + "/tasks.json", taskcopy.WorkspaceCarrierPath + "/manifest.json"}) +} + +// StageWorkspace restores a workspace for admission in an independent timeline, +// leaving the real chat's binding and folder untouched if admission refuses it. +func (e Engine) StageWorkspace(ctx context.Context, c cell.Cell, head string) error { + return e.stageIncoming(ctx, c, head, nil) +} + +func (e Engine) stageIncoming(ctx context.Context, c cell.Cell, head string, paths []string) error { + if err := os.MkdirAll(c.Root, 0700); err != nil { + return err + } + target := e.target(c) + target.Tree, target.CellDir, target.Timeline = c.Root, "", "incoming-admission-"+c.ID + transport := Spawn{Binary: e.Binary, Run: e.Run} + if _, err := transport.Do(ctx, target, indexScratchOp{}); err != nil { + return err + } + defer func() { _, _ = transport.Do(ctx, target, indexScratchOp{forget: true}) }() + _, err := transport.Do(ctx, target, restoreOp{Snapshot: head, Paths: paths}) + return err +} diff --git a/internal/handoff/admission_test.go b/internal/handoff/admission_test.go new file mode 100644 index 0000000000..8d4f9ce9b5 --- /dev/null +++ b/internal/handoff/admission_test.go @@ -0,0 +1,88 @@ +package handoff + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +// Contract A1 postpones the only ordinary whole-tree restore until acquisition. +func TestOrdinaryAdmissionRestoresOnlyAfterAcquiring(t *testing.T) { + for _, inPlace := range []bool{false, true} { + t.Run(map[bool]string{false: "owned", true: "borrowed"}[inPlace], func(t *testing.T) { + w, a, _ := backFromB(t) + take := a.taker() + take.InPlace = func(cell.Cell) bool { return inPlace } + take.Prepare = func(context.Context, cell.Cell, string) error { return nil } + take.Preflight = func(context.Context, cell.Cell) error { t.Error("ordinary chat ran workspace admission"); return nil } + started := time.Now() + acquired := false + restores := 0 + take.OnPhase = func(p Phase) { + if !p.Ended.IsZero() { + t.Logf("%s: %s", p.Name, p.Ended.Sub(p.Started)) + } else { + t.Logf("%s after %s", p.Name, p.Started.Sub(started)) + } + if p.Name == "first acquire" { + acquired = true + } + } + take.Fetch = admissionFetch{inner: take.Fetch, restore: func() { + restores++ + if !acquired { + t.Error("whole tree restored before acquiring") + } + }} + got, err := take.Take(t.Context(), chatID) + if err != nil { + t.Fatal(err) + } + if restores != 1 || got.Kept != "" || w.cellRec(chatID).Lease.Device != devA { + t.Fatalf("restores=%d kept=%q", restores, got.Kept) + } + if tree(t, got.Cell.Root)["b.txt"] != "from b" { + t.Fatal("latest files lost") + } + }) + } +} + +type admissionFetch struct { + inner Fetch + restore func() +} + +func (f admissionFetch) Fetch(ctx context.Context, c cell.Cell, head string) error { + f.restore() + return f.inner.Fetch(ctx, c, head) +} + +func (f admissionFetch) Complete(ctx context.Context, c cell.Cell, head string) error { + return f.inner.Complete(ctx, c, head) +} + +// Contract A1 keeps incoming workspace admission ahead of the lease and local files. +func TestIncomingWorkspaceChecksAdmissionBeforeAcquire(t *testing.T) { + w, a, _ := backFromB(t) + before := w.cellRec(chatID).Lease + take := a.taker() + denied := errors.New("choose local folders for this chat's working roots") + checked := false + take.Prepare = func(context.Context, cell.Cell, string) error { checked = true; return denied } + take.OnPhase = func(p Phase) { + if p.Name == "first acquire" { + t.Error("incoming workspace acquired before admission") + } + } + _, err := take.Take(t.Context(), chatID) + if !errors.Is(err, denied) || !checked { + t.Fatalf("incoming workspace skipped its admission check: %v", err) + } + if w.cellRec(chatID).Lease != before || tree(t, a.root(chatID))["b.txt"] != "" { + t.Fatal("rejected workspace changed the lease or local files") + } +} diff --git a/internal/handoff/handoff.go b/internal/handoff/handoff.go index c8df262a9f..148ee0ef6a 100644 --- a/internal/handoff/handoff.go +++ b/internal/handoff/handoff.go @@ -12,6 +12,7 @@ import ( "fmt" "github.com/Agent-Field/codeaf/internal/fsreplace" "os" + "time" "github.com/Agent-Field/codeaf/internal/cell" "github.com/Agent-Field/codeaf/internal/cellsync" @@ -45,6 +46,13 @@ type Local interface { // Taker continues a chat on this device. type Taker struct { + // stagedHead joins a claim's value copies so a failure restores only + // a native binding that the claim may have moved. + stagedHead *string + + // OnPhase receives measurements of local work during this take. + OnPhase func(Phase) + Dir directory.Client Fetch Fetch Local Local @@ -69,6 +77,9 @@ type Taker struct { // Preflight validates the authenticated staged tree before acquiring a lease // or replacing local files. It runs again if the acquired head moved. Preflight func(context.Context, cell.Cell) error + // Prepare admits incoming objects in private scratch without changing the + // chat's native binding. It replaces staged fetching and Preflight when set. + Prepare func(context.Context, cell.Cell, string) error } // Taken is a chat this device now drives. @@ -80,8 +91,8 @@ type Taken struct { } // Take makes this device the driver of chat id. The order is fixed: the -// directory's record, then keep local edits, then fetch into a staging folder, -// then the lease, then (only if the head moved meanwhile) one more fetch, then +// directory's record, then keep local edits, then fetch and admit incoming work +// in private scratch, then the lease and a second check if the head moved, then // the staging folder becomes the root, then the After hooks, then open. Fetch // precedes acquire, so a failed fetch never takes a lease, and the root // changes only after the acquire, so a lost race (directory.ErrLeaseHeld) @@ -141,11 +152,15 @@ func (t Taker) keepLocal(ctx context.Context, c cell.Cell, parentHead string, pl if !exists(c.Root) { return "", nil } + checked := Trace(t.OnPhase, "unpublished check") head, turns, err := t.Local.Unpublished(c, parentHead) + checked() if err != nil { return "", err } + checked = Trace(t.OnPhase, "dirty check") dirty, err := t.Local.Dirty(ctx, c) + checked() if err != nil { return "", err } @@ -160,7 +175,9 @@ func (t Taker) keepLocal(ctx context.Context, c cell.Cell, parentHead string, pl return "", nil } from := &cellsync.Driving{Cell: c, Head: parentHead, Frames: plan} + keptUpload := Trace(t.OnPhase, "kept-branch upload") kept, err := t.Branch(ctx, from, head, turns) + keptUpload() if err != nil { return "", fmt.Errorf("handoff: keep local edits of %s: %w", c.ID, err) } @@ -173,6 +190,7 @@ func (t Taker) keepLocal(ctx context.Context, c cell.Cell, parentHead string, pl // staging folder never outlives a failed claim. func (t Taker) claim(ctx context.Context, c cell.Cell, head string, how directory.AcquireOpts) (string, uint64, []string, error) { stage := cell.Cell{ID: c.ID, Root: stagingOf(c.Root)} + t.stagedHead = new(string) head, fence, plan, err := t.fetchAndAcquire(ctx, stage, c.Root, head, how) if err == nil { if err = t.install(ctx, stage, c, head); err != nil { @@ -183,7 +201,7 @@ func (t Taker) claim(ctx context.Context, c cell.Cell, head string, how director // Staged admission may have moved the engine's timeline binding even // though local files stayed in place. A retry must inspect that original // root rather than the staging folder we are about to remove. - if t.Preflight != nil && exists(c.Root) && exists(stage.Root) { + if (t.Preflight != nil && t.Prepare == nil || t.stagedHead != nil && *t.stagedHead != "") && exists(c.Root) && exists(stage.Root) { err = errors.Join(err, t.Local.Follow(ctx, stage, c)) } return "", 0, nil, errors.Join(err, os.RemoveAll(stage.Root)) @@ -198,25 +216,54 @@ func (t Taker) claim(ctx context.Context, c cell.Cell, head string, how director // nothing at from it starts empty. A chat in the person's own folder is only // completed in the store: it is restored where it stands once the lease is // ours, except when Preflight needs the staged files to validate admission -// before any local replacement. It answers the plan of the record the device actually +// before any local replacement. Prepare instead admits fetched objects in its +// own timeline and leaves the real restore until acquisition. It answers the +// plan of the record the device actually // acquired: another device may have published while the fetch ran, and the // next publish replaces the record's plan wholesale, so seeding from the // record read before the claim would carry the stale one (contract §22.2). func (t Taker) fetchAndAcquire(ctx context.Context, stage cell.Cell, from, head string, how directory.AcquireOpts) (string, uint64, []string, error) { + if t.stagedHead == nil { + t.stagedHead = new(string) + } + staged := Trace(t.OnPhase, "staging/preflight") + defer func() { + if staged != nil { + staged() + } + }() inPlace := t.InPlace != nil && t.InPlace(cell.Cell{ID: stage.ID, Root: from}) - if !inPlace || t.Preflight != nil { - if err := freshDir(stage.Root); err != nil { - return "", 0, nil, fmt.Errorf("handoff: prepare %s: %w", stage.ID, err) + prepare := func(head string) error { + if t.Prepare != nil { + if err := freshDir(stage.Root); err != nil { + return err + } + if err := t.bring(ctx, stage, true, head); err != nil { + return fmt.Errorf("handoff: fetch %s: %w", short(head), err) + } + return t.Prepare(ctx, stage, head) + } + if !inPlace || t.Preflight != nil { + if err := freshDir(stage.Root); err != nil { + return err + } + seedFrom(from, stage.Root) + } + if err := t.bring(ctx, stage, inPlace && t.Preflight == nil, head); err != nil { + return fmt.Errorf("handoff: fetch %s: %w", short(head), err) + } + if t.Preflight != nil { + return t.Preflight(ctx, stage) } - seedFrom(from, stage.Root) + return nil } - if err := t.bring(ctx, stage, inPlace && t.Preflight == nil, head); err != nil { - return "", 0, nil, fmt.Errorf("handoff: fetch %s: %w", short(head), err) + if err := prepare(head); err != nil { + return "", 0, nil, err } - if t.Preflight != nil { - if err := t.Preflight(ctx, stage); err != nil { - return "", 0, nil, err - } + staged() + staged = nil + if t.OnPhase != nil { + t.OnPhase(Phase{Name: "first acquire", Started: time.Now()}) } got, err := t.Dir.Acquire(ctx, stage.ID, how) if err != nil { @@ -224,13 +271,8 @@ func (t Taker) fetchAndAcquire(ctx context.Context, stage cell.Cell, from, head } if got.Cell.Head != head { head = got.Cell.Head - if err := t.bring(ctx, stage, inPlace && t.Preflight == nil, head); err != nil { - return "", 0, nil, t.giveBack(ctx, stage.ID, got.Cell.Lease.Fence, fmt.Errorf("handoff: fetch %s: %w", short(head), err)) - } - if t.Preflight != nil { - if err := t.Preflight(ctx, stage); err != nil { - return "", 0, nil, t.giveBack(ctx, stage.ID, got.Cell.Lease.Fence, err) - } + if err := prepare(head); err != nil { + return "", 0, nil, t.giveBack(ctx, stage.ID, got.Cell.Lease.Fence, err) } } return head, got.Cell.Lease.Fence, got.Cell.Frames, nil @@ -239,15 +281,22 @@ func (t Taker) fetchAndAcquire(ctx context.Context, stage cell.Cell, from, head // bring puts head where the claim restores it from: in the store alone for a // folder the person owns, else also in the staging folder. func (t Taker) bring(ctx context.Context, stage cell.Cell, inPlace bool, head string) error { + defer Trace(t.OnPhase, "fetch")() if inPlace { return t.Fetch.Complete(ctx, stage, head) } + if t.stagedHead != nil { + *t.stagedHead = head + } return t.Fetch.Fetch(ctx, stage, head) } +// StagingSuffix identifies a takeover's private scratch folder. +const StagingSuffix = ".taking" + // stagingOf is the folder a takeover materializes into: beside the root, so // the final move is a rename on one filesystem. -func stagingOf(root string) string { return root + ".taking" } +func stagingOf(root string) string { return root + StagingSuffix } // freshDir makes dir exist and be empty, whatever a crashed takeover left. func freshDir(dir string) error { @@ -262,7 +311,19 @@ func freshDir(dir string) error { // staging fetch, so this is only the engine's restore to that tree, and the // staging folder is dropped. A copy is replaced by the staging folder. func (t Taker) install(ctx context.Context, stage, c cell.Cell, head string) error { + defer Trace(t.OnPhase, "restore")() if t.InPlace == nil || !t.InPlace(c) { + if t.Prepare != nil { + if err := freshDir(stage.Root); err != nil { + return err + } + if t.stagedHead != nil { + *t.stagedHead = head + } + if err := t.Fetch.Fetch(ctx, stage, head); err != nil { + return err + } + } return t.replace(ctx, stage, c) } if err := t.Fetch.Fetch(ctx, c, head); err != nil { diff --git a/internal/handoff/phases.go b/internal/handoff/phases.go new file mode 100644 index 0000000000..48a5f81155 --- /dev/null +++ b/internal/handoff/phases.go @@ -0,0 +1,20 @@ +package handoff + +import "time" + +// Phase records the local work around a continuation. An unfinished phase marks +// the instant of the first lease request, before the relay can delay its answer. +type Phase struct { + Name string + Started time.Time + Ended time.Time +} + +// Trace measures one phase only when a caller has requested observations. +func Trace(report func(Phase), name string) func() { + if report == nil { + return func() {} + } + started := time.Now() + return func() { report(Phase{Name: name, Started: started, Ended: time.Now()}) } +} diff --git a/internal/session/takeover.go b/internal/session/takeover.go index 027c40646a..a98145a084 100644 --- a/internal/session/takeover.go +++ b/internal/session/takeover.go @@ -279,6 +279,9 @@ func (a *Agent) announceTakeover() { // TakeoverAsked reports that another window has asked for this session and it // has not been let go of yet. It is what a surface that missed the event asks. func (a *Agent) TakeoverAsked() bool { + // A displaced reader may have stopped its presence heartbeat. The holder + // still reads the request when its host asks whether it should retire. + a.drainTakeover() a.mu.Lock() defer a.mu.Unlock() return a.takenOver diff --git a/internal/session/takeover_test.go b/internal/session/takeover_test.go index f239aee53a..c0d7b48649 100644 --- a/internal/session/takeover_test.go +++ b/internal/session/takeover_test.go @@ -1,6 +1,7 @@ package session import ( + "github.com/Agent-Field/codeaf/internal/knowledge" "os" "path/filepath" "testing" @@ -163,3 +164,15 @@ func drainRoster(lane <-chan Event) { } } } + +// Contract A1 lets a displaced reader answer retirement without its old heartbeat. +func TestTakeoverReadAnswersWithoutThePresenceHeartbeat(t *testing.T) { + agent, dir := questionSession(t, "displaced", func(config *Config) { config.knowledgeReadVault = knowledge.Open(t.TempDir()) }) + agent.stopPresence() + if err := AskTakeover(dir); err != nil { + t.Fatal(err) + } + if !agent.TakeoverAsked() { + t.Fatal("a holder without its presence heartbeat ignored retirement") + } +} diff --git a/internal/session/workspace_transfer.go b/internal/session/workspace_transfer.go index 57bd14fbe9..50fd507654 100644 --- a/internal/session/workspace_transfer.go +++ b/internal/session/workspace_transfer.go @@ -698,3 +698,34 @@ func syncWorkspaceDirectory(path string) error { defer directory.Close() return directory.Sync() } + +// TaskWorkspaceAdmissionNeeded inspects only authenticated incoming metadata. +// Carriers still receive their complete validation before the lease is taken. +func TaskWorkspaceAdmissionNeeded(place Place) (bool, error) { + meta, err := LoadMeta(place.Dir) + if err != nil { + return false, err + } + if err := validateWorkspacePolicy(meta.WorkspacePolicy); err != nil { + return false, err + } + if meta.WorkspacePolicy != nil || WorkspaceOwnershipPrototypeEnabled() { + return true, nil + } + raw, err := readWorkspaceCheckpointBytes(place.Tasks()) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + document, err := decodeTasks(raw) + if err != nil { + return false, err + } + if document.WorkspaceTransfer != nil { + return true, nil + } + _, err = os.Lstat(filepath.Join(place.Dir, taskcopy.WorkspaceCarrierPath, "manifest.json")) + return !errors.Is(err, os.ErrNotExist), nil +} diff --git a/internal/syncsetup/multiroot_preflight_test.go b/internal/syncsetup/multiroot_preflight_test.go index f7aab2fba5..0c036f629e 100644 --- a/internal/syncsetup/multiroot_preflight_test.go +++ b/internal/syncsetup/multiroot_preflight_test.go @@ -17,6 +17,7 @@ import ( "github.com/Agent-Field/codeaf/internal/workspaceidentity" ) +// Contract A1 keeps workspace admission ahead of writes to borrowed folders. // Labelled API fixture: native/public Take is real; task metadata is supplied // explicitly to test rejection before task restoration mutates a shared repo. func TestPublicTakeRejectsTwoUnboundRootsBeforeRepoOrRegistryMutation(t *testing.T) { @@ -40,6 +41,14 @@ func TestPublicTakeRejectsTwoUnboundRootsBeforeRepoOrRegistryMutation(t *testing } at := first.Taken.Cell work := workspaceOf(at.Root) + meta, err := session.LoadMeta(at.Root) + if err != nil { + t.Fatal(err) + } + meta.Owned, meta.Workspace = false, work + if err := session.SaveMeta(at.Root, meta); err != nil { + t.Fatal(err) + } t.Setenv(home.EnvVar, h.homeB) secondHead := seedTwoRootProtocolFixture(t, at, work) b := h.openOn(h.b, h.engB, at, work, nameB) @@ -51,26 +60,29 @@ func TestPublicTakeRejectsTwoUnboundRootsBeforeRepoOrRegistryMutation(t *testing t.Fatal(err) } t.Setenv(home.EnvVar, h.homeA) + keptFile := filepath.Join(h.work, "receiver-only.txt") + if err := os.WriteFile(keptFile, []byte("keep the receiver folder until admission"), 0600); err != nil { + t.Fatal(err) + } registryPath := filepath.Join(h.homeA, "workspace-identities") if _, err := os.Stat(registryPath); !os.IsNotExist(err) { t.Fatalf("fixture receiver already has registry: %v", err) } - var beforeRefs, beforeObjects string - observed := false - h.engine.Transport = afterRestoreSnapshot{under: h.engine.Transport, after: func(target cellstore.Target) { - observed = true - beforeRefs = gitOut(t, target.Tree, "show-ref") - beforeObjects = gitOut(t, target.Tree, "count-objects", "-v") - if exec.Command("git", "-C", target.Tree, "cat-file", "-e", secondHead+"^{commit}").Run() == nil { - t.Fatal("fixture secondary commit already in primary snapshot") - } - }} + beforeTake, err := h.a.Dir.Cell(ctx, h.cell.ID) + if err != nil { + t.Fatal(err) + } + beforeRefs := gitOut(t, h.work, "show-ref") + beforeObjects := gitOut(t, h.work, "count-objects", "-v") + if exec.Command("git", "-C", h.work, "cat-file", "-e", secondHead+"^{commit}").Run() == nil { + t.Fatal("fixture secondary commit already in primary snapshot") + } _, err = h.continuerA().Take(ctx, h.cell.ID) if err == nil || !strings.Contains(err.Error(), "working root") { t.Fatalf("Take did not explain unbound roots: %v notices=%v", err, h.wall.all()) } - if !observed { - t.Fatal("native restore observation did not run") + if got, err := os.ReadFile(keptFile); err != nil || string(got) != "keep the receiver folder until admission" { + t.Errorf("admission changed the borrowed folder: %q %v", got, err) } if got := gitOut(t, h.work, "show-ref"); got != beforeRefs { t.Fatal("task restoration changed borrowed primary refs before rejection") @@ -88,10 +100,21 @@ func TestPublicTakeRejectsTwoUnboundRootsBeforeRepoOrRegistryMutation(t *testing if _, err := os.Stat(filepath.Join(h.cell.Root, cell.TreesDir, id)); !os.IsNotExist(err) { t.Fatalf("rejected task %s was instantiated", id) } - if _, err := os.Stat(filepath.Join(h.cell.Root, cell.CarriedTreesPath, id, "tree.json")); err != nil { - t.Fatalf("incoming archive %s lost: %v", id, err) + if _, err := os.Stat(filepath.Join(h.cell.Root, cell.CarriedTreesPath, id, "tree.json")); !os.IsNotExist(err) { + t.Fatalf("rejected admission wrote incoming archive %s into the chat: %v", id, err) } } + afterTake, err := h.a.Dir.Cell(ctx, h.cell.ID) + if err != nil { + t.Fatal(err) + } + if afterTake.Cell.Lease != beforeTake.Cell.Lease { + t.Fatal("rejected admission changed the lease") + } + incoming := h.engine.Sync(cellstore.SyncKeys{CellKey: h.a.Identity.CellKey(), Dedup: h.a.Identity.DedupSecret()}, h.a.Ledger) + if wanted, err := incoming.Want(ctx, h.cell, beforeTake.Cell.Head); err != nil || len(wanted) != 0 { + t.Fatalf("rejected admission lost fetched objects: %v %v", wanted, err) + } } type afterRestoreSnapshot struct { diff --git a/internal/syncsetup/take.go b/internal/syncsetup/take.go index ebc91f3155..43222ac666 100644 --- a/internal/syncsetup/take.go +++ b/internal/syncsetup/take.go @@ -28,6 +28,9 @@ import ( // TakeOptions is what the surface tells the take side about this machine. type TakeOptions struct { + // OnPhase receives local continuation measurements when requested. + OnPhase func(handoff.Phase) + // WorkspaceRoots explicitly associates incoming working-root identities with // chosen local folders in the ownership prototype. It grants no landing // permission; task work is restored into private copies. Nil reuses only @@ -75,7 +78,14 @@ type Continuer struct { // SyncEngine, and the After hooks bring the chat's derived files and its // secrets to the folder the tree landed in. func (s *Sync) Continuer(eng cellstore.Engine, opt TakeOptions) *Continuer { - eng.WorkspaceOf = func(c cell.Cell) string { return workspaceOf(c.Root) } + eng.WorkspaceOf = func(at cell.Cell) string { + // A staged tree can carry derived metadata from a borrowed folder. Its + // restore must still target private scratch until admission and acquisition. + if strings.HasSuffix(at.Root, handoff.StagingSuffix) && opt.RootFor != nil && filepath.Clean(at.Root) == filepath.Clean(opt.RootFor(at.ID)+handoff.StagingSuffix) { + return session.Place{Dir: at.Root, Owned: true}.Work() + } + return workspaceOf(at.Root) + } return &Continuer{sync: s, eng: eng, opt: opt} } @@ -85,7 +95,7 @@ func (s *Sync) Continuer(eng cellstore.Engine, opt TakeOptions) *Continuer { func (c *Continuer) taker(ctx context.Context, sc *Scope) handoff.Taker { s := c.sync sync := c.eng.Sync(cellstore.SyncKeys{CellKey: s.Identity.CellKey(), Dedup: s.Identity.DedupSecret()}, s.Ledger) - engine := landing{Engine: sync} + engine := landing{Engine: sync, workspace: c.eng.WorkspaceOf} pub := &cellsync.Publisher{Engine: engine, Store: sc.Store, Dir: s.Dir} // THE TAKEOVER'S BRANCHER RUNS WITH Map NIL. The chat being taken keeps its // id on this machine; only the edits set aside live in the branch, and a @@ -94,6 +104,7 @@ 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}, @@ -139,9 +150,12 @@ func (c *Continuer) OpenBranch(ctx context.Context, id string) (Continued, error func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseExpectation, branchOnly bool) (Continued, error) { root := c.opt.RootFor(id) defer handoff.Arrive(root)() + localCapture := handoff.Trace(c.opt.OnPhase, "local capture") if err := cellstore.Await(ctx, cell.Cell{Root: root}); err != nil { + localCapture() return Continued{}, err } + localCapture() view, err := c.sync.Dir.Cell(ctx, id) if err != nil { return Continued{}, err @@ -177,7 +191,9 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx seen = &directory.LeaseExpectation{Device: lease.Device, Fence: lease.Fence} } from := c.sync.deviceName(lease.Device) + retired := handoff.Trace(c.opt.OnPhase, "retirement") c.retireHolder(ctx, id) + retired() sc := c.sync.scope(id) defer sc.Settle() intendedPrimary := session.Place{Dir: root, Owned: true}.Work() @@ -185,10 +201,10 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx intendedPrimary = project } taker := c.taker(ctx, sc) - taker.Preflight = func(_ context.Context, staged cell.Cell) error { + preflight := func(_ context.Context, staged cell.Cell) error { place := session.Place{Dir: staged.Root, Workspace: intendedPrimary} plan, err := session.TaskWorkspaceTransferPlan(place) - if err != nil || plan == nil { + if err != nil { return err } // Independent branches extract settled results in branchIdentity; they @@ -196,11 +212,27 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx if view.Cell.ParentCell != "" { return nil } + if plan == nil { + return session.ValidateTaskRestoreRoots(place, intendedPrimary) + } if !session.WorkspaceOwnershipEnabled(place) { return errors.New("this chat needs supported private task placement on the receiving device") } return session.ValidateTaskWorkspaceSetBindingsForDestination(place, root, c.opt.WorkspaceRoots) } + taker.Prepare = func(ctx context.Context, staged cell.Cell, head string) error { + if err := c.eng.StageAdmission(ctx, staged, head); err != nil { + return err + } + needed, err := session.TaskWorkspaceAdmissionNeeded(session.Place{Dir: staged.Root, Workspace: intendedPrimary}) + if err != nil || !needed { + return err + } + if err := c.eng.StageWorkspace(ctx, staged, head); err != nil { + return err + } + return preflight(ctx, staged) + } if view.Cell.ParentCell != "" { // The branch's snapshot was sealed before it had its own id. Rebind // its journal before rebuilding the row, so two chats never share an id. @@ -384,10 +416,17 @@ func (c *Continuer) branch(b cellsync.Brancher) func(context.Context, *cellsync. // landing is the engine for a chat that has not landed on this machine yet: the // engine works from inside the folder it restores into, so the folders are // made before it is asked what it is missing. -type landing struct{ cellsync.Engine } +type landing struct { + cellsync.Engine + workspace func(cell.Cell) string +} func (l landing) Want(ctx context.Context, c cell.Cell, head string) ([]string, error) { - if err := os.MkdirAll(workspaceOf(c.Root), 0o700); err != nil { + workspace := workspaceOf(c.Root) + if l.workspace != nil { + workspace = l.workspace(c) + } + if err := os.MkdirAll(workspace, 0o700); err != nil { return nil, err } return l.Engine.Want(ctx, c, head) diff --git a/internal/syncsetup/take_phases_test.go b/internal/syncsetup/take_phases_test.go new file mode 100644 index 0000000000..020d9b62b5 --- /dev/null +++ b/internal/syncsetup/take_phases_test.go @@ -0,0 +1,118 @@ +package syncsetup + +import ( + "context" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/handoff" + "github.com/Agent-Field/codeaf/internal/session" +) + +type measuredRestore struct { + acquired *atomic.Bool + count *atomic.Int32 + before *atomic.Int32 +} + +func (m measuredRestore) run(ctx context.Context, dir string, environ []string, argv ...string) ([]byte, error) { + whole := len(argv) > 2 && argv[2] == "materialize" + if len(argv) > 2 && argv[2] == "rewind" { + whole = true + for _, arg := range argv { + if arg == "--dry-run" || strings.HasPrefix(arg, "--paths=") { + whole = false + } + } + } + if whole { + m.count.Add(1) + if !m.acquired.Load() { + m.before.Add(1) + } + } + command := exec.CommandContext(ctx, argv[0], argv[1:]...) + command.Dir, command.Env = dir, environ + return command.Output() +} + +// Contract A1 restores an ordinary chat exactly once, after its lease is held. +func TestOrdinaryTakeMaterializesOnceAfterAcquire(t *testing.T) { + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "0") + var acquired atomic.Bool + h := newTwoHomes(t, func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/acquire") { + acquired.Store(true) + } + next.ServeHTTP(w, r) + }) + }) + a := h.openA() + a.mustSay("latest saved reply") + h.durable(h.cell.ID, h.cell) + first, err := h.continuerB().Take(t.Context(), h.cell.ID) + mustNil(t, err) + b := h.openOn(h.b, h.engB, first.Taken.Cell, workspaceOf(first.Taken.Cell.Root), nameB) + b.mustSay("latest reply on the other computer") + h.durable(h.cell.ID, first.Taken.Cell) + var count, before atomic.Int32 + measure := measuredRestore{&acquired, &count, &before} + h.engine.Run = measure.run + h.engine.Transport = cellstore.Spawn{Binary: h.bin, Run: measure.run} + continuer := h.continuerA() + continuer.opt.OnPhase = func(p handoff.Phase) { + if !p.Ended.IsZero() { + t.Logf("phase %s: %s", p.Name, p.Ended.Sub(p.Started)) + } + } + acquired.Store(false) + got, err := continuer.Take(t.Context(), h.cell.ID) + mustNil(t, err) + if count.Load() != 1 || before.Load() != 0 { + t.Errorf("whole-tree materializations=%d, before acquire=%d; want 1, 0", count.Load(), before.Load()) + } + if got.Taken.Kept != "" { + t.Errorf("unexpected extra continuation branch: %s", got.Taken.Kept) + } +} + +type admissionTarget struct{ tree string } + +func (a *admissionTarget) Do(_ context.Context, target cellstore.Target, op cellstore.Op) ([]byte, error) { + if op.Verb() == "materialize" { + a.tree = target.Tree + } + return []byte(`{}`), nil +} + +// Contract A1 keeps pre-acquire workspace staging out of a borrowed folder. +func TestStagedWorkspaceNeverTargetsTheBorrowedFolder(t *testing.T) { + root := filepath.Join(t.TempDir(), "chat") + stage := root + handoff.StagingSuffix + project := t.TempDir() + if err := os.MkdirAll(filepath.Join(stage, ".cell"), 0700); err != nil { + t.Fatal(err) + } + if err := session.SaveMeta(stage, session.Meta{ID: "chat", Workspace: project}); err != nil { + t.Fatal(err) + } + target := &admissionTarget{} + sync := &Sync{} + continuer := sync.Continuer(cellstore.Engine{DataRoot: t.TempDir(), Transport: target}, TakeOptions{RootFor: func(string) string { return root }}) + take := continuer.taker(t.Context(), &Scope{Store: blobstore.NewMemory()}) + if err := take.Fetch.Fetch(t.Context(), cell.Cell{ID: "chat", Root: stage}, strings.Repeat("a", 64)); err != nil { + t.Fatal(err) + } + if target.tree == "" || filepath.Clean(target.tree) == filepath.Clean(project) { + t.Fatalf("workspace admission targeted borrowed folder %q", target.tree) + } +} diff --git a/internal/syncsetup/take_retired_session_test.go b/internal/syncsetup/take_retired_session_test.go index 33a9374be3..cfc0633ff9 100644 --- a/internal/syncsetup/take_retired_session_test.go +++ b/internal/syncsetup/take_retired_session_test.go @@ -8,16 +8,29 @@ import ( "os" "path/filepath" "strings" + "sync/atomic" "testing" "time" "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/handoff" "github.com/Agent-Field/codeaf/internal/session" ) +// Contract A1 retires a displaced local session before taking back an expired hold. func TestTakeBackRetiresARepliedSessionWithoutKeepingSharedWork(t *testing.T) { - h := newTwoHomes(t) - ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "0") + var firstAcquire atomic.Int64 + h := newTwoHomes(t, func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/acquire") { + firstAcquire.CompareAndSwap(0, time.Now().UnixNano()) + } + next.ServeHTTP(w, r) + }) + }) + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Minute) defer cancel() a := h.openA() mustNil(t, os.Remove(transcriptOf(h.cell))) @@ -69,7 +82,27 @@ func TestTakeBackRetiresARepliedSessionWithoutKeepingSharedWork(t *testing.T) { } } }() - back, err := h.continuerA().Take(ctx, h.cell.ID) + // The other holder disappears without releasing; only its expired lease remains. + b.drive.cancel() + <-b.drive.done + h.quiet.Store(true) + h.clock.advance(directory.LeaseTTL + time.Millisecond) + continuer := h.continuerA() + started := time.Now() + firstAcquire.Store(0) + continuer.opt.OnPhase = func(phase handoff.Phase) { + if !phase.Ended.IsZero() { + t.Logf("%s: %s", phase.Name, phase.Ended.Sub(phase.Started)) + } + } + back, err := continuer.Take(ctx, h.cell.ID) + if first := firstAcquire.Load(); first == 0 { + t.Error("take never reached acquire") + } else if elapsed := time.Unix(0, first).Sub(started); elapsed > 2*time.Second { + t.Errorf("first acquire took %s; want within 2s", elapsed) + } else { + t.Logf("first acquire: %s", elapsed) + } mustNil(t, err) mustNil(t, <-retired) if session.InUse(transcriptOf(h.cell)) { @@ -83,18 +116,33 @@ func TestTakeBackRetiresARepliedSessionWithoutKeepingSharedWork(t *testing.T) { } raw, err := os.ReadFile(transcriptOf(back.Taken.Cell)) mustNil(t, err) + file := filepath.Join(workspaceOf(back.Taken.Cell.Root), fmt.Sprintf("turn-%s-1.txt", h.cell.ID[len(h.cell.ID)-4:])) + if body, err := os.ReadFile(file); err != nil || string(body) != "reply after the move" { + t.Errorf("latest file lost: %q, %v", body, err) + } if !strings.Contains(string(raw), "reply after the move") { t.Fatal("arrived chat lost the newer reply") } } +// Contract A1 keeps unpublished local files while the newer chat continues. func TestTakeBackKeepsFilesSealedAfterDisplacement(t *testing.T) { + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "0") h := bContinued(t) ctx := context.Background() mustNil(t, os.WriteFile(filepath.Join(h.work, "unsent.txt"), []byte("keep this file"), 0600)) _, err := h.engine.Seal(ctx, h.cell, cellstore.TurnInfo{}) mustNil(t, err) - back, err := h.continuerA().Take(ctx, h.cell.ID) + continuer := h.continuerA() + started := time.Now() + continuer.opt.OnPhase = func(p handoff.Phase) { + if !p.Ended.IsZero() { + t.Logf("%s: %s", p.Name, p.Ended.Sub(p.Started)) + } else { + t.Logf("first acquire after %s", p.Started.Sub(started)) + } + } + back, err := continuer.Take(ctx, h.cell.ID) mustNil(t, err) if back.Taken.Kept == "" || back.KeptTurns != 1 { t.Fatalf("unsent file was not kept once: %+v", back) From 29a5c1225eb7d242d5bddc0a672bd3856742682b Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 22:28:20 -0400 Subject: [PATCH 02/28] fix(tasks): a task that finished on another computer opens, read-only When a chat arrives without its plan database, its finished tasks fill their rows and pages from the settled run receipt, the transferred trajectory and the local worker history. The page offers no task verbs, says the task has finished when asked to act, and never restarts a settled run. The sending computer's page is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/manual/chat/worker-harness.md | 8 +++ internal/session/moved_run_history_test.go | 57 ++++++++++++--- internal/session/plandb_steer.go | 5 ++ internal/session/plandb_tasks.go | 17 +++-- internal/session/task_run_history.go | 75 ++++++++++++++++++++ internal/tui3/moved_plan_history_test.go | 80 ++++++++++++++++++++++ internal/tui3/planroom.go | 7 ++ 7 files changed, 237 insertions(+), 12 deletions(-) create mode 100644 internal/tui3/moved_plan_history_test.go diff --git a/internal/manual/chat/worker-harness.md b/internal/manual/chat/worker-harness.md index aca49aa0db..e66d118ae4 100644 --- a/internal/manual/chat/worker-harness.md +++ b/internal/manual/chat/worker-harness.md @@ -1153,3 +1153,11 @@ Workspace captures and checkpoint writes take a private conversation lock in every profile, including legacy profiles, so policy activation cannot bypass the checkpoint fence. This creates local control files during capture and checkpoint writes; it does not change legacy task placement or rewrite history. + +## Open a task that finished on another computer + +A task that finished on another computer is still available in Tasks after you +continue its chat here. Open it to read its title, saved description when present, +recorded steps and worker transcript. The historical page is read-only: finished +work cannot be restarted, retried or resumed from that page. The original +computer keeps its existing task page. diff --git a/internal/session/moved_run_history_test.go b/internal/session/moved_run_history_test.go index 6cd90bf64b..0d4453e8b1 100644 --- a/internal/session/moved_run_history_test.go +++ b/internal/session/moved_run_history_test.go @@ -1,12 +1,16 @@ package session import ( + "github.com/Agent-Field/codeaf/internal/knowledge" + "github.com/Agent-Field/codeaf/internal/plandb" "os" "path/filepath" + "reflect" "testing" "time" ) +// Contract A2 keeps a finished moved task readable without its plan database. func TestAMovedRunKeepsItsRosterAndWorkerTranscript(t *testing.T) { for _, name := range []string{"worker.jsonl", "20261026-120000.000000_worker.jsonl"} { t.Run(name, func(t *testing.T) { @@ -14,7 +18,7 @@ func TestAMovedRunKeepsItsRosterAndWorkerTranscript(t *testing.T) { if err := os.MkdirAll(filepath.Join(place.Dir, ".cell"), 0700); err != nil { t.Fatal(err) } - writeCheckpoint(t, place.Tasks(), taskDocument{Type: taskDocumentType, Version: taskFileVersion, Seq: 1, Runs: []runRecord{{ID: 1, Title: "Create hello-task.txt", State: TaskDone, PlanTask: "t-1", Model: "z-ai/glm-5.3-flash", Merge: "merged", Changed: []string{"hello-task.txt"}}}}) + writeCheckpoint(t, place.Tasks(), taskDocument{Type: taskDocumentType, Version: taskFileVersion, Seq: 1, Runs: []runRecord{{ID: 1, Title: "Create hello-task.txt", State: TaskDone, PlanTask: "t-1", PendingRun: &PendingRunRecord{Brief: "Write a friendly greeting"}, Model: "z-ai/glm-5.3-flash", Merge: "merged", Changed: []string{"hello-task.txt"}}}}) dir := filepath.Join(place.NodeJournals(), "1") if err := os.MkdirAll(dir, 0700); err != nil { t.Fatal(err) @@ -28,14 +32,22 @@ func TestAMovedRunKeepsItsRosterAndWorkerTranscript(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "trajectory.jsonl"), trajectory, 0600); err != nil { t.Fatal(err) } - agent := journalAgent(t, place) - if len(agent.PlanTasks()) != 0 { - t.Fatal("fixture unexpectedly contains a plan database") + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.SessionFile = place.Transcript() + config.Place = place + config.knowledgeReadVault = knowledge.Open(t.TempDir()) + }) + planRows := agent.PlanTasks() + if len(planRows) != 1 || planRows[0].StateWord() != "done" { + t.Fatalf("finished historical task rows: %+v", planRows) } - if _, found := agent.PlanTaskPage("t-1"); found { - t.Fatal("a trajectory file unexpectedly recreated the missing plan page") + page, found := agent.PlanTaskPage("t-1") + if !found || !page.Historical || page.Row.Title != "Create hello-task.txt" || page.Description != "Write a friendly greeting" || len(page.Steps) != 1 || page.Steps[0].Command != "printf hello" || len(page.Worker) != 1 || page.Worker[0].Text != "created hello-task.txt" { + t.Fatalf("finished historical page: found=%v page=%+v", found, page) + } + if err := agent.PlanResume("t-1"); err == nil { + t.Fatal("a settled historical task resumed") } - t.Log("trajectory is present; a plan page still requires the plan database") events, stop := agent.WatchTaskUpdates() defer stop() select { @@ -63,6 +75,7 @@ func TestAMovedRunKeepsItsRosterAndWorkerTranscript(t *testing.T) { } } +// Contract A2 reads historical pages only from this task's retained local files. func TestWorkerHistoryPrefersTheNewestAndRefusesRedirects(t *testing.T) { place := newPlace(t, false) writeCheckpoint(t, place.Tasks(), taskDocument{Type: taskDocumentType, Version: taskFileVersion, Seq: 1, Runs: []runRecord{{ID: 1, Title: "Create hello-task.txt", State: TaskDone, PlanTask: "t-1"}}}) @@ -89,7 +102,8 @@ func TestWorkerHistoryPrefersTheNewestAndRefusesRedirects(t *testing.T) { t.Fatal(err) } outside := filepath.Join(t.TempDir(), "worker.jsonl") - if err := os.WriteFile(outside, body, 0600); err != nil { + outsideBody := string(body) + `{"type":"message","role":"assistant","content":"outside reply must stay out"}` + "\n" + if err := os.WriteFile(outside, []byte(outsideBody), 0600); err != nil { t.Fatal(err) } if err := os.Symlink(outside, stable); err != nil { @@ -107,10 +121,37 @@ func TestWorkerHistoryPrefersTheNewestAndRefusesRedirects(t *testing.T) { if err := os.Remove(dir); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(filepath.Dir(outside), planTrajectoryFile), []byte(`{"kind":"step","step":1,"command":"outside step must stay out"}`+"\n"), 0600); err != nil { + t.Fatal(err) + } if err := os.Symlink(filepath.Dir(outside), dir); err != nil { t.Fatal(err) } if got := agent.TaskJournal(1); got != "" { t.Fatalf("redirected task directory=%q", got) } + if page, ok := agent.PlanTaskPage("t-1"); !ok || len(page.Worker) != 0 || len(page.Steps) != 0 { + t.Fatalf("redirected history entered the historical page: %+v", page) + } +} + +// Contract A2 keeps the sending computer's database page ahead of its receipt. +func TestHistoricalReceiptLeavesTheSendingPlanPageUnchanged(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, planStoreFilename) + seedPlanStore(t, path, "sender", plandb.TaskSpec{ID: "1", Title: "Store title", Description: "Store description"}) + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + armPlanStore(t, agent, path, "sender") + before, found := agent.PlanTaskPage("t-1") + if !found { + t.Fatal("sender page missing") + } + agent.publishRunRow(agent.graph(), TaskNotice{ID: 1, PlanTask: "t-1", Title: "Receipt title", State: TaskDone, PendingRun: &PendingRunRecord{Brief: "Receipt description"}}) + after, found := agent.PlanTaskPage("t-1") + if !found || after.Historical || !reflect.DeepEqual(before, after) { + t.Fatalf("receipt changed the sending page: before=%+v after=%+v", before, after) + } + if rows := agent.PlanTasks(); len(rows) != 2 { + t.Fatalf("receipt duplicated a sender row: %+v", rows) + } } diff --git a/internal/session/plandb_steer.go b/internal/session/plandb_steer.go index fdf99a71c2..d51b05f59c 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 { + for _, notice := range a.historicalPlanNotices() { + if planTaskID(notice.PlanTask) == planTaskID(id) { + return errPlanEndedRun + } + } stores, plan, closeStores := a.openPlanReadHandles() defer closeStores() if len(stores) == 0 { diff --git a/internal/session/plandb_tasks.go b/internal/session/plandb_tasks.go index 6d20a95d0a..c29c36166f 100644 --- a/internal/session/plandb_tasks.go +++ b/internal/session/plandb_tasks.go @@ -178,7 +178,11 @@ type PlanTaskNote struct { // the description that is its work order, every note left on it, and the steps // its worker recorded. type PlanTaskPage struct { - Row PlanTaskRow + // Historical pages are retained receipts and accept no task actions. + Historical bool `json:",omitempty"` + // Worker is the transferred worker conversation for a historical page. + Worker []DisplayEntry `json:",omitempty"` + Row PlanTaskRow // Models are the run's accepted seats, including on a child page and before // any spend was saved. Row.Model remains the ledger's historical reading. Models []PlanTaskModel `json:",omitempty"` @@ -291,7 +295,7 @@ func (a *Agent) PlanTasks() []PlanTaskRow { stores, plan, closeStores := a.openPlanReadHandles() defer closeStores() if len(stores) == 0 { - return nil + return a.historicalPlanRows(nil) } var rows []PlanTaskRow copies := a.planDisplayRunCopy() @@ -325,6 +329,7 @@ func (a *Agent) PlanTasks() []PlanTaskRow { } } } + rows = a.historicalPlanRows(rows) if rows == nil { return []PlanTaskRow{} } @@ -339,7 +344,7 @@ func (a *Agent) PlanTaskPage(id string) (PlanTaskPage, bool) { stores, plan, closeStores := a.openPlanReadHandles() defer closeStores() if len(stores) == 0 { - return PlanTaskPage{}, false + return a.historicalPlanPage(id) } var store *plandb.Store var task *plandb.Task @@ -350,7 +355,7 @@ func (a *Agent) PlanTaskPage(id string) (PlanTaskPage, bool) { } } if task == nil { - return PlanTaskPage{}, false + return a.historicalPlanPage(id) } dir := filepath.Dir(store.Path()) spend := planSpendByTask(store.Path()) @@ -920,6 +925,10 @@ func planTrajectory(dir, id string) []PlanStep { if err != nil { return nil } + return decodePlanTrajectory(data) +} + +func decodePlanTrajectory(data []byte) []PlanStep { var steps []PlanStep for _, line := range strings.Split(string(data), "\n") { line = strings.TrimSpace(line) diff --git a/internal/session/task_run_history.go b/internal/session/task_run_history.go index 93b27d5bb8..8b26e780c5 100644 --- a/internal/session/task_run_history.go +++ b/internal/session/task_run_history.go @@ -108,3 +108,78 @@ func (a *Agent) restoreWorkerHistoryModels(document *taskDocument) { } } } + +// Historical rows fill only gaps in the plan stores. A sender with its store +// still reads the original page; a receiver can read settled work immediately. +func (a *Agent) historicalPlanRows(rows []PlanTaskRow) []PlanTaskRow { + known := map[string]bool{} + for _, row := range rows { + known[planTaskID(row.ID)] = true + } + for _, notice := range a.historicalPlanNotices() { + if !known[planTaskID(notice.PlanTask)] { + rows = append(rows, historicalPlanRow(notice)) + known[planTaskID(notice.PlanTask)] = true + } + } + return rows +} + +func (a *Agent) historicalPlanNotices() []TaskNotice { + g := a.tasker() + if g == nil { + return nil + } + g.mu.Lock() + defer g.mu.Unlock() + var notices []TaskNotice + for _, notice := range g.runRowsLocked() { + if notice.PlanTask != "" && (notice.State == TaskDone || notice.State == TaskFailed || notice.State == TaskUnverified) { + notices = append(notices, notice) + } + } + return notices +} + +func historicalPlanRow(notice TaskNotice) PlanTaskRow { + return PlanTaskRow{ID: notice.PlanTask, Title: notice.Title, Status: string(notice.State), + Stopped: notice.Stopped, Archived: true, Started: notice.StartedAt, Ended: notice.EndedAt, + Model: notice.Model, USD: notice.CostUSD, Note: notice.Report} +} + +func (a *Agent) historicalPlanPage(id string) (PlanTaskPage, bool) { + for _, notice := range a.historicalPlanNotices() { + if planTaskID(notice.PlanTask) != planTaskID(id) { + continue + } + page := PlanTaskPage{Row: historicalPlanRow(notice), Historical: true, Result: notice.Result} + if notice.PendingRun != nil { + page.Description = notice.PendingRun.Brief + } + a.mu.Lock() + base := taskJournalDir(a.familyPlace(nil), a.sessionID()) + a.mu.Unlock() + relative := filepath.Join(strconv.FormatUint(notice.ID, 10), planTrajectoryFile) + page.Steps = planStepDisplayFactsForPage(decodePlanTrajectory(readHistoricalFile(base, relative)), a.planDisplayRunCopy()) + page.Row.Steps = len(page.Steps) + history := findWorkerHistory(base, notice.ID) + if history.journal != "" { + relative, err := filepath.Rel(base, history.journal) + if err == nil { + page.Worker = ReadTranscriptBytes(readHistoricalFile(base, relative)).Entries + } + } + return page, true + } + return PlanTaskPage{}, false +} + +func readHistoricalFile(base, relative string) []byte { + file, err := openTaskHistoryPath(base, relative, false) + if err != nil { + return nil + } + defer file.Close() + data, _ := io.ReadAll(file) + return data +} diff --git a/internal/tui3/moved_plan_history_test.go b/internal/tui3/moved_plan_history_test.go new file mode 100644 index 0000000000..e03cabd2ad --- /dev/null +++ b/internal/tui3/moved_plan_history_test.go @@ -0,0 +1,80 @@ +package tui3 + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// Contract A2 opens transferred finished work through Tasks without a plan store. +func TestMovedFinishedTaskOpensReadOnlyFromTasks(t *testing.T) { + root := t.TempDir() + place := session.Place{Dir: root, Workspace: filepath.Join(root, "work")} + if err := os.MkdirAll(filepath.Join(place.NodeJournals(), "1"), 0700); err != nil { + t.Fatal(err) + } + for path, body := range map[string]string{ + place.Tasks(): `{"type":"tasks","version":1,"seq":1,"runs":[{"id":1,"title":"Create hello-task.txt","state":"done","planTask":"t-1","pendingRun":{"brief":"Write a friendly greeting"}}]}`, + filepath.Join(place.NodeJournals(), "1", "worker.jsonl"): "{\"type\":\"session\",\"version\":1,\"id\":\"worker\"}\n{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"created hello-task.txt\"}\n", + filepath.Join(place.NodeJournals(), "1", "trajectory.jsonl"): "{\"kind\":\"step\",\"step\":1,\"command\":\"printf hello\",\"observation\":\"hello\"}\n", + } { + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0600); err != nil { + t.Fatal(err) + } + } + agent, err := session.New(session.Config{Workspace: place.Workspace, Place: place, SessionFile: place.Transcript(), Model: "stub/chat", APIKey: "stub-key", BaseURL: "http://127.0.0.1:1"}) + if err != nil { + t.Fatal(err) + } + defer agent.Close() + a := newTestApp(agent) + a.width, a.height = 180, 48 + a.homeRoot = t.TempDir() + a.file, a.title = place.Transcript(), "moved chat" + a.planRows, a.planRowsRead, a.planRowsFront = agent.PlanTasks(), true, a.frontGen + a.planRowsStamp, a.planRowsAt = a.railStamp, a.now() + if !openTaskPlaceWithRows(a) { + t.Fatal("Tasks did not open") + } + found := false + for _, item := range a.taskSheet.reading.items { + if item.plan != nil && item.plan.ID == "t-1" { + a.taskSheetPointAt(item.entry) + found = true + break + } + } + if !found { + t.Fatalf("finished task missing from Tasks: %s", taskSheetText(a)) + } + spend(t, a, a.taskSheet.enter(a)) + if a.room == nil || a.room.plan == nil || !a.room.done { + t.Fatal("Tasks did not open a finished plan page") + } + text := planRoomText(t, a) + for _, want := range []string{"done", "Create hello-task.txt", "Write a friendly greeting", "created hello-task.txt"} { + if !strings.Contains(text, want) { + t.Errorf("page missing %q:\n%s", want, text) + } + } + if got := strings.Join(roomCommands(a), "|"); !strings.Contains(got, "printf hello") { + t.Errorf("recorded steps missing: %s", got) + } + if words := a.tasksPlanKeyWords(a.room.plan.page.Row); len(words) != 0 { + t.Errorf("finished task actions: %v", words) + } + drive(t, a, key(stopRaiseKey), key("p")) + spend(t, a, a.planRoomSteer("resume this task")) + if !strings.Contains(planRoomText(t, a), "this task has finished") { + t.Error("finished task did not explain its refusal") + } + if !a.room.done { + t.Fatal("finished task restarted") + } +} diff --git a/internal/tui3/planroom.go b/internal/tui3/planroom.go index e2924dbef2..5eb041fdcb 100644 --- a/internal/tui3/planroom.go +++ b/internal/tui3/planroom.go @@ -210,6 +210,9 @@ func (a *app) planRoomEntries(page session.PlanTaskPage) ([]entry, int) { call(live.Step, command, "", false) } } + for _, entry := range page.Worker { + add(entry) + } for _, note := range page.Notes { body := strings.TrimSpace(note.Body) if body == "" { @@ -462,6 +465,10 @@ func (a *app) planRoomAncestors() []*taskNode { // while the tab is the one on screen. `now` asks for it even when a read has // already come back, which is what switching to the tab does. func (a *app) planRoomWorkRead(now bool) tea.Cmd { + if plan := a.roomPlan(); plan != nil && plan.page.Historical { + plan.workRead = true + return nil + } plan := a.roomPlan() if plan == nil || a.room.tab != roomTabWork || plan.working { return nil From 4511258ca53fc3fbbc16eebf0182e34a8c2a9031 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 22:28:20 -0400 Subject: [PATCH 03/28] fix(saving): recovery is claimed only when this chat's turns are saved The sealing notice says saving works again only after this chat's own transcript is sealed and, for a chat that syncs, published. A store whose lock is held but whose socket is gone is diagnosed in bounded time with a plain sentence, never retried into another writer, and restarted only when the process is proven to be this client's own storage child for that exact socket. Close errors now name where the unsaved turns are kept. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/cellstore/daemon.go | 2 +- internal/cellstore/daemon_recover_other.go | 7 ++ internal/cellstore/daemon_recover_unix.go | 77 ++++++++++++++ internal/cellstore/daemon_stuck.go | 40 +++++++ internal/cellstore/daemon_stuck_test.go | 110 ++++++++++++++++++++ internal/cellstore/daemonstart.go | 87 +++++++++++++++- internal/cellstore/publication.go | 37 +++++++ internal/cellstore/sealwatch.go | 58 ++++++++++- internal/cellstore/sealwatch_test.go | 71 +++++++++++++ internal/cellstore/transport.go | 3 +- internal/manual/chat/sessions-and-rewind.md | 17 +++ internal/session/agent.go | 6 +- internal/session/saving_error.go | 30 ++++++ internal/session/saving_error_test.go | 47 +++++++++ internal/session/setup.go | 2 +- internal/syncsetup/drive.go | 13 ++- 16 files changed, 593 insertions(+), 14 deletions(-) create mode 100644 internal/cellstore/daemon_recover_other.go create mode 100644 internal/cellstore/daemon_recover_unix.go create mode 100644 internal/cellstore/daemon_stuck.go create mode 100644 internal/cellstore/daemon_stuck_test.go create mode 100644 internal/cellstore/publication.go create mode 100644 internal/session/saving_error.go create mode 100644 internal/session/saving_error_test.go diff --git a/internal/cellstore/daemon.go b/internal/cellstore/daemon.go index 01f4e3df61..54c1f9d398 100644 --- a/internal/cellstore/daemon.go +++ b/internal/cellstore/daemon.go @@ -134,7 +134,7 @@ func (d Daemon) Stop(ctx context.Context) error { return nil } -func unavailable(err error) error { return fmt.Errorf("%w: %v", ErrUnavailable, err) } +func unavailable(err error) error { return fmt.Errorf("%w: %w", ErrUnavailable, err) } func newRequest(t Target, op Op) wireRequest { args, _ := json.Marshal(op) // an Op is plain data diff --git a/internal/cellstore/daemon_recover_other.go b/internal/cellstore/daemon_recover_other.go new file mode 100644 index 0000000000..045a0923c6 --- /dev/null +++ b/internal/cellstore/daemon_recover_other.go @@ -0,0 +1,7 @@ +//go:build !linux && !darwin + +package cellstore + +import "context" + +func recoverOwnedDaemon(context.Context, string) bool { return false } diff --git a/internal/cellstore/daemon_recover_unix.go b/internal/cellstore/daemon_recover_unix.go new file mode 100644 index 0000000000..4483bf049c --- /dev/null +++ b/internal/cellstore/daemon_recover_unix.go @@ -0,0 +1,77 @@ +//go:build linux || darwin + +package cellstore + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" + "time" +) + +// Recovery only stops a child this client started. Process identity, user, +// socket argument and the broken listener state must all still agree. +func recoverOwnedDaemon(ctx context.Context, socket string) bool { + value, ok := daemonOwners.Load(socket) + if !ok { + return false + } + owned := value.(*spawnedDaemon) + select { + case <-owned.exited: + return !heldWithoutSocket(socket) + default: + } + uid, argv := storageProcess(ctx, owned.process.Pid) + if !daemonServesSocket(uid, strconv.Itoa(os.Getuid()), argv, socket) || !heldWithoutSocket(socket) { + return false + } + if err := owned.process.Signal(syscall.SIGTERM); err != nil { + return false + } + timer := time.NewTimer(missingSocketWithin) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return false + case <-owned.exited: + return !heldWithoutSocket(socket) + } +} + +func storageProcess(ctx context.Context, pid int) (string, []string) { + if runtime.GOOS == "linux" { + root := filepath.Join("/proc", strconv.Itoa(pid)) + info, err := os.Stat(root) + if err != nil { + return "", nil + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return "", nil + } + raw, err := os.ReadFile(filepath.Join(root, "cmdline")) + if err != nil { + return "", nil + } + return strconv.FormatUint(uint64(stat.Uid), 10), strings.Split(strings.TrimSuffix(string(raw), "\x00"), "\x00") + } + bounded, cancel := context.WithTimeout(ctx, missingSocketWithin) + defer cancel() + raw, err := exec.CommandContext(bounded, "ps", "-p", strconv.Itoa(pid), "-o", "uid=", "-o", "command=").Output() + if err != nil { + return "", nil + } + fields := strings.Fields(string(raw)) + if len(fields) < 2 { + return "", nil + } + return fields[0], fields[1:] +} diff --git a/internal/cellstore/daemon_stuck.go b/internal/cellstore/daemon_stuck.go new file mode 100644 index 0000000000..1eaf0a57f4 --- /dev/null +++ b/internal/cellstore/daemon_stuck.go @@ -0,0 +1,40 @@ +package cellstore + +import ( + "fmt" + "path/filepath" + "regexp" + "strings" +) + +// SavingStuckError means a storage process owns the lock but cannot be reached. +type SavingStuckError struct{ Socket string } + +func (e *SavingStuckError) Error() string { + return fmt.Sprintf("saving is stuck; your turns stay on this computer, and saving resumes on its own within %s minutes", idleMinutes) +} + +func daemonServesSocket(uid, ownUID string, argv []string, socket string) bool { + if uid != ownUID || len(argv) < 4 || argv[1] != "serve" { + return false + } + name := filepath.Base(argv[0]) + if !storageProgramName.MatchString(name) { + return false + } + found := false + for i := 2; i < len(argv); i++ { + if argv[i] == "--socket" { + if found || i+1 >= len(argv) || argv[i+1] != socket { + return false + } + found = true + i++ + } else if strings.HasPrefix(argv[i], "--socket=") { + return false + } + } + return found +} + +var storageProgramName = regexp.MustCompile(`^furrow(?:-[0-9]+\.[0-9]+\.[0-9]+-[0-9a-f]+)?$`) diff --git a/internal/cellstore/daemon_stuck_test.go b/internal/cellstore/daemon_stuck_test.go new file mode 100644 index 0000000000..effc29da66 --- /dev/null +++ b/internal/cellstore/daemon_stuck_test.go @@ -0,0 +1,110 @@ +package cellstore + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/filelock" + "github.com/Agent-Field/codeaf/internal/furrow" +) + +// Contract A3 bounds a missing socket behind a held storage lock. +func TestHeldStoreLockWithoutSocketReturnsSavingStuck(t *testing.T) { + socket := filepath.Join(t.TempDir(), "engine.sock") + lock, err := os.OpenFile(socket+".lock", os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + t.Fatal(err) + } + defer lock.Close() + if err := filelock.Lock(lock, true, true); err != nil { + t.Fatal(err) + } + defer filelock.Unlock(lock) + started := time.Now() + _, err = (Daemon{Socket: socket, Binary: filepath.Join(t.TempDir(), "no-daemon")}).Do(context.Background(), Target{}, snapOp{}) + var stuck *SavingStuckError + if !errors.As(err, &stuck) { + t.Fatalf("held lock without socket = %v, want saving-stuck error", err) + } + if elapsed := time.Since(started); elapsed > time.Second { + t.Fatalf("held lock waited %s", elapsed) + } + if !strings.Contains(err.Error(), "saving is stuck") || !strings.Contains(err.Error(), idleMinutes+" minutes") { + t.Fatalf("no recovery sentence: %v", err) + } + // A repeated verb returns the cached diagnosis instead of restarting or spinning. + started = time.Now() + _, err = startAndDial(context.Background(), Daemon{Socket: socket}) + if !errors.As(err, &stuck) || time.Since(started) > 100*time.Millisecond { + t.Fatalf("repeated stuck start: %v", err) + } +} + +// Contract A3 permits recovery only for the same user's exact storage command. +func TestStorageProcessProofRequiresExactSocketAndUser(t *testing.T) { + for _, tc := range []struct { + name, uid, program, socket string + want bool + }{ + {"exact", "1000", "/owned/furrow", "/temporary/engine.sock", true}, + {"near path", "1000", "/owned/furrow", "/temporary/engine.sock.old", false}, + {"other user", "1001", "/owned/furrow", "/temporary/engine.sock", false}, + {"other program", "1000", "/owned/other", "/temporary/engine.sock", false}, + {"helper program", "1000", "/owned/furrow-helper", "/temporary/engine.sock", false}, + } { + t.Run(tc.name, func(t *testing.T) { + got := daemonServesSocket(tc.uid, "1000", []string{tc.program, "serve", "--socket", tc.socket, "--idle-minutes", idleMinutes}, "/temporary/engine.sock") + if got != tc.want { + t.Fatalf("process proof = %v, want %v", got, tc.want) + } + }) + } +} + +// Contract A3 never falls through to another writer behind an unreachable store. +func TestSavingStuckDoesNotFallBackToAnotherStorageWriter(t *testing.T) { + primary := &fixedTransport{err: unavailable(&SavingStuckError{Socket: "temporary.sock"})} + secondary := &fixedTransport{out: "unexpected writer"} + _, err := (Fallback{Primary: primary, Secondary: secondary}).Do(t.Context(), Target{}, snapOp{}) + var stuck *SavingStuckError + if secondary.calls != 0 || !errors.As(err, &stuck) { + t.Fatalf("stuck storage fell back: calls=%d err=%v", secondary.calls, err) + } +} + +// Contract A3 replaces only an owned storage child whose listener disappeared. +func TestOwnedStoreWithoutSocketRestartsAfterProof(t *testing.T) { + if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { + t.Skip("process identity is unavailable") + } + bin, err := furrow.ResolveOwned() + if err != nil { + t.Skipf("no test storage binary: %v", err) + } + daemon := daemonFor(t, bin).(Daemon) + t.Cleanup(StopSpawned) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + conn, err := daemon.connect(ctx) + if err != nil { + t.Fatal(err) + } + conn.Close() + if err := os.Remove(daemon.Socket); err != nil { + t.Fatal(err) + } + conn, err = daemon.connect(ctx) + if err != nil { + t.Fatalf("owned unreachable storage did not recover: %v", err) + } + defer conn.Close() + if err := daemon.verify(conn); err != nil { + t.Fatal(err) + } +} diff --git a/internal/cellstore/daemonstart.go b/internal/cellstore/daemonstart.go index 3477c342ed..bfdab77f8a 100644 --- a/internal/cellstore/daemonstart.go +++ b/internal/cellstore/daemonstart.go @@ -2,7 +2,10 @@ package cellstore import ( "context" + "errors" "fmt" + + "github.com/Agent-Field/codeaf/internal/filelock" "net" "os" "os/exec" @@ -37,6 +40,9 @@ const retryAfter = 30 * time.Second // how long its final group flush and catalog close will take (U1/C28). var daemonExits sync.Map +var daemonOwners sync.Map +var startLocks sync.Map + var ( startMu sync.Mutex failed = map[string]failure{} // socket -> last failed start @@ -52,17 +58,58 @@ type failure struct { // process won the race for the socket) is a success: the daemon exits quietly // when it loses. func startAndDial(ctx context.Context, d Daemon) (net.Conn, error) { - startMu.Lock() - defer startMu.Unlock() + gate, _ := startLocks.LoadOrStore(d.Socket, make(chan struct{}, 1)) + lock := gate.(chan struct{}) + timer := time.NewTimer(startWait) + defer timer.Stop() + select { + case lock <- struct{}{}: + defer func() { <-lock }() + case <-ctx.Done(): + return nil, ctx.Err() + case <-timer.C: + if heldWithoutSocket(d.Socket) { + return nil, &SavingStuckError{Socket: d.Socket} + } + return nil, errors.New("saving is waiting for the storage process to start; try again shortly") + } if conn, err := dial(ctx, d.Socket); err == nil { - return conn, nil // an earlier caller started it while this one waited + return conn, nil } - if f, ok := failed[d.Socket]; ok && time.Since(f.at) < retryAfter { - return nil, f.err + startMu.Lock() + f, cached := failed[d.Socket] + startMu.Unlock() + if cached && time.Since(f.at) < retryAfter { + var stuck *SavingStuckError + if !errors.As(f.err, &stuck) || heldWithoutSocket(d.Socket) { + return nil, f.err + } + } + if heldWithoutSocket(d.Socket) { + timer := time.NewTimer(missingSocketWithin) + defer timer.Stop() + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-timer.C: + } + if conn, err := dial(ctx, d.Socket); err == nil { + return conn, nil + } + if heldWithoutSocket(d.Socket) && !recoverOwnedDaemon(ctx, d.Socket) { + err := &SavingStuckError{Socket: d.Socket} + startMu.Lock() + failed[d.Socket] = failure{at: time.Now(), err: err} + startMu.Unlock() + return nil, err + } } + conn, err := launchAndDial(ctx, d) if err != nil { + startMu.Lock() failed[d.Socket] = failure{at: time.Now(), err: err} + startMu.Unlock() } return conn, err } @@ -93,9 +140,12 @@ func launch(d Daemon) (<-chan struct{}, error) { exited := make(chan struct{}) remember(cmd.Process, exited) daemonExits.Store(d.Socket, (<-chan struct{})(exited)) + owner := &spawnedDaemon{process: cmd.Process, exited: exited} + daemonOwners.Store(d.Socket, owner) go func() { // reap it; it outlives this call by design _ = cmd.Wait() close(exited) + daemonOwners.CompareAndDelete(d.Socket, owner) daemonExits.CompareAndDelete(d.Socket, (<-chan struct{})(exited)) }() return exited, nil @@ -112,12 +162,39 @@ func awaitDial(ctx context.Context, socket string, exited <-chan struct{}) (net. for { conn, err := dial(ctx, socket) if err == nil || time.Now().After(deadline) || ctx.Err() != nil { + if err != nil && heldWithoutSocket(socket) { + return nil, &SavingStuckError{Socket: socket} + } return conn, err } select { case <-exited: + if heldWithoutSocket(socket) { + return nil, &SavingStuckError{Socket: socket} + } return dial(ctx, socket) case <-time.After(10 * time.Millisecond): } } } + +// A lock can precede bind briefly during startup. One quiet wait distinguishes +// that race from a listener whose socket disappeared while it stayed alive. +const missingSocketWithin = 250 * time.Millisecond + +func heldWithoutSocket(socket string) bool { + if _, err := os.Lstat(socket); !errors.Is(err, os.ErrNotExist) { + return false + } + file, err := os.OpenFile(socket+".lock", os.O_RDWR, 0) + if err != nil { + return false + } + defer file.Close() + err = filelock.Lock(file, true, true) + if err == nil { + _ = filelock.Unlock(file) + return false + } + return filelock.IsBusy(err) +} diff --git a/internal/cellstore/publication.go b/internal/cellstore/publication.go new file mode 100644 index 0000000000..27f3bdd574 --- /dev/null +++ b/internal/cellstore/publication.go @@ -0,0 +1,37 @@ +package cellstore + +import ( + "sync" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +var publication = struct { + sync.Mutex + roots map[string]int +}{roots: map[string]int{}} + +// RequirePublication keeps recovery notices behind the chat's publish boundary, +// including startup turns sealed before the drive had its first fence. +func RequirePublication(c cell.Cell) func() { + publication.Lock() + publication.roots[c.Root]++ + publication.Unlock() + var once sync.Once + return func() { + once.Do(func() { + publication.Lock() + defer publication.Unlock() + publication.roots[c.Root]-- + if publication.roots[c.Root] == 0 { + delete(publication.roots, c.Root) + } + }) + } +} + +func requiresPublication(c cell.Cell) bool { + publication.Lock() + defer publication.Unlock() + return publication.roots[c.Root] > 0 +} diff --git a/internal/cellstore/sealwatch.go b/internal/cellstore/sealwatch.go index 4b635def13..9c9c5c0366 100644 --- a/internal/cellstore/sealwatch.go +++ b/internal/cellstore/sealwatch.go @@ -1,8 +1,11 @@ package cellstore import ( + "os" "sync" "time" + + "github.com/Agent-Field/codeaf/internal/cell" ) // TailQuiet is how long the record must be still, after a turn ended, before @@ -26,6 +29,8 @@ const TailQuiet = 2 * time.Second // surface to draw a segment on sets Say, and the sentences go there as they // happen instead of waiting to be taken. type SealWatch struct { + turn cell.Cell + // Say, when set, receives each sentence as it happens. Say func(string) // OnTurnEnd, when set, hears each time the agent finishes a turn. The door @@ -80,6 +85,9 @@ func (w *SealWatch) fail(cause string) { } func (w *SealWatch) recover() { + if w.turn.Root != "" && !w.ownTurnSaved() { + return + } if w.failing { w.say("sealing works again, and the calls made meanwhile are sealed now") } @@ -167,10 +175,13 @@ func (w *SealWatch) TurnEnded() { } } -// Failing reports whether the most recent seal failed. +// Failing reports whether this chat still has a saving failure. func (w *SealWatch) Failing() bool { w.mu.Lock() defer w.mu.Unlock() + if w.failing && w.turn.Root != "" { + w.recover() + } return w.failing } @@ -178,6 +189,9 @@ func (w *SealWatch) Failing() bool { func (w *SealWatch) Take() string { w.mu.Lock() defer w.mu.Unlock() + if w.failing && w.turn.Root != "" { + w.recover() + } if len(w.said) == 0 { return "" } @@ -185,3 +199,45 @@ func (w *SealWatch) Take() string { w.said = w.said[1:] return sentence } + +// TrackTurn binds recovery notices to this chat's saved transcript. +func (w *SealWatch) TrackTurn(root string) { + w.mu.Lock() + defer w.mu.Unlock() + w.turn = cell.Cell{Root: root} +} + +// A successful background seal cannot speak for the chat's unsaved answer. +// Synced turns also retain their failure until the publish boundary covers it. +func (w *SealWatch) ownTurnSaved() bool { + captures.Lock() + state := captures.cells[w.turn.Root] + if state != nil { + select { + case <-state.done: + if state.err != nil { + captures.Unlock() + return false + } + default: + captures.Unlock() + return false + } + } + captures.Unlock() + head, err := Head(w.turn) + if err != nil || head == nil { + return false + } + info, err := os.Stat(rel(w.turn, cell.TranscriptPath)) + if err != nil || head.Receipt.Transcript.End != info.Size() { + return false + } + if head.Turn.Fence != 0 || requiresPublication(w.turn) { + unpublished, _, err := Unpublished(w.turn, "") + if err != nil || unpublished != "" { + return false + } + } + return true +} diff --git a/internal/cellstore/sealwatch_test.go b/internal/cellstore/sealwatch_test.go index 36900cf921..3c731449d8 100644 --- a/internal/cellstore/sealwatch_test.go +++ b/internal/cellstore/sealwatch_test.go @@ -3,6 +3,7 @@ package cellstore import ( "context" "errors" + "os" "path/filepath" "strings" "testing" @@ -196,3 +197,73 @@ func TestWaitingArmsOnlyTheInitialIdleChat(t *testing.T) { t.Fatal("actual turn end was lost") } } + +// Contract A3 keeps an unrelated seal from announcing recovery of this chat. +func TestSealWatchWaitsForItsOwnTurnAndPublish(t *testing.T) { + c := newCell(t) + transcript := rel(c, ".cell/transcript.jsonl") + if err := os.WriteFile(transcript, []byte("the unsaved reply\n"), 0600); err != nil { + t.Fatal(err) + } + engine := (&fakeEngine{}).engine(t) + engine.Identity = Identity{Device: "this-computer", Fence: 1} + var watch SealWatch + watch.TrackTurn(c.Root) + watch.Report(errors.New("turn seal failed")) + watch.Take() + other := newCell(t) + if _, err := engine.Seal(t.Context(), other, TurnInfo{}); err != nil { + t.Fatal(err) + } + watch.Report(nil) + if !watch.Failing() || watch.Take() != "" { + t.Fatal("unrelated successful seal claimed recovery") + } + saved, err := engine.Seal(t.Context(), c, TurnInfo{}) + if err != nil { + t.Fatal(err) + } + watch.Report(nil) + if !watch.Failing() || watch.Take() != "" { + t.Fatal("unpublished turn claimed recovery") + } + if err := NotePublished(c, saved.Turn.ID); err != nil { + t.Fatal(err) + } + watch.Report(nil) + if watch.Failing() || !strings.Contains(watch.Take(), "sealing works again") || watch.Take() != "" { + t.Fatal("own saved and published turn did not say recovery once") + } + watch.Report(nil) + if watch.Take() != "" { + t.Fatal("recovery repeated") + } +} + +// Contract A3 also waits for publication when startup sealed before its first fence. +func TestSealWatchStartupTurnStillWaitsForPublish(t *testing.T) { + c := newCell(t) + if err := os.WriteFile(rel(c, ".cell/transcript.jsonl"), []byte("startup reply\n"), 0600); err != nil { + t.Fatal(err) + } + release := RequirePublication(c) + defer release() + var watch SealWatch + watch.TrackTurn(c.Root) + watch.Report(errors.New("startup turn failed")) + watch.Take() + saved, err := (&fakeEngine{}).engine(t).Seal(t.Context(), c, TurnInfo{}) + if err != nil { + t.Fatal(err) + } + watch.Report(nil) + if !watch.Failing() || watch.Take() != "" { + t.Fatal("startup seal claimed recovery before publishing") + } + if err := NotePublished(c, saved.Turn.ID); err != nil { + t.Fatal(err) + } + if watch.Failing() || !strings.Contains(watch.Take(), "sealing works again") { + t.Fatal("published startup turn did not recover") + } +} diff --git a/internal/cellstore/transport.go b/internal/cellstore/transport.go index 0e63f09022..947790ae2c 100644 --- a/internal/cellstore/transport.go +++ b/internal/cellstore/transport.go @@ -139,7 +139,8 @@ var _ Transport = Fallback{} // Do implements Transport. func (f Fallback) Do(ctx context.Context, t Target, op Op) ([]byte, error) { out, err := f.Primary.Do(ctx, t, op) - if errors.Is(err, ErrUnavailable) { + var stuck *SavingStuckError + if errors.Is(err, ErrUnavailable) && !errors.As(err, &stuck) { return f.Secondary.Do(ctx, t, op) } return out, err diff --git a/internal/manual/chat/sessions-and-rewind.md b/internal/manual/chat/sessions-and-rewind.md index 1b3770385f..0b447932cc 100644 --- a/internal/manual/chat/sessions-and-rewind.md +++ b/internal/manual/chat/sessions-and-rewind.md @@ -985,3 +985,20 @@ In windows that can keep several chats open, its connection opens in the background, with an opening notice on the current chat. You can select another tab or cancel with Escape while the engine is busy. A canceled opening does not replace the selected chat when it finishes. Chats already open in this window switch directly. + +## Saving stopped or could not save this chat + +If the storage process is alive but its socket has disappeared, codeaf says +“saving is stuck”. Your turns stay on this computer. When codeaf can safely +replace its own unreachable storage process, saving resumes with a fresh one. +Otherwise the process exits after its idle timeout, within 10 minutes, and the +next save starts it again. + +“sealing works again, and the calls made meanwhile are sealed now” appears only +after this chat's own turns are saved again. A chat that syncs must also have +published those turns. Another task saving successfully does not clear this +chat's saving failure. + +If closing cannot finish saving, the error names the local transcript that keeps +the unsaved turns. Keep that file: after storage becomes reachable again, the +turns can be saved and published, or kept separately when you continue the chat. diff --git a/internal/session/agent.go b/internal/session/agent.go index 54ef9324c0..fba47a4bfb 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -139,6 +139,9 @@ func newAgent(config Config, client Completer) (*Agent, error) { if config.WorkspaceURI != "" { system += "\n\nWORKSPACE STORAGE\nUse mktemp or $TMPDIR for temporary files and $XDG_CACHE_HOME for ordinary caches. These locations are outside the files that a task lands. Keep task deliverables in the assigned working directory. The directory is an assignment, not a shell sandbox; external and remote writes still need an explicit purpose." } + if watch, ok := config.Seals.(interface{ TrackTurn(string) }); ok && config.Place.Dir != "" && !config.InTask && !config.Errand { + watch.TrackTurn(config.Place.Dir) + } agent := &Agent{ config: config, system: system, @@ -2405,7 +2408,8 @@ func (f sessionCompleter) FallbackModels(model string) []string { // bounded because Close is what the person's quit reaches: a turn wedged // inside a tool must not hold the process open, and past the grace the journal // simply stops accepting writes rather than writing to a closed descriptor. -func (a *Agent) Close() error { +func (a *Agent) Close() (saveErr error) { + defer func() { saveErr = SavingError(a.config.SessionFile, saveErr) }() // WHAT THE RECORD ALREADY HOLDS IS SETTLED BEFORE THE DOOR SHUTS, so an idle // session that read a landing in its last turn does not re-tell it tomorrow. // What the record does NOT hold stays owed, which is the whole point: a note diff --git a/internal/session/saving_error.go b/internal/session/saving_error.go new file mode 100644 index 0000000000..1da2b12b8a --- /dev/null +++ b/internal/session/saving_error.go @@ -0,0 +1,30 @@ +package session + +import ( + "errors" + "fmt" +) + +// UnsavedChatError keeps the local recovery location beside a saving failure. +type UnsavedChatError struct { + Transcript string + Cause error +} + +func (e *UnsavedChatError) Error() string { + return fmt.Sprintf("could not save this chat; unsaved turns are kept in %s: %v", e.Transcript, e.Cause) +} + +func (e *UnsavedChatError) Unwrap() error { return e.Cause } + +// SavingError adds the chat's recovery location to a seal or publish failure. +func SavingError(transcript string, cause error) error { + if cause == nil { + return nil + } + var kept *UnsavedChatError + if errors.As(cause, &kept) { + return cause + } + return &UnsavedChatError{Transcript: transcript, Cause: cause} +} diff --git a/internal/session/saving_error_test.go b/internal/session/saving_error_test.go new file mode 100644 index 0000000000..50a26cf980 --- /dev/null +++ b/internal/session/saving_error_test.go @@ -0,0 +1,47 @@ +package session + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/knowledge" +) + +type failedSavingSeat struct { + executor.Seat + err error +} + +func (s failedSavingSeat) Drain(context.Context) error { return s.err } + +// Contract A3 names the local transcript when close cannot seal the chat. +func TestCloseSavingFailureNamesTheRetainedTurns(t *testing.T) { + cause := errors.New("saving is stuck") + place := newPlace(t, false) + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.Place, config.SessionFile = place, place.Transcript() + config.knowledgeReadVault = knowledge.Open(t.TempDir()) + config.Seat = failedSavingSeat{Seat: executor.Host, err: cause} + }) + err := agent.Close() + var unsaved *UnsavedChatError + if !errors.As(err, &unsaved) || unsaved.Transcript != place.Transcript() || !errors.Is(err, cause) || !strings.Contains(err.Error(), place.Transcript()) { + t.Fatalf("close lost the saved recovery location: %v", err) + } +} + +// Contract A3 gives publish failures the same local recovery location. +func TestPublishSavingFailureNamesTheRetainedTurns(t *testing.T) { + cause := errors.New("relay unavailable") + err := SavingError("/temporary/chat/transcript.jsonl", cause) + var unsaved *UnsavedChatError + if !errors.As(err, &unsaved) || !errors.Is(err, cause) || !strings.Contains(err.Error(), unsaved.Transcript) { + t.Fatal(err) + } + if SavingError("unused", nil) != nil { + t.Fatal("successful save became a failure") + } +} diff --git a/internal/session/setup.go b/internal/session/setup.go index 864bc805c0..a77526cc2d 100644 --- a/internal/session/setup.go +++ b/internal/session/setup.go @@ -37,7 +37,7 @@ const ( // SealState is what a session's seat knows about whether its last seal held. // cellstore.SealWatch is the one implementation. type SealState interface { - // Failing reports whether the most recent seal failed. + // Failing reports whether this chat still has a saving failure. Failing() bool // Take hands over the next sentence worth saying, and "" when there is none. Take() string diff --git a/internal/syncsetup/drive.go b/internal/syncsetup/drive.go index f37080166b..08cd7cf81b 100644 --- a/internal/syncsetup/drive.go +++ b/internal/syncsetup/drive.go @@ -49,10 +49,11 @@ type Drive struct { directoryID string recordedFence uint64 - cancel context.CancelFunc - done chan struct{} - once sync.Once - closeErr error + cancel context.CancelFunc + done chan struct{} + once sync.Once + closeErr error + publicationDone func() sent string // fingerprint of the vault as last sent; touched by the flush loop and Close only @@ -94,6 +95,7 @@ func (s *Sync) Drive(ctx context.Context, eng cellstore.Engine, c cell.Cell, opt if err := d.recordSendLease(); err != nil { return nil, errors.Join(err, s.Dir.Release(ctx, drv.ID(), drv.Fence)) } + d.publicationDone = cellstore.RequirePublication(c) d.catchUp(drv) run, cancel := context.WithCancel(context.WithoutCancel(ctx)) d.cancel = cancel @@ -518,6 +520,9 @@ func (d *Drive) Close(ctx context.Context) error { d.syncVault(ctx) d.closeErr = d.batcher.Close(ctx) d.scope.Settle() // a publish that failed after the last flush line still counts + if d.closeErr == nil && d.publicationDone != nil { + d.publicationDone() + } }) return d.closeErr } From 77eb081ce6cafb36f503c3d5c8d2bf0304d7ffea Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 21:32:36 -0400 Subject: [PATCH 04/28] fix(cli): cluster status says the plain sentence when the relay is down When the relay cannot be reached, cluster status and the leadership commands now use the same plain network sentence as devices, with no Go error text, and still exit non-zero. The sentence is shared rather than copied, and a removed device's own sentence still takes precedence. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/cluster.go | 6 ++--- cmd/codeaf/cluster_unreachable_test.go | 26 +++++++++++++++++++ cmd/codeaf/devices.go | 23 +++++++++++++++- .../manual/chat/running-from-the-terminal.md | 8 ++++++ 4 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 cmd/codeaf/cluster_unreachable_test.go diff --git a/cmd/codeaf/cluster.go b/cmd/codeaf/cluster.go index 6228a0e0df..732986e919 100644 --- a/cmd/codeaf/cluster.go +++ b/cmd/codeaf/cluster.go @@ -45,20 +45,20 @@ func runCluster(args []string) error { } ctx, cancel := context.WithTimeout(context.Background(), clusterctl.ForwardTimeout) defer cancel() - return clusterCommand(ctx, s, args[0], os.Stdout) + return explainSyncUnreachable(clusterCommand(ctx, s, args[0], os.Stdout), s.Relay) } func clusterCommand(ctx context.Context, c clusterDoor, verb string, out io.Writer) error { if verb == "lead" { r, err := c.LeadCluster(ctx, 0) if err != nil { - return explainRemovedDevice(err) + return explainSyncUnreachable(explainRemovedDevice(err), "") } _, err = fmt.Fprintf(out, "computer %d is coordinating · term %d · revision %d · read barrier %d\n", r.Node, r.Term, r.Revision, r.Barrier) return err } s, err := c.ClusterStatus(ctx) if err != nil { - return explainRemovedDevice(err) + return explainSyncUnreachable(explainRemovedDevice(err), "") } if s.Leader != 0 { fmt.Fprintf(out, "coordinator %d\n", s.Leader) diff --git a/cmd/codeaf/cluster_unreachable_test.go b/cmd/codeaf/cluster_unreachable_test.go new file mode 100644 index 0000000000..07ffaf9c89 --- /dev/null +++ b/cmd/codeaf/cluster_unreachable_test.go @@ -0,0 +1,26 @@ +package main + +import ( + "bytes" + "context" + "errors" + "net/url" + "strings" + "testing" +) + +// B5 gives an unreachable relay the same words at both command doors. +func TestClusterStatusUsesTheDevicesUnreachableSentence(t *testing.T) { + var out bytes.Buffer + failure := &url.Error{Op: "Get", URL: "http://relay.invalid/v2/cluster/control", Err: errors.New("dial tcp: connect: connection refused")} + err := clusterCommand(context.Background(), clusterFixture{err: failure}, "status", &out) + want := "the sync service at http://relay.invalid cannot be reached from here — check this computer's network" + if err == nil || err.Error() != want || out.Len() != 0 { + t.Fatalf("status: %v output=%q", err, out.String()) + } + for _, raw := range []string{"Get ", "dial tcp", "connection refused", "/v2/cluster/control"} { + if strings.Contains(err.Error(), raw) { + t.Fatalf("status exposed %q: %v", raw, err) + } + } +} diff --git a/cmd/codeaf/devices.go b/cmd/codeaf/devices.go index 2b9b8ae188..82ff954d2e 100644 --- a/cmd/codeaf/devices.go +++ b/cmd/codeaf/devices.go @@ -18,6 +18,8 @@ import ( "context" "errors" "fmt" + "net" + "net/url" "os" "slices" "strings" @@ -411,13 +413,32 @@ func (c chatsKind) explain(err error) error { case errors.Is(err, wireauth.ErrSkew): return errors.New(chatlist.ClockOff) case errors.Is(err, directory.ErrUnreachable): - return fmt.Errorf("the sync service at %s cannot be reached from here — check this computer's network", c.relay) + return explainSyncUnreachable(err, c.relay) case errors.Is(err, directory.ErrNotFound): return errors.New("that device is not in your list any more — `codeaf devices` shows the ones there are") } return err } +// explainSyncUnreachable keeps network failures in the same words at every +// command door while preserving the cause for exit handling. +func explainSyncUnreachable(err error, relay string) error { + if errors.Is(err, directory.ErrRevoked) { + return err + } + var request *url.Error + var network net.Error + if !errors.Is(err, directory.ErrUnreachable) && !errors.As(err, &request) && !errors.As(err, &network) { + return err + } + if relay == "" && request != nil { + if address, parseErr := url.Parse(request.URL); parseErr == nil { + relay = address.Scheme + "://" + address.Host + } + } + return toldLine{line: fmt.Sprintf("the sync service at %s cannot be reached from here — check this computer's network", relay), err: err} +} + // explainRemovedDevice gives every command the same removal and recovery words. func explainRemovedDevice(err error) error { if errors.Is(err, directory.ErrRevoked) { diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index d95bbb8bd1..adc299c1fd 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -1,5 +1,13 @@ # Commands you type in a terminal +## codeaf cluster status when the sync service cannot be reached + +If the service cannot be reached, `codeaf cluster status` exits with a failure +status and uses the same sentence as `codeaf devices`: +`the sync service at
cannot be reached from here — check this computer's network`. +The address is your configured sync service. Check this computer's network +and that the service is running, then try the command again. + ## Is there a newer version — update a dev build — keep a dev build up to date — latest dev — /update — why does it say this every time I start Every launch of a release build checks whether there is a newer version: the From 08033c6efbc5fdbaf48e3669f541bd59168aa00a Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 21:33:43 -0400 Subject: [PATCH 05/28] fix(chat): words typed while a chat opens stay with it, and closing never freezes - Keys and pastes typed while /new opens belong to the new chat; Esc gives them back to the chat you were in, unsent, and says so. - A moved copy keeps its project label after this computer is removed. - Home never offers to continue this computer's own chat. - The first Enter in a copy held elsewhere asks a fresh, bounded ownership read instead of trusting a hint, so a reachable relay no longer reads as "other machines unreachable". - Closing a chat and quitting run the engine work off the update loop with a bound, and keys pressed meanwhile cannot send another chat's draft. - A final save that fails is said once on the terminal after the screen is restored, in one plain sentence. - The surface carries a once-per-outage "not syncing" notice behind an optional renewal-status interface. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/chatv3_process.go | 2 +- cmd/codeaf/chatv3_surface.go | 10 + cmd/codeaf/final_save.go | 46 +++ cmd/codeaf/main.go | 5 +- cmd/codeaf/quit_save_test.go | 105 +++++++ cmd/codeaf/synctake.go | 33 +- internal/chatlist/copy.go | 2 + internal/chatlist/rows.go | 14 + .../continuing-a-chat-on-another-computer.md | 24 ++ internal/manual/chat/keys.md | 33 ++ internal/tui3/app.go | 45 ++- internal/tui3/conversation_close.go | 55 ++++ internal/tui3/conversation_open.go | 41 ++- internal/tui3/detach.go | 2 +- internal/tui3/homeband_devices.go | 1 + internal/tui3/homeband_resume.go | 13 +- internal/tui3/homepanel_heldelsewhere.go | 2 +- internal/tui3/keeper.go | 83 +++-- internal/tui3/manual_devices_test.go | 1 + internal/tui3/relay_surface_test.go | 288 ++++++++++++++++++ internal/tui3/renewal_notice.go | 41 +++ internal/tui3/takeover.go | 2 +- internal/tui3/tui3.go | 5 + internal/tui3/welcome.go | 2 +- 24 files changed, 817 insertions(+), 38 deletions(-) create mode 100644 cmd/codeaf/final_save.go create mode 100644 cmd/codeaf/quit_save_test.go create mode 100644 internal/tui3/conversation_close.go create mode 100644 internal/tui3/relay_surface_test.go create mode 100644 internal/tui3/renewal_notice.go diff --git a/cmd/codeaf/chatv3_process.go b/cmd/codeaf/chatv3_process.go index d2d06f97ae..addc99eef6 100644 --- a/cmd/codeaf/chatv3_process.go +++ b/cmd/codeaf/chatv3_process.go @@ -1088,6 +1088,6 @@ func v3RehomeLegacyMemories(brain *store.Store) { // closed. A bounded drain must never silently acknowledge a pending save (D3). func closeV3Agent(agent interface{ Close() error }) { if err := agent.Close(); err != nil { - fmt.Fprintln(os.Stderr, "codeaf: "+err.Error()) + recordFinalSaveFailure(err) } } diff --git a/cmd/codeaf/chatv3_surface.go b/cmd/codeaf/chatv3_surface.go index 81c1c70e0a..bbc07aeba3 100644 --- a/cmd/codeaf/chatv3_surface.go +++ b/cmd/codeaf/chatv3_surface.go @@ -47,6 +47,16 @@ var ( // it would lack is everything below, which is why [TestOnlyTheSurfaceHelperRunsTheV3Surface] // reads this package's sources rather than trusting the next door to remember. func runSurface(ctx context.Context, options tui3.Options) error { + finalSaving.Lock() + finalSaving.surface = true + finalSaving.Unlock() + previousSaveFailure := options.SaveFailure + options.SaveFailure = func(err error) { + recordFinalSaveFailure(err) + if previousSaveFailure != nil { + previousSaveFailure(err) + } + } revision := surfaceRevision() executable, executableErr := surfaceRunningExecutable() curl := codeupdate.CurlCommand diff --git a/cmd/codeaf/final_save.go b/cmd/codeaf/final_save.go new file mode 100644 index 0000000000..60538e75e0 --- /dev/null +++ b/cmd/codeaf/final_save.go @@ -0,0 +1,46 @@ +package main + +import ( + "context" + "fmt" + "os" + "sync" +) + +const quitSaveFailedWord = "codeaf could not save this chat; unsaved turns stay on this computer and return on the next start." + +var finalSaving struct { + sync.Mutex + failure error + surface bool +} + +// Close can fail while the alternate screen still owns the terminal. Retain +// that result until all surface and process cleanup has returned. +func recordFinalSaveFailure(err error) { + if err == nil { + return + } + finalSaving.Lock() + defer finalSaving.Unlock() + if finalSaving.failure == nil { + finalSaving.failure = err + } +} + +func finishFinalSaving(ctx context.Context) bool { + recordFinalSaveFailure(flushFinalSaves(ctx)) + finalSaving.Lock() + err, surface := finalSaving.failure, finalSaving.surface + finalSaving.failure, finalSaving.surface = nil, false + finalSaving.Unlock() + if err == nil { + return true + } + terminal := os.Stderr + if surface { + terminal = os.Stdout + } + fmt.Fprintln(terminal, quitSaveFailedWord) + return false +} diff --git a/cmd/codeaf/main.go b/cmd/codeaf/main.go index 491c793f0b..6015964a08 100644 --- a/cmd/codeaf/main.go +++ b/cmd/codeaf/main.go @@ -44,6 +44,8 @@ import ( tea "github.com/charmbracelet/bubbletea" ) +var flushFinalSaves = cellstore.Flush + func main() { if startupMaintenanceNeeded(os.Args[1:]) { home.Adopt(log.Printf) @@ -196,8 +198,7 @@ func execute() (code int) { defer func() { ctx, cancel := context.WithTimeout(context.Background(), cellstore.SealWithin) defer cancel() - if err := cellstore.Flush(ctx); err != nil { - fmt.Fprintln(os.Stderr, "codeaf: could not finish saving this chat:", err) + if !finishFinalSaving(ctx) { if code == 0 { code = 1 } diff --git a/cmd/codeaf/quit_save_test.go b/cmd/codeaf/quit_save_test.go new file mode 100644 index 0000000000..a1d48eff7d --- /dev/null +++ b/cmd/codeaf/quit_save_test.go @@ -0,0 +1,105 @@ +package main + +import ( + "context" + "errors" + "io" + "os" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/tui3" +) + +type failedSaveAgent struct{} + +func (failedSaveAgent) Close() error { + return errors.New("snapshot: dial unix /missing.sock: connection refused") +} + +// B7 puts the failure on the restored screen even when stderr is a log. +func TestQuitSaveFailureUsesTheRestoredSurfaceOutput(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Cleanup(cell.UseAvailabilityInTests(false)) + oldRun, oldFlush := runSurfaceProgram, flushFinalSaves + oldOut, oldErr := os.Stdout, os.Stderr + out, err := os.CreateTemp(t.TempDir(), "screen") + if err != nil { + t.Fatal(err) + } + defer out.Close() + errs, err := os.CreateTemp(t.TempDir(), "errors") + if err != nil { + t.Fatal(err) + } + defer errs.Close() + os.Stdout, os.Stderr = out, errs + t.Cleanup(func() { + runSurfaceProgram, flushFinalSaves = oldRun, oldFlush + os.Stdout, os.Stderr = oldOut, oldErr + }) + runSurfaceProgram = func(context.Context, tui3.Options) error { + closeV3Agent(failedSaveAgent{}) + closeV3Agent(failedSaveAgent{}) + _, err := io.WriteString(os.Stdout, "screen restored\n") + return err + } + if err := runSurface(context.Background(), tui3.Options{ProfileDir: t.TempDir()}); err != nil { + t.Fatal(err) + } + flushFinalSaves = func(context.Context) error { return failedSaveAgent{}.Close() } + defer telemetryArgs("--version")() + if code := execute(); code == 0 { + t.Fatal("failed save exited successfully") + } + if _, err := out.Seek(0, io.SeekStart); err != nil { + t.Fatal(err) + } + printed, err := io.ReadAll(out) + if err != nil { + t.Fatal(err) + } + want := "codeaf could not save this chat; unsaved turns stay on this computer and return on the next start." + if strings.Count(string(printed), want) != 1 || !strings.HasSuffix(string(printed), want+"\n") || !strings.HasPrefix(string(printed), "screen restored\n") { + t.Fatalf("restored terminal: %q", printed) + } + if _, err := errs.Seek(0, io.SeekStart); err != nil { + t.Fatal(err) + } + raw, err := io.ReadAll(errs) + if err != nil || len(raw) != 0 { + t.Fatalf("error log=%q read=%v", raw, err) + } +} + +// B7 reports a final save failure once without exposing the engine's error. +func TestQuitSaveFailureIsOnePlainSentence(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + finalSaving.Lock() + finalSaving.surface = false + finalSaving.Unlock() + oldFlush := flushFinalSaves + defer func() { flushFinalSaves = oldFlush }() + flushFinalSaves = func(context.Context) error { + return errors.New("seal: snapshot: engine unavailable: dial unix /missing.sock: no such file") + } + defer telemetryArgs("--version")() + oldErr := os.Stderr + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stderr = w + defer func() { os.Stderr = oldErr; r.Close() }() + code := execute() + w.Close() + printed, err := io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + want := "codeaf could not save this chat; unsaved turns stay on this computer and return on the next start.\n" + if code == 0 || string(printed) != want { + t.Fatalf("exit=%d terminal=%q", code, printed) + } +} diff --git a/cmd/codeaf/synctake.go b/cmd/codeaf/synctake.go index 1cf3f16c23..4cec73deff 100644 --- a/cmd/codeaf/synctake.go +++ b/cmd/codeaf/synctake.go @@ -10,6 +10,7 @@ import ( "github.com/Agent-Field/codeaf/internal/automation" "github.com/Agent-Field/codeaf/internal/cell" "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/chatlist" "github.com/Agent-Field/codeaf/internal/devname" "github.com/Agent-Field/codeaf/internal/directory" "github.com/Agent-Field/codeaf/internal/dirwatch" @@ -39,8 +40,9 @@ func wireSync(o *tui3.Options) { return } if o.Machines == nil { - o.Machines = s + o.Machines = surfaceMachines{s} } + o.DeviceID = s.Device.ID() if o.Fleet == nil { o.Fleet = s } @@ -63,6 +65,35 @@ func wireSync(o *tui3.Options) { guard.Go("sync/standing-live", func() { watchStanding(s.Home, s.Follow, surfaceNotices.SayHomeOnce) }) } +// surfaceMachines keeps display hints out of the decision to send a message. +type surfaceMachines struct{ *syncsetup.Sync } + +func (s surfaceMachines) RowsForSend(ctx context.Context) ([]chatlist.Row, uint64, error) { + if s.Relay != "" { + current, ok, err := syncsetup.Open(s.Home) + if err != nil { + return nil, 0, err + } + if !ok { + return nil, 0, nil + } + s.Sync = current + } + if s.Removed() { + return nil, 0, directory.ErrRevoked + } + if syncsetup.Quiet(s.Home) { + return nil, 0, nil + } + l, err := s.Dir.List(ctx) + if err != nil { + return nil, 0, err + } + key := directory.MetadataKey(s.Identity.CellKey()) + open := func(sealed string) (string, error) { return directory.OpenName(key, sealed) } + return chatlist.Rows(l, s.Device.ID(), open), l.Version, nil +} + // standingWithin bounds the launch-time check so a silent relay costs nothing. const standingWithin = 15 * time.Second diff --git a/internal/chatlist/copy.go b/internal/chatlist/copy.go index 6b55a7ad10..85dbb45c4d 100644 --- a/internal/chatlist/copy.go +++ b/internal/chatlist/copy.go @@ -18,6 +18,8 @@ const ( NoIdentity = "this machine has no identity yet: codeaf identity import" SyncOff = "sync is off: set CODEAF_SYNC_URL to your sync address" Unreachable = "other machines unreachable" + SyncPaused = "this chat is not syncing because other machines are unreachable; it keeps working here" + SyncRestored = "this chat is syncing again" ClockOff = "this computer's clock is off by more than 5 minutes" // What the relay's refusals say (one sentence each, in the order the // cellsync table lists them). None names a code or a number of requests: diff --git a/internal/chatlist/rows.go b/internal/chatlist/rows.go index b9b63e0642..180895df88 100644 --- a/internal/chatlist/rows.go +++ b/internal/chatlist/rows.go @@ -69,6 +69,20 @@ type Versioned interface { RowsAt(ctx context.Context) ([]Row, uint64, error) } +// SendSource reads ownership for a submitted message independently of display +// hints. The context bounds any wait for a first directory reading. +type SendSource interface { + RowsForSend(context.Context) ([]Row, uint64, error) +} + +// RowsForSend uses the source's ownership door when it has one. +func RowsForSend(ctx context.Context, src Source) ([]Row, uint64, error) { + if fresh, ok := src.(SendSource); ok { + return fresh.RowsForSend(ctx) + } + return RowsAt(ctx, src) +} + // RowsAt reads src and reports the version it was read at, or 0 when the // source does not keep versions. func RowsAt(ctx context.Context, src Source) ([]Row, uint64, error) { 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 5d58ccf214..20d910cf1b 100644 --- a/internal/manual/chat/continuing-a-chat-on-another-computer.md +++ b/internal/manual/chat/continuing-a-chat-on-another-computer.md @@ -29,6 +29,11 @@ Moving chats and pairing are available by default on Linux, macOS and native Win Before showing this question for a send, codeaf checks which computer holds the chat now. An older listing alone cannot raise it. If the check cannot reach your other devices and this window already knows the chat moved away, your words stay in the box and nothing is sent. Continuing a chat this computer already holds keeps its open conversation and your typed words. +The first Enter waits up to five seconds for a current reading, including in +a freshly opened copy. `other machines unreachable` means that reading +failed; it never means a reading has not happened yet. Your words stay unsent +while the reading is in progress. + After `continue here` succeeds, those same words, pasted text and attachments are in the arrived chat's box. Press enter again when you want to send them. Choosing `leave it there` or a move that fails also keeps the whole draft. Taking a chat from Home restores that chat's own saved draft, or leaves its box empty. Words typed for a different chat stay with that chat, even when both work in one place on disk. A draft held behind a send's continuation card stays with the chat that raised it. @@ -63,6 +68,9 @@ alt+c continue here · alt+x not now The line follows the devices row: it stands while the device is offline or has let go of the chat, even if that became true after you opened codeaf, and goes away if the device comes back. A device that is online and still working on the chat is never offered. +Home never offers this computer's own chat as work to continue from another +computer, even when an older record says this computer went offline. + To bring work from a device that is online, use the devices row: `alt+m bring work here`. The page *Adding a device to your devices, approving it, and removing it* shows the row and the dots. ## What happens when the chat arrives — the banner Moved from tower, and what was not brought @@ -129,6 +137,22 @@ Moving chats and pairing are available by default on Linux, macOS and native Win A chat arriving here for the first time works in its own copy. It is filed under the folder it came from, apart from conversations in this computer's own folder of the same name. Home and the keys-row label say `proj-a (copy from another machine)`. This remains a copy even if a folder with the original path happens to exist on this computer. Taking back a chat that already works in your own project restores that project in place. An unknown origin is left unnamed. +Removing this computer from your devices does not change those project +labels. Switching away and back keeps the same copy label or ordinary project +name. + +## This chat is not syncing — other machines unreachable, working offline + +If this computer holds the chat and renewals have failed for longer than one +renewal period, one dim line says +`this chat is not syncing because other machines are unreachable; it keeps working here`. +It is said once per outage. +When renewals succeed again, one line says `this chat is syncing again`. + +You can keep working here while offline. If another computer continued the +chat meanwhile, the existing ` continued this chat; this window now +only shows it` notice still applies. + ## Reopen a kept copy after another computer takes the chat When unsent turns were kept as a separate copy, reopening that copy checks who holds the copy itself. Another computer may still hold the original chat. The previous window finishes saving and stops syncing before the reopened copy starts its own sync, so a late save cannot change which copy it opens. Taking that copy elsewhere still stops new tool work in its older window. diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index d00abc447d..10f9b81301 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -1,5 +1,38 @@ # Keys, typing, and the mouse — what all the keys do +## Typing while a new chat opens — /new, paste, Enter and Escape + +Words typed or pasted while `/new` opens belong to the new chat. Backspace +edits those words. Enter during the opening leaves them unsent in the new +chat's composer; press Enter after it opens to send them. + +Escape cancels the opening. If you typed words meanwhile, they return to the +chat you were leaving, unsent, with +`opening cancelled; your words are back in this chat, unsent`. +Typing when no chat is opening works as usual. + +## Closing a chat when the engine is unreachable — window frozen, queued keys + +`/quit` closes the current chat when another chat is open. The next chat +appears immediately while closing finishes in the background. The keyboard +waits for that close for at most one second; keys and pastes during that wait +are discarded, so they cannot send the next chat's draft. The window keeps +drawing and resizing throughout. + +If closing fails, it says +`this chat could not be saved; its unsaved turns stay on this computer for the next start`. + +A close still running after that second says +`this chat is still finishing in the background`. +A slow close alone does not mean saving failed. + +## Could not save on quit — unsaved turns on the next start + +If the final save fails, the restored terminal says +`codeaf could not save this chat; unsaved turns stay on this computer and return on the next start.` +The command exits with a failure status. This sentence stays visible after +the full-screen chat closes, including when error output goes to a log. + ## How do I make this one chat think harder — can I click the thinking level — effort keys `alt+e` walks this conversation's thinking rung; `/effort` opens the ladder and diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 56b816cd84..2c35b28af0 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -2839,7 +2839,12 @@ type app struct { // read by [app.placeWord] and [app.contextStart]. owned bool // movedName names a chat moved here whose project is not on this machine. - movedName string + movedName string + deviceID string + syncOutages map[string]bool + conversationClosing bool + quitting bool + saveFailure func(error) // handedApproval is the tool-approval posture this launch knows the // surface's own profile cannot answer, carried in Options.ApprovalMode. Over // --host it is the engine's row; locally it is --yolo's forced allow. Empty @@ -3078,6 +3083,8 @@ func newApp(ctx context.Context, opts Options) *app { bashBackgroundAfter: opts.BashBackgroundAfterSeconds, owned: owned, movedName: movedName, + deviceID: opts.DeviceID, + saveFailure: opts.SaveFailure, landing: opts.Landing, autoContinue: opts.Landing && !opts.PickSession && opts.Host == "", takeOverAt: opts.TakeOver, @@ -3774,6 +3781,7 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { // waiting for whatever repaints next. a.takeLinkNotice() a.takeSealNotice() + a.takeRenewalNotice() // An explicit gesture after View replaces its pending navigation intent. switch msg.(type) { case tea.KeyPressMsg, tea.PasteMsg, tea.MouseClickMsg, tea.MouseWheelMsg: @@ -3788,6 +3796,15 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { return a, cmd } switch msg := msg.(type) { + case conversationClosedMsg: + a.conversationClosing = false + if msg.err != nil { + a.note(closeSaveFailedWord) + } else if msg.finishing { + a.note(closeStillFinishingWord) + } + a.touch() + return a, nil case completionToastExpiredMsg: if msg.serial == a.completionToast.serial { a.dismissCompletionToast() @@ -3909,6 +3926,9 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { return a, nil case tea.KeyPressMsg: + if a.quitting || (a.conversationClosing && msg.String() != "ctrl+c") { + return a, nil + } if a.cdelete.on { return a, a.conversationDeleteKey(msg) } @@ -3923,6 +3943,9 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { return a, nil } a.cancelConversationOpening() + if msg.String() == "esc" { + return a, nil + } } a.sawAPerson() a.stirred() @@ -4073,6 +4096,9 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { return a, a.dropSettled() case tea.PasteStartMsg: + if a.conversationClosing || a.quitting { + return a, nil + } if a.conversationOpening && a.conversationPreview != nil { view := a.conversationPreview.view view.pasting, view.pasted, view.pasteAt = true, view.pasted[:0], a.now() @@ -4084,6 +4110,9 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { return a, nil case tea.PasteMsg: + if a.conversationClosing || a.quitting { + return a, nil + } if a.conversationOpening && a.conversationPreview != nil { view := a.conversationPreview.view if view.pasting { @@ -4114,6 +4143,9 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { return a, a.paste(msg.Content) case tea.PasteEndMsg: + if a.conversationClosing || a.quitting { + return a, nil + } if a.conversationOpening && a.conversationPreview != nil { view := a.conversationPreview.view if view.pasting { @@ -8877,7 +8909,7 @@ func (a *app) finishRenew(conv Conversation, whole, replacing bool) tea.Cmd { // The older seam hands back an agent alone, so the surface keeps every // other seam it was holding ([app.takeUp] states this). conv = Conversation{Agent: conv.Agent, SessionFile: conv.SessionFile, - Workspace: a.workspace, Place: a.place, Owned: a.owned, + Workspace: a.workspace, Place: a.place, Owned: a.owned && a.movedName == "", ContextWindow: a.ctxWindow, DraftFile: a.draftFile, History: a.history, RecentSessions: a.recentSessions, SaveApproval: a.saveApproval, SaveBashApproval: a.saveBashApproval, ApplyApprovals: a.applyApprovals} @@ -9023,6 +9055,10 @@ func (a *app) orchestrateEvent(ev session.Event) tea.Cmd { // conversation it is holding — ending the ones this process runs, detaching from // the ones a host runs (keeper.go's [app.leaveEverything]). func (a *app) quit() tea.Cmd { + if a.quitting { + return nil + } + a.quitting = true if a.creditHookStop != nil { a.creditHookStop() a.creditHookStop = nil @@ -9052,7 +9088,7 @@ func (a *app) quit() tea.Cmd { // thing running it. The ones held behind the screen have their own drafts // already on disk — written when they were left — so what is above is the // only box that still needs saving. - a.leaveEverything() + leave := a.leavingWork() // AND THE FILE DOOR GOES WITH THE SURFACE THAT OPENED IT. Every minted id and // the browse token die here, which is the whole of the capability bargain: a // URL that outlived the conversation would be a door onto somebody else's @@ -9062,8 +9098,7 @@ func (a *app) quit() tea.Cmd { // transcript; one left open by a process that has gone is a conversation // nobody can reopen, and a stood one's folder would never reach the item it // made (homeexchange.go's [app.fileEveryExchange]). - a.fileEveryExchange() - return tea.Quit + return quitAfterClosing(leave) } // interrupt is esc: stop the turn, keep what it said. diff --git a/internal/tui3/conversation_close.go b/internal/tui3/conversation_close.go new file mode 100644 index 0000000000..d309a667c3 --- /dev/null +++ b/internal/tui3/conversation_close.go @@ -0,0 +1,55 @@ +package tui3 + +import ( + "time" + + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/session" +) + +const conversationCloseWithin = time.Second +const closeSaveFailedWord = "this chat could not be saved; its unsaved turns stay on this computer for the next start" +const closeStillFinishingWord = "this chat is still finishing in the background" + +type conversationClosedMsg struct { + err error + finishing bool +} + +func quitAfterClosing(work func()) tea.Cmd { + return func() tea.Msg { + done := make(chan struct{}) + go func() { defer close(done); work() }() + timer := time.NewTimer(conversationCloseWithin) + defer timer.Stop() + select { + case <-done: + case <-timer.C: + } + return tea.QuitMsg{} + } +} + +// Closing releases the keyboard after a bounded wait. The close method has no +// cancellation door, so it can finish later without holding the update loop. +func closeConversationLater(agent Agent, door session.StopDoor, report func(error)) tea.Cmd { + return func() tea.Msg { + done := make(chan error, 1) + go func() { + agent.InterruptFor(door) + err := agent.Close() + if err != nil && report != nil { + report(err) + } + done <- err + }() + timer := time.NewTimer(conversationCloseWithin) + defer timer.Stop() + select { + case err := <-done: + return conversationClosedMsg{err: err} + case <-timer.C: + return conversationClosedMsg{finishing: true} + } + } +} diff --git a/internal/tui3/conversation_open.go b/internal/tui3/conversation_open.go index 3e46c2ed63..1e93f294a0 100644 --- a/internal/tui3/conversation_open.go +++ b/internal/tui3/conversation_open.go @@ -10,6 +10,7 @@ import ( const conversationOpeningWord = "opening conversation… · esc cancels" const conversationOpeningState = "opening chat" +const openingCancelledDraftWord = "opening cancelled; your words are back in this chat, unsent" // A first message gets its own screen before the engine has opened its journal. // The old conversation and both composers remain intact until opening succeeds. @@ -35,6 +36,9 @@ func (a *app) previewConversation(text string) tea.Cmd { feed: feed{live: -1, think: -1, entries: []entry{{kind: entryUser, text: text, pending: true}}}, } a.conversationPreview = &conversationPreview{view: view} + if text == "" { + view.entries = nil + } a.touch() return a.wake() } @@ -81,14 +85,27 @@ func (a *app) openingDraftKey(msg tea.KeyPressMsg) bool { } func (a *app) restoreOpeningDraft(preview *conversationPreview) { - if preview == nil || preview.view.input.empty() { + if preview == nil { + return + } + view := preview.view + if view.pasting { + view.pasting = false + view.paste(string(view.pasted)) + view.pasted = nil + } + if view.input.empty() { return } box := &a.input if a.at(pageHome) { box = &a.home.box } - box.setText(box.String() + "\n" + preview.view.pastesUnfolded(preview.view.input.String())) + text := preview.view.pastesUnfolded(preview.view.input.String()) + if !box.empty() { + text = box.String() + "\n" + text + } + box.setText(text) } // conversationLater separates preparing a conversation from committing its @@ -145,6 +162,10 @@ func (a *app) conversationLater(ask func() (Conversation, error), say func(strin } else { a.putComposer(preview.view.liveComposer()) } + if preview.view.pasting { + a.pasting, a.pasteAt = true, preview.view.pasteAt + a.pasted = append([]rune(nil), preview.view.pasted...) + } } return cmd } @@ -152,6 +173,7 @@ func (a *app) conversationLater(ask func() (Conversation, error), say func(strin } func (a *app) cancelConversationOpening() { + hadDraft := a.conversationPreview != nil && (!a.conversationPreview.view.input.empty() || len(a.conversationPreview.view.pasted) > 0) if a.conversationPreview == nil { defer a.tabSettleTeam() } @@ -160,9 +182,17 @@ func (a *app) cancelConversationOpening() { a.conversationPreview = nil a.conversationRequest++ if a.at(pageHome) { - a.home.say("", "") + if hadDraft { + a.home.say(openingCancelledDraftWord, "") + } else { + a.home.say("", "") + } } else { - a.note("opening cancelled") + if hadDraft { + a.note(openingCancelledDraftWord) + } else { + a.note("opening cancelled") + } } a.touch() } @@ -195,13 +225,14 @@ func (a *app) renewLater(say func(string), after func() tea.Cmd) tea.Cmd { } replacing := a.renewReplaces() ask, whole := a.startDoor("") - return a.conversationLater(ask, say, func(conv Conversation) tea.Cmd { + cmd := a.conversationLater(ask, say, func(conv Conversation) tea.Cmd { cmd := a.finishRenew(conv, whole, replacing) if after != nil { return tea.Batch(cmd, after()) } return cmd }) + return tea.Batch(cmd, a.previewConversation("")) } func (a *app) homeStartLater(text, place string) tea.Cmd { diff --git a/internal/tui3/detach.go b/internal/tui3/detach.go index 36364cacd7..12d6aeaa7b 100644 --- a/internal/tui3/detach.go +++ b/internal/tui3/detach.go @@ -240,7 +240,7 @@ func (a *app) front() Conversation { SessionFile: a.file, Workspace: a.workspace, Place: a.place, - Owned: a.owned, + Owned: a.owned && a.movedName == "", ContextWindow: a.ctxWindow, DraftFile: a.draftFile, History: a.history, diff --git a/internal/tui3/homeband_devices.go b/internal/tui3/homeband_devices.go index 9d4cb299c4..ac1f166cfd 100644 --- a/internal/tui3/homeband_devices.go +++ b/internal/tui3/homeband_devices.go @@ -85,6 +85,7 @@ func (a *app) tookRoster(msg rosterMsg) tea.Cmd { a.devRow.asking = false if msg.err == nil { a.devRow.devices = msg.devices + a.considerResume() a.touch() } return a.finishBring() diff --git a/internal/tui3/homeband_resume.go b/internal/tui3/homeband_resume.go index dd360c80c6..2c38baba4d 100644 --- a/internal/tui3/homeband_resume.go +++ b/internal/tui3/homeband_resume.go @@ -80,6 +80,7 @@ func (r *resumeOffer) answer() { // resumeState is what the offer needs to know about the fleet, so the choice of // chat is a pure function of it. type resumeState struct { + self string rows []chatlist.Row online []string feedUp bool @@ -101,7 +102,7 @@ func (s resumeState) pick() *chatlist.Row { // offered says a row is a chat to rescue. func (s resumeState) offered(row chatlist.Row) bool { live := row.Status == chatlist.Running || row.Status == chatlist.Off || (row.Status == chatlist.Idle && !row.Mine) - return live && row.Device != "" && !s.answered[row.Device] && row.DurableAgo <= resumeRecent && s.away(row) + return live && !row.Mine && (s.self == "" || row.DeviceID != s.self) && row.Device != "" && !s.answered[row.Device] && row.DurableAgo <= resumeRecent && s.away(row) } // away says the chat's device has let go of it or is gone: its lease lapsed, @@ -132,6 +133,13 @@ func (a *app) considerResume() { // resumeState is the fleet as the newest good listing and the feed show it. func (a *app) resumeState() resumeState { state := resumeState{rows: a.machineRead.rows, answered: a.leftOff.answered} + state.self = a.deviceID + for _, device := range a.devRow.devices { + if device.Self { + state.self = device.ID + break + } + } if a.dirFeed != nil { st := a.dirFeed.State() state.feedUp, state.online = st.Up, st.Online @@ -228,9 +236,10 @@ func (a *app) continueAfterQuit(err error) tea.Cmd { return nil } var best *chatlist.Row + self := a.resumeState().self for i := range a.machineRead.rows { r := &a.machineRead.rows[i] - if r.Status == chatlist.Idle && r.Released && !r.Mine && r.DeviceID != "" && r.ReleasedAgo >= 0 && r.ReleasedAgo <= resumeRecent && (best == nil || r.ReleasedAgo < best.ReleasedAgo) { + if r.Status == chatlist.Idle && r.Released && !r.Mine && r.DeviceID != "" && r.DeviceID != self && r.ReleasedAgo >= 0 && r.ReleasedAgo <= resumeRecent && (best == nil || r.ReleasedAgo < best.ReleasedAgo) { best = r } } diff --git a/internal/tui3/homepanel_heldelsewhere.go b/internal/tui3/homepanel_heldelsewhere.go index 08999ee7f5..c0f2ee56a6 100644 --- a/internal/tui3/homepanel_heldelsewhere.go +++ b/internal/tui3/homepanel_heldelsewhere.go @@ -94,7 +94,7 @@ func (a *app) checkBeforeSendWith(resume func() tea.Cmd) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), machinesAskTimeout) defer cancel() - rows, version, err := chatlist.RowsAt(ctx, src) + rows, version, err := chatlist.RowsForSend(ctx, src) msg := ownershipReadMsg{file: file, draft: draft, chips: chips, resume: resume, first: first, reading: homeMachinesMsg{rows: rows, version: version, err: err}} id := filepath.Base(session.DirOf(file)) if local, ok := src.(sendLeaseSource); ok { diff --git a/internal/tui3/keeper.go b/internal/tui3/keeper.go index a5de1a2624..2bdad8b7da 100644 --- a/internal/tui3/keeper.go +++ b/internal/tui3/keeper.go @@ -1268,7 +1268,13 @@ func (a *app) closeFront() (tea.Cmd, bool) { // arrived, and "you closed this conversation" and "another window took it" are // two different sentences to be owed (internal/session's stopcause.go). func (a *app) closeFrontFor(door session.StopDoor) (tea.Cmd, bool) { - return a.leaveFront(func(agent Agent) { a.endAgentFor(agent, door) }, true) + var leaving Agent + cmd, more := a.leaveFront(func(agent Agent) { leaving = agent }, true) + if !more || leaving == nil { + return cmd, more + } + a.conversationClosing = true + return tea.Batch(cmd, closeConversationLater(leaving, door, a.saveFailure)), true } // stepBackFront is the same act for a window that is NOT ending anything: another @@ -1374,6 +1380,12 @@ func (a *app) leaveFront(let func(Agent), say bool) (tea.Cmd, bool) { // serial quit would get slower the more of them somebody had open; this one // does not. func (a *app) leaveEverything() { + a.leavingWork()() +} + +// leavingWork captures surface state before any engine work leaves the loop. +// The returned work owns only the captured agents and thread-safe callbacks. +func (a *app) leavingWork() func() { agents := make([]Agent, 0, len(a.behind)+1) if a.agent != nil { agents = append(agents, a.agent) @@ -1386,32 +1398,67 @@ func (a *app) leaveEverything() { delete(a.behind, key) } a.prev = nil - waitRelease := a.waitForRelease != nil && a.waitForRelease() - var wg sync.WaitGroup - for _, agent := range agents { - wg.Add(1) - go func(agent Agent) { - defer wg.Done() - if waitRelease { - if hosted, ok := agent.(interface{ DetachAndWait() error }); ok { - _ = hosted.DetachAndWait() - return + type errandClose struct { + agent Agent + working bool + } + var errands []errandClose + for _, ex := range a.exchanges { + if ex != nil && !ex.filed { + ex.filed = true + errands = append(errands, errandClose{agent: ex.agent, working: ex.working}) + } + } + a.exchanges, a.home.exchanges = nil, nil + waitForRelease, report := a.waitForRelease, a.saveFailure + return func() { + waitRelease := waitForRelease != nil && waitForRelease() + var wg sync.WaitGroup + for _, agent := range agents { + wg.Add(1) + go func(agent Agent) { + defer wg.Done() + if waitRelease { + if hosted, ok := agent.(interface{ DetachAndWait() error }); ok { + if err := hosted.DetachAndWait(); err != nil && report != nil { + report(err) + } + return + } } - } - leaveAgent(agent) - }(agent) + if err := leaveAgentError(agent); err != nil && report != nil { + report(err) + } + }(agent) + } + for _, ex := range errands { + wg.Add(1) + go func() { + defer wg.Done() + if ex.working { + ex.agent.InterruptFor(session.StopByLeaving) + } + if err := ex.agent.Close(); err != nil && report != nil { + report(err) + } + }() + } + wg.Wait() } - wg.Wait() } // leaveAgent takes this terminal off one conversation: a detach where the work // outlives the window, and the ordinary interrupt-and-close where it does not. func leaveAgent(agent Agent) { + _ = leaveAgentError(agent) +} + +func leaveAgentError(agent Agent) error { if hosted, ok := agent.(detachable); ok { - _ = hosted.Detach() - return + return hosted.Detach() } - leaveAgentFor(agent, session.StopByLeaving) + agent.InterruptFor(session.StopByLeaving) + return agent.Close() } // Permanent deletion closes the owner rather than detaching its view. diff --git a/internal/tui3/manual_devices_test.go b/internal/tui3/manual_devices_test.go index 365fbdc002..4fc99c590a 100644 --- a/internal/tui3/manual_devices_test.go +++ b/internal/tui3/manual_devices_test.go @@ -28,6 +28,7 @@ func TestEverySentenceOfTheDeviceScreensIsOnItsPage(t *testing.T) { }, "naming-a-device": {devicesNameKeys, devicesNameAsk + "atlas▏", devicesNameOwn}, "continuing-a-chat-on-another-computer": { + chatlist.SyncPaused, chatlist.SyncRestored, continueAsk, continueStay, continueFails, continueUnreachable, lostRaceWord, chatlist.Unreachable, fmt.Sprintf(resumeAskFormat, "tower"), resumeYes, resumeNo, chatlist.Moved("tower", 3200e6, true), chatlist.Moved("", 3200e6, false), diff --git a/internal/tui3/relay_surface_test.go b/internal/tui3/relay_surface_test.go new file mode 100644 index 0000000000..a76f1274b4 --- /dev/null +++ b/internal/tui3/relay_surface_test.go @@ -0,0 +1,288 @@ +package tui3 + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" +) + +// B1 keeps every edit with the opening chat and leaves an early send unsent. +func TestNewOpeningKeepsTypingPasteAndEarlyEnter(t *testing.T) { + for _, cancel := range []bool{false, true} { + t.Run(map[bool]string{false: "open", true: "cancel"}[cancel], func(t *testing.T) { + a := openingLab(t) + a.closeHome() + old := a.agent.(*fakeAgent) + next := &fakeAgent{model: "m"} + release := make(chan struct{}) + defer close(release) + a.start = func(string) (Conversation, error) { + <-release + return Conversation{Agent: next, SessionFile: "/next/transcript.jsonl"}, nil + } + a.input.setText("/new") + _, pending := a.Update(key("enter")) + for _, msg := range []tea.Msg{key("a"), key("b"), key("backspace"), tea.PasteMsg{Content: " new chat"}, key("enter")} { + a.Update(msg) + } + if !a.conversationOpening || len(old.sent) != 0 { + t.Fatalf("opening=%v old sends=%v", a.conversationOpening, old.sent) + } + if cancel { + a.Update(key("esc")) + if a.input.String() != "a new chat" || !strings.Contains(plain(frame(a)), "opening cancelled; your words are back in this chat, unsent") { + t.Fatalf("cancel lost its words or explanation: %q\n%s", a.input.String(), plain(frame(a))) + } + } else { + release <- struct{}{} + spend(t, a, pending) + if a.input.String() != "a new chat" || len(next.sent) != 0 || a.agent != next { + t.Fatalf("new composer=%q sends=%v", a.input.String(), next.sent) + } + } + }) + } +} + +// B1 keeps a paste that starts before arrival and ends after the chat opens. +func TestNewOpeningKeepsAPasteAcrossArrival(t *testing.T) { + a := openingLab(t) + a.closeHome() + next := &fakeAgent{model: "m"} + release := make(chan struct{}) + defer close(release) + a.start = func(string) (Conversation, error) { + <-release + return Conversation{Agent: next, SessionFile: "/next/transcript.jsonl"}, nil + } + a.input.setText("/new") + _, cmd := a.Update(key("enter")) + a.Update(tea.PasteStartMsg{}) + a.Update(tea.PasteMsg{Content: "new chat words"}) + release <- struct{}{} + spend(t, a, cmd) + a.Update(tea.PasteEndMsg{}) + if got := a.pastesUnfolded(a.input.String()); got != "new chat words" || len(next.sent) != 0 { + t.Fatalf("arrived paste=%q sends=%v", got, next.sent) + } +} + +// B2 preserves the project label when a removed computer revisits a chat. +func TestRemovedComputerReopensWithTheSameProjectLabel(t *testing.T) { + for _, copy := range []bool{false, true} { + t.Run(map[bool]string{false: "project", true: "copy"}[copy], func(t *testing.T) { + work := t.TempDir() + want := work + if copy { + work = movedFolder(t, `{"id":"x","title":"fix it","origin":"/srv/other/ws9"}`) + want = "ws9 (copy from another machine)" + } + a := newTestApp(&fakeAgent{model: "m"}) + a.file = "/tmp/old/transcript.jsonl" + spend(t, a, a.takeBeside(Conversation{Agent: &fakeAgent{model: "m"}, SessionFile: "/tmp/copy/transcript.jsonl", Workspace: work})) + a.title = "Copy chat" + spend(t, a, a.takeBeside(Conversation{Agent: &fakeAgent{model: "m"}, SessionFile: "/tmp/next/transcript.jsonl"})) + a.title = "Next chat" + a.Update(homeMachinesMsg{err: directory.ErrRevoked}) + a.width = 160 + a.touch() + a.tabsRow(a.width) + span := tabSpanFor(t, a, "Copy chat") + drive(t, a, tea.MouseClickMsg{X: span.from + 1, Y: tabStripRow, Button: tea.MouseLeft}) + if got := plain(a.hintRow(a.width)); !strings.Contains(got, "project: "+want) { + t.Fatalf("project label changed: %s", got) + } + }) + } +} + +// B3 excludes the local holder even when stale records deny local ownership. +func TestResumeNeverOffersThisComputersOwnDevice(t *testing.T) { + for _, status := range []chatlist.Status{chatlist.Running, chatlist.Off, chatlist.Idle} { + a := leftOffApp(&fakeTaker{}) + a.deviceID = "self" + a.Update(homeMachinesMsg{rows: []chatlist.Row{{Cell: "chat", DeviceID: "self", Device: "laptop", Status: status}}}) + if got := leftOffFrame(a); strings.Contains(got, "Continue laptop's latest chat here?") { + t.Fatalf("offered this computer for %s: %s", status, got) + } + } + a := leftOffApp(&fakeTaker{}) + a.Update(homeMachinesMsg{rows: []chatlist.Row{{Cell: "chat", DeviceID: "self", Device: "laptop", Status: chatlist.Off}}}) + a.Update(rosterMsg{devices: []chatlist.Device{{ID: "self", Name: "laptop", Self: true}}}) + if got := leftOffFrame(a); strings.Contains(got, "Continue laptop's latest chat here?") { + t.Fatalf("the self-device reading left its stale offer: %s", got) + } +} + +type openingMachineSource struct { + ready chan struct{} + row chatlist.Row +} + +func (s *openingMachineSource) Rows(context.Context) ([]chatlist.Row, error) { + return nil, errors.New("display has not read yet") +} + +func (s *openingMachineSource) RowsForSend(ctx context.Context) ([]chatlist.Row, uint64, error) { + select { + case <-s.ready: + return []chatlist.Row{s.row}, 1, nil + case <-ctx.Done(): + return nil, 0, ctx.Err() + } +} + +// B4 waits for an actual reading before the first send decides the move card. +func TestFirstEnterWaitsForOwnershipReading(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + a.file = "/tmp/chat/transcript.jsonl" + a.taker = &fakeTaker{} + src := &openingMachineSource{ready: make(chan struct{}), row: chatlist.Row{Cell: "chat", DeviceID: "other", Device: "tower", Status: chatlist.Running, Fence: 3}} + a.machines = src + a.input.setText("keep these words") + _, cmd := a.Update(key("enter")) + close(src.ready) + spend(t, a, cmd) + if got := plain(frame(a)); strings.Contains(got, chatlist.Unreachable) || !strings.Contains(got, "Continue this chat here?") { + t.Fatalf("first send did not raise the card: %s", got) + } + if a.input.String() != "keep these words" { + t.Fatalf("draft changed to %q", a.input.String()) + } +} + +type renewalAgent struct { + *fakeAgent + since time.Time + held bool +} + +func (a *renewalAgent) RenewalStatus() (time.Time, time.Duration, bool) { + return a.since, 10 * time.Second, a.held +} + +// B6 says a sustained renewal outage once, then says syncing is back once. +func TestHolderReportsRenewalOutageAndRecovery(t *testing.T) { + agent := &renewalAgent{fakeAgent: &fakeAgent{model: "m"}, held: true} + a := newTestApp(agent) + now := time.Now() + a.clock = func() time.Time { return now } + agent.since = now + a.Update(tea.WindowSizeMsg{Width: 160, Height: 40}) + if got := plain(frame(a)); strings.Contains(got, "this chat is not syncing") { + t.Fatalf("a brief renewal failure was announced: %s", got) + } + now = now.Add(11 * time.Second) + for range 3 { + a.Update(tea.WindowSizeMsg{Width: 160, Height: 40}) + } + outage := "this chat is not syncing because other machines are unreachable; it keeps working here" + if got := plain(frame(a)); strings.Count(got, outage) != 1 { + t.Fatalf("outage notice count is wrong: %s", got) + } + agent.since = time.Time{} + for range 3 { + a.Update(tea.WindowSizeMsg{Width: 160, Height: 40}) + } + if got := plain(frame(a)); strings.Count(got, "this chat is syncing again") != 1 { + t.Fatalf("recovery notice count is wrong: %s", got) + } +} + +type blockedCloseAgent struct { + *fakeAgent + release chan struct{} + entered chan struct{} +} + +func (a *blockedCloseAgent) Close() error { + if a.entered != nil { + close(a.entered) + } + <-a.release + return nil +} + +// B7 returns from the close key before an unreachable engine answers. +func TestClosingChatNeverBlocksTheUpdateLoop(t *testing.T) { + agent := &blockedCloseAgent{fakeAgent: &fakeAgent{model: "m"}, release: make(chan struct{}), entered: make(chan struct{})} + a := newTestApp(agent) + a.file = "/tmp/closing/transcript.jsonl" + keepThree(t, a) + a.input.setText("/quit") + returned := make(chan tea.Cmd, 1) + go func() { _, cmd := a.Update(key("enter")); returned <- cmd }() + var cmd tea.Cmd + select { + case cmd = <-returned: + defer close(agent.release) + case <-time.After(500 * time.Millisecond): + close(agent.release) + <-returned + t.Fatal("closing held the update loop while the engine was unreachable") + } + finished := make(chan []tea.Msg, 1) + go func() { finished <- runCmd(cmd) }() + select { + case <-agent.entered: + case <-time.After(time.Second): + t.Fatal("close never started off the update loop") + } + a.input.setText("another chat's draft") + a.Update(tea.WindowSizeMsg{Width: 160, Height: 40}) + a.Update(key("x")) + a.Update(key("enter")) + if a.input.String() != "another chat's draft" || len(a.agent.(*fakeAgent).sent) != 0 { + t.Fatal("keys during close reached another chat's draft") + } + select { + case msgs := <-finished: + for _, msg := range msgs { + a.Update(msg) + } + case <-time.After(2 * time.Second): + t.Fatal("close command has no bound") + } + if a.conversationClosing { + t.Fatal("close never gave the keyboard back") + } +} + +// B7 also keeps the loop responsive when the last chat closes the window. +func TestQuittingLastChatNeverBlocksTheUpdateLoop(t *testing.T) { + agent := &blockedCloseAgent{fakeAgent: &fakeAgent{model: "m"}, release: make(chan struct{}), entered: make(chan struct{})} + a := newTestApp(agent) + a.input.setText("/quit") + returned := make(chan tea.Cmd, 1) + go func() { _, cmd := a.Update(key("enter")); returned <- cmd }() + select { + case cmd := <-returned: + defer close(agent.release) + finished := make(chan tea.Msg, 1) + go func() { finished <- cmd() }() + select { + case <-agent.entered: + case <-time.After(time.Second): + t.Fatal("last chat never started closing") + } + a.Update(tea.WindowSizeMsg{Width: 160, Height: 40}) + select { + case msg := <-finished: + if _, ok := msg.(tea.QuitMsg); !ok { + t.Fatalf("quit returned %T", msg) + } + case <-time.After(2 * time.Second): + t.Fatal("last chat close has no bound") + } + case <-time.After(500 * time.Millisecond): + close(agent.release) + <-returned + t.Fatal("last chat close held the update loop") + } +} diff --git a/internal/tui3/renewal_notice.go b/internal/tui3/renewal_notice.go new file mode 100644 index 0000000000..479507429c --- /dev/null +++ b/internal/tui3/renewal_notice.go @@ -0,0 +1,41 @@ +package tui3 + +import ( + "time" + + "github.com/Agent-Field/codeaf/internal/chatlist" +) + +// renewalReader reports the first failed renewal, its period, and whether this +// conversation still holds the chat. A successful renewal clears the time. +// The reading is local and never waits for the network. +type renewalReader interface { + RenewalStatus() (time.Time, time.Duration, bool) +} + +func (a *app) takeRenewalNotice() { + source, ok := a.agent.(renewalReader) + if !ok { + return + } + since, period, held := source.RenewalStatus() + if !held { + delete(a.syncOutages, a.file) + return + } + if since.IsZero() { + if a.syncOutages[a.file] { + delete(a.syncOutages, a.file) + a.note(chatlist.SyncRestored) + } + return + } + if period <= 0 || a.now().Sub(since) <= period || a.syncOutages[a.file] { + return + } + if a.syncOutages == nil { + a.syncOutages = map[string]bool{} + } + a.syncOutages[a.file] = true + a.note(chatlist.SyncPaused) +} diff --git a/internal/tui3/takeover.go b/internal/tui3/takeover.go index 1eb9e90846..793674ec6c 100644 --- a/internal/tui3/takeover.go +++ b/internal/tui3/takeover.go @@ -708,7 +708,7 @@ func (a *app) freshAfterLeaving(let func(Agent)) tea.Cmd { } if !whole { conv = Conversation{Agent: conv.Agent, SessionFile: conv.SessionFile, - Workspace: a.workspace, Place: a.place, Owned: a.owned, + Workspace: a.workspace, Place: a.place, Owned: a.owned && a.movedName == "", ContextWindow: a.ctxWindow, History: a.history, RecentSessions: a.recentSessions, SaveApproval: a.saveApproval, SaveBashApproval: a.saveBashApproval, ApplyApprovals: a.applyApprovals} diff --git a/internal/tui3/tui3.go b/internal/tui3/tui3.go index e07c75968d..cbd570001b 100644 --- a/internal/tui3/tui3.go +++ b/internal/tui3/tui3.go @@ -440,6 +440,11 @@ type Options struct { // machine that syncs nothing, and home then draws nothing extra: the // sessions panel is exactly what it was (homepanel_machines.go). Machines chatlist.Source + // DeviceID identifies this computer even before the first device listing. + DeviceID string + // SaveFailure keeps a close failure for the terminal's restored screen. + // The callback may run outside the update loop. + SaveFailure func(error) // Takeover continues a chat from another machine here. Nil is a machine // that cannot, and the offer is then absent rather than failing // (homepanel_continue.go). diff --git a/internal/tui3/welcome.go b/internal/tui3/welcome.go index 23fb82dfdc..2d7e8a8662 100644 --- a/internal/tui3/welcome.go +++ b/internal/tui3/welcome.go @@ -537,7 +537,7 @@ func (a *app) openSession(chosen Session) (tea.Cmd, string) { // fields would clear the recent list, the draft and the approval trio // ([app.takeUp] states this). The surface keeps what it was holding. conv = Conversation{Agent: conv.Agent, SessionFile: conv.SessionFile, - Workspace: a.workspace, Place: a.place, Owned: a.owned, + Workspace: a.workspace, Place: a.place, Owned: a.owned && a.movedName == "", ContextWindow: a.ctxWindow, DraftFile: a.draftFile, History: a.history, RecentSessions: a.recentSessions, SaveApproval: a.saveApproval, SaveBashApproval: a.saveBashApproval, ApplyApprovals: a.applyApprovals} From e5edeb3a4c39b69baaed84e82eadf374259354db Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 22:28:28 -0400 Subject: [PATCH 06/28] Bump embedded furrow to v0.3.5 (an exiting daemon removes only its own socket) furrow 0.3.5 records its socket's device and inode at bind time and removes the path at shutdown only while it still names that socket, so a daemon that outlived its client can no longer unlink the socket of the one that replaced it. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/furrowbin/pin.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/furrowbin/pin.json b/internal/furrowbin/pin.json index 25e308fb51..dbf0bb7ac7 100644 --- a/internal/furrowbin/pin.json +++ b/internal/furrowbin/pin.json @@ -1,31 +1,31 @@ { "repository": "Agent-Field/furrow", - "tag": "v0.3.4", - "version": "0.3.4", + "tag": "v0.3.5", + "version": "0.3.5", "artifacts": { "darwin-amd64": { "asset": "furrow-darwin-amd64", - "sha256": "284798adfce9534cc1ab953cc5e3f68a9bab4a3f5e17590079dff95e88e212a6" + "sha256": "afd6c5627909185f6ea7c284526937d1c709ee9b7ab3d6e8fc12b807c487af66" }, "darwin-arm64": { "asset": "furrow-darwin-arm64", - "sha256": "7e76abfb8e078bfffeca10b94ccadfe9dee34f499d0125637473b89fd5bc3f89" + "sha256": "18a6afdf75aac9ae738ea5a75c3ecc7e6096fa915b72c2916fbdefd0f45a3396" }, "linux-amd64": { "asset": "furrow-linux-amd64", - "sha256": "6dda88c3aaf4044af2e54701f1b64237df57e96640cff2e90ef31826521e4a35" + "sha256": "647c57a521637eb6930f77c1c29797bf15c34f598751756c70a19cea90d91827" }, "linux-arm64": { "asset": "furrow-linux-arm64", - "sha256": "3044201df55e64ee1efe8e7fa72af2e23231823d59bb7a1d201efb9fc6603782" + "sha256": "da87e1067bbb5eee27dbd034a5274d6ae8c48b938d05c791ea0e5503932c78d8" }, "windows-amd64": { "asset": "furrow-windows-amd64.exe", - "sha256": "dc37827d9378de18099dd1c7d819d8cd08faf87ebb53d290504df85de001d8c9" + "sha256": "bf4c60bacaea42b1a0cdb3c97279562fbadc73fea5bf69ca04d55f048d1a2cf1" }, "windows-arm64": { "asset": "furrow-windows-arm64.exe", - "sha256": "16c33e37b1e717e749e76db2ea78678842c1f3fb74f87c28c599b4f015efb9e2" + "sha256": "fedfab37475ab17bb3be4c8bdadaffcf77756aa3679815068d57ff3dd9e30861" } } } From fb9c405dd56b677f90fdfe4472b74ac7026d3f0c Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 22:31:47 -0400 Subject: [PATCH 07/28] docs(changes): entry for the two-computer stability wave Co-Authored-By: Claude Opus 5.5 (1M context) --- .../unreleased/1990-two-computer-stable.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 docs/changes/unreleased/1990-two-computer-stable.md diff --git a/docs/changes/unreleased/1990-two-computer-stable.md b/docs/changes/unreleased/1990-two-computer-stable.md new file mode 100644 index 0000000000..c489b6aaa2 --- /dev/null +++ b/docs/changes/unreleased/1990-two-computer-stable.md @@ -0,0 +1,20 @@ +--- +kind: fixed +title: Two computers stay in step - fast take-back, moved task pages, honest saving +pr: 1990 +surface: [chat, engine, remote] +invalidates: + - "Continuing a chat after its holder was killed took 6 to 16 seconds; the take now acquires before it restores the folder, and lands in about two." + - "A finished task in a chat that moved to another computer had no plan page there; it now opens read-only from its run receipt, steps and worker transcript." + - "The sealing notice said saving works again after any later seal; it now says so only once this chat's own turns are sealed and published." + - "A failed final save was printed only to the engine's log; it is now one plain sentence on the terminal after the screen is restored." + - "Keys typed while /new opened cancelled it and were sent in the chat being left; they now stay with the new chat, and Esc gives them back unsent." + - "The computer holding a chat said nothing while the relay was unreachable; it now says once that the chat is not syncing, and again when syncing is back." + - "codeaf cluster status printed a raw Go error when the relay was down; it now prints the same plain sentence as codeaf devices." + - "The embedded storage engine was furrow 0.3.4, which could unlink a newer daemon's socket; it is now furrow 0.3.5, which removes only its own." +--- +Fixes the defects the real two-computer relay tests found after the workspace +work landed. Closing a chat no longer freezes the window, Home never offers to +continue this computer's own chat, a moved copy keeps its label after this +computer is removed, and the first Enter in a copy held elsewhere no longer +claims other machines are unreachable while the relay answers. From aaadef4d726f1665803067507a43b689481cc903 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 22:57:55 -0400 Subject: [PATCH 08/28] fix(sync): the holder hears its renewals fail, and a hosted save failure reaches the terminal - The batcher keeps the first unreachable renewal or publish for the same hold and clears it on success; a displaced or removed hold reports none. That reading rides the engine host's facts as an optional field, so the surface's once-per-outage "not syncing" notice has a real source and an older peer simply reads as unknown. - Saving replies carry an optional transcript location. The host joins the agent's close with its final publication before it replies, and a detach is acknowledged only after the release, so a quit no longer exits while the engine is still saving and publishing. - The screen restores after its bounded wait while the launcher keeps the links open for the saving reply, then prints one plain sentence naming the transcript. A second ctrl+c exits at once even when cleanup is stuck. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/chatv3_layout.go | 1 + cmd/codeaf/chatv3_surface.go | 26 +++- cmd/codeaf/engine.go | 7 +- cmd/codeaf/final_save.go | 70 ++++++++++- cmd/codeaf/quit_save_test.go | 103 +++++++++++++++- cmd/codeaf/syncdrive.go | 28 +++-- cmd/codeaf/syncdrive_live.go | 39 +++++- cmd/codeaf/wormhole_contract_test.go | 2 +- internal/cellsync/batch.go | 27 ++++- internal/cellsync/renewal.go | 58 +++++++++ internal/cellsync/renewal_test.go | 97 +++++++++++++++ internal/enginehost/renewal_test.go | 74 ++++++++++++ internal/enginehost/saving_test.go | 67 ++++++++++ internal/manual/chat/keys.md | 14 ++- .../manual/chat/relay-limits-and-refusals.md | 2 +- internal/remote/client.go | 32 +++-- internal/remote/quit_release_test.go | 84 ++++++------- internal/remote/renewal.go | 59 +++++++++ internal/remote/saving_test.go | 36 ++++++ internal/remote/server.go | 90 +++++++++----- internal/remote/wire.go | 2 + internal/session/facts.go | 19 +++ internal/session/renewal.go | 25 ++++ internal/session/session.go | 2 + internal/syncsetup/drive.go | 33 +++-- internal/syncsetup/renewal_test.go | 114 ++++++++++++++++++ internal/tui3/app.go | 34 ++++-- internal/tui3/conversation_close.go | 36 ++++-- internal/tui3/keeper.go | 2 +- internal/tui3/relay_surface_test.go | 52 +++++++- internal/tui3/renewal_notice.go | 1 + internal/tui3/tui3.go | 4 + 32 files changed, 1094 insertions(+), 146 deletions(-) create mode 100644 internal/cellsync/renewal.go create mode 100644 internal/cellsync/renewal_test.go create mode 100644 internal/enginehost/renewal_test.go create mode 100644 internal/enginehost/saving_test.go create mode 100644 internal/remote/renewal.go create mode 100644 internal/remote/saving_test.go create mode 100644 internal/session/renewal.go create mode 100644 internal/syncsetup/renewal_test.go diff --git a/cmd/codeaf/chatv3_layout.go b/cmd/codeaf/chatv3_layout.go index 7db6e79615..1ae4e5be93 100644 --- a/cmd/codeaf/chatv3_layout.go +++ b/cmd/codeaf/chatv3_layout.go @@ -792,6 +792,7 @@ func v3Seated(cfg session.Config) session.Config { cfg.Seat, cfg.Machine, cfg.Seals, cfg.Interrupted = nil, nil, nil, nil seat, machine, seals := v3SeatWith(cfg.Place, nil, syncDrives) cfg.Seat, cfg.Interrupted = seat, executor.InterruptedOn(seat) + cfg.Renewals = syncDrives.renewalsOf(cfg.Place.ID()) if machine != nil { cfg.Machine = machine } diff --git a/cmd/codeaf/chatv3_surface.go b/cmd/codeaf/chatv3_surface.go index bbc07aeba3..7c3bd3f0dd 100644 --- a/cmd/codeaf/chatv3_surface.go +++ b/cmd/codeaf/chatv3_surface.go @@ -24,6 +24,7 @@ var ( surfaceRunningExecutable = os.Executable surfaceRevision = buildinfo.Revision surfaceArguments = func() []string { return append([]string(nil), os.Args[1:]...) } + surfaceForceExit = os.Exit ) // ── THE ONE WAY THE v3 SURFACE IS RUN ─────────────────────────────────────── @@ -49,6 +50,8 @@ var ( func runSurface(ctx context.Context, options tui3.Options) error { finalSaving.Lock() finalSaving.surface = true + finalSaving.forced = make(chan struct{}) + forced := finalSaving.forced finalSaving.Unlock() previousSaveFailure := options.SaveFailure options.SaveFailure = func(err error) { @@ -57,6 +60,19 @@ func runSurface(ctx context.Context, options tui3.Options) error { previousSaveFailure(err) } } + previousClosing, previousForce := options.ClosingStarted, options.ForceQuit + options.ClosingStarted = func(done <-chan struct{}) { + retainFinalClose(done) + if previousClosing != nil { + previousClosing(done) + } + } + options.ForceQuit = func() { + forceFinalClose() + if previousForce != nil { + previousForce() + } + } revision := surfaceRevision() executable, executableErr := surfaceRunningExecutable() curl := codeupdate.CurlCommand @@ -172,7 +188,15 @@ func runSurface(ctx context.Context, options tui3.Options) error { defer closeWire() options.Output = wire return withSurfaceLogger(options.ProfileDir, func() error { - return runSurfaceProgram(ctx, options) + err := runSurfaceProgram(ctx, options) + select { + case <-forced: + // The screen is restored before the forced exit skips stuck cleanup. + surfaceForceExit(130) + default: + } + awaitFinalCloses(ctx) + return err }) } diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go index ab48c971b1..2ecbc82840 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -1191,9 +1191,14 @@ func bootEngine(hello remote.Hello, workspaceFlag, sessionFlag string) (*remote. Closed: func(closed remote.WrappedAgent) { if built, ok := closed.(*session.Agent); ok { proc.forget(built) - syncDrives.closeCell(v3PlaceOf(built.SessionPath(), workspace).ID()) } }, + FinishSaving: func(closed remote.WrappedAgent) error { + if built, ok := closed.(*session.Agent); ok { + return session.SavingError(built.SessionPath(), syncDrives.closeCell(v3PlaceOf(built.SessionPath(), workspace).ID())) + } + return nil + }, // A linked-local surface writes a banked approval into this profile and // carries ConsentRule on the answer already crossing the wire. Rebuild // this conversation's gate at that door, using this conversation's own diff --git a/cmd/codeaf/final_save.go b/cmd/codeaf/final_save.go index 60538e75e0..ab0d0cb4f1 100644 --- a/cmd/codeaf/final_save.go +++ b/cmd/codeaf/final_save.go @@ -2,17 +2,69 @@ package main import ( "context" + "errors" "fmt" "os" "sync" + + "github.com/Agent-Field/codeaf/internal/session" ) const quitSaveFailedWord = "codeaf could not save this chat; unsaved turns stay on this computer and return on the next start." +const quitSaveKeptWord = "codeaf could not save this chat; unsaved turns are kept in %s and return on the next start." +// finalSaving keeps one failure until cleanup has returned the terminal. var finalSaving struct { sync.Mutex failure error surface bool + // pending keeps closing replies alive until the launcher can safely shut their links. + pending []<-chan struct{} + // forced bypasses cleanup after a second interrupt has restored the screen. + forced chan struct{} +} + +func retainFinalClose(done <-chan struct{}) { + finalSaving.Lock() + defer finalSaving.Unlock() + kept := finalSaving.pending[:0] + for _, previous := range finalSaving.pending { + select { + case <-previous: + default: + kept = append(kept, previous) + } + } + finalSaving.pending = append(kept, done) +} + +func forceFinalClose() { + finalSaving.Lock() + defer finalSaving.Unlock() + if finalSaving.forced != nil { + select { + case <-finalSaving.forced: + default: + close(finalSaving.forced) + } + } +} + +// awaitFinalCloses keeps the links open for saving replies after the screen closes. +func awaitFinalCloses(ctx context.Context) { + finalSaving.Lock() + pending, forced := finalSaving.pending, finalSaving.forced + finalSaving.pending = nil + finalSaving.Unlock() + for _, done := range pending { + select { + case <-done: + case <-forced: + return + case <-ctx.Done(): + return + } + } } // Close can fail while the alternate screen still owns the terminal. Retain @@ -23,11 +75,21 @@ func recordFinalSaveFailure(err error) { } finalSaving.Lock() defer finalSaving.Unlock() - if finalSaving.failure == nil { + if finalSaving.failure == nil || (unsavedTranscript(finalSaving.failure) == "" && unsavedTranscript(err) != "") { finalSaving.failure = err } } +// unsavedTranscript reads only the typed location supplied by the writer. +func unsavedTranscript(err error) string { + var unsaved *session.UnsavedChatError + if errors.As(err, &unsaved) { + return unsaved.Transcript + } + return "" +} + +// finishFinalSaving reports one plain sentence after the screen is restored. func finishFinalSaving(ctx context.Context) bool { recordFinalSaveFailure(flushFinalSaves(ctx)) finalSaving.Lock() @@ -41,6 +103,10 @@ func finishFinalSaving(ctx context.Context) bool { if surface { terminal = os.Stdout } - fmt.Fprintln(terminal, quitSaveFailedWord) + line := quitSaveFailedWord + if transcript := unsavedTranscript(err); transcript != "" { + line = fmt.Sprintf(quitSaveKeptWord, transcript) + } + fmt.Fprintln(terminal, line) return false } diff --git a/cmd/codeaf/quit_save_test.go b/cmd/codeaf/quit_save_test.go index a1d48eff7d..fe4b376ca5 100644 --- a/cmd/codeaf/quit_save_test.go +++ b/cmd/codeaf/quit_save_test.go @@ -7,15 +7,17 @@ import ( "os" "strings" "testing" + "time" "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/tui3" ) type failedSaveAgent struct{} func (failedSaveAgent) Close() error { - return errors.New("snapshot: dial unix /missing.sock: connection refused") + return session.SavingError("/temporary/chat/transcript.jsonl", errors.New("snapshot: dial unix /missing.sock: connection refused")) } // B7 puts the failure on the restored screen even when stderr is a log. @@ -60,7 +62,7 @@ func TestQuitSaveFailureUsesTheRestoredSurfaceOutput(t *testing.T) { if err != nil { t.Fatal(err) } - want := "codeaf could not save this chat; unsaved turns stay on this computer and return on the next start." + want := "codeaf could not save this chat; unsaved turns are kept in /temporary/chat/transcript.jsonl and return on the next start." if strings.Count(string(printed), want) != 1 || !strings.HasSuffix(string(printed), want+"\n") || !strings.HasPrefix(string(printed), "screen restored\n") { t.Fatalf("restored terminal: %q", printed) } @@ -103,3 +105,100 @@ func TestQuitSaveFailureIsOnePlainSentence(t *testing.T) { t.Fatalf("exit=%d terminal=%q", code, printed) } } + +// Contract D2 waits for the actual saving reply after restoring the screen. +func TestQuitKeepsALateSaveFailureAfterTheScreenIsRestored(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Cleanup(cell.UseAvailabilityInTests(false)) + oldRun, oldFlush, oldOut := runSurfaceProgram, flushFinalSaves, os.Stdout + out, err := os.CreateTemp(t.TempDir(), "screen") + if err != nil { + t.Fatal(err) + } + defer out.Close() + os.Stdout = out + t.Cleanup(func() { runSurfaceProgram, flushFinalSaves, os.Stdout = oldRun, oldFlush, oldOut }) + release, saved, restored := make(chan struct{}), make(chan struct{}), make(chan struct{}) + released := false + defer func() { + if !released { + close(release) + } + <-saved + }() + runSurfaceProgram = func(_ context.Context, options tui3.Options) error { + if options.ClosingStarted != nil { + options.ClosingStarted(saved) + } + go func() { + defer close(saved) + <-release + options.SaveFailure(failedSaveAgent{}.Close()) + }() + _, err := io.WriteString(os.Stdout, "screen restored\n") + close(restored) + return err + } + profile := t.TempDir() + finished := make(chan error, 1) + go func() { finished <- runSurface(context.Background(), tui3.Options{ProfileDir: profile}) }() + <-restored + select { + case err := <-finished: + t.Fatalf("quit returned %v before the host's saving reply", err) + case <-time.After(100 * time.Millisecond): + } + close(release) + released = true + if err := <-finished; err != nil { + t.Fatal(err) + } + flushFinalSaves = func(context.Context) error { return nil } + defer telemetryArgs("--version")() + if code := execute(); code == 0 { + t.Fatal("the late failed save exited successfully") + } + if _, err := out.Seek(0, io.SeekStart); err != nil { + t.Fatal(err) + } + printed, err := io.ReadAll(out) + if err != nil { + t.Fatal(err) + } + want := "codeaf could not save this chat; unsaved turns are kept in /temporary/chat/transcript.jsonl and return on the next start.\n" + if !strings.HasPrefix(string(printed), "screen restored\n") || !strings.HasSuffix(string(printed), want) || strings.Count(string(printed), want) != 1 { + t.Fatalf("late saving report = %q", printed) + } +} + +// Contract D3 lets a second interrupt end the process after restoring its screen. +func TestSecondQuitInterruptSkipsStuckCleanupAfterRestoringScreen(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Cleanup(cell.UseAvailabilityInTests(false)) + oldRun, oldExit := runSurfaceProgram, surfaceForceExit + t.Cleanup(func() { + runSurfaceProgram, surfaceForceExit = oldRun, oldExit + finalSaving.Lock() + finalSaving.surface, finalSaving.forced = false, nil + finalSaving.Unlock() + }) + restored, exitCode := false, 0 + surfaceForceExit = func(code int) { + if !restored { + t.Fatal("the forced exit ran before restoring the terminal") + } + exitCode = code + } + runSurfaceProgram = func(_ context.Context, options tui3.Options) error { + options.ClosingStarted(make(chan struct{})) + options.ForceQuit() + restored = true + return nil + } + if err := runSurface(context.Background(), tui3.Options{ProfileDir: t.TempDir()}); err != nil { + t.Fatal(err) + } + if exitCode != 130 { + t.Fatalf("the second interrupt did not end the process: exit=%d", exitCode) + } +} diff --git a/cmd/codeaf/syncdrive.go b/cmd/codeaf/syncdrive.go index 5c159d5b5c..dae5d827ed 100644 --- a/cmd/codeaf/syncdrive.go +++ b/cmd/codeaf/syncdrive.go @@ -64,7 +64,8 @@ func (b *driveBook) driveOf(c cell.Cell, engine cellstore.Engine, report func(er // back here) never drives again: the chat opened here needs a drive side of its own, or every // tool call in it would be refused as superseded. delete(b.drive, c.ID) - d := newLiveDrive(func() (*syncsetup.Drive, error) { return startDrive(c, engine, report) }) + d := newLiveDrive(func(changed func()) (*syncsetup.Drive, error) { return startDrive(c, engine, report, changed) }) + d.transcript = (session.Place{Dir: c.Root}).Transcript() if d != nil { if b.drive == nil { b.drive = map[string]*liveDrive{} @@ -77,7 +78,7 @@ func (b *driveBook) driveOf(c cell.Cell, engine cellstore.Engine, report func(er // startDrive opens the drive side of c. The error it answers is already // reported; it is returned so a caller can tell a computer that has no identity // yet (which /pair will change) from one that cannot sync at all. -func startDrive(c cell.Cell, engine cellstore.Engine, report func(error)) (*syncsetup.Drive, error) { +func startDrive(c cell.Cell, engine cellstore.Engine, report func(error), changed ...func()) (*syncsetup.Drive, error) { s, ok, err := syncsetup.Open(home.Dir()) if errors.Is(err, syncsetup.ErrNoIdentity) { // Waiting for an identity is not a seal failing: the live drive starts by itself once one @@ -96,8 +97,13 @@ func startDrive(c cell.Cell, engine cellstore.Engine, report func(error)) (*sync if line, first := s.FirstRun(); first { surfaceNotices.Say(line) } + var onChange func() + if len(changed) > 0 { + onChange = changed[0] + } d, err := s.Drive(context.Background(), engine, c, syncsetup.DriveOptions{ - DeviceName: deviceName(), + DeviceName: deviceName(), + OnRenewalChange: onChange, // The title the chat has now, sealed on its way out: the other machine's // list names the chat by it. Title: func() string { return chatTitle(c) }, @@ -111,6 +117,13 @@ func startDrive(c cell.Cell, engine cellstore.Engine, report func(error)) (*sync return d, err } +// renewalsOf reads the writer already installed for a chat without starting one. +func (b *driveBook) renewalsOf(id string) *liveDrive { + b.mu.Lock() + defer b.mu.Unlock() + return b.drive[id] +} + // sayRefusal tells the person what the relay refused when it refused this // device outright (removed from the fleet, replaced, over its limit) and // answers true, so the chat runs unsynced with the reason on screen instead @@ -142,6 +155,7 @@ func (b *driveBook) closeAll() { for _, d := range drives { if err := d.Close(ctx); err != nil { log.Print("sync: closing a chat: ", err) + recordFinalSaveFailure(session.SavingError(d.transcript, err)) } } } @@ -175,16 +189,14 @@ func driveStore(d *liveDrive) func(cellstore.Engine) cellstore.Store { // closeCell is a conversation's final release after its agent has closed and // its last seal is durable, even when the local engine process stays alive. -func (b *driveBook) closeCell(id string) { +func (b *driveBook) closeCell(id string) error { d := b.takeCell(id) if d == nil { - return + return nil } ctx, cancel := context.WithTimeout(context.Background(), driveCloseWithin) defer cancel() - if err := d.Close(ctx); err != nil { - log.Print("sync: closing a chat: ", err) - } + return session.SavingError(d.transcript, d.Close(ctx)) } // takeCell forgets one drive under the book lock before any closing I/O begins. diff --git a/cmd/codeaf/syncdrive_live.go b/cmd/codeaf/syncdrive_live.go index f973225657..bc31d9f922 100644 --- a/cmd/codeaf/syncdrive_live.go +++ b/cmd/codeaf/syncdrive_live.go @@ -21,6 +21,8 @@ import ( // is the identity, and goes on with the real drive from the call that finds it. type liveDrive struct { start func() (*syncsetup.Drive, error) + // transcript identifies the retained turns if this writer's final publish fails. + transcript string mu sync.Mutex drive *syncsetup.Drive @@ -28,12 +30,15 @@ type liveDrive struct { ready chan struct{} attempted bool closed bool + // changes coalesces local health updates for the host's cached surface state. + changes chan struct{} } // newLiveDrive starts the drive side behind the first paint. Local startup // seals do not wait for the relay; new tool work waits for its ownership check. -func newLiveDrive(start func() (*syncsetup.Drive, error)) *liveDrive { - l := &liveDrive{start: start} +func newLiveDrive(start func(func()) (*syncsetup.Drive, error)) *liveDrive { + l := &liveDrive{changes: make(chan struct{}, 1)} + l.start = func() (*syncsetup.Drive, error) { return start(l.renewalChanged) } l.try() return l } @@ -76,9 +81,39 @@ func (l *liveDrive) install(d *syncsetup.Drive, err error, ready chan struct{}) if d != nil { d.CatchUp() } + l.renewalChanged() return l.closed } +// RenewalStatus reads an already-started drive without starting one or waiting. +func (l *liveDrive) RenewalStatus() (time.Time, time.Duration, bool) { + if l == nil { + return time.Time{}, 0, false + } + l.mu.Lock() + d, closed := l.drive, l.closed + l.mu.Unlock() + if d == nil || closed { + return time.Time{}, 0, false + } + return d.RenewalStatus() +} + +// RenewalChanges lets the host hear health changes even when no turn is running. +func (l *liveDrive) RenewalChanges() <-chan struct{} { + if l == nil { + return nil + } + return l.changes +} + +func (l *liveDrive) renewalChanged() { + select { + case l.changes <- struct{}{}: + default: + } +} + // pending reads the startup boundary without holding the mutex while waiting // for its network work to finish. func (l *liveDrive) pending() <-chan struct{} { diff --git a/cmd/codeaf/wormhole_contract_test.go b/cmd/codeaf/wormhole_contract_test.go index 3deb48d0e1..e78cac27bb 100644 --- a/cmd/codeaf/wormhole_contract_test.go +++ b/cmd/codeaf/wormhole_contract_test.go @@ -88,7 +88,7 @@ func TestC4SeatConstructionDoesNotWaitForTheRelay(t *testing.T) { // TestC4AStartupFaultReleasesWaitingTools proves C4: background sync failing // cannot leave the visible conversation waiting forever for its ownership read. func TestC4AStartupFaultReleasesWaitingTools(t *testing.T) { - d := newLiveDrive(func() (*syncsetup.Drive, error) { panic("startup fixture") }) + d := newLiveDrive(func(func()) (*syncsetup.Drive, error) { panic("startup fixture") }) done := make(chan struct{}) go func() { _ = d.Gate(); close(done) }() select { diff --git a/internal/cellsync/batch.go b/internal/cellsync/batch.go index 742fd1910a..2b974aa063 100644 --- a/internal/cellsync/batch.go +++ b/internal/cellsync/batch.go @@ -46,13 +46,15 @@ type Batcher struct { OnLost func() OnError func(error) // a refusal (see RefusalOf) the person should hear of, once per episode Now func() time.Time // default time.Now; tests inject a fake clock + // OnRenewalChange wakes local readers when renewal health or ownership changes. + OnRenewalChange func() // Liveness, when set, is a watch socket that can vouch for the lease, so // the heartbeat is sent only while it cannot. Nil beats every tick. Liveness Liveness // Sleep waits d or until ctx ends; tests inject a fake. Default is real time. Sleep func(ctx context.Context, d time.Duration) error - mu sync.Mutex // guards Driving, noted, stale, published, refusedSince and told; never held over the network + mu sync.Mutex // guards driving and renewal state; never held over the network noted []string // heads of sealed turns not yet durable, oldest first stale bool // the lease was lost and the orphans are not branched yet published bool // a publish renewed the lease since the last heartbeat tick @@ -60,6 +62,10 @@ type Batcher struct { 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 + // renewalSince retains the first unreachable renewal for the current hold. + renewalSince time.Time + // 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 @@ -385,6 +391,9 @@ func (b *Batcher) release(ctx context.Context) error { return err } err := b.Publisher.Dir.Release(ctx, d.ID(), d.Fence) + if err == nil || errors.Is(err, directory.ErrFenceStale) { + b.endRenewals() + } if errors.Is(err, directory.ErrFenceStale) { return nil } @@ -447,6 +456,7 @@ func (b *Batcher) drain(ctx context.Context) error { func (b *Batcher) publish(ctx context.Context, head string) error { d, _, _ := b.view() ex, err := b.Publisher.publish(ctx, &d, head, b.info()) + b.renewalResult(d, err) if errors.Is(err, ErrSuperseded) { b.setStale() if b.OnLost != nil { @@ -473,6 +483,7 @@ func (b *Batcher) beat(ctx context.Context) { return } _, err := b.Publisher.Dir.Heartbeat(ctx, d.ID(), directory.Beat{Fence: d.Fence, Pending: pending}) + b.renewalResult(d, err) switch { case err == nil: b.tell(pending) @@ -601,11 +612,21 @@ func (b *Batcher) view() (Driving, uint32, bool) { func (b *Batcher) adopt(d Driving) { b.mu.Lock() - defer b.mu.Unlock() + changed := b.Driving.ID() != d.ID() || b.Driving.Fence != d.Fence + if changed { + b.renewalSince = time.Time{} + } *b.Driving = d + b.mu.Unlock() + if changed { + b.renewalChanged() + } } -func (b *Batcher) setStale() { b.setFlag(&b.stale, true) } +func (b *Batcher) setStale() { + b.setFlag(&b.stale, true) + b.endRenewals() +} func (b *Batcher) clearStale() { b.setFlag(&b.stale, false) } func (b *Batcher) setFlag(flag *bool, v bool) { diff --git a/internal/cellsync/renewal.go b/internal/cellsync/renewal.go new file mode 100644 index 0000000000..15bc5dd68b --- /dev/null +++ b/internal/cellsync/renewal.go @@ -0,0 +1,58 @@ +package cellsync + +import ( + "errors" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// RenewalStatus reads synchronized local health without contacting the relay. +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 + return b.renewalSince, directory.HeartbeatEvery, held +} + +// renewalResult belongs only to the hold the request actually renewed. +// Late replies from a displaced writer cannot report recovery for a new hold. +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 { + 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 + case err == nil: + changed = !b.renewalSince.IsZero() + b.renewalSince = time.Time{} + case errors.Is(err, directory.ErrUnreachable), errors.Is(err, blobstore.ErrUnreachable), errors.Is(err, wireauth.ErrUnavailable): + if seen.Fence != 0 && b.renewalSince.IsZero() { + b.renewalSince, changed = b.now(), true + } + } + } + b.mu.Unlock() + if changed { + b.renewalChanged() + } +} + +func (b *Batcher) endRenewals() { + b.mu.Lock() + changed := !b.renewalEnded + b.renewalEnded, b.renewalSince = true, time.Time{} + b.mu.Unlock() + if changed { + b.renewalChanged() + } +} + +func (b *Batcher) renewalChanged() { + if b.OnRenewalChange != nil { + b.OnRenewalChange() + } +} diff --git a/internal/cellsync/renewal_test.go b/internal/cellsync/renewal_test.go new file mode 100644 index 0000000000..1551640b18 --- /dev/null +++ b/internal/cellsync/renewal_test.go @@ -0,0 +1,97 @@ +package cellsync + +import ( + "context" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// Contract D1 lets a successful publish renew the same hold and clear its outage. +func TestHeldPublishRenewsHealthAndRemovalEndsIt(t *testing.T) { + r := newRig(t) + r.publishFirst(map[string]string{"reply": "first"}) + b := r.batcher() + b.Now = r.clock.Now + changes := make(chan struct{}, 8) + b.OnRenewalChange = func() { changes <- struct{}{} } + ctx, cancel := context.WithCancel(t.Context()) + ended := make(chan struct{}) + go func() { defer close(ended); b.Run(ctx) }() + defer func() { cancel(); <-ended }() + wait := func(what string, want func(time.Time, time.Duration, bool) bool) { + t.Helper() + timer := time.NewTimer(5 * time.Second) + defer timer.Stop() + for !want(b.RenewalStatus()) { + select { + case <-changes: + case <-timer.C: + t.Fatalf("did not observe %s", what) + } + } + } + r.store.set(blobstore.ErrUnreachable, nil) + note(b, r.seal(map[string]string{"reply": "second"})) + wait("failed publish", func(since time.Time, period time.Duration, held bool) bool { + return since.Equal(r.clock.Now()) && period > 0 && held + }) + r.store.set(nil, nil) + b.Freed() + wait("publish recovery", func(since time.Time, _ time.Duration, held bool) bool { return since.IsZero() && held }) + r.dirA.set(wireauth.ErrRevoked) + note(b, r.seal(map[string]string{"reply": "third"})) + wait("removal", func(_ time.Time, _ time.Duration, held bool) bool { return !held }) +} + +// Contract D1 withdraws the hold as soon as a renewal reports displacement. +func TestStaleRenewalWithdrawsHoldWhilePublishIsStillWaiting(t *testing.T) { + r := newRig(t) + r.publishFirst(map[string]string{"reply": "first"}) + b := r.batcher() + entered, release, ticks := make(chan struct{}), make(chan struct{}), make(chan struct{}) + r.store.set(nil, func(ctx context.Context) { + select { + case <-entered: + default: + close(entered) + } + select { + case <-release: + case <-ctx.Done(): + } + }) + b.Sleep = func(ctx context.Context, _ time.Duration) error { + select { + case <-ticks: + return nil + case <-ctx.Done(): + return ctx.Err() + } + } + changes := make(chan struct{}, 8) + b.OnRenewalChange = func() { changes <- struct{}{} } + ctx, cancel := context.WithCancel(t.Context()) + ended := make(chan struct{}) + go func() { defer close(ended); b.Run(ctx) }() + defer func() { cancel(); close(release); <-ended }() + note(b, r.seal(map[string]string{"reply": "second"})) + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("publish did not reach the held connection") + } + r.dirA.set(directory.ErrFenceStale) + ticks <- struct{}{} + select { + case <-changes: + case <-time.After(5 * time.Second): + t.Fatal("a stale renewal waited for the unfinished publish before withdrawing its hold") + } + if _, _, held := b.RenewalStatus(); held { + t.Fatal("a displaced writer still reports holding the chat") + } +} diff --git a/internal/enginehost/renewal_test.go b/internal/enginehost/renewal_test.go new file mode 100644 index 0000000000..fb237c4391 --- /dev/null +++ b/internal/enginehost/renewal_test.go @@ -0,0 +1,74 @@ +package enginehost + +import ( + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/remote" +) + +// renewingAgent supplies the health changes that arrive while a chat is idle. +type renewingAgent struct { + stubAgent + mu sync.Mutex + since time.Time + held bool + changes chan struct{} +} + +func (a *renewingAgent) RenewalStatus() (time.Time, time.Duration, bool) { + a.mu.Lock() + defer a.mu.Unlock() + return a.since, 10 * time.Second, a.held +} + +func (a *renewingAgent) RenewalChanges() <-chan struct{} { return a.changes } + +func (a *renewingAgent) change(since time.Time, held bool) { + a.mu.Lock() + a.since, a.held = since, held + a.mu.Unlock() + a.changes <- struct{}{} +} + +// Contract D1 carries welcome, idle failure, recovery and displacement across the host wire. +func TestHostCarriesRenewalHealthWithoutSurfaceCalls(t *testing.T) { + shortHome(t) + workspace := t.TempDir() + far := &renewingAgent{held: true, changes: make(chan struct{}, 1)} + stopped := make(chan error, 1) + go func() { + stopped <- Run(workspace, Options{Boot: func(remote.Hello) (*remote.Engine, error) { + return &remote.Engine{Agent: far, Workspace: workspace}, nil + }}) + }() + waitForHostQuietly(t, workspace) + t.Cleanup(func() { _ = Retire(workspace, true); <-stopped }) + client := dialHost(t, workspace) + defer client.Close() + reader, ok := any(client.Agent()).(interface { + RenewalStatus() (time.Time, time.Duration, bool) + }) + if !ok { + t.Fatal("the surface's hosted agent cannot read renewal health") + } + if since, period, held := reader.RenewalStatus(); !since.IsZero() || period != 10*time.Second || !held { + t.Fatalf("welcome renewal state = %v, %v, %v", since, period, held) + } + before := client.CallsMade() + firstFailure := time.Now().Add(-20 * time.Second).UTC() + for _, state := range []struct { + since time.Time + held bool + }{{firstFailure, true}, {time.Time{}, true}, {time.Time{}, false}} { + far.change(state.since, state.held) + waitUntil(t, "idle renewal state", func() bool { + since, period, held := reader.RenewalStatus() + return since.Equal(state.since) && period == 10*time.Second && held == state.held + }) + } + if calls := client.CallsMade() - before; calls != 0 { + t.Fatalf("reading cached renewal health made %d calls", calls) + } +} diff --git a/internal/enginehost/saving_test.go b/internal/enginehost/saving_test.go new file mode 100644 index 0000000000..d2498e17e5 --- /dev/null +++ b/internal/enginehost/saving_test.go @@ -0,0 +1,67 @@ +package enginehost + +import ( + "errors" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/session" +) + +type unsavedHostAgent struct { + stubAgent + failure error +} + +func (a unsavedHostAgent) Close() error { return a.failure } + +// Contract D2 returns the host's own close and final publish failures before departure. +func TestHostReturnsUnsavedTranscriptOnCloseAndDetach(t *testing.T) { + for _, finalPublish := range []bool{false, true} { + for _, door := range []string{"close", "detach", "detach and wait"} { + t.Run(door+map[bool]string{false: "/seal", true: "/publish"}[finalPublish], func(t *testing.T) { + shortHome(t) + workspace := t.TempDir() + transcript := filepath.Join(workspace, "transcript.jsonl") + failure := session.SavingError(transcript, errors.New("seal: snapshot: dial unix missing.sock: connection refused")) + far := unsavedHostAgent{failure: failure} + if finalPublish { + far.failure = nil + } + stopped := make(chan error, 1) + go func() { + stopped <- Run(workspace, Options{Boot: func(remote.Hello) (*remote.Engine, error) { + engine := &remote.Engine{Agent: far, Workspace: workspace, ReleaseOnLeave: func(remote.WrappedAgent) bool { return true }} + if finalPublish { + engine.FinishSaving = func(remote.WrappedAgent) error { return failure } + } + return engine, nil + }}) + }() + waitForHostQuietly(t, workspace) + t.Cleanup(func() { _ = Retire(workspace, true); <-stopped }) + client := dialHost(t, workspace) + defer client.Close() + var err error + switch door { + case "close": + err = client.Agent().Close() + case "detach": + err = client.Agent().Detach() + default: + err = client.Agent().DetachAndWait() + } + var unsaved *session.UnsavedChatError + if !errors.As(err, &unsaved) || unsaved.Transcript != transcript { + t.Fatalf("departure lost the unsaved transcript: %v", err) + } + if door == "close" { + if again := client.Agent().Close(); !errors.As(again, &unsaved) || unsaved.Transcript != transcript { + t.Fatalf("repeated close hid the save failure: %v", again) + } + } + }) + } + } +} diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 10f9b81301..22cfadb356 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -26,13 +26,21 @@ A close still running after that second says `this chat is still finishing in the background`. A slow close alone does not mean saving failed. -## Could not save on quit — unsaved turns on the next start +## Could not save on quit — where are the unsaved turns kept -If the final save fails, the restored terminal says +If the final save fails and the transcript location is known, the restored terminal says +`codeaf could not save this chat; unsaved turns are kept in and return on the next start.` +The path names the transcript on the computer running the chat, including over +`--host`. When the location is unknown, it says `codeaf could not save this chat; unsaved turns stay on this computer and return on the next start.` -The command exits with a failure status. This sentence stays visible after +The command exits with a failure status. One sentence stays visible after the full-screen chat closes, including when error output goes to a log. +The screen closes after at most one second while a slow close finishes. The +launcher keeps its connection open for the saving reply before it exits. A +second ctrl+c while quitting restores the terminal and exits immediately; +unfinished turns stay on the computer running the chat for the next start. + ## How do I make this one chat think harder — can I click the thinking level — effort keys `alt+e` walks this conversation's thinking rung; `/effort` opens the ladder and diff --git a/internal/manual/chat/relay-limits-and-refusals.md b/internal/manual/chat/relay-limits-and-refusals.md index fe9d0f4ae8..c2ead41b16 100644 --- a/internal/manual/chat/relay-limits-and-refusals.md +++ b/internal/manual/chat/relay-limits-and-refusals.md @@ -40,7 +40,7 @@ A refused upload leaves the saved turn on this computer. These are the other sen - **Another of your computers stopped this one** (`401`, code `revoked`). The chat says `this device was removed by another of your devices, so this chat stays here only — run `codeaf pair` to bring it back`, and does not try again: only a new pairing brings the computer back. - **The clock is off** (`401`, code `skew`). The chat says `this computer's clock is off by more than 5 minutes`, once, and tries again at each normal turn of sync. -Each sentence is said once for as long as the trouble lasts, and again if the relay answers in between and then refuses again. An unreachable relay is not a refusal: the chat says nothing about it and tries again with the doubling wait. +Each sentence is said once for as long as the trouble lasts, and again if the relay answers in between and then refuses again. An unreachable relay is retried with the doubling wait. If this computer holds the chat and renewals fail for longer than one renewal period, it says `this chat is not syncing because other machines are unreachable; it keeps working here` once. A successful renewal or publish then says `this chat is syncing again` once. A displaced or removed computer does not claim that it is syncing again. On your own relay the full case is its disk: free space on the machine that runs it, then reopen the chat. diff --git a/internal/remote/client.go b/internal/remote/client.go index a72998cfbf..058acea2c3 100644 --- a/internal/remote/client.go +++ b/internal/remote/client.go @@ -918,7 +918,11 @@ func (c *Client) deliver(frame Frame) { return } if frame.Error != "" { - waiting <- result{err: errors.New(frame.Error), answered: true} + err := errors.New(frame.Error) + if frame.UnsavedTranscript != "" { + err = session.SavingError(frame.UnsavedTranscript, err) + } + waiting <- result{err: err, answered: true} return } waiting <- result{payload: frame.Payload, answered: true} @@ -1935,7 +1939,7 @@ func (a *Agent) Compact(ctx context.Context) error { // behind it, and only cmd/codeaf shuts the door. func (a *Agent) Close() error { _, err := a.c.call(nil, MethodClose, nil) - return err + return session.SavingError(a.c.Welcome().SessionFile, err) } // WorkOutlivesExit says whether this conversation keeps working once the view @@ -1948,18 +1952,16 @@ func (a *Agent) WorkOutlivesExit() bool { return a.c.Welcome().Persistent } // Detach lets go of this VIEW of the conversation, which is what a terminal // closing means: the window is gone and the work need not be. // -// Against a session host it sends [MethodDetach] — nothing interrupted, nothing -// closed, the connection ended by the far side's reader loop with the turn left -// to finish — because [MethodClose] would end a running task on behalf of -// somebody who only shut a window. Against a one-shot engine, whose whole life -// is this pipe, leaving IS ending, so the interrupt and the flush stand. +// Running work stays on its host. A paired writer with no remaining work +// finishes saving before acknowledging departure, so its final failure still +// reaches the terminal. A one-shot engine ends with its connection and always +// interrupts and flushes before leaving. // -// Every call below is bounded ([Client.call] carries callDeadline) and safe on a -// dead connection, so this cannot hold a quit open. +// Every request is bounded, and the surface restores while slow saving finishes. func (a *Agent) Detach() error { if a.WorkOutlivesExit() { _, err := a.c.call(nil, MethodDetach, nil) - return err + return session.SavingError(a.c.Welcome().SessionFile, err) } a.Interrupt() return a.Close() @@ -1972,7 +1974,7 @@ func (a *Agent) DetachAndWait() error { return a.Detach() } _, err := a.c.call(nil, MethodDetach, DetachArgs{WaitRelease: true}) - return err + return session.SavingError(a.c.Welcome().SessionFile, err) } // Model is the model the next request will use. @@ -2648,6 +2650,14 @@ func (a *Agent) NeedsPerson() bool { return a.c.facts.read().NeedsPerson } // read of what it said and never a frame on the wire. func (a *Agent) SealFailing() bool { return a.c.facts.read().Unsealed } +// RenewalStatus reads the far writer's last health update without a network call. +func (a *Agent) RenewalStatus() (time.Time, time.Duration, bool) { + if state := a.c.facts.read().Renewal; state != nil { + return state.Since, state.Period, state.Held + } + return time.Time{}, 0, false +} + // ReplaceQuestion starts a revised request after retiring the pending turn. func (a *Agent) ReplaceQuestion(ctx context.Context, answer session.Answer) (<-chan session.Event, error) { return a.open(ctx, MethodQuestionReplace, QuestionArgs{Answer: answer}) diff --git a/internal/remote/quit_release_test.go b/internal/remote/quit_release_test.go index 68d36752fe..3863c342ef 100644 --- a/internal/remote/quit_release_test.go +++ b/internal/remote/quit_release_test.go @@ -6,53 +6,45 @@ import ( "time" ) -// A clean quit requires the last idle chat's closing boundary to finish before the -// terminal receives its deliberate departure acknowledgment. +// Contract D2 keeps the saving result in every idle writer's departure reply. func TestDetachAcknowledgesOnlyAfterClosingRelease(t *testing.T) { - far := newCloseGatedAgent() - engine := &Engine{Agent: far, ReleaseOnLeave: func(WrappedAgent) bool { return true }} - client := viewOn(t, NewSession(engine, true)) - done := make(chan error, 1) - go func() { done <- client.Agent().DetachAndWait() }() - select { - case <-far.entered: - case <-done: - close(far.release) - _ = client.Close() - t.Fatal("detach acknowledged before starting the closing release") - case <-time.After(time.Second): - close(far.release) - t.Fatal("detach did not start the closing release") - } - acknowledged := false - select { - case <-done: - acknowledged = true - t.Error("detach acknowledged before the closing release finished") - default: - } - close(far.release) - if !acknowledged { - if err := <-done; err != nil { - t.Fatal(err) - } - } -} - -// A clean quit requires an ordinary window to keep its asynchronous detach even -// when its chat will release after the last connection closes. -func TestOrdinaryDetachDoesNotWaitForRelease(t *testing.T) { - far := newCloseGatedAgent() - engine := &Engine{Agent: far, ReleaseOnLeave: func(WrappedAgent) bool { return true }} - client := viewOn(t, NewSession(engine, true)) - defer close(far.release) - if err := client.Agent().Detach(); err != nil { - t.Fatal(err) - } - select { - case <-far.entered: - t.Fatal("ordinary detach waited for the closing release") - default: + for _, ordinary := range []bool{false, true} { + t.Run(map[bool]string{false: "coordinating", true: "ordinary"}[ordinary], func(t *testing.T) { + far := newCloseGatedAgent() + engine := &Engine{Agent: far, ReleaseOnLeave: func(WrappedAgent) bool { return true }} + client := viewOn(t, NewSession(engine, true)) + done := make(chan error, 1) + go func() { + if ordinary { + done <- client.Agent().Detach() + } else { + done <- client.Agent().DetachAndWait() + } + }() + select { + case <-far.entered: + case <-done: + close(far.release) + _ = client.Close() + t.Fatal("detach acknowledged before starting the closing release") + case <-time.After(time.Second): + close(far.release) + t.Fatal("detach did not start the closing release") + } + acknowledged := false + select { + case <-done: + acknowledged = true + t.Error("detach acknowledged before the closing release finished") + default: + } + close(far.release) + if !acknowledged { + if err := <-done; err != nil { + t.Fatal(err) + } + } + }) } } diff --git a/internal/remote/renewal.go b/internal/remote/renewal.go new file mode 100644 index 0000000000..5d5322e833 --- /dev/null +++ b/internal/remote/renewal.go @@ -0,0 +1,59 @@ +package remote + +import "github.com/Agent-Field/codeaf/internal/guard" + +// watchRenewals forwards idle health changes through the ordinary cached facts. +// A swap replaces the subscription so the previous writer cannot keep it alive. +func (sess *Session) watchRenewals() { + sess.mu.Lock() + if sess.renewalStop != nil { + close(sess.renewalStop) + sess.renewalStop = nil + } + agent, generation := sess.agent, sess.generation + closed := sess.closed + sess.mu.Unlock() + if closed { + return + } + source, ok := agent.(interface{ RenewalChanges() <-chan struct{} }) + if !ok { + return + } + changes := source.RenewalChanges() + if changes == nil { + return + } + stop := make(chan struct{}) + sess.mu.Lock() + if sess.closed || sess.generation != generation { + sess.mu.Unlock() + return + } + sess.renewalStop = stop + sess.pumps.Add(1) + sess.mu.Unlock() + guard.Go("chat renewal changes", func() { + defer sess.pumps.Done() + for { + select { + case _, open := <-changes: + if !open { + return + } + sess.announce() + case <-stop: + return + } + } + }) +} + +func (sess *Session) stopRenewals() { + sess.mu.Lock() + defer sess.mu.Unlock() + if sess.renewalStop != nil { + close(sess.renewalStop) + sess.renewalStop = nil + } +} diff --git a/internal/remote/saving_test.go b/internal/remote/saving_test.go new file mode 100644 index 0000000000..d333ba2608 --- /dev/null +++ b/internal/remote/saving_test.go @@ -0,0 +1,36 @@ +package remote + +import ( + "errors" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" +) + +type plainSaveFailure struct{ *fakeAgent } + +func (a plainSaveFailure) Close() error { return errors.New("snapshot: dial unix missing.sock") } + +// Contract D2 keeps a known transcript even when an older peer sends only error text. +func TestOldSavingReplyKeepsTheWelcomeTranscript(t *testing.T) { + const transcript = "/temporary/remote-chat/transcript.jsonl" + loop, err := Loopback(Hello{}, Options{Boot: func(Hello) (*Engine, error) { + return &Engine{Agent: plainSaveFailure{&fakeAgent{}}, SessionFile: transcript}, nil + }}) + if err != nil { + t.Fatal(err) + } + defer loop.Close() + err = loop.Client.Agent().Close() + var unsaved *session.UnsavedChatError + if !errors.As(err, &unsaved) || unsaved.Transcript != transcript { + t.Fatalf("an older saving reply lost the known transcript: %v", err) + } + before := loop.CallsMade() + if since, period, held := loop.Client.Agent().RenewalStatus(); !since.IsZero() || period != 0 || held { + t.Fatalf("a peer without renewal health supplied %v, %v, %v", since, period, held) + } + if loop.CallsMade() != before { + t.Fatal("reading absent health queried an older peer") + } +} diff --git a/internal/remote/server.go b/internal/remote/server.go index b7381c4884..9228399d30 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -156,6 +156,8 @@ type Engine struct { // conversation in it. Nil for a door that retains nothing. It changes no // wire shape — a conversation ending is already the end of its stream. Closed func(agent WrappedAgent) + // FinishSaving publishes and releases a closed writer before its reply is sent. + FinishSaving func(agent WrappedAgent) error // ReleaseOnLeave enables immediate idle retirement after the last deliberate // detach for a paired movable chat. Torn links retain their existing grace. ReleaseOnLeave func(WrappedAgent) bool @@ -406,6 +408,10 @@ type Session struct { releaseOnIdle bool instance string closeDone chan struct{} + // closeErr retains the completed saving result for concurrent and repeated departures. + closeErr error + // renewalStop ends idle health forwarding when this conversation changes or closes. + renewalStop chan struct{} // generation counts the session swaps. Every pump remembers the generation // it was born in and goes quiet the moment it is not the current one — see @@ -546,6 +552,7 @@ func NewSession(engine *Engine, persistent bool) *Session { // arriving surface reads in its welcome — and the turn itself is journalled // either way, because the engine is the only writer of the session file. sess.watchOwnTurns() + sess.watchRenewals() // AND THE CONVERSATION IS FILED UNDER THE NAME ITS OWN NEWS ARRIVES UNDER, // from the moment it exists, so the phase of a wake that runs before // anybody attaches has somewhere to be steered to (news.go). @@ -767,42 +774,53 @@ func (sess *Session) lastWatched() string { // interrupted with its journal flushed, exactly as a person's own /quit would. // The window is that walk, not the whole idle span. func (sess *Session) RetireIfIdle(olderThan time.Duration) bool { + retired, _ := sess.retireIfIdle(olderThan) + return retired +} + +func (sess *Session) retireIfIdle(olderThan time.Duration) (bool, error) { if sess.releasePending() { olderThan = 0 } idle := sess.IdleSince() if idle.IsZero() || time.Since(idle) <= olderThan { - return false + return false, nil } sess.mu.Lock() if again := sess.idleSinceLocked(); again.IsZero() || time.Since(again) <= olderThan { // Somebody came back, a turn opened, or a card was raised while the // question was being asked. The conversation stays. sess.mu.Unlock() - return false + return false, nil } agent, already := sess.agent, sess.closed name := sess.lastWatch sess.closed = true sess.mu.Unlock() - _ = sess.shutDown(agent, already, session.StopByRetired, name) - return true + return true, sess.shutDown(agent, already, session.StopByRetired, name) } // shutDown is what ending a conversation does once the caller has won the // closed flag: every lane left, the turn interrupted, the journal flushed. -func (sess *Session) shutDown(agent WrappedAgent, already bool, door session.StopDoor, name string) error { +func (sess *Session) shutDown(agent WrappedAgent, already bool, door session.StopDoor, name string) (saveErr error) { if already { // A concurrent final stream may already be releasing this chat. Its // closed flag cannot acknowledge a departure before that release ends. if sess.closeDone != nil { <-sess.closeDone } - return nil - } - if sess.closeDone != nil { - defer close(sess.closeDone) + sess.mu.Lock() + defer sess.mu.Unlock() + return sess.closeErr } + defer func() { + sess.mu.Lock() + sess.closeErr = saveErr + sess.mu.Unlock() + if sess.closeDone != nil { + close(sess.closeDone) + } + }() // The rails are left BEFORE the agent is, so no lane is still delivering off // a conversation that is being flushed and shut (tasklane.go), and version // 11's subscription goes the same way (standinglane.go). @@ -812,6 +830,7 @@ func (sess *Session) shutDown(agent WrappedAgent, already bool, door session.Sto // a rail left open on a conversation being flushed is a subscription the // sweep has already decided is over (wakelane.go). sess.stopWakeLane() + sess.stopRenewals() // AND THE NEWSROOM LOSES THIS CONVERSATION, which is what puts the two // global readers back when the last one on this process goes: a host // holding nothing has to read as a build with nobody watching, because that @@ -833,8 +852,7 @@ func (sess *Session) shutDown(agent WrappedAgent, already bool, door session.Sto agent.InterruptFor(door) } err := agent.Close() - sess.noteClosed(agent) - return err + return errors.Join(err, sess.noteClosed(agent)) } // noteClosed tells the door that built this agent that its conversation is @@ -846,11 +864,18 @@ func (sess *Session) shutDown(agent WrappedAgent, already bool, door session.Sto // It is read off the engine rather than captured at construction for the reason // [Session.folder] gives about its own reading — the engine behind a session // can be swapped under it. -func (sess *Session) noteClosed(agent WrappedAgent) { - if agent == nil || sess.engine == nil || sess.engine.Closed == nil { - return +func (sess *Session) noteClosed(agent WrappedAgent) error { + if agent == nil || sess.engine == nil { + return nil + } + var err error + if sess.engine.FinishSaving != nil { + err = sess.engine.FinishSaving(agent) + } + if sess.engine.Closed != nil { + sess.engine.Closed(agent) } - sess.engine.Closed(agent) + return err } // folder is where a picture arriving on this wire gets written down: the @@ -1645,6 +1670,7 @@ func (sess *Session) swap(asked *server, build func() (WrappedAgent, string, boo sess.retakeTaskLanes() sess.retakeLanes() sess.retakeWakeLane() + sess.watchRenewals() // AND THE NEWSROOM IS TOLD THE ROOM'S NAME HAS CHANGED. /new and /resume // mint a whole new agent, whose news arrives under a name of its own, and a // conversation still filed under the old one would draw a status row that @@ -1866,13 +1892,13 @@ func (s *server) serve(in io.Reader) (err error) { // money into a journal nobody will reopen. The interrupt goes first so the // turn stops where it is and keeps its partial reply — exactly what ctrl+c // does locally — and then the agent is closed, which flushes the file. -func (s *server) leave() { +func (s *server) leave() error { if s.left.Swap(true) { - return + return nil } sess := s.session if sess == nil { - return + return nil } sess.detach(s, s.detached.Load()) if s.detached.Load() { @@ -1880,12 +1906,11 @@ func (s *server) leave() { engine, agent := sess.engine, sess.agent sess.mu.Unlock() if engine != nil && engine.ReleaseOnLeave != nil && engine.ReleaseOnLeave(agent) { - sess.releaseAfterLeave() + return sess.releaseAfterLeave() } } if s.goodbye.Load() { - _ = sess.closeLeaving() - return + return sess.closeLeaving() } if !sess.persistent { // AND THIS ONE IS THE UNATTENDED DOOR BY CONSTRUCTION, whatever the @@ -1893,8 +1918,9 @@ func (s *server) leave() { // conversation's whole life, so nothing is ever going to attach to it // again and there is no window left to come back. It names the one it // had, which is the last thing anybody reading the journal can use. - _ = sess.closeFor(session.StopByRetired, sess.lastWatched()) + return sess.closeFor(session.StopByRetired, sess.lastWatched()) } + return nil } // handshake reads the first line and answers it. A version mismatch and a line @@ -2041,6 +2067,10 @@ func (s *server) dispatch(call Frame) { result := Frame{Kind: "result", ID: call.ID, Payload: payload} if err != nil { result.Payload, result.Error = nil, err.Error() + var unsaved *session.UnsavedChatError + if errors.As(err, &unsaved) { + result.UnsavedTranscript = unsaved.Transcript + } } _ = s.send(result) // And only now does a turn opened by that call begin to speak. @@ -2738,17 +2768,14 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { return nil, sess.closeLeaving() case MethodDetach: - args, err := arg[DetachArgs](call) + _, err := arg[DetachArgs](call) if err != nil { return nil, err } s.detached.Store(true) - if args.WaitRelease { - // A coordinating terminal must finish closing its idle chats - // before handing leadership back and stopping its member. - s.leave() - } - return nil, nil + // An idle writer's final save belongs in the departure reply while + // somebody can still hear its failure. Running work stays hosted. + return nil, s.leave() case MethodHeldQuestions: sess.mu.Lock() @@ -3451,13 +3478,14 @@ func mustJSON(value any) json.RawMessage { // releaseAfterLeave retains a clean departure until the final stream ends. // The idle decision still checks attached windows and unfinished work together. -func (sess *Session) releaseAfterLeave() { +func (sess *Session) releaseAfterLeave() error { func() { sess.mu.Lock() defer sess.mu.Unlock() sess.releaseOnIdle = len(sess.surfaces) == 0 }() - sess.RetireIfIdle(0) + _, err := sess.retireIfIdle(0) + return err } func (sess *Session) releasePending() bool { diff --git a/internal/remote/wire.go b/internal/remote/wire.go index 82422942ea..cc2709d6d0 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -450,6 +450,8 @@ type Frame struct { // Error is a call that failed, on "result" frames, and the reason on // "fatal" frames. Error string `json:"error,omitempty"` + // UnsavedTranscript preserves a recovery location on a failed saving reply. + UnsavedTranscript string `json:"unsavedTranscript,omitempty"` // Seq is this event's position in its stream, counting from 1, on "event" // and "closed" frames only. diff --git a/internal/session/facts.go b/internal/session/facts.go index 43144e2541..f776797212 100644 --- a/internal/session/facts.go +++ b/internal/session/facts.go @@ -1,5 +1,7 @@ package session +import "time" + // facts.go is the SMALL SET OF FACTS A FRAME READS, gathered in one place. // // A surface sitting in front of a local agent asks these one at a time and each @@ -113,6 +115,15 @@ type Facts struct { // that is not sealed at all and an engine that predates the field all state // nothing, and the emptiness law draws nothing for each. Unsealed bool `json:"unsealed,omitempty"` + // Renewal carries optional writer health; older engines leave it absent. + Renewal *RenewalFact `json:"renewal,omitempty"` +} + +// RenewalFact is a local renewal reading carried whole to a watching surface. +type RenewalFact struct { + Since time.Time `json:"since,omitzero"` + Period time.Duration `json:"period,omitempty"` + Held bool `json:"held,omitempty"` } // LevelFor is the reasoning level held for one model id, and "" for a model @@ -204,6 +215,14 @@ func FactsOf(source FactSource) Facts { if door, ok := source.(interface{ SealFailing() bool }); ok { facts.Unsealed = door.SealFailing() } + if door, ok := source.(interface { + RenewalStatus() (time.Time, time.Duration, bool) + }); ok { + since, period, held := door.RenewalStatus() + if period > 0 { + facts.Renewal = &RenewalFact{Since: since, Period: period, Held: held} + } + } return facts } diff --git a/internal/session/renewal.go b/internal/session/renewal.go new file mode 100644 index 0000000000..0548bd6f14 --- /dev/null +++ b/internal/session/renewal.go @@ -0,0 +1,25 @@ +package session + +import "time" + +// RenewalState keeps health local to the writer and signals changes while idle. +type RenewalState interface { + RenewalStatus() (time.Time, time.Duration, bool) + RenewalChanges() <-chan struct{} +} + +// RenewalStatus reads the chat's local renewal state without starting any work. +func (a *Agent) RenewalStatus() (time.Time, time.Duration, bool) { + if a == nil || a.config.Renewals == nil { + return time.Time{}, 0, false + } + return a.config.Renewals.RenewalStatus() +} + +// RenewalChanges wakes the host when idle renewal health needs to cross the wire. +func (a *Agent) RenewalChanges() <-chan struct{} { + if a == nil || a.config.Renewals == nil { + return nil + } + return a.config.Renewals.RenewalChanges() +} diff --git a/internal/session/session.go b/internal/session/session.go index 1ba176d278..6c35980341 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -1260,6 +1260,8 @@ type Config struct { // seat: whoever builds one builds the other, and a config with no seat has // none. Nil draws and says nothing. Seals SealState + // Renewals reads the local writer's health so the surface can report an outage. + Renewals RenewalState // AuthKeySource explains a refused worker account without supplying routing // settings to a worker whose completer already owns its endpoint and key. // Nil keeps ordinary sessions on their own profile and source ladder. diff --git a/internal/syncsetup/drive.go b/internal/syncsetup/drive.go index 08cd7cf81b..c15cad448f 100644 --- a/internal/syncsetup/drive.go +++ b/internal/syncsetup/drive.go @@ -30,6 +30,8 @@ type DriveOptions struct { Title func() string // OnNotice hears a person-facing line: the superseded line, the skew line. OnNotice func(string) + // OnRenewalChange wakes local readers after a renewal or ownership change. + OnRenewalChange func() // Sleep replaces real time between flushes; tests inject a fake. Sleep func(ctx context.Context, d time.Duration) error } @@ -312,15 +314,16 @@ func (s *Sync) batcher(eng cellstore.Engine, c cell.Cell, drv cellsync.Driving, Dir: s.Dir, Publisher: pub, Map: branches, NewID: func() (string, error) { return cell.NewID(time.Now()) }, }, - Driving: &drv, - Interval: s.Interval, - Info: d.info, - OnFlush: d.flushed(), - OnSuperseded: d.becomeViewer, - OnLost: func() { d.refusal(directory.ErrFenceStale) }, - OnError: d.refusal, - Sleep: d.opt.Sleep, - Liveness: s.Liveness(), + Driving: &drv, + Interval: s.Interval, + Info: d.info, + OnFlush: d.flushed(), + OnSuperseded: d.becomeViewer, + OnLost: func() { d.refusal(directory.ErrFenceStale) }, + OnError: d.refusal, + Sleep: d.opt.Sleep, + Liveness: s.Liveness(), + OnRenewalChange: d.opt.OnRenewalChange, }, nil } @@ -342,8 +345,12 @@ func (d *Drive) becomeViewer(s cellsync.Superseded) { } line := chatlist.Superseded(device) d.mu.Lock() + changed := d.line == "" d.line = line d.mu.Unlock() + if changed && d.opt.OnRenewalChange != nil { + d.opt.OnRenewalChange() + } // Refuse new work immediately, then preserve the pending save before // announcing displacement. A routine ownership check does no fsync (C28). ctx, cancel := context.WithTimeout(context.Background(), cellstore.SealWithin) @@ -399,6 +406,14 @@ func (d *Drive) Viewer() (string, bool) { return d.line, d.line != "" } +// RenewalStatus reads this writer's cached renewal health without network work. +func (d *Drive) RenewalStatus() (time.Time, time.Duration, bool) { + if _, viewer := d.Viewer(); viewer || d.batcher == nil || d.sync.removed.Load() { + return time.Time{}, 0, false + } + return d.batcher.RenewalStatus() +} + // HoldsLease says whether this drive side still drives the chat: it is not a // viewer, and the directory's lease is this device's at the fence this drive // side publishes under. A lease taken by another device since (a take by force diff --git a/internal/syncsetup/renewal_test.go b/internal/syncsetup/renewal_test.go new file mode 100644 index 0000000000..7ab8359804 --- /dev/null +++ b/internal/syncsetup/renewal_test.go @@ -0,0 +1,114 @@ +package syncsetup + +import ( + "context" + "fmt" + "net" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "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/home" + "github.com/Agent-Field/codeaf/internal/identity" +) + +type renewalStatusReader interface { + RenewalStatus() (time.Time, time.Duration, bool) +} + +// refusingRenewals puts a real connection refusal on the relay boundary. +type refusingRenewals struct { + directory.Client + address string + down atomic.Bool +} + +func (r *refusingRenewals) Heartbeat(ctx context.Context, id string, beat directory.Beat) (directory.CellView, error) { + if r.down.Load() { + conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", r.address) + if conn != nil { + conn.Close() + } + return directory.CellView{}, fmt.Errorf("%w: %v", directory.ErrUnreachable, err) + } + return r.Client.Heartbeat(ctx, id, beat) +} + +// Contract D1 keeps the first failed renewal locally until a real renewal succeeds. +func TestHeldDriveReportsRefusedRenewalsAndRecovery(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + t.Setenv(URLVar, "http://relay.invalid") + local := t.TempDir() + id, err := identity.Ensure(local) + mustNil(t, err) + dev, err := identity.Device(local) + mustNil(t, err) + memory := directory.NewMemory(time.Now) + owner, other := memory.For(dev.ID()), memory.For("another-computer") + c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.HostBound}) + mustNil(t, err) + _, err = owner.Create(t.Context(), c.ID, directory.CellInit{}) + mustNil(t, err) + syncer := &Sync{Home: local, Identity: id, Device: dev, Dir: owner, Store: blobstore.NewMemory()} + listener, err := net.Listen("tcp", "127.0.0.1:0") + mustNil(t, err) + address := listener.Addr().String() + mustNil(t, listener.Close()) + relay := &refusingRenewals{Client: owner, address: address} + syncer.Dir = relay + ticks, ready := make(chan struct{}), make(chan struct{}, 1) + drive, err := syncer.Drive(t.Context(), cellstore.Engine{}, c, DriveOptions{Sleep: func(ctx context.Context, _ time.Duration) error { + ready <- struct{}{} + select { + case <-ticks: + return nil + case <-ctx.Done(): + return ctx.Err() + } + }}) + mustNil(t, err) + defer drive.Close(t.Context()) + reader, ok := any(drive).(renewalStatusReader) + if !ok { + t.Fatal("the held drive cannot report renewal failures") + } + <-ready + tick := func() { + t.Helper() + select { + case ticks <- struct{}{}: + case <-time.After(5 * time.Second): + t.Fatal("renewal tick did not start") + } + select { + case <-ready: + case <-time.After(5 * time.Second): + t.Fatal("renewal tick did not finish") + } + } + before := time.Now() + relay.down.Store(true) + tick() + waitFor(t, "failed renewal", func() bool { since, _, _ := reader.RenewalStatus(); return !since.IsZero() }) + since, period, held := reader.RenewalStatus() + if !held || period != directory.HeartbeatEvery || since.Before(before) || since.After(time.Now()) { + t.Fatalf("renewal status = %v, %v, %v", since, period, held) + } + tick() + if next, _, _ := reader.RenewalStatus(); !next.Equal(since) { + t.Fatalf("a repeated failure changed the first failure from %v to %v", since, next) + } + relay.down.Store(false) + tick() + waitFor(t, "renewal recovery", func() bool { since, _, held := reader.RenewalStatus(); return held && since.IsZero() }) + view, err := other.Cell(t.Context(), c.ID) + mustNil(t, err) + _, err = other.Acquire(t.Context(), c.ID, directory.AcquireOpts{Force: true, Expect: &directory.LeaseExpectation{Device: view.Cell.Lease.Device, Fence: view.Cell.Lease.Fence}}) + mustNil(t, err) + tick() + waitFor(t, "displacement", func() bool { _, _, held := reader.RenewalStatus(); return !held }) +} diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 2c35b28af0..b863098124 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -2839,12 +2839,21 @@ type app struct { // read by [app.placeWord] and [app.contextStart]. owned bool // movedName names a chat moved here whose project is not on this machine. - movedName string - deviceID string - syncOutages map[string]bool + movedName string + // deviceID excludes this computer from offers to continue another computer's chat. + deviceID string + // syncOutages remembers which chats have already announced their current outage. + syncOutages map[string]bool + // conversationClosing discards keys while a departing chat finishes closing. conversationClosing bool - quitting bool - saveFailure func(error) + // quitting keeps new input out of teardown while allowing another interrupt to leave. + quitting bool + // saveFailure retains closing errors for the report after the screen is restored. + saveFailure func(error) + // closingStarted retains pending engine replies after the terminal is restored. + closingStarted func(<-chan struct{}) + // forceQuit requests immediate process exit after a second interrupt restores the screen. + forceQuit func() // handedApproval is the tool-approval posture this launch knows the // surface's own profile cannot answer, carried in Options.ApprovalMode. Over // --host it is the engine's row; locally it is --yolo's forced allow. Empty @@ -3085,6 +3094,8 @@ func newApp(ctx context.Context, opts Options) *app { movedName: movedName, deviceID: opts.DeviceID, saveFailure: opts.SaveFailure, + closingStarted: opts.ClosingStarted, + forceQuit: opts.ForceQuit, landing: opts.Landing, autoContinue: opts.Landing && !opts.PickSession && opts.Host == "", takeOverAt: opts.TakeOver, @@ -3926,7 +3937,16 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { return a, nil case tea.KeyPressMsg: - if a.quitting || (a.conversationClosing && msg.String() != "ctrl+c") { + if a.quitting { + if msg.String() == "ctrl+c" { + if a.forceQuit != nil { + a.forceQuit() + } + return a, tea.Quit + } + return a, nil + } + if a.conversationClosing && msg.String() != "ctrl+c" { return a, nil } if a.cdelete.on { @@ -9098,7 +9118,7 @@ func (a *app) quit() tea.Cmd { // transcript; one left open by a process that has gone is a conversation // nobody can reopen, and a stood one's folder would never reach the item it // made (homeexchange.go's [app.fileEveryExchange]). - return quitAfterClosing(leave) + return quitAfterClosing(leave, a.closingStarted) } // interrupt is esc: stop the turn, keep what it said. diff --git a/internal/tui3/conversation_close.go b/internal/tui3/conversation_close.go index d309a667c3..a9d22736ca 100644 --- a/internal/tui3/conversation_close.go +++ b/internal/tui3/conversation_close.go @@ -7,19 +7,25 @@ import ( "github.com/Agent-Field/codeaf/internal/session" ) +// conversationCloseWithin bounds how long the surface waits for engine cleanup. const conversationCloseWithin = time.Second const closeSaveFailedWord = "this chat could not be saved; its unsaved turns stay on this computer for the next start" const closeStillFinishingWord = "this chat is still finishing in the background" +// conversationClosedMsg returns a closing result without blocking the update loop. type conversationClosedMsg struct { err error finishing bool } -func quitAfterClosing(work func()) tea.Cmd { +// quitAfterClosing restores the terminal even when cleanup is still running. +func quitAfterClosing(work func(), started func(<-chan struct{})) tea.Cmd { + done := make(chan struct{}) + if started != nil { + started(done) + } + go func() { defer close(done); work() }() return func() tea.Msg { - done := make(chan struct{}) - go func() { defer close(done); work() }() timer := time.NewTimer(conversationCloseWithin) defer timer.Stop() select { @@ -32,17 +38,21 @@ func quitAfterClosing(work func()) tea.Cmd { // Closing releases the keyboard after a bounded wait. The close method has no // cancellation door, so it can finish later without holding the update loop. -func closeConversationLater(agent Agent, door session.StopDoor, report func(error)) tea.Cmd { +func closeConversationLater(agent Agent, door session.StopDoor, report func(error), started func(<-chan struct{})) tea.Cmd { + done, finished := make(chan error, 1), make(chan struct{}) + if started != nil { + started(finished) + } + go func() { + defer close(finished) + agent.InterruptFor(door) + err := agent.Close() + if err != nil && report != nil { + report(err) + } + done <- err + }() return func() tea.Msg { - done := make(chan error, 1) - go func() { - agent.InterruptFor(door) - err := agent.Close() - if err != nil && report != nil { - report(err) - } - done <- err - }() timer := time.NewTimer(conversationCloseWithin) defer timer.Stop() select { diff --git a/internal/tui3/keeper.go b/internal/tui3/keeper.go index 2bdad8b7da..8da7d81c9e 100644 --- a/internal/tui3/keeper.go +++ b/internal/tui3/keeper.go @@ -1274,7 +1274,7 @@ func (a *app) closeFrontFor(door session.StopDoor) (tea.Cmd, bool) { return cmd, more } a.conversationClosing = true - return tea.Batch(cmd, closeConversationLater(leaving, door, a.saveFailure)), true + return tea.Batch(cmd, closeConversationLater(leaving, door, a.saveFailure, a.closingStarted)), true } // stepBackFront is the same act for a window that is NOT ending anything: another diff --git a/internal/tui3/relay_surface_test.go b/internal/tui3/relay_surface_test.go index a76f1274b4..e588c284bf 100644 --- a/internal/tui3/relay_surface_test.go +++ b/internal/tui3/relay_surface_test.go @@ -10,6 +10,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/Agent-Field/codeaf/internal/chatlist" "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/session" ) // B1 keeps every edit with the opening chat and leaves an early send unsent. @@ -167,13 +168,24 @@ func (a *renewalAgent) RenewalStatus() (time.Time, time.Duration, bool) { return a.since, 10 * time.Second, a.held } -// B6 says a sustained renewal outage once, then says syncing is back once. +func (a *renewalAgent) RenewalChanges() <-chan struct{} { return nil } + +// Contract D1 says a sustained outage and recovery once through the real agent. func TestHolderReportsRenewalOutageAndRecovery(t *testing.T) { - agent := &renewalAgent{fakeAgent: &fakeAgent{model: "m"}, held: true} + t.Setenv("CODEAF_HOME", t.TempDir()) + source := &renewalAgent{fakeAgent: &fakeAgent{model: "m"}, held: true} + agent, err := session.New(session.Config{ + Workspace: t.TempDir(), Model: "test/renewal", APIKey: "fixture", + BaseURL: "http://127.0.0.1:1/v1", System: "Test only", Renewals: source, + }) + if err != nil { + t.Fatal(err) + } + defer agent.Close() a := newTestApp(agent) now := time.Now() a.clock = func() time.Time { return now } - agent.since = now + source.since = now a.Update(tea.WindowSizeMsg{Width: 160, Height: 40}) if got := plain(frame(a)); strings.Contains(got, "this chat is not syncing") { t.Fatalf("a brief renewal failure was announced: %s", got) @@ -186,7 +198,7 @@ func TestHolderReportsRenewalOutageAndRecovery(t *testing.T) { if got := plain(frame(a)); strings.Count(got, outage) != 1 { t.Fatalf("outage notice count is wrong: %s", got) } - agent.since = time.Time{} + source.since = time.Time{} for range 3 { a.Update(tea.WindowSizeMsg{Width: 160, Height: 40}) } @@ -286,3 +298,35 @@ func TestQuittingLastChatNeverBlocksTheUpdateLoop(t *testing.T) { t.Fatal("last chat close held the update loop") } } + +// Contract D3 keeps a second interrupt available while a close is stuck. +func TestSecondCtrlCLeavesWhileQuitIsStillClosing(t *testing.T) { + agent := &blockedCloseAgent{fakeAgent: &fakeAgent{model: "m"}, release: make(chan struct{}), entered: make(chan struct{})} + a := newTestApp(agent) + forced := false + a.forceQuit = func() { forced = true } + _, first := a.Update(key("ctrl+c")) + if first == nil { + t.Fatal("the first interrupt did not start quitting") + } + finished := make(chan tea.Msg, 1) + go func() { finished <- first() }() + defer func() { close(agent.release); <-finished }() + select { + case <-agent.entered: + case <-time.After(time.Second): + t.Fatal("quit never started closing") + } + _, second := a.Update(key("ctrl+c")) + if second == nil { + t.Fatal("quitting swallowed the second interrupt") + } + if msg := second(); msg == nil { + t.Fatal("the second interrupt did not end the surface") + } else if _, ok := msg.(tea.QuitMsg); !ok { + t.Fatalf("second interrupt returned %T", msg) + } + if !forced { + t.Fatal("the second interrupt did not release process cleanup") + } +} diff --git a/internal/tui3/renewal_notice.go b/internal/tui3/renewal_notice.go index 479507429c..ee8cc32e14 100644 --- a/internal/tui3/renewal_notice.go +++ b/internal/tui3/renewal_notice.go @@ -13,6 +13,7 @@ type renewalReader interface { RenewalStatus() (time.Time, time.Duration, bool) } +// takeRenewalNotice says each sustained outage and recovery once for its chat. func (a *app) takeRenewalNotice() { source, ok := a.agent.(renewalReader) if !ok { diff --git a/internal/tui3/tui3.go b/internal/tui3/tui3.go index cbd570001b..55f8d8ee03 100644 --- a/internal/tui3/tui3.go +++ b/internal/tui3/tui3.go @@ -445,6 +445,10 @@ type Options struct { // SaveFailure keeps a close failure for the terminal's restored screen. // The callback may run outside the update loop. SaveFailure func(error) + // ClosingStarted retains pending cleanup until the restored terminal can report it. + ClosingStarted func(<-chan struct{}) + // ForceQuit cancels that wait when a second interrupt asks to leave immediately. + ForceQuit func() // Takeover continues a chat from another machine here. Nil is a machine // that cannot, and the offer is then absent rather than failing // (homepanel_continue.go). From 936dabd8afba9b6d233af3a52bee65f10fcd26ff Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 22:57:55 -0400 Subject: [PATCH 09/28] docs(manual): words typed while /new opens wait in the new chat Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/manual/chat/commands.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index d1c0fdfa4c..db03b64542 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -468,8 +468,10 @@ with a fresh agent and a fresh session file. On a current engine connection, opening runs in the background. The screen says `opening conversation… · esc cancels` while it prepares the conversation. Repeated -Enter does not create another one. Esc, typing, or clicking cancels the pending -transition; the existing conversation and unsent draft stay available. +Enter does not create another one. Words you type meanwhile belong to the new +conversation and wait, unsent, in its box once it opens. Esc or clicking cancels the +pending transition; the existing conversation and its unsent draft stay available, and +anything typed for the new one comes back to this one, unsent. **It adds one rather than closing this one.** The conversation you were in is left open behind it — still streaming its turn, still running its tasks — and `tab` over an empty From bd7ea8ecc408bb92c89e3cbee8ae3d627a0e2328 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 23:49:15 -0400 Subject: [PATCH 10/28] fix(saving): the recovery notice reaches an idle chat, and an unused one clears The sealing watch now tracks this conversation's own turn boundary, so a later bookkeeping append cannot hide a turn that was saved, and a turn that has just started cannot borrow the previous one's saved head. Publication is required only where the drive will actually publish, so an unused startup chat that recovers locally clears. An idle hosted chat's notice crosses the host's notice frames, so "sealing works again" reaches the window even when no turn is running. A clean release also removes the matching local send hold. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/cellstore/publication.go | 36 ++++-- internal/cellstore/sealwatch.go | 28 ++++- internal/cellstore/sealwatch_boundary_test.go | 83 ++++++++++++++ internal/manual/chat/sessions-and-rewind.md | 9 +- internal/remote/client.go | 5 + internal/remote/renewal.go | 39 ++++++- internal/session/sealnotice.go | 15 +++ internal/syncsetup/drive.go | 15 ++- internal/syncsetup/seal_recovery_idle_test.go | 103 ++++++++++++++++++ internal/syncsetup/send_hold_release_test.go | 24 ++++ internal/syncsetup/sendlease.go | 54 +++++++++ 11 files changed, 390 insertions(+), 21 deletions(-) create mode 100644 internal/cellstore/sealwatch_boundary_test.go create mode 100644 internal/syncsetup/seal_recovery_idle_test.go create mode 100644 internal/syncsetup/send_hold_release_test.go diff --git a/internal/cellstore/publication.go b/internal/cellstore/publication.go index 27f3bdd574..3ceb25802e 100644 --- a/internal/cellstore/publication.go +++ b/internal/cellstore/publication.go @@ -8,30 +8,48 @@ import ( var publication = struct { sync.Mutex - roots map[string]int -}{roots: map[string]int{}} + roots map[string]map[*publicationRule]bool +}{roots: map[string]map[*publicationRule]bool{}} + +type publicationRule struct{ owes func(int64) bool } // RequirePublication keeps recovery notices behind the chat's publish boundary, // including startup turns sealed before the drive had its first fence. -func RequirePublication(c cell.Cell) func() { +func RequirePublication(c cell.Cell, owes ...func(int64) bool) func() { + rule := &publicationRule{} + if len(owes) > 0 { + rule.owes = owes[0] + } publication.Lock() - publication.roots[c.Root]++ + if publication.roots[c.Root] == nil { + publication.roots[c.Root] = map[*publicationRule]bool{} + } + publication.roots[c.Root][rule] = true publication.Unlock() var once sync.Once return func() { once.Do(func() { publication.Lock() defer publication.Unlock() - publication.roots[c.Root]-- - if publication.roots[c.Root] == 0 { + delete(publication.roots[c.Root], rule) + if len(publication.roots[c.Root]) == 0 { delete(publication.roots, c.Root) } }) } } -func requiresPublication(c cell.Cell) bool { +func requiresPublication(c cell.Cell, end int64) bool { publication.Lock() - defer publication.Unlock() - return publication.roots[c.Root] > 0 + rules := make([]*publicationRule, 0, len(publication.roots[c.Root])) + for rule := range publication.roots[c.Root] { + rules = append(rules, rule) + } + publication.Unlock() + for _, rule := range rules { + if rule.owes == nil || rule.owes(end) { + return true + } + } + return false } diff --git a/internal/cellstore/sealwatch.go b/internal/cellstore/sealwatch.go index 9c9c5c0366..57ed07229e 100644 --- a/internal/cellstore/sealwatch.go +++ b/internal/cellstore/sealwatch.go @@ -51,6 +51,8 @@ type SealWatch struct { cause string said []string failing bool + // requiredEnd follows conversation writes, so later bookkeeping cannot hide recovery. + requiredEnd int64 } // Report takes the outcome of one seal: nil when it held, the error when it did @@ -62,6 +64,7 @@ func (w *SealWatch) Report(err error) { w.recover() return } + w.requireTranscript() w.fail(err.Error()) } @@ -168,6 +171,7 @@ func (w *SealWatch) TurnEnded() { w.mu.Lock() w.started = true w.idle = true + w.requireTranscript() w.stopTail() w.mu.Unlock() if w.OnTurnEnd != nil { @@ -205,11 +209,24 @@ func (w *SealWatch) TrackTurn(root string) { w.mu.Lock() defer w.mu.Unlock() w.turn = cell.Cell{Root: root} + w.requiredEnd = 0 + w.requireTranscript() +} + +func (w *SealWatch) requireTranscript() { + if w.turn.Root != "" { + if info, err := os.Stat(rel(w.turn, cell.TranscriptPath)); err == nil { + w.requiredEnd = max(w.requiredEnd, info.Size()) + } + } } // A successful background seal cannot speak for the chat's unsaved answer. // Synced turns also retain their failure until the publish boundary covers it. func (w *SealWatch) ownTurnSaved() bool { + if w.started && !w.idle { + return false + } captures.Lock() state := captures.cells[w.turn.Root] if state != nil { @@ -230,10 +247,17 @@ func (w *SealWatch) ownTurnSaved() bool { return false } info, err := os.Stat(rel(w.turn, cell.TranscriptPath)) - if err != nil || head.Receipt.Transcript.End != info.Size() { + if err != nil { + return false + } + requiredEnd := w.requiredEnd + if !w.idle { + requiredEnd = max(requiredEnd, info.Size()) + } + if info.Size() < requiredEnd || head.Receipt.Transcript.End < requiredEnd { return false } - if head.Turn.Fence != 0 || requiresPublication(w.turn) { + if head.Turn.Fence != 0 || requiresPublication(w.turn, head.Receipt.Transcript.End) { unpublished, _, err := Unpublished(w.turn, "") if err != nil || unpublished != "" { return false diff --git a/internal/cellstore/sealwatch_boundary_test.go b/internal/cellstore/sealwatch_boundary_test.go new file mode 100644 index 0000000000..a8712f9442 --- /dev/null +++ b/internal/cellstore/sealwatch_boundary_test.go @@ -0,0 +1,83 @@ +package cellstore + +import ( + "errors" + "os" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +// Contract E1 follows the latest spoken boundary despite later bookkeeping appends. +func TestSealRecoveryCoversTheTurnBeforeLaterBookkeeping(t *testing.T) { + c := newCell(t) + path := rel(c, cell.TranscriptPath) + write := func(text string) { + t.Helper() + if err := os.WriteFile(path, []byte(text), 0600); err != nil { + t.Fatal(err) + } + } + write("first answer\n") + var watch SealWatch + watch.TrackTurn(c.Root) + watch.Report(errors.New("first seal failed")) + watch.Take() + watch.TurnStarted() + write("first answer\nlatest answer\n") + watch.Wrote() + watch.TurnEnded() + engine := (&fakeEngine{}).engine(t) + _, err := engine.Seal(t.Context(), c, TurnInfo{}) + if err != nil { + t.Fatal(err) + } + write("first answer\nlatest answer\nbookkeeping\n") + watch.Report(nil) + if watch.Failing() || !strings.Contains(watch.Take(), "sealing works again") { + t.Fatal("later append hid the saved turn's recovery") + } + watch.TurnStarted() + write("first answer\nlatest answer\nbookkeeping\nunsaved answer\n") + watch.Wrote() + watch.Report(errors.New("next seal failed")) + watch.Take() + watch.Report(nil) + if !watch.Failing() || watch.Take() != "" { + t.Fatal("an older seal claimed the latest answer was saved") + } +} + +// Contract E1 keeps an active turn from borrowing an older saved turn's recovery. +func TestSealRecoveryWaitsForTheActiveTurnToFinish(t *testing.T) { + c := newCell(t) + path := rel(c, cell.TranscriptPath) + if err := os.WriteFile(path, []byte("previous answer\n"), 0600); err != nil { + t.Fatal(err) + } + engine := (&fakeEngine{}).engine(t) + if _, err := engine.Seal(t.Context(), c, TurnInfo{}); err != nil { + t.Fatal(err) + } + var watch SealWatch + watch.TrackTurn(c.Root) + watch.TurnStarted() + watch.Report(errors.New("current turn seal failed")) + if !watch.Failing() { + t.Fatal("an active turn borrowed the previous turn's saved head") + } + watch.Take() + if err := os.WriteFile(path, []byte("previous answer\ncurrent answer\n"), 0600); err != nil { + t.Fatal(err) + } + watch.Wrote() + if _, err := engine.Seal(t.Context(), c, TurnInfo{}); err != nil { + t.Fatal(err) + } + watch.Report(nil) + watch.TurnEnded() + if watch.Failing() || !strings.Contains(watch.Take(), "sealing works again") { + t.Fatal("the finished saved turn did not recover") + } +} diff --git a/internal/manual/chat/sessions-and-rewind.md b/internal/manual/chat/sessions-and-rewind.md index 0b447932cc..8c1f328424 100644 --- a/internal/manual/chat/sessions-and-rewind.md +++ b/internal/manual/chat/sessions-and-rewind.md @@ -995,9 +995,12 @@ Otherwise the process exits after its idle timeout, within 10 minutes, and the next save starts it again. “sealing works again, and the calls made meanwhile are sealed now” appears only -after this chat's own turns are saved again. A chat that syncs must also have -published those turns. Another task saving successfully does not clear this -chat's saving failure. +after this chat's own latest turn is saved again. A spoken turn that syncs must +also be published. An unused startup chat recovers after its local seal; it owes +no publication until you speak. The notice appears once on the next surface tick, +even after the answer has finished, and the saving-failure status clears. +Later bookkeeping does not hide that recovery. Another task saving successfully +does not clear this chat's saving failure. If closing cannot finish saving, the error names the local transcript that keeps the unsaved turns. Keep that file: after storage becomes reachable again, the diff --git a/internal/remote/client.go b/internal/remote/client.go index 058acea2c3..5a85fb4915 100644 --- a/internal/remote/client.go +++ b/internal/remote/client.go @@ -890,6 +890,11 @@ func (c *Client) read() { // reader goroutine before the surface is notified of a changed name. // Reading the replica never waits for the update loop to catch up. c.factsFrame(frame.Payload) + case "notice": + var line string + if json.Unmarshal(frame.Payload, &line) == nil && line != "" { + c.note(line) + } case "fatal": c.bury(spokenError{reason: frame.Error}) return diff --git a/internal/remote/renewal.go b/internal/remote/renewal.go index 5d5322e833..82e86c4a60 100644 --- a/internal/remote/renewal.go +++ b/internal/remote/renewal.go @@ -1,6 +1,10 @@ package remote -import "github.com/Agent-Field/codeaf/internal/guard" +import ( + "time" + + "github.com/Agent-Field/codeaf/internal/guard" +) // watchRenewals forwards idle health changes through the ordinary cached facts. // A swap replaces the subscription so the previous writer cannot keep it alive. @@ -16,12 +20,12 @@ func (sess *Session) watchRenewals() { if closed { return } - source, ok := agent.(interface{ RenewalChanges() <-chan struct{} }) - if !ok { - return + var changes <-chan struct{} + if source, ok := agent.(interface{ RenewalChanges() <-chan struct{} }); ok { + changes = source.RenewalChanges() } - changes := source.RenewalChanges() - if changes == nil { + seals, sealing := agent.(interface{ TakeSealNotice() string }) + if changes == nil && !sealing { return } stop := make(chan struct{}) @@ -35,6 +39,12 @@ func (sess *Session) watchRenewals() { sess.mu.Unlock() guard.Go("chat renewal changes", func() { defer sess.pumps.Done() + var tick <-chan time.Time + if sealing { + timer := time.NewTicker(100 * time.Millisecond) + defer timer.Stop() + tick = timer.C + } for { select { case _, open := <-changes: @@ -42,6 +52,23 @@ func (sess *Session) watchRenewals() { return } sess.announce() + case <-tick: + sess.mu.Lock() + if sess.closed || sess.generation != generation { + sess.mu.Unlock() + return + } + watching := sess.watchingLocked() + sess.mu.Unlock() + if len(watching) == 0 { + continue + } + for line := seals.TakeSealNotice(); line != ""; line = seals.TakeSealNotice() { + sess.announce() + for _, surface := range watching { + _ = surface.send(Frame{Kind: "notice", Payload: mustJSON(line)}) + } + } case <-stop: return } diff --git a/internal/session/sealnotice.go b/internal/session/sealnotice.go index 68772f059a..7ccf4d3173 100644 --- a/internal/session/sealnotice.go +++ b/internal/session/sealnotice.go @@ -9,6 +9,21 @@ func (a *Agent) SealFailing() bool { return a != nil && a.config.Seals != nil && a.config.Seals.Failing() } +// TakeSealNotice forwards saving news after a turn's event stream has ended. +// A running turn keeps its own notices on that stream. +func (a *Agent) TakeSealNotice() string { + if a == nil || a.config.InTask || a.config.Errand || a.config.Seals == nil { + return "" + } + a.mu.Lock() + running := a.running + a.mu.Unlock() + if running { + return "" + } + return a.config.Seals.Take() +} + // noticeSealed says on the turn's own stream what the seat's seal watch has to // say: a file kept out of the saved history, a seal that failed, a seal that // works again. diff --git a/internal/syncsetup/drive.go b/internal/syncsetup/drive.go index c15cad448f..9a73ccf6f3 100644 --- a/internal/syncsetup/drive.go +++ b/internal/syncsetup/drive.go @@ -97,7 +97,10 @@ func (s *Sync) Drive(ctx context.Context, eng cellstore.Engine, c cell.Cell, opt if err := d.recordSendLease(); err != nil { return nil, errors.Join(err, s.Dir.Release(ctx, drv.ID(), drv.Fence)) } - d.publicationDone = cellstore.RequirePublication(c) + d.publicationDone = cellstore.RequirePublication(c, func(end int64) bool { + spoken, known := session.ConversationIn(session.Place{Dir: c.Root}.Transcript(), end) + return spoken || !known + }) d.catchUp(drv) run, cancel := context.WithCancel(context.WithoutCancel(ctx)) d.cancel = cancel @@ -347,7 +350,11 @@ func (d *Drive) becomeViewer(s cellsync.Superseded) { d.mu.Lock() changed := d.line == "" d.line = line + expected := sendLease{Cell: d.directoryID, Fence: d.recordedFence} d.mu.Unlock() + if err := d.sync.forgetSendLease(d.cell.ID, expected); err != nil { + d.notice(err.Error()) + } if changed && d.opt.OnRenewalChange != nil { d.opt.OnRenewalChange() } @@ -535,6 +542,12 @@ func (d *Drive) Close(ctx context.Context) error { d.syncVault(ctx) d.closeErr = d.batcher.Close(ctx) d.scope.Settle() // a publish that failed after the last flush line still counts + if d.closeErr == nil { + d.mu.Lock() + expected := sendLease{Cell: d.directoryID, Fence: d.recordedFence} + d.mu.Unlock() + d.closeErr = d.sync.forgetSendLease(d.cell.ID, expected) + } if d.closeErr == nil && d.publicationDone != nil { d.publicationDone() } diff --git a/internal/syncsetup/seal_recovery_idle_test.go b/internal/syncsetup/seal_recovery_idle_test.go new file mode 100644 index 0000000000..86d7e6a4a7 --- /dev/null +++ b/internal/syncsetup/seal_recovery_idle_test.go @@ -0,0 +1,103 @@ +package syncsetup + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "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/furrow" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/session" +) + +// Sealing recovery needs native captures and a signed relay, without unrelated consensus storage. +func nativeSealingRig(t *testing.T) *twoHomes { + t.Helper() + celltest.RequireEngine(t) + bin, err := furrow.ResolveOwned() + if err != nil { + t.Fatalf("native engine: %v", err) + } + server := relay(t) + t.Setenv(home.EnvVar, t.TempDir()) + t.Setenv(IntervalVar, "50") + r := &driveRig{t: t, bin: bin, work: t.TempDir()} + r.a = openSync(t, machine(t, server.URL)) + r.cell, err = cell.CreateIn(t.TempDir(), cell.Options{Class: cell.HostBound}) + mustNil(t, err) + r.engine = cellstore.EngineFor(r.work) + r.engine.DataRoot, r.engine.Binary, r.engine.Transport = t.TempDir(), bin, cellstore.Spawn{Binary: bin} + r.sealMeta() + t.Cleanup(cellstore.StopSpawned) + t.Cleanup(session.CloseUsage) + return &twoHomes{driveRig: r} +} + +// Contract E1 carries recovery after a failed first seal, two good seals and publication. +func TestPublishedRecoveryReachesAnIdleHostedChat(t *testing.T) { + r := nativeSealingRig(t) + watch := &cellstore.SealWatch{} + agent, err := session.New(session.Config{Place: session.Place{Dir: r.cell.Root}, Workspace: r.work, Model: "test/sealing", APIKey: "fixture", BaseURL: "http://127.0.0.1:1/v1", System: "Test only", Seals: watch}) + mustNil(t, err) + d, err := r.a.Drive(t.Context(), r.engine, r.cell, DriveOptions{}) + mustNil(t, err) + defer func() { mustNil(t, d.Close(context.Background())) }() + appendTo(t, transcriptOf(r.cell), `{"type":"message","role":"user","content":"first turn"}`+"\n") + ctx, cancel := context.WithDeadline(t.Context(), time.Now().Add(-time.Second)) + _, err = d.Store(r.engine).Seal(ctx, r.cell, cellstore.TurnInfo{}) + cancel() + if err == nil { + t.Fatal("expired first seal unexpectedly succeeded") + } + watch.Report(err) + watch.Take() + loop, err := remote.Loopback(remote.Hello{}, remote.Options{Boot: func(remote.Hello) (*remote.Engine, error) { return &remote.Engine{Agent: agent}, nil }}) + mustNil(t, err) + defer loop.Close() + for _, words := range []string{"first reply", "second reply"} { + appendTo(t, transcriptOf(r.cell), `{"type":"message","role":"assistant","content":"`+words+`"}`+"\n") + _, err = d.Store(r.engine).Seal(t.Context(), r.cell, cellstore.TurnInfo{}) + mustNil(t, err) + watch.Report(nil) + } + r.durable(r.cell.ID, r.cell) + var said string + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + said += loop.Client.TakeNotice() + if strings.Contains(said, "sealing works again") && !loop.Client.Agent().SealFailing() { + break + } + time.Sleep(time.Millisecond) + } + if strings.Count(said, "sealing works again") != 1 || loop.Client.Agent().SealFailing() { + t.Fatalf("idle hosted recovery=%q failing=%v", said, loop.Client.Agent().SealFailing()) + } + if watch.Take() != "" || loop.Client.TakeNotice() != "" { + t.Fatal("recovery remained queued after delivery") + } +} + +// Contract E1 lets unused startup bookkeeping recover without publishing a chat. +func TestUnusedPairedStartupSealRecoversWithoutPublication(t *testing.T) { + r := nativeSealingRig(t) + var watch cellstore.SealWatch + watch.TrackTurn(r.cell.Root) + watch.Report(errors.New("startup seal failed")) + watch.Take() + d, err := r.a.Drive(t.Context(), r.engine, r.cell, DriveOptions{}) + mustNil(t, err) + defer func() { mustNil(t, d.Close(context.Background())) }() + _, err = d.Store(r.engine).Seal(t.Context(), r.cell, cellstore.TurnInfo{}) + mustNil(t, err) + watch.Report(nil) + if watch.Failing() || !strings.Contains(watch.Take(), "sealing works again") || watch.Take() != "" { + t.Fatal("unused paired startup still waits for a publication it does not owe") + } +} diff --git a/internal/syncsetup/send_hold_release_test.go b/internal/syncsetup/send_hold_release_test.go new file mode 100644 index 0000000000..41e2e9b559 --- /dev/null +++ b/internal/syncsetup/send_hold_release_test.go @@ -0,0 +1,24 @@ +package syncsetup + +import ( + "os" + "testing" +) + +// Contract E2 clears a released hold without withdrawing another writer's fence. +func TestDepartingWriterClearsOnlyItsOwnSendHold(t *testing.T) { + s := &Sync{Home: t.TempDir(), Ledger: "fixture"} + first, next := sendLease{Cell: "chat", Fence: 1}, sendLease{Cell: "chat", Fence: 2} + mustNil(t, s.recordSendLease("chat", first)) + mustNil(t, s.recordSendLease("chat", next)) + mustNil(t, s.forgetSendLease("chat", first)) + _, fence, err := s.SendLease("chat") + mustNil(t, err) + if fence != next.Fence { + t.Fatal("an old writer withdrew a newer fence") + } + mustNil(t, s.forgetSendLease("chat", next)) + if _, _, err := s.SendLease("chat"); !os.IsNotExist(err) { + t.Fatalf("released hold survived: %v", err) + } +} diff --git a/internal/syncsetup/sendlease.go b/internal/syncsetup/sendlease.go index 2c2de2f96a..f449ce6879 100644 --- a/internal/syncsetup/sendlease.go +++ b/internal/syncsetup/sendlease.go @@ -4,9 +4,11 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "os" "path/filepath" + "github.com/Agent-Field/codeaf/internal/filelock" "github.com/Agent-Field/codeaf/internal/fsreplace" ) @@ -42,6 +44,8 @@ func (s *Sync) SendLease(id string) (string, uint64, error) { func (d *Drive) recordSendLease() error { fence := d.batcher.Fence() + d.mu.Lock() + defer d.mu.Unlock() if fence == 0 || fence == d.recordedFence { return nil } @@ -53,6 +57,11 @@ func (d *Drive) recordSendLease() error { } func (s *Sync) recordSendLease(id string, held sendLease) error { + lock, err := s.lockSendLease(id) + if err != nil { + return err + } + defer lock.Close() raw, err := json.Marshal(held) if err != nil { return err @@ -79,3 +88,48 @@ func (s *Sync) recordSendLease(id string, held sendLease) error { } return nil } + +// forgetSendLease retires only this writer's fence, preserving a newer local owner. +func (s *Sync) forgetSendLease(id string, expected sendLease) error { + lock, err := s.lockSendLease(id) + if err != nil { + return err + } + defer lock.Close() + path := s.sendLeasePath(id) + 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) lockSendLease(id string) (*os.File, error) { + path := s.sendLeasePath(id) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return nil, err + } + lock, err := os.OpenFile(path+".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 +} From 096080f538ad1cbbcbd987847eb8c9c16ca4c4ce Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 23:49:15 -0400 Subject: [PATCH 11/28] fix(chat): one Enter sends without asking the relay, and quitting keeps every word - A chat this computer holds, or one never shared, sends from its local hold and fence with no relay request; a copy held elsewhere makes one bounded read, and an established outage is not paid for again on every send. - A reply that arrives after the draft or chat changed says why the words are still unsent instead of dropping them silently. - Any quit, including the terminal closing, folds the words typed while /new opens into the saved draft first. - A close that finishes late reports its failure in the chat now open, naming the chat it closed. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/send_source_local_test.go | 145 ++++++++++++++++++++ cmd/codeaf/synctake.go | 27 ++++ internal/cellstore/published.go | 13 ++ internal/manual/chat/keys.md | 24 +++- internal/syncsetup/drive_lease_test.go | 2 + internal/tui3/app.go | 11 ++ internal/tui3/conversation_close.go | 22 ++- internal/tui3/homeexchange_test.go | 5 +- internal/tui3/homepanel_heldelsewhere.go | 40 ++++-- internal/tui3/keeper.go | 16 ++- internal/tui3/offlooplaw_test.go | 20 ++- internal/tui3/quit_opening_close_test.go | 96 +++++++++++++ internal/tui3/relay_surface_test.go | 22 +++ internal/tui3/send_ownership_safety_test.go | 6 + 14 files changed, 421 insertions(+), 28 deletions(-) create mode 100644 cmd/codeaf/send_source_local_test.go create mode 100644 internal/tui3/quit_opening_close_test.go diff --git a/cmd/codeaf/send_source_local_test.go b/cmd/codeaf/send_source_local_test.go new file mode 100644 index 0000000000..e711fb6cd0 --- /dev/null +++ b/cmd/codeaf/send_source_local_test.go @@ -0,0 +1,145 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/dirwatch" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/tui3" +) + +type sendSurfaceSource struct{ surfaceMachines } + +func (sendSurfaceSource) Follow() dirwatch.Follower { return nil } + +func (sendSurfaceSource) Rows(context.Context) ([]chatlist.Row, error) { return nil, nil } +func (sendSurfaceSource) RowsAt(context.Context) ([]chatlist.Row, uint64, error) { return nil, 0, nil } + +type recordingSendAgent struct { + quietAgent + sent chan string +} + +func (a *recordingSendAgent) Submit(ctx context.Context, text string) (<-chan session.Event, error) { + a.sent <- text + return a.quietAgent.Submit(ctx, text) +} + +// Contract E4 reserves the bounded relay read for copies without local authority. +func TestConcreteSendSourceKeepsHeldAndUnpublishedChatsOffTheNetwork(t *testing.T) { + for _, kind := range []string{"held", "unpublished", "moved"} { + t.Run(kind, func(t *testing.T) { + celltest.Available(t) + home := t.TempDir() + var lists atomic.Int32 + relay := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/dir/list" { + lists.Add(1) + } + <-r.Context().Done() + })) + defer relay.Close() + t.Setenv(syncsetup.URLVar, relay.URL) + _, err := identity.Ensure(home) + if err != nil { + t.Fatal(err) + } + dev, err := identity.LoadDevice(home) + if err != nil { + t.Fatal(err) + } + c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + s, ok, err := syncsetup.Open(home) + if err != nil || !ok { + t.Fatalf("open: %v", err) + } + if kind == "held" { + sum := sha256.Sum256([]byte(s.Ledger + "/" + dev.ID() + "/" + c.ID)) + path := filepath.Join(home, "v3", "sync", "holds", hex.EncodeToString(sum[:])) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(map[string]any{"cell": c.ID, "fence": 7}) + if err := os.WriteFile(path, raw, 0600); err != nil { + t.Fatal(err) + } + } + if kind == "moved" { + if err := os.WriteFile(filepath.Join(c.Root, ".cell", "adopted.json"), []byte(`{"V":1,"head":"received"}`), 0600); err != nil { + t.Fatal(err) + } + } + source := surfaceMachines{s} + read := func(offline bool) ([]chatlist.Row, error) { + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond) + defer cancel() + rows, _, _, err := source.RowsForChatSend(ctx, filepath.Join(c.Root, cell.TranscriptPath), offline) + return rows, err + } + rows, err := read(false) + if kind == "moved" { + if err == nil || lists.Load() != 1 { + t.Fatalf("moved copy reads=%d err=%v", lists.Load(), err) + } + _, _ = read(true) + if lists.Load() != 1 { + t.Fatal("established outage paid for another read") + } + } else { + if err != nil || lists.Load() != 0 { + t.Fatalf("%s chat reads=%d err=%v", kind, lists.Load(), err) + } + if kind == "held" && (len(rows) != 1 || rows[0].Status != chatlist.Here || rows[0].Fence != 7) { + t.Fatalf("local hold=%+v", rows) + } + t.Setenv("CODEAF_HOME", home) + input, typed := io.Pipe() + defer input.Close() + defer typed.Close() + agent := &recordingSendAgent{sent: make(chan string, 2)} + screen := &firstFrameSink{painted: make(chan struct{}, 1)} + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { + done <- tui3.Run(ctx, tui3.Options{Agent: agent, SessionFile: filepath.Join(c.Root, cell.TranscriptPath), Workspace: t.TempDir(), Machines: sendSurfaceSource{source}, Notice: "frame arrived before sync", Input: input, Output: screen, Width: 100, Height: 30}) + }() + defer func() { cancel(); <-done }() + <-screen.painted + _, err := io.WriteString(typed, "one Enter sends\r") + if err != nil { + t.Fatal(err) + } + select { + case got := <-agent.sent: + if got != "one Enter sends" { + t.Fatalf("sent=%q", got) + } + case <-time.After(time.Second): + t.Fatal("first Enter left the words unsent") + } + if lists.Load() != 0 { + t.Fatalf("sending asked the relay %d times", lists.Load()) + } + } + }) + } +} diff --git a/cmd/codeaf/synctake.go b/cmd/codeaf/synctake.go index 4cec73deff..395a6f461d 100644 --- a/cmd/codeaf/synctake.go +++ b/cmd/codeaf/synctake.go @@ -68,6 +68,33 @@ func wireSync(o *tui3.Options) { // surfaceMachines keeps display hints out of the decision to send a message. type surfaceMachines struct{ *syncsetup.Sync } +// RowsForChatSend uses a local hold or a never-shared chat before asking the relay. +// A known outage keeps unknown copies unsent without repeating the network wait. +func (s surfaceMachines) RowsForChatSend(ctx context.Context, file string, offline bool) ([]chatlist.Row, uint64, bool, error) { + if s.Removed() { + return nil, 0, false, directory.ErrRevoked + } + if syncsetup.Quiet(s.Home) { + return nil, 0, true, nil + } + root := session.DirOf(file) + id := filepath.Base(root) + if remote, fence, err := s.SendLease(id); err == nil && fence != 0 { + return []chatlist.Row{{Cell: remote, Status: chatlist.Here, Mine: true, Fence: fence}}, 0, true, nil + } + if c, err := cell.OpenAt(root, id); err == nil { + shared, err := cellstore.Shared(c) + if err == nil && !shared { + return nil, 0, true, nil + } + } + if offline { + return nil, 0, false, directory.ErrUnreachable + } + rows, version, err := s.RowsForSend(ctx) + return rows, version, false, err +} + func (s surfaceMachines) RowsForSend(ctx context.Context) ([]chatlist.Row, uint64, error) { if s.Relay != "" { current, ok, err := syncsetup.Open(s.Home) diff --git a/internal/cellstore/published.go b/internal/cellstore/published.go index d396ea1faa..f656231565 100644 --- a/internal/cellstore/published.go +++ b/internal/cellstore/published.go @@ -14,6 +14,19 @@ import ( // a clean working tree can still hold turns no other device has received. const publishedPath = ".cell/published.json" +// Shared reports whether a chat has ever crossed a publication or adoption boundary. +// Missing records mean a local chat; unreadable records cannot grant permission. +func Shared(c cell.Cell) (bool, error) { + for _, path := range []string{publishedPath, adoptedPath} { + if _, err := os.Stat(rel(c, path)); err == nil { + return true, nil + } else if !errors.Is(err, os.ErrNotExist) { + return false, err + } + } + return false, nil +} + // KnowsHead says whether head is in this tree's sealed ancestry. An adopted // head has no line in the carried turn log, so its note counts even after the // next local seal has extended it. diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 22cfadb356..16a4a3e43a 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -11,6 +11,9 @@ chat you were leaving, unsent, with `opening cancelled; your words are back in this chat, unsent`. Typing when no chat is opening works as usual. +Closing the terminal or quitting during opening also keeps those words, +including an unfinished paste. They return unsent on the next start. + ## Closing a chat when the engine is unreachable — window frozen, queued keys `/quit` closes the current chat when another chat is open. The next chat @@ -25,6 +28,8 @@ If closing fails, it says A close still running after that second says `this chat is still finishing in the background`. A slow close alone does not mean saving failed. +If that background close later fails, the active chat shows the closed chat's +name followed by the same saving-failure sentence, once. ## Could not save on quit — where are the unsaved turns kept @@ -37,10 +42,27 @@ The command exits with a failure status. One sentence stays visible after the full-screen chat closes, including when error output goes to a log. The screen closes after at most one second while a slow close finishes. The -launcher keeps its connection open for the saving reply before it exits. A +launcher keeps its connection open for the final publish and release before it exits. +When the wait after restoring the screen lasts more than about a second, it says +`saving this chat before quitting… (up to 30s)`. +This wait is bounded by the saving ceiling. If the ceiling expires, the final +saving-failure sentence stays visible and unsaved turns remain on this computer. A second ctrl+c while quitting restores the terminal and exits immediately; unfinished turns stay on the computer running the chat for the next start. +## One Enter sends — checking which computer holds this chat + +A chat held here, or one never shared, sends with one Enter using its local +hold. Sending does not wait for the sync service to answer. An established +outage does not add another network wait to every send. + +A copy with no current hold gets one fresh check, bounded to five seconds. +It sends, offers `Continue this chat here?`, or explains why the words remain +unsent. If you change chats meanwhile, it says +`chat changed before sending; your words are still unsent`. +If you edit the draft meanwhile, it says +`draft changed before sending; press enter to send your current words`. + ## How do I make this one chat think harder — can I click the thinking level — effort keys `alt+e` walks this conversation's thinking rung; `/effort` opens the ladder and diff --git a/internal/syncsetup/drive_lease_test.go b/internal/syncsetup/drive_lease_test.go index 4946e770ab..401a1f8338 100644 --- a/internal/syncsetup/drive_lease_test.go +++ b/internal/syncsetup/drive_lease_test.go @@ -72,6 +72,7 @@ func TestC7R2OwnershipReadIncludesFirstPublish(t *testing.T) { }) }) parent = r.cell.ID + appendTo(t, transcriptOf(r.cell), `{"type":"message","role":"user","content":"start this chat"}`+"\n") c := r.startChat(¬iceLog{}) flushed := c.drive.batcher.OnFlush c.drive.batcher.OnFlush = func(f cellsync.Flush) { @@ -136,6 +137,7 @@ func TestC7R2DisplacedWindowDoesNotOwnItsPreservedBranch(t *testing.T) { }) ctx := t.Context() parent = r.cell.ID + appendTo(t, transcriptOf(r.cell), `{"type":"message","role":"user","content":"start this chat"}`+"\n") sealed, err := r.engine.Seal(ctx, r.cell, cellstore.TurnInfo{}) mustNil(t, err) _, err = r.a.Dir.Create(ctx, parent, directory.CellInit{Head: sealed.Turn.ID}) diff --git a/internal/tui3/app.go b/internal/tui3/app.go index b863098124..d9c15ab8bf 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -3435,6 +3435,9 @@ func newApp(ctx context.Context, opts Options) *app { a.titleSent = terminalTitle(a) a.refreshCreditWarnings() // AND THE NOTICE DESK, once the surface can say a note (notices.go). + if opts.Notices == nil { + opts.Notices = NewNotices() + } a.useNotices(opts.Notices) return a } @@ -9088,6 +9091,14 @@ func (a *app) quit() tea.Cmd { // surface even when the browser is still open, just as account connections // and file doors below do. a.cancelSetupAuth() + if a.conversationOpening { + a.cancelConversationOpening() + } + if a.pasting { + a.pasting = false + a.paste(string(a.pasted)) + a.pasted = nil + } // The draft goes to disk on the way out, synchronously and before anything // else: the debounce may be mid-window, and a sentence typed in the last // three hundred milliseconds of a session is exactly the one a person would diff --git a/internal/tui3/conversation_close.go b/internal/tui3/conversation_close.go index a9d22736ca..c1514f9a96 100644 --- a/internal/tui3/conversation_close.go +++ b/internal/tui3/conversation_close.go @@ -1,6 +1,7 @@ package tui3 import ( + "sync" "time" tea "charm.land/bubbletea/v2" @@ -38,19 +39,26 @@ func quitAfterClosing(work func(), started func(<-chan struct{})) tea.Cmd { // Closing releases the keyboard after a bounded wait. The close method has no // cancellation door, so it can finish later without holding the update loop. -func closeConversationLater(agent Agent, door session.StopDoor, report func(error), started func(<-chan struct{})) tea.Cmd { +func closeConversationLater(agent Agent, door session.StopDoor, report func(error), started func(<-chan struct{}), lateFailure func()) tea.Cmd { done, finished := make(chan error, 1), make(chan struct{}) + var mu sync.Mutex + late := false if started != nil { started(finished) } go func() { defer close(finished) - agent.InterruptFor(door) - err := agent.Close() + err := leaveAgentFor(agent, door) if err != nil && report != nil { report(err) } + mu.Lock() done <- err + notify := late && err != nil && lateFailure != nil + mu.Unlock() + if notify { + lateFailure() + } }() return func() tea.Msg { timer := time.NewTimer(conversationCloseWithin) @@ -59,6 +67,14 @@ func closeConversationLater(agent Agent, door session.StopDoor, report func(erro case err := <-done: return conversationClosedMsg{err: err} case <-timer.C: + mu.Lock() + defer mu.Unlock() + select { + case err := <-done: + return conversationClosedMsg{err: err} + default: + late = true + } return conversationClosedMsg{finishing: true} } } diff --git a/internal/tui3/homeexchange_test.go b/internal/tui3/homeexchange_test.go index 3693c93624..8f9ab60923 100644 --- a/internal/tui3/homeexchange_test.go +++ b/internal/tui3/homeexchange_test.go @@ -103,6 +103,7 @@ func (l *errandLab) app(here string, turns ...[]session.Event) *app { l.t.Helper() l.agent = &errandAgent{fakeAgent: fakeAgent{model: "m", turns: turns}} a := l.homeLab.app(here) + keepSurfaceWorkersInTest(l.t, a) a.errandsRoot = l.errands a.errand = func(orders ErrandOrders) (Agent, error) { l.dirs = append(l.dirs, orders.Dir) @@ -720,7 +721,7 @@ func TestEscLeavesTheExchangeAliveAndTheListMoving(t *testing.T) { if cursorWord(a) == before { t.Fatal("the list did not move with an exchange standing beside it") } - a.quit() + spend(t, a, a.quit()) if lab.agent.closes == 0 { t.Fatal("quitting left the errand's agent open") } @@ -1585,7 +1586,7 @@ func TestQuittingFilesEveryOpenExchange(t *testing.T) { a.errandEvent(first, session.Event{Kind: session.EventAutomationUpdate, Automation: &session.AutomationNotice{Update: "saved", Automation: automation.Automation{ID: "cccc000000000009", Title: "leave"}}}) - a.quit() + spend(t, a, a.quit()) if len(a.exchanges) != 0 { t.Fatalf("quitting left %d exchanges open", len(a.exchanges)) } diff --git a/internal/tui3/homepanel_heldelsewhere.go b/internal/tui3/homepanel_heldelsewhere.go index c0f2ee56a6..f3b0bed651 100644 --- a/internal/tui3/homepanel_heldelsewhere.go +++ b/internal/tui3/homepanel_heldelsewhere.go @@ -23,9 +23,8 @@ import ( // instead: home, with the chat's row from that machine under the cursor and the // `Continue here` card up. The words stay in the box. // -// A submitted message asks once off the loop before choosing the door, because -// the cached holder may have changed. The draft stays intact until the answer -// arrives; an unreachable reading leaves those words unsent. +// Local authority lets an ordinary message send without a relay request. A copy +// without that authority asks once off the loop before choosing its door. // heldElsewhere is the row of the chat on screen when another machine holds it. func (a *app) heldElsewhere() (chatlist.Row, bool) { @@ -53,6 +52,7 @@ type ownershipReadMsg struct { head, leaseCell string fence uint64 proof bool + local bool } type sendHold struct { @@ -68,9 +68,8 @@ type sendLeaseSource interface { SendLease(string) (string, uint64, error) } -// checkBeforeSend closes the interval between another device's acquire and -// its change frame arriving. This one read per submitted message stays off the -// update loop, and a never-paired home remains quiet through its source's gate. +// checkBeforeSend reads local authority before requesting any missing ownership. +// The read stays off the update loop, including its bounded network fallback. func (a *app) checkBeforeSend() tea.Cmd { return a.checkBeforeSendWith(func() tea.Cmd { return a.enterCheckedLine(true) }) } @@ -84,6 +83,7 @@ func (a *app) needsOwnershipRead() bool { func (a *app) checkBeforeSendWith(resume func() tea.Cmd) tea.Cmd { src, file, draft := a.machines, a.file, a.input.String() + offline := a.machineRead.down chips := slices.Clone(a.chips) var first *composerState if a.conversationPreview != nil && !a.conversationOpening { @@ -94,8 +94,20 @@ func (a *app) checkBeforeSendWith(resume func() tea.Cmd) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), machinesAskTimeout) defer cancel() - rows, version, err := chatlist.RowsForSend(ctx, src) - msg := ownershipReadMsg{file: file, draft: draft, chips: chips, resume: resume, first: first, reading: homeMachinesMsg{rows: rows, version: version, err: err}} + var rows []chatlist.Row + var version uint64 + var local bool + var err error + if scoped, ok := src.(interface { + RowsForChatSend(context.Context, string, bool) ([]chatlist.Row, uint64, bool, error) + }); ok { + rows, version, local, err = scoped.RowsForChatSend(ctx, file, offline) + } else if offline { + err = directory.ErrUnreachable + } else { + rows, version, err = chatlist.RowsForSend(ctx, src) + } + msg := ownershipReadMsg{file: file, draft: draft, chips: chips, resume: resume, first: first, local: local, reading: homeMachinesMsg{rows: rows, version: version, err: err}} id := filepath.Base(session.DirOf(file)) if local, ok := src.(sendLeaseSource); ok { msg.proof = true @@ -138,6 +150,7 @@ func (a *app) afterOwnershipRead(msg ownershipReadMsg) tea.Cmd { side.pastes, side.draftCursor = nil, nil } } + a.note("chat changed before sending; your words are still unsent") return nil } if msg.first != nil { @@ -145,7 +158,10 @@ func (a *app) afterOwnershipRead(msg ownershipReadMsg) tea.Cmd { // draft, which must not invalidate that send while ownership is read. next := a.liveComposer() a.putComposer(*msg.first) - reading := a.recordMachines(msg.reading) + var reading tea.Cmd + if !msg.local { + reading = a.recordMachines(msg.reading) + } cmd := a.resumeOwnedSend(msg) if a.liveComposer().empty() { a.putComposer(next) @@ -159,9 +175,13 @@ func (a *app) afterOwnershipRead(msg ownershipReadMsg) tea.Cmd { return tea.Batch(reading, cmd) } if a.input.String() != msg.draft || !slices.Equal(a.chips, msg.chips) { + a.note("draft changed before sending; press enter to send your current words") return nil } - reading := a.recordMachines(msg.reading) + var reading tea.Cmd + if !msg.local { + reading = a.recordMachines(msg.reading) + } return tea.Batch(reading, a.resumeOwnedSend(msg)) } diff --git a/internal/tui3/keeper.go b/internal/tui3/keeper.go index 8da7d81c9e..c927a0bf82 100644 --- a/internal/tui3/keeper.go +++ b/internal/tui3/keeper.go @@ -1268,13 +1268,20 @@ func (a *app) closeFront() (tea.Cmd, bool) { // arrived, and "you closed this conversation" and "another window took it" are // two different sentences to be owed (internal/session's stopcause.go). func (a *app) closeFrontFor(door session.StopDoor) (tea.Cmd, bool) { + name := a.sessionName() + if name == "" { + name = a.place + } var leaving Agent cmd, more := a.leaveFront(func(agent Agent) { leaving = agent }, true) if !more || leaving == nil { return cmd, more } a.conversationClosing = true - return tea.Batch(cmd, closeConversationLater(leaving, door, a.saveFailure, a.closingStarted)), true + desk := a.outsideDesk + return tea.Batch(cmd, closeConversationLater(leaving, door, a.saveFailure, a.closingStarted, func() { + desk.Say(name + ": " + closeSaveFailedWord) + })), true } // stepBackFront is the same act for a window that is NOT ending anything: another @@ -1304,8 +1311,7 @@ func (a *app) endAgent(agent Agent) { a.endAgentFor(agent, session.StopByLeaving // CONVERSATION to go, not for the reply to be thrown away — so the engine owes // them a sentence about the answer that never came. func (a *app) endAgentFor(agent Agent, door session.StopDoor) { - agent.InterruptFor(door) - if err := agent.Close(); err != nil { + if err := leaveAgentFor(agent, door); err != nil { a.note("close failed: " + err.Error()) } } @@ -1462,9 +1468,9 @@ func leaveAgentError(agent Agent) error { } // Permanent deletion closes the owner rather than detaching its view. -func leaveAgentFor(agent Agent, door session.StopDoor) { +func leaveAgentFor(agent Agent, door session.StopDoor) error { agent.InterruptFor(door) - _ = agent.Close() + return agent.Close() } // leaveOffFrame is [leaveAgent] started away from the caller. Detach and diff --git a/internal/tui3/offlooplaw_test.go b/internal/tui3/offlooplaw_test.go index fecca6ee54..9b7f68cda6 100644 --- a/internal/tui3/offlooplaw_test.go +++ b/internal/tui3/offlooplaw_test.go @@ -165,7 +165,7 @@ var doorsStillOnTheLoop = map[string]string{ // doorsOnTheLoopBudget is how many such call sites there may be, and it is a // ratchet in `internal/ci`'s own shape: it may only ever be lowered. Move one // off the loop and lower this number in the same commit. -const doorsOnTheLoopBudget = 38 +const doorsOnTheLoopBudget = 37 // NO DOOR IS ASKED FROM THE UPDATE LOOP. // @@ -222,7 +222,9 @@ func TestNoEngineDoorIsAskedFromTheUpdateLoop(t *testing.T) { if _, named := doorsStillOnTheLoop[sel.Sel.Name]; named { // Named debt, carrying where it is asked from. Counted so // the ratchet can hold, and not reported. - debt++ + if !inside(sel) { + debt++ + } return true } found++ @@ -304,13 +306,17 @@ func agentNames(file *ast.File) map[string]bool { return names } -// offLoopSpans is every function literal handed to [app.offLoop] or to -// [app.besideLine] in one file — the only places a door may be asked, both off -// the update loop — and beside it every literal nested -// inside one of those, which is where a door may NOT be asked: the fold runs on -// the update loop. +// offLoopSpans includes explicit goroutines and function literals handed to +// [app.offLoop] or [app.besideLine], which run outside the update loop, +// and beside them every literal nested inside a queued command, where a door +// may not be asked because the fold runs on the update loop. func offLoopSpans(file *ast.File) (allowed, folds []*ast.FuncLit) { ast.Inspect(file, func(n ast.Node) bool { + if launch, ok := n.(*ast.GoStmt); ok { + if lit, ok := launch.Call.Fun.(*ast.FuncLit); ok { + allowed = append(allowed, lit) + } + } call, ok := n.(*ast.CallExpr) if !ok { return true diff --git a/internal/tui3/quit_opening_close_test.go b/internal/tui3/quit_opening_close_test.go new file mode 100644 index 0000000000..9df35ac732 --- /dev/null +++ b/internal/tui3/quit_opening_close_test.go @@ -0,0 +1,96 @@ +package tui3 + +import ( + "errors" + "github.com/Agent-Field/codeaf/internal/chatlist" + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" +) + +// Contract E3 keeps opening words and an unfinished paste across an orderly exit. +func TestSignalQuitRestoresTheOpeningDraftOnNextStart(t *testing.T) { + a := openingLab(t) + a.closeHome() + a.draftFile = filepath.Join(t.TempDir(), "draft.txt") + release := make(chan struct{}) + a.start = func(string) (Conversation, error) { + <-release + return Conversation{Agent: &fakeAgent{model: "m"}, SessionFile: "/next/transcript.jsonl"}, nil + } + a.input.setText("/new") + _, opening := a.Update(key("enter")) + for _, r := range "typed " { + a.Update(key(string(r))) + } + a.Update(tea.PasteStartMsg{}) + a.Update(tea.PasteMsg{Content: "unfinished paste"}) + request := a.conversationRequest + _, quitting := a.Update(sigQuitMsg{}) + spend(t, a, quitting) + close(release) + spend(t, a, opening) + restarted := newApp(t.Context(), Options{Agent: &fakeAgent{model: "m"}, Workspace: a.workspace, SessionFile: a.file, DraftFile: a.draftFile}) + if got := restarted.pastesUnfolded(restarted.input.String()); got != "typed unfinished paste" { + t.Fatalf("next start restored %q", got) + } + if a.conversationOpening || a.conversationRequest == request { + t.Fatal("quit left the pending open valid") + } +} + +// Contract E4 explains an ownership reply that cannot spend its original draft. +func TestChangedOwnershipReplyExplainsTheUnsentFirstEnter(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + a.file = "/tmp/chat/transcript.jsonl" + source := &openingMachineSource{ready: make(chan struct{}), row: chatlist.Row{Cell: "chat", Status: chatlist.Here, Fence: 1}} + a.machines = source + a.input.setText("original words") + _, pending := a.Update(key("enter")) + a.Update(key("x")) + close(source.ready) + spend(t, a, pending) + if len(a.agent.(*fakeAgent).sent) != 0 || !strings.Contains(lastNote(t, a), "draft changed before sending; press enter to send your current words") { + t.Fatalf("first Enter had no explanation: %q", lastNote(t, a)) + } +} + +type lateFailedCloseAgent struct{ *blockedCloseAgent } + +func (a *lateFailedCloseAgent) Close() error { + _ = a.blockedCloseAgent.Close() + return errors.New("saving failed") +} + +// Contract E5 names a closed chat when its background save eventually fails. +func TestSlowCloseSaysItsEventualFailureInTheActiveChat(t *testing.T) { + agent := &lateFailedCloseAgent{&blockedCloseAgent{fakeAgent: &fakeAgent{model: "m"}, release: make(chan struct{})}} + a := newTestApp(agent) + t.Cleanup(a.leaveEverything) + a.file, a.title = "/tmp/closing/transcript.jsonl", "pricing research" + keepThree(t, a) + finished := make(chan struct{}) + a.closingStarted = func(done <-chan struct{}) { go func() { <-done; close(finished) }() } + cmd, more := a.closeFront() + if !more { + t.Fatal("fixture has no next chat") + } + spend(t, a, cmd) + close(agent.release) + <-finished + a.Update(noticesMsg{}) + var shown string + for _, entry := range a.entries { + shown += entry.text + "\n" + } + if !strings.Contains(shown, "pricing research") || !strings.Contains(shown, closeSaveFailedWord) { + t.Fatalf("active chat missed the late failure: %s", shown) + } + before := len(a.entries) + a.Update(noticesMsg{}) + if len(a.entries) != before { + t.Fatal("late failure repeated") + } +} diff --git a/internal/tui3/relay_surface_test.go b/internal/tui3/relay_surface_test.go index e588c284bf..07f33ef7a0 100644 --- a/internal/tui3/relay_surface_test.go +++ b/internal/tui3/relay_surface_test.go @@ -13,11 +13,24 @@ import ( "github.com/Agent-Field/codeaf/internal/session" ) +// Each fixture closes the workers its gestures start before releasing its files. +func keepSurfaceWorkersInTest(t *testing.T, a *app) { + t.Helper() + t.Cleanup(func() { + if !a.quitting { + a.leaveEverything() + } + a.doorLine.close() + }) + a.closingStarted = func(done <-chan struct{}) { t.Cleanup(func() { <-done }) } +} + // B1 keeps every edit with the opening chat and leaves an early send unsent. func TestNewOpeningKeepsTypingPasteAndEarlyEnter(t *testing.T) { for _, cancel := range []bool{false, true} { t.Run(map[bool]string{false: "open", true: "cancel"}[cancel], func(t *testing.T) { a := openingLab(t) + keepSurfaceWorkersInTest(t, a) a.closeHome() old := a.agent.(*fakeAgent) next := &fakeAgent{model: "m"} @@ -54,6 +67,7 @@ func TestNewOpeningKeepsTypingPasteAndEarlyEnter(t *testing.T) { // B1 keeps a paste that starts before arrival and ends after the chat opens. func TestNewOpeningKeepsAPasteAcrossArrival(t *testing.T) { a := openingLab(t) + keepSurfaceWorkersInTest(t, a) a.closeHome() next := &fakeAgent{model: "m"} release := make(chan struct{}) @@ -85,6 +99,7 @@ func TestRemovedComputerReopensWithTheSameProjectLabel(t *testing.T) { want = "ws9 (copy from another machine)" } a := newTestApp(&fakeAgent{model: "m"}) + keepSurfaceWorkersInTest(t, a) a.file = "/tmp/old/transcript.jsonl" spend(t, a, a.takeBeside(Conversation{Agent: &fakeAgent{model: "m"}, SessionFile: "/tmp/copy/transcript.jsonl", Workspace: work})) a.title = "Copy chat" @@ -107,6 +122,7 @@ func TestRemovedComputerReopensWithTheSameProjectLabel(t *testing.T) { func TestResumeNeverOffersThisComputersOwnDevice(t *testing.T) { for _, status := range []chatlist.Status{chatlist.Running, chatlist.Off, chatlist.Idle} { a := leftOffApp(&fakeTaker{}) + keepSurfaceWorkersInTest(t, a) a.deviceID = "self" a.Update(homeMachinesMsg{rows: []chatlist.Row{{Cell: "chat", DeviceID: "self", Device: "laptop", Status: status}}}) if got := leftOffFrame(a); strings.Contains(got, "Continue laptop's latest chat here?") { @@ -114,6 +130,7 @@ func TestResumeNeverOffersThisComputersOwnDevice(t *testing.T) { } } a := leftOffApp(&fakeTaker{}) + keepSurfaceWorkersInTest(t, a) a.Update(homeMachinesMsg{rows: []chatlist.Row{{Cell: "chat", DeviceID: "self", Device: "laptop", Status: chatlist.Off}}}) a.Update(rosterMsg{devices: []chatlist.Device{{ID: "self", Name: "laptop", Self: true}}}) if got := leftOffFrame(a); strings.Contains(got, "Continue laptop's latest chat here?") { @@ -142,6 +159,7 @@ func (s *openingMachineSource) RowsForSend(ctx context.Context) ([]chatlist.Row, // B4 waits for an actual reading before the first send decides the move card. func TestFirstEnterWaitsForOwnershipReading(t *testing.T) { a := newTestApp(&fakeAgent{model: "m"}) + keepSurfaceWorkersInTest(t, a) a.file = "/tmp/chat/transcript.jsonl" a.taker = &fakeTaker{} src := &openingMachineSource{ready: make(chan struct{}), row: chatlist.Row{Cell: "chat", DeviceID: "other", Device: "tower", Status: chatlist.Running, Fence: 3}} @@ -183,6 +201,7 @@ func TestHolderReportsRenewalOutageAndRecovery(t *testing.T) { } defer agent.Close() a := newTestApp(agent) + keepSurfaceWorkersInTest(t, a) now := time.Now() a.clock = func() time.Time { return now } source.since = now @@ -225,6 +244,7 @@ func (a *blockedCloseAgent) Close() error { func TestClosingChatNeverBlocksTheUpdateLoop(t *testing.T) { agent := &blockedCloseAgent{fakeAgent: &fakeAgent{model: "m"}, release: make(chan struct{}), entered: make(chan struct{})} a := newTestApp(agent) + keepSurfaceWorkersInTest(t, a) a.file = "/tmp/closing/transcript.jsonl" keepThree(t, a) a.input.setText("/quit") @@ -270,6 +290,7 @@ func TestClosingChatNeverBlocksTheUpdateLoop(t *testing.T) { func TestQuittingLastChatNeverBlocksTheUpdateLoop(t *testing.T) { agent := &blockedCloseAgent{fakeAgent: &fakeAgent{model: "m"}, release: make(chan struct{}), entered: make(chan struct{})} a := newTestApp(agent) + keepSurfaceWorkersInTest(t, a) a.input.setText("/quit") returned := make(chan tea.Cmd, 1) go func() { _, cmd := a.Update(key("enter")); returned <- cmd }() @@ -303,6 +324,7 @@ func TestQuittingLastChatNeverBlocksTheUpdateLoop(t *testing.T) { func TestSecondCtrlCLeavesWhileQuitIsStillClosing(t *testing.T) { agent := &blockedCloseAgent{fakeAgent: &fakeAgent{model: "m"}, release: make(chan struct{}), entered: make(chan struct{})} a := newTestApp(agent) + keepSurfaceWorkersInTest(t, a) forced := false a.forceQuit = func() { forced = true } _, first := a.Update(key("ctrl+c")) diff --git a/internal/tui3/send_ownership_safety_test.go b/internal/tui3/send_ownership_safety_test.go index c5a65a1d2b..a2732ab5f4 100644 --- a/internal/tui3/send_ownership_safety_test.go +++ b/internal/tui3/send_ownership_safety_test.go @@ -28,6 +28,7 @@ func TestSendRequiresTheWindowsCurrentHold(t *testing.T) { t.Run(state.name, func(t *testing.T) { src := &continuationListing{row: chatlist.Row{Cell: heldCell, Status: chatlist.Here, Mine: true, Fence: 2}} a, agent := heldLab(t, src, &fakeTaker{}) + keepSurfaceWorkersInTest(t, a) a.closeHome() src.change(func(r *chatlist.Row) { r.Status, r.Mine, r.Fence = state.status, state.mine, state.fence }) src.change(func(r *chatlist.Row) { r.Head = "newer" }) @@ -53,6 +54,7 @@ func (*windowProofSource) SendLease(string) (string, uint64, error) { return hel func TestFreshReadRejectsAnotherWindowsHold(t *testing.T) { src := &windowProofSource{&continuationListing{row: chatlist.Row{Cell: heldCell, Status: chatlist.Here, Mine: true, Fence: 2}}} a, agent := heldLab(t, src, &fakeTaker{}) + keepSurfaceWorkersInTest(t, a) a.closeHome() a.input.setText("still unsent") drive(t, a, key("enter")) @@ -67,6 +69,7 @@ func TestFreshReadRejectsAnotherWindowsHold(t *testing.T) { func TestHeldSendUsesOneFreshReading(t *testing.T) { src := &continuationListing{row: chatlist.Row{Cell: heldCell, Status: chatlist.Here, Mine: true, Fence: 2}} a, agent := heldLab(t, src, &fakeTaker{}) + keepSurfaceWorkersInTest(t, a) a.closeHome() before := src.reads a.input.setText("send this") @@ -90,6 +93,7 @@ func TestLapsedCurrentChatSendsAfterAConditionalReturn(t *testing.T) { src := &continuationListing{row: chatlist.Row{Cell: heldCell, Status: chatlist.Off, Mine: true, DeviceID: "dev_here", Fence: 2, Head: "saved"}} taker := &returningTaker{} a, old := heldLab(t, src, taker) + keepSurfaceWorkersInTest(t, a) a.closeHome() root := session.DirOf(a.file) if err := os.MkdirAll(filepath.Join(root, ".cell"), 0700); err != nil { @@ -118,6 +122,7 @@ func TestFailedRestoreCannotSendOnTheReleasedCopy(t *testing.T) { src := &continuationListing{row: leftOffRow(heldCell, "other", chatlist.Running, time.Minute)} taker := &fakeTaker{err: errors.New("handoff: engine materialize: rewind cancelled before mutation: workspace path changed during rewind: .git")} a, agent := heldLab(t, src, taker) + keepSurfaceWorkersInTest(t, a) a.closeHome() a.input.setText("keep this unsent") drive(t, a, key("enter")) @@ -142,6 +147,7 @@ func TestHeldAndUnpublishedChatsStillSend(t *testing.T) { rows = chatlist.Static{{Cell: heldCell, Status: chatlist.Here, Mine: true, Fence: 2}} } a, agent := heldLab(t, rows, &fakeTaker{}) + keepSurfaceWorkersInTest(t, a) a.closeHome() a.input.setText("send this") drive(t, a, key("enter")) From d5eba71a1cf6f7a2ce0ffe42003d5d6a36bcdde1 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 23:49:16 -0400 Subject: [PATCH 12/28] fix(quit): wait for the hand-off, say so, and stop at the save ceiling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the screen is restored the launcher waits for the final publish and release, says "saving this chat before quitting…" when that takes more than a moment, and stops at the existing save ceiling with the plain sentence. A real quit through the engine host is proven to release the chat, so the other computer's bare launch moves it. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/final_save.go | 36 +++- cmd/codeaf/quit_handoff_test.go | 284 ++++++++++++++++++++++++++++++++ cmd/codeaf/quit_wait_test.go | 50 ++++++ 3 files changed, 364 insertions(+), 6 deletions(-) create mode 100644 cmd/codeaf/quit_handoff_test.go create mode 100644 cmd/codeaf/quit_wait_test.go diff --git a/cmd/codeaf/final_save.go b/cmd/codeaf/final_save.go index ab0d0cb4f1..91d3443f41 100644 --- a/cmd/codeaf/final_save.go +++ b/cmd/codeaf/final_save.go @@ -5,13 +5,17 @@ import ( "errors" "fmt" "os" + "os/signal" "sync" + "time" + "github.com/Agent-Field/codeaf/internal/cellstore" "github.com/Agent-Field/codeaf/internal/session" ) const quitSaveFailedWord = "codeaf could not save this chat; unsaved turns stay on this computer and return on the next start." const quitSaveKeptWord = "codeaf could not save this chat; unsaved turns are kept in %s and return on the next start." +const quitSavingWord = "saving this chat before quitting… (up to %s)" // finalSaving keeps one failure until cleanup has returned the terminal. var finalSaving struct { @@ -52,17 +56,37 @@ func forceFinalClose() { // awaitFinalCloses keeps the links open for saving replies after the screen closes. func awaitFinalCloses(ctx context.Context) { + ctx, cancel := context.WithTimeout(ctx, cellstore.SealWithin) + defer cancel() finalSaving.Lock() pending, forced := finalSaving.pending, finalSaving.forced finalSaving.pending = nil finalSaving.Unlock() + if len(pending) == 0 { + return + } + timer := time.NewTimer(time.Second) + defer timer.Stop() + interrupt := make(chan os.Signal, 1) + signal.Notify(interrupt, os.Interrupt) + defer signal.Stop(interrupt) for _, done := range pending { - select { - case <-done: - case <-forced: - return - case <-ctx.Done(): - return + waiting: + for { + select { + case <-done: + break waiting + case <-timer.C: + fmt.Fprintln(os.Stdout, fmt.Sprintf(quitSavingWord, cellstore.SealWithin)) + case <-forced: + return + case <-interrupt: + surfaceForceExit(130) + return + case <-ctx.Done(): + recordFinalSaveFailure(ctx.Err()) + return + } } } } diff --git a/cmd/codeaf/quit_handoff_test.go b/cmd/codeaf/quit_handoff_test.go new file mode 100644 index 0000000000..0919dddeec --- /dev/null +++ b/cmd/codeaf/quit_handoff_test.go @@ -0,0 +1,284 @@ +package main + +import ( + "context" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/enginehost" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/relayserve" + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/tui3" +) + +type savingQuitAgent struct { + quietAgent + save func() error +} + +func (a *savingQuitAgent) Close() error { return a.save() } + +// Contract E2 follows the actual quit, launcher teardown and bare receiving launch. +func TestHostedSurfaceQuitPublishesReleasesAndBareLaunchMovesTheChat(t *testing.T) { + runHostedQuitContract(t, false) +} + +// Contract E2 bounds an offline quit and names the locally retained transcript. +func TestHostedSurfaceQuitWithRelayDownKeepsTheLocalTurns(t *testing.T) { + runHostedQuitContract(t, true) +} + +func runHostedQuitContract(t *testing.T, relayDown bool) { + t.Helper() + cfg, c := doorChat(t) + short, err := os.MkdirTemp("", "ce-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(short) + t.Setenv(home.EnvVar, short) + id, err := identity.Ensure(short) + if err != nil { + t.Fatal(err) + } + service := relayserve.New(relayserve.Config{Store: t.TempDir()}) + defer service.Close() + var releases atomic.Int32 + var down atomic.Bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if down.Load() { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + if strings.HasSuffix(r.URL.Path, "/release") { + releases.Add(1) + } + service.Handler.ServeHTTP(w, r) + })) + defer server.Close() + t.Setenv(syncsetup.URLVar, server.URL) + a, ok, err := syncsetup.Open(short) + if err != nil || !ok { + t.Fatalf("open sender: %v", err) + } + bHome := t.TempDir() + if _, err := identity.Adopt(bHome, id, false); err != nil { + t.Fatal(err) + } + b, ok, err := syncsetup.Open(bHome) + if err != nil || !ok { + t.Fatalf("open receiver: %v", err) + } + engine := cellstore.EngineFor(cfg.Place.Workspace) + engine.DataRoot = t.TempDir() + t.Cleanup(cellstore.StopSpawned) + if err := session.SaveMeta(c.Root, session.Meta{ID: c.ID, Workspace: cfg.Place.Workspace, Title: "quit handoff"}); err != nil { + t.Fatal(err) + } + transcript := filepath.Join(c.Root, cell.TranscriptPath) + write := func(words string) { + t.Helper() + if err := os.WriteFile(transcript, []byte(words), 0600); err != nil { + t.Fatal(err) + } + } + first := `{"type":"session","version":3,"id":"quit"}` + "\n" + `{"type":"message","role":"user","content":"first turn"}` + "\n" + write(first) + drive, err := a.Drive(t.Context(), engine, c, syncsetup.DriveOptions{DeviceName: "sender"}) + if err != nil { + t.Fatal(err) + } + defer drive.Close(context.Background()) + if _, err := drive.Store(engine).Seal(t.Context(), c, cellstore.TurnInfo{}); err != nil { + t.Fatal(err) + } + drive.Idle() + deadline := time.Now().Add(20 * time.Second) + for { + _, fence, e := a.SendLease(c.ID) + if e == nil && fence != 0 { + break + } + if time.Now().After(deadline) { + t.Fatal("first turn did not publish") + } + time.Sleep(time.Millisecond) + } + write(first + `{"type":"message","role":"assistant","content":"final words"}` + "\n") + saving, release := make(chan struct{}), make(chan struct{}) + defer func() { + select { + case <-release: + default: + close(release) + } + }() + far := &savingQuitAgent{save: func() error { + close(saving) + <-release + _, err := drive.Store(engine).Seal(context.Background(), c, cellstore.TurnInfo{}) + return err + }} + hostDone := make(chan error, 1) + go func() { + hostDone <- enginehost.Run(cfg.Place.Workspace, enginehost.Options{Boot: func(remote.Hello) (*remote.Engine, error) { + return &remote.Engine{Agent: far, Workspace: cfg.Place.Workspace, SessionFile: transcript, ReleaseOnLeave: func(remote.WrappedAgent) bool { return true }, FinishSaving: func(remote.WrappedAgent) error { + ctx, cancel := context.WithTimeout(context.Background(), cellstore.SealWithin) + defer cancel() + return session.SavingError(transcript, drive.Close(ctx)) + }}, nil + }}) + }() + var conn net.Conn + for time.Now().Before(deadline) { + conn, err = enginehost.Dial(cfg.Place.Workspace) + if err == nil { + break + } + time.Sleep(time.Millisecond) + } + if conn == nil { + t.Fatal("host never listened") + } + client, err := remote.Dial(conn, "", remote.Hello{}) + if err != nil { + t.Fatal(err) + } + defer client.Close() + defer func() { + select { + case <-release: + default: + close(release) + } + _ = enginehost.Retire(cfg.Place.Workspace, true) + <-hostDone + }() + oldRun := runSurfaceProgram + defer func() { runSurfaceProgram = oldRun }() + input, typed := io.Pipe() + defer input.Close() + defer typed.Close() + screen := &surfaceFrameWriter{ready: make(chan struct{})} + restored := make(chan struct{}) + runSurfaceProgram = func(ctx context.Context, o tui3.Options) error { + o.Input = input + o.Output = screen + o.Width = 100 + o.Height = 30 + err := tui3.Run(ctx, o) + close(restored) + return err + } + ctx, cancel := context.WithTimeout(t.Context(), 25*time.Second) + defer cancel() + done := make(chan error, 1) + go func() { + done <- runSurface(ctx, tui3.Options{Agent: client.Agent(), Workspace: cfg.Place.Workspace, SessionFile: transcript, ProfileDir: short}) + }() + <-screen.ready + began := time.Now() + down.Store(relayDown) + if _, err := io.WriteString(typed, "/quit\r"); err != nil { + t.Fatal(err) + } + <-saving + <-restored + select { + case err := <-done: + t.Fatalf("launcher returned before saving: %v", err) + default: + } + close(release) + if err := <-done; err != nil { + t.Fatal(err) + } + if relayDown { + if time.Since(began) > cellstore.SealWithin+time.Second { + t.Fatal("offline quit exceeded the save ceiling") + } + if releases.Load() != 0 { + t.Fatal("offline quit advertised a release") + } + if _, fence, err := a.SendLease(c.ID); err != nil || fence == 0 { + t.Fatalf("offline quit lost the retained hold: %v", err) + } + raw, err := os.ReadFile(transcript) + if err != nil || !strings.Contains(string(raw), "final words") { + t.Fatal("offline quit lost its final words") + } + oldFlush, oldOut := flushFinalSaves, os.Stdout + out, err := os.CreateTemp(t.TempDir(), "saved-line") + if err != nil { + t.Fatal(err) + } + defer out.Close() + defer func() { flushFinalSaves, os.Stdout = oldFlush, oldOut }() + flushFinalSaves = func(context.Context) error { return nil } + os.Stdout = out + if finishFinalSaving(t.Context()) { + t.Fatal("offline quit claimed a complete save") + } + if _, err := out.Seek(0, io.SeekStart); err != nil { + t.Fatal(err) + } + line, err := io.ReadAll(out) + if err != nil || !strings.Contains(string(line), "unsaved turns are kept in "+transcript) { + t.Fatalf("offline quit report=%q err=%v", line, err) + } + return + } + if releases.Load() != 1 { + t.Fatalf("relay releases=%d", releases.Load()) + } + if _, _, err := a.SendLease(c.ID); !os.IsNotExist(err) { + t.Fatalf("sender hold survived quit: %v", err) + } + view, err := b.Dir.Cell(t.Context(), c.ID) + if err != nil || view.Cell.Lease.ReleasedAt == 0 { + t.Fatalf("relay has no released chat: %+v %v", view, err) + } + head, err := cellstore.Head(c) + if err != nil || head == nil || view.Cell.Head != head.Turn.ID { + t.Fatalf("final head was not published: %v", err) + } + engineB := cellstore.EngineFor("") + engineB.DataRoot = t.TempDir() + copies := t.TempDir() + taker := takeover{b.Continuer(engineB, syncsetup.TakeOptions{DeviceName: "receiver", RootFor: func(id string) string { return filepath.Join(copies, id) }})} + landInput, landTyped := io.Pipe() + defer landInput.Close() + defer landTyped.Close() + landScreen := &surfaceFrameWriter{ready: make(chan struct{})} + landCtx, landCancel := context.WithCancel(t.Context()) + defer landCancel() + landDone := make(chan error, 1) + go func() { + landDone <- tui3.Run(landCtx, tui3.Options{Agent: &quietAgent{}, Workspace: t.TempDir(), SessionFile: "/new/transcript.jsonl", Landing: true, Machines: surfaceMachines{b}, Takeover: taker, Input: landInput, Output: landScreen, Width: 100, Height: 30, Open: func(where, file string) (tui3.Conversation, error) { + return tui3.Conversation{Agent: &quietAgent{}, Workspace: where, SessionFile: file}, nil + }}) + }() + deadline = time.Now().Add(20 * time.Second) + for !strings.Contains(landScreen.String(), "Moved from sender") && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + landCancel() + <-landDone + if !strings.Contains(landScreen.String(), "Moved from sender") { + t.Fatalf("bare launch did not move the chat: %s", landScreen.String()) + } +} diff --git a/cmd/codeaf/quit_wait_test.go b/cmd/codeaf/quit_wait_test.go new file mode 100644 index 0000000000..ccc67cab51 --- /dev/null +++ b/cmd/codeaf/quit_wait_test.go @@ -0,0 +1,50 @@ +package main + +import ( + "context" + "io" + "os" + "strings" + "testing" + "time" +) + +// Contract E2 explains a long post-screen save and bounds an unavailable writer. +func TestQuitWaitExplainsSavingAndKeepsTheExistingCeiling(t *testing.T) { + oldOut := os.Stdout + out, err := os.CreateTemp(t.TempDir(), "screen") + if err != nil { + t.Fatal(err) + } + defer out.Close() + os.Stdout = out + finalSaving.Lock() + finalSaving.failure, finalSaving.pending, finalSaving.forced = nil, nil, nil + finalSaving.Unlock() + t.Cleanup(func() { + os.Stdout = oldOut + finalSaving.Lock() + finalSaving.failure, finalSaving.pending, finalSaving.forced = nil, nil, nil + finalSaving.Unlock() + }) + retainFinalClose(make(chan struct{})) + ctx, cancel := context.WithTimeout(t.Context(), 1200*time.Millisecond) + defer cancel() + awaitFinalCloses(ctx) + if _, err := out.Seek(0, io.SeekStart); err != nil { + t.Fatal(err) + } + raw, err := io.ReadAll(out) + if err != nil { + t.Fatal(err) + } + if strings.Count(string(raw), "saving this chat before quitting…") != 1 { + t.Fatalf("long wait=%q", raw) + } + finalSaving.Lock() + failure := finalSaving.failure + finalSaving.Unlock() + if failure == nil { + t.Fatal("quit's exhausted save bound claimed success") + } +} From 8d287a15f026df406fe32aa985deb864784bcf6a Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 23:49:19 -0400 Subject: [PATCH 13/28] fix(cli): cluster status tells a slow relay from an unreachable one A deadline now says the sync service did not answer in time; only a refused connection, a missing route or a DNS failure says it cannot be reached. A joined computer's status reads the relay's answer without reopening the local member store, and the whole command is bounded by one ceiling. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/cluster.go | 26 +++++++ cmd/codeaf/cluster_deadline_test.go | 74 +++++++++++++++++++ cmd/codeaf/cluster_unreachable_test.go | 4 +- cmd/codeaf/devices.go | 15 +++- .../manual/chat/coordination-and-workers.md | 12 +++ internal/syncsetup/cluster.go | 3 + .../syncsetup/cluster_status_joined_test.go | 35 +++++++++ 7 files changed, 163 insertions(+), 6 deletions(-) create mode 100644 cmd/codeaf/cluster_deadline_test.go create mode 100644 internal/syncsetup/cluster_status_joined_test.go diff --git a/cmd/codeaf/cluster.go b/cmd/codeaf/cluster.go index 732986e919..f620c0db91 100644 --- a/cmd/codeaf/cluster.go +++ b/cmd/codeaf/cluster.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "context" "errors" "fmt" @@ -48,6 +49,31 @@ func runCluster(args []string) error { return explainSyncUnreachable(clusterCommand(ctx, s, args[0], os.Stdout), s.Relay) } func clusterCommand(ctx context.Context, c clusterDoor, verb string, out io.Writer) error { + ctx, cancel := context.WithTimeout(ctx, clusterctl.ForwardTimeout) + defer cancel() + type result struct { + text string + err error + } + done := make(chan result, 1) + go func() { + var output bytes.Buffer + err := clusterCommandWork(ctx, c, verb, &output) + done <- result{output.String(), err} + }() + select { + case got := <-done: + if got.err != nil { + return got.err + } + _, err := io.WriteString(out, got.text) + return err + case <-ctx.Done(): + return explainSyncUnreachable(ctx.Err(), "") + } +} + +func clusterCommandWork(ctx context.Context, c clusterDoor, verb string, out io.Writer) error { if verb == "lead" { r, err := c.LeadCluster(ctx, 0) if err != nil { diff --git a/cmd/codeaf/cluster_deadline_test.go b/cmd/codeaf/cluster_deadline_test.go new file mode 100644 index 0000000000..7bc09d143b --- /dev/null +++ b/cmd/codeaf/cluster_deadline_test.go @@ -0,0 +1,74 @@ +package main + +import ( + "bytes" + "context" + "errors" + "net" + "net/url" + "syscall" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/clusterctl" +) + +// Contract E6 distinguishes an unanswered request from a network that has no route. +func TestClusterStatusDistinguishesDeadlinesFromUnreachableNetworks(t *testing.T) { + for _, item := range []struct { + name string + cause error + want string + }{ + {"deadline", context.DeadlineExceeded, "the sync service at http://relay.invalid did not answer in time — try again"}, + {"refused", syscall.ECONNREFUSED, "the sync service at http://relay.invalid cannot be reached from here — check this computer's network"}, + {"route", syscall.ENETUNREACH, "the sync service at http://relay.invalid cannot be reached from here — check this computer's network"}, + {"dns", &net.DNSError{Err: "no such host", Name: "relay.invalid", IsNotFound: true}, "the sync service at http://relay.invalid cannot be reached from here — check this computer's network"}, + } { + t.Run(item.name, func(t *testing.T) { + failure := &url.Error{Op: "Get", URL: "http://relay.invalid/v2/cluster/control", Err: item.cause} + var output bytes.Buffer + err := clusterCommand(t.Context(), clusterFixture{err: failure}, "status", &output) + if err == nil || err.Error() != item.want || !errors.Is(err, item.cause) || output.Len() != 0 { + t.Fatalf("status=%v output=%q", err, output.String()) + } + }) + } +} + +type slowClusterStatus struct { + clusterFixture + release, finished chan struct{} +} + +func (s slowClusterStatus) ClusterStatus(context.Context) (clusterctl.Status, error) { + defer close(s.finished) + <-s.release + return clusterctl.Status{Self: 42}, nil +} + +// Contract E6 bounds the command even when local store work ignores cancellation. +func TestClusterStatusBoundsLocalStoreWorkWithoutLateOutput(t *testing.T) { + s := slowClusterStatus{release: make(chan struct{}), finished: make(chan struct{})} + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond) + defer cancel() + var output bytes.Buffer + returned := make(chan error, 1) + go func() { returned <- clusterCommand(ctx, s, "status", &output) }() + select { + case err := <-returned: + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("status=%v", err) + } + case <-time.After(time.Second): + close(s.release) + <-s.finished + <-returned + t.Fatal("local store work exceeded the command deadline") + } + close(s.release) + <-s.finished + if output.Len() != 0 { + t.Fatalf("late store work printed %q", output.String()) + } +} diff --git a/cmd/codeaf/cluster_unreachable_test.go b/cmd/codeaf/cluster_unreachable_test.go index 07ffaf9c89..60dc81182a 100644 --- a/cmd/codeaf/cluster_unreachable_test.go +++ b/cmd/codeaf/cluster_unreachable_test.go @@ -3,16 +3,16 @@ package main import ( "bytes" "context" - "errors" "net/url" "strings" + "syscall" "testing" ) // B5 gives an unreachable relay the same words at both command doors. func TestClusterStatusUsesTheDevicesUnreachableSentence(t *testing.T) { var out bytes.Buffer - failure := &url.Error{Op: "Get", URL: "http://relay.invalid/v2/cluster/control", Err: errors.New("dial tcp: connect: connection refused")} + failure := &url.Error{Op: "Get", URL: "http://relay.invalid/v2/cluster/control", Err: syscall.ECONNREFUSED} err := clusterCommand(context.Background(), clusterFixture{err: failure}, "status", &out) want := "the sync service at http://relay.invalid cannot be reached from here — check this computer's network" if err == nil || err.Error() != want || out.Len() != 0 { diff --git a/cmd/codeaf/devices.go b/cmd/codeaf/devices.go index 82ff954d2e..ea1a61de3b 100644 --- a/cmd/codeaf/devices.go +++ b/cmd/codeaf/devices.go @@ -23,6 +23,7 @@ import ( "os" "slices" "strings" + "syscall" "time" "github.com/Agent-Field/codeaf/internal/cell" @@ -428,15 +429,21 @@ func explainSyncUnreachable(err error, relay string) error { } var request *url.Error var network net.Error - if !errors.Is(err, directory.ErrUnreachable) && !errors.As(err, &request) && !errors.As(err, &network) { - return err - } + errors.As(err, &request) + errors.As(err, &network) if relay == "" && request != nil { if address, parseErr := url.Parse(request.URL); parseErr == nil { relay = address.Scheme + "://" + address.Host } } - return toldLine{line: fmt.Sprintf("the sync service at %s cannot be reached from here — check this computer's network", relay), err: err} + if errors.Is(err, context.DeadlineExceeded) || network != nil && network.Timeout() { + return toldLine{line: fmt.Sprintf("the sync service at %s did not answer in time — try again", relay), err: err} + } + var dns *net.DNSError + if errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.ENETUNREACH) || errors.Is(err, syscall.EHOSTUNREACH) || errors.As(err, &dns) { + return toldLine{line: fmt.Sprintf("the sync service at %s cannot be reached from here — check this computer's network", relay), err: err} + } + return err } // explainRemovedDevice gives every command the same removal and recovery words. diff --git a/internal/manual/chat/coordination-and-workers.md b/internal/manual/chat/coordination-and-workers.md index d9b35664b0..924687f760 100644 --- a/internal/manual/chat/coordination-and-workers.md +++ b/internal/manual/chat/coordination-and-workers.md @@ -11,6 +11,18 @@ There is no codeaf phone app yet, so a phone cannot lead yet. A phone browser's A phone would need a supported client, persistent storage, enrollment and a live connection before it could take this role. Those requirements are not a working phone capability today. Leadership coordinates metadata; it does not move a chat or run its tools. +## Cluster status did not answer in time — slow storage or sync service + +`codeaf cluster status` has a thirty-second command ceiling, including local +storage work. A computer already joined is read from the service without +reopening its local member store. A deadline says +`the sync service at
did not answer in time — try again`. +Slow local storage can also use up that allowance; this sentence does not +establish a network failure. + +A refused connection, missing route or failed name lookup instead says +`the sync service at
cannot be reached from here — check this computer's network`. + ## Joined computers, learner capacity and an outdated build An open, paired home with sync on joins the hosted relay, or a self-hosted `codeaf relay --store `, as a durable learner. Its store lives under the home, and reopening reuses its node. Losing that store enrolls a fresh node and retires the old authority. Three anchors vote until a personal computer is explicitly asked to lead, so pairing alone never requires either laptop to stay awake. Joining starts while the paired home is open, or when you ask for `codeaf cluster status`. diff --git a/internal/syncsetup/cluster.go b/internal/syncsetup/cluster.go index 7b578c3b5b..d4afc3ee8f 100644 --- a/internal/syncsetup/cluster.go +++ b/internal/syncsetup/cluster.go @@ -26,6 +26,9 @@ func (s *Sync) ClusterStatus(ctx context.Context) (clusterctl.Status, error) { if err != nil { return status, s.clusterRefusal(ctx, err) } + if status.Self != 0 { + return status, nil + } s.memberMu.Lock() defer s.memberMu.Unlock() if s.member != nil { diff --git a/internal/syncsetup/cluster_status_joined_test.go b/internal/syncsetup/cluster_status_joined_test.go new file mode 100644 index 0000000000..f4e37755c9 --- /dev/null +++ b/internal/syncsetup/cluster_status_joined_test.go @@ -0,0 +1,35 @@ +package syncsetup + +import ( + "context" + "testing" + "time" +) + +// Contract E6 keeps a joined computer's status off its local member lock and store. +func TestJoinedClusterStatusDoesNotReopenTheMemberStore(t *testing.T) { + r := newMemberRig(t) + s := r.home(nil) + readyMember(t, s) + s.stopMember() + s.memberMu.Lock() + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + done := make(chan error, 1) + go func() { + status, err := s.ClusterStatus(ctx) + if err == nil && status.Self == 0 { + err = errMemberWaiting + } + done <- err + }() + select { + case err := <-done: + s.memberMu.Unlock() + mustNil(t, err) + case <-time.After(2 * time.Second): + s.memberMu.Unlock() + <-done + t.Fatal("status waited for the local member lock instead of reading the relay") + } +} From ebe2fce3861f614656fed9699379a26cf407300e Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 23:49:19 -0400 Subject: [PATCH 14/28] test(tasks): a moved finished task now has its historical page The roster fixture now expects the read-only historical page a completed transferred task carries, keeping its recorded-model and journal checks. The services page names services in its title so a search for the word reaches it. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/manual/chat/services.md | 2 +- internal/session/task_journal_test.go | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/manual/chat/services.md b/internal/manual/chat/services.md index e6679eb7d3..c4780263d3 100644 --- a/internal/manual/chat/services.md +++ b/internal/manual/chat/services.md @@ -1,4 +1,4 @@ -# Providers — the places models come from +# Providers and services — the places models come from Older builds called these **services** (or connections, or model services); this page and the surfaces it describes now say **provider**. The word `codeaf services` in a shell is diff --git a/internal/session/task_journal_test.go b/internal/session/task_journal_test.go index fae856563a..a4a508a75d 100644 --- a/internal/session/task_journal_test.go +++ b/internal/session/task_journal_test.go @@ -277,7 +277,7 @@ func TestPlanBackedHistoryRefusesRedirectedTaskAndWorker(t *testing.T) { } // A taken chat may retain its completed task and journal without a plan store. -// Its ordinary task room reads the replayed notice, not a PlanTaskPage or index. +// Its historical task room and replayed roster retain the recorded model. func TestTakenWorkerRosterCarriesRecordedModelWithoutPlanStore(t *testing.T) { for _, saved := range []string{"", "recorded/prior-model"} { t.Run(saved, func(t *testing.T) { @@ -296,8 +296,8 @@ func TestTakenWorkerRosterCarriesRecordedModelWithoutPlanStore(t *testing.T) { t.Fatal(err) } agent := journalAgent(t, place) - if _, found := agent.PlanTaskPage("t-2"); found { - t.Fatal("fixture unexpectedly has a plan page; it must use the generic task room") + if page, found := agent.PlanTaskPage("t-2"); !found || !page.Historical { + t.Fatalf("completed worker has no historical task room: %+v", page) } if got := agent.TaskJournal(2); got != journal { t.Fatalf("worker journal=%q, want %q", got, journal) From 03db4885544ac348e0cab4c18145032955bd5aaf Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 27 Oct 2026 00:02:09 -0400 Subject: [PATCH 15/28] fix(cli): guard the bounded cluster answer and unlock final-save state from defers The cluster command's formatting lives in clusterCommand again, with the bounded runner split out and its goroutine guarded, so a panic there cannot take the terminal down. The final-save and live-drive critical sections each move into a helper that unlocks from a defer. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/chatv3_surface.go | 6 +-- cmd/codeaf/cluster.go | 85 ++++++++++++++++++++---------------- cmd/codeaf/final_save.go | 39 +++++++++++++---- cmd/codeaf/syncdrive_live.go | 17 ++++++-- 4 files changed, 93 insertions(+), 54 deletions(-) diff --git a/cmd/codeaf/chatv3_surface.go b/cmd/codeaf/chatv3_surface.go index 7c3bd3f0dd..527869deef 100644 --- a/cmd/codeaf/chatv3_surface.go +++ b/cmd/codeaf/chatv3_surface.go @@ -48,11 +48,7 @@ var ( // it would lack is everything below, which is why [TestOnlyTheSurfaceHelperRunsTheV3Surface] // reads this package's sources rather than trusting the next door to remember. func runSurface(ctx context.Context, options tui3.Options) error { - finalSaving.Lock() - finalSaving.surface = true - finalSaving.forced = make(chan struct{}) - forced := finalSaving.forced - finalSaving.Unlock() + forced := armFinalSaving() previousSaveFailure := options.SaveFailure options.SaveFailure = func(err error) { recordFinalSaveFailure(err) diff --git a/cmd/codeaf/cluster.go b/cmd/codeaf/cluster.go index f620c0db91..f0af0ea370 100644 --- a/cmd/codeaf/cluster.go +++ b/cmd/codeaf/cluster.go @@ -7,6 +7,7 @@ import ( "fmt" "github.com/Agent-Field/codeaf/internal/clusterctl" cs "github.com/Agent-Field/codeaf/internal/clusterstate" + "github.com/Agent-Field/codeaf/internal/guard" "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/syncsetup" "io" @@ -48,7 +49,51 @@ func runCluster(args []string) error { defer cancel() return explainSyncUnreachable(clusterCommand(ctx, s, args[0], os.Stdout), s.Relay) } + +// clusterCommand answers `codeaf cluster status` and `codeaf cluster lead`. The +// whole answer is written into a buffer by work bounded by one ceiling, so a +// store open that ignores its context cannot hold the terminal past it, and an +// answer that arrives late never reaches the terminal at all. func clusterCommand(ctx context.Context, c clusterDoor, verb string, out io.Writer) error { + return boundedClusterOutput(ctx, out, func(ctx context.Context, out io.Writer) error { + if verb == "lead" { + r, err := c.LeadCluster(ctx, 0) + if err != nil { + return explainSyncUnreachable(explainRemovedDevice(err), "") + } + _, err = fmt.Fprintf(out, "computer %d is coordinating · term %d · revision %d · read barrier %d\n", r.Node, r.Term, r.Revision, r.Barrier) + return err + } + s, err := c.ClusterStatus(ctx) + if err != nil { + return explainSyncUnreachable(explainRemovedDevice(err), "") + } + if s.Leader != 0 { + fmt.Fprintf(out, "coordinator %d\n", s.Leader) + } + if len(s.Configuration.Voters) > 0 { + fmt.Fprintf(out, "voting computers %v\n", s.Configuration.Voters) + } + if s.Self != 0 { + fmt.Fprintf(out, "this computer %d\n", s.Self) + } + if len(s.Configuration.Learners) > 0 { + fmt.Fprintf(out, "learning computers %v\n", s.Configuration.Learners) + } + if len(s.Ready) > 0 { + fmt.Fprintf(out, "ready computers %v\n", s.Ready) + } + if len(s.Configuration.Outgoing) > 0 { + fmt.Fprintf(out, "finishing the change from %v\n", s.Configuration.Outgoing) + } + return nil + }) +} + +// boundedClusterOutput runs one cluster answer under the forwarding ceiling and +// copies its text to out only when it finished in time. A deadline is reported +// as the sync service not answering, never as an unreachable network. +func boundedClusterOutput(ctx context.Context, out io.Writer, work func(context.Context, io.Writer) error) error { ctx, cancel := context.WithTimeout(ctx, clusterctl.ForwardTimeout) defer cancel() type result struct { @@ -56,11 +101,11 @@ func clusterCommand(ctx context.Context, c clusterDoor, verb string, out io.Writ err error } done := make(chan result, 1) - go func() { + guard.Go("cluster/answer", func() { var output bytes.Buffer - err := clusterCommandWork(ctx, c, verb, &output) + err := work(ctx, &output) done <- result{output.String(), err} - }() + }) select { case got := <-done: if got.err != nil { @@ -72,37 +117,3 @@ func clusterCommand(ctx context.Context, c clusterDoor, verb string, out io.Writ return explainSyncUnreachable(ctx.Err(), "") } } - -func clusterCommandWork(ctx context.Context, c clusterDoor, verb string, out io.Writer) error { - if verb == "lead" { - r, err := c.LeadCluster(ctx, 0) - if err != nil { - return explainSyncUnreachable(explainRemovedDevice(err), "") - } - _, err = fmt.Fprintf(out, "computer %d is coordinating · term %d · revision %d · read barrier %d\n", r.Node, r.Term, r.Revision, r.Barrier) - return err - } - s, err := c.ClusterStatus(ctx) - if err != nil { - return explainSyncUnreachable(explainRemovedDevice(err), "") - } - if s.Leader != 0 { - fmt.Fprintf(out, "coordinator %d\n", s.Leader) - } - if len(s.Configuration.Voters) > 0 { - fmt.Fprintf(out, "voting computers %v\n", s.Configuration.Voters) - } - if s.Self != 0 { - fmt.Fprintf(out, "this computer %d\n", s.Self) - } - if len(s.Configuration.Learners) > 0 { - fmt.Fprintf(out, "learning computers %v\n", s.Configuration.Learners) - } - if len(s.Ready) > 0 { - fmt.Fprintf(out, "ready computers %v\n", s.Ready) - } - if len(s.Configuration.Outgoing) > 0 { - fmt.Fprintf(out, "finishing the change from %v\n", s.Configuration.Outgoing) - } - return nil -} diff --git a/cmd/codeaf/final_save.go b/cmd/codeaf/final_save.go index 91d3443f41..8d2c8206ea 100644 --- a/cmd/codeaf/final_save.go +++ b/cmd/codeaf/final_save.go @@ -28,6 +28,35 @@ var finalSaving struct { forced chan struct{} } +// armFinalSaving marks this process as one whose surface reports the final save +// on the restored terminal, and returns the channel a forced exit closes. +func armFinalSaving() chan struct{} { + finalSaving.Lock() + defer finalSaving.Unlock() + finalSaving.surface = true + finalSaving.forced = make(chan struct{}) + return finalSaving.forced +} + +// takeFinalCloses hands the waiter every closing reply still owed, once. +func takeFinalCloses() ([]<-chan struct{}, chan struct{}) { + finalSaving.Lock() + defer finalSaving.Unlock() + pending := finalSaving.pending + finalSaving.pending = nil + return pending, finalSaving.forced +} + +// takeFinalSaveFailure hands back the retained failure, once, and whether a +// surface was running, which decides the terminal stream the sentence goes to. +func takeFinalSaveFailure() (error, bool) { + finalSaving.Lock() + defer finalSaving.Unlock() + err, surface := finalSaving.failure, finalSaving.surface + finalSaving.failure, finalSaving.surface = nil, false + return err, surface +} + func retainFinalClose(done <-chan struct{}) { finalSaving.Lock() defer finalSaving.Unlock() @@ -58,10 +87,7 @@ func forceFinalClose() { func awaitFinalCloses(ctx context.Context) { ctx, cancel := context.WithTimeout(ctx, cellstore.SealWithin) defer cancel() - finalSaving.Lock() - pending, forced := finalSaving.pending, finalSaving.forced - finalSaving.pending = nil - finalSaving.Unlock() + pending, forced := takeFinalCloses() if len(pending) == 0 { return } @@ -116,10 +142,7 @@ func unsavedTranscript(err error) string { // finishFinalSaving reports one plain sentence after the screen is restored. func finishFinalSaving(ctx context.Context) bool { recordFinalSaveFailure(flushFinalSaves(ctx)) - finalSaving.Lock() - err, surface := finalSaving.failure, finalSaving.surface - finalSaving.failure, finalSaving.surface = nil, false - finalSaving.Unlock() + err, surface := takeFinalSaveFailure() if err == nil { return true } diff --git a/cmd/codeaf/syncdrive_live.go b/cmd/codeaf/syncdrive_live.go index bc31d9f922..1e859e0f10 100644 --- a/cmd/codeaf/syncdrive_live.go +++ b/cmd/codeaf/syncdrive_live.go @@ -85,15 +85,24 @@ func (l *liveDrive) install(d *syncsetup.Drive, err error, ready chan struct{}) return l.closed } +// openDrive is the started drive, or nil when none has started or this writer +// has closed. It never starts one. +func (l *liveDrive) openDrive() *syncsetup.Drive { + l.mu.Lock() + defer l.mu.Unlock() + if l.closed { + return nil + } + return l.drive +} + // RenewalStatus reads an already-started drive without starting one or waiting. func (l *liveDrive) RenewalStatus() (time.Time, time.Duration, bool) { if l == nil { return time.Time{}, 0, false } - l.mu.Lock() - d, closed := l.drive, l.closed - l.mu.Unlock() - if d == nil || closed { + d := l.openDrive() + if d == nil { return time.Time{}, 0, false } return d.RenewalStatus() From a93f3d02c89599a479e5e47052890e21009c141e Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 27 Oct 2026 00:54:45 -0400 Subject: [PATCH 16/28] fix(sync): only a live hold sends, and a killed computer can take its chat back - The send shortcut that skips the relay now needs the agent's live held flag, not just a hold file on disk. A window another computer took asks before sending again, and a hold left by a process that died can never grant a send. - A writer that became a viewer cannot record a new hold, and an explicitly displaced writer with no rows keeps its words unsent. - On start and at a take, the saved hold is reconciled with the relay's record and retired when its device, fence or expiry is stale, so a computer that was killed and taken over takes the chat back on the first try from Home or from its own copy's card. - The card names the relay's real holder and last saved turn, and every take that falls to the catch-all sentence logs its real cause. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/hosted_takeback_test.go | 515 ++++++++++++++++++ cmd/codeaf/send_source_local_test.go | 9 +- cmd/codeaf/synctake.go | 9 +- internal/chatlist/copy.go | 3 + internal/chatlist/offer_test.go | 2 +- internal/chatlist/takeover_holder_test.go | 14 + .../continuing-a-chat-on-another-computer.md | 24 +- internal/syncsetup/drive.go | 3 + .../syncsetup/send_hold_reconcile_test.go | 48 ++ internal/syncsetup/sendlease.go | 20 +- internal/syncsetup/take.go | 3 + internal/tui3/displaced_offline_send_test.go | 44 ++ internal/tui3/homepanel_continue_test.go | 7 +- internal/tui3/homepanel_heldelsewhere.go | 23 +- internal/tui3/take_failure_log_test.go | 28 + 15 files changed, 735 insertions(+), 17 deletions(-) create mode 100644 cmd/codeaf/hosted_takeback_test.go create mode 100644 internal/chatlist/takeover_holder_test.go create mode 100644 internal/syncsetup/send_hold_reconcile_test.go create mode 100644 internal/tui3/displaced_offline_send_test.go create mode 100644 internal/tui3/take_failure_log_test.go diff --git a/cmd/codeaf/hosted_takeback_test.go b/cmd/codeaf/hosted_takeback_test.go new file mode 100644 index 0000000000..d217f13421 --- /dev/null +++ b/cmd/codeaf/hosted_takeback_test.go @@ -0,0 +1,515 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "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/directory" + "github.com/Agent-Field/codeaf/internal/enginehost" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/handoff" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/reqsign" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/tui3" +) + +type hostedMoveRig struct { + t *testing.T + c cell.Cell + work string + a, b *syncsetup.Sync + ea, eb cellstore.Engine + bRoots string + modelCalls atomic.Int32 + mu sync.Mutex + requests []string + acquires []time.Time + changes chan struct{} +} + +type moveRenewals struct { + drive *syncsetup.Drive + changes <-chan struct{} +} + +func (m moveRenewals) RenewalStatus() (time.Time, time.Duration, bool) { + return m.drive.RenewalStatus() +} +func (m moveRenewals) RenewalChanges() <-chan struct{} { return m.changes } + +func newHostedMoveRig(t *testing.T) *hostedMoveRig { + t.Helper() + cfg, c := doorChat(t) + short, err := os.MkdirTemp("", "cf-move-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(short) }) + t.Setenv(home.EnvVar, short) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "0") + id, err := identity.Ensure(short) + if err != nil { + t.Fatal(err) + } + h := &hostedMoveRig{t: t, c: c, work: cfg.Place.Workspace, bRoots: t.TempDir(), changes: make(chan struct{}, 1)} + service := relayserveForMove(t) + relay := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.mu.Lock() + h.requests = append(h.requests, r.Method+" "+r.URL.Path) + if strings.HasSuffix(r.URL.Path, "/acquire") { + h.acquires = append(h.acquires, time.Now()) + } + h.mu.Unlock() + service.ServeHTTP(w, r) + })) + t.Cleanup(relay.Close) + t.Setenv(syncsetup.URLVar, relay.URL) + t.Setenv(syncsetup.IntervalVar, "50") + h.a, _, err = syncsetup.Open(short) + if err != nil { + t.Fatal(err) + } + bHome := t.TempDir() + if _, err := identity.Adopt(bHome, id, false); err != nil { + t.Fatal(err) + } + h.b, _, err = syncsetup.Open(bHome) + if err != nil { + t.Fatal(err) + } + h.ea, h.eb = cellstore.EngineFor(h.work), cellstore.EngineFor("") + h.ea.DataRoot, h.eb.DataRoot = t.TempDir(), t.TempDir() + t.Cleanup(cellstore.StopSpawned) + if err := session.SaveMeta(c.Root, session.Meta{ID: c.ID, Workspace: h.work, Title: "take-back chat"}); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(cfg.Place.Transcript(), []byte(fmt.Sprintf("{\"type\":\"session\",\"version\":1,\"id\":%q}\n{\"type\":\"message\",\"role\":\"user\",\"content\":\"saved question\"}\n{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"1\"}\n", c.ID)), 0600); err != nil { + t.Fatal(err) + } + return h +} + +func relayserveForMove(t *testing.T) http.Handler { + t.Helper() + dir := directory.NewMemory(time.Now) + t.Cleanup(func() { dir.Close() }) + store := blobstore.NewMemory() + auth := reqsign.AuthenticateAt(time.Now) + mux := http.NewServeMux() + mux.Handle("/v1/dir/", directory.Handler(auth, func(string) (directory.Directory, error) { return dir, nil })) + mux.Handle("/", blobstore.Handler(auth, func(string) (blobstore.Store, error) { return store, nil })) + return mux +} + +func (h *hostedMoveRig) wait(what string, ready func() bool) { + h.t.Helper() + deadline := time.Now().Add(20 * time.Second) + for !ready() { + if time.Now().After(deadline) { + h.t.Fatal("waiting for " + what) + } + time.Sleep(time.Millisecond) + } +} + +func (h *hostedMoveRig) publish(s *syncsetup.Sync, e cellstore.Engine, c cell.Cell, name string) *syncsetup.Drive { + h.t.Helper() + d, err := s.Drive(h.t.Context(), e, c, syncsetup.DriveOptions{DeviceName: name, OnRenewalChange: func() { + select { + case h.changes <- struct{}{}: + default: + } + }}) + if err != nil { + h.t.Fatal(err) + } + h.t.Cleanup(func() { _ = d.Close(context.Background()) }) + sealed, err := d.Store(e).Seal(h.t.Context(), c, cellstore.TurnInfo{}) + if err != nil { + h.t.Fatal(err) + } + d.Idle() + h.wait("publication", func() bool { + v, err := s.Dir.Cell(h.t.Context(), c.ID) + return err == nil && v.Cell.Head == sealed.Turn.ID + }) + return d +} + +func (h *hostedMoveRig) taker(s *syncsetup.Sync, e cellstore.Engine, root func(string) string, phases func(handoff.Phase)) takeover { + return takeover{s.Continuer(e, syncsetup.TakeOptions{DeviceName: "receiver", RootFor: root, OnPhase: phases})} +} + +func (h *hostedMoveRig) host(d *syncsetup.Drive, c cell.Cell, work string, e cellstore.Engine) *remote.Client { + h.t.Helper() + model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + h.modelCalls.Add(1) + } + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"answer\"},\"finish_reason\":null}]}\n\ndata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n") + })) + h.t.Cleanup(model.Close) + first := true + done := make(chan error, 1) + go func() { + done <- enginehost.Run(work, enginehost.Options{Boot: func(remote.Hello) (*remote.Engine, error) { + driver := d + if !first { + var err error + driver, err = h.a.Drive(h.t.Context(), e, c, syncsetup.DriveOptions{DeviceName: "receiver"}) + if err != nil { + return nil, err + } + } + first = false + seat, err := cellstore.SeatOver(executor.HostBound, c, work, nil, nil, func(cellstore.Engine) cellstore.Store { return driver.Store(e) }) + if err != nil { + return nil, err + } + agent, err := session.New(session.Config{Workspace: work, Place: session.Place{Dir: c.Root, Workspace: work}, SessionFile: session.Place{Dir: c.Root}.Transcript(), Model: "stub/chat", APIKey: "stub", BaseURL: model.URL, Seat: seat, Renewals: moveRenewals{driver, h.changes}}) + if err != nil { + return nil, err + } + return &remote.Engine{Agent: agent, Workspace: work, SessionFile: agent.SessionPath(), Place: session.Place{Dir: c.Root, Workspace: work}, ReleaseOnLeave: func(remote.WrappedAgent) bool { return true }, FinishSaving: func(remote.WrappedAgent) error { return driver.Close(context.Background()) }}, nil + }}) + }() + var err error + var conn net.Conn + h.wait("host socket", func() bool { conn, err = enginehost.Dial(work); return err == nil }) + client, err := remote.Dial(conn, "", remote.Hello{}) + if err != nil { + h.t.Fatal(err) + } + h.t.Cleanup(func() { client.Close(); _ = enginehost.Retire(work, true); <-done }) + return client +} + +func (h *hostedMoveRig) surface(client *remote.Client, taker takeover, landing bool) (*surfaceFrameWriter, *io.PipeWriter) { + h.t.Helper() + input, typed := io.Pipe() + screen := &surfaceFrameWriter{ready: make(chan struct{})} + ctx, cancel := context.WithCancel(h.t.Context()) + done := make(chan error, 1) + go func() { + done <- tui3.Run(ctx, tui3.Options{Agent: client.Agent(), Workspace: h.work, SessionFile: session.Place{Dir: h.c.Root}.Transcript(), Landing: landing, Machines: surfaceMachines{h.a}, Takeover: taker, Input: input, Output: screen, Width: 120, Height: 35, Open: func(where, file string) (tui3.Conversation, error) { + conn, err := enginehost.Dial(h.work) + if err != nil { + return tui3.Conversation{}, err + } + opened, err := remote.Dial(conn, "", remote.Hello{Session: file}) + if err != nil { + conn.Close() + return tui3.Conversation{}, err + } + h.t.Cleanup(func() { opened.Close() }) + return tui3.Conversation{Agent: opened.Agent(), Workspace: where, SessionFile: file}, nil + }}) + }() + h.t.Cleanup(func() { cancel(); input.Close(); typed.Close(); <-done }) + h.t.Cleanup(func() { + if h.t.Failed() { + h.t.Logf("surface: %s", screen.String()) + } + }) + <-screen.ready + return screen, typed +} + +// Contract F1 requires the hosted viewer's first Enter to ask before any turn runs. +func TestHostedDisplacedWindowAsksBeforeSending(t *testing.T) { + h := newHostedMoveRig(t) + d := h.publish(h.a, h.ea, h.c, "sender") + client := h.host(d, h.c, h.work, h.ea) + rootB := func(id string) string { return filepath.Join(h.bRoots, id) } + if _, err := h.taker(h.b, h.eb, rootB, nil).Take(t.Context(), h.c.ID); err != nil { + t.Fatal(err) + } + if err := d.CheckLease(t.Context()); err != nil { + t.Fatal(err) + } + // A retained receipt must never override the live viewer state. + staleHoldForMove(t, h.a, h.c.ID, 1) + screen, typed := h.surface(client, h.taker(h.a, h.ea, func(string) string { return h.c.Root }, nil), false) + h.mu.Lock() + h.requests = nil + h.mu.Unlock() + if _, err := io.WriteString(typed, "Add 1 to your last answer. Just the number.\r"); err != nil { + t.Fatal(err) + } + h.wait("continue card", func() bool { return strings.Contains(screen.String(), "Continue this chat here?") }) + if h.modelCalls.Load() != 0 { + t.Fatalf("displaced Enter made %d model calls", h.modelCalls.Load()) + } + h.mu.Lock() + defer h.mu.Unlock() + for _, r := range h.requests { + if strings.HasSuffix(r, "/publish") || strings.HasPrefix(r, "POST /v1/dir/cells/") && !strings.HasSuffix(r, "/heartbeat") { + t.Errorf("displaced Enter mutated relay: %s", r) + } + } +} + +func staleHoldForMove(t *testing.T, s *syncsetup.Sync, id string, fence uint64) { + t.Helper() + sum := sha256.Sum256([]byte(s.Ledger + "/" + s.Device.ID() + "/" + id)) + path := filepath.Join(s.Home, "v3", "sync", "holds", hex.EncodeToString(sum[:])) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(fmt.Sprintf(`{"cell":%q,"fence":%d}`, id, fence)), 0600); err != nil { + t.Fatal(err) + } +} + +// Contract F3 bounds the real host's retirement and forbids uploads of shared work. +func TestHostedTakeBackReachesAcquireWithoutSharedUploadsOrReleaseDelay(t *testing.T) { + h := newHostedMoveRig(t) + d := h.publish(h.a, h.ea, h.c, "sender") + client := h.host(d, h.c, h.work, h.ea) + if err := h.b.Rename(t.Context(), "other computer"); err != nil { + t.Fatal(err) + } + rootB := func(id string) string { return filepath.Join(h.bRoots, id) } + if _, err := h.taker(h.b, h.eb, rootB, nil).Take(t.Context(), h.c.ID); err != nil { + t.Fatal(err) + } + if err := d.CheckLease(t.Context()); err != nil { + t.Fatal(err) + } + staleHoldForMove(t, h.a, h.c.ID, 1) + var started time.Time + phases := func(p handoff.Phase) { + if p.Ended.IsZero() { + t.Logf("phase %s at %s", p.Name, p.Started.Sub(started)) + } else { + t.Logf("phase %s: %s", p.Name, p.Ended.Sub(p.Started)) + } + } + screen, typed := h.surface(client, h.taker(h.a, h.ea, func(string) string { return h.c.Root }, phases), false) + if _, err := io.WriteString(typed, "take back this chat\r"); err != nil { + t.Fatal(err) + } + h.wait("take-back card", func() bool { return strings.Contains(screen.String(), "Continue this chat here?") }) + h.mu.Lock() + h.requests = nil + h.acquires = nil + h.mu.Unlock() + started = time.Now() + if _, err := io.WriteString(typed, "1\r"); err != nil { + t.Fatal(err) + } + h.wait("hosted take-back arrival", func() bool { return strings.Contains(screen.String(), "Moved from other computer") }) + h.mu.Lock() + defer h.mu.Unlock() + if len(h.acquires) < 2 { + t.Fatalf("take and reopened hosted writer made %d acquires; want both", len(h.acquires)) + } + t.Logf("relay first acquire after key: %s", h.acquires[0].Sub(started)) + if elapsed := h.acquires[0].Sub(started); elapsed > time.Second { + t.Errorf("first acquire took %s; want within 1s", elapsed) + } + for i := 1; i < len(h.acquires); i++ { + t.Logf("relay acquire %d after key: %s; gap: %s", i+1, h.acquires[i].Sub(started), h.acquires[i].Sub(h.acquires[i-1])) + if gap := h.acquires[i].Sub(h.acquires[i-1]); gap >= 5*time.Second { + t.Errorf("acquire gap=%s", gap) + } + } + for _, r := range h.requests { + if strings.HasSuffix(r, "/acquire") { + break + } + if strings.HasPrefix(r, "POST /v1/store/frames") { + t.Errorf("shared work uploaded before acquire: %s", r) + } + } +} + +type crashedWriterConfig struct { + Home, Root, Work, Data string +} + +// TestCrashedWriterProcess is the disposable writer used by the crash contract. +func TestCrashedWriterProcess(t *testing.T) { + raw := os.Getenv("CODEAF_CRASHED_WRITER") + if raw == "" { + return + } + celltest.RequireEngine(t) + var cfg crashedWriterConfig + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatal(err) + } + t.Setenv(home.EnvVar, cfg.Home) + s, ok, err := syncsetup.Open(cfg.Home) + if err != nil || !ok { + t.Fatalf("open crashed writer: %v", err) + } + c, err := cell.OpenAt(cfg.Root, filepath.Base(cfg.Root)) + if err != nil { + t.Fatal(err) + } + e := cellstore.EngineFor(cfg.Work) + e.DataRoot = cfg.Data + d, err := s.Drive(t.Context(), e, c, syncsetup.DriveOptions{DeviceName: "killed computer"}) + if err != nil { + t.Fatal(err) + } + agent, err := session.New(session.Config{Workspace: cfg.Work, Place: session.Place{Dir: c.Root, Workspace: cfg.Work}, SessionFile: session.Place{Dir: c.Root}.Transcript(), Model: "stub/chat", APIKey: "stub", BaseURL: "http://127.0.0.1:1"}) + if err != nil { + t.Fatal(err) + } + _ = agent + sealed, err := d.Store(e).Seal(t.Context(), c, cellstore.TurnInfo{}) + if err != nil { + t.Fatal(err) + } + d.Idle() + for { + v, err := s.Dir.Cell(t.Context(), c.ID) + _, fence, heldErr := s.SendLease(c.ID) + shared, sharedErr := cellstore.Shared(c) + if err == nil && v.Cell.Head == sealed.Turn.ID && heldErr == nil && fence != 0 && sharedErr == nil && shared { + break + } + time.Sleep(time.Millisecond) + } + fmt.Fprintln(os.Stdout, "writer holds the chat") + <-t.Context().Done() +} + +func (h *hostedMoveRig) killedWriter() { + h.t.Helper() + raw, err := json.Marshal(crashedWriterConfig{h.a.Home, h.c.Root, h.work, h.ea.DataRoot}) + if err != nil { + h.t.Fatal(err) + } + child := exec.CommandContext(h.t.Context(), os.Args[0], "-test.run=^TestCrashedWriterProcess$", "-test.timeout=2m") + child.Env = append(os.Environ(), "CODEAF_CRASHED_WRITER="+string(raw)) + output, err := child.StdoutPipe() + if err != nil { + h.t.Fatal(err) + } + var stderr bytes.Buffer + child.Stderr = &stderr + if err := child.Start(); err != nil { + h.t.Fatal(err) + } + joined := false + defer func() { + if !joined { + child.Process.Kill() + child.Wait() + } + }() + ready := make(chan string, 1) + go func() { + scan := bufio.NewScanner(output) + var lines []string + for scan.Scan() { + lines = append(lines, scan.Text()) + if scan.Text() == "writer holds the chat" { + ready <- "" + return + } + } + ready <- strings.Join(lines, "\n") + }() + select { + case failure := <-ready: + if failure != "" { + h.t.Fatalf("writer failed: %s\n%s", failure, stderr.String()) + } + case <-time.After(30 * time.Second): + h.t.Fatal("writer did not publish before its kill") + } + if err := child.Process.Kill(); err != nil { + h.t.Fatal(err) + } + _ = child.Wait() + joined = true + if _, fence, err := h.a.SendLease(h.c.ID); err != nil || fence == 0 { + h.t.Fatalf("kill did not leave its receipt: %v", err) + } +} + +// Contract F2 takes a killed writer's chat back on the first Home or card attempt. +func TestKilledComputerTakesBackFromHomeAndLocalCard(t *testing.T) { + for _, door := range []string{"home", "card"} { + t.Run(door, func(t *testing.T) { + h := newHostedMoveRig(t) + h.killedWriter() + if err := h.b.Rename(t.Context(), "other computer"); err != nil { + t.Fatal(err) + } + rootB := func(id string) string { return filepath.Join(h.bRoots, id) } + if _, err := h.taker(h.b, h.eb, rootB, nil).Take(t.Context(), h.c.ID); err != nil { + t.Fatal(err) + } + restarted, _, err := syncsetup.Open(h.a.Home) + if err != nil { + t.Fatal(err) + } + h.a = restarted + d, err := h.a.Drive(t.Context(), h.ea, h.c, syncsetup.DriveOptions{DeviceName: "killed computer"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { d.Close(context.Background()) }) + if _, _, err := h.a.SendLease(h.c.ID); !os.IsNotExist(err) { + t.Errorf("startup retained a stale receipt: %v", err) + } + client := h.host(d, h.c, h.work, h.ea) + screen, typed := h.surface(client, h.taker(h.a, h.ea, func(string) string { return h.c.Root }, nil), door == "home") + if door == "home" { + if _, err := io.WriteString(typed, " "); err != nil { + t.Fatal(err) + } + h.wait("Home continuation offer", func() bool { return strings.Contains(screen.String(), "alt+c") }) + if _, err := io.WriteString(typed, "\x1bc"); err != nil { + t.Fatal(err) + } + } else { + if _, err := io.WriteString(typed, "take back this chat\r"); err != nil { + t.Fatal(err) + } + h.wait("card naming the current holder", func() bool { + return strings.Contains(screen.String(), "Continue this chat here?") && strings.Contains(screen.String(), "other computer") + }) + if _, err := io.WriteString(typed, "1\r"); err != nil { + t.Fatal(err) + } + } + h.wait("Moved from after the first take", func() bool { return strings.Contains(screen.String(), "Moved from other computer") }) + v, err := h.a.Dir.Cell(t.Context(), h.c.ID) + if err != nil || v.Cell.Lease.Device != h.a.Device.ID() { + t.Fatalf("take-back holder=%+v: %v", v.Cell.Lease, err) + } + }) + } +} diff --git a/cmd/codeaf/send_source_local_test.go b/cmd/codeaf/send_source_local_test.go index e711fb6cd0..3d678c4ad4 100644 --- a/cmd/codeaf/send_source_local_test.go +++ b/cmd/codeaf/send_source_local_test.go @@ -34,6 +34,11 @@ func (sendSurfaceSource) RowsAt(context.Context) ([]chatlist.Row, uint64, error) type recordingSendAgent struct { quietAgent sent chan string + held bool +} + +func (a *recordingSendAgent) RenewalStatus() (time.Time, time.Duration, bool) { + return time.Time{}, time.Second, a.held } func (a *recordingSendAgent) Submit(ctx context.Context, text string) (<-chan session.Event, error) { @@ -92,7 +97,7 @@ func TestConcreteSendSourceKeepsHeldAndUnpublishedChatsOffTheNetwork(t *testing. read := func(offline bool) ([]chatlist.Row, error) { ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond) defer cancel() - rows, _, _, err := source.RowsForChatSend(ctx, filepath.Join(c.Root, cell.TranscriptPath), offline) + rows, _, _, err := source.RowsForChatSend(ctx, filepath.Join(c.Root, cell.TranscriptPath), offline, kind == "held") return rows, err } rows, err := read(false) @@ -115,7 +120,7 @@ func TestConcreteSendSourceKeepsHeldAndUnpublishedChatsOffTheNetwork(t *testing. input, typed := io.Pipe() defer input.Close() defer typed.Close() - agent := &recordingSendAgent{sent: make(chan string, 2)} + agent := &recordingSendAgent{sent: make(chan string, 2), held: kind == "held"} screen := &firstFrameSink{painted: make(chan struct{}, 1)} ctx, cancel := context.WithCancel(t.Context()) done := make(chan error, 1) diff --git a/cmd/codeaf/synctake.go b/cmd/codeaf/synctake.go index 395a6f461d..444c1bcb65 100644 --- a/cmd/codeaf/synctake.go +++ b/cmd/codeaf/synctake.go @@ -68,9 +68,9 @@ func wireSync(o *tui3.Options) { // surfaceMachines keeps display hints out of the decision to send a message. type surfaceMachines struct{ *syncsetup.Sync } -// RowsForChatSend uses a local hold or a never-shared chat before asking the relay. +// RowsForChatSend uses a live writer's receipt or a never-shared chat before asking the relay. // A known outage keeps unknown copies unsent without repeating the network wait. -func (s surfaceMachines) RowsForChatSend(ctx context.Context, file string, offline bool) ([]chatlist.Row, uint64, bool, error) { +func (s surfaceMachines) RowsForChatSend(ctx context.Context, file string, offline, holding bool) ([]chatlist.Row, uint64, bool, error) { if s.Removed() { return nil, 0, false, directory.ErrRevoked } @@ -79,10 +79,11 @@ func (s surfaceMachines) RowsForChatSend(ctx context.Context, file string, offli } root := session.DirOf(file) id := filepath.Base(root) - if remote, fence, err := s.SendLease(id); err == nil && fence != 0 { + remote, fence, leaseErr := s.SendLease(id) + if holding && leaseErr == nil && fence != 0 { return []chatlist.Row{{Cell: remote, Status: chatlist.Here, Mine: true, Fence: fence}}, 0, true, nil } - if c, err := cell.OpenAt(root, id); err == nil { + if c, err := cell.OpenAt(root, id); err == nil && (leaseErr != nil || fence == 0) { shared, err := cellstore.Shared(c) if err == nil && !shared { return nil, 0, true, nil diff --git a/internal/chatlist/copy.go b/internal/chatlist/copy.go index 85dbb45c4d..aa6d240f9e 100644 --- a/internal/chatlist/copy.go +++ b/internal/chatlist/copy.go @@ -179,6 +179,9 @@ func TakeoverLine(r Row) string { if r.Pending > 0 { line += fmt.Sprintf(takeoverMore, turns(r.Pending), r.Device) } + if r.Status == Off && r.Device != "" { + return fmt.Sprintf("%s offline; %s", r.Device, line) + } return holderLead(r) + line } diff --git a/internal/chatlist/offer_test.go b/internal/chatlist/offer_test.go index c561f5ff0f..f94868c2e9 100644 --- a/internal/chatlist/offer_test.go +++ b/internal/chatlist/offer_test.go @@ -125,7 +125,7 @@ func TestMovedSaysTheMeasuredTime(t *testing.T) { // A chat whose holder is offline stopped; its sentence does not say it runs. func TestTakeoverLineOfAQuietRowDoesNotSayRunning(t *testing.T) { r := Row{Status: Running, Device: "build-box", DurableAgo: 15 * time.Second}.Quiet() - if got, want := TakeoverLine(r), "last saved turn 15s ago"; got != want { + if got, want := TakeoverLine(r), "build-box offline; last saved turn 15s ago"; got != want { t.Fatalf("got %q, want %q", got, want) } } diff --git a/internal/chatlist/takeover_holder_test.go b/internal/chatlist/takeover_holder_test.go new file mode 100644 index 0000000000..ab9439c0db --- /dev/null +++ b/internal/chatlist/takeover_holder_test.go @@ -0,0 +1,14 @@ +package chatlist + +import ( + "testing" + "time" +) + +// Contract F2 keeps the relay's holder visible even when that computer is offline. +func TestTakeoverCardNamesTheOfflineHolderAndSavedTurn(t *testing.T) { + r := Row{Device: "other computer", Status: Off, DurableAgo: 37 * time.Second} + if got, want := TakeoverLine(r), "other computer offline; last saved turn 37s ago"; got != want { + t.Fatalf("card=%q; want %q", got, want) + } +} 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 20d910cf1b..d78c20ef2a 100644 --- a/internal/manual/chat/continuing-a-chat-on-another-computer.md +++ b/internal/manual/chat/continuing-a-chat-on-another-computer.md @@ -29,8 +29,9 @@ Moving chats and pairing are available by default on Linux, macOS and native Win Before showing this question for a send, codeaf checks which computer holds the chat now. An older listing alone cannot raise it. If the check cannot reach your other devices and this window already knows the chat moved away, your words stay in the box and nothing is sent. Continuing a chat this computer already holds keeps its open conversation and your typed words. -The first Enter waits up to five seconds for a current reading, including in -a freshly opened copy. `other machines unreachable` means that reading +The first Enter waits up to five seconds for a current reading when this window +does not hold the shared chat, including in a freshly opened copy. +`other machines unreachable` means that reading failed; it never means a reading has not happened yet. Your words stay unsent while the reading is in progress. @@ -178,6 +179,20 @@ A chat has one holder at a time. To take a chat back from the computer that took 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`, its local copy is not replaced, and its row goes back to `running on `. 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. +## 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 +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 +` offline; last saved turn ago`. + +Turns and file edits that never reached the service are kept separately before +the newer chat arrives. Shared turns and unchanged closing bookkeeping do not +need another upload before the move. + ## I reopened an old local chat after the other computer let go — where are the newer turns 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. @@ -328,6 +343,11 @@ Yes. Directory requests from older builds keep the existing 1 MiB request limit, 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. +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 +from an earlier process cannot bypass the question. A window whose writer +still holds the chat sends with one Enter without asking the service first. + A new chat can send its first message normally. A sleeping computer can also resume sending when nobody else has taken the chat and its local saved turn is still current. That safe return happens without another question. If the ownership check cannot reach your other computers, a chat this window already holds keeps working and its turns wait to be shared; any other message stays unsent. ## A move that failed partway — are my typed words safe, can the old copy still send diff --git a/internal/syncsetup/drive.go b/internal/syncsetup/drive.go index 9a73ccf6f3..63fd87cec7 100644 --- a/internal/syncsetup/drive.go +++ b/internal/syncsetup/drive.go @@ -245,6 +245,9 @@ func (s *Sync) driving(ctx context.Context, c cell.Cell) (cellsync.Driving, stri if err != nil { return drv, "", err } + if err := s.reconcileSendLease(c.ID, drv.ID(), view); err != nil { + return drv, "", err + } known, err := cellstore.KnowsHead(c, view.Cell.Head) if err != nil { return drv, "", err diff --git a/internal/syncsetup/send_hold_reconcile_test.go b/internal/syncsetup/send_hold_reconcile_test.go new file mode 100644 index 0000000000..faab3ba632 --- /dev/null +++ b/internal/syncsetup/send_hold_reconcile_test.go @@ -0,0 +1,48 @@ +package syncsetup + +import ( + "os" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellsync" + "github.com/Agent-Field/codeaf/internal/directory" +) + +// Contract F1 forbids a late publication callback from reviving a viewer's receipt. +func TestDisplacedWriterCannotRecordAnotherSendHold(t *testing.T) { + s := &Sync{Home: t.TempDir(), Ledger: "fixture"} + d := &Drive{sync: s, cell: cell.Cell{ID: "chat"}, directoryID: "chat", line: "continued elsewhere", batcher: &cellsync.Batcher{Driving: &cellsync.Driving{Fence: 7}}} + mustNil(t, d.recordSendLease()) + if _, _, err := s.SendLease("chat"); !os.IsNotExist(err) { + t.Fatalf("viewer recreated a send hold: %v", err) + } +} + +// Contract F2 retires stale receipts while retaining the relay's matching live hold. +func TestRelayReadingReconcilesOnlyTheStaleSendHold(t *testing.T) { + for _, kind := range []string{"older fence", "other holder", "expired", "current"} { + t.Run(kind, func(t *testing.T) { + s := &Sync{Home: t.TempDir(), Ledger: "fixture"} + mustNil(t, s.recordSendLease("chat", sendLease{Cell: "chat", Fence: 3})) + view := directory.CellView{Now: 100, Cell: directory.Cell{Lease: directory.Lease{Device: s.Device.ID(), Fence: 3, Expires: 200}}} + switch kind { + case "older fence": + view.Cell.Lease.Fence = 4 + case "other holder": + view.Cell.Lease.Device = "other" + case "expired": + view.Cell.Lease.Expires = 100 + } + mustNil(t, s.reconcileSendLease("chat", "chat", view)) + _, fence, err := s.SendLease("chat") + if kind == "current" { + if err != nil || fence != 3 { + t.Fatalf("current receipt lost: %d %v", fence, err) + } + } else if !os.IsNotExist(err) { + t.Fatalf("stale receipt survived: %d %v", fence, err) + } + }) + } +} diff --git a/internal/syncsetup/sendlease.go b/internal/syncsetup/sendlease.go index f449ce6879..63a89b19d1 100644 --- a/internal/syncsetup/sendlease.go +++ b/internal/syncsetup/sendlease.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" + "github.com/Agent-Field/codeaf/internal/directory" "github.com/Agent-Field/codeaf/internal/filelock" "github.com/Agent-Field/codeaf/internal/fsreplace" ) @@ -46,7 +47,7 @@ func (d *Drive) recordSendLease() error { fence := d.batcher.Fence() d.mu.Lock() defer d.mu.Unlock() - if fence == 0 || fence == d.recordedFence { + if d.line != "" || fence == 0 || fence == d.recordedFence { return nil } if err := d.sync.recordSendLease(d.cell.ID, sendLease{Cell: d.directoryID, Fence: fence}); err != nil { @@ -56,6 +57,23 @@ func (d *Drive) recordSendLease() error { return nil } +// reconcileSendLease removes a dead writer's receipt only after a fresh relay reading. +// Conditional removal preserves a newer writer that recorded its hold meanwhile. +func (s *Sync) reconcileSendLease(id, remoteID string, view directory.CellView) error { + remote, fence, err := s.SendLease(id) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + lease := view.Cell.Lease + if remote == remoteID && (lease.Device != s.Device.ID() || lease.Fence != fence || lease.Expires <= view.Now) { + return s.forgetSendLease(id, sendLease{Cell: remote, Fence: fence}) + } + return nil +} + func (s *Sync) recordSendLease(id string, held sendLease) error { lock, err := s.lockSendLease(id) if err != nil { diff --git a/internal/syncsetup/take.go b/internal/syncsetup/take.go index 43222ac666..b98efdb991 100644 --- a/internal/syncsetup/take.go +++ b/internal/syncsetup/take.go @@ -160,6 +160,9 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx if err != nil { return Continued{}, err } + if err := c.sync.reconcileSendLease(id, id, view); err != nil { + return Continued{}, err + } if branchOnly && (view.Cell.ParentCell == "" || view.Cell.Archived) { return Continued{}, errors.New("these turns are no longer available to open separately") } diff --git a/internal/tui3/displaced_offline_send_test.go b/internal/tui3/displaced_offline_send_test.go new file mode 100644 index 0000000000..b8b9be73e8 --- /dev/null +++ b/internal/tui3/displaced_offline_send_test.go @@ -0,0 +1,44 @@ +package tui3 + +import ( + "context" + "os" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" +) + +type unheldOfflineSource struct{ *continuationListing } + +func (unheldOfflineSource) RowsForChatSend(context.Context, string, bool, bool) ([]chatlist.Row, uint64, bool, error) { + return nil, 0, false, directory.ErrUnreachable +} +func (unheldOfflineSource) SendLease(string) (string, uint64, error) { return "", 0, os.ErrNotExist } + +type displacedSendAgent struct{ *fakeAgent } + +func (displacedSendAgent) RenewalStatus() (time.Time, time.Duration, bool) { + return time.Time{}, time.Second, false +} + +// Contract F1 keeps a displaced copy unsent even after its receipt and listing are gone. +func TestDisplacedOfflineWindowCannotBorrowAnEmptyListing(t *testing.T) { + src := unheldOfflineSource{&continuationListing{err: directory.ErrUnreachable}} + a, agent := heldLab(t, src, &fakeTaker{}) + keepSurfaceWorkersInTest(t, a) + a.closeHome() + a.machineRead.rows = nil + a.machineRead.sendHolds = nil + a.agent = displacedSendAgent{agent} + a.input.setText("keep these words here") + drive(t, a, key("enter")) + if len(agent.sent) != 0 || a.input.String() != "keep these words here" { + t.Fatalf("offline copy sent: %v, draft=%q", agent.sent, a.input.String()) + } + if !strings.Contains(strings.Join(noteTexts(a), "\n"), chatlist.Unreachable) { + t.Fatal("the unsent draft has no explanation") + } +} diff --git a/internal/tui3/homepanel_continue_test.go b/internal/tui3/homepanel_continue_test.go index ee3deb43a6..3a07825319 100644 --- a/internal/tui3/homepanel_continue_test.go +++ b/internal/tui3/homepanel_continue_test.go @@ -398,7 +398,8 @@ func TestTakeoverScreenGolden(t *testing.T) { golden := map[int][]string{ 80: { "╭─ ? Continue this chat here? ────────────────────────────────────────────────╮", - "│ last saved turn 3h ago; up to 3 turns may still be on host-a │", + "│ host-a offline; last saved turn 3h ago; up to 3 turns may still be on host- │", + "│ a │", "│ │", "│ ▸ 1 continue here │", "│ 2 leave it there safe answer │", @@ -407,8 +408,8 @@ func TestTakeoverScreenGolden(t *testing.T) { }, 40: { "╭─ ? Continue this chat here? ────────╮", - "│ last saved turn 3h ago; up to 3 │", - "│ turns may still be on host-a │", + "│ host-a offline; last saved turn 3h │", + "│ ago; up to 3 turns may still be on │", "│ │", "│ ▸ 1 continue here │", "│ 2 leave it there safe answer │", diff --git a/internal/tui3/homepanel_heldelsewhere.go b/internal/tui3/homepanel_heldelsewhere.go index f3b0bed651..ea426122c3 100644 --- a/internal/tui3/homepanel_heldelsewhere.go +++ b/internal/tui3/homepanel_heldelsewhere.go @@ -3,6 +3,7 @@ package tui3 import ( "context" "errors" + "log" "path/filepath" "slices" "strings" @@ -53,6 +54,8 @@ type ownershipReadMsg struct { fence uint64 proof bool local bool + // notHeld prevents a saved receipt from reviving a displaced writer offline. + notHeld bool } type sendHold struct { @@ -82,7 +85,7 @@ func (a *app) needsOwnershipRead() bool { } func (a *app) checkBeforeSendWith(resume func() tea.Cmd) tea.Cmd { - src, file, draft := a.machines, a.file, a.input.String() + src, file, draft, agent := a.machines, a.file, a.input.String(), a.agent offline := a.machineRead.down chips := slices.Clone(a.chips) var first *composerState @@ -98,22 +101,30 @@ func (a *app) checkBeforeSendWith(resume func() tea.Cmd) tea.Cmd { var version uint64 var local bool var err error + holding, notHeld := false, false + if writer, ok := agent.(renewalReader); ok { + _, _, holding = writer.RenewalStatus() + notHeld = !holding + } if scoped, ok := src.(interface { - RowsForChatSend(context.Context, string, bool) ([]chatlist.Row, uint64, bool, error) + RowsForChatSend(context.Context, string, bool, bool) ([]chatlist.Row, uint64, bool, error) }); ok { - rows, version, local, err = scoped.RowsForChatSend(ctx, file, offline) + rows, version, local, err = scoped.RowsForChatSend(ctx, file, offline, holding) } else if offline { err = directory.ErrUnreachable } else { rows, version, err = chatlist.RowsForSend(ctx, src) } - msg := ownershipReadMsg{file: file, draft: draft, chips: chips, resume: resume, first: first, local: local, reading: homeMachinesMsg{rows: rows, version: version, err: err}} + msg := ownershipReadMsg{file: file, draft: draft, chips: chips, resume: resume, first: first, local: local, notHeld: notHeld, reading: homeMachinesMsg{rows: rows, version: version, err: err}} id := filepath.Base(session.DirOf(file)) if local, ok := src.(sendLeaseSource); ok { msg.proof = true msg.leaseCell, msg.fence, _ = local.SendLease(id) msg.reading.holdCell, msg.reading.holdFence = id, msg.fence } + if msg.notHeld { + msg.leaseCell = "" + } remote := id if msg.leaseCell != "" { remote = msg.leaseCell @@ -257,6 +268,9 @@ func (a *app) resumeOwnedSend(msg ownershipReadMsg) tea.Cmd { // could not be made: the chat is this window's own at the fence it recorded, or // it was never shared and nobody is known to hold it. func (a *app) offlineSendHeld(id, remote string, msg ownershipReadMsg) bool { + if msg.notHeld && msg.proof && !msg.local { + return false + } held := a.machineRead.sendHolds[id] if held.blocked { return false @@ -325,6 +339,7 @@ func (a *app) failedTake(msg homeTakenMsg) string { case strings.Contains(msg.err.Error(), "rewind cancelled"), strings.Contains(msg.err.Error(), "changed during rewind"): return continueFails + " because the folder changed during the move; your words are still in the box" default: + log.Printf("continue chat %s: %v", msg.row.Cell, msg.err) return continueFails + " because the chat could not be put in this folder; your words are still in the box. Try continue here again." } } diff --git a/internal/tui3/take_failure_log_test.go b/internal/tui3/take_failure_log_test.go new file mode 100644 index 0000000000..125d7ffa63 --- /dev/null +++ b/internal/tui3/take_failure_log_test.go @@ -0,0 +1,28 @@ +package tui3 + +import ( + "bytes" + "errors" + "log" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/chatlist" +) + +// Contract F2 records the cause of every otherwise unexplained continuation failure. +func TestFailedTakeLogsTheCatchAllCause(t *testing.T) { + var out bytes.Buffer + previous := log.Writer() + log.SetOutput(&out) + defer log.SetOutput(previous) + a := &app{} + err := errors.New("local admission could not read the saved tree") + line := a.failedTake(homeTakenMsg{row: chatlist.Row{Cell: "saved-chat"}, err: err}) + if !strings.Contains(line, "could not be put in this folder") { + t.Fatalf("failure line=%q", line) + } + if !strings.Contains(out.String(), err.Error()) || !strings.Contains(out.String(), "saved-chat") { + t.Fatalf("missing cause in chat log: %q", out.String()) + } +} From ed64fa0f7a86a22bd2815269f49eed972580fb81 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 27 Oct 2026 00:54:46 -0400 Subject: [PATCH 17/28] fix(pair): a pairing deadline says so in a plain sentence The directory client keeps the typed cause of an HTTP failure, so a deadline is no longer called a network fault, and the terminal and in-chat pairing doors say the shared did-not-answer-in-time sentence instead of raw Go error text. Cancelled is reserved for a person actually cancelling. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/chatv3_pair.go | 8 ++- cmd/codeaf/devices.go | 2 +- cmd/codeaf/pair.go | 4 +- cmd/codeaf/pair_deadline_test.go | 72 +++++++++++++++++++ internal/directory/http.go | 2 +- internal/directory/http_deadline_test.go | 25 +++++++ .../chat/use-this-on-another-computer.md | 13 +++- internal/pair/deadline.go | 30 ++++++++ internal/pair/linkjoin.go | 4 ++ 9 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 cmd/codeaf/pair_deadline_test.go create mode 100644 internal/directory/http_deadline_test.go create mode 100644 internal/pair/deadline.go diff --git a/cmd/codeaf/chatv3_pair.go b/cmd/codeaf/chatv3_pair.go index 45ffd65405..7c530aeaf9 100644 --- a/cmd/codeaf/chatv3_pair.go +++ b/cmd/codeaf/chatv3_pair.go @@ -34,7 +34,8 @@ func (chatPairDoor) Offer(ctx context.Context, ui pair.OfferUI) (string, error) if err != nil { return "", err } - return pair.Offer(ctx, route, grant, ui) + label, err := pair.Offer(ctx, route, grant, ui) + return label, explainSyncUnreachable(err, route.URL) } // Join types another computer's code and takes its chats. A computer with @@ -48,7 +49,7 @@ func (chatPairDoor) Join(ctx context.Context, typed string, ui pair.JoinUI) (pai return pair.Joined{}, err } joined, err := pair.Join(ctx, route, pairJoining(false), typed, ui) - return joined, chatJoinRefusal(err) + return joined, chatJoinRefusal(explainSyncUnreachable(err, route.URL)) } // chatJoinRefusal says the plain refusal the chat's way: the terminal's own @@ -70,7 +71,8 @@ func (chatPairDoor) JoinReplacing(ctx context.Context, typed string, ui pair.Joi if err != nil { return pair.Joined{}, err } - return pair.Join(ctx, route, pairJoining(true), typed, ui) + joined, err := pair.Join(ctx, route, pairJoining(true), typed, ui) + return joined, explainSyncUnreachable(err, route.URL) } // HasOwnChats reports whether this home holds an identity of its own — the diff --git a/cmd/codeaf/devices.go b/cmd/codeaf/devices.go index ea1a61de3b..5c709722aa 100644 --- a/cmd/codeaf/devices.go +++ b/cmd/codeaf/devices.go @@ -437,7 +437,7 @@ func explainSyncUnreachable(err error, relay string) error { } } if errors.Is(err, context.DeadlineExceeded) || network != nil && network.Timeout() { - return toldLine{line: fmt.Sprintf("the sync service at %s did not answer in time — try again", relay), err: err} + return pair.ServiceTimeout(err, relay) } var dns *net.DNSError if errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.ENETUNREACH) || errors.Is(err, syscall.EHOSTUNREACH) || errors.As(err, &dns) { diff --git a/cmd/codeaf/pair.go b/cmd/codeaf/pair.go index befb0d0560..c28d9eaaa9 100644 --- a/cmd/codeaf/pair.go +++ b/cmd/codeaf/pair.go @@ -121,7 +121,7 @@ func (d pairDoor) run(ctx context.Context, args []string) error { if err := d.naming(ctx, *name, flags.Args(), *code); err != nil { return err } - return d.dispatch(ctx, flags.Args(), *via, *replace, *code) + return explainSyncUnreachable(d.dispatch(ctx, flags.Args(), *via, *replace, *code), *via) } // naming settles what this computer is called before a join starts, because the @@ -232,7 +232,7 @@ func joinedSentence(joined pair.Joined) string { return joined.Sentence() } // endedByPerson turns the end a person chose with ctrl+c into a clean exit that // says nothing was paired, and leaves every other failure as the sentence it already is. func (d pairDoor) endedByPerson(ctx context.Context, err error) error { - if ctx.Err() != nil { + if errors.Is(ctx.Err(), context.Canceled) { // The terminal echoes ^C on the prompt's own line, so start a fresh one. fmt.Fprintln(d.term.out) d.term.say(pair.CancelledLine) diff --git a/cmd/codeaf/pair_deadline_test.go b/cmd/codeaf/pair_deadline_test.go new file mode 100644 index 0000000000..295798cc80 --- /dev/null +++ b/cmd/codeaf/pair_deadline_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// Contract F4 gives a pairing request deadline the same plain sentence as status. +func TestPairDoorExplainsARequestDeadline(t *testing.T) { + celltest.RequireEngine(t) + for _, kind := range []string{"transport", "parent", "approve"} { + t.Run(kind, func(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-r.Context().Done(): + case <-time.After(200 * time.Millisecond): + } + })) + defer server.Close() + t.Setenv(syncsetup.URLVar, server.URL) + var out bytes.Buffer + door := newPairDoor(strings.NewReader(""), &out) + client := &http.Client{Timeout: 20 * time.Millisecond} + door.requests = func(route pair.Mailbox) directory.Requests { + return directory.NewLinkHTTP(route.URL, client) + } + ctx := t.Context() + var args []string + if kind == "parent" { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, 20*time.Millisecond) + defer cancel() + client.Timeout = time.Second + } + if kind == "approve" { + key, err := pair.NewLinkKey() + if err != nil { + t.Fatal(err) + } + ref := pair.LinkRef{Code: "k7m2q9xd", Key: key} + args = []string{"approve", ref.Token()} + door.approver = func(route pair.Mailbox) (pair.Approver, error) { + return pair.Approver{Requests: door.requests(route)}, nil + } + } + err := door.run(ctx, args) + want := "the sync service at " + server.URL + " did not answer in time — try again" + if err == nil || err.Error() != want { + t.Fatalf("pairing deadline=%v; want %q", err, want) + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("pairing lost the deadline cause: %v", err) + } + if strings.Contains(out.String(), context.DeadlineExceeded.Error()) { + t.Fatalf("raw deadline on screen: %s", out.String()) + } + }) + } +} diff --git a/internal/directory/http.go b/internal/directory/http.go index 0f7eeea004..c2ab3ffa3d 100644 --- a/internal/directory/http.go +++ b/internal/directory/http.go @@ -164,7 +164,7 @@ func (h *HTTP) doHeader(ctx context.Context, method, path string, in, out any) ( h.sign(req, body) resp, err := h.hc.Do(req) if err != nil { - return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) + return nil, fmt.Errorf("%w: %w", ErrUnreachable, err) } defer resp.Body.Close() if resp.StatusCode >= 300 { diff --git a/internal/directory/http_deadline_test.go b/internal/directory/http_deadline_test.go new file mode 100644 index 0000000000..7e6cdab9fc --- /dev/null +++ b/internal/directory/http_deadline_test.go @@ -0,0 +1,25 @@ +package directory_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" +) + +// Contract F4 retains request deadlines so each pairing door can explain the cause. +func TestHTTPRetainsTheRequestDeadlineCause(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + defer server.Close() + ctx, cancel := context.WithDeadline(t.Context(), time.Now().Add(-time.Second)) + defer cancel() + c := directory.NewLinkHTTP(server.URL, server.Client()) + _, err := c.CreateRequest(ctx, directory.NewRequest{}) + if !errors.Is(err, directory.ErrUnreachable) || !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("request cause lost: %v", err) + } +} diff --git a/internal/manual/chat/use-this-on-another-computer.md b/internal/manual/chat/use-this-on-another-computer.md index 0bcd5bdb5b..349feedfbb 100644 --- a/internal/manual/chat/use-this-on-another-computer.md +++ b/internal/manual/chat/use-this-on-another-computer.md @@ -352,7 +352,7 @@ Moving chats and pairing are available by default on Linux, macOS and native Win Other things the relay can say while you pair: -The relay does not answer. The name is the host you used: +The relay cannot be reached. The name is the host you used: ``` cannot reach relay.example.com — check your internet connection and try again @@ -370,6 +370,17 @@ The relay is an old one that does not know pairing: that sync service is too old for pairing ``` +## Pairing timed out — the sync service did not answer in time + +A request that runs out of time does not diagnose a network fault. It says: + +``` +the sync service at https://relay.example.com did not answer in time — try again +``` + +The address is the service you used. Try pairing again; a busy service or disk +can also delay its answer. + ## I lost my laptop — what to do 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. diff --git a/internal/pair/deadline.go b/internal/pair/deadline.go new file mode 100644 index 0000000000..71dcb701ba --- /dev/null +++ b/internal/pair/deadline.go @@ -0,0 +1,30 @@ +package pair + +import ( + "errors" + "fmt" + "net/url" +) + +// ServiceTimeout names a service that did not answer without diagnosing the network. +// The cause remains available to callers that distinguish cancellation from failure. +func ServiceTimeout(cause error, address string) error { + var request *url.Error + if errors.As(cause, &request) { + if parsed, err := url.Parse(request.URL); err == nil && parsed.Host != "" { + address = parsed.Scheme + "://" + parsed.Host + } + } + return serviceTimeout{cause: cause, address: address} +} + +type serviceTimeout struct { + cause error + address string +} + +func (e serviceTimeout) Error() string { + return fmt.Sprintf("the sync service at %s did not answer in time — try again", e.address) +} + +func (e serviceTimeout) Unwrap() error { return e.cause } diff --git a/internal/pair/linkjoin.go b/internal/pair/linkjoin.go index 436a40c72f..c6f2335beb 100644 --- a/internal/pair/linkjoin.go +++ b/internal/pair/linkjoin.go @@ -8,6 +8,7 @@ import ( "context" "crypto/rand" "errors" + "net" "time" "golang.org/x/crypto/nacl/box" @@ -204,7 +205,10 @@ func (a *asker) install(ctx context.Context, j LinkJoining, g LinkGrant) (LinkJo // phraseLink turns what the directory said into the one sentence for it. func phraseLink(err error, host string) error { + var network net.Error switch { + case errors.Is(err, context.DeadlineExceeded), errors.As(err, &network) && network.Timeout(): + return ServiceTimeout(err, host) case errors.Is(err, directory.ErrRequestGone): return ErrLinkExpired case errors.Is(err, directory.ErrRateLimited): From b4c5c3cc372b5373c293817b42633730d78dde04 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 27 Oct 2026 02:15:11 -0400 Subject: [PATCH 18/28] fix(cli): codeaf devices says only that the list is unavailable when the relay is down With the relay unreachable or slow, the command printed the unavailable sentence and then a false "nothing is paired yet", and exited 0. It now prints only the plain unreachable or deadline sentence and exits non-zero, as cluster status does. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/devices.go | 10 ++-------- cmd/codeaf/devices_outage_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 8 deletions(-) create mode 100644 cmd/codeaf/devices_outage_test.go diff --git a/cmd/codeaf/devices.go b/cmd/codeaf/devices.go index 5c709722aa..0371e2f0e1 100644 --- a/cmd/codeaf/devices.go +++ b/cmd/codeaf/devices.go @@ -20,7 +20,6 @@ import ( "fmt" "net" "net/url" - "os" "slices" "strings" "syscall" @@ -351,12 +350,7 @@ func rank(b bool) int { func (c chatsKind) table(time.Time) (string, error) { rows, err := c.rows() if err != nil { - if errors.Is(err, directory.ErrRevoked) { - return "", err - } - // The other kind is still worth showing when the relay is not answering. - fmt.Fprintln(os.Stderr, "your devices cannot be listed now:", err) - return "", nil + return "", err } // A list of only this computer says nothing a person did not know. if len(rows) == 0 || (len(rows) == 1 && rows[0].This) { @@ -440,7 +434,7 @@ func explainSyncUnreachable(err error, relay string) error { return pair.ServiceTimeout(err, relay) } var dns *net.DNSError - if errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.ENETUNREACH) || errors.Is(err, syscall.EHOSTUNREACH) || errors.As(err, &dns) { + if errors.Is(err, directory.ErrUnreachable) || errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.ENETUNREACH) || errors.Is(err, syscall.EHOSTUNREACH) || errors.As(err, &dns) { return toldLine{line: fmt.Sprintf("the sync service at %s cannot be reached from here — check this computer's network", relay), err: err} } return err diff --git a/cmd/codeaf/devices_outage_test.go b/cmd/codeaf/devices_outage_test.go new file mode 100644 index 0000000000..3f97efbcec --- /dev/null +++ b/cmd/codeaf/devices_outage_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// Contract G3/J19 refuses an unavailable listing without claiming nothing is paired. +func TestDevicesOutageDoesNotClaimNothingIsPaired(t *testing.T) { + _ = newHostedMoveRig(t) + relay := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "unavailable", http.StatusServiceUnavailable) + })) + relay.Close() + t.Setenv(syncsetup.URLVar, relay.URL) + printed, err := captureStdout(t, func() error { return runDevices(nil) }) + if err == nil || !strings.Contains(err.Error(), "cannot be reached from here") { + t.Errorf("listing must fail with the network sentence: output=%q error=%v", printed, err) + } + if strings.TrimSpace(printed) != "" || strings.Contains(printed, pair.NoDevices) { + t.Errorf("unavailable listing printed an empty-device claim: %q", printed) + } +} From 05ac2551490c0a11aa7bf74ff57400a09b068b64 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 27 Oct 2026 02:15:11 -0400 Subject: [PATCH 19/28] fix(tasks): a moved worker's history is found without its plan identity The run history resolves a moved finished worker's transcript from the task store when the plan identity never arrived, so the page can show it. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/session/task_run_history.go | 16 +++++-- internal/session/task_store.go | 2 +- internal/tui3/moved_task_history_test.go | 61 ++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 internal/tui3/moved_task_history_test.go diff --git a/internal/session/task_run_history.go b/internal/session/task_run_history.go index 8b26e780c5..e63ce2dc5b 100644 --- a/internal/session/task_run_history.go +++ b/internal/session/task_run_history.go @@ -94,17 +94,25 @@ func (a *Agent) planWorkerRecordedModel(taskID string) string { return findWorkerHistory(taskJournalDir(place, session), id).model } -// Resolve missing recorded models once before graph recovery takes its lock. +// Resolve retained worker identities before graph recovery takes its lock. // Roster watchers then replay ordinary notices without filesystem reads. -func (a *Agent) restoreWorkerHistoryModels(document *taskDocument) { +func (a *Agent) restoreWorkerHistory(document *taskDocument) { a.mu.Lock() place, session := a.familyPlace(nil), a.sessionID() a.mu.Unlock() base := taskJournalDir(place, session) for i := range document.Runs { record := &document.Runs[i] - if record.Model == "" && record.Kind == "" && record.ID != 0 { - record.Model = findWorkerHistory(base, record.ID).model + if record.Kind != "" || record.ID == 0 || record.Model != "" && record.PlanTask != "" { + continue + } + history := findWorkerHistory(base, record.ID) + if record.Model == "" { + record.Model = history.model + } + if record.State == TaskDone && record.PlanTask == "" && history.journal != "" { + // A saved worker without a plan still has a stable, read-only task page. + record.PlanTask = planStoreID("history-" + strconv.FormatUint(record.ID, 10)) } } } diff --git a/internal/session/task_store.go b/internal/session/task_store.go index 99ecafc4d7..96da8b2da9 100644 --- a/internal/session/task_store.go +++ b/internal/session/task_store.go @@ -1800,7 +1800,7 @@ func (a *Agent) recoverTasks() { // one already on the column — and would write its journal into that run's // folder (orchestrate.go's orchestrateJournalPath). a.reserveRunNames(document.Runs) - a.restoreWorkerHistoryModels(&document) + a.restoreWorkerHistory(&document) graph := a.graph() recovery := graph.rehydrate(document, a.config.Workspace, a.settlePolicy()) diff --git a/internal/tui3/moved_task_history_test.go b/internal/tui3/moved_task_history_test.go new file mode 100644 index 0000000000..a13272753c --- /dev/null +++ b/internal/tui3/moved_task_history_test.go @@ -0,0 +1,61 @@ +package tui3 + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// Contract G3/J16 opens a moved finished worker whose checkpoint has no plan identity. +func TestMovedFinishedTaskWithoutPlanIdentityShowsItsWorker(t *testing.T) { + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "0") + place := session.Place{Dir: filepath.Join(t.TempDir(), "copied-chat"), Owned: true} + place.Workspace = place.Work() + if err := os.MkdirAll(filepath.Join(place.NodeJournals(), "1"), 0700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(place.Workspace, 0700); err != nil { + t.Fatal(err) + } + checkpoint := `{"type":"tasks","version":1,"seq":1,"runs":[{"id":1,"title":"Create hello task file","state":"done","model":"fixture/history","report":"file created","result":"file created"}]}` + if err := os.WriteFile(place.Tasks(), []byte(checkpoint), 0600); err != nil { + t.Fatal(err) + } + worker := "{\"type\":\"session\",\"version\":1,\"id\":\"worker\",\"model\":\"fixture/history\"}\n{\"type\":\"message\",\"role\":\"user\",\"content\":\"Create hello task file\"}\n{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"The retained worker inspected the files.\"}\n" + if err := os.WriteFile(filepath.Join(place.NodeJournals(), "1", "20261027-011448.092944_worker.jsonl"), []byte(worker), 0600); err != nil { + t.Fatal(err) + } + step := `{"kind":"step","step":1,"command":"printf hello-task","observation":"hello-task"}` + "\n" + if err := os.WriteFile(filepath.Join(place.NodeJournals(), "1", "trajectory.jsonl"), []byte(step), 0600); err != nil { + t.Fatal(err) + } + agent, err := session.New(session.Config{Place: place, Workspace: place.Workspace, SessionFile: place.Transcript(), Model: "fixture/history", APIKey: "fixture", BaseURL: "http://127.0.0.1:1"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { agent.Close() }) + a := newTestApp(agent) + a.width, a.height = 120, 40 + a.file, a.workspace = place.Transcript(), place.Workspace + rows := agent.PlanTasks() + if len(rows) != 1 || rows[0].Title != "Create hello task file" { + t.Fatalf("finished task is absent from its task list: %+v", rows) + } + openPlanRoomNow(t, a, rows[0].ID) + if a.room == nil || a.room.plan == nil || !a.room.plan.page.Historical { + t.Fatal("moved finished task did not open its historical page") + } + if text := roomText(a); !strings.Contains(text, "The retained worker inspected the files.") || !strings.Contains(text, "file created") { + t.Fatalf("moved task has no saved worker conversation: %s", text) + } + if commands := strings.Join(roomCommands(a), "\n"); !strings.Contains(commands, "printf hello-task") { + t.Fatalf("moved task has no retained step: %q", commands) + } + drive(t, a, key("r"), key("R")) + if !a.room.done || a.input.String() != "rR" { + t.Fatal("finished task accepted a restart action") + } +} From 4f4b648d266e58a47811c16e4f7bad51c4ba93f4 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 27 Oct 2026 02:15:12 -0400 Subject: [PATCH 20/28] fix(chat): sent words never come back, a removed computer keeps its own chats - A card take consumes the words it sends: they are gone from every draft store once sent, a later take or restart opens an empty box, and the take opens exactly one conversation with no stray copy of the words. - A failed take, a cancelled card or a cancelled folder picker keeps the words once, in the box they were typed in. - The card never names this computer as the holder of its own chat, a landing never says it moved from this computer, and a kept branch counts the conversation turns that were actually unpublished. - A removed computer sends in a chat that was never shared and in the chat it held at removal, with no relay request; it refuses only a chat another computer took, with the removal sentence. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/kept_chat_turns_test.go | 60 ++++++ cmd/codeaf/removed_local_send_test.go | 108 +++++++++++ cmd/codeaf/synctake.go | 9 +- internal/cellstore/published.go | 11 +- internal/cellstore/unpublished_turns.go | 60 ++++++ internal/chatlist/copy.go | 4 + .../continuing-a-chat-on-another-computer.md | 12 ++ .../chat/use-this-on-another-computer.md | 8 + internal/syncsetup/take.go | 8 + internal/tui3/app.go | 4 + .../tui3/continue_draft_consumption_test.go | 171 ++++++++++++++++++ internal/tui3/continue_draft_store.go | 68 +++++++ internal/tui3/conversation_open.go | 8 + internal/tui3/homepanel_continue.go | 71 ++++++-- internal/tui3/homepanel_heldelsewhere.go | 18 +- internal/tui3/keeper.go | 1 + internal/tui3/self_hold_continue_test.go | 52 ++++++ internal/tui3/takeover.go | 7 + 18 files changed, 650 insertions(+), 30 deletions(-) create mode 100644 cmd/codeaf/kept_chat_turns_test.go create mode 100644 cmd/codeaf/removed_local_send_test.go create mode 100644 internal/cellstore/unpublished_turns.go create mode 100644 internal/tui3/continue_draft_consumption_test.go create mode 100644 internal/tui3/continue_draft_store.go create mode 100644 internal/tui3/self_hold_continue_test.go diff --git a/cmd/codeaf/kept_chat_turns_test.go b/cmd/codeaf/kept_chat_turns_test.go new file mode 100644 index 0000000000..857099ef04 --- /dev/null +++ b/cmd/codeaf/kept_chat_turns_test.go @@ -0,0 +1,60 @@ +package main + +import ( + "fmt" + "os" + "testing" + + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/session" +) + +// Contracts G2.2–3 count one unpublished exchange across its intermediate saves. +func TestLocalContinuationKeepsOneUnpublishedTurnAcrossManySaves(t *testing.T) { + for _, sealedAnswer := range []bool{false, true} { + t.Run(fmt.Sprint("sealed answer ", sealedAnswer), func(t *testing.T) { + h := newHostedMoveRig(t) + d := h.publish(h.a, h.ea, h.c, "this computer") + if err := d.Close(t.Context()); err != nil { + t.Fatal(err) + } + journal := session.Place{Dir: h.c.Root}.Transcript() + appendLine := func(line string) { + t.Helper() + f, err := os.OpenFile(journal, os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + t.Fatal(err) + } + _, writeErr := fmt.Fprintln(f, line) + if err := f.Close(); writeErr != nil || err != nil { + t.Fatalf("journal append: %v %v", writeErr, err) + } + } + appendLine(`{"type":"message","role":"user","content":"one unpublished question"}`) + appendLine(`{"type":"message","role":"assistant","content":"I will inspect it first."}`) + for i := 0; i < 7; i++ { + appendLine(`{"type":"message","role":"assistant","toolCalls":[{"id":"tool","type":"function"}]}`) + appendLine(`{"type":"message","role":"tool","toolCallId":"tool","content":"tool result"}`) + if _, err := h.ea.Seal(t.Context(), h.c, cellstore.TurnInfo{}); err != nil { + t.Fatal(err) + } + } + appendLine(`{"type":"message","role":"assistant","content":"one unpublished answer"}`) + if sealedAnswer { + if _, err := h.ea.Seal(t.Context(), h.c, cellstore.TurnInfo{}); err != nil { + t.Fatal(err) + } + } + got, err := h.taker(h.a, h.ea, func(string) string { return h.c.Root }, nil).Take(t.Context(), h.c.ID) + if err != nil { + t.Fatal(err) + } + if got.Kept == "" || got.KeptTurns != 1 { + t.Errorf("one unpublished turn kept as %q with %d turns", got.Kept, got.KeptTurns) + } + if got.Resume.From != "" { + t.Errorf("local continuation arrived from %q", got.Resume.From) + } + }) + } +} diff --git a/cmd/codeaf/removed_local_send_test.go b/cmd/codeaf/removed_local_send_test.go new file mode 100644 index 0000000000..75df4c8ea5 --- /dev/null +++ b/cmd/codeaf/removed_local_send_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "context" + "errors" + "io" + "testing" + "time" + + "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/session" + "github.com/Agent-Field/codeaf/internal/tui3" +) + +type removedLocalSource struct { + sendSurfaceSource + rows []chatlist.Row + read chan struct{} +} + +func (s removedLocalSource) Rows(context.Context) ([]chatlist.Row, error) { return s.rows, nil } +func (s removedLocalSource) RowsAt(context.Context) ([]chatlist.Row, uint64, error) { + return s.rows, 0, nil +} +func (s removedLocalSource) RowsForChatSend(ctx context.Context, file string, offline, held bool) ([]chatlist.Row, uint64, bool, error) { + rows, version, local, err := s.surfaceMachines.RowsForChatSend(ctx, file, offline, held) + select { + case s.read <- struct{}{}: + default: + } + return rows, version, local, err +} + +// Contract G3/J20 preserves local work after removal without reviving a displaced copy. +func TestRemovedHostedWindowSendsOnlyItsOwnLocalChats(t *testing.T) { + for _, kind := range []string{"new", "held at removal", "displaced"} { + t.Run(kind, func(t *testing.T) { + h := newHostedMoveRig(t) + if err := h.a.Rename(t.Context(), "this computer"); err != nil { + t.Fatal(err) + } + if err := h.b.Rename(t.Context(), "other computer"); err != nil { + t.Fatal(err) + } + var rows []chatlist.Row + if kind != "new" { + if err := cellstore.NotePublished(h.c, "shared"); err != nil { + t.Fatal(err) + } + row := chatlist.Row{Cell: h.c.ID, Status: chatlist.Here, Mine: true, Fence: 7} + if kind == "displaced" { + row.Status, row.Mine, row.Device = chatlist.Running, false, "other computer" + } + rows = []chatlist.Row{row} + } + if err := h.b.Dir.Revoke(t.Context(), h.a.Device.ID()); err != nil { + t.Fatal(err) + } + if _, err := h.a.Dir.List(t.Context()); !errors.Is(err, directory.ErrRevoked) { + t.Fatalf("removal fixture: %v", err) + } + before := len(h.requests) + source := removedLocalSource{sendSurfaceSource{surfaceMachines{h.a}}, rows, make(chan struct{}, 1)} + agent := &recordingSendAgent{sent: make(chan string, 1)} + input, typed := io.Pipe() + defer input.Close() + defer typed.Close() + 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, SessionFile: session.Place{Dir: h.c.Root}.Transcript(), Workspace: h.work, Machines: source, Input: input, Output: screen, Width: 120, Height: 35}) + }() + defer func() { cancel(); <-done }() + <-screen.ready + _, _ = io.WriteString(typed, "keep working locally\r") + if kind == "displaced" { + select { + case <-source.read: + case <-time.After(5 * time.Second): + t.Fatal("no ownership reading") + } + select { + case words := <-agent.sent: + t.Fatalf("displaced copy sent %q", words) + case <-time.After(100 * time.Millisecond): + } + } else { + select { + case words := <-agent.sent: + if words != "keep working locally" { + t.Fatalf("sent %q", words) + } + case <-time.After(5 * time.Second): + t.Fatalf("own chat refused after removal: %s", screen.String()) + } + } + h.mu.Lock() + after := len(h.requests) + h.mu.Unlock() + if after != before { + t.Errorf("removed chat sent %d relay requests", after-before) + } + }) + } +} diff --git a/cmd/codeaf/synctake.go b/cmd/codeaf/synctake.go index 444c1bcb65..6ac646e7af 100644 --- a/cmd/codeaf/synctake.go +++ b/cmd/codeaf/synctake.go @@ -71,14 +71,19 @@ type surfaceMachines struct{ *syncsetup.Sync } // RowsForChatSend uses a live writer's receipt or a never-shared chat before asking the relay. // A known outage keeps unknown copies unsent without repeating the network wait. func (s surfaceMachines) RowsForChatSend(ctx context.Context, file string, offline, holding bool) ([]chatlist.Row, uint64, bool, error) { + root := session.DirOf(file) + id := filepath.Base(root) if s.Removed() { + if c, err := cell.OpenAt(root, id); err == nil { + if shared, err := cellstore.Shared(c); err == nil && !shared { + return nil, 0, true, directory.ErrRevoked + } + } return nil, 0, false, directory.ErrRevoked } if syncsetup.Quiet(s.Home) { return nil, 0, true, nil } - root := session.DirOf(file) - id := filepath.Base(root) remote, fence, leaseErr := s.SendLease(id) if holding && leaseErr == nil && fence != 0 { return []chatlist.Row{{Cell: remote, Status: chatlist.Here, Mine: true, Fence: fence}}, 0, true, nil diff --git a/internal/cellstore/published.go b/internal/cellstore/published.go index f656231565..8d82bd06e4 100644 --- a/internal/cellstore/published.go +++ b/internal/cellstore/published.go @@ -98,18 +98,23 @@ func Unpublished(c cell.Cell, remoteHead string) (string, uint32, error) { if err != nil { return "", 0, err } - var n uint32 + span := head.Receipt.Transcript for i := len(turns) - 1; i >= 0; i-- { turn := turns[i] if turn.ID == base { break } - n++ + receipt, err := readReceipt(c, turn.Receipt) + if err != nil { + return "", 0, err + } + span.Start = min(span.Start, receipt.Transcript.Start) // An adopted head has no line in this log. Its first local child // still names that boundary, so earlier history is already uploaded. if turn.Parent == base { break } } - return head.Turn.ID, max(1, n), nil + n, err := unpublishedConversationTurns(c, span) + return head.Turn.ID, max(1, n), err } diff --git a/internal/cellstore/unpublished_turns.go b/internal/cellstore/unpublished_turns.go new file mode 100644 index 0000000000..62f68c43cd --- /dev/null +++ b/internal/cellstore/unpublished_turns.go @@ -0,0 +1,60 @@ +package cellstore + +import ( + "bufio" + "encoding/json" + "io" + "os" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +// unpublishedConversationTurns counts exchanges rather than intermediate saves or tool records. +func unpublishedConversationTurns(c cell.Cell, span Range) (uint32, error) { + if span.End <= span.Start { + return 0, nil + } + file, err := os.Open(rel(c, cell.TranscriptPath)) + if err != nil { + return 0, err + } + defer file.Close() + scan := bufio.NewScanner(io.NewSectionReader(file, span.Start, span.End-span.Start)) + scan.Buffer(make([]byte, 4096), 8<<20) + var questions, answers uint32 + leadingReply := false + for scan.Scan() { + var record struct { + Type string `json:"type"` + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []json.RawMessage `json:"toolCalls"` + } + if err := json.Unmarshal(scan.Bytes(), &record); err != nil { + return 0, err + } + if record.Type != "message" { + continue + } + switch record.Role { + case "user": + questions++ + case "assistant": + leadingReply = leadingReply || questions == 0 + if len(record.ToolCalls) == 0 && record.Content != "" { + answers++ + } + case "tool": + leadingReply = leadingReply || questions == 0 + } + } + if questions == 0 { + // Older reply-only journals still describe their exchanges by their answers. + return answers, scan.Err() + } + if leadingReply { + // A reply whose question was already shared still has one unpublished tail. + questions++ + } + return questions, scan.Err() +} diff --git a/internal/chatlist/copy.go b/internal/chatlist/copy.go index aa6d240f9e..3971ff5812 100644 --- a/internal/chatlist/copy.go +++ b/internal/chatlist/copy.go @@ -37,6 +37,7 @@ const ( branchKeep = "%s from %s: discard" branchShort = "%s · %s" takeoverLine = "last saved turn %s ago" + lastHeldHere = "this chat was last held here" takeoverMore = "; up to %s may still be on %s" keptEdits = "your unsaved edits here were kept as %s from %s" supersededLine = "%s continued this chat; this window now only shows it" @@ -176,6 +177,9 @@ func BranchShort(r Row) string { return fmt.Sprintf(branchShort, turns(r.OrphanT // says where first, because continuing it here stops it there. func TakeoverLine(r Row) string { line := fmt.Sprintf(takeoverLine, agoWord(r.DurableAgo)) + if r.Mine { + return lastHeldHere + "; " + line + } if r.Pending > 0 { line += fmt.Sprintf(takeoverMore, turns(r.Pending), r.Device) } 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 d78c20ef2a..18c66cd9af 100644 --- a/internal/manual/chat/continuing-a-chat-on-another-computer.md +++ b/internal/manual/chat/continuing-a-chat-on-another-computer.md @@ -37,14 +37,26 @@ while the reading is in progress. After `continue here` succeeds, those same words, pasted text and attachments are in the arrived chat's box. Press enter again when you want to send them. Choosing `leave it there` or a move that fails also keeps the whole draft. +The move opens one conversation for that chat. Once you send those words, they are cleared from its saved drafts, including matching copies left by an earlier window. Taking the chat back, reopening it or restarting codeaf then shows an empty box unless you typed a new draft. + Taking a chat from Home restores that chat's own saved draft, or leaves its box empty. Words typed for a different chat stay with that chat, even when both work in one place on disk. A draft held behind a send's continuation card stays with the chat that raised it. +## Read a finished task's worker conversation after moving its chat + +Open `/task` and choose the finished task. Its saved worker conversation and steps remain readable with its outcome after moving the chat, even when the original working copy is no longer available. A finished task offers no restart action; typing `r` or `R` writes those letters in the box. + ## What does leave it there do 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. On `Continue this chat here?`, `leave it there` closes the question without moving the chat. Your typed words stay in the box and nothing is sent. If the question opened because you tried to send from an older window, this answer is already selected. Choose `1 continue here` to bring the chat here before sending. +## Why does continue here say this chat was last held here + +When this computer was the chat's last holder and its hold expired, the card says `this chat was last held here; last saved turn 2h ago`, with the age of its saved turn. Choose `continue here` to resume it. The landing says `Moved here` and never names this computer as somewhere the chat moved from. + +If unpublished work needs to be kept separately, the number of kept turns counts conversation turns. Intermediate saves and tool records within one question and answer do not become extra turns. + ## What does running on tower mean — a row on home and the verb it offers 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. diff --git a/internal/manual/chat/use-this-on-another-computer.md b/internal/manual/chat/use-this-on-another-computer.md index 349feedfbb..f00b892eaf 100644 --- a/internal/manual/chat/use-this-on-another-computer.md +++ b/internal/manual/chat/use-this-on-another-computer.md @@ -438,3 +438,11 @@ and existing remote access still work. Use a build carrying the newer engine, or 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 PEM certificate bundle containing only `BEGIN CERTIFICATE` blocks is kept in saved history and can move with the workspace. A `.pem` suffix alone does not make it a private key. Files containing PEM private-key headers (including encrypted keys and OpenSSH keys), PuTTY private keys, and PKCS#12 key containers are withheld; a PEM containing both certificates and a private key is withheld too. The same folder exclusions still apply: a certificate inside an install folder left behind for rebuilding is rebuilt with that folder. + +## My computer was removed — can I still send in my own chats + +A removed computer keeps working locally in a new chat and in a chat it held when it was removed. Those turns stay on this computer and do not sync. A chat another computer had already taken stays unsent here. Removal offers no way to take that chat back; pair the computer again to bring syncing back. + +## Why does codeaf devices fail when the sync service is down + +If your devices cannot be listed, `codeaf devices` exits with an error explaining that the sync service cannot be reached. It does not say nothing is paired when the listing is unavailable. A connection that times out says the service did not answer in time. diff --git a/internal/syncsetup/take.go b/internal/syncsetup/take.go index b98efdb991..0257a35be2 100644 --- a/internal/syncsetup/take.go +++ b/internal/syncsetup/take.go @@ -194,6 +194,9 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx seen = &directory.LeaseExpectation{Device: lease.Device, Fence: lease.Fence} } from := c.sync.deviceName(lease.Device) + if lease.Device == c.sync.Device.ID() { + from = "" + } retired := handoff.Trace(c.opt.OnPhase, "retirement") c.retireHolder(ctx, id) retired() @@ -412,6 +415,11 @@ func (c *Continuer) branch(b cellsync.Brancher) func(context.Context, *cellsync. if err != nil { return "", err } + // A final save can finish the same exchange as the earlier tool saves. + // Count the complete kept conversation once after that save. + if _, turns, err = cellstore.Unpublished(opened, from.Head); err != nil { + return "", err + } return b.Branch(ctx, from, head, turns, c.sync.publishInfo(opened, titleOf(opened.Root))) } } diff --git a/internal/tui3/app.go b/internal/tui3/app.go index d9c15ab8bf..de3357f35e 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -1483,6 +1483,10 @@ type app struct { // LEAVING. The open's landing spends it; a shared window opens in place // and spends it at once. homeTakenSaid func() tea.Cmd + // homeTaking keeps a retiring session on its own continuation until arrival. + homeTaking string + // homeTakeDraft moves the source composer only after the arriving door opens. + homeTakeDraft *homeTakenMsg // teamsDisk is where the teams are kept and the edits not yet written // there (teamseam.go). teamsDisk teamsDisk diff --git a/internal/tui3/continue_draft_consumption_test.go b/internal/tui3/continue_draft_consumption_test.go new file mode 100644 index 0000000000..de5382f90a --- /dev/null +++ b/internal/tui3/continue_draft_consumption_test.go @@ -0,0 +1,171 @@ +package tui3 + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/session" +) + +// Contracts G1.1, G1.3 and G1.4 spend the old draft when its chat returns elsewhere. +func TestContinueSendCannotRestoreItsWordsOrCreateAnotherChat(t *testing.T) { + for _, retire := range []bool{false, true} { + t.Run(map[bool]string{false: "moved folder", true: "retired session"}[retire], func(t *testing.T) { + src := &continuationListing{row: leftOffRow(heldCell, "other computer", chatlist.Running, time.Minute)} + taker := &fakeTaker{} + a, old := heldLab(t, src, taker) + a.closeHome() + oldFile, oldWork := a.file, a.workspace + drafts := t.TempDir() + oldDraft := DraftFile(drafts, oldWork) + a.draftFile = oldDraft + words := "What is 19 times 21? Answer with just the number." + a.input.setText(words) + a.writeDraftsNow(a.leavingDraft()) + priorDraft := asWindow(t, deadPid(t), func() string { return DraftFile(drafts, oldWork) }) + prior := newTestApp(&fakeAgent{model: "m"}) + prior.file, prior.workspace, prior.draftFile = oldFile, oldWork, priorDraft + prior.input.setText(words) + prior.writeDraftsNow(prior.leavingDraft()) + arrivedWork := t.TempDir() + arrivedFile := filepath.Join(arrivedWork, heldCell, ".cell", "transcript.jsonl") + arrivedDraft := DraftFile(drafts, arrivedWork) + arrived := &switchAgent{fakeAgent: &fakeAgent{model: "m"}} + a.open = func(work, file string) (Conversation, error) { + return Conversation{Agent: arrived, Workspace: arrivedWork, SessionFile: file, DraftFile: arrivedDraft}, nil + } + starts := 0 + start := a.start + a.start = func(work string) (Conversation, error) { starts++; return start(work) } + taker.taken = Taken{Transcript: arrivedFile, Fence: 2} + taker.on = func(string) { + if retire { + drive(t, a, taskEventMsg{gen: a.taskGen, ev: session.Event{Kind: session.EventTakeover, Text: session.TakeoverWord}}) + } + src.change(func(row *chatlist.Row) { row.Mine, row.Status, row.Fence = true, chatlist.Here, 2 }) + } + drive(t, a, key("enter")) + confirm(t, a) + if a.file != arrivedFile || a.at(pageHome) { + t.Fatalf("take did not open the arriving chat: %q", a.file) + } + if len(arrived.sent) == 0 { + if got := readDraft(arrivedDraft); got != words { + t.Errorf("arrived unsent draft=%q", got) + } + drive(t, a, key("enter")) + } + if !reflect.DeepEqual(arrived.sent, []string{words}) || len(old.sent) != 0 { + t.Fatalf("sent=%v old=%v", arrived.sent, old.sent) + } + if starts != 0 || len(a.behind) != 0 { + t.Errorf("take left another chat: starts=%d held=%d", starts, len(a.behind)) + } + for _, file := range []string{oldDraft, arrivedDraft, priorDraft} { + if got := readDraft(file); got != "" { + t.Errorf("sent words remain in draft %s: %q", filepath.Base(file), got) + } + keep, _ := readDraftKeep(draftKeepPath(file)) + for _, slot := range keep.Slots { + if slot.Task == 0 && slot.Run == "" && slot.Guest == "" && slot.Text != "" { + t.Errorf("sent words remain in saved composer: %q", slot.Text) + } + } + } + reopened := newTestApp(&fakeAgent{model: "m"}) + reopened.file, reopened.workspace, reopened.draftFile = oldFile, oldWork, oldDraft + reopened.restoreDraft() + if got := reopened.mainDraftText(); got != "" { + t.Errorf("later take-back restored sent words: %q", got) + } + later := &fakeAgent{model: "m"} + a.open = func(work, file string) (Conversation, error) { + return Conversation{Agent: later, Workspace: oldWork, SessionFile: file, DraftFile: oldDraft}, nil + } + taker.taken = Taken{Transcript: oldFile, Fence: 4} + taker.on = func(string) { + src.change(func(row *chatlist.Row) { row.Mine, row.Status, row.Fence = true, chatlist.Here, 4 }) + } + src.change(func(row *chatlist.Row) { row.Mine, row.Status, row.Fence = false, chatlist.Running, 3 }) + spend(t, a, a.openHome()) + standOn(t, a, heldCell) + confirm(t, a) + if a.file != oldFile || a.mainDraftText() != "" { + t.Fatalf("take-back opened %q with %q", a.file, a.mainDraftText()) + } + typeText(t, a, "Double your last number. Just the number.") + drive(t, a, key("enter")) + if !reflect.DeepEqual(later.sent, []string{"Double your last number. Just the number."}) { + t.Errorf("take-back repeated the old message: %v", later.sent) + } + }) + } +} + +// Contracts G1.2–4 preserve one unsent composer through a refused take's retirement. +func TestFailedContinueKeepsOneDraftWithoutCreatingAChat(t *testing.T) { + src := &continuationListing{row: leftOffRow(heldCell, "other computer", chatlist.Running, time.Minute)} + taker := &fakeTaker{err: errors.New("folder unavailable")} + a, old := heldLab(t, src, taker) + a.closeHome() + a.draftFile = DraftFile(t.TempDir(), a.workspace) + a.input.setText(" keep these unsent words once ") + a.input.cursor = 6 + want := a.mainComposer() + starts := 0 + start := a.start + a.start = func(work string) (Conversation, error) { starts++; return start(work) } + taker.on = func(string) { + drive(t, a, taskEventMsg{gen: a.taskGen, ev: session.Event{Kind: session.EventTakeover, Text: session.TakeoverWord}}) + } + drive(t, a, key("enter")) + confirm(t, a) + if got := a.mainComposer(); !reflect.DeepEqual(got, want) { + t.Fatalf("failed take changed words: %+v", got) + } + a.writeDraftsNow(a.leavingDraft()) + if got := readDraft(a.draftFile); got != want.box.String() { + t.Errorf("failed take's draft=%q", got) + } + if starts != 0 || len(a.behind) != 0 || len(old.sent) != 0 { + t.Errorf("failed take made extra work: starts=%d held=%d sent=%v", starts, len(a.behind), old.sent) + } + if _, err := os.Stat(a.draftFile); err != nil { + t.Fatal(err) + } +} + +// Contract G1.1 also spends matching older drafts when the chat is already held here. +func TestAlreadyHereContinuationSpendsTheEarlierWindowsDraft(t *testing.T) { + src := &continuationListing{row: leftOffRow(heldCell, "other computer", chatlist.Running, time.Minute)} + taker := &fakeTaker{} + a, agent := heldLab(t, src, taker) + a.closeHome() + drafts := t.TempDir() + a.draftFile = DraftFile(drafts, a.workspace) + a.input.setText("send these words once") + a.writeDraftsNow(a.leavingDraft()) + previous := newTestApp(&fakeAgent{model: "m"}) + previous.file, previous.workspace = a.file, a.workspace + previous.draftFile = asWindow(t, deadPid(t), func() string { return DraftFile(drafts, a.workspace) }) + previous.input.setText(a.input.String()) + previous.writeDraftsNow(previous.leavingDraft()) + taker.taken = Taken{AlreadyHere: true, Transcript: a.file, Fence: 7} + taker.on = func(string) { + src.change(func(row *chatlist.Row) { row.Mine, row.Status, row.Fence = true, chatlist.Here, 7 }) + } + drive(t, a, key("enter")) + confirm(t, a) + drive(t, a, key("enter")) + if !reflect.DeepEqual(agent.sent, []string{"send these words once"}) || len(a.behind) != 0 { + t.Fatalf("already-held continuation sent=%v held=%d", agent.sent, len(a.behind)) + } + if got := readDraft(previous.draftFile); got != "" { + t.Errorf("earlier window still holds sent words: %q", got) + } +} diff --git a/internal/tui3/continue_draft_store.go b/internal/tui3/continue_draft_store.go new file mode 100644 index 0000000000..22e270cebc --- /dev/null +++ b/internal/tui3/continue_draft_store.go @@ -0,0 +1,68 @@ +package tui3 + +import ( + "path/filepath" + "slices" + "time" +) + +// saveTakeDraftAt keeps the arriving words durable before their source is cleared. +func (a *app) saveTakeDraftAt(conv Conversation, state composerState) error { + path := draftKeepPath(conv.DraftFile) + keep, _ := readDraftKeep(path) + workspace := conv.Workspace + if workspace == "" { + workspace = a.workspace + } + owner := draftOwnerOf(a.host, workspace, conv.SessionFile) + keep.Version, keep.Owner, keep.At = draftKeepVersion, owner, a.now().UTC().Format(time.RFC3339Nano) + keep.Slots = slices.DeleteFunc(keep.Slots, func(slot draftKeepSlot) bool { + return slot.Owner == owner && slot.Task == 0 && slot.Run == "" && slot.Guest == "" + }) + slot := keptSlot(mainRecipient, state) + slot.Owner = owner + keep.Slots = append(keep.Slots, slot) + return (draftSave{keepPath: path, textPath: conv.DraftFile, keep: keep, text: slot.Text, rev: claimDraftRev(path)}).commit() +} + +// moveTakeDraftCopies retires matching copies left by earlier windows of this chat. +func (a *app) moveTakeDraftCopies(state composerState) { + if a.draftFile == "" { + return + } + want := keptSlot(mainRecipient, state) + owner := a.draftKeepOwner() + files, _ := filepath.Glob(filepath.Join(filepath.Dir(a.draftFile), draftPrefix(a.workspace)+"*.json")) + for _, file := range files { + if file == draftKeepPath(a.draftFile) || draftWindowAlive(file) { + continue + } + keep, how := readDraftKeep(file) + if how != draftKeepFound { + continue + } + changed := false + slots := keep.Slots[:0] + for _, slot := range keep.Slots { + if slot.Owner == owner && slot.Task == 0 && slot.Run == "" && slot.Guest == "" && + slot.Text == want.Text && slices.Equal(slot.Pastes, want.Pastes) && slices.Equal(slot.Chips, want.Chips) { + slot.Text, slot.Caret, slot.Pastes, slot.Chips = "", 0, nil, nil + changed = true + } + if !slot.empty() { + slots = append(slots, slot) + } + } + if !changed { + continue + } + keep.Slots = slots + gone := draftKeepFile{path: file, keep: keep} + if keep.Owner == owner && readDraft(draftExportPath(file)) == want.Text { + gone.export = draftExportPath(file) + } + if err := gone.prune(); err != nil { + a.noteDraftKeepFailed(err) + } + } +} diff --git a/internal/tui3/conversation_open.go b/internal/tui3/conversation_open.go index 1e93f294a0..dcf2d2de09 100644 --- a/internal/tui3/conversation_open.go +++ b/internal/tui3/conversation_open.go @@ -173,6 +173,10 @@ func (a *app) conversationLater(ask func() (Conversation, error), say func(strin } func (a *app) cancelConversationOpening() { + if a.homeTakeDraft != nil { + a.restoreTakeDraft(*a.homeTakeDraft) + a.homeTaking, a.homeTakeDraft, a.homeTakenSaid = "", nil, nil + } hadDraft := a.conversationPreview != nil && (!a.conversationPreview.view.input.empty() || len(a.conversationPreview.view.pasted) > 0) if a.conversationPreview == nil { defer a.tabSettleTeam() @@ -326,6 +330,10 @@ func (a *app) homeOpenLater(line homeLine, takeover bool) tea.Cmd { } return tea.Batch(opened, a.homeLandOnTask(line), said) }, func(err error) tea.Cmd { + if a.homeTakeDraft != nil { + a.restoreTakeDraft(*a.homeTakeDraft) + a.homeTaking, a.homeTakeDraft, a.homeTakenSaid = "", nil, nil + } if errors.Is(err, session.ErrSessionLocked) || err.Error() == sessionBusyWord { if takeover { return a.homeTakeoverEnter(line) diff --git a/internal/tui3/homepanel_continue.go b/internal/tui3/homepanel_continue.go index b4c078a2a5..827832cd21 100644 --- a/internal/tui3/homepanel_continue.go +++ b/internal/tui3/homepanel_continue.go @@ -256,6 +256,8 @@ func (a *app) continueShown(row chatlist.Row, at int) questionShown { // presentRow is the row as the device's presence reads now: a chat whose holder // is not online is one that stopped, so the card does not say it runs there. func (a *app) presentRow(row chatlist.Row) chatlist.Row { + self := a.resumeState().self + row.Mine = row.Mine || self != "" && row.DeviceID == self if a.resumeState().away(row) { return row.Quiet() } @@ -264,12 +266,13 @@ func (a *app) presentRow(row chatlist.Row) chatlist.Row { // homeTakenMsg is the takeover's answer, coming back off the update loop. type homeTakenMsg struct { - row chatlist.Row - taken Taken - err error - roots map[string]string - draft *composerState - send func() tea.Cmd + row chatlist.Row + taken Taken + err error + roots map[string]string + draft *composerState + send func() tea.Cmd + fromFile string } // takeRemote asks for the chat off the update loop because a move waits on the network. @@ -287,12 +290,17 @@ func (a *app) takeRemoteState(msg homeTakenMsg) tea.Cmd { a.home.say(chatlist.Removed, "") return nil } + if a.homeTaking != "" { + return nil + } + a.homeTaking = msg.row.Cell a.home.say(continuing, "") taker, ctx := a.taker, a.ctx - if msg.draft == nil && a.file != "" && filepath.Base(session.DirOf(a.file)) == msg.row.Cell { + if a.file != "" && filepath.Base(session.DirOf(a.file)) == msg.row.Cell { + msg.fromFile = a.file // The displaced session may retire during the move, so its unsent words travel with the answer. composer := a.mainComposer() - if !composer.empty() { + if msg.draft == nil && !composer.empty() { msg.draft = &composer } } @@ -311,6 +319,9 @@ func (a *app) takeRemoteState(msg homeTakenMsg) tea.Cmd { // tookTakeover restores unsent words on failure and opens the arrived chat on success. func (a *app) tookTakeover(msg homeTakenMsg) tea.Cmd { + if msg.err != nil { + a.homeTaking = "" + } var missing *session.WorkspaceBindingsRequired if errors.As(msg.err, &missing) { if _, ok := a.taker.(workspaceRootTaker); ok && len(missing.Roots) > 0 { @@ -402,6 +413,9 @@ func (a *app) rebuildMachines() { // the one they are arriving in. A shared window opens in place, and the ride // is spent here rather than at the landing. func (a *app) openTaken(msg homeTakenMsg) tea.Cmd { + if a.presentRow(msg.row).Mine { + msg.taken.Resume.From = "" + } if a.machineRead.sendHolds == nil { a.machineRead.sendHolds = map[string]sendHold{} } @@ -429,23 +443,31 @@ func (a *app) openTaken(msg homeTakenMsg) tea.Cmd { } line, ok := a.takenLine(msg) if !ok { + a.homeTaking = "" a.restoreTakeDraft(msg) a.home.say(joinSaid(moved, kept), "") return a.askMachines() } + a.homeTakeDraft = &msg a.homeTakenSaid = func() tea.Cmd { // A local session of this same chat was retired before replacement. // Its old handle cannot remain a selectable copy behind the new one. - key := a.convKey(line.row.Transcript) - if held := a.behind[key]; held != nil && !msg.taken.AlreadyHere { - if held.watch != nil { - held.watch.stop() + for _, file := range []string{line.row.Transcript, msg.fromFile} { + key := a.convKey(file) + if held := a.behind[key]; held != nil && !msg.taken.AlreadyHere { + if held.watch != nil { + held.watch.stop() + } + delete(a.behind, key) + a.forget(key) } - delete(a.behind, key) - a.forget(key) } + a.homeTaking, a.homeTakeDraft = "", nil a.note(joinSaid(moved, kept)) if msg.draft != nil { + if msg.taken.AlreadyHere { + a.moveTakeDraftCopies(*msg.draft) + } a.putComposer(*msg.draft) } else if !msg.taken.AlreadyHere { // A take from Home restores the arrived chat's saved words. The @@ -473,6 +495,7 @@ func (a *app) openTaken(msg homeTakenMsg) tea.Cmd { } switch { case cmd == nil && a.at(pageHome): + a.homeTaking, a.homeTakeDraft = "", nil a.restoreTakeDraft(msg) // THE OPEN REFUSED and home still stands: the sentence has no // conversation to arrive in, so home itself says the takeover @@ -491,6 +514,26 @@ func (a *app) openTaken(msg homeTakenMsg) tea.Cmd { return tea.Batch(cmd, a.askMachines()) } +// moveTakeDraft leaves one composer with the arriving chat after its door opens. +func (a *app) moveTakeDraft(conv Conversation) { + msg := a.homeTakeDraft + if msg == nil || msg.draft == nil || msg.fromFile == "" || a.file != msg.fromFile { + return + } + if conv.DraftFile != "" && conv.DraftFile != a.draftFile { + if err := a.saveTakeDraftAt(conv, *msg.draft); err != nil { + a.noteDraftKeepFailed(err) + return + } + } + a.moveTakeDraftCopies(*msg.draft) + if a.composerOwner == mainRecipient { + a.putComposer(composerState{}) + } + a.keepComposer(mainRecipient, composerState{}) + a.writeDraftsNow("") +} + func (a *app) restoreTakeDraft(msg homeTakenMsg) { if msg.draft == nil { return diff --git a/internal/tui3/homepanel_heldelsewhere.go b/internal/tui3/homepanel_heldelsewhere.go index ea426122c3..65c66a4e29 100644 --- a/internal/tui3/homepanel_heldelsewhere.go +++ b/internal/tui3/homepanel_heldelsewhere.go @@ -170,7 +170,7 @@ func (a *app) afterOwnershipRead(msg ownershipReadMsg) tea.Cmd { next := a.liveComposer() a.putComposer(*msg.first) var reading tea.Cmd - if !msg.local { + if !msg.local || msg.reading.err != nil { reading = a.recordMachines(msg.reading) } cmd := a.resumeOwnedSend(msg) @@ -190,7 +190,7 @@ func (a *app) afterOwnershipRead(msg ownershipReadMsg) tea.Cmd { return nil } var reading tea.Cmd - if !msg.local { + if !msg.local || msg.reading.err != nil { reading = a.recordMachines(msg.reading) } return tea.Batch(reading, a.resumeOwnedSend(msg)) @@ -240,13 +240,9 @@ 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 taker, ok := a.taker.(conditionalTaker); ok { + if _, ok := a.taker.(conditionalTaker); ok { draft := a.mainComposer() - ctx := a.ctx - return func() tea.Msg { - taken, err := taker.TakeSeen(ctx, row.Cell, &directory.LeaseExpectation{Device: row.DeviceID, Fence: row.Fence}) - return homeTakenMsg{row: row, taken: taken, err: err, draft: &draft, send: msg.resume} - } + return a.takeRemoteState(homeTakenMsg{row: row, draft: &draft, send: msg.resume}) } } if a.taker == nil { @@ -268,7 +264,7 @@ func (a *app) resumeOwnedSend(msg ownershipReadMsg) tea.Cmd { // could not be made: the chat is this window's own at the fence it recorded, or // it was never shared and nobody is known to hold it. func (a *app) offlineSendHeld(id, remote string, msg ownershipReadMsg) bool { - if msg.notHeld && msg.proof && !msg.local { + if msg.notHeld && msg.proof && !msg.local && !a.machineRead.computerRemoved { return false } held := a.machineRead.sendHolds[id] @@ -276,7 +272,7 @@ func (a *app) offlineSendHeld(id, remote string, msg ownershipReadMsg) bool { return false } fence := held.fence - if msg.proof { + if msg.proof && (!a.machineRead.computerRemoved || msg.fence != 0) { fence = msg.fence } for _, row := range a.machineRead.rows { @@ -284,7 +280,7 @@ func (a *app) offlineSendHeld(id, remote string, msg ownershipReadMsg) bool { return fence != 0 && row.Status == chatlist.Here && row.Fence == fence } } - return fence == 0 + return fence == 0 && (!a.machineRead.computerRemoved || !msg.proof || msg.local) } func nonemptyDevice(device string) string { diff --git a/internal/tui3/keeper.go b/internal/tui3/keeper.go index c927a0bf82..949c1db8e8 100644 --- a/internal/tui3/keeper.go +++ b/internal/tui3/keeper.go @@ -1185,6 +1185,7 @@ func (a *app) startBeside(workspace string) (tea.Cmd, string) { // takeBeside is the two lines both doors above end in: the conversation on // screen steps aside and goes on running, and the new one takes the surface. func (a *app) takeBeside(conv Conversation) tea.Cmd { + a.moveTakeDraft(conv) // The name of what is being left, read while it is still in front, and said // afterwards on a door that could not keep it (below). Notes are cleared by // the detach, so this is remembered rather than written now. diff --git a/internal/tui3/self_hold_continue_test.go b/internal/tui3/self_hold_continue_test.go new file mode 100644 index 0000000000..c7e148a047 --- /dev/null +++ b/internal/tui3/self_hold_continue_test.go @@ -0,0 +1,52 @@ +package tui3 + +import ( + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/chatlist" + machine "github.com/Agent-Field/codeaf/internal/preflight" +) + +// Contract G2.1 describes a stale self-hold without naming this computer. +func TestContinueCardDescribesTheChatLastHeldHere(t *testing.T) { + for _, mine := range []bool{false, true} { + row := leftOffRow(heldCell, "this computer's name", chatlist.Off, 2*time.Hour) + row.Mine, row.DeviceID, row.Pending = mine, "self", 1 + a, _ := heldLab(t, &continuationListing{row: row}, &fakeTaker{}) + a.deviceID = "self" + a.closeHome() + typeText(t, a, "continue my chat") + drive(t, a, key("enter")) + ask, ok := a.homeAsking() + if !ok { + t.Fatal("stale self-hold did not offer continuation") + } + reason := ask.question.Reason + if strings.Contains(reason, row.Device) || !strings.Contains(reason, "last held here") || !strings.Contains(reason, "last saved turn 2h ago") { + t.Errorf("self-hold card: %q", reason) + } + } +} + +// Contract G2.2 never describes a local continuation as a move from this computer. +func TestSelfHoldLandingDoesNotSayMovedFromThisComputer(t *testing.T) { + row := leftOffRow(heldCell, "this computer's name", chatlist.Off, time.Hour) + row.Mine, row.DeviceID = true, "self" + src := &continuationListing{row: row} + taker := &fakeTaker{} + a, _ := heldLab(t, src, taker) + a.deviceID = "self" + taker.taken = Taken{Transcript: a.file, Elapsed: time.Second, Resume: machine.Resume{From: row.Device}} + taker.on = func(string) { src.change(func(r *chatlist.Row) { r.Status = chatlist.Here }) } + a.closeHome() + typeText(t, a, "continue my chat") + drive(t, a, key("enter")) + confirm(t, a) + for _, note := range noteTexts(a) { + if strings.Contains(note, "Moved from "+row.Device) { + t.Errorf("self-hold landing: %q", note) + } + } +} diff --git a/internal/tui3/takeover.go b/internal/tui3/takeover.go index 793674ec6c..6e20866104 100644 --- a/internal/tui3/takeover.go +++ b/internal/tui3/takeover.go @@ -71,6 +71,7 @@ package tui3 import ( "context" + "path/filepath" "strings" "time" @@ -586,6 +587,12 @@ func (a *app) landTakeover(transcript string) { // (draft.go's [adoptDraft]), so the sentence the person was in the middle of // walks to the other terminal with the conversation. func (a *app) takeOver() tea.Cmd { + if a.homeTaking != "" && filepath.Base(session.DirOf(a.file)) == a.homeTaking { + // This window is reopening the same chat, so retirement needs no new chat. + a.dropParked() + a.writeDraftsNow(a.leavingDraft()) + return closeConversationLater(a.agent, session.StopByTakeover, a.saveFailure, a.closingStarted, nil) + } a.dropParked() if a.draftFile != "" { // The whole composer, so the window taking this conversation over takes From 7bf7ba9b8bd8b1f446ddc8ab2c050125ee1705e2 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 27 Oct 2026 02:04:07 -0400 Subject: [PATCH 21/28] fix(sync): a copied chat can be taken back as often as it moves The pre-acquire inspection named its private timeline after the chat alone, so once a copy was filed into its project bucket the next take's staging folder no longer matched the binding the detached inspection timeline kept, and furrow refused every later take with "timeline is attached to another folder". The inspection timeline is now named from its scratch folder, so a chat can ping-pong between computers any number of times, and a home already holding the old binding takes the chat without any repair step. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/copied_pingpong_test.go | 341 ++++++++++++++++++++ internal/cellstore/admission_retake_test.go | 73 +++++ internal/cellstore/materialize.go | 6 +- 3 files changed, 419 insertions(+), 1 deletion(-) create mode 100644 cmd/codeaf/copied_pingpong_test.go create mode 100644 internal/cellstore/admission_retake_test.go diff --git a/cmd/codeaf/copied_pingpong_test.go b/cmd/codeaf/copied_pingpong_test.go new file mode 100644 index 0000000000..532d0a4986 --- /dev/null +++ b/cmd/codeaf/copied_pingpong_test.go @@ -0,0 +1,341 @@ +package main + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "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/furrow" + "github.com/Agent-Field/codeaf/internal/handoff" + "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" + "github.com/Agent-Field/codeaf/internal/tui3" +) + +type copiedPingPong struct { + *hostedMoveRig + copyRoot string + initialRoot string + door string + replies []string +} + +type copiedTakeResult struct { + taken tui3.Taken + err error +} + +type copiedSurfaceTaker struct { + inner takeover + result chan copiedTakeResult +} + +func (c copiedSurfaceTaker) Take(ctx context.Context, id string) (tui3.Taken, error) { + return c.TakeSeen(ctx, id, nil) +} + +func (c copiedSurfaceTaker) TakeSeen(ctx context.Context, id string, seen *directory.LeaseExpectation) (tui3.Taken, error) { + got, err := c.inner.TakeSeen(ctx, id, seen) + c.result <- copiedTakeResult{got, err} + return got, err +} + +func (c copiedSurfaceTaker) SafeToContinue(ctx context.Context, id, current string, automatic bool) (bool, error) { + if automatic { + return false, nil + } + return c.inner.SafeToContinue(ctx, id, current, false) +} + +func (h *copiedPingPong) surfaceTake(s *syncsetup.Sync, taker takeover, root, door, from string) (cell.Cell, error) { + h.t.Helper() + t := h.t + input, typed := io.Pipe() + screen := &surfaceFrameWriter{ready: make(chan struct{})} + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + result := make(chan copiedTakeResult, 1) + file := session.Place{Dir: root}.Transcript() + go func() { + done <- tui3.Run(ctx, tui3.Options{Agent: &quietAgent{}, Workspace: filepath.Join(root, "work"), SessionFile: file, + Landing: door == "home", Machines: surfaceMachines{s}, DeviceID: s.Device.ID(), + Takeover: copiedSurfaceTaker{taker, result}, Input: input, Output: screen, Width: 120, Height: 35, + Open: func(where, file string) (tui3.Conversation, error) { + return tui3.Conversation{Agent: &quietAgent{}, Workspace: where, SessionFile: file}, nil + }}) + }() + defer func() { cancel(); input.Close(); typed.Close(); <-done }() + <-screen.ready + if door == "home" { + h.wait("Home continuation offer", func() bool { + return strings.Contains(screen.String(), "Continue "+from+"'s latest chat here?") + }) + _, _ = io.WriteString(typed, "\x1bc") + } else { + _, _ = io.WriteString(typed, "continue this copied chat\r") + h.wait("copied chat card", func() bool { return strings.Contains(screen.String(), "Continue this chat here?") }) + _, _ = io.WriteString(typed, "1\r") + } + var got copiedTakeResult + deadline := time.NewTimer(2 * time.Minute) + defer deadline.Stop() + tick := time.NewTicker(10 * time.Millisecond) + defer tick.Stop() + confirmed := door != "home" +waiting: + for { + select { + case got = <-result: + break waiting + case <-tick.C: + if !confirmed && strings.Contains(screen.String(), "Continue this chat here?") { + confirmed = true + _, _ = io.WriteString(typed, "1\r") + } + case <-deadline.C: + t.Fatalf("take did not finish: %s", screen.String()) + } + } + if got.err != nil { + return cell.Cell{}, got.err + } + h.wait("Moved from "+from, func() bool { return strings.Contains(screen.String(), "Moved from "+from) }) + return cell.OpenAt(session.DirOf(got.taken.Transcript), h.c.ID) +} + +func newCopiedPingPong(t *testing.T) *copiedPingPong { + t.Helper() + h := newHostedMoveRig(t) + h.ea.DataRoot = filepath.Join(h.a.Home, "v3", "stores") + h.eb.DataRoot = filepath.Join(h.b.Home, "v3", "stores") + machine, err := preflight.OpenMachine(h.c.Root, h.work) + if err != nil { + t.Fatal(err) + } + machine.Observer().Refresh() + return &copiedPingPong{hostedMoveRig: h, initialRoot: filepath.Join(h.b.Home, "v3", "projects", "copies", h.c.ID)} +} + +func (h *copiedPingPong) trace(p handoff.Phase) { + if !p.Ended.IsZero() { + h.t.Logf("%s: %s", p.Name, p.Ended.Sub(p.Started)) + } +} + +func (h *copiedPingPong) publishTurn(s *syncsetup.Sync, e cellstore.Engine, c cell.Cell, work, name, text string) { + h.t.Helper() + t := h.t + t.Setenv(home.EnvVar, s.Home) + e.Workspace = work + if err := os.WriteFile(filepath.Join(work, "latest.txt"), []byte(text), 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", text) + closeErr := journal.Close() + if writeErr != nil || closeErr != nil { + t.Fatalf("append reply: %v %v", writeErr, closeErr) + } + d := h.publish(s, e, c, name) + if err := d.Close(context.Background()); err != nil { + t.Fatal(err) + } + h.replies = append(h.replies, text) +} + +func (h *copiedPingPong) takeB() (cell.Cell, error) { + h.t.Helper() + h.t.Setenv(home.EnvVar, h.b.Home) + if err := os.Rename(h.work, h.work+".away"); err != nil { + h.t.Fatal(err) + } + defer func() { + if err := os.Rename(h.work+".away", h.work); err != nil { + h.t.Fatal(err) + } + }() + rootFor := func(string) string { + if h.copyRoot != "" { + return h.copyRoot + } + return h.initialRoot + } + c := h.b.Continuer(h.eb, syncsetup.TakeOptions{DeviceName: "computer B", RootFor: rootFor, OnPhase: h.trace, + BucketFor: func(string) string { return filepath.Join(h.b.Home, "v3", "projects", "copy--project") }}) + door := h.door + if h.copyRoot == "" { + door = "home" + } + got, err := h.surfaceTake(h.b, takeover{c}, rootFor(h.c.ID), door, "computer A") + if err != nil { + return cell.Cell{}, err + } + if got.Root == h.initialRoot { + h.t.Fatal("the first arrival was not filed into its copy bucket") + } + h.copyRoot = got.Root + return got, nil +} + +func (h *copiedPingPong) takeA() cell.Cell { + h.t.Helper() + h.t.Setenv(home.EnvVar, h.a.Home) + c := h.a.Continuer(h.ea, syncsetup.TakeOptions{DeviceName: "computer A", RootFor: func(string) string { return h.c.Root }, OnPhase: h.trace}) + got, err := h.surfaceTake(h.a, takeover{c}, h.c.Root, "home", "computer B") + if err != nil { + h.t.Fatal(err) + } + return got +} + +func (h *copiedPingPong) content(c cell.Cell, work, text string) { + h.t.Helper() + if body, err := os.ReadFile(filepath.Join(work, "latest.txt")); err != nil || string(body) != text { + h.t.Fatalf("latest file=%q: %v; want %q", body, err, text) + } + body, err := os.ReadFile(session.Place{Dir: c.Root}.Transcript()) + if err != nil { + h.t.Fatal(err) + } + for _, reply := range h.replies { + if !strings.Contains(string(body), reply) { + h.t.Fatalf("copied chat lost reply %q", reply) + } + } + h.liveBinding() +} + +func (h *copiedPingPong) liveBinding() { + h.t.Helper() + for data, work := range map[string]string{h.ea.DataRoot: h.work, h.eb.DataRoot: filepath.Join(h.copyRoot, "work")} { + want, err := filepath.EvalSymlinks(work) + if err != nil { + h.t.Fatal(err) + } + bindings, err := filepath.Glob(filepath.Join(data, "shared", "store-v1", "workspaces", "*", "root.path")) + if err != nil { + h.t.Fatal(err) + } + live := 0 + for _, binding := range bindings { + if _, err := os.Stat(filepath.Join(filepath.Dir(binding), "detached")); err == nil { + continue + } else if !os.IsNotExist(err) { + h.t.Fatal(err) + } + root, err := os.ReadFile(binding) + if err != nil || string(root) != want { + h.t.Fatalf("live binding=%q: %v; want %s", root, err, want) + } + live++ + } + if live != 1 { + h.t.Fatalf("live bindings for %s=%d; want 1", want, live) + } + } +} + +// Contract H1, H2 and H6 keep the latest files and replies through three real copied takes. +func TestCopiedChatPingPongsThroughThreeTakes(t *testing.T) { + for _, door := range []string{"home", "card"} { + t.Run(door, func(t *testing.T) { + h := newCopiedPingPong(t) + h.door = door + for round := 1; round <= 3; round++ { + fromA := fmt.Sprintf("reply A %d", round) + h.publishTurn(h.a, h.ea, h.c, h.work, "computer A", fromA) + b, err := h.takeB() + if err != nil { + t.Fatalf("B take %d: %v", round, err) + } + h.content(b, filepath.Join(b.Root, "work"), fromA) + if round == 3 { + break + } + fromB := fmt.Sprintf("reply B %d", round) + h.publishTurn(h.b, h.eb, b, filepath.Join(b.Root, "work"), "computer B", fromB) + a := h.takeA() + h.content(a, h.work, fromB) + } + }) + } +} + +// Contract H5 and H6 repair a filed copy beside a detached admission timeline from its first take. +func TestCopiedChatTakeRepairsDetachedAdmissionBinding(t *testing.T) { + h := newCopiedPingPong(t) + h.door = "home" + h.publishTurn(h.a, h.ea, h.c, h.work, "computer A", "first arrival") + b, err := h.takeB() + if err != nil { + t.Fatal(err) + } + stage := h.initialRoot + ".taking" + bin, err := furrow.ResolveOwned() + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(stage, 0700); err != nil { + t.Fatal(err) + } + stage, err = filepath.EvalSymlinks(stage) + if err != nil { + t.Fatal(err) + } + for _, verb := range [][]string{{"watch", "--no-daemon"}, {"forget"}} { + args := append([]string{"--json"}, verb...) + args = append(args, "--timeline", "incoming-admission-"+h.c.ID) + cmd := exec.CommandContext(t.Context(), bin, args...) + cmd.Dir = stage + cmd.Env = append(os.Environ(), "FURROW_DATA_DIR="+filepath.Join(h.eb.DataRoot, "shared")) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("seed detached binding: %s: %v", out, err) + } + } + if err := os.RemoveAll(stage); err != nil { + t.Fatal(err) + } + bindings, err := filepath.Glob(filepath.Join(h.eb.DataRoot, "shared", "store-v1", "workspaces", "*", "root.path")) + if err != nil { + t.Fatal(err) + } + found := false + for _, binding := range bindings { + root, err := os.ReadFile(binding) + if err != nil { + t.Fatal(err) + } + if string(root) == stage { + if _, err := os.Stat(filepath.Join(filepath.Dir(binding), "detached")); err != nil { + t.Fatalf("old admission binding is not detached: %v", err) + } + found = true + } + } + if !found { + t.Fatal("broken home lacks its detached admission binding") + } + h.liveBinding() + h.publishTurn(h.b, h.eb, b, filepath.Join(b.Root, "work"), "computer B", "return from B") + h.takeA() + h.publishTurn(h.a, h.ea, h.c, h.work, "computer A", "latest after repair") + b, err = h.takeB() + if err != nil { + t.Fatalf("take from broken home: %v", err) + } + h.content(b, filepath.Join(b.Root, "work"), "latest after repair") +} diff --git a/internal/cellstore/admission_retake_test.go b/internal/cellstore/admission_retake_test.go new file mode 100644 index 0000000000..8518c8443f --- /dev/null +++ b/internal/cellstore/admission_retake_test.go @@ -0,0 +1,73 @@ +package cellstore + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cell/celltest" +) + +// Contract H4 keeps repeated admission in different scratch folders off the real timeline. +func TestRepeatedAdmissionNeverTouchesTheChatTimeline(t *testing.T) { + celltest.RequireEngine(t) + engine := realEngine(t) + source := newCell(t) + if err := os.WriteFile(filepath.Join(source.Root, "work.txt"), []byte("saved work"), 0600); err != nil { + t.Fatal(err) + } + sealed, err := engine.Seal(t.Context(), source, TurnInfo{}) + if err != nil { + t.Fatal(err) + } + if err := Await(t.Context(), source); err != nil { + t.Fatal(err) + } + bindings, err := filepath.Glob(filepath.Join(engine.StoreDir(source), "store-v1", "workspaces", "*", "root.path")) + if err != nil || len(bindings) != 1 { + t.Fatalf("source bindings=%v: %v", bindings, err) + } + paths := []string{bindings[0], filepath.Join(filepath.Dir(bindings[0]), "paths.head"), filepath.Join(engine.LocalDir(source), "engine-tree")} + type savedFile struct { + body string + info os.FileInfo + } + before := make(map[string]savedFile) + for _, path := range paths { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + before[path] = savedFile{string(raw), info} + } + for range 3 { + stage := cell.Cell{ID: source.ID, Root: t.TempDir()} + if err := engine.StageAdmission(t.Context(), stage, sealed.Turn.ID); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(stage.Root, "work.txt")); !os.IsNotExist(err) { + t.Fatalf("admission restored working files: %v", err) + } + if err := engine.StageWorkspace(t.Context(), stage, sealed.Turn.ID); err != nil { + t.Fatal(err) + } + if raw, err := os.ReadFile(filepath.Join(stage.Root, "work.txt")); err != nil || string(raw) != "saved work" { + t.Fatalf("workspace admission=%q: %v", raw, err) + } + } + for path, saved := range before { + raw, err := os.ReadFile(path) + if err != nil || string(raw) != saved.body { + t.Fatalf("admission changed binding contents: %s: %v", path, err) + } + info, err := os.Stat(path) + if err != nil || !info.ModTime().Equal(saved.info.ModTime()) { + t.Fatalf("admission touched timeline metadata: %s: %v", path, err) + } + } +} diff --git a/internal/cellstore/materialize.go b/internal/cellstore/materialize.go index 7cec959cb6..e80bb5cc48 100644 --- a/internal/cellstore/materialize.go +++ b/internal/cellstore/materialize.go @@ -2,6 +2,7 @@ package cellstore import ( "context" + "crypto/sha256" "fmt" "os" @@ -116,7 +117,10 @@ func (e Engine) stageIncoming(ctx context.Context, c cell.Cell, head string, pat return err } target := e.target(c) - target.Tree, target.CellDir, target.Timeline = c.Root, "", "incoming-admission-"+c.ID + target.Tree, target.CellDir = canonicalTree(c.Root), "" + // Filing a copy changes its next staging folder. Detached timelines retain + // their folder binding, so admission identity belongs to the scratch path. + target.Timeline = fmt.Sprintf("incoming-admission-%x", sha256.Sum256([]byte(target.Tree))) transport := Spawn{Binary: e.Binary, Run: e.Run} if _, err := transport.Do(ctx, target, indexScratchOp{}); err != nil { return err From e6f22a39bf1d7b2bc643f693e81442b281ad8854 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 27 Oct 2026 02:04:08 -0400 Subject: [PATCH 22/28] fix(tasks): a moved finished task shows its steps and worker transcript The historical row now carries the receipt's conversation and run, so the Tasks door opens the read-only historical page whichever of the index and the roster arrives first, instead of keeping the summary card. The page also opens the work fold, so the worker's own reply and the recorded steps are on screen. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/manual/chat/worker-harness.md | 4 + internal/session/plandb_tasks.go | 4 + internal/session/task_run_history.go | 10 +- internal/tui3/moved_task_door_test.go | 145 +++++++++++++++++++++++++ internal/tui3/planroom.go | 13 +++ internal/tui3/taskplan.go | 7 +- 6 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 internal/tui3/moved_task_door_test.go diff --git a/internal/manual/chat/worker-harness.md b/internal/manual/chat/worker-harness.md index e66d118ae4..e5bd990754 100644 --- a/internal/manual/chat/worker-harness.md +++ b/internal/manual/chat/worker-harness.md @@ -1161,3 +1161,7 @@ continue its chat here. Open it to read its title, saved description when presen recorded steps and worker transcript. The historical page is read-only: finished work cannot be restarted, retried or resumed from that page. The original computer keeps its existing task page. + +The transcript tab opens with the saved steps and worker conversation expanded. +`ctrl+e` folds or opens that work. `Tab` switches between transcript and work; +the saved history remains readable even when the run's working copy is gone. diff --git a/internal/session/plandb_tasks.go b/internal/session/plandb_tasks.go index c29c36166f..59d4671b6c 100644 --- a/internal/session/plandb_tasks.go +++ b/internal/session/plandb_tasks.go @@ -34,6 +34,10 @@ import ( // the span it covered, the last thing anybody said on it, and where its record // lives. type PlanTaskRow struct { + // HistoricalRun and HistoricalSession identify the retained run receipt + // before its roster notice arrives, without guessing from its title. + HistoricalRun uint64 `json:",omitempty"` + HistoricalSession string `json:",omitempty"` // Done, Running, Queued, Failed and Total summarize every task below a run root. // They stay zero on ordinary task rows. Claimed work is running; pending and // ready work is queued. Total includes every descendant store row. diff --git a/internal/session/task_run_history.go b/internal/session/task_run_history.go index e63ce2dc5b..a698bb5c83 100644 --- a/internal/session/task_run_history.go +++ b/internal/session/task_run_history.go @@ -126,7 +126,7 @@ func (a *Agent) historicalPlanRows(rows []PlanTaskRow) []PlanTaskRow { } for _, notice := range a.historicalPlanNotices() { if !known[planTaskID(notice.PlanTask)] { - rows = append(rows, historicalPlanRow(notice)) + rows = append(rows, a.historicalPlanRow(notice)) known[planTaskID(notice.PlanTask)] = true } } @@ -149,8 +149,12 @@ func (a *Agent) historicalPlanNotices() []TaskNotice { return notices } -func historicalPlanRow(notice TaskNotice) PlanTaskRow { +func (a *Agent) historicalPlanRow(notice TaskNotice) PlanTaskRow { + a.mu.Lock() + owner := a.sessionID() + a.mu.Unlock() return PlanTaskRow{ID: notice.PlanTask, Title: notice.Title, Status: string(notice.State), + HistoricalRun: notice.ID, HistoricalSession: owner, Stopped: notice.Stopped, Archived: true, Started: notice.StartedAt, Ended: notice.EndedAt, Model: notice.Model, USD: notice.CostUSD, Note: notice.Report} } @@ -160,7 +164,7 @@ func (a *Agent) historicalPlanPage(id string) (PlanTaskPage, bool) { if planTaskID(notice.PlanTask) != planTaskID(id) { continue } - page := PlanTaskPage{Row: historicalPlanRow(notice), Historical: true, Result: notice.Result} + page := PlanTaskPage{Row: a.historicalPlanRow(notice), Historical: true, Result: notice.Result} if notice.PendingRun != nil { page.Description = notice.PendingRun.Brief } diff --git a/internal/tui3/moved_task_door_test.go b/internal/tui3/moved_task_door_test.go new file mode 100644 index 0000000000..351ccc5b16 --- /dev/null +++ b/internal/tui3/moved_task_door_test.go @@ -0,0 +1,145 @@ +package tui3 + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/session" +) + +// Contract I1 and I4 keep transferred steps and worker replies behind the task key. +func TestMovedFinishedTaskRealDoorShowsStepsAndWorker(t *testing.T) { + for _, roster := range []bool{true, false} { + name := "index before roster" + if roster { + name = "restored roster" + } + t.Run(name, func(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, ".cell"), 0700); err != nil { + t.Fatal(err) + } + place := session.Place{Dir: root, Workspace: filepath.Join(root, "work")} + files := map[string]string{ + place.Tasks(): `{"type":"tasks","version":1,"seq":1,"nodes":[],"runs":[{"id":1,"title":"Create hello-task.txt with 'hi from a task'","state":"done","planTask":"t-1","crewState":{"version":1,"routed":true,"Decision":{}},"report":"Created the greeting file.","result":"Created the greeting file.","model":"z-ai/glm-5.3-flash","elapsed_ms":26000,"costUsd":0.08,"startedAt":"2026-10-27T01:14:48Z","endedAt":"2026-10-27T01:15:14Z"}]}`, + filepath.Join(place.NodeJournals(), "1", "20261027-011448.092944_worker.jsonl"): `{"type":"session","version":1,"id":"worker","model":"z-ai/glm-5.3-flash"} +{"type":"message","role":"assistant","content":"The worker wrote hi from a task.\n\nThe saved conversation stays readable.","toolCalls":[{"id":"finish","function":{"name":"bash","arguments":"{\"command\":\"plandb done 1 --result 'Created the greeting file.'\"}"}}]} +{"type":"message","role":"tool","toolCallId":"finish","content":"done"} +`, + filepath.Join(place.NodeJournals(), "1", "trajectory.jsonl"): "{\"kind\":\"step\",\"step\":1,\"command\":\"printf 'hi from a task' > hello-task.txt\",\"observation\":\"greeting saved\"}\n", + filepath.Join(place.NodeJournals(), "1", "fixes.json"): "{}\n", + filepath.Join(place.NodeJournals(), "qe6q8c", "20261027-011504.157512_worker.jsonl"): "{\"type\":\"session\",\"version\":1,\"id\":\"checker\"}\n{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"The checker reply belongs to another task.\"}\n", + filepath.Join(place.NodeJournals(), "qe6q8c", "trajectory.jsonl"): "{\"kind\":\"step\",\"step\":1,\"command\":\"cat checker-only.txt\",\"observation\":\"checker output\"}\n", + } + for path, body := range files { + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0600); err != nil { + t.Fatal(err) + } + } + var modelCalls atomic.Int64 + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/models/") { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"endpoints":[]}}`)) + return + } + modelCalls.Add(1) + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer provider.Close() + agent, err := session.New(session.Config{Workspace: place.Workspace, Place: place, SessionFile: place.Transcript(), ProfileDir: t.TempDir(), Model: "stub/chat", APIKey: "stub-key", BaseURL: provider.URL}) + if err != nil { + t.Fatal(err) + } + defer agent.Close() + loop, err := remote.Loopback(remote.Hello{Version: remote.Version}, remote.Options{ + Boot: func(remote.Hello) (*remote.Engine, error) { + return &remote.Engine{Agent: agent, SessionFile: place.Transcript(), Workspace: place.Workspace}, nil + }, + }) + if err != nil { + t.Fatal(err) + } + defer loop.Close() + a := newTestApp(loop.Client.Agent()) + a.width, a.height = 180, 48 + a.homeRoot = t.TempDir() + a.file, a.title = place.Transcript(), "moved chat" + a.clock = func() time.Time { return time.Date(2026, 10, 27, 1, 16, 0, 0, time.UTC) } + a.comp.tasks = session.RebuiltTaskRows(place.Dir) + for path := range files { + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + files[path] = string(body) + } + if roster { + events, stop := loop.Client.Agent().WatchTaskUpdates() + defer stop() + select { + case event := <-events: + a.taskUpdate(event) + case <-time.After(5 * time.Second): + t.Fatal("finished task missing from restored roster") + } + } + a.input.setText("/task") + drive(t, a, key("enter")) + openTaskFolds(a) + if len(a.taskSheet.reading.items) != 1 { + t.Fatalf("Tasks must list the run once:\n%s", taskSheetText(a)) + } + a.taskSheetPointAt(a.taskSheet.reading.items[0].entry) + drive(t, a, key("enter")) + if calls := modelCalls.Load(); calls != 0 { + t.Errorf("opening finished work made %d model calls", calls) + } + screen := planRoomText(t, a) + for _, want := range []string{"Create hello-task.txt with 'hi from a task'", "done", "printf 'hi from a task'", "The worker wrote hi from a task", "The saved conversation stays readable."} { + if !strings.Contains(screen, want) { + t.Errorf("task page missing %q:\n%s", want, screen) + } + } + for _, unwanted := range []string{"checker reply", "checker-only.txt"} { + if strings.Contains(screen, unwanted) { + t.Errorf("task page includes another task's history %q", unwanted) + } + } + // Contract I2 keeps the same saved conversation on the transcript tab. + drive(t, a, key("tab")) + if !strings.Contains(planRoomText(t, a), "the run's working copy is not here any more") { + t.Errorf("missing working copy did not explain itself:\n%s", planRoomText(t, a)) + } + drive(t, a, key("tab")) + screen = planRoomText(t, a) + if !strings.Contains(screen, "The saved conversation stays readable.") || !strings.Contains(screen, "printf 'hi from a task'") { + t.Errorf("returning to transcript hid saved work:\n%s", screen) + } + a.input.setText("resume this task") + drive(t, a, key("enter")) + if a.room == nil || !a.room.done || !strings.Contains(planRoomText(t, a), "this task has finished") { + t.Error("historical page did not refuse to resume finished work") + } + for path, want := range files { + body, err := os.ReadFile(path) + if err != nil || string(body) != want { + t.Errorf("reading finished work changed %s: %v", filepath.Base(path), err) + } + } + if calls := modelCalls.Load(); calls != 0 { + t.Errorf("reading finished work made %d model calls", calls) + } + }) + } +} diff --git a/internal/tui3/planroom.go b/internal/tui3/planroom.go index 5eb041fdcb..b475ff78de 100644 --- a/internal/tui3/planroom.go +++ b/internal/tui3/planroom.go @@ -81,6 +81,19 @@ func (a *app) openPlanRoom(page session.PlanTaskPage) tea.Cmd { room.plan = &planRoom{id: id} a.room = room a.planRoomTake(page) + if page.Historical { + // Retained work opens expanded so the saved steps and worker replies + // are visible even when the worker ended by making a final call. + room.workOpen = make(map[int]bool) + folds := a.deckFolds(room.deck()) + for _, fold := range folds { + room.workOpen[fold.key] = true + } + stampHierarchy(room.entries, folds) + for _, caption := range deriveCaptions(room.entries, 0) { + room.capOpen[caption.start] = true + } + } // THE BOX TALKS TO THIS TASK, under its own name, exactly as it does on a // node's page ([app.openRoom]): what was being written elsewhere is stashed // under its own reader and this page's own unsent line comes back. diff --git a/internal/tui3/taskplan.go b/internal/tui3/taskplan.go index 98a0036af1..03e1c18129 100644 --- a/internal/tui3/taskplan.go +++ b/internal/tui3/taskplan.go @@ -18,6 +18,7 @@ package tui3 // slice of [session.Agent] this file needs. import ( + "strconv" "strings" "time" @@ -655,15 +656,19 @@ func planStoreDraws(rows []tasksMineRow, plan []session.PlanTaskRow) ([]tasksMin return rows, nil } held := make(map[string]bool, len(plan)) + history := make(map[tasksKey]bool) for _, task := range plan { if id := strings.TrimSpace(task.ID); id != "" { held[id] = true } + if task.HistoricalRun != 0 { + history[tasksKey{session: task.HistoricalSession, id: strconv.FormatUint(task.HistoricalRun, 10)}] = true + } } kept := make([]tasksMineRow, 0, len(rows)) replaced := make(map[tasksKey]bool) for _, row := range rows { - if held[strings.TrimSpace(row.planTask)] { + if held[strings.TrimSpace(row.planTask)] || history[tasksKeyOf(row.entry)] { replaced[tasksKeyOf(row.entry)] = true continue } From e5bf39c6b8641141994ae121a4448a7948bf94ec Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 27 Oct 2026 02:17:33 -0400 Subject: [PATCH 23/28] docs(changes): note the devices and pairing sentences in the wave entry Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/changes/unreleased/1990-two-computer-stable.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changes/unreleased/1990-two-computer-stable.md b/docs/changes/unreleased/1990-two-computer-stable.md index c489b6aaa2..35e8aee52b 100644 --- a/docs/changes/unreleased/1990-two-computer-stable.md +++ b/docs/changes/unreleased/1990-two-computer-stable.md @@ -12,6 +12,8 @@ invalidates: - "The computer holding a chat said nothing while the relay was unreachable; it now says once that the chat is not syncing, and again when syncing is back." - "codeaf cluster status printed a raw Go error when the relay was down; it now prints the same plain sentence as codeaf devices." - "The embedded storage engine was furrow 0.3.4, which could unlink a newer daemon's socket; it is now furrow 0.3.5, which removes only its own." + - "codeaf devices printed a false nothing-is-paired line and exited 0 when the relay was down; it now prints only the unavailable sentence and exits non-zero." + - "A pairing that timed out showed raw Go error text; it now says plainly that the sync service did not answer in time." --- Fixes the defects the real two-computer relay tests found after the workspace work landed. Closing a chat no longer freezes the window, Home never offers to From b1ab72eb8f369eccfa3869f72ed495db7fff5836 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 27 Oct 2026 02:58:32 -0400 Subject: [PATCH 24/28] fix(sync): a window that knows it was displaced never sends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A window that has drawn "… continued this chat" could still send for a few seconds, because its drive kept believing it held the chat until its own next heartbeat failed; the turn ran, the publish was refused and the chat forked. Now the surface latches a send block as soon as a newer listing row or the displaced notice arrives, so the next Enter raises the continue card, and the drive follows the directory feed and retires its hold the moment another computer acquires the chat. A held chat with no displacement news still sends at once with no relay request. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/displaced_watch_test.go | 178 ++++++++++++++++++ .../continuing-a-chat-on-another-computer.md | 13 ++ internal/syncsetup/displaced_watch_test.go | 97 ++++++++++ internal/syncsetup/drive.go | 23 ++- internal/syncsetup/drive_watch.go | 47 +++++ internal/tui3/app.go | 5 +- internal/tui3/displaced_watch_test.go | 67 +++++++ internal/tui3/homepanel_heldelsewhere.go | 25 +++ 8 files changed, 446 insertions(+), 9 deletions(-) create mode 100644 cmd/codeaf/displaced_watch_test.go create mode 100644 internal/syncsetup/displaced_watch_test.go create mode 100644 internal/syncsetup/drive_watch.go create mode 100644 internal/tui3/displaced_watch_test.go diff --git a/cmd/codeaf/displaced_watch_test.go b/cmd/codeaf/displaced_watch_test.go new file mode 100644 index 0000000000..2864408446 --- /dev/null +++ b/cmd/codeaf/displaced_watch_test.go @@ -0,0 +1,178 @@ +package main + +import ( + "context" + "io" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/handoff" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/tui3" +) + +// Contract K1 and K5 require the first Enter after watch displacement to ask before running a turn. +func TestHostedWatchDisplacedWindowAsksBeforeHeartbeat(t *testing.T) { + h := newHostedMoveRig(t) + sleeping := make(chan struct{}, 1) + d, err := h.a.Drive(t.Context(), h.ea, h.c, syncsetup.DriveOptions{DeviceName: "original computer", Sleep: func(ctx context.Context, _ time.Duration) error { + select { + case sleeping <- struct{}{}: + default: + } + <-ctx.Done() + return ctx.Err() + }, OnRenewalChange: func() { + select { + case h.changes <- struct{}{}: + default: + } + }}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = d.Close(context.Background()) }) + sealed, err := d.Store(h.ea).Seal(t.Context(), h.c, cellstore.TurnInfo{}) + if err != nil { + t.Fatal(err) + } + d.Idle() + waitForWatchFixture(t, "publication and send hold", func() bool { + v, err := h.a.Dir.Cell(t.Context(), h.c.ID) + _, fence, holdErr := h.a.SendLease(h.c.ID) + return err == nil && v.Cell.Head == sealed.Turn.ID && holdErr == nil && fence != 0 + }) + <-sleeping + client := h.host(d, h.c, h.work, h.ea) + screen, typed := h.surface(client, h.taker(h.a, h.ea, func(string) string { return h.c.Root }, nil), false) + if err := h.b.Rename(t.Context(), "taking computer"); err != nil { + t.Fatal(err) + } + var homeResult homeWatchTake + homeDone := make(chan struct{}) + go func() { + defer close(homeDone) + c, err := takeLiveChatFromHome(t, h) + homeResult = homeWatchTake{c, err} + }() + t.Cleanup(func() { <-homeDone }) + deadline := time.NewTimer(2 * time.Minute) + defer deadline.Stop() + frames := time.NewTicker(time.Millisecond) + defer frames.Stop() + for !strings.Contains(screen.String(), "continued this chat; this window now only shows it") { + select { + case <-frames.C: + case <-deadline.C: + t.Fatal("watch displacement notice did not arrive") + } + } + h.mu.Lock() + h.requests = nil + h.mu.Unlock() + if _, err := io.WriteString(typed, "keep this question unsent\r"); err != nil { + t.Fatal(err) + } + h.wait("continue card or unexpected completion", func() bool { + return strings.Contains(screen.String(), "Continue this chat here?") || h.modelCalls.Load() != 0 + }) + if h.modelCalls.Load() != 0 || !strings.Contains(screen.String(), "Continue this chat here?") { + t.Fatalf("Enter after the displaced notice ran %d model completions instead of asking", h.modelCalls.Load()) + } + h.mu.Lock() + requests := append([]string(nil), h.requests...) + h.mu.Unlock() + for _, r := range requests { + if strings.HasSuffix(r, "/heartbeat") || strings.HasSuffix(r, "/publish") || strings.HasPrefix(r, "POST /v1/dir/cells/") { + t.Errorf("displaced Enter mutated relay before any heartbeat tick: %s", r) + } + } + select { + case <-homeDone: + if homeResult.err != nil { + t.Fatal(homeResult.err) + } + next, err := h.b.Drive(t.Context(), h.eb, homeResult.cell, syncsetup.DriveOptions{DeviceName: "taking computer", Sleep: func(ctx context.Context, _ time.Duration) error { + <-ctx.Done() + return ctx.Err() + }}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = next.Close(context.Background()) }) + case <-deadline.C: + t.Fatal("Home take did not arrive") + } +} + +type homeWatchTake struct { + cell cell.Cell + err error +} + +func takeLiveChatFromHome(t *testing.T, h *hostedMoveRig) (cell.Cell, error) { + t.Helper() + input, typed := io.Pipe() + ctx, cancel := context.WithCancel(t.Context()) + screen := &surfaceFrameWriter{ready: make(chan struct{})} + done := make(chan error, 1) + result := make(chan copiedTakeResult, 1) + go func() { + done <- tui3.Run(ctx, tui3.Options{Agent: &quietAgent{}, Landing: true, Machines: surfaceMachines{h.b}, DeviceID: h.b.Device.ID(), Takeover: copiedSurfaceTaker{h.taker(h.b, h.eb, func(id string) string { return filepath.Join(h.bRoots, id) }, func(p handoff.Phase) { + if !p.Ended.IsZero() { + t.Logf("Home take %s: %s", p.Name, p.Ended.Sub(p.Started)) + } + }), result}, Input: input, Output: screen, Width: 120, Height: 35, Open: func(work, file string) (tui3.Conversation, error) { + return tui3.Conversation{Agent: &quietAgent{}, Workspace: work, SessionFile: file}, nil + }}) + }() + defer func() { cancel(); input.Close(); typed.Close(); <-done }() + defer func() { + if t.Failed() { + t.Logf("taking Home: %s", screen.String()) + } + }() + <-screen.ready + if _, err := io.WriteString(typed, " "); err != nil { + return cell.Cell{}, err + } + h.wait("running chat on Home", func() bool { return strings.Contains(screen.String(), "running on original computer") }) + if _, err := io.WriteString(typed, "\r"); err != nil { + return cell.Cell{}, err + } + h.wait("Home continue card", func() bool { return strings.Contains(screen.String(), "Continue this chat here?") }) + if _, err := io.WriteString(typed, "1\r"); err != nil { + return cell.Cell{}, err + } + select { + case got := <-result: + if got.err != nil { + return cell.Cell{}, got.err + } + h.wait("Home arrival", func() bool { return strings.Contains(screen.String(), "Moved from original computer") }) + return cell.OpenAt(session.DirOf(got.taken.Transcript), h.c.ID) + case <-time.After(2 * time.Minute): + t.Fatal("Home take did not finish") + return cell.Cell{}, nil + } +} + +func waitForWatchFixture(t *testing.T, what string, ready func() bool) { + t.Helper() + deadline := time.NewTimer(2 * time.Minute) + defer deadline.Stop() + tick := time.NewTicker(time.Millisecond) + defer tick.Stop() + for !ready() { + select { + case <-tick.C: + case <-deadline.C: + t.Fatal("waiting for " + what) + } + } +} 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 18c66cd9af..f35cb6bf30 100644 --- a/internal/manual/chat/continuing-a-chat-on-another-computer.md +++ b/internal/manual/chat/continuing-a-chat-on-another-computer.md @@ -191,6 +191,19 @@ A chat has one holder at a time. To take a chat back from the computer that took 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`, its local copy is not replaced, and its row goes back to `running on `. 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. +## This window only shows the chat — can I still send here + +Once this window learns that another computer holds the chat, Enter opens +**Continue this chat here?** and keeps your words unsent. This applies as soon +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. + +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. + ## 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 diff --git a/internal/syncsetup/displaced_watch_test.go b/internal/syncsetup/displaced_watch_test.go new file mode 100644 index 0000000000..42d5dda004 --- /dev/null +++ b/internal/syncsetup/displaced_watch_test.go @@ -0,0 +1,97 @@ +package syncsetup + +import ( + "context" + "net/http/httptest" + "os" + "testing" + "time" + + "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/directory" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/reqsign" +) + +// Contract K2 retires the hold on watch news before any heartbeat can discover displacement. +func TestWatchDisplacementRetiresTheDriveHoldImmediately(t *testing.T) { + for _, newerLocal := range []bool{false, true} { + t.Run(map[bool]string{false: "another holder", true: "higher local fence"}[newerLocal], func(t *testing.T) { watchDisplacementRetiresHold(t, newerLocal) }) + } +} + +func watchDisplacementRetiresHold(t *testing.T, newerLocal bool) { + celltest.RequireEngine(t) + memory := directory.NewMemory(time.Now) + t.Cleanup(func() { _ = memory.Close() }) + relay := httptest.NewServer(directory.Handler(reqsign.AuthenticateAt(time.Now), func(string) (directory.Directory, error) { return memory, nil })) + t.Cleanup(relay.Close) + t.Setenv(URLVar, relay.URL) + local := t.TempDir() + t.Setenv(home.EnvVar, local) + _, err := identity.Ensure(local) + mustNil(t, err) + s, ok, err := Open(local) + mustNil(t, err) + if !ok { + t.Fatal("native sync did not open") + } + otherHome := t.TempDir() + _, err = identity.Adopt(otherHome, s.Identity, false) + mustNil(t, err) + other, _, err := Open(otherHome) + mustNil(t, err) + mustNil(t, other.Rename(t.Context(), "other computer")) + c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.HostBound}) + mustNil(t, err) + _, err = s.Dir.Create(t.Context(), c.ID, directory.CellInit{}) + mustNil(t, err) + changed := make(chan struct{}, 1) + sleeping := make(chan struct{}, 1) + d, err := s.Drive(t.Context(), cellstore.Engine{}, c, DriveOptions{Sleep: func(ctx context.Context, _ time.Duration) error { + select { + case sleeping <- struct{}{}: + default: + } + <-ctx.Done() + return ctx.Err() + }, OnRenewalChange: func() { + select { + case changed <- struct{}{}: + default: + } + }}) + mustNil(t, err) + t.Cleanup(func() { _ = d.Close(context.Background()) }) + <-sleeping + if _, _, held := d.RenewalStatus(); !held { + t.Fatal("the original writer did not hold the chat") + } + _, fence, err := s.SendLease(c.ID) + mustNil(t, err) + if fence == 0 { + t.Fatal("the original writer had no hold receipt") + } + if newerLocal { + other = s + } + _, err = other.Dir.Acquire(t.Context(), c.ID, directory.AcquireOpts{Force: true, Expect: &directory.LeaseExpectation{Device: s.Device.ID(), Fence: fence}}) + mustNil(t, err) + select { + case <-changed: + case <-time.After(5 * time.Second): + t.Fatal("watch displacement left the drive held until a heartbeat") + } + if _, _, held := d.RenewalStatus(); held { + t.Error("watch displacement left RenewalStatus held") + } + if _, _, err := s.SendLease(c.ID); !os.IsNotExist(err) { + t.Errorf("watch displacement retained its hold file: %v", err) + } + if err := d.Gate(); err == nil { + t.Error("watch displacement left tools enabled") + } +} diff --git a/internal/syncsetup/drive.go b/internal/syncsetup/drive.go index 63fd87cec7..8979724761 100644 --- a/internal/syncsetup/drive.go +++ b/internal/syncsetup/drive.go @@ -56,6 +56,7 @@ type Drive struct { once sync.Once closeErr error publicationDone func() + ownership chan struct{} sent string // fingerprint of the vault as last sent; touched by the flush loop and Close only @@ -76,7 +77,7 @@ 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{})} + d := &Drive{sync: s, files: files, cell: c, opt: opt, done: make(chan struct{}), ownership: make(chan struct{}, 1)} if err := s.putDevice(ctx, opt.DeviceName); err != nil { return nil, err } @@ -106,7 +107,10 @@ func (s *Sync) Drive(ctx context.Context, eng cellstore.Engine, c cell.Cell, opt d.cancel = cancel go func() { defer close(d.done) + var work sync.WaitGroup + work.Go(func() { d.followOwnership(run) }) _ = d.batcher.Run(run) + work.Wait() }() return d, nil } @@ -329,7 +333,7 @@ func (s *Sync) batcher(eng cellstore.Engine, c cell.Cell, drv cellsync.Driving, OnError: d.refusal, Sleep: d.opt.Sleep, Liveness: s.Liveness(), - OnRenewalChange: d.opt.OnRenewalChange, + OnRenewalChange: d.renewalChanged, }, nil } @@ -345,14 +349,9 @@ func (d *Drive) info() cellsync.PublishInfo { // becomeViewer is the Batcher's word that another device took the chat over: // from now on this window only shows it, and says who has it. func (d *Drive) becomeViewer(s cellsync.Superseded) { - device := unknownDevice - if s.By != "" && s.By != unknownDevice { - device = d.sync.deviceName(s.By) - } - line := chatlist.Superseded(device) d.mu.Lock() changed := d.line == "" - d.line = line + d.line = chatlist.Superseded(unknownDevice) expected := sendLease{Cell: d.directoryID, Fence: d.recordedFence} d.mu.Unlock() if err := d.sync.forgetSendLease(d.cell.ID, expected); err != nil { @@ -361,6 +360,14 @@ func (d *Drive) becomeViewer(s cellsync.Superseded) { if changed && d.opt.OnRenewalChange != nil { d.opt.OnRenewalChange() } + device := unknownDevice + if s.By != "" && s.By != unknownDevice { + device = d.sync.deviceName(s.By) + } + line := chatlist.Superseded(device) + d.mu.Lock() + d.line = line + d.mu.Unlock() // Refuse new work immediately, then preserve the pending save before // announcing displacement. A routine ownership check does no fsync (C28). ctx, cancel := context.WithTimeout(context.Background(), cellstore.SealWithin) diff --git a/internal/syncsetup/drive_watch.go b/internal/syncsetup/drive_watch.go new file mode 100644 index 0000000000..235706e573 --- /dev/null +++ b/internal/syncsetup/drive_watch.go @@ -0,0 +1,47 @@ +package syncsetup + +import "context" + +// followOwnership turns directory change news into a fresh reading of this +// writer's hold. Losing ownership cannot wait for a heartbeat or another turn. +func (d *Drive) followOwnership(ctx context.Context) { + feed := d.sync.Follow() + if feed == nil { + return + } + defer feed.Close() + var version uint64 + for { + state := feed.State() + if state.Version > version && d.batcher.Fence() != 0 { + version = state.Version + read, cancel := context.WithTimeout(ctx, requestTimeout) + _ = d.CheckLease(read) + cancel() + } + if _, viewer := d.Viewer(); viewer || state.Refused != nil { + return + } + select { + case _, open := <-feed.Changes(): + if !open { + return + } + case <-ctx.Done(): + return + case <-d.ownership: + } + } +} + +// renewalChanged also wakes a watch reading deferred until the first publish +// established its fence. Ownership news must not be spent before that reply. +func (d *Drive) renewalChanged() { + select { + case d.ownership <- struct{}{}: + default: + } + if d.opt.OnRenewalChange != nil { + d.opt.OnRenewalChange() + } +} diff --git a/internal/tui3/app.go b/internal/tui3/app.go index de3357f35e..c1aed5a563 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -7290,7 +7290,10 @@ func (a *app) take(u session.Usage) { // note answers a UI action or reports something requiring the person's attention. // Engine bookkeeping uses feed.note so its audience is explicit at the producer. -func (a *app) note(text string) { a.noteWritten(text, false, nil) } +func (a *app) note(text string) { + a.blockDisplacedSend(text) + a.noteWritten(text, false, nil) +} // noteWritten is the one body behind all three of the app's note doors, and the // only thing it adds to the feed's own is THE ECHO ONTO HOME'S MESSAGE LINE. diff --git a/internal/tui3/displaced_watch_test.go b/internal/tui3/displaced_watch_test.go new file mode 100644 index 0000000000..82f6039121 --- /dev/null +++ b/internal/tui3/displaced_watch_test.go @@ -0,0 +1,67 @@ +package tui3 + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/chatlist" +) + +type cachedHeldSource struct{ *continuationListing } + +func (*cachedHeldSource) SendLease(string) (string, uint64, error) { return heldCell, 2, nil } + +func (s *cachedHeldSource) RowsForChatSend(ctx context.Context, _ string, _, held bool) ([]chatlist.Row, uint64, bool, error) { + if held { + return []chatlist.Row{{Cell: heldCell, Mine: true, Status: chatlist.Here, Fence: 2}}, 0, true, nil + } + rows, err := s.Rows(ctx) + return rows, 3, false, err +} + +type cachedHeldAgent struct{ *fakeAgent } + +func (cachedHeldAgent) RenewalStatus() (time.Time, time.Duration, bool) { + return time.Time{}, time.Second, true +} + +// Contract K1 gives listing and notice displacement priority over a cached held flag. +func TestKnownDisplacementOutranksTheCachedWriterHold(t *testing.T) { + for _, news := range []string{"listing", "notice"} { + t.Run(news, func(t *testing.T) { + src := &cachedHeldSource{&continuationListing{row: chatlist.Row{Cell: heldCell, Mine: true, Status: chatlist.Here, Fence: 2}}} + a, agent := heldLab(t, src, &fakeTaker{}) + keepSurfaceWorkersInTest(t, a) + a.agent = cachedHeldAgent{agent} + a.closeHome() + src.change(func(row *chatlist.Row) { + row.Status, row.Mine, row.Fence = chatlist.Running, false, 3 + row.Device, row.DeviceID = "other computer", "other" + }) + if news == "listing" { + drain(t, a, a.askMachines()) + } else { + desk := NewNotices() + a.useNotices(desk) + desk.Say(chatlist.Superseded("other computer")) + a.saidNotices() + } + if !strings.Contains(plain(lastNote(t, a)), "continued this chat") { + t.Fatal("displacement was not shown") + } + a.input.setText("keep these words unsent") + drive(t, a, key("enter")) + if len(agent.sent) != 0 { + t.Fatalf("known displaced window sent %v", agent.sent) + } + if _, card := a.homeAsking(); !card { + t.Fatal("known displaced window did not raise its continue card") + } + if got := string(a.mainComposer().box.value); got != "keep these words unsent" { + t.Errorf("unsent composer = %q", got) + } + }) + } +} diff --git a/internal/tui3/homepanel_heldelsewhere.go b/internal/tui3/homepanel_heldelsewhere.go index 65c66a4e29..a8cef14951 100644 --- a/internal/tui3/homepanel_heldelsewhere.go +++ b/internal/tui3/homepanel_heldelsewhere.go @@ -87,6 +87,7 @@ func (a *app) needsOwnershipRead() bool { func (a *app) checkBeforeSendWith(resume func() tea.Cmd) tea.Cmd { src, file, draft, agent := a.machines, a.file, a.input.String(), a.agent offline := a.machineRead.down + blocked := a.machineRead.sendHolds[filepath.Base(session.DirOf(file))].blocked chips := slices.Clone(a.chips) var first *composerState if a.conversationPreview != nil && !a.conversationOpening { @@ -106,6 +107,9 @@ func (a *app) checkBeforeSendWith(resume func() tea.Cmd) tea.Cmd { _, _, holding = writer.RenewalStatus() notHeld = !holding } + if blocked { + holding, notHeld = false, true + } if scoped, ok := src.(interface { RowsForChatSend(context.Context, string, bool, bool) ([]chatlist.Row, uint64, bool, error) }); ok { @@ -296,6 +300,10 @@ func (a *app) rememberSendHolds(msg homeMachinesMsg) { } for _, row := range msg.rows { held, known := a.machineRead.sendHolds[row.Cell] + if heldElsewhere(row) && (!known || row.Fence >= held.fence) { + held.blocked = true + a.machineRead.sendHolds[row.Cell] = held + } if row.Status != chatlist.Here || held.blocked || held.failed { continue } @@ -307,6 +315,23 @@ func (a *app) rememberSendHolds(msg homeMachinesMsg) { } } +var displacedSendSuffix = chatlist.Superseded("") + +// blockDisplacedSend keeps a shown displacement stronger than a cached writer +// reading. Only a successful continuation can make this window writable again. +func (a *app) blockDisplacedSend(text string) { + if a.file == "" || !strings.HasSuffix(text, displacedSendSuffix) { + return + } + if a.machineRead.sendHolds == nil { + a.machineRead.sendHolds = map[string]sendHold{} + } + id := filepath.Base(session.DirOf(a.file)) + held := a.machineRead.sendHolds[id] + held.blocked = true + a.machineRead.sendHolds[id] = held +} + func (a *app) failedTake(msg homeTakenMsg) string { if a.machineRead.sendHolds == nil { a.machineRead.sendHolds = map[string]sendHold{} From 9a132e9a2b3ffc65ae7298c6c1b6b1c840d188ff Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 27 Oct 2026 03:01:09 -0400 Subject: [PATCH 25/28] fix(tasks): a moved task's history is read below a symlinked folder The task history reader refused a symlink anywhere above the conversation, so on a computer whose home sits below an aliased folder (macOS's /tmp is /private/tmp) a moved task's worker transcript and steps could never be read, and its page showed only the summary. The trusted conversation location is now resolved first, while the task's own state, history, worker and trajectory files still refuse any redirect. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/manual/chat/tasks.md | 10 + .../session/moved_run_history_alias_test.go | 76 +++++++ internal/session/task_room.go | 2 +- internal/session/task_run_history.go | 31 ++- internal/tui3/moved_task_hosted_test.go | 209 ++++++++++++++++++ 5 files changed, 317 insertions(+), 11 deletions(-) create mode 100644 internal/session/moved_run_history_alias_test.go create mode 100644 internal/tui3/moved_task_hosted_test.go diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index 02457a88b5..565bce0143 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -5287,6 +5287,16 @@ only completed runs and no plan nodes. The activity page lists the chat once and uses the task's settled state. When the transferred local worker journal is present, its transcript remains readable without the original plan database. +## Read finished task steps and transcript after moving to another computer + +Open `/task`, select the finished task and press `enter`. Its saved steps and +worker replies open expanded, including on macOS and when the chat lives under +a temporary folder. The `transcript` tab shows that worker's conversation; +`work` shows the working copy when it is available. If the copy is gone, the tab +says `the run's working copy is not here any more`; return to `transcript` to +keep reading. Finished history is read-only, offers no task actions, and never +starts the task again. + ## What does queued behind it mean? — open tasks are waiting on this one `· 2 queued behind it` on a row means two open tasks directly wait for that task diff --git a/internal/session/moved_run_history_alias_test.go b/internal/session/moved_run_history_alias_test.go new file mode 100644 index 0000000000..4da1bd7881 --- /dev/null +++ b/internal/session/moved_run_history_alias_test.go @@ -0,0 +1,76 @@ +package session + +import ( + "os" + "path/filepath" + "testing" +) + +// Contract L1 accepts the chat's location while refusing redirects within its history. +func TestMovedTaskHistoryUnderAnAliasRejectsInternalRedirects(t *testing.T) { + for _, redirect := range []string{"state", "history", "task", "worker", "trajectory"} { + t.Run(redirect, func(t *testing.T) { + physical := t.TempDir() + alias := filepath.Join(t.TempDir(), "tmp") + if err := os.Symlink(physical, alias); err != nil { + t.Fatal(err) + } + place := Place{Dir: filepath.Join(alias, "copied-chat"), Workspace: t.TempDir()} + dir := filepath.Join(place.Dir, ".cell", "tasks", "1") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + writeCheckpoint(t, place.Tasks(), taskDocument{Type: taskDocumentType, Version: taskFileVersion, Seq: 1, Runs: []runRecord{{ID: 1, Title: "Write a greeting", State: TaskDone, PlanTask: "t-1"}}}) + worker := filepath.Join(dir, "20261027-011448.092944_worker.jsonl") + trajectory := filepath.Join(dir, "trajectory.jsonl") + for path, body := range map[string]string{ + worker: "{\"type\":\"session\",\"version\":1,\"id\":\"worker\",\"model\":\"saved/model\"}\n{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"Greeting saved.\"}\n", + trajectory: "{\"kind\":\"step\",\"step\":1,\"command\":\"printf greeting\",\"observation\":\"greeting\"}\n", + } { + if err := os.WriteFile(path, []byte(body), 0600); err != nil { + t.Fatal(err) + } + } + agent := journalAgent(t, place) + page, found := agent.PlanTaskPage("t-1") + if !found || len(page.Worker) != 1 || len(page.Steps) != 1 { + t.Fatalf("ordinary history beneath an ancestor alias is unreadable: %+v", page) + } + if got := agent.TaskJournal(1); got != worker { + t.Fatalf("worker path changed: %q, want %q", got, worker) + } + if rows := RebuiltTaskRows(place.Dir); len(rows) != 1 || rows[0].TranscriptURI != taskURI(worker) || rows[0].Model != "saved/model" { + t.Fatalf("received activity row lost its worker: %+v", rows) + } + target := map[string]string{ + "state": filepath.Join(place.Dir, ".cell"), + "history": place.NodeJournals(), + "task": dir, + "worker": worker, + "trajectory": trajectory, + }[redirect] + outside := filepath.Join(t.TempDir(), "retained") + if err := os.Rename(target, outside); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, target); err != nil { + t.Fatal(err) + } + page, found = agent.PlanTaskPage("t-1") + if !found || !page.Historical { + t.Fatal("the finished receipt disappeared") + } + if redirect != "trajectory" { + if len(page.Worker) != 0 || agent.TaskJournal(1) != "" { + t.Error("redirected worker entered the task history") + } + if rows := RebuiltTaskRows(place.Dir); len(rows) != 1 || rows[0].TranscriptURI != "" { + t.Errorf("redirected worker entered the activity index: %+v", rows) + } + } + if redirect != "worker" && len(page.Steps) != 0 { + t.Error("redirected trajectory entered the task history") + } + }) + } +} diff --git a/internal/session/task_room.go b/internal/session/task_room.go index 7a300e4c43..f716da2b20 100644 --- a/internal/session/task_room.go +++ b/internal/session/task_room.go @@ -882,7 +882,7 @@ func (a *Agent) TaskJournal(id uint64) string { a.mu.Lock() place, session := a.familyPlace(nil), a.sessionID() a.mu.Unlock() - return findWorkerHistory(taskJournalDir(place, session), id).journal + return findWorkerHistory(place, session, id).journal } if path := node.journalPath(); path != "" { return path diff --git a/internal/session/task_run_history.go b/internal/session/task_run_history.go index a698bb5c83..68f2e9032b 100644 --- a/internal/session/task_run_history.go +++ b/internal/session/task_run_history.go @@ -13,9 +13,20 @@ import ( type localWorkerHistory struct{ journal, model string } +// The conversation location may have a symlink above it. Resolve that trusted +// location before rejecting redirects within its retained task history. +func taskHistoryDir(place Place, session string) string { + if place.Dir != "" { + place.Dir = canonicalPath(place.Dir) + } + return taskJournalDir(place, session) +} + // Plan workers have their own numeric task directory. Never search another // task, accept helper logs, or reuse an absolute path from a moved checkpoint. -func findWorkerHistory(base string, id uint64) localWorkerHistory { +func findWorkerHistory(place Place, session string, id uint64) localWorkerHistory { + base := taskHistoryDir(place, session) + journalDir := taskJournalDir(place, session) relative := strconv.FormatUint(id, 10) directory, err := openTaskHistoryPath(base, relative, true) if err != nil { @@ -38,12 +49,12 @@ func findWorkerHistory(base string, id uint64) localWorkerHistory { } header, ok := readWorkerHeader(base, filepath.Join(relative, name)) if ok { - return localWorkerHistory{journal: filepath.Join(base, relative, name), model: header.Model} + return localWorkerHistory{journal: filepath.Join(journalDir, relative, name), model: header.Model} } } worker := filepath.Join(relative, "worker.jsonl") if header, ok := readWorkerHeader(base, worker); ok { - return localWorkerHistory{journal: filepath.Join(base, worker), model: header.Model} + return localWorkerHistory{journal: filepath.Join(journalDir, worker), model: header.Model} } return localWorkerHistory{} } @@ -73,7 +84,7 @@ func withLocalWorkerHistory(entry TaskIndexEntry, place Place) TaskIndexEntry { if err != nil || id == 0 { return entry } - history := findWorkerHistory(taskJournalDir(place, entry.SessionID), id) + history := findWorkerHistory(place, entry.SessionID, id) entry.TranscriptURI = taskURI(history.journal) if entry.Model == "" { entry.Model = history.model @@ -91,7 +102,7 @@ func (a *Agent) planWorkerRecordedModel(taskID string) string { a.mu.Lock() place, session := a.familyPlace(nil), a.sessionID() a.mu.Unlock() - return findWorkerHistory(taskJournalDir(place, session), id).model + return findWorkerHistory(place, session, id).model } // Resolve retained worker identities before graph recovery takes its lock. @@ -100,13 +111,12 @@ func (a *Agent) restoreWorkerHistory(document *taskDocument) { a.mu.Lock() place, session := a.familyPlace(nil), a.sessionID() a.mu.Unlock() - base := taskJournalDir(place, session) for i := range document.Runs { record := &document.Runs[i] if record.Kind != "" || record.ID == 0 || record.Model != "" && record.PlanTask != "" { continue } - history := findWorkerHistory(base, record.ID) + history := findWorkerHistory(place, session, record.ID) if record.Model == "" { record.Model = history.model } @@ -169,14 +179,15 @@ func (a *Agent) historicalPlanPage(id string) (PlanTaskPage, bool) { page.Description = notice.PendingRun.Brief } a.mu.Lock() - base := taskJournalDir(a.familyPlace(nil), a.sessionID()) + place, session := a.familyPlace(nil), a.sessionID() a.mu.Unlock() + base := taskHistoryDir(place, session) relative := filepath.Join(strconv.FormatUint(notice.ID, 10), planTrajectoryFile) page.Steps = planStepDisplayFactsForPage(decodePlanTrajectory(readHistoricalFile(base, relative)), a.planDisplayRunCopy()) page.Row.Steps = len(page.Steps) - history := findWorkerHistory(base, notice.ID) + history := findWorkerHistory(place, session, notice.ID) if history.journal != "" { - relative, err := filepath.Rel(base, history.journal) + relative, err := filepath.Rel(taskJournalDir(place, session), history.journal) if err == nil { page.Worker = ReadTranscriptBytes(readHistoricalFile(base, relative)).Entries } diff --git a/internal/tui3/moved_task_hosted_test.go b/internal/tui3/moved_task_hosted_test.go new file mode 100644 index 0000000000..567951ebd6 --- /dev/null +++ b/internal/tui3/moved_task_hosted_test.go @@ -0,0 +1,209 @@ +package tui3 + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/enginehost" + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/session" +) + +// Contracts L1 and L2 keep received finished work readable without starting it. +func TestHostedMovedFinishedTaskRealDoorShowsStepsAndWorker(t *testing.T) { + for _, identity := range []string{"t-1", ""} { + name := "saved plan identity" + if identity == "" { + name = "no plan identity" + } + t.Run(name, func(t *testing.T) { + for _, roster := range []bool{true, false} { + name := "index first" + if roster { + name = "roster first" + } + t.Run(name, func(t *testing.T) { hostedMovedFinishedTask(t, identity, roster) }) + } + }) + } +} + +func hostedMovedFinishedTask(t *testing.T, identity string, roster bool) { + t.Helper() + home, err := os.MkdirTemp("/tmp", "codeaf-l-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(home) }) + t.Setenv("CODEAF_HOME", home) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "0") + physical := t.TempDir() + alias := filepath.Join(t.TempDir(), "tmp") + // An alias above the chat mirrors temporary folders on systems whose + // temporary root is a symlink, while the retained task files remain ordinary. + if err := os.Symlink(physical, alias); err != nil { + t.Fatal(err) + } + root := filepath.Join(alias, "1234567890abcdef") + place := session.Place{Dir: root, Workspace: filepath.Join(root, "work")} + files := map[string]string{ + filepath.Join(root, ".cell", "tasks.json"): `{"type":"tasks","version":1,"seq":1,"nodes":[],"runs":[{"id":1,"title":"Create hello-task.txt with exact content","state":"done","planTask":"t-1","crewState":{"version":1,"routed":true,"Decision":{}},"report":"Created the greeting file.","result":"Created the greeting file.","model":"z-ai/glm-5.3-flash","elapsed_ms":12000,"costUsd":0.07,"startedAt":"2026-10-27T01:14:48Z","endedAt":"2026-10-27T01:15:00Z"}]}`, + filepath.Join(root, ".cell", "transcript.jsonl"): `{"type":"session","version":1,"id":"1234567890abcdef","model":"stub/chat"}` + "\n", + filepath.Join(root, ".cell", "tasks", "1", "20261027-011448.092944_worker.jsonl"): `{"type":"session","version":1,"id":"worker","model":"z-ai/glm-5.3-flash"} +{"type":"message","role":"assistant","content":"The worker wrote hi from a task.\n\nThe saved conversation stays readable.","toolCalls":[{"id":"finish","function":{"name":"bash","arguments":"{\"command\":\"plandb done 1 --result 'Created the greeting file.'\"}"}}]} +{"type":"message","role":"tool","toolCallId":"finish","content":"done"} +`, + filepath.Join(root, ".cell", "tasks", "1", "trajectory.jsonl"): "{\"kind\":\"step\",\"step\":1,\"command\":\"printf 'hi from a task' > hello-task.txt\",\"observation\":\"greeting saved\"}\n", + filepath.Join(root, ".cell", "tasks", "ao88ym", "20261027-011504.157512_worker.jsonl"): "{\"type\":\"session\",\"version\":1,\"id\":\"checker\"}\n{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"The checker reply belongs to another task.\"}\n", + filepath.Join(root, ".cell", "tasks", "ao88ym", "trajectory.jsonl"): "{\"kind\":\"step\",\"step\":1,\"command\":\"cat checker-only.txt\",\"observation\":\"checker output\"}\n", + } + if identity == "" { + path := filepath.Join(root, ".cell", "tasks.json") + files[path] = strings.Replace(files[path], `"planTask":"t-1",`, "", 1) + } + for path, body := range files { + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0600); err != nil { + t.Fatal(err) + } + } + if err := os.MkdirAll(place.Workspace, 0700); err != nil { + t.Fatal(err) + } + var modelCalls atomic.Int64 + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/models/") { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"endpoints":[]}}`)) + return + } + modelCalls.Add(1) + w.WriteHeader(http.StatusServiceUnavailable) + })) + t.Cleanup(provider.Close) + engine, err := session.New(session.Config{Workspace: place.Workspace, Place: place, SessionFile: place.Transcript(), ProfileDir: t.TempDir(), Model: "stub/chat", APIKey: "stub-key", BaseURL: provider.URL}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = engine.Close() }) + stopped := make(chan error, 1) + t.Cleanup(func() { + if err := engine.Close(); err != nil { + t.Errorf("close finished conversation: %v", err) + } + if _, err := enginehost.Stop(place.Workspace); err != nil { + t.Errorf("stop fixture host: %v", err) + } + select { + case err := <-stopped: + if err != nil { + t.Errorf("host stopped: %v", err) + } + case <-time.After(10 * time.Second): + t.Error("host did not stop") + } + }) + conn, err := enginehost.Attach(place.Workspace, func() error { + go func() { + stopped <- enginehost.Run(place.Workspace, enginehost.Options{ + Boot: func(remote.Hello) (*remote.Engine, error) { + return &remote.Engine{Agent: engine, SessionFile: place.Transcript(), Workspace: place.Workspace, Place: place}, nil + }, + }) + }() + return nil + }) + if err != nil { + t.Fatal(err) + } + client, err := remote.Dial(conn, "", remote.Hello{Version: remote.Version}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + a := newTestApp(client.Agent()) + a.width, a.height = 180, 48 + a.homeRoot = home + a.file, a.title, a.workspace = place.Transcript(), "background task creates hello file", place.Workspace + a.clock = func() time.Time { return time.Date(2026, 10, 27, 1, 16, 0, 0, time.UTC) } + a.comp.tasks = session.RebuiltTaskRows(place.Dir) + for path := range files { + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + files[path] = string(body) + } + if roster { + events, stop := client.Agent().WatchTaskUpdates() + defer stop() + select { + case event := <-events: + a.taskUpdate(event) + case <-time.After(5 * time.Second): + t.Fatal("finished task missing from restored roster") + } + } + a.input.setText("/task") + drive(t, a, key("enter")) + openTaskFolds(a) + if len(a.taskSheet.reading.items) != 1 { + t.Fatalf("Tasks must list the run once:\n%s", taskSheetText(a)) + } + t.Logf("Tasks screen:\n%s", taskSheetText(a)) + a.taskSheetPointAt(a.taskSheet.reading.items[0].entry) + drive(t, a, key("enter")) + screen := planRoomText(t, a) + t.Logf("Task screen:\n%s", screen) + for _, want := range []string{"Create hello-task.txt with exact content", "done", "printf 'hi from a task'", "The worker wrote hi from a task", "The saved conversation stays readable."} { + if !strings.Contains(screen, want) { + t.Errorf("task page missing %q", want) + } + } + if a.room == nil || a.room.plan == nil || !a.room.plan.page.Historical || !a.room.done { + t.Fatal("the task row did not open a finished historical page") + } + t.Logf("Enter opened historical page %q", a.room.plan.id) + for _, unwanted := range []string{"checker reply", "checker-only.txt"} { + if strings.Contains(screen, unwanted) { + t.Errorf("task page includes another task's history %q", unwanted) + } + } + if words := a.tasksPlanKeyWords(a.room.plan.page.Row); len(words) != 0 { + t.Errorf("finished task offers actions: %v", words) + } + drive(t, a, key("tab")) + if !strings.Contains(planRoomText(t, a), "the run's working copy is not here any more") { + t.Errorf("missing working copy did not explain itself:\n%s", planRoomText(t, a)) + } + drive(t, a, key("tab")) + screen = planRoomText(t, a) + if !strings.Contains(screen, "The saved conversation stays readable.") || !strings.Contains(screen, "printf 'hi from a task'") { + t.Errorf("returning to transcript hid saved work:\n%s", screen) + } + drive(t, a, key("r"), key("R")) + if !a.room.done || a.input.String() != "rR" { + t.Error("finished task accepted a restart action") + } + drive(t, a, key("enter")) + if !a.room.done || !strings.Contains(planRoomText(t, a), "this task has finished") { + t.Error("finished task did not refuse new work") + } + for path, want := range files { + body, err := os.ReadFile(path) + if err != nil || string(body) != want { + t.Errorf("reading finished work changed %s: %v", filepath.Base(path), err) + } + } + if calls := modelCalls.Load(); calls != 0 { + t.Errorf("reading finished work made %d model calls", calls) + } +} From 706919557b37513603a5b5d9f2d0cfdcb3312358 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 27 Oct 2026 03:22:43 -0400 Subject: [PATCH 26/28] fix(chat): a card shows only its own chat's words, and Esc returns to the window - Draft storage is keyed per chat, so two copied chats from different projects on one computer never share a draft, and a chat keeps its draft through a move into its copy folder. - A continue card shows only the words of the window that raised it and that chat's own project; a card opened from a Home row shows that chat's draft or nothing. - Esc ("leave it there") on a card raised from a chat window goes back to that window with the words still in its box. - cluster status reads under one five-second ceiling, and Home says other machines did not answer in time on a deadline instead of calling them unreachable. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/chatv3.go | 2 +- cmd/codeaf/chatv3_beside.go | 6 +- cmd/codeaf/chatv3_draft_identity_test.go | 48 ++++++++++ cmd/codeaf/chatv3_host.go | 2 +- cmd/codeaf/chatv3_local.go | 9 +- cmd/codeaf/chatv3_process.go | 2 +- cmd/codeaf/cluster.go | 8 ++ cmd/codeaf/cluster_status_bound_test.go | 37 ++++++++ internal/chatlist/copy.go | 1 + .../continuing-a-chat-on-another-computer.md | 8 ++ .../manual/chat/coordination-and-workers.md | 2 +- internal/manual/chat/keys.md | 17 ++-- internal/tui3/continue_card.go | 47 ++++++++++ internal/tui3/continue_card_scope_test.go | 91 +++++++++++++++++++ internal/tui3/continue_draft_store.go | 2 +- internal/tui3/copied_chat_draft_test.go | 64 +++++++++++++ internal/tui3/draft.go | 50 +++++----- internal/tui3/draftkeep.go | 45 ++++++--- internal/tui3/draftkeep_test.go | 9 +- internal/tui3/homedraft.go | 3 + internal/tui3/homepanel_continue.go | 7 +- internal/tui3/homepanel_machines.go | 20 +++- internal/tui3/ownership_deadline_test.go | 26 ++++++ internal/tui3/question.go | 2 + internal/tui3/questionpanel.go | 4 +- 25 files changed, 436 insertions(+), 76 deletions(-) create mode 100644 cmd/codeaf/chatv3_draft_identity_test.go create mode 100644 cmd/codeaf/cluster_status_bound_test.go create mode 100644 internal/tui3/continue_card.go create mode 100644 internal/tui3/continue_card_scope_test.go create mode 100644 internal/tui3/copied_chat_draft_test.go create mode 100644 internal/tui3/ownership_deadline_test.go diff --git a/cmd/codeaf/chatv3.go b/cmd/codeaf/chatv3.go index 1a46692383..073899addd 100644 --- a/cmd/codeaf/chatv3.go +++ b/cmd/codeaf/chatv3.go @@ -483,7 +483,7 @@ func openChatV3(name string, args []string, pickSession bool) error { draft := "" if keepDraft { if dir, err := v3Dir(); err == nil { - draft = tui3.DraftFile(dir, workspace) + draft = tui3.ChatDraftFile(dir, "", workspace, cfg.SessionFile) } } // THE AGENT-BUILDING SEAM (chatv3_process.go's [v3Seam]). Every conversation diff --git a/cmd/codeaf/chatv3_beside.go b/cmd/codeaf/chatv3_beside.go index b3f436d857..202c1f273e 100644 --- a/cmd/codeaf/chatv3_beside.go +++ b/cmd/codeaf/chatv3_beside.go @@ -355,13 +355,17 @@ func (f *engineFleet) takeAll() []*engineConn { func (f *engineFleet) bundle(conn *engineConn, welcome remote.Welcome) tui3.Conversation { agent := &besideAgent{countingAgent: &countingAgent{Agent: conn.client.Agent()}, conn: conn, fleet: f} link := hostLink(newHostSeams(conn.client)) + draft := "" + if f.machine.draft != "" { + draft = tui3.ChatDraftFile(filepath.Dir(f.machine.draft), f.dest, welcome.Workspace, welcome.SessionFile) + } conv := tui3.Conversation{ Agent: agent, SessionFile: welcome.SessionFile, Workspace: welcome.Workspace, Resumed: welcome.Resumed, Notice: hostEntryNotice(welcome), - DraftFile: f.machine.draft, + DraftFile: draft, History: f.machine.history, Link: &link, TaskRoom: agent.TaskRoom, diff --git a/cmd/codeaf/chatv3_draft_identity_test.go b/cmd/codeaf/chatv3_draft_identity_test.go new file mode 100644 index 0000000000..c1be4bbff4 --- /dev/null +++ b/cmd/codeaf/chatv3_draft_identity_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/remote" +) + +// Contract J23.2 gives copied chats separate draft keys that survive folder moves. +func TestCopiedChatDoorsKeepDistinctStableDraftKeys(t *testing.T) { + profile := t.TempDir() + t.Setenv("CODEAF_HOME", profile) + t.Setenv("CODEAF_PROFILE_DIR", profile) + fleet := onePipeFleet("", hostedClient(t)) + t.Cleanup(fleet.closeAll) + welcome := remote.Welcome{Version: remote.Version, ProfileDir: profile} + keyOf := func(path string) string { + t.Helper() + parts := strings.Split(filepath.Base(path), "-") + if len(parts) != 4 { + t.Fatalf("unexpected draft name: %q", path) + } + return parts[1] + } + keyFor := func(file string) string { + t.Helper() + welcome.SessionFile = file + opts, _ := hostOptions(fleet, welcome, false) + return keyOf(opts.DraftFile) + } + first := keyFor("/copies/ws9/01J0000000000000000000000A/.cell/transcript.jsonl") + second := keyFor("/copies/ws/01J0000000000000000000000B/.cell/transcript.jsonl") + if first == second { + t.Fatal("two copied chats with no reported workspace share a draft key") + } + if moved := keyFor("/copies/another-project/01J0000000000000000000000A/.cell/transcript.jsonl"); moved != first { + t.Fatal("moving a copied chat changed its draft key") + } + welcome.SessionFile = "/copies/ws9/01J0000000000000000000000A/.cell/transcript.jsonl" + openedFirst := fleet.bundle(fleet.boot, welcome) + welcome.SessionFile = "/copies/ws/01J0000000000000000000000B/.cell/transcript.jsonl" + openedSecond := fleet.bundle(fleet.boot, welcome) + if keyOf(openedFirst.DraftFile) != first || keyOf(openedSecond.DraftFile) != second || openedFirst.DraftFile == openedSecond.DraftFile { + t.Fatal("conversations opened beside each other reused the boot chat's draft") + } +} diff --git a/cmd/codeaf/chatv3_host.go b/cmd/codeaf/chatv3_host.go index 9a6a7ec5de..78a432970e 100644 --- a/cmd/codeaf/chatv3_host.go +++ b/cmd/codeaf/chatv3_host.go @@ -885,7 +885,7 @@ func hostOptions(fleet *engineFleet, welcome remote.Welcome, pick bool) (tui3.Op if dir, err := v3Dir(); err == nil { store := history.New(filepath.Join(dir, "history.jsonl")) options.History = store - options.DraftFile = tui3.DraftFile(dir, dest+":"+welcome.Workspace) + options.DraftFile = tui3.ChatDraftFile(dir, dest, welcome.Workspace, welcome.SessionFile) } // ── AND THE DOORS ONTO ANOTHER CONVERSATION ───────────────────────────── // diff --git a/cmd/codeaf/chatv3_local.go b/cmd/codeaf/chatv3_local.go index f9713feded..b5fc049365 100644 --- a/cmd/codeaf/chatv3_local.go +++ b/cmd/codeaf/chatv3_local.go @@ -365,13 +365,10 @@ func openChatV3Local(launch localLaunch) error { // door has no engine to ask about. Both keep the road they had // ([tui3.Options.EngineAnswers]). options.EngineAnswers = v3HostAnswers - // The draft this terminal has half-typed is keyed by the workspace and NOT - // by a machine, because there is no machine: a person who takes the host - // road today and the in-process door tomorrow is in the same place both - // times, and a key with an empty machine glued to the front of it would - // hide their own unsent words from them. + // The local engine and the in-process door share the same chat identity, + // so changing how the chat opens cannot hide its unsent words. if dir, err := v3Dir(); err == nil { - options.DraftFile = tui3.DraftFile(dir, welcome.Workspace) + options.DraftFile = tui3.ChatDraftFile(dir, "", welcome.Workspace, welcome.SessionFile) } // THIS IS A WINDOW ON THIS MACHINE, whose automations it shows: it holds // its own presence here rather than asking the engine to, because the diff --git a/cmd/codeaf/chatv3_process.go b/cmd/codeaf/chatv3_process.go index addc99eef6..4a20b57a19 100644 --- a/cmd/codeaf/chatv3_process.go +++ b/cmd/codeaf/chatv3_process.go @@ -931,7 +931,7 @@ func (s *v3Seam) bundle(agent v3Live, launch *v3Launch, cfg session.Config, draft := "" if keepDraft { if dir, err := v3Dir(); err == nil { - draft = tui3.DraftFile(dir, workspace) + draft = tui3.ChatDraftFile(dir, "", workspace, cfg.SessionFile) } } var recall tui3.History diff --git a/cmd/codeaf/cluster.go b/cmd/codeaf/cluster.go index f0af0ea370..a44d6a2c32 100644 --- a/cmd/codeaf/cluster.go +++ b/cmd/codeaf/cluster.go @@ -12,6 +12,7 @@ import ( "github.com/Agent-Field/codeaf/internal/syncsetup" "io" "os" + "time" ) const clusterHelp = `codeaf cluster status @@ -27,6 +28,8 @@ type clusterDoor interface { LeadCluster(context.Context, uint64) (cs.LeadershipReceipt, error) } +const clusterStatusTimeout = 5 * time.Second + func runCluster(args []string) error { if askedForHelp(args) { fmt.Fprint(usageOut, clusterHelp) @@ -55,6 +58,11 @@ func runCluster(args []string) error { // store open that ignores its context cannot hold the terminal past it, and an // answer that arrives late never reaches the terminal at all. func clusterCommand(ctx context.Context, c clusterDoor, verb string, out io.Writer) error { + if verb == "status" { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, clusterStatusTimeout) + defer cancel() + } return boundedClusterOutput(ctx, out, func(ctx context.Context, out io.Writer) error { if verb == "lead" { r, err := c.LeadCluster(ctx, 0) diff --git a/cmd/codeaf/cluster_status_bound_test.go b/cmd/codeaf/cluster_status_bound_test.go new file mode 100644 index 0000000000..a9dd5f33a4 --- /dev/null +++ b/cmd/codeaf/cluster_status_bound_test.go @@ -0,0 +1,37 @@ +package main + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/clusterctl" +) + +type deadlineStatusDoor struct { + clusterFixture + remaining time.Duration +} + +func (s *deadlineStatusDoor) ClusterStatus(ctx context.Context) (clusterctl.Status, error) { + if deadline, ok := ctx.Deadline(); ok { + s.remaining = time.Until(deadline) + } + return clusterctl.Status{}, context.DeadlineExceeded +} + +// Contract J22 bounds a status read and reports its deadline without blaming the network. +func TestClusterStatusUsesOneShortReadDeadline(t *testing.T) { + door := &deadlineStatusDoor{} + var output bytes.Buffer + err := clusterCommand(t.Context(), door, "status", &output) + if door.remaining <= 0 || door.remaining > 5*time.Second { + t.Errorf("status read deadline = %s, want at most five seconds", door.remaining) + } + if !errors.Is(err, context.DeadlineExceeded) || !strings.Contains(err.Error(), "did not answer in time") || strings.Contains(err.Error(), "cannot be reached") || output.Len() != 0 { + t.Fatalf("deadline response = %v, output = %q", err, output.String()) + } +} diff --git a/internal/chatlist/copy.go b/internal/chatlist/copy.go index 3971ff5812..cfe5ae3005 100644 --- a/internal/chatlist/copy.go +++ b/internal/chatlist/copy.go @@ -18,6 +18,7 @@ const ( NoIdentity = "this machine has no identity yet: codeaf identity import" SyncOff = "sync is off: set CODEAF_SYNC_URL to your sync address" Unreachable = "other machines unreachable" + ReadTimedOut = "other machines did not answer in time" SyncPaused = "this chat is not syncing because other machines are unreachable; it keeps working here" SyncRestored = "this chat is syncing again" ClockOff = "this computer's clock is off by more than 5 minutes" 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 f35cb6bf30..d0854f83c2 100644 --- a/internal/manual/chat/continuing-a-chat-on-another-computer.md +++ b/internal/manual/chat/continuing-a-chat-on-another-computer.md @@ -51,6 +51,14 @@ Moving chats and pairing are available by default on Linux, macOS and native Win On `Continue this chat here?`, `leave it there` closes the question without moving the chat. Your typed words stay in the box and nothing is sent. If the question opened because you tried to send from an older window, this answer is already selected. Choose `1 continue here` to bring the chat here before sending. +## Esc on a continue card returns to my chat, and each card keeps its own draft + +If Enter in a displaced chat raises the continue card, `esc` or `2 leave it there` returns to that same chat window with its full draft still in the box. If you opened the card from a Home row, leaving the card keeps you on Home. + +A continue card previews only that chat's unsent words, or nothing when no draft is available. Its project foot belongs to the selected chat. Words typed in another chat never appear on this card. Saved drafts belong to their chat even when a copied chat moves to a different project folder; opening another copy cannot adopt them. + +Home says `other machines did not answer in time` when its ownership listing reaches a deadline. Your words stay unsent. This sentence does not establish a network failure. + ## Why does continue here say this chat was last held here When this computer was the chat's last holder and its hold expired, the card says `this chat was last held here; last saved turn 2h ago`, with the age of its saved turn. Choose `continue here` to resume it. The landing says `Moved here` and never names this computer as somewhere the chat moved from. diff --git a/internal/manual/chat/coordination-and-workers.md b/internal/manual/chat/coordination-and-workers.md index 924687f760..58af353b43 100644 --- a/internal/manual/chat/coordination-and-workers.md +++ b/internal/manual/chat/coordination-and-workers.md @@ -13,7 +13,7 @@ A phone would need a supported client, persistent storage, enrollment and a live ## Cluster status did not answer in time — slow storage or sync service -`codeaf cluster status` has a thirty-second command ceiling, including local +`codeaf cluster status` has a five-second command ceiling, including local storage work. A computer already joined is read from the service without reopening its local member store. A deadline says `the sync service at
did not answer in time — try again`. diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 16a4a3e43a..b576f7a687 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -1223,14 +1223,15 @@ that has moved on. There is nothing to press; it is automatic. as `ctrl+j` on some terminals; only the latter opens a blank line, and nothing on the frame draws them. One kept on disk used to be adopted by the next window in the directory, which then opened with a box that looked empty, was not, and refused `space space` for home. -- The file is keyed by the directory plus this process's id, and is written with mode - 0600. -- At startup, if this window's own draft file is missing, codeaf takes the newest - draft in the same directory whose process is no longer running, moves it into this - window's name, and loads it. Files belonging to a process that is still alive are - never touched, and older orphans are left where they are. An empty orphan is - deleted. A process id that cannot be checked is treated as still alive — codeaf - errs toward leaving your sentence on disk. +- The file is keyed by the chat and this window, and is written with mode 0600. + Copied chats have separate keys across projects, and the same chat keeps its + identity when its copy moves to another folder. +- At startup, codeaf restores that chat's newest saved draft from a window whose + process is no longer running. Another chat's saved words are never adopted. + Older plain drafts without a recorded chat can still be recovered for older + conversations in the same project. Files belonging to a process that is still + alive are never touched, and older drafts are left where they are. A process id + that cannot be checked is treated as still alive. - CR and CRLF in a restored draft are normalised to LF. - No draft is kept at all if codeaf was started without a draft file. diff --git a/internal/tui3/continue_card.go b/internal/tui3/continue_card.go new file mode 100644 index 0000000000..e6b18d822f --- /dev/null +++ b/internal/tui3/continue_card.go @@ -0,0 +1,47 @@ +package tui3 + +import ( + "path/filepath" + + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/session" +) + +// continueCard keeps the selected chat's facts separate from the window behind Home. +type continueCard struct { + draft composerState + project string + returnFile string +} + +func (a *app) continueCardFor(row chatlist.Row, fromWindow bool) *continueCard { + card := &continueCard{} + if a.file != "" && filepath.Base(session.DirOf(a.file)) == row.Cell { + card.draft = a.mainComposer() + card.project = a.hostedPath(a.placeWord(tildePath(a.workspace, a.tilde))) + if fromWindow { + card.returnFile = a.file + } + } + if line, ok := a.homeLineOf(row.Cell); ok { + card.project = a.hostedPath(tildePath(line.row.Workspace, a.tilde)) + if line.row.Origin != "" { + card.project = filepath.Base(line.row.Origin) + session.MovedCopyWord + } + } + return card +} + +// questionPreviewRows shows only the composer explicitly attached to a move card. +func (a *app) questionPreviewRows(q questionShown, width int) []string { + if q.continuation == nil { + rows, _, _ := a.inputBlock(width) + return rows + } + box := q.continuation.draft.box + if box.empty() { + return nil + } + rows, _, _ := draftBlockWithTags(&box, a.pal, width, homeDraftRows, "", "", nil, a.pal.ink) + return rows +} diff --git a/internal/tui3/continue_card_scope_test.go b/internal/tui3/continue_card_scope_test.go new file mode 100644 index 0000000000..a5fbc855b7 --- /dev/null +++ b/internal/tui3/continue_card_scope_test.go @@ -0,0 +1,91 @@ +package tui3 + +import ( + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/session" +) + +// Contract J23.3 returns a displaced chat's card to the window that raised it. +func TestDisplacedChatCardEscapeReturnsToItsWindow(t *testing.T) { + for _, answer := range [][]string{{"esc"}, {"2", "enter"}} { + t.Run(strings.Join(answer, "+"), func(t *testing.T) { + src := &continuationListing{row: leftOffRow(heldCell, "other computer", chatlist.Running, time.Minute)} + a, agent := heldLab(t, src, &fakeTaker{}) + a.closeHome() + file := a.file + a.input.setText(" words for A1 only ") + a.input.cursor = 5 + want := a.mainComposer() + drive(t, a, key("enter")) + if _, card := a.homeAsking(); !card { + t.Fatal("the displaced window did not raise its card") + } + for _, k := range answer { + drive(t, a, key(k)) + } + if a.at(pageHome) || a.file != file { + t.Error("leave it there did not return to A1's window") + } + if got := a.mainComposer(); !reflect.DeepEqual(got, want) { + t.Errorf("A1's composer changed: %+v", got) + } + if len(agent.sent) != 0 { + t.Fatalf("leaving the card sent words: %v", agent.sent) + } + }) + } +} + +// Contract J23.1 gives a Home card only its selected chat's words and project. +func TestHomeContinueCardBelongsToTheSelectedCopy(t *testing.T) { + lab := newHomeLab(t) + work1, work2 := lab.workspace("ws9"), lab.workspace("ws") + file1 := lab.session("copy-one", heldCell, "A1", work1, time.Now()) + const second = "0000000000000002" + file2 := lab.session("copy-two", second, "A2", work2, time.Now()) + for file, origin := range map[string]string{file1: work1, file2: work2} { + meta := session.Meta{ID: filepath.Base(session.DirOf(file)), Title: map[string]string{file1: "A1", file2: "A2"}[file], Workspace: origin, Origin: origin, Owned: true, LastUserAt: time.Now()} + if err := session.SaveMeta(session.DirOf(file), meta); err != nil { + t.Fatal(err) + } + } + a := lab.app(file1) + a.width, a.height = 200, 70 + a.machines = chatlist.Static{ + {Cell: heldCell, Title: "A1", Device: "other computer", Status: chatlist.Running}, + {Cell: second, Title: "A2", Device: "other computer", Status: chatlist.Running}, + } + a.taker = &fakeTaker{} + a.input.setText("words belonging only to A1") + drive(t, a, key("enter")) + if _, card := a.homeAsking(); !card { + t.Fatal("A1 did not raise a card") + } + if frame := homeText(a); !strings.Contains(frame, "words belonging only to A1") || !strings.Contains(frame, "project: ws9 (copy from another machine)") { + t.Fatalf("A1's card did not show its own words and project:\n%s", frame) + } + drive(t, a, key("esc")) + drain(t, a, a.openHome()) + standOn(t, a, second) + frame := homeText(a) + if strings.Contains(frame, "words belonging only to A1") { + t.Errorf("A2's card shows A1's words:\n%s", frame) + } + if !strings.Contains(frame, "project: ws (copy from another machine)") || strings.Contains(frame, "project: ws9") { + t.Errorf("A2's card names the wrong project:\n%s", frame) + } + drive(t, a, key("esc")) + if !a.at(pageHome) { + t.Fatal("a card opened from Home left Home") + } + a.closeHome() + if a.file != file1 || a.input.String() != "words belonging only to A1" { + t.Fatal("A1 did not keep its unsent words") + } +} diff --git a/internal/tui3/continue_draft_store.go b/internal/tui3/continue_draft_store.go index 22e270cebc..868d1e3090 100644 --- a/internal/tui3/continue_draft_store.go +++ b/internal/tui3/continue_draft_store.go @@ -32,7 +32,7 @@ func (a *app) moveTakeDraftCopies(state composerState) { } want := keptSlot(mainRecipient, state) owner := a.draftKeepOwner() - files, _ := filepath.Glob(filepath.Join(filepath.Dir(a.draftFile), draftPrefix(a.workspace)+"*.json")) + files, _ := filepath.Glob(filepath.Join(filepath.Dir(a.draftFile), "draft-*.json")) for _, file := range files { if file == draftKeepPath(a.draftFile) || draftWindowAlive(file) { continue diff --git a/internal/tui3/copied_chat_draft_test.go b/internal/tui3/copied_chat_draft_test.go new file mode 100644 index 0000000000..f729b5be51 --- /dev/null +++ b/internal/tui3/copied_chat_draft_test.go @@ -0,0 +1,64 @@ +package tui3 + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// Contract J23.2 restores a copy's own words after its project folder changes. +func TestCopiedChatDraftFollowsItsChatAcrossFolders(t *testing.T) { + root, drafts, profile := t.TempDir(), t.TempDir(), t.TempDir() + open := func(work, file, draft string) *app { + return newApp(context.Background(), Options{Agent: &fakeAgent{model: "m"}, Workspace: work, SessionFile: file, DraftFile: draft, ProfileDir: profile, UsageLedger: labLedger()}) + } + file := filepath.Join(root, "copy-ws9", "01J0000000000000000000000A", ".cell", "transcript.jsonl") + work := filepath.Join(filepath.Dir(filepath.Dir(file)), "work") + draft := asWindow(t, deadPid(t), func() string { return DraftFile(drafts, work) }) + a := open(work, file, draft) + a.input.setText("words saved only for A1") + a.writeDraftsNow(a.leavingDraft()) + // This record uses the format written before chat identities survived moves. + owner := "\x00" + work + "\x00" + file + old := draftKeep{Version: 1, Owner: owner, At: "2026-10-27T12:00:00Z", Slots: []draftKeepSlot{{Owner: owner, Text: "words saved only for A1", Caret: 5}}} + raw, err := json.Marshal(old) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(draftKeepPath(draft), raw, 0600); err != nil { + t.Fatal(err) + } + movedFile := filepath.Join(root, "copy-new-ws9", "01J0000000000000000000000A", ".cell", "transcript.jsonl") + movedWork := filepath.Join(filepath.Dir(filepath.Dir(movedFile)), "work") + b := open(movedWork, movedFile, DraftFile(drafts, movedWork)) + if got := b.input.String(); got != "words saved only for A1" { + t.Fatalf("moved A1 restored %q", got) + } + otherFile := filepath.Join(root, "copy-ws", "01J0000000000000000000000B", ".cell", "transcript.jsonl") + other := open(movedWork, otherFile, DraftFile(drafts, movedWork)) + if got := other.input.String(); got != "" { + t.Fatalf("A2 restored A1's words: %q", got) + } +} + +// Contract J23.2 never adopts another named chat's plain draft export. +func TestCopiedChatCannotAdoptAnotherChatsDraftExport(t *testing.T) { + drafts, work := t.TempDir(), t.TempDir() + first := newTestApp(&fakeAgent{model: "m"}) + first.workspace = work + first.file = filepath.Join(work, "01J0000000000000000000000A", ".cell", "transcript.jsonl") + first.draftFile = asWindow(t, deadPid(t), func() string { return DraftFile(drafts, work) }) + first.input.setText("words saved only for A1") + first.writeDraftsNow(first.leavingDraft()) + other := newApp(context.Background(), Options{Agent: &fakeAgent{model: "m"}, Workspace: work, + SessionFile: filepath.Join(work, "01J0000000000000000000000B", ".cell", "transcript.jsonl"), + DraftFile: DraftFile(drafts, work), ProfileDir: t.TempDir(), UsageLedger: labLedger()}) + if got := other.input.String(); got != "" { + t.Errorf("A2 adopted A1's words: %q", got) + } + if _, err := os.Stat(first.draftFile); err != nil { + t.Errorf("A1's draft export was taken: %v", err) + } +} diff --git a/internal/tui3/draft.go b/internal/tui3/draft.go index 90fa79970e..00d28825d8 100644 --- a/internal/tui3/draft.go +++ b/internal/tui3/draft.go @@ -15,35 +15,16 @@ import ( tea "charm.land/bubbletea/v2" ) -// THE DRAFT IS THE PERSON'S, NOT THE SESSION'S. +// A DRAFT BELONGS TO ONE CHAT AND ONE WINDOW. // -// That one sentence decides everything in this file. The half-written message -// in the box belongs to whoever typed it, so it survives a closed window, a -// crash, a /new, and a session file that has moved on without it — a draft -// older than the session's last message is STILL restored, because the person -// who typed it did not stop meaning it when a turn finished. It is keyed by -// directory rather than by session for the same reason: the sentence was about -// this project. +// Unsent words survive closing, crashes and later turns, but sharing a project +// never transfers them to another chat. An identified chat keeps the same draft +// identity when its copy moves folders. Each window also has its own file, so +// one window's edits and sends cannot overwrite another window's box. // -// It is cleared on submit, and only on submit. -// -// ── AND IT BELONGS TO ONE BOX ── -// -// A draft is the text in a window's input, and two windows open on one project -// are two inputs. Keyed by directory alone they were one file: each window's -// debounce overwrote the other's half-sentence, whichever quit last decided what -// survived, a submit in either deleted what the other was still typing, and both -// restored the leftover at startup — the person's own words appearing in a -// window they had never typed them into. So the name carries the WINDOW as well -// as the directory, and the window is this process. -// -// What that costs is the restore, and paying it is [adoptDraft] below: a -// window's own file is gone the next time codeaf starts, because the pid is. A -// draft whose window is dead is an ORPHAN, and an orphan is the person's -// sentence with nobody holding it — so the next window opened on that directory -// takes it over. A file whose pid is still running is never touched, which is -// the one guarantee that matters here: it is better to leave a sentence on disk -// for the next window than to lift it out of a live one. +// A later window restores only records belonging to its chat whose original +// process is gone. Older plain drafts with no recorded chat can still be adopted +// by older conversations in the same project. A live window's file stays with it. // draftOwner is what this process's drafts are named after. A pid is unique // among the processes alive at one moment, which is exactly the property the @@ -70,8 +51,7 @@ const draftDebounce = 300 * time.Millisecond // matches has nothing left to do. type draftSaveMsg struct{ file string } -// draftOrdinals is how many drafts this process has named on each workspace, -// keyed by the canonical directory. +// draftOrdinals counts the files this process has named for each draft identity. // // IT EXISTS BECAUSE ONE TERMINAL CAN HOLD TWO CONVERSATIONS IN ONE PROJECT. // The name used to be the workspace and the pid, which is unique among the @@ -111,6 +91,15 @@ func DraftFile(dir, workspace string) string { draftPrefix(workspace)+strconv.Itoa(ordinal)+"-"+strconv.Itoa(draftOwner)+".txt") } +// ChatDraftFile names a window's draft by its chat, even when its copy moves. +func ChatDraftFile(dir, host, workspace, file string) string { + key := draftOwnerOf(host, workspace, file) + if key == "" { + key = host + ":" + workspace + } + return DraftFile(dir, key) +} + // draftPrefix is everything in the name before the window: the part two windows // on one directory share, and the glob an orphan hunt uses. func draftPrefix(workspace string) string { @@ -349,6 +338,9 @@ func adoptDraft(own, workspace string) string { if path == own || draftWindowAlive(path) { continue } + if keep, how := readDraftKeep(draftKeepPath(path)); how == draftKeepFound && namedConversation(keep.Owner) { + continue + } info, err := os.Stat(path) if err != nil || info.ModTime().Before(latest) { continue diff --git a/internal/tui3/draftkeep.go b/internal/tui3/draftkeep.go index fa85b55ff0..715de0f662 100644 --- a/internal/tui3/draftkeep.go +++ b/internal/tui3/draftkeep.go @@ -13,6 +13,9 @@ import ( "time" tea "charm.land/bubbletea/v2" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/session" ) // AN UNSENT LINE OUTLIVES THE WINDOW, AND IT COMES BACK TO THE READER IT WAS @@ -202,24 +205,23 @@ type draftKeepFile struct { // ── who a record belongs to ───────────────────────────────────────────────── -// draftOwnerOf is one conversation's durable identity: the machine, the project -// and the transcript. -// -// THE TRANSCRIPT PATH ALONE IS NOT AN IDENTITY. A session opened over a -// connection names a file on the far machine (host.go), and two machines -// mounting the same path mean two different conversations; so does the same -// relative journal reached through two projects. Restoring task 7 across that -// gap is the misdelivery this wave exists to end, one connection wider. +// draftOwnerOf follows an identified chat across copied folders. Older journals +// without a chat identity remain scoped to their machine, project and path. func draftOwnerOf(host, workspace, file string) string { if strings.TrimSpace(file) == "" { return "" } + if dir, ok := session.FolderOf(file); ok && cell.ValidID(filepath.Base(dir)) { + return strings.TrimSpace(host) + draftChatOwner + filepath.Base(dir) + } return strings.TrimSpace(host) + "\x00" + filepath.Clean(workspace) + "\x00" + filepath.Clean(file) } // draftUnnamedOwner marks an identity that is a WINDOW and not a conversation. const draftUnnamedOwner = "\x00window\x00" +const draftChatOwner = "\x00chat\x00" + // draftWindowOwnerOf is the identity of a conversation with no transcript to be // named after: the draft file, which is this window's and this conversation's // and lives exactly as long as the words do. @@ -330,9 +332,21 @@ func readDraftKeep(path string) (draftKeep, draftKeepRead) { if keep.Version > draftKeepVersion { return draftKeep{}, draftKeepStrange } + keep.Owner = currentDraftOwner(keep.Owner) + for i := range keep.Slots { + keep.Slots[i].Owner = currentDraftOwner(keep.Slots[i].Owner) + } return keep, draftKeepFound } +// currentDraftOwner also recognizes records written before a copy moved folders. +func currentDraftOwner(owner string) string { + if parts := strings.Split(owner, "\x00"); len(parts) == 3 { + return draftOwnerOf(parts[0], parts[1], parts[2]) + } + return owner +} + // keepStrangeAside moves a record this build cannot read out of the way, under a // name in the same directory, and REFUSES THE WRITE if it cannot: replacing a // file whose contents are unknown is deleting words on the strength of not @@ -1140,11 +1154,9 @@ func (a *app) layKeptDrafts(allowUnowned bool) { if keep, how := readDraftKeep(draftKeepPath(a.draftFile)); how == draftKeepFound { a.keptElsewhere, _ = a.takeKeptSlots(keep, states) settled = a.speaksFor(keep) - // A shared connection reuses this window's store across selections, - // and a chat arriving through a take brings only its own words. In - // both, the plain export belongs to the record's owner; falling back - // to it would put another chat's unsent text into the incoming chat. - if (a.shared || !allowUnowned) && namedConversation(keep.Owner) { + // A named record is the whole answer. Its plain export belongs to that + // recorded chat, so it cannot supply missing words for another chat. + if namedConversation(keep.Owner) { settled = true } } @@ -1193,6 +1205,9 @@ func (a *app) layKeptDrafts(allowUnowned bool) { // to this one would be migrating a recipient because two directories matched. func (a *app) legacyDraftText() string { text := readDraft(a.draftFile) + if strings.Contains(a.draftKeepOwner(), draftChatOwner) { + return text + } if text == "" && draftFirstHere(a.draftFile, a.workspace) { text = adoptDraft(a.draftFile, a.workspace) } @@ -1248,12 +1263,12 @@ func (a *app) takeKeptSlots(keep draftKeep, into map[recipient]composerState) (l // back the sentence that clearing spent. Older records are left exactly where // they are — never read for this conversation again, never deleted. func (a *app) reuniteDrafts(into map[recipient]composerState) ([]draftKeepFile, bool) { - if a.draftKeepOwner() == "" || a.draftFile == "" || strings.TrimSpace(a.workspace) == "" { + if a.draftKeepOwner() == "" || a.draftFile == "" { return nil, false } own := draftKeepPath(a.draftFile) directory := filepath.Dir(a.draftFile) - candidates, err := filepath.Glob(filepath.Join(directory, draftPrefix(a.workspace)+"*.json")) + candidates, err := filepath.Glob(filepath.Join(directory, "draft-*.json")) if err != nil { return nil, false } diff --git a/internal/tui3/draftkeep_test.go b/internal/tui3/draftkeep_test.go index cf96d22a60..af05829ae3 100644 --- a/internal/tui3/draftkeep_test.go +++ b/internal/tui3/draftkeep_test.go @@ -270,7 +270,8 @@ func TestATaskLineFollowsItsConversationIntoAnotherWindow(t *testing.T) { // A SENTENCE ADOPTED FROM A DEAD WINDOW ON THIS DIRECTORY BRINGS NOTHING WITH // IT. The draft follows the person and the directory; a task page follows its // conversation, and two directories matching is not that conversation. -func TestAnAdoptedSentenceDoesNotBringAStrangersTaskLine(t *testing.T) { +// Contract J23.2 keeps another chat's draft and task line with that chat. +func TestAnotherChatsDraftAndTaskLineAreNeverAdopted(t *testing.T) { dir := t.TempDir() gone, _, _ := roomApp(t) @@ -283,13 +284,13 @@ func TestAnAdoptedSentenceDoesNotBringAStrangersTaskLine(t *testing.T) { gone.writeDraftsNow(gone.leavingDraft()) left := draftKeepPath(gone.draftFile) - // A DIFFERENT conversation opens in this directory and adopts the orphan. + // Sharing a project does not give the next conversation these words. next, _, _ := roomApp(t) next.workspace, next.file = "/tmp/lab", filepath.Join(dir, "s2.jsonl") next.draftFile = DraftFile(dir, "/tmp/lab") next.restoreDraft() - if got := next.input.String(); got != "yesterday's sentence" { - t.Fatalf("the orphan sentence came back as %q", got) + if got := next.input.String(); got != "" { + t.Fatalf("another chat's sentence came back as %q", got) } next.openRoom(7, "Fix the nil-map crash") if got := next.input.String(); got != "" { diff --git a/internal/tui3/homedraft.go b/internal/tui3/homedraft.go index 36c63fc7c1..67978e51ee 100644 --- a/internal/tui3/homedraft.go +++ b/internal/tui3/homedraft.go @@ -225,6 +225,9 @@ func (a *app) targetChordWords() string { // targetProject is the destination of the next conversation, written as a // path so projects with the same basename remain distinguishable. func (a *app) targetProject() string { + if a.at(pageHome) && a.home.ask != nil && a.home.ask.continuation != nil { + return a.home.ask.continuation.project + } // A ROW THAT IS A COPY TAKEN HERE FROM ANOTHER MACHINE is named for where // it came from and never for the folder it recorded: that folder is on // the machine the chat left, and the footer of the conversation itself diff --git a/internal/tui3/homepanel_continue.go b/internal/tui3/homepanel_continue.go index 827832cd21..eba7fc4d39 100644 --- a/internal/tui3/homepanel_continue.go +++ b/internal/tui3/homepanel_continue.go @@ -175,7 +175,7 @@ func (a *app) homeMachineEnter(line homeLine) tea.Cmd { // BRANCH, so with it out of reach the acting offers are disabled and say why // on the foot, where every other refusal on this screen is said. if a.machineRead.down { - a.home.say(chatlist.Unreachable, "") + a.home.say(a.machineRead.unavailableWord(), "") return nil } return run(a, row, offer) @@ -227,7 +227,9 @@ func (a *app) raiseContinue(row chatlist.Row, at int) tea.Cmd { // takeover sentence as the reason. The cursor starts on option `at`. func (a *app) continueShown(row chatlist.Row, at int) questionShown { row = a.presentRow(row) + card := a.continueCardFor(row, at == continueStayAt) return questionShown{ + continuation: card, question: session.Question{ Kind: homeContinueKind, Ask: session.AskConfirmation, @@ -246,6 +248,9 @@ func (a *app) continueShown(row chatlist.Row, at int) questionShown { pick: at, local: func(answer session.Answer) tea.Cmd { if answer.FirstKey() != "1" { + if card.returnFile != "" && a.file == card.returnFile { + a.closeHome() + } return nil } return a.takeRemote(row) diff --git a/internal/tui3/homepanel_machines.go b/internal/tui3/homepanel_machines.go index 8a51ac722e..438ac5e8c4 100644 --- a/internal/tui3/homepanel_machines.go +++ b/internal/tui3/homepanel_machines.go @@ -31,9 +31,10 @@ import ( // [homeCellTitleFloor] cells does the sentence go whole. // machineReading is the last listing the other machines gave, and whether the -// last ask failed. A failed ask keeps the rows: the list stays on screen, dim, -// under `other machines unreachable`. +// last ask failed. A failed ask keeps the rows and explains whether the reading +// reached its deadline or the connection could not be made. type machineReading struct { + err error sendHolds map[string]sendHold computerRemoved bool rows []chatlist.Row @@ -63,8 +64,7 @@ type homeMachinesMsg struct { holdFence uint64 } -// machinesAskTimeout bounds one ask, so a silent relay is unreachable rather -// than an ask that never returns. +// machinesAskTimeout bounds one ask without treating silence as a network failure. const machinesAskTimeout = 5 * time.Second // askMachines lists the other machines, off the update loop for the reason a @@ -122,6 +122,7 @@ func (a *app) recordMachines(msg homeMachinesMsg) tea.Cmd { } before, wasElsewhere := a.heldElsewhere() a.fileMachines(msg) + a.machineRead.err = msg.err if row, elsewhere := a.heldElsewhere(); msg.err == nil && elsewhere && (!wasElsewhere || before.DeviceID != row.DeviceID || before.Device != row.Device) { a.note(chatlist.Superseded(nonemptyDevice(row.Device))) } @@ -145,11 +146,20 @@ func (m machineReading) into(in *homeGridInput, own []homeLine) homePanelRows { rows := homePanelCut(in, panelSessions, lines) if m.down { rows.lines = append(rows.lines, homeLine{kind: homeMachineRow, cell: &homeCell{ - kind: cellWhisper, panel: panelSessions, title: chatlist.Unreachable}}) + kind: cellWhisper, panel: panelSessions, title: m.unavailableWord()}}) } return rows } +// unavailableWord preserves the distinction between a deadline and a failed connection. +func (m machineReading) unavailableWord() string { + var timeout interface{ Timeout() bool } + if errors.Is(m.err, context.DeadlineExceeded) || errors.As(m.err, &timeout) && timeout.Timeout() { + return chatlist.ReadTimedOut + } + return chatlist.Unreachable +} + // lines are the rows to draw: none of the kind that only this machine can hold, // and none for a chat this machine already lists unless another machine holds it // live. That one is drawn WITH its door (`running on desktop`, enter takes it), diff --git a/internal/tui3/ownership_deadline_test.go b/internal/tui3/ownership_deadline_test.go new file mode 100644 index 0000000000..4ebe7c6f40 --- /dev/null +++ b/internal/tui3/ownership_deadline_test.go @@ -0,0 +1,26 @@ +package tui3 + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" +) + +type deadlineChatListing struct{} + +func (deadlineChatListing) Rows(context.Context) ([]chatlist.Row, error) { + return nil, fmt.Errorf("%w: %w", directory.ErrUnreachable, context.DeadlineExceeded) +} + +// Contract J22 describes an expired ownership read as a deadline on Home. +func TestHomeOwnershipDeadlineDoesNotClaimTheNetworkIsUnreachable(t *testing.T) { + a := machinesHome(t, deadlineChatListing{}, 200) + frame := homeText(a) + if !strings.Contains(frame, "other machines did not answer in time") || strings.Contains(frame, "other machines unreachable") { + t.Fatalf("ownership read deadline was misreported:\n%s", frame) + } +} diff --git a/internal/tui3/question.go b/internal/tui3/question.go index 27e8644700..79b4096f2e 100644 --- a/internal/tui3/question.go +++ b/internal/tui3/question.go @@ -168,6 +168,8 @@ func (a *app) questionDoors() (questionAgent, bool) { // is on offer, and how to answer it when no engine is behind it. type questionShown struct { question session.Question + // continuation binds a move card's preview and return to its own chat. + continuation *continueCard // local answers a question the SURFACE raised — the stop card, the // tab-close card, anything this program asks about itself. It is nil on // every question that came from the engine, which go through diff --git a/internal/tui3/questionpanel.go b/internal/tui3/questionpanel.go index 7da4d2c2e6..df6150a5dc 100644 --- a/internal/tui3/questionpanel.go +++ b/internal/tui3/questionpanel.go @@ -160,8 +160,8 @@ func (a *app) questionPanelInside(q questionShown, width, inner, above int, edge rows = append(rows, questionPanelGap+a.pal.dim(fit(a.questionWritingRow(q), inner-2))) keyRow = a.pal.data("esc") + a.pal.dim(" back") } - if len(edge) == 0 && a.questionPanelTyping(q) { - input, _, _ := a.inputBlock(inner - len(questionPanelGap)) + if len(edge) == 0 && (q.continuation != nil || a.questionPanelTyping(q)) { + input := a.questionPreviewRows(q, inner-len(questionPanelGap)) for _, line := range input { rows = append(rows, questionPanelGap+line) } From 86373f7ace7a9edc5585e912d0fdc812a47cd6ba Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 27 Oct 2026 03:24:36 -0400 Subject: [PATCH 27/28] fix(chat): a send whose ownership read ran out of time says so and asks again A read that only hit its deadline no longer reads as other machines being unreachable: the window says they did not answer in time, keeps the words, and the next Enter makes a fresh read instead of replaying the cached failure. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/tui3/homepanel_heldelsewhere.go | 6 ++++-- internal/tui3/ownership_deadline_test.go | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/internal/tui3/homepanel_heldelsewhere.go b/internal/tui3/homepanel_heldelsewhere.go index a8cef14951..5a4b895ccc 100644 --- a/internal/tui3/homepanel_heldelsewhere.go +++ b/internal/tui3/homepanel_heldelsewhere.go @@ -86,7 +86,9 @@ func (a *app) needsOwnershipRead() bool { func (a *app) checkBeforeSendWith(resume func() tea.Cmd) tea.Cmd { src, file, draft, agent := a.machines, a.file, a.input.String(), a.agent - offline := a.machineRead.down + // A read that only ran out of time does not show the connection is down, + // so the next Enter asks again rather than replaying the cached failure. + offline := a.machineRead.down && a.machineRead.unavailableWord() != chatlist.ReadTimedOut blocked := a.machineRead.sendHolds[filepath.Base(session.DirOf(file))].blocked chips := slices.Clone(a.chips) var first *composerState @@ -228,7 +230,7 @@ func (a *app) resumeOwnedSend(msg ownershipReadMsg) tea.Cmd { if a.offlineSendHeld(id, remote, msg) { return msg.resume() } - a.note(chatlist.Unreachable) + a.note(a.machineRead.unavailableWord()) return nil } for _, row := range msg.reading.rows { diff --git a/internal/tui3/ownership_deadline_test.go b/internal/tui3/ownership_deadline_test.go index 4ebe7c6f40..1644ad1f97 100644 --- a/internal/tui3/ownership_deadline_test.go +++ b/internal/tui3/ownership_deadline_test.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" "testing" + "time" "github.com/Agent-Field/codeaf/internal/chatlist" "github.com/Agent-Field/codeaf/internal/directory" @@ -24,3 +25,25 @@ func TestHomeOwnershipDeadlineDoesNotClaimTheNetworkIsUnreachable(t *testing.T) t.Fatalf("ownership read deadline was misreported:\n%s", frame) } } + +// Contract J22 keeps a timed-out send unsent, names the deadline, and asks +// again on the next Enter instead of replaying the cached failure. +func TestSendOwnershipDeadlineKeepsWordsAndNamesTheDeadline(t *testing.T) { + src := &continuationListing{row: leftOffRow(heldCell, "other computer", chatlist.Running, time.Minute)} + a, agent := heldLab(t, src, &fakeTaker{}) + a.closeHome() + a.machines = deadlineChatListing{} + a.input.setText("keep these words") + drive(t, a, key("enter")) + if got := plain(lastNote(t, a)); !strings.Contains(got, chatlist.ReadTimedOut) || strings.Contains(got, chatlist.Unreachable) { + t.Fatalf("deadline = %q", got) + } + if len(agent.sent) != 0 || a.input.String() != "keep these words" { + t.Fatal("deadline changed or sent the draft") + } + a.machines = src + drive(t, a, key("enter")) + if _, card := a.homeAsking(); !card { + t.Fatal("a cached deadline prevented the next ownership reading") + } +} From fcfcf09adec4e387ca14cbfbbef90c95412add03 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 27 Oct 2026 03:36:24 -0400 Subject: [PATCH 28/28] fix(chat): a fresh reading that finds the chat held here lifts a news-only send block The displaced-window block set from a listing row or the displaced notice now yields to the reading a send makes itself: when that newer reading shows this computer holds the chat, the send goes ahead. A block a take decided, or a failed take, still never lifts that way. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/tui3/homepanel_heldelsewhere.go | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/internal/tui3/homepanel_heldelsewhere.go b/internal/tui3/homepanel_heldelsewhere.go index 5a4b895ccc..41c5e3b4cc 100644 --- a/internal/tui3/homepanel_heldelsewhere.go +++ b/internal/tui3/homepanel_heldelsewhere.go @@ -61,6 +61,11 @@ type ownershipReadMsg struct { type sendHold struct { fence uint64 blocked bool + // seen marks a block that only news of another holder set: a listing row + // or the displaced notice. A fresh reading made for this send that finds + // the chat held here again lifts it, because that reading is newer than + // the news; a block a take decided, or a failed take, never lifts that way. + seen bool // failed marks a hold whose take did not finish, so its session is a // copy this window may not keep using even if a later take finds the // chat already here. @@ -242,7 +247,11 @@ func (a *app) resumeOwnedSend(msg ownershipReadMsg) tea.Cmd { if msg.proof { fence = msg.fence } - if row.Status == chatlist.Here && row.Fence == fence && !held.blocked { + if row.Status == chatlist.Here && row.Fence == fence && (!held.blocked || held.seen && row.Mine && !held.failed) { + if held.blocked { + held.blocked, held.seen = false, false + a.machineRead.sendHolds[id] = held + } return msg.resume() } if !held.blocked && (row.Status == chatlist.Idle || row.Status == chatlist.Off) && row.Head != "" && row.Head == msg.head { @@ -303,7 +312,7 @@ func (a *app) rememberSendHolds(msg homeMachinesMsg) { for _, row := range msg.rows { held, known := a.machineRead.sendHolds[row.Cell] if heldElsewhere(row) && (!known || row.Fence >= held.fence) { - held.blocked = true + held.blocked, held.seen = true, !known || held.seen || !held.blocked a.machineRead.sendHolds[row.Cell] = held } if row.Status != chatlist.Here || held.blocked || held.failed { @@ -320,7 +329,8 @@ func (a *app) rememberSendHolds(msg homeMachinesMsg) { var displacedSendSuffix = chatlist.Superseded("") // blockDisplacedSend keeps a shown displacement stronger than a cached writer -// reading. Only a successful continuation can make this window writable again. +// reading. A successful continuation makes this window writable again, and so +// does a fresh reading for a send that finds the chat held here. func (a *app) blockDisplacedSend(text string) { if a.file == "" || !strings.HasSuffix(text, displacedSendSuffix) { return @@ -329,8 +339,8 @@ func (a *app) blockDisplacedSend(text string) { a.machineRead.sendHolds = map[string]sendHold{} } id := filepath.Base(session.DirOf(a.file)) - held := a.machineRead.sendHolds[id] - held.blocked = true + held, known := a.machineRead.sendHolds[id] + held.blocked, held.seen = true, !known || held.seen || !held.blocked a.machineRead.sendHolds[id] = held }