Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
4790d51
perf(sync): a take-back acquires before it restores the whole tree
AbirAbbas Oct 27, 2026
29a5c12
fix(tasks): a task that finished on another computer opens, read-only
AbirAbbas Oct 27, 2026
4511258
fix(saving): recovery is claimed only when this chat's turns are saved
AbirAbbas Oct 27, 2026
77eb081
fix(cli): cluster status says the plain sentence when the relay is down
AbirAbbas Oct 27, 2026
08033c6
fix(chat): words typed while a chat opens stay with it, and closing n…
AbirAbbas Oct 27, 2026
e5edeb3
Bump embedded furrow to v0.3.5 (an exiting daemon removes only its ow…
AbirAbbas Oct 27, 2026
fb9c405
docs(changes): entry for the two-computer stability wave
AbirAbbas Oct 27, 2026
aaadef4
fix(sync): the holder hears its renewals fail, and a hosted save fail…
AbirAbbas Oct 27, 2026
936dabd
docs(manual): words typed while /new opens wait in the new chat
AbirAbbas Oct 27, 2026
bd7ea8e
fix(saving): the recovery notice reaches an idle chat, and an unused …
AbirAbbas Oct 27, 2026
096080f
fix(chat): one Enter sends without asking the relay, and quitting kee…
AbirAbbas Oct 27, 2026
d5eba71
fix(quit): wait for the hand-off, say so, and stop at the save ceiling
AbirAbbas Oct 27, 2026
8d287a1
fix(cli): cluster status tells a slow relay from an unreachable one
AbirAbbas Oct 27, 2026
ebe2fce
test(tasks): a moved finished task now has its historical page
AbirAbbas Oct 27, 2026
03db488
fix(cli): guard the bounded cluster answer and unlock final-save stat…
AbirAbbas Oct 27, 2026
a93f3d0
fix(sync): only a live hold sends, and a killed computer can take its…
AbirAbbas Oct 27, 2026
ed64fa0
fix(pair): a pairing deadline says so in a plain sentence
AbirAbbas Oct 27, 2026
b4c5c3c
fix(cli): codeaf devices says only that the list is unavailable when …
AbirAbbas Oct 27, 2026
05ac255
fix(tasks): a moved worker's history is found without its plan identity
AbirAbbas Oct 27, 2026
4f4b648
fix(chat): sent words never come back, a removed computer keeps its o…
AbirAbbas Oct 27, 2026
7bf7ba9
fix(sync): a copied chat can be taken back as often as it moves
AbirAbbas Oct 27, 2026
e6f22a3
fix(tasks): a moved finished task shows its steps and worker transcript
AbirAbbas Oct 27, 2026
e5bf39c
docs(changes): note the devices and pairing sentences in the wave entry
AbirAbbas Oct 27, 2026
b1ab72e
fix(sync): a window that knows it was displaced never sends
AbirAbbas Oct 27, 2026
9a132e9
fix(tasks): a moved task's history is read below a symlinked folder
AbirAbbas Oct 27, 2026
7069195
fix(chat): a card shows only its own chat's words, and Esc returns to…
AbirAbbas Oct 27, 2026
86373f7
fix(chat): a send whose ownership read ran out of time says so and as…
AbirAbbas Oct 27, 2026
fcfcf09
fix(chat): a fresh reading that finds the chat held here lifts a news…
AbirAbbas Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/codeaf/chatv3.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion cmd/codeaf/chatv3_beside.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
48 changes: 48 additions & 0 deletions cmd/codeaf/chatv3_draft_identity_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
2 changes: 1 addition & 1 deletion cmd/codeaf/chatv3_host.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────
//
Expand Down
1 change: 1 addition & 0 deletions cmd/codeaf/chatv3_layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
9 changes: 3 additions & 6 deletions cmd/codeaf/chatv3_local.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions cmd/codeaf/chatv3_pair.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions cmd/codeaf/chatv3_process.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
32 changes: 31 additions & 1 deletion cmd/codeaf/chatv3_surface.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────
Expand All @@ -47,6 +48,27 @@ 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 {
forced := armFinalSaving()
previousSaveFailure := options.SaveFailure
options.SaveFailure = func(err error) {
recordFinalSaveFailure(err)
if previousSaveFailure != nil {
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
Expand Down Expand Up @@ -162,7 +184,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
})
}

Expand Down
101 changes: 73 additions & 28 deletions cmd/codeaf/cluster.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
package main

import (
"bytes"
"context"
"errors"
"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"
"os"
"time"
)

const clusterHelp = `codeaf cluster status
Expand All @@ -25,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)
Expand All @@ -45,38 +50,78 @@ 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)
}

// 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 {
if verb == "lead" {
r, err := c.LeadCluster(ctx, 0)
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)
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 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)
}
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 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 {
text string
err error
}
if len(s.Configuration.Outgoing) > 0 {
fmt.Fprintf(out, "finishing the change from %v\n", s.Configuration.Outgoing)
done := make(chan result, 1)
guard.Go("cluster/answer", func() {
var output bytes.Buffer
err := work(ctx, &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(), "")
}
return nil
}
Loading
Loading