From 7bce0e25a8542f73bb092856e6a36be09b6ac98d Mon Sep 17 00:00:00 2001 From: ningmao-hlyz <2997825003@qq.com> Date: Fri, 9 Oct 2026 13:32:50 +0800 Subject: [PATCH 01/24] fix(workspace): name a workspace by its resolved path A folder reached through a symlink became two projects, two conversation histories and two engines: engineWorkspace and /project stopped at filepath.Clean, so each spelling hashed to its own enginehost host directory and its own project row. Resolve symlinks (filepath.EvalSymlinks) at both doors where a person's path becomes a key: engineWorkspace (--daemon/--status/--stop and the host's workspace name) and runProjectCommand (the project pin). Fixes Agent-Field/CodeAF#1761 --- cmd/codeaf/engine.go | 8 ++++ cmd/codeaf/engine_symlink_test.go | 67 +++++++++++++++++++++++++++++++ internal/tui3/projectcmd.go | 11 +++++ 3 files changed, 86 insertions(+) create mode 100644 cmd/codeaf/engine_symlink_test.go diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go index 2577e8011c..673331a302 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -1377,6 +1377,14 @@ func engineWorkspace(path string) (string, error) { expanded = filepath.Join(home, expanded) } expanded = filepath.Clean(expanded) + // A WORKSPACE IS NAMED BY ITS RESOLVED PATH, so a folder reached through a + // symlink keys the same project, conversation history and engine host as + // the folder it points at. Clean alone leaves both spellings distinct and + // sha256(Clean(...)) in enginehost.where then builds one host directory per + // spelling — two engines, two histories, one folder. + if resolved, err := filepath.EvalSymlinks(expanded); err == nil { + expanded = resolved + } info, err := os.Stat(expanded) if err != nil { return "", fmt.Errorf("open %s: %w", expanded, err) diff --git a/cmd/codeaf/engine_symlink_test.go b/cmd/codeaf/engine_symlink_test.go new file mode 100644 index 0000000000..48e488c445 --- /dev/null +++ b/cmd/codeaf/engine_symlink_test.go @@ -0,0 +1,67 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// TestEngineWorkspaceResolvesSymlink is the regression for issue #1761: a +// folder reached through a symlink must key to the same workspace as the +// folder it points at, so one folder cannot become two projects and two +// engines. Before the fix engineWorkspace stopped at filepath.Clean, so the +// alias and the target returned different paths and enginehost.where hashed +// each spelling to its own host directory. +func TestEngineWorkspaceResolvesSymlink(t *testing.T) { + tmp := t.TempDir() + real := filepath.Join(tmp, "realrepo") + if err := os.Mkdir(real, 0o755); err != nil { + t.Fatal(err) + } + link := filepath.Join(tmp, "linkedrepo") + if err := os.Symlink(real, link); err != nil { + t.Fatal(err) + } + + fromTarget, err := engineWorkspace(real) + if err != nil { + t.Fatalf("engineWorkspace(target): %v", err) + } + fromLink, err := engineWorkspace(link) + if err != nil { + t.Fatalf("engineWorkspace(link): %v", err) + } + + resolvedLink, err := filepath.EvalSymlinks(link) + if err != nil { + t.Fatalf("EvalSymlinks(link): %v", err) + } + + if fromLink != resolvedLink { + t.Fatalf("link spelling not resolved: got %q, want %q", fromLink, resolvedLink) + } + if fromLink != fromTarget { + t.Fatalf("two spellings keyed differently: link=%q target=%q", fromLink, fromTarget) + } +} + +// TestEngineWorkspaceKeepsNonSymlinkPathUnchanged guards that an ordinary +// folder still resolves to itself, so the fix only moves symlinked spellings. +func TestEngineWorkspaceKeepsNonSymlinkPathUnchanged(t *testing.T) { + tmp := t.TempDir() + real := filepath.Join(tmp, "plainrepo") + if err := os.Mkdir(real, 0o755); err != nil { + t.Fatal(err) + } + resolvedReal, err := filepath.EvalSymlinks(real) + if err != nil { + t.Fatalf("EvalSymlinks(plain): %v", err) + } + got, err := engineWorkspace(real) + if err != nil { + t.Fatalf("engineWorkspace(plain): %v", err) + } + if got != resolvedReal { + t.Fatalf("plain path changed: got %q, want %q", got, resolvedReal) + } +} diff --git a/internal/tui3/projectcmd.go b/internal/tui3/projectcmd.go index bf6c882cc3..1bc3f8264a 100644 --- a/internal/tui3/projectcmd.go +++ b/internal/tui3/projectcmd.go @@ -22,6 +22,7 @@ package tui3 import ( "os" + "path/filepath" "strings" tea "charm.land/bubbletea/v2" @@ -75,6 +76,16 @@ func (a *app) runProjectCommand(rest string) tea.Cmd { return nil } path := a.resolvePath(rest) + // A PIN NAMES A FOLDER, NOT A SPELLING. A path taken through a symlink must + // pin the folder the link reaches, or home stores one project under the + // alias while a launch from the resolved path stores another — two + // projects, two histories, two engines for one folder (#1761). The stat + // below already pays the walk EvalSymlinks costs on a missing target, so a + // path that does not resolve simply falls through to the refusal word as + // it always did. + if resolved, err := filepath.EvalSymlinks(path); err == nil { + path = resolved + } info, err := os.Stat(path) if path == "" || err != nil || !info.IsDir() { a.home.say(projectNoFolderWord+rest, "") From fa7d4383a77025c7b659254bb1b8b4bcb4a9978d Mon Sep 17 00:00:00 2001 From: ningmao-hlyz <2997825003@qq.com> Date: Fri, 9 Oct 2026 13:46:44 +0800 Subject: [PATCH 02/24] changelog: a folder reached through a symlink is one project and one engine (#1798) --- .../unreleased/1798-symlink-workspace-keys.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 docs/changes/unreleased/1798-symlink-workspace-keys.md diff --git a/docs/changes/unreleased/1798-symlink-workspace-keys.md b/docs/changes/unreleased/1798-symlink-workspace-keys.md new file mode 100644 index 0000000000..015c7f2de0 --- /dev/null +++ b/docs/changes/unreleased/1798-symlink-workspace-keys.md @@ -0,0 +1,19 @@ +--- +kind: fixed +title: a folder reached through a symlink is one project and one engine, not two +pr: 1798 +surface: [engine, chat] +invalidates: + - "A folder reached through a symlink became two projects, two conversation histories and two engines. `codeaf engine --status --workspace ` missed the engine holding ``, and home's projects panel listed the folder once per spelling. engineWorkspace stopped at filepath.Clean, so each spelling hashed to its own enginehost host directory. engineWorkspace now resolves symlinks (filepath.EvalSymlinks after the absolute path is settled), so both spellings of one folder reach one project, one conversation history and one engine." +--- + +Found in the v0.7.1 happy-path pass on macOS, where `/tmp` is a link to +`/private/tmp`, and reproducible on any system with `mkdir d && ln -s d l`. +The workspace key-minting door in `cmd/codeaf` — the one every engine command +(`--daemon`, `--status`, `--stop`) and the host's workspace name go through — +expanded and cleaned the path but never resolved its links. Two spellings of +one folder then keyed two host directories in `internal/enginehost.where` +(sha256 of the cleaned string), so the person saw one folder while codeaf kept +two of everything. The fix names a workspace by its resolved path at that door; +a regression test drives a symlink through `engineWorkspace` and asserts both +spellings land on the same directory. \ No newline at end of file From 4f8cd37b60f6fb9ee26ecb6794fb0d2b7aa99cf3 Mon Sep 17 00:00:00 2001 From: ningmao-hlyz <2997825003@qq.com> Date: Wed, 14 Oct 2026 06:07:23 +0800 Subject: [PATCH 03/24] enginehost: name a host by the folder, not a spelling of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every identity question in this package — host lookup, dial, spawn, lock, status, stop — funnels through where, so where is the one place a symlinked spelling must resolve. A surface that reached the folder through a link and a daemon named by the resolved path hashed to two host directories, and the launch spent its wait failing to meet (#1761). A path that does not resolve — a workspace not on disk yet, a test's imagined directory — keeps its spelling. --- internal/enginehost/enginehost.go | 14 ++++- .../enginehost/enginehost_symlink_test.go | 57 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 internal/enginehost/enginehost_symlink_test.go diff --git a/internal/enginehost/enginehost.go b/internal/enginehost/enginehost.go index 0c34b3b7dc..b976dd3e74 100644 --- a/internal/enginehost/enginehost.go +++ b/internal/enginehost/enginehost.go @@ -125,12 +125,24 @@ func Dir(workspace string) (string, error) { // that a question was asked. The doors that are about to WRITE something — the // host's own listener, the spawn lock, the wait for a host to go — call [Dir] // and make it themselves. +// +// A HOST DIRECTORY IS NAMED BY THE FOLDER, NOT A SPELLING OF IT. Every identity +// question in this package — host lookup, dial, spawn, lock, status, stop — +// funnels through here, so this is the one place a symlinked spelling must be +// resolved: a surface that reached the folder through a link and a daemon that +// was handed the resolved path would otherwise hash to two host directories and +// spend the launch failing to meet (#1761). A path that does not resolve — a +// workspace not on disk yet, a test's imagined directory — keeps its spelling. func where(workspace string) (string, error) { workspace = strings.TrimSpace(workspace) if workspace == "" { return "", errors.New("engine host: no workspace to hold") } - sum := sha256.Sum256([]byte(filepath.Clean(workspace))) + workspace = filepath.Clean(workspace) + if resolved, err := filepath.EvalSymlinks(workspace); err == nil { + workspace = resolved + } + sum := sha256.Sum256([]byte(workspace)) return home.Join("v3", "hosts", hex.EncodeToString(sum[:8])), nil } diff --git a/internal/enginehost/enginehost_symlink_test.go b/internal/enginehost/enginehost_symlink_test.go new file mode 100644 index 0000000000..68c07ac89e --- /dev/null +++ b/internal/enginehost/enginehost_symlink_test.go @@ -0,0 +1,57 @@ +package enginehost + +import ( + "os" + "path/filepath" + "testing" +) + +// TestAHostDirectoryIsNamedByTheFolderNotASpelling is the identity-layer +// regression for issue #1761 and the review of #1798: every question this +// package answers — lookup, dial, lock, socket — funnels through [where], and +// where is the one place a symlinked spelling must resolve. Before the fix, a +// surface that reached the folder through a link and a daemon named by the +// resolved path hashed to two host directories, and the launch spent its wait +// failing to meet. +func TestAHostDirectoryIsNamedByTheFolderNotASpelling(t *testing.T) { + shortHome(t) + target := t.TempDir() + link := filepath.Join(t.TempDir(), "linkedrepo") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + + socketFromTarget, err := SocketPath(target) + if err != nil { + t.Fatalf("SocketPath(target): %v", err) + } + socketFromLink, err := SocketPath(link) + if err != nil { + t.Fatalf("SocketPath(link): %v", err) + } + if socketFromLink != socketFromTarget { + t.Fatalf("two spellings were given two sockets: link=%q target=%q", socketFromLink, socketFromTarget) + } + + lockFromTarget, err := LockPath(target) + if err != nil { + t.Fatalf("LockPath(target): %v", err) + } + lockFromLink, err := LockPath(link) + if err != nil { + t.Fatalf("LockPath(link): %v", err) + } + if lockFromLink != lockFromTarget { + t.Fatalf("two spellings were given two locks: link=%q target=%q", lockFromLink, lockFromTarget) + } + + // And a plain directory still keys to itself: resolving only moves + // spellings that a link carries. + plainSocket, err := SocketPath(filepath.Join(t.TempDir(), "plainrepo")) + if err != nil { + t.Fatalf("SocketPath(plain): %v", err) + } + if plainSocket == socketFromTarget { + t.Fatal("a different workspace was given the same socket") + } +} From 803cdd5a1797adf0a346d9f6945039499385e96d Mon Sep 17 00:00:00 2001 From: ningmao-hlyz <2997825003@qq.com> Date: Wed, 14 Oct 2026 06:07:30 +0800 Subject: [PATCH 04/24] engine: the default workspace is the same folder as the named one engineWorkspace("") returned home without the resolution a typed home goes through, so a home behind a symlink keyed two hosts depending on whether --workspace was omitted or spelled out (#1761). The default now walks the same door. And the launch-side regression: a surface that dials through a symlinked spelling must meet the host standing at the folder it reaches. --- cmd/codeaf/engine.go | 8 ++++ cmd/codeaf/engine_symlink_test.go | 80 +++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go index 673331a302..ce2713b50b 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -1367,6 +1367,14 @@ func engineWorkspace(path string) (string, error) { } path = strings.TrimSpace(path) if path == "" { + // THE DEFAULT IS THE SAME DOOR AS THE NAMED ONE. Omitting --workspace + // means the home directory, and home reaches the resolution below only + // when somebody TYPES it — an omitted flag returned the raw spelling, so + // a home behind a symlink keyed two hosts depending on how it was said + // (#1761). + if resolved, err := filepath.EvalSymlinks(home); err == nil { + home = resolved + } return home, nil } expanded, err := expandHome(path) diff --git a/cmd/codeaf/engine_symlink_test.go b/cmd/codeaf/engine_symlink_test.go index 48e488c445..17646fbf1a 100644 --- a/cmd/codeaf/engine_symlink_test.go +++ b/cmd/codeaf/engine_symlink_test.go @@ -4,6 +4,9 @@ import ( "os" "path/filepath" "testing" + + "github.com/Agent-Field/codeaf/internal/buildinfo" + "github.com/Agent-Field/codeaf/internal/remote" ) // TestEngineWorkspaceResolvesSymlink is the regression for issue #1761: a @@ -65,3 +68,80 @@ func TestEngineWorkspaceKeepsNonSymlinkPathUnchanged(t *testing.T) { t.Fatalf("plain path changed: got %q, want %q", got, resolvedReal) } } + +// TestADialThroughASymlinkMeetsTheHostAtTheFolder is the launch-side regression +// for the review of #1798: the local surface used to hash the spelling the +// person's terminal carried (os.Getwd keeps a symlinked alias), while the +// daemon resolved its --workspace before it listened — so the surface dialed +// one host directory and the daemon was born in another, and the launch fell +// back to a conversation in the terminal. Host lookup, dial and spawn now all +// funnel through the one resolved identity (enginehost.where), so a host +// standing at the folder answers a dial that arrives through any spelling of +// it. +func TestADialThroughASymlinkMeetsTheHostAtTheFolder(t *testing.T) { + shortStateRoot(t) + target := t.TempDir() + link := filepath.Join(t.TempDir(), "linkedrepo") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + + // A daemon was handed --workspace by a door that resolves (engineWorkspace), + // so a host stands where the FOLDER hashes. Stand one there, speaking this + // build's wire so the stale-host question before the dial is answered. + standIn(t, target, remote.HostSelf{Version: remote.Version, Build: buildinfo.Identity()}, false) + + // The surface reached the folder through the link and dials with the + // spelling it carries. The dial must still meet the host — and the spawn + // callback must never fire, because something is already answering. + linkDial := &localLink{workspace: link} + conn, err := linkDial.dial() + if err != nil { + t.Fatalf("a dial through the symlink missed the host at the folder: %v", err) + } + _ = conn.Close() + + // And the target's own spelling reaches the same place, since one folder is + // one host. + targetDial := &localLink{workspace: target} + conn, err = targetDial.dial() + if err != nil { + t.Fatalf("a dial from the target spelling missed its own host: %v", err) + } + _ = conn.Close() +} + +// TestTheDefaultWorkspaceIsTheSameFolderAsTheNamedHome is the default-home +// half of the #1798 review: engineWorkspace("") used to return home without +// the resolution a TYPED home goes through, so a home behind a symlink keyed +// two hosts depending on whether --workspace was omitted or spelled out. +func TestTheDefaultWorkspaceIsTheSameFolderAsTheNamedHome(t *testing.T) { + target := t.TempDir() + link := filepath.Join(t.TempDir(), "homelink") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + // os.UserHomeDir reads HOME on unix and USERPROFILE on Windows; name both + // so the default reaches the link on every machine this runs on. + t.Setenv("HOME", link) + t.Setenv("USERPROFILE", link) + + omitted, err := engineWorkspace("") + if err != nil { + t.Fatalf("engineWorkspace(\"\"): %v", err) + } + named, err := engineWorkspace(link) + if err != nil { + t.Fatalf("engineWorkspace(home): %v", err) + } + resolved, err := filepath.EvalSymlinks(link) + if err != nil { + t.Fatalf("EvalSymlinks(home): %v", err) + } + if omitted != resolved { + t.Fatalf("the omitted flag kept the raw home spelling: got %q, want %q", omitted, resolved) + } + if omitted != named { + t.Fatalf("omitting --workspace and naming the same home keyed two workspaces: %q vs %q", omitted, named) + } +} From d26cae9588162324265aba7aa153e41ca51794f8 Mon Sep 17 00:00:00 2001 From: ningmao-hlyz <2997825003@qq.com> Date: Wed, 14 Oct 2026 06:07:30 +0800 Subject: [PATCH 05/24] tui3: the pin regression for a typed /project through a symlink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pin names the folder the link reaches, not the spelling that was typed — the typed-path half of #1761's regression, beside the daemon and host halves already on this branch. --- internal/tui3/projectcmd_symlink_test.go | 46 ++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 internal/tui3/projectcmd_symlink_test.go diff --git a/internal/tui3/projectcmd_symlink_test.go b/internal/tui3/projectcmd_symlink_test.go new file mode 100644 index 0000000000..6d9aa2bfc7 --- /dev/null +++ b/internal/tui3/projectcmd_symlink_test.go @@ -0,0 +1,46 @@ +package tui3 + +import ( + "os" + "path/filepath" + "testing" +) + +// A PIN NAMES THE FOLDER THE LINK REACHES, NOT THE SPELLING THAT WAS TYPED. +// The typed /project regression for issue #1761: a path taken through a symlink +// used to pin the alias, so home held one project under the link's name while a +// launch from the resolved path held another — two projects, two histories, two +// engines for one folder. The pin now resolves before it lands. +func TestProjectPinResolvesASymlinkedSpelling(t *testing.T) { + a, _, root := mixedLab(t) + runCmd(a.openHome()) + + real := filepath.Join(root, "realrepo") + if err := os.Mkdir(real, 0o755); err != nil { + t.Fatal(err) + } + link := filepath.Join(root, "linkedrepo") + if err := os.Symlink(real, link); err != nil { + t.Fatal(err) + } + + runCmd(a.homeSlash("/project " + link)) + + resolved, err := filepath.EvalSymlinks(link) + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + if a.target.where != resolved { + t.Fatalf("/project through a symlink pinned %q, want the folder it reaches %q", a.target.where, resolved) + } + + // And the folder's own spelling pins the same place, since one folder is + // one project. + runCmd(a.homeSlash("/project " + real)) + if a.target.where != resolved { + t.Fatalf("/project from the target pinned %q, want %q", a.target.where, resolved) + } + if a.home.msg != "" { + t.Fatalf("a taken path wrote %q over home's keys row", a.home.msg) + } +} From 7b8b378dc440642993bfd927f5b379ee480750fe Mon Sep 17 00:00:00 2001 From: ningmao-hlyz <2997825003@qq.com> Date: Wed, 14 Oct 2026 06:38:22 +0800 Subject: [PATCH 06/24] chat: the project key is named by the folder, not a spelling of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The surface's own answer — v3Workspace — minted the session bucket and the tools root from whatever spelling the terminal walked in with, so on the in-process doors one folder reached through a symlink was still a second project beside the same folder named by its resolved path. The review of #1798 caught the host half of that split; this closes the surface half the same way, at the one place a project key is minted. --- cmd/codeaf/chatv3_layout.go | 24 ++++++++++++-- cmd/codeaf/chatv3_symlink_test.go | 55 +++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 cmd/codeaf/chatv3_symlink_test.go diff --git a/cmd/codeaf/chatv3_layout.go b/cmd/codeaf/chatv3_layout.go index 11beaea45f..f51460ca0c 100644 --- a/cmd/codeaf/chatv3_layout.go +++ b/cmd/codeaf/chatv3_layout.go @@ -82,14 +82,32 @@ func encodeWorkspace(workspace string) string { // explicit workspace — the engine's --workspace, a door that has already // resolved one — is taken as given and is always borrowed: it is a place // somebody named, and naming a place is asking to work in it. +// +// A PROJECT IS NAMED BY THE FOLDER, NOT A SPELLING OF IT. Every answer here is +// a key this surface reads and writes sessions, tasks and memory under, so the +// spelling a terminal carried into the directory (a path reached through a +// symlink) may not leak into it — the engine's door resolved (#1761), and a +// bucket minted from the alias here would still have been a second project, +// a second history, for one folder. A path that does not resolve keeps its +// spelling. func v3Workspace(cwd, explicit string) (string, bool) { if named := strings.TrimSpace(explicit); named != "" { - return named, false + return v3CanonicalPlace(named), false } if root, found := v3GitRoot(cwd); found { - return root, false + return v3CanonicalPlace(root), false + } + return v3CanonicalPlace(cwd), v3NoProjectPlace(cwd) +} + +// v3CanonicalPlace resolves the links in a path that is about to become a key, +// and hands back the spelling it was given when the path does not resolve — a +// workspace not on disk yet, a test's imagined directory. +func v3CanonicalPlace(dir string) string { + if resolved, err := filepath.EvalSymlinks(dir); err == nil { + return resolved } - return cwd, v3NoProjectPlace(cwd) + return dir } // v3NoProjectPlace reports whether a directory is somewhere the person stood by diff --git a/cmd/codeaf/chatv3_symlink_test.go b/cmd/codeaf/chatv3_symlink_test.go new file mode 100644 index 0000000000..9c61191eca --- /dev/null +++ b/cmd/codeaf/chatv3_symlink_test.go @@ -0,0 +1,55 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// A PROJECT IS NAMED BY THE FOLDER, NOT A SPELLING OF IT. The surface-side +// regression for issue #1761: the bucket key a launch reads and writes sessions +// under used to carry whatever spelling the terminal walked in with, so one +// folder reached through a link was a second project — a second history — +// beside the same folder named by its resolved path. +func TestAProjectIsNamedByTheFolderNotTheSpelling(t *testing.T) { + target := t.TempDir() + link := filepath.Join(t.TempDir(), "linkedrepo") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + resolved, err := filepath.EvalSymlinks(link) + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + + // A non-git directory reached through the link keys the same bucket as the + // folder it reaches. + aliasProject, _ := v3Workspace(link, "") + if aliasProject != resolved { + t.Fatalf("the alias spelling became the project: got %q, want %q", aliasProject, resolved) + } + bucketFromAlias, err := v3ProjectDir(aliasProject) + if err != nil { + t.Fatalf("v3ProjectDir(alias): %v", err) + } + bucketFromTarget, err := v3ProjectDir(resolved) + if err != nil { + t.Fatalf("v3ProjectDir(target): %v", err) + } + if bucketFromAlias != bucketFromTarget { + t.Fatalf("one folder was given two buckets: alias=%q target=%q", bucketFromAlias, bucketFromTarget) + } + + // Naming the folder explicitly keys one project either way. + namedAlias, borrowed := v3Workspace("", link) + if namedAlias != resolved || borrowed { + t.Fatalf("an explicit workspace keys %q (borrowed=%v), want %q borrowed", namedAlias, borrowed, resolved) + } + + // And a path that does not resolve keeps its spelling. + imagined := filepath.Join(t.TempDir(), "nowhere-at-all") + kept, _ := v3Workspace(imagined, "") + if kept != imagined { + t.Fatalf("a path that is not on disk was rewritten: got %q, want %q", kept, imagined) + } +} From c0b1d9aa2086bb43c5cace84c4ef3f51aebeb31e Mon Sep 17 00:00:00 2001 From: ningmao-hlyz <2997825003@qq.com> Date: Wed, 14 Oct 2026 06:38:23 +0800 Subject: [PATCH 07/24] gitidentity: the path fallback is the resolved path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProjectKey's own doc calls the fallback "the canonical path's hash", but filepath.Abs canonicalizes nothing about links — a folder reached through a symlink keyed two memory projects (#1761). EvalSymlinks after Abs, with the absolute spelling kept when the path does not resolve. --- internal/gitidentity/project.go | 7 ++++ internal/gitidentity/project_symlink_test.go | 37 ++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 internal/gitidentity/project_symlink_test.go diff --git a/internal/gitidentity/project.go b/internal/gitidentity/project.go index 3b9c8fff31..7ebce5202f 100644 --- a/internal/gitidentity/project.go +++ b/internal/gitidentity/project.go @@ -43,6 +43,13 @@ func ProjectKey(workspace string) (string, error) { if err != nil { return "", nil } + // THE CANONICAL PATH IS THE RESOLVED ONE. A folder reached through a + // symlink is one project: the alias and the target hash to the same key, or + // memory would hold two owners for one folder (#1761). A path that does not + // resolve keeps the absolute spelling. + if resolved, err := filepath.EvalSymlinks(absolute); err == nil { + return hashKey(resolved), nil + } return hashKey(absolute), nil } diff --git a/internal/gitidentity/project_symlink_test.go b/internal/gitidentity/project_symlink_test.go new file mode 100644 index 0000000000..cd283aca49 --- /dev/null +++ b/internal/gitidentity/project_symlink_test.go @@ -0,0 +1,37 @@ +package gitidentity + +import ( + "os" + "path/filepath" + "testing" +) + +// A folder reached through a symlink is one project: the alias and the target +// hash to the same key, or memory holds two owners for one folder (#1761). A +// workspace that is not on disk keeps the absolute spelling — the key is still +// minted, because a project with no key quarantines its writes. +func TestThePathFallbackKeysOneFolderAcrossItsSpellings(t *testing.T) { + target := t.TempDir() + link := filepath.Join(t.TempDir(), "linkedrepo") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + + fromTarget, err := ProjectKey(target) + if err != nil { + t.Fatalf("ProjectKey(target): %v", err) + } + fromLink, err := ProjectKey(link) + if err != nil { + t.Fatalf("ProjectKey(link): %v", err) + } + if fromLink != fromTarget { + t.Fatalf("two spellings of one folder keyed two projects: link=%q target=%q", fromLink, fromTarget) + } + + imagined := filepath.Join(t.TempDir(), "nowhere-at-all") + key, err := ProjectKey(imagined) + if err != nil || key == "" { + t.Fatalf("a workspace that is not on disk minted no key: %q, %v", key, err) + } +} From ac09e719ccd5cd8818dd5086a90184f89610a59d Mon Sep 17 00:00:00 2001 From: ningmao-hlyz <2997825003@qq.com> Date: Wed, 14 Oct 2026 06:38:23 +0800 Subject: [PATCH 08/24] changelog: the symlink fix now names every door that mints a key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entry described the engineWorkspace door alone. The review pass that followed spread the resolution across the host identity, the default home, the surface's project key and the memory path fallback — the entry says so, because a memory that says "fixed at engineWorkspace" would send the next reader looking for one door and finding four. --- .../unreleased/1798-symlink-workspace-keys.md | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/docs/changes/unreleased/1798-symlink-workspace-keys.md b/docs/changes/unreleased/1798-symlink-workspace-keys.md index 015c7f2de0..ececeffd4e 100644 --- a/docs/changes/unreleased/1798-symlink-workspace-keys.md +++ b/docs/changes/unreleased/1798-symlink-workspace-keys.md @@ -4,16 +4,23 @@ title: a folder reached through a symlink is one project and one engine, not two pr: 1798 surface: [engine, chat] invalidates: - - "A folder reached through a symlink became two projects, two conversation histories and two engines. `codeaf engine --status --workspace ` missed the engine holding ``, and home's projects panel listed the folder once per spelling. engineWorkspace stopped at filepath.Clean, so each spelling hashed to its own enginehost host directory. engineWorkspace now resolves symlinks (filepath.EvalSymlinks after the absolute path is settled), so both spellings of one folder reach one project, one conversation history and one engine." + - "A folder reached through a symlink became two projects, two conversation histories and two engines. `codeaf engine --status --workspace ` missed the engine holding ``, and home's projects panel listed the folder once per spelling. The key-minting doors stopped at filepath.Clean, so each spelling hashed to its own key. Every door that turns a path into a key now resolves symlinks first (filepath.EvalSymlinks, with the original spelling kept when the path does not resolve): engineWorkspace (the engine commands and the host's workspace name, including the default home), enginehost.where (the host directory every lookup, dial, spawn, lock and status is keyed by), v3Workspace (the session bucket and tools root), gitidentity's path fallback (the memory project key), and the typed /project pin. Both spellings of one folder reach one project, one conversation history and one engine." --- Found in the v0.7.1 happy-path pass on macOS, where `/tmp` is a link to `/private/tmp`, and reproducible on any system with `mkdir d && ln -s d l`. -The workspace key-minting door in `cmd/codeaf` — the one every engine command -(`--daemon`, `--status`, `--stop`) and the host's workspace name go through — -expanded and cleaned the path but never resolved its links. Two spellings of -one folder then keyed two host directories in `internal/enginehost.where` -(sha256 of the cleaned string), so the person saw one folder while codeaf kept -two of everything. The fix names a workspace by its resolved path at that door; -a regression test drives a symlink through `engineWorkspace` and asserts both -spellings land on the same directory. \ No newline at end of file +`engineWorkspace` — the door every engine command (`--daemon`, `--status`, +`--stop`) and the host's workspace name go through — expanded and cleaned the +path but never resolved its links, so two spellings of one folder keyed two +host directories in `internal/enginehost.where` (sha256 of the cleaned string), +and the person saw one folder while codeaf kept two of everything. + +The resolution was first added at `engineWorkspace` alone, and the review of +this pull request caught what that left split: the local surface still hashed +the alias its terminal carried while the daemon listened under the resolved +key, and the default home skipped the door entirely. The identity now resolves +at every layer that mints a key — the host directory inside `where` itself, so +lookup, dial, spawn, lock and status cannot disagree; the default home in +`engineWorkspace`; the session bucket and memory project key in `cmd/codeaf`; +the typed `/project` pin in `internal/tui3`. A regression test drives a symlink +through each layer and asserts both spellings land on one key. From aecfbf8e6e3d704e65f275db3c465137e7524b97 Mon Sep 17 00:00:00 2001 From: ningmao-hlyz <2997825003@qq.com> Date: Thu, 15 Oct 2026 01:58:56 +0800 Subject: [PATCH 09/24] enginehost: the host an older build wrote for this folder is still its host The host directory is named by a hash of the workspace, and once that hash was taken from the resolved spelling (#1761), a directory an older build named by the raw spelling it was handed went dark for every door here: Held reads what was written inside, but Dial, Stop, the status door and the takeover rule ask by key - an upgrade stranded a running engine and the commands that must stop it. A canonical miss is now answered by host directories whose recorded workspace resolves to the same folder, and the canonical error is what the caller still sees when nothing anywhere answers. --- internal/enginehost/enginehost.go | 76 +++++++++++++++++- internal/enginehost/enginehost_legacy_test.go | 77 +++++++++++++++++++ 2 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 internal/enginehost/enginehost_legacy_test.go diff --git a/internal/enginehost/enginehost.go b/internal/enginehost/enginehost.go index b976dd3e74..313ccd18ec 100644 --- a/internal/enginehost/enginehost.go +++ b/internal/enginehost/enginehost.go @@ -163,12 +163,86 @@ func SocketPath(workspace string) (string, error) { // Dial connects to a host that is already running, and fails when none is. It // never starts one — see [Attach] for that. +// +// THE HOST AN OLDER BUILD WROTE FOR THIS FOLDER IS STILL THIS FOLDER'S HOST. +// The directory a host lives in is named by a hash of the workspace, and once +// that hash was taken from the resolved spelling (#1761), a directory an older +// build named by the raw spelling it was handed went dark for every door here: +// [Held] reads what was written inside, but Dial, Stop, the status door and the +// takeover rule ask by key. A directory whose recorded workspace ([placeName]) +// resolves to the same folder is this workspace's host whoever wrote it, so a +// canonical miss is answered by the legacy directories before this gives up — +// which is what keeps an upgrade from orphaning a running engine, its +// conversations and the commands that must still stop it. The canonical error +// is the one handed back when nothing anywhere answers, so the retry semantics +// above this door do not move. func Dial(workspace string) (net.Conn, error) { socket, err := SocketPath(workspace) if err != nil { return nil, err } - return net.DialTimeout("unix", socket, dialTimeout) + conn, dialErr := net.DialTimeout("unix", socket, dialTimeout) + if dialErr == nil { + return conn, nil + } + for _, legacy := range legacySockets(workspace) { + if conn, err := net.DialTimeout("unix", legacy, dialTimeout); err == nil { + return conn, nil + } + } + return nil, dialErr +} + +// legacySockets names the live sockets of host directories an older build wrote +// for this workspace — directories whose recorded workspace resolves to the +// same folder — and none when there are no such directories or the workspace +// itself does not resolve. It reads the state root and follows links, and makes +// nothing. +func legacySockets(workspace string) []string { + workspace = strings.TrimSpace(workspace) + if workspace == "" { + return nil + } + resolved, err := filepath.EvalSymlinks(filepath.Clean(workspace)) + if err != nil { + return nil + } + root := home.Join("v3", "hosts") + entries, err := os.ReadDir(root) + if err != nil { + return nil + } + canonical, err := where(workspace) + if err != nil { + return nil + } + var sockets []string + for _, entry := range entries { + if !entry.IsDir() { + continue + } + dir := filepath.Join(root, entry.Name()) + if dir == canonical { + continue + } + name, err := os.ReadFile(filepath.Join(dir, placeName)) + if err != nil { + continue + } + recorded := strings.TrimSpace(string(name)) + if recorded == "" { + continue + } + resolvedRecorded, err := filepath.EvalSymlinks(filepath.Clean(recorded)) + if err != nil || resolvedRecorded != resolved { + continue + } + socket := filepath.Join(dir, socketName) + if SocketPathFits(socket) { + sockets = append(sockets, socket) + } + } + return sockets } // dialTimeout is how long one connect attempt may take. A unix socket connects diff --git a/internal/enginehost/enginehost_legacy_test.go b/internal/enginehost/enginehost_legacy_test.go new file mode 100644 index 0000000000..e2a34adf04 --- /dev/null +++ b/internal/enginehost/enginehost_legacy_test.go @@ -0,0 +1,77 @@ +package enginehost + +import ( + "crypto/sha256" + "encoding/hex" + "net" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/home" +) + +// legacyHostDir mints the host directory the way a build BEFORE the resolved +// workspace key (#1761) named it: sha256 of the spelling it was handed. An +// upgrade fixture needs the old name, because that is what the installed base +// left on disk. +func legacyHostDir(t *testing.T, workspace string) string { + t.Helper() + sum := sha256.Sum256([]byte(filepath.Clean(workspace))) + dir := home.Join("v3", "hosts", hex.EncodeToString(sum[:8])) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatalf("make a legacy host directory: %v", err) + } + return dir +} + +// THE HOST AN OLDER BUILD WROTE FOR THIS FOLDER IS STILL THIS FOLDER'S HOST. +// The upgrade regression for the resolved workspace key: a host directory named +// by the raw spelling an older build was handed — a link, the way a terminal +// carries it — stays reachable by lookup, so Dial meets it, and with it Stop, +// the status door and the takeover rule keep reaching an engine the upgrade did +// not start. Before the legacy lookup, Held listed the engine and Dial missed +// its live socket, which is an upgrade that strands a running engine. +func TestADialMeetsAHostAnOlderBuildWroteForTheSameFolder(t *testing.T) { + shortHome(t) + target := t.TempDir() + link := filepath.Join(t.TempDir(), "linkedrepo") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + + // The old build was told the link's spelling and hashed that, so its host + // directory carries the old key and cites the spelling it was given. + dir := legacyHostDir(t, link) + if err := os.WriteFile(filepath.Join(dir, placeName), []byte(link+"\n"), 0o600); err != nil { + t.Fatal(err) + } + listener, err := net.Listen("unix", filepath.Join(dir, socketName)) + if err != nil { + t.Fatalf("stand a live socket in the legacy directory: %v", err) + } + t.Cleanup(func() { _ = listener.Close() }) + + // The new build asks by the FOLDER — the resolved spelling. Held already + // answers for the legacy directory; Dial must now reach it too, and the + // plain target spelling with it, since one folder is one host. + if held, err := Held(); err != nil || len(held) != 1 || !strings.HasSuffix(held[0], "linkedrepo") { + t.Fatalf("Held() = %v (%v), want the legacy directory's own citation", held, err) + } + conn, err := Dial(target) + if err != nil { + t.Fatalf("a dial by the resolved folder missed the host an older build wrote: %v", err) + } + _ = conn.Close() + conn, err = Dial(link) + if err != nil { + t.Fatalf("a dial by the legacy spelling missed its own host: %v", err) + } + _ = conn.Close() + + // And a folder that never had a host is still answered with nothing. + if _, err := Dial(filepath.Join(t.TempDir(), "nowhere")); err == nil { + t.Fatal("a workspace with no host anywhere dialed to something") + } +} From f9960664941a535d910a3d33eb96d5b86a59efc8 Mon Sep 17 00:00:00 2001 From: ningmao-hlyz <2997825003@qq.com> Date: Thu, 15 Oct 2026 01:58:56 +0800 Subject: [PATCH 10/24] chat: conversations an older build filed under another spelling still resume Buckets minted before the project key was resolved (#1761) carry the spelling the terminal walked in with, and a launch that reads only the canonical bucket opened a fresh journal beside every conversation a person already had. When the canonical bucket holds nothing spoken, the newest folder in another bucket whose meta.workspace citation resolves to this folder resumes where it lives - no migration, no merge; the canonical key takes over from there. --- cmd/codeaf/chatv3_layout.go | 70 ++++++++++++++++++++++++++++++++ cmd/codeaf/chatv3_legacy_test.go | 70 ++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 cmd/codeaf/chatv3_legacy_test.go diff --git a/cmd/codeaf/chatv3_layout.go b/cmd/codeaf/chatv3_layout.go index f51460ca0c..d12f64c081 100644 --- a/cmd/codeaf/chatv3_layout.go +++ b/cmd/codeaf/chatv3_layout.go @@ -294,6 +294,19 @@ func v3ResolveSession(explicit, workspace, launchDir string, owned bool) (v3Sess // happened. return v3Session{Place: place, Transcript: place.Transcript(), Resumed: resumed, Bucket: bucket}, nil } + + // A CONVERSATION AN OLDER BUILD FILED UNDER ANOTHER SPELLING IS STILL THIS + // WORKSPACE'S CONVERSATION. Before the project key was resolved (#1761), + // buckets were minted from whatever spelling the terminal carried, and an + // upgrade that reads only the canonical bucket would open a fresh journal + // beside every conversation a person already had. The newest spoken folder + // whose recorded workspace resolves to this folder resumes where it lives — + // no merge, no move; the canonical bucket simply takes over from here. + if legacy, legacyBucket, found := v3LegacyFolder(workspace); found { + place := v3PlaceFor(legacy.dir, workspace, owned || legacy.meta.Owned) + return v3Session{Place: place, Transcript: place.Transcript(), Resumed: true, Bucket: legacyBucket}, nil + } + place, err := v3MintSession(bucket, workspace, launchDir, owned) if err != nil { return v3Session{}, err @@ -302,6 +315,58 @@ func v3ResolveSession(explicit, workspace, launchDir string, owned bool) (v3Sess return v3Session{Place: place, Transcript: place.Transcript(), Bucket: bucket}, nil } +// v3LegacyFolder searches the project buckets OTHER than this workspace's own +// for the newest conversation somebody has spoken in whose recorded workspace +// ([session.Meta.Workspace]) resolves to the same folder, and answers it with +// the bucket it lives in. It is the upgrade road for buckets minted before the +// project key was resolved (#1761): the bucket name is a spelling's encoding and +// cannot be reversed, but every folder cites its own workspace, and a citation +// that resolves to this folder is this folder's conversation. Empty folders and +// folders that cite nothing are not candidates — a resume must come back to +// words, and a citation that was never written cannot be trusted to spellings. +func v3LegacyFolder(workspace string) (v3Folder, string, bool) { + workspace = strings.TrimSpace(workspace) + if workspace == "" { + return v3Folder{}, "", false + } + resolved, err := filepath.EvalSymlinks(filepath.Clean(workspace)) + if err != nil { + return v3Folder{}, "", false + } + root := home.Join("v3", "projects") + entries, err := os.ReadDir(root) + if err != nil { + return v3Folder{}, "", false + } + canonical := filepath.Join(root, encodeWorkspace(workspace)) + var newest v3Folder + newestBucket := "" + for _, entry := range entries { + if !entry.IsDir() { + continue + } + bucket := filepath.Join(root, entry.Name()) + if bucket == canonical { + continue + } + spoken, _ := v3ScanBucket(bucket) + for _, folder := range spoken { + recorded := strings.TrimSpace(folder.meta.Workspace) + if recorded == "" { + continue + } + resolvedRecorded, err := filepath.EvalSymlinks(filepath.Clean(recorded)) + if err != nil || resolvedRecorded != resolved { + continue + } + if newestBucket == "" || folder.at.After(newest.at) { + newest, newestBucket = folder, bucket + } + } + } + return newest, newestBucket, newestBucket != "" +} + // v3PickFolder resumes the newest conversation somebody has spoken in. A // writer mints a fresh identity when there are only unused conversations: an // old empty identity could be assigned to a team between discovery and opening. @@ -344,6 +409,11 @@ func v3LatestTranscript(workspace string) string { spoken, empty := v3ScanBucket(bucket) folder, _, found := v3PickFolder(spoken, empty, false) if !found { + // A CONVERSATION AN OLDER BUILD FILED UNDER ANOTHER SPELLING IS STILL + // THIS WORKSPACE'S CONVERSATION ([v3LegacyFolder]). + if legacy, _, found := v3LegacyFolder(workspace); found { + return session.Place{Dir: legacy.dir}.Transcript() + } return "" } return session.Place{Dir: folder.dir}.Transcript() diff --git a/cmd/codeaf/chatv3_legacy_test.go b/cmd/codeaf/chatv3_legacy_test.go new file mode 100644 index 0000000000..03f7cc929b --- /dev/null +++ b/cmd/codeaf/chatv3_legacy_test.go @@ -0,0 +1,70 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/session" +) + +// THE CONVERSATIONS AN OLDER BUILD FILED UNDER ANOTHER SPELLING ARE STILL THIS +// FOLDER'S CONVERSATIONS. The upgrade regression for the resolved project key: +// a bucket minted before #1761 was named by the raw spelling the terminal +// carried, and a launch that reads only the canonical bucket would open a fresh +// journal beside every conversation a person already had. The newest spoken +// folder whose recorded workspace resolves to this folder resumes where it +// lives; a comprehensive merge of historical buckets is not required, and none +// is attempted here. +func TestAConversationInALegacyBucketStillResumes(t *testing.T) { + t.Setenv("CODEAF_HOME", filepath.Join(t.TempDir(), "state")) + target := t.TempDir() + link := filepath.Join(t.TempDir(), "linkedrepo") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + resolved, err := filepath.EvalSymlinks(link) + if err != nil { + t.Fatal(err) + } + + // The old build wrote the bucket under the alias and cited the alias in the + // folder's own meta — the citation is the identity, the bucket name is not. + legacyBucket := filepath.Join(home.Join("v3", "projects"), encodeWorkspace(link)) + held := writeV3Session(t, legacyBucket, "00000000000000e1", "this morning", time.Now().Add(-time.Hour)) + meta, err := session.LoadMeta(held) + if err != nil { + t.Fatal(err) + } + meta.Workspace = link + if err := session.SaveMeta(held, meta); err != nil { + t.Fatal(err) + } + + // A launch that stands on the resolved folder resumes the held + // conversation, from the bucket it already lives in. + want := filepath.Join(held, "transcript.jsonl") + found, err := v3ResolveSession("", resolved, resolved, false) + if err != nil { + t.Fatal(err) + } + if found.Transcript != want { + t.Fatalf("the launch opened %q, want the conversation the old build filed (%s)", found.Transcript, want) + } + if !found.Resumed { + t.Fatal("a conversation somebody has spoken in resumed as a fresh one") + } + if found.Bucket != legacyBucket { + t.Fatalf("the resumed conversation was filed under %q, want the bucket it lives in %q", found.Bucket, legacyBucket) + } + + // And the engine's hello key — the question asked before anything opens — + // answers with the same conversation, so a plain hello joins it rather than + // booting a second agent onto the journal. + if key := engineHelloKey(remote.Hello{Version: remote.Version, Workspace: resolved}, resolved, ""); key != want { + t.Fatalf("a plain hello keyed to %q, want the held conversation (%s)", key, want) + } +} From ab9dfb69e66b177da54cedd12b7c1a3e5bf559e7 Mon Sep 17 00:00:00 2001 From: ningmao-hlyz <2997825003@qq.com> Date: Thu, 15 Oct 2026 01:58:57 +0800 Subject: [PATCH 11/24] tests: the fixtures key by the spelling the current build writes The two hello fixtures populated a bucket by the raw temporary-directory spelling while the doors they exercise resolve it; the status expectation and the typed /project pin expectation kept the raw spelling too. On macOS, where every temporary root is reached through a link, all four failed. The fixtures and expectations now stand where the current build stands, and their substantive assertions are unchanged. --- cmd/codeaf/engine_stale_test.go | 8 +++++++- cmd/codeaf/hostlatest_test.go | 12 ++++++++++++ internal/tui3/homefate_test.go | 12 +++++++++--- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/cmd/codeaf/engine_stale_test.go b/cmd/codeaf/engine_stale_test.go index 73d4542e8a..22ff9f3a48 100644 --- a/cmd/codeaf/engine_stale_test.go +++ b/cmd/codeaf/engine_stale_test.go @@ -807,11 +807,17 @@ func TestStatusSaysNoneWhenNothingHoldsTheWorkspace(t *testing.T) { shortEngineHome(t) home := t.TempDir() t.Setenv("HOME", home) + // The status door names the workspace by its resolved path (#1761); the + // expectation follows the same spelling rather than the one typed in. + resolved, err := engineWorkspace(home) + if err != nil { + t.Fatal(err) + } var out strings.Builder if err := runEngineStatus(&out, home); err != nil { t.Fatal(err) } - if got := strings.TrimSpace(out.String()); got != "no engine is holding "+home+" on this machine" { + if got := strings.TrimSpace(out.String()); got != "no engine is holding "+resolved+" on this machine" { t.Fatalf("status of an empty workspace said %q", got) } } diff --git a/cmd/codeaf/hostlatest_test.go b/cmd/codeaf/hostlatest_test.go index c4e4862efe..ab5e4c53d8 100644 --- a/cmd/codeaf/hostlatest_test.go +++ b/cmd/codeaf/hostlatest_test.go @@ -31,6 +31,13 @@ import ( func TestAPlainHelloKeysToTheConversationTheBootWouldOpen(t *testing.T) { t.Setenv("CODEAF_HOME", filepath.Join(t.TempDir(), "state")) workspace := t.TempDir() + // The fixture models a disk the CURRENT build wrote, and the current build + // names a workspace by its resolved path (#1761): on macOS the temporary + // root is reached through a link, so the fixture stands on the resolved + // spelling before it writes or asks anything. + if resolved, err := filepath.EvalSymlinks(workspace); err == nil { + workspace = resolved + } bucket, err := v3ProjectDir(workspace) if err != nil { t.Fatal(err) @@ -63,6 +70,11 @@ func TestAPlainHelloKeysToTheConversationTheBootWouldOpen(t *testing.T) { func TestAWorkspaceWithNoConversationKeysToNothingAndThenToWhatItOpened(t *testing.T) { t.Setenv("CODEAF_HOME", filepath.Join(t.TempDir(), "state")) workspace := t.TempDir() + // Same resolved fixture as above: the current build keys workspaces by + // their resolved path (#1761), so the fixture does too. + if resolved, err := filepath.EvalSymlinks(workspace); err == nil { + workspace = resolved + } if key := engineHelloKey(remote.Hello{Version: remote.Version, Workspace: workspace}, workspace, ""); key != "" { t.Fatalf("an empty workspace keyed to %s", key) diff --git a/internal/tui3/homefate_test.go b/internal/tui3/homefate_test.go index d5fafdcb48..81ef9b7966 100644 --- a/internal/tui3/homefate_test.go +++ b/internal/tui3/homefate_test.go @@ -190,19 +190,25 @@ func TestTheStoreLandingKeepsHomesSheetAboutTheNextConversation(t *testing.T) { // /project WITH A PATH TAKES THE PATH AND OPENS NOTHING. A person who typed the // folder has already answered the question the browser exists to ask, and the // pin is a string on this window rather than a round trip — so the keys row -// says the new folder on the very next frame. +// says the new folder on the very next frame. The pin names the FOLDER the path +// reaches, not the spelling that was typed (#1761), so the expectation resolves +// the fixture path the same way the pin does. func TestProjectWithAPathAtHomePinsItWithoutTheBrowser(t *testing.T) { a, _, root := mixedLab(t) runCmd(a.openHome()) inner := filepath.Join(root, "here", "inner") runCmd(a.homeSlash("/project " + inner)) + pinned, err := filepath.EvalSymlinks(inner) + if err != nil { + t.Fatal(err) + } if a.folder.open { t.Fatal("/project opened the browser instead of taking the path") } - if a.target.where != inner { - t.Fatalf("/project pinned %q, want %q", a.target.where, inner) + if a.target.where != pinned { + t.Fatalf("/project pinned %q, want %q", a.target.where, pinned) } if !a.at(pageHome) { t.Fatal("/project left home") From d21401337d1fe0124931798f4beace1a975c33a2 Mon Sep 17 00:00:00 2001 From: ningmao-hlyz <2997825003@qq.com> Date: Thu, 15 Oct 2026 01:58:57 +0800 Subject: [PATCH 12/24] changelog: the upgrade keeps what the old key wrote The entry now says how: host directories an older build named by the raw spelling stay reachable through their recorded workspace, and conversations in buckets minted the same way resume through the workspace their folders cite. --- .../changes/unreleased/1798-symlink-workspace-keys.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/changes/unreleased/1798-symlink-workspace-keys.md b/docs/changes/unreleased/1798-symlink-workspace-keys.md index ececeffd4e..33fed8b76e 100644 --- a/docs/changes/unreleased/1798-symlink-workspace-keys.md +++ b/docs/changes/unreleased/1798-symlink-workspace-keys.md @@ -24,3 +24,14 @@ lookup, dial, spawn, lock and status cannot disagree; the default home in `engineWorkspace`; the session bucket and memory project key in `cmd/codeaf`; the typed `/project` pin in `internal/tui3`. A regression test drives a symlink through each layer and asserts both spellings land on one key. + +THE UPGRADE KEEPS WHAT THE OLD KEY WROTE. A host directory and a conversation +bucket an older build named by the raw spelling are still this folder's: a +`Dial` that misses the canonical key looks for host directories whose recorded +workspace (`internal/enginehost`'s place file) resolves to the same folder, so +`--status`, `--stop`, `--status-all`, `--stop-all` and the takeover rule reach +an engine the upgrade did not start; and a launch whose canonical bucket holds +no spoken conversation resumes the newest folder in another bucket whose +`meta.workspace` citation resolves to it. No migration, no merge — the old +records are read where they live, and the canonical key takes over from there. + From 73262a5943e36a0be67ee098448ba3d07c70bb33 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 20 Oct 2026 08:55:33 -0400 Subject: [PATCH 13/24] fix(enginehost): wait for alias-keyed hosts to release their locks Keep upgrade retirement behind the original host lock until its journals finish closing. Exercise status, stop, sweeps and idle or busy takeover through both workspace spellings. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/engine_upgrade_test.go | 108 ++++++++++++++++++ .../unreleased/1798-symlink-workspace-keys.md | 5 + internal/enginehost/enginehost.go | 23 +++- internal/enginehost/enginehost_legacy_test.go | 41 +++++++ .../manual/chat/staying-on-that-machine.md | 12 ++ 5 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 cmd/codeaf/engine_upgrade_test.go diff --git a/cmd/codeaf/engine_upgrade_test.go b/cmd/codeaf/engine_upgrade_test.go new file mode 100644 index 0000000000..a09437291f --- /dev/null +++ b/cmd/codeaf/engine_upgrade_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "net" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/enginehost" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/remote" +) + +// The installed host predates canonical keys. Construct its directory without +// calling today's identity helpers, then use the ordinary command doors. +func legacyEngineStandIn(t *testing.T, alias string, busy bool) *standInHost { + t.Helper() + sum := sha256.Sum256([]byte(filepath.Clean(alias))) + dir := home.Join("v3", "hosts", hex.EncodeToString(sum[:8])) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "workspace"), []byte(alias+"\n"), 0o600); err != nil { + t.Fatal(err) + } + listener, err := net.Listen("unix", filepath.Join(dir, "host.sock")) + if err != nil { + t.Fatal(err) + } + host := &standInHost{listener: listener, self: remote.HostSelf{ + Version: remote.Version, Build: "two-days-ago", BuiltAt: earlier, + Workspace: alias, PID: 4242, Busy: busy, + }} + t.Cleanup(host.close) + go host.serve() + return host +} + +func TestLegacyEngineUpgradeDoors(t *testing.T) { + for _, spelling := range []string{"resolved", "alias"} { + for _, door := range []string{"dial", "stop", "stop-all", "takeover", "busy-takeover"} { + t.Run(spelling+"/"+door, func(t *testing.T) { + shortEngineHome(t) + target := t.TempDir() + alias := filepath.Join(t.TempDir(), "alias") + if err := os.Symlink(target, alias); err != nil { + t.Fatal(err) + } + workspace, err := filepath.EvalSymlinks(target) + if err != nil { + t.Fatal(err) + } + if spelling == "alias" { + workspace = alias + } + host := legacyEngineStandIn(t, alias, door == "busy-takeover") + if held, err := enginehost.Held(); err != nil || len(held) != 1 || held[0] != alias { + t.Fatalf("Held = %v, %v; want the old workspace citation %s", held, err, alias) + } + var status strings.Builder + if err := runEngineStatus(&status, workspace); err != nil || !strings.Contains(status.String(), "is held by an engine") { + t.Fatalf("status = %q, %v", status.String(), err) + } + status.Reset() + if err := runEngineStatusAll(&status); err != nil || !strings.Contains(status.String(), alias+" is held by an engine") { + t.Fatalf("status-all = %q, %v", status.String(), err) + } + switch door { + case "dial": + conn, err := enginehost.Dial(workspace) + if err != nil { + t.Fatal(err) + } + _ = conn.Close() + return + case "stop": + if stopped, err := enginehost.Stop(workspace); err != nil || !stopped { + t.Fatalf("Stop = %v, %v", stopped, err) + } + case "stop-all": + if err := runEngineStopAll(); err != nil { + t.Fatal(err) + } + case "takeover", "busy-takeover": + note, err := clearStaleEngineHostAs(workspace, window("today", later)) + if err != nil { + t.Fatal(err) + } + if door == "busy-takeover" { + if !strings.Contains(note, "still holding work") || !host.waitingSaid() || host.forced() || !hostAnswers(workspace) { + t.Fatalf("busy legacy host was not safely joined: %q, asks %+v", note, host.questions()) + } + return + } + if !strings.Contains(note, "replaced the older engine") || host.forced() { + t.Fatalf("idle legacy host was not safely replaced: %q, asks %+v", note, host.questions()) + } + } + if hostAnswers(workspace) { + t.Fatal("legacy host still answers after retirement") + } + }) + } + } +} diff --git a/docs/changes/unreleased/1798-symlink-workspace-keys.md b/docs/changes/unreleased/1798-symlink-workspace-keys.md index 33fed8b76e..a46b4a5144 100644 --- a/docs/changes/unreleased/1798-symlink-workspace-keys.md +++ b/docs/changes/unreleased/1798-symlink-workspace-keys.md @@ -35,3 +35,8 @@ no spoken conversation resumes the newest folder in another bucket whose `meta.workspace` citation resolves to it. No migration, no merge — the old records are read where they live, and the canonical key takes over from there. +Retiring an alias-keyed host also waits for its original lock, not just the new +canonical lock: the listener can disappear while the old engine is still flushing +its journals. Upgrade regressions exercise status, stop, sweeps and idle/busy +takeover through both spellings. + diff --git a/internal/enginehost/enginehost.go b/internal/enginehost/enginehost.go index 1b43ca126b..9d468d795d 100644 --- a/internal/enginehost/enginehost.go +++ b/internal/enginehost/enginehost.go @@ -447,14 +447,31 @@ func waitForGone(workspace string, within time.Duration) error { if err != nil { return err } + // A host reached through the upgrade lookup holds its OLD directory's + // lock until its journals have closed. The canonical lock alone is free + // throughout that shutdown, so it cannot prove the workspace is free. + dirs := []string{dir} + for _, socket := range legacySockets(workspace) { + dirs = append(dirs, filepath.Dir(socket)) + } deadline := time.Now().Add(within) for { conn, err := Dial(workspace) if err == nil { _ = conn.Close() - } else if held, err := takeLock(filepath.Join(dir, lockName)); err == nil { - _ = releaseLock(held) - return nil + } else { + free := true + for _, dir := range dirs { + held, err := takeLock(filepath.Join(dir, lockName)) + if err != nil { + free = false + break + } + _ = releaseLock(held) + } + if free { + return nil + } } if time.Now().After(deadline) { return errors.New("engine host: the host is still holding this workspace") diff --git a/internal/enginehost/enginehost_legacy_test.go b/internal/enginehost/enginehost_legacy_test.go index e2a34adf04..cba43847c4 100644 --- a/internal/enginehost/enginehost_legacy_test.go +++ b/internal/enginehost/enginehost_legacy_test.go @@ -26,6 +26,47 @@ func legacyHostDir(t *testing.T, workspace string) string { return dir } +// A host drops its listener before it finishes flushing journals. The old +// directory's lock must keep replacement waiting throughout that interval. +func TestLegacyRetirementWaitsForTheLegacyLock(t *testing.T) { + shortHome(t) + target := t.TempDir() + link := filepath.Join(t.TempDir(), "linkedrepo") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + dir := legacyHostDir(t, link) + if err := os.WriteFile(filepath.Join(dir, placeName), []byte(link), 0o600); err != nil { + t.Fatal(err) + } + listener, err := net.Listen("unix", filepath.Join(dir, socketName)) + if err != nil { + t.Fatal(err) + } + lock, err := takeLock(filepath.Join(dir, lockName)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = releaseLock(lock) }() + if err := listener.Close(); err != nil { + t.Fatal(err) + } + for _, workspace := range []string{target, link} { + if err := waitForGone(workspace, 0); err == nil { + t.Errorf("retirement through %s completed while the legacy host still held its lock", workspace) + } + } + if err := releaseLock(lock); err != nil { + t.Fatal(err) + } + lock = nil + for _, workspace := range []string{target, link} { + if err := waitForGone(workspace, 0); err != nil { + t.Errorf("retirement through %s did not finish after the legacy lock was released: %v", workspace, err) + } + } +} + // THE HOST AN OLDER BUILD WROTE FOR THIS FOLDER IS STILL THIS FOLDER'S HOST. // The upgrade regression for the resolved workspace key: a host directory named // by the raw spelling an older build was handed — a link, the way a terminal diff --git a/internal/manual/chat/staying-on-that-machine.md b/internal/manual/chat/staying-on-that-machine.md index d25e5eced1..3f3b39796c 100644 --- a/internal/manual/chat/staying-on-that-machine.md +++ b/internal/manual/chat/staying-on-that-machine.md @@ -441,6 +441,18 @@ cold on the next connection rather than staying warm. If what you are actually chasing is a reply that stopped and said so on screen, this is not the page — *Models and cost* has the sentence you read and what each of them means. +## Symlink aliases and engines started before an upgrade + +A folder and a symlink pointing to it reach the same engine, including when your home +directory is a symlink and you omit `--workspace`. An engine started by an older build +under the alias stays reachable through either spelling: `--status`, `--stop`, +`--status-all` and `--stop-all` can still find it. Launching a newer build can replace +that engine when it is idle; work in flight keeps running. A replacement waits for the +old engine to finish closing its journals as well as closing its socket. + +Compatibility lookup needs the recorded alias to still resolve to the folder. A deleted +or broken symlink cannot identify its old engine through the resolved folder. + ## Which engine is holding my folder — codeaf engine --status, what process, which binary, which build ``` From 58b3a982397f9d8aa7a34bb284ff8762a2874755 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 20 Oct 2026 09:03:28 -0400 Subject: [PATCH 14/24] fix(chat): resolve legacy hello keys before abandoned empty sessions Use the same spoken-conversation priority for hello discovery and boot. Prove that two real socket connections reuse one agent for an alias-keyed conversation even when the canonical bucket contains an empty session. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/chatv3_layout.go | 10 +- cmd/codeaf/chatv3_legacy_test.go | 97 +++++++++++++++++++ .../unreleased/1798-symlink-workspace-keys.md | 5 + internal/manual/chat/sessions-and-rewind.md | 18 +++- 4 files changed, 125 insertions(+), 5 deletions(-) diff --git a/cmd/codeaf/chatv3_layout.go b/cmd/codeaf/chatv3_layout.go index 53308ff491..6eeb2f2e64 100644 --- a/cmd/codeaf/chatv3_layout.go +++ b/cmd/codeaf/chatv3_layout.go @@ -408,13 +408,17 @@ func v3LatestTranscript(workspace string) string { return "" } spoken, empty := v3ScanBucket(bucket) - folder, _, found := v3PickFolder(spoken, empty, false) - if !found { + folder, resumed, found := v3PickFolder(spoken, empty, false) + if !resumed { // A CONVERSATION AN OLDER BUILD FILED UNDER ANOTHER SPELLING IS STILL - // THIS WORKSPACE'S CONVERSATION ([v3LegacyFolder]). + // THIS WORKSPACE'S CONVERSATION ([v3LegacyFolder]). Boot prefers those + // words to an abandoned empty canonical folder, so hello must too or it + // would boot a second agent onto the legacy journal the host holds. if legacy, _, found := v3LegacyFolder(workspace); found { return session.Place{Dir: legacy.dir}.Transcript() } + } + if !found { return "" } return session.Place{Dir: folder.dir}.Transcript() diff --git a/cmd/codeaf/chatv3_legacy_test.go b/cmd/codeaf/chatv3_legacy_test.go index 03f7cc929b..2f58c54fa0 100644 --- a/cmd/codeaf/chatv3_legacy_test.go +++ b/cmd/codeaf/chatv3_legacy_test.go @@ -3,9 +3,11 @@ package main import ( "os" "path/filepath" + "sync/atomic" "testing" "time" + "github.com/Agent-Field/codeaf/internal/enginehost" "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/remote" "github.com/Agent-Field/codeaf/internal/session" @@ -68,3 +70,98 @@ func TestAConversationInALegacyBucketStillResumes(t *testing.T) { t.Fatalf("a plain hello keyed to %q, want the held conversation (%s)", key, want) } } + +// Boot ignores abandoned empty folders. A hello must prefer the same spoken +// legacy conversation, or it will boot onto a journal the host already holds. +func TestLegacyHelloAndBootAgreeWithAnEmptyCanonicalBucket(t *testing.T) { + shortEngineHome(t) + target := t.TempDir() + link := filepath.Join(t.TempDir(), "linkedrepo") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + workspace, _ := v3Workspace(link, link) + legacyBucket := home.Join("v3", "projects", encodeWorkspace(link)) + legacy := writeV3Session(t, legacyBucket, "00000000000000e2", "keep this conversation", time.Unix(100, 0)) + meta, err := session.LoadMeta(legacy) + if err != nil { + t.Fatal(err) + } + meta.Workspace = link + if err := session.SaveMeta(legacy, meta); err != nil { + t.Fatal(err) + } + bucket, err := v3ProjectDir(workspace) + if err != nil { + t.Fatal(err) + } + writeV3Session(t, bucket, "00000000000000e3", "", time.Time{}) + want := session.Place{Dir: legacy}.Transcript() + for _, spelling := range []string{workspace, link} { + key := engineHelloKey(remote.Hello{Version: remote.Version, Workspace: spelling}, spelling, "") + if key != want { + t.Errorf("hello through %s chose %s, want legacy conversation %s", spelling, key, want) + } + project, owned := v3Workspace(spelling, spelling) + found, err := v3ResolveSession("", project, spelling, owned) + if err != nil || found.Transcript != want || !found.Resumed { + t.Fatalf("boot through %s = %+v, %v; want legacy conversation %s", spelling, found, err, want) + } + } + + // Exercise the host's real lookup over two socket connections. The second + // plain hello must join the held agent instead of booting its journal again. + var boots atomic.Int32 + stopped := make(chan error, 1) + go func() { + stopped <- enginehost.Run(workspace, enginehost.Options{ + Key: func(hello remote.Hello) string { return engineHelloKey(hello, workspace, "") }, + Boot: func(remote.Hello) (*remote.Engine, error) { + boots.Add(1) + found, err := v3ResolveSession("", workspace, workspace, false) + if err != nil { + return nil, err + } + return &remote.Engine{Agent: &quietAgent{}, Workspace: workspace, SessionFile: found.Transcript}, nil + }, + }) + }() + t.Cleanup(func() { + _, _ = enginehost.Stop(workspace) + select { + case err := <-stopped: + if err != nil { + t.Errorf("host stopped: %v", err) + } + case <-time.After(5 * time.Second): + t.Error("host did not stop") + } + }) + deadline := time.Now().Add(5 * time.Second) + for { + if _, err := enginehost.Ask(workspace, remote.WhoIs{}); err == nil { + break + } + if time.Now().After(deadline) { + t.Fatal("host did not start") + } + time.Sleep(10 * time.Millisecond) + } + for _, spelling := range []string{workspace, link} { + conn, err := enginehost.Dial(spelling) + if err != nil { + t.Fatal(err) + } + client, err := remote.Dial(conn, "", remote.Hello{Version: remote.Version, Workspace: spelling}) + if err != nil { + t.Fatal(err) + } + defer client.Close() + if got := client.Welcome().SessionFile; got != want { + t.Fatalf("hello opened %s, want %s", got, want) + } + } + if got := boots.Load(); got != 1 { + t.Fatalf("two plain hellos booted %d agents onto one legacy journal; want one", got) + } +} diff --git a/docs/changes/unreleased/1798-symlink-workspace-keys.md b/docs/changes/unreleased/1798-symlink-workspace-keys.md index a46b4a5144..112dd1db30 100644 --- a/docs/changes/unreleased/1798-symlink-workspace-keys.md +++ b/docs/changes/unreleased/1798-symlink-workspace-keys.md @@ -40,3 +40,8 @@ canonical lock: the listener can disappear while the old engine is still flushin its journals. Upgrade regressions exercise status, stop, sweeps and idle/busy takeover through both spellings. + +A spoken alias-keyed conversation also outranks an abandoned empty canonical +folder during hello discovery, matching boot's resume order. Otherwise two plain +hellos could boot two agents onto the same legacy journal. A real-socket regression +asserts both hellos open that journal with one boot. diff --git a/internal/manual/chat/sessions-and-rewind.md b/internal/manual/chat/sessions-and-rewind.md index 3dffdda771..cf0b3c6286 100644 --- a/internal/manual/chat/sessions-and-rewind.md +++ b/internal/manual/chat/sessions-and-rewind.md @@ -362,8 +362,8 @@ at. ## Picking up where you left off -Launching with no arguments resumes **this directory's most recently written conversation**, -chosen by file modification time. A directory that has never held one gets a fresh file. +Launching with no arguments resumes **this directory's conversation you last spoke in**, +chosen by its last user message time. A directory that has never held one gets a fresh file. It opens showing the last **40** blocks of that conversation rather than all of it, which is what keeps the first frame quick. Everything older is still there and still reachable: @@ -936,3 +936,17 @@ rather than taking over an empty conversation that could acquire membership duri `Conversation unavailable` on a team card means its saved transcript is missing. A missing file is not recreated silently; add a new conversation and choose it as manager explicitly. + +## Symlink folders and resuming conversations from before an upgrade + +A folder and a symlink pointing to it are one project. If an older build saved your +conversation under the alias, launching from either spelling can still resume it where +it lives. When the resolved folder has no conversation you have spoken in, codeaf looks +for the most recently spoken conversation whose recorded workspace still resolves to +that folder. An abandoned empty conversation in the new location does not hide it, and +another window joins the same conversation the engine is already holding. + +Existing spoken history in the resolved folder takes priority. This does not combine or +move historical folders. A broken or removed alias cannot identify an old conversation +through the resolved folder; `codeaf chat --session ` still opens a transcript you +name explicitly. From 79b81714ed200ed413b392c845156aa2cbce01c9 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 20 Oct 2026 13:24:19 -0400 Subject: [PATCH 15/24] fix(chat): resume owned legacy sessions by launch citation Use LaunchDir only for owned conversations so hello and boot find their old HOME or temporary bucket and retain their private workspace. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/chatv3_layout.go | 5 +++ cmd/codeaf/review_owned_upgrade_test.go | 49 +++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 cmd/codeaf/review_owned_upgrade_test.go diff --git a/cmd/codeaf/chatv3_layout.go b/cmd/codeaf/chatv3_layout.go index 6eeb2f2e64..7cba311c17 100644 --- a/cmd/codeaf/chatv3_layout.go +++ b/cmd/codeaf/chatv3_layout.go @@ -353,6 +353,11 @@ func v3LegacyFolder(workspace string) (v3Folder, string, bool) { spoken, _ := v3ScanBucket(bucket) for _, folder := range spoken { recorded := strings.TrimSpace(folder.meta.Workspace) + // Owned work/ is private to the conversation. Its launch citation + // names the project bucket the older build chose for HOME or temp. + if folder.meta.Owned { + recorded = strings.TrimSpace(folder.meta.LaunchDir) + } if recorded == "" { continue } diff --git a/cmd/codeaf/review_owned_upgrade_test.go b/cmd/codeaf/review_owned_upgrade_test.go new file mode 100644 index 0000000000..31d10d16da --- /dev/null +++ b/cmd/codeaf/review_owned_upgrade_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" +) + +func TestReviewExistingOwnedHomeConversationResumes(t *testing.T) { + t.Setenv("CODEAF_HOME", filepath.Join(t.TempDir(), "state")) + target := t.TempDir() + alias := filepath.Join(t.TempDir(), "home") + if err := os.Symlink(target, alias); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", alias) + oldBucket := home.Join("v3", "projects", encodeWorkspace(alias)) + old := writeV3Session(t, oldBucket, "00000000000000f1", "Remember the draft we wrote at home", time.Now()) + meta, err := session.LoadMeta(old) + if err != nil { + t.Fatal(err) + } + meta.Owned, meta.Workspace, meta.LaunchDir = true, filepath.Join(old, "work"), alias + if err := os.MkdirAll(meta.Workspace, 0700); err != nil { + t.Fatal(err) + } + if err := session.SaveMeta(old, meta); err != nil { + t.Fatal(err) + } + project, owned := v3Workspace(alias, "") + found, err := v3ResolveSession("", project, alias, owned) + if err != nil { + t.Fatal(err) + } + t.Logf("old bucket=%s; new project=%s; legacy meta.workspace=%s", oldBucket, project, meta.Workspace) + if hello := v3LatestTranscript(project); hello != found.Transcript { + t.Fatalf("hello selected %s; boot selected %s", hello, found.Transcript) + } + if !found.Place.Owned || found.Place.Workspace != meta.Workspace { + t.Fatalf("resume lost owned workspace: %+v", found.Place) + } + if !found.Resumed || found.Transcript != (session.Place{Dir: old}).Transcript() { + t.Fatalf("unchanged launch at symlinked HOME opened %s (resumed=%v), want %s", found.Transcript, found.Resumed, (session.Place{Dir: old}).Transcript()) + } +} From 1d68c6385aee2553edace7ecad91ead1efc11502 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 20 Oct 2026 13:31:21 -0400 Subject: [PATCH 16/24] fix(enginehost): acquire canonical and proven legacy lifetime locks together Use one directory inventory for Attach, Run, startup probing and retirement. Recheck under the canonical lock and retain legacy locks for the host lifetime so a listener gap cannot allow a second engine. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/enginehost/dialstart.go | 28 ++-- internal/enginehost/enginehost.go | 148 +++++++++++------- internal/enginehost/host.go | 31 ++-- .../enginehost/review_run_upgrade_test.go | 82 ++++++++++ .../review_starting_upgrade_test.go | 103 ++++++++++++ 5 files changed, 307 insertions(+), 85 deletions(-) create mode 100644 internal/enginehost/review_run_upgrade_test.go create mode 100644 internal/enginehost/review_starting_upgrade_test.go diff --git a/internal/enginehost/dialstart.go b/internal/enginehost/dialstart.go index cf3781512f..0f19d0b226 100644 --- a/internal/enginehost/dialstart.go +++ b/internal/enginehost/dialstart.go @@ -51,22 +51,20 @@ const ( // and never a wait, and because it reads the file rather than a pid it is correct // across pid namespaces. A missing lock file means no host has taken it. func hostLockHeld(workspace string) bool { - // where, not Dir: probing whether a host is coming up must not create the - // host directory (Dir would MkdirAll it), the same rule Dial and SocketPath - // keep so a mere question leaves no litter behind. - dir, err := where(workspace) - if err != nil { - return false - } - file, err := os.OpenFile(filepath.Join(dir, lockName), os.O_RDWR, 0o600) - if err != nil { - return false - } - defer file.Close() - if err := filelock.Lock(file, true, true); err != nil { - return filelock.IsBusy(err) + for _, dir := range hostDirs(workspace) { + file, err := os.OpenFile(filepath.Join(dir, lockName), os.O_RDWR, 0o600) + if err != nil { + continue + } + err = filelock.Lock(file, true, true) + if err == nil { + _ = filelock.Unlock(file) + } + _ = file.Close() + if filelock.IsBusy(err) { + return true + } } - _ = filelock.Unlock(file) return false } diff --git a/internal/enginehost/enginehost.go b/internal/enginehost/enginehost.go index 9d468d795d..b36dc5a1b3 100644 --- a/internal/enginehost/enginehost.go +++ b/internal/enginehost/enginehost.go @@ -193,48 +193,55 @@ func Dial(workspace string) (net.Conn, error) { return nil, dialErr } -// legacySockets names the live sockets of host directories an older build wrote -// for this workspace — directories whose recorded workspace resolves to the -// same folder — and none when there are no such directories or the workspace -// itself does not resolve. It reads the state root and follows links, and makes -// nothing. -func legacySockets(workspace string) []string { - workspace = strings.TrimSpace(workspace) - if workspace == "" { - return nil - } - resolved, err := filepath.EvalSymlinks(filepath.Clean(workspace)) +// hostDirs is the shared lock and socket inventory. The incoming spelling is +// proof of its old key even before a citation was written. Other old keys need +// a live workspace citation; a socket need not exist yet. +func hostDirs(workspace string) []string { + canonical, err := where(workspace) if err != nil { return nil } - root := home.Join("v3", "hosts") - entries, err := os.ReadDir(root) - if err != nil { - return nil + dirs := []string{canonical} + seen := map[string]bool{canonical: true} + add := func(dir string) { + if info, err := os.Lstat(dir); err == nil && info.IsDir() && !seen[dir] { + dirs = append(dirs, dir) + seen[dir] = true + } } - canonical, err := where(workspace) + raw := sha256.Sum256([]byte(filepath.Clean(strings.TrimSpace(workspace)))) + add(home.Join("v3", "hosts", hex.EncodeToString(raw[:8]))) + resolved, err := filepath.EvalSymlinks(filepath.Clean(workspace)) if err != nil { - return nil + return dirs } - var sockets []string + root := home.Join("v3", "hosts") + entries, _ := os.ReadDir(root) for _, entry := range entries { if !entry.IsDir() { continue } dir := filepath.Join(root, entry.Name()) - if dir == canonical { + if seen[dir] { continue } name, err := os.ReadFile(filepath.Join(dir, placeName)) - if err != nil { + if err != nil || strings.TrimSpace(string(name)) == "" { continue } - recorded := strings.TrimSpace(string(name)) - if recorded == "" { - continue + recorded, err := filepath.EvalSymlinks(filepath.Clean(strings.TrimSpace(string(name)))) + if err == nil && recorded == resolved { + add(dir) } - resolvedRecorded, err := filepath.EvalSymlinks(filepath.Clean(recorded)) - if err != nil || resolvedRecorded != resolved { + } + return dirs +} + +func legacySockets(workspace string) []string { + dirs := hostDirs(workspace) + var sockets []string + for i, dir := range dirs { + if i == 0 { continue } socket := filepath.Join(dir, socketName) @@ -245,6 +252,48 @@ func legacySockets(workspace string) []string { return sockets } +// takeWorkspaceLocks serializes acquisition on the canonical key, then holds +// every proven legacy lock together. Re-enumerating under that lock closes the +// preflight/acquisition gap between new builds. Run keeps the complete set for +// its lifetime, so an older build cannot start on a known old key during it. +func takeWorkspaceLocks(workspace string) ([]*os.File, error) { + dir, err := Dir(workspace) + if err != nil { + return nil, err + } + first, err := takeLock(filepath.Join(dir, lockName)) + if err != nil { + return nil, err + } + locks := []*os.File{first} + held := map[string]bool{dir: true} + for { + added := false + for _, legacy := range hostDirs(workspace) { + if held[legacy] { + continue + } + lock, err := takeLock(filepath.Join(legacy, lockName)) + if err != nil { + releaseWorkspaceLocks(locks) + return nil, err + } + locks = append(locks, lock) + held[legacy] = true + added = true + } + if !added { + return locks, nil + } + } +} + +func releaseWorkspaceLocks(locks []*os.File) { + for i := len(locks) - 1; i >= 0; i-- { + _ = releaseLock(locks[i]) + } +} + // dialTimeout is how long one connect attempt may take. A unix socket connects // instantly or not at all; this exists so a socket file whose host is wedged // cannot hang the surface's whole launch. @@ -284,19 +333,18 @@ func Attach(workspace string, spawn func() error) (net.Conn, error) { if spawn == nil { return nil, errors.New("engine host: nothing to start a host with") } - dir, err := Dir(workspace) - if err != nil { + if _, err := Dir(workspace); err != nil { return nil, err } - // A lock that is BUSY is an answer and not a failure: somebody else is - // already the host or is starting one, and waiting for their socket is all - // this connection has left to do. - if held, err := takeLock(filepath.Join(dir, lockName)); err == nil { - // The lock is released BEFORE the spawn rather than after it, because - // the host we are about to start wants this very lock for its own life. - // The window that opens is the one described above, and it costs at - // most a second process that finds the lock taken and exits. - _ = releaseLock(held) + // A successful probe holds the SAME inventory Run will acquire again. + // Run's atomic reacquisition is the handoff: if an older host takes a legacy + // lock after this release, the child refuses it before opening any socket. + if locks, err := takeWorkspaceLocks(workspace); err == nil { + if conn, err := Dial(workspace); err == nil { + releaseWorkspaceLocks(locks) + return conn, nil + } + releaseWorkspaceLocks(locks) if err := spawn(); err != nil { return nil, err } @@ -443,35 +491,23 @@ func retire(workspace string, ask remote.WhoIs) error { // moment, and a caller that spawned into that moment would watch its new host // find the lock busy and exit without a word. func waitForGone(workspace string, within time.Duration) error { - dir, err := Dir(workspace) - if err != nil { + if _, err := Dir(workspace); err != nil { return err } - // A host reached through the upgrade lookup holds its OLD directory's - // lock until its journals have closed. The canonical lock alone is free - // throughout that shutdown, so it cannot prove the workspace is free. - dirs := []string{dir} - for _, socket := range legacySockets(workspace) { - dirs = append(dirs, filepath.Dir(socket)) - } deadline := time.Now().Add(within) for { conn, err := Dial(workspace) if err == nil { _ = conn.Close() - } else { - free := true - for _, dir := range dirs { - held, err := takeLock(filepath.Join(dir, lockName)) - if err != nil { - free = false - break - } - _ = releaseLock(held) - } - if free { + } else if locks, err := takeWorkspaceLocks(workspace); err == nil { + // Recheck while all lifetime locks are held; no ready or starting host can + // enter between a successful preflight and this answer. + conn, err := Dial(workspace) + releaseWorkspaceLocks(locks) + if err != nil { return nil } + _ = conn.Close() } if time.Now().After(deadline) { return errors.New("engine host: the host is still holding this workspace") diff --git a/internal/enginehost/host.go b/internal/enginehost/host.go index a222f01f28..94d65167ba 100644 --- a/internal/enginehost/host.go +++ b/internal/enginehost/host.go @@ -123,8 +123,9 @@ type Host struct { dir string opts Options - listener net.Listener - lock *os.File + listener net.Listener + lock *os.File + legacyLocks []*os.File // binary is the file this host was started from, as it looked when it // started. A host whose own binary has been replaced retires the moment it @@ -191,7 +192,7 @@ func Run(workspace string, opts Options) error { // the socket exists and released only when this process ends, so "is there // a host" is a question the operating system answers rather than a file // somebody has to remember to delete. - lock, err := takeLock(filepath.Join(dir, lockName)) + locks, err := takeWorkspaceLocks(workspace) if err != nil { return ErrHostRunning } @@ -209,7 +210,7 @@ func Run(workspace string, opts Options) error { _ = os.Remove(socket) listener, err := net.Listen("unix", socket) if err != nil { - _ = releaseLock(lock) + releaseWorkspaceLocks(locks) return fmt.Errorf("engine host: %w", err) } // The readable half of the directory's name, for a person looking at @@ -217,16 +218,17 @@ func Run(workspace string, opts Options) error { _ = os.WriteFile(filepath.Join(dir, placeName), []byte(workspace+"\n"), 0o600) h := &Host{ - workspace: workspace, - dir: dir, - opts: opts, - binary: thisBinary(), - started: time.Now(), - listener: listener, - lock: lock, - sessions: map[string]*remote.Session{}, - quiet: time.Now(), - done: make(chan struct{}), + workspace: workspace, + dir: dir, + opts: opts, + binary: thisBinary(), + started: time.Now(), + listener: listener, + lock: locks[0], + legacyLocks: locks[1:], + sessions: map[string]*remote.Session{}, + quiet: time.Now(), + done: make(chan struct{}), } return h.serve() } @@ -801,6 +803,7 @@ func (h *Host) shutdown() { socket := filepath.Join(h.dir, socketName) _ = h.listener.Close() _ = os.Remove(socket) + releaseWorkspaceLocks(h.legacyLocks) _ = releaseLock(h.lock) } diff --git a/internal/enginehost/review_run_upgrade_test.go b/internal/enginehost/review_run_upgrade_test.go new file mode 100644 index 0000000000..59ac7ed429 --- /dev/null +++ b/internal/enginehost/review_run_upgrade_test.go @@ -0,0 +1,82 @@ +package enginehost + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "net" + "os" + "path/filepath" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/remote" +) + +func TestReviewRunRefusesStartingLegacyLock(t *testing.T) { + shortHome(t) + target := t.TempDir() + alias := filepath.Join(t.TempDir(), "project") + if err := os.Symlink(target, alias); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256([]byte(filepath.Clean(alias))) + dir := home.Join("v3", "hosts", hex.EncodeToString(sum[:8])) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, placeName), []byte(alias), 0600); err != nil { + t.Fatal(err) + } + oldLock, err := takeLock(filepath.Join(dir, lockName)) + if err != nil { + t.Fatal(err) + } + defer func() { + if oldLock != nil { + releaseLock(oldLock) + } + }() + done := make(chan error, 1) + go func() { + done <- Run(alias, Options{Boot: func(remote.Hello) (*remote.Engine, error) { return nil, errors.New("fixture never opens an agent") }}) + }() + socket, err := SocketPath(alias) + if err != nil { + t.Fatal(err) + } + deadline := time.NewTimer(5 * time.Second) + defer deadline.Stop() + tick := time.NewTicker(10 * time.Millisecond) + defer tick.Stop() + for { + select { + case err := <-done: + if !errors.Is(err, ErrHostRunning) { + t.Fatalf("Run returned %v; want ErrHostRunning while legacy lock held", err) + } + return + case <-tick.C: + conn, err := net.DialTimeout("unix", socket, 20*time.Millisecond) + if err != nil { + continue + } + conn.Close() + // Leave a clean host behind even when the assertion fails. + releaseLock(oldLock) + oldLock = nil + if stopped, err := Stop(alias); err != nil || !stopped { + t.Errorf("cleanup Stop=%v,%v", stopped, err) + } + select { + case <-done: + case <-time.After(5 * time.Second): + t.Error("new host did not shut down") + } + t.Fatal("Run opened a second host socket while the legacy lifetime lock was still held") + case <-deadline.C: + t.Fatal("Run neither refused the legacy lock nor opened a socket") + } + } +} diff --git a/internal/enginehost/review_starting_upgrade_test.go b/internal/enginehost/review_starting_upgrade_test.go new file mode 100644 index 0000000000..fc3120afd5 --- /dev/null +++ b/internal/enginehost/review_starting_upgrade_test.go @@ -0,0 +1,103 @@ +package enginehost + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/home" +) + +func TestReviewAttachDoesNotSpawnBesideStartingLegacyHost(t *testing.T) { + shortHome(t) + target := t.TempDir() + alias := filepath.Join(t.TempDir(), "project") + if err := os.Symlink(target, alias); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256([]byte(filepath.Clean(alias))) + dir := home.Join("v3", "hosts", hex.EncodeToString(sum[:8])) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + // The workspace record is already present when a legacy host restarts in + // an old directory. Its life lock is taken, but Listen has not run yet. + if err := os.WriteFile(filepath.Join(dir, placeName), []byte(alias), 0600); err != nil { + t.Fatal(err) + } + lock, err := takeLock(filepath.Join(dir, lockName)) + if err != nil { + t.Fatal(err) + } + defer releaseLock(lock) + spawned := false + conn, err := Attach(alias, func() error { + spawned = true + return errors.New("review fixture: canonical process would now start") + }) + if conn != nil { + conn.Close() + } + t.Logf("Attach returned %v; spawned=%v while legacy lock held", err, spawned) + if spawned { + t.Fatal("upgrade spawned a new host while a legacy engine still held its lifetime lock") + } +} + +// Startup probing must see the same no-listener legacy lock as Attach and Run. +func TestReviewStartingProbeSeesLegacyLifetimeLock(t *testing.T) { + shortHome(t) + target := t.TempDir() + alias := filepath.Join(t.TempDir(), "project") + if err := os.Symlink(target, alias); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256([]byte(filepath.Clean(alias))) + dir := home.Join("v3", "hosts", hex.EncodeToString(sum[:8])) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, placeName), []byte(alias), 0600); err != nil { + t.Fatal(err) + } + lock, err := takeLock(filepath.Join(dir, lockName)) + if err != nil { + t.Fatal(err) + } + defer releaseLock(lock) + if !hostLockHeld(alias) { + t.Fatal("startup probe missed the held legacy lifetime lock") + } + if conn, err := DialStartingHost(alias); err == nil { + conn.Close() + t.Fatal("a held lock with no listener answered") + } +} + +// The new host holds old keys throughout its lifetime, closing the race in +// which a successful legacy preflight was followed by acquisition elsewhere. +func TestReviewCanonicalHostHoldsProvenLegacyLock(t *testing.T) { + shortHome(t) + target := t.TempDir() + alias := filepath.Join(t.TempDir(), "project") + if err := os.Symlink(target, alias); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256([]byte(filepath.Clean(alias))) + dir := home.Join("v3", "hosts", hex.EncodeToString(sum[:8])) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, placeName), []byte(alias), 0600); err != nil { + t.Fatal(err) + } + liveHost(t, target) + lock, err := takeLock(filepath.Join(dir, lockName)) + if err == nil { + releaseLock(lock) + t.Fatal("a running canonical host left a proven old lifetime lock free") + } +} From 22300f43b721e06e8d86184bf9ea29c374c32291 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 20 Oct 2026 13:31:38 -0400 Subject: [PATCH 17/24] fix(chat): retain workspace matching alongside owned launch citations Accept either citation for owned conversations while keeping launch-only matches unavailable to borrowed sessions. Cover both HOME and temporary owned launches. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/chatv3_layout.go | 25 +++++++++++++++++-------- cmd/codeaf/review_owned_upgrade_test.go | 13 ++++++++++++- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/cmd/codeaf/chatv3_layout.go b/cmd/codeaf/chatv3_layout.go index 7cba311c17..0718b3a2d0 100644 --- a/cmd/codeaf/chatv3_layout.go +++ b/cmd/codeaf/chatv3_layout.go @@ -318,7 +318,8 @@ func v3ResolveSession(explicit, workspace, launchDir string, owned bool) (v3Sess // v3LegacyFolder searches the project buckets OTHER than this workspace's own // for the newest conversation somebody has spoken in whose recorded workspace -// ([session.Meta.Workspace]) resolves to the same folder, and answers it with +// ([session.Meta.Workspace], or LaunchDir for owned sessions) resolves to the +// same folder, and answers it with // the bucket it lives in. It is the upgrade road for buckets minted before the // project key was resolved (#1761): the bucket name is a spelling's encoding and // cannot be reversed, but every folder cites its own workspace, and a citation @@ -352,17 +353,25 @@ func v3LegacyFolder(workspace string) (v3Folder, string, bool) { } spoken, _ := v3ScanBucket(bucket) for _, folder := range spoken { - recorded := strings.TrimSpace(folder.meta.Workspace) - // Owned work/ is private to the conversation. Its launch citation + citations := []string{folder.meta.Workspace} + // Owned work/ is private to the conversation. Its launch citation also // names the project bucket the older build chose for HOME or temp. if folder.meta.Owned { - recorded = strings.TrimSpace(folder.meta.LaunchDir) + citations = append(citations, folder.meta.LaunchDir) } - if recorded == "" { - continue + matched := false + for _, recorded := range citations { + recorded = strings.TrimSpace(recorded) + if recorded == "" { + continue + } + resolvedRecorded, err := filepath.EvalSymlinks(filepath.Clean(recorded)) + if err == nil && resolvedRecorded == resolved { + matched = true + break + } } - resolvedRecorded, err := filepath.EvalSymlinks(filepath.Clean(recorded)) - if err != nil || resolvedRecorded != resolved { + if !matched { continue } if newestBucket == "" || folder.at.After(newest.at) { diff --git a/cmd/codeaf/review_owned_upgrade_test.go b/cmd/codeaf/review_owned_upgrade_test.go index 31d10d16da..6601685dfa 100644 --- a/cmd/codeaf/review_owned_upgrade_test.go +++ b/cmd/codeaf/review_owned_upgrade_test.go @@ -11,13 +11,24 @@ import ( ) func TestReviewExistingOwnedHomeConversationResumes(t *testing.T) { + for _, kind := range []string{"home", "temp"} { + t.Run(kind, func(t *testing.T) { reviewOwnedConversation(t, kind) }) + } +} + +func reviewOwnedConversation(t *testing.T, kind string) { t.Setenv("CODEAF_HOME", filepath.Join(t.TempDir(), "state")) target := t.TempDir() alias := filepath.Join(t.TempDir(), "home") if err := os.Symlink(target, alias); err != nil { t.Fatal(err) } - t.Setenv("HOME", alias) + if kind == "home" { + t.Setenv("HOME", alias) + t.Setenv("USERPROFILE", alias) + } else { + t.Setenv("HOME", t.TempDir()) + } oldBucket := home.Join("v3", "projects", encodeWorkspace(alias)) old := writeV3Session(t, oldBucket, "00000000000000f1", "Remember the draft we wrote at home", time.Now()) meta, err := session.LoadMeta(old) From 4ae9c128b0659c0f2738a8ab6be28fa4e4210d6d Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 20 Oct 2026 13:32:07 -0400 Subject: [PATCH 18/24] fix(memory): retain proven path-owner aliases in project scope Carry canonical and historical owner keys through launch, resume, anchoring and workers. Read and edit old rows in place while keeping new writes canonical, including non-Git folders and repositories without origin. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/chatv3.go | 5 +- cmd/codeaf/chatv3_layout.go | 10 ++ cmd/codeaf/chatv3_process.go | 1 + cmd/codeaf/do_memory.go | 3 +- cmd/codeaf/review_memory_upgrade_test.go | 84 ++++++++++++++++ internal/gitidentity/project.go | 28 ++++++ internal/gitidentity/review_upgrade_test.go | 88 +++++++++++++++++ internal/manual/chat/what-i-remember.md | 13 +++ internal/session/agent.go | 9 ++ internal/session/memory.go | 27 ++++-- internal/session/memory_upgrade_test.go | 54 +++++++++++ internal/session/orchestrate.go | 5 +- internal/session/session.go | 3 + internal/session/task_audit.go | 5 +- internal/session/task_run.go | 5 +- internal/session/tools_anchor_workspace.go | 1 + internal/workspacepath/path.go | 100 ++++++++++++++++++++ internal/workspacepath/path_test.go | 62 ++++++++++++ 18 files changed, 488 insertions(+), 15 deletions(-) create mode 100644 cmd/codeaf/review_memory_upgrade_test.go create mode 100644 internal/gitidentity/review_upgrade_test.go create mode 100644 internal/session/memory_upgrade_test.go create mode 100644 internal/workspacepath/path.go create mode 100644 internal/workspacepath/path_test.go diff --git a/cmd/codeaf/chatv3.go b/cmd/codeaf/chatv3.go index a7fc5a69ad..e9e46ab500 100644 --- a/cmd/codeaf/chatv3.go +++ b/cmd/codeaf/chatv3.go @@ -1050,8 +1050,9 @@ func v3ConfigFor(proc *v3Process, opts v3Options, workspace string, place sessio // owner a memory really belongs to. Empty (an error, or a workspace // that could not be resolved) quarantines project writes rather than // guessing. - MemoryProjectKey: v3ProjectKey(workspace), - MemoryProjectName: gitidentity.ProjectName(workspace), + MemoryProjectKey: v3ProjectKey(workspace), + MemoryProjectAliases: gitidentity.ProjectKeys(workspace, proc.LaunchDir, opts.Workspace), + MemoryProjectName: gitidentity.ProjectName(workspace), // AND THE SKILL SHELF, which is the line above, and a shelf of the skill // folders alone when the memory store would not open ([v3SkillShelf]): // the skills a person installed for another harness are not memory, and diff --git a/cmd/codeaf/chatv3_layout.go b/cmd/codeaf/chatv3_layout.go index 0718b3a2d0..fd77065115 100644 --- a/cmd/codeaf/chatv3_layout.go +++ b/cmd/codeaf/chatv3_layout.go @@ -499,6 +499,8 @@ func v3PlaceOf(transcript, workspace string) session.Place { // with it. It is the ONE place a config changes conversations, so /new, a // contended resume and the picker cannot drift apart. func v3PointAt(cfg session.Config, place session.Place) (session.Config, error) { + previousWorkspace := cfg.Workspace + previousKey, previousAliases := cfg.MemoryProjectKey, cfg.MemoryProjectAliases cfg.Place = place cfg.SessionFile = place.Transcript() if !place.Owned { @@ -510,6 +512,10 @@ func v3PointAt(cfg session.Config, place session.Place) (session.Config, error) cfg.Workspace = workspace } cfg.MemoryProjectKey = v3ProjectKey(cfg.Workspace) + cfg.MemoryProjectAliases = gitidentity.ProjectKeys(cfg.Workspace, previousWorkspace, v3LaunchDir()) + if cfg.MemoryProjectKey == previousKey { + cfg.MemoryProjectAliases = append(cfg.MemoryProjectAliases, previousAliases...) + } cfg.MemoryProjectName = gitidentity.ProjectName(cfg.Workspace) return cfg, nil } @@ -525,6 +531,10 @@ func v3PointAt(cfg session.Config, place session.Place) (session.Config, error) // every door through this function agrees about whose project this is — // and the name moves with it, so a line kept here says where it holds. cfg.MemoryProjectKey = v3ProjectKey(cfg.Workspace) + cfg.MemoryProjectAliases = gitidentity.ProjectKeys(cfg.Workspace, previousWorkspace, v3LaunchDir()) + if cfg.MemoryProjectKey == previousKey { + cfg.MemoryProjectAliases = append(cfg.MemoryProjectAliases, previousAliases...) + } cfg.MemoryProjectName = gitidentity.ProjectName(cfg.Workspace) return cfg, nil } diff --git a/cmd/codeaf/chatv3_process.go b/cmd/codeaf/chatv3_process.go index 7665bcc803..6c0abe5f63 100644 --- a/cmd/codeaf/chatv3_process.go +++ b/cmd/codeaf/chatv3_process.go @@ -973,6 +973,7 @@ func (s *v3Seam) anchor(agent interface { // on the scratch folder would scope every later launch's project reads and // writes to a project this conversation is no longer in. s.boot.Config.MemoryProjectKey = v3ProjectKey(resolved) + s.boot.Config.MemoryProjectAliases = gitidentity.ProjectKeys(resolved, path) s.boot.Config.MemoryProjectName = gitidentity.ProjectName(resolved) s.boot.Place = s.boot.Config.Place if bucket, bucketErr := v3ProjectDir(resolved); bucketErr == nil { diff --git a/cmd/codeaf/do_memory.go b/cmd/codeaf/do_memory.go index 0f811898ef..6629772779 100644 --- a/cmd/codeaf/do_memory.go +++ b/cmd/codeaf/do_memory.go @@ -1,6 +1,7 @@ package main import ( + "github.com/Agent-Field/codeaf/internal/gitidentity" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/store" ) @@ -20,5 +21,5 @@ func doMemory(workspace string) string { return "" } defer brain.Close() - return session.MemoryWorld(brain, session.MemoryOwners(v3ProjectKey(workspace))) + return session.MemoryWorld(brain, session.MemoryOwners(v3ProjectKey(workspace), gitidentity.ProjectKeys(workspace)...)) } diff --git a/cmd/codeaf/review_memory_upgrade_test.go b/cmd/codeaf/review_memory_upgrade_test.go new file mode 100644 index 0000000000..c114c9adf7 --- /dev/null +++ b/cmd/codeaf/review_memory_upgrade_test.go @@ -0,0 +1,84 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/store" +) + +// The launch has already replaced the raw spelling before it assembles the +// config. The old conversation citation must still recover its memory owner. +func TestReviewResumedConversationKeepsPathMemory(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + target := t.TempDir() + alias := filepath.Join(t.TempDir(), "project") + if err := os.Symlink(target, alias); err != nil { + t.Fatal(err) + } + oldBucket := homeProjectForReview(alias) + old := writeV3Session(t, oldBucket, "00000000000000f3", "remember this project's contract", time.Now()) + meta, err := session.LoadMeta(old) + if err != nil { + t.Fatal(err) + } + meta.Workspace = alias + if err := session.SaveMeta(old, meta); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256([]byte(filepath.Clean(alias))) + oldOwner := store.OwnerProject(hex.EncodeToString(sum[:16])) + brain, err := store.Open(defaultChatDB()) + if err != nil { + t.Fatal(err) + } + const words = "Use the archived client contract for this project" + saved, err := brain.Write(store.WriteRequest{Owner: oldOwner, Text: words, SourceSession: "existing-session"}) + if err != nil { + t.Fatal(err) + } + if err := brain.Close(); err != nil { + t.Fatal(err) + } + project, owned := v3Workspace(alias, alias) + if got := doMemory(project); !strings.Contains(got, words) { + t.Fatalf("headless project's memory: %q", got) + } + brain, err = store.Open(defaultChatDB()) + if err != nil { + t.Fatal(err) + } + defer brain.Close() + found, err := v3ResolveSession("", project, alias, owned) + if err != nil { + t.Fatal(err) + } + cfg, err := v3PointAt(session.Config{Workspace: project, Memory: brain, Model: "test/model", APIKey: "unused", BaseURL: "http://127.0.0.1:1", ProfileDir: t.TempDir()}, found.Place) + if err != nil { + t.Fatal(err) + } + agent, err := session.New(cfg) + if err != nil { + t.Fatal(err) + } + defer agent.Close() + rows, err := agent.Memories("") + if err != nil || len(rows) != 1 || rows[0].ID != saved.Memory.ID { + t.Fatalf("resumed project memories: %+v (%v)", rows, err) + } + // Compatibility is a read permission, not a silent durable reownership. + oldRows, err := brain.MemoriesFor([]string{oldOwner}) + if err != nil || len(oldRows) != 1 { + t.Fatalf("opening changed historical ownership: %v (%v)", oldRows, err) + } +} + +func homeProjectForReview(workspace string) string { + return filepath.Join(os.Getenv("CODEAF_HOME"), "v3", "projects", encodeWorkspace(workspace)) +} diff --git a/internal/gitidentity/project.go b/internal/gitidentity/project.go index 08e37db2fc..8f13e3bc58 100644 --- a/internal/gitidentity/project.go +++ b/internal/gitidentity/project.go @@ -19,6 +19,8 @@ import ( "os/exec" "path/filepath" "strings" + + "github.com/Agent-Field/codeaf/internal/workspacepath" ) // ProjectKey answers the stable project key of a workspace: the normalized @@ -53,6 +55,32 @@ func ProjectKey(workspace string) (string, error) { return hashKey(absolute), nil } +// ProjectKeys names the canonical write key followed by proven historical path +// owners. Remote identities have never depended on a spelling and need no aliases. +func ProjectKeys(workspace string, candidates ...string) []string { + key, _ := ProjectKey(workspace) + if key == "" { + return nil + } + keys := []string{key} + if remote := NormalizeRemoteURL(originRemote(workspace)); remote != "" { + return keys + } + seen := map[string]bool{key: true} + for _, path := range workspacepath.Aliases(workspace, candidates...) { + absolute, err := filepath.Abs(path) + if err != nil { + continue + } + alias := hashKey(absolute) + if !seen[alias] { + keys = append(keys, alias) + seen[alias] = true + } + } + return keys +} + // hashKey is the one way a project identity is minted. func hashKey(value string) string { sum := sha256.Sum256([]byte(value)) diff --git a/internal/gitidentity/review_upgrade_test.go b/internal/gitidentity/review_upgrade_test.go new file mode 100644 index 0000000000..39cc5e0169 --- /dev/null +++ b/internal/gitidentity/review_upgrade_test.go @@ -0,0 +1,88 @@ +package gitidentity_test + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/gitidentity" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/store" +) + +func TestReviewExistingPathOwnedMemoryRemainsVisible(t *testing.T) { + for _, git := range []bool{false, true} { + t.Run(map[bool]string{false: "plain", true: "git-without-origin"}[git], func(t *testing.T) { reviewPathMemory(t, git) }) + } +} + +func reviewPathMemory(t *testing.T, git bool) { + t.Setenv("CODEAF_HOME", t.TempDir()) + target := t.TempDir() + if git { + if out, err := exec.Command("git", "init", target).CombinedOutput(); err != nil { + t.Fatalf("git init: %s: %v", out, err) + } + } + alias := filepath.Join(t.TempDir(), "notes") + if err := os.Symlink(target, alias); err != nil { + t.Fatal(err) + } + absolute, err := filepath.Abs(alias) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256([]byte(absolute)) + oldKey := hex.EncodeToString(sum[:16]) + path := filepath.Join(t.TempDir(), "brain.db") + brain, err := store.Open(path) + if err != nil { + t.Fatal(err) + } + written, err := brain.Write(store.WriteRequest{Owner: store.OwnerProject(oldKey), Text: "Use the archived client contract for this project", SourceSession: "existing-session"}) + if err != nil { + t.Fatal(err) + } + if err := brain.Close(); err != nil { + t.Fatal(err) + } + brain, err = store.Open(path) + if err != nil { + t.Fatal(err) + } + defer brain.Close() + key, err := gitidentity.ProjectKey(alias) + if err != nil { + t.Fatal(err) + } + agent, err := session.New(session.Config{Workspace: alias, Model: "test/model", APIKey: "unused", BaseURL: "http://127.0.0.1:1", Memory: brain, MemoryProjectKey: key, ProfileDir: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + defer agent.Close() + rows, err := agent.Memories("") + if err != nil { + t.Fatal(err) + } + t.Logf("old owner=%s, current owner=%s, visible=%d", store.OwnerProject(oldKey), store.OwnerProject(key), len(rows)) + for _, row := range rows { + if row.ID == written.Memory.ID { + // Editing in project scope must reach the historical row. + if _, err := agent.MoveMemory(row.ID, "project"); err != nil { + t.Fatal(err) + } + if _, err := agent.Remember("Keep new project notes under the current owner"); err != nil { + t.Fatal(err) + } + current, err := brain.MemoriesFor(session.MemoryOwners(key)) + if err != nil || len(current) != 2 { + t.Fatalf("canonical project writes: %v (%v)", current, err) + } + return + } + } + t.Fatalf("existing project memory %s disappeared after reopening unchanged workspace %q", written.Memory.ID, alias) +} diff --git a/internal/manual/chat/what-i-remember.md b/internal/manual/chat/what-i-remember.md index 6c78c7470c..d5a8457740 100644 --- a/internal/manual/chat/what-i-remember.md +++ b/internal/manual/chat/what-i-remember.md @@ -571,3 +571,16 @@ drops out and something confirmed this morning rises. A suggestion that fails on the same error is counted against itself. There is no command or panel for it, it is not on the Memories tab, and the noticing setting does not turn it off: the one line on a failed row is the whole of what it says. + +## Project memories after a symlink workspace upgrade + +A folder and a symlink pointing to it have one project identity. In a folder without +an origin remote, older builds kept project memories under a hash of the path spelling. +Those lines remain visible to the project and its workers when codeaf can prove the +old spelling still names the same folder: from the launch path or a saved conversation +or engine workspace citation. This also covers Git repositories without an origin. + +Existing rows keep their owners until you deliberately change them. New project memories +use the resolved folder's owner; a correction supersedes the old line under that owner. +There is no bulk move or deletion of old memories. Remote-based Git identities keep the +same keys. A removed or broken alias cannot prove that an old path owner belongs here. diff --git a/internal/session/agent.go b/internal/session/agent.go index 9f020618dc..8b3f012776 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -13,6 +13,7 @@ import ( "github.com/Agent-Field/codeaf/internal/buildinfo" "github.com/Agent-Field/codeaf/internal/effort" "github.com/Agent-Field/codeaf/internal/env" + "github.com/Agent-Field/codeaf/internal/gitidentity" "github.com/Agent-Field/codeaf/internal/guard" lanes "github.com/Agent-Field/codeaf/internal/lane" "github.com/Agent-Field/codeaf/internal/modelsource" @@ -113,6 +114,14 @@ func newAgent(config Config, client Completer) (*Agent, error) { if client == nil { return nil, errors.New("session: completer is required") } + // Older callers supply only a workspace and key. Resolve their read aliases + // once at construction, never on the per-turn memory path. + if config.MemoryProjectKey != "" && config.MemoryProjectAliases == nil { + keys := gitidentity.ProjectKeys(config.Workspace) + if len(keys) > 0 && keys[0] == config.MemoryProjectKey { + config.MemoryProjectAliases = keys + } + } if config.newerBuild == nil { config.newerBuild = buildinfo.StaleNotice } diff --git a/internal/session/memory.go b/internal/session/memory.go index b4f4c5345b..898ee0ceed 100644 --- a/internal/session/memory.go +++ b/internal/session/memory.go @@ -245,18 +245,26 @@ func (a *Agent) projectKey() (key, name string, owned bool) { // memoryOwners is what this conversation may SEE: the person, this machine, // and this project. func (a *Agent) memoryOwners() []string { - key, _, _ := a.projectKey() - return MemoryOwners(key) + a.mu.Lock() + defer a.mu.Unlock() + return MemoryOwners(a.config.MemoryProjectKey, a.config.MemoryProjectAliases...) } // MemoryOwners is the owner list a conversation at one project may SEE: the // person, this machine, and the project when its key is known. It is the one // builder of that list, exported for the doors outside this package that read // the block for a place with no conversation open on it (`codeaf do`). -func MemoryOwners(projectKey string) []string { +func MemoryOwners(projectKey string, aliases ...string) []string { owners := []string{store.OwnerUser, store.OwnerMachine} - if key := strings.TrimSpace(projectKey); key != "" { - owners = append(owners, store.OwnerProject(key)) + if strings.TrimSpace(projectKey) != "" { + seen := map[string]bool{} + for _, key := range append([]string{projectKey}, aliases...) { + key = strings.TrimSpace(key) + if key != "" && !seen[key] { + owners = append(owners, store.OwnerProject(key)) + seen[key] = true + } + } } return owners } @@ -408,7 +416,7 @@ func (a *Agent) refreshMemoryLocked() { a.memoryText = "" return } - memories, err := st.MemoriesFor(MemoryOwners(a.config.MemoryProjectKey)) + memories, err := st.MemoriesFor(MemoryOwners(a.config.MemoryProjectKey, a.config.MemoryProjectAliases...)) if err != nil { return } @@ -535,6 +543,13 @@ func (a *Agent) keepLine(text, scope, origin string, replaced *store.Memory, unb // that said "this corrects line 3" and named a different scope word has // still named line 3. owner, widened = replaced.Owner, false + key, _, _ := a.projectKey() + for _, allowed := range a.memoryOwners()[2:] { + if replaced.Owner == allowed { + owner = store.OwnerProject(key) + break + } + } } _, projectName, _ := a.projectKey() reach := memoryReach(owner, widened) diff --git a/internal/session/memory_upgrade_test.go b/internal/session/memory_upgrade_test.go new file mode 100644 index 0000000000..6b44f36b0f --- /dev/null +++ b/internal/session/memory_upgrade_test.go @@ -0,0 +1,54 @@ +package session + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/gitidentity" + "github.com/Agent-Field/codeaf/internal/store" +) + +func TestReviewPathMemoryAliasesPermitScopedEdits(t *testing.T) { + target := t.TempDir() + alias := filepath.Join(t.TempDir(), "project") + if err := os.Symlink(target, alias); err != nil { + t.Fatal(err) + } + key, err := gitidentity.ProjectKey(alias) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256([]byte(filepath.Clean(alias))) + oldOwner := store.OwnerProject(hex.EncodeToString(sum[:16])) + agent, brain := brainAgent(t, &reflexScript{}, func(c *Config) { c.Workspace = alias; c.MemoryProjectKey = key }) + saved, err := brain.Write(store.WriteRequest{Owner: oldOwner, Text: "Use the archived client contract", SourceSession: "old-session"}) + if err != nil { + t.Fatal(err) + } + if _, err := brain.Write(store.WriteRequest{Owner: store.OwnerProject("unrelated"), Text: "Never show the unrelated project's rule"}); err != nil { + t.Fatal(err) + } + agent.mu.Lock() + agent.refreshMemoryLocked() + block := agent.memoryText + agent.mu.Unlock() + if !strings.Contains(block, saved.Memory.Text) || strings.Contains(block, "unrelated") { + t.Fatalf("project memory block: %q", block) + } + // A correction creates its replacement under today's write key. The old row + // is superseded through the owner's permission list, never silently moved. + fresh, _, _, err := agent.keepLine("Use the current client contract", "project", store.OriginPerson, &saved.Memory, false) + if err != nil || fresh.Owner != store.OwnerProject(key) { + t.Fatalf("scoped correction: %+v (%v)", fresh, err) + } + if receipt, err := agent.forgetLine("current client contract"); err != nil || !strings.HasPrefix(receipt, "forgot") { + t.Fatalf("scoped forget: %s (%v)", receipt, err) + } + if receipt, err := agent.forgetLine("unrelated"); err != nil || !strings.HasPrefix(receipt, "nothing remembered matches") { + t.Fatalf("unrelated scope: %s (%v)", receipt, err) + } +} diff --git a/internal/session/orchestrate.go b/internal/session/orchestrate.go index aaa1dac303..481e24864e 100644 --- a/internal/session/orchestrate.go +++ b/internal/session/orchestrate.go @@ -1086,8 +1086,9 @@ func (e *orchestrateExec) newChild(dir string, node orchestrate.Node) (*Agent, e // AND THE CONVERSATION'S MEMORY, on a task node's exact terms // (task_run.go's newTaskAgentOn): the parent's brain lent read-only, the // root's project key, no memory write. - bindingStore: parent.bindingBrain(), - MemoryProjectKey: parent.MemoryProjectKey, + bindingStore: parent.bindingBrain(), + MemoryProjectKey: parent.MemoryProjectKey, + MemoryProjectAliases: parent.MemoryProjectAliases, // And its litter follows the run's own session rather than the directory // the node works in, for a task node's reason exactly (task_run.go's // newChild counterpart, landing.go). diff --git a/internal/session/session.go b/internal/session/session.go index dfc0666a25..f94d7e35ac 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -1368,6 +1368,9 @@ type Config struct { // read is scoped to the conversation's project, and it is NEVER inherited by // a config copy that was not written to carry it. MemoryProjectKey string + // MemoryProjectAliases permits reads and edits of historical path owners. + // New rows still use MemoryProjectKey; existing rows keep their durable owner. + MemoryProjectAliases []string // MemoryProjectName is the name a person knows that project by — the // repository's name, or the folder's — written beside every project memory // this conversation keeps, so the memory place can say where a line holds diff --git a/internal/session/task_audit.go b/internal/session/task_audit.go index f209c615f9..5e95bfa69d 100644 --- a/internal/session/task_audit.go +++ b/internal/session/task_audit.go @@ -3179,8 +3179,9 @@ func (a *Agent) newAuditAgent(dir string, node *TaskNode, door auditDoor, on str // AND THE CONVERSATION'S MEMORY, LENT READ-ONLY: a judge that must not // gain the writable store still reads the same owner's lines the node // worked under ([TaskGraph.memoryWorld]). - bindingStore: parent.bindingBrain(), - MemoryProjectKey: parent.MemoryProjectKey, + bindingStore: parent.bindingBrain(), + MemoryProjectKey: parent.MemoryProjectKey, + MemoryProjectAliases: parent.MemoryProjectAliases, // The auditor reads rather than writes, but reading is what makes a // dropping: a long file it looks at is stubbed on its way out of the live // context (stub.go), and with nothing here those bytes landed in the diff --git a/internal/session/task_run.go b/internal/session/task_run.go index 23f8d0fce0..15d14530f4 100644 --- a/internal/session/task_run.go +++ b/internal/session/task_run.go @@ -7605,8 +7605,9 @@ func (a *Agent) newTaskAgentOn(ctx context.Context, dir string, node *TaskNode, // own, so its parent's is LENT: the same owner set and the same memory // section closing its brief ([TaskGraph.memoryWorld]), and no write, // noticing, import or verb. - bindingStore: parent.bindingBrain(), - MemoryProjectKey: parent.MemoryProjectKey, + bindingStore: parent.bindingBrain(), + MemoryProjectKey: parent.MemoryProjectKey, + MemoryProjectAliases: parent.MemoryProjectAliases, // The node learns from, and into, the PROJECT'S error→fix file rather // than one of its own (fixstore.go states why a node cannot find it // alone). A worker hammering a build in a worktree is the richest source diff --git a/internal/session/tools_anchor_workspace.go b/internal/session/tools_anchor_workspace.go index 522874e503..925296b204 100644 --- a/internal/session/tools_anchor_workspace.go +++ b/internal/session/tools_anchor_workspace.go @@ -84,6 +84,7 @@ func (a *Agent) AnchorWorkspace(path string) (string, error) { // mint, and an unprovable workspace answers "" — the quarantine — exactly as // the launching doors answer it. a.config.MemoryProjectKey = anchoredProjectKey(resolved) + a.config.MemoryProjectAliases = gitidentity.ProjectKeys(resolved, path) a.config.MemoryProjectName = gitidentity.ProjectName(resolved) // AND THE NEXT REQUEST MUST CARRY THE NEW PROJECT'S MEMORY, not the scratch // folder's: the block is re-read and message[0] rebuilt now, so a call made diff --git a/internal/workspacepath/path.go b/internal/workspacepath/path.go new file mode 100644 index 0000000000..63a5e7f5f5 --- /dev/null +++ b/internal/workspacepath/path.go @@ -0,0 +1,100 @@ +// Package workspacepath keeps the spellings that provably name one folder. +package workspacepath + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/Agent-Field/codeaf/internal/home" +) + +// Canonical is the write spelling. Unresolvable paths keep their old fallback. +func Canonical(path string) string { + path = filepath.Clean(path) + if resolved, err := filepath.EvalSymlinks(path); err == nil { + return resolved + } + return path +} + +// Same requires two live citations; absent or broken links prove nothing. +func Same(a, b string) bool { + if strings.TrimSpace(a) == "" || strings.TrimSpace(b) == "" { + return false + } + a, err := filepath.EvalSymlinks(filepath.Clean(a)) + if err != nil { + return false + } + b, err = filepath.EvalSymlinks(filepath.Clean(b)) + return err == nil && a == b +} + +// Aliases returns the canonical write spelling, the incoming spelling, and +// equivalent spellings retained in host/session citations. It never invents an +// alias from a bucket name, follows state-directory links, or changes a record. +func Aliases(workspace string, candidates ...string) []string { + if strings.TrimSpace(workspace) == "" { + return nil + } + result := []string{Canonical(workspace)} + seen := map[string]bool{result[0]: true} + add := func(path string) { + path = strings.TrimSpace(path) + if path == "" { + return + } + path = filepath.Clean(path) + if !seen[path] && Same(workspace, path) { + result = append(result, path) + seen[path] = true + } + } + add(workspace) + for _, path := range candidates { + add(path) + } + hosts, _ := os.ReadDir(home.Join("v3", "hosts")) + for _, entry := range hosts { + if !entry.IsDir() { + continue + } + raw, err := os.ReadFile(home.Join("v3", "hosts", entry.Name(), "workspace")) + if err == nil { + add(string(raw)) + } + } + projects, _ := os.ReadDir(home.Join("v3", "projects")) + for _, project := range projects { + if !project.IsDir() { + continue + } + bucket := home.Join("v3", "projects", project.Name()) + sessions, _ := os.ReadDir(bucket) + for _, entry := range sessions { + if !entry.IsDir() { + continue + } + raw, err := os.ReadFile(filepath.Join(bucket, entry.Name(), "meta.json")) + if err != nil { + continue + } + var meta struct { + ID string `json:"id"` + Workspace string `json:"workspace"` + LaunchDir string `json:"launchDir"` + Owned bool `json:"owned"` + } + if json.Unmarshal(raw, &meta) != nil || meta.ID == "" { + continue + } + add(meta.Workspace) + // A borrowed launch can also cite an alias of the root itself. + // Same rejects a subdirectory or unrelated launch location. + add(meta.LaunchDir) + } + } + return result +} diff --git a/internal/workspacepath/path_test.go b/internal/workspacepath/path_test.go new file mode 100644 index 0000000000..a269e4b2cc --- /dev/null +++ b/internal/workspacepath/path_test.go @@ -0,0 +1,62 @@ +package workspacepath + +import ( + "os" + "path/filepath" + "slices" + "testing" + + "github.com/Agent-Field/codeaf/internal/home" +) + +func TestAliasesRequireLiveEquivalentCitations(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + target := t.TempDir() + alias := filepath.Join(t.TempDir(), "project") + if err := os.Symlink(target, alias); err != nil { + t.Fatal(err) + } + dir := home.Join("v3", "hosts", "old-key") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "workspace"), []byte(alias), 0600); err != nil { + t.Fatal(err) + } + unrelated := t.TempDir() + if got := Aliases(target, unrelated); len(got) != 2 || !slices.Contains(got, alias) || slices.Contains(got, unrelated) { + t.Fatalf("aliases: %v", got) + } + if err := os.Remove(alias); err != nil { + t.Fatal(err) + } + if got := Aliases(target); len(got) != 1 || got[0] != target { + t.Fatalf("broken citation: %v", got) + } + if got := Canonical(alias); got != alias { + t.Fatalf("missing path fallback: %s", got) + } +} + +func TestAliasLookupDoesNotFollowStateDirectoryLinks(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + target := t.TempDir() + alias := filepath.Join(t.TempDir(), "project") + if err := os.Symlink(target, alias); err != nil { + t.Fatal(err) + } + outside := t.TempDir() + if err := os.WriteFile(filepath.Join(outside, "workspace"), []byte(alias), 0600); err != nil { + t.Fatal(err) + } + hosts := home.Join("v3", "hosts") + if err := os.MkdirAll(hosts, 0700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(hosts, "linked-host")); err != nil { + t.Fatal(err) + } + if got := Aliases(target); len(got) != 1 { + t.Fatalf("followed a linked state entry: %v", got) + } +} From 9feab721258cd6d89655c8e23bf92581d5a16614 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 20 Oct 2026 13:32:48 -0400 Subject: [PATCH 19/24] fix(drafts): restore proven alias prefixes and structured owners Search canonical and cited historical prefixes together. Match local workspace and transcript aliases while preserving machine, conversation, live-window and newest-record guards; save restored composers before pruning their old records. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/tui3/draft.go | 39 +++++- internal/tui3/draftkeep.go | 40 ++++-- internal/tui3/review_draft_upgrade_test.go | 143 +++++++++++++++++++++ 3 files changed, 208 insertions(+), 14 deletions(-) create mode 100644 internal/tui3/review_draft_upgrade_test.go diff --git a/internal/tui3/draft.go b/internal/tui3/draft.go index fb2c30024a..cab64f9161 100644 --- a/internal/tui3/draft.go +++ b/internal/tui3/draft.go @@ -13,6 +13,7 @@ import ( "time" tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/workspacepath" ) // THE DRAFT IS THE PERSON'S, NOT THE SESSION'S. @@ -102,6 +103,7 @@ func DraftFile(dir, workspace string) string { if dir == "" { return "" } + workspace = workspacepath.Canonical(workspace) key := filepath.Clean(workspace) draftOrdinals.mu.Lock() ordinal := draftOrdinals.n[key] @@ -332,17 +334,14 @@ func (a *app) restoreDraft() { // matching is not that conversation being opened again — so its documents, its // tray and its task pages stay where they are, for the window that opens it // (draftkeep.go's [app.reuniteDrafts]). -func adoptDraft(own, workspace string) string { +func adoptDraft(own, workspace string, aliases ...string) string { directory := filepath.Dir(own) if strings.TrimSpace(workspace) == "" { // Nothing to glob from. A hunt with no workspace would either sweep the // directory or match nothing, and matching nothing is the safe half. return "" } - candidates, err := filepath.Glob(filepath.Join(directory, draftPrefix(workspace)+"*.txt")) - if err != nil { - return "" - } + candidates := draftCandidates(directory, append([]string{workspace}, aliases...), ".txt") var ( newest string @@ -455,3 +454,33 @@ func writeDraft(path, text string) error { } return os.WriteFile(path, []byte(text), 0o600) } + +// draftCandidates reads all proven prefixes together so newest-record ordering +// and the live-window guard apply across the upgrade boundary as well. +func draftCandidates(directory string, workspaces []string, extension string) []string { + seen := map[string]bool{} + var paths []string + entries, _ := os.ReadDir(directory) + for _, workspace := range workspaces { + // Read names literally so state-root glob metacharacters stay literal. + prefix := draftPrefix(workspace) + for _, entry := range entries { + if entry.IsDir() || !strings.HasPrefix(entry.Name(), prefix) || !strings.HasSuffix(entry.Name(), extension) { + continue + } + path := filepath.Join(directory, entry.Name()) + if !seen[path] { + paths = append(paths, path) + seen[path] = true + } + } + } + return paths +} + +func (a *app) draftWorkspaces() []string { + if a.host != "" { + return []string{a.workspace} + } + return workspacepath.Aliases(a.workspace) +} diff --git a/internal/tui3/draftkeep.go b/internal/tui3/draftkeep.go index 0a7b04688d..844a3575da 100644 --- a/internal/tui3/draftkeep.go +++ b/internal/tui3/draftkeep.go @@ -13,6 +13,7 @@ import ( "time" tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/workspacepath" ) // AN UNSENT LINE OUTLIVES THE WINDOW, AND IT COMES BACK TO THE READER IT WAS @@ -214,6 +215,9 @@ func draftOwnerOf(host, workspace, file string) string { if strings.TrimSpace(file) == "" { return "" } + if strings.TrimSpace(host) == "" { + workspace, file = workspacepath.Canonical(workspace), workspacepath.Canonical(file) + } return strings.TrimSpace(host) + "\x00" + filepath.Clean(workspace) + "\x00" + filepath.Clean(file) } @@ -259,7 +263,7 @@ func namedConversation(owner string) bool { // belongs to nobody this build can name and is never laid out. func (a *app) ownsSlot(slot draftKeepSlot) bool { owner := a.draftKeepOwner() - return owner != "" && slot.Owner == owner + return owner != "" && a.matchesDraftOwner(slot.Owner) } // speaksFor reports whether a record has anything to say about THIS @@ -274,17 +278,38 @@ func (a *app) speaksFor(keep draftKeep) bool { if owner == "" { return false } - if keep.Owner == owner { + if a.matchesDraftOwner(keep.Owner) { return true } for _, slot := range keep.Slots { - if slot.Owner == owner { + if a.matchesDraftOwner(slot.Owner) { return true } } return false } +// matchesDraftOwner preserves the machine and conversation boundaries. Only +// local paths can be resolved here; a far machine's paths are opaque strings. +func (a *app) matchesDraftOwner(owner string) bool { + current := a.draftKeepOwner() + if current == "" || owner == "" { + return false + } + if current == owner { + return true + } + if a.host != "" || !namedConversation(owner) { + return false + } + parts := strings.Split(owner, "\x00") + if len(parts) != 3 || parts[0] != "" { + return false + } + same := func(x, y string) bool { return filepath.Clean(x) == filepath.Clean(y) || workspacepath.Same(x, y) } + return same(parts[1], a.workspace) && same(parts[2], a.file) +} + // ── the record on disk ────────────────────────────────────────────────────── // draftKeepPath is the record beside one draft file: the same name with another @@ -1187,7 +1212,7 @@ func (a *app) layKeptDrafts() { func (a *app) legacyDraftText() string { text := readDraft(a.draftFile) if text == "" && draftFirstHere(a.draftFile, a.workspace) { - text = adoptDraft(a.draftFile, a.workspace) + text = adoptDraft(a.draftFile, a.workspace, a.draftWorkspaces()...) } return text } @@ -1246,10 +1271,7 @@ func (a *app) reuniteDrafts(into map[recipient]composerState) ([]draftKeepFile, } own := draftKeepPath(a.draftFile) directory := filepath.Dir(a.draftFile) - candidates, err := filepath.Glob(filepath.Join(directory, draftPrefix(a.workspace)+"*.json")) - if err != nil { - return nil, false - } + candidates := draftCandidates(directory, a.draftWorkspaces(), ".json") var speakers []draftKeepFile for _, path := range candidates { if path == own || draftWindowAlive(path) { @@ -1275,7 +1297,7 @@ func (a *app) reuniteDrafts(into map[recipient]composerState) ([]draftKeepFile, // same words — now held here — or the ones a crash left behind after they were // spent. It is cleared with the prune, and only where that window was holding // THIS conversation: a carrier's export is somebody else's sentence. - if newest.keep.Owner == a.draftKeepOwner() { + if a.matchesDraftOwner(newest.keep.Owner) { if stale := draftExportPath(newest.path); readDraft(stale) != "" { newest.export = stale } diff --git a/internal/tui3/review_draft_upgrade_test.go b/internal/tui3/review_draft_upgrade_test.go new file mode 100644 index 0000000000..c5b3109558 --- /dev/null +++ b/internal/tui3/review_draft_upgrade_test.go @@ -0,0 +1,143 @@ +package tui3 + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// reviewDraftUpgrade writes the filename and owner bytes of a pre-upgrade +// window directly. Calling today's mint would fail to exercise an upgrade. +func reviewDraftUpgrade(t *testing.T) (string, string, string, string) { + t.Helper() + t.Setenv("CODEAF_HOME", t.TempDir()) + target := t.TempDir() + alias := filepath.Join(t.TempDir(), "project") + if err := os.Symlink(target, alias); err != nil { + t.Fatal(err) + } + dir := t.TempDir() + sum := sha256.Sum256([]byte(filepath.Clean(alias))) + old := filepath.Join(dir, fmt.Sprintf("draft-%s-0-%d.txt", hex.EncodeToString(sum[:8]), deadPid(t))) + // The existing borrowed conversation is proof of the alias even when the + // new launch only knows the canonical path. + conversation := filepath.Join(os.Getenv("CODEAF_HOME"), "v3", "projects", "old-bucket", "00000000000000f1") + if err := os.MkdirAll(conversation, 0700); err != nil { + t.Fatal(err) + } + if err := session.SaveMeta(conversation, session.Meta{ID: "00000000000000f1", Workspace: alias}); err != nil { + t.Fatal(err) + } + transcript := (session.Place{Dir: conversation}).Transcript() + return target, alias, old, transcript +} + +func TestReviewExistingDraftPrefixSurvivesUpgrade(t *testing.T) { + workspace, _, old, transcript := reviewDraftUpgrade(t) + if err := os.WriteFile(old, []byte("unfinished contract amendments"), 0600); err != nil { + t.Fatal(err) + } + next, _, _ := roomApp(t) + next.workspace, next.file = workspace, transcript + next.draftFile = DraftFile(filepath.Dir(old), workspace) + next.restoreDraft() + if got := next.input.String(); got != "unfinished contract amendments" { + t.Fatalf("restored %q", got) + } + if _, err := os.Stat(old); !os.IsNotExist(err) { + t.Fatalf("adopted plain draft was not moved: %v", err) + } +} + +func TestReviewStructuredDraftSurvivesUpgrade(t *testing.T) { + workspace, alias, old, transcript := reviewDraftUpgrade(t) + owner := "\x00" + filepath.Clean(alias) + "\x00" + filepath.Clean(transcript) + attachment := picture(t, "kept.png") + keep := draftKeep{Version: 1, Owner: owner, At: "2026-10-20T12:00:00Z", Slots: []draftKeepSlot{ + {Owner: owner, Text: "review [paste 1 · 2 lines]", Caret: 6, Pastes: []draftKeepPaste{{N: 1, Text: "archived\ncontract"}}, Chips: []draftKeepChip{{Path: attachment}}}, + {Owner: owner, Task: 7, Text: "widen the pipe", Caret: 4}, + }} + raw, err := json.Marshal(keep) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(draftKeepPath(old), raw, 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(old, []byte("stale export must not return"), 0600); err != nil { + t.Fatal(err) + } + next, _, _ := roomApp(t) + next.workspace, next.file = workspace, transcript + next.draftFile = DraftFile(filepath.Dir(old), workspace) + next.restoreDraft() + if next.input.String() != keep.Slots[0].Text || next.input.cursor != 6 { + t.Fatalf("main composer = %+v", next.input) + } + restored, how := readDraftKeep(draftKeepPath(next.draftFile)) + if how != draftKeepFound || len(restored.Slots) != 2 { + t.Fatalf("restored record: %+v (%v)", restored, how) + } + main, ok := keptSlotFor(t, draftKeepPath(next.draftFile), mainRecipient) + if !ok || len(main.Pastes) != 1 || main.Pastes[0].Text != "archived\ncontract" || len(main.Chips) != 1 || main.Chips[0].Path != attachment { + t.Fatalf("documents/tray: %+v", main) + } + next.openRoom(7, "Fix the pipe") + if got := next.input.String(); got != "widen the pipe" { + t.Fatalf("task composer: %q", got) + } + if slot, held := keptSlotFor(t, draftKeepPath(old), taskRecipient(7)); held { + t.Fatalf("reunion copied the slot: %+v", slot) + } + if _, err := os.Stat(old); !os.IsNotExist(err) { + t.Fatalf("stale legacy export survived reunion: %v", err) + } +} + +func TestReviewAliasDraftKeepsIdentityAndNewestGuards(t *testing.T) { + workspace, alias, old, transcript := reviewDraftUpgrade(t) + owner := "\x00" + alias + "\x00" + transcript + write := func(path string, keep draftKeep) { + raw, err := json.Marshal(keep) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(draftKeepPath(path), raw, 0600); err != nil { + t.Fatal(err) + } + } + write(old, draftKeep{Version: 1, Owner: owner, At: "2026-10-20T12:00:00Z", Slots: []draftKeepSlot{{Owner: owner, Task: 7, Text: "old correction"}}}) + other := "\x00" + alias + "\x00" + filepath.Join(filepath.Dir(transcript), "other.jsonl") + stranger := filepath.Join(filepath.Dir(old), filepath.Base(old)+"-other-999999998.txt") + write(stranger, draftKeep{Version: 1, Owner: other, At: "2026-10-20T14:00:00Z", Slots: []draftKeepSlot{{Owner: other, Task: 7, Text: "somebody else's correction"}}}) + next, _, _ := roomApp(t) + next.workspace, next.file = workspace, transcript + next.draftFile = DraftFile(filepath.Dir(old), workspace) + // A newer canonical record says this conversation's box and pages are clear. + newer := filepath.Join(filepath.Dir(old), fmt.Sprintf("%s42-%d.txt", draftPrefix(workspace), deadPid(t))) + write(newer, draftKeep{Version: 1, Owner: "\x00" + workspace + "\x00" + transcript, At: "2026-10-20T13:00:00Z"}) + next.restoreDraft() + next.openRoom(7, "Fix the pipe") + if got := next.input.String(); got != "" { + t.Fatalf("newer clear restored %q", got) + } + if slot, held := keptSlotFor(t, draftKeepPath(stranger), taskRecipient(7)); !held || slot.Text != "somebody else's correction" { + t.Fatalf("stranger's slot changed: %+v", slot) + } + // A live alias record remains owned by that window even if it is newest. + live := filepath.Join(filepath.Dir(old), fmt.Sprintf("%s99-%d.txt", draftPrefix(alias), os.Getpid())) + write(live, draftKeep{Version: 1, Owner: owner, At: "2026-10-20T15:00:00Z", Slots: []draftKeepSlot{{Owner: owner, Task: 7, Text: "still typing"}}}) + another, _, _ := roomApp(t) + another.workspace, another.file = workspace, transcript + another.draftFile = DraftFile(filepath.Dir(old), workspace) + another.restoreDraft() + if slot, held := keptSlotFor(t, draftKeepPath(live), taskRecipient(7)); !held || slot.Text != "still typing" { + t.Fatalf("live window lost its slot: %+v", slot) + } +} From cf974db3ee84d6f2a02f4af139447180329a73cc Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 20 Oct 2026 13:32:49 -0400 Subject: [PATCH 20/24] fix(sessions): normalize temporary roots and launch provenance together Use the same resolved path classification at launch and sweep, including symlinked HOME and TMPDIR outside /tmp. Retain the temporary citation and verify the sweep can still classify it. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/chatv3_layout.go | 28 ++-------- cmd/codeaf/review_temp_provenance_test.go | 63 +++++++++++++++++++++++ internal/session/sweep.go | 17 +----- internal/workspacepath/path.go | 22 ++++++++ 4 files changed, 91 insertions(+), 39 deletions(-) create mode 100644 cmd/codeaf/review_temp_provenance_test.go diff --git a/cmd/codeaf/chatv3_layout.go b/cmd/codeaf/chatv3_layout.go index fd77065115..73ec88f9b0 100644 --- a/cmd/codeaf/chatv3_layout.go +++ b/cmd/codeaf/chatv3_layout.go @@ -36,6 +36,7 @@ import ( "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/teams" + "github.com/Agent-Field/codeaf/internal/workspacepath" ) // v3Dir is ~/.codeaf/v3: the directory this surface keeps its own files in — @@ -124,17 +125,10 @@ func v3CanonicalPlace(dir string) string { // starts a process that was never about a place at all. Those own a workspace, // and a session whose workspace was temporary is litter the sweep may reap. func v3NoProjectPlace(dir string) bool { - dir = filepath.Clean(dir) - if home, err := os.UserHomeDir(); err == nil && filepath.Clean(home) == dir { + if userHome, err := os.UserHomeDir(); err == nil && workspacepath.Canonical(userHome) == workspacepath.Canonical(dir) { return true } - for _, temporary := range []string{os.TempDir(), "/tmp"} { - temporary = filepath.Clean(temporary) - if dir == temporary || strings.HasPrefix(dir, temporary+string(filepath.Separator)) { - return true - } - } - return false + return v3TempPath(dir) } // v3GitRootTimeout bounds the one subprocess this answer costs. @@ -683,21 +677,7 @@ func v3StampLaunchDir(launchDir, workspace string) string { // typed it. The pair is pinned by a test; if the sweep's rule ever widens, this // one widens with it or a conversation gets reaped under somebody. func v3TempPath(path string) bool { - path = strings.TrimSpace(path) - if path == "" { - return false - } - path = filepath.Clean(path) - for _, temporary := range []string{os.TempDir(), "/tmp"} { - temporary = filepath.Clean(strings.TrimSpace(temporary)) - if temporary == "" || temporary == string(filepath.Separator) { - continue - } - if path == temporary || strings.HasPrefix(path, temporary+string(filepath.Separator)) { - return true - } - } - return false + return workspacepath.Temporary(path) } // ── the bucket, read and groomed ──────────────────────────────────────────── diff --git a/cmd/codeaf/review_temp_provenance_test.go b/cmd/codeaf/review_temp_provenance_test.go new file mode 100644 index 0000000000..53ad73287c --- /dev/null +++ b/cmd/codeaf/review_temp_provenance_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "github.com/Agent-Field/codeaf/internal/session" + "os" + "path/filepath" + "testing" + "time" +) + +func TestReviewResolvedTemporaryLaunchKeepsItsCitation(t *testing.T) { + // A temp root whose physical path is outside /tmp emulates /var -> + // /private/var on macOS; a target still under /tmp would hide this bug. + cache, err := os.UserCacheDir() + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(cache, 0700); err != nil { + t.Fatal(err) + } + target, err := os.MkdirTemp(cache, "codeaf-temp-upgrade-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(target) + alias := filepath.Join(t.TempDir(), "temps") + if err := os.Symlink(target, alias); err != nil { + t.Fatal(err) + } + t.Setenv("TMPDIR", alias) + launch := filepath.Join(alias, "launch") + if err := os.Mkdir(launch, 0700); err != nil { + t.Fatal(err) + } + project, owned := v3Workspace(launch, "") + if !owned { + t.Fatal("fixture must open an owned conversation") + } + stamp := v3StampLaunchDir(launch, project) + t.Logf("raw launch=%s, project=%s, stamp=%q", launch, project, stamp) + if stamp != launch { + t.Fatal("normalizing the temporary workspace discarded its launch citation") + } + // A session whose stored launch is the workspace spelling must remain + // recognizable to the sweeper after that spelling becomes canonical. + root := t.TempDir() + dir := filepath.Join(root, "old-bucket", "00000000000000f2") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + now := time.Now() + if err := session.SaveMeta(dir, session.Meta{ID: "00000000000000f2", Owned: true, Workspace: filepath.Join(dir, "work"), LaunchDir: project, LastUserAt: now.Add(-30 * 24 * time.Hour)}); err != nil { + t.Fatal(err) + } + if err := os.WriteFile((session.Place{Dir: dir}).Transcript(), []byte("{}\n"), 0600); err != nil { + t.Fatal(err) + } + session.SweepPlaces(root, now, nil) + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatalf("resolved temp citation no longer marks litter: %v", err) + } + +} diff --git a/internal/session/sweep.go b/internal/session/sweep.go index 3755721899..6052198a2c 100644 --- a/internal/session/sweep.go +++ b/internal/session/sweep.go @@ -61,6 +61,7 @@ import ( "github.com/Agent-Field/codeaf/internal/filelock" "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/workspacepath" ) const ( @@ -296,21 +297,7 @@ func sweepIsLitter(meta Meta, dir string, now time.Time) bool { // asked, because TMPDIR moves the first without making the second any less of a // temp directory to the person who typed it. func underTempDir(path string) bool { - path = strings.TrimSpace(path) - if path == "" { - return false - } - path = filepath.Clean(path) - for _, temp := range []string{os.TempDir(), "/tmp"} { - temp = filepath.Clean(strings.TrimSpace(temp)) - if temp == "" || temp == string(filepath.Separator) { - continue - } - if path == temp || strings.HasPrefix(path, temp+string(filepath.Separator)) { - return true - } - } - return false + return workspacepath.Temporary(path) } // reapSession removes one litter session, EVERY REGISTRATION FIRST. diff --git a/internal/workspacepath/path.go b/internal/workspacepath/path.go index 63a5e7f5f5..0464834724 100644 --- a/internal/workspacepath/path.go +++ b/internal/workspacepath/path.go @@ -98,3 +98,25 @@ func Aliases(workspace string, candidates ...string) []string { } return result } + +// Temporary classifies the path and roots using the same resolved spelling. +// TMPDIR may itself be a link, including /var -> /private/var on macOS. +func Temporary(path string) bool { + if strings.TrimSpace(path) == "" { + return false + } + path = Canonical(strings.TrimSpace(path)) + for _, root := range []string{os.TempDir(), "/tmp"} { + if strings.TrimSpace(root) == "" { + continue + } + root = Canonical(strings.TrimSpace(root)) + if root == string(filepath.Separator) { + continue + } + if path == root || strings.HasPrefix(path, root+string(filepath.Separator)) { + return true + } + } + return false +} From 36bab87a7b10c25efd04933468a9b09658a8994b Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 20 Oct 2026 13:32:49 -0400 Subject: [PATCH 21/24] docs: specify symlink upgrade preservation and its limits Describe owned launch citations, path-memory aliases, draft restoration, lifetime-lock handoff and normalized temporary provenance without promising a merge of historical buckets. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../unreleased/1798-symlink-workspace-keys.md | 46 +++++++++++-------- internal/manual/chat/sessions-and-rewind.md | 16 ++++++- .../manual/chat/staying-on-that-machine.md | 4 +- 3 files changed, 46 insertions(+), 20 deletions(-) diff --git a/docs/changes/unreleased/1798-symlink-workspace-keys.md b/docs/changes/unreleased/1798-symlink-workspace-keys.md index 112dd1db30..40a25abcc9 100644 --- a/docs/changes/unreleased/1798-symlink-workspace-keys.md +++ b/docs/changes/unreleased/1798-symlink-workspace-keys.md @@ -4,7 +4,7 @@ title: a folder reached through a symlink is one project and one engine, not two pr: 1798 surface: [engine, chat] invalidates: - - "A folder reached through a symlink became two projects, two conversation histories and two engines. `codeaf engine --status --workspace ` missed the engine holding ``, and home's projects panel listed the folder once per spelling. The key-minting doors stopped at filepath.Clean, so each spelling hashed to its own key. Every door that turns a path into a key now resolves symlinks first (filepath.EvalSymlinks, with the original spelling kept when the path does not resolve): engineWorkspace (the engine commands and the host's workspace name, including the default home), enginehost.where (the host directory every lookup, dial, spawn, lock and status is keyed by), v3Workspace (the session bucket and tools root), gitidentity's path fallback (the memory project key), and the typed /project pin. Both spellings of one folder reach one project, one conversation history and one engine." + - "A folder reached through a symlink became two projects, two conversation histories and two engines. `codeaf engine --status --workspace ` missed the engine holding ``, and home's projects panel listed the folder once per spelling. The key-minting doors stopped at filepath.Clean, so each spelling hashed to its own key. Every door that turns a path into a key now resolves symlinks first (filepath.EvalSymlinks, with the original spelling kept when the path does not resolve): engineWorkspace (the engine commands and the host's workspace name, including the default home), enginehost.where (the host directory every lookup, dial, spawn, lock and status is keyed by), v3Workspace (the session bucket and tools root), gitidentity's path fallback (the memory project key), and the typed /project pin. Both spellings share new workspace keys; existing records remain accessible through proven aliases, without merging historical buckets." --- Found in the v0.7.1 happy-path pass on macOS, where `/tmp` is a link to @@ -25,23 +25,33 @@ lookup, dial, spawn, lock and status cannot disagree; the default home in the typed `/project` pin in `internal/tui3`. A regression test drives a symlink through each layer and asserts both spellings land on one key. -THE UPGRADE KEEPS WHAT THE OLD KEY WROTE. A host directory and a conversation -bucket an older build named by the raw spelling are still this folder's: a -`Dial` that misses the canonical key looks for host directories whose recorded -workspace (`internal/enginehost`'s place file) resolves to the same folder, so -`--status`, `--stop`, `--status-all`, `--stop-all` and the takeover rule reach -an engine the upgrade did not start; and a launch whose canonical bucket holds -no spoken conversation resumes the newest folder in another bucket whose -`meta.workspace` citation resolves to it. No migration, no merge — the old -records are read where they live, and the canonical key takes over from there. +THE UPGRADE READS PROVEN OLD KEYS. Reads accept the canonical key, the incoming +raw spelling, and old spellings whose recorded workspace citations still resolve +to this folder. A launch whose canonical bucket has no spoken conversation can +resume a conversation in its old bucket. Owned conversations can also match their +recorded launch directory, preserving HOME and temporary-launch resumption and +owned posture. Hello follows the same order as boot; canonical spoken history +still wins, and historical conversation buckets are not combined or moved. -Retiring an alias-keyed host also waits for its original lock, not just the new -canonical lock: the listener can disappear while the old engine is still flushing -its journals. Upgrade regressions exercise status, stop, sweeps and idle/busy -takeover through both spellings. +Path-owned project memories remain visible and editable through their historical +owners, including non-Git folders and repositories without an origin. New rows +use the canonical owner. Opening a project does not silently re-own its rows; +explicit moves and corrections remain deliberate memory operations. Workers, +resumed conversations, anchoring and headless runs carry the same read aliases. +Remote-derived Git identity is unchanged. +Plain and structured drafts are discovered across proven alias prefixes. A +structured owner must still name the same machine and conversation; task text, +pasted documents and attachments come back with it. Live windows are excluded, +and the newest matching record wins even when it records a cleared composer. -A spoken alias-keyed conversation also outranks an abandoned empty canonical -folder during hello discovery, matching boot's resume order. Otherwise two plain -hellos could boot two agents onto the same legacy journal. A real-socket regression -asserts both hellos open that journal with one boot. +Attach, Run, startup probing and retirement share the host-directory inventory. +A held old lifetime lock excludes startup even before Listen or after the socket +closes. Canonical acquisition serializes the lock inventory, and Run reacquires +and holds every proven old lock throughout its life, including the spawn handoff. +Status, stop, sweeps and idle/busy takeover still reach ready old hosts. + +Temporary roots and paths resolve consistently when classifying launches and +sweeping abandoned sessions, so a TMPDIR link outside /tmp retains provenance. +Compatibility requires a valid incoming spelling or a live equivalent citation; +a missing or broken alias cannot recover a historical hash from its name alone. diff --git a/internal/manual/chat/sessions-and-rewind.md b/internal/manual/chat/sessions-and-rewind.md index cf0b3c6286..02745df1ea 100644 --- a/internal/manual/chat/sessions-and-rewind.md +++ b/internal/manual/chat/sessions-and-rewind.md @@ -943,10 +943,24 @@ A folder and a symlink pointing to it are one project. If an older build saved y conversation under the alias, launching from either spelling can still resume it where it lives. When the resolved folder has no conversation you have spoken in, codeaf looks for the most recently spoken conversation whose recorded workspace still resolves to -that folder. An abandoned empty conversation in the new location does not hide it, and +that folder. An owned conversation opened at home or in a temporary folder can also +match its recorded launch directory; it keeps its own private workspace. An abandoned empty conversation in the new location does not hide it, and another window joins the same conversation the engine is already holding. Existing spoken history in the resolved folder takes priority. This does not combine or move historical folders. A broken or removed alias cannot identify an old conversation through the resolved folder; `codeaf chat --session ` still opens a transcript you name explicitly. + + +## Saved drafts after a symlink workspace upgrade + +Unsent text saved under an older path spelling is still offered when that spelling +provably names this folder, from the launch path or saved workspace citations. The newest +orphaned draft is adopted; a draft held by a live window stays with that window. + +A structured draft restores its caret, pasted documents, attachment tray and task-page +text only to the same conversation on the same machine. Alias spellings of its workspace +and transcript are accepted when both still resolve to the same files. A newer record +that says the composer was cleared still wins over older unsent text. Restored content is +saved under the current key before the old record is pruned; unrelated slots stay on disk. diff --git a/internal/manual/chat/staying-on-that-machine.md b/internal/manual/chat/staying-on-that-machine.md index 3f3b39796c..b08ae1d458 100644 --- a/internal/manual/chat/staying-on-that-machine.md +++ b/internal/manual/chat/staying-on-that-machine.md @@ -448,7 +448,9 @@ directory is a symlink and you omit `--workspace`. An engine started by an older under the alias stays reachable through either spelling: `--status`, `--stop`, `--status-all` and `--stop-all` can still find it. Launching a newer build can replace that engine when it is idle; work in flight keeps running. A replacement waits for the -old engine to finish closing its journals as well as closing its socket. +old engine to finish closing its journals as well as closing its socket. A held old +lifetime lock also blocks startup before a socket exists. The replacement acquires the +canonical lock and all proven old locks together and holds them for its lifetime. Compatibility lookup needs the recorded alias to still resolve to the folder. A deleted or broken symlink cannot identify its old engine through the resolved folder. From 866521f34c37292c6fb0336df3c7ca6eb97ec96f Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 20 Oct 2026 13:38:23 -0400 Subject: [PATCH 22/24] fix(memory): preserve historical edits and raw engine launch provenance Keep the store graph owner invariant for corrections, with new independently remembered lines still written canonically. Carry a proven raw workspace through the existing local hello field so flat-session memory owners stay readable without metadata. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/chatv3_local.go | 12 +++++- cmd/codeaf/engine.go | 7 ++++ cmd/codeaf/review_memory_upgrade_test.go | 40 +++++++++++++++++++ .../unreleased/1798-symlink-workspace-keys.md | 4 +- internal/gitidentity/project.go | 1 + internal/manual/chat/what-i-remember.md | 2 +- internal/session/memory.go | 7 ---- internal/session/memory_upgrade_test.go | 22 ++++++++-- 8 files changed, 81 insertions(+), 14 deletions(-) diff --git a/cmd/codeaf/chatv3_local.go b/cmd/codeaf/chatv3_local.go index a56baa88ac..4fcd14289b 100644 --- a/cmd/codeaf/chatv3_local.go +++ b/cmd/codeaf/chatv3_local.go @@ -36,6 +36,7 @@ import ( teamstore "github.com/Agent-Field/codeaf/internal/teams" "github.com/Agent-Field/codeaf/internal/tui3" codeupdate "github.com/Agent-Field/codeaf/internal/update" + "github.com/Agent-Field/codeaf/internal/workspacepath" ) // localLaunch is one launch that goes through this machine's own session host. @@ -198,6 +199,11 @@ func openChatV3Local(launch localLaunch) error { Level: launch.level, Launch: launch.shape, } + // The host is dialed by the canonical key; the existing hello workspace + // field can retain a proven raw launch spelling for project-memory reads. + if raw := v3LaunchDir(); workspacepath.Same(raw, launch.workspace) { + hello.Workspace = raw + } client, err := remote.Roam("", hello, remote.Roaming{Dial: link.dial}) // A HOST REFUSAL NEVER PUSHES THE WINDOW IN-PROCESS. The engine answered, // and it answered that the conversation this launch asked for is held by @@ -671,7 +677,11 @@ func v3MachineIsSetUp() bool { // localBesideHello carries the ordinary terminal's settings to a sibling chat. func localBesideHello(ask engineAsk, launch localLaunch) remote.Hello { - return remote.Hello{Workspace: ask.workspace, Session: ask.session, New: ask.mint, Model: launch.model, Level: launch.level, Launch: launch.shape} + workspace := ask.workspace + if raw := v3LaunchDir(); workspacepath.Same(raw, workspace) { + workspace = raw + } + return remote.Hello{Workspace: workspace, Session: ask.session, New: ask.mint, Model: launch.model, Level: launch.level, Launch: launch.shape} } // localErrandDoor is [tui3.Options.Errand] on the engine road: home's `ask here`, diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go index 4c70af202c..7a4b19c86c 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -58,6 +58,7 @@ import ( "github.com/Agent-Field/codeaf/internal/remote" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/store" + "github.com/Agent-Field/codeaf/internal/workspacepath" ) // A session agent is what the wire serves, and this is where the two are held @@ -1275,6 +1276,12 @@ func engineLaunchOptions(hello remote.Hello, workspace, sessionFlag string) v3Op Model: strings.TrimSpace(hello.Model), Session: firstEngineWord(hello.Session, sessionFlag), } + // Keep the incoming spelling as read provenance. openV3Launch still resolves + // its write key, while memory can include the pre-upgrade path owner even + // when a flat transcript left no workspace citation. + if workspacepath.Same(hello.Workspace, workspace) { + opts.Workspace = hello.Workspace + } // A HELLO MINTING ITS OWN CONVERSATION NAMES NO TRANSCRIPT, and the host's // --session flag is not an answer for it either: that flag says which // conversation this daemon opens for a hello that did not choose, and this diff --git a/cmd/codeaf/review_memory_upgrade_test.go b/cmd/codeaf/review_memory_upgrade_test.go index c114c9adf7..a98c458022 100644 --- a/cmd/codeaf/review_memory_upgrade_test.go +++ b/cmd/codeaf/review_memory_upgrade_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/Agent-Field/codeaf/internal/remote" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/store" ) @@ -82,3 +83,42 @@ func TestReviewResumedConversationKeepsPathMemory(t *testing.T) { func homeProjectForReview(workspace string) string { return filepath.Join(os.Getenv("CODEAF_HOME"), "v3", "projects", encodeWorkspace(workspace)) } + +func TestReviewEngineLaunchKeepsRawPathMemoryWithoutCitations(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + target := t.TempDir() + alias := filepath.Join(t.TempDir(), "project") + if err := os.Symlink(target, alias); err != nil { + t.Fatal(err) + } + workspace, err := engineWorkspace(alias) + if err != nil { + t.Fatal(err) + } + opts := engineLaunchOptions(remote.Hello{Workspace: alias}, workspace, "") + sum := sha256.Sum256([]byte(filepath.Clean(alias))) + brain, err := store.Open(filepath.Join(t.TempDir(), "brain.db")) + if err != nil { + t.Fatal(err) + } + defer brain.Close() + saved, err := brain.Write(store.WriteRequest{Owner: store.OwnerProject(hex.EncodeToString(sum[:16])), Text: "Keep the flat session's project contract"}) + if err != nil { + t.Fatal(err) + } + cfg, err := v3PointAt(session.Config{Workspace: opts.Workspace, Memory: brain, Model: "test/model", APIKey: "unused", BaseURL: "http://127.0.0.1:1", ProfileDir: t.TempDir()}, session.Place{Workspace: workspace}) + if err != nil { + t.Fatal(err) + } + // A flat legacy journal has no Place directory or metadata to scan. + cfg.SessionFile = "" + agent, err := session.New(cfg) + if err != nil { + t.Fatal(err) + } + defer agent.Close() + rows, err := agent.Memories("") + if err != nil || len(rows) != 1 || rows[0].ID != saved.Memory.ID { + t.Fatalf("engine's raw launch memory: %+v (%v)", rows, err) + } +} diff --git a/docs/changes/unreleased/1798-symlink-workspace-keys.md b/docs/changes/unreleased/1798-symlink-workspace-keys.md index 40a25abcc9..64b3fddad7 100644 --- a/docs/changes/unreleased/1798-symlink-workspace-keys.md +++ b/docs/changes/unreleased/1798-symlink-workspace-keys.md @@ -34,8 +34,8 @@ owned posture. Hello follows the same order as boot; canonical spoken history still wins, and historical conversation buckets are not combined or moved. Path-owned project memories remain visible and editable through their historical -owners, including non-Git folders and repositories without an origin. New rows -use the canonical owner. Opening a project does not silently re-own its rows; +owners, including non-Git folders and repositories without an origin. Newly +remembered lines use the canonical owner; edits preserve their historical owner. Opening a project does not silently re-own its rows; explicit moves and corrections remain deliberate memory operations. Workers, resumed conversations, anchoring and headless runs carry the same read aliases. Remote-derived Git identity is unchanged. diff --git a/internal/gitidentity/project.go b/internal/gitidentity/project.go index 8f13e3bc58..b0580869ef 100644 --- a/internal/gitidentity/project.go +++ b/internal/gitidentity/project.go @@ -58,6 +58,7 @@ func ProjectKey(workspace string) (string, error) { // ProjectKeys names the canonical write key followed by proven historical path // owners. Remote identities have never depended on a spelling and need no aliases. func ProjectKeys(workspace string, candidates ...string) []string { + workspace = strings.TrimSpace(workspace) key, _ := ProjectKey(workspace) if key == "" { return nil diff --git a/internal/manual/chat/what-i-remember.md b/internal/manual/chat/what-i-remember.md index d5a8457740..5606619b8e 100644 --- a/internal/manual/chat/what-i-remember.md +++ b/internal/manual/chat/what-i-remember.md @@ -581,6 +581,6 @@ old spelling still names the same folder: from the launch path or a saved conver or engine workspace citation. This also covers Git repositories without an origin. Existing rows keep their owners until you deliberately change them. New project memories -use the resolved folder's owner; a correction supersedes the old line under that owner. +use the resolved folder's owner. Editing an existing line preserves its historical owner. There is no bulk move or deletion of old memories. Remote-based Git identities keep the same keys. A removed or broken alias cannot prove that an old path owner belongs here. diff --git a/internal/session/memory.go b/internal/session/memory.go index 898ee0ceed..516a822054 100644 --- a/internal/session/memory.go +++ b/internal/session/memory.go @@ -543,13 +543,6 @@ func (a *Agent) keepLine(text, scope, origin string, replaced *store.Memory, unb // that said "this corrects line 3" and named a different scope word has // still named line 3. owner, widened = replaced.Owner, false - key, _, _ := a.projectKey() - for _, allowed := range a.memoryOwners()[2:] { - if replaced.Owner == allowed { - owner = store.OwnerProject(key) - break - } - } } _, projectName, _ := a.projectKey() reach := memoryReach(owner, widened) diff --git a/internal/session/memory_upgrade_test.go b/internal/session/memory_upgrade_test.go index 6b44f36b0f..4ce9fa5e03 100644 --- a/internal/session/memory_upgrade_test.go +++ b/internal/session/memory_upgrade_test.go @@ -39,12 +39,28 @@ func TestReviewPathMemoryAliasesPermitScopedEdits(t *testing.T) { if !strings.Contains(block, saved.Memory.Text) || strings.Contains(block, "unrelated") { t.Fatalf("project memory block: %q", block) } - // A correction creates its replacement under today's write key. The old row - // is superseded through the owner's permission list, never silently moved. + // Editing a historical line retains its owner, preserving the store graph. + // A new independently remembered line uses today's canonical write key. fresh, _, _, err := agent.keepLine("Use the current client contract", "project", store.OriginPerson, &saved.Memory, false) - if err != nil || fresh.Owner != store.OwnerProject(key) { + if err != nil || fresh.Owner != oldOwner { t.Fatalf("scoped correction: %+v (%v)", fresh, err) } + if _, err := agent.Remember("Keep fresh project notes"); err != nil { + t.Fatal(err) + } + current, err := brain.MemoriesFor([]string{store.OwnerProject(key)}) + if err != nil { + t.Fatal(err) + } + found := false + for _, row := range current { + if row.Text == "Keep fresh project notes" { + found = true + } + } + if !found { + t.Fatal("new line was not kept under the canonical owner") + } if receipt, err := agent.forgetLine("current client contract"); err != nil || !strings.HasPrefix(receipt, "forgot") { t.Fatalf("scoped forget: %s (%v)", receipt, err) } From 3c04e2511779aef7cd38b5abea201dd15ba5d6b0 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 20 Oct 2026 13:38:23 -0400 Subject: [PATCH 23/24] fix(drafts): carry raw launch aliases through conversation switches Retain validated launch spellings in the surface even when a flat session has no metadata citation. Propagate that read provenance with each composer and clear it alongside draft storage on takeover. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/chatv3.go | 2 ++ cmd/codeaf/chatv3_local.go | 1 + cmd/codeaf/chatv3_process.go | 2 ++ internal/tui3/app.go | 5 ++++- internal/tui3/detach.go | 1 + internal/tui3/draft.go | 2 +- internal/tui3/draft_launch_alias_test.go | 27 ++++++++++++++++++++++++ internal/tui3/keeper.go | 1 + internal/tui3/takeover.go | 2 ++ internal/tui3/tui3.go | 6 ++++++ internal/tui3/welcome.go | 2 +- 11 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 internal/tui3/draft_launch_alias_test.go diff --git a/cmd/codeaf/chatv3.go b/cmd/codeaf/chatv3.go index e9e46ab500..5f3c23bbfb 100644 --- a/cmd/codeaf/chatv3.go +++ b/cmd/codeaf/chatv3.go @@ -37,6 +37,7 @@ import ( "github.com/Agent-Field/codeaf/internal/trace" "github.com/Agent-Field/codeaf/internal/tui3" codeupdate "github.com/Agent-Field/codeaf/internal/update" + "github.com/Agent-Field/codeaf/internal/workspacepath" ) // runChatV3 is the v3 door: one session agent over this directory, and the @@ -675,6 +676,7 @@ func openChatV3(name string, args []string, pickSession bool) error { ContextWindow: cfg.ContextWindow, History: recall, DraftFile: draft, + DraftAliases: workspacepath.Aliases(workspace, proc.LaunchDir, seed.Workspace), // The consent card's "always", written down AND handed to the gate this // session is running on (chatv3_approval.go). The second half is why a // banked rule answers the very next call instead of the next launch. diff --git a/cmd/codeaf/chatv3_local.go b/cmd/codeaf/chatv3_local.go index 4fcd14289b..dc5132a1f9 100644 --- a/cmd/codeaf/chatv3_local.go +++ b/cmd/codeaf/chatv3_local.go @@ -377,6 +377,7 @@ func openChatV3Local(launch localLaunch) error { // hide their own unsent words from them. if dir, err := v3Dir(); err == nil { options.DraftFile = tui3.DraftFile(dir, welcome.Workspace) + options.DraftAliases = workspacepath.Aliases(welcome.Workspace, v3LaunchDir()) } // 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 6c0abe5f63..b40adfc13d 100644 --- a/cmd/codeaf/chatv3_process.go +++ b/cmd/codeaf/chatv3_process.go @@ -47,6 +47,7 @@ import ( "github.com/Agent-Field/codeaf/internal/store" "github.com/Agent-Field/codeaf/internal/subharness" "github.com/Agent-Field/codeaf/internal/tui3" + "github.com/Agent-Field/codeaf/internal/workspacepath" ) // v3Process is everything a launch may borrow but must not build twice. @@ -946,6 +947,7 @@ func (s *v3Seam) bundle(agent v3Live, launch *v3Launch, cfg session.Config, Notice: notice, ContextWindow: cfg.ContextWindow, DraftFile: draft, + DraftAliases: workspacepath.Aliases(workspace, s.proc.LaunchDir, s.seed.Workspace), History: recall, // This project's conversations, walked on the keystroke that asks for // them and never at boot — and this PROJECT's, which is what makes the diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 3384e04d88..d8193dbf68 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -2242,6 +2242,7 @@ type app struct { // draftFile is where the unsent sentence is kept between sessions // (draft.go); empty means it is not kept at all. draftFile string + draftAliases []string draftPending bool // artifacts is the deliverables index a finished /export records itself in // (export.go). Empty is the door saying nothing, which [app.artifactsIndex] @@ -2994,6 +2995,7 @@ func newApp(ctx context.Context, opts Options) *app { providerFetchError: opts.ProviderFetchError, history: opts.History, draftFile: opts.DraftFile, + draftAliases: opts.DraftAliases, artifacts: opts.ArtifactsIndex, ctxWindow: opts.ContextWindow, profileDir: opts.ProfileDir, @@ -8354,6 +8356,7 @@ func (a *app) takeUp(conv Conversation, whole bool) { // be keeping what somebody's repository asked us not to keep (draft.go, and // the door's per-workspace read of the same two rows). a.draftFile = conv.DraftFile + a.draftAliases = conv.DraftAliases a.history = conv.History a.saveApproval = conv.SaveApproval a.saveBashApproval = conv.SaveBashApproval @@ -8513,7 +8516,7 @@ func (a *app) finishRenew(conv Conversation, whole, replacing bool) tea.Cmd { // 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, - ContextWindow: a.ctxWindow, DraftFile: a.draftFile, History: a.history, + ContextWindow: a.ctxWindow, DraftFile: a.draftFile, DraftAliases: a.draftAliases, History: a.history, RecentSessions: a.recentSessions, SaveApproval: a.saveApproval, SaveBashApproval: a.saveBashApproval, ApplyApprovals: a.applyApprovals} } diff --git a/internal/tui3/detach.go b/internal/tui3/detach.go index 9cc17277e0..70b7ebaf92 100644 --- a/internal/tui3/detach.go +++ b/internal/tui3/detach.go @@ -243,6 +243,7 @@ func (a *app) front() Conversation { Owned: a.owned, ContextWindow: a.ctxWindow, DraftFile: a.draftFile, + DraftAliases: a.draftAliases, History: a.history, RecentSessions: a.recentSessions, SaveApproval: a.saveApproval, diff --git a/internal/tui3/draft.go b/internal/tui3/draft.go index cab64f9161..adbdb14615 100644 --- a/internal/tui3/draft.go +++ b/internal/tui3/draft.go @@ -482,5 +482,5 @@ func (a *app) draftWorkspaces() []string { if a.host != "" { return []string{a.workspace} } - return workspacepath.Aliases(a.workspace) + return workspacepath.Aliases(a.workspace, a.draftAliases...) } diff --git a/internal/tui3/draft_launch_alias_test.go b/internal/tui3/draft_launch_alias_test.go new file mode 100644 index 0000000000..a3bbcbc714 --- /dev/null +++ b/internal/tui3/draft_launch_alias_test.go @@ -0,0 +1,27 @@ +package tui3 + +import ( + "os" + "path/filepath" + "testing" +) + +// Flat --session launches may have no meta.json or old engine citation. The +// raw launch spelling must travel with the composer even after normalization. +func TestReviewRawLaunchKeepsDraftWithoutCitations(t *testing.T) { + workspace, alias, old, transcript := reviewDraftUpgrade(t) + if err := os.RemoveAll(filepath.Join(os.Getenv("CODEAF_HOME"), "v3")); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(old, []byte("a flat session's unfinished sentence"), 0600); err != nil { + t.Fatal(err) + } + next, _, _ := roomApp(t) + next.workspace, next.file = workspace, transcript + next.draftFile = DraftFile(filepath.Dir(old), workspace) + next.draftAliases = []string{alias} + next.restoreDraft() + if got := next.input.String(); got != "a flat session's unfinished sentence" { + t.Fatalf("raw launch draft: %q", got) + } +} diff --git a/internal/tui3/keeper.go b/internal/tui3/keeper.go index d03d5ae326..bca187d082 100644 --- a/internal/tui3/keeper.go +++ b/internal/tui3/keeper.go @@ -1169,6 +1169,7 @@ func (a *app) takeBeside(conv Conversation) tea.Cmd { // belongs to this window, with separate owner-scoped slots inside it. if conv.DraftFile == "" { conv.DraftFile = leaving.DraftFile + conv.DraftAliases = leaving.DraftAliases } if conv.History == nil { conv.History = leaving.History diff --git a/internal/tui3/takeover.go b/internal/tui3/takeover.go index 1eb9e90846..1ac306f1ba 100644 --- a/internal/tui3/takeover.go +++ b/internal/tui3/takeover.go @@ -595,6 +595,7 @@ func (a *app) takeOver() tea.Cmd { // Cleared so the close below leaves the files where they are; the // conversation arriving brings its own draft file with it. a.draftFile = "" + a.draftAliases = nil } cmd, moved := a.closeFrontFor(session.StopByTakeover) if !moved { @@ -806,6 +807,7 @@ func (a *app) movedAway(word string) tea.Cmd { // The whole composer, exactly as the takeover road hands it over. a.writeDraftsNow(a.leavingDraft()) a.draftFile = "" + a.draftAliases = nil } cmd, landed := a.stepBackFront() if !landed { diff --git a/internal/tui3/tui3.go b/internal/tui3/tui3.go index 173af01c89..58045ef1de 100644 --- a/internal/tui3/tui3.go +++ b/internal/tui3/tui3.go @@ -257,6 +257,9 @@ type Conversation struct { // is the project saying keep nothing — which is already how the surface // spells "not kept at all" (draft.go). DraftFile string + // DraftAliases carries raw launch spellings for compatibility reads. Each is + // accepted only while it still resolves to this conversation's workspace. + DraftAliases []string // History is the recall list this conversation's up arrow walks, and nil is // this WORKSPACE keeping none. It is the store the door opened once per // process rather than one per conversation: the file is one file and the @@ -804,6 +807,9 @@ type Options struct { // DraftFile is where the unsent draft is kept between sessions (draft.go). // Empty keeps it nowhere. Use [DraftFile] to name it. DraftFile string + // DraftAliases carries raw launch spellings for compatibility reads. Each is + // accepted only while it still resolves to this conversation's workspace. + DraftAliases []string // ArtifactsIndex is the deliverables index a finished /export writes its row // to (internal/session's artifacts.go). Empty falls through to diff --git a/internal/tui3/welcome.go b/internal/tui3/welcome.go index c3107d8362..c13b2ea0a6 100644 --- a/internal/tui3/welcome.go +++ b/internal/tui3/welcome.go @@ -538,7 +538,7 @@ func (a *app) openSession(chosen Session) (tea.Cmd, string) { // ([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, - ContextWindow: a.ctxWindow, DraftFile: a.draftFile, History: a.history, + ContextWindow: a.ctxWindow, DraftFile: a.draftFile, DraftAliases: a.draftAliases, History: a.history, RecentSessions: a.recentSessions, SaveApproval: a.saveApproval, SaveBashApproval: a.saveBashApproval, ApplyApprovals: a.applyApprovals} } From de11a6b19ad9d48a269ad6471fc9bb2ddffde46d Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 20 Oct 2026 13:54:43 -0400 Subject: [PATCH 24/24] test: resolve temporary fixture roots before alias assertions Use a physical target in the citation-filtering fixtures so macOS temp-root symlinks do not introduce an extra valid incoming alias into their expected sets. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/workspacepath/path_test.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/workspacepath/path_test.go b/internal/workspacepath/path_test.go index a269e4b2cc..7a536d9fa8 100644 --- a/internal/workspacepath/path_test.go +++ b/internal/workspacepath/path_test.go @@ -11,7 +11,10 @@ import ( func TestAliasesRequireLiveEquivalentCitations(t *testing.T) { t.Setenv("CODEAF_HOME", t.TempDir()) - target := t.TempDir() + target, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } alias := filepath.Join(t.TempDir(), "project") if err := os.Symlink(target, alias); err != nil { t.Fatal(err) @@ -40,7 +43,10 @@ func TestAliasesRequireLiveEquivalentCitations(t *testing.T) { func TestAliasLookupDoesNotFollowStateDirectoryLinks(t *testing.T) { t.Setenv("CODEAF_HOME", t.TempDir()) - target := t.TempDir() + target, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } alias := filepath.Join(t.TempDir(), "project") if err := os.Symlink(target, alias); err != nil { t.Fatal(err)