Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
7bce0e2
fix(workspace): name a workspace by its resolved path
ningmao-hlyz Oct 9, 2026
fa7d438
changelog: a folder reached through a symlink is one project and one …
ningmao-hlyz Oct 9, 2026
cb09518
Merge branch 'dev' into fix/1761-symlink-workspace-keys
ZeroPoint95 Oct 13, 2026
4f8cd37
enginehost: name a host by the folder, not a spelling of it
ningmao-hlyz Oct 13, 2026
803cdd5
engine: the default workspace is the same folder as the named one
ningmao-hlyz Oct 13, 2026
d26cae9
tui3: the pin regression for a typed /project through a symlink
ningmao-hlyz Oct 13, 2026
7b8b378
chat: the project key is named by the folder, not a spelling of it
ningmao-hlyz Oct 13, 2026
c0b1d9a
gitidentity: the path fallback is the resolved path
ningmao-hlyz Oct 13, 2026
ac09e71
changelog: the symlink fix now names every door that mints a key
ningmao-hlyz Oct 13, 2026
16643c4
Merge branch 'dev' into fix/1761-symlink-workspace-keys
ZeroPoint95 Oct 14, 2026
aecfbf8
enginehost: the host an older build wrote for this folder is still it…
ningmao-hlyz Oct 14, 2026
f996066
chat: conversations an older build filed under another spelling still…
ningmao-hlyz Oct 14, 2026
ab9dfb6
tests: the fixtures key by the spelling the current build writes
ningmao-hlyz Oct 14, 2026
d214013
changelog: the upgrade keeps what the old key wrote
ningmao-hlyz Oct 14, 2026
e66c564
Merge branch 'dev' into fix/1761-symlink-workspace-keys
ZeroPoint95 Oct 15, 2026
92c8138
Merge dev into fix/1761-symlink-workspace-keys
AbirAbbas Oct 20, 2026
73262a5
fix(enginehost): wait for alias-keyed hosts to release their locks
AbirAbbas Oct 20, 2026
58b3a98
fix(chat): resolve legacy hello keys before abandoned empty sessions
AbirAbbas Oct 20, 2026
cb479c9
Merge branch 'dev' into fix/1761-symlink-workspace-keys
ningmao-hlyz Oct 20, 2026
df94b0d
Merge current dev into fix-1798
AbirAbbas Oct 20, 2026
79b8171
fix(chat): resume owned legacy sessions by launch citation
AbirAbbas Oct 20, 2026
1d68c63
fix(enginehost): acquire canonical and proven legacy lifetime locks t…
AbirAbbas Oct 20, 2026
22300f4
fix(chat): retain workspace matching alongside owned launch citations
AbirAbbas Oct 20, 2026
4ae9c12
fix(memory): retain proven path-owner aliases in project scope
AbirAbbas Oct 20, 2026
9feab72
fix(drafts): restore proven alias prefixes and structured owners
AbirAbbas Oct 20, 2026
cf974db
fix(sessions): normalize temporary roots and launch provenance together
AbirAbbas Oct 20, 2026
36bab87
docs: specify symlink upgrade preservation and its limits
AbirAbbas Oct 20, 2026
866521f
fix(memory): preserve historical edits and raw engine launch provenance
AbirAbbas Oct 20, 2026
3c04e25
fix(drafts): carry raw launch aliases through conversation switches
AbirAbbas Oct 20, 2026
de11a6b
test: resolve temporary fixture roots before alias assertions
AbirAbbas Oct 20, 2026
b711b54
Merge current dev into fix/1761-symlink-workspace-keys
AbirAbbas Oct 20, 2026
f0c5726
Merge the author's dev sync into the takeover branch
AbirAbbas Oct 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions cmd/codeaf/chatv3.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1050,8 +1052,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
Expand Down
152 changes: 124 additions & 28 deletions cmd/codeaf/chatv3_layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down Expand Up @@ -83,14 +84,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 cwd, v3NoProjectPlace(cwd)
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 dir
}

// v3NoProjectPlace reports whether a directory is somewhere the person stood by
Expand All @@ -106,17 +125,10 @@ func v3Workspace(cwd, explicit string) (string, bool) {
// 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.
Expand Down Expand Up @@ -277,6 +289,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
Expand All @@ -285,6 +310,72 @@ 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], 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
// 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 {
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 {
citations = append(citations, folder.meta.LaunchDir)
}
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
}
}
if !matched {
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.
Expand Down Expand Up @@ -325,7 +416,16 @@ func v3LatestTranscript(workspace string) string {
return ""
}
spoken, empty := v3ScanBucket(bucket)
folder, _, found := v3PickFolder(spoken, empty, false)
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]). 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 ""
}
Expand Down Expand Up @@ -393,6 +493,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 {
Expand All @@ -404,6 +506,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
}
Expand All @@ -419,6 +525,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
}
Expand Down Expand Up @@ -567,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 ────────────────────────────────────────────
Expand Down
Loading
Loading